python-sendparcel-inpost 0.1.2__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 (43) hide show
  1. python_sendparcel_inpost-0.3.0/.github/workflows/ci.yml +28 -0
  2. python_sendparcel_inpost-0.3.0/.github/workflows/release.yml +23 -0
  3. python_sendparcel_inpost-0.3.0/CHANGELOG.md +63 -0
  4. python_sendparcel_inpost-0.3.0/LICENSE +10 -0
  5. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/PKG-INFO +8 -2
  6. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/pyproject.toml +10 -2
  7. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/__init__.py +5 -1
  8. python_sendparcel_inpost-0.3.0/src/sendparcel_inpost/circuit_breaker.py +155 -0
  9. python_sendparcel_inpost-0.3.0/src/sendparcel_inpost/client.py +425 -0
  10. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/exceptions.py +29 -0
  11. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/providers/__init__.py +6 -1
  12. python_sendparcel_inpost-0.3.0/src/sendparcel_inpost/providers/base.py +414 -0
  13. python_sendparcel_inpost-0.3.0/src/sendparcel_inpost/providers/courier.py +85 -0
  14. python_sendparcel_inpost-0.3.0/src/sendparcel_inpost/providers/locker.py +95 -0
  15. python_sendparcel_inpost-0.3.0/src/sendparcel_inpost/rate_limiter.py +57 -0
  16. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/status_mapping.py +23 -13
  17. python_sendparcel_inpost-0.3.0/tests/__init__.py +0 -0
  18. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/tests/conftest.py +6 -1
  19. python_sendparcel_inpost-0.3.0/tests/test_client.py +567 -0
  20. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/tests/test_config_schema.py +16 -0
  21. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/tests/test_courier_provider.py +48 -64
  22. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/tests/test_locker_provider.py +201 -88
  23. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/tests/test_status_mapping.py +13 -4
  24. python_sendparcel_inpost-0.3.0/tests/test_transport.py +54 -0
  25. python_sendparcel_inpost-0.1.2/CHANGELOG.md +0 -23
  26. python_sendparcel_inpost-0.1.2/src/sendparcel_inpost/client.py +0 -177
  27. python_sendparcel_inpost-0.1.2/src/sendparcel_inpost/enums.py +0 -18
  28. python_sendparcel_inpost-0.1.2/src/sendparcel_inpost/providers/courier.py +0 -308
  29. python_sendparcel_inpost-0.1.2/src/sendparcel_inpost/providers/locker.py +0 -322
  30. python_sendparcel_inpost-0.1.2/tests/test_client.py +0 -233
  31. python_sendparcel_inpost-0.1.2/tests/test_enums.py +0 -31
  32. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/.gitignore +0 -0
  33. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/CONTRIBUTING.md +0 -0
  34. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/README.md +0 -0
  35. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/docs/api.md +0 -0
  36. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/docs/configuration.md +0 -0
  37. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/docs/index.md +0 -0
  38. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/docs/quickstart.md +0 -0
  39. /python_sendparcel_inpost-0.1.2/tests/__init__.py → /python_sendparcel_inpost-0.3.0/src/sendparcel_inpost/py.typed +0 -0
  40. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/src/sendparcel_inpost/types.py +0 -0
  41. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/tests/test_entry_points.py +0 -0
  42. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/tests/test_exceptions.py +0 -0
  43. {python_sendparcel_inpost-0.1.2 → python_sendparcel_inpost-0.3.0}/tests/test_types.py +0 -0
@@ -0,0 +1,28 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ lint:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v5
14
+ with:
15
+ enable-cache: true
16
+ - run: uv sync --extra dev
17
+ - run: uv run ruff check .
18
+ - run: uv run ruff format --check .
19
+
20
+ test:
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+ - uses: astral-sh/setup-uv@v5
25
+ with:
26
+ enable-cache: true
27
+ - run: uv sync --extra dev
28
+ - run: uv run pytest tests/ --cov=sendparcel_inpost --cov-report=xml -v
@@ -0,0 +1,23 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ permissions:
9
+ id-token: write
10
+
11
+ jobs:
12
+ release:
13
+ runs-on: ubuntu-latest
14
+ environment: release
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: astral-sh/setup-uv@v5
18
+ with:
19
+ enable-cache: true
20
+ - name: Build package
21
+ run: uv build
22
+ - name: Publish to PyPI
23
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -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)
@@ -0,0 +1,10 @@
1
+
2
+ MIT License
3
+
4
+ Copyright (c) 2025, Dominik Kozaczko
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
7
+
8
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
9
+
10
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -1,9 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-sendparcel-inpost
3
- Version: 0.1.2
3
+ Version: 0.3.0
4
4
  Summary: InPost ShipX provider for python-sendparcel.
