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
@@ -0,0 +1,94 @@
1
+ # src/fleetpull/incremental/window.py
2
+ """The half-open watermark resume window — the canonical internal fetch window.
3
+
4
+ A pure, stdlib-only leaf beside the cursors (DESIGN §4). ``DateWindow`` is the
5
+ *resume value* a watermark fetch is built from, distinct from the stored
6
+ ``DateWatermark`` cursor: the cursor records the maximum event timestamp seen, and
7
+ the window is what the resume resolver derives from it (``watermark - lookback`` up
8
+ to the trailing edge). A fetch is built from the window, never from the watermark
9
+ directly.
10
+
11
+ The window is half-open, ``[start, end)`` — start inclusive, end exclusive — and
12
+ that is the canonical internal form: the half-open boundary is what lets storage's
13
+ delete-by-window predicate (``>= start & < end``) and the start-anchored
14
+ append-filter share one rule, so a cross-boundary event is never double-counted at
15
+ a window edge (DESIGN §4). Unlike the pure ``cursor.py`` carriers, ``DateWindow``
16
+ enforces one structural invariant on construction — ``start < end`` — because that
17
+ ordering has no downstream enforcement; UTC validity, which does (the codec
18
+ boundary), is deferred there exactly as ``DateWatermark`` defers it.
19
+ """
20
+
21
+ from dataclasses import dataclass
22
+ from datetime import date, datetime, timedelta
23
+
24
+ __all__: list[str] = ['DateWindow']
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class DateWindow:
29
+ """
30
+ The half-open ``[start, end)`` watermark resume window.
31
+
32
+ The resume value the spec-builder turns into a request, distinct from the
33
+ stored ``DateWatermark`` cursor — a fetch is built from this window, not from
34
+ the watermark. Half-open by definition: ``start`` inclusive, ``end`` exclusive.
35
+ The half-open boundary is the canonical internal convention that lets the
36
+ delete-by-window predicate and the start-anchored append-filter share one rule
37
+ (DESIGN §4); there is deliberately no ``contains`` / ``__contains__`` —
38
+ membership is storage's vectorized Polars ``>= start & < end``, never scalar
39
+ row-level.
40
+
41
+ Construction enforces the one structural invariant, ``start < end`` (strict,
42
+ well-ordered, mirroring the run ledger's window). It lives here on the type,
43
+ not deferred like ``DateWatermark``'s UTC check, because the ordering has no
44
+ downstream boundary to catch it — an inverted or empty window is a loud-failure
45
+ bug, not a value to carry; the resume resolver returns ``None`` for a caught-up
46
+ ``start >= end`` rather than constructing one, so this invariant now backstops a
47
+ direct construction bug. UTC validity is not checked here; it crosses the codec
48
+ boundary when a spec-builder serializes the bounds, and that boundary raises on
49
+ naive/non-UTC, exactly as for ``DateWatermark``.
50
+
51
+ Attributes:
52
+ start: The window's inclusive start, timezone-aware UTC.
53
+ end: The window's exclusive end, timezone-aware UTC; must be after
54
+ ``start``.
55
+ """
56
+
57
+ start: datetime
58
+ end: datetime
59
+
60
+ def __post_init__(self) -> None:
61
+ """
62
+ Enforce the ``start < end`` ordering invariant on construction.
63
+
64
+ Raises:
65
+ ValueError: ``start`` is not strictly before ``end`` (an inverted or
66
+ empty window); the message names both bounds.
67
+ TypeError: ``start`` and ``end`` mix naive and aware datetimes —
68
+ surfaced from the stdlib comparison, a loud failure (UTC validity
69
+ otherwise defers to the codec boundary).
70
+
71
+ Side Effects:
72
+ None — reads ``start`` / ``end`` and may raise.
73
+ """
74
+ if self.start >= self.end:
75
+ raise ValueError(
76
+ f'DateWindow requires start < end; got start={self.start!r}, '
77
+ f'end={self.end!r}'
78
+ )
79
+
80
+ @property
81
+ def last_covered_date(self) -> date:
82
+ """
83
+ The last calendar date the half-open window covers.
84
+
85
+ The ``(end - 1µs).date()`` derivation, stated once: the exclusive
86
+ ``end`` itself is not covered, so a window ending exactly at UTC
87
+ midnight does not cover that date while a mid-day ``end`` does. The
88
+ one-microsecond step back is exact because every datetime in
89
+ fleetpull is microsecond-precision UTC end to end (DESIGN §3/§4).
90
+
91
+ Returns:
92
+ The UTC calendar date of the last covered instant.
93
+ """
94
+ return (self.end - timedelta(microseconds=1)).date()
@@ -0,0 +1,5 @@
1
+ """Package logging setup, driven by a validated LoggerConfig."""
2
+
3
+ from fleetpull.logger.setup import setup_logger
4
+
5
+ __all__: list[str] = ['setup_logger']
@@ -0,0 +1,145 @@
1
+ # src/fleetpull/logger/setup.py
2
+ """Logging configuration for the fleetpull package.
3
+
4
+ Provides centralized logging setup so every module that calls
5
+ ``logging.getLogger(__name__)`` inherits the same level, format, and
6
+ handler configuration. Users drive the setup from a validated
7
+ ``LoggerConfig`` — no ad-hoc level parsing or path expansion happens
8
+ here; that is the configuration layer's job.
9
+
10
+ Library convention:
11
+ fleetpull attaches no handlers at import time and registers no
12
+ ``NullHandler``. When a consuming application never configures
13
+ logging, Python's ``lastResort`` handler surfaces WARNING-and-above
14
+ records on stderr — for a data-fetching library, warnings (rate
15
+ limit penalties, session invalidations) should be visible by
16
+ default, not swallowed. Applications that want full control
17
+ configure the ``'fleetpull'`` logger with stdlib tools or call
18
+ :func:`setup_logger`.
19
+
20
+ Timestamps:
21
+ All log timestamps are UTC with a ``Z`` suffix. fleetpull's data
22
+ semantics are UTC end to end; log time matches data time so
23
+ incident correlation never crosses a timezone boundary.
24
+ """
25
+
26
+ import logging
27
+ import sys
28
+ import time
29
+ from typing import Final, TextIO
30
+
31
+ from fleetpull.config import LoggerConfig
32
+
33
+ __all__: list[str] = ['setup_logger']
34
+
35
+ # Name of the package's root logger; every fleetpull module logger
36
+ # (logging.getLogger(__name__)) is a descendant and inherits this
37
+ # configuration.
38
+ _PACKAGE_LOGGER_NAME: Final[str] = 'fleetpull'
39
+
40
+ # Shared format and date-format for every handler this module attaches.
41
+ # Module-level constants so tests can assert against them by identity
42
+ # rather than re-typing the literals.
43
+ _LOG_FORMAT: Final[str] = (
44
+ '%(asctime)s - %(levelname)-8s - [%(threadName)s] - [%(name)s] - %(message)s'
45
+ )
46
+ _DATE_FORMAT: Final[str] = '%Y-%m-%dT%H:%M:%SZ'
47
+
48
+
49
+ def setup_logger(config: LoggerConfig) -> None:
50
+ """
51
+ Configure the fleetpull package logger from a ``LoggerConfig``.
52
+
53
+ Clears any handlers already attached to the ``fleetpull`` logger,
54
+ installs a console handler (always) and an optional file handler,
55
+ and sets the package logger's level so that neither handler is
56
+ starved by an ancestor filter. All module loggers created with
57
+ ``logging.getLogger(__name__)`` inside fleetpull inherit the result
58
+ automatically.
59
+
60
+ The function is idempotent: calling it twice produces the same
61
+ handler count as one call. The second call's ``LoggerConfig`` fully
62
+ supersedes the first.
63
+
64
+ Args:
65
+ config: Validated ``LoggerConfig``. ``console_level`` drives
66
+ stderr output; ``file_path``, when set, enables file
67
+ logging at ``file_level``.
68
+
69
+ Returns:
70
+ None. Matches the stdlib convention (``logging.basicConfig``
71
+ and friends all return None); module loggers inherit the
72
+ configuration automatically, so no handle is useful.
73
+
74
+ Side Effects:
75
+ - Closes and replaces the ``fleetpull`` logger's existing
76
+ handlers (closing releases the previous configuration's file
77
+ descriptors).
78
+ - Sets the ``fleetpull`` logger's level (the minimum of the
79
+ active handler levels, so no record is filtered at the
80
+ logger before reaching a handler).
81
+ - Sets ``propagate = False`` on the ``fleetpull`` logger so a
82
+ hosting application that configures the root logger does not
83
+ see duplicate records.
84
+ - Creates the parent directory of ``file_path`` if file
85
+ logging is enabled and the directory does not exist.
86
+ - Emits a single INFO-level record confirming the
87
+ configuration, through the just-installed handlers.
88
+ """
89
+ package_logger: logging.Logger = logging.getLogger(_PACKAGE_LOGGER_NAME)
90
+
91
+ # Close, then clear, existing handlers: closing releases the file
92
+ # descriptors (and Windows file locks) a previous configuration
93
+ # holds; clearing makes the call idempotent so a caller can
94
+ # reconfigure at runtime without accumulating duplicates. Closing
95
+ # a StreamHandler never closes its underlying stream, so stderr is
96
+ # safe.
97
+ for previous_handler in package_logger.handlers:
98
+ previous_handler.close()
99
+ package_logger.handlers.clear()
100
+
101
+ # A hosting application may configure the root logger; disabling
102
+ # propagation prevents every fleetpull record from being emitted
103
+ # twice.
104
+ package_logger.propagate = False
105
+
106
+ log_formatter: logging.Formatter = logging.Formatter(
107
+ fmt=_LOG_FORMAT,
108
+ datefmt=_DATE_FORMAT,
109
+ )
110
+ # UTC timestamps: see the module docstring. The converter applies
111
+ # to asctime only; record.created remains an epoch float.
112
+ log_formatter.converter = time.gmtime
113
+
114
+ console_handler: logging.StreamHandler[TextIO] = logging.StreamHandler(sys.stderr)
115
+ console_handler.setFormatter(log_formatter)
116
+ console_handler.setLevel(config.console_level)
117
+ package_logger.addHandler(console_handler)
118
+
119
+ if config.file_path is not None:
120
+ config.file_path.parent.mkdir(parents=True, exist_ok=True)
121
+
122
+ file_handler: logging.FileHandler = logging.FileHandler(
123
+ filename=config.file_path,
124
+ mode='a',
125
+ encoding='utf-8',
126
+ )
127
+ file_handler.setFormatter(log_formatter)
128
+ file_handler.setLevel(config.file_level)
129
+ package_logger.addHandler(file_handler)
130
+
131
+ # The logger's own level must not filter records before they reach
132
+ # any handler. file_level participates only when a file handler is
133
+ # actually attached; otherwise it is inert by design.
134
+ effective_level: int = (
135
+ min(config.console_level, config.file_level)
136
+ if config.file_path is not None
137
+ else config.console_level
138
+ )
139
+ package_logger.setLevel(effective_level)
140
+
141
+ package_logger.info(
142
+ 'Logging configured: console_level=%s%s',
143
+ logging.getLevelName(config.console_level),
144
+ f', file={config.file_path}' if config.file_path is not None else '',
145
+ )
@@ -0,0 +1,6 @@
1
+ """The response-model contract: the config-policy base and type-recovery coercion."""
2
+
3
+ from fleetpull.model_contract.coercions import empty_str_to_none
4
+ from fleetpull.model_contract.response import ResponseModel
5
+
6
+ __all__: list[str] = ['ResponseModel', 'empty_str_to_none']
@@ -0,0 +1,33 @@
1
+ # src/fleetpull/model_contract/coercions.py
2
+ """Type-recovery coercion for stringly wire values.
3
+
4
+ Value-level wire-cleaning on a model is allowed only where recovering
5
+ the declared type is structural (DESIGN section 9): a field typed
6
+ ``int`` receiving ``""`` cannot validate at all, so a before-validator
7
+ lifts the empty string ahead of parsing (Motive ``VehicleSummary.year``
8
+ is the shipped case). String fields never use this — models preserve
9
+ ``""`` faithfully from the wire, and empty strings normalize to null
10
+ once, at the DataFrame boundary (``records.normalize_empty_strings``).
11
+ A formatted value someone chose (``"22.3 mi"``) is never touched
12
+ anywhere: parsing it would presume a use case.
13
+ """
14
+
15
+ __all__: list[str] = ['empty_str_to_none']
16
+
17
+
18
+ # A BeforeValidator receives the raw wire value, and the layering
19
+ # contract bars model_contract from vocabulary's JsonValue.
20
+ # typing-justified: object is the strictest annotation available here.
21
+ def empty_str_to_none(value: object) -> object:
22
+ """Lift a bare empty string to ``None``; everything else passes through.
23
+
24
+ Args:
25
+ value: The raw wire value, ahead of field validation.
26
+
27
+ Returns:
28
+ ``None`` when ``value`` is exactly ``''``; otherwise ``value``
29
+ unchanged.
30
+ """
31
+ if value == '':
32
+ return None
33
+ return value
@@ -0,0 +1,45 @@
1
+ # src/fleetpull/model_contract/response.py
2
+ """The shared config-policy base every per-record response model extends.
3
+
4
+ Carries only configuration — no fields, no methods, no schema-derivation helpers
5
+ (those are the records layer's concern, §9, and attach when it is built). Models
6
+ that extend ``ResponseModel`` stay pure API mirrors.
7
+ """
8
+
9
+ from pydantic import BaseModel, ConfigDict
10
+
11
+ __all__: list[str] = ['ResponseModel']
12
+
13
+
14
+ class ResponseModel(BaseModel):
15
+ """
16
+ Config-policy base for response models; subclasses add only fields.
17
+
18
+ Each ``model_config`` setting is deliberate:
19
+
20
+ - ``frozen=True`` — verified inbound data is immutable once validated.
21
+ - ``extra='ignore'`` — response models mirror evolving provider APIs, so a
22
+ field a provider adds is dropped, not a crash (records derives the Polars
23
+ schema from declared fields, so an unknown field has nowhere to land
24
+ anyway). A deliberate departure from the house ``extra='forbid'`` default,
25
+ justified for inbound mirrors specifically.
26
+ - non-strict (the absence of ``strict=True``) — Pydantic's lax coercion turns
27
+ Motive's stringly-typed numerics into typed values; ``strict=True`` would
28
+ reject them, and the §9 coercion overrides only handle what lax cannot. (The
29
+ GeoTab auth slice models use ``strict=True`` because auth responses are
30
+ well-typed — the opposite case.)
31
+ - ``populate_by_name=True`` — models alias camelCase wire keys to snake_case
32
+ fields; this lets construction by either the alias or the field name
33
+ succeed.
34
+ - ``str_strip_whitespace=True`` — trims incoming strings as structural
35
+ hygiene, never a semantic transform.
36
+ - ``validate_default=True`` — house standard.
37
+ """
38
+
39
+ model_config = ConfigDict(
40
+ frozen=True,
41
+ extra='ignore',
42
+ populate_by_name=True,
43
+ str_strip_whitespace=True,
44
+ validate_default=True,
45
+ )
@@ -0,0 +1,7 @@
1
+ """Pydantic response models, one subpackage per provider.
2
+
3
+ ``ResponseModel`` (the config-policy base) lives in ``fleetpull.model_contract``;
4
+ provider model packages import it from there directly.
5
+ """
6
+
7
+ __all__: list[str] = []
@@ -0,0 +1,166 @@
1
+ # src/fleetpull/models/geotab/__init__.py
2
+ """GeoTab response models; the face re-exports each endpoint module's models."""
3
+
4
+ from fleetpull.models.geotab.annotation_log import (
5
+ AnnotationLog,
6
+ AnnotationLogDriverRef,
7
+ AnnotationLogDutyStatusLogRef,
8
+ )
9
+ from fleetpull.models.geotab.audit import Audit
10
+ from fleetpull.models.geotab.device import CustomFeatures, Device, DeviceFlags
11
+ from fleetpull.models.geotab.driver_change import (
12
+ DriverChange,
13
+ DriverChangeDeviceRef,
14
+ DriverChangeDriverRef,
15
+ )
16
+ from fleetpull.models.geotab.duty_status_log import (
17
+ DutyStatusLog,
18
+ DutyStatusLogDeviceRef,
19
+ DutyStatusLogDriverRef,
20
+ )
21
+ from fleetpull.models.geotab.dvir_log import (
22
+ DvirLog,
23
+ DvirLogDefectList,
24
+ DvirLogDeviceRef,
25
+ DvirLogDriverRef,
26
+ DvirLogTrailerRef,
27
+ )
28
+ from fleetpull.models.geotab.exception_event import (
29
+ ExceptionEvent,
30
+ ExceptionEventDeviceRef,
31
+ ExceptionEventDiagnosticRef,
32
+ ExceptionEventDriverRef,
33
+ ExceptionEventRuleRef,
34
+ )
35
+ from fleetpull.models.geotab.fault_data import (
36
+ FaultData,
37
+ FaultDataControllerRef,
38
+ FaultDataDeviceRef,
39
+ FaultDataDiagnosticRef,
40
+ FaultDataFailureModeRef,
41
+ FaultDataFaultStates,
42
+ )
43
+ from fleetpull.models.geotab.fill_up import (
44
+ FillUp,
45
+ FillUpDeviceRef,
46
+ FillUpDriverRef,
47
+ FillUpLocation,
48
+ FillUpTankCapacity,
49
+ FillUpTankLevelExtrema,
50
+ FillUpTankLevelPoint,
51
+ )
52
+ from fleetpull.models.geotab.fuel_and_energy_used import (
53
+ FuelAndEnergyUsed,
54
+ FuelAndEnergyUsedDeviceRef,
55
+ )
56
+ from fleetpull.models.geotab.fuel_tax_detail import (
57
+ FuelTaxDetail,
58
+ FuelTaxDetailDeviceRef,
59
+ FuelTaxDetailDriverRef,
60
+ )
61
+ from fleetpull.models.geotab.log_record import LogRecord, LogRecordDeviceRef
62
+ from fleetpull.models.geotab.media_file import (
63
+ MediaFile,
64
+ MediaFileDeviceRef,
65
+ MediaFileDriverRef,
66
+ )
67
+ from fleetpull.models.geotab.shared import (
68
+ GeotabAddressedLocation,
69
+ GeotabCoordinate,
70
+ GeotabPostalAddress,
71
+ GeotabTimeSpan,
72
+ bare_id_to_reference,
73
+ parse_timespan,
74
+ )
75
+ from fleetpull.models.geotab.shipment_log import (
76
+ ShipmentLog,
77
+ ShipmentLogDeviceRef,
78
+ ShipmentLogDriverRef,
79
+ )
80
+ from fleetpull.models.geotab.status_data import (
81
+ StatusData,
82
+ StatusDataDeviceRef,
83
+ StatusDataDiagnosticRef,
84
+ )
85
+ from fleetpull.models.geotab.text_message import (
86
+ TextMessage,
87
+ TextMessageContent,
88
+ TextMessageDeviceRef,
89
+ )
90
+ from fleetpull.models.geotab.trip import (
91
+ Trip,
92
+ TripDeviceRef,
93
+ TripDriverRef,
94
+ TripStopPoint,
95
+ )
96
+ from fleetpull.models.geotab.user import User, UserAccessGroupFilterRef
97
+
98
+ __all__: list[str] = [
99
+ 'AnnotationLog',
100
+ 'AnnotationLogDriverRef',
101
+ 'AnnotationLogDutyStatusLogRef',
102
+ 'Audit',
103
+ 'CustomFeatures',
104
+ 'Device',
105
+ 'DeviceFlags',
106
+ 'DriverChange',
107
+ 'DriverChangeDeviceRef',
108
+ 'DriverChangeDriverRef',
109
+ 'DutyStatusLog',
110
+ 'DutyStatusLogDeviceRef',
111
+ 'DutyStatusLogDriverRef',
112
+ 'DvirLog',
113
+ 'DvirLogDefectList',
114
+ 'DvirLogDeviceRef',
115
+ 'DvirLogDriverRef',
116
+ 'DvirLogTrailerRef',
117
+ 'ExceptionEvent',
118
+ 'ExceptionEventDeviceRef',
119
+ 'ExceptionEventDiagnosticRef',
120
+ 'ExceptionEventDriverRef',
121
+ 'ExceptionEventRuleRef',
122
+ 'FaultData',
123
+ 'FaultDataControllerRef',
124
+ 'FaultDataDeviceRef',
125
+ 'FaultDataDiagnosticRef',
126
+ 'FaultDataFailureModeRef',
127
+ 'FaultDataFaultStates',
128
+ 'FillUp',
129
+ 'FillUpDeviceRef',
130
+ 'FillUpDriverRef',
131
+ 'FillUpLocation',
132
+ 'FillUpTankCapacity',
133
+ 'FillUpTankLevelExtrema',
134
+ 'FillUpTankLevelPoint',
135
+ 'FuelAndEnergyUsed',
136
+ 'FuelAndEnergyUsedDeviceRef',
137
+ 'FuelTaxDetail',
138
+ 'FuelTaxDetailDeviceRef',
139
+ 'FuelTaxDetailDriverRef',
140
+ 'GeotabAddressedLocation',
141
+ 'GeotabCoordinate',
142
+ 'GeotabPostalAddress',
143
+ 'GeotabTimeSpan',
144
+ 'LogRecord',
145
+ 'LogRecordDeviceRef',
146
+ 'MediaFile',
147
+ 'MediaFileDeviceRef',
148
+ 'MediaFileDriverRef',
149
+ 'ShipmentLog',
150
+ 'ShipmentLogDeviceRef',
151
+ 'ShipmentLogDriverRef',
152
+ 'StatusData',
153
+ 'StatusDataDeviceRef',
154
+ 'StatusDataDiagnosticRef',
155
+ 'TextMessage',
156
+ 'TextMessageContent',
157
+ 'TextMessageDeviceRef',
158
+ 'Trip',
159
+ 'TripDeviceRef',
160
+ 'TripDriverRef',
161
+ 'TripStopPoint',
162
+ 'User',
163
+ 'UserAccessGroupFilterRef',
164
+ 'bare_id_to_reference',
165
+ 'parse_timespan',
166
+ ]
@@ -0,0 +1,109 @@
1
+ # src/fleetpull/models/geotab/annotation_log.py
2
+ """GeoTab AnnotationLog response model (``GetFeed`` on ``typeName: AnnotationLog``).
3
+
4
+ Written from the 2026-07-21 feed wave three SCALE census (walked at
5
+ scale at the probed tenant), never from docs. An AnnotationLog is one
6
+ free-text annotation attached to an HOS duty-status log — a versioned
7
+ feed (annotations are user-editable), so re-emission under newer
8
+ ``version`` tokens is expected and the consumer reconciles by
9
+ ``(id, max version)`` (DESIGN §4).
10
+
11
+ This vertical COMPLETES the wave-two ``duty_status_logs`` loop: a
12
+ DutyStatusLog carries an ``annotations`` id-list (the strict
13
+ ``list[str]`` reduction), and each AnnotationLog's ``dutyStatusLog.id``
14
+ points BACK to the DutyStatusLog it annotates — a bidirectional join
15
+ across the two verticals (``annotation_logs.duty_status_log__id`` ↔
16
+ ``duty_status_logs.annotations``).
17
+
18
+ Requiredness posture (the wave-two conservative stance, DESIGN §8): the
19
+ census is a TENANT-SCOPED observation (8,857 records), so structural
20
+ requiredness is limited to the record identity — ``id``, ``dateTime``
21
+ (the event time), ``version``, and the primary entity ref
22
+ (``dutyStatusLog``, the annotated log — the annotation's subject) — and
23
+ every other field is optional EVEN where the census was total. The
24
+ observed arms:
25
+
26
+ - ``dutyStatusLog`` and ``driver`` were object-only (``{id}``) at
27
+ scale; both ride the shared ``bare_id_to_reference`` lift regardless
28
+ (the census-scope lesson, DESIGN §8: a tenant census cannot prove
29
+ the string arm absent, and the lift is structural and
30
+ sentinel-agnostic).
31
+
32
+ ``dateTime`` is recovered tz-aware by validation, the GeoTab sibling
33
+ idiom. ``comment`` is a census-open free-text string.
34
+ """
35
+
36
+ from datetime import datetime
37
+ from typing import Annotated
38
+
39
+ from pydantic import BeforeValidator, ConfigDict
40
+ from pydantic.alias_generators import to_camel
41
+
42
+ from fleetpull.model_contract import ResponseModel
43
+ from fleetpull.models.geotab.shared import bare_id_to_reference
44
+
45
+ __all__: list[str] = [
46
+ 'AnnotationLog',
47
+ 'AnnotationLogDriverRef',
48
+ 'AnnotationLogDutyStatusLogRef',
49
+ ]
50
+
51
+
52
+ class AnnotationLogDriverRef(ResponseModel):
53
+ """The annotating driver's reference.
54
+
55
+ Census-observed as an ``{id}`` object at scale; the shared coercion
56
+ lifts a bare string defensively (the census-scope lesson).
57
+ """
58
+
59
+ model_config = ConfigDict(alias_generator=to_camel)
60
+
61
+ id: str
62
+
63
+
64
+ class AnnotationLogDutyStatusLogRef(ResponseModel):
65
+ """The annotated duty-status log's reference — the annotation's subject.
66
+
67
+ The BACK-REFERENCE completing the wave-two loop: ``id`` points to
68
+ the ``duty_status_logs`` record this annotation belongs to.
69
+ Census-observed as an ``{id}`` object at scale; the shared coercion
70
+ lifts a bare string defensively (the census-scope lesson).
71
+ """
72
+
73
+ model_config = ConfigDict(alias_generator=to_camel)
74
+
75
+ id: str
76
+
77
+
78
+ class AnnotationLog(ResponseModel):
79
+ """One GeoTab duty-status-log annotation from the AnnotationLog feed.
80
+
81
+ The wave-two conservative mirror (the module docstring's posture):
82
+ ``id`` / ``date_time`` / ``version`` / ``duty_status_log`` required,
83
+ everything else optional even where census-total.
84
+
85
+ Attributes:
86
+ comment: The annotation's free text (census-open str).
87
+ date_time: The annotation's UTC instant — the endpoint's event
88
+ time.
89
+ driver: The annotating driver's reference (object-only at scale;
90
+ defensively lifted).
91
+ duty_status_log: The annotated duty-status log's reference — the
92
+ back-reference to the ``duty_status_logs`` vertical.
93
+ id: GeoTab's record id.
94
+ version: The record's version token — the reconcile key beside
95
+ ``id``.
96
+ """
97
+
98
+ model_config = ConfigDict(alias_generator=to_camel)
99
+
100
+ comment: str | None = None
101
+ date_time: datetime
102
+ driver: Annotated[
103
+ AnnotationLogDriverRef | None, BeforeValidator(bare_id_to_reference)
104
+ ] = None
105
+ duty_status_log: Annotated[
106
+ AnnotationLogDutyStatusLogRef, BeforeValidator(bare_id_to_reference)
107
+ ]
108
+ id: str
109
+ version: str
@@ -0,0 +1,56 @@
1
+ # src/fleetpull/models/geotab/audit.py
2
+ """GeoTab Audit response model (``GetFeed`` on ``typeName: Audit``).
3
+
4
+ Written from the 2026-07-21 feed wave three SCALE census (walked at
5
+ scale at the probed tenant), never from docs. An Audit is one
6
+ configuration-change audit-trail entry — a versioned feed, so
7
+ re-emission under newer ``version`` tokens is expected and the consumer
8
+ reconciles by ``(id, max version)`` (DESIGN §4).
9
+
10
+ The SIMPLEST feed vertical: NO reference fields at all (no ref models),
11
+ six flat keys. Requiredness posture (the wave-two conservative stance,
12
+ DESIGN §8): the census is a TENANT-SCOPED observation (20,000 records),
13
+ so structural requiredness is limited to the record identity — ``id``,
14
+ ``dateTime`` (the event time), and ``version`` — and every other field
15
+ is optional EVEN where the census was total.
16
+
17
+ ``dateTime`` is recovered tz-aware by validation, the GeoTab sibling
18
+ idiom. ``comment``, ``name``, and ``userName`` are census-open
19
+ free-text strings.
20
+ """
21
+
22
+ from datetime import datetime
23
+
24
+ from pydantic import ConfigDict
25
+ from pydantic.alias_generators import to_camel
26
+
27
+ from fleetpull.model_contract import ResponseModel
28
+
29
+ __all__: list[str] = ['Audit']
30
+
31
+
32
+ class Audit(ResponseModel):
33
+ """One GeoTab audit-trail entry from the Audit feed.
34
+
35
+ The wave-two conservative mirror (the module docstring's posture):
36
+ ``id`` / ``date_time`` / ``version`` required, everything else
37
+ optional even where census-total.
38
+
39
+ Attributes:
40
+ comment: The audit entry's free text (census-open str).
41
+ date_time: The entry's UTC instant — the endpoint's event time.
42
+ id: GeoTab's record id.
43
+ name: The audited object's name (census-open str).
44
+ user_name: The acting user's name (census-open str).
45
+ version: The record's version token — the reconcile key beside
46
+ ``id``.
47
+ """
48
+
49
+ model_config = ConfigDict(alias_generator=to_camel)
50
+
51
+ comment: str | None = None
52
+ date_time: datetime
53
+ id: str
54
+ name: str | None = None
55
+ user_name: str | None = None
56
+ version: str