persistence-kit 3.14.1__tar.gz → 3.16.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 (119) hide show
  1. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/PKG-INFO +22 -2
  2. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/README.md +19 -1
  3. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/api/rate_limit.py +6 -11
  4. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/client.py +12 -4
  5. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/factory.py +2 -0
  6. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/memory.py +21 -16
  7. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/registry.py +3 -0
  8. persistence_kit-3.16.0/persistence_kit/restclient/tracing.py +59 -0
  9. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/settings/app_settings.py +2 -1
  10. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/storage/factory.py +3 -3
  11. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/storage/s3.py +32 -16
  12. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/pyproject.toml +6 -3
  13. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/LICENSE +0 -0
  14. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/__init__.py +0 -0
  15. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/api/__init__.py +0 -0
  16. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/api/common.py +0 -0
  17. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/api/encrypted_middleware.py +0 -0
  18. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/api/encrypted_routes.py +0 -0
  19. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/api/error_handlers.py +0 -0
  20. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/api/exceptions.py +0 -0
  21. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/api/route_loader.py +0 -0
  22. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/authenticated_user.py +0 -0
  23. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/bootstrap/__init__.py +0 -0
  24. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/bootstrap/configuration.py +0 -0
  25. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/bootstrap/seeders.py +0 -0
  26. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/bootstrap/startup.py +0 -0
  27. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/cache/__init__.py +0 -0
  28. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/cache/contracts.py +0 -0
  29. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/cache/dynamodb.py +0 -0
  30. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/cache/factory.py +0 -0
  31. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/cache/memory.py +0 -0
  32. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/cache/mongo.py +0 -0
  33. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/cache/namespaced.py +0 -0
  34. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/contracts/__init__.py +0 -0
  35. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/contracts/repository.py +0 -0
  36. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/contracts/view_repository.py +0 -0
  37. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/mail/__init__.py +0 -0
  38. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/mail/contracts.py +0 -0
  39. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/mail/errors.py +0 -0
  40. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/mail/factory.py +0 -0
  41. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/mail/mailer.py +0 -0
  42. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/mail/memory.py +0 -0
  43. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/mail/message.py +0 -0
  44. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/mail/mime.py +0 -0
  45. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/mail/preview.py +0 -0
  46. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/mail/smtp.py +0 -0
  47. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/mail/templates.py +0 -0
  48. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/py.typed +0 -0
  49. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository/__init__.py +0 -0
  50. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository/dynamodb_repo/dynamodb_mapper.py +0 -0
  51. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository/dynamodb_repo/dynamodb_repo.py +0 -0
  52. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository/filter_ops.py +0 -0
  53. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository/memory_repo/__init__.py +0 -0
  54. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository/memory_repo/memory_repo.py +0 -0
  55. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository/mongo_repo/__init__.py +0 -0
  56. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository/mongo_repo/mongo_mapper.py +0 -0
  57. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository/mongo_repo/mongo_repo.py +0 -0
  58. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository/sqlalchemy_repo/__init__.py +0 -0
  59. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository/sqlalchemy_repo/schema_evolve.py +0 -0
  60. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_dataclass_mapper.py +0 -0
  61. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_engine.py +0 -0
  62. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_repo.py +0 -0
  63. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository/sqlalchemy_repo/table_factory.py +0 -0
  64. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository_factory/__init__.py +0 -0
  65. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository_factory/factory/__init__.py +0 -0
  66. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository_factory/factory/repository_factory.py +0 -0
  67. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository_factory/registry/__init__.py +0 -0
  68. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository_factory/registry/entity_registry.py +0 -0
  69. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository_factory/view/__init__.py +0 -0
  70. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/repository_factory/view/populating_repository.py +0 -0
  71. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/resilience/__init__.py +0 -0
  72. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/resilience/circuit.py +0 -0
  73. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/__init__.py +0 -0
  74. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/aggregate.py +0 -0
  75. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/auth/__init__.py +0 -0
  76. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/auth/api_key.py +0 -0
  77. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/auth/base.py +0 -0
  78. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/auth/basic.py +0 -0
  79. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/auth/bearer.py +0 -0
  80. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/auth/login.py +0 -0
  81. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/auth/oauth2.py +0 -0
  82. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/caching.py +0 -0
  83. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/config.py +0 -0
  84. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/contracts.py +0 -0
  85. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/errors.py +0 -0
  86. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/mapping.py +0 -0
  87. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/payload.py +0 -0
  88. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/populate.py +0 -0
  89. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/provider.py +0 -0
  90. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/resolver.py +0 -0
  91. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/restclient/retry.py +0 -0
  92. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/security/__init__.py +0 -0
  93. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/security/encrypted/__init__.py +0 -0
  94. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/security/encrypted/envelope.py +0 -0
  95. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/security/encrypted/errors.py +0 -0
  96. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/security/factory.py +0 -0
  97. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/security/ports.py +0 -0
  98. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/security/providers/__init__.py +0 -0
  99. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/security/providers/cognito_identity_provider.py +0 -0
  100. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/security/providers/encryption_provider.py +0 -0
  101. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/security/providers/memory_security_provider.py +0 -0
  102. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/security/registration.py +0 -0
  103. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/security/token_verifiers/__init__.py +0 -0
  104. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/security/token_verifiers/cognito_jwt_verifier.py +0 -0
  105. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/security/token_verifiers/memory_jwt_verifier.py +0 -0
  106. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/settings/__init__.py +0 -0
  107. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/settings/cache_settings.py +0 -0
  108. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/settings/constants.py +0 -0
  109. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/settings/mail_settings.py +0 -0
  110. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/settings/parsers.py +0 -0
  111. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/settings/repo_settings.py +0 -0
  112. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/storage/__init__.py +0 -0
  113. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/storage/contracts.py +0 -0
  114. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/storage/errors.py +0 -0
  115. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/storage/local.py +0 -0
  116. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/storage/media.py +0 -0
  117. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/storage/routes.py +0 -0
  118. {persistence_kit-3.14.1 → persistence_kit-3.16.0}/persistence_kit/utils/__init__.py +0 -0
  119. {persistence_kit-3.14.1 → persistence_kit-3.16.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.14.1
3
+ Version: 3.16.0
4
4
  Summary: Reusable persistence and repository toolkit
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -30,6 +30,7 @@ Provides-Extra: security-cognito
30
30
  Provides-Extra: storage-routes
31
31
  Provides-Extra: storage-s3
32
32
  Provides-Extra: testing
33
+ Provides-Extra: tracing
33
34
  Requires-Dist: asyncpg (>=0.30.0,<1.0.0)
34
35
  Requires-Dist: boto3 (>=1.35.0,<2.0.0) ; extra == "storage-s3" or extra == "security-cognito" or extra == "dynamodb" or extra == "all"
35
36
  Requires-Dist: cryptography (>=42.0.0) ; extra == "encrypted" or extra == "testing" or extra == "all"
@@ -37,6 +38,7 @@ Requires-Dist: fastapi (>=0.115.0,<1.0.0) ; extra == "api" or extra == "storage-
37
38
  Requires-Dist: httpx (>=0.28.0,<1.0.0) ; extra == "restclient" or extra == "testing" or extra == "all"
38
39
  Requires-Dist: jinja2 (>=3.1.4,<4.0.0) ; extra == "mail" or extra == "testing" or extra == "all"
39
40
  Requires-Dist: motor (>=3.7.1,<4.0.0)
41
+ Requires-Dist: opentelemetry-api (>=1.20.0,<2.0.0) ; extra == "tracing" or extra == "testing" or extra == "all"
40
42
  Requires-Dist: pydantic-settings (>=2.3.0,<3.0.0)
41
43
  Requires-Dist: pyjwt[crypto] (>=2.10.1,<3.0.0) ; extra == "security" or extra == "security-cognito" or extra == "testing" or extra == "all"
42
44
  Requires-Dist: python-multipart (>=0.0.31,<1.0.0) ; extra == "api" or extra == "storage-routes" or extra == "security" or extra == "security-cognito" or extra == "testing" or extra == "all"
@@ -156,7 +158,8 @@ pip install "persistence-kit[api,security,restclient]"
156
158
  | `restclient` | The REST client and its cache | httpx |
157
159
  | `encrypted` | Encrypted request and response payloads | fastapi, cryptography |
158
160
  | `mail` | Mail templates rendered with Jinja2 (sending itself needs no extra) | jinja2 |
159
- | `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart, cryptography, jinja2 |
161
+ | `tracing` | A span for every REST call, inside the request's trace | opentelemetry-api |
162
+ | `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart, cryptography, jinja2, opentelemetry-api |
160
163
  | `all` | Every optional capability | all of the above |
161
164
 
162
165
  Importing `persistence_kit` never loads an optional dependency by itself. Ask for
@@ -206,6 +209,15 @@ url = await storage.generate_presigned_url(key)
206
209
  `LocalObjectStorage` writes to a directory and signs its own download URLs.
207
210
  `S3ObjectStorage` uploads to S3 and returns AWS presigned URLs. Pick one with
208
211
  `get_export_storage(settings)`, which reads your settings and caches the adapter.
212
+ Give `S3ObjectStorage` a `prefix` and every key it writes, presigns, deletes or
213
+ lists stays under that folder of the bucket, while the keys you pass and get back
214
+ stay relative to it.
215
+
216
+ For files your users upload, `build_media_storage(settings, namespace=...,
217
+ url_prefix=...)` checks type and size and keeps each namespace in its own
218
+ folder: `<LOCAL_MEDIA_STORAGE_DIR>/<namespace>` with `MEDIA_STORAGE_PROVIDER=local`,
219
+ and `<namespace>/` inside `AWS_S3_MEDIA_BUCKET` with `s3`. Keys are the same on
220
+ both, so moving to S3 does not change what your app stores.
209
221
 
210
222
  FastAPI apps can mount `build_local_export_storage_router(...)` to serve local
211
223
  files, passing their settings provider, an optional current-user dependency, and
@@ -329,6 +341,13 @@ real one in tests.
329
341
  Base URLs can be overridden per environment with `REST_SERVICE_URLS`, a JSON map
330
342
  of service name to URL, without touching the registration.
331
343
 
344
+ With `[tracing]` installed and a tracer provider in the app, every call is a client
345
+ span inside the current trace, named `<METHOD> <path>` (`GET /api/invoices`) and
346
+ tagged with the service in `peer.service`, with the host, the status and the retries. The query string is
347
+ never recorded, and a failure keeps only the error type, not its message, so a
348
+ password sent as a query parameter does not reach the traces. `MemoryRestClient`
349
+ registered under a name produces the same spans, so they show up in local runs too.
350
+
332
351
  The cache is a plain key-value store with TTL, chosen by `CACHE_BACKEND` the
333
352
  same way repositories are chosen by `REPO_DATABASE`. Set `CACHE_NAMESPACE` when
334
353
  several apps share one DynamoDB table or Mongo collection so their keys do not
@@ -449,6 +468,7 @@ The factories read that object: `get_identity_provider`, `get_token_verifier`,
449
468
  | `CACHE_BACKEND` | `memory`, `mongo` or `dynamodb`. Defaults to `memory` |
450
469
  | `CACHE_NAMESPACE` | Key prefix, so apps sharing a cache store do not collide |
451
470
  | `REST_SERVICE_URLS` | JSON map `{"service": "base_url"}` overriding registered REST base URLs |
471
+ | `TRUSTED_PROXY_HOPS` | Proxies in front of the app that append to `X-Forwarded-For`. The auth rate limit counts the address the outermost one saw, so a forged header does not open a new bucket. `1` by default (one load balancer), `2` behind CloudFront and an ALB, `0` with no proxy |
452
472
  | `MONGO_DSN`, `MONGO_DB` | Mongo connection |
453
473
  | `POSTGRES_DSN` | Full DSN, if you prefer it to the pieces below |
454
474
  | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB` | Postgres connection |
@@ -107,7 +107,8 @@ pip install "persistence-kit[api,security,restclient]"
107
107
  | `restclient` | The REST client and its cache | httpx |
108
108
  | `encrypted` | Encrypted request and response payloads | fastapi, cryptography |
109
109
  | `mail` | Mail templates rendered with Jinja2 (sending itself needs no extra) | jinja2 |
110
- | `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart, cryptography, jinja2 |
110
+ | `tracing` | A span for every REST call, inside the request's trace | opentelemetry-api |
111
+ | `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart, cryptography, jinja2, opentelemetry-api |
111
112
  | `all` | Every optional capability | all of the above |
112
113
 
113
114
  Importing `persistence_kit` never loads an optional dependency by itself. Ask for
@@ -157,6 +158,15 @@ url = await storage.generate_presigned_url(key)
157
158
  `LocalObjectStorage` writes to a directory and signs its own download URLs.
158
159
  `S3ObjectStorage` uploads to S3 and returns AWS presigned URLs. Pick one with
159
160
  `get_export_storage(settings)`, which reads your settings and caches the adapter.
161
+ Give `S3ObjectStorage` a `prefix` and every key it writes, presigns, deletes or
162
+ lists stays under that folder of the bucket, while the keys you pass and get back
163
+ stay relative to it.
164
+
165
+ For files your users upload, `build_media_storage(settings, namespace=...,
166
+ url_prefix=...)` checks type and size and keeps each namespace in its own
167
+ folder: `<LOCAL_MEDIA_STORAGE_DIR>/<namespace>` with `MEDIA_STORAGE_PROVIDER=local`,
168
+ and `<namespace>/` inside `AWS_S3_MEDIA_BUCKET` with `s3`. Keys are the same on
169
+ both, so moving to S3 does not change what your app stores.
160
170
 
161
171
  FastAPI apps can mount `build_local_export_storage_router(...)` to serve local
162
172
  files, passing their settings provider, an optional current-user dependency, and
@@ -280,6 +290,13 @@ real one in tests.
280
290
  Base URLs can be overridden per environment with `REST_SERVICE_URLS`, a JSON map
281
291
  of service name to URL, without touching the registration.
282
292
 
293
+ With `[tracing]` installed and a tracer provider in the app, every call is a client
294
+ span inside the current trace, named `<METHOD> <path>` (`GET /api/invoices`) and
295
+ tagged with the service in `peer.service`, with the host, the status and the retries. The query string is
296
+ never recorded, and a failure keeps only the error type, not its message, so a
297
+ password sent as a query parameter does not reach the traces. `MemoryRestClient`
298
+ registered under a name produces the same spans, so they show up in local runs too.
299
+
283
300
  The cache is a plain key-value store with TTL, chosen by `CACHE_BACKEND` the
284
301
  same way repositories are chosen by `REPO_DATABASE`. Set `CACHE_NAMESPACE` when
285
302
  several apps share one DynamoDB table or Mongo collection so their keys do not
@@ -400,6 +417,7 @@ The factories read that object: `get_identity_provider`, `get_token_verifier`,
400
417
  | `CACHE_BACKEND` | `memory`, `mongo` or `dynamodb`. Defaults to `memory` |
401
418
  | `CACHE_NAMESPACE` | Key prefix, so apps sharing a cache store do not collide |
402
419
  | `REST_SERVICE_URLS` | JSON map `{"service": "base_url"}` overriding registered REST base URLs |
420
+ | `TRUSTED_PROXY_HOPS` | Proxies in front of the app that append to `X-Forwarded-For`. The auth rate limit counts the address the outermost one saw, so a forged header does not open a new bucket. `1` by default (one load balancer), `2` behind CloudFront and an ALB, `0` with no proxy |
403
421
  | `MONGO_DSN`, `MONGO_DB` | Mongo connection |
404
422
  | `POSTGRES_DSN` | Full DSN, if you prefer it to the pieces below |
405
423
  | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB` | Postgres connection |
@@ -48,16 +48,11 @@ class InMemoryRateLimiter:
48
48
  return None
49
49
 
50
50
 
51
- def _resolve_client_id(request: Request) -> str:
52
- forwarded_for = request.headers.get("x-forwarded-for")
53
- if forwarded_for:
54
- first_ip = forwarded_for.split(",")[0].strip()
55
- if first_ip:
56
- return first_ip
57
-
58
- cf_connecting_ip = request.headers.get("cf-connecting-ip")
59
- if cf_connecting_ip:
60
- return cf_connecting_ip.strip()
51
+ def _resolve_client_id(request: Request, trusted_proxy_hops: int) -> str:
52
+ forwarded_for = request.headers.get("x-forwarded-for", "")
53
+ chain = [hop.strip() for hop in forwarded_for.split(",") if hop.strip()]
54
+ if 0 < trusted_proxy_hops <= len(chain):
55
+ return chain[-trusted_proxy_hops]
61
56
 
62
57
  client_host = request.client.host if request.client else None
63
58
  return client_host or "unknown"
@@ -87,7 +82,7 @@ def build_auth_rate_limit_dependency(
87
82
  limit = getattr(settings, limit_setting_name)
88
83
  retry_after = await _get_rate_limiter(request).hit(
89
84
  scope=scope,
90
- client_id=_resolve_client_id(request),
85
+ client_id=_resolve_client_id(request, getattr(settings, "trusted_proxy_hops", 1)),
91
86
  limit=limit,
92
87
  window_seconds=settings.auth_rate_limit_window_seconds,
93
88
  )
@@ -23,6 +23,7 @@ from persistence_kit.restclient.mapping import ModelMappingMixin
23
23
  from persistence_kit.restclient.payload import prepare_request
24
24
  from persistence_kit.restclient.resolver import StaticEndpointResolver
25
25
  from persistence_kit.restclient.retry import RetryPolicy
26
+ from persistence_kit.restclient.tracing import traced_call
26
27
 
27
28
 
28
29
  class HttpxRestClient(ModelMappingMixin):
@@ -41,7 +42,9 @@ class HttpxRestClient(ModelMappingMixin):
41
42
  retry_policy: RetryPolicy | None = None,
42
43
  circuit_breaker: CircuitBreaker | None = None,
43
44
  client: httpx.AsyncClient | None = None,
45
+ name: str = "",
44
46
  ) -> None:
47
+ self.name = name
45
48
  self._config = config or ServiceConfig()
46
49
  self._resolver = resolver or StaticEndpointResolver()
47
50
  self._auth = authenticator or NoAuth()
@@ -89,6 +92,14 @@ class HttpxRestClient(ModelMappingMixin):
89
92
  )
