metadata_app_utils.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  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 ast
  17. import json
  18. import logging
  19. import os.path
  20. import sys
  21. from typing import Any
  22. from typing import Dict
  23. from typing import List
  24. from typing import Optional
  25. from typing import Set
  26. from elyra.metadata.manager import MetadataManager
  27. """Utility functions and classes used for metadata applications and classes."""
  28. logging.basicConfig(level=logging.INFO, format="[%(levelname)1.1s %(asctime)s.%(msecs).03d] %(message)s")
  29. class Option(object):
  30. """Represents the base option class."""
  31. def __init__(
  32. self,
  33. cli_option: str,
  34. name: Optional[str] = None,
  35. description: Optional[str] = None,
  36. default_value: Optional[Any] = None,
  37. value: Optional[Any] = None,
  38. enum: Optional[List[Any]] = None,
  39. required: bool = False,
  40. type: str = "string",
  41. ):
  42. self.cli_option: str = cli_option
  43. self.name: Optional[str] = name
  44. self.description: Optional[str] = description
  45. self.default_value = default_value
  46. self.value: Optional[Any] = default_value
  47. self.enum: Optional[List[Any]] = enum
  48. self.required: bool = required
  49. self.type: str = type
  50. self.processed: bool = False
  51. self.bad_value: Optional[str] = None # set_value() can set this to an error message
  52. def set_value(self, value: Any):
  53. try:
  54. if self.type == "string":
  55. self.value = value
  56. elif self.type == "array":
  57. self.value = Option.coerce_array_value(value)
  58. elif self.type == "object":
  59. self.value = self._get_object_value(value)
  60. elif self.type == "integer":
  61. self.value = int(value)
  62. elif self.type == "number":
  63. if "." in value:
  64. self.value = float(value)
  65. else:
  66. self.value = int(value)
  67. elif self.type == "boolean":
  68. if isinstance(value, bool):
  69. self.value = value
  70. elif str(value).lower() in ("true", "1"):
  71. self.value = True
  72. elif str(value).lower() in ("false", "0"):
  73. self.value = False
  74. else:
  75. self.value = value # let it take its course
  76. elif self.type == "null":
  77. if str(value) in ("null", "None"):
  78. self.value = None
  79. else:
  80. self.value = value
  81. else: # type is None, try special ast eval...
  82. try:
  83. self.value = ast.literal_eval(value)
  84. except Exception: # Just go with what they gave since type is undefined
  85. self.value = value
  86. except (ValueError, SyntaxError):
  87. self.handle_value_error(value)
  88. def _get_object_value(self, value: str) -> Dict:
  89. """Checks if value is an existing filename, if so, it reads the file and loads its JSON,
  90. otherwise, it evaluates the string and ensures its evaluated as a dictionary.
  91. """
  92. object_value: Optional[Dict] = None
  93. if os.path.isfile(value):
  94. try:
  95. with open(value) as json_file:
  96. try:
  97. object_value = json.load(json_file)
  98. except Exception as ex1:
  99. self.bad_value = (
  100. f"Parameter '{self.cli_option}' requires a JSON-formatted file or string "
  101. f"and the following error occurred attempting to load {value}'s contents "
  102. f"as JSON: '{ex1}'. Try again with an appropriately formatted file."
  103. )
  104. except Exception as ex:
  105. if self.bad_value is None: # Error is file-related
  106. self.bad_value = (
  107. f"Parameter '{self.cli_option}' requires a JSON-formatted file or string "
  108. f"and the following error occurred attempting to open file '{value}': '{ex}'. "
  109. f"Try again with an appropriately formatted file."
  110. )
  111. else: # not a file, so evaluate and ensure its of the right type
  112. try:
  113. object_value = ast.literal_eval(value) # use ast over json.loads as its more forgiving
  114. except Exception as ex:
  115. self.bad_value = (
  116. f"Parameter '{self.cli_option}' requires a JSON-formatted file or string and "
  117. f"the following error occurred attempting to interpret the string as JSON: '{ex}'. "
  118. f"Try again with an appropriate value."
  119. )
  120. if type(object_value) is not dict:
  121. self.bad_value = (
  122. f"Parameter '{self.cli_option}' requires a JSON-formatted file or string and "
  123. f"could not interpret the string as a dictionary and got a {type(object_value)} "
  124. f"instead. Try again with an appropriate value."
  125. )
  126. return object_value
  127. @staticmethod
  128. def coerce_array_value(value: Any):
  129. new_value = value
  130. if value[0] != "[" and value[-1] != "]": # attempt to coerce to list
  131. new_value = str(value.split(","))
  132. # The following assumes the array items should be strings and will break
  133. # non-quoted items like integers, numbers and booleans. Its being left
  134. # here in case we want to support that scenario, which would likely mean
  135. # checking the entries to ensure they are expecting string values before
  136. # splitting.
  137. # elif value[0] == '[' and value[-1] == ']':
  138. # # we have brackets. If not internal quotes split within the brackets.
  139. # # This handles the common (but invalid) "[item1,item2]" format.
  140. # if value[1] not in ["'", '"'] and value[-2] not in ["'", '"']:
  141. # new_value = str(value[1:-1].split(","))
  142. return ast.literal_eval(new_value)
  143. @staticmethod
  144. def get_article(type: str) -> str:
  145. vowels = ["a", "e", "i", "o", "u"] # we'll deal with 'y' as needed
  146. if type[0] in vowels:
  147. return "an"
  148. return "a"
  149. def get_format_hint(self) -> str:
  150. if self.enum:
  151. msg = f"must be one of: {self.enum}"
  152. elif self.type == "array":
  153. msg = "\"['item1', 'item2']\" or \"item1,item2\""
  154. elif self.type == "object":
  155. msg = "\"{'str1': 'value1', 'int2': 2}\" or file containing JSON"
  156. elif self.type == "integer":
  157. msg = "'n' where 'n' is an integer"
  158. elif self.type == "number":
  159. msg = "'n.m' where 'n' and 'm' are integers"
  160. elif self.type == "boolean":
  161. msg = "'true' or 'false'"
  162. elif self.type == "null":
  163. msg = "'null' or 'None'"
  164. elif self.type == "string":
  165. msg = "sequence of characters"
  166. else:
  167. msg = "Can't be determined"
  168. return msg
  169. def handle_value_error(self, value: Any) -> None:
  170. pre_amble = f"Parameter '{self.cli_option}' requires {Option.get_article(self.type)} {self.type} with format:"
  171. post_amble = f'and "{value}" was given. Try again with an appropriate value.'
  172. self.bad_value = f"{pre_amble} {self.get_format_hint()} {post_amble}"
  173. def get_additional_info(self) -> str:
  174. return ""
  175. def print_help(self):
  176. data_type = self.type if self.type is not None else "?"
  177. option_entry = f"{self.cli_option}=<{data_type}>"
  178. required_entry = ""
  179. if self.required:
  180. required_entry = "Required. "
  181. format_entry = f"Format: {self.get_format_hint()}"
  182. additional_info = self.get_additional_info()
  183. print(f"{option_entry} ({required_entry}{format_entry}) {additional_info}")
  184. self.print_description()
  185. def print_description(self):
  186. print(f"\t{self.description}")
  187. class Flag(Option):
  188. """Represents a command-line flag. When present, the value used is `not default_value`."""
  189. def __init__(self, flag: str, **kwargs):
  190. super().__init__(flag, type="boolean", **kwargs)
  191. def print_help(self):
  192. print(self.cli_option)
  193. self.print_description()
  194. class CliOption(Option):
  195. """Represents a command-line option."""
  196. def __init__(self, cli_option: str, **kwargs):
  197. super().__init__(cli_option, **kwargs)
  198. class SchemaProperty(CliOption):
  199. """Represents the necessary information to handle a property from the schema.
  200. No validation is performed on corresponding instance values since the
  201. schema validation in the metadata service applies that.
  202. SchemaProperty instances are initialized from the corresponding property stanza
  203. from the schema
  204. """
  205. # The following keywords are not supported. If encountered, an exception will be
  206. # raised indicating that --file or --json be used, or, if an object-valued property
  207. unsupported_keywords = {"$ref", "$defs", "$anchor", "oneOf", "anyOf", "allOf"}
  208. # Skip the following keywords when building the description. We will already
  209. # have description and type and the others are difficult to display in a succinct manner.
  210. # Schema validation will still enforce these.
  211. skipped_keywords = {
  212. "description",
  213. "type",
  214. "items",
  215. "additionalItems",
  216. "properties" "propertyNames",
  217. "dependencies",
  218. "examples",
  219. "contains",
  220. "additionalProperties",
  221. "patternProperties",
  222. }.union(unsupported_keywords)
  223. # Turn off the inclusion of meta-property information in the printed help messages (Issue #837)
  224. print_meta_properties = False
  225. def __init__(self, name: str, schema_property: Dict):
  226. self.schema_property = schema_property
  227. cli_option = "--" + name
  228. super().__init__(
  229. cli_option=cli_option,
  230. name=name,
  231. description=schema_property.get("description"),
  232. default_value=schema_property.get("default"),
  233. enum=schema_property.get("enum"),
  234. type=schema_property.get("type"),
  235. )
  236. def print_description(self):
  237. additional_clause = ""
  238. if self.print_meta_properties: # Only if enabled
  239. for meta_prop, value in self.schema_property.items():
  240. if meta_prop in self.skipped_keywords:
  241. continue
  242. additional_clause = self._build_clause(additional_clause, meta_prop, value)
  243. description = self.description or ""
  244. print(f"\t{description}{additional_clause}")
  245. def _build_clause(self, additional_clause, meta_prop, value):
  246. if len(additional_clause) == 0:
  247. additional_clause = additional_clause + "; "
  248. else:
  249. additional_clause = additional_clause + ", "
  250. additional_clause = additional_clause + meta_prop + ": " + str(value)
  251. return additional_clause
  252. class MetadataSchemaProperty(SchemaProperty):
  253. """Represents the property from the schema that resides in the Metadata stanza."""
  254. def __init__(self, name: str, schema_property: Dict):
  255. super().__init__(name, schema_property)
  256. self.unsupported_meta_props = self._get_unsupported_keywords()
  257. def get_additional_info(self) -> str:
  258. if self.unsupported_meta_props:
  259. return f"*** References unsupported keywords: {self.unsupported_meta_props}. See note below."
  260. return super().get_additional_info()
  261. def _get_unsupported_keywords(self) -> Set[str]:
  262. """Returns the set of unsupported keywords found at the top-level of this property's schema."""
  263. # Gather top-level property names from schema into a set and return the intersection
  264. # with the unsupported keywords.
  265. schema_props = set(self.schema_property.keys())
  266. return schema_props & SchemaProperty.unsupported_keywords
  267. class JSONBasedOption(CliOption):
  268. """Represents a command-line option representing a JSON string."""
  269. def __init__(self, cli_option: str, **kwargs):
  270. super().__init__(cli_option, type="object", **kwargs)
  271. self._schema_name_arg = None
  272. self._display_name_arg = None
  273. self._name_arg = None
  274. self._metadata = None
  275. @property
  276. def schema_name_arg(self) -> str:
  277. if self._schema_name_arg is None:
  278. if self.value is not None:
  279. self._schema_name_arg = self.value.get("schema_name")
  280. return self._schema_name_arg
  281. @property
  282. def display_name_arg(self) -> str:
  283. if self._display_name_arg is None:
  284. if self.value is not None:
  285. self._display_name_arg = self.value.get("display_name")
  286. return self._display_name_arg
  287. @property
  288. def name_arg(self) -> str: # Overridden in base class
  289. return self._name_arg
  290. @property
  291. def metadata(self) -> Dict:
  292. """Returns the metadata stanza in the JSON. If not present, it considers
  293. the complete JSON to be the "metadata stanza", allowing applications to
  294. create instances more easily.
  295. """
  296. if self._metadata is None:
  297. if self.value is not None:
  298. self._metadata = self.value.get("metadata")
  299. # This stanza may not be present. If not, set to the
  300. # entire json since this could be the actual user data (feature)
  301. if self._metadata is None:
  302. self._metadata = self.value
  303. if self._metadata is None:
  304. self._metadata = {}
  305. return self._metadata
  306. def transfer_names_to_argvs(self, argv: List[str], argv_mappings: Dict[str, str]):
  307. """Transfers the values for schema_name, display_name and name to the argv sets if not currently set
  308. via command line. This can simplify the command line when already specified in the JSON. It also
  309. enables a way for these values to override the values in the JSON by setting them on the command line.
  310. """
  311. for option in ["schema_name", "display_name", "name"]:
  312. arg: str = f"--{option}"
  313. if arg not in argv_mappings.keys():
  314. if option == "schema_name":
  315. name = self.schema_name_arg
  316. elif option == "display_name":
  317. name = self.display_name_arg
  318. else:
  319. name = self.name_arg
  320. if name is not None: # Only include if we have a value
  321. argv_mappings[arg] = name
  322. argv.append(f"{arg}={name}")
  323. class JSONOption(JSONBasedOption):
  324. """Represents a command-line option representing a JSON string."""
  325. @property
  326. def name_arg(self):
  327. # Name can be derived from display_name using normalization method.
  328. if self._name_arg is None:
  329. if self.value is not None and self.display_name_arg is not None:
  330. self._name_arg = MetadataManager.get_normalized_name(self.display_name_arg)
  331. return self._name_arg
  332. def get_format_hint(self) -> str:
  333. return (
  334. "A JSON-formatted string consisting of at least the 'metadata' stanza "
  335. "(e.g., --json=\"{'metadata': { 'value1', 'int2': 2 }}\""
  336. )
  337. def set_value(self, value: str):
  338. """Take the given value (json), and load it into a dictionary to ensure it parses as JSON."""
  339. # Confirm that value a) is not None and b) specifies an existing file
  340. if value is None:
  341. self.bad_value = (
  342. "Parameter '--json' requires a value with format JSON and no value was provided. "
  343. "Try again with an appropriate value."
  344. )
  345. else:
  346. super().set_value(value)
  347. class FileOption(JSONBasedOption):
  348. """Represents a command-line option representing a file containing JSON."""
  349. def __init__(self, cli_option: str, **kwargs):
  350. super().__init__(cli_option, **kwargs)
  351. self.filename = ""
  352. @property
  353. def name_arg(self):
  354. # Name can be derived from the filename
  355. if self._name_arg is None:
  356. if self.value is not None:
  357. self._name_arg = os.path.splitext(os.path.basename(self.filename))[0]
  358. return self._name_arg
  359. def get_format_hint(self) -> str:
  360. return "An existing file containing valid JSON consisting of at least the 'metadata' stanza"
  361. def set_value(self, value: str):
  362. """Take the given value (file), open the file and load it into a dictionary to ensure it parses as JSON."""
  363. # Confirm that value a) is not None and b) specifies an existing file
  364. if value is None:
  365. self.bad_value = (
  366. "Parameter '--file' requires a file with format JSON and no value was provided. "
  367. "Try again with an appropriate value."
  368. )
  369. else:
  370. if not os.path.isfile(value):
  371. self.bad_value = (
  372. f"Parameter '--file' requires a file with format JSON and {value} is not a file. "
  373. "Try again with an appropriate value."
  374. )
  375. else:
  376. self.filename = value
  377. super().set_value(value)
  378. def handle_value_error(self, value: Any) -> None:
  379. pre_amble = "Parameter '--file' requires a file with format: JSON "
  380. post_amble = f'and "{value}" was given. Try again with an appropriate value.'
  381. self.bad_value = f"{pre_amble} {self.get_format_hint()} {post_amble}"
  382. class AppBase(object):
  383. """Base class for application-level classes. Provides logging, arguments handling,
  384. help methods, and anything common to its derived classes.
  385. """
  386. subcommands = {}
  387. description = None
  388. argv = []
  389. argv_mappings = {} # Contains separation of argument name to value
  390. def __init__(self, **kwargs):
  391. self.argv = kwargs["argv"]
  392. self._get_argv_mappings()
  393. self.log = logging.getLogger() # setup logger so that metadata service logging is displayed
  394. def _get_argv_mappings(self):
  395. """Walk argv and build mapping from argument to value for later processing."""
  396. check_next_parameter: bool = False
  397. log_option: Optional[str] = None
  398. option: str = "" # set to empty to satsify linter in check_next_parameter logic below
  399. value: Optional[str]
  400. for arg in self.argv:
  401. if check_next_parameter:
  402. check_next_parameter = False
  403. if not arg.startswith("--"):
  404. # if this doesn't specify a new argument,
  405. # set this value as the previous option's value
  406. self.argv_mappings[option] = arg
  407. continue
  408. if "=" in arg:
  409. option, value = arg.split("=", 1)
  410. else:
  411. option, value = arg, None
  412. # Check for --debug or --log-level option. if found set, appropriate
  413. # log-level and skip. Note this so we can alter self.argv after processing.
  414. if option == "--debug":
  415. log_option = arg
  416. logging.getLogger().setLevel(logging.DEBUG)
  417. continue
  418. elif option == "--log-level":
  419. log_option = arg
  420. logging.getLogger().setLevel(value)
  421. continue
  422. # Handle case where an argument specifies its parameter w/o an `=`
  423. # to enable better flexibility and compatibility with other CLI tools.
  424. if option.startswith("--") and value is None:
  425. check_next_parameter = True
  426. self.argv_mappings[option] = value
  427. if log_option:
  428. self.argv.remove(log_option)
  429. def log_and_exit(self, msg: Optional[str] = None, exit_status: int = 1, display_help: bool = False):
  430. if msg:
  431. # Prefix message with 'ERROR: ' if we're going to follow it up with full usage so
  432. # the error is not lost amongst the usage.
  433. full_msg = f"ERROR: {msg}" if display_help else msg
  434. print(full_msg)
  435. if display_help:
  436. print()
  437. self.print_help()
  438. AppBase.exit(exit_status)
  439. def get_subcommand(self):
  440. """Checks argv[0] to see if it matches one of the expected subcommands. If so,
  441. that item is removed from argv and that subcommand tuple (class, description)
  442. is returned. If no an expected subcommand is not found (None, None) is returned.
  443. """
  444. if len(self.argv) > 0:
  445. arg = self.argv[0]
  446. if arg in self.subcommands.keys():
  447. subcommand = self.subcommands.get(arg)
  448. self._remove_argv_entry(arg)
  449. return subcommand
  450. if arg in ["--help", "-h"]:
  451. self.log_and_exit(display_help=True)
  452. msg = f"Subcommand '{self.argv[0]}' is invalid."
  453. else:
  454. msg = "No subcommand specified."
  455. self.exit_no_subcommand(msg)
  456. def exit_no_subcommand(self, msg: str):
  457. print(f"{msg} One of: {list(self.subcommands)} must be specified.")
  458. print()
  459. self.print_subcommands()
  460. self.exit(1)
  461. def process_cli_option(self, cli_option: Option, check_help: bool = False):
  462. """Check if the given option exists in the current arguments. If found set its
  463. the Option instance's value to that of the argv. Once processed, update the
  464. argv lists by removing the option. If the option is a required property and
  465. is not in the argv lists or does not have a value, exit.
  466. """
  467. # if check_help is enabled, check the arguments for help options and
  468. # exit if found. This is only necessary when processing individual options.
  469. if check_help and self.has_help():
  470. self.log_and_exit(display_help=True)
  471. if cli_option.processed:
  472. return
  473. option = cli_option.cli_option
  474. if option in self.argv_mappings.keys():
  475. if isinstance(cli_option, Flag): # flags set their value opposite their default
  476. cli_option.value = not cli_option.default_value
  477. else: # this is a regular option, just set value
  478. cli_option.set_value(self.argv_mappings.get(option))
  479. if cli_option.bad_value:
  480. self.log_and_exit(cli_option.bad_value, display_help=True)
  481. if cli_option.required:
  482. if not cli_option.value:
  483. self.log_and_exit(f"Parameter '{cli_option.cli_option}' requires a value.", display_help=True)
  484. elif cli_option.enum: # ensure value is in set
  485. if cli_option.value not in cli_option.enum:
  486. self.log_and_exit(
  487. f"Parameter '{cli_option.cli_option}' requires one of the "
  488. f"following values: {cli_option.enum}",
  489. display_help=True,
  490. )
  491. self._remove_argv_entry(option)
  492. elif cli_option.required and cli_option.value is None:
  493. if cli_option.enum is None:
  494. self.log_and_exit(f"'{cli_option.cli_option}' is a required parameter.", display_help=True)
  495. else:
  496. self.log_and_exit(
  497. f"'{cli_option.cli_option}' is a required parameter and must be one of the "
  498. f"following values: {cli_option.enum}.",
  499. display_help=True,
  500. )
  501. cli_option.processed = True
  502. def process_cli_options(self, cli_options: List[Option]):
  503. """For each Option instance in the list, process it according to the argv lists.
  504. After traversal, if arguments still remain, log help and exit.
  505. """
  506. # Since we're down to processing options (no subcommands), scan the arguments
  507. # for help entries and, if found, exit with the help message.
  508. if self.has_help():
  509. self.log_and_exit(display_help=True)
  510. for option in cli_options:
  511. self.process_cli_option(option)
  512. # Check if there are still unprocessed arguments. If so, and fail_unexpected is true,
  513. # log and exit, else issue warning and continue.
  514. if len(self.argv) > 0:
  515. msg = f"The following arguments were unexpected: {self.argv}"
  516. self.log_and_exit(msg, display_help=True)
  517. def has_help(self):
  518. """Checks the arguments to see if any match the help options.
  519. We do this by converting two lists to sets and checking if
  520. there's an intersection.
  521. """
  522. helps = {"--help", "-h"}
  523. args = set(self.argv_mappings.keys())
  524. help_list = list(helps & args)
  525. return len(help_list) > 0
  526. def _remove_argv_entry(self, cli_option: str):
  527. """Removes the argument entry corresponding to cli_option in both
  528. self.argv and self.argv_mappings
  529. """
  530. # build the argv entry from the mappings since it must be located with name=value
  531. if cli_option not in self.argv_mappings.keys():
  532. self.log_and_exit(f"Can't find option '{cli_option}' in argv!")
  533. entry = cli_option
  534. value = self.argv_mappings.get(cli_option)
  535. if value:
  536. # Determine if this value is associated with the option via '=' or
  537. # a "floating" option/value pair
  538. entry = entry + "=" + value
  539. if entry in self.argv:
  540. self.argv.remove(entry)
  541. else: # remove both of the "floating" option/value pair
  542. self.argv.remove(cli_option)
  543. self.argv.remove(value)
  544. else:
  545. self.argv.remove(entry)
  546. self.argv_mappings.pop(cli_option)
  547. def print_help(self):
  548. self.print_description()
  549. def print_description(self):
  550. print(self.description or "")
  551. def print_subcommands(self):
  552. print()
  553. print("Subcommands")
  554. print("-----------")
  555. print("Subcommands are launched as `elyra-metadata cmd [args]`. For information on")
  556. print("using subcommand 'cmd', run: `elyra-metadata cmd -h` or `elyra-metadata cmd --help`.")
  557. print("\nFind more information at https://elyra.readthedocs.io/en/latest/")
  558. print()
  559. for subcommand, desc in self.subcommands.items():
  560. print(f"{subcommand:<10}{desc[1]:>10}")
  561. @staticmethod
  562. def exit(status: int):
  563. sys.exit(status)