1import os
2import sysconfig
3
4
5def reset_tzpath(to=None):
6    global TZPATH
7
8    tzpaths = to
9    if tzpaths is not None:
10        if isinstance(tzpaths, (str, bytes)):
11            raise TypeError(
12                f"tzpaths must be a list or tuple, "
13                + f"not {type(tzpaths)}: {tzpaths!r}"
14            )
15
16        if not all(map(os.path.isabs, tzpaths)):
17            raise ValueError(_get_invalid_paths_message(tzpaths))
18        base_tzpath = tzpaths
19    else:
20        env_var = os.environ.get("PYTHONTZPATH", None)
21        if env_var is not None:
22            base_tzpath = _parse_python_tzpath(env_var)
23        else:
24            base_tzpath = _parse_python_tzpath(
25                sysconfig.get_config_var("TZPATH")
26            )
27
28    TZPATH = tuple(base_tzpath)
29
30
31def _parse_python_tzpath(env_var):
32    if not env_var:
33        return ()
34
35    raw_tzpath = env_var.split(os.pathsep)
36    new_tzpath = tuple(filter(os.path.isabs, raw_tzpath))
37
38    # If anything has been filtered out, we will warn about it
39    if len(new_tzpath) != len(raw_tzpath):
40        import warnings
41
42        msg = _get_invalid_paths_message(raw_tzpath)
43
44        warnings.warn(
45            "Invalid paths specified in PYTHONTZPATH environment variable."
46            + msg,
47            InvalidTZPathWarning,
48        )
49
50    return new_tzpath
51
52
53def _get_invalid_paths_message(tzpaths):
54    invalid_paths = (path for path in tzpaths if not os.path.isabs(path))
55
56    prefix = "\n    "
57    indented_str = prefix + prefix.join(invalid_paths)
58
59    return (
60        "Paths should be absolute but found the following relative paths:"
61        + indented_str
62    )
63
64
65def find_tzfile(key):
66    """Retrieve the path to a TZif file from a key."""
67    _validate_tzfile_path(key)
68    for search_path in TZPATH:
69        filepath = os.path.join(search_path, key)
70        if os.path.isfile(filepath):
71            return filepath
72
73    return None
74
75
76_TEST_PATH = os.path.normpath(os.path.join("_", "_"))[:-1]
77
78
79def _validate_tzfile_path(path, _base=_TEST_PATH):
80    if os.path.isabs(path):
81        raise ValueError(
82            f"ZoneInfo keys may not be absolute paths, got: {path}"
83        )
84
85    # We only care about the kinds of path normalizations that would change the
86    # length of the key - e.g. a/../b -> a/b, or a/b/ -> a/b. On Windows,
87    # normpath will also change from a/b to a\b, but that would still preserve
88    # the length.
89    new_path = os.path.normpath(path)
90    if len(new_path) != len(path):
91        raise ValueError(
92            f"ZoneInfo keys must be normalized relative paths, got: {path}"
93        )
94
95    resolved = os.path.normpath(os.path.join(_base, new_path))
96    if not resolved.startswith(_base):
97        raise ValueError(
98            f"ZoneInfo keys must refer to subdirectories of TZPATH, got: {path}"
99        )
100
101
102del _TEST_PATH
103
104
105def 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
170class InvalidTZPathWarning(RuntimeWarning):
171    """Warning raised if an invalid path is specified in PYTHONTZPATH."""
172
173
174TZPATH = ()
175reset_tzpath()
176