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"""
2babel.dates
3~~~~~~~~~~~
5Locale dependent formatting and parsing of dates and times.
7The default locale for the functions in this module is determined by the
8following environment variables, in that order:
10 * ``LC_TIME``,
11 * ``LC_ALL``, and
12 * ``LANG``
14:copyright: (c) 2013-2026 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
42 _Instant: TypeAlias = datetime.date | datetime.time | float | None
43 _PredefinedTimeFormat: TypeAlias = Literal['full', 'long', 'medium', 'short']
44 _Context: TypeAlias = Literal['format', 'stand-alone']
45 _DtOrTzinfo: TypeAlias = datetime.datetime | datetime.tzinfo | str | int | datetime.time | None # fmt: skip
47# "If a given short metazone form is known NOT to be understood in a given
48# locale and the parent locale has this value such that it would normally
49# be inherited, the inheritance of this value can be explicitly disabled by
50# use of the 'no inheritance marker' as the value, which is 3 simultaneous [sic]
51# empty set characters ( U+2205 )."
52# - https://www.unicode.org/reports/tr35/tr35-dates.html#Metazone_Names
54NO_INHERITANCE_MARKER = '\u2205\u2205\u2205'
56UTC = datetime.timezone.utc
57LOCALTZ = localtime.LOCALTZ
59LC_TIME = default_locale('LC_TIME')
62def _localize(tz: datetime.tzinfo, dt: datetime.datetime) -> datetime.datetime:
63 # Support localizing with both pytz and zoneinfo tzinfos
64 # nothing to do
65 if dt.tzinfo is tz:
66 return dt
68 if hasattr(tz, 'localize'): # pytz
69 return tz.localize(dt)
71 if dt.tzinfo is None:
72 # convert naive to localized
73 return dt.replace(tzinfo=tz)
75 # convert timezones
76 return dt.astimezone(tz)
79def _get_dt_and_tzinfo(
80 dt_or_tzinfo: _DtOrTzinfo,
81) -> tuple[datetime.datetime | None, datetime.tzinfo]:
82 """
83 Parse a `dt_or_tzinfo` value into a datetime and a tzinfo.
85 See the docs for this function's callers for semantics.
87 :rtype: tuple[datetime, tzinfo]
88 """
89 if dt_or_tzinfo is None:
90 dt = datetime.datetime.now()
91 tzinfo = LOCALTZ
92 elif isinstance(dt_or_tzinfo, str):
93 dt = None
94 tzinfo = get_timezone(dt_or_tzinfo)
95 elif isinstance(dt_or_tzinfo, int):
96 dt = None
97 tzinfo = UTC
98 elif isinstance(dt_or_tzinfo, (datetime.datetime, datetime.time)):
99 dt = _get_datetime(dt_or_tzinfo)
100 tzinfo = dt.tzinfo if dt.tzinfo is not None else UTC
101 else:
102 dt = None
103 tzinfo = dt_or_tzinfo
104 return dt, tzinfo
107def _get_tz_name(dt_or_tzinfo: _DtOrTzinfo) -> str:
108 """
109 Get the timezone name out of a time, datetime, or tzinfo object.
111 :rtype: str
112 """
113 dt, tzinfo = _get_dt_and_tzinfo(dt_or_tzinfo)
114 if hasattr(tzinfo, 'zone'): # pytz object
115 return tzinfo.zone
116 elif hasattr(tzinfo, 'key') and tzinfo.key is not None: # ZoneInfo object
117 return tzinfo.key
118 else:
119 return tzinfo.tzname(dt or datetime.datetime.now(UTC))
122def _get_datetime(instant: _Instant) -> datetime.datetime:
123 """
124 Get a datetime out of an "instant" (date, time, datetime, number).
126 .. warning:: The return values of this function may depend on the system clock.
128 If the instant is None, the current moment is used.
129 If the instant is a time, it's augmented with today's date.
131 Dates are converted to naive datetimes with midnight as the time component.
133 >>> from datetime import date, datetime
134 >>> _get_datetime(date(2015, 1, 1))
135 datetime.datetime(2015, 1, 1, 0, 0)
137 UNIX timestamps are converted to datetimes.
139 >>> _get_datetime(1400000000)
140 datetime.datetime(2014, 5, 13, 16, 53, 20)
142 Other values are passed through as-is.
144 >>> x = datetime(2015, 1, 1)
145 >>> _get_datetime(x) is x
146 True
148 :param instant: date, time, datetime, integer, float or None
149 :return: a datetime
150 """
151 if instant is None:
152 return datetime.datetime.now(UTC).replace(tzinfo=None)
153 elif isinstance(instant, (int, float)):
154 return datetime.datetime.fromtimestamp(instant, UTC).replace(tzinfo=None)
155 elif isinstance(instant, datetime.time):
156 return datetime.datetime.combine(datetime.date.today(), instant)
157 elif isinstance(instant, datetime.date) and not isinstance(instant, datetime.datetime): # fmt: skip
158 return datetime.datetime.combine(instant, datetime.time())
159 # TODO (3.x): Add an assertion/type check for this fallthrough branch:
160 return instant
163def _ensure_datetime_tzinfo(
164 dt: datetime.datetime,
165 tzinfo: datetime.tzinfo | None = None,
166) -> datetime.datetime:
167 """
168 Ensure the datetime passed has an attached tzinfo.
170 If the datetime is tz-naive to begin with, UTC is attached.
172 If a tzinfo is passed in, the datetime is normalized to that timezone.
174 >>> from datetime import datetime
175 >>> _get_tz_name(_ensure_datetime_tzinfo(datetime(2015, 1, 1)))
176 'UTC'
178 >>> tz = get_timezone("Europe/Stockholm")
179 >>> _ensure_datetime_tzinfo(datetime(2015, 1, 1, 13, 15, tzinfo=UTC), tzinfo=tz).hour
180 14
182 :param datetime: Datetime to augment.
183 :param tzinfo: optional tzinfo
184 :return: datetime with tzinfo
185 :rtype: datetime
186 """
187 if dt.tzinfo is None:
188 dt = dt.replace(tzinfo=UTC)
189 if tzinfo is not None:
190 dt = dt.astimezone(get_timezone(tzinfo))
191 if hasattr(tzinfo, 'normalize'): # pytz
192 dt = tzinfo.normalize(dt)
193 return dt
196def _get_time(
197 time: datetime.time | datetime.datetime | None,
198 tzinfo: datetime.tzinfo | None = None,
199) -> datetime.time:
200 """
201 Get a timezoned time from a given instant.
203 .. warning:: The return values of this function may depend on the system clock.
205 :param time: time, datetime or None
206 :rtype: time
207 """
208 if time is None:
209 time = datetime.datetime.now(UTC)
210 elif isinstance(time, (int, float)):
211 time = datetime.datetime.fromtimestamp(time, UTC)
213 if time.tzinfo is None:
214 time = time.replace(tzinfo=UTC)
216 if isinstance(time, datetime.datetime):
217 if tzinfo is not None:
218 time = time.astimezone(tzinfo)
219 if hasattr(tzinfo, 'normalize'): # pytz
220 time = tzinfo.normalize(time)
221 time = time.timetz()
222 elif tzinfo is not None:
223 time = time.replace(tzinfo=tzinfo)
224 return time
227def get_timezone(zone: str | datetime.tzinfo | None = None) -> datetime.tzinfo:
228 """Looks up a timezone by name and returns it. The timezone object
229 returned comes from ``pytz`` or ``zoneinfo``, whichever is available.
230 It corresponds to the `tzinfo` interface and can be used with all of
231 the functions of Babel that operate with dates.
233 If a timezone is not known a :exc:`LookupError` is raised. If `zone`
234 is ``None`` a local zone object is returned.
236 :param zone: the name of the timezone to look up. If a timezone object
237 itself is passed in, it's returned unchanged.
238 """
239 if zone is None:
240 return LOCALTZ
241 if not isinstance(zone, str):
242 return zone
244 if pytz:
245 try:
246 return pytz.timezone(zone)
247 except pytz.UnknownTimeZoneError as e:
248 exc = e
249 else:
250 assert zoneinfo
251 try:
252 return zoneinfo.ZoneInfo(zone)
253 except zoneinfo.ZoneInfoNotFoundError as e:
254 exc = e
256 raise LookupError(f"Unknown timezone {zone}") from exc
259def get_period_names(
260 width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
261 context: _Context = 'stand-alone',
262 locale: Locale | str | None = None,
263) -> LocaleDataDict:
264 """Return the names for day periods (AM/PM) used by the locale.
266 >>> get_period_names(locale='en_US')['am']
267 'AM'
269 :param width: the width to use, one of "abbreviated", "narrow", or "wide"
270 :param context: the context, either "format" or "stand-alone"
271 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
272 """
273 return Locale.parse(locale or LC_TIME).day_periods[context][width]
276def get_day_names(
277 width: Literal['abbreviated', 'narrow', 'short', 'wide'] = 'wide',
278 context: _Context = 'format',
279 locale: Locale | str | None = None,
280) -> LocaleDataDict:
281 """Return the day names used by the locale for the specified format.
283 >>> get_day_names('wide', locale='en_US')[1]
284 'Tuesday'
285 >>> get_day_names('short', locale='en_US')[1]
286 'Tu'
287 >>> get_day_names('abbreviated', locale='es')[1]
288 'mar'
289 >>> get_day_names('narrow', context='stand-alone', locale='de_DE')[1]
290 'D'
292 :param width: the width to use, one of "wide", "abbreviated", "short" or "narrow"
293 :param context: the context, either "format" or "stand-alone"
294 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
295 """
296 return Locale.parse(locale or LC_TIME).days[context][width]
299def get_month_names(
300 width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
301 context: _Context = 'format',
302 locale: Locale | str | None = None,
303) -> LocaleDataDict:
304 """Return the month names used by the locale for the specified format.
306 >>> get_month_names('wide', locale='en_US')[1]
307 'January'
308 >>> get_month_names('abbreviated', locale='es')[1]
309 'ene'
310 >>> get_month_names('narrow', context='stand-alone', locale='de_DE')[1]
311 'J'
313 :param width: the width to use, one of "wide", "abbreviated", or "narrow"
314 :param context: the context, either "format" or "stand-alone"
315 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
316 """
317 return Locale.parse(locale or LC_TIME).months[context][width]
320def get_quarter_names(
321 width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
322 context: _Context = 'format',
323 locale: Locale | str | None = None,
324) -> LocaleDataDict:
325 """Return the quarter names used by the locale for the specified format.
327 >>> get_quarter_names('wide', locale='en_US')[1]
328 '1st quarter'
329 >>> get_quarter_names('abbreviated', locale='de_DE')[1]
330 'Q1'
331 >>> get_quarter_names('narrow', locale='de_DE')[1]
332 '1'
334 :param width: the width to use, one of "wide", "abbreviated", or "narrow"
335 :param context: the context, either "format" or "stand-alone"
336 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
337 """
338 return Locale.parse(locale or LC_TIME).quarters[context][width]
341def get_era_names(
342 width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
343 locale: Locale | str | None = None,
344) -> LocaleDataDict:
345 """Return the era names used by the locale for the specified format.
347 >>> get_era_names('wide', locale='en_US')[1]
348 'Anno Domini'
349 >>> get_era_names('abbreviated', locale='de_DE')[1]
350 'n. Chr.'
352 :param width: the width to use, either "wide", "abbreviated", or "narrow"
353 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
354 """
355 return Locale.parse(locale or LC_TIME).eras[width]
358def get_date_format(
359 format: _PredefinedTimeFormat = 'medium',
360 locale: Locale | str | None = None,
361) -> DateTimePattern:
362 """Return the date formatting patterns used by the locale for the specified
363 format.
365 >>> get_date_format(locale='en_US')
366 <DateTimePattern 'MMM d, y'>
367 >>> get_date_format('full', locale='de_DE')
368 <DateTimePattern 'EEEE, d. MMMM y'>
370 :param format: the format to use, one of "full", "long", "medium", or
371 "short"
372 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
373 """
374 return Locale.parse(locale or LC_TIME).date_formats[format]
377def get_datetime_format(
378 format: _PredefinedTimeFormat = 'medium',
379 locale: Locale | str | None = None,
380) -> DateTimePattern:
381 """Return the datetime formatting patterns used by the locale for the
382 specified format.
384 >>> get_datetime_format(locale='en_US')
385 '{1}, {0}'
387 :param format: the format to use, one of "full", "long", "medium", or
388 "short"
389 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
390 """
391 patterns = Locale.parse(locale or LC_TIME).datetime_formats
392 if format not in patterns:
393 format = None
394 return patterns[format]
397def get_time_format(
398 format: _PredefinedTimeFormat = 'medium',
399 locale: Locale | str | None = None,
400) -> DateTimePattern:
401 """Return the time formatting patterns used by the locale for the specified
402 format.
404 >>> get_time_format(locale='en_US')
405 <DateTimePattern 'h:mm:ss\\u202fa'>
406 >>> get_time_format('full', locale='de_DE')
407 <DateTimePattern 'HH:mm:ss zzzz'>
409 :param format: the format to use, one of "full", "long", "medium", or
410 "short"
411 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
412 """
413 return Locale.parse(locale or LC_TIME).time_formats[format]
416def get_timezone_gmt(
417 datetime: _Instant = None,
418 width: Literal['long', 'short', 'iso8601', 'iso8601_short'] = 'long',
419 locale: Locale | str | None = None,
420 return_z: bool = False,
421) -> str:
422 """Return the timezone associated with the given `datetime` object formatted
423 as string indicating the offset from GMT.
425 >>> from datetime import datetime
426 >>> dt = datetime(2007, 4, 1, 15, 30)
427 >>> get_timezone_gmt(dt, locale='en')
428 'GMT+00:00'
429 >>> get_timezone_gmt(dt, locale='en', return_z=True)
430 'Z'
431 >>> get_timezone_gmt(dt, locale='en', width='iso8601_short')
432 '+00'
433 >>> tz = get_timezone('America/Los_Angeles')
434 >>> dt = _localize(tz, datetime(2007, 4, 1, 15, 30))
435 >>> get_timezone_gmt(dt, locale='en')
436 'GMT-07:00'
437 >>> get_timezone_gmt(dt, 'short', locale='en')
438 '-0700'
439 >>> get_timezone_gmt(dt, locale='en', width='iso8601_short')
440 '-07'
442 The long format depends on the locale, for example in France the acronym
443 UTC string is used instead of GMT:
445 >>> get_timezone_gmt(dt, 'long', locale='fr_FR')
446 'UTC-07:00'
448 .. versionadded:: 0.9
450 :param datetime: the ``datetime`` object; if `None`, the current date and
451 time in UTC is used
452 :param width: either "long" or "short" or "iso8601" or "iso8601_short"
453 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
454 :param return_z: True or False; Function returns indicator "Z"
455 when local time offset is 0
456 """
457 datetime = _ensure_datetime_tzinfo(_get_datetime(datetime))
458 locale = Locale.parse(locale or LC_TIME)
460 offset = datetime.tzinfo.utcoffset(datetime)
461 seconds = offset.days * 24 * 60 * 60 + offset.seconds
462 if return_z and seconds == 0:
463 return 'Z'
464 sign = '-' if seconds < 0 else '+'
465 hours, seconds = divmod(abs(seconds), 3600)
466 if seconds == 0 and width == 'iso8601_short':
467 return '%s%02d' % (sign, hours)
468 elif width == 'short' or width == 'iso8601_short':
469 pattern = '%s%02d%02d'
470 elif width == 'iso8601':
471 pattern = '%s%02d:%02d'
472 else:
473 pattern = locale.zone_formats['gmt'] % '%s%02d:%02d'
474 return pattern % (sign, hours, seconds // 60)
477def get_timezone_location(
478 dt_or_tzinfo: _DtOrTzinfo = None,
479 locale: Locale | str | None = None,
480 return_city: bool = False,
481) -> str:
482 """Return a representation of the given timezone using "location format".
484 The result depends on both the local display name of the country and the
485 city associated with the time zone:
487 >>> tz = get_timezone('America/St_Johns')
488 >>> print(get_timezone_location(tz, locale='de_DE'))
489 Kanada (St. John’s) (Ortszeit)
490 >>> print(get_timezone_location(tz, locale='en'))
491 Canada (St. John’s) Time
492 >>> print(get_timezone_location(tz, locale='en', return_city=True))
493 St. John’s
494 >>> tz = get_timezone('America/Mexico_City')
495 >>> get_timezone_location(tz, locale='de_DE')
496 'Mexiko (Mexiko-Stadt) (Ortszeit)'
498 If the timezone is associated with a country that uses only a single
499 timezone, just the localized country name is returned:
501 >>> tz = get_timezone('Europe/Berlin')
502 >>> get_timezone_name(tz, locale='de_DE')
503 'Mitteleuropäische Zeit'
505 .. versionadded:: 0.9
507 :param dt_or_tzinfo: the ``datetime`` or ``tzinfo`` object that determines
508 the timezone; if `None`, the current date and time in
509 UTC is assumed
510 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
511 :param return_city: True or False, if True then return exemplar city (location)
512 for the time zone
513 :return: the localized timezone name using location format
515 """
516 locale = Locale.parse(locale or LC_TIME)
518 zone = _get_tz_name(dt_or_tzinfo)
520 # Get the canonical time-zone code
521 zone = get_global('zone_aliases').get(zone, zone)
523 info = locale.time_zones.get(zone, {})
525 # Otherwise, if there is only one timezone for the country, return the
526 # localized country name
527 region_format = locale.zone_formats['region']
528 territory = get_global('zone_territories').get(zone)
529 if territory not in locale.territories:
530 territory = 'ZZ' # invalid/unknown
531 territory_name = locale.territories[territory]
532 if (
533 not return_city
534 and territory
535 and len(get_global('territory_zones').get(territory, [])) == 1
536 ):
537 return region_format % territory_name
539 # Otherwise, include the city in the output
540 fallback_format = locale.zone_formats['fallback']
541 if 'city' in info:
542 city_name = info['city']
543 else:
544 metazone = get_global('meta_zones').get(zone)
545 metazone_info = locale.meta_zones.get(metazone, {})
546 if 'city' in metazone_info:
547 city_name = metazone_info['city']
548 elif '/' in zone:
549 city_name = zone.split('/', 1)[1].replace('_', ' ')
550 else:
551 city_name = zone.replace('_', ' ')
553 if return_city:
554 return city_name
555 return region_format % (
556 fallback_format
557 % {
558 '0': city_name,
559 '1': territory_name,
560 }
561 )
564def get_timezone_name(
565 dt_or_tzinfo: _DtOrTzinfo = None,
566 width: Literal['long', 'short'] = 'long',
567 uncommon: bool = False,
568 locale: Locale | str | None = None,
569 zone_variant: Literal['generic', 'daylight', 'standard'] | None = None,
570 return_zone: bool = False,
571) -> str:
572 r"""Return the localized display name for the given timezone. The timezone
573 may be specified using a ``datetime`` or `tzinfo` object.
575 >>> from datetime import time
576 >>> dt = time(15, 30, tzinfo=get_timezone('America/Los_Angeles'))
577 >>> get_timezone_name(dt, locale='en_US') # doctest: +SKIP
578 'Pacific Standard Time'
579 >>> get_timezone_name(dt, locale='en_US', return_zone=True)
580 'America/Los_Angeles'
581 >>> get_timezone_name(dt, width='short', locale='en_US') # doctest: +SKIP
582 'PST'
584 If this function gets passed only a `tzinfo` object and no concrete
585 `datetime`, the returned display name is independent of daylight savings
586 time. This can be used for example for selecting timezones, or to set the
587 time of events that recur across DST changes:
589 >>> tz = get_timezone('America/Los_Angeles')
590 >>> get_timezone_name(tz, locale='en_US')
591 'Pacific Time'
592 >>> get_timezone_name(tz, 'short', locale='en_US')
593 'PT'
595 If no localized display name for the timezone is available, and the timezone
596 is associated with a country that uses only a single timezone, the name of
597 that country is returned, formatted according to the locale:
599 >>> tz = get_timezone('Europe/Berlin')
600 >>> get_timezone_name(tz, locale='de_DE')
601 'Mitteleuropäische Zeit'
602 >>> get_timezone_name(tz, locale='pt_BR')
603 'Horário da Europa Central'
605 On the other hand, if the country uses multiple timezones, the city is also
606 included in the representation:
608 >>> tz = get_timezone('America/St_Johns')
609 >>> get_timezone_name(tz, locale='de_DE')
610 'Neufundland-Zeit'
612 Note that short format is currently not supported for all timezones and
613 all locales. This is partially because not every timezone has a short
614 code in every locale. In that case it currently falls back to the long
615 format.
617 For more information see `LDML Appendix J: Time Zone Display Names
618 <https://www.unicode.org/reports/tr35/#Time_Zone_Fallback>`_
620 .. versionadded:: 0.9
622 .. versionchanged:: 1.0
623 Added `zone_variant` support.
625 :param dt_or_tzinfo: the ``datetime`` or ``tzinfo`` object that determines
626 the timezone; if a ``tzinfo`` object is used, the
627 resulting display name will be generic, i.e.
628 independent of daylight savings time; if `None`, the
629 current date in UTC is assumed
630 :param width: either "long" or "short"
631 :param uncommon: deprecated and ignored
632 :param zone_variant: defines the zone variation to return. By default the
633 variation is defined from the datetime object
634 passed in. If no datetime object is passed in, the
635 ``'generic'`` variation is assumed. The following
636 values are valid: ``'generic'``, ``'daylight'`` and
637 ``'standard'``.
638 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
639 :param return_zone: True or False. If true then function
640 returns long time zone ID
641 """
642 dt, tzinfo = _get_dt_and_tzinfo(dt_or_tzinfo)
643 locale = Locale.parse(locale or LC_TIME)
645 zone = _get_tz_name(dt_or_tzinfo)
647 if zone_variant is None:
648 if dt is None:
649 zone_variant = 'generic'
650 else:
651 dst = tzinfo.dst(dt)
652 zone_variant = "daylight" if dst else "standard"
653 else:
654 if zone_variant not in ('generic', 'standard', 'daylight'):
655 raise ValueError('Invalid zone variation')
657 # Get the canonical time-zone code
658 zone = get_global('zone_aliases').get(zone, zone)
659 if return_zone:
660 return zone
661 info = locale.time_zones.get(zone, {})
662 # Try explicitly translated zone names first
663 if width in info and zone_variant in info[width]:
664 value = info[width][zone_variant]
665 if value != NO_INHERITANCE_MARKER:
666 return value
668 metazone = get_global('meta_zones').get(zone)
669 if metazone:
670 metazone_info = locale.meta_zones.get(metazone, {})
671 if width in metazone_info:
672 name = metazone_info[width].get(zone_variant)
673 if width == 'short' and name == NO_INHERITANCE_MARKER:
674 # If the short form is marked no-inheritance,
675 # try to fall back to the long name instead.
676 name = metazone_info.get('long', {}).get(zone_variant)
677 if name and name != NO_INHERITANCE_MARKER:
678 return name
680 # If we have a concrete datetime, we assume that the result can't be
681 # independent of daylight savings time, so we return the GMT offset
682 if dt is not None:
683 return get_timezone_gmt(dt, width=width, locale=locale)
685 return get_timezone_location(dt_or_tzinfo, locale=locale)
688def format_date(
689 date: datetime.date | None = None,
690 format: _PredefinedTimeFormat | str = 'medium',
691 locale: Locale | str | None = None,
692) -> str:
693 """Return a date formatted according to the given pattern.
695 >>> from datetime import date
696 >>> d = date(2007, 4, 1)
697 >>> format_date(d, locale='en_US')
698 'Apr 1, 2007'
699 >>> format_date(d, format='full', locale='de_DE')
700 'Sonntag, 1. April 2007'
702 If you don't want to use the locale default formats, you can specify a
703 custom date pattern:
705 >>> format_date(d, "EEE, MMM d, ''yy", locale='en')
706 "Sun, Apr 1, '07"
708 :param date: the ``date`` or ``datetime`` object; if `None`, the current
709 date is used
710 :param format: one of "full", "long", "medium", or "short", or a custom
711 date/time pattern
712 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
713 """
714 if date is None:
715 date = datetime.date.today()
716 elif isinstance(date, datetime.datetime):
717 date = date.date()
719 locale = Locale.parse(locale or LC_TIME)
720 if format in ('full', 'long', 'medium', 'short'):
721 format = get_date_format(format, locale=locale)
722 pattern = parse_pattern(format)
723 return pattern.apply(date, locale)
726def format_datetime(
727 datetime: _Instant = None,
728 format: _PredefinedTimeFormat | str = 'medium',
729 tzinfo: datetime.tzinfo | None = None,
730 locale: Locale | str | None = None,
731) -> str:
732 r"""Return a date formatted according to the given pattern.
734 >>> from datetime import datetime
735 >>> dt = datetime(2007, 4, 1, 15, 30)
736 >>> format_datetime(dt, locale='en_US')
737 'Apr 1, 2007, 3:30:00\u202fPM'
739 For any pattern requiring the display of the timezone:
741 >>> format_datetime(dt, 'full', tzinfo=get_timezone('Europe/Paris'),
742 ... locale='fr_FR')
743 'dimanche 1 avril 2007, 17:30:00 heure d’été d’Europe centrale'
744 >>> format_datetime(dt, "yyyy.MM.dd G 'at' HH:mm:ss zzz",
745 ... tzinfo=get_timezone('US/Eastern'), locale='en')
746 '2007.04.01 AD at 11:30:00 EDT'
748 :param datetime: the `datetime` object; if `None`, the current date and
749 time is used
750 :param format: one of "full", "long", "medium", or "short", or a custom
751 date/time pattern
752 :param tzinfo: the timezone to apply to the time for display
753 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
754 """
755 datetime = _ensure_datetime_tzinfo(_get_datetime(datetime), tzinfo)
757 locale = Locale.parse(locale or LC_TIME)
758 if format in ('full', 'long', 'medium', 'short'):
759 return (
760 get_datetime_format(format, locale=locale)
761 .replace("'", "")
762 .replace('{0}', format_time(datetime, format, tzinfo=None, locale=locale))
763 .replace('{1}', format_date(datetime, format, locale=locale))
764 )
765 else:
766 return parse_pattern(format).apply(datetime, locale)
769def format_time(
770 time: datetime.time | datetime.datetime | float | None = None,
771 format: _PredefinedTimeFormat | str = 'medium',
772 tzinfo: datetime.tzinfo | None = None,
773 locale: Locale | str | None = None,
774) -> str:
775 r"""Return a time formatted according to the given pattern.
777 >>> from datetime import datetime, time
778 >>> t = time(15, 30)
779 >>> format_time(t, locale='en_US')
780 '3:30:00\u202fPM'
781 >>> format_time(t, format='short', locale='de_DE')
782 '15:30'
784 If you don't want to use the locale default formats, you can specify a
785 custom time pattern:
787 >>> format_time(t, "hh 'o''clock' a", locale='en')
788 "03 o'clock PM"
790 For any pattern requiring the display of the time-zone a
791 timezone has to be specified explicitly:
793 >>> t = datetime(2007, 4, 1, 15, 30)
794 >>> tzinfo = get_timezone('Europe/Paris')
795 >>> t = _localize(tzinfo, t)
796 >>> format_time(t, format='full', tzinfo=tzinfo, locale='fr_FR')
797 '15:30:00 heure d’été d’Europe centrale'
798 >>> format_time(t, "hh 'o''clock' a, zzzz", tzinfo=get_timezone('US/Eastern'),
799 ... locale='en')
800 "09 o'clock AM, Eastern Daylight Time"
802 As that example shows, when this function gets passed a
803 ``datetime.datetime`` value, the actual time in the formatted string is
804 adjusted to the timezone specified by the `tzinfo` parameter. If the
805 ``datetime`` is "naive" (i.e. it has no associated timezone information),
806 it is assumed to be in UTC.
808 These timezone calculations are **not** performed if the value is of type
809 ``datetime.time``, as without date information there's no way to determine
810 what a given time would translate to in a different timezone without
811 information about whether daylight savings time is in effect or not. This
812 means that time values are left as-is, and the value of the `tzinfo`
813 parameter is only used to display the timezone name if needed:
815 >>> t = time(15, 30)
816 >>> format_time(t, format='full', tzinfo=get_timezone('Europe/Paris'),
817 ... locale='fr_FR') # doctest: +SKIP
818 '15:30:00 heure normale d\u2019Europe centrale'
819 >>> format_time(t, format='full', tzinfo=get_timezone('US/Eastern'),
820 ... locale='en_US') # doctest: +SKIP
821 '3:30:00\u202fPM Eastern Standard Time'
823 :param time: the ``time`` or ``datetime`` object; if `None`, the current
824 time in UTC is used
825 :param format: one of "full", "long", "medium", or "short", or a custom
826 date/time pattern
827 :param tzinfo: the time-zone to apply to the time for display
828 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
829 """
831 # get reference date for if we need to find the right timezone variant
832 # in the pattern
833 ref_date = time.date() if isinstance(time, datetime.datetime) else None
835 time = _get_time(time, tzinfo)
837 locale = Locale.parse(locale or LC_TIME)
838 if format in ('full', 'long', 'medium', 'short'):
839 format = get_time_format(format, locale=locale)
840 return parse_pattern(format).apply(time, locale, reference_date=ref_date)
843def format_skeleton(
844 skeleton: str,
845 datetime: _Instant = None,
846 tzinfo: datetime.tzinfo | None = None,
847 fuzzy: bool = True,
848 locale: Locale | str | None = None,
849) -> str:
850 r"""Return a time and/or date formatted according to the given pattern.
852 The skeletons are defined in the CLDR data and provide more flexibility
853 than the simple short/long/medium formats, but are a bit harder to use.
854 The are defined using the date/time symbols without order or punctuation
855 and map to a suitable format for the given locale.
857 >>> from datetime import datetime
858 >>> t = datetime(2007, 4, 1, 15, 30)
859 >>> format_skeleton('MMMEd', t, locale='fr')
860 'dim. 1 avr.'
861 >>> format_skeleton('MMMEd', t, locale='en')
862 'Sun, Apr 1'
863 >>> format_skeleton('yMMd', t, locale='fi') # yMMd is not in the Finnish locale; yMd gets used
864 '1.4.2007'
865 >>> format_skeleton('yMMd', t, fuzzy=False, locale='fi') # yMMd is not in the Finnish locale, an error is thrown
866 Traceback (most recent call last):
867 ...
868 KeyError: yMMd
869 >>> 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
870 Traceback (most recent call last):
871 ...
872 KeyError: None
874 After the skeleton is resolved to a pattern `format_datetime` is called so
875 all timezone processing etc is the same as for that.
877 :param skeleton: A date time skeleton as defined in the cldr data.
878 :param datetime: the ``time`` or ``datetime`` object; if `None`, the current
879 time in UTC is used
880 :param tzinfo: the time-zone to apply to the time for display
881 :param fuzzy: If the skeleton is not found, allow choosing a skeleton that's
882 close enough to it. If there is no close match, a `KeyError`
883 is thrown.
884 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
885 """
886 locale = Locale.parse(locale or LC_TIME)
887 if fuzzy and skeleton not in locale.datetime_skeletons:
888 skeleton = match_skeleton(skeleton, locale.datetime_skeletons)
889 format = locale.datetime_skeletons[skeleton]
890 return format_datetime(datetime, format, tzinfo, locale)
893TIMEDELTA_UNITS: tuple[tuple[str, int], ...] = (
894 ('year', 3600 * 24 * 365),
895 ('month', 3600 * 24 * 30),
896 ('week', 3600 * 24 * 7),
897 ('day', 3600 * 24),
898 ('hour', 3600),
899 ('minute', 60),
900 ('second', 1),
901)
904def format_timedelta(
905 delta: datetime.timedelta | int,
906 granularity: Literal[
907 'year',
908 'month',
909 'week',
910 'day',
911 'hour',
912 'minute',
913 'second',
914 ] = 'second',
915 threshold: float = 0.85,
916 add_direction: bool = False,
917 format: Literal['narrow', 'short', 'medium', 'long'] = 'long',
918 locale: Locale | str | None = None,
919) -> str:
920 """Return a time delta according to the rules of the given locale.
922 >>> from datetime import timedelta
923 >>> format_timedelta(timedelta(weeks=12), locale='en_US')
924 '3 months'
925 >>> format_timedelta(timedelta(seconds=1), locale='es')
926 '1 segundo'
928 The granularity parameter can be provided to alter the lowest unit
929 presented, which defaults to a second.
931 >>> format_timedelta(timedelta(hours=3), granularity='day', locale='en_US')
932 '1 day'
934 The threshold parameter can be used to determine at which value the
935 presentation switches to the next higher unit. A higher threshold factor
936 means the presentation will switch later. For example:
938 >>> format_timedelta(timedelta(hours=23), threshold=0.9, locale='en_US')
939 '1 day'
940 >>> format_timedelta(timedelta(hours=23), threshold=1.1, locale='en_US')
941 '23 hours'
943 In addition directional information can be provided that informs
944 the user if the date is in the past or in the future:
946 >>> format_timedelta(timedelta(hours=1), add_direction=True, locale='en')
947 'in 1 hour'
948 >>> format_timedelta(timedelta(hours=-1), add_direction=True, locale='en')
949 '1 hour ago'
951 The format parameter controls how compact or wide the presentation is:
953 >>> format_timedelta(timedelta(hours=3), format='short', locale='en')
954 '3 hr'
955 >>> format_timedelta(timedelta(hours=3), format='narrow', locale='en')
956 '3h'
958 :param delta: a ``timedelta`` object representing the time difference to
959 format, or the delta in seconds as an `int` value
960 :param granularity: determines the smallest unit that should be displayed,
961 the value can be one of "year", "month", "week", "day",
962 "hour", "minute" or "second"
963 :param threshold: factor that determines at which point the presentation
964 switches to the next higher unit
965 :param add_direction: if this flag is set to `True` the return value will
966 include directional information. For instance a
967 positive timedelta will include the information about
968 it being in the future, a negative will be information
969 about the value being in the past.
970 :param format: the format, can be "narrow", "short" or "long". (
971 "medium" is deprecated, currently converted to "long" to
972 maintain compatibility)
973 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
974 """
975 if format not in ('narrow', 'short', 'medium', 'long'):
976 raise TypeError('Format must be one of "narrow", "short" or "long"')
977 if format == 'medium':
978 warnings.warn(
979 '"medium" value for format param of format_timedelta is deprecated. Use "long" instead',
980 category=DeprecationWarning,
981 stacklevel=2,
982 )
983 format = 'long'
984 if isinstance(delta, datetime.timedelta):
985 seconds = int((delta.days * 86400) + delta.seconds)
986 else:
987 seconds = delta
988 locale = Locale.parse(locale or LC_TIME)
989 date_fields = locale._data["date_fields"]
990 unit_patterns = locale._data["unit_patterns"]
992 def _iter_patterns(a_unit):
993 if add_direction:
994 # Try to find the length variant version first ("year-narrow")
995 # before falling back to the default.
996 unit_rel_patterns = date_fields.get(f"{a_unit}-{format}") or date_fields.get(a_unit) or {}
997 if seconds >= 0:
998 yield unit_rel_patterns['future']
999 else:
1000 yield unit_rel_patterns['past']
1001 a_unit = f"duration-{a_unit}"
1002 yield unit_patterns.get(a_unit, {}).get(format) # resolves aliases
1004 for unit, secs_per_unit in TIMEDELTA_UNITS:
1005 value = abs(seconds) / secs_per_unit
1006 if value >= threshold or unit == granularity:
1007 if unit == granularity and value > 0:
1008 value = max(1, value)
1009 value = int(round(value))
1010 plural_form = locale.plural_form(value)
1011 pattern = None
1012 for patterns in _iter_patterns(unit):
1013 if patterns is not None:
1014 pattern = patterns.get(plural_form) or patterns.get('other')
1015 if pattern:
1016 break
1017 # This really should not happen
1018 if pattern is None:
1019 return ''
1020 return pattern.replace('{0}', str(value))
1022 return ''
1025def _format_fallback_interval(
1026 start: _Instant,
1027 end: _Instant,
1028 skeleton: str | None,
1029 tzinfo: datetime.tzinfo | None,
1030 locale: Locale,
1031) -> str:
1032 if skeleton in locale.datetime_skeletons: # Use the given skeleton
1033 format = lambda dt: format_skeleton(skeleton, dt, tzinfo, locale=locale)
1034 elif all(
1035 # Both are just dates
1036 (isinstance(d, datetime.date) and not isinstance(d, datetime.datetime))
1037 for d in (start, end)
1038 ):
1039 format = lambda dt: format_date(dt, locale=locale)
1040 elif all(
1041 # Both are times
1042 (isinstance(d, datetime.time) and not isinstance(d, datetime.date))
1043 for d in (start, end)
1044 ):
1045 format = lambda dt: format_time(dt, tzinfo=tzinfo, locale=locale)
1046 else:
1047 format = lambda dt: format_datetime(dt, tzinfo=tzinfo, locale=locale)
1049 formatted_start = format(start)
1050 formatted_end = format(end)
1052 if formatted_start == formatted_end:
1053 return format(start)
1055 return (
1056 locale.interval_formats.get(None, "{0}-{1}")
1057 .replace("{0}", formatted_start)
1058 .replace("{1}", formatted_end)
1059 )
1062def format_interval(
1063 start: _Instant,
1064 end: _Instant,
1065 skeleton: str | None = None,
1066 tzinfo: datetime.tzinfo | None = None,
1067 fuzzy: bool = True,
1068 locale: Locale | str | None = None,
1069) -> str:
1070 """
1071 Format an interval between two instants according to the locale's rules.
1073 >>> from datetime import date, time
1074 >>> format_interval(date(2016, 1, 15), date(2016, 1, 17), "yMd", locale="fi")
1075 '15.–17.1.2016'
1077 >>> format_interval(time(12, 12), time(16, 16), "Hm", locale="en_GB")
1078 '12:12–16:16'
1080 >>> format_interval(time(5, 12), time(16, 16), "hm", locale="en_US")
1081 '5:12\\u202fAM\\u2009–\\u20094:16\\u202fPM'
1083 >>> format_interval(time(16, 18), time(16, 24), "Hm", locale="it")
1084 '16:18–16:24'
1086 If the start instant equals the end instant, the interval is formatted like the instant.
1088 >>> format_interval(time(16, 18), time(16, 18), "Hm", locale="it")
1089 '16:18'
1091 Unknown skeletons fall back to "default" formatting.
1093 >>> format_interval(date(2015, 1, 1), date(2017, 1, 1), "wzq", locale="ja")
1094 '2015/01/01~2017/01/01'
1096 >>> format_interval(time(16, 18), time(16, 24), "xxx", locale="ja")
1097 '16:18:00~16:24:00'
1099 >>> format_interval(date(2016, 1, 15), date(2016, 1, 17), "xxx", locale="de")
1100 '15.01.2016\\u2009–\\u200917.01.2016'
1102 :param start: First instant (datetime/date/time)
1103 :param end: Second instant (datetime/date/time)
1104 :param skeleton: The "skeleton format" to use for formatting.
1105 :param tzinfo: tzinfo to use (if none is already attached)
1106 :param fuzzy: If the skeleton is not found, allow choosing a skeleton that's
1107 close enough to it.
1108 :param locale: A locale object or identifier. Defaults to the system time locale.
1109 :return: Formatted interval
1110 """
1111 locale = Locale.parse(locale or LC_TIME)
1113 # NB: The quote comments below are from the algorithm description in
1114 # https://www.unicode.org/reports/tr35/tr35-dates.html#intervalFormats
1116 # > Look for the intervalFormatItem element that matches the "skeleton",
1117 # > starting in the current locale and then following the locale fallback
1118 # > chain up to, but not including root.
1120 interval_formats = locale.interval_formats
1122 if skeleton not in interval_formats or not skeleton:
1123 # > If no match was found from the previous step, check what the closest
1124 # > match is in the fallback locale chain, as in availableFormats. That
1125 # > is, this allows for adjusting the string value field's width,
1126 # > including adjusting between "MMM" and "MMMM", and using different
1127 # > variants of the same field, such as 'v' and 'z'.
1128 if skeleton and fuzzy:
1129 skeleton = match_skeleton(skeleton, interval_formats)
1130 else:
1131 skeleton = None
1132 if not skeleton: # Still no match whatsoever?
1133 # > Otherwise, format the start and end datetime using the fallback pattern.
1134 return _format_fallback_interval(start, end, skeleton, tzinfo, locale)
1136 skel_formats = interval_formats[skeleton]
1138 if start == end:
1139 return format_skeleton(skeleton, start, tzinfo, fuzzy=fuzzy, locale=locale)
1141 start = _ensure_datetime_tzinfo(_get_datetime(start), tzinfo=tzinfo)
1142 end = _ensure_datetime_tzinfo(_get_datetime(end), tzinfo=tzinfo)
1144 start_fmt = DateTimeFormat(start, locale=locale)
1145 end_fmt = DateTimeFormat(end, locale=locale)
1147 # > If a match is found from previous steps, compute the calendar field
1148 # > with the greatest difference between start and end datetime. If there
1149 # > is no difference among any of the fields in the pattern, format as a
1150 # > single date using availableFormats, and return.
1152 for field in PATTERN_CHAR_ORDER: # These are in largest-to-smallest order
1153 if field in skel_formats and start_fmt.extract(field) != end_fmt.extract(field):
1154 # > If there is a match, use the pieces of the corresponding pattern to
1155 # > format the start and end datetime, as above.
1156 return "".join(
1157 parse_pattern(pattern).apply(instant, locale)
1158 for pattern, instant in zip(skel_formats[field], (start, end))
1159 )
1161 # > Otherwise, format the start and end datetime using the fallback pattern.
1163 return _format_fallback_interval(start, end, skeleton, tzinfo, locale)
1166def get_period_id(
1167 time: _Instant,
1168 tzinfo: datetime.tzinfo | None = None,
1169 type: Literal['selection'] | None = None,
1170 locale: Locale | str | None = None,
1171) -> str:
1172 """
1173 Get the day period ID for a given time.
1175 This ID can be used as a key for the period name dictionary.
1177 >>> from datetime import time
1178 >>> get_period_names(locale="de")[get_period_id(time(7, 42), locale="de")]
1179 'Morgen'
1181 >>> get_period_id(time(0), locale="en_US")
1182 'midnight'
1184 >>> get_period_id(time(0), type="selection", locale="en_US")
1185 'morning1'
1187 :param time: The time to inspect.
1188 :param tzinfo: The timezone for the time. See ``format_time``.
1189 :param type: The period type to use. Either "selection" or None.
1190 The selection type is used for selecting among phrases such as
1191 “Your email arrived yesterday evening” or “Your email arrived last night”.
1192 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale.
1193 :return: period ID. Something is always returned -- even if it's just "am" or "pm".
1194 """
1195 time = _get_time(time, tzinfo)
1196 seconds_past_midnight = int(time.hour * 60 * 60 + time.minute * 60 + time.second)
1197 locale = Locale.parse(locale or LC_TIME)
1199 # The LDML rules state that the rules may not overlap, so iterating in arbitrary
1200 # order should be alright, though `at` periods should be preferred.
1201 rulesets = locale.day_period_rules.get(type, {}).items()
1203 for rule_id, rules in rulesets:
1204 for rule in rules:
1205 if "at" in rule and rule["at"] == seconds_past_midnight:
1206 return rule_id
1208 for rule_id, rules in rulesets:
1209 for rule in rules:
1210 if "from" in rule and "before" in rule:
1211 if rule["from"] < rule["before"]:
1212 if rule["from"] <= seconds_past_midnight < rule["before"]:
1213 return rule_id
1214 else:
1215 # e.g. from="21:00" before="06:00"
1216 if (
1217 rule["from"] <= seconds_past_midnight < 86400
1218 or 0 <= seconds_past_midnight < rule["before"]
1219 ):
1220 return rule_id
1222 start_ok = end_ok = False
1224 if "from" in rule and seconds_past_midnight >= rule["from"]:
1225 start_ok = True
1226 if "to" in rule and seconds_past_midnight <= rule["to"]:
1227 # This rule type does not exist in the present CLDR data;
1228 # excuse the lack of test coverage.
1229 end_ok = True
1230 if "before" in rule and seconds_past_midnight < rule["before"]:
1231 end_ok = True
1232 if "after" in rule:
1233 raise NotImplementedError("'after' is deprecated as of CLDR 29.")
1235 if start_ok and end_ok:
1236 return rule_id
1238 if seconds_past_midnight < 43200:
1239 return "am"
1240 else:
1241 return "pm"
1244class ParseError(ValueError):
1245 pass
1248def parse_date(
1249 string: str,
1250 locale: Locale | str | None = None,
1251 format: _PredefinedTimeFormat | str = 'medium',
1252) -> datetime.date:
1253 """Parse a date from a string.
1255 If an explicit format is provided, it is used to parse the date.
1257 >>> parse_date('01.04.2004', format='dd.MM.yyyy')
1258 datetime.date(2004, 4, 1)
1260 If no format is given, or if it is one of "full", "long", "medium",
1261 or "short", the function first tries to interpret the string as
1262 ISO-8601 date format and then uses the date format for the locale
1263 as a hint to determine the order in which the date fields appear in
1264 the string.
1266 >>> parse_date('4/1/04', locale='en_US')
1267 datetime.date(2004, 4, 1)
1268 >>> parse_date('01.04.2004', locale='de_DE')
1269 datetime.date(2004, 4, 1)
1270 >>> parse_date('2004-04-01', locale='en_US')
1271 datetime.date(2004, 4, 1)
1272 >>> parse_date('2004-04-01', locale='de_DE')
1273 datetime.date(2004, 4, 1)
1274 >>> parse_date('01.04.04', locale='de_DE', format='short')
1275 datetime.date(2004, 4, 1)
1277 :param string: the string containing the date
1278 :param locale: a `Locale` object or a locale identifier
1279 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
1280 :param format: the format to use, either an explicit date format,
1281 or one of "full", "long", "medium", or "short"
1282 (see ``get_time_format``)
1283 """
1284 numbers = re.findall(r'(\d+)', string)
1285 if not numbers:
1286 raise ParseError("No numbers were found in input")
1288 use_predefined_format = format in ('full', 'long', 'medium', 'short')
1289 # we try ISO-8601 format first, meaning similar to formats
1290 # extended YYYY-MM-DD or basic YYYYMMDD
1291 iso_alike = re.match(
1292 r'^(\d{4})-?([01]\d)-?([0-3]\d)$',
1293 string,
1294 flags=re.ASCII, # allow only ASCII digits
1295 )
1296 if iso_alike and use_predefined_format:
1297 try:
1298 return datetime.date(*map(int, iso_alike.groups()))
1299 except ValueError:
1300 pass # a locale format might fit better, so let's continue
1302 if use_predefined_format:
1303 fmt = get_date_format(format=format, locale=locale)
1304 else:
1305 fmt = parse_pattern(format)
1306 format_str = fmt.pattern.lower()
1307 year_idx = format_str.index('y')
1308 month_idx = format_str.find('m')
1309 if month_idx < 0:
1310 month_idx = format_str.index('l')
1311 day_idx = format_str.index('d')
1313 indexes = sorted([(year_idx, 'Y'), (month_idx, 'M'), (day_idx, 'D')])
1314 indexes = {item[1]: idx for idx, item in enumerate(indexes)}
1316 # FIXME: this currently only supports numbers, but should also support month
1317 # names, both in the requested locale, and english
1319 year = numbers[indexes['Y']]
1320 year = 2000 + int(year) if len(year) == 2 else int(year)
1321 month = int(numbers[indexes['M']])
1322 day = int(numbers[indexes['D']])
1323 if month > 12:
1324 month, day = day, month
1325 return datetime.date(year, month, day)
1328def parse_time(
1329 string: str,
1330 locale: Locale | str | None = None,
1331 format: _PredefinedTimeFormat | str = 'medium',
1332) -> datetime.time:
1333 """Parse a time from a string.
1335 This function uses the time format for the locale as a hint to determine
1336 the order in which the time fields appear in the string.
1338 If an explicit format is provided, the function will use it to parse
1339 the time instead.
1341 >>> parse_time('15:30:00', locale='en_US')
1342 datetime.time(15, 30)
1343 >>> parse_time('15:30:00', format='H:mm:ss')
1344 datetime.time(15, 30)
1346 :param string: the string containing the time
1347 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale.
1348 :param format: the format to use, either an explicit time format,
1349 or one of "full", "long", "medium", or "short"
1350 (see ``get_time_format``)
1351 :return: the parsed time
1352 :rtype: `time`
1353 """
1354 numbers = re.findall(r'(\d+)', string)
1355 if not numbers:
1356 raise ParseError("No numbers were found in input")
1358 # TODO: try ISO format first?
1359 if format in ('full', 'long', 'medium', 'short'):
1360 fmt = get_time_format(format=format, locale=locale)
1361 else:
1362 fmt = parse_pattern(format)
1363 format_str = fmt.pattern.lower()
1364 hour_idx = format_str.find('h')
1365 if hour_idx < 0:
1366 hour_idx = format_str.index('k')
1367 min_idx = format_str.index('m')
1368 # format might not contain seconds
1369 if (sec_idx := format_str.find('s')) < 0:
1370 sec_idx = math.inf
1372 indexes = sorted([(hour_idx, 'H'), (min_idx, 'M'), (sec_idx, 'S')])
1373 indexes = {item[1]: idx for idx, item in enumerate(indexes)}
1375 # TODO: support time zones
1377 # Check if the format specifies a period to be used;
1378 # if it does, look for 'pm' to figure out an offset.
1379 hour_offset = 0
1380 if 'a' in format_str and 'pm' in string.lower():
1381 hour_offset = 12
1383 # Parse up to three numbers from the string.
1384 minute = second = 0
1385 hour = int(numbers[indexes['H']]) + hour_offset
1386 if len(numbers) > 1:
1387 minute = int(numbers[indexes['M']])
1388 if len(numbers) > 2:
1389 second = int(numbers[indexes['S']])
1390 return datetime.time(hour, minute, second)
1393class DateTimePattern:
1394 def __init__(self, pattern: str, format: DateTimeFormat):
1395 self.pattern = pattern
1396 self.format = format
1398 def __repr__(self) -> str:
1399 return f"<{type(self).__name__} {self.pattern!r}>"
1401 def __str__(self) -> str:
1402 pat = self.pattern
1403 return pat
1405 def __mod__(self, other: DateTimeFormat) -> str:
1406 if not isinstance(other, DateTimeFormat):
1407 return NotImplemented
1408 return self.format % other
1410 def apply(
1411 self,
1412 datetime: datetime.date | datetime.time,
1413 locale: Locale | str | None,
1414 reference_date: datetime.date | None = None,
1415 ) -> str:
1416 return self % DateTimeFormat(datetime, locale, reference_date)
1419class DateTimeFormat:
1420 def __init__(
1421 self,
1422 value: datetime.date | datetime.time,
1423 locale: Locale | str,
1424 reference_date: datetime.date | None = None,
1425 ) -> None:
1426 assert isinstance(value, (datetime.date, datetime.datetime, datetime.time))
1427 if isinstance(value, (datetime.datetime, datetime.time)) and value.tzinfo is None:
1428 value = value.replace(tzinfo=UTC)
1429 self.value = value
1430 self.locale = Locale.parse(locale)
1431 self.reference_date = reference_date
1433 def __getitem__(self, name: str) -> str:
1434 char = name[0]
1435 num = len(name)
1436 if char == 'G':
1437 return self.format_era(char, num)
1438 elif char in ('y', 'Y', 'u'):
1439 return self.format_year(char, num)
1440 elif char in ('Q', 'q'):
1441 return self.format_quarter(char, num)
1442 elif char in ('M', 'L'):
1443 return self.format_month(char, num)
1444 elif char in ('w', 'W'):
1445 return self.format_week(char, num)
1446 elif char == 'd':
1447 return self.format(self.value.day, num)
1448 elif char == 'D':
1449 return self.format_day_of_year(num)
1450 elif char == 'F':
1451 return self.format_day_of_week_in_month()
1452 elif char in ('E', 'e', 'c'):
1453 return self.format_weekday(char, num)
1454 elif char in ('a', 'b', 'B'):
1455 return self.format_period(char, num)
1456 elif char == 'h':
1457 if self.value.hour % 12 == 0:
1458 return self.format(12, num)
1459 else:
1460 return self.format(self.value.hour % 12, num)
1461 elif char == 'H':
1462 return self.format(self.value.hour, num)
1463 elif char == 'K':
1464 return self.format(self.value.hour % 12, num)
1465 elif char == 'k':
1466 if self.value.hour == 0:
1467 return self.format(24, num)
1468 else:
1469 return self.format(self.value.hour, num)
1470 elif char == 'm':
1471 return self.format(self.value.minute, num)
1472 elif char == 's':
1473 return self.format(self.value.second, num)
1474 elif char == 'S':
1475 return self.format_frac_seconds(num)
1476 elif char == 'A':
1477 return self.format_milliseconds_in_day(num)
1478 elif char in ('z', 'Z', 'v', 'V', 'x', 'X', 'O'):
1479 return self.format_timezone(char, num)
1480 else:
1481 raise KeyError(f"Unsupported date/time field {char!r}")
1483 def extract(self, char: str) -> int:
1484 char = str(char)[0]
1485 if char == 'y':
1486 return self.value.year
1487 elif char == 'M':
1488 return self.value.month
1489 elif char == 'd':
1490 return self.value.day
1491 elif char == 'H':
1492 return self.value.hour
1493 elif char == 'h':
1494 return self.value.hour % 12 or 12
1495 elif char == 'm':
1496 return self.value.minute
1497 elif char == 'a':
1498 return int(self.value.hour >= 12) # 0 for am, 1 for pm
1499 else:
1500 raise NotImplementedError(
1501 f"Not implemented: extracting {char!r} from {self.value!r}",
1502 )
1504 def format_era(self, char: str, num: int) -> str:
1505 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[max(3, num)]
1506 era = int(self.value.year >= 0)
1507 return get_era_names(width, self.locale)[era]
1509 def format_year(self, char: str, num: int) -> str:
1510 value = self.value.year
1511 if char.isupper():
1512 month = self.value.month
1513 if month == 1 and self.value.day < 7 and self.get_week_of_year() >= 52:
1514 value -= 1
1515 elif month == 12 and self.value.day > 25 and self.get_week_of_year() <= 2:
1516 value += 1
1517 year = self.format(value, num)
1518 if num == 2:
1519 year = year[-2:]
1520 return year
1522 def format_quarter(self, char: str, num: int) -> str:
1523 quarter = (self.value.month - 1) // 3 + 1
1524 if num <= 2:
1525 return '%0*d' % (num, quarter)
1526 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[num]
1527 context = {'Q': 'format', 'q': 'stand-alone'}[char]
1528 return get_quarter_names(width, context, self.locale)[quarter]
1530 def format_month(self, char: str, num: int) -> str:
1531 if num <= 2:
1532 return '%0*d' % (num, self.value.month)
1533 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[num]
1534 context = {'M': 'format', 'L': 'stand-alone'}[char]
1535 return get_month_names(width, context, self.locale)[self.value.month]
1537 def format_week(self, char: str, num: int) -> str:
1538 if char.islower(): # week of year
1539 week = self.get_week_of_year()
1540 return self.format(week, num)
1541 else: # week of month
1542 week = self.get_week_of_month()
1543 return str(week)
1545 def format_weekday(self, char: str = 'E', num: int = 4) -> str:
1546 """
1547 Return weekday from parsed datetime according to format pattern.
1549 >>> from datetime import date
1550 >>> format = DateTimeFormat(date(2016, 2, 28), Locale.parse('en_US'))
1551 >>> format.format_weekday()
1552 'Sunday'
1554 'E': Day of week - Use one through three letters for the abbreviated day name, four for the full (wide) name,
1555 five for the narrow name, or six for the short name.
1556 >>> format.format_weekday('E',2)
1557 'Sun'
1559 'e': Local day of week. Same as E except adds a numeric value that will depend on the local starting day of the
1560 week, using one or two letters. For this example, Monday is the first day of the week.
1561 >>> format.format_weekday('e',2)
1562 '01'
1564 'c': Stand-Alone local day of week - Use one letter for the local numeric value (same as 'e'), three for the
1565 abbreviated day name, four for the full (wide) name, five for the narrow name, or six for the short name.
1566 >>> format.format_weekday('c',1)
1567 '1'
1569 :param char: pattern format character ('e','E','c')
1570 :param num: count of format character
1572 """
1573 if num < 3:
1574 if char.islower():
1575 value = 7 - self.locale.first_week_day + self.value.weekday()
1576 return self.format(value % 7 + 1, num)
1577 num = 3
1578 weekday = self.value.weekday()
1579 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow', 6: 'short'}[num]
1580 context = "stand-alone" if char == "c" else "format"
1581 return get_day_names(width, context, self.locale)[weekday]
1583 def format_day_of_year(self, num: int) -> str:
1584 return self.format(self.get_day_of_year(), num)
1586 def format_day_of_week_in_month(self) -> str:
1587 return str((self.value.day - 1) // 7 + 1)
1589 def format_period(self, char: str, num: int) -> str:
1590 """
1591 Return period from parsed datetime according to format pattern.
1593 >>> from datetime import datetime, time
1594 >>> format = DateTimeFormat(time(13, 42), 'fi_FI')
1595 >>> format.format_period('a', 1)
1596 'ip.'
1597 >>> format.format_period('b', 1)
1598 'iltap.'
1599 >>> format.format_period('b', 4)
1600 'iltapäivä'
1601 >>> format.format_period('B', 4)
1602 'iltapäivällä'
1603 >>> format.format_period('B', 5)
1604 'ip.'
1606 >>> format = DateTimeFormat(datetime(2022, 4, 28, 6, 27), 'zh_Hant')
1607 >>> format.format_period('a', 1)
1608 '上午'
1609 >>> format.format_period('B', 1)
1610 '清晨'
1612 :param char: pattern format character ('a', 'b', 'B')
1613 :param num: count of format character
1615 """
1616 widths = [
1617 {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[max(3, num)],
1618 'wide',
1619 'narrow',
1620 'abbreviated',
1621 ]
1622 if char == 'a':
1623 period = 'pm' if self.value.hour >= 12 else 'am'
1624 context = 'format'
1625 else:
1626 period = get_period_id(self.value, locale=self.locale)
1627 context = 'format' if char == 'B' else 'stand-alone'
1628 for width in widths:
1629 period_names = get_period_names(context=context, width=width, locale=self.locale)
1630 if period in period_names:
1631 return period_names[period]
1632 raise ValueError(f"Could not format period {period} in {self.locale}")
1634 def format_frac_seconds(self, num: int) -> str:
1635 """ Return fractional seconds.
1637 Rounds the time's microseconds to the precision given by the number \
1638 of digits passed in.
1639 """
1640 value = self.value.microsecond / 1000000
1641 return self.format(round(value, num) * 10**num, num)
1643 def format_milliseconds_in_day(self, num):
1644 msecs = (
1645 self.value.microsecond // 1000
1646 + self.value.second * 1000
1647 + self.value.minute * 60000
1648 + self.value.hour * 3600000
1649 )
1650 return self.format(msecs, num)
1652 def format_timezone(self, char: str, num: int) -> str:
1653 width = {3: 'short', 4: 'long', 5: 'iso8601'}[max(3, num)]
1655 # It could be that we only receive a time to format, but also have a
1656 # reference date which is important to distinguish between timezone
1657 # variants (summer/standard time)
1658 value = self.value
1659 if self.reference_date:
1660 value = datetime.datetime.combine(self.reference_date, self.value)
1662 if char == 'z':
1663 return get_timezone_name(value, width, locale=self.locale)
1664 elif char == 'Z':
1665 if num == 5:
1666 return get_timezone_gmt(value, width, locale=self.locale, return_z=True)
1667 return get_timezone_gmt(value, width, locale=self.locale)
1668 elif char == 'O':
1669 if num == 4:
1670 return get_timezone_gmt(value, width, locale=self.locale)
1671 # TODO: To add support for O:1
1672 elif char == 'v':
1673 return get_timezone_name(value.tzinfo, width, locale=self.locale)
1674 elif char == 'V':
1675 if num == 1:
1676 return get_timezone_name(value.tzinfo, width, locale=self.locale)
1677 elif num == 2:
1678 return get_timezone_name(value.tzinfo, locale=self.locale, return_zone=True)
1679 elif num == 3:
1680 return get_timezone_location(value.tzinfo, locale=self.locale, return_city=True) # fmt: skip
1681 return get_timezone_location(value.tzinfo, locale=self.locale)
1682 elif char in 'Xx':
1683 return_z = char == 'X'
1684 if num == 1:
1685 width = 'iso8601_short'
1686 elif num in (2, 4):
1687 width = 'short'
1688 elif num in (3, 5):
1689 width = 'iso8601'
1690 return get_timezone_gmt(value, width=width, locale=self.locale, return_z=return_z) # fmt: skip
1692 def format(self, value: SupportsInt, length: int) -> str:
1693 return '%0*d' % (length, value)
1695 def get_day_of_year(self, date: datetime.date | None = None) -> int:
1696 if date is None:
1697 date = self.value
1698 return (date - date.replace(month=1, day=1)).days + 1
1700 def get_week_of_year(self) -> int:
1701 """Return the week of the year."""
1702 day_of_year = self.get_day_of_year(self.value)
1703 week = self.get_week_number(day_of_year)
1704 if week == 0:
1705 date = datetime.date(self.value.year - 1, 12, 31)
1706 week = self.get_week_number(self.get_day_of_year(date), date.weekday())
1707 elif week > 52:
1708 weekday = datetime.date(self.value.year + 1, 1, 1).weekday()
1709 if (
1710 self.get_week_number(1, weekday) == 1
1711 and 32 - (weekday - self.locale.first_week_day) % 7 <= self.value.day
1712 ):
1713 week = 1
1714 return week
1716 def get_week_of_month(self) -> int:
1717 """Return the week of the month."""
1718 return self.get_week_number(self.value.day)
1720 def get_week_number(self, day_of_period: int, day_of_week: int | None = None) -> int:
1721 """Return the number of the week of a day within a period. This may be
1722 the week number in a year or the week number in a month.
1724 Usually this will return a value equal to or greater than 1, but if the
1725 first week of the period is so short that it actually counts as the last
1726 week of the previous period, this function will return 0.
1728 >>> date = datetime.date(2006, 1, 8)
1729 >>> DateTimeFormat(date, 'de_DE').get_week_number(6)
1730 1
1731 >>> DateTimeFormat(date, 'en_US').get_week_number(6)
1732 2
1734 :param day_of_period: the number of the day in the period (usually
1735 either the day of month or the day of year)
1736 :param day_of_week: the week day; if omitted, the week day of the
1737 current date is assumed
1738 """
1739 if day_of_week is None:
1740 day_of_week = self.value.weekday()
1741 first_day = (day_of_week - self.locale.first_week_day - day_of_period + 1) % 7
1742 if first_day < 0:
1743 first_day += 7
1744 week_number = (day_of_period + first_day - 1) // 7
1745 if 7 - first_day >= self.locale.min_week_days:
1746 week_number += 1
1747 return week_number
1750PATTERN_CHARS: dict[str, list[int] | None] = {
1751 'G': [1, 2, 3, 4, 5], # era
1752 'y': None, 'Y': None, 'u': None, # year
1753 'Q': [1, 2, 3, 4, 5], 'q': [1, 2, 3, 4, 5], # quarter
1754 'M': [1, 2, 3, 4, 5], 'L': [1, 2, 3, 4, 5], # month
1755 'w': [1, 2], 'W': [1], # week
1756 'd': [1, 2], 'D': [1, 2, 3], 'F': [1], 'g': None, # day
1757 'E': [1, 2, 3, 4, 5, 6], 'e': [1, 2, 3, 4, 5, 6], 'c': [1, 3, 4, 5, 6], # week day
1758 'a': [1, 2, 3, 4, 5], 'b': [1, 2, 3, 4, 5], 'B': [1, 2, 3, 4, 5], # period
1759 'h': [1, 2], 'H': [1, 2], 'K': [1, 2], 'k': [1, 2], # hour
1760 'm': [1, 2], # minute
1761 's': [1, 2], 'S': None, 'A': None, # second
1762 'z': [1, 2, 3, 4], 'Z': [1, 2, 3, 4, 5], 'O': [1, 4], 'v': [1, 4], # zone
1763 'V': [1, 2, 3, 4], 'x': [1, 2, 3, 4, 5], 'X': [1, 2, 3, 4, 5], # zone
1764} # fmt: skip
1766#: The pattern characters declared in the Date Field Symbol Table
1767#: (https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table)
1768#: in order of decreasing magnitude.
1769PATTERN_CHAR_ORDER = "GyYuUQqMLlwWdDFgEecabBChHKkjJmsSAzZOvVXx"
1772def parse_pattern(pattern: str | DateTimePattern) -> DateTimePattern:
1773 """Parse date, time, and datetime format patterns.
1775 >>> parse_pattern("MMMMd").format
1776 '%(MMMM)s%(d)s'
1777 >>> parse_pattern("MMM d, yyyy").format
1778 '%(MMM)s %(d)s, %(yyyy)s'
1780 Pattern can contain literal strings in single quotes:
1782 >>> parse_pattern("H:mm' Uhr 'z").format
1783 '%(H)s:%(mm)s Uhr %(z)s'
1785 An actual single quote can be used by using two adjacent single quote
1786 characters:
1788 >>> parse_pattern("hh' o''clock'").format
1789 "%(hh)s o'clock"
1791 :param pattern: the formatting pattern to parse
1792 """
1793 if isinstance(pattern, DateTimePattern):
1794 return pattern
1795 return _cached_parse_pattern(pattern)
1798@lru_cache(maxsize=1024)
1799def _cached_parse_pattern(pattern: str) -> DateTimePattern:
1800 result = []
1802 for tok_type, tok_value in tokenize_pattern(pattern):
1803 if tok_type == "chars":
1804 result.append(tok_value.replace('%', '%%'))
1805 elif tok_type == "field":
1806 fieldchar, fieldnum = tok_value
1807 limit = PATTERN_CHARS[fieldchar]
1808 if limit and fieldnum not in limit:
1809 raise ValueError(f"Invalid length for field: {fieldchar * fieldnum!r}")
1810 result.append('%%(%s)s' % (fieldchar * fieldnum))
1811 else:
1812 raise NotImplementedError(f"Unknown token type: {tok_type}")
1813 return DateTimePattern(pattern, ''.join(result))
1816def tokenize_pattern(pattern: str) -> list[tuple[str, str | tuple[str, int]]]:
1817 """
1818 Tokenize date format patterns.
1820 Returns a list of (token_type, token_value) tuples.
1822 ``token_type`` may be either "chars" or "field".
1824 For "chars" tokens, the value is the literal value.
1826 For "field" tokens, the value is a tuple of (field character, repetition count).
1828 :param pattern: Pattern string
1829 """
1830 result = []
1831 quotebuf = None
1832 charbuf = []
1833 fieldchar = ['']
1834 fieldnum = [0]
1836 def append_chars():
1837 result.append(('chars', ''.join(charbuf).replace('\0', "'")))
1838 del charbuf[:]
1840 def append_field():
1841 result.append(('field', (fieldchar[0], fieldnum[0])))
1842 fieldchar[0] = ''
1843 fieldnum[0] = 0
1845 for char in pattern.replace("''", '\0'):
1846 if quotebuf is None:
1847 if char == "'": # quote started
1848 if fieldchar[0]:
1849 append_field()
1850 elif charbuf:
1851 append_chars()
1852 quotebuf = []
1853 elif char in PATTERN_CHARS:
1854 if charbuf:
1855 append_chars()
1856 if char == fieldchar[0]:
1857 fieldnum[0] += 1
1858 else:
1859 if fieldchar[0]:
1860 append_field()
1861 fieldchar[0] = char
1862 fieldnum[0] = 1
1863 else:
1864 if fieldchar[0]:
1865 append_field()
1866 charbuf.append(char)
1868 elif quotebuf is not None:
1869 if char == "'": # end of quote
1870 charbuf.extend(quotebuf)
1871 quotebuf = None
1872 else: # inside quote
1873 quotebuf.append(char)
1875 if fieldchar[0]:
1876 append_field()
1877 elif charbuf:
1878 append_chars()
1880 return result
1883def untokenize_pattern(tokens: Iterable[tuple[str, str | tuple[str, int]]]) -> str:
1884 """
1885 Turn a date format pattern token stream back into a string.
1887 This is the reverse operation of ``tokenize_pattern``.
1888 """
1889 output = []
1890 for tok_type, tok_value in tokens:
1891 if tok_type == "field":
1892 output.append(tok_value[0] * tok_value[1])
1893 elif tok_type == "chars":
1894 if not any(ch in PATTERN_CHARS for ch in tok_value): # No need to quote
1895 output.append(tok_value)
1896 else:
1897 output.append("'%s'" % tok_value.replace("'", "''"))
1898 return "".join(output)
1901def split_interval_pattern(pattern: str) -> list[str]:
1902 """
1903 Split an interval-describing datetime pattern into multiple pieces.
1905 > The pattern is then designed to be broken up into two pieces by determining the first repeating field.
1906 - https://www.unicode.org/reports/tr35/tr35-dates.html#intervalFormats
1908 >>> split_interval_pattern('E d.M. – E d.M.')
1909 ['E d.M. – ', 'E d.M.']
1910 >>> split_interval_pattern("Y 'text' Y 'more text'")
1911 ["Y 'text '", "Y 'more text'"]
1912 >>> split_interval_pattern('E, MMM d – E')
1913 ['E, MMM d – ', 'E']
1914 >>> split_interval_pattern("MMM d")
1915 ['MMM d']
1916 >>> split_interval_pattern("y G")
1917 ['y G']
1918 >>> split_interval_pattern('MMM d – d')
1919 ['MMM d – ', 'd']
1921 :param pattern: Interval pattern string
1922 :return: list of "subpatterns"
1923 """
1925 seen_fields = set()
1926 parts = [[]]
1928 for tok_type, tok_value in tokenize_pattern(pattern):
1929 if tok_type == "field":
1930 if tok_value[0] in seen_fields: # Repeated field
1931 parts.append([])
1932 seen_fields.clear()
1933 seen_fields.add(tok_value[0])
1934 parts[-1].append((tok_type, tok_value))
1936 return [untokenize_pattern(tokens) for tokens in parts]
1939def match_skeleton(
1940 skeleton: str,
1941 options: Iterable[str],
1942 allow_different_fields: bool = False,
1943) -> str | None:
1944 """
1945 Find the closest match for the given datetime skeleton among the options given.
1947 This uses the rules outlined in the TR35 document.
1949 >>> match_skeleton('yMMd', ('yMd', 'yMMMd'))
1950 'yMd'
1952 >>> match_skeleton('yMMd', ('jyMMd',), allow_different_fields=True)
1953 'jyMMd'
1955 >>> match_skeleton('yMMd', ('qyMMd',), allow_different_fields=False)
1957 >>> match_skeleton('hmz', ('hmv',))
1958 'hmv'
1960 :param skeleton: The skeleton to match
1961 :param options: An iterable of other skeletons to match against
1962 :param allow_different_fields: Whether to allow a match that uses different fields
1963 than the skeleton requested.
1964 :return: The closest skeleton match, or if no match was found, None.
1965 :rtype: str|None
1966 """
1968 # TODO: maybe implement pattern expansion?
1970 # Based on the implementation in
1971 # https://github.com/unicode-org/icu/blob/main/icu4j/main/core/src/main/java/com/ibm/icu/text/DateIntervalInfo.java
1973 # Filter out falsy values and sort for stability; when `interval_formats` is passed in, there may be a None key.
1974 options = sorted(option for option in options if option)
1976 if 'z' in skeleton and not any('z' in option for option in options):
1977 skeleton = skeleton.replace('z', 'v')
1978 if 'k' in skeleton and not any('k' in option for option in options):
1979 skeleton = skeleton.replace('k', 'H')
1980 if 'K' in skeleton and not any('K' in option for option in options):
1981 skeleton = skeleton.replace('K', 'h')
1982 if 'a' in skeleton and not any('a' in option for option in options):
1983 skeleton = skeleton.replace('a', '')
1984 if 'b' in skeleton and not any('b' in option for option in options):
1985 skeleton = skeleton.replace('b', '')
1987 get_input_field_width = dict(t[1] for t in tokenize_pattern(skeleton) if t[0] == "field").get # fmt: skip
1988 best_skeleton = None
1989 best_distance = None
1990 for option in options:
1991 get_opt_field_width = dict(t[1] for t in tokenize_pattern(option) if t[0] == "field").get # fmt: skip
1992 distance = 0
1993 for field in PATTERN_CHARS:
1994 input_width = get_input_field_width(field, 0)
1995 opt_width = get_opt_field_width(field, 0)
1996 if input_width == opt_width:
1997 continue
1998 if opt_width == 0 or input_width == 0:
1999 if not allow_different_fields: # This one is not okay
2000 option = None
2001 break
2002 # Magic weight constant for "entirely different fields"
2003 distance += 0x1000
2004 elif field == 'M' and (
2005 (input_width > 2 and opt_width <= 2) or (input_width <= 2 and opt_width > 2)
2006 ):
2007 # Magic weight constant for "text turns into a number"
2008 distance += 0x100
2009 else:
2010 distance += abs(input_width - opt_width)
2012 if not option:
2013 # We lost the option along the way (probably due to "allow_different_fields")
2014 continue
2016 if not best_skeleton or distance < best_distance:
2017 best_skeleton = option
2018 best_distance = distance
2020 if distance == 0: # Found a perfect match!
2021 break
2023 return best_skeleton