python-sendparcel 0.3.0__tar.gz → 0.3.2__tar.gz
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.3.0 → python_sendparcel-0.3.2}/CHANGELOG.md +9 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/PKG-INFO +1 -1
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/pyproject.toml +1 -1
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/__init__.py +10 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/flow.py +99 -8
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/provider.py +69 -1
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/providers/dummy.py +21 -2
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/types.py +77 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_batch.py +10 -2
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_builtin_dummy_provider.py +31 -7
- python_sendparcel-0.3.2/tests/test_cancel_outcome.py +320 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_flow.py +17 -4
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_integration.py +16 -3
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_public_api.py +5 -0
- python_sendparcel-0.3.2/tests/test_quote.py +87 -0
- python_sendparcel-0.3.2/tests/test_search_points.py +219 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/.github/workflows/ci.yml +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/.github/workflows/ecosystem-smoke.yml +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/.github/workflows/release.yml +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/.gitignore +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/.pre-commit-config.yaml +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/CONTRIBUTING.md +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/LICENSE +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/README.md +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/Makefile +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/compatibility-matrix.md +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/conf.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/getting-started.md +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/index.md +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/installation-matrix.md +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/plans/2026-02-17-order-optional.md +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/provider-authoring.md +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/release-policy.md +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/requirements.txt +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/batch.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/concurrent.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/enums.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/exceptions.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/factory.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/fsm.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/logging.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/protocols.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/providers/__init__.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/py.typed +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/registry.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/conftest.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_concurrent.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_enums.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_exceptions.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_fsm.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_protocols.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_provider.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_provider_enhancements.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_registry.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_types.py +0 -0
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.2 (2026-07-09)
|
|
4
|
+
|
|
5
|
+
- `DummyProvider.cancel_shipment` returns a structured `CancelOutcome`
|
|
6
|
+
(configurable via `cancel_success` / `cancel_reason` settings) instead of a
|
|
7
|
+
bare bool — aligning core's own provider with the 0.3.1 cancel trait that
|
|
8
|
+
`ShipmentFlow.cancel_shipment` already requires.
|
|
9
|
+
- `ShipmentFlow`: typed returns for `cancel_shipment` / `search_points`
|
|
10
|
+
(mypy no-any-return).
|
|
11
|
+
|
|
3
12
|
All notable changes to this project will be documented in this file.
|
|
4
13
|
|
|
5
14
|
The format is based on [Keep a Changelog](https://keepachangelog.com/),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-sendparcel
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
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
|
|
@@ -21,8 +21,13 @@ from sendparcel.providers.dummy import DummyProvider
|
|
|
21
21
|
from sendparcel.registry import registry
|
|
22
22
|
from sendparcel.types import (
|
|
23
23
|
CallbackContext,
|
|
24
|
+
CancelOutcome,
|
|
25
|
+
CancelReason,
|
|
24
26
|
CreateLabelOutcome,
|
|
25
27
|
CreateShipmentOutcome,
|
|
28
|
+
GeoPoint,
|
|
29
|
+
PickupPoint,
|
|
30
|
+
Quote,
|
|
26
31
|
ShipmentUpdateOutcome,
|
|
27
32
|
ShipmentUpdateResult,
|
|
28
33
|
)
|
|
@@ -32,16 +37,21 @@ __all__ = [
|
|
|
32
37
|
"BatchCreateResult",
|
|
33
38
|
"BatchResult",
|
|
34
39
|
"CallbackContext",
|
|
40
|
+
"CancelOutcome",
|
|
41
|
+
"CancelReason",
|
|
35
42
|
"CommunicationError",
|
|
36
43
|
"ConfirmationMethod",
|
|
37
44
|
"CreateLabelOutcome",
|
|
38
45
|
"CreateShipmentOutcome",
|
|
39
46
|
"DummyProvider",
|
|
47
|
+
"GeoPoint",
|
|
40
48
|
"InvalidCallbackError",
|
|
41
49
|
"InvalidTransitionError",
|
|
42
50
|
"LabelFormat",
|
|
51
|
+
"PickupPoint",
|
|
43
52
|
"ProviderCapabilityError",
|
|
44
53
|
"ProviderNotFoundError",
|
|
54
|
+
"Quote",
|
|
45
55
|
"SendParcelException",
|
|
46
56
|
"ShipmentBatch",
|
|
47
57
|
"ShipmentFlow",
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, cast
|
|
6
7
|
|
|
7
8
|
import httpx
|
|
8
9
|
|
|
@@ -19,14 +20,30 @@ from sendparcel.registry import registry as default_registry
|
|
|
19
20
|
from sendparcel.types import (
|
|
20
21
|
AddressInfo,
|
|
21
22
|
CallbackContext,
|
|
23
|
+
CancelOutcome,
|
|
24
|
+
CancelReason,
|
|
22
25
|
CreateLabelOutcome,
|
|
23
26
|
CreateShipmentOutcome,
|
|
27
|
+
GeoPoint,
|
|
24
28
|
ParcelInfo,
|
|
29
|
+
PickupPoint,
|
|
25
30
|
ShipmentUpdateOutcome,
|
|
26
31
|
ShipmentUpdateResult,
|
|
27
32
|
)
|
|
28
33
|
|
|
29
34
|
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class _PointSearchShipment:
|
|
37
|
+
"""Placeholder satisfying the Shipment protocol for provider calls
|
|
38
|
+
that do not operate on a specific shipment (e.g. point search)."""
|
|
39
|
+
|
|
40
|
+
provider: str
|
|
41
|
+
id: str = ""
|
|
42
|
+
status: str = "new"
|
|
43
|
+
external_id: str = ""
|
|
44
|
+
tracking_number: str = ""
|
|
45
|
+
|
|
46
|
+
|
|
30
47
|
class ShipmentFlow:
|
|
31
48
|
"""Framework-agnostic shipment orchestration."""
|
|
32
49
|
|
|
@@ -193,19 +210,93 @@ class ShipmentFlow:
|
|
|
193
210
|
saved = await self._apply_update(shipment, normalized_update)
|
|
194
211
|
return ShipmentUpdateOutcome(shipment=saved, update=normalized_update)
|
|
195
212
|
|
|
196
|
-
async def cancel_shipment(
|
|
197
|
-
|
|
198
|
-
|
|
213
|
+
async def cancel_shipment(
|
|
214
|
+
self, shipment: Shipment, **kwargs: Any
|
|
215
|
+
) -> CancelOutcome:
|
|
216
|
+
"""Cancel shipment via provider and persist state.
|
|
217
|
+
|
|
218
|
+
Returns a structured :class:`CancelOutcome` so callers can
|
|
219
|
+
distinguish permanent denies from retryable failures.
|
|
220
|
+
|
|
221
|
+
- ``CANCELLED`` / ``ALREADY_CANCELLED`` → transitions shipment to
|
|
222
|
+
``CANCELLED``.
|
|
223
|
+
- ``REFUSED_IN_TRANSIT`` / ``NOT_CANCELLABLE`` → leaves shipment
|
|
224
|
+
in current state (caller decides UX).
|
|
225
|
+
- ``TRANSIENT_ERROR`` → raises ``CommunicationError`` (retryable),
|
|
226
|
+
no state change.
|
|
227
|
+
- ``AUTH_ERROR`` → re-raises provider auth error.
|
|
228
|
+
"""
|
|
199
229
|
provider = self._get_provider(shipment)
|
|
200
|
-
|
|
201
|
-
|
|
230
|
+
outcome = cast(
|
|
231
|
+
"CancelOutcome",
|
|
232
|
+
await self._call_provider(provider.cancel_shipment(**kwargs)),
|
|
202
233
|
)
|
|
203
|
-
if
|
|
234
|
+
if outcome.get("reason") == CancelReason.TRANSIENT_ERROR:
|
|
235
|
+
raise CommunicationError(
|
|
236
|
+
outcome.get("detail") or "Cancel failed with transient error",
|
|
237
|
+
context={
|
|
238
|
+
"provider_status_code": outcome.get("provider_status_code"),
|
|
239
|
+
"reason": CancelReason.TRANSIENT_ERROR,
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
if outcome.get("cancelled"):
|
|
204
243
|
transition_shipment(shipment, ShipmentStatus.CANCELLED)
|
|
205
244
|
await self.repository.update_fields(
|
|
206
245
|
shipment_id=shipment.id, status=shipment.status
|
|
207
246
|
)
|
|
208
|
-
return
|
|
247
|
+
return outcome
|
|
248
|
+
|
|
249
|
+
async def search_points(
|
|
250
|
+
self,
|
|
251
|
+
provider_slug: str,
|
|
252
|
+
*,
|
|
253
|
+
query: str | None = None,
|
|
254
|
+
near: GeoPoint | None = None,
|
|
255
|
+
radius_m: int | None = None,
|
|
256
|
+
point_type: str | None = None,
|
|
257
|
+
limit: int = 20,
|
|
258
|
+
**kwargs: Any,
|
|
259
|
+
) -> list[PickupPoint]:
|
|
260
|
+
"""Search carrier pickup points via the registered provider.
|
|
261
|
+
|
|
262
|
+
Convenience method that delegates to the provider's
|
|
263
|
+
``search_points`` capability. Raises
|
|
264
|
+
``ProviderCapabilityError`` if the provider does not support
|
|
265
|
+
point search.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
provider_slug: Provider identifier.
|
|
269
|
+
query: Free-text search (city, address, or point code).
|
|
270
|
+
near: :class:`GeoPoint` for proximity search.
|
|
271
|
+
radius_m: Search radius in metres when ``near`` is given.
|
|
272
|
+
point_type: Provider taxonomy filter.
|
|
273
|
+
limit: Maximum number of results.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
List of :class:`PickupPoint` results.
|
|
277
|
+
"""
|
|
278
|
+
provider_class = self.registry.get_by_slug(provider_slug)
|
|
279
|
+
provider_config = self.config.get(provider_slug, {})
|
|
280
|
+
from sendparcel.factory import create_provider
|
|
281
|
+
|
|
282
|
+
provider = create_provider(
|
|
283
|
+
_PointSearchShipment(provider=provider_slug),
|
|
284
|
+
provider_class,
|
|
285
|
+
provider_config,
|
|
286
|
+
)
|
|
287
|
+
return cast(
|
|
288
|
+
"list[PickupPoint]",
|
|
289
|
+
await self._call_provider(
|
|
290
|
+
provider.search_points(
|
|
291
|
+
query=query,
|
|
292
|
+
near=near,
|
|
293
|
+
radius_m=radius_m,
|
|
294
|
+
point_type=point_type,
|
|
295
|
+
limit=limit,
|
|
296
|
+
**kwargs,
|
|
297
|
+
)
|
|
298
|
+
),
|
|
299
|
+
)
|
|
209
300
|
|
|
210
301
|
def _get_provider(self, shipment: Shipment) -> BaseProvider:
|
|
211
302
|
from sendparcel.factory import create_provider
|
|
@@ -12,8 +12,12 @@ from sendparcel.protocols import Shipment
|
|
|
12
12
|
from sendparcel.types import (
|
|
13
13
|
AddressInfo,
|
|
14
14
|
CallbackContext,
|
|
15
|
+
CancelOutcome,
|
|
16
|
+
GeoPoint,
|
|
15
17
|
LabelInfo,
|
|
16
18
|
ParcelInfo,
|
|
19
|
+
PickupPoint,
|
|
20
|
+
Quote,
|
|
17
21
|
ShipmentCreateResult,
|
|
18
22
|
ShipmentUpdateResult,
|
|
19
23
|
)
|
|
@@ -230,9 +234,13 @@ class BaseProvider(ABC):
|
|
|
230
234
|
"status polling"
|
|
231
235
|
)
|
|
232
236
|
|
|
233
|
-
async def cancel_shipment(self, **kwargs: Any) ->
|
|
237
|
+
async def cancel_shipment(self, **kwargs: Any) -> CancelOutcome:
|
|
234
238
|
"""Cancel shipment if provider supports cancellation.
|
|
235
239
|
|
|
240
|
+
Returns a structured :class:`CancelOutcome` so callers can
|
|
241
|
+
distinguish permanent denies (REFUSED_IN_TRANSIT, NOT_CANCELLABLE)
|
|
242
|
+
from retryable failures (TRANSIENT_ERROR).
|
|
243
|
+
|
|
236
244
|
Raises :exc:`ProviderCapabilityError` if the provider does not
|
|
237
245
|
support cancellation.
|
|
238
246
|
"""
|
|
@@ -240,3 +248,63 @@ class BaseProvider(ABC):
|
|
|
240
248
|
f"Provider {self.__class__.__name__!r} does not support "
|
|
241
249
|
"cancellation"
|
|
242
250
|
)
|
|
251
|
+
|
|
252
|
+
async def search_points(
|
|
253
|
+
self,
|
|
254
|
+
*,
|
|
255
|
+
query: str | None = None,
|
|
256
|
+
near: GeoPoint | None = None,
|
|
257
|
+
radius_m: int | None = None,
|
|
258
|
+
point_type: str | None = None,
|
|
259
|
+
limit: int = 20,
|
|
260
|
+
**kwargs: Any,
|
|
261
|
+
) -> list[PickupPoint]:
|
|
262
|
+
"""Search carrier pickup points (lockers, parcel shops, etc.).
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
query: Free-text search (city, address, or point code).
|
|
266
|
+
near: :class:`GeoPoint` for proximity search.
|
|
267
|
+
radius_m: Search radius in metres when ``near`` is given.
|
|
268
|
+
point_type: Provider taxonomy filter (e.g. "parcel_locker").
|
|
269
|
+
limit: Maximum number of results to return.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
List of :class:`PickupPoint` results, ordered by distance
|
|
273
|
+
when ``near`` is given, else by relevance/name.
|
|
274
|
+
|
|
275
|
+
Raises:
|
|
276
|
+
ProviderCapabilityError: If the provider does not support
|
|
277
|
+
point search.
|
|
278
|
+
"""
|
|
279
|
+
raise ProviderCapabilityError(
|
|
280
|
+
f"Provider {self.__class__.__name__!r} does not support "
|
|
281
|
+
"pickup point search"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
async def get_quote(
|
|
285
|
+
self,
|
|
286
|
+
*,
|
|
287
|
+
service: str,
|
|
288
|
+
parcels: list[ParcelInfo],
|
|
289
|
+
sender_address: AddressInfo | None = None,
|
|
290
|
+
receiver_address: AddressInfo | None = None,
|
|
291
|
+
**kwargs: Any,
|
|
292
|
+
) -> Quote:
|
|
293
|
+
"""Get a shipping rate quote for a service/route combination.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
service: Service slug (e.g. "parcel_locker_31_0").
|
|
297
|
+
parcels: List of parcel definitions.
|
|
298
|
+
sender_address: Optional sender address.
|
|
299
|
+
receiver_address: Optional receiver address.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
:class:`Quote` with carrier cost as ``Decimal``.
|
|
303
|
+
|
|
304
|
+
Raises:
|
|
305
|
+
ProviderCapabilityError: If the provider does not support
|
|
306
|
+
rate lookup (callers should fall back to static pricing).
|
|
307
|
+
"""
|
|
308
|
+
raise ProviderCapabilityError(
|
|
309
|
+
f"Provider {self.__class__.__name__!r} does not support rate lookup"
|
|
310
|
+
)
|
|
@@ -12,6 +12,8 @@ from sendparcel.provider import BaseProvider
|
|
|
12
12
|
from sendparcel.types import (
|
|
13
13
|
AddressInfo,
|
|
14
14
|
CallbackContext,
|
|
15
|
+
CancelOutcome,
|
|
16
|
+
CancelReason,
|
|
15
17
|
LabelInfo,
|
|
16
18
|
ParcelInfo,
|
|
17
19
|
ShipmentCreateResult,
|
|
@@ -78,6 +80,23 @@ class DummyProvider(BaseProvider):
|
|
|
78
80
|
status=self.get_setting("status_override", self.shipment.status)
|
|
79
81
|
)
|
|
80
82
|
|
|
81
|
-
async def cancel_shipment(self, **kwargs: Any) ->
|
|
83
|
+
async def cancel_shipment(self, **kwargs: Any) -> CancelOutcome:
|
|
84
|
+
"""Cancel with a configurable structured outcome.
|
|
85
|
+
|
|
86
|
+
Settings: ``cancel_success`` (bool, default True) and
|
|
87
|
+
``cancel_reason`` (a :class:`CancelReason` value used when the
|
|
88
|
+
cancel is configured to fail; defaults to ``not_cancellable``).
|
|
89
|
+
"""
|
|
82
90
|
await self._simulate_latency()
|
|
83
|
-
|
|
91
|
+
if bool(self.get_setting("cancel_success", True)):
|
|
92
|
+
return CancelOutcome(
|
|
93
|
+
cancelled=True, reason=CancelReason.CANCELLED, retryable=False
|
|
94
|
+
)
|
|
95
|
+
reason = CancelReason(
|
|
96
|
+
self.get_setting("cancel_reason", CancelReason.NOT_CANCELLABLE)
|
|
97
|
+
)
|
|
98
|
+
return CancelOutcome(
|
|
99
|
+
cancelled=False,
|
|
100
|
+
reason=reason,
|
|
101
|
+
retryable=reason == CancelReason.TRANSIENT_ERROR,
|
|
102
|
+
)
|
|
@@ -5,7 +5,9 @@ from __future__ import annotations
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import json
|
|
7
7
|
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
8
9
|
from decimal import Decimal
|
|
10
|
+
from enum import StrEnum
|
|
9
11
|
from typing import Any, TypedDict
|
|
10
12
|
|
|
11
13
|
from sendparcel.enums import LabelFormat
|
|
@@ -107,6 +109,81 @@ class ShipmentUpdateOutcome:
|
|
|
107
109
|
update: ShipmentUpdateResult
|
|
108
110
|
|
|
109
111
|
|
|
112
|
+
class CancelReason(StrEnum):
|
|
113
|
+
"""Reason for a cancel_shipment outcome."""
|
|
114
|
+
|
|
115
|
+
CANCELLED = "cancelled"
|
|
116
|
+
ALREADY_CANCELLED = "already_cancelled"
|
|
117
|
+
REFUSED_IN_TRANSIT = "refused_in_transit"
|
|
118
|
+
NOT_CANCELLABLE = "not_cancellable"
|
|
119
|
+
TRANSIENT_ERROR = "transient_error"
|
|
120
|
+
AUTH_ERROR = "auth_error"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _CancelOutcomeRequired(TypedDict):
|
|
124
|
+
cancelled: bool
|
|
125
|
+
reason: CancelReason
|
|
126
|
+
retryable: bool
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class CancelOutcome(_CancelOutcomeRequired, total=False):
|
|
130
|
+
"""Structured result from cancel_shipment.
|
|
131
|
+
|
|
132
|
+
Replaces the bare bool return so callers can distinguish permanent
|
|
133
|
+
denies (REFUSED_IN_TRANSIT, NOT_CANCELLABLE) from retryable failures
|
|
134
|
+
(TRANSIENT_ERROR).
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
provider_status_code: int | None
|
|
138
|
+
detail: str | None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class GeoPoint(TypedDict):
|
|
142
|
+
"""Geographic coordinate pair."""
|
|
143
|
+
|
|
144
|
+
lat: float
|
|
145
|
+
lng: float
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class _PickupPointRequired(TypedDict):
|
|
149
|
+
code: str
|
|
150
|
+
name: str
|
|
151
|
+
provider_slug: str
|
|
152
|
+
address: str
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class PickupPoint(_PickupPointRequired, total=False):
|
|
156
|
+
"""Carrier pickup point (locker, parcel shop, etc.).
|
|
157
|
+
|
|
158
|
+
``code`` is the machine id used as ``target_point`` in
|
|
159
|
+
``create_shipment``, so a searched point is directly usable
|
|
160
|
+
without translation.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
location: GeoPoint | None
|
|
164
|
+
opening_hours: str | None
|
|
165
|
+
point_type: str | None
|
|
166
|
+
raw: dict[str, Any] | None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _QuoteRequired(TypedDict):
|
|
170
|
+
provider_slug: str
|
|
171
|
+
service: str
|
|
172
|
+
amount: Decimal
|
|
173
|
+
currency: str
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class Quote(_QuoteRequired, total=False):
|
|
177
|
+
"""Shipping rate quote from a carrier.
|
|
178
|
+
|
|
179
|
+
``amount`` is the carrier cost in the provider's currency.
|
|
180
|
+
Use ``Decimal`` — never ``float`` — for monetary values.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
valid_until: datetime | None
|
|
184
|
+
raw: dict[str, Any] | None
|
|
185
|
+
|
|
186
|
+
|
|
110
187
|
@dataclass(slots=True)
|
|
111
188
|
class CallbackContext:
|
|
112
189
|
"""Everything needed to process a webhook callback.
|
|
@@ -15,6 +15,8 @@ from sendparcel.registry import PluginRegistry
|
|
|
15
15
|
from sendparcel.types import (
|
|
16
16
|
AddressInfo,
|
|
17
17
|
CallbackContext,
|
|
18
|
+
CancelOutcome,
|
|
19
|
+
CancelReason,
|
|
18
20
|
ParcelInfo,
|
|
19
21
|
ShipmentCreateResult,
|
|
20
22
|
ShipmentUpdateResult,
|
|
@@ -168,8 +170,14 @@ class DummyProvider(BaseProvider):
|
|
|
168
170
|
) -> ShipmentUpdateResult:
|
|
169
171
|
return {"status": ShipmentStatus.CREATED}
|
|
170
172
|
|
|
171
|
-
async def cancel_shipment(self, **kwargs: Any) ->
|
|
172
|
-
return
|
|
173
|
+
async def cancel_shipment(self, **kwargs: Any) -> CancelOutcome:
|
|
174
|
+
return CancelOutcome(
|
|
175
|
+
cancelled=True,
|
|
176
|
+
reason=CancelReason.CANCELLED,
|
|
177
|
+
retryable=False,
|
|
178
|
+
provider_status_code=200,
|
|
179
|
+
detail=None,
|
|
180
|
+
)
|
|
173
181
|
|
|
174
182
|
|
|
175
183
|
@pytest.fixture
|
|
@@ -8,7 +8,12 @@ import pytest
|
|
|
8
8
|
from sendparcel.enums import ConfirmationMethod
|
|
9
9
|
from sendparcel.exceptions import InvalidCallbackError
|
|
10
10
|
from sendparcel.providers.dummy import DummyProvider
|
|
11
|
-
from sendparcel.types import
|
|
11
|
+
from sendparcel.types import (
|
|
12
|
+
AddressInfo,
|
|
13
|
+
CallbackContext,
|
|
14
|
+
CancelReason,
|
|
15
|
+
ParcelInfo,
|
|
16
|
+
)
|
|
12
17
|
|
|
13
18
|
_SENDER = AddressInfo(
|
|
14
19
|
name="Test Sender",
|
|
@@ -166,19 +171,38 @@ class TestFetchShipmentStatus:
|
|
|
166
171
|
|
|
167
172
|
class TestCancelShipment:
|
|
168
173
|
@pytest.mark.asyncio
|
|
169
|
-
async def
|
|
174
|
+
async def test_cancel_returns_outcome_by_default(self) -> None:
|
|
170
175
|
provider = DummyProvider(DummyShipment(), config={})
|
|
171
176
|
|
|
172
|
-
|
|
177
|
+
outcome = await provider.cancel_shipment()
|
|
173
178
|
|
|
174
|
-
assert
|
|
179
|
+
assert outcome["cancelled"] is True
|
|
180
|
+
assert outcome["reason"] == CancelReason.CANCELLED
|
|
181
|
+
assert outcome["retryable"] is False
|
|
175
182
|
|
|
176
183
|
@pytest.mark.asyncio
|
|
177
|
-
async def
|
|
184
|
+
async def test_cancel_can_be_configured_to_refuse(self) -> None:
|
|
178
185
|
provider = DummyProvider(
|
|
179
186
|
DummyShipment(), config={"cancel_success": False}
|
|
180
187
|
)
|
|
181
188
|
|
|
182
|
-
|
|
189
|
+
outcome = await provider.cancel_shipment()
|
|
190
|
+
|
|
191
|
+
assert outcome["cancelled"] is False
|
|
192
|
+
assert outcome["reason"] == CancelReason.NOT_CANCELLABLE
|
|
193
|
+
assert outcome["retryable"] is False
|
|
194
|
+
|
|
195
|
+
@pytest.mark.asyncio
|
|
196
|
+
async def test_cancel_reason_is_configurable(self) -> None:
|
|
197
|
+
provider = DummyProvider(
|
|
198
|
+
DummyShipment(),
|
|
199
|
+
config={
|
|
200
|
+
"cancel_success": False,
|
|
201
|
+
"cancel_reason": "refused_in_transit",
|
|
202
|
+
},
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
outcome = await provider.cancel_shipment()
|
|
183
206
|
|
|
184
|
-
assert
|
|
207
|
+
assert outcome["cancelled"] is False
|
|
208
|
+
assert outcome["reason"] == CancelReason.REFUSED_IN_TRANSIT
|