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.
- python_sendparcel-0.1.0.dist-info/METADATA +449 -0
- python_sendparcel-0.1.0.dist-info/RECORD +17 -0
- python_sendparcel-0.1.0.dist-info/WHEEL +4 -0
- python_sendparcel-0.1.0.dist-info/licenses/LICENSE +10 -0
- sendparcel/__init__.py +29 -0
- sendparcel/enums.py +24 -0
- sendparcel/exceptions.py +21 -0
- sendparcel/flow.py +164 -0
- sendparcel/fsm.py +131 -0
- sendparcel/protocols.py +41 -0
- sendparcel/provider.py +63 -0
- sendparcel/providers/__init__.py +7 -0
- sendparcel/providers/dummy.py +77 -0
- sendparcel/py.typed +0 -0
- sendparcel/registry.py +70 -0
- sendparcel/types.py +80 -0
- sendparcel/validators.py +20 -0
|
@@ -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
|
+
[](https://pypi.org/project/python-sendparcel/)
|
|
56
|
+
[](https://pypi.org/project/python-sendparcel/)
|
|
57
|
+
[](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,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"
|
sendparcel/exceptions.py
ADDED
|
@@ -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
|
+
)
|
sendparcel/protocols.py
ADDED
|
@@ -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,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]
|
sendparcel/validators.py
ADDED
|
@@ -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
|