metaxy 0.0.1.dev3__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.
Files changed (111) hide show
  1. metaxy/__init__.py +170 -0
  2. metaxy/_packaging.py +96 -0
  3. metaxy/_testing/__init__.py +55 -0
  4. metaxy/_testing/config.py +43 -0
  5. metaxy/_testing/metaxy_project.py +780 -0
  6. metaxy/_testing/models.py +111 -0
  7. metaxy/_testing/parametric/__init__.py +13 -0
  8. metaxy/_testing/parametric/metadata.py +664 -0
  9. metaxy/_testing/pytest_helpers.py +74 -0
  10. metaxy/_testing/runbook.py +533 -0
  11. metaxy/_utils.py +35 -0
  12. metaxy/_version.py +1 -0
  13. metaxy/cli/app.py +97 -0
  14. metaxy/cli/console.py +13 -0
  15. metaxy/cli/context.py +167 -0
  16. metaxy/cli/graph.py +610 -0
  17. metaxy/cli/graph_diff.py +290 -0
  18. metaxy/cli/list.py +46 -0
  19. metaxy/cli/metadata.py +317 -0
  20. metaxy/cli/migrations.py +999 -0
  21. metaxy/cli/utils.py +268 -0
  22. metaxy/config.py +680 -0
  23. metaxy/entrypoints.py +296 -0
  24. metaxy/ext/__init__.py +1 -0
  25. metaxy/ext/dagster/__init__.py +54 -0
  26. metaxy/ext/dagster/constants.py +10 -0
  27. metaxy/ext/dagster/dagster_type.py +156 -0
  28. metaxy/ext/dagster/io_manager.py +200 -0
  29. metaxy/ext/dagster/metaxify.py +512 -0
  30. metaxy/ext/dagster/observable.py +115 -0
  31. metaxy/ext/dagster/resources.py +27 -0
  32. metaxy/ext/dagster/selection.py +73 -0
  33. metaxy/ext/dagster/table_metadata.py +417 -0
  34. metaxy/ext/dagster/utils.py +462 -0
  35. metaxy/ext/sqlalchemy/__init__.py +23 -0
  36. metaxy/ext/sqlalchemy/config.py +29 -0
  37. metaxy/ext/sqlalchemy/plugin.py +353 -0
  38. metaxy/ext/sqlmodel/__init__.py +13 -0
  39. metaxy/ext/sqlmodel/config.py +29 -0
  40. metaxy/ext/sqlmodel/plugin.py +499 -0
  41. metaxy/graph/__init__.py +29 -0
  42. metaxy/graph/describe.py +325 -0
  43. metaxy/graph/diff/__init__.py +21 -0
  44. metaxy/graph/diff/diff_models.py +446 -0
  45. metaxy/graph/diff/differ.py +769 -0
  46. metaxy/graph/diff/models.py +443 -0
  47. metaxy/graph/diff/rendering/__init__.py +18 -0
  48. metaxy/graph/diff/rendering/base.py +323 -0
  49. metaxy/graph/diff/rendering/cards.py +188 -0
  50. metaxy/graph/diff/rendering/formatter.py +805 -0
  51. metaxy/graph/diff/rendering/graphviz.py +246 -0
  52. metaxy/graph/diff/rendering/mermaid.py +326 -0
  53. metaxy/graph/diff/rendering/rich.py +169 -0
  54. metaxy/graph/diff/rendering/theme.py +48 -0
  55. metaxy/graph/diff/traversal.py +247 -0
  56. metaxy/graph/status.py +329 -0
  57. metaxy/graph/utils.py +58 -0
  58. metaxy/metadata_store/__init__.py +32 -0
  59. metaxy/metadata_store/_ducklake_support.py +419 -0
  60. metaxy/metadata_store/base.py +1792 -0
  61. metaxy/metadata_store/bigquery.py +354 -0
  62. metaxy/metadata_store/clickhouse.py +184 -0
  63. metaxy/metadata_store/delta.py +371 -0
  64. metaxy/metadata_store/duckdb.py +446 -0
  65. metaxy/metadata_store/exceptions.py +61 -0
  66. metaxy/metadata_store/ibis.py +542 -0
  67. metaxy/metadata_store/lancedb.py +391 -0
  68. metaxy/metadata_store/memory.py +292 -0
  69. metaxy/metadata_store/system/__init__.py +57 -0
  70. metaxy/metadata_store/system/events.py +264 -0
  71. metaxy/metadata_store/system/keys.py +9 -0
  72. metaxy/metadata_store/system/models.py +129 -0
  73. metaxy/metadata_store/system/storage.py +957 -0
  74. metaxy/metadata_store/types.py +10 -0
  75. metaxy/metadata_store/utils.py +104 -0
  76. metaxy/metadata_store/warnings.py +36 -0
  77. metaxy/migrations/__init__.py +32 -0
  78. metaxy/migrations/detector.py +291 -0
  79. metaxy/migrations/executor.py +516 -0
  80. metaxy/migrations/generator.py +319 -0
  81. metaxy/migrations/loader.py +231 -0
  82. metaxy/migrations/models.py +528 -0
  83. metaxy/migrations/ops.py +447 -0
  84. metaxy/models/__init__.py +0 -0
  85. metaxy/models/bases.py +12 -0
  86. metaxy/models/constants.py +139 -0
  87. metaxy/models/feature.py +1335 -0
  88. metaxy/models/feature_spec.py +338 -0
  89. metaxy/models/field.py +263 -0
  90. metaxy/models/fields_mapping.py +307 -0
  91. metaxy/models/filter_expression.py +297 -0
  92. metaxy/models/lineage.py +285 -0
  93. metaxy/models/plan.py +232 -0
  94. metaxy/models/types.py +475 -0
  95. metaxy/py.typed +0 -0
  96. metaxy/utils/__init__.py +1 -0
  97. metaxy/utils/constants.py +2 -0
  98. metaxy/utils/exceptions.py +23 -0
  99. metaxy/utils/hashing.py +230 -0
  100. metaxy/versioning/__init__.py +31 -0
  101. metaxy/versioning/engine.py +656 -0
  102. metaxy/versioning/feature_dep_transformer.py +151 -0
  103. metaxy/versioning/ibis.py +249 -0
  104. metaxy/versioning/lineage_handler.py +205 -0
  105. metaxy/versioning/polars.py +189 -0
  106. metaxy/versioning/renamed_df.py +35 -0
  107. metaxy/versioning/types.py +63 -0
  108. metaxy-0.0.1.dev3.dist-info/METADATA +96 -0
  109. metaxy-0.0.1.dev3.dist-info/RECORD +111 -0
  110. metaxy-0.0.1.dev3.dist-info/WHEEL +4 -0
  111. metaxy-0.0.1.dev3.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import wraps
