python-sendparcel 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.
@@ -0,0 +1,449 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-sendparcel
3
+ Version: 0.1.0
4
+ Summary: Framework-agnostic parcel shipping core for Python.
5
+ Project-URL: Homepage, https://github.com/sendparcel/python-sendparcel
6
+ Project-URL: Documentation, https://python-sendparcel.readthedocs.io/
7
+ Project-URL: Repository, https://github.com/sendparcel/python-sendparcel
8
+ Project-URL: Changelog, https://github.com/sendparcel/python-sendparcel/blob/main/CHANGELOG.md
9
+ Project-URL: Issue Tracker, https://github.com/sendparcel/python-sendparcel/issues
10
+ Author-email: Dominik Kozaczko <dominik@kozaczko.info>
11
+ License: MIT
12
+ License-File: LICENSE
13
+ Keywords: delivery,logistics,parcel,sendparcel,shipping
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Natural Language :: English
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: anyio>=4.0
25
+ Requires-Dist: httpx>=0.27.0
26
+ Requires-Dist: transitions>=0.9.0
27
+ Provides-Extra: all
28
+ Requires-Dist: django-sendparcel>=0.1.0; extra == 'all'
29
+ Requires-Dist: fastapi-sendparcel>=0.1.0; extra == 'all'
30
+ Requires-Dist: litestar-sendparcel>=0.1.0; extra == 'all'
31
+ Provides-Extra: dev
32
+ Requires-Dist: pre-commit-hooks>=6.0.0; extra == 'dev'
33
+ Requires-Dist: pre-commit>=4.0; extra == 'dev'
34
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
35
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
36
+ Requires-Dist: pytest>=8.0; extra == 'dev'
37
+ Requires-Dist: ruff>=0.9.0; extra == 'dev'
38
+ Requires-Dist: ty>=0.0.16; extra == 'dev'
39
+ Provides-Extra: django
40
+ Requires-Dist: django-sendparcel>=0.1.0; extra == 'django'
41
+ Provides-Extra: dummy
42
+ Provides-Extra: fastapi
43
+ Requires-Dist: fastapi-sendparcel>=0.1.0; extra == 'fastapi'
44
+ Provides-Extra: frameworks
45
+ Requires-Dist: django-sendparcel>=0.1.0; extra == 'frameworks'
46
+ Requires-Dist: fastapi-sendparcel>=0.1.0; extra == 'frameworks'
47
+ Requires-Dist: litestar-sendparcel>=0.1.0; extra == 'frameworks'
48
+ Provides-Extra: litestar
49
+ Requires-Dist: litestar-sendparcel>=0.1.0; extra == 'litestar'
50
+ Provides-Extra: providers
51
+ Description-Content-Type: text/markdown
52
+
53
+ # python-sendparcel
54
+
55
+ [![PyPI](https://img.shields.io/pypi/v/python-sendparcel.svg)](https://pypi.org/project/python-sendparcel/)
56
+ [![Python Version](https://img.shields.io/pypi/pyversions/python-sendparcel.svg)](https://pypi.org/project/python-sendparcel/)
57
+ [![License](https://img.shields.io/pypi/l/python-sendparcel.svg)](https://pypi.org/project/python-sendparcel/)
58
+
59
+ Framework-agnostic parcel shipping core for Python.
60
+
61
+ ---
62
+
63
+ > **Alpha notice** — This project is at version **0.1.0**. The public API
64
+ > may change between minor releases until 1.0 is reached. Pin your
65
+ > dependency accordingly.
66
+
67
+ ## Features
68
+
69
+ - **Provider plugin system** — register providers via entry points or manually; auto-discovery at first use.
70
+ - **Shipment domain types** — `AddressInfo`, `ParcelInfo`, `LabelInfo`, `ShipmentCreateResult`, `ShipmentStatusResponse`, and `TrackingEvent` as strict TypedDicts.
71
+ - **Finite state machine** — 9-state `ShipmentStatus` enum (`NEW` → `CREATED` → `LABEL_READY` → `IN_TRANSIT` → `OUT_FOR_DELIVERY` → `DELIVERED`, plus `CANCELLED`, `FAILED`, `RETURNED`) with guarded transitions powered by [transitions](https://github.com/pytransitions/transitions).
72
+ - **ShipmentFlow orchestrator** — framework-agnostic async workflow for creating shipments, fetching labels, handling callbacks, polling status, and cancelling.
73
+ - **BaseProvider ABC** — define your own provider by subclassing a single class with well-defined async methods.
74
+ - **Built-in DummyProvider** — deterministic reference provider for testing and local development.
75
+ - **Pluggable validators** — attach validator callables to `ShipmentFlow` for global or per-operation validation.
76
+ - **Runtime protocols** — `Order`, `Shipment`, and `ShipmentRepository` are `@runtime_checkable` protocols; bring your own models and persistence.
77
+ - **Async-first** — the entire runtime is async, powered by [anyio](https://anyio.readthedocs.io/).
78
+
79
+ ## Installation
80
+
81
+ ### With pip
82
+
83
+ ```bash
84
+ pip install python-sendparcel
85
+ ```
86
+
87
+ ### With uv
88
+
89
+ ```bash
90
+ uv add python-sendparcel
91
+ ```
92
+
93
+ ### Framework adapters
94
+
95
+ Install the adapter for your web framework:
96
+
97
+ ```bash
98
+ pip install python-sendparcel[django] # Django integration
99
+ pip install python-sendparcel[fastapi] # FastAPI integration
100
+ pip install python-sendparcel[litestar] # Litestar integration
101
+ pip install python-sendparcel[frameworks] # all framework adapters
102
+ pip install python-sendparcel[all] # everything
103
+ ```
104
+
105
+ ### Extras reference
106
+
107
+ | Extra | Installs |
108
+ |---|---|
109
+ | `dummy` | Built-in dummy provider (no extra package) |
110
+ | `django` | `django-sendparcel` |
111
+ | `fastapi` | `fastapi-sendparcel` |
112
+ | `litestar` | `litestar-sendparcel` |
113
+ | `providers` | Built-in providers (currently `dummy`) |
114
+ | `frameworks` | All framework adapters |
115
+ | `all` | Framework adapters |
116
+
117
+ ## Quick Start
118
+
119
+ python-sendparcel is framework-agnostic. You provide implementations of three
120
+ protocols — `Order`, `Shipment`, and `ShipmentRepository` — and the library
121
+ handles orchestration, state transitions, and provider communication.
122
+
123
+ ### 1. Implement the Order protocol
124
+
125
+ ```python
126
+ from decimal import Decimal
127
+ from dataclasses import dataclass, field
128
+
129
+ from sendparcel.types import AddressInfo, ParcelInfo
130
+
131
+
132
+ @dataclass
133
+ class MyOrder:
134
+ """Satisfies the sendparcel Order protocol."""
135
+
136
+ sender: AddressInfo
137
+ receiver: AddressInfo
138
+ parcels: list[ParcelInfo] = field(default_factory=list)
139
+
140
+ def get_total_weight(self) -> Decimal:
141
+ return sum(p["weight_kg"] for p in self.parcels)
142
+
143
+ def get_parcels(self) -> list[ParcelInfo]:
144
+ return self.parcels
145
+
146
+ def get_sender_address(self) -> AddressInfo:
147
+ return self.sender
148
+
149
+ def get_receiver_address(self) -> AddressInfo:
150
+ return self.receiver
151
+ ```
152
+
153
+ ### 2. Implement the Shipment and ShipmentRepository protocols
154
+
155
+ ```python
156
+ from dataclasses import dataclass
157
+
158
+
159
+ @dataclass
160
+ class MyShipment:
161
+ """Satisfies the sendparcel Shipment protocol."""
162
+
163
+ id: str
164
+ order: MyOrder
165
+ status: str = ""
166
+ provider: str = ""
167
+ external_id: str = ""
168
+ tracking_number: str = ""
169
+ label_url: str = ""
170
+
171
+
172
+ class InMemoryRepository:
173
+ """Minimal in-memory ShipmentRepository for demonstration."""
174
+
175
+ def __init__(self):
176
+ self._store: dict[str, MyShipment] = {}
177
+ self._counter = 0
178
+
179
+ async def get_by_id(self, shipment_id: str) -> MyShipment:
180
+ return self._store[shipment_id]
181
+
182
+ async def create(self, **kwargs) -> MyShipment:
183
+ self._counter += 1
184
+ shipment_id = str(self._counter)
185
+ shipment = MyShipment(
186
+ id=shipment_id,
187
+ order=kwargs["order"],
188
+ status=kwargs.get("status", ""),
189
+ provider=kwargs.get("provider", ""),
190
+ )
191
+ self._store[shipment_id] = shipment
192
+ return shipment
193
+
194
+ async def save(self, shipment: MyShipment) -> MyShipment:
195
+ self._store[shipment.id] = shipment
196
+ return shipment
197
+
198
+ async def update_status(
199
+ self, shipment_id: str, status: str, **fields
200
+ ) -> MyShipment:
201
+ shipment = self._store[shipment_id]
202
+ shipment.status = status
203
+ return shipment
204
+ ```
205
+
206
+ ### 3. Create a shipment with ShipmentFlow
207
+
208
+ ```python
209
+ import anyio
210
+
211
+ from sendparcel import ShipmentFlow, ShipmentStatus
212
+ from sendparcel.types import AddressInfo, ParcelInfo
213
+
214
+
215
+ async def main():
216
+ repo = InMemoryRepository()
217
+ flow = ShipmentFlow(repository=repo)
218
+
219
+ order = MyOrder(
220
+ sender=AddressInfo(
221
+ name="Sender Co.",
222
+ line1="ul. Marszalkowska 1",
223
+ city="Warszawa",
224
+ postal_code="00-001",
225
+ country_code="PL",
226
+ ),
227
+ receiver=AddressInfo(
228
+ name="Jan Kowalski",
229
+ line1="ul. Dluga 10",
230
+ city="Gdansk",
231
+ postal_code="80-001",
232
+ country_code="PL",
233
+ ),
234
+ parcels=[ParcelInfo(weight_kg=Decimal("2.5"))],
235
+ )
236
+
237
+ # Create shipment using the built-in dummy provider
238
+ shipment = await flow.create_shipment(order, provider_slug="dummy")
239
+ print(shipment.status) # "created" or "label_ready"
240
+ print(shipment.external_id) # "dummy-1"
241
+ print(shipment.tracking_number) # "DUMMY-1"
242
+
243
+
244
+ anyio.run(main)
245
+ ```
246
+
247
+ ## Architecture
248
+
249
+ python-sendparcel is organized into focused modules:
250
+
251
+ ```
252
+ sendparcel/
253
+ ├── __init__.py # Public API surface
254
+ ├── enums.py # ShipmentStatus, ConfirmationMethod
255
+ ├── types.py # TypedDict definitions (AddressInfo, ParcelInfo, …)
256
+ ├── protocols.py # Order, Shipment, ShipmentRepository protocols
257
+ ├── provider.py # BaseProvider ABC
258
+ ├── registry.py # PluginRegistry with entry-point discovery
259
+ ├── flow.py # ShipmentFlow orchestrator
260
+ ├── fsm.py # State machine transitions (pytransitions)
261
+ ├── validators.py # Pluggable validation chain
262
+ ├── exceptions.py # Exception hierarchy
263
+ └── providers/
264
+ ├── __init__.py # Built-in provider list
265
+ └── dummy.py # DummyProvider reference implementation
266
+ ```
267
+
268
+ ### Key components
269
+
270
+ | Component | Module | Description |
271
+ |---|---|---|
272
+ | `ShipmentFlow` | `flow.py` | Async orchestrator — creates shipments, fetches labels, handles callbacks, polls status, cancels. |
273
+ | `BaseProvider` | `provider.py` | Abstract base class that all shipping providers must subclass. |
274
+ | `PluginRegistry` | `registry.py` | Discovers providers from `sendparcel.providers` entry points and built-ins. Global `registry` singleton. |
275
+ | `ShipmentStatus` | `enums.py` | 9-state `StrEnum` representing the shipment lifecycle. |
276
+ | Domain types | `types.py` | `AddressInfo`, `ParcelInfo`, `LabelInfo`, `ShipmentCreateResult`, `ShipmentStatusResponse`, `TrackingEvent`. |
277
+ | Protocols | `protocols.py` | `Order`, `Shipment`, `ShipmentRepository` — all `@runtime_checkable`. |
278
+ | FSM | `fsm.py` | Transition definitions with guards (e.g. `label_url` required before `confirm_label`). |
279
+ | Validators | `validators.py` | Chain of callables invoked before provider operations. |
280
+
281
+ ### Shipment state machine
282
+
283
+ ```
284
+ mark_in_transit
285
+ ┌────────────────────┐
286
+ │ ▼
287
+ NEW ──confirm_created──▸ CREATED ──confirm_label──▸ LABEL_READY IN_TRANSIT ──mark_out_for_delivery──▸ OUT_FOR_DELIVERY
288
+ │ │ │ │ │ │ │
289
+ │ │ │ │ ├── mark_delivered ─────────────────▸│ │
290
+ │ │ │ │ │ │ │
291
+ │ │ │ │ │ mark_delivered │ │
292
+ │ │ │ │ │ ┌────────────────────────────────-─┘ │
293
+ │ │ │ │ │ ▼ │
294
+ │ │ │ │ │ DELIVERED │
295
+ │ │ │ │ │ │ │
296
+ │ │ │ │ │ └── mark_returned ──▸ RETURNED ◂─────┤
297
+ │ │ │ │ │ ▴ │
298
+ │ │ │ │ └── mark_returned ─────────┘ │
299
+ │ │ │ │ │
300
+ └──────── cancel ─────────┴────── cancel ────────────┴──▸ CANCELLED │
301
+
302
+ Any of {NEW, CREATED, LABEL_READY, IN_TRANSIT, OUT_FOR_DELIVERY} ──fail──▸ FAILED │
303
+ ```
304
+
305
+ Guards enforce data integrity:
306
+ - `confirm_label` requires `label_url` to be set on the shipment.
307
+ - `mark_in_transit` requires `tracking_number` to be set on the shipment.
308
+
309
+ ## Provider Authoring
310
+
311
+ Create a provider by subclassing `BaseProvider` and implementing `create_shipment`:
312
+
313
+ ```python
314
+ from typing import ClassVar
315
+
316
+ from sendparcel.provider import BaseProvider
317
+ from sendparcel.types import ShipmentCreateResult
318
+
319
+
320
+ class MyCarrierProvider(BaseProvider):
321
+ slug: ClassVar[str] = "mycarrier"
322
+ display_name: ClassVar[str] = "My Carrier"
323
+ supported_countries: ClassVar[list[str]] = ["PL", "DE"]
324
+ supported_services: ClassVar[list[str]] = ["standard"]
325
+
326
+ async def create_shipment(self, **kwargs) -> ShipmentCreateResult:
327
+ # Call your carrier's API here
328
+ api_key = self.get_setting("api_key")
329
+ sender = self.shipment.order.get_sender_address()
330
+ receiver = self.shipment.order.get_receiver_address()
331
+ # ... HTTP call to carrier API ...
332
+ return ShipmentCreateResult(
333
+ external_id="carrier-12345",
334
+ tracking_number="TRACK-12345",
335
+ )
336
+ ```
337
+
338
+ ### Entry-point registration
339
+
340
+ Declare your provider in `pyproject.toml` so it is auto-discovered:
341
+
342
+ ```toml
343
+ [project.entry-points."sendparcel.providers"]
344
+ mycarrier = "mycarrier_sendparcel.provider:MyCarrierProvider"
345
+ ```
346
+
347
+ ### Manual registration
348
+
349
+ ```python
350
+ from sendparcel import registry
351
+
352
+ registry.register(MyCarrierProvider)
353
+ ```
354
+
355
+ ### Provider configuration
356
+
357
+ Pass per-provider settings through `ShipmentFlow`:
358
+
359
+ ```python
360
+ flow = ShipmentFlow(
361
+ repository=repo,
362
+ config={
363
+ "mycarrier": {
364
+ "api_key": "sk_live_...",
365
+ "sandbox": True,
366
+ },
367
+ },
368
+ )
369
+ ```
370
+
371
+ Settings are accessible inside the provider via `self.get_setting("api_key")`.
372
+
373
+ ### Optional methods
374
+
375
+ Beyond the required `create_shipment`, providers can override:
376
+
377
+ | Method | Purpose |
378
+ |---|---|
379
+ | `create_label(**kwargs)` | Generate or fetch a shipping label. |
380
+ | `verify_callback(data, headers, **kwargs)` | Validate webhook authenticity. |
381
+ | `handle_callback(data, headers, **kwargs)` | Apply webhook status updates. |
382
+ | `fetch_shipment_status(**kwargs)` | Poll current shipment status. |
383
+ | `cancel_shipment(**kwargs)` | Cancel a shipment. |
384
+
385
+ ### Class-level attributes
386
+
387
+ | Attribute | Type | Description |
388
+ |---|---|---|
389
+ | `slug` | `str` | Unique provider identifier. |
390
+ | `display_name` | `str` | Human-readable name. |
391
+ | `supported_countries` | `list[str]` | ISO country codes. |
392
+ | `supported_services` | `list[str]` | Service level identifiers. |
393
+ | `confirmation_method` | `ConfirmationMethod` | `PUSH` (webhook) or `PULL` (polling). Default: `PUSH`. |
394
+ | `user_selectable` | `bool` | Whether this provider appears in `registry.get_choices()`. Default: `True`. |
395
+
396
+ ## Ecosystem
397
+
398
+ python-sendparcel is the core library. Framework-specific integrations are
399
+ provided by separate packages:
400
+
401
+ | Package | Framework | Repository |
402
+ |---|---|---|
403
+ | [django-sendparcel](https://github.com/sendparcel/django-sendparcel) | Django | `sendparcel/django-sendparcel` |
404
+ | [fastapi-sendparcel](https://github.com/sendparcel/fastapi-sendparcel) | FastAPI | `sendparcel/fastapi-sendparcel` |
405
+ | [litestar-sendparcel](https://github.com/sendparcel/litestar-sendparcel) | Litestar | `sendparcel/litestar-sendparcel` |
406
+
407
+ Each wrapper provides framework-native models, views/routes, and repository
408
+ implementations so you don't have to write the boilerplate shown in the Quick
409
+ Start above.
410
+
411
+ ## Supported Versions
412
+
413
+ | Python | Status |
414
+ |---|---|
415
+ | 3.12+ | Supported |
416
+ | 3.13 | Supported |
417
+ | < 3.12 | Not supported |
418
+
419
+ ### Core dependencies
420
+
421
+ | Package | Minimum version |
422
+ |---|---|
423
+ | `transitions` | 0.9.0 |
424
+ | `httpx` | 0.27.0 |
425
+ | `anyio` | 4.0 |
426
+
427
+ ## Running Tests
428
+
429
+ The test suite uses **pytest** with **pytest-asyncio**.
430
+
431
+ ```bash
432
+ # Install dev dependencies
433
+ uv sync --extra dev
434
+
435
+ # Run the full test suite
436
+ uv run pytest
437
+
438
+ # With coverage
439
+ uv run pytest --cov=sendparcel --cov-report=term-missing
440
+ ```
441
+
442
+ ## Credits
443
+
444
+ - **Author:** Dominik Kozaczko ([dominik@kozaczko.info](mailto:dominik@kozaczko.info))
445
+ - Inspired by the [django-getpaid](https://github.com/django-getpaid/django-getpaid) architecture and plugin model.
446
+
447
+ ## License
448
+
449
+ [MIT](https://opensource.org/licenses/MIT)
@@ -0,0 +1,17 @@
1
+ sendparcel/__init__.py,sha256=WACW8aHx1NhIjfnyB9zgl4TtF__74x-yzwQSzl-yDT4,710
2
+ sendparcel/enums.py,sha256=HUdeuKIt3s2Gzh6loXPvNKJXqGm9LSfuHRwVmhPtcVM,513
3
+ sendparcel/exceptions.py,sha256=9nfRcWxHh9YTEmLSUgoUH22qmHP9-EIgHVAXQiox80k,570
4
+ sendparcel/flow.py,sha256=zKUKiFthYbJWgVR863acY1ZkJq4FWB6xff8SV6mb_dc,6374
5
+ sendparcel/fsm.py,sha256=qdQzJVupvjamZ8nvixaqO5XEvovOPdC6GwbudeJf9n8,3662
6
+ sendparcel/protocols.py,sha256=XFsodkNXHENeLEonlDioq-R5r0JRzf7L58V3DJ9jYRw,1094
7
+ sendparcel/provider.py,sha256=eKiot1b3SOgaqMJzKjSTgT6CbX9xNEvZSwZn02xha4g,2063
8
+ sendparcel/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ sendparcel/registry.py,sha256=s3uhw0ja9BoFKnS3c_W-oBXGMlZY7mmQRiFxs3wJ88o,2342
10
+ sendparcel/types.py,sha256=R2Z90fj3gzG3Uyu5W2eOHFMcA6MudTh94POw7GgQZYo,1522
11
+ sendparcel/validators.py,sha256=0LRSM2cqTF3f8CImP2mt8fXPGeQSD4tIpMxxhfk0YhA,536
12
+ sendparcel/providers/__init__.py,sha256=ff82dvrWuPS-UIGF5AoX_fgpcYZaGu2k6q2yrxMZacI,192
13
+ sendparcel/providers/dummy.py,sha256=miMzRL8EXotQEV0_Moa-rH6rRQiA0fM57O8a0kS5iP4,2715
14
+ python_sendparcel-0.1.0.dist-info/METADATA,sha256=CziXBxiHU8TXzuMaCVo6x9jX9E6imk_O435Gm4anwJk,17156
15
+ python_sendparcel-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
16
+ python_sendparcel-0.1.0.dist-info/licenses/LICENSE,sha256=IZXSBOjgGvChgayLmtTnU40iE7hsrrU3WVEYKx0sywY,1075
17
+ python_sendparcel-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,10 @@
1
+
2
+ MIT License
3
+
4
+ Copyright (c) 2025, Dominik Kozaczko
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
7
+
8
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
9
+
10
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
sendparcel/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """sendparcel core package."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from sendparcel.enums import ConfirmationMethod, ShipmentStatus
6
+ from sendparcel.exceptions import (
7
+ CommunicationError,
8
+ InvalidCallbackError,
9
+ InvalidTransitionError,
10
+ SendParcelException,
11
+ )
12
+ from sendparcel.flow import ShipmentFlow
13
+ from sendparcel.provider import BaseProvider
14
+ from sendparcel.providers.dummy import DummyProvider
15
+ from sendparcel.registry import registry
16
+
17
+ __all__ = [
18
+ "BaseProvider",
19
+ "CommunicationError",
20
+ "ConfirmationMethod",
21
+ "DummyProvider",
22
+ "InvalidCallbackError",
23
+ "InvalidTransitionError",
24
+ "SendParcelException",
25
+ "ShipmentFlow",
26
+ "ShipmentStatus",
27
+ "__version__",
28
+ "registry",
29
+ ]
sendparcel/enums.py ADDED
@@ -0,0 +1,24 @@
1
+ """Shipment processing enums."""
2
+
3
+ from enum import StrEnum
4
+
5
+
6
+ class ShipmentStatus(StrEnum):
7
+ """Shipment lifecycle status."""
8
+
9
+ NEW = "new"
10
+ CREATED = "created"
11
+ LABEL_READY = "label_ready"
12
+ IN_TRANSIT = "in_transit"
13
+ OUT_FOR_DELIVERY = "out_for_delivery"
14
+ DELIVERED = "delivered"
15
+ CANCELLED = "cancelled"
16
+ FAILED = "failed"
17
+ RETURNED = "returned"
18
+
19
+
20
+ class ConfirmationMethod(StrEnum):
21
+ """How the provider confirms shipment status updates."""
22
+
23
+ PUSH = "PUSH"
24
+ PULL = "PULL"
@@ -0,0 +1,21 @@
1
+ """Exception hierarchy for sendparcel."""
2
+
3
+
4
+ class SendParcelException(Exception):
5
+ """Base exception for sendparcel."""
6
+
7
+ def __init__(self, message: str = "", context: dict | None = None) -> None:
8
+ super().__init__(message)
9
+ self.context = context or {}
10
+
11
+
12
+ class CommunicationError(SendParcelException):
13
+ """Provider communication failed."""
14
+
15
+
16
+ class InvalidCallbackError(SendParcelException):
17
+ """Webhook callback validation failed."""
18
+
19
+
20
+ class InvalidTransitionError(SendParcelException):
21
+ """Invalid shipment state transition requested."""
sendparcel/flow.py ADDED
@@ -0,0 +1,164 @@
1
+ """Shipment flow orchestrator."""
2
+
3
+ import httpx
4
+
5
+ from sendparcel.enums import ShipmentStatus
6
+ from sendparcel.exceptions import (
7
+ CommunicationError,
8
+ InvalidTransitionError,
9
+ SendParcelException,
10
+ )
11
+ from sendparcel.fsm import (
12
+ STATUS_TO_CALLBACK,
13
+ create_shipment_machine,
14
+ )
15
+ from sendparcel.protocols import Order, Shipment, ShipmentRepository
16
+ from sendparcel.registry import registry
17
+ from sendparcel.validators import run_validators
18
+
19
+
20
+ class ShipmentFlow:
21
+ """Framework-agnostic shipment orchestration."""
22
+
23
+ def __init__(
24
+ self,
25
+ repository: ShipmentRepository,
26
+ config: dict | None = None,
27
+ validators: list | None = None,
28
+ ) -> None:
29
+ self.repository = repository
30
+ self.config = config or {}
31
+ self.validators = validators or []
32
+
33
+ async def create_shipment(
34
+ self,
35
+ order: Order,
36
+ provider_slug: str,
37
+ **kwargs,
38
+ ) -> Shipment:
39
+ """Create a shipment record for an order."""
40
+ registry.get_by_slug(provider_slug)
41
+ shipment = await self.repository.create(
42
+ order=order,
43
+ provider=provider_slug,
44
+ status=ShipmentStatus.NEW,
45
+ **kwargs,
46
+ )
47
+ create_shipment_machine(shipment)
48
+ provider = self._get_provider(shipment)
49
+ result = await self._call_provider(provider.create_shipment(**kwargs))
50
+ shipment.external_id = result.get("external_id", "")
51
+ shipment.tracking_number = result.get("tracking_number", "")
52
+ self._trigger(shipment, "confirm_created")
53
+ label = result.get("label") or {}
54
+ label_url = label.get("url", "")
55
+ if label_url:
56
+ shipment.label_url = label_url
57
+ if shipment.may_trigger("confirm_label"): # ty: ignore[unresolved-attribute] # dynamic FSM trigger guard
58
+ shipment.confirm_label() # ty: ignore[unresolved-attribute] # dynamic FSM trigger
59
+ return await self.repository.save(shipment)
60
+
61
+ async def create_label(self, shipment: Shipment, **kwargs) -> Shipment:
62
+ """Create provider label and persist shipment."""
63
+ run_validators({"shipment": shipment}, validators=self.validators)
64
+ create_shipment_machine(shipment)
65
+ provider = self._get_provider(shipment)
66
+ label = await self._call_provider(provider.create_label(**kwargs))
67
+ shipment.label_url = label.get("url", "")
68
+ if shipment.may_trigger("confirm_label"): # ty: ignore[unresolved-attribute] # dynamic FSM trigger guard
69
+ shipment.confirm_label() # ty: ignore[unresolved-attribute] # dynamic FSM trigger
70
+ return await self.repository.save(shipment)
71
+
72
+ async def handle_callback(
73
+ self,
74
+ shipment: Shipment,
75
+ data: dict,
76
+ headers: dict,
77
+ **kwargs,
78
+ ) -> Shipment:
79
+ """Verify and apply provider callback."""
80
+ provider = self._get_provider(shipment)
81
+ create_shipment_machine(shipment)
82
+ await self._call_provider(
83
+ provider.verify_callback(data, headers, **kwargs)
84
+ )
85
+ await self._call_provider(
86
+ provider.handle_callback(data, headers, **kwargs)
87
+ )
88
+ return await self.repository.save(shipment)
89
+
90
+ async def fetch_and_update_status(self, shipment: Shipment) -> Shipment:
91
+ """Fetch status from provider and persist."""
92
+ provider = self._get_provider(shipment)
93
+ create_shipment_machine(shipment)
94
+ response = await self._call_provider(provider.fetch_shipment_status())
95
+ status_value = response.get("status")
96
+ callback = self._resolve_callback(status_value)
97
+ if callback:
98
+ self._trigger(shipment, callback)
99
+ return await self.repository.save(shipment)
100
+
101
+ async def cancel_shipment(self, shipment: Shipment, **kwargs) -> bool:
102
+ """Cancel shipment via provider and persist state."""
103
+ provider = self._get_provider(shipment)
104
+ create_shipment_machine(shipment)
105
+ cancelled = await self._call_provider(
106
+ provider.cancel_shipment(**kwargs)
107
+ )
108
+ if cancelled:
109
+ self._trigger(shipment, "cancel")
110
+ await self.repository.save(shipment)
111
+ return cancelled
112
+
113
+ def _get_provider(self, shipment: Shipment):
114
+ provider_class = registry.get_by_slug(shipment.provider)
115
+ provider_config = self.config.get(shipment.provider, {})
116
+ return provider_class(shipment, config=provider_config)
117
+
118
+ async def _call_provider(self, coro):
119
+ """Call a provider coroutine, wrapping non-domain errors."""
120
+ try:
121
+ return await coro
122
+ except SendParcelException:
123
+ raise
124
+ except httpx.HTTPError as exc:
125
+ raise CommunicationError(
126
+ str(exc),
127
+ context={"original_error": type(exc).__name__},
128
+ ) from exc
129
+ except Exception as exc:
130
+ raise CommunicationError(
131
+ str(exc),
132
+ context={"original_error": type(exc).__name__},
133
+ ) from exc
134
+
135
+ def _resolve_callback(self, status_value: str | None) -> str | None:
136
+ """Map a provider status value to an FSM callback name.
137
+
138
+ Only accepts values from STATUS_TO_CALLBACK mapping.
139
+ Raw callback names (e.g., "cancel") are NOT accepted as
140
+ status values -- providers must return status enum values
141
+ (e.g., "cancelled").
142
+ """
143
+ if status_value is None:
144
+ return None
145
+ callback = STATUS_TO_CALLBACK.get(status_value)
146
+ if callback:
147
+ return callback
148
+ raise InvalidTransitionError(
149
+ f"Unknown status value {status_value!r}. "
150
+ f"Expected one of: {', '.join(sorted(STATUS_TO_CALLBACK))}"
151
+ )
152
+
153
+ def _trigger(self, shipment: Shipment, callback: str, **kwargs) -> None:
154
+ trigger = getattr(shipment, callback, None)
155
+ if trigger is None or not callable(trigger):
156
+ raise InvalidTransitionError(
157
+ f"Shipment has no callback trigger {callback!r}"
158
+ )
159
+ if not shipment.may_trigger(callback): # ty: ignore[unresolved-attribute] # dynamic FSM trigger guard
160
+ raise InvalidTransitionError(
161
+ f"Callback {callback!r} cannot be executed from status "
162
+ f"{shipment.status!r}"
163
+ )
164
+ trigger(**kwargs)
sendparcel/fsm.py ADDED
@@ -0,0 +1,131 @@
1
+ """Shipment state machine definitions."""
2
+
3
+ from transitions import Machine
4
+ from transitions.core import MachineError
5
+
6
+ from sendparcel.enums import ShipmentStatus
7
+
8
+ CALLBACK_NAMES = (
9
+ "confirm_created",
10
+ "confirm_label",
11
+ "mark_in_transit",
12
+ "mark_out_for_delivery",
13
+ "mark_delivered",
14
+ "mark_returned",
15
+ "cancel",
16
+ "fail",
17
+ )
18
+
19
+
20
+ def _require_label_url(event_data):
21
+ """Guard: reject confirm_label if shipment has no label_url."""
22
+ model = event_data.model
23
+ if not getattr(model, "label_url", ""):
24
+ raise MachineError(
25
+ f"Transition '{event_data.event.name}'"
26
+ " requires label_url to be set."
27
+ )
28
+
29
+
30
+ def _require_tracking_number(event_data):
31
+ """Guard: reject mark_in_transit if shipment has no tracking_number."""
32
+ model = event_data.model
33
+ if not getattr(model, "tracking_number", ""):
34
+ raise MachineError(
35
+ f"Transition '{event_data.event.name}'"
36
+ " requires tracking_number to be set."
37
+ )
38
+
39
+
40
+ SHIPMENT_TRANSITIONS = [
41
+ {
42
+ "trigger": "confirm_created",
43
+ "source": ShipmentStatus.NEW,
44
+ "dest": ShipmentStatus.CREATED,
45
+ },
46
+ {
47
+ "trigger": "confirm_label",
48
+ "source": ShipmentStatus.CREATED,
49
+ "dest": ShipmentStatus.LABEL_READY,
50
+ "before": _require_label_url,
51
+ },
52
+ {
53
+ "trigger": "mark_in_transit",
54
+ "source": [ShipmentStatus.CREATED, ShipmentStatus.LABEL_READY],
55
+ "dest": ShipmentStatus.IN_TRANSIT,
56
+ "before": _require_tracking_number,
57
+ },
58
+ {
59
+ "trigger": "mark_out_for_delivery",
60
+ "source": ShipmentStatus.IN_TRANSIT,
61
+ "dest": ShipmentStatus.OUT_FOR_DELIVERY,
62
+ },
63
+ {
64
+ "trigger": "mark_delivered",
65
+ "source": [
66
+ ShipmentStatus.IN_TRANSIT,
67
+ ShipmentStatus.OUT_FOR_DELIVERY,
68
+ ],
69
+ "dest": ShipmentStatus.DELIVERED,
70
+ },
71
+ {
72
+ "trigger": "mark_returned",
73
+ "source": [
74
+ ShipmentStatus.IN_TRANSIT,
75
+ ShipmentStatus.OUT_FOR_DELIVERY,
76
+ ShipmentStatus.DELIVERED,
77
+ ],
78
+ "dest": ShipmentStatus.RETURNED,
79
+ },
80
+ {
81
+ "trigger": "cancel",
82
+ "source": [
83
+ ShipmentStatus.NEW,
84
+ ShipmentStatus.CREATED,
85
+ ShipmentStatus.LABEL_READY,
86
+ ],
87
+ "dest": ShipmentStatus.CANCELLED,
88
+ },
89
+ {
90
+ "trigger": "fail",
91
+ "source": [
92
+ ShipmentStatus.NEW,
93
+ ShipmentStatus.CREATED,
94
+ ShipmentStatus.LABEL_READY,
95
+ ShipmentStatus.IN_TRANSIT,
96
+ ShipmentStatus.OUT_FOR_DELIVERY,
97
+ ],
98
+ "dest": ShipmentStatus.FAILED,
99
+ },
100
+ ]
101
+
102
+ ALLOWED_CALLBACKS: frozenset[str] = frozenset(CALLBACK_NAMES)
103
+
104
+ STATUS_TO_CALLBACK: dict[str, str] = {
105
+ ShipmentStatus.CREATED.value: "confirm_created",
106
+ ShipmentStatus.LABEL_READY.value: "confirm_label",
107
+ ShipmentStatus.IN_TRANSIT.value: "mark_in_transit",
108
+ ShipmentStatus.OUT_FOR_DELIVERY.value: "mark_out_for_delivery",
109
+ ShipmentStatus.DELIVERED.value: "mark_delivered",
110
+ ShipmentStatus.RETURNED.value: "mark_returned",
111
+ ShipmentStatus.CANCELLED.value: "cancel",
112
+ ShipmentStatus.FAILED.value: "fail",
113
+ }
114
+
115
+
116
+ def create_shipment_machine(shipment) -> Machine:
117
+ """Attach shipment FSM to shipment object."""
118
+ initial = (
119
+ ShipmentStatus(shipment.status)
120
+ if shipment.status
121
+ else ShipmentStatus.NEW
122
+ )
123
+ return Machine(
124
+ model=shipment,
125
+ states=ShipmentStatus,
126
+ transitions=SHIPMENT_TRANSITIONS,
127
+ initial=initial,
128
+ model_attribute="status",
129
+ auto_transitions=False,
130
+ send_event=True,
131
+ )
@@ -0,0 +1,41 @@
1
+ """Framework integration protocols."""
2
+
3
+ from decimal import Decimal
4
+ from typing import Protocol, runtime_checkable
5
+
6
+ from sendparcel.types import AddressInfo, ParcelInfo
7
+
8
+
9
+ @runtime_checkable
10
+ class Order(Protocol):
11
+ """Order shape expected by sendparcel core."""
12
+
13
+ def get_total_weight(self) -> Decimal: ...
14
+ def get_parcels(self) -> list[ParcelInfo]: ...
15
+ def get_sender_address(self) -> AddressInfo: ...
16
+ def get_receiver_address(self) -> AddressInfo: ...
17
+
18
+
19
+ @runtime_checkable
20
+ class Shipment(Protocol):
21
+ """Shipment shape expected by sendparcel core."""
22
+
23
+ id: str
24
+ order: Order
25
+ status: str
26
+ provider: str
27
+ external_id: str
28
+ tracking_number: str
29
+ label_url: str
30
+
31
+
32
+ @runtime_checkable
33
+ class ShipmentRepository(Protocol):
34
+ """Persistence abstraction for adapters."""
35
+
36
+ async def get_by_id(self, shipment_id: str) -> Shipment: ...
37
+ async def create(self, **kwargs) -> Shipment: ...
38
+ async def save(self, shipment: Shipment) -> Shipment: ...
39
+ async def update_status(
40
+ self, shipment_id: str, status: str, **fields
41
+ ) -> Shipment: ...
sendparcel/provider.py ADDED
@@ -0,0 +1,63 @@
1
+ """Base provider abstraction."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import ClassVar
5
+
6
+ from sendparcel.enums import ConfirmationMethod
7
+ from sendparcel.protocols import Shipment
8
+ from sendparcel.types import (
9
+ LabelInfo,
10
+ ShipmentCreateResult,
11
+ ShipmentStatusResponse,
12
+ )
13
+
14
+
15
+ class BaseProvider(ABC):
16
+ """Base class for parcel delivery providers."""
17
+
18
+ slug: ClassVar[str] = ""
19
+ display_name: ClassVar[str] = ""
20
+ supported_countries: ClassVar[list[str]] = []
21
+ supported_services: ClassVar[list[str]] = []
22
+ confirmation_method: ClassVar[ConfirmationMethod] = ConfirmationMethod.PUSH
23
+ user_selectable: ClassVar[bool] = True
24
+
25
+ def __init__(self, shipment: Shipment, config: dict | None = None) -> None:
26
+ self.shipment = shipment
27
+ self.config = config or {}
28
+
29
+ def get_setting(self, name: str, default=None):
30
+ """Read provider setting from config."""
31
+ return self.config.get(name, default)
32
+
33
+ @abstractmethod
34
+ async def create_shipment(self, **kwargs) -> ShipmentCreateResult:
35
+ """Create shipment in provider API."""
36
+
37
+ async def create_label(self, **kwargs) -> LabelInfo:
38
+ """Create/fetch label for shipment."""
39
+ raise NotImplementedError
40
+
41
+ async def verify_callback(
42
+ self, data: dict, headers: dict, **kwargs
43
+ ) -> None:
44
+ """Verify callback authenticity.
45
+
46
+ Providers that accept callbacks MUST override this to validate
47
+ signatures/tokens. Raise InvalidCallbackError to reject.
48
+ """
49
+ raise NotImplementedError
50
+
51
+ async def handle_callback(
52
+ self, data: dict, headers: dict, **kwargs
53
+ ) -> None:
54
+ """Apply callback updates to shipment."""
55
+ raise NotImplementedError
56
+
57
+ async def fetch_shipment_status(self, **kwargs) -> ShipmentStatusResponse:
58
+ """Fetch latest shipment status from provider."""
59
+ raise NotImplementedError
60
+
61
+ async def cancel_shipment(self, **kwargs) -> bool:
62
+ """Cancel shipment if provider supports cancellation."""
63
+ raise NotImplementedError
@@ -0,0 +1,7 @@
1
+ """Built-in providers shipped with sendparcel."""
2
+
3
+ from sendparcel.providers.dummy import DummyProvider
4
+
5
+ BUILTIN_PROVIDERS = (DummyProvider,)
6
+
7
+ __all__ = ["BUILTIN_PROVIDERS", "DummyProvider"]
@@ -0,0 +1,77 @@
1
+ """Deterministic built-in dummy provider implementation."""
2
+
3
+ from typing import ClassVar
4
+
5
+ import anyio
6
+
7
+ from sendparcel.exceptions import InvalidCallbackError
8
+ from sendparcel.fsm import STATUS_TO_CALLBACK
9
+ from sendparcel.provider import BaseProvider
10
+ from sendparcel.types import (
11
+ LabelInfo,
12
+ ShipmentCreateResult,
13
+ ShipmentStatusResponse,
14
+ )
15
+
16
+
17
+ class DummyProvider(BaseProvider):
18
+ """Reference provider for local/dev/testing usage."""
19
+
20
+ slug: ClassVar[str] = "dummy"
21
+ display_name: ClassVar[str] = "Dummy"
22
+ supported_countries: ClassVar[list[str]] = ["PL", "DE", "US"]
23
+ supported_services: ClassVar[list[str]] = ["standard", "express"]
24
+
25
+ def _label_url(self) -> str:
26
+ base = self.get_setting("label_base_url", "https://dummy.local/labels")
27
+ return f"{str(base).rstrip('/')}/{self.shipment.id}.pdf"
28
+
29
+ async def _simulate_latency(self) -> None:
30
+ delay = float(self.get_setting("latency_seconds", 0.0))
31
+ await anyio.sleep(delay)
32
+
33
+ async def create_shipment(self, **kwargs) -> ShipmentCreateResult:
34
+ await self._simulate_latency()
35
+ shipment_id = str(self.shipment.id)
36
+ return ShipmentCreateResult(
37
+ external_id=f"dummy-{shipment_id}",
38
+ tracking_number=f"DUMMY-{shipment_id.upper()}",
39
+ )
40
+
41
+ async def create_label(self, **kwargs) -> LabelInfo:
42
+ await self._simulate_latency()
43
+ return LabelInfo(format="PDF", url=self._label_url())
44
+
45
+ async def verify_callback(
46
+ self, data: dict, headers: dict, **kwargs
47
+ ) -> None:
48
+ expected = self.get_setting("callback_token", "dummy-token")
49
+ provided = headers.get("x-dummy-token", "")
50
+ if provided != expected:
51
+ raise InvalidCallbackError("BAD TOKEN")
52
+
53
+ async def handle_callback(
54
+ self, data: dict, headers: dict, **kwargs
55
+ ) -> None:
56
+ await self._simulate_latency()
57
+ status_value = data.get("status")
58
+ if not status_value:
59
+ return
60
+
61
+ callback = STATUS_TO_CALLBACK.get(str(status_value), str(status_value))
62
+ trigger = getattr(self.shipment, callback, None)
63
+ may_trigger = getattr(self.shipment, "may_trigger", None)
64
+ if trigger is None or may_trigger is None:
65
+ return
66
+ if may_trigger(callback):
67
+ trigger()
68
+
69
+ async def fetch_shipment_status(self, **kwargs) -> ShipmentStatusResponse:
70
+ await self._simulate_latency()
71
+ return ShipmentStatusResponse(
72
+ status=self.get_setting("status_override", self.shipment.status),
73
+ )
74
+
75
+ async def cancel_shipment(self, **kwargs) -> bool:
76
+ await self._simulate_latency()
77
+ return bool(self.get_setting("cancel_success", True))
sendparcel/py.typed ADDED
File without changes
sendparcel/registry.py ADDED
@@ -0,0 +1,70 @@
1
+ """Provider plugin registry."""
2
+
3
+ from importlib.metadata import entry_points
4
+
5
+ from sendparcel.provider import BaseProvider
6
+
7
+ ENTRY_POINT_GROUP = "sendparcel.providers"
8
+
9
+
10
+ class PluginRegistry:
11
+ """Discover and store provider classes."""
12
+
13
+ def __init__(self) -> None:
14
+ self._providers: dict[str, type[BaseProvider]] = {}
15
+ self._discovered = False
16
+
17
+ def discover(self) -> None:
18
+ """Load providers from entry points."""
19
+ from sendparcel.providers import BUILTIN_PROVIDERS
20
+
21
+ for provider_class in BUILTIN_PROVIDERS:
22
+ self._register_provider(provider_class)
23
+ eps = entry_points(group=ENTRY_POINT_GROUP)
24
+ for ep in eps:
25
+ provider_class = ep.load()
26
+ if isinstance(provider_class, type) and issubclass(
27
+ provider_class, BaseProvider
28
+ ):
29
+ self._register_provider(provider_class)
30
+ self._discovered = True
31
+
32
+ def register(self, provider_class: type[BaseProvider]) -> None:
33
+ """Register provider manually."""
34
+ self._register_provider(provider_class)
35
+
36
+ def unregister(self, slug: str) -> None:
37
+ """Unregister provider by slug."""
38
+ self._providers.pop(slug, None)
39
+
40
+ def get_by_slug(self, slug: str) -> type[BaseProvider]:
41
+ """Get provider class by slug."""
42
+ self._ensure_discovered()
43
+ return self._providers[slug]
44
+
45
+ def get_choices(self) -> list[tuple[str, str]]:
46
+ """Get provider slug/display pairs for user-facing selection."""
47
+ self._ensure_discovered()
48
+ return [
49
+ (p.slug, p.display_name)
50
+ for p in self._providers.values()
51
+ if p.user_selectable
52
+ ]
53
+
54
+ def _ensure_discovered(self) -> None:
55
+ if not self._discovered:
56
+ self.discover()
57
+
58
+ def _register_provider(self, provider_class: type[BaseProvider]) -> None:
59
+ slug = provider_class.slug
60
+ existing = self._providers.get(slug)
61
+ if existing is not None and existing is not provider_class:
62
+ raise ValueError(
63
+ f"Duplicate provider slug {slug!r}: "
64
+ f"{existing.__module__}.{existing.__name__} and "
65
+ f"{provider_class.__module__}.{provider_class.__name__}"
66
+ )
67
+ self._providers[slug] = provider_class
68
+
69
+
70
+ registry = PluginRegistry()
sendparcel/types.py ADDED
@@ -0,0 +1,80 @@
1
+ """Shared type definitions."""
2
+
3
+ from decimal import Decimal
4
+ from typing import TypedDict
5
+
6
+
7
+ class _AddressInfoRequired(TypedDict):
8
+ """Required address fields."""
9
+
10
+ name: str
11
+ line1: str
12
+ city: str
13
+ postal_code: str
14
+ country_code: str
15
+
16
+
17
+ class AddressInfo(_AddressInfoRequired, total=False):
18
+ """Address payload used by providers."""
19
+
20
+ company: str
21
+ line2: str
22
+ state: str
23
+ phone: str
24
+ email: str
25
+
26
+
27
+ class _ParcelInfoRequired(TypedDict):
28
+ """Required parcel fields."""
29
+
30
+ weight_kg: Decimal
31
+
32
+
33
+ class ParcelInfo(_ParcelInfoRequired, total=False):
34
+ """Parcel dimensions and weight."""
35
+
36
+ length_cm: Decimal
37
+ width_cm: Decimal
38
+ height_cm: Decimal
39
+
40
+
41
+ class _LabelInfoRequired(TypedDict):
42
+ """Required label fields."""
43
+
44
+ format: str
45
+
46
+
47
+ class LabelInfo(_LabelInfoRequired, total=False):
48
+ """Shipping label metadata."""
49
+
50
+ url: str
51
+ content_base64: str
52
+
53
+
54
+ class TrackingEvent(TypedDict, total=False):
55
+ """Single tracking timeline event."""
56
+
57
+ code: str
58
+ description: str
59
+ occurred_at: str
60
+ location: str
61
+
62
+
63
+ class _ShipmentCreateResultRequired(TypedDict):
64
+ """Required result fields."""
65
+
66
+ external_id: str
67
+
68
+
69
+ class ShipmentCreateResult(_ShipmentCreateResultRequired, total=False):
70
+ """Provider response for create_shipment."""
71
+
72
+ tracking_number: str
73
+ label: LabelInfo
74
+
75
+
76
+ class ShipmentStatusResponse(TypedDict, total=False):
77
+ """Provider response for fetch_shipment_status."""
78
+
79
+ status: str | None
80
+ tracking_events: list[TrackingEvent]
@@ -0,0 +1,20 @@
1
+ """Pluggable validation system.
2
+
3
+ Validators are callables that receive a data dict, optionally
4
+ modify it, and return it. Raise an exception to reject.
5
+ """
6
+
7
+ from collections.abc import Callable
8
+
9
+
10
+ def run_validators(
11
+ data: dict, validators: list[Callable] | None = None
12
+ ) -> dict:
13
+ """Run a chain of validators on data.
14
+
15
+ Each validator receives the data dict and must return it
16
+ (possibly modified). Raise an exception to reject.
17
+ """
18
+ for validator in validators or []:
19
+ data = validator(data)
20
+ return data