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
35import argparse
46import csv
57import logging
68import os
7- from collections .abc import Generator , Sequence
9+ from collections .abc import Mapping , Sequence
810from dataclasses import dataclass
9- from enum import Enum
1011from 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- )
2314from metadata .generated .schema .type .basic import Markdown
24- from metadata .ingestion .ometa .ometa_api import OpenMetadata
2515from metadata .ingestion .ometa .utils import model_str
16+ from metadata .sdk import DashboardDataModels , Tables , configure , reset
2617from pydantic import BaseModel , ConfigDict , Field , ValidationError
2718
2819logger = 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
4123class CSVColumnSchema (BaseModel ):
@@ -65,39 +47,42 @@ class ProcessSummary:
6547
6648
6749def 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
10388def clean_column_name (column_name : str ) -> str :
@@ -108,54 +93,25 @@ def clean_column_name(column_name: str) -> str:
10893
10994
11095def 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+
193153def 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
221189def 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 )
0 commit comments