senaite.core.api.dtime.now()   A
last analyzed

Complexity

Conditions 1

Size

Total Lines 7
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 2
dl 0
loc 7
rs 10
c 0
b 0
f 0
cc 1
nop 0
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
YMD_REGEX = (
50
    r"^"
51
    r"((?P<y>(\d+))y){0,1}\s*"  # years
52
    r"((?P<m>(\d+))m){0,1}\s*"  # months
53
    r"((?P<d>(\d+))d){0,1}\s*"  # days
54
    r"((?P<h>(\d+))h){0,1}\s*"  # hours
55
)
56
57
_marker = object()
58
59
60
def is_str(obj):
61
    """Check if the given object is a string
62
63
    :param obj: arbitrary object
64
    :returns: True when the object is a string
65
    """
66
    return isinstance(obj, six.string_types)
67
68
69
def is_d(dt):
70
    """Check if the date is a Python `date` object
71
72
    :param dt: date to check
73
    :returns: True when the date is a Python `date`
74
    """
75
    return type(dt) is date
76
77
78
def is_dt(dt):
79
    """Check if the date is a Python `datetime` object
80
81
    :param dt: date to check
82
    :returns: True when the date is a Python `datetime`
83
    """
84
    return type(dt) is datetime
85
86
87
def is_DT(dt):
88
    """Check if the date is a Zope `DateTime` object
89
90
    :param dt: object to check
91
    :returns: True when the object is a Zope `DateTime`
92
    """
93
    return type(dt) is DateTime
94
95
96
def is_date(dt):
97
    """Check if the date is a datetime or DateTime object
98
99
    :param dt: date to check
100
    :returns: True when the object is either a datetime or DateTime
101
    """
102
    if is_str(dt):
103
        DT = to_DT(dt)
104
        return is_date(DT)
105
    if is_d(dt):
106
        return True
107
    if is_dt(dt):
108
        return True
109
    if is_DT(dt):
110
        return True
111
    return False
112
113
114
def is_timezone_naive(dt):
115
    """Check if the date is timezone naive
116
117
    :param dt: date to check
118
    :returns: True when the date has no timezone
119
    """
120
    if is_d(dt):
121
        return True
122
    elif is_DT(dt):
123
        return dt.timezoneNaive()
124
    elif is_dt(dt):
125
        return dt.tzinfo is None
126
    elif is_str(dt):
127
        DT = to_DT(dt)
128
        return is_timezone_naive(DT)
129
    raise APIError("Expected a date type, got '%r'" % type(dt))
130
131
132
def is_timezone_aware(dt):
133
    """Check if the date is timezone aware
134
135
    :param dt: date to check
136
    :returns: True when the date has a timezone
137
    """
138
    return not is_timezone_naive(dt)
139
140
141
def to_DT(dt):
142
    """Convert to DateTime
143
144
    :param dt: DateTime/datetime/date
145
    :returns: DateTime object
146
    """
147
    INTERNATIONAL_FMT = re.compile(
148
        r"^\s*(3[01]|[12][0-9]|0?[1-9])\.(1[012]|0?[1-9])\.(\d{2,4})\s*"
149
    )
150
    if is_DT(dt):
151
        return dt
152
    elif is_str(dt):
153
        kwargs = {}
154
        if re.match(INTERNATIONAL_FMT, dt):
155
            # This will fail silently and you get a wrong date:
156
            # dt = DateTime("02.07.2010") # Parses like US date 02/07/2010
157
            # https://github.com/zopefoundation/DateTime/blob/master/src/DateTime/DateTime.py#L641-L645
158
            kwargs["datefmt"] = "international"
159
        try:
160
            return DateTime(dt, **kwargs)
161
        except (DateError, DateTimeError, TimeError):
162
            try:
163
                dt = ansi_to_dt(dt)
164
                return to_DT(dt)
165
            except ValueError:
166
                return None
167
        except (SyntaxError, IndexError):
168
            return None
169
    elif is_dt(dt):
170
        try:
171
            # We do this because isoformat() comes with the +/- utc offset at
