python-sendparcel 0.1.0__py3-none-any.whl → 0.2.0__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
@@ -1,19 +1,36 @@
1
1
  """Shipment flow orchestrator."""
2
2
 
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
3
7
  import httpx
4
8
 
5
9
  from sendparcel.enums import ShipmentStatus
6
10
  from sendparcel.exceptions import (
7
11
  CommunicationError,
8
- InvalidTransitionError,
12
+ ProviderCapabilityError,
9
13
  SendParcelException,
10
14
  )
11
- from sendparcel.fsm import (
12
- STATUS_TO_CALLBACK,
13
- create_shipment_machine,
15
+ from sendparcel.fsm import transition_shipment
16
+ from sendparcel.protocols import Shipment, ShipmentRepository
17
+ from sendparcel.provider import (
18
+ BaseProvider,
19
+ CancellableProvider,
20
+ LabelProvider,
21
+ PullStatusProvider,
22
+ PushCallbackProvider,
23
+ )
24
+ from sendparcel.registry import PluginRegistry
25
+ from sendparcel.registry import registry as default_registry
26
+ from sendparcel.types import (
27
+ AddressInfo,
28
+ CreateLabelOutcome,
29
+ CreateShipmentOutcome,
30
+ ParcelInfo,
31
+ ShipmentUpdateOutcome,
32
+ ShipmentUpdateResult,
14
33
  )
15
- from sendparcel.protocols import Order, Shipment, ShipmentRepository
16
- from sendparcel.registry import registry
17
34
  from sendparcel.validators import run_validators
18
35
 
19
36
 
@@ -23,100 +40,233 @@ class ShipmentFlow:
23
40
  def __init__(
24
41
  self,
25
42
  repository: ShipmentRepository,
26
- config: dict | None = None,
27
- validators: list | None = None,
43
+ config: dict[str, Any] | None = None,
44
+ validators: list[Any] | None = None,
45
+ registry: PluginRegistry | None = None,
28
46
  ) -> None:
29
47
  self.repository = repository
30
48
  self.config = config or {}
31
49
  self.validators = validators or []
50
+ self.registry = registry or default_registry
32
51
 
33
52
  async def create_shipment(
34
53
  self,
35
- order: Order,
36
54
  provider_slug: str,
37
- **kwargs,
38
- ) -> Shipment:
39
- """Create a shipment record for an order."""
40
- registry.get_by_slug(provider_slug)
41
- shipment = await self.repository.create(
42
- order=order,
43
- provider=provider_slug,
44
- status=ShipmentStatus.NEW,
45
- **kwargs,
46
- )
47
- create_shipment_machine(shipment)
55
+ *,
56
+ sender_address: AddressInfo,
57
+ receiver_address: AddressInfo,
58
+ parcels: list[ParcelInfo],
59
+ idempotency_key: str | None = None,
60
+ **kwargs: Any,
61
+ ) -> CreateShipmentOutcome:
62
+ """Create a shipment record with explicit address and parcel data.
63
+
64
+ Uses persistence-enforced idempotency: if a shipment with the same
65
+ provider + idempotency_key already exists, it is returned without
66
+ calling the provider again.
67
+
68
+ On provider failure:
69
+ - CommunicationError (timeout, network): marks shipment as
70
+ ``SUBMITTED`` (ambiguous — provider may have accepted).
71
+ The caller should reconcile via polling or callback.
72
+ - Other errors: marks shipment as ``FAILED`` and re-raises.
73
+
74
+ The shipment record is never deleted on failure, ensuring that
75
+ retries and reconciliation always have a record to work with.
76
+
77
+ Args:
78
+ provider_slug: Provider identifier.
79
+ sender_address: Sender address info.
80
+ receiver_address: Receiver address info.
81
+ parcels: List of parcel definitions.
82
+ idempotency_key: Optional key for retry safety. Stored in the
83
+ ``reference_id`` field of the shipment record.
84
+ **kwargs: Passed to the provider (after repo-only fields
85
+ are stripped).
86
+
87
+ Returns:
88
+ CreateShipmentOutcome with the persisted shipment and
89
+ optional label payload.
90
+ """
91
+ self.registry.get_by_slug(provider_slug)
92
+
93
+ # Separate repository kwargs from provider kwargs.
94
+ repo_kwargs: dict[str, Any] = {}
95
+ for key in ("reference_id",):
96
+ if key in kwargs:
97
+ repo_kwargs[key] = kwargs.pop(key)
98
+
99
+ # Apply idempotency key to reference_id if provided.
100
+ if idempotency_key is not None:
101
+ repo_kwargs.setdefault("reference_id", idempotency_key)
102
+
103
+ # Create shipment — use atomic idempotency if key provided.
104
+ if idempotency_key is not None:
105
+ (
106
+ existing,
107
+ created,
108
+ ) = await self.repository.create_with_idempotency_key(
109
+ provider=provider_slug,
110
+ status=ShipmentStatus.NEW.value,
111
+ **repo_kwargs,
112
+ )
113
+ if existing is not None:
114
+ return CreateShipmentOutcome(shipment=existing, label=None)
115
+ if created is None:
116
+ raise RuntimeError(
117
+ "create_with_idempotency_key returned (None, None) "
118
+ "— repository contract violated"
119
+ )
120
+ shipment = created
121
+ else:
122
+ shipment = await self.repository.create(
123
+ provider=provider_slug,
124
+ status=ShipmentStatus.NEW.value,
125
+ **repo_kwargs,
126
+ )
127
+
48
128
  provider = self._get_provider(shipment)
