persistence-kit 3.2.0__tar.gz → 3.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/PKG-INFO +2 -1
  2. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/__init__.py +2 -0
  3. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/settings/__init__.py +2 -0
  4. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/settings/app_settings.py +10 -0
  5. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/storage/__init__.py +10 -0
  6. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/storage/contracts.py +4 -0
  7. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/storage/factory.py +70 -1
  8. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/storage/local.py +34 -0
  9. persistence_kit-3.3.0/persistence_kit/storage/media.py +107 -0
  10. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/storage/s3.py +30 -0
  11. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/pyproject.toml +8 -7
  12. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/LICENSE +0 -0
  13. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/README.md +0 -0
  14. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/api/__init__.py +0 -0
  15. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/api/common.py +0 -0
  16. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/api/error_handlers.py +0 -0
  17. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/api/exceptions.py +0 -0
  18. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/api/rate_limit.py +0 -0
  19. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/api/route_loader.py +0 -0
  20. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/authenticated_user.py +0 -0
  21. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/bootstrap/__init__.py +0 -0
  22. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/bootstrap/configuration.py +0 -0
  23. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/bootstrap/seeders.py +0 -0
  24. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/bootstrap/startup.py +0 -0
  25. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/contracts/__init__.py +0 -0
  26. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/contracts/repository.py +0 -0
  27. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/contracts/view_repository.py +0 -0
  28. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/py.typed +0 -0
  29. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository/__init__.py +0 -0
  30. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository/dynamodb_repo/dynamodb_mapper.py +0 -0
  31. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository/dynamodb_repo/dynamodb_repo.py +0 -0
  32. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository/filter_ops.py +0 -0
  33. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository/memory_repo/__init__.py +0 -0
  34. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository/memory_repo/memory_repo.py +0 -0
  35. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository/mongo_repo/__init__.py +0 -0
  36. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository/mongo_repo/mongo_mapper.py +0 -0
  37. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository/mongo_repo/mongo_repo.py +0 -0
  38. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository/sqlalchemy_repo/__init__.py +0 -0
  39. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository/sqlalchemy_repo/schema_evolve.py +0 -0
  40. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_dataclass_mapper.py +0 -0
  41. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_engine.py +0 -0
  42. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_repo.py +0 -0
  43. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository/sqlalchemy_repo/table_factory.py +0 -0
  44. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository_factory/__init__.py +0 -0
  45. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository_factory/factory/__init__.py +0 -0
  46. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository_factory/factory/repository_factory.py +0 -0
  47. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository_factory/registry/__init__.py +0 -0
  48. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository_factory/registry/entity_registry.py +0 -0
  49. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository_factory/view/__init__.py +0 -0
  50. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/repository_factory/view/populating_repository.py +0 -0
  51. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/security/__init__.py +0 -0
  52. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/security/factory.py +0 -0
  53. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/security/ports.py +0 -0
  54. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/security/providers/__init__.py +0 -0
  55. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/security/providers/cognito_identity_provider.py +0 -0
  56. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/security/providers/memory_security_provider.py +0 -0
  57. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/security/registration.py +0 -0
  58. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/security/token_verifiers/__init__.py +0 -0
  59. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/security/token_verifiers/cognito_jwt_verifier.py +0 -0
  60. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/security/token_verifiers/memory_jwt_verifier.py +0 -0
  61. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/settings/constants.py +0 -0
  62. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/settings/parsers.py +0 -0
  63. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/settings/repo_settings.py +0 -0
  64. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/storage/errors.py +0 -0
  65. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/storage/routes.py +0 -0
  66. {persistence_kit-3.2.0 → persistence_kit-3.3.0}/persistence_kit/utils/__init__.py +0 -0
  67. {persistence_kit-3.2.0 → persistence_kit-3.3.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.2.0
3
+ Version: 3.3.0
4
4
  Summary: Reusable persistence and repository toolkit
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -32,6 +32,7 @@ Requires-Dist: fastapi (>=0.115.0,<0.116.0) ; extra == "api" or extra == "storag
32
32
  Requires-Dist: motor (>=3.7.1,<4.0.0)
33
33
  Requires-Dist: pydantic-settings (>=2.3.0,<3.0.0)
34
34
  Requires-Dist: pyjwt[crypto] (>=2.10.1,<3.0.0) ; extra == "security" or extra == "security-cognito" or extra == "testing" or extra == "all"
35
+ Requires-Dist: python-multipart (>=0.0.20,<0.0.21) ; extra == "api" or extra == "storage-routes" or extra == "security" or extra == "security-cognito" or extra == "testing" or extra == "all"
35
36
  Requires-Dist: sqlalchemy[asyncio] (>=2.0.43,<3.0.0)
36
37
  Requires-Dist: typing-extensions (>=4.12.0,<5.0.0)
37
38
  Project-URL: Documentation, https://github.com/andresfserrano/persistence-kit#readme
@@ -13,6 +13,7 @@ from .settings import (
13
13
  AuthProvider,
14
14
  DeploymentStage,
15
15
  ExportStorageProvider,
16
+ MediaStorageProvider,
16
17
  PersistenceKitSettings,
17
18
  )
18
19
  from .settings.constants import Database
@@ -186,6 +187,7 @@ __all__ = [
186
187
  "RepoSettings",
187
188
  "AuthProvider",
188
189
  "ExportStorageProvider",
190
+ "MediaStorageProvider",
189
191
  "DeploymentStage",
190
192
  "LOCAL_DEFAULT_JOB_SERVICE_API_KEY",
191
193
  "DEFAULT_MEMORY_JWT_SECRET",
@@ -6,6 +6,7 @@ from .app_settings import (
6
6
  AuthProvider,
7
7
  DeploymentStage,
8
8
  ExportStorageProvider,
9
+ MediaStorageProvider,
9
10
  PersistenceKitSettings,
10
11
  )
11
12
  from .constants import Database
@@ -18,6 +19,7 @@ __all__ = [
18
19
  "RepoSettings",
19
20
  "AuthProvider",
20
21
  "ExportStorageProvider",
22
+ "MediaStorageProvider",
21
23
  "DeploymentStage",
22
24
  "LOCAL_DEFAULT_JOB_SERVICE_API_KEY",
23
25
  "DEFAULT_MEMORY_JWT_SECRET",
@@ -17,6 +17,11 @@ class ExportStorageProvider(str, Enum):
17
17
  S3 = "s3"
18
18
 
19
19
 
20
+ class MediaStorageProvider(str, Enum):
21
+ LOCAL = "local"
22
+ S3 = "s3"
23
+
24
+
20
25
  class DeploymentStage(str, Enum):
21
26
  LOCAL = "local"
22
27
  DEV = "dev"
@@ -53,6 +58,11 @@ class PersistenceKitSettings(RepoSettings):
53
58
  local_export_storage_dir: str = ".local"
54
59
  local_export_url_secret: str = "local-export-dev-secret-key-32-bytes"
55
60
 
61
+ # Media Storage (user-uploaded assets like product images, avatars, etc.)
62
+ media_storage_provider: MediaStorageProvider = MediaStorageProvider.LOCAL
63
+ local_media_storage_dir: str = ".local/media"
64
+ aws_s3_media_bucket: str | None = None
65
+
56
66
  # Cognito
57
67
  cognito_user_pool_id: str | None = None
58
68
  cognito_app_client_id: str | None = None
@@ -10,6 +10,7 @@ from persistence_kit.storage.factory import (
10
10
  EXPORT_STORAGE_BUILDERS,
11
11
  ExportStorage,
12
12
  ExportStorageBuilder,
13
+ build_media_storage,
13
14
  get_export_storage,
14
15
  )
15
16
 
@@ -23,6 +24,7 @@ _ROUTE_EXPORTS = {
23
24
  "guess_export_media_type",
24
25
  }
25
26
  _S3_EXPORTS = {"S3ObjectStorage", "S3ExportStorageProvider"}
27
+ _MEDIA_EXPORTS = {"MediaStorage"}
26
28
 
27
29
  __all__ = [
28
30
  "ObjectStorage",
@@ -39,6 +41,8 @@ __all__ = [
39
41
  "ExportStorageBuilder",
40
42
  "EXPORT_STORAGE_BUILDERS",
41
43
  "get_export_storage",
44
+ "MediaStorage",
45
+ "build_media_storage",
42
46
  "SettingsProvider",
43
47
  "CurrentUserDependency",
44
48
  "ExportDownloadAuthorizer",
@@ -67,4 +71,10 @@ def __getattr__(name: str):
67
71
  value = getattr(s3, name)
68
72
  globals()[name] = value
69
73
  return value
74
+ if name in _MEDIA_EXPORTS:
75
+ from persistence_kit.storage import media
76
+
77
+ value = getattr(media, name)
78
+ globals()[name] = value
79
+ return value
70
80
  raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -24,3 +24,7 @@ class ObjectStorage(Protocol):
24
24
  key: str,
25
25
  expires_in: int = 3600,
26
26
  ) -> str: ...
27
+
28
+ async def delete(self, key: str) -> None: ...
29
+
30
+ async def list_keys(self, prefix: str = "") -> list[str]: ...
@@ -1,7 +1,11 @@
1
1
  from functools import lru_cache
2
2
  from typing import Callable
3
3
 
4
- from persistence_kit.settings import ExportStorageProvider, PersistenceKitSettings
4
+ from persistence_kit.settings import (
5
+ ExportStorageProvider,
6
+ MediaStorageProvider,
7
+ PersistenceKitSettings,
8
+ )
5
9
  from persistence_kit.storage.contracts import ObjectStorage
6
10
  from persistence_kit.storage.errors import StorageConfigError
7
11
 
@@ -69,3 +73,68 @@ def get_export_storage(settings: PersistenceKitSettings) -> ExportStorage:
69
73
  f"Proveedor de storage de exportaciones no soportado: '{provider}'."
70
74
  )
71
75
  return builder(settings)
76
+
77
+
78
+ @lru_cache
79
+ def _local_media_object_storage_cached(base_dir: str) -> ObjectStorage:
80
+ from persistence_kit.storage.local import LocalObjectStorage
81
+
82
+ return LocalObjectStorage(
83
+ base_dir=base_dir,
84
+ public_base_url="",
85
+ signing_secret="not-used-for-public-media",
86
+ )
87
+
88
+
89
+ @lru_cache
90
+ def _s3_media_object_storage_cached(bucket: str, region: str) -> ObjectStorage:
91
+ from persistence_kit.storage.s3 import S3ObjectStorage
92
+
93
+ return S3ObjectStorage(bucket=bucket, region=region)
94
+
95
+
96
+ def _build_media_object_storage(
97
+ settings: PersistenceKitSettings,
98
+ namespace: str,
99
+ ) -> ObjectStorage:
100
+ namespace = namespace.strip().strip("/").strip()
101
+ if not namespace:
102
+ raise StorageConfigError("El namespace de MediaStorage no puede estar vacío.")
103
+ if settings.media_storage_provider == MediaStorageProvider.LOCAL:
104
+ return _local_media_object_storage_cached(
105
+ f"{settings.local_media_storage_dir.rstrip('/')}/{namespace}"
106
+ )
107
+ if settings.media_storage_provider == MediaStorageProvider.S3:
108
+ if not settings.aws_s3_media_bucket:
109
+ raise StorageConfigError(
110
+ "Falta configuracion AWS_S3_MEDIA_BUCKET para el storage de media."
111
+ )
112
+ return _s3_media_object_storage_cached(
113
+ settings.aws_s3_media_bucket, settings.aws_region
114
+ )
115
+ provider = getattr(
116
+ settings.media_storage_provider, "value", settings.media_storage_provider
117
+ )
118
+ raise StorageConfigError(
119
+ f"Proveedor de storage de media no soportado: '{provider}'."
120
+ )
121
+
122
+
123
+ def build_media_storage(
124
+ settings: PersistenceKitSettings,
125
+ *,
126
+ namespace: str,
127
+ url_prefix: str,
128
+ allowed_content_types=None,
129
+ max_bytes: int | None = None,
130
+ ):
131
+ from persistence_kit.storage.media import MediaStorage
132
+
133
+ storage = _build_media_object_storage(settings, namespace)
134
+ return MediaStorage(
135
+ storage=storage,
136
+ public_base_url=settings.public_base_url,
137
+ url_prefix=url_prefix,
138
+ allowed_content_types=allowed_content_types,
139
+ max_bytes=max_bytes,
140
+ )
@@ -91,6 +91,40 @@ class LocalObjectStorage:
91
91
  ).digest()
92
92
  return f"{payload_token}.{_b64encode(signature)}"
93
93
 
94
+ async def delete(self, key: str) -> None:
95
+ destination = self.get_file_path(key)
96
+
97
+ def _delete() -> None:
98
+ if destination.is_file():
99
+ destination.unlink()
100
+ current = destination.parent
101
+ while current != self._base_dir and current.is_relative_to(self._base_dir):
102
+ try:
103
+ if not current.is_dir() or any(current.iterdir()):
104
+ break
105
+ current.rmdir()
106
+ except OSError:
107
+ break
108
+ current = current.parent
109
+
110
+ await asyncio.to_thread(_delete)
111
+
112
+ async def list_keys(self, prefix: str = "") -> list[str]:
113
+ normalized_prefix = prefix.strip("/").rstrip("/")
114
+ root = self._base_dir / normalized_prefix if normalized_prefix else self._base_dir
115
+
116
+ def _list() -> list[str]:
117
+ if not root.is_dir():
118
+ return []
119
+ collected: list[str] = []
120
+ for path in root.rglob("*"):
121
+ if path.is_file():
122
+ rel = path.relative_to(self._base_dir).as_posix()
123
+ collected.append(rel)
124
+ return collected
125
+
126
+ return await asyncio.to_thread(_list)
127
+
94
128
  def validate_download_token(self, key: str, token: str) -> bool:
95
129
  try:
96
130
  payload_token, signature_token = token.split(".", maxsplit=1)
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Iterable
5
+
6
+ from persistence_kit.storage.contracts import ObjectStorage
7
+
8
+
9
+ class MediaStorage:
10
+ CONTENT_TYPE_TO_EXTENSION: dict[str, str] = {
11
+ "image/jpeg": "jpg",
12
+ "image/png": "png",
13
+ "image/webp": "webp",
14
+ "image/gif": "gif",
15
+ "application/pdf": "pdf",
16
+ }
17
+ DEFAULT_ALLOWED_CONTENT_TYPES: tuple[str, ...] = (
18
+ "image/jpeg",
19
+ "image/png",
20
+ "image/webp",
21
+ )
22
+ DEFAULT_MAX_BYTES: int = 5 * 1024 * 1024
23
+
24
+ def __init__(
25
+ self,
26
+ *,
27
+ storage: ObjectStorage,
28
+ public_base_url: str | None,
29
+ url_prefix: str,
30
+ allowed_content_types: Iterable[str] | None = None,
31
+ max_bytes: int | None = None,
32
+ ) -> None:
33
+ self._storage = storage
34
+ self._public_base_url = (public_base_url or "").rstrip("/")
35
+ self._url_prefix = "/" + url_prefix.strip("/")
36
+ self._allowed_content_types = tuple(
37
+ allowed_content_types
38
+ if allowed_content_types is not None
39
+ else self.DEFAULT_ALLOWED_CONTENT_TYPES
40
+ )
41
+ self._max_bytes = max_bytes if max_bytes is not None else self.DEFAULT_MAX_BYTES
42
+
43
+ @property
44
+ def url_prefix(self) -> str:
45
+ return self._url_prefix
46
+
47
+ @property
48
+ def allowed_content_types(self) -> tuple[str, ...]:
49
+ return self._allowed_content_types
50
+
51
+ @property
52
+ def max_bytes(self) -> int:
53
+ return self._max_bytes
54
+
55
+ def validate_upload(self, data: bytes, content_type: str) -> None:
56
+ from persistence_kit.api.exceptions import ValidationException
57
+
58
+ if not data:
59
+ raise ValidationException("El archivo está vacío.")
60
+ if content_type not in self._allowed_content_types:
61
+ allowed = ", ".join(self._allowed_content_types)
62
+ raise ValidationException(
63
+ f"Tipo de archivo no permitido. Permitidos: {allowed}."
64
+ )
65
+ if len(data) > self._max_bytes:
66
+ max_mb = self._max_bytes // (1024 * 1024)
67
+ raise ValidationException(
68
+ f"El archivo excede el tamaño máximo permitido de {max_mb}MB."
69
+ )
70
+
71
+ async def upload(
72
+ self,
73
+ key: str,
74
+ data: bytes,
75
+ content_type: str = "application/octet-stream",
76
+ ) -> str:
77
+ return await self._storage.upload(key, data, content_type=content_type)
78
+
79
+ async def upload_file(
80
+ self,
81
+ key: str,
82
+ file_path: Path,
83
+ content_type: str = "application/octet-stream",
84
+ ) -> str:
85
+ return await self._storage.upload_file(key, file_path, content_type=content_type)
86
+
87
+ async def delete(self, key: str) -> None:
88
+ await self._storage.delete(key)
89
+
90
+ async def generate_presigned_url(self, key: str, expires_in: int = 3600) -> str:
91
+ return await self._storage.generate_presigned_url(key, expires_in=expires_in)
92
+
93
+ def build_url(self, key: str | None) -> str | None:
94
+ if not key:
95
+ return None
96
+ return f"{self._public_base_url}{self._url_prefix}/{key}"
97
+
98
+ async def remove_other_files_in_scope(self, scope: str, keep_key: str) -> None:
99
+ existing_keys = await self._storage.list_keys(prefix=scope)
100
+ for existing in existing_keys:
101
+ if existing != keep_key:
102
+ await self._storage.delete(existing)
103
+
104
+ async def remove_scope_dir(self, scope: str) -> None:
105
+ existing_keys = await self._storage.list_keys(prefix=scope)
106
+ for existing in existing_keys:
107
+ await self._storage.delete(existing)
@@ -6,6 +6,7 @@ from pathlib import Path
6
6
 
7
7
  from persistence_kit.storage.errors import (
8
8
  StorageConfigError,
9
+ StorageError,
9
10
  StoragePresignError,
10
11
  StorageUploadError,
11
12
  )
@@ -91,5 +92,34 @@ class S3ObjectStorage:
91
92
  logger.exception("S3 presign fallido bucket=%s key=%s", self._bucket, key)
92
93
  raise StoragePresignError(f"No fue posible generar la URL prefirmada: {exc}") from exc
93
94
 
95
+ async def delete(self, key: str) -> None:
96
+ def _delete() -> None:
97
+ self._client.delete_object(Bucket=self._bucket, Key=key)
98
+
99
+ try:
100
+ await asyncio.to_thread(_delete)
101
+ except ClientError as exc:
102
+ logger.exception("S3 delete fallido bucket=%s key=%s", self._bucket, key)
103
+ raise StorageError(f"No fue posible borrar el objeto en S3: {exc}") from exc
104
+
105
+ async def list_keys(self, prefix: str = "") -> list[str]:
106
+ normalized_prefix = prefix.lstrip("/")
107
+ if normalized_prefix and not normalized_prefix.endswith("/"):
108
+ normalized_prefix = normalized_prefix + "/"
109
+
110
+ def _list() -> list[str]:
111
+ keys: list[str] = []
112
+ paginator = self._client.get_paginator("list_objects_v2")
113
+ for page in paginator.paginate(Bucket=self._bucket, Prefix=normalized_prefix):
114
+ for obj in page.get("Contents", []) or []:
115
+ keys.append(obj["Key"])
116
+ return keys
117
+
118
+ try:
119
+ return await asyncio.to_thread(_list)
120
+ except ClientError as exc:
121
+ logger.exception("S3 list fallido bucket=%s prefix=%s", self._bucket, normalized_prefix)
122
+ raise StorageError(f"No fue posible listar objetos en S3: {exc}") from exc
123
+
94
124
 
95
125
  S3ExportStorageProvider = S3ObjectStorage
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "persistence-kit"
3
- version = "3.2.0"
3
+ version = "3.3.0"
4
4
  description = "Reusable persistence and repository toolkit"
5
5
  authors = ["Andres Felipe Serrano Barrios <andresfserrano1@gmail.com>"]
6
6
  readme = "README.md"
@@ -33,16 +33,17 @@ pydantic-settings = "^2.3.0"
33
33
  fastapi = {version = "^0.115.0", optional = true}
34
34
  boto3 = {version = "^1.35.0", optional = true}
35
35
  pyjwt = {version = "^2.10.1", extras = ["crypto"], optional = true}
36
+ python-multipart = {version = "^0.0.20", optional = true}
36
37
 
37
38
  [tool.poetry.extras]
38
- api = ["fastapi"]
39
+ api = ["fastapi", "python-multipart"]
39
40
  storage-s3 = ["boto3"]
40
- storage-routes = ["fastapi"]
41
- security = ["fastapi", "pyjwt"]
42
- security-cognito = ["fastapi", "pyjwt", "boto3"]
41
+ storage-routes = ["fastapi", "python-multipart"]
42
+ security = ["fastapi", "pyjwt", "python-multipart"]
43
+ security-cognito = ["fastapi", "pyjwt", "boto3", "python-multipart"]
43
44
  dynamodb = ["boto3"]
44
- testing = ["fastapi", "pyjwt"]
45
- all = ["fastapi", "boto3", "pyjwt"]
45
+ testing = ["fastapi", "pyjwt", "python-multipart"]
46
+ all = ["fastapi", "boto3", "pyjwt", "python-multipart"]
46
47
 
47
48
  [tool.poetry.group.dev.dependencies]
48
49
  pytest = "^8.3.0"
File without changes