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.
Files changed (39) hide show
  1. python_sendparcel_inpost-0.3.0/CHANGELOG.md +63 -0
  2. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/PKG-INFO +2 -2
  3. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/pyproject.toml +2 -2
  4. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/__init__.py +3 -1
  5. python_sendparcel_inpost-0.3.0/src/sendparcel_inpost/circuit_breaker.py +155 -0
  6. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/client.py +35 -361
  7. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/exceptions.py +2 -0
  8. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/providers/base.py +130 -84
  9. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/providers/courier.py +0 -8
  10. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/providers/locker.py +0 -8
  11. python_sendparcel_inpost-0.3.0/src/sendparcel_inpost/rate_limiter.py +57 -0
  12. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/status_mapping.py +23 -13
  13. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/tests/conftest.py +6 -1
  14. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/tests/test_client.py +65 -4
  15. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/tests/test_config_schema.py +16 -0
  16. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/tests/test_courier_provider.py +7 -4
  17. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/tests/test_locker_provider.py +72 -27
  18. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/tests/test_status_mapping.py +13 -4
  19. python_sendparcel_inpost-0.3.0/tests/test_transport.py +54 -0
  20. python_sendparcel_inpost-0.2.0/CHANGELOG.md +0 -23
  21. python_sendparcel_inpost-0.2.0/src/sendparcel_inpost/enums.py +0 -18
  22. python_sendparcel_inpost-0.2.0/tests/test_enums.py +0 -31
  23. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/.github/workflows/ci.yml +0 -0
  24. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/.github/workflows/release.yml +0 -0
  25. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/.gitignore +0 -0
  26. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/CONTRIBUTING.md +0 -0
  27. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/LICENSE +0 -0
  28. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/README.md +0 -0
  29. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/docs/api.md +0 -0
  30. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/docs/configuration.md +0 -0
  31. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/docs/index.md +0 -0
  32. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/docs/quickstart.md +0 -0
  33. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/providers/__init__.py +0 -0
  34. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/py.typed +0 -0
  35. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/types.py +0 -0
  36. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/tests/__init__.py +0 -0
  37. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/tests/test_entry_points.py +0 -0
  38. {python_sendparcel_inpost-0.2.0 → python_sendparcel_inpost-0.3.0}/tests/test_exceptions.py +0 -0
  39. {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.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'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-sendparcel-inpost"
3
- version = "0.2.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.1.1", "httpx>=0.27.0", "anyio>=4.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 = [
@@ -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()