metadata_app.py 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041
  1. #
  2. # Copyright 2018-2022 Elyra Authors
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. # You may obtain a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. #
  16. import json
  17. import os
  18. import sys
  19. from typing import Dict
  20. from typing import List
  21. from deprecation import deprecated
  22. from jsonschema import ValidationError
  23. from elyra.metadata.error import MetadataExistsError, MetadataNotFoundError
  24. from elyra.metadata.manager import MetadataManager
  25. from elyra.metadata.metadata import Metadata
  26. from elyra.metadata.metadata_app_utils import AppBase
  27. from elyra.metadata.metadata_app_utils import CliOption
  28. from elyra.metadata.metadata_app_utils import FileOption
  29. from elyra.metadata.metadata_app_utils import Flag
  30. from elyra.metadata.metadata_app_utils import JSONBasedOption
  31. from elyra.metadata.metadata_app_utils import JSONOption
  32. from elyra.metadata.metadata_app_utils import MetadataSchemaProperty
  33. from elyra.metadata.metadata_app_utils import Option
  34. from elyra.metadata.metadata_app_utils import SchemaProperty
  35. from elyra.metadata.schema import SchemaManager
  36. class SchemaspaceBase(AppBase):
  37. """Simple attribute-only base class for the various schemaspace subcommand classes"""
  38. # These will be set on class creation when subcommand creates the schemaspace-specific class
  39. description = None
  40. schemaspace = None
  41. schemas = None
  42. options = []
  43. def print_help(self):
  44. super().print_help()
  45. print()
  46. print("Options")
  47. print("-------")
  48. print()
  49. for option in self.options:
  50. option.print_help()
  51. def start(self):
  52. # Process client options since all subclasses are option processor
  53. self.process_cli_options(self.options)
  54. class SchemaspaceList(SchemaspaceBase):
  55. """Handles the 'list' subcommand functionality for a specific schemaspace."""
  56. json_flag = Flag("--json", name="json", description="List complete instances as JSON", default_value=False)
  57. valid_only_flag = Flag(
  58. "--valid-only",
  59. name="valid-only",
  60. description="Only list valid instances (default includes invalid instances)",
  61. default_value=False,
  62. )
  63. # 'List' flags
  64. options = [json_flag, valid_only_flag]
  65. def __init__(self, **kwargs):
  66. super().__init__(**kwargs)
  67. self.metadata_manager = MetadataManager(schemaspace=self.schemaspace)
  68. def start(self):
  69. super().start() # process options
  70. include_invalid = not self.valid_only_flag.value
  71. try:
  72. metadata_instances = self.metadata_manager.get_all(include_invalid=include_invalid)
  73. except MetadataNotFoundError:
  74. metadata_instances = None
  75. if self.json_flag.value:
  76. if metadata_instances is None:
  77. metadata_instances = []
  78. print(metadata_instances)
  79. else:
  80. if not metadata_instances:
  81. print(f"No metadata instances found for {self.schemaspace}")
  82. return
  83. validity_clause = "includes invalid" if include_invalid else "valid only"
  84. print(f"Available metadata instances for {self.schemaspace} ({validity_clause}):")
  85. sorted_instances = sorted(metadata_instances, key=lambda inst: (inst.schema_name, inst.name))
  86. # pad to width of longest instance
  87. max_schema_name_len = len("Schema")
  88. max_name_len = len("Instance")
  89. max_resource_len = len("Resource")
  90. for instance in sorted_instances:
  91. max_schema_name_len = max(len(instance.schema_name), max_schema_name_len)
  92. max_name_len = max(len(instance.name), max_name_len)
  93. max_resource_len = max(len(instance.resource), max_resource_len)
  94. print()
  95. print(
  96. f"{'Schema'.ljust(max_schema_name_len)} {'Instance'.ljust(max_name_len)} "
  97. f"{'Resource'.ljust(max_resource_len)} "
  98. )
  99. print(
  100. f"{'------'.ljust(max_schema_name_len)} {'--------'.ljust(max_name_len)} "
  101. f"{'--------'.ljust(max_resource_len)} "
  102. )
  103. for instance in sorted_instances:
  104. invalid = ""
  105. if instance.reason and len(instance.reason) > 0:
  106. invalid = f"**INVALID** ({instance.reason})"
  107. print(
  108. f"{instance.schema_name.ljust(max_schema_name_len)} {instance.name.ljust(max_name_len)} "
  109. f"{instance.resource.ljust(max_resource_len)} {invalid}"
  110. )
  111. class SchemaspaceRemove(SchemaspaceBase):
  112. """Handles the 'remove' subcommand functionality for a specific schemaspace."""
  113. name_option = CliOption(
  114. "--name", name="name", description="The name of the metadata instance to remove", required=True
  115. )
  116. # 'Remove' options
  117. options = [name_option]
  118. def __init__(self, **kwargs):
  119. super().__init__(**kwargs)
  120. self.metadata_manager = MetadataManager(schemaspace=self.schemaspace)
  121. def start(self):
  122. super().start() # process options
  123. name = self.name_option.value
  124. try:
  125. self.metadata_manager.get(name)
  126. except MetadataNotFoundError as mnfe:
  127. self.log_and_exit(mnfe)
  128. except ValidationError: # Probably deleting invalid instance
  129. pass
  130. self.metadata_manager.remove(name)
  131. print(f"Metadata instance '{name}' removed from schemaspace '{self.schemaspace}'.")
  132. class SchemaspaceCreate(SchemaspaceBase):
  133. """Handles the 'create' subcommand functionality for a specific schemaspace."""
  134. # Known options, others will be derived from schema based on schema_name...
  135. name_option = CliOption("--name", name="name", description="The name of the metadata instance.")
  136. file_option = FileOption(
  137. "--file",
  138. name="file",
  139. description="The filename containing the metadata instance. "
  140. "Can be used to bypass individual property arguments.",
  141. )
  142. json_option = JSONOption(
  143. "--json",
  144. name="json",
  145. description="The JSON string containing the metadata instance. "
  146. "Can be used to bypass individual property arguments.",
  147. )
  148. # 'create' options
  149. options: List[Option] = [file_option, json_option] # defer name option until after schema
  150. update_mode = False
  151. def __init__(self, **kwargs):
  152. super().__init__(**kwargs)
  153. self.complex_properties: List[str] = []
  154. self.metadata_manager = MetadataManager(schemaspace=self.schemaspace)
  155. # First, process the schema_name option so we can then load the appropriate schema
  156. # file to build the schema-based options. If help is requested, give it to them.
  157. # As an added benefit, if the schemaspace has one schema, got ahead and default that value.
  158. # If multiple, add the list so proper messaging can be applied. As a result, we need to
  159. # to build the option here since this is where we have access to the schemas.
  160. schema_list = list(self.schemas.keys())
  161. if len(schema_list) == 1:
  162. self.schema_name_option = CliOption(
  163. "--schema_name",
  164. name="schema_name",
  165. default_value=schema_list[0],
  166. description="The schema_name of the metadata instance " f"(defaults to '{schema_list[0]}')",
  167. required=True,
  168. )
  169. else:
  170. enum = schema_list
  171. self.schema_name_option = CliOption(
  172. "--schema_name",
  173. name="schema_name",
  174. enum=enum,
  175. description="The schema_name of the metadata instance " f"Must be one of: {enum}",
  176. required=True,
  177. )
  178. self.options.extend([self.schema_name_option, self.name_option])
  179. # Determine if --json, --file, or --replace are in use and relax required properties if so.
  180. bulk_metadata = self._process_json_based_options()
  181. relax_required = bulk_metadata or self.update_mode
  182. # This needs to occur following json-based options since they may add it as an option
  183. self.process_cli_option(self.schema_name_option, check_help=True)
  184. # Schema appears to be a valid name, convert its properties to options and continue
  185. schema = self.schemas[self.schema_name_option.value]
  186. # Convert schema properties to options, gathering complex property names
  187. schema_options = self._schema_to_options(schema, relax_required)
  188. self.options.extend(schema_options)
  189. def start(self):
  190. super().start() # process options
  191. # Get known options, then gather display_name and build metadata dict.
  192. name = self.name_option.value
  193. schema_name = self.schema_name_option.value
  194. display_name = None
  195. metadata = {}
  196. # Walk the options looking for SchemaProperty instances. Any MetadataSchemaProperty instances go
  197. # into the metadata dict. Note that we process JSONBasedOptions (--json or --file) prior to
  198. # MetadataSchemaProperty types since the former will set the base metadata stanza and individual
  199. # values can be used to override the former's content (like BYO authentication OVPs, for example).
  200. for option in self.options:
  201. if isinstance(option, MetadataSchemaProperty):
  202. # skip adding any non required properties that have no value (unless its a null type).
  203. if not option.required and not option.value and option.type != "null":
  204. continue
  205. metadata[option.name] = option.value
  206. elif isinstance(option, SchemaProperty):
  207. if option.name == "display_name": # Be sure we have a display_name
  208. display_name = option.value
  209. continue
  210. elif isinstance(option, JSONBasedOption):
  211. metadata.update(option.metadata)
  212. if display_name is None and self.update_mode is False: # Only require on create
  213. self.log_and_exit(f"Could not determine display_name from schema '{schema_name}'")
  214. ex_msg = None
  215. new_instance = None
  216. try:
  217. if self.update_mode: # if replacing, fetch the instance so it can be updated
  218. updated_instance = self.metadata_manager.get(name)
  219. updated_instance.schema_name = schema_name
  220. if display_name:
  221. updated_instance.display_name = display_name
  222. updated_instance.metadata.update(metadata)
  223. new_instance = self.metadata_manager.update(name, updated_instance)
  224. else: # create a new instance
  225. instance = Metadata(schema_name=schema_name, name=name, display_name=display_name, metadata=metadata)
  226. new_instance = self.metadata_manager.create(name, instance)
  227. except Exception as ex:
  228. ex_msg = str(ex)
  229. if new_instance:
  230. print(
  231. f"Metadata instance '{new_instance.name}' for schema '{schema_name}' has been written "
  232. f"to: {new_instance.resource}"
  233. )
  234. else:
  235. if ex_msg:
  236. self.log_and_exit(
  237. f"The following exception occurred saving metadata instance "
  238. f"for schema '{schema_name}': {ex_msg}",
  239. display_help=False,
  240. )
  241. else:
  242. self.log_and_exit(
  243. f"A failure occurred saving metadata instance '{name}' for " f"schema '{schema_name}'.",
  244. display_help=False,
  245. )
  246. def _process_json_based_options(self) -> bool:
  247. """Process the file and json options to see if they have values (and those values can be loaded as JSON)
  248. Then check payloads for schema_name, display_name and derive name options and add to argv mappings
  249. if currently not specified.
  250. If either option is set, indicate that the metadata stanza should be skipped (return True)
  251. """
  252. bulk_metadata = False
  253. self.process_cli_option(self.file_option, check_help=True)
  254. self.process_cli_option(self.json_option, check_help=True)
  255. # if both are set, raise error
  256. if self.json_option.value is not None and self.file_option.value is not None:
  257. self.log_and_exit("At most one of '--json' or '--file' can be set at a time.", display_help=True)
  258. elif self.json_option.value is not None:
  259. bulk_metadata = True
  260. self.json_option.transfer_names_to_argvs(self.argv, self.argv_mappings)
  261. elif self.file_option.value is not None:
  262. bulk_metadata = True
  263. self.file_option.transfer_names_to_argvs(self.argv, self.argv_mappings)
  264. # else, neither is set so metadata stanza will be considered
  265. return bulk_metadata
  266. def _schema_to_options(self, schema: Dict, relax_required: bool = False) -> List[Option]:
  267. """Takes a JSON schema and builds a list of SchemaProperty instances corresponding to each
  268. property in the schema. There are two sections of properties, one that includes
  269. schema_name and display_name and another within the metadata container - which
  270. will be separated by class type - SchemaProperty vs. MetadataSchemaProperty.
  271. If relax_required is true, a --json or --file option is in use and the primary metadata
  272. comes from those options OR the --replace option is in use, in which case the primary
  273. metadata comes from the existing instance (being replaced). In such cases, skip setting
  274. required values since most will come from the JSON-based option or already be present
  275. (in the case of replace). This allows CLI-specified metadata properties to override the
  276. primary metadata (either in the JSON options or from the existing instance).
  277. """
  278. options = {}
  279. properties = schema["properties"]
  280. for name, value in properties.items():
  281. if name == "schema_name": # already have this option, skip
  282. continue
  283. if name != "metadata":
  284. options[name] = SchemaProperty(name, value)
  285. else: # convert first-level metadata properties to options...
  286. metadata_properties = properties["metadata"]["properties"]
  287. for md_name, md_value in metadata_properties.items():
  288. msp = MetadataSchemaProperty(md_name, md_value)
  289. # skip if this property was not specified on the command line and its a replace/bulk op
  290. if msp.cli_option not in self.argv_mappings and relax_required:
  291. continue
  292. if msp.unsupported_meta_props: # if this option includes complex meta-props, note that.
  293. self.complex_properties.append(md_name)
  294. options[md_name] = msp
  295. # Now set required-ness on MetadataProperties, but only when creation is using fine-grained property options
  296. if not relax_required:
  297. required_props = properties["metadata"].get("required")
  298. for required in required_props:
  299. options.get(required).required = True
  300. # ... and top-level (schema) Properties if we're not replacing (updating)
  301. if self.update_mode is False:
  302. required_props = set(schema.get("required")) - {"schema_name", "metadata"} # skip schema_name & metadata
  303. for required in required_props:
  304. options.get(required).required = True
  305. return list(options.values())
  306. def print_help(self):
  307. super().print_help()
  308. # If we gathered any complex properties, go ahead and note how behaviors might be affected, etc.
  309. if self.complex_properties:
  310. print(
  311. f"Note: The following properties in this schema contain JSON keywords that are not supported "
  312. f"by the tooling: {self.complex_properties}."
  313. )
  314. print(
  315. "This can impact the tool's ability to derive context from the schema, including a property's "
  316. "type, description, or behaviors included in complex types like 'oneOf'."
  317. )
  318. print(
  319. "It is recommended that options corresponding to these properties be set after understanding "
  320. "the schema or indirectly using `--file` or `--json` options."
  321. )
  322. print(
  323. 'If the property is of type "object" it can be set using a file containing only that property\'s '
  324. "JSON."
  325. )
  326. print(f"The following are considered unsupported keywords: {SchemaProperty.unsupported_keywords}")
  327. class SchemaspaceUpdate(SchemaspaceCreate):
  328. """Handles the 'update' subcommand functionality for a specific schemaspace."""
  329. update_mode = True
  330. class SchemaspaceInstall(SchemaspaceBase):
  331. """DEPRECATED (removed in v4.0):
  332. Handles the 'install' subcommand functionality for a specific schemaspace.
  333. """
  334. # Known options, others will be derived from schema based on schema_name...
  335. replace_flag = Flag("--replace", name="replace", description="Replace an existing instance", default_value=False)
  336. name_option = CliOption("--name", name="name", description="The name of the metadata instance to install")
  337. file_option = FileOption(
  338. "--file",
  339. name="file",
  340. description="The filename containing the metadata instance to install. "
  341. "Can be used to bypass individual property arguments.",
  342. )
  343. json_option = JSONOption(
  344. "--json",
  345. name="json",
  346. description="The JSON string containing the metadata instance to install. "
  347. "Can be used to bypass individual property arguments.",
  348. )
  349. # 'Install' options
  350. options: List[Option] = [replace_flag, file_option, json_option] # defer name option until after schema
  351. def __init__(self, **kwargs):
  352. super().__init__(**kwargs)
  353. self.complex_properties: List[str] = []
  354. self.metadata_manager = MetadataManager(schemaspace=self.schemaspace)
  355. # First, process the schema_name option so we can then load the appropriate schema
  356. # file to build the schema-based options. If help is requested, give it to them.
  357. # As an added benefit, if the schemaspace has one schema, got ahead and default that value.
  358. # If multiple, add the list so proper messaging can be applied. As a result, we need to
  359. # to build the option here since this is where we have access to the schemas.
  360. schema_list = list(self.schemas.keys())
  361. if len(schema_list) == 1:
  362. self.schema_name_option = CliOption(
  363. "--schema_name",
  364. name="schema_name",
  365. default_value=schema_list[0],
  366. description="The schema_name of the metadata instance to " f"install (defaults to '{schema_list[0]}')",
  367. required=True,
  368. )
  369. else:
  370. enum = schema_list
  371. self.schema_name_option = CliOption(
  372. "--schema_name",
  373. name="schema_name",
  374. enum=enum,
  375. description="The schema_name of the metadata instance to install. " f"Must be one of: {enum}",
  376. required=True,
  377. )
  378. self.options.extend([self.schema_name_option, self.name_option])
  379. # Since we need to know if the replace option is in use prior to normal option processing,
  380. # go ahead and check for its existence on the command-line and process if present.
  381. if self.replace_flag.cli_option in self.argv_mappings.keys():
  382. self.process_cli_option(self.replace_flag)
  383. # Determine if --json, --file, or --replace are in use and relax required properties if so.
  384. bulk_metadata = self._process_json_based_options()
  385. relax_required = bulk_metadata or self.replace_flag.value
  386. # This needs to occur following json-based options since they may add it as an option
  387. self.process_cli_option(self.schema_name_option, check_help=True)
  388. # Schema appears to be a valid name, convert its properties to options and continue
  389. schema = self.schemas[self.schema_name_option.value]
  390. # Convert schema properties to options, gathering complex property names
  391. schema_options = self._schema_to_options(schema, relax_required)
  392. self.options.extend(schema_options)
  393. def start(self):
  394. super().start() # process options
  395. # Get known options, then gather display_name and build metadata dict.
  396. name = self.name_option.value
  397. schema_name = self.schema_name_option.value
  398. display_name = None
  399. metadata = {}
  400. # Walk the options looking for SchemaProperty instances. Any MetadataSchemaProperty instances go
  401. # into the metadata dict. Note that we process JSONBasedOptions (--json or --file) prior to
  402. # MetadataSchemaProperty types since the former will set the base metadata stanza and individual
  403. # values can be used to override the former's content (like BYO authentication OVPs, for example).
  404. for option in self.options:
  405. if isinstance(option, MetadataSchemaProperty):
  406. # skip adding any non required properties that have no value (unless its a null type).
  407. if not option.required and not option.value and option.type != "null":
  408. continue
  409. metadata[option.name] = option.value
  410. elif isinstance(option, SchemaProperty):
  411. if option.name == "display_name": # Be sure we have a display_name
  412. display_name = option.value
  413. continue
  414. elif isinstance(option, JSONBasedOption):
  415. metadata.update(option.metadata)
  416. if display_name is None and self.replace_flag.value is False: # Only require on create
  417. self.log_and_exit(f"Could not determine display_name from schema '{schema_name}'")
  418. ex_msg = None
  419. new_instance = None
  420. try:
  421. if self.replace_flag.value: # if replacing, fetch the instance so it can be updated
  422. updated_instance = self.metadata_manager.get(name)
  423. updated_instance.schema_name = schema_name
  424. if display_name:
  425. updated_instance.display_name = display_name
  426. updated_instance.metadata.update(metadata)
  427. new_instance = self.metadata_manager.update(name, updated_instance)
  428. else: # create a new instance
  429. instance = Metadata(schema_name=schema_name, name=name, display_name=display_name, metadata=metadata)
  430. new_instance = self.metadata_manager.create(name, instance)
  431. except Exception as ex:
  432. ex_msg = str(ex)
  433. if new_instance:
  434. print(
  435. f"Metadata instance '{new_instance.name}' for schema '{schema_name}' has been written "
  436. f"to: {new_instance.resource}"
  437. )
  438. else:
  439. if ex_msg:
  440. self.log_and_exit(
  441. f"The following exception occurred saving metadata instance "
  442. f"for schema '{schema_name}': {ex_msg}",
  443. display_help=False,
  444. )
  445. else:
  446. self.log_and_exit(
  447. f"A failure occurred saving metadata instance '{name}' for " f"schema '{schema_name}'.",
  448. display_help=False,
  449. )
  450. def _process_json_based_options(self) -> bool:
  451. """Process the file and json options to see if they have values (and those values can be loaded as JSON)
  452. Then check payloads for schema_name, display_name and derive name options and add to argv mappings
  453. if currently not specified.
  454. If either option is set, indicate that the metadata stanza should be skipped (return True)
  455. """
  456. bulk_metadata = False
  457. self.process_cli_option(self.file_option, check_help=True)
  458. self.process_cli_option(self.json_option, check_help=True)
  459. # if both are set, raise error
  460. if self.json_option.value is not None and self.file_option.value is not None:
  461. self.log_and_exit("At most one of '--json' or '--file' can be set at a time.", display_help=True)
  462. elif self.json_option.value is not None:
  463. bulk_metadata = True
  464. self.json_option.transfer_names_to_argvs(self.argv, self.argv_mappings)
  465. elif self.file_option.value is not None:
  466. bulk_metadata = True
  467. self.file_option.transfer_names_to_argvs(self.argv, self.argv_mappings)
  468. # else, neither is set so metadata stanza will be considered
  469. return bulk_metadata
  470. def _schema_to_options(self, schema: Dict, relax_required: bool = False) -> List[Option]:
  471. """Takes a JSON schema and builds a list of SchemaProperty instances corresponding to each
  472. property in the schema. There are two sections of properties, one that includes
  473. schema_name and display_name and another within the metadata container - which
  474. will be separated by class type - SchemaProperty vs. MetadataSchemaProperty.
  475. If relax_required is true, a --json or --file option is in use and the primary metadata
  476. comes from those options OR the --replace option is in use, in which case the primary
  477. metadata comes from the existing instance (being replaced). In such cases, skip setting
  478. required values since most will come from the JSON-based option or already be present
  479. (in the case of replace). This allows CLI-specified metadata properties to override the
  480. primary metadata (either in the JSON options or from the existing instance).
  481. """
  482. options = {}
  483. properties = schema["properties"]
  484. for name, value in properties.items():
  485. if name == "schema_name": # already have this option, skip
  486. continue
  487. if name != "metadata":
  488. options[name] = SchemaProperty(name, value)
  489. else: # convert first-level metadata properties to options...
  490. metadata_properties = properties["metadata"]["properties"]
  491. for md_name, md_value in metadata_properties.items():
  492. msp = MetadataSchemaProperty(md_name, md_value)
  493. # skip if this property was not specified on the command line and its a replace/bulk op
  494. if msp.cli_option not in self.argv_mappings and relax_required:
  495. continue
  496. if msp.unsupported_meta_props: # if this option includes complex meta-props, note that.
  497. self.complex_properties.append(md_name)
  498. options[md_name] = msp
  499. # Now set required-ness on MetadataProperties, but only when creation is using fine-grained property options
  500. if not relax_required:
  501. required_props = properties["metadata"].get("required")
  502. for required in required_props:
  503. options.get(required).required = True
  504. # ... and top-level (schema) Properties if we're not replacing (updating)
  505. if self.replace_flag.value is False:
  506. required_props = set(schema.get("required")) - {"schema_name", "metadata"} # skip schema_name & metadata
  507. for required in required_props:
  508. options.get(required).required = True
  509. return list(options.values())
  510. def print_help(self):
  511. super().print_help()
  512. # If we gathered any complex properties, go ahead and note how behaviors might be affected, etc.
  513. if self.complex_properties:
  514. print(
  515. f"Note: The following properties in this schema contain JSON keywords that are not supported "
  516. f"by the tooling: {self.complex_properties}."
  517. )
  518. print(
  519. "This can impact the tool's ability to derive context from the schema, including a property's "
  520. "type, description, or behaviors included in complex types like 'oneOf'."
  521. )
  522. print(
  523. "It is recommended that options corresponding to these properties be set after understanding "
  524. "the schema or indirectly using `--file` or `--json` options."
  525. )
  526. print(
  527. 'If the property is of type "object" it can be set using a file containing only that property\'s '
  528. "JSON."
  529. )
  530. print(f"The following are considered unsupported keywords: {SchemaProperty.unsupported_keywords}")
  531. class SchemaspaceMigrate(SchemaspaceBase):
  532. """Handles the 'migrate' subcommand functionality for a specific schemaspace."""
  533. # 'Migrate' options
  534. options = []
  535. def __init__(self, **kwargs):
  536. super().__init__(**kwargs)
  537. def start(self):
  538. super().start() # process options
  539. # Regardless of schemaspace, call migrate. If the schemaspace implementation doesn't
  540. # require migration, an appropriate log statement will be produced.
  541. schemaspace = SchemaManager.instance().get_schemaspace(self.schemaspace)
  542. migrated = schemaspace.migrate()
  543. if migrated:
  544. print(f"The following {self.schemaspace} instances were migrated: {migrated}")
  545. else:
  546. print(f"No instances of schemaspace {self.schemaspace} were migrated.")
  547. class SchemaspaceExport(SchemaspaceBase):
  548. """Handles the 'export' subcommand functionality for a specific schemaspace."""
  549. schema_name_option = CliOption(
  550. "--schema_name",
  551. name="schema_name",
  552. description="The schema name of the metadata instances to export",
  553. required=False,
  554. )
  555. include_invalid_flag = Flag(
  556. "--include-invalid",
  557. name="include-invalid",
  558. description="Export valid and invalid instances. " "By default only valid instances are exported.",
  559. default_value=False,
  560. )
  561. clean_flag = Flag(
  562. "--clean", name="clean", description="Clear out contents of the export directory", default_value=False
  563. )
  564. directory_option = CliOption(
  565. "--directory",
  566. name="directory",
  567. description="The local file system path where the exported metadata will be stored",
  568. required=True,
  569. )
  570. # 'Export' flags
  571. options: List[Option] = [schema_name_option, include_invalid_flag, clean_flag, directory_option]
  572. def __init__(self, **kwargs):
  573. super().__init__(**kwargs)
  574. self.metadata_manager = MetadataManager(schemaspace=self.schemaspace)
  575. def start(self):
  576. super().start() # process options
  577. schema_name = self.schema_name_option.value
  578. if schema_name:
  579. schema_list = sorted(list(self.schemas.keys()))
  580. if schema_name not in schema_list:
  581. print(
  582. f"Schema name '{schema_name}' is invalid. For the '{self.schemaspace}' schemaspace, "
  583. f"the schema name must be one of {schema_list}"
  584. )
  585. self.exit(1)
  586. include_invalid = self.include_invalid_flag.value
  587. directory = self.directory_option.value
  588. clean = self.clean_flag.value
  589. try:
  590. if self.schema_name_option is not None:
  591. metadata_instances = self.metadata_manager.get_all(
  592. include_invalid=include_invalid, of_schema=schema_name
  593. )
  594. else:
  595. metadata_instances = self.metadata_manager.get_all(include_invalid=include_invalid)
  596. except MetadataNotFoundError:
  597. metadata_instances = None
  598. if not metadata_instances:
  599. print(
  600. f"No metadata instances found for schemaspace '{self.schemaspace}'"
  601. + (f" and schema '{schema_name}'" if schema_name else "")
  602. )
  603. print(f"Nothing exported to '{directory}'")
  604. return
  605. dest_directory = os.path.join(directory, self.schemaspace)
  606. if not os.path.exists(dest_directory):
  607. try:
  608. print(f"Creating directory structure for '{dest_directory}'")
  609. os.makedirs(dest_directory)
  610. except OSError as e:
  611. print(f"Error creating directory structure for '{dest_directory}': {e.strerror}: '{e.filename}'")
  612. self.exit(1)
  613. else:
  614. if clean:
  615. files = [os.path.join(dest_directory, f) for f in os.listdir(dest_directory)]
  616. if len(files) > 0:
  617. print(f"Cleaning out all files in '{dest_directory}'")
  618. [os.remove(f) for f in files if os.path.isfile(f)]
  619. print(
  620. f"Exporting metadata instances for schemaspace '{self.schemaspace}'"
  621. + (f" and schema '{schema_name}'" if schema_name else "")
  622. + (" (includes invalid)" if include_invalid else " (valid only)")
  623. + f" to '{dest_directory}'"
  624. )
  625. num_valid_exported = 0
  626. num_invalid_exported = 0
  627. for instance in metadata_instances:
  628. dict_metadata = instance.to_dict()
  629. output_file = os.path.join(dest_directory, f'{dict_metadata["name"]}.json')
  630. if "reason" in dict_metadata and len(dict_metadata["reason"]) > 0:
  631. num_invalid_exported += 1
  632. else:
  633. num_valid_exported += 1
  634. with open(output_file, mode="w") as output_file:
  635. json.dump(dict_metadata, output_file, indent=4)
  636. total_exported = num_valid_exported + num_invalid_exported
  637. print(
  638. f"Exported {total_exported} "
  639. + ("instances" if total_exported > 1 else "instance")
  640. + f" ({num_invalid_exported} of which "
  641. + ("is" if num_invalid_exported == 1 else "are")
  642. + " invalid)"
  643. )
  644. class SchemaspaceImport(SchemaspaceBase):
  645. """Handles the 'import' subcommand functionality for a specific schemaspace."""
  646. directory_option = CliOption(
  647. "--directory",
  648. name="directory",
  649. description="The local file system path from where the metadata will be imported",
  650. required=True,
  651. )
  652. overwrite_flag = Flag(
  653. "--overwrite",
  654. name="overwrite",
  655. description="Overwrite existing metadata instance with the same name",
  656. default_value=False,
  657. )
  658. # 'Import' flags
  659. options: List[Option] = [directory_option, overwrite_flag]
  660. def __init__(self, **kwargs):
  661. super().__init__(**kwargs)
  662. self.metadata_manager = MetadataManager(schemaspace=self.schemaspace)
  663. def start(self):
  664. super().start() # process options
  665. src_directory = self.directory_option.value
  666. try:
  667. json_files = [f for f in os.listdir(src_directory) if f.endswith(".json")]
  668. except OSError as e:
  669. print(f"Unable to reach the '{src_directory}' directory: {e.strerror}: '{e.filename}'")
  670. self.exit(1)
  671. if len(json_files) == 0:
  672. print(f"No instances for import found in the '{src_directory}' directory")
  673. return
  674. metadata_file = None
  675. non_imported_files = []
  676. for file in json_files:
  677. filepath = os.path.join(src_directory, file)
  678. try:
  679. with open(filepath) as f:
  680. metadata_file = json.loads(f.read())
  681. except OSError as e:
  682. non_imported_files.append([file, e.strerror])
  683. continue
  684. name = os.path.splitext(file)[0]
  685. try:
  686. schema_name = metadata_file["schema_name"]
  687. display_name = metadata_file["display_name"]
  688. metadata = metadata_file["metadata"]
  689. except KeyError as e:
  690. non_imported_files.append([file, f"Could not find '{e.args[0]}' key in the import file '{filepath}'"])
  691. continue
  692. try:
  693. if self.overwrite_flag.value: # if overwrite flag is true
  694. try: # try updating the existing instance
  695. updated_instance = self.metadata_manager.get(name)
  696. updated_instance.schema_name = schema_name
  697. if display_name:
  698. updated_instance.display_name = display_name
  699. if name:
  700. updated_instance.name = name
  701. updated_instance.metadata.update(metadata)
  702. self.metadata_manager.update(name, updated_instance)
  703. except MetadataNotFoundError: # no existing instance - create new
  704. instance = Metadata(
  705. schema_name=schema_name, name=name, display_name=display_name, metadata=metadata
  706. )
  707. self.metadata_manager.create(name, instance)
  708. else:
  709. instance = Metadata(
  710. schema_name=schema_name, name=name, display_name=display_name, metadata=metadata
  711. )
  712. self.metadata_manager.create(name, instance)
  713. except Exception as e:
  714. if isinstance(e, MetadataExistsError):
  715. non_imported_files.append([file, f"{str(e)} Use '--overwrite' to update."])
  716. else:
  717. non_imported_files.append([file, str(e)])
  718. instance_count_not_imported = len(non_imported_files)
  719. instance_count_imported = len(json_files) - instance_count_not_imported
  720. print(f"Imported {instance_count_imported} " + ("instance" if instance_count_imported == 1 else "instances"))
  721. if instance_count_not_imported > 0:
  722. print(
  723. f"{instance_count_not_imported} "
  724. + ("instance" if instance_count_not_imported == 1 else "instances")
  725. + " could not be imported"
  726. )
  727. non_imported_files.sort(key=lambda x: x[0])
  728. print("\nThe following files could not be imported: ")
  729. # pad to width of longest file and reason
  730. max_file_name_len = len("File")
  731. max_reason_len = len("Reason")
  732. for file in non_imported_files:
  733. max_file_name_len = max(len(file[0]), max_file_name_len)
  734. max_reason_len = max(len(file[1]), max_reason_len)
  735. print(f"{'File'.ljust(max_file_name_len)} {'Reason'.ljust(max_reason_len)}")
  736. print(f"{'----'.ljust(max_file_name_len)} {'------'.ljust(max_reason_len)}")
  737. for file in non_imported_files:
  738. print(f"{file[0].ljust(max_file_name_len)} {file[1].ljust(max_reason_len)}")
  739. class SubcommandBase(AppBase):
  740. """Handles building the appropriate subcommands based on existing schemaspaces."""
  741. subcommand_description = None # Overridden in subclass
  742. schemaspace_base_class = None # Overridden in subclass
  743. def __init__(self, **kwargs):
  744. super().__init__(**kwargs)
  745. self.schemaspace_schemas = kwargs["schemaspace_schemas"]
  746. # For each schemaspace in current schemas, add a corresponding subcommand
  747. # This requires a new subclass of the SchemaspaceList class with an appropriate description
  748. self.subcommands = {}
  749. for schemaspace, schemas in self.schemaspace_schemas.items():
  750. subcommand_description = self.subcommand_description.format(schemaspace=schemaspace)
  751. # Create the appropriate schemaspace class, initialized with its description,
  752. # schemaspace, and corresponding schemas as attributes,
  753. schemaspace_class = type(
  754. schemaspace,
  755. (self.schemaspace_base_class,),
  756. {"description": subcommand_description, "schemaspace": schemaspace, "schemas": schemas},
  757. )
  758. self.subcommands[schemaspace] = (schemaspace_class, schemaspace_class.description)
  759. def start(self):
  760. subcommand = self.get_subcommand()
  761. subinstance = subcommand[0](argv=self.argv, schemaspace_schemas=self.schemaspace_schemas)
  762. return subinstance.start()
  763. def print_help(self):
  764. super().print_help()
  765. self.print_subcommands()
  766. class List(SubcommandBase):
  767. """Lists a metadata instances of a given schemaspace."""
  768. description = "List metadata instances for a given schemaspace."
  769. subcommand_description = "List installed metadata for {schemaspace}."
  770. schemaspace_base_class = SchemaspaceList
  771. def __init__(self, **kwargs):
  772. super().__init__(**kwargs)
  773. class Remove(SubcommandBase):
  774. """Removes a metadata instance from a given schemaspace."""
  775. description = "Remove a metadata instance from a given schemaspace."
  776. subcommand_description = "Remove a metadata instance from schemaspace '{schemaspace}'."
  777. schemaspace_base_class = SchemaspaceRemove
  778. def __init__(self, **kwargs):
  779. super().__init__(**kwargs)
  780. @deprecated(deprecated_in="3.7.0", removed_in="4.0", details="Use Create or Update instead")
  781. class Install(SubcommandBase):
  782. """DEPRECATED. Installs a metadata instance into a given schemaspace."""
  783. description = "DEPRECATED. Install a metadata instance into a given schemaspace. Use 'create' or 'update' instead."
  784. subcommand_description = "DEPRECATED. Install a metadata instance into schemaspace '{schemaspace}'."
  785. schemaspace_base_class = SchemaspaceInstall
  786. def __init__(self, **kwargs):
  787. super().__init__(**kwargs)
  788. class Create(SubcommandBase):
  789. """Creates a metadata instance in a given schemaspace."""
  790. description = "Create a metadata instance in a given schemaspace."
  791. subcommand_description = "Create a metadata instance in schemaspace '{schemaspace}'."
  792. schemaspace_base_class = SchemaspaceCreate
  793. def __init__(self, **kwargs):
  794. super().__init__(**kwargs)
  795. class Update(SubcommandBase):
  796. """Updates a metadata instance in a given schemaspace."""
  797. description = "Update a metadata instance in a given schemaspace."
  798. subcommand_description = "Update a metadata instance in schemaspace '{schemaspace}'."
  799. schemaspace_base_class = SchemaspaceUpdate
  800. def __init__(self, **kwargs):
  801. super().__init__(**kwargs)
  802. class Migrate(SubcommandBase):
  803. """Migrates metadata instances in a given schemaspace."""
  804. description = "Migrate metadata instances in a given schemaspace."
  805. subcommand_description = "Migrate metadata instance in schemaspace '{schemaspace}'."
  806. schemaspace_base_class = SchemaspaceMigrate
  807. def __init__(self, **kwargs):
  808. super().__init__(**kwargs)
  809. class Export(SubcommandBase):
  810. """Exports metadata instances in a given schemaspace."""
  811. description = "Export metadata instances in a given schemaspace."
  812. subcommand_description = "Export installed metadata in schemaspace '{schemaspace}'."
  813. schemaspace_base_class = SchemaspaceExport
  814. def __init__(self, **kwargs):
  815. super().__init__(**kwargs)
  816. class Import(SubcommandBase):
  817. """Imports metadata instances into a given schemaspace."""
  818. description = "Import metadata instances into a given schemaspace."
  819. subcommand_description = "Import metadata instances into schemaspace '{schemaspace}'."
  820. schemaspace_base_class = SchemaspaceImport
  821. def __init__(self, **kwargs):
  822. super().__init__(**kwargs)
  823. class MetadataApp(AppBase):
  824. """Lists, creates, updates, removes, migrates, exports and imports metadata for a given schemaspace."""
  825. name = "elyra-metadata"
  826. description = """Manage Elyra metadata."""
  827. subcommands = {
  828. "list": (List, List.description.splitlines()[0]),
  829. "create": (Create, Create.description.splitlines()[0]),
  830. "update": (Update, Update.description.splitlines()[0]),
  831. "install": (Install, Install.description.splitlines()[0]),
  832. "remove": (Remove, Remove.description.splitlines()[0]),
  833. "migrate": (Migrate, Migrate.description.splitlines()[0]),
  834. "export": (Export, Export.description.splitlines()[0]),
  835. "import": (Import, Import.description.splitlines()[0]),
  836. }
  837. @classmethod
  838. def main(cls):
  839. elyra_metadata = cls(argv=sys.argv[1:])
  840. elyra_metadata.start()
  841. def __init__(self, **kwargs):
  842. super().__init__(**kwargs)
  843. self.schemaspace_schemas = {}
  844. schema_mgr = SchemaManager.instance()
  845. # Migration should include deprecated schemaspaces
  846. include_deprecated = False
  847. args = kwargs.get("argv", [])
  848. if len(args) > 0:
  849. # identify commands that can operate on deprecated schemaspaces
  850. include_deprecated = args[0] not in ["install", "create", "update", "import"]
  851. schemaspace_names = schema_mgr.get_schemaspace_names(include_deprecated=include_deprecated)
  852. for name in schemaspace_names:
  853. self.schemaspace_schemas[name] = schema_mgr.get_schemaspace_schemas(name)
  854. def start(self):
  855. subcommand = self.get_subcommand()
  856. subinstance = subcommand[0](argv=self.argv, schemaspace_schemas=self.schemaspace_schemas)
  857. return subinstance.start()
  858. def print_help(self):
  859. super().print_help()
  860. self.print_subcommands()
  861. if __name__ == "__main__":
  862. MetadataApp.main()