Coverage for adhoc-cicd-odoo-odoo / odoo / tools / date_utils.py: 21%
195 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:05 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 18:05 +0000
1from __future__ import annotations
3import calendar
4import math
5import re
6import typing
7from datetime import date, datetime, time, timedelta, tzinfo
9import pytz
10from dateutil.relativedelta import relativedelta, weekdays
12from .float_utils import float_round
14if typing.TYPE_CHECKING:
15 import babel
16 from collections.abc import Callable, Iterable, Iterator
17 from odoo.orm.types import Environment
18 D = typing.TypeVar('D', date, datetime)
20utc = pytz.utc
22TRUNCATE_TODAY = relativedelta(microsecond=0, second=0, minute=0, hour=0)
23TRUNCATE_UNIT = {
24 'day': TRUNCATE_TODAY,
25 'month': TRUNCATE_TODAY,
26 'year': TRUNCATE_TODAY,
27 'week': TRUNCATE_TODAY,
28 'hour': relativedelta(microsecond=0, second=0, minute=0),
29 'minute': relativedelta(microsecond=0, second=0),
30 'second': relativedelta(microsecond=0),
31}
32WEEKDAY_NUMBER = dict(zip(
33 ('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'),
34 range(7),
35 strict=True,
36))
37_SHORT_DATE_UNIT = {
38 'd': 'days',
39 'm': 'months',
40 'y': 'years',
41 'w': 'weeks',
42 'H': 'hours',
43 'M': 'minutes',
44 'S': 'seconds',
45}
47__all__ = [
48 'date_range',
49 'float_to_time',
50 'get_fiscal_year',
51 'get_month',
52 'get_quarter',
53 'get_quarter_number',
54 'get_timedelta',
55 'localized',
56 'parse_date',
57 'parse_iso_date',
58 'sum_intervals',
59 'time_to_float',
60 'to_timezone',
61]
64def float_to_time(hours: float) -> time:
65 """ Convert a number of hours into a time object. """
66 if hours == 24.0: 66 ↛ 67line 66 didn't jump to line 67 because the condition on line 66 was never true
67 return time.max
68 fractional, integral = math.modf(hours)
69 return time(int(integral), int(float_round(60 * fractional, precision_digits=0)), 0)
72def time_to_float(duration: time | timedelta) -> float:
73 """ Convert a time object to a number of hours. """
74 if isinstance(duration, timedelta):
75 return duration.total_seconds() / 3600
76 if duration == time.max:
77 return 24.0
78 seconds = duration.microsecond / 1_000_000 + duration.second + duration.minute * 60
79 return seconds / 3600 + duration.hour
82def localized(dt: datetime) -> datetime:
83 """ When missing, add tzinfo to a datetime. """
84 return dt if dt.tzinfo else dt.replace(tzinfo=utc)
87def to_timezone(tz: tzinfo | None) -> Callable[[datetime], datetime]:
88 """ Get a function converting a datetime to another localized datetime. """
89 if tz is None: 89 ↛ 91line 89 didn't jump to line 91 because the condition on line 89 was always true
90 return lambda dt: dt.astimezone(utc).replace(tzinfo=None)
91 return lambda dt: dt.astimezone(tz)
94def parse_iso_date(value: str) -> date | datetime:
95 """ Parse a ISO encoded string to a date or datetime.
97 :raises ValueError: when the format is invalid or has a timezone
98 """
99 # Looks like ISO format
100 if len(value) <= 10: 100 ↛ 102line 100 didn't jump to line 102 because the condition on line 100 was always true
101 return date.fromisoformat(value)
102 now = datetime.fromisoformat(value)
103 if now.tzinfo is not None:
104 raise ValueError(f"expecting only datetimes with no timezone: {value!r}")
105 return now
108def parse_date(value: str, env: Environment) -> date | datetime:
109 r""" Parse a technical date string into a date or datetime.
111 This supports ISO formatted dates and dates relative to now.
112 `parse_iso_date` is used if the input starts with r'\d+-'.
113 Otherwise, the date is computed by starting from now at user's timezone.
114 We can also start 'today' (resulting in a date type). Then we apply offsets:
116 - we can add 'd', 'w', 'm', 'y', 'H', 'M', 'S':
117 days, weeks, months, years, hours, minutes, seconds
118 - "+3d" to add 3 days
119 - "-1m" to subtract one month
120 - we can set a part of the date which will reset to midnight or only lower
121 date parts
122 - "=1d" sets first day of month at midnight
123 - "=6m" sets June and resets to midnight
124 - "=3H" sets time to 3:00:00
125 - weekdays are handled similarly
126 - "=tuesday" sets to Tuesday of the current week at midnight
127 - "+monday" goes to next Monday (no change if we are on Monday)
128 - "=week_start" sets to the first day of the current week, according to the locale
130 The DSL for relative dates is as follows:
131 ```
132 relative_date := ('today' | 'now')? offset*
133 offset := date_rel | time_rel | weekday
134 date_rel := (regex) [=+-]\d+[dwmy]
135 time_rel := (regex) [=+-]\d+[HMS]
136 weekday := [=+-] ('monday' | ... | 'sunday' | 'week_start')
137 ```
139 An equivalent function is JavaScript is `parseSmartDateInput`.
141 :param value: The string to parse
142 :param env: The environment to get the current date (in user's tz)
143 :param naive: Whether to cast the result to a naive datetime.
144 """
145 if re.match(r'\d+-', value):
146 return parse_iso_date(value)
147 terms = value.split()
148 if not terms:
149 raise ValueError("Empty date value")
151 # Find the starting point
152 from odoo.orm.fields_temporal import Date, Datetime # noqa: PLC0415
154 dt: datetime | date = Datetime.now()
155 term = terms.pop(0) if terms[0] in ('today', 'now') else 'now'
156 if term == 'today':
157 dt = Date.context_today(env['base'], dt)
158 else:
159 dt = Datetime.context_timestamp(env['base'], dt)
161 for term in terms:
162 operator = term[0]
163 if operator not in ('+', '-', '=') or len(term) < 3:
164 raise ValueError(f"Invalid term {term!r} in expression date: {value!r}")
166 # Weekday
167 dayname = term[1:]
168 if dayname in WEEKDAY_NUMBER or dayname == "week_start":
169 week_start = int(env["res.lang"]._get_data(code=env.user.lang).week_start) - 1
170 weekday = week_start if dayname == "week_start" else WEEKDAY_NUMBER[dayname]
171 weekday_offset = ((weekday - week_start) % 7) - ((dt.weekday() - week_start) % 7)
172 if operator in ('+', '-'):
173 if operator == '+' and weekday_offset < 0:
174 weekday_offset += 7
175 elif operator == '-' and weekday_offset > 0:
176 weekday_offset -= 7
177 elif isinstance(dt, datetime):
178 dt += TRUNCATE_TODAY
179 dt += timedelta(weekday_offset)
180 continue
182 # Operations on dates
183 try:
184 unit = _SHORT_DATE_UNIT[term[-1]]
185 if operator in ('+', '-'):
186 number = int(term[:-1]) # positive or negative
187 else:
188 number = int(term[1:-1])
189 unit = unit.removesuffix('s')
190 if isinstance(dt, datetime):
191 dt += TRUNCATE_UNIT[unit]
192 # note: '=Nw' is not supported
193 dt += relativedelta(**{unit: number})
194 except (ValueError, TypeError, KeyError):
195 raise ValueError(f"Invalid term {term!r} in expression date: {value!r}")
197 # always return a naive date
198 if isinstance(dt, datetime) and dt.tzinfo is not None:
199 dt = dt.astimezone(pytz.utc).replace(tzinfo=None)
200 return dt
203def get_month(date: D) -> tuple[D, D]:
204 """ Compute the month date range from a date (set first and last day of month).
205 """
206 return date.replace(day=1), date.replace(day=calendar.monthrange(date.year, date.month)[1])
209def get_quarter_number(date: date) -> int:
210 """ Get the quarter from a date (1-4)."""
211 return (date.month - 1) // 3 + 1
214def get_quarter(date: D) -> tuple[D, D]:
215 """ Compute the quarter date range from a date (set first and last day of quarter).
216 """
217 month_from = (date.month - 1) // 3 * 3 + 1
218 date_from = date.replace(month=month_from, day=1)
219 date_to = date_from.replace(month=month_from + 2)
220 date_to = date_to.replace(day=calendar.monthrange(date_to.year, date_to.month)[1])
221 return date_from, date_to
224def get_fiscal_year(date: D, day: int = 31, month: int = 12) -> tuple[D, D]:
225 """ Compute the fiscal year date range from a date (first and last day of fiscal year).
226 A fiscal year is the period used by governments for accounting purposes and vary between countries.
227 By default, calling this method with only one parameter gives the calendar year because the ending date of the
228 fiscal year is set to the YYYY-12-31.
230 :param date: A date belonging to the fiscal year
231 :param day: The day of month the fiscal year ends.
232 :param month: The month of year the fiscal year ends.
233 :return: The start and end dates of the fiscal year.
234 """
236 def fix_day(year, month, day):
237 max_day = calendar.monthrange(year, month)[1]
238 if month == 2 and day in (28, max_day):
239 return max_day
240 return min(day, max_day)
242 date_to = date.replace(month=month, day=fix_day(date.year, month, day))
244 if date <= date_to:
245 date_from = date_to - relativedelta(years=1)
246 day = fix_day(date_from.year, date_from.month, date_from.day)
247 date_from = date_from.replace(day=day)
248 date_from += relativedelta(days=1)
249 else:
250 date_from = date_to + relativedelta(days=1)
251 date_to = date_to + relativedelta(years=1)
252 day = fix_day(date_to.year, date_to.month, date_to.day)
253 date_to = date_to.replace(day=day)
254 return date_from, date_to
257def get_timedelta(qty: int, granularity: typing.Literal['hour', 'day', 'week', 'month', 'year']):
258 """ Helper to get a `relativedelta` object for the given quantity and interval unit.
259 """
260 switch = {
261 'hour': relativedelta(hours=qty),
262 'day': relativedelta(days=qty),
263 'week': relativedelta(weeks=qty),
264 'month': relativedelta(months=qty),
265 'year': relativedelta(years=qty),
266 }
267 return switch[granularity]
270Granularity = typing.Literal['year', 'quarter', 'month', 'week', 'day', 'hour']
273def start_of(value: D, granularity: Granularity) -> D:
274 """
275 Get start of a time period from a date or a datetime.
277 :param value: initial date or datetime.
278 :param granularity: type of period in string, can be year, quarter, month, week, day or hour.
279 :return: a date/datetime object corresponding to the start of the specified period.
280 """
281 is_datetime = isinstance(value, datetime)
282 if granularity == "year":
283 result = value.replace(month=1, day=1)
284 elif granularity == "quarter":
285 # Q1 = Jan 1st
286 # Q2 = Apr 1st
287 # Q3 = Jul 1st
288 # Q4 = Oct 1st
289 result = get_quarter(value)[0]
290 elif granularity == "month":
291 result = value.replace(day=1)
292 elif granularity == 'week':
293 # `calendar.weekday` uses ISO8601 for start of week reference, this means that
294 # by default MONDAY is the first day of the week and SUNDAY is the last.
295 result = value - relativedelta(days=calendar.weekday(value.year, value.month, value.day))
296 elif granularity == "day":
297 result = value
298 elif granularity == "hour" and is_datetime:
299 return datetime.combine(value, time.min).replace(hour=value.hour)
300 elif is_datetime:
301 raise ValueError(
302 "Granularity must be year, quarter, month, week, day or hour for value %s" % value
303 )
304 else:
305 raise ValueError(
306 "Granularity must be year, quarter, month, week or day for value %s" % value
307 )
309 return datetime.combine(result, time.min) if is_datetime else result
312def end_of(value: D, granularity: Granularity) -> D:
313 """
314 Get end of a time period from a date or a datetime.
316 :param value: initial date or datetime.
317 :param granularity: Type of period in string, can be year, quarter, month, week, day or hour.
318 :return: A date/datetime object corresponding to the start of the specified period.
319 """
320 is_datetime = isinstance(value, datetime)
321 if granularity == "year": 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true
322 result = value.replace(month=12, day=31)
323 elif granularity == "quarter": 323 ↛ 328line 323 didn't jump to line 328 because the condition on line 323 was never true
324 # Q1 = Mar 31st
325 # Q2 = Jun 30th
326 # Q3 = Sep 30th
327 # Q4 = Dec 31st
328 result = get_quarter(value)[1]
329 elif granularity == "month": 329 ↛ 331line 329 didn't jump to line 331 because the condition on line 329 was always true
330 result = value + relativedelta(day=1, months=1, days=-1)
331 elif granularity == 'week':
332 # `calendar.weekday` uses ISO8601 for start of week reference, this means that
333 # by default MONDAY is the first day of the week and SUNDAY is the last.
334 result = value + relativedelta(days=6 - calendar.weekday(value.year, value.month, value.day))
335 elif granularity == "day":
336 result = value
337 elif granularity == "hour" and is_datetime:
338 return datetime.combine(value, time.max).replace(hour=value.hour)
339 elif is_datetime:
340 raise ValueError(
341 "Granularity must be year, quarter, month, week, day or hour for value %s" % value
342 )
343 else:
344 raise ValueError(
345 "Granularity must be year, quarter, month, week or day for value %s" % value
346 )
348 return datetime.combine(result, time.max) if is_datetime else result
351def add(value: D, *args, **kwargs) -> D:
352 """
353 Return the sum of ``value`` and a :class:`relativedelta`.
355 :param value: initial date or datetime.
356 :param args: positional args to pass directly to :class:`relativedelta`.
357 :param kwargs: keyword args to pass directly to :class:`relativedelta`.
358 :return: the resulting date/datetime.
359 """
360 return value + relativedelta(*args, **kwargs)
363def subtract(value: D, *args, **kwargs) -> D:
364 """
365 Return the difference between ``value`` and a :class:`relativedelta`.
367 :param value: initial date or datetime.
368 :param args: positional args to pass directly to :class:`relativedelta`.
369 :param kwargs: keyword args to pass directly to :class:`relativedelta`.
370 :return: the resulting date/datetime.
371 """
372 return value - relativedelta(*args, **kwargs)
375def date_range(start: D, end: D, step: relativedelta = relativedelta(months=1)) -> Iterator[datetime]:
376 """Date range generator with a step interval.
378 :param start: beginning date of the range.
379 :param end: ending date of the range (inclusive).
380 :param step: interval of the range (positive).
381 :return: a range of datetime from start to end.
382 """
384 post_process = lambda dt: dt # noqa: E731
385 if isinstance(start, datetime) and isinstance(end, datetime):
386 are_naive = start.tzinfo is None and end.tzinfo is None
387 are_utc = start.tzinfo == pytz.utc and end.tzinfo == pytz.utc
389 # Cases with miscellenous timezone are more complexe because of DST.
390 are_others = start.tzinfo and end.tzinfo and not are_utc
392 if are_others and start.tzinfo.zone != end.tzinfo.zone:
393 raise ValueError("Timezones of start argument and end argument seem inconsistent")
395 if not are_naive and not are_utc and not are_others:
396 raise ValueError("Timezones of start argument and end argument mismatch")
398 if not are_naive:
399 post_process = start.tzinfo.localize
400 start = start.replace(tzinfo=None)
401 end = end.replace(tzinfo=None)
403 elif isinstance(start, date) and isinstance(end, date):
404 if not isinstance(start + step, date):
405 raise ValueError("the step interval must add only entire days") # noqa: TRY004
406 else:
407 raise ValueError("start/end should be both date or both datetime type") # noqa: TRY004
409 if start > end:
410 raise ValueError("start > end, start date must be before end")
412 if start >= start + step:
413 raise ValueError("Looks like step is null or negative")
415 while start <= end:
416 yield post_process(start)
417 start += step
420def sum_intervals(intervals: Iterable[tuple[datetime, datetime, ...]]) -> float:
421 """ Sum the intervals duration (unit: hour)"""
422 return sum(
423 (interval[1] - interval[0]).total_seconds() / 3600
424 for interval in intervals
425 )
428def weeknumber(locale: babel.Locale, date: date) -> tuple[int, int]:
429 """Computes the year and weeknumber of `date`. The week number is 1-indexed
430 (so the first week is week number 1).
432 For ISO locales (first day of week = monday, min week days = 4) the concept
433 is clear and the Python stdlib implements it directly.
435 For other locales, it's basically nonsensical as there is no actual
436 definition. For now we will implement non-split first-day-of-year, that is
437 the first week of the year is the one which contains the first day of the
438 year (taking first day of week in account), and the days of the previous
439 year which are part of that week are considered to be in the next year for
440 calendaring purposes.
442 That is December 27, 2015 is in the first week of 2016.
444 An alternative is to split the week in two, so the week from December 27,
445 2015 to January 2, 2016 would be *both* W53/2015 and W01/2016.
446 """
447 if locale.first_week_day == 0 and locale.min_week_days == 4:
448 # woohoo nothing to do
449 return date.isocalendar()[:2]
451 # first find the first day of the first week of the next year, if the
452 # reference date is after that then it must be in the first week of the next
453 # year, remove this if we decide to implement split weeks instead
454 fdny = date.replace(year=date.year + 1, month=1, day=1) \
455 - relativedelta(weekday=weekdays[locale.first_week_day](-1))
456 if date >= fdny:
457 return date.year + 1, 1
459 # otherwise get the number of periods of 7 days between the first day of the
460 # first week and the reference
461 fdow = date.replace(month=1, day=1) \
462 - relativedelta(weekday=weekdays[locale.first_week_day](-1))
463 doy = (date - fdow).days
465 return date.year, (doy // 7 + 1)
468def weekstart(locale: babel.Locale, date: date):
469 """
470 Return the first weekday of the week containing `day`
472 If `day` is already that weekday, it is returned unchanged.
473 Otherwise, it is shifted back to the most recent such weekday.
475 Examples: week starts Sunday
476 - weekstart of Sat 30 Aug -> Sun 24 Aug
477 - weekstart of Sat 23 Aug -> Sun 17 Aug
478 """
479 return date + relativedelta(weekday=weekdays[locale.first_week_day](-1))
482def weekend(locale: babel.Locale, date: date):
483 """
484 Return the last weekday of the week containing `day`
486 If `day` is already that weekday, it is returned unchanged.
487 Otherwise, it is shifted forward to the next such weekday.
489 Examples: week starts Sunday (so week ends Saturday)
490 - weekend of Sun 24 Aug -> Sat 30 Aug
491 - weekend of Sat 30 Aug -> Sat 30 Aug
492 """
493 return weekstart(locale, date) + relativedelta(days=6)