Passed
Push — 2.x ( a6588e...513289 )
by Ramon
09:37
created

senaite.core.api.dtime   F

Complexity

Total Complexity 106

Size/Duplication

Total Lines 653
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 106
eloc 312
dl 0
loc 653
rs 2
c 0
b 0
f 0

27 Functions

Rating   Name   Duplication   Size   Complexity  
A to_zone() 0 19 5
A from_timestamp() 0 7 1
A is_dt() 0 7 1
A to_timestamp() 0 15 4
B to_dt() 0 22 6
A to_iso_format() 0 11 4
A is_d() 0 7 1
A is_timezone_naive() 0 16 5
A to_msgstr() 0 11 1
A is_DT() 0 7 1
A get_tzinfo() 0 21 4
A ansi_to_dt() 0 18 4
B get_os_timezone() 0 22 6
B get_relative_delta() 0 36 6
A is_valid_timezone() 0 11 2
B to_ansi() 0 42 6
A is_date() 0 16 5
A to_timedelta() 0 26 4
A timedelta_to_dict() 0 32 3
B get_timezone() 0 39 8
A now() 0 7 1
B date_to_string() 0 51 6
A is_timezone_aware() 0 7 1
C to_DT() 0 40 10
A to_C1989() 0 4 1
A is_str() 0 7 1
C to_localized_time() 0 47 9

How to fix   Complexity   

Complexity

Complex classes like senaite.core.api.dtime often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
# -*- coding: utf-8 -*-
2
#
3
# This file is part of SENAITE.CORE.
4
#
5
# SENAITE.CORE is free software: you can redistribute it and/or modify it under
6
# the terms of the GNU General Public License as published by the Free Software
7
# Foundation, version 2.
8
#
9
# This program is distributed in the hope that it will be useful, but WITHOUT
10
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
12
# details.
13
#
14
# You should have received a copy of the GNU General Public License along with
15
# this program; if not, write to the Free Software Foundation, Inc., 51
16
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17
#
18
# Copyright 2018-2025 by it's authors.
19
# Some rights reserved, see README and LICENSE.
20
21
import os
22
import re
23
import time
24
from datetime import date
25
from datetime import datetime
26
from datetime import timedelta
27
from dateutil.relativedelta import relativedelta
28
from string import Template
29
30
import six
31
32
import pytz
33
from bika.lims import logger
34
from bika.lims.api import APIError
35
from bika.lims.api import get_tool
36
from bika.lims.api import to_int
37
from DateTime import DateTime
38
from DateTime.DateTime import DateError
39
from DateTime.DateTime import DateTimeError
40
from DateTime.DateTime import SyntaxError
41
from DateTime.DateTime import TimeError
42
from senaite.core.i18n import get_month_name
43
from senaite.core.i18n import get_weekday_name
44
from zope.i18n import translate
45
46
47
RX_GMT = r"^(\bGMT\b|)([+-]?)(\d{1,2})"
48
49
_marker = object()
50
51
52
def is_str(obj):
53
    """Check if the given object is a string
54
55
    :param obj: arbitrary object
56
    :returns: True when the object is a string
57
    """
58
    return isinstance(obj, six.string_types)
59
60
61
def is_d(dt):
62
    """Check if the date is a Python `date` object
63
64
    :param dt: date to check
65
    :returns: True when the date is a Python `date`
66
    """
67
    return type(dt) is date
68
69
70
def is_dt(dt):
71
    """Check if the date is a Python `datetime` object
72
73
    :param dt: date to check
74
    :returns: True when the date is a Python `datetime`
75
    """
76
    return type(dt) is datetime
77
78
79
def is_DT(dt):
80
    """Check if the date is a Zope `DateTime` object
81
82
    :param dt: object to check
83
    :returns: True when the object is a Zope `DateTime`
84
    """
85
    return type(dt) is DateTime
86
87
88
def is_date(dt):
89
    """Check if the date is a datetime or DateTime object
90
91
    :param dt: date to check
92
    :returns: True when the object is either a datetime or DateTime
93
    """
94
    if is_str(dt):
