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,427 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/geotab/_requests.py
|
|
2
|
+
"""The shared GeoTab JSON-RPC request machinery for the endpoint leaves.
|
|
3
|
+
|
|
4
|
+
One module carries everything a GeoTab leaf composes on the request side
|
|
5
|
+
(renamed from ``_get_requests`` when the ``GetFeed`` builder joined,
|
|
6
|
+
2026-07-21): the JSON-RPC POST envelope (every request is a POST to
|
|
7
|
+
``https://{server}/apiv1``), the snapshot seek-walk builder, the
|
|
8
|
+
windowed builder with its per-type sort declaration, the ``GetFeed``
|
|
9
|
+
seed-or-resume builder every feed leaf shares, the ``server_host``
|
|
10
|
+
resolver, and the ``GetCountOfCheck`` truth instrument. Underscore-
|
|
11
|
+
prefixed so the registry walk skips it: this module is machinery, not an
|
|
12
|
+
endpoint leaf.
|
|
13
|
+
|
|
14
|
+
The probe provenance the shapes rest on:
|
|
15
|
+
|
|
16
|
+
- Plain ``Get`` silently hard-caps at 5,000 records with no continuation
|
|
17
|
+
signal; a captured ``GetCountOf`` above the cap proved records beyond
|
|
18
|
+
5,000 are invisible to bare ``Get``.
|
|
19
|
+
- Id-sortability is PER-TYPE, never assumed: ``Device`` supports it
|
|
20
|
+
(captured 2026-07-09), ``User`` supports it (re-proven 2026-07-16),
|
|
21
|
+
``ExceptionEvent`` rejects it outright (captured 2026-07-15:
|
|
22
|
+
``ArgumentException``, "Can not sort by id") -- and any sort composed
|
|
23
|
+
with a search degrades to the deterministic ``-32000
|
|
24
|
+
GenericException`` for that type.
|
|
25
|
+
- The sorted first-request shape is the probed one: ``sort`` inside
|
|
26
|
+
``params`` with ``sortBy: id``, ``sortDirection: asc``, and an
|
|
27
|
+
EXPLICIT null ``offset`` -- never an absent key. ``lastId`` is never
|
|
28
|
+
written (probe-settled decision; the docs name it an
|
|
29
|
+
``ArgumentException`` beside id-sort).
|
|
30
|
+
- ``GetFeed`` rides the same JSON-RPC POST but is its OWN method class
|
|
31
|
+
(its own ~60/min rate budget; the 2026-07-21 header-decrement probe).
|
|
32
|
+
Seeding via ``search.fromDate`` on the tokenless first call is
|
|
33
|
+
wire-proven (DESIGN section 4 carries the docs-falsified record);
|
|
34
|
+
resuming sends ``fromVersion`` and never ``search``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from collections.abc import Mapping
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from typing import Final
|
|
40
|
+
|
|
41
|
+
from fleetpull.config import DEFAULT_GEOTAB_SERVER, GeotabConfig
|
|
42
|
+
from fleetpull.endpoints.shared import (
|
|
43
|
+
ResumeValue,
|
|
44
|
+
require_date_window,
|
|
45
|
+
require_feed_resume,
|
|
46
|
+
)
|
|
47
|
+
from fleetpull.incremental import DateWindow, FeedSeed, FeedToken
|
|
48
|
+
from fleetpull.network.contract import (
|
|
49
|
+
EnvelopeFetcher,
|
|
50
|
+
HttpMethod,
|
|
51
|
+
RequestSpec,
|
|
52
|
+
StrictEnvelopeSlice,
|
|
53
|
+
validated_envelope_slice,
|
|
54
|
+
)
|
|
55
|
+
from fleetpull.timing import to_iso8601
|
|
56
|
+
from fleetpull.vocabulary import JsonValue
|
|
57
|
+
|
|
58
|
+
__all__: list[str] = [
|
|
59
|
+
'GeotabGetFeedSpecBuilder',
|
|
60
|
+
'GeotabGetSpecBuilder',
|
|
61
|
+
'GeotabWindowedGetSpecBuilder',
|
|
62
|
+
'GetCountOfCheck',
|
|
63
|
+
'server_host',
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
# The JSON-RPC ingress path every GeoTab method POSTs to.
|
|
67
|
+
_API_PATH: Final[str] = '/apiv1'
|
|
68
|
+
|
|
69
|
+
# Wire-protocol tokens: module-private Final constants, colocated with
|
|
70
|
+
# the strategies that emit them (the constants-scope precedent;
|
|
71
|
+
# deliberately unshared with the decoder module's own copies).
|
|
72
|
+
_METHOD_KEY: Final[str] = 'method'
|
|
73
|
+
_PARAMS_KEY: Final[str] = 'params'
|
|
74
|
+
_TYPE_NAME_KEY: Final[str] = 'typeName'
|
|
75
|
+
_SEARCH_KEY: Final[str] = 'search'
|
|
76
|
+
_FROM_DATE_KEY: Final[str] = 'fromDate'
|
|
77
|
+
_TO_DATE_KEY: Final[str] = 'toDate'
|
|
78
|
+
_RESULTS_LIMIT_KEY: Final[str] = 'resultsLimit'
|
|
79
|
+
_SORT_KEY: Final[str] = 'sort'
|
|
80
|
+
_SORT_BY_KEY: Final[str] = 'sortBy'
|
|
81
|
+
_SORT_DIRECTION_KEY: Final[str] = 'sortDirection'
|
|
82
|
+
_OFFSET_KEY: Final[str] = 'offset'
|
|
83
|
+
_FROM_VERSION_KEY: Final[str] = 'fromVersion'
|
|
84
|
+
_GET_METHOD: Final[str] = 'Get'
|
|
85
|
+
_GET_COUNT_OF_METHOD: Final[str] = 'GetCountOf'
|
|
86
|
+
_GET_FEED_METHOD: Final[str] = 'GetFeed'
|
|
87
|
+
_ID_SORT: Final[str] = 'id'
|
|
88
|
+
_ASCENDING: Final[str] = 'asc'
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def server_host(config: GeotabConfig) -> str:
|
|
92
|
+
"""The authentication host the spec URLs are built on.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
config: The validated GeoTab configuration.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
``auth.server`` when a credential is configured; the placeholder
|
|
99
|
+
default otherwise (a credential-less config still builds every
|
|
100
|
+
discovered leaf -- the registry walk requires it -- but can never
|
|
101
|
+
fetch, so the placeholder never reaches the wire; the session
|
|
102
|
+
strategy retargets every prepared request to the resolved host).
|
|
103
|
+
|
|
104
|
+
Side Effects:
|
|
105
|
+
None.
|
|
106
|
+
"""
|
|
107
|
+
if config.auth is not None:
|
|
108
|
+
return config.auth.server
|
|
109
|
+
# Pre-auth placeholder host for a default-constructed (credential-less)
|
|
110
|
+
# config; the session strategy retargets every prepared request, so no
|
|
111
|
+
# request ever leaves for this host un-retargeted.
|
|
112
|
+
return DEFAULT_GEOTAB_SERVER
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _post_spec(server: str, json_body: dict[str, JsonValue]) -> RequestSpec:
|
|
116
|
+
"""The JSON-RPC POST envelope every GeoTab method rides.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
server: The pre-auth authentication host.
|
|
120
|
+
json_body: The credential-less JSON-RPC body to send.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
A credential-less POST for ``https://{server}/apiv1``.
|
|
124
|
+
"""
|
|
125
|
+
return RequestSpec(
|
|
126
|
+
method=HttpMethod.POST,
|
|
127
|
+
url=f'https://{server}{_API_PATH}',
|
|
128
|
+
json_body=json_body,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _get_json_body(
|
|
133
|
+
type_name: str,
|
|
134
|
+
results_limit: int,
|
|
135
|
+
window: DateWindow | None,
|
|
136
|
+
id_sort: bool,
|
|
137
|
+
) -> dict[str, JsonValue]:
|
|
138
|
+
"""Assemble a ``Get`` body from the leaf's declared shape.
|
|
139
|
+
|
|
140
|
+
``params`` composes ``typeName`` always; the window as
|
|
141
|
+
``search.fromDate`` / ``search.toDate`` (UTC ISO-8601 ``Z`` strings)
|
|
142
|
+
when one is given; ``resultsLimit`` always; and the probed ``sort``
|
|
143
|
+
member (``sortBy: id``, ``sortDirection: asc``, EXPLICIT null
|
|
144
|
+
``offset``) only when the walk is id-sorted.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
type_name: The GeoTab entity name (``'Device'``, ``'Trip'``).
|
|
148
|
+
results_limit: The per-request record limit.
|
|
149
|
+
window: The resume window riding ``search``; ``None`` for a
|
|
150
|
+
snapshot (no search member at all).
|
|
151
|
+
id_sort: Whether the probed ``sort`` member is written.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
The credential-less JSON-RPC ``Get`` body.
|
|
155
|
+
"""
|
|
156
|
+
params: dict[str, JsonValue] = {_TYPE_NAME_KEY: type_name}
|
|
157
|
+
if window is not None:
|
|
158
|
+
params[_SEARCH_KEY] = {
|
|
159
|
+
_FROM_DATE_KEY: to_iso8601(window.start),
|
|
160
|
+
_TO_DATE_KEY: to_iso8601(window.end),
|
|
161
|
+
}
|
|
162
|
+
params[_RESULTS_LIMIT_KEY] = results_limit
|
|
163
|
+
if id_sort:
|
|
164
|
+
params[_SORT_KEY] = {
|
|
165
|
+
_SORT_BY_KEY: _ID_SORT,
|
|
166
|
+
_SORT_DIRECTION_KEY: _ASCENDING,
|
|
167
|
+
_OFFSET_KEY: None,
|
|
168
|
+
}
|
|
169
|
+
return {_METHOD_KEY: _GET_METHOD, _PARAMS_KEY: params}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True, slots=True)
|
|
173
|
+
class GeotabGetSpecBuilder:
|
|
174
|
+
"""Build the snapshot seek walk's first ``Get`` request.
|
|
175
|
+
|
|
176
|
+
Sort is intrinsic here, not declared: a seek walk without id-sort is
|
|
177
|
+
meaningless -- termination and completeness both ride the
|
|
178
|
+
id-ascending advance (the probed shape's provenance is the module
|
|
179
|
+
docstring). Every request after this one is the decoder's.
|
|
180
|
+
|
|
181
|
+
``build_spec`` rejects any non-``None`` resume: a snapshot always
|
|
182
|
+
resumes from nothing, so a window reaching this builder is a wiring
|
|
183
|
+
bug that would otherwise silently fetch the entire entity set
|
|
184
|
+
unwindowed.
|
|
185
|
+
|
|
186
|
+
Attributes:
|
|
187
|
+
server: The pre-auth authentication host (retargeted by the
|
|
188
|
+
session strategy after Authenticate).
|
|
189
|
+
type_name: The GeoTab entity to walk (``'Device'``, ``'User'``).
|
|
190
|
+
results_limit: The page size; 5000 -- the largest sound page
|
|
191
|
+
under the silent cap.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
server: str
|
|
195
|
+
type_name: str
|
|
196
|
+
results_limit: int
|
|
197
|
+
|
|
198
|
+
def build_spec(
|
|
199
|
+
self, resume: ResumeValue, member_values: Mapping[str, str]
|
|
200
|
+
) -> RequestSpec:
|
|
201
|
+
"""Build the walk's first request.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
resume: Must be ``None`` -- a snapshot resumes from nothing;
|
|
205
|
+
any other value is a wiring bug.
|
|
206
|
+
member_values: Accepted to satisfy the protocol; unused --
|
|
207
|
+
a single-chain endpoint binds no member.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
A credential-less JSON-RPC POST; ``params.credentials`` and
|
|
211
|
+
the resolved host are the session strategy's injections.
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
TypeError: ``resume`` is not ``None``.
|
|
215
|
+
"""
|
|
216
|
+
if resume is not None:
|
|
217
|
+
raise TypeError(
|
|
218
|
+
f'{type(self).__name__} requires a None resume -- a snapshot '
|
|
219
|
+
f'resumes from nothing; got {type(resume).__name__}.'
|
|
220
|
+
)
|
|
221
|
+
return _post_spec(
|
|
222
|
+
self.server,
|
|
223
|
+
_get_json_body(
|
|
224
|
+
type_name=self.type_name,
|
|
225
|
+
results_limit=self.results_limit,
|
|
226
|
+
window=None,
|
|
227
|
+
id_sort=True,
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(frozen=True, slots=True)
|
|
233
|
+
class GeotabWindowedGetSpecBuilder:
|
|
234
|
+
"""Build a watermark endpoint's windowed ``Get`` request.
|
|
235
|
+
|
|
236
|
+
The resume window rides ``search.fromDate`` / ``search.toDate``;
|
|
237
|
+
``id_sort`` declares whether the probed ``sort`` member is written
|
|
238
|
+
beside it. Id-sortability is a per-type PROBED provider capability,
|
|
239
|
+
never assumed (module docstring: Device yes 2026-07-09, User yes
|
|
240
|
+
2026-07-16, ExceptionEvent no 2026-07-15). ``id_sort=True`` selects
|
|
241
|
+
the seek-walk pairing (the decoder advances ``sort.offset``, and its
|
|
242
|
+
advance spreads the sent params so ``search`` survives every page --
|
|
243
|
+
live-verified 2026-07-13 on Trip); ``id_sort=False`` also selects
|
|
244
|
+
the single-shot/bisection pairing -- there is no page advance, the
|
|
245
|
+
driver re-invokes this builder per sub-window. The ExceptionEvent
|
|
246
|
+
probes found ``version`` and date fields sortable for that type --
|
|
247
|
+
banked as feed-design input, which is why this field may later widen
|
|
248
|
+
to a seek-key enum.
|
|
249
|
+
|
|
250
|
+
Attributes:
|
|
251
|
+
server: The pre-auth authentication host (retargeted by the
|
|
252
|
+
session strategy after Authenticate).
|
|
253
|
+
type_name: The GeoTab entity to fetch (``'Trip'``,
|
|
254
|
+
``'ExceptionEvent'``).
|
|
255
|
+
results_limit: The per-request record limit; for an unsorted
|
|
256
|
+
leaf it doubles as the bisection overflow threshold.
|
|
257
|
+
id_sort: Whether the probed ``sort`` member is written -- the
|
|
258
|
+
per-type declared capability.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
server: str
|
|
262
|
+
type_name: str
|
|
263
|
+
results_limit: int
|
|
264
|
+
id_sort: bool
|
|
265
|
+
|
|
266
|
+
def build_spec(
|
|
267
|
+
self, resume: ResumeValue, member_values: Mapping[str, str]
|
|
268
|
+
) -> RequestSpec:
|
|
269
|
+
"""Build one window's request.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
resume: The window to fetch. Must be a ``DateWindow`` -- a
|
|
273
|
+
watermark endpoint always resumes from one; any other
|
|
274
|
+
value is a wiring bug.
|
|
275
|
+
member_values: Accepted to satisfy the protocol; unused --
|
|
276
|
+
a single-chain endpoint binds no member.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
A credential-less JSON-RPC POST carrying the window as
|
|
280
|
+
``search.fromDate`` / ``search.toDate`` (UTC ISO-8601 ``Z``
|
|
281
|
+
strings; the half-open end passes verbatim -- the runner's
|
|
282
|
+
per-batch window filter owns the boundary), with the probed
|
|
283
|
+
``sort`` member iff ``id_sort``.
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
TypeError: ``resume`` is not a ``DateWindow``.
|
|
287
|
+
"""
|
|
288
|
+
window = require_date_window(resume, type(self).__name__)
|
|
289
|
+
return _post_spec(
|
|
290
|
+
self.server,
|
|
291
|
+
_get_json_body(
|
|
292
|
+
type_name=self.type_name,
|
|
293
|
+
results_limit=self.results_limit,
|
|
294
|
+
window=window,
|
|
295
|
+
id_sort=self.id_sort,
|
|
296
|
+
),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@dataclass(frozen=True, slots=True)
|
|
301
|
+
class GeotabGetFeedSpecBuilder:
|
|
302
|
+
"""Build a feed endpoint's first ``GetFeed`` request: seed or resume.
|
|
303
|
+
|
|
304
|
+
One builder serves every feed leaf; the leaf declares only its
|
|
305
|
+
``type_name`` and ``results_limit``. The resume value decides the
|
|
306
|
+
shape (the wire-proven pair, DESIGN section 4):
|
|
307
|
+
|
|
308
|
+
- ``FeedSeed`` -- the tokenless first run. ``search.fromDate`` carries
|
|
309
|
+
the cold-start anchor, positioning the feed at a version covering all
|
|
310
|
+
entities with date >= that instant; NO ``fromVersion`` is written.
|
|
311
|
+
Wire-proven to the second on LogRecord and StatusData (2026-07-21)
|
|
312
|
+
DESPITE the docs claiming those types' search is ignored.
|
|
313
|
+
- ``FeedToken`` -- every run after. ``fromVersion`` carries the stored
|
|
314
|
+
token; NO ``search`` is written (the decoder's advances strip it the
|
|
315
|
+
same way, so the pair of sites agree).
|
|
316
|
+
|
|
317
|
+
Every request after this one is the decoder's
|
|
318
|
+
(``GeotabFeedPageDecoder`` advances by ``toVersion`` and reads
|
|
319
|
+
``resultsLimit`` from the sent body, so builder-versus-decoder
|
|
320
|
+
divergence is structurally impossible).
|
|
321
|
+
|
|
322
|
+
Attributes:
|
|
323
|
+
server: The pre-auth authentication host (retargeted by the
|
|
324
|
+
session strategy after Authenticate).
|
|
325
|
+
type_name: The GeoTab entity to feed (``'LogRecord'``, ``'Trip'``).
|
|
326
|
+
results_limit: The page size; the short-page terminal rule and the
|
|
327
|
+
50,000 protocol maximum both key off it (per-type caps are the
|
|
328
|
+
leaf's declaration concern).
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
server: str
|
|
332
|
+
type_name: str
|
|
333
|
+
results_limit: int
|
|
334
|
+
|
|
335
|
+
def build_spec(
|
|
336
|
+
self, resume: ResumeValue, member_values: Mapping[str, str]
|
|
337
|
+
) -> RequestSpec:
|
|
338
|
+
"""Build the feed walk's first request.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
resume: The seed-or-token a feed run always carries -- a
|
|
342
|
+
``FeedSeed`` (first run) or ``FeedToken`` (every run
|
|
343
|
+
after); any other value is a wiring bug.
|
|
344
|
+
member_values: Accepted to satisfy the protocol; unused --
|
|
345
|
+
a single-chain endpoint binds no member.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
A credential-less JSON-RPC POST; ``params.credentials`` and
|
|
349
|
+
the resolved host are the session strategy's injections.
|
|
350
|
+
|
|
351
|
+
Raises:
|
|
352
|
+
TypeError: ``resume`` is neither a ``FeedSeed`` nor a
|
|
353
|
+
``FeedToken``.
|
|
354
|
+
ValueError: A seed ``start`` that is naive or non-UTC
|
|
355
|
+
(surfaced from the timing codec).
|
|
356
|
+
"""
|
|
357
|
+
feed_resume = require_feed_resume(resume, type(self).__name__)
|
|
358
|
+
params: dict[str, JsonValue] = {_TYPE_NAME_KEY: self.type_name}
|
|
359
|
+
match feed_resume:
|
|
360
|
+
case FeedSeed(start=start):
|
|
361
|
+
params[_SEARCH_KEY] = {_FROM_DATE_KEY: to_iso8601(start)}
|
|
362
|
+
case FeedToken(from_version=from_version):
|
|
363
|
+
params[_FROM_VERSION_KEY] = from_version
|
|
364
|
+
params[_RESULTS_LIMIT_KEY] = self.results_limit
|
|
365
|
+
return _post_spec(
|
|
366
|
+
self.server, {_METHOD_KEY: _GET_FEED_METHOD, _PARAMS_KEY: params}
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class _GetCountOfEnvelope(StrictEnvelopeSlice):
|
|
371
|
+
"""Envelope slice: ``GetCountOf`` returns the count under ``result``.
|
|
372
|
+
|
|
373
|
+
A ``StrictEnvelopeSlice``, so a stringly count fails loudly instead
|
|
374
|
+
of coercing and a boolean never passes as an integer.
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
result: int
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@dataclass(frozen=True, slots=True)
|
|
381
|
+
class GetCountOfCheck:
|
|
382
|
+
"""The GeoTab completeness check: ``GetCountOf`` as truth instrument.
|
|
383
|
+
|
|
384
|
+
Fires one ``GetCountOf`` JSON-RPC request for the harvested entity
|
|
385
|
+
through the same open client the harvest used -- session auth, the
|
|
386
|
+
limiter (one token on the given scope, the token-per-attempt law),
|
|
387
|
+
and the classifier all apply -- and reads the integer count through
|
|
388
|
+
a private envelope slice. Declared on the snapshot definitions so
|
|
389
|
+
the single-fetch driver can prove the capped ``Get`` walk lost
|
|
390
|
+
nothing (probe-settled decision).
|
|
391
|
+
|
|
392
|
+
Attributes:
|
|
393
|
+
server: The pre-auth authentication host (retargeted by the
|
|
394
|
+
session strategy, exactly as the data requests are).
|
|
395
|
+
type_name: The GeoTab entity to count (``'Device'``, ``'User'``).
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
server: str
|
|
399
|
+
type_name: str
|
|
400
|
+
|
|
401
|
+
def expected_count(self, client: EnvelopeFetcher, quota_scope: str) -> int:
|
|
402
|
+
"""Return GeoTab's reported count of the harvested entity.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
client: The open transport client the harvest ran on (its
|
|
406
|
+
single-request ``fetch_envelope`` surface).
|
|
407
|
+
quota_scope: The endpoint's rate-limit scope key
|
|
408
|
+
(``GEOTAB_GET`` -- the count spends from the same
|
|
409
|
+
method-class budget as the data pages).
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
The provider-reported entity count.
|
|
413
|
+
|
|
414
|
+
Raises:
|
|
415
|
+
ProviderResponseError: The envelope's ``result`` is not an
|
|
416
|
+
integer (via the slice model), or the request failed
|
|
417
|
+
fatally (via the client).
|
|
418
|
+
"""
|
|
419
|
+
spec = _post_spec(
|
|
420
|
+
self.server,
|
|
421
|
+
{
|
|
422
|
+
_METHOD_KEY: _GET_COUNT_OF_METHOD,
|
|
423
|
+
_PARAMS_KEY: {_TYPE_NAME_KEY: self.type_name},
|
|
424
|
+
},
|
|
425
|
+
)
|
|
426
|
+
envelope = client.fetch_envelope(spec, quota_scope)
|
|
427
|
+
return validated_envelope_slice(_GetCountOfEnvelope, envelope).result
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/geotab/annotation_logs.py
|
|
2
|
+
"""The GeoTab annotation_logs binding: the duty-status-log annotation feed.
|
|
3
|
+
|
|
4
|
+
A ``GetFeed`` drive of the ``AnnotationLog`` entity — free-text
|
|
5
|
+
annotations attached to HOS duty-status logs, carrying a per-record
|
|
6
|
+
``version``. Annotations are user-editable, so re-emission under newer
|
|
7
|
+
versions is expected, every emitted version is stored, and the consumer
|
|
8
|
+
reconciles by ``(id, max version)`` (DESIGN §4). The annotation's
|
|
9
|
+
``dutyStatusLog`` ref points back to the ``duty_status_logs`` vertical
|
|
10
|
+
(``models/geotab/annotation_log.py``), completing the wave-two loop.
|
|
11
|
+
|
|
12
|
+
``resultsLimit`` is the 50,000 protocol maximum: the docs list no lower
|
|
13
|
+
per-type cap for this type (the SCALE census cannot probe a cap the
|
|
14
|
+
population never reaches — the FillUp dual-provenance lesson's other
|
|
15
|
+
arm, DESIGN §8), so the protocol maximum stands.
|
|
16
|
+
|
|
17
|
+
Every request here is a JSON-RPC POST whose ``params.credentials`` and
|
|
18
|
+
resolved host are the session strategy's injections (the devices-leaf
|
|
19
|
+
convention); the host this module writes is a pre-auth placeholder.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from typing import Final
|
|
23
|
+
|
|
24
|
+
from fleetpull.config import GeotabConfig
|
|
25
|
+
from fleetpull.endpoints.geotab._requests import GeotabGetFeedSpecBuilder, server_host
|
|
26
|
+
from fleetpull.endpoints.shared import EndpointDefinition, FeedMode, StorageKind
|
|
27
|
+
from fleetpull.models.geotab import AnnotationLog
|
|
28
|
+
from fleetpull.network.decoders import GeotabFeedPageDecoder
|
|
29
|
+
from fleetpull.vocabulary import Provider, QuotaScope
|
|
30
|
+
|
|
31
|
+
__all__: list[str] = ['build_endpoint']
|
|
32
|
+
|
|
33
|
+
# The GetFeed protocol maximum; no lower documented per-type cap.
|
|
34
|
+
_RESULTS_LIMIT: Final[int] = 50000
|
|
35
|
+
|
|
36
|
+
_ANNOTATION_LOG_TYPE_NAME: Final[str] = 'AnnotationLog'
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_endpoint(config: GeotabConfig) -> EndpointDefinition[AnnotationLog]:
|
|
40
|
+
"""Build the GeoTab annotation_logs feed binding.
|
|
41
|
+
|
|
42
|
+
Duty-status-log annotations fetched incrementally as a version-token
|
|
43
|
+
feed: the run resumes from the stored token (seeded via
|
|
44
|
+
``search.fromDate`` on the tokenless first run only), each page
|
|
45
|
+
appends durably before its ``toVersion`` commits, and the fetched
|
|
46
|
+
records land in ``date=YYYY-MM-DD`` partitions as new numbered part
|
|
47
|
+
files — re-emitted versions accumulate for the consumer's
|
|
48
|
+
``(id, max version)`` reconcile.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
config: The validated GeoTab configuration; supplies the
|
|
52
|
+
authentication host the pre-auth spec URLs are built on.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The frozen annotation_logs ``EndpointDefinition``. Construction
|
|
56
|
+
validates the ``FeedMode`` / ``APPEND_LOG`` /
|
|
57
|
+
``event_time_column`` triple against the response model.
|
|
58
|
+
"""
|
|
59
|
+
return EndpointDefinition(
|
|
60
|
+
provider=Provider.GEOTAB,
|
|
61
|
+
name='annotation_logs',
|
|
62
|
+
spec_builder=GeotabGetFeedSpecBuilder(
|
|
63
|
+
server=server_host(config),
|
|
64
|
+
type_name=_ANNOTATION_LOG_TYPE_NAME,
|
|
65
|
+
results_limit=_RESULTS_LIMIT,
|
|
66
|
+
),
|
|
67
|
+
page_decoder=GeotabFeedPageDecoder(),
|
|
68
|
+
response_model=AnnotationLog,
|
|
69
|
+
quota_scope=QuotaScope.GEOTAB_FEED,
|
|
70
|
+
storage_kind=StorageKind.APPEND_LOG,
|
|
71
|
+
sync_mode=FeedMode(),
|
|
72
|
+
event_time_column='date_time',
|
|
73
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/geotab/audits.py
|
|
2
|
+
"""The GeoTab audits binding: the configuration audit-trail feed.
|
|
3
|
+
|
|
4
|
+
A ``GetFeed`` drive of the ``Audit`` entity — audit-trail entries
|
|
5
|
+
carrying a per-record ``version``. Audit entries reconcile by
|
|
6
|
+
``(id, max version)`` (DESIGN §4). The simplest feed vertical: the
|
|
7
|
+
``Audit`` model carries no reference fields.
|
|
8
|
+
|
|
9
|
+
``resultsLimit`` is the 50,000 protocol maximum: the docs list no lower
|
|
10
|
+
per-type cap for this type (the SCALE census cannot probe a cap the
|
|
11
|
+
population never reaches — the FillUp dual-provenance lesson's other
|
|
12
|
+
arm, DESIGN §8), so the protocol maximum stands.
|
|
13
|
+
|
|
14
|
+
Every request here is a JSON-RPC POST whose ``params.credentials`` and
|
|
15
|
+
resolved host are the session strategy's injections (the devices-leaf
|
|
16
|
+
convention); the host this module writes is a pre-auth placeholder.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from typing import Final
|
|
20
|
+
|
|
21
|
+
from fleetpull.config import GeotabConfig
|
|
22
|
+
from fleetpull.endpoints.geotab._requests import GeotabGetFeedSpecBuilder, server_host
|
|
23
|
+
from fleetpull.endpoints.shared import EndpointDefinition, FeedMode, StorageKind
|
|
24
|
+
from fleetpull.models.geotab import Audit
|
|
25
|
+
from fleetpull.network.decoders import GeotabFeedPageDecoder
|
|
26
|
+
from fleetpull.vocabulary import Provider, QuotaScope
|
|
27
|
+
|
|
28
|
+
__all__: list[str] = ['build_endpoint']
|
|
29
|
+
|
|
30
|
+
# The GetFeed protocol maximum; no lower documented per-type cap.
|
|
31
|
+
_RESULTS_LIMIT: Final[int] = 50000
|
|
32
|
+
|
|
33
|
+
_AUDIT_TYPE_NAME: Final[str] = 'Audit'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_endpoint(config: GeotabConfig) -> EndpointDefinition[Audit]:
|
|
37
|
+
"""Build the GeoTab audits feed binding.
|
|
38
|
+
|
|
39
|
+
Audit-trail entries fetched incrementally as a version-token feed:
|
|
40
|
+
the run resumes from the stored token (seeded via ``search.fromDate``
|
|
41
|
+
on the tokenless first run only), each page appends durably before
|
|
42
|
+
its ``toVersion`` commits, and the fetched records land in
|
|
43
|
+
``date=YYYY-MM-DD`` partitions as new numbered part files —
|
|
44
|
+
re-emitted versions accumulate for the consumer's ``(id, max
|
|
45
|
+
version)`` reconcile.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
config: The validated GeoTab configuration; supplies the
|
|
49
|
+
authentication host the pre-auth spec URLs are built on.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The frozen audits ``EndpointDefinition``. Construction validates
|
|
53
|
+
the ``FeedMode`` / ``APPEND_LOG`` / ``event_time_column`` triple
|
|
54
|
+
against the response model.
|
|
55
|
+
"""
|
|
56
|
+
return EndpointDefinition(
|
|
57
|
+
provider=Provider.GEOTAB,
|
|
58
|
+
name='audits',
|
|
59
|
+
spec_builder=GeotabGetFeedSpecBuilder(
|
|
60
|
+
server=server_host(config),
|
|
61
|
+
type_name=_AUDIT_TYPE_NAME,
|
|
62
|
+
results_limit=_RESULTS_LIMIT,
|
|
63
|
+
),
|
|
64
|
+
page_decoder=GeotabFeedPageDecoder(),
|
|
65
|
+
response_model=Audit,
|
|
66
|
+
quota_scope=QuotaScope.GEOTAB_FEED,
|
|
67
|
+
storage_kind=StorageKind.APPEND_LOG,
|
|
68
|
+
sync_mode=FeedMode(),
|
|
69
|
+
event_time_column='date_time',
|
|
70
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/geotab/devices.py
|
|
2
|
+
"""The GeoTab devices binding: a factory composing the devices snapshot
|
|
3
|
+
EndpointDefinition from resolved GeoTab configuration.
|
|
4
|
+
|
|
5
|
+
A binding cannot be a module-level constant because its spec-builder and
|
|
6
|
+
completeness check need the run's configured authentication host, known
|
|
7
|
+
only after the YAML config loads; so the endpoint is a factory taking a
|
|
8
|
+
validated ``GeotabConfig`` and returning the frozen
|
|
9
|
+
``EndpointDefinition`` the composition root hands to the client (the
|
|
10
|
+
Motive leaf convention).
|
|
11
|
+
|
|
12
|
+
Every request here is a JSON-RPC POST to ``https://{server}/apiv1``
|
|
13
|
+
whose ``params.credentials`` are injected by the session auth strategy,
|
|
14
|
+
never built here; the strategy also retargets each prepared request to
|
|
15
|
+
the session's resolved host, so the host this module writes is a
|
|
16
|
+
pre-auth placeholder that never reaches the wire on its own (DESIGN
|
|
17
|
+
section 8). The seek walk and the ``GetCountOfCheck`` truth instrument
|
|
18
|
+
live in the shared ``_requests`` module (promoted when the users
|
|
19
|
+
leaf became their second consumer); this leaf binds them to ``Device``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from typing import Final
|
|
23
|
+
|
|
24
|
+
from fleetpull.config import GeotabConfig
|
|
25
|
+
from fleetpull.endpoints.geotab._requests import (
|
|
26
|
+
GeotabGetSpecBuilder,
|
|
27
|
+
GetCountOfCheck,
|
|
28
|
+
server_host,
|
|
29
|
+
)
|
|
30
|
+
from fleetpull.endpoints.shared import (
|
|
31
|
+
EndpointDefinition,
|
|
32
|
+
SnapshotMode,
|
|
33
|
+
StorageKind,
|
|
34
|
+
)
|
|
35
|
+
from fleetpull.models.geotab import Device
|
|
36
|
+
from fleetpull.network.decoders import GeotabGetPageDecoder
|
|
37
|
+
from fleetpull.vocabulary import Provider, QuotaScope
|
|
38
|
+
|
|
39
|
+
__all__: list[str] = ['build_endpoint']
|
|
40
|
+
|
|
41
|
+
# The largest sound page under Get's silent 5,000-record cap.
|
|
42
|
+
_RESULTS_LIMIT: Final[int] = 5000
|
|
43
|
+
|
|
44
|
+
_DEVICE_TYPE_NAME: Final[str] = 'Device'
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_endpoint(config: GeotabConfig) -> EndpointDefinition[Device]:
|
|
48
|
+
"""Build the GeoTab devices snapshot binding.
|
|
49
|
+
|
|
50
|
+
A full-listing snapshot of the account's Device entities (tracked
|
|
51
|
+
vehicles and trailer entries alike): no resume, a single parquet
|
|
52
|
+
file, full replacement each run. Records arrive as a plain list
|
|
53
|
+
under ``result``, walked by id-ascending seek pages under the
|
|
54
|
+
silent 5,000-record ``Get`` cap, and every harvest is verified
|
|
55
|
+
against ``GetCountOf`` before anything flows downstream.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
config: The validated GeoTab configuration; supplies the
|
|
59
|
+
authentication host the pre-auth spec URLs are built on.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The frozen devices ``EndpointDefinition``.
|
|
63
|
+
"""
|
|
64
|
+
server = server_host(config)
|
|
65
|
+
return EndpointDefinition(
|
|
66
|
+
provider=Provider.GEOTAB,
|
|
67
|
+
name='devices',
|
|
68
|
+
spec_builder=GeotabGetSpecBuilder(
|
|
69
|
+
server=server,
|
|
70
|
+
type_name=_DEVICE_TYPE_NAME,
|
|
71
|
+
results_limit=_RESULTS_LIMIT,
|
|
72
|
+
),
|
|
73
|
+
page_decoder=GeotabGetPageDecoder(),
|
|
74
|
+
response_model=Device,
|
|
75
|
+
quota_scope=QuotaScope.GEOTAB_GET,
|
|
76
|
+
storage_kind=StorageKind.SINGLE,
|
|
77
|
+
sync_mode=SnapshotMode(),
|
|
78
|
+
completeness_check=GetCountOfCheck(server=server, type_name=_DEVICE_TYPE_NAME),
|
|
79
|
+
)
|