Skip to content

Commit 242f407

Browse files
committed
refactor: scope CSV updates through asset facades
1 parent 1a58833 commit 242f407

4 files changed

Lines changed: 251 additions & 222 deletions

File tree

dynamic_csv_importer/README.md

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
> This example targets `openmetadata-ingestion==2.0.0.0rc1`. It updates existing assets and does
55
> not create entities or columns.
66
7-
The importer matches the `column.name*` field from an OpenMetadata CSV export against columns on
8-
tables and dashboard data models. It can update column descriptions and display names or run in a
9-
report-only mode.
7+
The importer matches the `column.name*` field from an OpenMetadata CSV export
8+
against columns on explicitly named tables and dashboard data models. It can
9+
update column descriptions and display names or build the same plan in
10+
report-only mode. It never scans the catalog.
1011

1112
## Run the Example
1213

@@ -19,14 +20,17 @@ export OPENMETADATA_HOST="http://localhost:8585/api"
1920
export OPENMETADATA_JWT_TOKEN="<personal-access-token>"
2021
python -m dynamic_csv_importer.csv_importer \
2122
--csv-path dynamic_csv_importer/sample_dbt_jaffle.csv \
22-
--entities all \
23+
--table-fqn sample.database.schema.customers \
24+
--dashboard-data-model-fqn sample_dashboard.customer_model \
2325
--report-only
2426
```
2527

26-
Remove `--report-only` after reviewing the match counts. Override the environment-based connection
27-
settings with `--url` and `--jwt-token` when needed.
28+
Both FQN flags are repeatable, and at least one is required. The explicit FQNs
29+
are the authorization boundary: only those named assets can be updated. Remove
30+
`--report-only` after reviewing the counts; live mutation is the default.
2831

29-
Supported `--entities` values are `all`, `Table`, and `DashboardDataModel`.
32+
Credentials are read only from `OPENMETADATA_HOST` and
33+
`OPENMETADATA_JWT_TOKEN`. There are no URL or token CLI flags.
3034

3135
## CSV Columns
3236

@@ -39,5 +43,17 @@ remaining fields make the input compatible with a normal OpenMetadata export.
3943

4044
## Validation Boundary
4145

42-
Repository tests validate argument handling, CSV parsing, matching, and request-model compatibility
43-
without connecting to OpenMetadata.
46+
The complete CSV and every target are loaded before the first update. Invalid
47+
CSV rows or a missing target abort the run before mutation. Each destination is
48+
a deep copy and is sent through `Tables.update()` or
49+
`DashboardDataModels.update()`.
50+
51+
There is a normal concurrency window between the initial reads and facade
52+
updates. Each facade update re-reads the current entity before creating its JSON
53+
Patch, but the planned destination came from the earlier snapshot. Use
54+
`--report-only`, rerun against disposable targets first, and avoid concurrent
55+
edits to the same columns during a live import.
56+
57+
Repository tests validate arguments, full CSV parsing, deep-copy changes,
58+
preflight ordering, and mocked facade updates without connecting to
59+
OpenMetadata.

dynamic_csv_importer/csv_importer.py

Lines changed: 90 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,23 @@
1-
"""Update existing OpenMetadata column descriptions and display names from CSV."""
1+
"""Update named OpenMetadata assets from a fully parsed column CSV."""
2+
3+
from __future__ import annotations
24

35
import argparse
46
import csv
57
import logging
68
import os
7-
from collections.abc import Generator, Sequence
9+
from collections.abc import Mapping, Sequence
810
from dataclasses import dataclass
9-
from enum import Enum
1011
from pathlib import Path
12+
from typing import Any
1113

12-
from metadata.generated.schema.entity.data.dashboardDataModel import (
13-
DashboardDataModel,
14-
)
15-
from metadata.generated.schema.entity.data.table import Table
16-
from metadata.generated.schema.entity.services.connections.metadata.openMetadataConnection import ( # noqa: E501
17-
AuthProvider,
18-
OpenMetadataConnection,
19-
)
20-
from metadata.generated.schema.security.client.openMetadataJWTClientConfig import (
21-
OpenMetadataJWTClientConfig,
22-
)
2314
from metadata.generated.schema.type.basic import Markdown
24-
from metadata.ingestion.ometa.ometa_api import OpenMetadata
2515
from metadata.ingestion.ometa.utils import model_str
16+
from metadata.sdk import DashboardDataModels, Tables, configure, reset
2617
from pydantic import BaseModel, ConfigDict, Field, ValidationError
2718

2819
logger = logging.getLogger(__name__)
29-
30-
ALL_ENTITIES = [Table, DashboardDataModel]
31-
32-
33-
class SupportedEntities(Enum):
34-
"""Entity collections supported by the importer."""
35-
36-
ALL = "all"
37-
TABLES = Table.__name__
38-
DASHBOARD_DATA_MODEL = DashboardDataModel.__name__
20+
REQUIRED_ENV = ("OPENMETADATA_HOST", "OPENMETADATA_JWT_TOKEN")
3921

4022

4123
class CSVColumnSchema(BaseModel):
@@ -65,39 +47,42 @@ class ProcessSummary:
6547

6648

6749
def parse_arguments(argv: Sequence[str] | None = None) -> argparse.Namespace:
68-
"""Parse CLI options, using standard OpenMetadata environment variables."""
69-
host = os.environ.get("OPENMETADATA_HOST")
70-
token = os.environ.get("OPENMETADATA_JWT_TOKEN")
50+
"""Parse explicit asset targets without accepting credentials on the CLI."""
7151
parser = argparse.ArgumentParser(
72-
description="Import column metadata from CSV into OpenMetadata"
52+
description="Import column metadata into explicitly named OpenMetadata assets"
7353
)
7454
parser.add_argument(
7555
"--csv-path", required=True, help="Path to the CSV file containing metadata"
7656
)
7757
parser.add_argument(
78-
"--entities",
79-
choices=[entity.value for entity in SupportedEntities],
80-
default=SupportedEntities.ALL.value,
81-
help="Entity types to process (default: all)",
82-
)
83-
parser.add_argument(
84-
"--url",
85-
default=host,
86-
required=host is None,
87-
help="OpenMetadata API URL; defaults to OPENMETADATA_HOST",
58+
"--table-fqn",
59+
action="append",
60+
default=[],
61+
help="Table FQN to update; repeat for multiple tables",
8862
)
8963
parser.add_argument(
90-
"--jwt-token",
91-
default=token,
92-
required=token is None,
93-
help="JWT token; defaults to OPENMETADATA_JWT_TOKEN",
64+
"--dashboard-data-model-fqn",
65+
action="append",
66+
default=[],
67+
help="Dashboard data model FQN to update; repeat for multiple models",
9468
)
9569
parser.add_argument(
9670
"--report-only",
9771
action="store_true",
98-
help="Report matches without updating entities",
72+
help="Build and report the update plan without mutating assets",
9973
)
100-
return parser.parse_args(argv)
74+
args = parser.parse_args(argv)
75+
if not args.table_fqn and not args.dashboard_data_model_fqn:
76+
parser.error("at least one --table-fqn or --dashboard-data-model-fqn is required")
77+
return args
78+
79+
80+
def validate_environment(environ: Mapping[str, str] | None = None) -> None:
81+
"""Require standard credentials before configuring the SDK."""
82+
values = os.environ if environ is None else environ
83+
missing = [name for name in REQUIRED_ENV if not values.get(name)]
84+
if missing:
85+
raise ValueError(f"Missing required environment variables: {', '.join(missing)}")
10186

10287

10388
def clean_column_name(column_name: str) -> str:
@@ -108,54 +93,25 @@ def clean_column_name(column_name: str) -> str:
10893

10994

11095
def load_csv_data(csv_path: str | Path) -> dict[str, CSVColumnSchema]:
111-
"""Load valid rows from an OpenMetadata column CSV export."""
96+
"""Parse and validate the complete CSV before any server request."""
11297
rows: dict[str, CSVColumnSchema] = {}
98+
failures: list[int] = []
11399
with Path(csv_path).open(encoding="utf-8", newline="") as file:
114100
for row_number, raw_row in enumerate(csv.DictReader(file), 2):
115101
try:
116102
row = CSVColumnSchema.model_validate(raw_row)
117-
except ValidationError as exc:
118-
logger.warning("Skipping invalid CSV row %d: %s", row_number, exc)
103+
except ValidationError:
104+
failures.append(row_number)
119105
continue
120106
row.column_name = clean_column_name(row.column_name)
121107
rows[row.column_name] = row
108+
if failures:
109+
lines = ", ".join(str(number) for number in failures)
110+
raise ValueError(f"Invalid CSV rows: {lines}")
122111
return rows
123112

124113

125-
def initialize_ometa_client(url: str, jwt_token: str) -> OpenMetadata:
126-
"""Construct the OpenMetadata 2.0 client without performing API calls."""
127-
config = OpenMetadataConnection(
128-
hostPort=url,
129-
authProvider=AuthProvider.openmetadata,
130-
securityConfig=OpenMetadataJWTClientConfig(jwtToken=jwt_token),
131-
)
132-
return OpenMetadata(config=config)
133-
134-
135-
def _iter_entity_type(ometa: OpenMetadata, entity_type: type) -> Generator:
136-
"""Yield one entity type through the paginated API."""
137-
after = None
138-
while True:
139-
page = ometa.list_entities(entity=entity_type, limit=100, after=after)
140-
yield from page.entities
141-
if not page.after:
142-
return
143-
after = page.after
144-
145-
146-
def get_entities_to_process(ometa: OpenMetadata, entities: str) -> Generator:
147-
"""Yield the selected supported entities."""
148-
if entities == SupportedEntities.ALL.value:
149-
entity_types = ALL_ENTITIES
150-
elif entities == SupportedEntities.TABLES.value:
151-
entity_types = [Table]
152-
else:
153-
entity_types = [DashboardDataModel]
154-
for entity_type in entity_types:
155-
yield from _iter_entity_type(ometa, entity_type)
156-
157-
158-
def matching_rows(entity, csv_data: dict[str, CSVColumnSchema]) -> list[CSVColumnSchema]:
114+
def matching_rows(entity: Any, csv_data: dict[str, CSVColumnSchema]) -> list[CSVColumnSchema]:
159115
"""Return CSV rows that match columns on an entity."""
160116
if not getattr(entity, "columns", None):
161117
return []
@@ -166,8 +122,8 @@ def matching_rows(entity, csv_data: dict[str, CSVColumnSchema]) -> list[CSVColum
166122
]
167123

168124

169-
def build_updated_entity(entity, matches: list[CSVColumnSchema]):
170-
"""Return an updated entity copy, or None when no value changes."""
125+
def build_updated_entity(entity: Any, matches: list[CSVColumnSchema]) -> Any | None:
126+
"""Return a deep-copied destination, or None when no value changes."""
171127
updated_entity = entity.model_copy(deep=True)
172128
updates_made = False
173129
rows_by_name = {row.column_name: row for row in matches}
@@ -190,44 +146,70 @@ def build_updated_entity(entity, matches: list[CSVColumnSchema]):
190146
return updated_entity if updates_made else None
191147

192148

149+
def _unique(values: Sequence[str]) -> list[str]:
150+
return list(dict.fromkeys(values))
151+
152+
193153
def process_entities(
194-
ometa: OpenMetadata,
195-
entities: str,
154+
table_fqns: Sequence[str],
155+
dashboard_data_model_fqns: Sequence[str],
196156
csv_data: dict[str, CSVColumnSchema],
197157
report_only: bool = False,
198158
) -> ProcessSummary:
199-
"""Match and optionally patch entities, returning deterministic counts."""
200-
processed = matched = total_matches = updated = 0
201-
for entity in get_entities_to_process(ometa, entities):
202-
processed += 1
159+
"""Retrieve every authorized target, build all changes, then update."""
160+
targets = [(Tables, fqn) for fqn in _unique(table_fqns)]
161+
targets += [(DashboardDataModels, fqn) for fqn in _unique(dashboard_data_model_fqns)]
162+
retrieved: list[tuple[Any, Any]] = []
163+
for facade, fqn in targets:
164+
entity = facade.retrieve_by_name(fqn, nullable=True)
165+
if entity is None:
166+
raise RuntimeError(f"Target not found: {fqn}")
167+
retrieved.append((facade, entity))
168+
169+
matched = total_matches = 0
170+
planned: list[tuple[Any, Any]] = []
171+
for facade, entity in retrieved:
203172
matches = matching_rows(entity, csv_data)
204173
if not matches:
205174
continue
206175
matched += 1
207176
total_matches += len(matches)
208177
destination = build_updated_entity(entity, matches)
209-
if not report_only and destination is not None:
210-
result = ometa.patch(
211-
entity=type(entity),
212-
source=entity,
213-
destination=destination,
214-
skip_on_failure=False,
215-
)
216-
if result is not None:
178+
if destination is not None:
179+
planned.append((facade, destination))
180+
181+
updated = 0
182+
if not report_only:
183+
for facade, destination in planned:
184+
if facade.update(destination) is not None:
217185
updated += 1
218-
return ProcessSummary(processed, matched, total_matches, updated)
186+
return ProcessSummary(len(retrieved), matched, total_matches, updated)
219187

220188

221189
def main(argv: Sequence[str] | None = None) -> int:
222-
"""Run the importer CLI."""
223-
logging.basicConfig(
224-
level=logging.INFO,
225-
format="[%(asctime)s] %(levelname)-8s - %(message)s",
226-
)
227-
args = parse_arguments(argv)
228-
csv_data = load_csv_data(args.csv_path)
229-
client = initialize_ometa_client(args.url, args.jwt_token)
230-
summary = process_entities(client, args.entities, csv_data, args.report_only)
190+
"""Run the importer with environment-only credentials."""
191+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
192+
try:
193+
args = parse_arguments(argv)
194+
validate_environment()
195+
csv_data = load_csv_data(args.csv_path)
196+
configure()
197+
try:
198+
summary = process_entities(
199+
args.table_fqn,
200+
args.dashboard_data_model_fqn,
201+
csv_data,
202+
args.report_only,
203+
)
204+
finally:
205+
reset()
206+
except (ValueError, RuntimeError) as exc:
207+
logger.error("CSV import failed: %s", exc)
208+
return 1
209+
except Exception as exc:
210+
logger.error("CSV import failed: %s", type(exc).__name__)
211+
return 1
212+
231213
logger.info("Entities processed: %d", summary.entities_processed)
232214
logger.info("Entities with matches: %d", summary.entities_with_matches)
233215
logger.info("Column matches: %d", summary.total_matches)

dynamic_csv_importer/sample_dbt_jaffle.csv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ credit_card_amount,Credit Card New Amount,Amount paid using credit card,DECIMAL,
1313
coupon_amount,Coupon Amount,Discount amount applied through coupon codes,DECIMAL,DOUBLE,,,Finance.Payment,Payment.Financial
1414
bank_transfer_amount,Bank Transfer Amount,Amount paid via bank transfer new,DECIMAL,DOUBLE,,,Finance.Payment,Payment.Financial
1515
gift_card_amount,Gift Card Amount,Amount paid using gift card new balance,DECIMAL,DOUBLE,,,Finance.Payment,Payment.Financial
16-
country_name,Country Super Name,this is a cool new description,,,,,,
16+
country_name,Country Super Name,this is a cool new description,VARCHAR,STRING,,,,

0 commit comments

Comments
 (0)