python-sendparcel 0.2.0__py3-none-any.whl → 0.3.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.2.0.dist-info → python_sendparcel-0.3.0.dist-info}/METADATA +41 -24
- python_sendparcel-0.3.0.dist-info/RECORD +20 -0
- sendparcel/__init__.py +6 -12
- sendparcel/batch.py +77 -75
- sendparcel/concurrent.py +71 -0
- sendparcel/factory.py +33 -0
- sendparcel/flow.py +18 -44
- sendparcel/fsm.py +8 -8
- sendparcel/logging.py +5 -23
- sendparcel/provider.py +183 -33
- sendparcel/providers/dummy.py +7 -20
- sendparcel/registry.py +9 -0
- sendparcel/types.py +31 -4
- python_sendparcel-0.2.0.dist-info/RECORD +0 -19
- sendparcel/validators.py +0 -21
- {python_sendparcel-0.2.0.dist-info → python_sendparcel-0.3.0.dist-info}/WHEEL +0 -0
- {python_sendparcel-0.2.0.dist-info → python_sendparcel-0.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-sendparcel
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Framework-agnostic parcel shipping core for Python.
|
|
5
5
|
Project-URL: Homepage, https://github.com/python-sendparcel/python-sendparcel
|
|
6
6
|
Project-URL: Repository, https://github.com/python-sendparcel/python-sendparcel
|
|
@@ -23,12 +23,13 @@ Requires-Python: >=3.12
|
|
|
23
23
|
Requires-Dist: anyio>=4.0
|
|
24
24
|
Requires-Dist: httpx>=0.27.0
|
|
25
25
|
Provides-Extra: all
|
|
26
|
-
Requires-Dist: django-sendparcel>=0.
|
|
27
|
-
Requires-Dist: python-sendparcel-cli>=0.
|
|
28
|
-
Requires-Dist: python-sendparcel-dpdpl>=0.1.
|
|
29
|
-
Requires-Dist: python-sendparcel-inpost>=0.
|
|
26
|
+
Requires-Dist: django-sendparcel>=0.3.0; extra == 'all'
|
|
27
|
+
Requires-Dist: python-sendparcel-cli>=0.2.0; extra == 'all'
|
|
28
|
+
Requires-Dist: python-sendparcel-dpdpl>=0.1.1; extra == 'all'
|
|
29
|
+
Requires-Dist: python-sendparcel-inpost>=0.3.0; extra == 'all'
|
|
30
|
+
Requires-Dist: python-sendparcel-orlenpaczka>=0.0.1; extra == 'all'
|
|
30
31
|
Provides-Extra: cli
|
|
31
|
-
Requires-Dist: python-sendparcel-cli>=0.
|
|
32
|
+
Requires-Dist: python-sendparcel-cli>=0.2.0; extra == 'cli'
|
|
32
33
|
Provides-Extra: dev
|
|
33
34
|
Requires-Dist: pre-commit-hooks>=6.0.0; extra == 'dev'
|
|
34
35
|
Requires-Dist: pre-commit>=4.0; extra == 'dev'
|
|
@@ -36,26 +37,30 @@ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
|
36
37
|
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
37
38
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
38
39
|
Requires-Dist: ruff>=0.9.0; extra == 'dev'
|
|
40
|
+
Requires-Dist: trio>=0.27; extra == 'dev'
|
|
39
41
|
Requires-Dist: ty>=0.0.16; extra == 'dev'
|
|
40
42
|
Provides-Extra: django
|
|
41
|
-
Requires-Dist: django-sendparcel>=0.
|
|
43
|
+
Requires-Dist: django-sendparcel>=0.3.0; extra == 'django'
|
|
42
44
|
Provides-Extra: dpdpl
|
|
43
|
-
Requires-Dist: python-sendparcel-dpdpl>=0.1.
|
|
45
|
+
Requires-Dist: python-sendparcel-dpdpl>=0.1.1; extra == 'dpdpl'
|
|
44
46
|
Provides-Extra: dummy
|
|
45
47
|
Provides-Extra: frameworks
|
|
46
|
-
Requires-Dist: django-sendparcel>=0.
|
|
48
|
+
Requires-Dist: django-sendparcel>=0.3.0; extra == 'frameworks'
|
|
47
49
|
Provides-Extra: inpost
|
|
48
|
-
Requires-Dist: python-sendparcel-inpost>=0.
|
|
50
|
+
Requires-Dist: python-sendparcel-inpost>=0.3.0; extra == 'inpost'
|
|
51
|
+
Provides-Extra: orlenpaczka
|
|
52
|
+
Requires-Dist: python-sendparcel-orlenpaczka>=0.0.1; extra == 'orlenpaczka'
|
|
49
53
|
Provides-Extra: providers
|
|
50
|
-
Requires-Dist: python-sendparcel-dpdpl>=0.1.
|
|
51
|
-
Requires-Dist: python-sendparcel-inpost>=0.
|
|
54
|
+
Requires-Dist: python-sendparcel-dpdpl>=0.1.1; extra == 'providers'
|
|
55
|
+
Requires-Dist: python-sendparcel-inpost>=0.3.0; extra == 'providers'
|
|
56
|
+
Requires-Dist: python-sendparcel-orlenpaczka>=0.0.1; extra == 'providers'
|
|
52
57
|
Description-Content-Type: text/markdown
|
|
53
58
|
|
|
54
59
|
# python-sendparcel
|
|
55
60
|
|
|
56
61
|
Framework-agnostic parcel shipping core for Python.
|
|
57
62
|
|
|
58
|
-
> Alpha notice: `0.
|
|
63
|
+
> Alpha notice: `0.3.0` is still unstable. The API can change fast because the
|
|
59
64
|
> ecosystem is still being cleaned up.
|
|
60
65
|
|
|
61
66
|
## What it is
|
|
@@ -120,6 +125,12 @@ class InMemoryRepository:
|
|
|
120
125
|
self._store[shipment.id] = shipment
|
|
121
126
|
return shipment
|
|
122
127
|
|
|
128
|
+
async def update_fields(self, shipment_id: str, **fields) -> MyShipment:
|
|
129
|
+
shipment = self._store[shipment_id]
|
|
130
|
+
for key, value in fields.items():
|
|
131
|
+
setattr(shipment, key, value)
|
|
132
|
+
return shipment
|
|
133
|
+
|
|
123
134
|
|
|
124
135
|
async def main() -> None:
|
|
125
136
|
flow = ShipmentFlow(repository=InMemoryRepository())
|
|
@@ -156,15 +167,20 @@ anyio.run(main)
|
|
|
156
167
|
|
|
157
168
|
## Provider model
|
|
158
169
|
|
|
159
|
-
|
|
170
|
+
All providers subclass `BaseProvider`; capability methods raise
|
|
171
|
+
`ProviderCapabilityError` unless overridden:
|
|
172
|
+
|
|
173
|
+
- `BaseProvider.create_shipment(...)` returns `ShipmentCreateResult` (required).
|
|
160
174
|
- `BaseProvider.confirmation_method` defaults to `ConfirmationMethod.NONE`.
|
|
161
|
-
- `
|
|
162
|
-
- `
|
|
163
|
-
|
|
164
|
-
- `
|
|
175
|
+
- `BaseProvider.create_label(...)` returns `LabelInfo`.
|
|
176
|
+
- `BaseProvider.verify_callback(ctx)` / `handle_callback(ctx)` return
|
|
177
|
+
`None` / `ShipmentUpdateResult`.
|
|
178
|
+
- `BaseProvider.fetch_shipment_status(...)` returns `ShipmentUpdateResult`.
|
|
179
|
+
- `BaseProvider.cancel_shipment(...)` returns `bool`.
|
|
165
180
|
|
|
166
|
-
Use `ConfirmationMethod.PUSH` only
|
|
167
|
-
`ConfirmationMethod.PULL` only
|
|
181
|
+
Use `ConfirmationMethod.PUSH` only when the callback methods are overridden
|
|
182
|
+
and `ConfirmationMethod.PULL` only when `fetch_shipment_status` is. See
|
|
183
|
+
`docs/provider-authoring.md` for the full guide.
|
|
168
184
|
|
|
169
185
|
The core owns shipment state transitions. Providers translate carrier responses
|
|
170
186
|
into normalized results.
|
|
@@ -184,10 +200,9 @@ uv add python-sendparcel
|
|
|
184
200
|
## Extras
|
|
185
201
|
|
|
186
202
|
- `django`
|
|
187
|
-
- `fastapi`
|
|
188
|
-
- `litestar`
|
|
189
203
|
- `inpost`
|
|
190
204
|
- `dpdpl`
|
|
205
|
+
- `orlenpaczka`
|
|
191
206
|
- `cli`
|
|
192
207
|
- `frameworks`
|
|
193
208
|
- `providers`
|
|
@@ -198,6 +213,8 @@ uv add python-sendparcel
|
|
|
198
213
|
```bash
|
|
199
214
|
uv sync --extra dev
|
|
200
215
|
uv run pytest
|
|
201
|
-
uv run ruff check
|
|
202
|
-
uv run
|
|
216
|
+
uv run ruff check .
|
|
217
|
+
uv run ruff format --check .
|
|
218
|
+
uv run ty check
|
|
219
|
+
uv run mypy src
|
|
203
220
|
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
sendparcel/__init__.py,sha256=OZbB9xNjPbJvnKnaqHv4Ds7-IFc-M8sbjSgkszdNNE8,1521
|
|
2
|
+
sendparcel/batch.py,sha256=4K3PgCOtV2QUNRqH_DuuhqbSYwIiNe-mSk6HKNORsmA,9332
|
|
3
|
+
sendparcel/concurrent.py,sha256=RFkuIjL9XehDqBc2cHXivbzYlJtR_9ZxjAqO5_hxJzY,2082
|
|
4
|
+
sendparcel/enums.py,sha256=e1UM9ka5zWJg4naMCoVeUrIy_PVPPEYHOBh6XiXaW4Q,687
|
|
5
|
+
sendparcel/exceptions.py,sha256=zC5_o9oQfNuOBKaiw5csaHAJmhYsZc3WVbOaoq7s1W0,1437
|
|
6
|
+
sendparcel/factory.py,sha256=VVB8Kz3J-7QI-VjrJsMwKso5SVZf97yZBX5nMbnCMM4,1045
|
|
7
|
+
sendparcel/flow.py,sha256=6NED69FuqrvoVVXMk6P2V1RymLX_4V3-KIm7Ch2aAXk,10360
|
|
8
|
+
sendparcel/fsm.py,sha256=bRkt8tj_mg71Wq8obhhKK6nrQuN2a4Y_YXiL7kYxPDU,3134
|
|
9
|
+
sendparcel/logging.py,sha256=TOetzdKJtl6YIY9Dpq-uxWB-jituOEKx0DpWmGrrBMU,4256
|
|
10
|
+
sendparcel/protocols.py,sha256=7YX2KTOEG5Nje6bzibmIODFiTX7tgqb6I3WYgx9uVS4,2344
|
|
11
|
+
sendparcel/provider.py,sha256=H28ZjvBpHY4C3LBvSm7DYa645300N4k2-X2fvl0epqc,8201
|
|
12
|
+
sendparcel/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
sendparcel/registry.py,sha256=CCaU7Esd6vS5eVvvv_oLuWEhd9j17Zk3-hEOZl-_7Vk,3790
|
|
14
|
+
sendparcel/types.py,sha256=nNECD7J6WZSlB8-SXnkperAW3d5zmKOM-9l-u9dZ5mk,3042
|
|
15
|
+
sendparcel/providers/__init__.py,sha256=ff82dvrWuPS-UIGF5AoX_fgpcYZaGu2k6q2yrxMZacI,192
|
|
16
|
+
sendparcel/providers/dummy.py,sha256=J_FOzy0NsgKeI3Zxe0vTfPBdVeVSXKGd_zyMWfBL58Q,2836
|
|
17
|
+
python_sendparcel-0.3.0.dist-info/METADATA,sha256=UJGCgut2oKHrg8P6HylzOvU2LeWCLE7l-0aoAoOCEpQ,7175
|
|
18
|
+
python_sendparcel-0.3.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
19
|
+
python_sendparcel-0.3.0.dist-info/licenses/LICENSE,sha256=IZXSBOjgGvChgayLmtTnU40iE7hsrrU3WVEYKx0sywY,1075
|
|
20
|
+
python_sendparcel-0.3.0.dist-info/RECORD,,
|
sendparcel/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""sendparcel core package."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.
|
|
3
|
+
__version__ = "0.3.0"
|
|
4
4
|
|
|
5
5
|
from sendparcel.batch import BatchCreateResult, BatchResult, ShipmentBatch
|
|
6
6
|
from sendparcel.enums import ConfirmationMethod, LabelFormat, ShipmentStatus
|
|
@@ -13,18 +13,14 @@ from sendparcel.exceptions import (
|
|
|
13
13
|
SendParcelException,
|
|
14
14
|
ShipmentNotFoundError,
|
|
15
15
|
)
|
|
16
|
+
from sendparcel.factory import create_provider
|
|
16
17
|
from sendparcel.flow import ShipmentFlow
|
|
17
18
|
from sendparcel.logging import configure_logging, get_logger
|
|
18
|
-
from sendparcel.provider import
|
|
19
|
-
BaseProvider,
|
|
20
|
-
CancellableProvider,
|
|
21
|
-
LabelProvider,
|
|
22
|
-
PullStatusProvider,
|
|
23
|
-
PushCallbackProvider,
|
|
24
|
-
)
|
|
19
|
+
from sendparcel.provider import BaseProvider
|
|
25
20
|
from sendparcel.providers.dummy import DummyProvider
|
|
26
21
|
from sendparcel.registry import registry
|
|
27
22
|
from sendparcel.types import (
|
|
23
|
+
CallbackContext,
|
|
28
24
|
CreateLabelOutcome,
|
|
29
25
|
CreateShipmentOutcome,
|
|
30
26
|
ShipmentUpdateOutcome,
|
|
@@ -35,7 +31,7 @@ __all__ = [
|
|
|
35
31
|
"BaseProvider",
|
|
36
32
|
"BatchCreateResult",
|
|
37
33
|
"BatchResult",
|
|
38
|
-
"
|
|
34
|
+
"CallbackContext",
|
|
39
35
|
"CommunicationError",
|
|
40
36
|
"ConfirmationMethod",
|
|
41
37
|
"CreateLabelOutcome",
|
|
@@ -44,11 +40,8 @@ __all__ = [
|
|
|
44
40
|
"InvalidCallbackError",
|
|
45
41
|
"InvalidTransitionError",
|
|
46
42
|
"LabelFormat",
|
|
47
|
-
"LabelProvider",
|
|
48
43
|
"ProviderCapabilityError",
|
|
49
44
|
"ProviderNotFoundError",
|
|
50
|
-
"PullStatusProvider",
|
|
51
|
-
"PushCallbackProvider",
|
|
52
45
|
"SendParcelException",
|
|
53
46
|
"ShipmentBatch",
|
|
54
47
|
"ShipmentFlow",
|
|
@@ -58,6 +51,7 @@ __all__ = [
|
|
|
58
51
|
"ShipmentUpdateResult",
|
|
59
52
|
"__version__",
|
|
60
53
|
"configure_logging",
|
|
54
|
+
"create_provider",
|
|
61
55
|
"get_logger",
|
|
62
56
|
"registry",
|
|
63
57
|
]
|
sendparcel/batch.py
CHANGED
|
@@ -28,14 +28,15 @@ Usage::
|
|
|
28
28
|
|
|
29
29
|
from __future__ import annotations
|
|
30
30
|
|
|
31
|
-
import asyncio
|
|
32
31
|
from dataclasses import dataclass, field
|
|
33
32
|
from typing import Any
|
|
34
33
|
|
|
34
|
+
from sendparcel.concurrent import ConcurrentExecutor, ConcurrentResult
|
|
35
35
|
from sendparcel.flow import ShipmentFlow
|
|
36
36
|
from sendparcel.logging import get_logger
|
|
37
37
|
from sendparcel.protocols import ShipmentRepository
|
|
38
38
|
from sendparcel.registry import PluginRegistry
|
|
39
|
+
from sendparcel.registry import registry as default_registry
|
|
39
40
|
|
|
40
41
|
logger = get_logger(__name__)
|
|
41
42
|
|
|
@@ -80,12 +81,25 @@ class BatchCreateResult:
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
|
|
84
|
+
def _to_batch_result(res: ConcurrentResult) -> BatchResult:
|
|
85
|
+
"""Convert a ConcurrentResult to a BatchResult."""
|
|
86
|
+
if res.success:
|
|
87
|
+
result: BatchResult = res.value
|
|
88
|
+
return result
|
|
89
|
+
return BatchResult(
|
|
90
|
+
index=res.index,
|
|
91
|
+
success=False,
|
|
92
|
+
error=res.error,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
83
96
|
class ShipmentBatch:
|
|
84
97
|
"""Batch shipment operations.
|
|
85
98
|
|
|
86
99
|
Provides efficient batch creation, status fetching, and cancellation
|
|
87
|
-
of shipments.
|
|
88
|
-
|
|
100
|
+
of shipments. Operations are independent — if one shipment fails,
|
|
101
|
+
the others are still processed. There is no cross-shipment
|
|
102
|
+
transaction or rollback.
|
|
89
103
|
|
|
90
104
|
Args:
|
|
91
105
|
repository: Shipment repository for persisting results.
|
|
@@ -104,8 +118,8 @@ class ShipmentBatch:
|
|
|
104
118
|
) -> None:
|
|
105
119
|
self.repository = repository
|
|
106
120
|
self.config = config or {}
|
|
107
|
-
self.registry = registry or
|
|
108
|
-
self.
|
|
121
|
+
self.registry = registry or default_registry
|
|
122
|
+
self._executor = ConcurrentExecutor(max_concurrent)
|
|
109
123
|
|
|
110
124
|
async def create_shipments(
|
|
111
125
|
self,
|
|
@@ -126,29 +140,23 @@ class ShipmentBatch:
|
|
|
126
140
|
Returns:
|
|
127
141
|
BatchCreateResult with per-shipment results.
|
|
128
142
|
"""
|
|
129
|
-
semaphore = asyncio.Semaphore(self.max_concurrent)
|
|
130
143
|
|
|
131
|
-
async def _create_one(
|
|
132
|
-
|
|
133
|
-
) -> BatchResult:
|
|
134
|
-
async with semaphore:
|
|
135
|
-
return await self._create_single_shipment(index, shipment_data)
|
|
144
|
+
async def _create_one(data: dict[str, Any], index: int) -> BatchResult:
|
|
145
|
+
return await self._create_single_shipment(index, data)
|
|
136
146
|
|
|
137
|
-
|
|
138
|
-
|
|
147
|
+
concurrent_results = await self._executor.execute(
|
|
148
|
+
shipments, _create_one
|
|
139
149
|
)
|
|
150
|
+
batch_results = [_to_batch_result(r) for r in concurrent_results]
|
|
140
151
|
|
|
141
|
-
successful = sum(1 for r in
|
|
142
|
-
failed = sum(1 for r in
|
|
143
|
-
|
|
144
|
-
# Sort by original index to preserve input order.
|
|
145
|
-
results.sort(key=lambda r: r.index)
|
|
152
|
+
successful = sum(1 for r in batch_results if r.success)
|
|
153
|
+
failed = sum(1 for r in batch_results if not r.success)
|
|
146
154
|
|
|
147
155
|
return BatchCreateResult(
|
|
148
156
|
total=len(shipments),
|
|
149
157
|
successful=successful,
|
|
150
158
|
failed=failed,
|
|
151
|
-
results=
|
|
159
|
+
results=batch_results,
|
|
152
160
|
)
|
|
153
161
|
|
|
154
162
|
async def _create_single_shipment(
|
|
@@ -228,36 +236,33 @@ class ShipmentBatch:
|
|
|
228
236
|
Returns:
|
|
229
237
|
List of BatchResult with per-shipment status updates.
|
|
230
238
|
"""
|
|
231
|
-
semaphore = asyncio.Semaphore(self.max_concurrent)
|
|
232
|
-
|
|
233
|
-
async def _fetch_one(index: int, sid: str) -> BatchResult:
|
|
234
|
-
async with semaphore:
|
|
235
|
-
try:
|
|
236
|
-
shipment = await self.repository.get_by_id(sid)
|
|
237
|
-
flow = ShipmentFlow(
|
|
238
|
-
repository=self.repository,
|
|
239
|
-
config=self.config,
|
|
240
|
-
registry=self.registry,
|
|
241
|
-
)
|
|
242
|
-
outcome = await flow.fetch_and_update_status(shipment)
|
|
243
|
-
return BatchResult(
|
|
244
|
-
index=index,
|
|
245
|
-
success=True,
|
|
246
|
-
shipment=outcome.shipment,
|
|
247
|
-
outcome=outcome,
|
|
248
|
-
)
|
|
249
|
-
except Exception as exc:
|
|
250
|
-
return BatchResult(
|
|
251
|
-
index=index,
|
|
252
|
-
success=False,
|
|
253
|
-
error=str(exc),
|
|
254
|
-
)
|
|
255
239
|
|
|
256
|
-
|
|
257
|
-
|
|
240
|
+
async def _fetch_one(sid: str, index: int) -> BatchResult:
|
|
241
|
+
try:
|
|
242
|
+
shipment = await self.repository.get_by_id(sid)
|
|
243
|
+
flow = ShipmentFlow(
|
|
244
|
+
repository=self.repository,
|
|
245
|
+
config=self.config,
|
|
246
|
+
registry=self.registry,
|
|
247
|
+
)
|
|
248
|
+
outcome = await flow.fetch_and_update_status(shipment)
|
|
249
|
+
return BatchResult(
|
|
250
|
+
index=index,
|
|
251
|
+
success=True,
|
|
252
|
+
shipment=outcome.shipment,
|
|
253
|
+
outcome=outcome,
|
|
254
|
+
)
|
|
255
|
+
except Exception as exc:
|
|
256
|
+
return BatchResult(
|
|
257
|
+
index=index,
|
|
258
|
+
success=False,
|
|
259
|
+
error=str(exc),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
concurrent_results = await self._executor.execute(
|
|
263
|
+
shipment_ids, _fetch_one
|
|
258
264
|
)
|
|
259
|
-
|
|
260
|
-
return results
|
|
265
|
+
return [_to_batch_result(r) for r in concurrent_results]
|
|
261
266
|
|
|
262
267
|
async def cancel_shipments(
|
|
263
268
|
self,
|
|
@@ -271,33 +276,30 @@ class ShipmentBatch:
|
|
|
271
276
|
Returns:
|
|
272
277
|
List of BatchResult with per-shipment cancellation results.
|
|
273
278
|
"""
|
|
274
|
-
semaphore = asyncio.Semaphore(self.max_concurrent)
|
|
275
|
-
|
|
276
|
-
async def _cancel_one(index: int, sid: str) -> BatchResult:
|
|
277
|
-
async with semaphore:
|
|
278
|
-
try:
|
|
279
|
-
shipment = await self.repository.get_by_id(sid)
|
|
280
|
-
flow = ShipmentFlow(
|
|
281
|
-
repository=self.repository,
|
|
282
|
-
config=self.config,
|
|
283
|
-
registry=self.registry,
|
|
284
|
-
)
|
|
285
|
-
cancelled = await flow.cancel_shipment(shipment)
|
|
286
|
-
return BatchResult(
|
|
287
|
-
index=index,
|
|
288
|
-
success=True,
|
|
289
|
-
shipment=shipment,
|
|
290
|
-
outcome={"cancelled": cancelled},
|
|
291
|
-
)
|
|
292
|
-
except Exception as exc:
|
|
293
|
-
return BatchResult(
|
|
294
|
-
index=index,
|
|
295
|
-
success=False,
|
|
296
|
-
error=str(exc),
|
|
297
|
-
)
|
|
298
279
|
|
|
299
|
-
|
|
300
|
-
|
|
280
|
+
async def _cancel_one(sid: str, index: int) -> BatchResult:
|
|
281
|
+
try:
|
|
282
|
+
shipment = await self.repository.get_by_id(sid)
|
|
283
|
+
flow = ShipmentFlow(
|
|
284
|
+
repository=self.repository,
|
|
285
|
+
config=self.config,
|
|
286
|
+
registry=self.registry,
|
|
287
|
+
)
|
|
288
|
+
cancelled = await flow.cancel_shipment(shipment)
|
|
289
|
+
return BatchResult(
|
|
290
|
+
index=index,
|
|
291
|
+
success=True,
|
|
292
|
+
shipment=shipment,
|
|
293
|
+
outcome={"cancelled": cancelled},
|
|
294
|
+
)
|
|
295
|
+
except Exception as exc:
|
|
296
|
+
return BatchResult(
|
|
297
|
+
index=index,
|
|
298
|
+
success=False,
|
|
299
|
+
error=str(exc),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
concurrent_results = await self._executor.execute(
|
|
303
|
+
shipment_ids, _cancel_one
|
|
301
304
|
)
|
|
302
|
-
|
|
303
|
-
return results
|
|
305
|
+
return [_to_batch_result(r) for r in concurrent_results]
|
sendparcel/concurrent.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Generic concurrent executor with semaphore-based concurrency control."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Coroutine
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
import anyio
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ConcurrentResult:
|
|
16
|
+
"""Result of a single concurrent operation."""
|
|
17
|
+
|
|
18
|
+
index: int
|
|
19
|
+
success: bool
|
|
20
|
+
value: Any = None
|
|
21
|
+
error: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConcurrentExecutor:
|
|
25
|
+
"""Executes async callables with bounded concurrency.
|
|
26
|
+
|
|
27
|
+
Backend-agnostic: uses anyio primitives, so it runs under both
|
|
28
|
+
asyncio and trio.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, max_concurrent: int = 5) -> None:
|
|
32
|
+
self._max_concurrent = max_concurrent
|
|
33
|
+
|
|
34
|
+
async def execute(
|
|
35
|
+
self,
|
|
36
|
+
items: list[Any],
|
|
37
|
+
operation: Callable[[Any, int], Coroutine[Any, Any, T]],
|
|
38
|
+
) -> list[ConcurrentResult]:
|
|
39
|
+
"""Execute operation on each item with bounded concurrency.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
items: List of items to process.
|
|
43
|
+
operation: Async callable(item, index) -> result.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
List of ConcurrentResult in the same order as items.
|
|
47
|
+
"""
|
|
48
|
+
semaphore = anyio.Semaphore(self._max_concurrent)
|
|
49
|
+
results: list[ConcurrentResult | None] = [None] * len(items)
|
|
50
|
+
|
|
51
|
+
async def _run(index: int, item: Any) -> None:
|
|
52
|
+
async with semaphore:
|
|
53
|
+
try:
|
|
54
|
+
value = await operation(item, index)
|
|
55
|
+
results[index] = ConcurrentResult(
|
|
56
|
+
index=index,
|
|
57
|
+
success=True,
|
|
58
|
+
value=value,
|
|
59
|
+
)
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
results[index] = ConcurrentResult(
|
|
62
|
+
index=index,
|
|
63
|
+
success=False,
|
|
64
|
+
error=str(exc),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
async with anyio.create_task_group() as tg:
|
|
68
|
+
for i, item in enumerate(items):
|
|
69
|
+
tg.start_soon(_run, i, item)
|
|
70
|
+
|
|
71
|
+
return [r for r in results if r is not None]
|
sendparcel/factory.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Provider factory — wires providers with their transports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sendparcel.protocols import Shipment
|
|
8
|
+
from sendparcel.provider import BaseProvider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_provider(
|
|
12
|
+
shipment: Shipment,
|
|
13
|
+
provider_class: type[BaseProvider],
|
|
14
|
+
config: dict[str, Any],
|
|
15
|
+
) -> BaseProvider:
|
|
16
|
+
"""Wire a provider with its transport from config.
|
|
17
|
+
|
|
18
|
+
Uses ``provider_class.transport_factory`` to build the transport.
|
|
19
|
+
No provider-specific logic — fully generic.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
shipment: The shipment the provider will operate on.
|
|
23
|
+
provider_class: The provider class to instantiate.
|
|
24
|
+
config: Provider configuration dict (from framework settings).
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
A provider instance with transport injected.
|
|
28
|
+
"""
|
|
29
|
+
factory = getattr(provider_class, "transport_factory", None)
|
|
30
|
+
if factory is not None:
|
|
31
|
+
transport = factory(**config)
|
|
32
|
+
return provider_class(shipment, config, transport=transport)
|
|
33
|
+
return provider_class(shipment, config)
|
sendparcel/flow.py
CHANGED
|
@@ -9,29 +9,22 @@ import httpx
|
|
|
9
9
|
from sendparcel.enums import ShipmentStatus
|
|
10
10
|
from sendparcel.exceptions import (
|
|
11
11
|
CommunicationError,
|
|
12
|
-
ProviderCapabilityError,
|
|
13
12
|
SendParcelException,
|
|
14
13
|
)
|
|
15
14
|
from sendparcel.fsm import transition_shipment
|
|
16
15
|
from sendparcel.protocols import Shipment, ShipmentRepository
|
|
17
|
-
from sendparcel.provider import
|
|
18
|
-
BaseProvider,
|
|
19
|
-
CancellableProvider,
|
|
20
|
-
LabelProvider,
|
|
21
|
-
PullStatusProvider,
|
|
22
|
-
PushCallbackProvider,
|
|
23
|
-
)
|
|
16
|
+
from sendparcel.provider import BaseProvider
|
|
24
17
|
from sendparcel.registry import PluginRegistry
|
|
25
18
|
from sendparcel.registry import registry as default_registry
|
|
26
19
|
from sendparcel.types import (
|
|
27
20
|
AddressInfo,
|
|
21
|
+
CallbackContext,
|
|
28
22
|
CreateLabelOutcome,
|
|
29
23
|
CreateShipmentOutcome,
|
|
30
24
|
ParcelInfo,
|
|
31
25
|
ShipmentUpdateOutcome,
|
|
32
26
|
ShipmentUpdateResult,
|
|
33
27
|
)
|
|
34
|
-
from sendparcel.validators import run_validators
|
|
35
28
|
|
|
36
29
|
|
|
37
30
|
class ShipmentFlow:
|
|
@@ -41,12 +34,10 @@ class ShipmentFlow:
|
|
|
41
34
|
self,
|
|
42
35
|
repository: ShipmentRepository,
|
|
43
36
|
config: dict[str, Any] | None = None,
|
|
44
|
-
validators: list[Any] | None = None,
|
|
45
37
|
registry: PluginRegistry | None = None,
|
|
46
38
|
) -> None:
|
|
47
39
|
self.repository = repository
|
|
48
40
|
self.config = config or {}
|
|
49
|
-
self.validators = validators or []
|
|
50
41
|
self.registry = registry or default_registry
|
|
51
42
|
|
|
52
43
|
async def create_shipment(
|
|
@@ -92,7 +83,7 @@ class ShipmentFlow:
|
|
|
92
83
|
|
|
93
84
|
# Separate repository kwargs from provider kwargs.
|
|
94
85
|
repo_kwargs: dict[str, Any] = {}
|
|
95
|
-
for key in ("reference_id",):
|
|
86
|
+
for key in ("reference_id", "order"):
|
|
96
87
|
if key in kwargs:
|
|
97
88
|
repo_kwargs[key] = kwargs.pop(key)
|
|
98
89
|
|
|
@@ -162,13 +153,7 @@ class ShipmentFlow:
|
|
|
162
153
|
) -> CreateLabelOutcome:
|
|
163
154
|
"""Create provider label and persist shipment metadata."""
|
|
164
155
|
|
|
165
|
-
run_validators({"shipment": shipment}, validators=self.validators)
|
|
166
156
|
provider = self._get_provider(shipment)
|
|
167
|
-
if not isinstance(provider, LabelProvider):
|
|
168
|
-
raise ProviderCapabilityError(
|
|
169
|
-
f"Provider {shipment.provider!r} does not support "
|
|
170
|
-
"label creation"
|
|
171
|
-
)
|
|
172
157
|
label = await self._call_provider(provider.create_label(**kwargs))
|
|
173
158
|
transition_shipment(shipment, ShipmentStatus.LABEL_READY)
|
|
174
159
|
saved = await self.repository.update_fields(
|
|
@@ -178,25 +163,21 @@ class ShipmentFlow:
|
|
|
178
163
|
|
|
179
164
|
async def handle_callback(
|
|
180
165
|
self,
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
**kwargs: Any,
|
|
166
|
+
ctx: CallbackContext,
|
|
167
|
+
*,
|
|
168
|
+
shipment: Shipment | None = None,
|
|
185
169
|
) -> ShipmentUpdateOutcome:
|
|
186
|
-
"""Verify and apply provider callback.
|
|
170
|
+
"""Verify and apply provider callback.
|
|
187
171
|
|
|
172
|
+
The caller is responsible for loading the shipment (from
|
|
173
|
+
``ctx.shipment_id``) and passing it in. If ``shipment`` is not
|
|
174
|
+
provided, it will be loaded from the repository.
|
|
175
|
+
"""
|
|
176
|
+
if shipment is None:
|
|
177
|
+
shipment = await self.repository.get_by_id(ctx.shipment_id)
|
|
188
178
|
provider = self._get_provider(shipment)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
f"Provider {shipment.provider!r} does not support "
|
|
192
|
-
"push callbacks"
|
|
193
|
-
)
|
|
194
|
-
await self._call_provider(
|
|
195
|
-
provider.verify_callback(data, headers, **kwargs)
|
|
196
|
-
)
|
|
197
|
-
update = await self._call_provider(
|
|
198
|
-
provider.handle_callback(data, headers, **kwargs)
|
|
199
|
-
)
|
|
179
|
+
await self._call_provider(provider.verify_callback(ctx))
|
|
180
|
+
update = await self._call_provider(provider.handle_callback(ctx))
|
|
200
181
|
normalized_update = update or ShipmentUpdateResult()
|
|
201
182
|
saved = await self._apply_update(shipment, normalized_update)
|
|
202
183
|
return ShipmentUpdateOutcome(shipment=saved, update=normalized_update)
|
|
@@ -207,11 +188,6 @@ class ShipmentFlow:
|
|
|
207
188
|
"""Fetch status from provider and persist."""
|
|
208
189
|
|
|
209
190
|
provider = self._get_provider(shipment)
|
|
210
|
-
if not isinstance(provider, PullStatusProvider):
|
|
211
|
-
raise ProviderCapabilityError(
|
|
212
|
-
f"Provider {shipment.provider!r} does not support "
|
|
213
|
-
"status polling"
|
|
214
|
-
)
|
|
215
191
|
update = await self._call_provider(provider.fetch_shipment_status())
|
|
216
192
|
normalized_update = update or ShipmentUpdateResult()
|
|
217
193
|
saved = await self._apply_update(shipment, normalized_update)
|
|
@@ -221,10 +197,6 @@ class ShipmentFlow:
|
|
|
221
197
|
"""Cancel shipment via provider and persist state."""
|
|
222
198
|
|
|
223
199
|
provider = self._get_provider(shipment)
|
|
224
|
-
if not isinstance(provider, CancellableProvider):
|
|
225
|
-
raise ProviderCapabilityError(
|
|
226
|
-
f"Provider {shipment.provider!r} does not support cancellation"
|
|
227
|
-
)
|
|
228
200
|
cancelled = await self._call_provider(
|
|
229
201
|
provider.cancel_shipment(**kwargs)
|
|
230
202
|
)
|
|
@@ -236,9 +208,11 @@ class ShipmentFlow:
|
|
|
236
208
|
return bool(cancelled)
|
|
237
209
|
|
|
238
210
|
def _get_provider(self, shipment: Shipment) -> BaseProvider:
|
|
211
|
+
from sendparcel.factory import create_provider
|
|
212
|
+
|
|
239
213
|
provider_class = self.registry.get_by_slug(shipment.provider)
|
|
240
214
|
provider_config = self.config.get(shipment.provider, {})
|
|
241
|
-
return
|
|
215
|
+
return create_provider(shipment, provider_class, provider_config)
|
|
242
216
|
|
|
243
217
|
async def _apply_update(
|
|
244
218
|
self, shipment: Shipment, update: ShipmentUpdateResult
|
sendparcel/fsm.py
CHANGED
|
@@ -6,7 +6,7 @@ from sendparcel.enums import ShipmentStatus
|
|
|
6
6
|
from sendparcel.exceptions import InvalidTransitionError
|
|
7
7
|
from sendparcel.protocols import Shipment
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
_ALLOWED_TRANSITIONS: dict[ShipmentStatus, frozenset[ShipmentStatus]] = {
|
|
10
10
|
ShipmentStatus.NEW: frozenset(
|
|
11
11
|
{
|
|
12
12
|
ShipmentStatus.CREATED,
|
|
@@ -64,7 +64,7 @@ ALLOWED_STATUS_TRANSITIONS: dict[ShipmentStatus, frozenset[ShipmentStatus]] = {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
|
|
67
|
-
def
|
|
67
|
+
def _normalize(status: str | ShipmentStatus) -> ShipmentStatus:
|
|
68
68
|
"""Normalise a status to a :class:`ShipmentStatus` enum member."""
|
|
69
69
|
|
|
70
70
|
try:
|
|
@@ -81,11 +81,11 @@ def can_transition(
|
|
|
81
81
|
) -> bool:
|
|
82
82
|
"""Check whether a shipment status transition is allowed."""
|
|
83
83
|
|
|
84
|
-
current =
|
|
85
|
-
target =
|
|
84
|
+
current = _normalize(current_status)
|
|
85
|
+
target = _normalize(target_status)
|
|
86
86
|
if current == target:
|
|
87
87
|
return True
|
|
88
|
-
return target in
|
|
88
|
+
return target in _ALLOWED_TRANSITIONS[current]
|
|
89
89
|
|
|
90
90
|
|
|
91
91
|
def transition_shipment(
|
|
@@ -93,12 +93,12 @@ def transition_shipment(
|
|
|
93
93
|
) -> Shipment:
|
|
94
94
|
"""Apply a validated status transition to a shipment."""
|
|
95
95
|
|
|
96
|
-
current =
|
|
97
|
-
target =
|
|
96
|
+
current = _normalize(shipment.status)
|
|
97
|
+
target = _normalize(target_status)
|
|
98
98
|
if current == target:
|
|
99
99
|
shipment.status = target.value
|
|
100
100
|
return shipment
|
|
101
|
-
if target not in
|
|
101
|
+
if target not in _ALLOWED_TRANSITIONS[current]:
|
|
102
102
|
raise InvalidTransitionError(
|
|
103
103
|
f"Shipment cannot transition from {current!r} to {target!r}"
|
|
104
104
|
)
|
sendparcel/logging.py
CHANGED
|
@@ -29,19 +29,6 @@ from datetime import UTC, datetime
|
|
|
29
29
|
from typing import Any
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
def _format_extra(extra: dict[str, Any] | None) -> dict[str, Any]:
|
|
33
|
-
"""Convert extra fields to a serializable dict."""
|
|
34
|
-
if not extra:
|
|
35
|
-
return {}
|
|
36
|
-
result: dict[str, Any] = {}
|
|
37
|
-
for k, v in extra.items():
|
|
38
|
-
if isinstance(v, (str, int, float, bool, type(None))):
|
|
39
|
-
result[k] = v
|
|
40
|
-
else:
|
|
41
|
-
result[k] = str(v)
|
|
42
|
-
return result
|
|
43
|
-
|
|
44
|
-
|
|
45
32
|
class _JsonFormatter(logging.Formatter):
|
|
46
33
|
"""JSON formatter for structured logging.
|
|
47
34
|
|
|
@@ -49,7 +36,7 @@ class _JsonFormatter(logging.Formatter):
|
|
|
49
36
|
"""
|
|
50
37
|
|
|
51
38
|
def format(self, record: logging.LogRecord) -> str:
|
|
52
|
-
extra =
|
|
39
|
+
extra = getattr(record, "extra", None) or {}
|
|
53
40
|
log_entry: dict[str, Any] = {
|
|
54
41
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
55
42
|
"level": record.levelname,
|
|
@@ -67,11 +54,10 @@ class _HumanFormatter(logging.Formatter):
|
|
|
67
54
|
"""Human-readable formatter for development."""
|
|
68
55
|
|
|
69
56
|
def format(self, record: logging.LogRecord) -> str:
|
|
70
|
-
extra =
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
msg = f"{msg} {extra_str}"
|
|
57
|
+
extra = getattr(record, "extra", None) or {}
|
|
58
|
+
if extra:
|
|
59
|
+
extra_str = " ".join(f"{k}={v}" for k, v in extra.items())
|
|
60
|
+
record.msg = f"{record.msg} {extra_str}"
|
|
75
61
|
return super().format(record)
|
|
76
62
|
|
|
77
63
|
|
|
@@ -149,7 +135,3 @@ def get_logger(
|
|
|
149
135
|
# ``sendparcel.configure_logging()`` explicitly from your application
|
|
150
136
|
# startup code (e.g. Django ``AppConfig.ready()``) to enable structured
|
|
151
137
|
# logging for the sendparcel packages.
|
|
152
|
-
#
|
|
153
|
-
# Alias for backwards compatibility with any code that imported
|
|
154
|
-
# ``_configure_structured_logging`` directly (pre-0.1.2).
|
|
155
|
-
_configure_structured_logging = configure_logging
|
sendparcel/provider.py
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
"""Base provider abstraction
|
|
1
|
+
"""Base provider abstraction."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Callable
|
|
6
7
|
from typing import Any, ClassVar
|
|
7
8
|
|
|
8
9
|
from sendparcel.enums import ConfirmationMethod
|
|
10
|
+
from sendparcel.exceptions import ProviderCapabilityError
|
|
9
11
|
from sendparcel.protocols import Shipment
|
|
10
12
|
from sendparcel.types import (
|
|
11
13
|
AddressInfo,
|
|
14
|
+
CallbackContext,
|
|
12
15
|
LabelInfo,
|
|
13
16
|
ParcelInfo,
|
|
14
17
|
ShipmentCreateResult,
|
|
@@ -17,7 +20,17 @@ from sendparcel.types import (
|
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
class BaseProvider(ABC):
|
|
20
|
-
"""Base class for parcel delivery providers.
|
|
23
|
+
"""Base class for parcel delivery providers.
|
|
24
|
+
|
|
25
|
+
Capability methods (``create_label``, ``handle_callback``,
|
|
26
|
+
``fetch_shipment_status``, ``cancel_shipment``) raise
|
|
27
|
+
:exc:`ProviderCapabilityError` by default. Override only the
|
|
28
|
+
methods the provider supports.
|
|
29
|
+
|
|
30
|
+
The flow orchestrator calls capability methods directly — no
|
|
31
|
+
``isinstance`` trait checks. If a provider doesn't support a
|
|
32
|
+
capability, the default implementation raises.
|
|
33
|
+
"""
|
|
21
34
|
|
|
22
35
|
slug: ClassVar[str] = ""
|
|
23
36
|
display_name: ClassVar[str] = ""
|
|
@@ -26,18 +39,138 @@ class BaseProvider(ABC):
|
|
|
26
39
|
confirmation_method: ClassVar[ConfirmationMethod] = ConfirmationMethod.NONE
|
|
27
40
|
user_selectable: ClassVar[bool] = True
|
|
28
41
|
config_schema: ClassVar[dict[str, Any]] = {}
|
|
42
|
+
transport_factory: ClassVar[Callable[..., Any] | None] = None
|
|
43
|
+
"""Callable that builds a transport from config dict.
|
|
44
|
+
Signature: transport_factory(**config) -> transport.
|
|
45
|
+
None means the provider doesn't need HTTP transport (e.g. DummyProvider).
|
|
46
|
+
"""
|
|
29
47
|
|
|
30
48
|
def __init__(
|
|
31
|
-
self,
|
|
49
|
+
self,
|
|
50
|
+
shipment: Shipment,
|
|
51
|
+
config: dict[str, Any] | None = None,
|
|
52
|
+
*,
|
|
53
|
+
transport: Any = None,
|
|
32
54
|
) -> None:
|
|
33
55
|
self.shipment = shipment
|
|
34
56
|
self.config = config or {}
|
|
57
|
+
self._transport = transport
|
|
58
|
+
if self.config_schema:
|
|
59
|
+
self._validate_config()
|
|
35
60
|
|
|
36
61
|
def get_setting(self, name: str, default: Any = None) -> Any:
|
|
37
62
|
"""Read provider setting from config."""
|
|
38
|
-
|
|
39
63
|
return self.config.get(name, default)
|
|
40
64
|
|
|
65
|
+
def _get_client(self) -> Any:
|
|
66
|
+
"""Return the injected transport, or raise if not configured."""
|
|
67
|
+
if self._transport is None:
|
|
68
|
+
raise RuntimeError(
|
|
69
|
+
f"{self.__class__.__name__} requires a transport. "
|
|
70
|
+
"Use create_provider() to wire the provider."
|
|
71
|
+
)
|
|
72
|
+
return self._transport
|
|
73
|
+
|
|
74
|
+
def _validate_config(self) -> None:
|
|
75
|
+
"""Validate configuration against config_schema."""
|
|
76
|
+
for field_name, spec in self.config_schema.items():
|
|
77
|
+
if not spec.get("required", False):
|
|
78
|
+
continue
|
|
79
|
+
value = self.get_setting(field_name)
|
|
80
|
+
if value is None or value == "":
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"{self.__class__.__name__} requires "
|
|
83
|
+
f"'{field_name}' in config."
|
|
84
|
+
)
|
|
85
|
+
expected_type = spec.get("type")
|
|
86
|
+
if expected_type and value is not None:
|
|
87
|
+
type_map: dict[str, type | tuple[type, ...]] = {
|
|
88
|
+
"str": str,
|
|
89
|
+
"int": int,
|
|
90
|
+
"float": (int, float),
|
|
91
|
+
"bool": bool,
|
|
92
|
+
"list[str]": list,
|
|
93
|
+
}
|
|
94
|
+
python_type = type_map.get(expected_type)
|
|
95
|
+
if python_type and not isinstance(value, python_type):
|
|
96
|
+
raise TypeError(
|
|
97
|
+
f"{self.__class__.__name__} config '{field_name}' "
|
|
98
|
+
f"must be {expected_type}, got {type(value).__name__}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def _address_to_provider(
|
|
102
|
+
self,
|
|
103
|
+
addr: AddressInfo,
|
|
104
|
+
*,
|
|
105
|
+
field_format: str = "snake",
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
"""Convert AddressInfo to provider-specific address dict.
|
|
108
|
+
|
|
109
|
+
field_format: 'snake' (default), 'camel', 'pascal'
|
|
110
|
+
Subclasses can override _address_format ClassVar to set their default.
|
|
111
|
+
"""
|
|
112
|
+
fmt = getattr(self, "_address_format", field_format)
|
|
113
|
+
|
|
114
|
+
def _convert(key: str) -> str:
|
|
115
|
+
if fmt == "camel":
|
|
116
|
+
parts = key.split("_")
|
|
117
|
+
return parts[0] + "".join(p.capitalize() for p in parts[1:])
|
|
118
|
+
elif fmt == "pascal":
|
|
119
|
+
return "".join(p.capitalize() for p in key.split("_"))
|
|
120
|
+
return key # snake
|
|
121
|
+
|
|
122
|
+
result: dict[str, Any] = {}
|
|
123
|
+
|
|
124
|
+
# Map common fields
|
|
125
|
+
field_mappings = [
|
|
126
|
+
("company", "company"),
|
|
127
|
+
("first_name", "first_name"),
|
|
128
|
+
("last_name", "last_name"),
|
|
129
|
+
("name", "name"),
|
|
130
|
+
("street", "street"),
|
|
131
|
+
("building_number", "building_number"),
|
|
132
|
+
("flat_number", "flat_number"),
|
|
133
|
+
("line1", "line1"),
|
|
134
|
+
("city", "city"),
|
|
135
|
+
("country_code", "country_code"),
|
|
136
|
+
("postal_code", "postal_code"),
|
|
137
|
+
("phone", "phone"),
|
|
138
|
+
("email", "email"),
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
for src, dst in field_mappings:
|
|
142
|
+
value = addr.get(src)
|
|
143
|
+
if value:
|
|
144
|
+
result[_convert(dst)] = value
|
|
145
|
+
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
def _parcels_to_provider(
|
|
149
|
+
self,
|
|
150
|
+
parcels: list[ParcelInfo],
|
|
151
|
+
) -> list[dict[str, Any]]:
|
|
152
|
+
"""Convert ParcelInfo list to provider-specific parcel dicts."""
|
|
153
|
+
result = []
|
|
154
|
+
for parcel in parcels:
|
|
155
|
+
p: dict[str, Any] = {}
|
|
156
|
+
weight = parcel.get("weight_kg")
|
|
157
|
+
if weight is not None:
|
|
158
|
+
p["weight"] = float(weight)
|
|
159
|
+
|
|
160
|
+
length = parcel.get("length_cm")
|
|
161
|
+
width = parcel.get("width_cm")
|
|
162
|
+
height = parcel.get("height_cm")
|
|
163
|
+
if length:
|
|
164
|
+
p["length"] = float(length)
|
|
165
|
+
if width:
|
|
166
|
+
p["width"] = float(width)
|
|
167
|
+
if height:
|
|
168
|
+
p["height"] = float(height)
|
|
169
|
+
|
|
170
|
+
result.append(p)
|
|
171
|
+
|
|
172
|
+
return result or [{"weight": 1.0}]
|
|
173
|
+
|
|
41
174
|
@abstractmethod
|
|
42
175
|
async def create_shipment(
|
|
43
176
|
self,
|
|
@@ -49,44 +182,61 @@ class BaseProvider(ABC):
|
|
|
49
182
|
) -> ShipmentCreateResult:
|
|
50
183
|
"""Create shipment in provider API."""
|
|
51
184
|
|
|
52
|
-
|
|
53
|
-
class LabelProvider(ABC):
|
|
54
|
-
"""Trait for providers that support label generation."""
|
|
55
|
-
|
|
56
|
-
@abstractmethod
|
|
57
185
|
async def create_label(self, **kwargs: Any) -> LabelInfo:
|
|
58
|
-
"""Create or fetch label payload for a shipment.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
186
|
+
"""Create or fetch label payload for a shipment.
|
|
187
|
+
|
|
188
|
+
Raises :exc:`ProviderCapabilityError` if the provider does not
|
|
189
|
+
support label generation.
|
|
190
|
+
"""
|
|
191
|
+
raise ProviderCapabilityError(
|
|
192
|
+
f"Provider {self.__class__.__name__!r} does not support "
|
|
193
|
+
"label creation"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
async def verify_callback(self, ctx: CallbackContext) -> None:
|
|
197
|
+
"""Verify callback authenticity.
|
|
198
|
+
|
|
199
|
+
Raises :exc:`ProviderCapabilityError` if the provider does not
|
|
200
|
+
support push callbacks.
|
|
201
|
+
"""
|
|
202
|
+
raise ProviderCapabilityError(
|
|
203
|
+
f"Provider {self.__class__.__name__!r} does not support "
|
|
204
|
+
"push callbacks"
|
|
205
|
+
)
|
|
69
206
|
|
|
70
|
-
@abstractmethod
|
|
71
207
|
async def handle_callback(
|
|
72
|
-
self,
|
|
208
|
+
self, ctx: CallbackContext
|
|
73
209
|
) -> ShipmentUpdateResult:
|
|
74
|
-
"""Normalize callback data into a shipment update payload.
|
|
75
|
-
|
|
210
|
+
"""Normalize callback data into a shipment update payload.
|
|
76
211
|
|
|
77
|
-
|
|
78
|
-
|
|
212
|
+
Raises :exc:`ProviderCapabilityError` if the provider does not
|
|
213
|
+
support push callbacks.
|
|
214
|
+
"""
|
|
215
|
+
raise ProviderCapabilityError(
|
|
216
|
+
f"Provider {self.__class__.__name__!r} does not support "
|
|
217
|
+
"push callbacks"
|
|
218
|
+
)
|
|
79
219
|
|
|
80
|
-
@abstractmethod
|
|
81
220
|
async def fetch_shipment_status(
|
|
82
221
|
self, **kwargs: Any
|
|
83
222
|
) -> ShipmentUpdateResult:
|
|
84
|
-
"""Fetch latest shipment update from provider.
|
|
223
|
+
"""Fetch latest shipment update from provider.
|
|
85
224
|
|
|
225
|
+
Raises :exc:`ProviderCapabilityError` if the provider does not
|
|
226
|
+
support status polling.
|
|
227
|
+
"""
|
|
228
|
+
raise ProviderCapabilityError(
|
|
229
|
+
f"Provider {self.__class__.__name__!r} does not support "
|
|
230
|
+
"status polling"
|
|
231
|
+
)
|
|
86
232
|
|
|
87
|
-
class CancellableProvider(ABC):
|
|
88
|
-
"""Trait for providers that support shipment cancellation."""
|
|
89
|
-
|
|
90
|
-
@abstractmethod
|
|
91
233
|
async def cancel_shipment(self, **kwargs: Any) -> bool:
|
|
92
|
-
"""Cancel shipment if provider supports cancellation.
|
|
234
|
+
"""Cancel shipment if provider supports cancellation.
|
|
235
|
+
|
|
236
|
+
Raises :exc:`ProviderCapabilityError` if the provider does not
|
|
237
|
+
support cancellation.
|
|
238
|
+
"""
|
|
239
|
+
raise ProviderCapabilityError(
|
|
240
|
+
f"Provider {self.__class__.__name__!r} does not support "
|
|
241
|
+
"cancellation"
|
|
242
|
+
)
|
sendparcel/providers/dummy.py
CHANGED
|
@@ -8,15 +8,10 @@ import anyio
|
|
|
8
8
|
|
|
9
9
|
from sendparcel.enums import ConfirmationMethod, LabelFormat
|
|
10
10
|
from sendparcel.exceptions import InvalidCallbackError
|
|
11
|
-
from sendparcel.provider import
|
|
12
|
-
BaseProvider,
|
|
13
|
-
CancellableProvider,
|
|
14
|
-
LabelProvider,
|
|
15
|
-
PullStatusProvider,
|
|
16
|
-
PushCallbackProvider,
|
|
17
|
-
)
|
|
11
|
+
from sendparcel.provider import BaseProvider
|
|
18
12
|
from sendparcel.types import (
|
|
19
13
|
AddressInfo,
|
|
14
|
+
CallbackContext,
|
|
20
15
|
LabelInfo,
|
|
21
16
|
ParcelInfo,
|
|
22
17
|
ShipmentCreateResult,
|
|
@@ -24,13 +19,7 @@ from sendparcel.types import (
|
|
|
24
19
|
)
|
|
25
20
|
|
|
26
21
|
|
|
27
|
-
class DummyProvider(
|
|
28
|
-
BaseProvider,
|
|
29
|
-
LabelProvider,
|
|
30
|
-
PushCallbackProvider,
|
|
31
|
-
PullStatusProvider,
|
|
32
|
-
CancellableProvider,
|
|
33
|
-
):
|
|
22
|
+
class DummyProvider(BaseProvider):
|
|
34
23
|
"""Reference provider for local, development, and test usage."""
|
|
35
24
|
|
|
36
25
|
slug: ClassVar[str] = "dummy"
|
|
@@ -66,19 +55,17 @@ class DummyProvider(
|
|
|
66
55
|
await self._simulate_latency()
|
|
67
56
|
return LabelInfo(format=LabelFormat.PDF, url=self._label_url())
|
|
68
57
|
|
|
69
|
-
async def verify_callback(
|
|
70
|
-
self, data: dict[str, Any], headers: dict[str, Any], **kwargs: Any
|
|
71
|
-
) -> None:
|
|
58
|
+
async def verify_callback(self, ctx: CallbackContext) -> None:
|
|
72
59
|
expected = self.get_setting("callback_token", "dummy-token")
|
|
73
|
-
provided = headers.get("x-dummy-token", "")
|
|
60
|
+
provided = ctx.headers.get("x-dummy-token", "")
|
|
74
61
|
if provided != expected:
|
|
75
62
|
raise InvalidCallbackError("BAD TOKEN")
|
|
76
63
|
|
|
77
64
|
async def handle_callback(
|
|
78
|
-
self,
|
|
65
|
+
self, ctx: CallbackContext
|
|
79
66
|
) -> ShipmentUpdateResult:
|
|
80
67
|
await self._simulate_latency()
|
|
81
|
-
status_value =
|
|
68
|
+
status_value = ctx.payload.get("status")
|
|
82
69
|
if not status_value:
|
|
83
70
|
return ShipmentUpdateResult()
|
|
84
71
|
return ShipmentUpdateResult(status=str(status_value))
|
sendparcel/registry.py
CHANGED
|
@@ -62,6 +62,15 @@ class PluginRegistry:
|
|
|
62
62
|
except KeyError as exc:
|
|
63
63
|
raise ProviderNotFoundError(slug) from exc
|
|
64
64
|
|
|
65
|
+
def slugs(self) -> list[str]:
|
|
66
|
+
"""Get all registered provider slugs.
|
|
67
|
+
|
|
68
|
+
Thread-safe.
|
|
69
|
+
"""
|
|
70
|
+
self._ensure_discovered()
|
|
71
|
+
with self._lock:
|
|
72
|
+
return list(self._providers)
|
|
73
|
+
|
|
65
74
|
def get_choices(self) -> list[tuple[str, str]]:
|
|
66
75
|
"""Get provider slug/display pairs for user-facing selection.
|
|
67
76
|
|
sendparcel/types.py
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
5
7
|
from dataclasses import dataclass
|
|
6
8
|
from decimal import Decimal
|
|
7
|
-
from typing import TypedDict
|
|
9
|
+
from typing import Any, TypedDict
|
|
8
10
|
|
|
9
11
|
from sendparcel.enums import LabelFormat
|
|
10
12
|
from sendparcel.protocols import Shipment
|
|
@@ -81,9 +83,6 @@ class ShipmentUpdateResult(TypedDict, total=False):
|
|
|
81
83
|
tracking_events: list[TrackingEvent]
|
|
82
84
|
|
|
83
85
|
|
|
84
|
-
ShipmentStatusResponse = ShipmentUpdateResult
|
|
85
|
-
|
|
86
|
-
|
|
87
86
|
@dataclass(frozen=True, slots=True)
|
|
88
87
|
class CreateShipmentOutcome:
|
|
89
88
|
"""Flow result for shipment creation."""
|
|
@@ -106,3 +105,31 @@ class ShipmentUpdateOutcome:
|
|
|
106
105
|
|
|
107
106
|
shipment: Shipment
|
|
108
107
|
update: ShipmentUpdateResult
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(slots=True)
|
|
111
|
+
class CallbackContext:
|
|
112
|
+
"""Everything needed to process a webhook callback.
|
|
113
|
+
|
|
114
|
+
Framework layers build this in one place. Core operates on it.
|
|
115
|
+
The retry store persists this as a single blob.
|
|
116
|
+
|
|
117
|
+
The ``dedup_hash`` is computed from the payload alone (not headers
|
|
118
|
+
or source_ip) so that legitimate re-submissions of the same callback
|
|
119
|
+
data are deduplicated regardless of transport metadata.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
shipment_id: str
|
|
123
|
+
payload: dict[str, Any]
|
|
124
|
+
headers: dict[str, str]
|
|
125
|
+
source_ip: str
|
|
126
|
+
raw_body: bytes
|
|
127
|
+
provider_slug: str = ""
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def dedup_hash(self) -> str:
|
|
131
|
+
"""Deterministic SHA-256 hash of the payload for dedup checks."""
|
|
132
|
+
raw = json.dumps(self.payload, sort_keys=True, default=str).encode(
|
|
133
|
+
"utf-8"
|
|
134
|
+
)
|
|
135
|
+
return hashlib.sha256(raw).hexdigest()
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
sendparcel/__init__.py,sha256=aHh6hwMkQVrICkswatKnt3Aru-v3kQI0PotD0M3QocQ,1612
|
|
2
|
-
sendparcel/batch.py,sha256=wilO2jPbwlmLowYbhfhNZV17VPX6K_Lf9aHKxWUgbcQ,9400
|
|
3
|
-
sendparcel/enums.py,sha256=e1UM9ka5zWJg4naMCoVeUrIy_PVPPEYHOBh6XiXaW4Q,687
|
|
4
|
-
sendparcel/exceptions.py,sha256=zC5_o9oQfNuOBKaiw5csaHAJmhYsZc3WVbOaoq7s1W0,1437
|
|
5
|
-
sendparcel/flow.py,sha256=REP0i1W2YU09bLVBZWJDUf4mkQ4An4yTZOKMPsfGXL8,11258
|
|
6
|
-
sendparcel/fsm.py,sha256=7Oqccbn2U56htTJLQEidNhZn_0TVtR-js_6OHkTu0zE,3182
|
|
7
|
-
sendparcel/logging.py,sha256=SY7bTtXyPDM-tYCXHRV99YFwK8lHDXpfgB8IN8tOrH0,4843
|
|
8
|
-
sendparcel/protocols.py,sha256=7YX2KTOEG5Nje6bzibmIODFiTX7tgqb6I3WYgx9uVS4,2344
|
|
9
|
-
sendparcel/provider.py,sha256=6f6Z3hmLNrN2t-pulVAFEPrZnNttX1kmK6ATCnqdZD4,2690
|
|
10
|
-
sendparcel/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
-
sendparcel/registry.py,sha256=xLDgux5kkp2n5DHfLwOD6mx7yBfMdOyD4QHKUcxJpnw,3575
|
|
12
|
-
sendparcel/types.py,sha256=afPuER1CslkvcTMMogY0EVYrTjUsA36gPxVQguYtIBg,2201
|
|
13
|
-
sendparcel/validators.py,sha256=i_y-edT1hgS_2HeRdARlFaQXiL9sYAzWVsjYPVBqRDc,589
|
|
14
|
-
sendparcel/providers/__init__.py,sha256=ff82dvrWuPS-UIGF5AoX_fgpcYZaGu2k6q2yrxMZacI,192
|
|
15
|
-
sendparcel/providers/dummy.py,sha256=sbXDtPel3DEH_FIN9_gX2xXBLEqZ7Pto3CP_JMcsaEM,3102
|
|
16
|
-
python_sendparcel-0.2.0.dist-info/METADATA,sha256=fozQ4Jz1PhvcQAEnl1zHHb2DTTIXNvhJdKDGjWBPnzE,6429
|
|
17
|
-
python_sendparcel-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
18
|
-
python_sendparcel-0.2.0.dist-info/licenses/LICENSE,sha256=IZXSBOjgGvChgayLmtTnU40iE7hsrrU3WVEYKx0sywY,1075
|
|
19
|
-
python_sendparcel-0.2.0.dist-info/RECORD,,
|
sendparcel/validators.py
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
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
|
-
from typing import Any
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def run_validators(
|
|
12
|
-
data: dict[str, Any], validators: list[Callable[..., Any]] | None = None
|
|
13
|
-
) -> dict[str, Any]:
|
|
14
|
-
"""Run a chain of validators on data.
|
|
15
|
-
|
|
16
|
-
Each validator receives the data dict and must return it
|
|
17
|
-
(possibly modified). Raise an exception to reject.
|
|
18
|
-
"""
|
|
19
|
-
for validator in validators or []:
|
|
20
|
-
data = validator(data)
|
|
21
|
-
return data
|
|
File without changes
|
|
File without changes
|