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.
@@ -1,12 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-sendparcel
3
- Version: 0.1.1
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: Documentation, https://python-sendparcel.readthedocs.io/
7
- Project-URL: Repository, https://github.com/sendparcel/python-sendparcel
8
- Project-URL: Changelog, https://github.com/sendparcel/python-sendparcel/blob/main/CHANGELOG.md
9
- Project-URL: Issue Tracker, https://github.com/sendparcel/python-sendparcel/issues
5
+ Project-URL: Homepage, https://github.com/python-sendparcel/python-sendparcel
6
+ Project-URL: Repository, https://github.com/python-sendparcel/python-sendparcel
7
+ Project-URL: Changelog, https://github.com/python-sendparcel/python-sendparcel/blob/main/CHANGELOG.md
8
+ Project-URL: Issue Tracker, https://github.com/python-sendparcel/python-sendparcel/issues
10
9
  Author-email: Dominik Kozaczko <dominik@kozaczko.info>
11
10
  License: MIT
12
11
  License-File: LICENSE
@@ -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.1.0; extra == 'all'
28
- Requires-Dist: fastapi-sendparcel>=0.1.0; extra == 'all'
29
- Requires-Dist: litestar-sendparcel>=0.1.0; extra == 'all'
30
- Requires-Dist: python-sendparcel-cli>=0.1.0; extra == 'all'
31
- Requires-Dist: python-sendparcel-dpdpl>=0.1.0; extra == 'all'
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.1.0; extra == 'cli'
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.1.0; extra == 'django'
43
+ Requires-Dist: django-sendparcel>=0.3.0; extra == 'django'
45
44
  Provides-Extra: dpdpl
46
- Requires-Dist: python-sendparcel-dpdpl>=0.1.0; extra == 'dpdpl'
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.1.0; extra == 'frameworks'
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.1.0; extra == 'inpost'
56
- Provides-Extra: litestar
57
- Requires-Dist: litestar-sendparcel>=0.1.0; extra == 'litestar'
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.0; extra == 'providers'
60
- 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'
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.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
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
- - `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).
169
174
  - `BaseProvider.confirmation_method` defaults to `ConfirmationMethod.NONE`.
170
- - `LabelProvider.create_label(...)` returns `LabelInfo`.
171
- - `PushCallbackProvider.handle_callback(...)` returns `ShipmentUpdateResult`.
172
- - `PullStatusProvider.fetch_shipment_status(...)` returns `ShipmentUpdateResult`.
173
- - `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`.
174
180
 
175
- Use `ConfirmationMethod.PUSH` only with `PushCallbackProvider` and
176
- `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.
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 src tests
211
- 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
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.30.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
sendparcel/__init__.py CHANGED
@@ -1,7 +1,8 @@
1
1
  """sendparcel core package."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.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.provider import (
17
- BaseProvider,
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
- "CancellableProvider",
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]
@@ -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
@@ -15,6 +15,7 @@ class ShipmentStatus(StrEnum):
15
15
  CANCELLED = "cancelled"
16
16
  FAILED = "failed"
17
17
  RETURNED = "returned"
18
+ SUBMITTED = "submitted"
18
19
 
19
20
 
20
21
  class LabelFormat(StrEnum):
sendparcel/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)