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 +23 -0
- fluvv/__main__.py +4 -0
- fluvv/cli.py +152 -0
- fluvv/contract.py +122 -0
- fluvv/export.py +55 -0
- fluvv/governance.py +571 -0
- fluvv/lineage.py +154 -0
- fluvv/lint.py +94 -0
- fluvv/manifest.py +239 -0
- fluvv/models.py +98 -0
- fluvv/product.py +102 -0
- fluvv/schema.py +109 -0
- fluvv/schemas/domain.schema.json +29 -0
- fluvv/schemas/fluvv.schema.json +175 -0
- fluvv/schemas/glossary.schema.json +52 -0
- fluvv/schemas/manifest.schema.json +515 -0
- fluvv/schemas/odps-1.1.0.schema.json +717 -0
- fluvv/semantic.py +300 -0
- fluvv-1.0.0.dist-info/METADATA +136 -0
- fluvv-1.0.0.dist-info/RECORD +25 -0
- fluvv-1.0.0.dist-info/WHEEL +5 -0
- fluvv-1.0.0.dist-info/entry_points.txt +2 -0
- fluvv-1.0.0.dist-info/licenses/LICENSE +202 -0
- fluvv-1.0.0.dist-info/licenses/NOTICE +10 -0
- fluvv-1.0.0.dist-info/top_level.txt +1 -0
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
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
|
+
)
|