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

728 statements  

1""" 

2babel.dates 

3~~~~~~~~~~~ 

4 

5Locale dependent formatting and parsing of dates and times. 

6 

7The default locale for the functions in this module is determined by the 

8following environment variables, in that order: 

9 

10 * ``LC_TIME``, 

11 * ``LC_ALL``, and 

12 * ``LANG`` 

13 

14:copyright: (c) 2013-2025 by the Babel Team. 

15:license: BSD, see LICENSE for more details. 

16""" 

17 

18from __future__ import annotations 

19 

20import math 

21import re 

22import warnings 

23from functools import lru_cache 

24from typing import TYPE_CHECKING, Literal, SupportsInt 

25 

26try: 

27 import pytz 

28except ModuleNotFoundError: 

29 pytz = None 

30 import zoneinfo 

31 

32import datetime 

33from collections.abc import Iterable 

34 

35from babel import localtime 

36from babel.core import Locale, default_locale, get_global 

37from babel.localedata import LocaleDataDict 

38 

39if TYPE_CHECKING: 

40 from typing_extensions import TypeAlias 

41 

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 

46 

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 

53 

54NO_INHERITANCE_MARKER = '\u2205\u2205\u2205' 

55 

56UTC = datetime.timezone.utc 

57LOCALTZ = localtime.LOCALTZ 

58 

59LC_TIME = default_locale('LC_TIME') 

60 

61 

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 

67 

68 if hasattr(tz, 'localize'): # pytz 

69 return tz.localize(dt) 

70 

71 if dt.tzinfo is None: 

72 # convert naive to localized 

73 return dt.replace(tzinfo=tz) 

74 

75 # convert timezones 

76 return dt.astimezone(tz) 

77 

78 

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. 

84 

85 See the docs for this function's callers for semantics. 

86 

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 

105 

106 

107def _get_tz_name(dt_or_tzinfo: _DtOrTzinfo) -> str: 

108 """ 

109 Get the timezone name out of a time, datetime, or tzinfo object. 

110 

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)) 

120 

121 

122def _get_datetime(instant: _Instant) -> datetime.datetime: 

123 """ 

124 Get a datetime out of an "instant" (date, time, datetime, number). 

125 

126 .. warning:: The return values of this function may depend on the system clock. 

127 

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. 

130 

131 Dates are converted to naive datetimes with midnight as the time component. 

132 

133 >>> from datetime import date, datetime 

134 >>> _get_datetime(date(2015, 1, 1)) 

135 datetime.datetime(2015, 1, 1, 0, 0) 

136 

137 UNIX timestamps are converted to datetimes. 

138 

139 >>> _get_datetime(1400000000) 

140 datetime.datetime(2014, 5, 13, 16, 53, 20) 

141 

142 Other values are passed through as-is. 

143 

144 >>> x = datetime(2015, 1, 1) 

145 >>> _get_datetime(x) is x 

146 True 

147 

148 :param instant: date, time, datetime, integer, float or None 

149 :type instant: date|time|datetime|int|float|None 

150 :return: a datetime 

151 :rtype: datetime 

152 """ 

153 if instant is None: 

154 return datetime.datetime.now(UTC).replace(tzinfo=None) 

155 elif isinstance(instant, (int, float)): 

156 return datetime.datetime.fromtimestamp(instant, UTC).replace(tzinfo=None) 

157 elif isinstance(instant, datetime.time): 

158 return datetime.datetime.combine(datetime.date.today(), instant) 

159 elif isinstance(instant, datetime.date) and not isinstance(instant, datetime.datetime): # fmt: skip 

160 return datetime.datetime.combine(instant, datetime.time()) 

161 # TODO (3.x): Add an assertion/type check for this fallthrough branch: 

162 return instant 

163 

164 

165def _ensure_datetime_tzinfo( 

166 dt: datetime.datetime, 

167 tzinfo: datetime.tzinfo | None = None, 

168) -> datetime.datetime: 

169 """ 

170 Ensure the datetime passed has an attached tzinfo. 

171 

172 If the datetime is tz-naive to begin with, UTC is attached. 

173 

174 If a tzinfo is passed in, the datetime is normalized to that timezone. 

175 

176 >>> from datetime import datetime 

177 >>> _get_tz_name(_ensure_datetime_tzinfo(datetime(2015, 1, 1))) 

178 'UTC' 

179 

180 >>> tz = get_timezone("Europe/Stockholm") 

181 >>> _ensure_datetime_tzinfo(datetime(2015, 1, 1, 13, 15, tzinfo=UTC), tzinfo=tz).hour 

182 14 

183 

184 :param datetime: Datetime to augment. 

185 :param tzinfo: optional tzinfo 

186 :return: datetime with tzinfo 

187 :rtype: datetime 

188 """ 

189 if dt.tzinfo is None: 

190 dt = dt.replace(tzinfo=UTC) 

191 if tzinfo is not None: 

192 dt = dt.astimezone(get_timezone(tzinfo)) 

193 if hasattr(tzinfo, 'normalize'): # pytz 

194 dt = tzinfo.normalize(dt) 

195 return dt 

196 

197 

198def _get_time( 

199 time: datetime.time | datetime.datetime | None, 

200 tzinfo: datetime.tzinfo | None = None, 

201) -> datetime.time: 

202 """ 

203 Get a timezoned time from a given instant. 

204 

205 .. warning:: The return values of this function may depend on the system clock. 

206 

207 :param time: time, datetime or None 

208 :rtype: time 

209 """ 

210 if time is None: 

211 time = datetime.datetime.now(UTC) 

212 elif isinstance(time, (int, float)): 

213 time = datetime.datetime.fromtimestamp(time, UTC) 

214 

215 if time.tzinfo is None: 

216 time = time.replace(tzinfo=UTC) 

217 

218 if isinstance(time, datetime.datetime): 

219 if tzinfo is not None: 

220 time = time.astimezone(tzinfo) 

221 if hasattr(tzinfo, 'normalize'): # pytz 

222 time = tzinfo.normalize(time) 

223 time = time.timetz() 

224 elif tzinfo is not None: 

225 time = time.replace(tzinfo=tzinfo) 

226 return time 

227 

228 

229def get_timezone(zone: str | datetime.tzinfo | None = None) -> datetime.tzinfo: 

230 """Looks up a timezone by name and returns it. The timezone object 

231 returned comes from ``pytz`` or ``zoneinfo``, whichever is available. 

232 It corresponds to the `tzinfo` interface and can be used with all of 

233 the functions of Babel that operate with dates. 

234 

235 If a timezone is not known a :exc:`LookupError` is raised. If `zone` 

236 is ``None`` a local zone object is returned. 

237 

238 :param zone: the name of the timezone to look up. If a timezone object 

239 itself is passed in, it's returned unchanged. 

240 """ 

241 if zone is None: 

242 return LOCALTZ 

243 if not isinstance(zone, str): 

244 return zone 

245 

246 if pytz: 

247 try: 

248 return pytz.timezone(zone) 

249 except pytz.UnknownTimeZoneError as e: 

250 exc = e 

251 else: 

252 assert zoneinfo 

253 try: 

254 return zoneinfo.ZoneInfo(zone) 

255 except zoneinfo.ZoneInfoNotFoundError as e: 

256 exc = e 

257 

258 raise LookupError(f"Unknown timezone {zone}") from exc 

259 

260 

261def get_period_names( 

262 width: Literal['abbreviated', 'narrow', 'wide'] = 'wide', 

263 context: _Context = 'stand-alone', 

264 locale: Locale | str | None = None, 

265) -> LocaleDataDict: 

266 """Return the names for day periods (AM/PM) used by the locale. 

267 

268 >>> get_period_names(locale='en_US')['am'] 

269 'AM' 

270 

271 :param width: the width to use, one of "abbreviated", "narrow", or "wide" 

272 :param context: the context, either "format" or "stand-alone" 

273 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale. 

274 """ 

275 return Locale.parse(locale or LC_TIME).day_periods[context][width] 

276 

277 

278def get_day_names( 

279 width: Literal['abbreviated', 'narrow', 'short', 'wide'] = 'wide', 

280 context: _Context = 'format', 

281 locale: Locale | str | None = None, 

282) -> LocaleDataDict: 

283 """Return the day names used by the locale for the specified format. 

284 

285 >>> get_day_names('wide', locale='en_US')[1] 

286 'Tuesday' 

287 >>> get_day_names('short', locale='en_US')[1] 

288 'Tu' 

289 >>> get_day_names('abbreviated', locale='es')[1] 

290 'mar' 

291 >>> get_day_names('narrow', context='stand-alone', locale='de_DE')[1] 

292 'D' 

293 

294 :param width: the width to use, one of "wide", "abbreviated", "short" or "narrow" 

295 :param context: the context, either "format" or "stand-alone" 

296 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale. 

297 """ 

298 return Locale.parse(locale or LC_TIME).days[context][width] 

299 

300 

301def get_month_names( 

302 width: Literal['abbreviated', 'narrow', 'wide'] = 'wide', 

303 context: _Context = 'format', 

304 locale: Locale | str | None = None, 

305) -> LocaleDataDict: 

306 """Return the month names used by the locale for the specified format. 

307 

308 >>> get_month_names('wide', locale='en_US')[1] 

309 'January' 

310 >>> get_month_names('abbreviated', locale='es')[1] 

311 'ene' 

312 >>> get_month_names('narrow', context='stand-alone', locale='de_DE')[1] 

313 'J' 

314 

315 :param width: the width to use, one of "wide", "abbreviated", or "narrow" 

316 :param context: the context, either "format" or "stand-alone" 

317 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale. 

318 """ 

319 return Locale.parse(locale or LC_TIME).months[context][width] 

320 

321 

322def get_quarter_names( 

323 width: Literal['abbreviated', 'narrow', 'wide'] = 'wide', 

324 context: _Context = 'format', 

325 locale: Locale | str | None = None, 

326) -> LocaleDataDict: 

327 """Return the quarter names used by the locale for the specified format. 

328 

329 >>> get_quarter_names('wide', locale='en_US')[1] 

330 '1st quarter' 

331 >>> get_quarter_names('abbreviated', locale='de_DE')[1] 

332 'Q1' 

333 >>> get_quarter_names('narrow', locale='de_DE')[1] 

