1import os 2import sysconfig 3 4 5def _reset_tzpath(to=None, stacklevel=4): 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 None: 22 env_var = sysconfig.get_config_var("TZPATH") 23 base_tzpath = _parse_python_tzpath(env_var, stacklevel) 24 25 TZPATH = tuple(base_tzpath) 26 27 28def reset_tzpath(to=None): 29 """Reset global TZPATH.""" 30 # We need `_reset_tzpath` helper function because it produces a warning, 31 # it is used as both a module-level call and a public API. 32 # This is how we equalize the stacklevel for both calls. 33 _reset_tzpath(to) 34 35 36def _parse_python_tzpath(env_var, stacklevel): 37 if not env_var: 38 return () 39 40 raw_tzpath = env_var.split(os.pathsep) 41 new_tzpath = tuple(filter(os.path.isabs, raw_tzpath)) 42 43 # If anything has been filtered out, we will warn about it 44 if len(new_tzpath) != len(raw_tzpath): 45 import warnings 46 47 msg = _get_invalid_paths_message(raw_tzpath) 48 49 warnings.warn( 50 "Invalid paths specified in PYTHONTZPATH environment variable. " 51 + msg, 52 InvalidTZPathWarning, 53 stacklevel=stacklevel, 54 ) 55 56 return new_tzpath 57 58 59def _get_invalid_paths_message(tzpaths): 60 invalid_paths = (path for path in tzpaths if not os.path.isabs(path)) 61 62 prefix = "\n " 63 indented_str = prefix + prefix.join(invalid_paths) 64 65 return ( 66 "Paths should be absolute but found the following relative paths:" 67 + indented_str 68 ) 69 70 71def find_tzfile(key): 72 """Retrieve the path to a TZif file from a key.""" 73 _validate_tzfile_path(key) 74 for search_path in TZPATH: 75 filepath = os.path.join(search_path, key) 76 if os.path.isfile(filepath): 77 return filepath 78 79 return None 80 81 82_TEST_PATH = os.path.normpath(os.path.join("_", "_"))[:-1] 83 84 85def _validate_tzfile_path(path, _base=_TEST_PATH): 86 if os.path.isabs(path): 87 raise ValueError( 88 f"ZoneInfo keys may not be absolute paths, got: {path}" 89 ) 90 91 # We only care about the kinds of path normalizations that would change the 92 # length of the key - e.g. a/../b -> a/b, or a/b/ -> a/b. On Windows, 93 # normpath will also change from a/b to a\b, but that would still preserve 94 # the length. 95 new_path = os.path.normpath(path) 96 if len(new_path) != len(path): 97 raise ValueError( 98 f"ZoneInfo keys must be normalized relative paths, got: {path}" 99 ) 100 101 resolved = os.path.normpath(os.path.join(_base, new_path)) 102 if not resolved.startswith(_base): 103 raise ValueError( 104 f"ZoneInfo keys must refer to subdirectories of TZPATH, got: {path}" 105 ) 106 107 108del _TEST_PATH 109 110 111def available_timezones(): 112 """Returns a set containing all available time zones. 113 114 .. caution:: 115 116 This may attempt to open a large number of files, since the best way to 117 determine if a given file on the time zone search path is to open it 118 and check for the "magic string" at the beginning. 119 """ 120 from importlib import resources 121 122 valid_zones = set() 123 124 # Start with loading from the tzdata package if it exists: this has a 125 # pre-assembled list of zones that only requires opening one file. 126 try: 127 with resources.files("tzdata").joinpath("zones").open("r") as f: 128 for zone in f: 129 zone = zone.strip() 130 if zone: 131 valid_zones.add(zone) 132 except (ImportError, FileNotFoundError): 133 pass 134 135 def valid_key(fpath): 136 try: 137 with open(fpath, "rb") as f: 138 return f.read(4) == b"TZif" 139 except Exception: # pragma: nocover 140 return False 141 142 for tz_root in TZPATH: 143 if not os.path.exists(tz_root): 144 continue 145 146 for root, dirnames, files in os.walk(tz_root): 147 if root == tz_root: 148 # right/ and posix/ are special directories and shouldn't be 149 # included in the output of available zones 150 if "right" in dirnames: 151 dirnames.remove("right") 152 if "posix" in dirnames: 153 dirnames.remove("posix") 154 155 for file in files: 156 fpath = os.path.join(root, file) 157 158 key = os.path.relpath(fpath, start=tz_root) 159 if os.sep != "/": # pragma: nocover 160 key = key.replace(os.sep, "/") 161 162 if not key or key in valid_zones: 163 continue 164 165 if valid_key(fpath): 166 valid_zones.add(key) 167 168 if "posixrules" in valid_zones: 169 # posixrules is a special symlink-only time zone where it exists, it 170 # should not be included in the output 171 valid_zones.remove("posixrules") 172 173 return valid_zones 174 175 176class InvalidTZPathWarning(RuntimeWarning): 177 """Warning raised if an invalid path is specified in PYTHONTZPATH.""" 178 179 180TZPATH = () 181_reset_tzpath(stacklevel=5) 182