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,139 @@
|
|
|
1
|
+
# src/fleetpull/config/logger.py
|
|
2
|
+
"""Logging configuration model.
|
|
3
|
+
|
|
4
|
+
LoggerConfig is one section of fleetpull's user-provided YAML
|
|
5
|
+
configuration. Like every model in fleetpull.config, it validates user
|
|
6
|
+
input at the boundary; consuming code (fleetpull.logger.setup) receives
|
|
7
|
+
only validated, normalized values.
|
|
8
|
+
|
|
9
|
+
Level fields accept standard level names case-insensitively ('debug',
|
|
10
|
+
'INFO') or the standard integer levels (10, 20, 30, 40, 50) and are
|
|
11
|
+
normalized to integers at validation time. Booleans, nonstandard
|
|
12
|
+
integers, deprecated aliases (WARN, FATAL), and NOTSET are rejected.
|
|
13
|
+
``file_path`` normalizes through ``paths.resolve_path`` at validation
|
|
14
|
+
time, like every path field in the config layer.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from pydantic import field_validator
|
|
22
|
+
|
|
23
|
+
from fleetpull.config.base import ConfigModel
|
|
24
|
+
from fleetpull.paths import resolve_path
|
|
25
|
+
|
|
26
|
+
__all__: list[str] = ['LoggerConfig']
|
|
27
|
+
|
|
28
|
+
# Canonical mapping from level name to Python logging integer. The name
|
|
29
|
+
# set is fixed to the five standard levels on purpose: the deprecated
|
|
30
|
+
# aliases (WARN, FATAL) and NOTSET (level 0, "inherit from parent") are
|
|
31
|
+
# rejected so a YAML file reads the same as the logging documentation.
|
|
32
|
+
# The integer values are pulled from the stdlib rather than hardcoded.
|
|
33
|
+
_LEVEL_NAME_TO_INT: dict[str, int] = {
|
|
34
|
+
level_name: getattr(logging, level_name)
|
|
35
|
+
for level_name in ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Used to validate integer input and to render error messages that show
|
|
39
|
+
# both accepted forms.
|
|
40
|
+
_ALLOWED_LEVEL_INTS: frozenset[int] = frozenset(_LEVEL_NAME_TO_INT.values())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# typing-justified: mode='before' validator input is contractually arbitrary
|
|
44
|
+
def _coerce_log_level(raw_value: Any) -> int:
|
|
45
|
+
# ``Any``: receives the raw pre-validation value from a Pydantic
|
|
46
|
+
# ``mode='before'`` validator, which is contractually arbitrary —
|
|
47
|
+
# any YAML scalar the user might write. The isinstance chain below
|
|
48
|
+
# enumerates every accepted shape and rejects the rest.
|
|
49
|
+
"""
|
|
50
|
+
Convert a user-supplied log level (name or int) to a logging integer.
|
|
51
|
+
|
|
52
|
+
Accepts:
|
|
53
|
+
- A ``str`` naming one of DEBUG / INFO / WARNING / ERROR /
|
|
54
|
+
CRITICAL (case-insensitive, surrounding whitespace stripped).
|
|
55
|
+
- An ``int`` equal to one of the standard levels (10, 20, 30,
|
|
56
|
+
40, 50).
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
raw_value: The value supplied by the user.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The Python logging integer corresponding to ``raw_value``.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ValueError: If ``raw_value`` is a bool, a nonstandard integer,
|
|
66
|
+
an unrecognized name, or any other type. ``ValueError``
|
|
67
|
+
(rather than ``TypeError``) is used throughout so Pydantic
|
|
68
|
+
wraps the failure into a ``ValidationError`` attributed to
|
|
69
|
+
the offending field.
|
|
70
|
+
"""
|
|
71
|
+
if isinstance(raw_value, bool):
|
|
72
|
+
# bool is a subclass of int; reject explicitly so True does not
|
|
73
|
+
# quietly become log level 1.
|
|
74
|
+
raise ValueError('log level must be a level name or integer, got bool')
|
|
75
|
+
|
|
76
|
+
if isinstance(raw_value, int):
|
|
77
|
+
if raw_value not in _ALLOWED_LEVEL_INTS:
|
|
78
|
+
allowed_pairs: str = ', '.join(
|
|
79
|
+
f'{name}={value}' for name, value in _LEVEL_NAME_TO_INT.items()
|
|
80
|
+
)
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f'integer {raw_value} is not a standard log level; '
|
|
83
|
+
f'allowed: {allowed_pairs}'
|
|
84
|
+
)
|
|
85
|
+
return raw_value
|
|
86
|
+
|
|
87
|
+
if isinstance(raw_value, str):
|
|
88
|
+
normalized_name: str = raw_value.strip().upper()
|
|
89
|
+
if normalized_name not in _LEVEL_NAME_TO_INT:
|
|
90
|
+
allowed_names: str = ', '.join(_LEVEL_NAME_TO_INT)
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f'{raw_value!r} is not a recognized log level; '
|
|
93
|
+
f'allowed: {allowed_names} (case-insensitive)'
|
|
94
|
+
)
|
|
95
|
+
return _LEVEL_NAME_TO_INT[normalized_name]
|
|
96
|
+
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f'log level must be a level name or integer, got {type(raw_value).__name__}'
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class LoggerConfig(ConfigModel):
|
|
103
|
+
"""
|
|
104
|
+
User-facing logging configuration.
|
|
105
|
+
|
|
106
|
+
Attributes:
|
|
107
|
+
console_level: Minimum level for stderr console output. Accepts
|
|
108
|
+
a standard level name or integer; normalized to int.
|
|
109
|
+
Defaults to INFO.
|
|
110
|
+
file_path: Path to a log file, normalized through
|
|
111
|
+
``paths.resolve_path`` at validation. When None (the
|
|
112
|
+
default), file logging is disabled and ``file_level`` is
|
|
113
|
+
inert.
|
|
114
|
+
file_level: Minimum level for file output. Required with a
|
|
115
|
+
default of DEBUG so there is never a None to narrow at the
|
|
116
|
+
use site; it simply has no effect while ``file_path`` is
|
|
117
|
+
None.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
console_level: int = logging.INFO
|
|
121
|
+
file_path: Path | None = None
|
|
122
|
+
file_level: int = logging.DEBUG
|
|
123
|
+
|
|
124
|
+
@field_validator('console_level', 'file_level', mode='before')
|
|
125
|
+
@classmethod
|
|
126
|
+
# typing-justified: mode='before' validator input is contractually arbitrary
|
|
127
|
+
def _coerce_level(cls, raw_value: Any) -> int:
|
|
128
|
+
# ``Any``: mode='before' validators receive arbitrary
|
|
129
|
+
# pre-validation input; _coerce_log_level enumerates the
|
|
130
|
+
# accepted shapes. No field label is threaded through —
|
|
131
|
+
# Pydantic's ValidationError attributes the failure to the
|
|
132
|
+
# correct field via its loc.
|
|
133
|
+
return _coerce_log_level(raw_value)
|
|
134
|
+
|
|
135
|
+
@field_validator('file_path')
|
|
136
|
+
@classmethod
|
|
137
|
+
def _resolve(cls, value: Path | None) -> Path | None:
|
|
138
|
+
"""Normalize the path lexically when present; ``None`` passes through."""
|
|
139
|
+
return None if value is None else resolve_path(value)
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
# src/fleetpull/config/providers.py
|
|
2
|
+
"""The provider config family: the shared base, the sections, the container.
|
|
3
|
+
|
|
4
|
+
One model family per file (house rule): ``ProviderConfig`` (the
|
|
5
|
+
per-provider contract), the concrete provider sections (``MotiveConfig``,
|
|
6
|
+
``GeotabConfig``, ``SamsaraConfig``), and ``ProvidersConfig`` (the
|
|
7
|
+
``providers:`` YAML container) evolve together.
|
|
8
|
+
|
|
9
|
+
The family also owns two provider facts consumed above the models:
|
|
10
|
+
``PROVIDER_CREDENTIAL_ENV_VARS`` (the conventional per-provider
|
|
11
|
+
credential environment variables the loading step merges from) and
|
|
12
|
+
``require_provider_credentials`` (the enablement invariant the root
|
|
13
|
+
config enforces at validation: endpoints listed with no credential is a
|
|
14
|
+
``ConfigurationError``).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from typing import ClassVar
|
|
19
|
+
|
|
20
|
+
from pydantic import Field, SecretStr, field_validator
|
|
21
|
+
|
|
22
|
+
from fleetpull.config.base import ConfigModel
|
|
23
|
+
from fleetpull.config.geotab import GeotabAuthConfig
|
|
24
|
+
from fleetpull.config.rate_limit import RateLimitConfig
|
|
25
|
+
from fleetpull.exceptions import ConfigurationError
|
|
26
|
+
from fleetpull.vocabulary import Provider, QuotaScope
|
|
27
|
+
|
|
28
|
+
__all__: list[str] = [
|
|
29
|
+
'PROVIDER_CREDENTIAL_ENV_VARS',
|
|
30
|
+
'GeotabConfig',
|
|
31
|
+
'MotiveConfig',
|
|
32
|
+
'ProviderConfig',
|
|
33
|
+
'ProvidersConfig',
|
|
34
|
+
'SamsaraConfig',
|
|
35
|
+
'default_provider_sections',
|
|
36
|
+
'require_provider_credentials',
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# The conventional credential environment variable per provider -- the
|
|
40
|
+
# fallback the loading step merges when the YAML key is absent (a YAML
|
|
41
|
+
# literal wins). The mapping is asymmetric by credential shape: Motive's
|
|
42
|
+
# variable supplies the WHOLE credential (`api_key`); GeoTab's supplies
|
|
43
|
+
# the PASSWORD FIELD only (username, database, and server always come
|
|
44
|
+
# from the YAML `auth` section -- they are not secrets). New providers
|
|
45
|
+
# add their entry as they port.
|
|
46
|
+
PROVIDER_CREDENTIAL_ENV_VARS: Mapping[str, str] = {
|
|
47
|
+
'motive': 'MOTIVE_API_KEY',
|
|
48
|
+
'geotab': 'GEOTAB_PASSWORD',
|
|
49
|
+
'samsara': 'SAMSARA_API_KEY',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _validated_base_url(value: str) -> str:
|
|
54
|
+
"""Reject a schemeless URL and drop any trailing slash.
|
|
55
|
+
|
|
56
|
+
The shared per-field check behind the ``base_url`` validators of the
|
|
57
|
+
static-key providers (Motive, Samsara) -- generic URL hygiene, not
|
|
58
|
+
provider semantics, so sharing it couples nothing.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
value: The configured base URL.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The base URL with no trailing slash.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ValueError: When the URL carries no http(s) scheme.
|
|
68
|
+
"""
|
|
69
|
+
if not value.startswith(('http://', 'https://')):
|
|
70
|
+
raise ValueError('base_url must start with http:// or https://')
|
|
71
|
+
return value.rstrip('/')
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# The watermark-window defaults every provider section shares: a week
|
|
75
|
+
# of late-arrival margin, no trailing-edge holdback. Declared once here
|
|
76
|
+
# because the knobs are part of the per-provider contract on
|
|
77
|
+
# ``ProviderConfig``; per-provider YAML keys and a declared ``sync``
|
|
78
|
+
# value still override (provider key > sync key > default; the
|
|
79
|
+
# precedence lives in ``config/resolution.py``).
|
|
80
|
+
_DEFAULT_LOOKBACK_DAYS: int = 7
|
|
81
|
+
_DEFAULT_CUTOFF_DAYS: int = 0
|
|
82
|
+
|
|
83
|
+
_MOTIVE_DEFAULT_BASE_URL: str = 'https://api.gomotive.com'
|
|
84
|
+
_MOTIVE_MAX_RECORDS_PER_PAGE: int = 100
|
|
85
|
+
|
|
86
|
+
# Conservative default budget for the Motive scope. Motive's real published
|
|
87
|
+
# per-key limits remain unverified (DESIGN §13 open question; the documented
|
|
88
|
+
# /vehicle_locations limit was not observed to enforce, §8), so this default
|
|
89
|
+
# is the diagnostic's proven-safe posture: the live full-fleet fan-out ran
|
|
90
|
+
# under these values without a single 429. Tighten or raise once the real
|
|
91
|
+
# limits are pinned by probing.
|
|
92
|
+
_MOTIVE_DEFAULT_RATE_LIMIT: RateLimitConfig = RateLimitConfig(
|
|
93
|
+
requests_per_period=60, period_seconds=60.0, burst=10, max_concurrency=2
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ProviderConfig(ConfigModel):
|
|
98
|
+
"""Base for per-provider configuration sections.
|
|
99
|
+
|
|
100
|
+
Subclassed once per provider (``MotiveConfig``, ...). Carries the
|
|
101
|
+
per-provider contract: each subclass binds its ``quota_scope`` and
|
|
102
|
+
``credential_hint``, exposes its credential shape through the
|
|
103
|
+
``credential`` property, and
|
|
104
|
+
defaults its ``rate_limit``; the shared watermark window knobs
|
|
105
|
+
(``lookback_days``, ``cutoff_days``) are declared here once for
|
|
106
|
+
every provider; the model policy itself comes from ``ConfigModel``.
|
|
107
|
+
|
|
108
|
+
Attributes:
|
|
109
|
+
quota_scope: The quota scope this provider's budget governs. A
|
|
110
|
+
``ClassVar`` bound by each subclass -- provider identity is a
|
|
111
|
+
code fact, not user configuration; a subclass that forgets to
|
|
112
|
+
bind it fails loudly on first attribute access.
|
|
113
|
+
credential_hint: Where this provider's credential comes from -- the
|
|
114
|
+
YAML field and the conventional environment variable -- phrased
|
|
115
|
+
to complete the enablement error's ``no credential resolves;
|
|
116
|
+
set <hint>`` sentence. A ``ClassVar`` bound by each subclass,
|
|
117
|
+
like ``quota_scope``.
|
|
118
|
+
rate_limit: The token-bucket budget for this provider's scope.
|
|
119
|
+
Each subclass supplies its own documented default.
|
|
120
|
+
endpoints: The endpoint names this provider syncs, as listed in
|
|
121
|
+
the YAML section. Strings here -- validation against the
|
|
122
|
+
endpoint catalog happens at ``Sync`` construction, above this
|
|
123
|
+
tier, never in ``config`` -- but duplicates are rejected at
|
|
124
|
+
validation (a duplicated name would run twice, concurrently).
|
|
125
|
+
Default empty; a provider with no endpoints is disabled
|
|
126
|
+
regardless of its credential.
|
|
127
|
+
lookback_days: Late-arrival re-fetch margin in whole days for
|
|
128
|
+
watermark endpoints -- how far before the stored watermark
|
|
129
|
+
each resume re-fetches, so a record that landed after its
|
|
130
|
+
event-time day is recovered and its partitions replaced.
|
|
131
|
+
Optional per-provider YAML key; when absent, root-level
|
|
132
|
+
resolution fans in a declared ``sync.lookback_days``, else
|
|
133
|
+
this documented default stands (provider key > sync key >
|
|
134
|
+
default). Non-negative; zero means no margin beyond the
|
|
135
|
+
watermark's own date.
|
|
136
|
+
cutoff_days: Trailing-edge holdback in whole days for watermark
|
|
137
|
+
endpoints -- how far the resume window's end is held back
|
|
138
|
+
from the clock, so a still-arriving day is never frozen as a
|
|
139
|
+
complete partition. The complement of ``lookback_days``:
|
|
140
|
+
both express the same provider data-latency concern from
|
|
141
|
+
opposite ends, and both carry the same per-provider-key >
|
|
142
|
+
``sync``-key > default precedence. Optional; defaults to 0.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
quota_scope: ClassVar[QuotaScope]
|
|
146
|
+
credential_hint: ClassVar[str]
|
|
147
|
+
|
|
148
|
+
rate_limit: RateLimitConfig
|
|
149
|
+
endpoints: tuple[str, ...] = ()
|
|
150
|
+
lookback_days: int = Field(default=_DEFAULT_LOOKBACK_DAYS, ge=0)
|
|
151
|
+
cutoff_days: int = Field(default=_DEFAULT_CUTOFF_DAYS, ge=0)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def credential(self) -> SecretStr | GeotabAuthConfig | None:
|
|
155
|
+
"""The provider's configured credential, or ``None`` when absent.
|
|
156
|
+
|
|
157
|
+
Each section binds its own credential shape (a bare ``SecretStr``
|
|
158
|
+
key for the static-key providers, GeoTab's four-field ``auth``
|
|
159
|
+
section whole) behind this one property, so the enablement guard,
|
|
160
|
+
the disabled-provider warning, and ``Sync``'s credential read all
|
|
161
|
+
stay provider-agnostic loops.
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
NotImplementedError: The subclass failed to bind its credential
|
|
165
|
+
shape -- a definition bug surfaced on first read.
|
|
166
|
+
"""
|
|
167
|
+
raise NotImplementedError(
|
|
168
|
+
f'{type(self).__name__} must define its credential property'
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def scope_rate_limits(self) -> dict[str, RateLimitConfig]:
|
|
172
|
+
"""Every quota scope this provider's config budgets, with its limit.
|
|
173
|
+
|
|
174
|
+
The base emission is the one bound scope: ``{quota_scope: rate_limit}``.
|
|
175
|
+
A provider metering more than one method class overrides this to add
|
|
176
|
+
its extra scopes (GeoTab's feed and authenticate classes), so the
|
|
177
|
+
limiter registry derives every scope from the configs themselves
|
|
178
|
+
without provider special-casing.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
The quota-scope-keyed budgets this config governs.
|
|
182
|
+
"""
|
|
183
|
+
return {self.quota_scope.value: self.rate_limit}
|
|
184
|
+
|
|
185
|
+
@field_validator('endpoints')
|
|
186
|
+
@classmethod
|
|
187
|
+
def _reject_duplicate_endpoints(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
188
|
+
"""Reject a duplicated endpoint name loudly, naming every duplicate.
|
|
189
|
+
|
|
190
|
+
A duplicated name would run twice -- concurrently, since endpoints
|
|
191
|
+
within a provider run staged-concurrent -- so it is a configuration
|
|
192
|
+
failure to surface, never a silent dedup.
|
|
193
|
+
"""
|
|
194
|
+
duplicated = sorted({name for name in value if value.count(name) > 1})
|
|
195
|
+
if duplicated:
|
|
196
|
+
raise ValueError(
|
|
197
|
+
f'endpoint names must be unique; duplicated: {", ".join(duplicated)}'
|
|
198
|
+
)
|
|
199
|
+
return value
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class MotiveConfig(ProviderConfig):
|
|
203
|
+
"""
|
|
204
|
+
User-facing Motive provider settings, one instance per run.
|
|
205
|
+
|
|
206
|
+
Attributes:
|
|
207
|
+
api_key: The Motive API credential for the config-driven sync
|
|
208
|
+
path (``fetch`` takes its credential as an argument instead).
|
|
209
|
+
Optional in YAML -- ``FleetpullConfig.from_yaml`` merges the
|
|
210
|
+
``MOTIVE_API_KEY`` environment variable when the key is
|
|
211
|
+
absent. ``SecretStr`` from parse time on: masked in every
|
|
212
|
+
repr and never logged.
|
|
213
|
+
base_url: Root of the Motive API. Optional; defaults to Motive's
|
|
214
|
+
documented production host. Must carry an http(s) scheme and
|
|
215
|
+
is normalized to drop any trailing slash, so a spec-builder
|
|
216
|
+
joins a leading-slash request path to it directly.
|
|
217
|
+
records_per_page: Page size requested from paginated Motive
|
|
218
|
+
endpoints. Optional; defaults to Motive's documented maximum.
|
|
219
|
+
Bounded to ``1..100`` (the documented ceiling) so a typo
|
|
220
|
+
cannot silently request an out-of-range page.
|
|
221
|
+
rate_limit: The Motive scope's token-bucket budget. Optional;
|
|
222
|
+
defaults to the conservative values the live diagnostic proved
|
|
223
|
+
safe (Motive's real published limits are unverified -- DESIGN
|
|
224
|
+
§13); see ``_MOTIVE_DEFAULT_RATE_LIMIT`` for the rationale.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
quota_scope: ClassVar[QuotaScope] = QuotaScope.MOTIVE
|
|
228
|
+
credential_hint: ClassVar[str] = (
|
|
229
|
+
f"'providers.motive.api_key' in the YAML or the "
|
|
230
|
+
f'{PROVIDER_CREDENTIAL_ENV_VARS["motive"]} environment variable'
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
api_key: SecretStr | None = None
|
|
234
|
+
base_url: str = Field(default=_MOTIVE_DEFAULT_BASE_URL)
|
|
235
|
+
rate_limit: RateLimitConfig = Field(default=_MOTIVE_DEFAULT_RATE_LIMIT)
|
|
236
|
+
records_per_page: int = Field(
|
|
237
|
+
default=_MOTIVE_MAX_RECORDS_PER_PAGE, ge=1, le=_MOTIVE_MAX_RECORDS_PER_PAGE
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def credential(self) -> SecretStr | None:
|
|
242
|
+
"""The Motive credential: the bare API key, or ``None``."""
|
|
243
|
+
return self.api_key
|
|
244
|
+
|
|
245
|
+
@field_validator('base_url')
|
|
246
|
+
@classmethod
|
|
247
|
+
def _require_scheme_and_strip_slash(cls, value: str) -> str:
|
|
248
|
+
"""Apply the shared base-URL hygiene check (see the module helper)."""
|
|
249
|
+
return _validated_base_url(value)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# The Get method-class budget, from the captured rate headers
|
|
253
|
+
# (2026-07-09): `X-Rate-Limit-Remaining: 649` after one call implies
|
|
254
|
+
# ~650/min -- a SINGLE datum, so this default errs conservative on burst
|
|
255
|
+
# and is revisited if live runs contradict it. The docs caveat that the
|
|
256
|
+
# headers may precede enforcement; fleetpull self-limits at the
|
|
257
|
+
# advertised budget regardless (DESIGN §8 probe-settled decision 3).
|
|
258
|
+
# max_concurrency mirrors the Motive posture and is inert until a GeoTab
|
|
259
|
+
# fan-out endpoint exists (no GeoTab endpoint declares a fan-out today).
|
|
260
|
+
_GEOTAB_DEFAULT_GET_RATE_LIMIT: RateLimitConfig = RateLimitConfig(
|
|
261
|
+
requests_per_period=650, period_seconds=60.0, burst=100, max_concurrency=2
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# The GetFeed method-class budget: ~60/min, from the 2026-07-21
|
|
265
|
+
# header-decrement probe (x-rate-limit-limit '1m' with remaining counting
|
|
266
|
+
# down call by call on GetFeed while the Get class sat at ~650/min --
|
|
267
|
+
# GetFeed is its OWN method class, not a Get-class spender). Burst stays
|
|
268
|
+
# conservative on the small budget, and max_concurrency 2 lets two feed
|
|
269
|
+
# ENDPOINTS interleave pages within a sync (each feed walk is itself
|
|
270
|
+
# strictly serial, one chain page after page) without letting a wider
|
|
271
|
+
# fan-out ever form on this class.
|
|
272
|
+
_GEOTAB_DEFAULT_FEED_RATE_LIMIT: RateLimitConfig = RateLimitConfig(
|
|
273
|
+
requests_per_period=60, period_seconds=60.0, burst=10, max_concurrency=2
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# The Authenticate method-class budget: 10/min, from the June 2026
|
|
277
|
+
# `OverLimitException` capture ("API calls quota exceeded. Maximum
|
|
278
|
+
# admitted 10 per 1m.", paired `retry-after: 56`) and the provider docs
|
|
279
|
+
# row (Status: Active). Authenticate fires rarely behind the session
|
|
280
|
+
# manager's single-flight lock, so burst stays small and max_concurrency
|
|
281
|
+
# is 1 -- inert by construction (nothing fans out on Authenticate; the
|
|
282
|
+
# call only ever takes limiter slots one at a time).
|
|
283
|
+
_GEOTAB_DEFAULT_AUTHENTICATE_RATE_LIMIT: RateLimitConfig = RateLimitConfig(
|
|
284
|
+
requests_per_period=10, period_seconds=60.0, burst=2, max_concurrency=1
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class GeotabConfig(ProviderConfig):
|
|
289
|
+
"""
|
|
290
|
+
User-facing GeoTab provider settings, one instance per run.
|
|
291
|
+
|
|
292
|
+
The inherited watermark window knobs apply since the ``trips``
|
|
293
|
+
vertical (amended 2026-07-13; the earlier omission encoded a
|
|
294
|
+
superseded feeds-only view of GeoTab incrementality -- windowed
|
|
295
|
+
``Get`` is GeoTab's history path today, and feeds remain the future
|
|
296
|
+
incremental mechanism; DESIGN §4's amendment). For ``trips``, the
|
|
297
|
+
``lookback_days`` margin is also what absorbs GeoTab's Trip
|
|
298
|
+
recalculation. Deliberately no ``base_url``: the API host is
|
|
299
|
+
``auth.server``, and the session strategy retargets every call to
|
|
300
|
+
the host ``Authenticate`` resolves (DESIGN §8).
|
|
301
|
+
|
|
302
|
+
Attributes:
|
|
303
|
+
auth: The four-field GeoTab credential (username, password,
|
|
304
|
+
database, server), nested -- it never flattens into the
|
|
305
|
+
provider section. Optional in YAML for a disabled provider;
|
|
306
|
+
enablement requires it (``require_provider_credentials``).
|
|
307
|
+
``from_yaml`` merges the ``GEOTAB_PASSWORD`` environment
|
|
308
|
+
variable into an ``auth`` section missing its password.
|
|
309
|
+
endpoints: The endpoint names this provider syncs (catalog
|
|
310
|
+
validation happens at ``Sync`` construction, above this tier).
|
|
311
|
+
rate_limit: The Get method-class budget (the scope ``devices``
|
|
312
|
+
and ``trips`` declare); default from the captured 2026-07-09
|
|
313
|
+
headers -- see ``_GEOTAB_DEFAULT_GET_RATE_LIMIT`` for the
|
|
314
|
+
single-datum caveat.
|
|
315
|
+
feed_rate_limit: The GetFeed method-class budget (the scope the
|
|
316
|
+
feed endpoints declare); default ~60/min from the 2026-07-21
|
|
317
|
+
header-decrement probe -- see
|
|
318
|
+
``_GEOTAB_DEFAULT_FEED_RATE_LIMIT``.
|
|
319
|
+
authenticate_rate_limit: The Authenticate method-class budget;
|
|
320
|
+
default 10/min from the June 2026 capture -- see
|
|
321
|
+
``_GEOTAB_DEFAULT_AUTHENTICATE_RATE_LIMIT``.
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
quota_scope: ClassVar[QuotaScope] = QuotaScope.GEOTAB_GET
|
|
325
|
+
credential_hint: ClassVar[str] = (
|
|
326
|
+
f"'providers.geotab.auth' (username, database, and optional "
|
|
327
|
+
f'server in the YAML; the password from the YAML or the '
|
|
328
|
+
f'{PROVIDER_CREDENTIAL_ENV_VARS["geotab"]} environment variable)'
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
auth: GeotabAuthConfig | None = None
|
|
332
|
+
rate_limit: RateLimitConfig = Field(default=_GEOTAB_DEFAULT_GET_RATE_LIMIT)
|
|
333
|
+
feed_rate_limit: RateLimitConfig = Field(default=_GEOTAB_DEFAULT_FEED_RATE_LIMIT)
|
|
334
|
+
authenticate_rate_limit: RateLimitConfig = Field(
|
|
335
|
+
default=_GEOTAB_DEFAULT_AUTHENTICATE_RATE_LIMIT
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
@property
|
|
339
|
+
def credential(self) -> GeotabAuthConfig | None:
|
|
340
|
+
"""The GeoTab credential: the four-field ``auth`` section, or ``None``.
|
|
341
|
+
|
|
342
|
+
The resolvable-password half of GeoTab enablement is structural:
|
|
343
|
+
``GeotabAuthConfig`` requires its password, so a present ``auth``
|
|
344
|
+
that reached validation carries one (YAML literal or the merged
|
|
345
|
+
``GEOTAB_PASSWORD`` environment variable). A ``None`` here is the
|
|
346
|
+
wholly absent credential section.
|
|
347
|
+
"""
|
|
348
|
+
return self.auth
|
|
349
|
+
|
|
350
|
+
def scope_rate_limits(self) -> dict[str, RateLimitConfig]:
|
|
351
|
+
"""The three GeoTab method-class budgets, each under its own scope.
|
|
352
|
+
|
|
353
|
+
GeoTab meters per method class (DESIGN §8): the inherited emission
|
|
354
|
+
covers the Get-class scope the ``quota_scope`` ClassVar binds, and
|
|
355
|
+
this override adds the GetFeed class (the feed endpoints' scope) and
|
|
356
|
+
the dedicated Authenticate class from the same config, so every
|
|
357
|
+
GeoTab method-class scope is registered wherever a ``GeotabConfig``
|
|
358
|
+
is.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
The Get, GetFeed, and Authenticate scope budgets.
|
|
362
|
+
"""
|
|
363
|
+
return {
|
|
364
|
+
**super().scope_rate_limits(),
|
|
365
|
+
QuotaScope.GEOTAB_FEED.value: self.feed_rate_limit,
|
|
366
|
+
QuotaScope.GEOTAB_AUTHENTICATE.value: self.authenticate_rate_limit,
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
_SAMSARA_DEFAULT_BASE_URL: str = 'https://api.samsara.com'
|
|
371
|
+
|
|
372
|
+
# Conservative default budget for the provider-wide Samsara scope.
|
|
373
|
+
# Samsara's documented limits (developers.samsara.com/docs/rate-limits,
|
|
374
|
+
# fetched 2026-07-17; documented, not captured) are 150 requests/second
|
|
375
|
+
# per token and 200/second per organization, BUT individual endpoints
|
|
376
|
+
# carry tiered limits down to 100 requests per MINUTE. Until the
|
|
377
|
+
# per-endpoint scope split lands (DESIGN §7 anticipates it; each
|
|
378
|
+
# endpoint's tier is pinned as it ports), the provider-wide scope
|
|
379
|
+
# self-limits at that tightest documented tier so no endpoint can be
|
|
380
|
+
# over-driven by a provider-level default. Raise in config for known
|
|
381
|
+
# faster tiers; revisit as endpoints declare their own scopes.
|
|
382
|
+
_SAMSARA_DEFAULT_RATE_LIMIT: RateLimitConfig = RateLimitConfig(
|
|
383
|
+
requests_per_period=100, period_seconds=60.0, burst=10, max_concurrency=2
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class SamsaraConfig(ProviderConfig):
|
|
388
|
+
"""
|
|
389
|
+
User-facing Samsara provider settings, one instance per run.
|
|
390
|
+
|
|
391
|
+
Attributes:
|
|
392
|
+
api_key: The Samsara API token for the config-driven sync path
|
|
393
|
+
(``fetch`` takes its credential as an argument instead).
|
|
394
|
+
Optional in YAML -- ``FleetpullConfig.from_yaml`` merges the
|
|
395
|
+
``SAMSARA_API_KEY`` environment variable when the key is
|
|
396
|
+
absent. ``SecretStr`` from parse time on: masked in every
|
|
397
|
+
repr and never logged. Travels as a bearer token; the
|
|
398
|
+
``Bearer`` prefix is the auth ingress's concern, never
|
|
399
|
+
configured here.
|
|
400
|
+
base_url: Root of the Samsara API. Optional; defaults to
|
|
401
|
+
Samsara's documented production host. Must carry an http(s)
|
|
402
|
+
scheme and is normalized to drop any trailing slash, so a
|
|
403
|
+
spec-builder joins a leading-slash request path to it
|
|
404
|
+
directly.
|
|
405
|
+
rate_limit: The provider-wide Samsara scope's token-bucket
|
|
406
|
+
budget. Optional; defaults to the tightest documented
|
|
407
|
+
per-endpoint tier -- see ``_SAMSARA_DEFAULT_RATE_LIMIT`` for
|
|
408
|
+
the rationale and the per-endpoint-scope revisit note.
|
|
409
|
+
"""
|
|
410
|
+
|
|
411
|
+
quota_scope: ClassVar[QuotaScope] = QuotaScope.SAMSARA
|
|
412
|
+
credential_hint: ClassVar[str] = (
|
|
413
|
+
f"'providers.samsara.api_key' in the YAML or the "
|
|
414
|
+
f'{PROVIDER_CREDENTIAL_ENV_VARS["samsara"]} environment variable'
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
api_key: SecretStr | None = None
|
|
418
|
+
base_url: str = Field(default=_SAMSARA_DEFAULT_BASE_URL)
|
|
419
|
+
rate_limit: RateLimitConfig = Field(default=_SAMSARA_DEFAULT_RATE_LIMIT)
|
|
420
|
+
|
|
421
|
+
@property
|
|
422
|
+
def credential(self) -> SecretStr | None:
|
|
423
|
+
"""The Samsara credential: the bare API token, or ``None``."""
|
|
424
|
+
return self.api_key
|
|
425
|
+
|
|
426
|
+
@field_validator('base_url')
|
|
427
|
+
@classmethod
|
|
428
|
+
def _require_scheme_and_strip_slash(cls, value: str) -> str:
|
|
429
|
+
"""Apply the shared base-URL hygiene check (see the module helper)."""
|
|
430
|
+
return _validated_base_url(value)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class ProvidersConfig(ConfigModel):
|
|
434
|
+
"""
|
|
435
|
+
The per-provider configuration entries, one instance per run.
|
|
436
|
+
|
|
437
|
+
An absent entry means the provider is simply not configured -- no
|
|
438
|
+
warning, no error; the enablement rules apply only to entries that
|
|
439
|
+
are present.
|
|
440
|
+
|
|
441
|
+
Attributes:
|
|
442
|
+
motive: The Motive provider section, or ``None`` when the YAML
|
|
443
|
+
does not configure Motive.
|
|
444
|
+
geotab: The GeoTab provider section, or ``None`` when the YAML
|
|
445
|
+
does not configure GeoTab.
|
|
446
|
+
samsara: The Samsara provider section, or ``None`` when the YAML
|
|
447
|
+
does not configure Samsara.
|
|
448
|
+
"""
|
|
449
|
+
|
|
450
|
+
motive: MotiveConfig | None = None
|
|
451
|
+
geotab: GeotabConfig | None = None
|
|
452
|
+
samsara: SamsaraConfig | None = None
|
|
453
|
+
|
|
454
|
+
def named_sections(self) -> list[tuple[str, ProviderConfig | None]]:
|
|
455
|
+
"""Every provider section, present or not, named and in field order.
|
|
456
|
+
|
|
457
|
+
The provider roll-call the enablement guard and the
|
|
458
|
+
disabled-provider warning loop over, so neither restates the
|
|
459
|
+
provider list; adding a provider extends this and nothing there.
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
``(provider name, section-or-None)`` pairs in the container's
|
|
463
|
+
field order.
|
|
464
|
+
"""
|
|
465
|
+
return [
|
|
466
|
+
('motive', self.motive),
|
|
467
|
+
('geotab', self.geotab),
|
|
468
|
+
('samsara', self.samsara),
|
|
469
|
+
]
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def require_provider_credentials(providers: ProvidersConfig) -> None:
|
|
473
|
+
"""Enforce the credential half of enablement over every present provider.
|
|
474
|
+
|
|
475
|
+
A provider that lists endpoints must have a credential; the root
|
|
476
|
+
config calls this during validation, so the rule holds for YAML
|
|
477
|
+
loading and direct construction alike. (The other half -- a
|
|
478
|
+
credential with no endpoints -- is merely a disabled provider and
|
|
479
|
+
warns at load time, outside validation.)
|
|
480
|
+
|
|
481
|
+
Args:
|
|
482
|
+
providers: The validated providers container.
|
|
483
|
+
|
|
484
|
+
Raises:
|
|
485
|
+
ConfigurationError: A provider lists endpoints but carries no
|
|
486
|
+
credential, naming the YAML field and the conventional
|
|
487
|
+
environment variable (the section's ``credential_hint``) --
|
|
488
|
+
never any credential value.
|
|
489
|
+
"""
|
|
490
|
+
for name, section in providers.named_sections():
|
|
491
|
+
if section is None or not section.endpoints or section.credential is not None:
|
|
492
|
+
continue
|
|
493
|
+
raise ConfigurationError(
|
|
494
|
+
'provider credential missing',
|
|
495
|
+
provider=name,
|
|
496
|
+
detail=(
|
|
497
|
+
f'endpoints are configured but no credential resolves; set '
|
|
498
|
+
f'{section.credential_hint}'
|
|
499
|
+
),
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def default_provider_sections() -> dict[Provider, ProviderConfig]:
|
|
504
|
+
"""One default-constructed provider section per provider package.
|
|
505
|
+
|
|
506
|
+
The discovery walk (``build_endpoint_registry``) builds every leaf it
|
|
507
|
+
finds and requires a config per provider package regardless of
|
|
508
|
+
enablement, so both composition roots (``fetch`` and ``Sync``) need
|
|
509
|
+
every provider represented even when the requested endpoints belong to
|
|
510
|
+
another; this is the one statement of that default roll-call. Extends
|
|
511
|
+
as provider endpoint packages land.
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
The default-constructed provider configs by provider.
|
|
515
|
+
"""
|
|
516
|
+
return {
|
|
517
|
+
Provider.MOTIVE: MotiveConfig(),
|
|
518
|
+
Provider.GEOTAB: GeotabConfig(),
|
|
519
|
+
Provider.SAMSARA: SamsaraConfig(),
|
|
520
|
+
}
|