planar 0.5.0__py3-none-any.whl

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 (289) hide show
  1. planar/.__init__.py.un~ +0 -0
  2. planar/._version.py.un~ +0 -0
  3. planar/.app.py.un~ +0 -0
  4. planar/.cli.py.un~ +0 -0
  5. planar/.config.py.un~ +0 -0
  6. planar/.context.py.un~ +0 -0
  7. planar/.db.py.un~ +0 -0
  8. planar/.di.py.un~ +0 -0
  9. planar/.engine.py.un~ +0 -0
  10. planar/.files.py.un~ +0 -0
  11. planar/.log_context.py.un~ +0 -0
  12. planar/.log_metadata.py.un~ +0 -0
  13. planar/.logging.py.un~ +0 -0
  14. planar/.object_registry.py.un~ +0 -0
  15. planar/.otel.py.un~ +0 -0
  16. planar/.server.py.un~ +0 -0
  17. planar/.session.py.un~ +0 -0
  18. planar/.sqlalchemy.py.un~ +0 -0
  19. planar/.task_local.py.un~ +0 -0
  20. planar/.test_app.py.un~ +0 -0
  21. planar/.test_config.py.un~ +0 -0
  22. planar/.test_object_config.py.un~ +0 -0
  23. planar/.test_sqlalchemy.py.un~ +0 -0
  24. planar/.test_utils.py.un~ +0 -0
  25. planar/.util.py.un~ +0 -0
  26. planar/.utils.py.un~ +0 -0
  27. planar/__init__.py +26 -0
  28. planar/_version.py +1 -0
  29. planar/ai/.__init__.py.un~ +0 -0
  30. planar/ai/._models.py.un~ +0 -0
  31. planar/ai/.agent.py.un~ +0 -0
  32. planar/ai/.agent_utils.py.un~ +0 -0
  33. planar/ai/.events.py.un~ +0 -0
  34. planar/ai/.files.py.un~ +0 -0
  35. planar/ai/.models.py.un~ +0 -0
  36. planar/ai/.providers.py.un~ +0 -0
  37. planar/ai/.pydantic_ai.py.un~ +0 -0
  38. planar/ai/.pydantic_ai_agent.py.un~ +0 -0
  39. planar/ai/.pydantic_ai_provider.py.un~ +0 -0
  40. planar/ai/.step.py.un~ +0 -0
  41. planar/ai/.test_agent.py.un~ +0 -0
  42. planar/ai/.test_agent_serialization.py.un~ +0 -0
  43. planar/ai/.test_providers.py.un~ +0 -0
  44. planar/ai/.utils.py.un~ +0 -0
  45. planar/ai/__init__.py +15 -0
  46. planar/ai/agent.py +457 -0
  47. planar/ai/agent_utils.py +205 -0
  48. planar/ai/models.py +140 -0
  49. planar/ai/providers.py +1088 -0
  50. planar/ai/test_agent.py +1298 -0
  51. planar/ai/test_agent_serialization.py +229 -0
  52. planar/ai/test_providers.py +463 -0
  53. planar/ai/utils.py +102 -0
  54. planar/app.py +494 -0
  55. planar/cli.py +282 -0
  56. planar/config.py +544 -0
  57. planar/db/.db.py.un~ +0 -0
  58. planar/db/__init__.py +17 -0
  59. planar/db/alembic/env.py +136 -0
  60. planar/db/alembic/script.py.mako +28 -0
  61. planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
  62. planar/db/alembic.ini +128 -0
  63. planar/db/db.py +318 -0
  64. planar/files/.config.py.un~ +0 -0
  65. planar/files/.local.py.un~ +0 -0
  66. planar/files/.local_filesystem.py.un~ +0 -0
  67. planar/files/.model.py.un~ +0 -0
  68. planar/files/.models.py.un~ +0 -0
  69. planar/files/.s3.py.un~ +0 -0
  70. planar/files/.storage.py.un~ +0 -0
  71. planar/files/.test_files.py.un~ +0 -0
  72. planar/files/__init__.py +2 -0
  73. planar/files/models.py +162 -0
  74. planar/files/storage/.__init__.py.un~ +0 -0
  75. planar/files/storage/.base.py.un~ +0 -0
  76. planar/files/storage/.config.py.un~ +0 -0
  77. planar/files/storage/.context.py.un~ +0 -0
  78. planar/files/storage/.local_directory.py.un~ +0 -0
  79. planar/files/storage/.test_local_directory.py.un~ +0 -0
  80. planar/files/storage/.test_s3.py.un~ +0 -0
  81. planar/files/storage/base.py +61 -0
  82. planar/files/storage/config.py +44 -0
  83. planar/files/storage/context.py +15 -0
  84. planar/files/storage/local_directory.py +188 -0
  85. planar/files/storage/s3.py +220 -0
  86. planar/files/storage/test_local_directory.py +162 -0
  87. planar/files/storage/test_s3.py +299 -0
  88. planar/files/test_files.py +283 -0
  89. planar/human/.human.py.un~ +0 -0
  90. planar/human/.test_human.py.un~ +0 -0
  91. planar/human/__init__.py +2 -0
  92. planar/human/human.py +458 -0
  93. planar/human/models.py +80 -0
  94. planar/human/test_human.py +385 -0
  95. planar/logging/.__init__.py.un~ +0 -0
  96. planar/logging/.attributes.py.un~ +0 -0
  97. planar/logging/.formatter.py.un~ +0 -0
  98. planar/logging/.logger.py.un~ +0 -0
  99. planar/logging/.otel.py.un~ +0 -0
  100. planar/logging/.tracer.py.un~ +0 -0
  101. planar/logging/__init__.py +10 -0
  102. planar/logging/attributes.py +54 -0
  103. planar/logging/context.py +14 -0
  104. planar/logging/formatter.py +113 -0
  105. planar/logging/logger.py +114 -0
  106. planar/logging/otel.py +51 -0
  107. planar/modeling/.mixin.py.un~ +0 -0
  108. planar/modeling/.storage.py.un~ +0 -0
  109. planar/modeling/__init__.py +0 -0
  110. planar/modeling/field_helpers.py +59 -0
  111. planar/modeling/json_schema_generator.py +94 -0
  112. planar/modeling/mixins/__init__.py +10 -0
  113. planar/modeling/mixins/auditable.py +52 -0
  114. planar/modeling/mixins/test_auditable.py +97 -0
  115. planar/modeling/mixins/test_timestamp.py +134 -0
  116. planar/modeling/mixins/test_uuid_primary_key.py +52 -0
  117. planar/modeling/mixins/timestamp.py +53 -0
  118. planar/modeling/mixins/uuid_primary_key.py +19 -0
  119. planar/modeling/orm/.planar_base_model.py.un~ +0 -0
  120. planar/modeling/orm/__init__.py +18 -0
  121. planar/modeling/orm/planar_base_entity.py +29 -0
  122. planar/modeling/orm/query_filter_builder.py +122 -0
  123. planar/modeling/orm/reexports.py +15 -0
  124. planar/object_config/.object_config.py.un~ +0 -0
  125. planar/object_config/__init__.py +11 -0
  126. planar/object_config/models.py +114 -0
  127. planar/object_config/object_config.py +378 -0
  128. planar/object_registry.py +100 -0
  129. planar/registry_items.py +65 -0
  130. planar/routers/.__init__.py.un~ +0 -0
  131. planar/routers/.agents_router.py.un~ +0 -0
  132. planar/routers/.crud.py.un~ +0 -0
  133. planar/routers/.decision.py.un~ +0 -0
  134. planar/routers/.event.py.un~ +0 -0
  135. planar/routers/.file_attachment.py.un~ +0 -0
  136. planar/routers/.files.py.un~ +0 -0
  137. planar/routers/.files_router.py.un~ +0 -0
  138. planar/routers/.human.py.un~ +0 -0
  139. planar/routers/.info.py.un~ +0 -0
  140. planar/routers/.models.py.un~ +0 -0
  141. planar/routers/.object_config_router.py.un~ +0 -0
  142. planar/routers/.rule.py.un~ +0 -0
  143. planar/routers/.test_object_config_router.py.un~ +0 -0
  144. planar/routers/.test_workflow_router.py.un~ +0 -0
  145. planar/routers/.workflow.py.un~ +0 -0
  146. planar/routers/__init__.py +13 -0
  147. planar/routers/agents_router.py +197 -0
  148. planar/routers/entity_router.py +143 -0
  149. planar/routers/event.py +91 -0
  150. planar/routers/files.py +142 -0
  151. planar/routers/human.py +151 -0
  152. planar/routers/info.py +131 -0
  153. planar/routers/models.py +170 -0
  154. planar/routers/object_config_router.py +133 -0
  155. planar/routers/rule.py +108 -0
  156. planar/routers/test_agents_router.py +174 -0
  157. planar/routers/test_object_config_router.py +367 -0
  158. planar/routers/test_routes_security.py +169 -0
  159. planar/routers/test_rule_router.py +470 -0
  160. planar/routers/test_workflow_router.py +274 -0
  161. planar/routers/workflow.py +468 -0
  162. planar/rules/.decorator.py.un~ +0 -0
  163. planar/rules/.runner.py.un~ +0 -0
  164. planar/rules/.test_rules.py.un~ +0 -0
  165. planar/rules/__init__.py +23 -0
  166. planar/rules/decorator.py +184 -0
  167. planar/rules/models.py +355 -0
  168. planar/rules/rule_configuration.py +191 -0
  169. planar/rules/runner.py +64 -0
  170. planar/rules/test_rules.py +750 -0
  171. planar/scaffold_templates/app/__init__.py.j2 +0 -0
  172. planar/scaffold_templates/app/db/entities.py.j2 +11 -0
  173. planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
  174. planar/scaffold_templates/main.py.j2 +13 -0
  175. planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
  176. planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
  177. planar/scaffold_templates/pyproject.toml.j2 +10 -0
  178. planar/security/.jwt_middleware.py.un~ +0 -0
  179. planar/security/auth_context.py +148 -0
  180. planar/security/authorization.py +388 -0
  181. planar/security/default_policies.cedar +77 -0
  182. planar/security/jwt_middleware.py +116 -0
  183. planar/security/security_context.py +18 -0
  184. planar/security/tests/test_authorization_context.py +78 -0
  185. planar/security/tests/test_cedar_basics.py +41 -0
  186. planar/security/tests/test_cedar_policies.py +158 -0
  187. planar/security/tests/test_jwt_principal_context.py +179 -0
  188. planar/session.py +40 -0
  189. planar/sse/.constants.py.un~ +0 -0
  190. planar/sse/.example.html.un~ +0 -0
  191. planar/sse/.hub.py.un~ +0 -0
  192. planar/sse/.model.py.un~ +0 -0
  193. planar/sse/.proxy.py.un~ +0 -0
  194. planar/sse/constants.py +1 -0
  195. planar/sse/example.html +126 -0
  196. planar/sse/hub.py +216 -0
  197. planar/sse/model.py +8 -0
  198. planar/sse/proxy.py +257 -0
  199. planar/task_local.py +37 -0
  200. planar/test_app.py +51 -0
  201. planar/test_cli.py +372 -0
  202. planar/test_config.py +512 -0
  203. planar/test_object_config.py +527 -0
  204. planar/test_object_registry.py +14 -0
  205. planar/test_sqlalchemy.py +158 -0
  206. planar/test_utils.py +105 -0
  207. planar/testing/.client.py.un~ +0 -0
  208. planar/testing/.memory_storage.py.un~ +0 -0
  209. planar/testing/.planar_test_client.py.un~ +0 -0
  210. planar/testing/.predictable_tracer.py.un~ +0 -0
  211. planar/testing/.synchronizable_tracer.py.un~ +0 -0
  212. planar/testing/.test_memory_storage.py.un~ +0 -0
  213. planar/testing/.workflow_observer.py.un~ +0 -0
  214. planar/testing/__init__.py +0 -0
  215. planar/testing/memory_storage.py +78 -0
  216. planar/testing/planar_test_client.py +54 -0
  217. planar/testing/synchronizable_tracer.py +153 -0
  218. planar/testing/test_memory_storage.py +143 -0
  219. planar/testing/workflow_observer.py +73 -0
  220. planar/utils.py +70 -0
  221. planar/workflows/.__init__.py.un~ +0 -0
  222. planar/workflows/.builtin_steps.py.un~ +0 -0
  223. planar/workflows/.concurrency_tracing.py.un~ +0 -0
  224. planar/workflows/.context.py.un~ +0 -0
  225. planar/workflows/.contrib.py.un~ +0 -0
  226. planar/workflows/.decorators.py.un~ +0 -0
  227. planar/workflows/.durable_test.py.un~ +0 -0
  228. planar/workflows/.errors.py.un~ +0 -0
  229. planar/workflows/.events.py.un~ +0 -0
  230. planar/workflows/.exceptions.py.un~ +0 -0
  231. planar/workflows/.execution.py.un~ +0 -0
  232. planar/workflows/.human.py.un~ +0 -0
  233. planar/workflows/.lock.py.un~ +0 -0
  234. planar/workflows/.misc.py.un~ +0 -0
  235. planar/workflows/.model.py.un~ +0 -0
  236. planar/workflows/.models.py.un~ +0 -0
  237. planar/workflows/.notifications.py.un~ +0 -0
  238. planar/workflows/.orchestrator.py.un~ +0 -0
  239. planar/workflows/.runtime.py.un~ +0 -0
  240. planar/workflows/.serialization.py.un~ +0 -0
  241. planar/workflows/.step.py.un~ +0 -0
  242. planar/workflows/.step_core.py.un~ +0 -0
  243. planar/workflows/.sub_workflow_runner.py.un~ +0 -0
  244. planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
  245. planar/workflows/.test_concurrency.py.un~ +0 -0
  246. planar/workflows/.test_concurrency_detection.py.un~ +0 -0
  247. planar/workflows/.test_human.py.un~ +0 -0
  248. planar/workflows/.test_lock_timeout.py.un~ +0 -0
  249. planar/workflows/.test_orchestrator.py.un~ +0 -0
  250. planar/workflows/.test_race_conditions.py.un~ +0 -0
  251. planar/workflows/.test_serialization.py.un~ +0 -0
  252. planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
  253. planar/workflows/.test_workflow.py.un~ +0 -0
  254. planar/workflows/.tracing.py.un~ +0 -0
  255. planar/workflows/.types.py.un~ +0 -0
  256. planar/workflows/.util.py.un~ +0 -0
  257. planar/workflows/.utils.py.un~ +0 -0
  258. planar/workflows/.workflow.py.un~ +0 -0
  259. planar/workflows/.workflow_wrapper.py.un~ +0 -0
  260. planar/workflows/.wrappers.py.un~ +0 -0
  261. planar/workflows/__init__.py +42 -0
  262. planar/workflows/context.py +44 -0
  263. planar/workflows/contrib.py +190 -0
  264. planar/workflows/decorators.py +217 -0
  265. planar/workflows/events.py +185 -0
  266. planar/workflows/exceptions.py +34 -0
  267. planar/workflows/execution.py +198 -0
  268. planar/workflows/lock.py +229 -0
  269. planar/workflows/misc.py +5 -0
  270. planar/workflows/models.py +154 -0
  271. planar/workflows/notifications.py +96 -0
  272. planar/workflows/orchestrator.py +383 -0
  273. planar/workflows/query.py +256 -0
  274. planar/workflows/serialization.py +409 -0
  275. planar/workflows/step_core.py +373 -0
  276. planar/workflows/step_metadata.py +357 -0
  277. planar/workflows/step_testing_utils.py +86 -0
  278. planar/workflows/sub_workflow_runner.py +191 -0
  279. planar/workflows/test_concurrency_detection.py +120 -0
  280. planar/workflows/test_lock_timeout.py +140 -0
  281. planar/workflows/test_serialization.py +1195 -0
  282. planar/workflows/test_suspend_deserialization.py +231 -0
  283. planar/workflows/test_workflow.py +1967 -0
  284. planar/workflows/tracing.py +106 -0
  285. planar/workflows/wrappers.py +41 -0
  286. planar-0.5.0.dist-info/METADATA +285 -0
  287. planar-0.5.0.dist-info/RECORD +289 -0
  288. planar-0.5.0.dist-info/WHEEL +4 -0
  289. planar-0.5.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,188 @@