172
            # the end, so it becomes easier than having to rely on tzinfo stuff
173
            return DateTime(dt.isoformat())
174
        except DateTimeError:
175
            return DateTime(dt)
176
    elif is_d(dt):
177
        dt = datetime(dt.year, dt.month, dt.day)
178
        return DateTime(dt.isoformat())
179
    else:
180
        return None
181
182
183
def to_dt(dt):
184
    """Convert to datetime
185
186
    :param dt: DateTime/datetime/date
187
    :returns: datetime object
188
    """
189
    if is_DT(dt):
190
        # get a valid pytz timezone
191
        tz = get_timezone(dt)
192
        dt = dt.asdatetime()
193
        if is_valid_timezone(tz):
194
            dt = to_zone(dt, tz)
195
        return dt
196
    elif is_str(dt):
197
        DT = to_DT(dt)
198
        return to_dt(DT)
199
    elif is_dt(dt):
200
        return dt
201
    elif is_d(dt):
202
        return datetime(dt.year, dt.month, dt.day)
203
    else:
204
        return None
205
206
207
def now():
208
    """Returns a timezone-aware datetime representing current date and time
209
    as defined in Zope's TZ environment variable or, if not set, from OS
210
211
    :returns: timezone-aware datetime object
212
    """
213
    return to_dt(DateTime())
214
215
216
def ansi_to_dt(dt):
217
    """The YYYYMMDD format is defined by ANSI X3.30. Therefore, 2 December 1,
218
    1989 would be represented as 19891201. When times are transmitted, they
219
    shall be represented as HHMMSS, and shall be linked to dates as specified
220
    by ANSI X3.43.3 Date and time together shall be specified as up to a
221
    14-character string: YYYYMMDD[HHMMSS]
222
    :param str:
223
    :return: datetime object
224
    """
225
    if not is_str(dt):
226
        raise TypeError("Type is not supported")
227
    if len(dt) == 8:
228
        date_format = "%Y%m%d"
229
    elif len(dt) == 14:
230
        date_format = "%Y%m%d%H%M%S"
231
    else:
232
        raise ValueError("No ANSI format date")
233
    return datetime.strptime(dt, date_format)
234
235
236
def to_ansi(dt, show_time=True, timezone=None):
237
    """Returns the date in ANSI X3.30/X4.43.3) format
238
    :param dt: DateTime/datetime/date
239
    :param show_time: if true, returns YYYYMMDDHHMMSS. YYYYMMDD otherwise
240
    :param timezone: if set, converts the date to the given timezone
241
    :returns: str that represents the datetime in ANSI format
242
    """
243
    dt = to_dt(dt)
244
    if dt is None:
245
        return None
246
247
    if timezone and is_valid_timezone(timezone):
248
        if is_timezone_naive(dt):
249
            # XXX Localize to default TZ to overcome `to_dt` inconsistency:
250
            #
251
            # - if the input value is a datetime/date type, `to_dt` does not
252
            #   convert to the OS's zone, even if the value is tz-naive.
253
            # - if the input value is a str or DateTime, `to_dt` does  convert
254
            #   to the system default zone.
255
            #
256
            # For instance:
257
            # >>> to_dt("19891201131405")
258
            # datetime.datetime(1989, 12, 1, 13, 14, 5, tzinfo=<StaticTzInfo 'Etc/GMT'>)
259
            # >>> ansi_to_dt("19891201131405")
260
            # datetime.datetime(1989, 12, 1, 13, 14, 5)
261
            #
262
            # That causes the following inconsistency:
263
            # >>> to_ansi(to_dt("19891201131405"), timezone="Pacific/Fiji")
264
            # 19891202011405
265
            # >>> to_ansi(ansi_to_dt("19891201131405"), timezone="Pacific/Fiji")
266
            # 19891201131405
267
268
            default_tz = get_timezone(dt)
269
            default_zone = pytz.timezone(default_tz)
270
            dt = default_zone.localize(dt)
271
272
        dt = to_zone(dt, timezone)
