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,447 @@
1
+ """
2
+ Pytest fixtures for testing Phlo assets and workflows.
3
+
4
+ Provides reusable fixtures for common test scenarios including mock resources,
5
+ test data, and temporary directories.
6
+
7
+ Example:
8
+ >>> def test_my_asset(mock_iceberg_catalog, sample_partition_date):
9
+ ... # Use fixtures automatically
10
+ ... pass
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import tempfile
17
+ from datetime import datetime, timedelta
18
+ from pathlib import Path
19
+ from typing import Any, Callable, Iterator
20
+
21
+ import pandas as pd
22
+ import pytest
23
+
24
+ from phlo_testing.execution import MockAssetContext
25
+ from phlo_testing.hooks import MockHookBus
26
+ from phlo_testing.conftest_template import get_conftest_template
27
+ from phlo_testing.mock_dlt import MockDLTResource, mock_dlt_source
28
+ from phlo_testing.mock_iceberg import MockIcebergCatalog
29
+ from phlo_testing.mock_trino import MockTrinoResource
30
+
31
+ # --- Mock Resource Fixtures ---
32
+
33
+
34
+ @pytest.fixture
35
+ def mock_iceberg_catalog() -> Iterator[MockIcebergCatalog]:
36
+ """
37
+ Provide a fresh MockIcebergCatalog for each test.
38
+
39
+ Fixture is function-scoped and auto-cleaned up after test.
40
+
41
+ Example:
42
+ >>> def test_with_catalog(mock_iceberg_catalog):
43
+ ... table = mock_iceberg_catalog.create_table(
44
+ ... "raw.users",
45
+ ... schema=get_schema(),
46
+ ... )
47
+ """
48
+ catalog = MockIcebergCatalog()
49
+ yield catalog
50
+ catalog.close()
51
+
52
+
53
+ @pytest.fixture
54
+ def mock_trino() -> Iterator[MockTrinoResource]:
55
+ """
56
+ Provide a fresh MockTrinoResource for each test.
57
+
58
+ Fixture is function-scoped and auto-cleaned up after test.
59
+
60
+ Example:
61
+ >>> def test_with_trino(mock_trino):
62
+ ... cursor = mock_trino.cursor()
63
+ ... cursor.execute("SELECT 1 as id")
64
+ """
65
+ trino = MockTrinoResource()
66
+ yield trino
67
+ trino.close()
68
+
69
+
70
+ @pytest.fixture
71
+ def mock_asset_context() -> Iterator[MockAssetContext]:
72
+ """
73
+ Provide a fresh MockAssetContext for each test.
74
+
75
+ Includes mock Iceberg and Trino resources plus logging capture.
76
+
77
+ Example:
78
+ >>> def test_with_context(mock_asset_context):
79
+ ... context.log("test message")
80
+ ... assert "test message" in context.logs
81
+ """
82
+ context = MockAssetContext()
83
+ yield context
84
+
85
+
86
+ @pytest.fixture
87
+ def mock_hook_bus() -> MockHookBus:
88
+ """Provide a mock hook bus without plugin discovery."""
89
+ return MockHookBus()
90
+
91
+
92
+ @pytest.fixture
93
+ def mock_resources(
94
+ mock_iceberg_catalog: MockIcebergCatalog,
95
+ mock_trino: MockTrinoResource,
96
+ ) -> dict[str, Any]:
97
+ """
98
+ Provide all mock resources in a dict.
99
+
100
+ Useful for passing to functions that need multiple resources.
101
+
102
+ Example:
103
+ >>> def test_with_resources(mock_resources):
104
+ ... iceberg = mock_resources["iceberg"]
105
+ ... trino = mock_resources["trino"]
106
+ """
107
+ return {
108
+ "iceberg": mock_iceberg_catalog,
109
+ "trino": mock_trino,
110
+ }
111
+
112
+
113
+ # --- Test Data Fixtures ---
114
+
115
+
116
+ @pytest.fixture
117
+ def sample_partition_date() -> str:
118
+ """
119
+ Provide a standard partition date for tests.
120
+
121
+ Returns ISO format date string (e.g., "2024-01-15").
122
+
123
+ Example:
124
+ >>> def test_asset(sample_partition_date):
125
+ ... assert sample_partition_date == "2024-01-15"
126
+ """
127
+ return "2024-01-15"
128
+
129
+
130
+ @pytest.fixture
131
+ def sample_partition_range() -> tuple[str, str]:
132
+ """
133
+ Provide a range of partition dates.
134
+
135
+ Returns tuple of (start_date, end_date) in ISO format.
136
+
137
+ Example:
138
+ >>> def test_backfill(sample_partition_range):
139
+ ... start, end = sample_partition_range
140
+ ... # start = "2024-01-01"
141
+ ... # end = "2024-01-31"
142
+ """
143
+ start = "2024-01-01"
144
+ end = "2024-01-31"
145
+ return (start, end)
146
+
147
+
148
+ @pytest.fixture
149
+ def sample_dlt_data() -> list[dict[str, Any]]:
150
+ """
151
+ Provide sample DLT source data.
152
+
153
+ Returns list of test records.
154
+
155
+ Example:
156
+ >>> def test_ingestion(sample_dlt_data):
157
+ ... source = mock_dlt_source(sample_dlt_data)
158
+ """
159
+ return [
160
+ {
161
+ "id": 1,
162
+ "name": "Alice",
163
+ "email": "alice@example.com",
164
+ "created_at": "2024-01-15T10:00:00Z",
165
+ },
166
+ {
167
+ "id": 2,
168
+ "name": "Bob",
169
+ "email": "bob@example.com",
170
+ "created_at": "2024-01-15T11:00:00Z",
171
+ },
172
+ {
173
+ "id": 3,
174
+ "name": "Charlie",
175
+ "email": "charlie@example.com",
176
+ "created_at": "2024-01-15T12:00:00Z",
177
+ },
178
+ ]
179
+
180
+
181
+ @pytest.fixture
182
+ def sample_dataframe() -> pd.DataFrame:
183
+ """
184
+ Provide a sample DataFrame for testing.
185
+
186
+ Example:
187
+ >>> def test_transform(sample_dataframe):
188
+ ... assert len(sample_dataframe) == 3
189
+ """
190
+ return pd.DataFrame(
191
+ {
192
+ "id": [1, 2, 3],
193
+ "name": ["Alice", "Bob", "Charlie"],
194
+ "value": [100.5, 200.75, 150.25],
195
+ "date": pd.date_range("2024-01-01", periods=3),
196
+ }
197
+ )
198
+
199
+
200
+ @pytest.fixture
201
+ def mock_dlt_source_fixture(sample_dlt_data: list[dict]) -> MockDLTResource:
202
+ """
203
+ Provide a mock DLT source with sample data.
204
+
205
+ Example:
206
+ >>> def test_with_source(mock_dlt_source_fixture):
207
+ ... for record in mock_dlt_source_fixture:
208
+ ... # Process record
209
+ """
210
+ return mock_dlt_source(sample_dlt_data, resource_name="test_data")
211
+
212
+
213
+ # --- File System Fixtures ---
214
+
215
+
216
+ @pytest.fixture
217
+ def temp_staging_dir() -> Iterator[Path]:
218
+ """
219
+ Provide a temporary directory for test files.
220
+
221
+ Auto-cleaned up after test.
222
+
223
+ Example:
224
+ >>> def test_with_temp_dir(temp_staging_dir):
225
+ ... parquet_file = temp_staging_dir / "data.parquet"
226
+ ... df.to_parquet(parquet_file)
227
+ """
228
+ with tempfile.TemporaryDirectory() as tmpdir:
229
+ yield Path(tmpdir)
230
+
231
+
232
+ @pytest.fixture
233
+ def test_data_dir() -> Path:
234
+ """
235
+ Provide path to test data directory.
236
+
237
+ Looks for `tests/fixtures/data/` relative to project root.
238
+
239
+ Example:
240
+ >>> def test_with_data_dir(test_data_dir):
241
+ ... data_file = test_data_dir / "users.json"
242
+ """
243
+ # Find project root by looking for pyproject.toml
244
+ current = Path(__file__).parent
245
+ while current != current.parent:
246
+ if (current / "pyproject.toml").exists():
247
+ return current / "tests" / "fixtures" / "data"
248
+ current = current.parent
249
+
250
+ # Fallback to temp dir if not found
251
+ return Path(tempfile.gettempdir()) / "test_data"
252
+
253
+
254
+ # --- Composite Fixtures ---
255
+
256
+
257
+ @pytest.fixture
258
+ def setup_test_catalog(
259
+ mock_iceberg_catalog: MockIcebergCatalog,
260
+ sample_dataframe: pd.DataFrame,
261
+ ) -> MockIcebergCatalog:
262
+ """
263
+ Provide a pre-populated test catalog.
264
+
265
+ Creates sample tables in the catalog.
266
+
267
+ Example:
268
+ >>> def test_with_setup_catalog(setup_test_catalog):
269
+ ... table = setup_test_catalog.load_table("raw.users")
270
+ """
271
+ from pyiceberg.schema import Schema
272
+ from pyiceberg.types import DoubleType, IntegerType, NestedField, StringType
273
+
274
+ # Create sample table
275
+ schema = Schema(
276
+ NestedField(field_id=1, name="id", type=IntegerType(), required=True),
277
+ NestedField(field_id=2, name="name", type=StringType(), required=True),
278
+ NestedField(field_id=3, name="value", type=DoubleType(), required=False),
279
+ )
280
+
281
+ table = mock_iceberg_catalog.create_table("raw.test_data", schema=schema)
282
+ table.append(sample_dataframe)
283
+
284
+ return mock_iceberg_catalog
285
+
286
+
287
+ @pytest.fixture
288
+ def setup_test_trino(
289
+ mock_trino: MockTrinoResource,
290
+ sample_dataframe: pd.DataFrame,
291
+ ) -> MockTrinoResource:
292
+ """
293
+ Provide a pre-populated Trino resource.
294
+
295
+ Loads sample tables into Trino.
296
+
297
+ Example:
298
+ >>> def test_with_setup_trino(setup_test_trino):
299
+ ... cursor = setup_test_trino.cursor()
300
+ ... cursor.execute("SELECT * FROM test.sample_data")
301
+ """
302
+ mock_trino.load_table("test.sample_data", sample_dataframe)
303
+ return mock_trino
304
+
305
+
306
+ # --- Data Loading Fixtures ---
307
+
308
+
309
+ @pytest.fixture
310
+ def load_json_fixture(test_data_dir: Path) -> Callable[[str], Any]:
311
+ """
312
+ Provide helper to load JSON fixture files.
313
+
314
+ Example:
315
+ >>> def test_with_json(load_json_fixture):
316
+ ... data = load_json_fixture("users.json")
317
+ """
318
+
319
+ def _load_json(filename: str) -> Any:
320
+ filepath = test_data_dir / filename
321
+ if not filepath.exists():
322
+ raise FileNotFoundError(f"Fixture not found: {filepath}")
323
+
324
+ with open(filepath) as f:
325
+ return json.load(f)
326
+
327
+ return _load_json
328
+
329
+
330
+ @pytest.fixture
331
+ def load_csv_fixture(test_data_dir: Path) -> Callable[[str], pd.DataFrame]:
332
+ """
333
+ Provide helper to load CSV fixture files.
334
+
335
+ Example:
336
+ >>> def test_with_csv(load_csv_fixture):
337
+ ... df = load_csv_fixture("users.csv")
338
+ """
339
+
340
+ def _load_csv(filename: str) -> pd.DataFrame:
341
+ filepath = test_data_dir / filename
342
+ if not filepath.exists():
343
+ raise FileNotFoundError(f"Fixture not found: {filepath}")
344
+
345
+ return pd.read_csv(filepath)
346
+
347
+ return _load_csv
348
+
349
+
350
+ # --- Configuration Fixtures ---
351
+
352
+
353
+ @pytest.fixture
354
+ def test_config() -> dict[str, Any]:
355
+ """
356
+ Provide test configuration overrides.
357
+
358
+ Returns dict with test-specific config values.
359
+
360
+ Example:
361
+ >>> def test_with_config(test_config, monkeypatch):
362
+ ... monkeypatch.setenv("PHLO_ENV", "test")
363
+ """
364
+ return {
365
+ "environment": "test",
366
+ "log_level": "DEBUG",
367
+ "parallel_workers": 1,
368
+ }
369
+
370
+
371
+ # --- Session-scoped Fixtures ---
372
+
373
+
374
+ @pytest.fixture(scope="session")
375
+ def session_temp_dir() -> Iterator[Path]:
376
+ """
377
+ Provide a temporary directory for the entire test session.
378
+
379
+ Useful for shared test data.
380
+
381
+ Example:
382
+ >>> def test_with_session_dir(session_temp_dir):
383
+ ... # Shared across all tests in session
384
+ """
385
+ with tempfile.TemporaryDirectory() as tmpdir:
386
+ yield Path(tmpdir)
387
+
388
+
389
+ @pytest.fixture(scope="session")
390
+ def session_catalog() -> Iterator[MockIcebergCatalog]:
391
+ """
392
+ Provide a catalog shared across all tests in session.
393
+
394
+ Use carefully - tests should clean up their own tables.
395
+
396
+ Example:
397
+ >>> def test_with_session_catalog(session_catalog):
398
+ ... # Shared across all tests
399
+ """
400
+ catalog = MockIcebergCatalog()
401
+ yield catalog
402
+ catalog.close()
403
+
404
+
405
+ # --- Parametrization Helpers ---
406
+
407
+
408
+ def create_partition_dates(start: str, end: str, step_days: int = 1) -> list[str]:
409
+ """
410
+ Create list of partition dates for parametrized tests.
411
+
412
+ Args:
413
+ start: Start date (ISO format)
414
+ end: End date (ISO format)
415
+ step_days: Days between partitions
416
+
417
+ Returns:
418
+ List of date strings
419
+
420
+ Example:
421
+ >>> dates = create_partition_dates("2024-01-01", "2024-01-31", step_days=7)
422
+ >>> # ["2024-01-01", "2024-01-08", "2024-01-15", ...]
423
+ """
424
+ start_dt = datetime.fromisoformat(start)
425
+ end_dt = datetime.fromisoformat(end)
426
+
427
+ dates = []
428
+ current = start_dt
429
+
430
+ while current <= end_dt:
431
+ dates.append(current.isoformat()[:10])
432
+ current += timedelta(days=step_days)
433
+
434
+ return dates
435
+
436
+
437
+ @pytest.fixture
438
+ def conftest_template() -> str:
439
+ """
440
+ Get template for conftest.py file.
441
+
442
+ Provides a ready-to-use conftest.py for new test directories.
443
+
444
+ Returns:
445
+ String content for conftest.py
446
+ """
447
+ return get_conftest_template()
phlo_testing/hooks.py ADDED
@@ -0,0 +1,135 @@
1
+ """Hook testing utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Iterable
7
+
8
+ from phlo.hooks import (
9
+ HookEvent,
10
+ IngestionEvent,
11
+ LineageEvent,
12
+ PublishEvent,
13
+ QualityResultEvent,
14
+ ServiceLifecycleEvent,
15
+ TelemetryEvent,
16
+ TransformEvent,
17
+ )
18
+ from phlo.hooks.bus import HookBus
19
+ from phlo.plugins.hooks import HookFilter, HookRegistration
20
+
21
+
22
+ class MockHookBus(HookBus):
23
+ """Hook bus that skips plugin discovery for tests."""
24
+
25
+ def _ensure_discovered(self) -> None:
26
+ self._discovered = True
27
+
28
+
29
+ @dataclass
30
+ class CapturedEvents:
31
+ """Capture hook events in memory for assertions."""
32
+
33
+ events: list[HookEvent]
34
+
35
+ def handler(self, event: HookEvent) -> None:
36
+ """Append a hook event to the captured list."""
37
+
38
+ self.events.append(event)
39
+
40
+
41
+ def capture_events(
42
+ *,
43
+ bus: HookBus,
44
+ event_types: Iterable[str] | None = None,
45
+ ) -> CapturedEvents:
46
+ """Register a hook handler that collects emitted events."""
47
+
48
+ captured = CapturedEvents(events=[])
49
+ filters = HookFilter(event_types=set(event_types)) if event_types else None
50
+ bus.register(
51
+ HookRegistration(
52
+ hook_name="capture_events",
53
+ handler=captured.handler,
54
+ filters=filters,
55
+ ),
56
+ plugin_name="phlo-testing",
57
+ )
58
+ return captured
59
+
60
+
61
+ def sample_ingestion_event() -> IngestionEvent:
62
+ """Return a sample ingestion event for tests."""
63
+
64
+ return IngestionEvent(
65
+ event_type="ingestion.end",
66
+ asset_key="dlt_sample",
67
+ table_name="bronze.sample",
68
+ group_name="sample",
69
+ partition_key="2024-01-01",
70
+ status="success",
71
+ )
72
+
73
+
74
+ def sample_quality_event() -> QualityResultEvent:
75
+ """Return a sample quality event for tests."""
76
+
77
+ return QualityResultEvent(
78
+ event_type="quality.result",
79
+ asset_key="sample_asset",
80
+ check_name="null_check",
81
+ passed=True,
82
+ check_type="NullCheck",
83
+ )
84
+
85
+
86
+ def sample_transform_event() -> TransformEvent:
87
+ """Return a sample transform event for tests."""
88
+
89
+ return TransformEvent(
90
+ event_type="transform.end",
91
+ tool="dbt",
92
+ status="success",
93
+ )
94
+
95
+
96
+ def sample_publish_event() -> PublishEvent:
97
+ """Return a sample publish event for tests."""
98
+
99
+ return PublishEvent(
100
+ event_type="publish.end",
101
+ asset_key="publish_sample_marts",
102
+ target_system="postgres",
103
+ tables={"sample": "marts.sample"},
104
+ status="success",
105
+ )
106
+
107
+
108
+ def sample_lineage_event() -> LineageEvent:
109
+ """Return a sample lineage event for tests."""
110
+
111
+ return LineageEvent(
112
+ event_type="lineage.edges",
113
+ edges=[("raw.sample", "marts.sample")],
114
+ )
115
+
116
+
117
+ def sample_telemetry_event() -> TelemetryEvent:
118
+ """Return a sample telemetry event for tests."""
119
+
120
+ return TelemetryEvent(
121
+ event_type="telemetry.metric",
122
+ name="sample_metric",
123
+ value=1,
124
+ )
125
+
126
+
127
+ def sample_service_event() -> ServiceLifecycleEvent:
128
+ """Return a sample service lifecycle event for tests."""
129
+
130
+ return ServiceLifecycleEvent(
131
+ event_type="service.post_start",
132
+ service_name="postgres",
133
+ phase="post_start",
134
+ status="success",
135
+ )