4
+ from typing import TYPE_CHECKING
5
+
6
+ import narwhals as nw
7
+ import polars as pl
8
+
9
+ from metaxy.utils.hashing import get_hash_truncation_length
10
+
11
+ if TYPE_CHECKING:
12
+ from metaxy.models.feature import BaseFeature
13
+ from metaxy.versioning.types import HashAlgorithm
14
+
15
+
16
+ def add_metaxy_provenance_column(
17
+ df: pl.DataFrame,
18
+ feature: type[BaseFeature],
19
+ hash_algorithm: HashAlgorithm | None = None,
20
+ ) -> pl.DataFrame:
21
+ """Add metaxy_provenance column to a DataFrame based on metaxy_provenance_by_field.
22
+
23
+
24
+ Args:
25
+ df: Polars DataFrame with metaxy_provenance_by_field column
26
+ feature: Feature class to get the feature plan from
27
+ hash_algorithm: Hash algorithm to use. If None, uses XXHASH64.
28
+
29
+ Returns:
30
+ Polars DataFrame with metaxy_provenance column added
31
+ """
32
+ from metaxy.versioning.polars import PolarsVersioningEngine
33
+ from metaxy.versioning.types import HashAlgorithm as HashAlgo
34
+
35
+ if hash_algorithm is None:
36
+ hash_algorithm = HashAlgo.XXHASH64
37
+
38
+ # Get the feature plan from the active graph
39
+ plan = feature.graph.get_feature_plan(feature.spec().key)
40
+
41
+ # Create engine
42
+ engine = PolarsVersioningEngine(plan=plan)
43
+
44
+ # Convert to Narwhals, add provenance column, convert back
45
+ df_nw = nw.from_native(df.lazy())
46
+ df_nw = engine.hash_struct_version_column(df_nw, hash_algorithm=hash_algorithm)
47
+ result_df = df_nw.collect().to_native()
48
+
49
+ # Apply hash truncation if specified
50
+ result_df = result_df.with_columns(
51
+ pl.col("metaxy_provenance").str.slice(0, get_hash_truncation_length())
52
+ )
53
+
54
+ return result_df
55
+
56
+
57
+ def skip_exception(exception: type[Exception], reason: str):
58
+ # Func below is the real decorator and will receive the test function as param
59
+ def decorator_func(f):
60
+ @wraps(f)
61
+ def wrapper(*args, **kwargs):
62
+ try:
63
+ # Try to run the test
64
+ return f(*args, **kwargs)
65
+ except exception:
66
+ import pytest
67
+
68
+ # If exception of given type happens
69
+ # just swallow it and raise pytest.Skip with given reason
70
+ pytest.skip(f"skipped {exception.__name__}: {reason}")
71
+
72
+ return wrapper
73
+
74
+ return decorator_func
@@ -0,0 +1,533 @@
1
+ """Runbook system for testing and documenting Metaxy examples.
2
+
3
+ This module provides:
4
+ - Pydantic models for `.example.yaml` runbook files (Runbook, Scenario, Step types)
5
+ - RunbookRunner for executing runbooks with automatic patch management
6
+ - Context manager for running examples in tests
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ import subprocess
14
+ from abc import ABC, abstractmethod
15
+ from collections.abc import Iterator
16
+ from contextlib import contextmanager
17
+ from enum import Enum
18
+ from pathlib import Path
19
+ from typing import Annotated, Literal
20
+
21
+ from pydantic import BaseModel, ConfigDict
22
+ from pydantic import Field as PydanticField
23
+
24
+ # ============================================================================
25
+ # Runbook Models
26
+ # ============================================================================
27
+
28
+
29
+ class StepType(str, Enum):
30
+ """Type of step in a runbook scenario."""
31
+
32
+ RUN_COMMAND = "run_command"
33
+ APPLY_PATCH = "apply_patch"
34
+ ASSERT_OUTPUT = "assert_output"
35
+
36
+
37
+ class BaseStep(BaseModel, ABC): # pyright: ignore[reportUnsafeMultipleInheritance]
38
+ """Base class for runbook steps.
39
+
40
+ Each step represents an action in testing or documenting an example.
41
+ """
42
+
43
+ model_config = ConfigDict(frozen=True)
44
+
45
+ description: str | None = None
46
+ """Optional human-readable description of this step."""
47
+
48
+ @abstractmethod
49
+ def step_type(self) -> StepType:
50
+ """Return the step type for this step."""
51
+ raise NotImplementedError
52
+
53
+
54
+ class RunCommandStep(BaseStep):
55
+ """Run a command or Python module.
56
+
57
+ This step executes a shell command or Python module and optionally captures
58
+ the output for later assertions.
59
+
60
+ Examples:
61
+ >>> # Run a Python module
62
+ >>> RunCommandStep(
63
+ ... type="run_command",
64
+ ... command="python -m example_recompute.setup_data",
65
+ ... )
66
+
67
+ >>> # Run metaxy CLI
68
+ >>> RunCommandStep(
69
+ ... type="run_command",
70
+ ... command="metaxy list features",
71
+ ... capture_output=True,
72
+ ... )
73
+ """
74
+
75
+ type: Literal[StepType.RUN_COMMAND] = StepType.RUN_COMMAND
76
+ command: str
77
+ """The command to execute (e.g., 'python -m module_name' or 'metaxy list features')."""
78
+
79
+ env: dict[str, str] | None = None
80
+ """Environment variables to set for this command."""
81
+
82
+ capture_output: bool = False
83
+ """Whether to capture stdout/stderr for assertions."""
84
+
85
+ timeout: float = 30.0
86
+ """Timeout in seconds for the command."""
87
+
88
+ def step_type(self) -> StepType:
89
+ return StepType.RUN_COMMAND
90
+
91
+
92
+ class ApplyPatchStep(BaseStep):
93
+ """Apply a git patch file to modify example code.
94
+
95
+ This step applies a patch to transition between code versions, demonstrating
96
+ code evolution. Patches are applied temporarily during test execution.
97
+
98
+ The patch_path is relative to the example directory (where .example.yaml lives).
99
+
100
+ Examples:
101
+ >>> # Apply a patch to update algorithm
102
+ >>> ApplyPatchStep(
103
+ ... type="apply_patch",
104
+ ... patch_path="patches/01_update_algorithm.patch",
105
+ ... description="Update parent feature embedding algorithm to v2",
106
+ ... )
107
+ """
108
+
109
+ type: Literal[StepType.APPLY_PATCH] = StepType.APPLY_PATCH
110
+ patch_path: str
111
+ """Path to patch file relative to example directory."""
112
+
113
+ def step_type(self) -> StepType:
114
+ return StepType.APPLY_PATCH
115
+
116
+
117
+ class AssertOutputStep(BaseStep):
118
+ """Assert on the output of the previous command.
119
+
120
+ This step validates that the previous RunCommandStep produced the expected
121
+ output. Supports substring matching and regex patterns.
122
+
123
+ Examples:
124
+ >>> # Assert specific strings appear in output
125
+ >>> AssertOutputStep(
126
+ ... type="assert_output",
127
+ ... contains=["Pipeline STAGE=1", "✅ Stage 1 pipeline complete!"],
128
+ ... )
129
+
130
+ >>> # Assert returncode is 0
131
+ >>> AssertOutputStep(
132
+ ... type="assert_output",
133
+ ... returncode=0,
134
+ ... )
135
+ """
136
+
137
+ type: Literal[StepType.ASSERT_OUTPUT] = StepType.ASSERT_OUTPUT
138
+ contains: list[str] | None = None
139
+ """List of substrings that must appear in stdout."""
140
+
141
+ not_contains: list[str] | None = None
142
+ """List of substrings that must NOT appear in stdout."""
143
+
144
+ matches_regex: str | None = None
145
+ """Regex pattern that stdout must match."""
146
+
147
+ returncode: int | None = None
148
+ """Expected return code (default: 0 if not specified)."""
149
+
150
+ def step_type(self) -> StepType:
151
+ return StepType.ASSERT_OUTPUT
152
+
153
+
154
+ class Scenario(BaseModel):
155
+ """A scenario represents a sequence of steps to test an example.
156
+
157
+ Scenarios are the main unit of testing. Each scenario has a name and a list
158
+ of steps that are executed in order.
159
+
160
+ Examples:
161
+ >>> Scenario(
162
+ ... name="Initial run",
163
+ ... description="First pipeline run with initial feature definitions",
164
+ ... steps=[
165
+ ... RunCommandStep(command="python -m example.setup_data"),
166
+ ... RunCommandStep(command="python -m example.pipeline", capture_output=True),
167
+ ... AssertOutputStep(contains=["Pipeline complete!"]),
168
+ ... ],
169
+ ... )
170
+ """
171
+
172
+ model_config = ConfigDict(frozen=True)
173
+
174
+ name: str
175
+ """Name of this scenario (e.g., 'Initial run', 'Idempotent rerun')."""
176
+
177
+ description: str | None = None
178
+ """Optional human-readable description of what this scenario tests."""
179
+
180
+ steps: list[
181
+ Annotated[
182
+ RunCommandStep | ApplyPatchStep | AssertOutputStep,
183
+ PydanticField(discriminator="type"),
184
+ ]
185
+ ]
186
+ """Ordered list of steps to execute in this scenario."""
187
+
188
+
189
+ class Runbook(BaseModel):
190
+ """Top-level runbook model for an example.
191
+
192
+ A runbook defines how to test and document an example. It contains metadata
193
+ about the example and one or more scenarios that test different aspects.
194
+
195
+ The runbook file should be named `.example.yaml` and placed in the example
196
+ directory alongside metaxy.toml.
197
+
198
+ Examples:
199
+ >>> Runbook(
200
+ ... name="Recompute Example",
201
+ ... description="Demonstrates automatic recomputation",
202
+ ... package_name="example_recompute",
203
+ ... scenarios=[...],
204
+ ... )
205
+ """
206
+
207
+ model_config = ConfigDict(frozen=True)
208
+
209
+ name: str
210
+ """Human-readable name of the example."""
211
+
212
+ description: str | None = None
213
+ """Description of what this example demonstrates."""
214
+
215
+ package_name: str
216
+ """Python package name (e.g., 'example_recompute')."""
217
+
218
+ scenarios: list[Scenario]
219
+ """List of test scenarios for this example."""
220
+
221
+ @classmethod
222
+ def from_yaml_file(cls, path: Path) -> Runbook:
223
+ """Load a runbook from a YAML file.
224
+
225
+ Args:
226
+ path: Path to the .example.yaml file.
227
+
228
+ Returns:
229
+ Parsed Runbook instance.
230
+ """
231
+ import yaml
232
+
233
+ with open(path) as f:
234
+ data = yaml.safe_load(f)
235
+ return cls.model_validate(data)
236
+
237
+ def to_yaml_file(self, path: Path) -> None:
238
+ """Save this runbook to a YAML file.
239
+
240
+ Args:
241
+ path: Path where the .example.yaml file should be written.
242
+ """
243
+ import yaml
244
+
245
+ with open(path, "w") as f:
246
+ # Use model_dump with mode='json' to get JSON-serializable data
247
+ data = self.model_dump(mode="json")
248
+ yaml.safe_dump(data, f, sort_keys=False, default_flow_style=False)
249
+
250
+
251
+ # ============================================================================
252
+ # Runbook Runner
253
+ # ============================================================================
254
+
255
+
256
+ class CommandResult:
257
+ """Result of running a command."""
258
+
259
+ def __init__(self, returncode: int, stdout: str, stderr: str):
260
+ self.returncode = returncode
261
+ self.stdout = stdout
262
+ self.stderr = stderr
263
+
264
+
265
+ class RunbookRunner:
266
+ """Runner for executing example runbooks.
267
+
268
+ This class handles:
269
+ - Loading runbooks from YAML
270
+ - Setting up test environments
271
+ - Executing commands
272
+ - Applying/reverting patches
273
+ - Validating assertions
274
+ """
275
+
276
+ def __init__(
277
+ self,
278
+ runbook: Runbook,
279
+ example_dir: Path,
280
+ override_db_path: Path | None = None,
281
+ env_overrides: dict[str, str] | None = None,
282
+ ):
283
+ """Initialize the runbook runner.
284
+
285
+ Args:
286
+ runbook: The runbook to execute.
287
+ example_dir: Directory containing the example code and .example.yaml.
288
+ override_db_path: Optional path to database (for METAXY_STORES__DEV__CONFIG__DATABASE).
289
+ env_overrides: Additional environment variable overrides.
290
+ """
291
+ self.runbook = runbook
292
+ self.example_dir = example_dir
293
+ self.override_db_path = override_db_path
294
+ self.env_overrides = env_overrides or {}
295
+ self.last_result: CommandResult | None = None
296
+ self.applied_patches: list[str] = []
297
+
298
+ def get_base_env(self) -> dict[str, str]:
299
+ """Get base environment with test-specific overrides.
300
+
301
+ Returns:
302
+ Environment dict with test database and other overrides.
303
+ """
304
+ env = os.environ.copy()
305
+
306
+ # Override database path if provided
307
+ if self.override_db_path:
308
+ env["METAXY_STORES__DEV__CONFIG__DATABASE"] = str(self.override_db_path)
309
+
310
+ # Apply additional overrides
311
+ env.update(self.env_overrides)
312
+
313
+ return env
314
+
315
+ def run_command(
316
+ self,
317
+ step: RunCommandStep,
318
+ scenario_name: str,
319
+ ) -> CommandResult:
320
+ """Execute a command step.
321
+
322
+ Args:
323
+ step: The RunCommandStep to execute.
324
+ scenario_name: Name of the current scenario (for logging).
325
+
326
+ Returns:
327
+ CommandResult with returncode and output.
328
+ """
329
+ env = self.get_base_env()
330
+
331
+ # Apply step-specific environment variables
332
+ if step.env:
333
+ env.update(step.env)
334
+
335
+ # Execute the command
336
+ result = subprocess.run(
337
+ step.command,
338
+ shell=True,
339
+ capture_output=step.capture_output,
340
+ text=True,
341
+ timeout=step.timeout,
342
+ env=env,
343
+ cwd=self.example_dir,
344
+ )
345
+
346
+ # Store for assertions
347
+ self.last_result = CommandResult(
348
+ returncode=result.returncode,
349
+ stdout=result.stdout if step.capture_output else "",
350
+ stderr=result.stderr if step.capture_output else "",
351
+ )
352
+
353
+ return self.last_result
354
+
355
+ def apply_patch(self, step: ApplyPatchStep, scenario_name: str) -> None:
356
+ """Apply a patch file.
357
+
358
+ Args:
359
+ step: The ApplyPatchStep to execute.
360
+ scenario_name: Name of the current scenario (for logging).
361
+
362
+ Raises:
363
+ RuntimeError: If patch application fails.
364
+ """
365
+ patch_path_abs = self.example_dir / step.patch_path
366
+
367
+ if not patch_path_abs.exists():
368
+ raise FileNotFoundError(
369
+ f"Patch file not found: {patch_path_abs} (resolved from {step.patch_path})"
370
+ )
371
+
372
+ # Apply the patch using patch command (works without git)
373
+ # -p1: strip one level from paths (a/ and b/ prefixes)
374
+ # -i: specify input file
375
+ # --no-backup-if-mismatch: don't create .orig backup files
376
+ result = subprocess.run(
377
+ ["patch", "-p1", "-i", step.patch_path, "--no-backup-if-mismatch"],
378
+ capture_output=True,
379
+ text=True,
380
+ cwd=self.example_dir,
381
+ )
382
+
383
+ if result.returncode != 0:
384
+ raise RuntimeError(
385
+ f"Failed to apply patch {step.patch_path}:\n{result.stderr}"
386
+ )
387
+
388
+ # Track applied patches for cleanup (use relative path)
389
+ self.applied_patches.append(step.patch_path)
390
+
391
+ def revert_patches(self) -> None:
392
+ """Revert all applied patches in reverse order."""
393
+ for patch_path in reversed(self.applied_patches):
394
+ # Use patch -R to reverse the patch
395
+ subprocess.run(
396
+ ["patch", "-R", "-p1", "-i", patch_path, "--no-backup-if-mismatch"],
397
+ capture_output=True,
398
+ cwd=self.example_dir,
399
+ )
400
+ self.applied_patches.clear()
401
+
402
+ def assert_output(self, step: AssertOutputStep, scenario_name: str) -> None:
403
+ """Validate assertions on the last command result.
404
+
405
+ Args:
406
+ step: The AssertOutputStep to execute.
407
+ scenario_name: Name of the current scenario (for logging).
408
+
409
+ Raises:
410
+ AssertionError: If any assertion fails.
411
+ """
412
+ if self.last_result is None:
413
+ raise RuntimeError(
414
+ "No command result available for assertion. "
415
+ "AssertOutputStep must follow a RunCommandStep with capture_output=True."
416
+ )
417
+
418
+ # Check return code
419
+ expected_returncode = step.returncode if step.returncode is not None else 0
420
+ assert self.last_result.returncode == expected_returncode, (
421
+ f"Expected returncode {expected_returncode}, "
422
+ f"got {self.last_result.returncode}\n"
423
+ f"stderr: {self.last_result.stderr}"
424
+ )
425
+
426
+ # Check contains assertions
427
+ if step.contains:
428
+ for substring in step.contains:
429
+ assert substring in self.last_result.stdout, (
430
+ f"Expected substring not found in stdout: {substring!r}\n"
431
+ f"stdout: {self.last_result.stdout}"
432
+ )
433
+
434
+ # Check not_contains assertions
435
+ if step.not_contains:
436
+ for substring in step.not_contains:
437
+ assert substring not in self.last_result.stdout, (
438
+ f"Unexpected substring found in stdout: {substring!r}\n"
439
+ f"stdout: {self.last_result.stdout}"
440
+ )
441
+
442
+ # Check regex match
443
+ if step.matches_regex:
444
+ assert re.search(step.matches_regex, self.last_result.stdout), (
445
+ f"Regex pattern not matched: {step.matches_regex!r}\n"
446
+ f"stdout: {self.last_result.stdout}"
447
+ )
448
+
449
+ def run_scenario(self, scenario: Scenario) -> None:
450
+ """Execute a single scenario.
451
+
452
+ Args:
453
+ scenario: The scenario to execute.
454
+ """
455
+ for step in scenario.steps:
456
+ if isinstance(step, RunCommandStep):
457
+ self.run_command(step, scenario.name)
458
+ elif isinstance(step, ApplyPatchStep):
459
+ self.apply_patch(step, scenario.name)
460
+ elif isinstance(step, AssertOutputStep):
461
+ self.assert_output(step, scenario.name)
462
+ else:
463
+ raise ValueError(f"Unknown step type: {type(step)}")
464
+
465
+ def run(self) -> None:
466
+ """Execute all scenarios in the runbook."""
467
+ try:
468
+ for scenario in self.runbook.scenarios:
469
+ self.run_scenario(scenario)
470
+ finally:
471
+ # Always revert patches on completion or error
472
+ if self.applied_patches:
473
+ self.revert_patches()
474
+
475
+ @classmethod
476
+ def from_yaml_file(
477
+ cls,
478
+ yaml_path: Path,
479
+ override_db_path: Path | None = None,
480
+ env_overrides: dict[str, str] | None = None,
481
+ ) -> RunbookRunner:
482
+ """Create a runner from a YAML runbook file.
483
+
484
+ Args:
485
+ yaml_path: Path to the .example.yaml file.
486
+ override_db_path: Optional path to test database.
487
+ env_overrides: Additional environment variable overrides.
488
+
489
+ Returns:
490
+ Configured RunbookRunner instance.
491
+ """
492
+ runbook = Runbook.from_yaml_file(yaml_path)
493
+ example_dir = yaml_path.parent
494
+ return cls(
495
+ runbook=runbook,
496
+ example_dir=example_dir,
497
+ override_db_path=override_db_path,
498
+ env_overrides=env_overrides,
499
+ )
500
+
501
+ @classmethod
502
+ @contextmanager
503
+ def runner_for_project(
504
+ cls,
505
+ example_dir: Path,
506
+ override_db_path: Path | None = None,
507
+ env_overrides: dict[str, str] | None = None,
508
+ ) -> Iterator[RunbookRunner]:
509
+ """Context manager for running an example runbook in tests.
510
+
511
+ This handles cleanup even if the runbook execution fails.
512
+
513
+ Args:
514
+ example_dir: Directory containing .example.yaml.
515
+ override_db_path: Optional path to test database.
516
+ env_overrides: Additional environment variable overrides.
517
+
518
+ Yields:
519
+ RunbookRunner instance ready to execute.
520
+ """
521
+ yaml_path = example_dir / ".example.yaml"
522
+ runner = cls.from_yaml_file(
523
+ yaml_path=yaml_path,
524
+ override_db_path=override_db_path,
525
+ env_overrides=env_overrides,
526
+ )
527
+
528
+ try:
529
+ yield runner
530
+ finally:
531
+ # Ensure patches are reverted
532
+ if runner.applied_patches:
533
+ runner.revert_patches()
metaxy/_utils.py ADDED
@@ -0,0 +1,35 @@
1
+ from typing import overload
2
+
3
+ import narwhals as nw
4
+ import polars as pl
5
+ from narwhals.typing import DataFrameT, Frame, FrameT, LazyFrameT
6
+
7
+
8
+ def collect_to_polars(frame: Frame) -> pl.DataFrame:
9
+ """Helper to convert a Narwhals frame into an eager Polars DataFrame."""
10
+
11
+ return frame.lazy().collect().to_polars()
12
+
13
+
14
+ @overload
15
+ def switch_implementation_to_polars(frame: DataFrameT) -> DataFrameT: ...
16
+
17
+
18
+ @overload
19
+ def switch_implementation_to_polars(frame: LazyFrameT) -> LazyFrameT: ...
20
+
21
+
22
+ def switch_implementation_to_polars(frame: FrameT) -> FrameT:
23
+ if frame.implementation == nw.Implementation.POLARS:
24
+ return frame
25
+ elif isinstance(frame, nw.DataFrame):
26
+ return nw.from_native(frame.to_polars())
27
+ elif isinstance(frame, nw.LazyFrame):
28
+ return nw.from_native(
29
+ frame.collect().to_polars(),
30
+ ).lazy()
31
+ else:
32
+ raise ValueError(f"Unsupported frame type: {type(frame)}")
33
+
34
+
35
+ __all__ = ["collect_to_polars"]
metaxy/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1.dev3"