fleetpull 0.1.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.
- fleetpull/__init__.py +36 -0
- fleetpull/api/__init__.py +32 -0
- fleetpull/api/auth_ingress.py +216 -0
- fleetpull/api/catalog.py +139 -0
- fleetpull/api/fetch.py +229 -0
- fleetpull/api/identity.py +90 -0
- fleetpull/api/sync.py +735 -0
- fleetpull/cli.py +146 -0
- fleetpull/config/__init__.py +47 -0
- fleetpull/config/base.py +18 -0
- fleetpull/config/example.py +90 -0
- fleetpull/config/geotab.py +78 -0
- fleetpull/config/http.py +32 -0
- fleetpull/config/loading.py +195 -0
- fleetpull/config/logger.py +139 -0
- fleetpull/config/providers.py +520 -0
- fleetpull/config/rate_limit.py +50 -0
- fleetpull/config/resolution.py +156 -0
- fleetpull/config/retry.py +69 -0
- fleetpull/config/root.py +157 -0
- fleetpull/config/sections.py +146 -0
- fleetpull/endpoints/__init__.py +24 -0
- fleetpull/endpoints/geotab/__init__.py +12 -0
- fleetpull/endpoints/geotab/_requests.py +427 -0
- fleetpull/endpoints/geotab/annotation_logs.py +73 -0
- fleetpull/endpoints/geotab/audits.py +70 -0
- fleetpull/endpoints/geotab/devices.py +79 -0
- fleetpull/endpoints/geotab/driver_changes.py +73 -0
- fleetpull/endpoints/geotab/duty_status_logs.py +75 -0
- fleetpull/endpoints/geotab/dvir_logs.py +76 -0
- fleetpull/endpoints/geotab/exception_events.py +119 -0
- fleetpull/endpoints/geotab/fault_data.py +72 -0
- fleetpull/endpoints/geotab/fill_ups.py +79 -0
- fleetpull/endpoints/geotab/fuel_and_energy_used.py +74 -0
- fleetpull/endpoints/geotab/fuel_tax_details.py +75 -0
- fleetpull/endpoints/geotab/log_records.py +74 -0
- fleetpull/endpoints/geotab/media_files.py +74 -0
- fleetpull/endpoints/geotab/shipment_logs.py +71 -0
- fleetpull/endpoints/geotab/status_data.py +71 -0
- fleetpull/endpoints/geotab/text_messages.py +77 -0
- fleetpull/endpoints/geotab/trips.py +102 -0
- fleetpull/endpoints/geotab/users.py +81 -0
- fleetpull/endpoints/motive/__init__.py +12 -0
- fleetpull/endpoints/motive/_spec_builders.py +94 -0
- fleetpull/endpoints/motive/driver_idle_rollups.py +113 -0
- fleetpull/endpoints/motive/driving_periods.py +85 -0
- fleetpull/endpoints/motive/groups.py +61 -0
- fleetpull/endpoints/motive/idle_events.py +94 -0
- fleetpull/endpoints/motive/users.py +64 -0
- fleetpull/endpoints/motive/vehicle_locations.py +170 -0
- fleetpull/endpoints/motive/vehicle_utilizations.py +122 -0
- fleetpull/endpoints/motive/vehicles.py +106 -0
- fleetpull/endpoints/registry.py +280 -0
- fleetpull/endpoints/samsara/__init__.py +12 -0
- fleetpull/endpoints/samsara/_spec_builders.py +202 -0
- fleetpull/endpoints/samsara/addresses.py +77 -0
- fleetpull/endpoints/samsara/asset_locations.py +217 -0
- fleetpull/endpoints/samsara/driver_fuel_energy_reports.py +124 -0
- fleetpull/endpoints/samsara/driver_vehicle_assignments.py +211 -0
- fleetpull/endpoints/samsara/drivers.py +169 -0
- fleetpull/endpoints/samsara/engine_states.py +114 -0
- fleetpull/endpoints/samsara/gps_readings.py +113 -0
- fleetpull/endpoints/samsara/idling_events.py +195 -0
- fleetpull/endpoints/samsara/odometer_readings.py +116 -0
- fleetpull/endpoints/samsara/trips.py +217 -0
- fleetpull/endpoints/samsara/vehicle_fuel_energy_reports.py +139 -0
- fleetpull/endpoints/samsara/vehicles.py +121 -0
- fleetpull/endpoints/shared/__init__.py +57 -0
- fleetpull/endpoints/shared/base.py +529 -0
- fleetpull/endpoints/shared/request_shape.py +227 -0
- fleetpull/endpoints/shared/resume.py +74 -0
- fleetpull/endpoints/shared/spec_builders.py +59 -0
- fleetpull/endpoints/shared/sync_mode.py +159 -0
- fleetpull/endpoints/shared/url_paths.py +149 -0
- fleetpull/exceptions.py +320 -0
- fleetpull/incremental/__init__.py +27 -0
- fleetpull/incremental/cursor.py +66 -0
- fleetpull/incremental/resolution.py +152 -0
- fleetpull/incremental/seed.py +53 -0
- fleetpull/incremental/window.py +94 -0
- fleetpull/logger/__init__.py +5 -0
- fleetpull/logger/setup.py +145 -0
- fleetpull/model_contract/__init__.py +6 -0
- fleetpull/model_contract/coercions.py +33 -0
- fleetpull/model_contract/response.py +45 -0
- fleetpull/models/__init__.py +7 -0
- fleetpull/models/geotab/__init__.py +166 -0
- fleetpull/models/geotab/annotation_log.py +109 -0
- fleetpull/models/geotab/audit.py +56 -0
- fleetpull/models/geotab/device.py +217 -0
- fleetpull/models/geotab/driver_change.py +102 -0
- fleetpull/models/geotab/duty_status_log.py +200 -0
- fleetpull/models/geotab/dvir_log.py +184 -0
- fleetpull/models/geotab/exception_event.py +135 -0
- fleetpull/models/geotab/fault_data.py +168 -0
- fleetpull/models/geotab/fill_up.py +189 -0
- fleetpull/models/geotab/fuel_and_energy_used.py +81 -0
- fleetpull/models/geotab/fuel_tax_detail.py +150 -0
- fleetpull/models/geotab/log_record.py +68 -0
- fleetpull/models/geotab/media_file.py +133 -0
- fleetpull/models/geotab/shared.py +221 -0
- fleetpull/models/geotab/shipment_log.py +105 -0
- fleetpull/models/geotab/status_data.py +108 -0
- fleetpull/models/geotab/text_message.py +125 -0
- fleetpull/models/geotab/trip.py +162 -0
- fleetpull/models/geotab/user.py +187 -0
- fleetpull/models/motive/__init__.py +43 -0
- fleetpull/models/motive/driver_idle_rollup.py +109 -0
- fleetpull/models/motive/driving_period.py +82 -0
- fleetpull/models/motive/group.py +49 -0
- fleetpull/models/motive/idle_event.py +77 -0
- fleetpull/models/motive/shared.py +192 -0
- fleetpull/models/motive/user.py +217 -0
- fleetpull/models/motive/vehicle.py +162 -0
- fleetpull/models/motive/vehicle_location.py +127 -0
- fleetpull/models/motive/vehicle_utilization.py +128 -0
- fleetpull/models/samsara/__init__.py +108 -0
- fleetpull/models/samsara/address.py +149 -0
- fleetpull/models/samsara/asset_location.py +131 -0
- fleetpull/models/samsara/driver.py +232 -0
- fleetpull/models/samsara/driver_fuel_energy_report.py +153 -0
- fleetpull/models/samsara/driver_vehicle_assignment.py +168 -0
- fleetpull/models/samsara/engine_state.py +92 -0
- fleetpull/models/samsara/gps_reading.py +146 -0
- fleetpull/models/samsara/idling_event.py +174 -0
- fleetpull/models/samsara/odometer_reading.py +90 -0
- fleetpull/models/samsara/trip.py +199 -0
- fleetpull/models/samsara/vehicle.py +167 -0
- fleetpull/models/samsara/vehicle_fuel_energy_report.py +191 -0
- fleetpull/network/__init__.py +9 -0
- fleetpull/network/auth/__init__.py +15 -0
- fleetpull/network/auth/authenticate.py +271 -0
- fleetpull/network/auth/manager.py +223 -0
- fleetpull/network/auth/models.py +57 -0
- fleetpull/network/auth/strategies.py +169 -0
- fleetpull/network/classifiers/__init__.py +11 -0
- fleetpull/network/classifiers/geotab.py +145 -0
- fleetpull/network/classifiers/motive.py +79 -0
- fleetpull/network/classifiers/samsara.py +80 -0
- fleetpull/network/client/__init__.py +25 -0
- fleetpull/network/client/page.py +32 -0
- fleetpull/network/client/profile.py +27 -0
- fleetpull/network/client/registry.py +103 -0
- fleetpull/network/client/registry_base.py +150 -0
- fleetpull/network/client/runtime.py +44 -0
- fleetpull/network/client/transport.py +381 -0
- fleetpull/network/contract/__init__.py +50 -0
- fleetpull/network/contract/auth.py +48 -0
- fleetpull/network/contract/classifier.py +189 -0
- fleetpull/network/contract/envelope_fetcher.py +33 -0
- fleetpull/network/contract/envelopes.py +189 -0
- fleetpull/network/contract/outcome.py +43 -0
- fleetpull/network/contract/page_decoder.py +101 -0
- fleetpull/network/contract/request.py +97 -0
- fleetpull/network/decoders/__init__.py +40 -0
- fleetpull/network/decoders/_window_stamp.py +78 -0
- fleetpull/network/decoders/geotab.py +309 -0
- fleetpull/network/decoders/motive.py +204 -0
- fleetpull/network/decoders/motive_reports.py +114 -0
- fleetpull/network/decoders/samsara.py +343 -0
- fleetpull/network/decoders/samsara_reports.py +119 -0
- fleetpull/network/decoders/single_page.py +55 -0
- fleetpull/network/limits/__init__.py +13 -0
- fleetpull/network/limits/bucket_math.py +58 -0
- fleetpull/network/limits/limiter.py +153 -0
- fleetpull/network/limits/registry.py +96 -0
- fleetpull/network/posture/__init__.py +6 -0
- fleetpull/network/posture/client_options.py +96 -0
- fleetpull/network/retry/__init__.py +13 -0
- fleetpull/network/retry/decision.py +155 -0
- fleetpull/network/tls/__init__.py +5 -0
- fleetpull/network/tls/truststore_context.py +35 -0
- fleetpull/orchestrator/__init__.py +30 -0
- fleetpull/orchestrator/backfill.py +127 -0
- fleetpull/orchestrator/batch.py +155 -0
- fleetpull/orchestrator/bisection.py +271 -0
- fleetpull/orchestrator/drivers.py +350 -0
- fleetpull/orchestrator/entry.py +347 -0
- fleetpull/orchestrator/executors.py +128 -0
- fleetpull/orchestrator/fanout.py +163 -0
- fleetpull/orchestrator/feed_drive.py +266 -0
- fleetpull/orchestrator/metadata_projection.py +195 -0
- fleetpull/orchestrator/outcome.py +53 -0
- fleetpull/orchestrator/recording.py +85 -0
- fleetpull/orchestrator/resume.py +143 -0
- fleetpull/orchestrator/roster_harvest.py +108 -0
- fleetpull/orchestrator/roster_refresh.py +363 -0
- fleetpull/orchestrator/runner.py +262 -0
- fleetpull/orchestrator/shape_resolution.py +221 -0
- fleetpull/orchestrator/spine.py +166 -0
- fleetpull/orchestrator/streaming.py +153 -0
- fleetpull/orchestrator/unit_loop.py +273 -0
- fleetpull/orchestrator/watermark_drive.py +455 -0
- fleetpull/paths/__init__.py +13 -0
- fleetpull/paths/datasets.py +38 -0
- fleetpull/paths/partitions.py +41 -0
- fleetpull/paths/resolution.py +98 -0
- fleetpull/polars_typing/__init__.py +20 -0
- fleetpull/py.typed +0 -0
- fleetpull/records/__init__.py +19 -0
- fleetpull/records/convert.py +53 -0
- fleetpull/records/dataframe.py +63 -0
- fleetpull/records/event_time.py +70 -0
- fleetpull/records/fields.py +190 -0
- fleetpull/records/flatten.py +54 -0
- fleetpull/records/roster_members.py +74 -0
- fleetpull/records/schema.py +73 -0
- fleetpull/records/validation.py +62 -0
- fleetpull/resources/__init__.py +10 -0
- fleetpull/resources/config.example.yaml +222 -0
- fleetpull/roster/__init__.py +16 -0
- fleetpull/roster/definition.py +46 -0
- fleetpull/roster/key.py +40 -0
- fleetpull/roster/registry.py +93 -0
- fleetpull/state/__init__.py +36 -0
- fleetpull/state/cursors.py +417 -0
- fleetpull/state/database.py +463 -0
- fleetpull/state/migrations.py +430 -0
- fleetpull/state/reconcile.py +127 -0
- fleetpull/state/rosters.py +159 -0
- fleetpull/state/run_ledger.py +572 -0
- fleetpull/state/work_units.py +648 -0
- fleetpull/storage/__init__.py +30 -0
- fleetpull/storage/append.py +187 -0
- fleetpull/storage/atomic.py +161 -0
- fleetpull/storage/files.py +176 -0
- fleetpull/storage/frames.py +98 -0
- fleetpull/storage/metadata.py +161 -0
- fleetpull/storage/partitioned.py +205 -0
- fleetpull/storage/pruning.py +169 -0
- fleetpull/storage/read.py +35 -0
- fleetpull/storage/result.py +34 -0
- fleetpull/storage/single_file.py +130 -0
- fleetpull/storage/splitting.py +88 -0
- fleetpull/storage/staging.py +178 -0
- fleetpull/storage/writers.py +145 -0
- fleetpull/timing/__init__.py +21 -0
- fleetpull/timing/canon.py +92 -0
- fleetpull/timing/clock.py +192 -0
- fleetpull/timing/codec.py +122 -0
- fleetpull/timing/sleeper.py +65 -0
- fleetpull/vocabulary/__init__.py +16 -0
- fleetpull/vocabulary/json_types.py +20 -0
- fleetpull/vocabulary/provider.py +29 -0
- fleetpull/vocabulary/quota_scope.py +47 -0
- fleetpull/vocabulary/response_category.py +32 -0
- fleetpull-0.1.0.dist-info/METADATA +248 -0
- fleetpull-0.1.0.dist-info/RECORD +252 -0
- fleetpull-0.1.0.dist-info/WHEEL +5 -0
- fleetpull-0.1.0.dist-info/entry_points.txt +2 -0
- fleetpull-0.1.0.dist-info/licenses/LICENSE +202 -0
- fleetpull-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# src/fleetpull/timing/clock.py
|
|
2
|
+
"""Time abstraction for fleetpull.
|
|
3
|
+
|
|
4
|
+
Provides an injectable clock interface to avoid scattered
|
|
5
|
+
``datetime.now()`` calls and enable deterministic time in tests.
|
|
6
|
+
Anything in fleetpull that needs the current time — rate-limiter token
|
|
7
|
+
refill, watermark computation, fetch-window resolution, run-ledger
|
|
8
|
+
timestamps — takes a clock rather than calling the standard library
|
|
9
|
+
directly. ``Clock`` is the Protocol defining the time-provider
|
|
10
|
+
interface; ``SystemClock`` is the production implementation backed by
|
|
11
|
+
system time; ``FrozenClock`` is the test implementation fixed at a
|
|
12
|
+
specific moment.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from datetime import UTC, date, datetime, timedelta
|
|
18
|
+
from typing import Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
from fleetpull.timing.canon import require_utc
|
|
21
|
+
|
|
22
|
+
__all__: list[str] = [
|
|
23
|
+
'Clock',
|
|
24
|
+
'FrozenClock',
|
|
25
|
+
'SystemClock',
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class Clock(Protocol):
|
|
31
|
+
"""
|
|
32
|
+
Interface for time providers in fleetpull.
|
|
33
|
+
|
|
34
|
+
Centralizes time access (no scattered ``datetime.now()`` calls),
|
|
35
|
+
enables deterministic time in tests, and enforces timezone-aware
|
|
36
|
+
UTC timestamps internally. All implementations must return
|
|
37
|
+
timezone-aware UTC datetimes.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def now_utc(self) -> datetime:
|
|
41
|
+
"""
|
|
42
|
+
Return the current time as a timezone-aware UTC datetime.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Timezone-aware datetime in UTC.
|
|
46
|
+
"""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
def today_utc(self) -> date:
|
|
50
|
+
"""
|
|
51
|
+
Return today's date in UTC.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Current UTC date.
|
|
55
|
+
"""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
def monotonic_seconds(self) -> float:
|
|
59
|
+
"""
|
|
60
|
+
Return a monotonic timestamp for duration measurement.
|
|
61
|
+
|
|
62
|
+
Monotonic clocks are unaffected by NTP adjustments or DST changes,
|
|
63
|
+
making them suitable for measuring elapsed time.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Monotonic time in seconds.
|
|
67
|
+
"""
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class SystemClock:
|
|
73
|
+
"""
|
|
74
|
+
Production clock backed by system wall-clock and monotonic timer.
|
|
75
|
+
|
|
76
|
+
Returns timezone-aware UTC datetimes. This is the default clock
|
|
77
|
+
for production use.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def now_utc(self) -> datetime:
|
|
81
|
+
"""Return current UTC time."""
|
|
82
|
+
return datetime.now(tz=UTC)
|
|
83
|
+
|
|
84
|
+
def today_utc(self) -> date:
|
|
85
|
+
"""Return current UTC date."""
|
|
86
|
+
return self.now_utc().date()
|
|
87
|
+
|
|
88
|
+
def monotonic_seconds(self) -> float:
|
|
89
|
+
"""
|
|
90
|
+
Return monotonic time from ``time.perf_counter()``.
|
|
91
|
+
|
|
92
|
+
The invariant is monotonic, never wall clock.
|
|
93
|
+
``time.perf_counter()`` is the deliberate choice over
|
|
94
|
+
``time.monotonic()``: on Windows under CPython 3.12,
|
|
95
|
+
``monotonic()`` is GetTickCount64 with roughly 15.6 ms
|
|
96
|
+
resolution, while ``perf_counter()`` is
|
|
97
|
+
QueryPerformanceCounter — and the test convention of real
|
|
98
|
+
threads with tiny waits needs the finer clock on exactly that
|
|
99
|
+
platform.
|
|
100
|
+
"""
|
|
101
|
+
return time.perf_counter()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class FrozenClock:
|
|
105
|
+
"""
|
|
106
|
+
Deterministic clock for tests and reproducible runs.
|
|
107
|
+
|
|
108
|
+
Starts at a fixed UTC datetime and only advances when explicitly
|
|
109
|
+
mutated via ``advance()`` or ``set_time()``. Not thread-safe: keep
|
|
110
|
+
usage test-scoped or wrap externally.
|
|
111
|
+
|
|
112
|
+
Example:
|
|
113
|
+
>>> clock = FrozenClock(start_time_utc=datetime(2026, 1, 23, 12, tzinfo=UTC))
|
|
114
|
+
>>> clock.now_utc()
|
|
115
|
+
datetime.datetime(2026, 1, 23, 12, 0, tzinfo=datetime.timezone.utc)
|
|
116
|
+
>>> clock.advance(timedelta(hours=1))
|
|
117
|
+
>>> clock.now_utc().hour
|
|
118
|
+
13
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
__slots__ = ('_current_monotonic_seconds', '_current_time_utc')
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
*,
|
|
126
|
+
start_time_utc: datetime,
|
|
127
|
+
start_monotonic_seconds: float = 0.0,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""
|
|
130
|
+
Initialize a frozen clock.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
start_time_utc: Initial time (must be timezone-aware UTC).
|
|
134
|
+
start_monotonic_seconds: Initial monotonic value (non-negative).
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
ValueError: If start_time_utc is naive or not UTC (the
|
|
138
|
+
``require_utc`` canonical-UTC guard).
|
|
139
|
+
ValueError: If start_monotonic_seconds is negative.
|
|
140
|
+
"""
|
|
141
|
+
if start_monotonic_seconds < 0.0:
|
|
142
|
+
raise ValueError('start_monotonic_seconds must be non-negative')
|
|
143
|
+
|
|
144
|
+
self._current_time_utc: datetime = require_utc(start_time_utc)
|
|
145
|
+
self._current_monotonic_seconds: float = start_monotonic_seconds
|
|
146
|
+
|
|
147
|
+
def now_utc(self) -> datetime:
|
|
148
|
+
"""Return the frozen UTC time."""
|
|
149
|
+
return self._current_time_utc
|
|
150
|
+
|
|
151
|
+
def today_utc(self) -> date:
|
|
152
|
+
"""Return the frozen UTC date."""
|
|
153
|
+
return self._current_time_utc.date()
|
|
154
|
+
|
|
155
|
+
def monotonic_seconds(self) -> float:
|
|
156
|
+
"""Return the frozen monotonic value."""
|
|
157
|
+
return self._current_monotonic_seconds
|
|
158
|
+
|
|
159
|
+
def advance(self, delta: timedelta) -> None:
|
|
160
|
+
"""
|
|
161
|
+
Advance the clock by a duration.
|
|
162
|
+
|
|
163
|
+
Advances wall-clock and monotonic time together, keeping them
|
|
164
|
+
correlated the way real time behaves.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
delta: Time to advance (must be non-negative).
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
ValueError: If delta is negative.
|
|
171
|
+
"""
|
|
172
|
+
if delta.total_seconds() < 0:
|
|
173
|
+
raise ValueError('delta must be non-negative')
|
|
174
|
+
|
|
175
|
+
self._current_time_utc += delta
|
|
176
|
+
self._current_monotonic_seconds += delta.total_seconds()
|
|
177
|
+
|
|
178
|
+
def set_time(self, new_time_utc: datetime) -> None:
|
|
179
|
+
"""
|
|
180
|
+
Set the clock to a specific UTC time.
|
|
181
|
+
|
|
182
|
+
Does not adjust the monotonic counter — use ``advance()`` for
|
|
183
|
+
correlated wall/monotonic changes.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
new_time_utc: New time (must be timezone-aware UTC).
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
ValueError: If new_time_utc is naive or not UTC (the
|
|
190
|
+
``require_utc`` canonical-UTC guard).
|
|
191
|
+
"""
|
|
192
|
+
self._current_time_utc = require_utc(new_time_utc)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# src/fleetpull/timing/codec.py
|
|
2
|
+
"""Pure conversions between UTC datetimes and their wire/storage string forms.
|
|
3
|
+
|
|
4
|
+
A leaf: stdlib plus the sibling ``canon`` module, nothing internal beyond
|
|
5
|
+
this package. Converts our own outbound and persisted values — request
|
|
6
|
+
parameters and DateWatermark state — between ``datetime`` and string. It
|
|
7
|
+
never parses a provider response body; response timestamps are cast
|
|
8
|
+
vectorized in the records layer.
|
|
9
|
+
|
|
10
|
+
UTC discipline, enforced both directions (the canonical-UTC doctrine,
|
|
11
|
+
``canon.py``):
|
|
12
|
+
- Encoders reject a naive or non-UTC datetime via ``require_utc`` — a
|
|
13
|
+
non-UTC value reaching here missed an ingress and is a bug, failed
|
|
14
|
+
loud.
|
|
15
|
+
- ``from_iso8601`` normalizes any offset to UTC and rejects a naive
|
|
16
|
+
result — an ISO string with no offset is ambiguous, never assumed UTC.
|
|
17
|
+
It is the string-ingress twin of ``canon.ensure_utc``.
|
|
18
|
+
|
|
19
|
+
Because every internal timestamp is UTC end to end, DST and ambiguous-local
|
|
20
|
+
bugs are structurally impossible.
|
|
21
|
+
|
|
22
|
+
Bad input raises stdlib ``ValueError`` — a bad value is a caller bug or
|
|
23
|
+
malformed input, never a ``FleetpullError``. The consuming boundary
|
|
24
|
+
translates when it wants a typed failure (the config loader maps a user's
|
|
25
|
+
bad date to ``ConfigurationError``). Keeping the raise stdlib is what lets
|
|
26
|
+
``timing`` import nothing internal.
|
|
27
|
+
|
|
28
|
+
The epoch encoders (``to_unix_seconds`` / ``to_unix_millis``) are
|
|
29
|
+
deliberately absent still: the first epoch-consuming endpoint (Samsara
|
|
30
|
+
trips, epoch milliseconds, captured 2026-07-20) keeps its conversion at
|
|
31
|
+
the leaf as a single-consumer fact (exact integer arithmetic in
|
|
32
|
+
``endpoints/samsara/trips.py``); the codec takes the function when a
|
|
33
|
+
second epoch consumer lands (the rule of two).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from datetime import UTC, datetime
|
|
37
|
+
|
|
38
|
+
from fleetpull.timing.canon import require_utc
|
|
39
|
+
|
|
40
|
+
__all__: list[str] = [
|
|
41
|
+
'from_iso8601',
|
|
42
|
+
'to_iso8601',
|
|
43
|
+
'to_utc_date_string',
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def to_iso8601(moment: datetime) -> str:
|
|
48
|
+
"""
|
|
49
|
+
Render a UTC datetime as a seconds-precision ISO-8601 'Z' string.
|
|
50
|
+
|
|
51
|
+
The form GeoTab's ``fromDate`` and DateWatermark persistence use, e.g.
|
|
52
|
+
``'2026-06-01T00:00:00Z'``. Sub-second precision is dropped: window
|
|
53
|
+
parameters are second-granular and persisted watermarks read cleaner
|
|
54
|
+
without it.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
moment: A timezone-aware UTC datetime.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The ISO-8601 string with a trailing ``Z``.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ValueError: If ``moment`` is naive or not UTC.
|
|
64
|
+
"""
|
|
65
|
+
validated_moment: datetime = require_utc(moment)
|
|
66
|
+
# isoformat emits '+00:00' for a UTC datetime; swap for the 'Z' form
|
|
67
|
+
# providers expect. The offset is the only '+00:00' a validated-UTC ISO
|
|
68
|
+
# string can contain, so the suffix strip is unambiguous.
|
|
69
|
+
iso_with_offset: str = validated_moment.isoformat(timespec='seconds')
|
|
70
|
+
return iso_with_offset.removesuffix('+00:00') + 'Z'
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def to_utc_date_string(moment: datetime) -> str:
|
|
74
|
+
"""
|
|
75
|
+
Render the UTC calendar date of a datetime as ``'YYYY-MM-DD'``.
|
|
76
|
+
|
|
77
|
+
Used for hive partition keys (``date=YYYY-MM-DD``) and date-only request
|
|
78
|
+
parameters. The date is the UTC date, since ``moment`` is UTC.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
moment: A timezone-aware UTC datetime.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
The UTC date in ISO ``'YYYY-MM-DD'`` form.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: If ``moment`` is naive or not UTC.
|
|
88
|
+
"""
|
|
89
|
+
validated_moment: datetime = require_utc(moment)
|
|
90
|
+
return validated_moment.date().isoformat()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def from_iso8601(text: str) -> datetime:
|
|
94
|
+
"""
|
|
95
|
+
Parse an ISO-8601 datetime string into a timezone-aware UTC datetime.
|
|
96
|
+
|
|
97
|
+
Accepts any offset form ``datetime.fromisoformat`` handles on 3.12 (``Z``,
|
|
98
|
+
``+HH:MM``, fractional seconds) and normalizes it to UTC. A string with no
|
|
99
|
+
offset — including a date-only string — is ambiguous and rejected; this
|
|
100
|
+
module never assumes UTC for an unzoned value.
|
|
101
|
+
|
|
102
|
+
Consumers: reading a persisted DateWatermark back, and parsing a
|
|
103
|
+
user-supplied date from YAML config. The config path is untrusted, so a
|
|
104
|
+
malformed value raises here and the config loader translates it.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
text: An ISO-8601 datetime string carrying a UTC offset.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The instant as a timezone-aware UTC datetime (tzinfo is
|
|
111
|
+
``datetime.UTC``).
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
ValueError: If ``text`` is not parseable ISO-8601, or carries no
|
|
115
|
+
offset (naive).
|
|
116
|
+
"""
|
|
117
|
+
parsed_moment: datetime = datetime.fromisoformat(text)
|
|
118
|
+
if parsed_moment.tzinfo is None:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f'ISO-8601 datetime must carry a UTC offset; got naive {text!r}'
|
|
121
|
+
)
|
|
122
|
+
return parsed_moment.astimezone(UTC)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# src/fleetpull/timing/sleeper.py
|
|
2
|
+
"""Injectable sleep seam for backoff waiting.
|
|
3
|
+
|
|
4
|
+
A one-method seam kept separate from ``Clock``: ``Clock`` reads time
|
|
5
|
+
(``now_utc`` / ``today_utc`` / ``monotonic_seconds``) and never consumes it.
|
|
6
|
+
Code that must pause — the transport client's TRANSIENT backoff — takes a
|
|
7
|
+
``Sleeper`` so tests substitute a recording double and never wait real time,
|
|
8
|
+
exactly as ``Clock`` is injected to make time deterministic.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
__all__: list[str] = [
|
|
16
|
+
'Sleeper',
|
|
17
|
+
'SystemSleeper',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class Sleeper(Protocol):
|
|
23
|
+
"""
|
|
24
|
+
Interface for sleep providers.
|
|
25
|
+
|
|
26
|
+
One capability — pause the calling thread for a duration — so the seam is
|
|
27
|
+
a single method (the ``Clock`` / ``RandomFractionGenerator`` precedent).
|
|
28
|
+
Tests inject a recording double; production injects ``SystemSleeper``.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def sleep(self, seconds: float) -> None:
|
|
32
|
+
"""
|
|
33
|
+
Block the calling thread for ``seconds``.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
seconds: Non-negative duration to sleep. Behavior on a negative
|
|
37
|
+
value is the implementation's (``SystemSleeper`` defers to
|
|
38
|
+
``time.sleep``, which raises ``ValueError``).
|
|
39
|
+
"""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class SystemSleeper:
|
|
45
|
+
"""
|
|
46
|
+
Production sleeper backed by ``time.sleep``.
|
|
47
|
+
|
|
48
|
+
Frozen and stateless, so one shared instance is safe to inject anywhere
|
|
49
|
+
(the ``SystemClock`` precedent).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def sleep(self, seconds: float) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Sleep via ``time.sleep``.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
seconds: Non-negative duration to sleep.
|
|
58
|
+
|
|
59
|
+
Side Effects:
|
|
60
|
+
Blocks the calling thread for ``seconds``.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ValueError: If ``seconds`` is negative (raised by ``time.sleep``).
|
|
64
|
+
"""
|
|
65
|
+
time.sleep(seconds)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# src/fleetpull/vocabulary/__init__.py
|
|
2
|
+
"""Shared, dependency-free package vocabulary."""
|
|
3
|
+
|
|
4
|
+
from fleetpull.vocabulary.json_types import JsonObject, JsonScalar, JsonValue
|
|
5
|
+
from fleetpull.vocabulary.provider import Provider
|
|
6
|
+
from fleetpull.vocabulary.quota_scope import QuotaScope
|
|
7
|
+
from fleetpull.vocabulary.response_category import ResponseCategory
|
|
8
|
+
|
|
9
|
+
__all__: list[str] = [
|
|
10
|
+
'JsonObject',
|
|
11
|
+
'JsonScalar',
|
|
12
|
+
'JsonValue',
|
|
13
|
+
'Provider',
|
|
14
|
+
'QuotaScope',
|
|
15
|
+
'ResponseCategory',
|
|
16
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# src/fleetpull/vocabulary/json_types.py
|
|
2
|
+
"""JSON type vocabulary: the recursive shape of a JSON document.
|
|
3
|
+
|
|
4
|
+
The package-wide aliases for JSON data -- spoken by the network contract
|
|
5
|
+
(request bodies, response envelopes, page decoding), the records layer (raw
|
|
6
|
+
record dicts entering validation), and the orchestrator (batch plumbing).
|
|
7
|
+
Homed in ``vocabulary`` because the concept is generic JSON, not a network
|
|
8
|
+
contract: three layers speak it, so it lives in the dependency-free leaf
|
|
9
|
+
they all sit above, not in the transport package one of them happens to be.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__all__: list[str] = ['JsonObject', 'JsonScalar', 'JsonValue']
|
|
13
|
+
|
|
14
|
+
# The actual type of a JSON document, recursively. Used for JSON-RPC
|
|
15
|
+
# bodies and response envelopes; available to every layer.
|
|
16
|
+
type JsonScalar = str | int | float | bool | None
|
|
17
|
+
type JsonValue = JsonScalar | list[JsonValue] | dict[str, JsonValue]
|
|
18
|
+
# A single JSON object -- the shape one extracted record takes; the endpoints
|
|
19
|
+
# layer types the record extractor's output as ``list[JsonObject]``.
|
|
20
|
+
type JsonObject = dict[str, JsonValue]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# src/fleetpull/vocabulary/provider.py
|
|
2
|
+
"""
|
|
3
|
+
The closed set of telematics providers fleetpull extracts from.
|
|
4
|
+
|
|
5
|
+
Shared, dependency-free package vocabulary, sibling to ``ResponseCategory``:
|
|
6
|
+
an endpoint declares the provider it belongs to, and the composition root
|
|
7
|
+
keys per-provider auth and response classification on it. Homed in the leaf
|
|
8
|
+
that imports nothing internal so every layer can name a provider without
|
|
9
|
+
forming a cycle.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
|
|
14
|
+
__all__: list[str] = ['Provider']
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Provider(StrEnum):
|
|
18
|
+
"""
|
|
19
|
+
Closed set of telematics providers.
|
|
20
|
+
|
|
21
|
+
String values are the lowercase provider keys used in logging and
|
|
22
|
+
quota-scope naming. A provider earns a member only when fleetpull
|
|
23
|
+
implements its auth and response classification — which all three
|
|
24
|
+
already have.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
GEOTAB = 'geotab'
|
|
28
|
+
MOTIVE = 'motive'
|
|
29
|
+
SAMSARA = 'samsara'
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# src/fleetpull/vocabulary/quota_scope.py
|
|
2
|
+
"""
|
|
3
|
+
The closed set of quota scopes an endpoint definition declares.
|
|
4
|
+
|
|
5
|
+
Shared, dependency-free package vocabulary, sibling to ``Provider`` and
|
|
6
|
+
``ResponseCategory`` in the leaf. A ``QuotaScope`` is which token bucket an
|
|
7
|
+
endpoint spends from — the type of ``endpoint.quota_scope`` — not which
|
|
8
|
+
provider it belongs to. The two are separate vocabularies on purpose — folding
|
|
9
|
+
both into one type would be exactly the conflation this avoids — and they
|
|
10
|
+
diverged when GeoTab metered its endpoints apart from the rest (the
|
|
11
|
+
method-class scopes below; the §13 Samsara ``vehicle_locations`` case would
|
|
12
|
+
add another ``QuotaScope`` member while the ``Provider`` stays ``SAMSARA``).
|
|
13
|
+
|
|
14
|
+
GeoTab meters per method class, not per provider (§8, captured 2026-07-09),
|
|
15
|
+
so its scopes are method-class members: ``GEOTAB_GET`` is the Get-class data
|
|
16
|
+
scope endpoint definitions declare, ``GEOTAB_FEED`` is the GetFeed-class
|
|
17
|
+
scope the feed endpoints declare (its own ~60/min budget, proven by the
|
|
18
|
+
2026-07-21 header-decrement probe — distinct from the ~650/min Get class),
|
|
19
|
+
and ``GEOTAB_AUTHENTICATE`` is the dedicated Authenticate scope —
|
|
20
|
+
auth-internal, never an endpoint declaration; the composition root passes it
|
|
21
|
+
to the authenticator factory by name.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from enum import StrEnum
|
|
25
|
+
|
|
26
|
+
__all__: list[str] = ['QuotaScope']
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class QuotaScope(StrEnum):
|
|
30
|
+
"""
|
|
31
|
+
Closed set of quota scopes (which token bucket an endpoint spends from).
|
|
32
|
+
|
|
33
|
+
String values are the lowercase scope keys the ``RateLimiterRegistry`` is
|
|
34
|
+
already keyed on (matching the provider-derived scope strings in use today),
|
|
35
|
+
so a ``QuotaScope`` member passes to ``QuotaScopeLimiter(quota_scope: str)``
|
|
36
|
+
transparently — it is a ``str``.
|
|
37
|
+
|
|
38
|
+
Membership vs. limits: scope *membership* is this closed architectural set
|
|
39
|
+
(code); scope *limits* are config (§7). Adding a scope is therefore a code
|
|
40
|
+
change (a new member) plus a config change (its limits) — not config-only.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
GEOTAB_AUTHENTICATE = 'geotab_authenticate'
|
|
44
|
+
GEOTAB_FEED = 'geotab_feed'
|
|
45
|
+
GEOTAB_GET = 'geotab_get'
|
|
46
|
+
MOTIVE = 'motive'
|
|
47
|
+
SAMSARA = 'samsara'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# src/fleetpull/vocabulary/response_category.py
|
|
2
|
+
"""
|
|
3
|
+
The closed response-classification vocabulary.
|
|
4
|
+
|
|
5
|
+
Shared, dependency-free package vocabulary: the classification layer
|
|
6
|
+
produces it, the client dispatches on it, the retry layer takes it as
|
|
7
|
+
input, and ``RetriesExhaustedError`` carries it on a public field.
|
|
8
|
+
Homed in a leaf that imports nothing internal so every layer — root
|
|
9
|
+
exceptions included — can depend on it without forming a cycle.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
|
|
14
|
+
__all__: list[str] = ['ResponseCategory']
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ResponseCategory(StrEnum):
|
|
18
|
+
"""
|
|
19
|
+
Closed vocabulary of "what the client does next."
|
|
20
|
+
|
|
21
|
+
Each member earns its slot by demanding a distinct client action.
|
|
22
|
+
Closure invariant (DESIGN.md §8): a new category is admissible only
|
|
23
|
+
if it arrives with a new client action.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
SUCCESS = 'success' # parse and yield records
|
|
27
|
+
TRANSIENT = 'transient' # retry with backoff
|
|
28
|
+
RATE_LIMITED = 'rate_limited' # penalize the shared quota scope, then retry
|
|
29
|
+
AUTH_FAILURE = (
|
|
30
|
+
'auth_failure' # ask the auth strategy whether one retry is worthwhile
|
|
31
|
+
)
|
|
32
|
+
FATAL = 'fatal' # raise
|