Passed
Push — 2.x ( 4e425f...90f02d )
by Jordi
06:17
created

senaite.core.api.dtime.timedelta_to_dict()   A

Complexity

Conditions 3

Size

Total Lines 32
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 13
dl 0
loc 32
rs 9.75
c 0
b 0
f 0
cc 3
nop 2
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-2024 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 zope.i18n import translate
43
44
45
_marker = object()
46
47
48
def is_str(obj):
49
    """Check if the given object is a string
50
51
    :param obj: arbitrary object
52
    :returns: True when the object is a string
53
    """
54
    return isinstance(obj, six.string_types)
55
56
57
def is_d(dt):
58
    """Check if the date is a Python `date` object
59
60
    :param dt: date to check
61
    :returns: True when the date is a Python `date`
62
    """
63
    return type(dt) is date
64
65
66
def is_dt(dt):
67
    """Check if the date is a Python `datetime` object
68
69
    :param dt: date to check
70
    :returns: True when the date is a Python `datetime`
71
    """
72
    return type(dt) is datetime
73
74
75
def is_DT(dt):
76
    """Check if the date is a Zope `DateTime` object
77
78
    :param dt: object to check
79
    :returns: True when the object is a Zope `DateTime`
80
    """
81
    return type(dt) is DateTime
82
83
84
def is_date(dt):
85
    """Check if the date is a datetime or DateTime object
86
87
    :param dt: date to check
88
    :returns: True when the object is either a datetime or DateTime
89
    """
90
    if is_str(dt):
91
        DT = to_DT(dt)
92
        return is_date(DT)
93
    if is_d(dt):
94
        return True
95
    if is_dt(dt):
96
        return True
97
    if is_DT(dt):
98
        return True
99
    return False
100
101
102
def is_timezone_naive(dt):
103
    """Check if the date is timezone naive
104
105
    :param dt: date to check
106
    :returns: True when the date has no timezone
107
    """
108
    if is_d(dt):
109
        return True
110
    elif is_DT(dt):
111
        return dt.timezoneNaive()
112
    elif is_dt(dt):
113
        return dt.tzinfo is None
114
    elif is_str(dt):
115
        DT = to_DT(dt)
116
        return is_timezone_naive(DT)
117
    raise APIError("Expected a date type, got '%r'" % type(dt))
118
119
120
def is_timezone_aware(dt):
121
    """Check if the date is timezone aware
122
123
    :param dt: date to check
124
    :returns: True when the date has a timezone
125
    """
126
    return not is_timezone_naive(dt)
127
128
129
def to_DT(dt):
130
    """Convert to DateTime
131
132
    :param dt: DateTime/datetime/date
133
    :returns: DateTime object
134
    """
135
    INTERNATIONAL_FMT = re.compile(
136
        r"^\s*(3[01]|[12][0-9]|0?[1-9])\.(1[012]|0?[1-9])\.(\d{2,4})\s*"
137
    )
138
    if is_DT(dt):
139
        return dt
140
    elif is_str(dt):
141
        kwargs = {}
142
        if re.match(INTERNATIONAL_FMT, dt):
143
            # This will fail silently and you get a wrong date:
144
            # dt = DateTime("02.07.2010") # Parses like US date 02/07/2010
145
            # https://github.com/zopefoundation/DateTime/blob/master/src/DateTime/DateTime.py#L641-L645
146
            kwargs["datefmt"] = "international"
147
        try:
148
            return DateTime(dt, **kwargs)
149
        except (DateError, TimeError):
150
            try:
151
                dt = ansi_to_dt(dt)
152
                return to_DT(dt)
153
            except ValueError:
154
                return None
155
        except (SyntaxError, IndexError):
156
            return None
157
    elif is_dt(dt):
158
        try:
159
            # XXX Why do this instead of DateTime(dt)?
160
            return DateTime(dt.isoformat())
161
        except DateTimeError:
162
            return DateTime(dt)
163
    elif is_d(dt):
164
        dt = datetime(dt.year, dt.month, dt.day)
165
        return DateTime(dt.isoformat())
166
    else:
167
        return None
168
169
170
def to_dt(dt):
171
    """Convert to datetime
172
173
    :param dt: DateTime/datetime/date
174
    :returns: datetime object
175
    """
176
    if is_DT(dt):
177
        # get a valid pytz timezone
178
        tz = get_timezone(dt)
179
        dt = dt.asdatetime()
180
        if is_valid_timezone(tz):
181
            dt = to_zone(dt, tz)
182
        return dt
183
    elif is_str(dt):
184
        DT = to_DT(dt)
185
        return to_dt(DT)
