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.
Files changed (252) hide show
  1. fleetpull/__init__.py +36 -0
  2. fleetpull/api/__init__.py +32 -0
  3. fleetpull/api/auth_ingress.py +216 -0
  4. fleetpull/api/catalog.py +139 -0
  5. fleetpull/api/fetch.py +229 -0
  6. fleetpull/api/identity.py +90 -0
  7. fleetpull/api/sync.py +735 -0
  8. fleetpull/cli.py +146 -0
  9. fleetpull/config/__init__.py +47 -0
  10. fleetpull/config/base.py +18 -0
  11. fleetpull/config/example.py +90 -0
  12. fleetpull/config/geotab.py +78 -0
  13. fleetpull/config/http.py +32 -0
  14. fleetpull/config/loading.py +195 -0
  15. fleetpull/config/logger.py +139 -0
  16. fleetpull/config/providers.py +520 -0
  17. fleetpull/config/rate_limit.py +50 -0
  18. fleetpull/config/resolution.py +156 -0
  19. fleetpull/config/retry.py +69 -0
  20. fleetpull/config/root.py +157 -0
  21. fleetpull/config/sections.py +146 -0
  22. fleetpull/endpoints/__init__.py +24 -0
  23. fleetpull/endpoints/geotab/__init__.py +12 -0
  24. fleetpull/endpoints/geotab/_requests.py +427 -0
  25. fleetpull/endpoints/geotab/annotation_logs.py +73 -0
  26. fleetpull/endpoints/geotab/audits.py +70 -0
  27. fleetpull/endpoints/geotab/devices.py +79 -0
  28. fleetpull/endpoints/geotab/driver_changes.py +73 -0
  29. fleetpull/endpoints/geotab/duty_status_logs.py +75 -0
  30. fleetpull/endpoints/geotab/dvir_logs.py +76 -0
  31. fleetpull/endpoints/geotab/exception_events.py +119 -0
  32. fleetpull/endpoints/geotab/fault_data.py +72 -0
  33. fleetpull/endpoints/geotab/fill_ups.py +79 -0
  34. fleetpull/endpoints/geotab/fuel_and_energy_used.py +74 -0
  35. fleetpull/endpoints/geotab/fuel_tax_details.py +75 -0
  36. fleetpull/endpoints/geotab/log_records.py +74 -0
  37. fleetpull/endpoints/geotab/media_files.py +74 -0
  38. fleetpull/endpoints/geotab/shipment_logs.py +71 -0
  39. fleetpull/endpoints/geotab/status_data.py +71 -0
  40. fleetpull/endpoints/geotab/text_messages.py +77 -0
  41. fleetpull/endpoints/geotab/trips.py +102 -0
  42. fleetpull/endpoints/geotab/users.py +81 -0
  43. fleetpull/endpoints/motive/__init__.py +12 -0
  44. fleetpull/endpoints/motive/_spec_builders.py +94 -0
  45. fleetpull/endpoints/motive/driver_idle_rollups.py +113 -0
  46. fleetpull/endpoints/motive/driving_periods.py +85 -0
  47. fleetpull/endpoints/motive/groups.py +61 -0
  48. fleetpull/endpoints/motive/idle_events.py +94 -0
  49. fleetpull/endpoints/motive/users.py +64 -0
  50. fleetpull/endpoints/motive/vehicle_locations.py +170 -0
  51. fleetpull/endpoints/motive/vehicle_utilizations.py +122 -0
  52. fleetpull/endpoints/motive/vehicles.py +106 -0
  53. fleetpull/endpoints/registry.py +280 -0
  54. fleetpull/endpoints/samsara/__init__.py +12 -0
  55. fleetpull/endpoints/samsara/_spec_builders.py +202 -0
  56. fleetpull/endpoints/samsara/addresses.py +77 -0
  57. fleetpull/endpoints/samsara/asset_locations.py +217 -0
  58. fleetpull/endpoints/samsara/driver_fuel_energy_reports.py +124 -0
  59. fleetpull/endpoints/samsara/driver_vehicle_assignments.py +211 -0
  60. fleetpull/endpoints/samsara/drivers.py +169 -0
  61. fleetpull/endpoints/samsara/engine_states.py +114 -0
  62. fleetpull/endpoints/samsara/gps_readings.py +113 -0
  63. fleetpull/endpoints/samsara/idling_events.py +195 -0
  64. fleetpull/endpoints/samsara/odometer_readings.py +116 -0
  65. fleetpull/endpoints/samsara/trips.py +217 -0
  66. fleetpull/endpoints/samsara/vehicle_fuel_energy_reports.py +139 -0
  67. fleetpull/endpoints/samsara/vehicles.py +121 -0
  68. fleetpull/endpoints/shared/__init__.py +57 -0
  69. fleetpull/endpoints/shared/base.py +529 -0
  70. fleetpull/endpoints/shared/request_shape.py +227 -0
  71. fleetpull/endpoints/shared/resume.py +74 -0
  72. fleetpull/endpoints/shared/spec_builders.py +59 -0
  73. fleetpull/endpoints/shared/sync_mode.py +159 -0
  74. fleetpull/endpoints/shared/url_paths.py +149 -0
  75. fleetpull/exceptions.py +320 -0
  76. fleetpull/incremental/__init__.py +27 -0
  77. fleetpull/incremental/cursor.py +66 -0
  78. fleetpull/incremental/resolution.py +152 -0
  79. fleetpull/incremental/seed.py +53 -0
  80. fleetpull/incremental/window.py +94 -0
  81. fleetpull/logger/__init__.py +5 -0
  82. fleetpull/logger/setup.py +145 -0
  83. fleetpull/model_contract/__init__.py +6 -0
  84. fleetpull/model_contract/coercions.py +33 -0
  85. fleetpull/model_contract/response.py +45 -0
  86. fleetpull/models/__init__.py +7 -0
  87. fleetpull/models/geotab/__init__.py +166 -0
  88. fleetpull/models/geotab/annotation_log.py +109 -0
  89. fleetpull/models/geotab/audit.py +56 -0
  90. fleetpull/models/geotab/device.py +217 -0
  91. fleetpull/models/geotab/driver_change.py +102 -0
  92. fleetpull/models/geotab/duty_status_log.py +200 -0
  93. fleetpull/models/geotab/dvir_log.py +184 -0
  94. fleetpull/models/geotab/exception_event.py +135 -0
  95. fleetpull/models/geotab/fault_data.py +168 -0
  96. fleetpull/models/geotab/fill_up.py +189 -0
  97. fleetpull/models/geotab/fuel_and_energy_used.py +81 -0
  98. fleetpull/models/geotab/fuel_tax_detail.py +150 -0
  99. fleetpull/models/geotab/log_record.py +68 -0
  100. fleetpull/models/geotab/media_file.py +133 -0
  101. fleetpull/models/geotab/shared.py +221 -0
  102. fleetpull/models/geotab/shipment_log.py +105 -0
  103. fleetpull/models/geotab/status_data.py +108 -0
  104. fleetpull/models/geotab/text_message.py +125 -0
  105. fleetpull/models/geotab/trip.py +162 -0
  106. fleetpull/models/geotab/user.py +187 -0
  107. fleetpull/models/motive/__init__.py +43 -0
  108. fleetpull/models/motive/driver_idle_rollup.py +109 -0
  109. fleetpull/models/motive/driving_period.py +82 -0
  110. fleetpull/models/motive/group.py +49 -0
  111. fleetpull/models/motive/idle_event.py +77 -0
  112. fleetpull/models/motive/shared.py +192 -0
  113. fleetpull/models/motive/user.py +217 -0
  114. fleetpull/models/motive/vehicle.py +162 -0
  115. fleetpull/models/motive/vehicle_location.py +127 -0
  116. fleetpull/models/motive/vehicle_utilization.py +128 -0
  117. fleetpull/models/samsara/__init__.py +108 -0
  118. fleetpull/models/samsara/address.py +149 -0
  119. fleetpull/models/samsara/asset_location.py +131 -0
  120. fleetpull/models/samsara/driver.py +232 -0
  121. fleetpull/models/samsara/driver_fuel_energy_report.py +153 -0
  122. fleetpull/models/samsara/driver_vehicle_assignment.py +168 -0
  123. fleetpull/models/samsara/engine_state.py +92 -0
  124. fleetpull/models/samsara/gps_reading.py +146 -0
  125. fleetpull/models/samsara/idling_event.py +174 -0
  126. fleetpull/models/samsara/odometer_reading.py +90 -0
  127. fleetpull/models/samsara/trip.py +199 -0
  128. fleetpull/models/samsara/vehicle.py +167 -0
  129. fleetpull/models/samsara/vehicle_fuel_energy_report.py +191 -0
  130. fleetpull/network/__init__.py +9 -0
  131. fleetpull/network/auth/__init__.py +15 -0
  132. fleetpull/network/auth/authenticate.py +271 -0
  133. fleetpull/network/auth/manager.py +223 -0
  134. fleetpull/network/auth/models.py +57 -0
  135. fleetpull/network/auth/strategies.py +169 -0
  136. fleetpull/network/classifiers/__init__.py +11 -0
  137. fleetpull/network/classifiers/geotab.py +145 -0
  138. fleetpull/network/classifiers/motive.py +79 -0
  139. fleetpull/network/classifiers/samsara.py +80 -0
  140. fleetpull/network/client/__init__.py +25 -0
  141. fleetpull/network/client/page.py +32 -0
  142. fleetpull/network/client/profile.py +27 -0
  143. fleetpull/network/client/registry.py +103 -0
  144. fleetpull/network/client/registry_base.py +150 -0
  145. fleetpull/network/client/runtime.py +44 -0
  146. fleetpull/network/client/transport.py +381 -0
  147. fleetpull/network/contract/__init__.py +50 -0
  148. fleetpull/network/contract/auth.py +48 -0
  149. fleetpull/network/contract/classifier.py +189 -0
  150. fleetpull/network/contract/envelope_fetcher.py +33 -0
  151. fleetpull/network/contract/envelopes.py +189 -0
  152. fleetpull/network/contract/outcome.py +43 -0
  153. fleetpull/network/contract/page_decoder.py +101 -0
  154. fleetpull/network/contract/request.py +97 -0
  155. fleetpull/network/decoders/__init__.py +40 -0
  156. fleetpull/network/decoders/_window_stamp.py +78 -0
  157. fleetpull/network/decoders/geotab.py +309 -0
  158. fleetpull/network/decoders/motive.py +204 -0
  159. fleetpull/network/decoders/motive_reports.py +114 -0
  160. fleetpull/network/decoders/samsara.py +343 -0
  161. fleetpull/network/decoders/samsara_reports.py +119 -0
  162. fleetpull/network/decoders/single_page.py +55 -0
  163. fleetpull/network/limits/__init__.py +13 -0
  164. fleetpull/network/limits/bucket_math.py +58 -0
  165. fleetpull/network/limits/limiter.py +153 -0
  166. fleetpull/network/limits/registry.py +96 -0
  167. fleetpull/network/posture/__init__.py +6 -0
  168. fleetpull/network/posture/client_options.py +96 -0
  169. fleetpull/network/retry/__init__.py +13 -0
  170. fleetpull/network/retry/decision.py +155 -0
  171. fleetpull/network/tls/__init__.py +5 -0
  172. fleetpull/network/tls/truststore_context.py +35 -0
  173. fleetpull/orchestrator/__init__.py +30 -0
  174. fleetpull/orchestrator/backfill.py +127 -0
  175. fleetpull/orchestrator/batch.py +155 -0
  176. fleetpull/orchestrator/bisection.py +271 -0
  177. fleetpull/orchestrator/drivers.py +350 -0
  178. fleetpull/orchestrator/entry.py +347 -0
  179. fleetpull/orchestrator/executors.py +128 -0
  180. fleetpull/orchestrator/fanout.py +163 -0
  181. fleetpull/orchestrator/feed_drive.py +266 -0
  182. fleetpull/orchestrator/metadata_projection.py +195 -0
  183. fleetpull/orchestrator/outcome.py +53 -0
  184. fleetpull/orchestrator/recording.py +85 -0
  185. fleetpull/orchestrator/resume.py +143 -0
  186. fleetpull/orchestrator/roster_harvest.py +108 -0
  187. fleetpull/orchestrator/roster_refresh.py +363 -0
  188. fleetpull/orchestrator/runner.py +262 -0
  189. fleetpull/orchestrator/shape_resolution.py +221 -0
  190. fleetpull/orchestrator/spine.py +166 -0
  191. fleetpull/orchestrator/streaming.py +153 -0
  192. fleetpull/orchestrator/unit_loop.py +273 -0
  193. fleetpull/orchestrator/watermark_drive.py +455 -0
  194. fleetpull/paths/__init__.py +13 -0
  195. fleetpull/paths/datasets.py +38 -0
  196. fleetpull/paths/partitions.py +41 -0
  197. fleetpull/paths/resolution.py +98 -0
  198. fleetpull/polars_typing/__init__.py +20 -0
  199. fleetpull/py.typed +0 -0
  200. fleetpull/records/__init__.py +19 -0
  201. fleetpull/records/convert.py +53 -0
  202. fleetpull/records/dataframe.py +63 -0
  203. fleetpull/records/event_time.py +70 -0
  204. fleetpull/records/fields.py +190 -0
  205. fleetpull/records/flatten.py +54 -0
  206. fleetpull/records/roster_members.py +74 -0
  207. fleetpull/records/schema.py +73 -0
  208. fleetpull/records/validation.py +62 -0
  209. fleetpull/resources/__init__.py +10 -0
  210. fleetpull/resources/config.example.yaml +222 -0
  211. fleetpull/roster/__init__.py +16 -0
  212. fleetpull/roster/definition.py +46 -0
  213. fleetpull/roster/key.py +40 -0
  214. fleetpull/roster/registry.py +93 -0
  215. fleetpull/state/__init__.py +36 -0
  216. fleetpull/state/cursors.py +417 -0
  217. fleetpull/state/database.py +463 -0
  218. fleetpull/state/migrations.py +430 -0
  219. fleetpull/state/reconcile.py +127 -0
  220. fleetpull/state/rosters.py +159 -0
  221. fleetpull/state/run_ledger.py +572 -0
  222. fleetpull/state/work_units.py +648 -0
  223. fleetpull/storage/__init__.py +30 -0
  224. fleetpull/storage/append.py +187 -0
  225. fleetpull/storage/atomic.py +161 -0
  226. fleetpull/storage/files.py +176 -0
  227. fleetpull/storage/frames.py +98 -0
  228. fleetpull/storage/metadata.py +161 -0
  229. fleetpull/storage/partitioned.py +205 -0
  230. fleetpull/storage/pruning.py +169 -0
  231. fleetpull/storage/read.py +35 -0
  232. fleetpull/storage/result.py +34 -0
  233. fleetpull/storage/single_file.py +130 -0
  234. fleetpull/storage/splitting.py +88 -0
  235. fleetpull/storage/staging.py +178 -0
  236. fleetpull/storage/writers.py +145 -0
  237. fleetpull/timing/__init__.py +21 -0
  238. fleetpull/timing/canon.py +92 -0
  239. fleetpull/timing/clock.py +192 -0
  240. fleetpull/timing/codec.py +122 -0
  241. fleetpull/timing/sleeper.py +65 -0
  242. fleetpull/vocabulary/__init__.py +16 -0
  243. fleetpull/vocabulary/json_types.py +20 -0
  244. fleetpull/vocabulary/provider.py +29 -0
  245. fleetpull/vocabulary/quota_scope.py +47 -0
  246. fleetpull/vocabulary/response_category.py +32 -0
  247. fleetpull-0.1.0.dist-info/METADATA +248 -0
  248. fleetpull-0.1.0.dist-info/RECORD +252 -0
  249. fleetpull-0.1.0.dist-info/WHEEL +5 -0
  250. fleetpull-0.1.0.dist-info/entry_points.txt +2 -0
  251. fleetpull-0.1.0.dist-info/licenses/LICENSE +202 -0
  252. 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
+ ]
@@ -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
@@ -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
+ )