diff --git a/craft_application/application.py b/craft_application/application.py
index 6636adf1..89471684 100644
--- a/craft_application/application.py
+++ b/craft_application/application.py
@@ -658,7 +658,7 @@ def _expand_environment(self, yaml_data: dict[str, Any], build_for: str) -> None
info = craft_parts.ProjectInfo(
application_name=self.app.name, # not used in environment expansion
cache_dir=pathlib.Path(), # not used in environment expansion
- arch=util.convert_architecture_deb_to_platform(build_for_arch),
+ arch=build_for_arch,
project_name=yaml_data.get("name", ""),
project_dirs=project_dirs,
project_vars=environment_vars,
@@ -682,7 +682,7 @@ def _get_project_vars(self, yaml_data: dict[str, Any]) -> dict[str, str]:
"""Return a dict with project variables to be expanded."""
pvars: dict[str, str] = {}
for var in self.app.project_variables:
- pvars[var] = yaml_data.get(var, "")
+ pvars[var] = str(yaml_data.get(var, ""))
return pvars
def _set_global_environment(self, info: craft_parts.ProjectInfo) -> None:
diff --git a/craft_application/models/__init__.py b/craft_application/models/__init__.py
index cb5665ee..0646a81f 100644
--- a/craft_application/models/__init__.py
+++ b/craft_application/models/__init__.py
@@ -15,13 +15,14 @@
# with this program. If not, see .
"""General-purpose models for *craft applications."""
-from craft_application.models.base import CraftBaseConfig, CraftBaseModel
+from craft_application.models.base import CraftBaseModel
from craft_application.models.constraints import (
ProjectName,
ProjectTitle,
SummaryStr,
UniqueStrList,
VersionStr,
+ get_validator_by_regex,
)
from craft_application.models.grammar import (
GrammarAwareProject,
@@ -43,7 +44,6 @@
"BuildInfo",
"DEVEL_BASE_INFOS",
"DEVEL_BASE_WARNING",
- "CraftBaseConfig",
"CraftBaseModel",
"get_grammar_aware_part_keywords",
"GrammarAwareProject",
@@ -55,4 +55,5 @@
"SummaryStr",
"UniqueStrList",
"VersionStr",
+ "get_validator_by_regex",
]
diff --git a/craft_application/models/base.py b/craft_application/models/base.py
index da7932e1..08e30438 100644
--- a/craft_application/models/base.py
+++ b/craft_application/models/base.py
@@ -30,24 +30,19 @@ def alias_generator(s: str) -> str:
return s.replace("_", "-")
-class CraftBaseConfig(pydantic.BaseConfig): # pylint: disable=too-few-public-methods
- """Pydantic model configuration."""
-
- validate_assignment = True
- extra = pydantic.Extra.forbid
- allow_mutation = True
- allow_population_by_field_name = True
- alias_generator = alias_generator
-
-
class CraftBaseModel(pydantic.BaseModel):
"""Base model for craft-application classes."""
- Config = CraftBaseConfig
+ model_config = pydantic.ConfigDict(
+ validate_assignment=True,
+ extra="forbid",
+ populate_by_name=True,
+ alias_generator=alias_generator,
+ )
def marshal(self) -> dict[str, str | list[str] | dict[str, Any]]:
"""Convert to a dictionary."""
- return self.dict(by_alias=True, exclude_unset=True)
+ return self.model_dump(mode="json", by_alias=True, exclude_unset=True)
@classmethod
def unmarshal(cls, data: dict[str, Any]) -> Self:
@@ -62,7 +57,7 @@ def unmarshal(cls, data: dict[str, Any]) -> Self:
if not isinstance(data, dict): # pyright: ignore[reportUnnecessaryIsInstance]
raise TypeError("Project data is not a dictionary")
- return cls(**data)
+ return cls.model_validate(data)
@classmethod
def from_yaml_file(cls, path: pathlib.Path) -> Self:
@@ -94,6 +89,10 @@ def to_yaml_file(self, path: pathlib.Path) -> None:
with path.open("wt") as file:
util.dump_yaml(self.marshal(), stream=file)
+ def to_yaml_string(self) -> str:
+ """Return this model as a YAML string."""
+ return util.dump_yaml(self.marshal())
+
@classmethod
def transform_pydantic_error(cls, error: pydantic.ValidationError) -> None:
"""Modify, in-place, validation errors generated by Pydantic.
diff --git a/craft_application/models/constraints.py b/craft_application/models/constraints.py
index 2dd429b5..edec9a5f 100644
--- a/craft_application/models/constraints.py
+++ b/craft_application/models/constraints.py
@@ -14,78 +14,230 @@
# You should have received a copy of the GNU Lesser General Public License along
# with this program. If not, see .
"""Constrained pydantic types for *craft applications."""
+
+import collections
import re
+from collections.abc import Callable
+from typing import Annotated, Literal, TypeVar
-from pydantic import ConstrainedList, ConstrainedStr, StrictStr
+import license_expression # type: ignore[import]
+import pydantic
+from pydantic_core import PydanticCustomError
+T = TypeVar("T")
+Tv = TypeVar("Tv")
-class ProjectName(ConstrainedStr):
- """A constrained string for describing a project name.
- Project name rules:
- * Valid characters are lower-case ASCII letters, numerals and hyphens.
- * Must contain at least one letter
- * May not start or end with a hyphen
- * May not have two hyphens in a row
- """
+def _validate_list_is_unique(value: list[T]) -> list[T]:
+ value_set = set(value)
+ if len(value_set) == len(value):
+ return value
+ dupes = [item for item, count in collections.Counter(value).items() if count > 1]
+ raise ValueError(f"duplicate values in list: {dupes}")
+
- min_length = 1
- max_length = 40
- strict = True
- strip_whitespace = True
- regex = re.compile(r"^([a-z0-9][a-z0-9-]?)*[a-z]+([a-z0-9-]?[a-z0-9])*$")
+def get_validator_by_regex(
+ regex: re.Pattern[str], error_msg: str
+) -> Callable[[str], str]:
+ """Get a string validator by regular expression with a known error message.
+ This allows providing better error messages for regex-based validation than the
+ standard message provided by pydantic. Simply place the result of this function in
+ a BeforeValidator attached to your annotated type.
+ :param regex: a compiled regular expression on a string.
+ :param error_msg: The error message to raise if the value is invalid.
+ :returns: A validator function ready to be used by pydantic.BeforeValidator
+ """
+
+ def validate(value: str) -> str:
+ """Validate the given string with the outer regex, raising the error message.
+
+ :param value: a string to be validated
+ :returns: that same string if it's valid.
+ :raises: ValueError if the string is invalid.
+ """
+ value = str(value)
+ if not regex.match(value):
+ raise ValueError(error_msg)
+ return value
+
+ return validate
+
+
+UniqueList = Annotated[
+ list[T],
+ pydantic.AfterValidator(_validate_list_is_unique),
+ pydantic.Field(json_schema_extra={"uniqueItems": True}),
+]
+
+SingleEntryList = Annotated[
+ list[T],
+ pydantic.Field(min_length=1, max_length=1),
+]
+
+SingleEntryDict = Annotated[
+ dict[T, Tv],
+ pydantic.Field(min_length=1, max_length=1),
+]
+
+_PROJECT_NAME_DESCRIPTION = """\
+The name of this project. This is used when uploading, publishing, or installing.
+
+Project name rules:
+* Valid characters are lower-case ASCII letters, numerals and hyphens.
+* Must contain at least one letter
+* May not start or end with a hyphen
+* May not have two hyphens in a row
+"""
+
+_PROJECT_NAME_REGEX = r"^([a-z0-9][a-z0-9-]?)*[a-z]+([a-z0-9-]?[a-z0-9])*$"
+_PROJECT_NAME_COMPILED_REGEX = re.compile(_PROJECT_NAME_REGEX)
MESSAGE_INVALID_NAME = (
"invalid name: Names can only use ASCII lowercase letters, numbers, and hyphens. "
"They must have at least one letter, may not start or end with a hyphen, "
"and may not have two hyphens in a row."
)
-
-class ProjectTitle(StrictStr):
- """A constrained string for describing a project title."""
-
- min_length = 2
- max_length = 40
- strip_whitespace = True
-
-
-class SummaryStr(ConstrainedStr):
- """A constrained string for a short summary of a project."""
-
- strip_whitespace = True
- max_length = 78
-
-
-class UniqueStrList(ConstrainedList):
- """A list of strings, each of which must be unique.
-
- This is roughly equivalent to an ordered set of strings, but implemented with a list.
- """
-
- __args__ = (str,)
- item_type = str
- unique_items = True
-
-
-class VersionStr(ConstrainedStr):
- """A valid version string.
-
- Should match snapd valid versions:
- https://github.com/snapcore/snapd/blame/a39482ead58bf06cddbc0d3ffad3c17dfcf39913/snap/validate.go#L96
- Applications may use a different set of constraints if necessary, but
- ideally they will retain this same constraint.
- """
-
- max_length = 32
- strip_whitespace = True
- regex = re.compile(r"^[a-zA-Z0-9](?:[a-zA-Z0-9:.+~-]*[a-zA-Z0-9+~])?$")
-
-
+ProjectName = Annotated[
+ str,
+ pydantic.BeforeValidator(
+ get_validator_by_regex(_PROJECT_NAME_COMPILED_REGEX, MESSAGE_INVALID_NAME)
+ ),
+ pydantic.Field(
+ min_length=1,
+ max_length=40,
+ strict=True,
+ pattern=_PROJECT_NAME_REGEX,
+ description=_PROJECT_NAME_DESCRIPTION,
+ title="Project Name",
+ examples=[
+ "ubuntu",
+ "jupyterlab-desktop",
+ "lxd",
+ "digikam",
+ "kafka",
+ "mysql-router-k8s",
+ ],
+ ),
+]
+
+
+ProjectTitle = Annotated[
+ str,
+ pydantic.Field(
+ min_length=2,
+ max_length=40,
+ title="Title",
+ description="A human-readable title.",
+ examples=[
+ "Ubuntu Linux",
+ "Jupyter Lab Desktop",
+ "LXD",
+ "DigiKam",
+ "Apache Kafka",
+ "MySQL Router K8s charm",
+ ],
+ ),
+]
+
+SummaryStr = Annotated[
+ str,
+ pydantic.Field(
+ max_length=78,
+ title="Summary",
+ description="A short description of your project.",
+ examples=[
+ "Linux for Human Beings",
+ "The cross-platform desktop application for JupyterLab",
+ "Container and VM manager",
+ "Photo Management Program",
+ "Charm for routing MySQL databases in Kubernetes",
+ "An open-source event streaming platform for high-performance data pipelines",
+ ],
+ ),
+]
+
+UniqueStrList = UniqueList[str]
+
+_VERSION_STR_REGEX = r"^[a-zA-Z0-9](?:[a-zA-Z0-9:.+~-]*[a-zA-Z0-9+~])?$"
+_VERSION_STR_COMPILED_REGEX = re.compile(_VERSION_STR_REGEX)
MESSAGE_INVALID_VERSION = (
"invalid version: Valid versions consist of upper- and lower-case "
"alphanumeric characters, as well as periods, colons, plus signs, tildes, "
"and hyphens. They cannot begin with a period, colon, plus sign, tilde, or "
"hyphen. They cannot end with a period, colon, or hyphen."
)
+
+VersionStr = Annotated[
+ str,
+ pydantic.BeforeValidator(
+ get_validator_by_regex(_VERSION_STR_COMPILED_REGEX, MESSAGE_INVALID_VERSION)
+ ),
+ pydantic.Field(
+ max_length=32,
+ pattern=_VERSION_STR_REGEX,
+ strict=False,
+ coerce_numbers_to_str=True,
+ title="version string",
+ description="A string containing the version of the project",
+ examples=[
+ "0.1",
+ "1.0.0",
+ "v1.0.0",
+ "24.04",
+ ],
+ ),
+]
+"""A valid version string.
+
+Should match snapd valid versions:
+https://github.com/snapcore/snapd/blame/a39482ead58bf06cddbc0d3ffad3c17dfcf39913/snap/validate.go#L96
+Applications may use a different set of constraints if necessary, but
+ideally they will retain this same constraint.
+"""
+
+
+def _parse_spdx_license(value: str) -> license_expression.LicenseExpression:
+ licensing = license_expression.get_spdx_licensing()
+ if (
+ lic := licensing.parse( # pyright: ignore[reportUnknownMemberType]
+ value, validate=True
+ )
+ ) is not None:
+ return lic
+ raise ValueError
+
+
+def _validate_spdx_license(value: str) -> str:
+ """Ensure the provided licence is a valid SPDX licence."""
+ try:
+ _ = _parse_spdx_license(value)
+ except (license_expression.ExpressionError, ValueError):
+ raise PydanticCustomError(
+ "not_spdx_license",
+ "License '{wrong_license}' not valid. It must be in SPDX format.",
+ {"wrong_license": value},
+ ) from None
+ else:
+ return value
+
+
+SpdxLicenseStr = Annotated[
+ str,
+ pydantic.AfterValidator(_validate_spdx_license),
+ pydantic.Field(
+ title="License",
+ description="SPDX license string.",
+ examples=[
+ "GPL-3.0",
+ "MIT",
+ "LGPL-3.0-or-later",
+ "GPL-3.0+ and MIT",
+ ],
+ ),
+]
+
+ProprietaryLicenseStr = Literal["proprietary"]
+
+LicenseStr = SpdxLicenseStr | ProprietaryLicenseStr
diff --git a/craft_application/models/grammar.py b/craft_application/models/grammar.py
index d13f8f0e..794d0f8f 100644
--- a/craft_application/models/grammar.py
+++ b/craft_application/models/grammar.py
@@ -18,65 +18,62 @@
from typing import Any
import pydantic
-from craft_grammar.models import ( # type: ignore[import-untyped]
- GrammarBool,
- GrammarDict,
- GrammarDictList,
- GrammarInt,
- GrammarSingleEntryDictList,
- GrammarStr,
- GrammarStrList,
-)
+from craft_grammar.models import Grammar # type: ignore[import-untyped]
+from pydantic import ConfigDict
from craft_application.models.base import alias_generator
+from craft_application.models.constraints import SingleEntryDict
class _GrammarAwareModel(pydantic.BaseModel):
- class Config:
- """Default configuration for grammar-aware models."""
-
- validate_assignment = True
- extra = pydantic.Extra.allow # verify only grammar-aware parts
- alias_generator = alias_generator
- allow_population_by_field_name = True
+ model_config = ConfigDict(
+ validate_assignment=True,
+ extra="allow",
+ alias_generator=alias_generator,
+ populate_by_name=True,
+ )
class _GrammarAwarePart(_GrammarAwareModel):
- plugin: GrammarStr | None
- source: GrammarStr | None
- source_checksum: GrammarStr | None
- source_branch: GrammarStr | None
- source_commit: GrammarStr | None
- source_depth: GrammarInt | None
- source_subdir: GrammarStr | None
- source_submodules: GrammarStrList | None
- source_tag: GrammarStr | None
- source_type: GrammarStr | None
- disable_parallel: GrammarBool | None
- after: GrammarStrList | None
- overlay_packages: GrammarStrList | None
- stage_snaps: GrammarStrList | None
- stage_packages: GrammarStrList | None
- build_snaps: GrammarStrList | None
- build_packages: GrammarStrList | None
- build_environment: GrammarSingleEntryDictList | None
- build_attributes: GrammarStrList | None
- organize_files: GrammarDict | None = pydantic.Field(alias="organize")
- overlay_files: GrammarStrList | None = pydantic.Field(alias="overlay")
- stage_files: GrammarStrList | None = pydantic.Field(alias="stage")
- prime_files: GrammarStrList | None = pydantic.Field(alias="prime")
- override_pull: GrammarStr | None
- overlay_script: GrammarStr | None
- override_build: GrammarStr | None
- override_stage: GrammarStr | None
- override_prime: GrammarStr | None
- permissions: GrammarDictList | None
- parse_info: GrammarStrList | None
+ plugin: Grammar[str] | None = None
+ source: Grammar[str] | None = None
+ source_checksum: Grammar[str] | None = None
+ source_branch: Grammar[str] | None = None
+ source_commit: Grammar[str] | None = None
+ source_depth: Grammar[int] | None = None
+ source_subdir: Grammar[str] | None = None
+ source_submodules: Grammar[list[str]] | None = None
+ source_tag: Grammar[str] | None = None
+ source_type: Grammar[str] | None = None
+ disable_parallel: Grammar[bool] | None = None
+ after: Grammar[list[str]] | None = None
+ overlay_packages: Grammar[list[str]] | None = None
+ stage_snaps: Grammar[list[str]] | None = None
+ stage_packages: Grammar[list[str]] | None = None
+ build_snaps: Grammar[list[str]] | None = None
+ build_packages: Grammar[list[str]] | None = None
+ build_environment: Grammar[list[SingleEntryDict[str, str]]] | None = None
+ build_attributes: Grammar[list[str]] | None = None
+ organize_files: Grammar[dict[str, str]] | None = pydantic.Field(
+ default=None, alias="organize"
+ )
+ overlay_files: Grammar[list[str]] | None = pydantic.Field(None, alias="overlay")
+ stage_files: Grammar[list[str]] | None = pydantic.Field(None, alias="stage")
+ prime_files: Grammar[list[str]] | None = pydantic.Field(None, alias="prime")
+ override_pull: Grammar[str] | None = None
+ overlay_script: Grammar[str] | None = None
+ override_build: Grammar[str] | None = None
+ override_stage: Grammar[str] | None = None
+ override_prime: Grammar[str] | None = None
+ permissions: Grammar[list[dict[str, int | str]]] | None = None
+ parse_info: Grammar[list[str]] | None = None
def get_grammar_aware_part_keywords() -> list[str]:
"""Return all supported grammar keywords for a part."""
- keywords: list[str] = [item.alias for item in _GrammarAwarePart.__fields__.values()]
+ keywords: list[str] = [
+ item.alias or name for name, item in _GrammarAwarePart.model_fields.items()
+ ]
return keywords
@@ -85,9 +82,8 @@ class GrammarAwareProject(_GrammarAwareModel):
parts: "dict[str, _GrammarAwarePart]"
- @pydantic.root_validator( # pyright: ignore[reportUntypedFunctionDecorator,reportUnknownMemberType]
- pre=True
- )
+ @pydantic.model_validator(mode="before")
+ @classmethod
def _ensure_parts(cls, data: dict[str, Any]) -> dict[str, Any]:
"""Ensure that the "parts" dictionary exists.
@@ -101,4 +97,4 @@ def _ensure_parts(cls, data: dict[str, Any]) -> dict[str, Any]:
@classmethod
def validate_grammar(cls, data: dict[str, Any]) -> None:
"""Ensure grammar-enabled entries are syntactically valid."""
- cls(**data)
+ cls.model_validate(data)
diff --git a/craft_application/models/metadata.py b/craft_application/models/metadata.py
index 20abf84a..8a01f7b0 100644
--- a/craft_application/models/metadata.py
+++ b/craft_application/models/metadata.py
@@ -15,20 +15,20 @@
# with this program. If not, see .
"""Base project metadata model."""
import pydantic
-from typing_extensions import override
-from craft_application.models.base import CraftBaseConfig, CraftBaseModel
+from craft_application.models import base
-class BaseMetadata(CraftBaseModel):
+class BaseMetadata(base.CraftBaseModel):
"""Project metadata base model.
This model is the basis for output metadata files that are stored in
the application's output.
"""
- @override
- class Config(CraftBaseConfig):
- """Allows writing of unknown fields."""
-
- extra = pydantic.Extra.allow
+ model_config = pydantic.ConfigDict(
+ validate_assignment=True,
+ extra="allow",
+ populate_by_name=True,
+ alias_generator=base.alias_generator,
+ )
diff --git a/craft_application/models/project.py b/craft_application/models/project.py
index bfbb3de0..550f00e1 100644
--- a/craft_application/models/project.py
+++ b/craft_application/models/project.py
@@ -20,7 +20,7 @@
import abc
import dataclasses
from collections.abc import Mapping
-from typing import Any
+from typing import Annotated, Any
import craft_parts
import craft_providers.bases
@@ -28,17 +28,15 @@
from craft_cli import emit
from craft_providers import bases
from craft_providers.errors import BaseConfigurationError
-from pydantic import AnyUrl
-from typing_extensions import override
from craft_application import errors
-from craft_application.models.base import CraftBaseConfig, CraftBaseModel
+from craft_application.models import base
from craft_application.models.constraints import (
- MESSAGE_INVALID_NAME,
- MESSAGE_INVALID_VERSION,
ProjectName,
ProjectTitle,
+ SingleEntryList,
SummaryStr,
+ UniqueList,
UniqueStrList,
VersionStr,
)
@@ -89,24 +87,14 @@ class BuildInfo:
"""The base to build on."""
-class BuildPlannerConfig(CraftBaseConfig):
- """Config for BuildProjects."""
-
- extra = pydantic.Extra.ignore
- """The BuildPlanner model uses attributes from the project yaml."""
-
-
-class Platform(CraftBaseModel):
+class Platform(base.CraftBaseModel):
"""Project platform definition."""
- build_on: list[str] | None = pydantic.Field(min_items=1, unique_items=True)
- build_for: list[str] | None = pydantic.Field(
- min_items=1, max_items=1, unique_items=True
- )
+ build_on: UniqueList[str] | None = pydantic.Field(min_length=1)
+ build_for: SingleEntryList[str] | None = None
- @pydantic.validator( # pyright: ignore[reportUnknownMemberType,reportUntypedFunctionDecorator]
- "build_on", "build_for"
- )
+ @pydantic.field_validator("build_on", "build_for", mode="after")
+ @classmethod
def _validate_architectures(cls, values: list[str]) -> list[str]:
"""Validate the architecture entries."""
for architecture in values:
@@ -118,21 +106,21 @@ def _validate_architectures(cls, values: list[str]) -> list[str]:
return values
- @pydantic.root_validator( # pyright: ignore[reportUntypedFunctionDecorator,reportUnknownMemberType]
- skip_on_failure=True
- )
+ @pydantic.model_validator(mode="before")
@classmethod
- def _validate_platform_set(cls, values: Mapping[str, Any]) -> Mapping[str, Any]:
+ 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 not values.get("build_on") and values.get("build_for"):
+ if values.get("build_for") and not values.get("build_on"):
raise errors.CraftValidationError(
- "'build_for' expects 'build_on' to also be provided."
+ "'build-for' expects 'build-on' to also be provided."
)
return values
-def _populate_platforms(platforms: dict[str, Platform]) -> dict[str, Platform]:
+def _populate_platforms(platforms: dict[str, Any]) -> dict[str, Any]:
"""Populate empty platform entries.
:param platforms: The platform data.
@@ -142,32 +130,36 @@ def _populate_platforms(platforms: dict[str, Platform]) -> dict[str, Platform]:
for platform_label, platform in platforms.items():
if not platform:
# populate "empty" platforms entries from the platform's name
- platforms[platform_label] = Platform(
- build_on=[platform_label], build_for=[platform_label]
- )
+ platforms[platform_label] = {
+ "build-on": [platform_label],
+ "build-for": [platform_label],
+ }
return platforms
-class BuildPlanner(CraftBaseModel, metaclass=abc.ABCMeta):
+class BuildPlanner(base.CraftBaseModel, metaclass=abc.ABCMeta):
"""The BuildPlanner obtains a build plan for the project."""
- platforms: dict[str, Platform]
- base: str | None
- build_base: str | None
+ model_config = pydantic.ConfigDict(
+ validate_assignment=True,
+ extra="ignore",
+ populate_by_name=True,
+ alias_generator=base.alias_generator,
+ )
- Config = BuildPlannerConfig
+ platforms: dict[str, Platform]
+ base: str | None = None
+ build_base: str | None = None
- @pydantic.validator( # pyright: ignore[reportUnknownMemberType,reportUntypedFunctionDecorator]
- "platforms", pre=True
- )
- def _populate_platforms(cls, platforms: dict[str, Platform]) -> dict[str, Platform]:
+ @pydantic.field_validator("platforms", mode="before")
+ @classmethod
+ def _populate_platforms(cls, platforms: dict[str, Any]) -> dict[str, Any]:
"""Populate empty platform entries."""
return _populate_platforms(platforms)
- @pydantic.validator( # pyright: ignore[reportUnknownMemberType,reportUntypedFunctionDecorator]
- "platforms",
- )
+ @pydantic.field_validator("platforms", mode="after")
+ @classmethod
def _validate_platforms_all_keyword(
cls, platforms: dict[str, Any]
) -> dict[str, Any]:
@@ -231,41 +223,62 @@ def get_build_plan(self) -> list[BuildInfo]:
return build_infos
-class Project(CraftBaseModel):
+def _validate_package_repository(repository: dict[str, Any]) -> dict[str, Any]:
+ """Validate a package repository with lazy loading of craft-archives.
+
+ :param repository: a dictionary representing a package repository.
+ :returns: That same dictionary, if valid.
+ :raises: ValueError if the repository is not valid.
+ """
+ # This check is not always used, import it here to avoid unnecessary
+ from craft_archives import repo # type: ignore[import-untyped]
+
+ repo.validate_repository(repository)
+ return repository
+
+
+def _validate_part(part: dict[str, Any]) -> dict[str, Any]:
+ """Verify each part (craft-parts will re-validate this)."""
+ craft_parts.validate_part(part)
+ return part
+
+
+class Project(base.CraftBaseModel):
"""Craft Application project definition."""
name: ProjectName
- title: ProjectTitle | None
- version: VersionStr | None
- summary: SummaryStr | None
- description: str | None
+ title: ProjectTitle | None = None
+ version: VersionStr | None = None
+ summary: SummaryStr | None = None
+ description: str | None = None
- base: Any | None = None
- build_base: Any | None = None
+ base: str | None = None
+ build_base: str | None = None
platforms: dict[str, Platform]
- contact: str | UniqueStrList | None
- issues: str | UniqueStrList | None
- source_code: AnyUrl | None
- license: str | None
+ contact: str | UniqueStrList | None = None
+ issues: str | UniqueStrList | None = None
+ source_code: pydantic.AnyUrl | None = None
+ license: str | None = None
- adopt_info: str | None
+ adopt_info: str | None = None
- parts: dict[str, dict[str, Any]] # parts are handled by craft-parts
+ parts: dict[ # parts are handled by craft-parts
+ str,
+ Annotated[dict[str, Any], pydantic.BeforeValidator(_validate_part)],
+ ]
- package_repositories: list[dict[str, Any]] | None
-
- @pydantic.validator( # pyright: ignore[reportUnknownMemberType,reportUntypedFunctionDecorator]
- "parts", each_item=True
- )
- def _validate_parts(cls, item: dict[str, Any]) -> dict[str, Any]:
- """Verify each part (craft-parts will re-validate this)."""
- craft_parts.validate_part(item)
- return item
+ package_repositories: (
+ list[
+ Annotated[
+ dict[str, Any], pydantic.AfterValidator(_validate_package_repository)
+ ]
+ ]
+ | None
+ ) = None
- @pydantic.validator( # pyright: ignore[reportUnknownMemberType,reportUntypedFunctionDecorator]
- "platforms", pre=True
- )
+ @pydantic.field_validator("platforms", mode="before")
+ @classmethod
def _populate_platforms(cls, platforms: dict[str, Platform]) -> dict[str, Platform]:
"""Populate empty platform entries."""
return _populate_platforms(platforms)
@@ -296,28 +309,30 @@ def _providers_base(cls, base: str) -> craft_providers.bases.BaseAlias | None:
"""
try:
name, channel = base.split("@")
- return craft_providers.bases.get_base_alias((name, channel))
+ return craft_providers.bases.get_base_alias(
+ craft_providers.bases.BaseName(name, channel)
+ )
except (ValueError, BaseConfigurationError) as err:
raise ValueError(f"Unknown base {base!r}") from err
- @pydantic.root_validator( # pyright: ignore[reportUnknownMemberType,reportUntypedFunctionDecorator]
- pre=False
- )
- def _validate_devel(cls, values: dict[str, Any]) -> dict[str, Any]:
+ @pydantic.field_validator("build_base", mode="before")
+ @classmethod
+ def _validate_devel_base(
+ cls, build_base: str, info: pydantic.ValidationInfo
+ ) -> str:
"""Validate the build-base is 'devel' for the current devel base."""
- base = values.get("base")
+ base = info.data.get("base")
# if there is no base, do not validate the build-base
if not base:
- return values
+ return build_base
base_alias = cls._providers_base(base)
# if the base does not map to a base alias, do not validate the build-base
if not base_alias:
- return values
+ return build_base
- build_base = values.get("build_base") or base
- build_base_alias = cls._providers_base(build_base)
+ build_base_alias = cls._providers_base(build_base or base)
# warn if a devel build-base is being used, error if a devel build-base is not
# used for a devel base
@@ -330,35 +345,4 @@ def _validate_devel(cls, values: dict[str, Any]) -> dict[str, Any]:
f"A development build-base must be used when base is {base!r}"
)
- return values
-
- @override
- @classmethod
- def transform_pydantic_error(cls, error: pydantic.ValidationError) -> None:
- errors_to_messages: dict[tuple[str, str], str] = {
- ("version", "value_error.str.regex"): MESSAGE_INVALID_VERSION,
- ("name", "value_error.str.regex"): MESSAGE_INVALID_NAME,
- }
-
- CraftBaseModel.transform_pydantic_error(error)
-
- for error_dict in error.errors():
- loc_and_type = (str(error_dict["loc"][0]), error_dict["type"])
- if message := errors_to_messages.get(loc_and_type):
- # Note that unfortunately, Pydantic 1.x does not have the
- # "input" key in the error dict, so we can't put the original
- # value in the error message.
- error_dict["msg"] = message
-
- @pydantic.validator( # pyright: ignore[reportUnknownMemberType,reportUntypedFunctionDecorator]
- "package_repositories", each_item=True
- )
- def _validate_package_repositories(
- cls, repository: dict[str, Any]
- ) -> dict[str, Any]:
- # This check is not always used, import it here to avoid unnecessary
- from craft_archives import repo # type: ignore[import-untyped]
-
- repo.validate_repository(repository)
-
- return repository
+ return build_base
diff --git a/craft_application/services/lifecycle.py b/craft_application/services/lifecycle.py
index 3fd70ca8..b1498a9a 100644
--- a/craft_application/services/lifecycle.py
+++ b/craft_application/services/lifecycle.py
@@ -38,7 +38,7 @@
from craft_application import errors, models, util
from craft_application.services import base
-from craft_application.util import convert_architecture_deb_to_platform, repositories
+from craft_application.util import repositories
if TYPE_CHECKING: # pragma: no cover
from pathlib import Path
@@ -187,7 +187,7 @@ def _init_lifecycle_manager(self) -> LifecycleManager:
return LifecycleManager(
{"parts": self._project.parts},
application_name=self._app.name,
- arch=convert_architecture_deb_to_platform(build_for),
+ arch=build_for,
cache_dir=self._cache_dir,
work_dir=self._work_dir,
ignore_local_sources=self._app.source_ignore_patterns,
diff --git a/craft_application/services/provider.py b/craft_application/services/provider.py
index 38607d32..a359d930 100644
--- a/craft_application/services/provider.py
+++ b/craft_application/services/provider.py
@@ -141,7 +141,7 @@ def instance(
def get_base(
self,
- base_name: bases.BaseName | tuple[str, str],
+ base_name: bases.BaseName,
*,
instance_name: str,
**kwargs: bool | str | pathlib.Path | None,
@@ -164,7 +164,7 @@ def get_base(
# this only applies to our Buildd images (i.e.; Ubuntu)
self.packages.extend(["gpg", "dirmngr"])
return base_class(
- alias=alias, # pyright: ignore[reportArgumentType] craft-providers annotations are loose.
+ alias=alias, # type: ignore[arg-type]
compatibility_tag=f"{self._app.name}-{base_class.compatibility_tag}",
hostname=instance_name,
snaps=self.snaps,
diff --git a/craft_application/util/error_formatting.py b/craft_application/util/error_formatting.py
index 4864a274..c6fdbf5b 100644
--- a/craft_application/util/error_formatting.py
+++ b/craft_application/util/error_formatting.py
@@ -14,11 +14,12 @@
# You should have received a copy of the GNU Lesser General Public License along
# with this program. If not, see .
"""Helper utilities for formatting error messages."""
+from __future__ import annotations
+
from collections.abc import Iterable
-from typing import TYPE_CHECKING, NamedTuple
+from typing import NamedTuple
-if TYPE_CHECKING: # pragma: no cover
- from pydantic.error_wrappers import ErrorDict
+from pydantic import error_wrappers
class FieldLocationTuple(NamedTuple):
@@ -28,7 +29,7 @@ class FieldLocationTuple(NamedTuple):
location: str = "top-level"
@classmethod
- def from_str(cls, loc_str: str) -> "FieldLocationTuple":
+ def from_str(cls, loc_str: str) -> FieldLocationTuple:
"""Return split field location.
If top-level, location is returned as unquoted "top-level".
@@ -64,13 +65,13 @@ def format_pydantic_error(loc: Iterable[str | int], message: str) -> str:
return f"- extra field {field_name!r} not permitted in {location} configuration"
if message == "the list has duplicated items":
return f"- duplicate {field_name!r} entry not permitted in {location} configuration"
- if field_path == "__root__":
+ if field_path in ("__root__", ""):
return f"- {message}"
return f"- {message} (in field {field_path!r})"
def format_pydantic_errors(
- errors: "Iterable[ErrorDict]", *, file_name: str = "yaml file"
+ errors: Iterable[error_wrappers.ErrorDict], *, file_name: str = "yaml file"
) -> str:
"""Format errors.
@@ -109,6 +110,7 @@ def _format_pydantic_error_message(msg: str) -> str:
"""Format pydantic's error message field."""
# Replace shorthand "str" with "string".
msg = msg.replace("str type expected", "string type expected")
+ msg = msg.removeprefix("Value error, ")
if msg:
msg = msg[0].lower() + msg[1:]
return msg
diff --git a/craft_application/util/snap_config.py b/craft_application/util/snap_config.py
index ca36301d..98cb50d9 100644
--- a/craft_application/util/snap_config.py
+++ b/craft_application/util/snap_config.py
@@ -39,7 +39,7 @@ def is_running_from_snap(app_name: str) -> bool:
return os.getenv("SNAP_NAME") == app_name and os.getenv("SNAP") is not None
-class SnapConfig(pydantic.BaseModel, extra=pydantic.Extra.forbid):
+class SnapConfig(pydantic.BaseModel, extra="forbid"):
"""Data stored in a snap config.
:param provider: provider to use. Valid values are 'lxd' and 'multipass'.
@@ -47,9 +47,7 @@ class SnapConfig(pydantic.BaseModel, extra=pydantic.Extra.forbid):
provider: Literal["lxd", "multipass"] | None = None
- @pydantic.validator( # pyright: ignore[reportUnknownMemberType,reportUntypedFunctionDecorator]
- "provider", pre=True
- )
+ @pydantic.field_validator("provider", mode="before")
@classmethod
def normalize(cls, provider: str) -> str:
"""Normalize provider name."""
diff --git a/pyproject.toml b/pyproject.toml
index c91b7476..1990bfaa 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,15 +3,16 @@ name = "craft-application"
description = "A framework for *craft applications."
dynamic = ["version", "readme"]
dependencies = [
- "craft-archives>=1.1.3",
+ "craft-archives>=2.0.0",
"craft-cli>=2.6.0",
- "craft-grammar>=1.2.0",
- "craft-parts>=1.33.0",
- "craft-providers>=1.20.0,<2.0",
+ "craft-grammar>=2.0.0",
+ "craft-parts>=2.0.0",
+ "craft-providers>=2.0.0",
"snap-helpers>=0.4.2",
"platformdirs>=3.10",
- "pydantic>=1.10,<2.0",
- "pydantic-yaml<1.0",
+ "pydantic~=2.0",
+ "license-expression>=30.0.0",
+ # "pydantic-yaml<1.0",
# Pygit2 and libgit2 need to match versions.
# Further info: https://www.pygit2.org/install.html#version-numbers
# Minor versions of pygit2 can include breaking changes, so we need to check
@@ -88,6 +89,11 @@ requires = [
]
build-backend = "setuptools.build_meta"
+[tool]
+rye = { dev-dependencies = [
+ "pytest-xdist>=3.6.1",
+] }
+
[tool.setuptools.dynamic]
readme = {file = "README.rst"}
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index a4e9f7e3..a6ae6c48 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -23,7 +23,7 @@
import pytest
from craft_application import launchpad
from craft_application.services import provider, remotebuild
-from craft_providers import lxd, multipass
+from craft_providers import bases, lxd, multipass
def pytest_configure(config: pytest.Config):
@@ -85,3 +85,10 @@ def snap_safe_tmp_path():
dir=directory or pathlib.Path.home(),
) as temp_dir:
yield pathlib.Path(temp_dir)
+
+
+@pytest.fixture()
+def pretend_jammy(mocker) -> None:
+ """Pretend we're running on jammy. Used for tests that use destructive mode."""
+ fake_host = bases.BaseName(name="ubuntu", version="22.04")
+ mocker.patch("craft_application.util.get_host_base", return_value=fake_host)
diff --git a/tests/integration/services/test_lifecycle.py b/tests/integration/services/test_lifecycle.py
index f0ef346f..c14455d3 100644
--- a/tests/integration/services/test_lifecycle.py
+++ b/tests/integration/services/test_lifecycle.py
@@ -18,8 +18,6 @@
import textwrap
import craft_cli
-import craft_parts
-import craft_parts.overlays
import pytest
import pytest_check
from craft_application.services.lifecycle import LifecycleService
@@ -110,9 +108,9 @@ def test_package_repositories_in_overlay(
# Mock overlay-related calls that need root; we won't be actually installing
# any packages, just checking that the repositories are correctly installed
# in the overlay.
- mocker.patch.object(craft_parts.overlays.OverlayManager, "refresh_packages_list") # type: ignore[reportPrivateImportUsage]
- mocker.patch.object(craft_parts.overlays.OverlayManager, "download_packages") # type: ignore[reportPrivateImportUsage]
- mocker.patch.object(craft_parts.overlays.OverlayManager, "install_packages") # type: ignore[reportPrivateImportUsage]
+ mocker.patch("craft_parts.overlays.OverlayManager.refresh_packages_list")
+ mocker.patch("craft_parts.overlays.OverlayManager.download_packages")
+ mocker.patch("craft_parts.overlays.OverlayManager.install_packages")
mocker.patch.object(os, "geteuid", return_value=0)
parts = {
@@ -165,6 +163,8 @@ def test_package_repositories_in_overlay(
assert overlay_apt.is_dir()
# Checking that the files are present should be enough
- assert (overlay_apt / "keyrings/craft-9BE21867.gpg").is_file()
- assert (overlay_apt / "sources.list.d/craft-ppa-mozillateam_ppa.sources").is_file()
- assert (overlay_apt / "preferences.d/craft-archives").is_file()
+ pytest_check.is_true((overlay_apt / "keyrings/craft-9BE21867.gpg").is_file())
+ pytest_check.is_true(
+ (overlay_apt / "sources.list.d/craft-ppa-mozillateam_ppa.sources").is_file()
+ )
+ pytest_check.is_true((overlay_apt / "preferences.d/craft-archives").is_file())
diff --git a/tests/integration/services/test_provider.py b/tests/integration/services/test_provider.py
index 0f77c28d..68f5c85f 100644
--- a/tests/integration/services/test_provider.py
+++ b/tests/integration/services/test_provider.py
@@ -1,6 +1,6 @@
# This file is part of craft-application.
#
-# Copyright 2023 Canonical Ltd.
+# Copyright 2023-2024 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
@@ -27,9 +27,13 @@
@pytest.mark.parametrize(
"base_name",
[
- # Skipping Oracular test for now; see
- # https://github.com/canonical/craft-providers/issues/593
- pytest.param(("ubuntu", "24.10"), id="ubuntu_latest", marks=pytest.mark.skip),
+ pytest.param(
+ ("ubuntu", "24.10"),
+ id="ubuntu_latest",
+ marks=pytest.mark.skip(
+ reason="Skipping Oracular test for now; see https://github.com/canonical/craft-providers/issues/593"
+ ),
+ ),
pytest.param(("ubuntu", "24.04"), id="ubuntu_lts"),
pytest.param(("ubuntu", "22.04"), id="ubuntu_old_lts"),
],
diff --git a/tests/integration/test_application.py b/tests/integration/test_application.py
index f84893d8..7da91574 100644
--- a/tests/integration/test_application.py
+++ b/tests/integration/test_application.py
@@ -123,6 +123,7 @@ def test_special_inputs(capsys, monkeypatch, app, argv, stdout, stderr, exit_cod
pytest_check.equal(captured.err, stderr, "stderr does not match")
+@pytest.mark.usefixtures("pretend_jammy")
@pytest.mark.parametrize("project", (d.name for d in VALID_PROJECTS_DIR.iterdir()))
def test_project_managed(capsys, monkeypatch, tmp_path, project, create_app):
monkeypatch.setenv("CRAFT_DEBUG", "1")
@@ -144,7 +145,7 @@ def test_project_managed(capsys, monkeypatch, tmp_path, project, create_app):
)
-@pytest.mark.usefixtures("full_build_plan")
+@pytest.mark.usefixtures("full_build_plan", "pretend_jammy")
@pytest.mark.parametrize("project", (d.name for d in VALID_PROJECTS_DIR.iterdir()))
def test_project_destructive(
capsys,
@@ -365,6 +366,7 @@ def _inner():
return _inner
+@pytest.mark.usefixtures("pretend_jammy")
@pytest.mark.enable_features("build_secrets")
def test_build_secrets_destructive(
monkeypatch, setup_secrets_project, check_secrets_output
@@ -381,6 +383,7 @@ def test_build_secrets_destructive(
check_secrets_output()
+@pytest.mark.usefixtures("pretend_jammy")
@pytest.mark.enable_features("build_secrets")
def test_build_secrets_managed(
monkeypatch, tmp_path, setup_secrets_project, check_secrets_output
@@ -406,6 +409,7 @@ def test_build_secrets_managed(
check_secrets_output()
+@pytest.mark.usefixtures("pretend_jammy")
def test_lifecycle_error_logging(monkeypatch, tmp_path, create_app):
monkeypatch.chdir(tmp_path)
shutil.copytree(INVALID_PROJECTS_DIR / "build-error", tmp_path, dirs_exist_ok=True)
@@ -424,6 +428,7 @@ def test_lifecycle_error_logging(monkeypatch, tmp_path, create_app):
assert parts_message in log_contents
+@pytest.mark.usefixtures("pretend_jammy")
def test_runtime_error_logging(monkeypatch, tmp_path, create_app, mocker):
monkeypatch.chdir(tmp_path)
shutil.copytree(INVALID_PROJECTS_DIR / "build-error", tmp_path, dirs_exist_ok=True)
@@ -437,6 +442,7 @@ def test_runtime_error_logging(monkeypatch, tmp_path, create_app, mocker):
monkeypatch.setattr("sys.argv", ["testcraft", "pack", "--destructive-mode"])
app = create_app()
+
app.run()
log_contents = craft_cli.emit._log_filepath.read_text()
diff --git a/tests/unit/commands/test_lifecycle.py b/tests/unit/commands/test_lifecycle.py
index 4800cdea..7c08030c 100644
--- a/tests/unit/commands/test_lifecycle.py
+++ b/tests/unit/commands/test_lifecycle.py
@@ -52,15 +52,16 @@
({"destructive_mode": False, "use_lxd": True}, ["--use-lxd"]),
]
STEP_NAMES = [step.name.lower() for step in craft_parts.Step]
-MANAGED_LIFECYCLE_COMMANDS = {
+MANAGED_LIFECYCLE_COMMANDS = (
PullCommand,
OverlayCommand,
BuildCommand,
StageCommand,
PrimeCommand,
-}
-UNMANAGED_LIFECYCLE_COMMANDS = {CleanCommand, PackCommand}
-ALL_LIFECYCLE_COMMANDS = MANAGED_LIFECYCLE_COMMANDS | UNMANAGED_LIFECYCLE_COMMANDS
+)
+UNMANAGED_LIFECYCLE_COMMANDS = (CleanCommand, PackCommand)
+ALL_LIFECYCLE_COMMANDS = MANAGED_LIFECYCLE_COMMANDS + UNMANAGED_LIFECYCLE_COMMANDS
+NON_CLEAN_COMMANDS = (*MANAGED_LIFECYCLE_COMMANDS, PackCommand)
def get_fake_command_class(parent_cls, managed):
@@ -101,7 +102,7 @@ def test_get_lifecycle_command_group(enable_overlay, commands):
actual = get_lifecycle_command_group()
- assert set(actual.commands) == commands
+ assert set(actual.commands) == set(commands)
Features.reset()
@@ -170,7 +171,7 @@ def test_parts_command_get_managed_cmd(
)
@pytest.mark.parametrize("parts", PARTS_LISTS)
# clean command has different logic for `run_managed()`
-@pytest.mark.parametrize("command_cls", ALL_LIFECYCLE_COMMANDS - {CleanCommand})
+@pytest.mark.parametrize("command_cls", NON_CLEAN_COMMANDS)
def test_parts_command_run_managed(
app_metadata,
mock_services,
@@ -553,7 +554,7 @@ def test_shell_after(
mock_subprocess_run.assert_called_once_with(["bash"], check=False)
-@pytest.mark.parametrize("command_cls", MANAGED_LIFECYCLE_COMMANDS | {PackCommand})
+@pytest.mark.parametrize("command_cls", [*MANAGED_LIFECYCLE_COMMANDS, PackCommand])
def test_debug(app_metadata, fake_services, mocker, mock_subprocess_run, command_cls):
parsed_args = argparse.Namespace(parts=None, debug=True)
error_message = "Lifecycle run failed!"
diff --git a/tests/unit/models/test_base.py b/tests/unit/models/test_base.py
index 126d8889..2d9390a8 100644
--- a/tests/unit/models/test_base.py
+++ b/tests/unit/models/test_base.py
@@ -27,11 +27,13 @@ class MyBaseModel(models.CraftBaseModel):
value1: int
value2: str
- @pydantic.validator("value1")
+ @pydantic.field_validator("value1", mode="after")
+ @classmethod
def _validate_value1(cls, _v):
raise ValueError("Bad value1 value")
- @pydantic.validator("value2")
+ @pydantic.field_validator("value2", mode="after")
+ @classmethod
def _validate_value2(cls, _v):
raise ValueError("Bad value2 value")
diff --git a/tests/unit/models/test_constraints.py b/tests/unit/models/test_constraints.py
index 87d3cbbf..c4a06013 100644
--- a/tests/unit/models/test_constraints.py
+++ b/tests/unit/models/test_constraints.py
@@ -14,12 +14,21 @@
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see .
"""Tests for project model."""
+
import re
from string import ascii_letters, ascii_lowercase, digits
+from typing import cast
import pydantic.errors
import pytest
-from craft_application.models.constraints import ProjectName, VersionStr
+from craft_application.models import constraints
+from craft_application.models.constraints import (
+ LicenseStr,
+ ProjectName,
+ ProprietaryLicenseStr,
+ SpdxLicenseStr,
+ VersionStr,
+)
from hypothesis import given, strategies
ALPHA_NUMERIC = [*ascii_letters, *digits]
@@ -74,13 +83,58 @@ def string_or_unique_list():
)
+# endregion
+# region Unique list values tests
+@given(
+ strategies.sets(
+ strategies.one_of(
+ strategies.none(),
+ strategies.integers(),
+ strategies.floats(),
+ strategies.text(),
+ )
+ )
+)
+def test_validate_list_is_unique_hypothesis_success(values: set):
+ values_list = list(values)
+ constraints._validate_list_is_unique(values_list)
+
+
+@pytest.mark.parametrize(
+ "values", [[], [None], [None, 0], [None, 0, ""], [True, 2, "True", "2", "two"]]
+)
+def test_validate_list_is_unique_success(values: list):
+ constraints._validate_list_is_unique(values)
+
+
+@pytest.mark.parametrize(
+ ("values", "expected_dupes_text"),
+ [
+ ([None, None], "[None]"),
+ ([0, 0], "[0]"),
+ ([1, True], "[1]"),
+ ([True, 1], "[True]"),
+ (["this", "that", "this"], "['this']"),
+ ],
+)
+def test_validate_list_is_unique_with_duplicates(values, expected_dupes_text):
+ with pytest.raises(ValueError, match="^duplicate values in list: ") as exc_info:
+ constraints._validate_list_is_unique(values)
+
+ assert exc_info.value.args[0].endswith(expected_dupes_text)
+
+
# endregion
# region ProjectName tests
+class _ProjectNameModel(pydantic.BaseModel):
+ name: ProjectName
+
+
@given(name=valid_project_name_strategy())
def test_valid_project_name_hypothesis(name):
- project_name = ProjectName.validate(name)
+ project = _ProjectNameModel(name=name)
- assert project_name == name
+ assert project.name == name
@pytest.mark.parametrize(
@@ -97,9 +151,9 @@ def test_valid_project_name_hypothesis(name):
],
)
def test_valid_project_name(name):
- project_name = ProjectName.validate(name)
+ project = _ProjectNameModel(name=name)
- assert project_name == name
+ assert project.name == name
@pytest.mark.parametrize(
@@ -113,48 +167,148 @@ def test_valid_project_name(name):
],
)
def test_invalid_project_name(name):
- with pytest.raises(pydantic.PydanticValueError):
- ProjectName.validate(name)
+ with pytest.raises(pydantic.ValidationError):
+ _ProjectNameModel(name=name)
# endregion
# region VersionStr tests
-@given(version=strategies.integers(min_value=0))
+class _VersionStrModel(pydantic.BaseModel):
+ version: VersionStr
+
+
+@given(version=strategies.integers(min_value=0, max_value=10**32 - 1))
def test_version_str_hypothesis_integers(version):
- version_str = VersionStr(version)
- VersionStr.validate(version_str)
+ version_str = str(version)
+ _VersionStrModel(version=version_str)
assert version_str == str(version)
@given(version=strategies.floats(min_value=0.0))
def test_version_str_hypothesis_floats(version):
- version_str = VersionStr(version)
- VersionStr.validate(version_str)
+ version_str = str(version)
+ _VersionStrModel(version=version_str)
assert version_str == str(version)
@given(version=valid_version_strategy())
def test_version_str_hypothesis(version):
- version_str = VersionStr(version)
- VersionStr.validate(version)
+ version_str = str(version)
+ _VersionStrModel(version=version)
assert version_str == str(version)
@pytest.mark.parametrize("version", ["0", "1.0", "1.0.0.post10+git12345678"])
def test_valid_version_str(version):
- version_str = VersionStr(version)
- VersionStr.validate(version)
+ version_str = str(version)
+ _VersionStrModel(version=version)
assert version_str == str(version)
@pytest.mark.parametrize("version", [""])
def test_invalid_version_str(version):
- with pytest.raises(pydantic.PydanticValueError):
- VersionStr.validate(VersionStr(version))
+ with pytest.raises(pydantic.ValidationError):
+ _VersionStrModel(version=str(version))
+
+
+# endregion
+# region SpdxLicenseStr tests
+
+_VALID_SPDX_LICENCES = [
+ "MIT",
+ "GPL-3.0",
+ "GPL-3.0+",
+ "GPL-3.0+ and MIT",
+ "LGPL-3.0+ or BSD-3-Clause",
+]
+
+
+@pytest.fixture(params=_VALID_SPDX_LICENCES)
+def valid_spdx_license_str(request: pytest.FixtureRequest) -> str:
+ return cast(str, request.param)
+
+
+class _SpdxLicenseStrModel(pydantic.BaseModel):
+ license: SpdxLicenseStr
+
+
+def test_spdx_license_str_valid(valid_spdx_license_str: str) -> None:
+ model = _SpdxLicenseStrModel(license=valid_spdx_license_str)
+ assert model.license == valid_spdx_license_str
+
+
+@pytest.mark.parametrize("license_str", ["Copyright 1990", "Proprietary"])
+def test_spdx_license_str_invalid(license_str):
+ with pytest.raises(pydantic.ValidationError) as validation_error:
+ _ = _SpdxLicenseStrModel(license=license_str)
+ assert validation_error.match(
+ f"License '{license_str}' not valid. It must be in SPDX format.",
+ )
+
+
+def test_spdx_parser_with_none():
+ from craft_application.models.constraints import _validate_spdx_license
+
+ val = None
+ with pytest.raises(
+ ValueError, match=f"License '{val}' not valid. It must be in SPDX format."
+ ):
+ _validate_spdx_license(val) # pyright: ignore[reportArgumentType]
+
+
+# endregion
+# region ProprietaryLicenseStr tests
+class _ProprietaryLicenseStrModel(pydantic.BaseModel):
+ license: ProprietaryLicenseStr
+
+
+def test_proprietary_str_valid():
+ model = _ProprietaryLicenseStrModel(license="proprietary")
+ assert model.license == "proprietary"
+
+
+def test_proprietary_str_invalid():
+ with pytest.raises(pydantic.ValidationError) as validation_error:
+ _ = _ProprietaryLicenseStrModel(
+ license="non-proprietary" # pyright: ignore[reportArgumentType]
+ )
+ assert validation_error.match("Input should be 'proprietary'")
+
+
+# endregion
+# region LicenseStr tests
+class _LicenseStrModel(pydantic.BaseModel):
+ license: LicenseStr
+
+
+@pytest.mark.parametrize(
+ "license_str",
+ [*_VALID_SPDX_LICENCES, "proprietary"],
+)
+def test_license_str_valid(license_str):
+ model = _LicenseStrModel(license=license_str)
+ assert model.license == license_str
+
+
+@pytest.mark.parametrize("license_str", ["Copyright 1990", "Proprietary"])
+def test_license_str_invalid(license_str):
+ with pytest.raises(pydantic.ValidationError) as validation_error:
+ _ = _LicenseStrModel(license=license_str)
+ assert validation_error.match(
+ f"License '{license_str}' not valid. It must be in SPDX format.",
+ )
+
+
+def test_license_str_invalid_literal():
+ with pytest.raises(pydantic.ValidationError) as validation_error:
+ _ = _LicenseStrModel(
+ license="non-proprietary" # pyright: ignore[reportArgumentType]
+ )
+ assert validation_error.match("Input should be 'proprietary'")
# endregion
diff --git a/tests/unit/models/test_project.py b/tests/unit/models/test_project.py
index 1c591880..502a1fbc 100644
--- a/tests/unit/models/test_project.py
+++ b/tests/unit/models/test_project.py
@@ -16,6 +16,7 @@
"""Tests for BaseProject"""
import copy
import pathlib
+import re
import textwrap
from textwrap import dedent
@@ -29,7 +30,6 @@
DEVEL_BASE_WARNING,
BuildInfo,
BuildPlanner,
- Platform,
Project,
constraints,
)
@@ -42,10 +42,10 @@
def basic_project():
# pyright doesn't like these types and doesn't have a pydantic plugin like mypy.
# Because of this, we need to silence several errors in these constants.
- return Project( # pyright: ignore[reportCallIssue]
- name="project-name", # pyright: ignore[reportGeneralTypeIssues]
- version="1.0", # pyright: ignore[reportGeneralTypeIssues]
- platforms={"arm64": None},
+ return Project(
+ name="project-name",
+ version="1.0",
+ platforms={"arm64": None}, # pyright: ignore[reportArgumentType]
parts=PARTS_DICT,
)
@@ -71,19 +71,21 @@ def basic_project_dict():
@pytest.fixture()
def full_project():
- return Project( # pyright: ignore[reportCallIssue]
- name="full-project", # pyright: ignore[reportGeneralTypeIssues]
- title="A fully-defined project", # pyright: ignore[reportGeneralTypeIssues]
- base="ubuntu@24.04",
- version="1.0.0.post64+git12345678", # pyright: ignore[reportGeneralTypeIssues]
- contact="author@project.org",
- issues="https://github.com/canonical/craft-application/issues",
- source_code="https://github.com/canonical/craft-application", # pyright: ignore[reportGeneralTypeIssues]
- summary="A fully-defined craft-application project.", # pyright: ignore[reportGeneralTypeIssues]
- description="A fully-defined craft-application project.\nWith more than one line.\n",
- license="LGPLv3",
- platforms={"arm64": None},
- parts=PARTS_DICT,
+ return Project.model_validate(
+ {
+ "name": "full-project",
+ "title": "A fully-defined project",
+ "base": "ubuntu@24.04",
+ "version": "1.0.0.post64+git12345678",
+ "contact": "author@project.org",
+ "issues": "https://github.com/canonical/craft-application/issues",
+ "source_code": "https://github.com/canonical/craft-application",
+ "summary": "A fully-defined craft-application project.",
+ "description": "A fully-defined craft-application project.\nWith more than one line.\n",
+ "license": "LGPLv3",
+ "platforms": {"arm64": None},
+ "parts": PARTS_DICT,
+ }
)
@@ -223,13 +225,14 @@ def test_from_yaml_data_failure(project_file, error_class):
("full_project", PROJECTS_DIR / "full_project.yaml"),
],
)
-def test_to_yaml_file(project_fixture, expected_file, tmp_path, request):
+def test_to_yaml(project_fixture, expected_file, tmp_path, request):
project = request.getfixturevalue(project_fixture)
actual_file = tmp_path / "out.yaml"
project.to_yaml_file(actual_file)
assert actual_file.read_text() == expected_file.read_text()
+ assert actual_file.read_text() == project.to_yaml_string()
def test_effective_base_is_base(full_project):
@@ -237,7 +240,7 @@ def test_effective_base_is_base(full_project):
class FakeBuildBaseProject(Project):
- build_base: str | None # pyright: ignore[reportGeneralTypeIssues]
+ build_base: str | None = None
def test_effective_base_is_build_base():
@@ -246,7 +249,7 @@ def test_effective_base_is_build_base():
name="project-name", # pyright: ignore[reportGeneralTypeIssues]
version="1.0", # pyright: ignore[reportGeneralTypeIssues]
parts={},
- platforms={"arm64": None},
+ platforms={"arm64": None}, # pyright: ignore[reportArgumentType]
base="ubuntu@22.04",
build_base="ubuntu@24.04",
)
@@ -255,11 +258,11 @@ def test_effective_base_is_build_base():
def test_effective_base_unknown():
- project = FakeBuildBaseProject( # pyright: ignore[reportCallIssue]
- name="project-name", # pyright: ignore[reportGeneralTypeIssues]
- version="1.0", # pyright: ignore[reportGeneralTypeIssues]
+ project = FakeBuildBaseProject(
+ name="project-name",
+ version="1.0",
parts={},
- platforms={"arm64": None},
+ platforms={"arm64": None}, # pyright: ignore[reportArgumentType]
base=None,
build_base=None,
)
@@ -272,11 +275,11 @@ def test_effective_base_unknown():
def test_devel_base_devel_build_base(emitter):
"""Base can be 'devel' when the build-base is 'devel'."""
- _ = FakeBuildBaseProject( # pyright: ignore[reportCallIssue]
- name="project-name", # pyright: ignore[reportGeneralTypeIssues]
- version="1.0", # pyright: ignore[reportGeneralTypeIssues]
+ _ = FakeBuildBaseProject(
+ name="project-name",
+ version="1.0",
parts={},
- platforms={"arm64": None},
+ platforms={"arm64": None}, # pyright: ignore[reportArgumentType]
base=f"ubuntu@{DEVEL_BASE_INFOS[0].current_devel_base.value}",
build_base=f"ubuntu@{DEVEL_BASE_INFOS[0].current_devel_base.value}",
)
@@ -286,11 +289,11 @@ def test_devel_base_devel_build_base(emitter):
def test_devel_base_no_base():
"""Do not validate the build-base if there is no base."""
- _ = FakeBuildBaseProject( # pyright: ignore[reportCallIssue]
- name="project-name", # pyright: ignore[reportGeneralTypeIssues]
- version="1.0", # pyright: ignore[reportGeneralTypeIssues]
+ _ = FakeBuildBaseProject(
+ name="project-name",
+ version="1.0",
parts={},
- platforms={"arm64": None},
+ platforms={"arm64": None}, # pyright: ignore[reportArgumentType]
)
@@ -301,22 +304,22 @@ def test_devel_base_no_base_alias(mocker):
return_value=None,
)
- _ = FakeBuildBaseProject( # pyright: ignore[reportCallIssue]
- name="project-name", # pyright: ignore[reportGeneralTypeIssues]
- version="1.0", # pyright: ignore[reportGeneralTypeIssues]
+ _ = FakeBuildBaseProject(
+ name="project-name",
+ version="1.0",
parts={},
- platforms={"arm64": None},
+ platforms={"arm64": None}, # pyright: ignore[reportArgumentType]
)
def test_devel_base_no_build_base():
"""Base can be 'devel' if the build-base is not set."""
- _ = FakeBuildBaseProject( # pyright: ignore[reportCallIssue]
- name="project-name", # pyright: ignore[reportGeneralTypeIssues]
- version="1.0", # pyright: ignore[reportGeneralTypeIssues]
+ _ = FakeBuildBaseProject(
+ name="project-name",
+ version="1.0",
parts={},
base=f"ubuntu@{DEVEL_BASE_INFOS[0].current_devel_base.value}",
- platforms={"arm64": None},
+ platforms={"arm64": None}, # pyright: ignore[reportArgumentType]
)
@@ -412,19 +415,37 @@ def test_unmarshal_undefined_repositories(full_project_dict):
assert project.package_repositories is None
-def test_unmarshal_invalid_repositories(full_project_dict):
+@pytest.mark.parametrize(
+ ("repositories_val", "error_lines"),
+ [
+ (
+ [[]],
+ [
+ "- input should be a valid dictionary (in field 'package-repositories[0]')"
+ ],
+ ),
+ (
+ [{}],
+ [
+ "- field 'type' required in 'package-repositories[0]' configuration",
+ "- field 'url' required in 'package-repositories[0]' configuration",
+ "- field 'key-id' required in 'package-repositories[0]' configuration",
+ ],
+ ),
+ ],
+)
+def test_unmarshal_invalid_repositories(
+ full_project_dict, repositories_val, error_lines
+):
"""Test that package-repositories are validated in Project with package repositories feature."""
- full_project_dict["package-repositories"] = [[]]
+ full_project_dict["package-repositories"] = repositories_val
project_path = pathlib.Path("myproject.yaml")
with pytest.raises(CraftValidationError) as error:
Project.from_yaml_data(full_project_dict, project_path)
- assert error.value.args[0] == (
- "Bad myproject.yaml content:\n"
- "- field 'type' required in 'package-repositories[0]' configuration\n"
- "- field 'url' required in 'package-repositories[0]' configuration\n"
- "- field 'key-id' required in 'package-repositories[0]' configuration"
+ assert error.value.args[0] == "\n".join(
+ ("Bad myproject.yaml content:", *error_lines)
)
@@ -596,10 +617,30 @@ def test_get_build_plan_all_with_other_platforms(platforms):
def test_get_build_plan_build_on_all():
"""`build-on: all` is not allowed."""
with pytest.raises(pydantic.ValidationError) as raised:
- BuildPlanner(
- base="ubuntu@24.04",
- platforms={"arm64": Platform(build_on=["all"], build_for=["s390x"])},
- build_base=None,
+ BuildPlanner.model_validate(
+ {
+ "base": "ubuntu@24.04",
+ "platforms": {
+ "arm64": {"build-on": ["all"], "build-for": ["s390x"]},
+ },
+ "build_base": None,
+ }
)
assert "'all' cannot be used for 'build-on'" in str(raised.value)
+
+
+def test_invalid_part_error(basic_project_dict):
+ """Check that the part name is included in the error message."""
+ basic_project_dict["parts"] = {
+ "p1": {"plugin": "badplugin"},
+ "p2": {"plugin": "nil", "bad-key": 1},
+ }
+ expected = textwrap.dedent(
+ """\
+ Bad bla.yaml content:
+ - plugin not registered: 'badplugin' (in field 'parts.p1')
+ - extra inputs are not permitted (in field 'parts.p2.bad-key')"""
+ )
+ with pytest.raises(CraftValidationError, match=re.escape(expected)):
+ Project.from_yaml_data(basic_project_dict, filepath=pathlib.Path("bla.yaml"))
diff --git a/tests/unit/services/test_lifecycle.py b/tests/unit/services/test_lifecycle.py
index 410eac13..315c6c97 100644
--- a/tests/unit/services/test_lifecycle.py
+++ b/tests/unit/services/test_lifecycle.py
@@ -710,7 +710,7 @@ def test_lifecycle_project_variables(
"""Test that project variables are set after the lifecycle runs."""
class LocalProject(models.Project):
- color: str | None
+ color: str | None = None
fake_project = LocalProject.unmarshal(
{
diff --git a/tests/unit/test_application.py b/tests/unit/test_application.py
index fbcfbea3..b8da1cd2 100644
--- a/tests/unit/test_application.py
+++ b/tests/unit/test_application.py
@@ -35,6 +35,7 @@
import craft_cli
import craft_parts
import craft_providers
+import pydantic
import pytest
import pytest_check
from craft_application import (
@@ -54,7 +55,6 @@
from craft_parts.plugins.plugins import PluginType
from craft_providers import bases
from overrides import override
-from pydantic import validator
EMPTY_COMMAND_GROUP = craft_cli.CommandGroup("FakeCommands", [])
BASIC_PROJECT_YAML = """
@@ -670,8 +670,8 @@ def test_gets_project(monkeypatch, tmp_path, app_metadata, fake_services):
app.run()
- assert fake_services.project is not None
- assert app.project is not None
+ pytest_check.is_not_none(fake_services.project)
+ pytest_check.is_not_none(app.project)
def test_fails_without_project(
@@ -2013,11 +2013,13 @@ class MyRaisingPlanner(models.BuildPlanner):
value1: int
value2: str
- @validator("value1")
+ @pydantic.field_validator("value1", mode="after")
+ @classmethod
def _validate_value1(cls, v):
raise ValueError(f"Bad value1: {v}")
- @validator("value2")
+ @pydantic.field_validator("value2", mode="after")
+ @classmethod
def _validate_value(cls, v):
raise ValueError(f"Bad value2: {v}")
@@ -2037,13 +2039,13 @@ def test_build_planner_errors(tmp_path, monkeypatch, fake_services):
app = FakeApplication(app_metadata, fake_services)
project_contents = textwrap.dedent(
"""\
- name: my-project
- base: ubuntu@24.04
- value1: 10
- value2: "banana"
- platforms:
- amd64:
- """
+ name: my-project
+ base: ubuntu@24.04
+ value1: 10
+ value2: "banana"
+ platforms:
+ amd64:
+ """
).strip()
project_path = tmp_path / "testcraft.yaml"
project_path.write_text(project_contents)
diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py
index a6a2a49e..4edd335f 100644
--- a/tests/unit/test_errors.py
+++ b/tests/unit/test_errors.py
@@ -21,7 +21,8 @@
import pytest
import pytest_check
from craft_application.errors import CraftValidationError, PartsLifecycleError
-from pydantic import BaseModel, conint
+from pydantic import BaseModel
+from typing_extensions import Self
@pytest.mark.parametrize(
@@ -67,27 +68,52 @@ def test_parts_lifecycle_error_from_os_error(
assert actual == expected
+class Model(BaseModel):
+ gt_int: int = pydantic.Field(gt=42)
+ a_float: float
+ b_int: int = 0
+
+ @pydantic.model_validator(mode="after")
+ def b_smaller_gt(self) -> Self:
+ if self.b_int >= self.gt_int:
+ raise ValueError("'b_int' must be smaller than 'gt_int'")
+ return self
+
+
def test_validation_error_from_pydantic():
- class Model(BaseModel):
- gt_int: conint(gt=42) # pyright: ignore [reportInvalidTypeForm]
- a_float: float
-
- data = {
- "gt_int": 21,
- "a_float": "not a float",
- }
+ data = {"gt_int": 21, "a_float": "not a float"}
+ try:
+ Model(**data)
+ except pydantic.ValidationError as e:
+ err = CraftValidationError.from_pydantic(e, file_name="myfile.yaml")
+ else: # pragma: no cover
+ pytest.fail("Model failed to fail to validate!")
+
+ expected = textwrap.dedent(
+ """
+ Bad myfile.yaml content:
+ - input should be greater than 42 (in field 'gt_int')
+ - input should be a valid number, unable to parse string as a number (in field 'a_float')
+ """
+ ).strip()
+
+ message = str(err)
+ assert message == expected
+
+
+def test_validation_error_from_pydantic_model():
+ data = {"gt_int": 100, "a_float": 1.0, "b_int": 3000}
try:
Model(**data)
except pydantic.ValidationError as e:
err = CraftValidationError.from_pydantic(e, file_name="myfile.yaml")
else: # pragma: no cover
- pytest.fail("Model failed to validate!")
+ pytest.fail("Model failed to fail to validate!")
expected = textwrap.dedent(
"""
Bad myfile.yaml content:
- - ensure this value is greater than 42 (in field 'gt_int')
- - value is not a valid float (in field 'a_float')
+ - 'b_int' must be smaller than 'gt_int'
"""
).strip()
diff --git a/tests/unit/util/test_snap_config.py b/tests/unit/util/test_snap_config.py
index d38d643b..de34cf85 100644
--- a/tests/unit/util/test_snap_config.py
+++ b/tests/unit/util/test_snap_config.py
@@ -87,7 +87,7 @@ def test_unmarshal_invalid_provider_error():
assert str(raised.value) == (
"Bad snap config content:\n"
- "- unexpected value; permitted: 'lxd', 'multipass' (in field 'provider')"
+ "- input should be 'lxd' or 'multipass' (in field 'provider')"
)
@@ -98,7 +98,7 @@ def test_unmarshal_extra_data_error():
assert str(raised.value) == (
"Bad snap config content:\n"
- "- extra field 'test' not permitted in top-level configuration"
+ "- extra inputs are not permitted (in field 'test')"
)