planar 0.5.0__py3-none-any.whl → 0.8.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 (211) hide show
  1. planar/_version.py +1 -1
  2. planar/ai/agent.py +155 -283
  3. planar/ai/agent_base.py +170 -0
  4. planar/ai/agent_utils.py +7 -0
  5. planar/ai/pydantic_ai.py +638 -0
  6. planar/ai/test_agent_serialization.py +1 -1
  7. planar/app.py +64 -20
  8. planar/cli.py +39 -27
  9. planar/config.py +45 -36
  10. planar/db/db.py +2 -1
  11. planar/files/storage/azure_blob.py +343 -0
  12. planar/files/storage/base.py +7 -0
  13. planar/files/storage/config.py +70 -7
  14. planar/files/storage/s3.py +6 -6
  15. planar/files/storage/test_azure_blob.py +435 -0
  16. planar/logging/formatter.py +17 -4
  17. planar/logging/test_formatter.py +327 -0
  18. planar/registry_items.py +2 -1
  19. planar/routers/agents_router.py +3 -1
  20. planar/routers/files.py +11 -2
  21. planar/routers/models.py +14 -1
  22. planar/routers/test_agents_router.py +1 -1
  23. planar/routers/test_files_router.py +49 -0
  24. planar/routers/test_routes_security.py +5 -7
  25. planar/routers/test_workflow_router.py +270 -3
  26. planar/routers/workflow.py +95 -36
  27. planar/rules/models.py +36 -39
  28. planar/rules/test_data/account_dormancy_management.json +223 -0
  29. planar/rules/test_data/airline_loyalty_points_calculator.json +262 -0
  30. planar/rules/test_data/applicant_risk_assessment.json +435 -0
  31. planar/rules/test_data/booking_fraud_detection.json +407 -0
  32. planar/rules/test_data/cellular_data_rollover_system.json +258 -0
  33. planar/rules/test_data/clinical_trial_eligibility_screener.json +437 -0
  34. planar/rules/test_data/customer_lifetime_value.json +143 -0
  35. planar/rules/test_data/import_duties_calculator.json +289 -0
  36. planar/rules/test_data/insurance_prior_authorization.json +443 -0
  37. planar/rules/test_data/online_check_in_eligibility_system.json +254 -0
  38. planar/rules/test_data/order_consolidation_system.json +375 -0
  39. planar/rules/test_data/portfolio_risk_monitor.json +471 -0
  40. planar/rules/test_data/supply_chain_risk.json +253 -0
  41. planar/rules/test_data/warehouse_cross_docking.json +237 -0
  42. planar/rules/test_rules.py +750 -6
  43. planar/scaffold_templates/planar.dev.yaml.j2 +6 -6
  44. planar/scaffold_templates/planar.prod.yaml.j2 +9 -5
  45. planar/scaffold_templates/pyproject.toml.j2 +1 -1
  46. planar/security/auth_context.py +21 -0
  47. planar/security/{jwt_middleware.py → auth_middleware.py} +70 -17
  48. planar/security/authorization.py +9 -15
  49. planar/security/tests/test_auth_middleware.py +162 -0
  50. planar/sse/proxy.py +4 -9
  51. planar/test_app.py +92 -1
  52. planar/test_cli.py +81 -59
  53. planar/test_config.py +17 -14
  54. planar/testing/fixtures.py +325 -0
  55. planar/testing/planar_test_client.py +5 -2
  56. planar/utils.py +41 -1
  57. planar/workflows/execution.py +1 -1
  58. planar/workflows/orchestrator.py +5 -0
  59. planar/workflows/serialization.py +12 -6
  60. planar/workflows/step_core.py +3 -1
  61. planar/workflows/test_serialization.py +9 -1
  62. {planar-0.5.0.dist-info → planar-0.8.0.dist-info}/METADATA +30 -5
  63. planar-0.8.0.dist-info/RECORD +166 -0
  64. planar/.__init__.py.un~ +0 -0
  65. planar/._version.py.un~ +0 -0
  66. planar/.app.py.un~ +0 -0
  67. planar/.cli.py.un~ +0 -0
  68. planar/.config.py.un~ +0 -0
  69. planar/.context.py.un~ +0 -0
  70. planar/.db.py.un~ +0 -0
  71. planar/.di.py.un~ +0 -0
  72. planar/.engine.py.un~ +0 -0
  73. planar/.files.py.un~ +0 -0
  74. planar/.log_context.py.un~ +0 -0
  75. planar/.log_metadata.py.un~ +0 -0
  76. planar/.logging.py.un~ +0 -0
  77. planar/.object_registry.py.un~ +0 -0
  78. planar/.otel.py.un~ +0 -0
  79. planar/.server.py.un~ +0 -0
  80. planar/.session.py.un~ +0 -0
  81. planar/.sqlalchemy.py.un~ +0 -0
  82. planar/.task_local.py.un~ +0 -0
  83. planar/.test_app.py.un~ +0 -0
  84. planar/.test_config.py.un~ +0 -0
  85. planar/.test_object_config.py.un~ +0 -0
  86. planar/.test_sqlalchemy.py.un~ +0 -0
  87. planar/.test_utils.py.un~ +0 -0
  88. planar/.util.py.un~ +0 -0
  89. planar/.utils.py.un~ +0 -0
  90. planar/ai/.__init__.py.un~ +0 -0
  91. planar/ai/._models.py.un~ +0 -0
  92. planar/ai/.agent.py.un~ +0 -0
  93. planar/ai/.agent_utils.py.un~ +0 -0
  94. planar/ai/.events.py.un~ +0 -0
  95. planar/ai/.files.py.un~ +0 -0
  96. planar/ai/.models.py.un~ +0 -0
  97. planar/ai/.providers.py.un~ +0 -0
  98. planar/ai/.pydantic_ai.py.un~ +0 -0
  99. planar/ai/.pydantic_ai_agent.py.un~ +0 -0
  100. planar/ai/.pydantic_ai_provider.py.un~ +0 -0
  101. planar/ai/.step.py.un~ +0 -0
  102. planar/ai/.test_agent.py.un~ +0 -0
  103. planar/ai/.test_agent_serialization.py.un~ +0 -0
  104. planar/ai/.test_providers.py.un~ +0 -0
  105. planar/ai/.utils.py.un~ +0 -0
  106. planar/ai/providers.py +0 -1088
  107. planar/ai/test_agent.py +0 -1298
  108. planar/ai/test_providers.py +0 -463
  109. planar/db/.db.py.un~ +0 -0
  110. planar/files/.config.py.un~ +0 -0
  111. planar/files/.local.py.un~ +0 -0
  112. planar/files/.local_filesystem.py.un~ +0 -0
  113. planar/files/.model.py.un~ +0 -0
  114. planar/files/.models.py.un~ +0 -0
  115. planar/files/.s3.py.un~ +0 -0
  116. planar/files/.storage.py.un~ +0 -0
  117. planar/files/.test_files.py.un~ +0 -0
  118. planar/files/storage/.__init__.py.un~ +0 -0
  119. planar/files/storage/.base.py.un~ +0 -0
  120. planar/files/storage/.config.py.un~ +0 -0
  121. planar/files/storage/.context.py.un~ +0 -0
  122. planar/files/storage/.local_directory.py.un~ +0 -0
  123. planar/files/storage/.test_local_directory.py.un~ +0 -0
  124. planar/files/storage/.test_s3.py.un~ +0 -0
  125. planar/human/.human.py.un~ +0 -0
  126. planar/human/.test_human.py.un~ +0 -0
  127. planar/logging/.__init__.py.un~ +0 -0
  128. planar/logging/.attributes.py.un~ +0 -0
  129. planar/logging/.formatter.py.un~ +0 -0
  130. planar/logging/.logger.py.un~ +0 -0
  131. planar/logging/.otel.py.un~ +0 -0
  132. planar/logging/.tracer.py.un~ +0 -0
  133. planar/modeling/.mixin.py.un~ +0 -0
  134. planar/modeling/.storage.py.un~ +0 -0
  135. planar/modeling/orm/.planar_base_model.py.un~ +0 -0
  136. planar/object_config/.object_config.py.un~ +0 -0
  137. planar/routers/.__init__.py.un~ +0 -0
  138. planar/routers/.agents_router.py.un~ +0 -0
  139. planar/routers/.crud.py.un~ +0 -0
  140. planar/routers/.decision.py.un~ +0 -0
  141. planar/routers/.event.py.un~ +0 -0
  142. planar/routers/.file_attachment.py.un~ +0 -0
  143. planar/routers/.files.py.un~ +0 -0
  144. planar/routers/.files_router.py.un~ +0 -0
  145. planar/routers/.human.py.un~ +0 -0
  146. planar/routers/.info.py.un~ +0 -0
  147. planar/routers/.models.py.un~ +0 -0
  148. planar/routers/.object_config_router.py.un~ +0 -0
  149. planar/routers/.rule.py.un~ +0 -0
  150. planar/routers/.test_object_config_router.py.un~ +0 -0
  151. planar/routers/.test_workflow_router.py.un~ +0 -0
  152. planar/routers/.workflow.py.un~ +0 -0
  153. planar/rules/.decorator.py.un~ +0 -0
  154. planar/rules/.runner.py.un~ +0 -0
  155. planar/rules/.test_rules.py.un~ +0 -0
  156. planar/security/.jwt_middleware.py.un~ +0 -0
  157. planar/sse/.constants.py.un~ +0 -0
  158. planar/sse/.example.html.un~ +0 -0
  159. planar/sse/.hub.py.un~ +0 -0
  160. planar/sse/.model.py.un~ +0 -0
  161. planar/sse/.proxy.py.un~ +0 -0
  162. planar/testing/.client.py.un~ +0 -0
  163. planar/testing/.memory_storage.py.un~ +0 -0
  164. planar/testing/.planar_test_client.py.un~ +0 -0
  165. planar/testing/.predictable_tracer.py.un~ +0 -0
  166. planar/testing/.synchronizable_tracer.py.un~ +0 -0
  167. planar/testing/.test_memory_storage.py.un~ +0 -0
  168. planar/testing/.workflow_observer.py.un~ +0 -0
  169. planar/workflows/.__init__.py.un~ +0 -0
  170. planar/workflows/.builtin_steps.py.un~ +0 -0
  171. planar/workflows/.concurrency_tracing.py.un~ +0 -0
  172. planar/workflows/.context.py.un~ +0 -0
  173. planar/workflows/.contrib.py.un~ +0 -0
  174. planar/workflows/.decorators.py.un~ +0 -0
  175. planar/workflows/.durable_test.py.un~ +0 -0
  176. planar/workflows/.errors.py.un~ +0 -0
  177. planar/workflows/.events.py.un~ +0 -0
  178. planar/workflows/.exceptions.py.un~ +0 -0
  179. planar/workflows/.execution.py.un~ +0 -0
  180. planar/workflows/.human.py.un~ +0 -0
  181. planar/workflows/.lock.py.un~ +0 -0
  182. planar/workflows/.misc.py.un~ +0 -0
  183. planar/workflows/.model.py.un~ +0 -0
  184. planar/workflows/.models.py.un~ +0 -0
  185. planar/workflows/.notifications.py.un~ +0 -0
  186. planar/workflows/.orchestrator.py.un~ +0 -0
  187. planar/workflows/.runtime.py.un~ +0 -0
  188. planar/workflows/.serialization.py.un~ +0 -0
  189. planar/workflows/.step.py.un~ +0 -0
  190. planar/workflows/.step_core.py.un~ +0 -0
  191. planar/workflows/.sub_workflow_runner.py.un~ +0 -0
  192. planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
  193. planar/workflows/.test_concurrency.py.un~ +0 -0
  194. planar/workflows/.test_concurrency_detection.py.un~ +0 -0
  195. planar/workflows/.test_human.py.un~ +0 -0
  196. planar/workflows/.test_lock_timeout.py.un~ +0 -0
  197. planar/workflows/.test_orchestrator.py.un~ +0 -0
  198. planar/workflows/.test_race_conditions.py.un~ +0 -0
  199. planar/workflows/.test_serialization.py.un~ +0 -0
  200. planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
  201. planar/workflows/.test_workflow.py.un~ +0 -0
  202. planar/workflows/.tracing.py.un~ +0 -0
  203. planar/workflows/.types.py.un~ +0 -0
  204. planar/workflows/.util.py.un~ +0 -0
  205. planar/workflows/.utils.py.un~ +0 -0
  206. planar/workflows/.workflow.py.un~ +0 -0
  207. planar/workflows/.workflow_wrapper.py.un~ +0 -0
  208. planar/workflows/.wrappers.py.un~ +0 -0
  209. planar-0.5.0.dist-info/RECORD +0 -289
  210. {planar-0.5.0.dist-info → planar-0.8.0.dist-info}/WHEEL +0 -0
  211. {planar-0.5.0.dist-info → planar-0.8.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,435 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import uuid
5
+ from contextlib import asynccontextmanager
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import pytest
9
+
10
+ try:
11
+ from azure.core.exceptions import ResourceExistsError
12
+ from azure.storage.blob._shared.policies_async import ExponentialRetry
13
+ from azure.storage.blob.aio import BlobServiceClient
14
+
15
+ from planar.files.storage.azure_blob import AzureBlobStorage
16
+
17
+ azure_available = True
18
+ import_error = None
19
+ except ImportError as e:
20
+ import_error = e
21
+ azure_available = False
22
+
23
+ # Avoid evaluating runtime annotations when Azure SDK isn't installed
24
+ if TYPE_CHECKING or azure_available:
25
+ # Only imported for type checking; not at runtime
26
+ from azure.core.exceptions import ResourceExistsError
27
+ from azure.storage.blob._shared.policies_async import ExponentialRetry
28
+ from azure.storage.blob.aio import BlobServiceClient
29
+
30
+ from planar.files.storage.azure_blob import AzureBlobStorage # pragma: no cover
31
+ else:
32
+ AzureBlobStorage = Any # type: ignore
33
+
34
+ from planar.logging import get_logger
35
+
36
+ pytestmark = [
37
+ pytest.mark.skipif(
38
+ not azure_available,
39
+ reason=f"Azure blob not available: {import_error or 'unknown error'}",
40
+ ),
41
+ pytest.mark.azure_blob,
42
+ ]
43
+
44
+
45
+ logger = get_logger(__name__)
46
+
47
+ # --- Configuration for Azurite (Azure Storage Emulator) ---
48
+
49
+ AZURITE_ACCOUNT_NAME = "devstoreaccount1"
50
+ AZURITE_ACCOUNT_KEY = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=="
51
+ AZURITE_ENDPOINT = "http://127.0.0.1:10000"
52
+ AZURITE_CONNECTION_STRING = (
53
+ f"DefaultEndpointsProtocol=http;AccountName={AZURITE_ACCOUNT_NAME};"
54
+ f"AccountKey={AZURITE_ACCOUNT_KEY};BlobEndpoint={AZURITE_ENDPOINT}/{AZURITE_ACCOUNT_NAME};"
55
+ )
56
+
57
+ # Generate a unique container name for each test run session
58
+ SESSION_CONTAINER_NAME = f"planar-test-{uuid.uuid4()}"
59
+
60
+
61
+ @pytest.fixture()
62
+ def azure_client():
63
+ """Provides an Azure BlobServiceClient for direct interaction (e.g., container creation)."""
64
+ client = BlobServiceClient.from_connection_string(
65
+ AZURITE_CONNECTION_STRING,
66
+ connection_timeout=5,
67
+ read_timeout=5,
68
+ retry_policy=ExponentialRetry(
69
+ retry_total=1,
70
+ ),
71
+ )
72
+ return client
73
+
74
+
75
+ @pytest.fixture(autouse=True)
76
+ async def ensure_azure_container(azure_client):
77
+ """
78
+ Ensures the Azure container exists before tests run.
79
+ This runs automatically due to autouse=True.
80
+ """
81
+ logger.warning(
82
+ "attempting to create container",
83
+ container_name=SESSION_CONTAINER_NAME,
84
+ storage_type="azurite",
85
+ )
86
+
87
+ try:
88
+ # Add timeout to container creation to fail fast if Azurite isn't running
89
+ async with azure_client:
90
+ container_client = azure_client.get_container_client(SESSION_CONTAINER_NAME)
91
+ await container_client.create_container(timeout=1)
92
+ logger.info("container created", container_name=SESSION_CONTAINER_NAME)
93
+ except ResourceExistsError:
94
+ logger.info("container already exists", container_name=SESSION_CONTAINER_NAME)
95
+ except asyncio.TimeoutError:
96
+ pytest.fail(
97
+ f"Timeout creating Azure container {SESSION_CONTAINER_NAME}. "
98
+ "Is Azurite running? Start with: docker run -p 127.0.0.1:10000:10000 -d mcr.microsoft.com/azure-storage/azurite azurite-blob"
99
+ )
100
+ except Exception as e:
101
+ pytest.fail(
102
+ f"Failed to create Azure container {SESSION_CONTAINER_NAME}: {e}. "
103
+ "Is Azurite running? Start with: docker run -p 127.0.0.1:10000:10000 -d mcr.microsoft.com/azure-storage/azurite azurite-blob"
104
+ )
105
+
106
+ yield # Tests run here
107
+
108
+
109
+ @pytest.fixture
110
+ async def azure_storage_connection_string():
111
+ """Provides an AzureBlobStorage instance using connection string auth."""
112
+ storage_instance = AzureBlobStorage(
113
+ container_name=SESSION_CONTAINER_NAME,
114
+ connection_string=AZURITE_CONNECTION_STRING,
115
+ )
116
+ async with storage_instance as storage:
117
+ yield storage
118
+
119
+
120
+ @pytest.fixture
121
+ async def azure_storage_account_key():
122
+ """Provides an AzureBlobStorage instance using account key auth."""
123
+ account_url = f"{AZURITE_ENDPOINT}/{AZURITE_ACCOUNT_NAME}"
124
+ storage_instance = AzureBlobStorage(
125
+ container_name=SESSION_CONTAINER_NAME,
126
+ account_url=account_url,
127
+ account_key=AZURITE_ACCOUNT_KEY,
128
+ )
129
+ async with storage_instance as storage:
130
+ yield storage
131
+
132
+
133
+ @asynccontextmanager
134
+ async def cleanup_azure_blob(storage: AzureBlobStorage, ref: str):
135
+ """Context manager to ensure an Azure blob is deleted after use."""
136
+ try:
137
+ yield
138
+ finally:
139
+ try:
140
+ logger.debug("cleaning up blob", blob_ref=ref)
141
+ await storage.delete(ref)
142
+ except FileNotFoundError:
143
+ logger.debug("blob already deleted", blob_ref=ref)
144
+ except Exception as e:
145
+ logger.warning("blob cleanup failed", blob_ref=ref, error=str(e))
146
+
147
+
148
+ # --- Test Cases ---
149
+ async def test_put_get_bytes_connection_string(
150
+ azure_storage_connection_string: AzureBlobStorage,
151
+ ):
152
+ """Test storing and retrieving raw bytes using connection string auth."""
153
+ storage = azure_storage_connection_string
154
+ test_data = b"some binary data \x00\xff for azure blob"
155
+ mime_type = "application/octet-stream"
156
+ ref = None
157
+
158
+ try:
159
+ ref = await storage.put_bytes(test_data, mime_type=mime_type)
160
+ assert isinstance(ref, str)
161
+
162
+ # Validate ref is a UUID
163
+ try:
164
+ uuid.UUID(ref)
165
+ except ValueError:
166
+ pytest.fail(f"Returned ref '{ref}' is not a valid UUID string")
167
+
168
+ async with cleanup_azure_blob(storage, ref):
169
+ retrieved_data, retrieved_mime = await storage.get_bytes(ref)
170
+
171
+ assert retrieved_data == test_data
172
+ assert retrieved_mime == mime_type
173
+
174
+ # Check external URL (should be a SAS URL)
175
+ url = await storage.external_url(ref)
176
+ assert url is not None
177
+ assert SESSION_CONTAINER_NAME in url
178
+ assert ref in url
179
+ assert "sig=" in url # SAS signature
180
+
181
+ except Exception as e:
182
+ if ref:
183
+ await cleanup_azure_blob(storage, ref).__aexit__(None, None, None)
184
+ raise e
185
+
186
+
187
+ async def test_put_get_bytes_account_key(
188
+ azure_storage_account_key: AzureBlobStorage,
189
+ ):
190
+ """Test storing and retrieving raw bytes using account key auth."""
191
+ storage = azure_storage_account_key
192
+ test_data = b"azure blob test data with account key"
193
+ mime_type = "text/plain"
194
+ ref = None
195
+
196
+ try:
197
+ ref = await storage.put_bytes(test_data, mime_type=mime_type)
198
+
199
+ async with cleanup_azure_blob(storage, ref):
200
+ retrieved_data, retrieved_mime = await storage.get_bytes(ref)
201
+
202
+ assert retrieved_data == test_data
203
+ assert retrieved_mime == mime_type
204
+
205
+ # Check external URL
206
+ url = await storage.external_url(ref)
207
+ assert url is not None
208
+ assert ref in url
209
+
210
+ except Exception as e:
211
+ if ref:
212
+ await cleanup_azure_blob(storage, ref).__aexit__(None, None, None)
213
+ raise e
214
+
215
+
216
+ async def test_put_get_string(
217
+ azure_storage_connection_string: AzureBlobStorage,
218
+ ):
219
+ """Test storing and retrieving a string."""
220
+ storage = azure_storage_connection_string
221
+ test_string = "Hello, Azure Blob Storage! This is a test string with Unicode: éàçü."
222
+ mime_type = "text/plain"
223
+ encoding = "utf-8"
224
+ ref = None
225
+
226
+ try:
227
+ ref = await storage.put_string(
228
+ test_string, encoding=encoding, mime_type=mime_type
229
+ )
230
+ expected_mime_type = f"{mime_type}; charset={encoding}"
231
+
232
+ async with cleanup_azure_blob(storage, ref):
233
+ retrieved_string, retrieved_mime = await storage.get_string(
234
+ ref, encoding=encoding
235
+ )
236
+
237
+ assert retrieved_string == test_string
238
+ assert retrieved_mime == expected_mime_type
239
+
240
+ except Exception as e:
241
+ if ref:
242
+ await cleanup_azure_blob(storage, ref).__aexit__(None, None, None)
243
+ raise e
244
+
245
+
246
+ async def test_put_get_stream(
247
+ azure_storage_connection_string: AzureBlobStorage,
248
+ ):
249
+ """Test storing data from an async generator stream."""
250
+ storage = azure_storage_connection_string
251
+ test_chunks = [b"azure_chunk1 ", b"azure_chunk2 ", b"azure_chunk3"]
252
+ full_data = b"".join(test_chunks)
253
+ mime_type = "application/json"
254
+ ref = None
255
+
256
+ async def _test_stream():
257
+ for chunk in test_chunks:
258
+ yield chunk
259
+ await asyncio.sleep(0.01) # Simulate async work
260
+
261
+ try:
262
+ ref = await storage.put(_test_stream(), mime_type=mime_type)
263
+
264
+ async with cleanup_azure_blob(storage, ref):
265
+ stream, retrieved_mime = await storage.get(ref)
266
+ retrieved_data = b""
267
+ async for chunk in stream:
268
+ retrieved_data += chunk
269
+
270
+ assert retrieved_data == full_data
271
+ assert retrieved_mime == mime_type
272
+
273
+ except Exception as e:
274
+ if ref:
275
+ await cleanup_azure_blob(storage, ref).__aexit__(None, None, None)
276
+ raise e
277
+
278
+
279
+ async def test_put_no_mime_type(
280
+ azure_storage_connection_string: AzureBlobStorage,
281
+ ):
282
+ """Test storing data without providing a mime type."""
283
+ storage = azure_storage_connection_string
284
+ test_data = b"azure data without mime"
285
+ ref = None
286
+
287
+ try:
288
+ ref = await storage.put_bytes(test_data)
289
+
290
+ async with cleanup_azure_blob(storage, ref):
291
+ retrieved_data, retrieved_mime = await storage.get_bytes(ref)
292
+
293
+ assert retrieved_data == test_data
294
+ # Azure might not set a mime type if none provided
295
+ logger.debug(
296
+ "retrieved mime type", mime_type=retrieved_mime, provided=False
297
+ )
298
+
299
+ except Exception as e:
300
+ if ref:
301
+ await cleanup_azure_blob(storage, ref).__aexit__(None, None, None)
302
+ raise e
303
+
304
+
305
+ async def test_delete(
306
+ azure_storage_connection_string: AzureBlobStorage,
307
+ ):
308
+ """Test deleting stored data."""
309
+ storage = azure_storage_connection_string
310
+ ref = await storage.put_bytes(b"to be deleted from azure", mime_type="text/plain")
311
+
312
+ # Verify blob exists before delete
313
+ try:
314
+ _, _ = await storage.get(ref)
315
+ except FileNotFoundError:
316
+ pytest.fail(f"Blob {ref} should exist before deletion but was not found.")
317
+
318
+ # Delete the blob
319
+ await storage.delete(ref)
320
+
321
+ # Verify blob is gone after delete
322
+ with pytest.raises(FileNotFoundError):
323
+ await storage.get(ref)
324
+
325
+ # Deleting again should be idempotent (no error)
326
+ try:
327
+ await storage.delete(ref)
328
+ except Exception as e:
329
+ pytest.fail(f"Deleting already deleted ref raised an exception: {e}")
330
+
331
+
332
+ async def test_get_non_existent(
333
+ azure_storage_connection_string: AzureBlobStorage,
334
+ ):
335
+ """Test getting a reference that does not exist."""
336
+ storage = azure_storage_connection_string
337
+ non_existent_ref = str(uuid.uuid4())
338
+
339
+ with pytest.raises(FileNotFoundError):
340
+ await storage.get(non_existent_ref)
341
+
342
+
343
+ async def test_delete_non_existent(
344
+ azure_storage_connection_string: AzureBlobStorage,
345
+ ):
346
+ """Test deleting a reference that does not exist (should not raise error)."""
347
+ storage = azure_storage_connection_string
348
+ non_existent_ref = str(uuid.uuid4())
349
+
350
+ try:
351
+ await storage.delete(non_existent_ref)
352
+ except Exception as e:
353
+ pytest.fail(f"Deleting non-existent ref raised an exception: {e}")
354
+
355
+
356
+ async def test_external_url_connection_string(
357
+ azure_storage_connection_string: AzureBlobStorage,
358
+ ):
359
+ """Test that external_url returns a valid-looking SAS URL with connection string auth."""
360
+ storage = azure_storage_connection_string
361
+ ref = None
362
+
363
+ try:
364
+ ref = await storage.put_bytes(b"some data for url test")
365
+
366
+ async with cleanup_azure_blob(storage, ref):
367
+ url = await storage.external_url(ref)
368
+ assert url is not None
369
+ assert ref in url
370
+ assert SESSION_CONTAINER_NAME in url
371
+ # SAS URLs should have these query parameters
372
+ assert "sig=" in url # Signature
373
+ assert "se=" in url # Expiry time
374
+
375
+ except Exception as e:
376
+ if ref:
377
+ await cleanup_azure_blob(storage, ref).__aexit__(None, None, None)
378
+ raise e
379
+
380
+
381
+ def test_config_validation():
382
+ """Test that the configuration validation works properly."""
383
+ from planar.files.storage.config import AzureBlobConfig
384
+
385
+ # These should all validate successfully
386
+ # Test connection string only (valid)
387
+ AzureBlobConfig(
388
+ backend="azure_blob",
389
+ container_name="test",
390
+ connection_string="DefaultEndpointsProtocol=https;AccountName=test;AccountKey=key;",
391
+ )
392
+
393
+ # Test account_url + account_key (valid)
394
+ AzureBlobConfig(
395
+ backend="azure_blob",
396
+ container_name="test",
397
+ account_url="https://test.blob.core.windows.net",
398
+ account_key="test-key",
399
+ )
400
+
401
+ # Test account_url + use_azure_ad (valid)
402
+ AzureBlobConfig(
403
+ backend="azure_blob",
404
+ container_name="test",
405
+ account_url="https://test.blob.core.windows.net",
406
+ use_azure_ad=True,
407
+ )
408
+
409
+ # Test invalid configs
410
+ with pytest.raises(ValueError, match="connection_string"):
411
+ # Connection string + other options
412
+ AzureBlobConfig(
413
+ backend="azure_blob",
414
+ container_name="test",
415
+ connection_string="test",
416
+ account_url="https://test.blob.core.windows.net",
417
+ )
418
+
419
+ with pytest.raises(ValueError, match="account_url must be provided"):
420
+ # No connection string and no account_url
421
+ AzureBlobConfig(
422
+ backend="azure_blob",
423
+ container_name="test",
424
+ use_azure_ad=True,
425
+ )
426
+
427
+ with pytest.raises(ValueError, match="exactly one credential method"):
428
+ # Both account_key and use_azure_ad
429
+ AzureBlobConfig(
430
+ backend="azure_blob",
431
+ container_name="test",
432
+ account_url="https://test.blob.core.windows.net",
433
+ account_key="key",
434
+ use_azure_ad=True,
435
+ )
@@ -2,6 +2,7 @@ import json
2
2
  import logging
3
3
  from typing import Any, Dict
4
4
 
5
+ from pydantic import BaseModel
5
6
  from pygments import formatters, highlight, lexers
6
7
 
7
8
  # A set of standard LogRecord attributes that should not be included in the extra fields.
@@ -31,7 +32,6 @@ STANDARD_LOG_RECORD_ATTRS = {
31
32
  "stack_info",
32
33
  "thread",
33
34
  "threadName",
34
- "taskName",
35
35
  }
36
36
 
37
37
 
@@ -46,10 +46,23 @@ RESET = "\033[0m"
46
46
  DARK_GRAY = "\033[90m"
47
47
 
48
48
 
49
+ def serialize_value(val: Any) -> Any:
50
+ if isinstance(val, BaseModel):
51
+ return val.model_dump(mode="json")
52
+ elif isinstance(val, list):
53
+ return [serialize_value(item) for item in val]
54
+ elif isinstance(val, dict):
55
+ return {k: serialize_value(v) for k, v in val.items()}
56
+ elif not isinstance(val, (dict, list, int, bool, float, str, type(None))):
57
+ return str(val)
58
+ else:
59
+ return val
60
+
61
+
49
62
  def json_print(value: Any, use_colors: bool = False) -> str:
50
- if not isinstance(value, (dict, list, int, bool, float, str)):
51
- value = str(value)
52
- stringified = json.dumps(value)
63
+ serialized = serialize_value(value)
64
+ stringified = json.dumps(serialized)
65
+
53
66
  if use_colors:
54
67
  lexer = lexers.JsonLexer()
55
68
  formatter = formatters.TerminalFormatter()