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.
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/PKG-INFO +1 -1
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/pyproject.toml +1 -1
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/__init__.py +10 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/flow.py +93 -8
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/provider.py +69 -1
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/types.py +77 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_batch.py +10 -2
- python_sendparcel-0.3.1/tests/test_cancel_outcome.py +320 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_flow.py +17 -4
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_integration.py +16 -3
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_public_api.py +5 -0
- python_sendparcel-0.3.1/tests/test_quote.py +87 -0
- python_sendparcel-0.3.1/tests/test_search_points.py +219 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/.github/workflows/ci.yml +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/.github/workflows/ecosystem-smoke.yml +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/.github/workflows/release.yml +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/.gitignore +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/.pre-commit-config.yaml +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/CHANGELOG.md +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/CONTRIBUTING.md +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/LICENSE +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/README.md +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/Makefile +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/compatibility-matrix.md +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/conf.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/getting-started.md +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/index.md +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/installation-matrix.md +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/plans/2026-02-17-order-optional.md +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/provider-authoring.md +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/release-policy.md +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/docs/requirements.txt +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/batch.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/concurrent.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/enums.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/exceptions.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/factory.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/fsm.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/logging.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/protocols.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/providers/__init__.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/providers/dummy.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/py.typed +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/src/sendparcel/registry.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/conftest.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_builtin_dummy_provider.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_concurrent.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_enums.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_exceptions.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_fsm.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_protocols.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_provider.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_provider_enhancements.py +0 -0
- {python_sendparcel-0.3.0 → python_sendparcel-0.3.1}/tests/test_registry.py +0 -0
- {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.
|
|
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
|
|
@@ -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(
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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) ->
|
|
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) ->
|
|
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
|
|
@@ -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) ->
|
|
88
|
-
|
|
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) ->
|
|
79
|
-
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|