Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.8/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-2024 by the Babel Team.
15 :license: BSD, see LICENSE for more details.
16"""
18from __future__ import annotations
20import re
21import warnings
22from functools import lru_cache
23from typing import TYPE_CHECKING, SupportsInt
25try:
26 import pytz
27except ModuleNotFoundError:
28 pytz = None
29 import zoneinfo
31import datetime
32from collections.abc import Iterable
34from babel import localtime
35from babel.core import Locale, default_locale, get_global
36from babel.localedata import LocaleDataDict
38if TYPE_CHECKING:
39 from typing_extensions import Literal, TypeAlias
40 _Instant: TypeAlias = datetime.date | datetime.time | float | None
41 _PredefinedTimeFormat: TypeAlias = Literal['full', 'long', 'medium', 'short']
42 _Context: TypeAlias = Literal['format', 'stand-alone']
43 _DtOrTzinfo: TypeAlias = datetime.datetime | datetime.tzinfo | str | int | datetime.time | None
45# "If a given short metazone form is known NOT to be understood in a given
46# locale and the parent locale has this value such that it would normally
47# be inherited, the inheritance of this value can be explicitly disabled by
48# use of the 'no inheritance marker' as the value, which is 3 simultaneous [sic]
49# empty set characters ( U+2205 )."
50# - https://www.unicode.org/reports/tr35/tr35-dates.html#Metazone_Names
52NO_INHERITANCE_MARKER = '\u2205\u2205\u2205'
54UTC = datetime.timezone.utc
55LOCALTZ = localtime.LOCALTZ
57LC_TIME = default_locale('LC_TIME')
60def _localize(tz: datetime.tzinfo, dt: datetime.datetime) -> datetime.datetime:
61 # Support localizing with both pytz and zoneinfo tzinfos
62 # nothing to do
63 if dt.tzinfo is tz:
64 return dt
66 if hasattr(tz, 'localize'): # pytz
67 return tz.localize(dt)
69 if dt.tzinfo is None:
70 # convert naive to localized
71 return dt.replace(tzinfo=tz)
73 # convert timezones
74 return dt.astimezone(tz)
77def _get_dt_and_tzinfo(dt_or_tzinfo: _DtOrTzinfo) -> tuple[datetime.datetime | None, datetime.tzinfo]:
78 """
79 Parse a `dt_or_tzinfo` value into a datetime and a tzinfo.
81 See the docs for this function's callers for semantics.
83 :rtype: tuple[datetime, tzinfo]
84 """
85 if dt_or_tzinfo is None:
86 dt = datetime.datetime.now()
87 tzinfo = LOCALTZ
88 elif isinstance(dt_or_tzinfo, str):
89 dt = None
90 tzinfo = get_timezone(dt_or_tzinfo)
91 elif isinstance(dt_or_tzinfo, int):
92 dt = None
93 tzinfo = UTC
94 elif isinstance(dt_or_tzinfo, (datetime.datetime, datetime.time)):
95 dt = _get_datetime(dt_or_tzinfo)
96 tzinfo = dt.tzinfo if dt.tzinfo is not None else UTC
97 else:
98 dt = None
99 tzinfo = dt_or_tzinfo
100 return dt, tzinfo
103def _get_tz_name(dt_or_tzinfo: _DtOrTzinfo) -> str:
104 """
105 Get the timezone name out of a time, datetime, or tzinfo object.
107 :rtype: str
108 """
109 dt, tzinfo = _get_dt_and_tzinfo(dt_or_tzinfo)
110 if hasattr(tzinfo, 'zone'): # pytz object
111 return tzinfo.zone
112 elif hasattr(tzinfo, 'key') and tzinfo.key is not None: # ZoneInfo object
113 return tzinfo.key
114 else:
115 return tzinfo.tzname(dt or datetime.datetime.now(UTC))
118def _get_datetime(instant: _Instant) -> datetime.datetime:
119 """
120 Get a datetime out of an "instant" (date, time, datetime, number).
122 .. warning:: The return values of this function may depend on the system clock.
124 If the instant is None, the current moment is used.
125 If the instant is a time, it's augmented with today's date.
127 Dates are converted to naive datetimes with midnight as the time component.
129 >>> from datetime import date, datetime
130 >>> _get_datetime(date(2015, 1, 1))
131 datetime.datetime(2015, 1, 1, 0, 0)
133 UNIX timestamps are converted to datetimes.
135 >>> _get_datetime(1400000000)
136 datetime.datetime(2014, 5, 13, 16, 53, 20)
138 Other values are passed through as-is.
140 >>> x = datetime(2015, 1, 1)
141 >>> _get_datetime(x) is x
142 True
144 :param instant: date, time, datetime, integer, float or None
145 :type instant: date|time|datetime|int|float|None
146 :return: a datetime
147 :rtype: datetime
148 """
149 if instant is None:
150 return datetime.datetime.now(UTC).replace(tzinfo=None)
151 elif isinstance(instant, (int, float)):
152 return datetime.datetime.fromtimestamp(instant, UTC).replace(tzinfo=None)
153 elif isinstance(instant, datetime.time):
154 return datetime.datetime.combine(datetime.date.today(), instant)
155 elif isinstance(instant, datetime.date) and not isinstance(instant, datetime.datetime):
156 return datetime.datetime.combine(instant, datetime.time())
157 # TODO (3.x): Add an assertion/type check for this fallthrough branch:
158 return instant
161def _ensure_datetime_tzinfo(dt: datetime.datetime, tzinfo: datetime.tzinfo | None = None) -> datetime.datetime:
162 """
163 Ensure the datetime passed has an attached tzinfo.
165 If the datetime is tz-naive to begin with, UTC is attached.
167 If a tzinfo is passed in, the datetime is normalized to that timezone.
169 >>> from datetime import datetime
170 >>> _get_tz_name(_ensure_datetime_tzinfo(datetime(2015, 1, 1)))
171 'UTC'
173 >>> tz = get_timezone("Europe/Stockholm")
174 >>> _ensure_datetime_tzinfo(datetime(2015, 1, 1, 13, 15, tzinfo=UTC), tzinfo=tz).hour
175 14
177 :param datetime: Datetime to augment.
178 :param tzinfo: optional tzinfo
179 :return: datetime with tzinfo
180 :rtype: datetime
181 """
182 if dt.tzinfo is None:
183 dt = dt.replace(tzinfo=UTC)
184 if tzinfo is not None:
185 dt = dt.astimezone(get_timezone(tzinfo))
186 if hasattr(tzinfo, 'normalize'): # pytz
187 dt = tzinfo.normalize(dt)
188 return dt
191def _get_time(
192 time: datetime.time | datetime.datetime | None,
193 tzinfo: datetime.tzinfo | None = None,
194) -> datetime.time:
195 """
196 Get a timezoned time from a given instant.
198 .. warning:: The return values of this function may depend on the system clock.
200 :param time: time, datetime or None
201 :rtype: time
202 """
203 if time is None:
204 time = datetime.datetime.now(UTC)
205 elif isinstance(time, (int, float)):
206 time = datetime.datetime.fromtimestamp(time, UTC)
208 if time.tzinfo is None:
209 time = time.replace(tzinfo=UTC)
211 if isinstance(time, datetime.datetime):
212 if tzinfo is not None:
213 time = time.astimezone(tzinfo)
214 if hasattr(tzinfo, 'normalize'): # pytz
215 time = tzinfo.normalize(time)
216 time = time.timetz()
217 elif tzinfo is not None:
218 time = time.replace(tzinfo=tzinfo)
219 return time
222def get_timezone(zone: str | datetime.tzinfo | None = None) -> datetime.tzinfo:
223 """Looks up a timezone by name and returns it. The timezone object
224 returned comes from ``pytz`` or ``zoneinfo``, whichever is available.
225 It corresponds to the `tzinfo` interface and can be used with all of
226 the functions of Babel that operate with dates.
228 If a timezone is not known a :exc:`LookupError` is raised. If `zone`
229 is ``None`` a local zone object is returned.
231 :param zone: the name of the timezone to look up. If a timezone object
232 itself is passed in, it's returned unchanged.
233 """
234 if zone is None:
235 return LOCALTZ
236 if not isinstance(zone, str):
237 return zone
239 if pytz:
240 try:
241 return pytz.timezone(zone)
242 except pytz.UnknownTimeZoneError as e:
243 exc = e
244 else:
245 assert zoneinfo
246 try:
247 return zoneinfo.ZoneInfo(zone)
248 except zoneinfo.ZoneInfoNotFoundError as e:
249 exc = e
251 raise LookupError(f"Unknown timezone {zone}") from exc
254def get_period_names(width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
255 context: _Context = 'stand-alone', locale: Locale | str | None = LC_TIME) -> LocaleDataDict:
256 """Return the names for day periods (AM/PM) used by the locale.
258 >>> get_period_names(locale='en_US')['am']
259 u'AM'
261 :param width: the width to use, one of "abbreviated", "narrow", or "wide"
262 :param context: the context, either "format" or "stand-alone"
263 :param locale: the `Locale` object, or a locale string
264 """
265 return Locale.parse(locale).day_periods[context][width]
268def get_day_names(width: Literal['abbreviated', 'narrow', 'short', 'wide'] = 'wide',
269 context: _Context = 'format', locale: Locale | str | None = LC_TIME) -> LocaleDataDict:
270 """Return the day names used by the locale for the specified format.
272 >>> get_day_names('wide', locale='en_US')[1]
273 u'Tuesday'
274 >>> get_day_names('short', locale='en_US')[1]
275 u'Tu'
276 >>> get_day_names('abbreviated', locale='es')[1]
277 u'mar'
278 >>> get_day_names('narrow', context='stand-alone', locale='de_DE')[1]
279 u'D'
281 :param width: the width to use, one of "wide", "abbreviated", "short" or "narrow"
282 :param context: the context, either "format" or "stand-alone"
283 :param locale: the `Locale` object, or a locale string
284 """
285 return Locale.parse(locale).days[context][width]
288def get_month_names(width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
289 context: _Context = 'format', locale: Locale | str | None = LC_TIME) -> LocaleDataDict:
290 """Return the month names used by the locale for the specified format.
292 >>> get_month_names('wide', locale='en_US')[1]
293 u'January'
294 >>> get_month_names('abbreviated', locale='es')[1]
295 u'ene'
296 >>> get_month_names('narrow', context='stand-alone', locale='de_DE')[1]
297 u'J'
299 :param width: the width to use, one of "wide", "abbreviated", or "narrow"
300 :param context: the context, either "format" or "stand-alone"
301 :param locale: the `Locale` object, or a locale string
302 """
303 return Locale.parse(locale).months[context][width]
306def get_quarter_names(width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
307 context: _Context = 'format', locale: Locale | str | None = LC_TIME) -> LocaleDataDict:
308 """Return the quarter names used by the locale for the specified format.
310 >>> get_quarter_names('wide', locale='en_US')[1]
311 u'1st quarter'
312 >>> get_quarter_names('abbreviated', locale='de_DE')[1]
313 u'Q1'
314 >>> get_quarter_names('narrow', locale='de_DE')[1]
315 u'1'
317 :param width: the width to use, one of "wide", "abbreviated", or "narrow"
318 :param context: the context, either "format" or "stand-alone"
319 :param locale: the `Locale` object, or a locale string
320 """
321 return Locale.parse(locale).quarters[context][width]
324def get_era_names(width: Literal['abbreviated', 'narrow', 'wide'] = 'wide',
325 locale: Locale | str | None = LC_TIME) -> LocaleDataDict:
326 """Return the era names used by the locale for the specified format.
328 >>> get_era_names('wide', locale='en_US')[1]
329 u'Anno Domini'
330 >>> get_era_names('abbreviated', locale='de_DE')[1]
331 u'n. Chr.'
333 :param width: the width to use, either "wide", "abbreviated", or "narrow"
334 :param locale: the `Locale` object, or a locale string
335 """
336 return Locale.parse(locale).eras[width]
339def get_date_format(format: _PredefinedTimeFormat = 'medium', locale: Locale | str | None = LC_TIME) -> DateTimePattern:
340 """Return the date formatting patterns used by the locale for the specified
341 format.
343 >>> get_date_format(locale='en_US')
344 <DateTimePattern u'MMM d, y'>
345 >>> get_date_format('full', locale='de_DE')
346 <DateTimePattern u'EEEE, d. MMMM y'>
348 :param format: the format to use, one of "full", "long", "medium", or
349 "short"
350 :param locale: the `Locale` object, or a locale string
351 """
352 return Locale.parse(locale).date_formats[format]
355def get_datetime_format(format: _PredefinedTimeFormat = 'medium', locale: Locale | str | None = LC_TIME) -> DateTimePattern:
356 """Return the datetime formatting patterns used by the locale for the
357 specified format.
359 >>> get_datetime_format(locale='en_US')
360 u'{1}, {0}'
362 :param format: the format to use, one of "full", "long", "medium", or
363 "short"
364 :param locale: the `Locale` object, or a locale string
365 """
366 patterns = Locale.parse(locale).datetime_formats
367 if format not in patterns:
368 format = None
369 return patterns[format]
372def get_time_format(format: _PredefinedTimeFormat = 'medium', locale: Locale | str | None = LC_TIME) -> DateTimePattern:
373 """Return the time formatting patterns used by the locale for the specified
374 format.
376 >>> get_time_format(locale='en_US')
377 <DateTimePattern u'h:mm:ss\u202fa'>
378 >>> get_time_format('full', locale='de_DE')
379 <DateTimePattern u'HH:mm:ss zzzz'>
381 :param format: the format to use, one of "full", "long", "medium", or
382 "short"
383 :param locale: the `Locale` object, or a locale string
384 """
385 return Locale.parse(locale).time_formats[format]
388def get_timezone_gmt(
389 datetime: _Instant = None,
390 width: Literal['long', 'short', 'iso8601', 'iso8601_short'] = 'long',
391 locale: Locale | str | None = LC_TIME,
392 return_z: bool = False,
393) -> str:
394 """Return the timezone associated with the given `datetime` object formatted
395 as string indicating the offset from GMT.
397 >>> from datetime import datetime
398 >>> dt = datetime(2007, 4, 1, 15, 30)
399 >>> get_timezone_gmt(dt, locale='en')
400 u'GMT+00:00'
401 >>> get_timezone_gmt(dt, locale='en', return_z=True)
402 'Z'
403 >>> get_timezone_gmt(dt, locale='en', width='iso8601_short')
404 u'+00'
405 >>> tz = get_timezone('America/Los_Angeles')
406 >>> dt = _localize(tz, datetime(2007, 4, 1, 15, 30))
407 >>> get_timezone_gmt(dt, locale='en')
408 u'GMT-07:00'
409 >>> get_timezone_gmt(dt, 'short', locale='en')
410 u'-0700'
411 >>> get_timezone_gmt(dt, locale='en', width='iso8601_short')
412 u'-07'
414 The long format depends on the locale, for example in France the acronym
415 UTC string is used instead of GMT:
417 >>> get_timezone_gmt(dt, 'long', locale='fr_FR')
418 u'UTC-07:00'
420 .. versionadded:: 0.9
422 :param datetime: the ``datetime`` object; if `None`, the current date and
423 time in UTC is used
424 :param width: either "long" or "short" or "iso8601" or "iso8601_short"
425 :param locale: the `Locale` object, or a locale string
426 :param return_z: True or False; Function returns indicator "Z"
427 when local time offset is 0
428 """
429 datetime = _ensure_datetime_tzinfo(_get_datetime(datetime))
430 locale = Locale.parse(locale)
432 offset = datetime.tzinfo.utcoffset(datetime)
433 seconds = offset.days * 24 * 60 * 60 + offset.seconds
434 hours, seconds = divmod(seconds, 3600)
435 if return_z and hours == 0 and seconds == 0:
436 return 'Z'
437 elif seconds == 0 and width == 'iso8601_short':
438 return '%+03d' % hours
439 elif width == 'short' or width == 'iso8601_short':
440 pattern = '%+03d%02d'
441 elif width == 'iso8601':
442 pattern = '%+03d:%02d'
443 else:
444 pattern = locale.zone_formats['gmt'] % '%+03d:%02d'
445 return pattern % (hours, seconds // 60)
448def get_timezone_location(
449 dt_or_tzinfo: _DtOrTzinfo = None,
450 locale: Locale | str | None = LC_TIME,
451 return_city: bool = False,
452) -> str:
453 """Return a representation of the given timezone using "location format".
455 The result depends on both the local display name of the country and the
456 city associated with the time zone:
458 >>> tz = get_timezone('America/St_Johns')
459 >>> print(get_timezone_location(tz, locale='de_DE'))
460 Kanada (St. John’s) (Ortszeit)
461 >>> print(get_timezone_location(tz, locale='en'))
462 Canada (St. John’s) Time
463 >>> print(get_timezone_location(tz, locale='en', return_city=True))
464 St. John’s
465 >>> tz = get_timezone('America/Mexico_City')
466 >>> get_timezone_location(tz, locale='de_DE')
467 u'Mexiko (Mexiko-Stadt) (Ortszeit)'
469 If the timezone is associated with a country that uses only a single
470 timezone, just the localized country name is returned:
472 >>> tz = get_timezone('Europe/Berlin')
473 >>> get_timezone_name(tz, locale='de_DE')
474 u'Mitteleurop\\xe4ische Zeit'
476 .. versionadded:: 0.9
478 :param dt_or_tzinfo: the ``datetime`` or ``tzinfo`` object that determines
479 the timezone; if `None`, the current date and time in
480 UTC is assumed
481 :param locale: the `Locale` object, or a locale string
482 :param return_city: True or False, if True then return exemplar city (location)
483 for the time zone
484 :return: the localized timezone name using location format
486 """
487 locale = Locale.parse(locale)
489 zone = _get_tz_name(dt_or_tzinfo)
491 # Get the canonical time-zone code
492 zone = get_global('zone_aliases').get(zone, zone)
494 info = locale.time_zones.get(zone, {})
496 # Otherwise, if there is only one timezone for the country, return the
497 # localized country name
498 region_format = locale.zone_formats['region']
499 territory = get_global('zone_territories').get(zone)
500 if territory not in locale.territories:
501 territory = 'ZZ' # invalid/unknown
502 territory_name = locale.territories[territory]
503 if not return_city and territory and len(get_global('territory_zones').get(territory, [])) == 1:
504 return region_format % territory_name
506 # Otherwise, include the city in the output
507 fallback_format = locale.zone_formats['fallback']
508 if 'city' in info:
509 city_name = info['city']
510 else:
511 metazone = get_global('meta_zones').get(zone)
512 metazone_info = locale.meta_zones.get(metazone, {})
513 if 'city' in metazone_info:
514 city_name = metazone_info['city']
515 elif '/' in zone:
516 city_name = zone.split('/', 1)[1].replace('_', ' ')
517 else:
518 city_name = zone.replace('_', ' ')
520 if return_city:
521 return city_name
522 return region_format % (fallback_format % {
523 '0': city_name,
524 '1': territory_name,
525 })
528def get_timezone_name(
529 dt_or_tzinfo: _DtOrTzinfo = None,
530 width: Literal['long', 'short'] = 'long',
531 uncommon: bool = False,
532 locale: Locale | str | None = LC_TIME,
533 zone_variant: Literal['generic', 'daylight', 'standard'] | None = None,
534 return_zone: bool = False,
535) -> str:
536 r"""Return the localized display name for the given timezone. The timezone
537 may be specified using a ``datetime`` or `tzinfo` object.
539 >>> from datetime import time
540 >>> dt = time(15, 30, tzinfo=get_timezone('America/Los_Angeles'))
541 >>> get_timezone_name(dt, locale='en_US') # doctest: +SKIP
542 u'Pacific Standard Time'
543 >>> get_timezone_name(dt, locale='en_US', return_zone=True)
544 'America/Los_Angeles'
545 >>> get_timezone_name(dt, width='short', locale='en_US') # doctest: +SKIP
546 u'PST'
548 If this function gets passed only a `tzinfo` object and no concrete
549 `datetime`, the returned display name is independent of daylight savings
550 time. This can be used for example for selecting timezones, or to set the
551 time of events that recur across DST changes:
553 >>> tz = get_timezone('America/Los_Angeles')
554 >>> get_timezone_name(tz, locale='en_US')
555 u'Pacific Time'
556 >>> get_timezone_name(tz, 'short', locale='en_US')
557 u'PT'
559 If no localized display name for the timezone is available, and the timezone
560 is associated with a country that uses only a single timezone, the name of
561 that country is returned, formatted according to the locale:
563 >>> tz = get_timezone('Europe/Berlin')
564 >>> get_timezone_name(tz, locale='de_DE')
565 u'Mitteleurop\xe4ische Zeit'
566 >>> get_timezone_name(tz, locale='pt_BR')
567 u'Hor\xe1rio da Europa Central'
569 On the other hand, if the country uses multiple timezones, the city is also
570 included in the representation:
572 >>> tz = get_timezone('America/St_Johns')
573 >>> get_timezone_name(tz, locale='de_DE')
574 u'Neufundland-Zeit'
576 Note that short format is currently not supported for all timezones and
577 all locales. This is partially because not every timezone has a short
578 code in every locale. In that case it currently falls back to the long
579 format.
581 For more information see `LDML Appendix J: Time Zone Display Names
582 <https://www.unicode.org/reports/tr35/#Time_Zone_Fallback>`_
584 .. versionadded:: 0.9
586 .. versionchanged:: 1.0
587 Added `zone_variant` support.
589 :param dt_or_tzinfo: the ``datetime`` or ``tzinfo`` object that determines
590 the timezone; if a ``tzinfo`` object is used, the
591 resulting display name will be generic, i.e.
592 independent of daylight savings time; if `None`, the
593 current date in UTC is assumed
594 :param width: either "long" or "short"
595 :param uncommon: deprecated and ignored
596 :param zone_variant: defines the zone variation to return. By default the
597 variation is defined from the datetime object
598 passed in. If no datetime object is passed in, the
599 ``'generic'`` variation is assumed. The following
600 values are valid: ``'generic'``, ``'daylight'`` and
601 ``'standard'``.
602 :param locale: the `Locale` object, or a locale string
603 :param return_zone: True or False. If true then function
604 returns long time zone ID
605 """
606 dt, tzinfo = _get_dt_and_tzinfo(dt_or_tzinfo)
607 locale = Locale.parse(locale)
609 zone = _get_tz_name(dt_or_tzinfo)
611 if zone_variant is None:
612 if dt is None:
613 zone_variant = 'generic'
614 else:
615 dst = tzinfo.dst(dt)
616 zone_variant = "daylight" if dst else "standard"
617 else:
618 if zone_variant not in ('generic', 'standard', 'daylight'):
619 raise ValueError('Invalid zone variation')
621 # Get the canonical time-zone code
622 zone = get_global('zone_aliases').get(zone, zone)
623 if return_zone:
624 return zone
625 info = locale.time_zones.get(zone, {})
626 # Try explicitly translated zone names first
627 if width in info and zone_variant in info[width]:
628 return info[width][zone_variant]
630 metazone = get_global('meta_zones').get(zone)
631 if metazone:
632 metazone_info = locale.meta_zones.get(metazone, {})
633 if width in metazone_info:
634 name = metazone_info[width].get(zone_variant)
635 if width == 'short' and name == NO_INHERITANCE_MARKER:
636 # If the short form is marked no-inheritance,
637 # try to fall back to the long name instead.
638 name = metazone_info.get('long', {}).get(zone_variant)
639 if name:
640 return name
642 # If we have a concrete datetime, we assume that the result can't be
643 # independent of daylight savings time, so we return the GMT offset
644 if dt is not None:
645 return get_timezone_gmt(dt, width=width, locale=locale)
647 return get_timezone_location(dt_or_tzinfo, locale=locale)
650def format_date(
651 date: datetime.date | None = None,
652 format: _PredefinedTimeFormat | str = 'medium',
653 locale: Locale | str | None = LC_TIME,
654) -> str:
655 """Return a date formatted according to the given pattern.
657 >>> from datetime import date
658 >>> d = date(2007, 4, 1)
659 >>> format_date(d, locale='en_US')
660 u'Apr 1, 2007'
661 >>> format_date(d, format='full', locale='de_DE')
662 u'Sonntag, 1. April 2007'
664 If you don't want to use the locale default formats, you can specify a
665 custom date pattern:
667 >>> format_date(d, "EEE, MMM d, ''yy", locale='en')
668 u"Sun, Apr 1, '07"
670 :param date: the ``date`` or ``datetime`` object; if `None`, the current
671 date is used
672 :param format: one of "full", "long", "medium", or "short", or a custom
673 date/time pattern
674 :param locale: a `Locale` object or a locale identifier
675 """
676 if date is None:
677 date = datetime.date.today()
678 elif isinstance(date, datetime.datetime):
679 date = date.date()
681 locale = Locale.parse(locale)
682 if format in ('full', 'long', 'medium', 'short'):
683 format = get_date_format(format, locale=locale)
684 pattern = parse_pattern(format)
685 return pattern.apply(date, locale)
688def format_datetime(
689 datetime: _Instant = None,
690 format: _PredefinedTimeFormat | str = 'medium',
691 tzinfo: datetime.tzinfo | None = None,
692 locale: Locale | str | None = LC_TIME,
693) -> str:
694 r"""Return a date formatted according to the given pattern.
696 >>> from datetime import datetime
697 >>> dt = datetime(2007, 4, 1, 15, 30)
698 >>> format_datetime(dt, locale='en_US')
699 u'Apr 1, 2007, 3:30:00\u202fPM'
701 For any pattern requiring the display of the timezone:
703 >>> format_datetime(dt, 'full', tzinfo=get_timezone('Europe/Paris'),
704 ... locale='fr_FR')
705 'dimanche 1 avril 2007, 17:30:00 heure d’été d’Europe centrale'
706 >>> format_datetime(dt, "yyyy.MM.dd G 'at' HH:mm:ss zzz",
707 ... tzinfo=get_timezone('US/Eastern'), locale='en')
708 u'2007.04.01 AD at 11:30:00 EDT'
710 :param datetime: the `datetime` object; if `None`, the current date and
711 time is used
712 :param format: one of "full", "long", "medium", or "short", or a custom
713 date/time pattern
714 :param tzinfo: the timezone to apply to the time for display
715 :param locale: a `Locale` object or a locale identifier
716 """
717 datetime = _ensure_datetime_tzinfo(_get_datetime(datetime), tzinfo)
719 locale = Locale.parse(locale)
720 if format in ('full', 'long', 'medium', 'short'):
721 return get_datetime_format(format, locale=locale) \
722 .replace("'", "") \
723 .replace('{0}', format_time(datetime, format, tzinfo=None,
724 locale=locale)) \
725 .replace('{1}', format_date(datetime, format, locale=locale))
726 else:
727 return parse_pattern(format).apply(datetime, locale)
730def format_time(
731 time: datetime.time | datetime.datetime | float | None = None,
732 format: _PredefinedTimeFormat | str = 'medium',
733 tzinfo: datetime.tzinfo | None = None, locale: Locale | str | None = LC_TIME,
734) -> str:
735 r"""Return a time formatted according to the given pattern.
737 >>> from datetime import datetime, time
738 >>> t = time(15, 30)
739 >>> format_time(t, locale='en_US')
740 u'3:30:00\u202fPM'
741 >>> format_time(t, format='short', locale='de_DE')
742 u'15:30'
744 If you don't want to use the locale default formats, you can specify a
745 custom time pattern:
747 >>> format_time(t, "hh 'o''clock' a", locale='en')
748 u"03 o'clock PM"
750 For any pattern requiring the display of the time-zone a
751 timezone has to be specified explicitly:
753 >>> t = datetime(2007, 4, 1, 15, 30)
754 >>> tzinfo = get_timezone('Europe/Paris')
755 >>> t = _localize(tzinfo, t)
756 >>> format_time(t, format='full', tzinfo=tzinfo, locale='fr_FR')
757 '15:30:00 heure d’été d’Europe centrale'
758 >>> format_time(t, "hh 'o''clock' a, zzzz", tzinfo=get_timezone('US/Eastern'),
759 ... locale='en')
760 u"09 o'clock AM, Eastern Daylight Time"
762 As that example shows, when this function gets passed a
763 ``datetime.datetime`` value, the actual time in the formatted string is
764 adjusted to the timezone specified by the `tzinfo` parameter. If the
765 ``datetime`` is "naive" (i.e. it has no associated timezone information),
766 it is assumed to be in UTC.
768 These timezone calculations are **not** performed if the value is of type
769 ``datetime.time``, as without date information there's no way to determine
770 what a given time would translate to in a different timezone without
771 information about whether daylight savings time is in effect or not. This
772 means that time values are left as-is, and the value of the `tzinfo`
773 parameter is only used to display the timezone name if needed:
775 >>> t = time(15, 30)
776 >>> format_time(t, format='full', tzinfo=get_timezone('Europe/Paris'),
777 ... locale='fr_FR') # doctest: +SKIP
778 u'15:30:00 heure normale d\u2019Europe centrale'
779 >>> format_time(t, format='full', tzinfo=get_timezone('US/Eastern'),
780 ... locale='en_US') # doctest: +SKIP
781 u'3:30:00\u202fPM Eastern Standard Time'
783 :param time: the ``time`` or ``datetime`` object; if `None`, the current
784 time in UTC is used
785 :param format: one of "full", "long", "medium", or "short", or a custom
786 date/time pattern
787 :param tzinfo: the time-zone to apply to the time for display
788 :param locale: a `Locale` object or a locale identifier
789 """
791 # get reference date for if we need to find the right timezone variant
792 # in the pattern
793 ref_date = time.date() if isinstance(time, datetime.datetime) else None
795 time = _get_time(time, tzinfo)
797 locale = Locale.parse(locale)
798 if format in ('full', 'long', 'medium', 'short'):
799 format = get_time_format(format, locale=locale)
800 return parse_pattern(format).apply(time, locale, reference_date=ref_date)
803def format_skeleton(
804 skeleton: str,
805 datetime: _Instant = None,
806 tzinfo: datetime.tzinfo | None = None,
807 fuzzy: bool = True,
808 locale: Locale | str | None = LC_TIME,
809) -> str:
810 r"""Return a time and/or date formatted according to the given pattern.
812 The skeletons are defined in the CLDR data and provide more flexibility
813 than the simple short/long/medium formats, but are a bit harder to use.
814 The are defined using the date/time symbols without order or punctuation
815 and map to a suitable format for the given locale.
817 >>> from datetime import datetime
818 >>> t = datetime(2007, 4, 1, 15, 30)
819 >>> format_skeleton('MMMEd', t, locale='fr')
820 u'dim. 1 avr.'
821 >>> format_skeleton('MMMEd', t, locale='en')
822 u'Sun, Apr 1'
823 >>> format_skeleton('yMMd', t, locale='fi') # yMMd is not in the Finnish locale; yMd gets used
824 u'1.4.2007'
825 >>> format_skeleton('yMMd', t, fuzzy=False, locale='fi') # yMMd is not in the Finnish locale, an error is thrown
826 Traceback (most recent call last):
827 ...
828 KeyError: yMMd
830 After the skeleton is resolved to a pattern `format_datetime` is called so
831 all timezone processing etc is the same as for that.
833 :param skeleton: A date time skeleton as defined in the cldr data.
834 :param datetime: the ``time`` or ``datetime`` object; if `None`, the current
835 time in UTC is used
836 :param tzinfo: the time-zone to apply to the time for display
837 :param fuzzy: If the skeleton is not found, allow choosing a skeleton that's
838 close enough to it.
839 :param locale: a `Locale` object or a locale identifier
840 """
841 locale = Locale.parse(locale)
842 if fuzzy and skeleton not in locale.datetime_skeletons:
843 skeleton = match_skeleton(skeleton, locale.datetime_skeletons)
844 format = locale.datetime_skeletons[skeleton]
845 return format_datetime(datetime, format, tzinfo, locale)
848TIMEDELTA_UNITS: tuple[tuple[str, int], ...] = (
849 ('year', 3600 * 24 * 365),
850 ('month', 3600 * 24 * 30),
851 ('week', 3600 * 24 * 7),
852 ('day', 3600 * 24),
853 ('hour', 3600),
854 ('minute', 60),
855 ('second', 1),
856)
859def format_timedelta(
860 delta: datetime.timedelta | int,
861 granularity: Literal['year', 'month', 'week', 'day', 'hour', 'minute', 'second'] = 'second',
862 threshold: float = .85,
863 add_direction: bool = False,
864 format: Literal['narrow', 'short', 'medium', 'long'] = 'long',
865 locale: Locale | str | None = LC_TIME,
866) -> str:
867 """Return a time delta according to the rules of the given locale.
869 >>> from datetime import timedelta
870 >>> format_timedelta(timedelta(weeks=12), locale='en_US')
871 u'3 months'
872 >>> format_timedelta(timedelta(seconds=1), locale='es')
873 u'1 segundo'
875 The granularity parameter can be provided to alter the lowest unit
876 presented, which defaults to a second.
878 >>> format_timedelta(timedelta(hours=3), granularity='day', locale='en_US')
879 u'1 day'
881 The threshold parameter can be used to determine at which value the
882 presentation switches to the next higher unit. A higher threshold factor
883 means the presentation will switch later. For example:
885 >>> format_timedelta(timedelta(hours=23), threshold=0.9, locale='en_US')
886 u'1 day'
887 >>> format_timedelta(timedelta(hours=23), threshold=1.1, locale='en_US')
888 u'23 hours'
890 In addition directional information can be provided that informs
891 the user if the date is in the past or in the future:
893 >>> format_timedelta(timedelta(hours=1), add_direction=True, locale='en')
894 u'in 1 hour'
895 >>> format_timedelta(timedelta(hours=-1), add_direction=True, locale='en')
896 u'1 hour ago'
898 The format parameter controls how compact or wide the presentation is:
900 >>> format_timedelta(timedelta(hours=3), format='short', locale='en')
901 u'3 hr'
902 >>> format_timedelta(timedelta(hours=3), format='narrow', locale='en')
903 u'3h'
905 :param delta: a ``timedelta`` object representing the time difference to
906 format, or the delta in seconds as an `int` value
907 :param granularity: determines the smallest unit that should be displayed,
908 the value can be one of "year", "month", "week", "day",
909 "hour", "minute" or "second"
910 :param threshold: factor that determines at which point the presentation
911 switches to the next higher unit
912 :param add_direction: if this flag is set to `True` the return value will
913 include directional information. For instance a
914 positive timedelta will include the information about
915 it being in the future, a negative will be information
916 about the value being in the past.
917 :param format: the format, can be "narrow", "short" or "long". (
918 "medium" is deprecated, currently converted to "long" to
919 maintain compatibility)
920 :param locale: a `Locale` object or a locale identifier
921 """
922 if format not in ('narrow', 'short', 'medium', 'long'):
923 raise TypeError('Format must be one of "narrow", "short" or "long"')
924 if format == 'medium':
925 warnings.warn(
926 '"medium" value for format param of format_timedelta'
927 ' is deprecated. Use "long" instead',
928 category=DeprecationWarning,
929 stacklevel=2,
930 )
931 format = 'long'
932 if isinstance(delta, datetime.timedelta):
933 seconds = int((delta.days * 86400) + delta.seconds)
934 else:
935 seconds = delta
936 locale = Locale.parse(locale)
938 def _iter_patterns(a_unit):
939 if add_direction:
940 unit_rel_patterns = locale._data['date_fields'][a_unit]
941 if seconds >= 0:
942 yield unit_rel_patterns['future']
943 else:
944 yield unit_rel_patterns['past']
945 a_unit = f"duration-{a_unit}"
946 unit_pats = locale._data['unit_patterns'].get(a_unit, {})
947 yield unit_pats.get(format)
948 # We do not support `<alias>` tags at all while ingesting CLDR data,
949 # so these aliases specified in `root.xml` are hard-coded here:
950 # <unitLength type="long"><alias source="locale" path="../unitLength[@type='short']"/></unitLength>
951 # <unitLength type="narrow"><alias source="locale" path="../unitLength[@type='short']"/></unitLength>
952 if format in ("long", "narrow"):
953 yield unit_pats.get("short")
955 for unit, secs_per_unit in TIMEDELTA_UNITS:
956 value = abs(seconds) / secs_per_unit
957 if value >= threshold or unit == granularity:
958 if unit == granularity and value > 0:
959 value = max(1, value)
960 value = int(round(value))
961 plural_form = locale.plural_form(value)
962 pattern = None
963 for patterns in _iter_patterns(unit):
964 if patterns is not None:
965 pattern = patterns.get(plural_form) or patterns.get('other')
966 if pattern:
967 break
968 # This really should not happen
969 if pattern is None:
970 return ''
971 return pattern.replace('{0}', str(value))
973 return ''
976def _format_fallback_interval(
977 start: _Instant,
978 end: _Instant,
979 skeleton: str | None,
980 tzinfo: datetime.tzinfo | None,
981 locale: Locale | str | None = LC_TIME,
982) -> str:
983 if skeleton in locale.datetime_skeletons: # Use the given skeleton
984 format = lambda dt: format_skeleton(skeleton, dt, tzinfo, locale=locale)
985 elif all((isinstance(d, datetime.date) and not isinstance(d, datetime.datetime)) for d in (start, end)): # Both are just dates
986 format = lambda dt: format_date(dt, locale=locale)
987 elif all((isinstance(d, datetime.time) and not isinstance(d, datetime.date)) for d in (start, end)): # Both are times
988 format = lambda dt: format_time(dt, tzinfo=tzinfo, locale=locale)
989 else:
990 format = lambda dt: format_datetime(dt, tzinfo=tzinfo, locale=locale)
992 formatted_start = format(start)
993 formatted_end = format(end)
995 if formatted_start == formatted_end:
996 return format(start)
998 return (
999 locale.interval_formats.get(None, "{0}-{1}").
1000 replace("{0}", formatted_start).
1001 replace("{1}", formatted_end)
1002 )
1005def format_interval(
1006 start: _Instant,
1007 end: _Instant,
1008 skeleton: str | None = None,
1009 tzinfo: datetime.tzinfo | None = None,
1010 fuzzy: bool = True,
1011 locale: Locale | str | None = LC_TIME,
1012) -> str:
1013 """
1014 Format an interval between two instants according to the locale's rules.
1016 >>> from datetime import date, time
1017 >>> format_interval(date(2016, 1, 15), date(2016, 1, 17), "yMd", locale="fi")
1018 u'15.\u201317.1.2016'
1020 >>> format_interval(time(12, 12), time(16, 16), "Hm", locale="en_GB")
1021 '12:12\u201316:16'
1023 >>> format_interval(time(5, 12), time(16, 16), "hm", locale="en_US")
1024 '5:12\u202fAM\u2009–\u20094:16\u202fPM'
1026 >>> format_interval(time(16, 18), time(16, 24), "Hm", locale="it")
1027 '16:18\u201316:24'
1029 If the start instant equals the end instant, the interval is formatted like the instant.
1031 >>> format_interval(time(16, 18), time(16, 18), "Hm", locale="it")
1032 '16:18'
1034 Unknown skeletons fall back to "default" formatting.
1036 >>> format_interval(date(2015, 1, 1), date(2017, 1, 1), "wzq", locale="ja")
1037 '2015/01/01\uff5e2017/01/01'
1039 >>> format_interval(time(16, 18), time(16, 24), "xxx", locale="ja")
1040 '16:18:00\uff5e16:24:00'
1042 >>> format_interval(date(2016, 1, 15), date(2016, 1, 17), "xxx", locale="de")
1043 '15.01.2016\u2009–\u200917.01.2016'
1045 :param start: First instant (datetime/date/time)
1046 :param end: Second instant (datetime/date/time)
1047 :param skeleton: The "skeleton format" to use for formatting.
1048 :param tzinfo: tzinfo to use (if none is already attached)
1049 :param fuzzy: If the skeleton is not found, allow choosing a skeleton that's
1050 close enough to it.
1051 :param locale: A locale object or identifier.
1052 :return: Formatted interval
1053 """
1054 locale = Locale.parse(locale)
1056 # NB: The quote comments below are from the algorithm description in
1057 # https://www.unicode.org/reports/tr35/tr35-dates.html#intervalFormats
1059 # > Look for the intervalFormatItem element that matches the "skeleton",
1060 # > starting in the current locale and then following the locale fallback
1061 # > chain up to, but not including root.
1063 interval_formats = locale.interval_formats
1065 if skeleton not in interval_formats or not skeleton:
1066 # > If no match was found from the previous step, check what the closest
1067 # > match is in the fallback locale chain, as in availableFormats. That
1068 # > is, this allows for adjusting the string value field's width,
1069 # > including adjusting between "MMM" and "MMMM", and using different
1070 # > variants of the same field, such as 'v' and 'z'.
1071 if skeleton and fuzzy:
1072 skeleton = match_skeleton(skeleton, interval_formats)
1073 else:
1074 skeleton = None
1075 if not skeleton: # Still no match whatsoever?
1076 # > Otherwise, format the start and end datetime using the fallback pattern.
1077 return _format_fallback_interval(start, end, skeleton, tzinfo, locale)
1079 skel_formats = interval_formats[skeleton]
1081 if start == end:
1082 return format_skeleton(skeleton, start, tzinfo, fuzzy=fuzzy, locale=locale)
1084 start = _ensure_datetime_tzinfo(_get_datetime(start), tzinfo=tzinfo)
1085 end = _ensure_datetime_tzinfo(_get_datetime(end), tzinfo=tzinfo)
1087 start_fmt = DateTimeFormat(start, locale=locale)
1088 end_fmt = DateTimeFormat(end, locale=locale)
1090 # > If a match is found from previous steps, compute the calendar field
1091 # > with the greatest difference between start and end datetime. If there
1092 # > is no difference among any of the fields in the pattern, format as a
1093 # > single date using availableFormats, and return.
1095 for field in PATTERN_CHAR_ORDER: # These are in largest-to-smallest order
1096 if field in skel_formats and start_fmt.extract(field) != end_fmt.extract(field):
1097 # > If there is a match, use the pieces of the corresponding pattern to
1098 # > format the start and end datetime, as above.
1099 return "".join(
1100 parse_pattern(pattern).apply(instant, locale)
1101 for pattern, instant
1102 in zip(skel_formats[field], (start, end))
1103 )
1105 # > Otherwise, format the start and end datetime using the fallback pattern.
1107 return _format_fallback_interval(start, end, skeleton, tzinfo, locale)
1110def get_period_id(
1111 time: _Instant,
1112 tzinfo: datetime.tzinfo | None = None,
1113 type: Literal['selection'] | None = None,
1114 locale: Locale | str | None = LC_TIME,
1115) -> str:
1116 """
1117 Get the day period ID for a given time.
1119 This ID can be used as a key for the period name dictionary.
1121 >>> from datetime import time
1122 >>> get_period_names(locale="de")[get_period_id(time(7, 42), locale="de")]
1123 u'Morgen'
1125 >>> get_period_id(time(0), locale="en_US")
1126 u'midnight'
1128 >>> get_period_id(time(0), type="selection", locale="en_US")
1129 u'night1'
1131 :param time: The time to inspect.
1132 :param tzinfo: The timezone for the time. See ``format_time``.
1133 :param type: The period type to use. Either "selection" or None.
1134 The selection type is used for selecting among phrases such as
1135 “Your email arrived yesterday evening” or “Your email arrived last night”.
1136 :param locale: the `Locale` object, or a locale string
1137 :return: period ID. Something is always returned -- even if it's just "am" or "pm".
1138 """
1139 time = _get_time(time, tzinfo)
1140 seconds_past_midnight = int(time.hour * 60 * 60 + time.minute * 60 + time.second)
1141 locale = Locale.parse(locale)
1143 # The LDML rules state that the rules may not overlap, so iterating in arbitrary
1144 # order should be alright, though `at` periods should be preferred.
1145 rulesets = locale.day_period_rules.get(type, {}).items()
1147 for rule_id, rules in rulesets:
1148 for rule in rules:
1149 if "at" in rule and rule["at"] == seconds_past_midnight:
1150 return rule_id
1152 for rule_id, rules in rulesets:
1153 for rule in rules:
1154 if "from" in rule and "before" in rule:
1155 if rule["from"] < rule["before"]:
1156 if rule["from"] <= seconds_past_midnight < rule["before"]:
1157 return rule_id
1158 else:
1159 # e.g. from="21:00" before="06:00"
1160 if rule["from"] <= seconds_past_midnight < 86400 or \
1161 0 <= seconds_past_midnight < rule["before"]:
1162 return rule_id
1164 start_ok = end_ok = False
1166 if "from" in rule and seconds_past_midnight >= rule["from"]:
1167 start_ok = True
1168 if "to" in rule and seconds_past_midnight <= rule["to"]:
1169 # This rule type does not exist in the present CLDR data;
1170 # excuse the lack of test coverage.
1171 end_ok = True
1172 if "before" in rule and seconds_past_midnight < rule["before"]:
1173 end_ok = True
1174 if "after" in rule:
1175 raise NotImplementedError("'after' is deprecated as of CLDR 29.")
1177 if start_ok and end_ok:
1178 return rule_id
1180 if seconds_past_midnight < 43200:
1181 return "am"
1182 else:
1183 return "pm"
1186class ParseError(ValueError):
1187 pass
1190def parse_date(
1191 string: str,
1192 locale: Locale | str | None = LC_TIME,
1193 format: _PredefinedTimeFormat = 'medium',
1194) -> datetime.date:
1195 """Parse a date from a string.
1197 This function first tries to interpret the string as ISO-8601
1198 date format, then uses the date format for the locale as a hint to
1199 determine the order in which the date fields appear in the string.
1201 >>> parse_date('4/1/04', locale='en_US')
1202 datetime.date(2004, 4, 1)
1203 >>> parse_date('01.04.2004', locale='de_DE')
1204 datetime.date(2004, 4, 1)
1205 >>> parse_date('2004-04-01', locale='en_US')
1206 datetime.date(2004, 4, 1)
1207 >>> parse_date('2004-04-01', locale='de_DE')
1208 datetime.date(2004, 4, 1)
1210 :param string: the string containing the date
1211 :param locale: a `Locale` object or a locale identifier
1212 :param format: the format to use (see ``get_date_format``)
1213 """
1214 numbers = re.findall(r'(\d+)', string)
1215 if not numbers:
1216 raise ParseError("No numbers were found in input")
1218 # we try ISO-8601 format first, meaning similar to formats
1219 # extended YYYY-MM-DD or basic YYYYMMDD
1220 iso_alike = re.match(r'^(\d{4})-?([01]\d)-?([0-3]\d)$',
1221 string, flags=re.ASCII) # allow only ASCII digits
1222 if iso_alike:
1223 try:
1224 return datetime.date(*map(int, iso_alike.groups()))
1225 except ValueError:
1226 pass # a locale format might fit better, so let's continue
1228 format_str = get_date_format(format=format, locale=locale).pattern.lower()
1229 year_idx = format_str.index('y')
1230 month_idx = format_str.index('m')
1231 if month_idx < 0:
1232 month_idx = format_str.index('l')
1233 day_idx = format_str.index('d')
1235 indexes = sorted([(year_idx, 'Y'), (month_idx, 'M'), (day_idx, 'D')])
1236 indexes = {item[1]: idx for idx, item in enumerate(indexes)}
1238 # FIXME: this currently only supports numbers, but should also support month
1239 # names, both in the requested locale, and english
1241 year = numbers[indexes['Y']]
1242 year = 2000 + int(year) if len(year) == 2 else int(year)
1243 month = int(numbers[indexes['M']])
1244 day = int(numbers[indexes['D']])
1245 if month > 12:
1246 month, day = day, month
1247 return datetime.date(year, month, day)
1250def parse_time(
1251 string: str,
1252 locale: Locale | str | None = LC_TIME,
1253 format: _PredefinedTimeFormat = 'medium',
1254) -> datetime.time:
1255 """Parse a time from a string.
1257 This function uses the time format for the locale as a hint to determine
1258 the order in which the time fields appear in the string.
1260 >>> parse_time('15:30:00', locale='en_US')
1261 datetime.time(15, 30)
1263 :param string: the string containing the time
1264 :param locale: a `Locale` object or a locale identifier
1265 :param format: the format to use (see ``get_time_format``)
1266 :return: the parsed time
1267 :rtype: `time`
1268 """
1269 numbers = re.findall(r'(\d+)', string)
1270 if not numbers:
1271 raise ParseError("No numbers were found in input")
1273 # TODO: try ISO format first?
1274 format_str = get_time_format(format=format, locale=locale).pattern.lower()
1275 hour_idx = format_str.index('h')
1276 if hour_idx < 0:
1277 hour_idx = format_str.index('k')
1278 min_idx = format_str.index('m')
1279 sec_idx = format_str.index('s')
1281 indexes = sorted([(hour_idx, 'H'), (min_idx, 'M'), (sec_idx, 'S')])
1282 indexes = {item[1]: idx for idx, item in enumerate(indexes)}
1284 # TODO: support time zones
1286 # Check if the format specifies a period to be used;
1287 # if it does, look for 'pm' to figure out an offset.
1288 hour_offset = 0
1289 if 'a' in format_str and 'pm' in string.lower():
1290 hour_offset = 12
1292 # Parse up to three numbers from the string.
1293 minute = second = 0
1294 hour = int(numbers[indexes['H']]) + hour_offset
1295 if len(numbers) > 1:
1296 minute = int(numbers[indexes['M']])
1297 if len(numbers) > 2:
1298 second = int(numbers[indexes['S']])
1299 return datetime.time(hour, minute, second)
1302class DateTimePattern:
1304 def __init__(self, pattern: str, format: DateTimeFormat):
1305 self.pattern = pattern
1306 self.format = format
1308 def __repr__(self) -> str:
1309 return f"<{type(self).__name__} {self.pattern!r}>"
1311 def __str__(self) -> str:
1312 pat = self.pattern
1313 return pat
1315 def __mod__(self, other: DateTimeFormat) -> str:
1316 if not isinstance(other, DateTimeFormat):
1317 return NotImplemented
1318 return self.format % other
1320 def apply(
1321 self,
1322 datetime: datetime.date | datetime.time,
1323 locale: Locale | str | None,
1324 reference_date: datetime.date | None = None,
1325 ) -> str:
1326 return self % DateTimeFormat(datetime, locale, reference_date)
1329class DateTimeFormat:
1331 def __init__(
1332 self,
1333 value: datetime.date | datetime.time,
1334 locale: Locale | str,
1335 reference_date: datetime.date | None = None,
1336 ) -> None:
1337 assert isinstance(value, (datetime.date, datetime.datetime, datetime.time))
1338 if isinstance(value, (datetime.datetime, datetime.time)) and value.tzinfo is None:
1339 value = value.replace(tzinfo=UTC)
1340 self.value = value
1341 self.locale = Locale.parse(locale)
1342 self.reference_date = reference_date
1344 def __getitem__(self, name: str) -> str:
1345 char = name[0]
1346 num = len(name)
1347 if char == 'G':
1348 return self.format_era(char, num)
1349 elif char in ('y', 'Y', 'u'):
1350 return self.format_year(char, num)
1351 elif char in ('Q', 'q'):
1352 return self.format_quarter(char, num)
1353 elif char in ('M', 'L'):
1354 return self.format_month(char, num)
1355 elif char in ('w', 'W'):
1356 return self.format_week(char, num)
1357 elif char == 'd':
1358 return self.format(self.value.day, num)
1359 elif char == 'D':
1360 return self.format_day_of_year(num)
1361 elif char == 'F':
1362 return self.format_day_of_week_in_month()
1363 elif char in ('E', 'e', 'c'):
1364 return self.format_weekday(char, num)
1365 elif char in ('a', 'b', 'B'):
1366 return self.format_period(char, num)
1367 elif char == 'h':
1368 if self.value.hour % 12 == 0:
1369 return self.format(12, num)
1370 else:
1371 return self.format(self.value.hour % 12, num)
1372 elif char == 'H':
1373 return self.format(self.value.hour, num)
1374 elif char == 'K':
1375 return self.format(self.value.hour % 12, num)
1376 elif char == 'k':
1377 if self.value.hour == 0:
1378 return self.format(24, num)
1379 else:
1380 return self.format(self.value.hour, num)
1381 elif char == 'm':
1382 return self.format(self.value.minute, num)
1383 elif char == 's':
1384 return self.format(self.value.second, num)
1385 elif char == 'S':
1386 return self.format_frac_seconds(num)
1387 elif char == 'A':
1388 return self.format_milliseconds_in_day(num)
1389 elif char in ('z', 'Z', 'v', 'V', 'x', 'X', 'O'):
1390 return self.format_timezone(char, num)
1391 else:
1392 raise KeyError(f"Unsupported date/time field {char!r}")
1394 def extract(self, char: str) -> int:
1395 char = str(char)[0]
1396 if char == 'y':
1397 return self.value.year
1398 elif char == 'M':
1399 return self.value.month
1400 elif char == 'd':
1401 return self.value.day
1402 elif char == 'H':
1403 return self.value.hour
1404 elif char == 'h':
1405 return self.value.hour % 12 or 12
1406 elif char == 'm':
1407 return self.value.minute
1408 elif char == 'a':
1409 return int(self.value.hour >= 12) # 0 for am, 1 for pm
1410 else:
1411 raise NotImplementedError(f"Not implemented: extracting {char!r} from {self.value!r}")
1413 def format_era(self, char: str, num: int) -> str:
1414 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[max(3, num)]
1415 era = int(self.value.year >= 0)
1416 return get_era_names(width, self.locale)[era]
1418 def format_year(self, char: str, num: int) -> str:
1419 value = self.value.year
1420 if char.isupper():
1421 value = self.value.isocalendar()[0]
1422 year = self.format(value, num)
1423 if num == 2:
1424 year = year[-2:]
1425 return year
1427 def format_quarter(self, char: str, num: int) -> str:
1428 quarter = (self.value.month - 1) // 3 + 1
1429 if num <= 2:
1430 return '%0*d' % (num, quarter)
1431 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[num]
1432 context = {'Q': 'format', 'q': 'stand-alone'}[char]
1433 return get_quarter_names(width, context, self.locale)[quarter]
1435 def format_month(self, char: str, num: int) -> str:
1436 if num <= 2:
1437 return '%0*d' % (num, self.value.month)
1438 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[num]
1439 context = {'M': 'format', 'L': 'stand-alone'}[char]
1440 return get_month_names(width, context, self.locale)[self.value.month]
1442 def format_week(self, char: str, num: int) -> str:
1443 if char.islower(): # week of year
1444 day_of_year = self.get_day_of_year()
1445 week = self.get_week_number(day_of_year)
1446 if week == 0:
1447 date = self.value - datetime.timedelta(days=day_of_year)
1448 week = self.get_week_number(self.get_day_of_year(date),
1449 date.weekday())
1450 return self.format(week, num)
1451 else: # week of month
1452 week = self.get_week_number(self.value.day)
1453 if week == 0:
1454 date = self.value - datetime.timedelta(days=self.value.day)
1455 week = self.get_week_number(date.day, date.weekday())
1456 return str(week)
1458 def format_weekday(self, char: str = 'E', num: int = 4) -> str:
1459 """
1460 Return weekday from parsed datetime according to format pattern.
1462 >>> from datetime import date
1463 >>> format = DateTimeFormat(date(2016, 2, 28), Locale.parse('en_US'))
1464 >>> format.format_weekday()
1465 u'Sunday'
1467 'E': Day of week - Use one through three letters for the abbreviated day name, four for the full (wide) name,
1468 five for the narrow name, or six for the short name.
1469 >>> format.format_weekday('E',2)
1470 u'Sun'
1472 'e': Local day of week. Same as E except adds a numeric value that will depend on the local starting day of the
1473 week, using one or two letters. For this example, Monday is the first day of the week.
1474 >>> format.format_weekday('e',2)
1475 '01'
1477 'c': Stand-Alone local day of week - Use one letter for the local numeric value (same as 'e'), three for the
1478 abbreviated day name, four for the full (wide) name, five for the narrow name, or six for the short name.
1479 >>> format.format_weekday('c',1)
1480 '1'
1482 :param char: pattern format character ('e','E','c')
1483 :param num: count of format character
1485 """
1486 if num < 3:
1487 if char.islower():
1488 value = 7 - self.locale.first_week_day + self.value.weekday()
1489 return self.format(value % 7 + 1, num)
1490 num = 3
1491 weekday = self.value.weekday()
1492 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow', 6: 'short'}[num]
1493 context = "stand-alone" if char == "c" else "format"
1494 return get_day_names(width, context, self.locale)[weekday]
1496 def format_day_of_year(self, num: int) -> str:
1497 return self.format(self.get_day_of_year(), num)
1499 def format_day_of_week_in_month(self) -> str:
1500 return str((self.value.day - 1) // 7 + 1)
1502 def format_period(self, char: str, num: int) -> str:
1503 """
1504 Return period from parsed datetime according to format pattern.
1506 >>> from datetime import datetime, time
1507 >>> format = DateTimeFormat(time(13, 42), 'fi_FI')
1508 >>> format.format_period('a', 1)
1509 u'ip.'
1510 >>> format.format_period('b', 1)
1511 u'iltap.'
1512 >>> format.format_period('b', 4)
1513 u'iltapäivä'
1514 >>> format.format_period('B', 4)
1515 u'iltapäivällä'
1516 >>> format.format_period('B', 5)
1517 u'ip.'
1519 >>> format = DateTimeFormat(datetime(2022, 4, 28, 6, 27), 'zh_Hant')
1520 >>> format.format_period('a', 1)
1521 u'上午'
1522 >>> format.format_period('B', 1)
1523 u'清晨'
1525 :param char: pattern format character ('a', 'b', 'B')
1526 :param num: count of format character
1528 """
1529 widths = [{3: 'abbreviated', 4: 'wide', 5: 'narrow'}[max(3, num)],
1530 'wide', 'narrow', 'abbreviated']
1531 if char == 'a':
1532 period = 'pm' if self.value.hour >= 12 else 'am'
1533 context = 'format'
1534 else:
1535 period = get_period_id(self.value, locale=self.locale)
1536 context = 'format' if char == 'B' else 'stand-alone'
1537 for width in widths:
1538 period_names = get_period_names(context=context, width=width, locale=self.locale)
1539 if period in period_names:
1540 return period_names[period]
1541 raise ValueError(f"Could not format period {period} in {self.locale}")
1543 def format_frac_seconds(self, num: int) -> str:
1544 """ Return fractional seconds.
1546 Rounds the time's microseconds to the precision given by the number \
1547 of digits passed in.
1548 """
1549 value = self.value.microsecond / 1000000
1550 return self.format(round(value, num) * 10**num, num)
1552 def format_milliseconds_in_day(self, num):
1553 msecs = self.value.microsecond // 1000 + self.value.second * 1000 + \
1554 self.value.minute * 60000 + self.value.hour * 3600000
1555 return self.format(msecs, num)
1557 def format_timezone(self, char: str, num: int) -> str:
1558 width = {3: 'short', 4: 'long', 5: 'iso8601'}[max(3, num)]
1560 # It could be that we only receive a time to format, but also have a
1561 # reference date which is important to distinguish between timezone
1562 # variants (summer/standard time)
1563 value = self.value
1564 if self.reference_date:
1565 value = datetime.datetime.combine(self.reference_date, self.value)
1567 if char == 'z':
1568 return get_timezone_name(value, width, locale=self.locale)
1569 elif char == 'Z':
1570 if num == 5:
1571 return get_timezone_gmt(value, width, locale=self.locale, return_z=True)
1572 return get_timezone_gmt(value, width, locale=self.locale)
1573 elif char == 'O':
1574 if num == 4:
1575 return get_timezone_gmt(value, width, locale=self.locale)
1576 # TODO: To add support for O:1
1577 elif char == 'v':
1578 return get_timezone_name(value.tzinfo, width,
1579 locale=self.locale)
1580 elif char == 'V':
1581 if num == 1:
1582 return get_timezone_name(value.tzinfo, width,
1583 uncommon=True, locale=self.locale)
1584 elif num == 2:
1585 return get_timezone_name(value.tzinfo, locale=self.locale, return_zone=True)
1586 elif num == 3:
1587 return get_timezone_location(value.tzinfo, locale=self.locale, return_city=True)
1588 return get_timezone_location(value.tzinfo, locale=self.locale)
1589 # Included additional elif condition to add support for 'Xx' in timezone format
1590 elif char == 'X':
1591 if num == 1:
1592 return get_timezone_gmt(value, width='iso8601_short', locale=self.locale,
1593 return_z=True)
1594 elif num in (2, 4):
1595 return get_timezone_gmt(value, width='short', locale=self.locale,
1596 return_z=True)
1597 elif num in (3, 5):
1598 return get_timezone_gmt(value, width='iso8601', locale=self.locale,
1599 return_z=True)
1600 elif char == 'x':
1601 if num == 1:
1602 return get_timezone_gmt(value, width='iso8601_short', locale=self.locale)
1603 elif num in (2, 4):
1604 return get_timezone_gmt(value, width='short', locale=self.locale)
1605 elif num in (3, 5):
1606 return get_timezone_gmt(value, width='iso8601', locale=self.locale)
1608 def format(self, value: SupportsInt, length: int) -> str:
1609 return '%0*d' % (length, value)
1611 def get_day_of_year(self, date: datetime.date | None = None) -> int:
1612 if date is None:
1613 date = self.value
1614 return (date - date.replace(month=1, day=1)).days + 1
1616 def get_week_number(self, day_of_period: int, day_of_week: int | None = None) -> int:
1617 """Return the number of the week of a day within a period. This may be
1618 the week number in a year or the week number in a month.
1620 Usually this will return a value equal to or greater than 1, but if the
1621 first week of the period is so short that it actually counts as the last
1622 week of the previous period, this function will return 0.
1624 >>> date = datetime.date(2006, 1, 8)
1625 >>> DateTimeFormat(date, 'de_DE').get_week_number(6)
1626 1
1627 >>> DateTimeFormat(date, 'en_US').get_week_number(6)
1628 2
1630 :param day_of_period: the number of the day in the period (usually
1631 either the day of month or the day of year)
1632 :param day_of_week: the week day; if omitted, the week day of the
1633 current date is assumed
1634 """
1635 if day_of_week is None:
1636 day_of_week = self.value.weekday()
1637 first_day = (day_of_week - self.locale.first_week_day -
1638 day_of_period + 1) % 7
1639 if first_day < 0:
1640 first_day += 7
1641 week_number = (day_of_period + first_day - 1) // 7
1643 if 7 - first_day >= self.locale.min_week_days:
1644 week_number += 1
1646 if self.locale.first_week_day == 0:
1647 # Correct the weeknumber in case of iso-calendar usage (first_week_day=0).
1648 # If the weeknumber exceeds the maximum number of weeks for the given year
1649 # we must count from zero.For example the above calculation gives week 53
1650 # for 2018-12-31. By iso-calender definition 2018 has a max of 52
1651 # weeks, thus the weeknumber must be 53-52=1.
1652 max_weeks = datetime.date(year=self.value.year, day=28, month=12).isocalendar()[1]
1653 if week_number > max_weeks:
1654 week_number -= max_weeks
1656 return week_number
1659PATTERN_CHARS: dict[str, list[int] | None] = {
1660 'G': [1, 2, 3, 4, 5], # era
1661 'y': None, 'Y': None, 'u': None, # year
1662 'Q': [1, 2, 3, 4, 5], 'q': [1, 2, 3, 4, 5], # quarter
1663 'M': [1, 2, 3, 4, 5], 'L': [1, 2, 3, 4, 5], # month
1664 'w': [1, 2], 'W': [1], # week
1665 'd': [1, 2], 'D': [1, 2, 3], 'F': [1], 'g': None, # day
1666 'E': [1, 2, 3, 4, 5, 6], 'e': [1, 2, 3, 4, 5, 6], 'c': [1, 3, 4, 5, 6], # week day
1667 'a': [1, 2, 3, 4, 5], 'b': [1, 2, 3, 4, 5], 'B': [1, 2, 3, 4, 5], # period
1668 'h': [1, 2], 'H': [1, 2], 'K': [1, 2], 'k': [1, 2], # hour
1669 'm': [1, 2], # minute
1670 's': [1, 2], 'S': None, 'A': None, # second
1671 'z': [1, 2, 3, 4], 'Z': [1, 2, 3, 4, 5], 'O': [1, 4], 'v': [1, 4], # zone
1672 'V': [1, 2, 3, 4], 'x': [1, 2, 3, 4, 5], 'X': [1, 2, 3, 4, 5], # zone
1673}
1675#: The pattern characters declared in the Date Field Symbol Table
1676#: (https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table)
1677#: in order of decreasing magnitude.
1678PATTERN_CHAR_ORDER = "GyYuUQqMLlwWdDFgEecabBChHKkjJmsSAzZOvVXx"
1681def parse_pattern(pattern: str | DateTimePattern) -> DateTimePattern:
1682 """Parse date, time, and datetime format patterns.
1684 >>> parse_pattern("MMMMd").format
1685 u'%(MMMM)s%(d)s'
1686 >>> parse_pattern("MMM d, yyyy").format
1687 u'%(MMM)s %(d)s, %(yyyy)s'
1689 Pattern can contain literal strings in single quotes:
1691 >>> parse_pattern("H:mm' Uhr 'z").format
1692 u'%(H)s:%(mm)s Uhr %(z)s'
1694 An actual single quote can be used by using two adjacent single quote
1695 characters:
1697 >>> parse_pattern("hh' o''clock'").format
1698 u"%(hh)s o'clock"
1700 :param pattern: the formatting pattern to parse
1701 """
1702 if isinstance(pattern, DateTimePattern):
1703 return pattern
1704 return _cached_parse_pattern(pattern)
1707@lru_cache(maxsize=1024)
1708def _cached_parse_pattern(pattern: str) -> DateTimePattern:
1709 result = []
1711 for tok_type, tok_value in tokenize_pattern(pattern):
1712 if tok_type == "chars":
1713 result.append(tok_value.replace('%', '%%'))
1714 elif tok_type == "field":
1715 fieldchar, fieldnum = tok_value
1716 limit = PATTERN_CHARS[fieldchar]
1717 if limit and fieldnum not in limit:
1718 raise ValueError(f"Invalid length for field: {fieldchar * fieldnum!r}")
1719 result.append('%%(%s)s' % (fieldchar * fieldnum))
1720 else:
1721 raise NotImplementedError(f"Unknown token type: {tok_type}")
1722 return DateTimePattern(pattern, ''.join(result))
1725def tokenize_pattern(pattern: str) -> list[tuple[str, str | tuple[str, int]]]:
1726 """
1727 Tokenize date format patterns.
1729 Returns a list of (token_type, token_value) tuples.
1731 ``token_type`` may be either "chars" or "field".
1733 For "chars" tokens, the value is the literal value.
1735 For "field" tokens, the value is a tuple of (field character, repetition count).
1737 :param pattern: Pattern string
1738 :type pattern: str
1739 :rtype: list[tuple]
1740 """
1741 result = []
1742 quotebuf = None
1743 charbuf = []
1744 fieldchar = ['']
1745 fieldnum = [0]
1747 def append_chars():
1748 result.append(('chars', ''.join(charbuf).replace('\0', "'")))
1749 del charbuf[:]
1751 def append_field():
1752 result.append(('field', (fieldchar[0], fieldnum[0])))
1753 fieldchar[0] = ''
1754 fieldnum[0] = 0
1756 for char in pattern.replace("''", '\0'):
1757 if quotebuf is None:
1758 if char == "'": # quote started
1759 if fieldchar[0]:
1760 append_field()
1761 elif charbuf:
1762 append_chars()
1763 quotebuf = []
1764 elif char in PATTERN_CHARS:
1765 if charbuf:
1766 append_chars()
1767 if char == fieldchar[0]:
1768 fieldnum[0] += 1
1769 else:
1770 if fieldchar[0]:
1771 append_field()
1772 fieldchar[0] = char
1773 fieldnum[0] = 1
1774 else:
1775 if fieldchar[0]:
1776 append_field()
1777 charbuf.append(char)
1779 elif quotebuf is not None:
1780 if char == "'": # end of quote
1781 charbuf.extend(quotebuf)
1782 quotebuf = None
1783 else: # inside quote
1784 quotebuf.append(char)
1786 if fieldchar[0]:
1787 append_field()
1788 elif charbuf:
1789 append_chars()
1791 return result
1794def untokenize_pattern(tokens: Iterable[tuple[str, str | tuple[str, int]]]) -> str:
1795 """
1796 Turn a date format pattern token stream back into a string.
1798 This is the reverse operation of ``tokenize_pattern``.
1800 :type tokens: Iterable[tuple]
1801 :rtype: str
1802 """
1803 output = []
1804 for tok_type, tok_value in tokens:
1805 if tok_type == "field":
1806 output.append(tok_value[0] * tok_value[1])
1807 elif tok_type == "chars":
1808 if not any(ch in PATTERN_CHARS for ch in tok_value): # No need to quote
1809 output.append(tok_value)
1810 else:
1811 output.append("'%s'" % tok_value.replace("'", "''"))
1812 return "".join(output)
1815def split_interval_pattern(pattern: str) -> list[str]:
1816 """
1817 Split an interval-describing datetime pattern into multiple pieces.
1819 > The pattern is then designed to be broken up into two pieces by determining the first repeating field.
1820 - https://www.unicode.org/reports/tr35/tr35-dates.html#intervalFormats
1822 >>> split_interval_pattern(u'E d.M. \u2013 E d.M.')
1823 [u'E d.M. \u2013 ', 'E d.M.']
1824 >>> split_interval_pattern("Y 'text' Y 'more text'")
1825 ["Y 'text '", "Y 'more text'"]
1826 >>> split_interval_pattern(u"E, MMM d \u2013 E")
1827 [u'E, MMM d \u2013 ', u'E']
1828 >>> split_interval_pattern("MMM d")
1829 ['MMM d']
1830 >>> split_interval_pattern("y G")
1831 ['y G']
1832 >>> split_interval_pattern(u"MMM d \u2013 d")
1833 [u'MMM d \u2013 ', u'd']
1835 :param pattern: Interval pattern string
1836 :return: list of "subpatterns"
1837 """
1839 seen_fields = set()
1840 parts = [[]]
1842 for tok_type, tok_value in tokenize_pattern(pattern):
1843 if tok_type == "field":
1844 if tok_value[0] in seen_fields: # Repeated field
1845 parts.append([])
1846 seen_fields.clear()
1847 seen_fields.add(tok_value[0])
1848 parts[-1].append((tok_type, tok_value))
1850 return [untokenize_pattern(tokens) for tokens in parts]
1853def match_skeleton(skeleton: str, options: Iterable[str], allow_different_fields: bool = False) -> str | None:
1854 """
1855 Find the closest match for the given datetime skeleton among the options given.
1857 This uses the rules outlined in the TR35 document.
1859 >>> match_skeleton('yMMd', ('yMd', 'yMMMd'))
1860 'yMd'
1862 >>> match_skeleton('yMMd', ('jyMMd',), allow_different_fields=True)
1863 'jyMMd'
1865 >>> match_skeleton('yMMd', ('qyMMd',), allow_different_fields=False)
1867 >>> match_skeleton('hmz', ('hmv',))
1868 'hmv'
1870 :param skeleton: The skeleton to match
1871 :type skeleton: str
1872 :param options: An iterable of other skeletons to match against
1873 :type options: Iterable[str]
1874 :return: The closest skeleton match, or if no match was found, None.
1875 :rtype: str|None
1876 """
1878 # TODO: maybe implement pattern expansion?
1880 # Based on the implementation in
1881 # http://source.icu-project.org/repos/icu/icu4j/trunk/main/classes/core/src/com/ibm/icu/text/DateIntervalInfo.java
1883 # Filter out falsy values and sort for stability; when `interval_formats` is passed in, there may be a None key.
1884 options = sorted(option for option in options if option)
1886 if 'z' in skeleton and not any('z' in option for option in options):
1887 skeleton = skeleton.replace('z', 'v')
1889 get_input_field_width = dict(t[1] for t in tokenize_pattern(skeleton) if t[0] == "field").get
1890 best_skeleton = None
1891 best_distance = None
1892 for option in options:
1893 get_opt_field_width = dict(t[1] for t in tokenize_pattern(option) if t[0] == "field").get
1894 distance = 0
1895 for field in PATTERN_CHARS:
1896 input_width = get_input_field_width(field, 0)
1897 opt_width = get_opt_field_width(field, 0)
1898 if input_width == opt_width:
1899 continue
1900 if opt_width == 0 or input_width == 0:
1901 if not allow_different_fields: # This one is not okay
1902 option = None
1903 break
1904 distance += 0x1000 # Magic weight constant for "entirely different fields"
1905 elif field == 'M' and ((input_width > 2 and opt_width <= 2) or (input_width <= 2 and opt_width > 2)):
1906 distance += 0x100 # Magic weight for "text turns into a number"
1907 else:
1908 distance += abs(input_width - opt_width)
1910 if not option: # We lost the option along the way (probably due to "allow_different_fields")
1911 continue
1913 if not best_skeleton or distance < best_distance:
1914 best_skeleton = option
1915 best_distance = distance
1917 if distance == 0: # Found a perfect match!
1918 break
1920 return best_skeleton