blob: f659e512d56f85c047c5a2879c516ca36d23c027 [file] [edit]
# /// script
# dependencies = [
# "requests",
# ]
# ///
from __future__ import annotations
import asyncio
import base64
import gzip
import json
import logging
import os
import pathlib
import shutil
import struct
import subprocess
import tempfile
import urllib.parse
from typing import Final
IANA_LATEST_LOCATION: Final[str] = (
"https://www.iana.org/time-zones/repository/tzdb-latest.tar.lz"
)
SOURCE: Final[str] = "https://data.iana.org/time-zones/releases"
DATA_DIR: Final[pathlib.Path] = pathlib.Path("tests/resources/data")
def _is_tzif(fpath: pathlib.Path) -> bool:
if fpath.is_dir():
return False
with open(fpath, "rb") as f:
magic = f.read(4)
return magic == b"TZif"
def _parse_filename_from_url(url: str) -> str:
parsed_url = urllib.parse.urlparse(url)
filename = parsed_url.path.rsplit("/", 1)[-1]
return filename
async def download_tzdb(
working_dir: pathlib.Path,
url: str = IANA_LATEST_LOCATION,
) -> pathlib.Path:
"""Download the tzdata and tzcode tarballs."""
# Imported here so that the tests can import this module without requests
import requests
filename = _parse_filename_from_url(url)
r = requests.get(url, allow_redirects=True, stream=True)
download_location = working_dir / filename
logging.info(
"Downloaded %s from %s to %s", filename, url, download_location
)
with open(download_location, "wb") as f:
f.write(r.raw.read())
return download_location
async def unpack_tzdb_tarball(
download_location: pathlib.Path,
output_dir: pathlib.Path | None = None,
cwd: pathlib.Path | None = None,
) -> pathlib.Path:
logging.info("Unpacking %s to %s", download_location, output_dir)
if cwd is None:
cwd = pathlib.Path.cwd()
if output_dir is None:
output_dir = cwd / "tzdb"
output_dir.mkdir()
await asyncio.to_thread(
subprocess.run,
[
"tar",
"--lzip",
"-xf",
os.fspath(download_location.absolute()),
"-C",
os.fspath(output_dir),
],
cwd=cwd,
check=True,
)
# The tzdb tarball has for some reason a top-level directory called
# tzdb-..., which is annoying to deal with. We will delete that and
# unpack its contents into `output_dir`
for i, subdir in list(enumerate(output_dir.glob("tzdb-*"))):
if i >= 1:
raise ValueError("TZDB directory contains more than one subdir")
for p in subdir.iterdir():
p.rename(output_dir / p.name)
subdir.rmdir()
return output_dir
async def _file_to_v1(tzif_file: pathlib.Path, out_path: pathlib.Path) -> None:
# Make sure that the parents exist
out_path.parent.mkdir(parents=True, exist_ok=True)
# Ensure that this is a valid TZif file
contents = tzif_file.read_bytes()
if contents[0:4] != b"TZif":
raise ValueError(f"Invalid TZif data found! {tzif_file}")
# Version 1 files have a NUL byte in place of the version number
version_byte = contents[4:5]
version = 1 if version_byte == b"\x00" else int(version_byte)
header_start = 4 + 16
header_end = header_start + 24 # 6l == 24 bytes
if version < 2:
logging.warning(
"Version 1 file found, no conversion necessary (%s)", tzif_file
)
shutil.copyfile(tzif_file, out_path)
return
isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt = struct.unpack(
">6l", contents[header_start:header_end]
)
file_size = (
timecnt * 5 + typecnt * 6 + charcnt + leapcnt * 8 + isstdcnt + isutcnt
)
file_size += header_end
out = b"TZif" + b"\x00" + contents[5:file_size]
if contents[file_size : (file_size + 4)] != b"TZif":
raise ValueError(
f"Version 2 file not truncated at Version 2 header: {tzif_file}"
)
out_path.write_bytes(out)
async def tzif_files_to_v1(
input_base_path: pathlib.Path, output_base_path: pathlib.Path
) -> None:
tasks = []
for tzif_in_file in filter(_is_tzif, input_base_path.rglob("*")):
tzif_v1_file = output_base_path / tzif_in_file.relative_to(
input_base_path
)
tasks.append(
asyncio.create_task(_file_to_v1(tzif_in_file, tzif_v1_file))
)
await asyncio.gather(*tasks)
async def make_tzdb_install(
install_dir: pathlib.Path, cwd: pathlib.Path, slim: bool
) -> None:
cmd = ["make", f"DESTDIR={os.fspath(install_dir)}"]
if slim:
cmd.append("ZFLAGS=-b slim")
else:
cmd.append("ZFLAGS=-b fat")
cmd.append("install")
await asyncio.to_thread(
subprocess.run,
cmd,
check=True,
cwd=cwd,
)
def file_to_text(tzif_file: pathlib.Path) -> str:
"""Compress the file with gzip and base64 encode."""
tzif_data = tzif_file.read_bytes()
compressed_data = gzip.compress(tzif_data)
b64_data = base64.b64encode(compressed_data).decode("utf-8")
return b64_data
def zonefile_to_json(
data_root: pathlib.Path, json_outfile: pathlib.Path, keys: frozenset[str]
) -> None:
out_data = {}
for dirpath, dirnames, filenames in data_root.walk():
for file in filenames:
filepath = dirpath / file
tzkey = str(filepath.relative_to(data_root))
if tzkey not in keys:
continue
out_data[tzkey] = file_to_text(dirpath / file)
json_outfile.write_text(json.dumps(out_data, sort_keys=True, indent=2))
def write_metadata(json_outfile: pathlib.Path, tzdb_dir: pathlib.Path) -> None:
"""Record which tzdb release the data files were generated from."""
version = (tzdb_dir / "version").read_text().strip()
logging.info("Generated data from tzdb version %s", version)
metadata = {"tzdata_version": version}
json_outfile.write_text(
json.dumps(metadata, sort_keys=True, indent=2) + "\n"
)
def read_required_keys(loc: pathlib.Path) -> frozenset[str]:
json_str = loc.read_text()
return frozenset(json.loads(json_str))
async def main() -> None:
required_keys = read_required_keys(DATA_DIR / "keys.json")
with tempfile.TemporaryDirectory() as td:
tdp = pathlib.Path(td)
download_location = await download_tzdb(working_dir=tdp)
tzdata_dir = tdp / "tzdb"
V1_OUT = tdp / "v1"
V2P_OUT = tdp / "v2p_fat"
V2P_OUT_SLIM = tdp / "v2p_slim"
await unpack_tzdb_tarball(
download_location, cwd=tdp, output_dir=tzdata_dir
)
v2p_fat_future = make_tzdb_install(V2P_OUT, cwd=tzdata_dir, slim=False)
v2p_slim_future = make_tzdb_install(
V2P_OUT_SLIM, cwd=tzdata_dir, slim=True
)
V2P_ZONEINFO = V2P_OUT / "usr/share/zoneinfo"
V2P_SLIM_ZONEINFO = V2P_OUT_SLIM / "usr/share/zoneinfo"
await v2p_fat_future
v1_future = tzif_files_to_v1(V2P_OUT / "usr/share/zoneinfo", V1_OUT)
await asyncio.gather(v2p_slim_future, v1_future)
zonefile_to_json(
V1_OUT, DATA_DIR / "zoneinfo_v1.json", keys=required_keys
)
zonefile_to_json(
V2P_ZONEINFO, DATA_DIR / "zoneinfo_fat.json", keys=required_keys
)
zonefile_to_json(
V2P_SLIM_ZONEINFO,
DATA_DIR / "zoneinfo_slim.json",
keys=required_keys,
)
write_metadata(DATA_DIR / "metadata.json", tzdata_dir)
if __name__ == "__main__":
asyncio.run(main())