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.
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
- from sendparcel.validators import run_validators
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
- shipment: Shipment,
182
- data: dict[str, Any],
183
- headers: dict[str, Any],
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
- if not isinstance(provider, PushCallbackProvider):
190
- raise ProviderCapabilityError(
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(self, shipment: Shipment, **kwargs: Any) -> bool:
221
- """Cancel shipment via provider and persist state."""
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
- if not isinstance(provider, CancellableProvider):
225
- raise ProviderCapabilityError(
226
- f"Provider {shipment.provider!r} does not support cancellation"
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
- cancelled = await self._call_provider(
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 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
+ )
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 provider_class(shipment, config=provider_config)
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
- ALLOWED_STATUS_TRANSITIONS: dict[ShipmentStatus, frozenset[ShipmentStatus]] = {
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 normalize_status(status: str | ShipmentStatus) -> ShipmentStatus:
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 = normalize_status(current_status)
85
- target = normalize_status(target_status)
84
+ current = _normalize(current_status)
85
+ target = _normalize(target_status)
86
86
  if current == target:
87
87
  return True
88
- return target in ALLOWED_STATUS_TRANSITIONS[current]
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 = normalize_status(shipment.status)
97
- target = normalize_status(target_status)
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 ALLOWED_STATUS_TRANSITIONS[current]:
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 = _format_extra(getattr(record, "extra", None))
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 = _format_extra(getattr(record, "extra", None))
71
- extra_str = " ".join(f"{k}={v}" for k, v in extra.items())
72
- msg = record.getMessage()
73
- if extra_str:
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 and capability trait mixins."""
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, shipment: Shipment, config: dict[str, Any] | None = None
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
- class PushCallbackProvider(ABC):
62
- """Trait for providers that receive push notifications."""
200
+ async def verify_callback(self, ctx: CallbackContext) -> None:
201
+ """Verify callback authenticity.
63
202
 
64
- @abstractmethod
65
- async def verify_callback(
66
- self, data: dict[str, Any], headers: dict[str, Any], **kwargs: Any
67
- ) -> None:
68
- """Verify callback authenticity."""
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, data: dict[str, Any], headers: dict[str, Any], **kwargs: Any
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
- class PullStatusProvider(ABC):
78
- """Trait for providers that support status polling."""
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
- class CancellableProvider(ABC):
88
- """Trait for providers that support shipment cancellation."""
237
+ async def cancel_shipment(self, **kwargs: Any) -> CancelOutcome:
238
+ """Cancel shipment if provider supports cancellation.
89
239
 
90
- @abstractmethod
91
- async def cancel_shipment(self, **kwargs: Any) -> bool:
92
- """Cancel shipment if provider supports cancellation."""
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
+ )