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.
Files changed (55) hide show
  1. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/CHANGELOG.md +9 -0
  2. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/PKG-INFO +1 -1
  3. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/pyproject.toml +1 -1
  4. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/__init__.py +10 -0
  5. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/flow.py +99 -8
  6. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/provider.py +69 -1
  7. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/providers/dummy.py +21 -2
  8. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/types.py +77 -0
  9. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_batch.py +10 -2
  10. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_builtin_dummy_provider.py +31 -7
  11. python_sendparcel-0.3.2/tests/test_cancel_outcome.py +320 -0
  12. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_flow.py +17 -4
  13. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_integration.py +16 -3
  14. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_public_api.py +5 -0
  15. python_sendparcel-0.3.2/tests/test_quote.py +87 -0
  16. python_sendparcel-0.3.2/tests/test_search_points.py +219 -0
  17. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/.github/workflows/ci.yml +0 -0
  18. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/.github/workflows/ecosystem-smoke.yml +0 -0
  19. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/.github/workflows/release.yml +0 -0
  20. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/.gitignore +0 -0
  21. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/.pre-commit-config.yaml +0 -0
  22. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/CONTRIBUTING.md +0 -0
  23. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/LICENSE +0 -0
  24. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/README.md +0 -0
  25. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/Makefile +0 -0
  26. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/compatibility-matrix.md +0 -0
  27. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/conf.py +0 -0
  28. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/getting-started.md +0 -0
  29. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/index.md +0 -0
  30. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/installation-matrix.md +0 -0
  31. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/plans/2026-02-17-order-optional.md +0 -0
  32. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/provider-authoring.md +0 -0
  33. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/release-policy.md +0 -0
  34. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/docs/requirements.txt +0 -0
  35. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/batch.py +0 -0
  36. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/concurrent.py +0 -0
  37. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/enums.py +0 -0
  38. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/exceptions.py +0 -0
  39. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/factory.py +0 -0
  40. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/fsm.py +0 -0
  41. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/logging.py +0 -0
  42. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/protocols.py +0 -0
  43. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/providers/__init__.py +0 -0
  44. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/py.typed +0 -0
  45. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/src/sendparcel/registry.py +0 -0
  46. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/conftest.py +0 -0
  47. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_concurrent.py +0 -0
  48. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_enums.py +0 -0
  49. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_exceptions.py +0 -0
  50. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_fsm.py +0 -0
  51. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_protocols.py +0 -0
  52. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_provider.py +0 -0
  53. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_provider_enhancements.py +0 -0
  54. {python_sendparcel-0.3.0 → python_sendparcel-0.3.2}/tests/test_registry.py +0 -0
  55. {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.0
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-sendparcel"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  description = "Framework-agnostic parcel shipping core for Python."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -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 typing import Any
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(self, shipment: Shipment, **kwargs: Any) -> bool:
197
- """Cancel shipment via provider and persist state."""
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
- cancelled = await self._call_provider(
201
- provider.cancel_shipment(**kwargs)
230
+ outcome = cast(
231
+ "CancelOutcome",
232
+ await self._call_provider(provider.cancel_shipment(**kwargs)),
202
233
  )
203
- if cancelled:
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 bool(cancelled)
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) -> bool:
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) -> bool:
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
- return bool(self.get_setting("cancel_success", True))
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) -> bool:
172
- return True
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 AddressInfo, CallbackContext, ParcelInfo
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 test_cancel_returns_true_by_default(self) -> None:
174
+ async def test_cancel_returns_outcome_by_default(self) -> None:
170
175
  provider = DummyProvider(DummyShipment(), config={})
171
176
 
172
- result = await provider.cancel_shipment()
177
+ outcome = await provider.cancel_shipment()
173
178
 
174
- assert result is True
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 test_cancel_can_be_configured_to_fail(self) -> None:
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
- result = await provider.cancel_shipment()
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 result is False
207
+ assert outcome["cancelled"] is False
208
+ assert outcome["reason"] == CancelReason.REFUSED_IN_TRANSIT