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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-05 13:35 +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``, ``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``.
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.
32When running in a docker container, there are several ways to set up the local time zone:
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``.
38Example::
40 import gws.lib.datetimex as datetimex
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'
46 next_week = datetimex.add(d, weeks=1)
47 datetimex.parse_duration('1h30m') # 5400
48"""
50from typing import Optional
52import datetime as dt
53import contextlib
54import os
55import re
56import zoneinfo
58import pendulum
59import pendulum._helpers
60import pendulum.helpers
61import pendulum.parsing
62import pendulum.parsing.exceptions
64import gws
65import gws.lib.osx
68class Error(gws.Error):
69 """Date and time error."""
71 pass
74UTC = zoneinfo.ZoneInfo('UTC')
75"""The UTC time zone."""
77_ZI_CACHE = {
78 'utc': UTC,
79 'UTC': UTC,
80 'Etc/UTC': UTC,
81}
83_ZI_ALL = set(zoneinfo.available_timezones())
86# Time zones
89def is_valid_time_zone(tz: str) -> bool:
90 """Check if a time zone string is valid.
92 Args:
93 tz: Time zone string, like ``Europe/Berlin``.
95 Returns:
96 ``True`` if the time zone is known.
97 """
99 return tz in _ZI_CACHE or tz in _ZI_ALL
102def set_local_time_zone(tz: str):
103 """Set the local time zone for the system.
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.
108 Args:
109 tz: Time zone string, like ``Europe/Berlin``.
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()
117 gws.log.debug(f'set_local_time_zone: cur={cur_zi} new={new_zi}')
119 if new_zi == cur_zi:
120 return
121 _set_localtime_from_zone_info(new_zi)
123 gws.log.debug(f'set_local_time_zone: cur={_zone_info_from_localtime()}')
126def time_zone(tz: str = '') -> zoneinfo.ZoneInfo:
127 """Get a ZoneInfo object for the specified time zone.
129 The local time zone is determined from ``/etc/localtime``; if that fails, UTC is assumed.
131 Args:
132 tz: Time zone string, like ``Europe/Berlin``. An empty string means the local time zone.
134 Returns:
135 The ZoneInfo object.
137 Raises:
138 ``Error``: If the time zone is invalid.
139 """
141 if tz in _ZI_CACHE:
142 return _ZI_CACHE[tz]
144 if not tz:
145 _ZI_CACHE[''] = _zone_info_from_localtime()
146 return _ZI_CACHE['']
148 return _zone_info_from_string(tz)
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'])
157def _zone_info_from_localtime():
158 a = '/etc/localtime'
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
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
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
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
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
199# init from the env variable right now
201if 'TZ' in os.environ:
202 _set_localtime_from_zone_info(_zone_info_from_string(os.environ['TZ']))
205# Constructors
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.
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.
222 Returns:
223 A timezone-aware datetime.
225 Raises:
226 ``Error``: If the time zone is invalid.
227 """
229 return dt.datetime(year, month, day, hour, minute, second, microsecond, fold=fold, tzinfo=time_zone(tz))
232def now(tz: str = '') -> dt.datetime:
233 """Get the current date and time.
235 Args:
236 tz: Time zone string, the local time zone by default.
238 Returns:
239 The current datetime in the given time zone.
240 """
242 return _now(time_zone(tz))
245def now_utc() -> dt.datetime:
246 """Get the current date and time in UTC.
248 Returns:
249 The current datetime in UTC.
250 """
252 return _now(UTC)
255# for testing
257_MOCK_NOW = None
260@contextlib.contextmanager
261def mock_now(d):
262 """Context manager that makes all functions here use a fixed current date and time, for testing.
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
273def _now(tzinfo):
274 return _MOCK_NOW or dt.datetime.now(tz=tzinfo)
277def today(tz: str = '') -> dt.datetime:
278 """Get today's date at midnight.
280 Args:
281 tz: Time zone string, the local time zone by default.
283 Returns:
284 A datetime at midnight of the current day in the given time zone.
285 """
287 return now(tz).replace(hour=0, minute=0, second=0, microsecond=0)
290def today_utc() -> dt.datetime:
291 """Get today's date at midnight in UTC.
293 Returns:
294 A datetime at midnight of the current day in UTC.
295 """
297 return now_utc().replace(hour=0, minute=0, second=0, microsecond=0)
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.
303 Strings are parsed like in ``from_string``. Dates become midnight in the given time zone.
305 Args:
306 s: Input to parse.
307 tz: Time zone for timezone-naive inputs, the local time zone by default.
309 Returns:
310 A datetime, or ``None`` if the input is empty or cannot be parsed.
311 """
313 if not s:
314 return None
316 if isinstance(s, dt.datetime):
317 return _ensure_tzinfo(s, tz)
319 if isinstance(s, dt.date):
320 return new(s.year, s.month, s.day, tz=tz)
322 try:
323 return from_string(str(s), tz)
324 except Error:
325 pass
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.
331 Strings are parsed like in ``from_iso_time_string``.
333 Args:
334 s: Input to parse.
335 tz: Time zone for timezone-naive inputs, the local time zone by default.
337 Returns:
338 A datetime, or ``None`` if the input is empty or cannot be parsed.
339 """
341 if not s:
342 return
344 if isinstance(s, dt.time):
345 return _datetime(_ensure_tzinfo(s, tz))
347 try:
348 return from_iso_time_string(str(s), tz)
349 except Error:
350 pass
353def from_string(s: str, tz: str = '') -> dt.datetime:
354 """Parse a date or datetime string.
356 Accepts ISO 8601 and some other common formats understood by ``pendulum``.
357 A date without time becomes midnight in the given time zone.
359 Args:
360 s: Date or datetime string.
361 tz: Time zone for timezone-naive inputs, the local time zone by default.
363 Returns:
364 A datetime.
366 Raises:
367 ``Error``: If the string cannot be parsed or is not a date or datetime.
368 """
370 return _pend_parse_datetime(s.strip(), tz, iso_only=False)
373def from_iso_string(s: str, tz: str = '') -> dt.datetime:
374 """Parse an ISO 8601 date or datetime string.
376 A date without time becomes midnight in the given time zone.
378 Args:
379 s: ISO 8601 date or datetime string.
380 tz: Time zone for timezone-naive inputs, the local time zone by default.
382 Returns:
383 A datetime.
385 Raises:
386 ``Error``: If the string cannot be parsed or is not a date or datetime.
387 """
389 return _pend_parse_datetime(s.strip(), tz, iso_only=True)
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.
395 Args:
396 s: ISO 8601 time string.
397 tz: Time zone for timezone-naive inputs, the local time zone by default.
399 Returns:
400 A datetime.
402 Raises:
403 ``Error``: If the string cannot be parsed or is not a time.
404 """
406 return _pend_parse_time(s.strip(), tz, iso_only=True)
409def from_timestamp(n: float, tz: str = '') -> dt.datetime:
410 """Create a datetime from a Unix timestamp.
412 Args:
413 n: Unix timestamp, in seconds since the epoch.
414 tz: Time zone string, the local time zone by default.
416 Returns:
417 A datetime in the given time zone.
418 """
420 return dt.datetime.fromtimestamp(n, tz=time_zone(tz))
423# Formatters
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.
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.
435 Returns:
436 A string like ``2024-05-01T12:30:00+0200``.
437 """
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
451def to_iso_date_string(d: Optional[dt.date] = None) -> str:
452 """Convert a date to an ISO date string.
454 Args:
455 d: Date to convert, the current date and time by default.
457 Returns:
458 A string like ``2024-05-01``.
459 """
461 return _datetime(d).strftime('%Y-%m-%d')
464def to_basic_string(d: Optional[dt.date] = None, with_ms=False) -> str:
465 """Convert a date to a compact string without separators.
467 Args:
468 d: Date to convert, the current date and time by default.
469 with_ms: Append milliseconds as three digits.
471 Returns:
472 A string like ``20240501123000``, or ``20240501123000123`` with milliseconds.
473 """
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
482def to_iso_time_string(d: Optional[dt.date] = None, with_tz='+') -> str:
483 """Convert a date to an ISO 8601 time string.
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.
490 Returns:
491 A string like ``12:30:00+0200``.
492 """
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
503def to_string(fmt: str, d: Optional[dt.date] = None) -> str:
504 """Convert a date to a string using a custom format.
506 Args:
507 fmt: ``strftime`` format string.
508 d: Date to convert, the current date and time by default.
510 Returns:
511 The formatted string.
512 """
514 return _datetime(d).strftime(fmt)
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.
520 Args:
521 d: Datetime or time to convert. For a date or ``None``, ``00:00:00`` is returned.
522 with_tz: Not used.
524 Returns:
525 A string like ``12:30:00``.
526 """
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'
533# Converters
536def to_timestamp(d: Optional[dt.date] = None) -> int:
537 """Convert a date to a Unix timestamp.
539 Args:
540 d: Date to convert, the current date and time by default.
542 Returns:
543 Whole seconds since the epoch.
544 """
546 return int(_datetime(d).timestamp())
549def to_millis(d: Optional[dt.date] = None) -> int:
550 """Convert a date to milliseconds since the Unix epoch.
552 Args:
553 d: Date to convert, the current date and time by default.
555 Returns:
556 Whole milliseconds since the epoch.
557 """
559 return int(_datetime(d).timestamp() * 1000)
562def to_utc(d: Optional[dt.date] = None) -> dt.datetime:
563 """Convert a date to the UTC time zone.
565 Args:
566 d: Date to convert, the current date and time by default.
568 Returns:
569 A datetime in UTC.
570 """
572 return _datetime(d).astimezone(time_zone('UTC'))
575def to_local(d: Optional[dt.date] = None) -> dt.datetime:
576 """Convert a date to the local time zone.
578 Args:
579 d: Date to convert, the current date and time by default.
581 Returns:
582 A datetime in the local time zone.
583 """
585 return _datetime(d).astimezone(time_zone(''))
588def to_time_zone(tz: str, d: Optional[dt.date] = None) -> dt.datetime:
589 """Convert a date to a specific time zone.
591 Args:
592 tz: Target time zone string.
593 d: Date to convert, the current date and time by default.
595 Returns:
596 A datetime in the target time zone.
598 Raises:
599 ``Error``: If the time zone is invalid.
600 """
602 return _datetime(d).astimezone(time_zone(tz))
605# Predicates
608def is_date(x) -> bool:
609 """Check if an object is a date.
611 Args:
612 x: Object to check.
614 Returns:
615 ``True`` if the object is a ``date``. Since ``datetime`` is a subclass of ``date``, this is also ``True`` for datetimes.
616 """
618 return isinstance(x, dt.date)
621def is_datetime(x) -> bool:
622 """Check if an object is a datetime.
624 Args:
625 x: Object to check.
627 Returns:
628 ``True`` if the object is a ``datetime``.
629 """
631 return isinstance(x, dt.datetime)
634def is_utc(d: dt.datetime) -> bool:
635 """Check if a datetime is in the UTC time zone.
637 Args:
638 d: Datetime to check. A naive datetime is assumed to be local.
640 Returns:
641 ``True`` if the time zone of the datetime is UTC.
642 """
644 return _zone_info_from_tzinfo(gws.u.require(_datetime(d).tzinfo)) == UTC
647def is_local(d: dt.datetime) -> bool:
648 """Check if a datetime is in the local time zone.
650 Args:
651 d: Datetime to check. A naive datetime is assumed to be local.
653 Returns:
654 ``True`` if the time zone of the datetime is the local time zone.
655 """
657 return _zone_info_from_tzinfo(gws.u.require(_datetime(d).tzinfo)) == time_zone('')
660# Arithmetic
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.
666 Negative values subtract.
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.
679 Returns:
680 The resulting datetime.
681 """
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 )
696class Diff:
697 """Difference between two dates, as returned by ``difference`` and ``total_difference``."""
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."""
716 def __repr__(self):
717 return repr(vars(self))
720def difference(d1: dt.date, d2: Optional[dt.date] = None) -> Diff:
721 """Compute the difference between two dates, broken down into components.
723 The components add up to the whole difference, for example ``1 year, 2 months, 1 week, 3 days``.
725 Args:
726 d1: The start date.
727 d2: The end date, the current date and time by default.
729 Returns:
730 The difference from ``d1`` to ``d2``.
731 """
733 pd = _precise_diff(d1, d2)
734 df = Diff()
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
745 return df
748def total_difference(d1: dt.date, d2: Optional[dt.date] = None) -> Diff:
749 """Compute the total difference between two dates in each unit.
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.
754 Args:
755 d1: The start date.
756 d2: The end date, the current date and time by default.
758 Returns:
759 The difference from ``d1`` to ``d2``.
760 """
762 pd = _precise_diff(d1, d2)
763 total = (_utc(_datetime(d2)) - _utc(_datetime(d1))).total_seconds()
764 df = Diff()
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
775 return df
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))
783def _utc(d: dt.datetime) -> dt.datetime:
784 return d.astimezone(dt.timezone.utc)
787def _sign(n: int) -> int:
788 return -1 if n < 0 else 1
791# Wrappers for useful pendulum utilities
793# fmt:off
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'))
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'))
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
820# fmt:on
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}
840def next(day: int | str, d: Optional[dt.date] = None, keep_time=False) -> dt.datetime:
841 """Get the next occurrence of a specific weekday.
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.
848 Returns:
849 The datetime of the next occurrence after the starting date.
850 """
852 return _unpend(_pend(d).next(_WD[day], keep_time))
855def prev(day: int | str, d: Optional[dt.date] = None, keep_time=False) -> dt.datetime:
856 """Get the previous occurrence of a specific weekday.
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.
863 Returns:
864 The datetime of the previous occurrence before the starting date.
865 """
867 return _unpend(_pend(d).previous(_WD[day], keep_time))
870# Duration
872_DURATION_UNITS = {
873 'w': 3600 * 24 * 7,
874 'd': 3600 * 24,
875 'h': 3600,
876 'm': 60,
877 's': 1,
878}
881def parse_duration(s: str) -> int:
882 """Convert a duration string to seconds.
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.
887 Args:
888 s: Duration string, or an integer number of seconds, which is returned as is.
890 Returns:
891 The duration in seconds.
893 Raises:
894 ``Error``: If the string is not a valid duration.
895 """
897 if isinstance(s, int):
898 return s
900 p = None
901 r = 0
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
913 if p:
914 r += p
916 return r
919def format_duration(s: int) -> str:
920 """Format a duration in seconds to a string.
922 Args:
923 s: Duration in seconds.
925 Returns:
926 A string like ``1d 2h 30m``, or ``0s`` for a zero duration.
927 """
929 r = ''
931 for u, v in _DURATION_UNITS.items():
932 n = s // v
933 if n:
934 r += f'{n}{u} '
935 s -= n * v
937 return r.strip() or '0s'
939##
941# conversions
944def _datetime(d: dt.date | dt.time | None) -> dt.datetime:
945 # ensure a valid datetime object
947 if d is None:
948 return now()
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='')
957 if isinstance(d, dt.date):
958 # promote date to midnight UTC
959 return dt.datetime(d.year, d.month, d.day, tzinfo=UTC)
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)
966 raise Error(f'invalid datetime value {d!r}')
969def _ensure_tzinfo(d, tz: str):
970 # attach tzinfo if not set
972 if not d.tzinfo:
973 return d.replace(tzinfo=time_zone(tz))
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)
980 # failing that, keep existing tzinfo
981 return d
984# pendulum.DateTime <-> python datetime
987def _pend(d: dt.date | None) -> pendulum.DateTime:
988 return pendulum.instance(_datetime(d))
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 )
1005# NB using private APIs
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
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)
1023 # times and durations not accepted
1024 raise Error(f'invalid date {s!r}')
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
1037 if isinstance(d, dt.time):
1038 return _datetime(_ensure_tzinfo(d, tz))
1040 # dates and durations not accepted
1041 raise Error(f'invalid time {s!r}')