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
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
1"""Date and time utilities.
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.
7Functions in this package fall into these groups:
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``.
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.
22When a function accepts a date or time argument, it is converted to a datetime as follows:
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.
29Parsers (``parse``, ``from_string`` etc.) attach the given time zone to naive input,
30and a parsed date becomes midnight in that time zone.
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``).
35Example::
37 import gws.lib.datetimex as datetimex
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'
43 next_week = datetimex.add(d, weeks=1)
44 datetimex.parse_duration('1h30m') # 5400
45"""
47from typing import Optional
49import datetime as dt
50import contextlib
51import os
52import re
53import zoneinfo
55import pendulum
56import pendulum._helpers
57import pendulum.helpers
58import pendulum.parsing
59import pendulum.parsing.exceptions
61import gws
64class Error(gws.Error):
65 """Date and time error."""
67 pass
70UTC = zoneinfo.ZoneInfo('UTC')
71"""The UTC time zone."""
73_ZI_CACHE = {
74 'utc': UTC,
75 'UTC': UTC,
76 'Etc/UTC': UTC,
77}
79_ZI_ALL = set(zoneinfo.available_timezones())
82# Time zones
85def is_valid_time_zone(tz: str) -> bool:
86 """Check if a time zone string is valid.
88 Args:
89 tz: Time zone string, like ``Europe/Berlin``.
91 Returns:
92 ``True`` if the time zone is known.
93 """
95 return tz in _ZI_CACHE or tz in _ZI_ALL
98def time_zone(tz: str = '') -> zoneinfo.ZoneInfo:
99 """Get a ZoneInfo object for the specified time zone.
101 The local time zone is determined from the ``TZ`` environment variable or from ``/etc/localtime``;
102 if that fails, UTC is assumed.
104 Args:
105 tz: Time zone string, like ``Europe/Berlin``. An empty string means the local time zone.
107 Returns:
108 The ZoneInfo object.
110 Raises:
111 ``Error``: If the time zone is invalid.
112 """
114 if tz in _ZI_CACHE:
115 return _ZI_CACHE[tz]
117 if not tz:
118 _ZI_CACHE[''] = _local_zone_info()
119 return _ZI_CACHE['']
121 return _zone_info_from_string(tz)
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
135def _zone_info_from_localtime():
136 a = '/etc/localtime'
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
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
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
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
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
180# Constructors
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.
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.
197 Returns:
198 A timezone-aware datetime.
200 Raises:
201 ``Error``: If the time zone is invalid.
202 """
204 return dt.datetime(year, month, day, hour, minute, second, microsecond, fold=fold, tzinfo=time_zone(tz))
207def now(tz: str = '') -> dt.datetime:
208 """Get the current date and time.
210 Args:
211 tz: Time zone string, the local time zone by default.
213 Returns:
214 The current datetime in the given time zone.
215 """
217 return _now(time_zone(tz))
220def now_utc() -> dt.datetime:
221 """Get the current date and time in UTC.
223 Returns:
224 The current datetime in UTC.
225 """
227 return _now(UTC)
230# for testing
232_MOCK_NOW = None
235@contextlib.contextmanager
236def mock_now(d):
237 """Context manager that makes all functions here use a fixed current date and time, for testing.
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
248def _now(tzinfo):
249 return _MOCK_NOW or dt.datetime.now(tz=tzinfo)
252def today(tz: str = '') -> dt.datetime:
253 """Get today's date at midnight.
255 Args:
256 tz: Time zone string, the local time zone by default.
258 Returns:
259 A datetime at midnight of the current day in the given time zone.
260 """
262 return now(tz).replace(hour=0, minute=0, second=0, microsecond=0)
265def today_utc() -> dt.datetime:
266 """Get today's date at midnight in UTC.
268 Returns:
269 A datetime at midnight of the current day in UTC.
270 """
272 return now_utc().replace(hour=0, minute=0, second=0, microsecond=0)
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.
278 Strings are parsed like in ``from_string``. Dates become midnight in the given time zone.
280 Args:
281 s: Input to parse.
282 tz: Time zone for timezone-naive inputs, the local time zone by default.
284 Returns:
285 A datetime, or ``None`` if the input is empty or cannot be parsed.
286 """
288 if not s:
289 return None
291 if isinstance(s, dt.datetime):
292 return _ensure_tzinfo(s, tz)
294 if isinstance(s, dt.date):
295 return new(s.year, s.month, s.day, tz=tz)
297 try:
298 return from_string(str(s), tz)
299 except Error:
300 pass
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.
306 Strings are parsed like in ``from_iso_time_string``.
308 Args:
309 s: Input to parse.
310 tz: Time zone for timezone-naive inputs, the local time zone by default.
312 Returns:
313 A datetime, or ``None`` if the input is empty or cannot be parsed.
314 """
316 if not s:
317 return
319 if isinstance(s, dt.time):
320 return _datetime(_ensure_tzinfo(s, tz))
322 try:
323 return from_iso_time_string(str(s), tz)
324 except Error:
325 pass
328def from_string(s: str, tz: str = '') -> dt.datetime:
329 """Parse a date or datetime string.
331 Accepts ISO 8601 and some other common formats understood by ``pendulum``.
332 A date without time becomes midnight in the given time zone.
334 Args:
335 s: Date or datetime string.
336 tz: Time zone for timezone-naive inputs, the local time zone by default.
338 Returns:
339 A datetime.
341 Raises:
342 ``Error``: If the string cannot be parsed or is not a date or datetime.
343 """
345 return _pend_parse_datetime(s.strip(), tz, iso_only=False)
348def from_iso_string(s: str, tz: str = '') -> dt.datetime:
349 """Parse an ISO 8601 date or datetime string.
351 A date without time becomes midnight in the given time zone.
353 Args:
354 s: ISO 8601 date or datetime string.
355 tz: Time zone for timezone-naive inputs, the local time zone by default.
357 Returns:
358 A datetime.
360 Raises:
361 ``Error``: If the string cannot be parsed or is not a date or datetime.
362 """
364 return _pend_parse_datetime(s.strip(), tz, iso_only=True)
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.
370 Args:
371 s: ISO 8601 time string.
372 tz: Time zone for timezone-naive inputs, the local time zone by default.
374 Returns:
375 A datetime.
377 Raises:
378 ``Error``: If the string cannot be parsed or is not a time.
379 """
381 return _pend_parse_time(s.strip(), tz, iso_only=True)
384def from_timestamp(n: float, tz: str = '') -> dt.datetime:
385 """Create a datetime from a Unix timestamp.
387 Args:
388 n: Unix timestamp, in seconds since the epoch.
389 tz: Time zone string, the local time zone by default.
391 Returns:
392 A datetime in the given time zone.
393 """
395 return dt.datetime.fromtimestamp(n, tz=time_zone(tz))
398# Formatters
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.
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.
410 Returns:
411 A string like ``2024-05-01T12:30:00+0200``.
412 """
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
426def to_iso_date_string(d: Optional[dt.date] = None) -> str:
427 """Convert a date to an ISO date string.
429 Args:
430 d: Date to convert, the current date and time by default.
432 Returns:
433 A string like ``2024-05-01``.
434 """
436 return _datetime(d).strftime('%Y-%m-%d')
439def to_basic_string(d: Optional[dt.date] = None, with_ms=False) -> str:
440 """Convert a date to a compact string without separators.
442 Args:
443 d: Date to convert, the current date and time by default.
444 with_ms: Append milliseconds as three digits.
446 Returns:
447 A string like ``20240501123000``, or ``20240501123000123`` with milliseconds.
448 """
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
457def to_iso_time_string(d: Optional[dt.date] = None, with_tz='+') -> str:
458 """Convert a date to an ISO 8601 time string.
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.
465 Returns:
466 A string like ``12:30:00+0200``.
467 """
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
478def to_string(fmt: str, d: Optional[dt.date] = None) -> str:
479 """Convert a date to a string using a custom format.
481 Args:
482 fmt: ``strftime`` format string.
483 d: Date to convert, the current date and time by default.
485 Returns:
486 The formatted string.
487 """
489 return _datetime(d).strftime(fmt)
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.
495 Args:
496 d: Datetime or time to convert. For a date or ``None``, ``00:00:00`` is returned.
498 Returns:
499 A string like ``12:30:00``.
500 """
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'
507# Converters
510def to_timestamp(d: Optional[dt.date] = None) -> int:
511 """Convert a date to a Unix timestamp.
513 Args:
514 d: Date to convert, the current date and time by default.
516 Returns:
517 Whole seconds since the epoch.
518 """
520 return int(_datetime(d).timestamp())
523def to_millis(d: Optional[dt.date] = None) -> int:
524 """Convert a date to milliseconds since the Unix epoch.
526 Args:
527 d: Date to convert, the current date and time by default.
529 Returns:
530 Whole milliseconds since the epoch.
531 """
533 return int(_datetime(d).timestamp() * 1000)
536def to_utc(d: Optional[dt.date] = None) -> dt.datetime:
537 """Convert a date to the UTC time zone.
539 Args:
540 d: Date to convert, the current date and time by default.
542 Returns:
543 A datetime in UTC.
544 """
546 return _datetime(d).astimezone(time_zone('UTC'))
549def to_local(d: Optional[dt.date] = None) -> dt.datetime:
550 """Convert a date to the local time zone.
552 Args:
553 d: Date to convert, the current date and time by default.
555 Returns:
556 A datetime in the local time zone.
557 """
559 return _datetime(d).astimezone(time_zone(''))
562def to_time_zone(tz: str, d: Optional[dt.date] = None) -> dt.datetime:
563 """Convert a date to a specific time zone.
565 Args:
566 tz: Target time zone string.
567 d: Date to convert, the current date and time by default.
569 Returns:
570 A datetime in the target time zone.
572 Raises:
573 ``Error``: If the time zone is invalid.
574 """
576 return _datetime(d).astimezone(time_zone(tz))
579# Predicates
582def is_date(x) -> bool:
583 """Check if an object is a date.
585 Args:
586 x: Object to check.
588 Returns:
589 ``True`` if the object is a ``date``. Since ``datetime`` is a subclass of ``date``, this is also ``True`` for datetimes.
590 """
592 return isinstance(x, dt.date)
595def is_datetime(x) -> bool:
596 """Check if an object is a datetime.
598 Args:
599 x: Object to check.
601 Returns:
602 ``True`` if the object is a ``datetime``.
603 """
605 return isinstance(x, dt.datetime)
608def is_utc(d: dt.datetime) -> bool:
609 """Check if a datetime is in the UTC time zone.
611 Args:
612 d: Datetime to check. A naive datetime is assumed to be local.
614 Returns:
615 ``True`` if the time zone of the datetime is UTC.
616 """
618 return _zone_info_from_tzinfo(gws.u.require(_datetime(d).tzinfo)) == UTC
621def is_local(d: dt.datetime) -> bool:
622 """Check if a datetime is in the local time zone.
624 Args:
625 d: Datetime to check. A naive datetime is assumed to be local.
627 Returns:
628 ``True`` if the time zone of the datetime is the local time zone.
629 """
631 return _zone_info_from_tzinfo(gws.u.require(_datetime(d).tzinfo)) == time_zone('')
634# Arithmetic
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.
640 Negative values subtract.
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.
653 Returns:
654 The resulting datetime.
655 """
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 )
670class Diff:
671 """Difference between two dates, as returned by ``difference`` and ``total_difference``."""
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."""
690 def __repr__(self):
691 return repr(vars(self))
694def difference(d1: dt.date, d2: Optional[dt.date] = None) -> Diff:
695 """Compute the difference between two dates, broken down into components.
697 The components add up to the whole difference, for example ``1 year, 2 months, 1 week, 3 days``.
699 Args:
700 d1: The start date.
701 d2: The end date, the current date and time by default.
703 Returns:
704 The difference from ``d1`` to ``d2``.
705 """
707 pd = _precise_diff(d1, d2)
708 df = Diff()
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
719 return df
722def total_difference(d1: dt.date, d2: Optional[dt.date] = None) -> Diff:
723 """Compute the total difference between two dates in each unit.
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.
728 Args:
729 d1: The start date.
730 d2: The end date, the current date and time by default.
732 Returns:
733 The difference from ``d1`` to ``d2``.
734 """
736 pd = _precise_diff(d1, d2)
737 total = (_utc(_datetime(d2)) - _utc(_datetime(d1))).total_seconds()
738 df = Diff()
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
749 return df
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))
757def _utc(d: dt.datetime) -> dt.datetime:
758 return d.astimezone(dt.timezone.utc)
761def _sign(n: int) -> int:
762 return -1 if n < 0 else 1
765# Wrappers for useful pendulum utilities
767# fmt:off
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'))
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'))
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
794# fmt:on
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}
814def next(day: int | str, d: Optional[dt.date] = None, keep_time=False) -> dt.datetime:
815 """Get the next occurrence of a specific weekday.
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.
822 Returns:
823 The datetime of the next occurrence after the starting date.
824 """
826 return _unpend(_pend(d).next(_WD[day], keep_time))
829def prev(day: int | str, d: Optional[dt.date] = None, keep_time=False) -> dt.datetime:
830 """Get the previous occurrence of a specific weekday.
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.
837 Returns:
838 The datetime of the previous occurrence before the starting date.
839 """
841 return _unpend(_pend(d).previous(_WD[day], keep_time))
844# Duration
846_DURATION_UNITS = {
847 'w': 3600 * 24 * 7,
848 'd': 3600 * 24,
849 'h': 3600,
850 'm': 60,
851 's': 1,
852}
855def parse_duration(s: str) -> int:
856 """Convert a duration string to seconds.
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.
861 Args:
862 s: Duration string, or an integer number of seconds, which is returned as is.
864 Returns:
865 The duration in seconds.
867 Raises:
868 ``Error``: If the string is not a valid duration.
869 """
871 if isinstance(s, int):
872 return s
874 p = None
875 r = 0
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
887 if p:
888 r += p
890 return r
893def format_duration(s: int) -> str:
894 """Format a duration in seconds to a string.
896 Args:
897 s: Duration in seconds.
899 Returns:
900 A string like ``1d 2h 30m``, or ``0s`` for a zero duration.
901 """
903 r = ''
905 for u, v in _DURATION_UNITS.items():
906 n = s // v
907 if n:
908 r += f'{n}{u} '
909 s -= n * v
911 return r.strip() or '0s'
913##
915# conversions
918def _datetime(d: dt.date | dt.time | None) -> dt.datetime:
919 # ensure a valid datetime object
921 if d is None:
922 return now()
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='')
931 if isinstance(d, dt.date):
932 # promote date to midnight UTC
933 return dt.datetime(d.year, d.month, d.day, tzinfo=UTC)
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)
940 raise Error(f'invalid datetime value {d!r}')
943def _ensure_tzinfo(d, tz: str):
944 # attach tzinfo if not set
946 if not d.tzinfo:
947 return d.replace(tzinfo=time_zone(tz))
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)
954 # failing that, keep existing tzinfo
955 return d
958# pendulum.DateTime <-> python datetime
961def _pend(d: dt.date | None) -> pendulum.DateTime:
962 return pendulum.instance(_datetime(d))
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 )
979# NB using private APIs
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
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)
997 # times and durations not accepted
998 raise Error(f'invalid date {s!r}')
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
1011 if isinstance(d, dt.time):
1012 return _datetime(_ensure_tzinfo(d, tz))
1014 # dates and durations not accepted
1015 raise Error(f'invalid time {s!r}')