Coverage for gws-app/gws/lib/datetimex/__init__.py: 80%

310 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""Date and time utilities. 

2 

3These utilities are wrappers around the ``datetime`` module. Some functions also use 

4``pendulum`` (https://pendulum.eustace.io/), however all functions here return 

5stock ``datetime.datetime`` objects, and all returned objects are timezone-aware. 

6 

7Functions in this package fall into these groups: 

8 

9- time zones: ``time_zone``, ``is_valid_time_zone``, ``set_local_time_zone``, 

10- constructors and parsers: ``new``, ``now``, ``today``, ``parse``, ``from_string``, ``from_iso_string``, ``from_timestamp`` and others, 

11- formatters: ``to_iso_string``, ``to_iso_date_string``, ``to_basic_string``, ``to_string`` and others, 

12- converters: ``to_timestamp``, ``to_millis``, ``to_utc``, ``to_local``, ``to_time_zone``, 

13- predicates: ``is_date``, ``is_datetime``, ``is_utc``, ``is_local``, 

14- arithmetic: ``add``, ``difference``, ``total_difference``, ``next``, ``prev``, 

15- wrappers for ``pendulum`` helpers: ``start_of_<unit>`` and ``end_of_<unit>`` (for ``second``, ``minute``, ``hour``, ``day``, ``week``, ``month``, ``year``), 

16 ``day_of_week``, ``day_of_year``, ``week_of_month``, ``week_of_year``, ``days_in_month``, 

17- durations: ``parse_duration``, ``format_duration``. 

18 

19Time zones are given as zoneinfo strings, like ``Europe/Berlin``. An empty string (the default) 

20means the local time zone. Alias names like ``CEST`` are not supported. 

21 

22When a function accepts a date or time argument, it is converted to a datetime as follows: 

23 

24- ``None`` means the current date and time, 

25- naive ``datetime`` objects are assumed to be in the local time zone, 

26- ``date`` objects are promoted to ``datetime`` with the time set to midnight UTC, 

27- ``time`` objects are promoted to ``datetime`` with today's date. 

28 

29Parsers (``parse``, ``from_string`` etc.) attach the given time zone to naive input, 

30and a parsed date becomes midnight in that time zone. 

31 

32When running in a docker container, there are several ways to set up the local time zone: 

33 

34- by setting the config variable ``server.timeZone`` (see ``gws.config.parser``), 

35- by setting the ``TZ`` environment variable, 

36- by mounting a host zone info file to ``/etc/localtime``. 

37 

38Example:: 

39 

40 import gws.lib.datetimex as datetimex 

41 

42 d = datetimex.parse('2024-05-01T12:30:00', tz='Europe/Berlin') 

43 datetimex.to_iso_string(d) # '2024-05-01T12:30:00+0200' 

44 datetimex.to_iso_string(datetimex.to_utc(d), with_tz='Z') # '2024-05-01T10:30:00Z' 

45 

46 next_week = datetimex.add(d, weeks=1) 

47 datetimex.parse_duration('1h30m') # 5400 

48""" 

49 

50from typing import Optional 

51 

52import datetime as dt 

53import contextlib 

54import os 

55import re 

56import zoneinfo 

57 

58import pendulum 

59import pendulum._helpers 

60import pendulum.helpers 

61import pendulum.parsing 

62import pendulum.parsing.exceptions 

63 

64import gws 

65import gws.lib.osx 

66 

67 

68class Error(gws.Error): 

69 """Date and time error.""" 

70 

71 pass 

72 

73 

74UTC = zoneinfo.ZoneInfo('UTC') 

75"""The UTC time zone.""" 

76 

77_ZI_CACHE = { 

78 'utc': UTC, 

79 'UTC': UTC, 

80 'Etc/UTC': UTC, 

81} 

82 

83_ZI_ALL = set(zoneinfo.available_timezones()) 

84 

85 

86# Time zones 

87 

88 

89def is_valid_time_zone(tz: str) -> bool: 

90 """Check if a time zone string is valid. 

91 

92 Args: 

93 tz: Time zone string, like ``Europe/Berlin``. 

94 

95 Returns: 

96 ``True`` if the time zone is known. 

97 """ 

98 

99 return tz in _ZI_CACHE or tz in _ZI_ALL 

100 

101 

102def set_local_time_zone(tz: str): 

103 """Set the local time zone for the system. 

104 

105 The time zone is set by linking ``/etc/localtime`` to the zone info file, 

106 which requires root privileges. Nothing is done if the time zone is already set. 

107 

108 Args: 

109 tz: Time zone string, like ``Europe/Berlin``. 

110 

111 Raises: 

112 ``Error``: If the time zone is invalid, or the process is not running as root. 

113 """ 

114 new_zi = time_zone(tz) 

115 cur_zi = _zone_info_from_localtime() 

116 

117 gws.log.debug(f'set_local_time_zone: cur={cur_zi} new={new_zi}') 

118 

119 if new_zi == cur_zi: 

120 return 

121 _set_localtime_from_zone_info(new_zi) 

122 

123 gws.log.debug(f'set_local_time_zone: cur={_zone_info_from_localtime()}') 

124 

125 

126def time_zone(tz: str = '') -> zoneinfo.ZoneInfo: 

127 """Get a ZoneInfo object for the specified time zone. 

128 

129 The local time zone is determined from ``/etc/localtime``; if that fails, UTC is assumed. 

130 

131 Args: 

132 tz: Time zone string, like ``Europe/Berlin``. An empty string means the local time zone. 

133 

134 Returns: 

135 The ZoneInfo object. 

136 

137 Raises: 

138 ``Error``: If the time zone is invalid. 

139 """ 

140 

141 if tz in _ZI_CACHE: 

142 return _ZI_CACHE[tz] 

143 

144 if not tz: 

145 _ZI_CACHE[''] = _zone_info_from_localtime() 

146 return _ZI_CACHE[''] 

147 

148 return _zone_info_from_string(tz) 

149 

150 

151def _set_localtime_from_zone_info(zi): 

152 if os.getuid() != 0: 

153 raise Error('cannot set timezone, must be root') 

154 gws.lib.osx.run(['ln', '-fs', f'/usr/share/zoneinfo/{zi}', '/etc/localtime']) 

155 

156 

157def _zone_info_from_localtime(): 

158 a = '/etc/localtime' 

159 

160 try: 

161 p = os.readlink(a) 

162 except FileNotFoundError: 

163 gws.log.warning(f'time zone: {a!r} not found, assuming UTC') 

164 return UTC 

165 

166 m = re.search(r'zoneinfo/(.+)$', p) 

167 if not m: 

168 gws.log.warning(f'time zone: {a!r}={p!r} invalid, assuming UTC') 

169 return UTC 

170 

171 try: 

172 return zoneinfo.ZoneInfo(m.group(1)) 

173 except zoneinfo.ZoneInfoNotFoundError: 

174 gws.log.warning(f'time zone: {a!r}={p!r} not found, assuming UTC') 

175 return UTC 

176 

177 

178def _zone_info_from_string(tz): 

179 if tz not in _ZI_ALL: 

180 raise Error(f'invalid time zone {tz!r}') 

181 try: 

182 return zoneinfo.ZoneInfo(tz) 

183 except zoneinfo.ZoneInfoNotFoundError as exc: 

184 raise Error(f'invalid time zone {tz!r}') from exc 

185 

186 

187def _zone_info_from_tzinfo(tzinfo: dt.tzinfo): 

188 if type(tzinfo) is zoneinfo.ZoneInfo: 

189 return tzinfo 

190 s = str(tzinfo) 

191 if s == '+0:0': 

192 return UTC 

193 try: 

194 return _zone_info_from_string(s) 

195 except Error: 

196 pass 

197 

198 

199# init from the env variable right now 

200 

201if 'TZ' in os.environ: 

202 _set_localtime_from_zone_info(_zone_info_from_string(os.environ['TZ'])) 

203 

204 

205# Constructors 

206 

207 

208def new(year, month, day, hour=0, minute=0, second=0, microsecond=0, fold=0, tz: str = '') -> dt.datetime: 

209 """Create a new datetime object with the specified components. 

210 

211 Args: 

212 year: Year. 

213 month: Month. 

214 day: Day. 

215 hour: Hour. 

216 minute: Minute. 

217 second: Second. 

218 microsecond: Microsecond. 

219 fold: Fold value for ambiguous local times, see ``datetime.datetime``. 

220 tz: Time zone string, the local time zone by default. 

221 

222 Returns: 

223 A timezone-aware datetime. 

224 

225 Raises: 

226 ``Error``: If the time zone is invalid. 

227 """ 

228 

229 return dt.datetime(year, month, day, hour, minute, second, microsecond, fold=fold, tzinfo=time_zone(tz)) 

230 

231 

232def now(tz: str = '') -> dt.datetime: 

233 """Get the current date and time. 

234 

235 Args: 

236 tz: Time zone string, the local time zone by default. 

237 

238 Returns: 

239 The current datetime in the given time zone. 

240 """ 

241 

242 return _now(time_zone(tz)) 

243 

244 

245def now_utc() -> dt.datetime: 

246 """Get the current date and time in UTC. 

247 

248 Returns: 

249 The current datetime in UTC. 

250 """ 

251 

252 return _now(UTC) 

253 

254 

255# for testing 

256 

257_MOCK_NOW = None 

258 

259 

260@contextlib.contextmanager 

261def mock_now(d): 

262 """Context manager that makes all functions here use a fixed current date and time, for testing. 

263 

264 Args: 

265 d: Datetime to use as the current date and time. 

266 """ 

267 global _MOCK_NOW 

268 _MOCK_NOW = d 

269 yield 

270 _MOCK_NOW = None 

271 

272 

273def _now(tzinfo): 

274 return _MOCK_NOW or dt.datetime.now(tz=tzinfo) 

275 

276 

277def today(tz: str = '') -> dt.datetime: 

278 """Get today's date at midnight. 

279 

280 Args: 

281 tz: Time zone string, the local time zone by default. 

282 

283 Returns: 

284 A datetime at midnight of the current day in the given time zone. 

285 """ 

286 

287 return now(tz).replace(hour=0, minute=0, second=0, microsecond=0) 

288 

289 

290def today_utc() -> dt.datetime: 

291 """Get today's date at midnight in UTC. 

292 

293 Returns: 

294 A datetime at midnight of the current day in UTC. 

295 """ 

296 

297 return now_utc().replace(hour=0, minute=0, second=0, microsecond=0) 

298 

299 

300def parse(s: str | dt.datetime | dt.date | None, tz: str = '') -> Optional[dt.datetime]: 

301 """Parse a string, datetime, or date into a datetime object. 

302 

303 Strings are parsed like in ``from_string``. Dates become midnight in the given time zone. 

304 

305 Args: 

306 s: Input to parse. 

307 tz: Time zone for timezone-naive inputs, the local time zone by default. 

308 

309 Returns: 

310 A datetime, or ``None`` if the input is empty or cannot be parsed. 

311 """ 

312 

313 if not s: 

314 return None 

315 

316 if isinstance(s, dt.datetime): 

317 return _ensure_tzinfo(s, tz) 

318 

319 if isinstance(s, dt.date): 

320 return new(s.year, s.month, s.day, tz=tz) 

321 

322 try: 

323 return from_string(str(s), tz) 

324 except Error: 

325 pass 

326 

327 

328def parse_time(s: str | dt.time | None, tz: str = '') -> Optional[dt.datetime]: 

329 """Parse a string or time into a datetime object with today's date. 

330 

331 Strings are parsed like in ``from_iso_time_string``. 

332 

333 Args: 

334 s: Input to parse. 

335 tz: Time zone for timezone-naive inputs, the local time zone by default. 

336 

337 Returns: 

338 A datetime, or ``None`` if the input is empty or cannot be parsed. 

339 """ 

340 

341 if not s: 

342 return 

343 

344 if isinstance(s, dt.time): 

345 return _datetime(_ensure_tzinfo(s, tz)) 

346 

347 try: 

348 return from_iso_time_string(str(s), tz) 

349 except Error: 

350 pass 

351 

352 

353def from_string(s: str, tz: str = '') -> dt.datetime: 

354 """Parse a date or datetime string. 

355 

356 Accepts ISO 8601 and some other common formats understood by ``pendulum``. 

357 A date without time becomes midnight in the given time zone. 

358 

359 Args: 

360 s: Date or datetime string. 

361 tz: Time zone for timezone-naive inputs, the local time zone by default. 

362 

363 Returns: 

364 A datetime. 

365 

366 Raises: 

367 ``Error``: If the string cannot be parsed or is not a date or datetime. 

368 """ 

369 

370 return _pend_parse_datetime(s.strip(), tz, iso_only=False) 

371 

372 

373def from_iso_string(s: str, tz: str = '') -> dt.datetime: 

374 """Parse an ISO 8601 date or datetime string. 

375 

376 A date without time becomes midnight in the given time zone. 

377 

378 Args: 

379 s: ISO 8601 date or datetime string. 

380 tz: Time zone for timezone-naive inputs, the local time zone by default. 

381 

382 Returns: 

383 A datetime. 

384 

385 Raises: 

386 ``Error``: If the string cannot be parsed or is not a date or datetime. 

387 """ 

388 

389 return _pend_parse_datetime(s.strip(), tz, iso_only=True) 

390 

391 

392def from_iso_time_string(s: str, tz: str = '') -> dt.datetime: 

393 """Parse an ISO 8601 time string into a datetime with today's date. 

394 

395 Args: 

396 s: ISO 8601 time string. 

397 tz: Time zone for timezone-naive inputs, the local time zone by default. 

398 

399 Returns: 

400 A datetime. 

401 

402 Raises: 

403 ``Error``: If the string cannot be parsed or is not a time. 

404 """ 

405 

406 return _pend_parse_time(s.strip(), tz, iso_only=True) 

407 

408 

409def from_timestamp(n: float, tz: str = '') -> dt.datetime: 

410 """Create a datetime from a Unix timestamp. 

411 

412 Args: 

413 n: Unix timestamp, in seconds since the epoch. 

414 tz: Time zone string, the local time zone by default. 

415 

416 Returns: 

417 A datetime in the given time zone. 

418 """ 

419 

420 return dt.datetime.fromtimestamp(n, tz=time_zone(tz)) 

421 

422 

423# Formatters 

424 

425 

426def to_iso_string(d: Optional[dt.date] = None, with_tz='+', sep='T') -> str: 

427 """Convert a date or time to an ISO 8601 datetime string. 

428 

429 Args: 

430 d: Date or time to convert, the current date and time by default. 

431 with_tz: Time zone suffix: ``"+"`` for ``+hhmm``, ``":"`` for ``+hh:mm``, 

432 ``"Z"`` for ``Z`` if the offset is zero and ``+hhmm`` otherwise. An empty value omits the time zone. 

433 sep: Separator between date and time. 

434 

435 Returns: 

436 A string like ``2024-05-01T12:30:00+0200``. 

437 """ 

438 

439 d = _datetime(d) 

440 s = d.strftime(f'%Y-%m-%d{sep}%H:%M:%S') 

441 if not with_tz: 

442 return s 

443 tz = d.strftime('%z') 

444 if with_tz == 'Z' and tz == '+0000': 

445 return s + 'Z' 

446 if with_tz == ':' and len(tz) == 5: 

447 return s + tz[:3] + ':' + tz[3:] 

448 return s + tz 

449 

450 

451def to_iso_date_string(d: Optional[dt.date] = None) -> str: 

452 """Convert a date to an ISO date string. 

453 

454 Args: 

455 d: Date to convert, the current date and time by default. 

456 

457 Returns: 

458 A string like ``2024-05-01``. 

459 """ 

460 

461 return _datetime(d).strftime('%Y-%m-%d') 

462 

463 

464def to_basic_string(d: Optional[dt.date] = None, with_ms=False) -> str: 

465 """Convert a date to a compact string without separators. 

466 

467 Args: 

468 d: Date to convert, the current date and time by default. 

469 with_ms: Append milliseconds as three digits. 

470 

471 Returns: 

472 A string like ``20240501123000``, or ``20240501123000123`` with milliseconds. 

473 """ 

474 

475 d = _datetime(d) 

476 s = d.strftime('%Y%m%d%H%M%S') 

477 if with_ms: 

478 s += f'{d.microsecond // 1000:03d}' 

479 return s 

480 

481 

482def to_iso_time_string(d: Optional[dt.date] = None, with_tz='+') -> str: 

483 """Convert a date to an ISO 8601 time string. 

484 

485 Args: 

486 d: Date to convert, the current date and time by default. 

487 with_tz: Time zone suffix: ``"+"`` for ``+hhmm``, ``"Z"`` for ``Z`` if the offset is zero 

488 and ``+hhmm`` otherwise. An empty value omits the time zone. 

489 

490 Returns: 

491 A string like ``12:30:00+0200``. 

492 """ 

493 

494 fmt = '%H:%M:%S' 

495 if with_tz: 

496 fmt += '%z' 

497 s = _datetime(d).strftime(fmt) 

498 if with_tz == 'Z' and s.endswith('+0000'): 

499 s = s[:-5] + 'Z' 

500 return s 

501 

502 

503def to_string(fmt: str, d: Optional[dt.date] = None) -> str: 

504 """Convert a date to a string using a custom format. 

505 

506 Args: 

507 fmt: ``strftime`` format string. 

508 d: Date to convert, the current date and time by default. 

509 

510 Returns: 

511 The formatted string. 

512 """ 

513 

514 return _datetime(d).strftime(fmt) 

515 

516 

517def time_to_iso_string(d: Optional[dt.date | dt.time] = None, with_tz='+') -> str: 

518 """Convert a date or time to a time string without time zone. 

519 

520 Args: 

521 d: Datetime or time to convert. For a date or ``None``, ``00:00:00`` is returned. 

522 with_tz: Not used. 

523 

524 Returns: 

525 A string like ``12:30:00``. 

526 """ 

527 

528 if isinstance(d, (dt.datetime, dt.time)): 

529 return f'{d.hour:02d}:{d.minute:02d}:{d.second:02d}' 

530 return f'00:00:00' 

531 

532 

533# Converters 

534 

535 

536def to_timestamp(d: Optional[dt.date] = None) -> int: 

537 """Convert a date to a Unix timestamp. 

538 

539 Args: 

540 d: Date to convert, the current date and time by default. 

541 

542 Returns: 

543 Whole seconds since the epoch. 

544 """ 

545 

546 return int(_datetime(d).timestamp()) 

547 

548 

549def to_millis(d: Optional[dt.date] = None) -> int: 

550 """Convert a date to milliseconds since the Unix epoch. 

551 

552 Args: 

553 d: Date to convert, the current date and time by default. 

554 

555 Returns: 

556 Whole milliseconds since the epoch. 

557 """ 

558 

559 return int(_datetime(d).timestamp() * 1000) 

560 

561 

562def to_utc(d: Optional[dt.date] = None) -> dt.datetime: 

563 """Convert a date to the UTC time zone. 

564 

565 Args: 

566 d: Date to convert, the current date and time by default. 

567 

568 Returns: 

569 A datetime in UTC. 

570 """ 

571 

572 return _datetime(d).astimezone(time_zone('UTC')) 

573 

574 

575def to_local(d: Optional[dt.date] = None) -> dt.datetime: 

576 """Convert a date to the local time zone. 

577 

578 Args: 

579 d: Date to convert, the current date and time by default. 

580 

581 Returns: 

582 A datetime in the local time zone. 

583 """ 

584 

585 return _datetime(d).astimezone(time_zone('')) 

586 

587 

588def to_time_zone(tz: str, d: Optional[dt.date] = None) -> dt.datetime: 

589 """Convert a date to a specific time zone. 

590 

591 Args: 

592 tz: Target time zone string. 

593 d: Date to convert, the current date and time by default. 

594 

595 Returns: 

596 A datetime in the target time zone. 

597 

598 Raises: 

599 ``Error``: If the time zone is invalid. 

600 """ 

601 

602 return _datetime(d).astimezone(time_zone(tz)) 

603 

604 

605# Predicates 

606 

607 

608def is_date(x) -> bool: 

609 """Check if an object is a date. 

610 

611 Args: 

612 x: Object to check. 

613 

614 Returns: 

615 ``True`` if the object is a ``date``. Since ``datetime`` is a subclass of ``date``, this is also ``True`` for datetimes. 

616 """ 

617 

618 return isinstance(x, dt.date) 

619 

620 

621def is_datetime(x) -> bool: 

622 """Check if an object is a datetime. 

623 

624 Args: 

625 x: Object to check. 

626 

627 Returns: 

628 ``True`` if the object is a ``datetime``. 

629 """ 

630 

631 return isinstance(x, dt.datetime) 

632 

633 

634def is_utc(d: dt.datetime) -> bool: 

635 """Check if a datetime is in the UTC time zone. 

636 

637 Args: 

638 d: Datetime to check. A naive datetime is assumed to be local. 

639 

640 Returns: 

641 ``True`` if the time zone of the datetime is UTC. 

642 """ 

643 

644 return _zone_info_from_tzinfo(gws.u.require(_datetime(d).tzinfo)) == UTC 

645 

646 

647def is_local(d: dt.datetime) -> bool: 

648 """Check if a datetime is in the local time zone. 

649 

650 Args: 

651 d: Datetime to check. A naive datetime is assumed to be local. 

652 

653 Returns: 

654 ``True`` if the time zone of the datetime is the local time zone. 

655 """ 

656 

657 return _zone_info_from_tzinfo(gws.u.require(_datetime(d).tzinfo)) == time_zone('') 

658 

659 

660# Arithmetic 

661 

662 

663def add(d: Optional[dt.date] = None, years=0, months=0, days=0, weeks=0, hours=0, minutes=0, seconds=0, microseconds=0) -> dt.datetime: 

664 """Add a duration to a date. 

665 

666 Negative values subtract. 

667 

668 Args: 

669 d: Base date, the current date and time by default. 

670 years: Years to add. 

671 months: Months to add. 

672 days: Days to add. 

673 weeks: Weeks to add. 

674 hours: Hours to add. 

675 minutes: Minutes to add. 

676 seconds: Seconds to add. 

677 microseconds: Microseconds to add. 

678 

679 Returns: 

680 The resulting datetime. 

681 """ 

682 

683 return pendulum.helpers.add_duration( 

684 _datetime(d), 

685 years=years, 

686 months=months, 

687 days=days, 

688 weeks=weeks, 

689 hours=hours, 

690 minutes=minutes, 

691 seconds=seconds, 

692 microseconds=microseconds, 

693 ) 

694 

695 

696class Diff: 

697 """Difference between two dates, as returned by ``difference`` and ``total_difference``.""" 

698 

699 years: int 

700 """Years.""" 

701 months: int 

702 """Months.""" 

703 weeks: int 

704 """Weeks.""" 

705 days: int 

706 """Days.""" 

707 hours: int 

708 """Hours.""" 

709 minutes: int 

710 """Minutes.""" 

711 seconds: int 

712 """Seconds.""" 

713 microseconds: int 

714 """Microseconds.""" 

715 

716 def __repr__(self): 

717 return repr(vars(self)) 

718 

719 

720def difference(d1: dt.date, d2: Optional[dt.date] = None) -> Diff: 

721 """Compute the difference between two dates, broken down into components. 

722 

723 The components add up to the whole difference, for example ``1 year, 2 months, 1 week, 3 days``. 

724 

725 Args: 

726 d1: The start date. 

727 d2: The end date, the current date and time by default. 

728 

729 Returns: 

730 The difference from ``d1`` to ``d2``. 

731 """ 

732 

733 pd = _precise_diff(d1, d2) 

734 df = Diff() 

735 

736 df.years = pd.years 

737 df.months = pd.months 

738 df.weeks = _sign(pd.days) * (abs(pd.days) // 7) 

739 df.days = _sign(pd.days) * (abs(pd.days) % 7) 

740 df.hours = pd.hours 

741 df.minutes = pd.minutes 

742 df.seconds = pd.seconds 

743 df.microseconds = pd.microseconds 

744 

745 return df 

746 

747 

748def total_difference(d1: dt.date, d2: Optional[dt.date] = None) -> Diff: 

749 """Compute the total difference between two dates in each unit. 

750 

751 Each component holds the whole difference expressed in that unit, 

752 for example, for a difference of one year and two months, ``years`` is 1 and ``months`` is 14. 

753 

754 Args: 

755 d1: The start date. 

756 d2: The end date, the current date and time by default. 

757 

758 Returns: 

759 The difference from ``d1`` to ``d2``. 

760 """ 

761 

762 pd = _precise_diff(d1, d2) 

763 total = (_utc(_datetime(d2)) - _utc(_datetime(d1))).total_seconds() 

764 df = Diff() 

765 

766 df.years = pd.years 

767 df.months = pd.years * 12 + pd.months 

768 df.weeks = _sign(pd.total_days) * (abs(pd.total_days) // 7) 

769 df.days = pd.total_days 

770 df.hours = int(total / 3600) 

771 df.minutes = int(total / 60) 

772 df.seconds = int(total) 

773 df.microseconds = df.seconds * 1_000_000 

774 

775 return df 

776 

777 

778def _precise_diff(d1, d2): 

779 # NB pendulum's compiled `precise_diff` is broken, use the pure python version 

780 return pendulum._helpers.precise_diff(_datetime(d1), _datetime(d2)) 

781 

782 

783def _utc(d: dt.datetime) -> dt.datetime: 

784 return d.astimezone(dt.timezone.utc) 

785 

786 

787def _sign(n: int) -> int: 

788 return -1 if n < 0 else 1 

789 

790 

791# Wrappers for useful pendulum utilities 

792 

793# fmt:off 

794 

795def start_of_second(d: Optional[dt.date] = None) -> dt.datetime: return _unpend(_pend(d).start_of('second')) 

796def start_of_minute(d: Optional[dt.date] = None) -> dt.datetime: return _unpend(_pend(d).start_of('minute')) 

797def start_of_hour (d: Optional[dt.date] = None) -> dt.datetime: return _unpend(_pend(d).start_of('hour')) 

798def start_of_day (d: Optional[dt.date] = None) -> dt.datetime: return _unpend(_pend(d).start_of('day')) 

799def start_of_week (d: Optional[dt.date] = None) -> dt.datetime: return _unpend(_pend(d).start_of('week')) 

800def start_of_month (d: Optional[dt.date] = None) -> dt.datetime: return _unpend(_pend(d).start_of('month')) 

801def start_of_year (d: Optional[dt.date] = None) -> dt.datetime: return _unpend(_pend(d).start_of('year')) 

802 

803 

804def end_of_second(d: Optional[dt.date] = None) -> dt.datetime: return _unpend(_pend(d).end_of('second')) 

805def end_of_minute(d: Optional[dt.date] = None) -> dt.datetime: return _unpend(_pend(d).end_of('minute')) 

806def end_of_hour (d: Optional[dt.date] = None) -> dt.datetime: return _unpend(_pend(d).end_of('hour')) 

807def end_of_day (d: Optional[dt.date] = None) -> dt.datetime: return _unpend(_pend(d).end_of('day')) 

808def end_of_week (d: Optional[dt.date] = None) -> dt.datetime: return _unpend(_pend(d).end_of('week')) 

809def end_of_month (d: Optional[dt.date] = None) -> dt.datetime: return _unpend(_pend(d).end_of('month')) 

810def end_of_year (d: Optional[dt.date] = None) -> dt.datetime: return _unpend(_pend(d).end_of('year')) 

811 

812 

813def day_of_week (d: Optional[dt.date] = None) -> int: return _pend(d).day_of_week 

814def day_of_year (d: Optional[dt.date] = None) -> int: return _pend(d).day_of_year 

815def week_of_month (d: Optional[dt.date] = None) -> int: return _pend(d).week_of_month 

816def week_of_year (d: Optional[dt.date] = None) -> int: return _pend(d).week_of_year 

817def days_in_month (d: Optional[dt.date] = None) -> int: return _pend(d).days_in_month 

818 

819 

820# fmt:on 

821 

822_WD = { 

823 0: pendulum.WeekDay.MONDAY, 

824 1: pendulum.WeekDay.TUESDAY, 

825 2: pendulum.WeekDay.WEDNESDAY, 

826 3: pendulum.WeekDay.THURSDAY, 

827 4: pendulum.WeekDay.FRIDAY, 

828 5: pendulum.WeekDay.SATURDAY, 

829 6: pendulum.WeekDay.SUNDAY, 

830 'monday': pendulum.WeekDay.MONDAY, 

831 'tuesday': pendulum.WeekDay.TUESDAY, 

832 'wednesday': pendulum.WeekDay.WEDNESDAY, 

833 'thursday': pendulum.WeekDay.THURSDAY, 

834 'friday': pendulum.WeekDay.FRIDAY, 

835 'saturday': pendulum.WeekDay.SATURDAY, 

836 'sunday': pendulum.WeekDay.SUNDAY, 

837} 

838 

839 

840def next(day: int | str, d: Optional[dt.date] = None, keep_time=False) -> dt.datetime: 

841 """Get the next occurrence of a specific weekday. 

842 

843 Args: 

844 day: Day of the week, ``0`` to ``6`` for Monday to Sunday, or a lowercase weekday name like ``monday``. 

845 d: Starting date, the current date and time by default. 

846 keep_time: Keep the time of the starting date, otherwise the time is set to midnight. 

847 

848 Returns: 

849 The datetime of the next occurrence after the starting date. 

850 """ 

851 

852 return _unpend(_pend(d).next(_WD[day], keep_time)) 

853 

854 

855def prev(day: int | str, d: Optional[dt.date] = None, keep_time=False) -> dt.datetime: 

856 """Get the previous occurrence of a specific weekday. 

857 

858 Args: 

859 day: Day of the week, ``0`` to ``6`` for Monday to Sunday, or a lowercase weekday name like ``monday``. 

860 d: Starting date, the current date and time by default. 

861 keep_time: Keep the time of the starting date, otherwise the time is set to midnight. 

862 

863 Returns: 

864 The datetime of the previous occurrence before the starting date. 

865 """ 

866 

867 return _unpend(_pend(d).previous(_WD[day], keep_time)) 

868 

869 

870# Duration 

871 

872_DURATION_UNITS = { 

873 'w': 3600 * 24 * 7, 

874 'd': 3600 * 24, 

875 'h': 3600, 

876 'm': 60, 

877 's': 1, 

878} 

879 

880 

881def parse_duration(s: str) -> int: 

882 """Convert a duration string to seconds. 

883 

884 The string consists of numbers followed by units ``w``, ``d``, ``h``, ``m`` or ``s``, 

885 like ``1w2d3h4m5s``. A trailing number without a unit is taken as seconds. 

886 

887 Args: 

888 s: Duration string, or an integer number of seconds, which is returned as is. 

889 

890 Returns: 

891 The duration in seconds. 

892 

893 Raises: 

894 ``Error``: If the string is not a valid duration. 

895 """ 

896 

897 if isinstance(s, int): 

898 return s 

899 

900 p = None 

901 r = 0 

902 

903 for n, v in re.findall(r'(\d+)|(\D+)', str(s).strip()): 

904 if n: 

905 p = int(n) 

906 continue 

907 v = v.strip() 

908 if p is None or v not in _DURATION_UNITS: 

909 raise Error('invalid duration', s) 

910 r += p * _DURATION_UNITS[v] 

911 p = None 

912 

913 if p: 

914 r += p 

915 

916 return r 

917 

918 

919def format_duration(s: int) -> str: 

920 """Format a duration in seconds to a string. 

921 

922 Args: 

923 s: Duration in seconds. 

924 

925 Returns: 

926 A string like ``1d 2h 30m``, or ``0s`` for a zero duration. 

927 """ 

928 

929 r = '' 

930 

931 for u, v in _DURATION_UNITS.items(): 

932 n = s // v 

933 if n: 

934 r += f'{n}{u} ' 

935 s -= n * v 

936 

937 return r.strip() or '0s' 

938 

939## 

940 

941# conversions 

942 

943 

944def _datetime(d: dt.date | dt.time | None) -> dt.datetime: 

945 # ensure a valid datetime object 

946 

947 if d is None: 

948 return now() 

949 

950 if isinstance(d, dt.datetime): 

951 # if a value is a naive datetime, assume the local tz 

952 # see https://www.postgresql.org/docs/current/datatype-datetime.html#DATATYPE-DATETIME-INPUT-TIME-STAMPS: 

953 # > Conversions between timestamp without time zone and timestamp with time zone normally assume 

954 # > that the timestamp without time zone value should be taken or given as timezone local time. 

955 return _ensure_tzinfo(d, tz='') 

956 

957 if isinstance(d, dt.date): 

958 # promote date to midnight UTC 

959 return dt.datetime(d.year, d.month, d.day, tzinfo=UTC) 

960 

961 if isinstance(d, dt.time): 

962 # promote time to today's time 

963 n = _now(d.tzinfo) 

964 return dt.datetime(n.year, n.month, n.day, d.hour, d.minute, d.second, d.microsecond, d.tzinfo, fold=d.fold) 

965 

966 raise Error(f'invalid datetime value {d!r}') 

967 

968 

969def _ensure_tzinfo(d, tz: str): 

970 # attach tzinfo if not set 

971 

972 if not d.tzinfo: 

973 return d.replace(tzinfo=time_zone(tz)) 

974 

975 # try to convert 'their' tzinfo (might be an unnamed dt.timezone or pendulum.FixedTimezone) to zoneinfo 

976 zi = _zone_info_from_tzinfo(d.tzinfo) 

977 if zi: 

978 return d.replace(tzinfo=zi) 

979 

980 # failing that, keep existing tzinfo 

981 return d 

982 

983 

984# pendulum.DateTime <-> python datetime 

985 

986 

987def _pend(d: dt.date | None) -> pendulum.DateTime: 

988 return pendulum.instance(_datetime(d)) 

989 

990 

991def _unpend(p: pendulum.DateTime) -> dt.datetime: 

992 return dt.datetime( 

993 p.year, 

994 p.month, 

995 p.day, 

996 p.hour, 

997 p.minute, 

998 p.second, 

999 p.microsecond, 

1000 tzinfo=p.tzinfo, 

1001 fold=p.fold, 

1002 ) 

1003 

1004 

1005# NB using private APIs 

1006 

1007 

1008def _pend_parse_datetime(s, tz, iso_only): 

1009 try: 

1010 if iso_only: 

1011 d = pendulum.parsing.parse_iso8601(s) 

1012 else: 

1013 # do not normalize 

1014 d = pendulum.parsing._parse(s) 

1015 except (ValueError, pendulum.parsing.exceptions.ParserError) as exc: 

1016 raise Error(f'invalid date {s!r}') from exc 

1017 

1018 if isinstance(d, dt.datetime): 

1019 return _ensure_tzinfo(d, tz) 

1020 if isinstance(d, dt.date): 

1021 return new(d.year, d.month, d.day, tz=tz) 

1022 

1023 # times and durations not accepted 

1024 raise Error(f'invalid date {s!r}') 

1025 

1026 

1027def _pend_parse_time(s, tz, iso_only): 

1028 try: 

1029 if iso_only: 

1030 d = pendulum.parsing.parse_iso8601(s) 

1031 else: 

1032 # do not normalize 

1033 d = pendulum.parsing._parse(s) 

1034 except (ValueError, pendulum.parsing.exceptions.ParserError) as exc: 

1035 raise Error(f'invalid time {s!r}') from exc 

1036 

1037 if isinstance(d, dt.time): 

1038 return _datetime(_ensure_tzinfo(d, tz)) 

1039 

1040 # dates and durations not accepted 

1041 raise Error(f'invalid time {s!r}')