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.
- {python_sendparcel-0.2.0.dist-info → python_sendparcel-0.3.1.dist-info}/METADATA +41 -24
- python_sendparcel-0.3.1.dist-info/RECORD +20 -0
- sendparcel/__init__.py +16 -12
- sendparcel/batch.py +77 -75
- sendparcel/concurrent.py +71 -0
- sendparcel/factory.py +33 -0
- sendparcel/flow.py +110 -51
- sendparcel/fsm.py +8 -8
- sendparcel/logging.py +5 -23
- sendparcel/provider.py +248 -30
- sendparcel/providers/dummy.py +7 -20
- sendparcel/registry.py +9 -0
- sendparcel/types.py +108 -4
- python_sendparcel-0.2.0.dist-info/RECORD +0 -19
- sendparcel/validators.py +0 -21
- {python_sendparcel-0.2.0.dist-info → python_sendparcel-0.3.1.dist-info}/WHEEL +0 -0
- {python_sendparcel-0.2.0.dist-info → python_sendparcel-0.3.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-sendparcel
|
|
3
|
-
Version: 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.
|
|
27
|
-
Requires-Dist: python-sendparcel-cli>=0.
|
|
28
|
-
Requires-Dist: python-sendparcel-dpdpl>=0.1.
|
|
29
|
-
Requires-Dist: python-sendparcel-inpost>=0.
|
|
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.
|
|
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.
|
|
43
|
+
Requires-Dist: django-sendparcel>=0.3.0; extra == 'django'
|
|
42
44
|
Provides-Extra: dpdpl
|
|
43
|
-
Requires-Dist: python-sendparcel-dpdpl>=0.1.
|
|
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.
|
|
48
|
+
Requires-Dist: django-sendparcel>=0.3.0; extra == 'frameworks'
|
|
47
49
|
Provides-Extra: inpost
|
|
48
|
-
Requires-Dist: python-sendparcel-inpost>=0.
|
|
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.
|
|
51
|
-
Requires-Dist: python-sendparcel-inpost>=0.
|
|
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.
|
|
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
|
-
|
|
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
|
-
- `
|
|
162
|
-
- `
|
|
163
|
-
|
|
164
|
-
- `
|
|
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
|
|
167
|
-
`ConfirmationMethod.PULL` only
|
|
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
|
|
202
|
-
uv run
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
"
|
|
51
|
+
"PickupPoint",
|
|
48
52
|
"ProviderCapabilityError",
|
|
49
53
|
"ProviderNotFoundError",
|
|
50
|
-
"
|
|
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.
|
|
88
|
-
|
|
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
|
|
108
|
-
self.
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
|
142
|
-
failed = sum(1 for r in
|
|
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=
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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
|
-
|
|
303
|
-
return results
|
|
305
|
+
return [_to_batch_result(r) for r in concurrent_results]
|
sendparcel/concurrent.py
ADDED
|
@@ -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)
|