186
    elif is_dt(dt):
187
        return dt
188
    elif is_d(dt):
189
        return datetime(dt.year, dt.month, dt.day)
190
    else:
191
        return None
192
193
194
def ansi_to_dt(dt):
195
    """The YYYYMMDD format is defined by ANSI X3.30. Therefore, 2 December 1,
196
    1989 would be represented as 19891201. When times are transmitted, they
197
    shall be represented as HHMMSS, and shall be linked to dates as specified
198
    by ANSI X3.43.3 Date and time together shall be specified as up to a
199
    14-character string: YYYYMMDD[HHMMSS]
200
    :param str:
201
    :return: datetime object
202
    """
203
    if not is_str(dt):
204
        raise TypeError("Type is not supported")
205
    if len(dt) == 8:
206
        date_format = "%Y%m%d"
207
    elif len(dt) == 14:
208
        date_format = "%Y%m%d%H%M%S"
209
    else:
210
        raise ValueError("No ANSI format date")
211
    return datetime.strptime(dt, date_format)
212
213
214
def to_ansi(dt, show_time=True):
215
    """Returns the date in ANSI X3.30/X4.43.3) format
216
    :param dt: DateTime/datetime/date
217
    :param show_time: if true, returns YYYYMMDDHHMMSS. YYYYMMDD otherwise
218
    :returns: str that represents the datetime in ANSI format
219
    """
220
    dt = to_dt(dt)
221
    if dt is None:
222
        return None
223
224
    ansi = "{:04d}{:02d}{:02d}".format(dt.year, dt.month, dt.day)
225
    if not show_time:
226
        return ansi
227
    return "{}{:02d}{:02d}{:02d}".format(ansi, dt.hour, dt.minute, dt.second)
228
229
230
def get_timezone(dt, default="Etc/GMT"):
231
    """Get a valid pytz timezone name of the datetime object
232
233
    :param dt: date object
234
    :returns: timezone as string, e.g. Etc/GMT or CET
235
    """
236
    tz = None
237
    if is_dt(dt):
238
        tz = dt.tzname()
239
    elif is_DT(dt):
240
        tz = dt.timezone()
241
    elif is_d(dt):
242
        tz = default
243
244
    if tz:
245
        # convert DateTime `GMT` to `Etc/GMT` timezones
246
        # NOTE: `GMT+1` get `Etc/GMT-1`!
247
        if tz.startswith("GMT+0"):
248
            tz = tz.replace("GMT+0", "Etc/GMT")
249
        elif tz.startswith("GMT+"):
250
            tz = tz.replace("GMT+", "Etc/GMT-")
251
        elif tz.startswith("GMT-"):
252
            tz = tz.replace("GMT-", "Etc/GMT+")
253
        elif tz.startswith("GMT"):
254
            tz = tz.replace("GMT", "Etc/GMT")
255
    else:
256
        tz = default
257
258
    return tz
259
260
261
def get_tzinfo(dt_tz, default=pytz.UTC):
262
    """Returns the valid pytz tinfo from the date or timezone name
263
264
    Returns the default timezone info if date does not have a valid timezone
265
    set or is TZ-naive
266
267
    :param dt: timezone name or date object to extract the tzinfo
268
    :type dt: str/date/datetime/DateTime
269
    :param: default: timezone name or pytz tzinfo object
270
    :returns: pytz tzinfo object, e.g. `<UTC>, <StaticTzInfo 'Etc/GMT+2'>
271
    :rtype: UTC/BaseTzInfo/StaticTzInfo/DstTzInfo
272
    """
273
    if is_str(default):
274
        default = pytz.timezone(default)
275
    try:
276
        if is_str(dt_tz):
277
            return pytz.timezone(dt_tz)
278
        tz = get_timezone(dt_tz, default=default.zone)
279
        return pytz.timezone(tz)
280
    except pytz.UnknownTimeZoneError:
281
        return default
282
283
284
def is_valid_timezone(timezone):
285
    """Checks if the timezone is a valid pytz/Olson name
286
287
    :param timezone: pytz/Olson timezone name
288
    :returns: True when the timezone is a valid zone
289
    """
290
    try:
291
        pytz.timezone(timezone)
292
        return True
293
    except pytz.UnknownTimeZoneError:
294
        return False
295
296
297
def get_os_timezone(default="Etc/GMT"):
298
    """Return the default timezone of the system
299
300
    :returns: OS timezone or default timezone
301
    """
302
    timezone = None
303
    if "TZ" in os.environ.keys():
304
        # Timezone from OS env var
305
        timezone = os.environ["TZ"]
306
    if not timezone:
307
        # Timezone from python time
