Skip to content

Commit e527ec8

Browse files
authored
bpo-40536: Add zoneinfo.available_timezones (GH-20158)
This was not specified in the PEP, but it will likely be a frequently requested feature if it's not included. This includes only the "canonical" zones, not a simple listing of every valid value of `key` that can be passed to `Zoneinfo`, because it seems likely that that's what people will want.
1 parent 9681953 commit e527ec8

File tree

6 files changed

+230
-2
lines changed

6 files changed

+230
-2
lines changed

Doc/library/zoneinfo.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,29 @@ pickled in an environment with a different version of the time zone data.
337337
Functions
338338
---------
339339

340+
.. function:: available_timezones()
341+
342+
Get a set containing all the valid keys for IANA time zones available
343+
anywhere on the time zone path. This is recalculated on every call to the
344+
function.
345+
346+
This function only includes canonical zone names and does not include
347+
"special" zones such as those under the ``posix/`` and ``right/``
348+
directories, or the ``posixrules`` zone.
349+
350+
.. caution::
351+
352+
This function may open a large number of files, as the best way to
353+
determine if a file on the time zone path is a valid time zone is to
354+
read the "magic string" at the beginning.
355+
356+
.. note::
357+
358+
These values are not designed to be exposed to end-users; for user
359+
facing elements, applications should use something like CLDR (the
360+
Unicode Common Locale Data Repository) to get more user-friendly
361+
strings. See also the cautionary note on :attr:`ZoneInfo.key`.
362+
340363
.. function:: reset_tzpath(to=None)
341364

342365
Sets or resets the time zone search path (:data:`TZPATH`) for the module.

Lib/test/test_zoneinfo/_support.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,35 @@ def setUpClass(cls):
6666
super().setUpClass()
6767

6868
@contextlib.contextmanager
69-
def tzpath_context(self, tzpath, lock=TZPATH_LOCK):
69+
def tzpath_context(self, tzpath, block_tzdata=True, lock=TZPATH_LOCK):
70+
def pop_tzdata_modules():
71+
tzdata_modules = {}
72+
for modname in list(sys.modules):
73+
if modname.split(".", 1)[0] != "tzdata": # pragma: nocover
74+
continue
75+
76+
tzdata_modules[modname] = sys.modules.pop(modname)
77+
78+
return tzdata_modules
79+
7080
with lock:
81+
if block_tzdata:
82+
# In order to fully exclude tzdata from the path, we need to
83+
# clear the sys.modules cache of all its contents — setting the
84+
# root package to None is not enough to block direct access of
85+
# already-imported submodules (though it will prevent new
86+
# imports of submodules).
87+
tzdata_modules = pop_tzdata_modules()
88+
sys.modules["tzdata"] = None
89+
7190
old_path = self.module.TZPATH
7291
try:
7392
self.module.reset_tzpath(tzpath)
7493
yield
7594
finally:
95+
if block_tzdata:
96+
sys.modules.pop("tzdata")
97+
for modname, module in tzdata_modules.items():
98+
sys.modules[modname] = module
99+
76100
self.module.reset_tzpath(old_path)

Lib/test/test_zoneinfo/test_zoneinfo.py

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import tempfile
1717
import unittest
1818
from datetime import date, datetime, time, timedelta, timezone
19+
from functools import cached_property
1920

