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,299 @@
|
|
1
|
+
import asyncio
|
2
|
+
import os
|
3
|
+
import uuid
|
4
|
+
from contextlib import asynccontextmanager
|
5
|
+
|
6
|
+
import boto3
|
7
|
+
import botocore
|
8
|
+
import botocore.client
|
9
|
+
import botocore.exceptions
|
10
|
+
import pytest
|
11
|
+
|
12
|
+
from planar.files.storage.s3 import S3Storage
|
13
|
+
|
14
|
+
pytestmark = pytest.mark.skipif(
|
15
|
+
os.getenv("PLANAR_TEST_S3_STORAGE", "0") != "1",
|
16
|
+
reason="S3 tests must be enabled via PLANAR_TEST_S3_STORAGE env var",
|
17
|
+
)
|
18
|
+
|
19
|
+
# --- Configuration for LocalStack/S3 Compatible Service ---
|
20
|
+
|
21
|
+
S3_PORT = 4566
|
22
|
+
# LocalStack S3 endpoint
|
23
|
+
S3_ENDPOINT_URL = f"http://127.0.0.1:{S3_PORT}"
|
24
|
+
# Dummy credentials for LocalStack (usually not strictly required)
|
25
|
+
AWS_ACCESS_KEY_ID = "test"
|
26
|
+
AWS_SECRET_ACCESS_KEY = "test"
|
27
|
+
AWS_REGION = "us-east-1"
|
28
|
+
# Generate a unique bucket name for each test run session
|
29
|
+
SESSION_BUCKET_NAME = f"planar-test-bucket-{uuid.uuid4()}"
|
30
|
+
|
31
|
+
|
32
|
+
@pytest.fixture()
|
33
|
+
def s3_boto_client(): # Synchronous client
|
34
|
+
"""Provides a boto3 S3 client for direct interaction (e.g., bucket creation)."""
|
35
|
+
client = boto3.client(
|
36
|
+
"s3",
|
37
|
+
endpoint_url=S3_ENDPOINT_URL,
|
38
|
+
aws_access_key_id=AWS_ACCESS_KEY_ID,
|
39
|
+
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
|
40
|
+
region_name=AWS_REGION,
|
41
|
+
config=botocore.client.Config(signature_version="s3v4"),
|
42
|
+
)
|
43
|
+
return client
|
44
|
+
|
45
|
+
|
46
|
+
@pytest.fixture(autouse=True)
|
47
|
+
async def ensure_s3_bucket(s3_boto_client):
|
48
|
+
"""
|
49
|
+
Ensures the S3 bucket exists before tests run.
|
50
|
+
This runs automatically due to autouse=True.
|
51
|
+
"""
|
52
|
+
print(f"Attempting to create bucket: {SESSION_BUCKET_NAME} at {S3_ENDPOINT_URL}")
|
53
|
+
|
54
|
+
create_kwargs = {
|
55
|
+
"Bucket": SESSION_BUCKET_NAME,
|
56
|
+
}
|
57
|
+
|
58
|
+
try:
|
59
|
+
await asyncio.to_thread(s3_boto_client.create_bucket, **create_kwargs)
|
60
|
+
print(f"Bucket {SESSION_BUCKET_NAME} created or confirmed existing.")
|
61
|
+
except botocore.exceptions.ClientError as e:
|
62
|
+
error_code = e.response.get("Error", {}).get("Code")
|
63
|
+
if error_code in ("BucketAlreadyOwnedByYou", "BucketAlreadyExists"):
|
64
|
+
print(f"Bucket {SESSION_BUCKET_NAME} already exists.")
|
65
|
+
else:
|
66
|
+
pytest.fail(
|
67
|
+
f"Failed to create S3 bucket {SESSION_BUCKET_NAME} "
|
68
|
+
f"at {S3_ENDPOINT_URL}: {e}. Is LocalStack running?"
|
69
|
+
)
|
70
|
+
except Exception as e:
|
71
|
+
pytest.fail(
|
72
|
+
f"An unexpected error occurred during bucket creation for {SESSION_BUCKET_NAME}: {e}"
|
73
|
+
)
|
74
|
+
|
75
|
+
yield # Tests run here
|
76
|
+
|
77
|
+
|
78
|
+
@pytest.fixture
|
79
|
+
async def s3_storage() -> S3Storage:
|
80
|
+
"""Provides an instance of S3Storage configured for the test bucket."""
|
81
|
+
storage_instance = S3Storage(
|
82
|
+
bucket_name=SESSION_BUCKET_NAME,
|
83
|
+
endpoint_url=S3_ENDPOINT_URL,
|
84
|
+
access_key_id=AWS_ACCESS_KEY_ID,
|
85
|
+
secret_access_key=AWS_SECRET_ACCESS_KEY,
|
86
|
+
region=AWS_REGION,
|
87
|
+
presigned_url_ttl=60,
|
88
|
+
)
|
89
|
+
return storage_instance
|
90
|
+
|
91
|
+
|
92
|
+
@asynccontextmanager
|
93
|
+
async def cleanup_s3_object(storage: S3Storage, ref: str):
|
94
|
+
"""Context manager to ensure an S3 object is deleted after use."""
|
95
|
+
try:
|
96
|
+
yield
|
97
|
+
finally:
|
98
|
+
try:
|
99
|
+
print(f"Cleaning up S3 object: {ref}")
|
100
|
+
await storage.delete(ref)
|
101
|
+
except FileNotFoundError:
|
102
|
+
print(f"Cleanup: S3 object {ref} already deleted or not found.")
|
103
|
+
except Exception as e:
|
104
|
+
print(f"Warning: Failed to cleanup S3 object {ref}: {e}")
|
105
|
+
|
106
|
+
|
107
|
+
# --- Test Cases ---
|
108
|
+
|
109
|
+
|
110
|
+
async def test_put_get_bytes(s3_storage: S3Storage):
|
111
|
+
"""Test storing and retrieving raw bytes."""
|
112
|
+
test_data = b"some binary data \x00\xff for s3"
|
113
|
+
mime_type = "application/octet-stream"
|
114
|
+
ref = None
|
115
|
+
try:
|
116
|
+
ref = await s3_storage.put_bytes(test_data, mime_type=mime_type)
|
117
|
+
assert isinstance(ref, str)
|
118
|
+
# S3 keys don't have to be UUIDs, but our implementation generates them
|
119
|
+
try:
|
120
|
+
uuid.UUID(ref)
|
121
|
+
except ValueError:
|
122
|
+
pytest.fail(f"Returned ref '{ref}' is not a valid UUID string")
|
123
|
+
|
124
|
+
async with cleanup_s3_object(s3_storage, ref):
|
125
|
+
retrieved_data, retrieved_mime = await s3_storage.get_bytes(ref)
|
126
|
+
|
127
|
+
assert retrieved_data == test_data
|
128
|
+
# S3 might add charset or other params, check starts with
|
129
|
+
assert retrieved_mime is not None
|
130
|
+
assert retrieved_mime.startswith(mime_type)
|
131
|
+
|
132
|
+
# Check external URL (should be a presigned URL)
|
133
|
+
url = await s3_storage.external_url(ref)
|
134
|
+
assert url is not None
|
135
|
+
base_expected_url = f"{S3_ENDPOINT_URL}/{SESSION_BUCKET_NAME}/{ref}"
|
136
|
+
assert url.startswith(base_expected_url)
|
137
|
+
assert "X-Amz-Signature" in url
|
138
|
+
assert "X-Amz-Expires" in url
|
139
|
+
|
140
|
+
except Exception as e:
|
141
|
+
if ref:
|
142
|
+
await cleanup_s3_object(s3_storage, ref).__aexit__(None, None, None)
|
143
|
+
raise e
|
144
|
+
|
145
|
+
|
146
|
+
async def test_put_get_string(s3_storage: S3Storage):
|
147
|
+
"""Test storing and retrieving a string."""
|
148
|
+
test_string = "Hello, S3! This is a test string with Unicode: éàçü."
|
149
|
+
mime_type = "text/plain"
|
150
|
+
encoding = "utf-16"
|
151
|
+
ref = None
|
152
|
+
try:
|
153
|
+
# Store with explicit encoding and mime type
|
154
|
+
ref = await s3_storage.put_string(
|
155
|
+
test_string, encoding=encoding, mime_type=mime_type
|
156
|
+
)
|
157
|
+
expected_mime_type = f"{mime_type}; charset={encoding}"
|
158
|
+
|
159
|
+
async with cleanup_s3_object(s3_storage, ref):
|
160
|
+
retrieved_string, retrieved_mime = await s3_storage.get_string(
|
161
|
+
ref, encoding=encoding
|
162
|
+
)
|
163
|
+
|
164
|
+
assert retrieved_string == test_string
|
165
|
+
assert retrieved_mime == expected_mime_type
|
166
|
+
|
167
|
+
except Exception as e:
|
168
|
+
if ref:
|
169
|
+
await cleanup_s3_object(s3_storage, ref).__aexit__(None, None, None)
|
170
|
+
raise e
|
171
|
+
|
172
|
+
# Test default encoding (utf-8)
|
173
|
+
ref_utf8 = None
|
174
|
+
try:
|
175
|
+
ref_utf8 = await s3_storage.put_string(test_string, mime_type="text/html")
|
176
|
+
expected_mime_utf8 = "text/html; charset=utf-8"
|
177
|
+
|
178
|
+
async with cleanup_s3_object(s3_storage, ref_utf8):
|
179
|
+
retrieved_string_utf8, retrieved_mime_utf8 = await s3_storage.get_string(
|
180
|
+
ref_utf8
|
181
|
+
)
|
182
|
+
assert retrieved_string_utf8 == test_string
|
183
|
+
assert retrieved_mime_utf8 == expected_mime_utf8
|
184
|
+
except Exception as e:
|
185
|
+
if ref_utf8:
|
186
|
+
await cleanup_s3_object(s3_storage, ref_utf8).__aexit__(None, None, None)
|
187
|
+
raise e
|
188
|
+
|
189
|
+
|
190
|
+
async def test_put_get_stream(s3_storage: S3Storage):
|
191
|
+
"""Test storing data from an async generator stream."""
|
192
|
+
test_chunks = [b"s3_chunk1 ", b"s3_chunk2 ", b"s3_chunk3"]
|
193
|
+
full_data = b"".join(test_chunks)
|
194
|
+
mime_type = "image/jpeg" # Different mime type for variety
|
195
|
+
ref = None
|
196
|
+
|
197
|
+
async def _test_stream():
|
198
|
+
for chunk in test_chunks:
|
199
|
+
yield chunk
|
200
|
+
await asyncio.sleep(0.01) # Simulate async work
|
201
|
+
|
202
|
+
try:
|
203
|
+
ref = await s3_storage.put(_test_stream(), mime_type=mime_type)
|
204
|
+
|
205
|
+
async with cleanup_s3_object(s3_storage, ref):
|
206
|
+
stream, retrieved_mime = await s3_storage.get(ref)
|
207
|
+
retrieved_data = b""
|
208
|
+
async for chunk in stream:
|
209
|
+
retrieved_data += chunk
|
210
|
+
|
211
|
+
assert retrieved_data == full_data
|
212
|
+
assert retrieved_mime is not None
|
213
|
+
assert retrieved_mime.startswith(mime_type)
|
214
|
+
except Exception as e:
|
215
|
+
if ref:
|
216
|
+
await cleanup_s3_object(s3_storage, ref).__aexit__(None, None, None)
|
217
|
+
raise e
|
218
|
+
|
219
|
+
|
220
|
+
async def test_put_no_mime_type(s3_storage: S3Storage):
|
221
|
+
"""Test storing data without providing a mime type."""
|
222
|
+
test_data = b"s3 data without mime"
|
223
|
+
ref = None
|
224
|
+
try:
|
225
|
+
ref = await s3_storage.put_bytes(test_data)
|
226
|
+
async with cleanup_s3_object(s3_storage, ref):
|
227
|
+
retrieved_data, retrieved_mime = await s3_storage.get_bytes(ref)
|
228
|
+
|
229
|
+
assert retrieved_data == test_data
|
230
|
+
# S3 might assign a default mime type (like binary/octet-stream) or none
|
231
|
+
# Depending on the S3 provider, this might be None or a default
|
232
|
+
print(f"Retrieved mime type (no mime put): {retrieved_mime}")
|
233
|
+
# assert retrieved_mime is None or retrieved_mime == 'binary/octet-stream'
|
234
|
+
# For now, let's just check the data
|
235
|
+
except Exception as e:
|
236
|
+
if ref:
|
237
|
+
await cleanup_s3_object(s3_storage, ref).__aexit__(None, None, None)
|
238
|
+
raise e
|
239
|
+
|
240
|
+
|
241
|
+
async def test_delete(s3_storage: S3Storage):
|
242
|
+
"""Test deleting stored data."""
|
243
|
+
ref = await s3_storage.put_bytes(b"to be deleted from s3", mime_type="text/plain")
|
244
|
+
|
245
|
+
# Verify object exists before delete (optional, get raises if not found)
|
246
|
+
try:
|
247
|
+
_, _ = await s3_storage.get(ref)
|
248
|
+
except FileNotFoundError:
|
249
|
+
pytest.fail(f"Object {ref} should exist before deletion but was not found.")
|
250
|
+
|
251
|
+
# Delete the object
|
252
|
+
await s3_storage.delete(ref)
|
253
|
+
|
254
|
+
# Verify object is gone after delete
|
255
|
+
with pytest.raises(FileNotFoundError):
|
256
|
+
await s3_storage.get(ref)
|
257
|
+
|
258
|
+
# Deleting again should be idempotent (no error)
|
259
|
+
try:
|
260
|
+
await s3_storage.delete(ref)
|
261
|
+
except Exception as e:
|
262
|
+
pytest.fail(f"Deleting already deleted ref raised an exception: {e}")
|
263
|
+
|
264
|
+
|
265
|
+
async def test_get_non_existent(s3_storage: S3Storage):
|
266
|
+
"""Test getting a reference that does not exist."""
|
267
|
+
non_existent_ref = str(uuid.uuid4())
|
268
|
+
with pytest.raises(FileNotFoundError):
|
269
|
+
await s3_storage.get(non_existent_ref)
|
270
|
+
|
271
|
+
|
272
|
+
async def test_delete_non_existent(s3_storage: S3Storage):
|
273
|
+
"""Test deleting a reference that does not exist (should not raise error)."""
|
274
|
+
non_existent_ref = str(uuid.uuid4())
|
275
|
+
try:
|
276
|
+
await s3_storage.delete(non_existent_ref)
|
277
|
+
except Exception as e:
|
278
|
+
pytest.fail(f"Deleting non-existent ref raised an exception: {e}")
|
279
|
+
|
280
|
+
|
281
|
+
async def test_external_url(s3_storage: S3Storage):
|
282
|
+
"""Test that external_url returns a valid-looking presigned S3 object URL."""
|
283
|
+
ref = None
|
284
|
+
try:
|
285
|
+
ref = await s3_storage.put_bytes(b"some data for url test")
|
286
|
+
async with cleanup_s3_object(s3_storage, ref):
|
287
|
+
url = await s3_storage.external_url(ref)
|
288
|
+
assert url is not None
|
289
|
+
base_expected_url = f"{S3_ENDPOINT_URL}/{SESSION_BUCKET_NAME}/{ref}"
|
290
|
+
assert url.startswith(base_expected_url)
|
291
|
+
assert "X-Amz-Algorithm" in url
|
292
|
+
assert "X-Amz-Credential" in url
|
293
|
+
assert "X-Amz-Date" in url
|
294
|
+
assert "X-Amz-Expires" in url
|
295
|
+
assert "X-Amz-Signature" in url
|
296
|
+
except Exception as e:
|
297
|
+
if ref:
|
298
|
+
await cleanup_s3_object(s3_storage, ref).__aexit__(None, None, None)
|
299
|
+
raise e
|
@@ -0,0 +1,283 @@
|
|
1
|
+
"""
|
2
|
+
Test file handling in Planar workflows.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import uuid
|
6
|
+
from pathlib import Path
|
7
|
+
from typing import AsyncGenerator, cast
|
8
|
+
|
9
|
+
import pytest
|
10
|
+
from pydantic import BaseModel, Field
|
11
|
+
from sqlmodel.ext.asyncio.session import AsyncSession
|
12
|
+
|
13
|
+
from planar.app import PlanarApp
|
14
|
+
from planar.config import sqlite_config
|
15
|
+
from planar.files import PlanarFile
|
16
|
+
from planar.files.models import PlanarFileMetadata
|
17
|
+
from planar.files.storage.base import Storage
|
18
|
+
from planar.workflows.decorators import workflow
|
19
|
+
from planar.workflows.execution import execute
|
20
|
+
from planar.workflows.models import Workflow
|
21
|
+
|
22
|
+
app = PlanarApp(
|
23
|
+
config=sqlite_config(":memory:"),
|
24
|
+
title="Planar app for testing file workflows",
|
25
|
+
description="Testing",
|
26
|
+
)
|
27
|
+
|
28
|
+
|
29
|
+
@pytest.fixture(name="app")
|
30
|
+
def app_fixture():
|
31
|
+
yield app
|
32
|
+
|
33
|
+
|
34
|
+
@pytest.fixture
|
35
|
+
async def planar_file(
|
36
|
+
storage: Storage,
|
37
|
+
session: AsyncSession, # Change type hint
|
38
|
+
) -> PlanarFile:
|
39
|
+
"""Create a PlanarFile instance for testing."""
|
40
|
+
# Store test content
|
41
|
+
test_data = b"Test file content for workflow"
|
42
|
+
mime_type = "text/plain"
|
43
|
+
|
44
|
+
# Store the file and get a reference
|
45
|
+
storage_ref = await storage.put_bytes(test_data, mime_type=mime_type)
|
46
|
+
|
47
|
+
# Create and store the file metadata
|
48
|
+
file_metadata = PlanarFileMetadata(
|
49
|
+
filename="test_file.txt",
|
50
|
+
content_type=mime_type,
|
51
|
+
size=len(test_data),
|
52
|
+
storage_ref=storage_ref,
|
53
|
+
)
|
54
|
+
session.add(file_metadata)
|
55
|
+
await session.commit()
|
56
|
+
await session.refresh(file_metadata)
|
57
|
+
|
58
|
+
# Return a PlanarFile reference (not the full metadata)
|
59
|
+
return PlanarFile(
|
60
|
+
id=file_metadata.id,
|
61
|
+
filename=file_metadata.filename,
|
62
|
+
content_type=file_metadata.content_type,
|
63
|
+
size=file_metadata.size,
|
64
|
+
)
|
65
|
+
|
66
|
+
|
67
|
+
# Define models for workflow testing
|
68
|
+
class FileProcessingInput(BaseModel):
|
69
|
+
"""Input model for a workflow that processes a file."""
|
70
|
+
|
71
|
+
title: str = Field(description="Title of the processing job")
|
72
|
+
file: PlanarFile = Field(description="The file to process")
|
73
|
+
max_chars: int = Field(description="Maximum characters to extract", default=100)
|
74
|
+
|
75
|
+
|
76
|
+
class FileProcessingResult(BaseModel):
|
77
|
+
"""Result model for a file processing workflow."""
|
78
|
+
|
79
|
+
title: str = Field(description="Title of the processing job")
|
80
|
+
characters: int = Field(description="Number of characters in the file")
|
81
|
+
content_preview: str = Field(description="Preview of the file content")
|
82
|
+
file_id: uuid.UUID = Field(description="ID of the processed file")
|
83
|
+
|
84
|
+
|
85
|
+
async def test_workflow_with_planar_file(
|
86
|
+
session: AsyncSession,
|
87
|
+
planar_file: PlanarFile,
|
88
|
+
):
|
89
|
+
"""Test that a workflow can accept and process a PlanarFile input."""
|
90
|
+
|
91
|
+
@workflow()
|
92
|
+
async def file_processing_workflow(input_data: PlanarFile):
|
93
|
+
file_content = await input_data.get_content()
|
94
|
+
char_count = len(file_content)
|
95
|
+
content_str = file_content.decode("utf-8")
|
96
|
+
preview = content_str[:100]
|
97
|
+
|
98
|
+
# Return structured result
|
99
|
+
return FileProcessingResult(
|
100
|
+
title="Test File Processing",
|
101
|
+
characters=char_count,
|
102
|
+
content_preview=preview,
|
103
|
+
file_id=input_data.id,
|
104
|
+
)
|
105
|
+
|
106
|
+
wf = await file_processing_workflow.start(planar_file)
|
107
|
+
result = await execute(wf)
|
108
|
+
|
109
|
+
# Verify the result
|
110
|
+
assert isinstance(result, FileProcessingResult)
|
111
|
+
assert result.title == "Test File Processing"
|
112
|
+
assert result.characters == len(b"Test file content for workflow")
|
113
|
+
assert result.content_preview == "Test file content for workflow"
|
114
|
+
assert result.file_id == planar_file.id
|
115
|
+
|
116
|
+
# Verify the workflow completed successfully
|
117
|
+
updated_wf = await session.get(Workflow, wf.id)
|
118
|
+
assert updated_wf is not None
|
119
|
+
assert updated_wf.status == "succeeded"
|
120
|
+
assert updated_wf.args == [planar_file.model_dump(mode="json")]
|
121
|
+
|
122
|
+
# Verify that the result stored in the workflow is correct
|
123
|
+
workflow_result = cast(dict, updated_wf.result)
|
124
|
+
assert workflow_result["title"] == "Test File Processing"
|
125
|
+
assert workflow_result["characters"] == len(b"Test file content for workflow")
|
126
|
+
assert workflow_result["content_preview"] == "Test file content for workflow"
|
127
|
+
assert workflow_result["file_id"] == str(planar_file.id)
|
128
|
+
|
129
|
+
|
130
|
+
TEST_BYTES = b"Test data for upload"
|
131
|
+
TEST_FILENAME = "upload_test.txt"
|
132
|
+
TEST_CONTENT_TYPE = "text/plain"
|
133
|
+
TEST_SIZE = len(TEST_BYTES)
|
134
|
+
DEFAULT_CONTENT_TYPE = "application/octet-stream"
|
135
|
+
|
136
|
+
|
137
|
+
async def assert_upload_success(
|
138
|
+
uploaded_file: PlanarFile,
|
139
|
+
expected_filename: str,
|
140
|
+
expected_content: bytes,
|
141
|
+
expected_content_type: str,
|
142
|
+
expected_size: int,
|
143
|
+
session: AsyncSession,
|
144
|
+
):
|
145
|
+
"""Helper function to assert successful file upload."""
|
146
|
+
assert isinstance(uploaded_file, PlanarFile)
|
147
|
+
assert uploaded_file.filename == expected_filename
|
148
|
+
assert uploaded_file.content_type == expected_content_type
|
149
|
+
assert uploaded_file.size == expected_size
|
150
|
+
assert isinstance(uploaded_file.id, uuid.UUID)
|
151
|
+
|
152
|
+
# Verify database record
|
153
|
+
metadata = await session.get(PlanarFileMetadata, uploaded_file.id)
|
154
|
+
assert metadata is not None
|
155
|
+
assert metadata.filename == expected_filename
|
156
|
+
assert metadata.content_type == expected_content_type
|
157
|
+
assert metadata.size == expected_size
|
158
|
+
assert metadata.storage_ref is not None
|
159
|
+
|
160
|
+
# Verify stored content
|
161
|
+
retrieved_content = await uploaded_file.get_content()
|
162
|
+
assert retrieved_content == expected_content
|
163
|
+
|
164
|
+
|
165
|
+
async def test_planar_file_upload_bytes(storage: Storage, session: AsyncSession):
|
166
|
+
"""Test PlanarFile.upload with bytes content."""
|
167
|
+
uploaded_file = await PlanarFile.upload(
|
168
|
+
content=TEST_BYTES,
|
169
|
+
filename=TEST_FILENAME,
|
170
|
+
content_type="text/plain",
|
171
|
+
size=100,
|
172
|
+
)
|
173
|
+
await assert_upload_success(
|
174
|
+
uploaded_file,
|
175
|
+
TEST_FILENAME,
|
176
|
+
TEST_BYTES,
|
177
|
+
"text/plain",
|
178
|
+
100,
|
179
|
+
session,
|
180
|
+
)
|
181
|
+
|
182
|
+
|
183
|
+
async def test_planar_file_upload_bytes_defaults(
|
184
|
+
storage: Storage, session: AsyncSession
|
185
|
+
):
|
186
|
+
"""Test PlanarFile.upload with bytes content using default size/type."""
|
187
|
+
uploaded_file = await PlanarFile.upload(content=TEST_BYTES, filename=TEST_FILENAME)
|
188
|
+
await assert_upload_success(
|
189
|
+
uploaded_file,
|
190
|
+
TEST_FILENAME,
|
191
|
+
TEST_BYTES,
|
192
|
+
DEFAULT_CONTENT_TYPE, # Default type expected
|
193
|
+
TEST_SIZE, # Size should be calculated
|
194
|
+
session,
|
195
|
+
)
|
196
|
+
|
197
|
+
|
198
|
+
async def test_planar_file_upload_path(
|
199
|
+
storage: Storage, session: AsyncSession, tmp_path: Path
|
200
|
+
):
|
201
|
+
"""Test PlanarFile.upload with Path content."""
|
202
|
+
test_file = tmp_path / TEST_FILENAME
|
203
|
+
test_file.write_bytes(TEST_BYTES)
|
204
|
+
|
205
|
+
uploaded_file = await PlanarFile.upload(
|
206
|
+
content=test_file,
|
207
|
+
filename=TEST_FILENAME,
|
208
|
+
content_type=TEST_CONTENT_TYPE,
|
209
|
+
size=TEST_SIZE,
|
210
|
+
)
|
211
|
+
await assert_upload_success(
|
212
|
+
uploaded_file,
|
213
|
+
TEST_FILENAME,
|
214
|
+
TEST_BYTES,
|
215
|
+
TEST_CONTENT_TYPE,
|
216
|
+
TEST_SIZE,
|
217
|
+
session,
|
218
|
+
)
|
219
|
+
|
220
|
+
|
221
|
+
async def test_planar_file_upload_path_defaults(
|
222
|
+
storage: Storage, session: AsyncSession, tmp_path: Path
|
223
|
+
):
|
224
|
+
"""Test PlanarFile.upload with Path content using default/inferred size/type."""
|
225
|
+
test_file = tmp_path / "another_test.json" # Use different extension for inference
|
226
|
+
test_data = b'{"key": "value"}'
|
227
|
+
test_file.write_bytes(test_data)
|
228
|
+
|
229
|
+
uploaded_file = await PlanarFile.upload(
|
230
|
+
content=test_file,
|
231
|
+
filename="data.json", # Ensure filename matches for inference
|
232
|
+
)
|
233
|
+
await assert_upload_success(
|
234
|
+
uploaded_file,
|
235
|
+
"data.json",
|
236
|
+
test_data,
|
237
|
+
"application/json", # Inferred type expected
|
238
|
+
len(test_data), # Size should be calculated
|
239
|
+
session,
|
240
|
+
)
|
241
|
+
|
242
|
+
|
243
|
+
async def simple_byte_stream(
|
244
|
+
data: bytes, chunk_size: int = 10
|
245
|
+
) -> AsyncGenerator[bytes, None]:
|
246
|
+
"""Helper async generator for stream tests."""
|
247
|
+
for i in range(0, len(data), chunk_size):
|
248
|
+
yield data[i : i + chunk_size]
|
249
|
+
|
250
|
+
|
251
|
+
async def test_planar_file_upload_stream(storage: Storage, session: AsyncSession):
|
252
|
+
"""Test PlanarFile.upload with AsyncGenerator content."""
|
253
|
+
uploaded_file = await PlanarFile.upload(
|
254
|
+
content=simple_byte_stream(TEST_BYTES),
|
255
|
+
filename=TEST_FILENAME,
|
256
|
+
content_type=TEST_CONTENT_TYPE,
|
257
|
+
size=TEST_SIZE,
|
258
|
+
)
|
259
|
+
await assert_upload_success(
|
260
|
+
uploaded_file,
|
261
|
+
TEST_FILENAME,
|
262
|
+
TEST_BYTES,
|
263
|
+
TEST_CONTENT_TYPE,
|
264
|
+
TEST_SIZE,
|
265
|
+
session,
|
266
|
+
)
|
267
|
+
|
268
|
+
|
269
|
+
async def test_planar_file_upload_stream_defaults(
|
270
|
+
storage: Storage, session: AsyncSession
|
271
|
+
):
|
272
|
+
"""Test PlanarFile.upload with AsyncGenerator content using default size/type."""
|
273
|
+
uploaded_file = await PlanarFile.upload(
|
274
|
+
content=simple_byte_stream(TEST_BYTES), filename=TEST_FILENAME
|
275
|
+
)
|
276
|
+
await assert_upload_success(
|
277
|
+
uploaded_file,
|
278
|
+
TEST_FILENAME,
|
279
|
+
TEST_BYTES,
|
280
|
+
DEFAULT_CONTENT_TYPE, # Default type expected
|
281
|
+
-1, # Size should be unknown (-1)
|
282
|
+
session,
|
283
|
+
)
|
Binary file
|
Binary file
|
planar/human/__init__.py
ADDED