Skip to content
40 changes: 40 additions & 0 deletions craft_application/util/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import craft_parts
import craft_platforms
import craft_providers
import pydantic

from craft_application import errors

Expand Down Expand Up @@ -91,6 +92,45 @@ def handle_runtime_error(
retcode=error.retcode,
)
transformed.__cause__ = error
case pydantic.ValidationError():
# An unhandled pydantic ValidationError almost always means the
# user's project configuration contains an invalid value (for
# example an unsupported ``base``). Without this case it falls
# through to the generic handler below and is reported as an
# "internal error" (exit 70) along with a raw pydantic traceback
# and an errors.pydantic.dev link, even though it is a plain user
# configuration error. Render it as a structured config error.
# NB: pydantic.ValidationError is a subclass of ValueError, so this
# case must come before any bare ValueError handling.
return_code = os.EX_DATAERR
transformed = errors.CraftValidationError.from_pydantic(
error,
file_name=f"{app.name}.yaml",

@bepri bepri May 26, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not a guarantee that this is the offending file. For example, Snapcraft could also be any one of:

  • .snapcraft.yaml
  • snap/snapcraft.yaml
  • build-aux/snap/snapcraft.yaml

I'm not sure what the right fix is here to pass this information down. @lengau WDYT about a solution involving kwargs to optionally pass the output of ProjectService.resolve_project_file_path() into this method?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Longer term yes, but let's not block this PR on it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very well then

resolution=(
"Check the reported field(s) against the supported "
"values in the documented project schema."
),
)
transformed.__cause__ = error
case ValueError() if "BuilddBaseAlias" in str(error):
# Host base-alias lookup failures (e.g.
# ``ValueError("'26.04' is not a valid BuilddBaseAlias")``)
# currently bubble up as a bare ValueError and are reported as an
# "internal error". Surface them as a structured error instead.
# This is a shallow guard keyed off the exception message; the
# proper fix is to raise a typed error at the base-alias lookup
# site in craft-providers. Tracked in #1086 (blocked by
# canonical/craft-providers#969).
return_code = os.EX_CONFIG
transformed = craft_cli.CraftError(
f"Unsupported base for this host: {error}",
resolution=(
"Build on a host whose Ubuntu release matches a supported "
"base, or set a supported 'base'/'build-base' in your "
"project configuration."
),
)
transformed.__cause__ = error
case _:
unrecognized_error = True
if isinstance(error, craft_platforms.CraftError):
Expand Down
20 changes: 19 additions & 1 deletion docs/reference/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,24 @@ Changelog
For a complete list of commits, check out the `1.2.3`_ release on GitHub.


7.1.0 (unreleased)
------------------

Fixes
=====

- Unhandled ``pydantic.ValidationError`` is now reported as a structured
project configuration error (exit ``EX_DATAERR``) with a recommended
resolution, rather than as an "internal error" with a raw pydantic
traceback.
- ``ValueError`` failures from a host base-alias lookup
(``'<release>' is not a valid BuilddBaseAlias``) are now reported as a
structured configuration error (exit ``EX_CONFIG``) instead of as an
"internal error".

For a complete list of commits, check out the `7.1.0`_ release on GitHub.


7.0.0 (2026-06-02)
------------------

Expand Down Expand Up @@ -66,7 +84,6 @@ Services
- ``ServiceFactory.set_kwargs()`` is removed. Use ``ServiceFactory.update_kwargs()``
instead.


For a complete list of commits, check out the `7.0.0`_ release on GitHub.

6.4.0 (2026-04-23)
Expand Down Expand Up @@ -1358,3 +1375,4 @@ For a complete list of commits, check out the `2.7.0`_ release on GitHub.
.. _6.3.1: https://github.com/canonical/craft-application/releases/tag/6.3.1
.. _6.4.0: https://github.com/canonical/craft-application/releases/tag/6.4.0
.. _7.0.0: https://github.com/canonical/craft-application/releases/tag/7.0.0
.. _7.1.0: https://github.com/canonical/craft-application/releases/tag/7.1.0
60 changes: 59 additions & 1 deletion tests/unit/util/test_logging.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import logging
import os

import pydantic
import pytest
import pytest_check
from craft_application import util
from craft_application import errors, util
from craft_application.application import AppMetadata
from craft_application.util.logging import handle_runtime_error
from hypothesis import given, strategies


Expand All @@ -18,3 +23,56 @@ def test_setup_loggers_resulting_level(names):
for name in names:
logger = logging.getLogger(name)
pytest_check.equal(logger.level, logging.DEBUG)


@pytest.fixture
def app_metadata():
return AppMetadata(name="testcraft", summary="A summary")


def test_handle_runtime_error_pydantic_is_user_config_error(app_metadata):
"""A pydantic ValidationError is rendered as a structured config error.

It must not fall through to the generic 'internal error' (exit 70) path.
"""

class _Model(pydantic.BaseModel):
base: int

with pytest.raises(pydantic.ValidationError) as exc_info:
_Model.model_validate({"base": "ubuntu@26.04"})
error = exc_info.value

captured = []
return_code = handle_runtime_error(app_metadata, error, print_error=captured.append)

pytest_check.equal(return_code, os.EX_DATAERR)
pytest_check.is_instance(captured[0], errors.CraftValidationError)
pytest_check.is_in("testcraft.yaml", captured[0].args[0])
pytest_check.is_not_none(captured[0].resolution)
pytest_check.is_true("internal error" not in captured[0].args[0])
pytest_check.equal(captured[0].__cause__, error)


def test_handle_runtime_error_base_alias_value_error(app_metadata):
"""A host base-alias lookup ValueError becomes a structured error."""
error = ValueError("'26.04' is not a valid BuilddBaseAlias")

captured = []
return_code = handle_runtime_error(app_metadata, error, print_error=captured.append)

pytest_check.equal(return_code, os.EX_CONFIG)
pytest_check.is_in("Unsupported base", captured[0].args[0])
pytest_check.is_not_none(captured[0].resolution)
pytest_check.is_true("internal error" not in captured[0].args[0])


def test_handle_runtime_error_generic_value_error_is_internal(app_metadata):
"""An unrelated ValueError still reports as an internal error."""
error = ValueError("something unexpected")

captured = []
return_code = handle_runtime_error(app_metadata, error, print_error=captured.append)

pytest_check.equal(return_code, os.EX_SOFTWARE)
pytest_check.is_in("internal error", captured[0].args[0])
Loading