Coverage for src/signalk_cli/history/_time.py: 98%
85 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-06 00:17 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-06 00:17 +0000
1"""Time ranges for History API requests."""
3import logging
4import re
5from dataclasses import dataclass
6from datetime import UTC, datetime, timedelta
8logger = logging.getLogger("signalk_cli")
10_TIMESTAMP_FMT = "%Y-%m-%dT%H:%M:%SZ"
12_DURATION_RE = re.compile(
13 r"^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?"
14 r"(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$"
15)
18def _has_date_parts(duration: str) -> bool:
19 m = _DURATION_RE.match(duration)
20 return bool(m and any(m.group(i) for i in (1, 2, 3, 4)))
23def _duration_to_timedelta(duration: str) -> timedelta:
24 m = _DURATION_RE.match(duration)
25 if not m: 25 ↛ 26line 25 didn't jump to line 26 because the condition on line 25 was never true
26 raise ValueError(f"Cannot parse duration: {duration!r}")
27 years, months, weeks, days, hours, minutes = (
28 int(m.group(i) or 0) for i in range(1, 7)
29 )
30 secs = float(m.group(7) or 0)
31 return timedelta(
32 days=years * 365 + months * 30 + weeks * 7 + days,
33 hours=hours,
34 minutes=minutes,
35 seconds=secs,
36 )
39def normalise_duration(
40 duration: str | None, from_: str | None, to: str | None
41) -> tuple[str | None, str | None, str | None]:
42 """Convert date-component durations to explicit from/to timestamps.
44 SignalK only accepts PT-prefix (time-only) durations. Durations containing
45 Y/M/W/D are expanded to from/to pairs:
46 from + duration → to = from + duration
47 to + duration → from = to - duration
48 duration alone → from = now - duration, to = now
50 Returns (from_, to, duration_or_None).
51 """
52 if not duration:
53 return from_, to, duration
54 try:
55 int(duration)
56 return from_, to, duration # integer seconds, pass through
57 except ValueError:
58 pass
59 if not _has_date_parts(duration):
60 return from_, to, duration # PT-only, pass through
62 delta = _duration_to_timedelta(duration)
63 fmt = _TIMESTAMP_FMT
64 now = datetime.now(UTC)
66 if from_ is not None and to is not None:
67 return from_, to, None
68 elif from_ is not None:
69 from_dt = datetime.fromisoformat(from_)
70 return from_, (from_dt + delta).strftime(fmt), None
71 elif to is not None:
72 to_dt = datetime.fromisoformat(to)
73 return (to_dt - delta).strftime(fmt), to, None
74 else:
75 return (now - delta).strftime(fmt), now.strftime(fmt), None
78def apply_time_default(time_params: dict) -> dict:
79 """If neither 'from' nor 'duration' is set, default to the hour ending at 'to' (or now)."""
80 if "from" in time_params or "duration" in time_params:
81 return time_params
82 if "to" in time_params:
83 try:
84 to_dt = datetime.fromisoformat(time_params["to"])
85 except ValueError:
86 to_dt = datetime.now(UTC)
87 else:
88 to_dt = datetime.now(UTC)
89 from_dt = to_dt - timedelta(hours=1)
90 result = {
91 **time_params,
92 "from": from_dt.strftime(_TIMESTAMP_FMT),
93 "to": to_dt.strftime(_TIMESTAMP_FMT),
94 }
95 logger.info(
96 "No time range specified — defaulting to from=%s to=%s",
97 result["from"],
98 result["to"],
99 )
100 return result
103def _format_time(value: str | datetime | None) -> str | None:
104 if value is None or isinstance(value, str):
105 return value
106 if value.tzinfo is None:
107 value = value.replace(tzinfo=UTC)
108 return value.astimezone(UTC).strftime(_TIMESTAMP_FMT)
111def _format_duration(value: str | int | timedelta | None) -> str | None:
112 if value is None or isinstance(value, str):
113 return value
114 if isinstance(value, timedelta):
115 seconds = value.total_seconds()
116 return str(int(seconds)) if seconds.is_integer() else f"PT{seconds}S"
117 return str(value)
120@dataclass(frozen=True)
121class TimeRange:
122 """The time window for a History API request.
124 Any combination of start, end and duration may be given, as
125 the History API allows. Timestamps may be ISO 8601 strings or datetimes
126 (naive datetimes are taken as UTC). Durations may be ISO 8601 strings
127 (PT15M, P1D), integer seconds, or timedeltas.
129 Examples:
130 ```python
131 last_hour = TimeRange(duration="PT1H")
132 one_day = TimeRange(start="2026-05-27T00:00:00Z", duration="P1D")
133 ```
134 """
136 start: str | datetime | None = None
137 end: str | datetime | None = None
138 duration: str | int | timedelta | None = None
140 def resolved(self) -> "TimeRange":
141 """Return an equivalent range the server accepts, with all values as strings.
143 The server only accepts time-only durations (PT...), so durations
144 with date parts (P1D, P1W) are expanded to explicit start and
145 end timestamps. With no start and no duration, the range defaults to
146 the hour ending at end, or now.
147 """
148 start, end, duration = normalise_duration(
149 _format_duration(self.duration),
150 _format_time(self.start),
151 _format_time(self.end),
152 )
153 params = apply_time_default(_params(start, end, duration))
154 return TimeRange(
155 start=params.get("from"),
156 end=params.get("to"),
157 duration=params.get("duration"),
158 )
160 def params(self) -> dict[str, str]:
161 """Return the from/to/duration query parameters for this range, as given."""
162 return _params(
163 _format_time(self.start),
164 _format_time(self.end),
165 _format_duration(self.duration),
166 )
169def _params(start: str | None, end: str | None, duration: str | None) -> dict[str, str]:
170 p: dict[str, str] = {}
171 if start:
172 p["from"] = start
173 if end:
174 p["to"] = end
175 if duration:
176 p["duration"] = duration
177 return p