334 '1' 

335 

336 :param width: the width to use, one of "wide", "abbreviated", or "narrow" 

337 :param context: the context, either "format" or "stand-alone" 

338 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale. 

339 """ 

340 return Locale.parse(locale or LC_TIME).quarters[context][width] 

341 

342 

343def get_era_names( 

344 width: Literal['abbreviated', 'narrow', 'wide'] = 'wide', 

345 locale: Locale | str | None = None, 

346) -> LocaleDataDict: 

347 """Return the era names used by the locale for the specified format. 

348 

349 >>> get_era_names('wide', locale='en_US')[1] 

350 'Anno Domini' 

351 >>> get_era_names('abbreviated', locale='de_DE')[1] 

352 'n. Chr.' 

353 

354 :param width: the width to use, either "wide", "abbreviated", or "narrow" 

355 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale. 

356 """ 

357 return Locale.parse(locale or LC_TIME).eras[width] 

358 

359 

360def get_date_format( 

361 format: _PredefinedTimeFormat = 'medium', 

362 locale: Locale | str | None = None, 

363) -> DateTimePattern: 

364 """Return the date formatting patterns used by the locale for the specified 

365 format. 

366 

367 >>> get_date_format(locale='en_US') 

368 <DateTimePattern 'MMM d, y'> 

369 >>> get_date_format('full', locale='de_DE') 

370 <DateTimePattern 'EEEE, d. MMMM y'> 

371 

372 :param format: the format to use, one of "full", "long", "medium", or 

373 "short" 

374 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale. 

375 """ 

376 return Locale.parse(locale or LC_TIME).date_formats[format] 

377 

378 

379def get_datetime_format( 

380 format: _PredefinedTimeFormat = 'medium', 

381 locale: Locale | str | None = None, 

382) -> DateTimePattern: 

383 """Return the datetime formatting patterns used by the locale for the 

384 specified format. 

385 

386 >>> get_datetime_format(locale='en_US') 

387 '{1}, {0}' 

388 

389 :param format: the format to use, one of "full", "long", "medium", or 

390 "short" 

391 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale. 

392 """ 

393 patterns = Locale.parse(locale or LC_TIME).datetime_formats 

394 if format not in patterns: 

395 format = None 

396 return patterns[format] 

397 

398 

399def get_time_format( 

400 format: _PredefinedTimeFormat = 'medium', 

401 locale: Locale | str | None = None, 

402) -> DateTimePattern: 

403 """Return the time formatting patterns used by the locale for the specified 

404 format. 

405 

406 >>> get_time_format(locale='en_US') 

407 <DateTimePattern 'h:mm:ss\\u202fa'> 

408 >>> get_time_format('full', locale='de_DE') 

409 <DateTimePattern 'HH:mm:ss zzzz'> 

410 

411 :param format: the format to use, one of "full", "long", "medium", or 

412 "short" 

413 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale. 

414 """ 

415 return Locale.parse(locale or LC_TIME).time_formats[format] 

416 

417 

418def get_timezone_gmt( 

419 datetime: _Instant = None, 

420 width: Literal['long', 'short', 'iso8601', 'iso8601_short'] = 'long', 

421 locale: Locale | str | None = None, 

422 return_z: bool = False, 

423) -> str: 

424 """Return the timezone associated with the given `datetime` object formatted 

425 as string indicating the offset from GMT. 

426 

427 >>> from datetime import datetime 

428 >>> dt = datetime(2007, 4, 1, 15, 30) 

429 >>> get_timezone_gmt(dt, locale='en') 

430 'GMT+00:00' 

431 >>> get_timezone_gmt(dt, locale='en', return_z=True) 

432 'Z' 

433 >>> get_timezone_gmt(dt, locale='en', width='iso8601_short') 

434 '+00' 

435 >>> tz = get_timezone('America/Los_Angeles') 

436 >>> dt = _localize(tz, datetime(2007, 4, 1, 15, 30)) 

437 >>> get_timezone_gmt(dt, locale='en') 

438 'GMT-07:00' 

439 >>> get_timezone_gmt(dt, 'short', locale='en') 

440 '-0700' 

441 >>> get_timezone_gmt(dt, locale='en', width='iso8601_short') 

442 '-07' 

443 

444 The long format depends on the locale, for example in France the acronym 

445 UTC string is used instead of GMT: 

446 

447 >>> get_timezone_gmt(dt, 'long', locale='fr_FR') 

448 'UTC-07:00' 

449 

450 .. versionadded:: 0.9 

451 

452 :param datetime: the ``datetime`` object; if `None`, the current date and 

453 time in UTC is used 

454 :param width: either "long" or "short" or "iso8601" or "iso8601_short" 

455 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale. 

456 :param return_z: True or False; Function returns indicator "Z" 

457 when local time offset is 0 

458 """ 

459 datetime = _ensure_datetime_tzinfo(_get_datetime(datetime)) 

460 locale = Locale.parse(locale or LC_TIME) 

461 

462 offset = datetime.tzinfo.utcoffset(datetime) 

463 seconds = offset.days * 24 * 60 * 60 + offset.seconds 

464 hours, seconds = divmod(seconds, 3600) 

465 if return_z and hours == 0 and seconds == 0: 

466 return 'Z' 

467 elif seconds == 0 and width == 'iso8601_short': 

468 return '%+03d' % hours 

469 elif width == 'short' or width == 'iso8601_short': 

470 pattern = '%+03d%02d' 

471 elif width == 'iso8601': 

472 pattern = '%+03d:%02d' 

473 else: 

474 pattern = locale.zone_formats['gmt'] % '%+03d:%02d' 

475 return pattern % (hours, seconds // 60) 

476 

477 

478def get_timezone_location( 

479 dt_or_tzinfo: _DtOrTzinfo = None, 

480 locale: Locale | str | None = None, 

481 return_city: bool = False, 

482) -> str: 

483 """Return a representation of the given timezone using "location format". 

484 

485 The result depends on both the local display name of the country and the 

486 city associated with the time zone: 

487 

488 >>> tz = get_timezone('America/St_Johns') 

489 >>> print(get_timezone_location(tz, locale='de_DE')) 

490 Kanada (St. John’s) (Ortszeit) 

491 >>> print(get_timezone_location(tz, locale='en')) 

492 Canada (St. John’s) Time 

493 >>> print(get_timezone_location(tz, locale='en', return_city=True)) 

494 St. John’s 

495 >>> tz = get_timezone('America/Mexico_City') 

496 >>> get_timezone_location(tz, locale='de_DE') 

497 'Mexiko (Mexiko-Stadt) (Ortszeit)' 

498 

499 If the timezone is associated with a country that uses only a single 

500 timezone, just the localized country name is returned: 

501 

502 >>> tz = get_timezone('Europe/Berlin') 

503 >>> get_timezone_name(tz, locale='de_DE') 

504 'Mitteleuropäische Zeit' 

505 

506 .. versionadded:: 0.9 

507 

508 :param dt_or_tzinfo: the ``datetime`` or ``tzinfo`` object that determines 

509 the timezone; if `None`, the current date and time in 

510 UTC is assumed 

511 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale. 

512 :param return_city: True or False, if True then return exemplar city (location) 

513 for the time zone 

514 :return: the localized timezone name using location format 

515 

516 """ 

517 locale = Locale.parse(locale or LC_TIME) 

518 

519 zone = _get_tz_name(dt_or_tzinfo) 

520 

521 # Get the canonical time-zone code 

522 zone = get_global('zone_aliases').get(zone, zone) 

523 

524 info = locale.time_zones.get(zone, {}) 

525 

526 # Otherwise, if there is only one timezone for the country, return the 

527 # localized country name 

528 region_format = locale.zone_formats['region'] 

529 territory = get_global('zone_territories').get(zone) 

530 if territory not in locale.territories: 

531 territory = 'ZZ' # invalid/unknown 

532 territory_name = locale.territories[territory] 

533 if ( 

534 not return_city 

535 and territory 

536 and len(get_global('territory_zones').get(territory, [])) == 1 

537 ): 

538 return region_format % territory_name 

539 

540 # Otherwise, include the city in the output 

541 fallback_format = locale.zone_formats['fallback'] 

542 if 'city' in info: 

543 city_name = info['city'] 

544 else: 

545 metazone = get_global('meta_zones').get(zone) 

546 metazone_info = locale.meta_zones.get(metazone, {}) 

547 if 'city' in metazone_info: 

548 city_name = metazone_info['city'] 

549 elif '/' in zone: 

550 city_name = zone.split('/', 1)[1].replace('_', ' ') 

551 else: 

552 city_name = zone.replace('_', ' ') 

553 

554 if return_city: 

555 return city_name 

556 return region_format % ( 

557 fallback_format 

558 % { 

559 '0': city_name, 

560 '1': territory_name, 

561 } 

562 ) 

563 

564 

565def get_timezone_name( 

566 dt_or_tzinfo: _DtOrTzinfo = None, 

567 width: Literal['long', 'short'] = 'long', 

568 uncommon: bool = False, 

569 locale: Locale | str | None = None, 

570 zone_variant: Literal['generic', 'daylight', 'standard'] | None = None, 

571 return_zone: bool = False, 

572) -> str: 

573 r"""Return the localized display name for the given timezone. The timezone 

574 may be specified using a ``datetime`` or `tzinfo` object. 

575 

576 >>> from datetime import time 

577 >>> dt = time(15, 30, tzinfo=get_timezone('America/Los_Angeles')) 

578 >>> get_timezone_name(dt, locale='en_US') # doctest: +SKIP 

579 'Pacific Standard Time' 

580 >>> get_timezone_name(dt, locale='en_US', return_zone=True) 

581 'America/Los_Angeles' 

582 >>> get_timezone_name(dt, width='short', locale='en_US') # doctest: +SKIP 

583 'PST' 

584 

585 If this function gets passed only a `tzinfo` object and no concrete 

586 `datetime`, the returned display name is independent of daylight savings 

587 time. This can be used for example for selecting timezones, or to set the 

588 time of events that recur across DST changes: 

589 

590 >>> tz = get_timezone('America/Los_Angeles') 

591 >>> get_timezone_name(tz, locale='en_US') 

592 'Pacific Time' 

593 >>> get_timezone_name(tz, 'short', locale='en_US') 

