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.
@@ -1,12 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-sendparcel
3
- Version: 0.1.1
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: Documentation, https://python-sendparcel.readthedocs.io/
7
- Project-URL: Repository, https://github.com/sendparcel/python-sendparcel
8
- Project-URL: Changelog, https://github.com/sendparcel/python-sendparcel/blob/main/CHANGELOG.md
9
- Project-URL: Issue Tracker, https://github.com/sendparcel/python-sendparcel/issues
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.30.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
sendparcel/__init__.py CHANGED
@@ -1,7 +1,8 @@
1
1
  """sendparcel core package."""
2
2
 
3
- __version__ = "0.1.1"
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
@@ -15,6 +15,7 @@ class ShipmentStatus(StrEnum):
15
15
  CANCELLED = "cancelled"
16
16
  FAILED = "failed"
17
17
  RETURNED = "returned"
18
+ SUBMITTED = "submitted"
18
19
 
19
20
 
20
21
  class LabelFormat(StrEnum):
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: Any = None,
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
- shipment = await self.repository.create(
65
- provider=provider_slug,
66
- status=ShipmentStatus.NEW.value,
67
- **kwargs,
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
- result = await self._call_provider(
71
- provider.create_shipment(
72
- sender_address=sender_address,
73
- receiver_address=receiver_address,
74
- parcels=parcels,
75
- **kwargs,
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.save(shipment)
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.save(shipment)
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
- shipment.tracking_number = str(tracking_number)
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
- return await self.repository.save(shipment)
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 non-domain errors."""
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 Exception as exc:
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[str, frozenset[str]] = {
10
- ShipmentStatus.NEW.value: frozenset(
9
+ ALLOWED_STATUS_TRANSITIONS: dict[ShipmentStatus, frozenset[ShipmentStatus]] = {
10
+ ShipmentStatus.NEW: frozenset(
11
11
  {
12
- ShipmentStatus.CREATED.value,
13
- ShipmentStatus.CANCELLED.value,
14
- ShipmentStatus.FAILED.value,
12
+ ShipmentStatus.CREATED,
13
+ ShipmentStatus.CANCELLED,
14
+ ShipmentStatus.FAILED,
15
+ ShipmentStatus.SUBMITTED,
15
16
  }
16
17
  ),
17
- ShipmentStatus.CREATED.value: frozenset(
18
+ ShipmentStatus.CREATED: frozenset(
18
19
  {
19
- ShipmentStatus.LABEL_READY.value,
20
- ShipmentStatus.IN_TRANSIT.value,
21
- ShipmentStatus.OUT_FOR_DELIVERY.value,
22
- ShipmentStatus.DELIVERED.value,
23
- ShipmentStatus.RETURNED.value,
24
- ShipmentStatus.CANCELLED.value,
25
- ShipmentStatus.FAILED.value,
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.value: frozenset(
29
+ ShipmentStatus.LABEL_READY: frozenset(
29
30
  {
30
- ShipmentStatus.IN_TRANSIT.value,
31
- ShipmentStatus.OUT_FOR_DELIVERY.value,
32
- ShipmentStatus.DELIVERED.value,
33
- ShipmentStatus.RETURNED.value,
34
- ShipmentStatus.CANCELLED.value,
35
- ShipmentStatus.FAILED.value,
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.value: frozenset(
39
+ ShipmentStatus.IN_TRANSIT: frozenset(
39
40
  {
40
- ShipmentStatus.OUT_FOR_DELIVERY.value,
41
- ShipmentStatus.DELIVERED.value,
42
- ShipmentStatus.RETURNED.value,
43
- ShipmentStatus.FAILED.value,
41
+ ShipmentStatus.OUT_FOR_DELIVERY,
42
+ ShipmentStatus.DELIVERED,
43
+ ShipmentStatus.RETURNED,
44
+ ShipmentStatus.FAILED,
44
45
  }
45
46
  ),
46
- ShipmentStatus.OUT_FOR_DELIVERY.value: frozenset(
47
+ ShipmentStatus.OUT_FOR_DELIVERY: frozenset(
47
48
  {
48
- ShipmentStatus.DELIVERED.value,
49
- ShipmentStatus.RETURNED.value,
50
- ShipmentStatus.FAILED.value,
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) -> str:
61
- """Return a normalized shipment status string."""
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).value
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
- async def get_by_id(self, shipment_id: str) -> Shipment: ...
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
- from sendparcel.providers import BUILTIN_PROVIDERS
24
-
25
- for provider_class in BUILTIN_PROVIDERS:
26
- self._register_provider(provider_class)
27
- for entry_point in entry_points(group=ENTRY_POINT_GROUP):
28
- provider_class = entry_point.load()
29
- if isinstance(provider_class, type) and issubclass(
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
- self._register_provider(provider_class)
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
- self._providers.pop(slug, None)
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
- try:
50
- return self._providers[slug]
51
- except KeyError as exc:
52
- raise ProviderNotFoundError(slug) from exc
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
- return [
59
- (provider.slug, provider.display_name)
60
- for provider in self._providers.values()
61
- if provider.user_selectable
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,,