#!/usr/bin/env python
# -*- coding: utf-8 -*-
# ---------------------------------------------------------------------
# Copyright (c) Merchise Autrement [~º/~] and Contributors
# All rights reserved.
#
# This is free software; you can do what the LICENCE file allows you to.
#
"""Extends the standard `datetime` module.
- Python's ``datetime.strftime`` doesn't handle dates previous to 1900.
This module define classes to override `date` and `datetime` to support the
formatting of a date through its full proleptic Gregorian date range.
Based on code submitted to comp.lang.python by Andrew Dalke, copied from
Django and generalized.
You may use this module as a drop-in replacement of the standard library
`datetime` module.
"""
from datetime import * # noqa
from datetime import timedelta, date, datetime
import datetime as _stdlib # noqa
from re import compile as _regex_compile
from time import strftime as _time_strftime
from enum import IntEnum
from typing import Iterator, Tuple, Union # noqa
from xotl.tools.deprecation import deprecated
class WEEKDAY(IntEnum):
"""Simple constants for 'weekday' method."""
MONDAY = 0
TUESDAY = 1
WEDNESDAY = 2
THURSDAY = 3
FRIDAY = 4
SATURDAY = 5
SUNDAY = 6
class ISOWEEKDAY(IntEnum):
"""Simple constants for 'weekday' method."""
MONDAY = 1
TUESDAY = 2
WEDNESDAY = 3
THURSDAY = 4
FRIDAY = 5
SATURDAY = 6
SUNDAY = 7
[docs]@deprecated("plain objects")
def assure(obj):
"""Make sure that a `date` or `datetime` instance is a safe version.
This is only a type checker alternative to standard library.
"""
if isinstance(obj, (date, datetime, _stdlib.time, timedelta)):
return obj
else:
raise TypeError("Not valid type for datetime assuring: %s" % obj)
@deprecated(assure)
def new_date(d):
"""Generate a safe date from a legacy datetime date object."""
return date(d.year, d.month, d.day)
@deprecated(assure)
def new_datetime(d):
"""Generate a safe datetime given a legacy date or datetime object."""
args = [d.year, d.month, d.day]
if isinstance(d, datetime):
args.extend([d.hour, d.minute, d.second, d.microsecond, d.tzinfo])
return datetime(*args)
del deprecated
# This library does not support strftime's "%s" or "%y" format strings.
# Allowed if there's an even number of "%"s because they are escaped.
_illegal_formatting = _regex_compile(br"((^|[^%])(%%)*%[sy])")
def _year_find_all(fmt, year, no_year_tuple):
text = _time_strftime(fmt, (year,) + no_year_tuple)
regex = _regex_compile(str(year))
return {match.start() for match in regex.finditer(text)}
_TD_LABELS = "dhms" # days, hours, minutes, seconds
def _strfnumber(number, format_spec="%0.2f"):
"""Convert a floating point number into string using a smart way.
Used internally in strfdelta.
"""
res = format_spec % number
if "." in res:
res = res.rstrip("0")
if res.endswith("."):
res = res[:-1]
return res
[docs]def strfdelta(delta):
"""
Format a timedelta using a smart pretty algorithm.
Only two levels of values will be printed.
::
>>> def t(h, m):
... return timedelta(hours=h, minutes=m)
>>> strfdelta(t(4, 56)) == '4h 56m'
True
"""
ss, sss = str("%s%s"), str(" %s%s")
if delta.days:
days = delta.days
delta -= timedelta(days=days)
hours = delta.total_seconds() / 60 / 60
res = ss % (days, _TD_LABELS[0])
if hours >= 0.01:
res += sss % (_strfnumber(hours), _TD_LABELS[1])
else:
seconds = delta.total_seconds()
if seconds > 60:
minutes = seconds / 60
if minutes > 60:
hours = int(minutes / 60)
minutes -= hours * 60
res = ss % (hours, _TD_LABELS[1])
if minutes >= 0.01:
res += sss % (_strfnumber(minutes), _TD_LABELS[2])
else:
minutes = int(minutes)
seconds -= 60 * minutes
res = ss % (minutes, _TD_LABELS[2])
if seconds >= 0.01:
res += sss % (_strfnumber(seconds), _TD_LABELS[3])
else:
res = ss % (_strfnumber(seconds, "%0.3f"), _TD_LABELS[3])
return res
[docs]def strftime(dt, fmt):
"""Used as `strftime` method of `date` and `datetime` redefined classes.
Also could be used with standard instances.
"""
if dt.year >= 1900:
bases = type(dt).mro()
i = 0
base = _strftime = type(dt).strftime
while _strftime == base:
aux = getattr(bases[i], "strftime", base)
if aux != base:
_strftime = aux
else:
i += 1
return _strftime(dt, fmt)
else:
illegal_formatting = _illegal_formatting.search(fmt)
if illegal_formatting is None:
year = dt.year
# For every non-leap year century, advance by 6 years to get into
# the 28-year repeat cycle
delta = 2000 - year
year += 6 * (delta // 100 + delta // 400)
year += ((2000 - year) // 28) * 28 # Move to around the year 2000
no_year_tuple = dt.timetuple()[1:]
sites = _year_find_all(fmt, year, no_year_tuple)
sites &= _year_find_all(fmt, year + 28, no_year_tuple)
res = _time_strftime(fmt, (year,) + no_year_tuple)
syear = "%04d" % dt.year
for site in sites:
res = res[:site] + syear + res[site + 4 :]
return res
else:
msg = "strftime of dates before 1900 does not handle %s"
raise TypeError(msg % illegal_formatting.group(0))
def parse_date(value=None):
if value:
y, m, d = value.split("-")
return date(int(y), int(m), int(d))
else:
return date.today()
def parse_datetime(value=None):
"""Parse a datime in format 'YYYY-MM-DD HH:MM[:SS][.MS]'.
The hour-minute component is mandatory.
"""
if value:
d, t = value.split()
y, m, d = d.split("-")
if "." in t:
moment, ms = t.split(".")
else:
moment, ms = t, "0"
timing = moment.split(":")
if len(timing) == 2:
h, mn = timing
s = 0
elif len(timing) == 3:
h, mn, s = timing
else:
raise ValueError("Invalid time string %r" % t)
return datetime(int(y), int(m), int(d), int(h), int(mn), int(s), int(ms))
else:
return datetime.now()
[docs]def get_month_first(ref=None):
"""Given a reference date, returns the first date of the same month. If
`ref` is not given, then uses current date as the reference.
"""
aux = ref or date.today()
y, m = aux.year, aux.month
return date(y, m, 1)
[docs]def get_month_last(ref=None):
"""Given a reference date, returns the last date of the same month. If
`ref` is not given, then uses current date as the reference.
"""
aux = ref or date.today()
y, m = aux.year, aux.month
if m == 12:
m = 1
y += 1
else:
m += 1
return date(y, m, 1) - timedelta(1)
[docs]def get_next_month(ref=None, lastday=False):
"""Get the first or last day of the *next month*.
If `lastday` is False return the first date of the `next month`.
Otherwise, return the last date.
The *next month* is computed with regards to a reference date. If `ref`
is None, take the current date as the reference.
Examples:
>>> get_next_month(date(2017, 1, 23))
date(2017, 2, 1)
>>> get_next_month(date(2017, 1, 23), lastday=True)
date(2017, 2, 28)
.. versionadded:: 1.7.3
"""
result = get_month_last(ref) + timedelta(days=1)
if lastday:
return get_month_last(result)
else:
return result
[docs]def is_full_month(start, end):
"""Returns true if the arguments comprises a whole month."""
sd, sm, sy = start.day, start.month, start.year
em, ey = end.month, end.year
return (
(sd == 1) and (sm == em) and (sy == ey) and (em != (end + timedelta(1)).month)
)
[docs]class flextime(timedelta):
@classmethod
def parse_simple_timeformat(cls, which):
if "h" in which:
hour, rest = which.split("h")
else:
hour, rest = 0, which
return int(hour), int(rest), 0
def __new__(cls, *args, **kwargs):
first = None
if args:
first, rest = args[0], args[1:]
_super = super().__new__
if first and not rest and not kwargs:
hour, minutes, seconds = cls.parse_simple_timeformat(first)
return _super(cls, hours=hour, minutes=minutes, seconds=seconds)
else:
return _super(cls, *args, **kwargs)
# TODO: Merge this with the new time span.
[docs]def daterange(*args):
"""Similar to standard 'range' function, but for date objets.
Returns an iterator that yields each date in the range of ``[start,
stop)``, not including the stop.
If `start` is given, it must be a date (or `datetime`) value; and in this
case only `stop` may be an integer meaning the numbers of days to look
ahead (or back if `stop` is negative).
If only `stop` is given, `start` will be the first day of stop's month.
`step`, if given, should be a non-zero integer meaning the numbers of days
to jump from one date to the next. It defaults to ``1``. If it's positive
then `stop` should happen after `start`, otherwise no dates will be
yielded. If it's negative `stop` should be before `start`.
As with `range`, `stop` is never included in the yielded dates.
"""
import operator
# Use base classes to allow broader argument values
from datetime import date, datetime
if len(args) == 1:
start, stop, step = None, args[0], None
elif len(args) == 2:
start, stop = args
step = None
else:
start, stop, step = args
if not step and step is not None:
raise ValueError("Invalid step value %r" % step)
if not start:
if not isinstance(stop, (date, datetime)):
raise TypeError("stop must a date if start is None")
else:
start = get_month_first(stop)
else:
if stop is not None and not isinstance(stop, (date, datetime)):
stop = start + timedelta(days=stop)
if step is None or step > 0:
compare = operator.lt
else:
compare = operator.gt
step = timedelta(days=(step if step else 1))
# Encloses the generator so that signature validation exceptions happen
# without needing to call next().
def _generator():
current = start
while stop is None or compare(current, stop):
yield current
current += step
return _generator()
[docs]class DateField:
"""A simple descriptor for dates.
Ensures that assigned values must be parseable dates and parses them.
"""
def __init__(self, name, nullable=False):
self.name = name
self.nullable = nullable
def __get__(self, instance, owner):
if instance is not None:
res = instance.__dict__[self.name]
return res
else:
return self
def __set__(self, instance, value):
if value in (None, False):
# We regard False as None, so that working with Odoo is easier:
# missing values in Odoo, often come as False instead of None.
if not self.nullable:
raise ValueError("Setting None to a required field")
else:
value = None
elif isinstance(value, datetime):
value = value.date()
elif not isinstance(value, date):
value = parse_date(value)
instance.__dict__[self.name] = value
class DateTimeField(object):
"""A simple descriptor for datetimes.
Ensures that assigned values must be parseable date or datetime and parses
them.
If `prefer_last_minute` is False when converting from date, the time
component will be '00:00:00', if True, the time component will be
'23:59:59'.
.. versionadded:: 1.9.7
"""
def __init__(self, name, nullable=False, prefer_last_minute=False):
self.name = name
self.nullable = nullable
self.prefer_last_minute = prefer_last_minute
def __get__(self, instance, owner):
if instance is not None:
res = instance.__dict__[self.name]
return res
else:
return self
def __set__(self, instance, value):
if value in (None, False):
# We regard False as None, so that working with Odoo is easier:
# missing values in Odoo, often come as False instead of None.
if not self.nullable:
raise ValueError("Setting None to a required field")
else:
value = None
elif isinstance(value, datetime):
# needed because datetime is subclass of date, and the next
# condition would match.
pass
elif isinstance(value, date):
if not self.prefer_last_minute:
value = datetime(value.year, value.month, value.day)
else:
value = datetime(value.year, value.month, value.day, 23, 59, 59)
else:
try:
value = parse_datetime(value)
except ValueError:
value = parse_date(value)
self.__set__(instance, value) # lazy me
return
instance.__dict__[self.name] = value
[docs]class TimeSpan:
"""A *continuous* span of time.
Time spans objects are iterable. They yield exactly two times: first the
start date, and then the end date::
>>> ts = TimeSpan('2017-08-01', '2017-09-01')
>>> tuple(ts)
(date(2017, 8, 1), date(2017, 9, 1))
Time spans objects have two items::
>>> ts[0]
date(2017, 8, 1)
>>> ts[1]
date(2017, 9, 1)
>>> ts[:]
(date(2017, 8, 1), date(2017, 9, 1))
Two time spans are equal if their start_date and end_date are equal. When
comparing a time span with a date, the date is coerced to a time span
(`from_date`:meth:).
.. note:: Comparing time spans with date time spans `coerces the time span
<DateTimeSpan.from_timespan>`:meth: before comparing.
A time span with its `start` set to None is unbound to the past. A time
span with its `end` set to None is unbound to the future. A time span
that is both unbound to the past and the future contains all possible
dates. A time span that is not unbound in any direction is
`bound <bound>`:attr:.
A bound time span is `valid`:attr: if its start date comes before its end
date. Unbound time spans are always valid.
Time spans can `intersect <__mul__>`:meth:, compared for containment of
dates and by the subset/superset order operations (``<=``, ``>=``). In
this regard, they represent the *set* of dates between `start` and `end`,
inclusively.
.. warning:: Time spans don't implement the union or difference operations
expected in sets because the difference/union of two span is not
necessarily *continuous*.
"""
start_date = DateField("start_date", nullable=True)
end_date = DateField("end_date", nullable=True)
def __init__(self, start_date=None, end_date=None):
self.start_date = start_date
self.end_date = end_date
[docs] @classmethod
def from_date(self, date: date) -> "TimeSpan":
"""Return a new time span that covers a single `date`."""
return self(start_date=date, end_date=date)
@property
def past_unbound(self) -> bool:
"True if the time span is not bound into the past."
return self.start_date is None
@property
def future_unbound(self) -> bool:
"True if the time span is not bound into the future."
return self.end_date is None
@property
def unbound(self) -> bool:
"""True if the time span is `unbound into the past <past_unbound>`:attr: or
`unbount into the future <future_unbound>`:attr: or both.
"""
return self.future_unbound or self.past_unbound
@property
def bound(self) -> bool:
"True if the time span is not `unbound <unbound>`:attr:."
return not self.unbound
@property
def valid(self) -> bool:
"""A bound time span is valid if it starts before it ends.
Unbound time spans are always valid.
"""
if self.bound:
return self.start_date <= self.end_date
else:
return True
[docs] def __contains__(self, other):
"""Test date `other` is in the time span."""
if isinstance(other, date):
if isinstance(other, datetime):
other = other.date()
if self.start_date and self.end_date:
return self.start_date <= other <= self.end_date
elif self.start_date:
return self.start_date <= other
elif self.end_date:
return other <= self.end_date
else:
return True
else:
return False
[docs] def overlaps(self, other):
"""Test if the time spans overlaps."""
return bool(self & other)
[docs] def isdisjoint(self, other):
return not self.overlaps(other)
[docs] def __le__(self, other):
"True if `other` is a superset."
return (self & other) == self
issubset = __le__
def __lt__(self, other):
"True if `other` is a proper superset."
return self != other and self <= other
def __gt__(self, other):
"True if `other` is a proper subset."
return self != other and self >= other
[docs] def __ge__(self, other):
"True if `other` is a subset."
# Notice that ge is not the opposite of lt.
return (self & other) == other
issuperset = covers = __ge__
def __iter__(self):
yield self.start_date
yield self.end_date
def __getitem__(self, index):
this = tuple(self)
return this[index]
def __eq__(self, other):
if isinstance(other, date):
other = type(self).from_date(other)
elif isinstance(other, DateTimeSpan):
return other == self
if not isinstance(other, TimeSpan):
return NotImplemented
return self.start_date == other.start_date and self.end_date == other.end_date
def __hash__(self):
return hash((TimeSpan, self.start_date, self.end_date))
[docs] def __and__(self, other):
"""Get the time span that is the intersection with another time span.
If two time spans don't overlap, return `EmptyTimeSpan`:data:.
If `other` is not a TimeSpan we try to create one. If `other` is a
date, we create the TimeSpan that starts and end that very day. Other
types are passed unchanged to the constructor.
When `other` is a `DateTimeSpan`:class:, convert `self` to a `date
time span <DateTimeSpan.from_timespan>`:meth: before doing the
intersection.
"""
from xotl.tools.infinity import Infinity
if isinstance(other, _EmptyTimeSpan):
return other
elif isinstance(other, date):
other = TimeSpan.from_date(other)
elif isinstance(other, DateTimeSpan):
return other & self
elif not isinstance(other, TimeSpan):
raise TypeError("Invalid type '%s'" % type(other).__name__)
start = max(self.start_date or -Infinity, other.start_date or -Infinity)
end = min(self.end_date or Infinity, other.end_date or Infinity)
if start <= end:
if start is -Infinity:
start = None
if end is Infinity:
end = None
return type(self)(start, end)
else:
return EmptyTimeSpan
__mul__ = __rmul__ = __rand__ = __and__
def __bool__(self):
return True
__nonzero__ = __bool__
[docs] def __len__(self):
"""The amount of dates in the span.
.. warning:: If the time span is `unbound`:attr: this method returns
NotImplemented. This will make python complain with a
TypeError.
.. versionadded:: 1.8.2
"""
if self.bound:
return (self.end_date - self.start_date).days
else:
return NotImplemented
[docs] def __lshift__(self, delta):
"""Return the time span displaced to the past in `delta`.
:param delta: The number of days to displace. It can be either an
integer or a `datetime.timedelta`:class:. The integer
will be converted to ``timedelta(days=delta)``.
.. note:: Delta values that don't amount to at least a day will be the
same as 0.
.. versionadded:: 1.8.2
.. warning:: Python does have a boundaries for the dates it can
represent, so displacing a TimeSpan can cause OverflowError.
"""
import numbers
if isinstance(delta, numbers.Integral):
delta = timedelta(days=delta) # noqa
start = self.start_date - delta if self.start_date else None
end = self.end_date - delta if self.end_date else None
return type(self)(start, end)
[docs] def __rshift__(self, delta):
"""Return the time span displaced to the future in `delta`.
:param delta: The number of days to displace. It can be either an
integer or a `datetime.timedelta`:class:. The integer
will be converted to ``timedelta(days=delta)``.
.. note:: Delta values that don't amount to at least a day will be the
same as 0.
.. versionadded:: 1.8.2
.. warning:: Python does have a boundaries for the dates it can
represent, so displacing a TimeSpan can cause OverflowError.
"""
return self << -delta
[docs] def intersection(self, *others):
"Return ``self [& other1 & ...]``."
import operator
from functools import reduce
return reduce(operator.mul, others, self)
[docs] def diff(self, other):
"""Return the two time spans which (combined) contain all the dates in
`self` which are not in `other`.
Notice this method returns a tuple of exactly two items.
If `other` and `self` don't overlap, return ``(self, EmptyTimeSpan)``.
If ``self <= other`` is True, return the tuple with the empty time
span in both positions.
Otherwise `self` will have some dates which are not in `other`; there
are possible three cases:
a) other starts before or at self's start date; return the empty time
span and the time span containing the dates after `other.end_date`
up to `self.end_date`
b) other ends at or after self's end date; return the dates from
`self.start_date` up to the date before `other.start_date` and the
empty time span.
c) `other` is fully contained in `self`; return two non-empty time
spans as in the previous cases.
.. versionadded:: 1.9.7
"""
if not self & other:
return self, EmptyTimeSpan # type: ignore
other = self & other
if self == other:
return EmptyTimeSpan, EmptyTimeSpan # type: ignore
else:
assert self > other
day = timedelta(days=1)
if self.start_date == other.start_date:
return (
EmptyTimeSpan, # type: ignore
TimeSpan(other.end_date + day, self.end_date),
)
elif self.end_date == other.end_date:
return (
TimeSpan(self.start_date, other.start_date - day),
EmptyTimeSpan,
)
else:
return (
TimeSpan(self.start_date, other.start_date - day),
TimeSpan(other.end_date + day, self.end_date),
)
def __repr__(self):
start, end = self
return "TimeSpan(%r, %r)" % (
start.isoformat() if start else None,
end.isoformat() if end else None,
)
__str__ = __repr__
class _EmptyTimeSpan:
__slots__ = [] # no inner structure
def __bool__(self):
return False
__nonzero__ = __bool__
def __contains__(self, which):
return False # I don't contain noone
# The empty is equal only to itself
def __eq__(self, which):
if isinstance(which, (TimeSpan, date, _EmptyTimeSpan)):
# We expect `self` to be a singleton, but pickle protocol 1 does
# not warrant to call our __new__.
return self is which
else:
return NotImplemented
def __ne__(self, other):
res = self == other
if res is not NotImplemented:
return not res
else:
return res
# The empty set is a subset of any other set. dates are regarded as the
# set that contains that
def __le__(self, which):
if isinstance(which, (TimeSpan, date, _EmptyTimeSpan)):
return True
else:
return NotImplemented
# The empty set is only a superset of itself.
__ge__ = covers = __eq__
# The empty set is a *proper* subset of any set but itself. The empty
# set is disjoint with any other set but itself.
__lt__ = isdisjoint = __ne__
# The empty set is a *proper* superset of no one
def __gt__(self, which):
if isinstance(which, (TimeSpan, date, _EmptyTimeSpan)):
return True
else:
return NotImplemented
# `empty | x == empty + x == x`
def __add__(self, which):
if isinstance(which, (TimeSpan, date, _EmptyTimeSpan)):
return which
else:
raise TypeError
__or__ = __add__
# `empty & x == empty * x == empty`
def __mul__(self, other):
if isinstance(other, (TimeSpan, date, _EmptyTimeSpan)):
return self
else:
raise TypeError
__and__ = __mul__
def __repr__(self):
return "EmptyTimeSpan"
__str__ = __repr__
def __new__(cls):
res = getattr(cls, "_instance", None)
if res is None:
res = cls._instance = super().__new__(cls)
return res
def __reduce__(self):
# So that unpickling returns the singleton
return type(self), ()
def __len__(self):
return 0
def __lshift__(self, delta):
return self
def __rshift__(self, delta):
return self
# I solemnly swear that EmptyTimeSpan is of type DateTimeSpan.
EmptyTimeSpan = _EmptyTimeSpan()
# TODO: Move this to xotl.tools.objects or somewhere else
class SynchronizedField(object):
"""A synchronized descriptor.
Whenever the `source` gets updated, update the second.
"""
def __init__(self, descriptor, setting_descriptor, set_throu_get=True):
self.descriptor = descriptor
self.setting_descriptor = setting_descriptor
self.set_throu_get = set_throu_get
def __get__(self, instance, owner):
return self.descriptor.__get__(instance, owner)
def __set__(self, instance, value):
from xotl.tools.context import context
self.descriptor.__set__(instance, value)
if (SynchronizedField, self.setting_descriptor) not in context:
with context((SynchronizedField, self.setting_descriptor)):
if self.set_throu_get:
value = self.__get__(instance, type(instance))
self.setting_descriptor.__set__(instance, value)
[docs]class DateTimeSpan(TimeSpan):
"""A *continuous* span of time (with datetime at each boundary).
`DateTimeSpan`:class: is a minor extension of `TimeSpan`:class:, and is a
subclass.
DateTimeSpan objects are iterable. They yield exactly two datetimes:
first the start date, and then the end date::
>>> ts = DateTimeSpan('2017-08-01 11:00', '2017-09-01 23:00')
>>> tuple(ts)
(datetime(2017, 8, 1, 11, 0), date(2017, 9, 1, 23, 0))
The API of DateTimeSpan is just the natural transformation of the API of
`TimeSpan`:class:.
The `start_date` and `end_date` attributes are interlocked with the
`start_datetime` and `end_datetime`. By changing `start_date`, you also
change `start_datetime` with the same date at 00:00 without tzinfo. By
setting `start_datetime` you also update `start_date`. By setting
`end_date` you also update `end_datetime` with the same date at 23:59:59
without tzinfo.
.. versionadded:: 1.9.7
.. warning:: DateTimeSpan is provided on a provisional basis. Future
releases can change its API or remove it completely.
"""
start_datetime = SynchronizedField(
DateTimeField("start_datetime", nullable=True), TimeSpan.start_date
)
end_datetime = SynchronizedField(
DateTimeField("end_datetime", nullable=True, prefer_last_minute=True),
TimeSpan.end_date,
)
start_date = SynchronizedField(TimeSpan.start_date, start_datetime.descriptor)
end_date = SynchronizedField(TimeSpan.end_date, end_datetime.descriptor)
def __init__(self, start_datetime=None, end_datetime=None):
# Don't call super because our fields are synchronized.
self.start_datetime = start_datetime
self.end_datetime = end_datetime
[docs] @classmethod
def from_datetime(self, dt):
# type: (datetime) -> DateTimeSpan
"""Return a new date time span that covers a single `datetime`.
If `dt` is actually a date, the start_datetime will be at '00:00:00'
and the end_datetime will be at '23:59:59'.
"""
return self(start_datetime=dt, end_datetime=dt)
[docs] @classmethod
def from_timespan(self, ts):
# type: (TimeSpan) -> DateTimeSpan
"""Return a new date time span from a timespan.
Notice the start datetime will be set at '00:00:00' and the end
datetime at '23:59:59'.
If `ts` is already a DateTimeSpan, return it unchanged.
"""
if isinstance(ts, DateTimeSpan):
return ts
else:
return self(start_datetime=ts.start_date, end_datetime=ts.end_date)
@property
def past_unbound(self):
# type: () -> bool
"True if the time span is not bound into the past."
return self.start_datetime is None
@property
def future_unbound(self):
# type: () -> bool
"True if the time span is not bound into the future."
return self.end_datetime is None
@property
def unbound(self):
# type: () -> bool
"""True if the time span is `unbound into the past <past_unbound>`:attr: or
`unbount into the future <future_unbound>`:attr: or both.
"""
return self.future_unbound or self.past_unbound
@property
def bound(self):
# type: () -> bool
"True if the time span is not `unbound <unbound>`:attr:."
return not self.unbound
@property
def valid(self):
# type: () -> bool
"""A bound time span is valid if it starts before it ends.
Unbound time spans are always valid.
"""
if self.bound:
return self.start_datetime <= self.end_datetime
else:
return True
[docs] def __contains__(self, other):
# type: (date) -> bool
"""Test if datetime `other` is in the datetime span.
If `other` is a `~datetime.date`:class:, we convert it to a naive
datetime at midnight (00:00:00).
"""
if isinstance(other, date):
if not isinstance(other, datetime):
other = datetime(other.year, other.month, other.day) # type: ignore
if self.start_datetime and self.end_datetime:
return self.start_datetime <= other <= self.end_datetime
elif self.start_datetime:
return self.start_datetime <= other
elif self.end_datetime:
return other <= self.end_datetime
else:
return True
else:
return False
[docs] def overlaps(self, other):
# type: (TimeSpan) -> bool
"""Test if the time spans overlaps."""
return bool(self & other)
[docs] def isdisjoint(self, other):
# type: (TimeSpan) -> bool
return not self.overlaps(other)
[docs] def __le__(self, other):
# type: (TimeSpan) -> bool
"True if `other` is a superset."
return (self & other) == self
issubset = __le__
def __lt__(self, other):
# type: (TimeSpan) -> bool
"True if `other` is a proper superset."
return self != other and self <= other
def __gt__(self, other):
# type: (TimeSpan) -> bool
"True if `other` is a proper subset."
return self != other and self >= other
[docs] def __ge__(self, other):
# type: (TimeSpan) -> bool
"True if `other` is a subset."
# Notice that ge is not the opposite of lt.
return (self & other) == other
issuperset = covers = __ge__
def __iter__(self) -> Iterator[datetime]: # type: ignore
yield self.start_datetime
yield self.end_datetime
def __getitem__(self, index) -> datetime: # type: ignore
this = tuple(self)
return this[index]
def __eq__(self, other):
if isinstance(other, date):
other = type(self).from_datetime(other)
elif isinstance(other, TimeSpan) and not isinstance(
other, DateTimeSpan
): # noqa
other = self.from_timespan(other)
elif not isinstance(other, DateTimeSpan):
return NotImplemented
return (
self.start_datetime == other.start_datetime
and self.end_datetime == other.end_datetime
)
def __hash__(self):
return hash((DateTimeSpan, self.start_datetime, self.end_datetime))
[docs] def __and__(self, other):
# type: (TimeSpan) -> DateTimeSpan
"""Get the date time span that is the intersection with another time span.
If two time spans don't overlap, return the object
`EmptyTimeSpan`:any:.
If `other` is not a DateTimeSpan we try to create one. If `other` is
a date/datetime, we create use `from_datetime`:meth:. If `other` is
TimeSpan we use `from_timespan`:meth:. Other types are passed
unchanged to the constructor.
"""
from xotl.tools.infinity import Infinity
if isinstance(other, _EmptyTimeSpan):
return other
elif isinstance(other, date):
other = DateTimeSpan.from_datetime(other)
elif isinstance(other, TimeSpan):
other = DateTimeSpan.from_timespan(other)
elif not isinstance(other, TimeSpan):
raise TypeError("Invalid type '%s'" % type(other).__name__)
start = max(self.start_datetime or -Infinity, other.start_datetime or -Infinity)
end = min(self.end_datetime or Infinity, other.end_datetime or Infinity)
if start <= end:
if start is -Infinity:
start = None
if end is Infinity:
end = None
return type(self)(start, end)
else:
return EmptyTimeSpan
__mul__ = __rmul__ = __rand__ = __and__
def __bool__(self):
# type: () -> bool
return True
__nonzero__ = __bool__
[docs] def __lshift__(self, delta):
# type: (Union[int, timedelta]) -> DateTimeSpan
"""Return the date time span displaced to the past in `delta`.
:param delta: The number of days to displace. It can be either an
integer or a `datetime.timedelta`:class:. The integer
will be converted to ``timedelta(days=delta)``.
.. warning:: Python does have a boundaries for the dates it can
represent, so displacing can cause OverflowError.
"""
import numbers
if isinstance(delta, numbers.Integral):
delta = timedelta(days=delta)
start = self.start_datetime - delta if self.start_datetime else None
end = self.end_datetime - delta if self.end_datetime else None
return type(self)(start, end)
[docs] def __rshift__(self, delta):
# type: (Union[int, timedelta]) -> DateTimeSpan
"""Return the date time span displaced to the future in `delta`.
:param delta: The number of days to displace. It can be either an
integer or a `datetime.timedelta`:class:. The integer
will be converted to ``timedelta(days=delta)``.
.. warning:: Python does have a boundaries for the dates it can
represent, so displacing can cause OverflowError.
"""
return self << -delta
[docs] def intersection(self, *others):
# type: (TimeSpan) -> DateTimeSpan
"Return ``self [& other1 & ...]``."
import operator
from functools import reduce
return reduce(operator.mul, others, self)
[docs] def diff(self, other):
# type: (TimeSpan) -> Tuple[DateTimeSpan, DateTimeSpan]
"""Return the two datetime spans which (combined) contain all the
seconds in `self` which are not in `other`.
Notice this method returns a tuple of exactly two items.
If `other` and `self` don't overlap, return ``(self, EmptyTimeSpan)``.
If ``self <= other`` is True, return the tuple with the empty time
span in both positions.
Otherwise `self` will have some datetimes which are not in `other`;
there are possible three cases:
a) other starts before or at self's start datetime; return the empty
time span and the datetime span from the second after
`other.end_datetime` up to `self.end_datetime`
b) other ends at or after self's end date; return the datetime span
from `self.start_datetime` up to the second before
`other.start_datetime` and the empty time span.
c) `other` is fully contained in `self`; return two non-empty datetime
spans as in the previous cases.
"""
if not self & other:
return self, EmptyTimeSpan
other = self & other
if self == other:
return EmptyTimeSpan, EmptyTimeSpan
else:
assert self > other
sec = timedelta(seconds=1)
if self.start_datetime == other.start_datetime:
return (
EmptyTimeSpan,
DateTimeSpan(other.end_datetime + sec, self.end_datetime),
)
elif self.end_datetime == other.end_datetime:
return (
DateTimeSpan(self.start_datetime, other.start_datetime - sec),
EmptyTimeSpan,
)
else:
return (
DateTimeSpan(self.start_datetime, other.start_datetime - sec),
DateTimeSpan(other.end_datetime + sec, self.end_datetime),
)
def __repr__(self):
start, end = self
return "DateTimeSpan(%r, %r)" % (
start.isoformat().replace("T", " ") if start else None,
end.isoformat().replace("T", " ") if end else None,
)
__str__ = __repr__
del IntEnum