Files
myusuf3-delorean/delorean/dates.py
T
dualfroz 518ab0a093 fix: make Delorean hashable by defining __hash__
Defining __eq__ without __hash__ sets __hash__ to None in Python 3, so
Delorean instances were unhashable and could not be used in sets or as
dict keys (unlike datetime). Hash on self.epoch, the same value __eq__
compares, so equal instances hash equal.
2026-09-04 09:39:40 +02:00

782 lines
23 KiB
Python

import sys
from datetime import datetime, timedelta, timezone
from functools import partial, update_wrapper
from operator import index
import humanize
from babel.dates import format_datetime
from dateutil.relativedelta import relativedelta
from tzlocal import get_localzone
from .exceptions import DeloreanInvalidDatetime, DeloreanInvalidTimezone
from .timezones import timezone as get_timezone
from .timezones import utc
def get_total_second(td):
"""
This method takes a timedelta and return the number of seconds it
represents with the resolution of 10**6
"""
return (td.microseconds + (td.seconds + td.days * 24 * 3600) * 1e6) / 1e6
def is_datetime_naive(dt):
"""
This method returns true if the datetime is naive else returns false
"""
if dt.tzinfo is None:
return True
else:
return False
def is_datetime_instance(dt):
if dt is None:
return
if not isinstance(dt, datetime):
raise ValueError("Please provide a datetime instance to Delorean")
def _move_datetime(dt, direction, delta):
"""
Move datetime given delta by given direction
"""
if direction == "next":
dt = dt + delta
elif direction == "last":
dt = dt - delta
else:
pass
# raise some delorean error here
return dt
def move_datetime_day(dt, direction, num_shifts):
delta = relativedelta(days=+num_shifts)
return _move_datetime(dt, direction, delta)
def move_datetime_namedday(dt, direction, unit):
TOTAL_DAYS = 7
days = {
"monday": 1,
"tuesday": 2,
"wednesday": 3,
"thursday": 4,
"friday": 5,
"saturday": 6,
"sunday": 7,
}
current_day = days[dt.strftime("%A").lower()]
target_day = days[unit.lower()]
if direction == "next":
if current_day < target_day:
delta_days = target_day - current_day
else:
delta_days = (target_day - current_day) + TOTAL_DAYS
elif direction == "last":
if current_day <= target_day:
delta_days = (current_day - target_day) + TOTAL_DAYS
else:
delta_days = current_day - target_day
delta = relativedelta(days=+delta_days)
return _move_datetime(dt, direction, delta)
def move_datetime_month(dt, direction, num_shifts):
"""
Move datetime 1 month in the chosen direction.
unit is a no-op, to keep the API the same as the day case
"""
delta = relativedelta(months=+num_shifts)
return _move_datetime(dt, direction, delta)
def move_datetime_week(dt, direction, num_shifts):
"""
Move datetime 1 week in the chosen direction.
unit is a no-op, to keep the API the same as the day case
"""
delta = relativedelta(weeks=+num_shifts)
return _move_datetime(dt, direction, delta)
def move_datetime_year(dt, direction, num_shifts):
"""
Move datetime 1 year in the chosen direction.
unit is a no-op, to keep the API the same as the day case
"""
delta = relativedelta(years=+num_shifts)
return _move_datetime(dt, direction, delta)
def move_datetime_hour(dt, direction, num_shifts):
delta = relativedelta(hours=+num_shifts)
return _move_datetime(dt, direction, delta)
def move_datetime_minute(dt, direction, num_shifts):
delta = relativedelta(minutes=+num_shifts)
return _move_datetime(dt, direction, delta)
def move_datetime_second(dt, direction, num_shifts):
delta = relativedelta(seconds=+num_shifts)
return _move_datetime(dt, direction, delta)
def datetime_timezone(tz):
"""
This method given a timezone returns a localized datetime object.
"""
utc_datetime_naive = datetime.now(timezone.utc).replace(tzinfo=None)
# return a localized datetime to UTC
utc_localized_datetime = localize(utc_datetime_naive, "UTC")
# normalize the datetime to given timezone
normalized_datetime = normalize(utc_localized_datetime, tz)
return normalized_datetime
def localize(dt, tz):
"""
Given a naive datetime object this method will return a localized
datetime object
"""
tz = get_timezone(tz)
# Attaching a zone to an already-aware datetime would move the instant it
# represents rather than describe it, so refuse rather than guess.
if dt.tzinfo is not None:
raise DeloreanInvalidDatetime("localize requires a naive datetime")
# A pytz zone from a caller still on 1.x carries no offset until localized;
# attaching one directly would silently yield that zone's LMT offset.
if hasattr(tz, "localize"):
return tz.localize(dt)
return dt.replace(tzinfo=tz)
def normalize(dt, tz):
"""
Given a object with a timezone return a datetime object
normalized to the proper timezone.
This means take the give localized datetime and returns the
datetime normalized to match the specified timezone.
"""
tz = get_timezone(tz)
if dt.tzinfo is None:
raise DeloreanInvalidDatetime("normalize requires an aware datetime")
return dt.astimezone(tz)
class Delorean(object):
"""
The class `Delorean <Delorean>` object. This method accepts naive
datetime objects, with a string timezone.
"""
_VALID_SHIFT_DIRECTIONS = ("last", "next")
_VALID_SHIFT_UNITS = (
"second",
"minute",
"hour",
"day",
"week",
"month",
"year",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
)
def __init__(self, datetime=None, timezone=None):
# maybe set timezone on the way in here. if here set it if not
# use UTC
is_datetime_instance(datetime)
if datetime:
if is_datetime_naive(datetime):
if timezone:
self._dt = localize(datetime, timezone)
self._tzinfo = self._dt.tzinfo
else:
# TODO(mlew, 2015-08-09):
# Should we really throw an error here, or should this
# default to UTC?)
raise DeloreanInvalidTimezone("Provide a valid timezone")
else:
self._tzinfo = datetime.tzinfo
self._dt = datetime
else:
if timezone:
self._tzinfo = get_timezone(timezone)
self._dt = datetime_timezone(self._tzinfo)
self._tzinfo = self._dt.tzinfo
else:
self._tzinfo = utc
self._dt = datetime_timezone(utc)
def __repr__(self):
dt = self.datetime.replace(tzinfo=None)
# Both zoneinfo zones and fixed offsets render as something timezone()
# accepts, so this stays round-trippable through eval().
return "Delorean(datetime=%r, timezone=%r)" % (dt, str(self.timezone))
def __eq__(self, other):
if isinstance(other, Delorean):
return self.epoch == other.epoch
return False
def __hash__(self):
# Defining __eq__ sets __hash__ to None in Python 3, which makes
# Delorean objects unhashable and unusable in sets or as dict keys.
# Hash on the same value equality is based on so that equal objects
# (same instant, regardless of timezone) share a hash.
return hash(self.epoch)
def __lt__(self, other):
return self.epoch < other.epoch
def __gt__(self, other):
return self.epoch > other.epoch
def __ge__(self, other):
return self.epoch >= other.epoch
def __le__(self, other):
return self.epoch <= other.epoch
def __ne__(self, other):
return not self == other
def __add__(self, other):
if not isinstance(other, timedelta):
raise TypeError("Delorean objects can only be added with timedelta objects")
dt = self._dt + other
return Delorean(datetime=dt, timezone=self.timezone)
def __sub__(self, other):
if isinstance(other, timedelta):
dt = self._dt - other
return Delorean(datetime=dt, timezone=self.timezone)
elif isinstance(other, Delorean):
return self._dt - other._dt
else:
raise TypeError(
"Delorean objects can only be subtracted with timedelta or other Delorean objects"
)
def __getattr__(self, name):
"""
Implement __getattr__ to call `shift_date` function when function
called does not exist
"""
func_parts = name.split("_")
# is the func we are trying to call the right length?
if len(func_parts) != 2:
raise AttributeError
# is the function we are trying to call valid?
if (
func_parts[0] not in self._VALID_SHIFT_DIRECTIONS
or func_parts[1] not in self._VALID_SHIFT_UNITS
):
raise AttributeError
# dispatch our function
func = partial(self._shift_date, func_parts[0], func_parts[1])
# update our partial with self.shift_date attributes
update_wrapper(func, self._shift_date)
return func
def _shift_date(self, direction, unit, *args):
"""
Shift datetime in `direction` in _VALID_SHIFT_DIRECTIONS and by some
unit in _VALID_SHIFTS and shift that amount by some multiple,
defined by by args[0] if it exists
"""
this_module = sys.modules[__name__]
num_shifts = 1
if len(args) > 0:
if unit == "second":
num_shifts = args[0]
else:
try:
num_shifts = index(args[0])
except TypeError:
raise TypeError(f"{unit} shifts require an integer") from None
if unit in [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
]:
shift_func = move_datetime_namedday
dt = shift_func(self._dt, direction, unit)
if num_shifts > 1:
for n in range(num_shifts - 1):
dt = shift_func(dt, direction, unit)
else:
shift_func = getattr(this_module, "move_datetime_%s" % unit)
dt = shift_func(self._dt, direction, num_shifts)
return Delorean(datetime=dt.replace(tzinfo=None), timezone=self.timezone)
@property
def timezone(self):
"""
Returns the tzinfo object associated with the Delorean object: a
`zoneinfo.ZoneInfo` for a named zone, or a `datetime.timezone` for a
fixed offset.
.. testsetup::
from datetime import datetime
from delorean import Delorean
.. doctest::
>>> d = Delorean(datetime(2015, 1, 1), timezone='UTC')
>>> d.timezone
zoneinfo.ZoneInfo(key='UTC')
.. versionchanged:: 2.0
Timezones are standard library objects. Earlier versions returned
`pytz` objects, so comparisons against `pytz.utc` and friends need
updating to :func:`delorean.timezone` or `zoneinfo.ZoneInfo`.
"""
return self._tzinfo
def truncate(self, s):
"""
Truncate the delorean object to the nearest s
(second, minute, hour, day, month, year)
This is a destructive method, modifies the internal datetime
object associated with the Delorean object.
.. testsetup::
from datetime import datetime
from delorean import Delorean
.. doctest::
>>> d = Delorean(datetime(2015, 1, 1, 12, 10), timezone='US/Pacific')
>>> d.truncate('hour')
Delorean(datetime=datetime.datetime(2015, 1, 1, 12, 0), timezone='US/Pacific')
"""
if s == "second":
self._dt = self._dt.replace(microsecond=0)
elif s == "minute":
self._dt = self._dt.replace(second=0, microsecond=0)
elif s == "hour":
self._dt = self._dt.replace(minute=0, second=0, microsecond=0)
elif s == "day":
self._dt = self._dt.replace(hour=0, minute=0, second=0, microsecond=0)
elif s == "month":
self._dt = self._dt.replace(
day=1, hour=0, minute=0, second=0, microsecond=0
)
elif s == "year":
self._dt = self._dt.replace(
month=1, day=1, hour=0, minute=0, second=0, microsecond=0
)
else:
raise ValueError("Invalid truncation level")
return self
@property
def naive(self):
"""
Returns a naive datetime object associated with the Delorean
object, this method simply converts the localize datetime to UTC
and removes the tzinfo that is associated with it modifying the Delorean object.
.. testsetup::
from datetime import datetime
from delorean import Delorean
.. doctest::
>>> d = Delorean(datetime(2015, 1, 1), timezone='US/Pacific')
>>> d.naive
datetime.datetime(2015, 1, 1, 8, 0)
"""
self.shift("UTC")
return self._dt.replace(tzinfo=None)
@classmethod
def now(cls, timezone=None):
if timezone:
return cls(timezone=timezone)
else:
return cls(timezone=get_localzone())
@classmethod
def utcnow(cls):
return cls()
@property
def midnight(self):
"""
Returns a new `Delorean` object for midnight of the day associated
with this object. This object is left unchanged.
.. testsetup::
from datetime import datetime
from delorean import Delorean
.. doctest::
>>> d = Delorean(datetime(2015, 1, 1, 12), timezone='UTC')
>>> d.midnight
Delorean(datetime=datetime.datetime(2015, 1, 1, 0, 0), timezone='UTC')
"""
return Delorean(
datetime=self._dt.replace(hour=0, minute=0, second=0, microsecond=0),
timezone=self.timezone,
)
@property
def start_of_day(self):
"""
Returns a new `Delorean` object for the start of the day associated
with this object. This object is left unchanged.
.. testsetup::
from datetime import datetime
from delorean import Delorean
.. doctest::
>>> d = Delorean(datetime(2015, 1, 1, 12), timezone='UTC')
>>> d.start_of_day
Delorean(datetime=datetime.datetime(2015, 1, 1, 0, 0), timezone='UTC')
"""
return self.midnight
@property
def end_of_day(self):
"""
Returns a new `Delorean` object for the end of the day associated
with this object. This object is left unchanged.
.. testsetup::
from datetime import datetime
from delorean import Delorean
.. doctest::
>>> d = Delorean(datetime(2015, 1, 1, 12), timezone='UTC')
>>> d.end_of_day
Delorean(datetime=datetime.datetime(2015, 1, 1, 23, 59, 59, 999999), timezone='UTC')
"""
return Delorean(
datetime=self._dt.replace(
hour=23, minute=59, second=59, microsecond=999999
),
timezone=self.timezone,
)
@property
def start_of_month(self):
"""
Returns a new `Delorean` object for the first moment of the month
associated with this object. This object is left unchanged.
.. testsetup::
from datetime import datetime
from delorean import Delorean
.. doctest::
>>> d = Delorean(datetime(2015, 5, 15, 14, 30), timezone='UTC')
>>> d.start_of_month
Delorean(datetime=datetime.datetime(2015, 5, 1, 0, 0), timezone='UTC')
"""
return Delorean(
datetime=self._dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0),
timezone=self.timezone,
)
@property
def end_of_month(self):
"""
Returns a new `Delorean` object for the last moment of the month
associated with this object, accounting for month length and leap
years. This object is left unchanged.
.. testsetup::
from datetime import datetime
from delorean import Delorean
.. doctest::
>>> d = Delorean(datetime(2015, 2, 15, 14, 30), timezone='UTC')
>>> d.end_of_month
Delorean(datetime=datetime.datetime(2015, 2, 28, 23, 59, 59, 999999), timezone='UTC')
"""
end = self._dt.replace(
hour=23, minute=59, second=59, microsecond=999999
) + relativedelta(day=31)
return Delorean(datetime=end, timezone=self.timezone)
@property
def start_of_year(self):
"""
Returns a new `Delorean` object for the first moment of the year
associated with this object. This object is left unchanged.
.. testsetup::
from datetime import datetime
from delorean import Delorean
.. doctest::
>>> d = Delorean(datetime(2015, 5, 15, 14, 30), timezone='UTC')
>>> d.start_of_year
Delorean(datetime=datetime.datetime(2015, 1, 1, 0, 0), timezone='UTC')
"""
return Delorean(
datetime=self._dt.replace(
month=1, day=1, hour=0, minute=0, second=0, microsecond=0
),
timezone=self.timezone,
)
@property
def end_of_year(self):
"""
Returns a new `Delorean` object for the last moment of the year
associated with this object. This object is left unchanged.
.. testsetup::
from datetime import datetime
from delorean import Delorean
.. doctest::
>>> d = Delorean(datetime(2015, 5, 15, 14, 30), timezone='UTC')
>>> d.end_of_year
Delorean(datetime=datetime.datetime(2015, 12, 31, 23, 59, 59, 999999), timezone='UTC')
"""
return Delorean(
datetime=self._dt.replace(
month=12, day=31, hour=23, minute=59, second=59, microsecond=999999
),
timezone=self.timezone,
)
def shift(self, timezone):
"""
Shifts the timezone from the current timezone to the specified timezone associated with the Delorean object,
modifying the Delorean object and returning the modified object.
.. testsetup::
from datetime import datetime
from delorean import Delorean
.. doctest::
>>> d = Delorean(datetime(2015, 1, 1), timezone='US/Pacific')
>>> d.shift('UTC')
Delorean(datetime=datetime.datetime(2015, 1, 1, 8, 0), timezone='UTC')
"""
try:
self._tzinfo = get_timezone(timezone)
except DeloreanInvalidTimezone:
raise DeloreanInvalidTimezone("Provide a valid timezone") from None
self._dt = self._dt.astimezone(self._tzinfo)
self._tzinfo = self._dt.tzinfo
return self
@property
def timestamp(self):
"""
Returns the total seconds since the Unix epoch associated with
the Delorean object.
.. testsetup::
from datetime import datetime
from delorean import Delorean
.. doctest::
>>> d = Delorean(datetime(2015, 1, 1), timezone='US/Pacific')
>>> d.timestamp
1420099200.0
"""
epoch_sec = datetime.fromtimestamp(0, timezone.utc)
now_sec = self._dt.astimezone(utc)
delta_sec = now_sec - epoch_sec
return get_total_second(delta_sec)
@property
def epoch(self):
"""
Alias of :attr:`timestamp`, kept for backwards compatibility.
The value is seconds since the Unix epoch, so `timestamp` is the
more accurate name; the epoch itself is the fixed point the count
starts from.
.. testsetup::
from datetime import datetime
from delorean import Delorean
.. doctest::
>>> d = Delorean(datetime(2015, 1, 1), timezone='US/Pacific')
>>> d.epoch
1420099200.0
"""
return self.timestamp
@property
def date(self):
"""
Returns the actual date object associated with
the Delorean object.
.. testsetup::
from datetime import datetime
from delorean import Delorean
.. doctest::
>>> d = Delorean(datetime(2015, 1, 1, 12, 15), timezone='US/Pacific')
>>> d.date
datetime.date(2015, 1, 1)
"""
return self._dt.date()
@property
def datetime(self):
"""
Returns the actual datetime object associated with
the Delorean object.
.. testsetup::
from datetime import datetime
from delorean import Delorean
.. doctest::
>>> d = Delorean(datetime(2015, 1, 1, 12, 15), timezone='UTC')
>>> d.datetime
datetime.datetime(2015, 1, 1, 12, 15, tzinfo=zoneinfo.ZoneInfo(key='UTC'))
"""
return self._dt
def replace(self, **kwargs):
"""
Returns a new Delorean object after applying replace on the
existing datetime object.
.. testsetup::
from datetime import datetime
from delorean import Delorean
.. doctest::
>>> d = Delorean(datetime(2015, 1, 1, 12, 15), timezone='UTC')
>>> d.replace(hour=8)
Delorean(datetime=datetime.datetime(2015, 1, 1, 8, 15), timezone='UTC')
"""
return Delorean(datetime=self._dt.replace(**kwargs), timezone=self.timezone)
def humanize(self):
"""
Humanize relative to now:
.. testsetup::
from datetime import timedelta
from delorean import Delorean
.. doctest::
>>> past = Delorean.utcnow() - timedelta(hours=1)
>>> past.humanize()
'an hour ago'
"""
now = self.now(self.timezone)
return humanize.naturaltime(now - self)
def format_datetime(self, format="medium", locale="en_US"):
r"""
Return a date string formatted to the given pattern.
Note that CLDR separates the time from the AM/PM marker with a narrow
no-break space (``\u202f``), not a plain space.
.. testsetup::
from delorean import Delorean
.. doctest::
>>> d = Delorean(datetime(2015, 1, 1, 12, 30), timezone='US/Pacific')
>>> d.format_datetime(locale='en_US')
'Jan 1, 2015, 12:30:00\u202fPM'
>>> d.format_datetime(format='long', locale='de_DE')
'1. Januar 2015, 12:30:00 -0800'
:param format: one of "full", "long", "medium", "short", or a custom datetime pattern
:param locale: a locale identifier
"""
return format_datetime(self._dt, format=format, locale=locale)