python-sendparcel 0.1.0__py3-none-any.whl → 0.2.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/METADATA +203 -0
- python_sendparcel-0.2.0.dist-info/RECORD +19 -0
- {python_sendparcel-0.1.0.dist-info → python_sendparcel-0.2.0.dist-info}/WHEEL +1 -1
- sendparcel/__init__.py +37 -3
- sendparcel/batch.py +303 -0
- sendparcel/enums.py +11 -0
- sendparcel/exceptions.py +33 -1
- sendparcel/flow.py +226 -96
- sendparcel/fsm.py +92 -117
- sendparcel/logging.py +155 -0
- sendparcel/protocols.py +53 -19
- sendparcel/provider.py +55 -26
- sendparcel/providers/dummy.py +44 -25
- sendparcel/registry.py +69 -25
- 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.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-sendparcel
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Framework-agnostic parcel shipping core for Python.
|
|
5
|
+
Project-URL: Homepage, https://github.com/python-sendparcel/python-sendparcel
|
|
6
|
+
Project-URL: Repository, https://github.com/python-sendparcel/python-sendparcel
|
|
7
|
+
Project-URL: Changelog, https://github.com/python-sendparcel/python-sendparcel/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Issue Tracker, https://github.com/python-sendparcel/python-sendparcel/issues
|
|
9
|
+
Author-email: Dominik Kozaczko <dominik@kozaczko.info>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: delivery,logistics,parcel,sendparcel,shipping
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Natural Language :: English
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Requires-Dist: anyio>=4.0
|
|
24
|
+
Requires-Dist: httpx>=0.27.0
|
|
25
|
+
Provides-Extra: all
|
|
26
|
+
Requires-Dist: django-sendparcel>=0.1.0; extra == 'all'
|
|
27
|
+
Requires-Dist: python-sendparcel-cli>=0.1.0; extra == 'all'
|
|
28
|
+
Requires-Dist: python-sendparcel-dpdpl>=0.1.0; extra == 'all'
|
|
29
|
+
Requires-Dist: python-sendparcel-inpost>=0.1.0; extra == 'all'
|
|
30
|
+
Provides-Extra: cli
|
|
31
|
+
Requires-Dist: python-sendparcel-cli>=0.1.0; extra == 'cli'
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: pre-commit-hooks>=6.0.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: pre-commit>=4.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
37
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
38
|
+
Requires-Dist: ruff>=0.9.0; extra == 'dev'
|
|
39
|
+
Requires-Dist: ty>=0.0.16; extra == 'dev'
|
|
40
|
+
Provides-Extra: django
|
|
41
|
+
Requires-Dist: django-sendparcel>=0.1.0; extra == 'django'
|
|
42
|
+
Provides-Extra: dpdpl
|
|
43
|
+
Requires-Dist: python-sendparcel-dpdpl>=0.1.0; extra == 'dpdpl'
|
|
44
|
+
Provides-Extra: dummy
|
|
45
|
+
Provides-Extra: frameworks
|
|
46
|
+
Requires-Dist: django-sendparcel>=0.1.0; extra == 'frameworks'
|
|
47
|
+
Provides-Extra: inpost
|
|
48
|
+
Requires-Dist: python-sendparcel-inpost>=0.1.0; extra == 'inpost'
|
|
49
|
+
Provides-Extra: providers
|
|
50
|
+
Requires-Dist: python-sendparcel-dpdpl>=0.1.0; extra == 'providers'
|
|
51
|
+
Requires-Dist: python-sendparcel-inpost>=0.1.0; extra == 'providers'
|
|
52
|
+
Description-Content-Type: text/markdown
|
|
53
|
+
|
|
54
|
+
# python-sendparcel
|
|
55
|
+
|
|
56
|
+
Framework-agnostic parcel shipping core for Python.
|
|
57
|
+
|
|
58
|
+
> Alpha notice: `0.1.1` is still unstable. The API can change fast because the
|
|
59
|
+
> ecosystem is still being cleaned up.
|
|
60
|
+
|
|
61
|
+
## What it is
|
|
62
|
+
|
|
63
|
+
- Provider-agnostic shipment orchestration with a single core flow.
|
|
64
|
+
- Explicit shipment metadata persistence: `id`, `status`, `provider`, `external_id`, `tracking_number`.
|
|
65
|
+
- Label payloads are operation results, not persisted shipment fields.
|
|
66
|
+
- One normalized provider update contract for both callbacks and polling.
|
|
67
|
+
- Runtime-checkable `Shipment` and `ShipmentRepository` protocols so adapters can use their own models.
|
|
68
|
+
|
|
69
|
+
## Core contract
|
|
70
|
+
|
|
71
|
+
- `ShipmentFlow.create_shipment(...) -> CreateShipmentOutcome`
|
|
72
|
+
- `ShipmentFlow.create_label(...) -> CreateLabelOutcome`
|
|
73
|
+
- `ShipmentFlow.handle_callback(...) -> ShipmentUpdateOutcome`
|
|
74
|
+
- `ShipmentFlow.fetch_and_update_status(...) -> ShipmentUpdateOutcome`
|
|
75
|
+
- `ShipmentFlow.cancel_shipment(...) -> bool`
|
|
76
|
+
|
|
77
|
+
`CreateShipmentOutcome` and `CreateLabelOutcome` return label payloads when available.
|
|
78
|
+
The shipment object never stores label bytes or a persisted `label_url` in the core contract.
|
|
79
|
+
|
|
80
|
+
## Quick start
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from dataclasses import dataclass
|
|
84
|
+
from decimal import Decimal
|
|
85
|
+
|
|
86
|
+
import anyio
|
|
87
|
+
|
|
88
|
+
from sendparcel import ShipmentFlow
|
|
89
|
+
from sendparcel.types import AddressInfo, ParcelInfo
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class MyShipment:
|
|
94
|
+
id: str
|
|
95
|
+
status: str = "new"
|
|
96
|
+
provider: str = ""
|
|
97
|
+
external_id: str = ""
|
|
98
|
+
tracking_number: str = ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class InMemoryRepository:
|
|
102
|
+
def __init__(self) -> None:
|
|
103
|
+
self._store: dict[str, MyShipment] = {}
|
|
104
|
+
self._counter = 0
|
|
105
|
+
|
|
106
|
+
async def get_by_id(self, shipment_id: str) -> MyShipment:
|
|
107
|
+
return self._store[shipment_id]
|
|
108
|
+
|
|
109
|
+
async def create(self, **kwargs) -> MyShipment:
|
|
110
|
+
self._counter += 1
|
|
111
|
+
shipment = MyShipment(
|
|
112
|
+
id=str(self._counter),
|
|
113
|
+
status=str(kwargs.get("status", "new")),
|
|
114
|
+
provider=str(kwargs.get("provider", "")),
|
|
115
|
+
)
|
|
116
|
+
self._store[shipment.id] = shipment
|
|
117
|
+
return shipment
|
|
118
|
+
|
|
119
|
+
async def save(self, shipment: MyShipment) -> MyShipment:
|
|
120
|
+
self._store[shipment.id] = shipment
|
|
121
|
+
return shipment
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def main() -> None:
|
|
125
|
+
flow = ShipmentFlow(repository=InMemoryRepository())
|
|
126
|
+
|
|
127
|
+
created = await flow.create_shipment(
|
|
128
|
+
"dummy",
|
|
129
|
+
sender_address=AddressInfo(
|
|
130
|
+
name="Sender Co.",
|
|
131
|
+
line1="Marszalkowska 1",
|
|
132
|
+
city="Warsaw",
|
|
133
|
+
postal_code="00-001",
|
|
134
|
+
country_code="PL",
|
|
135
|
+
),
|
|
136
|
+
receiver_address=AddressInfo(
|
|
137
|
+
name="Jan Kowalski",
|
|
138
|
+
line1="Dluga 10",
|
|
139
|
+
city="Gdansk",
|
|
140
|
+
postal_code="80-001",
|
|
141
|
+
country_code="PL",
|
|
142
|
+
),
|
|
143
|
+
parcels=[ParcelInfo(weight_kg=Decimal("2.5"))],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
print(created.shipment.status)
|
|
147
|
+
print(created.shipment.external_id)
|
|
148
|
+
print(created.shipment.tracking_number)
|
|
149
|
+
|
|
150
|
+
labelled = await flow.create_label(created.shipment)
|
|
151
|
+
print(labelled.label.get("url"))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
anyio.run(main)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Provider model
|
|
158
|
+
|
|
159
|
+
- `BaseProvider.create_shipment(...)` returns `ShipmentCreateResult`.
|
|
160
|
+
- `BaseProvider.confirmation_method` defaults to `ConfirmationMethod.NONE`.
|
|
161
|
+
- `LabelProvider.create_label(...)` returns `LabelInfo`.
|
|
162
|
+
- `PushCallbackProvider.handle_callback(...)` returns `ShipmentUpdateResult`.
|
|
163
|
+
- `PullStatusProvider.fetch_shipment_status(...)` returns `ShipmentUpdateResult`.
|
|
164
|
+
- `CancellableProvider.cancel_shipment(...)` returns `bool`.
|
|
165
|
+
|
|
166
|
+
Use `ConfirmationMethod.PUSH` only with `PushCallbackProvider` and
|
|
167
|
+
`ConfirmationMethod.PULL` only with `PullStatusProvider`.
|
|
168
|
+
|
|
169
|
+
The core owns shipment state transitions. Providers translate carrier responses
|
|
170
|
+
into normalized results.
|
|
171
|
+
|
|
172
|
+
## Installation
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
pip install python-sendparcel
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
With `uv`:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
uv add python-sendparcel
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Extras
|
|
185
|
+
|
|
186
|
+
- `django`
|
|
187
|
+
- `fastapi`
|
|
188
|
+
- `litestar`
|
|
189
|
+
- `inpost`
|
|
190
|
+
- `dpdpl`
|
|
191
|
+
- `cli`
|
|
192
|
+
- `frameworks`
|
|
193
|
+
- `providers`
|
|
194
|
+
- `all`
|
|
195
|
+
|
|
196
|
+
## Development
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
uv sync --extra dev
|
|
200
|
+
uv run pytest
|
|
201
|
+
uv run ruff check src tests
|
|
202
|
+
uv run mypy src tests
|
|
203
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
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/__init__.py
CHANGED
|
@@ -1,29 +1,63 @@
|
|
|
1
1
|
"""sendparcel core package."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.
|
|
3
|
+
__version__ = "0.2.0"
|
|
4
4
|
|
|
5
|
-
from sendparcel.
|
|
5
|
+
from sendparcel.batch import BatchCreateResult, BatchResult, ShipmentBatch
|
|
6
|
+
from sendparcel.enums import ConfirmationMethod, LabelFormat, ShipmentStatus
|
|
6
7
|
from sendparcel.exceptions import (
|
|
7
8
|
CommunicationError,
|
|
8
9
|
InvalidCallbackError,
|
|
9
10
|
InvalidTransitionError,
|
|
11
|
+
ProviderCapabilityError,
|
|
12
|
+
ProviderNotFoundError,
|
|
10
13
|
SendParcelException,
|
|
14
|
+
ShipmentNotFoundError,
|
|
11
15
|
)
|
|
12
16
|
from sendparcel.flow import ShipmentFlow
|
|
13
|
-
from sendparcel.
|
|
17
|
+
from sendparcel.logging import configure_logging, get_logger
|
|
18
|
+
from sendparcel.provider import (
|
|
19
|
+
BaseProvider,
|
|
20
|
+
CancellableProvider,
|
|
21
|
+
LabelProvider,
|
|
22
|
+
PullStatusProvider,
|
|
23
|
+
PushCallbackProvider,
|
|
24
|
+
)
|
|
14
25
|
from sendparcel.providers.dummy import DummyProvider
|
|
15
26
|
from sendparcel.registry import registry
|
|
27
|
+
from sendparcel.types import (
|
|
28
|
+
CreateLabelOutcome,
|
|
29
|
+
CreateShipmentOutcome,
|
|
30
|
+
ShipmentUpdateOutcome,
|
|
31
|
+
ShipmentUpdateResult,
|
|
32
|
+
)
|
|
16
33
|
|
|
17
34
|
__all__ = [
|
|
18
35
|
"BaseProvider",
|
|
36
|
+
"BatchCreateResult",
|
|
37
|
+
"BatchResult",
|
|
38
|
+
"CancellableProvider",
|
|
19
39
|
"CommunicationError",
|
|
20
40
|
"ConfirmationMethod",
|
|
41
|
+
"CreateLabelOutcome",
|
|
42
|
+
"CreateShipmentOutcome",
|
|
21
43
|
"DummyProvider",
|
|
22
44
|
"InvalidCallbackError",
|
|
23
45
|
"InvalidTransitionError",
|
|
46
|
+
"LabelFormat",
|
|
47
|
+
"LabelProvider",
|
|
48
|
+
"ProviderCapabilityError",
|
|
49
|
+
"ProviderNotFoundError",
|
|
50
|
+
"PullStatusProvider",
|
|
51
|
+
"PushCallbackProvider",
|
|
24
52
|
"SendParcelException",
|
|
53
|
+
"ShipmentBatch",
|
|
25
54
|
"ShipmentFlow",
|
|
55
|
+
"ShipmentNotFoundError",
|
|
26
56
|
"ShipmentStatus",
|
|
57
|
+
"ShipmentUpdateOutcome",
|
|
58
|
+
"ShipmentUpdateResult",
|
|
27
59
|
"__version__",
|
|
60
|
+
"configure_logging",
|
|
61
|
+
"get_logger",
|
|
28
62
|
"registry",
|
|
29
63
|
]
|
sendparcel/batch.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Batch shipment operations for sendparcel.
|
|
2
|
+
|
|
3
|
+
Provides batch creation, status fetching, and cancellation of shipments.
|
|
4
|
+
Designed for efficiency when processing multiple shipments at once.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from sendparcel.batch import ShipmentBatch
|
|
9
|
+
|
|
10
|
+
batch = ShipmentBatch(repository=repo, config=config)
|
|
11
|
+
results = await batch.create_shipments(
|
|
12
|
+
[
|
|
13
|
+
{
|
|
14
|
+
"provider_slug": "inpost-courier",
|
|
15
|
+
"sender_address": {...},
|
|
16
|
+
"receiver_address": {...},
|
|
17
|
+
"parcels": [...],
|
|
18
|
+
},
|
|
19
|
+
# ... more shipments
|
|
20
|
+
]
|
|
21
|
+
)
|
|
22
|
+
for result in results:
|
|
23
|
+
if result.success:
|
|
24
|
+
print(f"Created {result.shipment.tracking_number}")
|
|
25
|
+
else:
|
|
26
|
+
print(f"Failed: {result.error}")
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from sendparcel.flow import ShipmentFlow
|
|
36
|
+
from sendparcel.logging import get_logger
|
|
37
|
+
from sendparcel.protocols import ShipmentRepository
|
|
38
|
+
from sendparcel.registry import PluginRegistry
|
|
39
|
+
|
|
40
|
+
logger = get_logger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class BatchResult:
|
|
45
|
+
"""Result of a single batch operation."""
|
|
46
|
+
|
|
47
|
+
index: int
|
|
48
|
+
success: bool
|
|
49
|
+
shipment: Any | None = None
|
|
50
|
+
error: str | None = None
|
|
51
|
+
outcome: Any | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class BatchCreateResult:
|
|
56
|
+
"""Result of a batch create operation."""
|
|
57
|
+
|
|
58
|
+
total: int
|
|
59
|
+
successful: int
|
|
60
|
+
failed: int
|
|
61
|
+
results: list[BatchResult] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def success(self) -> bool:
|
|
65
|
+
"""True if all shipments were created successfully."""
|
|
66
|
+
return self.failed == 0
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def summary(self) -> dict[str, Any]:
|
|
70
|
+
"""Return a summary of the batch operation."""
|
|
71
|
+
return {
|
|
72
|
+
"total": self.total,
|
|
73
|
+
"successful": self.successful,
|
|
74
|
+
"failed": self.failed,
|
|
75
|
+
"success_rate": (
|
|
76
|
+
round(self.successful / self.total * 100, 2)
|
|
77
|
+
if self.total > 0
|
|
78
|
+
else 0.0
|
|
79
|
+
),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ShipmentBatch:
|
|
84
|
+
"""Batch shipment operations.
|
|
85
|
+
|
|
86
|
+
Provides efficient batch creation, status fetching, and cancellation
|
|
87
|
+
of shipments. All operations are atomic within the batch — if one
|
|
88
|
+
shipment fails, the others are still processed.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
repository: Shipment repository for persisting results.
|
|
92
|
+
config: Provider configuration.
|
|
93
|
+
registry: Provider registry (defaults to module-level singleton).
|
|
94
|
+
max_concurrent: Maximum number of concurrent provider calls
|
|
95
|
+
(default: 5).
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
repository: ShipmentRepository,
|
|
101
|
+
config: dict[str, Any] | None = None,
|
|
102
|
+
registry: PluginRegistry | None = None,
|
|
103
|
+
max_concurrent: int = 5,
|
|
104
|
+
) -> None:
|
|
105
|
+
self.repository = repository
|
|
106
|
+
self.config = config or {}
|
|
107
|
+
self.registry = registry or PluginRegistry()
|
|
108
|
+
self.max_concurrent = max_concurrent
|
|
109
|
+
|
|
110
|
+
async def create_shipments(
|
|
111
|
+
self,
|
|
112
|
+
shipments: list[dict[str, Any]],
|
|
113
|
+
) -> BatchCreateResult:
|
|
114
|
+
"""Create multiple shipments in a batch.
|
|
115
|
+
|
|
116
|
+
Each shipment dict must contain:
|
|
117
|
+
- provider_slug: Provider identifier
|
|
118
|
+
- sender_address: Sender address info
|
|
119
|
+
- receiver_address: Receiver address info
|
|
120
|
+
- parcels: List of parcel definitions
|
|
121
|
+
- Optional: idempotency_key, reference_id, and other kwargs
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
shipments: List of shipment dicts to create.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
BatchCreateResult with per-shipment results.
|
|
128
|
+
"""
|
|
129
|
+
semaphore = asyncio.Semaphore(self.max_concurrent)
|
|
130
|
+
|
|
131
|
+
async def _create_one(
|
|
132
|
+
index: int, shipment_data: dict[str, Any]
|
|
133
|
+
) -> BatchResult:
|
|
134
|
+
async with semaphore:
|
|
135
|
+
return await self._create_single_shipment(index, shipment_data)
|
|
136
|
+
|
|
137
|
+
results = await asyncio.gather(
|
|
138
|
+
*(_create_one(i, d) for i, d in enumerate(shipments))
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
successful = sum(1 for r in results if r.success)
|
|
142
|
+
failed = sum(1 for r in results if not r.success)
|
|
143
|
+
|
|
144
|
+
# Sort by original index to preserve input order.
|
|
145
|
+
results.sort(key=lambda r: r.index)
|
|
146
|
+
|
|
147
|
+
return BatchCreateResult(
|
|
148
|
+
total=len(shipments),
|
|
149
|
+
successful=successful,
|
|
150
|
+
failed=failed,
|
|
151
|
+
results=results,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
async def _create_single_shipment(
|
|
155
|
+
self, index: int, shipment_data: dict[str, Any]
|
|
156
|
+
) -> BatchResult:
|
|
157
|
+
provider_slug = shipment_data.get("provider_slug")
|
|
158
|
+
if not provider_slug:
|
|
159
|
+
return BatchResult(
|
|
160
|
+
index=index,
|
|
161
|
+
success=False,
|
|
162
|
+
error="Missing provider_slug",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
self.registry.get_by_slug(provider_slug)
|
|
167
|
+
|
|
168
|
+
flow = ShipmentFlow(
|
|
169
|
+
repository=self.repository,
|
|
170
|
+
config=self.config,
|
|
171
|
+
registry=self.registry,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
outcome = await flow.create_shipment(
|
|
175
|
+
provider_slug=provider_slug,
|
|
176
|
+
sender_address=shipment_data["sender_address"],
|
|
177
|
+
receiver_address=shipment_data["receiver_address"],
|
|
178
|
+
parcels=shipment_data["parcels"],
|
|
179
|
+
**{
|
|
180
|
+
k: v
|
|
181
|
+
for k, v in shipment_data.items()
|
|
182
|
+
if k
|
|
183
|
+
not in (
|
|
184
|
+
"provider_slug",
|
|
185
|
+
"sender_address",
|
|
186
|
+
"receiver_address",
|
|
187
|
+
"parcels",
|
|
188
|
+
)
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
logger.info(
|
|
193
|
+
"Batch create: shipment %d created successfully "
|
|
194
|
+
"(provider=%s, tracking=%s)",
|
|
195
|
+
index,
|
|
196
|
+
provider_slug,
|
|
197
|
+
outcome.shipment.tracking_number,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return BatchResult(
|
|
201
|
+
index=index,
|
|
202
|
+
success=True,
|
|
203
|
+
shipment=outcome.shipment,
|
|
204
|
+
outcome=outcome,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
logger.warning(
|
|
209
|
+
"Batch create: shipment %d failed: %s",
|
|
210
|
+
index,
|
|
211
|
+
exc,
|
|
212
|
+
)
|
|
213
|
+
return BatchResult(
|
|
214
|
+
index=index,
|
|
215
|
+
success=False,
|
|
216
|
+
error=str(exc),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
async def fetch_statuses(
|
|
220
|
+
self,
|
|
221
|
+
shipment_ids: list[str],
|
|
222
|
+
) -> list[BatchResult]:
|
|
223
|
+
"""Fetch statuses for multiple shipments.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
shipment_ids: List of shipment IDs to fetch.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
List of BatchResult with per-shipment status updates.
|
|
230
|
+
"""
|
|
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
|
+
|
|
256
|
+
results = await asyncio.gather(
|
|
257
|
+
*(_fetch_one(i, sid) for i, sid in enumerate(shipment_ids))
|
|
258
|
+
)
|
|
259
|
+
results.sort(key=lambda r: r.index)
|
|
260
|
+
return results
|
|
261
|
+
|
|
262
|
+
async def cancel_shipments(
|
|
263
|
+
self,
|
|
264
|
+
shipment_ids: list[str],
|
|
265
|
+
) -> list[BatchResult]:
|
|
266
|
+
"""Cancel multiple shipments.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
shipment_ids: List of shipment IDs to cancel.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
List of BatchResult with per-shipment cancellation results.
|
|
273
|
+
"""
|
|
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
|
+
|
|
299
|
+
results = await asyncio.gather(
|
|
300
|
+
*(_cancel_one(i, sid) for i, sid in enumerate(shipment_ids))
|
|
301
|
+
)
|
|
302
|
+
results.sort(key=lambda r: r.index)
|
|
303
|
+
return results
|
sendparcel/enums.py
CHANGED
|
@@ -15,10 +15,21 @@ class ShipmentStatus(StrEnum):
|
|
|
15
15
|
CANCELLED = "cancelled"
|
|
16
16
|
FAILED = "failed"
|
|
17
17
|
RETURNED = "returned"
|
|
18
|
+
SUBMITTED = "submitted"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LabelFormat(StrEnum):
|
|
22
|
+
"""Shipping label format."""
|
|
23
|
+
|
|
24
|
+
PDF = "PDF"
|
|
25
|
+
ZPL = "ZPL"
|
|
26
|
+
PNG = "PNG"
|
|
27
|
+
EPL = "EPL"
|
|
18
28
|
|
|
19
29
|
|
|
20
30
|
class ConfirmationMethod(StrEnum):
|
|
21
31
|
"""How the provider confirms shipment status updates."""
|
|
22
32
|
|
|
33
|
+
NONE = "NONE"
|
|
23
34
|
PUSH = "PUSH"
|
|
24
35
|
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."""
|