594 'PT' 

595 

596 If no localized display name for the timezone is available, and the timezone 

597 is associated with a country that uses only a single timezone, the name of 

598 that country is returned, formatted according to the locale: 

599 

600 >>> tz = get_timezone('Europe/Berlin') 

601 >>> get_timezone_name(tz, locale='de_DE') 

602 'Mitteleuropäische Zeit' 

603 >>> get_timezone_name(tz, locale='pt_BR') 

604 'Horário da Europa Central' 

605 

606 On the other hand, if the country uses multiple timezones, the city is also 

607 included in the representation: 

608 

609 >>> tz = get_timezone('America/St_Johns') 

610 >>> get_timezone_name(tz, locale='de_DE') 

611 'Neufundland-Zeit' 

612 

613 Note that short format is currently not supported for all timezones and 

614 all locales. This is partially because not every timezone has a short 

615 code in every locale. In that case it currently falls back to the long 

616 format. 

617 

618 For more information see `LDML Appendix J: Time Zone Display Names 

619 <https://www.unicode.org/reports/tr35/#Time_Zone_Fallback>`_ 

620 

621 .. versionadded:: 0.9 

622 

623 .. versionchanged:: 1.0 

624 Added `zone_variant` support. 

625 

626 :param dt_or_tzinfo: the ``datetime`` or ``tzinfo`` object that determines 

627 the timezone; if a ``tzinfo`` object is used, the 

628 resulting display name will be generic, i.e. 

629 independent of daylight savings time; if `None`, the 

630 current date in UTC is assumed 

631 :param width: either "long" or "short" 

632 :param uncommon: deprecated and ignored 

633 :param zone_variant: defines the zone variation to return. By default the 

634 variation is defined from the datetime object 

635 passed in. If no datetime object is passed in, the 

636 ``'generic'`` variation is assumed. The following 

637 values are valid: ``'generic'``, ``'daylight'`` and 

638 ``'standard'``. 

639 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale. 

640 :param return_zone: True or False. If true then function 

641 returns long time zone ID 

642 """ 

643 dt, tzinfo = _get_dt_and_tzinfo(dt_or_tzinfo) 

644 locale = Locale.parse(locale or LC_TIME) 

645 

646 zone = _get_tz_name(dt_or_tzinfo) 

647 

648 if zone_variant is None: 

649 if dt is None: 

650 zone_variant = 'generic' 

651 else: 

652 dst = tzinfo.dst(dt) 

653 zone_variant = "daylight" if dst else "standard" 

654 else: 

655 if zone_variant not in ('generic', 'standard', 'daylight'): 

656 raise ValueError('Invalid zone variation') 

657 

658 # Get the canonical time-zone code 

659 zone = get_global('zone_aliases').get(zone, zone) 

660 if return_zone: 

661 return zone 

662 info = locale.time_zones.get(zone, {}) 

663 # Try explicitly translated zone names first 

664 if width in info and zone_variant in info[width]: 

665 value = info[width][zone_variant] 

666 if value != NO_INHERITANCE_MARKER: 

667 return value 

668 

669 metazone = get_global('meta_zones').get(zone) 

670 if metazone: 

671 metazone_info = locale.meta_zones.get(metazone, {}) 

672 if width in metazone_info: 

673 name = metazone_info[width].get(zone_variant) 

674 if width == 'short' and name == NO_INHERITANCE_MARKER: 

675 # If the short form is marked no-inheritance, 

676 # try to fall back to the long name instead. 

677 name = metazone_info.get('long', {}).get(zone_variant) 

678 if name and name != NO_INHERITANCE_MARKER: 

679 return name 

680 

681 # If we have a concrete datetime, we assume that the result can't be 

682 # independent of daylight savings time, so we return the GMT offset 

683 if dt is not None: 

684 return get_timezone_gmt(dt, width=width, locale=locale) 

685 

686 return get_timezone_location(dt_or_tzinfo, locale=locale) 

687 

688 

689def format_date( 

690 date: datetime.date | None = None, 

691 format: _PredefinedTimeFormat | str = 'medium', 

692 locale: Locale | str | None = None, 

693) -> str: 

694 """Return a date formatted according to the given pattern. 

695 

696 >>> from datetime import date 

697 >>> d = date(2007, 4, 1) 

698 >>> format_date(d, locale='en_US') 

699 'Apr 1, 2007' 

700 >>> format_date(d, format='full', locale='de_DE') 

701 'Sonntag, 1. April 2007' 

702 

703 If you don't want to use the locale default formats, you can specify a 

704 custom date pattern: 

705 

706 >>> format_date(d, "EEE, MMM d, ''yy", locale='en') 

707 "Sun, Apr 1, '07" 

708 

709 :param date: the ``date`` or ``datetime`` object; if `None`, the current 

710 date is used 

711 :param format: one of "full", "long", "medium", or "short", or a custom 

712 date/time pattern 

713 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale. 

714 """ 

715 if date is None: 

716 date = datetime.date.today() 

717 elif isinstance(date, datetime.datetime): 

718 date = date.date() 

719 

720 locale = Locale.parse(locale or LC_TIME) 

721 if format in ('full', 'long', 'medium', 'short'): 

722 format = get_date_format(format, locale=locale) 

723 pattern = parse_pattern(format) 

724 return pattern.apply(date, locale) 

725 

726 

727def format_datetime( 

728 datetime: _Instant = None, 

729 format: _PredefinedTimeFormat | str = 'medium', 

730 tzinfo: datetime.tzinfo | None = None, 

731 locale: Locale | str | None = None, 

732) -> str: 

733 r"""Return a date formatted according to the given pattern. 

734 

735 >>> from datetime import datetime 

736 >>> dt = datetime(2007, 4, 1, 15, 30) 

737 >>> format_datetime(dt, locale='en_US') 

738 'Apr 1, 2007, 3:30:00\u202fPM' 

739 

740 For any pattern requiring the display of the timezone: 

741 

742 >>> format_datetime(dt, 'full', tzinfo=get_timezone('Europe/Paris'), 

743 ... locale='fr_FR') 

744 'dimanche 1 avril 2007, 17:30:00 heure d’été d’Europe centrale' 

745 >>> format_datetime(dt, "yyyy.MM.dd G 'at' HH:mm:ss zzz", 

746 ... tzinfo=get_timezone('US/Eastern'), locale='en') 

747 '2007.04.01 AD at 11:30:00 EDT' 

748 

749 :param datetime: the `datetime` object; if `None`, the current date and 

750 time is used 

751 :param format: one of "full", "long", "medium", or "short", or a custom 

752 date/time pattern 

753 :param tzinfo: the timezone to apply to the time for display 

754 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale. 

755 """ 

756 datetime = _ensure_datetime_tzinfo(_get_datetime(datetime), tzinfo) 

757 

758 locale = Locale.parse(locale or LC_TIME) 

759 if format in ('full', 'long', 'medium', 'short'): 

760 return ( 

761 get_datetime_format(format, locale=locale) 

762 .replace("'", "") 

763 .replace('{0}', format_time(datetime, format, tzinfo=None, locale=locale)) 

764 .replace('{1}', format_date(datetime, format, locale=locale)) 

765 ) 

766 else: 

767 return parse_pattern(format).apply(datetime, locale) 

768 

769 

770def format_time( 

771 time: datetime.time | datetime.datetime | float | None = None, 

772 format: _PredefinedTimeFormat | str = 'medium', 

773 tzinfo: datetime.tzinfo | None = None, 

774 locale: Locale | str | None = None, 

775) -> str: 

776 r"""Return a time formatted according to the given pattern. 

777 

778 >>> from datetime import datetime, time 

779 >>> t = time(15, 30) 

780 >>> format_time(t, locale='en_US') 

781 '3:30:00\u202fPM' 

782 >>> format_time(t, format='short', locale='de_DE') 

783 '15:30' 

784 

785 If you don't want to use the locale default formats, you can specify a 

786 custom time pattern: 

787 

788 >>> format_time(t, "hh 'o''clock' a", locale='en') 

789 "03 o'clock PM" 

790 

791 For any pattern requiring the display of the time-zone a 

792 timezone has to be specified explicitly: 

793 

794 >>> t = datetime(2007, 4, 1, 15, 30) 

795 >>> tzinfo = get_timezone('Europe/Paris') 

796 >>> t = _localize(tzinfo, t) 

797 >>> format_time(t, format='full', tzinfo=tzinfo, locale='fr_FR') 

798 '15:30:00 heure d’été d’Europe centrale' 

799 >>> format_time(t, "hh 'o''clock' a, zzzz", tzinfo=get_timezone('US/Eastern'), 

800 ... locale='en') 

801 "09 o'clock AM, Eastern Daylight Time" 

802 

803 As that example shows, when this function gets passed a 

804 ``datetime.datetime`` value, the actual time in the formatted string is 

805 adjusted to the timezone specified by the `tzinfo` parameter. If the 

806 ``datetime`` is "naive" (i.e. it has no associated timezone information), 

807 it is assumed to be in UTC. 

808 

809 These timezone calculations are **not** performed if the value is of type 

810 ``datetime.time``, as without date information there's no way to determine 

811 what a given time would translate to in a different timezone without 

812 information about whether daylight savings time is in effect or not. This 

813 means that time values are left as-is, and the value of the `tzinfo` 

814 parameter is only used to display the timezone name if needed: 

815 

816 >>> t = time(15, 30) 

817 >>> format_time(t, format='full', tzinfo=get_timezone('Europe/Paris'), 

818 ... locale='fr_FR') # doctest: +SKIP 

819 '15:30:00 heure normale d\u2019Europe centrale' 

820 >>> format_time(t, format='full', tzinfo=get_timezone('US/Eastern'), 

821 ... locale='en_US') # doctest: +SKIP 

822 '3:30:00\u202fPM Eastern Standard Time' 

823 

824 :param time: the ``time`` or ``datetime`` object; if `None`, the current 

825 time in UTC is used 

826 :param format: one of "full", "long", "medium", or "short", or a custom 

827 date/time pattern 

828 :param tzinfo: the time-zone to apply to the time for display 

829 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale. 