273
274
    ansi = "{:04d}{:02d}{:02d}".format(dt.year, dt.month, dt.day)
275
    if not show_time:
276
        return ansi
277
    return "{}{:02d}{:02d}{:02d}".format(ansi, dt.hour, dt.minute, dt.second)
278
279
280
def get_timezone(dt, default="Etc/GMT"):
281
    """Get a valid pytz timezone name of the datetime object
282
283
    :param dt: date object
284
    :returns: timezone as string, e.g. Etc/GMT or CET
285
    """
286
    tz = None
287
    if is_dt(dt):
288
        tz = dt.tzname()
289
    elif is_DT(dt):
290
        tz = dt.timezone()
291
    elif is_d(dt):
292
        tz = default
293
294
    if tz:
295
        # convert DateTime `GMT` to `Etc/GMT` timezones
296
        # NOTE: `GMT+1` get `Etc/GMT-1`!
297
        #
298
        # The special area of "Etc" is used for some administrative zones,
299
        # particularly for "Etc/UTC" which represents Coordinated Universal
300
        # Time. In order to conform with the POSIX style, those zone names
301
        # beginning with "Etc/GMT" have their sign reversed from the standard
302
        # ISO 8601 convention. In the "Etc" area, zones west of GMT have a
303
        # positive sign and those east have a negative sign in their name (e.g
304
        # "Etc/GMT-14" is 14 hours ahead of GMT).
305
        # --- From https://en.wikipedia.org/wiki/Tz_database#Area
306
        match = re.match(RX_GMT, tz)
307
        if match:
308
            groups = match.groups()
309
            hours = to_int(groups[2], 0)
310
            if not hours:
311
                return "Etc/GMT"
312
313
            offset = "-" if groups[1] == "+" else "+"
314
            tz = "Etc/GMT%s%s" % (offset, hours)
315
    else:
316
        tz = default
317
318
    return tz
319
320
321
def get_tzinfo(dt_tz, default=pytz.UTC):
322
    """Returns the valid pytz tinfo from the date or timezone name
323
324
    Returns the default timezone info if date does not have a valid timezone
325
    set or is TZ-naive
326
327
    :param dt: timezone name or date object to extract the tzinfo
328
    :type dt: str/date/datetime/DateTime
329
    :param: default: timezone name or pytz tzinfo object
330
    :returns: pytz tzinfo object, e.g. `<UTC>, <StaticTzInfo 'Etc/GMT+2'>
331
    :rtype: UTC/BaseTzInfo/StaticTzInfo/DstTzInfo
332
    """
333
    if is_str(default):
334
        default = pytz.timezone(default)
335
    try:
336
        if is_str(dt_tz):
337
            return pytz.timezone(dt_tz)
338
        tz = get_timezone(dt_tz, default=default.zone)
339
        return pytz.timezone(tz)
340
    except pytz.UnknownTimeZoneError:
341
        return default
342
343
344
def is_valid_timezone(timezone):
345
    """Checks if the timezone is a valid pytz/Olson name
346
347
    :param timezone: pytz/Olson timezone name
348
    :returns: True when the timezone is a valid zone
349
    """
350
    try:
351
        pytz.timezone(timezone)
352
        return True
353
    except pytz.UnknownTimeZoneError:
354
        return False
355
356
357
def get_os_timezone(default="Etc/GMT"):
358
    """Return the default timezone of the system
359
360
    :returns: OS timezone or default timezone
361
    """
362
    timezone = None
363
    if "TZ" in os.environ.keys():
364
        # Timezone from OS env var
365
        timezone = os.environ["TZ"]
366
    if not timezone:
367
        # Timezone from python time
368
        zones = time.tzname
369
        if zones and len(zones) > 0:
370
            timezone = zones[0]
371
        else:
372
            logger.warn(
373
                "Operating system\'s timezone cannot be found. "
374
                "Falling back to %s." % default)
375
            timezone = default
376
    if not is_valid_timezone(timezone):
377
        return default
378
    return timezone
