phlo-testing 0.1.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.
@@ -0,0 +1,405 @@
1
+ """
2
+ Helper to execute assets with mocked dependencies and capture results.
3
+
4
+ Provides `test_asset_execution()` for running `@phlo_ingestion` assets in tests
5
+ with mocked Iceberg, Trino, and DLT dependencies.
6
+
7
+ Example:
8
+ >>> result = test_asset_execution(
9
+ ... my_asset,
10
+ ... partition="2024-01-01",
11
+ ... mock_data=[{"id": 1, "name": "Alice"}],
12
+ ... )
13
+ >>> assert result.success
14
+ >>> assert len(result.data) == 1
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from typing import Any, Callable, Optional
23
+
24
+ import pandas as pd
25
+
26
+ from phlo.logging import get_logger
27
+ from phlo_testing.mock_iceberg import MockIcebergCatalog
28
+ from phlo_testing.mock_trino import MockTrinoResource
29
+
30
+
31
+ @dataclass
32
+ class AssetTestResult:
33
+ """
34
+ Result of executing an asset in test mode.
35
+
36
+ Attributes:
37
+ success: Whether asset execution succeeded
38
+ data: Resulting DataFrame (if available)
39
+ metadata: Metadata from MaterializeResult
40
+ logs: Captured log messages
41
+ duration: Execution time in seconds
42
+ error: Exception if execution failed
43
+ raw_result: Raw Dagster ExecuteInProcessResult (advanced use)
44
+ """
45
+
46
+ success: bool
47
+ data: Optional[pd.DataFrame] = None
48
+ metadata: dict[str, Any] = field(default_factory=dict)
49
+ logs: list[str] = field(default_factory=list)
50
+ duration: float = 0.0
51
+ error: Optional[Exception] = None
52
+ raw_result: Optional[Any] = None
53
+
54
+
55
+ class MockAssetContext:
56
+ """
57
+ Mock Dagster context for asset execution.
58
+
59
+ Provides mocked resources (Iceberg, Trino, DLT) and logging.
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ partition_key: Optional[str] = None,
65
+ mock_iceberg: Optional[MockIcebergCatalog] = None,
66
+ mock_trino: Optional[MockTrinoResource] = None,
67
+ ) -> None:
68
+ """
69
+ Initialize mock context.
70
+
71
+ Args:
72
+ partition_key: Partition identifier (e.g., "2024-01-01")
73
+ mock_iceberg: MockIcebergCatalog instance (creates new if None)
74
+ mock_trino: MockTrinoResource instance (creates new if None)
75
+ """
76
+ self.partition_key = partition_key or "2024-01-01"
77
+ self.iceberg = mock_iceberg or MockIcebergCatalog()
78
+ self.trino = mock_trino or MockTrinoResource()
79
+
80
+ self._logs: list[str] = []
81
+ self._logger = self._create_logger()
82
+
83
+ def _create_logger(self) -> Any:
84
+ """Create logger that captures to self._logs."""
85
+ name = f"asset_test_{id(self)}"
86
+ logger = get_logger(name)
87
+
88
+ std_logger = logging.getLogger(name)
89
+ std_logger.setLevel(logging.DEBUG)
90
+ std_logger.propagate = False
91
+ std_logger.handlers = []
92
+
93
+ # Add custom handler to capture logs
94
+ class LogCapture(logging.Handler):
95
+ def __init__(self, logs_list: list[str]) -> None:
96
+ super().__init__()
97
+ self.logs = logs_list
98
+
99
+ def emit(self, record: logging.LogRecord) -> None:
100
+ self.logs.append(self.format(record))
101
+
102
+ handler = LogCapture(self._logs)
103
+ formatter = logging.Formatter(
104
+ "%(levelname)s - %(name)s - %(message)s",
105
+ )
106
+ handler.setFormatter(formatter)
107
+ std_logger.addHandler(handler)
108
+
109
+ return logger
110
+
111
+ def log(self, message: str, level: str = "INFO") -> None:
112
+ """
113
+ Log a message.
114
+
115
+ Args:
116
+ message: Message to log
117
+ level: Log level (DEBUG, INFO, WARNING, ERROR)
118
+ """
119
+ resolved_level = level.lower()
120
+ if resolved_level == "warn":
121
+ resolved_level = "warning"
122
+ getattr(self._logger, resolved_level)(message)
123
+
124
+ @property
125
+ def logs(self) -> list[str]:
126
+ """Get all captured logs."""
127
+ return self._logs.copy()
128
+
129
+ def get_resource(self, name: str) -> Any:
130
+ """
131
+ Get a mock resource by name.
132
+
133
+ Args:
134
+ name: Resource name (iceberg, trino, etc.)
135
+
136
+ Returns:
137
+ Mock resource instance
138
+
139
+ Raises:
140
+ ValueError: If resource doesn't exist
141
+ """
142
+ resources = {
143
+ "iceberg": self.iceberg,
144
+ "trino": self.trino,
145
+ }
146
+
147
+ if name not in resources:
148
+ raise ValueError(f"Unknown resource: {name}")
149
+
150
+ return resources[name]
151
+
152
+
153
+ def test_asset_execution(
154
+ asset_fn: Callable,
155
+ partition: str = "2024-01-01",
156
+ mock_data: Optional[list[dict[str, Any]]] = None,
157
+ mock_iceberg: Optional[MockIcebergCatalog] = None,
158
+ mock_trino: Optional[MockTrinoResource] = None,
159
+ expected_schema: Optional[Any] = None,
160
+ materialize_kwargs: Optional[dict[str, Any]] = None,
161
+ _pytest_skip: bool = True, # Flag to prevent pytest collection
162
+ ) -> AssetTestResult:
163
+ """
164
+ Execute an asset with mocked dependencies.
165
+
166
+ Runs a `@phlo_ingestion` asset in isolation with mocked Iceberg,
167
+ Trino, and DLT services. Captures results and logs for inspection.
168
+
169
+ Args:
170
+ asset_fn: Asset function to test
171
+ partition: Partition key (e.g., "2024-01-01")
172
+ mock_data: Mock data to return from DLT source
173
+ mock_iceberg: Pre-configured MockIcebergCatalog (uses new if None)
174
+ mock_trino: Pre-configured MockTrinoResource (uses new if None)
175
+ expected_schema: Pandera schema to validate results
176
+ materialize_kwargs: Extra kwargs to pass to materialize
177
+
178
+ Returns:
179
+ AssetTestResult with execution details
180
+
181
+ Raises:
182
+ ValueError: If asset execution fails (and success=False in result)
183
+
184
+ Example:
185
+ >>> @phlo_ingestion(
186
+ ... unique_key="id",
187
+ ... validation_schema=MySchema,
188
+ ... )
189
+ ... def my_asset(partition_date: str):
190
+ ... return [{"id": 1, "name": "Alice"}]
191
+ ...
192
+ >>> result = test_asset_execution(
193
+ ... my_asset,
194
+ ... partition="2024-01-01",
195
+ ... )
196
+ >>> assert result.success
197
+ >>> assert len(result.data) == 1
198
+ """
199
+ if mock_data is None:
200
+ mock_data = []
201
+
202
+ if materialize_kwargs is None:
203
+ materialize_kwargs = {}
204
+
205
+ start_time = time.time()
206
+ context = MockAssetContext(
207
+ partition_key=partition,
208
+ mock_iceberg=mock_iceberg,
209
+ mock_trino=mock_trino,
210
+ )
211
+
212
+ try:
213
+ # Call asset function with mock context
214
+ result = asset_fn(partition_date=partition)
215
+
216
+ # Convert result to DataFrame if needed
217
+ if isinstance(result, pd.DataFrame):
218
+ data = result
219
+ elif isinstance(result, list):
220
+ data = pd.DataFrame(result) if result else pd.DataFrame()
221
+ else:
222
+ # Assume it's an iterator/generator
223
+ data = pd.DataFrame(list(result)) if result else pd.DataFrame()
224
+
225
+ # Validate against expected schema if provided
226
+ if expected_schema is not None:
227
+ try:
228
+ expected_schema.validate(data)
229
+ except Exception as e:
230
+ return AssetTestResult(
231
+ success=False,
232
+ data=data,
233
+ logs=context.logs,
234
+ duration=time.time() - start_time,
235
+ error=ValueError(f"Schema validation failed: {e}"),
236
+ )
237
+
238
+ return AssetTestResult(
239
+ success=True,
240
+ data=data,
241
+ metadata={
242
+ "row_count": len(data),
243
+ "columns": list(data.columns),
244
+ "partition": partition,
245
+ },
246
+ logs=context.logs,
247
+ duration=time.time() - start_time,
248
+ raw_result=result,
249
+ )
250
+
251
+ except Exception as e:
252
+ return AssetTestResult(
253
+ success=False,
254
+ logs=context.logs,
255
+ duration=time.time() - start_time,
256
+ error=e,
257
+ )
258
+
259
+
260
+ def test_asset_with_catalog(
261
+ asset_fn: Callable,
262
+ partition: str = "2024-01-01",
263
+ catalog: Optional[MockIcebergCatalog] = None,
264
+ ) -> AssetTestResult:
265
+ """
266
+ Execute an asset with access to mock Iceberg catalog.
267
+
268
+ Useful for testing assets that read from or write to Iceberg tables.
269
+
270
+ Args:
271
+ asset_fn: Asset function to test
272
+ partition: Partition key
273
+ catalog: Pre-configured MockIcebergCatalog
274
+
275
+ Returns:
276
+ AssetTestResult with catalog access
277
+
278
+ Example:
279
+ >>> catalog = MockIcebergCatalog()
280
+ >>> # ... set up tables in catalog ...
281
+ >>> result = test_asset_with_catalog(
282
+ ... my_transform_asset,
283
+ ... partition="2024-01-01",
284
+ ... catalog=catalog,
285
+ ... )
286
+ """
287
+ if catalog is None:
288
+ catalog = MockIcebergCatalog()
289
+
290
+ return test_asset_execution(
291
+ asset_fn,
292
+ partition=partition,
293
+ mock_iceberg=catalog,
294
+ )
295
+
296
+
297
+ def test_asset_with_trino(
298
+ asset_fn: Callable,
299
+ partition: str = "2024-01-01",
300
+ trino: Optional[MockTrinoResource] = None,
301
+ ) -> AssetTestResult:
302
+ """
303
+ Execute an asset with access to mock Trino resource.
304
+
305
+ Useful for testing quality checks and transform assets.
306
+
307
+ Args:
308
+ asset_fn: Asset function to test
309
+ partition: Partition key
310
+ trino: Pre-configured MockTrinoResource
311
+
312
+ Returns:
313
+ AssetTestResult with Trino access
314
+
315
+ Example:
316
+ >>> trino = MockTrinoResource()
317
+ >>> # ... set up tables ...
318
+ >>> result = test_asset_with_trino(
319
+ ... my_quality_check,
320
+ ... trino=trino,
321
+ ... )
322
+ """
323
+ if trino is None:
324
+ trino = MockTrinoResource()
325
+
326
+ return test_asset_execution(
327
+ asset_fn,
328
+ partition=partition,
329
+ mock_trino=trino,
330
+ )
331
+
332
+
333
+ class TestAssetExecutor:
334
+ """
335
+ Reusable executor for testing multiple asset runs.
336
+
337
+ Maintains catalog state across multiple executions for integration testing.
338
+
339
+ Example:
340
+ >>> executor = TestAssetExecutor()
341
+ >>> result1 = executor.execute(asset1, partition="2024-01-01")
342
+ >>> result2 = executor.execute(asset2, partition="2024-01-01")
343
+ >>> # Both use same catalog instance
344
+ """
345
+
346
+ def __init__(
347
+ self,
348
+ catalog: Optional[MockIcebergCatalog] = None,
349
+ trino: Optional[MockTrinoResource] = None,
350
+ ) -> None:
351
+ """
352
+ Initialize executor.
353
+
354
+ Args:
355
+ catalog: Shared MockIcebergCatalog
356
+ trino: Shared MockTrinoResource
357
+ """
358
+ self.catalog = catalog or MockIcebergCatalog()
359
+ self.trino = trino or MockTrinoResource()
360
+ self.results: list[AssetTestResult] = []
361
+
362
+ def execute(
363
+ self,
364
+ asset_fn: Callable,
365
+ partition: str = "2024-01-01",
366
+ mock_data: Optional[list[dict[str, Any]]] = None,
367
+ ) -> AssetTestResult:
368
+ """
369
+ Execute an asset with shared resources.
370
+
371
+ Args:
372
+ asset_fn: Asset function to test
373
+ partition: Partition key
374
+ mock_data: Mock data (not used in executor mode)
375
+
376
+ Returns:
377
+ AssetTestResult
378
+ """
379
+ result = test_asset_execution(
380
+ asset_fn,
381
+ partition=partition,
382
+ mock_iceberg=self.catalog,
383
+ mock_trino=self.trino,
384
+ )
385
+
386
+ self.results.append(result)
387
+ return result
388
+
389
+ def get_results(self, asset_fn: Callable) -> list[AssetTestResult]:
390
+ """
391
+ Get results for a specific asset.
392
+
393
+ Args:
394
+ asset_fn: Asset function to filter by
395
+
396
+ Returns:
397
+ List of results for that asset
398
+ """
399
+ # This is a simplified implementation
400
+ # In practice, you'd track asset names
401
+ return self.results
402
+
403
+ def cleanup(self) -> None:
404
+ """Clean up resources."""
405
+ self.catalog.close()