From 8a303f932ab5f983f1ca0977f0062f5c8f0d2375 Mon Sep 17 00:00:00 2001 From: Callahan Kovacs Date: Fri, 11 Oct 2024 18:01:43 -0500 Subject: [PATCH] feat: create InitService Signed-off-by: Callahan Kovacs --- craft_application/commands/init.py | 150 +-------- craft_application/commands/other.py | 3 +- craft_application/services/__init__.py | 2 + craft_application/services/init.py | 155 +++++++++ craft_application/services/service_factory.py | 2 + craft_application/util/file.py | 1 + pyproject.toml | 1 + tests/conftest.py | 11 + tests/integration/commands/__init__.py | 0 .../{commands => services}/test_init.py | 307 +++++------------- tests/integration/test_application.py | 3 +- tests/unit/commands/test_init.py | 58 ++++ tests/unit/commands/test_other.py | 7 +- tests/unit/conftest.py | 1 + tests/unit/remote/conftest.py | 29 -- tests/unit/services/test_init.py | 205 ++++++++++++ 16 files changed, 540 insertions(+), 395 deletions(-) create mode 100644 craft_application/services/init.py delete mode 100644 tests/integration/commands/__init__.py rename tests/integration/{commands => services}/test_init.py (53%) create mode 100644 tests/unit/commands/test_init.py delete mode 100644 tests/unit/remote/conftest.py create mode 100644 tests/unit/services/test_init.py diff --git a/craft_application/commands/init.py b/craft_application/commands/init.py index 99cf7387..d1bb7324 100644 --- a/craft_application/commands/init.py +++ b/craft_application/commands/init.py @@ -16,20 +16,9 @@ from __future__ import annotations import argparse -import os import pathlib -import shutil from textwrap import dedent -from typing import Any, cast - -from craft_cli import emit -from jinja2 import ( - Environment, - PackageLoader, - StrictUndefined, -) - -from craft_application.util import make_executable +from typing import cast from . import base @@ -69,107 +58,19 @@ def run(self, parsed_args: argparse.Namespace) -> None: """Run the command.""" project_dir = self._get_project_dir(parsed_args) name = self._get_name(parsed_args) - context = self._get_context(parsed_args) - template_dir = self._get_template_dir(parsed_args) - environment = self._get_templates_environment(template_dir) - executable_files = self._get_executable_files(parsed_args) - - self._create_project_dir(project_dir=project_dir, name=name) - self._render_project( - environment, - project_dir, - template_dir, - context, - executable_files, - ) - - def _copy_template_file( - self, - template_name: str, - template_dir: pathlib.Path, - project_dir: pathlib.Path, - ) -> None: - """Copy the non-ninja template from template_dir to project_dir. - - If the file already exists in the projects copying is skipped. - - :param project_dir: The directory to render the files into. - :param template_dir: The directory where templates are stored. - :param template_name: Name of the template to copy. - """ - emit.debug(f"Copying file {template_name} to {project_dir}") - template_file = template_dir / template_name - destination_file = project_dir / template_name - if destination_file.exists(): - emit.trace(f"Skipping file {template_name} as it is already present") - return - destination_file.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(template_file, destination_file, follow_symlinks=False) - - def _render_project( - self, - environment: Environment, - project_dir: pathlib.Path, - template_dir: pathlib.Path, - context: dict[str, Any], - executable_files: list[str], - ) -> None: - """Render files for a project from a template. + self._services.init.run(project_dir=project_dir, name=name) - :param environment: The Jinja environment to use. - :param project_dir: The directory to render the files into. - :param template_dir: The directory where templates are stored. - :param context: The context to render the templates with. - :param executable_files: The list of files that should be executable. - """ - for template_name in environment.list_templates(): - if not template_name.endswith(".j2"): - self._copy_template_file(template_name, template_dir, project_dir) - continue - template = environment.get_template(template_name) - - # trim off `.j2` - rendered_template_name = pathlib.Path(template_name).stem - emit.debug(f"Rendering {template_name} to {rendered_template_name}") - - path = project_dir / rendered_template_name - if path.exists(): - emit.trace(f"Skipping file {template_name} as it is already present") - continue - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("wt", encoding="utf8") as file: - out = template.render(context) - file.write(out) - if rendered_template_name in executable_files and os.name == "posix": - make_executable(file) - emit.debug(" made executable") - emit.message("Successfully initialised project.") - - def _get_template_dir( - self, - parsed_args: argparse.Namespace, # noqa: ARG002 (unused-method-argument) - ) -> pathlib.Path: - """Return the path to the template directory.""" - return pathlib.Path("templates") - - def _get_executable_files( - self, - parsed_args: argparse.Namespace, # noqa: ARG002 (unused-method-argument) - ) -> list[str]: - """Return the list of files that should be executable.""" - return [] - - def _get_context(self, parsed_args: argparse.Namespace) -> dict[str, Any]: - """Get context to render templates with. + def _get_name(self, parsed_args: argparse.Namespace) -> str: + """Get name of the package that is about to be initialized. - :returns: A dict of context variables. + Check if name is set explicitly or fallback to project_dir. """ - name = self._get_name(parsed_args) - emit.debug(f"Set project name to '{name}'") - - return {"name": name} + if parsed_args.name is not None: + return cast(str, parsed_args.name) + return self._get_project_dir(parsed_args).name - def _get_project_dir(self, parsed_args: argparse.Namespace) -> pathlib.Path: + @staticmethod + def _get_project_dir(parsed_args: argparse.Namespace) -> pathlib.Path: """Get project dir where project should be initialized. It applies rules in the following order: @@ -182,34 +83,3 @@ def _get_project_dir(self, parsed_args: argparse.Namespace) -> pathlib.Path: # If both args are undefined, default to current dir return pathlib.Path.cwd().resolve() - - def _get_name(self, parsed_args: argparse.Namespace) -> str: - """Get name of the package that is about to be initialized. - - Check if name is set explicitly or fallback to project_dir. - """ - if parsed_args.name is not None: - return cast(str, parsed_args.name) - return self._get_project_dir(parsed_args).name - - def _create_project_dir( - self, - project_dir: pathlib.Path, - *, - name: str, - ) -> None: - """Create the project path if it does not already exist.""" - if not project_dir.exists(): - project_dir.mkdir(parents=True) - emit.debug(f"Using project directory {str(project_dir)!r} for {name}") - - def _get_templates_environment(self, template_dir: pathlib.Path) -> Environment: - """Create and return a Jinja environment to deal with the templates.""" - loader = PackageLoader(self._app.name, str(template_dir)) - return Environment( - loader=loader, - autoescape=False, # noqa: S701 (jinja2-autoescape-false) - keep_trailing_newline=True, # they're not text files if they don't end in newline! - optimized=False, # optimization doesn't make sense for one-offs - undefined=StrictUndefined, - ) # fail on undefined diff --git a/craft_application/commands/other.py b/craft_application/commands/other.py index 3994dc64..25963089 100644 --- a/craft_application/commands/other.py +++ b/craft_application/commands/other.py @@ -18,7 +18,7 @@ from craft_cli import CommandGroup, emit -from craft_application.commands import base +from . import InitCommand, base if TYPE_CHECKING: # pragma: no cover import argparse @@ -27,6 +27,7 @@ def get_other_command_group() -> CommandGroup: """Return the lifecycle related command group.""" commands: list[type[base.AppCommand]] = [ + InitCommand, VersionCommand, ] diff --git a/craft_application/services/__init__.py b/craft_application/services/__init__.py index accbd923..e9c066c2 100644 --- a/craft_application/services/__init__.py +++ b/craft_application/services/__init__.py @@ -19,6 +19,7 @@ from craft_application.services.config import ConfigService from craft_application.services.fetch import FetchService from craft_application.services.lifecycle import LifecycleService +from craft_application.services.init import InitService from craft_application.services.package import PackageService from craft_application.services.provider import ProviderService from craft_application.services.remotebuild import RemoteBuildService @@ -31,6 +32,7 @@ "ProjectService", "ConfigService", "LifecycleService", + "InitService", "PackageService", "ProviderService", "RemoteBuildService", diff --git a/craft_application/services/init.py b/craft_application/services/init.py new file mode 100644 index 00000000..71b016e4 --- /dev/null +++ b/craft_application/services/init.py @@ -0,0 +1,155 @@ +# This file is part of craft-application. +# +# Copyright 2024 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +"""Service for initializing a project.""" + +import os +import pathlib +import shutil +from typing import Any + +import jinja2 +from craft_cli import emit + +from craft_application.util import make_executable + +from . import base + + +class InitService(base.AppService): + """Service class for initializing a project.""" + + def run(self, *, project_dir: pathlib.Path, name: str) -> None: + """Initialize a new project.""" + context = self._get_context(name=name) + template_dir = self._get_template_dir() + environment = self._get_templates_environment(template_dir) + executable_files = self._get_executable_files() + + self._create_project_dir(project_dir=project_dir, name=name) + self._render_project( + environment, + project_dir, + template_dir, + context, + executable_files, + ) + + def _copy_template_file( + self, + template_name: str, + template_dir: pathlib.Path, + project_dir: pathlib.Path, + ) -> None: + """Copy the non-ninja template from template_dir to project_dir. + + If the file already exists in the projects copying is skipped. + + :param project_dir: The directory to render the files into. + :param template_dir: The directory where templates are stored. + :param template_name: Name of the template to copy. + """ + emit.debug(f"Copying file {template_name} to {project_dir}") + template_file = template_dir / template_name + destination_file = project_dir / template_name + if destination_file.exists(): + emit.debug(f"Skipping file {template_name} because it is already present.") + return + destination_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(template_file, destination_file, follow_symlinks=False) + + def _render_project( + self, + environment: jinja2.Environment, + project_dir: pathlib.Path, + template_dir: pathlib.Path, + context: dict[str, Any], + executable_files: list[str], + ) -> None: + """Render files for a project from a template. + + :param environment: The Jinja environment to use. + :param project_dir: The directory to render the files into. + :param template_dir: The directory where templates are stored. + :param context: The context to render the templates with. + :param executable_files: The list of files that should be executable. + """ + for template_name in environment.list_templates(): + if not template_name.endswith(".j2"): + self._copy_template_file(template_name, template_dir, project_dir) + continue + template = environment.get_template(template_name) + + # trim off `.j2` + rendered_template_name = template_name[:-3] + emit.debug(f"Rendering {template_name} to {rendered_template_name}") + + path = project_dir / rendered_template_name + if path.exists(): + emit.trace(f"Skipping file {template_name} as it is already present") + continue + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wt", encoding="utf8") as file: + out = template.render(context) + file.write(out) + if rendered_template_name in executable_files and os.name == "posix": + make_executable(file) + emit.debug(" made executable") + emit.message("Successfully initialised project.") + + def _get_template_dir(self) -> pathlib.Path: + """Return the path to the template directory.""" + return pathlib.Path("templates") + + def _get_executable_files(self) -> list[str]: + """Return the list of files that should be executable.""" + return [] + + def _get_context(self, name: str) -> dict[str, Any]: + """Get context to render templates with. + + :returns: A dict of context variables. + """ + emit.debug(f"Set project name to '{name}'") + + return {"name": name} + + def _create_project_dir( + self, + project_dir: pathlib.Path, + *, + name: str, + ) -> None: + """Create the project path if it does not already exist.""" + if not project_dir.exists(): + project_dir.mkdir(parents=True) + emit.debug(f"Using project directory {str(project_dir)!r} for {name}") + + def _get_loader(self, template_dir: pathlib.Path) -> jinja2.PackageLoader: + """Return a Jinja template loader for the given template directory.""" + return jinja2.PackageLoader(self._app.name, str(template_dir)) + + def _get_templates_environment( + self, template_dir: pathlib.Path + ) -> jinja2.Environment: + """Create and return a Jinja environment to deal with the templates.""" + return jinja2.Environment( + loader=self._get_loader(template_dir), + autoescape=False, # noqa: S701 (jinja2-autoescape-false) + keep_trailing_newline=True, # they're not text files if they don't end in newline! + optimized=False, # optimization doesn't make sense for one-offs + undefined=jinja2.StrictUndefined, + ) # fail on undefined diff --git a/craft_application/services/service_factory.py b/craft_application/services/service_factory.py index d7c3cf4f..51416e52 100644 --- a/craft_application/services/service_factory.py +++ b/craft_application/services/service_factory.py @@ -44,6 +44,7 @@ class ServiceFactory: RequestClass: type[services.RequestService] = services.RequestService ConfigClass: type[services.ConfigService] = services.ConfigService FetchClass: type[services.FetchService] = services.FetchService + InitClass: type[services.InitService] = services.InitService project: models.Project | None = None @@ -57,6 +58,7 @@ class ServiceFactory: request: services.RequestService = None # type: ignore[assignment] config: services.ConfigService = None # type: ignore[assignment] fetch: services.FetchService = None # type: ignore[assignment] + init: services.InitService = None # type: ignore[assignment] def __post_init__(self) -> None: self._service_kwargs: dict[str, dict[str, Any]] = {} diff --git a/craft_application/util/file.py b/craft_application/util/file.py index fccf39dd..a78ff384 100644 --- a/craft_application/util/file.py +++ b/craft_application/util/file.py @@ -27,6 +27,7 @@ def make_executable(fh: io.IOBase) -> None: """Make open file fh executable. + :param fh: An open file object. """ fileno = fh.fileno() diff --git a/pyproject.toml b/pyproject.toml index 2e1de3d0..c43aac57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "craft-parts>=2.1.1", "craft-platforms>=0.3.1", "craft-providers>=2.0.4", + "Jinja2~=3.1", "snap-helpers>=0.4.2", "platformdirs>=3.10", "pydantic~=2.0", diff --git a/tests/conftest.py b/tests/conftest.py index 9da4a645..c198584f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -339,3 +339,14 @@ def app(app_metadata, fake_services): @pytest.fixture def manifest_data_dir(): return pathlib.Path(__file__).parent / "data/manifest" + + +@pytest.fixture +def new_dir(tmp_path): + """Change to a new temporary directory.""" + cwd = pathlib.Path.cwd() + os.chdir(tmp_path) + + yield tmp_path + + os.chdir(cwd) diff --git a/tests/integration/commands/__init__.py b/tests/integration/commands/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/integration/commands/test_init.py b/tests/integration/services/test_init.py similarity index 53% rename from tests/integration/commands/test_init.py rename to tests/integration/services/test_init.py index d8afe143..dcad9838 100644 --- a/tests/integration/commands/test_init.py +++ b/tests/integration/services/test_init.py @@ -13,25 +13,40 @@ # # You should have received a copy of the GNU Lesser General Public License along # with this program. If not, see . -"""Tests for init command.""" -import argparse +"""Tests for init service.""" + +import pathlib import sys -from pathlib import Path -from textwrap import dedent +import textwrap import pytest -from craft_application.commands.init import ( - InitCommand, -) from craft_application.models.project import Project -from jinja2 import Environment, FileSystemLoader, StrictUndefined, Undefined +from craft_application.services import InitService +from jinja2 import FileSystemLoader + +# init operates in the current working directory +pytestmark = pytest.mark.usefixtures("new_dir") + + +@pytest.fixture +def init_service(app_metadata, fake_services, mocker, tmp_path): + _init_service = InitService(app_metadata, fake_services) + _init_service.setup() + + mocker.patch.object( + _init_service, + "_get_loader", + return_value=FileSystemLoader(tmp_path / "templates"), + ) + + return _init_service @pytest.fixture -def fake_empty_template_dir(tmp_path) -> Path: - empty_template_dir_path = Path(tmp_path / "fake_template_dir") - empty_template_dir_path.mkdir() +def fake_empty_template_dir(tmp_path) -> pathlib.Path: + empty_template_dir_path = pathlib.Path(tmp_path / "templates") + empty_template_dir_path.mkdir(parents=True) return empty_template_dir_path @@ -40,13 +55,16 @@ def project_yaml_filename() -> str: return "testcraft.yaml" +# TODO: test nested templates + + @pytest.fixture def template_dir_with_testcraft_yaml_j2( - fake_empty_template_dir: Path, + fake_empty_template_dir: pathlib.Path, project_yaml_filename: str, -) -> Path: +) -> pathlib.Path: (fake_empty_template_dir / f"{project_yaml_filename}.j2").write_text( - dedent( + textwrap.dedent( """ # This file configures testcraft. @@ -93,8 +111,8 @@ def template_dir_with_testcraft_yaml_j2( @pytest.fixture def template_dir_with_multiple_non_ninja_files( - fake_empty_template_dir: Path, -) -> Path: + fake_empty_template_dir: pathlib.Path, +) -> pathlib.Path: file_1 = fake_empty_template_dir / "file1.txt" file_1.write_text("Content of file1.txt") file_2 = fake_empty_template_dir / "nested" / "file2.txt" @@ -105,78 +123,37 @@ def template_dir_with_multiple_non_ninja_files( @pytest.fixture def template_dir_with_symlinks( - template_dir_with_testcraft_yaml_j2: Path, -) -> Path: + template_dir_with_testcraft_yaml_j2: pathlib.Path, +) -> pathlib.Path: symlink_to_python_executable = template_dir_with_testcraft_yaml_j2 / "py3_symlink" symlink_to_python_executable.symlink_to(sys.executable) return template_dir_with_testcraft_yaml_j2 @pytest.fixture -def fake_empty_project_dir(tmp_path, monkeypatch) -> Path: - empty_project_dir_path = Path(tmp_path / "fake-project-dir") +def fake_empty_project_dir(tmp_path) -> pathlib.Path: + empty_project_dir_path = pathlib.Path(tmp_path / "fake-project-dir") empty_project_dir_path.mkdir() - monkeypatch.chdir(empty_project_dir_path) return empty_project_dir_path @pytest.fixture -def non_empty_project_dir(tmp_path, monkeypatch) -> Path: - non_empty_project_dir_path = Path(tmp_path / "fake-non-empty-project-dir") +def non_empty_project_dir(tmp_path) -> pathlib.Path: + non_empty_project_dir_path = pathlib.Path(tmp_path / "fake-non-empty-project-dir") non_empty_project_dir_path.mkdir() (non_empty_project_dir_path / "some_project_file").touch() - monkeypatch.chdir(non_empty_project_dir_path) return non_empty_project_dir_path -def get_jinja2_template_environment( - fake_template_dir: Path, - *, - autoescape: bool = False, - keep_trailing_newline: bool = True, - optimized: bool = False, - undefined: type[Undefined] = StrictUndefined, -) -> Environment: - return Environment( - loader=FileSystemLoader(fake_template_dir), - autoescape=autoescape, # noqa: S701 (jinja2-autoescape-false) - keep_trailing_newline=keep_trailing_newline, - optimized=optimized, - undefined=undefined, - ) - - -def get_command_arguments( - *, - name: str | None = None, - project_dir: Path | None = None, -) -> argparse.Namespace: - return argparse.Namespace( - project_dir=project_dir, - name=name, - ) - - -@pytest.fixture -def init_command(app_metadata) -> InitCommand: - return InitCommand({"app": app_metadata, "services": []}) - - +@pytest.mark.usefixtures("fake_empty_template_dir") def test_init_works_with_empty_templates_dir( - init_command: InitCommand, - fake_empty_project_dir: Path, - fake_empty_template_dir, + init_service: InitService, + fake_empty_project_dir: pathlib.Path, emitter, - mocker, check, ): - parsed_args = get_command_arguments() - mocker.patch.object( - init_command, - "_get_templates_environment", - return_value=get_jinja2_template_environment(fake_empty_template_dir), - ) - init_command.run(parsed_args) + """Initialize a project with an empty templates directory.""" + init_service.run(project_dir=fake_empty_project_dir, name="fake-project-dir") with check: assert emitter.assert_message("Successfully initialised project.") @@ -186,24 +163,16 @@ def test_init_works_with_empty_templates_dir( ), "Project dir should be initialized empty" +@pytest.mark.usefixtures("template_dir_with_testcraft_yaml_j2") def test_init_works_with_single_template_file( - init_command: InitCommand, - fake_empty_project_dir: Path, + init_service: InitService, + fake_empty_project_dir: pathlib.Path, project_yaml_filename: str, - template_dir_with_testcraft_yaml_j2: Path, emitter, - mocker, check, ): - parsed_args = get_command_arguments() - mocker.patch.object( - init_command, - "_get_templates_environment", - return_value=get_jinja2_template_environment( - template_dir_with_testcraft_yaml_j2 - ), - ) - init_command.run(parsed_args) + """Initialize a project with a single template file.""" + init_service.run(project_dir=fake_empty_project_dir, name="fake-project-dir") with check: assert emitter.assert_message("Successfully initialised project.") @@ -217,43 +186,34 @@ def test_init_works_with_single_template_file( assert project.name == fake_empty_project_dir.name +@pytest.mark.usefixtures("template_dir_with_testcraft_yaml_j2") def test_init_works_with_single_template_and_custom_name( - init_command: InitCommand, - fake_empty_project_dir: Path, + init_service: InitService, + fake_empty_project_dir: pathlib.Path, project_yaml_filename: str, - template_dir_with_testcraft_yaml_j2: Path, emitter, - mocker, check, ): - custom_name = "some-other-testproject" - parsed_args = get_command_arguments(name=custom_name) - mocker.patch.object( - init_command, - "_get_templates_environment", - return_value=get_jinja2_template_environment( - template_dir_with_testcraft_yaml_j2 - ), - ) - init_command.run(parsed_args) + """Initialize a project with a single template file and custom name.""" + name = "some-other-test-project" + init_service.run(project_dir=fake_empty_project_dir, name=name) with check: assert emitter.assert_message("Successfully initialised project.") - project_yaml_path = Path(fake_empty_project_dir, project_yaml_filename) + project_yaml_path = pathlib.Path(fake_empty_project_dir, project_yaml_filename) with check: assert project_yaml_path.exists(), "Project should be initialized with template" project = Project.from_yaml_file(project_yaml_path) with check: - assert project.name == custom_name + assert project.name == name def check_file_existence_and_content( - check, - file_path: Path, - content: str, + check, file_path: pathlib.Path, content: str ) -> None: + """Helper function to ensure a file exists and has the correct content.""" with check: assert file_path.exists(), f"{file_path.name} should be created" @@ -261,30 +221,14 @@ def check_file_existence_and_content( assert file_path.read_text() == content, f"{file_path.name} incorrect content" +@pytest.mark.usefixtures("template_dir_with_multiple_non_ninja_files") def test_init_works_with_non_jinja2_templates( - init_command: InitCommand, - fake_empty_project_dir: Path, - template_dir_with_multiple_non_ninja_files: Path, + init_service: InitService, + fake_empty_project_dir: pathlib.Path, emitter, - mocker, check, ): - parsed_args = get_command_arguments() - - mocker.patch.object( - init_command, - "_get_template_dir", - return_value=template_dir_with_multiple_non_ninja_files, - ) - - mocker.patch.object( - init_command, - "_get_templates_environment", - return_value=get_jinja2_template_environment( - template_dir_with_multiple_non_ninja_files - ), - ) - init_command.run(parsed_args) + init_service.run(project_dir=fake_empty_project_dir, name="fake-project-dir") with check: assert emitter.assert_message("Successfully initialised project.") @@ -299,28 +243,14 @@ def test_init_works_with_non_jinja2_templates( ) +@pytest.mark.usefixtures("template_dir_with_symlinks") def test_init_does_not_follow_symlinks_but_copies_them_as_is( - init_command: InitCommand, - fake_empty_project_dir: Path, + init_service: InitService, + fake_empty_project_dir: pathlib.Path, project_yaml_filename: str, - template_dir_with_symlinks: Path, - mocker, check, ): - parsed_args = get_command_arguments() - - mocker.patch.object( - init_command, - "_get_template_dir", - return_value=template_dir_with_symlinks, - ) - - mocker.patch.object( - init_command, - "_get_templates_environment", - return_value=get_jinja2_template_environment(template_dir_with_symlinks), - ) - init_command.run(parsed_args) + init_service.run(project_dir=fake_empty_project_dir, name="fake-project-dir") project = Project.from_yaml_file(fake_empty_project_dir / project_yaml_filename) with check: @@ -331,32 +261,18 @@ def test_init_does_not_follow_symlinks_but_copies_them_as_is( ).is_symlink(), "Symlink should be left intact." +@pytest.mark.usefixtures("template_dir_with_testcraft_yaml_j2") def test_init_does_not_fail_on_non_empty_dir( - init_command: InitCommand, - non_empty_project_dir: Path, + init_service: InitService, + non_empty_project_dir: pathlib.Path, project_yaml_filename: str, - template_dir_with_testcraft_yaml_j2: Path, - mocker, emitter, check, ): - parsed_args = get_command_arguments() - - mocker.patch.object( - init_command, - "_get_template_dir", - return_value=template_dir_with_testcraft_yaml_j2, + init_service.run( + project_dir=non_empty_project_dir, name="fake-non-empty-project-dir" ) - mocker.patch.object( - init_command, - "_get_templates_environment", - return_value=get_jinja2_template_environment( - template_dir_with_testcraft_yaml_j2 - ), - ) - init_command.run(parsed_args) - with check: assert emitter.assert_message("Successfully initialised project.") @@ -369,33 +285,18 @@ def test_init_does_not_fail_on_non_empty_dir( assert project.name == non_empty_project_dir.name +@pytest.mark.usefixtures("template_dir_with_testcraft_yaml_j2") def test_init_does_not_override_existing_craft_yaml( - init_command: InitCommand, - non_empty_project_dir: Path, + init_service: InitService, + non_empty_project_dir: pathlib.Path, project_yaml_filename: str, fake_project: Project, - template_dir_with_testcraft_yaml_j2: Path, - mocker, emitter, check, ): - parsed_args = get_command_arguments(project_dir=non_empty_project_dir) fake_project.to_yaml_file(non_empty_project_dir / project_yaml_filename) - mocker.patch.object( - init_command, - "_get_template_dir", - return_value=template_dir_with_testcraft_yaml_j2, - ) - - mocker.patch.object( - init_command, - "_get_templates_environment", - return_value=get_jinja2_template_environment( - template_dir_with_testcraft_yaml_j2 - ), - ) - init_command.run(parsed_args) + init_service.run(project_dir=non_empty_project_dir, name="fake-project-dir") with check: assert emitter.assert_message("Successfully initialised project.") @@ -409,35 +310,17 @@ def test_init_does_not_override_existing_craft_yaml( assert project.name == fake_project.name +@pytest.mark.usefixtures("template_dir_with_testcraft_yaml_j2") def test_init_with_different_name_and_directory( - init_command: InitCommand, - fake_empty_project_dir: Path, - template_dir_with_testcraft_yaml_j2: Path, + init_service: InitService, + fake_empty_project_dir: pathlib.Path, project_yaml_filename: str, - mocker, emitter, check, ): - custom_project_name = "some-custom-project" - parsed_args = get_command_arguments( - name=custom_project_name, - project_dir=fake_empty_project_dir, - ) + name = "some-custom-project" - mocker.patch.object( - init_command, - "_get_template_dir", - return_value=template_dir_with_testcraft_yaml_j2, - ) - - mocker.patch.object( - init_command, - "_get_templates_environment", - return_value=get_jinja2_template_environment( - template_dir_with_testcraft_yaml_j2 - ), - ) - init_command.run(parsed_args) + init_service.run(project_dir=fake_empty_project_dir, name=name) with check: assert emitter.assert_message("Successfully initialised project.") @@ -448,36 +331,20 @@ def test_init_with_different_name_and_directory( assert project_yaml_path.exists(), "Project should be initialized with template" project = Project.from_yaml_file(project_yaml_path) with check: - assert project.name == custom_project_name + assert project.name == name +@pytest.mark.usefixtures("template_dir_with_testcraft_yaml_j2") def test_init_with_default_arguments_uses_current_directory( - init_command: InitCommand, - fake_empty_project_dir: Path, - template_dir_with_testcraft_yaml_j2: Path, + init_service: InitService, + fake_empty_project_dir: pathlib.Path, project_yaml_filename: str, - mocker, emitter, check, ): expected_project_name = fake_empty_project_dir.name - parsed_args = get_command_arguments() - - mocker.patch.object( - init_command, - "_get_template_dir", - return_value=template_dir_with_testcraft_yaml_j2, - ) - - mocker.patch.object( - init_command, - "_get_templates_environment", - return_value=get_jinja2_template_environment( - template_dir_with_testcraft_yaml_j2 - ), - ) - init_command.run(parsed_args) + init_service.run(project_dir=fake_empty_project_dir, name="fake-project-dir") with check: assert emitter.assert_message("Successfully initialised project.") diff --git a/tests/integration/test_application.py b/tests/integration/test_application.py index 536f660c..d758c26c 100644 --- a/tests/integration/test_application.py +++ b/tests/integration/test_application.py @@ -72,11 +72,12 @@ def app(create_app): -V, --version: Show the application version and exit Starter commands: + init: Create an initial project filetree version: Show the application version and exit Commands can be classified as follows: Lifecycle: build, clean, pack, prime, pull, stage - Other: version + Other: init, version For more information about a command, run 'testcraft help '. For a summary of all commands, run 'testcraft help --all'. diff --git a/tests/unit/commands/test_init.py b/tests/unit/commands/test_init.py new file mode 100644 index 00000000..5f0019ee --- /dev/null +++ b/tests/unit/commands/test_init.py @@ -0,0 +1,58 @@ +# This file is part of craft-application. +# +# Copyright 2024 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program. If not, see . + +"""Tests for init command.""" + +import argparse +import pathlib + +import pytest +from craft_application.commands import InitCommand + +# init operates in the current working directory +pytestmark = pytest.mark.usefixtures("new_dir") + + +@pytest.fixture +def init_command(app_metadata, mock_services): + return InitCommand({"app": app_metadata, "services": mock_services}) + + +@pytest.mark.parametrize("name", [None, "my-project"]) +def test_init_in_cwd(init_command, name, new_dir, mock_services): + """Test the init command in the current working directory.""" + expected_name = name or new_dir.name + parsed_args = argparse.Namespace(project_dir=None, name=name) + + init_command.run(parsed_args) + + mock_services.init.run.assert_called_once_with( + project_dir=new_dir, name=expected_name + ) + + +@pytest.mark.parametrize("name", [None, "my-project"]) +def test_init_run_project_dir(init_command, name, mock_services): + """Test the init command in a project directly.""" + expected_name = name or "test-project-dir" + project_dir = pathlib.Path("test-project-dir") + parsed_args = argparse.Namespace(project_dir=project_dir, name=name) + + init_command.run(parsed_args) + + mock_services.init.run.assert_called_once_with( + project_dir=project_dir, name=expected_name + ) diff --git a/tests/unit/commands/test_other.py b/tests/unit/commands/test_other.py index fcfef2b5..95a40b5c 100644 --- a/tests/unit/commands/test_other.py +++ b/tests/unit/commands/test_other.py @@ -13,15 +13,14 @@ # # You should have received a copy of the GNU Lesser General Public License along # with this program. If not, see . -"""Tests for lifecycle commands.""" +"""Tests for other commands.""" import argparse import pytest +from craft_application.commands import InitCommand from craft_application.commands.other import VersionCommand, get_other_command_group -OTHER_COMMANDS = { - VersionCommand, -} +OTHER_COMMANDS = {InitCommand, VersionCommand} @pytest.mark.parametrize("commands", [OTHER_COMMANDS]) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 33749dd3..7b9b5c98 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -51,4 +51,5 @@ def mock_services(app_metadata, fake_project, fake_package_service_class): factory.provider = mock.Mock(spec=services.ProviderService) factory.remote_build = mock.Mock(spec_set=services.RemoteBuildService) factory.fetch = mock.Mock(spec=services.FetchService) + factory.init = mock.Mock(spec=services.InitService) return factory diff --git a/tests/unit/remote/conftest.py b/tests/unit/remote/conftest.py deleted file mode 100644 index f64a3ec5..00000000 --- a/tests/unit/remote/conftest.py +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (C) 2022,2024 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program. If not, see . -import os -import pathlib - -import pytest - - -@pytest.fixture -def new_dir(tmp_path): - """Change to a new temporary directory.""" - - cwd = pathlib.Path.cwd() - os.chdir(tmp_path) - - yield tmp_path - - os.chdir(cwd) diff --git a/tests/unit/services/test_init.py b/tests/unit/services/test_init.py new file mode 100644 index 00000000..5c640e84 --- /dev/null +++ b/tests/unit/services/test_init.py @@ -0,0 +1,205 @@ +# This file is part of craft-application. +# +# Copyright 2024 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +"""Unit tests for the InitService.""" + +import pathlib +from unittest import mock + +import jinja2 +import pytest +import pytest_check +from craft_application import services + + +@pytest.fixture +def init_service(app_metadata, fake_services): + _init_service = services.InitService(app_metadata, fake_services) + _init_service.setup() + return _init_service + + +@pytest.fixture +def mock_template(): + _mock_template = mock.Mock(spec=jinja2.Template) + _mock_template.render.return_value = "rendered content" + return _mock_template + + +@pytest.fixture +def mock_environment(mock_template): + _mock_environment = mock.Mock(spec=jinja2.Environment) + _mock_environment.get_template.return_value = mock_template + return _mock_environment + + +def test_get_context(init_service): + context = init_service._get_context(name="my-project") + + assert context == {"name": "my-project"} + + +def test_get_template_dir(init_service): + template_dir = init_service._get_template_dir() + + assert template_dir == pathlib.Path("templates") + + +def test_get_executable_files(init_service): + executable_files = init_service._get_executable_files() + + assert executable_files == [] + + +def test_create_project_dir(init_service, tmp_path, emitter): + project_dir = tmp_path / "my-project" + + init_service._create_project_dir(project_dir=project_dir, name="my-project") + + assert project_dir.is_dir() + emitter.assert_debug(f"Using project directory {str(project_dir)!r} for my-project") + + +def test_create_project_dir_exists(init_service, tmp_path, emitter): + """Do not error if the project directory already exists.""" + project_dir = tmp_path / "my-project" + project_dir.mkdir() + + init_service._create_project_dir(project_dir=project_dir, name="my-project") + + assert project_dir.is_dir() + emitter.assert_debug(f"Using project directory {str(project_dir)!r} for my-project") + + +def test_get_templates_environment(init_service, mocker): + """Test that _get_templates_environment returns a Jinja2 environment.""" + mock_package_loader = mocker.patch("jinja2.PackageLoader") + mock_environment = mocker.patch("jinja2.Environment") + + environment = init_service._get_templates_environment(pathlib.Path("test-dir")) + + mock_package_loader.assert_called_once_with("testcraft", "test-dir") + mock_environment.assert_called_once_with( + loader=mock_package_loader.return_value, + autoescape=False, + keep_trailing_newline=True, + optimized=False, + undefined=jinja2.StrictUndefined, + ) + assert environment == mock_environment.return_value + + +@pytest.mark.parametrize("template_filename", ["file1.txt", "nested/file2.txt"]) +def test_copy_template_file(init_service, tmp_path, template_filename): + # create template + template_dir = tmp_path / "templates" + template_file = template_dir / template_filename + template_file.parent.mkdir(parents=True, exist_ok=True) + template_file.write_text("content") + # create project with an existing file + project_dir = tmp_path / "project" + project_dir.mkdir() + + init_service._copy_template_file(template_filename, template_dir, project_dir) + + assert (project_dir / template_filename).read_text() == "content" + + +@pytest.mark.parametrize("template_name", ["file1.txt", "nested/file2.txt"]) +def test_copy_template_file_exists(init_service, tmp_path, template_name, emitter): + """Do not overwrite existing files.""" + # create template + template_dir = tmp_path / "templates" + template_file = template_dir / template_name + template_file.parent.mkdir(parents=True, exist_ok=True) + template_file.write_text("content") + # create project with an existing file + project_dir = tmp_path / "project" + (project_dir / template_name).parent.mkdir(parents=True, exist_ok=True) + (project_dir / template_name).write_text("existing content") + + init_service._copy_template_file(template_name, template_dir, project_dir) + + assert (project_dir / template_name).read_text() == "existing content" + emitter.assert_debug( + f"Skipping file {template_name} because it is already present." + ) + + +def test_render_project(init_service, tmp_path, mock_environment): + template_filenames = [ + # Jinja2 templates + "jinja-file.txt.j2", + "nested/jinja-file.txt.j2", + # Non-Jinja2 templates (regular files) + "non-jinja-file.txt", + "nested/non-jinja-file.txt", + ] + mock_environment.list_templates.return_value = template_filenames + project_dir = tmp_path / "project" + project_dir.mkdir() + template_dir = tmp_path / "templates" + for filename in template_filenames: + (template_dir / filename).parent.mkdir(parents=True, exist_ok=True) + (template_dir / filename).write_text("template content") + + init_service._render_project( + environment=mock_environment, + project_dir=project_dir, + template_dir=template_dir, + context={"name": "my-project"}, + executable_files=[], + ) + + pytest_check.equal((project_dir / "jinja-file.txt").read_text(), "rendered content") + pytest_check.equal( + (project_dir / "nested/jinja-file.txt").read_text(), "rendered content" + ) + pytest_check.equal( + (project_dir / "non-jinja-file.txt").read_text(), "template content" + ) + pytest_check.equal( + (project_dir / "nested/non-jinja-file.txt").read_text(), "template content" + ) + + +def test_render_project_executable(init_service, tmp_path, mock_environment): + template_filenames = [ + "executable-1.sh.j2", + "executable-2.sh.j2", + "executable-3.sh", + ] + mock_environment.list_templates.return_value = template_filenames + project_dir = tmp_path / "project" + project_dir.mkdir() + template_dir = tmp_path / "templates" + for filename in template_filenames: + (template_dir / filename).parent.mkdir(parents=True, exist_ok=True) + (template_dir / filename).write_text("template content") + + init_service._render_project( + environment=mock_environment, + project_dir=project_dir, + template_dir=template_dir, + context={"name": "my-project"}, + executable_files=["executable-1.sh", "executable-3.sh"], + ) + + import os + + pytest_check.is_true(os.access(project_dir / "executable-1.sh", os.X_OK)) + pytest_check.is_false(os.access(project_dir / "executable-2.sh", os.X_OK)) + pytest_check.is_false(os.access(project_dir / "executable-3.sh", os.X_OK))