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))