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 '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 'Tuesday'
281 >>> get_day_names('short', locale='en_US')[1]
282 'Tu'
283 >>> get_day_names('abbreviated', locale='es')[1]
284 'mar'
285 >>> get_day_names('narrow', context='stand-alone', locale='de_DE')[1]
286 '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 'January'
304 >>> get_month_names('abbreviated', locale='es')[1]
305 'ene'
306 >>> get_month_names('narrow', context='stand-alone', locale='de_DE')[1]
307 '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 '1st quarter'
325 >>> get_quarter_names('abbreviated', locale='de_DE')[1]
326 'Q1'
327 >>> get_quarter_names('narrow', locale='de_DE')[1]
328 '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 'Anno Domini'
345 >>> get_era_names('abbreviated', locale='de_DE')[1]
346 '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 'MMM d, y'>
363 >>> get_date_format('full', locale='de_DE')
364 <DateTimePattern '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 '{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 'h:mm:ss\\u202fa'>
402 >>> get_time_format('full', locale='de_DE')
403 <DateTimePattern '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 '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 '+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 'GMT-07:00'
433 >>> get_timezone_gmt(dt, 'short', locale='en')
434 '-0700'
435 >>> get_timezone_gmt(dt, locale='en', width='iso8601_short')
436 '-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 '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 '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 'Mitteleuropäische 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 '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 '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 'Pacific Time'
580 >>> get_timezone_name(tz, 'short', locale='en_US')
581 '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 'Mitteleuropäische Zeit'
590 >>> get_timezone_name(tz, locale='pt_BR')
591 'Horário 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 '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 value = info[width][zone_variant]
653 if value != NO_INHERITANCE_MARKER:
654 return value
656 metazone = get_global('meta_zones').get(zone)
657 if metazone:
658 metazone_info = locale.meta_zones.get(metazone, {})
659 if width in metazone_info:
660 name = metazone_info[width].get(zone_variant)
661 if width == 'short' and name == NO_INHERITANCE_MARKER:
662 # If the short form is marked no-inheritance,
663 # try to fall back to the long name instead.
664 name = metazone_info.get('long', {}).get(zone_variant)
665 if name and name != NO_INHERITANCE_MARKER:
666 return name
668 # If we have a concrete datetime, we assume that the result can't be
669 # independent of daylight savings time, so we return the GMT offset
670 if dt is not None:
671 return get_timezone_gmt(dt, width=width, locale=locale)
673 return get_timezone_location(dt_or_tzinfo, locale=locale)
676def format_date(
677 date: datetime.date | None = None,
678 format: _PredefinedTimeFormat | str = 'medium',
679 locale: Locale | str | None = None,
680) -> str:
681 """Return a date formatted according to the given pattern.
683 >>> from datetime import date
684 >>> d = date(2007, 4, 1)
685 >>> format_date(d, locale='en_US')
686 'Apr 1, 2007'
687 >>> format_date(d, format='full', locale='de_DE')
688 'Sonntag, 1. April 2007'
690 If you don't want to use the locale default formats, you can specify a
691 custom date pattern:
693 >>> format_date(d, "EEE, MMM d, ''yy", locale='en')
694 "Sun, Apr 1, '07"
696 :param date: the ``date`` or ``datetime`` object; if `None`, the current
697 date is used
698 :param format: one of "full", "long", "medium", or "short", or a custom
699 date/time pattern
700 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
701 """
702 if date is None:
703 date = datetime.date.today()
704 elif isinstance(date, datetime.datetime):
705 date = date.date()
707 locale = Locale.parse(locale or LC_TIME)
708 if format in ('full', 'long', 'medium', 'short'):
709 format = get_date_format(format, locale=locale)
710 pattern = parse_pattern(format)
711 return pattern.apply(date, locale)
714def format_datetime(
715 datetime: _Instant = None,
716 format: _PredefinedTimeFormat | str = 'medium',
717 tzinfo: datetime.tzinfo | None = None,
718 locale: Locale | str | None = None,
719) -> str:
720 r"""Return a date formatted according to the given pattern.
722 >>> from datetime import datetime
723 >>> dt = datetime(2007, 4, 1, 15, 30)
724 >>> format_datetime(dt, locale='en_US')
725 'Apr 1, 2007, 3:30:00\u202fPM'
727 For any pattern requiring the display of the timezone:
729 >>> format_datetime(dt, 'full', tzinfo=get_timezone('Europe/Paris'),
730 ... locale='fr_FR')
731 'dimanche 1 avril 2007, 17:30:00 heure d’été d’Europe centrale'
732 >>> format_datetime(dt, "yyyy.MM.dd G 'at' HH:mm:ss zzz",
733 ... tzinfo=get_timezone('US/Eastern'), locale='en')
734 '2007.04.01 AD at 11:30:00 EDT'
736 :param datetime: the `datetime` object; if `None`, the current date and
737 time is used
738 :param format: one of "full", "long", "medium", or "short", or a custom
739 date/time pattern
740 :param tzinfo: the timezone to apply to the time for display
741 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
742 """
743 datetime = _ensure_datetime_tzinfo(_get_datetime(datetime), tzinfo)
745 locale = Locale.parse(locale or LC_TIME)
746 if format in ('full', 'long', 'medium', 'short'):
747 return get_datetime_format(format, locale=locale) \
748 .replace("'", "") \
749 .replace('{0}', format_time(datetime, format, tzinfo=None,
750 locale=locale)) \
751 .replace('{1}', format_date(datetime, format, locale=locale))
752 else:
753 return parse_pattern(format).apply(datetime, locale)
756def format_time(
757 time: datetime.time | datetime.datetime | float | None = None,
758 format: _PredefinedTimeFormat | str = 'medium',
759 tzinfo: datetime.tzinfo | None = None,
760 locale: Locale | str | None = None,
761) -> str:
762 r"""Return a time formatted according to the given pattern.
764 >>> from datetime import datetime, time
765 >>> t = time(15, 30)
766 >>> format_time(t, locale='en_US')
767 '3:30:00\u202fPM'
768 >>> format_time(t, format='short', locale='de_DE')
769 '15:30'
771 If you don't want to use the locale default formats, you can specify a
772 custom time pattern:
774 >>> format_time(t, "hh 'o''clock' a", locale='en')
775 "03 o'clock PM"
777 For any pattern requiring the display of the time-zone a
778 timezone has to be specified explicitly:
780 >>> t = datetime(2007, 4, 1, 15, 30)
781 >>> tzinfo = get_timezone('Europe/Paris')
782 >>> t = _localize(tzinfo, t)
783 >>> format_time(t, format='full', tzinfo=tzinfo, locale='fr_FR')
784 '15:30:00 heure d’été d’Europe centrale'
785 >>> format_time(t, "hh 'o''clock' a, zzzz", tzinfo=get_timezone('US/Eastern'),
786 ... locale='en')
787 "09 o'clock AM, Eastern Daylight Time"
789 As that example shows, when this function gets passed a
790 ``datetime.datetime`` value, the actual time in the formatted string is
791 adjusted to the timezone specified by the `tzinfo` parameter. If the
792 ``datetime`` is "naive" (i.e. it has no associated timezone information),
793 it is assumed to be in UTC.
795 These timezone calculations are **not** performed if the value is of type
796 ``datetime.time``, as without date information there's no way to determine
797 what a given time would translate to in a different timezone without
798 information about whether daylight savings time is in effect or not. This
799 means that time values are left as-is, and the value of the `tzinfo`
800 parameter is only used to display the timezone name if needed:
802 >>> t = time(15, 30)
803 >>> format_time(t, format='full', tzinfo=get_timezone('Europe/Paris'),
804 ... locale='fr_FR') # doctest: +SKIP
805 '15:30:00 heure normale d\u2019Europe centrale'
806 >>> format_time(t, format='full', tzinfo=get_timezone('US/Eastern'),
807 ... locale='en_US') # doctest: +SKIP
808 '3:30:00\u202fPM Eastern Standard Time'
810 :param time: the ``time`` or ``datetime`` object; if `None`, the current
811 time in UTC is used
812 :param format: one of "full", "long", "medium", or "short", or a custom
813 date/time pattern
814 :param tzinfo: the time-zone to apply to the time for display
815 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
816 """
818 # get reference date for if we need to find the right timezone variant
819 # in the pattern
820 ref_date = time.date() if isinstance(time, datetime.datetime) else None
822 time = _get_time(time, tzinfo)
824 locale = Locale.parse(locale or LC_TIME)
825 if format in ('full', 'long', 'medium', 'short'):
826 format = get_time_format(format, locale=locale)
827 return parse_pattern(format).apply(time, locale, reference_date=ref_date)
830def format_skeleton(
831 skeleton: str,
832 datetime: _Instant = None,
833 tzinfo: datetime.tzinfo | None = None,
834 fuzzy: bool = True,
835 locale: Locale | str | None = None,
836) -> str:
837 r"""Return a time and/or date formatted according to the given pattern.
839 The skeletons are defined in the CLDR data and provide more flexibility
840 than the simple short/long/medium formats, but are a bit harder to use.
841 The are defined using the date/time symbols without order or punctuation
842 and map to a suitable format for the given locale.
844 >>> from datetime import datetime
845 >>> t = datetime(2007, 4, 1, 15, 30)
846 >>> format_skeleton('MMMEd', t, locale='fr')
847 'dim. 1 avr.'
848 >>> format_skeleton('MMMEd', t, locale='en')
849 'Sun, Apr 1'
850 >>> format_skeleton('yMMd', t, locale='fi') # yMMd is not in the Finnish locale; yMd gets used
851 '1.4.2007'
852 >>> format_skeleton('yMMd', t, fuzzy=False, locale='fi') # yMMd is not in the Finnish locale, an error is thrown
853 Traceback (most recent call last):
854 ...
855 KeyError: yMMd
856 >>> 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
857 Traceback (most recent call last):
858 ...
859 KeyError: None
861 After the skeleton is resolved to a pattern `format_datetime` is called so
862 all timezone processing etc is the same as for that.
864 :param skeleton: A date time skeleton as defined in the cldr data.
865 :param datetime: the ``time`` or ``datetime`` object; if `None`, the current
866 time in UTC is used
867 :param tzinfo: the time-zone to apply to the time for display
868 :param fuzzy: If the skeleton is not found, allow choosing a skeleton that's
869 close enough to it. If there is no close match, a `KeyError`
870 is thrown.
871 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
872 """
873 locale = Locale.parse(locale or LC_TIME)
874 if fuzzy and skeleton not in locale.datetime_skeletons:
875 skeleton = match_skeleton(skeleton, locale.datetime_skeletons)
876 format = locale.datetime_skeletons[skeleton]
877 return format_datetime(datetime, format, tzinfo, locale)
880TIMEDELTA_UNITS: tuple[tuple[str, int], ...] = (
881 ('year', 3600 * 24 * 365),
882 ('month', 3600 * 24 * 30),
883 ('week', 3600 * 24 * 7),
884 ('day', 3600 * 24),
885 ('hour', 3600),
886 ('minute', 60),
887 ('second', 1),
888)
891def format_timedelta(
892 delta: datetime.timedelta | int,
893 granularity: Literal['year', 'month', 'week', 'day', 'hour', 'minute', 'second'] = 'second',
894 threshold: float = .85,
895 add_direction: bool = False,
896 format: Literal['narrow', 'short', 'medium', 'long'] = 'long',
897 locale: Locale | str | None = None,
898) -> str:
899 """Return a time delta according to the rules of the given locale.
901 >>> from datetime import timedelta
902 >>> format_timedelta(timedelta(weeks=12), locale='en_US')
903 '3 months'
904 >>> format_timedelta(timedelta(seconds=1), locale='es')
905 '1 segundo'
907 The granularity parameter can be provided to alter the lowest unit
908 presented, which defaults to a second.
910 >>> format_timedelta(timedelta(hours=3), granularity='day', locale='en_US')
911 '1 day'
913 The threshold parameter can be used to determine at which value the
914 presentation switches to the next higher unit. A higher threshold factor
915 means the presentation will switch later. For example:
917 >>> format_timedelta(timedelta(hours=23), threshold=0.9, locale='en_US')
918 '1 day'
919 >>> format_timedelta(timedelta(hours=23), threshold=1.1, locale='en_US')
920 '23 hours'
922 In addition directional information can be provided that informs
923 the user if the date is in the past or in the future:
925 >>> format_timedelta(timedelta(hours=1), add_direction=True, locale='en')
926 'in 1 hour'
927 >>> format_timedelta(timedelta(hours=-1), add_direction=True, locale='en')
928 '1 hour ago'
930 The format parameter controls how compact or wide the presentation is:
932 >>> format_timedelta(timedelta(hours=3), format='short', locale='en')
933 '3 hr'
934 >>> format_timedelta(timedelta(hours=3), format='narrow', locale='en')
935 '3h'
937 :param delta: a ``timedelta`` object representing the time difference to
938 format, or the delta in seconds as an `int` value
939 :param granularity: determines the smallest unit that should be displayed,
940 the value can be one of "year", "month", "week", "day",
941 "hour", "minute" or "second"
942 :param threshold: factor that determines at which point the presentation
943 switches to the next higher unit
944 :param add_direction: if this flag is set to `True` the return value will
945 include directional information. For instance a
946 positive timedelta will include the information about
947 it being in the future, a negative will be information
948 about the value being in the past.
949 :param format: the format, can be "narrow", "short" or "long". (
950 "medium" is deprecated, currently converted to "long" to
951 maintain compatibility)
952 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
953 """
954 if format not in ('narrow', 'short', 'medium', 'long'):
955 raise TypeError('Format must be one of "narrow", "short" or "long"')
956 if format == 'medium':
957 warnings.warn(
958 '"medium" value for format param of format_timedelta'
959 ' is deprecated. Use "long" instead',
960 category=DeprecationWarning,
961 stacklevel=2,
962 )
963 format = 'long'
964 if isinstance(delta, datetime.timedelta):
965 seconds = int((delta.days * 86400) + delta.seconds)
966 else:
967 seconds = delta
968 locale = Locale.parse(locale or LC_TIME)
969 date_fields = locale._data["date_fields"]
970 unit_patterns = locale._data["unit_patterns"]
972 def _iter_patterns(a_unit):
973 if add_direction:
974 # Try to find the length variant version first ("year-narrow")
975 # before falling back to the default.
976 unit_rel_patterns = (date_fields.get(f"{a_unit}-{format}") or date_fields[a_unit])
977 if seconds >= 0:
978 yield unit_rel_patterns['future']
979 else:
980 yield unit_rel_patterns['past']
981 a_unit = f"duration-{a_unit}"
982 unit_pats = unit_patterns.get(a_unit, {})
983 yield unit_pats.get(format)
984 # We do not support `<alias>` tags at all while ingesting CLDR data,
985 # so these aliases specified in `root.xml` are hard-coded here:
986 # <unitLength type="long"><alias source="locale" path="../unitLength[@type='short']"/></unitLength>
987 # <unitLength type="narrow"><alias source="locale" path="../unitLength[@type='short']"/></unitLength>
988 if format in ("long", "narrow"):
989 yield unit_pats.get("short")
991 for unit, secs_per_unit in TIMEDELTA_UNITS:
992 value = abs(seconds) / secs_per_unit
993 if value >= threshold or unit == granularity:
994 if unit == granularity and value > 0:
995 value = max(1, value)
996 value = int(round(value))
997 plural_form = locale.plural_form(value)
998 pattern = None
999 for patterns in _iter_patterns(unit):
1000 if patterns is not None:
1001 pattern = patterns.get(plural_form) or patterns.get('other')
1002 if pattern:
1003 break
1004 # This really should not happen
1005 if pattern is None:
1006 return ''
1007 return pattern.replace('{0}', str(value))
1009 return ''
1012def _format_fallback_interval(
1013 start: _Instant,
1014 end: _Instant,
1015 skeleton: str | None,
1016 tzinfo: datetime.tzinfo | None,
1017 locale: Locale,
1018) -> str:
1019 if skeleton in locale.datetime_skeletons: # Use the given skeleton
1020 format = lambda dt: format_skeleton(skeleton, dt, tzinfo, locale=locale)
1021 elif all((isinstance(d, datetime.date) and not isinstance(d, datetime.datetime)) for d in (start, end)): # Both are just dates
1022 format = lambda dt: format_date(dt, locale=locale)
1023 elif all((isinstance(d, datetime.time) and not isinstance(d, datetime.date)) for d in (start, end)): # Both are times
1024 format = lambda dt: format_time(dt, tzinfo=tzinfo, locale=locale)
1025 else:
1026 format = lambda dt: format_datetime(dt, tzinfo=tzinfo, locale=locale)
1028 formatted_start = format(start)
1029 formatted_end = format(end)
1031 if formatted_start == formatted_end:
1032 return format(start)
1034 return (
1035 locale.interval_formats.get(None, "{0}-{1}").
1036 replace("{0}", formatted_start).
1037 replace("{1}", formatted_end)
1038 )
1041def format_interval(
1042 start: _Instant,
1043 end: _Instant,
1044 skeleton: str | None = None,
1045 tzinfo: datetime.tzinfo | None = None,
1046 fuzzy: bool = True,
1047 locale: Locale | str | None = None,
1048) -> str:
1049 """
1050 Format an interval between two instants according to the locale's rules.
1052 >>> from datetime import date, time
1053 >>> format_interval(date(2016, 1, 15), date(2016, 1, 17), "yMd", locale="fi")
1054 '15.–17.1.2016'
1056 >>> format_interval(time(12, 12), time(16, 16), "Hm", locale="en_GB")
1057 '12:12–16:16'
1059 >>> format_interval(time(5, 12), time(16, 16), "hm", locale="en_US")
1060 '5:12\\u202fAM\\u2009–\\u20094:16\\u202fPM'
1062 >>> format_interval(time(16, 18), time(16, 24), "Hm", locale="it")
1063 '16:18–16:24'
1065 If the start instant equals the end instant, the interval is formatted like the instant.
1067 >>> format_interval(time(16, 18), time(16, 18), "Hm", locale="it")
1068 '16:18'
1070 Unknown skeletons fall back to "default" formatting.
1072 >>> format_interval(date(2015, 1, 1), date(2017, 1, 1), "wzq", locale="ja")
1073 '2015/01/01~2017/01/01'
1075 >>> format_interval(time(16, 18), time(16, 24), "xxx", locale="ja")
1076 '16:18:00~16:24:00'
1078 >>> format_interval(date(2016, 1, 15), date(2016, 1, 17), "xxx", locale="de")
1079 '15.01.2016\\u2009–\\u200917.01.2016'
1081 :param start: First instant (datetime/date/time)
1082 :param end: Second instant (datetime/date/time)
1083 :param skeleton: The "skeleton format" to use for formatting.
1084 :param tzinfo: tzinfo to use (if none is already attached)
1085 :param fuzzy: If the skeleton is not found, allow choosing a skeleton that's
1086 close enough to it.
1087 :param locale: A locale object or identifier. Defaults to the system time locale.
1088 :return: Formatted interval
1089 """
1090 locale = Locale.parse(locale or LC_TIME)
1092 # NB: The quote comments below are from the algorithm description in
1093 # https://www.unicode.org/reports/tr35/tr35-dates.html#intervalFormats
1095 # > Look for the intervalFormatItem element that matches the "skeleton",
1096 # > starting in the current locale and then following the locale fallback
1097 # > chain up to, but not including root.
1099 interval_formats = locale.interval_formats
1101 if skeleton not in interval_formats or not skeleton:
1102 # > If no match was found from the previous step, check what the closest
1103 # > match is in the fallback locale chain, as in availableFormats. That
1104 # > is, this allows for adjusting the string value field's width,
1105 # > including adjusting between "MMM" and "MMMM", and using different
1106 # > variants of the same field, such as 'v' and 'z'.
1107 if skeleton and fuzzy:
1108 skeleton = match_skeleton(skeleton, interval_formats)
1109 else:
1110 skeleton = None
1111 if not skeleton: # Still no match whatsoever?
1112 # > Otherwise, format the start and end datetime using the fallback pattern.
1113 return _format_fallback_interval(start, end, skeleton, tzinfo, locale)
1115 skel_formats = interval_formats[skeleton]
1117 if start == end:
1118 return format_skeleton(skeleton, start, tzinfo, fuzzy=fuzzy, locale=locale)
1120 start = _ensure_datetime_tzinfo(_get_datetime(start), tzinfo=tzinfo)
1121 end = _ensure_datetime_tzinfo(_get_datetime(end), tzinfo=tzinfo)
1123 start_fmt = DateTimeFormat(start, locale=locale)
1124 end_fmt = DateTimeFormat(end, locale=locale)
1126 # > If a match is found from previous steps, compute the calendar field
1127 # > with the greatest difference between start and end datetime. If there
1128 # > is no difference among any of the fields in the pattern, format as a
1129 # > single date using availableFormats, and return.
1131 for field in PATTERN_CHAR_ORDER: # These are in largest-to-smallest order
1132 if field in skel_formats and start_fmt.extract(field) != end_fmt.extract(field):
1133 # > If there is a match, use the pieces of the corresponding pattern to
1134 # > format the start and end datetime, as above.
1135 return "".join(
1136 parse_pattern(pattern).apply(instant, locale)
1137 for pattern, instant
1138 in zip(skel_formats[field], (start, end))
1139 )
1141 # > Otherwise, format the start and end datetime using the fallback pattern.
1143 return _format_fallback_interval(start, end, skeleton, tzinfo, locale)
1146def get_period_id(
1147 time: _Instant,
1148 tzinfo: datetime.tzinfo | None = None,
1149 type: Literal['selection'] | None = None,
1150 locale: Locale | str | None = None,
1151) -> str:
1152 """
1153 Get the day period ID for a given time.
1155 This ID can be used as a key for the period name dictionary.
1157 >>> from datetime import time
1158 >>> get_period_names(locale="de")[get_period_id(time(7, 42), locale="de")]
1159 'Morgen'
1161 >>> get_period_id(time(0), locale="en_US")
1162 'midnight'
1164 >>> get_period_id(time(0), type="selection", locale="en_US")
1165 'morning1'
1167 :param time: The time to inspect.
1168 :param tzinfo: The timezone for the time. See ``format_time``.
1169 :param type: The period type to use. Either "selection" or None.
1170 The selection type is used for selecting among phrases such as
1171 “Your email arrived yesterday evening” or “Your email arrived last night”.
1172 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
1173 :return: period ID. Something is always returned -- even if it's just "am" or "pm".
1174 """
1175 time = _get_time(time, tzinfo)
1176 seconds_past_midnight = int(time.hour * 60 * 60 + time.minute * 60 + time.second)
1177 locale = Locale.parse(locale or LC_TIME)
1179 # The LDML rules state that the rules may not overlap, so iterating in arbitrary
1180 # order should be alright, though `at` periods should be preferred.
1181 rulesets = locale.day_period_rules.get(type, {}).items()
1183 for rule_id, rules in rulesets:
1184 for rule in rules:
1185 if "at" in rule and rule["at"] == seconds_past_midnight:
1186 return rule_id
1188 for rule_id, rules in rulesets:
1189 for rule in rules:
1190 if "from" in rule and "before" in rule:
1191 if rule["from"] < rule["before"]:
1192 if rule["from"] <= seconds_past_midnight < rule["before"]:
1193 return rule_id
1194 else:
1195 # e.g. from="21:00" before="06:00"
1196 if rule["from"] <= seconds_past_midnight < 86400 or \
1197 0 <= seconds_past_midnight < rule["before"]:
1198 return rule_id
1200 start_ok = end_ok = False
1202 if "from" in rule and seconds_past_midnight >= rule["from"]:
1203 start_ok = True
1204 if "to" in rule and seconds_past_midnight <= rule["to"]:
1205 # This rule type does not exist in the present CLDR data;
1206 # excuse the lack of test coverage.
1207 end_ok = True
1208 if "before" in rule and seconds_past_midnight < rule["before"]:
1209 end_ok = True
1210 if "after" in rule:
1211 raise NotImplementedError("'after' is deprecated as of CLDR 29.")
1213 if start_ok and end_ok:
1214 return rule_id
1216 if seconds_past_midnight < 43200:
1217 return "am"
1218 else:
1219 return "pm"
1222class ParseError(ValueError):
1223 pass
1226def parse_date(
1227 string: str,
1228 locale: Locale | str | None = None,
1229 format: _PredefinedTimeFormat | str = 'medium',
1230) -> datetime.date:
1231 """Parse a date from a string.
1233 If an explicit format is provided, it is used to parse the date.
1235 >>> parse_date('01.04.2004', format='dd.MM.yyyy')
1236 datetime.date(2004, 4, 1)
1238 If no format is given, or if it is one of "full", "long", "medium",
1239 or "short", the function first tries to interpret the string as
1240 ISO-8601 date format and then uses the date format for the locale
1241 as a hint to determine the order in which the date fields appear in
1242 the string.
1244 >>> parse_date('4/1/04', locale='en_US')
1245 datetime.date(2004, 4, 1)
1246 >>> parse_date('01.04.2004', locale='de_DE')
1247 datetime.date(2004, 4, 1)
1248 >>> parse_date('2004-04-01', locale='en_US')
1249 datetime.date(2004, 4, 1)
1250 >>> parse_date('2004-04-01', locale='de_DE')
1251 datetime.date(2004, 4, 1)
1252 >>> parse_date('01.04.04', locale='de_DE', format='short')
1253 datetime.date(2004, 4, 1)
1255 :param string: the string containing the date
1256 :param locale: a `Locale` object or a locale identifier
1257 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
1258 :param format: the format to use, either an explicit date format,
1259 or one of "full", "long", "medium", or "short"
1260 (see ``get_time_format``)
1261 """
1262 numbers = re.findall(r'(\d+)', string)
1263 if not numbers:
1264 raise ParseError("No numbers were found in input")
1266 use_predefined_format = format in ('full', 'long', 'medium', 'short')
1267 # we try ISO-8601 format first, meaning similar to formats
1268 # extended YYYY-MM-DD or basic YYYYMMDD
1269 iso_alike = re.match(r'^(\d{4})-?([01]\d)-?([0-3]\d)$',
1270 string, flags=re.ASCII) # allow only ASCII digits
1271 if iso_alike and use_predefined_format:
1272 try:
1273 return datetime.date(*map(int, iso_alike.groups()))
1274 except ValueError:
1275 pass # a locale format might fit better, so let's continue
1277 if use_predefined_format:
1278 fmt = get_date_format(format=format, locale=locale)
1279 else:
1280 fmt = parse_pattern(format)
1281 format_str = fmt.pattern.lower()
1282 year_idx = format_str.index('y')
1283 month_idx = format_str.find('m')
1284 if month_idx < 0:
1285 month_idx = format_str.index('l')
1286 day_idx = format_str.index('d')
1288 indexes = sorted([(year_idx, 'Y'), (month_idx, 'M'), (day_idx, 'D')])
1289 indexes = {item[1]: idx for idx, item in enumerate(indexes)}
1291 # FIXME: this currently only supports numbers, but should also support month
1292 # names, both in the requested locale, and english
1294 year = numbers[indexes['Y']]
1295 year = 2000 + int(year) if len(year) == 2 else int(year)
1296 month = int(numbers[indexes['M']])
1297 day = int(numbers[indexes['D']])
1298 if month > 12:
1299 month, day = day, month
1300 return datetime.date(year, month, day)
1303def parse_time(
1304 string: str,
1305 locale: Locale | str | None = None,
1306 format: _PredefinedTimeFormat | str = 'medium',
1307) -> datetime.time:
1308 """Parse a time from a string.
1310 This function uses the time format for the locale as a hint to determine
1311 the order in which the time fields appear in the string.
1313 If an explicit format is provided, the function will use it to parse
1314 the time instead.
1316 >>> parse_time('15:30:00', locale='en_US')
1317 datetime.time(15, 30)
1318 >>> parse_time('15:30:00', format='H:mm:ss')
1319 datetime.time(15, 30)
1321 :param string: the string containing the time
1322 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
1323 :param format: the format to use, either an explicit time format,
1324 or one of "full", "long", "medium", or "short"
1325 (see ``get_time_format``)
1326 :return: the parsed time
1327 :rtype: `time`
1328 """
1329 numbers = re.findall(r'(\d+)', string)
1330 if not numbers:
1331 raise ParseError("No numbers were found in input")
1333 # TODO: try ISO format first?
1334 if format in ('full', 'long', 'medium', 'short'):
1335 fmt = get_time_format(format=format, locale=locale)
1336 else:
1337 fmt = parse_pattern(format)
1338 format_str = fmt.pattern.lower()
1339 hour_idx = format_str.find('h')
1340 if hour_idx < 0:
1341 hour_idx = format_str.index('k')
1342 min_idx = format_str.index('m')
1343 # format might not contain seconds
1344 if (sec_idx := format_str.find('s')) < 0:
1345 sec_idx = math.inf
1347 indexes = sorted([(hour_idx, 'H'), (min_idx, 'M'), (sec_idx, 'S')])
1348 indexes = {item[1]: idx for idx, item in enumerate(indexes)}
1350 # TODO: support time zones
1352 # Check if the format specifies a period to be used;
1353 # if it does, look for 'pm' to figure out an offset.
1354 hour_offset = 0
1355 if 'a' in format_str and 'pm' in string.lower():
1356 hour_offset = 12
1358 # Parse up to three numbers from the string.
1359 minute = second = 0
1360 hour = int(numbers[indexes['H']]) + hour_offset
1361 if len(numbers) > 1:
1362 minute = int(numbers[indexes['M']])
1363 if len(numbers) > 2:
1364 second = int(numbers[indexes['S']])
1365 return datetime.time(hour, minute, second)
1368class DateTimePattern:
1370 def __init__(self, pattern: str, format: DateTimeFormat):
1371 self.pattern = pattern
1372 self.format = format
1374 def __repr__(self) -> str:
1375 return f"<{type(self).__name__} {self.pattern!r}>"
1377 def __str__(self) -> str:
1378 pat = self.pattern
1379 return pat
1381 def __mod__(self, other: DateTimeFormat) -> str:
1382 if not isinstance(other, DateTimeFormat):
1383 return NotImplemented
1384 return self.format % other
1386 def apply(
1387 self,
1388 datetime: datetime.date | datetime.time,
1389 locale: Locale | str | None,
1390 reference_date: datetime.date | None = None,
1391 ) -> str:
1392 return self % DateTimeFormat(datetime, locale, reference_date)
1395class DateTimeFormat:
1397 def __init__(
1398 self,
1399 value: datetime.date | datetime.time,
1400 locale: Locale | str,
1401 reference_date: datetime.date | None = None,
1402 ) -> None:
1403 assert isinstance(value, (datetime.date, datetime.datetime, datetime.time))
1404 if isinstance(value, (datetime.datetime, datetime.time)) and value.tzinfo is None:
1405 value = value.replace(tzinfo=UTC)
1406 self.value = value
1407 self.locale = Locale.parse(locale)
1408 self.reference_date = reference_date
1410 def __getitem__(self, name: str) -> str:
1411 char = name[0]
1412 num = len(name)
1413 if char == 'G':
1414 return self.format_era(char, num)
1415 elif char in ('y', 'Y', 'u'):
1416 return self.format_year(char, num)
1417 elif char in ('Q', 'q'):
1418 return self.format_quarter(char, num)
1419 elif char in ('M', 'L'):
1420 return self.format_month(char, num)
1421 elif char in ('w', 'W'):
1422 return self.format_week(char, num)
1423 elif char == 'd':
1424 return self.format(self.value.day, num)
1425 elif char == 'D':
1426 return self.format_day_of_year(num)
1427 elif char == 'F':
1428 return self.format_day_of_week_in_month()
1429 elif char in ('E', 'e', 'c'):
1430 return self.format_weekday(char, num)
1431 elif char in ('a', 'b', 'B'):
1432 return self.format_period(char, num)
1433 elif char == 'h':
1434 if self.value.hour % 12 == 0:
1435 return self.format(12, num)
1436 else:
1437 return self.format(self.value.hour % 12, num)
1438 elif char == 'H':
1439 return self.format(self.value.hour, num)
1440 elif char == 'K':
1441 return self.format(self.value.hour % 12, num)
1442 elif char == 'k':
1443 if self.value.hour == 0:
1444 return self.format(24, num)
1445 else:
1446 return self.format(self.value.hour, num)
1447 elif char == 'm':
1448 return self.format(self.value.minute, num)
1449 elif char == 's':
1450 return self.format(self.value.second, num)
1451 elif char == 'S':
1452 return self.format_frac_seconds(num)
1453 elif char == 'A':
1454 return self.format_milliseconds_in_day(num)
1455 elif char in ('z', 'Z', 'v', 'V', 'x', 'X', 'O'):
1456 return self.format_timezone(char, num)
1457 else:
1458 raise KeyError(f"Unsupported date/time field {char!r}")
1460 def extract(self, char: str) -> int:
1461 char = str(char)[0]
1462 if char == 'y':
1463 return self.value.year
1464 elif char == 'M':
1465 return self.value.month
1466 elif char == 'd':
1467 return self.value.day
1468 elif char == 'H':
1469 return self.value.hour
1470 elif char == 'h':
1471 return self.value.hour % 12 or 12
1472 elif char == 'm':
1473 return self.value.minute
1474 elif char == 'a':
1475 return int(self.value.hour >= 12) # 0 for am, 1 for pm
1476 else:
1477 raise NotImplementedError(f"Not implemented: extracting {char!r} from {self.value!r}")
1479 def format_era(self, char: str, num: int) -> str:
1480 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[max(3, num)]
1481 era = int(self.value.year >= 0)
1482 return get_era_names(width, self.locale)[era]
1484 def format_year(self, char: str, num: int) -> str:
1485 value = self.value.year
1486 if char.isupper():
1487 month = self.value.month
1488 if month == 1 and self.value.day < 7 and self.get_week_of_year() >= 52:
1489 value -= 1
1490 elif month == 12 and self.value.day > 25 and self.get_week_of_year() <= 2:
1491 value += 1
1492 year = self.format(value, num)
1493 if num == 2:
1494 year = year[-2:]
1495 return year
1497 def format_quarter(self, char: str, num: int) -> str:
1498 quarter = (self.value.month - 1) // 3 + 1
1499 if num <= 2:
1500 return '%0*d' % (num, quarter)
1501 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[num]
1502 context = {'Q': 'format', 'q': 'stand-alone'}[char]
1503 return get_quarter_names(width, context, self.locale)[quarter]
1505 def format_month(self, char: str, num: int) -> str:
1506 if num <= 2:
1507 return '%0*d' % (num, self.value.month)
1508 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[num]
1509 context = {'M': 'format', 'L': 'stand-alone'}[char]
1510 return get_month_names(width, context, self.locale)[self.value.month]
1512 def format_week(self, char: str, num: int) -> str:
1513 if char.islower(): # week of year
1514 week = self.get_week_of_year()
1515 return self.format(week, num)
1516 else: # week of month
1517 week = self.get_week_of_month()
1518 return str(week)
1520 def format_weekday(self, char: str = 'E', num: int = 4) -> str:
1521 """
1522 Return weekday from parsed datetime according to format pattern.
1524 >>> from datetime import date
1525 >>> format = DateTimeFormat(date(2016, 2, 28), Locale.parse('en_US'))
1526 >>> format.format_weekday()
1527 'Sunday'
1529 'E': Day of week - Use one through three letters for the abbreviated day name, four for the full (wide) name,
1530 five for the narrow name, or six for the short name.
1531 >>> format.format_weekday('E',2)
1532 'Sun'
1534 'e': Local day of week. Same as E except adds a numeric value that will depend on the local starting day of the
1535 week, using one or two letters. For this example, Monday is the first day of the week.
1536 >>> format.format_weekday('e',2)
1537 '01'
1539 'c': Stand-Alone local day of week - Use one letter for the local numeric value (same as 'e'), three for the
1540 abbreviated day name, four for the full (wide) name, five for the narrow name, or six for the short name.
1541 >>> format.format_weekday('c',1)
1542 '1'
1544 :param char: pattern format character ('e','E','c')
1545 :param num: count of format character
1547 """
1548 if num < 3:
1549 if char.islower():
1550 value = 7 - self.locale.first_week_day + self.value.weekday()
1551 return self.format(value % 7 + 1, num)
1552 num = 3
1553 weekday = self.value.weekday()
1554 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow', 6: 'short'}[num]
1555 context = "stand-alone" if char == "c" else "format"
1556 return get_day_names(width, context, self.locale)[weekday]
1558 def format_day_of_year(self, num: int) -> str:
1559 return self.format(self.get_day_of_year(), num)
1561 def format_day_of_week_in_month(self) -> str:
1562 return str((self.value.day - 1) // 7 + 1)
1564 def format_period(self, char: str, num: int) -> str:
1565 """
1566 Return period from parsed datetime according to format pattern.
1568 >>> from datetime import datetime, time
1569 >>> format = DateTimeFormat(time(13, 42), 'fi_FI')
1570 >>> format.format_period('a', 1)
1571 'ip.'
1572 >>> format.format_period('b', 1)
1573 'iltap.'
1574 >>> format.format_period('b', 4)
1575 'iltapäivä'
1576 >>> format.format_period('B', 4)
1577 'iltapäivällä'
1578 >>> format.format_period('B', 5)
1579 'ip.'
1581 >>> format = DateTimeFormat(datetime(2022, 4, 28, 6, 27), 'zh_Hant')
1582 >>> format.format_period('a', 1)
1583 '上午'
1584 >>> format.format_period('B', 1)
1585 '清晨'
1587 :param char: pattern format character ('a', 'b', 'B')
1588 :param num: count of format character
1590 """
1591 widths = [{3: 'abbreviated', 4: 'wide', 5: 'narrow'}[max(3, num)],
1592 'wide', 'narrow', 'abbreviated']
1593 if char == 'a':
1594 period = 'pm' if self.value.hour >= 12 else 'am'
1595 context = 'format'
1596 else:
1597 period = get_period_id(self.value, locale=self.locale)
1598 context = 'format' if char == 'B' else 'stand-alone'
1599 for width in widths:
1600 period_names = get_period_names(context=context, width=width, locale=self.locale)
1601 if period in period_names:
1602 return period_names[period]
1603 raise ValueError(f"Could not format period {period} in {self.locale}")
1605 def format_frac_seconds(self, num: int) -> str:
1606 """ Return fractional seconds.
1608 Rounds the time's microseconds to the precision given by the number \
1609 of digits passed in.
1610 """
1611 value = self.value.microsecond / 1000000
1612 return self.format(round(value, num) * 10**num, num)
1614 def format_milliseconds_in_day(self, num):
1615 msecs = self.value.microsecond // 1000 + self.value.second * 1000 + \
1616 self.value.minute * 60000 + self.value.hour * 3600000
1617 return self.format(msecs, num)
1619 def format_timezone(self, char: str, num: int) -> str:
1620 width = {3: 'short', 4: 'long', 5: 'iso8601'}[max(3, num)]
1622 # It could be that we only receive a time to format, but also have a
1623 # reference date which is important to distinguish between timezone
1624 # variants (summer/standard time)
1625 value = self.value
1626 if self.reference_date:
1627 value = datetime.datetime.combine(self.reference_date, self.value)
1629 if char == 'z':
1630 return get_timezone_name(value, width, locale=self.locale)
1631 elif char == 'Z':
1632 if num == 5:
1633 return get_timezone_gmt(value, width, locale=self.locale, return_z=True)
1634 return get_timezone_gmt(value, width, locale=self.locale)
1635 elif char == 'O':
1636 if num == 4:
1637 return get_timezone_gmt(value, width, locale=self.locale)
1638 # TODO: To add support for O:1
1639 elif char == 'v':
1640 return get_timezone_name(value.tzinfo, width,
1641 locale=self.locale)
1642 elif char == 'V':
1643 if num == 1:
1644 return get_timezone_name(value.tzinfo, width,
1645 uncommon=True, locale=self.locale)
1646 elif num == 2:
1647 return get_timezone_name(value.tzinfo, locale=self.locale, return_zone=True)
1648 elif num == 3:
1649 return get_timezone_location(value.tzinfo, locale=self.locale, return_city=True)
1650 return get_timezone_location(value.tzinfo, locale=self.locale)
1651 # Included additional elif condition to add support for 'Xx' in timezone format
1652 elif char == 'X':
1653 if num == 1:
1654 return get_timezone_gmt(value, width='iso8601_short', locale=self.locale,
1655 return_z=True)
1656 elif num in (2, 4):
1657 return get_timezone_gmt(value, width='short', locale=self.locale,
1658 return_z=True)
1659 elif num in (3, 5):
1660 return get_timezone_gmt(value, width='iso8601', locale=self.locale,
1661 return_z=True)
1662 elif char == 'x':
1663 if num == 1:
1664 return get_timezone_gmt(value, width='iso8601_short', locale=self.locale)
1665 elif num in (2, 4):
1666 return get_timezone_gmt(value, width='short', locale=self.locale)
1667 elif num in (3, 5):
1668 return get_timezone_gmt(value, width='iso8601', locale=self.locale)
1670 def format(self, value: SupportsInt, length: int) -> str:
1671 return '%0*d' % (length, value)
1673 def get_day_of_year(self, date: datetime.date | None = None) -> int:
1674 if date is None:
1675 date = self.value
1676 return (date - date.replace(month=1, day=1)).days + 1
1678 def get_week_of_year(self) -> int:
1679 """Return the week of the year."""
1680 day_of_year = self.get_day_of_year(self.value)
1681 week = self.get_week_number(day_of_year)
1682 if week == 0:
1683 date = datetime.date(self.value.year - 1, 12, 31)
1684 week = self.get_week_number(self.get_day_of_year(date),
1685 date.weekday())
1686 elif week > 52:
1687 weekday = datetime.date(self.value.year + 1, 1, 1).weekday()
1688 if self.get_week_number(1, weekday) == 1 and \
1689 32 - (weekday - self.locale.first_week_day) % 7 <= self.value.day:
1690 week = 1
1691 return week
1693 def get_week_of_month(self) -> int:
1694 """Return the week of the month."""
1695 return self.get_week_number(self.value.day)
1697 def get_week_number(self, day_of_period: int, day_of_week: int | None = None) -> int:
1698 """Return the number of the week of a day within a period. This may be
1699 the week number in a year or the week number in a month.
1701 Usually this will return a value equal to or greater than 1, but if the
1702 first week of the period is so short that it actually counts as the last
1703 week of the previous period, this function will return 0.
1705 >>> date = datetime.date(2006, 1, 8)
1706 >>> DateTimeFormat(date, 'de_DE').get_week_number(6)
1707 1
1708 >>> DateTimeFormat(date, 'en_US').get_week_number(6)
1709 2
1711 :param day_of_period: the number of the day in the period (usually
1712 either the day of month or the day of year)
1713 :param day_of_week: the week day; if omitted, the week day of the
1714 current date is assumed
1715 """
1716 if day_of_week is None:
1717 day_of_week = self.value.weekday()
1718 first_day = (day_of_week - self.locale.first_week_day -
1719 day_of_period + 1) % 7
1720 if first_day < 0:
1721 first_day += 7
1722 week_number = (day_of_period + first_day - 1) // 7
1723 if 7 - first_day >= self.locale.min_week_days:
1724 week_number += 1
1725 return week_number
1728PATTERN_CHARS: dict[str, list[int] | None] = {
1729 'G': [1, 2, 3, 4, 5], # era
1730 'y': None, 'Y': None, 'u': None, # year
1731 'Q': [1, 2, 3, 4, 5], 'q': [1, 2, 3, 4, 5], # quarter
1732 'M': [1, 2, 3, 4, 5], 'L': [1, 2, 3, 4, 5], # month
1733 'w': [1, 2], 'W': [1], # week
1734 'd': [1, 2], 'D': [1, 2, 3], 'F': [1], 'g': None, # day
1735 'E': [1, 2, 3, 4, 5, 6], 'e': [1, 2, 3, 4, 5, 6], 'c': [1, 3, 4, 5, 6], # week day
1736 'a': [1, 2, 3, 4, 5], 'b': [1, 2, 3, 4, 5], 'B': [1, 2, 3, 4, 5], # period
1737 'h': [1, 2], 'H': [1, 2], 'K': [1, 2], 'k': [1, 2], # hour
1738 'm': [1, 2], # minute
1739 's': [1, 2], 'S': None, 'A': None, # second
1740 'z': [1, 2, 3, 4], 'Z': [1, 2, 3, 4, 5], 'O': [1, 4], 'v': [1, 4], # zone
1741 'V': [1, 2, 3, 4], 'x': [1, 2, 3, 4, 5], 'X': [1, 2, 3, 4, 5], # zone
1742}
1744#: The pattern characters declared in the Date Field Symbol Table
1745#: (https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table)
1746#: in order of decreasing magnitude.
1747PATTERN_CHAR_ORDER = "GyYuUQqMLlwWdDFgEecabBChHKkjJmsSAzZOvVXx"
1750def parse_pattern(pattern: str | DateTimePattern) -> DateTimePattern:
1751 """Parse date, time, and datetime format patterns.
1753 >>> parse_pattern("MMMMd").format
1754 '%(MMMM)s%(d)s'
1755 >>> parse_pattern("MMM d, yyyy").format
1756 '%(MMM)s %(d)s, %(yyyy)s'
1758 Pattern can contain literal strings in single quotes:
1760 >>> parse_pattern("H:mm' Uhr 'z").format
1761 '%(H)s:%(mm)s Uhr %(z)s'
1763 An actual single quote can be used by using two adjacent single quote
1764 characters:
1766 >>> parse_pattern("hh' o''clock'").format
1767 "%(hh)s o'clock"
1769 :param pattern: the formatting pattern to parse
1770 """
1771 if isinstance(pattern, DateTimePattern):
1772 return pattern
1773 return _cached_parse_pattern(pattern)
1776@lru_cache(maxsize=1024)
1777def _cached_parse_pattern(pattern: str) -> DateTimePattern:
1778 result = []
1780 for tok_type, tok_value in tokenize_pattern(pattern):
1781 if tok_type == "chars":
1782 result.append(tok_value.replace('%', '%%'))
1783 elif tok_type == "field":
1784 fieldchar, fieldnum = tok_value
1785 limit = PATTERN_CHARS[fieldchar]
1786 if limit and fieldnum not in limit:
1787 raise ValueError(f"Invalid length for field: {fieldchar * fieldnum!r}")
1788 result.append('%%(%s)s' % (fieldchar * fieldnum))
1789 else:
1790 raise NotImplementedError(f"Unknown token type: {tok_type}")
1791 return DateTimePattern(pattern, ''.join(result))
1794def tokenize_pattern(pattern: str) -> list[tuple[str, str | tuple[str, int]]]:
1795 """
1796 Tokenize date format patterns.
1798 Returns a list of (token_type, token_value) tuples.
1800 ``token_type`` may be either "chars" or "field".
1802 For "chars" tokens, the value is the literal value.
1804 For "field" tokens, the value is a tuple of (field character, repetition count).
1806 :param pattern: Pattern string
1807 :type pattern: str
1808 :rtype: list[tuple]
1809 """
1810 result = []
1811 quotebuf = None
1812 charbuf = []
1813 fieldchar = ['']
1814 fieldnum = [0]
1816 def append_chars():
1817 result.append(('chars', ''.join(charbuf).replace('\0', "'")))
1818 del charbuf[:]
1820 def append_field():
1821 result.append(('field', (fieldchar[0], fieldnum[0])))
1822 fieldchar[0] = ''
1823 fieldnum[0] = 0
1825 for char in pattern.replace("''", '\0'):
1826 if quotebuf is None:
1827 if char == "'": # quote started
1828 if fieldchar[0]:
1829 append_field()
1830 elif charbuf:
1831 append_chars()
1832 quotebuf = []
1833 elif char in PATTERN_CHARS:
1834 if charbuf:
1835 append_chars()
1836 if char == fieldchar[0]:
1837 fieldnum[0] += 1
1838 else:
1839 if fieldchar[0]:
1840 append_field()
1841 fieldchar[0] = char
1842 fieldnum[0] = 1
1843 else:
1844 if fieldchar[0]:
1845 append_field()
1846 charbuf.append(char)
1848 elif quotebuf is not None:
1849 if char == "'": # end of quote
1850 charbuf.extend(quotebuf)
1851 quotebuf = None
1852 else: # inside quote
1853 quotebuf.append(char)
1855 if fieldchar[0]:
1856 append_field()
1857 elif charbuf:
1858 append_chars()
1860 return result
1863def untokenize_pattern(tokens: Iterable[tuple[str, str | tuple[str, int]]]) -> str:
1864 """
1865 Turn a date format pattern token stream back into a string.
1867 This is the reverse operation of ``tokenize_pattern``.
1869 :type tokens: Iterable[tuple]
1870 :rtype: str
1871 """
1872 output = []
1873 for tok_type, tok_value in tokens:
1874 if tok_type == "field":
1875 output.append(tok_value[0] * tok_value[1])
1876 elif tok_type == "chars":
1877 if not any(ch in PATTERN_CHARS for ch in tok_value): # No need to quote
1878 output.append(tok_value)
1879 else:
1880 output.append("'%s'" % tok_value.replace("'", "''"))
1881 return "".join(output)
1884def split_interval_pattern(pattern: str) -> list[str]:
1885 """
1886 Split an interval-describing datetime pattern into multiple pieces.
1888 > The pattern is then designed to be broken up into two pieces by determining the first repeating field.
1889 - https://www.unicode.org/reports/tr35/tr35-dates.html#intervalFormats
1891 >>> split_interval_pattern('E d.M. – E d.M.')
1892 ['E d.M. – ', 'E d.M.']
1893 >>> split_interval_pattern("Y 'text' Y 'more text'")
1894 ["Y 'text '", "Y 'more text'"]
1895 >>> split_interval_pattern('E, MMM d – E')
1896 ['E, MMM d – ', 'E']
1897 >>> split_interval_pattern("MMM d")
1898 ['MMM d']
1899 >>> split_interval_pattern("y G")
1900 ['y G']
1901 >>> split_interval_pattern('MMM d – d')
1902 ['MMM d – ', 'd']
1904 :param pattern: Interval pattern string
1905 :return: list of "subpatterns"
1906 """
1908 seen_fields = set()
1909 parts = [[]]
1911 for tok_type, tok_value in tokenize_pattern(pattern):
1912 if tok_type == "field":
1913 if tok_value[0] in seen_fields: # Repeated field
1914 parts.append([])
1915 seen_fields.clear()
1916 seen_fields.add(tok_value[0])
1917 parts[-1].append((tok_type, tok_value))
1919 return [untokenize_pattern(tokens) for tokens in parts]
1922def match_skeleton(skeleton: str, options: Iterable[str], allow_different_fields: bool = False) -> str | None:
1923 """
1924 Find the closest match for the given datetime skeleton among the options given.
1926 This uses the rules outlined in the TR35 document.
1928 >>> match_skeleton('yMMd', ('yMd', 'yMMMd'))
1929 'yMd'
1931 >>> match_skeleton('yMMd', ('jyMMd',), allow_different_fields=True)
1932 'jyMMd'
1934 >>> match_skeleton('yMMd', ('qyMMd',), allow_different_fields=False)
1936 >>> match_skeleton('hmz', ('hmv',))
1937 'hmv'
1939 :param skeleton: The skeleton to match
1940 :type skeleton: str
1941 :param options: An iterable of other skeletons to match against
1942 :type options: Iterable[str]
1943 :param allow_different_fields: Whether to allow a match that uses different fields
1944 than the skeleton requested.
1945 :type allow_different_fields: bool
1947 :return: The closest skeleton match, or if no match was found, None.
1948 :rtype: str|None
1949 """
1951 # TODO: maybe implement pattern expansion?
1953 # Based on the implementation in
1954 # https://github.com/unicode-org/icu/blob/main/icu4j/main/core/src/main/java/com/ibm/icu/text/DateIntervalInfo.java
1956 # Filter out falsy values and sort for stability; when `interval_formats` is passed in, there may be a None key.
1957 options = sorted(option for option in options if option)
1959 if 'z' in skeleton and not any('z' in option for option in options):
1960 skeleton = skeleton.replace('z', 'v')
1961 if 'k' in skeleton and not any('k' in option for option in options):
1962 skeleton = skeleton.replace('k', 'H')
1963 if 'K' in skeleton and not any('K' in option for option in options):
1964 skeleton = skeleton.replace('K', 'h')
1965 if 'a' in skeleton and not any('a' in option for option in options):
1966 skeleton = skeleton.replace('a', '')
1967 if 'b' in skeleton and not any('b' in option for option in options):
1968 skeleton = skeleton.replace('b', '')
1970 get_input_field_width = dict(t[1] for t in tokenize_pattern(skeleton) if t[0] == "field").get
1971 best_skeleton = None
1972 best_distance = None
1973 for option in options:
1974 get_opt_field_width = dict(t[1] for t in tokenize_pattern(option) if t[0] == "field").get
1975 distance = 0
1976 for field in PATTERN_CHARS:
1977 input_width = get_input_field_width(field, 0)
1978 opt_width = get_opt_field_width(field, 0)
1979 if input_width == opt_width:
1980 continue
1981 if opt_width == 0 or input_width == 0:
1982 if not allow_different_fields: # This one is not okay
1983 option = None
1984 break
1985 distance += 0x1000 # Magic weight constant for "entirely different fields"
1986 elif field == 'M' and ((input_width > 2 and opt_width <= 2) or (input_width <= 2 and opt_width > 2)):
1987 distance += 0x100 # Magic weight for "text turns into a number"
1988 else:
1989 distance += abs(input_width - opt_width)
1991 if not option: # We lost the option along the way (probably due to "allow_different_fields")
1992 continue
1994 if not best_skeleton or distance < best_distance:
1995 best_skeleton = option
1996 best_distance = distance
1998 if distance == 0: # Found a perfect match!
1999 break
2001 return best_skeleton