49
- result = await self._call_provider(provider.create_shipment(**kwargs))
50
- shipment.external_id = result.get("external_id", "")
51
- shipment.tracking_number = result.get("tracking_number", "")
52
- self._trigger(shipment, "confirm_created")
53
- label = result.get("label") or {}
54
- label_url = label.get("url", "")
55
- if label_url:
56
- shipment.label_url = label_url
57
- if shipment.may_trigger("confirm_label"): # ty: ignore[unresolved-attribute] # dynamic FSM trigger guard
58
- shipment.confirm_label() # ty: ignore[unresolved-attribute] # dynamic FSM trigger
59
- return await self.repository.save(shipment)
60
-
61
- async def create_label(self, shipment: Shipment, **kwargs) -> Shipment:
62
- """Create provider label and persist shipment."""
129
+ try:
130
+ result = await self._call_provider(
131
+ provider.create_shipment(
132
+ sender_address=sender_address,
133
+ receiver_address=receiver_address,
134
+ parcels=parcels,
135
+ **kwargs,
136
+ )
137
+ )
138
+ except CommunicationError:
139
+ # Ambiguous: provider may have accepted the shipment
140
+ # (e.g. timeout after acceptance). Mark as SUBMITTED for
141
+ # reconciliation via polling or callback.
142
+ transition_shipment(shipment, ShipmentStatus.SUBMITTED)
143
+ await self.repository.save(shipment)
144
+ return CreateShipmentOutcome(shipment=shipment, label=None)
145
+ except Exception:
146
+ # Non-communication error: mark as FAILED, never delete.
147
+ transition_shipment(shipment, ShipmentStatus.FAILED)
148
+ await self.repository.save(shipment)
149
+ raise
150
+
151
+ shipment.external_id = str(result.get("external_id", ""))
152
+ shipment.tracking_number = str(result.get("tracking_number", ""))
153
+ transition_shipment(shipment, ShipmentStatus.CREATED)
154
+ label = result.get("label")
155
+ if label is not None:
156
+ transition_shipment(shipment, ShipmentStatus.LABEL_READY)
157
+ saved = await self.repository.save(shipment)
158
+ return CreateShipmentOutcome(shipment=saved, label=label)
159
+
160
+ async def create_label(
161
+ self, shipment: Shipment, **kwargs: Any
162
+ ) -> CreateLabelOutcome:
163
+ """Create provider label and persist shipment metadata."""
164
+
63
165
  run_validators({"shipment": shipment}, validators=self.validators)
64
- create_shipment_machine(shipment)
65
166
  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
+ )
66
172
  label = await self._call_provider(provider.create_label(**kwargs))
