python-sendparcel 0.2.0__py3-none-any.whl → 0.3.1__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.1
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=jnFj2CbAJQLE4eeOerlAWgn0ub--fqRdhkscFRvBkfA,1689
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=ur-GNH9UinrQy2ttErFXzRB4TTh4jdbBP8BlokIKA4A,13328
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=lXuUevoDVdzqHFry0Mm_lSRgAJF5t6HQ1rj6sL5-_Js,10525
12
+ sendparcel/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ sendparcel/registry.py,sha256=CCaU7Esd6vS5eVvvv_oLuWEhd9j17Zk3-hEOZl-_7Vk,3790
14
+ sendparcel/types.py,sha256=E96qcbQ6qfpvtA-LRdIzQounYXDWnfMNZJypzJ3dI1g,4855
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.1.dist-info/METADATA,sha256=vdcN_KGTpb5PjfoxvcSZXHerCcomZE7WDfS9dMdPXhk,7175
18
+ python_sendparcel-0.3.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
19
+ python_sendparcel-0.3.1.dist-info/licenses/LICENSE,sha256=IZXSBOjgGvChgayLmtTnU40iE7hsrrU3WVEYKx0sywY,1075
20
+ python_sendparcel-0.3.1.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,20 +13,21 @@ 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,
24
+ CancelOutcome,
25
+ CancelReason,
28
26
  CreateLabelOutcome,
29
27
  CreateShipmentOutcome,
28
+ GeoPoint,
29
+ PickupPoint,
30
+ Quote,
30
31
  ShipmentUpdateOutcome,
31
32
  ShipmentUpdateResult,
32
33
  )
@@ -35,20 +36,22 @@ __all__ = [
35
36
  "BaseProvider",
36
37
  "BatchCreateResult",
37
38
  "BatchResult",
38
- "CancellableProvider",
39
+ "CallbackContext",
40
+ "CancelOutcome",
41
+ "CancelReason",
39
42
  "CommunicationError",
40
43
  "ConfirmationMethod",
41
44
  "CreateLabelOutcome",
42
45
  "CreateShipmentOutcome",
43
46
  "DummyProvider",
47
+ "GeoPoint",
44
48
  "InvalidCallbackError",
45
49
  "InvalidTransitionError",
46
50
  "LabelFormat",
47
- "LabelProvider",
51
+ "PickupPoint",
48
52
  "ProviderCapabilityError",
49
53
  "ProviderNotFoundError",
50
- "PullStatusProvider",
51
- "PushCallbackProvider",
54
+ "Quote",
52
55
  "SendParcelException",
53
56
  "ShipmentBatch",
54
57
  "ShipmentFlow",
@@ -58,6 +61,7 @@ __all__ = [
58
61
  "ShipmentUpdateResult",
59
62
  "__version__",
60
63
  "configure_logging",
64
+ "create_provider",
61
65
  "get_logger",
62
66
  "registry",
63
67
  ]
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)