Coverage for /home/martinb/.local/share/virtualenvs/camcops/lib/python3.6/site-packages/cardinal_pythonlib/datetimefunc.py : 35%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#!/usr/bin/env python
2# cardinal_pythonlib/datetimefunc.py
4"""
5===============================================================================
7 Original code copyright (C) 2009-2021 Rudolf Cardinal (rudolf@pobox.com).
9 This file is part of cardinal_pythonlib.
11 Licensed under the Apache License, Version 2.0 (the "License");
12 you may not use this file except in compliance with the License.
13 You may obtain a copy of the License at
15 https://www.apache.org/licenses/LICENSE-2.0
17 Unless required by applicable law or agreed to in writing, software
18 distributed under the License is distributed on an "AS IS" BASIS,
19 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20 See the License for the specific language governing permissions and
21 limitations under the License.
23===============================================================================
25**Support functions for date/time.**
26"""
28import datetime
29import logging
30import sys
31from string import Formatter
32from typing import Any, Optional, Union
33import unittest
35try:
36 from arrow import Arrow
37except ImportError:
38 Arrow = None
40try:
41 import dateutil.parser
42except ImportError:
43 dateutil = None
45from isodate.isoduration import parse_duration, Duration as IsodateDuration
46import pendulum
47from pendulum import Date, DateTime, Duration, Time
48from pendulum.tz import local_timezone
49from pendulum.tz.timezone import Timezone
51from cardinal_pythonlib.logs import main_only_quicksetup_rootlogger
53PotentialDatetimeType = Union[None, datetime.datetime, datetime.date,
54 DateTime, str, Arrow]
55DateTimeLikeType = Union[datetime.datetime, DateTime, Arrow]
56DateLikeType = Union[datetime.date, DateTime, Arrow]
58log = logging.getLogger(__name__)
61# =============================================================================
62# Coerce things to our favourite datetime class
63# ... including adding timezone information to timezone-naive objects
64# =============================================================================
66def coerce_to_pendulum(x: PotentialDatetimeType,
67 assume_local: bool = False) -> Optional[DateTime]:
68 """
69 Converts something to a :class:`pendulum.DateTime`.
71 Args:
72 x: something that may be coercible to a datetime
73 assume_local: if ``True``, assume local timezone; if ``False``, assume
74 UTC
76 Returns:
77 a :class:`pendulum.DateTime`, or ``None``.
79 Raises:
80 pendulum.parsing.exceptions.ParserError: if a string fails to parse
81 ValueError: if no conversion possible
82 """
83 if not x: # None and blank string
84 return None
85 if isinstance(x, DateTime):
86 return x
87 tz = get_tz_local() if assume_local else get_tz_utc()
88 if isinstance(x, datetime.datetime):
89 # noinspection PyTypeChecker
90 return pendulum.instance(x, tz=tz) # (*)
91 elif isinstance(x, datetime.date):
92 # BEWARE: datetime subclasses date. The order is crucial here.
93 # Can also use: type(x) is datetime.date
94 # noinspection PyUnresolvedReferences
95 midnight = DateTime.min.time()
96 # We use the standard python datetime.combine rather than the pendulum
97 # DateTime.combine so that the tz will not be ignored in the call to
98 # pendulum.instance
99 dt = datetime.datetime.combine(x, midnight)
100 # noinspection PyTypeChecker
101 return pendulum.instance(dt, tz=tz) # (*)
102 elif isinstance(x, str):
103 # noinspection PyTypeChecker
104 return pendulum.parse(x, tz=tz) # (*) # may raise
105 else:
106 raise ValueError(f"Don't know how to convert to DateTime: {x!r}")
107 # (*) If x already knew its timezone, it will not
108 # be altered; "tz" will only be applied in the absence of other info.
111def coerce_to_pendulum_date(x: PotentialDatetimeType,
112 assume_local: bool = False) -> Optional[Date]:
113 """
114 Converts something to a :class:`pendulum.Date`.
116 Args:
117 x: something that may be coercible to a date
118 assume_local: if ``True``, assume local timezone; if ``False``, assume
119 UTC
121 Returns:
122 a :class:`pendulum.Date`, or ``None``.
124 Raises:
125 pendulum.parsing.exceptions.ParserError: if a string fails to parse
126 ValueError: if no conversion possible
127 """
128 p = coerce_to_pendulum(x, assume_local=assume_local)
129 return None if p is None else p.date()
132def pendulum_to_datetime(x: DateTime) -> datetime.datetime:
133 """
134 Used, for example, where a database backend insists on datetime.datetime.
136 Compare code in :meth:`pendulum.datetime.DateTime.int_timestamp`.
137 """
138 return datetime.datetime(
139 x.year, x.month, x.day,
140 x.hour, x.minute, x.second, x.microsecond,
141 tzinfo=x.tzinfo
142 )
145def pendulum_to_datetime_stripping_tz(x: DateTime) -> datetime.datetime:
146 """
147 Converts a Pendulum ``DateTime`` to a ``datetime.datetime`` that has had
148 timezone information stripped.
149 """
150 return datetime.datetime(
151 x.year, x.month, x.day,
152 x.hour, x.minute, x.second, x.microsecond,
153 tzinfo=None
154 )
157def pendulum_to_utc_datetime_without_tz(x: DateTime) -> datetime.datetime:
158 """
159 Converts a Pendulum ``DateTime`` (which will have timezone information) to
160 a ``datetime.datetime`` that (a) has no timezone information, and (b) is
161 in UTC.
163 Example:
165 .. code-block:: python
167 import pendulum
168 from cardinal_pythonlib.datetimefunc import *
169 in_moscow = pendulum.parse("2018-01-01T09:00+0300") # 9am in Moscow
170 in_london = pendulum.UTC.convert(in_moscow) # 6am in UTC
171 dt_utc_from_moscow = pendulum_to_utc_datetime_without_tz(in_moscow) # 6am, no timezone info
172 dt_utc_from_london = pendulum_to_utc_datetime_without_tz(in_london) # 6am, no timezone info
174 """ # noqa
175 pendulum_in_utc = pendulum.UTC.convert(x)
176 return pendulum_to_datetime_stripping_tz(pendulum_in_utc)
179def pendulum_date_to_datetime_date(x: Date) -> datetime.date:
180 """
181 Takes a :class:`pendulum.Date` and returns a :class:`datetime.date`.
182 Used, for example, where a database backend insists on
183 :class:`datetime.date`.
184 """
185 return datetime.date(year=x.year, month=x.month, day=x.day)
188def pendulum_time_to_datetime_time(x: Time) -> datetime.time:
189 """
190 Takes a :class:`pendulum.Time` and returns a :class:`datetime.time`.
191 Used, for example, where a database backend insists on
192 :class:`datetime.time`.
193 """
194 return datetime.time(
195 hour=x.hour, minute=x.minute, second=x.second,
196 microsecond=x.microsecond,
197 tzinfo=x.tzinfo
198 )
201# =============================================================================
202# Format dates/times/timedelta to strings
203# =============================================================================
205def format_datetime(d: PotentialDatetimeType,
206 fmt: str,
207 default: str = None) -> Optional[str]:
208 """
209 Format a datetime with a ``strftime`` format specification string, or
210 return ``default`` if the input is ``None``.
211 """
212 d = coerce_to_pendulum(d)
213 if d is None:
214 return default
215 return d.strftime(fmt)
218def strfdelta(tdelta: Union[datetime.timedelta, int, float, str],
219 fmt='{D:02}d {H:02}h {M:02}m {S:02}s',
220 inputtype='timedelta'):
221 """
222 Convert a ``datetime.timedelta`` object or a regular number to a custom-
223 formatted string, just like the ``strftime()`` method does for
224 ``datetime.datetime`` objects.
226 The ``fmt`` argument allows custom formatting to be specified. Fields can
227 include ``seconds``, ``minutes``, ``hours``, ``days``, and ``weeks``. Each
228 field is optional.
230 Some examples:
232 .. code-block:: none
234 '{D:02}d {H:02}h {M:02}m {S:02}s' --> '05d 08h 04m 02s' (default)
235 '{W}w {D}d {H}:{M:02}:{S:02}' --> '4w 5d 8:04:02'
236 '{D:2}d {H:2}:{M:02}:{S:02}' --> ' 5d 8:04:02'
237 '{H}h {S}s' --> '72h 800s'
239 The ``inputtype`` argument allows ``tdelta`` to be a regular number,
240 instead of the default behaviour of treating it as a ``datetime.timedelta``
241 object. Valid ``inputtype`` strings:
243 .. code-block:: none
245 'timedelta', # treats input as a datetime.timedelta
246 's', 'seconds',
247 'm', 'minutes',
248 'h', 'hours',
249 'd', 'days',
250 'w', 'weeks'
252 Modified from
253 https://stackoverflow.com/questions/538666/python-format-timedelta-to-string
254 """ # noqa
256 # Convert tdelta to integer seconds.
257 if inputtype == 'timedelta':
258 remainder = int(tdelta.total_seconds())
259 elif inputtype in ['s', 'seconds']:
260 remainder = int(tdelta)
261 elif inputtype in ['m', 'minutes']:
262 remainder = int(tdelta) * 60
263 elif inputtype in ['h', 'hours']:
264 remainder = int(tdelta) * 3600
265 elif inputtype in ['d', 'days']:
266 remainder = int(tdelta) * 86400
267 elif inputtype in ['w', 'weeks']:
268 remainder = int(tdelta) * 604800
269 else:
270 raise ValueError(f"Bad inputtype: {inputtype}")
272 f = Formatter()
273 desired_fields = [field_tuple[1] for field_tuple in f.parse(fmt)]
274 possible_fields = ('W', 'D', 'H', 'M', 'S')
275 constants = {'W': 604800, 'D': 86400, 'H': 3600, 'M': 60, 'S': 1}
276 values = {}
277 for field in possible_fields:
278 if field in desired_fields and field in constants:
279 values[field], remainder = divmod(remainder, constants[field])
280 return f.format(fmt, **values)
283# =============================================================================
284# Time zones themselves
285# =============================================================================
287def get_tz_local() -> Timezone: # datetime.tzinfo:
288 """
289 Returns the local timezone, in :class:`pendulum.Timezone`` format.
290 (This is a subclass of :class:`datetime.tzinfo`.)
291 """
292 return local_timezone()
295def get_tz_utc() -> Timezone: # datetime.tzinfo:
296 """
297 Returns the UTC timezone.
298 """
299 return pendulum.UTC
302# =============================================================================
303# Now
304# =============================================================================
306def get_now_localtz_pendulum() -> DateTime:
307 """
308 Get the time now in the local timezone, as a :class:`pendulum.DateTime`.
309 """
310 tz = get_tz_local()
311 return pendulum.now().in_tz(tz)
314def get_now_utc_pendulum() -> DateTime:
315 """
316 Get the time now in the UTC timezone, as a :class:`pendulum.DateTime`.
317 """
318 tz = get_tz_utc()
319 return DateTime.utcnow().in_tz(tz)
322def get_now_utc_datetime() -> datetime.datetime:
323 """
324 Get the time now in the UTC timezone, as a :class:`datetime.datetime`.
325 """
326 return datetime.datetime.now(pendulum.UTC)
329# =============================================================================
330# From one timezone to another
331# =============================================================================
333def convert_datetime_to_utc(dt: PotentialDatetimeType) -> DateTime:
334 """
335 Convert date/time with timezone to UTC (with UTC timezone).
336 """
337 dt = coerce_to_pendulum(dt)
338 tz = get_tz_utc()
339 return dt.in_tz(tz)
342def convert_datetime_to_local(dt: PotentialDatetimeType) -> DateTime:
343 """
344 Convert date/time with timezone to local timezone.
345 """
346 dt = coerce_to_pendulum(dt)
347 tz = get_tz_local()
348 return dt.in_tz(tz)
351# =============================================================================
352# Time differences
353# =============================================================================
355def get_duration_h_m(start: Union[str, DateTime],
356 end: Union[str, DateTime],
357 default: str = "N/A") -> str:
358 """
359 Calculate the time between two dates/times expressed as strings.
361 Args:
362 start: start date/time
363 end: end date/time
364 default: string value to return in case either of the inputs is
365 ``None``
367 Returns:
368 a string that is one of
370 .. code-block:
372 'hh:mm'
373 '-hh:mm'
374 default
376 """
377 start = coerce_to_pendulum(start)
378 end = coerce_to_pendulum(end)
379 if start is None or end is None:
380 return default
381 duration = end - start
382 minutes = duration.in_minutes()
383 (hours, minutes) = divmod(minutes, 60)
384 if hours < 0:
385 # negative... trickier
386 # Python's divmod does interesting things with negative numbers:
387 # Hours will be negative, and minutes always positive
388 hours += 1
389 minutes = 60 - minutes
390 return "-{}:{}".format(hours, "00" if minutes == 0 else minutes)
391 else:
392 return "{}:{}".format(hours, "00" if minutes == 0 else minutes)
395def get_age(dob: PotentialDatetimeType,
396 when: PotentialDatetimeType,
397 default: str = "") -> Union[int, str]:
398 """
399 Age (in whole years) at a particular date, or ``default``.
401 Args:
402 dob: date of birth
403 when: date/time at which to calculate age
404 default: value to return if either input is ``None``
406 Returns:
407 age in whole years (rounded down), or ``default``
409 """
410 dob = coerce_to_pendulum_date(dob)
411 when = coerce_to_pendulum_date(when)
412 if dob is None or when is None:
413 return default
414 return (when - dob).years
417def pendulum_duration_from_timedelta(td: datetime.timedelta) -> Duration:
418 """
419 Converts a :class:`datetime.timedelta` into a :class:`pendulum.Duration`.
421 .. code-block:: python
423 from cardinal_pythonlib.datetimefunc import pendulum_duration_from_timedelta
424 from datetime import timedelta
425 from pendulum import Duration
427 td1 = timedelta(days=5, hours=3, minutes=2, microseconds=5)
428 d1 = pendulum_duration_from_timedelta(td1)
430 td2 = timedelta(microseconds=5010293989234)
431 d2 = pendulum_duration_from_timedelta(td2)
433 td3 = timedelta(days=5000)
434 d3 = pendulum_duration_from_timedelta(td3)
435 """ # noqa
436 return Duration(seconds=td.total_seconds())
439def pendulum_duration_from_isodate_duration(dur: IsodateDuration) -> Duration:
440 """
441 Converts a :class:`isodate.isoduration.Duration` into a
442 :class:`pendulum.Duration`.
444 Both :class:`isodate.isoduration.Duration` and :class:`pendulum.Duration`
445 incorporate an internal representation of a :class:`datetime.timedelta`
446 (weeks, days, hours, minutes, seconds, milliseconds, microseconds) and
447 separate representations of years and months.
449 The :class:`isodate.isoduration.Duration` year/month elements are both of
450 type :class:`decimal.Decimal` -- although its ``str()`` representation
451 converts these silently to integer, which is quite nasty.
453 If you create a Pendulum Duration it normalizes within its timedelta parts,
454 but not across years and months. That is obviously because neither years
455 and months are of exactly fixed duration.
457 Raises:
459 :exc:`ValueError` if the year or month component is not an integer
461 .. code-block:: python
463 from cardinal_pythonlib.datetimefunc import pendulum_duration_from_isodate_duration
464 from isodate.isoduration import Duration as IsodateDuration
465 from pendulum import Duration as PendulumDuration
467 td1 = IsodateDuration(days=5, hours=3, minutes=2, microseconds=5)
468 d1 = pendulum_duration_from_isodate_duration(td1)
470 td2 = IsodateDuration(microseconds=5010293989234)
471 d2 = pendulum_duration_from_isodate_duration(td2)
473 td3 = IsodateDuration(days=5000)
474 d3 = pendulum_duration_from_isodate_duration(td3)
476 td4 = IsodateDuration(days=5000, years=5, months=2)
477 d4 = pendulum_duration_from_isodate_duration(td4)
478 # ... doesn't normalize across years/months; see explanation above
480 td5 = IsodateDuration(days=5000, years=5.1, months=2.2)
481 d5 = pendulum_duration_from_isodate_duration(td5) # will raise
482 """ # noqa
483 y = dur.years
484 if y.to_integral_value() != y:
485 raise ValueError(f"Can't handle non-integer years {y!r}")
486 m = dur.months
487 if m.to_integral_value() != m:
488 raise ValueError(f"Can't handle non-integer months {y!r}")
489 return Duration(seconds=dur.tdelta.total_seconds(),
490 years=int(y),
491 months=int(m))
494def duration_from_iso(iso_duration: str) -> Duration:
495 """
496 Converts an ISO-8601 format duration into a :class:`pendulum.Duration`.
498 Raises:
500 - :exc:`isodate.isoerror.ISO8601Error` for bad input
501 - :exc:`ValueError` if the input had non-integer year or month values
503 - The ISO-8601 duration format is ``P[n]Y[n]M[n]DT[n]H[n]M[n]S``; see
504 https://en.wikipedia.org/wiki/ISO_8601#Durations.
506 - ``pendulum.Duration.min`` and ``pendulum.Duration.max`` values are
507 ``Duration(weeks=-142857142, days=-5)`` and ``Duration(weeks=142857142,
508 days=6)`` respectively.
510 - ``isodate`` supports negative durations of the format ``-P<something>``,
511 such as ``-PT5S`` for "minus 5 seconds", but not e.g. ``PT-5S``.
513 - I'm not clear if ISO-8601 itself supports negative durations. This
514 suggests not: https://github.com/moment/moment/issues/2408. But lots of
515 implementations (including to some limited extent ``isodate``) do support
516 this concept.
518 .. code-block:: python
520 from pendulum import DateTime
521 from cardinal_pythonlib.datetimefunc import duration_from_iso
522 from cardinal_pythonlib.logs import main_only_quicksetup_rootlogger
523 main_only_quicksetup_rootlogger()
525 d1 = duration_from_iso("P5W")
526 d2 = duration_from_iso("P3Y1DT3H1M2S")
527 d3 = duration_from_iso("P7000D")
528 d4 = duration_from_iso("P1Y7000D")
529 d5 = duration_from_iso("PT10053.22S")
530 d6 = duration_from_iso("PT-10053.22S") # raises ISO8601 error
531 d7 = duration_from_iso("-PT5S")
532 d7 = duration_from_iso("PT-5S") # raises ISO8601 error
533 now = DateTime.now()
534 print(now)
535 print(now + d1)
536 print(now + d2)
537 print(now + d3)
538 print(now + d4)
540 """
541 duration = parse_duration(iso_duration) # type: Union[datetime.timedelta, IsodateDuration] # noqa
542 if isinstance(duration, datetime.timedelta):
543 result = pendulum_duration_from_timedelta(duration)
544 elif isinstance(duration, IsodateDuration):
545 result = pendulum_duration_from_isodate_duration(duration)
546 else:
547 raise AssertionError(
548 f"Bug in isodate.parse_duration, which returned unknown duration "
549 f"type: {duration!r}")
550 # log.debug("Converted {!r} -> {!r} -> {!r}".format(
551 # iso_duration, duration, result))
552 return result
555def duration_to_iso(d: Duration, permit_years_months: bool = True,
556 minus_sign_at_front: bool = True) -> str:
557 """
558 Converts a :class:`pendulum.Duration` into an ISO-8601 formatted string.
560 Args:
561 d:
562 the duration
564 permit_years_months:
565 - if ``False``, durations with non-zero year or month components
566 will raise a :exc:`ValueError`; otherwise, the ISO format will
567 always be ``PT<seconds>S``.
568 - if ``True``, year/month components will be accepted, and the
569 ISO format will be ``P<years>Y<months>MT<seconds>S``.
571 minus_sign_at_front:
572 Applies to negative durations, which probably aren't part of the
573 ISO standard.
575 - if ``True``, the format ``-P<positive_duration>`` is used, i.e.
576 with a minus sign at the front and individual components
577 positive.
578 - if ``False``, the format ``PT-<positive_seconds>S`` (etc.) is
579 used, i.e. with a minus sign for each component. This format is
580 not re-parsed successfully by ``isodate`` and will therefore
581 fail :func:`duration_from_iso`.
583 Raises:
585 :exc:`ValueError` for bad input
587 The maximum length of the resulting string (see test code below) is:
589 - 21 if years/months are not permitted;
590 - ill-defined if years/months are permitted, but 29 for much more than is
591 realistic (negative, 1000 years, 11 months, and the maximum length for
592 seconds/microseconds).
594 .. code-block:: python
596 from pendulum import DateTime, Duration
597 from cardinal_pythonlib.datetimefunc import duration_from_iso, duration_to_iso
598 from cardinal_pythonlib.logs import main_only_quicksetup_rootlogger
599 main_only_quicksetup_rootlogger()
601 d1 = duration_from_iso("P5W")
602 d2 = duration_from_iso("P3Y1DT3H1M2S")
603 d3 = duration_from_iso("P7000D")
604 d4 = duration_from_iso("P1Y7000D")
605 d5 = duration_from_iso("PT10053.22S")
606 print(duration_to_iso(d1))
607 print(duration_to_iso(d2))
608 print(duration_to_iso(d3))
609 print(duration_to_iso(d4))
610 print(duration_to_iso(d5))
611 assert d1 == duration_from_iso(duration_to_iso(d1))
612 assert d2 == duration_from_iso(duration_to_iso(d2))
613 assert d3 == duration_from_iso(duration_to_iso(d3))
614 assert d4 == duration_from_iso(duration_to_iso(d4))
615 assert d5 == duration_from_iso(duration_to_iso(d5))
616 strmin = duration_to_iso(Duration.min) # '-P0Y0MT86399999913600.0S'
617 strmax = duration_to_iso(Duration.max) # 'P0Y0MT86400000000000.0S'
618 duration_from_iso(strmin) # raises ISO8601Error from isodate package (bug?)
619 duration_from_iso(strmax) # raises OverflowError from isodate package
620 print(strmin) # P0Y0MT-86399999913600.0S
621 print(strmax) # P0Y0MT86400000000000.0S
622 d6 = duration_from_iso("P100Y999MT86400000000000.0S") # OverflowError
623 d7 = duration_from_iso("P0Y1MT86400000000000.0S") # OverflowError
624 d8 = duration_from_iso("P0Y1111111111111111MT76400000000000.0S") # accepted!
625 # ... length e.g. 38; see len(duration_to_iso(d8))
627 # So the maximum string length may be ill-defined if years/months are
628 # permitted (since Python 3 integers are unbounded; try 99 ** 10000).
629 # But otherwise:
631 d9longest = duration_from_iso("-P0Y0MT10000000000000.000009S")
632 d10toolong = duration_from_iso("-P0Y0MT100000000000000.000009S") # fails, too many days
633 assert d9longest == duration_from_iso(duration_to_iso(d9longest))
635 d11longest_with_us = duration_from_iso("-P0Y0MT1000000000.000009S") # microseconds correct
636 d12toolong_rounds_us = duration_from_iso("-P0Y0MT10000000000.000009S") # error in microseconds
637 d13toolong_drops_us = duration_from_iso("-P0Y0MT10000000000000.000009S") # drops microseconds (within datetime.timedelta)
638 d14toolong_parse_fails = duration_from_iso("-P0Y0MT100000000000000.000009S") # fails, too many days
639 assert d11longest_with_us == duration_from_iso(duration_to_iso(d11longest_with_us))
640 assert d12toolong_rounds_us == duration_from_iso(duration_to_iso(d12toolong_rounds_us))
641 assert d13toolong_drops_us == duration_from_iso(duration_to_iso(d13toolong_drops_us))
643 longest_without_ym = duration_to_iso(d11longest_with_us, permit_years_months=False)
644 print(longest_without_ym) # -PT1000000000.000009S
645 print(len(longest_without_ym)) # 21
647 d15longest_realistic_with_ym_us = duration_from_iso("-P1000Y11MT1000000000.000009S") # microseconds correct
648 longest_realistic_with_ym = duration_to_iso(d15longest_realistic_with_ym_us)
649 print(longest_realistic_with_ym) # -P1000Y11MT1000000000.000009S
650 print(len(longest_realistic_with_ym)) # 29
652 # Now, double-check how the Pendulum classes handle year/month
653 # calculations:
654 basedate1 = DateTime(year=2000, month=1, day=1) # 2000-01-01
655 print(basedate1 + Duration(years=1)) # 2001-01-01; OK
656 print(basedate1 + Duration(months=1)) # 2000-02-01; OK
657 basedate2 = DateTime(year=2004, month=2, day=1) # 2004-02-01; leap year
658 print(basedate2 + Duration(years=1)) # 2005-01-01; OK
659 print(basedate2 + Duration(months=1)) # 2000-03-01; OK
660 print(basedate2 + Duration(months=1, days=1)) # 2000-03-02; OK
662 """ # noqa
663 prefix = ""
664 negative = d < Duration()
665 if negative and minus_sign_at_front:
666 prefix = "-"
667 d = -d
668 if permit_years_months:
669 return prefix + "P{years}Y{months}MT{seconds}S".format(
670 years=d.years,
671 months=d.months,
672 seconds=d.total_seconds(), # float
673 )
674 else:
675 if d.years != 0:
676 raise ValueError(
677 f"Duration has non-zero years: {d.years!r}")
678 if d.months != 0:
679 raise ValueError(
680 f"Duration has non-zero months: {d.months!r}")
681 return prefix + f"PT{d.total_seconds()}S"
684# =============================================================================
685# Other manipulations
686# =============================================================================
688def truncate_date_to_first_of_month(
689 dt: Optional[DateLikeType]) -> Optional[DateLikeType]:
690 """
691 Change the day to the first of the month.
692 """
693 if dt is None:
694 return None
695 return dt.replace(day=1)
698# =============================================================================
699# Older date/time functions for native Python datetime objects
700# =============================================================================
702def get_now_utc_notz_datetime() -> datetime.datetime:
703 """
704 Get the UTC time now, but with no timezone information,
705 in :class:`datetime.datetime` format.
706 """
707 now = datetime.datetime.utcnow()
708 return now.replace(tzinfo=None)
711def coerce_to_datetime(x: Any) -> Optional[datetime.datetime]:
712 """
713 Ensure an object is a :class:`datetime.datetime`, or coerce to one, or
714 raise :exc:`ValueError` or :exc:`OverflowError` (as per
715 https://dateutil.readthedocs.org/en/latest/parser.html).
716 """
717 if x is None:
718 return None
719 elif isinstance(x, DateTime):
720 return pendulum_to_datetime(x)
721 elif isinstance(x, datetime.datetime):
722 return x
723 elif isinstance(x, datetime.date):
724 return datetime.datetime(x.year, x.month, x.day)
725 else:
726 return dateutil.parser.parse(x) # may raise
729# =============================================================================
730# Unit testing
731# =============================================================================
733class TestCoerceToPendulum(unittest.TestCase):
734 def test_returns_none_if_falsey(self) -> None:
735 self.assertIsNone(coerce_to_pendulum(''))
737 def test_returns_input_if_pendulum_datetime(self) -> None:
738 datetime_in = DateTime.now()
739 datetime_out = coerce_to_pendulum(datetime_in)
741 self.assertIs(datetime_in, datetime_out)
743 def test_converts_python_datetime_with_local_tz(self) -> None:
744 datetime_in = datetime.datetime(2020, 6, 15, hour=15, minute=42)
745 datetime_out = coerce_to_pendulum(datetime_in, assume_local=True)
747 self.assertIsInstance(datetime_out, DateTime)
748 self.assertTrue(datetime_out.is_local())
750 def test_converts_python_datetime_with_utc_tz(self) -> None:
751 datetime_in = datetime.datetime(2020, 6, 15, hour=15, minute=42)
752 datetime_out = coerce_to_pendulum(datetime_in)
754 self.assertIsInstance(datetime_out, DateTime)
755 self.assertTrue(datetime_out.is_utc())
757 def test_converts_python_datetime_with_tz(self) -> None:
758 utc_offset = datetime.timedelta(hours=5, minutes=30)
759 datetime_in = datetime.datetime(
760 2020, 6, 15, hour=15, minute=42,
761 tzinfo=datetime.timezone(utc_offset)
762 )
763 datetime_out = coerce_to_pendulum(datetime_in)
765 self.assertIsInstance(datetime_out, DateTime)
766 self.assertEqual(datetime_out.utcoffset(), utc_offset)
768 def test_converts_python_date_with_local_tz(self) -> None:
769 date_in = datetime.date(2020, 6, 15)
770 datetime_out = coerce_to_pendulum(date_in, assume_local=True)
772 self.assertIsInstance(datetime_out, DateTime)
773 self.assertTrue(datetime_out.is_local())
775 def test_converts_python_date_with_utc_tz(self) -> None:
776 date_in = datetime.date(2020, 6, 15)
777 datetime_out = coerce_to_pendulum(date_in)
779 self.assertIsInstance(datetime_out, DateTime)
780 self.assertTrue(datetime_out.is_utc())
782 def test_parses_datetime_string_with_tz(self) -> None:
783 datetime_in = "2020-06-15T14:52:36+05:30"
784 datetime_out = coerce_to_pendulum(datetime_in)
786 self.assertIsInstance(datetime_out, DateTime)
787 self.assertEqual(
788 datetime_out.utcoffset(),
789 datetime.timedelta(hours=5, minutes=30)
790 )
792 def test_parses_datetime_string_with_utc_tz(self) -> None:
793 datetime_in = "2020-06-15T14:52:36"
794 datetime_out = coerce_to_pendulum(datetime_in)
796 self.assertIsInstance(datetime_out, DateTime)
797 self.assertTrue(datetime_out.is_utc())
799 def test_parses_datetime_string_with_local_tz(self) -> None:
800 datetime_in = "2020-06-15T14:52:36"
801 datetime_out = coerce_to_pendulum(datetime_in, assume_local=True)
803 self.assertIsInstance(datetime_out, DateTime)
804 self.assertTrue(datetime_out.is_local())
806 def test_raises_if_type_invalid(self) -> None:
807 with self.assertRaises(ValueError) as cm:
808 # noinspection PyTypeChecker
809 coerce_to_pendulum(12345)
811 self.assertIn(
812 "Don't know how to convert to DateTime", str(cm.exception)
813 )
816# =============================================================================
817# main
818# =============================================================================
820if __name__ == "__main__":
821 main_only_quicksetup_rootlogger(level=logging.DEBUG)
822 log.info("Running unit tests")
823 unittest.main(argv=[sys.argv[0]])
824 sys.exit(0)