persistence-kit 3.3.0__tar.gz → 3.5.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.
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/PKG-INFO +3 -1
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/__init__.py +81 -0
- persistence_kit-3.5.0/persistence_kit/resilience/__init__.py +11 -0
- persistence_kit-3.5.0/persistence_kit/resilience/circuit.py +85 -0
- persistence_kit-3.5.0/persistence_kit/restclient/__init__.py +105 -0
- persistence_kit-3.5.0/persistence_kit/restclient/aggregate.py +95 -0
- persistence_kit-3.5.0/persistence_kit/restclient/auth/__init__.py +16 -0
- persistence_kit-3.5.0/persistence_kit/restclient/auth/api_key.py +34 -0
- persistence_kit-3.5.0/persistence_kit/restclient/auth/base.py +13 -0
- persistence_kit-3.5.0/persistence_kit/restclient/auth/basic.py +20 -0
- persistence_kit-3.5.0/persistence_kit/restclient/auth/bearer.py +26 -0
- persistence_kit-3.5.0/persistence_kit/restclient/auth/login.py +124 -0
- persistence_kit-3.5.0/persistence_kit/restclient/auth/oauth2.py +96 -0
- persistence_kit-3.5.0/persistence_kit/restclient/client.py +179 -0
- persistence_kit-3.5.0/persistence_kit/restclient/config.py +32 -0
- persistence_kit-3.5.0/persistence_kit/restclient/contracts.py +99 -0
- persistence_kit-3.5.0/persistence_kit/restclient/errors.py +41 -0
- persistence_kit-3.5.0/persistence_kit/restclient/factory.py +51 -0
- persistence_kit-3.5.0/persistence_kit/restclient/mapping.py +104 -0
- persistence_kit-3.5.0/persistence_kit/restclient/memory.py +132 -0
- persistence_kit-3.5.0/persistence_kit/restclient/payload.py +68 -0
- persistence_kit-3.5.0/persistence_kit/restclient/populate.py +148 -0
- persistence_kit-3.5.0/persistence_kit/restclient/registry.py +109 -0
- persistence_kit-3.5.0/persistence_kit/restclient/resolver.py +151 -0
- persistence_kit-3.5.0/persistence_kit/restclient/retry.py +48 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/settings/app_settings.py +12 -1
- persistence_kit-3.5.0/persistence_kit/settings/parsers.py +29 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/storage/__init__.py +2 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/storage/media.py +4 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/storage/routes.py +26 -2
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/pyproject.toml +5 -3
- persistence_kit-3.3.0/persistence_kit/settings/parsers.py +0 -9
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/LICENSE +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/README.md +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/api/__init__.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/api/common.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/api/error_handlers.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/api/exceptions.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/api/rate_limit.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/api/route_loader.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/authenticated_user.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/bootstrap/__init__.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/bootstrap/configuration.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/bootstrap/seeders.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/bootstrap/startup.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/contracts/__init__.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/contracts/repository.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/contracts/view_repository.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/py.typed +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/__init__.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/dynamodb_repo/dynamodb_mapper.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/dynamodb_repo/dynamodb_repo.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/filter_ops.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/memory_repo/__init__.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/memory_repo/memory_repo.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/mongo_repo/__init__.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/mongo_repo/mongo_mapper.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/mongo_repo/mongo_repo.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/sqlalchemy_repo/__init__.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/sqlalchemy_repo/schema_evolve.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_dataclass_mapper.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_engine.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_repo.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/sqlalchemy_repo/table_factory.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository_factory/__init__.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository_factory/factory/__init__.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository_factory/factory/repository_factory.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository_factory/registry/__init__.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository_factory/registry/entity_registry.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository_factory/view/__init__.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository_factory/view/populating_repository.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/__init__.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/factory.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/ports.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/providers/__init__.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/providers/cognito_identity_provider.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/providers/memory_security_provider.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/registration.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/token_verifiers/__init__.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/token_verifiers/cognito_jwt_verifier.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/token_verifiers/memory_jwt_verifier.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/settings/__init__.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/settings/constants.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/settings/repo_settings.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/storage/contracts.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/storage/errors.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/storage/factory.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/storage/local.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/storage/s3.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/utils/__init__.py +0 -0
- {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/utils/upsert.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: persistence-kit
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.5.0
|
|
4
4
|
Summary: Reusable persistence and repository toolkit
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -21,6 +21,7 @@ Classifier: Typing :: Typed
|
|
|
21
21
|
Provides-Extra: all
|
|
22
22
|
Provides-Extra: api
|
|
23
23
|
Provides-Extra: dynamodb
|
|
24
|
+
Provides-Extra: restclient
|
|
24
25
|
Provides-Extra: security
|
|
25
26
|
Provides-Extra: security-cognito
|
|
26
27
|
Provides-Extra: storage-routes
|
|
@@ -29,6 +30,7 @@ Provides-Extra: testing
|
|
|
29
30
|
Requires-Dist: asyncpg (>=0.30.0,<0.31.0)
|
|
30
31
|
Requires-Dist: boto3 (>=1.35.0,<2.0.0) ; extra == "storage-s3" or extra == "security-cognito" or extra == "dynamodb" or extra == "all"
|
|
31
32
|
Requires-Dist: fastapi (>=0.115.0,<0.116.0) ; extra == "api" or extra == "storage-routes" or extra == "security" or extra == "security-cognito" or extra == "testing" or extra == "all"
|
|
33
|
+
Requires-Dist: httpx (>=0.28.0,<0.29.0) ; extra == "restclient" or extra == "testing" or extra == "all"
|
|
32
34
|
Requires-Dist: motor (>=3.7.1,<4.0.0)
|
|
33
35
|
Requires-Dist: pydantic-settings (>=2.3.0,<3.0.0)
|
|
34
36
|
Requires-Dist: pyjwt[crypto] (>=2.10.1,<3.0.0) ; extra == "security" or extra == "security-cognito" or extra == "testing" or extra == "all"
|
|
@@ -32,6 +32,7 @@ from .storage import (
|
|
|
32
32
|
get_export_storage,
|
|
33
33
|
)
|
|
34
34
|
from .authenticated_user import AuthenticatedUser
|
|
35
|
+
from .resilience import CircuitBreaker, CircuitOpenError, CircuitState
|
|
35
36
|
from .utils.upsert import dataclass_field_names, upsert_entity
|
|
36
37
|
|
|
37
38
|
_OPTIONAL_EXPORTS = {
|
|
@@ -175,6 +176,83 @@ _OPTIONAL_EXPORTS = {
|
|
|
175
176
|
"guess_export_media_type",
|
|
176
177
|
"storage-routes",
|
|
177
178
|
),
|
|
179
|
+
"build_rest_client": (
|
|
180
|
+
"persistence_kit.restclient.factory",
|
|
181
|
+
"build_rest_client",
|
|
182
|
+
"restclient",
|
|
183
|
+
),
|
|
184
|
+
"RestClientRegistry": (
|
|
185
|
+
"persistence_kit.restclient.registry",
|
|
186
|
+
"RestClientRegistry",
|
|
187
|
+
"restclient",
|
|
188
|
+
),
|
|
189
|
+
"HttpxRestClient": (
|
|
190
|
+
"persistence_kit.restclient.client",
|
|
191
|
+
"HttpxRestClient",
|
|
192
|
+
"restclient",
|
|
193
|
+
),
|
|
194
|
+
"MemoryRestClient": (
|
|
195
|
+
"persistence_kit.restclient.memory",
|
|
196
|
+
"MemoryRestClient",
|
|
197
|
+
"restclient",
|
|
198
|
+
),
|
|
199
|
+
"ServiceConfig": (
|
|
200
|
+
"persistence_kit.restclient.config",
|
|
201
|
+
"ServiceConfig",
|
|
202
|
+
"restclient",
|
|
203
|
+
),
|
|
204
|
+
"RetryPolicy": (
|
|
205
|
+
"persistence_kit.restclient.retry",
|
|
206
|
+
"RetryPolicy",
|
|
207
|
+
"restclient",
|
|
208
|
+
),
|
|
209
|
+
"StaticEndpointResolver": (
|
|
210
|
+
"persistence_kit.restclient.resolver",
|
|
211
|
+
"StaticEndpointResolver",
|
|
212
|
+
"restclient",
|
|
213
|
+
),
|
|
214
|
+
"DirectoryEndpointResolver": (
|
|
215
|
+
"persistence_kit.restclient.resolver",
|
|
216
|
+
"DirectoryEndpointResolver",
|
|
217
|
+
"restclient",
|
|
218
|
+
),
|
|
219
|
+
"decode": ("persistence_kit.restclient.mapping", "decode", "restclient"),
|
|
220
|
+
"encode": ("persistence_kit.restclient.mapping", "encode", "restclient"),
|
|
221
|
+
"NoAuth": ("persistence_kit.restclient.auth", "NoAuth", "restclient"),
|
|
222
|
+
"ApiKeyAuth": ("persistence_kit.restclient.auth", "ApiKeyAuth", "restclient"),
|
|
223
|
+
"BearerAuth": ("persistence_kit.restclient.auth", "BearerAuth", "restclient"),
|
|
224
|
+
"BasicAuth": ("persistence_kit.restclient.auth", "BasicAuth", "restclient"),
|
|
225
|
+
"OAuth2ClientCredentials": (
|
|
226
|
+
"persistence_kit.restclient.auth",
|
|
227
|
+
"OAuth2ClientCredentials",
|
|
228
|
+
"restclient",
|
|
229
|
+
),
|
|
230
|
+
"LoginTokenAuth": (
|
|
231
|
+
"persistence_kit.restclient.auth",
|
|
232
|
+
"LoginTokenAuth",
|
|
233
|
+
"restclient",
|
|
234
|
+
),
|
|
235
|
+
"RestClient": ("persistence_kit.restclient.contracts", "RestClient", "restclient"),
|
|
236
|
+
"Authenticator": (
|
|
237
|
+
"persistence_kit.restclient.contracts",
|
|
238
|
+
"Authenticator",
|
|
239
|
+
"restclient",
|
|
240
|
+
),
|
|
241
|
+
"EndpointResolver": (
|
|
242
|
+
"persistence_kit.restclient.contracts",
|
|
243
|
+
"EndpointResolver",
|
|
244
|
+
"restclient",
|
|
245
|
+
),
|
|
246
|
+
"RestClientError": (
|
|
247
|
+
"persistence_kit.restclient.errors",
|
|
248
|
+
"RestClientError",
|
|
249
|
+
"restclient",
|
|
250
|
+
),
|
|
251
|
+
"RestHTTPError": (
|
|
252
|
+
"persistence_kit.restclient.errors",
|
|
253
|
+
"RestHTTPError",
|
|
254
|
+
"restclient",
|
|
255
|
+
),
|
|
178
256
|
}
|
|
179
257
|
|
|
180
258
|
__all__ = [
|
|
@@ -200,6 +278,9 @@ __all__ = [
|
|
|
200
278
|
"is_duplicate_startup_error",
|
|
201
279
|
"run_startup_bootstrap",
|
|
202
280
|
"AuthenticatedUser",
|
|
281
|
+
"CircuitBreaker",
|
|
282
|
+
"CircuitState",
|
|
283
|
+
"CircuitOpenError",
|
|
203
284
|
"dataclass_field_names",
|
|
204
285
|
"upsert_entity",
|
|
205
286
|
"ObjectStorage",
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CircuitState(str, Enum):
|
|
8
|
+
CLOSED = "closed"
|
|
9
|
+
OPEN = "open"
|
|
10
|
+
HALF_OPEN = "half_open"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CircuitOpenError(Exception):
|
|
14
|
+
"""Raised when an operation is short-circuited because the circuit is open."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CircuitBreaker:
|
|
18
|
+
"""Generic in-process circuit breaker for any external operation.
|
|
19
|
+
|
|
20
|
+
Domain-agnostic: it only counts successes and failures. The caller decides
|
|
21
|
+
what counts as a failure (an exception, a 5xx status, a slow response, a DB
|
|
22
|
+
error) and calls :meth:`record_failure` / :meth:`record_success`, gating each
|
|
23
|
+
attempt on :meth:`allow` (or :meth:`guard`).
|
|
24
|
+
|
|
25
|
+
After ``failure_threshold`` consecutive failures the circuit opens and calls
|
|
26
|
+
are rejected for ``recovery_timeout`` seconds. It then moves to half-open and
|
|
27
|
+
lets ``half_open_max_calls`` probe calls through: a success closes it, a
|
|
28
|
+
failure re-opens it.
|
|
29
|
+
|
|
30
|
+
Not synchronized; intended for a single event loop per breaker instance.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
failure_threshold: int = 5,
|
|
37
|
+
recovery_timeout: float = 30.0,
|
|
38
|
+
half_open_max_calls: int = 1,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.failure_threshold = failure_threshold
|
|
41
|
+
self.recovery_timeout = recovery_timeout
|
|
42
|
+
self.half_open_max_calls = half_open_max_calls
|
|
43
|
+
self._state = CircuitState.CLOSED
|
|
44
|
+
self._failures = 0
|
|
45
|
+
self._opened_at = 0.0
|
|
46
|
+
self._half_open_calls = 0
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def state(self) -> CircuitState:
|
|
50
|
+
return self._state
|
|
51
|
+
|
|
52
|
+
def allow(self) -> bool:
|
|
53
|
+
if self._state is CircuitState.OPEN:
|
|
54
|
+
if time.monotonic() - self._opened_at >= self.recovery_timeout:
|
|
55
|
+
self._state = CircuitState.HALF_OPEN
|
|
56
|
+
self._half_open_calls = 0
|
|
57
|
+
else:
|
|
58
|
+
return False
|
|
59
|
+
if self._state is CircuitState.HALF_OPEN:
|
|
60
|
+
if self._half_open_calls >= self.half_open_max_calls:
|
|
61
|
+
return False
|
|
62
|
+
self._half_open_calls += 1
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
def guard(self, message: str = "Circuito abierto; operacion corto-circuitada.") -> None:
|
|
66
|
+
if not self.allow():
|
|
67
|
+
raise CircuitOpenError(message)
|
|
68
|
+
|
|
69
|
+
def record_success(self) -> None:
|
|
70
|
+
self._failures = 0
|
|
71
|
+
self._half_open_calls = 0
|
|
72
|
+
self._state = CircuitState.CLOSED
|
|
73
|
+
|
|
74
|
+
def record_failure(self) -> None:
|
|
75
|
+
if self._state is CircuitState.HALF_OPEN:
|
|
76
|
+
self._trip()
|
|
77
|
+
return
|
|
78
|
+
self._failures += 1
|
|
79
|
+
if self._failures >= self.failure_threshold:
|
|
80
|
+
self._trip()
|
|
81
|
+
|
|
82
|
+
def _trip(self) -> None:
|
|
83
|
+
self._state = CircuitState.OPEN
|
|
84
|
+
self._opened_at = time.monotonic()
|
|
85
|
+
self._half_open_calls = 0
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from persistence_kit.restclient.auth import (
|
|
2
|
+
ApiKeyAuth,
|
|
3
|
+
ApiKeyLocation,
|
|
4
|
+
BasicAuth,
|
|
5
|
+
BearerAuth,
|
|
6
|
+
LoginTokenAuth,
|
|
7
|
+
NoAuth,
|
|
8
|
+
OAuth2ClientCredentials,
|
|
9
|
+
)
|
|
10
|
+
from persistence_kit.resilience import CircuitBreaker, CircuitState
|
|
11
|
+
from persistence_kit.restclient.config import ServiceConfig
|
|
12
|
+
from persistence_kit.restclient.contracts import (
|
|
13
|
+
Authenticator,
|
|
14
|
+
EndpointResolver,
|
|
15
|
+
RestClient,
|
|
16
|
+
RestRequest,
|
|
17
|
+
RestResponse,
|
|
18
|
+
)
|
|
19
|
+
from persistence_kit.restclient.errors import (
|
|
20
|
+
RestAuthError,
|
|
21
|
+
RestCircuitOpenError,
|
|
22
|
+
RestClientError,
|
|
23
|
+
RestConfigError,
|
|
24
|
+
RestHTTPError,
|
|
25
|
+
RestTimeoutError,
|
|
26
|
+
RestTransportError,
|
|
27
|
+
)
|
|
28
|
+
from persistence_kit.restclient.aggregate import (
|
|
29
|
+
Expansion,
|
|
30
|
+
expand,
|
|
31
|
+
expand_chain,
|
|
32
|
+
expand_recursive,
|
|
33
|
+
)
|
|
34
|
+
from persistence_kit.restclient.factory import build_rest_client
|
|
35
|
+
from persistence_kit.restclient.mapping import (
|
|
36
|
+
ModelMappingMixin,
|
|
37
|
+
decode,
|
|
38
|
+
encode,
|
|
39
|
+
)
|
|
40
|
+
from persistence_kit.restclient.memory import MemoryRestClient
|
|
41
|
+
from persistence_kit.restclient.populate import (
|
|
42
|
+
RestRelation,
|
|
43
|
+
RestRelationRegistry,
|
|
44
|
+
populate,
|
|
45
|
+
)
|
|
46
|
+
from persistence_kit.restclient.payload import serialize_xml
|
|
47
|
+
from persistence_kit.restclient.registry import RegisteredService, RestClientRegistry
|
|
48
|
+
from persistence_kit.restclient.resolver import (
|
|
49
|
+
DirectoryEndpointResolver,
|
|
50
|
+
StaticEndpointResolver,
|
|
51
|
+
)
|
|
52
|
+
from persistence_kit.restclient.retry import RetryPolicy
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"RestClient",
|
|
56
|
+
"RestRequest",
|
|
57
|
+
"RestResponse",
|
|
58
|
+
"Authenticator",
|
|
59
|
+
"EndpointResolver",
|
|
60
|
+
"ServiceConfig",
|
|
61
|
+
"StaticEndpointResolver",
|
|
62
|
+
"DirectoryEndpointResolver",
|
|
63
|
+
"serialize_xml",
|
|
64
|
+
"RetryPolicy",
|
|
65
|
+
"CircuitBreaker",
|
|
66
|
+
"CircuitState",
|
|
67
|
+
"decode",
|
|
68
|
+
"encode",
|
|
69
|
+
"ModelMappingMixin",
|
|
70
|
+
"expand",
|
|
71
|
+
"expand_chain",
|
|
72
|
+
"expand_recursive",
|
|
73
|
+
"Expansion",
|
|
74
|
+
"populate",
|
|
75
|
+
"RestRelation",
|
|
76
|
+
"RestRelationRegistry",
|
|
77
|
+
"NoAuth",
|
|
78
|
+
"ApiKeyAuth",
|
|
79
|
+
"ApiKeyLocation",
|
|
80
|
+
"BearerAuth",
|
|
81
|
+
"BasicAuth",
|
|
82
|
+
"OAuth2ClientCredentials",
|
|
83
|
+
"LoginTokenAuth",
|
|
84
|
+
"MemoryRestClient",
|
|
85
|
+
"RestClientRegistry",
|
|
86
|
+
"RegisteredService",
|
|
87
|
+
"build_rest_client",
|
|
88
|
+
"RestClientError",
|
|
89
|
+
"RestConfigError",
|
|
90
|
+
"RestAuthError",
|
|
91
|
+
"RestTimeoutError",
|
|
92
|
+
"RestTransportError",
|
|
93
|
+
"RestCircuitOpenError",
|
|
94
|
+
"RestHTTPError",
|
|
95
|
+
"HttpxRestClient",
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def __getattr__(name: str):
|
|
100
|
+
if name == "HttpxRestClient":
|
|
101
|
+
from persistence_kit.restclient.client import HttpxRestClient
|
|
102
|
+
|
|
103
|
+
globals()[name] = HttpxRestClient
|
|
104
|
+
return HttpxRestClient
|
|
105
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Awaitable, Callable, Sequence
|
|
6
|
+
|
|
7
|
+
Fetch = Callable[[Any], Awaitable[Sequence[Any]]]
|
|
8
|
+
Attach = "str | Callable[[Any, list[Any]], None]"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _attach(node: Any, children: list[Any], attach: Any) -> None:
|
|
12
|
+
if callable(attach):
|
|
13
|
+
attach(node, children)
|
|
14
|
+
elif isinstance(node, dict):
|
|
15
|
+
node[attach] = children
|
|
16
|
+
else:
|
|
17
|
+
setattr(node, attach, children)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class Expansion:
|
|
22
|
+
"""One level of a hierarchy: how to fetch a node's children and where to
|
|
23
|
+
place them on the node."""
|
|
24
|
+
|
|
25
|
+
fetch: Fetch
|
|
26
|
+
attach: Any
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def expand(
|
|
30
|
+
nodes: Sequence[Any],
|
|
31
|
+
fetch: Fetch,
|
|
32
|
+
attach: Any,
|
|
33
|
+
*,
|
|
34
|
+
concurrency: int = 8,
|
|
35
|
+
) -> list[Any]:
|
|
36
|
+
"""Fetch the children of every node concurrently, attach them, and return the
|
|
37
|
+
flattened next frontier so levels can be chained.
|
|
38
|
+
|
|
39
|
+
``fetch`` is any async ``node -> children``; it needs no knowledge of the
|
|
40
|
+
transport. ``attach`` is a field name (attribute or dict key) or a callable
|
|
41
|
+
``(node, children) -> None``.
|
|
42
|
+
"""
|
|
43
|
+
nodes = list(nodes)
|
|
44
|
+
if not nodes:
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
semaphore = asyncio.Semaphore(max(1, concurrency))
|
|
48
|
+
|
|
49
|
+
async def run(node: Any) -> list[Any]:
|
|
50
|
+
async with semaphore:
|
|
51
|
+
children = list(await fetch(node))
|
|
52
|
+
_attach(node, children, attach)
|
|
53
|
+
return children
|
|
54
|
+
|
|
55
|
+
results = await asyncio.gather(*(run(node) for node in nodes))
|
|
56
|
+
|
|
57
|
+
frontier: list[Any] = []
|
|
58
|
+
for children in results:
|
|
59
|
+
frontier.extend(children)
|
|
60
|
+
return frontier
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def expand_chain(
|
|
64
|
+
roots: Sequence[Any],
|
|
65
|
+
expansions: Sequence[Expansion],
|
|
66
|
+
*,
|
|
67
|
+
concurrency: int = 8,
|
|
68
|
+
) -> list[Any]:
|
|
69
|
+
"""Apply several expansion levels in a single call. Each level expands the
|
|
70
|
+
frontier produced by the previous one, so ``roots`` ends up fully nested."""
|
|
71
|
+
frontier: list[Any] = list(roots)
|
|
72
|
+
for expansion in expansions:
|
|
73
|
+
frontier = await expand(
|
|
74
|
+
frontier, expansion.fetch, expansion.attach, concurrency=concurrency
|
|
75
|
+
)
|
|
76
|
+
return list(roots)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def expand_recursive(
|
|
80
|
+
nodes: Sequence[Any],
|
|
81
|
+
fetch: Fetch,
|
|
82
|
+
attach: Any,
|
|
83
|
+
*,
|
|
84
|
+
concurrency: int = 8,
|
|
85
|
+
max_depth: int | None = None,
|
|
86
|
+
) -> list[Any]:
|
|
87
|
+
"""Expand a self-referential hierarchy of homogeneous nodes until no children
|
|
88
|
+
are returned or ``max_depth`` is reached."""
|
|
89
|
+
roots = list(nodes)
|
|
90
|
+
frontier = roots
|
|
91
|
+
depth = 0
|
|
92
|
+
while frontier and (max_depth is None or depth < max_depth):
|
|
93
|
+
frontier = await expand(frontier, fetch, attach, concurrency=concurrency)
|
|
94
|
+
depth += 1
|
|
95
|
+
return roots
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from persistence_kit.restclient.auth.api_key import ApiKeyAuth, ApiKeyLocation
|
|
2
|
+
from persistence_kit.restclient.auth.base import NoAuth
|
|
3
|
+
from persistence_kit.restclient.auth.basic import BasicAuth
|
|
4
|
+
from persistence_kit.restclient.auth.bearer import BearerAuth
|
|
5
|
+
from persistence_kit.restclient.auth.login import LoginTokenAuth
|
|
6
|
+
from persistence_kit.restclient.auth.oauth2 import OAuth2ClientCredentials
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"NoAuth",
|
|
10
|
+
"ApiKeyAuth",
|
|
11
|
+
"ApiKeyLocation",
|
|
12
|
+
"BearerAuth",
|
|
13
|
+
"BasicAuth",
|
|
14
|
+
"OAuth2ClientCredentials",
|
|
15
|
+
"LoginTokenAuth",
|
|
16
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from persistence_kit.restclient.contracts import RestRequest, RestResponse
|
|
6
|
+
|
|
7
|
+
ApiKeyLocation = Literal["header", "query"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ApiKeyAuth:
|
|
11
|
+
"""Injects an API key either as a request header or a query parameter."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
name: str,
|
|
16
|
+
value: str,
|
|
17
|
+
*,
|
|
18
|
+
location: ApiKeyLocation = "header",
|
|
19
|
+
) -> None:
|
|
20
|
+
if location not in ("header", "query"):
|
|
21
|
+
raise ValueError("location debe ser 'header' o 'query'.")
|
|
22
|
+
self._name = name
|
|
23
|
+
self._value = value
|
|
24
|
+
self._location = location
|
|
25
|
+
|
|
26
|
+
async def apply(self, request: RestRequest) -> RestRequest:
|
|
27
|
+
if self._location == "header":
|
|
28
|
+
request.headers[self._name] = self._value
|
|
29
|
+
else:
|
|
30
|
+
request.params[self._name] = self._value
|
|
31
|
+
return request
|
|
32
|
+
|
|
33
|
+
async def on_unauthorized(self, response: RestResponse) -> bool:
|
|
34
|
+
return False
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from persistence_kit.restclient.contracts import RestRequest, RestResponse
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NoAuth:
|
|
7
|
+
"""Authenticator for public APIs that require no credentials."""
|
|
8
|
+
|
|
9
|
+
async def apply(self, request: RestRequest) -> RestRequest:
|
|
10
|
+
return request
|
|
11
|
+
|
|
12
|
+
async def on_unauthorized(self, response: RestResponse) -> bool:
|
|
13
|
+
return False
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
|
|
5
|
+
from persistence_kit.restclient.contracts import RestRequest, RestResponse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BasicAuth:
|
|
9
|
+
"""HTTP Basic authentication."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, username: str, password: str) -> None:
|
|
12
|
+
raw = f"{username}:{password}".encode("utf-8")
|
|
13
|
+
self._value = "Basic " + base64.b64encode(raw).decode("ascii")
|
|
14
|
+
|
|
15
|
+
async def apply(self, request: RestRequest) -> RestRequest:
|
|
16
|
+
request.headers["Authorization"] = self._value
|
|
17
|
+
return request
|
|
18
|
+
|
|
19
|
+
async def on_unauthorized(self, response: RestResponse) -> bool:
|
|
20
|
+
return False
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from persistence_kit.restclient.contracts import RestRequest, RestResponse
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BearerAuth:
|
|
7
|
+
"""Injects a static bearer (or custom scheme) token in a header."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
token: str,
|
|
12
|
+
*,
|
|
13
|
+
scheme: str = "Bearer",
|
|
14
|
+
header: str = "Authorization",
|
|
15
|
+
) -> None:
|
|
16
|
+
self._token = token
|
|
17
|
+
self._scheme = scheme
|
|
18
|
+
self._header = header
|
|
19
|
+
|
|
20
|
+
async def apply(self, request: RestRequest) -> RestRequest:
|
|
21
|
+
prefix = f"{self._scheme} " if self._scheme else ""
|
|
22
|
+
request.headers[self._header] = f"{prefix}{self._token}"
|
|
23
|
+
return request
|
|
24
|
+
|
|
25
|
+
async def on_unauthorized(self, response: RestResponse) -> bool:
|
|
26
|
+
return False
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
|
|
6
|
+
from persistence_kit.restclient.contracts import RestRequest, RestResponse
|
|
7
|
+
from persistence_kit.restclient.errors import RestAuthError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _dig(data: Any, path: str) -> Any:
|
|
11
|
+
current = data
|
|
12
|
+
for part in path.split("."):
|
|
13
|
+
if isinstance(current, dict):
|
|
14
|
+
current = current.get(part)
|
|
15
|
+
else:
|
|
16
|
+
current = getattr(current, part, None)
|
|
17
|
+
return current
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LoginTokenAuth:
|
|
21
|
+
"""Logs in with username/password at a login endpoint and sends the returned
|
|
22
|
+
token as a bearer. The token is cached until shortly before expiry and
|
|
23
|
+
re-fetched automatically after a 401.
|
|
24
|
+
|
|
25
|
+
``send_as`` picks the request body ("json" or "form"). ``token_field`` and
|
|
26
|
+
``expires_field`` are dotted paths into the login response.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
login_url: str,
|
|
33
|
+
username: str,
|
|
34
|
+
password: str,
|
|
35
|
+
username_field: str = "username",
|
|
36
|
+
password_field: str = "password",
|
|
37
|
+
send_as: str = "json",
|
|
38
|
+
token_field: str = "access_token",
|
|
39
|
+
expires_field: str | None = "expires_in",
|
|
40
|
+
default_ttl_seconds: float = 3600.0,
|
|
41
|
+
extra_fields: Mapping[str, Any] | None = None,
|
|
42
|
+
header: str = "Authorization",
|
|
43
|
+
scheme: str = "Bearer",
|
|
44
|
+
leeway_seconds: float = 30.0,
|
|
45
|
+
timeout_seconds: float = 10.0,
|
|
46
|
+
verify_tls: bool = True,
|
|
47
|
+
) -> None:
|
|
48
|
+
if send_as not in ("json", "form"):
|
|
49
|
+
raise ValueError("send_as debe ser 'json' o 'form'.")
|
|
50
|
+
self._login_url = login_url
|
|
51
|
+
self._username = username
|
|
52
|
+
self._password = password
|
|
53
|
+
self._username_field = username_field
|
|
54
|
+
self._password_field = password_field
|
|
55
|
+
self._send_as = send_as
|
|
56
|
+
self._token_field = token_field
|
|
57
|
+
self._expires_field = expires_field
|
|
58
|
+
self._default_ttl = default_ttl_seconds
|
|
59
|
+
self._extra_fields = dict(extra_fields or {})
|
|
60
|
+
self._header = header
|
|
61
|
+
self._scheme = scheme
|
|
62
|
+
self._leeway = leeway_seconds
|
|
63
|
+
self._timeout = timeout_seconds
|
|
64
|
+
self._verify_tls = verify_tls
|
|
65
|
+
self._token: str | None = None
|
|
66
|
+
self._expires_at = 0.0
|
|
67
|
+
|
|
68
|
+
async def apply(self, request: RestRequest) -> RestRequest:
|
|
69
|
+
token = await self._ensure_token()
|
|
70
|
+
prefix = f"{self._scheme} " if self._scheme else ""
|
|
71
|
+
request.headers[self._header] = f"{prefix}{token}"
|
|
72
|
+
return request
|
|
73
|
+
|
|
74
|
+
async def on_unauthorized(self, response: RestResponse) -> bool:
|
|
75
|
+
self._token = None
|
|
76
|
+
self._expires_at = 0.0
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
async def _ensure_token(self) -> str:
|
|
80
|
+
if self._token and time.monotonic() < self._expires_at - self._leeway:
|
|
81
|
+
return self._token
|
|
82
|
+
await self._login()
|
|
83
|
+
assert self._token is not None
|
|
84
|
+
return self._token
|
|
85
|
+
|
|
86
|
+
async def _login(self) -> None:
|
|
87
|
+
import httpx
|
|
88
|
+
|
|
89
|
+
payload: dict[str, Any] = {
|
|
90
|
+
self._username_field: self._username,
|
|
91
|
+
self._password_field: self._password,
|
|
92
|
+
}
|
|
93
|
+
payload.update(self._extra_fields)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
async with httpx.AsyncClient(
|
|
97
|
+
timeout=self._timeout, verify=self._verify_tls
|
|
98
|
+
) as client:
|
|
99
|
+
if self._send_as == "json":
|
|
100
|
+
response = await client.post(self._login_url, json=payload)
|
|
101
|
+
else:
|
|
102
|
+
response = await client.post(self._login_url, data=payload)
|
|
103
|
+
except httpx.HTTPError as exc:
|
|
104
|
+
raise RestAuthError(
|
|
105
|
+
f"No se pudo contactar el login endpoint '{self._login_url}': {exc}"
|
|
106
|
+
) from exc
|
|
107
|
+
|
|
108
|
+
if response.status_code >= 400:
|
|
109
|
+
raise RestAuthError(
|
|
110
|
+
f"El login endpoint respondio {response.status_code} al autenticar."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
data = response.json()
|
|
114
|
+
token = _dig(data, self._token_field)
|
|
115
|
+
if not token:
|
|
116
|
+
raise RestAuthError(
|
|
117
|
+
f"La respuesta del login no incluye el campo '{self._token_field}'."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
self._token = str(token)
|
|
121
|
+
expires = _dig(data, self._expires_field) if self._expires_field else None
|
|
122
|
+
self._expires_at = time.monotonic() + (
|
|
123
|
+
float(expires) if expires else self._default_ttl
|
|
124
|
+
)
|