diff --git a/elyra/cli/__init__.py b/elyra/cli/__init__.py new file mode 100644 index 000000000..4ab3d2186 --- /dev/null +++ b/elyra/cli/__init__.py @@ -0,0 +1,18 @@ +# +# Copyright 2018-2021 Elyra Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from .utils import log_and_exit +from .decorators import options_from_schema diff --git a/elyra/cli/decorators.py b/elyra/cli/decorators.py new file mode 100644 index 000000000..5f6c14388 --- /dev/null +++ b/elyra/cli/decorators.py @@ -0,0 +1,40 @@ +# +# Copyright 2018-2021 Elyra Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import click +from elyra.metadata import SchemaManager + + +def options_from_schema(): + def decorator(f): + namespace_schemas = SchemaManager.load_namespace_schemas() + for namespace, schemas in namespace_schemas.items(): + for schema_name, schema in schemas.items(): + required_props = schema['properties']['metadata'].get('required') + for name, value in schema['properties']['metadata']['properties'].items(): + print(f'>>> processing parameter -> {name}') + param_decls = (f'--{name}') + + attrs = dict() + if name in required_props: + attrs['required'] = True + else: + attrs['required'] = False + + click.option(*param_decls, **attrs)(f) + return f + + return decorator diff --git a/elyra/cli/elyra_app.py b/elyra/cli/elyra_app.py new file mode 100644 index 000000000..222667bd4 --- /dev/null +++ b/elyra/cli/elyra_app.py @@ -0,0 +1,27 @@ +# +# Copyright 2018-2021 Elyra Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import click +import entrypoints + + +@click.group() +def main(): + pass + + +for cli_app in entrypoints.get_group_all('elyra.pipeline.cli'): + app_instance = cli_app.load() + main.add_command(app_instance) diff --git a/elyra/cli/metadata_app.py b/elyra/cli/metadata_app.py new file mode 100644 index 000000000..b99806592 --- /dev/null +++ b/elyra/cli/metadata_app.py @@ -0,0 +1,176 @@ +# +# Copyright 2018-2021 Elyra Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import click +# import json +# import os +from elyra.metadata import MetadataManager, MetadataNotFoundError, SchemaManager +from jsonschema import ValidationError + +from elyra.cli import log_and_exit, options_from_schema + +# from elyra.metadata.metadata_app import NamespaceBase + + +@click.group() +def metadata(): # *args, **kwargs + pass + + +@click.command(help='List metadata instances for a given namespace.') +@click.argument('namespace', type=str, required=False) +@click.option('--valid-only', + type=bool, + required=False, + default=False, + help='Only list valid instances (default includes invalid instances') +@click.option('--json', type=bool, required=False, default=False, help='List complete instances as JSON') +def list(namespace, valid_only, json): + _validate_namespace(namespace) + include_invalid = not valid_only + + metadata_instances = None + try: + metadata_manager = MetadataManager(namespace=namespace) + metadata_instances = metadata_manager.get_all(include_invalid=include_invalid) + except MetadataNotFoundError: + pass + + if json: + if metadata_instances is None: + metadata_instances = [] + print(metadata_instances) + else: + if not metadata_instances: + print("No metadata instances found for {}".format(namespace)) + return + + validity_clause = "includes invalid" if include_invalid else "valid only" + print("Available metadata instances for {} ({}):".format(namespace, validity_clause)) + + sorted_instances = sorted(metadata_instances, key=lambda inst: (inst.schema_name, inst.name)) + # pad to width of longest instance + max_schema_name_len = len('Schema') + max_name_len = len('Instance') + max_resource_len = len('Resource') + for instance in sorted_instances: + max_schema_name_len = max(len(instance.schema_name), max_schema_name_len) + max_name_len = max(len(instance.name), max_name_len) + max_resource_len = max(len(instance.resource), max_resource_len) + + print() + print("%s %s %s " % ('Schema'.ljust(max_schema_name_len), + 'Instance'.ljust(max_name_len), + 'Resource'.ljust(max_resource_len))) + print("%s %s %s " % ('------'.ljust(max_schema_name_len), + '--------'.ljust(max_name_len), + '--------'.ljust(max_resource_len))) + for instance in sorted_instances: + invalid = "" + if instance.reason and len(instance.reason) > 0: + invalid = "**INVALID** ({})".format(instance.reason) + print("%s %s %s %s" % (instance.schema_name.ljust(max_schema_name_len), + instance.name.ljust(max_name_len), + instance.resource.ljust(max_resource_len), + invalid)) + + +@click.command(help='Remove a metadata instance from a given namespace.') +@click.argument('namespace', type=str, required=False) +@click.option('--name', type=str, required=True, help='The name of the metadata instance to remove') +def remove(namespace, name): + _validate_namespace(namespace) + try: + metadata_manager = MetadataManager(namespace=namespace) + metadata_manager.get(name) + metadata_manager.remove(name) + print("Metadata instance '{}' removed from namespace '{}'.".format(name, namespace)) + + except MetadataNotFoundError as mnfe: + log_and_exit(mnfe) + except ValidationError: # Probably deleting invalid instance + pass + + +@click.command(help='Install a metadata instance into a given namespace.') +@click.argument('namespace', type=str, required=False) +@click.option('--name', type=str, required=True, help='The name of the metadata instance to add') +@click.option('--replace', type=bool, required=False, default=False, help='Replace existing instance') +@options_from_schema() +def install(namespace, name, replace, *args, **kwargs): + _validate_namespace(namespace) + + +def _get_namespaces(): + namespaces = [] + namespace_schemas = SchemaManager.load_namespace_schemas() + for namespace, schemas in namespace_schemas.items(): + namespaces.append(namespace) + print('>>>') + print(namespace) + print(schemas) + print() + return namespaces + + +def _validate_namespace(namespace): + msg = None + if not namespace: + msg = f'No namespace specified. Must specify one of: {_get_namespaces()}' + elif namespace not in _get_namespaces(): + msg = f'No namespace specified. Must specify one of: {_get_namespaces()}' + + if msg: + log_and_exit(msg) + + + + +# def _schema_to_options(schema): +# """ +# Takes a JSON schema and builds a list of SchemaProperty instances corresponding to each +# property in the schema. There are two sections of properties, one that includes +# schema_name and display_name and another within the metadata container - which +# will be separated by class type - SchemaProperty vs. MetadataSchemaProperty. +# """ +# options = {} +# properties = schema['properties'] +# for name, value in properties.items(): +# if name == 'schema_name': # already have this option, skip +# continue +# if name != 'metadata': +# options[name] = SchemaProperty(name, value) +# else: # process metadata properties... +# metadata_properties = properties['metadata']['properties'] +# for md_name, md_value in metadata_properties.items(): +# options[md_name] = MetadataSchemaProperty(md_name, md_value) +# +# # Now set required-ness on MetadataProperties and top-level Properties +# required_props = properties['metadata'].get('required') +# for required in required_props: +# options.get(required).required = True +# +# required_props = schema.get('required') +# for required in required_props: +# # skip schema_name & metadata, already required, and metadata is not an option to be presented +# if required not in ['schema_name', 'metadata']: +# options.get(required).required = True +# return list(options.values()) + + +metadata.add_command(list) +metadata.add_command(remove) +metadata.add_command(install) diff --git a/elyra/cli/utils.py b/elyra/cli/utils.py new file mode 100644 index 000000000..dda958a9e --- /dev/null +++ b/elyra/cli/utils.py @@ -0,0 +1,33 @@ +# +# Copyright 2018-2021 Elyra Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import click +import sys + + +def log_and_exit(msg=None, show_help=True, exit_status=1): + """ + Print a help message and exit with a proper exit status. The help parameter + can be a help function. + """ + if msg: + click.echo(msg) + click.echo() + + if show_help: + click.echo(click.get_current_context().get_help()) + + sys.exit(exit_status) diff --git a/elyra/metadata/schema.py b/elyra/metadata/schema.py index 568b67d2c..0db548b18 100644 --- a/elyra/metadata/schema.py +++ b/elyra/metadata/schema.py @@ -29,8 +29,9 @@ class SchemaManager(SingletonConfigurable): - """Singleton used to store all schemas for all metadata types. - Note: we currently don't refresh these entries. + """ + Singleton used to store all schemas for all metadata types. + Note: we currently don't refresh these entries. """ def __init__(self, **kwargs): @@ -62,13 +63,17 @@ def get_schema(self, namespace: str, schema_name: str) -> dict: return schema_json def add_schema(self, namespace: str, schema_name: str, schema: dict) -> None: - """Adds (updates) schema to set of stored schemas. """ + """ + Adds (updates) schema to set of stored schemas. + """ self.validate_namespace(namespace) self.log.debug("SchemaManager: Adding schema '{}' to namespace '{}'".format(schema_name, namespace)) self.namespace_schemas[namespace][schema_name] = schema def clear_all(self) -> None: - """Primarily used for testing, this method reloads schemas from initial values. """ + """ + Primarily used for testing, this method reloads schemas from initial values. + """ self.log.debug("SchemaManager: Reloading all schemas for all namespaces.") self.namespace_schemas = SchemaManager.load_namespace_schemas() @@ -80,11 +85,12 @@ def remove_schema(self, namespace: str, schema_name: str) -> None: @classmethod def load_namespace_schemas(cls, schema_dir: Optional[str] = None) -> dict: - """Loads the static schema files into a dictionary indexed by namespace. - If schema_dir is not specified, the static location relative to this - file will be used. - Note: The schema file must have a top-level string-valued attribute - named 'namespace' to be included in the resulting dictionary. + """ + Loads the static schema files into a dictionary indexed by namespace. + If schema_dir is not specified, the static location relative to this + file will be used. + Note: The schema file must have a top-level string-valued attribute + named 'namespace' to be included in the resulting dictionary. """ # The following exposes the metadata-test namespace if true or 1. # Metadata testing will enable this env. Note: this cannot be globally diff --git a/setup.py b/setup.py index 0718aab3c..88526a607 100644 --- a/setup.py +++ b/setup.py @@ -109,7 +109,11 @@ ), entry_points={ 'console_scripts': [ - 'elyra-metadata = elyra.metadata.metadata_app:MetadataApp.main', + 'elyra = elyra.cli.elyra_app:main' + # 'elyra-metadata = elyra.cli.metadata_app:metadata', + ], + 'elyra.pipeline.cli': [ + 'metadata = elyra.cli.metadata_app:metadata', ], 'elyra.pipeline.processors': [ 'local = elyra.pipeline.processor_local:LocalPipelineProcessor',