Source code for craft_application.models.platforms
# This file is part of craft-application.
#
# Copyright 2023-2025 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 <http://www.gnu.org/licenses/>.
"""Models that describe platforms."""
import enum
import re
from collections.abc import Iterable, Mapping
from typing import Annotated, ClassVar, cast, get_args
import craft_platforms
import pydantic
from pydantic_core import core_schema as cs
from typing_extensions import Any, Self, TypeVar
from craft_application import errors
from craft_application.models import base
from craft_application.models.constraints import SingleEntryList, UniqueList
RESERVED_PLATFORM_NAMES = frozenset(
# Adding a reserved platform name is a breaking change and requires a major release.
(
"any", # "any" is used for `for` grammar.
"*", # This could be confused for "all".
)
)
def _validate_platform_name(name: str) -> str:
"""Validate that the given platform name is not reserved."""
if name in RESERVED_PLATFORM_NAMES:
raise ValueError(f"Reserved platform name: {name!r}")
if "/" in name:
raise ValueError("Platform names cannot contain the '/' character.")
return name
PlatformName = Annotated[
str,
pydantic.BeforeValidator(_validate_platform_name),
pydantic.Field(
title="Platform name",
description="The name of this platform. May not contain '/'",
examples=["riscv64", "my-special-platform"],
json_schema_extra={
"not": {"enum": cast(pydantic.JsonValue, sorted(RESERVED_PLATFORM_NAMES))}
},
),
]
PlatformNameAdapter = pydantic.TypeAdapter[str](PlatformName)
[docs]
class Platform(base.CraftBaseModel):
"""A single platform entry in the platforms dictionary.
This model defines how a single value under the ``platforms`` key works for a project.
"""
build_on: UniqueList[str] | str = pydantic.Field(
min_length=1,
examples=[
"amd64",
["arm64", "riscv64"],
],
)
"""Architectures to build on.
This list must contain unique values. If this field is a string containing the
build-on architecture, it will be parsed at runtime into a single-entry list.
"""
build_for: SingleEntryList[str] | str = pydantic.Field(
examples=[
"amd64",
["riscv64"],
]
)
"""Target architecture for the build.
If this field is a string containing the target architecture, it will be parsed at
runtime into a single-entry list. ``build-for`` is optional if the name of the
platform is a valid ``build-for`` entry, but the model will contain the correct
value.
"""
@pydantic.field_validator("build_on", "build_for", mode="before")
@classmethod
def _vectorise_architectures(cls, values: str | list[str]) -> list[str]:
"""Convert string build-on and build-for to lists."""
if isinstance(values, str):
return [values]
return values
[docs]
@pydantic.field_validator("build_on", "build_for", mode="after")
@classmethod
def _validate_architectures(cls, values: list[str]) -> list[str]:
"""Validate the architecture entries.
Entries must be a valid debian architecture or 'all'. Architectures may
be preceded by an optional base prefix formatted as '[<base>:]<arch>'.
:raises ValueError: If any of the bases or architectures are not valid.
"""
[craft_platforms.parse_base_and_architecture(arch) for arch in values]
return values
[docs]
@pydantic.field_validator("build_on", mode="after")
@classmethod
def _validate_build_on_real_arch(cls, values: list[str]) -> list[str]:
"""Validate that we must build on a real architecture."""
for value in values:
_, arch = craft_platforms.parse_base_and_architecture(value)
if arch == "all":
raise ValueError("'all' cannot be used for 'build-on'")
return values
@pydantic.model_validator(mode="before")
@classmethod
def _validate_platform_set(
cls, values: Mapping[str, list[str]]
) -> Mapping[str, Any]:
"""If build_for is provided, then build_on must also be."""
# "if values" here ensures that a None value errors properly.
if values and values.get("build_for") and not values.get("build_on"):
raise errors.CraftValidationError(
"'build-for' expects 'build-on' to also be provided."
)
return values
@classmethod
def from_platforms(cls, platforms: craft_platforms.Platforms) -> dict[str, Self]:
"""Create a dictionary of these objects from craft_platforms PlatformDicts."""
result: dict[str, Self] = {}
for key, value in platforms.items():
name = str(key)
platform = (
{"build-on": [name], "build-for": [name]} if value is None else value
)
result[name] = cls.model_validate(platform)
return result
PT = TypeVar("PT", bound=Platform)
[docs]
class GenericPlatformsDict(dict[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.
"""
_shorthand_keys: ClassVar[type[enum.Enum] | Iterable[enum.Enum]] = (
craft_platforms.DebianArchitecture
)
"""This class variable dictates what keys make valid shorthand names.
Valid shorthand names may be used as keys with a null value, being placed into both
``build-on`` and ``build-for``, as in:
.. code-block:: yaml
platforms:
amd64:
or may contain only a ``build-on`` key with an inferred ``build-for``, as in:
.. code-block:: yaml
platforms:
riscv64:
build-on: [amd64, riscv64]
Platform names that are not valid shorthand must contain both a ``build-on`` and
a ``build-for`` key.
"""
[docs]
@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(
PlatformNameAdapter.core_schema, value_type.__pydantic_core_schema__
)
[docs]
@classmethod
def __get_pydantic_json_schema__(
cls, core_schema: cs.CoreSchema, handler: pydantic.GetJsonSchemaHandler
) -> pydantic.json_schema.JsonSchemaValue:
"""Get the JSON schema for this PlatformsDict.
This method converts the pydantic core schema into a JSON schema dictionary.
The default implementation adds the possible values from :attr:`_shorthand_keys`
as keys that do not require a value or can have ``build-on`` values without
declaring a ``build-for`` value.
"""
json_schema = handler(core_schema)
json_schema = handler.resolve_ref_schema(json_schema)
arch_pattern_values = "|".join(
re.escape(key.value) for key in cls._shorthand_keys
)
arch_platform_schema = cs.typed_dict_schema(
{
"build-on": cs.typed_dict_field(
cs.union_schema([cs.str_schema(), cs.list_schema(cs.str_schema())]),
required=True,
),
"build-for": cs.typed_dict_field(
cs.union_schema([cs.str_schema(), cs.list_schema(cs.str_schema())]),
required=False,
),
},
total=True,
)
json_schema["patternProperties"] = {
f"({arch_pattern_values})": handler(
cs.union_schema([cs.none_schema(), arch_platform_schema])
)
}
return json_schema
[docs]
class PlatformsDict(GenericPlatformsDict[Platform]):
"""A dictionary with a Pydantic schema for the general platforms key.
This is the default Pydantic model for the ``platforms`` dictionary on a ``Project``
model. Most applications will simply use this without modification, as it provides
the default implementation. An application that uses the generic
:ref:`platform-schema` may use this directly. Applications that need their own
``Platform`` model can override :py:class:`.GenericPlatformsDict`.
"""