90
93
  request = await self._auth.apply(request)
91
94
 
95
+ with traced_call(self.name, request.method, request.url) as call:
96
+ response, attempts = await self._exchange(request)
97
+ call.record(response.status_code, attempts)
98
+ if self._config.raise_for_status and not response.is_success:
99
+ raise RestHTTPError(response.status_code, response=response)
100
+ return response
101
+
102
+ async def _exchange(self, request: RestRequest) -> tuple[RestResponse, int]:
92
103
  attempt = 0
93
104
  refreshed = False
94
105
  while True:
@@ -128,10 +139,7 @@ class HttpxRestClient(ModelMappingMixin):
128
139
  attempt += 1
129
140
  continue
130
141
  break
131
-
132
- if self._config.raise_for_status and not response.is_success:
133
- raise RestHTTPError(response.status_code, response=response)
134
- return response
142
+ return response, attempt
135
143
 
136
144
  async def _send(self, request: RestRequest) -> RestResponse:
137
145
  try:
@@ -24,6 +24,7 @@ def build_rest_client(
24
24
  retry_policy: RetryPolicy | None = None,
25
25
  circuit_breaker: CircuitBreaker | None = None,
26
26
  settings: Any | None = None,
27
+ name: str = "",
27
28
  ) -> RestClient:
28
29
  if config is None:
29
30
  config = (
@@ -48,4 +49,5 @@ def build_rest_client(
48
49
  authenticator=authenticator,
49
50
  retry_policy=retry_policy,
50
51
  circuit_breaker=circuit_breaker,
52
+ name=name,
51
53
  )
@@ -13,6 +13,7 @@ from persistence_kit.restclient.contracts import (
13
13
  from persistence_kit.restclient.errors import RestHTTPError
14
14
  from persistence_kit.restclient.mapping import ModelMappingMixin
15
15
  from persistence_kit.restclient.payload import prepare_request
16
+ from persistence_kit.restclient.tracing import traced_call
16
17
 
17
18
  Handler = Callable[[RestRequest], RestResponse]
18
19
 
@@ -39,7 +40,9 @@ class MemoryRestClient(ModelMappingMixin):
39
40
  resolver: EndpointResolver | None = None,
40
41
  default_headers: Mapping[str, str] | None = None,
41
42
  raise_for_status: bool = True,
43
+ name: str = "",
42
44
  ) -> None:
45
+ self.name = name
43
46
  self._auth = authenticator or NoAuth()
44
47
  self._resolver = resolver
45
48
  self._default_headers = dict(default_headers or {})
@@ -100,22 +103,24 @@ class MemoryRestClient(ModelMappingMixin):
100
103
  request = await self._auth.apply(request)
101
104
  self.calls.append(request)
102
105
 
103
- stub = self._stubs.get((method.upper(), service))
104
- if stub is None:
105
- response = RestResponse(
106
- status_code=404,
107
- headers={},
108
- content=b"",
109
- url=url,
110
- )
111
- elif callable(stub):
112
- response = stub(request)
113
- else:
114
- response = stub
115
-
116
- if self._raise and not response.is_success:
117
- raise RestHTTPError(response.status_code, response=response)
118
- return response
106
+ with traced_call(self.name, request.method, request.url) as call:
107
+ stub = self._stubs.get((method.upper(), service))
108
+ if stub is None:
109
+ response = RestResponse(
110
+ status_code=404,
111
+ headers={},
112
+ content=b"",
113
+ url=url,
114
+ )
115
+ elif callable(stub):
116
+ response = stub(request)
117
+ else:
118
+ response = stub
119
+
120
+ call.record(response.status_code)
121
+ if self._raise and not response.is_success:
122
+ raise RestHTTPError(response.status_code, response=response)
123
+ return response
119
124
 
120
125
  async def get(self, service: str, **kwargs: Any) -> RestResponse:
121
126
  return await self.request("GET", service, **kwargs)
@@ -83,6 +83,8 @@ class RestClientRegistry:
83
83
  Inyecta dobles de prueba o stubs sin pasar por el transporte HTTP: ``get``
84
84
  devuelve este cliente tal cual, sin construir un ``HttpxRestClient``.
85
85
  """
86
+ if hasattr(client, "name") and not client.name:
87
+ client.name = name
86
88
  self._clients[name] = client
87
89
  return self
88
90
 
@@ -108,6 +110,7 @@ class RestClientRegistry:
108
110
  authenticator=service.authenticator,
109
111
  retry_policy=service.retry_policy,
110
112
  circuit_breaker=service.circuit_breaker,
113
+ name=name,
111
114
  )
112
115
 
113
116
  if service.config.cache_ttl_seconds or service.config.cacheable:
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from typing import Any, Iterator
5
+ from urllib.parse import urlsplit
6
+
7
+ try:
8
+ from opentelemetry import trace
9
+ from opentelemetry.trace import SpanKind, Status, StatusCode
10
+ except ImportError:
11
+ trace = None
12
+
13
+ TRACER_NAME = "persistence_kit.restclient"
14
+
15
+
16
+ class TracedCall:
17
+ def __init__(self, span: Any = None) -> None:
18
+ self._span = span
19
+
20
+ def record(self, status_code: int, attempts: int = 0) -> None:
21
+ if self._span is None:
22
+ return
23
+ self._span.set_attribute("http.response.status_code", status_code)
24
+ if attempts:
25
+ self._span.set_attribute("http.request.resend_count", attempts)
26
+ if status_code >= 400:
27
+ self._span.set_status(Status(StatusCode.ERROR))
28
+
29
+
30
+ @contextmanager
31
+ def traced_call(service_name: str, method: str, url: str) -> Iterator[TracedCall]:
32
+ if trace is None:
33
+ yield TracedCall()
34
+ return
35
+ parts = urlsplit(url)
36
+ path = parts.path or "/"
37
+ verb = method.upper()
38
+ tracer = trace.get_tracer(TRACER_NAME)
39
+ with tracer.start_as_current_span(
40
+ f"{verb} {path}",
41
+ kind=SpanKind.CLIENT,
42
+ record_exception=False,
43
+ set_status_on_exception=False,
44
+ ) as span:
45
+ span.set_attribute("http.request.method", verb)
46
+ span.set_attribute("url.path", path)
47
+ if parts.hostname:
48
+ span.set_attribute("server.address", parts.hostname)
49
+ if service_name:
50
+ span.set_attribute("peer.service", service_name)
51
+ try:
52
+ yield TracedCall(span)
53
+ except Exception as error:
54
+ status_code = getattr(error, "status_code", None)
55
+ if isinstance(status_code, int):
56
+ span.set_attribute("http.response.status_code", status_code)
57
+ span.set_attribute("error.type", type(error).__name__)
58
+ span.set_status(Status(StatusCode.ERROR))
59
+ raise
@@ -1,6 +1,6 @@
1
1
  from enum import Enum
2
2
 
3
- from pydantic import field_validator, model_validator
3
+ from pydantic import Field, field_validator, model_validator
4
4
  from pydantic_settings import SettingsConfigDict
5
5
 
6
6
  from persistence_kit.settings.repo_settings import RepoSettings
@@ -57,6 +57,7 @@ class PersistenceKitSettings(RepoSettings):
57
57
  auth_login_rate_limit: int = 10
58
58
  auth_refresh_rate_limit: int = 30
59
59
  auth_password_rate_limit: int = 5
60
+ trusted_proxy_hops: int = Field(default=1, ge=0)
60
61
 
61
62
  # Export Storage
62
63
  export_storage_provider: ExportStorageProvider = ExportStorageProvider.LOCAL
@@ -87,10 +87,10 @@ def _local_media_object_storage_cached(base_dir: str) -> ObjectStorage:
87
87
 
88
88
 
89
89
  @lru_cache
90
- def _s3_media_object_storage_cached(bucket: str, region: str) -> ObjectStorage:
90
+ def _s3_media_object_storage_cached(bucket: str, region: str, prefix: str) -> ObjectStorage:
91
91
  from persistence_kit.storage.s3 import S3ObjectStorage
92
92
 
93
- return S3ObjectStorage(bucket=bucket, region=region)
93
+ return S3ObjectStorage(bucket=bucket, region=region, prefix=prefix)
94
94
 
95
95
 
96
96
  def _build_media_object_storage(
@@ -110,7 +110,7 @@ def _build_media_object_storage(
110
110
  "Falta configuracion AWS_S3_MEDIA_BUCKET para el storage de media."
111
111
  )
112
112
  return _s3_media_object_storage_cached(
113
- settings.aws_s3_media_bucket, settings.aws_region
113
+ settings.aws_s3_media_bucket, settings.aws_region, namespace
114
114
  )
115
115
  provider = getattr(
116
116
  settings.media_storage_provider, "value", settings.media_storage_provider
@@ -22,35 +22,42 @@ logger = logging.getLogger(__name__)
22
22
 
23
23
 
24
24
  class S3ObjectStorage:
25
- def __init__(self, *, bucket: str, region: str) -> None:
25
+ def __init__(self, *, bucket: str, region: str, prefix: str = "") -> None:
26
26
  if boto3 is None:
27
27
  raise StorageConfigError("Falta dependencia boto3 para integrar con S3.")
28
28
  self._bucket = bucket
29
29
  self._region = region
30
+ normalized_prefix = prefix.strip("/")
31
+ self._prefix = f"{normalized_prefix}/" if normalized_prefix else ""
30
32
  self._client = boto3.client("s3", region_name=region)
31
33
 
34
+ def _object_key(self, key: str) -> str:
35
+ return f"{self._prefix}{key}"
36
+
32
37
  async def upload(
33
38
  self,
34
39
  key: str,
35
40
  data: bytes,
36
41
  content_type: str = "application/octet-stream",
37
42
  ) -> str:
43
+ object_key = self._object_key(key)
44
+
38
45
  def _upload() -> None:
39
46
  self._client.put_object(
40
47
  Bucket=self._bucket,
41
- Key=key,
48
+ Key=object_key,
42
49
  Body=data,
43
50
  ContentType=content_type,
44
51
  )
45
52
 
46
- logger.info("S3 upload iniciado bucket=%s key=%s", self._bucket, key)
53
+ logger.info("S3 upload iniciado bucket=%s key=%s", self._bucket, object_key)
47
54
  try:
48
55
  await asyncio.to_thread(_upload)
49
56
  except ClientError as exc:
50
- logger.exception("S3 upload fallido bucket=%s key=%s", self._bucket, key)
57
+ logger.exception("S3 upload fallido bucket=%s key=%s", self._bucket, object_key)
51
58
  raise StorageUploadError(f"No fue posible subir el archivo a S3: {exc}") from exc
52
59
 
53
- logger.info("S3 upload exitoso bucket=%s key=%s", self._bucket, key)
60
+ logger.info("S3 upload exitoso bucket=%s key=%s", self._bucket, object_key)
54
61
  return key
55
62
 
56
63
  async def upload_file(
@@ -59,66 +66,75 @@ class S3ObjectStorage:
59
66
  file_path: Path,
60
67
  content_type: str = "application/octet-stream",
61
68
  ) -> str:
69
+ object_key = self._object_key(key)
70
+
62
71
  def _upload_file() -> None:
63
72
  with file_path.open("rb") as file_obj:
64
73
  self._client.upload_fileobj(
65
74
  Fileobj=file_obj,
66
75
  Bucket=self._bucket,
67
- Key=key,
76
+ Key=object_key,
68
77
  ExtraArgs={"ContentType": content_type},
69
78
  )
70
79
 
71
- logger.info("S3 file upload iniciado bucket=%s key=%s path=%s", self._bucket, key, file_path)
80
+ logger.info(
81
+ "S3 file upload iniciado bucket=%s key=%s path=%s", self._bucket, object_key, file_path
82
+ )
72
83
  try:
73
84
  await asyncio.to_thread(_upload_file)
74
85
  except ClientError as exc:
75
- logger.exception("S3 file upload fallido bucket=%s key=%s", self._bucket, key)
86
+ logger.exception("S3 file upload fallido bucket=%s key=%s", self._bucket, object_key)
76
87
  raise StorageUploadError(f"No fue posible subir el archivo a S3: {exc}") from exc
77
88
 
78
- logger.info("S3 file upload exitoso bucket=%s key=%s", self._bucket, key)
89
+ logger.info("S3 file upload exitoso bucket=%s key=%s", self._bucket, object_key)
79
90
  return key
80
91
 
81
92
  async def generate_presigned_url(self, key: str, expires_in: int = 3600) -> str:
93
+ object_key = self._object_key(key)
94
+
82
95
  def _presign() -> str:
83
96
  return self._client.generate_presigned_url(
84
97
  "get_object",
85
- Params={"Bucket": self._bucket, "Key": key},
98
+ Params={"Bucket": self._bucket, "Key": object_key},
86
99
  ExpiresIn=expires_in,
87
100
  )
88
101
 
89
102
  try:
90
103
  return await asyncio.to_thread(_presign)
91
104
  except ClientError as exc:
92
- logger.exception("S3 presign fallido bucket=%s key=%s", self._bucket, key)
105
+ logger.exception("S3 presign fallido bucket=%s key=%s", self._bucket, object_key)
93
106
  raise StoragePresignError(f"No fue posible generar la URL prefirmada: {exc}") from exc
94
107
 
95
108
  async def delete(self, key: str) -> None:
109
+ object_key = self._object_key(key)
110
+
96
111
  def _delete() -> None:
97
- self._client.delete_object(Bucket=self._bucket, Key=key)
112
+ self._client.delete_object(Bucket=self._bucket, Key=object_key)
98
113
 
99
114
  try:
100
115
  await asyncio.to_thread(_delete)
101
116
  except ClientError as exc:
102
- logger.exception("S3 delete fallido bucket=%s key=%s", self._bucket, key)
117
+ logger.exception("S3 delete fallido bucket=%s key=%s", self._bucket, object_key)
103
118
  raise StorageError(f"No fue posible borrar el objeto en S3: {exc}") from exc
104
119
 
105
120
  async def list_keys(self, prefix: str = "") -> list[str]:
106
121
  normalized_prefix = prefix.lstrip("/")
107
122
  if normalized_prefix and not normalized_prefix.endswith("/"):
108
123
  normalized_prefix = normalized_prefix + "/"
124
+ search_prefix = self._object_key(normalized_prefix)
109
125
 
110
126
  def _list() -> list[str]:
111
127
  keys: list[str] = []
112
128
  paginator = self._client.get_paginator("list_objects_v2")
113
- for page in paginator.paginate(Bucket=self._bucket, Prefix=normalized_prefix):
129
+ for page in paginator.paginate(Bucket=self._bucket, Prefix=search_prefix):
114
130
  for obj in page.get("Contents", []) or []:
115
- keys.append(obj["Key"])
131
+ keys.append(obj["Key"].removeprefix(self._prefix))
116
132
  return keys
117
133
 
118
134
  try:
119
135
  return await asyncio.to_thread(_list)
120
136
  except ClientError as exc:
121
- logger.exception("S3 list fallido bucket=%s prefix=%s", self._bucket, normalized_prefix)
137
+ logger.exception("S3 list fallido bucket=%s prefix=%s", self._bucket, search_prefix)
122
138
  raise StorageError(f"No fue posible listar objetos en S3: {exc}") from exc
123
139
 
124
140
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "persistence-kit"
3
- version = "3.14.1"
3
+ version = "3.16.0"
4
4
  description = "Reusable persistence and repository toolkit"
5
5
  authors = ["Andres Felipe Serrano Barrios <andresfserrano1@gmail.com>"]
6
6
  readme = "README.md"
@@ -37,6 +37,7 @@ python-multipart = {version = ">=0.0.31,<1.0.0", optional = true}
37
37
  httpx = {version = ">=0.28.0,<1.0.0", optional = true}
38
38
  cryptography = {version = ">=42.0.0", optional = true}
39
39
  jinja2 = {version = "^3.1.4", optional = true}
40
+ opentelemetry-api = {version = ">=1.20.0,<2.0.0", optional = true}
40
41
 
41
42
  [tool.poetry.extras]
42
43
  api = ["fastapi", "python-multipart"]
@@ -48,12 +49,14 @@ encrypted = ["fastapi", "cryptography"]
48
49
  dynamodb = ["boto3"]
49
50
  restclient = ["httpx"]
50
51
  mail = ["jinja2"]
51
- testing = ["fastapi", "pyjwt", "python-multipart", "httpx", "cryptography", "jinja2"]
52
- all = ["fastapi", "boto3", "pyjwt", "python-multipart", "httpx", "cryptography", "jinja2"]
52
+ tracing = ["opentelemetry-api"]
53
+ testing = ["fastapi", "pyjwt", "python-multipart", "httpx", "cryptography", "jinja2", "opentelemetry-api"]
54
+ all = ["fastapi", "boto3", "pyjwt", "python-multipart", "httpx", "cryptography", "jinja2", "opentelemetry-api"]
53
55
 
54
56
  [tool.poetry.group.dev.dependencies]
55
57
  pytest = "^8.3.0"
56
58
  pytest-asyncio = ">=0.23.0,<2.0.0"
59
+ opentelemetry-sdk = ">=1.20.0,<2.0.0"
57
60
 
58
61
  [tool.pytest.ini_options]
59
62
  testpaths = ["tests"]