From e49653c634345491ffece9fe435798d7d1215fde Mon Sep 17 00:00:00 2001 From: Callahan Kovacs Date: Thu, 7 Nov 2024 07:06:02 -0600 Subject: [PATCH] chore: overide InitService, render name, fix spread tests Signed-off-by: Callahan Kovacs --- snapcraft/cli.py | 1 - snapcraft/commands/__init__.py | 2 - snapcraft/commands/init.py | 59 -------- snapcraft/models/project.py | 6 +- snapcraft/services/__init__.py | 2 + snapcraft/services/init.py | 82 +++++++++++ snapcraft/services/service_factory.py | 3 + .../{snapcraft.yaml => snapcraft.yaml.j2} | 2 +- tests/spread/general/init/task.yaml | 30 +++- tests/unit/commands/test_init.py | 122 ++++++----------- tests/unit/services/test_init.py | 129 ++++++++++++++++++ 11 files changed, 284 insertions(+), 154 deletions(-) delete mode 100644 snapcraft/commands/init.py create mode 100644 snapcraft/services/init.py rename snapcraft/templates/simple/snap/{snapcraft.yaml => snapcraft.yaml.j2} (90%) create mode 100644 tests/unit/services/test_init.py diff --git a/snapcraft/cli.py b/snapcraft/cli.py index 98d890ceeb..361ad8ed03 100644 --- a/snapcraft/cli.py +++ b/snapcraft/cli.py @@ -151,7 +151,6 @@ "Other", [ commands.LintCommand, - commands.InitCommand, ], ), ] diff --git a/snapcraft/commands/__init__.py b/snapcraft/commands/__init__.py index b1a8cdc5eb..f3ee75220f 100644 --- a/snapcraft/commands/__init__.py +++ b/snapcraft/commands/__init__.py @@ -28,7 +28,6 @@ ExtensionsCommand, ListExtensionsCommand, ) -from .init import InitCommand from .legacy import ( StoreLegacyCreateKeyCommand, StoreLegacyGatedCommand, @@ -67,7 +66,6 @@ __all__ = [ "ExpandExtensionsCommand", "ExtensionsCommand", - "InitCommand", "LintCommand", "ListExtensionsCommand", "ListPluginsCommand", diff --git a/snapcraft/commands/init.py b/snapcraft/commands/init.py deleted file mode 100644 index 89dac23186..0000000000 --- a/snapcraft/commands/init.py +++ /dev/null @@ -1,59 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2023-2024 Canonical Ltd. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU 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 General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Snapcraft init command.""" - -import argparse - -import craft_application.commands -import craft_cli -from typing_extensions import override - -from snapcraft import errors -from snapcraft.parts.yaml_utils import get_snap_project - - -class InitCommand(craft_application.commands.InitCommand): - """Snapcraft init command.""" - - @override - def run(self, parsed_args: argparse.Namespace) -> None: - """Pack a directory or run the lifecycle and pack all artifacts.""" - project_dir = self._get_project_dir(parsed_args) - - if parsed_args.name: - craft_cli.emit.progress( - "Ignoring '--name' parameter because it is not supported yet.", - permanent=True, - ) - - try: - craft_cli.emit.progress("Checking for an existing 'snapcraft.yaml'.") - project = get_snap_project(project_dir) - raise errors.SnapcraftError( - "could not initialise a new snapcraft project because " - f"{str(project.project_file)!r} already exists" - ) - # the `ProjectMissing` error means a new project can be initialized - except errors.ProjectMissing: - craft_cli.emit.progress("Could not find an existing 'snapcraft.yaml'.") - - super().run(parsed_args) - - craft_cli.emit.message( - "Go to https://docs.snapcraft.io/the-snapcraft-format/8337 for more " - "information about the snapcraft.yaml format." - ) diff --git a/snapcraft/models/project.py b/snapcraft/models/project.py index 0b2b0bee59..31a363313d 100644 --- a/snapcraft/models/project.py +++ b/snapcraft/models/project.py @@ -224,7 +224,7 @@ def _validate_version_name(version: str, model_name: str) -> None: ) -def _validate_name(*, name: str, field_name: str) -> str: +def validate_name(*, name: str, field_name: str) -> str: """Validate a name. :param name: The name to validate. @@ -261,7 +261,7 @@ def _validate_component(name: str) -> str: raise ValueError( "component names cannot start with the reserved prefix 'snap-'" ) - return _validate_name(name=name, field_name="component") + return validate_name(name=name, field_name="component") def _get_partitions_from_components( @@ -729,7 +729,7 @@ def _validate_mandatory_base(self): @pydantic.field_validator("name") @classmethod def _validate_snap_name(cls, name): - return _validate_name(name=name, field_name="snap") + return validate_name(name=name, field_name="snap") @pydantic.field_validator("components") @classmethod diff --git a/snapcraft/services/__init__.py b/snapcraft/services/__init__.py index 746d7dbafb..055e32006a 100644 --- a/snapcraft/services/__init__.py +++ b/snapcraft/services/__init__.py @@ -17,6 +17,7 @@ """Snapcraft services.""" from snapcraft.services.assertions import Assertion +from snapcraft.services.init import Init from snapcraft.services.lifecycle import Lifecycle from snapcraft.services.package import Package from snapcraft.services.provider import Provider @@ -26,6 +27,7 @@ __all__ = [ "Assertion", + "Init", "Lifecycle", "Package", "Provider", diff --git a/snapcraft/services/init.py b/snapcraft/services/init.py new file mode 100644 index 0000000000..92c079c162 --- /dev/null +++ b/snapcraft/services/init.py @@ -0,0 +1,82 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2024 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Service for initializing a project.""" + +import pathlib + +import craft_cli +from craft_application import services +from typing_extensions import override + +from snapcraft import errors +from snapcraft.models.project import validate_name +from snapcraft.parts.yaml_utils import get_snap_project + + +class Init(services.InitService): + """Service class for initializing a project.""" + + @override + def initialise_project( + self, + *, + project_dir: pathlib.Path, + project_name: str, + template_dir: pathlib.Path, + ) -> None: + try: + validate_name(name=project_name, field_name="snap") + if len(project_name) > 40: + raise ValueError("snap names must be 40 characters or less") + except ValueError as err: + raise errors.SnapcraftError( + message=f"Invalid snap name {project_name!r}: {str(err)}.", + resolution="Provide a valid name with '--name' or rename the project directory.", + ) from err + + super().initialise_project( + project_dir=project_dir, + project_name=project_name, + template_dir=template_dir, + ) + craft_cli.emit.message( + "Go to https://docs.snapcraft.io/the-snapcraft-format/8337 for more " + "information about the snapcraft.yaml format." + ) + + @override + def check_for_existing_files( + self, + *, + project_dir: pathlib.Path, + template_dir: pathlib.Path, + ) -> None: + try: + craft_cli.emit.progress("Checking for an existing 'snapcraft.yaml'.") + project = get_snap_project(project_dir) + raise errors.SnapcraftError( + "Could not initialise a new snapcraft project because " + f"{str(project.project_file)!r} already exists" + ) + # the `ProjectMissing` error means a new project can be initialised + except errors.ProjectMissing: + craft_cli.emit.progress("Could not find an existing 'snapcraft.yaml'.") + + super().check_for_existing_files( + project_dir=project_dir, + template_dir=template_dir, + ) diff --git a/snapcraft/services/service_factory.py b/snapcraft/services/service_factory.py index bbb9f0630f..0d51c74168 100644 --- a/snapcraft/services/service_factory.py +++ b/snapcraft/services/service_factory.py @@ -33,6 +33,9 @@ class SnapcraftServiceFactory(ServiceFactory): project: models.Project | None = None # type: ignore[reportIncompatibleVariableOverride] # These are overrides of default ServiceFactory services + InitClass: type[services.Init] = ( # type: ignore[reportIncompatibleVariableOverride] + services.Init + ) LifecycleClass: type[services.Lifecycle] = ( # type: ignore[reportIncompatibleVariableOverride] services.Lifecycle ) diff --git a/snapcraft/templates/simple/snap/snapcraft.yaml b/snapcraft/templates/simple/snap/snapcraft.yaml.j2 similarity index 90% rename from snapcraft/templates/simple/snap/snapcraft.yaml rename to snapcraft/templates/simple/snap/snapcraft.yaml.j2 index 4ef5b5f2d6..5b69f37f7f 100644 --- a/snapcraft/templates/simple/snap/snapcraft.yaml +++ b/snapcraft/templates/simple/snap/snapcraft.yaml.j2 @@ -1,4 +1,4 @@ -name: my-snap-name # you probably want to 'snapcraft register ' +name: {{ name }} # you probably want to 'snapcraft register ' base: core24 # the base snap is the execution environment for this snap version: '0.1' # just for humans, typically '1.2+git' or '1.3.2' summary: Single-line elevator pitch for your amazing snap # 79 char long summary diff --git a/tests/spread/general/init/task.yaml b/tests/spread/general/init/task.yaml index 8c69c91b6e..e86e905028 100644 --- a/tests/spread/general/init/task.yaml +++ b/tests/spread/general/init/task.yaml @@ -1,23 +1,31 @@ summary: Run snapcraft init +systems: [ubuntu-22*] + environment: - PROFILE/default_profile: null - PROFILE/simple_profile: simple PROFILE: null - PROJECT_DIR/default_dir: null - PROJECT_DIR/project_dir: test-project-dir PROJECT_DIR: null + NAME: null + PROFILE/default_profile: null + PROFILE/simple_profile: simple + NAME/with_name: test-snap-name + PROJECT_DIR/with_project_dir: test-project-dir + NAME/with_project_dir_and_name: test-snap-name + PROJECT_DIR/with_project_dir_and_name: test-project-dir restore: | + unset SNAPCRAFT_BUILD_ENVIRONMENT + if [[ -n "$PROJECT_DIR" ]]; then cd "$PROJECT_DIR" fi + snapcraft clean + rm -f ./*.snap rm -rf ./snap execute: | - # unset SNAPCRAFT_BUILD_ENVIRONMENT=host unset SNAPCRAFT_BUILD_ENVIRONMENT args=("init") @@ -26,6 +34,10 @@ execute: | args+=("--profile" "$PROFILE") fi + if [[ -n "$NAME" ]]; then + args+=("--name" "$NAME") + fi + if [[ -n "$PROJECT_DIR" ]]; then args+=("$PROJECT_DIR") fi @@ -36,7 +48,13 @@ execute: | cd "$PROJECT_DIR" fi - # the base should be core24 + if [[ -n "$NAME" ]]; then + expected_name="$NAME" + else + expected_name="$(basename "$PWD")" + fi + + grep "^name: ${expected_name}" snap/snapcraft.yaml grep "^base: core24" snap/snapcraft.yaml # 'snapcraft init' should create a usable snapcraft.yaml file diff --git a/tests/unit/commands/test_init.py b/tests/unit/commands/test_init.py index d0c6c2e75c..eac92759ec 100644 --- a/tests/unit/commands/test_init.py +++ b/tests/unit/commands/test_init.py @@ -14,33 +14,61 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import os import pathlib import sys -from pathlib import Path from textwrap import dedent import pytest from snapcraft import application from snapcraft.models.project import Project -from snapcraft.parts.yaml_utils import _SNAP_PROJECT_FILES, apply_yaml, process_yaml +from snapcraft.parts.yaml_utils import apply_yaml, process_yaml -@pytest.mark.parametrize("profile", [None, "simple"]) -@pytest.mark.parametrize("name", [None, "test-snap-name"]) -@pytest.mark.parametrize("project_dir", [None, "test-project-dir"]) -def test_init_default(profile, name, project_dir, emitter, new_dir, mocker): - """Test the 'snapcraft init' command.""" +@pytest.fixture +def valid_new_dir(tmp_path): + """Change to a new temporary directory whose name is a valid snap name.""" + cwd = os.getcwd() + new_dir = tmp_path / "test-snap-name-dir" + new_dir.mkdir() + os.chdir(new_dir) + + yield new_dir + + os.chdir(cwd) + + +def _create_command( + *, + profile: str | None = None, + project_dir: str | None = None, + name: str | None = None, +): + """Build a snapcraft init command.""" cmd = ["snapcraft", "init"] if profile: cmd.extend(["--profile", profile]) - if name: - cmd.extend(["--name", name]) if project_dir: cmd.append(project_dir) - snapcraft_yaml = pathlib.Path(project_dir) / "snap/snapcraft.yaml" + if name: + cmd.extend(["--name", name]) + return cmd + + +@pytest.mark.parametrize("profile", [None, "simple"]) +@pytest.mark.parametrize("name", [None, "test-snap-name"]) +@pytest.mark.parametrize("project_dir", [None, "test-project-dir"]) +def test_init_default(profile, name, project_dir, emitter, valid_new_dir, mocker): + """Test the 'snapcraft init' command.""" + if name: + expected_name = name + elif project_dir: + expected_name = project_dir else: - snapcraft_yaml = Path("snap/snapcraft.yaml") + expected_name = str(valid_new_dir.name) + snapcraft_yaml = pathlib.Path(project_dir or valid_new_dir) / "snap/snapcraft.yaml" + cmd = _create_command(profile=profile, project_dir=project_dir, name=name) mocker.patch.object(sys, "argv", cmd) app = application.create_app() @@ -52,7 +80,7 @@ def test_init_default(profile, name, project_dir, emitter, new_dir, mocker): project = Project.unmarshal(data) assert project == Project.unmarshal( { - "name": "my-snap-name", + "name": expected_name, "base": "core24", "version": "0.1", "summary": "Single-line elevator pitch for your amazing snap", @@ -70,80 +98,10 @@ def test_init_default(profile, name, project_dir, emitter, new_dir, mocker): "platforms": {"amd64": {"build-on": "amd64", "build-for": "amd64"}}, } ) - if name: - emitter.assert_progress( - "Ignoring '--name' parameter because it is not supported yet.", - permanent=True, - ) emitter.assert_progress("Checking for an existing 'snapcraft.yaml'.") emitter.assert_progress("Could not find an existing 'snapcraft.yaml'.") - emitter.assert_message("Successfully initialised project.") emitter.assert_message( "Go to https://docs.snapcraft.io/the-snapcraft-format/8337 for more " "information about the snapcraft.yaml format." ) - - -@pytest.mark.parametrize("profile", [None, "simple"]) -@pytest.mark.parametrize("name", [None, "test-snap-name"]) -@pytest.mark.parametrize("project_dir", [None, "test-project-dir"]) -def test_init_snap_dir_exists(profile, name, project_dir, emitter, new_dir, mocker): - """'snapcraft init' should work even if the 'snap/' directory already exists.""" - cmd = ["snapcraft", "init"] - if profile: - cmd.extend(["--profile", profile]) - if name: - cmd.extend(["--name", name]) - if project_dir: - cmd.append(project_dir) - snapcraft_yaml = pathlib.Path(project_dir) / "snap/snapcraft.yaml" - else: - snapcraft_yaml = Path("snap/snapcraft.yaml") - snapcraft_yaml.parent.mkdir(parents=True) - mocker.patch.object(sys, "argv", cmd) - app = application.create_app() - - app.run() - - assert snapcraft_yaml.exists() emitter.assert_message("Successfully initialised project.") - emitter.assert_message( - "Go to https://docs.snapcraft.io/the-snapcraft-format/8337 for more " - "information about the snapcraft.yaml format." - ) - - -@pytest.mark.parametrize( - "snapcraft_yaml", [project.project_file for project in _SNAP_PROJECT_FILES] -) -@pytest.mark.parametrize("profile", [None, "simple"]) -@pytest.mark.parametrize("name", [None, "test-snap-name"]) -@pytest.mark.parametrize("project_dir", [None, "test-project-dir"]) -def test_init_exists( - profile, name, project_dir, capsys, emitter, new_dir, snapcraft_yaml, mocker -): - """Raise an error if a snapcraft.yaml file already exists.""" - cmd = ["snapcraft", "init"] - if profile: - cmd.extend(["--profile", profile]) - if name: - cmd.extend(["--name", name]) - if project_dir: - cmd.append(project_dir) - snapcraft_yaml_path = pathlib.Path(project_dir) / snapcraft_yaml - else: - snapcraft_yaml_path = snapcraft_yaml - mocker.patch.object(sys, "argv", cmd) - snapcraft_yaml_path.parent.mkdir(parents=True, exist_ok=True) - snapcraft_yaml_path.touch() - app = application.create_app() - - app.run() - - out, err = capsys.readouterr() - assert not out - assert ( - "could not initialise a new snapcraft project because " - f"{str(snapcraft_yaml)!r} already exists" - ) in err - emitter.assert_progress("Checking for an existing 'snapcraft.yaml'.") diff --git a/tests/unit/services/test_init.py b/tests/unit/services/test_init.py new file mode 100644 index 0000000000..f073d2effd --- /dev/null +++ b/tests/unit/services/test_init.py @@ -0,0 +1,129 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# 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 the init service.""" + +import importlib.resources +import pathlib + +import pytest + +from snapcraft import errors +from snapcraft.parts.yaml_utils import _SNAP_PROJECT_FILES + + +@pytest.fixture() +def init_service(default_factory): + from snapcraft.application import APP_METADATA + from snapcraft.services import Init + + service = Init(app=APP_METADATA, services=default_factory) + + return service + + +def template_dir(): + with importlib.resources.path("snapcraft", "templates") as _template_dir: + return _template_dir / "simple" + + +@pytest.mark.parametrize( + "name", + [ + "name", + "name-with-dashes", + "name0123", + "0123name", + "a234567890123456789012345678901234567890", + ], +) +def test_init_valid_name(name, init_service, new_dir, emitter): + """Initialise a project with a valid snap name.""" + init_service.initialise_project( + project_dir=new_dir, project_name=name, template_dir=template_dir() + ) + + assert (new_dir / "snap/snapcraft.yaml").exists() + emitter.assert_message( + "Go to https://docs.snapcraft.io/the-snapcraft-format/8337 for more " + "information about the snapcraft.yaml format." + ) + + +@pytest.mark.parametrize( + "name,error", + [ + ("name_with_underscores", "snap names can only use"), + ("name-with-UPPERCASE", "snap names can only use"), + ("name with spaces", "snap names can only use"), + ("-name-starts-with-hyphen", "snap names cannot start with a hyphen"), + ("name-ends-with-hyphen-", "snap names cannot end with a hyphen"), + ("name-has--two-hyphens", "snap names cannot have two hyphens in a row"), + ("123456", "snap names can only use"), + ( + "a2345678901234567890123456789012345678901", + "snap names must be 40 characters or less", + ), + ], +) +def test_init_invalid_name(name, error, init_service, new_dir): + """Error on invalid names.""" + expected_error = f"Invalid snap name {name!r}: {error}." + + with pytest.raises(errors.SnapcraftError, match=expected_error): + init_service.initialise_project( + project_dir=new_dir, + project_name=name, + template_dir=template_dir(), + ) + + +def test_init_snap_dir_exists(init_service, new_dir, emitter): + """Initialise a project even if the 'snap/' directory already exists.""" + snapcraft_yaml = new_dir / "snap/snapcraft.yaml" + snapcraft_yaml.parent.mkdir(parents=True) + + init_service.check_for_existing_files( + project_dir=new_dir, template_dir=template_dir() + ) + init_service.initialise_project( + project_dir=new_dir, project_name="test-snap-name", template_dir=template_dir() + ) + + assert snapcraft_yaml.exists() + emitter.assert_message( + "Go to https://docs.snapcraft.io/the-snapcraft-format/8337 for more " + "information about the snapcraft.yaml format." + ) + + +@pytest.mark.parametrize( + "project_file", [project.project_file for project in _SNAP_PROJECT_FILES] +) +def test_init_exists(init_service, new_dir, project_file): + """Raise an error if a snapcraft.yaml file already exists.""" + snapcraft_yaml = pathlib.Path(new_dir) / project_file + snapcraft_yaml.parent.mkdir(parents=True, exist_ok=True) + snapcraft_yaml.touch() + expected = ( + "Could not initialise a new snapcraft project because " + f"{str(project_file)!r} already exists" + ) + + with pytest.raises(errors.SnapcraftError, match=expected): + init_service.check_for_existing_files( + project_dir=new_dir, template_dir=template_dir() + )