gooderp18绿色标准版
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

292 lines
11KB

  1. # -*- coding: utf-8 -*-
  2. import calendar
  3. import math
  4. from datetime import date, datetime, time
  5. from typing import Tuple, TypeVar, Literal, Iterator, Type
  6. import babel
  7. import pytz
  8. from dateutil.relativedelta import relativedelta, weekdays
  9. from .func import lazy
  10. D = TypeVar('D', date, datetime)
  11. __all__ = [
  12. 'date_range',
  13. 'get_fiscal_year',
  14. 'get_month',
  15. 'get_quarter',
  16. 'get_quarter_number',
  17. 'get_timedelta',
  18. ]
  19. def date_type(value: D) -> Type[D]:
  20. ''' Return either the datetime.datetime class or datetime.date type whether `value` is a datetime or a date.
  21. :param value: A datetime.datetime or datetime.date object.
  22. :return: datetime.datetime or datetime.date
  23. '''
  24. return datetime if isinstance(value, datetime) else date
  25. def get_month(date: D) -> Tuple[D, D]:
  26. ''' Compute the month dates range on which the 'date' parameter belongs to.
  27. '''
  28. return date.replace(day=1), date.replace(day=calendar.monthrange(date.year, date.month)[1])
  29. def get_quarter_number(date: date) -> int:
  30. ''' Get the number of the quarter on which the 'date' parameter belongs to.
  31. '''
  32. return math.ceil(date.month / 3)
  33. def get_quarter(date: D) -> Tuple[D, D]:
  34. ''' Compute the quarter dates range on which the 'date' parameter belongs to.
  35. '''
  36. quarter_number = get_quarter_number(date)
  37. month_from = ((quarter_number - 1) * 3) + 1
  38. date_from = date.replace(month=month_from, day=1)
  39. date_to = date_from + relativedelta(months=2)
  40. date_to = date_to.replace(day=calendar.monthrange(date_to.year, date_to.month)[1])
  41. return date_from, date_to
  42. def get_fiscal_year(date: D, day: int = 31, month: int = 12) -> Tuple[D, D]:
  43. ''' Compute the fiscal year dates range on which the 'date' parameter belongs to.
  44. A fiscal year is the period used by governments for accounting purposes and vary between countries.
  45. By default, calling this method with only one parameter gives the calendar year because the ending date of the
  46. fiscal year is set to the YYYY-12-31.
  47. :param date: A date belonging to the fiscal year
  48. :param day: The day of month the fiscal year ends.
  49. :param month: The month of year the fiscal year ends.
  50. :return: The start and end dates of the fiscal year.
  51. '''
  52. def fix_day(year, month, day):
  53. max_day = calendar.monthrange(year, month)[1]
  54. if month == 2 and day in (28, max_day):
  55. return max_day
  56. return min(day, max_day)
  57. date_to = date.replace(month=month, day=fix_day(date.year, month, day))
  58. if date <= date_to:
  59. date_from = date_to - relativedelta(years=1)
  60. day = fix_day(date_from.year, date_from.month, date_from.day)
  61. date_from = date_from.replace(day=day)
  62. date_from += relativedelta(days=1)
  63. else:
  64. date_from = date_to + relativedelta(days=1)
  65. date_to = date_to + relativedelta(years=1)
  66. day = fix_day(date_to.year, date_to.month, date_to.day)
  67. date_to = date_to.replace(day=day)
  68. return date_from, date_to
  69. def get_timedelta(qty: int, granularity: Literal['hour', 'day', 'week', 'month', 'year']):
  70. """ Helper to get a `relativedelta` object for the given quantity and interval unit.
  71. """
  72. switch = {
  73. 'hour': relativedelta(hours=qty),
  74. 'day': relativedelta(days=qty),
  75. 'week': relativedelta(weeks=qty),
  76. 'month': relativedelta(months=qty),
  77. 'year': relativedelta(years=qty),
  78. }
  79. return switch[granularity]
  80. Granularity = Literal['year', 'quarter', 'month', 'week', 'day', 'hour']
  81. def start_of(value: D, granularity: Granularity) -> D:
  82. """
  83. Get start of a time period from a date or a datetime.
  84. :param value: initial date or datetime.
  85. :param granularity: type of period in string, can be year, quarter, month, week, day or hour.
  86. :return: a date/datetime object corresponding to the start of the specified period.
  87. """
  88. is_datetime = isinstance(value, datetime)
  89. if granularity == "year":
  90. result = value.replace(month=1, day=1)
  91. elif granularity == "quarter":
  92. # Q1 = Jan 1st
  93. # Q2 = Apr 1st
  94. # Q3 = Jul 1st
  95. # Q4 = Oct 1st
  96. result = get_quarter(value)[0]
  97. elif granularity == "month":
  98. result = value.replace(day=1)
  99. elif granularity == 'week':
  100. # `calendar.weekday` uses ISO8601 for start of week reference, this means that
  101. # by default MONDAY is the first day of the week and SUNDAY is the last.
  102. result = value - relativedelta(days=calendar.weekday(value.year, value.month, value.day))
  103. elif granularity == "day":
  104. result = value
  105. elif granularity == "hour" and is_datetime:
  106. return datetime.combine(value, time.min).replace(hour=value.hour)
  107. elif is_datetime:
  108. raise ValueError(
  109. "Granularity must be year, quarter, month, week, day or hour for value %s" % value
  110. )
  111. else:
  112. raise ValueError(
  113. "Granularity must be year, quarter, month, week or day for value %s" % value
  114. )
  115. return datetime.combine(result, time.min) if is_datetime else result
  116. def end_of(value: D, granularity: Granularity) -> D:
  117. """
  118. Get end of a time period from a date or a datetime.
  119. :param value: initial date or datetime.
  120. :param granularity: Type of period in string, can be year, quarter, month, week, day or hour.
  121. :return: A date/datetime object corresponding to the start of the specified period.
  122. """
  123. is_datetime = isinstance(value, datetime)
  124. if granularity == "year":
  125. result = value.replace(month=12, day=31)
  126. elif granularity == "quarter":
  127. # Q1 = Mar 31st
  128. # Q2 = Jun 30th
  129. # Q3 = Sep 30th
  130. # Q4 = Dec 31st
  131. result = get_quarter(value)[1]
  132. elif granularity == "month":
  133. result = value + relativedelta(day=1, months=1, days=-1)
  134. elif granularity == 'week':
  135. # `calendar.weekday` uses ISO8601 for start of week reference, this means that
  136. # by default MONDAY is the first day of the week and SUNDAY is the last.
  137. result = value + relativedelta(days=6-calendar.weekday(value.year, value.month, value.day))
  138. elif granularity == "day":
  139. result = value
  140. elif granularity == "hour" and is_datetime:
  141. return datetime.combine(value, time.max).replace(hour=value.hour)
  142. elif is_datetime:
  143. raise ValueError(
  144. "Granularity must be year, quarter, month, week, day or hour for value %s" % value
  145. )
  146. else:
  147. raise ValueError(
  148. "Granularity must be year, quarter, month, week or day for value %s" % value
  149. )
  150. return datetime.combine(result, time.max) if is_datetime else result
  151. def add(value: D, *args, **kwargs) -> D:
  152. """
  153. Return the sum of ``value`` and a :class:`relativedelta`.
  154. :param value: initial date or datetime.
  155. :param args: positional args to pass directly to :class:`relativedelta`.
  156. :param kwargs: keyword args to pass directly to :class:`relativedelta`.
  157. :return: the resulting date/datetime.
  158. """
  159. return value + relativedelta(*args, **kwargs)
  160. def subtract(value: D, *args, **kwargs) -> D:
  161. """
  162. Return the difference between ``value`` and a :class:`relativedelta`.
  163. :param value: initial date or datetime.
  164. :param args: positional args to pass directly to :class:`relativedelta`.
  165. :param kwargs: keyword args to pass directly to :class:`relativedelta`.
  166. :return: the resulting date/datetime.
  167. """
  168. return value - relativedelta(*args, **kwargs)
  169. def date_range(start: D, end: D, step: relativedelta = relativedelta(months=1)) -> Iterator[datetime]:
  170. """Date range generator with a step interval.
  171. :param start: beginning date of the range.
  172. :param end: ending date of the range.
  173. :param step: interval of the range.
  174. :return: a range of datetime from start to end.
  175. """
  176. if isinstance(start, datetime) and isinstance(end, datetime):
  177. are_naive = start.tzinfo is None and end.tzinfo is None
  178. are_utc = start.tzinfo == pytz.utc and end.tzinfo == pytz.utc
  179. # Cases with miscellenous timezone are more complexe because of DST.
  180. are_others = start.tzinfo and end.tzinfo and not are_utc
  181. if are_others and start.tzinfo.zone != end.tzinfo.zone:
  182. raise ValueError("Timezones of start argument and end argument seem inconsistent")
  183. if not are_naive and not are_utc and not are_others:
  184. raise ValueError("Timezones of start argument and end argument mismatch")
  185. dt = start.replace(tzinfo=None)
  186. end_dt = end.replace(tzinfo=None)
  187. post_process = start.tzinfo.localize if start.tzinfo else lambda dt: dt
  188. elif isinstance(start, date) and isinstance(end, date):
  189. # FIXME: not correctly typed, and will break if the step is a fractional
  190. # day: `relativedelta` will return a datetime, which can't be
  191. # compared with a `date`
  192. dt, end_dt = start, end
  193. post_process = lambda dt: dt
  194. else:
  195. raise ValueError("start/end should be both date or both datetime type")
  196. if start > end:
  197. raise ValueError("start > end, start date must be before end")
  198. if start == start + step:
  199. raise ValueError("Looks like step is null")
  200. while dt <= end_dt:
  201. yield post_process(dt)
  202. dt = dt + step
  203. def weeknumber(locale: babel.Locale, date: date) -> Tuple[int, int]:
  204. """Computes the year and weeknumber of `date`. The week number is 1-indexed
  205. (so the first week is week number 1).
  206. For ISO locales (first day of week = monday, min week days = 4) the concept
  207. is clear and the Python stdlib implements it directly.
  208. For other locales, it's basically nonsensical as there is no actual
  209. definition. For now we will implement non-split first-day-of-year, that is
  210. the first week of the year is the one which contains the first day of the
  211. year (taking first day of week in account), and the days of the previous
  212. year which are part of that week are considered to be in the next year for
  213. calendaring purposes.
  214. That is December 27, 2015 is in the first week of 2016.
  215. An alternative is to split the week in two, so the week from December 27,
  216. 2015 to January 2, 2016 would be *both* W53/2015 and W01/2016.
  217. """
  218. if locale.first_week_day == 0 and locale.min_week_days == 4:
  219. # woohoo nothing to do
  220. return date.isocalendar()[:2]
  221. # first find the first day of the first week of the next year, if the
  222. # reference date is after that then it must be in the first week of the next
  223. # year, remove this if we decide to implement split weeks instead
  224. fdny = date.replace(year=date.year + 1, month=1, day=1) \
  225. - relativedelta(weekday=weekdays[locale.first_week_day](-1))
  226. if date >= fdny:
  227. return date.year + 1, 1
  228. # otherwise get the number of periods of 7 days between the first day of the
  229. # first week and the reference
  230. fdow = date.replace(month=1, day=1) \
  231. - relativedelta(weekday=weekdays[locale.first_week_day](-1))
  232. doy = (date - fdow).days
  233. return date.year, (doy // 7 + 1)
上海开阖软件有限公司 沪ICP备12045867号-1