python-sendparcel 0.1.0__py3-none-any.whl → 0.1.1__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.1.dist-info/METADATA +212 -0
- python_sendparcel-0.1.1.dist-info/RECORD +17 -0
- {python_sendparcel-0.1.0.dist-info → python_sendparcel-0.1.1.dist-info}/WHEEL +1 -1
- sendparcel/__init__.py +28 -3
- sendparcel/enums.py +10 -0
- sendparcel/exceptions.py +33 -1
- sendparcel/flow.py +116 -87
- sendparcel/fsm.py +86 -118
- sendparcel/protocols.py +3 -18
- sendparcel/provider.py +55 -26
- sendparcel/providers/dummy.py +44 -25
- sendparcel/registry.py +19 -7
- sendparcel/types.py +47 -19
- sendparcel/validators.py +3 -2
- python_sendparcel-0.1.0.dist-info/METADATA +0 -449
- python_sendparcel-0.1.0.dist-info/RECORD +0 -17
- {python_sendparcel-0.1.0.dist-info → python_sendparcel-0.1.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-sendparcel
|
|
3
|
+
Version: 0.1.1
|
|
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
|
+
Provides-Extra: all
|
|
27
|
+
Requires-Dist: django-sendparcel>=0.1.0; extra == 'all'
|
|
28
|
+
Requires-Dist: fastapi-sendparcel>=0.1.0; extra == 'all'
|
|
29
|
+
Requires-Dist: litestar-sendparcel>=0.1.0; extra == 'all'
|
|
30
|
+
Requires-Dist: python-sendparcel-cli>=0.1.0; extra == 'all'
|
|
31
|
+
Requires-Dist: python-sendparcel-dpdpl>=0.1.0; extra == 'all'
|
|
32
|
+
Requires-Dist: python-sendparcel-inpost>=0.1.0; extra == 'all'
|
|
33
|
+
Provides-Extra: cli
|
|
34
|
+
Requires-Dist: python-sendparcel-cli>=0.1.0; extra == 'cli'
|
|
35
|
+
Provides-Extra: dev
|
|
36
|
+
Requires-Dist: pre-commit-hooks>=6.0.0; extra == 'dev'
|
|
37
|
+
Requires-Dist: pre-commit>=4.0; extra == 'dev'
|
|
38
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
39
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
40
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
41
|
+
Requires-Dist: ruff>=0.9.0; extra == 'dev'
|
|
42
|
+
Requires-Dist: ty>=0.0.16; extra == 'dev'
|
|
43
|
+
Provides-Extra: django
|
|
44
|
+
Requires-Dist: django-sendparcel>=0.1.0; extra == 'django'
|
|
45
|
+
Provides-Extra: dpdpl
|
|
46
|
+
Requires-Dist: python-sendparcel-dpdpl>=0.1.0; extra == 'dpdpl'
|
|
47
|
+
Provides-Extra: dummy
|
|
48
|
+
Provides-Extra: fastapi
|
|
49
|
+
Requires-Dist: fastapi-sendparcel>=0.1.0; extra == 'fastapi'
|
|
50
|
+
Provides-Extra: frameworks
|
|
51
|
+
Requires-Dist: django-sendparcel>=0.1.0; extra == 'frameworks'
|
|
52
|
+
Requires-Dist: fastapi-sendparcel>=0.1.0; extra == 'frameworks'
|
|
53
|
+
Requires-Dist: litestar-sendparcel>=0.1.0; extra == 'frameworks'
|
|
54
|
+
Provides-Extra: inpost
|
|
55
|
+
Requires-Dist: python-sendparcel-inpost>=0.1.0; extra == 'inpost'
|
|
56
|
+
Provides-Extra: litestar
|
|
57
|
+
Requires-Dist: litestar-sendparcel>=0.1.0; extra == 'litestar'
|
|
58
|
+
Provides-Extra: providers
|
|
59
|
+
Requires-Dist: python-sendparcel-dpdpl>=0.1.0; extra == 'providers'
|
|
60
|
+
Requires-Dist: python-sendparcel-inpost>=0.1.0; extra == 'providers'
|
|
61
|
+
Description-Content-Type: text/markdown
|
|
62
|
+
|
|
63
|
+
# python-sendparcel
|
|
64
|
+
|
|
65
|
+
Framework-agnostic parcel shipping core for Python.
|
|
66
|
+
|
|
67
|
+
> Alpha notice: `0.1.1` is still unstable. The API can change fast because the
|
|
68
|
+
> ecosystem is still being cleaned up.
|
|
69
|
+
|
|
70
|
+
## What it is
|
|
71
|
+
|
|
72
|
+
- Provider-agnostic shipment orchestration with a single core flow.
|
|
73
|
+
- Explicit shipment metadata persistence: `id`, `status`, `provider`, `external_id`, `tracking_number`.
|
|
74
|
+
- Label payloads are operation results, not persisted shipment fields.
|
|
75
|
+
- One normalized provider update contract for both callbacks and polling.
|
|
76
|
+
- Runtime-checkable `Shipment` and `ShipmentRepository` protocols so adapters can use their own models.
|
|
77
|
+
|
|
78
|
+
## Core contract
|
|
79
|
+
|
|
80
|
+
- `ShipmentFlow.create_shipment(...) -> CreateShipmentOutcome`
|
|
81
|
+
- `ShipmentFlow.create_label(...) -> CreateLabelOutcome`
|
|
82
|
+
- `ShipmentFlow.handle_callback(...) -> ShipmentUpdateOutcome`
|
|
83
|
+
- `ShipmentFlow.fetch_and_update_status(...) -> ShipmentUpdateOutcome`
|
|
84
|
+
- `ShipmentFlow.cancel_shipment(...) -> bool`
|
|
85
|
+
|
|
86
|
+
`CreateShipmentOutcome` and `CreateLabelOutcome` return label payloads when available.
|
|
87
|
+
The shipment object never stores label bytes or a persisted `label_url` in the core contract.
|
|
88
|
+
|
|
89
|
+
## Quick start
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from dataclasses import dataclass
|
|
93
|
+
from decimal import Decimal
|
|
94
|
+
|
|
95
|
+
import anyio
|
|
96
|
+
|
|
97
|
+
from sendparcel import ShipmentFlow
|
|
98
|
+
from sendparcel.types import AddressInfo, ParcelInfo
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class MyShipment:
|
|
103
|
+
id: str
|
|
104
|
+
status: str = "new"
|
|
105
|
+
provider: str = ""
|
|
106
|
+
external_id: str = ""
|
|
107
|
+
tracking_number: str = ""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class InMemoryRepository:
|
|
111
|
+
def __init__(self) -> None:
|
|
112
|
+
self._store: dict[str, MyShipment] = {}
|
|
113
|
+
self._counter = 0
|
|
114
|
+
|
|
115
|
+
async def get_by_id(self, shipment_id: str) -> MyShipment:
|
|
116
|
+
return self._store[shipment_id]
|
|
117
|
+
|
|
118
|
+
async def create(self, **kwargs) -> MyShipment:
|
|
119
|
+
self._counter += 1
|
|
120
|
+
shipment = MyShipment(
|
|
121
|
+
id=str(self._counter),
|
|
122
|
+
status=str(kwargs.get("status", "new")),
|
|
123
|
+
provider=str(kwargs.get("provider", "")),
|
|
124
|
+
)
|
|
125
|
+
self._store[shipment.id] = shipment
|
|
126
|
+
return shipment
|
|
127
|
+
|
|
128
|
+
async def save(self, shipment: MyShipment) -> MyShipment:
|
|
129
|
+
self._store[shipment.id] = shipment
|
|
130
|
+
return shipment
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def main() -> None:
|
|
134
|
+
flow = ShipmentFlow(repository=InMemoryRepository())
|
|
135
|
+
|
|
136
|
+
created = await flow.create_shipment(
|
|
137
|
+
"dummy",
|
|
138
|
+
sender_address=AddressInfo(
|
|
139
|
+
name="Sender Co.",
|
|
140
|
+
line1="Marszalkowska 1",
|
|
141
|
+
city="Warsaw",
|
|
142
|
+
postal_code="00-001",
|
|
143
|
+
country_code="PL",
|
|
144
|
+
),
|
|
145
|
+
receiver_address=AddressInfo(
|
|
146
|
+
name="Jan Kowalski",
|
|
147
|
+
line1="Dluga 10",
|
|
148
|
+
city="Gdansk",
|
|
149
|
+
postal_code="80-001",
|
|
150
|
+
country_code="PL",
|
|
151
|
+
),
|
|
152
|
+
parcels=[ParcelInfo(weight_kg=Decimal("2.5"))],
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
print(created.shipment.status)
|
|
156
|
+
print(created.shipment.external_id)
|
|
157
|
+
print(created.shipment.tracking_number)
|
|
158
|
+
|
|
159
|
+
labelled = await flow.create_label(created.shipment)
|
|
160
|
+
print(labelled.label.get("url"))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
anyio.run(main)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Provider model
|
|
167
|
+
|
|
168
|
+
- `BaseProvider.create_shipment(...)` returns `ShipmentCreateResult`.
|
|
169
|
+
- `BaseProvider.confirmation_method` defaults to `ConfirmationMethod.NONE`.
|
|
170
|
+
- `LabelProvider.create_label(...)` returns `LabelInfo`.
|
|
171
|
+
- `PushCallbackProvider.handle_callback(...)` returns `ShipmentUpdateResult`.
|
|
172
|
+
- `PullStatusProvider.fetch_shipment_status(...)` returns `ShipmentUpdateResult`.
|
|
173
|
+
- `CancellableProvider.cancel_shipment(...)` returns `bool`.
|
|
174
|
+
|
|
175
|
+
Use `ConfirmationMethod.PUSH` only with `PushCallbackProvider` and
|
|
176
|
+
`ConfirmationMethod.PULL` only with `PullStatusProvider`.
|
|
177
|
+
|
|
178
|
+
The core owns shipment state transitions. Providers translate carrier responses
|
|
179
|
+
into normalized results.
|
|
180
|
+
|
|
181
|
+
## Installation
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
pip install python-sendparcel
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
With `uv`:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
uv add python-sendparcel
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Extras
|
|
194
|
+
|
|
195
|
+
- `django`
|
|
196
|
+
- `fastapi`
|
|
197
|
+
- `litestar`
|
|
198
|
+
- `inpost`
|
|
199
|
+
- `dpdpl`
|
|
200
|
+
- `cli`
|
|
201
|
+
- `frameworks`
|
|
202
|
+
- `providers`
|
|
203
|
+
- `all`
|
|
204
|
+
|
|
205
|
+
## Development
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
uv sync --extra dev
|
|
209
|
+
uv run pytest
|
|
210
|
+
uv run ruff check src tests
|
|
211
|
+
uv run mypy src tests
|
|
212
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
sendparcel/__init__.py,sha256=laO3AFTeFkq_XiqzIvFWMZPblm35Rt51tKteNrNgh9Y,1314
|
|
2
|
+
sendparcel/enums.py,sha256=9tg9eMD5QpHOTNfsedncYQqfkxW_IXVcFfgJL6dCD1o,659
|
|
3
|
+
sendparcel/exceptions.py,sha256=zC5_o9oQfNuOBKaiw5csaHAJmhYsZc3WVbOaoq7s1W0,1437
|
|
4
|
+
sendparcel/flow.py,sha256=gLQuIJXxde_Ta4oseBJvyjCNc80YPq4g1tK4KwnOv90,6940
|
|
5
|
+
sendparcel/fsm.py,sha256=TbKkDNEjqWPDBDdnzvGWEaUtTcEXPJPpcZdSkuqIquI,3147
|
|
6
|
+
sendparcel/protocols.py,sha256=y4TQb0p0z0WO39KCqwiCU0uTi4pXbvgRMbB_LsIPvJ8,689
|
|
7
|
+
sendparcel/provider.py,sha256=6f6Z3hmLNrN2t-pulVAFEPrZnNttX1kmK6ATCnqdZD4,2690
|
|
8
|
+
sendparcel/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
sendparcel/registry.py,sha256=lVWpNZuXISC2c12GC7fvk-fULHVQ6exFvMqv5ilswg8,2700
|
|
10
|
+
sendparcel/types.py,sha256=afPuER1CslkvcTMMogY0EVYrTjUsA36gPxVQguYtIBg,2201
|
|
11
|
+
sendparcel/validators.py,sha256=i_y-edT1hgS_2HeRdARlFaQXiL9sYAzWVsjYPVBqRDc,589
|
|
12
|
+
sendparcel/providers/__init__.py,sha256=ff82dvrWuPS-UIGF5AoX_fgpcYZaGu2k6q2yrxMZacI,192
|
|
13
|
+
sendparcel/providers/dummy.py,sha256=sbXDtPel3DEH_FIN9_gX2xXBLEqZ7Pto3CP_JMcsaEM,3102
|
|
14
|
+
python_sendparcel-0.1.1.dist-info/METADATA,sha256=-vbQtZxuDuTQeZLa40hfZlqEJefWJAuC-IsnGqt33vc,6888
|
|
15
|
+
python_sendparcel-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
16
|
+
python_sendparcel-0.1.1.dist-info/licenses/LICENSE,sha256=IZXSBOjgGvChgayLmtTnU40iE7hsrrU3WVEYKx0sywY,1075
|
|
17
|
+
python_sendparcel-0.1.1.dist-info/RECORD,,
|
sendparcel/__init__.py
CHANGED
|
@@ -1,29 +1,54 @@
|
|
|
1
1
|
"""sendparcel core package."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.1.
|
|
3
|
+
__version__ = "0.1.1"
|
|
4
4
|
|
|
5
|
-
from sendparcel.enums import ConfirmationMethod, ShipmentStatus
|
|
5
|
+
from sendparcel.enums import ConfirmationMethod, LabelFormat, ShipmentStatus
|
|
6
6
|
from sendparcel.exceptions import (
|
|
7
7
|
CommunicationError,
|
|
8
8
|
InvalidCallbackError,
|
|
9
9
|
InvalidTransitionError,
|
|
10
|
+
ProviderCapabilityError,
|
|
11
|
+
ProviderNotFoundError,
|
|
10
12
|
SendParcelException,
|
|
13
|
+
ShipmentNotFoundError,
|
|
11
14
|
)
|
|
12
15
|
from sendparcel.flow import ShipmentFlow
|
|
13
|
-
from sendparcel.provider import
|
|
16
|
+
from sendparcel.provider import (
|
|
17
|
+
BaseProvider,
|
|
18
|
+
CancellableProvider,
|
|
19
|
+
LabelProvider,
|
|
20
|
+
PullStatusProvider,
|
|
21
|
+
PushCallbackProvider,
|
|
22
|
+
)
|
|
14
23
|
from sendparcel.providers.dummy import DummyProvider
|
|
15
24
|
from sendparcel.registry import registry
|
|
25
|
+
from sendparcel.types import (
|
|
26
|
+
CreateLabelOutcome,
|
|
27
|
+
CreateShipmentOutcome,
|
|
28
|
+
ShipmentUpdateOutcome,
|
|
29
|
+
)
|
|
16
30
|
|
|
17
31
|
__all__ = [
|
|
18
32
|
"BaseProvider",
|
|
33
|
+
"CancellableProvider",
|
|
19
34
|
"CommunicationError",
|
|
20
35
|
"ConfirmationMethod",
|
|
36
|
+
"CreateLabelOutcome",
|
|
37
|
+
"CreateShipmentOutcome",
|
|
21
38
|
"DummyProvider",
|
|
22
39
|
"InvalidCallbackError",
|
|
23
40
|
"InvalidTransitionError",
|
|
41
|
+
"LabelFormat",
|
|
42
|
+
"LabelProvider",
|
|
43
|
+
"ProviderCapabilityError",
|
|
44
|
+
"ProviderNotFoundError",
|
|
45
|
+
"PullStatusProvider",
|
|
46
|
+
"PushCallbackProvider",
|
|
24
47
|
"SendParcelException",
|
|
25
48
|
"ShipmentFlow",
|
|
49
|
+
"ShipmentNotFoundError",
|
|
26
50
|
"ShipmentStatus",
|
|
51
|
+
"ShipmentUpdateOutcome",
|
|
27
52
|
"__version__",
|
|
28
53
|
"registry",
|
|
29
54
|
]
|
sendparcel/enums.py
CHANGED
|
@@ -17,8 +17,18 @@ class ShipmentStatus(StrEnum):
|
|
|
17
17
|
RETURNED = "returned"
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
class LabelFormat(StrEnum):
|
|
21
|
+
"""Shipping label format."""
|
|
22
|
+
|
|
23
|
+
PDF = "PDF"
|
|
24
|
+
ZPL = "ZPL"
|
|
25
|
+
PNG = "PNG"
|
|
26
|
+
EPL = "EPL"
|
|
27
|
+
|
|
28
|
+
|
|
20
29
|
class ConfirmationMethod(StrEnum):
|
|
21
30
|
"""How the provider confirms shipment status updates."""
|
|
22
31
|
|
|
32
|
+
NONE = "NONE"
|
|
23
33
|
PUSH = "PUSH"
|
|
24
34
|
PULL = "PULL"
|
sendparcel/exceptions.py
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
"""Exception hierarchy for sendparcel."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
3
7
|
|
|
4
8
|
class SendParcelException(Exception):
|
|
5
9
|
"""Base exception for sendparcel."""
|
|
6
10
|
|
|
7
|
-
def __init__(
|
|
11
|
+
def __init__(
|
|
12
|
+
self, message: str = "", context: dict[str, Any] | None = None
|
|
13
|
+
) -> None:
|
|
8
14
|
super().__init__(message)
|
|
9
15
|
self.context = context or {}
|
|
10
16
|
|
|
@@ -19,3 +25,29 @@ class InvalidCallbackError(SendParcelException):
|
|
|
19
25
|
|
|
20
26
|
class InvalidTransitionError(SendParcelException):
|
|
21
27
|
"""Invalid shipment state transition requested."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ShipmentNotFoundError(SendParcelException):
|
|
31
|
+
"""Shipment not found in repository."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self, shipment_id: str, context: dict[str, Any] | None = None
|
|
35
|
+
) -> None:
|
|
36
|
+
super().__init__(f"Shipment {shipment_id} not found", context)
|
|
37
|
+
self.shipment_id = shipment_id
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProviderNotFoundError(SendParcelException):
|
|
41
|
+
"""Provider slug not found in the registry."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self, provider_slug: str, context: dict[str, Any] | None = None
|
|
45
|
+
) -> None:
|
|
46
|
+
super().__init__(
|
|
47
|
+
f"Provider {provider_slug!r} is not registered", context
|
|
48
|
+
)
|
|
49
|
+
self.provider_slug = provider_slug
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ProviderCapabilityError(SendParcelException):
|
|
53
|
+
"""Provider does not support the requested capability."""
|
sendparcel/flow.py
CHANGED
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
"""Shipment flow orchestrator."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
3
7
|
import httpx
|
|
4
8
|
|
|
5
9
|
from sendparcel.enums import ShipmentStatus
|
|
6
10
|
from sendparcel.exceptions import (
|
|
7
11
|
CommunicationError,
|
|
8
|
-
|
|
12
|
+
ProviderCapabilityError,
|
|
9
13
|
SendParcelException,
|
|
10
14
|
)
|
|
11
|
-
from sendparcel.fsm import
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
from sendparcel.fsm import transition_shipment
|
|
16
|
+
from sendparcel.protocols import Shipment, ShipmentRepository
|
|
17
|
+
from sendparcel.provider import (
|
|
18
|
+
BaseProvider,
|
|
19
|
+
CancellableProvider,
|
|
20
|
+
LabelProvider,
|
|
21
|
+
PullStatusProvider,
|
|
22
|
+
PushCallbackProvider,
|
|
23
|
+
)
|
|
24
|
+
from sendparcel.types import (
|
|
25
|
+
AddressInfo,
|
|
26
|
+
CreateLabelOutcome,
|
|
27
|
+
CreateShipmentOutcome,
|
|
28
|
+
ParcelInfo,
|
|
29
|
+
ShipmentUpdateOutcome,
|
|
30
|
+
ShipmentUpdateResult,
|
|
14
31
|
)
|
|
15
|
-
from sendparcel.protocols import Order, Shipment, ShipmentRepository
|
|
16
|
-
from sendparcel.registry import registry
|
|
17
32
|
from sendparcel.validators import run_validators
|
|
18
33
|
|
|
19
34
|
|
|
@@ -23,100 +38,145 @@ class ShipmentFlow:
|
|
|
23
38
|
def __init__(
|
|
24
39
|
self,
|
|
25
40
|
repository: ShipmentRepository,
|
|
26
|
-
config: dict | None = None,
|
|
27
|
-
validators: list | None = None,
|
|
41
|
+
config: dict[str, Any] | None = None,
|
|
42
|
+
validators: list[Any] | None = None,
|
|
43
|
+
registry: Any = None,
|
|
28
44
|
) -> None:
|
|
29
45
|
self.repository = repository
|
|
30
46
|
self.config = config or {}
|
|
31
47
|
self.validators = validators or []
|
|
48
|
+
from sendparcel.registry import registry as default_registry
|
|
49
|
+
|
|
50
|
+
self.registry = registry or default_registry
|
|
32
51
|
|
|
33
52
|
async def create_shipment(
|
|
34
53
|
self,
|
|
35
|
-
order: Order,
|
|
36
54
|
provider_slug: str,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
55
|
+
*,
|
|
56
|
+
sender_address: AddressInfo,
|
|
57
|
+
receiver_address: AddressInfo,
|
|
58
|
+
parcels: list[ParcelInfo],
|
|
59
|
+
**kwargs: Any,
|
|
60
|
+
) -> CreateShipmentOutcome:
|
|
61
|
+
"""Create a shipment record with explicit address and parcel data."""
|
|
62
|
+
|
|
63
|
+
self.registry.get_by_slug(provider_slug)
|
|
41
64
|
shipment = await self.repository.create(
|
|
42
|
-
order=order,
|
|
43
65
|
provider=provider_slug,
|
|
44
|
-
status=ShipmentStatus.NEW,
|
|
66
|
+
status=ShipmentStatus.NEW.value,
|
|
45
67
|
**kwargs,
|
|
46
68
|
)
|
|
47
|
-
create_shipment_machine(shipment)
|
|
48
69
|
provider = self._get_provider(shipment)
|
|
49
|
-
result = await self._call_provider(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
result = await self._call_provider(
|
|
71
|
+
provider.create_shipment(
|
|
72
|
+
sender_address=sender_address,
|
|
73
|
+
receiver_address=receiver_address,
|
|
74
|
+
parcels=parcels,
|
|
75
|
+
**kwargs,
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
shipment.external_id = str(result.get("external_id", ""))
|
|
79
|
+
shipment.tracking_number = str(result.get("tracking_number", ""))
|
|
80
|
+
transition_shipment(shipment, ShipmentStatus.CREATED)
|
|
81
|
+
label = result.get("label")
|
|
82
|
+
if label is not None:
|
|
83
|
+
transition_shipment(shipment, ShipmentStatus.LABEL_READY)
|
|
84
|
+
saved = await self.repository.save(shipment)
|
|
85
|
+
return CreateShipmentOutcome(shipment=saved, label=label)
|
|
86
|
+
|
|
87
|
+
async def create_label(
|
|
88
|
+
self, shipment: Shipment, **kwargs: Any
|
|
89
|
+
) -> CreateLabelOutcome:
|
|
90
|
+
"""Create provider label and persist shipment metadata."""
|
|
60
91
|
|
|
61
|
-
async def create_label(self, shipment: Shipment, **kwargs) -> Shipment:
|
|
62
|
-
"""Create provider label and persist shipment."""
|
|
63
92
|
run_validators({"shipment": shipment}, validators=self.validators)
|
|
64
|
-
create_shipment_machine(shipment)
|
|
65
93
|
provider = self._get_provider(shipment)
|
|
94
|
+
if not isinstance(provider, LabelProvider):
|
|
95
|
+
raise ProviderCapabilityError(
|
|
96
|
+
f"Provider {shipment.provider!r} does not support "
|
|
97
|
+
"label creation"
|
|
98
|
+
)
|
|
66
99
|
label = await self._call_provider(provider.create_label(**kwargs))
|
|
67
|
-
shipment
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return await self.repository.save(shipment)
|
|
100
|
+
transition_shipment(shipment, ShipmentStatus.LABEL_READY)
|
|
101
|
+
saved = await self.repository.save(shipment)
|
|
102
|
+
return CreateLabelOutcome(shipment=saved, label=label)
|
|
71
103
|
|
|
72
104
|
async def handle_callback(
|
|
73
105
|
self,
|
|
74
106
|
shipment: Shipment,
|
|
75
|
-
data: dict,
|
|
76
|
-
headers: dict,
|
|
77
|
-
**kwargs,
|
|
78
|
-
) ->
|
|
107
|
+
data: dict[str, Any],
|
|
108
|
+
headers: dict[str, Any],
|
|
109
|
+
**kwargs: Any,
|
|
110
|
+
) -> ShipmentUpdateOutcome:
|
|
79
111
|
"""Verify and apply provider callback."""
|
|
112
|
+
|
|
80
113
|
provider = self._get_provider(shipment)
|
|
81
|
-
|
|
114
|
+
if not isinstance(provider, PushCallbackProvider):
|
|
115
|
+
raise ProviderCapabilityError(
|
|
116
|
+
f"Provider {shipment.provider!r} does not support "
|
|
117
|
+
"push callbacks"
|
|
118
|
+
)
|
|
82
119
|
await self._call_provider(
|
|
83
120
|
provider.verify_callback(data, headers, **kwargs)
|
|
84
121
|
)
|
|
85
|
-
await self._call_provider(
|
|
122
|
+
update = await self._call_provider(
|
|
86
123
|
provider.handle_callback(data, headers, **kwargs)
|
|
87
124
|
)
|
|
88
|
-
|
|
125
|
+
normalized_update = update or ShipmentUpdateResult()
|
|
126
|
+
saved = await self._apply_update(shipment, normalized_update)
|
|
127
|
+
return ShipmentUpdateOutcome(shipment=saved, update=normalized_update)
|
|
89
128
|
|
|
90
|
-
async def fetch_and_update_status(
|
|
129
|
+
async def fetch_and_update_status(
|
|
130
|
+
self, shipment: Shipment
|
|
131
|
+
) -> ShipmentUpdateOutcome:
|
|
91
132
|
"""Fetch status from provider and persist."""
|
|
133
|
+
|
|
92
134
|
provider = self._get_provider(shipment)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
135
|
+
if not isinstance(provider, PullStatusProvider):
|
|
136
|
+
raise ProviderCapabilityError(
|
|
137
|
+
f"Provider {shipment.provider!r} does not support "
|
|
138
|
+
"status polling"
|
|
139
|
+
)
|
|
140
|
+
update = await self._call_provider(provider.fetch_shipment_status())
|
|
141
|
+
normalized_update = update or ShipmentUpdateResult()
|
|
142
|
+
saved = await self._apply_update(shipment, normalized_update)
|
|
143
|
+
return ShipmentUpdateOutcome(shipment=saved, update=normalized_update)
|
|
100
144
|
|
|
101
|
-
async def cancel_shipment(self, shipment: Shipment, **kwargs) -> bool:
|
|
145
|
+
async def cancel_shipment(self, shipment: Shipment, **kwargs: Any) -> bool:
|
|
102
146
|
"""Cancel shipment via provider and persist state."""
|
|
147
|
+
|
|
103
148
|
provider = self._get_provider(shipment)
|
|
104
|
-
|
|
149
|
+
if not isinstance(provider, CancellableProvider):
|
|
150
|
+
raise ProviderCapabilityError(
|
|
151
|
+
f"Provider {shipment.provider!r} does not support cancellation"
|
|
152
|
+
)
|
|
105
153
|
cancelled = await self._call_provider(
|
|
106
154
|
provider.cancel_shipment(**kwargs)
|
|
107
155
|
)
|
|
108
156
|
if cancelled:
|
|
109
|
-
|
|
157
|
+
transition_shipment(shipment, ShipmentStatus.CANCELLED)
|
|
110
158
|
await self.repository.save(shipment)
|
|
111
|
-
return cancelled
|
|
159
|
+
return bool(cancelled)
|
|
112
160
|
|
|
113
|
-
def _get_provider(self, shipment: Shipment):
|
|
114
|
-
provider_class = registry.get_by_slug(shipment.provider)
|
|
161
|
+
def _get_provider(self, shipment: Shipment) -> BaseProvider:
|
|
162
|
+
provider_class = self.registry.get_by_slug(shipment.provider)
|
|
115
163
|
provider_config = self.config.get(shipment.provider, {})
|
|
116
164
|
return provider_class(shipment, config=provider_config)
|
|
117
165
|
|
|
118
|
-
async def
|
|
166
|
+
async def _apply_update(
|
|
167
|
+
self, shipment: Shipment, update: ShipmentUpdateResult
|
|
168
|
+
) -> Shipment:
|
|
169
|
+
tracking_number = update.get("tracking_number")
|
|
170
|
+
if tracking_number:
|
|
171
|
+
shipment.tracking_number = str(tracking_number)
|
|
172
|
+
status = update.get("status")
|
|
173
|
+
if status is not None:
|
|
174
|
+
transition_shipment(shipment, status)
|
|
175
|
+
return await self.repository.save(shipment)
|
|
176
|
+
|
|
177
|
+
async def _call_provider(self, coro: Any) -> Any:
|
|
119
178
|
"""Call a provider coroutine, wrapping non-domain errors."""
|
|
179
|
+
|
|
120
180
|
try:
|
|
121
181
|
return await coro
|
|
122
182
|
except SendParcelException:
|
|
@@ -131,34 +191,3 @@ class ShipmentFlow:
|
|
|
131
191
|
str(exc),
|
|
132
192
|
context={"original_error": type(exc).__name__},
|
|
133
193
|
) 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)
|