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

307 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-23 20:06 +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``, 

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 

32The local time zone is taken from the ``TZ`` environment variable, or, if it is not set, 

33from the ``/etc/localtime`` symlink. In a docker container, set ``TZ`` (the GWS image defaults to ``Europe/Berlin``). 

34 

35Example:: 

36 

37 import gws.lib.datetimex as datetimex 

38 

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

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

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

42 

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

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

45""" 

46 

47from typing import Optional 

48 

49import datetime as dt 

50import contextlib 

51import os 

52import re 

53import zoneinfo 

54 

55import pendulum 

56import pendulum._helpers 

57import pendulum.helpers 

58import pendulum.parsing 

59import pendulum.parsing.exceptions 

60 

61import gws 

62 

63 

64class Error(gws.Error): 

65 """Date and time error.""" 

66 

67 pass 

68 

69 

70UTC = zoneinfo.ZoneInfo('UTC') 

71"""The UTC time zone.""" 

72 

73_ZI_CACHE = { 

74 'utc': UTC, 

75 'UTC': UTC, 

76 'Etc/UTC': UTC, 

77} 

78 

79_ZI_ALL = set(zoneinfo.available_timezones()) 

80 

81 

82# Time zones 

83 

84 

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

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

87 

88 Args: 

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

90 

91 Returns: 

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

93 """ 

94 

95 return tz in _ZI_CACHE or tz in _ZI_ALL 

96 

97 

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

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

100 

101 The local time zone is determined from the ``TZ`` environment variable or from ``/etc/localtime``; 

102 if that fails, UTC is assumed. 

103 

104 Args: 

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

106 

107 Returns: 

108 The ZoneInfo object. 

109 

110 Raises: 

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

112 """ 

113 

114 if tz in _ZI_CACHE: 

115 return _ZI_CACHE[tz] 

116 

117 if not tz: 

118 _ZI_CACHE[''] = _local_zone_info() 

119 return _ZI_CACHE[''] 

120 

121 return _zone_info_from_string(tz) 

122 

123 

124def _local_zone_info(): 

125 tz = os.environ.get('TZ', '').lstrip(':') 

126 if not tz: 

127 return _zone_info_from_localtime() 

128 try: 

129 return _zone_info_from_string(tz) 

130 except Error: 

131 gws.log.warning(f'time zone: TZ={tz!r} invalid, assuming UTC') 

132 return UTC 

133 

134 

135def _zone_info_from_localtime(): 

136 a = '/etc/localtime' 

137 

138 try: 

139 p = os.readlink(a) 

140 except FileNotFoundError: 

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

142 return UTC 

143 except OSError: 

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

145 return UTC 

146 

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

148 if not m: 

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

150 return UTC 

151 

152 try: 

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

154 except zoneinfo.ZoneInfoNotFoundError: 

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

156 return UTC 

157 

158 

159def _zone_info_from_string(tz): 

160 if tz not in _ZI_ALL: 

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

162 try: 

163 return zoneinfo.ZoneInfo(tz) 

164 except zoneinfo.ZoneInfoNotFoundError as exc: 

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

166 

167 

168def _zone_info_from_tzinfo(tzinfo: dt.tzinfo): 

169 if type(tzinfo) is zoneinfo.ZoneInfo: 

170 return tzinfo 

171 s = str(tzinfo) 

172 if s == '+0:0': 

173 return UTC 

174 try: 

175 return _zone_info_from_string(s) 

176 except Error: 

177 pass 

178 

179 

180# Constructors 

181 

182 

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

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

185 

186 Args: 

187 year: Year. 

188 month: Month. 

189 day: Day. 

190 hour: Hour. 

191 minute: Minute. 

192 second: Second. 

193 microsecond: Microsecond. 

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

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

196 

197 Returns: 

198 A timezone-aware datetime. 

199 

200 Raises: 

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

202 """ 

203 

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

205 

206 

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

208 """Get the current date and time. 

209 

210 Args: 

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

212 

213 Returns: 

214 The current datetime in the given time zone. 

215 """ 

216 

217 return _now(time_zone(tz)) 

218 

219 

220def now_utc() -> dt.datetime: 

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

222 

223 Returns: 

224 The current datetime in UTC. 

