python-sendparcel 0.1.1__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.1.1.dist-info → python_sendparcel-0.2.0.dist-info}/METADATA +5 -14
- python_sendparcel-0.2.0.dist-info/RECORD +19 -0
- {python_sendparcel-0.1.1.dist-info → python_sendparcel-0.2.0.dist-info}/WHEEL +1 -1
- sendparcel/__init__.py +10 -1
- sendparcel/batch.py +303 -0
- sendparcel/enums.py +1 -0
- sendparcel/flow.py +123 -22
- sendparcel/fsm.py +45 -38
- sendparcel/logging.py +155 -0
- sendparcel/protocols.py +50 -1
- sendparcel/registry.py +60 -28
- python_sendparcel-0.1.1.dist-info/RECORD +0 -17
- {python_sendparcel-0.1.1.dist-info → python_sendparcel-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-sendparcel
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Framework-agnostic parcel shipping core for Python.
|
|
5
|
-
Project-URL: Homepage, https://github.com/sendparcel/python-sendparcel
|
|
6
|
-
Project-URL:
|
|
7
|
-
Project-URL:
|
|
8
|
-
Project-URL:
|
|
9
|
-
Project-URL: Issue Tracker, https://github.com/sendparcel/python-sendparcel/issues
|
|
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
|
|
10
9
|
Author-email: Dominik Kozaczko <dominik@kozaczko.info>
|
|
11
10
|
License: MIT
|
|
12
11
|
License-File: LICENSE
|
|
@@ -25,8 +24,6 @@ Requires-Dist: anyio>=4.0
|
|
|
25
24
|
Requires-Dist: httpx>=0.27.0
|
|
26
25
|
Provides-Extra: all
|
|
27
26
|
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
27
|
Requires-Dist: python-sendparcel-cli>=0.1.0; extra == 'all'
|
|
31
28
|
Requires-Dist: python-sendparcel-dpdpl>=0.1.0; extra == 'all'
|
|
32
29
|
Requires-Dist: python-sendparcel-inpost>=0.1.0; extra == 'all'
|
|
@@ -45,16 +42,10 @@ Requires-Dist: django-sendparcel>=0.1.0; extra == 'django'
|
|
|
45
42
|
Provides-Extra: dpdpl
|
|
46
43
|
Requires-Dist: python-sendparcel-dpdpl>=0.1.0; extra == 'dpdpl'
|
|
47
44
|
Provides-Extra: dummy
|
|
48
|
-
Provides-Extra: fastapi
|
|
49
|
-
Requires-Dist: fastapi-sendparcel>=0.1.0; extra == 'fastapi'
|
|
50
45
|
Provides-Extra: frameworks
|
|
51
46
|
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
47
|
Provides-Extra: inpost
|
|
55
48
|
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
49
|
Provides-Extra: providers
|
|
59
50
|
Requires-Dist: python-sendparcel-dpdpl>=0.1.0; extra == 'providers'
|
|
60
51
|
Requires-Dist: python-sendparcel-inpost>=0.1.0; extra == 'providers'
|
|
@@ -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,7 +1,8 @@
|
|
|
1
1
|
"""sendparcel core package."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.
|
|
3
|
+
__version__ = "0.2.0"
|
|
4
4
|
|
|
5
|
+
from sendparcel.batch import BatchCreateResult, BatchResult, ShipmentBatch
|
|
5
6
|
from sendparcel.enums import ConfirmationMethod, LabelFormat, ShipmentStatus
|
|
6
7
|
from sendparcel.exceptions import (
|
|
7
8
|
CommunicationError,
|
|
@@ -13,6 +14,7 @@ from sendparcel.exceptions import (
|
|
|
13
14
|
ShipmentNotFoundError,
|
|
14
15
|
)
|
|
15
16
|
from sendparcel.flow import ShipmentFlow
|
|
17
|
+
from sendparcel.logging import configure_logging, get_logger
|
|
16
18
|
from sendparcel.provider import (
|
|
17
19
|
BaseProvider,
|
|
18
20
|
CancellableProvider,
|
|
@@ -26,10 +28,13 @@ from sendparcel.types import (
|
|
|
26
28
|
CreateLabelOutcome,
|
|
27
29
|
CreateShipmentOutcome,
|
|
28
30
|
ShipmentUpdateOutcome,
|
|
31
|
+
ShipmentUpdateResult,
|
|
29
32
|
)
|
|
30
33
|
|
|
31
34
|
__all__ = [
|
|
32
35
|
"BaseProvider",
|
|
36
|
+
"BatchCreateResult",
|
|
37
|
+
"BatchResult",
|
|
33
38
|
"CancellableProvider",
|
|
34
39
|
"CommunicationError",
|
|
35
40
|
"ConfirmationMethod",
|
|
@@ -45,10 +50,14 @@ __all__ = [
|
|
|
45
50
|
"PullStatusProvider",
|
|
46
51
|
"PushCallbackProvider",
|
|
47
52
|
"SendParcelException",
|
|
53
|
+
"ShipmentBatch",
|
|
48
54
|
"ShipmentFlow",
|
|
49
55
|
"ShipmentNotFoundError",
|
|
50
56
|
"ShipmentStatus",
|
|
51
57
|
"ShipmentUpdateOutcome",
|
|
58
|
+
"ShipmentUpdateResult",
|
|
52
59
|
"__version__",
|
|
60
|
+
"configure_logging",
|
|
61
|
+
"get_logger",
|
|
53
62
|
"registry",
|
|
54
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
sendparcel/flow.py
CHANGED
|
@@ -21,6 +21,8 @@ from sendparcel.provider import (
|
|
|
21
21
|
PullStatusProvider,
|
|
22
22
|
PushCallbackProvider,
|
|
23
23
|
)
|
|
24
|
+
from sendparcel.registry import PluginRegistry
|
|
25
|
+
from sendparcel.registry import registry as default_registry
|
|
24
26
|
from sendparcel.types import (
|
|
25
27
|
AddressInfo,
|
|
26
28
|
CreateLabelOutcome,
|
|
@@ -40,13 +42,11 @@ class ShipmentFlow:
|
|
|
40
42
|
repository: ShipmentRepository,
|
|
41
43
|
config: dict[str, Any] | None = None,
|
|
42
44
|
validators: list[Any] | None = None,
|
|
43
|
-
registry:
|
|
45
|
+
registry: PluginRegistry | None = None,
|
|
44
46
|
) -> None:
|
|
45
47
|
self.repository = repository
|
|
46
48
|
self.config = config or {}
|
|
47
49
|
self.validators = validators or []
|
|
48
|
-
from sendparcel.registry import registry as default_registry
|
|
49
|
-
|
|
50
50
|
self.registry = registry or default_registry
|
|
51
51
|
|
|
52
52
|
async def create_shipment(
|
|
@@ -56,25 +56,98 @@ class ShipmentFlow:
|
|
|
56
56
|
sender_address: AddressInfo,
|
|
57
57
|
receiver_address: AddressInfo,
|
|
58
58
|
parcels: list[ParcelInfo],
|
|
59
|
+
idempotency_key: str | None = None,
|
|
59
60
|
**kwargs: Any,
|
|
60
61
|
) -> CreateShipmentOutcome:
|
|
61
|
-
"""Create a shipment record with explicit address and parcel data.
|
|
62
|
+
"""Create a shipment record with explicit address and parcel data.
|
|
63
|
+
|
|
64
|
+
Uses persistence-enforced idempotency: if a shipment with the same
|
|
65
|
+
provider + idempotency_key already exists, it is returned without
|
|
66
|
+
calling the provider again.
|
|
67
|
+
|
|
68
|
+
On provider failure:
|
|
69
|
+
- CommunicationError (timeout, network): marks shipment as
|
|
70
|
+
``SUBMITTED`` (ambiguous — provider may have accepted).
|
|
71
|
+
The caller should reconcile via polling or callback.
|
|
72
|
+
- Other errors: marks shipment as ``FAILED`` and re-raises.
|
|
73
|
+
|
|
74
|
+
The shipment record is never deleted on failure, ensuring that
|
|
75
|
+
retries and reconciliation always have a record to work with.
|
|
62
76
|
|
|
77
|
+
Args:
|
|
78
|
+
provider_slug: Provider identifier.
|
|
79
|
+
sender_address: Sender address info.
|
|
80
|
+
receiver_address: Receiver address info.
|
|
81
|
+
parcels: List of parcel definitions.
|
|
82
|
+
idempotency_key: Optional key for retry safety. Stored in the
|
|
83
|
+
``reference_id`` field of the shipment record.
|
|
84
|
+
**kwargs: Passed to the provider (after repo-only fields
|
|
85
|
+
are stripped).
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
CreateShipmentOutcome with the persisted shipment and
|
|
89
|
+
optional label payload.
|
|
90
|
+
"""
|
|
63
91
|
self.registry.get_by_slug(provider_slug)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
92
|
+
|
|
93
|
+
# Separate repository kwargs from provider kwargs.
|
|
94
|
+
repo_kwargs: dict[str, Any] = {}
|
|
95
|
+
for key in ("reference_id",):
|
|
96
|
+
if key in kwargs:
|
|
97
|
+
repo_kwargs[key] = kwargs.pop(key)
|
|
98
|
+
|
|
99
|
+
# Apply idempotency key to reference_id if provided.
|
|
100
|
+
if idempotency_key is not None:
|
|
101
|
+
repo_kwargs.setdefault("reference_id", idempotency_key)
|
|
102
|
+
|
|
103
|
+
# Create shipment — use atomic idempotency if key provided.
|
|
104
|
+
if idempotency_key is not None:
|
|
105
|
+
(
|
|
106
|
+
existing,
|
|
107
|
+
created,
|
|
108
|
+
) = await self.repository.create_with_idempotency_key(
|
|
109
|
+
provider=provider_slug,
|
|
110
|
+
status=ShipmentStatus.NEW.value,
|
|
111
|
+
**repo_kwargs,
|
|
112
|
+
)
|
|
113
|
+
if existing is not None:
|
|
114
|
+
return CreateShipmentOutcome(shipment=existing, label=None)
|
|
115
|
+
if created is None:
|
|
116
|
+
raise RuntimeError(
|
|
117
|
+
"create_with_idempotency_key returned (None, None) "
|
|
118
|
+
"— repository contract violated"
|
|
119
|
+
)
|
|
120
|
+
shipment = created
|
|
121
|
+
else:
|
|
122
|
+
shipment = await self.repository.create(
|
|
123
|
+
provider=provider_slug,
|
|
124
|
+
status=ShipmentStatus.NEW.value,
|
|
125
|
+
**repo_kwargs,
|
|
126
|
+
)
|
|
127
|
+
|
|
69
128
|
provider = self._get_provider(shipment)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
129
|
+
try:
|
|
130
|
+
result = await self._call_provider(
|
|
131
|
+
provider.create_shipment(
|
|
132
|
+
sender_address=sender_address,
|
|
133
|
+
receiver_address=receiver_address,
|
|
134
|
+
parcels=parcels,
|
|
135
|
+
**kwargs,
|
|
136
|
+
)
|
|
76
137
|
)
|
|
77
|
-
|
|
138
|
+
except CommunicationError:
|
|
139
|
+
# Ambiguous: provider may have accepted the shipment
|
|
140
|
+
# (e.g. timeout after acceptance). Mark as SUBMITTED for
|
|
141
|
+
# reconciliation via polling or callback.
|
|
142
|
+
transition_shipment(shipment, ShipmentStatus.SUBMITTED)
|
|
143
|
+
await self.repository.save(shipment)
|
|
144
|
+
return CreateShipmentOutcome(shipment=shipment, label=None)
|
|
145
|
+
except Exception:
|
|
146
|
+
# Non-communication error: mark as FAILED, never delete.
|
|
147
|
+
transition_shipment(shipment, ShipmentStatus.FAILED)
|
|
148
|
+
await self.repository.save(shipment)
|
|
149
|
+
raise
|
|
150
|
+
|
|
78
151
|
shipment.external_id = str(result.get("external_id", ""))
|
|
79
152
|
shipment.tracking_number = str(result.get("tracking_number", ""))
|
|
80
153
|
transition_shipment(shipment, ShipmentStatus.CREATED)
|
|
@@ -98,7 +171,9 @@ class ShipmentFlow:
|
|
|
98
171
|
)
|
|
99
172
|
label = await self._call_provider(provider.create_label(**kwargs))
|
|
100
173
|
transition_shipment(shipment, ShipmentStatus.LABEL_READY)
|
|
101
|
-
saved = await self.repository.
|
|
174
|
+
saved = await self.repository.update_fields(
|
|
175
|
+
shipment_id=shipment.id, status=shipment.status
|
|
176
|
+
)
|
|
102
177
|
return CreateLabelOutcome(shipment=saved, label=label)
|
|
103
178
|
|
|
104
179
|
async def handle_callback(
|
|
@@ -155,7 +230,9 @@ class ShipmentFlow:
|
|
|
155
230
|
)
|
|
156
231
|
if cancelled:
|
|
157
232
|
transition_shipment(shipment, ShipmentStatus.CANCELLED)
|
|
158
|
-
await self.repository.
|
|
233
|
+
await self.repository.update_fields(
|
|
234
|
+
shipment_id=shipment.id, status=shipment.status
|
|
235
|
+
)
|
|
159
236
|
return bool(cancelled)
|
|
160
237
|
|
|
161
238
|
def _get_provider(self, shipment: Shipment) -> BaseProvider:
|
|
@@ -166,16 +243,29 @@ class ShipmentFlow:
|
|
|
166
243
|
async def _apply_update(
|
|
167
244
|
self, shipment: Shipment, update: ShipmentUpdateResult
|
|
168
245
|
) -> Shipment:
|
|
246
|
+
"""Apply a normalized update to a shipment atomically.
|
|
247
|
+
|
|
248
|
+
Uses the atomic ``update_fields`` persistence primitive to
|
|
249
|
+
prevent concurrent read-modify-save races.
|
|
250
|
+
"""
|
|
251
|
+
fields: dict[str, Any] = {}
|
|
169
252
|
tracking_number = update.get("tracking_number")
|
|
170
253
|
if tracking_number:
|
|
171
|
-
|
|
254
|
+
fields["tracking_number"] = str(tracking_number)
|
|
172
255
|
status = update.get("status")
|
|
173
256
|
if status is not None:
|
|
174
257
|
transition_shipment(shipment, status)
|
|
175
|
-
|
|
258
|
+
fields["status"] = shipment.status
|
|
259
|
+
return await self.repository.update_fields(
|
|
260
|
+
shipment_id=shipment.id, **fields
|
|
261
|
+
)
|
|
176
262
|
|
|
177
263
|
async def _call_provider(self, coro: Any) -> Any:
|
|
178
|
-
"""Call a provider coroutine, wrapping
|
|
264
|
+
"""Call a provider coroutine, wrapping only network errors.
|
|
265
|
+
|
|
266
|
+
Domain errors (ValueError, KeyError, TypeError, etc.) are re-raised
|
|
267
|
+
as-is so they are distinguishable from communication failures.
|
|
268
|
+
"""
|
|
179
269
|
|
|
180
270
|
try:
|
|
181
271
|
return await coro
|
|
@@ -186,7 +276,18 @@ class ShipmentFlow:
|
|
|
186
276
|
str(exc),
|
|
187
277
|
context={"original_error": type(exc).__name__},
|
|
188
278
|
) from exc
|
|
189
|
-
except
|
|
279
|
+
except TimeoutError as exc:
|
|
280
|
+
raise CommunicationError(
|
|
281
|
+
str(exc),
|
|
282
|
+
context={"original_error": type(exc).__name__},
|
|
283
|
+
) from exc
|
|
284
|
+
except ExceptionGroup as exc:
|
|
285
|
+
# Preserve typed inner exceptions: if any inner exception is a
|
|
286
|
+
# SendParcelException, re-raise it directly. Otherwise wrap the
|
|
287
|
+
# entire group in a CommunicationError.
|
|
288
|
+
for e in exc.exceptions:
|
|
289
|
+
if isinstance(e, SendParcelException):
|
|
290
|
+
raise e from exc
|
|
190
291
|
raise CommunicationError(
|
|
191
292
|
str(exc),
|
|
192
293
|
context={"original_error": type(exc).__name__},
|
sendparcel/fsm.py
CHANGED
|
@@ -6,62 +6,69 @@ from sendparcel.enums import ShipmentStatus
|
|
|
6
6
|
from sendparcel.exceptions import InvalidTransitionError
|
|
7
7
|
from sendparcel.protocols import Shipment
|
|
8
8
|
|
|
9
|
-
ALLOWED_STATUS_TRANSITIONS: dict[
|
|
10
|
-
ShipmentStatus.NEW
|
|
9
|
+
ALLOWED_STATUS_TRANSITIONS: dict[ShipmentStatus, frozenset[ShipmentStatus]] = {
|
|
10
|
+
ShipmentStatus.NEW: frozenset(
|
|
11
11
|
{
|
|
12
|
-
ShipmentStatus.CREATED
|
|
13
|
-
ShipmentStatus.CANCELLED
|
|
14
|
-
ShipmentStatus.FAILED
|
|
12
|
+
ShipmentStatus.CREATED,
|
|
13
|
+
ShipmentStatus.CANCELLED,
|
|
14
|
+
ShipmentStatus.FAILED,
|
|
15
|
+
ShipmentStatus.SUBMITTED,
|
|
15
16
|
}
|
|
16
17
|
),
|
|
17
|
-
ShipmentStatus.CREATED
|
|
18
|
+
ShipmentStatus.CREATED: frozenset(
|
|
18
19
|
{
|
|
19
|
-
ShipmentStatus.LABEL_READY
|
|
20
|
-
ShipmentStatus.IN_TRANSIT
|
|
21
|
-
ShipmentStatus.OUT_FOR_DELIVERY
|
|
22
|
-
ShipmentStatus.DELIVERED
|
|
23
|
-
ShipmentStatus.RETURNED
|
|
24
|
-
ShipmentStatus.CANCELLED
|
|
25
|
-
ShipmentStatus.FAILED
|
|
20
|
+
ShipmentStatus.LABEL_READY,
|
|
21
|
+
ShipmentStatus.IN_TRANSIT,
|
|
22
|
+
ShipmentStatus.OUT_FOR_DELIVERY,
|
|
23
|
+
ShipmentStatus.DELIVERED,
|
|
24
|
+
ShipmentStatus.RETURNED,
|
|
25
|
+
ShipmentStatus.CANCELLED,
|
|
26
|
+
ShipmentStatus.FAILED,
|
|
26
27
|
}
|
|
27
28
|
),
|
|
28
|
-
ShipmentStatus.LABEL_READY
|
|
29
|
+
ShipmentStatus.LABEL_READY: frozenset(
|
|
29
30
|
{
|
|
30
|
-
ShipmentStatus.IN_TRANSIT
|
|
31
|
-
ShipmentStatus.OUT_FOR_DELIVERY
|
|
32
|
-
ShipmentStatus.DELIVERED
|
|
33
|
-
ShipmentStatus.RETURNED
|
|
34
|
-
ShipmentStatus.CANCELLED
|
|
35
|
-
ShipmentStatus.FAILED
|
|
31
|
+
ShipmentStatus.IN_TRANSIT,
|
|
32
|
+
ShipmentStatus.OUT_FOR_DELIVERY,
|
|
33
|
+
ShipmentStatus.DELIVERED,
|
|
34
|
+
ShipmentStatus.RETURNED,
|
|
35
|
+
ShipmentStatus.CANCELLED,
|
|
36
|
+
ShipmentStatus.FAILED,
|
|
36
37
|
}
|
|
37
38
|
),
|
|
38
|
-
ShipmentStatus.IN_TRANSIT
|
|
39
|
+
ShipmentStatus.IN_TRANSIT: frozenset(
|
|
39
40
|
{
|
|
40
|
-
ShipmentStatus.OUT_FOR_DELIVERY
|
|
41
|
-
ShipmentStatus.DELIVERED
|
|
42
|
-
ShipmentStatus.RETURNED
|
|
43
|
-
ShipmentStatus.FAILED
|
|
41
|
+
ShipmentStatus.OUT_FOR_DELIVERY,
|
|
42
|
+
ShipmentStatus.DELIVERED,
|
|
43
|
+
ShipmentStatus.RETURNED,
|
|
44
|
+
ShipmentStatus.FAILED,
|
|
44
45
|
}
|
|
45
46
|
),
|
|
46
|
-
ShipmentStatus.OUT_FOR_DELIVERY
|
|
47
|
+
ShipmentStatus.OUT_FOR_DELIVERY: frozenset(
|
|
47
48
|
{
|
|
48
|
-
ShipmentStatus.DELIVERED
|
|
49
|
-
ShipmentStatus.RETURNED
|
|
50
|
-
ShipmentStatus.FAILED
|
|
49
|
+
ShipmentStatus.DELIVERED,
|
|
50
|
+
ShipmentStatus.RETURNED,
|
|
51
|
+
ShipmentStatus.FAILED,
|
|
52
|
+
}
|
|
53
|
+
),
|
|
54
|
+
ShipmentStatus.DELIVERED: frozenset({ShipmentStatus.RETURNED}),
|
|
55
|
+
ShipmentStatus.CANCELLED: frozenset(),
|
|
56
|
+
ShipmentStatus.FAILED: frozenset(),
|
|
57
|
+
ShipmentStatus.RETURNED: frozenset(),
|
|
58
|
+
ShipmentStatus.SUBMITTED: frozenset(
|
|
59
|
+
{
|
|
60
|
+
ShipmentStatus.CREATED,
|
|
61
|
+
ShipmentStatus.FAILED,
|
|
51
62
|
}
|
|
52
63
|
),
|
|
53
|
-
ShipmentStatus.DELIVERED.value: frozenset({ShipmentStatus.RETURNED.value}),
|
|
54
|
-
ShipmentStatus.CANCELLED.value: frozenset(),
|
|
55
|
-
ShipmentStatus.FAILED.value: frozenset(),
|
|
56
|
-
ShipmentStatus.RETURNED.value: frozenset(),
|
|
57
64
|
}
|
|
58
65
|
|
|
59
66
|
|
|
60
|
-
def normalize_status(status: str | ShipmentStatus) ->
|
|
61
|
-
"""
|
|
67
|
+
def normalize_status(status: str | ShipmentStatus) -> ShipmentStatus:
|
|
68
|
+
"""Normalise a status to a :class:`ShipmentStatus` enum member."""
|
|
62
69
|
|
|
63
70
|
try:
|
|
64
|
-
return ShipmentStatus(status)
|
|
71
|
+
return ShipmentStatus(status)
|
|
65
72
|
except ValueError as exc:
|
|
66
73
|
raise InvalidTransitionError(
|
|
67
74
|
f"Unknown shipment status {status!r}"
|
|
@@ -89,11 +96,11 @@ def transition_shipment(
|
|
|
89
96
|
current = normalize_status(shipment.status)
|
|
90
97
|
target = normalize_status(target_status)
|
|
91
98
|
if current == target:
|
|
92
|
-
shipment.status = target
|
|
99
|
+
shipment.status = target.value
|
|
93
100
|
return shipment
|
|
94
101
|
if target not in ALLOWED_STATUS_TRANSITIONS[current]:
|
|
95
102
|
raise InvalidTransitionError(
|
|
96
103
|
f"Shipment cannot transition from {current!r} to {target!r}"
|
|
97
104
|
)
|
|
98
|
-
shipment.status = target
|
|
105
|
+
shipment.status = target.value
|
|
99
106
|
return shipment
|
sendparcel/logging.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Structured logging for sendparcel.
|
|
2
|
+
|
|
3
|
+
Provides JSON-formatted logging with consistent fields:
|
|
4
|
+
- timestamp (ISO 8601)
|
|
5
|
+
- level
|
|
6
|
+
- logger name
|
|
7
|
+
- message
|
|
8
|
+
- extra context (module, function, shipment_id, etc.)
|
|
9
|
+
|
|
10
|
+
Usage::
|
|
11
|
+
|
|
12
|
+
from sendparcel.logging import get_logger
|
|
13
|
+
|
|
14
|
+
logger = get_logger()
|
|
15
|
+
logger.info("Shipment created", shipment_id="abc-123", provider="inpost")
|
|
16
|
+
|
|
17
|
+
In production, the JSON formatter outputs one-line JSON records suitable
|
|
18
|
+
for structured log aggregation (Datadog, CloudWatch, ELK, etc.).
|
|
19
|
+
In development, falls back to human-readable format.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
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
|
+
class _JsonFormatter(logging.Formatter):
|
|
46
|
+
"""JSON formatter for structured logging.
|
|
47
|
+
|
|
48
|
+
Outputs one-line JSON records with consistent fields.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
52
|
+
extra = _format_extra(getattr(record, "extra", None))
|
|
53
|
+
log_entry: dict[str, Any] = {
|
|
54
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
55
|
+
"level": record.levelname,
|
|
56
|
+
"logger": record.name,
|
|
57
|
+
"message": record.getMessage(),
|
|
58
|
+
}
|
|
59
|
+
if extra:
|
|
60
|
+
log_entry["extra"] = extra
|
|
61
|
+
if hasattr(record, "exc_info") and record.exc_info:
|
|
62
|
+
log_entry["exception"] = self.formatException(record.exc_info)
|
|
63
|
+
return json.dumps(log_entry, default=str)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _HumanFormatter(logging.Formatter):
|
|
67
|
+
"""Human-readable formatter for development."""
|
|
68
|
+
|
|
69
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
70
|
+
extra = _format_extra(getattr(record, "extra", None))
|
|
71
|
+
extra_str = " ".join(f"{k}={v}" for k, v in extra.items())
|
|
72
|
+
msg = record.getMessage()
|
|
73
|
+
if extra_str:
|
|
74
|
+
msg = f"{msg} {extra_str}"
|
|
75
|
+
return super().format(record)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def configure_logging(
|
|
79
|
+
level: int = logging.INFO,
|
|
80
|
+
json_format: bool | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Configure structured logging for the sendparcel package.
|
|
83
|
+
|
|
84
|
+
Call this once at application startup (e.g. Django ``AppConfig.ready()``,
|
|
85
|
+
FastAPI lifespan, or a CLI ``__main__.py``) to enable JSON-formatted
|
|
86
|
+
structured logging for all sendparcel packages.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
level: Logging level (default: INFO).
|
|
90
|
+
json_format: Force JSON format (True/False). If None, uses
|
|
91
|
+
JSON when the ``SENDPARCEL_LOG_FORMAT`` environment variable
|
|
92
|
+
is set to ``"json"``, human-readable otherwise.
|
|
93
|
+
"""
|
|
94
|
+
if json_format is None:
|
|
95
|
+
json_format = os.environ.get("SENDPARCEL_LOG_FORMAT") == "json"
|
|
96
|
+
|
|
97
|
+
for name in ("sendparcel", "sendparcel_inpost", "sendparcel_django"):
|
|
98
|
+
logger = logging.getLogger(name)
|
|
99
|
+
logger.setLevel(level)
|
|
100
|
+
logger.propagate = False
|
|
101
|
+
|
|
102
|
+
# Avoid adding handlers multiple times
|
|
103
|
+
if logger.handlers:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
107
|
+
handler.setFormatter(
|
|
108
|
+
_JsonFormatter() if json_format else _HumanFormatter()
|
|
109
|
+
)
|
|
110
|
+
logger.addHandler(handler)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_logger(
|
|
114
|
+
name: str | None = None,
|
|
115
|
+
*,
|
|
116
|
+
extra: dict[str, Any] | None = None,
|
|
117
|
+
) -> logging.LoggerAdapter[logging.Logger]:
|
|
118
|
+
"""Get a structured logger for the sendparcel ecosystem.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
name: Logger name. Defaults to the caller's module name.
|
|
122
|
+
extra: Extra fields to include in every log record.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
A LoggerAdapter with consistent structured logging.
|
|
126
|
+
|
|
127
|
+
Usage::
|
|
128
|
+
|
|
129
|
+
logger = get_logger()
|
|
130
|
+
logger.info("Shipment created", shipment_id="abc-123")
|
|
131
|
+
logger.error("API failed", exc_info=True)
|
|
132
|
+
"""
|
|
133
|
+
# Auto-detect caller module if not specified
|
|
134
|
+
if name is None:
|
|
135
|
+
import inspect
|
|
136
|
+
|
|
137
|
+
frame = inspect.currentframe()
|
|
138
|
+
if frame is not None:
|
|
139
|
+
caller = frame.f_back
|
|
140
|
+
if caller is not None:
|
|
141
|
+
name = caller.f_globals.get("__name__", __name__)
|
|
142
|
+
|
|
143
|
+
logger = logging.getLogger(name)
|
|
144
|
+
adapter = logging.LoggerAdapter(logger, extra=extra or {})
|
|
145
|
+
return adapter
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# NOTE: Logging is NOT configured on import. Call
|
|
149
|
+
# ``sendparcel.configure_logging()`` explicitly from your application
|
|
150
|
+
# startup code (e.g. Django ``AppConfig.ready()``) to enable structured
|
|
151
|
+
# 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/protocols.py
CHANGED
|
@@ -18,9 +18,58 @@ class Shipment(Protocol):
|
|
|
18
18
|
class ShipmentRepository(Protocol):
|
|
19
19
|
"""Persistence abstraction for adapters."""
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
def get_by_id_sync(
|
|
22
|
+
self, shipment_id: str, *, for_update: bool = False
|
|
23
|
+
) -> Shipment:
|
|
24
|
+
"""Synchronous fetch — required for transactional helpers."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
async def get_by_id(
|
|
28
|
+
self, shipment_id: str, *, for_update: bool = False
|
|
29
|
+
) -> Shipment: ...
|
|
22
30
|
async def create(self, **kwargs: Any) -> Shipment: ...
|
|
23
31
|
async def save(self, shipment: Shipment) -> Shipment: ...
|
|
24
32
|
async def update_status(
|
|
25
33
|
self, shipment_id: str, status: str, **fields: Any
|
|
26
34
|
) -> Shipment: ...
|
|
35
|
+
async def delete(self, shipment_id: str) -> None: ...
|
|
36
|
+
async def find_by_reference(
|
|
37
|
+
self, provider: str, reference_id: str
|
|
38
|
+
) -> Shipment | None: ...
|
|
39
|
+
async def create_with_idempotency_key(
|
|
40
|
+
self,
|
|
41
|
+
provider: str,
|
|
42
|
+
status: str,
|
|
43
|
+
reference_id: str,
|
|
44
|
+
**kwargs: Any,
|
|
45
|
+
) -> tuple[Shipment | None, Shipment | None]:
|
|
46
|
+
"""Atomically check for existing + create if absent.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
(existing, created) — exactly one is None.
|
|
50
|
+
If a shipment with this provider + reference_id already
|
|
51
|
+
exists, returns (existing, None).
|
|
52
|
+
If no such shipment exists, creates one and returns
|
|
53
|
+
(None, created).
|
|
54
|
+
"""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
async def update_fields(self, shipment_id: str, **fields: Any) -> Shipment:
|
|
58
|
+
"""Atomically update shipment fields by ID.
|
|
59
|
+
|
|
60
|
+
This is the atomic persistence primitive for callback and
|
|
61
|
+
polling flows. Unlike save() which mutates an in-memory
|
|
62
|
+
object, this performs a single atomic update operation,
|
|
63
|
+
preventing concurrent read-modify-save races.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
shipment_id: The shipment to update.
|
|
67
|
+
**fields: Fields to update (e.g. status, tracking_number).
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The updated shipment object.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
ShipmentNotFoundError: If no shipment with this ID exists.
|
|
74
|
+
"""
|
|
75
|
+
...
|
sendparcel/registry.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import threading
|
|
5
6
|
from importlib.metadata import entry_points
|
|
6
7
|
|
|
7
8
|
from sendparcel.exceptions import ProviderNotFoundError
|
|
@@ -11,61 +12,92 @@ ENTRY_POINT_GROUP = "sendparcel.providers"
|
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class PluginRegistry:
|
|
14
|
-
"""Discover and store provider classes.
|
|
15
|
+
"""Discover and store provider classes.
|
|
16
|
+
|
|
17
|
+
Thread-safe: all public methods and ``_register_provider`` are
|
|
18
|
+
protected by a ``threading.Lock``.
|
|
19
|
+
"""
|
|
15
20
|
|
|
16
21
|
def __init__(self) -> None:
|
|
17
22
|
self._providers: dict[str, type[BaseProvider]] = {}
|
|
18
23
|
self._discovered = False
|
|
24
|
+
self._lock = threading.Lock()
|
|
19
25
|
|
|
20
26
|
def discover(self) -> None:
|
|
21
|
-
"""Load providers from entry points.
|
|
27
|
+
"""Load providers from entry points.
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
provider_class, BaseProvider
|
|
31
|
-
):
|
|
32
|
-
self._register_provider(provider_class)
|
|
33
|
-
self._discovered = True
|
|
29
|
+
Thread-safe: acquires ``_lock`` before mutating state.
|
|
30
|
+
Idempotent — subsequent calls are no-ops.
|
|
31
|
+
"""
|
|
32
|
+
with self._lock:
|
|
33
|
+
if self._discovered:
|
|
34
|
+
return
|
|
35
|
+
self._discover_unlocked()
|
|
34
36
|
|
|
35
37
|
def register(self, provider_class: type[BaseProvider]) -> None:
|
|
36
|
-
"""Register provider manually.
|
|
38
|
+
"""Register provider manually.
|
|
37
39
|
|
|
38
|
-
|
|
40
|
+
Thread-safe.
|
|
41
|
+
"""
|
|
42
|
+
with self._lock:
|
|
43
|
+
self._register_provider(provider_class)
|
|
39
44
|
|
|
40
45
|
def unregister(self, slug: str) -> None:
|
|
41
|
-
"""Unregister provider by slug.
|
|
46
|
+
"""Unregister provider by slug.
|
|
42
47
|
|
|
43
|
-
|
|
48
|
+
Thread-safe.
|
|
49
|
+
"""
|
|
50
|
+
with self._lock:
|
|
51
|
+
self._providers.pop(slug, None)
|
|
44
52
|
|
|
45
53
|
def get_by_slug(self, slug: str) -> type[BaseProvider]:
|
|
46
|
-
"""Get provider class by slug.
|
|
54
|
+
"""Get provider class by slug.
|
|
47
55
|
|
|
56
|
+
Thread-safe.
|
|
57
|
+
"""
|
|
48
58
|
self._ensure_discovered()
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
with self._lock:
|
|
60
|
+
try:
|
|
61
|
+
return self._providers[slug]
|
|
62
|
+
except KeyError as exc:
|
|
63
|
+
raise ProviderNotFoundError(slug) from exc
|
|
53
64
|
|
|
54
65
|
def get_choices(self) -> list[tuple[str, str]]:
|
|
55
|
-
"""Get provider slug/display pairs for user-facing selection.
|
|
66
|
+
"""Get provider slug/display pairs for user-facing selection.
|
|
56
67
|
|
|
68
|
+
Thread-safe.
|
|
69
|
+
"""
|
|
57
70
|
self._ensure_discovered()
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
with self._lock:
|
|
72
|
+
return [
|
|
73
|
+
(provider.slug, provider.display_name)
|
|
74
|
+
for provider in self._providers.values()
|
|
75
|
+
if provider.user_selectable
|
|
76
|
+
]
|
|
63
77
|
|
|
64
78
|
def _ensure_discovered(self) -> None:
|
|
65
79
|
if not self._discovered:
|
|
66
80
|
self.discover()
|
|
67
81
|
|
|
82
|
+
def _discover_unlocked(self) -> None:
|
|
83
|
+
"""Internal discover — caller must hold ``_lock``."""
|
|
84
|
+
from sendparcel.providers import BUILTIN_PROVIDERS
|
|
85
|
+
|
|
86
|
+
for provider_class in BUILTIN_PROVIDERS:
|
|
87
|
+
self._register_provider(provider_class)
|
|
88
|
+
for entry_point in entry_points(group=ENTRY_POINT_GROUP):
|
|
89
|
+
provider_class = entry_point.load()
|
|
90
|
+
if isinstance(provider_class, type) and issubclass(
|
|
91
|
+
provider_class, BaseProvider
|
|
92
|
+
):
|
|
93
|
+
self._register_provider(provider_class)
|
|
94
|
+
self._discovered = True
|
|
95
|
+
|
|
68
96
|
def _register_provider(self, provider_class: type[BaseProvider]) -> None:
|
|
97
|
+
"""Register a single provider class.
|
|
98
|
+
|
|
99
|
+
Caller must hold ``_lock``.
|
|
100
|
+
"""
|
|
69
101
|
slug = provider_class.slug
|
|
70
102
|
if not isinstance(slug, str) or not slug:
|
|
71
103
|
raise ValueError("Provider classes must declare a non-empty slug")
|
|
@@ -1,17 +0,0 @@
|
|
|
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,,
|
|
File without changes
|