#!/usr/bin/env python3 import argparse import json import re import sys from datetime import datetime, timedelta, timezone def _eprint(msg: str) -> None: print(msg, file=sys.stderr) def _format_utc_iso(dt: datetime) -> str: dt_utc = dt.astimezone(timezone.utc) return dt_utc.isoformat().replace("+00:00", "Z") def _parse_tz(tz_str: str) -> timezone: tz_str = tz_str.strip() if tz_str in {"UTC", "Z"}: return timezone.utc m = re.fullmatch(r"([+-])(\d{2}):?(\d{2})", tz_str) if m: sign, hh, mm = m.group(1), int(m.group(2)), int(m.group(3)) delta = timedelta(hours=hh, minutes=mm) if sign == "-": delta = -delta return timezone(delta) try: from zoneinfo import ZoneInfo # pyright: ignore[reportMissingImports] return ZoneInfo(tz_str) # type: ignore[return-value] except Exception as e: raise ValueError( f"Unsupported timezone '{tz_str}'. Use 'UTC', an offset like '+08:00', or an IANA name like 'Asia/Shanghai'." ) from e def _parse_datetime(dt_str: str, assume_tz: str | None) -> tuple[datetime, str | None]: raw = dt_str.strip() normalized = raw if normalized.endswith(("Z", "z")): normalized = normalized[:-1] + "+00:00" dt: datetime | None = None try: dt = datetime.fromisoformat(normalized) except ValueError: pass if dt is None: patterns = [ "%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S.%f%z", "%Y-%m-%d %H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d", ] for p in patterns: try: dt = datetime.strptime(raw, p) break except ValueError: continue if dt is None: raise ValueError( "Unsupported datetime format. Prefer ISO 8601 like '2026-03-09T12:33:54.230+0800' or RFC3339 like '2026-03-09T04:33:54Z'." ) assumed: str | None = None if dt.tzinfo is None: if not assume_tz: raise ValueError( "Datetime has no timezone info. Re-run with --assume-tz (e.g., --assume-tz UTC or --assume-tz Asia/Shanghai)." ) tz = _parse_tz(assume_tz) dt = dt.replace(tzinfo=tz) assumed = assume_tz return dt, assumed def _dt_info(dt: datetime, assumed_tz: str | None = None) -> dict[str, str]: dt_utc = dt.astimezone(timezone.utc) epoch = datetime(1970, 1, 1, tzinfo=timezone.utc) delta = dt_utc - epoch total_us = (delta.days * 86400 + delta.seconds) * 1_000_000 + delta.microseconds info: dict[str, str] = { "utc_iso": _format_utc_iso(dt_utc), "epoch_s": str(total_us // 1_000_000), "epoch_ms": str(total_us // 1_000), "epoch_ns": str(total_us * 1_000), } if assumed_tz is not None: info["assumed_tz"] = assumed_tz return info def _ts_to_dt(ts: int, unit: str) -> datetime: if unit == "s": seconds, micros = ts, 0 elif unit == "ms": seconds, rem_ms = divmod(ts, 1_000) micros = rem_ms * 1_000 elif unit == "ns": seconds, rem_ns = divmod(ts, 1_000_000_000) micros = rem_ns // 1_000 else: raise ValueError("unit must be one of: s, ms, ns") epoch = datetime(1970, 1, 1, tzinfo=timezone.utc) return epoch + timedelta(seconds=seconds, microseconds=micros) def _parse_duration(s: str) -> timedelta: s = s.strip() m = re.fullmatch(r"(\d+)([smhd])", s) if not m: raise ValueError("Duration must match '' where unit is one of: s, m, h, d (e.g., 30m, 2h).") n = int(m.group(1)) unit = m.group(2) if unit == "s": return timedelta(seconds=n) if unit == "m": return timedelta(minutes=n) if unit == "h": return timedelta(hours=n) return timedelta(days=n) def _get_field(data: object, path: str) -> object: cur: object = data for part in path.split("."): if not isinstance(cur, dict) or part not in cur: raise KeyError(path) cur = cur[part] return cur def _emit(data: object, field: str | None) -> None: if field: try: v = _get_field(data, field) except KeyError: raise ValueError(f"Unknown --field '{field}'.") from None if isinstance(v, (dict, list)): print(json.dumps(v)) else: print(v) return print(json.dumps(data, indent=2, sort_keys=True)) def _cmd_dt_to_ts(args: argparse.Namespace) -> None: dt, assumed = _parse_datetime(args.dt, args.assume_tz) data = { "input": args.dt, **_dt_info(dt, assumed_tz=assumed), } _emit(data, args.field) def _cmd_ts_to_dt(args: argparse.Namespace) -> None: ts = int(args.ts) dt_utc = _ts_to_dt(ts, args.unit) out_tz = _parse_tz(args.tz) dt_out = dt_utc.astimezone(out_tz) data: dict[str, str] = { "input_ts": str(ts), "input_unit": args.unit, "utc_iso": _format_utc_iso(dt_utc), "tz": args.tz, "tz_iso": dt_out.isoformat(), } _emit(data, args.field) def _cmd_window(args: argparse.Namespace) -> None: before = _parse_duration(args.before) after = _parse_duration(args.after) if (args.center_dt is None) == (args.center_ts is None): raise ValueError("Provide exactly one of: --center-dt or --center-ts") if args.center_dt is not None: center_dt, assumed = _parse_datetime(args.center_dt, args.assume_tz) center = _dt_info(center_dt, assumed_tz=assumed) center["input"] = args.center_dt else: ts = int(args.center_ts) dt_utc = _ts_to_dt(ts, args.unit) center = {"input_ts": str(ts), "input_unit": args.unit, **_dt_info(dt_utc)} dt_center_utc = datetime.fromisoformat(center["utc_iso"].replace("Z", "+00:00")) start = _dt_info(dt_center_utc - before) end = _dt_info(dt_center_utc + after) data = { "center": center, "start": start, "end": end, "before": args.before, "after": args.after, } _emit(data, args.field) def main(argv: list[str]) -> int: p = argparse.ArgumentParser( description="Deterministically convert between datetime strings and Unix timestamps (s/ms/ns).", ) sub = p.add_subparsers(dest="cmd", required=True) p_dt = sub.add_parser("dt-to-ts", help="Convert a datetime string to Unix timestamps.") p_dt.add_argument("--dt", required=True, help="Datetime string (prefer ISO 8601).") p_dt.add_argument( "--assume-tz", default=None, help="Timezone to assume when --dt has no timezone (e.g., UTC, +08:00, Asia/Shanghai).", ) p_dt.add_argument( "--field", default=None, help="Print a single field (e.g., epoch_ms, utc_iso). Supports dot paths.", ) p_dt.set_defaults(func=_cmd_dt_to_ts) p_ts = sub.add_parser("ts-to-dt", help="Convert a Unix timestamp to datetime strings.") p_ts.add_argument("--ts", required=True, help="Timestamp value.") p_ts.add_argument("--unit", choices=["s", "ms", "ns"], default="s", help="Unit of --ts.") p_ts.add_argument( "--tz", default="UTC", help="Timezone for tz_iso output (UTC, +08:00, Asia/Shanghai).", ) p_ts.add_argument("--field", default=None, help="Print a single field. Supports dot paths.") p_ts.set_defaults(func=_cmd_ts_to_dt) p_w = sub.add_parser("window", help="Compute a start/end window around a center time.") p_w.add_argument("--center-dt", default=None, help="Center datetime string (prefer ISO 8601).") p_w.add_argument("--center-ts", default=None, help="Center timestamp value.") p_w.add_argument("--unit", choices=["s", "ms", "ns"], default="s", help="Unit of --center-ts (if provided).") p_w.add_argument( "--assume-tz", default=None, help="Timezone to assume when --center-dt has no timezone.", ) p_w.add_argument("--before", required=True, help="Duration before center (e.g., 30m, 2h).") p_w.add_argument("--after", required=True, help="Duration after center (e.g., 30m, 2h).") p_w.add_argument("--field", default=None, help="Print a single field. Supports dot paths.") p_w.set_defaults(func=_cmd_window) args = p.parse_args(argv) try: args.func(args) except ValueError as e: _eprint(f"ERROR: {e}") return 2 return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))