830 """ 

831 

832 # get reference date for if we need to find the right timezone variant 

833 # in the pattern 

834 ref_date = time.date() if isinstance(time, datetime.datetime) else None 

835 

836 time = _get_time(time, tzinfo) 

837 

838 locale = Locale.parse(locale or LC_TIME) 

839 if format in ('full', 'long', 'medium', 'short'): 

840 format = get_time_format(format, locale=locale) 

841 return parse_pattern(format).apply(time, locale, reference_date=ref_date) 

842 

843 

844def format_skeleton( 

845 skeleton: str, 

846 datetime: _Instant = None, 

847 tzinfo: datetime.tzinfo | None = None, 

848 fuzzy: bool = True, 

849 locale: Locale | str | None = None, 

850) -> str: 

851 r"""Return a time and/or date formatted according to the given pattern. 

852 

853 The skeletons are defined in the CLDR data and provide more flexibility 

854 than the simple short/long/medium formats, but are a bit harder to use. 

855 The are defined using the date/time symbols without order or punctuation 

856 and map to a suitable format for the given locale. 

857 

858 >>> from datetime import datetime 

859 >>> t = datetime(2007, 4, 1, 15, 30) 

860 >>> format_skeleton('MMMEd', t, locale='fr') 

861 'dim. 1 avr.' 

862 >>> format_skeleton('MMMEd', t, locale='en') 

863 'Sun, Apr 1' 

864 >>> format_skeleton('yMMd', t, locale='fi') # yMMd is not in the Finnish locale; yMd gets used 

865 '1.4.2007' 

866 >>> format_skeleton('yMMd', t, fuzzy=False, locale='fi') # yMMd is not in the Finnish locale, an error is thrown 

867 Traceback (most recent call last): 

868 ... 

869 KeyError: yMMd 

870 >>> 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 

871 Traceback (most recent call last): 

872 ... 

873 KeyError: None 

874 

875 After the skeleton is resolved to a pattern `format_datetime` is called so 

876 all timezone processing etc is the same as for that. 

877 

878 :param skeleton: A date time skeleton as defined in the cldr data. 

879 :param datetime: the ``time`` or ``datetime`` object; if `None`, the current 

880 time in UTC is used 

881 :param tzinfo: the time-zone to apply to the time for display 

882 :param fuzzy: If the skeleton is not found, allow choosing a skeleton that's 

883 close enough to it. If there is no close match, a `KeyError` 

884 is thrown. 

885 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale. 

886 """ 

887 locale = Locale.parse(locale or LC_TIME) 

888 if fuzzy and skeleton not in locale.datetime_skeletons: 

889 skeleton = match_skeleton(skeleton, locale.datetime_skeletons) 

890 format = locale.datetime_skeletons[skeleton] 

891 return format_datetime(datetime, format, tzinfo, locale) 

892 

893 

894TIMEDELTA_UNITS: tuple[tuple[str, int], ...] = ( 

895 ('year', 3600 * 24 * 365), 

896 ('month', 3600 * 24 * 30), 

897 ('week', 3600 * 24 * 7), 

898 ('day', 3600 * 24), 

899 ('hour', 3600), 

900 ('minute', 60), 

901 ('second', 1), 

902) 

903 

904 

905def format_timedelta( 

906 delta: datetime.timedelta | int, 

907 granularity: Literal[ 

908 'year', 

909 'month', 

910 'week', 

911 'day', 

912 'hour', 

913 'minute', 

914 'second', 

915 ] = 'second', 

916 threshold: float = 0.85, 

917 add_direction: bool = False, 

918 format: Literal['narrow', 'short', 'medium', 'long'] = 'long', 

919 locale: Locale | str | None = None, 

920) -> str: 

921 """Return a time delta according to the rules of the given locale. 

922 

923 >>> from datetime import timedelta 

924 >>> format_timedelta(timedelta(weeks=12), locale='en_US') 

925 '3 months' 

926 >>> format_timedelta(timedelta(seconds=1), locale='es') 

927 '1 segundo' 

928 

929 The granularity parameter can be provided to alter the lowest unit 

930 presented, which defaults to a second. 

931 

932 >>> format_timedelta(timedelta(hours=3), granularity='day', locale='en_US') 

933 '1 day' 

934 

935 The threshold parameter can be used to determine at which value the 

936 presentation switches to the next higher unit. A higher threshold factor 

937 means the presentation will switch later. For example: 

938 

939 >>> format_timedelta(timedelta(hours=23), threshold=0.9, locale='en_US') 

940 '1 day' 

941 >>> format_timedelta(timedelta(hours=23), threshold=1.1, locale='en_US') 

942 '23 hours' 

943 

944 In addition directional information can be provided that informs 

945 the user if the date is in the past or in the future: 

946 

947 >>> format_timedelta(timedelta(hours=1), add_direction=True, locale='en') 

948 'in 1 hour' 

949 >>> format_timedelta(timedelta(hours=-1), add_direction=True, locale='en') 

950 '1 hour ago' 

951 

952 The format parameter controls how compact or wide the presentation is: 

953 

954 >>> format_timedelta(timedelta(hours=3), format='short', locale='en') 

955 '3 hr' 

956 >>> format_timedelta(timedelta(hours=3), format='narrow', locale='en') 

957 '3h' 

958 

959 :param delta: a ``timedelta`` object representing the time difference to 

960 format, or the delta in seconds as an `int` value 

961 :param granularity: determines the smallest unit that should be displayed, 

962 the value can be one of "year", "month", "week", "day", 

963 "hour", "minute" or "second" 

964 :param threshold: factor that determines at which point the presentation 

965 switches to the next higher unit 

966 :param add_direction: if this flag is set to `True` the return value will 

967 include directional information. For instance a 

968 positive timedelta will include the information about 

969 it being in the future, a negative will be information 

970 about the value being in the past. 

971 :param format: the format, can be "narrow", "short" or "long". ( 

972 "medium" is deprecated, currently converted to "long" to 

973 maintain compatibility) 

974 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale. 

975 """ 

976 if format not in ('narrow', 'short', 'medium', 'long'): 

977 raise TypeError('Format must be one of "narrow", "short" or "long"') 

978 if format == 'medium': 

979 warnings.warn( 

980 '"medium" value for format param of format_timedelta is deprecated. Use "long" instead', 

981 category=DeprecationWarning, 

982 stacklevel=2, 

983 ) 

984 format = 'long' 

985 if isinstance(delta, datetime.timedelta): 

986 seconds = int((delta.days * 86400) + delta.seconds) 

987 else: 

988 seconds = delta 

989 locale = Locale.parse(locale or LC_TIME) 

990 date_fields = locale._data["date_fields"] 

991 unit_patterns = locale._data["unit_patterns"] 

992 

993 def _iter_patterns(a_unit): 

994 if add_direction: 

995 # Try to find the length variant version first ("year-narrow") 

996 # before falling back to the default. 

997 unit_rel_patterns = date_fields.get(f"{a_unit}-{format}") or date_fields[a_unit] 

998 if seconds >= 0: 

999 yield unit_rel_patterns['future'] 

1000 else: 

1001 yield unit_rel_patterns['past'] 

1002 a_unit = f"duration-{a_unit}" 

1003 unit_pats = unit_patterns.get(a_unit, {}) 

1004 yield unit_pats.get(format) 

1005 # We do not support `<alias>` tags at all while ingesting CLDR data, 

1006 # so these aliases specified in `root.xml` are hard-coded here: 

1007 # <unitLength type="long"><alias source="locale" path="../unitLength[@type='short']"/></unitLength> 

1008 # <unitLength type="narrow"><alias source="locale" path="../unitLength[@type='short']"/></unitLength> 

1009 if format in ("long", "narrow"): 

1010 yield unit_pats.get("short") 

1011 

1012 for unit, secs_per_unit in TIMEDELTA_UNITS: 

1013 value = abs(seconds) / secs_per_unit 

1014 if value >= threshold or unit == granularity: 

1015 if unit == granularity and value > 0: 

1016 value = max(1, value) 

1017 value = int(round(value)) 

1018 plural_form = locale.plural_form(value) 

1019 pattern = None 

1020 for patterns in _iter_patterns(unit): 

1021 if patterns is not None: 

1022 pattern = patterns.get(plural_form) or patterns.get('other') 

1023 if pattern: 

1024 break 

1025 # This really should not happen 

1026 if pattern is None: 

1027 return '' 

1028 return pattern.replace('{0}', str(value)) 

1029 

1030 return '' 

1031 

1032 

1033def _format_fallback_interval( 

1034 start: _Instant, 

1035 end: _Instant, 

1036 skeleton: str | None, 

1037 tzinfo: datetime.tzinfo | None, 

1038 locale: Locale, 

1039) -> str: 

1040 if skeleton in locale.datetime_skeletons: # Use the given skeleton 

1041 format = lambda dt: format_skeleton(skeleton, dt, tzinfo, locale=locale) 

1042 elif all( 

1043 # Both are just dates 

1044 (isinstance(d, datetime.date) and not isinstance(d, datetime.datetime)) 

1045 for d in (start, end) 

1046 ): 

1047 format = lambda dt: format_date(dt, locale=locale) 

1048 elif all( 

1049 # Both are times 

1050 (isinstance(d, datetime.time) and not isinstance(d, datetime.date)) 

1051 for d in (start, end) 

1052 ): 

1053 format = lambda dt: format_time(dt, tzinfo=tzinfo, locale=locale) 

1054 else: 

1055 format = lambda dt: format_datetime(dt, tzinfo=tzinfo, locale=locale) 

1056 

1057 formatted_start = format(start) 

1058 formatted_end = format(end) 

1059 

1060 if formatted_start == formatted_end: 

1061 return format(start) 

1062 

1063 return ( 

1064 locale.interval_formats.get(None, "{0}-{1}") 

1065 .replace("{0}", formatted_start) 

1066 .replace("{1}", formatted_end) 

1067 ) 

1068 

1069 

1070def format_interval( 

1071 start: _Instant, 

1072 end: _Instant, 

1073 skeleton: str | None = None, 

1074 tzinfo: datetime.tzinfo | None = None, 

1075 fuzzy: bool = True, 

1076 locale: Locale | str | None = None, 

1077) -> str: 

1078 """ 

1079 Format an interval between two instants according to the locale's rules. 

1080 

1081 >>> from datetime import date, time 

