diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 804000ad..cf7a4a4e 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -17,7 +17,7 @@ jobs: runs-on: ${{ matrix.config.os }} strategy: matrix: - python-version: [3.9] + python-version: [3.6] config: - os: windows-latest steps: @@ -37,12 +37,15 @@ jobs: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - + - name: Testing CLI (Runs both unit and integration tests) + run: | + coverage run --source=greengrassTools -m pytest -v -s tests && coverage report --show-missing --fail-under=70 + build-on-non-windows: runs-on: ${{ matrix.config.os }} strategy: matrix: - python-version: [3.9] + python-version: [3.6] config: - os: ubuntu-latest - os: macos-latest @@ -62,4 +65,7 @@ jobs: run: | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics \ No newline at end of file + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Testing CLI (Runs both unit and integration tests) + run: | + coverage run --source=greengrassTools -m pytest -v -s tests && coverage report --show-missing --fail-under=70 \ No newline at end of file diff --git a/.gitignore b/.gitignore index f059621d..1964c61e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,11 @@ .idea/ **.DS_STORE **.DS_Store -*.iml \ No newline at end of file +*.iml +*build/ +*dist/ +*.egg-info +*__pycache__ +*htmlcov/ +*.coverage +*.vscode diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..b617068f --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include greengrassTools/static/* \ No newline at end of file diff --git a/README.md b/README.md index 847260ca..6d4e8e3e 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,24 @@ -## My Project -TODO: Fill this README out! +## Build the tool +``` +python3 setup.py install +python3 setup.py sdist bdist_wheel +pip3 install dist/greengrass_tools-1.0.0-py3-none-any.whl --force-reinstall +``` -Be sure to: +After installing greengrass-tools, run commands like +``` +greengrass-tools --help +greengrass-tools component --help +greengrass-tools component init --help +``` -* Change the title in this README -* Edit your repository description on GitHub +## Testing -## Security +``` +pip3 install pytest coverage +``` -See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. - -## License - -This project is licensed under the Apache-2.0 License. +From the root folder, run +```coverage run --source=greengrassTools -m pytest -v -s tests && coverage report --show-missing``` diff --git a/greengrassTools/CLIParser.py b/greengrassTools/CLIParser.py new file mode 100644 index 00000000..1edbde32 --- /dev/null +++ b/greengrassTools/CLIParser.py @@ -0,0 +1,115 @@ +import argparse + +import greengrassTools.common.consts as consts +import greengrassTools.common.model_actions as model_actions +import greengrassTools.common.parse_args_actions as parse_args_actions + + +class CLIParser: + def __init__(self, command, top_level_parser): + """A class that represents an argument parser at command level.""" + self.command = command + if command != consts.cli_tool_name: + self.top_level_parser = top_level_parser + self.parser = self.top_level_parser.add_parser( + command, help="{} help".format(command) + ) + else: + self.parser = argparse.ArgumentParser(prog=consts.cli_tool_name) + self.subparsers = self.parser.add_subparsers( + dest=command, help="{} help".format(command) + ) + + def create_parser(self, cli_model): + """ + Creates a parser with arguments and subcommands at specified command level and returns it. + + Parameters + ---------- + cli_model(dict): A dictonary object which contains CLI arguments and sub-commands at each command level. + + Returns + ------- + parser(argparse.ArgumentParser): ArgumentParser object which can parse args at its command level. + """ + if self.command in cli_model: + self.command_model = cli_model[self.command] + self._add_arguments() + self._get_subcommands_from_model(cli_model) + return self.parser + + def _add_arguments(self): + """ + Adds command-line argument to the parser to define how it should be parsed from commandline. + + Retrieves and passes positionl/optional args along with all other parameters as kwargs from the + provided at each command level. + + Parameters + ---------- + None + + Returns + ------- + None + """ + if "arguments" in self.command_model: + arguments = self.command_model["arguments"] + for argument in arguments: + name, other_args = self._get_arg_from_model(argument) + if len(name) == 2: # For optional args + self.parser.add_argument(name[0], name[1], **other_args) + elif len(name) == 1: # For positional args + self.parser.add_argument(name[0], **other_args) + + def _get_subcommands_from_model(self, cli_model): + """ + Creates a subparser for every subcommand of a command. + + Retrieves and passes positionl/optional args along with all other parameters as kwargs from the + provided at each command level. + + Parameters + ---------- + cli_model(dict): A dictonary object which contains CLI arguments and sub-commands at each command level. + + Returns + ------- + None + """ + + if "sub-commands" in self.command_model: + sub_commands = self.command_model["sub-commands"] + for sub_command in sub_commands: + CLIParser(sub_command, self.subparsers).create_parser(cli_model) + + def _get_arg_from_model(self, argument): + """ + Creates parameters of parser.add_argument from the argument in cli_model. + + Parameters + ---------- + argument(dict): A dictonary object which argument parameters. + Full list: greengrassTools.common.consts.arg_parameters + + Returns + ------- + argument["name"](list): List of all optional and positional argument parameters + modified_arg(dict): A dictionary object with all other parameters that 'argument' param has. + """ + modified_arg = {} + for param in consts.arg_parameters: + if param in argument and param != "name": + modified_arg[param] = argument[param] + return argument["name"], modified_arg + + +def main(): + cli_tool = CLIParser(consts.cli_tool_name, None) + cli_model = model_actions.get_validated_model() + if cli_model: + cli_parser = cli_tool.create_parser(cli_model) + args_namespace = cli_parser.parse_args() + parse_args_actions.run_command(args_namespace) + else: + print("Please provide a valid model to create a CLI parser") diff --git a/greengrassTools/__init__.py b/greengrassTools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/greengrassTools/commands/__init__.py b/greengrassTools/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/greengrassTools/commands/component/__init__.py b/greengrassTools/commands/component/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/greengrassTools/commands/component/build.py b/greengrassTools/commands/component/build.py new file mode 100644 index 00000000..cb9a83f3 --- /dev/null +++ b/greengrassTools/commands/component/build.py @@ -0,0 +1,2 @@ +def run(args): + print(" I build the project based on args") diff --git a/greengrassTools/commands/component/component.py b/greengrassTools/commands/component/component.py new file mode 100644 index 00000000..c46ac881 --- /dev/null +++ b/greengrassTools/commands/component/component.py @@ -0,0 +1,16 @@ +def init(d_args): + import greengrassTools.commands.component.init as init + + init.run(d_args) + + +def build(d_args): + import greengrassTools.commands.component.build as build + + build.run(d_args) + + +def publish(d_args): + import greengrassTools.commands.component.publish as publish + + publish.run(d_args) diff --git a/greengrassTools/commands/component/init.py b/greengrassTools/commands/component/init.py new file mode 100644 index 00000000..2f62801c --- /dev/null +++ b/greengrassTools/commands/component/init.py @@ -0,0 +1,2 @@ +def run(args): + print(" I init the project based on args") diff --git a/greengrassTools/commands/component/publish.py b/greengrassTools/commands/component/publish.py new file mode 100644 index 00000000..e96bcf6c --- /dev/null +++ b/greengrassTools/commands/component/publish.py @@ -0,0 +1,2 @@ +def run(args): + print(" I publish the project based on args") diff --git a/greengrassTools/commands/methods.py b/greengrassTools/commands/methods.py new file mode 100644 index 00000000..e8589b8d --- /dev/null +++ b/greengrassTools/commands/methods.py @@ -0,0 +1,13 @@ +from greengrassTools.commands.component import component + + +def _greengrass_tools_component_init(d_args): + component.init(d_args) + + +def _greengrass_tools_component_build(d_args): + component.build(d_args) + + +def _greengrass_tools_component_publish(d_args): + component.publish(d_args) diff --git a/greengrassTools/common/__init__.py b/greengrassTools/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/greengrassTools/common/consts.py b/greengrassTools/common/consts.py new file mode 100644 index 00000000..4f13cf51 --- /dev/null +++ b/greengrassTools/common/consts.py @@ -0,0 +1,17 @@ +cli_tool_name = "greengrass-tools" + +cli_tool_name_in_method_names = "greengrass_tools" + +arg_parameters = [ + "name", + "action", + "nargs", + "const", + "default", + "type", + "choices", + "required", + "help", + "metavar", + "dest", +] diff --git a/greengrassTools/common/model_actions.py b/greengrassTools/common/model_actions.py new file mode 100644 index 00000000..f3f7399b --- /dev/null +++ b/greengrassTools/common/model_actions.py @@ -0,0 +1,100 @@ +import os +import json +import greengrassTools + + +def is_valid_model(cli_model, command): + """ + Validates CLI model of arguments and subcommands at the specified command level. + + Parameters + ---------- + cli_model(dict): A dictonary object which contains CLI arguments and sub-commands at each command level. + command(string): Command in the cli_model which is used to validate args and subcommands at its level. + + Returns + ------- + (bool): Returns True when the cli model is valid else False. + """ + if command not in cli_model: + return False + else: + # Validate args + if "arguments" in cli_model[command]: + for argument in cli_model[command]["arguments"]: + if not is_valid_argument_model(argument): + return False + + # Validate sub-commands + if "sub-commands" in cli_model[command]: + if not is_valid_subcommand_model( + cli_model, cli_model[command]["sub-commands"] + ): + return False + return True + + +def is_valid_argument_model(argument): + """ + Validates CLI model specified argument level. + + With this validation, every argument is mandated to have name and help at the minimum. + Any other custom validation to the arguments can go here. + + Parameters + ---------- + argument(dict): A dictonary object which argument parameters. + Full list: greengrassTools.common.consts.arg_parameters + + Returns + ------- + (bool): Returns True when the argument is valid else False. + """ + if "name" not in argument or "help" not in argument: + return False + # Add custom validation for args if needed. + return True + + +def is_valid_subcommand_model(cli_model, subcommands): + """ + Validates CLI model specified subcommand level. + + With this validation, every subcommand is mandated to be present as an individual key in the cli_model. + + Parameters + ---------- + cli_model(dict): A dictonary object which contains CLI arguments and sub-commands at each command level. + subcommands(list): List of subcommands of a command. + + Returns + ------- + (bool): Returns True when the subcommand is valid else False. + """ + for subc in subcommands: + if not is_valid_model(cli_model, subc): + return False + return True + + +def get_validated_model(): + """ + This function loads the cli model json file from static location as a dict and validates it. + + Parameters + ---------- + None + + Returns + ------- + cli_model(dict): Empty if the model is invalid otherwise returns cli model. + """ + cli_model_file = os.path.join( + os.path.dirname(greengrassTools.__file__), "static", "cli_model.json" + ) + with open(cli_model_file) as f: + cli_model = json.loads(f.read()) + if is_valid_model(cli_model, greengrassTools.common.consts.cli_tool_name): + return cli_model + else: + return {} diff --git a/greengrassTools/common/parse_args_actions.py b/greengrassTools/common/parse_args_actions.py new file mode 100644 index 00000000..9770dd5a --- /dev/null +++ b/greengrassTools/common/parse_args_actions.py @@ -0,0 +1,70 @@ +import greengrassTools.commands.methods as command_methods +import greengrassTools.common.consts as consts + + +def run_command(args_namespace): + """ + Based on the namespace, appropriate action is determined and called by its name. + + Parameters + ---------- + args_namespace(argparse.NameSpace): An object holding attributes from parsed command. + + Returns + ------- + None + """ + d_args = vars(args_namespace) # args as dictionary + method_name = get_method_from_command(d_args, consts.cli_tool_name, "") + call_action_by_name(method_name, d_args) + + +def call_action_by_name(method_name, d_args): + """ + Method name determined from the namespace is modified to choose and call actual method by its name. + + Since method names cannot have "-" and "greengrass-tools" contain a "-", we substitute + "greengrass-tools" with "greengrass_tools" + + Parameters + ---------- + method_name(string): Method name determined from the args namespace. + d_args(dict): A dictionary object that contains parsed args namespace is passed to + appropriate action. + + Returns + ------- + None + """ + method_name = method_name.replace( + consts.cli_tool_name, consts.cli_tool_name_in_method_names + ) + method_to_call = getattr(command_methods, method_name) + method_to_call(d_args) + + +def get_method_from_command(d_args, command, method_name): + """ + A recursive function that builds the method_name from the command. + + 'greengrass-tools component init --lang python --template template-name' + When the above command is parsed(parse_args), the following namespace is returned. + Namespace(greengrass-tools='component', foo=None, component='init', init=None, lang='python', template='template-name') + where, + greengrass-tools -> component, component -> init, init -> None and we derive the method name from this as + '_greengrass-tools_component_init' + + Parameters + ---------- + d_args(dict): A dictionary object that contains parsed args namespace of a command. + command(string): Command from the namespace that is appended to method name and is used to determine its subcommand + method_name(string): Method name determined from the args namespace. + + Returns + ------- + method_name(string): Method name determined from the args namespace. + """ + method_name = "{}_{}".format(method_name, command) + if d_args[command] is None: + return method_name + return get_method_from_command(d_args, d_args[command], method_name) diff --git a/greengrassTools/static/cli_model.json b/greengrassTools/static/cli_model.json new file mode 100644 index 00000000..e3108596 --- /dev/null +++ b/greengrassTools/static/cli_model.json @@ -0,0 +1,31 @@ +{ + "greengrass-tools" :{ + "sub-commands":[ + "component" + ] + }, + "component" : { + "sub-commands":[ + "init", "build", "publish" + ] + }, + + "init" :{ + "arguments" :[ + { + "name" : ["-l","--lang"], + "help":"Specify the language of the template.", + "choices":["python", "java"] + }, + { + "name" : ["-t", "--template"], + "help":"Specify the name of the template you want to use.", + "required": "True" + } + ] + }, + "build" :{ + }, + "publish" :{ + } +} diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..dbe21fdd --- /dev/null +++ b/setup.py @@ -0,0 +1,28 @@ +from setuptools import setup, find_packages + + +long_description = "Greengrass CLI Tool for creating Greengrass components." + +setup( + name="greengrass-tools", + version="1.0.0", + author="AWS IoT Greengrass Labs", + author_email="nukai@amazon.com", + url="", + description="Greengrass CLI Tool for creating Greengrass components", + long_description=long_description, + long_description_content_type="text/markdown", + license="Apache-2.0", + packages=find_packages(), + entry_points={ + "console_scripts": ["greengrass-tools = greengrassTools.CLIParser:main"] + }, + classifiers=( + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ), + keywords="aws iot greengrass cli component", + zip_safe=False, + include_package_data=True, +) diff --git a/tests/greengrassTools/common/test_model_actions.py b/tests/greengrassTools/common/test_model_actions.py new file mode 100644 index 00000000..f20e57d5 --- /dev/null +++ b/tests/greengrassTools/common/test_model_actions.py @@ -0,0 +1,139 @@ +import greengrassTools.common.model_actions as model_actions +import greengrassTools.common.consts as consts + + +def test_model_existence(mocker): + """ + Integ: Test for the existence of command model file even before building the cli tool. + """ + command_model = model_actions.get_validated_model() + assert ( + type(command_model) == dict + ) # Command model obtained should always be a dictionary + assert len(command_model) > 0 # Command model is never empty + assert ( + consts.cli_tool_name in command_model + ) # Command model should contain the name of CLI as a key + + +def test_is_valid_argument_model(): + + """ + Case 1: Valid argument + Case 2: Invalid arg without name + Case 3: Invalid arg without help + """ + valid_arg = { + "name": ["-l", "--lang"], + "help": "language help", + "choices": ["p", "j"], + } + invalid_arg_without_name = {"names": ["-l", "--lang"], "help": "help"} + invalid_arg_without_help = {"name": ["-l", "--lang"], "helper": "help"} + + # Case 1 + assert model_actions.is_valid_argument_model(valid_arg) + + # Case 2 + assert not model_actions.is_valid_model( + invalid_arg_without_name, consts.cli_tool_name + ) + + # Case 3 + assert not model_actions.is_valid_model( + invalid_arg_without_help, consts.cli_tool_name + ) + + +def test_is_valid_subcommand_model(): + + """ + Case 1: Valid subcommand + Case 2: Invalid subcommand with no key + """ + + model = { + "greengrass-tools": {"sub-commands": ["component"]}, + "component": {"sub-commands": ["init", "build"]}, + "init": { + "arguments": [ + { + "name": ["-l", "--lang"], + "help": "language help", + "choices": ["p", "j"], + }, + {"name": ["template"], "help": "template help"}, + ] + }, + "build": {}, + } + valid_model_subcommands = ["component"] + invalid_model_subcommands = [ + "component", + "invalid-subcommand-that-is-not-present-as-key", + ] + + # Case 1 + assert model_actions.is_valid_subcommand_model(model, valid_model_subcommands) + + # Case 2 + assert not model_actions.is_valid_subcommand_model(model, invalid_model_subcommands) + + +def test_is_valid_model(): + + """ + Case 1: Valid model with correct args ang sub-commands + Case 2: Invalid model with incorrect sub-commands + Case 3: Invalid model with incorrect arguments + """ + + valid_model = { + "greengrass-tools": {"sub-commands": ["component"]}, + "component": {"sub-commands": ["init", "build"]}, + "init": { + "arguments": [ + { + "name": ["-l", "--lang"], + "help": "language help", + "choices": ["p", "j"], + }, + {"name": ["template"], "help": "template help"}, + ] + }, + "build": {}, + } + invalid_model_subcommands = { + "greengrass-tools": {"sub-commands": ["component", "invalid-sub-command"]}, + "component": {}, + } + invalid_model_args_without_name = { + "greengrass-tools": { + "sub-commands": ["component"], + "arguments": [{"names": ["-l", "--lang"], "help": "help"}], + }, + "component": {}, + } + invalid_model_args_without_help = { + "greengrass-tools": { + "sub-commands": ["component"], + "arguments": [{"name": ["-l", "--lang"], "helper": "help"}], + }, + "component": {}, + } + + # Case 1 + assert model_actions.is_valid_model(valid_model, consts.cli_tool_name) + + # Case 2 + assert not model_actions.is_valid_model( + invalid_model_subcommands, consts.cli_tool_name + ) + + # Case 3 + assert not model_actions.is_valid_model( + invalid_model_args_without_name, consts.cli_tool_name + ) + assert not model_actions.is_valid_model( + invalid_model_args_without_help, consts.cli_tool_name + ) diff --git a/tests/test_CLIParser.py b/tests/test_CLIParser.py new file mode 100644 index 00000000..69a9a1e1 --- /dev/null +++ b/tests/test_CLIParser.py @@ -0,0 +1,115 @@ +import argparse + +import greengrassTools.CLIParser as cli_parser + +import greengrassTools.common.consts as consts + + +def test_CLIParser_initiation(): + """ + This test checks for the correctness of CLIParser that creates argument parser with commands and sub-commands. + """ + + # If CLIParser is initiated with the cli tool name, it has no top-level parser. + parser = cli_parser.CLIParser(consts.cli_tool_name, None) + assert not hasattr(parser, "top_level_parser") + assert parser.command == consts.cli_tool_name + assert type(parser.parser) == argparse.ArgumentParser + assert parser.subparsers.dest == consts.cli_tool_name + + # If CLIParser is initiated with the sub-command, it has a top-level parser. Here, it is cli tool name + sub_command = "component" + subparser = cli_parser.CLIParser(sub_command, parser.subparsers) + assert hasattr(subparser, "top_level_parser") + assert subparser.top_level_parser.dest == consts.cli_tool_name + assert subparser.command != consts.cli_tool_name + assert subparser.command == sub_command + assert type(subparser.parser) == argparse.ArgumentParser + assert subparser.subparsers.dest == sub_command + + +def test_CLIParser_create_parser(): + """ + This test checks for the correctness of CLIParser that creates argument parser with commands and sub-commands. + """ + + # If CLIParser is initiated with the cli tool name, it has no top-level parser. + parser = cli_parser.CLIParser(consts.cli_tool_name, None).create_parser( + test_model_file() + ) + assert type(parser) == argparse.ArgumentParser + + +def test_CLIParser_get_arg_from_model(): + """ + Case 1: Check that only known params are passed in the form of names and kwargs as needed by parser.add_argument. + Case 2: Other params in the model file that are not relevant to the argument are not passed. + """ + # Case 1 + test_arg = { + "name": ["-l", "--lang"], + "help": "Specify the language of the template.", + "choices": ["python", "java"], + } + cli_tool = cli_parser.CLIParser(consts.cli_tool_name, None) + params_of_add_arg_command = cli_tool._get_arg_from_model(test_arg) + names_in_command, rest_args_as_dict = params_of_add_arg_command + + assert type(params_of_add_arg_command) == tuple + assert len(names_in_command) == 2 + assert names_in_command[0] == "-l" + + assert type(rest_args_as_dict) == dict + assert ( + "choices" in rest_args_as_dict + ) # Check that "choices" is present in the parameters passed to add_argument command + assert ( + "default" not in rest_args_as_dict + ) # Full list of accepted params in common.consts file. + assert "names" not in rest_args_as_dict + assert "help" in rest_args_as_dict + + # Case 2 + test_arg2 = {"name": ["-l"], "unknown_param": "Not relevant to arg params"} + params_of_add_arg_command2 = cli_tool._get_arg_from_model(test_arg2) + names_in_command2, rest_args_as_dict2 = params_of_add_arg_command2 + assert len(names_in_command2) == 1 + + assert ( + "unknown_param" not in rest_args_as_dict2 + ) # Full list of accepted params in common.consts file. + + +def test_CLIParser_get_subcommands_from_model(): + """ + Case 1: Check that only known params are passed in the form of names and kwargs as needed by parser.add_argument. + Case 2: Other params in the model file that are not relevant to the argument are not passed. + """ + # Case 1 + cli_tool = cli_parser.CLIParser(consts.cli_tool_name, None) + model = test_model_file() + cli_tool.command_model = model[consts.cli_tool_name] + cli_tool._get_subcommands_from_model(model) + + +def test_model_file(): + model = { + "greengrass-tools": {"sub-commands": ["component"]}, + "component": {"sub-commands": ["init", "build", "publish"]}, + "init": { + "arguments": [ + { + "name": ["-l", "--lang"], + "help": "Specify the language of the template.", + "choices": ["python", "java"], + }, + { + "name": ["template"], + "help": "Specify the name of the template you want to use.", + }, + ] + }, + "build": {}, + "publish": {}, + } + return model