python-sendparcel-inpost 0.2.0__py3-none-any.whl → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-sendparcel-inpost
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: InPost ShipX provider for python-sendparcel.
5
5
  Project-URL: Homepage, https://github.com/python-sendparcel/python-sendparcel-inpost
6
6
  Project-URL: Repository, https://github.com/python-sendparcel/python-sendparcel-inpost
@@ -22,7 +22,7 @@ Classifier: Typing :: Typed
22
22
  Requires-Python: >=3.12
23
23
  Requires-Dist: anyio>=4.0
24
24
  Requires-Dist: httpx>=0.27.0
25
- Requires-Dist: python-sendparcel>=0.1.1
25
+ Requires-Dist: python-sendparcel>=0.3.0
26
26
  Provides-Extra: dev
27
27
  Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
28
28
  Requires-Dist: pytest-cov>=5.0; extra == 'dev'
@@ -0,0 +1,17 @@
1
+ sendparcel_inpost/__init__.py,sha256=Tkz5IeAveOLj_1CtR8xKfuon2URY9rst-qsKY66yLqI,548
2
+ sendparcel_inpost/circuit_breaker.py,sha256=RZ6Sry63Jh9y7OdUCdXACxOIIfkgyiGggFG7h34DPCA,4939
3
+ sendparcel_inpost/client.py,sha256=jftpFhvmcQDmpkAm8Bac0tqW5w4IxoC1frB9nso-Mqs,14920
4
+ sendparcel_inpost/exceptions.py,sha256=E79mErjpni41_U9A2Ql2Cw4yAKd82cpguRPwNiamg2Y,2142
5
+ sendparcel_inpost/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ sendparcel_inpost/rate_limiter.py,sha256=sct5bYofsdKGLcGrCUXWeciyGIq_yr332kP7oLpTCZE,1870
7
+ sendparcel_inpost/status_mapping.py,sha256=9UDU8XFCWKfKXPyWTV9E_dzgExdZjgZrBkcWUYj2smI,2782
8
+ sendparcel_inpost/types.py,sha256=Pu-OJpC6te4_gY6PLv-Ag0o2IoulYytdr9t0FGf4GIA,1426
9
+ sendparcel_inpost/providers/__init__.py,sha256=akn431q6mekenZV5Y4wjCh2UkLSi2s51umBHZ37orJg,336
10
+ sendparcel_inpost/providers/base.py,sha256=7uQ8e0K0NPHOQ1-JM9XGyc5OzWd3OKUO_Goqm65sL0M,13952
11
+ sendparcel_inpost/providers/courier.py,sha256=gdjZmoBlLTQa73x_JRameJQYCg2F9WhBwt2S-_0r2UU,2592
12
+ sendparcel_inpost/providers/locker.py,sha256=mzn5WvnCi7lLWPoWW7NaHMty-vb4gzFSpgb9ucCHV0o,2867
13
+ python_sendparcel_inpost-0.3.0.dist-info/METADATA,sha256=IRnCMaIs50L-d62gMvbzSd848VITd2JHIeo8_TUqPMM,3446
14
+ python_sendparcel_inpost-0.3.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
15
+ python_sendparcel_inpost-0.3.0.dist-info/entry_points.txt,sha256=XuQdmE0LIuIc3TvGPWnYjoB14zXCEof6Q8h1tMp2arE,170
16
+ python_sendparcel_inpost-0.3.0.dist-info/licenses/LICENSE,sha256=IZXSBOjgGvChgayLmtTnU40iE7hsrrU3WVEYKx0sywY,1075
17
+ python_sendparcel_inpost-0.3.0.dist-info/RECORD,,
@@ -1,9 +1,10 @@
1
1
  """InPost ShipX provider for python-sendparcel."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
4
4
 
5
5
  from sendparcel_inpost.client import ShipXClient
6
6
  from sendparcel_inpost.exceptions import CircuitBreakerError
7
+ from sendparcel_inpost.providers.base import aclose_transports
7
8
  from sendparcel_inpost.providers.courier import InPostCourierProvider
8
9
  from sendparcel_inpost.providers.locker import InPostLockerProvider
9
10
 
@@ -13,4 +14,5 @@ __all__ = [
13
14
  "InPostLockerProvider",
14
15
  "ShipXClient",
15
16
  "__version__",
17
+ "aclose_transports",
16
18
  ]
@@ -0,0 +1,155 @@
1
+ """Circuit breaker and metrics for external API resilience."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+
11
+ @dataclass
12
+ class ShipXMetrics:
13
+ """Collects metrics for a single ShipXClient instance.
14
+
15
+ Thread-safe. Use ``snapshot()`` to get a point-in-time view.
16
+ """
17
+
18
+ _lock: threading.Lock = field(default_factory=threading.Lock)
19
+ _request_count: int = 0
20
+ _error_count: int = 0
21
+ _circuit_trip_count: int = 0
22
+ _retry_count: int = 0
23
+ _total_latency_ms: float = 0.0
24
+ _max_latency_ms: float = 0.0
25
+ _last_error: str | None = None
26
+ _last_error_at: float | None = None
27
+ _circuit_state: str = "closed"
28
+
29
+ @property
30
+ def circuit_state(self) -> str:
31
+ with self._lock:
32
+ return self._circuit_state
33
+
34
+ def set_circuit_state(self, state: str) -> None:
35
+ with self._lock:
36
+ self._circuit_state = state
37
+
38
+ def record_request(
39
+ self,
40
+ latency_ms: float,
41
+ success: bool,
42
+ error: str | None = None,
43
+ ) -> None:
44
+ with self._lock:
45
+ self._request_count += 1
46
+ self._total_latency_ms += latency_ms
47
+ if latency_ms > self._max_latency_ms:
48
+ self._max_latency_ms = latency_ms
49
+ if not success:
50
+ self._error_count += 1
51
+ self._last_error = error
52
+ self._last_error_at = time.time()
53
+
54
+ def record_circuit_trip(self) -> None:
55
+ with self._lock:
56
+ self._circuit_trip_count += 1
57
+
58
+ def record_retry(self) -> None:
59
+ with self._lock:
60
+ self._retry_count += 1
61
+
62
+ def snapshot(self) -> dict[str, Any]:
63
+ """Return a point-in-time copy of all metrics."""
64
+ with self._lock:
65
+ avg_latency = (
66
+ self._total_latency_ms / self._request_count
67
+ if self._request_count > 0
68
+ else 0.0
69
+ )
70
+ return {
71
+ "request_count": self._request_count,
72
+ "error_count": self._error_count,
73
+ "retry_count": self._retry_count,
74
+ "circuit_trip_count": self._circuit_trip_count,
75
+ "circuit_state": self._circuit_state,
76
+ "avg_latency_ms": round(avg_latency, 2),
77
+ "max_latency_ms": round(self._max_latency_ms, 2),
78
+ "last_error": self._last_error,
79
+ "last_error_at": self._last_error_at,
80
+ }
81
+
82
+
83
+ class _CircuitBreaker:
84
+ """Simple circuit breaker for the ShipX API.
85
+
86
+ States:
87
+ - CLOSED: normal operation, requests pass through
88
+ - OPEN: circuit tripped, requests fail fast
89
+ - HALF_OPEN: one probe request allowed after cooldown
90
+
91
+ Transitions:
92
+ - CLOSED → OPEN: after `threshold` consecutive failures
93
+ - OPEN → HALF_OPEN: after `cooldown` seconds
94
+ - HALF_OPEN → CLOSED: probe request succeeds
95
+ - HALF_OPEN → OPEN: probe request fails
96
+ """
97
+
98
+ class State:
99
+ CLOSED = "closed"
100
+ OPEN = "open"
101
+ HALF_OPEN = "half_open"
102
+
103
+ def __init__(
104
+ self,
105
+ threshold: int = 5,
106
+ cooldown: float = 30.0,
107
+ ) -> None:
108
+ self._threshold = threshold
109
+ self._cooldown = cooldown
110
+ self._state = self.State.CLOSED
111
+ self._failures = 0
112
+ self._opened_at: float | None = None
113
+
114
+ @property
115
+ def state(self) -> str:
116
+ """Current circuit state, transitioning OPEN → HALF_OPEN on cooldown."""
117
+ if self._state == self.State.OPEN and self._opened_at is not None:
118
+ elapsed = time.monotonic() - self._opened_at
119
+ if elapsed >= self._cooldown:
120
+ self._state = self.State.HALF_OPEN
121
+ return self._state
122
+
123
+ @property
124
+ def failures(self) -> int:
125
+ return self._failures
126
+
127
+ @property
128
+ def cooldown_remaining(self) -> float:
129
+ if self._state != self.State.OPEN or self._opened_at is None:
130
+ return 0.0
131
+ elapsed = time.monotonic() - self._opened_at
132
+ return max(0.0, self._cooldown - elapsed)
133
+
134
+ def allow_request(self) -> bool:
135
+ """Check if a request should be allowed through."""
136
+ current = self.state # triggers OPEN → HALF_OPEN transition
137
+ return current != self.State.OPEN
138
+
139
+ def record_success(self) -> None:
140
+ """Record a successful request."""
141
+ self._failures = 0
142
+ self._state = self.State.CLOSED
143
+ self._opened_at = None
144
+
145
+ def record_failure(self) -> None:
146
+ """Record a failed request."""
147
+ self._failures += 1
148
+ if self.state == self.State.HALF_OPEN:
149
+ # Probe failed — reopen circuit
150
+ self._state = self.State.OPEN
151
+ self._opened_at = time.monotonic()
152
+ elif self._failures >= self._threshold:
153
+ # Threshold reached — open circuit
154
+ self._state = self.State.OPEN
155
+ self._opened_at = time.monotonic()
@@ -2,99 +2,26 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import asyncio
6
- import atexit
7
5
  import logging