1082 >>> format_interval(date(2016, 1, 15), date(2016, 1, 17), "yMd", locale="fi") 

1083 '15.–17.1.2016' 

1084 

1085 >>> format_interval(time(12, 12), time(16, 16), "Hm", locale="en_GB") 

1086 '12:12–16:16' 

1087 

1088 >>> format_interval(time(5, 12), time(16, 16), "hm", locale="en_US") 

1089 '5:12\\u202fAM\\u2009–\\u20094:16\\u202fPM' 

1090 

1091 >>> format_interval(time(16, 18), time(16, 24), "Hm", locale="it") 

1092 '16:18–16:24' 

1093 

1094 If the start instant equals the end instant, the interval is formatted like the instant. 

1095 

1096 >>> format_interval(time(16, 18), time(16, 18), "Hm", locale="it") 

1097 '16:18' 

1098 

1099 Unknown skeletons fall back to "default" formatting. 

1100 

1101 >>> format_interval(date(2015, 1, 1), date(2017, 1, 1), "wzq", locale="ja") 

1102 '2015/01/01~2017/01/01' 

1103 

1104 >>> format_interval(time(16, 18), time(16, 24), "xxx", locale="ja") 

1105 '16:18:00~16:24:00' 

1106 

1107 >>> format_interval(date(2016, 1, 15), date(2016, 1, 17), "xxx", locale="de") 

1108 '15.01.2016\\u2009–\\u200917.01.2016' 

1109 

1110 :param start: First instant (datetime/date/time) 

1111 :param end: Second instant (datetime/date/time) 

1112 :param skeleton: The "skeleton format" to use for formatting. 

1113 :param tzinfo: tzinfo to use (if none is already attached) 

1114 :param fuzzy: If the skeleton is not found, allow choosing a skeleton that's 

1115 close enough to it. 

1116 :param locale: A locale object or identifier. Defaults to the system time locale. 

1117 :return: Formatted interval 

1118 """ 

1119 locale = Locale.parse(locale or LC_TIME) 

1120 

1121 # NB: The quote comments below are from the algorithm description in 

1122 # https://www.unicode.org/reports/tr35/tr35-dates.html#intervalFormats 

1123 

1124 # > Look for the intervalFormatItem element that matches the "skeleton", 

1125 # > starting in the current locale and then following the locale fallback 

1126 # > chain up to, but not including root. 

1127 

1128 interval_formats = locale.interval_formats 

1129 

1130 if skeleton not in interval_formats or not skeleton: 

1131 # > If no match was found from the previous step, check what the closest 

1132 # > match is in the fallback locale chain, as in availableFormats. That 

1133 # > is, this allows for adjusting the string value field's width, 

1134 # > including adjusting between "MMM" and "MMMM", and using different 

1135 # > variants of the same field, such as 'v' and 'z'. 

1136 if skeleton and fuzzy: 

1137 skeleton = match_skeleton(skeleton, interval_formats) 

1138 else: 

1139 skeleton = None 

1140 if not skeleton: # Still no match whatsoever? 

1141 # > Otherwise, format the start and end datetime using the fallback pattern. 

1142 return _format_fallback_interval(start, end, skeleton, tzinfo, locale) 

1143 

1144 skel_formats = interval_formats[skeleton] 

1145 

1146 if start == end: 

1147 return format_skeleton(skeleton, start, tzinfo, fuzzy=fuzzy, locale=locale) 

1148 

1149 start = _ensure_datetime_tzinfo(_get_datetime(start), tzinfo=tzinfo) 

1150 end = _ensure_datetime_tzinfo(_get_datetime(end), tzinfo=tzinfo) 

1151 

1152 start_fmt = DateTimeFormat(start, locale=locale) 

1153 end_fmt = DateTimeFormat(end, locale=locale) 

1154 

1155 # > If a match is found from previous steps, compute the calendar field 

1156 # > with the greatest difference between start and end datetime. If there 

1157 # > is no difference among any of the fields in the pattern, format as a 

1158 # > single date using availableFormats, and return. 

1159 

1160 for field in PATTERN_CHAR_ORDER: # These are in largest-to-smallest order 

1161 if field in skel_formats and start_fmt.extract(field) != end_fmt.extract(field): 

1162 # > If there is a match, use the pieces of the corresponding pattern to 

1163 # > format the start and end datetime, as above. 

1164 return "".join( 

1165 parse_pattern(pattern).apply(instant, locale) 

1166 for pattern, instant in zip(skel_formats[field], (start, end)) 

1167 ) 

1168 

1169 # > Otherwise, format the start and end datetime using the fallback pattern. 

1170 

1171 return _format_fallback_interval(start, end, skeleton, tzinfo, locale) 

1172 

1173 

1174def get_period_id( 

1175 time: _Instant, 

1176 tzinfo: datetime.tzinfo | None = None, 

1177 type: Literal['selection'] | None = None, 

1178 locale: Locale | str | None = None, 

1179) -> str: 

1180 """ 

1181 Get the day period ID for a given time. 

1182 

1183 This ID can be used as a key for the period name dictionary. 

1184 

1185 >>> from datetime import time 

1186 >>> get_period_names(locale="de")[get_period_id(time(7, 42), locale="de")] 

1187 'Morgen' 

1188 

1189 >>> get_period_id(time(0), locale="en_US") 

1190 'midnight' 

1191 

1192 >>> get_period_id(time(0), type="selection", locale="en_US") 

1193 'morning1' 

1194 

1195 :param time: The time to inspect. 

1196 :param tzinfo: The timezone for the time. See ``format_time``. 

1197 :param type: The period type to use. Either "selection" or None. 

1198 The selection type is used for selecting among phrases such as 

1199 “Your email arrived yesterday evening” or “Your email arrived last night”. 

1200 :param locale: the `Locale` object, or a locale string. Defaults to the system time locale. 

1201 :return: period ID. Something is always returned -- even if it's just "am" or "pm". 

1202 """ 

1203 time = _get_time(time, tzinfo) 

1204 seconds_past_midnight = int(time.hour * 60 * 60 + time.minute * 60 + time.second) 

1205 locale = Locale.parse(locale or LC_TIME) 

1206 

1207 # The LDML rules state that the rules may not overlap, so iterating in arbitrary 

1208 # order should be alright, though `at` periods should be preferred. 

1209 rulesets = locale.day_period_rules.get(type, {}).items() 

1210 

1211 for rule_id, rules in rulesets: 

1212 for rule in rules: 

1213 if "at" in rule and rule["at"] == seconds_past_midnight: 

1214 return rule_id 

1215 

1216 for rule_id, rules in rulesets: 

1217 for rule in rules: 

1218 if "from" in rule and "before" in rule: 

1219 if rule["from"] < rule["before"]: 

1220 if rule["from"] <= seconds_past_midnight < rule["before"]: 

1221 return rule_id 

1222 else: 

1223 # e.g. from="21:00" before="06:00" 

1224 if ( 

1225 rule["from"] <= seconds_past_midnight < 86400 

1226 or 0 <= seconds_past_midnight < rule["before"] 

1227 ): 

1228 return rule_id 

1229 

1230 start_ok = end_ok = False 

1231 

1232 if "from" in rule and seconds_past_midnight >= rule["from"]: 

1233 start_ok = True 

1234 if "to" in rule and seconds_past_midnight <= rule["to"]: 

1235 # This rule type does not exist in the present CLDR data; 

1236 # excuse the lack of test coverage. 

1237 end_ok = True 

1238 if "before" in rule and seconds_past_midnight < rule["before"]: 

1239 end_ok = True 

1240 if "after" in rule: 

1241 raise NotImplementedError("'after' is deprecated as of CLDR 29.") 

1242 

1243 if start_ok and end_ok: 

1244 return rule_id 

1245 

1246 if seconds_past_midnight < 43200: 

1247 return "am" 

1248 else: 

1249 return "pm" 

1250 

1251 

1252class ParseError(ValueError): 

1253 pass 

1254 

1255 

1256def parse_date( 

1257 string: str, 

1258 locale: Locale | str | None = None, 

1259 format: _PredefinedTimeFormat | str = 'medium', 

1260) -> datetime.date: 

1261 """Parse a date from a string. 

1262 

1263 If an explicit format is provided, it is used to parse the date. 

1264 

1265 >>> parse_date('01.04.2004', format='dd.MM.yyyy') 

1266 datetime.date(2004, 4, 1) 

1267 

1268 If no format is given, or if it is one of "full", "long", "medium", 

1269 or "short", the function first tries to interpret the string as 

1270 ISO-8601 date format and then uses the date format for the locale 

1271 as a hint to determine the order in which the date fields appear in 

1272 the string. 

1273 

1274 >>> parse_date('4/1/04', locale='en_US') 

1275 datetime.date(2004, 4, 1) 

1276 >>> parse_date('01.04.2004', locale='de_DE') 

1277 datetime.date(2004, 4, 1) 

1278 >>> parse_date('2004-04-01', locale='en_US') 

1279 datetime.date(2004, 4, 1) 

1280 >>> parse_date('2004-04-01', locale='de_DE') 

1281 datetime.date(2004, 4, 1) 

1282 >>> parse_date('01.04.04', locale='de_DE', format='short') 

1283 datetime.date(2004, 4, 1) 

1284 

1285 :param string: the string containing the date 

1286 :param locale: a `Locale` object or a locale identifier 

1287 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale. 

1288 :param format: the format to use, either an explicit date format, 

1289 or one of "full", "long", "medium", or "short" 

1290 (see ``get_time_format``) 