67
- shipment.label_url = label.get("url", "")
68
- if shipment.may_trigger("confirm_label"): # ty: ignore[unresolved-attribute] # dynamic FSM trigger guard
69
- shipment.confirm_label() # ty: ignore[unresolved-attribute] # dynamic FSM trigger
70
- return await self.repository.save(shipment)
173
+ transition_shipment(shipment, ShipmentStatus.LABEL_READY)
174
+ saved = await self.repository.update_fields(
175
+ shipment_id=shipment.id, status=shipment.status
176
+ )
177
+ return CreateLabelOutcome(shipment=saved, label=label)
71
178
 
72
179
  async def handle_callback(
73
180
  self,
74
181
  shipment: Shipment,
75
- data: dict,
76
- headers: dict,
77
- **kwargs,
78
- ) -> Shipment:
182
+ data: dict[str, Any],
183
+ headers: dict[str, Any],
184
+ **kwargs: Any,
185
+ ) -> ShipmentUpdateOutcome:
79
186
  """Verify and apply provider callback."""
187
+
80
188
  provider = self._get_provider(shipment)
81
- create_shipment_machine(shipment)
189
+ if not isinstance(provider, PushCallbackProvider):
190
+ raise ProviderCapabilityError(
191
+ f"Provider {shipment.provider!r} does not support "
192
+ "push callbacks"
193
+ )
82
194
  await self._call_provider(
83
195
  provider.verify_callback(data, headers, **kwargs)
84
196
  )
85
- await self._call_provider(
197
+ update = await self._call_provider(
86
198
  provider.handle_callback(data, headers, **kwargs)
87
199
  )
88
- return await self.repository.save(shipment)
200
+ normalized_update = update or ShipmentUpdateResult()
201
+ saved = await self._apply_update(shipment, normalized_update)
202
+ return ShipmentUpdateOutcome(shipment=saved, update=normalized_update)
89
203
 
90
- async def fetch_and_update_status(self, shipment: Shipment) -> Shipment:
204
+ async def fetch_and_update_status(
205
+ self, shipment: Shipment
206
+ ) -> ShipmentUpdateOutcome:
91
207
  """Fetch status from provider and persist."""
208
+
92
209
  provider = self._get_provider(shipment)
93
- create_shipment_machine(shipment)
94
- response = await self._call_provider(provider.fetch_shipment_status())
95
- status_value = response.get("status")
96
- callback = self._resolve_callback(status_value)
97
- if callback:
98
- self._trigger(shipment, callback)
99
- return await self.repository.save(shipment)
100
-
101
- async def cancel_shipment(self, shipment: Shipment, **kwargs) -> bool:
210
+ if not isinstance(provider, PullStatusProvider):
211
+ raise ProviderCapabilityError(
212
+ f"Provider {shipment.provider!r} does not support "
213
+ "status polling"
214
+ )
215
+ update = await self._call_provider(provider.fetch_shipment_status())
216
+ normalized_update = update or ShipmentUpdateResult()
217
+ saved = await self._apply_update(shipment, normalized_update)
218
+ return ShipmentUpdateOutcome(shipment=saved, update=normalized_update)
219
+
220
+ async def cancel_shipment(self, shipment: Shipment, **kwargs: Any) -> bool:
102
221
  """Cancel shipment via provider and persist state."""
222
+
103
223
  provider = self._get_provider(shipment)
104
- create_shipment_machine(shipment)
224
+ if not isinstance(provider, CancellableProvider):
225
+ raise ProviderCapabilityError(
226
+ f"Provider {shipment.provider!r} does not support cancellation"
227
+ )
105
228
  cancelled = await self._call_provider(
106
229
  provider.cancel_shipment(**kwargs)
107
230
  )
108
231
  if cancelled:
109
- self._trigger(shipment, "cancel")
110
- await self.repository.save(shipment)
111
- return cancelled
232
+ transition_shipment(shipment, ShipmentStatus.CANCELLED)
233
+ await self.repository.update_fields(
234
+ shipment_id=shipment.id, status=shipment.status
235
+ )
236
+ return bool(cancelled)
112
237
 
113
- def _get_provider(self, shipment: Shipment):
114
- provider_class = registry.get_by_slug(shipment.provider)
238
+ def _get_provider(self, shipment: Shipment) -> BaseProvider:
239
+ provider_class = self.registry.get_by_slug(shipment.provider)
115
240
  provider_config = self.config.get(shipment.provider, {})
116
241
  return provider_class(shipment, config=provider_config)
117
242
 
118
- async def _call_provider(self, coro):
119
- """Call a provider coroutine, wrapping non-domain errors."""
243
+ async def _apply_update(
244
+ self, shipment: Shipment, update: ShipmentUpdateResult
245
+ ) -> Shipment:
246
+ """Apply a normalized update to a shipment atomically.
247
+
248
+ Uses the atomic ``update_fields`` persistence primitive to
249
+ prevent concurrent read-modify-save races.
250
+ """
251
+ fields: dict[str, Any] = {}
252
+ tracking_number = update.get("tracking_number")
253
+ if tracking_number:
254
+ fields["tracking_number"] = str(tracking_number)
255
+ status = update.get("status")
256
+ if status is not None:
257
+ transition_shipment(shipment, status)
258
+ fields["status"] = shipment.status
259
+ return await self.repository.update_fields(
260
+ shipment_id=shipment.id, **fields
261
+ )
262
+
263
+ async def _call_provider(self, coro: Any) -> Any:
264
+ """Call a provider coroutine, wrapping only network errors.
265
+
266
+ Domain errors (ValueError, KeyError, TypeError, etc.) are re-raised
267
+ as-is so they are distinguishable from communication failures.
268
+ """
269
+
120
270
  try:
121
271
  return await coro
122
272
  except SendParcelException:
@@ -126,39 +276,19 @@ class ShipmentFlow:
126
276
  str(exc),
127
277
  context={"original_error": type(exc).__name__},
128
278
  ) from exc