95
        DT = to_DT(dt)
96
        return is_date(DT)
97
    if is_d(dt):
98
        return True
99
    if is_dt(dt):
100
        return True
101
    if is_DT(dt):
102
        return True
103
    return False
104
105
106
def is_timezone_naive(dt):
107
    """Check if the date is timezone naive
108
109
    :param dt: date to check
110
    :returns: True when the date has no timezone
111
    """
112
    if is_d(dt):
113
        return True
114
    elif is_DT(dt):
115
        return dt.timezoneNaive()
116
    elif is_dt(dt):
117
        return dt.tzinfo is None
118
    elif is_str(dt):
119
        DT = to_DT(dt)
120
        return is_timezone_naive(DT)
121
    raise APIError("Expected a date type, got '%r'" % type(dt))
122
123
124
def is_timezone_aware(dt):
125
    """Check if the date is timezone aware
126
127
    :param dt: date to check
128
    :returns: True when the date has a timezone
129
    """
130
    return not is_timezone_naive(dt)
131
132
133
def to_DT(dt):
134
    """Convert to DateTime
135
136
    :param dt: DateTime/datetime/date
137
    :returns: DateTime object
138
    """
139
    INTERNATIONAL_FMT = re.compile(
140
        r"^\s*(3[01]|[12][0-9]|0?[1-9])\.(1[012]|0?[1-9])\.(\d{2,4})\s*"
141
    )
142
    if is_DT(dt):
143
        return dt
144
    elif is_str(dt):
145
        kwargs = {}
146
        if re.match(INTERNATIONAL_FMT, dt):
147
            # This will fail silently and you get a wrong date:
148
            # dt = DateTime("02.07.2010") # Parses like US date 02/07/2010
149
            # https://github.com/zopefoundation/DateTime/blob/master/src/DateTime/DateTime.py#L641-L645
150
            kwargs["datefmt"] = "international"
151
        try:
152
            return DateTime(dt, **kwargs)
153
        except (DateError, DateTimeError, TimeError):
154
            try:
155
                dt = ansi_to_dt(dt)
156
                return to_DT(dt)
157
            except ValueError:
158
                return None
159
        except (SyntaxError, IndexError):
160
            return None
161
    elif is_dt(dt):
162
        try:
163
            # We do this because isoformat() comes with the +/- utc offset at
164
            # the end, so it becomes easier than having to rely on tzinfo stuff
165
            return DateTime(dt.isoformat())
166
        except DateTimeError:
167
            return DateTime(dt)
168
    elif is_d(dt):
169
        dt = datetime(dt.year, dt.month, dt.day)
170
        return DateTime(dt.isoformat())
171
    else:
172
        return None
173
174
175
def to_dt(dt):
176
    """Convert to datetime
177
178
    :param dt: DateTime/datetime/date
179
    :returns: datetime object
180
    """
181
    if is_DT(dt):
182
        # get a valid pytz timezone
183
        tz = get_timezone(dt)
184
        dt = dt.asdatetime()
185
        if is_valid_timezone(tz):
186
            dt = to_zone(dt, tz)
187
        return dt
188
    elif is_str(dt):
189
        DT = to_DT(dt)
190
        return to_dt(DT)
191
    elif is_dt(dt):
192
        return dt
193
    elif is_d(dt):
194
        return datetime(dt.year, dt.month, dt.day)
195
    else:
196
        return None
197
198
199
def now():
200
    """Returns a timezone-aware datetime representing current date and time
201
    as defined in Zope's TZ environment variable or, if not set, from OS
202
203
    :returns: timezone-aware datetime object
204
    """
205
    return to_dt(DateTime())
206
207
208
def ansi_to_dt(dt):
209
    """The YYYYMMDD format is defined by ANSI X3.30. Therefore, 2 December 1,
210
    1989 would be represented as 19891201. When times are transmitted, they
211
    shall be represented as HHMMSS, and shall be linked to dates as specified
212
    by ANSI X3.43.3 Date and time together shall be specified as up to a
213
    14-character string: YYYYMMDD[HHMMSS]
214
    :param str:
215
    :return: datetime object
216
    """
