python-sendparcel 0.3.0__tar.gz → 0.3.1__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.1}/PKG-INFO +1 -1
  2. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/pyproject.toml +1 -1
  3. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/__init__.py +10 -0
  4. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/flow.py +93 -8
  5. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/provider.py +69 -1
  6. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/types.py +77 -0
  7. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_batch.py +10 -2
  8. python_sendparcel-0.3.1/tests/test_cancel_outcome.py +320 -0
  9. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_flow.py +17 -4
  10. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_integration.py +16 -3
  11. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_public_api.py +5 -0
  12. python_sendparcel-0.3.1/tests/test_quote.py +87 -0
  13. python_sendparcel-0.3.1/tests/test_search_points.py +219 -0
  14. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/.github/workflows/ci.yml +0 -0
  15. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/.github/workflows/ecosystem-smoke.yml +0 -0
  16. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/.github/workflows/release.yml +0 -0
  17. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/.gitignore +0 -0
  18. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/.pre-commit-config.yaml +0 -0
  19. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/CHANGELOG.md +0 -0
  20. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/CONTRIBUTING.md +0 -0
  21. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/LICENSE +0 -0
  22. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/README.md +0 -0
  23. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/Makefile +0 -0
  24. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/compatibility-matrix.md +0 -0
  25. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/conf.py +0 -0
  26. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/getting-started.md +0 -0
  27. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/index.md +0 -0
  28. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/installation-matrix.md +0 -0
  29. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/plans/2026-02-17-order-optional.md +0 -0
  30. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/provider-authoring.md +0 -0
  31. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/release-policy.md +0 -0
  32. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/requirements.txt +0 -0
  33. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/batch.py +0 -0
  34. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/concurrent.py +0 -0
  35. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/enums.py +0 -0
  36. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/exceptions.py +0 -0
  37. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/factory.py +0 -0
  38. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/fsm.py +0 -0
  39. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/logging.py +0 -0
  40. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/protocols.py +0 -0
  41. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/providers/__init__.py +0 -0
  42. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/providers/dummy.py +0 -0
  43. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/py.typed +0 -0
  44. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/registry.py +0 -0
  45. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/conftest.py +0 -0
  46. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_builtin_dummy_provider.py +0 -0
  47. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_concurrent.py +0 -0
  48. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_enums.py +0 -0
  49. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_exceptions.py +0 -0
  50. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_fsm.py +0 -0
  51. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_protocols.py +0 -0
  52. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_provider.py +0 -0
  53. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_provider_enhancements.py +0 -0
  54. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_registry.py +0 -0
  55. {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-sendparcel
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Framework-agnostic parcel shipping core for Python.
5
5
  Project-URL: Homepage, https://github.com/python-sendparcel/python-sendparcel
6
6
  Project-URL: Repository, https://github.com/python-sendparcel/python-sendparcel
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-sendparcel"
3
- version = "0.3.0"
3
+ version = "0.3.1"
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,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from dataclasses import dataclass
5
6
  from typing import Any
6
7
 
7
8
  import httpx
@@ -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,87 @@ 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)
202
- )
203
- if cancelled:
230
+ outcome = await self._call_provider(provider.cancel_shipment(**kwargs))
231
+ if outcome.get("reason") == CancelReason.TRANSIENT_ERROR:
232
+ raise CommunicationError(
233
+ outcome.get("detail") or "Cancel failed with transient error",
234
+ context={
235
+ "provider_status_code": outcome.get("provider_status_code"),
236
+ "reason": CancelReason.TRANSIENT_ERROR,
237
+ },
238
+ )
239
+ if outcome.get("cancelled"):
204
240
  transition_shipment(shipment, ShipmentStatus.CANCELLED)
205
241
  await self.repository.update_fields(
206
242
  shipment_id=shipment.id, status=shipment.status
207
243
  )