379
380
381
def to_zone(dt, timezone):
382
    """Convert date to timezone
383
384
    Adds the timezone for timezone naive datetimes
385
386
    :param dt: date object
387
    :param timezone: timezone
388
    :returns: date converted to timezone
389
    """
390
    if is_dt(dt) or is_d(dt):
391
        dt = to_dt(dt)
392
        zone = pytz.timezone(timezone)
393
        if is_timezone_aware(dt):
394
            return dt.astimezone(zone)
395
        return zone.localize(dt)
396
    elif is_DT(dt):
397
        # NOTE: This shifts the time according to the TZ offset
398
        return dt.toZone(timezone)
399
    raise TypeError("Expected a date, got '%r'" % type(dt))
400
401
402
def to_timestamp(dt):
403
    """Generate a Portable Operating System Interface (POSIX) timestamp
404
405
    :param dt: date object
406
    :returns: timestamp in seconds
407
    """
408
    timestamp = 0
409
    if is_DT(dt):
410
        timestamp = dt.timeTime()
411
    elif is_dt(dt):
412
        timestamp = time.mktime(dt.timetuple())
413
    elif is_str(dt):
414
        DT = to_DT(dt)
415
        return to_timestamp(DT)
416
    return timestamp
417
418
419
def from_timestamp(timestamp):
420
    """Generate a datetime object from a POSIX timestamp
421
422
    :param timestamp: POSIX timestamp
423
    :returns: datetime object
424
    """
425
    return datetime.utcfromtimestamp(timestamp)
426
427
428
def to_iso_format(dt):
429
    """Convert to ISO format
430
    """
431
    if is_dt(dt):
432
        return dt.isoformat()
433
    elif is_DT(dt):
434
        return dt.ISO()
435
    elif is_str(dt):
436
        DT = to_DT(dt)
437
        return to_iso_format(DT)
438
    return None
439
440
441
def to_msgstr(fmt):
442
    """Converts to the format used by the TranslationServiceTool, that supports
443
    the following directives, each used as a variable in msgstr:
444
       ${A} ${a} ${B} ${b} ${H} ${I} ${m} ${d} ${M} ${p} ${S} ${Y} ${y} ${Z}
445
446
    For example: "${d}-${m}-${Y} ${H}:${M}"
447
448
    Note the following directives from C standard are NOT supported:
449
        %w %f %z %j %U %W %c %x %X
450
    """
451
    return re.sub(r"%([aAdbBmyYHIpMSZ])", r"${\1}", fmt)
452
453
454
def to_C1989(fmt):
455
    """Converts to a format code that adheres to the C standard (1989 version)
456
    """
457
    return re.sub(r"\${([aAwdbBmyYHIpMSfzZjUWcxX])}", r"%\1", fmt)
458
459
460
def date_to_string(dt, fmt="%Y-%m-%d", default=""):
461
    """Format the date to string
462
    """
463
    if not is_date(dt):
464
        return default
465
466
    # NOTE: The function `is_date` evaluates also string dates as `True`.
467
    #       We ensure in such a case to have a `DateTime` object and leave
468
    #       possible `datetime` objects unchanged.
469
    if isinstance(dt, six.string_types):
470
        dt = to_DT(dt)
471
472
    # convert the format to C Standard (1989 version) just in case
473
    fmt_c1989 = to_C1989(fmt)
474
475
    try:
476
        return dt.strftime(fmt_c1989) or default
477
    except ValueError:
478
        # The datetime strftime() function requires year >= 1900
479
        # Use safe_substitute instead
480
        new_fmt = to_msgstr(fmt)
481
482
        # Manually extract relevant date and time parts
483
        dt = to_DT(dt)
484
        data = {
485
            "Y": dt.year(),
486
            "y": dt.yy(),
487
            "m": dt.mm(),
488
            "d": dt.dd(),
489
            "H": "{:0>2}".format(dt.h_24()),
490
            "I": "{:0>2}".format(dt.h_12()),
491
            "M": "{:0>2}".format(dt.minute()),
492
            "p": dt.ampm().upper(),
493
            "S": "{:0>2}".format(dt.second()),
494
            "Z": get_timezone(dt),
495
            "b": get_month_name(dt.month(), abbr=True),
496
            "B": get_month_name(dt.month()),
497
        }
