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.
- timeweave-1.0.0/LICENSE +21 -0
- timeweave-1.0.0/PKG-INFO +139 -0
- timeweave-1.0.0/README.md +104 -0
- timeweave-1.0.0/pyproject.toml +70 -0
- timeweave-1.0.0/setup.cfg +4 -0
- timeweave-1.0.0/src/timeweave/__init__.py +210 -0
- timeweave-1.0.0/src/timeweave/_http.py +139 -0
- timeweave-1.0.0/src/timeweave/_logging.py +93 -0
- timeweave-1.0.0/src/timeweave/_state.py +134 -0
- timeweave-1.0.0/src/timeweave/compiler.py +873 -0
- timeweave-1.0.0/src/timeweave/tzkit.py +1294 -0
- timeweave-1.0.0/src/timeweave/updater.py +564 -0
- timeweave-1.0.0/src/timeweave.egg-info/PKG-INFO +139 -0
- timeweave-1.0.0/src/timeweave.egg-info/SOURCES.txt +16 -0
- timeweave-1.0.0/src/timeweave.egg-info/dependency_links.txt +1 -0
- timeweave-1.0.0/src/timeweave.egg-info/entry_points.txt +4 -0
- timeweave-1.0.0/src/timeweave.egg-info/requires.txt +9 -0
- timeweave-1.0.0/src/timeweave.egg-info/top_level.txt +1 -0
timeweave-1.0.0/LICENSE
ADDED
|
@@ -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.
|
timeweave-1.0.0/PKG-INFO
ADDED
|
@@ -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,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}")
|