217
    if not is_str(dt):
218
        raise TypeError("Type is not supported")
219
    if len(dt) == 8:
220
        date_format = "%Y%m%d"
221
    elif len(dt) == 14:
222
        date_format = "%Y%m%d%H%M%S"
223
    else:
224
        raise ValueError("No ANSI format date")
225
    return datetime.strptime(dt, date_format)
226
227
228
def to_ansi(dt, show_time=True, timezone=None):
229
    """Returns the date in ANSI X3.30/X4.43.3) format
230
    :param dt: DateTime/datetime/date
231
    :param show_time: if true, returns YYYYMMDDHHMMSS. YYYYMMDD otherwise
232
    :param timezone: if set, converts the date to the given timezone
233
    :returns: str that represents the datetime in ANSI format
234
    """
235
    dt = to_dt(dt)
236
    if dt is None:
237
        return None
238
239
    if timezone and is_valid_timezone(timezone):
240
        if is_timezone_naive(dt):
241
            # XXX Localize to default TZ to overcome `to_dt` inconsistency:
242
            #
243
            # - if the input value is a datetime/date type, `to_dt` does not
244
            #   convert to the OS's zone, even if the value is tz-naive.
245
            # - if the input value is a str or DateTime, `to_dt` does  convert
246
            #   to the system default zone.
247
            #
248
            # For instance:
249
            # >>> to_dt("19891201131405")
250
            # datetime.datetime(1989, 12, 1, 13, 14, 5, tzinfo=<StaticTzInfo 'Etc/GMT'>)
251
            # >>> ansi_to_dt("19891201131405")
252
            # datetime.datetime(1989, 12, 1, 13, 14, 5)
253
            #
254
            # That causes the following inconsistency:
255
            # >>> to_ansi(to_dt("19891201131405"), timezone="Pacific/Fiji")
256
            # 19891202011405
257
            # >>> to_ansi(ansi_to_dt("19891201131405"), timezone="Pacific/Fiji")
258
            # 19891201131405
259
260
            default_tz = get_timezone(dt)
261
            default_zone = pytz.timezone(default_tz)
262
            dt = default_zone.localize(dt)
263
264
        dt = to_zone(dt, timezone)
265
266
    ansi = "{:04d}{:02d}{:02d}".format(dt.year, dt.month, dt.day)
267
    if not show_time:
268
        return ansi
269
    return "{}{:02d}{:02d}{:02d}".format(ansi, dt.hour, dt.minute, dt.second)
270
271
272
def get_timezone(dt, default="Etc/GMT"):
273
    """Get a valid pytz timezone name of the datetime object
274
275
    :param dt: date object
276
    :returns: timezone as string, e.g. Etc/GMT or CET
277
    """
278
    tz = None
279
    if is_dt(dt):
280
        tz = dt.tzname()
281
    elif is_DT(dt):
282
        tz = dt.timezone()
283
    elif is_d(dt):
284
        tz = default
285
286
    if tz:
287
        # convert DateTime `GMT` to `Etc/GMT` timezones
288
        # NOTE: `GMT+1` get `Etc/GMT-1`!
289
        #
290
        # The special area of "Etc" is used for some administrative zones,
291
        # particularly for "Etc/UTC" which represents Coordinated Universal
292
        # Time. In order to conform with the POSIX style, those zone names
293
        # beginning with "Etc/GMT" have their sign reversed from the standard
294
        # ISO 8601 convention. In the "Etc" area, zones west of GMT have a
295
        # positive sign and those east have a negative sign in their name (e.g
296
        # "Etc/GMT-14" is 14 hours ahead of GMT).
297
        # --- From https://en.wikipedia.org/wiki/Tz_database#Area
298
        match = re.match(RX_GMT, tz)
299
        if match:
300
            groups = match.groups()
301
            hours = to_int(groups[2], 0)
302
            if not hours:
303
                return "Etc/GMT"
304
305
            offset = "-" if groups[1] == "+" else "+"
306
            tz = "Etc/GMT%s%s" % (offset, hours)
307
    else:
