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/cli.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# src/fleetpull/cli.py
|
|
2
|
+
"""The fleetpull command line: config-driven sync and config scaffolding.
|
|
3
|
+
|
|
4
|
+
Two subcommands. ``fleetpull sync <config>`` is the shell form of
|
|
5
|
+
``Sync(config).run()`` (DESIGN §10): parse the arguments, run the sync, and
|
|
6
|
+
translate the operational-failure family (``FleetpullError``) into a stderr
|
|
7
|
+
line and exit code 1. No logging setup happens here — ``Sync.run`` applies
|
|
8
|
+
the config's logging section itself — and an unexpected exception propagates
|
|
9
|
+
with its traceback: a bug report, not an operational outcome. ``fleetpull
|
|
10
|
+
init-config [path]`` writes the packaged annotated example configuration to
|
|
11
|
+
disk, the onboarding path for a pip-installed user who has no repository to
|
|
12
|
+
copy it from.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import sys
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from fleetpull.api import Sync
|
|
21
|
+
from fleetpull.config import EXAMPLE_CONFIG_FILENAME, write_example_config
|
|
22
|
+
from fleetpull.exceptions import FleetpullError
|
|
23
|
+
|
|
24
|
+
__all__: list[str] = ['main']
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
28
|
+
"""Build the ``fleetpull`` argument parser.
|
|
29
|
+
|
|
30
|
+
Two required subcommands: ``sync`` (run a config-driven sync) and
|
|
31
|
+
``init-config`` (write the example configuration).
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The configured parser.
|
|
35
|
+
"""
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
prog='fleetpull',
|
|
38
|
+
description=(
|
|
39
|
+
'Pull fleet telematics data from provider APIs into typed parquet.'
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
subcommands = parser.add_subparsers(dest='command', required=True)
|
|
43
|
+
|
|
44
|
+
sync_parser = subcommands.add_parser(
|
|
45
|
+
'sync', help='run a config-driven sync: fleetpull sync <config.yaml>'
|
|
46
|
+
)
|
|
47
|
+
sync_parser.add_argument(
|
|
48
|
+
'config', type=Path, help='path to the fleetpull YAML configuration file'
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
init_parser = subcommands.add_parser(
|
|
52
|
+
'init-config',
|
|
53
|
+
help=(
|
|
54
|
+
'write the annotated example configuration to a path '
|
|
55
|
+
f'(default ./{EXAMPLE_CONFIG_FILENAME})'
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
init_parser.add_argument(
|
|
59
|
+
'path',
|
|
60
|
+
type=Path,
|
|
61
|
+
nargs='?',
|
|
62
|
+
default=Path(EXAMPLE_CONFIG_FILENAME),
|
|
63
|
+
help=(
|
|
64
|
+
'destination file, or an existing directory to write '
|
|
65
|
+
f'{EXAMPLE_CONFIG_FILENAME} into '
|
|
66
|
+
f'(default ./{EXAMPLE_CONFIG_FILENAME})'
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
init_parser.add_argument(
|
|
70
|
+
'--force',
|
|
71
|
+
action='store_true',
|
|
72
|
+
help='overwrite the destination if it already exists',
|
|
73
|
+
)
|
|
74
|
+
return parser
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _run_sync(config: Path) -> int:
|
|
78
|
+
"""Run a config-driven sync, mapping operational failures to exit code 1.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
config: The YAML configuration path.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
``0`` on success; ``1`` on a ``FleetpullError``-family failure
|
|
85
|
+
(whose message lands on stderr).
|
|
86
|
+
|
|
87
|
+
Side Effects:
|
|
88
|
+
Runs the sync (network, parquet, state, logging per the config);
|
|
89
|
+
writes the failure message to ``sys.stderr`` on an operational
|
|
90
|
+
error.
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
Sync(config).run()
|
|
94
|
+
except FleetpullError as error:
|
|
95
|
+
sys.stderr.write(f'{error}\n')
|
|
96
|
+
return 1
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _run_init_config(path: Path, *, force: bool) -> int:
|
|
101
|
+
"""Write the example configuration, mapping a refusal to exit code 1.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
path: The destination file, or an existing directory.
|
|
105
|
+
force: Overwrite an existing destination when ``True``.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
``0`` after writing; ``1`` when the destination exists (and
|
|
109
|
+
``force`` is unset) or the write fails, the reason on stderr.
|
|
110
|
+
|
|
111
|
+
Side Effects:
|
|
112
|
+
Writes the example config to disk; prints the written path to
|
|
113
|
+
stdout on success, the failure to ``sys.stderr`` otherwise.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
written = write_example_config(path, force=force)
|
|
117
|
+
except OSError as error:
|
|
118
|
+
sys.stderr.write(f'{error}\n')
|
|
119
|
+
return 1
|
|
120
|
+
sys.stdout.write(f'wrote example configuration to {written}\n')
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
125
|
+
"""Run the fleetpull CLI and return its exit code.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
argv: The argument vector to parse, or ``None`` for ``sys.argv[1:]``
|
|
129
|
+
(the console-script entry point).
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
``0`` on success; ``1`` on an operational failure (a
|
|
133
|
+
``FleetpullError``-family sync error, or a refused/failed config
|
|
134
|
+
write), whose message lands on stderr.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
SystemExit: Argparse's exit on a missing or unknown argument
|
|
138
|
+
(exit code 2).
|
|
139
|
+
|
|
140
|
+
Side Effects:
|
|
141
|
+
Per subcommand: runs the sync, or writes the example config.
|
|
142
|
+
"""
|
|
143
|
+
arguments = _build_parser().parse_args(argv)
|
|
144
|
+
if arguments.command == 'init-config':
|
|
145
|
+
return _run_init_config(arguments.path, force=arguments.force)
|
|
146
|
+
return _run_sync(arguments.config)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Pydantic models for user-provided YAML configuration, one model family per
|
|
2
|
+
file, with ``FleetpullConfig.from_yaml`` as the loading API."""
|
|
3
|
+
|
|
4
|
+
from fleetpull.config.base import ConfigModel
|
|
5
|
+
from fleetpull.config.example import (
|
|
6
|
+
EXAMPLE_CONFIG_FILENAME,
|
|
7
|
+
read_example_config,
|
|
8
|
+
write_example_config,
|
|
9
|
+
)
|
|
10
|
+
from fleetpull.config.geotab import DEFAULT_GEOTAB_SERVER, GeotabAuthConfig
|
|
11
|
+
from fleetpull.config.http import HttpConfig
|
|
12
|
+
from fleetpull.config.logger import LoggerConfig
|
|
13
|
+
from fleetpull.config.providers import (
|
|
14
|
+
GeotabConfig,
|
|
15
|
+
MotiveConfig,
|
|
16
|
+
ProviderConfig,
|
|
17
|
+
ProvidersConfig,
|
|
18
|
+
SamsaraConfig,
|
|
19
|
+
default_provider_sections,
|
|
20
|
+
)
|
|
21
|
+
from fleetpull.config.rate_limit import RateLimitConfig
|
|
22
|
+
from fleetpull.config.retry import RetryConfig
|
|
23
|
+
from fleetpull.config.root import FleetpullConfig
|
|
24
|
+
from fleetpull.config.sections import StateConfig, StorageConfig, SyncConfig
|
|
25
|
+
|
|
26
|
+
__all__: list[str] = [
|
|
27
|
+
'DEFAULT_GEOTAB_SERVER',
|
|
28
|
+
'EXAMPLE_CONFIG_FILENAME',
|
|
29
|
+
'ConfigModel',
|
|
30
|
+
'FleetpullConfig',
|
|
31
|
+
'GeotabAuthConfig',
|
|
32
|
+
'GeotabConfig',
|
|
33
|
+
'HttpConfig',
|
|
34
|
+
'LoggerConfig',
|
|
35
|
+
'MotiveConfig',
|
|
36
|
+
'ProviderConfig',
|
|
37
|
+
'ProvidersConfig',
|
|
38
|
+
'RateLimitConfig',
|
|
39
|
+
'RetryConfig',
|
|
40
|
+
'SamsaraConfig',
|
|
41
|
+
'StateConfig',
|
|
42
|
+
'StorageConfig',
|
|
43
|
+
'SyncConfig',
|
|
44
|
+
'default_provider_sections',
|
|
45
|
+
'read_example_config',
|
|
46
|
+
'write_example_config',
|
|
47
|
+
]
|
fleetpull/config/base.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# src/fleetpull/config/base.py
|
|
2
|
+
"""The shared configuration-model base: the policy, stated exactly once.
|
|
3
|
+
|
|
4
|
+
Every config model inherits ``ConfigModel``: frozen so a loaded config
|
|
5
|
+
cannot mutate mid-run, ``extra='forbid'`` so a misspelled YAML key is
|
|
6
|
+
rejected rather than silently dropped, and ``validate_default=True`` so
|
|
7
|
+
defaulted values pass the same validators as supplied ones.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict
|
|
11
|
+
|
|
12
|
+
__all__: list[str] = ['ConfigModel']
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigModel(BaseModel):
|
|
16
|
+
"""Base for every fleetpull configuration model; carries the policy only."""
|
|
17
|
+
|
|
18
|
+
model_config = ConfigDict(frozen=True, extra='forbid', validate_default=True)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# src/fleetpull/config/example.py
|
|
2
|
+
"""The packaged example configuration: read it, or materialize it to disk.
|
|
3
|
+
|
|
4
|
+
``config.example.yaml`` ships inside the wheel (``fleetpull.resources``),
|
|
5
|
+
so a pip-installed user has no repository to copy it from. ``fleetpull
|
|
6
|
+
init-config`` writes it to a path of their choosing through
|
|
7
|
+
``write_example_config``; ``read_example_config`` returns its text for any
|
|
8
|
+
programmatic caller. Read through ``importlib.resources`` so the file
|
|
9
|
+
resolves identically in a built wheel and an editable checkout.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from importlib import resources
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from fleetpull.paths import PathInput, resolve_path
|
|
16
|
+
|
|
17
|
+
__all__: list[str] = [
|
|
18
|
+
'EXAMPLE_CONFIG_FILENAME',
|
|
19
|
+
'read_example_config',
|
|
20
|
+
'write_example_config',
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
# The resource's package and name; the file lives in ``fleetpull/resources``.
|
|
24
|
+
_RESOURCE_PACKAGE = 'fleetpull.resources'
|
|
25
|
+
_RESOURCE_NAME = 'config.example.yaml'
|
|
26
|
+
|
|
27
|
+
# The default filename ``init-config`` writes when given only a directory
|
|
28
|
+
# (or nothing) -- not the resource's own name, so the materialized file
|
|
29
|
+
# reads as the user's config rather than "the example".
|
|
30
|
+
EXAMPLE_CONFIG_FILENAME = 'fleetpull_config.yaml'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def read_example_config() -> str:
|
|
34
|
+
"""Return the packaged example configuration's text.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
The full ``config.example.yaml`` document, verbatim UTF-8.
|
|
38
|
+
|
|
39
|
+
Side Effects:
|
|
40
|
+
Reads the packaged resource.
|
|
41
|
+
"""
|
|
42
|
+
return (
|
|
43
|
+
resources.files(_RESOURCE_PACKAGE)
|
|
44
|
+
.joinpath(_RESOURCE_NAME)
|
|
45
|
+
.read_text(encoding='utf-8')
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _resolve_destination(destination: PathInput) -> Path:
|
|
50
|
+
"""Resolve the write target, defaulting a directory to the config filename.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
destination: The user-supplied path -- a file path, or a directory
|
|
54
|
+
(existing) into which the default filename is written.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The concrete file path to write.
|
|
58
|
+
"""
|
|
59
|
+
resolved = resolve_path(destination)
|
|
60
|
+
if resolved.is_dir():
|
|
61
|
+
return resolved / EXAMPLE_CONFIG_FILENAME
|
|
62
|
+
return resolved
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def write_example_config(destination: PathInput, *, force: bool = False) -> Path:
|
|
66
|
+
"""Write the packaged example configuration to ``destination``.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
destination: Where to write -- a file path, or an existing
|
|
70
|
+
directory (the default filename ``fleetpull_config.yaml`` is
|
|
71
|
+
appended). The parent directory must already exist.
|
|
72
|
+
force: Overwrite an existing target when ``True``; otherwise a
|
|
73
|
+
pre-existing target is a loud refusal (never clobber a config a
|
|
74
|
+
user may have edited).
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The path actually written.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
FileExistsError: The target exists and ``force`` is ``False``.
|
|
81
|
+
OSError: The parent directory is missing or the write fails.
|
|
82
|
+
|
|
83
|
+
Side Effects:
|
|
84
|
+
Writes a file to disk.
|
|
85
|
+
"""
|
|
86
|
+
target = _resolve_destination(destination)
|
|
87
|
+
if target.exists() and not force:
|
|
88
|
+
raise FileExistsError(f'{target} already exists; pass force to overwrite it')
|
|
89
|
+
target.write_text(read_example_config(), encoding='utf-8')
|
|
90
|
+
return target
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# src/fleetpull/config/geotab.py
|
|
2
|
+
"""GeoTab authentication configuration model.
|
|
3
|
+
|
|
4
|
+
GeotabAuthConfig is the GeoTab authentication section of fleetpull's
|
|
5
|
+
user-provided YAML configuration (nested under ``providers.geotab.auth``;
|
|
6
|
+
the provider section itself lives with its family in
|
|
7
|
+
``config/providers.py``). The password is a ``SecretStr``: its value is
|
|
8
|
+
extracted with ``.get_secret_value()`` only at the moment of use (inside
|
|
9
|
+
the real authenticate function), and is never logged or included in
|
|
10
|
+
``repr()``/``str()`` output.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from pydantic import Field, SecretStr, field_validator
|
|
14
|
+
|
|
15
|
+
from fleetpull.config.base import ConfigModel
|
|
16
|
+
|
|
17
|
+
__all__: list[str] = ['DEFAULT_GEOTAB_SERVER', 'GeotabAuthConfig']
|
|
18
|
+
|
|
19
|
+
# The documented default authentication host. Hoisted as a named constant
|
|
20
|
+
# (and exported through the config face) because it is a shared provider
|
|
21
|
+
# fact: the auth section defaults to it, and the GeoTab spec builders use
|
|
22
|
+
# it as the pre-auth placeholder host for a credential-less config.
|
|
23
|
+
DEFAULT_GEOTAB_SERVER: str = 'my.geotab.com'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GeotabAuthConfig(ConfigModel):
|
|
27
|
+
"""
|
|
28
|
+
GeoTab authentication credentials and target database.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
username: GeoTab account username (non-empty).
|
|
32
|
+
password: GeoTab account password; masked in all output.
|
|
33
|
+
database: GeoTab database name (non-empty).
|
|
34
|
+
server: The authentication host — a bare hostname like
|
|
35
|
+
``my.geotab.com`` (no scheme, path, or whitespace). The
|
|
36
|
+
authenticator builds ``https://{server}/apiv1`` from it.
|
|
37
|
+
``Authenticate`` may redirect subsequent calls to a
|
|
38
|
+
different resolved host — that is session state, not
|
|
39
|
+
configuration, so it never lives here.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
username: str = Field(min_length=1)
|
|
43
|
+
password: SecretStr
|
|
44
|
+
database: str = Field(min_length=1)
|
|
45
|
+
server: str = Field(default=DEFAULT_GEOTAB_SERVER, min_length=1)
|
|
46
|
+
|
|
47
|
+
@field_validator('server')
|
|
48
|
+
@classmethod
|
|
49
|
+
def _server_is_bare_hostname(cls, server: str) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Reject anything but a bare hostname.
|
|
52
|
+
|
|
53
|
+
The authenticator builds ``https://{server}/apiv1``; a scheme,
|
|
54
|
+
path, or stray whitespace would corrupt that URL. Caught here at
|
|
55
|
+
the config boundary with a message that says exactly what to
|
|
56
|
+
write.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
server: The configured server value.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The value unchanged when it is a bare hostname.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ValueError: When the value carries a scheme, a path, a
|
|
66
|
+
slash, or whitespace.
|
|
67
|
+
"""
|
|
68
|
+
if '/' in server:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f'server must be a bare hostname like "my.geotab.com" — no '
|
|
71
|
+
f'scheme or path; got {server!r} (write "https://my.geotab.com" '
|
|
72
|
+
f'as "my.geotab.com")'
|
|
73
|
+
)
|
|
74
|
+
if any(character.isspace() for character in server):
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f'server must be a bare hostname with no whitespace; got {server!r}'
|
|
77
|
+
)
|
|
78
|
+
return server
|
fleetpull/config/http.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# src/fleetpull/config/http.py
|
|
2
|
+
"""HTTP transport configuration: timeouts and TLS posture.
|
|
3
|
+
|
|
4
|
+
Every network call in the package requires explicit timeouts (house
|
|
5
|
+
rule); this model is their single YAML-facing source.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
|
|
10
|
+
from fleetpull.config.base import ConfigModel
|
|
11
|
+
|
|
12
|
+
__all__: list[str] = ['HttpConfig']
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HttpConfig(ConfigModel):
|
|
16
|
+
"""
|
|
17
|
+
User-facing HTTP transport settings, one instance per run.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
connect_timeout_seconds: Timeout for establishing a connection.
|
|
21
|
+
read_timeout_seconds: Timeout for reading a response.
|
|
22
|
+
use_truststore: Build SSL contexts from the operating system's
|
|
23
|
+
trust store (``network/tls/``) — required behind
|
|
24
|
+
TLS-intercepting corporate proxies. Default False:
|
|
25
|
+
unproxied environments (production deployment targets) use
|
|
26
|
+
httpx's bundled CA store; the trust-store path is opt-in
|
|
27
|
+
where the proxy is the exception, not the rule.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
connect_timeout_seconds: float = Field(default=10.0, gt=0)
|
|
31
|
+
read_timeout_seconds: float = Field(default=30.0, gt=0)
|
|
32
|
+
use_truststore: bool = False
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# src/fleetpull/config/loading.py
|
|
2
|
+
"""The single-concern loading steps behind ``FleetpullConfig.from_yaml``.
|
|
3
|
+
|
|
4
|
+
Reading and parsing the file, merging conventional credential
|
|
5
|
+
environment variables into the raw document, shaping validation failures
|
|
6
|
+
into actionable messages, and the post-validation disabled-provider
|
|
7
|
+
warning. Environment access lives here and nowhere else in the config
|
|
8
|
+
layer; none of it runs inside validators.
|
|
9
|
+
|
|
10
|
+
Every failure surfaces as a ``ConfigurationError`` a user can act on: a
|
|
11
|
+
missing file names the path; a parse failure names the line the parser
|
|
12
|
+
reports, composed from the parser's ``problem`` text and never the
|
|
13
|
+
marked source snippet (a snippet could echo a malformed credential
|
|
14
|
+
line); a validation failure carries one ``key.path: message`` entry per
|
|
15
|
+
error and no raw input values.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
from pydantic import SecretStr, ValidationError
|
|
24
|
+
|
|
25
|
+
from fleetpull.config.providers import PROVIDER_CREDENTIAL_ENV_VARS, ProvidersConfig
|
|
26
|
+
from fleetpull.exceptions import ConfigurationError
|
|
27
|
+
|
|
28
|
+
__all__: list[str] = [
|
|
29
|
+
'read_yaml_document',
|
|
30
|
+
'validation_detail',
|
|
31
|
+
'warn_disabled_providers',
|
|
32
|
+
'with_environment_credentials',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# typing-justified: YAML values are arbitrary user input until validated
|
|
39
|
+
def read_yaml_document(config_path: Path) -> dict[str, object]:
|
|
40
|
+
"""Read and parse the file into the raw top-level mapping.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
config_path: The file to read.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The parsed top-level mapping; an empty file is an empty mapping
|
|
47
|
+
(validation then names the missing required sections).
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
ConfigurationError: The file is missing, unparseable (naming the
|
|
51
|
+
line the parser reports), or not a mapping at the top level.
|
|
52
|
+
"""
|
|
53
|
+
if not config_path.is_file():
|
|
54
|
+
raise ConfigurationError('config file not found', detail=str(config_path))
|
|
55
|
+
try:
|
|
56
|
+
loaded = yaml.safe_load(config_path.read_text(encoding='utf-8'))
|
|
57
|
+
except yaml.YAMLError as parse_error:
|
|
58
|
+
raise ConfigurationError(
|
|
59
|
+
'config file is not valid YAML',
|
|
60
|
+
detail=_parse_error_detail(config_path, parse_error),
|
|
61
|
+
) from None
|
|
62
|
+
if loaded is None:
|
|
63
|
+
return {}
|
|
64
|
+
if not isinstance(loaded, dict):
|
|
65
|
+
raise ConfigurationError(
|
|
66
|
+
'config file must be a YAML mapping at the top level',
|
|
67
|
+
detail=f'{config_path}: got {type(loaded).__name__}',
|
|
68
|
+
)
|
|
69
|
+
return loaded
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _parse_error_detail(config_path: Path, parse_error: yaml.YAMLError) -> str:
|
|
73
|
+
"""Locate a parse failure at the line the parser reports.
|
|
74
|
+
|
|
75
|
+
Composes from the parser's ``problem`` description, never the marked
|
|
76
|
+
source snippet -- a snippet could echo a malformed credential line.
|
|
77
|
+
"""
|
|
78
|
+
problem: str = getattr(parse_error, 'problem', None) or type(parse_error).__name__
|
|
79
|
+
mark = getattr(parse_error, 'problem_mark', None)
|
|
80
|
+
if isinstance(mark, yaml.Mark):
|
|
81
|
+
return f'{config_path}, line {mark.line + 1}: {problem}'
|
|
82
|
+
return f'{config_path}: {problem}'
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _with_api_key_from_env(
|
|
86
|
+
# typing-justified: rewrites the raw provider sections before validation
|
|
87
|
+
providers: dict[str, object],
|
|
88
|
+
provider_name: str,
|
|
89
|
+
# typing-justified: rewrites the raw provider sections before validation
|
|
90
|
+
) -> dict[str, object]:
|
|
91
|
+
"""Merge one static-key provider's environment credential, if resolvable.
|
|
92
|
+
|
|
93
|
+
The whole-credential half of the documented asymmetry: the provider's
|
|
94
|
+
conventional variable supplies ``api_key`` outright, merged only when
|
|
95
|
+
the key is absent from the YAML section (a YAML literal wins) and the
|
|
96
|
+
variable carries a non-empty value (empty counts as unset). Shared
|
|
97
|
+
verbatim by the Motive and Samsara arms.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
providers: The raw ``providers`` mapping.
|
|
101
|
+
provider_name: The static-key provider to merge (``'motive'`` /
|
|
102
|
+
``'samsara'``).
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
The providers mapping with the credential merged, or unchanged.
|
|
106
|
+
|
|
107
|
+
Side Effects:
|
|
108
|
+
Reads the process environment.
|
|
109
|
+
"""
|
|
110
|
+
section = providers.get(provider_name)
|
|
111
|
+
if not isinstance(section, dict) or 'api_key' in section:
|
|
112
|
+
return providers
|
|
113
|
+
value = os.environ.get(PROVIDER_CREDENTIAL_ENV_VARS[provider_name])
|
|
114
|
+
if not value:
|
|
115
|
+
return providers
|
|
116
|
+
return {**providers, provider_name: {**section, 'api_key': SecretStr(value)}}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# typing-justified: rewrites the raw document before validation
|
|
120
|
+
def with_environment_credentials(document: dict[str, object]) -> dict[str, object]:
|
|
121
|
+
"""Merge conventional credential environment variables into the document.
|
|
122
|
+
|
|
123
|
+
Applied per provider from ``PROVIDER_CREDENTIAL_ENV_VARS``, only when
|
|
124
|
+
the credential is absent from the YAML (a YAML literal wins) and the
|
|
125
|
+
variable carries a non-empty value (empty counts as unset). The shape
|
|
126
|
+
is per-provider (the mapping's documented asymmetry): Motive's and
|
|
127
|
+
Samsara's variables each supply the whole credential (``api_key``);
|
|
128
|
+
GeoTab's fills only the ``password`` field of a YAML-present ``auth``
|
|
129
|
+
section --
|
|
130
|
+
username, database, and server are not secrets and always come from
|
|
131
|
+
the YAML, so an absent ``auth`` section is left for the enablement
|
|
132
|
+
guard to reject. The value is wrapped in ``SecretStr`` here, so the
|
|
133
|
+
raw string never travels in the document.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
document: The raw document mapping.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
The document with resolvable credentials merged; otherwise
|
|
140
|
+
unchanged.
|
|
141
|
+
|
|
142
|
+
Side Effects:
|
|
143
|
+
Reads the process environment -- the only place in the config
|
|
144
|
+
layer that does.
|
|
145
|
+
"""
|
|
146
|
+
providers_section = document.get('providers')
|
|
147
|
+
if not isinstance(providers_section, dict):
|
|
148
|
+
return document
|
|
149
|
+
merged_providers = _with_api_key_from_env(dict(providers_section), 'motive')
|
|
150
|
+
merged_providers = _with_api_key_from_env(merged_providers, 'samsara')
|
|
151
|
+
geotab_section = merged_providers.get('geotab')
|
|
152
|
+
if isinstance(geotab_section, dict):
|
|
153
|
+
auth_section = geotab_section.get('auth')
|
|
154
|
+
if isinstance(auth_section, dict) and 'password' not in auth_section:
|
|
155
|
+
geotab_value = os.environ.get(PROVIDER_CREDENTIAL_ENV_VARS['geotab'])
|
|
156
|
+
if geotab_value:
|
|
157
|
+
merged_providers['geotab'] = {
|
|
158
|
+
**geotab_section,
|
|
159
|
+
'auth': {**auth_section, 'password': SecretStr(geotab_value)},
|
|
160
|
+
}
|
|
161
|
+
return {**document, 'providers': merged_providers}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def validation_detail(validation_error: ValidationError) -> str:
|
|
165
|
+
"""Summarize validation errors as ``key.path: message`` entries.
|
|
166
|
+
|
|
167
|
+
Uses each error's location and message only -- never the offending
|
|
168
|
+
input value, which could be a credential.
|
|
169
|
+
"""
|
|
170
|
+
return '; '.join(
|
|
171
|
+
f'{".".join(str(item) for item in entry["loc"])}: {entry["msg"]}'
|
|
172
|
+
for entry in validation_error.errors()
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def warn_disabled_providers(providers: ProvidersConfig) -> None:
|
|
177
|
+
"""Log one WARNING per provider with a credential but no endpoints.
|
|
178
|
+
|
|
179
|
+
The provider is merely disabled, not misconfigured, so this is a
|
|
180
|
+
post-validation side effect of loading -- never a validator's job.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
providers: The validated providers container.
|
|
184
|
+
|
|
185
|
+
Side Effects:
|
|
186
|
+
Logs through this module's logger.
|
|
187
|
+
"""
|
|
188
|
+
for name, section in providers.named_sections():
|
|
189
|
+
if section is None or section.credential is None or section.endpoints:
|
|
190
|
+
continue
|
|
191
|
+
logger.warning(
|
|
192
|
+
"providers.%s: a credential resolves but 'endpoints' is empty; "
|
|
193
|
+
'the provider is disabled for this run.',
|
|
194
|
+
name,
|
|
195
|
+
)
|