1291 """ 

1292 numbers = re.findall(r'(\d+)', string) 

1293 if not numbers: 

1294 raise ParseError("No numbers were found in input") 

1295 

1296 use_predefined_format = format in ('full', 'long', 'medium', 'short') 

1297 # we try ISO-8601 format first, meaning similar to formats 

1298 # extended YYYY-MM-DD or basic YYYYMMDD 

1299 iso_alike = re.match( 

1300 r'^(\d{4})-?([01]\d)-?([0-3]\d)$', 

1301 string, 

1302 flags=re.ASCII, # allow only ASCII digits 

1303 ) 

1304 if iso_alike and use_predefined_format: 

1305 try: 

1306 return datetime.date(*map(int, iso_alike.groups())) 

1307 except ValueError: 

1308 pass # a locale format might fit better, so let's continue 

1309 

1310 if use_predefined_format: 

1311 fmt = get_date_format(format=format, locale=locale) 

1312 else: 

1313 fmt = parse_pattern(format) 

1314 format_str = fmt.pattern.lower() 

1315 year_idx = format_str.index('y') 

1316 month_idx = format_str.find('m') 

1317 if month_idx < 0: 

1318 month_idx = format_str.index('l') 

1319 day_idx = format_str.index('d') 

1320 

1321 indexes = sorted([(year_idx, 'Y'), (month_idx, 'M'), (day_idx, 'D')]) 

1322 indexes = {item[1]: idx for idx, item in enumerate(indexes)} 

1323 

1324 # FIXME: this currently only supports numbers, but should also support month 

1325 # names, both in the requested locale, and english 

1326 

1327 year = numbers[indexes['Y']] 

1328 year = 2000 + int(year) if len(year) == 2 else int(year) 

1329 month = int(numbers[indexes['M']]) 

1330 day = int(numbers[indexes['D']]) 

1331 if month > 12: 

1332 month, day = day, month 

1333 return datetime.date(year, month, day) 

1334 

1335 

1336def parse_time( 

1337 string: str, 

1338 locale: Locale | str | None = None, 

1339 format: _PredefinedTimeFormat | str = 'medium', 

1340) -> datetime.time: 

1341 """Parse a time from a string. 

1342 

1343 This function uses the time format for the locale as a hint to determine 

1344 the order in which the time fields appear in the string. 

1345 

1346 If an explicit format is provided, the function will use it to parse 

1347 the time instead. 

1348 

1349 >>> parse_time('15:30:00', locale='en_US') 

1350 datetime.time(15, 30) 

1351 >>> parse_time('15:30:00', format='H:mm:ss') 

1352 datetime.time(15, 30) 

1353 

1354 :param string: the string containing the time 

1355 :param locale: a `Locale` object or a locale identifier. Defaults to the system time locale. 

1356 :param format: the format to use, either an explicit time format, 

1357 or one of "full", "long", "medium", or "short" 

1358 (see ``get_time_format``) 

1359 :return: the parsed time 

1360 :rtype: `time` 

1361 """ 

1362 numbers = re.findall(r'(\d+)', string) 

1363 if not numbers: 

1364 raise ParseError("No numbers were found in input") 

1365 

1366 # TODO: try ISO format first? 

1367 if format in ('full', 'long', 'medium', 'short'): 

1368 fmt = get_time_format(format=format, locale=locale) 

1369 else: 

1370 fmt = parse_pattern(format) 

1371 format_str = fmt.pattern.lower() 

1372 hour_idx = format_str.find('h') 

1373 if hour_idx < 0: 

1374 hour_idx = format_str.index('k') 

1375 min_idx = format_str.index('m') 

1376 # format might not contain seconds 

1377 if (sec_idx := format_str.find('s')) < 0: 

1378 sec_idx = math.inf 

1379 

1380 indexes = sorted([(hour_idx, 'H'), (min_idx, 'M'), (sec_idx, 'S')]) 

1381 indexes = {item[1]: idx for idx, item in enumerate(indexes)} 

1382 

1383 # TODO: support time zones 

1384 

1385 # Check if the format specifies a period to be used; 

1386 # if it does, look for 'pm' to figure out an offset. 

1387 hour_offset = 0 

1388 if 'a' in format_str and 'pm' in string.lower(): 

1389 hour_offset = 12 

1390 

1391 # Parse up to three numbers from the string. 

1392 minute = second = 0 

1393 hour = int(numbers[indexes['H']]) + hour_offset 

1394 if len(numbers) > 1: 

1395 minute = int(numbers[indexes['M']]) 

1396 if len(numbers) > 2: 

1397 second = int(numbers[indexes['S']]) 

1398 return datetime.time(hour, minute, second) 

1399 

1400 

1401class DateTimePattern: 

1402 def __init__(self, pattern: str, format: DateTimeFormat): 

1403 self.pattern = pattern 

1404 self.format = format 

1405 

1406 def __repr__(self) -> str: 

1407 return f"<{type(self).__name__} {self.pattern!r}>" 

1408 

1409 def __str__(self) -> str: 

1410 pat = self.pattern 

1411 return pat 

1412 

1413 def __mod__(self, other: DateTimeFormat) -> str: 

1414 if not isinstance(other, DateTimeFormat): 

1415 return NotImplemented 

1416 return self.format % other 

1417 

1418 def apply( 

1419 self, 

1420 datetime: datetime.date | datetime.time, 

1421 locale: Locale | str | None, 

1422 reference_date: datetime.date | None = None, 

1423 ) -> str: 

1424 return self % DateTimeFormat(datetime, locale, reference_date) 

1425 

1426 

1427class DateTimeFormat: 

1428 def __init__( 

1429 self, 

1430 value: datetime.date | datetime.time, 

1431 locale: Locale | str, 

1432 reference_date: datetime.date | None = None, 

1433 ) -> None: 

1434 assert isinstance(value, (datetime.date, datetime.datetime, datetime.time)) 

1435 if isinstance(value, (datetime.datetime, datetime.time)) and value.tzinfo is None: 

1436 value = value.replace(tzinfo=UTC) 

1437 self.value = value 

1438 self.locale = Locale.parse(locale) 

1439 self.reference_date = reference_date 

1440 

1441 def __getitem__(self, name: str) -> str: 

1442 char = name[0] 

1443 num = len(name) 

1444 if char == 'G': 

1445 return self.format_era(char, num) 

1446 elif char in ('y', 'Y', 'u'): 

1447 return self.format_year(char, num) 

1448 elif char in ('Q', 'q'): 

1449 return self.format_quarter(char, num) 

1450 elif char in ('M', 'L'): 

1451 return self.format_month(char, num) 

1452 elif char in ('w', 'W'): 

1453 return self.format_week(char, num) 

1454 elif char == 'd': 

1455 return self.format(self.value.day, num) 

1456 elif char == 'D': 

1457 return self.format_day_of_year(num) 

1458 elif char == 'F': 

1459 return self.format_day_of_week_in_month() 

1460 elif char in ('E', 'e', 'c'): 

1461 return self.format_weekday(char, num) 

1462 elif char in ('a', 'b', 'B'): 

1463 return self.format_period(char, num) 

1464 elif char == 'h': 

1465 if self.value.hour % 12 == 0: 

1466 return self.format(12, num) 

1467 else: 

1468 return self.format(self.value.hour % 12, num) 

1469 elif char == 'H': 

1470 return self.format(self.value.hour, num) 

1471 elif char == 'K': 

1472 return self.format(self.value.hour % 12, num) 

1473 elif char == 'k': 

1474 if self.value.hour == 0: 

1475 return self.format(24, num) 

1476 else: 

1477 return self.format(self.value.hour, num) 

1478 elif char == 'm': 

1479 return self.format(self.value.minute, num) 

1480 elif char == 's': 

1481 return self.format(self.value.second, num) 

1482 elif char == 'S': 

1483 return self.format_frac_seconds(num) 

1484 elif char == 'A': 

1485 return self.format_milliseconds_in_day(num) 

1486 elif char in ('z', 'Z', 'v', 'V', 'x', 'X', 'O'): 

1487 return self.format_timezone(char, num) 

1488 else: 

1489 raise KeyError(f"Unsupported date/time field {char!r}") 

1490 

1491 def extract(self, char: str) -> int: 

1492 char = str(char)[0] 

1493 if char == 'y': 

1494 return self.value.year 

1495 elif char == 'M': 

1496 return self.value.month 

1497 elif char == 'd': 

1498 return self.value.day 

1499 elif char == 'H': 

1500 return self.value.hour 

1501 elif char == 'h': 

1502 return self.value.hour % 12 or 12 

1503 elif char == 'm': 

1504 return self.value.minute 

1505 elif char == 'a': 

1506 return int(self.value.hour >= 12) # 0 for am, 1 for pm 

1507 else: 

1508 raise NotImplementedError( 

1509 f"Not implemented: extracting {char!r} from {self.value!r}", 

1510 ) 

1511 

1512 def format_era(self, char: str, num: int) -> str: 

1513 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[max(3, num)] 

1514 era = int(self.value.year >= 0) 

1515 return get_era_names(width, self.locale)[era] 

1516 

1517 def format_year(self, char: str, num: int) -> str: 

1518 value = self.value.year 

1519 if char.isupper(): 

1520 month = self.value.month 

1521 if month == 1 and self.value.day < 7 and self.get_week_of_year() >= 52: 

1522 value -= 1 

1523 elif month == 12 and self.value.day > 25 and self.get_week_of_year() <= 2: 

1524 value += 1 

1525 year = self.format(value, num) 

1526 if num == 2: 

1527 year = year[-2:] 

1528 return year 

1529 

1530 def format_quarter(self, char: str, num: int) -> str: 

1531 quarter = (self.value.month - 1) // 3 + 1 

1532 if num <= 2: 

1533 return '%0*d' % (num, quarter) 

1534 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[num] 

1535 context = {'Q': 'format', 'q': 'stand-alone'}[char] 

1536 return get_quarter_names(width, context, self.locale)[quarter] 

1537 

1538 def format_month(self, char: str, num: int) -> str: 

1539 if num <= 2: 

1540 return '%0*d' % (num, self.value.month) 

1541 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[num] 

1542 context = {'M': 'format', 'L': 'stand-alone'}[char] 

1543 return get_month_names(width, context, self.locale)[self.value.month] 

1544 

1545 def format_week(self, char: str, num: int) -> str: 

1546 if char.islower(): # week of year 

1547 week = self.get_week_of_year() 

1548 return self.format(week, num) 

1549 else: # week of month 

1550 week = self.get_week_of_month() 

1551 return str(week) 

1552 

1553 def format_weekday(self, char: str = 'E', num: int = 4) -> str: 

1554 """ 

1555 Return weekday from parsed datetime according to format pattern. 

1556 

1557 >>> from datetime import date 

1558 >>> format = DateTimeFormat(date(2016, 2, 28), Locale.parse('en_US')) 

1559 >>> format.format_weekday() 

1560 'Sunday' 

1561 

1562 'E': Day of week - Use one through three letters for the abbreviated day name, four for the full (wide) name, 