8
- import threading
9
6
  import time
10
- from dataclasses import dataclass, field
11
7
  from types import TracebackType
12
8
  from typing import Any, cast
13
9
 
10
+ import anyio
14
11
  import httpx
15
12
 
13
+ from sendparcel_inpost.circuit_breaker import ShipXMetrics, _CircuitBreaker
16
14
  from sendparcel_inpost.exceptions import (
17
15
  CircuitBreakerError,
18
16
  ShipXAPIError,
19
17
  ShipXAuthenticationError,
20
18
  ShipXValidationError,
21
19
  )
20
+ from sendparcel_inpost.rate_limiter import _TokenBucket
22
21
 
23
22
  logger = logging.getLogger(__name__)
24
23
 
25
24
 
26
- @dataclass
27
- class ShipXMetrics:
28
- """Collects metrics for a single ShipXClient instance.
29
-
30
- Thread-safe. Use ``snapshot()`` to get a point-in-time view.
31
- """
32
-
33
- _lock: threading.Lock = field(default_factory=threading.Lock)
34
- _request_count: int = 0
35
- _error_count: int = 0
36
- _circuit_trip_count: int = 0
37
- _retry_count: int = 0
38
- _total_latency_ms: float = 0.0
39
- _max_latency_ms: float = 0.0
40
- _last_error: str | None = None
41
- _last_error_at: float | None = None
42
- _circuit_state: str = "closed"
43
-
44
- @property
45
- def circuit_state(self) -> str:
46
- with self._lock:
47
- return self._circuit_state
48
-
49
- def set_circuit_state(self, state: str) -> None:
50
- with self._lock:
51
- self._circuit_state = state
52
-
53
- def record_request(
54
- self,
55
- latency_ms: float,
56
- success: bool,
57
- error: str | None = None,
58
- ) -> None:
59
- with self._lock:
60
- self._request_count += 1
61
- self._total_latency_ms += latency_ms
62
- if latency_ms > self._max_latency_ms:
63
- self._max_latency_ms = latency_ms
64
- if not success:
65
- self._error_count += 1
66
- self._last_error = error
67
- self._last_error_at = time.time()
68
-
69
- def record_circuit_trip(self) -> None:
70
- with self._lock:
71
- self._circuit_trip_count += 1
72
-
73
- def record_retry(self) -> None:
74
- with self._lock:
75
- self._retry_count += 1
76
-
77
- def snapshot(self) -> dict[str, Any]:
78
- """Return a point-in-time copy of all metrics."""
79
- with self._lock:
80
- avg_latency = (
81
- self._total_latency_ms / self._request_count
82
- if self._request_count > 0
83
- else 0.0
84
- )
85
- return {
86
- "request_count": self._request_count,
87
- "error_count": self._error_count,
88
- "retry_count": self._retry_count,
89
- "circuit_trip_count": self._circuit_trip_count,
90
- "circuit_state": self._circuit_state,
91
- "avg_latency_ms": round(avg_latency, 2),
92
- "max_latency_ms": round(self._max_latency_ms, 2),
93
- "last_error": self._last_error,
94
- "last_error_at": self._last_error_at,
95
- }
96
-
97
-
98
25
  PRODUCTION_BASE_URL = "https://api-shipx-pl.easypack24.net"
