19.0 vanilla

This commit is contained in:
Ernad Husremovic 2025-10-03 18:07:25 +02:00
parent 0a7ae8db93
commit 991d2234ca
416 changed files with 646602 additions and 300844 deletions

View file

@ -1,60 +1,228 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import calendar
import math
from datetime import date, datetime, time
from typing import Tuple, TypeVar, Literal, Iterator, Type
import re
import typing
from datetime import date, datetime, time, timedelta, tzinfo
import babel
import pytz
from dateutil.relativedelta import relativedelta, weekdays
from .func import lazy
from .float_utils import float_round
D = TypeVar('D', date, datetime)
if typing.TYPE_CHECKING:
import babel
from collections.abc import Callable, Iterable, Iterator
from odoo.orm.types import Environment
D = typing.TypeVar('D', date, datetime)
utc = pytz.utc
TRUNCATE_TODAY = relativedelta(microsecond=0, second=0, minute=0, hour=0)
TRUNCATE_UNIT = {
'day': TRUNCATE_TODAY,
'month': TRUNCATE_TODAY,
'year': TRUNCATE_TODAY,
'week': TRUNCATE_TODAY,
'hour': relativedelta(microsecond=0, second=0, minute=0),
'minute': relativedelta(microsecond=0, second=0),
'second': relativedelta(microsecond=0),
}
WEEKDAY_NUMBER = dict(zip(
('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'),
range(7),
strict=True,
))
_SHORT_DATE_UNIT = {
'd': 'days',
'm': 'months',
'y': 'years',
'w': 'weeks',
'H': 'hours',
'M': 'minutes',
'S': 'seconds',
}
__all__ = [
'date_range',
'float_to_time',
'get_fiscal_year',
'get_month',
'get_quarter',
'get_quarter_number',
'get_timedelta',
'localized',
'parse_date',
'parse_iso_date',
'sum_intervals',
'time_to_float',
'to_timezone',
]
def date_type(value: D) -> Type[D]:
''' Return either the datetime.datetime class or datetime.date type whether `value` is a datetime or a date.
:param value: A datetime.datetime or datetime.date object.
:return: datetime.datetime or datetime.date
'''
return datetime if isinstance(value, datetime) else date
def float_to_time(hours: float) -> time:
""" Convert a number of hours into a time object. """
if hours == 24.0:
return time.max
fractional, integral = math.modf(hours)
return time(int(integral), int(float_round(60 * fractional, precision_digits=0)), 0)
def get_month(date: D) -> Tuple[D, D]:
''' Compute the month dates range on which the 'date' parameter belongs to.
'''
def time_to_float(duration: time | timedelta) -> float:
""" Convert a time object to a number of hours. """
if isinstance(duration, timedelta):
return duration.total_seconds() / 3600
if duration == time.max:
return 24.0
seconds = duration.microsecond / 1_000_000 + duration.second + duration.minute * 60
return seconds / 3600 + duration.hour
def localized(dt: datetime) -> datetime:
""" When missing, add tzinfo to a datetime. """
return dt if dt.tzinfo else dt.replace(tzinfo=utc)
def to_timezone(tz: tzinfo | None) -> Callable[[datetime], datetime]:
""" Get a function converting a datetime to another localized datetime. """
if tz is None:
return lambda dt: dt.astimezone(utc).replace(tzinfo=None)
return lambda dt: dt.astimezone(tz)
def parse_iso_date(value: str) -> date | datetime:
""" Parse a ISO encoded string to a date or datetime.
:raises ValueError: when the format is invalid or has a timezone
"""
# Looks like ISO format
if len(value) <= 10:
return date.fromisoformat(value)
now = datetime.fromisoformat(value)
if now.tzinfo is not None:
raise ValueError(f"expecting only datetimes with no timezone: {value!r}")
return now
def parse_date(value: str, env: Environment) -> date | datetime:
r""" Parse a technical date string into a date or datetime.
This supports ISO formatted dates and dates relative to now.
`parse_iso_date` is used if the input starts with r'\d+-'.
Otherwise, the date is computed by starting from now at user's timezone.
We can also start 'today' (resulting in a date type). Then we apply offsets:
- we can add 'd', 'w', 'm', 'y', 'H', 'M', 'S':
days, weeks, months, years, hours, minutes, seconds
- "+3d" to add 3 days
- "-1m" to subtract one month
- we can set a part of the date which will reset to midnight or only lower
date parts
- "=1d" sets first day of month at midnight
- "=6m" sets June and resets to midnight
- "=3H" sets time to 3:00:00
- weekdays are handled similarly
- "=tuesday" sets to Tuesday of the current week at midnight
- "+monday" goes to next Monday (no change if we are on Monday)
- "=week_start" sets to the first day of the current week, according to the locale
The DSL for relative dates is as follows:
```
relative_date := ('today' | 'now')? offset*
offset := date_rel | time_rel | weekday
date_rel := (regex) [=+-]\d+[dwmy]
time_rel := (regex) [=+-]\d+[HMS]
weekday := [=+-] ('monday' | ... | 'sunday' | 'week_start')
```
An equivalent function is JavaScript is `parseSmartDateInput`.
:param value: The string to parse
:param env: The environment to get the current date (in user's tz)
:param naive: Whether to cast the result to a naive datetime.
"""
if re.match(r'\d+-', value):
return parse_iso_date(value)
terms = value.split()
if not terms:
raise ValueError("Empty date value")
# Find the starting point
from odoo.orm.fields_temporal import Date, Datetime # noqa: PLC0415
dt: datetime | date = Datetime.now()
term = terms.pop(0) if terms[0] in ('today', 'now') else 'now'
if term == 'today':
dt = Date.context_today(env['base'], dt)
else:
dt = Datetime.context_timestamp(env['base'], dt)
for term in terms:
operator = term[0]
if operator not in ('+', '-', '=') or len(term) < 3:
raise ValueError(f"Invalid term {term!r} in expression date: {value!r}")
# Weekday
dayname = term[1:]
if dayname in WEEKDAY_NUMBER or dayname == "week_start":
week_start = int(env["res.lang"]._get_data(code=env.user.lang).week_start) - 1
weekday = week_start if dayname == "week_start" else WEEKDAY_NUMBER[dayname]
weekday_offset = ((weekday - week_start) % 7) - ((dt.weekday() - week_start) % 7)
if operator in ('+', '-'):
if operator == '+' and weekday_offset < 0:
weekday_offset += 7
elif operator == '-' and weekday_offset > 0:
weekday_offset -= 7
elif isinstance(dt, datetime):
dt += TRUNCATE_TODAY
dt += timedelta(weekday_offset)
continue
# Operations on dates
try:
unit = _SHORT_DATE_UNIT[term[-1]]
if operator in ('+', '-'):
number = int(term[:-1]) # positive or negative
else:
number = int(term[1:-1])
unit = unit.removesuffix('s')
if isinstance(dt, datetime):
dt += TRUNCATE_UNIT[unit]
# note: '=Nw' is not supported
dt += relativedelta(**{unit: number})
except (ValueError, TypeError, KeyError):
raise ValueError(f"Invalid term {term!r} in expression date: {value!r}")
# always return a naive date
if isinstance(dt, datetime) and dt.tzinfo is not None:
dt = dt.astimezone(pytz.utc).replace(tzinfo=None)
return dt
def get_month(date: D) -> tuple[D, D]:
""" Compute the month date range from a date (set first and last day of month).
"""
return date.replace(day=1), date.replace(day=calendar.monthrange(date.year, date.month)[1])
def get_quarter_number(date: date) -> int:
''' Get the number of the quarter on which the 'date' parameter belongs to.
'''
return math.ceil(date.month / 3)
""" Get the quarter from a date (1-4)."""
return (date.month - 1) // 3 + 1
def get_quarter(date: D) -> Tuple[D, D]:
''' Compute the quarter dates range on which the 'date' parameter belongs to.
'''
quarter_number = get_quarter_number(date)
month_from = ((quarter_number - 1) * 3) + 1
def get_quarter(date: D) -> tuple[D, D]:
""" Compute the quarter date range from a date (set first and last day of quarter).
"""
month_from = (date.month - 1) // 3 * 3 + 1
date_from = date.replace(month=month_from, day=1)
date_to = date_from + relativedelta(months=2)
date_to = date_from.replace(month=month_from + 2)
date_to = date_to.replace(day=calendar.monthrange(date_to.year, date_to.month)[1])
return date_from, date_to
def get_fiscal_year(date: D, day: int = 31, month: int = 12) -> Tuple[D, D]:
''' Compute the fiscal year dates range on which the 'date' parameter belongs to.
def get_fiscal_year(date: D, day: int = 31, month: int = 12) -> tuple[D, D]:
""" Compute the fiscal year date range from a date (first and last day of fiscal year).
A fiscal year is the period used by governments for accounting purposes and vary between countries.
By default, calling this method with only one parameter gives the calendar year because the ending date of the
fiscal year is set to the YYYY-12-31.
@ -63,7 +231,7 @@ def get_fiscal_year(date: D, day: int = 31, month: int = 12) -> Tuple[D, D]:
:param day: The day of month the fiscal year ends.
:param month: The month of year the fiscal year ends.
:return: The start and end dates of the fiscal year.
'''
"""
def fix_day(year, month, day):
max_day = calendar.monthrange(year, month)[1]
@ -86,7 +254,7 @@ def get_fiscal_year(date: D, day: int = 31, month: int = 12) -> Tuple[D, D]:
return date_from, date_to
def get_timedelta(qty: int, granularity: Literal['hour', 'day', 'week', 'month', 'year']):
def get_timedelta(qty: int, granularity: typing.Literal['hour', 'day', 'week', 'month', 'year']):
""" Helper to get a `relativedelta` object for the given quantity and interval unit.
"""
switch = {
@ -99,7 +267,7 @@ def get_timedelta(qty: int, granularity: Literal['hour', 'day', 'week', 'month',
return switch[granularity]
Granularity = Literal['year', 'quarter', 'month', 'week', 'day', 'hour']
Granularity = typing.Literal['year', 'quarter', 'month', 'week', 'day', 'hour']
def start_of(value: D, granularity: Granularity) -> D:
@ -163,7 +331,7 @@ def end_of(value: D, granularity: Granularity) -> D:
elif granularity == 'week':
# `calendar.weekday` uses ISO8601 for start of week reference, this means that
# by default MONDAY is the first day of the week and SUNDAY is the last.
result = value + relativedelta(days=6-calendar.weekday(value.year, value.month, value.day))
result = value + relativedelta(days=6 - calendar.weekday(value.year, value.month, value.day))
elif granularity == "day":
result = value
elif granularity == "hour" and is_datetime:
@ -208,11 +376,12 @@ def date_range(start: D, end: D, step: relativedelta = relativedelta(months=1))
"""Date range generator with a step interval.
:param start: beginning date of the range.
:param end: ending date of the range.
:param step: interval of the range.
:param end: ending date of the range (inclusive).
:param step: interval of the range (positive).
:return: a range of datetime from start to end.
"""
post_process = lambda dt: dt # noqa: E731
if isinstance(start, datetime) and isinstance(end, datetime):
are_naive = start.tzinfo is None and end.tzinfo is None
are_utc = start.tzinfo == pytz.utc and end.tzinfo == pytz.utc
@ -226,32 +395,37 @@ def date_range(start: D, end: D, step: relativedelta = relativedelta(months=1))
if not are_naive and not are_utc and not are_others:
raise ValueError("Timezones of start argument and end argument mismatch")
dt = start.replace(tzinfo=None)
end_dt = end.replace(tzinfo=None)
post_process = start.tzinfo.localize if start.tzinfo else lambda dt: dt
if not are_naive:
post_process = start.tzinfo.localize
start = start.replace(tzinfo=None)
end = end.replace(tzinfo=None)
elif isinstance(start, date) and isinstance(end, date):
# FIXME: not correctly typed, and will break if the step is a fractional
# day: `relativedelta` will return a datetime, which can't be
# compared with a `date`
dt, end_dt = start, end
post_process = lambda dt: dt
if not isinstance(start + step, date):
raise ValueError("the step interval must add only entire days") # noqa: TRY004
else:
raise ValueError("start/end should be both date or both datetime type")
raise ValueError("start/end should be both date or both datetime type") # noqa: TRY004
if start > end:
raise ValueError("start > end, start date must be before end")
if start == start + step:
raise ValueError("Looks like step is null")
if start >= start + step:
raise ValueError("Looks like step is null or negative")
while dt <= end_dt:
yield post_process(dt)
dt = dt + step
while start <= end:
yield post_process(start)
start += step
def weeknumber(locale: babel.Locale, date: date) -> Tuple[int, int]:
def sum_intervals(intervals: Iterable[tuple[datetime, datetime, ...]]) -> float:
""" Sum the intervals duration (unit: hour)"""
return sum(
(interval[1] - interval[0]).total_seconds() / 3600
for interval in intervals
)
def weeknumber(locale: babel.Locale, date: date) -> tuple[int, int]:
"""Computes the year and weeknumber of `date`. The week number is 1-indexed
(so the first week is week number 1).
@ -289,3 +463,31 @@ def weeknumber(locale: babel.Locale, date: date) -> Tuple[int, int]:
doy = (date - fdow).days
return date.year, (doy // 7 + 1)
def weekstart(locale: babel.Locale, date: date):
"""
Return the first weekday of the week containing `day`
If `day` is already that weekday, it is returned unchanged.
Otherwise, it is shifted back to the most recent such weekday.
Examples: week starts Sunday
- weekstart of Sat 30 Aug -> Sun 24 Aug
- weekstart of Sat 23 Aug -> Sun 17 Aug
"""
return date + relativedelta(weekday=weekdays[locale.first_week_day](-1))
def weekend(locale: babel.Locale, date: date):
"""
Return the last weekday of the week containing `day`
If `day` is already that weekday, it is returned unchanged.
Otherwise, it is shifted forward to the next such weekday.
Examples: week starts Sunday (so week ends Saturday)
- weekend of Sun 24 Aug -> Sat 30 Aug
- weekend of Sat 30 Aug -> Sat 30 Aug
"""
return weekstart(locale, date) + relativedelta(days=6)