225 """ 

226 

227 return _now(UTC) 

228 

229 

230# for testing 

231 

232_MOCK_NOW = None 

233 

234 

235@contextlib.contextmanager 

236def mock_now(d): 

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

238 

239 Args: 

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

241 """ 

242 global _MOCK_NOW 

243 _MOCK_NOW = d 

244 yield 

245 _MOCK_NOW = None 

246 

247 

248def _now(tzinfo): 

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

250 

251 

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

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

254 

255 Args: 

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

257 

258 Returns: 

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

260 """ 

261 

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

263 

264 

265def today_utc() -> dt.datetime: 

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

267 

268 Returns: 

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

270 """ 

271 

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

273 

274 

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

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

277 

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

279 

280 Args: 

281 s: Input to parse. 

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

283 

284 Returns: 

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

286 """ 

287 

288 if not s: 

289 return None 

290 

291 if isinstance(s, dt.datetime): 

292 return _ensure_tzinfo(s, tz) 

293 

294 if isinstance(s, dt.date): 

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

296 

297 try: 

298 return from_string(str(s), tz) 

299 except Error: 

300 pass 

301 

302 

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

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

305 

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

307 

308 Args: 

309 s: Input to parse. 

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

311 

312 Returns: 

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

314 """ 

315 

316 if not s: 

317 return 

318 

319 if isinstance(s, dt.time): 

320 return _datetime(_ensure_tzinfo(s, tz)) 

321 

322 try: 

323 return from_iso_time_string(str(s), tz) 

324 except Error: 

325 pass 

326 

327 

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

329 """Parse a date or datetime string. 

330 

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

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

333 

334 Args: 

335 s: Date or datetime string. 

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

337 

338 Returns: 

339 A datetime. 

340 

341 Raises: 

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

343 """ 

344 

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

346 

347 

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

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

350 

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

352 

353 Args: 

354 s: ISO 8601 date or datetime string. 

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

356 

357 Returns: 

358 A datetime. 

359 

360 Raises: 

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

362 """ 

363 

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

365 

366 

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

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

369 

370 Args: 

371 s: ISO 8601 time string. 

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

373 

374 Returns: 

375 A datetime. 

376 

377 Raises: 

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

379 """ 

380 

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

382 

383 

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

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

386 

387 Args: 

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

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

390 

391 Returns: 

392 A datetime in the given time zone. 

393 """ 

394 

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

396 

397 

398# Formatters 

399 

400 

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

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

403 

404 Args: 

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

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

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

408 sep: Separator between date and time. 

409 

410 Returns: 

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

412 """ 

413 

414 d = _datetime(d) 

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

416 if not with_tz: 

417 return s 

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

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

420 return s + 'Z' 

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

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

423 return s + tz 

424 

425 

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

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

428 

429 Args: 

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

431 

432 Returns: 

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

434 """ 

435 

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

437 

438 

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

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

441 

442 Args: 

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

444 with_ms: Append milliseconds as three digits. 

445 

446 Returns: 

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

448 """ 

449 

450 d = _datetime(d) 

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

452 if with_ms: 

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

454 return s 

455 

456 

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

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

459 

460 Args: 

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

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

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

464 

465 Returns: 

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

467 """ 

468 

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

470 if with_tz: 

471 fmt += '%z' 

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

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

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

475 return s 

476 

477 

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

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

480 

481 Args: 

482 fmt: ``strftime`` format string. 

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

484 

485 Returns: 

486 The formatted string. 

487 """ 

488 

489 return _datetime(d).strftime(fmt) 

490 

491 

492def time_to_iso_string(d: Optional[dt.date | dt.time] = None) -> str: 

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

494 

495 Args: 

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

497 

498 Returns: 

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

500 """ 

501 

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

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

504 return f'00:00:00' 

505 

506 

507# Converters 

508 

509 

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

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

512 

513 Args: 

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

515 

516 Returns: 

517 Whole seconds since the epoch. 

518 """ 

519 

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

521 

522 

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

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

525 

526 Args: 

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

528 

529 Returns: 

530 Whole milliseconds since the epoch. 

531 """ 

532 

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

534 

535 

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

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

538 

539 Args: 

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

541 

542 Returns: 

543 A datetime in UTC. 

544 """ 

545 

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

547 