99
26
  SANDBOX_BASE_URL = "https://sandbox-api-shipx-pl.easypack24.net"
100
27
 
@@ -107,250 +34,6 @@ DEFAULT_CIRCUIT_THRESHOLD = 5 # consecutive failures to open circuit
107
34
  DEFAULT_CIRCUIT_COOLDOWN = 30.0 # seconds before allowing probe request
108
35
 
109
36
 
110
- class _TokenBucket:
111
- """Async token bucket rate limiter.
112
-
113
- Tokens are added at a fixed rate up to a maximum burst size.
114
- Each request consumes one token. If no tokens are available,
115
- the caller waits until a token is refilled.
116
- """
117
-
118
- __slots__ = ("_burst", "_last_refill", "_lock", "_rate", "_tokens")
119
-
120
- def __init__(
121
- self,
122
- rate: float,
123
- burst: int,
124
- ) -> None:
125
- self._rate = rate
126
- self._burst = burst
127
- self._tokens = float(burst)
128
- self._last_refill: float | None = None
129
- self._lock = asyncio.Lock()
130
-
131
- async def acquire(self) -> None:
132
- """Acquire a token, waiting if necessary.
133
-
134
- Tokens are refilled based on elapsed wall-clock time up to the
135
- burst limit. When no tokens are available the caller waits the
136
- exact duration needed for one token to become available — sleep
137
- time is NOT counted toward refill to prevent burst above limits.
138
- """
139
- async with self._lock:
140
- now = asyncio.get_event_loop().time()
141
- if self._last_refill is None:
142
- self._last_refill = now
143
- elapsed = now - self._last_refill
144
- self._tokens = min(self._burst, self._tokens + elapsed * self._rate)
145
- self._last_refill = now
146
-
147
- if self._tokens < 1.0:
148
- wait_time = (1.0 - self._tokens) / self._rate
149
- await asyncio.sleep(wait_time)
150
- # Consume the token and advance last_refill so that
151
- # subsequent calls don't double-count the sleep.
152
- self._last_refill = asyncio.get_event_loop().time()
153
- self._tokens -= 1.0
154
- else:
155
- self._tokens -= 1.0
156
-
157
-
158
- class _CircuitBreaker:
159
- """Simple circuit breaker for the ShipX API.
160
-
161
- States:
162
- - CLOSED: normal operation, requests pass through
163
- - OPEN: circuit tripped, requests fail fast
164
- - HALF_OPEN: one probe request allowed after cooldown
165
-
166
- Transitions:
167
- - CLOSED → OPEN: after `threshold` consecutive failures
168
- - OPEN → HALF_OPEN: after `cooldown` seconds
169
- - HALF_OPEN → CLOSED: probe request succeeds
170
- - HALF_OPEN → OPEN: probe request fails
171
- """
172
-
173
- class State:
174
- CLOSED = "closed"
175
- OPEN = "open"
176
- HALF_OPEN = "half_open"
177
-
178
- def __init__(
179
- self,
180
- threshold: int = 5,
181
- cooldown: float = 30.0,
182
- ) -> None:
183
- self._threshold = threshold
184
- self._cooldown = cooldown
185
- self._state = self.State.CLOSED
186
- self._failures = 0
187
- self._opened_at: float | None = None
188
-
189
- @property
190
- def state(self) -> str:
191
- """Current circuit state, transitioning OPEN → HALF_OPEN on cooldown."""
192
- if self._state == self.State.OPEN and self._opened_at is not None:
193
- elapsed = asyncio.get_event_loop().time() - self._opened_at
194
- if elapsed >= self._cooldown:
195
- self._state = self.State.HALF_OPEN
196
- return self._state
197
-
198
- @property
199
- def failures(self) -> int:
200
- return self._failures
201
-
202
- @property
203
- def cooldown_remaining(self) -> float:
204
- if self._state != self.State.OPEN or self._opened_at is None:
205
- return 0.0
206
- elapsed = asyncio.get_event_loop().time() - self._opened_at
207
- return max(0.0, self._cooldown - elapsed)
208
-
209
- def allow_request(self) -> bool:
210
- """Check if a request should be allowed through."""
211
- current = self.state # triggers OPEN → HALF_OPEN transition
212
- return current != self.State.OPEN
213
-
214
- def record_success(self) -> None:
215
- """Record a successful request."""
216
- self._failures = 0
217
- self._state = self.State.CLOSED
218
- self._opened_at = None
219
-
220
- def record_failure(self) -> None:
221
- """Record a failed request."""
222
- self._failures += 1
223
- if self.state == self.State.HALF_OPEN:
224
- # Probe failed — reopen circuit
225
- self._state = self.State.OPEN
226
- self._opened_at = asyncio.get_event_loop().time()
227
- elif self._failures >= self._threshold:
228
- # Threshold reached — open circuit
229
- self._state = self.State.OPEN
230
- self._opened_at = asyncio.get_event_loop().time()
231
-
232
-
233
- class _ClientPool:
234
- """Thread-safe pool of shared ShipXClient instances.
235
-
236
- Clients are keyed by (token, organization_id, sandbox, base_url,
237
- timeout, rate_limit, burst, max_retries, retry_base_delay,
238
- circuit_threshold, circuit_cooldown).
239
- Multiple providers with the same credentials share a single client.
240
- """
241
-
242
- def __init__(self) -> None:
243
- self._clients: dict[
244
- tuple[
245
- str,
246
- int,
247
- bool,
248
- str | None,
249
- float,
250
- float,
251
- int,
252
- int,
253
- float,
254
- int,
255
- float,
256
- ],
257
- ShipXClient,
258
- ] = {}
259
- self._lock = threading.Lock()
260
-
261
- def get(
262
- self,
263
- token: str,
264
- organization_id: int,
265
- *,
266
- sandbox: bool = False,
267
- base_url: str | None = None,
268
- timeout: float = DEFAULT_TIMEOUT,
269
- rate_limit: float = DEFAULT_RATE_LIMIT,
270
- burst: int = DEFAULT_BURST,
271
- max_retries: int = DEFAULT_MAX_RETRIES,
272
- retry_base_delay: float = DEFAULT_RETRY_BASE_DELAY,
273
- circuit_threshold: int = DEFAULT_CIRCUIT_THRESHOLD,
274
- circuit_cooldown: float = DEFAULT_CIRCUIT_COOLDOWN,
275
- ) -> ShipXClient:
276
- """Get or create a shared client for the given credentials.
277
-
278
- The pool key includes all parameters that affect client behavior,
279
- ensuring that clients with different rate limits, burst sizes,
280
- circuit breaker settings, or retry policies are cached separately.
281
- """
282
- key = (
283
- token,
284
- organization_id,
285
- sandbox,
286
- base_url,
287
- timeout,
288
- rate_limit,
289
- burst,
290
- max_retries,
291
- retry_base_delay,
292
- circuit_threshold,
293
- circuit_cooldown,
294
- )
295
- with self._lock:
296
- client = self._clients.get(key)
297
- if client is None:
298
- client = ShipXClient(
299
- token=token,
300
- organization_id=organization_id,
301
- sandbox=sandbox,
302
- base_url=base_url,
303
- timeout=timeout,
304
- rate_limit=rate_limit,
305
- burst=burst,
306
- max_retries=max_retries,
307
- retry_base_delay=retry_base_delay,
308
- circuit_threshold=circuit_threshold,
309
- circuit_cooldown=circuit_cooldown,
310
- )
311
- self._clients[key] = client
312
- return client
313
-
314
- async def close_all(self) -> None:
315
- """Close all pooled clients.
316
-
317
- Must be awaited from an async context (e.g. an ``atexit`` hook
318
- running via ``anyio.run``, or a Django ASGI shutdown signal).
319
- """
320
- clients: list[ShipXClient] = []
321
- with self._lock:
322
- clients = list(self._clients.values())
323
- self._clients.clear()
324
- await asyncio.gather(
325
- *(c.close() for c in clients), return_exceptions=True
326
- )
327
-
328
-
329
- # Module-level singleton pool.
330
- _pool = _ClientPool()
331
-
332
- # Shutdown hook — registered at import time so it fires on process exit.
333
- # Uses anyio.run to create a temporary event loop for cleanup.
334
-
335
-
336
- def _shutdown_pool() -> None:
337
- """Clean up pooled HTTP clients on process exit."""
338
- import anyio
339
-
340
- try:
341
- anyio.run(_pool.close_all)
342
- except Exception:
343
- logger.warning("Failed to close client pool on shutdown", exc_info=True)
344
-
345
-
346
- atexit.register(_shutdown_pool)
347
-
348
-
349
- def get_client_pool() -> _ClientPool:
350
- """Return the module-level client pool singleton."""
351
- return _pool
352
-
353
-
354
37
  class ShipXClient:
355
38
  """Async HTTP client for InPost ShipX API.
356
39
 
@@ -451,6 +134,7 @@ class ShipXClient:
451
134
  """
452
135
  start = time.monotonic()
453
136
  retries = 0
137
+ op_name = getattr(coro, "__qualname__", repr(coro))
454
138
 
455
139
  # Check circuit breaker before attempting any work
456
140
  if not self._circuit.allow_request():
@@ -502,11 +186,11 @@ class ShipXClient:
502
186
  "Retry %d/%d for %s after %.1fs: %s",
503
187
  attempt + 1,
504
188
  self._max_retries,
505
- type(coro).__name__,
189
+ op_name,
506
190
  delay,
507
191
  exc,
508
192
  )
509
- await asyncio.sleep(delay)
193
+ await anyio.sleep(delay)
510
194
  else:
511
195
  latency_ms = (time.monotonic() - start) * 1000
512
196
  self._metrics.record_request(
@@ -515,59 +199,36 @@ class ShipXClient:
515
199
  error=f"{type(exc).__name__}: {exc}",
516
200
  )
517
201
  raise
518
- except httpx.HTTPStatusError as exc:
519
- if exc.response.status_code >= 500 and idempotent:
520
- last_exc = exc
521
- self._circuit.record_failure()
522
- self._metrics.set_circuit_state(self._circuit.state)
523
- if attempt < self._max_retries:
524
- retries += 1
525
- self._metrics.record_retry()
526
- delay = self._retry_base_delay * (2**attempt)
527
- logger.warning(
528
- "Retry %d/%d for %s after %.1fs: HTTP %d",
529
- attempt + 1,
530
- self._max_retries,
531
- type(coro).__name__,
532
- delay,
533
- exc.response.status_code,
534
- )
535
- await asyncio.sleep(delay)
536
- else:
537
- latency_ms = (time.monotonic() - start) * 1000
538
- self._metrics.record_request(
539
- latency_ms,
540
- success=False,
541
- error=f"HTTP {exc.response.status_code}",
542
- )
543
- raise
544
- else:
545
- latency_ms = (time.monotonic() - start) * 1000
546
- self._metrics.record_request(
547
- latency_ms,
548
- success=False,
549
- error=f"HTTP {exc.response.status_code}",
550
- )
551
- raise
552
202
  except ShipXAPIError as exc:
553
- if exc.status_code >= 500 and idempotent:
203
+ # 429: the request was rejected before processing, so it
204
+ # is safe to retry regardless of idempotency. Rate
205
+ # limiting is not a service failure — don't trip the
206
+ # circuit. 5xx: retry only idempotent operations.
207
+ rate_limited = exc.status_code == 429
208
+ retryable = rate_limited or (
209
+ exc.status_code >= 500 and idempotent
210
+ )
211
+ if retryable:
554
212
  last_exc = exc
555
- self._circuit.record_failure()
556
- self._metrics.set_circuit_state(self._circuit.state)
213
+ if not rate_limited:
214
+ self._circuit.record_failure()
215
+ self._metrics.set_circuit_state(self._circuit.state)
557
216
  if attempt < self._max_retries:
558
217
  retries += 1
559
218
  self._metrics.record_retry()
560
219
  delay = self._retry_base_delay * (2**attempt)