208
- return bool(cancelled)
244
+ return outcome
245
+
246
+ async def search_points(
247
+ self,
248
+ provider_slug: str,
249
+ *,
250
+ query: str | None = None,
251
+ near: GeoPoint | None = None,
252
+ radius_m: int | None = None,
253
+ point_type: str | None = None,
254
+ limit: int = 20,
255
+ **kwargs: Any,
256
+ ) -> list[PickupPoint]:
257
+ """Search carrier pickup points via the registered provider.
258
+
259
+ Convenience method that delegates to the provider's
260
+ ``search_points`` capability. Raises
261
+ ``ProviderCapabilityError`` if the provider does not support
262
+ point search.
263
+
264
+ Args:
265
+ provider_slug: Provider identifier.
266
+ query: Free-text search (city, address, or point code).
267
+ near: :class:`GeoPoint` for proximity search.
268
+ radius_m: Search radius in metres when ``near`` is given.
269
+ point_type: Provider taxonomy filter.
270
+ limit: Maximum number of results.
271
+
272
+ Returns:
273
+ List of :class:`PickupPoint` results.
274
+ """
275
+ provider_class = self.registry.get_by_slug(provider_slug)
276
+ provider_config = self.config.get(provider_slug, {})
277
+ from sendparcel.factory import create_provider
278
+
279
+ provider = create_provider(
280
+ _PointSearchShipment(provider=provider_slug),
281
+ provider_class,
282
+ provider_config,
283
+ )
284
+ return await self._call_provider(
285
+ provider.search_points(
286
+ query=query,
287
+ near=near,
288
+ radius_m=radius_m,
289
+ point_type=point_type,
290
+ limit=limit,
291
+ **kwargs,
292
+ )
293
+ )
209
294
 
210
295
  def _get_provider(self, shipment: Shipment) -> BaseProvider:
211
296
  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
