Skip to content
Draft
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
4 changes: 4 additions & 0 deletions craft_application/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@
from craft_application.models.metadata import BaseMetadata
from craft_application.models.platforms import (
GenericPlatformsDict,
GenericStrictPlatformsDict,
Platform,
PlatformsDict,
StrictPlatformsDict,
)
from craft_application.models.project import (
DEVEL_BASE_INFOS,
Expand All @@ -55,9 +57,11 @@
"get_grammar_aware_part_keywords",
"GrammarAwareProject",
"GenericPlatformsDict",
"GenericStrictPlatformsDict",
"PackState",
"Platform",
"PlatformsDict",
"StrictPlatformsDict",
"Project",
"ProjectName",
"ProjectTitle",
Expand Down
54 changes: 53 additions & 1 deletion craft_application/models/platforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import craft_platforms
import pydantic
from craft_platforms import platform_types
from pydantic_core import core_schema as cs
from typing_extensions import Any, Self, TypeVar

Expand Down Expand Up @@ -60,6 +61,7 @@ def _validate_platform_name(name: str) -> str:
),
]
PlatformNameAdapter = pydantic.TypeAdapter[str](PlatformName)
StrictPlatformNameAdapter = pydantic.TypeAdapter[str](platform_types.StrictPlatformName)


class Platform(base.CraftBaseModel):
Expand Down Expand Up @@ -153,10 +155,11 @@ def from_platforms(cls, platforms: craft_platforms.Platforms) -> dict[str, Self]
return result


NameType = TypeVar("NameType", bound=str)
PT = TypeVar("PT", bound=Platform)


class GenericPlatformsDict(dict[PlatformName, PT]):
class _GenericPlatformsDict(dict[NameType, PT]):
"""A generic dictionary describing the contents of the platforms key.

This class exists to generate Pydantic and JSON schemas for the platforms key on
Expand Down Expand Up @@ -251,6 +254,46 @@ def __get_pydantic_json_schema__(
return json_schema


class GenericPlatformsDict(_GenericPlatformsDict[PlatformName, PT]):
"""A generic dictionary describing the contents of the platforms key.

This class exists to generate Pydantic and JSON schemas for the platforms key on
a project. By making it a generic, an application can override the Platform
definition and provide its own PlatformsDict. A side effect of this, however, is
that an application cannot simply use the generic directly. Instead, it must create
a non-generic child class and use that.
"""


class GenericStrictPlatformsDict(
_GenericPlatformsDict[platform_types.StrictPlatformName, PT]
):
"""A generic platforms dictionary but with strict platform names."""

@classmethod
def __get_pydantic_core_schema__(
cls, source_type: type, handler: pydantic.GetCoreSchemaHandler
) -> cs.CoreSchema:
"""Get the Pydantic CoreSchema for this PlatformsDict.

From a Pydantic perspective, this dict is merely a ``dict[str, PT]``, where
``PT`` is the type of the Platform field. It is unlikely to need to override
this method.
"""
try:
(value_type,) = get_args(
cls.__orig_bases__[0] # type: ignore[attr-defined]
)
except (ValueError, AttributeError):
raise RuntimeError(
"Cannot get value type. This likely means the application is using "
"GenericPlatformsDict directly rather than creating a child class."
)
return cs.dict_schema(
StrictPlatformNameAdapter.core_schema, value_type.__pydantic_core_schema__
)


class PlatformsDict(GenericPlatformsDict[Platform]):
"""A dictionary with a Pydantic schema for the general platforms key.

Expand All @@ -260,3 +303,12 @@ class PlatformsDict(GenericPlatformsDict[Platform]):
:ref:`platform-schema` may use this directly. Applications that need their own
``Platform`` model can override :py:class:`.GenericPlatformsDict`.
"""


class StrictPlatformsDict(GenericStrictPlatformsDict[Platform]):
"""A dictionary with a Pydantic schema for a platforms key with strict names.

