python-sendparcel-inpost 0.2.0__tar.gz → 0.3.0__tar.gz
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_inpost-0.3.0/CHANGELOG.md +63 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/PKG-INFO +2 -2
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/pyproject.toml +2 -2
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/__init__.py +3 -1
- python_sendparcel_inpost-0.3.0/src/sendparcel_inpost/circuit_breaker.py +155 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/client.py +35 -361
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/exceptions.py +2 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/providers/base.py +130 -84
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/providers/courier.py +0 -8
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/providers/locker.py +0 -8
- python_sendparcel_inpost-0.3.0/src/sendparcel_inpost/rate_limiter.py +57 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/status_mapping.py +23 -13
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/tests/conftest.py +6 -1
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/tests/test_client.py +65 -4
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/tests/test_config_schema.py +16 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/tests/test_courier_provider.py +7 -4
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/tests/test_locker_provider.py +72 -27
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/tests/test_status_mapping.py +13 -4
- python_sendparcel_inpost-0.3.0/tests/test_transport.py +54 -0
- python_sendparcel_inpost-0.2.0/CHANGELOG.md +0 -23
- python_sendparcel_inpost-0.2.0/src/sendparcel_inpost/enums.py +0 -18
- python_sendparcel_inpost-0.2.0/tests/test_enums.py +0 -31
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/.github/workflows/ci.yml +0 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/.github/workflows/release.yml +0 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/.gitignore +0 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/CONTRIBUTING.md +0 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/LICENSE +0 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/README.md +0 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/docs/api.md +0 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/docs/configuration.md +0 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/docs/index.md +0 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/docs/quickstart.md +0 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/providers/__init__.py +0 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/py.typed +0 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/types.py +0 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/tests/__init__.py +0 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/tests/test_entry_points.py +0 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/tests/test_exceptions.py +0 -0
- {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/tests/test_types.py +0 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
|
+
|
|
8
|
+
## [0.3.0] - 2026-07-04
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Transport factory now returns a shared, cached `ShipXClient` per
|
|
13
|
+
configuration instead of building (and leaking) a fresh
|
|
14
|
+
`httpx.AsyncClient` on every operation. Connections are reused and
|
|
15
|
+
the circuit breaker, rate limiter, and metrics keep their state
|
|
16
|
+
across operations.
|
|
17
|
+
- HTTP 429 responses are retried (also for non-idempotent operations —
|
|
18
|
+
a rate-limited request was never processed), honoring the
|
|
19
|
+
`Retry-After` header when present.
|
|
20
|
+
- Retry log messages now name the operation being retried.
|
|
21
|
+
- Removed an unreachable `httpx.HTTPStatusError` retry branch.
|
|
22
|
+
- Replaced deprecated `asyncio.get_event_loop().time()` with
|
|
23
|
+
`time.monotonic()`; rate limiter now uses anyio primitives (works
|
|
24
|
+
under trio).
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- `sendparcel_inpost.aclose_transports()` — closes and evicts all
|
|
29
|
+
shared ShipX clients; call on application shutdown.
|
|
30
|
+
- Resilience settings exposed in `config_schema`: `rate_limit`,
|
|
31
|
+
`burst`, `max_retries`, `retry_base_delay`, `circuit_threshold`,
|
|
32
|
+
`circuit_cooldown`.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- Requires `python-sendparcel>=0.3.0` (unified `BaseProvider`,
|
|
37
|
+
`CallbackContext`, transport injection).
|
|
38
|
+
|
|
39
|
+
## [0.2.0] - 2025-06-05
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- Adapted to the python-sendparcel 0.2.x provider API:
|
|
44
|
+
`CallbackContext`-based callbacks and transport injection via
|
|
45
|
+
`transport_factory`.
|
|
46
|
+
- Removed dead enums (`ShipXService`, `ShipXParcelTemplate`).
|
|
47
|
+
|
|
48
|
+
## [0.1.0] - 2026-02-16
|
|
49
|
+
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
- InPost ShipX provider for python-sendparcel
|
|
53
|
+
- `InPostLockerProvider` for Paczkomat locker deliveries
|
|
54
|
+
- `InPostCourierProvider` for door-to-door courier deliveries
|
|
55
|
+
- `ShipXClient` standalone async HTTP client for the ShipX API
|
|
56
|
+
- ShipX exception hierarchy (`ShipXAPIError`, `ShipXAuthenticationError`, `ShipXValidationError`)
|
|
57
|
+
- ShipX-specific enums (`ShipXService`, `ShipXParcelTemplate`)
|
|
58
|
+
- ShipX-specific TypedDicts (`ShipXAddress`, `ShipXPeer`, `ShipXParcel`, `ShipXShipmentPayload`)
|
|
59
|
+
- Status mapping from 24 ShipX statuses to 8 sendparcel statuses
|
|
60
|
+
- Webhook verification by InPost source IP range (`91.216.25.0/24`)
|
|
61
|
+
- Address conversion with legacy name-splitting fallback
|
|
62
|
+
- Entry-point registration for auto-discovery
|
|
63
|
+
- Full test suite (93 tests)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-sendparcel-inpost
|
|
3
|
-
Version: 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.
|
|
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'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "python-sendparcel-inpost"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "InPost ShipX provider for python-sendparcel."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -18,7 +18,7 @@ classifiers = [
|
|
|
18
18
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
19
19
|
"Typing :: Typed",
|
|
20
20
|
]
|
|
21
|
-
dependencies = ["python-sendparcel>=0.
|
|
21
|
+
dependencies = ["python-sendparcel>=0.3.0", "httpx>=0.27.0", "anyio>=4.0"]
|
|
22
22
|
|
|
23
23
|
[project.optional-dependencies]
|
|
24
24
|
dev = [
|
{python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/__init__.py
RENAMED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""InPost ShipX provider for python-sendparcel."""
|
|
2
2
|
|
|
3
|
-
__version__ = "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()
|