+ )
@@ -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
@@ -0,0 +1,320 @@
1
+ """Tests for CancelOutcome and cancel_shipment result semantics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from sendparcel.enums import ShipmentStatus
8
+ from sendparcel.exceptions import CommunicationError
9
+ from sendparcel.flow import ShipmentFlow
10
+ from sendparcel.provider import BaseProvider
11
+ from sendparcel.types import (
12
+ AddressInfo,
13
+ CancelOutcome,
14
+ CancelReason,
15
+ ParcelInfo,
16
+ ShipmentCreateResult,
17
+ )
18
+
19
+
20
+ class CancelOutcomeTestProvider(BaseProvider):
21
+ """Provider that returns configurable CancelOutcome."""
22
+
23
+ slug = "cancel_test"
24
+ display_name = "Cancel Test"
25
+ cancel_result: CancelOutcome | Exception | None = None
26
+
27
+ async def create_shipment(
28
+ self,
29
+ *,
30
+ sender_address: AddressInfo,
31
+ receiver_address: AddressInfo,
32
+ parcels: list[ParcelInfo],
33
+ **kwargs,
34
+ ) -> ShipmentCreateResult:
35
+ return {"external_id": "ext-1"}
36
+
37
+ async def cancel_shipment(self, **kwargs) -> CancelOutcome:
38
+ if isinstance(self.cancel_result, Exception):
39
+ raise self.cancel_result
40
+ if self.cancel_result is None:
41
+ raise NotImplementedError("cancel_result not configured")
42
+ return self.cancel_result
43
+
44
+
45
+ class DummyShipment:
46
+ def __init__(self, status=ShipmentStatus.CREATED) -> None:
47
+ self.id = "ship-1"
48
+ self.status = status
49
+ self.provider = "cancel_test"
50
+ self.external_id = "12345"
51
+ self.tracking_number = "TRK-123"
52
+
53
+
54
+ class DummyRepo:
55
+ def __init__(self) -> None:
56
+ self.shipment = DummyShipment()
57
+ self.updated_shipment: DummyShipment | None = None
58
+
59
+ async def create(self, **kwargs) -> DummyShipment:
60
+ return self.shipment
61
+
62
+ async def save(self, shipment: DummyShipment) -> DummyShipment:
63
+ return shipment
64
+
65
+ async def update_status(self, shipment_id: str, status: str, **fields):
66
+ return self.shipment
67
+
68
+ async def update_fields(self, shipment_id: str, **fields) -> DummyShipment:
69
+ for key, value in fields.items():
70
+ setattr(self.shipment, key, value)
71
+ self.updated_shipment = self.shipment
72
+ return self.shipment
73
+
74
+ async def delete(self, shipment_id: str) -> None:
75
+ pass
76
+
77
+ async def find_by_reference(self, provider: str, reference_id: str):
78
+ return self.shipment
79
+
80
+ async def create_with_idempotency_key(
81
+ self, provider: str, status: str, reference_id: str, **kwargs
82
+ ):
83
+ return (None, self.shipment)
84
+
85
+ def get_by_id_sync(self, shipment_id: str, *, for_update: bool = False):
86
+ return self.shipment
87
+
88
+ async def get_by_id(self, shipment_id: str, *, for_update: bool = False):
89
+ return self.shipment
90
+
91
+
92
+ def _make_flow(
93
+ provider: type[CancelOutcomeTestProvider],
94
+ ) -> tuple[ShipmentFlow, CancelOutcomeTestProvider, DummyRepo]:
95
+ from sendparcel.registry import PluginRegistry
96
+
97
+ registry = PluginRegistry()
98
+ registry.register(provider)
99
+ repo = DummyRepo()
100
+ flow = ShipmentFlow(repository=repo, registry=registry)
101
+ instance = provider(shipment=repo.shipment)
102
+ return flow, instance, repo
103
+
104
+
105
+ class TestCancelReason:
106
+ """CancelReason enum tests."""
107
+
108
+ def test_cancelled_value(self) -> None:
109
+ assert CancelReason.CANCELLED == "cancelled"
110
+
111
+ def test_already_cancelled_value(self) -> None:
112
+ assert CancelReason.ALREADY_CANCELLED == "already_cancelled"
113
+
114
+ def test_refused_in_transit_value(self) -> None:
115
+ assert CancelReason.REFUSED_IN_TRANSIT == "refused_in_transit"
116
+
117
+ def test_not_cancellable_value(self) -> None:
118
+ assert CancelReason.NOT_CANCELLABLE == "not_cancellable"
119
+
120
+ def test_transient_error_value(self) -> None:
121
+ assert CancelReason.TRANSIENT_ERROR == "transient_error"
122
+
123
+ def test_auth_error_value(self) -> None:
124
+ assert CancelReason.AUTH_ERROR == "auth_error"
125
+
126
+
127
+ class TestCancelOutcome:
128
+ """CancelOutcome TypedDict tests."""
129
+
130
+ def test_successful_cancel(self) -> None:
131
+ outcome: CancelOutcome = {
132
+ "cancelled": True,
133
+ "reason": CancelReason.CANCELLED,
134
+ "retryable": False,
135
+ "provider_status_code": 200,
136
+ "detail": None,
137
+ }
138
+ assert outcome["cancelled"] is True
139
+ assert outcome["retryable"] is False
140
+
141
+ def test_already_cancelled(self) -> None:
142
+ outcome: CancelOutcome = {
143
+ "cancelled": True,
144
+ "reason": CancelReason.ALREADY_CANCELLED,
145
+ "retryable": False,
146
+ "provider_status_code": 404,
147
+ "detail": None,
148
+ }
149
+ assert outcome["cancelled"] is True
150
+ assert outcome["reason"] == CancelReason.ALREADY_CANCELLED
151
+
152
+ def test_refused_in_transit(self) -> None:
153
+ outcome: CancelOutcome = {
154
+ "cancelled": False,
155
+ "reason": CancelReason.REFUSED_IN_TRANSIT,
156
+ "retryable": False,
157
+ "provider_status_code": 400,
158
+ "detail": "Shipment already in transit",
159
+ }
160
+ assert outcome["cancelled"] is False
161
+ assert outcome["retryable"] is False
162
+
163
+ def test_transient_error(self) -> None:
164
+ outcome: CancelOutcome = {
165
+ "cancelled": False,
166
+ "reason": CancelReason.TRANSIENT_ERROR,
167
+ "retryable": True,
168
+ "provider_status_code": 503,
169
+ "detail": "Service unavailable",
170
+ }
171
+ assert outcome["cancelled"] is False
172
+ assert outcome["retryable"] is True
173
+
174
+
175
+ class TestFlowCancelShipment:
176
+ """ShipmentFlow.cancel_shipment integration tests."""
177
+
178
+ async def test_successful_cancel_transitions_to_cancelled(self) -> None:
179
+ provider_class = CancelOutcomeTestProvider
180
+ provider_class.cancel_result = CancelOutcome(
181
+ cancelled=True,
182
+ reason=CancelReason.CANCELLED,
183
+ retryable=False,
184
+ provider_status_code=200,
185
+ detail=None,
186
+ )
187
+ flow, _, repo = _make_flow(provider_class)
188
+
189
+ result = await flow.cancel_shipment(repo.shipment)
190
+
191
+ assert result["cancelled"] is True
192
+ assert result["reason"] == CancelReason.CANCELLED
193
+ assert repo.shipment.status == ShipmentStatus.CANCELLED
194
+
195
+ async def test_already_cancelled_transitions_to_cancelled(self) -> None:
196
+ provider_class = CancelOutcomeTestProvider
197
+ provider_class.cancel_result = CancelOutcome(
198
+ cancelled=True,
199
+ reason=CancelReason.ALREADY_CANCELLED,
200
+ retryable=False,
201
+ provider_status_code=404,
202
+ detail=None,
203
+ )
204
+ flow, _, repo = _make_flow(provider_class)
205
+
206
+ result = await flow.cancel_shipment(repo.shipment)
207
+
208
+ assert result["cancelled"] is True
209
+ assert result["reason"] == CancelReason.ALREADY_CANCELLED
210
+ assert repo.shipment.status == ShipmentStatus.CANCELLED
211
+
212
+ async def test_refused_in_transit_leaves_state_unchanged(self) -> None:
213
+ provider_class = CancelOutcomeTestProvider
214
+ provider_class.cancel_result = CancelOutcome(
215
+ cancelled=False,
216
+ reason=CancelReason.REFUSED_IN_TRANSIT,
217
+ retryable=False,
218
+ provider_status_code=400,
219
+ detail="Shipment already dispatched",
220
+ )
221
+ flow, _, repo = _make_flow(provider_class)
222
+ repo.shipment.status = ShipmentStatus.CREATED
223
+
224
+ result = await flow.cancel_shipment(repo.shipment)
225
+
226
+ assert result["cancelled"] is False
227
+ assert result["retryable"] is False
228
+ assert repo.shipment.status == ShipmentStatus.CREATED
229
+
230
+ async def test_not_cancellable_leaves_state_unchanged(self) -> None:
231
+ provider_class = CancelOutcomeTestProvider
232
+ provider_class.cancel_result = CancelOutcome(
233
+ cancelled=False,
234
+ reason=CancelReason.NOT_CANCELLABLE,
235
+ retryable=False,
236
+ provider_status_code=422,
237
+ detail="Cannot cancel collected shipment",
238
+ )
239
+ flow, _, repo = _make_flow(provider_class)
240
+ original_status = ShipmentStatus.LABEL_READY
241
+ repo.shipment.status = original_status
242
+
243
+ result = await flow.cancel_shipment(repo.shipment)
244
+
245
+ assert result["cancelled"] is False
246
+ assert repo.shipment.status == original_status
247
+
248
+ async def test_transient_error_raises_communication_error(self) -> None:
249
+ provider_class = CancelOutcomeTestProvider
250
+ provider_class.cancel_result = CommunicationError(
251
+ "API timeout",
252
+ context={"retryable": True},
253
+ )
254
+ flow, _, repo = _make_flow(provider_class)
255
+ original_status = ShipmentStatus.CREATED
256
+ repo.shipment.status = original_status
257
+
258
+ with pytest.raises(CommunicationError):
259
+ await flow.cancel_shipment(repo.shipment)
260
+
261
+ # State should not change on transient failure
262
+ assert repo.shipment.status == original_status
263
+
264
+ async def test_transient_error_outcome_raises_communication_error(
265
+ self,
266
+ ) -> None:
267
+ """A provider returning a TRANSIENT_ERROR outcome (instead of
268
+ raising) must still surface as CommunicationError from the flow,
269
+ so callers get one retryable signal regardless of provider style."""
270
+ provider_class = CancelOutcomeTestProvider
271
+ provider_class.cancel_result = CancelOutcome(
272
+ cancelled=False,
273
+ reason=CancelReason.TRANSIENT_ERROR,
274
+ retryable=True,
275
+ provider_status_code=503,
276
+ detail="Service unavailable",
277
+ )
278
+ flow, _, repo = _make_flow(provider_class)
279
+ original_status = ShipmentStatus.CREATED
280
+ repo.shipment.status = original_status
281
+
282
+ with pytest.raises(CommunicationError) as exc_info:
283
+ await flow.cancel_shipment(repo.shipment)
284
+
285
+ assert exc_info.value.context["provider_status_code"] == 503
286
+ assert repo.shipment.status == original_status
287
+
288
+ async def test_cancel_from_new_status(self) -> None:
289
+ provider_class = CancelOutcomeTestProvider
290
+ provider_class.cancel_result = CancelOutcome(
291
+ cancelled=True,
292
+ reason=CancelReason.CANCELLED,
293
+ retryable=False,
294
+ provider_status_code=200,
295
+ detail=None,
296
+ )
297
+ flow, _, repo = _make_flow(provider_class)
298
+ repo.shipment.status = ShipmentStatus.NEW
299
+
300
+ result = await flow.cancel_shipment(repo.shipment)
301
+
302
+ assert result["cancelled"] is True
303
+ assert repo.shipment.status == ShipmentStatus.CANCELLED
304
+
305
+ async def test_cancel_from_label_ready_status(self) -> None:
306
+ provider_class = CancelOutcomeTestProvider
307
+ provider_class.cancel_result = CancelOutcome(
308
+ cancelled=True,
309
+ reason=CancelReason.CANCELLED,
310
+ retryable=False,
311
+ provider_status_code=200,
312
+ detail=None,
313
+ )
314
+ flow, _, repo = _make_flow(provider_class)
315
+ repo.shipment.status = ShipmentStatus.LABEL_READY
316
+
317
+ result = await flow.cancel_shipment(repo.shipment)
318
+
319
+ assert result["cancelled"] is True
320
+ assert repo.shipment.status == ShipmentStatus.CANCELLED
@@ -20,6 +20,8 @@ from sendparcel.registry import registry
20
20
  from sendparcel.types import (
21
21
  AddressInfo,
22
22
  CallbackContext,
23
+ CancelOutcome,
24
+ CancelReason,
23
25
  LabelInfo,
24
26
  ParcelInfo,
25
27
  ShipmentCreateResult,
@@ -84,8 +86,19 @@ class FlowProvider(BaseProvider):
84
86
  tracking_events=[{"code": "polled"}],
85
87
  )
86
88
 
87
- async def cancel_shipment(self, **kwargs: Any) -> bool:
88
- return bool(self.get_setting("cancel_success", True))
89
+ async def cancel_shipment(self, **kwargs: Any) -> CancelOutcome:
90
+ success = bool(self.get_setting("cancel_success", True))
91
+ return CancelOutcome(
92
+ cancelled=success,
93
+ reason=(
94
+ CancelReason.CANCELLED
95
+ if success
96
+ else CancelReason.REFUSED_IN_TRANSIT
97
+ ),
98
+ retryable=False,
99
+ provider_status_code=200 if success else 400,
100
+ detail=None,
101
+ )
89
102
 
90
103
 
91
104
  class LabelIncludedProvider(BaseProvider):
@@ -377,7 +390,7 @@ class TestCancelShipment:
377
390
 
378
391
  cancelled = await flow.cancel_shipment(shipment)
379
392
 
380
- assert cancelled is True
393
+ assert cancelled["cancelled"] is True
381
394
  assert shipment.status == "cancelled"
382
395
 
383
396
  @pytest.mark.asyncio
@@ -392,7 +405,7 @@ class TestCancelShipment:
392
405
 
393
406
  cancelled = await flow.cancel_shipment(shipment)
394
407
 
395
- assert cancelled is False
408
+ assert cancelled["cancelled"] is False
396
409
  assert shipment.status == "created"
397
410
 
398
411
  @pytest.mark.asyncio
@@ -13,6 +13,8 @@ from sendparcel.registry import registry
13
13
  from sendparcel.types import (
14
14
  AddressInfo,
15
15
  CallbackContext,
16
+ CancelOutcome,
17
+ CancelReason,
16
18
  LabelInfo,
17
19
  ParcelInfo,
18
20
  ShipmentCreateResult,
@@ -75,8 +77,19 @@ class IntegrationProvider(BaseProvider):
75
77
  tracking_events=[{"code": "poll"}],
76
78
  )
77
79
 
78
- async def cancel_shipment(self, **kwargs: Any) -> bool:
79
- return bool(self.get_setting("cancel_success", True))
80
+ async def cancel_shipment(self, **kwargs: Any) -> CancelOutcome:
81
+ success = bool(self.get_setting("cancel_success", True))
82
+ return CancelOutcome(
83
+ cancelled=success,
84
+ reason=(
85
+ CancelReason.CANCELLED
86
+ if success
87
+ else CancelReason.REFUSED_IN_TRANSIT
88
+ ),
89
+ retryable=False,
90
+ provider_status_code=200 if success else 400,
91
+ detail=None,
92
+ )
80
93
 
81
94
 
82
95
  class InlineLabelProvider(IntegrationProvider):
@@ -203,5 +216,5 @@ async def test_cancel_rejection_keeps_current_status() -> None:
203
216
 
204
217
  cancelled = await flow.cancel_shipment(shipment)
205
218
 
206
- assert cancelled is False
219
+ assert cancelled["cancelled"] is False
207
220
  assert shipment.status == "created"
@@ -9,11 +9,16 @@ def test_public_api_exports_exact_set() -> None:
9
9
  "BaseProvider",
10
10
  "BatchCreateResult",
11
11
  "BatchResult",
12
+ "CancelOutcome",
13
+ "CancelReason",
12
14
  "CallbackContext",
13
15
  "CommunicationError",
14
16
  "ConfirmationMethod",
15
17
  "CreateLabelOutcome",
16
18
  "CreateShipmentOutcome",
19
+ "GeoPoint",
20
+ "PickupPoint",
21
+ "Quote",
17
22
  "DummyProvider",
18
23
  "configure_logging",
19
24
  "create_provider",
@@ -0,0 +1,87 @@
1
+ """Tests for get_quote capability."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal
6
+
7
+ import pytest
8
+
9
+ from sendparcel.exceptions import ProviderCapabilityError
10
+ from sendparcel.provider import BaseProvider
11
+ from sendparcel.types import (
12
+ AddressInfo,
13
+ ParcelInfo,
14
+ Quote,
15
+ ShipmentCreateResult,
16
+ )
17
+
18
+
19
+ class DummyShipment:
20
+ id = "ship-1"
21
+ status = "new"
22
+ provider = "no-quote"
23
+ external_id = ""
24
+ tracking_number = ""
25
+
26
+
27
+ class NoQuoteProvider(BaseProvider):
28
+ """Provider that does not support get_quote."""
29
+
30
+ slug = "no-quote"
31
+ display_name = "No Quote"
32
+
33
+ async def create_shipment(
34
+ self,
35
+ *,
36
+ sender_address: AddressInfo,
37
+ receiver_address: AddressInfo,
38
+ parcels: list[ParcelInfo],
39
+ **kwargs,
40
+ ) -> ShipmentCreateResult:
41
+ return {"external_id": "ext-1"}
42
+
43
+
44
+ class TestGetQuote:
45
+ """BaseProvider.get_quote raises ProviderCapabilityError by default."""
46
+
47
+ async def test_default_raises_capability_error(self) -> None:
48
+ provider = NoQuoteProvider(shipment=DummyShipment())
49
+ with pytest.raises(ProviderCapabilityError):
50
+ await provider.get_quote(
51
+ service="standard",
52
+ parcels=[{"weight_kg": Decimal("1.0")}],
53
+ )
54
+
55
+ async def test_error_message_names_provider(self) -> None:
56
+ provider = NoQuoteProvider(shipment=DummyShipment())
57
+ with pytest.raises(ProviderCapabilityError) as exc_info:
58
+ await provider.get_quote(
59
+ service="standard",
60
+ parcels=[{"weight_kg": Decimal("1.0")}],
61
+ )
62
+ assert "NoQuoteProvider" in str(exc_info.value)
63
+
64
+
65
+ class TestQuoteType:
66
+ """Quote TypedDict shape tests."""
67
+
68
+ def test_quote_shape(self) -> None:
69
+ quote: Quote = {
70
+ "provider_slug": "test",
71
+ "service": "standard",
72
+ "amount": Decimal("12.50"),
73
+ "currency": "PLN",
74
+ "valid_until": None,
75
+ "raw": None,
76
+ }
77
+ assert quote["amount"] == Decimal("12.50")
78
+ assert quote["currency"] == "PLN"
79
+
80
+ def test_quote_amount_is_decimal(self) -> None:
81
+ quote: Quote = {
82
+ "provider_slug": "test",
83
+ "service": "express",
84
+ "amount": Decimal("25.00"),
85
+ "currency": "EUR",
86
+ }
87
+ assert isinstance(quote["amount"], Decimal)
@@ -0,0 +1,219 @@
1
+ """Tests for search_points capability."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import pytest
8
+
9
+ from sendparcel.exceptions import ProviderCapabilityError
10
+ from sendparcel.flow import ShipmentFlow
11
+ from sendparcel.provider import BaseProvider
12
+ from sendparcel.types import (
13
+ AddressInfo,
14
+ GeoPoint,
15
+ ParcelInfo,
16
+ PickupPoint,
17
+ ShipmentCreateResult,
18
+ )
19
+
20
+
21
+ class SearchPointsProvider(BaseProvider):
22
+ """Provider that supports search_points with configurable results."""
23
+
24
+ slug = "search_test"
25
+ display_name = "Search Test"
26
+ points_result: list[PickupPoint] | Exception | None = None
27
+
28
+ async def create_shipment(
29
+ self,
30
+ *,
31
+ sender_address: AddressInfo,
32
+ receiver_address: AddressInfo,
33
+ parcels: list[ParcelInfo],
34
+ **kwargs: Any,
35
+ ) -> ShipmentCreateResult:
36
+ return {"external_id": "ext-1"}
37
+
38
+ async def search_points(
39
+ self,
40
+ *,
41
+ query: str | None = None,
42
+ near: GeoPoint | None = None,
43
+ radius_m: int | None = None,
44
+ point_type: str | None = None,
45
+ limit: int = 20,
46
+ **kwargs: Any,
47
+ ) -> list[PickupPoint]:
48
+ if isinstance(self.points_result, Exception):
49
+ raise self.points_result
50
+ if self.points_result is None:
51
+ raise NotImplementedError("points_result not configured")
52
+ return self.points_result
53
+
54
+
55
+ class NoSearchProvider(BaseProvider):
56
+ """Provider that does not support search_points."""
57
+
58
+ slug = "no-search"
59
+ display_name = "No Search"
60
+
61
+ async def create_shipment(
62
+ self,
63
+ *,
64
+ sender_address: AddressInfo,
65
+ receiver_address: AddressInfo,
66
+ parcels: list[ParcelInfo],
67
+ **kwargs: Any,
68
+ ) -> ShipmentCreateResult:
69
+ return {"external_id": "ext-1"}
70
+
71
+
72
+ class DummyShipment:
73
+ id = "ship-1"
74
+ status = "new"
75
+ provider = "search_test"
76
+ external_id = ""
77
+ tracking_number = ""
78
+
79
+
80
+ class DummyRepo:
81
+ async def create(self, **kwargs):
82
+ return DummyShipment()
83
+
84
+ async def save(self, shipment):
85
+ return shipment
86
+
87
+ async def update_status(self, shipment_id, status, **fields):
88
+ return DummyShipment()
89
+
90
+ async def update_fields(self, shipment_id, **fields):
91
+ return DummyShipment()
92
+
93
+ async def delete(self, shipment_id):
94
+ pass
95
+
96
+ async def find_by_reference(self, provider, reference_id):
97
+ return DummyShipment()
98
+
99
+ async def create_with_idempotency_key(
100
+ self, provider, status, reference_id, **kwargs
101
+ ):
102
+ return (None, DummyShipment())
103
+
104
+ def get_by_id_sync(self, shipment_id, *, for_update: bool = False):
105
+ return DummyShipment()
106
+
107
+ async def get_by_id(self, shipment_id, *, for_update: bool = False):
108
+ return DummyShipment()
109
+
110
+
111
+ def _make_flow(
112
+ provider: type[BaseProvider],
113
+ ) -> tuple[ShipmentFlow, BaseProvider, DummyRepo]:
114
+ from sendparcel.registry import PluginRegistry
115
+
116
+ registry = PluginRegistry()
117
+ registry.register(provider)
118
+ repo = DummyRepo()
119
+ flow = ShipmentFlow(repository=repo, registry=registry)
120
+ instance = provider(shipment=repo)
121
+ return flow, instance, repo
122
+
123
+
124
+ class TestSearchPointsProvider:
125
+ """BaseProvider.search_points raises ProviderCapabilityError by default."""
126
+
127
+ async def test_default_raises_capability_error(self) -> None:
128
+ provider = NoSearchProvider(shipment=DummyShipment())
129
+ with pytest.raises(ProviderCapabilityError):
130
+ await provider.search_points(query="Krakow")
131
+
132
+
133
+ class TestSearchPointsFlow:
134
+ """ShipmentFlow.search_points delegates to provider."""
135
+
136
+ async def test_flow_search_points_returns_provider_results(self) -> None:
137
+ provider_class = SearchPointsProvider
138
+ provider_class.points_result = [
139
+ PickupPoint(
140
+ code="KRA010",
141
+ name="Krakow Main",
142
+ provider_slug="search_test",
143
+ address="ul. Glowna 1, Krakow",
144
+ location={"lat": 50.0647, "lng": 19.9450},
145
+ opening_hours=None,
146
+ point_type="parcel_locker",
147
+ raw=None,
148
+ )
149
+ ]
150
+ flow, _, _ = _make_flow(provider_class)
151
+
152
+ points = await flow.search_points(
153
+ "search_test",
154
+ query="Krakow",
155
+ )
156
+
157
+ assert len(points) == 1
158
+ assert points[0]["code"] == "KRA010"
159
+ assert points[0]["name"] == "Krakow Main"
160
+
161
+ async def test_flow_search_points_passes_params_to_provider(self) -> None:
162
+ provider_class = SearchPointsProvider
163
+ provider_class.points_result = []
164
+ flow, _, __ = _make_flow(provider_class)
165
+
166
+ await flow.search_points(
167
+ "search_test",
168
+ query="Warsaw",
169
+ near={"lat": 52.23, "lng": 21.01},
170
+ radius_m=5000,
171
+ point_type="parcel_locker",
172
+ limit=10,
173
+ )
174
+
175
+ # Provider received the call (points_result was accessed)
176
+ assert provider_class.points_result == []
177
+
178
+ async def test_flow_search_points_unsupported_provider_raises(self) -> None:
179
+ flow, _, _ = _make_flow(NoSearchProvider)
180
+
181
+ with pytest.raises(ProviderCapabilityError):
182
+ await flow.search_points("no-search", query="Krakow")
183
+
184
+ async def test_flow_search_points_with_proximity(self) -> None:
185
+ provider_class = SearchPointsProvider
186
+ provider_class.points_result = [
187
+ PickupPoint(
188
+ code="WAW001",
189
+ name="Warsaw Center",
190
+ provider_slug="search_test",
191
+ address="ul. Marszalkowska 1, Warsaw",
192
+ location={"lat": 52.2300, "lng": 21.0100},
193
+ opening_hours=None,
194
+ point_type="parcel_locker",
195
+ raw=None,
196
+ ),
197
+ PickupPoint(
198
+ code="WAW002",
199
+ name="Warsaw North",
200
+ provider_slug="search_test",
201
+ address="ul. Wawelska 2, Warsaw",
202
+ location={"lat": 52.2400, "lng": 21.0200},
203
+ opening_hours=None,
204
+ point_type="parcel_locker",
205
+ raw=None,
206
+ ),
207
+ ]
208
+ flow, _, _ = _make_flow(provider_class)
209
+
210
+ points = await flow.search_points(
211
+ "search_test",
212
+ near={"lat": 52.23, "lng": 21.01},
213
+ radius_m=5000,
214
+ )
215
+
216
+ assert len(points) == 2
217
+ # Results ordered by distance (nearest first)
218
+ assert points[0]["code"] == "WAW001"
219
+ assert points[1]["code"] == "WAW002"