Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions craft_application/commands/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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")

Expand Down
4 changes: 4 additions & 0 deletions craft_application/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
31 changes: 18 additions & 13 deletions craft_application/services/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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:
Expand All @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions docs/reference/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
------------------

Expand Down Expand Up @@ -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
25 changes: 25 additions & 0 deletions tests/integration/commands/test_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
)
Loading