548 

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

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

551 

552 Args: 

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

554 

555 Returns: 

556 A datetime in the local time zone. 

557 """ 

558 

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

560 

561 

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

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

564 

565 Args: 

566 tz: Target time zone string. 

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

568 

569 Returns: 

570 A datetime in the target time zone. 

571 

572 Raises: 

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

574 """ 

575 

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

577 

578 

579# Predicates 

580 

581 

582def is_date(x) -> bool: 

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

584 

585 Args: 

586 x: Object to check. 

587 

588 Returns: 

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

590 """ 

591 

592 return isinstance(x, dt.date) 

593 

594 

595def is_datetime(x) -> bool: 

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

597 

598 Args: 

599 x: Object to check. 

600 

601 Returns: 

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

603 """ 

604 

605 return isinstance(x, dt.datetime) 

606 

607 

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

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

610 

611 Args: 

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

613 

614 Returns: 

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

616 """ 

617 

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

619 

620 

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

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

623 

624 Args: 

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

626 

627 Returns: 

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

629 """ 

630 

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

632 

633 

634# Arithmetic 

635 

636 

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

638 """Add a duration to a date. 

639 

640 Negative values subtract. 

641 

642 Args: 

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

644 years: Years to add. 

645 months: Months to add. 

646 days: Days to add. 

647 weeks: Weeks to add. 

648 hours: Hours to add. 

649 minutes: Minutes to add. 

650 seconds: Seconds to add. 

651 microseconds: Microseconds to add. 

652 

653 Returns: 

654 The resulting datetime. 

655 """ 

656 

657 return pendulum.helpers.add_duration( 

658 _datetime(d), 

659 years=years, 

660 months=months, 

661 days=days, 

662 weeks=weeks, 

663 hours=hours, 

664 minutes=minutes, 

665 seconds=seconds, 

666 microseconds=microseconds, 

667 ) 

668 

669 

670class Diff: 

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

672 

673 years: int 

674 """Years.""" 

675 months: int 

676 """Months.""" 

677 weeks: int 

678 """Weeks.""" 

679 days: int 

680 """Days.""" 

681 hours: int 

682 """Hours.""" 

683 minutes: int 

684 """Minutes.""" 

685 seconds: int 

686 """Seconds.""" 

687 microseconds: int 

688 """Microseconds.""" 

689 

690 def __repr__(self): 

691 return repr(vars(self)) 

692 

693 

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

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

696 

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

698 

699 Args: 

700 d1: The start date. 

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

702 

703 Returns: 

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

705 """ 

706 

707 pd = _precise_diff(d1, d2) 

708 df = Diff() 

709 

710 df.years = pd.years 

711 df.months = pd.months 

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

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

714 df.hours = pd.hours 

715 df.minutes = pd.minutes 

716 df.seconds = pd.seconds 

717 df.microseconds = pd.microseconds 

718 

719 return df 

720 

721 

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

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

724 

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

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

727 

728 Args: 

729 d1: The start date. 

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

731 

732 Returns: 

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

734 """ 

735 

736 pd = _precise_diff(d1, d2) 

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

738 df = Diff() 

739 

740 df.years = pd.years 

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

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

743 df.days = pd.total_days 

744 df.hours = int(total / 3600) 

745 df.minutes = int(total / 60) 

746 df.seconds = int(total) 

747 df.microseconds = df.seconds * 1_000_000 

748 

749 return df 

750 

751 

752def _precise_diff(d1, d2): 

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

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

755 

756 

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

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

759 

760 

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

762 return -1 if n < 0 else 1 

763 

764 

765# Wrappers for useful pendulum utilities 

766 

767# fmt:off 

768 

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

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

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

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

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

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

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

776 

777 

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

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

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

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

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

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

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

785 

786 

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

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

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

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

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

792 

793 

794# fmt:on 

795 

