timeweave 1.0.0__py3-none-any.whl
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/__init__.py +210 -0
- timeweave/_http.py +139 -0
- timeweave/_logging.py +93 -0
- timeweave/_state.py +134 -0
- timeweave/compiler.py +873 -0
- timeweave/tzkit.py +1294 -0
- timeweave/updater.py +564 -0
- timeweave-1.0.0.dist-info/METADATA +139 -0
- timeweave-1.0.0.dist-info/RECORD +13 -0
- timeweave-1.0.0.dist-info/WHEEL +5 -0
- timeweave-1.0.0.dist-info/entry_points.txt +4 -0
- timeweave-1.0.0.dist-info/licenses/LICENSE +21 -0
- timeweave-1.0.0.dist-info/top_level.txt +1 -0
timeweave/__init__.py
ADDED
|
@@ -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()
|
timeweave/_http.py
ADDED
|
@@ -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}")
|
timeweave/_logging.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any, Dict, Iterator, Optional
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"StructuredFormatter", "get_logger", "setup_logging",
|
|
13
|
+
"log_event", "operation",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StructuredFormatter(logging.Formatter):
|
|
18
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
19
|
+
payload: Dict[str, Any] = {
|
|
20
|
+
"timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds"),
|
|
21
|
+
"level": record.levelname,
|
|
22
|
+
"logger": record.name,
|
|
23
|
+
"event": record.getMessage(),
|
|
24
|
+
}
|
|
25
|
+
ctx = getattr(record, "context", None)
|
|
26
|
+
if isinstance(ctx, dict):
|
|
27
|
+
for key, value in ctx.items():
|
|
28
|
+
if value is not None:
|
|
29
|
+
payload[key] = value
|
|
30
|
+
if record.exc_info:
|
|
31
|
+
payload["exception"] = self.formatException(record.exc_info)
|
|
32
|
+
return json.dumps(payload, ensure_ascii=False, default=str)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_TEXT_FORMAT = logging.Formatter(
|
|
36
|
+
"%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
|
37
|
+
datefmt="%Y-%m-%dT%H:%M:%S%z",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_logger(name: str) -> logging.Logger:
|
|
42
|
+
return logging.getLogger(name)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def setup_logging(logger: logging.Logger, verbose: int = 0, quiet: bool = False,
|
|
46
|
+
log_file: Optional[str] = None, structured: bool = True) -> None:
|
|
47
|
+
if quiet:
|
|
48
|
+
level = logging.ERROR
|
|
49
|
+
elif verbose >= 2:
|
|
50
|
+
level = logging.DEBUG
|
|
51
|
+
else:
|
|
52
|
+
level = logging.INFO
|
|
53
|
+
|
|
54
|
+
logger.setLevel(logging.DEBUG)
|
|
55
|
+
logger.handlers.clear()
|
|
56
|
+
logger.propagate = False
|
|
57
|
+
|
|
58
|
+
stream = logging.StreamHandler(sys.stderr)
|
|
59
|
+
stream.setLevel(level)
|
|
60
|
+
stream.setFormatter(StructuredFormatter() if structured else _TEXT_FORMAT)
|
|
61
|
+
logger.addHandler(stream)
|
|
62
|
+
|
|
63
|
+
if log_file:
|
|
64
|
+
try:
|
|
65
|
+
handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
66
|
+
except OSError as exc:
|
|
67
|
+
logger.error("cannot open log file %s: %s", log_file, exc)
|
|
68
|
+
else:
|
|
69
|
+
handler.setLevel(logging.DEBUG)
|
|
70
|
+
handler.setFormatter(StructuredFormatter())
|
|
71
|
+
logger.addHandler(handler)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def log_event(logger: logging.Logger, level: int, event: str, **context: Any) -> None:
|
|
75
|
+
logger.log(level, event, extra={"context": context})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@contextlib.contextmanager
|
|
79
|
+
def operation(logger: logging.Logger, name: str, **ctx: Any) -> Iterator[None]:
|
|
80
|
+
start = time.monotonic()
|
|
81
|
+
log_event(logger, logging.INFO, "operation_start", operation=name, **ctx)
|
|
82
|
+
try:
|
|
83
|
+
yield
|
|
84
|
+
except BaseException as exc: # re-raised after logging
|
|
85
|
+
duration = round((time.monotonic() - start) * 1000)
|
|
86
|
+
log_event(logger, logging.ERROR, "operation_complete", operation=name,
|
|
87
|
+
status="error", duration_ms=duration,
|
|
88
|
+
error=f"{type(exc).__name__}: {exc}")
|
|
89
|
+
raise
|
|
90
|
+
else:
|
|
91
|
+
duration = round((time.monotonic() - start) * 1000)
|
|
92
|
+
log_event(logger, logging.INFO, "operation_complete", operation=name,
|
|
93
|
+
status="ok", duration_ms=duration)
|
timeweave/_state.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
from ._logging import get_logger, log_event
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"StateError", "atomic_write_bytes", "load_json", "save_json_atomic",
|
|
16
|
+
"ProcessLock", "should_run_periodic", "touch_marker", "marker_age_seconds",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
LOG = get_logger("timeweave.state")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StateError(Exception):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def atomic_write_bytes(path: Path, data: bytes) -> None:
|
|
27
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent),
|
|
29
|
+
prefix=path.name + ".", suffix=".tmp")
|
|
30
|
+
tmp_path = Path(tmp_name)
|
|
31
|
+
try:
|
|
32
|
+
with os.fdopen(fd, "wb") as handle:
|
|
33
|
+
handle.write(data)
|
|
34
|
+
handle.flush()
|
|
35
|
+
os.fsync(handle.fileno())
|
|
36
|
+
os.replace(str(tmp_path), str(path))
|
|
37
|
+
except OSError:
|
|
38
|
+
with contextlib.suppress(OSError):
|
|
39
|
+
tmp_path.unlink()
|
|
40
|
+
raise
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_json(path: Path) -> Optional[Dict[str, Any]]:
|
|
44
|
+
if not path.is_file():
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
48
|
+
except (OSError, ValueError) as exc:
|
|
49
|
+
log_event(LOG, logging.WARNING, "state_unreadable",
|
|
50
|
+
path=str(path), error=str(exc))
|
|
51
|
+
return None
|
|
52
|
+
if not isinstance(data, dict):
|
|
53
|
+
log_event(LOG, logging.WARNING, "state_malformed", path=str(path))
|
|
54
|
+
return None
|
|
55
|
+
return data
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def save_json_atomic(path: Path, obj: Dict[str, Any]) -> None:
|
|
59
|
+
atomic_write_bytes(path, json.dumps(obj, indent=2, default=str).encode("utf-8"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ProcessLock:
|
|
63
|
+
|
|
64
|
+
def __init__(self, path: Path, timeout: float = 30.0) -> None:
|
|
65
|
+
self.path = path
|
|
66
|
+
self.timeout = timeout
|
|
67
|
+
self._handle: Optional[Any] = None
|
|
68
|
+
self._posix = os.name == "posix"
|
|
69
|
+
|
|
70
|
+
def __enter__(self) -> "ProcessLock":
|
|
71
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
self._handle = open(self.path, "a+")
|
|
73
|
+
deadline = time.time() + self.timeout
|
|
74
|
+
while True:
|
|
75
|
+
try:
|
|
76
|
+
if self._posix:
|
|
77
|
+
import fcntl
|
|
78
|
+
fcntl.flock(self._handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
79
|
+
else:
|
|
80
|
+
import msvcrt
|
|
81
|
+
msvcrt.locking(self._handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
82
|
+
return self
|
|
83
|
+
except ImportError as exc:
|
|
84
|
+
log_event(LOG, logging.DEBUG, "lock_unsupported", error=str(exc))
|
|
85
|
+
return self
|
|
86
|
+
except OSError as exc:
|
|
87
|
+
if time.time() >= deadline:
|
|
88
|
+
self._handle.close()
|
|
89
|
+
self._handle = None
|
|
90
|
+
raise StateError(
|
|
91
|
+
f"another operation holds the lock ({self.path})") from exc
|
|
92
|
+
time.sleep(0.25)
|
|
93
|
+
|
|
94
|
+
def __exit__(self, *exc: Any) -> None:
|
|
95
|
+
if self._handle is None:
|
|
96
|
+
return
|
|
97
|
+
try:
|
|
98
|
+
if self._posix:
|
|
99
|
+
import fcntl
|
|
100
|
+
fcntl.flock(self._handle.fileno(), fcntl.LOCK_UN)
|
|
101
|
+
else:
|
|
102
|
+
with contextlib.suppress(OSError):
|
|
103
|
+
import msvcrt
|
|
104
|
+
self._handle.seek(0)
|
|
105
|
+
msvcrt.locking(self._handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
106
|
+
finally:
|
|
107
|
+
self._handle.close()
|
|
108
|
+
self._handle = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def marker_age_seconds(marker_path: Path, now: Optional[float] = None) -> Optional[float]:
|
|
112
|
+
try:
|
|
113
|
+
mtime = marker_path.stat().st_mtime
|
|
114
|
+
except FileNotFoundError:
|
|
115
|
+
return None
|
|
116
|
+
except OSError:
|
|
117
|
+
return None
|
|
118
|
+
return max(0.0, (now if now is not None else time.time()) - mtime)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def should_run_periodic(marker_path: Path, interval_seconds: float,
|
|
122
|
+
now: Optional[float] = None) -> bool:
|
|
123
|
+
age = marker_age_seconds(marker_path, now)
|
|
124
|
+
return age is None or age >= interval_seconds
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def touch_marker(marker_path: Path) -> None:
|
|
128
|
+
try:
|
|
129
|
+
marker_path.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
marker_path.touch(exist_ok=True)
|
|
131
|
+
os.utime(marker_path, None)
|
|
132
|
+
except OSError as exc:
|
|
133
|
+
log_event(LOG, logging.WARNING, "marker_touch_failed",
|
|
134
|
+
path=str(marker_path), error=str(exc))
|