Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/babel/dates.py: 12%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""
2 babel.dates
3 ~~~~~~~~~~~
5 Locale dependent formatting and parsing of dates and times.
7 The default locale for the functions in this module is determined by the
8 following environment variables, in that order:
10 * ``LC_TIME``,
11 * ``LC_ALL``, and
12 * ``LANG``
14 :copyright: (c) 2013-2025 by the Babel Team.
15 :license: BSD, see LICENSE for more details.
16"""
18from __future__ import annotations
20import math
21import re
22import warnings
23from functools import lru_cache
24from typing import TYPE_CHECKING, Literal, SupportsInt
26try:
27 import pytz
28except ModuleNotFoundError:
29 pytz = None
30 import zoneinfo
32import datetime
33from collections.abc import Iterable
35from babel import localtime
36from babel.core import Locale, default_locale, get_global
37from babel.localedata import LocaleDataDict
39if TYPE_CHECKING:
40 from typing_extensions import TypeAlias
41 _Instant: TypeAlias = datetime.date | datetime.time | float | None
42 _PredefinedTimeFormat: TypeAlias = Literal['full', 'long', 'medium', 'short']
43 _Context: TypeAlias = Literal['format', 'stand-alone']
44 _DtOrTzinfo: TypeAlias = datetime.datetime | datetime.tzinfo | str | int | datetime.time | None
46# "If a given short metazone form is known NOT to be understood in a given
47# locale and the parent locale has this value such that it would normally
48# be inherited, the inheritance of this value can be explicitly disabled by
49# use of the 'no inheritance marker' as the value, which is 3 simultaneous [sic]
50# empty set characters ( U+2205 )."
51# - https://www.unicode.org/reports/tr35/tr35-dates.html#Metazone_Names
53NO_INHERITANCE_MARKER = '\u2205\u2205\u2205'
55UTC = datetime.timezone.utc
56LOCALTZ = localtime.LOCALTZ
58LC_TIME = default_locale('LC_TIME')
61def _localize(tz: datetime.tzinfo, dt: datetime.datetime) -> datetime.datetime:
62 # Support localizing with both pytz and zoneinfo tzinfos
63 # nothing to do
64 if dt.tzinfo is tz:
65 return dt
67 if hasattr(tz, 'localize'): # pytz
68 return tz.localize(dt)
70 if dt.tzinfo is None:
71 # convert naive to localized
72 return dt.replace(tzinfo=tz)
74 # convert timezones
75 return dt.astimezone(tz)
78def _get_dt_and_tzinfo(dt_or_tzinfo: _DtOrTzinfo) -> tuple[datetime.datetime | None, datetime.tzinfo]:
79 """
80 Parse a `dt_or_tzinfo` value into a datetime and a tzinfo.
82 See the docs for this function's callers for semantics.
84 :rtype: tuple[datetime, tzinfo]
85 """
86 if dt_or_tzinfo is None:
87 dt = datetime.datetime.now()
88 tzinfo = LOCALTZ
89 elif isinstance(dt_or_tzinfo, str):
90 dt = None
91 tzinfo = get_timezone(dt_or_tzinfo)
92 elif isinstance(dt_or_tzinfo, int):
93 dt = None
94 tzinfo = UTC
95 elif isinstance(dt_or_tzinfo, (datetime.datetime, datetime.time)):
96 dt = _get_datetime(dt_or_tzinfo)
97 tzinfo = dt.tzinfo if dt.tzinfo is not None else UTC
98 else:
99 dt = None
100 tzinfo = dt_or_tzinfo
101 return dt, tzinfo
104def _get_tz_name(dt_or_tzinfo: _DtOrTzinfo) -> str:
105 """
106 Get the timezone name out of a time, datetime, or tzinfo object.
108 :rtype: str
109 """
110 dt, tzinfo = _get_dt_and_tzinfo(dt_or_tzinfo)
111 if hasattr(tzinfo, 'zone'): # pytz object
112 return tzinfo.zone
113 elif hasattr(tzinfo, 'key') and tzinfo.key is not None: # ZoneInfo object
114 return tzinfo.key
115 else:
116 return tzinfo.tzname(dt or datetime.datetime.now(UTC))
119def _get_datetime(instant: _Instant) -> datetime.datetime:
120 """
121 Get a datetime out of an "instant" (date, time, datetime, number).
123 .. warning:: The return values of this function may depend on the system clock.
125 If the instant is None, the current moment is used.
126 If the instant is a time, it's augmented with today's date.
128 Dates are converted to naive datetimes with midnight as the time component.
130 >>> from datetime import date, datetime
131 >>> _get_datetime(date(2015, 1, 1))
132 datetime.datetime(2015, 1, 1, 0, 0)
134 UNIX timestamps are converted to datetimes.
136 >>> _get_datetime(1400000000)
137 datetime.datetime(2014, 5, 13, 16, 53, 20)
139 Other values are passed through as-is.
141 >>> x = datetime(2015, 1, 1)
142 >>> _get_datetime(x) is x
143 True
145 :param instant: date, time, datetime, integer, float or None
146 :type instant: date|time|datetime|int|float|None
147 :return: a datetime
148 :rtype: datetime
149 """
150 if instant is None:
151 return datetime.datetime.now(UTC).replace(tzinfo=None)
152 elif isinstance(instant, (int, float)):
153 return datetime.datetime.fromtimestamp(instant, UTC).replace(tzinfo=None)
154 elif isinstance(instant, datetime.time):
155 return datetime.datetime.combine(datetime.date.today(), instant)
156 elif isinstance(instant, datetime.date) and not isinstance(instant, datetime.datetime):
157 return datetime.datetime.combine(instant, datetime.time())
158 # TODO (3.x): Add an assertion/type check for this fallthrough branch:
159 return instant
162def _ensure_datetime_tzinfo(dt: datetime.datetime, tzinfo: datetime.tzinfo | None = None) -> datetime.datetime:
163 """
164 Ensure the datetime passed has an attached tzinfo.
166 If the datetime is tz-naive to begin with, UTC is attached.
168 If a tzinfo is passed in, the datetime is normalized to that timezone.
170 >>> from datetime import datetime
171 >>> _get_tz_name(_ensure_datetime_tzinfo(datetime(2015, 1, 1)))
172 'UTC'
174 >>> tz = get_timezone("Europe/Stockholm")
175 >>> _ensure_datetime_tzinfo(datetime(2015, 1, 1, 13, 15, tzinfo=UTC), tzinfo=tz).hour
176 14
178 :param datetime: Datetime to augment.
179 :param tzinfo: optional tzinfo
180 :return: datetime with tzinfo
181 :rtype: datetime
182 """
183 if dt.tzinfo is None:
184 dt = dt.replace(tzinfo=UTC)
185 if tzinfo is not None:
186 dt = dt.astimezone(get_timezone(tzinfo))
187 if hasattr(tzinfo, 'normalize'): # pytz
188 dt = tzinfo.normalize(dt)
189 return dt
192def _get_time(
193 time: datetime.time | datetime.datetime | None,
194 tzinfo: datetime.tzinfo | None = None,
195) -> datetime.time:
196 """
197 Get a timezoned time from a given instant.
199 .. warning:: The return values of this function may depend on the system clock.
201 :param time: time, datetime or None
202 :rtype: time
203 """
204 if time is None:
205 time = datetime.datetime.now(UTC)
206 elif isinstance(time, (int, float)):
207 time = datetime.datetime.fromtimestamp(time, UTC)
209 if time.tzinfo is None:
210 time = time.replace(tzinfo=UTC)
212 if isinstance(time, datetime.datetime):
213 if tzinfo is not None:
214 time = time.astimezone(tzinfo)
215 if hasattr(tzinfo, 'normalize'): # pytz
216 time = tzinfo.normalize(time)
217 time = time.timetz()
218 elif tzinfo is not None:
219 time = time.replace(tzinfo=tzinfo)
220 return time
223def get_timezone(zone: str | datetime.tzinfo | None = None) -> datetime.tzinfo:
224 """Looks up a timezone by name and returns it. The timezone object
225 returned comes from ``pytz`` or ``zoneinfo``, whichever is available.
226 It corresponds to the `tzinfo` interface and can be used with all of
227 the functions of Babel that operate with dates.
229 If a timezone is not known a :exc:`LookupError` is raised. If `zone`
230 is ``None`` a local zone object is returned.
232 :param zone: the name of the timezone to look up. If a timezone object
233 itself is passed in, it's returned unchanged.
234 """
235 if zone is None:
236 return LOCALTZ
237 if not isinstance(zone, str):
238 return zone
240 if pytz:
241 try:
242 return pytz.timezone(zone)
243 except pytz.UnknownTimeZoneError as e:
244 exc = e
245 else:
246 assert zoneinfo
247 try:
248 return zoneinfo.ZoneInfo(zone)
249 except zoneinfo.ZoneInfoNotFoundError as e:
250 exc = e
252 raise LookupError(f"Unknown timezone {zone}") from exc
255def get_period_names(
256 width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
257 context: _Context = 'stand-alone',
258 locale: Locale | str | None = None,
259) -> LocaleDataDict:
260 """Return the names for day periods (AM/PM) used by the locale.
262 >>> get_period_names(locale='en_US')['am']
263 u'AM'
265 :param width: the width to use, one of "abbreviated", "narrow", or "wide"
266 :param context: the context, either "format" or "stand-alone"
267 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
268 """
269 return Locale.parse(locale or LC_TIME).day_periods[context][width]
272def get_day_names(
273 width: Literal['abbreviated', 'narrow', 'short', 'wide'] = 'wide',
274 context: _Context = 'format',
275 locale: Locale | str | None = None,
276) -> LocaleDataDict:
277 """Return the day names used by the locale for the specified format.
279 >>> get_day_names('wide', locale='en_US')[1]
280 u'Tuesday'
281 >>> get_day_names('short', locale='en_US')[1]
282 u'Tu'
283 >>> get_day_names('abbreviated', locale='es')[1]
284 u'mar'
285 >>> get_day_names('narrow', context='stand-alone', locale='de_DE')[1]
286 u'D'
288 :param width: the width to use, one of "wide", "abbreviated", "short" or "narrow"
289 :param context: the context, either "format" or "stand-alone"
290 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
291 """
292 return Locale.parse(locale or LC_TIME).days[context][width]
295def get_month_names(
296 width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
297 context: _Context = 'format',
298 locale: Locale | str | None = None,
299) -> LocaleDataDict:
300 """Return the month names used by the locale for the specified format.
302 >>> get_month_names('wide', locale='en_US')[1]
303 u'January'
304 >>> get_month_names('abbreviated', locale='es')[1]
305 u'ene'
306 >>> get_month_names('narrow', context='stand-alone', locale='de_DE')[1]
307 u'J'
309 :param width: the width to use, one of "wide", "abbreviated", or "narrow"
310 :param context: the context, either "format" or "stand-alone"
311 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
312 """
313 return Locale.parse(locale or LC_TIME).months[context][width]
316def get_quarter_names(
317 width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
318 context: _Context = 'format',
319 locale: Locale | str | None = None,
320) -> LocaleDataDict:
321 """Return the quarter names used by the locale for the specified format.
323 >>> get_quarter_names('wide', locale='en_US')[1]
324 u'1st quarter'
325 >>> get_quarter_names('abbreviated', locale='de_DE')[1]
326 u'Q1'
327 >>> get_quarter_names('narrow', locale='de_DE')[1]
328 u'1'
330 :param width: the width to use, one of "wide", "abbreviated", or "narrow"
331 :param context: the context, either "format" or "stand-alone"
332 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
333 """
334 return Locale.parse(locale or LC_TIME).quarters[context][width]
337def get_era_names(
338 width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
339 locale: Locale | str | None = None,
340) -> LocaleDataDict:
341 """Return the era names used by the locale for the specified format.
343 >>> get_era_names('wide', locale='en_US')[1]
344 u'Anno Domini'
345 >>> get_era_names('abbreviated', locale='de_DE')[1]
346 u'n. Chr.'
348 :param width: the width to use, either "wide", "abbreviated", or "narrow"
349 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
350 """
351 return Locale.parse(locale or LC_TIME).eras[width]
354def get_date_format(
355 format: _PredefinedTimeFormat = 'medium',
356 locale: Locale | str | None = None,
357) -> DateTimePattern:
358 """Return the date formatting patterns used by the locale for the specified
359 format.
361 >>> get_date_format(locale='en_US')
362 <DateTimePattern u'MMM d, y'>
363 >>> get_date_format('full', locale='de_DE')
364 <DateTimePattern u'EEEE, d. MMMM y'>
366 :param format: the format to use, one of "full", "long", "medium", or
367 "short"
368 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
369 """
370 return Locale.parse(locale or LC_TIME).date_formats[format]
373def get_datetime_format(
374 format: _PredefinedTimeFormat = 'medium',
375 locale: Locale | str | None = None,
376) -> DateTimePattern:
377 """Return the datetime formatting patterns used by the locale for the
378 specified format.
380 >>> get_datetime_format(locale='en_US')
381 u'{1}, {0}'
383 :param format: the format to use, one of "full", "long", "medium", or
384 "short"
385 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
386 """
387 patterns = Locale.parse(locale or LC_TIME).datetime_formats
388 if format not in patterns:
389 format = None
390 return patterns[format]
393def get_time_format(
394 format: _PredefinedTimeFormat = 'medium',
395 locale: Locale | str | None = None,
396) -> DateTimePattern:
397 """Return the time formatting patterns used by the locale for the specified
398 format.
400 >>> get_time_format(locale='en_US')
401 <DateTimePattern u'h:mm:ss\u202fa'>
402 >>> get_time_format('full', locale='de_DE')
403 <DateTimePattern u'HH:mm:ss zzzz'>
405 :param format: the format to use, one of "full", "long", "medium", or
406 "short"
407 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
408 """
409 return Locale.parse(locale or LC_TIME).time_formats[format]
412def get_timezone_gmt(
413 datetime: _Instant = None,
414 width: Literal['long', 'short', 'iso8601', 'iso8601_short'] = 'long',
415 locale: Locale | str | None = None,
416 return_z: bool = False,
417) -> str:
418 """Return the timezone associated with the given `datetime` object formatted
419 as string indicating the offset from GMT.
421 >>> from datetime import datetime
422 >>> dt = datetime(2007, 4, 1, 15, 30)
423 >>> get_timezone_gmt(dt, locale='en')
424 u'GMT+00:00'
425 >>> get_timezone_gmt(dt, locale='en', return_z=True)
426 'Z'
427 >>> get_timezone_gmt(dt, locale='en', width='iso8601_short')
428 u'+00'
429 >>> tz = get_timezone('America/Los_Angeles')
430 >>> dt = _localize(tz, datetime(2007, 4, 1, 15, 30))
431 >>> get_timezone_gmt(dt, locale='en')
432 u'GMT-07:00'
433 >>> get_timezone_gmt(dt, 'short', locale='en')
434 u'-0700'
435 >>> get_timezone_gmt(dt, locale='en', width='iso8601_short')
436 u'-07'
438 The long format depends on the locale, for example in France the acronym
439 UTC string is used instead of GMT:
441 >>> get_timezone_gmt(dt, 'long', locale='fr_FR')
442 u'UTC-07:00'
444 .. versionadded:: 0.9
446 :param datetime: the ``datetime`` object; if `None`, the current date and
447 time in UTC is used
448 :param width: either "long" or "short" or "iso8601" or "iso8601_short"
449 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
450 :param return_z: True or False; Function returns indicator "Z"
451 when local time offset is 0
452 """
453 datetime = _ensure_datetime_tzinfo(_get_datetime(datetime))
454 locale = Locale.parse(locale or LC_TIME)
456 offset = datetime.tzinfo.utcoffset(datetime)
457 seconds = offset.days * 24 * 60 * 60 + offset.seconds
458 hours, seconds = divmod(seconds, 3600)
459 if return_z and hours == 0 and seconds == 0:
460 return 'Z'
461 elif seconds == 0 and width == 'iso8601_short':
462 return '%+03d' % hours
463 elif width == 'short' or width == 'iso8601_short':
464 pattern = '%+03d%02d'
465 elif width == 'iso8601':
466 pattern = '%+03d:%02d'
467 else:
468 pattern = locale.zone_formats['gmt'] % '%+03d:%02d'
469 return pattern % (hours, seconds // 60)
472def get_timezone_location(
473 dt_or_tzinfo: _DtOrTzinfo = None,
474 locale: Locale | str | None = None,
475 return_city: bool = False,
476) -> str:
477 """Return a representation of the given timezone using "location format".
479 The result depends on both the local display name of the country and the
480 city associated with the time zone:
482 >>> tz = get_timezone('America/St_Johns')
483 >>> print(get_timezone_location(tz, locale='de_DE'))
484 Kanada (St. John’s) (Ortszeit)
485 >>> print(get_timezone_location(tz, locale='en'))
486 Canada (St. John’s) Time
487 >>> print(get_timezone_location(tz, locale='en', return_city=True))
488 St. John’s
489 >>> tz = get_timezone('America/Mexico_City')
490 >>> get_timezone_location(tz, locale='de_DE')
491 u'Mexiko (Mexiko-Stadt) (Ortszeit)'
493 If the timezone is associated with a country that uses only a single
494 timezone, just the localized country name is returned:
496 >>> tz = get_timezone('Europe/Berlin')
497 >>> get_timezone_name(tz, locale='de_DE')
498 u'Mitteleurop\\xe4ische Zeit'
500 .. versionadded:: 0.9
502 :param dt_or_tzinfo: the ``datetime`` or ``tzinfo`` object that determines
503 the timezone; if `None`, the current date and time in
504 UTC is assumed
505 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
506 :param return_city: True or False, if True then return exemplar city (location)
507 for the time zone
508 :return: the localized timezone name using location format
510 """
511 locale = Locale.parse(locale or LC_TIME)
513 zone = _get_tz_name(dt_or_tzinfo)
515 # Get the canonical time-zone code
516 zone = get_global('zone_aliases').get(zone, zone)
518 info = locale.time_zones.get(zone, {})
520 # Otherwise, if there is only one timezone for the country, return the
521 # localized country name
522 region_format = locale.zone_formats['region']
523 territory = get_global('zone_territories').get(zone)
524 if territory not in locale.territories:
525 territory = 'ZZ' # invalid/unknown
526 territory_name = locale.territories[territory]
527 if not return_city and territory and len(get_global('territory_zones').get(territory, [])) == 1:
528 return region_format % territory_name
530 # Otherwise, include the city in the output
531 fallback_format = locale.zone_formats['fallback']
532 if 'city' in info:
533 city_name = info['city']
534 else:
535 metazone = get_global('meta_zones').get(zone)
536 metazone_info = locale.meta_zones.get(metazone, {})
537 if 'city' in metazone_info:
538 city_name = metazone_info['city']
539 elif '/' in zone:
540 city_name = zone.split('/', 1)[1].replace('_', ' ')
541 else:
542 city_name = zone.replace('_', ' ')
544 if return_city:
545 return city_name
546 return region_format % (fallback_format % {
547 '0': city_name,
548 '1': territory_name,
549 })
552def get_timezone_name(
553 dt_or_tzinfo: _DtOrTzinfo = None,
554 width: Literal['long', 'short'] = 'long',
555 uncommon: bool = False,
556 locale: Locale | str | None = None,
557 zone_variant: Literal['generic', 'daylight', 'standard'] | None = None,
558 return_zone: bool = False,
559) -> str:
560 r"""Return the localized display name for the given timezone. The timezone
561 may be specified using a ``datetime`` or `tzinfo` object.
563 >>> from datetime import time
564 >>> dt = time(15, 30, tzinfo=get_timezone('America/Los_Angeles'))
565 >>> get_timezone_name(dt, locale='en_US') # doctest: +SKIP
566 u'Pacific Standard Time'
567 >>> get_timezone_name(dt, locale='en_US', return_zone=True)
568 'America/Los_Angeles'
569 >>> get_timezone_name(dt, width='short', locale='en_US') # doctest: +SKIP
570 u'PST'
572 If this function gets passed only a `tzinfo` object and no concrete
573 `datetime`, the returned display name is independent of daylight savings
574 time. This can be used for example for selecting timezones, or to set the
575 time of events that recur across DST changes:
577 >>> tz = get_timezone('America/Los_Angeles')
578 >>> get_timezone_name(tz, locale='en_US')
579 u'Pacific Time'
580 >>> get_timezone_name(tz, 'short', locale='en_US')
581 u'PT'
583 If no localized display name for the timezone is available, and the timezone
584 is associated with a country that uses only a single timezone, the name of
585 that country is returned, formatted according to the locale:
587 >>> tz = get_timezone('Europe/Berlin')
588 >>> get_timezone_name(tz, locale='de_DE')
589 u'Mitteleurop\xe4ische Zeit'
590 >>> get_timezone_name(tz, locale='pt_BR')
591 u'Hor\xe1rio da Europa Central'
593 On the other hand, if the country uses multiple timezones, the city is also
594 included in the representation:
596 >>> tz = get_timezone('America/St_Johns')
597 >>> get_timezone_name(tz, locale='de_DE')
598 u'Neufundland-Zeit'
600 Note that short format is currently not supported for all timezones and
601 all locales. This is partially because not every timezone has a short
602 code in every locale. In that case it currently falls back to the long
603 format.
605 For more information see `LDML Appendix J: Time Zone Display Names
606 <https://www.unicode.org/reports/tr35/#Time_Zone_Fallback>`_
608 .. versionadded:: 0.9
610 .. versionchanged:: 1.0
611 Added `zone_variant` support.
613 :param dt_or_tzinfo: the ``datetime`` or ``tzinfo`` object that determines
614 the timezone; if a ``tzinfo`` object is used, the
615 resulting display name will be generic, i.e.
616 independent of daylight savings time; if `None`, the
617 current date in UTC is assumed
618 :param width: either "long" or "short"
619 :param uncommon: deprecated and ignored
620 :param zone_variant: defines the zone variation to return. By default the
621 variation is defined from the datetime object
622 passed in. If no datetime object is passed in, the
623 ``'generic'`` variation is assumed. The following
624 values are valid: ``'generic'``, ``'daylight'`` and
625 ``'standard'``.
626 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
627 :param return_zone: True or False. If true then function
628 returns long time zone ID
629 """
630 dt, tzinfo = _get_dt_and_tzinfo(dt_or_tzinfo)
631 locale = Locale.parse(locale or LC_TIME)
633 zone = _get_tz_name(dt_or_tzinfo)
635 if zone_variant is None:
636 if dt is None:
637 zone_variant = 'generic'
638 else:
639 dst = tzinfo.dst(dt)
640 zone_variant = "daylight" if dst else "standard"
641 else:
642 if zone_variant not in ('generic', 'standard', 'daylight'):
643 raise ValueError('Invalid zone variation')
645 # Get the canonical time-zone code
646 zone = get_global('zone_aliases').get(zone, zone)
647 if return_zone:
648 return zone
649 info = locale.time_zones.get(zone, {})
650 # Try explicitly translated zone names first
651 if width in info and zone_variant in info[width]:
652 return info[width][zone_variant]
654 metazone = get_global('meta_zones').get(zone)
655 if metazone:
656 metazone_info = locale.meta_zones.get(metazone, {})
657 if width in metazone_info:
658 name = metazone_info[width].get(zone_variant)
659 if width == 'short' and name == NO_INHERITANCE_MARKER:
660 # If the short form is marked no-inheritance,
661 # try to fall back to the long name instead.
662 name = metazone_info.get('long', {}).get(zone_variant)
663 if name:
664 return name
666 # If we have a concrete datetime, we assume that the result can't be
667 # independent of daylight savings time, so we return the GMT offset
668 if dt is not None:
669 return get_timezone_gmt(dt, width=width, locale=locale)
671 return get_timezone_location(dt_or_tzinfo, locale=locale)
674def format_date(
675 date: datetime.date | None = None,
676 format: _PredefinedTimeFormat | str = 'medium',
677 locale: Locale | str | None = None,
678) -> str:
679 """Return a date formatted according to the given pattern.
681 >>> from datetime import date
682 >>> d = date(2007, 4, 1)
683 >>> format_date(d, locale='en_US')
684 u'Apr 1, 2007'
685 >>> format_date(d, format='full', locale='de_DE')
686 u'Sonntag, 1. April 2007'
688 If you don't want to use the locale default formats, you can specify a
689 custom date pattern:
691 >>> format_date(d, "EEE, MMM d, ''yy", locale='en')
692 u"Sun, Apr 1, '07"
694 :param date: the ``date`` or ``datetime`` object; if `None`, the current
695 date is used
696 :param format: one of "full", "long", "medium", or "short", or a custom
697 date/time pattern
698 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
699 """
700 if date is None:
701 date = datetime.date.today()
702 elif isinstance(date, datetime.datetime):
703 date = date.date()
705 locale = Locale.parse(locale or LC_TIME)
706 if format in ('full', 'long', 'medium', 'short'):
707 format = get_date_format(format, locale=locale)
708 pattern = parse_pattern(format)
709 return pattern.apply(date, locale)
712def format_datetime(
713 datetime: _Instant = None,
714 format: _PredefinedTimeFormat | str = 'medium',
715 tzinfo: datetime.tzinfo | None = None,
716 locale: Locale | str | None = None,
717) -> str:
718 r"""Return a date formatted according to the given pattern.
720 >>> from datetime import datetime
721 >>> dt = datetime(2007, 4, 1, 15, 30)
722 >>> format_datetime(dt, locale='en_US')
723 u'Apr 1, 2007, 3:30:00\u202fPM'
725 For any pattern requiring the display of the timezone:
727 >>> format_datetime(dt, 'full', tzinfo=get_timezone('Europe/Paris'),
728 ... locale='fr_FR')
729 'dimanche 1 avril 2007, 17:30:00 heure d’été d’Europe centrale'
730 >>> format_datetime(dt, "yyyy.MM.dd G 'at' HH:mm:ss zzz",
731 ... tzinfo=get_timezone('US/Eastern'), locale='en')
732 u'2007.04.01 AD at 11:30:00 EDT'
734 :param datetime: the `datetime` object; if `None`, the current date and
735 time is used
736 :param format: one of "full", "long", "medium", or "short", or a custom
737 date/time pattern
738 :param tzinfo: the timezone to apply to the time for display
739 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
740 """
741 datetime = _ensure_datetime_tzinfo(_get_datetime(datetime), tzinfo)
743 locale = Locale.parse(locale or LC_TIME)
744 if format in ('full', 'long', 'medium', 'short'):
745 return get_datetime_format(format, locale=locale) \
746 .replace("'", "") \
747 .replace('{0}', format_time(datetime, format, tzinfo=None,
748 locale=locale)) \
749 .replace('{1}', format_date(datetime, format, locale=locale))
750 else:
751 return parse_pattern(format).apply(datetime, locale)
754def format_time(
755 time: datetime.time | datetime.datetime | float | None = None,
756 format: _PredefinedTimeFormat | str = 'medium',
757 tzinfo: datetime.tzinfo | None = None,
758 locale: Locale | str | None = None,
759) -> str:
760 r"""Return a time formatted according to the given pattern.
762 >>> from datetime import datetime, time
763 >>> t = time(15, 30)
764 >>> format_time(t, locale='en_US')
765 u'3:30:00\u202fPM'
766 >>> format_time(t, format='short', locale='de_DE')
767 u'15:30'
769 If you don't want to use the locale default formats, you can specify a
770 custom time pattern:
772 >>> format_time(t, "hh 'o''clock' a", locale='en')
773 u"03 o'clock PM"
775 For any pattern requiring the display of the time-zone a
776 timezone has to be specified explicitly:
778 >>> t = datetime(2007, 4, 1, 15, 30)
779 >>> tzinfo = get_timezone('Europe/Paris')
780 >>> t = _localize(tzinfo, t)
781 >>> format_time(t, format='full', tzinfo=tzinfo, locale='fr_FR')
782 '15:30:00 heure d’été d’Europe centrale'
783 >>> format_time(t, "hh 'o''clock' a, zzzz", tzinfo=get_timezone('US/Eastern'),
784 ... locale='en')
785 u"09 o'clock AM, Eastern Daylight Time"
787 As that example shows, when this function gets passed a
788 ``datetime.datetime`` value, the actual time in the formatted string is
789 adjusted to the timezone specified by the `tzinfo` parameter. If the
790 ``datetime`` is "naive" (i.e. it has no associated timezone information),
791 it is assumed to be in UTC.
793 These timezone calculations are **not** performed if the value is of type
794 ``datetime.time``, as without date information there's no way to determine
795 what a given time would translate to in a different timezone without
796 information about whether daylight savings time is in effect or not. This
797 means that time values are left as-is, and the value of the `tzinfo`
798 parameter is only used to display the timezone name if needed:
800 >>> t = time(15, 30)
801 >>> format_time(t, format='full', tzinfo=get_timezone('Europe/Paris'),
802 ... locale='fr_FR') # doctest: +SKIP
803 u'15:30:00 heure normale d\u2019Europe centrale'
804 >>> format_time(t, format='full', tzinfo=get_timezone('US/Eastern'),
805 ... locale='en_US') # doctest: +SKIP
806 u'3:30:00\u202fPM Eastern Standard Time'
808 :param time: the ``time`` or ``datetime`` object; if `None`, the current
809 time in UTC is used
810 :param format: one of "full", "long", "medium", or "short", or a custom
811 date/time pattern
812 :param tzinfo: the time-zone to apply to the time for display
813 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
814 """
816 # get reference date for if we need to find the right timezone variant
817 # in the pattern
818 ref_date = time.date() if isinstance(time, datetime.datetime) else None
820 time = _get_time(time, tzinfo)
822 locale = Locale.parse(locale or LC_TIME)
823 if format in ('full', 'long', 'medium', 'short'):
824 format = get_time_format(format, locale=locale)
825 return parse_pattern(format).apply(time, locale, reference_date=ref_date)
828def format_skeleton(
829 skeleton: str,
830 datetime: _Instant = None,
831 tzinfo: datetime.tzinfo | None = None,
832 fuzzy: bool = True,
833 locale: Locale | str | None = None,
834) -> str:
835 r"""Return a time and/or date formatted according to the given pattern.
837 The skeletons are defined in the CLDR data and provide more flexibility
838 than the simple short/long/medium formats, but are a bit harder to use.
839 The are defined using the date/time symbols without order or punctuation
840 and map to a suitable format for the given locale.
842 >>> from datetime import datetime
843 >>> t = datetime(2007, 4, 1, 15, 30)
844 >>> format_skeleton('MMMEd', t, locale='fr')
845 u'dim. 1 avr.'
846 >>> format_skeleton('MMMEd', t, locale='en')
847 u'Sun, Apr 1'
848 >>> format_skeleton('yMMd', t, locale='fi') # yMMd is not in the Finnish locale; yMd gets used
849 u'1.4.2007'
850 >>> format_skeleton('yMMd', t, fuzzy=False, locale='fi') # yMMd is not in the Finnish locale, an error is thrown
851 Traceback (most recent call last):
852 ...
853 KeyError: yMMd
854 >>> format_skeleton('GH', t, fuzzy=True, locale='fi_FI') # GH is not in the Finnish locale and there is no close match, an error is thrown
855 Traceback (most recent call last):
856 ...
857 KeyError: None
859 After the skeleton is resolved to a pattern `format_datetime` is called so
860 all timezone processing etc is the same as for that.
862 :param skeleton: A date time skeleton as defined in the cldr data.
863 :param datetime: the ``time`` or ``datetime`` object; if `None`, the current
864 time in UTC is used
865 :param tzinfo: the time-zone to apply to the time for display
866 :param fuzzy: If the skeleton is not found, allow choosing a skeleton that's
867 close enough to it. If there is no close match, a `KeyError`
868 is thrown.
869 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
870 """
871 locale = Locale.parse(locale or LC_TIME)
872 if fuzzy and skeleton not in locale.datetime_skeletons:
873 skeleton = match_skeleton(skeleton, locale.datetime_skeletons)
874 format = locale.datetime_skeletons[skeleton]
875 return format_datetime(datetime, format, tzinfo, locale)
878TIMEDELTA_UNITS: tuple[tuple[str, int], ...] = (
879 ('year', 3600 * 24 * 365),
880 ('month', 3600 * 24 * 30),
881 ('week', 3600 * 24 * 7),
882 ('day', 3600 * 24),
883 ('hour', 3600),
884 ('minute', 60),
885 ('second', 1),
886)
889def format_timedelta(
890 delta: datetime.timedelta | int,
891 granularity: Literal['year', 'month', 'week', 'day', 'hour', 'minute', 'second'] = 'second',
892 threshold: float = .85,
893 add_direction: bool = False,
894 format: Literal['narrow', 'short', 'medium', 'long'] = 'long',
895 locale: Locale | str | None = None,
896) -> str:
897 """Return a time delta according to the rules of the given locale.
899 >>> from datetime import timedelta
900 >>> format_timedelta(timedelta(weeks=12), locale='en_US')
901 u'3 months'
902 >>> format_timedelta(timedelta(seconds=1), locale='es')
903 u'1 segundo'
905 The granularity parameter can be provided to alter the lowest unit
906 presented, which defaults to a second.
908 >>> format_timedelta(timedelta(hours=3), granularity='day', locale='en_US')
909 u'1 day'
911 The threshold parameter can be used to determine at which value the
912 presentation switches to the next higher unit. A higher threshold factor
913 means the presentation will switch later. For example:
915 >>> format_timedelta(timedelta(hours=23), threshold=0.9, locale='en_US')
916 u'1 day'
917 >>> format_timedelta(timedelta(hours=23), threshold=1.1, locale='en_US')
918 u'23 hours'
920 In addition directional information can be provided that informs
921 the user if the date is in the past or in the future:
923 >>> format_timedelta(timedelta(hours=1), add_direction=True, locale='en')
924 u'in 1 hour'
925 >>> format_timedelta(timedelta(hours=-1), add_direction=True, locale='en')
926 u'1 hour ago'
928 The format parameter controls how compact or wide the presentation is:
930 >>> format_timedelta(timedelta(hours=3), format='short', locale='en')
931 u'3 hr'
932 >>> format_timedelta(timedelta(hours=3), format='narrow', locale='en')
933 u'3h'
935 :param delta: a ``timedelta`` object representing the time difference to
936 format, or the delta in seconds as an `int` value
937 :param granularity: determines the smallest unit that should be displayed,
938 the value can be one of "year", "month", "week", "day",
939 "hour", "minute" or "second"
940 :param threshold: factor that determines at which point the presentation
941 switches to the next higher unit
942 :param add_direction: if this flag is set to `True` the return value will
943 include directional information. For instance a
944 positive timedelta will include the information about
945 it being in the future, a negative will be information
946 about the value being in the past.
947 :param format: the format, can be "narrow", "short" or "long". (
948 "medium" is deprecated, currently converted to "long" to
949 maintain compatibility)
950 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
951 """
952 if format not in ('narrow', 'short', 'medium', 'long'):
953 raise TypeError('Format must be one of "narrow", "short" or "long"')
954 if format == 'medium':
955 warnings.warn(
956 '"medium" value for format param of format_timedelta'
957 ' is deprecated. Use "long" instead',
958 category=DeprecationWarning,
959 stacklevel=2,
960 )
961 format = 'long'
962 if isinstance(delta, datetime.timedelta):
963 seconds = int((delta.days * 86400) + delta.seconds)
964 else:
965 seconds = delta
966 locale = Locale.parse(locale or LC_TIME)
967 date_fields = locale._data["date_fields"]
968 unit_patterns = locale._data["unit_patterns"]
970 def _iter_patterns(a_unit):
971 if add_direction:
972 # Try to find the length variant version first ("year-narrow")
973 # before falling back to the default.
974 unit_rel_patterns = (date_fields.get(f"{a_unit}-{format}") or date_fields[a_unit])
975 if seconds >= 0:
976 yield unit_rel_patterns['future']
977 else:
978 yield unit_rel_patterns['past']
979 a_unit = f"duration-{a_unit}"
980 unit_pats = unit_patterns.get(a_unit, {})
981 yield unit_pats.get(format)
982 # We do not support `<alias>` tags at all while ingesting CLDR data,
983 # so these aliases specified in `root.xml` are hard-coded here:
984 # <unitLength type="long"><alias source="locale" path="../unitLength[@type='short']"/></unitLength>
985 # <unitLength type="narrow"><alias source="locale" path="../unitLength[@type='short']"/></unitLength>
986 if format in ("long", "narrow"):
987 yield unit_pats.get("short")
989 for unit, secs_per_unit in TIMEDELTA_UNITS:
990 value = abs(seconds) / secs_per_unit
991 if value >= threshold or unit == granularity:
992 if unit == granularity and value > 0:
993 value = max(1, value)
994 value = int(round(value))
995 plural_form = locale.plural_form(value)
996 pattern = None
997 for patterns in _iter_patterns(unit):
998 if patterns is not None:
999 pattern = patterns.get(plural_form) or patterns.get('other')
1000 if pattern:
1001 break
1002 # This really should not happen
1003 if pattern is None:
1004 return ''
1005 return pattern.replace('{0}', str(value))
1007 return ''
1010def _format_fallback_interval(
1011 start: _Instant,
1012 end: _Instant,
1013 skeleton: str | None,
1014 tzinfo: datetime.tzinfo | None,
1015 locale: Locale,
1016) -> str:
1017 if skeleton in locale.datetime_skeletons: # Use the given skeleton
1018 format = lambda dt: format_skeleton(skeleton, dt, tzinfo, locale=locale)
1019 elif all((isinstance(d, datetime.date) and not isinstance(d, datetime.datetime)) for d in (start, end)): # Both are just dates
1020 format = lambda dt: format_date(dt, locale=locale)
1021 elif all((isinstance(d, datetime.time) and not isinstance(d, datetime.date)) for d in (start, end)): # Both are times
1022 format = lambda dt: format_time(dt, tzinfo=tzinfo, locale=locale)
1023 else:
1024 format = lambda dt: format_datetime(dt, tzinfo=tzinfo, locale=locale)
1026 formatted_start = format(start)
1027 formatted_end = format(end)
1029 if formatted_start == formatted_end:
1030 return format(start)
1032 return (
1033 locale.interval_formats.get(None, "{0}-{1}").
1034 replace("{0}", formatted_start).
1035 replace("{1}", formatted_end)
1036 )
1039def format_interval(
1040 start: _Instant,
1041 end: _Instant,
1042 skeleton: str | None = None,
1043 tzinfo: datetime.tzinfo | None = None,
1044 fuzzy: bool = True,
1045 locale: Locale | str | None = None,
1046) -> str:
1047 """
1048 Format an interval between two instants according to the locale's rules.
1050 >>> from datetime import date, time
1051 >>> format_interval(date(2016, 1, 15), date(2016, 1, 17), "yMd", locale="fi")
1052 u'15.\u201317.1.2016'
1054 >>> format_interval(time(12, 12), time(16, 16), "Hm", locale="en_GB")
1055 '12:12\u201316:16'
1057 >>> format_interval(time(5, 12), time(16, 16), "hm", locale="en_US")
1058 '5:12\u202fAM\u2009–\u20094:16\u202fPM'
1060 >>> format_interval(time(16, 18), time(16, 24), "Hm", locale="it")
1061 '16:18\u201316:24'
1063 If the start instant equals the end instant, the interval is formatted like the instant.
1065 >>> format_interval(time(16, 18), time(16, 18), "Hm", locale="it")
1066 '16:18'
1068 Unknown skeletons fall back to "default" formatting.
1070 >>> format_interval(date(2015, 1, 1), date(2017, 1, 1), "wzq", locale="ja")
1071 '2015/01/01\uff5e2017/01/01'
1073 >>> format_interval(time(16, 18), time(16, 24), "xxx", locale="ja")
1074 '16:18:00\uff5e16:24:00'
1076 >>> format_interval(date(2016, 1, 15), date(2016, 1, 17), "xxx", locale="de")
1077 '15.01.2016\u2009–\u200917.01.2016'
1079 :param start: First instant (datetime/date/time)
1080 :param end: Second instant (datetime/date/time)
1081 :param skeleton: The "skeleton format" to use for formatting.
1082 :param tzinfo: tzinfo to use (if none is already attached)
1083 :param fuzzy: If the skeleton is not found, allow choosing a skeleton that's
1084 close enough to it.
1085 :param locale: A locale object or identifier. Defaults to the system time locale.
1086 :return: Formatted interval
1087 """
1088 locale = Locale.parse(locale or LC_TIME)
1090 # NB: The quote comments below are from the algorithm description in
1091 # https://www.unicode.org/reports/tr35/tr35-dates.html#intervalFormats
1093 # > Look for the intervalFormatItem element that matches the "skeleton",
1094 # > starting in the current locale and then following the locale fallback
1095 # > chain up to, but not including root.
1097 interval_formats = locale.interval_formats
1099 if skeleton not in interval_formats or not skeleton:
1100 # > If no match was found from the previous step, check what the closest
1101 # > match is in the fallback locale chain, as in availableFormats. That
1102 # > is, this allows for adjusting the string value field's width,
1103 # > including adjusting between "MMM" and "MMMM", and using different
1104 # > variants of the same field, such as 'v' and 'z'.
1105 if skeleton and fuzzy:
1106 skeleton = match_skeleton(skeleton, interval_formats)
1107 else:
1108 skeleton = None
1109 if not skeleton: # Still no match whatsoever?
1110 # > Otherwise, format the start and end datetime using the fallback pattern.
1111 return _format_fallback_interval(start, end, skeleton, tzinfo, locale)
1113 skel_formats = interval_formats[skeleton]
1115 if start == end:
1116 return format_skeleton(skeleton, start, tzinfo, fuzzy=fuzzy, locale=locale)
1118 start = _ensure_datetime_tzinfo(_get_datetime(start), tzinfo=tzinfo)
1119 end = _ensure_datetime_tzinfo(_get_datetime(end), tzinfo=tzinfo)
1121 start_fmt = DateTimeFormat(start, locale=locale)
1122 end_fmt = DateTimeFormat(end, locale=locale)
1124 # > If a match is found from previous steps, compute the calendar field
1125 # > with the greatest difference between start and end datetime. If there
1126 # > is no difference among any of the fields in the pattern, format as a
1127 # > single date using availableFormats, and return.
1129 for field in PATTERN_CHAR_ORDER: # These are in largest-to-smallest order
1130 if field in skel_formats and start_fmt.extract(field) != end_fmt.extract(field):
1131 # > If there is a match, use the pieces of the corresponding pattern to
1132 # > format the start and end datetime, as above.
1133 return "".join(
1134 parse_pattern(pattern).apply(instant, locale)
1135 for pattern, instant
1136 in zip(skel_formats[field], (start, end))
1137 )
1139 # > Otherwise, format the start and end datetime using the fallback pattern.
1141 return _format_fallback_interval(start, end, skeleton, tzinfo, locale)
1144def get_period_id(
1145 time: _Instant,
1146 tzinfo: datetime.tzinfo | None = None,
1147 type: Literal['selection'] | None = None,
1148 locale: Locale | str | None = None,
1149) -> str:
1150 """
1151 Get the day period ID for a given time.
1153 This ID can be used as a key for the period name dictionary.
1155 >>> from datetime import time
1156 >>> get_period_names(locale="de")[get_period_id(time(7, 42), locale="de")]
1157 u'Morgen'
1159 >>> get_period_id(time(0), locale="en_US")
1160 u'midnight'
1162 >>> get_period_id(time(0), type="selection", locale="en_US")
1163 u'night1'
1165 :param time: The time to inspect.
1166 :param tzinfo: The timezone for the time. See ``format_time``.
1167 :param type: The period type to use. Either "selection" or None.
1168 The selection type is used for selecting among phrases such as
1169 “Your email arrived yesterday evening” or “Your email arrived last night”.
1170 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
1171 :return: period ID. Something is always returned -- even if it's just "am" or "pm".
1172 """
1173 time = _get_time(time, tzinfo)
1174 seconds_past_midnight = int(time.hour * 60 * 60 + time.minute * 60 + time.second)
1175 locale = Locale.parse(locale or LC_TIME)
1177 # The LDML rules state that the rules may not overlap, so iterating in arbitrary
1178 # order should be alright, though `at` periods should be preferred.
1179 rulesets = locale.day_period_rules.get(type, {}).items()
1181 for rule_id, rules in rulesets:
1182 for rule in rules:
1183 if "at" in rule and rule["at"] == seconds_past_midnight:
1184 return rule_id
1186 for rule_id, rules in rulesets:
1187 for rule in rules:
1188 if "from" in rule and "before" in rule:
1189 if rule["from"] < rule["before"]:
1190 if rule["from"] <= seconds_past_midnight < rule["before"]:
1191 return rule_id
1192 else:
1193 # e.g. from="21:00" before="06:00"
1194 if rule["from"] <= seconds_past_midnight < 86400 or \
1195 0 <= seconds_past_midnight < rule["before"]:
1196 return rule_id
1198 start_ok = end_ok = False
1200 if "from" in rule and seconds_past_midnight >= rule["from"]:
1201 start_ok = True
1202 if "to" in rule and seconds_past_midnight <= rule["to"]:
1203 # This rule type does not exist in the present CLDR data;
1204 # excuse the lack of test coverage.
1205 end_ok = True
1206 if "before" in rule and seconds_past_midnight < rule["before"]:
1207 end_ok = True
1208 if "after" in rule:
1209 raise NotImplementedError("'after' is deprecated as of CLDR 29.")
1211 if start_ok and end_ok:
1212 return rule_id
1214 if seconds_past_midnight < 43200:
1215 return "am"
1216 else:
1217 return "pm"
1220class ParseError(ValueError):
1221 pass
1224def parse_date(
1225 string: str,
1226 locale: Locale | str | None = None,
1227 format: _PredefinedTimeFormat | str = 'medium',
1228) -> datetime.date:
1229 """Parse a date from a string.
1231 If an explicit format is provided, it is used to parse the date.
1233 >>> parse_date('01.04.2004', format='dd.MM.yyyy')
1234 datetime.date(2004, 4, 1)
1236 If no format is given, or if it is one of "full", "long", "medium",
1237 or "short", the function first tries to interpret the string as
1238 ISO-8601 date format and then uses the date format for the locale
1239 as a hint to determine the order in which the date fields appear in
1240 the string.
1242 >>> parse_date('4/1/04', locale='en_US')
1243 datetime.date(2004, 4, 1)
1244 >>> parse_date('01.04.2004', locale='de_DE')
1245 datetime.date(2004, 4, 1)
1246 >>> parse_date('2004-04-01', locale='en_US')
1247 datetime.date(2004, 4, 1)
1248 >>> parse_date('2004-04-01', locale='de_DE')
1249 datetime.date(2004, 4, 1)
1250 >>> parse_date('01.04.04', locale='de_DE', format='short')
1251 datetime.date(2004, 4, 1)
1253 :param string: the string containing the date
1254 :param locale: a `Locale` object or a locale identifier
1255 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
1256 :param format: the format to use, either an explicit date format,
1257 or one of "full", "long", "medium", or "short"
1258 (see ``get_time_format``)
1259 """
1260 numbers = re.findall(r'(\d+)', string)
1261 if not numbers:
1262 raise ParseError("No numbers were found in input")
1264 use_predefined_format = format in ('full', 'long', 'medium', 'short')
1265 # we try ISO-8601 format first, meaning similar to formats
1266 # extended YYYY-MM-DD or basic YYYYMMDD
1267 iso_alike = re.match(r'^(\d{4})-?([01]\d)-?([0-3]\d)$',
1268 string, flags=re.ASCII) # allow only ASCII digits
1269 if iso_alike and use_predefined_format:
1270 try:
1271 return datetime.date(*map(int, iso_alike.groups()))
1272 except ValueError:
1273 pass # a locale format might fit better, so let's continue
1275 if use_predefined_format:
1276 fmt = get_date_format(format=format, locale=locale)
1277 else:
1278 fmt = parse_pattern(format)
1279 format_str = fmt.pattern.lower()
1280 year_idx = format_str.index('y')
1281 month_idx = format_str.find('m')
1282 if month_idx < 0:
1283 month_idx = format_str.index('l')
1284 day_idx = format_str.index('d')
1286 indexes = sorted([(year_idx, 'Y'), (month_idx, 'M'), (day_idx, 'D')])
1287 indexes = {item[1]: idx for idx, item in enumerate(indexes)}
1289 # FIXME: this currently only supports numbers, but should also support month
1290 # names, both in the requested locale, and english
1292 year = numbers[indexes['Y']]
1293 year = 2000 + int(year) if len(year) == 2 else int(year)
1294 month = int(numbers[indexes['M']])
1295 day = int(numbers[indexes['D']])
1296 if month > 12:
1297 month, day = day, month
1298 return datetime.date(year, month, day)
1301def parse_time(
1302 string: str,
1303 locale: Locale | str | None = None,
1304 format: _PredefinedTimeFormat | str = 'medium',
1305) -> datetime.time:
1306 """Parse a time from a string.
1308 This function uses the time format for the locale as a hint to determine
1309 the order in which the time fields appear in the string.
1311 If an explicit format is provided, the function will use it to parse
1312 the time instead.
1314 >>> parse_time('15:30:00', locale='en_US')
1315 datetime.time(15, 30)
1316 >>> parse_time('15:30:00', format='H:mm:ss')
1317 datetime.time(15, 30)
1319 :param string: the string containing the time
1320 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
1321 :param format: the format to use, either an explicit time format,
1322 or one of "full", "long", "medium", or "short"
1323 (see ``get_time_format``)
1324 :return: the parsed time
1325 :rtype: `time`
1326 """
1327 numbers = re.findall(r'(\d+)', string)
1328 if not numbers:
1329 raise ParseError("No numbers were found in input")
1331 # TODO: try ISO format first?
1332 if format in ('full', 'long', 'medium', 'short'):
1333 fmt = get_time_format(format=format, locale=locale)
1334 else:
1335 fmt = parse_pattern(format)
1336 format_str = fmt.pattern.lower()
1337 hour_idx = format_str.find('h')
1338 if hour_idx < 0:
1339 hour_idx = format_str.index('k')
1340 min_idx = format_str.index('m')
1341 # format might not contain seconds
1342 if (sec_idx := format_str.find('s')) < 0:
1343 sec_idx = math.inf
1345 indexes = sorted([(hour_idx, 'H'), (min_idx, 'M'), (sec_idx, 'S')])
1346 indexes = {item[1]: idx for idx, item in enumerate(indexes)}
1348 # TODO: support time zones
1350 # Check if the format specifies a period to be used;
1351 # if it does, look for 'pm' to figure out an offset.
1352 hour_offset = 0
1353 if 'a' in format_str and 'pm' in string.lower():
1354 hour_offset = 12
1356 # Parse up to three numbers from the string.
1357 minute = second = 0
1358 hour = int(numbers[indexes['H']]) + hour_offset
1359 if len(numbers) > 1:
1360 minute = int(numbers[indexes['M']])
1361 if len(numbers) > 2:
1362 second = int(numbers[indexes['S']])
1363 return datetime.time(hour, minute, second)
1366class DateTimePattern:
1368 def __init__(self, pattern: str, format: DateTimeFormat):
1369 self.pattern = pattern
1370 self.format = format
1372 def __repr__(self) -> str:
1373 return f"<{type(self).__name__} {self.pattern!r}>"
1375 def __str__(self) -> str:
1376 pat = self.pattern
1377 return pat
1379 def __mod__(self, other: DateTimeFormat) -> str:
1380 if not isinstance(other, DateTimeFormat):
1381 return NotImplemented
1382 return self.format % other
1384 def apply(
1385 self,
1386 datetime: datetime.date | datetime.time,
1387 locale: Locale | str | None,
1388 reference_date: datetime.date | None = None,
1389 ) -> str:
1390 return self % DateTimeFormat(datetime, locale, reference_date)
1393class DateTimeFormat:
1395 def __init__(
1396 self,
1397 value: datetime.date | datetime.time,
1398 locale: Locale | str,
1399 reference_date: datetime.date | None = None,
1400 ) -> None:
1401 assert isinstance(value, (datetime.date, datetime.datetime, datetime.time))
1402 if isinstance(value, (datetime.datetime, datetime.time)) and value.tzinfo is None:
1403 value = value.replace(tzinfo=UTC)
1404 self.value = value
1405 self.locale = Locale.parse(locale)
1406 self.reference_date = reference_date
1408 def __getitem__(self, name: str) -> str:
1409 char = name[0]
1410 num = len(name)
1411 if char == 'G':
1412 return self.format_era(char, num)
1413 elif char in ('y', 'Y', 'u'):
1414 return self.format_year(char, num)
1415 elif char in ('Q', 'q'):
1416 return self.format_quarter(char, num)
1417 elif char in ('M', 'L'):
1418 return self.format_month(char, num)
1419 elif char in ('w', 'W'):
1420 return self.format_week(char, num)
1421 elif char == 'd':
1422 return self.format(self.value.day, num)
1423 elif char == 'D':
1424 return self.format_day_of_year(num)
1425 elif char == 'F':
1426 return self.format_day_of_week_in_month()
1427 elif char in ('E', 'e', 'c'):
1428 return self.format_weekday(char, num)
1429 elif char in ('a', 'b', 'B'):
1430 return self.format_period(char, num)
1431 elif char == 'h':
1432 if self.value.hour % 12 == 0:
1433 return self.format(12, num)
1434 else:
1435 return self.format(self.value.hour % 12, num)
1436 elif char == 'H':
1437 return self.format(self.value.hour, num)
1438 elif char == 'K':
1439 return self.format(self.value.hour % 12, num)
1440 elif char == 'k':
1441 if self.value.hour == 0:
1442 return self.format(24, num)
1443 else:
1444 return self.format(self.value.hour, num)
1445 elif char == 'm':
1446 return self.format(self.value.minute, num)
1447 elif char == 's':
1448 return self.format(self.value.second, num)
1449 elif char == 'S':
1450 return self.format_frac_seconds(num)
1451 elif char == 'A':
1452 return self.format_milliseconds_in_day(num)
1453 elif char in ('z', 'Z', 'v', 'V', 'x', 'X', 'O'):
1454 return self.format_timezone(char, num)
1455 else:
1456 raise KeyError(f"Unsupported date/time field {char!r}")
1458 def extract(self, char: str) -> int:
1459 char = str(char)[0]
1460 if char == 'y':
1461 return self.value.year
1462 elif char == 'M':
1463 return self.value.month
1464 elif char == 'd':
1465 return self.value.day
1466 elif char == 'H':
1467 return self.value.hour
1468 elif char == 'h':
1469 return self.value.hour % 12 or 12
1470 elif char == 'm':
1471 return self.value.minute
1472 elif char == 'a':
1473 return int(self.value.hour >= 12) # 0 for am, 1 for pm
1474 else:
1475 raise NotImplementedError(f"Not implemented: extracting {char!r} from {self.value!r}")
1477 def format_era(self, char: str, num: int) -> str:
1478 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[max(3, num)]
1479 era = int(self.value.year >= 0)
1480 return get_era_names(width, self.locale)[era]
1482 def format_year(self, char: str, num: int) -> str:
1483 value = self.value.year
1484 if char.isupper():
1485 month = self.value.month
1486 if month == 1 and self.value.day < 7 and self.get_week_of_year() >= 52:
1487 value -= 1
1488 elif month == 12 and self.value.day > 25 and self.get_week_of_year() <= 2:
1489 value += 1
1490 year = self.format(value, num)
1491 if num == 2:
1492 year = year[-2:]
1493 return year
1495 def format_quarter(self, char: str, num: int) -> str:
1496 quarter = (self.value.month - 1) // 3 + 1
1497 if num <= 2:
1498 return '%0*d' % (num, quarter)
1499 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[num]
1500 context = {'Q': 'format', 'q': 'stand-alone'}[char]
1501 return get_quarter_names(width, context, self.locale)[quarter]
1503 def format_month(self, char: str, num: int) -> str:
1504 if num <= 2:
1505 return '%0*d' % (num, self.value.month)
1506 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[num]
1507 context = {'M': 'format', 'L': 'stand-alone'}[char]
1508 return get_month_names(width, context, self.locale)[self.value.month]
1510 def format_week(self, char: str, num: int) -> str:
1511 if char.islower(): # week of year
1512 week = self.get_week_of_year()
1513 return self.format(week, num)
1514 else: # week of month
1515 week = self.get_week_of_month()
1516 return str(week)
1518 def format_weekday(self, char: str = 'E', num: int = 4) -> str:
1519 """
1520 Return weekday from parsed datetime according to format pattern.
1522 >>> from datetime import date
1523 >>> format = DateTimeFormat(date(2016, 2, 28), Locale.parse('en_US'))
1524 >>> format.format_weekday()
1525 u'Sunday'
1527 'E': Day of week - Use one through three letters for the abbreviated day name, four for the full (wide) name,
1528 five for the narrow name, or six for the short name.
1529 >>> format.format_weekday('E',2)
1530 u'Sun'
1532 'e': Local day of week. Same as E except adds a numeric value that will depend on the local starting day of the
1533 week, using one or two letters. For this example, Monday is the first day of the week.
1534 >>> format.format_weekday('e',2)
1535 '01'
1537 'c': Stand-Alone local day of week - Use one letter for the local numeric value (same as 'e'), three for the
1538 abbreviated day name, four for the full (wide) name, five for the narrow name, or six for the short name.
1539 >>> format.format_weekday('c',1)
1540 '1'
1542 :param char: pattern format character ('e','E','c')
1543 :param num: count of format character
1545 """
1546 if num < 3:
1547 if char.islower():
1548 value = 7 - self.locale.first_week_day + self.value.weekday()
1549 return self.format(value % 7 + 1, num)
1550 num = 3
1551 weekday = self.value.weekday()
1552 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow', 6: 'short'}[num]
1553 context = "stand-alone" if char == "c" else "format"
1554 return get_day_names(width, context, self.locale)[weekday]
1556 def format_day_of_year(self, num: int) -> str:
1557 return self.format(self.get_day_of_year(), num)
1559 def format_day_of_week_in_month(self) -> str:
1560 return str((self.value.day - 1) // 7 + 1)
1562 def format_period(self, char: str, num: int) -> str:
1563 """
1564 Return period from parsed datetime according to format pattern.
1566 >>> from datetime import datetime, time
1567 >>> format = DateTimeFormat(time(13, 42), 'fi_FI')
1568 >>> format.format_period('a', 1)
1569 u'ip.'
1570 >>> format.format_period('b', 1)
1571 u'iltap.'
1572 >>> format.format_period('b', 4)
1573 u'iltapäivä'
1574 >>> format.format_period('B', 4)
1575 u'iltapäivällä'
1576 >>> format.format_period('B', 5)
1577 u'ip.'
1579 >>> format = DateTimeFormat(datetime(2022, 4, 28, 6, 27), 'zh_Hant')
1580 >>> format.format_period('a', 1)
1581 u'上午'
1582 >>> format.format_period('B', 1)
1583 u'清晨'
1585 :param char: pattern format character ('a', 'b', 'B')
1586 :param num: count of format character
1588 """
1589 widths = [{3: 'abbreviated', 4: 'wide', 5: 'narrow'}[max(3, num)],
1590 'wide', 'narrow', 'abbreviated']
1591 if char == 'a':
1592 period = 'pm' if self.value.hour >= 12 else 'am'
1593 context = 'format'
1594 else:
1595 period = get_period_id(self.value, locale=self.locale)
1596 context = 'format' if char == 'B' else 'stand-alone'
1597 for width in widths:
1598 period_names = get_period_names(context=context, width=width, locale=self.locale)
1599 if period in period_names:
1600 return period_names[period]
1601 raise ValueError(f"Could not format period {period} in {self.locale}")
1603 def format_frac_seconds(self, num: int) -> str:
1604 """ Return fractional seconds.
1606 Rounds the time's microseconds to the precision given by the number \
1607 of digits passed in.
1608 """
1609 value = self.value.microsecond / 1000000
1610 return self.format(round(value, num) * 10**num, num)
1612 def format_milliseconds_in_day(self, num):
1613 msecs = self.value.microsecond // 1000 + self.value.second * 1000 + \
1614 self.value.minute * 60000 + self.value.hour * 3600000
1615 return self.format(msecs, num)
1617 def format_timezone(self, char: str, num: int) -> str:
1618 width = {3: 'short', 4: 'long', 5: 'iso8601'}[max(3, num)]
1620 # It could be that we only receive a time to format, but also have a
1621 # reference date which is important to distinguish between timezone
1622 # variants (summer/standard time)
1623 value = self.value
1624 if self.reference_date:
1625 value = datetime.datetime.combine(self.reference_date, self.value)
1627 if char == 'z':
1628 return get_timezone_name(value, width, locale=self.locale)
1629 elif char == 'Z':
1630 if num == 5:
1631 return get_timezone_gmt(value, width, locale=self.locale, return_z=True)
1632 return get_timezone_gmt(value, width, locale=self.locale)
1633 elif char == 'O':
1634 if num == 4:
1635 return get_timezone_gmt(value, width, locale=self.locale)
1636 # TODO: To add support for O:1
1637 elif char == 'v':
1638 return get_timezone_name(value.tzinfo, width,
1639 locale=self.locale)
1640 elif char == 'V':
1641 if num == 1:
1642 return get_timezone_name(value.tzinfo, width,
1643 uncommon=True, locale=self.locale)
1644 elif num == 2:
1645 return get_timezone_name(value.tzinfo, locale=self.locale, return_zone=True)
1646 elif num == 3:
1647 return get_timezone_location(value.tzinfo, locale=self.locale, return_city=True)
1648 return get_timezone_location(value.tzinfo, locale=self.locale)
1649 # Included additional elif condition to add support for 'Xx' in timezone format
1650 elif char == 'X':
1651 if num == 1:
1652 return get_timezone_gmt(value, width='iso8601_short', locale=self.locale,
1653 return_z=True)
1654 elif num in (2, 4):
1655 return get_timezone_gmt(value, width='short', locale=self.locale,
1656 return_z=True)
1657 elif num in (3, 5):
1658 return get_timezone_gmt(value, width='iso8601', locale=self.locale,
1659 return_z=True)
1660 elif char == 'x':
1661 if num == 1:
1662 return get_timezone_gmt(value, width='iso8601_short', locale=self.locale)
1663 elif num in (2, 4):
1664 return get_timezone_gmt(value, width='short', locale=self.locale)
1665 elif num in (3, 5):
1666 return get_timezone_gmt(value, width='iso8601', locale=self.locale)
1668 def format(self, value: SupportsInt, length: int) -> str:
1669 return '%0*d' % (length, value)
1671 def get_day_of_year(self, date: datetime.date | None = None) -> int:
1672 if date is None:
1673 date = self.value
1674 return (date - date.replace(month=1, day=1)).days + 1
1676 def get_week_of_year(self) -> int:
1677 """Return the week of the year."""
1678 day_of_year = self.get_day_of_year(self.value)
1679 week = self.get_week_number(day_of_year)
1680 if week == 0:
1681 date = datetime.date(self.value.year - 1, 12, 31)
1682 week = self.get_week_number(self.get_day_of_year(date),
1683 date.weekday())
1684 elif week > 52:
1685 weekday = datetime.date(self.value.year + 1, 1, 1).weekday()
1686 if self.get_week_number(1, weekday) == 1 and \
1687 32 - (weekday - self.locale.first_week_day) % 7 <= self.value.day:
1688 week = 1
1689 return week
1691 def get_week_of_month(self) -> int:
1692 """Return the week of the month."""
1693 return self.get_week_number(self.value.day)
1695 def get_week_number(self, day_of_period: int, day_of_week: int | None = None) -> int:
1696 """Return the number of the week of a day within a period. This may be
1697 the week number in a year or the week number in a month.
1699 Usually this will return a value equal to or greater than 1, but if the
1700 first week of the period is so short that it actually counts as the last
1701 week of the previous period, this function will return 0.
1703 >>> date = datetime.date(2006, 1, 8)
1704 >>> DateTimeFormat(date, 'de_DE').get_week_number(6)
1705 1
1706 >>> DateTimeFormat(date, 'en_US').get_week_number(6)
1707 2
1709 :param day_of_period: the number of the day in the period (usually
1710 either the day of month or the day of year)
1711 :param day_of_week: the week day; if omitted, the week day of the
1712 current date is assumed
1713 """
1714 if day_of_week is None:
1715 day_of_week = self.value.weekday()
1716 first_day = (day_of_week - self.locale.first_week_day -
1717 day_of_period + 1) % 7
1718 if first_day < 0:
1719 first_day += 7
1720 week_number = (day_of_period + first_day - 1) // 7
1721 if 7 - first_day >= self.locale.min_week_days:
1722 week_number += 1
1723 return week_number
1726PATTERN_CHARS: dict[str, list[int] | None] = {
1727 'G': [1, 2, 3, 4, 5], # era
1728 'y': None, 'Y': None, 'u': None, # year
1729 'Q': [1, 2, 3, 4, 5], 'q': [1, 2, 3, 4, 5], # quarter
1730 'M': [1, 2, 3, 4, 5], 'L': [1, 2, 3, 4, 5], # month
1731 'w': [1, 2], 'W': [1], # week
1732 'd': [1, 2], 'D': [1, 2, 3], 'F': [1], 'g': None, # day
1733 'E': [1, 2, 3, 4, 5, 6], 'e': [1, 2, 3, 4, 5, 6], 'c': [1, 3, 4, 5, 6], # week day
1734 'a': [1, 2, 3, 4, 5], 'b': [1, 2, 3, 4, 5], 'B': [1, 2, 3, 4, 5], # period
1735 'h': [1, 2], 'H': [1, 2], 'K': [1, 2], 'k': [1, 2], # hour
1736 'm': [1, 2], # minute
1737 's': [1, 2], 'S': None, 'A': None, # second
1738 'z': [1, 2, 3, 4], 'Z': [1, 2, 3, 4, 5], 'O': [1, 4], 'v': [1, 4], # zone
1739 'V': [1, 2, 3, 4], 'x': [1, 2, 3, 4, 5], 'X': [1, 2, 3, 4, 5], # zone
1740}
1742#: The pattern characters declared in the Date Field Symbol Table
1743#: (https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table)
1744#: in order of decreasing magnitude.
1745PATTERN_CHAR_ORDER = "GyYuUQqMLlwWdDFgEecabBChHKkjJmsSAzZOvVXx"
1748def parse_pattern(pattern: str | DateTimePattern) -> DateTimePattern:
1749 """Parse date, time, and datetime format patterns.
1751 >>> parse_pattern("MMMMd").format
1752 u'%(MMMM)s%(d)s'
1753 >>> parse_pattern("MMM d, yyyy").format
1754 u'%(MMM)s %(d)s, %(yyyy)s'
1756 Pattern can contain literal strings in single quotes:
1758 >>> parse_pattern("H:mm' Uhr 'z").format
1759 u'%(H)s:%(mm)s Uhr %(z)s'
1761 An actual single quote can be used by using two adjacent single quote
1762 characters:
1764 >>> parse_pattern("hh' o''clock'").format
1765 u"%(hh)s o'clock"
1767 :param pattern: the formatting pattern to parse
1768 """
1769 if isinstance(pattern, DateTimePattern):
1770 return pattern
1771 return _cached_parse_pattern(pattern)
1774@lru_cache(maxsize=1024)
1775def _cached_parse_pattern(pattern: str) -> DateTimePattern:
1776 result = []
1778 for tok_type, tok_value in tokenize_pattern(pattern):
1779 if tok_type == "chars":
1780 result.append(tok_value.replace('%', '%%'))
1781 elif tok_type == "field":
1782 fieldchar, fieldnum = tok_value
1783 limit = PATTERN_CHARS[fieldchar]
1784 if limit and fieldnum not in limit:
1785 raise ValueError(f"Invalid length for field: {fieldchar * fieldnum!r}")
1786 result.append('%%(%s)s' % (fieldchar * fieldnum))
1787 else:
1788 raise NotImplementedError(f"Unknown token type: {tok_type}")
1789 return DateTimePattern(pattern, ''.join(result))
1792def tokenize_pattern(pattern: str) -> list[tuple[str, str | tuple[str, int]]]:
1793 """
1794 Tokenize date format patterns.
1796 Returns a list of (token_type, token_value) tuples.
1798 ``token_type`` may be either "chars" or "field".
1800 For "chars" tokens, the value is the literal value.
1802 For "field" tokens, the value is a tuple of (field character, repetition count).
1804 :param pattern: Pattern string
1805 :type pattern: str
1806 :rtype: list[tuple]
1807 """
1808 result = []
1809 quotebuf = None
1810 charbuf = []
1811 fieldchar = ['']
1812 fieldnum = [0]
1814 def append_chars():
1815 result.append(('chars', ''.join(charbuf).replace('\0', "'")))
1816 del charbuf[:]
1818 def append_field():
1819 result.append(('field', (fieldchar[0], fieldnum[0])))
1820 fieldchar[0] = ''
1821 fieldnum[0] = 0
1823 for char in pattern.replace("''", '\0'):
1824 if quotebuf is None:
1825 if char == "'": # quote started
1826 if fieldchar[0]:
1827 append_field()
1828 elif charbuf:
1829 append_chars()
1830 quotebuf = []
1831 elif char in PATTERN_CHARS:
1832 if charbuf:
1833 append_chars()
1834 if char == fieldchar[0]:
1835 fieldnum[0] += 1
1836 else:
1837 if fieldchar[0]:
1838 append_field()
1839 fieldchar[0] = char
1840 fieldnum[0] = 1
1841 else:
1842 if fieldchar[0]:
1843 append_field()
1844 charbuf.append(char)
1846 elif quotebuf is not None:
1847 if char == "'": # end of quote
1848 charbuf.extend(quotebuf)
1849 quotebuf = None
1850 else: # inside quote
1851 quotebuf.append(char)
1853 if fieldchar[0]:
1854 append_field()
1855 elif charbuf:
1856 append_chars()
1858 return result
1861def untokenize_pattern(tokens: Iterable[tuple[str, str | tuple[str, int]]]) -> str:
1862 """
1863 Turn a date format pattern token stream back into a string.
1865 This is the reverse operation of ``tokenize_pattern``.
1867 :type tokens: Iterable[tuple]
1868 :rtype: str
1869 """
1870 output = []
1871 for tok_type, tok_value in tokens:
1872 if tok_type == "field":
1873 output.append(tok_value[0] * tok_value[1])
1874 elif tok_type == "chars":
1875 if not any(ch in PATTERN_CHARS for ch in tok_value): # No need to quote
1876 output.append(tok_value)
1877 else:
1878 output.append("'%s'" % tok_value.replace("'", "''"))
1879 return "".join(output)
1882def split_interval_pattern(pattern: str) -> list[str]:
1883 """
1884 Split an interval-describing datetime pattern into multiple pieces.
1886 > The pattern is then designed to be broken up into two pieces by determining the first repeating field.
1887 - https://www.unicode.org/reports/tr35/tr35-dates.html#intervalFormats
1889 >>> split_interval_pattern(u'E d.M. \u2013 E d.M.')
1890 [u'E d.M. \u2013 ', 'E d.M.']
1891 >>> split_interval_pattern("Y 'text' Y 'more text'")
1892 ["Y 'text '", "Y 'more text'"]
1893 >>> split_interval_pattern(u"E, MMM d \u2013 E")
1894 [u'E, MMM d \u2013 ', u'E']
1895 >>> split_interval_pattern("MMM d")
1896 ['MMM d']
1897 >>> split_interval_pattern("y G")
1898 ['y G']
1899 >>> split_interval_pattern(u"MMM d \u2013 d")
1900 [u'MMM d \u2013 ', u'd']
1902 :param pattern: Interval pattern string
1903 :return: list of "subpatterns"
1904 """
1906 seen_fields = set()
1907 parts = [[]]
1909 for tok_type, tok_value in tokenize_pattern(pattern):
1910 if tok_type == "field":
1911 if tok_value[0] in seen_fields: # Repeated field
1912 parts.append([])
1913 seen_fields.clear()
1914 seen_fields.add(tok_value[0])
1915 parts[-1].append((tok_type, tok_value))
1917 return [untokenize_pattern(tokens) for tokens in parts]
1920def match_skeleton(skeleton: str, options: Iterable[str], allow_different_fields: bool = False) -> str | None:
1921 """
1922 Find the closest match for the given datetime skeleton among the options given.
1924 This uses the rules outlined in the TR35 document.
1926 >>> match_skeleton('yMMd', ('yMd', 'yMMMd'))
1927 'yMd'
1929 >>> match_skeleton('yMMd', ('jyMMd',), allow_different_fields=True)
1930 'jyMMd'
1932 >>> match_skeleton('yMMd', ('qyMMd',), allow_different_fields=False)
1934 >>> match_skeleton('hmz', ('hmv',))
1935 'hmv'
1937 :param skeleton: The skeleton to match
1938 :type skeleton: str
1939 :param options: An iterable of other skeletons to match against
1940 :type options: Iterable[str]
1941 :param allow_different_fields: Whether to allow a match that uses different fields
1942 than the skeleton requested.
1943 :type allow_different_fields: bool
1945 :return: The closest skeleton match, or if no match was found, None.
1946 :rtype: str|None
1947 """
1949 # TODO: maybe implement pattern expansion?
1951 # Based on the implementation in
1952 # https://github.com/unicode-org/icu/blob/main/icu4j/main/core/src/main/java/com/ibm/icu/text/DateIntervalInfo.java
1954 # Filter out falsy values and sort for stability; when `interval_formats` is passed in, there may be a None key.
1955 options = sorted(option for option in options if option)
1957 if 'z' in skeleton and not any('z' in option for option in options):
1958 skeleton = skeleton.replace('z', 'v')
1959 if 'k' in skeleton and not any('k' in option for option in options):
1960 skeleton = skeleton.replace('k', 'H')
1961 if 'K' in skeleton and not any('K' in option for option in options):
1962 skeleton = skeleton.replace('K', 'h')
1963 if 'a' in skeleton and not any('a' in option for option in options):
1964 skeleton = skeleton.replace('a', '')
1965 if 'b' in skeleton and not any('b' in option for option in options):
1966 skeleton = skeleton.replace('b', '')
1968 get_input_field_width = dict(t[1] for t in tokenize_pattern(skeleton) if t[0] == "field").get
1969 best_skeleton = None
1970 best_distance = None
1971 for option in options:
1972 get_opt_field_width = dict(t[1] for t in tokenize_pattern(option) if t[0] == "field").get
1973 distance = 0
1974 for field in PATTERN_CHARS:
1975 input_width = get_input_field_width(field, 0)
1976 opt_width = get_opt_field_width(field, 0)
1977 if input_width == opt_width:
1978 continue
1979 if opt_width == 0 or input_width == 0:
1980 if not allow_different_fields: # This one is not okay
1981 option = None
1982 break
1983 distance += 0x1000 # Magic weight constant for "entirely different fields"
1984 elif field == 'M' and ((input_width > 2 and opt_width <= 2) or (input_width <= 2 and opt_width > 2)):
1985 distance += 0x100 # Magic weight for "text turns into a number"
1986 else:
1987 distance += abs(input_width - opt_width)
1989 if not option: # We lost the option along the way (probably due to "allow_different_fields")
1990 continue
1992 if not best_skeleton or distance < best_distance:
1993 best_skeleton = option
1994 best_distance = distance
1996 if distance == 0: # Found a perfect match!
1997 break
1999 return best_skeleton