# copyright 2003-2012 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This file is part of logilab-common.
#
# logilab-common is free software: you can redistribute it and/or modify it under
# the terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option) any
# later version.
#
# logilab-common is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with logilab-common. If not, see <http://www.gnu.org/licenses/>.
"""Date manipulation helper functions."""
__docformat__ = "restructuredtext en"
import math
import re
import sys
from locale import getlocale, LC_TIME
from datetime import date, time, datetime, timedelta
from time import strptime as time_strptime
from calendar import monthrange, timegm
from typing import Union, List, Any, Optional, Generator
try:
from mx.DateTime import RelativeDateTime, Date, DateTimeType
except ImportError:
endOfMonth = None
DateTimeType = datetime
else:
endOfMonth = RelativeDateTime(months=1, day=-1)
# NOTE: should we implement a compatibility layer between date representations
# as we have in lgc.db ?
FRENCH_FIXED_HOLIDAYS = {
"jour_an": "%s-01-01",
"fete_travail": "%s-05-01",
"armistice1945": "%s-05-08",
"fete_nat": "%s-07-14",
"assomption": "%s-08-15",
"toussaint": "%s-11-01",
"armistice1918": "%s-11-11",
"noel": "%s-12-25",
}
FRENCH_MOBILE_HOLIDAYS = {
"paques2004": "2004-04-12",
"ascension2004": "2004-05-20",
"pentecote2004": "2004-05-31",
"paques2005": "2005-03-28",
"ascension2005": "2005-05-05",
"pentecote2005": "2005-05-16",
"paques2006": "2006-04-17",
"ascension2006": "2006-05-25",
"pentecote2006": "2006-06-05",
"paques2007": "2007-04-09",
"ascension2007": "2007-05-17",
"pentecote2007": "2007-05-28",
"paques2008": "2008-03-24",
"ascension2008": "2008-05-01",
"pentecote2008": "2008-05-12",
"paques2009": "2009-04-13",
"ascension2009": "2009-05-21",
"pentecote2009": "2009-06-01",
"paques2010": "2010-04-05",
"ascension2010": "2010-05-13",
"pentecote2010": "2010-05-24",
"paques2011": "2011-04-25",
"ascension2011": "2011-06-02",
"pentecote2011": "2011-06-13",
"paques2012": "2012-04-09",
"ascension2012": "2012-05-17",
"pentecote2012": "2012-05-28",
}
# XXX this implementation cries for multimethod dispatching
[docs]def get_step(dateobj: Union[date, datetime], nbdays: int = 1) -> timedelta:
# assume date is either a python datetime or a mx.DateTime object
if isinstance(dateobj, date):
return ONEDAY * nbdays
return nbdays # mx.DateTime is ok with integers
[docs]def datefactory(
year: int, month: int, day: int, sampledate: Union[date, datetime]
) -> Union[date, datetime]:
# assume date is either a python datetime or a mx.DateTime object
if isinstance(sampledate, datetime):
return datetime(year, month, day)
if isinstance(sampledate, date):
return date(year, month, day)
return Date(year, month, day)
[docs]def weekday(dateobj: Union[date, datetime]) -> int:
# assume date is either a python datetime or a mx.DateTime object
if isinstance(dateobj, date):
return dateobj.weekday()
return dateobj.day_of_week
[docs]def str2date(datestr: str, sampledate: Union[date, datetime]) -> Union[date, datetime]:
# NOTE: datetime.strptime is not an option until we drop py2.4 compat
year, month, day = [int(chunk) for chunk in datestr.split("-")]
return datefactory(year, month, day, sampledate)
[docs]def days_between(start: Union[date, datetime], end: Union[date, datetime]) -> int:
if isinstance(start, date):
# mypy: No overload variant of "__sub__" of "datetime" matches argument type "date"
# we ensure that end is a date
assert isinstance(end, date)
delta = end - start # type: ignore
# datetime.timedelta.days is always an integer (floored)
if delta.seconds:
return delta.days + 1
return delta.days
else:
return int(math.ceil((end - start).days))
[docs]def get_national_holidays(
begin: Union[date, datetime], end: Union[date, datetime]
) -> Union[List[date], List[datetime]]:
"""return french national days off between begin and end"""
begin = datefactory(begin.year, begin.month, begin.day, begin)
end = datefactory(end.year, end.month, end.day, end)
holidays = [str2date(datestr, begin) for datestr in FRENCH_MOBILE_HOLIDAYS.values()]
for year in range(begin.year, end.year + 1):
for datestr in FRENCH_FIXED_HOLIDAYS.values():
date = str2date(datestr % year, begin)
if date not in holidays:
holidays.append(date)
return [day for day in holidays if begin <= day < end]
[docs]def add_days_worked(start: date, days: int) -> date:
"""adds date but try to only take days worked into account"""
step = get_step(start)
weeks, plus = divmod(days, 5)
end = start + ((weeks * 7) + plus) * step
if weekday(end) >= 5: # saturday or sunday
end += 2 * step
end += len([x for x in get_national_holidays(start, end + step) if weekday(x) < 5]) * step
if weekday(end) >= 5: # saturday or sunday
end += 2 * step
return end
[docs]def nb_open_days(start: Union[date, datetime], end: Union[date, datetime]) -> int:
assert start <= end
step = get_step(start)
days = days_between(start, end)
weeks, plus = divmod(days, 7)
if weekday(start) > weekday(end):
plus -= 2
elif weekday(end) == 6:
plus -= 1
open_days = weeks * 5 + plus
nb_week_holidays = len(
[x for x in get_national_holidays(start, end + step) if weekday(x) < 5 and x < end]
)
open_days -= nb_week_holidays
if open_days < 0:
return 0
return open_days
[docs]def date_range(
begin: date, end: date, incday: Optional[Any] = None, incmonth: Optional[bool] = None
) -> Generator[date, Any, None]:
"""yields each date between begin and end
:param begin: the start date
:param end: the end date
:param incr: the step to use to iterate over dates. Default is
one day.
:param include: None (means no exclusion) or a function taking a
date as parameter, and returning True if the date
should be included.
When using mx datetime, you should *NOT* use incmonth argument, use instead
oneDay, oneHour, oneMinute, oneSecond, oneWeek or endOfMonth (to enumerate
months) as `incday` argument
"""
assert not (incday and incmonth)
begin = todate(begin)
end = todate(end)
if incmonth:
while begin < end:
yield begin
begin = next_month(begin, incmonth)
else:
incr = get_step(begin, incday or 1)
while begin < end:
yield begin
begin += incr
# makes py datetime usable #####################################################
ONEDAY: timedelta = timedelta(days=1)
ONEWEEK: timedelta = timedelta(days=7)
[docs]def strptime_time(value, format="%H:%M"):
return time(*time_strptime(value, format)[3:6])
[docs]def todate(somedate: date) -> date:
"""return a date from a date (leaving unchanged) or a datetime"""
if isinstance(somedate, datetime):
return date(somedate.year, somedate.month, somedate.day)
assert isinstance(somedate, (date, DateTimeType)), repr(somedate)
return somedate
[docs]def totime(somedate):
"""return a time from a time (leaving unchanged), date or datetime"""
# XXX mx compat
if not isinstance(somedate, time):
return time(somedate.hour, somedate.minute, somedate.second)
assert isinstance(somedate, (time)), repr(somedate)
return somedate
[docs]def todatetime(somedate):
"""return a date from a date (leaving unchanged) or a datetime"""
# take care, datetime is a subclass of date
if isinstance(somedate, datetime):
return somedate
assert isinstance(somedate, (date, DateTimeType)), repr(somedate)
return datetime(somedate.year, somedate.month, somedate.day)
[docs]def datetime2ticks(somedate: Union[date, datetime]) -> int:
return timegm(somedate.timetuple()) * 1000 + int(getattr(somedate, "microsecond", 0) / 1000)
[docs]def ticks2datetime(ticks: int) -> datetime:
miliseconds, microseconds = divmod(ticks, 1000)
try:
return datetime.fromtimestamp(miliseconds)
except (ValueError, OverflowError):
epoch = datetime.fromtimestamp(0)
nb_days, seconds = divmod(int(miliseconds), 86400)
delta = timedelta(nb_days, seconds=seconds, microseconds=microseconds)
try:
return epoch + delta
except (ValueError, OverflowError):
raise
[docs]def days_in_month(somedate: date) -> int:
return monthrange(somedate.year, somedate.month)[1]
[docs]def days_in_year(somedate):
feb = date(somedate.year, 2, 1)
if days_in_month(feb) == 29:
return 366
else:
return 365
[docs]def previous_month(somedate, nbmonth=1):
while nbmonth:
somedate = first_day(somedate) - ONEDAY
nbmonth -= 1
return somedate
[docs]def next_month(somedate: date, nbmonth: int = 1) -> date:
while nbmonth:
somedate = last_day(somedate) + ONEDAY
nbmonth -= 1
return somedate
[docs]def first_day(somedate):
return date(somedate.year, somedate.month, 1)
[docs]def last_day(somedate: date) -> date:
return date(somedate.year, somedate.month, days_in_month(somedate))
[docs]def ustrftime(somedate: datetime, fmt: str = "%Y-%m-%d") -> str:
"""like strftime, but returns a unicode string instead of an encoded
string which may be problematic with localized date.
"""
if sys.version_info >= (3, 3):
# datetime.date.strftime() supports dates since year 1 in Python >=3.3.
return somedate.strftime(fmt)
else:
try:
if sys.version_info < (3, 0):
encoding = getlocale(LC_TIME)[1] or "ascii"
return str(somedate.strftime(str(fmt)), encoding)
else:
return somedate.strftime(fmt)
except ValueError:
if somedate.year >= 1900:
raise
# datetime is not happy with dates before 1900
# we try to work around this, assuming a simple
# format string
fields = {
"Y": somedate.year,
"m": somedate.month,
"d": somedate.day,
}
if isinstance(somedate, datetime):
fields.update({"H": somedate.hour, "M": somedate.minute, "S": somedate.second})
fmt = re.sub("%([YmdHMS])", r"%(\1)02d", fmt)
return str(fmt) % fields
[docs]def utcdatetime(dt: datetime) -> datetime:
if dt.tzinfo is None:
return dt
# mypy: No overload variant of "__sub__" of "datetime" matches argument type "None"
return dt.replace(tzinfo=None) - dt.utcoffset() # type: ignore
[docs]def utctime(dt):
if dt.tzinfo is None:
return dt
return (dt + dt.utcoffset() + dt.dst()).replace(tzinfo=None)
[docs]def datetime_to_seconds(date):
"""return the number of seconds since the begining of the day for that date"""
return date.second + 60 * date.minute + 3600 * date.hour
[docs]def timedelta_to_days(delta):
"""return the time delta as a number of seconds"""
return delta.days + delta.seconds / (3600 * 24)
[docs]def timedelta_to_seconds(delta):
"""return the time delta as a fraction of days"""
return delta.days * (3600 * 24) + delta.seconds