python-sendparcel 0.2.0__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_sendparcel-0.2.0.dist-info → python_sendparcel-0.3.1.dist-info}/METADATA +41 -24
- python_sendparcel-0.3.1.dist-info/RECORD +20 -0
- sendparcel/__init__.py +16 -12
- sendparcel/batch.py +77 -75
- sendparcel/concurrent.py +71 -0
- sendparcel/factory.py +33 -0
- sendparcel/flow.py +110 -51
- sendparcel/fsm.py +8 -8
- sendparcel/logging.py +5 -23
- sendparcel/provider.py +248 -30
- sendparcel/providers/dummy.py +7 -20
- sendparcel/registry.py +9 -0
- sendparcel/types.py +108 -4
- python_sendparcel-0.2.0.dist-info/RECORD +0 -19
- sendparcel/validators.py +0 -21
- {python_sendparcel-0.2.0.dist-info → python_sendparcel-0.3.1.dist-info}/WHEEL +0 -0
- {python_sendparcel-0.2.0.dist-info → python_sendparcel-0.3.1.dist-info}/licenses/LICENSE +0 -0
sendparcel/flow.py
CHANGED
|
@@ -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
|
|
@@ -9,29 +10,38 @@ import httpx
|
|
|
9
10
|
from sendparcel.enums import ShipmentStatus
|
|
10
11
|
from sendparcel.exceptions import (
|
|
11
12
|
CommunicationError,
|
|
12
|
-
ProviderCapabilityError,
|
|
13
13
|
SendParcelException,
|
|
14
14
|
)
|
|
15
15
|
from sendparcel.fsm import transition_shipment
|
|
16
16
|
from sendparcel.protocols import Shipment, ShipmentRepository
|
|
17
|
-
from sendparcel.provider import
|
|
18
|
-
BaseProvider,
|
|
19
|
-
CancellableProvider,
|
|
20
|
-
LabelProvider,
|
|
21
|
-
PullStatusProvider,
|
|
22
|
-
PushCallbackProvider,
|
|
23
|
-
)
|
|
17
|
+
from sendparcel.provider import BaseProvider
|
|
24
18
|
from sendparcel.registry import PluginRegistry
|
|
25
19
|
from sendparcel.registry import registry as default_registry
|
|
26
20
|
from sendparcel.types import (
|
|
27
21
|
AddressInfo,
|
|
22
|
+
CallbackContext,
|
|
23
|
+
CancelOutcome,
|
|
24
|
+
CancelReason,
|
|
28
25
|
CreateLabelOutcome,
|
|
29
26
|
CreateShipmentOutcome,
|
|
27
|
+
GeoPoint,
|
|
30
28
|
ParcelInfo,
|
|
29
|
+
PickupPoint,
|
|
31
30
|
ShipmentUpdateOutcome,
|
|
32
31
|
ShipmentUpdateResult,
|
|
33
32
|
)
|
|
34
|
-
|
|
33
|
+
|
|
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 = ""
|
|
35
45
|
|
|
36
46
|
|
|
37
47
|
class ShipmentFlow:
|
|
@@ -41,12 +51,10 @@ class ShipmentFlow:
|
|
|
41
51
|
self,
|
|
42
52
|
repository: ShipmentRepository,
|
|
43
53
|
config: dict[str, Any] | None = None,
|
|
44
|
-
validators: list[Any] | None = None,
|
|
45
54
|
registry: PluginRegistry | None = None,
|
|
46
55
|
) -> None:
|
|
47
56
|
self.repository = repository
|
|
48
57
|
self.config = config or {}
|
|
49
|
-
self.validators = validators or []
|
|
50
58
|
self.registry = registry or default_registry
|
|
51
59
|
|
|
52
60
|
async def create_shipment(
|
|
@@ -92,7 +100,7 @@ class ShipmentFlow:
|
|
|
92
100
|
|
|
93
101
|
# Separate repository kwargs from provider kwargs.
|
|
94
102
|
repo_kwargs: dict[str, Any] = {}
|
|
95
|
-
for key in ("reference_id",):
|
|
103
|
+
for key in ("reference_id", "order"):
|
|
96
104
|
if key in kwargs:
|
|
97
105
|
repo_kwargs[key] = kwargs.pop(key)
|
|
98
106
|
|
|
@@ -162,13 +170,7 @@ class ShipmentFlow:
|
|
|
162
170
|
) -> CreateLabelOutcome:
|
|
163
171
|
"""Create provider label and persist shipment metadata."""
|
|
164
172
|
|
|
165
|
-
run_validators({"shipment": shipment}, validators=self.validators)
|
|
166
173
|
provider = self._get_provider(shipment)
|
|
167
|
-
if not isinstance(provider, LabelProvider):
|
|
168
|
-
raise ProviderCapabilityError(
|
|
169
|
-
f"Provider {shipment.provider!r} does not support "
|
|
170
|
-
"label creation"
|
|
171
|
-
)
|
|
172
174
|
label = await self._call_provider(provider.create_label(**kwargs))
|
|
173
175
|
transition_shipment(shipment, ShipmentStatus.LABEL_READY)
|
|
174
176
|
saved = await self.repository.update_fields(
|
|
@@ -178,25 +180,21 @@ class ShipmentFlow:
|
|
|
178
180
|
|
|
179
181
|
async def handle_callback(
|
|
180
182
|
self,
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
**kwargs: Any,
|
|
183
|
+
ctx: CallbackContext,
|
|
184
|
+
*,
|
|
185
|
+
shipment: Shipment | None = None,
|
|
185
186
|
) -> ShipmentUpdateOutcome:
|
|
186
|
-
"""Verify and apply provider callback.
|
|
187
|
+
"""Verify and apply provider callback.
|
|
187
188
|
|
|
189
|
+
The caller is responsible for loading the shipment (from
|
|
190
|
+
``ctx.shipment_id``) and passing it in. If ``shipment`` is not
|
|
191
|
+
provided, it will be loaded from the repository.
|
|
192
|
+
"""
|
|
193
|
+
if shipment is None:
|
|
194
|
+
shipment = await self.repository.get_by_id(ctx.shipment_id)
|
|
188
195
|
provider = self._get_provider(shipment)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
f"Provider {shipment.provider!r} does not support "
|
|
192
|
-
"push callbacks"
|
|
193
|
-
)
|
|
194
|
-
await self._call_provider(
|
|
195
|
-
provider.verify_callback(data, headers, **kwargs)
|
|
196
|
-
)
|
|
197
|
-
update = await self._call_provider(
|
|
198
|
-
provider.handle_callback(data, headers, **kwargs)
|
|
199
|
-
)
|
|
196
|
+
await self._call_provider(provider.verify_callback(ctx))
|
|
197
|
+
update = await self._call_provider(provider.handle_callback(ctx))
|
|
200
198
|
normalized_update = update or ShipmentUpdateResult()
|
|
201
199
|
saved = await self._apply_update(shipment, normalized_update)
|
|
202
200
|
return ShipmentUpdateOutcome(shipment=saved, update=normalized_update)
|
|
@@ -207,38 +205,99 @@ class ShipmentFlow:
|
|
|
207
205
|
"""Fetch status from provider and persist."""
|
|
208
206
|
|
|
209
207
|
provider = self._get_provider(shipment)
|
|
210
|
-
if not isinstance(provider, PullStatusProvider):
|
|
211
|
-
raise ProviderCapabilityError(
|
|
212
|
-
f"Provider {shipment.provider!r} does not support "
|
|
213
|
-
"status polling"
|
|
214
|
-
)
|
|
215
208
|
update = await self._call_provider(provider.fetch_shipment_status())
|
|
216
209
|
normalized_update = update or ShipmentUpdateResult()
|
|
217
210
|
saved = await self._apply_update(shipment, normalized_update)
|
|
218
211
|
return ShipmentUpdateOutcome(shipment=saved, update=normalized_update)
|
|
219
212
|
|
|
220
|
-
async def cancel_shipment(
|
|
221
|
-
|
|
222
|
-
|
|
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
|
+
"""
|
|
223
229
|
provider = self._get_provider(shipment)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
+
},
|
|
227
238
|
)
|
|
228
|
-
|
|
229
|
-
provider.cancel_shipment(**kwargs)
|
|
230
|
-
)
|
|
231
|
-
if cancelled:
|
|
239
|
+
if outcome.get("cancelled"):
|
|
232
240
|
transition_shipment(shipment, ShipmentStatus.CANCELLED)
|
|
233
241
|
await self.repository.update_fields(
|
|
234
242
|
shipment_id=shipment.id, status=shipment.status
|
|
235
243
|
)
|
|
236
|
-
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
|
+
)
|
|
237
294
|
|
|
238
295
|
def _get_provider(self, shipment: Shipment) -> BaseProvider:
|
|
296
|
+
from sendparcel.factory import create_provider
|
|
297
|
+
|
|
239
298
|
provider_class = self.registry.get_by_slug(shipment.provider)
|
|
240
299
|
provider_config = self.config.get(shipment.provider, {})
|
|
241
|
-
return
|
|
300
|
+
return create_provider(shipment, provider_class, provider_config)
|
|
242
301
|
|
|
243
302
|
async def _apply_update(
|
|
244
303
|
self, shipment: Shipment, update: ShipmentUpdateResult
|
sendparcel/fsm.py
CHANGED
|
@@ -6,7 +6,7 @@ from sendparcel.enums import ShipmentStatus
|
|
|
6
6
|
from sendparcel.exceptions import InvalidTransitionError
|
|
7
7
|
from sendparcel.protocols import Shipment
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
_ALLOWED_TRANSITIONS: dict[ShipmentStatus, frozenset[ShipmentStatus]] = {
|
|
10
10
|
ShipmentStatus.NEW: frozenset(
|
|
11
11
|
{
|
|
12
12
|
ShipmentStatus.CREATED,
|
|
@@ -64,7 +64,7 @@ ALLOWED_STATUS_TRANSITIONS: dict[ShipmentStatus, frozenset[ShipmentStatus]] = {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
|
|
67
|
-
def
|
|
67
|
+
def _normalize(status: str | ShipmentStatus) -> ShipmentStatus:
|
|
68
68
|
"""Normalise a status to a :class:`ShipmentStatus` enum member."""
|
|
69
69
|
|
|
70
70
|
try:
|
|
@@ -81,11 +81,11 @@ def can_transition(
|
|
|
81
81
|
) -> bool:
|
|
82
82
|
"""Check whether a shipment status transition is allowed."""
|
|
83
83
|
|
|
84
|
-
current =
|
|
85
|
-
target =
|
|
84
|
+
current = _normalize(current_status)
|
|
85
|
+
target = _normalize(target_status)
|
|
86
86
|
if current == target:
|
|
87
87
|
return True
|
|
88
|
-
return target in
|
|
88
|
+
return target in _ALLOWED_TRANSITIONS[current]
|
|
89
89
|
|
|
90
90
|
|
|
91
91
|
def transition_shipment(
|
|
@@ -93,12 +93,12 @@ def transition_shipment(
|
|
|
93
93
|
) -> Shipment:
|
|
94
94
|
"""Apply a validated status transition to a shipment."""
|
|
95
95
|
|
|
96
|
-
current =
|
|
97
|
-
target =
|
|
96
|
+
current = _normalize(shipment.status)
|
|
97
|
+
target = _normalize(target_status)
|
|
98
98
|
if current == target:
|
|
99
99
|
shipment.status = target.value
|
|
100
100
|
return shipment
|
|
101
|
-
if target not in
|
|
101
|
+
if target not in _ALLOWED_TRANSITIONS[current]:
|
|
102
102
|
raise InvalidTransitionError(
|
|
103
103
|
f"Shipment cannot transition from {current!r} to {target!r}"
|
|
104
104
|
)
|
sendparcel/logging.py
CHANGED
|
@@ -29,19 +29,6 @@ from datetime import UTC, datetime
|
|
|
29
29
|
from typing import Any
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
def _format_extra(extra: dict[str, Any] | None) -> dict[str, Any]:
|
|
33
|
-
"""Convert extra fields to a serializable dict."""
|
|
34
|
-
if not extra:
|
|
35
|
-
return {}
|
|
36
|
-
result: dict[str, Any] = {}
|
|
37
|
-
for k, v in extra.items():
|
|
38
|
-
if isinstance(v, (str, int, float, bool, type(None))):
|
|
39
|
-
result[k] = v
|
|
40
|
-
else:
|
|
41
|
-
result[k] = str(v)
|
|
42
|
-
return result
|
|
43
|
-
|
|
44
|
-
|
|
45
32
|
class _JsonFormatter(logging.Formatter):
|
|
46
33
|
"""JSON formatter for structured logging.
|
|
47
34
|
|
|
@@ -49,7 +36,7 @@ class _JsonFormatter(logging.Formatter):
|
|
|
49
36
|
"""
|
|
50
37
|
|
|
51
38
|
def format(self, record: logging.LogRecord) -> str:
|
|
52
|
-
extra =
|
|
39
|
+
extra = getattr(record, "extra", None) or {}
|
|
53
40
|
log_entry: dict[str, Any] = {
|
|
54
41
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
55
42
|
"level": record.levelname,
|
|
@@ -67,11 +54,10 @@ class _HumanFormatter(logging.Formatter):
|
|
|
67
54
|
"""Human-readable formatter for development."""
|
|
68
55
|
|
|
69
56
|
def format(self, record: logging.LogRecord) -> str:
|
|
70
|
-
extra =
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
msg = f"{msg} {extra_str}"
|
|
57
|
+
extra = getattr(record, "extra", None) or {}
|
|
58
|
+
if extra:
|
|
59
|
+
extra_str = " ".join(f"{k}={v}" for k, v in extra.items())
|
|
60
|
+
record.msg = f"{record.msg} {extra_str}"
|
|
75
61
|
return super().format(record)
|
|
76
62
|
|
|
77
63
|
|
|
@@ -149,7 +135,3 @@ def get_logger(
|
|
|
149
135
|
# ``sendparcel.configure_logging()`` explicitly from your application
|
|
150
136
|
# startup code (e.g. Django ``AppConfig.ready()``) to enable structured
|
|
151
137
|
# logging for the sendparcel packages.
|
|
152
|
-
#
|
|
153
|
-
# Alias for backwards compatibility with any code that imported
|
|
154
|
-
# ``_configure_structured_logging`` directly (pre-0.1.2).
|
|
155
|
-
_configure_structured_logging = configure_logging
|
sendparcel/provider.py
CHANGED
|
@@ -1,23 +1,40 @@
|
|
|
1
|
-
"""Base provider abstraction
|
|
1
|
+
"""Base provider abstraction."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Callable
|
|
6
7
|
from typing import Any, ClassVar
|
|
7
8
|
|
|
8
9
|
from sendparcel.enums import ConfirmationMethod
|
|
10
|
+
from sendparcel.exceptions import ProviderCapabilityError
|
|
9
11
|
from sendparcel.protocols import Shipment
|
|
10
12
|
from sendparcel.types import (
|
|
11
13
|
AddressInfo,
|
|
14
|
+
CallbackContext,
|
|
15
|
+
CancelOutcome,
|
|
16
|
+
GeoPoint,
|
|
12
17
|
LabelInfo,
|
|
13
18
|
ParcelInfo,
|
|
19
|
+
PickupPoint,
|
|
20
|
+
Quote,
|
|
14
21
|
ShipmentCreateResult,
|
|
15
22
|
ShipmentUpdateResult,
|
|
16
23
|
)
|
|
17
24
|
|
|
18
25
|
|
|
19
26
|
class BaseProvider(ABC):
|
|
20
|
-
"""Base class for parcel delivery providers.
|
|
27
|
+
"""Base class for parcel delivery providers.
|
|
28
|
+
|
|
29
|
+
Capability methods (``create_label``, ``handle_callback``,
|
|
30
|
+
``fetch_shipment_status``, ``cancel_shipment``) raise
|
|
31
|
+
:exc:`ProviderCapabilityError` by default. Override only the
|
|
32
|
+
methods the provider supports.
|
|
33
|
+
|
|
34
|
+
The flow orchestrator calls capability methods directly — no
|
|
35
|
+
``isinstance`` trait checks. If a provider doesn't support a
|
|
36
|
+
capability, the default implementation raises.
|
|
37
|
+
"""
|
|
21
38
|
|
|
22
39
|
slug: ClassVar[str] = ""
|
|
23
40
|
display_name: ClassVar[str] = ""
|
|
@@ -26,18 +43,138 @@ class BaseProvider(ABC):
|
|
|
26
43
|
confirmation_method: ClassVar[ConfirmationMethod] = ConfirmationMethod.NONE
|
|
27
44
|
user_selectable: ClassVar[bool] = True
|
|
28
45
|
config_schema: ClassVar[dict[str, Any]] = {}
|
|
46
|
+
transport_factory: ClassVar[Callable[..., Any] | None] = None
|
|
47
|
+
"""Callable that builds a transport from config dict.
|
|
48
|
+
Signature: transport_factory(**config) -> transport.
|
|
49
|
+
None means the provider doesn't need HTTP transport (e.g. DummyProvider).
|
|
50
|
+
"""
|
|
29
51
|
|
|
30
52
|
def __init__(
|
|
31
|
-
self,
|
|
53
|
+
self,
|
|
54
|
+
shipment: Shipment,
|
|
55
|
+
config: dict[str, Any] | None = None,
|
|
56
|
+
*,
|
|
57
|
+
transport: Any = None,
|
|
32
58
|
) -> None:
|
|
33
59
|
self.shipment = shipment
|
|
34
60
|
self.config = config or {}
|
|
61
|
+
self._transport = transport
|
|
62
|
+
if self.config_schema:
|
|
63
|
+
self._validate_config()
|
|
35
64
|
|
|
36
65
|
def get_setting(self, name: str, default: Any = None) -> Any:
|
|
37
66
|
"""Read provider setting from config."""
|
|
38
|
-
|
|
39
67
|
return self.config.get(name, default)
|
|
40
68
|
|
|
69
|
+
def _get_client(self) -> Any:
|
|
70
|
+
"""Return the injected transport, or raise if not configured."""
|
|
71
|
+
if self._transport is None:
|
|
72
|
+
raise RuntimeError(
|
|
73
|
+
f"{self.__class__.__name__} requires a transport. "
|
|
74
|
+
"Use create_provider() to wire the provider."
|
|
75
|
+
)
|
|
76
|
+
return self._transport
|
|
77
|
+
|
|
78
|
+
def _validate_config(self) -> None:
|
|
79
|
+
"""Validate configuration against config_schema."""
|
|
80
|
+
for field_name, spec in self.config_schema.items():
|
|
81
|
+
if not spec.get("required", False):
|
|
82
|
+
continue
|
|
83
|
+
value = self.get_setting(field_name)
|
|
84
|
+
if value is None or value == "":
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"{self.__class__.__name__} requires "
|
|
87
|
+
f"'{field_name}' in config."
|
|
88
|
+
)
|
|
89
|
+
expected_type = spec.get("type")
|
|
90
|
+
if expected_type and value is not None:
|
|
91
|
+
type_map: dict[str, type | tuple[type, ...]] = {
|
|
92
|
+
"str": str,
|
|
93
|
+
"int": int,
|
|
94
|
+
"float": (int, float),
|
|
95
|
+
"bool": bool,
|
|
96
|
+
"list[str]": list,
|
|
97
|
+
}
|
|
98
|
+
python_type = type_map.get(expected_type)
|
|
99
|
+
if python_type and not isinstance(value, python_type):
|
|
100
|
+
raise TypeError(
|
|
101
|
+
f"{self.__class__.__name__} config '{field_name}' "
|
|
102
|
+
f"must be {expected_type}, got {type(value).__name__}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _address_to_provider(
|
|
106
|
+
self,
|
|
107
|
+
addr: AddressInfo,
|
|
108
|
+
*,
|
|
109
|
+
field_format: str = "snake",
|
|
110
|
+
) -> dict[str, Any]:
|
|
111
|
+
"""Convert AddressInfo to provider-specific address dict.
|
|
112
|
+
|
|
113
|
+
field_format: 'snake' (default), 'camel', 'pascal'
|
|
114
|
+
Subclasses can override _address_format ClassVar to set their default.
|
|
115
|
+
"""
|
|
116
|
+
fmt = getattr(self, "_address_format", field_format)
|
|
117
|
+
|
|
118
|
+
def _convert(key: str) -> str:
|
|
119
|
+
if fmt == "camel":
|
|
120
|
+
parts = key.split("_")
|
|
121
|
+
return parts[0] + "".join(p.capitalize() for p in parts[1:])
|
|
122
|
+
elif fmt == "pascal":
|
|
123
|
+
return "".join(p.capitalize() for p in key.split("_"))
|
|
124
|
+
return key # snake
|
|
125
|
+
|
|
126
|
+
result: dict[str, Any] = {}
|
|
127
|
+
|
|
128
|
+
# Map common fields
|
|
129
|
+
field_mappings = [
|
|
130
|
+
("company", "company"),
|
|
131
|
+
("first_name", "first_name"),
|
|
132
|
+
("last_name", "last_name"),
|
|
133
|
+
("name", "name"),
|
|
134
|
+
("street", "street"),
|
|
135
|
+
("building_number", "building_number"),
|
|
136
|
+
("flat_number", "flat_number"),
|
|
137
|
+
("line1", "line1"),
|
|
138
|
+
("city", "city"),
|
|
139
|
+
("country_code", "country_code"),
|
|
140
|
+
("postal_code", "postal_code"),
|
|
141
|
+
("phone", "phone"),
|
|
142
|
+
("email", "email"),
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
for src, dst in field_mappings:
|
|
146
|
+
value = addr.get(src)
|
|
147
|
+
if value:
|
|
148
|
+
result[_convert(dst)] = value
|
|
149
|
+
|
|
150
|
+
return result
|
|
151
|
+
|
|
152
|
+
def _parcels_to_provider(
|
|
153
|
+
self,
|
|
154
|
+
parcels: list[ParcelInfo],
|
|
155
|
+
) -> list[dict[str, Any]]:
|
|
156
|
+
"""Convert ParcelInfo list to provider-specific parcel dicts."""
|
|
157
|
+
result = []
|
|
158
|
+
for parcel in parcels:
|
|
159
|
+
p: dict[str, Any] = {}
|
|
160
|
+
weight = parcel.get("weight_kg")
|
|
161
|
+
if weight is not None:
|
|
162
|
+
p["weight"] = float(weight)
|
|
163
|
+
|
|
164
|
+
length = parcel.get("length_cm")
|
|
165
|
+
width = parcel.get("width_cm")
|
|
166
|
+
height = parcel.get("height_cm")
|
|
167
|
+
if length:
|
|
168
|
+
p["length"] = float(length)
|
|
169
|
+
if width:
|
|
170
|
+
p["width"] = float(width)
|
|
171
|
+
if height:
|
|
172
|
+
p["height"] = float(height)
|
|
173
|
+
|
|
174
|
+
result.append(p)
|
|
175
|
+
|
|
176
|
+
return result or [{"weight": 1.0}]
|
|
177
|
+
|
|
41
178
|
@abstractmethod
|
|
42
179
|
async def create_shipment(
|
|
43
180
|
self,
|
|
@@ -49,44 +186,125 @@ class BaseProvider(ABC):
|
|
|
49
186
|
) -> ShipmentCreateResult:
|
|
50
187
|
"""Create shipment in provider API."""
|
|
51
188
|
|
|
52
|
-
|
|
53
|
-
class LabelProvider(ABC):
|
|
54
|
-
"""Trait for providers that support label generation."""
|
|
55
|
-
|
|
56
|
-
@abstractmethod
|
|
57
189
|
async def create_label(self, **kwargs: Any) -> LabelInfo:
|
|
58
|
-
"""Create or fetch label payload for a shipment.
|
|
190
|
+
"""Create or fetch label payload for a shipment.
|
|
59
191
|
|
|
192
|
+
Raises :exc:`ProviderCapabilityError` if the provider does not
|
|
193
|
+
support label generation.
|
|
194
|
+
"""
|
|
195
|
+
raise ProviderCapabilityError(
|
|
196
|
+
f"Provider {self.__class__.__name__!r} does not support "
|
|
197
|
+
"label creation"
|
|
198
|
+
)
|
|
60
199
|
|
|
61
|
-
|
|
62
|
-
|
|
200
|
+
async def verify_callback(self, ctx: CallbackContext) -> None:
|
|
201
|
+
"""Verify callback authenticity.
|
|
63
202
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
203
|
+
Raises :exc:`ProviderCapabilityError` if the provider does not
|
|
204
|
+
support push callbacks.
|
|
205
|
+
"""
|
|
206
|
+
raise ProviderCapabilityError(
|
|
207
|
+
f"Provider {self.__class__.__name__!r} does not support "
|
|
208
|
+
"push callbacks"
|
|
209
|
+
)
|
|
69
210
|
|
|
70
|
-
@abstractmethod
|
|
71
211
|
async def handle_callback(
|
|
72
|
-
self,
|
|
212
|
+
self, ctx: CallbackContext
|
|
73
213
|
) -> ShipmentUpdateResult:
|
|
74
|
-
"""Normalize callback data into a shipment update payload.
|
|
75
|
-
|
|
214
|
+
"""Normalize callback data into a shipment update payload.
|
|
76
215
|
|
|
77
|
-
|
|
78
|
-
|
|
216
|
+
Raises :exc:`ProviderCapabilityError` if the provider does not
|
|
217
|
+
support push callbacks.
|
|
218
|
+
"""
|
|
219
|
+
raise ProviderCapabilityError(
|
|
220
|
+
f"Provider {self.__class__.__name__!r} does not support "
|
|
221
|
+
"push callbacks"
|
|
222
|
+
)
|
|
79
223
|
|
|
80
|
-
@abstractmethod
|
|
81
224
|
async def fetch_shipment_status(
|
|
82
225
|
self, **kwargs: Any
|
|
83
226
|
) -> ShipmentUpdateResult:
|
|
84
|
-
"""Fetch latest shipment update from provider.
|
|
227
|
+
"""Fetch latest shipment update from provider.
|
|
85
228
|
|
|
229
|
+
Raises :exc:`ProviderCapabilityError` if the provider does not
|
|
230
|
+
support status polling.
|
|
231
|
+
"""
|
|
232
|
+
raise ProviderCapabilityError(
|
|
233
|
+
f"Provider {self.__class__.__name__!r} does not support "
|
|
234
|
+
"status polling"
|
|
235
|
+
)
|
|
86
236
|
|
|
87
|
-
|
|
88
|
-
|
|
237
|
+
async def cancel_shipment(self, **kwargs: Any) -> CancelOutcome:
|
|
238
|
+
"""Cancel shipment if provider supports cancellation.
|
|
89
239
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
+
|
|
244
|
+
Raises :exc:`ProviderCapabilityError` if the provider does not
|
|
245
|
+
support cancellation.
|
|
246
|
+
"""
|
|
247
|
+
raise ProviderCapabilityError(
|
|
248
|
+
f"Provider {self.__class__.__name__!r} does not support "
|
|
249
|
+
"cancellation"
|
|
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
|
+
)
|