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