5
+ Project-URL: Homepage, https://github.com/python-sendparcel/python-sendparcel-inpost
6
+ Project-URL: Repository, https://github.com/python-sendparcel/python-sendparcel-inpost
7
+ Project-URL: Changelog, https://github.com/python-sendparcel/python-sendparcel-inpost/blob/main/CHANGELOG.md
8
+ Project-URL: Issue Tracker, https://github.com/python-sendparcel/python-sendparcel-inpost/issues
5
9
  Author-email: Dominik Kozaczko <dominik@kozaczko.info>
6
10
  License: MIT
11
+ License-File: LICENSE
7
12
  Keywords: inpost,parcel,sendparcel,shipping,shipx
8
13
  Classifier: Development Status :: 3 - Alpha
9
14
  Classifier: Intended Audience :: Developers
@@ -17,13 +22,14 @@ Classifier: Typing :: Typed
17
22
  Requires-Python: >=3.12
18
23
  Requires-Dist: anyio>=4.0
19
24
  Requires-Dist: httpx>=0.27.0
20
- Requires-Dist: python-sendparcel>=0.1.1
25
+ Requires-Dist: python-sendparcel>=0.3.0
21
26
  Provides-Extra: dev
22
27
  Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
23
28
  Requires-Dist: pytest-cov>=5.0; extra == 'dev'
24
29
  Requires-Dist: pytest>=8.0; extra == 'dev'
25
30
  Requires-Dist: respx>=0.22.0; extra == 'dev'
26
31
  Requires-Dist: ruff>=0.9.0; extra == 'dev'
32
+ Requires-Dist: ty>=0.0.1a11; extra == 'dev'
27
33
  Description-Content-Type: text/markdown
28
34
 
29
35
  # python-sendparcel-inpost
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-sendparcel-inpost"
3
- version = "0.1.2"
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 = [
@@ -27,8 +27,16 @@ dev = [
27
27
  "pytest-cov>=5.0",
28
28
  "respx>=0.22.0",
29
29
  "ruff>=0.9.0",
30
+ "ty>=0.0.1a11",
30
31
  ]
31
32
 
33
+ [project.urls]
34
+ Homepage = "https://github.com/python-sendparcel/python-sendparcel-inpost"
35
+ # Documentation = "https://python-sendparcel-inpost.readthedocs.io/"
36
+ Repository = "https://github.com/python-sendparcel/python-sendparcel-inpost"
37
+ Changelog = "https://github.com/python-sendparcel/python-sendparcel-inpost/blob/main/CHANGELOG.md"
38
+ "Issue Tracker" = "https://github.com/python-sendparcel/python-sendparcel-inpost/issues"
39
+
32
40
  [project.entry-points."sendparcel.providers"]
33
41
  inpost_locker = "sendparcel_inpost.providers.locker:InPostLockerProvider"
34
42
  inpost_courier = "sendparcel_inpost.providers.courier:InPostCourierProvider"
@@ -1,14 +1,18 @@
1
1
  """InPost ShipX provider for python-sendparcel."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.3.0"
4
4
 
5
5
  from sendparcel_inpost.client import ShipXClient
6
+ from sendparcel_inpost.exceptions import CircuitBreakerError
7
+ from sendparcel_inpost.providers.base import aclose_transports
6
8
  from sendparcel_inpost.providers.courier import InPostCourierProvider
7
9
  from sendparcel_inpost.providers.locker import InPostLockerProvider
8
10
 
9
11
  __all__ = [
12
+ "CircuitBreakerError",
10
13
  "InPostCourierProvider",
11
14
  "InPostLockerProvider",
12
15
  "ShipXClient",
13
16
  "__version__",
17
+ "aclose_transports",
14
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()