dataweave-lib 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.
- dataweave/__init__.py +8 -0
- dataweave/__version__.py +6 -0
- dataweave/cli.py +185 -0
- dataweave/engine.py +236 -0
- dataweave/loader.py +52 -0
- dataweave/models.py +110 -0
- dataweave/operators/__init__.py +33 -0
- dataweave/operators/base.py +75 -0
- dataweave/operators/core.py +477 -0
- dataweave/profiler.py +191 -0
- dataweave/py.typed +0 -0
- dataweave/report.py +212 -0
- dataweave/schema.py +138 -0
- dataweave_lib-0.1.0.dist-info/METADATA +340 -0
- dataweave_lib-0.1.0.dist-info/RECORD +18 -0
- dataweave_lib-0.1.0.dist-info/WHEEL +5 -0
- dataweave_lib-0.1.0.dist-info/entry_points.txt +2 -0
- dataweave_lib-0.1.0.dist-info/top_level.txt +1 -0
dataweave/__init__.py
ADDED
dataweave/__version__.py
ADDED
dataweave/cli.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
# Copyright (c) 2024 Maharshi Soni
|
|
3
|
+
|
|
4
|
+
"""Click-based CLI for DataWeave."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
from dataweave.__version__ import __version__
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@click.group()
|
|
17
|
+
@click.version_option(version=__version__, prog_name="dataweave")
|
|
18
|
+
def cli() -> None:
|
|
19
|
+
"""DataWeave - Declarative Data Pipeline Framework.
|
|
20
|
+
|
|
21
|
+
Define ETL workflows in YAML. Transform, validate, profile, and load data
|
|
22
|
+
with zero configuration.
|
|
23
|
+
"""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@cli.command()
|
|
28
|
+
@click.argument("pipeline", type=click.Path(exists=True))
|
|
29
|
+
@click.option(
|
|
30
|
+
"--output", "-o",
|
|
31
|
+
type=click.Path(),
|
|
32
|
+
default=None,
|
|
33
|
+
help="Override output path from the pipeline config.",
|
|
34
|
+
)
|
|
35
|
+
@click.option("--dry-run", is_flag=True, help="Validate config without executing.")
|
|
36
|
+
@click.option("--verbose", "-v", is_flag=True, help="Show detailed step output.")
|
|
37
|
+
def run(pipeline: str, output: str | None, dry_run: bool, verbose: bool) -> None:
|
|
38
|
+
"""Run a YAML-defined data pipeline.
|
|
39
|
+
|
|
40
|
+
PIPELINE is the path to a .yaml pipeline configuration file.
|
|
41
|
+
"""
|
|
42
|
+
from dataweave.loader import load_pipeline
|
|
43
|
+
from dataweave.engine import PipelineEngine
|
|
44
|
+
|
|
45
|
+
config = load_pipeline(pipeline)
|
|
46
|
+
click.echo(f"Pipeline: {config.name}")
|
|
47
|
+
click.echo(f"Source: {config.source.path}")
|
|
48
|
+
click.echo(f"Steps: {len(config.steps)}")
|
|
49
|
+
|
|
50
|
+
if output:
|
|
51
|
+
if config.output:
|
|
52
|
+
config.output.path = output
|
|
53
|
+
else:
|
|
54
|
+
from dataweave.models import OutputConfig
|
|
55
|
+
config.output = OutputConfig(path=output)
|
|
56
|
+
|
|
57
|
+
if dry_run:
|
|
58
|
+
click.echo("\n[DRY RUN] Configuration is valid. No data was processed.")
|
|
59
|
+
for i, step in enumerate(config.steps, 1):
|
|
60
|
+
click.echo(f" Step {i}: {step.name} ({step.operator.value})")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
base_dir = Path(pipeline).parent
|
|
64
|
+
engine = PipelineEngine(config, base_dir=base_dir)
|
|
65
|
+
result = engine.run()
|
|
66
|
+
|
|
67
|
+
if result.success:
|
|
68
|
+
click.secho("\nPipeline completed successfully!", fg="green", bold=True)
|
|
69
|
+
else:
|
|
70
|
+
click.secho(f"\nPipeline failed: {result.error}", fg="red", bold=True)
|
|
71
|
+
|
|
72
|
+
click.echo(f"Total duration: {result.total_duration_ms:.1f}ms")
|
|
73
|
+
click.echo(f"Final shape: {result.final_row_count} rows x {result.final_column_count} columns")
|
|
74
|
+
|
|
75
|
+
if result.output_path:
|
|
76
|
+
click.echo(f"Output: {result.output_path}")
|
|
77
|
+
|
|
78
|
+
if verbose:
|
|
79
|
+
click.echo("\nStep Details:")
|
|
80
|
+
for sr in result.step_results:
|
|
81
|
+
status = click.style("OK", fg="green") if sr.success else click.style("FAIL", fg="red")
|
|
82
|
+
click.echo(
|
|
83
|
+
f" [{status}] {sr.step_name} ({sr.operator}): "
|
|
84
|
+
f"{sr.rows_in} -> {sr.rows_out} rows, {sr.duration_ms}ms"
|
|
85
|
+
)
|
|
86
|
+
if sr.error:
|
|
87
|
+
click.echo(f" Error: {sr.error}")
|
|
88
|
+
|
|
89
|
+
if not result.success:
|
|
90
|
+
raise SystemExit(1)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@cli.command()
|
|
94
|
+
@click.argument("data_file", type=click.Path(exists=True))
|
|
95
|
+
@click.option(
|
|
96
|
+
"--output", "-o",
|
|
97
|
+
type=click.Path(),
|
|
98
|
+
default=None,
|
|
99
|
+
help="Output path for the HTML report (default: <data_file>_profile.html).",
|
|
100
|
+
)
|
|
101
|
+
@click.option("--json", "as_json", is_flag=True, help="Output profile as JSON instead of HTML.")
|
|
102
|
+
def profile(data_file: str, output: str | None, as_json: bool) -> None:
|
|
103
|
+
"""Generate a profiling report for a CSV data file.
|
|
104
|
+
|
|
105
|
+
DATA_FILE is the path to a CSV file to profile.
|
|
106
|
+
"""
|
|
107
|
+
import json as json_mod
|
|
108
|
+
from dataweave.profiler import profile_dataframe
|
|
109
|
+
from dataweave.report import generate_report
|
|
110
|
+
|
|
111
|
+
click.echo(f"Profiling: {data_file}")
|
|
112
|
+
df = pd.read_csv(data_file)
|
|
113
|
+
click.echo(f"Shape: {df.shape[0]} rows x {df.shape[1]} columns")
|
|
114
|
+
|
|
115
|
+
prof = profile_dataframe(df)
|
|
116
|
+
|
|
117
|
+
if as_json:
|
|
118
|
+
out_path = output or str(Path(data_file).with_suffix(".profile.json"))
|
|
119
|
+
Path(out_path).write_text(
|
|
120
|
+
json_mod.dumps(prof.to_dict(), indent=2, default=str),
|
|
121
|
+
encoding="utf-8",
|
|
122
|
+
)
|
|
123
|
+
click.echo(f"JSON profile written to: {out_path}")
|
|
124
|
+
else:
|
|
125
|
+
out_path = output or str(Path(data_file).with_suffix("")) + "_profile.html"
|
|
126
|
+
generate_report(prof, out_path)
|
|
127
|
+
click.secho(f"HTML report written to: {out_path}", fg="green")
|
|
128
|
+
|
|
129
|
+
# Summary
|
|
130
|
+
click.echo(f"\nDuplicate rows: {prof.duplicate_row_count}")
|
|
131
|
+
for cp in prof.columns:
|
|
132
|
+
if cp.null_pct > 0:
|
|
133
|
+
click.echo(f" {cp.name}: {cp.null_pct:.1f}% nulls")
|
|
134
|
+
if cp.outlier_count > 0:
|
|
135
|
+
click.echo(f" {cp.name}: {cp.outlier_count} outliers detected")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@cli.command()
|
|
139
|
+
@click.argument("data_file", type=click.Path(exists=True))
|
|
140
|
+
@click.argument("rules_file", type=click.Path(exists=True))
|
|
141
|
+
@click.option("--verbose", "-v", is_flag=True, help="Show all check results, not just failures.")
|
|
142
|
+
def validate(data_file: str, rules_file: str, verbose: bool) -> None:
|
|
143
|
+
"""Validate a CSV file against a YAML rules file.
|
|
144
|
+
|
|
145
|
+
DATA_FILE is the path to a CSV file.
|
|
146
|
+
RULES_FILE is a YAML file with validation rules.
|
|
147
|
+
"""
|
|
148
|
+
import yaml
|
|
149
|
+
from dataweave.models import ValidationRule
|
|
150
|
+
from dataweave.operators.core import run_validations
|
|
151
|
+
|
|
152
|
+
click.echo(f"Validating: {data_file}")
|
|
153
|
+
df = pd.read_csv(data_file)
|
|
154
|
+
|
|
155
|
+
with open(rules_file, "r", encoding="utf-8") as f:
|
|
156
|
+
raw = yaml.safe_load(f)
|
|
157
|
+
|
|
158
|
+
rules = [ValidationRule(**r) for r in raw.get("rules", raw.get("validations", []))]
|
|
159
|
+
click.echo(f"Rules: {len(rules)}")
|
|
160
|
+
|
|
161
|
+
results = run_validations(df, rules)
|
|
162
|
+
|
|
163
|
+
passed = sum(1 for r in results if r["passed"])
|
|
164
|
+
failed = sum(1 for r in results if not r["passed"])
|
|
165
|
+
|
|
166
|
+
for r in results:
|
|
167
|
+
if r["passed"] and not verbose:
|
|
168
|
+
continue
|
|
169
|
+
icon = click.style("PASS", fg="green") if r["passed"] else click.style("FAIL", fg="red")
|
|
170
|
+
sev = f" [{r['severity']}]" if not r["passed"] else ""
|
|
171
|
+
click.echo(f" [{icon}] {r['column']}.{r['check']}{sev}: {r['message']}")
|
|
172
|
+
|
|
173
|
+
click.echo(f"\n{passed} passed, {failed} failed out of {len(results)} checks")
|
|
174
|
+
|
|
175
|
+
if failed > 0:
|
|
176
|
+
errors = [r for r in results if not r["passed"] and r["severity"] == "error"]
|
|
177
|
+
if errors:
|
|
178
|
+
click.secho(f"{len(errors)} error-level failures", fg="red", bold=True)
|
|
179
|
+
raise SystemExit(1)
|
|
180
|
+
else:
|
|
181
|
+
click.secho("All failures are warnings only", fg="yellow")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
cli()
|
dataweave/engine.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
# Copyright (c) 2024 Maharshi Soni
|
|
3
|
+
|
|
4
|
+
"""Pipeline execution engine for DataWeave."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from dataweave.models import PipelineConfig, StepConfig
|
|
16
|
+
from dataweave.operators.base import OperatorRegistry
|
|
17
|
+
|
|
18
|
+
# Ensure all operators are registered by importing the core module.
|
|
19
|
+
import dataweave.operators.core # noqa: F401
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class StepResult:
|
|
24
|
+
"""Result from executing a single pipeline step."""
|
|
25
|
+
|
|
26
|
+
step_name: str
|
|
27
|
+
operator: str
|
|
28
|
+
rows_in: int
|
|
29
|
+
rows_out: int
|
|
30
|
+
columns_out: int
|
|
31
|
+
duration_ms: float
|
|
32
|
+
success: bool
|
|
33
|
+
error: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class PipelineResult:
|
|
38
|
+
"""Result from executing an entire pipeline."""
|
|
39
|
+
|
|
40
|
+
pipeline_name: str
|
|
41
|
+
success: bool
|
|
42
|
+
total_duration_ms: float
|
|
43
|
+
step_results: list[StepResult] = field(default_factory=list)
|
|
44
|
+
output_path: str | None = None
|
|
45
|
+
final_row_count: int = 0
|
|
46
|
+
final_column_count: int = 0
|
|
47
|
+
error: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PipelineEngine:
|
|
51
|
+
"""Executes a DataWeave pipeline configuration against data."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, config: PipelineConfig, base_dir: Path | None = None) -> None:
|
|
54
|
+
"""Initialize the engine.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
config: Validated pipeline configuration.
|
|
58
|
+
base_dir: Base directory for resolving relative file paths.
|
|
59
|
+
Defaults to the current working directory.
|
|
60
|
+
"""
|
|
61
|
+
self.config = config
|
|
62
|
+
self.base_dir = base_dir or Path.cwd()
|
|
63
|
+
|
|
64
|
+
def _resolve_path(self, path_str: str) -> Path:
|
|
65
|
+
"""Resolve a file path relative to the base directory.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
path_str: Potentially relative path string.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Resolved absolute Path.
|
|
72
|
+
"""
|
|
73
|
+
p = Path(path_str)
|
|
74
|
+
if p.is_absolute():
|
|
75
|
+
return p
|
|
76
|
+
return self.base_dir / p
|
|
77
|
+
|
|
78
|
+
def _load_source(self) -> pd.DataFrame:
|
|
79
|
+
"""Load the source data from the configured path.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The loaded DataFrame.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
FileNotFoundError: If the source file does not exist.
|
|
86
|
+
ValueError: If the format is unsupported.
|
|
87
|
+
"""
|
|
88
|
+
source = self.config.source
|
|
89
|
+
path = self._resolve_path(source.path)
|
|
90
|
+
|
|
91
|
+
if not path.exists():
|
|
92
|
+
raise FileNotFoundError(f"Source file not found: {path}")
|
|
93
|
+
|
|
94
|
+
fmt = source.format.lower()
|
|
95
|
+
if fmt == "csv":
|
|
96
|
+
return pd.read_csv(path, **source.options)
|
|
97
|
+
elif fmt == "json":
|
|
98
|
+
return pd.read_json(path, **source.options)
|
|
99
|
+
elif fmt == "parquet":
|
|
100
|
+
return pd.read_parquet(path, **source.options)
|
|
101
|
+
else:
|
|
102
|
+
raise ValueError(f"Unsupported source format: '{fmt}'")
|
|
103
|
+
|
|
104
|
+
def _write_output(self, df: pd.DataFrame) -> str | None:
|
|
105
|
+
"""Write the final DataFrame to the configured output.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
df: Final DataFrame to write.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Output path string, or None if no output configured.
|
|
112
|
+
"""
|
|
113
|
+
if self.config.output is None:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
out = self.config.output
|
|
117
|
+
path = self._resolve_path(out.path)
|
|
118
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
|
|
120
|
+
fmt = out.format.lower()
|
|
121
|
+
if fmt == "csv":
|
|
122
|
+
df.to_csv(path, index=False, **out.options)
|
|
123
|
+
elif fmt == "json":
|
|
124
|
+
df.to_json(path, orient="records", indent=2, **out.options)
|
|
125
|
+
elif fmt == "parquet":
|
|
126
|
+
df.to_parquet(path, index=False, **out.options)
|
|
127
|
+
else:
|
|
128
|
+
raise ValueError(f"Unsupported output format: '{fmt}'")
|
|
129
|
+
|
|
130
|
+
return str(path)
|
|
131
|
+
|
|
132
|
+
def run(self) -> PipelineResult:
|
|
133
|
+
"""Execute the full pipeline.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
PipelineResult with details of each step and the final outcome.
|
|
137
|
+
"""
|
|
138
|
+
pipeline_start = time.perf_counter()
|
|
139
|
+
step_results: list[StepResult] = []
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
df = self._load_source()
|
|
143
|
+
except Exception as e:
|
|
144
|
+
duration = (time.perf_counter() - pipeline_start) * 1000
|
|
145
|
+
return PipelineResult(
|
|
146
|
+
pipeline_name=self.config.name,
|
|
147
|
+
success=False,
|
|
148
|
+
total_duration_ms=duration,
|
|
149
|
+
error=f"Failed to load source: {e}",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
for step in self.config.steps:
|
|
153
|
+
step_start = time.perf_counter()
|
|
154
|
+
rows_in = len(df)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
operator = OperatorRegistry.get(step.operator.value)
|
|
158
|
+
df = operator.execute(df, step)
|
|
159
|
+
duration = (time.perf_counter() - step_start) * 1000
|
|
160
|
+
|
|
161
|
+
step_results.append(StepResult(
|
|
162
|
+
step_name=step.name,
|
|
163
|
+
operator=step.operator.value,
|
|
164
|
+
rows_in=rows_in,
|
|
165
|
+
rows_out=len(df),
|
|
166
|
+
columns_out=len(df.columns),
|
|
167
|
+
duration_ms=round(duration, 2),
|
|
168
|
+
success=True,
|
|
169
|
+
))
|
|
170
|
+
except Exception as e:
|
|
171
|
+
duration = (time.perf_counter() - step_start) * 1000
|
|
172
|
+
step_results.append(StepResult(
|
|
173
|
+
step_name=step.name,
|
|
174
|
+
operator=step.operator.value,
|
|
175
|
+
rows_in=rows_in,
|
|
176
|
+
rows_out=rows_in,
|
|
177
|
+
columns_out=len(df.columns),
|
|
178
|
+
duration_ms=round(duration, 2),
|
|
179
|
+
success=False,
|
|
180
|
+
error=str(e),
|
|
181
|
+
))
|
|
182
|
+
|
|
183
|
+
total_duration = (time.perf_counter() - pipeline_start) * 1000
|
|
184
|
+
return PipelineResult(
|
|
185
|
+
pipeline_name=self.config.name,
|
|
186
|
+
success=False,
|
|
187
|
+
total_duration_ms=round(total_duration, 2),
|
|
188
|
+
step_results=step_results,
|
|
189
|
+
final_row_count=len(df),
|
|
190
|
+
final_column_count=len(df.columns),
|
|
191
|
+
error=f"Step '{step.name}' failed: {e}",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Write output
|
|
195
|
+
output_path: str | None = None
|
|
196
|
+
try:
|
|
197
|
+
output_path = self._write_output(df)
|
|
198
|
+
except Exception as e:
|
|
199
|
+
total_duration = (time.perf_counter() - pipeline_start) * 1000
|
|
200
|
+
return PipelineResult(
|
|
201
|
+
pipeline_name=self.config.name,
|
|
202
|
+
success=False,
|
|
203
|
+
total_duration_ms=round(total_duration, 2),
|
|
204
|
+
step_results=step_results,
|
|
205
|
+
final_row_count=len(df),
|
|
206
|
+
final_column_count=len(df.columns),
|
|
207
|
+
error=f"Failed to write output: {e}",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
total_duration = (time.perf_counter() - pipeline_start) * 1000
|
|
211
|
+
return PipelineResult(
|
|
212
|
+
pipeline_name=self.config.name,
|
|
213
|
+
success=True,
|
|
214
|
+
total_duration_ms=round(total_duration, 2),
|
|
215
|
+
step_results=step_results,
|
|
216
|
+
output_path=output_path,
|
|
217
|
+
final_row_count=len(df),
|
|
218
|
+
final_column_count=len(df.columns),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def run_to_dataframe(self) -> pd.DataFrame:
|
|
222
|
+
"""Execute the pipeline and return the final DataFrame directly.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
The transformed DataFrame.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
RuntimeError: If the pipeline fails at any step.
|
|
229
|
+
"""
|
|
230
|
+
df = self._load_source()
|
|
231
|
+
|
|
232
|
+
for step in self.config.steps:
|
|
233
|
+
operator = OperatorRegistry.get(step.operator.value)
|
|
234
|
+
df = operator.execute(df, step)
|
|
235
|
+
|
|
236
|
+
return df
|
dataweave/loader.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
# Copyright (c) 2024 Maharshi Soni
|
|
3
|
+
|
|
4
|
+
"""YAML pipeline configuration loader for DataWeave."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from dataweave.models import PipelineConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_pipeline(path: str | Path) -> PipelineConfig:
|
|
17
|
+
"""Load and validate a pipeline configuration from a YAML file.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
path: Path to the YAML pipeline file.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
A validated PipelineConfig instance.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
FileNotFoundError: If the YAML file does not exist.
|
|
27
|
+
yaml.YAMLError: If the YAML is malformed.
|
|
28
|
+
pydantic.ValidationError: If the config does not match the schema.
|
|
29
|
+
"""
|
|
30
|
+
path = Path(path)
|
|
31
|
+
if not path.exists():
|
|
32
|
+
raise FileNotFoundError(f"Pipeline config not found: {path}")
|
|
33
|
+
|
|
34
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
35
|
+
raw: dict[str, Any] = yaml.safe_load(f)
|
|
36
|
+
|
|
37
|
+
if raw is None:
|
|
38
|
+
raise ValueError(f"Empty pipeline config: {path}")
|
|
39
|
+
|
|
40
|
+
return PipelineConfig(**raw)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_pipeline_from_dict(data: dict[str, Any]) -> PipelineConfig:
|
|
44
|
+
"""Load a pipeline configuration from a dictionary.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
data: Dictionary matching the PipelineConfig schema.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
A validated PipelineConfig instance.
|
|
51
|
+
"""
|
|
52
|
+
return PipelineConfig(**data)
|
dataweave/models.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
# Copyright (c) 2024 Maharshi Soni
|
|
3
|
+
|
|
4
|
+
"""Pydantic models for DataWeave pipeline configuration."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, field_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OperatorType(str, Enum):
|
|
15
|
+
"""Supported pipeline operator types."""
|
|
16
|
+
|
|
17
|
+
FILTER = "filter"
|
|
18
|
+
TRANSFORM = "transform"
|
|
19
|
+
AGGREGATE = "aggregate"
|
|
20
|
+
JOIN = "join"
|
|
21
|
+
VALIDATE = "validate"
|
|
22
|
+
RENAME = "rename"
|
|
23
|
+
SELECT = "select"
|
|
24
|
+
SORT = "sort"
|
|
25
|
+
DEDUPLICATE = "deduplicate"
|
|
26
|
+
FILL_NA = "fill_na"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DataSourceConfig(BaseModel):
|
|
30
|
+
"""Configuration for a data source."""
|
|
31
|
+
|
|
32
|
+
path: str = Field(..., description="File path to the data source (CSV)")
|
|
33
|
+
format: str = Field(default="csv", description="File format")
|
|
34
|
+
options: dict[str, Any] = Field(
|
|
35
|
+
default_factory=dict, description="Extra read options"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class JoinConfig(BaseModel):
|
|
40
|
+
"""Configuration for a join operation."""
|
|
41
|
+
|
|
42
|
+
right_source: str = Field(..., description="Path to the right dataset")
|
|
43
|
+
on: str | list[str] = Field(..., description="Column(s) to join on")
|
|
44
|
+
how: str = Field(default="inner", description="Join type: inner, left, right, outer")
|
|
45
|
+
|
|
46
|
+
@field_validator("how")
|
|
47
|
+
@classmethod
|
|
48
|
+
def validate_how(cls, v: str) -> str:
|
|
49
|
+
allowed = {"inner", "left", "right", "outer"}
|
|
50
|
+
if v not in allowed:
|
|
51
|
+
raise ValueError(f"Join type must be one of {allowed}, got '{v}'")
|
|
52
|
+
return v
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ValidationRule(BaseModel):
|
|
56
|
+
"""A single data validation rule."""
|
|
57
|
+
|
|
58
|
+
column: str = Field(..., description="Column to validate")
|
|
59
|
+
check: str = Field(..., description="Check type: not_null, unique, min, max, pattern, in_set")
|
|
60
|
+
value: Any = Field(default=None, description="Expected value for the check")
|
|
61
|
+
severity: str = Field(default="error", description="error or warning")
|
|
62
|
+
|
|
63
|
+
@field_validator("check")
|
|
64
|
+
@classmethod
|
|
65
|
+
def validate_check(cls, v: str) -> str:
|
|
66
|
+
allowed = {"not_null", "unique", "min", "max", "pattern", "in_set", "dtype"}
|
|
67
|
+
if v not in allowed:
|
|
68
|
+
raise ValueError(f"Check must be one of {allowed}, got '{v}'")
|
|
69
|
+
return v
|
|
70
|
+
|
|
71
|
+
@field_validator("severity")
|
|
72
|
+
@classmethod
|
|
73
|
+
def validate_severity(cls, v: str) -> str:
|
|
74
|
+
if v not in {"error", "warning"}:
|
|
75
|
+
raise ValueError(f"Severity must be 'error' or 'warning', got '{v}'")
|
|
76
|
+
return v
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class StepConfig(BaseModel):
|
|
80
|
+
"""Configuration for a single pipeline step."""
|
|
81
|
+
|
|
82
|
+
name: str = Field(..., description="Step name")
|
|
83
|
+
operator: OperatorType = Field(..., description="Operator type")
|
|
84
|
+
params: dict[str, Any] = Field(
|
|
85
|
+
default_factory=dict, description="Operator-specific parameters"
|
|
86
|
+
)
|
|
87
|
+
join: Optional[JoinConfig] = Field(default=None, description="Join config (if operator is join)")
|
|
88
|
+
validations: list[ValidationRule] = Field(
|
|
89
|
+
default_factory=list, description="Validation rules (if operator is validate)"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class OutputConfig(BaseModel):
|
|
94
|
+
"""Configuration for pipeline output."""
|
|
95
|
+
|
|
96
|
+
path: str = Field(..., description="Output file path")
|
|
97
|
+
format: str = Field(default="csv", description="Output format")
|
|
98
|
+
options: dict[str, Any] = Field(
|
|
99
|
+
default_factory=dict, description="Extra write options"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class PipelineConfig(BaseModel):
|
|
104
|
+
"""Top-level pipeline configuration parsed from YAML."""
|
|
105
|
+
|
|
106
|
+
name: str = Field(..., description="Pipeline name")
|
|
107
|
+
description: str = Field(default="", description="Pipeline description")
|
|
108
|
+
source: DataSourceConfig = Field(..., description="Input data source")
|
|
109
|
+
steps: list[StepConfig] = Field(default_factory=list, description="Pipeline steps")
|
|
110
|
+
output: Optional[OutputConfig] = Field(default=None, description="Output config")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
# Copyright (c) 2024 Maharshi Soni
|
|
3
|
+
|
|
4
|
+
"""Built-in operators for DataWeave pipelines."""
|
|
5
|
+
|
|
6
|
+
from dataweave.operators.base import Operator, OperatorRegistry
|
|
7
|
+
from dataweave.operators.core import (
|
|
8
|
+
AggregateOperator,
|
|
9
|
+
DeduplicateOperator,
|
|
10
|
+
FillNaOperator,
|
|
11
|
+
FilterOperator,
|
|
12
|
+
JoinOperator,
|
|
13
|
+
RenameOperator,
|
|
14
|
+
SelectOperator,
|
|
15
|
+
SortOperator,
|
|
16
|
+
TransformOperator,
|
|
17
|
+
ValidateOperator,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Operator",
|
|
22
|
+
"OperatorRegistry",
|
|
23
|
+
"FilterOperator",
|
|
24
|
+
"TransformOperator",
|
|
25
|
+
"AggregateOperator",
|
|
26
|
+
"JoinOperator",
|
|
27
|
+
"ValidateOperator",
|
|
28
|
+
"RenameOperator",
|
|
29
|
+
"SelectOperator",
|
|
30
|
+
"SortOperator",
|
|
31
|
+
"DeduplicateOperator",
|
|
32
|
+
"FillNaOperator",
|
|
33
|
+
]
|