fluvv 1.0.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.
fluvv/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """Fluvv: source + pipeline config on top of vanilla ODCS contracts.
2
+
3
+ See docs/fluvv/SPEC.md for the spec this package implements.
4
+ """
5
+
6
+ from fluvv.contract import load_extension
7
+ from fluvv.export import ExportResult, export_artifacts
8
+ from fluvv.lint import LintResult, lint
9
+ from fluvv.models import IngestionConfig, FluvvExtension, Source, Transform
10
+ from fluvv.product import Contract
11
+
12
+ __all__ = [
13
+ "Contract",
14
+ "ExportResult",
15
+ "IngestionConfig",
16
+ "LintResult",
17
+ "FluvvExtension",
18
+ "Source",
19
+ "Transform",
20
+ "export_artifacts",
21
+ "lint",
22
+ "load_extension",
23
+ ]
fluvv/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from fluvv.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
fluvv/cli.py ADDED
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import entry_points, version
4
+ from pathlib import Path
5
+ from typing import Callable
6
+
7
+ import typer
8
+
9
+ from fluvv.contract import RepoRootNotFoundError
10
+ from fluvv.export import export_artifacts
11
+ from fluvv.lint import lint
12
+ from fluvv.manifest import DEFAULT_PATH as DEFAULT_MANIFEST_PATH, write_manifest
13
+ from fluvv.semantic import ProjectCheckError
14
+
15
+ # Installed packages add commands to the `fluvv` CLI by registering a
16
+ # `register(app)` function under this entry-point group. Fluvv Engine adds
17
+ # ingest and the OpenMetadata commands this way, so the open package never
18
+ # imports it.
19
+ PLUGIN_GROUP = "fluvv.commands"
20
+
21
+ # Fluvv Engine's commands. Listed only so that, without the engine, they
22
+ # say where they come from instead of failing as unknown commands.
23
+ ENGINE_COMMANDS = ("ingest", "push-openmetadata", "prune-openmetadata", "setup-openmetadata")
24
+
25
+
26
+ def _yaml_files(path: Path) -> list[Path]:
27
+ """What linting `path` means. A file is itself. A repository root (it
28
+ holds contracts/ or governance/) is the YAML in those two folders, so
29
+ `fluvv lint .` lints the project and nothing else in it. Any other
30
+ folder is every YAML file below it, skipping hidden folders like .venv.
31
+ The generated files under contracts/ are .py and .json, so they're
32
+ never picked up."""
33
+ if path.is_file():
34
+ return [path]
35
+ roots = [path / top for top in ("contracts", "governance") if (path / top).is_dir()] or [path]
36
+ return sorted(
37
+ found
38
+ for root in roots
39
+ for found in root.rglob("*.yaml")
40
+ if not any(part.startswith(".") for part in found.relative_to(root).parts[:-1])
41
+ )
42
+
43
+
44
+ def lint_command(
45
+ paths: list[Path] = typer.Argument(
46
+ None,
47
+ exists=True,
48
+ help="Contracts, governance files (Domain, Glossary, ODPS DataProduct), or folders of them. "
49
+ "Default: the repository in the current directory.",
50
+ ),
51
+ ) -> None:
52
+ """Lint contracts and governance files, each on its own. Exits non-zero
53
+ if any fail. References between files are checked by `compile`."""
54
+ all_passed = True
55
+ for path in paths or [Path(".")]:
56
+ files = _yaml_files(path)
57
+ if not files:
58
+ typer.echo(f"{path}: no .yaml files to lint", err=True)
59
+ all_passed = False
60
+ for contract_path in files:
61
+ result = lint(contract_path)
62
+ typer.echo(result.report())
63
+ all_passed &= result.passed
64
+ raise typer.Exit(code=0 if all_passed else 1)
65
+
66
+
67
+ def export_command(
68
+ contract_paths: list[Path] = typer.Argument(..., help="One or more Fluvv contract YAML files."),
69
+ ) -> None:
70
+ """Export the Spark schema and ingestion_config JSON for one or more
71
+ contracts. They're written under the repository's contracts/, so each
72
+ contract must be inside one."""
73
+ for contract_path in contract_paths:
74
+ try:
75
+ result = export_artifacts(contract_path)
76
+ except RepoRootNotFoundError as exc:
77
+ typer.echo(str(exc), err=True)
78
+ raise typer.Exit(code=1)
79
+ typer.echo(f"{contract_path}:")
80
+ typer.echo(f" {result.spark_schema_path}")
81
+ typer.echo(f" {result.ingestion_config_path}")
82
+
83
+
84
+ def compile_command(
85
+ out: Path = typer.Option(DEFAULT_MANIFEST_PATH, "--out", help="Where to write the manifest."),
86
+ ) -> None:
87
+ """Compile every contract and governance file under the current
88
+ directory (the repo root) into one manifest JSON, after checking that
89
+ each file can be read and their references resolve. Exits non-zero,
90
+ writing nothing, if not. `lint` checks each file in full."""
91
+ try:
92
+ typer.echo(write_manifest(Path("."), out))
93
+ except ProjectCheckError as exc:
94
+ typer.echo(exc.report())
95
+ raise typer.Exit(code=1)
96
+
97
+
98
+ def _engine_required(name: str) -> Callable[[], None]:
99
+ def command() -> None:
100
+ typer.echo(
101
+ f"`fluvv {name}` is part of Fluvv Engine, which isn't installed. "
102
+ "See https://fluvv.se/engine.",
103
+ err=True,
104
+ )
105
+ raise typer.Exit(code=1)
106
+
107
+ command.__doc__ = "Part of Fluvv Engine (not installed)."
108
+ return command
109
+
110
+
111
+ def _print_version(show: bool) -> None:
112
+ # An option callback, so it runs before typer asks for a subcommand.
113
+ if show:
114
+ typer.echo(f"fluvv {version('fluvv')}")
115
+ raise typer.Exit()
116
+
117
+
118
+ def _plugin_registrars() -> list[Callable[[typer.Typer], None]]:
119
+ return [ep.load() for ep in entry_points(group=PLUGIN_GROUP)]
120
+
121
+
122
+ def build_app() -> typer.Typer:
123
+ app = typer.Typer(help="Fluvv tooling: lint, export and compile Fluvv contracts and governance files.")
124
+
125
+ @app.callback()
126
+ def _main(
127
+ show_version: bool = typer.Option(
128
+ False, "--version", is_eager=True, callback=_print_version, help="Print the installed fluvv version and exit."
129
+ ),
130
+ ) -> None:
131
+ """(Callback present so `lint` stays an explicit subcommand as more get added.)"""
132
+
133
+ app.command(name="lint")(lint_command)
134
+ app.command(name="export")(export_command)
135
+ app.command(name="compile")(compile_command)
136
+ for register in _plugin_registrars():
137
+ register(app)
138
+
139
+ registered = {info.name for info in app.registered_commands}
140
+ for name in ENGINE_COMMANDS:
141
+ if name not in registered:
142
+ app.command(
143
+ name=name,
144
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
145
+ )(_engine_required(name))
146
+ return app
147
+
148
+
149
+ app = build_app()
150
+
151
+ if __name__ == "__main__":
152
+ app()
fluvv/contract.py ADDED
@@ -0,0 +1,122 @@
1
+ """Loading a Fluvv contract file and extracting its `sources`/`transform`
2
+ customProperties into the typed structure in fluvv.models."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+ from fluvv.models import FluvvExtension
12
+
13
+ FLUVV_CUSTOM_PROPERTIES = ("sources", "ingestion_config")
14
+ OPTIONAL_FLUVV_CUSTOM_PROPERTIES = ("transform", "businessMetrics")
15
+ """Validated the same way as FLUVV_CUSTOM_PROPERTIES when present, but
16
+ never raise MissingCustomPropertyError when absent -- a contract that
17
+ doesn't set these is still a complete, valid Fluvv contract. (`transform`
18
+ may only be omitted with a single source; lint checks that.)"""
19
+
20
+
21
+ class MissingCustomPropertyError(ValueError):
22
+ def __init__(self, property_name: str, contract_path: Path):
23
+ self.property_name = property_name
24
+ self.contract_path = contract_path
25
+ super().__init__(
26
+ f"{contract_path}: missing required customProperties entry '{property_name}'"
27
+ )
28
+
29
+
30
+ class UnresolvableSourceError(ValueError):
31
+ pass
32
+
33
+
34
+ class RepoRootNotFoundError(FileNotFoundError):
35
+ """A file outside any Fluvv repository was given to something that
36
+ needs the files around it: resolving upstream sources, or writing the
37
+ generated files under `contracts/`. Lint never raises it."""
38
+
39
+
40
+ def load_odcs_document(contract_path: Path) -> dict[str, Any]:
41
+ return yaml.safe_load(Path(contract_path).read_text())
42
+
43
+
44
+ def yaml_problem(exc: yaml.YAMLError) -> str:
45
+ """A YAML syntax error in one line, for a report rather than a traceback."""
46
+ mark, problem = getattr(exc, "problem_mark", None), getattr(exc, "problem", None)
47
+ if mark is not None and problem:
48
+ return f"invalid YAML at line {mark.line + 1}: {problem}"
49
+ return f"invalid YAML: {str(exc).splitlines()[0]}"
50
+
51
+
52
+ def find_repo_root(start: Path) -> Path:
53
+ """The repository root for `start` (SPEC.md Section 1), recognised by
54
+ the layout, not by fluvv's own files: a contracts repo doesn't
55
+ contain the package.
56
+
57
+ A file where the layout puts it names its root by position --
58
+ `contracts/<layer>/<domain>/<table>.yaml` or `governance/<kind>/<file>`
59
+ -- so a domain called `contracts` or `governance` can't be mistaken for
60
+ the top-level folder. Anything else resolves to the nearest directory
61
+ holding `contracts/` or `governance/`."""
62
+ path = start.resolve()
63
+ for top, depth in (("contracts", 3), ("governance", 2)):
64
+ if len(path.parents) > depth and path.parents[depth - 1].name == top:
65
+ return path.parents[depth]
66
+ for candidate in (path, *path.parents):
67
+ if (candidate / "contracts").is_dir() or (candidate / "governance").is_dir():
68
+ return candidate
69
+ raise RepoRootNotFoundError(
70
+ f"{start} isn't inside a Fluvv repository: no folder above it holds a "
71
+ "contracts/ or governance/ directory (SPEC.md Section 1)"
72
+ )
73
+
74
+
75
+ def get_custom_property(doc: dict[str, Any], property_name: str) -> Any | None:
76
+ for entry in doc.get("customProperties") or []:
77
+ if entry.get("property") == property_name:
78
+ return entry.get("value")
79
+ return None
80
+
81
+
82
+ def parse_extension(doc: dict[str, Any], contract_path: Path) -> FluvvExtension:
83
+ """The extension exactly as written: sources that name a `contract`
84
+ have no `location`/`format` yet. See resolve_extension()."""
85
+ values: dict[str, Any] = {}
86
+ for property_name in FLUVV_CUSTOM_PROPERTIES:
87
+ value = get_custom_property(doc, property_name)
88
+ if value is None:
89
+ raise MissingCustomPropertyError(property_name, contract_path)
90
+ values[property_name] = value
91
+ for property_name in OPTIONAL_FLUVV_CUSTOM_PROPERTIES:
92
+ value = get_custom_property(doc, property_name)
93
+ if value is not None:
94
+ values[property_name] = value
95
+ return FluvvExtension.model_validate(values)
96
+
97
+
98
+ def resolve_extension(extension: FluvvExtension, repo_root: Path) -> FluvvExtension:
99
+ """Fills each contract-backed source's `location`/`format` from that
100
+ contract's own `servers[0]`, so where a table lives is stated once --
101
+ in the contract that owns it. Pre-contract sources (`contract: null`)
102
+ keep what they declare."""
103
+ sources = []
104
+ for source in extension.sources:
105
+ if source.contract is None:
106
+ if not (source.location and source.format):
107
+ raise UnresolvableSourceError(f"source '{source.id}': `location` and `format` are required when `contract` is null")
108
+ sources.append(source)
109
+ continue
110
+ upstream_path = Path(repo_root) / source.contract
111
+ if not upstream_path.is_file():
112
+ raise UnresolvableSourceError(f"source '{source.id}': contract '{source.contract}' does not exist")
113
+ server = load_odcs_document(upstream_path)["servers"][0]
114
+ sources.append(source.model_copy(update={"location": server["path"], "format": server["format"]}))
115
+ return extension.model_copy(update={"sources": sources})
116
+
117
+
118
+ def load_extension(contract_path: Path, resolve: bool = True) -> FluvvExtension:
119
+ contract_path = Path(contract_path)
120
+ doc = load_odcs_document(contract_path)
121
+ extension = parse_extension(doc, contract_path)
122
+ return resolve_extension(extension, find_repo_root(contract_path)) if resolve else extension
fluvv/export.py ADDED
@@ -0,0 +1,55 @@
1
+ """Generate the artifacts derived from a contract:
2
+ - contracts/spark_schemas/<mirrored path>.py -- datacontract-cli export("spark")
3
+ - contracts/ingestion_config/<mirrored path>.json -- the sources/transform/
4
+ ingestion_config customProperties, consumed by the ingestion framework
5
+ at runtime. Fully resolved: a contract-backed source's location/format
6
+ are filled in from its upstream contract, so the engine never needs to
7
+ read another contract.
8
+
9
+ Each output path mirrors the contract's own path relative to contracts/,
10
+ e.g. contracts/bronze/sales/cars.yaml -> contracts/spark_schemas/bronze/sales/cars.py.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from datacontract.data_contract import DataContract
19
+
20
+ from fluvv.contract import find_repo_root, load_odcs_document, parse_extension, resolve_extension
21
+
22
+
23
+ @dataclass
24
+ class ExportResult:
25
+ contract_path: Path
26
+ spark_schema_path: Path
27
+ ingestion_config_path: Path
28
+
29
+
30
+ def _relative_stem(contract_path: Path, repo_root: Path) -> Path:
31
+ return contract_path.resolve().relative_to((repo_root / "contracts").resolve()).with_suffix("")
32
+
33
+
34
+ def export_artifacts(contract_path: Path | str, repo_root: Path | str | None = None) -> ExportResult:
35
+ contract_path = Path(contract_path)
36
+ repo_root = Path(repo_root) if repo_root is not None else find_repo_root(contract_path)
37
+ rel_stem = _relative_stem(contract_path, repo_root)
38
+
39
+ data_contract = DataContract(data_contract_file=str(contract_path))
40
+
41
+ spark_schema_path = (repo_root / "contracts" / "spark_schemas" / rel_stem).with_suffix(".py")
42
+ spark_schema_path.parent.mkdir(parents=True, exist_ok=True)
43
+ spark_schema_path.write_text(data_contract.export("spark"))
44
+
45
+ doc = load_odcs_document(contract_path)
46
+ extension = resolve_extension(parse_extension(doc, contract_path), repo_root)
47
+ ingestion_config_path = (repo_root / "contracts" / "ingestion_config" / rel_stem).with_suffix(".json")
48
+ ingestion_config_path.parent.mkdir(parents=True, exist_ok=True)
49
+ ingestion_config_path.write_text(extension.model_dump_json(by_alias=True, indent=2) + "\n")
50
+
51
+ return ExportResult(
52
+ contract_path=contract_path,
53
+ spark_schema_path=spark_schema_path,
54
+ ingestion_config_path=ingestion_config_path,
55
+ )