diff --git a/craft_application/commands/__init__.py b/craft_application/commands/__init__.py
index 6f9d065a..d636c58a 100644
--- a/craft_application/commands/__init__.py
+++ b/craft_application/commands/__init__.py
@@ -15,17 +15,16 @@
# along with this program. If not, see .
"""Command classes for a craft application."""
-from craft_application.commands.base import AppCommand, ExtensibleCommand
-from craft_application.commands import lifecycle
-from craft_application.commands.lifecycle import (
- get_lifecycle_command_group,
- LifecycleCommand,
-)
-from craft_application.commands.other import get_other_command_group
+from .base import AppCommand, ExtensibleCommand
+from . import lifecycle
+from .init import InitCommand
+from .lifecycle import get_lifecycle_command_group, LifecycleCommand
+from .other import get_other_command_group
__all__ = [
"AppCommand",
"ExtensibleCommand",
+ "InitCommand",
"lifecycle",
"LifecycleCommand",
"get_lifecycle_command_group",
diff --git a/craft_application/commands/init.py b/craft_application/commands/init.py
new file mode 100644
index 00000000..ac954a0d
--- /dev/null
+++ b/craft_application/commands/init.py
@@ -0,0 +1,144 @@
+# 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 .
+"""Command to initialise a project."""
+
+from __future__ import annotations
+
+import argparse
+import importlib.resources
+import pathlib
+from textwrap import dedent
+from typing import cast
+
+import craft_cli
+
+from craft_application.util import humanize_list
+
+from . import base
+
+
+class InitCommand(base.AppCommand):
+ """Command to create initial project files.
+
+ The init command should always produce a working and ready-to-build project.
+ """
+
+ name = "init"
+ help_msg = "Create an initial project filetree"
+ overview = dedent(
+ """
+ Initialise a project.
+
+ If '' is provided, initialise in that directory,
+ otherwise initialise in the current working directory.
+
+ If '--name ' is provided, the project will be named ''.
+ Otherwise, the project will be named after the directory it is initialised in.
+
+ '--profile ' is used to initialise the project for a specific use case.
+
+ Init can work in an existing project directory. If there are any files in the
+ directory that would be overwritten, then init command will fail.
+ """
+ )
+ common = True
+
+ default_profile = "simple"
+ """The default profile to use when initialising a project."""
+
+ def fill_parser(self, parser: argparse.ArgumentParser) -> None:
+ """Specify command's specific parameters."""
+ parser.add_argument(
+ "project_dir",
+ type=pathlib.Path,
+ nargs="?",
+ default=None,
+ help="Path to initialise project in; defaults to current working directory.",
+ )
+ parser.add_argument(
+ "--name",
+ type=str,
+ default=None,
+ help="The name of project; defaults to the name of ",
+ )
+ # TODO: this fails to render in `--help` (#530)
+ parser.add_argument(
+ "--profile",
+ type=str,
+ choices=self.profiles,
+ default=self.default_profile,
+ help=(
+ f"Use the specified project profile (default is {self.default_profile}, "
+ f"choices are {humanize_list(self.profiles, 'and')})"
+ ),
+ )
+
+ @property
+ def parent_template_dir(self) -> pathlib.Path:
+ """Return the path to the directory that contains all templates."""
+ with importlib.resources.path(
+ self._app.name, "templates"
+ ) as _parent_template_dir:
+ return _parent_template_dir
+
+ @property
+ def profiles(self) -> list[str]:
+ """A list of profile names generated from template directories."""
+ template_dirs = [
+ path for path in self.parent_template_dir.iterdir() if path.is_dir()
+ ]
+ return sorted([template.name for template in template_dirs])
+
+ def run(self, parsed_args: argparse.Namespace) -> None:
+ """Run the command."""
+ project_dir = self._get_project_dir(parsed_args)
+ project_name = self._get_name(parsed_args)
+ template_dir = pathlib.Path(self.parent_template_dir / parsed_args.profile)
+
+ craft_cli.emit.progress("Checking project directory.")
+ self._services.init.check_for_existing_files(
+ project_dir=project_dir, template_dir=template_dir
+ )
+
+ craft_cli.emit.progress("Initialising project.")
+ self._services.init.initialise_project(
+ project_dir=project_dir,
+ project_name=project_name,
+ template_dir=template_dir,
+ )
+ craft_cli.emit.message("Successfully initialised project.")
+
+ def _get_name(self, parsed_args: argparse.Namespace) -> str:
+ """Get name of the package that is about to be initialised.
+
+ 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
+
+ @staticmethod
+ def _get_project_dir(parsed_args: argparse.Namespace) -> pathlib.Path:
+ """Get project dir where project should be initialised.
+
+ It applies rules in the following order:
+ - if is specified explicitly, it returns
+ - if is undefined, it defaults to cwd
+ """
+ # if set explicitly, just return it
+ if parsed_args.project_dir is not None:
+ return pathlib.Path(parsed_args.project_dir).expanduser().resolve()
+
+ # If both args are undefined, default to current dir
+ return pathlib.Path.cwd().resolve()
diff --git a/craft_application/commands/other.py b/craft_application/commands/other.py
index eb30d80f..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,
]
@@ -37,7 +38,7 @@ def get_other_command_group() -> CommandGroup:
class VersionCommand(base.AppCommand):
- """Show the snapcraft version."""
+ """Show the application version."""
name = "version"
help_msg = "Show the application version and exit"
diff --git a/craft_application/errors.py b/craft_application/errors.py
index 0c4a74af..a0635730 100644
--- a/craft_application/errors.py
+++ b/craft_application/errors.py
@@ -246,3 +246,7 @@ def __init__( # (too many arguments)
class FetchServiceError(CraftError):
"""Errors related to the fetch-service."""
+
+
+class InitError(CraftError):
+ """Errors related to initialising a project."""
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..33ee2c88
--- /dev/null
+++ b/craft_application/services/init.py
@@ -0,0 +1,208 @@
+# 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.errors import InitError
+
+from . import base
+
+
+class InitService(base.AppService):
+ """Service class for initializing a project."""
+
+ def initialise_project(
+ self,
+ *,
+ project_dir: pathlib.Path,
+ project_name: str,
+ template_dir: pathlib.Path,
+ ) -> None:
+ """Initialise a new project from a template.
+
+ If a file already exists in the project directory, it is not overwritten.
+ Use `check_for_existing_files()` to see if this will occur before initialising
+ the project.
+
+ :param project_dir: The directory to initialise the project in.
+ :param project_name: The name of the project.
+ :param template_dir: The directory containing the templates.
+ """
+ emit.debug(
+ f"Initialising project {project_name!r} in {str(project_dir)!r} from "
+ f"template in {str(template_dir)!r}."
+ )
+ context = self._get_context(name=project_name)
+ environment = self._get_templates_environment(template_dir)
+ self._create_project_dir(project_dir=project_dir)
+ self._render_project(environment, project_dir, template_dir, context)
+
+ def check_for_existing_files(
+ self,
+ *,
+ project_dir: pathlib.Path,
+ template_dir: pathlib.Path,
+ ) -> None:
+ """Check if there are any existing files in the project directory that would be overwritten.
+
+ :param project_dir: The directory to initialise the project in.
+ :param template_dir: The directory containing the templates.
+
+ :raises InitError: If there are files in the project directory that would be overwritten.
+ """
+ template_files = self._get_template_files(template_dir)
+ existing_files = [
+ template_file
+ for template_file in template_files
+ if (project_dir / template_file).exists()
+ ]
+
+ if existing_files:
+ existing_files_formatted = "\n - ".join(existing_files)
+ raise InitError(
+ message=(
+ f"Cannot initialise project in {str(project_dir)!r} because it "
+ "would overwrite existing files.\nExisting files are:\n - "
+ f"{existing_files_formatted}"
+ ),
+ resolution=(
+ "Initialise the project in an empty directory or remove the existing files."
+ ),
+ retcode=os.EX_CANTCREAT,
+ )
+
+ 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],
+ ) -> 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.
+ """
+ emit.progress("Rendering project.")
+ 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:
+ file.write(template.render(context))
+ shutil.copystat((template_dir / template_name), path)
+ emit.progress("Rendered project.")
+
+ 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}
+
+ @staticmethod
+ def _create_project_dir(project_dir: pathlib.Path) -> None:
+ """Create the project directory if it does not already exist."""
+ emit.debug(f"Creating project directory {str(project_dir)!r}.")
+ project_dir.mkdir(parents=True, exist_ok=True)
+
+ def _get_loader(self, template_dir: pathlib.Path) -> jinja2.BaseLoader:
+ """Return a Jinja loader for the given template directory.
+
+ :param template_dir: The directory containing the templates.
+
+ :returns: A Jinja loader.
+ """
+ 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.
+
+ :param template_dir: The directory containing the templates.
+
+ :returns: A Jinja environment.
+ """
+ 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
+
+ def _get_template_files(self, template_dir: pathlib.Path) -> list[str]:
+ """Return a list of files that would be created from a template directory.
+
+ Note that the '.j2' suffix is removed from templates.
+
+ :param template_dir: The directory containing the templates.
+
+ :returns: A list of filenames that would be created.
+ """
+ templates = self._get_templates_environment(template_dir).list_templates()
+
+ return [
+ template[:-3] if template.endswith(".j2") else template
+ for template in templates
+ ]
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/docs/reference/changelog.rst b/docs/reference/changelog.rst
index ba143815..aba83d90 100644
--- a/docs/reference/changelog.rst
+++ b/docs/reference/changelog.rst
@@ -13,6 +13,19 @@ Application
- ``AppCommand`` subclasses now will always receive a valid ``app_config``
dict.
+Commands
+========
+
+- Adds an ``init`` command for initialising new projects.
+
+Services
+========
+
+- Adds an ``InitService`` for initialising new projects.
+
+..
+ For a complete list of commits, check out the `X.Y.Z`_ release on GitHub.
+
4.3.0 (2024-Oct-11)
-------------------
diff --git a/pyproject.toml b/pyproject.toml
index c185a502..62515806 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..ac105b72 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -24,11 +24,13 @@
import craft_application
import craft_parts
+import jinja2
import pydantic
import pytest
from craft_application import application, launchpad, models, services, util
from craft_cli import EmitterMode, emit
from craft_providers import bases
+from jinja2 import FileSystemLoader
from typing_extensions import override
if TYPE_CHECKING: # pragma: no cover
@@ -295,15 +297,29 @@ def __init__(
return FakeLifecycleService
+@pytest.fixture
+def fake_init_service_class(tmp_path):
+ class FakeInitService(services.InitService):
+ def _get_loader(self, template_dir: pathlib.Path) -> jinja2.BaseLoader:
+ return FileSystemLoader(tmp_path / "templates" / template_dir)
+
+ return FakeInitService
+
+
@pytest.fixture
def fake_services(
- app_metadata, fake_project, fake_lifecycle_service_class, fake_package_service_class
+ app_metadata,
+ fake_project,
+ fake_lifecycle_service_class,
+ fake_package_service_class,
+ fake_init_service_class,
):
return services.ServiceFactory(
app_metadata,
project=fake_project,
PackageClass=fake_package_service_class,
LifecycleClass=fake_lifecycle_service_class,
+ InitClass=fake_init_service_class,
)
@@ -339,3 +355,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
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/integration/commands/test_init.py b/tests/integration/commands/test_init.py
new file mode 100644
index 00000000..13cab7ba
--- /dev/null
+++ b/tests/integration/commands/test_init.py
@@ -0,0 +1,158 @@
+# 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 os
+import pathlib
+import textwrap
+
+import pytest
+from craft_application.commands import InitCommand
+
+# init operates in the current working directory
+pytestmark = pytest.mark.usefixtures("new_dir")
+
+
+@pytest.fixture(autouse=True)
+def mock_parent_template_dir(tmp_path, mocker):
+ """Mock the parent template directory."""
+ mocker.patch.object(
+ InitCommand,
+ "parent_template_dir",
+ pathlib.Path(tmp_path) / "templates",
+ )
+
+
+@pytest.fixture
+def fake_template_dirs(tmp_path):
+ """Set up a fake template directories with two templates.
+
+ These templates are very simple because the InitService tests focus on the
+ templates themselves.
+ """
+ parent_template_dir = tmp_path / "templates"
+
+ simple_template_file = parent_template_dir / "simple" / "simple-file.j2"
+ simple_template_file.parent.mkdir(parents=True)
+ simple_template_file.write_text("name={{ name }}")
+
+ other_template_file = parent_template_dir / "other-template" / "other-file.j2"
+ other_template_file.parent.mkdir(parents=True)
+ other_template_file.write_text("name={{ name }}")
+
+
+@pytest.mark.parametrize(
+ ("profile", "expected_file"),
+ [
+ (None, pathlib.Path("simple-file")),
+ ("simple", pathlib.Path("simple-file")),
+ ("other-template", pathlib.Path("other-file")),
+ ],
+)
+@pytest.mark.parametrize("project_dir", [None, "project-dir"])
+@pytest.mark.usefixtures("fake_template_dirs")
+def test_init(app, capsys, monkeypatch, profile, expected_file, project_dir):
+ """Initialise a project."""
+ expected_output = "Successfully initialised project"
+ command = ["testcraft", "init"]
+ if profile:
+ command.extend(["--profile", profile])
+ if project_dir:
+ command.append(project_dir)
+ expected_file = pathlib.Path(project_dir) / expected_file
+ monkeypatch.setattr("sys.argv", command)
+
+ return_code = app.run()
+ stdout, _ = capsys.readouterr()
+
+ assert return_code == os.EX_OK
+ assert expected_output in stdout
+ assert expected_file.is_file()
+ # name is not provided, so use the project directory name
+ assert f"name={expected_file.resolve().parent.name}" == expected_file.read_text()
+
+
+@pytest.mark.usefixtures("fake_template_dirs")
+@pytest.mark.parametrize(
+ ("project_dir", "expected_file"),
+ [
+ (None, pathlib.Path("simple-file")),
+ ("project-dir", pathlib.Path("project-dir") / "simple-file"),
+ ],
+)
+def test_init_name(app, capsys, monkeypatch, project_dir, expected_file):
+ """Initialise a project with a name."""
+ expected_output = "Successfully initialised project"
+ command = ["testcraft", "init", "--name", "test-project-name"]
+ if project_dir:
+ command.append(project_dir)
+ monkeypatch.setattr("sys.argv", command)
+
+ return_code = app.run()
+ stdout, _ = capsys.readouterr()
+
+ assert return_code == os.EX_OK
+ assert expected_output in stdout
+ assert expected_file.is_file()
+ assert expected_file.read_text() == "name=test-project-name"
+
+
+@pytest.mark.usefixtures("fake_template_dirs")
+def test_init_invalid_profile(app, capsys, monkeypatch):
+ """Give a helpful error message for invalid profiles."""
+ expected_error = "Error: argument --profile: invalid choice: 'bad' (choose from 'other-template', 'simple')"
+ monkeypatch.setattr("sys.argv", ["testcraft", "init", "--profile", "bad"])
+
+ return_code = app.run()
+ _, stderr = capsys.readouterr()
+
+ assert return_code == os.EX_USAGE
+ assert expected_error in stderr
+
+
+@pytest.mark.usefixtures("fake_template_dirs")
+def test_init_overlapping_file(app, capsys, monkeypatch, tmp_path):
+ """Give a helpful error message if a file would be overwritten."""
+ pathlib.Path("simple-file").touch()
+ expected_error = textwrap.dedent(
+ f"""
+ Cannot initialise project in {str(tmp_path)!r} because it would overwrite existing files.
+ Existing files are:
+ - simple-file
+ Recommended resolution: Initialise the project in an empty directory or remove the existing files."""
+ )
+ monkeypatch.setattr("sys.argv", ["testcraft", "init", "--profile", "simple"])
+
+ return_code = app.run()
+ _, stderr = capsys.readouterr()
+
+ assert return_code == os.EX_CANTCREAT
+ assert expected_error in stderr
+
+
+@pytest.mark.usefixtures("fake_template_dirs")
+def test_init_nonoverlapping_file(app, capsys, monkeypatch):
+ """Files can exist in the project directory if they won't be overwritten."""
+ expected_output = "Successfully initialised project"
+ pathlib.Path("unrelated-file").touch()
+ monkeypatch.setattr("sys.argv", ["testcraft", "init", "--profile", "simple"])
+
+ return_code = app.run()
+ stdout, _ = capsys.readouterr()
+
+ assert return_code == os.EX_OK
+ assert expected_output in stdout
+ assert pathlib.Path("simple-file").is_file()
diff --git a/tests/integration/services/test_init.py b/tests/integration/services/test_init.py
new file mode 100644
index 00000000..c911cd44
--- /dev/null
+++ b/tests/integration/services/test_init.py
@@ -0,0 +1,476 @@
+# 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 service."""
+
+import pathlib
+import subprocess
+import sys
+import textwrap
+
+import pytest
+from craft_application import errors
+from craft_application.models.project import Project
+from craft_application.services import InitService
+
+# init operates in the current working directory
+pytestmark = pytest.mark.usefixtures("new_dir")
+
+
+@pytest.fixture
+def init_service(fake_init_service_class, app_metadata, fake_services):
+ _init_service = fake_init_service_class(app_metadata, fake_services)
+ _init_service.setup()
+
+ return _init_service
+
+
+@pytest.fixture
+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
+
+
+@pytest.fixture
+def project_yaml_filename() -> str:
+ return "testcraft.yaml"
+
+
+@pytest.fixture
+def template_dir_with_testcraft_yaml_j2(
+ fake_empty_template_dir: pathlib.Path,
+ project_yaml_filename: str,
+) -> pathlib.Path:
+ """Creates the same testcraft.yaml file in the top-level and nested directories.
+
+ Normally a project would only have one testcraft.yaml file, but two are created for testing.
+ """
+ template_text = textwrap.dedent(
+ """
+ # This file configures testcraft.
+
+ # (Required)
+ name: {{ name }}
+
+ # (Required)
+ # The source package version
+ version: git
+
+ # (Required)
+ # Version of the build base OS
+ base: ubuntu@24.04
+
+ # (Recommended)
+ title: Testcraft Template Package
+
+ # (Required)
+ summary: A very short one-line summary of the package.
+
+ # (Required)
+ description: |
+ A single sentence that says what the source is, concisely and memorably.
+
+ A paragraph of one to three short sentences, that describe what the package does.
+
+ A third paragraph that explains what need the package meets.
+
+ Finally, a paragraph that describes whom the package is useful for.
+
+
+ parts:
+ {{ name }}:
+ plugin: nil
+ source: .
+ platforms:
+ amd64:
+ """
+ )
+ top_level_template = fake_empty_template_dir / f"{project_yaml_filename}.j2"
+ top_level_template.write_text(template_text)
+ nested_template = fake_empty_template_dir / "nested" / f"{project_yaml_filename}.j2"
+ nested_template.parent.mkdir()
+ nested_template.write_text(template_text)
+
+ return fake_empty_template_dir
+
+
+@pytest.fixture
+def template_dir_with_multiple_non_ninja_files(
+ 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"
+ file_2.parent.mkdir()
+ file_2.write_text("Content of the nested file")
+ return fake_empty_template_dir
+
+
+@pytest.fixture
+def template_dir_with_symlinks(
+ 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 template_dir_with_executables(
+ fake_empty_template_dir: pathlib.Path,
+) -> pathlib.Path:
+ """Create executable templated and non-templated files."""
+ for filename in [
+ "file.sh",
+ "nested/file.sh",
+ "template.sh.j2",
+ "nested/template.sh.j2",
+ ]:
+ filepath = fake_empty_template_dir / filename
+ filepath.parent.mkdir(exist_ok=True)
+ with filepath.open("wt", encoding="utf8") as file:
+ file.write("#!/bin/bash\necho 'Hello, world!'")
+ filepath.chmod(0o755)
+
+ return fake_empty_template_dir
+
+
+@pytest.fixture
+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()
+ return empty_project_dir_path
+
+
+@pytest.fixture
+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()
+ return non_empty_project_dir_path
+
+
+@pytest.mark.usefixtures("fake_empty_template_dir")
+def test_init_works_with_empty_templates_dir(
+ init_service: InitService,
+ fake_empty_project_dir: pathlib.Path,
+ fake_empty_template_dir: pathlib.Path,
+ emitter,
+ check,
+):
+ """Initialise a project with an empty templates directory."""
+ init_service.initialise_project(
+ project_dir=fake_empty_project_dir,
+ project_name="fake-project-dir",
+ template_dir=fake_empty_template_dir,
+ )
+
+ with check:
+ assert emitter.assert_progress("Rendered project.")
+ with check:
+ assert not list(
+ fake_empty_project_dir.iterdir()
+ ), "Project dir should be initialised empty"
+
+
+def test_init_works_with_simple_template(
+ init_service: InitService,
+ fake_empty_project_dir: pathlib.Path,
+ template_dir_with_testcraft_yaml_j2: pathlib.Path,
+ project_yaml_filename: str,
+ emitter,
+ check,
+):
+ """Initialise a project with a simple project template."""
+ init_service.initialise_project(
+ project_dir=fake_empty_project_dir,
+ project_name="fake-project-dir",
+ template_dir=template_dir_with_testcraft_yaml_j2,
+ )
+
+ with check:
+ assert emitter.assert_progress("Rendered project.")
+
+ project_yaml_paths = [
+ fake_empty_project_dir / project_yaml_filename,
+ fake_empty_project_dir / "nested" / project_yaml_filename,
+ ]
+
+ for project_yaml_path in project_yaml_paths:
+ with check:
+ assert (
+ project_yaml_path.exists()
+ ), "Project should be initialised with template"
+ project = Project.from_yaml_file(project_yaml_path)
+ assert project.name == fake_empty_project_dir.name
+
+
+def test_init_works_with_single_template_and_custom_name(
+ init_service: InitService,
+ fake_empty_project_dir: pathlib.Path,
+ template_dir_with_testcraft_yaml_j2: pathlib.Path,
+ project_yaml_filename: str,
+ emitter,
+ check,
+):
+ """Initialise a project with a single template file and custom name."""
+ name = "some-other-test-project"
+ init_service.initialise_project(
+ project_dir=fake_empty_project_dir,
+ project_name=name,
+ template_dir=template_dir_with_testcraft_yaml_j2,
+ )
+
+ with check:
+ assert emitter.assert_progress("Rendered project.")
+
+ project_yaml_path = pathlib.Path(fake_empty_project_dir, project_yaml_filename)
+
+ with check:
+ assert project_yaml_path.exists(), "Project should be initialised with template"
+ project = Project.from_yaml_file(project_yaml_path)
+ with check:
+ assert project.name == name
+
+
+def check_file_existence_and_content(
+ 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"
+
+ with check:
+ assert file_path.read_text() == content, f"{file_path.name} incorrect content"
+
+
+def test_init_works_with_non_jinja2_templates(
+ init_service: InitService,
+ fake_empty_project_dir: pathlib.Path,
+ template_dir_with_multiple_non_ninja_files: pathlib.Path,
+ emitter,
+ check,
+):
+ init_service.initialise_project(
+ project_dir=fake_empty_project_dir,
+ project_name="fake-project-dir",
+ template_dir=template_dir_with_multiple_non_ninja_files,
+ )
+
+ with check:
+ assert emitter.assert_progress("Rendered project.")
+
+ check_file_existence_and_content(
+ check, fake_empty_project_dir / "file1.txt", "Content of file1.txt"
+ )
+ check_file_existence_and_content(
+ check,
+ fake_empty_project_dir / "nested" / "file2.txt",
+ "Content of the nested file",
+ )
+
+
+def test_init_does_not_follow_symlinks_but_copies_them_as_is(
+ init_service: InitService,
+ fake_empty_project_dir: pathlib.Path,
+ template_dir_with_symlinks: pathlib.Path,
+ project_yaml_filename: str,
+ check,
+):
+ init_service.initialise_project(
+ project_dir=fake_empty_project_dir,
+ project_name="fake-project-dir",
+ template_dir=template_dir_with_symlinks,
+ )
+
+ project = Project.from_yaml_file(fake_empty_project_dir / project_yaml_filename)
+ with check:
+ assert project.name == fake_empty_project_dir.name
+ with check:
+ assert (
+ fake_empty_project_dir / "py3_symlink"
+ ).is_symlink(), "Symlink should be left intact."
+
+
+def test_init_copies_executables(
+ init_service: InitService,
+ fake_empty_project_dir: pathlib.Path,
+ template_dir_with_executables: pathlib.Path,
+ check,
+):
+ """Executability of template files should be preserved."""
+ init_service.initialise_project(
+ project_dir=fake_empty_project_dir,
+ project_name="fake-project-dir",
+ template_dir=template_dir_with_executables,
+ )
+
+ for filename in ["file.sh", "nested/file.sh", "template.sh", "nested/template.sh"]:
+ with check:
+ assert (
+ subprocess.check_output(
+ [str(fake_empty_project_dir / filename)], text=True
+ )
+ == "Hello, world!\n"
+ )
+
+
+def test_init_does_not_fail_on_non_empty_dir(
+ init_service: InitService,
+ non_empty_project_dir: pathlib.Path,
+ template_dir_with_testcraft_yaml_j2: pathlib.Path,
+ project_yaml_filename: str,
+ emitter,
+ check,
+):
+ init_service.initialise_project(
+ project_dir=non_empty_project_dir,
+ project_name="fake-non-empty-project-dir",
+ template_dir=template_dir_with_testcraft_yaml_j2,
+ )
+
+ with check:
+ assert emitter.assert_progress("Rendered project.")
+
+ project_yaml_path = non_empty_project_dir / project_yaml_filename
+
+ with check:
+ assert project_yaml_path.exists(), "Project should be initialised with template"
+ project = Project.from_yaml_file(project_yaml_path)
+ with check:
+ assert project.name == non_empty_project_dir.name
+
+
+def test_init_does_not_override_existing_craft_yaml(
+ init_service: InitService,
+ non_empty_project_dir: pathlib.Path,
+ template_dir_with_testcraft_yaml_j2,
+ project_yaml_filename: str,
+ fake_project: Project,
+ emitter,
+ check,
+):
+ fake_project.to_yaml_file(non_empty_project_dir / project_yaml_filename)
+
+ init_service.initialise_project(
+ project_dir=non_empty_project_dir,
+ project_name="fake-project-dir",
+ template_dir=template_dir_with_testcraft_yaml_j2,
+ )
+
+ with check:
+ assert emitter.assert_progress("Rendered project.")
+
+ project_yaml_path = non_empty_project_dir / project_yaml_filename
+
+ with check:
+ assert project_yaml_path.exists(), "Project should be initialised with template"
+ project = Project.from_yaml_file(project_yaml_path)
+ with check:
+ assert project.name == fake_project.name
+
+
+def test_init_with_different_name_and_directory(
+ init_service: InitService,
+ fake_empty_project_dir: pathlib.Path,
+ template_dir_with_testcraft_yaml_j2: pathlib.Path,
+ project_yaml_filename: str,
+ emitter,
+ check,
+):
+ name = "some-custom-project"
+
+ init_service.initialise_project(
+ project_dir=fake_empty_project_dir,
+ project_name=name,
+ template_dir=template_dir_with_testcraft_yaml_j2,
+ )
+
+ with check:
+ assert emitter.assert_progress("Rendered project.")
+
+ project_yaml_path = fake_empty_project_dir / project_yaml_filename
+
+ with check:
+ assert project_yaml_path.exists(), "Project should be initialised with template"
+ project = Project.from_yaml_file(project_yaml_path)
+ with check:
+ assert project.name == name
+
+
+def test_init_with_default_arguments_uses_current_directory(
+ init_service: InitService,
+ fake_empty_project_dir: pathlib.Path,
+ template_dir_with_testcraft_yaml_j2: pathlib.Path,
+ project_yaml_filename: str,
+ emitter,
+ check,
+):
+ expected_project_name = fake_empty_project_dir.name
+
+ init_service.initialise_project(
+ project_dir=fake_empty_project_dir,
+ project_name="fake-project-dir",
+ template_dir=template_dir_with_testcraft_yaml_j2,
+ )
+
+ with check:
+ assert emitter.assert_progress("Rendered project.")
+
+ project_yaml_path = fake_empty_project_dir / project_yaml_filename
+
+ with check:
+ assert project_yaml_path.exists(), "Project should be initialised with template"
+ project = Project.from_yaml_file(project_yaml_path)
+ with check:
+ assert project.name == expected_project_name
+
+
+def test_check_for_existing_files(
+ init_service: InitService,
+ fake_empty_project_dir: pathlib.Path,
+ template_dir_with_testcraft_yaml_j2: pathlib.Path,
+):
+ """No-op if there are no overlapping files."""
+ init_service.check_for_existing_files(
+ project_dir=fake_empty_project_dir,
+ template_dir=template_dir_with_testcraft_yaml_j2,
+ )
+
+
+def test_check_for_existing_files_error(
+ init_service: InitService,
+ fake_empty_project_dir: pathlib.Path,
+ template_dir_with_testcraft_yaml_j2: pathlib.Path,
+):
+ """No-op if there are no overlapping files."""
+ expected_error = textwrap.dedent(
+ f"""\
+ Cannot initialise project in {str(fake_empty_project_dir)!r} because it would overwrite existing files.
+ Existing files are:
+ - nested/testcraft.yaml
+ - testcraft.yaml"""
+ )
+ (fake_empty_project_dir / "testcraft.yaml").touch()
+ (fake_empty_project_dir / "nested").mkdir()
+ (fake_empty_project_dir / "nested" / "testcraft.yaml").touch()
+
+ with pytest.raises(errors.InitError, match=expected_error):
+ init_service.check_for_existing_files(
+ project_dir=fake_empty_project_dir,
+ template_dir=template_dir_with_testcraft_yaml_j2,
+ )
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..3f92f596
--- /dev/null
+++ b/tests/unit/commands/test_init.py
@@ -0,0 +1,115 @@
+# 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
+from craft_application.errors import InitError
+
+# init operates in the current working directory
+pytestmark = pytest.mark.usefixtures("new_dir")
+
+
+@pytest.fixture
+def init_command(app_metadata, mock_services, mocker, tmp_path):
+ mocker.patch.object(
+ InitCommand,
+ "parent_template_dir",
+ pathlib.Path(tmp_path) / "templates",
+ )
+ return InitCommand({"app": app_metadata, "services": mock_services})
+
+
+@pytest.fixture
+def fake_template_dirs(tmp_path):
+ """Set up a fake template directories with two templates.
+
+ These templates are very simple because tests focused on the templates themselves
+ are in the InitService tests.
+ """
+ parent_template_dir = tmp_path / "templates"
+
+ (parent_template_dir / "simple").mkdir(parents=True)
+ (parent_template_dir / "other-template").mkdir()
+
+ return parent_template_dir
+
+
+@pytest.mark.parametrize("name", [None, "my-project"])
+def test_init_in_cwd(init_command, name, new_dir, mock_services, emitter):
+ """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,
+ profile="test-profile",
+ )
+
+ init_command.run(parsed_args)
+
+ mock_services.init.initialise_project.assert_called_once_with(
+ project_dir=new_dir,
+ project_name=expected_name,
+ template_dir=init_command.parent_template_dir / "test-profile",
+ )
+ emitter.assert_message("Successfully initialised project.")
+
+
+@pytest.mark.parametrize("name", [None, "my-project"])
+def test_init_run_project_dir(init_command, name, mock_services, emitter):
+ """Test the init command in a project directory."""
+ expected_name = name or "test-project-dir"
+ project_dir = pathlib.Path("test-project-dir")
+ parsed_args = argparse.Namespace(
+ project_dir=project_dir,
+ name=name,
+ profile="test-profile",
+ )
+
+ init_command.run(parsed_args)
+
+ mock_services.init.initialise_project.assert_called_once_with(
+ project_dir=project_dir.expanduser().resolve(),
+ project_name=expected_name,
+ template_dir=init_command.parent_template_dir / "test-profile",
+ )
+ emitter.assert_message("Successfully initialised project.")
+
+
+@pytest.mark.usefixtures("fake_template_dirs")
+def test_profiles(init_command):
+ """Test profile generation."""
+ assert init_command.default_profile == "simple"
+ assert init_command.profiles == ["other-template", "simple"]
+
+
+def test_existing_files(init_command, tmp_path, mock_services):
+ """Error if the check for existing files fails."""
+ mock_services.init.check_for_existing_files.side_effect = InitError("test-error")
+ parsed_args = argparse.Namespace(
+ project_dir=tmp_path,
+ name="test-project-name",
+ profile="test-profile",
+ )
+
+ with pytest.raises(InitError, match="test-error"):
+ init_command.run(parsed_args)
+
+ mock_services.init.initialise_project.assert_not_called()
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..bd9cdca1
--- /dev/null
+++ b/tests/unit/services/test_init.py
@@ -0,0 +1,227 @@
+# 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 os
+import pathlib
+import textwrap
+
+import jinja2
+import pytest
+import pytest_check
+from craft_application import errors, 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_loader(mocker, tmp_path):
+ """Mock the loader so it does not try to import `testcraft.templates`."""
+ return mocker.patch(
+ "craft_application.services.init.InitService._get_loader",
+ return_value=jinja2.FileSystemLoader(tmp_path / "templates"),
+ )
+
+
+def test_get_context(init_service):
+ context = init_service._get_context(name="my-project")
+
+ assert context == {"name": "my-project"}
+
+
+@pytest.mark.parametrize("create_dir", [True, False])
+def test_create_project_dir(init_service, tmp_path, emitter, create_dir):
+ project_dir = tmp_path / "my-project"
+ if create_dir:
+ project_dir.mkdir()
+
+ init_service._create_project_dir(project_dir=project_dir)
+
+ assert project_dir.is_dir()
+ emitter.assert_debug(f"Creating project directory {str(project_dir)!r}.")
+
+
+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.usefixtures("mock_loader")
+@pytest.mark.parametrize("project_file", [None, "file.txt"])
+def test_check_for_existing_files(init_service, tmp_path, project_file):
+ """No-op if there are no overlapping files."""
+ # create template
+ template_dir = tmp_path / "templates"
+ template_dir.mkdir()
+ (template_dir / "file.txt").touch()
+ # create project with a different file
+ project_dir = tmp_path / "project"
+ if project_file:
+ project_dir.mkdir()
+ (project_dir / "other-file.txt").touch()
+
+ init_service.check_for_existing_files(
+ project_dir=project_dir, template_dir=template_dir
+ )
+
+
+@pytest.mark.usefixtures("mock_loader")
+def test_check_for_existing_files_error(init_service, tmp_path):
+ """Error if there are overlapping files."""
+ expected_error = textwrap.dedent(
+ f"""\
+ Cannot initialise project in {str(tmp_path / 'project')!r} because it would overwrite existing files.
+ Existing files are:
+ - file.txt"""
+ )
+ # create template
+ template_dir = tmp_path / "templates"
+ template_dir.mkdir()
+ (template_dir / "file.txt").touch()
+ # create project with a different file
+ project_dir = tmp_path / "project"
+ project_dir.mkdir()
+ (project_dir / "file.txt").touch()
+
+ with pytest.raises(errors.InitError, match=expected_error):
+ init_service.check_for_existing_files(
+ project_dir=project_dir, template_dir=template_dir
+ )
+
+
+@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."
+ )
+
+
+@pytest.mark.parametrize("filename", ["jinja-file.txt.j2", "nested/jinja-file.txt.j2"])
+@pytest.mark.usefixtures("mock_loader")
+def test_render_project_with_templates(filename, init_service, tmp_path):
+ """Render template files."""
+ project_dir = tmp_path / "project"
+ project_dir.mkdir()
+ template_dir = tmp_path / "templates"
+ (template_dir / filename).parent.mkdir(parents=True, exist_ok=True)
+ (template_dir / filename).write_text("{{ name }}")
+
+ environment = init_service._get_templates_environment(template_dir)
+ init_service._render_project(
+ environment=environment,
+ project_dir=project_dir,
+ template_dir=template_dir,
+ context={"name": "my-project"},
+ )
+
+ assert (project_dir / filename[:-3]).read_text() == "my-project"
+
+
+@pytest.mark.parametrize("filename", ["file.txt", "nested/file.txt"])
+@pytest.mark.usefixtures("mock_loader")
+def test_render_project_non_templates(filename, init_service, tmp_path):
+ """Copy non-template files when rendering a project."""
+ project_dir = tmp_path / "project"
+ project_dir.mkdir()
+ template_dir = tmp_path / "templates"
+ (template_dir / filename).parent.mkdir(parents=True, exist_ok=True)
+ (template_dir / filename).write_text("test content")
+
+ environment = init_service._get_templates_environment(template_dir)
+ init_service._render_project(
+ environment=environment,
+ project_dir=project_dir,
+ template_dir=template_dir,
+ context={"name": "my-project"},
+ )
+
+ assert (project_dir / filename).read_text() == "test content"
+
+
+@pytest.mark.usefixtures("mock_loader")
+def test_render_project_executable(init_service, tmp_path):
+ """Test that executable permissions are set on rendered files."""
+ project_dir = tmp_path / "project"
+ project_dir.mkdir()
+ template_dir = tmp_path / "templates"
+ template_dir.mkdir()
+ for filename in ["file-1.sh.j2", "file-2.sh"]:
+ (template_dir / filename).write_text("#!/bin/bash\necho 'Hello, world!'")
+ (template_dir / filename).chmod(0o755)
+ for filename in ["file-3.txt.j2", "file-4.txt"]:
+ (template_dir / filename).write_text("template content")
+
+ environment = init_service._get_templates_environment(template_dir)
+ init_service._render_project(
+ environment=environment,
+ project_dir=project_dir,
+ template_dir=template_dir,
+ context={"name": "my-project"},
+ )
+
+ pytest_check.is_true(os.access(project_dir / "file-1.sh", os.X_OK))
+ pytest_check.is_true(os.access(project_dir / "file-2.sh", os.X_OK))
+ pytest_check.is_false(os.access(project_dir / "file-3.txt", os.X_OK))
+ pytest_check.is_false(os.access(project_dir / "file-4.txt", os.X_OK))