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,366 @@
1
+ """
2
+ Local test mode for running tests without Docker.
3
+
4
+ Enables `phlo test --local` by automatically swapping production resources
5
+ with mock implementations backed by DuckDB.
6
+
7
+ Example:
8
+ >>> os.environ["PHLO_TEST_LOCAL"] = "1"
9
+ >>> # Assets automatically use mocks
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import tempfile
17
+ from contextlib import contextmanager
18
+ from pathlib import Path
19
+ from typing import Any, Iterator, Optional
20
+
21
+ from phlo_testing.mock_dlt import MockDLTResource, mock_dlt_source
22
+ from phlo_testing.mock_iceberg import MockIcebergCatalog
23
+ from phlo_testing.mock_trino import MockTrinoResource
24
+
25
+
26
+ class LocalTestMode:
27
+ """
28
+ Context manager to enable local test mode.
29
+
30
+ Replaces production resources with mocks for fast local testing.
31
+
32
+ Example:
33
+ >>> with LocalTestMode() as mode:
34
+ ... # All resources are mocked
35
+ ... iceberg = mode.iceberg
36
+ ... trino = mode.trino
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ fixture_dir: Optional[Path] = None,
42
+ use_recorded_fixtures: bool = False,
43
+ ) -> None:
44
+ """
45
+ Initialize local test mode.
46
+
47
+ Args:
48
+ fixture_dir: Directory for fixture recording/playback
49
+ use_recorded_fixtures: Whether to use pre-recorded fixtures
50
+ """
51
+ self.fixture_dir = fixture_dir or Path(tempfile.gettempdir()) / "phlo_test_fixtures"
52
+ self.fixture_dir.mkdir(exist_ok=True)
53
+
54
+ self.use_recorded_fixtures = use_recorded_fixtures
55
+ self._original_env: dict[str, Any] = {}
56
+ self._fixtures: dict[str, Any] = {}
57
+
58
+ # Initialize mock resources
59
+ self.iceberg = MockIcebergCatalog()
60
+ self.trino = MockTrinoResource()
61
+
62
+ def __enter__(self) -> LocalTestMode:
63
+ """Enter local test mode."""
64
+ # Save original environment
65
+ self._original_env = os.environ.copy()
66
+
67
+ # Set local test mode flag
68
+ os.environ["PHLO_TEST_LOCAL"] = "1"
69
+ os.environ["PHLO_LOG_LEVEL"] = "DEBUG"
70
+
71
+ # Load recorded fixtures if available
72
+ if self.use_recorded_fixtures:
73
+ self._load_fixtures()
74
+
75
+ return self
76
+
77
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
78
+ """Exit local test mode."""
79
+ # Restore original environment
80
+ os.environ.clear()
81
+ os.environ.update(self._original_env)
82
+
83
+ # Clean up resources
84
+ self.iceberg.close()
85
+ self.trino.close()
86
+
87
+ def record_fixture(self, name: str, data: Any) -> None:
88
+ """
89
+ Record a fixture for later playback.
90
+
91
+ Args:
92
+ name: Fixture name
93
+ data: Data to record
94
+ """
95
+ fixture_file = self.fixture_dir / f"{name}.json"
96
+
97
+ # Convert to JSON-serializable format
98
+ if hasattr(data, "to_dict"):
99
+ data = data.to_dict()
100
+ elif hasattr(data, "to_json"):
101
+ data = data.to_json()
102
+
103
+ with open(fixture_file, "w") as f:
104
+ json.dump(data, f, indent=2, default=str)
105
+
106
+ def load_fixture(self, name: str) -> Any:
107
+ """
108
+ Load a recorded fixture.
109
+
110
+ Args:
111
+ name: Fixture name
112
+
113
+ Returns:
114
+ Fixture data
115
+
116
+ Raises:
117
+ FileNotFoundError: If fixture doesn't exist
118
+ """
119
+ fixture_file = self.fixture_dir / f"{name}.json"
120
+
121
+ if not fixture_file.exists():
122
+ raise FileNotFoundError(f"Fixture not found: {fixture_file}")
123
+
124
+ with open(fixture_file) as f:
125
+ return json.load(f)
126
+
127
+ def _load_fixtures(self) -> None:
128
+ """Load all recorded fixtures."""
129
+ if not self.fixture_dir.exists():
130
+ return
131
+
132
+ for fixture_file in self.fixture_dir.glob("*.json"):
133
+ name = fixture_file.stem
134
+ with open(fixture_file) as f:
135
+ self._fixtures[name] = json.load(f)
136
+
137
+ def get_resource(self, name: str) -> Any:
138
+ """
139
+ Get a mock resource.
140
+
141
+ Args:
142
+ name: Resource name (iceberg, trino)
143
+
144
+ Returns:
145
+ Mock resource
146
+
147
+ Raises:
148
+ ValueError: If resource doesn't exist
149
+ """
150
+ resources = {
151
+ "iceberg": self.iceberg,
152
+ "trino": self.trino,
153
+ }
154
+
155
+ if name not in resources:
156
+ raise ValueError(f"Unknown resource: {name}")
157
+
158
+ return resources[name]
159
+
160
+
161
+ @contextmanager
162
+ def local_test_mode(
163
+ fixture_dir: Optional[Path] = None,
164
+ ) -> Iterator[LocalTestMode]:
165
+ """
166
+ Context manager for local test mode.
167
+
168
+ Args:
169
+ fixture_dir: Directory for fixtures
170
+
171
+ Yields:
172
+ LocalTestMode instance
173
+
174
+ Example:
175
+ >>> with local_test_mode() as mode:
176
+ ... # Test with mocked resources
177
+ ... table = mode.iceberg.create_table(...)
178
+ """
179
+ mode = LocalTestMode(fixture_dir=fixture_dir)
180
+
181
+ with mode:
182
+ yield mode
183
+
184
+
185
+ class LocalTestDecorator:
186
+ """
187
+ Decorator to mark tests that should use local mode.
188
+
189
+ Example:
190
+ >>> @local_test
191
+ ... def test_my_asset():
192
+ ... # Runs with mocked resources
193
+ ... pass
194
+ """
195
+
196
+ def __call__(self, func: Any) -> Any:
197
+ """Apply decorator."""
198
+
199
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
200
+ with local_test_mode():
201
+ return func(*args, **kwargs)
202
+
203
+ return wrapper
204
+
205
+
206
+ # Singleton decorator instance
207
+ local_test = LocalTestDecorator()
208
+
209
+
210
+ def is_local_test_mode() -> bool:
211
+ """
212
+ Check if running in local test mode.
213
+
214
+ Returns:
215
+ True if PHLO_TEST_LOCAL environment variable is set
216
+ """
217
+ return os.environ.get("PHLO_TEST_LOCAL", "").lower() in ("1", "true")
218
+
219
+
220
+ class FixtureRecorder:
221
+ """
222
+ Helper to record fixtures from real services.
223
+
224
+ Captures responses from production services and saves them for
225
+ replay in local mode.
226
+
227
+ Example:
228
+ >>> recorder = FixtureRecorder(fixture_dir)
229
+ >>> data = recorder.record_dlt_fetch("users", fetch_users_api)
230
+ """
231
+
232
+ def __init__(self, fixture_dir: Optional[Path] = None) -> None:
233
+ """
234
+ Initialize recorder.
235
+
236
+ Args:
237
+ fixture_dir: Directory to store fixtures
238
+ """
239
+ self.fixture_dir = fixture_dir or Path(tempfile.gettempdir()) / "phlo_fixtures"
240
+ self.fixture_dir.mkdir(exist_ok=True)
241
+
242
+ def record_dlt_source(
243
+ self,
244
+ name: str,
245
+ source_func: Any,
246
+ *args: Any,
247
+ **kwargs: Any,
248
+ ) -> list[dict[str, Any]]:
249
+ """
250
+ Record data from a DLT source.
251
+
252
+ Args:
253
+ name: Fixture name
254
+ source_func: Function that returns DLT source
255
+ *args: Args to pass to source_func
256
+ **kwargs: Kwargs to pass to source_func
257
+
258
+ Returns:
259
+ List of records from source
260
+ """
261
+ # Call source function to get data
262
+ source = source_func(*args, **kwargs)
263
+ data = list(source)
264
+
265
+ # Save to fixture
266
+ fixture_file = self.fixture_dir / f"{name}_dlt.json"
267
+
268
+ with open(fixture_file, "w") as f:
269
+ json.dump(data, f, indent=2, default=str)
270
+
271
+ return data
272
+
273
+ def record_sql_query(
274
+ self,
275
+ name: str,
276
+ query_func: Any,
277
+ *args: Any,
278
+ **kwargs: Any,
279
+ ) -> list[dict[str, Any]]:
280
+ """
281
+ Record results from a SQL query.
282
+
283
+ Args:
284
+ name: Fixture name
285
+ query_func: Function that executes query
286
+ *args: Args to pass to query_func
287
+ **kwargs: Kwargs to pass to query_func
288
+
289
+ Returns:
290
+ Query results
291
+ """
292
+ # Execute query
293
+ results = query_func(*args, **kwargs)
294
+
295
+ # Convert to list of dicts if needed
296
+ if hasattr(results, "to_dict"):
297
+ data = results.to_dict("records")
298
+ else:
299
+ data = list(results)
300
+
301
+ # Save to fixture
302
+ fixture_file = self.fixture_dir / f"{name}_sql.json"
303
+
304
+ with open(fixture_file, "w") as f:
305
+ json.dump(data, f, indent=2, default=str)
306
+
307
+ return data
308
+
309
+ def load_dlt_fixture(self, name: str) -> MockDLTResource:
310
+ """
311
+ Load a recorded DLT fixture.
312
+
313
+ Args:
314
+ name: Fixture name
315
+
316
+ Returns:
317
+ MockDLTResource with recorded data
318
+ """
319
+ fixture_file = self.fixture_dir / f"{name}_dlt.json"
320
+
321
+ if not fixture_file.exists():
322
+ raise FileNotFoundError(f"Fixture not found: {fixture_file}")
323
+
324
+ with open(fixture_file) as f:
325
+ data = json.load(f)
326
+
327
+ return mock_dlt_source(data, resource_name=name)
328
+
329
+ def get_fixture_dir(self) -> Path:
330
+ """Get fixture directory path."""
331
+ return self.fixture_dir
332
+
333
+ def list_fixtures(self) -> list[str]:
334
+ """List all recorded fixtures."""
335
+ if not self.fixture_dir.exists():
336
+ return []
337
+
338
+ return sorted(f.stem for f in self.fixture_dir.glob("*.*"))
339
+
340
+
341
+ # Environment variable helpers
342
+
343
+
344
+ def enable_local_test_mode() -> None:
345
+ """Enable local test mode for current process."""
346
+ os.environ["PHLO_TEST_LOCAL"] = "1"
347
+
348
+
349
+ def disable_local_test_mode() -> None:
350
+ """Disable local test mode for current process."""
351
+ os.environ.pop("PHLO_TEST_LOCAL", None)
352
+
353
+
354
+ def set_fixture_dir(path: Path) -> None:
355
+ """Set fixture directory path."""
356
+ os.environ["PHLO_FIXTURE_DIR"] = str(path)
357
+
358
+
359
+ def get_fixture_dir() -> Path:
360
+ """Get fixture directory path."""
361
+ env_path = os.environ.get("PHLO_FIXTURE_DIR")
362
+
363
+ if env_path:
364
+ return Path(env_path)
365
+
366
+ return Path(tempfile.gettempdir()) / "phlo_fixtures"
@@ -0,0 +1,287 @@
1
+ """
2
+ Mock DLT sources for testing without API calls.
3
+
4
+ Provides mock implementations of DLT sources that return predefined data,
5
+ enabling tests to run without external dependencies or network calls.
6
+
7
+ Example:
8
+ >>> data = [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
9
+ >>> source = mock_dlt_source(data, resource_name="users")
10
+ >>> for record in source:
11
+ ... print(record)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Iterator
18
+
19
+ import pandas as pd
20
+
21
+
22
+ @dataclass
23
+ class MockDLTResource:
24
+ """
25
+ Mock DLT resource that yields predefined data.
26
+
27
+ Mimics the interface of a DLT resource but returns fixed data
28
+ instead of fetching from an API.
29
+ """
30
+
31
+ name: str
32
+ data: list[dict[str, Any]]
33
+ _index: int = 0
34
+
35
+ def __iter__(self) -> Iterator[dict[str, Any]]:
36
+ """Iterate over resource data."""
37
+ self._index = 0
38
+ return self
39
+
40
+ def __next__(self) -> dict[str, Any]:
41
+ """Get next record."""
42
+ if self._index >= len(self.data):
43
+ raise StopIteration
44
+ record = self.data[self._index]
45
+ self._index += 1
46
+ return record
47
+
48
+ @property
49
+ def resources(self) -> dict[str, Any]:
50
+ """Get resource metadata."""
51
+ return {
52
+ self.name: {
53
+ "name": self.name,
54
+ "type": "resource",
55
+ "columns": self._infer_schema(),
56
+ }
57
+ }
58
+
59
+ def _infer_schema(self) -> dict[str, str]:
60
+ """Infer schema from data."""
61
+ if not self.data:
62
+ return {}
63
+
64
+ first_record = self.data[0]
65
+ schema = {}
66
+
67
+ for key, value in first_record.items():
68
+ if isinstance(value, int):
69
+ schema[key] = "bigint"
70
+ elif isinstance(value, float):
71
+ schema[key] = "double"
72
+ elif isinstance(value, bool):
73
+ schema[key] = "boolean"
74
+ elif isinstance(value, str):
75
+ schema[key] = "text"
76
+ elif isinstance(value, pd.Timestamp) or hasattr(value, "isoformat"):
77
+ schema[key] = "timestamp"
78
+ else:
79
+ schema[key] = "text"
80
+
81
+ return schema
82
+
83
+
84
+ @dataclass
85
+ class MockDLTSource:
86
+ """
87
+ Mock DLT source with multiple resources.
88
+
89
+ Mimics the interface of a DLT source but returns fixed data
90
+ instead of fetching from an API. Supports multiple resources.
91
+ """
92
+
93
+ resources: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
94
+ _current_resource: str | None = None
95
+ _current_index: int = 0
96
+
97
+ def add_resource(self, name: str, data: list[dict[str, Any]]) -> MockDLTResource:
98
+ """
99
+ Add a resource to the source.
100
+
101
+ Args:
102
+ name: Resource name
103
+ data: List of records
104
+
105
+ Returns:
106
+ MockDLTResource instance
107
+ """
108
+ self.resources[name] = data
109
+ return MockDLTResource(name=name, data=data)
110
+
111
+ def get_resource(self, name: str) -> MockDLTResource:
112
+ """
113
+ Get a resource by name.
114
+
115
+ Args:
116
+ name: Resource name
117
+
118
+ Returns:
119
+ MockDLTResource instance
120
+
121
+ Raises:
122
+ ValueError: If resource doesn't exist
123
+ """
124
+ if name not in self.resources:
125
+ raise ValueError(f"Resource {name} not found")
126
+
127
+ return MockDLTResource(name=name, data=self.resources[name])
128
+
129
+ def __iter__(self) -> Iterator[dict[str, Any]]:
130
+ """Iterate over all resources."""
131
+ for resource_name, data in self.resources.items():
132
+ for record in data:
133
+ yield record
134
+
135
+ def for_each(self, func: Any) -> None:
136
+ """
137
+ Apply a function to each record.
138
+
139
+ Args:
140
+ func: Function to apply (for dlt compatibility)
141
+ """
142
+ for record in self:
143
+ func(record)
144
+
145
+
146
+ def mock_dlt_source(
147
+ data: list[dict[str, Any]],
148
+ resource_name: str = "default",
149
+ ) -> MockDLTResource:
150
+ """
151
+ Create a mock DLT source with a single resource.
152
+
153
+ Drop-in replacement for `dlt.resource()` that returns predefined data
154
+ without making API calls.
155
+
156
+ Args:
157
+ data: List of records to return
158
+ resource_name: Name of the resource
159
+
160
+ Returns:
161
+ MockDLTResource instance
162
+
163
+ Example:
164
+ >>> data = [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
165
+ >>> source = mock_dlt_source(data, resource_name="users")
166
+ >>> for record in source:
167
+ ... print(record)
168
+ {"id": 1, "name": "Alice"}
169
+ {"id": 2, "name": "Bob"}
170
+ """
171
+ return MockDLTResource(name=resource_name, data=data)
172
+
173
+
174
+ def mock_dlt_source_multi(
175
+ resources: dict[str, list[dict[str, Any]]],
176
+ ) -> MockDLTSource:
177
+ """
178
+ Create a mock DLT source with multiple resources.
179
+
180
+ Args:
181
+ resources: Dict mapping resource names to data lists
182
+
183
+ Returns:
184
+ MockDLTSource instance
185
+
186
+ Example:
187
+ >>> source = mock_dlt_source_multi({
188
+ ... "users": [{"id": 1, "name": "Alice"}],
189
+ ... "orders": [{"order_id": 1, "user_id": 1}],
190
+ ... })
191
+ >>> for record in source:
192
+ ... print(record)
193
+ """
194
+ mock_source = MockDLTSource()
195
+ for name, data in resources.items():
196
+ mock_source.add_resource(name, data)
197
+ return mock_source
198
+
199
+
200
+ class MockDLTError(Exception):
201
+ """Exception for simulating DLT errors."""
202
+
203
+ pass
204
+
205
+
206
+ def mock_dlt_source_with_error(
207
+ data: list[dict[str, Any]],
208
+ resource_name: str = "default",
209
+ error_after: int | None = None,
210
+ error_message: str = "Mock DLT error",
211
+ ) -> MockDLTResource:
212
+ """
213
+ Create a mock DLT source that raises an error after N records.
214
+
215
+ Useful for testing error handling in ingestion pipelines.
216
+
217
+ Args:
218
+ data: List of records to return before error
219
+ resource_name: Name of the resource
220
+ error_after: Number of records before error (None = no error)
221
+ error_message: Error message to raise
222
+
223
+ Returns:
224
+ MockDLTResource instance
225
+
226
+ Example:
227
+ >>> source = mock_dlt_source_with_error(
228
+ ... [{"id": 1}, {"id": 2}],
229
+ ... error_after=1,
230
+ ... error_message="API rate limit exceeded"
231
+ ... )
232
+ >>> records = list(source) # Raises after 1 record
233
+ """
234
+
235
+ class ErrorRaisingResource(MockDLTResource):
236
+ """Resource that raises an error after N records."""
237
+
238
+ def __next__(self) -> dict[str, Any]:
239
+ if error_after is not None and self._index >= error_after:
240
+ raise MockDLTError(error_message)
241
+ return super().__next__()
242
+
243
+ return ErrorRaisingResource(name=resource_name, data=data)
244
+
245
+
246
+ def mock_dlt_pipeline(
247
+ data: dict[str, list[dict[str, Any]]],
248
+ ) -> MockDLTSource:
249
+ """
250
+ Create a mock DLT pipeline with multiple resources.
251
+
252
+ Convenience function for creating a complete mock pipeline.
253
+
254
+ Args:
255
+ data: Dict mapping table names to records
256
+
257
+ Returns:
258
+ MockDLTSource instance
259
+
260
+ Example:
261
+ >>> pipeline = mock_dlt_pipeline({
262
+ ... "users": [{"id": 1, "name": "Alice"}],
263
+ ... "orders": [{"order_id": 1, "user_id": 1}],
264
+ ... })
265
+ """
266
+ return mock_dlt_source_multi(data)
267
+
268
+
269
+ def create_mock_dlt_dataframe(
270
+ resource: MockDLTResource,
271
+ ) -> pd.DataFrame:
272
+ """
273
+ Convert mock DLT resource to pandas DataFrame.
274
+
275
+ Helper for testing data transformations.
276
+
277
+ Args:
278
+ resource: MockDLTResource instance
279
+
280
+ Returns:
281
+ DataFrame with resource data
282
+
283
+ Example:
284
+ >>> source = mock_dlt_source([{"id": 1}, {"id": 2}])
285
+ >>> df = create_mock_dlt_dataframe(source)
286
+ """
287
+ return pd.DataFrame(list(resource))