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.
- planar/.__init__.py.un~ +0 -0
- planar/._version.py.un~ +0 -0
- planar/.app.py.un~ +0 -0
- planar/.cli.py.un~ +0 -0
- planar/.config.py.un~ +0 -0
- planar/.context.py.un~ +0 -0
- planar/.db.py.un~ +0 -0
- planar/.di.py.un~ +0 -0
- planar/.engine.py.un~ +0 -0
- planar/.files.py.un~ +0 -0
- planar/.log_context.py.un~ +0 -0
- planar/.log_metadata.py.un~ +0 -0
- planar/.logging.py.un~ +0 -0
- planar/.object_registry.py.un~ +0 -0
- planar/.otel.py.un~ +0 -0
- planar/.server.py.un~ +0 -0
- planar/.session.py.un~ +0 -0
- planar/.sqlalchemy.py.un~ +0 -0
- planar/.task_local.py.un~ +0 -0
- planar/.test_app.py.un~ +0 -0
- planar/.test_config.py.un~ +0 -0
- planar/.test_object_config.py.un~ +0 -0
- planar/.test_sqlalchemy.py.un~ +0 -0
- planar/.test_utils.py.un~ +0 -0
- planar/.util.py.un~ +0 -0
- planar/.utils.py.un~ +0 -0
- planar/__init__.py +26 -0
- planar/_version.py +1 -0
- planar/ai/.__init__.py.un~ +0 -0
- planar/ai/._models.py.un~ +0 -0
- planar/ai/.agent.py.un~ +0 -0
- planar/ai/.agent_utils.py.un~ +0 -0
- planar/ai/.events.py.un~ +0 -0
- planar/ai/.files.py.un~ +0 -0
- planar/ai/.models.py.un~ +0 -0
- planar/ai/.providers.py.un~ +0 -0
- planar/ai/.pydantic_ai.py.un~ +0 -0
- planar/ai/.pydantic_ai_agent.py.un~ +0 -0
- planar/ai/.pydantic_ai_provider.py.un~ +0 -0
- planar/ai/.step.py.un~ +0 -0
- planar/ai/.test_agent.py.un~ +0 -0
- planar/ai/.test_agent_serialization.py.un~ +0 -0
- planar/ai/.test_providers.py.un~ +0 -0
- planar/ai/.utils.py.un~ +0 -0
- planar/ai/__init__.py +15 -0
- planar/ai/agent.py +457 -0
- planar/ai/agent_utils.py +205 -0
- planar/ai/models.py +140 -0
- planar/ai/providers.py +1088 -0
- planar/ai/test_agent.py +1298 -0
- planar/ai/test_agent_serialization.py +229 -0
- planar/ai/test_providers.py +463 -0
- planar/ai/utils.py +102 -0
- planar/app.py +494 -0
- planar/cli.py +282 -0
- planar/config.py +544 -0
- planar/db/.db.py.un~ +0 -0
- planar/db/__init__.py +17 -0
- planar/db/alembic/env.py +136 -0
- planar/db/alembic/script.py.mako +28 -0
- planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
- planar/db/alembic.ini +128 -0
- planar/db/db.py +318 -0
- planar/files/.config.py.un~ +0 -0
- planar/files/.local.py.un~ +0 -0
- planar/files/.local_filesystem.py.un~ +0 -0
- planar/files/.model.py.un~ +0 -0
- planar/files/.models.py.un~ +0 -0
- planar/files/.s3.py.un~ +0 -0
- planar/files/.storage.py.un~ +0 -0
- planar/files/.test_files.py.un~ +0 -0
- planar/files/__init__.py +2 -0
- planar/files/models.py +162 -0
- planar/files/storage/.__init__.py.un~ +0 -0
- planar/files/storage/.base.py.un~ +0 -0
- planar/files/storage/.config.py.un~ +0 -0
- planar/files/storage/.context.py.un~ +0 -0
- planar/files/storage/.local_directory.py.un~ +0 -0
- planar/files/storage/.test_local_directory.py.un~ +0 -0
- planar/files/storage/.test_s3.py.un~ +0 -0
- planar/files/storage/base.py +61 -0
- planar/files/storage/config.py +44 -0
- planar/files/storage/context.py +15 -0
- planar/files/storage/local_directory.py +188 -0
- planar/files/storage/s3.py +220 -0
- planar/files/storage/test_local_directory.py +162 -0
- planar/files/storage/test_s3.py +299 -0
- planar/files/test_files.py +283 -0
- planar/human/.human.py.un~ +0 -0
- planar/human/.test_human.py.un~ +0 -0
- planar/human/__init__.py +2 -0
- planar/human/human.py +458 -0
- planar/human/models.py +80 -0
- planar/human/test_human.py +385 -0
- planar/logging/.__init__.py.un~ +0 -0
- planar/logging/.attributes.py.un~ +0 -0
- planar/logging/.formatter.py.un~ +0 -0
- planar/logging/.logger.py.un~ +0 -0
- planar/logging/.otel.py.un~ +0 -0
- planar/logging/.tracer.py.un~ +0 -0
- planar/logging/__init__.py +10 -0
- planar/logging/attributes.py +54 -0
- planar/logging/context.py +14 -0
- planar/logging/formatter.py +113 -0
- planar/logging/logger.py +114 -0
- planar/logging/otel.py +51 -0
- planar/modeling/.mixin.py.un~ +0 -0
- planar/modeling/.storage.py.un~ +0 -0
- planar/modeling/__init__.py +0 -0
- planar/modeling/field_helpers.py +59 -0
- planar/modeling/json_schema_generator.py +94 -0
- planar/modeling/mixins/__init__.py +10 -0
- planar/modeling/mixins/auditable.py +52 -0
- planar/modeling/mixins/test_auditable.py +97 -0
- planar/modeling/mixins/test_timestamp.py +134 -0
- planar/modeling/mixins/test_uuid_primary_key.py +52 -0
- planar/modeling/mixins/timestamp.py +53 -0
- planar/modeling/mixins/uuid_primary_key.py +19 -0
- planar/modeling/orm/.planar_base_model.py.un~ +0 -0
- planar/modeling/orm/__init__.py +18 -0
- planar/modeling/orm/planar_base_entity.py +29 -0
- planar/modeling/orm/query_filter_builder.py +122 -0
- planar/modeling/orm/reexports.py +15 -0
- planar/object_config/.object_config.py.un~ +0 -0
- planar/object_config/__init__.py +11 -0
- planar/object_config/models.py +114 -0
- planar/object_config/object_config.py +378 -0
- planar/object_registry.py +100 -0
- planar/registry_items.py +65 -0
- planar/routers/.__init__.py.un~ +0 -0
- planar/routers/.agents_router.py.un~ +0 -0
- planar/routers/.crud.py.un~ +0 -0
- planar/routers/.decision.py.un~ +0 -0
- planar/routers/.event.py.un~ +0 -0
- planar/routers/.file_attachment.py.un~ +0 -0
- planar/routers/.files.py.un~ +0 -0
- planar/routers/.files_router.py.un~ +0 -0
- planar/routers/.human.py.un~ +0 -0
- planar/routers/.info.py.un~ +0 -0
- planar/routers/.models.py.un~ +0 -0
- planar/routers/.object_config_router.py.un~ +0 -0
- planar/routers/.rule.py.un~ +0 -0
- planar/routers/.test_object_config_router.py.un~ +0 -0
- planar/routers/.test_workflow_router.py.un~ +0 -0
- planar/routers/.workflow.py.un~ +0 -0
- planar/routers/__init__.py +13 -0
- planar/routers/agents_router.py +197 -0
- planar/routers/entity_router.py +143 -0
- planar/routers/event.py +91 -0
- planar/routers/files.py +142 -0
- planar/routers/human.py +151 -0
- planar/routers/info.py +131 -0
- planar/routers/models.py +170 -0
- planar/routers/object_config_router.py +133 -0
- planar/routers/rule.py +108 -0
- planar/routers/test_agents_router.py +174 -0
- planar/routers/test_object_config_router.py +367 -0
- planar/routers/test_routes_security.py +169 -0
- planar/routers/test_rule_router.py +470 -0
- planar/routers/test_workflow_router.py +274 -0
- planar/routers/workflow.py +468 -0
- planar/rules/.decorator.py.un~ +0 -0
- planar/rules/.runner.py.un~ +0 -0
- planar/rules/.test_rules.py.un~ +0 -0
- planar/rules/__init__.py +23 -0
- planar/rules/decorator.py +184 -0
- planar/rules/models.py +355 -0
- planar/rules/rule_configuration.py +191 -0
- planar/rules/runner.py +64 -0
- planar/rules/test_rules.py +750 -0
- planar/scaffold_templates/app/__init__.py.j2 +0 -0
- planar/scaffold_templates/app/db/entities.py.j2 +11 -0
- planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
- planar/scaffold_templates/main.py.j2 +13 -0
- planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
- planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
- planar/scaffold_templates/pyproject.toml.j2 +10 -0
- planar/security/.jwt_middleware.py.un~ +0 -0
- planar/security/auth_context.py +148 -0
- planar/security/authorization.py +388 -0
- planar/security/default_policies.cedar +77 -0
- planar/security/jwt_middleware.py +116 -0
- planar/security/security_context.py +18 -0
- planar/security/tests/test_authorization_context.py +78 -0
- planar/security/tests/test_cedar_basics.py +41 -0
- planar/security/tests/test_cedar_policies.py +158 -0
- planar/security/tests/test_jwt_principal_context.py +179 -0
- planar/session.py +40 -0
- planar/sse/.constants.py.un~ +0 -0
- planar/sse/.example.html.un~ +0 -0
- planar/sse/.hub.py.un~ +0 -0
- planar/sse/.model.py.un~ +0 -0
- planar/sse/.proxy.py.un~ +0 -0
- planar/sse/constants.py +1 -0
- planar/sse/example.html +126 -0
- planar/sse/hub.py +216 -0
- planar/sse/model.py +8 -0
- planar/sse/proxy.py +257 -0
- planar/task_local.py +37 -0
- planar/test_app.py +51 -0
- planar/test_cli.py +372 -0
- planar/test_config.py +512 -0
- planar/test_object_config.py +527 -0
- planar/test_object_registry.py +14 -0
- planar/test_sqlalchemy.py +158 -0
- planar/test_utils.py +105 -0
- planar/testing/.client.py.un~ +0 -0
- planar/testing/.memory_storage.py.un~ +0 -0
- planar/testing/.planar_test_client.py.un~ +0 -0
- planar/testing/.predictable_tracer.py.un~ +0 -0
- planar/testing/.synchronizable_tracer.py.un~ +0 -0
- planar/testing/.test_memory_storage.py.un~ +0 -0
- planar/testing/.workflow_observer.py.un~ +0 -0
- planar/testing/__init__.py +0 -0
- planar/testing/memory_storage.py +78 -0
- planar/testing/planar_test_client.py +54 -0
- planar/testing/synchronizable_tracer.py +153 -0
- planar/testing/test_memory_storage.py +143 -0
- planar/testing/workflow_observer.py +73 -0
- planar/utils.py +70 -0
- planar/workflows/.__init__.py.un~ +0 -0
- planar/workflows/.builtin_steps.py.un~ +0 -0
- planar/workflows/.concurrency_tracing.py.un~ +0 -0
- planar/workflows/.context.py.un~ +0 -0
- planar/workflows/.contrib.py.un~ +0 -0
- planar/workflows/.decorators.py.un~ +0 -0
- planar/workflows/.durable_test.py.un~ +0 -0
- planar/workflows/.errors.py.un~ +0 -0
- planar/workflows/.events.py.un~ +0 -0
- planar/workflows/.exceptions.py.un~ +0 -0
- planar/workflows/.execution.py.un~ +0 -0
- planar/workflows/.human.py.un~ +0 -0
- planar/workflows/.lock.py.un~ +0 -0
- planar/workflows/.misc.py.un~ +0 -0
- planar/workflows/.model.py.un~ +0 -0
- planar/workflows/.models.py.un~ +0 -0
- planar/workflows/.notifications.py.un~ +0 -0
- planar/workflows/.orchestrator.py.un~ +0 -0
- planar/workflows/.runtime.py.un~ +0 -0
- planar/workflows/.serialization.py.un~ +0 -0
- planar/workflows/.step.py.un~ +0 -0
- planar/workflows/.step_core.py.un~ +0 -0
- planar/workflows/.sub_workflow_runner.py.un~ +0 -0
- planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
- planar/workflows/.test_concurrency.py.un~ +0 -0
- planar/workflows/.test_concurrency_detection.py.un~ +0 -0
- planar/workflows/.test_human.py.un~ +0 -0
- planar/workflows/.test_lock_timeout.py.un~ +0 -0
- planar/workflows/.test_orchestrator.py.un~ +0 -0
- planar/workflows/.test_race_conditions.py.un~ +0 -0
- planar/workflows/.test_serialization.py.un~ +0 -0
- planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
- planar/workflows/.test_workflow.py.un~ +0 -0
- planar/workflows/.tracing.py.un~ +0 -0
- planar/workflows/.types.py.un~ +0 -0
- planar/workflows/.util.py.un~ +0 -0
- planar/workflows/.utils.py.un~ +0 -0
- planar/workflows/.workflow.py.un~ +0 -0
- planar/workflows/.workflow_wrapper.py.un~ +0 -0
- planar/workflows/.wrappers.py.un~ +0 -0
- planar/workflows/__init__.py +42 -0
- planar/workflows/context.py +44 -0
- planar/workflows/contrib.py +190 -0
- planar/workflows/decorators.py +217 -0
- planar/workflows/events.py +185 -0
- planar/workflows/exceptions.py +34 -0
- planar/workflows/execution.py +198 -0
- planar/workflows/lock.py +229 -0
- planar/workflows/misc.py +5 -0
- planar/workflows/models.py +154 -0
- planar/workflows/notifications.py +96 -0
- planar/workflows/orchestrator.py +383 -0
- planar/workflows/query.py +256 -0
- planar/workflows/serialization.py +409 -0
- planar/workflows/step_core.py +373 -0
- planar/workflows/step_metadata.py +357 -0
- planar/workflows/step_testing_utils.py +86 -0
- planar/workflows/sub_workflow_runner.py +191 -0
- planar/workflows/test_concurrency_detection.py +120 -0
- planar/workflows/test_lock_timeout.py +140 -0
- planar/workflows/test_serialization.py +1195 -0
- planar/workflows/test_suspend_deserialization.py +231 -0
- planar/workflows/test_workflow.py +1967 -0
- planar/workflows/tracing.py +106 -0
- planar/workflows/wrappers.py +41 -0
- planar-0.5.0.dist-info/METADATA +285 -0
- planar-0.5.0.dist-info/RECORD +289 -0
- planar-0.5.0.dist-info/WHEEL +4 -0
- 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
|