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,202 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/samsara/_spec_builders.py
|
|
2
|
+
"""The shared Samsara multi-leaf spec-builders.
|
|
3
|
+
|
|
4
|
+
Two builder families, each serving one wire family through multiple
|
|
5
|
+
leaves (the Motive ``_spec_builders`` promotion precedent):
|
|
6
|
+
|
|
7
|
+
- ``SamsaraVehicleStatsSpecBuilder`` for the three
|
|
8
|
+
``/fleet/vehicles/stats/history`` leaves (engine_states,
|
|
9
|
+
gps_readings, odometer_readings) -- one wire surface serving three
|
|
10
|
+
endpoints, each requesting exactly its own stat type, so the one
|
|
11
|
+
varying fact (``types``) is a builder field and the window rendering
|
|
12
|
+
is written once (three users at birth).
|
|
13
|
+
- ``SamsaraFuelEnergyReportSpecBuilder`` for the two
|
|
14
|
+
``/fleet/reports/{vehicles,drivers}/fuel-energy`` leaves -- one wire
|
|
15
|
+
family whose only varying fact is the path, and whose window rides
|
|
16
|
+
the surface family's own ``startDate``/``endDate`` param NAMES (two
|
|
17
|
+
users at birth).
|
|
18
|
+
|
|
19
|
+
The idling_events leaf keeps its own builder: it carries no ``types``
|
|
20
|
+
param and no naming quirk, and folding it in would trade a real
|
|
21
|
+
one-family bundle for a provider-wide window-builder abstraction
|
|
22
|
+
nobody asked for.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Final
|
|
28
|
+
|
|
29
|
+
from fleetpull.endpoints.shared import ResumeValue, require_date_window
|
|
30
|
+
from fleetpull.network.contract import HttpMethod, RequestSpec
|
|
31
|
+
from fleetpull.timing import to_iso8601
|
|
32
|
+
|
|
33
|
+
__all__: list[str] = [
|
|
34
|
+
'RECORDS_KEY',
|
|
35
|
+
'RESULTS_LIMIT',
|
|
36
|
+
'STATS_HISTORY_PATH',
|
|
37
|
+
'SamsaraFuelEnergyReportSpecBuilder',
|
|
38
|
+
'SamsaraVehicleStatsSpecBuilder',
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
# The one probed wire surface's coinciding facts, stated once for the
|
|
42
|
+
# three leaves (only the stat type varies per leaf). A re-probe that
|
|
43
|
+
# moves any of these lands here and reaches every leaf.
|
|
44
|
+
STATS_HISTORY_PATH: Final[str] = '/fleet/vehicles/stats/history'
|
|
45
|
+
RECORDS_KEY: Final[str] = 'data'
|
|
46
|
+
|
|
47
|
+
# The per-page vehicle count. 512 is THIS surface's probed maximum
|
|
48
|
+
# (limit=512 -> HTTP 200, limit=513 -> HTTP 400, captured 2026-07-20):
|
|
49
|
+
# the vehicles/drivers tier, NOT idling's 200. Never assume a sibling's
|
|
50
|
+
# limit.
|
|
51
|
+
RESULTS_LIMIT: Final[int] = 512
|
|
52
|
+
|
|
53
|
+
# The stat-type selector, API-enforced on input: an unknown value is a
|
|
54
|
+
# loud HTTP 400 naming the bogus type ('Invalid stat type(s): ...'),
|
|
55
|
+
# never a silent empty page (captured 2026-07-20).
|
|
56
|
+
_TYPES_PARAM: Final[str] = 'types'
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class SamsaraVehicleStatsSpecBuilder:
|
|
61
|
+
"""Build the fleet-wide, date-windowed first request for one stat type.
|
|
62
|
+
|
|
63
|
+
The ``SpecBuilder`` for a vehicle-stats single chain: a fixed
|
|
64
|
+
``GET base_url + path`` carrying the resume window as RFC3339
|
|
65
|
+
``startTime``/``endTime`` (the timing codec's ``to_iso8601``) plus
|
|
66
|
+
the FIXED ``types=<stat_type>`` selector baked into every request.
|
|
67
|
+
The decoder owns pagination: its ``first_request`` merges ``limit``
|
|
68
|
+
onto this spec and its ``after`` advance merges onto the sent spec,
|
|
69
|
+
so the window and the type selector persist across the whole
|
|
70
|
+
vehicle-axis cursor walk (the idling_events mechanism).
|
|
71
|
+
|
|
72
|
+
The canonical half-open ``[start, end)`` window maps to the wire as
|
|
73
|
+
``startTime = start`` and ``endTime = end``. Retrieval is
|
|
74
|
+
READING-TIME anchored on exactly that half-open window
|
|
75
|
+
(probe-proven: a 12:00-13:00Z window returned min 12:00:03.062Z,
|
|
76
|
+
max 12:59:56.881Z), so a window's readings are exactly those
|
|
77
|
+
timestamped inside it; the runner's post-fetch window filter is
|
|
78
|
+
pure hygiene, never load-bearing.
|
|
79
|
+
|
|
80
|
+
Attributes:
|
|
81
|
+
base_url: Root of the Samsara API, trailing-slash-normalized by
|
|
82
|
+
the provider config so the leading-slash path joins
|
|
83
|
+
directly.
|
|
84
|
+
path: The endpoint's leading-slash request path
|
|
85
|
+
(``'/fleet/vehicles/stats/history'``).
|
|
86
|
+
stat_type: The one stat type this endpoint requests -- the
|
|
87
|
+
verbatim ``types`` wire value, which is also the
|
|
88
|
+
per-vehicle series key the decoder unnests.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
base_url: str
|
|
92
|
+
path: str
|
|
93
|
+
stat_type: str
|
|
94
|
+
|
|
95
|
+
def build_spec(
|
|
96
|
+
self, resume: ResumeValue, member_values: Mapping[str, str]
|
|
97
|
+
) -> RequestSpec:
|
|
98
|
+
"""Build the fleet-wide, date-windowed, single-type GET.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
resume: The run's resume window. Must be a ``DateWindow`` --
|
|
102
|
+
a watermark endpoint always resumes from one; any other
|
|
103
|
+
value is a wiring bug.
|
|
104
|
+
member_values: Accepted to satisfy the protocol; unused -- a
|
|
105
|
+
fleet-wide single chain binds no member.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
A credential-less ``GET`` for ``base_url + path`` carrying
|
|
109
|
+
``types=<stat_type>`` and the window's bounds as RFC3339
|
|
110
|
+
``startTime``/``endTime``. Auth headers are layered on by
|
|
111
|
+
the client's ``ProviderProfile``; pagination parameters are
|
|
112
|
+
injected by the page decoder's ``first_request``.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
TypeError: ``resume`` is not a ``DateWindow``.
|
|
116
|
+
ValueError: A window bound is not canonical UTC.
|
|
117
|
+
|
|
118
|
+
Side Effects:
|
|
119
|
+
None.
|
|
120
|
+
"""
|
|
121
|
+
resume_window = require_date_window(resume, type(self).__name__)
|
|
122
|
+
params = {
|
|
123
|
+
_TYPES_PARAM: self.stat_type,
|
|
124
|
+
'startTime': to_iso8601(resume_window.start),
|
|
125
|
+
'endTime': to_iso8601(resume_window.end),
|
|
126
|
+
}
|
|
127
|
+
return RequestSpec(
|
|
128
|
+
method=HttpMethod.GET,
|
|
129
|
+
url=f'{self.base_url}{self.path}',
|
|
130
|
+
params=params,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True, slots=True)
|
|
135
|
+
class SamsaraFuelEnergyReportSpecBuilder:
|
|
136
|
+
"""Build the fleet-wide, date-windowed first request for one report arm.
|
|
137
|
+
|
|
138
|
+
The ``SpecBuilder`` for a fuel-energy report single chain: a fixed
|
|
139
|
+
``GET base_url + path`` carrying the resume window as RFC3339
|
|
140
|
+
``startDate``/``endDate`` (the timing codec's ``to_iso8601``). NOTE
|
|
141
|
+
the param NAMES: this surface family takes ``startDate``/``endDate``
|
|
142
|
+
-- unlike every other probed Samsara vertical's
|
|
143
|
+
``startTime``/``endTime`` -- while accepting full RFC3339 datetimes
|
|
144
|
+
despite the names (probed with ``T00:00:00Z`` values, and a 1-hour
|
|
145
|
+
window returned 200; captured 2026-07-21). The rendering itself is
|
|
146
|
+
exactly the sibling windowed builders' ``[start, end)`` mapping.
|
|
147
|
+
The decoder owns pagination AND the window stamp: its
|
|
148
|
+
``first_request`` merges ``limit`` onto this spec, its ``after``
|
|
149
|
+
advance merges onto the sent spec (so the window persists across
|
|
150
|
+
the whole walk), and it copies these two params back off the sent
|
|
151
|
+
spec onto every report row.
|
|
152
|
+
|
|
153
|
+
Attributes:
|
|
154
|
+
base_url: Root of the Samsara API, trailing-slash-normalized by
|
|
155
|
+
the provider config so the leading-slash path joins
|
|
156
|
+
directly.
|
|
157
|
+
path: The arm's leading-slash request path
|
|
158
|
+
(``'/fleet/reports/vehicles/fuel-energy'`` /
|
|
159
|
+
``'/fleet/reports/drivers/fuel-energy'``) -- the one fact
|
|
160
|
+
varying between the two leaves.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
base_url: str
|
|
164
|
+
path: str
|
|
165
|
+
|
|
166
|
+
def build_spec(
|
|
167
|
+
self, resume: ResumeValue, member_values: Mapping[str, str]
|
|
168
|
+
) -> RequestSpec:
|
|
169
|
+
"""Build the fleet-wide, date-windowed GET for one report arm.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
resume: The run's resume window. Must be a ``DateWindow`` --
|
|
173
|
+
a watermark endpoint always resumes from one; any other
|
|
174
|
+
value is a wiring bug.
|
|
175
|
+
member_values: Accepted to satisfy the protocol; unused -- a
|
|
176
|
+
fleet-wide single chain binds no member.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
A credential-less ``GET`` for ``base_url + path`` carrying
|
|
180
|
+
the window's bounds as RFC3339 ``startDate``/``endDate``
|
|
181
|
+
(the surface family's own param names). Auth headers are
|
|
182
|
+
layered on by the client's ``ProviderProfile``; pagination
|
|
183
|
+
parameters are injected by the page decoder's
|
|
184
|
+
``first_request``.
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
TypeError: ``resume`` is not a ``DateWindow``.
|
|
188
|
+
ValueError: A window bound is not canonical UTC.
|
|
189
|
+
|
|
190
|
+
Side Effects:
|
|
191
|
+
None.
|
|
192
|
+
"""
|
|
193
|
+
resume_window = require_date_window(resume, type(self).__name__)
|
|
194
|
+
params = {
|
|
195
|
+
'startDate': to_iso8601(resume_window.start),
|
|
196
|
+
'endDate': to_iso8601(resume_window.end),
|
|
197
|
+
}
|
|
198
|
+
return RequestSpec(
|
|
199
|
+
method=HttpMethod.GET,
|
|
200
|
+
url=f'{self.base_url}{self.path}',
|
|
201
|
+
params=params,
|
|
202
|
+
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/samsara/addresses.py
|
|
2
|
+
"""The Samsara addresses binding: a factory composing the addresses
|
|
3
|
+
snapshot EndpointDefinition from resolved Samsara configuration.
|
|
4
|
+
|
|
5
|
+
A binding cannot be a module-level constant because its spec-builder
|
|
6
|
+
needs the run's configured base URL, known only after the YAML config
|
|
7
|
+
loads; so the endpoint is a factory taking a validated ``SamsaraConfig``
|
|
8
|
+
and returning the frozen ``EndpointDefinition`` the composition root
|
|
9
|
+
hands to the client (the Motive leaf convention).
|
|
10
|
+
|
|
11
|
+
The vehicles template verbatim (2026-07-20 probe session): a plain
|
|
12
|
+
snapshot on the standard Samsara cursor contract -- ``data`` beside
|
|
13
|
+
``pagination {endCursor, hasNextPage}`` -- with no roster sourced, no
|
|
14
|
+
roster consumed, and no window. The full walk was the whole population
|
|
15
|
+
in one page (25 records), and the walk is complete by construction:
|
|
16
|
+
continuation is explicit on every page and a promised continuation
|
|
17
|
+
without a cursor fails loudly in the decoder, so no completeness check
|
|
18
|
+
is declared.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from typing import Final
|
|
22
|
+
|
|
23
|
+
from fleetpull.config import SamsaraConfig
|
|
24
|
+
from fleetpull.endpoints.shared import (
|
|
25
|
+
EndpointDefinition,
|
|
26
|
+
SnapshotMode,
|
|
27
|
+
StaticGetSpecBuilder,
|
|
28
|
+
StorageKind,
|
|
29
|
+
)
|
|
30
|
+
from fleetpull.models.samsara import Address
|
|
31
|
+
from fleetpull.network.decoders import SamsaraCursorPageDecoder
|
|
32
|
+
from fleetpull.vocabulary import Provider, QuotaScope
|
|
33
|
+
|
|
34
|
+
__all__: list[str] = ['build_endpoint']
|
|
35
|
+
|
|
36
|
+
_ADDRESSES_PATH: Final[str] = '/addresses'
|
|
37
|
+
_RECORDS_KEY: Final[str] = 'data'
|
|
38
|
+
|
|
39
|
+
# The per-page record count. The limit tier was probed directly on THIS
|
|
40
|
+
# endpoint (2026-07-20): limit=512 returned HTTP 200 and limit=513 a
|
|
41
|
+
# loud HTTP 400, so /addresses sits in the vehicles/drivers 512 tier --
|
|
42
|
+
# NOT the 200 tier of /idling/events. Samsara limit maxima are
|
|
43
|
+
# per-endpoint (the idling_events capture's rule): never assume a
|
|
44
|
+
# sibling's tier. A strong candidate for a user config knob.
|
|
45
|
+
_RESULTS_LIMIT: Final[int] = 512
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_endpoint(config: SamsaraConfig) -> EndpointDefinition[Address]:
|
|
49
|
+
"""Build the Samsara addresses snapshot binding.
|
|
50
|
+
|
|
51
|
+
A full-listing snapshot of the fleet's defined addresses (named
|
|
52
|
+
locations with geofences): no resume, a single parquet file, full
|
|
53
|
+
replacement each run. Records arrive as a top-level list under
|
|
54
|
+
``data``, walked by explicit cursor pages (``limit`` on page one,
|
|
55
|
+
``after`` merged thereafter), terminal on ``hasNextPage: false``.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
config: The validated Samsara configuration; supplies the base
|
|
59
|
+
URL the spec-builder joins to the addresses path.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The frozen addresses ``EndpointDefinition``.
|
|
63
|
+
"""
|
|
64
|
+
return EndpointDefinition(
|
|
65
|
+
provider=Provider.SAMSARA,
|
|
66
|
+
name='addresses',
|
|
67
|
+
spec_builder=StaticGetSpecBuilder(
|
|
68
|
+
base_url=config.base_url, path=_ADDRESSES_PATH
|
|
69
|
+
),
|
|
70
|
+
page_decoder=SamsaraCursorPageDecoder(
|
|
71
|
+
records_key=_RECORDS_KEY, results_limit=_RESULTS_LIMIT
|
|
72
|
+
),
|
|
73
|
+
response_model=Address,
|
|
74
|
+
quota_scope=QuotaScope.SAMSARA,
|
|
75
|
+
storage_kind=StorageKind.SINGLE,
|
|
76
|
+
sync_mode=SnapshotMode(),
|
|
77
|
+
)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/samsara/asset_locations.py
|
|
2
|
+
"""The Samsara asset_locations binding: the windowed batched roster
|
|
3
|
+
fan-out -- the first ``BatchedRosterFanOut`` consumer.
|
|
4
|
+
|
|
5
|
+
``GET /assets/location-and-speed/stream`` is a modern-envelope surface
|
|
6
|
+
(``data`` + ``pagination {endCursor, hasNextPage}`` -- the standard
|
|
7
|
+
cursor contract; the ``endCursor`` is a fat composite token, opaque,
|
|
8
|
+
passed back verbatim as ``after`` like every other cursor). The legacy
|
|
9
|
+
hub called this surface ``location_stream``; the shipped endpoint is
|
|
10
|
+
``asset_locations`` per the name=plural-of-entity invariant.
|
|
11
|
+
|
|
12
|
+
The ``ids`` parameter is REQUIRED: omitting it is a loud HTTP 400
|
|
13
|
+
(``"Need to include asset IDs to filter by."``), and the batch cap is
|
|
14
|
+
API-ENFORCED AT 50 -- 50 comma-joined ids returned 200 while 100,
|
|
15
|
+
200, and 609 each returned HTTP 400 naming the bound (``"Need to
|
|
16
|
+
filter by 50 or less asset IDs or syncTokens."``; captured
|
|
17
|
+
2026-07-20). So the binding declares
|
|
18
|
+
``request_shape=BatchedRosterFanOut`` over the Samsara ``vehicle_ids``
|
|
19
|
+
roster (declared beside its feeder in ``endpoints/samsara/vehicles.py``;
|
|
20
|
+
this module knows only the ``RosterKey``): one cursor-walk chain per
|
|
21
|
+
sorted 50-member batch, the batch comma-joined into the ``ids`` query
|
|
22
|
+
parameter. The batch is transport packing only -- every record carries
|
|
23
|
+
its own ``asset.id`` attribution, so no member attribution rides on the
|
|
24
|
+
request mapping, and the fan-out driver's progress narration counts
|
|
25
|
+
BATCHES for this shape (a deliberate, recorded consequence -- DESIGN
|
|
26
|
+
section 8).
|
|
27
|
+
|
|
28
|
+
The windowed leaf builder carries the resume window as RFC3339
|
|
29
|
+
``startTime``/``endTime`` (the idling_events builder precedent) and
|
|
30
|
+
merges the batch binding verbatim as a query parameter (the trips
|
|
31
|
+
member-merge precedent); the decoder's ``first_request`` merges
|
|
32
|
+
``limit`` and its ``after`` advance merges onto the SENT spec, so the
|
|
33
|
+
window and the batch ride every page of each chain's walk.
|
|
34
|
+
|
|
35
|
+
The per-endpoint ``limit`` maximum is 512, probed directly on THIS
|
|
36
|
+
surface: limit=512 returned HTTP 200 and limit=513 a loud HTTP 400 --
|
|
37
|
+
the vehicles/drivers tier, NOT idling's 200 (the per-endpoint
|
|
38
|
+
limit-tier rule, honored by probing rather than assuming).
|
|
39
|
+
|
|
40
|
+
Retrieval is READING-TIME anchored on the half-open ``[startTime,
|
|
41
|
+
endTime)`` window, probe-proven: a 12:00-13:00Z window returned
|
|
42
|
+
readings spanning exactly 12:00:03Z..12:59:56Z. Consequence:
|
|
43
|
+
``event_time_column='happened_at_time'``, the retrieval anchor and the
|
|
44
|
+
routing anchor coincide natively, no wire pad exists, and the runner's
|
|
45
|
+
post-fetch window filter is pure hygiene.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from collections.abc import Mapping
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from datetime import timedelta
|
|
51
|
+
from typing import Final
|
|
52
|
+
|
|
53
|
+
from fleetpull.config import SamsaraConfig
|
|
54
|
+
from fleetpull.endpoints.shared import (
|
|
55
|
+
BatchedRosterFanOut,
|
|
56
|
+
EndpointDefinition,
|
|
57
|
+
ResumeValue,
|
|
58
|
+
StorageKind,
|
|
59
|
+
WatermarkMode,
|
|
60
|
+
require_date_window,
|
|
61
|
+
)
|
|
62
|
+
from fleetpull.models.samsara import AssetLocation
|
|
63
|
+
from fleetpull.network.contract import HttpMethod, RequestSpec
|
|
64
|
+
from fleetpull.network.decoders import SamsaraCursorPageDecoder
|
|
65
|
+
from fleetpull.roster import RosterKey
|
|
66
|
+
from fleetpull.timing import to_iso8601
|
|
67
|
+
from fleetpull.vocabulary import Provider, QuotaScope
|
|
68
|
+
|
|
69
|
+
__all__: list[str] = [
|
|
70
|
+
'SamsaraAssetLocationsSpecBuilder',
|
|
71
|
+
'build_endpoint',
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
_ASSET_LOCATIONS_PATH: Final[str] = '/assets/location-and-speed/stream'
|
|
75
|
+
_RECORDS_KEY: Final[str] = 'data'
|
|
76
|
+
|
|
77
|
+
# The per-page record count. 512 is THIS endpoint's probed maximum
|
|
78
|
+
# (limit=512 -> HTTP 200, limit=513 -> HTTP 400, captured 2026-07-20):
|
|
79
|
+
# the vehicles/drivers tier, NOT idling's 200. Never assume a sibling's
|
|
80
|
+
# limit.
|
|
81
|
+
_RESULTS_LIMIT: Final[int] = 512
|
|
82
|
+
|
|
83
|
+
# The fan-out member key IS the wire query parameter, verbatim: the spec
|
|
84
|
+
# builder merges member_values into params unchanged, so declaring the
|
|
85
|
+
# exact wire token here leaves no translation seam to drift (the trips
|
|
86
|
+
# stance). Each member value is one sorted, comma-joined 50-id batch.
|
|
87
|
+
_IDS_PARAM: Final[str] = 'ids'
|
|
88
|
+
|
|
89
|
+
# The batch cap is API-ENFORCED AT 50, probed directly: 50 comma-joined
|
|
90
|
+
# ids -> HTTP 200; 100/200/609 -> HTTP 400 '{"message": "Need to filter
|
|
91
|
+
# by 50 or less asset IDs or syncTokens."}' (captured 2026-07-20).
|
|
92
|
+
_BATCH_SIZE: Final[int] = 50
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class SamsaraAssetLocationsSpecBuilder:
|
|
97
|
+
"""Build one batch chain's date-windowed first request.
|
|
98
|
+
|
|
99
|
+
The ``SpecBuilder`` for the asset_locations ``BatchedRosterFanOut``:
|
|
100
|
+
a fixed ``GET base_url + path`` carrying the chain's batch binding
|
|
101
|
+
verbatim as query parameters (``{'ids': '<id>,<id>,...'}`` -- the
|
|
102
|
+
trips member-merge precedent) plus the resume window as RFC3339
|
|
103
|
+
``startTime``/``endTime`` (the timing codec's ``to_iso8601``). The
|
|
104
|
+
decoder owns pagination: its ``first_request`` merges ``limit`` onto
|
|
105
|
+
this spec and its ``after`` advance merges onto the sent spec, so
|
|
106
|
+
the window and the batch persist across the whole cursor walk.
|
|
107
|
+
|
|
108
|
+
The canonical half-open ``[start, end)`` window maps to the wire as
|
|
109
|
+
``startTime = start`` and ``endTime = end``. Retrieval is
|
|
110
|
+
reading-time anchored on exactly that half-open window (module
|
|
111
|
+
docstring), so a window's readings are exactly those timestamped
|
|
112
|
+
inside it; the runner's post-fetch window filter is pure hygiene,
|
|
113
|
+
never load-bearing.
|
|
114
|
+
|
|
115
|
+
Attributes:
|
|
116
|
+
base_url: Root of the Samsara API, trailing-slash-normalized by
|
|
117
|
+
the provider config so the leading-slash path joins
|
|
118
|
+
directly.
|
|
119
|
+
path: The endpoint's leading-slash request path
|
|
120
|
+
(``'/assets/location-and-speed/stream'``).
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
base_url: str
|
|
124
|
+
path: str
|
|
125
|
+
|
|
126
|
+
def build_spec(
|
|
127
|
+
self, resume: ResumeValue, member_values: Mapping[str, str]
|
|
128
|
+
) -> RequestSpec:
|
|
129
|
+
"""Build one batch chain's windowed GET.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
resume: The run's resume window. Must be a ``DateWindow`` --
|
|
133
|
+
a watermark endpoint always resumes from one; any other
|
|
134
|
+
value is a wiring bug.
|
|
135
|
+
member_values: The batch binding, merged verbatim as query
|
|
136
|
+
parameters -- ``{'ids': <comma-joined batch>}`` for a
|
|
137
|
+
batched roster chain (``ids`` is REQUIRED by the wire;
|
|
138
|
+
an empty binding would earn the provider's own loud
|
|
139
|
+
400).
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
A credential-less ``GET`` for ``base_url + path`` carrying
|
|
143
|
+
the batch binding plus the window's bounds as RFC3339
|
|
144
|
+
``startTime``/``endTime``. Auth headers are layered on by
|
|
145
|
+
the client's ``ProviderProfile``; pagination parameters are
|
|
146
|
+
injected by the page decoder's ``first_request``.
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
TypeError: ``resume`` is not a ``DateWindow``.
|
|
150
|
+
ValueError: A window bound is not canonical UTC.
|
|
151
|
+
|
|
152
|
+
Side Effects:
|
|
153
|
+
None.
|
|
154
|
+
"""
|
|
155
|
+
resume_window = require_date_window(resume, type(self).__name__)
|
|
156
|
+
params = dict(member_values)
|
|
157
|
+
params['startTime'] = to_iso8601(resume_window.start)
|
|
158
|
+
params['endTime'] = to_iso8601(resume_window.end)
|
|
159
|
+
return RequestSpec(
|
|
160
|
+
method=HttpMethod.GET,
|
|
161
|
+
url=f'{self.base_url}{self.path}',
|
|
162
|
+
params=params,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def build_endpoint(config: SamsaraConfig) -> EndpointDefinition[AssetLocation]:
|
|
167
|
+
"""Build the Samsara asset_locations watermark binding.
|
|
168
|
+
|
|
169
|
+
Per-asset location readings fetched incrementally: the run resumes
|
|
170
|
+
from a ``DateWindow`` (watermark with the provider's late-arrival
|
|
171
|
+
lookback from config), the fetched readings are written to
|
|
172
|
+
``date=YYYY-MM-DD`` partitions on ``happened_at_time``, and each
|
|
173
|
+
refetched partition is replaced. Records arrive as a top-level list
|
|
174
|
+
under ``data``, already at the reading grain, walked by explicit
|
|
175
|
+
cursor pages per batch chain (``limit`` on page one, ``after``
|
|
176
|
+
merged thereafter, the window and batch parameters persisting
|
|
177
|
+
throughout), terminal on ``hasNextPage: false``. The
|
|
178
|
+
``request_shape`` declaration (``BatchedRosterFanOut``) names the
|
|
179
|
+
Samsara ``vehicle_ids`` roster; the orchestration entry resolves it
|
|
180
|
+
to members and the shape seam fans one chain per sorted 50-member
|
|
181
|
+
comma-joined batch, passing each batch string to the spec-builder's
|
|
182
|
+
``member_values`` -- this binding only declares the strategies, the
|
|
183
|
+
roster key, and the probed cap, never the roster's feeder.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
config: The validated Samsara configuration; supplies the base
|
|
187
|
+
URL the spec-builder joins to the stream path and the
|
|
188
|
+
lookback and cutoff the watermark mode carries.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
The frozen asset_locations ``EndpointDefinition``. Construction
|
|
192
|
+
validates the ``WatermarkMode`` / ``DATE_PARTITIONED`` /
|
|
193
|
+
``event_time_column`` triple against the response model.
|
|
194
|
+
"""
|
|
195
|
+
return EndpointDefinition(
|
|
196
|
+
provider=Provider.SAMSARA,
|
|
197
|
+
name='asset_locations',
|
|
198
|
+
spec_builder=SamsaraAssetLocationsSpecBuilder(
|
|
199
|
+
base_url=config.base_url, path=_ASSET_LOCATIONS_PATH
|
|
200
|
+
),
|
|
201
|
+
page_decoder=SamsaraCursorPageDecoder(
|
|
202
|
+
records_key=_RECORDS_KEY, results_limit=_RESULTS_LIMIT
|
|
203
|
+
),
|
|
204
|
+
response_model=AssetLocation,
|
|
205
|
+
quota_scope=QuotaScope.SAMSARA,
|
|
206
|
+
storage_kind=StorageKind.DATE_PARTITIONED,
|
|
207
|
+
sync_mode=WatermarkMode(
|
|
208
|
+
lookback=timedelta(days=config.lookback_days),
|
|
209
|
+
cutoff=timedelta(days=config.cutoff_days),
|
|
210
|
+
),
|
|
211
|
+
event_time_column='happened_at_time',
|
|
212
|
+
request_shape=BatchedRosterFanOut(
|
|
213
|
+
roster=RosterKey(Provider.SAMSARA, 'vehicle_ids'),
|
|
214
|
+
member_key=_IDS_PARAM,
|
|
215
|
+
batch_size=_BATCH_SIZE,
|
|
216
|
+
),
|
|
217
|
+
)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/samsara/driver_fuel_energy_reports.py
|
|
2
|
+
"""The Samsara driver_fuel_energy_reports binding: the driver arm of
|
|
3
|
+
the window-grain fuel-energy report pair (probe-settled 2026-07-21,
|
|
4
|
+
DESIGN section 8). The legacy hub's ``driver_fuel_energy``, renamed per
|
|
5
|
+
the name=snake-plural-of-model invariant (model
|
|
6
|
+
``DriverFuelEnergyReport``).
|
|
7
|
+
|
|
8
|
+
``GET /fleet/reports/drivers/fuel-energy`` is the vehicle arm's wire
|
|
9
|
+
family with the entity swapped: the standard cursor contract over the
|
|
10
|
+
NESTED record list (``data`` an OBJECT whose only key is
|
|
11
|
+
``driverReports``), reports fleet-wide with per-record driver
|
|
12
|
+
attribution (``driver {id, name}``; NO ``externalIds`` was ever
|
|
13
|
+
observed on this arm), so no fan-out and no roster -- the default
|
|
14
|
+
``SingleFetch`` shape, declared by declaring nothing.
|
|
15
|
+
|
|
16
|
+
The window-grain facts are the pair's, proven on the family
|
|
17
|
+
(vehicle_fuel_energy_reports carries the full evidence): the rollup
|
|
18
|
+
grain is the request window and day rollups are NON-ADDITIVE into
|
|
19
|
+
wider windows (89/267 mismatched, captured 2026-07-21), so the binding
|
|
20
|
+
declares ``fixed_unit_days=1`` -- the unit width is part of the ROW'S
|
|
21
|
+
MEANING and never floats with ``sync.backfill_chunk_days``. Rows carry
|
|
22
|
+
NO event-time key; the decoder stamps each with the sent window and
|
|
23
|
+
``event_time_column='window_start'`` routes each day's rollup to its
|
|
24
|
+
own partition. Pagination is real on this arm too: a 1-day driver
|
|
25
|
+
window showed ``hasNextPage: true`` at 100 reports, the same ~100
|
|
26
|
+
server-owned page size the placebo ``limit`` cannot move --
|
|
27
|
+
``results_limit=100`` is documentation-by-declaration.
|
|
28
|
+
|
|
29
|
+
The window rides the family's own ``startDate``/``endDate`` param
|
|
30
|
+
NAMES (RFC3339 datetimes accepted despite the names; the shared
|
|
31
|
+
``SamsaraFuelEnergyReportSpecBuilder`` carries the provenance).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from datetime import timedelta
|
|
35
|
+
from typing import Final
|
|
36
|
+
|
|
37
|
+
from fleetpull.config import SamsaraConfig
|
|
38
|
+
from fleetpull.endpoints.samsara._spec_builders import (
|
|
39
|
+
SamsaraFuelEnergyReportSpecBuilder,
|
|
40
|
+
)
|
|
41
|
+
from fleetpull.endpoints.shared import (
|
|
42
|
+
EndpointDefinition,
|
|
43
|
+
StorageKind,
|
|
44
|
+
WatermarkMode,
|
|
45
|
+
)
|
|
46
|
+
from fleetpull.models.samsara import DriverFuelEnergyReport
|
|
47
|
+
from fleetpull.network.decoders import SamsaraWindowReportPageDecoder
|
|
48
|
+
from fleetpull.vocabulary import Provider, QuotaScope
|
|
49
|
+
|
|
50
|
+
__all__: list[str] = ['build_endpoint']
|
|
51
|
+
|
|
52
|
+
_DRIVER_FUEL_ENERGY_PATH: Final[str] = '/fleet/reports/drivers/fuel-energy'
|
|
53
|
+
_RECORDS_KEY: Final[str] = 'data'
|
|
54
|
+
|
|
55
|
+
# The nested report key: `data` is an OBJECT whose only key is this
|
|
56
|
+
# arm's report list (captured 2026-07-21).
|
|
57
|
+
_REPORT_KEY: Final[str] = 'driverReports'
|
|
58
|
+
|
|
59
|
+
# The per-page record count. ~100 is the server's OWN observed page
|
|
60
|
+
# size (a 1-day driver window showed hasNextPage: true at 100 reports)
|
|
61
|
+
# and the `limit` param is PROVEN IGNORED on this surface family
|
|
62
|
+
# (limit=512/513/10 all paged identically; 513 NOT rejected -- no
|
|
63
|
+
# enforced tier, captured 2026-07-21). Declaring 100 documents the
|
|
64
|
+
# server's paging; it is not a working knob.
|
|
65
|
+
_RESULTS_LIMIT: Final[int] = 100
|
|
66
|
+
|
|
67
|
+
# The fixed work-unit width, in whole days -- the pair's window-grain
|
|
68
|
+
# and non-additivity proofs (module docstring; captured 2026-07-21):
|
|
69
|
+
# the unit width is part of the row's meaning, pinned here rather than
|
|
70
|
+
# left to sync.backfill_chunk_days.
|
|
71
|
+
_FIXED_UNIT_DAYS: Final[int] = 1
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_endpoint(
|
|
75
|
+
config: SamsaraConfig,
|
|
76
|
+
) -> EndpointDefinition[DriverFuelEnergyReport]:
|
|
77
|
+
"""Build the Samsara driver_fuel_energy_reports watermark binding.
|
|
78
|
+
|
|
79
|
+
Fleet-wide per-driver fuel-energy rollups fetched incrementally at
|
|
80
|
+
the fixed 1-day unit width: the run resumes from a ``DateWindow``
|
|
81
|
+
(watermark with the provider's late-arrival lookback from config),
|
|
82
|
+
the planner tiles it into exactly-one-day units (the declared
|
|
83
|
+
``fixed_unit_days`` -- module docstring for the non-additivity
|
|
84
|
+
proof), each unit's reports are stamped with its window by the
|
|
85
|
+
decoder and written to the ``date=YYYY-MM-DD`` partition on
|
|
86
|
+
``window_start``, and each refetched partition is replaced. Reports
|
|
87
|
+
arrive nested under ``data.driverReports``, walked by explicit
|
|
88
|
+
cursor pages (``limit`` on page one, ``after`` merged thereafter,
|
|
89
|
+
the ``startDate``/``endDate`` window persisting throughout),
|
|
90
|
+
terminal on ``hasNextPage: false``. No request shape is declared --
|
|
91
|
+
the endpoint is a fleet-wide ``SingleFetch``, the default.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
config: The validated Samsara configuration; supplies the base
|
|
95
|
+
URL the spec-builder joins to the report path and the
|
|
96
|
+
lookback and cutoff the watermark mode carries.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
The frozen driver_fuel_energy_reports ``EndpointDefinition``.
|
|
100
|
+
Construction validates the ``WatermarkMode`` /
|
|
101
|
+
``DATE_PARTITIONED`` / ``event_time_column`` triple against the
|
|
102
|
+
response model.
|
|
103
|
+
"""
|
|
104
|
+
return EndpointDefinition(
|
|
105
|
+
provider=Provider.SAMSARA,
|
|
106
|
+
name='driver_fuel_energy_reports',
|
|
107
|
+
spec_builder=SamsaraFuelEnergyReportSpecBuilder(
|
|
108
|
+
base_url=config.base_url, path=_DRIVER_FUEL_ENERGY_PATH
|
|
109
|
+
),
|
|
110
|
+
page_decoder=SamsaraWindowReportPageDecoder(
|
|
111
|
+
records_key=_RECORDS_KEY,
|
|
112
|
+
report_key=_REPORT_KEY,
|
|
113
|
+
results_limit=_RESULTS_LIMIT,
|
|
114
|
+
),
|
|
115
|
+
response_model=DriverFuelEnergyReport,
|
|
116
|
+
quota_scope=QuotaScope.SAMSARA,
|
|
117
|
+
storage_kind=StorageKind.DATE_PARTITIONED,
|
|
118
|
+
sync_mode=WatermarkMode(
|
|
119
|
+
lookback=timedelta(days=config.lookback_days),
|
|
120
|
+
cutoff=timedelta(days=config.cutoff_days),
|
|
121
|
+
fixed_unit_days=_FIXED_UNIT_DAYS,
|
|
122
|
+
),
|
|
123
|
+
event_time_column='window_start',
|
|
124
|
+
)
|