220
+ if rate_limited and exc.retry_after is not None:
221
+ delay = exc.retry_after
561
222
  logger.warning(
562
223
  "Retry %d/%d for %s after %.1fs: HTTP %d %s",
563
224
  attempt + 1,
564
225
  self._max_retries,
565
- type(coro).__name__,
226
+ op_name,
566
227
  delay,
567
228
  exc.status_code,
568
229
  exc.detail,
569
230
  )
570
- await asyncio.sleep(delay)
231
+ await anyio.sleep(delay)
571
232
  else:
572
233
  latency_ms = (time.monotonic() - start) * 1000
573
234
  self._metrics.record_request(
@@ -748,4 +409,17 @@ class ShipXClient:
748
409
  status_code=status_code,
749
410
  detail=str(detail),
750
411
  errors=errors,
412
+ retry_after=_parse_retry_after(response),
751
413
  )
414
+
415
+
416
+ def _parse_retry_after(response: httpx.Response) -> float | None:
417
+ """Parse the Retry-After header as delay seconds, if present."""
418
+ raw = response.headers.get("Retry-After")
419
+ if raw is None:
420
+ return None
421
+ try:
422
+ return max(0.0, float(raw))
423
+ except ValueError:
424
+ # HTTP-date form — not worth parsing; fall back to backoff.
425
+ return None
@@ -13,10 +13,12 @@ class ShipXAPIError(CommunicationError):
13
13
  status_code: int,
14
14
  detail: str,
15
15
  errors: list[dict[str, Any]] | None = None,
16
+ retry_after: float | None = None,
16
17
  ) -> None:
17
18
  self.status_code = status_code
18
19
  self.detail = detail
19
20
  self.errors = errors or []
