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')" )