python-sendparcel 0.1.1__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.
- {python_sendparcel-0.1.1.dist-info → python_sendparcel-0.3.0.dist-info}/METADATA +45 -37
- python_sendparcel-0.3.0.dist-info/RECORD +20 -0
- {python_sendparcel-0.1.1.dist-info → python_sendparcel-0.3.0.dist-info}/WHEEL +1 -1
- sendparcel/__init__.py +15 -12
- sendparcel/batch.py +305 -0
- sendparcel/concurrent.py +71 -0
- sendparcel/enums.py +1 -0
- sendparcel/factory.py +33 -0
- sendparcel/flow.py +140 -65
- sendparcel/fsm.py +51 -44
- sendparcel/logging.py +137 -0
- sendparcel/protocols.py +50 -1
- sendparcel/provider.py +183 -33
- sendparcel/providers/dummy.py +7 -20
- sendparcel/registry.py +69 -28
- sendparcel/types.py +31 -4
- python_sendparcel-0.1.1.dist-info/RECORD +0 -17
- sendparcel/validators.py +0 -21
- {python_sendparcel-0.1.1.dist-info → python_sendparcel-0.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-sendparcel
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Framework-agnostic parcel shipping core for Python.
|
|
5
|
-
Project-URL: Homepage, https://github.com/sendparcel/python-sendparcel
|
|
6
|
-
Project-URL:
|
|
7
|
-
Project-URL:
|
|
8
|
-
Project-URL:
|
|
9
|
-
Project-URL: Issue Tracker, https://github.com/sendparcel/python-sendparcel/issues
|
|
5
|
+
Project-URL: Homepage, https://github.com/python-sendparcel/python-sendparcel
|
|
6
|
+
Project-URL: Repository, https://github.com/python-sendparcel/python-sendparcel
|
|
7
|
+
Project-URL: Changelog, https://github.com/python-sendparcel/python-sendparcel/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Issue Tracker, https://github.com/python-sendparcel/python-sendparcel/issues
|
|
10
9
|
Author-email: Dominik Kozaczko <dominik@kozaczko.info>
|
|
11
10
|
License: MIT
|
|
12
11
|
License-File: LICENSE
|
|
@@ -24,14 +23,13 @@ Requires-Python: >=3.12
|
|
|
24
23
|
Requires-Dist: anyio>=4.0
|
|
25
24
|
Requires-Dist: httpx>=0.27.0
|
|
26
25
|
Provides-Extra: all
|
|
27
|
-
Requires-Dist: django-sendparcel>=0.
|
|
28
|
-
Requires-Dist:
|
|
29
|
-
Requires-Dist:
|
|
30
|
-
Requires-Dist: python-sendparcel-
|
|
31
|
-
Requires-Dist: python-sendparcel-
|
|
32
|
-
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'
|
|
33
31
|
Provides-Extra: cli
|
|
34
|
-
Requires-Dist: python-sendparcel-cli>=0.
|
|
32
|
+
Requires-Dist: python-sendparcel-cli>=0.2.0; extra == 'cli'
|
|
35
33
|
Provides-Extra: dev
|
|
36
34
|
Requires-Dist: pre-commit-hooks>=6.0.0; extra == 'dev'
|
|
37
35
|
Requires-Dist: pre-commit>=4.0; extra == 'dev'
|
|
@@ -39,32 +37,30 @@ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
|
39
37
|
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
40
38
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
41
39
|
Requires-Dist: ruff>=0.9.0; extra == 'dev'
|
|
40
|
+
Requires-Dist: trio>=0.27; extra == 'dev'
|
|
42
41
|
Requires-Dist: ty>=0.0.16; extra == 'dev'
|
|
43
42
|
Provides-Extra: django
|
|
44
|
-
Requires-Dist: django-sendparcel>=0.
|
|
43
|
+
Requires-Dist: django-sendparcel>=0.3.0; extra == 'django'
|
|
45
44
|
Provides-Extra: dpdpl
|
|
46
|
-
Requires-Dist: python-sendparcel-dpdpl>=0.1.
|
|
45
|
+
Requires-Dist: python-sendparcel-dpdpl>=0.1.1; extra == 'dpdpl'
|
|
47
46
|
Provides-Extra: dummy
|
|
48
|
-
Provides-Extra: fastapi
|
|
49
|
-
Requires-Dist: fastapi-sendparcel>=0.1.0; extra == 'fastapi'
|
|
50
47
|
Provides-Extra: frameworks
|
|
51
|
-
Requires-Dist: django-sendparcel>=0.
|
|
52
|
-
Requires-Dist: fastapi-sendparcel>=0.1.0; extra == 'frameworks'
|
|
53
|
-
Requires-Dist: litestar-sendparcel>=0.1.0; extra == 'frameworks'
|
|
48
|
+
Requires-Dist: django-sendparcel>=0.3.0; extra == 'frameworks'
|
|
54
49
|
Provides-Extra: inpost
|
|
55
|
-
Requires-Dist: python-sendparcel-inpost>=0.
|
|
56
|
-
Provides-Extra:
|
|
57
|
-
Requires-Dist:
|
|
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'
|
|
58
53
|
Provides-Extra: providers
|
|
59
|
-
Requires-Dist: python-sendparcel-dpdpl>=0.1.
|
|
60
|
-
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'
|
|
61
57
|
Description-Content-Type: text/markdown
|
|
62
58
|
|
|
63
59
|
# python-sendparcel
|
|
64
60
|
|
|
65
61
|
Framework-agnostic parcel shipping core for Python.
|
|
66
62
|
|
|
67
|
-
> Alpha notice: `0.
|
|
63
|
+
> Alpha notice: `0.3.0` is still unstable. The API can change fast because the
|
|
68
64
|
> ecosystem is still being cleaned up.
|
|
69
65
|
|
|
70
66
|
## What it is
|
|
@@ -129,6 +125,12 @@ class InMemoryRepository:
|
|
|
129
125
|
self._store[shipment.id] = shipment
|
|
130
126
|
return shipment
|
|
131
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
|
+
|
|
132
134
|
|
|
133
135
|
async def main() -> None:
|
|
134
136
|
flow = ShipmentFlow(repository=InMemoryRepository())
|
|
@@ -165,15 +167,20 @@ anyio.run(main)
|
|
|
165
167
|
|
|
166
168
|
## Provider model
|
|
167
169
|
|
|
168
|
-
|
|
170
|
+
All providers subclass `BaseProvider`; capability methods raise
|
|
171
|
+
`ProviderCapabilityError` unless overridden:
|
|
172
|
+
|
|
173
|
+
- `BaseProvider.create_shipment(...)` returns `ShipmentCreateResult` (required).
|
|
169
174
|
- `BaseProvider.confirmation_method` defaults to `ConfirmationMethod.NONE`.
|
|
170
|
-
- `
|
|
171
|
-
- `
|
|
172
|
-
|
|
173
|
-
- `
|
|
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`.
|
|
174
180
|
|
|
175
|
-
Use `ConfirmationMethod.PUSH` only
|
|
176
|
-
`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.
|
|
177
184
|
|
|
178
185
|
The core owns shipment state transitions. Providers translate carrier responses
|
|
179
186
|
into normalized results.
|
|
@@ -193,10 +200,9 @@ uv add python-sendparcel
|
|
|
193
200
|
## Extras
|
|
194
201
|
|
|
195
202
|
- `django`
|
|
196
|
-
- `fastapi`
|
|
197
|
-
- `litestar`
|
|
198
203
|
- `inpost`
|
|
199
204
|
- `dpdpl`
|
|
205
|
+
- `orlenpaczka`
|
|
200
206
|
- `cli`
|
|
201
207
|
- `frameworks`
|
|
202
208
|
- `providers`
|
|
@@ -207,6 +213,8 @@ uv add python-sendparcel
|
|
|
207
213
|
```bash
|
|
208
214
|
uv sync --extra dev
|
|
209
215
|
uv run pytest
|
|
210
|
-
uv run ruff check
|
|
211
|
-
uv run
|
|
216
|
+
uv run ruff check .
|
|
217
|
+
uv run ruff format --check .
|
|
218
|
+
uv run ty check
|
|
219
|
+
uv run mypy src
|
|
212
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,7 +1,8 @@
|
|
|
1
1
|
"""sendparcel core package."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.
|
|
3
|
+
__version__ = "0.3.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,
|
|
@@ -12,25 +13,25 @@ from sendparcel.exceptions import (
|
|
|
12
13
|
SendParcelException,
|
|
13
14
|
ShipmentNotFoundError,
|
|
14
15
|
)
|
|
16
|
+
from sendparcel.factory import create_provider
|
|
15
17
|
from sendparcel.flow import ShipmentFlow
|
|
16
|
-
from sendparcel.
|
|
17
|
-
|
|
18
|
-
CancellableProvider,
|
|
19
|
-
LabelProvider,
|
|
20
|
-
PullStatusProvider,
|
|
21
|
-
PushCallbackProvider,
|
|
22
|
-
)
|
|
18
|
+
from sendparcel.logging import configure_logging, get_logger
|
|
19
|
+
from sendparcel.provider import BaseProvider
|
|
23
20
|
from sendparcel.providers.dummy import DummyProvider
|
|
24
21
|
from sendparcel.registry import registry
|
|
25
22
|
from sendparcel.types import (
|
|
23
|
+
CallbackContext,
|
|
26
24
|
CreateLabelOutcome,
|
|
27
25
|
CreateShipmentOutcome,
|
|
28
26
|
ShipmentUpdateOutcome,
|
|
27
|
+
ShipmentUpdateResult,
|
|
29
28
|
)
|
|
30
29
|
|
|
31
30
|
__all__ = [
|
|
32
31
|
"BaseProvider",
|
|
33
|
-
"
|
|
32
|
+
"BatchCreateResult",
|
|
33
|
+
"BatchResult",
|
|
34
|
+
"CallbackContext",
|
|
34
35
|
"CommunicationError",
|
|
35
36
|
"ConfirmationMethod",
|
|
36
37
|
"CreateLabelOutcome",
|
|
@@ -39,16 +40,18 @@ __all__ = [
|
|
|
39
40
|
"InvalidCallbackError",
|
|
40
41
|
"InvalidTransitionError",
|
|
41
42
|
"LabelFormat",
|
|
42
|
-
"LabelProvider",
|
|
43
43
|
"ProviderCapabilityError",
|
|
44
44
|
"ProviderNotFoundError",
|
|
45
|
-
"PullStatusProvider",
|
|
46
|
-
"PushCallbackProvider",
|
|
47
45
|
"SendParcelException",
|
|
46
|
+
"ShipmentBatch",
|
|
48
47
|
"ShipmentFlow",
|
|
49
48
|
"ShipmentNotFoundError",
|
|
50
49
|
"ShipmentStatus",
|
|
51
50
|
"ShipmentUpdateOutcome",
|
|
51
|
+
"ShipmentUpdateResult",
|
|
52
52
|
"__version__",
|
|
53
|
+
"configure_logging",
|
|
54
|
+
"create_provider",
|
|
55
|
+
"get_logger",
|
|
53
56
|
"registry",
|
|
54
57
|
]
|
sendparcel/batch.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
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
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from sendparcel.concurrent import ConcurrentExecutor, ConcurrentResult
|
|
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
|
+
from sendparcel.registry import registry as default_registry
|
|
40
|
+
|
|
41
|
+
logger = get_logger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class BatchResult:
|
|
46
|
+
"""Result of a single batch operation."""
|
|
47
|
+
|
|
48
|
+
index: int
|
|
49
|
+
success: bool
|
|
50
|
+
shipment: Any | None = None
|
|
51
|
+
error: str | None = None
|
|
52
|
+
outcome: Any | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class BatchCreateResult:
|
|
57
|
+
"""Result of a batch create operation."""
|
|
58
|
+
|
|
59
|
+
total: int
|
|
60
|
+
successful: int
|
|
61
|
+
failed: int
|
|
62
|
+
results: list[BatchResult] = field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def success(self) -> bool:
|
|
66
|
+
"""True if all shipments were created successfully."""
|
|
67
|
+
return self.failed == 0
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def summary(self) -> dict[str, Any]:
|
|
71
|
+
"""Return a summary of the batch operation."""
|
|
72
|
+
return {
|
|
73
|
+
"total": self.total,
|
|
74
|
+
"successful": self.successful,
|
|
75
|
+
"failed": self.failed,
|
|
76
|
+
"success_rate": (
|
|
77
|
+
round(self.successful / self.total * 100, 2)
|
|
78
|
+
if self.total > 0
|
|
79
|
+
else 0.0
|
|
80
|
+
),
|
|
81
|
+
}
|
|
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
|
+
|
|
96
|
+
class ShipmentBatch:
|
|
97
|
+
"""Batch shipment operations.
|
|
98
|
+
|
|
99
|
+
Provides efficient batch creation, status fetching, and cancellation
|
|
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.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
repository: Shipment repository for persisting results.
|
|
106
|
+
config: Provider configuration.
|
|
107
|
+
registry: Provider registry (defaults to module-level singleton).
|
|
108
|
+
max_concurrent: Maximum number of concurrent provider calls
|
|
109
|
+
(default: 5).
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
repository: ShipmentRepository,
|
|
115
|
+
config: dict[str, Any] | None = None,
|
|
116
|
+
registry: PluginRegistry | None = None,
|
|
117
|
+
max_concurrent: int = 5,
|
|
118
|
+
) -> None:
|
|
119
|
+
self.repository = repository
|
|
120
|
+
self.config = config or {}
|
|
121
|
+
self.registry = registry or default_registry
|
|
122
|
+
self._executor = ConcurrentExecutor(max_concurrent)
|
|
123
|
+
|
|
124
|
+
async def create_shipments(
|
|
125
|
+
self,
|
|
126
|
+
shipments: list[dict[str, Any]],
|
|
127
|
+
) -> BatchCreateResult:
|
|
128
|
+
"""Create multiple shipments in a batch.
|
|
129
|
+
|
|
130
|
+
Each shipment dict must contain:
|
|
131
|
+
- provider_slug: Provider identifier
|
|
132
|
+
- sender_address: Sender address info
|
|
133
|
+
- receiver_address: Receiver address info
|
|
134
|
+
- parcels: List of parcel definitions
|
|
135
|
+
- Optional: idempotency_key, reference_id, and other kwargs
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
shipments: List of shipment dicts to create.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
BatchCreateResult with per-shipment results.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
async def _create_one(data: dict[str, Any], index: int) -> BatchResult:
|
|
145
|
+
return await self._create_single_shipment(index, data)
|
|
146
|
+
|
|
147
|
+
concurrent_results = await self._executor.execute(
|
|
148
|
+
shipments, _create_one
|
|
149
|
+
)
|
|
150
|
+
batch_results = [_to_batch_result(r) for r in concurrent_results]
|
|
151
|
+
|
|
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)
|
|
154
|
+
|
|
155
|
+
return BatchCreateResult(
|
|
156
|
+
total=len(shipments),
|
|
157
|
+
successful=successful,
|
|
158
|
+
failed=failed,
|
|
159
|
+
results=batch_results,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
async def _create_single_shipment(
|
|
163
|
+
self, index: int, shipment_data: dict[str, Any]
|
|
164
|
+
) -> BatchResult:
|
|
165
|
+
provider_slug = shipment_data.get("provider_slug")
|
|
166
|
+
if not provider_slug:
|
|
167
|
+
return BatchResult(
|
|
168
|
+
index=index,
|
|
169
|
+
success=False,
|
|
170
|
+
error="Missing provider_slug",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
self.registry.get_by_slug(provider_slug)
|
|
175
|
+
|
|
176
|
+
flow = ShipmentFlow(
|
|
177
|
+
repository=self.repository,
|
|
178
|
+
config=self.config,
|
|
179
|
+
registry=self.registry,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
outcome = await flow.create_shipment(
|
|
183
|
+
provider_slug=provider_slug,
|
|
184
|
+
sender_address=shipment_data["sender_address"],
|
|
185
|
+
receiver_address=shipment_data["receiver_address"],
|
|
186
|
+
parcels=shipment_data["parcels"],
|
|
187
|
+
**{
|
|
188
|
+
k: v
|
|
189
|
+
for k, v in shipment_data.items()
|
|
190
|
+
if k
|
|
191
|
+
not in (
|
|
192
|
+
"provider_slug",
|
|
193
|
+
"sender_address",
|
|
194
|
+
"receiver_address",
|
|
195
|
+
"parcels",
|
|
196
|
+
)
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
logger.info(
|
|
201
|
+
"Batch create: shipment %d created successfully "
|
|
202
|
+
"(provider=%s, tracking=%s)",
|
|
203
|
+
index,
|
|
204
|
+
provider_slug,
|
|
205
|
+
outcome.shipment.tracking_number,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return BatchResult(
|
|
209
|
+
index=index,
|
|
210
|
+
success=True,
|
|
211
|
+
shipment=outcome.shipment,
|
|
212
|
+
outcome=outcome,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
except Exception as exc:
|
|
216
|
+
logger.warning(
|
|
217
|
+
"Batch create: shipment %d failed: %s",
|
|
218
|
+
index,
|
|
219
|
+
exc,
|
|
220
|
+
)
|
|
221
|
+
return BatchResult(
|
|
222
|
+
index=index,
|
|
223
|
+
success=False,
|
|
224
|
+
error=str(exc),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
async def fetch_statuses(
|
|
228
|
+
self,
|
|
229
|
+
shipment_ids: list[str],
|
|
230
|
+
) -> list[BatchResult]:
|
|
231
|
+
"""Fetch statuses for multiple shipments.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
shipment_ids: List of shipment IDs to fetch.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
List of BatchResult with per-shipment status updates.
|
|
238
|
+
"""
|
|
239
|
+
|
|
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
|
|
264
|
+
)
|
|
265
|
+
return [_to_batch_result(r) for r in concurrent_results]
|
|
266
|
+
|
|
267
|
+
async def cancel_shipments(
|
|
268
|
+
self,
|
|
269
|
+
shipment_ids: list[str],
|
|
270
|
+
) -> list[BatchResult]:
|
|
271
|
+
"""Cancel multiple shipments.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
shipment_ids: List of shipment IDs to cancel.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
List of BatchResult with per-shipment cancellation results.
|
|
278
|
+
"""
|
|
279
|
+
|
|
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
|
|
304
|
+
)
|
|
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/enums.py
CHANGED
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)
|