1563 five for the narrow name, or six for the short name. 

1564 >>> format.format_weekday('E',2) 

1565 'Sun' 

1566 

1567 'e': Local day of week. Same as E except adds a numeric value that will depend on the local starting day of the 

1568 week, using one or two letters. For this example, Monday is the first day of the week. 

1569 >>> format.format_weekday('e',2) 

1570 '01' 

1571 

1572 'c': Stand-Alone local day of week - Use one letter for the local numeric value (same as 'e'), three for the 

1573 abbreviated day name, four for the full (wide) name, five for the narrow name, or six for the short name. 

1574 >>> format.format_weekday('c',1) 

1575 '1' 

1576 

1577 :param char: pattern format character ('e','E','c') 

1578 :param num: count of format character 

1579 

1580 """ 

1581 if num < 3: 

1582 if char.islower(): 

1583 value = 7 - self.locale.first_week_day + self.value.weekday() 

1584 return self.format(value % 7 + 1, num) 

1585 num = 3 

1586 weekday = self.value.weekday() 

1587 width = {3: 'abbreviated', 4: 'wide', 5: 'narrow', 6: 'short'}[num] 

1588 context = "stand-alone" if char == "c" else "format" 

1589 return get_day_names(width, context, self.locale)[weekday] 

1590 

1591 def format_day_of_year(self, num: int) -> str: 

1592 return self.format(self.get_day_of_year(), num) 

1593 

1594 def format_day_of_week_in_month(self) -> str: 

1595 return str((self.value.day - 1) // 7 + 1) 

1596 

1597 def format_period(self, char: str, num: int) -> str: 

1598 """ 

1599 Return period from parsed datetime according to format pattern. 

1600 

1601 >>> from datetime import datetime, time 

1602 >>> format = DateTimeFormat(time(13, 42), 'fi_FI') 

1603 >>> format.format_period('a', 1) 

1604 'ip.' 

1605 >>> format.format_period('b', 1) 

1606 'iltap.' 

1607 >>> format.format_period('b', 4) 

1608 'iltapäivä' 

1609 >>> format.format_period('B', 4) 

1610 'iltapäivällä' 

1611 >>> format.format_period('B', 5) 

1612 'ip.' 

1613 

1614 >>> format = DateTimeFormat(datetime(2022, 4, 28, 6, 27), 'zh_Hant') 

1615 >>> format.format_period('a', 1) 

1616 '上午' 

1617 >>> format.format_period('B', 1) 

1618 '清晨' 

1619 

1620 :param char: pattern format character ('a', 'b', 'B') 

1621 :param num: count of format character 

1622 

1623 """ 

1624 widths = [ 

1625 {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[max(3, num)], 

1626 'wide', 

1627 'narrow', 

1628 'abbreviated', 

1629 ] 

1630 if char == 'a': 

1631 period = 'pm' if self.value.hour >= 12 else 'am' 

1632 context = 'format' 

1633 else: 

1634 period = get_period_id(self.value, locale=self.locale) 

1635 context = 'format' if char == 'B' else 'stand-alone' 

1636 for width in widths: 

1637 period_names = get_period_names(context=context, width=width, locale=self.locale) 

1638 if period in period_names: 

1639 return period_names[period] 

1640 raise ValueError(f"Could not format period {period} in {self.locale}") 

1641 

1642 def format_frac_seconds(self, num: int) -> str: 

1643 """ Return fractional seconds. 

1644 

1645 Rounds the time's microseconds to the precision given by the number \ 

1646 of digits passed in. 

1647 """ 

1648 value = self.value.microsecond / 1000000 

1649 return self.format(round(value, num) * 10**num, num) 

1650 

1651 def format_milliseconds_in_day(self, num): 

1652 msecs = ( 

1653 self.value.microsecond // 1000 

1654 + self.value.second * 1000 

1655 + self.value.minute * 60000 

1656 + self.value.hour * 3600000 

1657 ) 

1658 return self.format(msecs, num) 

1659 

1660 def format_timezone(self, char: str, num: int) -> str: 

1661 width = {3: 'short', 4: 'long', 5: 'iso8601'}[max(3, num)] 

1662 

1663 # It could be that we only receive a time to format, but also have a 

1664 # reference date which is important to distinguish between timezone 

1665 # variants (summer/standard time) 

1666 value = self.value 

1667 if self.reference_date: 

1668 value = datetime.datetime.combine(self.reference_date, self.value) 

1669 

1670 if char == 'z': 

1671 return get_timezone_name(value, width, locale=self.locale) 

1672 elif char == 'Z': 

1673 if num == 5: 

1674 return get_timezone_gmt(value, width, locale=self.locale, return_z=True) 

1675 return get_timezone_gmt(value, width, locale=self.locale) 

1676 elif char == 'O': 

1677 if num == 4: 

1678 return get_timezone_gmt(value, width, locale=self.locale) 

1679 # TODO: To add support for O:1 

1680 elif char == 'v': 

1681 return get_timezone_name(value.tzinfo, width, locale=self.locale) 

1682 elif char == 'V': 

1683 if num == 1: 

1684 return get_timezone_name(value.tzinfo, width, locale=self.locale) 

1685 elif num == 2: 

1686 return get_timezone_name(value.tzinfo, locale=self.locale, return_zone=True) 

1687 elif num == 3: 

1688 return get_timezone_location(value.tzinfo, locale=self.locale, return_city=True) # fmt: skip 

1689 return get_timezone_location(value.tzinfo, locale=self.locale) 

1690 elif char in 'Xx': 

1691 return_z = char == 'X' 

1692 if num == 1: 

1693 width = 'iso8601_short' 

1694 elif num in (2, 4): 

1695 width = 'short' 

1696 elif num in (3, 5): 

1697 width = 'iso8601' 

1698 return get_timezone_gmt(value, width=width, locale=self.locale, return_z=return_z) # fmt: skip 

1699 

1700 def format(self, value: SupportsInt, length: int) -> str: 

1701 return '%0*d' % (length, value) 

1702 

1703 def get_day_of_year(self, date: datetime.date | None = None) -> int: 

1704 if date is None: 

1705 date = self.value 

1706 return (date - date.replace(month=1, day=1)).days + 1 

1707 

1708 def get_week_of_year(self) -> int: 

1709 """Return the week of the year.""" 

1710 day_of_year = self.get_day_of_year(self.value) 

1711 week = self.get_week_number(day_of_year) 

1712 if week == 0: 

1713 date = datetime.date(self.value.year - 1, 12, 31) 

1714 week = self.get_week_number(self.get_day_of_year(date), date.weekday()) 

1715 elif week > 52: 

1716 weekday = datetime.date(self.value.year + 1, 1, 1).weekday() 

1717 if ( 

1718 self.get_week_number(1, weekday) == 1 

1719 and 32 - (weekday - self.locale.first_week_day) % 7 <= self.value.day 

1720 ): 

1721 week = 1 

1722 return week 

1723 

1724 def get_week_of_month(self) -> int: 

1725 """Return the week of the month.""" 

1726 return self.get_week_number(self.value.day) 

1727 

1728 def get_week_number(self, day_of_period: int, day_of_week: int | None = None) -> int: 

1729 """Return the number of the week of a day within a period. This may be 

1730 the week number in a year or the week number in a month. 

1731 

1732 Usually this will return a value equal to or greater than 1, but if the 

1733 first week of the period is so short that it actually counts as the last 

1734 week of the previous period, this function will return 0. 

1735 

1736 >>> date = datetime.date(2006, 1, 8) 

1737 >>> DateTimeFormat(date, 'de_DE').get_week_number(6) 

1738 1 

1739 >>> DateTimeFormat(date, 'en_US').get_week_number(6) 

1740 2 

1741 

1742 :param day_of_period: the number of the day in the period (usually 

1743 either the day of month or the day of year) 

1744 :param day_of_week: the week day; if omitted, the week day of the 

1745 current date is assumed 

1746 """ 

1747 if day_of_week is None: 

1748 day_of_week = self.value.weekday() 

1749 first_day = (day_of_week - self.locale.first_week_day - day_of_period + 1) % 7 

1750 if first_day < 0: 

1751 first_day += 7 

1752 week_number = (day_of_period + first_day - 1) // 7 

1753 if 7 - first_day >= self.locale.min_week_days: 

1754 week_number += 1 

1755 return week_number 

1756 

1757 

1758PATTERN_CHARS: dict[str, list[int] | None] = { 

1759 'G': [1, 2, 3, 4, 5], # era 

1760 'y': None, 'Y': None, 'u': None, # year 

1761 'Q': [1, 2, 3, 4, 5], 'q': [1, 2, 3, 4, 5], # quarter 

1762 'M': [1, 2, 3, 4, 5], 'L': [1, 2, 3, 4, 5], # month 

1763 'w': [1, 2], 'W': [1], # week 

1764 'd': [1, 2], 'D': [1, 2, 3], 'F': [1], 'g': None, # day 

1765 'E': [1, 2, 3, 4, 5, 6], 'e': [1, 2, 3, 4, 5, 6], 'c': [1, 3, 4, 5, 6], # week day 

1766 'a': [1, 2, 3, 4, 5], 'b': [1, 2, 3, 4, 5], 'B': [1, 2, 3, 4, 5], # period 

1767 'h': [1, 2], 'H': [1, 2], 'K': [1, 2], 'k': [1, 2], # hour 

1768 'm': [1, 2], # minute 

1769 's': [1, 2], 'S': None, 'A': None, # second 

1770 'z': [1, 2, 3, 4], 'Z': [1, 2, 3, 4, 5], 'O': [1, 4], 'v': [1, 4], # zone 

1771 'V': [1, 2, 3, 4], 'x': [1, 2, 3, 4, 5], 'X': [1, 2, 3, 4, 5], # zone 

1772} # fmt: skip 

1773 

1774#: The pattern characters declared in the Date Field Symbol Table 

1775#: (https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table) 

1776#: in order of decreasing magnitude. 

1777PATTERN_CHAR_ORDER = "GyYuUQqMLlwWdDFgEecabBChHKkjJmsSAzZOvVXx" 

1778 

1779 

1780def parse_pattern(pattern: str | DateTimePattern) -> DateTimePattern: 

1781 """Parse date, time, and datetime format patterns. 