498
499
        if "${w}" in new_fmt:
500
            data["w"] = int(dt.strftime("%w"))
501
502
        if "${a}" in new_fmt.lower():
503
            # Weekday name ${A} or abbreviation (${a})
504
            weekday = int(dt.strftime("%w"))
505
            data.update({
506
                "a": get_weekday_name(weekday, abbr=True),
507
                "A": get_weekday_name(weekday),
508
            })
509
510
        return Template(new_fmt).safe_substitute(data) or default
511
512
513
def to_localized_time(dt, long_format=None, time_only=None,
514
                      context=None, request=None, default=""):
515
    """Convert a date object to a localized string
516
517
    :param dt: The date/time to localize
518
    :type dt: str/datetime/DateTime
519
    :param long_format: Return long date/time if True
520
    :type portal_type: boolean/null
521
    :param time_only: If True, only returns time.
522
    :type title: boolean/null
523
    :param context: The current context
524
    :type context: ATContentType
525
    :param request: The current request
526
    :type request: HTTPRequest object
527
    :returns: The formatted date as string
528
    :rtype: string
529
    """
530
    if not dt:
531
        return default
532
533
    try:
534
        ts = get_tool("translation_service")
535
        time_str = ts.ulocalized_time(
536
            dt, long_format, time_only, context, "senaite.core", request)
537
    except ValueError:
538
        # Handle dates < 1900
539
540
        # code taken from Products.CMFPlone.i18nl110n.ulocalized_time
541
        if time_only:
542
            msgid = "time_format"
543
        elif long_format:
544
            msgid = "date_format_long"
545
        else:
546
            msgid = "date_format_short"
547
548
        formatstring = translate(msgid, "senaite.core", {}, request)
549
        if formatstring == msgid:
550
            if msgid == "date_format_long":
551
                formatstring = "%Y-%m-%d %H:%M"  # 2038-01-19 03:14
552
            elif msgid == "date_format_short":
553
                formatstring = "%Y-%m-%d"  # 2038-01-19
554
            elif msgid == "time_format":
555
                formatstring = "%H:%M"  # 03:14
556
            else:
557
                formatstring = "[INTERNAL ERROR]"
558
        time_str = date_to_string(dt, formatstring, default=default)
559
    return time_str
560
561
562
def get_relativedelta(dt1, dt2=None):
563
    """Calculates the relative delta between two dates or datetimes
564
565
    If `dt2` is None, the current datetime is used.
566
567
    :param dt1: the first date/time to compare
568
    :type dt1: string/date/datetime/DateTime
569
    :param dt2: the second date/time to compare
570
    :type dt2: string/date/datetime/DateTime
571
    :returns: interval of time (e.g. `relativedelta(hours=+3)`)
572
    :rtype: dateutil.relativedelta
573
    """
574
    if not dt2:
575
        dt2 = datetime.now()
576
577
    dt1 = to_dt(dt1)
578
    dt2 = to_dt(dt2)
579
    if not all([dt1, dt2]):
580
        raise ValueError("No valid date or dates")
581
582
    naives = [is_timezone_naive(dt) for dt in [dt1, dt2]]
583
    if all(naives):
584
        # Both naive, no need to do anything special
585
        return relativedelta(dt2, dt1)
586
587
    elif is_timezone_naive(dt1):
588
        # From date is naive, assume same TZ as the to date
589
        tzinfo = get_tzinfo(dt2)
590
        dt1 = dt1.replace(tzinfo=tzinfo)
591
592
    elif is_timezone_naive(dt2):
593
        # To date is naive, assume same TZ as the from date
594
        tzinfo = get_tzinfo(dt1)
595
        dt2 = dt2.replace(tzinfo=tzinfo)
596
597
    return relativedelta(dt2, dt1)