308
        tz = default
309
310
    return tz
311
312
313
def get_tzinfo(dt_tz, default=pytz.UTC):
314
    """Returns the valid pytz tinfo from the date or timezone name
315
316
    Returns the default timezone info if date does not have a valid timezone
317
    set or is TZ-naive
318
319
    :param dt: timezone name or date object to extract the tzinfo
320
    :type dt: str/date/datetime/DateTime
321
    :param: default: timezone name or pytz tzinfo object
322
    :returns: pytz tzinfo object, e.g. `<UTC>, <StaticTzInfo 'Etc/GMT+2'>
323
    :rtype: UTC/BaseTzInfo/StaticTzInfo/DstTzInfo
324
    """
325
    if is_str(default):
326
        default = pytz.timezone(default)
327
    try:
328
        if is_str(dt_tz):
329
            return pytz.timezone(dt_tz)
330
        tz = get_timezone(dt_tz, default=default.zone)
331
        return pytz.timezone(tz)
332
    except pytz.UnknownTimeZoneError:
333
        return default
334
335
336
def is_valid_timezone(timezone):
337
    """Checks if the timezone is a valid pytz/Olson name
338
339
    :param timezone: pytz/Olson timezone name
340
    :returns: True when the timezone is a valid zone
341
    """
342
    try:
343
        pytz.timezone(timezone)
344
        return True
345
    except pytz.UnknownTimeZoneError:
346
        return False
347
348
349
def get_os_timezone(default="Etc/GMT"):
350
    """Return the default timezone of the system
351
352
    :returns: OS timezone or default timezone
353
    """
354
    timezone = None
355
    if "TZ" in os.environ.keys():
356
        # Timezone from OS env var
357
        timezone = os.environ["TZ"]
358
    if not timezone:
359
        # Timezone from python time
360
        zones = time.tzname
361
        if zones and len(zones) > 0:
362
            timezone = zones[0]
363
        else:
364
            logger.warn(
365
                "Operating system\'s timezone cannot be found. "
366
                "Falling back to %s." % default)
367
            timezone = default
368
    if not is_valid_timezone(timezone):
369
        return default
370
    return timezone
371
372
373
def to_zone(dt, timezone):
374
    """Convert date to timezone
375
376
    Adds the timezone for timezone naive datetimes
377
378
    :param dt: date object
379
    :param timezone: timezone
380
    :returns: date converted to timezone
381
    """
382
    if is_dt(dt) or is_d(dt):
383
        dt = to_dt(dt)
384
        zone = pytz.timezone(timezone)
385
        if is_timezone_aware(dt):
386
            return dt.astimezone(zone)
387
        return zone.localize(dt)
388
    elif is_DT(dt):
389
        # NOTE: This shifts the time according to the TZ offset
390
        return dt.toZone(timezone)
391
    raise TypeError("Expected a date, got '%r'" % type(dt))
392
393
394
def to_timestamp(dt):
395
    """Generate a Portable Operating System Interface (POSIX) timestamp
396
397
    :param dt: date object
398
    :returns: timestamp in seconds
399
    """
400
    timestamp = 0
401
    if is_DT(dt):
402
        timestamp = dt.timeTime()
403
    elif is_dt(dt):
404
        timestamp = time.mktime(dt.timetuple())
405
    elif is_str(dt):
406
        DT = to_DT(dt)
407
        return to_timestamp(DT)
408
    return timestamp
409
410
411
def from_timestamp(timestamp):
412
    """Generate a datetime object from a POSIX timestamp
413
414
    :param timestamp: POSIX timestamp
415
    :returns: datetime object
416
    """
417
    return datetime.utcfromtimestamp(timestamp)
418
419
420
def to_iso_format(dt):
421
    """Convert to ISO format
422
    """
423
    if is_dt(dt):
424
        return dt.isoformat()
425
    elif is_DT(dt):
426
        return dt.ISO()
427
    elif is_str(dt):
428
        DT = to_DT(dt)
429
        return to_iso_format(DT)
430
    return None
