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.
- python_sendparcel-0.2.0.dist-info/METADATA +203 -0
- python_sendparcel-0.2.0.dist-info/RECORD +19 -0
- {python_sendparcel-0.1.0.dist-info → python_sendparcel-0.2.0.dist-info}/WHEEL +1 -1
- sendparcel/__init__.py +37 -3
- sendparcel/batch.py +303 -0
- sendparcel/enums.py +11 -0
- sendparcel/exceptions.py +33 -1
- sendparcel/flow.py +226 -96
- sendparcel/fsm.py +92 -117
- sendparcel/logging.py +155 -0
- sendparcel/protocols.py +53 -19
- sendparcel/provider.py +55 -26
- sendparcel/providers/dummy.py +44 -25
- sendparcel/registry.py +69 -25
- sendparcel/types.py +47 -19
- sendparcel/validators.py +3 -2
- python_sendparcel-0.1.0.dist-info/METADATA +0 -449
- python_sendparcel-0.1.0.dist-info/RECORD +0 -17
- {python_sendparcel-0.1.0.dist-info → python_sendparcel-0.2.0.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
12
|
+
ProviderCapabilityError,
|
|
9
13
|
SendParcelException,
|
|
10
14
|
)
|
|
11
|
-
from sendparcel.fsm import
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
shipment.
|
|
70
|
-
|
|
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
|
-
) ->
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
await self.repository.
|
|
111
|
-
|
|
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
|
|
119
|
-
|
|
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
|
|
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
|
-
"""
|
|
1
|
+
"""Explicit shipment status transitions."""
|
|
2
2
|
|
|
3
|
-
from
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
ShipmentStatus.
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|