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,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"
|
phlo_testing/mock_dlt.py
ADDED
|
@@ -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))
|