diff --git a/.config/dictionary.txt b/.config/dictionary.txt index 1075031a..f046d106 100644 --- a/.config/dictionary.txt +++ b/.config/dictionary.txt @@ -1,5 +1,6 @@ addopts antsibull +argcomplete argnames argvalues capsys @@ -30,6 +31,7 @@ rulebook rulebooks sysargs templated +templating testcol testname testns diff --git a/.sonarlint/connectedMode.json b/.sonarlint/connectedMode.json new file mode 100644 index 00000000..b0418700 --- /dev/null +++ b/.sonarlint/connectedMode.json @@ -0,0 +1,4 @@ +{ + "sonarCloudOrganization": "ansible", + "projectKey": "ansible_ansible-creator" +} diff --git a/.vscode/settings.json b/.vscode/settings.json index c9a2baa3..e92207d0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,5 +21,9 @@ "python.testing.unittestEnabled": false, "triggerTaskOnSave.tasks": { "pydoclint": ["*.py"] + }, + "sonarlint.connectedMode.project": { + "connectionId": "ansible", + "projectKey": "ansible_ansible-creator" } } diff --git a/src/ansible_creator/arg_parser.py b/src/ansible_creator/arg_parser.py index e8a13acf..521ae021 100644 --- a/src/ansible_creator/arg_parser.py +++ b/src/ansible_creator/arg_parser.py @@ -115,7 +115,7 @@ def parse_args(self: Parser) -> tuple[argparse.Namespace, list[Msg]]: return self.args, self.pending_logs - def _add(self: Parser, subparser: SubParser) -> None: + def _add(self: Parser, subparser: SubParser[ArgumentParser]) -> None: """Add resources to an existing Ansible project. Args: @@ -224,7 +224,7 @@ def _add_args_plugin_common(self, parser: ArgumentParser) -> None: "current working directory.", ) - def _add_resource(self: Parser, subparser: SubParser) -> None: + def _add_resource(self: Parser, subparser: SubParser[ArgumentParser]) -> None: """Add resources to an existing Ansible project. Args: @@ -244,7 +244,7 @@ def _add_resource(self: Parser, subparser: SubParser) -> None: self._add_resource_devfile(subparser=subparser) self._add_resource_role(subparser=subparser) - def _add_resource_devcontainer(self: Parser, subparser: SubParser) -> None: + def _add_resource_devcontainer(self: Parser, subparser: SubParser[ArgumentParser]) -> None: """Add devcontainer files to an existing Ansible project. Args: @@ -266,7 +266,7 @@ def _add_resource_devcontainer(self: Parser, subparser: SubParser) -> None: self._add_args_common(parser) - def _add_resource_devfile(self: Parser, subparser: SubParser) -> None: + def _add_resource_devfile(self: Parser, subparser: SubParser[ArgumentParser]) -> None: """Add a devfile file to an existing Ansible project. Args: @@ -286,7 +286,7 @@ def _add_resource_devfile(self: Parser, subparser: SubParser) -> None: ) self._add_args_common(parser) - def _add_resource_role(self: Parser, subparser: SubParser) -> None: + def _add_resource_role(self: Parser, subparser: SubParser[ArgumentParser]) -> None: """Add a role to an existing Ansible collection. Args: @@ -310,7 +310,7 @@ def _add_resource_role(self: Parser, subparser: SubParser) -> None: ) self._add_args_common(parser) - def _add_plugin(self: Parser, subparser: SubParser) -> None: + def _add_plugin(self: Parser, subparser: SubParser[ArgumentParser]) -> None: """Add a plugin to an Ansible project. Args: @@ -331,7 +331,7 @@ def _add_plugin(self: Parser, subparser: SubParser) -> None: self._add_plugin_filter(subparser=subparser) self._add_plugin_lookup(subparser=subparser) - def _add_plugin_action(self: Parser, subparser: SubParser) -> None: + def _add_plugin_action(self: Parser, subparser: SubParser[ArgumentParser]) -> None: """Add an action plugin to an existing Ansible collection project. Args: @@ -345,7 +345,7 @@ def _add_plugin_action(self: Parser, subparser: SubParser) -> None: self._add_args_common(parser) self._add_args_plugin_common(parser) - def _add_plugin_filter(self: Parser, subparser: SubParser) -> None: + def _add_plugin_filter(self: Parser, subparser: SubParser[ArgumentParser]) -> None: """Add a filter plugin to an existing Ansible collection project. Args: @@ -359,7 +359,7 @@ def _add_plugin_filter(self: Parser, subparser: SubParser) -> None: self._add_args_common(parser) self._add_args_plugin_common(parser) - def _add_plugin_lookup(self: Parser, subparser: SubParser) -> None: + def _add_plugin_lookup(self: Parser, subparser: SubParser[ArgumentParser]) -> None: """Add a lookup plugin to an existing Ansible collection project. Args: @@ -373,7 +373,7 @@ def _add_plugin_lookup(self: Parser, subparser: SubParser) -> None: self._add_args_common(parser) self._add_args_plugin_common(parser) - def _init(self: Parser, subparser: SubParser) -> None: + def _init(self: Parser, subparser: SubParser[ArgumentParser]) -> None: """Initialize an Ansible project. Args: @@ -393,7 +393,7 @@ def _init(self: Parser, subparser: SubParser) -> None: self._init_collection(subparser=subparser) self._init_playbook(subparser=subparser) - def _init_collection(self: Parser, subparser: SubParser) -> None: + def _init_collection(self: Parser, subparser: SubParser[ArgumentParser]) -> None: """Initialize an Ansible collection. Args: @@ -422,7 +422,7 @@ def _init_collection(self: Parser, subparser: SubParser) -> None: self._add_args_common(parser) self._add_args_init_common(parser) - def _init_playbook(self: Parser, subparser: SubParser) -> None: + def _init_playbook(self: Parser, subparser: SubParser[ArgumentParser]) -> None: """Initialize an Ansible playbook. Args: @@ -453,7 +453,7 @@ def _init_playbook(self: Parser, subparser: SubParser) -> None: self._add_args_common(parser) self._add_args_init_common(parser) - def _valid_collection_name(self, collection: str) -> str | Msg: + def _valid_collection_name(self, collection: str) -> str: """Validate the collection name. Args: @@ -464,22 +464,18 @@ def _valid_collection_name(self, collection: str) -> str | Msg: """ fqcn = collection.split(".", maxsplit=1) expected_parts = 2 + name_filter = re.compile(r"^(?!_)[a-z0-9_]+$") + 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]): + elif 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: + elif 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 @@ -584,7 +580,7 @@ def add_argument_group( if TYPE_CHECKING: - SubParser: TypeAlias = argparse._SubParsersAction[ArgumentParser] # noqa: SLF001 + SubParser: TypeAlias = argparse._SubParsersAction # noqa: SLF001 class CustomHelpFormatter(HelpFormatter): diff --git a/src/ansible_creator/config.py b/src/ansible_creator/config.py index 69a63d0e..debf8d26 100644 --- a/src/ansible_creator/config.py +++ b/src/ansible_creator/config.py @@ -2,13 +2,9 @@ from __future__ import annotations -import re - from dataclasses import dataclass from typing import TYPE_CHECKING -from ansible_creator.constants import MIN_COLLECTION_NAME_LEN -from ansible_creator.exceptions import CreatorError from ansible_creator.utils import expand_path @@ -46,62 +42,13 @@ class Config: project: str = "" scm_org: str | None = None scm_project: str | None = None - - # TO-DO: Add instance variables for other 'create' and 'sample' - collection_name: str | None = None namespace: str = "" def __post_init__(self: Config) -> None: - """Post process config values. - - Raises: - CreatorError: When required values are missing or invalid. - """ - # Validation for: ansible-creator init - if not self.collection and self.project == "collection": - msg = "The argument 'collection' is required when scaffolding a collection." - raise CreatorError(msg) - - # Validation for: ansible-creator init --project=ansible-project - if self.project == "ansible-project" and (self.scm_org is None or self.scm_project is None): - msg = ( - "Parameters 'scm-org' and 'scm-project' are required when " - "scaffolding an ansible-project." - ) - raise CreatorError(msg) - - # Validation for: ansible-creator init testorg.testname --scm-org=weather - # --scm-project=demo --project=collection - if (self.scm_org or self.scm_project) and self.project != "ansible-project": - msg = ( - "The parameters 'scm-org' and 'scm-project' have no effect when" - " project is not set to ansible-project." - ) - self.output.warning(msg) - - # Validation for: ansible-creator init testorg.testname --project=ansible-project - # --scm-org weather --scm-project demo - if self.collection and self.project != "collection": - msg = "Collection name has no effect when project is set to ansible-project." - self.output.warning(msg) - - # Validation for collection name according to Ansible requirements + """Post process config values.""" if self.collection: fqcn = self.collection.split(".", maxsplit=1) - 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." - ) - raise CreatorError(msg) - - if len(fqcn[0]) <= MIN_COLLECTION_NAME_LEN or len(fqcn[-1]) <= MIN_COLLECTION_NAME_LEN: - msg = "Collection namespace and name must be longer than 2 characters." - raise CreatorError(msg) - object.__setattr__(self, "namespace", fqcn[0]) object.__setattr__(self, "collection_name", fqcn[-1]) diff --git a/src/ansible_creator/subcommands/init.py b/src/ansible_creator/subcommands/init.py index fac6c778..afd17dda 100644 --- a/src/ansible_creator/subcommands/init.py +++ b/src/ansible_creator/subcommands/init.py @@ -20,7 +20,18 @@ class Init: - """Class representing ansible-creator init subcommand.""" + """Class representing ansible-creator init subcommand. + + Attributes: + common_resources: List of common resources to copy. + """ + + common_resources = ( + "common.devcontainer", + "common.devfile", + "common.gitignore", + "common.vscode", + ) def __init__( self: Init, @@ -32,22 +43,35 @@ def __init__( config: App configuration object. """ self._namespace: str = config.namespace - self._collection_name: str | None = config.collection_name + self._collection_name = config.collection_name or "" self._init_path: Path = Path(config.init_path) self._force = config.force self._creator_version = config.creator_version self._project = config.project - self._scm_org = config.scm_org - self._scm_project = config.scm_project + self._scm_org = config.scm_org or "" + self._scm_project = config.scm_project or "" self._templar = Templar() self.output: Output = config.output - def run(self: Init) -> None: # noqa: C901 - """Start scaffolding skeleton. + def run(self: Init) -> None: + """Start scaffolding skeleton.""" + self._construct_init_path() + self.output.debug(msg=f"final collection path set to {self._init_path}") + + if self._init_path.exists(): + self.init_exists() + self._init_path.mkdir(parents=True, exist_ok=True) + + if self._project == "collection": + self._scaffold_collection() + elif self._project == "ansible-project": + self._scaffold_playbook() + + def _construct_init_path(self: Init) -> None: + """Construct the init path based on project type.""" + if self._project == "ansible-project": + return - Raises: - CreatorError: When computed collection path is an existing directory or file. - """ if ( self._init_path.parts[-2:] == ("collections", "ansible_collections") and self._project == "collection" @@ -55,104 +79,80 @@ def run(self: Init) -> None: # noqa: C901 ): self._init_path = self._init_path / self._namespace / self._collection_name - self.output.debug(msg=f"final collection path set to {self._init_path}") + def init_exists(self) -> None: + """Handle existing init path. + Raises: + CreatorError: When init path is a file or not empty and --force is not provided. + """ # check if init_path already exists - if self._init_path.exists(): - # init-path exists and is a file - if self._init_path.is_file(): - msg = f"the path {self._init_path} already exists, but is a file - aborting" - raise CreatorError( - msg, - ) - if next(self._init_path.iterdir(), None): - # init-path exists and is not empty, but user did not request --force - if not self._force: - msg = ( - f"The directory {self._init_path} is not empty.\n" - f"You can use --force to re-initialize this directory." - f"\nHowever it will delete ALL existing contents in it." - ) - raise CreatorError(msg) - - # user requested --force, re-initializing existing directory - self.output.warning( - f"re-initializing existing directory {self._init_path}", - ) - try: - shutil.rmtree(self._init_path) - except OSError as e: - err = f"failed to remove existing directory {self._init_path}: {e}" - raise CreatorError(err) from e - - # if init_path does not exist, create it - if not self._init_path.exists(): - self.output.debug(msg=f"creating new directory at {self._init_path}") - self._init_path.mkdir(parents=True) - - common_resources = [ - "common.devcontainer", - "common.devfile", - "common.gitignore", - "common.vscode", - ] - - if self._project == "collection": - if not isinstance(self._collection_name, str): - msg = "Collection name is required when scaffolding a collection." - raise CreatorError(msg) - # copy new_collection container to destination, templating files when found - self.output.debug(msg="started copying collection skeleton to destination") - template_data = TemplateData( - namespace=self._namespace, - collection_name=self._collection_name, - creator_version=self._creator_version, - ) - copier = Copier( - resources=["new_collection", *common_resources], - resource_id="new_collection", - dest=self._init_path, - output=self.output, - templar=self._templar, - template_data=template_data, - ) - copier.copy_containers() - - self.output.note( - f"collection {self._namespace}.{self._collection_name} " - f"created at {self._init_path}", - ) - - else: - self.output.debug( - msg="started copying ansible-project skeleton to destination", - ) - if not isinstance(self._scm_org, str) or not isinstance( - self._scm_project, - str, - ): + # init-path exists and is a file + if self._init_path.is_file(): + msg = f"the path {self._init_path} already exists, but is a file - aborting" + raise CreatorError(msg) + if next(self._init_path.iterdir(), None): + # init-path exists and is not empty, but user did not request --force + if not self._force: msg = ( - "Parameters 'scm-org' and 'scm-project' are required when " - "scaffolding an ansible-project." + f"The directory {self._init_path} is not empty.\n" + f"You can use --force to re-initialize this directory." + f"\nHowever it will delete ALL existing contents in it." ) raise CreatorError(msg) - template_data = TemplateData( - creator_version=self._creator_version, - scm_org=self._scm_org, - scm_project=self._scm_project, - ) - - copier = Copier( - resources=["ansible_project", *common_resources], - resource_id="ansible_project", - dest=self._init_path, - output=self.output, - templar=self._templar, - template_data=template_data, - ) - copier.copy_containers() - - self.output.note( - f"ansible project created at {self._init_path}", + # user requested --force, re-initializing existing directory + self.output.warning( + f"re-initializing existing directory {self._init_path}", ) + try: + shutil.rmtree(self._init_path) + except OSError as e: + err = f"failed to remove existing directory {self._init_path}: {e}" + raise CreatorError(err) from e + + def _scaffold_collection(self) -> None: + """Scaffold a collection project.""" + self.output.debug(msg="started copying collection skeleton to destination") + template_data = TemplateData( + namespace=self._namespace, + collection_name=self._collection_name, + creator_version=self._creator_version, + ) + copier = Copier( + resources=["new_collection", *self.common_resources], + resource_id="new_collection", + dest=self._init_path, + output=self.output, + templar=self._templar, + template_data=template_data, + ) + copier.copy_containers() + + self.output.note( + f"collection {self._namespace}.{self._collection_name} " + f"created at {self._init_path}", + ) + + def _scaffold_playbook(self: Init) -> None: + """Scaffold a playbook project.""" + self.output.debug(msg="started copying ansible-project skeleton to destination") + + template_data = TemplateData( + creator_version=self._creator_version, + scm_org=self._scm_org, + scm_project=self._scm_project, + ) + + copier = Copier( + resources=["ansible_project", *self.common_resources], + resource_id="ansible_project", + dest=self._init_path, + output=self.output, + templar=self._templar, + template_data=template_data, + ) + copier.copy_containers() + + self.output.note( + f"ansible project created at {self._init_path}", + ) diff --git a/src/ansible_creator/utils.py b/src/ansible_creator/utils.py index 74d59544..6dbe4443 100644 --- a/src/ansible_creator/utils.py +++ b/src/ansible_creator/utils.py @@ -74,7 +74,6 @@ class Copier: dest: The destination path to copy resources to. output: An instance of the Output class. template_data: A dictionary containing the original data to render templates with. - allow_overwrite: A list of paths that should be overwritten at destination. index: Index of the current resource being copied. resource_root: Root path for the resources. templar: An instance of the Templar class. @@ -85,7 +84,6 @@ class Copier: dest: Path output: Output template_data: TemplateData - allow_overwrite: list[str] | None = None index: int = 0 resource_root: str = "ansible_creator.resources" templar: Templar | None = None @@ -95,7 +93,7 @@ def resource(self: Copier) -> str: """Return the current resource being copied.""" return self.resources[self.index] - def _recursive_copy( # noqa: C901, PLR0912 + def _recursive_copy( self: Copier, root: Traversable, template_data: TemplateData, @@ -109,59 +107,95 @@ def _recursive_copy( # noqa: C901, PLR0912 self.output.debug(msg=f"current root set to {root}") for obj in root.iterdir(): - overwrite = False - # resource names may have a . but directories use / in the path - dest_name = str(obj).split( - self.resource.replace(".", "/") + "/", - maxsplit=1, - )[-1] - dest_path = self.dest / dest_name - if self.allow_overwrite and (dest_name in self.allow_overwrite): - overwrite = True - # replace placeholders in destination path with real values - for key, val in PATH_REPLACERS.items(): - if key in str(dest_path) and template_data: - str_dest_path = str(dest_path) - repl_val = getattr(template_data, val) - dest_path = Path(str_dest_path.replace(key, repl_val)) - - if obj.is_dir(): - if obj.name in SKIP_DIRS: - continue - if not dest_path.exists(): - dest_path.mkdir(parents=True) - - # recursively copy the directory - self._recursive_copy( - root=obj, - template_data=template_data, - ) + self.each_obj(obj, template_data) + + def each_obj(self, obj: Traversable, template_data: TemplateData) -> None: + """Recursively traverses a resource container and copies content to destination. - elif obj.is_file(): - if obj.name.split(".")[-1] in SKIP_FILES_TYPES: - continue - if obj.name == "__meta__.yml": - continue - # remove .j2 suffix at destination - needs_templating = False - if dest_path.suffix == ".j2": - dest_path = dest_path.with_suffix("") - needs_templating = True - dest_file = Path(self.dest) / dest_path - self.output.debug(msg=f"dest file is {dest_file}") - - # write at destination only if missing or belongs to overwrite list - if not dest_file.exists() or overwrite: - content = obj.read_text(encoding="utf-8") - # only render as templates if both of these are provided, - # and original file suffix was j2 - if self.templar and template_data and needs_templating: - content = self.templar.render_from_content( - template=content, - data=template_data, - ) - with dest_file.open("w", encoding="utf-8") as df_handle: - df_handle.write(content) + Args: + obj: A traversable object representing the root of the container to copy. + template_data: A dictionary containing current data to render templates with. + """ + # resource names may have a . but directories use / in the path + dest_name = str(obj).split( + self.resource.replace(".", "/") + "/", + maxsplit=1, + )[-1] + dest_path = self.dest / dest_name + + # replace placeholders in destination path with real values + for key, val in PATH_REPLACERS.items(): + if key in str(dest_path) and template_data: + str_dest_path = str(dest_path) + repl_val = getattr(template_data, val) + dest_path = Path(str_dest_path.replace(key, repl_val)) + + if obj.is_dir(): + if obj.name in SKIP_DIRS: + return + self._recursive_copy_dir(obj=obj, dest_path=dest_path, template_data=template_data) + + elif obj.is_file(): + if obj.name.split(".")[-1] in SKIP_FILES_TYPES or obj.name == "__meta__.yml": + return + self._copy_file( + obj=obj, + dest_path=dest_path, + template_data=template_data, + ) + + def _copy_file( + self, + obj: Traversable, + dest_path: Path, + template_data: TemplateData, + ) -> None: + """Copy a file to destination. + + Args: + obj: A traversable object representing the file to copy. + dest_path: The destination path to copy the file to. + template_data: A dictionary containing current data to render templates with. + """ + # remove .j2 suffix at destination + needs_templating = False + if dest_path.suffix == ".j2": + dest_path = dest_path.with_suffix("") + needs_templating = True + dest_file = Path(self.dest) / dest_path + self.output.debug(msg=f"dest file is {dest_file}") + + content = obj.read_text(encoding="utf-8") + # only render as templates if both of these are provided, + # and original file suffix was j2 + if self.templar and template_data and needs_templating: + content = self.templar.render_from_content( + template=content, + data=template_data, + ) + with dest_file.open("w", encoding="utf-8") as df_handle: + df_handle.write(content) + + def _recursive_copy_dir( + self, + obj: Traversable, + dest_path: Path, + template_data: TemplateData, + ) -> None: + """Recursively copy directories to destination. + + Args: + obj: A traversable object representing the directory to copy. + dest_path: The destination path to copy the directory to. + template_data: A dictionary containing current data to render templates with. + """ + dest_path.mkdir(parents=True, exist_ok=True) + + # recursively copy the directory + self._recursive_copy( + root=obj, + template_data=template_data, + ) def _per_container(self: Copier) -> None: """Copy files and directories from a possibly nested source to a destination. @@ -170,10 +204,8 @@ def _per_container(self: Copier) -> None: :raises CreatorError: if allow_overwrite is not a list. """ - self.output.debug( - msg=f"starting recursive copy with source container '{self.resource}'", - ) - self.output.debug(msg=f"allow_overwrite set to {self.allow_overwrite}") + msg = f"starting recursive copy with source container '{self.resource}'" + self.output.debug(msg) # Cast the template data to not pollute the original template_data = copy.deepcopy(self.template_data) diff --git a/tests/units/test_basic.py b/tests/units/test_basic.py index fa7eab4e..9c799de4 100644 --- a/tests/units/test_basic.py +++ b/tests/units/test_basic.py @@ -407,7 +407,7 @@ def test_main(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str with pytest.raises(SystemExit): runpy.run_module("ansible_creator.cli", run_name="__main__") - stdout, stderr = capsys.readouterr() + stdout, _stderr = capsys.readouterr() assert "The fastest way" in stdout @@ -422,7 +422,7 @@ def test_proj_main(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixtur with pytest.raises(SystemExit): runpy.run_module("ansible_creator", run_name="__main__") - stdout, stderr = capsys.readouterr() + stdout, _stderr = capsys.readouterr() assert "The fastest way" in stdout diff --git a/tests/units/test_init.py b/tests/units/test_init.py index b9a4bd5e..c2c9e238 100644 --- a/tests/units/test_init.py +++ b/tests/units/test_init.py @@ -226,132 +226,6 @@ def test_run_success_collections_alt_dir( ) -def test_error_1( - tmp_path: Path, - cli_args: ConfigDict, -) -> None: - """Test Init.run(). - - Validation for: ansible-creator init --project=ansible-project - - Args: - tmp_path: Temporary directory path. - cli_args: Dictionary, partial Init class object. - """ - cli_args["collection"] = "" - cli_args["project"] = "ansible-project" - cli_args["init_path"] = str(tmp_path / "new_project") - cli_args["scm_org"] = None - cli_args["scm_project"] = None - fail_msg = ( - "Parameters 'scm-org' and 'scm-project' are required when " - "scaffolding an ansible-project." - ) - with pytest.raises(CreatorError, match=fail_msg): - Init(Config(**cli_args)) - - -def test_error_2( - cli_args: ConfigDict, -) -> None: - """Test Init.run(). - - Validation for: ansible-creator init - - Args: - cli_args: Dictionary, partial Init class object. - """ - cli_args["collection"] = "" - cli_args["project"] = "collection" - cli_args["init_path"] = "" - cli_args["scm_org"] = "" - cli_args["scm_project"] = "" - fail_msg = "The argument 'collection' is required when scaffolding a collection." - with pytest.raises(CreatorError, match=fail_msg): - Init(Config(**cli_args)) - - -def test_warning( - capsys: pytest.CaptureFixture[str], - tmp_path: Path, - cli_args: ConfigDict, -) -> None: - """Test Init.run(). - - Validation for: ansible-creator init testorg.testname --scm-org=weather - --scm-project=demo --project=collection - - Args: - capsys: Pytest fixture to capture stdout and stderr. - tmp_path: Temporary directory path. - cli_args: Dictionary, partial Init class object. - """ - cli_args["collection"] = "testorg.testname" - cli_args["project"] = "collection" - cli_args["init_path"] = str(tmp_path / "testorg" / "testcol") - cli_args["scm_org"] = "weather" - cli_args["scm_project"] = "demo" - init = Init( - Config(**cli_args), - ) - init.run() - result = capsys.readouterr().out - - # this is required to handle random line breaks in CI, especially with macos runners - mod_result = "".join([line.strip() for line in result.splitlines()]) - assert ( - re.search( - r"Warning:\s*The parameters\s*'scm-org'\s*and\s*'scm-project'" - r"\s*have\s*no\s*effect\s*when\s*project\s*is\s*not\s*set\s*to\s*ansible-project", - mod_result, - ) - is not None - ) - - -def test_collection_name_char_error( - cli_args: ConfigDict, -) -> None: - """Test a collection's name for disallowed characters. - - Validation for: ansible-creator init - - Args: - cli_args: Dictionary, partial Init class object. - """ - cli_args["collection"] = "BAD.NAME" - cli_args["project"] = "collection" - cli_args["init_path"] = "" - cli_args["scm_org"] = "" - cli_args["scm_project"] = "" - fail_msg = ( - "Collection name can only contain lower case letters, underscores, and numbers" - " and cannot begin with an underscore." - ) - with pytest.raises(CreatorError, match=fail_msg): - Init(Config(**cli_args)) - - -def test_collection_name_length_error( - cli_args: ConfigDict, -) -> None: - """Test a collection's name for length greater than 2 characters. - - Validation for: ansible-creator init - - Args: - cli_args: Dictionary, partial Init class object. - """ - cli_args["collection"] = "na.co" - cli_args["project"] = "collection" - cli_args["init_path"] = "" - cli_args["scm_org"] = "" - cli_args["scm_project"] = "" - fail_msg = "Collection namespace and name must be longer than 2 characters." - with pytest.raises(CreatorError, match=fail_msg): - Init(Config(**cli_args)) - - def test_delete_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """Test a remove fails gracefully. @@ -418,97 +292,3 @@ def test_is_file_error(tmp_path: Path) -> None: with pytest.raises(CreatorError) as exc_info: init.run() assert "but is a file" in str(exc_info.value) - - -def test_collection_name_not_set(output: Output, tmp_path: Path) -> None: - """Although it shouldn't happen, test when collection name is not set. - - Args: - output: Output class object. - tmp_path: Temporary directory path. - """ - - class FakeConfig: - """Fake Config class, ours protects from this error. - - Attributes: - collection: The name of the collection. - collection_name: The name of the collection. - creator_version: The version of the creator. - force: Force overwrite of existing directory. - init_path: Path to initialize the project. - project: The type of project to scaffold. - output: The output object to use for logging. - namespace: The namespace for the collection. - scm_org: The SCM organization for the project. - scm_project: The SCM project for the project. - subcommand: The subcommand to execute. - """ - - collection: str = "foo.bar" - collection_name: str | None = None - creator_version: str = "0.0.1" - force: bool = False - init_path: Path = tmp_path - project: str = "collection" - output: Output - namespace: str | None = None - scm_org: str | None = None - scm_project: str | None = None - subcommand: str = "init" - - config = FakeConfig() - config.output = output - - expected = "Collection name is required when scaffolding a collection." - with pytest.raises(CreatorError, match=expected): - Init(config=config).run() # type: ignore[arg-type] - - -def test_scm_vals_not_set(output: Output, tmp_path: Path) -> None: - """Although it shouldn't happen, test when scm_org or scm_project not set. - - Args: - output: Output class object. - tmp_path: Temporary directory path. - """ - - class FakeConfig: - """Fake Config class, ours protects from this error. - - Attributes: - collection: The name of the collection. - collection_name: The name of the collection. - creator_version: The version of the creator. - force: Force overwrite of existing directory. - init_path: Path to initialize the project. - project: The type of project to scaffold. - output: The output object to use for logging. - namespace: The namespace for the collection. - scm_org: The SCM organization for the project. - scm_project: The SCM project for the project. - subcommand: The subcommand to execute. - """ - - collection: str = "foo.bar" - collection_name: str | None = None - creator_version: str = "0.0.1" - force: bool = False - init_path: Path = tmp_path - project: str = "ansible_project" - output: Output - namespace: str | None = None - scm_org: str | None = None - scm_project: str | None = None - subcommand: str = "init" - - config = FakeConfig() - config.output = output - - expected = ( - "Parameters 'scm-org' and 'scm-project'" - " are required when scaffolding an ansible-project." - ) - - with pytest.raises(CreatorError, match=expected): - Init(config=config).run() # type: ignore[arg-type] diff --git a/tests/units/test_utils.py b/tests/units/test_utils.py index 600e31c4..bfdeb653 100644 --- a/tests/units/test_utils.py +++ b/tests/units/test_utils.py @@ -2,7 +2,11 @@ from pathlib import Path -from ansible_creator.utils import expand_path +import pytest + +from ansible_creator.output import Output +from ansible_creator.types import TemplateData +from ansible_creator.utils import Copier, expand_path def test_expand_path() -> None: @@ -14,3 +18,24 @@ def test_expand_path() -> None: assert expand_path("foo") == Path.cwd() / "foo" assert expand_path("$HOME") == home assert expand_path("~/$HOME") == Path(f"{home}/{Path.home()}") + + +def test_skip_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, output: Output) -> None: + """Test the skip dirs constant. + + Args: + tmp_path: Temporary directory path. + monkeypatch: Pytest monkeypatch fixture. + output: Output class object. + """ + monkeypatch.setattr("ansible_creator.utils.SKIP_DIRS", ["docker"]) + copier = Copier( + resources=["common.devcontainer"], + resource_id="common.devcontainer", + dest=tmp_path, + output=output, + template_data=TemplateData(), + ) + copier.copy_containers() + assert (tmp_path / ".devcontainer" / "podman").exists() + assert not (tmp_path / ".devcontainer" / "docker").exists()