598
599
600
# BBB
601
get_relative_delta = get_relativedelta
602
603
604
def timedelta_to_dict(value, default=_marker):
605
    """Converts timedelta value to dict object
606
607
    {
608
        "days": 10,
609
        "hours": 10,
610
        "minutes": 10,
611
        "seconds": 10,
612
    }
613
614
    :param value: timedelta object for conversion
615
    :type value: timedelta
616
    :param value: timedelta object for conversion
617
    :type value: timedelta
618
    :returns converted timedelta as dict or default object
619
    :rtype: dict or default object
620
    """
621
622
    if not isinstance(value, timedelta):
623
        if default is _marker:
624
            raise TypeError("%r is not supported" % type(value))
625
        logger.warn(
626
            "Invalid value passed to timedelta->dict conversion. "
627
            "Falling back to default: %s." % default)
628
        return default
629
630
    # Note timedelta keeps days and seconds a part!
631
    return {
632
        "days": value.days,
633
        "hours": value.seconds // 3600,  # hours within a day
634
        "minutes": (value.seconds % 3600) // 60,  # minutes within an hour
635
        "seconds": value.seconds % 60,  # seconds within a minute
636
    }
637
638
639
def to_timedelta(value, default=_marker):
640
    """Converts dict object w/ days, hours, minutes, seconds keys to
641
       timedelta format
642
643
    :param value: dict object for conversion
644
    :type value: dict
645
    :returns converted timedelta
646
    :rtype: timedelta
647
    """
648
649
    if isinstance(value, timedelta):
650
        return value
651
652
    if not isinstance(value, dict):
653
        if default is _marker:
654
            raise TypeError("%r is not supported" % type(value))
655
        logger.warn(
656
            "Invalid value passed to dict->timedelta conversion. "
657
            "Falling back to default: %s." % default)
658
        return default
659
660
    return timedelta(
661
        days=to_int(value.get('days', 0), 0),
662
        hours=to_int(value.get('hours', 0), 0),
663
        minutes=to_int(value.get('minutes', 0), 0),
664
        seconds=to_int(value.get('seconds', 0), 0)
665
    )
666
667
668
def is_ymd(ymd):
669
    """Returns whether the string represents a duration of time in ymd format
670
671
    :param ymd: supposedly ymd string to evaluate
672
    :type ymd: str
673
    :returns: True if a valid duration in ymd format
674
    :rtype: bool
675
    """
676
    if not is_str(ymd):
677
        return False
678
    try:
679
        to_ymd(ymd)
680
    except (TypeError, ValueError):
681
        return False
682
    return True
683
684
685
def ymd(years=0, months=0, days=0, hours=0):
686
    """Returns a string representing a time duration in ymd format
687
688
    :param years: years
689
    :type years: int
690
    :param months: months
691
    :type months: int
692
    :param days: days
693
    :type days: int
694
    :param hours: hours
695
    :type hours: int
696
    :param default: fall-back value to return as default
697
    :returns: a string that represents a duration in ymd format
698
    :rtype: str
699
    """
700
    # create the relativedelta to take shifts into account
701
    delta = relativedelta(years=years, months=months, days=days, hours=hours)
702
    delta = delta.normalized()
703
704
    # apply ymd format, with zeros omitted
705
    values = [delta.years, delta.months, delta.days, delta.hours]
706
    values = map(lambda val: str(abs(val)), values)
707
    value = filter(lambda it: int(it[0]), zip(values, "ymdh"))
708
    value = " ".join(map("".join, value))
709
710
    # return a compliant ymd
711
    return value or "0d"
712
713
714
def to_ymd(duration, with_hours=False, default=_marker):
715
    """Returns the given duration in ymd format
716
717
    If default is _marker, either a TypeError or ValueError is raised if
718
    the duration is not valid or cannot be converted to ymd format
719
720
    :param duration: duration to be converted to a ymd format
721
    :type duration: str/relativedelta
722
    :param with_hours: whether hours have to be included in the output or not
723
    :type with_hours: bool
724
    :param default: fall-back value to return as default
725
    :returns: a string that represents a duration in ymd format
726
    :rtype: str
727
    """
