diff --git a/craft_application/commands/lifecycle.py b/craft_application/commands/lifecycle.py index 29bfd5ef4..a7fa9cad1 100644 --- a/craft_application/commands/lifecycle.py +++ b/craft_application/commands/lifecycle.py @@ -22,12 +22,15 @@ import textwrap from typing import Any, Literal, cast +import pydantic from craft_cli import CommandGroup, CraftError, emit from craft_parts.features import Features from typing_extensions import override from craft_application import errors, models, util from craft_application.commands import base +from craft_application.errors import TestFileError +from craft_application.util.error_formatting import format_pydantic_errors from craft_application.util.logging import handle_runtime_error _PACKED_FILE_LIST_PATH = ".craft/packed-files" @@ -675,6 +678,17 @@ def _run( # Add values for super classes like pack. parsed_args.output = pathlib.Path.cwd() + testing_service = self._services.get("testing") + # Fail early if spread.yaml is invalid. + try: + testing_service.parse_spread_yaml() + except pydantic.ValidationError as exc: + raise TestFileError( + format_pydantic_errors(exc.errors(), file_name="spread.yaml"), + reportable=False, + retcode=os.EX_DATAERR, + ) + if util.is_managed_mode(): # If we're in managed mode, we just need to pack. return super()._run(parsed_args=parsed_args, step_name=step_name, **kwargs) @@ -687,8 +701,6 @@ def _run( if parsed_args.platform: os.environ["CRAFT_PLATFORM"] = parsed_args.platform build_planner.set_platforms(parsed_args.platform) - - testing_service = self._services.get("testing") package = self._services.get("package") provider = self._services.get("provider") diff --git a/craft_application/errors.py b/craft_application/errors.py index bb2da7510..9ca04aaa8 100644 --- a/craft_application/errors.py +++ b/craft_application/errors.py @@ -48,6 +48,10 @@ class ProjectFileError(CraftError): """Errors to do with the project file or directory.""" +class TestFileError(CraftError): + """Errors to do with the craft test or spread file.""" + + class ProjectGenerationError(CraftError): """Errors to do with prime project generation.""" diff --git a/craft_application/services/testing.py b/craft_application/services/testing.py index 18515b65d..2ad001de7 100644 --- a/craft_application/services/testing.py +++ b/craft_application/services/testing.py @@ -76,14 +76,8 @@ def test( debug=debug, ) - def process_spread_yaml( - self, dest: pathlib.Path, pack_state: models.PackState - ) -> None: - """Process the spread configuration file. - - :param dest: the output path for spread.yaml. - """ - emit.debug("Processing spread.yaml.") + def parse_spread_yaml(self) -> models.CraftSpreadYaml: + """Read and parse the spread.yaml file for this project.""" spread_path = pathlib.Path("spread.yaml") if not spread_path.is_file(): raise CraftError( @@ -94,6 +88,22 @@ def process_spread_yaml( retcode=os.EX_CONFIG, ) + with spread_path.open() as file: + data = util.safe_yaml_load(file) + + return models.CraftSpreadYaml.unmarshal(data) + + def process_spread_yaml( + self, dest: pathlib.Path, pack_state: models.PackState + ) -> None: + """Process the spread configuration file. + + :param dest: the output path for spread.yaml. + """ + emit.debug("Processing spread.yaml.") + + simple = self.parse_spread_yaml() + craft_backend = self._get_backend() if not pack_state.artifact: @@ -102,11 +112,6 @@ def process_spread_yaml( resolution=f"Ensure that {self._app.artifact_type} files are generated before running the test.", ) - with spread_path.open() as file: - data = util.safe_yaml_load(file) - - simple = models.CraftSpreadYaml.unmarshal(data) - spread_yaml = models.SpreadYaml.from_craft( simple, craft_backend=craft_backend, diff --git a/docs/reference/changelog.rst b/docs/reference/changelog.rst index ebc871398..b0905e14f 100644 --- a/docs/reference/changelog.rst +++ b/docs/reference/changelog.rst @@ -15,6 +15,17 @@ Changelog For a complete list of commits, check out the `1.2.3`_ release on GitHub. +6.3.0 (unreleased) +------------------ + +Testing +======= + +- The ``test`` command now reads ``spread.yaml`` before beginning the lifecycle, + erroring early if the file is invalid. + +For a complete list of commits, check out the `6.3.0`_ release on GitHub. + 6.2.1 (2026-03-06) ------------------ @@ -1227,3 +1238,4 @@ For a complete list of commits, check out the `2.7.0`_ release on GitHub. .. _6.1.0: https://github.com/canonical/craft-application/releases/tag/6.1.0 .. _6.1.1: https://github.com/canonical/craft-application/releases/tag/6.1.1 .. _6.2.0: https://github.com/canonical/craft-application/releases/tag/6.2.0 +.. _6.3.0: https://github.com/canonical/craft-application/releases/tag/6.3.0 diff --git a/tests/integration/commands/test_lifecycle.py b/tests/integration/commands/test_lifecycle.py index 8fe9e0175..48037e95e 100644 --- a/tests/integration/commands/test_lifecycle.py +++ b/tests/integration/commands/test_lifecycle.py @@ -16,11 +16,13 @@ """Integration tests for lifecycle commands.""" import os +import pathlib import re import freezegun import pytest from craft_application.application import Application +from craft_application.commands import TestCommand @pytest.mark.usefixtures("fake_process") # Ensure we don't spin up a container. @@ -43,3 +45,26 @@ def test_unsupported_base_error( rf"Cannot {command} artifact. (Build b|B)ase '[a-z]+@\d+\.\d+' has reached end-of-life.", stderr, ) + + +@pytest.mark.usefixtures("fake_process") # Ensure we don't spin up a container. +def test_test_with_bad_spread_yaml( + app: Application, + capsys: pytest.CaptureFixture, + monkeypatch: pytest.MonkeyPatch, + new_dir: pathlib.Path, +): + """Initialise a project.""" + app.add_command_group("Lifecycle", [TestCommand], ordered=True) + monkeypatch.setattr("sys.argv", ["testcraft", "test"]) + spread_file = new_dir / "spread.yaml" + spread_file.write_text("summary: An incomplete test file.") + + retcode = app.run() + _, stderr = capsys.readouterr() + + assert retcode == os.EX_DATAERR + assert re.match( + r"^Bad spread.yaml content:\n- field 'backends' required in top-level configuration", + stderr, + )