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.
- metaxy/__init__.py +170 -0
- metaxy/_packaging.py +96 -0
- metaxy/_testing/__init__.py +55 -0
- metaxy/_testing/config.py +43 -0
- metaxy/_testing/metaxy_project.py +780 -0
- metaxy/_testing/models.py +111 -0
- metaxy/_testing/parametric/__init__.py +13 -0
- metaxy/_testing/parametric/metadata.py +664 -0
- metaxy/_testing/pytest_helpers.py +74 -0
- metaxy/_testing/runbook.py +533 -0
- metaxy/_utils.py +35 -0
- metaxy/_version.py +1 -0
- metaxy/cli/app.py +97 -0
- metaxy/cli/console.py +13 -0
- metaxy/cli/context.py +167 -0
- metaxy/cli/graph.py +610 -0
- metaxy/cli/graph_diff.py +290 -0
- metaxy/cli/list.py +46 -0
- metaxy/cli/metadata.py +317 -0
- metaxy/cli/migrations.py +999 -0
- metaxy/cli/utils.py +268 -0
- metaxy/config.py +680 -0
- metaxy/entrypoints.py +296 -0
- metaxy/ext/__init__.py +1 -0
- metaxy/ext/dagster/__init__.py +54 -0
- metaxy/ext/dagster/constants.py +10 -0
- metaxy/ext/dagster/dagster_type.py +156 -0
- metaxy/ext/dagster/io_manager.py +200 -0
- metaxy/ext/dagster/metaxify.py +512 -0
- metaxy/ext/dagster/observable.py +115 -0
- metaxy/ext/dagster/resources.py +27 -0
- metaxy/ext/dagster/selection.py +73 -0
- metaxy/ext/dagster/table_metadata.py +417 -0
- metaxy/ext/dagster/utils.py +462 -0
- metaxy/ext/sqlalchemy/__init__.py +23 -0
- metaxy/ext/sqlalchemy/config.py +29 -0
- metaxy/ext/sqlalchemy/plugin.py +353 -0
- metaxy/ext/sqlmodel/__init__.py +13 -0
- metaxy/ext/sqlmodel/config.py +29 -0
- metaxy/ext/sqlmodel/plugin.py +499 -0
- metaxy/graph/__init__.py +29 -0
- metaxy/graph/describe.py +325 -0
- metaxy/graph/diff/__init__.py +21 -0
- metaxy/graph/diff/diff_models.py +446 -0
- metaxy/graph/diff/differ.py +769 -0
- metaxy/graph/diff/models.py +443 -0
- metaxy/graph/diff/rendering/__init__.py +18 -0
- metaxy/graph/diff/rendering/base.py +323 -0
- metaxy/graph/diff/rendering/cards.py +188 -0
- metaxy/graph/diff/rendering/formatter.py +805 -0
- metaxy/graph/diff/rendering/graphviz.py +246 -0
- metaxy/graph/diff/rendering/mermaid.py +326 -0
- metaxy/graph/diff/rendering/rich.py +169 -0
- metaxy/graph/diff/rendering/theme.py +48 -0
- metaxy/graph/diff/traversal.py +247 -0
- metaxy/graph/status.py +329 -0
- metaxy/graph/utils.py +58 -0
- metaxy/metadata_store/__init__.py +32 -0
- metaxy/metadata_store/_ducklake_support.py +419 -0
- metaxy/metadata_store/base.py +1792 -0
- metaxy/metadata_store/bigquery.py +354 -0
- metaxy/metadata_store/clickhouse.py +184 -0
- metaxy/metadata_store/delta.py +371 -0
- metaxy/metadata_store/duckdb.py +446 -0
- metaxy/metadata_store/exceptions.py +61 -0
- metaxy/metadata_store/ibis.py +542 -0
- metaxy/metadata_store/lancedb.py +391 -0
- metaxy/metadata_store/memory.py +292 -0
- metaxy/metadata_store/system/__init__.py +57 -0
- metaxy/metadata_store/system/events.py +264 -0
- metaxy/metadata_store/system/keys.py +9 -0
- metaxy/metadata_store/system/models.py +129 -0
- metaxy/metadata_store/system/storage.py +957 -0
- metaxy/metadata_store/types.py +10 -0
- metaxy/metadata_store/utils.py +104 -0
- metaxy/metadata_store/warnings.py +36 -0
- metaxy/migrations/__init__.py +32 -0
- metaxy/migrations/detector.py +291 -0
- metaxy/migrations/executor.py +516 -0
- metaxy/migrations/generator.py +319 -0
- metaxy/migrations/loader.py +231 -0
- metaxy/migrations/models.py +528 -0
- metaxy/migrations/ops.py +447 -0
- metaxy/models/__init__.py +0 -0
- metaxy/models/bases.py +12 -0
- metaxy/models/constants.py +139 -0
- metaxy/models/feature.py +1335 -0
- metaxy/models/feature_spec.py +338 -0
- metaxy/models/field.py +263 -0
- metaxy/models/fields_mapping.py +307 -0
- metaxy/models/filter_expression.py +297 -0
- metaxy/models/lineage.py +285 -0
- metaxy/models/plan.py +232 -0
- metaxy/models/types.py +475 -0
- metaxy/py.typed +0 -0
- metaxy/utils/__init__.py +1 -0
- metaxy/utils/constants.py +2 -0
- metaxy/utils/exceptions.py +23 -0
- metaxy/utils/hashing.py +230 -0
- metaxy/versioning/__init__.py +31 -0
- metaxy/versioning/engine.py +656 -0
- metaxy/versioning/feature_dep_transformer.py +151 -0
- metaxy/versioning/ibis.py +249 -0
- metaxy/versioning/lineage_handler.py +205 -0
- metaxy/versioning/polars.py +189 -0
- metaxy/versioning/renamed_df.py +35 -0
- metaxy/versioning/types.py +63 -0
- metaxy-0.0.1.dev3.dist-info/METADATA +96 -0
- metaxy-0.0.1.dev3.dist-info/RECORD +111 -0
- metaxy-0.0.1.dev3.dist-info/WHEEL +4 -0
- 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"
|