728
    try:
729
        delta = to_relativedelta(duration)
730
        hours = delta.hours if with_hours else 0
731
        return ymd(delta.years, delta.months, delta.days, hours)
732
    except (TypeError, ValueError) as e:
733
        if default is _marker:
734
            raise e
735
        return default
736
737
738
def to_relativedelta(duration, normalized=True):
739
    """Returns the given duration as a relativedelta
740
741
    If default is _marker, either a TypeError or ValueError is raised if
742
    the duration is not valid or cannot be converted to relativedelta format
743
744
    :param duration: duration as length of time
745
    :type delta: str/relativedelta/tuple/list
746
    :param normalized: whether relative attributes are represented as integers
747
    :type normalized: bool
748
    :returns: a duration in time represented as a relativedelta object
749
    :rtype: relativedelta
750
    """
751
    if isinstance(duration, relativedelta):
752
        return duration.normalized() if normalized else duration
753
754
    if isinstance(duration, (tuple, list)):
755
        # convert to tuple of (years, months, days, hours) to int values
756
        keys = ["years", "months", "days", "hours"]
757
        values = [to_int(value, 0) for value in duration]
758
        values = dict(zip(keys, values))
759
        delta = relativedelta(**values)
760
        return delta.normalized() if normalized else delta
761
762
    if not is_str(duration):
763
        raise TypeError("{} is not supported".format(repr(duration)))
764
765
    # to lowercase and remove leading and trailing spaces
766
    raw_ymd = duration.lower().strip()
767
768
    # extract the years, months and days
769
    matches = re.search(YMD_REGEX, raw_ymd)
770
    values = [matches.group(v) for v in "ymdh"]
771
772
    # if all values are None, assume the ymd format was not valid
773
    nones = [value is None for value in values]
774
    if all(nones):
775
        raise ValueError("Not a valid ymd: {}".format(repr(duration)))
776
777
    # replace Nones with zeros and calculate everything with a relativedelta
778
    keys = ["years", "months", "days", "hours"]
779
    values = [to_int(value, 0) for value in values]
780
    values = dict(zip(keys, values))
781
    delta = relativedelta(**values)
782
    return delta.normalized() if normalized else delta
783
784
785
def get_since_date(duration, dt=None, default=_marker):
786
    """Returns the relative date when the event started
787
788
    If dt is None, the function uses the current date time as the date from
789
    which the relative date is calculated.
790
791
    When delta is not a valid period and default value is _marker, a TypeError
792
    or ValueError is raised. Otherwise, it returns the default value converted
793
    to datetime (or None if it cannot be converted)
794
795
    :param duration: duration as length of time
796
    :type duration: str/relativedelta/tuple/list
797
    :param dt: date from which the since date has to be calculated
798
    :type dt: string/DateTime/datetime/date
799
    :param default: fall-back date-like value to return as default
800
    :returns: the calculated relative date
801
    :rtype: tuple
802
    """
803
    try:
804
        delta = to_relativedelta(duration)
805
    except (TypeError, ValueError) as e:
806
        if default is _marker:
807
            raise e
808
        return to_dt(default)
809
810
    # datetime from which the relative date has to be calculated
811
    dt = to_dt(dt) if dt else now()
812
813
    # calculate the date when everything started
814
    return dt - delta
815
816
817
def get_ymd(dt1, dt2=None, with_hours=False):
818
    """Calculates the relative delta between two dates or datetimes and
819
    returns the duration in ymd format
820
821
    If `dt2` is None, the current datetime is used.
822
823
    :param dt1: the first date/time to compare
824
    :type dt1: string/date/datetime/DateTime
825
    :param dt2: the second date/time to compare
826
    :type dt2: string/date/datetime/DateTime
827
    :returns: interval of time in ymd format (e.g. "2y4m")
828
    :rtype: str
829
    """
830
    try:
831
        delta = get_relativedelta(dt1, dt2=dt2)
832
        return to_ymd(delta, with_hours=with_hours)
833
    except (ValueError, TypeError):
834
        return None
835