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,122 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/motive/vehicle_utilizations.py
|
|
2
|
+
"""The Motive vehicle_utilizations binding: the window-grain rollup
|
|
3
|
+
surface -- Motive's arm of the fixed-unit-width watermark family
|
|
4
|
+
(probe-settled 2026-07-21, DESIGN section 8). The legacy hub's
|
|
5
|
+
``vehicle_utilization``, shipped under the wire's own plural envelope
|
|
6
|
+
vocabulary (model ``VehicleUtilization``).
|
|
7
|
+
|
|
8
|
+
``GET /v2/vehicle_utilization`` carries the standard Motive
|
|
9
|
+
wrapped-list envelope (wrapper ``vehicle_utilizations`` /
|
|
10
|
+
``vehicle_utilization``) and offset pagination at the configured page
|
|
11
|
+
size (``per_page`` 50 and 100 both honored live). The population is the
|
|
12
|
+
WHOLE vehicle fleet regardless of window (1,466 on the 1-day and the
|
|
13
|
+
6-day probe alike -- inactive vehicles ride with zeroed metrics and a
|
|
14
|
+
``message`` status string), fleet-wide with per-record vehicle
|
|
15
|
+
attribution, so there is NO fan-out -- the default ``SingleFetch``
|
|
16
|
+
shape, declared by declaring nothing, and no roster is sourced or
|
|
17
|
+
consumed.
|
|
18
|
+
|
|
19
|
+
**THE ROLLUP GRAIN IS THE REQUEST WINDOW** (a 1-day and a 6-day request
|
|
20
|
+
each returned one rollup row per vehicle), so the binding declares
|
|
21
|
+
``fixed_unit_days=1`` on its ``WatermarkMode`` -- the unit width is
|
|
22
|
+
part of the ROW'S MEANING and never floats with
|
|
23
|
+
``sync.backfill_chunk_days`` (the Samsara fuel-energy machinery, its
|
|
24
|
+
second consumer; additivity was not probed here, and the family
|
|
25
|
+
precedent's do-not-sum posture rides the model docstring). Rows carry
|
|
26
|
+
NO date or time identity; the ``MotiveWindowReportPageDecoder`` stamps
|
|
27
|
+
each with the sent window and ``event_time_column='window_start'``
|
|
28
|
+
routes each day's rollup to its own partition.
|
|
29
|
+
|
|
30
|
+
**The window mapping and the company-local caveat.** The shared
|
|
31
|
+
``MotiveFleetDateRangeSpecBuilder`` maps the half-open unit
|
|
32
|
+
``DateWindow`` ``[start, end)`` to the INCLUSIVE date-label pair the
|
|
33
|
+
wire takes -- ``start_date`` is ``start``'s date and ``end_date`` the
|
|
34
|
+
last covered date (``end`` minus a day) -- proven inclusive on both
|
|
35
|
+
ends live (``start_date=end_date`` returned exactly one day's rollup).
|
|
36
|
+
With the fixed 1-day unit both labels are the unit's day. The labels
|
|
37
|
+
are interpreted in COMPANY-LOCAL days (the account's ``/v1/companies``
|
|
38
|
+
zone at a UTC-5 offset -- DESIGN section 8), so each unit's rows are
|
|
39
|
+
the provider's company-local-day rollups, mirrored verbatim: no pad, no
|
|
40
|
+
trim, the caveat documented on the model, never converted.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from datetime import timedelta
|
|
44
|
+
from typing import Final
|
|
45
|
+
|
|
46
|
+
from fleetpull.config import MotiveConfig
|
|
47
|
+
from fleetpull.endpoints.motive._spec_builders import MotiveFleetDateRangeSpecBuilder
|
|
48
|
+
from fleetpull.endpoints.shared import (
|
|
49
|
+
EndpointDefinition,
|
|
50
|
+
StorageKind,
|
|
51
|
+
WatermarkMode,
|
|
52
|
+
)
|
|
53
|
+
from fleetpull.models.motive import VehicleUtilization
|
|
54
|
+
from fleetpull.network.decoders import MotiveWindowReportPageDecoder
|
|
55
|
+
from fleetpull.vocabulary import Provider, QuotaScope
|
|
56
|
+
|
|
57
|
+
__all__: list[str] = ['build_endpoint']
|
|
58
|
+
|
|
59
|
+
_VEHICLE_UTILIZATIONS_PATH: Final[str] = '/v2/vehicle_utilization'
|
|
60
|
+
|
|
61
|
+
# The wire's own envelope vocabulary -- plural list key, singular item
|
|
62
|
+
# key, exactly the sibling wrapped-list convention (captured 2026-07-21).
|
|
63
|
+
_VEHICLE_UTILIZATIONS_LIST_KEY: Final[str] = 'vehicle_utilizations'
|
|
64
|
+
_VEHICLE_UTILIZATIONS_ITEM_KEY: Final[str] = 'vehicle_utilization'
|
|
65
|
+
|
|
66
|
+
# The fixed work-unit width, in whole days. The rollup grain is the
|
|
67
|
+
# request window (a 1-day and a 6-day request each returned one rollup
|
|
68
|
+
# row per vehicle, captured 2026-07-21), so the unit width is part of
|
|
69
|
+
# the row's meaning and is pinned here rather than left to
|
|
70
|
+
# sync.backfill_chunk_days (the fuel-energy machinery, DESIGN section 5).
|
|
71
|
+
_FIXED_UNIT_DAYS: Final[int] = 1
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_endpoint(config: MotiveConfig) -> EndpointDefinition[VehicleUtilization]:
|
|
75
|
+
"""Build the Motive vehicle_utilizations watermark binding.
|
|
76
|
+
|
|
77
|
+
Whole-fleet per-vehicle utilization rollups fetched incrementally at
|
|
78
|
+
the fixed 1-day unit width: the run resumes from a ``DateWindow``
|
|
79
|
+
(watermark with the provider's late-arrival lookback from config),
|
|
80
|
+
the planner tiles it into exactly-one-day units (the declared
|
|
81
|
+
``fixed_unit_days`` -- module docstring for the window-grain proof),
|
|
82
|
+
each unit's rows are stamped with its window by the decoder and
|
|
83
|
+
written to the ``date=YYYY-MM-DD`` partition on ``window_start``,
|
|
84
|
+
and each refetched partition is replaced. Records arrive wrapped
|
|
85
|
+
(``{"vehicle_utilizations": [{"vehicle_utilization": {...}}]}``)
|
|
86
|
+
under page-numbered pagination at the page size the config requests,
|
|
87
|
+
the ``start_date``/``end_date`` labels persisting across the walk.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
config: The validated Motive configuration; supplies the base
|
|
91
|
+
URL the spec-builder joins to the utilization path, the page
|
|
92
|
+
size the decoder requests, and the lookback and cutoff the
|
|
93
|
+
watermark mode carries.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
The frozen vehicle_utilizations ``EndpointDefinition``.
|
|
97
|
+
Construction validates the ``WatermarkMode`` /
|
|
98
|
+
``DATE_PARTITIONED`` / ``event_time_column`` triple against the
|
|
99
|
+
response model.
|
|
100
|
+
"""
|
|
101
|
+
return EndpointDefinition(
|
|
102
|
+
provider=Provider.MOTIVE,
|
|
103
|
+
name='vehicle_utilizations',
|
|
104
|
+
spec_builder=MotiveFleetDateRangeSpecBuilder(
|
|
105
|
+
base_url=config.base_url,
|
|
106
|
+
path=_VEHICLE_UTILIZATIONS_PATH,
|
|
107
|
+
),
|
|
108
|
+
page_decoder=MotiveWindowReportPageDecoder(
|
|
109
|
+
list_key=_VEHICLE_UTILIZATIONS_LIST_KEY,
|
|
110
|
+
item_key=_VEHICLE_UTILIZATIONS_ITEM_KEY,
|
|
111
|
+
per_page=config.records_per_page,
|
|
112
|
+
),
|
|
113
|
+
response_model=VehicleUtilization,
|
|
114
|
+
quota_scope=QuotaScope.MOTIVE,
|
|
115
|
+
storage_kind=StorageKind.DATE_PARTITIONED,
|
|
116
|
+
sync_mode=WatermarkMode(
|
|
117
|
+
lookback=timedelta(days=config.lookback_days),
|
|
118
|
+
cutoff=timedelta(days=config.cutoff_days),
|
|
119
|
+
fixed_unit_days=_FIXED_UNIT_DAYS,
|
|
120
|
+
),
|
|
121
|
+
event_time_column='window_start',
|
|
122
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/motive/vehicles.py
|
|
2
|
+
"""The Motive vehicles binding: a factory composing the vehicles snapshot
|
|
3
|
+
EndpointDefinition from resolved Motive configuration, plus the
|
|
4
|
+
``vehicle_ids`` roster the listing feeds.
|
|
5
|
+
|
|
6
|
+
A binding cannot be a module-level constant because its spec-builder
|
|
7
|
+
needs the run's configured base URL and page size, known only after the
|
|
8
|
+
YAML config loads; capturing config at import time would freeze a
|
|
9
|
+
default, and module-level mutable state is forbidden. So the endpoint is
|
|
10
|
+
a factory taking a validated ``MotiveConfig`` and returning the frozen
|
|
11
|
+
``EndpointDefinition`` the composition root hands to the client.
|
|
12
|
+
|
|
13
|
+
``VEHICLE_IDS_ROSTER`` is declared here, beside the feeder it describes:
|
|
14
|
+
the roster names this module's endpoint and its frame column, which is
|
|
15
|
+
provider-specific knowledge that belongs in the provider leaf. Unlike the
|
|
16
|
+
endpoint factory it needs no config, so it is a frozen constant -- and a
|
|
17
|
+
public one deliberately: ``build_roster_registry`` discovers public
|
|
18
|
+
module-level ``RosterDefinition`` constants in the same walk that finds
|
|
19
|
+
``build_endpoint``, so declaring the constant IS the registration
|
|
20
|
+
(no hand-maintained list exists to drift).
|
|
21
|
+
The include-inactive guarantee binds at the feeder population, not at
|
|
22
|
+
eviction policy: ``/v1/vehicles`` lists inactive and retired vehicles, so
|
|
23
|
+
a fan-out over this roster covers vehicles that were active during a
|
|
24
|
+
historical window even if they are inactive today.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from datetime import timedelta
|
|
28
|
+
from typing import Final
|
|
29
|
+
|
|
30
|
+
from fleetpull.config import MotiveConfig
|
|
31
|
+
from fleetpull.endpoints.shared import (
|
|
32
|
+
EndpointDefinition,
|
|
33
|
+
SnapshotMode,
|
|
34
|
+
StaticGetSpecBuilder,
|
|
35
|
+
StorageKind,
|
|
36
|
+
)
|
|
37
|
+
from fleetpull.models.motive import Vehicle
|
|
38
|
+
from fleetpull.network.decoders import MotiveWrappedListPageDecoder
|
|
39
|
+
from fleetpull.roster import RosterDefinition, RosterKey
|
|
40
|
+
from fleetpull.vocabulary import Provider, QuotaScope
|
|
41
|
+
|
|
42
|
+
__all__: list[str] = ['VEHICLE_IDS_ROSTER', 'build_endpoint']
|
|
43
|
+
|
|
44
|
+
_VEHICLES_PATH: Final[str] = '/v1/vehicles'
|
|
45
|
+
_VEHICLES_LIST_KEY: Final[str] = 'vehicles'
|
|
46
|
+
_VEHICLES_ITEM_KEY: Final[str] = 'vehicle'
|
|
47
|
+
|
|
48
|
+
# The fleet's membership changes on the order of days, so a daily re-list
|
|
49
|
+
# keeps the roster current without spending a full vehicles listing on
|
|
50
|
+
# every sync.
|
|
51
|
+
_VEHICLE_IDS_MAX_AGE: Final[timedelta] = timedelta(days=1)
|
|
52
|
+
|
|
53
|
+
# Eviction hysteresis (DESIGN §3): vehicle ids are permanent, absent-means-
|
|
54
|
+
# empty keys, so eviction is an efficiency lever (stop fanning over
|
|
55
|
+
# long-retired vehicles), not a correctness one. Three consecutive absent
|
|
56
|
+
# listings tolerate a transient provider omission before dropping a member.
|
|
57
|
+
_VEHICLE_IDS_EVICTION_THRESHOLD: Final[int] = 3
|
|
58
|
+
|
|
59
|
+
# The Motive vehicle_ids roster: fed by this module's vehicles listing, read
|
|
60
|
+
# by the vehicle_locations fan-out (which carries only the RosterKey).
|
|
61
|
+
VEHICLE_IDS_ROSTER: RosterDefinition = RosterDefinition(
|
|
62
|
+
key=RosterKey(Provider.MOTIVE, 'vehicle_ids'),
|
|
63
|
+
source_endpoint='vehicles',
|
|
64
|
+
source_column='vehicle_id',
|
|
65
|
+
max_age=_VEHICLE_IDS_MAX_AGE,
|
|
66
|
+
eviction_threshold=_VEHICLE_IDS_EVICTION_THRESHOLD,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def build_endpoint(config: MotiveConfig) -> EndpointDefinition[Vehicle]:
|
|
71
|
+
"""Build the Motive vehicles snapshot binding.
|
|
72
|
+
|
|
73
|
+
A full-dataset snapshot of the fleet's vehicles: no resume, a single
|
|
74
|
+
parquet file, full replacement each run. Records arrive wrapped
|
|
75
|
+
(``{"vehicles": [{"vehicle": {...}}]}``) under page-numbered
|
|
76
|
+
pagination, at the page size the config requests.
|
|
77
|
+
|
|
78
|
+
Only the pagination parameters are sent. The endpoint's optional
|
|
79
|
+
filters -- ``driver_ids[]``, ``fuel_type``, ``updated_after``, and the
|
|
80
|
+
``X-Time-Zone`` / ``X-Metric-Units`` / ``X-User-Id`` header params --
|
|
81
|
+
are deliberately not wired yet.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
config: The validated Motive configuration; supplies the base URL
|
|
85
|
+
the spec-builder joins to the vehicles path and the page size
|
|
86
|
+
the decoder requests.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The frozen vehicles ``EndpointDefinition``.
|
|
90
|
+
"""
|
|
91
|
+
return EndpointDefinition(
|
|
92
|
+
provider=Provider.MOTIVE,
|
|
93
|
+
name='vehicles',
|
|
94
|
+
spec_builder=StaticGetSpecBuilder(
|
|
95
|
+
base_url=config.base_url, path=_VEHICLES_PATH
|
|
96
|
+
),
|
|
97
|
+
page_decoder=MotiveWrappedListPageDecoder(
|
|
98
|
+
list_key=_VEHICLES_LIST_KEY,
|
|
99
|
+
item_key=_VEHICLES_ITEM_KEY,
|
|
100
|
+
per_page=config.records_per_page,
|
|
101
|
+
),
|
|
102
|
+
response_model=Vehicle,
|
|
103
|
+
quota_scope=QuotaScope.MOTIVE,
|
|
104
|
+
storage_kind=StorageKind.SINGLE,
|
|
105
|
+
sync_mode=SnapshotMode(),
|
|
106
|
+
)
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/registry.py
|
|
2
|
+
"""The endpoint catalog: ``EndpointRegistry`` and the discovery walk.
|
|
3
|
+
|
|
4
|
+
``EndpointRegistry`` is a dumb, immutable map from ``(provider, name)`` to the
|
|
5
|
+
endpoint's ``EndpointDefinition``. It answers ``get(provider, name)``; a duplicate
|
|
6
|
+
key at construction is a wiring bug and raises ``ConfigurationError``. It knows
|
|
7
|
+
nothing about providers, discovery, or config -- it is handed a bag of definitions
|
|
8
|
+
and indexes them.
|
|
9
|
+
|
|
10
|
+
``build_endpoint_registry`` is the one place endpoints are enumerated. It discovers
|
|
11
|
+
every endpoint leaf by walking the ``endpoints.<provider>`` packages (skipping
|
|
12
|
+
``shared``) for modules exposing a ``build_endpoint`` factory, injects each factory's
|
|
13
|
+
provider config by matching the factory's annotated config type against the supplied
|
|
14
|
+
configs, calls it, and indexes the results. Adding an endpoint is adding one leaf
|
|
15
|
+
module: no manifest, no registration, no provider list here.
|
|
16
|
+
|
|
17
|
+
``build_roster_registry`` is its sibling over the same walk: rosters are declared
|
|
18
|
+
as public module-level ``RosterDefinition`` constants beside their feeders, in
|
|
19
|
+
exactly the leaf modules the endpoint walk visits, so they are discovered the
|
|
20
|
+
same way -- no hand-maintained registration list, no per-provider export
|
|
21
|
+
to drift. Adding a roster is declaring one constant in its feeder's
|
|
22
|
+
module.
|
|
23
|
+
|
|
24
|
+
Discovery reaches the leaf modules dynamically rather than through each provider
|
|
25
|
+
package's face. That is a deliberate, named exception to the clause-3 face-routing
|
|
26
|
+
rule the import-discipline test enforces: the walk depends only on the
|
|
27
|
+
``build_endpoint`` contract, not on any specific module, so it is generic enumeration
|
|
28
|
+
rather than static coupling. The replacement guardrail is the structural contract
|
|
29
|
+
test (``tests/endpoints/test_endpoint_contract.py``): every endpoint leaf must expose
|
|
30
|
+
a callable ``build_endpoint`` taking one ``ProviderConfig`` subclass, so a typo or a
|
|
31
|
+
missing factory fails loudly there rather than silently vanishing from the catalog.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import importlib
|
|
35
|
+
import pkgutil
|
|
36
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
37
|
+
from types import ModuleType
|
|
38
|
+
from typing import cast, get_type_hints
|
|
39
|
+
|
|
40
|
+
import fleetpull.endpoints
|
|
41
|
+
from fleetpull.config import ProviderConfig
|
|
42
|
+
from fleetpull.endpoints.shared import EndpointDefinition
|
|
43
|
+
from fleetpull.exceptions import ConfigurationError
|
|
44
|
+
from fleetpull.model_contract import ResponseModel
|
|
45
|
+
from fleetpull.roster import RosterDefinition, RosterRegistry
|
|
46
|
+
from fleetpull.vocabulary import Provider
|
|
47
|
+
|
|
48
|
+
__all__: list[str] = [
|
|
49
|
+
'EndpointRegistry',
|
|
50
|
+
'build_endpoint_registry',
|
|
51
|
+
'build_roster_registry',
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
_FACTORY_NAME: str = 'build_endpoint'
|
|
55
|
+
_SHARED_PACKAGE: str = 'shared'
|
|
56
|
+
|
|
57
|
+
_EndpointFactory = Callable[[ProviderConfig], EndpointDefinition[ResponseModel]]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class EndpointRegistry:
|
|
61
|
+
"""An immutable catalog mapping ``(provider, name)`` to its definition.
|
|
62
|
+
|
|
63
|
+
Built once from a bag of definitions, it answers ``get(provider, name)``. The
|
|
64
|
+
map is private and frozen at construction; a duplicate ``(provider, name)`` is a
|
|
65
|
+
wiring bug and raises. It holds no provider knowledge and does no discovery.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
definitions: The endpoint definitions to catalog; their ``(provider, name)``
|
|
69
|
+
keys must be distinct.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ConfigurationError: Two definitions share a ``(provider, name)`` key.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self, definitions: Iterable[EndpointDefinition[ResponseModel]]
|
|
77
|
+
) -> None:
|
|
78
|
+
by_key: dict[tuple[Provider, str], EndpointDefinition[ResponseModel]] = {}
|
|
79
|
+
for definition in definitions:
|
|
80
|
+
key = (definition.provider, definition.name)
|
|
81
|
+
if key in by_key:
|
|
82
|
+
raise ConfigurationError(
|
|
83
|
+
'duplicate endpoint definition',
|
|
84
|
+
provider=definition.provider.value,
|
|
85
|
+
endpoint=definition.name,
|
|
86
|
+
detail=f'endpoint {definition.name!r} is defined twice',
|
|
87
|
+
)
|
|
88
|
+
by_key[key] = definition
|
|
89
|
+
self._by_key = by_key
|
|
90
|
+
|
|
91
|
+
def get(self, provider: Provider, name: str) -> EndpointDefinition[ResponseModel]:
|
|
92
|
+
"""Return the definition for a ``(provider, name)`` key.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
provider: The endpoint's provider.
|
|
96
|
+
name: The endpoint's name (e.g. ``'vehicles'``).
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
The endpoint's definition.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
ConfigurationError: No definition is registered for the key -- a consumer
|
|
103
|
+
references an endpoint the catalog does not declare.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
return self._by_key[(provider, name)]
|
|
107
|
+
except KeyError:
|
|
108
|
+
raise ConfigurationError(
|
|
109
|
+
'unknown endpoint',
|
|
110
|
+
provider=provider.value,
|
|
111
|
+
endpoint=name,
|
|
112
|
+
detail=f'no endpoint definition registered for {name!r}',
|
|
113
|
+
) from None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_endpoint_registry(configs: Iterable[ProviderConfig]) -> EndpointRegistry:
|
|
117
|
+
"""Discover every endpoint leaf, build its definition, and catalog them.
|
|
118
|
+
|
|
119
|
+
Walks the ``endpoints.<provider>`` packages for ``build_endpoint`` factories,
|
|
120
|
+
injects each factory's annotated provider config from ``configs`` (matched by
|
|
121
|
+
exact type), calls it, and returns the populated registry.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
configs: The resolved provider config instances, one per provider whose
|
|
125
|
+
endpoints should be built. Matched to each factory by the factory's
|
|
126
|
+
annotated parameter type.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
The populated ``EndpointRegistry``.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
ConfigurationError: A leaf exposes no callable ``build_endpoint``, a factory's
|
|
133
|
+
parameter is not exactly one ``ProviderConfig`` subclass, or no supplied
|
|
134
|
+
config matches a factory's annotated type.
|
|
135
|
+
"""
|
|
136
|
+
config_by_type: dict[type[ProviderConfig], ProviderConfig] = {
|
|
137
|
+
type(config): config for config in configs
|
|
138
|
+
}
|
|
139
|
+
definitions: list[EndpointDefinition[ResponseModel]] = []
|
|
140
|
+
for module_name in _iter_endpoint_leaf_modules():
|
|
141
|
+
module = importlib.import_module(module_name)
|
|
142
|
+
factory = _required_factory(module, module_name)
|
|
143
|
+
config = _config_for_factory(factory, module_name, config_by_type)
|
|
144
|
+
definitions.append(factory(config))
|
|
145
|
+
return EndpointRegistry(definitions)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def build_roster_registry() -> RosterRegistry:
|
|
149
|
+
"""Discover every declared roster and catalog them.
|
|
150
|
+
|
|
151
|
+
The sibling of ``build_endpoint_registry``, sharing the same leaf walk: a
|
|
152
|
+
roster is a public module-level ``RosterDefinition`` constant declared
|
|
153
|
+
beside its feeder, so it is discovered rather than hand-listed. Takes no
|
|
154
|
+
configs -- roster declarations are constants, not factories.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
The populated ``RosterRegistry``.
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
ConfigurationError: Two collected definitions share a ``RosterKey``
|
|
161
|
+
(e.g. a constant re-exported into a second leaf module), from
|
|
162
|
+
``RosterRegistry`` construction.
|
|
163
|
+
"""
|
|
164
|
+
definitions: list[RosterDefinition] = []
|
|
165
|
+
for module_name in _iter_endpoint_leaf_modules():
|
|
166
|
+
module = importlib.import_module(module_name)
|
|
167
|
+
definitions.extend(_module_roster_definitions(module))
|
|
168
|
+
return RosterRegistry(definitions)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _module_roster_definitions(module: ModuleType) -> list[RosterDefinition]:
|
|
172
|
+
"""The public module-level roster declarations of one endpoint leaf.
|
|
173
|
+
|
|
174
|
+
Only non-underscore names register: an underscore-prefixed definition is
|
|
175
|
+
file-private by the naming rule and stays out of the catalog.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
module: The imported leaf module.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
The module's declared ``RosterDefinition`` constants, in definition
|
|
182
|
+
order (module namespace order).
|
|
183
|
+
"""
|
|
184
|
+
return [
|
|
185
|
+
value
|
|
186
|
+
for name, value in vars(module).items()
|
|
187
|
+
if not name.startswith('_') and isinstance(value, RosterDefinition)
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _iter_endpoint_leaf_modules() -> Iterator[str]:
|
|
192
|
+
"""Yield the dotted name of every endpoint leaf module.
|
|
193
|
+
|
|
194
|
+
Walks the ``endpoints`` package for provider subpackages (skipping ``shared`` and
|
|
195
|
+
any non-package), then each provider package for leaf modules (skipping
|
|
196
|
+
``__init__``, which ``iter_modules`` omits, and any ``_``-prefixed private
|
|
197
|
+
module). Every yielded module is an endpoint leaf obligated to expose
|
|
198
|
+
``build_endpoint``.
|
|
199
|
+
"""
|
|
200
|
+
root_path = fleetpull.endpoints.__path__
|
|
201
|
+
root_name = fleetpull.endpoints.__name__
|
|
202
|
+
for provider in pkgutil.iter_modules(root_path):
|
|
203
|
+
if not provider.ispkg or provider.name == _SHARED_PACKAGE:
|
|
204
|
+
continue
|
|
205
|
+
package_name = f'{root_name}.{provider.name}'
|
|
206
|
+
package = importlib.import_module(package_name)
|
|
207
|
+
for leaf in pkgutil.iter_modules(package.__path__):
|
|
208
|
+
if leaf.ispkg or leaf.name.startswith('_'):
|
|
209
|
+
continue
|
|
210
|
+
yield f'{package_name}.{leaf.name}'
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _required_factory(module: ModuleType, module_name: str) -> _EndpointFactory:
|
|
214
|
+
"""Return the module's ``build_endpoint``, or raise if it is missing.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
module: The imported leaf module.
|
|
218
|
+
module_name: Its dotted name, for the error.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
The module's ``build_endpoint`` callable.
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
ConfigurationError: The module exposes no callable ``build_endpoint``.
|
|
225
|
+
"""
|
|
226
|
+
factory = getattr(module, _FACTORY_NAME, None)
|
|
227
|
+
if not callable(factory):
|
|
228
|
+
raise ConfigurationError(
|
|
229
|
+
'endpoint leaf missing build_endpoint',
|
|
230
|
+
detail=f'{module_name!r} must expose a callable {_FACTORY_NAME!r}',
|
|
231
|
+
)
|
|
232
|
+
return cast(_EndpointFactory, factory)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _config_for_factory(
|
|
236
|
+
factory: _EndpointFactory,
|
|
237
|
+
module_name: str,
|
|
238
|
+
config_by_type: dict[type[ProviderConfig], ProviderConfig],
|
|
239
|
+
) -> ProviderConfig:
|
|
240
|
+
"""Resolve the config instance a factory's annotation asks for.
|
|
241
|
+
|
|
242
|
+
Inspects the factory's single parameter, requires its type to be a
|
|
243
|
+
``ProviderConfig`` subclass, and returns the matching supplied instance.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
factory: The leaf's ``build_endpoint`` callable.
|
|
247
|
+
module_name: Its dotted name, for errors.
|
|
248
|
+
config_by_type: The supplied configs, keyed by exact type.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
The config instance matching the factory's annotated parameter type.
|
|
252
|
+
|
|
253
|
+
Raises:
|
|
254
|
+
ConfigurationError: The factory does not take exactly one parameter, its
|
|
255
|
+
parameter is not a ``ProviderConfig`` subclass, or no supplied config
|
|
256
|
+
matches its annotated type.
|
|
257
|
+
"""
|
|
258
|
+
hints = get_type_hints(factory)
|
|
259
|
+
hints.pop('return', None)
|
|
260
|
+
if len(hints) != 1:
|
|
261
|
+
raise ConfigurationError(
|
|
262
|
+
'endpoint factory arity',
|
|
263
|
+
detail=f'{module_name!r} build_endpoint must take exactly one '
|
|
264
|
+
f'annotated parameter',
|
|
265
|
+
)
|
|
266
|
+
(annotation,) = hints.values()
|
|
267
|
+
if not (isinstance(annotation, type) and issubclass(annotation, ProviderConfig)):
|
|
268
|
+
raise ConfigurationError(
|
|
269
|
+
'endpoint factory parameter type',
|
|
270
|
+
detail=f'{module_name!r} build_endpoint must annotate a '
|
|
271
|
+
f'ProviderConfig subclass',
|
|
272
|
+
)
|
|
273
|
+
try:
|
|
274
|
+
return config_by_type[annotation]
|
|
275
|
+
except KeyError:
|
|
276
|
+
raise ConfigurationError(
|
|
277
|
+
'no config supplied for endpoint',
|
|
278
|
+
detail=f'{module_name!r} requires {annotation.__name__} but no '
|
|
279
|
+
f'instance was supplied',
|
|
280
|
+
) from None
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/samsara/__init__.py
|
|
2
|
+
"""The Samsara endpoints package.
|
|
3
|
+
|
|
4
|
+
A provider package under the endpoints layer. It exposes no factory gather:
|
|
5
|
+
endpoint leaves are discovered by ``build_endpoint_registry`` walking this
|
|
6
|
+
package for modules exposing ``build_endpoint``, so a new Samsara endpoint is
|
|
7
|
+
a new leaf module here with nothing to register. Import a specific factory
|
|
8
|
+
from its leaf module (``fleetpull.endpoints.samsara.vehicles``) when one is
|
|
9
|
+
needed directly.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__all__: list[str] = []
|