1
+ import uuid
2
+ from pathlib import Path
3
+ from typing import AsyncGenerator
4
+
5
+ import aiofiles
6
+ import aiofiles.os
7
+
8
+ from planar.logging import get_logger
9
+
10
+ from .base import Storage
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ class LocalDirectoryStorage(Storage):
16
+ """Stores files and mime types in separate subdirectories on local disk."""
17
+
18
+ BLOB_SUBDIR = "blob"
19
+ MIME_SUBDIR = "mime"
20
+
21
+ def __init__(self, storage_dir: str | Path):
22
+ """
23
+ Initializes LocalDirectoryStorage.
24
+
25
+ Args:
26
+ storage_dir: The root directory where 'blob' and 'mime' subdirs will reside.
27
+ It will be created if it doesn't exist.
28
+ """
29
+ self.base_dir = Path(storage_dir).resolve()
30
+ self.blob_dir = self.base_dir / self.BLOB_SUBDIR
31
+ self.mime_dir = self.base_dir / self.MIME_SUBDIR
32
+ self.blob_dir.mkdir(parents=True, exist_ok=True)
33
+ self.mime_dir.mkdir(parents=True, exist_ok=True)
34
+
35
+ def _get_path(self, ref: str, subdir: str) -> Path:
36
+ """Constructs the full path for a given storage reference in a specific subdir."""
37
+ try:
38
+ # Validate ref is a UUID string
39
+ ref_uuid = str(uuid.UUID(ref))
40
+ except ValueError:
41
+ raise ValueError(f"Invalid storage reference format: {ref}")
42
+
43
+ if subdir == self.BLOB_SUBDIR:
44
+ return self.blob_dir / ref_uuid
45
+ elif subdir == self.MIME_SUBDIR:
46
+ return self.mime_dir / ref_uuid
47
+ else:
48
+ raise ValueError(f"Invalid subdir specified: {subdir}")
49
+
50
+ async def put(
51
+ self, stream: AsyncGenerator[bytes, None], mime_type: str | None = None
52
+ ) -> str:
53
+ """
54
+ Stores a stream to a local file and its mime type in separate files.
55
+
56
+ The storage reference returned is the unique filename (UUID).
57
+ """
58
+ ref = str(uuid.uuid4())
59
+ blob_path = self._get_path(ref, self.BLOB_SUBDIR)
60
+ mime_path = self._get_path(ref, self.MIME_SUBDIR)
61
+
62
+ try:
63
+ # Write blob data
64
+ async with aiofiles.open(blob_path, mode="wb") as f:
65
+ async for chunk in stream:
66
+ await f.write(chunk)
67
+
68
+ # Write mime type if provided
69
+ if mime_type:
70
+ async with aiofiles.open(mime_path, mode="w", encoding="utf-8") as f:
71
+ await f.write(mime_type)
72
+
73
+ return ref
74
+ except Exception as e:
75
+ logger.exception("error during put operation", ref=ref)
76
+ # Attempt to clean up potentially partially written files
77
+ if await aiofiles.os.path.exists(blob_path):
78
+ try:
79
+ await aiofiles.os.remove(blob_path)
80
+ except OSError as e2:
81
+ logger.warning(
82
+ "failed to cleanup blob file",
83
+ path=str(blob_path),
84
+ os_error=str(e2),
85
+ )
86
+ if await aiofiles.os.path.exists(mime_path):
87
+ try:
88
+ await aiofiles.os.remove(mime_path)
89
+ except OSError as e2:
90
+ logger.warning(
91
+ "failed to cleanup mime file",
92
+ path=str(mime_path),
93
+ os_error=str(e2),
94
+ )
95
+ raise IOError(f"Failed to store file or mime type for ref {ref}") from e
96
+
97
+ async def get(self, ref: str) -> tuple[AsyncGenerator[bytes, None], str | None]:
98
+ """
99
+ Retrieves a stream and its mime type from local files.
100
+ """
101
+ blob_path = self._get_path(ref, self.BLOB_SUBDIR)
102
+ mime_path = self._get_path(ref, self.MIME_SUBDIR)
103
+
104
+ if not await aiofiles.os.path.isfile(blob_path):
105
+ raise FileNotFoundError(f"Storage reference blob not found: {ref}")
106
+
107
+ # Read mime type first
108
+ mime_type: str | None = None
109
+ if await aiofiles.os.path.isfile(mime_path):
110
+ try:
111
+ async with aiofiles.open(mime_path, mode="r", encoding="utf-8") as f:
112
+ mime_type = (await f.read()).strip()
113
+ except Exception:
114
+ logger.exception(
115
+ "failed to read mime type file",
116
+ path=str(mime_path),
117
+ ref=ref,
118
+ )
119
+ # Proceed without mime type if reading fails
120
+
121
+ async def _stream():
122
+ try:
123
+ async with aiofiles.open(blob_path, mode="rb") as f:
124
+ while True:
125
+ chunk = await f.read(0xFFFF) # Read in 64k chunks
126
+ if not chunk:
127
+ break
128
+ yield chunk
129
+ except Exception as e:
130
+ logger.exception(
131
+ "error reading blob file", path=str(blob_path), ref=ref
132
+ )
133
+ raise IOError(f"Failed to read blob file for ref {ref}") from e
134
+
135
+ return _stream(), mime_type
136
+
137
+ async def delete(self, ref: str) -> None:
138
+ """
139
+ Deletes the blob file and its corresponding mime type file.
140
+ """
141
+ blob_path = self._get_path(ref, self.BLOB_SUBDIR)
142
+ mime_path = self._get_path(ref, self.MIME_SUBDIR)
143
+ deleted_blob = False
144
+ deleted_mime = False
145
+
146
+ # Delete blob file
147
+ if await aiofiles.os.path.isfile(blob_path):
148
+ try:
149
+ await aiofiles.os.remove(blob_path)
150
+ deleted_blob = True
151
+ except Exception as e:
152
+ raise IOError(f"Failed to delete blob file {blob_path}: {e}") from e
153
+ else:
154
+ # If blob doesn't exist, we consider it 'deleted' in terms of state
155
+ deleted_blob = True
156
+
157
+ # Delete mime file if it exists
158
+ if await aiofiles.os.path.isfile(mime_path):
159
+ try:
160
+ await aiofiles.os.remove(mime_path)
161
+ deleted_mime = True
162
+ except Exception as e:
163
+ # Log warning but don't raise if blob was successfully deleted or didn't exist
164
+ logger.exception(
165
+ "failed to delete mime file",
166
+ path=str(mime_path),
167
+ )
168
+ if not deleted_blob: # Re-raise if blob deletion also failed
169
+ raise IOError(
170
+ f"Failed to delete mime file {mime_path} after blob deletion failure: {e}"
171
+ ) from e
172
+
173
+ # Raise FileNotFoundError only if neither file existed initially
174
+ if (
175
+ not await aiofiles.os.path.exists(blob_path)
176
+ and not await aiofiles.os.path.exists(mime_path)
177
+ and not deleted_blob
178
+ and not deleted_mime
179
+ ):
180
+ # Check existence again to handle race conditions, though unlikely here
181
+ # If we get here, it means the initial check passed but deletion failed somehow,
182
+ # OR the files never existed. We treat the latter as FileNotFoundError.
183
+ # This logic might need refinement based on desired atomicity guarantees.
184
+ # For now, if the blob path doesn't exist after trying to delete, assume success or prior non-existence.
185
+ pass # Or raise FileNotFoundError if strict check is needed: raise FileNotFoundError(f"Storage reference not found: {ref}")
186
+
187
+ async def external_url(self, ref: str) -> str | None:
188
+ return None
@@ -0,0 +1,220 @@
1
+ import asyncio
2
+ import io
3
+ import uuid
4
+ from typing import Any, AsyncGenerator, Dict, Optional, Tuple
5
+
6
+ import boto3
7
+ from botocore.client import Config as BotoConfig
8
+ from botocore.exceptions import ClientError
9
+
10
+ from planar.logging import get_logger
11
+
12
+ from .base import Storage
13
+
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ class S3Storage(Storage):
18
+ """Stores files and mime types in an S3-compatible bucket using boto3."""
19
+
20
+ def __init__(
21
+ self,
22
+ bucket_name: str,
23
+ region: str,
24
+ endpoint_url: Optional[str] = None,
25
+ access_key_id: Optional[str] = None,
26
+ secret_access_key: Optional[str] = None,
27
+ session_token: Optional[str] = None, # For temporary credentials
28
+ boto_config: Optional[Dict[str, Any]] = None, # Additional boto3 client config
29
+ presigned_url_ttl: int = 3600,
30
+ ):
31
+ """
32
+ Initializes S3Storage.
33
+
34
+ Args:
35
+ bucket_name: The name of the S3 bucket.
36
+ endpoint_url: The S3 endpoint URL (e.g., 'https://s3.amazonaws.com' or custom).
37
+ access_key_id: AWS Access Key ID.
38
+ secret_access_key: AWS Secret Access Key.
39
+ region: The AWS region of the bucket.
40
+ session_token: AWS Session Token (for temporary credentials).
41
+ boto_config: Additional configuration options for boto3 client.
42
+ presigned_url_ttl: Time in seconds for which the presigned URL is valid.
43
+ """
44
+ self.bucket_name = bucket_name
45
+ self.endpoint_url = (
46
+ endpoint_url # Boto3 generally prefers endpoint_url without trailing slash
47
+ )
48
+ self.access_key_id = access_key_id
49
+ self.secret_access_key = secret_access_key
50
+ self.region = region
51
+ self.session_token = session_token
52
+ self.presigned_url_ttl = presigned_url_ttl
53
+
54
+ # Initialize boto3 S3 client
55
+ # Using s3v4 signature is often necessary for S3 compatible services like MinIO/LocalStack
56
+ config_options = {"signature_version": "s3v4"}
57
+ if boto_config:
58
+ config_options.update(boto_config)
59
+ config = BotoConfig(**config_options)
60
+ self.s3_client = boto3.client(
61
+ "s3",
62
+ endpoint_url=self.endpoint_url,
63
+ aws_access_key_id=self.access_key_id,
64
+ aws_secret_access_key=self.secret_access_key,
65
+ aws_session_token=self.session_token,
66
+ region_name=self.region,
67
+ config=config,
68
+ )
69
+
70
+ async def _get_object_url(self, ref: str) -> str | None:
71
+ """Generates a presigned URL for a given object reference."""
72
+ try:
73
+ # generate_presigned_url is synchronous, so we run it in a thread
74
+ url = await asyncio.to_thread(
75
+ self.s3_client.generate_presigned_url,
76
+ "get_object",
77
+ Params={"Bucket": self.bucket_name, "Key": ref},
78
+ ExpiresIn=self.presigned_url_ttl,
79
+ )
80
+ return url
81
+ except ClientError:
82
+ logger.exception(
83
+ "failed to generate presigned url",
84
+ ref=ref,
85
+ bucket_name=self.bucket_name,
86
+ )
87
+ # Returning None is a safe default if URL generation fails.
88
+ return None
89
+
90
+ async def put(
91
+ self, stream: AsyncGenerator[bytes, None], mime_type: str | None = None
92
+ ) -> str:
93
+ """
94
+ Stores a stream and optional mime type to an S3 object with a unique name.
95
+
96
+ The storage reference returned is the unique object key (UUID).
97
+ The mime_type is stored as the Content-Type metadata.
98
+ """
99
+ ref = str(uuid.uuid4())
100
+
101
+ # Collect data from async generator into bytes
102
+ data_bytes_list = []
103
+ async for chunk in stream:
104
+ data_bytes_list.append(chunk)
105
+ data_bytes = b"".join(data_bytes_list)
106
+
107
+ extra_args = {}
108
+ if mime_type:
109
+ extra_args["ContentType"] = mime_type
110
+
111
+ try:
112
+ await asyncio.to_thread(
113
+ self.s3_client.put_object,
114
+ Bucket=self.bucket_name,
115
+ Key=ref,
116
+ Body=data_bytes,
117
+ **extra_args,
118
+ )
119
+ return ref
120
+ except ClientError as e:
121
+ logger.exception(
122
+ "failed s3 put object",
123
+ ref=ref,
124
+ bucket_name=self.bucket_name,
125
+ error_response=e.response,
126
+ )
127
+ raise IOError(f"Failed to upload to S3 object {ref}. Error: {e}") from e
128
+ except Exception as e:
129
+ logger.exception(
130
+ "an unexpected error occurred during s3 upload",
131
+ ref=ref,
132
+ )
133
+ raise IOError(f"An error occurred during S3 upload for {ref}") from e
134
+
135
+ async def get(self, ref: str) -> Tuple[AsyncGenerator[bytes, None], str | None]:
136
+ """
137
+ Retrieves a stream and its mime type (Content-Type) from an S3 object
138
+ using its storage reference (object key).
139
+ """
140
+ try:
141
+ response = await asyncio.to_thread(
142
+ self.s3_client.get_object, Bucket=self.bucket_name, Key=ref
143
+ )
144
+
145
+ streaming_body = response["Body"]
146
+ mime_type = response.get("ContentType")
147
+
148
+ async def _stream_wrapper(body):
149
+ try:
150
+ while True:
151
+ # Read a chunk in the executor
152
+ chunk = await asyncio.to_thread(
153
+ body.read, io.DEFAULT_BUFFER_SIZE
154
+ )
155
+ if not chunk:
156
+ break
157
+ yield chunk
158
+ finally:
159
+ # Ensure the boto3 stream is closed
160
+ await asyncio.to_thread(body.close)
161
+
162
+ return _stream_wrapper(streaming_body), mime_type
163
+
164
+ except ClientError as e:
165
+ if e.response.get("Error", {}).get("Code") == "NoSuchKey":
166
+ logger.warning(
167
+ "s3 object not found", ref=ref, bucket_name=self.bucket_name
168
+ )
169
+ raise FileNotFoundError(f"S3 object not found: {ref}") from e
170
+ else:
171
+ logger.exception(
172
+ "failed s3 get object",
173
+ ref=ref,
174
+ bucket_name=self.bucket_name,
175
+ error_response=e.response,
176
+ )
177
+ raise IOError(
178
+ f"Failed to download from S3 object {ref}. Error: {e}"
179
+ ) from e
180
+ except Exception as e:
181
+ logger.exception(
182
+ "an unexpected error occurred during s3 download",
183
+ ref=ref,
184
+ )
185
+ raise IOError(f"An error occurred during S3 download for {ref}") from e
186
+
187
+ async def delete(self, ref: str) -> None:
188
+ """
189
+ Deletes an object from the S3 bucket using its storage reference (object key).
190
+ Boto3's delete_object is idempotent and does not error if the object is not found.
191
+ """
192
+ try:
193
+ await asyncio.to_thread(
194
+ self.s3_client.delete_object, Bucket=self.bucket_name, Key=ref
195
+ )
196
+ except ClientError as e:
197
+ # delete_object is generally idempotent. Log and raise if it's not a 'not found' scenario
198
+ # (though boto3 usually handles 'NoSuchKey' gracefully for delete).
199
+ logger.exception(
200
+ "failed s3 delete object",
201
+ ref=ref,
202
+ bucket_name=self.bucket_name,
203
+ error_response=e.response,
204
+ )
205
+ raise IOError(f"Failed to delete S3 object {ref}. Error: {e}") from e
206
+ except Exception as e:
207
+ logger.exception(
208
+ "an unexpected error occurred during s3 delete",
209
+ ref=ref,
210
+ )
211
+ raise IOError(f"An error occurred during S3 delete for {ref}") from e
212
+
213
+ async def external_url(self, ref: str) -> str | None:
214
+ """
215
+ Returns a presigned URL to access the S3 object.
216
+
217
+ The URL is temporary and its validity is determined by the `presigned_url_ttl`
218
+ parameter set during initialization.
219
+ """
220
+ return await self._get_object_url(ref)
@@ -0,0 +1,162 @@
1
+ import asyncio
2
+ import uuid
3
+ from pathlib import Path
4
+
5
+ import aiofiles.os
6
+ import pytest
7
+
8
+ from planar.files.storage.local_directory import LocalDirectoryStorage
9
+
10
+
11
+ @pytest.fixture
12
+ async def storage(tmp_path: Path) -> LocalDirectoryStorage:
13
+ """Provides an instance of LocalDirectoryStorage using a temporary directory."""
14
+ storage_instance = LocalDirectoryStorage(tmp_path)
15
+ # Ensure subdirectories exist (though constructor should handle this)
16
+ await aiofiles.os.makedirs(storage_instance.blob_dir, exist_ok=True)
17
+ await aiofiles.os.makedirs(storage_instance.mime_dir, exist_ok=True)
18
+ return storage_instance
19
+
20
+
21
+ async def test_put_get_bytes(storage: LocalDirectoryStorage):
22
+ """Test storing and retrieving raw bytes."""
23
+ test_data = b"some binary data \x00\xff"
24
+ mime_type = "application/octet-stream"
25
+
26
+ ref = await storage.put_bytes(test_data, mime_type=mime_type)
27
+ assert isinstance(ref, str)
28
+ try:
29
+ uuid.UUID(ref) # Check if ref is a valid UUID string
30
+ except ValueError:
31
+ pytest.fail(f"Returned ref '{ref}' is not a valid UUID string")
32
+
33
+ retrieved_data, retrieved_mime = await storage.get_bytes(ref)
34
+
35
+ assert retrieved_data == test_data
36
+ assert retrieved_mime == mime_type
37
+
38
+ # Check underlying files exist
39
+ blob_path = storage._get_path(ref, storage.BLOB_SUBDIR)
40
+ mime_path = storage._get_path(ref, storage.MIME_SUBDIR)
41
+ assert await aiofiles.os.path.exists(blob_path)
42
+ assert await aiofiles.os.path.exists(mime_path)
43
+
44
+
45
+ async def test_put_get_string(storage: LocalDirectoryStorage):
46
+ """Test storing and retrieving a string."""
47
+ test_string = "Hello, world! This is a test string with Unicode: éàçü."
48
+ mime_type = "text/plain"
49
+ encoding = "utf-16"
50
+
51
+ # Store with explicit encoding and mime type
52
+ ref = await storage.put_string(test_string, encoding=encoding, mime_type=mime_type)
53
+ expected_mime_type = f"{mime_type}; charset={encoding}"
54
+
55
+ retrieved_string, retrieved_mime = await storage.get_string(ref, encoding=encoding)
56
+
57
+ assert retrieved_string == test_string
58
+ assert retrieved_mime == expected_mime_type
59
+
60
+ # Test default encoding (utf-8)
61
+ ref_utf8 = await storage.put_string(test_string, mime_type="text/html")
62
+ expected_mime_utf8 = "text/html; charset=utf-8"
63
+ retrieved_string_utf8, retrieved_mime_utf8 = await storage.get_string(ref_utf8)
64
+ assert retrieved_string_utf8 == test_string
65
+ assert retrieved_mime_utf8 == expected_mime_utf8
66
+
67
+
68
+ async def test_put_get_stream(storage: LocalDirectoryStorage):
69
+ """Test storing data from an async generator stream."""
70
+ test_chunks = [b"chunk1 ", b"chunk2 ", b"chunk3"]
71
+ full_data = b"".join(test_chunks)
72
+ mime_type = "image/png"
73
+
74
+ async def _test_stream():
75
+ for chunk in test_chunks:
76
+ yield chunk
77
+ await asyncio.sleep(0.01) # Simulate async work
78
+
79
+ ref = await storage.put(_test_stream(), mime_type=mime_type)
80
+
81
+ stream, retrieved_mime = await storage.get(ref)
82
+ retrieved_data = b""
83
+ async for chunk in stream:
84
+ retrieved_data += chunk
85
+
86
+ assert retrieved_data == full_data
87
+ assert retrieved_mime == mime_type
88
+
89
+
90
+ async def test_put_no_mime_type(storage: LocalDirectoryStorage):
91
+ """Test storing data without providing a mime type."""
92
+ test_data = b"data without mime"
93
+
94
+ ref = await storage.put_bytes(test_data)
95
+ retrieved_data, retrieved_mime = await storage.get_bytes(ref)
96
+
97
+ assert retrieved_data == test_data
98
+ assert retrieved_mime is None
99
+
100
+ # Check only blob file exists
101
+ blob_path = storage._get_path(ref, storage.BLOB_SUBDIR)
102
+ mime_path = storage._get_path(ref, storage.MIME_SUBDIR)
103
+ assert await aiofiles.os.path.exists(blob_path)
104
+ assert not await aiofiles.os.path.exists(mime_path)
105
+
106
+
107
+ async def test_delete(storage: LocalDirectoryStorage):
108
+ """Test deleting stored data."""
109
+ ref = await storage.put_bytes(b"to be deleted", mime_type="text/plain")
110
+
111
+ blob_path = storage._get_path(ref, storage.BLOB_SUBDIR)
112
+ mime_path = storage._get_path(ref, storage.MIME_SUBDIR)
113
+
114
+ # Verify files exist before delete
115
+ assert await aiofiles.os.path.exists(blob_path)
116
+ assert await aiofiles.os.path.exists(mime_path)
117
+
118
+ await storage.delete(ref)
119
+
120
+ # Verify files are gone after delete
121
+ assert not await aiofiles.os.path.exists(blob_path)
122
+ assert not await aiofiles.os.path.exists(mime_path)
123
+
124
+ # Try getting deleted ref
125
+ with pytest.raises(FileNotFoundError):
126
+ await storage.get(ref)
127
+
128
+
129
+ async def test_get_non_existent(storage: LocalDirectoryStorage):
130
+ """Test getting a reference that does not exist."""
131
+ non_existent_ref = str(uuid.uuid4())
132
+ with pytest.raises(FileNotFoundError):
133
+ await storage.get(non_existent_ref)
134
+
135
+
136
+ async def test_delete_non_existent(storage: LocalDirectoryStorage):
137
+ """Test deleting a reference that does not exist (should not raise error)."""
138
+ non_existent_ref = str(uuid.uuid4())
139
+ try:
140
+ await storage.delete(non_existent_ref)
141
+ except Exception as e:
142
+ pytest.fail(f"Deleting non-existent ref raised an exception: {e}")
143
+
144
+
145
+ async def test_invalid_ref_format(storage: LocalDirectoryStorage):
146
+ """Test operations with an invalid storage reference format."""
147
+ invalid_ref = "not-a-uuid"
148
+ with pytest.raises(ValueError):
149
+ await storage.get(invalid_ref)
150
+
151
+ with pytest.raises(ValueError):
152
+ await storage.delete(invalid_ref)
153
+
154
+ with pytest.raises(ValueError):
155
+ storage._get_path(invalid_ref, storage.BLOB_SUBDIR)
156
+
157
+
158
+ async def test_external_url(storage: LocalDirectoryStorage):
159
+ """Test that external_url returns None for local storage."""
160
+ ref = await storage.put_bytes(b"some data")
161
+ url = await storage.external_url(ref)
162
+ assert url is None