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 ` 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)