431
432
433
def to_msgstr(fmt):
434
    """Converts to the format used by the TranslationServiceTool, that supports
435
    the following directives, each used as a variable in msgstr:
436
       ${A} ${a} ${B} ${b} ${H} ${I} ${m} ${d} ${M} ${p} ${S} ${Y} ${y} ${Z}
437
438
    For example: "${d}-${m}-${Y} ${H}:${M}"
439
440
    Note the following directives from C standard are NOT supported:
441
        %w %f %z %j %U %W %c %x %X
442
    """
443
    return re.sub(r"%([aAdbBmyYHIpMSZ])", r"${\1}", fmt)
444
445
446
def to_C1989(fmt):
447
    """Converts to a format code that adheres to the C standard (1989 version)
448
    """
449
    return re.sub(r"\${([aAwdbBmyYHIpMSfzZjUWcxX])}", r"%\1", fmt)
450
451
452
def date_to_string(dt, fmt="%Y-%m-%d", default=""):
453
    """Format the date to string
454
    """
455
    if not is_date(dt):
456
        return default
457
458
    # NOTE: The function `is_date` evaluates also string dates as `True`.
459
    #       We ensure in such a case to have a `DateTime` object and leave
460
    #       possible `datetime` objects unchanged.
461
    if isinstance(dt, six.string_types):
462
        dt = to_DT(dt)
463
464
    # convert the format to C Standard (1989 version) just in case
465
    fmt_c1989 = to_C1989(fmt)
466
467
    try:
468
        return dt.strftime(fmt_c1989) or default
469
    except ValueError:
470
        # The datetime strftime() function requires year >= 1900
471
        # Use safe_substitute instead
472
        new_fmt = to_msgstr(fmt)
473
474
        # Manually extract relevant date and time parts
475
        dt = to_DT(dt)
476
        data = {
477
            "Y": dt.year(),
478
            "y": dt.yy(),
479
            "m": dt.mm(),
480
            "d": dt.dd(),
481
            "H": "{:0>2}".format(dt.h_24()),
482
            "I": "{:0>2}".format(dt.h_12()),
483
            "M": "{:0>2}".format(dt.minute()),
484
            "p": dt.ampm().upper(),
485
            "S": "{:0>2}".format(dt.second()),
486
            "Z": get_timezone(dt),
487
            "b": get_month_name(dt.month(), abbr=True),
488
            "B": get_month_name(dt.month()),
489
        }
490
491
        if "${w}" in new_fmt:
492
            data["w"] = int(dt.strftime("%w"))
493
494
        if "${a}" in new_fmt.lower():
495
            # Weekday name ${A} or abbreviation (${a})
496
            weekday = int(dt.strftime("%w"))
497
            data.update({
498
                "a": get_weekday_name(weekday, abbr=True),
499
                "A": get_weekday_name(weekday),
500
            })
501
502
        return Template(new_fmt).safe_substitute(data) or default
503
504
505
def to_localized_time(dt, long_format=None, time_only=None,
506
                      context=None, request=None, default=""):
507
    """Convert a date object to a localized string
508
509
    :param dt: The date/time to localize
510
    :type dt: str/datetime/DateTime
511
    :param long_format: Return long date/time if True
512
    :type portal_type: boolean/null
513
    :param time_only: If True, only returns time.
514
    :type title: boolean/null
515
    :param context: The current context
516
    :type context: ATContentType
517
    :param request: The current request
518
    :type request: HTTPRequest object
519
    :returns: The formatted date as string
520
    :rtype: string
521
    """
522
    if not dt:
523
        return default
524
525
    try:
526
        ts = get_tool("translation_service")
527
        time_str = ts.ulocalized_time(
528
            dt, long_format, time_only, context, "senaite.core", request)
529
    except ValueError:
530
        # Handle dates < 1900
531
532
        # code taken from Products.CMFPlone.i18nl110n.ulocalized_time
533
        if time_only:
534
            msgid = "time_format"
535
        elif long_format:
536
            msgid = "date_format_long"
537
        else:
538
            msgid = "date_format_short"
539
540
        formatstring = translate(msgid, "senaite.core", {}, request)
