#!/usr/bin/env python
# cardinal_pythonlib/datetimefunc.py
"""
===============================================================================
Original code copyright (C) 2009-2022 Rudolf Cardinal (rudolf@pobox.com).
This file is part of cardinal_pythonlib.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
===============================================================================
**Support functions for date/time.**
Note regarding **durations**:
- ``datetime.timedelta`` takes parameters from microseconds to weeks; these
are all exact.
- ``isodate.isoduration.Duration`` also includes years and months, which are
well defined but not constant. It is explicit that it has two basic
components: {year, month} and {timedelta}. Internally, it also treats years
and months as separate.
- ``pendulum.Duration`` has the same span from microseconds to years, but it
has internal assumptions (in v2.1.1+ at least) that a year is 365 days and
a month is 30 days.
"""
import datetime
import logging
from string import Formatter
from typing import Any, Optional, Union
try:
# noinspection PyPackageRequirements
from arrow import Arrow
except ImportError:
Arrow = None
try:
import dateutil.parser
except ImportError:
dateutil = None
from isodate.isoduration import parse_duration, Duration as IsodateDuration
import pendulum
from pendulum import Date, DateTime, Duration, Time
from pendulum.tz import local_timezone
from pendulum.tz.timezone import Timezone
if Arrow is not None:
PotentialDatetimeType = Union[
None, datetime.datetime, datetime.date, DateTime, str, Arrow
]
DateTimeLikeType = Union[datetime.datetime, DateTime, Arrow]
DateLikeType = Union[datetime.date, DateTime, Arrow]
else:
PotentialDatetimeType = Union[
None, datetime.datetime, datetime.date, DateTime, str
]
DateTimeLikeType = Union[datetime.datetime, DateTime]
DateLikeType = Union[datetime.date, DateTime]
log = logging.getLogger(__name__)
# =============================================================================
# Coerce things to our favourite datetime class
# ... including adding timezone information to timezone-naive objects
# =============================================================================
[docs]def coerce_to_pendulum(
x: PotentialDatetimeType, assume_local: bool = False
) -> Optional[DateTime]:
"""
Converts something to a :class:`pendulum.DateTime`.
Args:
x:
Something that may be coercible to a datetime.
assume_local:
Governs what happens if no timezone information is present in the
source object. If ``True``, assume local timezone; if ``False``,
assume UTC.
Returns:
a :class:`pendulum.DateTime`, or ``None``.
Raises:
pendulum.parsing.exceptions.ParserError: if a string fails to parse
ValueError: if no conversion possible
"""
if not x: # None and blank string
return None
if isinstance(x, DateTime):
return x
tz_if_none_specified = get_tz_local() if assume_local else get_tz_utc()
if isinstance(x, datetime.datetime):
# noinspection PyTypeChecker
return pendulum.instance(x, tz=tz_if_none_specified) # (*)
elif isinstance(x, datetime.date):
# BEWARE: datetime subclasses date. The order is crucial here.
# Can also use: type(x) is datetime.date
# noinspection PyUnresolvedReferences
midnight = DateTime.min.time()
# We use the standard python datetime.combine rather than the pendulum
# DateTime.combine so that the tz will not be ignored in the call to
# pendulum.instance
dt = datetime.datetime.combine(x, midnight)
# noinspection PyTypeChecker
return pendulum.instance(dt, tz=tz_if_none_specified) # (*)
elif isinstance(x, str):
# noinspection PyTypeChecker
return pendulum.parse(x, tz=tz_if_none_specified) # (*) # may raise
else:
raise ValueError(f"Don't know how to convert to DateTime: {x!r}")
# (*) If x already knew its timezone, it will not
# be altered; "tz" will only be applied in the absence of other info.
[docs]def coerce_to_pendulum_date(
x: PotentialDatetimeType, assume_local: bool = False, to_utc: bool = False
) -> Optional[Date]:
"""
Converts something to a :class:`pendulum.Date`.
Args:
x:
Something that may be coercible to a date.
assume_local:
Governs what happens if no timezone information is present in the
source object. If ``True``, assume local timezone; if ``False``,
assume UTC.
to_utc:
Should we return the date in UTC (e.g. London) (``True``), or the
date in the timezone of the source (``False``)? For example,
2022-02-27T23:00-05:00 (11pm in New York) is 2022-02-28T04:00Z (4am
in London). Do you want the return value to be 27 Feb
(``to_utc=False``) or 28 Feb (``to_utc=True``)?
Returns:
a :class:`pendulum.Date`, or ``None``.
Raises:
pendulum.parsing.exceptions.ParserError: if a string fails to parse
ValueError: if no conversion possible
"""
p = coerce_to_pendulum(x, assume_local=assume_local)
if p is None:
return None
elif to_utc:
return pendulum.UTC.convert(p).date()
else:
return p.date()
[docs]def pendulum_to_datetime(x: DateTime) -> datetime.datetime:
"""
Used, for example, where a database backend insists on datetime.datetime.
Compare code in :meth:`pendulum.datetime.DateTime.int_timestamp`.
"""
return datetime.datetime(
x.year,
x.month,
x.day,
x.hour,
x.minute,
x.second,
x.microsecond,
tzinfo=x.tzinfo,
)
[docs]def pendulum_to_datetime_stripping_tz(x: DateTime) -> datetime.datetime:
"""
Converts a Pendulum ``DateTime`` to a ``datetime.datetime`` that has had
timezone information stripped.
"""
return datetime.datetime(
x.year,
x.month,
x.day,
x.hour,
x.minute,
x.second,
x.microsecond,
tzinfo=None,
)
[docs]def pendulum_to_utc_datetime_without_tz(x: DateTime) -> datetime.datetime:
"""
Converts a Pendulum ``DateTime`` (which will have timezone information) to
a ``datetime.datetime`` that (a) has no timezone information, and (b) is
in UTC.
Example:
.. code-block:: python
import pendulum
from cardinal_pythonlib.datetimefunc import *
in_moscow = pendulum.parse("2018-01-01T09:00+0300") # 9am in Moscow
in_london = pendulum.UTC.convert(in_moscow) # 6am in UTC
dt_utc_from_moscow = pendulum_to_utc_datetime_without_tz(in_moscow) # 6am, no timezone info
dt_utc_from_london = pendulum_to_utc_datetime_without_tz(in_london) # 6am, no timezone info
""" # noqa: E501
pendulum_in_utc = pendulum.UTC.convert(x)
return pendulum_to_datetime_stripping_tz(pendulum_in_utc)
[docs]def pendulum_date_to_datetime_date(x: Date) -> datetime.date:
"""
Takes a :class:`pendulum.Date` and returns a :class:`datetime.date`.
Used, for example, where a database backend insists on
:class:`datetime.date`.
"""
return datetime.date(year=x.year, month=x.month, day=x.day)
[docs]def pendulum_time_to_datetime_time(x: Time) -> datetime.time:
"""
Takes a :class:`pendulum.Time` and returns a :class:`datetime.time`.
Used, for example, where a database backend insists on
:class:`datetime.time`.
"""
return datetime.time(
hour=x.hour,
minute=x.minute,
second=x.second,
microsecond=x.microsecond,
tzinfo=x.tzinfo,
)
# =============================================================================
# Format dates/times/timedelta to strings
# =============================================================================
[docs]def strfdelta(
tdelta: Union[datetime.timedelta, int, float, str],
fmt="{D:02}d {H:02}h {M:02}m {S:02}s",
inputtype="timedelta",
):
"""
Convert a ``datetime.timedelta`` object or a regular number to a custom-
formatted string, just like the ``strftime()`` method does for
``datetime.datetime`` objects.
The ``fmt`` argument allows custom formatting to be specified. Fields can
include ``seconds``, ``minutes``, ``hours``, ``days``, and ``weeks``. Each
field is optional.
Some examples:
.. code-block:: none
'{D:02}d {H:02}h {M:02}m {S:02}s' --> '05d 08h 04m 02s' (default)
'{W}w {D}d {H}:{M:02}:{S:02}' --> '4w 5d 8:04:02'
'{D:2}d {H:2}:{M:02}:{S:02}' --> ' 5d 8:04:02'
'{H}h {S}s' --> '72h 800s'
The ``inputtype`` argument allows ``tdelta`` to be a regular number,
instead of the default behaviour of treating it as a ``datetime.timedelta``
object. Valid ``inputtype`` strings:
.. code-block:: none
'timedelta', # treats input as a datetime.timedelta
's', 'seconds',
'm', 'minutes',
'h', 'hours',
'd', 'days',
'w', 'weeks'
Modified from
https://stackoverflow.com/questions/538666/python-format-timedelta-to-string
"""
# Convert tdelta to integer seconds.
if inputtype == "timedelta":
remainder = int(tdelta.total_seconds())
elif inputtype in ["s", "seconds"]:
remainder = int(tdelta)
elif inputtype in ["m", "minutes"]:
remainder = int(tdelta) * 60
elif inputtype in ["h", "hours"]:
remainder = int(tdelta) * 3600
elif inputtype in ["d", "days"]:
remainder = int(tdelta) * 86400
elif inputtype in ["w", "weeks"]:
remainder = int(tdelta) * 604800
else:
raise ValueError(f"Bad inputtype: {inputtype}")
f = Formatter()
desired_fields = [field_tuple[1] for field_tuple in f.parse(fmt)]
possible_fields = ("W", "D", "H", "M", "S")
constants = {"W": 604800, "D": 86400, "H": 3600, "M": 60, "S": 1}
values = {}
for field in possible_fields:
if field in desired_fields and field in constants:
values[field], remainder = divmod(remainder, constants[field])
return f.format(fmt, **values)
# =============================================================================
# Time zones themselves
# =============================================================================
[docs]def get_tz_local() -> Timezone: # datetime.tzinfo:
"""
Returns the local timezone, in :class:`pendulum.Timezone`` format.
(This is a subclass of :class:`datetime.tzinfo`.)
"""
return local_timezone()
[docs]def get_tz_utc() -> Timezone: # datetime.tzinfo:
"""
Returns the UTC timezone.
"""
return pendulum.UTC
# =============================================================================
# Now
# =============================================================================
[docs]def get_now_localtz_pendulum() -> DateTime:
"""
Get the time now in the local timezone, as a :class:`pendulum.DateTime`.
"""
tz = get_tz_local()
return pendulum.now().in_tz(tz)
[docs]def get_now_utc_pendulum() -> DateTime:
"""
Get the time now in the UTC timezone, as a :class:`pendulum.DateTime`.
"""
tz = get_tz_utc()
return DateTime.utcnow().in_tz(tz)
[docs]def get_now_utc_datetime() -> datetime.datetime:
"""
Get the time now in the UTC timezone, as a :class:`datetime.datetime`.
"""
return datetime.datetime.now(pendulum.UTC)
# =============================================================================
# From one timezone to another
# =============================================================================
[docs]def convert_datetime_to_utc(dt: PotentialDatetimeType) -> DateTime:
"""
Convert date/time with timezone to UTC (with UTC timezone).
"""
dt = coerce_to_pendulum(dt)
tz = get_tz_utc()
return dt.in_tz(tz)
[docs]def convert_datetime_to_local(dt: PotentialDatetimeType) -> DateTime:
"""
Convert date/time with timezone to local timezone.
"""
dt = coerce_to_pendulum(dt)
tz = get_tz_local()
return dt.in_tz(tz)
# =============================================================================
# Time differences
# =============================================================================
[docs]def get_duration_h_m(
start: Union[str, DateTime],
end: Union[str, DateTime],
default: str = "N/A",
) -> str:
"""
Calculate the time between two dates/times expressed as strings.
Args:
start: start date/time
end: end date/time
default: string value to return in case either of the inputs is
``None``
Returns:
a string that is one of
.. code-block:
'hh:mm'
'-hh:mm'
default
"""
start = coerce_to_pendulum(start)
end = coerce_to_pendulum(end)
if start is None or end is None:
return default
duration = end - start
minutes = duration.in_minutes()
(hours, minutes) = divmod(minutes, 60)
if hours < 0:
# negative... trickier
# Python's divmod does interesting things with negative numbers:
# Hours will be negative, and minutes always positive
hours += 1
minutes = 60 - minutes
return "-{}:{}".format(hours, "00" if minutes == 0 else minutes)
else:
return "{}:{}".format(hours, "00" if minutes == 0 else minutes)
[docs]def get_age(
dob: PotentialDatetimeType, when: PotentialDatetimeType, default: str = ""
) -> Union[int, str]:
"""
Age (in whole years) at a particular date, or ``default``.
Args:
dob: date of birth
when: date/time at which to calculate age
default: value to return if either input is ``None``
Returns:
age in whole years (rounded down), or ``default``
"""
dob = coerce_to_pendulum_date(dob)
when = coerce_to_pendulum_date(when)
if dob is None or when is None:
return default
return (when - dob).years
[docs]def pendulum_duration_from_timedelta(td: datetime.timedelta) -> Duration:
"""
Converts a :class:`datetime.timedelta` into a :class:`pendulum.Duration`.
"""
return Duration(seconds=td.total_seconds())
[docs]def pendulum_duration_from_isodate_duration(dur: IsodateDuration) -> Duration:
"""
Converts a :class:`isodate.isoduration.Duration` into a
:class:`pendulum.Duration`.
Both :class:`isodate.isoduration.Duration` and :class:`pendulum.Duration`
incorporate an internal representation of a :class:`datetime.timedelta`
(weeks, days, hours, minutes, seconds, milliseconds, microseconds) and
separate representations of years and months.
The :class:`isodate.isoduration.Duration` year/month elements are both of
type :class:`decimal.Decimal` -- although its ``str()`` representation
converts these silently to integer, which is quite nasty.
If you create a Pendulum Duration it normalizes within its timedelta parts,
but not across years and months. That is obviously because neither years
and months are of exactly fixed duration.
Raises:
:exc:`ValueError` if the year or month component is not an integer
"""
y = dur.years
if y.to_integral_value() != y:
raise ValueError(f"Can't handle non-integer years {y!r}")
m = dur.months
if m.to_integral_value() != m:
raise ValueError(f"Can't handle non-integer months {y!r}")
return Duration(
seconds=dur.tdelta.total_seconds(), years=int(y), months=int(m)
)
[docs]def duration_from_iso(iso_duration: str) -> Duration:
"""
Converts an ISO-8601 format duration into a :class:`pendulum.Duration`.
Raises:
- :exc:`isodate.isoerror.ISO8601Error` for bad input
- :exc:`ValueError` if the input had non-integer year or month values
- The ISO-8601 duration format is ``P[n]Y[n]M[n]DT[n]H[n]M[n]S``; see
https://en.wikipedia.org/wiki/ISO_8601#Durations.
- P = period, or duration designator, which comes first
- [n]Y = number of years
- [n]M = number of months
- [n]W = number of weeks
- [n]D = number of days
- T = time designator (precedes the time component)
- [n]H = number of hours
- [n]M = number of minutes
- [n]S = number of seconds
- ``pendulum.Duration.min`` and ``pendulum.Duration.max`` values are
``Duration(weeks=-142857142, days=-5)`` and ``Duration(weeks=142857142,
days=6)`` respectively.
- ``isodate`` supports negative durations of the format ``-P<something>``,
such as ``-PT5S`` for "minus 5 seconds", but not e.g. ``PT-5S``.
- I'm not clear if ISO-8601 itself supports negative durations. This
suggests not: https://github.com/moment/moment/issues/2408. But lots of
implementations (including to some limited extent ``isodate``) do support
this concept.
"""
duration = parse_duration(
iso_duration
) # type: Union[datetime.timedelta, IsodateDuration]
# print(f"CONVERTING: {iso_duration!r} -> {duration!r}")
if isinstance(duration, datetime.timedelta):
# It'll be a timedelta if it doesn't contain years or months.
result = pendulum_duration_from_timedelta(duration)
elif isinstance(duration, IsodateDuration):
# It'll be a IsodateDuration if it contains years or months.
result = pendulum_duration_from_isodate_duration(duration)
else:
raise AssertionError(
f"Bug in isodate.parse_duration, which returned unknown duration "
f"type: {duration!r}"
)
# log.debug("Converted {!r} -> {!r} -> {!r}".format(
# iso_duration, duration, result))
return result
[docs]def get_pendulum_duration_nonyear_nonmonth_seconds(d: Duration) -> float:
"""
Returns the number of seconds in a :class:`pendulum.Duration` that are NOT
part of its year/month representation.
Before Pendulum 2.1.1, ``d.total_seconds()`` ignored year/month components,
so this function will return the same as ``d.total_seconds()``.
However, from Pendulum 2.1.1, ``total_seconds()`` incorporates year/month
information with the assumption that a year is 365 days and a month is 30
days, which is perhaps a bit iffy. This function removes that year/month
component and returns the "remaining" seconds.
"""
y = d.years
m = d.months
assumed_seconds_for_y_m = Duration(years=y, months=m).total_seconds()
# ... for old Pendulum versions, that will be zero
# ... for new Pendulum versions, that will be the number of seconds
# for that many years/months according to Pendulum's assumptions
return d.total_seconds() - assumed_seconds_for_y_m
[docs]def duration_to_iso(
d: Duration,
permit_years_months: bool = True,
minus_sign_at_front: bool = True,
) -> str:
"""
Converts a :class:`pendulum.Duration` into an ISO-8601 formatted string.
Args:
d:
the duration
permit_years_months:
- if ``False``, durations with non-zero year or month components
will raise a :exc:`ValueError`; otherwise, the ISO format will
always be ``PT<seconds>S``.
- if ``True``, year/month components will be accepted, and the
ISO format will be ``P<years>Y<months>MT<seconds>S``.
minus_sign_at_front:
Applies to negative durations, which probably aren't part of the
ISO standard.
- if ``True``, the format ``-P<positive_duration>`` is used, i.e.
with a minus sign at the front and individual components
positive.
- if ``False``, the format ``PT-<positive_seconds>S`` (etc.) is
used, i.e. with a minus sign for each component. This format is
not re-parsed successfully by ``isodate`` and will therefore
fail :func:`duration_from_iso`.
Raises:
:exc:`ValueError` for bad input
The maximum length of the resulting string (see test code below) is:
- 21 if years/months are not permitted;
- ill-defined if years/months are permitted, but 29 for much more than is
realistic (negative, 1000 years, 11 months, and the maximum length for
seconds/microseconds).
"""
prefix = ""
negative = d < Duration()
if negative and minus_sign_at_front:
prefix = "-"
d = -d
if permit_years_months:
# Watch out here. Before Pendulum 2.1.1, d.total_seconds() ignored
# year/month components. But from Pendulum 2.1.1, it incorporates
# year/month information with the assumption that a year is 365 days
# and a month is 30 days, which is perhaps a bit iffy.
y = d.years
m = d.months
s = get_pendulum_duration_nonyear_nonmonth_seconds(d)
return prefix + f"P{y}Y{m}MT{s}S"
else:
if d.years != 0:
raise ValueError(f"Duration has non-zero years: {d.years!r}")
if d.months != 0:
raise ValueError(f"Duration has non-zero months: {d.months!r}")
# At this point, it's easy. As there is no year/month component, (a) we
# are confident we have an exact interval that is always validly
# convertable to seconds, and (b) Pendulum versions before 2.1.1 and
# from 2.1.1 onwards will give us the right number of seconds.
s = d.total_seconds()
return prefix + f"PT{s}S"
# =============================================================================
# Other manipulations
# =============================================================================
[docs]def truncate_date_to_first_of_month(
dt: Optional[DateLikeType],
) -> Optional[DateLikeType]:
"""
Change the day to the first of the month.
"""
if dt is None:
return None
return dt.replace(day=1)
# =============================================================================
# Older date/time functions for native Python datetime objects
# =============================================================================
[docs]def get_now_utc_notz_datetime() -> datetime.datetime:
"""
Get the UTC time now, but with no timezone information,
in :class:`datetime.datetime` format.
"""
now = datetime.datetime.utcnow()
return now.replace(tzinfo=None)
[docs]def coerce_to_datetime(x: Any) -> Optional[datetime.datetime]:
"""
Ensure an object is a :class:`datetime.datetime`, or coerce to one, or
raise :exc:`ValueError` or :exc:`OverflowError` (as per
https://dateutil.readthedocs.org/en/latest/parser.html).
"""
if x is None:
return None
elif isinstance(x, DateTime):
return pendulum_to_datetime(x)
elif isinstance(x, datetime.datetime):
return x
elif isinstance(x, datetime.date):
return datetime.datetime(x.year, x.month, x.day)
else:
return dateutil.parser.parse(x) # may raise
[docs]def coerce_to_date(
x: Any, assume_local: bool = False, to_utc: bool = False
) -> Optional[datetime.date]:
"""
Ensure an object is a :class:`datetime.date`, or coerce to one, or
raise :exc:`ValueError` or :exc:`OverflowError` (as per
https://dateutil.readthedocs.org/en/latest/parser.html).
See also :func:`coerce_to_pendulum_date`, noting that
:class:`pendulum.Date` is a subclass of :class:`datetime.date`.
"""
pd = coerce_to_pendulum_date(x, assume_local=assume_local, to_utc=to_utc)
if pd is None:
return None
return pendulum_date_to_datetime_date(pd)