308
        zones = time.tzname
309
        if zones and len(zones) > 0:
310
            timezone = zones[0]
311
        else:
312
            logger.warn(
313
                "Operating system\'s timezone cannot be found. "
314
                "Falling back to %s." % default)
315
            timezone = default
316
    if not is_valid_timezone(timezone):
317
        return default
318
    return timezone
319
320
321
def to_zone(dt, timezone):
322
    """Convert date to timezone
323
324
    Adds the timezone for timezone naive datetimes
325
326
    :param dt: date object
327
    :param timezone: timezone
328
    :returns: date converted to timezone
329
    """
330
    if is_dt(dt) or is_d(dt):
331
        dt = to_dt(dt)
332
        zone = pytz.timezone(timezone)
333
        if is_timezone_aware(dt):
334
            return dt.astimezone(zone)
335
        return zone.localize(dt)
336
    elif is_DT(dt):
337
        # NOTE: This shifts the time according to the TZ offset
338
        return dt.toZone(timezone)
339
    raise TypeError("Expected a date, got '%r'" % type(dt))
340
341
342
def to_timestamp(dt):
343
    """Generate a Portable Operating System Interface (POSIX) timestamp
344
345
    :param dt: date object
346
    :returns: timestamp in seconds
347
    """
348
    timestamp = 0
349
    if is_DT(dt):
350
        timestamp = dt.timeTime()
351
    elif is_dt(dt):
352
        timestamp = time.mktime(dt.timetuple())
353
    elif is_str(dt):
354
        DT = to_DT(dt)
355
        return to_timestamp(DT)
356
    return timestamp
357
358
359
def from_timestamp(timestamp):
360
    """Generate a datetime object from a POSIX timestamp
361
362
    :param timestamp: POSIX timestamp
363
    :returns: datetime object
364
    """
365
    return datetime.utcfromtimestamp(timestamp)
366
367
368
def to_iso_format(dt):
369
    """Convert to ISO format
370
    """
371
    if is_dt(dt):
372
        return dt.isoformat()
373
    elif is_DT(dt):
374
        return dt.ISO()
375
    elif is_str(dt):
376
        DT = to_DT(dt)
377
        return to_iso_format(DT)
378
    return None
379
380
381
def date_to_string(dt, fmt="%Y-%m-%d", default=""):
382
    """Format the date to string
383
    """
384
    if not is_date(dt):
385
        return default
386
387
    # NOTE: The function `is_date` evaluates also string dates as `True`.
388
    #       We ensure in such a case to have a `DateTime` object and leave
389
    #       possible `datetime` objects unchanged.
390
    if isinstance(dt, six.string_types):
391
        dt = to_DT(dt)
392
393
    try:
394
        return dt.strftime(fmt)
395
    except ValueError:
396
        #  Fix ValueError: year=1111 is before 1900;
397
        #  the datetime strftime() methods require year >= 1900
398
399
        # convert format string to be something like "${Y}-${m}-${d}"
400
        new_fmt = ""
401
        var = False
402
        for x in fmt:
403
            if x == "%":
404
                var = True
405
                new_fmt += "${"
406
                continue
407
            if var:
408
                new_fmt += x
409
                new_fmt += "}"
410
                var = False
411
            else:
412
                new_fmt += x
413
414
        def pad(val):
415
            """Add a zero if val is a single digit
416
            """
417
            return "{:0>2}".format(val)
418
419
        # Manually extract relevant date and time parts
420
        dt = to_DT(dt)
421
        data = {
422
            "Y": dt.year(),
423
            "y": dt.yy(),
424
            "m": dt.mm(),
425
            "d": dt.dd(),
426
            "H": pad(dt.h_24()),
427
            "I": pad(dt.h_12()),
428
            "M": pad(dt.minute()),
429
            "p": dt.ampm().upper(),
430
            "S": dt.second(),
431
        }
432
433
        return Template(new_fmt).safe_substitute(data)
434
435
436
def to_localized_time(dt, long_format=None, time_only=None,
437
                      context=None, request=None, default=""):
438
    """Convert a date object to a localized string
439
440
    :param dt: The date/time to localize
441
    :type dt: str/datetime/DateTime
442
    :param long_format: Return long date/time if True
443
    :type portal_type: boolean/null
444
    :param time_only: If True, only returns time.
445
    :type title: boolean/null
446
    :param context: The current context
447
    :type context: ATContentType
448
    :param request: The current request
449
    :type request: HTTPRequest object
450
    :returns: The formatted date as string
451
    :rtype: string
452
    """
453
    if not dt:
454
        return default
455
456
    try:
457
        ts = get_tool("translation_service")
458
        time_str = ts.ulocalized_time(
459
            dt, long_format, time_only, context, "senaite.core", request)
460
    except ValueError:
461
        # Handle dates < 1900
462
463
        # code taken from Products.CMFPlone.i18nl110n.ulocalized_time
464
        if time_only:
465
            msgid = "time_format"
466
        elif long_format:
467
            msgid = "date_format_long"
468
        else:
469
            msgid = "date_format_short"
470
471
        formatstring = translate(msgid, "senaite.core", {}, request)
472
        if formatstring == msgid:
473
            if msgid == "date_format_long":
474
                formatstring = "%Y-%m-%d %H:%M"  # 2038-01-19 03:14
475
            elif msgid == "date_format_short":
476
                formatstring = "%Y-%m-%d"  # 2038-01-19
477
            elif msgid == "time_format":
478
                formatstring = "%H:%M"  # 03:14
479
            else:
480
                formatstring = "[INTERNAL ERROR]"
481
        time_str = date_to_string(dt, formatstring, default=default)
482
    return time_str
483
484
485
def get_relative_delta(dt1, dt2=None):
486
    """Calculates the relative delta between two dates or datetimes
487
488
    If `dt2` is None, the current datetime is used.
489
490
    :param dt1: the first date/time to compare
491
    :type dt1: string/date/datetime/DateTime
492
    :param dt2: the second date/time to compare
493
    :type dt2: string/date/datetime/DateTime
494
    :returns: interval of time (e.g. `relativedelta(hours=+3)`)
495
    :rtype: dateutil.relativedelta
496
    """
497
    if not dt2:
498
        dt2 = datetime.now()
499
500
    dt1 = to_dt(dt1)
501
    dt2 = to_dt(dt2)
502
    if not all([dt1, dt2]):
503
        raise ValueError("No valid date or dates")
504
505
    naives = [is_timezone_naive(dt) for dt in [dt1, dt2]]
506
    if all(naives):
507
        # Both naive, no need to do anything special
508
        return relativedelta(dt2, dt1)
509
510
    elif is_timezone_naive(dt1):
511
        # From date is naive, assume same TZ as the to date
512
        tzinfo = get_tzinfo(dt2)
513
        dt1 = dt1.replace(tzinfo=tzinfo)
514
515
    elif is_timezone_naive(dt2):
516
        # To date is naive, assume same TZ as the from date
517
        tzinfo = get_tzinfo(dt1)
518
        dt2 = dt2.replace(tzinfo=tzinfo)
519
520
    return relativedelta(dt2, dt1)
521
522
523
def timedelta_to_dict(value, default=_marker):
524
    """Converts timedelta value to dict object
525
526
    {
527
        "days": 10,
528
        "hours": 10,
529
        "minutes": 10,
530
        "seconds": 10,
531
    }
532
533
    :param value: timedelta object for conversion
534
    :type value: timedelta
535
    :param value: timedelta object for conversion
536
    :type value: timedelta
537
    :returns converted timedelta as dict or default object
538
    :rtype: dict or default object
539
    """
540
541
    if not isinstance(value, timedelta):
542
        if default is _marker:
543
            raise TypeError("%r is not supported" % type(value))
544
        logger.warn(
545
            "Invalid value passed to timedelta->dict conversion. "
546
            "Falling back to default: %s." % default)
547
        return default
548
549
    # Note timedelta keeps days and seconds a part!
550
    return {
551
        "days": value.days,
552
        "hours": value.seconds // 3600,  # hours within a day
553
        "minutes": (value.seconds % 3600) // 60,  # minutes within an hour
554
        "seconds": value.seconds % 60,  # seconds within a minute
555
    }
556
557
558
def to_timedelta(value, default=_marker):
559
    """Converts dict object w/ days, hours, minutes, seconds keys to
560
       timedelta format
561
562
    :param value: dict object for conversion
563
    :type value: dict
564
    :returns converted timedelta
565
    :rtype: timedelta
566
    """
567
568
    if isinstance(value, timedelta):
569
        return value
570
571
    if not isinstance(value, dict):
572
        if default is _marker:
573
            raise TypeError("%r is not supported" % type(value))
574
        logger.warn(
575
            "Invalid value passed to dict->timedelta conversion. "
576
            "Falling back to default: %s." % default)
577
        return default
578
579
    return timedelta(
580
        days=to_int(value.get('days', 0), 0),
581
        hours=to_int(value.get('hours', 0), 0),
582
        minutes=to_int(value.get('minutes', 0), 0),
583
        seconds=to_int(value.get('seconds', 0), 0)
584
    )
585