This is a Pydantic model for the ``platforms`` dictionary on a ``Project``
model. If a project model can use strict platform names, this dictionary should be
used to represent the platforms.
"""
3 changes: 2 additions & 1 deletion craft_application/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from craft_application.models.platforms import (
Platform,
PlatformsDict,
StrictPlatformsDict,
)


Expand Down Expand Up @@ -134,7 +135,7 @@ class Project(base.CraftBaseModel):
base: str | None = None
build_base: str | None = None

platforms: PlatformsDict = pydantic.Field(
platforms: PlatformsDict | StrictPlatformsDict = pydantic.Field(
description="Determines which architectures the project builds and runs on.",
examples=[
"{amd64: {build-on: [amd64], build-for: [amd64]}, arm64: {build-on: [amd64, arm64], build-for: [arm64]}}"
Expand Down
39 changes: 39 additions & 0 deletions craft_application/services/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import distro_support
import pydantic
from craft_cli import emit
from craft_platforms import validators
from distro_support.errors import (
UnknownDistributionError,
UnknownVersionError,
Expand All @@ -45,6 +46,20 @@
from .service_factory import ServiceFactory


NON_STRICT_PLATFORM_NAME_BASES = (
"ubuntu:20.04",
"ubuntu@20.04",
"core20",
"ubuntu:22.04",
"ubuntu@22.04",
"core22",
"ubuntu:24.04",
"ubuntu@24.04",
"core24",
)
"""Bases that do not get strict platform name validation."""


class ProjectService(base.AppService):
"""A service for handling access to the project."""

Expand Down Expand Up @@ -283,6 +298,11 @@ def get_platforms(self) -> dict[str, craft_platforms.PlatformDict]:
file_name=self.project_file_name,
) from None
self._validate_multi_base(self.__platforms)
if self.strict_platform_names:
for name in self.__platforms:
validators.validate_strict_platform_name(
name, allow_app_characters=False
)
return copy.deepcopy(self.__platforms)

def _validate_multi_base(
Expand Down Expand Up @@ -790,3 +810,22 @@ def _deep_update(base: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]
else:
base[key] = new_value
return base

@property
def strict_platform_names(self) -> bool:
"""Determine whether to use strict platform names.

