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,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
@@ -0,0 +1,2 @@
1
+ from .human import Human, Timeout, complete_human_task, get_human_tasks # noqa: F401
2
+ from .models import HumanTask, HumanTaskResult # noqa: F401