timeweave 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vxmpingz_
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.4
2
+ Name: timeweave
3
+ Version: 1.0.0
4
+ Summary: Timezone detection, conversion, and self-maintaining IANA rule cache.
5
+ Author: vxmpingz_
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/vxmpingz/timeweave
8
+ Project-URL: Issues, https://github.com/vxmpingz/timeweave/issues
9
+ Keywords: timezone,tzdata,zoneinfo,iana,dst,zic
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Topic :: System :: Systems Administration
24
+ Requires-Python: >=3.9
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Provides-Extra: tzdata
28
+ Requires-Dist: tzdata>=2024.1; extra == "tzdata"
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=7.4; extra == "dev"
31
+ Requires-Dist: pytest-cov>=4.1; extra == "dev"
32
+ Requires-Dist: ruff>=0.5; extra == "dev"
33
+ Requires-Dist: mypy>=1.10; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # timeweave
37
+
38
+ Timezone detection, conversion, and a self-maintaining IANA rule cache.
39
+ Zero runtime dependencies. Python 3.9+.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install timeweave
45
+ ```
46
+
47
+ ## Layers
48
+
49
+ | Module | Role |
50
+ |---|---|
51
+ | `timeweave.tzkit` | Detect, convert, parse, list zones (read path) |
52
+ | `timeweave.updater` | Fetch + SHA-256 verify + cache the rules DB |
53
+ | `timeweave.compiler` | Fetch + verify + `zic`-compile tz source into a zoneinfo tree |
54
+
55
+
56
+ ## Library use
57
+
58
+ ```python
59
+ import timeweave
60
+
61
+ info = timeweave.detect_timezone()
62
+ print(info["timezone"], info["utc_offset"], info["dst_active"])
63
+
64
+ result = timeweave.convert_timezone(
65
+ "2024-03-10 02:30", ["UTC", "Asia/Tokyo"], from_zone="America/New_York"
66
+ )
67
+ print(result["instant_utc"], result["local_time_kind"])
68
+
69
+ parsed = timeweave.parse_datetime("Sun, 10 Mar 2024 07:30:00 +0000")
70
+ ```
71
+
72
+ ## CLI
73
+
74
+ ```bash
75
+ tzkit detect --use-ip
76
+ tzkit convert '2024-03-10 02:30' --from America/New_York --to UTC --to Asia/Tokyo
77
+ tzkit parse 'Sun, 10 Mar 2024 07:30:00 +0000'
78
+ tzkit list Europe --transitions --sort offset
79
+
80
+ tz-update check --json
81
+ tz-compile refresh --force
82
+ tz-compile status
83
+ ```
84
+
85
+ ## DST policy
86
+
87
+ Wall-clock times that fall in a DST gap or fold are classified, never guessed
88
+ silently:
89
+
90
+ * `--ambiguous earliest|latest|raise` — repeated wall times (fold)
91
+ * `--nonexistent shift_forward|shift_backward|raise` — skipped wall times (gap)
92
+
93
+ `raise` exits `6` so CI catches the case.
94
+
95
+ ## Auto-update
96
+
97
+ `detect_timezone()` and `convert_timezone()` start a non-blocking daemon-thread
98
+ update check, at most once per 24h, gated by a marker file. Failures are logged
99
+ and swallowed; the caller's result is never affected.
100
+
101
+ Disable it:
102
+
103
+ | Variable | Effect |
104
+ |---|---|
105
+ | `TIMEWEAVE_NO_AUTO_UPDATE=1` | Disable the on-use check only |
106
+ | `TIMEWEAVE_OFFLINE=1` | Disable all outbound network from the package |
107
+ | `TIMEWEAVE_NO_NETWORK=1` | Same as `OFFLINE` |
108
+ | `TIMEWEAVE_AUTO_INTERVAL` | Seconds between checks (default `86400`) |
109
+
110
+ ## Environment
111
+
112
+ | Variable | Purpose |
113
+ |---|---|
114
+ | `TIMEWEAVE_SRC_URL` | Source endpoint (compiler) |
115
+ | `TIMEWEAVE_CHECKSUM_URL` | Published SHA-256 (default `<url>.sha256`) |
116
+ | `TIMEWEAVE_SIGNATURE_URL` + `TIMEWEAVE_GPG_KEYRING` | Authenticity verification |
117
+ | `TIMEWEAVE_ZIC_PATH` | Absolute `zic` path (trusted config only) |
118
+ | `TIMEWEAVE_CACHE_DIR` | Cache directory |
119
+ | `TZDIR` | Compiled zoneinfo tree; prepended to `zoneinfo.TZPATH` on import |
120
+ | `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` | Custom CA bundle |
121
+ | `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | Standard proxy settings |
122
+
123
+ ## Security notes
124
+
125
+ * **SHA-256 proves integrity, not authenticity.** If the same host serves the
126
+ payload and the checksum, it controls both. Set `TIMEWEAVE_SIGNATURE_URL` and
127
+ `TIMEWEAVE_GPG_KEYRING` against a trusted key for real authenticity.
128
+ * **The compiler targets an app-owned directory by default.** Writing into
129
+ `/usr/share/zoneinfo` requires `--system`, needs root, and affects every
130
+ process on the host. Prefer your OS's signed `tzdata` package for the system
131
+ tree; use this module for isolated, air-gapped, or version-pinned caches.
132
+ * **Archive members and `Zone`/`Link` names are validated before `zic` runs**,
133
+ and compiler output is re-checked for containment afterwards.
134
+ * The `zic` path is a fixed absolute constant, validated executable, and is
135
+ never read from a network response.
136
+
137
+ ## License
138
+
139
+ MIT
@@ -0,0 +1,104 @@
1
+ # timeweave
2
+
3
+ Timezone detection, conversion, and a self-maintaining IANA rule cache.
4
+ Zero runtime dependencies. Python 3.9+.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install timeweave
10
+ ```
11
+
12
+ ## Layers
13
+
14
+ | Module | Role |
15
+ |---|---|
16
+ | `timeweave.tzkit` | Detect, convert, parse, list zones (read path) |
17
+ | `timeweave.updater` | Fetch + SHA-256 verify + cache the rules DB |
18
+ | `timeweave.compiler` | Fetch + verify + `zic`-compile tz source into a zoneinfo tree |
19
+
20
+
21
+ ## Library use
22
+
23
+ ```python
24
+ import timeweave
25
+
26
+ info = timeweave.detect_timezone()
27
+ print(info["timezone"], info["utc_offset"], info["dst_active"])
28
+
29
+ result = timeweave.convert_timezone(
30
+ "2024-03-10 02:30", ["UTC", "Asia/Tokyo"], from_zone="America/New_York"
31
+ )
32
+ print(result["instant_utc"], result["local_time_kind"])
33
+
34
+ parsed = timeweave.parse_datetime("Sun, 10 Mar 2024 07:30:00 +0000")
35
+ ```
36
+
37
+ ## CLI
38
+
39
+ ```bash
40
+ tzkit detect --use-ip
41
+ tzkit convert '2024-03-10 02:30' --from America/New_York --to UTC --to Asia/Tokyo
42
+ tzkit parse 'Sun, 10 Mar 2024 07:30:00 +0000'
43
+ tzkit list Europe --transitions --sort offset
44
+
45
+ tz-update check --json
46
+ tz-compile refresh --force
47
+ tz-compile status
48
+ ```
49
+
50
+ ## DST policy
51
+
52
+ Wall-clock times that fall in a DST gap or fold are classified, never guessed
53
+ silently:
54
+
55
+ * `--ambiguous earliest|latest|raise` — repeated wall times (fold)
56
+ * `--nonexistent shift_forward|shift_backward|raise` — skipped wall times (gap)
57
+
58
+ `raise` exits `6` so CI catches the case.
59
+
60
+ ## Auto-update
61
+
62
+ `detect_timezone()` and `convert_timezone()` start a non-blocking daemon-thread
63
+ update check, at most once per 24h, gated by a marker file. Failures are logged
64
+ and swallowed; the caller's result is never affected.
65
+
66
+ Disable it:
67
+
68
+ | Variable | Effect |
69
+ |---|---|
70
+ | `TIMEWEAVE_NO_AUTO_UPDATE=1` | Disable the on-use check only |
71
+ | `TIMEWEAVE_OFFLINE=1` | Disable all outbound network from the package |
72
+ | `TIMEWEAVE_NO_NETWORK=1` | Same as `OFFLINE` |
73
+ | `TIMEWEAVE_AUTO_INTERVAL` | Seconds between checks (default `86400`) |
74
+
75
+ ## Environment
76
+
77
+ | Variable | Purpose |
78
+ |---|---|
79
+ | `TIMEWEAVE_SRC_URL` | Source endpoint (compiler) |
80
+ | `TIMEWEAVE_CHECKSUM_URL` | Published SHA-256 (default `<url>.sha256`) |
81
+ | `TIMEWEAVE_SIGNATURE_URL` + `TIMEWEAVE_GPG_KEYRING` | Authenticity verification |
82
+ | `TIMEWEAVE_ZIC_PATH` | Absolute `zic` path (trusted config only) |
83
+ | `TIMEWEAVE_CACHE_DIR` | Cache directory |
84
+ | `TZDIR` | Compiled zoneinfo tree; prepended to `zoneinfo.TZPATH` on import |
85
+ | `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` | Custom CA bundle |
86
+ | `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | Standard proxy settings |
87
+
88
+ ## Security notes
89
+
90
+ * **SHA-256 proves integrity, not authenticity.** If the same host serves the
91
+ payload and the checksum, it controls both. Set `TIMEWEAVE_SIGNATURE_URL` and
92
+ `TIMEWEAVE_GPG_KEYRING` against a trusted key for real authenticity.
93
+ * **The compiler targets an app-owned directory by default.** Writing into
94
+ `/usr/share/zoneinfo` requires `--system`, needs root, and affects every
95
+ process on the host. Prefer your OS's signed `tzdata` package for the system
96
+ tree; use this module for isolated, air-gapped, or version-pinned caches.
97
+ * **Archive members and `Zone`/`Link` names are validated before `zic` runs**,
98
+ and compiler output is re-checked for containment afterwards.
99
+ * The `zic` path is a fixed absolute constant, validated executable, and is
100
+ never read from a network response.
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,70 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "timeweave"
7
+ version = "1.0.0"
8
+ description = "Timezone detection, conversion, and self-maintaining IANA rule cache."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "vxmpingz_" }]
13
+ keywords = ["timezone", "tzdata", "zoneinfo", "iana", "dst", "zic"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: POSIX :: Linux",
19
+ "Operating System :: MacOS",
20
+ "Operating System :: Microsoft :: Windows",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: Software Development :: Libraries :: Python Modules",
28
+ "Topic :: System :: Systems Administration",
29
+ ]
30
+ dependencies = []
31
+
32
+ [project.optional-dependencies]
33
+ tzdata = ["tzdata>=2024.1"]
34
+ dev = [
35
+ "pytest>=7.4",
36
+ "pytest-cov>=4.1",
37
+ "ruff>=0.5",
38
+ "mypy>=1.10",
39
+ ]
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/vxmpingz/timeweave"
43
+ Issues = "https://github.com/vxmpingz/timeweave/issues"
44
+
45
+ [project.scripts]
46
+ tzkit = "timeweave.tzkit:main"
47
+ tz-update = "timeweave.updater:main"
48
+ tz-compile = "timeweave.compiler:main"
49
+
50
+ [tool.setuptools]
51
+ package-dir = { "" = "src" }
52
+
53
+ [tool.setuptools.packages.find]
54
+ where = ["src"]
55
+
56
+ [tool.setuptools.package-data]
57
+ timeweave = ["py.typed"]
58
+
59
+ [tool.ruff]
60
+ line-length = 92
61
+ target-version = "py39"
62
+
63
+ [tool.ruff.lint]
64
+ select = ["E", "F", "W", "I", "B", "UP", "S"]
65
+ ignore = ["S603", "S607"]
66
+
67
+ [tool.mypy]
68
+ python_version = "3.9"
69
+ warn_unused_ignores = true
70
+ warn_redundant_casts = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,210 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import threading
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional, Sequence
8
+
9
+ from ._logging import get_logger, log_event
10
+ from ._state import should_run_periodic, touch_marker
11
+
12
+ from . import tzkit as _tzkit
13
+ from . import updater as _updater
14
+ from . import compiler as _compiler
15
+
16
+ from .updater import _sync_database, get_cached_database
17
+ from .compiler import (refresh_zoneinfo, activate_cache,
18
+ get_cache_status, compile_source)
19
+
20
+ __version__ = "1.0.0"
21
+ __all__ = [
22
+ "detect_timezone", "convert_timezone", "parse_datetime",
23
+ "_sync_database", "refresh_zoneinfo", "activate_cache",
24
+ "get_cached_database", "get_cache_status", "compile_source",
25
+ "use_compiled_cache", "_ensure_cache", "auto_update_enabled",
26
+ "__version__",
27
+ ]
28
+
29
+ LOG = get_logger("timeweave")
30
+
31
+ _DEFAULT_INTERVAL = 24 * 3600
32
+ _AUTO_LOCK = threading.Lock()
33
+ _AUTO_THREAD: Optional[threading.Thread] = None
34
+
35
+
36
+ # --------------------------------------------------------------------------
37
+ # Environment helpers
38
+ # --------------------------------------------------------------------------
39
+
40
+ def _is_truthy(value: Optional[str]) -> bool:
41
+ return bool(value) and value.strip().lower() in ("1", "true", "yes", "on")
42
+
43
+
44
+ def auto_update_enabled() -> bool:
45
+ if _is_truthy(os.environ.get("TIMEWEAVE_NO_AUTO_UPDATE")):
46
+ return False
47
+ if _is_truthy(os.environ.get("TIMEWEAVE_OFFLINE")):
48
+ return False
49
+ if _is_truthy(os.environ.get("TIMEWEAVE_NO_NETWORK")):
50
+ return False
51
+ return True
52
+
53
+
54
+ def _auto_interval() -> float:
55
+ raw = os.environ.get("TIMEWEAVE_AUTO_INTERVAL")
56
+ if raw is None:
57
+ return _DEFAULT_INTERVAL
58
+ try:
59
+ value = float(raw)
60
+ except ValueError:
61
+ log_event(LOG, logging.WARNING, "invalid_env_value",
62
+ variable="TIMEWEAVE_AUTO_INTERVAL", value=raw)
63
+ return _DEFAULT_INTERVAL
64
+ return value if value > 0 else _DEFAULT_INTERVAL
65
+
66
+
67
+ def _cache_dir() -> Path:
68
+ override = os.environ.get("TIMEWEAVE_CACHE_DIR")
69
+ if override:
70
+ return Path(override).expanduser()
71
+ base = os.environ.get("XDG_CACHE_HOME")
72
+ if base:
73
+ return Path(base).expanduser() / "timeweave"
74
+ if os.name == "nt":
75
+ local = os.environ.get("LOCALAPPDATA")
76
+ if local:
77
+ return Path(local) / "timeweave" / "cache"
78
+ return Path.home() / ".cache" / "timeweave"
79
+
80
+
81
+ def _marker_path() -> Path:
82
+ return _cache_dir() / ".cache_freshness"
83
+
84
+ def _refresh_cache() -> None:
85
+ try:
86
+ result = _sync_database()
87
+ log_event(LOG, logging.INFO, "auto_update_finished",
88
+ status=getattr(result, "status", None),
89
+ changed=getattr(result, "changed", None),
90
+ db_version=getattr(result, "db_version", None))
91
+ except Exception as exc:
92
+ log_event(LOG, logging.DEBUG, "auto_update_error",
93
+ error=f"{type(exc).__name__}: {exc}")
94
+
95
+
96
+ def _ensure_cache(force: bool = False) -> bool:
97
+ global _AUTO_THREAD
98
+ try:
99
+ if not force and not auto_update_enabled():
100
+ return False
101
+ marker = _marker_path()
102
+ if not force and not should_run_periodic(marker, _auto_interval()):
103
+ return False
104
+ with _AUTO_LOCK:
105
+ if _AUTO_THREAD is not None and _AUTO_THREAD.is_alive():
106
+ return False
107
+ touch_marker(marker)
108
+ thread = threading.Thread(target=_refresh_cache,
109
+ name="curls-autoupdate", daemon=True)
110
+ thread.start()
111
+ _AUTO_THREAD = thread
112
+ log_event(LOG, logging.DEBUG, "auto_update_started", force=force)
113
+ return True
114
+ except Exception as exc:
115
+ log_event(LOG, logging.DEBUG, "auto_update_trigger_failed",
116
+ error=f"{type(exc).__name__}: {exc}")
117
+ return False
118
+
119
+ def _apply_tzpath(directory: str) -> bool:
120
+ path = Path(directory).expanduser()
121
+ if not path.is_dir():
122
+ log_event(LOG, logging.WARNING, "tzdir_missing", path=str(path))
123
+ return False
124
+ try:
125
+ import zoneinfo
126
+ existing = [p for p in zoneinfo.TZPATH if p != str(path)]
127
+ zoneinfo.reset_tzpath([str(path), *existing])
128
+ log_event(LOG, logging.INFO, "tzpath_applied", path=str(path))
129
+ return True
130
+ except Exception as exc:
131
+ log_event(LOG, logging.WARNING, "tzpath_apply_failed",
132
+ path=str(path), error=str(exc))
133
+ return False
134
+
135
+
136
+ def use_compiled_cache(config: Optional[Any] = None) -> bool:
137
+ return activate_cache(config)
138
+
139
+
140
+ def _init_tzdir() -> None:
141
+ tzdir = os.environ.get("TZDIR")
142
+ if tzdir:
143
+ _apply_tzpath(tzdir)
144
+
145
+ def _tzkit_config(config: Optional[Any]) -> Any:
146
+ if config is not None:
147
+ return config
148
+ cfg = _tzkit.Config()
149
+ if not auto_update_enabled():
150
+ cfg.no_network = True
151
+ return cfg
152
+
153
+ def detect_timezone(use_ip: bool = False, config: Optional[Any] = None) -> Dict[str, Any]:
154
+ _ensure_cache()
155
+ cfg = _tzkit_config(config)
156
+ return _tzkit.detect_timezone(cfg, use_ip=use_ip and auto_update_enabled())
157
+
158
+
159
+ def parse_datetime(text: str, *, assume_tz: Optional[str] = None,
160
+ ambiguous: str = "earliest", nonexistent: str = "shift_forward",
161
+ strict_abbrev: bool = False, dayfirst: bool = False,
162
+ config: Optional[Any] = None) -> Any:
163
+ cfg = _tzkit_config(config)
164
+ if dayfirst:
165
+ cfg.dayfirst = True
166
+ return _tzkit.parse_datetime(text, cfg, assume_tz=assume_tz,
167
+ ambiguous=ambiguous, nonexistent=nonexistent,
168
+ strict_abbrev=strict_abbrev)
169
+
170
+
171
+ def convert_timezone(datetime_string: str, to_zones: Sequence[str], *,
172
+ from_zone: Optional[str] = None, ambiguous: str = "earliest",
173
+ nonexistent: str = "shift_forward", strict_abbrev: bool = False,
174
+ config: Optional[Any] = None) -> Dict[str, Any]:
175
+ _ensure_cache()
176
+ cfg = _tzkit_config(config)
177
+
178
+ if not to_zones:
179
+ detected = _tzkit.detect_timezone(cfg, use_ip=False)["timezone"]
180
+ to_zones = [detected]
181
+
182
+ parsed = _tzkit.parse_datetime(
183
+ datetime_string, cfg, assume_tz=from_zone, ambiguous=ambiguous,
184
+ nonexistent=nonexistent, strict_abbrev=strict_abbrev)
185
+
186
+ from datetime import timezone as _timezone
187
+ rows: List[Dict[str, Any]] = []
188
+ for name in to_zones:
189
+ zone = _tzkit.get_zone(name)
190
+ local = parsed.dt.astimezone(zone)
191
+ rows.append({
192
+ "zone": zone.key,
193
+ "local_time": local.isoformat(),
194
+ "utc_offset": _tzkit.offset_str(local.utcoffset()),
195
+ "abbreviation": local.tzname() or "?",
196
+ "dst_active": _tzkit.is_dst(local),
197
+ })
198
+
199
+ return {
200
+ "input": datetime_string,
201
+ "instant_utc": parsed.dt.astimezone(_timezone.utc).isoformat(),
202
+ "epoch": parsed.dt.timestamp(),
203
+ "source_zone": parsed.tz_source,
204
+ "matched_format": parsed.matched,
205
+ "local_time_kind": parsed.kind,
206
+ "warnings": list(parsed.warnings),
207
+ "targets": rows,
208
+ }
209
+
210
+ _init_tzdir()
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import re
5
+ import ssl
6
+ import time
7
+ import urllib.error
8
+ import urllib.request
9
+ from dataclasses import dataclass
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from ._logging import get_logger, log_event
13
+
14
+ __all__ = [
15
+ "HttpError", "TlsConfigError", "FetchResult",
16
+ "build_ssl_context", "build_opener", "fetch",
17
+ "CHUNK", "redact_proxy",
18
+ ]
19
+
20
+ LOG = get_logger("timeweave.http")
21
+
22
+ CHUNK = 1024 * 256
23
+ _RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})
24
+ _TERMINAL_STATUS = frozenset({400, 401, 403, 404, 410})
25
+
26
+
27
+ class HttpError(Exception):
28
+ pass
29
+
30
+
31
+ class TlsConfigError(HttpError):
32
+ pass
33
+
34
+
35
+ @dataclass
36
+ class FetchResult:
37
+ status: int
38
+ body: Optional[bytes]
39
+ etag: Optional[str]
40
+ last_modified: Optional[str]
41
+ headers: Dict[str, str]
42
+ url: str
43
+
44
+
45
+ def redact_proxy(value: str) -> str:
46
+ return re.sub(r"://[^@/]+@", "://***@", value)
47
+
48
+
49
+ def build_ssl_context(ca_file: Optional[str] = None) -> ssl.SSLContext:
50
+ context = ssl.create_default_context()
51
+ context.check_hostname = True
52
+ context.verify_mode = ssl.CERT_REQUIRED
53
+ if ca_file:
54
+ try:
55
+ context.load_verify_locations(cafile=ca_file)
56
+ log_event(LOG, logging.INFO, "ca_bundle_loaded", path=ca_file)
57
+ except (ssl.SSLError, OSError) as exc:
58
+ raise TlsConfigError(f"cannot load CA bundle {ca_file}: {exc}") from exc
59
+ return context
60
+
61
+
62
+ def build_opener(ca_file: Optional[str] = None) -> urllib.request.OpenerDirector:
63
+ context = build_ssl_context(ca_file)
64
+ handlers: List[urllib.request.BaseHandler] = [
65
+ urllib.request.HTTPSHandler(context=context)
66
+ ]
67
+ proxies = urllib.request.getproxies()
68
+ if proxies:
69
+ redacted = {k: redact_proxy(v) for k, v in proxies.items()}
70
+ log_event(LOG, logging.INFO, "proxy_configured", proxies=redacted)
71
+ handlers.append(urllib.request.ProxyHandler(proxies))
72
+ else:
73
+ handlers.append(urllib.request.ProxyHandler({}))
74
+ opener = urllib.request.build_opener(*handlers)
75
+ opener.addheaders = []
76
+ return opener
77
+
78
+
79
+ def _read_capped(resp: Any, cap: int) -> bytes:
80
+ chunks: List[bytes] = []
81
+ total = 0
82
+ while True:
83
+ chunk = resp.read(CHUNK)
84
+ if not chunk:
85
+ break
86
+ total += len(chunk)
87
+ if total > cap:
88
+ raise HttpError(f"payload exceeds {cap} bytes; aborting")
89
+ chunks.append(chunk)
90
+ return b"".join(chunks)
91
+
92
+
93
+ def fetch(opener: urllib.request.OpenerDirector, url: str, *,
94
+ headers: Optional[Dict[str, str]] = None, timeout: float = 10.0,
95
+ retries: int = 2, cap: int = 64 * 1024 * 1024,
96
+ user_agent: str = "timeweave") -> FetchResult:
97
+ base_headers = {"User-Agent": user_agent}
98
+ if headers:
99
+ base_headers.update(headers)
100
+
101
+ last_exc: Optional[Exception] = None
102
+ for attempt in range(retries + 1):
103
+ request = urllib.request.Request(url, headers=base_headers, method="GET")
104
+ started = time.monotonic()
105
+ try:
106
+ with opener.open(request, timeout=timeout) as resp:
107
+ status = resp.getcode()
108
+ body = _read_capped(resp, cap)
109
+ result = FetchResult(
110
+ status=status, body=body,
111
+ etag=resp.headers.get("ETag"),
112
+ last_modified=resp.headers.get("Last-Modified"),
113
+ headers=dict(resp.headers.items()), url=url,
114
+ )
115
+ log_event(LOG, logging.INFO, "http_response", url=url,
116
+ status=status, etag=result.etag, bytes=len(body),
117
+ duration_ms=round((time.monotonic() - started) * 1000),
118
+ attempt=attempt + 1)
119
+ return result
120
+ except urllib.error.HTTPError as exc:
121
+ if exc.code == 304:
122
+ etag = exc.headers.get("ETag") if exc.headers else None
123
+ log_event(LOG, logging.INFO, "http_not_modified", url=url,
124
+ status=304, etag=etag, attempt=attempt + 1)
125
+ return FetchResult(304, None, etag, None,
126
+ dict(exc.headers.items()) if exc.headers else {},
127
+ url)
128
+ last_exc = exc
129
+ log_event(LOG, logging.WARNING, "http_error", url=url, status=exc.code,
130
+ reason=str(exc.reason), attempt=attempt + 1)
131
+ if exc.code in _TERMINAL_STATUS or exc.code not in _RETRYABLE_STATUS:
132
+ break
133
+ except (urllib.error.URLError, ssl.SSLError, TimeoutError, OSError) as exc:
134
+ last_exc = exc
135
+ log_event(LOG, logging.WARNING, "network_error", url=url,
136
+ error=str(getattr(exc, "reason", exc)), attempt=attempt + 1)
137
+ if attempt < retries:
138
+ time.sleep(min(1.5 * (attempt + 1), 5.0))
139
+ raise HttpError(f"request failed for {url}: {last_exc}")