796_WD = { 

797 0: pendulum.WeekDay.MONDAY, 

798 1: pendulum.WeekDay.TUESDAY, 

799 2: pendulum.WeekDay.WEDNESDAY, 

800 3: pendulum.WeekDay.THURSDAY, 

801 4: pendulum.WeekDay.FRIDAY, 

802 5: pendulum.WeekDay.SATURDAY, 

803 6: pendulum.WeekDay.SUNDAY, 

804 'monday': pendulum.WeekDay.MONDAY, 

805 'tuesday': pendulum.WeekDay.TUESDAY, 

806 'wednesday': pendulum.WeekDay.WEDNESDAY, 

807 'thursday': pendulum.WeekDay.THURSDAY, 

808 'friday': pendulum.WeekDay.FRIDAY, 

809 'saturday': pendulum.WeekDay.SATURDAY, 

810 'sunday': pendulum.WeekDay.SUNDAY, 

811} 

812 

813 

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

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

816 

817 Args: 

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

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

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

821 

822 Returns: 

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

824 """ 

825 

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

827 

828 

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

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

831 

832 Args: 

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

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

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

836 

837 Returns: 

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

839 """ 

840 

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

842 

843 

844# Duration 

845 

846_DURATION_UNITS = { 

847 'w': 3600 * 24 * 7, 

848 'd': 3600 * 24, 

849 'h': 3600, 

850 'm': 60, 

851 's': 1, 

852} 

853 

854 

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

856 """Convert a duration string to seconds. 

857 

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

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

860 

861 Args: 

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

863 

864 Returns: 

865 The duration in seconds. 

866 

867 Raises: 

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

869 """ 

870 

871 if isinstance(s, int): 

872 return s 

873 

874 p = None 

875 r = 0 

876 

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

878 if n: 

879 p = int(n) 

880 continue 

881 v = v.strip() 

882 if p is None or v not in _DURATION_UNITS: 

883 raise Error('invalid duration', s) 

884 r += p * _DURATION_UNITS[v] 

885 p = None 

886 

887 if p: 

888 r += p 

889 

890 return r 

891 

892 

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

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

895 

896 Args: 

897 s: Duration in seconds. 

898 

899 Returns: 

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

901 """ 

902 

903 r = '' 

904 

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

906 n = s // v 

907 if n: 

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

909 s -= n * v 

910 

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

912 

913## 

914 

915# conversions 

916 

917 

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

919 # ensure a valid datetime object 

920 

921 if d is None: 

922 return now() 

923 

924 if isinstance(d, dt.datetime): 

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

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

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

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

929 return _ensure_tzinfo(d, tz='') 

930 

931 if isinstance(d, dt.date): 

932 # promote date to midnight UTC 

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

934 

935 if isinstance(d, dt.time): 

936 # promote time to today's time 

937 n = _now(d.tzinfo) 

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

939 

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

941 

942 

943def _ensure_tzinfo(d, tz: str): 

944 # attach tzinfo if not set 

945 

946 if not d.tzinfo: 

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

948 

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

950 zi = _zone_info_from_tzinfo(d.tzinfo) 

951 if zi: 

952 return d.replace(tzinfo=zi) 

953 

954 # failing that, keep existing tzinfo 

955 return d 

956 

957 

958# pendulum.DateTime <-> python datetime 

959 

960 

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

962 return pendulum.instance(_datetime(d)) 

963 

964 

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

966 return dt.datetime( 

967 p.year, 

968 p.month, 

969 p.day, 

970 p.hour, 

971 p.minute, 

972 p.second, 

973 p.microsecond, 

974 tzinfo=p.tzinfo, 

975 fold=p.fold, 

976 ) 

977 

978 

979# NB using private APIs 

980 

981 

982def _pend_parse_datetime(s, tz, iso_only): 

983 try: 

984 if iso_only: 

985 d = pendulum.parsing.parse_iso8601(s) 

986 else: 

987 # do not normalize 

988 d = pendulum.parsing._parse(s) 

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

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

991 

992 if isinstance(d, dt.datetime): 

993 return _ensure_tzinfo(d, tz) 

994 if isinstance(d, dt.date): 

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

996 

997 # times and durations not accepted 

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

999 

1000 

1001def _pend_parse_time(s, tz, iso_only): 

1002 try: 

1003 if iso_only: 

1004 d = pendulum.parsing.parse_iso8601(s) 

1005 else: 

1006 # do not normalize 

1007 d = pendulum.parsing._parse(s) 

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

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

1010 

1011 if isinstance(d, dt.time): 

1012 return _datetime(_ensure_tzinfo(d, tz)) 

1013 

1014 # dates and durations not accepted 

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