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
fleetpull/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""fleetpull: Pull fleet telematics data from provider APIs into typed Polars output.
|
|
2
|
+
|
|
3
|
+
Retrieval, dtype coercion, and light structural normalization only. Output
|
|
4
|
+
stays as close to the raw API responses as is reasonable. No cross-endpoint
|
|
5
|
+
merging, no unified schema, no assumed end use.
|
|
6
|
+
|
|
7
|
+
The public data API: ``fetch(Endpoints.Motive.vehicles, auth=...)`` returns
|
|
8
|
+
an eager typed DataFrame, and ``Sync(config_path).run()`` executes a
|
|
9
|
+
config-driven parquet/state sync. Consumers catch ``FleetpullError`` or its
|
|
10
|
+
five public subclasses, all importable here; every other exception type is
|
|
11
|
+
internal.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from fleetpull.api import Endpoints, Sync, fetch
|
|
15
|
+
from fleetpull.exceptions import (
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
ConfigurationError,
|
|
18
|
+
FleetpullError,
|
|
19
|
+
ProviderResponseError,
|
|
20
|
+
RetriesExhaustedError,
|
|
21
|
+
SyncFailuresError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__version__: str = '0.1.0'
|
|
25
|
+
|
|
26
|
+
__all__: list[str] = [
|
|
27
|
+
'AuthenticationError',
|
|
28
|
+
'ConfigurationError',
|
|
29
|
+
'Endpoints',
|
|
30
|
+
'FleetpullError',
|
|
31
|
+
'ProviderResponseError',
|
|
32
|
+
'RetriesExhaustedError',
|
|
33
|
+
'Sync',
|
|
34
|
+
'SyncFailuresError',
|
|
35
|
+
'fetch',
|
|
36
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# src/fleetpull/api/__init__.py
|
|
2
|
+
"""The public data API: the ``Endpoints`` catalog, its identities, ``fetch``, and ``Sync``.
|
|
3
|
+
|
|
4
|
+
The top tier of the package vertical: it composes everything below
|
|
5
|
+
(config, endpoints, network, records, orchestrator) behind DESIGN §10's
|
|
6
|
+
two-verb surface. ``Sync``, the config-driven verb, composes the
|
|
7
|
+
orchestrator entry -- which is why this tier sits above ``orchestrator``,
|
|
8
|
+
not beside it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from fleetpull.api.auth_ingress import AuthInput
|
|
12
|
+
from fleetpull.api.catalog import Endpoints, available_endpoints
|
|
13
|
+
from fleetpull.api.fetch import fetch
|
|
14
|
+
from fleetpull.api.identity import (
|
|
15
|
+
EndpointIdentity,
|
|
16
|
+
FeedEndpoint,
|
|
17
|
+
SnapshotEndpoint,
|
|
18
|
+
WindowedEndpoint,
|
|
19
|
+
)
|
|
20
|
+
from fleetpull.api.sync import Sync
|
|
21
|
+
|
|
22
|
+
__all__: list[str] = [
|
|
23
|
+
'AuthInput',
|
|
24
|
+
'EndpointIdentity',
|
|
25
|
+
'Endpoints',
|
|
26
|
+
'FeedEndpoint',
|
|
27
|
+
'SnapshotEndpoint',
|
|
28
|
+
'Sync',
|
|
29
|
+
'WindowedEndpoint',
|
|
30
|
+
'available_endpoints',
|
|
31
|
+
'fetch',
|
|
32
|
+
]
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# src/fleetpull/api/auth_ingress.py
|
|
2
|
+
"""The auth ingress: the one public ``auth=`` shape, coerced immediately.
|
|
3
|
+
|
|
4
|
+
The public verbs take a single lax ``auth=`` value -- a bare string for
|
|
5
|
+
single-credential providers (Motive, Samsara), named fields (a plain
|
|
6
|
+
mapping or ``GeotabAuthConfig``) for GeoTab's four-field credential.
|
|
7
|
+
Tuples are rejected in both directions: the 1-tuple requires the
|
|
8
|
+
trailing-comma trap and the 4-tuple invites transposed fields discovered
|
|
9
|
+
only at auth-failure time (DESIGN §10). Ingress coerces every accepted
|
|
10
|
+
shape into the internal ``SecretStr``-carrying auth at this boundary, so
|
|
11
|
+
no bare secret survives past it -- not into a repr, an exception
|
|
12
|
+
message, or a log line.
|
|
13
|
+
|
|
14
|
+
Dispatch keys off the endpoint identity's provider, never off the auth
|
|
15
|
+
value's shape: a shape that happens to fit another provider is still a
|
|
16
|
+
``ConfigurationError`` here. The provider → (auth strategy, classifier)
|
|
17
|
+
knowledge lives in this module because it can live nowhere lower: the
|
|
18
|
+
ingress sits in the top tier and may import both ``config`` and
|
|
19
|
+
``network``, while ``config`` cannot import ``network``, so the mapping
|
|
20
|
+
cannot ride on provider configs.
|
|
21
|
+
|
|
22
|
+
Profile construction takes a ``ProviderProfileContext`` alongside the
|
|
23
|
+
identity and the credential: the composition-root collaborators a
|
|
24
|
+
provider's auth machinery draws on. Motive's static header needs none of
|
|
25
|
+
them and ignores the context; GeoTab's session stack consumes all three
|
|
26
|
+
(the authenticator's HTTP posture and limiter slot, the session
|
|
27
|
+
manager's clock). The context grows only when a provider's auth
|
|
28
|
+
machinery demands a new collaborator -- it is not a general-purpose bag.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from collections.abc import Mapping
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
|
|
34
|
+
from pydantic import SecretStr, ValidationError
|
|
35
|
+
|
|
36
|
+
from fleetpull.api.identity import EndpointIdentity
|
|
37
|
+
from fleetpull.config import GeotabAuthConfig, HttpConfig
|
|
38
|
+
from fleetpull.exceptions import ConfigurationError
|
|
39
|
+
from fleetpull.network.auth import (
|
|
40
|
+
GeotabSessionAuth,
|
|
41
|
+
GeotabSessionManager,
|
|
42
|
+
StaticHeaderAuth,
|
|
43
|
+
build_geotab_authenticator,
|
|
44
|
+
)
|
|
45
|
+
from fleetpull.network.classifiers import (
|
|
46
|
+
GeotabResponseClassifier,
|
|
47
|
+
MotiveResponseClassifier,
|
|
48
|
+
SamsaraResponseClassifier,
|
|
49
|
+
)
|
|
50
|
+
from fleetpull.network.client import ProviderProfile
|
|
51
|
+
from fleetpull.network.limits import RateLimiterRegistry
|
|
52
|
+
from fleetpull.timing import Clock
|
|
53
|
+
from fleetpull.vocabulary import Provider, QuotaScope
|
|
54
|
+
|
|
55
|
+
__all__: list[str] = ['AuthInput', 'ProviderProfileContext', 'build_provider_profile']
|
|
56
|
+
|
|
57
|
+
# The §10 auth union. SecretStr is accepted alongside the bare string so
|
|
58
|
+
# the config path (Sync) hands its already-wrapped credential straight
|
|
59
|
+
# through -- the raw value never needs unwrapping just to be re-wrapped
|
|
60
|
+
# at the boundary. GeoTab's arms carry the four-field credential whole.
|
|
61
|
+
type AuthInput = str | SecretStr | Mapping[str, str] | GeotabAuthConfig
|
|
62
|
+
|
|
63
|
+
# Provider knowledge no consumer should type: the header a Motive
|
|
64
|
+
# static key travels in.
|
|
65
|
+
_MOTIVE_AUTH_HEADER: str = 'X-API-Key'
|
|
66
|
+
|
|
67
|
+
# Samsara's bearer credential: the header and the value prefix. The
|
|
68
|
+
# ingress pre-formats 'Bearer <token>' before wrapping in SecretStr --
|
|
69
|
+
# StaticHeaderAuth documents pre-formatting as the composition root's
|
|
70
|
+
# job, and this is that site.
|
|
71
|
+
_SAMSARA_AUTH_HEADER: str = 'Authorization'
|
|
72
|
+
_SAMSARA_BEARER_PREFIX: str = 'Bearer '
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class ProviderProfileContext:
|
|
77
|
+
"""Composition-root collaborators provider profile construction draws on.
|
|
78
|
+
|
|
79
|
+
Grows only when a provider's auth machinery demands a new
|
|
80
|
+
collaborator; this is not a general-purpose bag.
|
|
81
|
+
|
|
82
|
+
Attributes:
|
|
83
|
+
http_config: Timeouts and TLS posture for auth-side HTTP calls.
|
|
84
|
+
limiter_registry: The shared registry; auth calls take limiter
|
|
85
|
+
slots like any other request (token-per-attempt).
|
|
86
|
+
clock: The run's shared clock (session-age timestamping). Required
|
|
87
|
+
-- no default -- because clock identity matters (the one-clock
|
|
88
|
+
rule): ``Sync`` passes its shared clock, ``fetch`` constructs
|
|
89
|
+
one at the profile-build site.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
http_config: HttpConfig
|
|
93
|
+
limiter_registry: RateLimiterRegistry
|
|
94
|
+
clock: Clock
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_provider_profile(
|
|
98
|
+
endpoint: EndpointIdentity, auth: AuthInput, context: ProviderProfileContext
|
|
99
|
+
) -> ProviderProfile:
|
|
100
|
+
"""Coerce the public ``auth=`` value into the provider's client profile.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
endpoint: The identity whose provider selects the credential
|
|
104
|
+
shape and classifier; the auth value's own shape never
|
|
105
|
+
drives dispatch.
|
|
106
|
+
auth: The public credential value. Motive and Samsara each
|
|
107
|
+
require a bare API-key/token string; GeoTab requires a
|
|
108
|
+
``GeotabAuthConfig`` or a mapping with its named fields.
|
|
109
|
+
context: The composition-root collaborators (HTTP posture,
|
|
110
|
+
limiter registry, clock) a provider's auth machinery draws
|
|
111
|
+
on; the static-key providers ignore it, GeoTab consumes it.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
The ``ProviderProfile`` (``SecretStr``-carrying auth strategy
|
|
115
|
+
plus response classifier) the transport client is built on.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
ConfigurationError: ``auth``'s shape mismatches the endpoint's
|
|
119
|
+
provider (naming the expected shape and the provider, never
|
|
120
|
+
the value).
|
|
121
|
+
"""
|
|
122
|
+
match endpoint.provider:
|
|
123
|
+
case Provider.MOTIVE:
|
|
124
|
+
if not isinstance(auth, str | SecretStr):
|
|
125
|
+
raise ConfigurationError(
|
|
126
|
+
'auth shape mismatch',
|
|
127
|
+
provider=endpoint.provider.value,
|
|
128
|
+
endpoint=endpoint.name,
|
|
129
|
+
detail=(
|
|
130
|
+
f'Motive auth is a bare API-key string (or SecretStr); '
|
|
131
|
+
f'got {type(auth).__name__}'
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
secret = auth if isinstance(auth, SecretStr) else SecretStr(auth)
|
|
135
|
+
return ProviderProfile(
|
|
136
|
+
auth=StaticHeaderAuth(_MOTIVE_AUTH_HEADER, secret),
|
|
137
|
+
classifier=MotiveResponseClassifier(),
|
|
138
|
+
)
|
|
139
|
+
case Provider.GEOTAB:
|
|
140
|
+
credential = _coerced_geotab_credential(endpoint, auth)
|
|
141
|
+
authenticate_fn = build_geotab_authenticator(
|
|
142
|
+
context.http_config,
|
|
143
|
+
context.limiter_registry,
|
|
144
|
+
QuotaScope.GEOTAB_AUTHENTICATE.value,
|
|
145
|
+
)
|
|
146
|
+
manager = GeotabSessionManager(credential, authenticate_fn, context.clock)
|
|
147
|
+
return ProviderProfile(
|
|
148
|
+
auth=GeotabSessionAuth(manager),
|
|
149
|
+
classifier=GeotabResponseClassifier(),
|
|
150
|
+
)
|
|
151
|
+
case Provider.SAMSARA:
|
|
152
|
+
if not isinstance(auth, str | SecretStr):
|
|
153
|
+
raise ConfigurationError(
|
|
154
|
+
'auth shape mismatch',
|
|
155
|
+
provider=endpoint.provider.value,
|
|
156
|
+
endpoint=endpoint.name,
|
|
157
|
+
detail=(
|
|
158
|
+
f'Samsara auth is a bare API-token string (or SecretStr); '
|
|
159
|
+
f'got {type(auth).__name__}'
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
raw_token = auth.get_secret_value() if isinstance(auth, SecretStr) else auth
|
|
163
|
+
bearer = SecretStr(f'{_SAMSARA_BEARER_PREFIX}{raw_token}')
|
|
164
|
+
return ProviderProfile(
|
|
165
|
+
auth=StaticHeaderAuth(_SAMSARA_AUTH_HEADER, bearer),
|
|
166
|
+
classifier=SamsaraResponseClassifier(),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _coerced_geotab_credential(
|
|
171
|
+
endpoint: EndpointIdentity, auth: AuthInput
|
|
172
|
+
) -> GeotabAuthConfig:
|
|
173
|
+
"""Coerce the GeoTab ``auth=`` shapes into the internal credential.
|
|
174
|
+
|
|
175
|
+
A ``GeotabAuthConfig`` passes through as-is; a plain mapping is
|
|
176
|
+
validated into one (Pydantic wraps the password into ``SecretStr``).
|
|
177
|
+
Every rejection names the provider and the expected shapes and the
|
|
178
|
+
received type -- never the value.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
endpoint: The GeoTab identity being fetched (error context).
|
|
182
|
+
auth: The public credential value.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
The ``SecretStr``-carrying credential.
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
ConfigurationError: The shape is neither accepted form, or the
|
|
189
|
+
mapping's fields fail credential validation.
|
|
190
|
+
"""
|
|
191
|
+
if isinstance(auth, GeotabAuthConfig):
|
|
192
|
+
return auth
|
|
193
|
+
shape_detail = (
|
|
194
|
+
'GeoTab auth is a GeotabAuthConfig or a mapping with '
|
|
195
|
+
'username/password/database and optional server; '
|
|
196
|
+
f'got {type(auth).__name__}'
|
|
197
|
+
)
|
|
198
|
+
if not isinstance(auth, Mapping):
|
|
199
|
+
raise ConfigurationError(
|
|
200
|
+
'auth shape mismatch',
|
|
201
|
+
provider=endpoint.provider.value,
|
|
202
|
+
endpoint=endpoint.name,
|
|
203
|
+
detail=shape_detail,
|
|
204
|
+
)
|
|
205
|
+
try:
|
|
206
|
+
return GeotabAuthConfig.model_validate(dict(auth))
|
|
207
|
+
except ValidationError as error:
|
|
208
|
+
field_names = ', '.join(
|
|
209
|
+
'.'.join(str(item) for item in entry['loc']) for entry in error.errors()
|
|
210
|
+
)
|
|
211
|
+
raise ConfigurationError(
|
|
212
|
+
'auth shape mismatch',
|
|
213
|
+
provider=endpoint.provider.value,
|
|
214
|
+
endpoint=endpoint.name,
|
|
215
|
+
detail=f'{shape_detail} (invalid fields: {field_names})',
|
|
216
|
+
) from None
|
fleetpull/api/catalog.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# src/fleetpull/api/catalog.py
|
|
2
|
+
"""The public ``Endpoints`` catalog: every exposed endpoint identity.
|
|
3
|
+
|
|
4
|
+
A static, committed module -- never codegen. Provider namespaces are
|
|
5
|
+
CapWords class-like containers (``Endpoints.Motive``, PEP 8's convention
|
|
6
|
+
for public class-like names); endpoint attributes are lowercase
|
|
7
|
+
identities typed snapshot, windowed, or feed, so the type checker
|
|
8
|
+
enforces ``fetch``'s snapshot-only exposure at the call site. The load-bearing
|
|
9
|
+
identity everywhere strings live stays the lowercase ``Provider`` value,
|
|
10
|
+
so the CapWords surface introduces no string drift (DESIGN §10).
|
|
11
|
+
|
|
12
|
+
The drift protection is the two-way parity discipline test
|
|
13
|
+
(``tests/api/test_catalog.py``) against ``build_endpoint_registry``:
|
|
14
|
+
every identity here must resolve in the discovery registry with a
|
|
15
|
+
matching mode, and every discovered endpoint must appear here with the
|
|
16
|
+
mode-matching identity type.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from fleetpull.api.identity import (
|
|
20
|
+
EndpointIdentity,
|
|
21
|
+
FeedEndpoint,
|
|
22
|
+
SnapshotEndpoint,
|
|
23
|
+
WindowedEndpoint,
|
|
24
|
+
)
|
|
25
|
+
from fleetpull.vocabulary import Provider
|
|
26
|
+
|
|
27
|
+
__all__: list[str] = ['Endpoints', 'available_endpoints']
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Endpoints:
|
|
31
|
+
"""Provider-namespaced catalog of every public endpoint identity.
|
|
32
|
+
|
|
33
|
+
The namespaces hold inert identities only -- the verbs stay flat and
|
|
34
|
+
provider-agnostic, so the catalog is organized by provider while the
|
|
35
|
+
behavior is not (DESIGN §10, the orchestrator-boundary principle).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
class Motive:
|
|
39
|
+
"""Motive endpoint identities."""
|
|
40
|
+
|
|
41
|
+
vehicles: SnapshotEndpoint = SnapshotEndpoint(Provider.MOTIVE, 'vehicles')
|
|
42
|
+
vehicle_locations: WindowedEndpoint = WindowedEndpoint(
|
|
43
|
+
Provider.MOTIVE, 'vehicle_locations'
|
|
44
|
+
)
|
|
45
|
+
driving_periods: WindowedEndpoint = WindowedEndpoint(
|
|
46
|
+
Provider.MOTIVE, 'driving_periods'
|
|
47
|
+
)
|
|
48
|
+
idle_events: WindowedEndpoint = WindowedEndpoint(Provider.MOTIVE, 'idle_events')
|
|
49
|
+
groups: SnapshotEndpoint = SnapshotEndpoint(Provider.MOTIVE, 'groups')
|
|
50
|
+
users: SnapshotEndpoint = SnapshotEndpoint(Provider.MOTIVE, 'users')
|
|
51
|
+
vehicle_utilizations: WindowedEndpoint = WindowedEndpoint(
|
|
52
|
+
Provider.MOTIVE, 'vehicle_utilizations'
|
|
53
|
+
)
|
|
54
|
+
driver_idle_rollups: WindowedEndpoint = WindowedEndpoint(
|
|
55
|
+
Provider.MOTIVE, 'driver_idle_rollups'
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
class Samsara:
|
|
59
|
+
"""Samsara endpoint identities."""
|
|
60
|
+
|
|
61
|
+
vehicles: SnapshotEndpoint = SnapshotEndpoint(Provider.SAMSARA, 'vehicles')
|
|
62
|
+
drivers: SnapshotEndpoint = SnapshotEndpoint(Provider.SAMSARA, 'drivers')
|
|
63
|
+
trips: WindowedEndpoint = WindowedEndpoint(Provider.SAMSARA, 'trips')
|
|
64
|
+
idling_events: WindowedEndpoint = WindowedEndpoint(
|
|
65
|
+
Provider.SAMSARA, 'idling_events'
|
|
66
|
+
)
|
|
67
|
+
addresses: SnapshotEndpoint = SnapshotEndpoint(Provider.SAMSARA, 'addresses')
|
|
68
|
+
engine_states: WindowedEndpoint = WindowedEndpoint(
|
|
69
|
+
Provider.SAMSARA, 'engine_states'
|
|
70
|
+
)
|
|
71
|
+
gps_readings: WindowedEndpoint = WindowedEndpoint(
|
|
72
|
+
Provider.SAMSARA, 'gps_readings'
|
|
73
|
+
)
|
|
74
|
+
odometer_readings: WindowedEndpoint = WindowedEndpoint(
|
|
75
|
+
Provider.SAMSARA, 'odometer_readings'
|
|
76
|
+
)
|
|
77
|
+
asset_locations: WindowedEndpoint = WindowedEndpoint(
|
|
78
|
+
Provider.SAMSARA, 'asset_locations'
|
|
79
|
+
)
|
|
80
|
+
driver_vehicle_assignments: WindowedEndpoint = WindowedEndpoint(
|
|
81
|
+
Provider.SAMSARA, 'driver_vehicle_assignments'
|
|
82
|
+
)
|
|
83
|
+
vehicle_fuel_energy_reports: WindowedEndpoint = WindowedEndpoint(
|
|
84
|
+
Provider.SAMSARA, 'vehicle_fuel_energy_reports'
|
|
85
|
+
)
|
|
86
|
+
driver_fuel_energy_reports: WindowedEndpoint = WindowedEndpoint(
|
|
87
|
+
Provider.SAMSARA, 'driver_fuel_energy_reports'
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
class Geotab:
|
|
91
|
+
"""GeoTab endpoint identities."""
|
|
92
|
+
|
|
93
|
+
devices: SnapshotEndpoint = SnapshotEndpoint(Provider.GEOTAB, 'devices')
|
|
94
|
+
users: SnapshotEndpoint = SnapshotEndpoint(Provider.GEOTAB, 'users')
|
|
95
|
+
trips: WindowedEndpoint = WindowedEndpoint(Provider.GEOTAB, 'trips')
|
|
96
|
+
exception_events: WindowedEndpoint = WindowedEndpoint(
|
|
97
|
+
Provider.GEOTAB, 'exception_events'
|
|
98
|
+
)
|
|
99
|
+
log_records: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'log_records')
|
|
100
|
+
status_data: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'status_data')
|
|
101
|
+
fill_ups: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'fill_ups')
|
|
102
|
+
fuel_and_energy_used: FeedEndpoint = FeedEndpoint(
|
|
103
|
+
Provider.GEOTAB, 'fuel_and_energy_used'
|
|
104
|
+
)
|
|
105
|
+
fuel_tax_details: FeedEndpoint = FeedEndpoint(
|
|
106
|
+
Provider.GEOTAB, 'fuel_tax_details'
|
|
107
|
+
)
|
|
108
|
+
fault_data: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'fault_data')
|
|
109
|
+
duty_status_logs: FeedEndpoint = FeedEndpoint(
|
|
110
|
+
Provider.GEOTAB, 'duty_status_logs'
|
|
111
|
+
)
|
|
112
|
+
driver_changes: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'driver_changes')
|
|
113
|
+
dvir_logs: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'dvir_logs')
|
|
114
|
+
annotation_logs: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'annotation_logs')
|
|
115
|
+
shipment_logs: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'shipment_logs')
|
|
116
|
+
audits: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'audits')
|
|
117
|
+
text_messages: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'text_messages')
|
|
118
|
+
media_files: FeedEndpoint = FeedEndpoint(Provider.GEOTAB, 'media_files')
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def available_endpoints() -> tuple[EndpointIdentity, ...]:
|
|
122
|
+
"""Enumerate the whole catalog -- its manifest, in declaration order.
|
|
123
|
+
|
|
124
|
+
Derived from the provider namespaces themselves: each namespace's
|
|
125
|
+
class ``vars()`` preserves declaration order, so the manifest is the
|
|
126
|
+
catalog read back, never a hand-kept repeat that could drift from it
|
|
127
|
+
(the parity test still proves both directions against discovery).
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Every identity the ``Endpoints`` catalog exposes -- Motive, then
|
|
131
|
+
Samsara, then GeoTab, each in declaration order.
|
|
132
|
+
"""
|
|
133
|
+
provider_namespaces = (Endpoints.Motive, Endpoints.Samsara, Endpoints.Geotab)
|
|
134
|
+
return tuple(
|
|
135
|
+
attribute
|
|
136
|
+
for namespace in provider_namespaces
|
|
137
|
+
for attribute in vars(namespace).values()
|
|
138
|
+
if isinstance(attribute, (SnapshotEndpoint, WindowedEndpoint, FeedEndpoint))
|
|
139
|
+
)
|
fleetpull/api/fetch.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# src/fleetpull/api/fetch.py
|
|
2
|
+
"""``fetch``: the snapshot-only, in-memory programmatic convenience verb.
|
|
3
|
+
|
|
4
|
+
The convenience charter (DESIGN §10): an endpoint identity, one
|
|
5
|
+
``auth=`` value, and almost nothing else, returning an eager typed
|
|
6
|
+
DataFrame with no state machinery -- no SQLite, no disk, no cursor, no
|
|
7
|
+
run ledger, no roster. ``fetch`` deliberately limits options; a caller
|
|
8
|
+
who wants windows, incremental resume, partitioned storage, or fan-out
|
|
9
|
+
is a sync user, not a fetch user with missing parameters.
|
|
10
|
+
|
|
11
|
+
Snapshot-only because the in-memory contract is only honest for
|
|
12
|
+
snapshots: a snapshot result is bounded by entity count, while a
|
|
13
|
+
windowed result grows with window width and fleet activity, unbounded by
|
|
14
|
+
anything the caller controls in memory. The exposure gate is the
|
|
15
|
+
``SnapshotEndpoint`` parameter type; a runtime guard backs it for the
|
|
16
|
+
audiences mypy never covers (notebooks foremost).
|
|
17
|
+
|
|
18
|
+
No member filtering, by design: ``fetch`` returns the endpoint's full
|
|
19
|
+
current listing. Filtering (by vehicle, by group, by status) presumes a
|
|
20
|
+
use case, and the scope refusal (§10) forbids exactly that presumption
|
|
21
|
+
-- consumers filter the returned frame themselves.
|
|
22
|
+
|
|
23
|
+
The composition is the state-free fetch trace (established by the
|
|
24
|
+
2026-07-06 pre-API audit):
|
|
25
|
+
provider configs from defaults → the discovery registry → the definition
|
|
26
|
+
by identity key → a ``ClientRuntime`` on internal defaults → the
|
|
27
|
+
auth-ingress profile (its ``ProviderProfileContext`` reusing the
|
|
28
|
+
runtime's HTTP posture and limiter registry, plus a clock held solely
|
|
29
|
+
for GeoTab session aging) → the shape-resolved request driver (the
|
|
30
|
+
shared ``resolve_request_driver`` seam with no roster source: the
|
|
31
|
+
stateless shapes a snapshot can declare -- single fetch, param sweep
|
|
32
|
+
-- resolve (a bisected window is watermark-only by construction and
|
|
33
|
+
never reaches this verb), a roster-backed shape (``RosterFanOut`` /
|
|
34
|
+
``BatchedRosterFanOut``) is refused loudly, and the
|
|
35
|
+
driver honors a declared completeness check, so a guarded snapshot is
|
|
36
|
+
verified on this verb exactly as under sync) → ``TransportClient`` →
|
|
37
|
+
``validate_records`` → ``models_to_dataframe``.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
import logging
|
|
41
|
+
|
|
42
|
+
import polars as pl
|
|
43
|
+
|
|
44
|
+
from fleetpull.api.auth_ingress import (
|
|
45
|
+
AuthInput,
|
|
46
|
+
ProviderProfileContext,
|
|
47
|
+
build_provider_profile,
|
|
48
|
+
)
|
|
49
|
+
from fleetpull.api.identity import FeedEndpoint, SnapshotEndpoint, WindowedEndpoint
|
|
50
|
+
from fleetpull.config import (
|
|
51
|
+
HttpConfig,
|
|
52
|
+
RetryConfig,
|
|
53
|
+
default_provider_sections,
|
|
54
|
+
)
|
|
55
|
+
from fleetpull.endpoints import build_endpoint_registry
|
|
56
|
+
from fleetpull.exceptions import ConfigurationError
|
|
57
|
+
from fleetpull.network.client import ClientRuntime, TransportClient
|
|
58
|
+
from fleetpull.network.limits import RateLimiterRegistry, rate_limits_from_configs
|
|
59
|
+
from fleetpull.orchestrator import FetchPoolRegistry, resolve_request_driver
|
|
60
|
+
from fleetpull.records import models_to_dataframe, validate_records
|
|
61
|
+
from fleetpull.timing import SystemClock
|
|
62
|
+
from fleetpull.vocabulary import JsonObject
|
|
63
|
+
|
|
64
|
+
__all__: list[str] = ['fetch']
|
|
65
|
+
|
|
66
|
+
logger = logging.getLogger(__name__)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# typing-justified: ingress guard; input unknowable by design; object forces narrowing
|
|
70
|
+
def _require_snapshot_identity(endpoint: object) -> None:
|
|
71
|
+
"""Reject a non-snapshot identity before any client construction.
|
|
72
|
+
|
|
73
|
+
The static gate (the ``SnapshotEndpoint`` parameter type) protects
|
|
74
|
+
type-checked callers; this guard is the same gate for the
|
|
75
|
+
convenience verb's unchecked audience. Typed on ``object`` so the
|
|
76
|
+
narrowing is real work to the type checker rather than a
|
|
77
|
+
statically-unreachable branch.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
endpoint: Whatever the caller passed as the endpoint identity.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
None when ``endpoint`` is a ``SnapshotEndpoint``.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ConfigurationError: ``endpoint`` is windowed- or feed-typed (naming
|
|
87
|
+
the endpoint and its mode) or is no catalog identity at all.
|
|
88
|
+
"""
|
|
89
|
+
if isinstance(endpoint, SnapshotEndpoint):
|
|
90
|
+
return
|
|
91
|
+
if isinstance(endpoint, WindowedEndpoint):
|
|
92
|
+
raise ConfigurationError(
|
|
93
|
+
'fetch is snapshot-only',
|
|
94
|
+
provider=endpoint.provider.value,
|
|
95
|
+
endpoint=endpoint.name,
|
|
96
|
+
detail=(
|
|
97
|
+
'this endpoint is windowed-mode; windowed retrieval is the '
|
|
98
|
+
'config-driven sync path, not a fetch option'
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
if isinstance(endpoint, FeedEndpoint):
|
|
102
|
+
raise ConfigurationError(
|
|
103
|
+
'fetch is snapshot-only',
|
|
104
|
+
provider=endpoint.provider.value,
|
|
105
|
+
endpoint=endpoint.name,
|
|
106
|
+
detail=(
|
|
107
|
+
'this endpoint is feed-mode; a feed is an unbounded version '
|
|
108
|
+
'stream with durable cursor state, retrieved only through '
|
|
109
|
+
'the config-driven sync path'
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
raise ConfigurationError(
|
|
113
|
+
'fetch requires a snapshot identity from the Endpoints catalog',
|
|
114
|
+
detail=f'got {type(endpoint).__name__}',
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def fetch(
|
|
119
|
+
endpoint: SnapshotEndpoint,
|
|
120
|
+
auth: AuthInput,
|
|
121
|
+
*,
|
|
122
|
+
use_truststore: bool = False,
|
|
123
|
+
) -> pl.DataFrame:
|
|
124
|
+
"""Fetch one snapshot endpoint's full current listing into a DataFrame.
|
|
125
|
+
|
|
126
|
+
End-to-end in memory: no SQLite, no disk, no cursor, no run ledger,
|
|
127
|
+
no roster. Anything beyond this surface -- windows, incremental
|
|
128
|
+
resume, partitioned storage, roster fan-out, member filtering -- is
|
|
129
|
+
the config-driven sync path's territory, not a missing parameter
|
|
130
|
+
here. The request driver is shape-resolved through the shared seam,
|
|
131
|
+
so every stateless request shape (single fetch, param sweep) is
|
|
132
|
+
served; a roster fan-out needs durable roster state and is refused.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
endpoint: A snapshot-typed identity from the ``Endpoints``
|
|
136
|
+
catalog (``Endpoints.Motive.vehicles``). Windowed identities
|
|
137
|
+
fail the type checker and, for unchecked callers, the
|
|
138
|
+
runtime guard.
|
|
139
|
+
auth: The provider credential. Motive and Samsara take a bare
|
|
140
|
+
API-key string; GeoTab takes named fields (a plain mapping
|
|
141
|
+
or a ``GeotabAuthConfig``). Coerced immediately into
|
|
142
|
+
``SecretStr``-carrying internals; the raw value never
|
|
143
|
+
appears in errors or logs.
|
|
144
|
+
use_truststore: Build TLS contexts from the operating system's
|
|
145
|
+
trust store -- required behind TLS-intercepting corporate
|
|
146
|
+
proxies. Default False, coerced into the identically named
|
|
147
|
+
``HttpConfig.use_truststore``. Timeouts and any further
|
|
148
|
+
transport posture are config-phase territory.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
An eager Polars DataFrame, dtype-coerced per the endpoint's
|
|
152
|
+
response model. Column order is deliberately unspecified. An
|
|
153
|
+
empty listing is a zero-row frame carrying the full typed schema
|
|
154
|
+
-- never ``None``, never a schemaless frame.
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
FleetpullError: Any operational failure -- always one of the
|
|
158
|
+
four public subclasses below; every other exception type is
|
|
159
|
+
internal and renameable.
|
|
160
|
+
ConfigurationError: The identity is not snapshot-typed, the auth
|
|
161
|
+
shape mismatches the endpoint's provider, the identity
|
|
162
|
+
resolves to no registered endpoint, or the endpoint declares
|
|
163
|
+
a roster-backed shape (``RosterFanOut`` /
|
|
164
|
+
``BatchedRosterFanOut``: roster state is sync territory;
|
|
165
|
+
fetch is stateless by contract).
|
|
166
|
+
AuthenticationError: The provider rejected the credential
|
|
167
|
+
unfixably.
|
|
168
|
+
RetriesExhaustedError: A retryable failure category exhausted
|
|
169
|
+
its attempt budget.
|
|
170
|
+
ProviderResponseError: A non-retryable or contract-violating
|
|
171
|
+
provider response, including a 200 whose body is not JSON
|
|
172
|
+
and records that fail model validation.
|
|
173
|
+
|
|
174
|
+
Scope: retrieval, dtype coercion, and light structural normalization
|
|
175
|
+
only -- no cross-endpoint joins, no unified schema, no assumed end
|
|
176
|
+
use (DESIGN §10).
|
|
177
|
+
"""
|
|
178
|
+
_require_snapshot_identity(endpoint)
|
|
179
|
+
provider_configs = default_provider_sections()
|
|
180
|
+
registry = build_endpoint_registry(list(provider_configs.values()))
|
|
181
|
+
definition = registry.get(endpoint.provider, endpoint.name)
|
|
182
|
+
runtime = ClientRuntime(
|
|
183
|
+
http_config=HttpConfig(use_truststore=use_truststore),
|
|
184
|
+
retry_config=RetryConfig(),
|
|
185
|
+
limiter_registry=RateLimiterRegistry(
|
|
186
|
+
rate_limits_from_configs(list(provider_configs.values()))
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
# fetch holds a clock solely for GeoTab session aging; the state-free
|
|
190
|
+
# trace otherwise needs none.
|
|
191
|
+
profile = build_provider_profile(
|
|
192
|
+
endpoint,
|
|
193
|
+
auth,
|
|
194
|
+
ProviderProfileContext(
|
|
195
|
+
http_config=runtime.http_config,
|
|
196
|
+
limiter_registry=runtime.limiter_registry,
|
|
197
|
+
clock=SystemClock(),
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
fetch_workers = {
|
|
201
|
+
endpoint.provider: (
|
|
202
|
+
provider_configs[endpoint.provider].rate_limit.max_concurrency
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
with FetchPoolRegistry(fetch_workers) as fetch_pools:
|
|
206
|
+
# The shared shape seam with no roster source: every stateless
|
|
207
|
+
# shape resolves; a roster-backed shape is refused loudly before any
|
|
208
|
+
# transport pool opens. The resolved driver owns the chain (spec
|
|
209
|
+
# build, page loop) and, when the definition declares a
|
|
210
|
+
# completeness check, the post-stream count verification -- fetch
|
|
211
|
+
# must not offer a weaker read of a guarded endpoint than sync
|
|
212
|
+
# does.
|
|
213
|
+
driver = resolve_request_driver(
|
|
214
|
+
definition, fetch_pools=fetch_pools, roster_members=None
|
|
215
|
+
)
|
|
216
|
+
with TransportClient(profile, runtime) as client:
|
|
217
|
+
raw_records: list[JsonObject] = [
|
|
218
|
+
record
|
|
219
|
+
for page in driver.record_batches(definition, client, None)
|
|
220
|
+
for record in page.records
|
|
221
|
+
]
|
|
222
|
+
logger.info(
|
|
223
|
+
'Fetched %d %s.%s records across the snapshot listing.',
|
|
224
|
+
len(raw_records),
|
|
225
|
+
endpoint.provider.value,
|
|
226
|
+
endpoint.name,
|
|
227
|
+
)
|
|
228
|
+
validated_models = validate_records(raw_records, definition.response_model)
|
|
229
|
+
return models_to_dataframe(validated_models, definition.response_model)
|