21
+ self.retry_after = retry_after
20
22
  super().__init__(
21
23
  f"ShipX API error {status_code}: {detail}",
22
24
  context={
@@ -7,26 +7,31 @@ from __future__ import annotations
7
7
 
8
8
  import base64
9
9
  import ipaddress
10
+ import threading
10
11
  from typing import Any, ClassVar
11
12
 
12
13
  from sendparcel.enums import ConfirmationMethod, LabelFormat
13
14
  from sendparcel.exceptions import InvalidCallbackError
14
15
  from sendparcel.logging import get_logger
15
16
  from sendparcel.protocols import Shipment
16
- from sendparcel.provider import (
17
- BaseProvider,
18
- CancellableProvider,
19
- LabelProvider,
20
- PullStatusProvider,
21
- PushCallbackProvider,
22
- )
17
+ from sendparcel.provider import BaseProvider
23
18
  from sendparcel.types import (
24
19
  AddressInfo,
20
+ CallbackContext,
25
21
  LabelInfo,
26
22
  ShipmentUpdateResult,
27
23
  )
28
24
 
29
- from sendparcel_inpost.client import ShipXClient, get_client_pool
25
+ from sendparcel_inpost.client import (
26
+ DEFAULT_BURST,
27
+ DEFAULT_CIRCUIT_COOLDOWN,
28
+ DEFAULT_CIRCUIT_THRESHOLD,
29
+ DEFAULT_MAX_RETRIES,
30
+ DEFAULT_RATE_LIMIT,
31
+ DEFAULT_RETRY_BASE_DELAY,
32
+ DEFAULT_TIMEOUT,
33
+ ShipXClient,
34
+ )
30
35
  from sendparcel_inpost.exceptions import (
31
36
  ShipXAPIError,
32
37
  ShipXAuthenticationError,
@@ -39,13 +44,65 @@ logger = get_logger(__name__)
39
44
  _DEFAULT_WEBHOOK_NETWORKS: list[str] = ["91.216.25.0/24"]
40
45
 
41
46
 
42
- class InPostBaseProvider(
43
- BaseProvider,
44
- LabelProvider,
45
- PushCallbackProvider,
46
- PullStatusProvider,
47
- CancellableProvider,
48
- ):
47
+ # Shared clients keyed by client-relevant config. The flow builds a
48
+ # provider (and calls the transport factory) on every operation, so the
49
+ # factory must reuse clients: a fresh httpx.AsyncClient per call would
50
+ # leak connections and reset the circuit breaker, rate limiter, and
51
+ # metrics on each operation.
52
+ _transport_cache: dict[tuple[Any, ...], ShipXClient] = {}
53
+ _transport_cache_lock = threading.Lock()
54
+
55
+
56
+ def _client_kwargs(config: dict[str, Any]) -> dict[str, Any]:
57
+ """Extract ShipXClient constructor kwargs from provider config."""
58
+ return {
59
+ "token": config.get("token", ""),
60
+ "organization_id": config.get("organization_id", 0),
61
+ "sandbox": config.get("sandbox", False),
62
+ "base_url": config.get("base_url"),
63
+ "timeout": config.get("timeout", DEFAULT_TIMEOUT),
64
+ "rate_limit": config.get("rate_limit", DEFAULT_RATE_LIMIT),
65
+ "burst": config.get("burst", DEFAULT_BURST),
66
+ "max_retries": config.get("max_retries", DEFAULT_MAX_RETRIES),
67
+ "retry_base_delay": config.get(
68
+ "retry_base_delay", DEFAULT_RETRY_BASE_DELAY
69
+ ),
70
+ "circuit_threshold": config.get(
71
+ "circuit_threshold", DEFAULT_CIRCUIT_THRESHOLD
72
+ ),
73
+ "circuit_cooldown": config.get(
74
+ "circuit_cooldown", DEFAULT_CIRCUIT_COOLDOWN
75
+ ),
76
+ }
77
+
78
+
79
+ def _build_inpost_transport(**config: Any) -> ShipXClient:
80
+ """Return the shared ShipXClient for this provider config."""
81
+ kwargs = _client_kwargs(config)
82
+ key = tuple(sorted(kwargs.items(), key=lambda kv: kv[0]))
83
+ with _transport_cache_lock:
84
+ client = _transport_cache.get(key)
85
+ if client is None:
86
+ client = ShipXClient(**kwargs)
87
+ _transport_cache[key] = client
88
+ return client
89
+
90
+
91
+ async def aclose_transports() -> None:
92
+ """Close and evict all shared ShipX clients.
93
+
94
+ Call during application shutdown to release HTTP connections.
95
+ Safe to call multiple times; subsequent factory calls create
96
+ fresh clients.
97
+ """
98
+ with _transport_cache_lock:
99
+ clients = list(_transport_cache.values())
100
+ _transport_cache.clear()
101
+ for client in clients:
102
+ await client.close()
103
+
104
+
105
+ class InPostBaseProvider(BaseProvider):
49
106
  """Shared implementation for InPost courier and locker providers.
50
107
 
51
108
  Subclasses must define:
@@ -61,6 +118,7 @@ class InPostBaseProvider(
61
118
  supported_countries: ClassVar[list[str]] = ["PL"]
62
119
  confirmation_method: ClassVar[ConfirmationMethod] = ConfirmationMethod.PUSH
63
120
  user_selectable: ClassVar[bool] = True
121
+ transport_factory: ClassVar[Any] = staticmethod(_build_inpost_transport)
64
122
  config_schema: ClassVar[dict[str, Any]] = {
65
123
  "token": {
66
124
  "type": "str",
@@ -94,6 +152,48 @@ class InPostBaseProvider(
94
152
  "description": "HTTP request timeout in seconds",
95
153
  "default": 30.0,
96
154
  },
155
+ "rate_limit": {
156
+ "type": "float",
157
+ "required": False,
158
+ "secret": False,
159
+ "description": "Max requests per second to the ShipX API",
160
+ "default": DEFAULT_RATE_LIMIT,
161
+ },
162
+ "burst": {
163
+ "type": "int",
164
+ "required": False,
165
+ "secret": False,
166
+ "description": "Rate limiter burst size",
167
+ "default": DEFAULT_BURST,
168
+ },
169
+ "max_retries": {
170
+ "type": "int",
171
+ "required": False,
172
+ "secret": False,
173
+ "description": "Max retries for transient failures",
174
+ "default": DEFAULT_MAX_RETRIES,
175
+ },
176
+ "retry_base_delay": {
177
+ "type": "float",
178
+ "required": False,
179
+ "secret": False,
180
+ "description": "Base delay for exponential backoff (seconds)",
181
+ "default": DEFAULT_RETRY_BASE_DELAY,
182
+ },
183
+ "circuit_threshold": {
184
+ "type": "int",
185
+ "required": False,
186
+ "secret": False,
187
+ "description": "Consecutive failures before the circuit opens",
188
+ "default": DEFAULT_CIRCUIT_THRESHOLD,
189
+ },
190
+ "circuit_cooldown": {
191
+ "type": "float",
192
+ "required": False,
193
+ "secret": False,
194
+ "description": "Seconds before the open circuit allows a probe",
195
+ "default": DEFAULT_CIRCUIT_COOLDOWN,
196
+ },
97
197
  "trusted_networks": {
98
198
  "type": "list[str]",
99
199
  "required": False,
@@ -116,57 +216,11 @@ class InPostBaseProvider(
116
216
  self,
117
217
  shipment: Shipment,
118
218
  config: dict[str, Any] | None = None,
219
+ *,
220
+ transport: Any = None,
119
221
  ) -> None:
120
- super().__init__(shipment, config)
121
- self._validate_config()
122
- self._client = self._build_client()
123
-
124
- def _validate_config(self) -> None:
125
- """Validate configuration against ``config_schema``.
126
-
127
- Checks that all required fields are present and have the
128
- correct type. Raises ``ValueError`` with a helpful message
129
- on the first violation found.
130
- """
131
- for field_name, spec in self.config_schema.items():
132
- if not spec.get("required", False):
133
- continue
134
- value = self.get_setting(field_name)
135
- if value is None or value == "":
136
- raise ValueError(
137
- f"InPost provider requires '{field_name}' in config. "
138
- f"Set {field_name!r} in your provider configuration."
139
- )
140
- expected_type = spec.get("type")
141
- if expected_type and value is not None:
142
- type_map: dict[str, type | tuple[type, ...]] = {
143
- "str": str,
144
- "int": int,
145
- "float": (int, float),
146
- "bool": bool,
147
- "list[str]": list,
148
- }
149
- python_type = type_map.get(expected_type)
150
- if python_type and not isinstance(value, python_type):
151
- raise TypeError(
152
- f"InPost provider config '{field_name}' must be "
153
- f"{expected_type}, got {type(value).__name__}"
154
- )
155
-
156
- def _build_client(self) -> ShipXClient:
157
- """Build a ShipXClient from provider config using the shared pool."""
158
- pool = get_client_pool()
159
- return pool.get(
160
- token=self.get_setting("token", ""),
161
- organization_id=self.get_setting("organization_id", 0),
162
- sandbox=self.get_setting("sandbox", False),
163
- base_url=self.get_setting("base_url"),
164
- timeout=self.get_setting("timeout", 30.0),
165
- )
166
-
167
- def _get_client(self) -> ShipXClient:
168
- """Return the shared ShipXClient instance."""
169
- return self._client
222
+ super().__init__(shipment, config, transport=transport)
223
+ self._client = transport
170
224
 
171
225
  def _address_to_peer(self, addr: AddressInfo) -> ShipXPeer:
172
226
  """Convert sendparcel AddressInfo to ShipX peer dict."""
@@ -243,23 +297,17 @@ class InPostBaseProvider(
243
297
 
244
298
  async def verify_callback(
245
299
  self,
246
- data: dict[str, Any],
247
- headers: dict[str, Any],
248
- source_ip: str | None = None,
300
+ ctx: CallbackContext,
249
301
  **kwargs: Any,
250
302
  ) -> None:
251
303
  """Verify InPost webhook by validated source IP.
252
304
 
253
305
  The provider checks the source IP against the configured
254
- ``trusted_networks`` setting. The IP can be supplied in two
255
- ways:
256
-
257
- 1. **Direct parameter** (preferred): pass ``source_ip`` directly
258
- from the framework layer (e.g. ``request.client.host`` in
259
- FastAPI/Litestar or ``request.META['REMOTE_ADDR']`` in Django).
260
- 2. **Header fallback**: if ``source_ip`` is not provided, the
261
- provider falls back to the ``x-validated-source-ip`` header,
262
- which the Django view sets from ``REMOTE_ADDR``.
306
+ ``trusted_networks`` setting. The IP is read from the
307
+ ``CallbackContext.source_ip`` field, which the framework
308
+ layer populates from the actual TCP connection
309
+ (``request.META['REMOTE_ADDR']`` in Django, ``request.client.host``
310
+ in FastAPI/Litestar).
263
311
 
264
312
  Verification can be disabled entirely with
265
313
  ``verify_webhook_ip=False`` (useful for local development).
@@ -267,12 +315,11 @@ class InPostBaseProvider(
267
315
  if not self.get_setting("verify_webhook_ip", True):
268
316
  return
269
317
 
270
- # Prefer direct parameter; fall back to header for Django compat.
271
- ip_str = source_ip or headers.get("x-validated-source-ip", "")
318
+ ip_str = ctx.source_ip
272
319
  if not ip_str:
273
320
  raise InvalidCallbackError(
274
- "Missing source IP; pass source_ip directly or set "
275
- "x-validated-source-ip header"
321
+ "Missing source IP; ensure the framework layer populates "
322
+ "CallbackContext.source_ip from the TCP connection"
276
323
  )
277
324
  await self.validate_source_ip(ip_str)
278
325
 
@@ -300,12 +347,11 @@ class InPostBaseProvider(
300
347
 
301
348
  async def handle_callback(
302
349
  self,
303
- data: dict[str, Any],
304
- headers: dict[str, Any],
350
+ ctx: CallbackContext,
305
351
  **kwargs: Any,
306
352
  ) -> ShipmentUpdateResult:
307
353
  """Process InPost webhook payload."""
308
- payload = data.get("payload", {})
354
+ payload = ctx.payload.get("payload", {})
309
355
  shipx_status = str(payload.get("status", ""))
310
356
  tracking_number = str(payload.get("tracking_number", ""))
311
357
  update = build_shipment_update(
@@ -5,7 +5,6 @@ from __future__ import annotations
5
5
  from typing import Any, ClassVar
6
6
 
7
7
  from sendparcel.enums import ConfirmationMethod
8
- from sendparcel.protocols import Shipment
9
8
  from sendparcel.types import (
10
9
  AddressInfo,
11
10
  ParcelInfo,
@@ -27,13 +26,6 @@ class InPostCourierProvider(
27
26
  ]
28
27
  confirmation_method: ClassVar[ConfirmationMethod] = ConfirmationMethod.PUSH
29
28
 
30
- def __init__(
31
- self,
32
- shipment: Shipment,
33
- config: dict[str, Any] | None = None,
34
- ) -> None:
35
- super().__init__(shipment, config)
36
-
37
29
  async def create_shipment(
38
30
  self,
39
31
  *,
@@ -5,7 +5,6 @@ from __future__ import annotations
5
5
  from typing import Any, ClassVar
6
6
 
7
7
  from sendparcel.enums import ConfirmationMethod
8
- from sendparcel.protocols import Shipment
9
8
  from sendparcel.types import (
10
9
  AddressInfo,
11
10
  ParcelInfo,
@@ -27,13 +26,6 @@ class InPostLockerProvider(
27
26
  ]
28
27
  confirmation_method: ClassVar[ConfirmationMethod] = ConfirmationMethod.PUSH
29
28
 
30
- def __init__(
31
- self,
32
- shipment: Shipment,
33
- config: dict[str, Any] | None = None,
34
- ) -> None:
35
- super().__init__(shipment, config)
36
-
37
29
  def _parcel_template_from_parcels(self, parcels: list[ParcelInfo]) -> str:
38
30
  """Determine locker parcel template from parcels.
39
31
 
@@ -0,0 +1,57 @@
1
+ """Async token bucket rate limiter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ import anyio
8
+
9
+
10
+ class _TokenBucket:
11
+ """Async token bucket rate limiter.
12
+
13
+ Tokens are added at a fixed rate up to a maximum burst size.
14
+ Each request consumes one token. If no tokens are available,
15
+ the caller waits until a token is refilled.
16
+
17
+ Backend-agnostic (anyio): works under both asyncio and trio.
18
+ """
19
+
20
+ __slots__ = ("_burst", "_last_refill", "_lock", "_rate", "_tokens")
21
+
22
+ def __init__(
23
+ self,
24
+ rate: float,
25
+ burst: int,
26
+ ) -> None:
27
+ self._rate = rate
28
+ self._burst = burst
29
+ self._tokens = float(burst)
30
+ self._last_refill: float | None = None
31
+ self._lock = anyio.Lock()
32
+
33
+ async def acquire(self) -> None:
34
+ """Acquire a token, waiting if necessary.
35
+
36
+ Tokens are refilled based on elapsed wall-clock time up to the
37
+ burst limit. When no tokens are available the caller waits the
38
+ exact duration needed for one token to become available — sleep
39
+ time is NOT counted toward refill to prevent burst above limits.
40
+ """
41
+ async with self._lock:
42
+ now = time.monotonic()
43
+ if self._last_refill is None:
44
+ self._last_refill = now
45
+ elapsed = now - self._last_refill
46
+ self._tokens = min(self._burst, self._tokens + elapsed * self._rate)
47
+ self._last_refill = now
48
+
49
+ if self._tokens < 1.0:
50
+ wait_time = (1.0 - self._tokens) / self._rate
51
+ await anyio.sleep(wait_time)
52
+ # Consume the token and advance last_refill so that
53
+ # subsequent calls don't double-count the sleep.
54
+ self._last_refill = time.monotonic()
55
+ self._tokens -= 1.0
56
+ else:
57
+ self._tokens -= 1.0
@@ -3,34 +3,33 @@
3
3
  from sendparcel.enums import ShipmentStatus
4
4
  from sendparcel.types import ShipmentUpdateResult
5
5
 
6
- SHIPX_TO_SENDPARCEL_STATUS: dict[str, ShipmentStatus] = {
7
- # CREATED
8
- "created": ShipmentStatus.CREATED,
6
+ # Non-trivial mappings only: ShipX status strings that don't match the
7
+ # ShipmentStatus enum value. Trivial cases (e.g. "created" → CREATED)
8
+ # are derived from the enum in map_shipx_status().
9
+ _NON_TRIVIAL_STATUS_MAP: dict[str, ShipmentStatus] = {
10
+ # → CREATED
9
11
  "offers_prepared": ShipmentStatus.CREATED,
10
12
  "offer_selected": ShipmentStatus.CREATED,
11
- # LABEL_READY
13
+ # LABEL_READY
12
14
  "confirmed": ShipmentStatus.LABEL_READY,
13
- # IN_TRANSIT
15
+ # IN_TRANSIT
14
16
  "dispatched_by_sender": ShipmentStatus.IN_TRANSIT,
15
17
  "collected_from_sender": ShipmentStatus.IN_TRANSIT,
16
18
  "taken_by_courier": ShipmentStatus.IN_TRANSIT,
17
19
  "adopted_at_source_branch": ShipmentStatus.IN_TRANSIT,
18
20
  "sent_from_source_branch": ShipmentStatus.IN_TRANSIT,
19
21
  "adopted_at_sorting_center": ShipmentStatus.IN_TRANSIT,
20
- # OUT_FOR_DELIVERY
21
- "out_for_delivery": ShipmentStatus.OUT_FOR_DELIVERY,
22
+ # OUT_FOR_DELIVERY
22
23
  "ready_to_pickup": ShipmentStatus.OUT_FOR_DELIVERY,
23
24
  "pickup_reminder_sent": ShipmentStatus.OUT_FOR_DELIVERY,
24
25
  "avizo": ShipmentStatus.OUT_FOR_DELIVERY,
25
26
  "stack_in_box_machine": ShipmentStatus.OUT_FOR_DELIVERY,
26
27
  "stack_in_customer_service_point": ShipmentStatus.OUT_FOR_DELIVERY,
27
- # DELIVERED
28
- "delivered": ShipmentStatus.DELIVERED,
29
- # CANCELLED
28
+ # → CANCELLED (ShipX uses US spelling "canceled")
30
29
  "canceled": ShipmentStatus.CANCELLED,
31
- # RETURNED
30
+ # RETURNED
32
31
  "returned_to_sender": ShipmentStatus.RETURNED,
33
- # FAILED
32
+ # FAILED
34
33
  "rejected_by_receiver": ShipmentStatus.FAILED,
35
34
  "undelivered": ShipmentStatus.FAILED,
36
35
  "oversized": ShipmentStatus.FAILED,
@@ -42,9 +41,20 @@ SHIPX_TO_SENDPARCEL_STATUS: dict[str, ShipmentStatus] = {
42
41
  def map_shipx_status(shipx_status: str) -> ShipmentStatus | None:
43
42
  """Map a ShipX status string to a sendparcel ShipmentStatus.
44
43
 
44
+ Trivial mappings (where the ShipX string matches the enum value) are
45
+ derived from the ShipmentStatus enum. Non-trivial mappings are looked
46
+ up in the explicit exceptions dict.
47
+
45
48
  Returns None if the status is not recognized.
46
49
  """
47
- return SHIPX_TO_SENDPARCEL_STATUS.get(shipx_status)
50
+ # Check non-trivial mappings first.
51
+ if shipx_status in _NON_TRIVIAL_STATUS_MAP:
52
+ return _NON_TRIVIAL_STATUS_MAP[shipx_status]
53
+ # Try trivial mapping: ShipX status string == enum value.
54
+ try:
55
+ return ShipmentStatus(shipx_status)
56
+ except ValueError:
57
+ return None
48
58
 
49
59
 
50
60
  def build_shipment_update(
@@ -1,16 +0,0 @@
1
- sendparcel_inpost/__init__.py,sha256=iF669tt96Ux4fW-DD2XAIiYEX7_F36gFGxZ9Mui-nzA,460
2
- sendparcel_inpost/client.py,sha256=wxoCH-7FQc7zdqE2xEXDyBLt-DSdKPHN4s1opTj6YvQ,25607
3
- sendparcel_inpost/enums.py,sha256=8R0BPxTc_PQdPTjYXMIFlFjNju4X6hL8Xq3EEUe5J94,384
4
- sendparcel_inpost/exceptions.py,sha256=JRCsLiqLU_u96aVVa5xz9a4MAVBxB6CqR6g_83YyD44,2061
5
- sendparcel_inpost/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- sendparcel_inpost/status_mapping.py,sha256=XPmp-92CUOidLY1EPeL7d0PGDEsqgRAJxK3247y5zWk,2251
7
- sendparcel_inpost/types.py,sha256=Pu-OJpC6te4_gY6PLv-Ag0o2IoulYytdr9t0FGf4GIA,1426
8
- sendparcel_inpost/providers/__init__.py,sha256=akn431q6mekenZV5Y4wjCh2UkLSi2s51umBHZ37orJg,336
9
- sendparcel_inpost/providers/base.py,sha256=dNxSTiE-BrJCCEmTlMVOuriLqpKu1wkMtM8dQspu5C4,12613
10
- sendparcel_inpost/providers/courier.py,sha256=N-lGvL_6R6wq0nnJUum8wvMwKLjDric8Vv4xODWm5Rg,2799
11
- sendparcel_inpost/providers/locker.py,sha256=1DG5nPcJGNlJP-ul87U_VBZkvt4VZXKhIws54kYTRe0,3074
12
- python_sendparcel_inpost-0.2.0.dist-info/METADATA,sha256=XKB409WqD0F3SmgsND_xHd43uy29VUeZ5u9_-WBQGQ0,3446
13
- python_sendparcel_inpost-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
14
- python_sendparcel_inpost-0.2.0.dist-info/entry_points.txt,sha256=XuQdmE0LIuIc3TvGPWnYjoB14zXCEof6Q8h1tMp2arE,170
15
- python_sendparcel_inpost-0.2.0.dist-info/licenses/LICENSE,sha256=IZXSBOjgGvChgayLmtTnU40iE7hsrrU3WVEYKx0sywY,1075
16
- python_sendparcel_inpost-0.2.0.dist-info/RECORD,,
@@ -1,18 +0,0 @@
1
- """ShipX-specific enumerations."""
2
-
3
- from enum import StrEnum
4
-
5
-
6
- class ShipXService(StrEnum):
7
- """ShipX shipment service types."""
8
-
9
- INPOST_LOCKER_STANDARD = "inpost_locker_standard"
10
- INPOST_COURIER_STANDARD = "inpost_courier_standard"
11
-
12
-
13
- class ShipXParcelTemplate(StrEnum):
14
- """Locker parcel size templates."""
15
-
16
- SMALL = "small"
17
- MEDIUM = "medium"
18
- LARGE = "large"