python-sendparcel 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

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