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.
Files changed (91) hide show
  1. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/PKG-INFO +3 -1
  2. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/__init__.py +81 -0
  3. persistence_kit-3.5.0/persistence_kit/resilience/__init__.py +11 -0
  4. persistence_kit-3.5.0/persistence_kit/resilience/circuit.py +85 -0
  5. persistence_kit-3.5.0/persistence_kit/restclient/__init__.py +105 -0
  6. persistence_kit-3.5.0/persistence_kit/restclient/aggregate.py +95 -0
  7. persistence_kit-3.5.0/persistence_kit/restclient/auth/__init__.py +16 -0
  8. persistence_kit-3.5.0/persistence_kit/restclient/auth/api_key.py +34 -0
  9. persistence_kit-3.5.0/persistence_kit/restclient/auth/base.py +13 -0
  10. persistence_kit-3.5.0/persistence_kit/restclient/auth/basic.py +20 -0
  11. persistence_kit-3.5.0/persistence_kit/restclient/auth/bearer.py +26 -0
  12. persistence_kit-3.5.0/persistence_kit/restclient/auth/login.py +124 -0
  13. persistence_kit-3.5.0/persistence_kit/restclient/auth/oauth2.py +96 -0
  14. persistence_kit-3.5.0/persistence_kit/restclient/client.py +179 -0
  15. persistence_kit-3.5.0/persistence_kit/restclient/config.py +32 -0
  16. persistence_kit-3.5.0/persistence_kit/restclient/contracts.py +99 -0
  17. persistence_kit-3.5.0/persistence_kit/restclient/errors.py +41 -0
  18. persistence_kit-3.5.0/persistence_kit/restclient/factory.py +51 -0
  19. persistence_kit-3.5.0/persistence_kit/restclient/mapping.py +104 -0
  20. persistence_kit-3.5.0/persistence_kit/restclient/memory.py +132 -0
  21. persistence_kit-3.5.0/persistence_kit/restclient/payload.py +68 -0
  22. persistence_kit-3.5.0/persistence_kit/restclient/populate.py +148 -0
  23. persistence_kit-3.5.0/persistence_kit/restclient/registry.py +109 -0
  24. persistence_kit-3.5.0/persistence_kit/restclient/resolver.py +151 -0
  25. persistence_kit-3.5.0/persistence_kit/restclient/retry.py +48 -0
  26. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/settings/app_settings.py +12 -1
  27. persistence_kit-3.5.0/persistence_kit/settings/parsers.py +29 -0
  28. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/storage/__init__.py +2 -0
  29. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/storage/media.py +4 -0
  30. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/storage/routes.py +26 -2
  31. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/pyproject.toml +5 -3
  32. persistence_kit-3.3.0/persistence_kit/settings/parsers.py +0 -9
  33. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/LICENSE +0 -0
  34. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/README.md +0 -0
  35. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/api/__init__.py +0 -0
  36. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/api/common.py +0 -0
  37. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/api/error_handlers.py +0 -0
  38. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/api/exceptions.py +0 -0
  39. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/api/rate_limit.py +0 -0
  40. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/api/route_loader.py +0 -0
  41. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/authenticated_user.py +0 -0
  42. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/bootstrap/__init__.py +0 -0
  43. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/bootstrap/configuration.py +0 -0
  44. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/bootstrap/seeders.py +0 -0
  45. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/bootstrap/startup.py +0 -0
  46. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/contracts/__init__.py +0 -0
  47. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/contracts/repository.py +0 -0
  48. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/contracts/view_repository.py +0 -0
  49. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/py.typed +0 -0
  50. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/__init__.py +0 -0
  51. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/dynamodb_repo/dynamodb_mapper.py +0 -0
  52. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/dynamodb_repo/dynamodb_repo.py +0 -0
  53. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/filter_ops.py +0 -0
  54. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/memory_repo/__init__.py +0 -0
  55. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/memory_repo/memory_repo.py +0 -0
  56. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/mongo_repo/__init__.py +0 -0
  57. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/mongo_repo/mongo_mapper.py +0 -0
  58. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/mongo_repo/mongo_repo.py +0 -0
  59. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/sqlalchemy_repo/__init__.py +0 -0
  60. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/sqlalchemy_repo/schema_evolve.py +0 -0
  61. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_dataclass_mapper.py +0 -0
  62. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_engine.py +0 -0
  63. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_repo.py +0 -0
  64. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository/sqlalchemy_repo/table_factory.py +0 -0
  65. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository_factory/__init__.py +0 -0
  66. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository_factory/factory/__init__.py +0 -0
  67. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository_factory/factory/repository_factory.py +0 -0
  68. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository_factory/registry/__init__.py +0 -0
  69. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository_factory/registry/entity_registry.py +0 -0
  70. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository_factory/view/__init__.py +0 -0
  71. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/repository_factory/view/populating_repository.py +0 -0
  72. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/__init__.py +0 -0
  73. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/factory.py +0 -0
  74. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/ports.py +0 -0
  75. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/providers/__init__.py +0 -0
  76. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/providers/cognito_identity_provider.py +0 -0
  77. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/providers/memory_security_provider.py +0 -0
  78. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/registration.py +0 -0
  79. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/token_verifiers/__init__.py +0 -0
  80. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/token_verifiers/cognito_jwt_verifier.py +0 -0
  81. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/security/token_verifiers/memory_jwt_verifier.py +0 -0
  82. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/settings/__init__.py +0 -0
  83. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/settings/constants.py +0 -0
  84. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/settings/repo_settings.py +0 -0
  85. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/storage/contracts.py +0 -0
  86. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/storage/errors.py +0 -0
  87. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/storage/factory.py +0 -0
  88. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/storage/local.py +0 -0
  89. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/storage/s3.py +0 -0
  90. {persistence_kit-3.3.0 → persistence_kit-3.5.0}/persistence_kit/utils/__init__.py +0 -0
  91. {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.0
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,11 @@
1
+ from persistence_kit.resilience.circuit import (
2
+ CircuitBreaker,
3
+ CircuitOpenError,
4
+ CircuitState,
5
+ )
6
+
7
+ __all__ = [
8
+ "CircuitBreaker",
9
+ "CircuitState",
10
+ "CircuitOpenError",
11
+ ]
@@ -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
+ )