phlo-openmetadata 0.1.0__tar.gz
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.
- phlo_openmetadata-0.1.0/PKG-INFO +19 -0
- phlo_openmetadata-0.1.0/README.md +70 -0
- phlo_openmetadata-0.1.0/pyproject.toml +56 -0
- phlo_openmetadata-0.1.0/setup.cfg +4 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata/__init__.py +18 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata/cli_openmetadata.py +105 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata/cli_plugin.py +21 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata/dbt_sync.py +301 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata/hooks_plugin.py +210 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata/lineage.py +266 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata/nessie_sync.py +114 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata/openmetadata-elasticsearch-setup.yaml +25 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata/openmetadata-mysql-setup.yaml +27 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata/openmetadata-setup.yaml +32 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata/openmetadata.py +768 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata/plugin.py +29 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata/quality_sync.py +551 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata/service.yaml +69 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata/settings.py +55 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata.egg-info/PKG-INFO +19 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata.egg-info/SOURCES.txt +29 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata.egg-info/dependency_links.txt +1 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata.egg-info/entry_points.txt +8 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata.egg-info/requires.txt +10 -0
- phlo_openmetadata-0.1.0/src/phlo_openmetadata.egg-info/top_level.txt +1 -0
- phlo_openmetadata-0.1.0/tests/test_dbt_sync.py +335 -0
- phlo_openmetadata-0.1.0/tests/test_integration_openmetadata.py +32 -0
- phlo_openmetadata-0.1.0/tests/test_lineage.py +262 -0
- phlo_openmetadata-0.1.0/tests/test_nessie_scanner.py +257 -0
- phlo_openmetadata-0.1.0/tests/test_openmetadata_client.py +297 -0
- phlo_openmetadata-0.1.0/tests/test_quality_sync.py +417 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: phlo-openmetadata
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenMetadata integration for Phlo
|
|
5
|
+
Author-email: Phlo Team <team@phlo.dev>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/plain
|
|
9
|
+
Requires-Dist: phlo>=0.1.0
|
|
10
|
+
Requires-Dist: phlo-dbt>=0.1.0
|
|
11
|
+
Requires-Dist: phlo-nessie>=0.1.0
|
|
12
|
+
Requires-Dist: phlo-postgres>=0.1.0
|
|
13
|
+
Requires-Dist: phlo-trino>=0.1.0
|
|
14
|
+
Requires-Dist: pyyaml>=6.0.1
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
17
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
18
|
+
|
|
19
|
+
OpenMetadata integration for Phlo.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# phlo-openmetadata
|
|
2
|
+
|
|
3
|
+
OpenMetadata integration for Phlo.
|
|
4
|
+
|
|
5
|
+
## Description
|
|
6
|
+
|
|
7
|
+
Syncs table metadata, lineage, and quality check results to OpenMetadata for data governance and discovery.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install phlo-openmetadata
|
|
13
|
+
# or
|
|
14
|
+
phlo plugin install openmetadata
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Configuration
|
|
18
|
+
|
|
19
|
+
| Variable | Default | Description |
|
|
20
|
+
| ------------------------------------ | --------------------- | -------------------------- |
|
|
21
|
+
| `OPENMETADATA_HOST` | `openmetadata-server` | OpenMetadata server host |
|
|
22
|
+
| `OPENMETADATA_PORT` | `8585` | OpenMetadata API port |
|
|
23
|
+
| `OPENMETADATA_USERNAME` | `admin` | Admin username |
|
|
24
|
+
| `OPENMETADATA_PASSWORD` | `admin` | Admin password |
|
|
25
|
+
| `OPENMETADATA_VERIFY_SSL` | `false` | Verify SSL certificates |
|
|
26
|
+
| `OPENMETADATA_SYNC_ENABLED` | `true` | Enable automatic sync |
|
|
27
|
+
| `OPENMETADATA_SYNC_INTERVAL_SECONDS` | `300` | Min interval between syncs |
|
|
28
|
+
|
|
29
|
+
## Auto-Configuration
|
|
30
|
+
|
|
31
|
+
This package is **fully auto-configured**:
|
|
32
|
+
|
|
33
|
+
| Feature | How It Works |
|
|
34
|
+
| --------------------- | ---------------------------------------------------------------- |
|
|
35
|
+
| **Hook Registration** | Receives `lineage.edges`, `quality.result`, `publish.end` events |
|
|
36
|
+
| **Lineage Sync** | Automatically syncs lineage edges to OpenMetadata |
|
|
37
|
+
| **Quality Results** | Syncs quality check results as test cases |
|
|
38
|
+
| **Table Metadata** | Syncs published tables with documentation |
|
|
39
|
+
|
|
40
|
+
### Event Flow
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
Pipeline Events → HookBus → OpenMetadataHookPlugin → OpenMetadata API
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
### CLI Commands
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Manually sync all tables
|
|
52
|
+
phlo openmetadata sync
|
|
53
|
+
|
|
54
|
+
# Sync specific table
|
|
55
|
+
phlo openmetadata sync --table bronze.users
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Programmatic
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from phlo_openmetadata.client import OpenMetadataClient
|
|
62
|
+
|
|
63
|
+
client = OpenMetadataClient()
|
|
64
|
+
client.sync_table_metadata("bronze.users")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Entry Points
|
|
68
|
+
|
|
69
|
+
- `phlo.plugins.cli` - Provides `openmetadata` CLI commands
|
|
70
|
+
- `phlo.plugins.hooks` - Provides `OpenMetadataHookPlugin` for sync
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=45", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "phlo-openmetadata"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "OpenMetadata integration for Phlo"
|
|
9
|
+
readme = {text = "OpenMetadata integration for Phlo.", content-type = "text/plain"}
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
authors = [
|
|
12
|
+
{name = "Phlo Team", email = "team@phlo.dev"},
|
|
13
|
+
]
|
|
14
|
+
license = {text = "MIT"}
|
|
15
|
+
dependencies = [
|
|
16
|
+
"phlo>=0.1.0",
|
|
17
|
+
"phlo-dbt>=0.1.0",
|
|
18
|
+
"phlo-nessie>=0.1.0",
|
|
19
|
+
"phlo-postgres>=0.1.0",
|
|
20
|
+
"phlo-trino>=0.1.0",
|
|
21
|
+
"pyyaml>=6.0.1",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
dev = [
|
|
26
|
+
"pytest>=7.0",
|
|
27
|
+
"ruff>=0.1.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.entry-points."phlo.plugins.cli"]
|
|
31
|
+
openmetadata = "phlo_openmetadata.cli_plugin:OpenMetadataCliPlugin"
|
|
32
|
+
|
|
33
|
+
[project.entry-points."phlo.plugins.hooks"]
|
|
34
|
+
openmetadata = "phlo_openmetadata.hooks_plugin:OpenMetadataHookPlugin"
|
|
35
|
+
|
|
36
|
+
[project.entry-points."phlo.plugins.services"]
|
|
37
|
+
openmetadata = "phlo_openmetadata.plugin:OpenMetadataServicePlugin"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools]
|
|
40
|
+
package-dir = {"" = "src"}
|
|
41
|
+
include-package-data = true
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.packages.find]
|
|
44
|
+
where = ["src"]
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.package-data]
|
|
47
|
+
phlo_openmetadata = [
|
|
48
|
+
"service.yaml",
|
|
49
|
+
"openmetadata-setup.yaml",
|
|
50
|
+
"openmetadata-mysql-setup.yaml",
|
|
51
|
+
"openmetadata-elasticsearch-setup.yaml",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[tool.ruff]
|
|
55
|
+
line-length = 100
|
|
56
|
+
target-version = "py311"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from phlo_openmetadata.dbt_sync import DbtManifestParser
|
|
2
|
+
from phlo_openmetadata.openmetadata import (
|
|
3
|
+
OpenMetadataClient,
|
|
4
|
+
OpenMetadataColumn,
|
|
5
|
+
OpenMetadataLineageEdge,
|
|
6
|
+
OpenMetadataTable,
|
|
7
|
+
)
|
|
8
|
+
from phlo_openmetadata.settings import OpenMetadataSettings, get_settings
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"DbtManifestParser",
|
|
12
|
+
"OpenMetadataSettings",
|
|
13
|
+
"OpenMetadataClient",
|
|
14
|
+
"OpenMetadataColumn",
|
|
15
|
+
"OpenMetadataLineageEdge",
|
|
16
|
+
"OpenMetadataTable",
|
|
17
|
+
"get_settings",
|
|
18
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""OpenMetadata CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from phlo_openmetadata.dbt_sync import DbtManifestParser
|
|
12
|
+
from phlo_openmetadata.nessie_sync import sync_nessie_tables_to_openmetadata
|
|
13
|
+
from phlo_openmetadata.openmetadata import OpenMetadataClient
|
|
14
|
+
from phlo_openmetadata.settings import get_settings
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group()
|
|
20
|
+
def openmetadata():
|
|
21
|
+
"""Manage OpenMetadata integration (optional)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@openmetadata.command()
|
|
25
|
+
def health() -> None:
|
|
26
|
+
"""Check OpenMetadata connectivity using configured credentials."""
|
|
27
|
+
cfg = get_settings()
|
|
28
|
+
client = OpenMetadataClient(
|
|
29
|
+
base_url=cfg.openmetadata_uri(),
|
|
30
|
+
username=cfg.openmetadata_username,
|
|
31
|
+
password=cfg.openmetadata_password,
|
|
32
|
+
verify_ssl=cfg.openmetadata_verify_ssl,
|
|
33
|
+
timeout=10,
|
|
34
|
+
service_name=cfg.openmetadata_service_name,
|
|
35
|
+
service_type=cfg.openmetadata_service_type,
|
|
36
|
+
database_name=cfg.openmetadata_database(),
|
|
37
|
+
)
|
|
38
|
+
ok = client.health_check()
|
|
39
|
+
if ok:
|
|
40
|
+
console.print("[green]OpenMetadata is reachable[/green]")
|
|
41
|
+
return
|
|
42
|
+
console.print("[red]OpenMetadata is not reachable[/red]")
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@openmetadata.command()
|
|
47
|
+
@click.option("--include-namespace", multiple=True, help="Only sync these namespaces (repeatable)")
|
|
48
|
+
@click.option("--exclude-namespace", multiple=True, help="Skip these namespaces (repeatable)")
|
|
49
|
+
@click.option("--dbt/--no-dbt", default=True, help="Also sync dbt models (if manifest exists)")
|
|
50
|
+
@click.option("--dbt-schema", default=None, help="Limit dbt sync to a schema (e.g., bronze)")
|
|
51
|
+
def sync(
|
|
52
|
+
include_namespace: tuple[str, ...],
|
|
53
|
+
exclude_namespace: tuple[str, ...],
|
|
54
|
+
dbt: bool,
|
|
55
|
+
dbt_schema: str | None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Sync Nessie catalog (and optionally dbt docs) into OpenMetadata."""
|
|
58
|
+
cfg = get_settings()
|
|
59
|
+
client = OpenMetadataClient(
|
|
60
|
+
base_url=cfg.openmetadata_uri(),
|
|
61
|
+
username=cfg.openmetadata_username,
|
|
62
|
+
password=cfg.openmetadata_password,
|
|
63
|
+
verify_ssl=cfg.openmetadata_verify_ssl,
|
|
64
|
+
timeout=30,
|
|
65
|
+
service_name=cfg.openmetadata_service_name,
|
|
66
|
+
service_type=cfg.openmetadata_service_type,
|
|
67
|
+
database_name=cfg.openmetadata_database(),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if not client.health_check():
|
|
71
|
+
console.print("[red]OpenMetadata is not reachable[/red]")
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
|
|
74
|
+
from phlo_nessie.catalog_scanner import NessieTableScanner
|
|
75
|
+
|
|
76
|
+
scanner = NessieTableScanner.from_config()
|
|
77
|
+
nessie_stats = sync_nessie_tables_to_openmetadata(
|
|
78
|
+
scanner,
|
|
79
|
+
client,
|
|
80
|
+
include_namespaces=list(include_namespace) or None,
|
|
81
|
+
exclude_namespaces=list(exclude_namespace) or None,
|
|
82
|
+
)
|
|
83
|
+
console.print(f"[green]Nessie sync[/green]: {nessie_stats}")
|
|
84
|
+
|
|
85
|
+
if dbt:
|
|
86
|
+
try:
|
|
87
|
+
parser = DbtManifestParser.from_config()
|
|
88
|
+
if dbt_schema:
|
|
89
|
+
dbt_stats = parser.sync_to_openmetadata(client, schema_name=dbt_schema)
|
|
90
|
+
else:
|
|
91
|
+
# Default to syncing schemas present in manifest by iterating models.
|
|
92
|
+
manifest = parser.load_manifest()
|
|
93
|
+
schemas = {m.get("schema") for m in parser.get_models(manifest).values()}
|
|
94
|
+
schemas = {s for s in schemas if isinstance(s, str)}
|
|
95
|
+
dbt_stats = {"created": 0, "failed": 0}
|
|
96
|
+
for schema_name in sorted(schemas):
|
|
97
|
+
partial = parser.sync_to_openmetadata(client, schema_name=schema_name)
|
|
98
|
+
dbt_stats["created"] += partial.get("created", 0)
|
|
99
|
+
dbt_stats["failed"] += partial.get("failed", 0)
|
|
100
|
+
console.print(f"[green]dbt sync[/green]: {dbt_stats}")
|
|
101
|
+
except FileNotFoundError:
|
|
102
|
+
console.print("[yellow]dbt manifest not found; skipping dbt sync[/yellow]")
|
|
103
|
+
except json.JSONDecodeError as e:
|
|
104
|
+
console.print(f"[red]Failed to parse dbt manifest: {e}[/red]")
|
|
105
|
+
sys.exit(1)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""CLI plugin for OpenMetadata integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from phlo.plugins.base import CliCommandPlugin, PluginMetadata
|
|
7
|
+
|
|
8
|
+
from phlo_openmetadata.cli_openmetadata import openmetadata
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OpenMetadataCliPlugin(CliCommandPlugin):
|
|
12
|
+
@property
|
|
13
|
+
def metadata(self) -> PluginMetadata:
|
|
14
|
+
return PluginMetadata(
|
|
15
|
+
name="openmetadata",
|
|
16
|
+
version="0.1.0",
|
|
17
|
+
description="CLI commands for OpenMetadata synchronization",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def get_cli_commands(self) -> list[click.Command]:
|
|
21
|
+
return [openmetadata]
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dbt manifest parser and synchronizer.
|
|
3
|
+
|
|
4
|
+
Parses dbt manifest.json and catalog.json to extract model documentation,
|
|
5
|
+
column descriptions, and tests for syncing to OpenMetadata.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
from phlo.logging import get_logger
|
|
15
|
+
|
|
16
|
+
from phlo_openmetadata.openmetadata import OpenMetadataColumn, OpenMetadataTable
|
|
17
|
+
from phlo_dbt.settings import get_settings
|
|
18
|
+
|
|
19
|
+
logger = get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DbtManifestParser:
|
|
23
|
+
"""
|
|
24
|
+
Parses dbt manifest.json for metadata extraction.
|
|
25
|
+
|
|
26
|
+
Extracts model descriptions, column-level documentation, tests,
|
|
27
|
+
and freshness policies for syncing to OpenMetadata.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, manifest_path: str, catalog_path: Optional[str] = None):
|
|
31
|
+
"""
|
|
32
|
+
Initialize dbt manifest parser.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
manifest_path: Path to dbt manifest.json
|
|
36
|
+
catalog_path: Path to dbt catalog.json (optional, for column docs)
|
|
37
|
+
"""
|
|
38
|
+
self.manifest_path = Path(manifest_path)
|
|
39
|
+
self.catalog_path = Path(catalog_path) if catalog_path else None
|
|
40
|
+
self.manifest = None
|
|
41
|
+
self.catalog = None
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_config(cls) -> DbtManifestParser:
|
|
45
|
+
"""Create parser from application config."""
|
|
46
|
+
config = get_settings()
|
|
47
|
+
return cls(
|
|
48
|
+
manifest_path=config.dbt_manifest_path,
|
|
49
|
+
catalog_path=config.dbt_catalog_path,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def load_manifest(self) -> dict[str, Any]:
|
|
53
|
+
"""
|
|
54
|
+
Load and parse dbt manifest.json.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Parsed manifest dictionary
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
FileNotFoundError: If manifest file not found
|
|
61
|
+
json.JSONDecodeError: If manifest is invalid JSON
|
|
62
|
+
"""
|
|
63
|
+
if not self.manifest_path.exists():
|
|
64
|
+
raise FileNotFoundError(f"dbt manifest not found: {self.manifest_path}")
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
with open(self.manifest_path) as f:
|
|
68
|
+
self.manifest = json.load(f)
|
|
69
|
+
logger.info(f"Loaded dbt manifest from {self.manifest_path}")
|
|
70
|
+
return self.manifest
|
|
71
|
+
except json.JSONDecodeError as e:
|
|
72
|
+
logger.error(f"Invalid JSON in manifest: {e}")
|
|
73
|
+
raise
|
|
74
|
+
|
|
75
|
+
def load_catalog(self) -> dict[str, Any]:
|
|
76
|
+
"""
|
|
77
|
+
Load and parse dbt catalog.json for column documentation.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Parsed catalog dictionary, or empty dict if not found
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
json.JSONDecodeError: If catalog is invalid JSON
|
|
84
|
+
"""
|
|
85
|
+
if not self.catalog_path or not self.catalog_path.exists():
|
|
86
|
+
logger.warning(
|
|
87
|
+
f"dbt catalog not found at {self.catalog_path}, "
|
|
88
|
+
"column-level docs will not be available"
|
|
89
|
+
)
|
|
90
|
+
return {}
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
with open(self.catalog_path) as f:
|
|
94
|
+
self.catalog = json.load(f)
|
|
95
|
+
logger.info(f"Loaded dbt catalog from {self.catalog_path}")
|
|
96
|
+
return self.catalog
|
|
97
|
+
except json.JSONDecodeError as e:
|
|
98
|
+
logger.error(f"Invalid JSON in catalog: {e}")
|
|
99
|
+
raise
|
|
100
|
+
|
|
101
|
+
def get_models(self, manifest: Optional[dict[str, Any]] = None) -> dict[str, dict[str, Any]]:
|
|
102
|
+
"""
|
|
103
|
+
Extract all models from manifest.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
manifest: Parsed manifest dict (uses loaded manifest if not provided)
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Dictionary mapping model unique_id to model metadata
|
|
110
|
+
"""
|
|
111
|
+
if manifest is None:
|
|
112
|
+
manifest = self.manifest or self.load_manifest()
|
|
113
|
+
|
|
114
|
+
models = {}
|
|
115
|
+
for unique_id, model in manifest.get("nodes", {}).items():
|
|
116
|
+
if unique_id.startswith("model."):
|
|
117
|
+
models[unique_id] = model
|
|
118
|
+
logger.debug(f"Found model: {model.get('name')}")
|
|
119
|
+
|
|
120
|
+
return models
|
|
121
|
+
|
|
122
|
+
def get_model_columns(
|
|
123
|
+
self,
|
|
124
|
+
model_name: str,
|
|
125
|
+
schema_name: str,
|
|
126
|
+
catalog: Optional[dict[str, Any]] = None,
|
|
127
|
+
) -> dict[str, dict[str, Any]]:
|
|
128
|
+
"""
|
|
129
|
+
Get column information for a model from catalog.json.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
model_name: Model name
|
|
133
|
+
schema_name: Schema name
|
|
134
|
+
catalog: Parsed catalog dict (uses loaded catalog if not provided)
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Dictionary mapping column name to column metadata
|
|
138
|
+
"""
|
|
139
|
+
if catalog is None:
|
|
140
|
+
catalog = self.catalog or self.load_catalog()
|
|
141
|
+
|
|
142
|
+
if not catalog:
|
|
143
|
+
return {}
|
|
144
|
+
|
|
145
|
+
def normalize_columns(columns: Any) -> dict[str, dict[str, Any]]:
|
|
146
|
+
if isinstance(columns, dict):
|
|
147
|
+
return columns
|
|
148
|
+
if isinstance(columns, list):
|
|
149
|
+
normalized: dict[str, dict[str, Any]] = {}
|
|
150
|
+
for entry in columns:
|
|
151
|
+
if not isinstance(entry, dict):
|
|
152
|
+
continue
|
|
153
|
+
name = entry.get("name")
|
|
154
|
+
if isinstance(name, str) and name:
|
|
155
|
+
normalized[name] = entry
|
|
156
|
+
return normalized
|
|
157
|
+
return {}
|
|
158
|
+
|
|
159
|
+
if isinstance(catalog.get("nodes"), dict):
|
|
160
|
+
nodes: dict[str, Any] = catalog.get("nodes", {})
|
|
161
|
+
for node in nodes.values():
|
|
162
|
+
if not isinstance(node, dict):
|
|
163
|
+
continue
|
|
164
|
+
metadata = node.get("metadata") or {}
|
|
165
|
+
if not isinstance(metadata, dict):
|
|
166
|
+
continue
|
|
167
|
+
if metadata.get("name") != model_name or metadata.get("schema") != schema_name:
|
|
168
|
+
continue
|
|
169
|
+
return normalize_columns(node.get("columns"))
|
|
170
|
+
|
|
171
|
+
return {}
|
|
172
|
+
|
|
173
|
+
key = f"{schema_name}.{model_name}"
|
|
174
|
+
model_entry = catalog.get(key, {})
|
|
175
|
+
return (
|
|
176
|
+
normalize_columns(model_entry.get("columns")) if isinstance(model_entry, dict) else {}
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def get_model_tests(
|
|
180
|
+
self,
|
|
181
|
+
model_unique_id: str,
|
|
182
|
+
manifest: Optional[dict[str, Any]] = None,
|
|
183
|
+
) -> list[dict[str, Any]]:
|
|
184
|
+
"""
|
|
185
|
+
Extract tests associated with a model.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
model_unique_id: Model unique_id (e.g., model.project.table)
|
|
189
|
+
manifest: Parsed manifest dict
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
List of test metadata dicts
|
|
193
|
+
"""
|
|
194
|
+
if manifest is None:
|
|
195
|
+
manifest = self.manifest or self.load_manifest()
|
|
196
|
+
|
|
197
|
+
tests = []
|
|
198
|
+
for unique_id, node in manifest.get("nodes", {}).items():
|
|
199
|
+
if unique_id.startswith("test.") and "test_metadata" in node:
|
|
200
|
+
depends = node.get("depends_on", {}).get("nodes", [])
|
|
201
|
+
if model_unique_id in depends:
|
|
202
|
+
tests.append(node)
|
|
203
|
+
return tests
|
|
204
|
+
|
|
205
|
+
def extract_openmetadata_table(
|
|
206
|
+
self,
|
|
207
|
+
model: dict[str, Any],
|
|
208
|
+
schema_name: str,
|
|
209
|
+
columns_info: Optional[dict[str, Any]] = None,
|
|
210
|
+
) -> OpenMetadataTable:
|
|
211
|
+
"""
|
|
212
|
+
Convert dbt model metadata to OpenMetadataTable format.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
model: dbt model metadata
|
|
216
|
+
schema_name: Schema name
|
|
217
|
+
columns_info: Optional column info from catalog.json
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
OpenMetadataTable object
|
|
221
|
+
"""
|
|
222
|
+
name = model.get("name", "unknown")
|
|
223
|
+
description = model.get("description")
|
|
224
|
+
|
|
225
|
+
columns = []
|
|
226
|
+
model_columns = model.get("columns", {}) or {}
|
|
227
|
+
|
|
228
|
+
for idx, (col_name, col_meta) in enumerate(model_columns.items()):
|
|
229
|
+
col_desc = col_meta.get("description")
|
|
230
|
+
data_type = "UNKNOWN"
|
|
231
|
+
|
|
232
|
+
if columns_info and col_name in columns_info:
|
|
233
|
+
data_type = columns_info[col_name].get("type", "UNKNOWN")
|
|
234
|
+
|
|
235
|
+
columns.append(
|
|
236
|
+
OpenMetadataColumn(
|
|
237
|
+
name=col_name,
|
|
238
|
+
description=col_desc,
|
|
239
|
+
dataType=data_type,
|
|
240
|
+
ordinalPosition=idx,
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
tags = []
|
|
245
|
+
for tag in model.get("tags", []) or []:
|
|
246
|
+
tags.append({"name": tag})
|
|
247
|
+
|
|
248
|
+
freshness = model.get("freshness")
|
|
249
|
+
if freshness and isinstance(freshness, dict):
|
|
250
|
+
warn_after = freshness.get("warn_after", {})
|
|
251
|
+
if isinstance(warn_after, dict):
|
|
252
|
+
count = warn_after.get("count")
|
|
253
|
+
period = warn_after.get("period")
|
|
254
|
+
if count and period:
|
|
255
|
+
tags.append({"name": f"freshness_warn_after_{count}_{period}"})
|
|
256
|
+
|
|
257
|
+
return OpenMetadataTable(
|
|
258
|
+
name=name,
|
|
259
|
+
description=description,
|
|
260
|
+
columns=columns if columns else None,
|
|
261
|
+
tags=tags if tags else None,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def sync_to_openmetadata(
|
|
265
|
+
self,
|
|
266
|
+
om_client: Any, # OpenMetadataClient
|
|
267
|
+
schema_name: str,
|
|
268
|
+
model_filter: Optional[list[str]] = None,
|
|
269
|
+
) -> dict[str, int]:
|
|
270
|
+
"""
|
|
271
|
+
Sync dbt models to OpenMetadata.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
om_client: OpenMetadataClient instance
|
|
275
|
+
schema_name: Target OpenMetadata schema name
|
|
276
|
+
model_filter: Optional list of dbt model names to sync
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Stats dict with created/failed counts
|
|
280
|
+
"""
|
|
281
|
+
stats = {"created": 0, "failed": 0}
|
|
282
|
+
|
|
283
|
+
manifest = self.load_manifest()
|
|
284
|
+
catalog = self.load_catalog()
|
|
285
|
+
|
|
286
|
+
models = self.get_models(manifest)
|
|
287
|
+
for unique_id, model in models.items():
|
|
288
|
+
model_name = model.get("name")
|
|
289
|
+
if model_filter and model_name not in model_filter:
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
columns_info = self.get_model_columns(model_name, schema_name, catalog)
|
|
294
|
+
om_table = self.extract_openmetadata_table(model, schema_name, columns_info)
|
|
295
|
+
om_client.create_or_update_table(schema_name, om_table)
|
|
296
|
+
stats["created"] += 1
|
|
297
|
+
except Exception as e:
|
|
298
|
+
logger.error(f"Failed to sync model {model_name}: {e}")
|
|
299
|
+
stats["failed"] += 1
|
|
300
|
+
|
|
301
|
+
return stats
|