1782 

1783 >>> parse_pattern("MMMMd").format 

1784 '%(MMMM)s%(d)s' 

1785 >>> parse_pattern("MMM d, yyyy").format 

1786 '%(MMM)s %(d)s, %(yyyy)s' 

1787 

1788 Pattern can contain literal strings in single quotes: 

1789 

1790 >>> parse_pattern("H:mm' Uhr 'z").format 

1791 '%(H)s:%(mm)s Uhr %(z)s' 

1792 

1793 An actual single quote can be used by using two adjacent single quote 

1794 characters: 

1795 

1796 >>> parse_pattern("hh' o''clock'").format 

1797 "%(hh)s o'clock" 

1798 

1799 :param pattern: the formatting pattern to parse 

1800 """ 

1801 if isinstance(pattern, DateTimePattern): 

1802 return pattern 

1803 return _cached_parse_pattern(pattern) 

1804 

1805 

1806@lru_cache(maxsize=1024) 

1807def _cached_parse_pattern(pattern: str) -> DateTimePattern: 

1808 result = [] 

1809 

1810 for tok_type, tok_value in tokenize_pattern(pattern): 

1811 if tok_type == "chars": 

1812 result.append(tok_value.replace('%', '%%')) 

1813 elif tok_type == "field": 

1814 fieldchar, fieldnum = tok_value 

1815 limit = PATTERN_CHARS[fieldchar] 

1816 if limit and fieldnum not in limit: 

1817 raise ValueError(f"Invalid length for field: {fieldchar * fieldnum!r}") 

1818 result.append('%%(%s)s' % (fieldchar * fieldnum)) 

1819 else: 

1820 raise NotImplementedError(f"Unknown token type: {tok_type}") 

1821 return DateTimePattern(pattern, ''.join(result)) 

1822 

1823 

1824def tokenize_pattern(pattern: str) -> list[tuple[str, str | tuple[str, int]]]: 

1825 """ 

1826 Tokenize date format patterns. 

1827 

1828 Returns a list of (token_type, token_value) tuples. 

1829 

1830 ``token_type`` may be either "chars" or "field". 

1831 

1832 For "chars" tokens, the value is the literal value. 

1833 

1834 For "field" tokens, the value is a tuple of (field character, repetition count). 

1835 

1836 :param pattern: Pattern string 

1837 :type pattern: str 

1838 :rtype: list[tuple] 

1839 """ 

1840 result = [] 

1841 quotebuf = None 

1842 charbuf = [] 

1843 fieldchar = [''] 

1844 fieldnum = [0] 

1845 

1846 def append_chars(): 

1847 result.append(('chars', ''.join(charbuf).replace('\0', "'"))) 

1848 del charbuf[:] 

1849 

1850 def append_field(): 

1851 result.append(('field', (fieldchar[0], fieldnum[0]))) 

1852 fieldchar[0] = '' 

1853 fieldnum[0] = 0 

1854 

1855 for char in pattern.replace("''", '\0'): 

1856 if quotebuf is None: 

1857 if char == "'": # quote started 

1858 if fieldchar[0]: 

1859 append_field() 

1860 elif charbuf: 

1861 append_chars() 

1862 quotebuf = [] 

1863 elif char in PATTERN_CHARS: 

1864 if charbuf: 

1865 append_chars() 

1866 if char == fieldchar[0]: 

1867 fieldnum[0] += 1 

1868 else: 

1869 if fieldchar[0]: 

1870 append_field() 

1871 fieldchar[0] = char 

1872 fieldnum[0] = 1 

1873 else: 

1874 if fieldchar[0]: 

1875 append_field() 

1876 charbuf.append(char) 

1877 

1878 elif quotebuf is not None: 

1879 if char == "'": # end of quote 

1880 charbuf.extend(quotebuf) 

1881 quotebuf = None 

1882 else: # inside quote 

1883 quotebuf.append(char) 

1884 

1885 if fieldchar[0]: 

1886 append_field() 

1887 elif charbuf: 

1888 append_chars() 

1889 

1890 return result 

1891 

1892 

1893def untokenize_pattern(tokens: Iterable[tuple[str, str | tuple[str, int]]]) -> str: 

1894 """ 

1895 Turn a date format pattern token stream back into a string. 

1896 

1897 This is the reverse operation of ``tokenize_pattern``. 

1898 

1899 :type tokens: Iterable[tuple] 

1900 :rtype: str 

1901 """ 

1902 output = [] 

1903 for tok_type, tok_value in tokens: 

1904 if tok_type == "field": 

1905 output.append(tok_value[0] * tok_value[1]) 

1906 elif tok_type == "chars": 

1907 if not any(ch in PATTERN_CHARS for ch in tok_value): # No need to quote 

1908 output.append(tok_value) 

1909 else: 

1910 output.append("'%s'" % tok_value.replace("'", "''")) 

1911 return "".join(output) 

1912 

1913 

1914def split_interval_pattern(pattern: str) -> list[str]: 

1915 """ 

1916 Split an interval-describing datetime pattern into multiple pieces. 

1917 

1918 > The pattern is then designed to be broken up into two pieces by determining the first repeating field. 

1919 - https://www.unicode.org/reports/tr35/tr35-dates.html#intervalFormats 

1920 

1921 >>> split_interval_pattern('E d.M. – E d.M.') 

1922 ['E d.M. – ', 'E d.M.'] 

1923 >>> split_interval_pattern("Y 'text' Y 'more text'") 

1924 ["Y 'text '", "Y 'more text'"] 

1925 >>> split_interval_pattern('E, MMM d – E') 

1926 ['E, MMM d – ', 'E'] 

1927 >>> split_interval_pattern("MMM d") 

1928 ['MMM d'] 

1929 >>> split_interval_pattern("y G") 

1930 ['y G'] 

1931 >>> split_interval_pattern('MMM d – d') 

1932 ['MMM d – ', 'd'] 

1933 

1934 :param pattern: Interval pattern string 

1935 :return: list of "subpatterns" 

1936 """ 

1937 

1938 seen_fields = set() 

1939 parts = [[]] 

1940 

1941 for tok_type, tok_value in tokenize_pattern(pattern): 

1942 if tok_type == "field": 

1943 if tok_value[0] in seen_fields: # Repeated field 

1944 parts.append([]) 

1945 seen_fields.clear() 

1946 seen_fields.add(tok_value[0]) 

1947 parts[-1].append((tok_type, tok_value)) 

1948 

1949 return [untokenize_pattern(tokens) for tokens in parts] 

1950 

1951 

1952def match_skeleton( 

1953 skeleton: str, 

1954 options: Iterable[str], 

1955 allow_different_fields: bool = False, 

1956) -> str | None: 

1957 """ 

1958 Find the closest match for the given datetime skeleton among the options given. 

1959 

1960 This uses the rules outlined in the TR35 document. 

1961 

1962 >>> match_skeleton('yMMd', ('yMd', 'yMMMd')) 

1963 'yMd' 

1964 

1965 >>> match_skeleton('yMMd', ('jyMMd',), allow_different_fields=True) 

1966 'jyMMd' 

1967 

1968 >>> match_skeleton('yMMd', ('qyMMd',), allow_different_fields=False) 

1969 

1970 >>> match_skeleton('hmz', ('hmv',)) 

1971 'hmv' 

1972 

1973 :param skeleton: The skeleton to match 

1974 :type skeleton: str 

1975 :param options: An iterable of other skeletons to match against 

1976 :type options: Iterable[str] 

1977 :param allow_different_fields: Whether to allow a match that uses different fields 

1978 than the skeleton requested. 

1979 :type allow_different_fields: bool 

1980 

1981 :return: The closest skeleton match, or if no match was found, None. 

1982 :rtype: str|None 

1983 """ 

1984 

1985 # TODO: maybe implement pattern expansion? 

1986 

1987 # Based on the implementation in 

1988 # https://github.com/unicode-org/icu/blob/main/icu4j/main/core/src/main/java/com/ibm/icu/text/DateIntervalInfo.java 

1989 

1990 # Filter out falsy values and sort for stability; when `interval_formats` is passed in, there may be a None key. 

1991 options = sorted(option for option in options if option) 

1992 

1993 if 'z' in skeleton and not any('z' in option for option in options): 

1994 skeleton = skeleton.replace('z', 'v') 

1995 if 'k' in skeleton and not any('k' in option for option in options): 

1996 skeleton = skeleton.replace('k', 'H') 

1997 if 'K' in skeleton and not any('K' in option for option in options): 

1998 skeleton = skeleton.replace('K', 'h') 

1999 if 'a' in skeleton and not any('a' in option for option in options): 

2000 skeleton = skeleton.replace('a', '') 

2001 if 'b' in skeleton and not any('b' in option for option in options): 

2002 skeleton = skeleton.replace('b', '') 

2003 

2004 get_input_field_width = dict(t[1] for t in tokenize_pattern(skeleton) if t[0] == "field").get # fmt: skip 

2005 best_skeleton = None 

2006 best_distance = None 

2007 for option in options: 

2008 get_opt_field_width = dict(t[1] for t in tokenize_pattern(option) if t[0] == "field").get # fmt: skip 

2009 distance = 0 

2010 for field in PATTERN_CHARS: 

2011 input_width = get_input_field_width(field, 0) 

2012 opt_width = get_opt_field_width(field, 0) 

2013 if input_width == opt_width: 

2014 continue 

2015 if opt_width == 0 or input_width == 0: 

2016 if not allow_different_fields: # This one is not okay 

2017 option = None 

2018 break 

2019 # Magic weight constant for "entirely different fields" 

2020 distance += 0x1000 

2021 elif field == 'M' and ( 

2022 (input_width > 2 and opt_width <= 2) or (input_width <= 2 and opt_width > 2) 

2023 ): 

2024 # Magic weight constant for "text turns into a number" 

2025 distance += 0x100 

2026 else: 

2027 distance += abs(input_width - opt_width) 

2028 

2029 if not option: 

2030 # We lost the option along the way (probably due to "allow_different_fields") 

2031 continue 

2032 

2033 if not best_skeleton or distance < best_distance: 

2034 best_skeleton = option 

2035 best_distance = distance 

2036 

2037 if distance == 0: # Found a perfect match! 

2038 break 

2039 

2040 return best_skeleton