129
- except Exception as exc:
279
+ except TimeoutError as exc:
280
+ raise CommunicationError(
281
+ str(exc),
282
+ context={"original_error": type(exc).__name__},
283
+ ) from exc
284
+ except ExceptionGroup as exc:
285
+ # Preserve typed inner exceptions: if any inner exception is a
286
+ # SendParcelException, re-raise it directly. Otherwise wrap the
287
+ # entire group in a CommunicationError.
288
+ for e in exc.exceptions:
289
+ if isinstance(e, SendParcelException):
290
+ raise e from exc
130
291
  raise CommunicationError(
131
292
  str(exc),
132
293
  context={"original_error": type(exc).__name__},
133
294
  ) from exc
134
-
135
- def _resolve_callback(self, status_value: str | None) -> str | None:
136
- """Map a provider status value to an FSM callback name.
137
-
138
- Only accepts values from STATUS_TO_CALLBACK mapping.
139
- Raw callback names (e.g., "cancel") are NOT accepted as
140
- status values -- providers must return status enum values
141
- (e.g., "cancelled").
142
- """
143
- if status_value is None:
144
- return None
145
- callback = STATUS_TO_CALLBACK.get(status_value)
146
- if callback:
147
- return callback
148
- raise InvalidTransitionError(
149
- f"Unknown status value {status_value!r}. "
150
- f"Expected one of: {', '.join(sorted(STATUS_TO_CALLBACK))}"
151
- )
152
-
153
- def _trigger(self, shipment: Shipment, callback: str, **kwargs) -> None:
154
- trigger = getattr(shipment, callback, None)
155
- if trigger is None or not callable(trigger):
156
- raise InvalidTransitionError(
157
- f"Shipment has no callback trigger {callback!r}"
158
- )
159
- if not shipment.may_trigger(callback): # ty: ignore[unresolved-attribute] # dynamic FSM trigger guard
160
- raise InvalidTransitionError(
161
- f"Callback {callback!r} cannot be executed from status "
162
- f"{shipment.status!r}"
163
- )
164
- trigger(**kwargs)
sendparcel/fsm.py CHANGED
@@ -1,131 +1,106 @@
1
- """Shipment state machine definitions."""
1
+ """Explicit shipment status transitions."""
2
2
 
3
- from transitions import Machine
4
- from transitions.core import MachineError
3
+ from __future__ import annotations
5
4
 
6
5
  from sendparcel.enums import ShipmentStatus
6
+ from sendparcel.exceptions import InvalidTransitionError
7
+ from sendparcel.protocols import Shipment
7
8
 
