cctv-stack 0.3.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.
- cctv/__init__.py +19 -0
- cctv/atomic.py +184 -0
- cctv/cleanup.py +1014 -0
- cctv/clip.py +278 -0
- cctv/config.py +742 -0
- cctv/config_cli.py +217 -0
- cctv/db.py +1478 -0
- cctv/dispatch.py +270 -0
- cctv/ffprobe.py +246 -0
- cctv/frigate.py +411 -0
- cctv/health.py +413 -0
- cctv/logging.py +157 -0
- cctv/materialise.py +488 -0
- cctv/migrations/0001_init.sql +199 -0
- cctv/migrations/0002_finding_kinds.sql +35 -0
- cctv/mqtt.py +171 -0
- cctv/paths.py +290 -0
- cctv/reviews.py +279 -0
- cctv/storage.py +751 -0
- cctv/sweep.py +254 -0
- cctv/timeutil.py +154 -0
- cctv/uploader.py +1417 -0
- cctv/worker.py +385 -0
- cctv_stack-0.3.0.dist-info/METADATA +410 -0
- cctv_stack-0.3.0.dist-info/RECORD +28 -0
- cctv_stack-0.3.0.dist-info/WHEEL +4 -0
- cctv_stack-0.3.0.dist-info/entry_points.txt +5 -0
- cctv_stack-0.3.0.dist-info/licenses/LICENSE +201 -0
cctv/sweep.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""The reconciliation sweep: find reviews we have not turned into clips, and turn them.
|
|
2
|
+
|
|
3
|
+
MQTT is the fast path and it is lossy — at-most-once from our side, and worse than that:
|
|
4
|
+
Frigate closes a review at start-up **without publishing `end` at all**. Measured, with
|
|
5
|
+
the subscriber on a broker that never restarted. A deployment trusting `end` loses that
|
|
6
|
+
clip silently. So the sweep is not a backstop for a rare failure; it is the mechanism
|
|
7
|
+
that makes materialisation complete, and MQTT is only the latency optimisation.
|
|
8
|
+
|
|
9
|
+
## `after` filters on `end_time`, not `start_time` — and the watermark depends on it
|
|
10
|
+
|
|
11
|
+
Frigate's `/api/review` applies the window as:
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
clauses = [(start_time < before) & ((end_time.is_null()) | (end_time > after))]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
So a review that *started* before the watermark but *ended* after it is still returned.
|
|
18
|
+
That is why the watermark is the highest `end_time` fully processed rather than a
|
|
19
|
+
`start_time`: advancing past a start time would skip any review that was still open when
|
|
20
|
+
we last looked, which is exactly when a restart happens.
|
|
21
|
+
|
|
22
|
+
## Progressing without losing work
|
|
23
|
+
|
|
24
|
+
If a review defers — the flush race, a transient Frigate failure — the watermark must not
|
|
25
|
+
move past it, or the next sweep never sees it again and the clip is lost by *retrying*.
|
|
26
|
+
But it also cannot stay put, or one persistently failing review blocks the sweep forever.
|
|
27
|
+
|
|
28
|
+
So the watermark advances to the highest `end_time` terminally decided, and then retreats
|
|
29
|
+
to just before the earliest *deferred* review. Deferrals are therefore revisited, and
|
|
30
|
+
everything else progresses.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import sqlite3
|
|
36
|
+
from collections.abc import Callable, Mapping
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from typing import Protocol
|
|
39
|
+
|
|
40
|
+
from . import db
|
|
41
|
+
from .config import CameraConfig, CamerasConfig
|
|
42
|
+
from .frigate import FrigateError
|
|
43
|
+
from .materialise import CREATED, DEFERRED, FAILED, SKIPPED, MaterialiseOutcome
|
|
44
|
+
from .reviews import ReviewCandidate, candidates_from_reviews, decide
|
|
45
|
+
from .timeutil import Instant
|
|
46
|
+
from .timeutil import now as _now
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"DEFAULT_LOOKBACK_S",
|
|
50
|
+
"WATERMARK_EPSILON",
|
|
51
|
+
"WATERMARK_NAME",
|
|
52
|
+
"ReviewLister",
|
|
53
|
+
"ReviewSweep",
|
|
54
|
+
"SweepResult",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
#: Where the watermark lives in `sweep_state`. Named for reviews rather than events,
|
|
58
|
+
#: since ADR-0016.
|
|
59
|
+
WATERMARK_NAME = "reviews_sweep"
|
|
60
|
+
|
|
61
|
+
#: On a first run there is no watermark, and Frigate may hold months of history whose
|
|
62
|
+
#: footage has long expired. Sweeping all of it would produce a burst of doomed fetches, so
|
|
63
|
+
#: a fresh install starts from a bounded lookback instead.
|
|
64
|
+
DEFAULT_LOOKBACK_S = 3600.0
|
|
65
|
+
|
|
66
|
+
#: The `after` filter is strictly `end_time > after`, so a deferred review is only
|
|
67
|
+
#: re-fetched if the watermark sits *just* before it.
|
|
68
|
+
WATERMARK_EPSILON = 0.001
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ReviewLister(Protocol):
|
|
72
|
+
def list_reviews(
|
|
73
|
+
self, *, after: Instant | None = None, before: Instant | None = None, limit: int = ...
|
|
74
|
+
) -> list[dict]: ...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class SweepResult:
|
|
79
|
+
"""What one sweep did. Counts rather than a boolean, because "how far behind am I" is
|
|
80
|
+
the question the health check and the dashboard both need answered.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
considered: int = 0
|
|
84
|
+
materialised: int = 0
|
|
85
|
+
skipped: int = 0
|
|
86
|
+
deferred: int = 0
|
|
87
|
+
failed: int = 0
|
|
88
|
+
unknown_camera: int = 0
|
|
89
|
+
watermark: float | None = None
|
|
90
|
+
#: Set only when the sweep itself could not run — Frigate unreachable, for instance.
|
|
91
|
+
#: Not for per-review failures, which are counted instead.
|
|
92
|
+
failure: str | None = None
|
|
93
|
+
outcomes: tuple[MaterialiseOutcome, ...] = field(default=(), repr=False)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def ok(self) -> bool:
|
|
97
|
+
return self.failure is None
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def needs_another_run(self) -> bool:
|
|
101
|
+
"""True when something is waiting to be retried."""
|
|
102
|
+
return self.deferred > 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True, slots=True)
|
|
106
|
+
class _Decision:
|
|
107
|
+
candidate: ReviewCandidate
|
|
108
|
+
camera: CameraConfig | None
|
|
109
|
+
outcome: MaterialiseOutcome | None = None
|
|
110
|
+
skip_reason: str | None = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ReviewSweep:
|
|
114
|
+
"""Walks the reviews Frigate holds and materialises the ones we are missing."""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
*,
|
|
119
|
+
conn: sqlite3.Connection,
|
|
120
|
+
client: ReviewLister,
|
|
121
|
+
materialiser,
|
|
122
|
+
clock: Callable[[], Instant] = _now,
|
|
123
|
+
lookback_s: float = DEFAULT_LOOKBACK_S,
|
|
124
|
+
limit: int = 200,
|
|
125
|
+
watermark_name: str = WATERMARK_NAME,
|
|
126
|
+
) -> None:
|
|
127
|
+
self.conn = conn
|
|
128
|
+
self.client = client
|
|
129
|
+
self.materialiser = materialiser
|
|
130
|
+
self._clock = clock
|
|
131
|
+
self.lookback_s = lookback_s
|
|
132
|
+
self.limit = limit
|
|
133
|
+
self.watermark_name = watermark_name
|
|
134
|
+
|
|
135
|
+
# -----------------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def run(self, *, config: CamerasConfig) -> SweepResult:
|
|
138
|
+
"""One pass. Intended to be called on a timer, not in a tight loop."""
|
|
139
|
+
now = self._clock()
|
|
140
|
+
after = self._resume_point(now)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
raw = self.client.list_reviews(after=after, before=now + 1, limit=self.limit)
|
|
144
|
+
except FrigateError as exc:
|
|
145
|
+
# Frigate unreachable. The watermark does not move and the failure count rises,
|
|
146
|
+
# so health can tell "idle" apart from "failing for an hour".
|
|
147
|
+
db.set_watermark(
|
|
148
|
+
self.conn,
|
|
149
|
+
name=self.watermark_name,
|
|
150
|
+
watermark=after,
|
|
151
|
+
succeeded=False,
|
|
152
|
+
note=str(exc)[:200],
|
|
153
|
+
now=now,
|
|
154
|
+
)
|
|
155
|
+
return SweepResult(watermark=after, failure=f"could not list reviews: {exc}")
|
|
156
|
+
|
|
157
|
+
candidates = candidates_from_reviews(raw)
|
|
158
|
+
if not candidates:
|
|
159
|
+
db.set_watermark(
|
|
160
|
+
self.conn, name=self.watermark_name, watermark=after, succeeded=True, now=now
|
|
161
|
+
)
|
|
162
|
+
return SweepResult(watermark=after)
|
|
163
|
+
|
|
164
|
+
known = db.known_review_ids(self.conn, [c.review_id for c in candidates])
|
|
165
|
+
decisions = [
|
|
166
|
+
self._handle(candidate, config=config, known=known) for candidate in candidates
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
watermark = self._advance(decisions, resume_point=after)
|
|
170
|
+
db.set_watermark(
|
|
171
|
+
self.conn, name=self.watermark_name, watermark=watermark, succeeded=True, now=now
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
outcomes = tuple(d.outcome for d in decisions if d.outcome is not None)
|
|
175
|
+
return SweepResult(
|
|
176
|
+
considered=len(decisions),
|
|
177
|
+
materialised=sum(1 for o in outcomes if o.status == CREATED),
|
|
178
|
+
skipped=sum(1 for d in decisions if d.skip_reason is not None)
|
|
179
|
+
+ sum(1 for o in outcomes if o.status == SKIPPED),
|
|
180
|
+
deferred=sum(1 for o in outcomes if o.status == DEFERRED),
|
|
181
|
+
failed=sum(1 for o in outcomes if o.status == FAILED),
|
|
182
|
+
unknown_camera=sum(1 for d in decisions if d.skip_reason == "unknown camera"),
|
|
183
|
+
watermark=watermark,
|
|
184
|
+
outcomes=outcomes,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# -----------------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def _handle(
|
|
190
|
+
self,
|
|
191
|
+
candidate: ReviewCandidate,
|
|
192
|
+
*,
|
|
193
|
+
config: CamerasConfig,
|
|
194
|
+
known: set[str],
|
|
195
|
+
) -> _Decision:
|
|
196
|
+
camera = config.cameras.get(candidate.camera)
|
|
197
|
+
if camera is None or not camera.enabled:
|
|
198
|
+
# A review for a camera that is not in `cameras.yml`, or was retired. Not an
|
|
199
|
+
# error: Frigate may still hold history for a camera we no longer manage.
|
|
200
|
+
return _Decision(candidate, None, skip_reason="unknown camera")
|
|
201
|
+
|
|
202
|
+
verdict = decide(
|
|
203
|
+
candidate,
|
|
204
|
+
severities=camera.clip.severities,
|
|
205
|
+
objects=camera.clip.objects,
|
|
206
|
+
known_review_ids=known,
|
|
207
|
+
)
|
|
208
|
+
if not verdict.materialise:
|
|
209
|
+
return _Decision(candidate, camera, skip_reason=verdict.reason)
|
|
210
|
+
|
|
211
|
+
outcome = self.materialiser.materialise(
|
|
212
|
+
candidate,
|
|
213
|
+
pre_roll_s=camera.clip.pre_roll_s,
|
|
214
|
+
post_roll_s=camera.clip.post_roll_s,
|
|
215
|
+
)
|
|
216
|
+
return _Decision(candidate, camera, outcome=outcome)
|
|
217
|
+
|
|
218
|
+
def _resume_point(self, now: Instant) -> Instant:
|
|
219
|
+
state = db.get_watermark(self.conn, self.watermark_name)
|
|
220
|
+
if state is None or state["watermark"] is None:
|
|
221
|
+
return now - self.lookback_s
|
|
222
|
+
return float(state["watermark"])
|
|
223
|
+
|
|
224
|
+
def _advance(self, decisions: list[_Decision], *, resume_point: Instant) -> Instant:
|
|
225
|
+
"""The new watermark: past everything settled, but not past anything deferred."""
|
|
226
|
+
settled: list[float] = []
|
|
227
|
+
deferred: list[float] = []
|
|
228
|
+
|
|
229
|
+
for decision in decisions:
|
|
230
|
+
end = decision.candidate.end_time
|
|
231
|
+
if end is None:
|
|
232
|
+
# An open review has no end to advance to, and it will come back anyway:
|
|
233
|
+
# the listing treats a null `end_time` as "inside the window" always.
|
|
234
|
+
continue
|
|
235
|
+
if decision.outcome is not None and decision.outcome.status == DEFERRED:
|
|
236
|
+
deferred.append(end)
|
|
237
|
+
else:
|
|
238
|
+
# Created, skipped or permanently failed — all terminal. A failure does not
|
|
239
|
+
# hold the watermark back, or one un-fetchable review would freeze the
|
|
240
|
+
# sweep forever; it is alerted on instead.
|
|
241
|
+
settled.append(end)
|
|
242
|
+
|
|
243
|
+
watermark = max(settled) if settled else resume_point
|
|
244
|
+
if deferred:
|
|
245
|
+
# Retreat to just before the earliest deferral, because the listing's filter is
|
|
246
|
+
# strictly `end_time > after` — sitting exactly on it would hide it.
|
|
247
|
+
watermark = min(watermark, min(deferred) - WATERMARK_EPSILON)
|
|
248
|
+
# Never backwards: redoing work is safe, skipping it is not.
|
|
249
|
+
return max(watermark, resume_point)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def cameras_for(config: CamerasConfig) -> Mapping[str, CameraConfig]:
|
|
253
|
+
"""Convenience for callers that want the enabled cameras only."""
|
|
254
|
+
return config.enabled_cameras()
|
cctv/timeutil.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Instants and their rendering.
|
|
2
|
+
|
|
3
|
+
Two kinds of time value exist in this system and confusing them is the single most
|
|
4
|
+
common bug of this kind. See [ADR-0017](../../../docs/adr/0017-time-handling.md) and
|
|
5
|
+
[INV-12](../../../docs/architecture/invariants.md).
|
|
6
|
+
|
|
7
|
+
* An **instant** is epoch seconds — a bare `float`. It is the same instant everywhere
|
|
8
|
+
on earth, so it carries no timezone and cannot be "in UTC" or "in IST". This is what
|
|
9
|
+
Frigate sends, what the database stores, and what every comparison uses.
|
|
10
|
+
* A **rendered** time is a wall-clock value, produced from an instant plus a timezone.
|
|
11
|
+
This is what a human reads: path components, log lines, the status page.
|
|
12
|
+
|
|
13
|
+
**Storage is never rendered.** Rendering happens only at the edges, only through this
|
|
14
|
+
module and [`cctv.paths`][cctv.paths], and always with an explicitly supplied timezone.
|
|
15
|
+
|
|
16
|
+
Why not just store local-time strings: an instant cannot be wrong and cannot lose
|
|
17
|
+
information, so any timezone stays derivable from it. A rendered string *can* be wrong,
|
|
18
|
+
and during a DST fallback two distinct instants render to the same string — which would
|
|
19
|
+
mean a silent overwrite.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import datetime as _dt
|
|
25
|
+
import re
|
|
26
|
+
import time
|
|
27
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Instant",
|
|
31
|
+
"TimeConfigError",
|
|
32
|
+
"format_rfc3339",
|
|
33
|
+
"has_dst",
|
|
34
|
+
"now",
|
|
35
|
+
"render",
|
|
36
|
+
"resolve_zone",
|
|
37
|
+
"to_instant",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
#: Epoch seconds. An alias, not a class — the point is that a bare `float` is the
|
|
41
|
+
#: correct representation, and wrapping it would invite arithmetic on the wrapper.
|
|
42
|
+
type Instant = float
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TimeConfigError(ValueError):
|
|
46
|
+
"""A timezone name that cannot be used. Raised at config load, never at request time."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# `+05:30`, `-0800`, `+05` — a fixed offset. These parse as "valid" but carry no DST
|
|
50
|
+
# history, so a site that observes DST would render wrongly for part of the year.
|
|
51
|
+
_OFFSET_LIKE = re.compile(r"^[+-]\d{2}:?\d{2}$|^[+-]\d{2}$")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_zone(name: str) -> ZoneInfo:
|
|
55
|
+
"""Resolve an IANA timezone name, once, at config load.
|
|
56
|
+
|
|
57
|
+
Resolving here and passing `ZoneInfo` around — rather than passing a string and
|
|
58
|
+
resolving per call — is what makes "resolved once at config load" enforceable
|
|
59
|
+
([INV-12](../../../docs/architecture/invariants.md)) instead of a convention.
|
|
60
|
+
|
|
61
|
+
Rejects bare offsets, because they cannot express DST history. Rejects names
|
|
62
|
+
absent from tzdata, because `ZoneInfo` would otherwise fail deep inside a worker
|
|
63
|
+
on the first clip of the day rather than at start-up.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
TimeConfigError: the name is missing, is an offset, or is unknown.
|
|
67
|
+
"""
|
|
68
|
+
if not isinstance(name, str) or not name.strip():
|
|
69
|
+
raise TimeConfigError(
|
|
70
|
+
"site.timezone is required and must be a non-empty IANA name. "
|
|
71
|
+
"It is never guessed from the host: a container's TZ is easy to get "
|
|
72
|
+
"wrong across six services, and wrong here means silently misplaced clips."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
name = name.strip()
|
|
76
|
+
|
|
77
|
+
if _OFFSET_LIKE.match(name):
|
|
78
|
+
raise TimeConfigError(
|
|
79
|
+
f"{name!r} is a UTC offset, not a timezone. Offsets carry no DST history, "
|
|
80
|
+
f"so a site that observes DST would render wrongly for part of the year. "
|
|
81
|
+
f"Use an IANA name such as 'Asia/Kolkata'."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
return ZoneInfo(name)
|
|
86
|
+
except (ZoneInfoNotFoundError, ValueError) as exc:
|
|
87
|
+
raise TimeConfigError(
|
|
88
|
+
f"Unknown timezone {name!r}. It must be an IANA name present in tzdata — "
|
|
89
|
+
f"either the system zoneinfo database or the 'tzdata' package."
|
|
90
|
+
) from exc
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def now() -> Instant:
|
|
94
|
+
"""The current instant.
|
|
95
|
+
|
|
96
|
+
Use this instead of `datetime.now()`. `datetime.now()` without a timezone returns a
|
|
97
|
+
naive local value, which is neither an instant nor a rendering — it is a wart, and
|
|
98
|
+
a lint rule forbids it in service code.
|
|
99
|
+
"""
|
|
100
|
+
return time.time()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_aware(value: _dt.datetime) -> bool:
|
|
104
|
+
"""Whether a datetime carries timezone information."""
|
|
105
|
+
return value.tzinfo is not None and value.tzinfo.utcoffset(value) is not None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def to_instant(value: _dt.datetime) -> Instant:
|
|
109
|
+
"""Convert an **aware** datetime to an instant.
|
|
110
|
+
|
|
111
|
+
A naive datetime is rejected rather than assumed to be UTC or local. Both
|
|
112
|
+
assumptions have shipped as production bugs; refusing to guess is cheaper.
|
|
113
|
+
"""
|
|
114
|
+
if not is_aware(value):
|
|
115
|
+
raise ValueError(
|
|
116
|
+
"A naive datetime has no defined instant. Attach a timezone (usually "
|
|
117
|
+
"`cctv.timeutil.UTC`) before converting. Guessing between UTC and local "
|
|
118
|
+
"time is the bug this check exists to prevent."
|
|
119
|
+
)
|
|
120
|
+
return value.timestamp()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def render(instant: Instant, zone: ZoneInfo) -> _dt.datetime:
|
|
124
|
+
"""Render an instant as an aware wall-clock value in `zone`.
|
|
125
|
+
|
|
126
|
+
Total: every instant renders. That is the direction that matters, because
|
|
127
|
+
instants are what we start from. Going the other way — a wall-clock value to an
|
|
128
|
+
instant — is *partial*, since a DST spring-forward skips an hour entirely and a
|
|
129
|
+
fallback repeats one. Nothing in this system does it.
|
|
130
|
+
"""
|
|
131
|
+
return _dt.datetime.fromtimestamp(instant, tz=zone)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def format_rfc3339(instant: Instant, zone: ZoneInfo) -> str:
|
|
135
|
+
"""Render an instant for a human: logs, the status page, notifications."""
|
|
136
|
+
return render(instant, zone).isoformat(timespec="milliseconds")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def has_dst(zone: ZoneInfo, year: int = 2026) -> bool:
|
|
140
|
+
"""Whether `zone` observes DST in `year`.
|
|
141
|
+
|
|
142
|
+
Used by tests and by an installer warning. A DST-observing site cannot be treated
|
|
143
|
+
as though it behaves like IST: Indian Standard Time is UTC+05:30 with no DST, so
|
|
144
|
+
the fallback-collision case in
|
|
145
|
+
[ADR-0006](../../../docs/adr/0006-canonical-clip-layout.md) cannot arise there —
|
|
146
|
+
but it certainly can in Sydney, which is the value in the example config.
|
|
147
|
+
"""
|
|
148
|
+
early = render(_dt.datetime(year, 1, 15, 12, tzinfo=_dt.UTC).timestamp(), zone)
|
|
149
|
+
mid = render(_dt.datetime(year, 7, 15, 12, tzinfo=_dt.UTC).timestamp(), zone)
|
|
150
|
+
return early.utcoffset() != mid.utcoffset()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
#: Convenience alias, since a great deal of test and generator code needs it.
|
|
154
|
+
UTC = _dt.UTC
|