541
        if formatstring == msgid:
542
            if msgid == "date_format_long":
543
                formatstring = "%Y-%m-%d %H:%M"  # 2038-01-19 03:14
544
            elif msgid == "date_format_short":
545
                formatstring = "%Y-%m-%d"  # 2038-01-19
546
            elif msgid == "time_format":
547
                formatstring = "%H:%M"  # 03:14
548
            else:
549
                formatstring = "[INTERNAL ERROR]"
550
        time_str = date_to_string(dt, formatstring, default=default)
551
    return time_str
552
553
554
def get_relative_delta(dt1, dt2=None):
555
    """Calculates the relative delta between two dates or datetimes
556
557
    If `dt2` is None, the current datetime is used.
558
559
    :param dt1: the first date/time to compare
560
    :type dt1: string/date/datetime/DateTime
561
    :param dt2: the second date/time to compare
562
    :type dt2: string/date/datetime/DateTime
563
    :returns: interval of time (e.g. `relativedelta(hours=+3)`)
564
    :rtype: dateutil.relativedelta
565
    """
566
    if not dt2:
567
        dt2 = datetime.now()
568
569
    dt1 = to_dt(dt1)
570
    dt2 = to_dt(dt2)
571
    if not all([dt1, dt2]):
572
        raise ValueError("No valid date or dates")
573
574
    naives = [is_timezone_naive(dt) for dt in [dt1, dt2]]
575
    if all(naives):
576
        # Both naive, no need to do anything special
577
        return relativedelta(dt2, dt1)
578
579
    elif is_timezone_naive(dt1):
580
        # From date is naive, assume same TZ as the to date
581
        tzinfo = get_tzinfo(dt2)
582
        dt1 = dt1.replace(tzinfo=tzinfo)
583
584
    elif is_timezone_naive(dt2):
585
        # To date is naive, assume same TZ as the from date
586
        tzinfo = get_tzinfo(dt1)
587
        dt2 = dt2.replace(tzinfo=tzinfo)
588
589
    return relativedelta(dt2, dt1)
590
591
592
def timedelta_to_dict(value, default=_marker):
593
    """Converts timedelta value to dict object
594
595
    {
596
        "days": 10,
597
        "hours": 10,
598
        "minutes": 10,
599
        "seconds": 10,
600
    }
601
602
    :param value: timedelta object for conversion
603
    :type value: timedelta
604
    :param value: timedelta object for conversion
605
    :type value: timedelta
606
    :returns converted timedelta as dict or default object
607
    :rtype: dict or default object
608
    """
609
610
    if not isinstance(value, timedelta):
611
        if default is _marker:
612
            raise TypeError("%r is not supported" % type(value))
613
        logger.warn(
614
            "Invalid value passed to timedelta->dict conversion. "
615
            "Falling back to default: %s." % default)
616
        return default
617
618
    # Note timedelta keeps days and seconds a part!
619
    return {
620
        "days": value.days,
621
        "hours": value.seconds // 3600,  # hours within a day
622
        "minutes": (value.seconds % 3600) // 60,  # minutes within an hour
623
        "seconds": value.seconds % 60,  # seconds within a minute
624
    }
625
626
627
def to_timedelta(value, default=_marker):
628
    """Converts dict object w/ days, hours, minutes, seconds keys to
629
       timedelta format
630
631
    :param value: dict object for conversion
632
    :type value: dict
633
    :returns converted timedelta
634
    :rtype: timedelta
635
    """
636
637
    if isinstance(value, timedelta):
638
        return value
639
640
    if not isinstance(value, dict):
641
        if default is _marker:
642
            raise TypeError("%r is not supported" % type(value))
643
        logger.warn(
644
            "Invalid value passed to dict->timedelta conversion. "
645
            "Falling back to default: %s." % default)
646
        return default
647
648
    return timedelta(
649
        days=to_int(value.get('days', 0), 0),
650
        hours=to_int(value.get('hours', 0), 0),
651
        minutes=to_int(value.get('minutes', 0), 0),
652
        seconds=to_int(value.get('seconds', 0), 0)
653
    )
654