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.
- phlo_testing/__init__.py +297 -0
- phlo_testing/conftest_template.py +65 -0
- phlo_testing/execution.py +405 -0
- phlo_testing/fixtures.py +447 -0
- phlo_testing/hooks.py +135 -0
- phlo_testing/local_mode.py +366 -0
- phlo_testing/mock_dlt.py +287 -0
- phlo_testing/mock_iceberg.py +435 -0
- phlo_testing/mock_trino.py +486 -0
- phlo_testing/placeholders.py +830 -0
- phlo_testing/utils.py +20 -0
- phlo_testing-0.1.0.dist-info/METADATA +22 -0
- phlo_testing-0.1.0.dist-info/RECORD +15 -0
- phlo_testing-0.1.0.dist-info/WHEEL +5 -0
- phlo_testing-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|