8
- CALLBACK_NAMES = (
9
- "confirm_created",
10
- "confirm_label",
11
- "mark_in_transit",
12
- "mark_out_for_delivery",
13
- "mark_delivered",
14
- "mark_returned",
15
- "cancel",
16
- "fail",
17
- )
18
-
19
-
20
- def _require_label_url(event_data):
21
- """Guard: reject confirm_label if shipment has no label_url."""
22
- model = event_data.model
23
- if not getattr(model, "label_url", ""):
24
- raise MachineError(
25
- f"Transition '{event_data.event.name}'"
26
- " requires label_url to be set."
27
- )
28
-
29
-
30
- def _require_tracking_number(event_data):
31
- """Guard: reject mark_in_transit if shipment has no tracking_number."""
32
- model = event_data.model
33
- if not getattr(model, "tracking_number", ""):
34
- raise MachineError(
35
- f"Transition '{event_data.event.name}'"
36
- " requires tracking_number to be set."
37
- )
38
-
39
-
40
- SHIPMENT_TRANSITIONS = [
41
- {
42
- "trigger": "confirm_created",
43
- "source": ShipmentStatus.NEW,
44
- "dest": ShipmentStatus.CREATED,
45
- },
46
- {
47
- "trigger": "confirm_label",
48
- "source": ShipmentStatus.CREATED,
49
- "dest": ShipmentStatus.LABEL_READY,
50
- "before": _require_label_url,
51
- },
52
- {
53
- "trigger": "mark_in_transit",
54
- "source": [ShipmentStatus.CREATED, ShipmentStatus.LABEL_READY],
55
- "dest": ShipmentStatus.IN_TRANSIT,
56
- "before": _require_tracking_number,
57
- },
58
- {
59
- "trigger": "mark_out_for_delivery",
60
- "source": ShipmentStatus.IN_TRANSIT,
61
- "dest": ShipmentStatus.OUT_FOR_DELIVERY,
62
- },
63
- {
64
- "trigger": "mark_delivered",
65
- "source": [
9
+ ALLOWED_STATUS_TRANSITIONS: dict[ShipmentStatus, frozenset[ShipmentStatus]] = {
10
+ ShipmentStatus.NEW: frozenset(
11
+ {
12
+ ShipmentStatus.CREATED,
13
+ ShipmentStatus.CANCELLED,
14
+ ShipmentStatus.FAILED,
15
+ ShipmentStatus.SUBMITTED,
16
+ }
17
+ ),
18
+ ShipmentStatus.CREATED: frozenset(
19
+ {
20
+ ShipmentStatus.LABEL_READY,
66
21
  ShipmentStatus.IN_TRANSIT,
67
22
  ShipmentStatus.OUT_FOR_DELIVERY,
68
- ],
69
- "dest": ShipmentStatus.DELIVERED,
70
- },
71
- {
72
- "trigger": "mark_returned",
73
- "source": [
23
+ ShipmentStatus.DELIVERED,
24
+ ShipmentStatus.RETURNED,
25
+ ShipmentStatus.CANCELLED,
26
+ ShipmentStatus.FAILED,
27
+ }
28
+ ),
29
+ ShipmentStatus.LABEL_READY: frozenset(
30
+ {
74
31
  ShipmentStatus.IN_TRANSIT,
75
32
  ShipmentStatus.OUT_FOR_DELIVERY,
76
33
  ShipmentStatus.DELIVERED,
77
- ],
78
- "dest": ShipmentStatus.RETURNED,
79
- },
80
- {
81
- "trigger": "cancel",
82
- "source": [
83
- ShipmentStatus.NEW,
84
- ShipmentStatus.CREATED,
85
- ShipmentStatus.LABEL_READY,
86
- ],
87
- "dest": ShipmentStatus.CANCELLED,
88
- },
89
- {
90
- "trigger": "fail",
91
- "source": [
92
- ShipmentStatus.NEW,
93
- ShipmentStatus.CREATED,
94
- ShipmentStatus.LABEL_READY,
95
- ShipmentStatus.IN_TRANSIT,
34
+ ShipmentStatus.RETURNED,
35
+ ShipmentStatus.CANCELLED,
36
+ ShipmentStatus.FAILED,
37
+ }
38
+ ),
39
+ ShipmentStatus.IN_TRANSIT: frozenset(
40
+ {
96
41
  ShipmentStatus.OUT_FOR_DELIVERY,
97
- ],
98
- "dest": ShipmentStatus.FAILED,
99
- },
100
- ]
42
+ ShipmentStatus.DELIVERED,
43
+ ShipmentStatus.RETURNED,
44
+ ShipmentStatus.FAILED,
45
+ }
46
+ ),
47
+ ShipmentStatus.OUT_FOR_DELIVERY: frozenset(
48
+ {
49
+ ShipmentStatus.DELIVERED,
50
+ ShipmentStatus.RETURNED,
51
+ ShipmentStatus.FAILED,
52
+ }
53
+ ),
54
+ ShipmentStatus.DELIVERED: frozenset({ShipmentStatus.RETURNED}),
55
+ ShipmentStatus.CANCELLED: frozenset(),
56
+ ShipmentStatus.FAILED: frozenset(),
57
+ ShipmentStatus.RETURNED: frozenset(),
58
+ ShipmentStatus.SUBMITTED: frozenset(
59
+ {
60
+ ShipmentStatus.CREATED,
61
+ ShipmentStatus.FAILED,
62
+ }
63
+ ),
64
+ }
101
65
 
