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 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))