Coverage for adhoc-cicd-odoo-odoo / odoo / tools / date_utils.py: 17%

195 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-09 18:22 +0000

1from __future__ import annotations 

2 

3import calendar 

4import math 

5import re 

6import typing 

7from datetime import date, datetime, time, timedelta, tzinfo 

8 

9import pytz 

10from dateutil.relativedelta import relativedelta, weekdays 

11 

12from .float_utils import float_round 

13 

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) 

19 

20utc = pytz.utc 

21 

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} 

46 

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] 

62 

63 

64def float_to_time(hours: float) -> time: 

65 """ Convert a number of hours into a time object. """ 

66 if hours == 24.0: 

67 return time.max 

68 fractional, integral = math.modf(hours) 

69 return time(int(integral), int(float_round(60 * fractional, precision_digits=0)), 0) 

70 

71 

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 

80 

81 

82def localized(dt: datetime) -> datetime: 

83 """ When missing, add tzinfo to a datetime. """ 

84 return dt if dt.tzinfo else dt.replace(tzinfo=utc) 

85 

86 

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: 

90 return lambda dt: dt.astimezone(utc).replace(tzinfo=None) 

91 return lambda dt: dt.astimezone(tz) 

92 

93 

94def parse_iso_date(value: str) -> date | datetime: 

95 """ Parse a ISO encoded string to a date or datetime. 

96 

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 

106 

107 

108def parse_date(value: str, env: Environment) -> date | datetime: 

109 r""" Parse a technical date string into a date or datetime. 

110 

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: 

115 

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 

129 

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 ``` 

138 

139 An equivalent function is JavaScript is `parseSmartDateInput`. 

140 

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") 

150 

151 # Find the starting point 

152 from odoo.orm.fields_temporal import Date, Datetime # noqa: PLC0415 

153 

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) 

160 

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}") 

165 

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 

181 

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}") 

196 

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 

201 

202 

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]) 

207 

208 

209def get_quarter_number(date: date) -> int: 

210 """ Get the quarter from a date (1-4).""" 

211 return (date.month - 1) // 3 + 1 

212 

213 

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 

222 

223 

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. 

229 

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 """ 

235 

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) 

241 

242 date_to = date.replace(month=month, day=fix_day(date.year, month, day)) 

243 

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 

255 

256 

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] 

268 

269 

270Granularity = typing.Literal['year', 'quarter', 'month', 'week', 'day', 'hour'] 

271 

272 

273def start_of(value: D, granularity: Granularity) -> D: 

274 """ 

275 Get start of a time period from a date or a datetime. 

276 

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 ) 

308 

309 return datetime.combine(result, time.min) if is_datetime else result 

310 

311 

312def end_of(value: D, granularity: Granularity) -> D: 

313 """ 

314 Get end of a time period from a date or a datetime. 

315 

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 ) 

347 

348 return datetime.combine(result, time.max) if is_datetime else result 

349 

350 

351def add(value: D, *args, **kwargs) -> D: 

352 """ 

353 Return the sum of ``value`` and a :class:`relativedelta`. 

354 

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) 

361 

362 

363def subtract(value: D, *args, **kwargs) -> D: 

364 """ 

365 Return the difference between ``value`` and a :class:`relativedelta`. 

366 

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) 

373 

374 

375def date_range(start: D, end: D, step: relativedelta = relativedelta(months=1)) -> Iterator[datetime]: 

376 """Date range generator with a step interval. 

377 

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 """ 

383 

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 

388 

389 # Cases with miscellenous timezone are more complexe because of DST. 

390 are_others = start.tzinfo and end.tzinfo and not are_utc 

391 

392 if are_others and start.tzinfo.zone != end.tzinfo.zone: 

393 raise ValueError("Timezones of start argument and end argument seem inconsistent") 

394 

395 if not are_naive and not are_utc and not are_others: 

396 raise ValueError("Timezones of start argument and end argument mismatch") 

397 

398 if not are_naive: 

399 post_process = start.tzinfo.localize 

400 start = start.replace(tzinfo=None) 

401 end = end.replace(tzinfo=None) 

402 

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 

408 

409 if start > end: 

410 raise ValueError("start > end, start date must be before end") 

411 

412 if start >= start + step: 

413 raise ValueError("Looks like step is null or negative") 

414 

415 while start <= end: 

416 yield post_process(start) 

417 start += step 

418 

419 

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 ) 

426 

427 

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). 

431 

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. 

434 

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. 

441 

442 That is December 27, 2015 is in the first week of 2016. 

443 

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] 

450 

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 

458 

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 

464 

465 return date.year, (doy // 7 + 1) 

466 

467 

468def weekstart(locale: babel.Locale, date: date): 

469 """ 

470 Return the first weekday of the week containing `day` 

471 

472 If `day` is already that weekday, it is returned unchanged. 

473 Otherwise, it is shifted back to the most recent such weekday. 

474 

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)) 

480 

481 

482def weekend(locale: babel.Locale, date: date): 

483 """ 

484 Return the last weekday of the week containing `day` 

485 

486 If `day` is already that weekday, it is returned unchanged. 

487 Otherwise, it is shifted forward to the next such weekday. 

488 

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)