2021
from . import _support as test_support
2122
from ._support import (
@@ -72,10 +73,18 @@ class TzPathUserMixin:
7273
def tzpath(self): # pragma: nocover
7374
return None
7475

76+
@property
77+
def block_tzdata(self):
78+
return True
79+
7580
def setUp(self):
7681
with contextlib.ExitStack() as stack:
7782
stack.enter_context(
78-
self.tzpath_context(self.tzpath, lock=TZPATH_TEST_LOCK)
83+
self.tzpath_context(
84+
self.tzpath,
85+
block_tzdata=self.block_tzdata,
86+
lock=TZPATH_TEST_LOCK,
87+
)
7988
)
8089
self.addCleanup(stack.pop_all().close)
8190

@@ -522,6 +531,10 @@ class TZDataTests(ZoneInfoTest):
522531
def tzpath(self):
523532
return []
524533

534+
@property
535+
def block_tzdata(self):
536+
return False
537+
525538
def zone_from_key(self, key):
526539
return self.klass(key=key)
527540

@@ -1628,6 +1641,32 @@ class CTzPathTest(TzPathTest):
16281641
class TestModule(ZoneInfoTestBase):
16291642
module = py_zoneinfo
16301643

1644+
@property
1645+
def zoneinfo_data(self):
1646+
return ZONEINFO_DATA
1647+
1648+
@cached_property
1649+
def _UTC_bytes(self):
1650+
zone_file = self.zoneinfo_data.path_from_key("UTC")
1651+
with open(zone_file, "rb") as f:
1652+
return f.read()
1653+
1654+
def touch_zone(self, key, tz_root):
1655+
"""Creates a valid TZif file at key under the zoneinfo root tz_root.
1656+
1657+
tz_root must exist, but all folders below that will be created.
1658+
"""
1659+
if not os.path.exists(tz_root):
1660+
raise FileNotFoundError(f"{tz_root} does not exist.")
1661+
1662+
root_dir, *tail = key.rsplit("/", 1)
1663+
if tail: # If there's no tail, then the first component isn't a dir
1664+
os.makedirs(os.path.join(tz_root, root_dir), exist_ok=True)
1665+
1666+
zonefile_path = os.path.join(tz_root, key)
1667+
with open(zonefile_path, "wb") as f:
1668+
f.write(self._UTC_bytes)
1669+
16311670
def test_getattr_error(self):
16321671
with self.assertRaises(AttributeError):
16331672
self.module.NOATTRIBUTE
@@ -1648,6 +1687,79 @@ def test_dir_unique(self):
16481687

16491688
self.assertCountEqual(module_dir, module_unique)
16501689

1690+
def test_available_timezones(self):
1691+
with self.tzpath_context([self.zoneinfo_data.tzpath]):
1692+
self.assertTrue(self.zoneinfo_data.keys) # Sanity check
1693+
1694+
available_keys = self.module.available_timezones()
1695+
zoneinfo_keys = set(self.zoneinfo_data.keys)
1696+
1697+
# If tzdata is not present, zoneinfo_keys == available_keys,
1698+
# otherwise it should be a subset.
1699+
union = zoneinfo_keys & available_keys
1700+
self.assertEqual(zoneinfo_keys, union)
1701+
1702+
def test_available_timezones_weirdzone(self):
1703+
with tempfile.TemporaryDirectory() as td:
1704+
# Make a fictional zone at "Mars/Olympus_Mons"
1705+
self.touch_zone("Mars/Olympus_Mons", td)
1706+
1707+
with self.tzpath_context([td]):
1708+
available_keys = self.module.available_timezones()
1709+
self.assertIn("Mars/Olympus_Mons", available_keys)
1710+
1711+
def test_folder_exclusions(self):
1712+
expected = {
1713+
"America/Los_Angeles",
1714+
"America/Santiago",
1715+
"America/Indiana/Indianapolis",
1716+
"UTC",
1717+
"Europe/Paris",
1718+
"Europe/London",
1719+
"Asia/Tokyo",
1720+
"Australia/Sydney",
1721+
}
1722+
1723+
base_tree = list(expected)
1724+
posix_tree = [f"posix/{x}" for x in base_tree]
1725+
right_tree = [f"right/{x}" for x in base_tree]
1726+
1727+
cases = [
1728+
("base_tree", base_tree),
1729+
("base_and_posix", base_tree + posix_tree),
1730+
("base_and_right", base_tree + right_tree),
1731+
("all_trees", base_tree + right_tree + posix_tree),
1732+
]
1733+
1734+
with tempfile.TemporaryDirectory() as td:
1735+
for case_name, tree in cases:
1736+
tz_root = os.path.join(td, case_name)
1737+
os.mkdir(tz_root)
1738+
1739+
for key in tree:
1740+
self.touch_zone(key, tz_root)
1741+
1742+
with self.tzpath_context([tz_root]):
1743+
with self.subTest(case_name):
1744+
actual = self.module.available_timezones()
1745+
self.assertEqual(actual, expected)
1746+
1747+
def test_exclude_posixrules(self):
1748+
expected = {
1749+
"America/New_York",
1750+
"Europe/London",
1751+
}
1752+
1753+
tree = list(expected) + ["posixrules"]
1754+
1755+
with tempfile.TemporaryDirectory() as td:
1756+
for key in tree:
1757+
self.touch_zone(key, td)
1758+
1759+
with self.tzpath_context([td]):
1760+
actual = self.module.available_timezones()
1761+
self.assertEqual(actual, expected)
1762+
16511763

16521764
class CTestModule(TestModule):
16531765
module = c_zoneinfo

Lib/zoneinfo/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
__all__ = [
22
"ZoneInfo",
33
"reset_tzpath",
4+
"available_timezones",
45
"TZPATH",
56
"ZoneInfoNotFoundError",
67
"InvalidTZPathWarning",
@@ -15,6 +16,7 @@
1516
from ._zoneinfo import ZoneInfo
1617

1718
reset_tzpath = _tzpath.reset_tzpath
19+
available_timezones = _tzpath.available_timezones
1820
InvalidTZPathWarning = _tzpath.InvalidTZPathWarning
1921

2022

Lib/zoneinfo/_tzpath.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,71 @@ def _validate_tzfile_path(path, _base=_TEST_PATH):
102102
del _TEST_PATH
103103

104104

105+
def available_timezones():
106+
"""Returns a set containing all available time zones.
107+
108+
.. caution::
109+
110+
This may attempt to open a large number of files, since the best way to
111+
determine if a given file on the time zone search path is to open it
112+
and check for the "magic string" at the beginning.
113+
"""
114+
from importlib import resources
115+
116+
valid_zones = set()
117+
118+
# Start with loading from the tzdata package if it exists: this has a
119+
# pre-assembled list of zones that only requires opening one file.
120+
try:
121+
with resources.open_text("tzdata", "zones") as f:
122+
for zone in f:
123+
zone = zone.strip()
124+
if zone:
125+
valid_zones.add(zone)
126+
except (ImportError, FileNotFoundError):
127+
pass
128+
129+
def valid_key(fpath):
130+
try:
131+
with open(fpath, "rb") as f:
132+
return f.read(4) == b"TZif"
133+
except Exception: # pragma: nocover
134+
return False
135+
136+
for tz_root in TZPATH:
137+
if not os.path.exists(tz_root):
138+
continue
139+
140+
for root, dirnames, files in os.walk(tz_root):
141+
if root == tz_root:
142+
# right/ and posix/ are special directories and shouldn't be
143+
# included in the output of available zones
144+
if "right" in dirnames:
145+
dirnames.remove("right")
146+
if "posix" in dirnames:
147+
dirnames.remove("posix")
148+
149+
for file in files:
150+
fpath = os.path.join(root, file)
151+
152+
key = os.path.relpath(fpath, start=tz_root)
153+
if os.sep != "/": # pragma: nocover
154+
key = key.replace(os.sep, "/")
155+
156+
if not key or key in valid_zones:
157+
continue
158+
159+
if valid_key(fpath):
160+
valid_zones.add(key)
161+
162+
if "posixrules" in valid_zones:
163+
# posixrules is a special symlink-only time zone where it exists, it
164+
# should not be included in the output
165+
valid_zones.remove("posixrules")
166+
167+
return valid_zones
168+
169+
105170
class InvalidTZPathWarning(RuntimeWarning):
106171
"""Warning raised if an invalid path is specified in PYTHONTZPATH."""
107172

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Added the :func:`~zoneinfo.available_timezones` function to the
2+
:mod:`zoneinfo` module. Patch by Paul Ganssle.

0 commit comments

Comments
 (0)