python-sendparcel 0.1.0__py3-none-any.whl → 0.1.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.
@@ -0,0 +1,212 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-sendparcel
3
+ Version: 0.1.1
4
+ Summary: Framework-agnostic parcel shipping core for Python.
5
+ Project-URL: Homepage, https://github.com/sendparcel/python-sendparcel
6
+ Project-URL: Documentation, https://python-sendparcel.readthedocs.io/
7
+ Project-URL: Repository, https://github.com/sendparcel/python-sendparcel
8
+ Project-URL: Changelog, https://github.com/sendparcel/python-sendparcel/blob/main/CHANGELOG.md
9
+ Project-URL: Issue Tracker, https://github.com/sendparcel/python-sendparcel/issues
10
+ Author-email: Dominik Kozaczko <dominik@kozaczko.info>
11
+ License: MIT
12
+ License-File: LICENSE
13
+ Keywords: delivery,logistics,parcel,sendparcel,shipping
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Natural Language :: English
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: anyio>=4.0
25
+ Requires-Dist: httpx>=0.27.0
26
+ Provides-Extra: all
27
+ Requires-Dist: django-sendparcel>=0.1.0; extra == 'all'
28
+ Requires-Dist: fastapi-sendparcel>=0.1.0; extra == 'all'
29
+ Requires-Dist: litestar-sendparcel>=0.1.0; extra == 'all'
30
+ Requires-Dist: python-sendparcel-cli>=0.1.0; extra == 'all'
31
+ Requires-Dist: python-sendparcel-dpdpl>=0.1.0; extra == 'all'
32
+ Requires-Dist: python-sendparcel-inpost>=0.1.0; extra == 'all'
33
+ Provides-Extra: cli
34
+ Requires-Dist: python-sendparcel-cli>=0.1.0; extra == 'cli'
35
+ Provides-Extra: dev
36
+ Requires-Dist: pre-commit-hooks>=6.0.0; extra == 'dev'
37
+ Requires-Dist: pre-commit>=4.0; extra == 'dev'
38
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
39
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
40
+ Requires-Dist: pytest>=8.0; extra == 'dev'
41
+ Requires-Dist: ruff>=0.9.0; extra == 'dev'
42
+ Requires-Dist: ty>=0.0.16; extra == 'dev'
43
+ Provides-Extra: django
44
+ Requires-Dist: django-sendparcel>=0.1.0; extra == 'django'
45
+ Provides-Extra: dpdpl
46
+ Requires-Dist: python-sendparcel-dpdpl>=0.1.0; extra == 'dpdpl'
47
+ Provides-Extra: dummy
48
+ Provides-Extra: fastapi
49
+ Requires-Dist: fastapi-sendparcel>=0.1.0; extra == 'fastapi'
50
+ Provides-Extra: frameworks
51
+ Requires-Dist: django-sendparcel>=0.1.0; extra == 'frameworks'
52
+ Requires-Dist: fastapi-sendparcel>=0.1.0; extra == 'frameworks'
53
+ Requires-Dist: litestar-sendparcel>=0.1.0; extra == 'frameworks'
54
+ Provides-Extra: inpost
55
+ Requires-Dist: python-sendparcel-inpost>=0.1.0; extra == 'inpost'
56
+ Provides-Extra: litestar
57
+ Requires-Dist: litestar-sendparcel>=0.1.0; extra == 'litestar'
58
+ Provides-Extra: providers
59
+ Requires-Dist: python-sendparcel-dpdpl>=0.1.0; extra == 'providers'
60
+ Requires-Dist: python-sendparcel-inpost>=0.1.0; extra == 'providers'
61
+ Description-Content-Type: text/markdown
62
+
63
+ # python-sendparcel
64
+
65
+ Framework-agnostic parcel shipping core for Python.
66
+
67
+ > Alpha notice: `0.1.1` is still unstable. The API can change fast because the
68
+ > ecosystem is still being cleaned up.
69
+
70
+ ## What it is
71
+
72
+ - Provider-agnostic shipment orchestration with a single core flow.
73
+ - Explicit shipment metadata persistence: `id`, `status`, `provider`, `external_id`, `tracking_number`.
74
+ - Label payloads are operation results, not persisted shipment fields.
75
+ - One normalized provider update contract for both callbacks and polling.
76
+ - Runtime-checkable `Shipment` and `ShipmentRepository` protocols so adapters can use their own models.
77
+
78
+ ## Core contract
79
+
80
+ - `ShipmentFlow.create_shipment(...) -> CreateShipmentOutcome`
81
+ - `ShipmentFlow.create_label(...) -> CreateLabelOutcome`
82
+ - `ShipmentFlow.handle_callback(...) -> ShipmentUpdateOutcome`
83
+ - `ShipmentFlow.fetch_and_update_status(...) -> ShipmentUpdateOutcome`
84
+ - `ShipmentFlow.cancel_shipment(...) -> bool`
85
+
86
+ `CreateShipmentOutcome` and `CreateLabelOutcome` return label payloads when available.
87
+ The shipment object never stores label bytes or a persisted `label_url` in the core contract.
88
+
89
+ ## Quick start
90
+
91
+ ```python
92
+ from dataclasses import dataclass
93
+ from decimal import Decimal
94
+
95
+ import anyio
96
+
97
+ from sendparcel import ShipmentFlow
98
+ from sendparcel.types import AddressInfo, ParcelInfo
99
+
100
+
101
+ @dataclass
102
+ class MyShipment:
103
+ id: str
104
+ status: str = "new"
105
+ provider: str = ""
106
+ external_id: str = ""
107
+ tracking_number: str = ""
108
+
109
+
110
+ class InMemoryRepository:
111
+ def __init__(self) -> None:
112
+ self._store: dict[str, MyShipment] = {}
113
+ self._counter = 0
114
+
115
+ async def get_by_id(self, shipment_id: str) -> MyShipment:
116
+ return self._store[shipment_id]
117
+
118
+ async def create(self, **kwargs) -> MyShipment:
119
+ self._counter += 1
120
+ shipment = MyShipment(
121
+ id=str(self._counter),
122
+ status=str(kwargs.get("status", "new")),
123
+ provider=str(kwargs.get("provider", "")),
124
+ )
125
+ self._store[shipment.id] = shipment
126
+ return shipment
127
+
128
+ async def save(self, shipment: MyShipment) -> MyShipment:
129
+ self._store[shipment.id] = shipment
130
+ return shipment
131
+
132
+
133
+ async def main() -> None:
134
+ flow = ShipmentFlow(repository=InMemoryRepository())
135
+
136
+ created = await flow.create_shipment(
137
+ "dummy",
138
+ sender_address=AddressInfo(
139
+ name="Sender Co.",
140
+ line1="Marszalkowska 1",
141
+ city="Warsaw",
142
+ postal_code="00-001",
143
+ country_code="PL",
144
+ ),
145
+ receiver_address=AddressInfo(
146
+ name="Jan Kowalski",
147
+ line1="Dluga 10",
148
+ city="Gdansk",
149
+ postal_code="80-001",
150
+ country_code="PL",
151
+ ),
152
+ parcels=[ParcelInfo(weight_kg=Decimal("2.5"))],
153
+ )
154
+
155
+ print(created.shipment.status)
156
+ print(created.shipment.external_id)
157
+ print(created.shipment.tracking_number)
158
+
159
+ labelled = await flow.create_label(created.shipment)
160
+ print(labelled.label.get("url"))
161
+
162
+
163
+ anyio.run(main)
164
+ ```
165
+
166
+ ## Provider model
167
+
168
+ - `BaseProvider.create_shipment(...)` returns `ShipmentCreateResult`.
169
+ - `BaseProvider.confirmation_method` defaults to `ConfirmationMethod.NONE`.
170
+ - `LabelProvider.create_label(...)` returns `LabelInfo`.
171
+ - `PushCallbackProvider.handle_callback(...)` returns `ShipmentUpdateResult`.
172
+ - `PullStatusProvider.fetch_shipment_status(...)` returns `ShipmentUpdateResult`.
173
+ - `CancellableProvider.cancel_shipment(...)` returns `bool`.
174
+
175
+ Use `ConfirmationMethod.PUSH` only with `PushCallbackProvider` and
176
+ `ConfirmationMethod.PULL` only with `PullStatusProvider`.
177
+
178
+ The core owns shipment state transitions. Providers translate carrier responses
179
+ into normalized results.
180
+
181
+ ## Installation
182
+
183
+ ```bash
184
+ pip install python-sendparcel
185
+ ```
186
+
187
+ With `uv`:
188
+
189
+ ```bash
190
+ uv add python-sendparcel
191
+ ```
192
+
193
+ ## Extras
194
+
195
+ - `django`
196
+ - `fastapi`
197
+ - `litestar`
198
+ - `inpost`
199
+ - `dpdpl`
200
+ - `cli`
201
+ - `frameworks`
202
+ - `providers`
203
+ - `all`
204
+
205
+ ## Development
206
+
207
+ ```bash
208
+ uv sync --extra dev
209
+ uv run pytest
210
+ uv run ruff check src tests
211
+ uv run mypy src tests
212
+ ```
@@ -0,0 +1,17 @@
1
+ sendparcel/__init__.py,sha256=laO3AFTeFkq_XiqzIvFWMZPblm35Rt51tKteNrNgh9Y,1314
2
+ sendparcel/enums.py,sha256=9tg9eMD5QpHOTNfsedncYQqfkxW_IXVcFfgJL6dCD1o,659
3
+ sendparcel/exceptions.py,sha256=zC5_o9oQfNuOBKaiw5csaHAJmhYsZc3WVbOaoq7s1W0,1437
4
+ sendparcel/flow.py,sha256=gLQuIJXxde_Ta4oseBJvyjCNc80YPq4g1tK4KwnOv90,6940
5
+ sendparcel/fsm.py,sha256=TbKkDNEjqWPDBDdnzvGWEaUtTcEXPJPpcZdSkuqIquI,3147
6
+ sendparcel/protocols.py,sha256=y4TQb0p0z0WO39KCqwiCU0uTi4pXbvgRMbB_LsIPvJ8,689
7
+ sendparcel/provider.py,sha256=6f6Z3hmLNrN2t-pulVAFEPrZnNttX1kmK6ATCnqdZD4,2690
8
+ sendparcel/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ sendparcel/registry.py,sha256=lVWpNZuXISC2c12GC7fvk-fULHVQ6exFvMqv5ilswg8,2700
10
+ sendparcel/types.py,sha256=afPuER1CslkvcTMMogY0EVYrTjUsA36gPxVQguYtIBg,2201
11
+ sendparcel/validators.py,sha256=i_y-edT1hgS_2HeRdARlFaQXiL9sYAzWVsjYPVBqRDc,589
12
+ sendparcel/providers/__init__.py,sha256=ff82dvrWuPS-UIGF5AoX_fgpcYZaGu2k6q2yrxMZacI,192
13
+ sendparcel/providers/dummy.py,sha256=sbXDtPel3DEH_FIN9_gX2xXBLEqZ7Pto3CP_JMcsaEM,3102
14
+ python_sendparcel-0.1.1.dist-info/METADATA,sha256=-vbQtZxuDuTQeZLa40hfZlqEJefWJAuC-IsnGqt33vc,6888
15
+ python_sendparcel-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
16
+ python_sendparcel-0.1.1.dist-info/licenses/LICENSE,sha256=IZXSBOjgGvChgayLmtTnU40iE7hsrrU3WVEYKx0sywY,1075
17
+ python_sendparcel-0.1.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.28.0
2
+ Generator: hatchling 1.29.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
sendparcel/__init__.py CHANGED
@@ -1,29 +1,54 @@
1
1
  """sendparcel core package."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.1"
4
4
 
5
- from sendparcel.enums import ConfirmationMethod, ShipmentStatus
5
+ from sendparcel.enums import ConfirmationMethod, LabelFormat, ShipmentStatus
6
6
  from sendparcel.exceptions import (
7
7
  CommunicationError,
8
8
  InvalidCallbackError,
9
9
  InvalidTransitionError,
10
+ ProviderCapabilityError,
11
+ ProviderNotFoundError,
10
12
  SendParcelException,
13
+ ShipmentNotFoundError,
11
14
  )
12
15
  from sendparcel.flow import ShipmentFlow
13
- from sendparcel.provider import BaseProvider
16
+ from sendparcel.provider import (
17
+ BaseProvider,
18
+ CancellableProvider,
19
+ LabelProvider,
20
+ PullStatusProvider,
21
+ PushCallbackProvider,
22
+ )
14
23
  from sendparcel.providers.dummy import DummyProvider
15
24
  from sendparcel.registry import registry
25
+ from sendparcel.types import (
26
+ CreateLabelOutcome,
27
+ CreateShipmentOutcome,
28
+ ShipmentUpdateOutcome,
29
+ )
16
30
 
17
31
  __all__ = [
18
32
  "BaseProvider",
33
+ "CancellableProvider",
19
34
  "CommunicationError",
20
35
  "ConfirmationMethod",
36
+ "CreateLabelOutcome",
37
+ "CreateShipmentOutcome",
21
38
  "DummyProvider",
22
39
  "InvalidCallbackError",
23
40
  "InvalidTransitionError",
41
+ "LabelFormat",
42
+ "LabelProvider",
43
+ "ProviderCapabilityError",
44
+ "ProviderNotFoundError",
45
+ "PullStatusProvider",
46
+ "PushCallbackProvider",
24
47
  "SendParcelException",
25
48
  "ShipmentFlow",
49
+ "ShipmentNotFoundError",
26
50
  "ShipmentStatus",
51
+ "ShipmentUpdateOutcome",
27
52
  "__version__",
28
53
  "registry",
29
54
  ]
sendparcel/enums.py CHANGED
@@ -17,8 +17,18 @@ class ShipmentStatus(StrEnum):
17
17
  RETURNED = "returned"
18
18
 
19
19
 
20
+ class LabelFormat(StrEnum):
21
+ """Shipping label format."""
22
+
23
+ PDF = "PDF"
24
+ ZPL = "ZPL"
25
+ PNG = "PNG"
26
+ EPL = "EPL"
27
+
28
+
20
29
  class ConfirmationMethod(StrEnum):
21
30
  """How the provider confirms shipment status updates."""
22
31
 
32
+ NONE = "NONE"
23
33
  PUSH = "PUSH"
24
34
  PULL = "PULL"
sendparcel/exceptions.py CHANGED
@@ -1,10 +1,16 @@
1
1
  """Exception hierarchy for sendparcel."""
2
2
 
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
3
7
 
4
8
  class SendParcelException(Exception):
5
9
  """Base exception for sendparcel."""
6
10
 
7
- def __init__(self, message: str = "", context: dict | None = None) -> None:
11
+ def __init__(
12
+ self, message: str = "", context: dict[str, Any] | None = None
13
+ ) -> None:
8
14
  super().__init__(message)
9
15
  self.context = context or {}
10
16
 
@@ -19,3 +25,29 @@ class InvalidCallbackError(SendParcelException):
19
25
 
20
26
  class InvalidTransitionError(SendParcelException):
21
27
  """Invalid shipment state transition requested."""
28
+
29
+
30
+ class ShipmentNotFoundError(SendParcelException):
31
+ """Shipment not found in repository."""
32
+
33
+ def __init__(
34
+ self, shipment_id: str, context: dict[str, Any] | None = None
35
+ ) -> None:
36
+ super().__init__(f"Shipment {shipment_id} not found", context)
37
+ self.shipment_id = shipment_id
38
+
39
+
40
+ class ProviderNotFoundError(SendParcelException):
41
+ """Provider slug not found in the registry."""
42
+
43
+ def __init__(
44
+ self, provider_slug: str, context: dict[str, Any] | None = None
45
+ ) -> None:
46
+ super().__init__(
47
+ f"Provider {provider_slug!r} is not registered", context
48
+ )
49
+ self.provider_slug = provider_slug
50
+
51
+
52
+ class ProviderCapabilityError(SendParcelException):
53
+ """Provider does not support the requested capability."""
sendparcel/flow.py CHANGED
@@ -1,19 +1,34 @@
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.types import (
25
+ AddressInfo,
26
+ CreateLabelOutcome,
27
+ CreateShipmentOutcome,
28
+ ParcelInfo,
29
+ ShipmentUpdateOutcome,
30
+ ShipmentUpdateResult,
14
31
  )
15
- from sendparcel.protocols import Order, Shipment, ShipmentRepository
16
- from sendparcel.registry import registry
17
32
  from sendparcel.validators import run_validators
18
33
 
19
34
 
@@ -23,100 +38,145 @@ class ShipmentFlow:
23
38
  def __init__(
24
39
  self,
25
40
  repository: ShipmentRepository,
26
- config: dict | None = None,
27
- validators: list | None = None,
41
+ config: dict[str, Any] | None = None,
42
+ validators: list[Any] | None = None,
43
+ registry: Any = None,
28
44
  ) -> None:
29
45
  self.repository = repository
30
46
  self.config = config or {}
31
47
  self.validators = validators or []
48
+ from sendparcel.registry import registry as default_registry
49
+
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)
55
+ *,
56
+ sender_address: AddressInfo,
57
+ receiver_address: AddressInfo,
58
+ parcels: list[ParcelInfo],
59
+ **kwargs: Any,
60
+ ) -> CreateShipmentOutcome:
61
+ """Create a shipment record with explicit address and parcel data."""
62
+
63
+ self.registry.get_by_slug(provider_slug)
41
64
  shipment = await self.repository.create(
42
- order=order,
43
65
  provider=provider_slug,
44
- status=ShipmentStatus.NEW,
66
+ status=ShipmentStatus.NEW.value,
45
67
  **kwargs,
46
68
  )
47
- create_shipment_machine(shipment)
48
69
  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)
70
+ result = await self._call_provider(
71
+ provider.create_shipment(
72
+ sender_address=sender_address,
73
+ receiver_address=receiver_address,
74
+ parcels=parcels,
75
+ **kwargs,
76
+ )
77
+ )
78
+ shipment.external_id = str(result.get("external_id", ""))
79
+ shipment.tracking_number = str(result.get("tracking_number", ""))
80
+ transition_shipment(shipment, ShipmentStatus.CREATED)
81
+ label = result.get("label")
82
+ if label is not None:
83
+ transition_shipment(shipment, ShipmentStatus.LABEL_READY)
84
+ saved = await self.repository.save(shipment)
85
+ return CreateShipmentOutcome(shipment=saved, label=label)
86
+
87
+ async def create_label(
88
+ self, shipment: Shipment, **kwargs: Any
89
+ ) -> CreateLabelOutcome:
90
+ """Create provider label and persist shipment metadata."""
60
91
 
61
- async def create_label(self, shipment: Shipment, **kwargs) -> Shipment:
62
- """Create provider label and persist shipment."""
63
92
  run_validators({"shipment": shipment}, validators=self.validators)
64
- create_shipment_machine(shipment)
65
93
  provider = self._get_provider(shipment)
94
+ if not isinstance(provider, LabelProvider):
95
+ raise ProviderCapabilityError(
96
+ f"Provider {shipment.provider!r} does not support "
97
+ "label creation"
98
+ )
66
99
  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)
100
+ transition_shipment(shipment, ShipmentStatus.LABEL_READY)
101
+ saved = await self.repository.save(shipment)
102
+ return CreateLabelOutcome(shipment=saved, label=label)
71
103
 
72
104
  async def handle_callback(
73
105
  self,
74
106
  shipment: Shipment,
75
- data: dict,
76
- headers: dict,
77
- **kwargs,
78
- ) -> Shipment:
107
+ data: dict[str, Any],
108
+ headers: dict[str, Any],
109
+ **kwargs: Any,
110
+ ) -> ShipmentUpdateOutcome:
79
111
  """Verify and apply provider callback."""
112
+
80
113
  provider = self._get_provider(shipment)
81
- create_shipment_machine(shipment)
114
+ if not isinstance(provider, PushCallbackProvider):
115
+ raise ProviderCapabilityError(
116
+ f"Provider {shipment.provider!r} does not support "
117
+ "push callbacks"
118
+ )
82
119
  await self._call_provider(
83
120
  provider.verify_callback(data, headers, **kwargs)
84
121
  )
85
- await self._call_provider(
122
+ update = await self._call_provider(
86
123
  provider.handle_callback(data, headers, **kwargs)
87
124
  )
88
- return await self.repository.save(shipment)
125
+ normalized_update = update or ShipmentUpdateResult()
126
+ saved = await self._apply_update(shipment, normalized_update)
127
+ return ShipmentUpdateOutcome(shipment=saved, update=normalized_update)
89
128
 
90
- async def fetch_and_update_status(self, shipment: Shipment) -> Shipment:
129
+ async def fetch_and_update_status(
130
+ self, shipment: Shipment
131
+ ) -> ShipmentUpdateOutcome:
91
132
  """Fetch status from provider and persist."""
133
+
92
134
  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)
135
+ if not isinstance(provider, PullStatusProvider):
136
+ raise ProviderCapabilityError(
137
+ f"Provider {shipment.provider!r} does not support "
138
+ "status polling"
139
+ )
140
+ update = await self._call_provider(provider.fetch_shipment_status())
141
+ normalized_update = update or ShipmentUpdateResult()
142
+ saved = await self._apply_update(shipment, normalized_update)
143
+ return ShipmentUpdateOutcome(shipment=saved, update=normalized_update)
100
144
 
101
- async def cancel_shipment(self, shipment: Shipment, **kwargs) -> bool:
145
+ async def cancel_shipment(self, shipment: Shipment, **kwargs: Any) -> bool:
102
146
  """Cancel shipment via provider and persist state."""
147
+
103
148
  provider = self._get_provider(shipment)
104
- create_shipment_machine(shipment)
149
+ if not isinstance(provider, CancellableProvider):
150
+ raise ProviderCapabilityError(
151
+ f"Provider {shipment.provider!r} does not support cancellation"
152
+ )
105
153
  cancelled = await self._call_provider(
106
154
  provider.cancel_shipment(**kwargs)
107
155
  )
108
156
  if cancelled:
109
- self._trigger(shipment, "cancel")
157
+ transition_shipment(shipment, ShipmentStatus.CANCELLED)
110
158
  await self.repository.save(shipment)
111
- return cancelled
159
+ return bool(cancelled)
112
160
 
113
- def _get_provider(self, shipment: Shipment):
114
- provider_class = registry.get_by_slug(shipment.provider)
161
+ def _get_provider(self, shipment: Shipment) -> BaseProvider:
162
+ provider_class = self.registry.get_by_slug(shipment.provider)
115
163
  provider_config = self.config.get(shipment.provider, {})
116
164
  return provider_class(shipment, config=provider_config)
117
165
 
118
- async def _call_provider(self, coro):
166
+ async def _apply_update(
167
+ self, shipment: Shipment, update: ShipmentUpdateResult
168
+ ) -> Shipment:
169
+ tracking_number = update.get("tracking_number")
170
+ if tracking_number:
171
+ shipment.tracking_number = str(tracking_number)
172
+ status = update.get("status")
173
+ if status is not None:
174
+ transition_shipment(shipment, status)
175
+ return await self.repository.save(shipment)
176
+
177
+ async def _call_provider(self, coro: Any) -> Any:
119
178
  """Call a provider coroutine, wrapping non-domain errors."""
179
+
120
180
  try:
121
181
  return await coro
122
182
  except SendParcelException:
@@ -131,34 +191,3 @@ class ShipmentFlow:
131
191
  str(exc),
132
192
  context={"original_error": type(exc).__name__},
133
193
  ) 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)