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 ADDED
@@ -0,0 +1,8 @@
1
+ # MIT License
2
+ # Copyright (c) 2024 Maharshi Soni
3
+
4
+ """DataWeave - Declarative Data Pipeline Framework."""
5
+
6
+ from dataweave.__version__ import __version__
7
+
8
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ # MIT License
2
+ # Copyright (c) 2024 Maharshi Soni
3
+
4
+ """Version information for DataWeave."""
5
+
6
+ __version__: str = "0.1.0"
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
+ ]