102
- ALLOWED_CALLBACKS: frozenset[str] = frozenset(CALLBACK_NAMES)
103
66
 
104
- STATUS_TO_CALLBACK: dict[str, str] = {
105
- ShipmentStatus.CREATED.value: "confirm_created",
106
- ShipmentStatus.LABEL_READY.value: "confirm_label",
107
- ShipmentStatus.IN_TRANSIT.value: "mark_in_transit",
108
- ShipmentStatus.OUT_FOR_DELIVERY.value: "mark_out_for_delivery",
109
- ShipmentStatus.DELIVERED.value: "mark_delivered",
110
- ShipmentStatus.RETURNED.value: "mark_returned",
111
- ShipmentStatus.CANCELLED.value: "cancel",
112
- ShipmentStatus.FAILED.value: "fail",
113
- }
67
+ def normalize_status(status: str | ShipmentStatus) -> ShipmentStatus:
68
+ """Normalise a status to a :class:`ShipmentStatus` enum member."""
114
69
 
70
+ try:
71
+ return ShipmentStatus(status)
72
+ except ValueError as exc:
73
+ raise InvalidTransitionError(
74
+ f"Unknown shipment status {status!r}"
75
+ ) from exc
115
76
 
116
- def create_shipment_machine(shipment) -> Machine:
117
- """Attach shipment FSM to shipment object."""
118
- initial = (
119
- ShipmentStatus(shipment.status)
120
- if shipment.status
121
- else ShipmentStatus.NEW
122
- )
123
- return Machine(
124
- model=shipment,
125
- states=ShipmentStatus,
126
- transitions=SHIPMENT_TRANSITIONS,
127
- initial=initial,
128
- model_attribute="status",
129
- auto_transitions=False,
130
- send_event=True,
131
- )
77
+
78
+ def can_transition(
79
+ current_status: str | ShipmentStatus,
80
+ target_status: str | ShipmentStatus,
81
+ ) -> bool:
82
+ """Check whether a shipment status transition is allowed."""
83
+
84
+ current = normalize_status(current_status)
85
+ target = normalize_status(target_status)
86
+ if current == target:
87
+ return True
88
+ return target in ALLOWED_STATUS_TRANSITIONS[current]
89
+
90
+
91
+ def transition_shipment(
92
+ shipment: Shipment, target_status: str | ShipmentStatus
93
+ ) -> Shipment:
94
+ """Apply a validated status transition to a shipment."""
95
+
96
+ current = normalize_status(shipment.status)
97
+ target = normalize_status(target_status)
98
+ if current == target:
99
+ shipment.status = target.value
100
+ return shipment
101
+ if target not in ALLOWED_STATUS_TRANSITIONS[current]:
102
+ raise InvalidTransitionError(
103
+ f"Shipment cannot transition from {current!r} to {target!r}"
104
+ )
105
+ shipment.status = target.value
106
+ return shipment