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.
Files changed (31) hide show
  1. phlo_openmetadata-0.1.0/PKG-INFO +19 -0
  2. phlo_openmetadata-0.1.0/README.md +70 -0
  3. phlo_openmetadata-0.1.0/pyproject.toml +56 -0
  4. phlo_openmetadata-0.1.0/setup.cfg +4 -0
  5. phlo_openmetadata-0.1.0/src/phlo_openmetadata/__init__.py +18 -0
  6. phlo_openmetadata-0.1.0/src/phlo_openmetadata/cli_openmetadata.py +105 -0
  7. phlo_openmetadata-0.1.0/src/phlo_openmetadata/cli_plugin.py +21 -0
  8. phlo_openmetadata-0.1.0/src/phlo_openmetadata/dbt_sync.py +301 -0
  9. phlo_openmetadata-0.1.0/src/phlo_openmetadata/hooks_plugin.py +210 -0
  10. phlo_openmetadata-0.1.0/src/phlo_openmetadata/lineage.py +266 -0
  11. phlo_openmetadata-0.1.0/src/phlo_openmetadata/nessie_sync.py +114 -0
  12. phlo_openmetadata-0.1.0/src/phlo_openmetadata/openmetadata-elasticsearch-setup.yaml +25 -0
  13. phlo_openmetadata-0.1.0/src/phlo_openmetadata/openmetadata-mysql-setup.yaml +27 -0
  14. phlo_openmetadata-0.1.0/src/phlo_openmetadata/openmetadata-setup.yaml +32 -0
  15. phlo_openmetadata-0.1.0/src/phlo_openmetadata/openmetadata.py +768 -0
  16. phlo_openmetadata-0.1.0/src/phlo_openmetadata/plugin.py +29 -0
  17. phlo_openmetadata-0.1.0/src/phlo_openmetadata/quality_sync.py +551 -0
  18. phlo_openmetadata-0.1.0/src/phlo_openmetadata/service.yaml +69 -0
  19. phlo_openmetadata-0.1.0/src/phlo_openmetadata/settings.py +55 -0
  20. phlo_openmetadata-0.1.0/src/phlo_openmetadata.egg-info/PKG-INFO +19 -0
  21. phlo_openmetadata-0.1.0/src/phlo_openmetadata.egg-info/SOURCES.txt +29 -0
  22. phlo_openmetadata-0.1.0/src/phlo_openmetadata.egg-info/dependency_links.txt +1 -0
  23. phlo_openmetadata-0.1.0/src/phlo_openmetadata.egg-info/entry_points.txt +8 -0
  24. phlo_openmetadata-0.1.0/src/phlo_openmetadata.egg-info/requires.txt +10 -0
  25. phlo_openmetadata-0.1.0/src/phlo_openmetadata.egg-info/top_level.txt +1 -0
  26. phlo_openmetadata-0.1.0/tests/test_dbt_sync.py +335 -0
  27. phlo_openmetadata-0.1.0/tests/test_integration_openmetadata.py +32 -0
  28. phlo_openmetadata-0.1.0/tests/test_lineage.py +262 -0
  29. phlo_openmetadata-0.1.0/tests/test_nessie_scanner.py +257 -0
  30. phlo_openmetadata-0.1.0/tests/test_openmetadata_client.py +297 -0
  31. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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