From 26d3a25c96d35b4d6992f2a27b9a41f93174ff67 Mon Sep 17 00:00:00 2001 From: "Bradley A. Thornton" Date: Wed, 17 Jul 2024 14:34:17 -0700 Subject: [PATCH] Refactor CLI (#253) * init * mo fixin * more * momo fixin * momo fixin * simple version * Remove params * Insert after removal * Better messages * f it * Better messages * rm general messages * line length * New tests * Mass updates * rm_bp * Fix init * comment out old * Type fixes * TYPE_CHECKING * mv ARGCOMPLETE * pylint fix * Collection name checking * Magic numbers * Documentation updates * Additional tests * Type fix for capsys * Type fix for capsys * Additional tests * Spelling fix * Env fix * Early exit for * Fix help inconsistency with plugin and resource * Remove demo files --- .config/dictionary.txt | 3 + .config/requirements-test.in | 1 + .pre-commit-config.yaml | 1 + README.md | 26 +- docs/index.md | 6 +- docs/installing.md | 98 +++-- src/ansible_creator/arg_parser.py | 645 ++++++++++++++++++++++++++++++ src/ansible_creator/cli.py | 175 ++------ src/ansible_creator/compat.py | 4 +- tests/integration/test_init.py | 62 ++- tests/units/test_argparse_help.py | 36 ++ tests/units/test_basic.py | 222 +++++++++- tests/units/test_output.py | 23 ++ 13 files changed, 1092 insertions(+), 210 deletions(-) create mode 100644 src/ansible_creator/arg_parser.py create mode 100644 tests/units/test_argparse_help.py create mode 100644 tests/units/test_output.py diff --git a/.config/dictionary.txt b/.config/dictionary.txt index d41dc5b..44f844a 100644 --- a/.config/dictionary.txt +++ b/.config/dictionary.txt @@ -5,6 +5,7 @@ argvalues capsys chakarborty conftest +delenv devcontainer devfile docsite @@ -18,6 +19,8 @@ kubedock levelname libera maxsplit +myproject +myorg myuser netcommon nilashish diff --git a/.config/requirements-test.in b/.config/requirements-test.in index d9e07fc..b487a68 100644 --- a/.config/requirements-test.in +++ b/.config/requirements-test.in @@ -1,3 +1,4 @@ +argcomplete black coverage[toml] mypy diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3a7f296..1dc3d81 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -95,6 +95,7 @@ repos: hooks: - id: mypy additional_dependencies: + - argcomplete - jinja2 - pytest - types-pyyaml diff --git a/README.md b/README.md index a79da14..604228b 100644 --- a/README.md +++ b/README.md @@ -18,23 +18,33 @@ $ pip install ansible-creator ```shell $ ansible-creator --help -usage: ansible-creator [-h] [--version] {init} ... +usage: ansible-creator [-h] command ... -Tool to scaffold Ansible Content. Get started by looking at the help text. +The fastest way to generate all your ansible content. -optional arguments: - -h, --help show this help message and exit - --version Print ansible-creator version and exit. +Positional arguments: + command + add Add resources to an existing Ansible project. + init Initialize a new Ansible project. -Commands: - {init} The subcommand to invoke. - init Initialize an Ansible Collection. +Options: + --version Print ansible-creator version and exit. + -h --help Show this help message and exit ``` ## Usage Full documentation on how to use this, along with it's integration with VS Code Ansible Extension can be found in https://ansible.readthedocs.io/projects/creator/. +## Command line completion + +`ansible-creator` has experimental command line completion for common shells. Please ensure you have the `argcomplete` package installed and configured. + +```shell +$ pip install argcomplete --user +$ activate-global-python-argcomplete --user +``` + ## Upcoming features - Scaffold Ansible plugins of your choice with the `create` action. diff --git a/docs/index.md b/docs/index.md index c7fdfff..be4bab0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,11 +12,7 @@ This documentation serves as a detailed guide for using ansible-creator, emphasi ## Upcoming Features -The `create` command is currently under development which will allow you scaffold ansible plugins of your choice. - -!!! notice - - Switch to the create [create branch](https://github.com/ansible/ansible-creator/tree/create) of the project to try it out! +The `add` command is currently under development which will allow you scaffold ansible plugins of your choice. ## Licensing diff --git a/docs/installing.md b/docs/installing.md index 8977780..736df29 100644 --- a/docs/installing.md +++ b/docs/installing.md @@ -18,6 +18,15 @@ The Command-Line Interface (CLI) for ansible-creator provides a straightforward If command line is not your preferred method, you can also leverage the GUI interface within VS Code's Ansible extension that offers a more visually intuitive experience of ansible-creator. See [here](collection_creation.md). +## Command line completion + +`ansible-creator` has experimental command line completion for common shells. Please ensure you have the `argcomplete` package installed and configured. + +```shell +$ pip install argcomplete --user +$ activate-global-python-argcomplete --user +``` + ### General Usage Get an overview of available commands and options by running: @@ -26,48 +35,45 @@ Get an overview of available commands and options by running: $ ansible-creator --help ``` -### Initialize Ansible Collection (`init` subcommand) +### Initialize an Ansible collection project -The `init` command enables you to initialize an Ansible Collection to create a foundational structure for the project. Use the following command template: +The `init collection` command enables you to initialize an Ansible collection project. Use the following command template: ```console -$ ansible-creator init --init-path +$ ansible-creator init collection ``` -#### Positional Argument(s) +#### Positional Arguments -| Parameter | Description | -| ---------- | -------------------------------------------------------------------- | -| collection | The name of the collection in the format `.`. | +| Parameter | Description | +| --------------- | ------------------------------------------------------- | +| collection-name | The collection name in the format '.'. | +| path | The destination directory for the collection project. | #### Optional Arguments -| Parameter | Description | -| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| -h, --help | Show help message and exit. | -| --na, --no-ansi | Disable the use of ANSI codes for terminal color. | -| --lf, --log-file | Log file to write to. | -| --ll, --log-level | Log level (notset, debug, info, warning, error, critical) for file output. | -| --la, --log-append | Append to log file. | -| --json | Output messages as JSON. | -| -v, --verbose | Give more CLI output. Option is additive and can be used up to 3 times. | -| --project | Project type to scaffold. Valid choices are collection, ansible-project. | -| --scm-org | The SCM org where the ansible-project will be hosted. This value is used as the namespace for the playbook adjacent collection. Required when `--project=ansible-project`. | -| --scm-project | The SCM project where the ansible-project will be hosted. This value is used as the collection_name for the playbook adjacent collection. Required when `--project=ansible-project`. | -| --init-path | The path where the skeleton collection will be scaffolded (default is the current working directory). | -| --force | Force re-initialize the specified directory as an Ansible collection. | +| Sort flag | Long flag | Flag argument | Description | +| --------- | ------------ | ------------- | ----------------------------------------------------------------------------------------------------- | +| -f | --force | | Force re-initialize the specified directory as an Ansible collection. (default: False) | +| | --json | | Output messages as JSON (default: False) | +| --la | --log-append | bool | Append to log file. (choices: true, false) (default: true) | +| --lf | --log-file | file | Log file to write to. (default: ./ansible-creator.log) | +| --ll | --log-level | level | Log level for file output. (choices: notset, debug, info, warning, error, critical) (default: notset) | +| --na | --no-ansi | | Disable the use of ANSI codes for terminal color. (default: False) | +| -h | --help | | Show this help message and exit | +| -v | --verbosity | | Give more Cli output. Option is additive, and can be used up to 3 times. (default: 0) | #### Example ```console -$ ansible-creator init testns.testname --init-path $HOME/collections/ansible_collections +$ ansible-creator init collection testns.testname $HOME/collections/ansible_collections ``` This command will scaffold the collection `testns.testname` at `/home/ansible-dev/collections/ansible_collections/testns/testname` #### Generated Ansible Collection Structure -Running the init command generates an Ansible Collection with a comprehensive directory structure. Explore it using: +Running the `init collection` command generates an Ansible collection project with a comprehensive directory structure. Explore it using: ```console $ tree -lla /home/ansible-dev/collections/ansible_collections/testns/testname @@ -176,34 +182,58 @@ The scaffolded collection includes a `hello_world` filter plugin, along with a m To run the `hello_world` integration test, follow these steps: - Git initialize the repository containing the scaffolded collection with `git init`. -- `pip install ansible-core molecule pytest-xdist pytest-ansible`. +- `pip install ansible-dev-tools`. - Invoke `pytest` from collection root. -### Initialize Ansible Project +### Initialize Ansible playbook project -The `init` command along with parameters `--project`, `--scm-org` and `--scm-project` enables you to initialize an Ansible Project to create a foundational structure for the project. Use the following command template: +The `init playbook` command enables you to initialize an Ansible playbook project. Use the following command template: -#### Example +```console +$ ansible-creator init playbook +``` + +#### Positional Arguments + +| Parameter | Description | +| --------------- | --------------------------------------------------------------------------------- | +| collection-name | The name for the playbook adjacent collection in the format '.'. | +| path | The destination directory for the playbook project. | + +#### Optional Arguments + +| Sort flag | Long flag | Flag argument | Description | +| --------- | ------------ | ------------- | ----------------------------------------------------------------------------------------------------- | +| -f | --force | | Force re-initialize the specified directory as an Ansible collection. (default: False) | +| | --json | | Output messages as JSON (default: False) | +| --la | --log-append | bool | Append to log file. (choices: true, false) (default: true) | +| --lf | --log-file | file | Log file to write to. (default: ./ansible-creator.log) | +| --ll | --log-level | level | Log level for file output. (choices: notset, debug, info, warning, error, critical) (default: notset) | +| --na | --no-ansi | | Disable the use of ANSI codes for terminal color. (default: False) | +| -h | --help | | Show this help message and exit | +| -v | --verbosity | | Give more Cli output. Option is additive, and can be used up to 3 times. (default: 0) | + +Example: ```console -$ ansible-creator init --project=ansible-project --scm-org=weather --scm-project=demo --init-path $HOME/path/to/scaffold/your/new_ansible_project +$ ansible-creator init playbook myorg.myproject $HOME/ansible-projects/playbook-project ``` -This command will scaffold the ansible-project `new_ansible_project` at `/home/user/path/to/your/new_ansible_project` +This command will scaffold the new Ansible playbook project at `/home/user/ansible-projects/playbook-project`. -#### Generated Ansible Project Structure +#### Generated Ansible playbook project Structure -Running the init command with parameters `--project`, `--scm-org` and `--scm-project` generates an Ansible Project with a comprehensive directory structure. Explore it using: +Running the `init playbook` command generates an Ansible playbook project with a comprehensive directory structure. Explore it using: ```console -$ tree -la /home/user/path/to/your/new_ansible_project +$ tree -la /home/user/ansible-projects/playbook-project . ├── ansible.cfg ├── ansible-navigator.yml ├── collections │   ├── ansible_collections -│   │   └── weather -│   │   └── demo +│   │   └── myorg +│   │   └── myproject │   │   ├── README.md │   │   └── roles │   │   └── run diff --git a/src/ansible_creator/arg_parser.py b/src/ansible_creator/arg_parser.py new file mode 100644 index 0000000..e8a13ac --- /dev/null +++ b/src/ansible_creator/arg_parser.py @@ -0,0 +1,645 @@ +"""Parse the command line arguments.""" + +from __future__ import annotations + +import argparse +import contextlib +import re +import sys + +from argparse import HelpFormatter +from operator import attrgetter +from pathlib import Path +from typing import TYPE_CHECKING, TypeAlias + +from ansible_creator.output import Level, Msg + + +if TYPE_CHECKING: + from collections.abc import Iterable + from typing import Any + + +try: + import argcomplete + + HAS_ARGCOMPLETE = True +except ImportError: # pragma: no cover + HAS_ARGCOMPLETE = False + +try: + from ._version import version as __version__ # type: ignore[unused-ignore,import-not-found] +except ImportError: # pragma: no cover + __version__ = "source" + +MIN_COLLECTION_NAME_LEN = 2 + +COMING_SOON = ( + "add resource devcontainer", + "add resource devfile", + "add resource role", + "add plugin action", + "add plugin filter", + "add plugin lookup", +) + + +class Parser: + """A parser for the command line arguments.""" + + def __init__(self: Parser) -> None: + """Initialize the parser.""" + self.args: argparse.Namespace + self.pending_logs: list[Msg] = [] + + def parse_args(self: Parser) -> tuple[argparse.Namespace, list[Msg]]: + """Parse the root arguments. + + Returns: + The parsed arguments and any pending logs + """ + is_init = sys.argv[1:2] == ["init"] + not_empty = sys.argv[2:] != [] + not_help = not any(arg in sys.argv for arg in ["-h", "--help"]) + if all((is_init, not_empty, not_help)): + proceed = self.handle_deprecations() + if not proceed: + return argparse.Namespace(), self.pending_logs + + parser = ArgumentParser( + description="The fastest way to generate all your ansible content.", + formatter_class=CustomHelpFormatter, + ) + parser.add_argument( + "--version", + action="version", + help="Print ansible-creator version and exit.", + version=__version__, + ) + subparser = parser.add_subparsers( + dest="subcommand", + metavar="command", + required=True, + ) + self._add(subparser=subparser) + self._init(subparser=subparser) + + if HAS_ARGCOMPLETE: + argcomplete.autocomplete(parser) + self.args = parser.parse_args() + + combinations = ( + ("subcommand", "type", "resource_type"), + ("subcommand", "type", "plugin_type"), + ("subcommand", "project"), + ) + for combination in combinations: + with contextlib.suppress(AttributeError): + name = " ".join(getattr(self.args, part) for part in combination) + + if name in COMING_SOON: + msg = f"The `{name}` command is coming soon. Please try in the next release." + self.pending_logs.append(Msg(prefix=Level.HINT, message=msg)) + self.pending_logs.append(Msg(prefix=Level.CRITICAL, message="Goodbye.")) + return self.args, self.pending_logs + + # The internal still reference the old project name + if self.args.project == "playbook": + self.args.project = "ansible-project" + with contextlib.suppress(ValueError): + self.args.scm_org, self.args.scm_project = self.args.collection.split( + ".", + maxsplit=1, + ) + self.args.collection = None + + return self.args, self.pending_logs + + def _add(self: Parser, subparser: SubParser) -> None: + """Add resources to an existing Ansible project. + + Args: + subparser: The subparser to add the resources to + """ + parser = subparser.add_parser( + "add", + formatter_class=CustomHelpFormatter, + help="Add resources to an existing Ansible project.", + ) + subparser = parser.add_subparsers( + dest="type", + required=True, + metavar="content-type", + ) + self._add_resource(subparser=subparser) + self._add_plugin(subparser=subparser) + + def _add_args_common(self, parser: ArgumentParser) -> None: + """Add common arguments to the parser. + + Args: + parser: The parser to add common arguments to + """ + parser.add_argument( + "--na", + "--no-ansi", + action="store_true", + default=False, + dest="no_ansi", + help="Disable the use of ANSI codes for terminal color.", + ) + + parser.add_argument( + "--lf", + "--log-file ", + dest="log_file", + default=str(Path.cwd() / "ansible-creator.log"), + help="Log file to write to.", + ) + + parser.add_argument( + "--ll", + "--log-level ", + dest="log_level", + default="notset", + choices=["notset", "debug", "info", "warning", "error", "critical"], + help="Log level for file output.", + ) + + parser.add_argument( + "--la", + "--log-append ", + dest="log_append", + choices=["true", "false"], + default="true", + help="Append to log file.", + ) + + parser.add_argument( + "--json", + dest="json", + action="store_true", + default=False, + help="Output messages as JSON", + ) + + parser.add_argument( + "-v", + "--verbosity", + dest="verbose", + action="count", + default=0, + help="Give more Cli output. Option is additive, and can be used up to 3 times.", + ) + + def _add_args_init_common(self, parser: ArgumentParser) -> None: + """Add common init arguments to the parser. + + Args: + parser: The parser to add common init arguments to + """ + parser.add_argument( + "-f", + "--force", + default=False, + dest="force", + action="store_true", + help="Force re-initialize the specified directory.", + ) + + def _add_args_plugin_common(self, parser: ArgumentParser) -> None: + """Add common plugin arguments to the parser. + + Args: + parser: The parser to add common plugin arguments to + """ + parser.add_argument( + "plugin_name", + help="The name of the plugin to add.", + ) + parser.add_argument( + "path", + default="./", + help="The path to the Ansible collection. The default is the " + "current working directory.", + ) + + def _add_resource(self: Parser, subparser: SubParser) -> None: + """Add resources to an existing Ansible project. + + Args: + subparser: The subparser to add resource to + """ + parser = subparser.add_parser( + "resource", + help="Add resources to an existing Ansible project.", + formatter_class=CustomHelpFormatter, + ) + subparser = parser.add_subparsers( + dest="resource_type", + metavar="resource-type", + required=True, + ) + self._add_resource_devcontainer(subparser=subparser) + self._add_resource_devfile(subparser=subparser) + self._add_resource_role(subparser=subparser) + + def _add_resource_devcontainer(self: Parser, subparser: SubParser) -> None: + """Add devcontainer files to an existing Ansible project. + + Args: + subparser: The subparser to add devcontainer files to + """ + parser = subparser.add_parser( + "devcontainer", + help="Add devcontainer files to an existing Ansible project.", + formatter_class=CustomHelpFormatter, + ) + + parser.add_argument( + "path", + default="./", + metavar="path", + help="The destination directory for the devcontainer files. The default is the " + "current working directory.", + ) + + self._add_args_common(parser) + + def _add_resource_devfile(self: Parser, subparser: SubParser) -> None: + """Add a devfile file to an existing Ansible project. + + Args: + subparser: The subparser to add devfile file to + """ + parser = subparser.add_parser( + "devfile", + help="Add a devfile file to an existing Ansible project.", + formatter_class=CustomHelpFormatter, + ) + parser.add_argument( + "path", + default="./", + metavar="path", + help="The destination directory for the devfile file. The default is the " + "current working directory.", + ) + self._add_args_common(parser) + + def _add_resource_role(self: Parser, subparser: SubParser) -> None: + """Add a role to an existing Ansible collection. + + Args: + subparser: The subparser to add role to + """ + parser = subparser.add_parser( + "role", + help="Add a role to an existing Ansible collection.", + formatter_class=CustomHelpFormatter, + ) + parser.add_argument( + "role_name", + help="The name of the role to add.", + ) + parser.add_argument( + "path", + default="./", + metavar="path", + help="The path to the Ansible collection. The default is the " + "current working directory.", + ) + self._add_args_common(parser) + + def _add_plugin(self: Parser, subparser: SubParser) -> None: + """Add a plugin to an Ansible project. + + Args: + subparser: The subparser to add plugin to + """ + parser = subparser.add_parser( + "plugin", + help="Add a plugin to an Ansible collection", + formatter_class=CustomHelpFormatter, + ) + subparser = parser.add_subparsers( + dest="plugin_type", + metavar="plugin-type", + required=True, + ) + + self._add_plugin_action(subparser=subparser) + self._add_plugin_filter(subparser=subparser) + self._add_plugin_lookup(subparser=subparser) + + def _add_plugin_action(self: Parser, subparser: SubParser) -> None: + """Add an action plugin to an existing Ansible collection project. + + Args: + subparser: The subparser to add action plugin to + """ + parser = subparser.add_parser( + "action", + help="Add an action plugin to an existing Ansible collection.", + formatter_class=CustomHelpFormatter, + ) + self._add_args_common(parser) + self._add_args_plugin_common(parser) + + def _add_plugin_filter(self: Parser, subparser: SubParser) -> None: + """Add a filter plugin to an existing Ansible collection project. + + Args: + subparser: The subparser to add filter plugin to + """ + parser = subparser.add_parser( + "filter", + help="Add a filter plugin to an existing Ansible collection.", + formatter_class=CustomHelpFormatter, + ) + self._add_args_common(parser) + self._add_args_plugin_common(parser) + + def _add_plugin_lookup(self: Parser, subparser: SubParser) -> None: + """Add a lookup plugin to an existing Ansible collection project. + + Args: + subparser: The subparser to add lookup plugin to + """ + parser = subparser.add_parser( + "lookup", + help="Add a lookup plugin to an existing Ansible collection.", + formatter_class=CustomHelpFormatter, + ) + self._add_args_common(parser) + self._add_args_plugin_common(parser) + + def _init(self: Parser, subparser: SubParser) -> None: + """Initialize an Ansible project. + + Args: + subparser: The subparser add init to + """ + parser = subparser.add_parser( + "init", + formatter_class=CustomHelpFormatter, + help="Initialize a new Ansible project.", + ) + subparser = parser.add_subparsers( + dest="project", + metavar="project-type", + required=True, + ) + + self._init_collection(subparser=subparser) + self._init_playbook(subparser=subparser) + + def _init_collection(self: Parser, subparser: SubParser) -> None: + """Initialize an Ansible collection. + + Args: + subparser: The subparser to add collection to + """ + parser = subparser.add_parser( + "collection", + help="Create a new Ansible collection project.", + formatter_class=CustomHelpFormatter, + ) + parser.add_argument( + "collection", + help="The collection name in the format '.'.", + metavar="collection-name", + type=self._valid_collection_name, + ) + parser.add_argument( + "init_path", + default="./", + metavar="path", + nargs="?", + help="The destination directory for the collection project. The default is the " + "current working directory.", + ) + + self._add_args_common(parser) + self._add_args_init_common(parser) + + def _init_playbook(self: Parser, subparser: SubParser) -> None: + """Initialize an Ansible playbook. + + Args: + subparser: The subparser to add playbook to + """ + parser = subparser.add_parser( + "playbook", + help="Create a new Ansible playbook project.", + formatter_class=CustomHelpFormatter, + ) + + parser.add_argument( + "collection", + help="The name for the playbook adjacent collection in the format" + " '.'.", + metavar="collection-name", + type=self._valid_collection_name, + ) + + parser.add_argument( + "init_path", + default="./", + metavar="path", + nargs="?", + help="The destination directory for the playbook project. The default is the " + "current working directory.", + ) + self._add_args_common(parser) + self._add_args_init_common(parser) + + def _valid_collection_name(self, collection: str) -> str | Msg: + """Validate the collection name. + + Args: + collection: The collection name to validate + + Returns: + The validated collection name + """ + fqcn = collection.split(".", maxsplit=1) + expected_parts = 2 + if len(fqcn) != expected_parts: + msg = "Collection name must be in the format '.'." + self.pending_logs.append(Msg(prefix=Level.CRITICAL, message=msg)) + return collection + + name_filter = re.compile(r"^(?!_)[a-z0-9_]+$") + + if not name_filter.match(fqcn[0]) or not name_filter.match(fqcn[1]): + msg = ( + "Collection name can only contain lower case letters, underscores, and numbers" + " and cannot begin with an underscore." + ) + self.pending_logs.append(Msg(prefix=Level.CRITICAL, message=msg)) + return collection + + if len(fqcn[0]) <= MIN_COLLECTION_NAME_LEN or len(fqcn[1]) <= MIN_COLLECTION_NAME_LEN: + msg = "Both the collection namespace and name must be longer than 2 characters." + self.pending_logs.append(Msg(prefix=Level.CRITICAL, message=msg)) + return collection + + def handle_deprecations(self: Parser) -> bool: # noqa: C901 + """Start parsing args passed from Cli. + + Returns: + True if parsing can proceed, False otherwise + """ + parser = argparse.ArgumentParser() + parser.add_argument("command", help="") + parser.add_argument("collection", nargs="?", help="") + parser.add_argument("--project", help="") + parser.add_argument("--scm-org", help="") + parser.add_argument("--scm-project", help="") + parser.add_argument("--init-path", help="") + args, extras = parser.parse_known_args() + + if args.collection in ["playbook", "collection"]: + return True + if args.project: + msg = "The `project` flag is no longer needed and will be removed." + self.pending_logs.append(Msg(prefix=Level.WARNING, message=msg)) + if not args.project: + msg = "The default value `collection` for project type will be removed." + self.pending_logs.append(Msg(prefix=Level.WARNING, message=msg)) + args.project = "collection" + if args.scm_org: + msg = "The `scm-org` flag is no longer needed and will be removed." + self.pending_logs.append(Msg(prefix=Level.WARNING, message=msg)) + if args.scm_project: + msg = "The `scm-project` flag is no longer needed and will be removed." + self.pending_logs.append(Msg(prefix=Level.WARNING, message=msg)) + if args.init_path: + msg = "The `init-path` flag is no longer needed and will be removed." + self.pending_logs.append(Msg(prefix=Level.WARNING, message=msg)) + + exit_msg = "The CLI has changed. Please refer to `--help` for the new syntax." + if args.project == "ansible-project": + args.project = "playbook" + if not args.scm_org or not args.scm_project: + self.pending_logs.append(Msg(prefix=Level.CRITICAL, message=exit_msg)) + return False + msg = "The `ansible-project` project type is deprecated. Please use `playbook`." + self.pending_logs.append(Msg(prefix=Level.WARNING, message=msg)) + args.collection = f"{args.scm_org}.{args.scm_project}" + if args.project == "collection" and not args.collection: + self.pending_logs.append(Msg(prefix=Level.CRITICAL, message=exit_msg)) + return False + # ansible-creator init collection, ansible-creator init playbook + + base_cli = ["ansible-creator", args.command, args.project, args.collection] + if args.init_path: + base_cli.append(args.init_path) + new_cli = base_cli + extras + hint = f"Please use the following command in the future: `{' '.join(new_cli)}`" + self.pending_logs.append(Msg(prefix=Level.HINT, message=hint)) + sys.argv = new_cli + return True + + +class ArgumentParser(argparse.ArgumentParser): + """A custom argument parser.""" + + def add_argument( # type: ignore[override] + self: ArgumentParser, + *args: Any, # noqa: ANN401 + **kwargs: Any, # noqa: ANN401 + ) -> None: + """Add an argument. + + Args: + *args: The arguments + **kwargs: The keyword arguments + """ + if "choices" in kwargs: + kwargs["help"] += f" (choices: {', '.join(kwargs['choices'])})" + if "default" in kwargs and kwargs["default"] != "==SUPPRESS==": + kwargs["help"] += f" (default: {kwargs['default']})" + kwargs["help"] = kwargs["help"][0].upper() + kwargs["help"][1:] + super().add_argument(*args, **kwargs) + + def add_argument_group( + self: ArgumentParser, + *args: Any, # noqa: ANN401 + **kwargs: Any, # noqa: ANN401 + ) -> argparse._ArgumentGroup: + """Add an argument group. + + Args: + *args: The arguments + **kwargs: The keyword arguments + + Returns: + The argument group + """ + group = super().add_argument_group(*args, **kwargs) + if group.title: + group.title = group.title.capitalize() + return group + + +if TYPE_CHECKING: + SubParser: TypeAlias = argparse._SubParsersAction[ArgumentParser] # noqa: SLF001 + + +class CustomHelpFormatter(HelpFormatter): + """A custom help formatter.""" + + def __init__(self: CustomHelpFormatter, prog: str) -> None: + """Initialize the help formatter. + + Args: + prog: The program name + """ + long_string = "--abc --really_really_really_log" + # 3 here accounts for the spaces in the ljust(6) below + HelpFormatter.__init__( + self, + prog=prog, + indent_increment=1, + max_help_position=len(long_string) + 3, + ) + + def _format_action_invocation( + self: CustomHelpFormatter, + action: argparse.Action, + ) -> str: + """Format the action invocation. + + Args: + action: The action to format + + Raises: + ValueError: If more than 2 options are given + + Returns: + The formatted action invocation + """ + if not action.option_strings: + default = self._get_default_metavar_for_positional(action) + (metavar,) = self._metavar_formatter(action, default)(1) + return metavar + + if len(action.option_strings) == 1: + return action.option_strings[0] + + max_variations = 2 + if len(action.option_strings) == max_variations: + # Account for a --1234 --long-option-name + return f"{action.option_strings[0].ljust(6)} {action.option_strings[1]}" + msg = "Too many option strings" + raise ValueError(msg) + + def add_arguments(self, actions: Iterable[argparse.Action]) -> None: + """Add arguments sorted by option strings. + + Args: + actions: The actions to add + """ + actions = sorted(actions, key=attrgetter("option_strings")) + super().add_arguments(actions) diff --git a/src/ansible_creator/cli.py b/src/ansible_creator/cli.py index a6d2c07..cf5e739 100644 --- a/src/ansible_creator/cli.py +++ b/src/ansible_creator/cli.py @@ -1,25 +1,24 @@ +# PYTHON_ARGCOMPLETE_OK """The ansible-creator Cli.""" from __future__ import annotations -import argparse import os import sys from importlib import import_module -from pathlib import Path +from typing import Any +from ansible_creator.arg_parser import Parser from ansible_creator.config import Config from ansible_creator.exceptions import CreatorError -from ansible_creator.output import Output +from ansible_creator.output import Msg, Output from ansible_creator.utils import TermFeatures, expand_path try: - from typing import Any - from ._version import version as __version__ -except ImportError: +except ImportError: # pragma: no cover __version__ = "source" @@ -28,13 +27,18 @@ class Cli: def __init__(self: Cli) -> None: """Initialize the Cli and parse Cli args.""" - self.args: dict[str, Any] = vars(self.parse_args()) + self.args: dict[str, Any] self.output: Output + self.pending_logs: list[Msg] self.term_features: TermFeatures + self.parse_args() def init_output(self: Cli) -> None: - """Initialize the output object.""" - no_ansi = self.args.pop("no_ansi") + """Initialize the output object. + + In case the arg parsing exited early, set some sane default values. + """ + no_ansi = self.args.pop("no_ansi", False) if not sys.stdout.isatty(): self.term_features = TermFeatures(color=False, links=False) else: @@ -44,148 +48,24 @@ def init_output(self: Cli) -> None: ) self.output = Output( - log_append=self.args.pop("log_append"), - log_file=str(expand_path(self.args.pop("log_file"))), - log_level=self.args.pop("log_level"), + log_append=self.args.pop("log_append", False), + log_file=str(expand_path(self.args.pop("log_file", "./ansible-creator.log"))), + log_level=self.args.pop("log_level", "info"), term_features=self.term_features, - verbosity=self.args.pop("verbose"), - display="json" if self.args.pop("json") else "text", - ) - - def parse_args(self: Cli) -> argparse.Namespace: - """Start parsing args passed from Cli. - - Returns: - The parsed arguments. - """ - parent_parser = argparse.ArgumentParser(add_help=False) - - parent_parser.add_argument( - "--na", - "--no-ansi", - action="store_true", - default=False, - dest="no_ansi", - help="Disable the use of ANSI codes for terminal color.", - ) - - parent_parser.add_argument( - "--lf", - "--log-file ", - dest="log_file", - default=str(Path.cwd() / "ansible-creator.log"), - help="Log file to write to.", - ) - - parent_parser.add_argument( - "--ll", - "--log-level ", - dest="log_level", - default="notset", - choices=["notset", "debug", "info", "warning", "error", "critical"], - help="Log level for file output.", - ) - - parent_parser.add_argument( - "--la", - "--log-append ", - dest="log_append", - choices=["true", "false"], - default="true", - help="Append to log file.", - ) - - parent_parser.add_argument( - "--json", - dest="json", - action="store_true", - default=False, - help="Output messages as JSON", - ) - - parent_parser.add_argument( - "-v", - dest="verbose", - action="count", - default=0, - help="Give more Cli output. Option is additive, and can be used up to 3 times.", - ) - - parser = argparse.ArgumentParser( - description=( - "Tool to scaffold Ansible Content. Get started by looking at the help text." - ), + verbosity=self.args.pop("verbose", 0), + display="json" if self.args.pop("json", None) else "text", ) - parser.add_argument( - "--version", - action="version", - version=__version__, - help="Print ansible-creator version and exit.", - ) - - subparsers = parser.add_subparsers( - help="The subcommand to invoke.", - title="Commands", - dest="subcommand", - ) - subparsers.required = True - - # 'init' command parser - - init_command_parser = subparsers.add_parser( - "init", - help="Initialize an Ansible Collection.", - description=("Creates the skeleton framework of an Ansible collection."), - parents=[parent_parser], - ) - - init_command_parser.add_argument( - "collection", - nargs="?", - help="The collection name in the format ``.``.", - ) - - init_command_parser.add_argument( - "--project", - choices=["ansible-project", "collection"], - default="collection", - help="Project type to scaffold. Valid choices are collection, ansible-project.", - ) - - init_command_parser.add_argument( - "--scm-org", - help=( - "The SCM org where the ansible-project will be hosted. This value is used as" - " the namespace for the playbook adjacent collection." - " Required when `--project=ansible-project`." - ), - ) - - init_command_parser.add_argument( - "--scm-project", - help=( - "The SCM project where the ansible-project will be hosted. This value is used as" - " the collection_name for the playbook adjacent collection." - " Required when `--project=ansible-project`." - ), - ) - - init_command_parser.add_argument( - "--init-path", - default="./", - help="The path in which the skeleton collection will be created. The default is the " - "current working directory.", - ) - - init_command_parser.add_argument( - "--force", - default=False, - action="store_true", - help="Force re-initialize the specified directory as an Ansible collection.", - ) + def parse_args(self: Cli) -> None: + """Start parsing args passed from Cli.""" + args, pending_logs = Parser().parse_args() + self.args = vars(args) + self.pending_logs = pending_logs - return parser.parse_args() + def process_pending_logs(self: Cli) -> None: + """Log any pending logs.""" + for msg in self.pending_logs: + getattr(self.output, msg.prefix.value.lower())(msg.message) def run(self: Cli) -> None: """Dispatch work to correct subcommand class.""" @@ -211,6 +91,7 @@ def main() -> None: """Entry point for ansible-creator Cli.""" cli = Cli() cli.init_output() + cli.process_pending_logs() cli.run() diff --git a/src/ansible_creator/compat.py b/src/ansible_creator/compat.py index 5965909..a455dd4 100644 --- a/src/ansible_creator/compat.py +++ b/src/ansible_creator/compat.py @@ -11,6 +11,8 @@ if sys.version_info >= (3, 11): from importlib.resources.abc import Traversable as _Traversable else: - from importlib.abc import Traversable as _Traversable # pylint: disable = deprecated-class + from importlib.abc import ( # pylint: disable = deprecated-class + Traversable as _Traversable, # pragma: no cover + ) Traversable = _Traversable diff --git a/tests/integration/test_init.py b/tests/integration/test_init.py index 6b0fd5c..bab1bb8 100644 --- a/tests/integration/test_init.py +++ b/tests/integration/test_init.py @@ -10,6 +10,8 @@ from subprocess import CalledProcessError, CompletedProcess from typing import Any +import pytest + cli_type = Callable[[Any], CompletedProcess[str] | CalledProcessError] @@ -26,9 +28,12 @@ def test_run_help(cli: cli_type) -> None: result = cli(f"{CREATOR_BIN} --help") assert result.returncode == 0, (result.stdout, result.stderr) - assert "Print ansible-creator version and exit." in result.stdout - assert "The subcommand to invoke." in result.stdout - assert "Initialize an Ansible Collection." in result.stdout + assert "The fastest way to generate all your ansible content." in result.stdout + assert "Positional arguments:" in result.stdout + assert "add" in result.stdout + assert "Add resources to an existing Ansible project." in result.stdout + assert "init" in result.stdout + assert "Initialize a new Ansible project." in result.stdout def test_run_no_subcommand(cli: cli_type) -> None: @@ -39,7 +44,7 @@ def test_run_no_subcommand(cli: cli_type) -> None: """ result = cli(str(CREATOR_BIN)) assert result.returncode != 0 - assert "the following arguments are required: subcommand" in result.stderr + assert "the following arguments are required: command" in result.stderr def test_run_init_no_input(cli: cli_type) -> None: @@ -50,10 +55,51 @@ def test_run_init_no_input(cli: cli_type) -> None: """ result = cli(f"{CREATOR_BIN} init") assert result.returncode != 0 - assert ( - "Error: The argument 'collection' is required when scaffolding a collection" - in result.stderr - ) + err = "the following arguments are required: project-type" + assert err in result.stderr + + +@pytest.mark.parametrize( + argnames="command", + argvalues=["init --project ansible-project", "init --init-path /tmp"], + ids=["project_no_scm", "collection_no_name"], +) +def test_run_deprecated_failure(command: str, cli: cli_type) -> None: + """Test running ansible-creator init with deprecated options. + + Args: + command: Command to run. + cli: cli_run function. + """ + result = cli(f"{CREATOR_BIN} {command}") + assert result.returncode != 0 + assert "is no longer needed and will be removed." in result.stdout + assert "The CLI has changed." in result.stderr + + +@pytest.mark.parametrize( + argnames=("args", "expected"), + argvalues=( + ("a.b", "must be longer than 2 characters."), + ("_a.b", "cannot begin with an underscore."), + ("foo", "must be in the format '.'."), + ), + ids=("short", "underscore", "no_dot"), +) +@pytest.mark.parametrize("command", ("collection", "playbook")) +def test_run_init_invalid_name(command: str, args: str, expected: str, cli: cli_type) -> None: + """Test running ansible-creator init with invalid collection name. + + Args: + command: Command to run. + args: Arguments to pass to the CLI. + expected: Expected error message. + cli: cli_run function. + """ + result = cli(f"{CREATOR_BIN} init {command} {args}") + assert result.returncode != 0 + assert result.stderr.startswith("Critical:") + assert expected in result.stderr def test_run_init_basic(cli: cli_type, tmp_path: Path) -> None: diff --git a/tests/units/test_argparse_help.py b/tests/units/test_argparse_help.py new file mode 100644 index 0000000..f80a874 --- /dev/null +++ b/tests/units/test_argparse_help.py @@ -0,0 +1,36 @@ +"""Test the custom help formatter.""" + +from __future__ import annotations + +import argparse + +import pytest + +from ansible_creator.arg_parser import CustomHelpFormatter + + +def test_custom_help_single() -> None: + """Test the custom help formatter with single.""" + parser = argparse.ArgumentParser(formatter_class=CustomHelpFormatter) + parser.add_argument("--foo", help="foo help") + help_text = parser.format_help() + line = " --foo foo help" + assert line in help_text.splitlines() + + +def test_custom_help_double() -> None: + """Test the custom help formatter with double.""" + parser = argparse.ArgumentParser(formatter_class=CustomHelpFormatter) + parser.add_argument("-f", "--foo", help="foo help") + help_text = parser.format_help() + line = " -f --foo foo help" + assert line in help_text.splitlines() + + +def test_custom_help_triple() -> None: + """Test the custom help formatter with triple.""" + parser = argparse.ArgumentParser(formatter_class=CustomHelpFormatter) + parser.add_argument("-f", "--foo", "--foolish", help="foo help") + + with pytest.raises(ValueError, match="Too many option strings"): + parser.format_help() diff --git a/tests/units/test_basic.py b/tests/units/test_basic.py index be8f9e6..ff98d7f 100644 --- a/tests/units/test_basic.py +++ b/tests/units/test_basic.py @@ -3,13 +3,16 @@ from __future__ import annotations import re +import runpy import sys from pathlib import Path import pytest +from ansible_creator.arg_parser import COMING_SOON from ansible_creator.cli import Cli +from ansible_creator.cli import main as cli_main from ansible_creator.config import Config from ansible_creator.output import Output from ansible_creator.utils import TermFeatures, expand_path @@ -52,8 +55,6 @@ def test_configuration_class(output: Output) -> None: "init_path": "./", "force": False, "project": "collection", # default value - "scm_org": None, - "scm_project": None, }, ], [ @@ -62,6 +63,8 @@ def test_configuration_class(output: Output) -> None: "init", "--project=ansible-project", "--init-path=/home/ansible/my-ansible-project", + "--scm-org=weather", + "--scm-project=demo", ], { "subcommand": "init", @@ -75,8 +78,8 @@ def test_configuration_class(output: Output) -> None: "init_path": "/home/ansible/my-ansible-project", "force": False, "project": "ansible-project", - "scm_org": None, - "scm_project": None, + "scm_org": "weather", + "scm_project": "demo", }, ], [ @@ -105,8 +108,6 @@ def test_configuration_class(output: Output) -> None: "init_path": "/home/ansible", "force": True, "project": "collection", # default value - "scm_org": None, - "scm_project": None, }, ], [ @@ -141,6 +142,54 @@ def test_configuration_class(output: Output) -> None: "scm_project": "demo", }, ], + [ + [ + "ansible-creator", + "init", + "collection", + "foo.bar", + "/test/test", + "--lf=test.log", + ], + { + "subcommand": "init", + "project": "collection", + "collection": "foo.bar", + "init_path": "/test/test", + "force": False, + "json": False, + "log_append": "true", + "log_file": "test.log", + "log_level": "notset", + "no_ansi": False, + "verbose": 0, + }, + ], + [ + [ + "ansible-creator", + "init", + "playbook", + "foo.bar", + "/test/test", + "--lf=test.log", + ], + { + "subcommand": "init", + "project": "ansible-project", + "scm_org": "foo", + "scm_project": "bar", + "collection": None, + "init_path": "/test/test", + "force": False, + "json": False, + "log_append": "true", + "log_file": "test.log", + "log_level": "notset", + "no_ansi": False, + "verbose": 0, + }, + ], ], ) def test_cli_parser( @@ -156,7 +205,8 @@ def test_cli_parser( expected: Expected values for the parsed CLI arguments. """ monkeypatch.setattr("sys.argv", sysargs) - assert vars(Cli().parse_args()) == expected + parsed_args = Cli().args + assert parsed_args == expected def test_missing_j2(monkeypatch: pytest.MonkeyPatch) -> None: @@ -245,3 +295,161 @@ def test_cli_main( result = capsys.readouterr().out # check stdout assert re.search("collection testns.testcol created", result) is not None + + +@pytest.mark.parametrize(argnames=["project"], argvalues=[["collection"], ["playbook"]]) +def test_collection_name_short( + project: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test invalid collection name. + + Args: + project: The project type. + monkeypatch: Pytest monkeypatch fixture. + """ + sysargs = [ + "ansible-creator", + "init", + project, + "a.b", + ] + monkeypatch.setattr("sys.argv", sysargs) + + cli = Cli() + + msg = "Both the collection namespace and name must be longer than 2 characters." + assert any(msg in log.message for log in cli.pending_logs) + + +@pytest.mark.parametrize(argnames=["project"], argvalues=[["collection"], ["playbook"]]) +def test_collection_name_invalid( + project: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test invalid collection name. + + Args: + project: The project type. + monkeypatch: Pytest monkeypatch fixture. + """ + sysargs = [ + "ansible-creator", + "init", + project, + "$____.^____", + ] + monkeypatch.setattr("sys.argv", sysargs) + + cli = Cli() + + msg = ( + "Collection name can only contain lower case letters, underscores," + " and numbers and cannot begin with an underscore." + ) + assert any(msg in log.message for log in cli.pending_logs) + + +def test_is_a_tty(monkeypatch: pytest.MonkeyPatch) -> None: + """Test is a tty. + + Args: + monkeypatch: Pytest monkeypatch fixture. + """ + sysargs = [ + "ansible-creator", + "init", + "testorg.testcol", + "/home/ansible", + ] + + monkeypatch.setattr("sys.argv", sysargs) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + + cli = Cli() + cli.init_output() + assert cli.output.term_features.color is True + assert cli.output.term_features.links is True + assert cli.output.term_features.any_enabled() is True + + +def test_not_a_tty(monkeypatch: pytest.MonkeyPatch) -> None: + """Test not a tty. + + Args: + monkeypatch: Pytest monkeypatch fixture. + """ + sysargs = [ + "ansible-creator", + "init", + "testorg.testcol", + "/home/ansible", + ] + + monkeypatch.setattr("sys.argv", sysargs) + monkeypatch.setattr("sys.stdout.isatty", lambda: False) + + cli = Cli() + cli.init_output() + assert cli.output.term_features.color is False + assert cli.output.term_features.links is False + assert cli.output.term_features.any_enabled() is False + + +def test_main(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + """Test cli main. + + Args: + monkeypatch: Pytest monkeypatch fixture. + capsys: Pytest capsys fixture. + """ + monkeypatch.setattr("sys.argv", ["ansible-creator", "--help"]) + + with pytest.raises(SystemExit): + runpy.run_module("ansible_creator.cli", run_name="__main__") + stdout, stderr = capsys.readouterr() + assert "The fastest way" in stdout + + +def test_proj_main(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + """Test project main. + + Args: + monkeypatch: Pytest monkeypatch fixture. + capsys: Pytest capsys fixture. + """ + monkeypatch.setattr("sys.argv", ["ansible-creator", "--help"]) + + with pytest.raises(SystemExit): + runpy.run_module("ansible_creator", run_name="__main__") + stdout, stderr = capsys.readouterr() + assert "The fastest way" in stdout + + +@pytest.mark.parametrize(argnames="args", argvalues=COMING_SOON, ids=lambda s: s.replace(" ", "_")) +def test_coming_soon( + args: str, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test coming soon. + + Args: + args: The name of the command. + capsys: Pytest capsys fixture. + monkeypatch: Pytest monkeypatch fixture. + """ + arg_parts = args.split() + resource = arg_parts[2] + if resource in ("devcontainer", "devfile"): + monkeypatch.setattr("sys.argv", ["ansible-creator", *arg_parts, "/foo"]) + elif resource in ("action", "filter", "lookup", "role"): + monkeypatch.setattr("sys.argv", ["ansible-creator", *arg_parts, "name", "/foo"]) + else: + pytest.fail("Fix this test with new COMING_SOON commands") + + with pytest.raises(SystemExit): + cli_main() + stdout, stderr = capsys.readouterr() + assert f"`{args}` command is coming soon" in stdout + assert "Goodbye" in stderr diff --git a/tests/units/test_output.py b/tests/units/test_output.py new file mode 100644 index 0000000..32572a5 --- /dev/null +++ b/tests/units/test_output.py @@ -0,0 +1,23 @@ +"""Test the output module.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from ansible_creator.output import console_width + + +@pytest.mark.parametrize(argnames="width, expected", argvalues=((79, 79), (131, 81), (133, 132))) +def test_console_width(width: int, expected: int, monkeypatch: pytest.MonkeyPatch) -> None: + """Test the console width function.""" + + def mock_get_terminal_size() -> SimpleNamespace: + return SimpleNamespace(columns=width, lines=24) + + monkeypatch.setattr("shutil.get_terminal_size", mock_get_terminal_size) + + monkeypatch.delenv("COLUMNS", raising=False) + + assert console_width() == expected