Applications may override this. The default behaviour is to return True for any
base or build base in a list of legacy bases or False otherwise.
Having a base or build-base of ``devel`` always returns True.
"""
base = self._load_raw_project().get("base")
build_base = self._load_raw_project().get("build-base")

if "devel" in (base, build_base):
return True

if not build_base and base in NON_STRICT_PLATFORM_NAME_BASES:
return False

return build_base not in NON_STRICT_PLATFORM_NAME_BASES
9 changes: 7 additions & 2 deletions docs/reference/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,23 @@ Changelog

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

6.3.0 (unreleased)
------------------

- The project service now has a
:py:class:`~craft_application.services.project.ProjectService.strict_platform_names`
property that, when True, enforces strict platform name validation. The property is
by default True for all bases except Ubuntu 20.04, 22.04, and 24.04.

6.2.0 (2026-02-12)
------------------

Services
========

- The Provider service now injects the application's base snap from the host into the
build environment.

Bug Fixes
=========

- Files for the test command, ``spread.yaml`` and ``spread/``, no longer cause
the part's build directory to be marked as dirty by Git.
Expand Down
9 changes: 9 additions & 0 deletions docs/reference/models/platforms.rst
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ unreserved name. The name can't contain forward slashes (/).
.. autoclass:: craft_application.models.PlatformsDict
:show-inheritance:

.. autoclass:: craft_application.models.StrictPlatformsDict
:show-inheritance:


Inheritance
-----------
Expand All @@ -67,3 +70,9 @@ class has several validators that may need to be modified.
:undoc-members:
:private-members: _shorthand_keys, __get_pydantic_core_schema__
:special-members: __get_pydantic_core_schema__, __get_pydantic_json_schema__

.. autoclass:: craft_application.models.GenericStrictPlatformsDict
:members:
:undoc-members:
:private-members: _shorthand_keys, __get_pydantic_core_schema__
:special-members: __get_pydantic_core_schema__, __get_pydantic_json_schema__
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ dependencies = [
"craft-cli>=3.2.0",
"craft-grammar>=2.3.0",
"craft-parts>=2.28.0",
"craft-platforms>=0.6.0",
"craft-platforms>=0.11.0",
"craft-providers>=3.3.0",
"Jinja2>=3.1.6,<4.0.0",
"snap-helpers>=0.4.2",
Expand Down
35 changes: 35 additions & 0 deletions tests/unit/models/test_platforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from craft_application.models.platforms import (
RESERVED_PLATFORM_NAMES,
PlatformsDict,
StrictPlatformsDict,
)
from hypothesis import given, strategies

Expand Down Expand Up @@ -56,3 +57,37 @@ def test_platform_name_invalid_character(name):
def test_fuzz_platform_name(name):
adapter = pydantic.TypeAdapter(PlatformsDict)
adapter.validate_python({name: {"build-on": ["riscv64"], "build-for": ["s390x"]}})


@pytest.mark.parametrize("name", ["app_only_platform"])
def test_strict_platform_name_invalid_character(name):
adapter = pydantic.TypeAdapter(StrictPlatformsDict)
with pytest.raises(ValueError, match=f"Invalid platform name: '{name}'"):
adapter.validate_python(
{name: {"build-on": ["riscv64"], "build-for": ["s390x"]}}
)


@given(
strategies.text(
strategies.characters(categories=["L", "N", "So"]),
min_size=1,
),
)
def test_fuzz_strict_platform_name(name):
adapter = pydantic.TypeAdapter(StrictPlatformsDict)
adapter.validate_python({name: {"build-on": ["riscv64"], "build-for": ["s390x"]}})


@given(
strategies.text(
strategies.characters(exclude_categories=["L", "N", "So"]),
min_size=1,
),
)
def test_fuzz_strict_platform_name_error(name):
adapter = pydantic.TypeAdapter(StrictPlatformsDict)
with pytest.raises(pydantic.ValidationError):
adapter.validate_python(
{name: {"build-on": ["riscv64"], "build-for": ["s390x"]}}
)
64 changes: 63 additions & 1 deletion tests/unit/services/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@
import pytest_mock
from craft_application import errors, models
from craft_application.application import AppMetadata
from craft_application.services.project import ProjectService
from craft_application.services.project import (
NON_STRICT_PLATFORM_NAME_BASES,
ProjectService,
)
from craft_application.services.service_factory import ServiceFactory
from craft_parts import ProjectVar, ProjectVarInfo
from hypothesis import given, strategies
Expand Down Expand Up @@ -305,6 +308,65 @@ def test_get_platforms_bad_value(
real_project_service.get_platforms()


@pytest.mark.parametrize(
"platforms",
[
{
"__THIS_IS_NOT_A_VALID_STRICT_PLATFORM_NAME__": {
"build-on": ["riscv64"],
"build-for": ["riscv64"],
},
},
{
";": {
"build-on": ["riscv64"],
"build-for": ["riscv64"],
},
},
],
)
@pytest.mark.parametrize("base", NON_STRICT_PLATFORM_NAME_BASES)
@pytest.mark.parametrize("base_key", ["base", "build-base"])
def test_get_platforms_strict_name_exemption(
real_project_service: ProjectService, platforms, base, base_key
):
real_project_service._load_raw_project = lambda: { # type: ignore[invalid-assignment]
"platforms": platforms,
base_key: base,
}
real_project_service.get_platforms()


@pytest.mark.parametrize(
"platforms",
[
{
"__THIS_IS_NOT_A_VALID_STRICT_PLATFORM_NAME__": {
"build-on": ["riscv64"],
"build-for": ["riscv64"],
},
},
{
";": {
"build-on": ["riscv64"],
"build-for": ["riscv64"],
},
},
],
)
@pytest.mark.parametrize("base", ["devel", "ubuntu@26.04"])
def test_get_platforms_strict_name_error(
real_project_service: ProjectService, platforms, base
):
real_project_service._load_raw_project = lambda: { # type: ignore[invalid-assignment]
"platforms": platforms,
"base": base,
}

with pytest.raises(craft_platforms.InvalidPlatformNameError):
real_project_service.get_platforms()


@pytest.mark.parametrize(
("data", "expected"),
[
Expand Down
Loading
Loading