diff --git a/craft_parts/parts.py b/craft_parts/parts.py index e8c5154a..bc6d666d 100644 --- a/craft_parts/parts.py +++ b/craft_parts/parts.py @@ -212,7 +212,12 @@ def __init__( project_dirs = ProjectDirs(partitions=partitions) if not plugin_properties: - plugin_properties = PluginProperties() + try: + plugin_properties = PluginProperties.unmarshal(data) + except ValidationError as err: + raise errors.PartSpecificationError.from_validation_error( + part_name=name, error_list=err.errors() + ) from err plugin_name: str = data.get("plugin", "") diff --git a/craft_parts/plugins/__init__.py b/craft_parts/plugins/__init__.py index 47561b0f..1eee7ed4 100644 --- a/craft_parts/plugins/__init__.py +++ b/craft_parts/plugins/__init__.py @@ -16,9 +16,8 @@ """Craft Parts plugins subsystem.""" -from .base import Plugin, PluginModel, extract_plugin_properties +from .base import Plugin from .plugins import ( - PluginProperties, extract_part_properties, get_plugin, get_plugin_class, @@ -27,15 +26,14 @@ unregister, unregister_all, ) +from .properties import PluginProperties from .validator import PluginEnvironmentValidator __all__ = [ "Plugin", "PluginEnvironmentValidator", - "PluginModel", "PluginProperties", "extract_part_properties", - "extract_plugin_properties", "get_plugin", "get_plugin_class", "get_registered_plugins", diff --git a/craft_parts/plugins/ant_plugin.py b/craft_parts/plugins/ant_plugin.py index b9382e25..99b6ba03 100644 --- a/craft_parts/plugins/ant_plugin.py +++ b/craft_parts/plugins/ant_plugin.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2022 Canonical Ltd. +# Copyright 2022,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 @@ -20,7 +20,7 @@ import os import shlex from collections.abc import Iterator -from typing import Any, cast +from typing import Literal, cast from urllib.parse import urlsplit from overrides import override @@ -28,37 +28,23 @@ from craft_parts import errors from . import validator -from .base import JavaPlugin, PluginModel, extract_plugin_properties +from .base import JavaPlugin from .properties import PluginProperties logger = logging.getLogger(__name__) -class AntPluginProperties(PluginProperties, PluginModel): +class AntPluginProperties(PluginProperties, frozen=True): """The part properties used by the Ant plugin.""" + plugin: Literal["ant"] = "ant" + ant_build_targets: list[str] = [] ant_build_file: str | None = None ant_properties: dict[str, str] = {} source: str - @classmethod - @override - def unmarshal(cls, data: dict[str, Any]) -> "AntPluginProperties": - """Populate make properties from the part specification. - - :param data: A dictionary containing part properties. - - :return: The populated plugin properties data object. - - :raise pydantic.ValidationError: If validation fails. - """ - plugin_data = extract_plugin_properties( - data, plugin_name="ant", required=["source"] - ) - return cls(**plugin_data) - class AntPluginEnvironmentValidator(validator.PluginEnvironmentValidator): """Check the execution environment for the Ant plugin. diff --git a/craft_parts/plugins/autotools_plugin.py b/craft_parts/plugins/autotools_plugin.py index c6e9c7e4..ebf7cc1f 100644 --- a/craft_parts/plugins/autotools_plugin.py +++ b/craft_parts/plugins/autotools_plugin.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2020 Canonical Ltd. +# Copyright 2020,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 @@ -16,39 +16,25 @@ """The autotools plugin implementation.""" -from typing import Any, cast +from typing import Literal, cast from overrides import override -from .base import Plugin, PluginModel, extract_plugin_properties +from .base import Plugin from .properties import PluginProperties -class AutotoolsPluginProperties(PluginProperties, PluginModel): +class AutotoolsPluginProperties(PluginProperties, frozen=True): """The part properties used by the autotools plugin.""" + plugin: Literal["autotools"] = "autotools" + autotools_configure_parameters: list[str] = [] autotools_bootstrap_parameters: list[str] = [] # part properties required by the plugin source: str - @classmethod - @override - def unmarshal(cls, data: dict[str, Any]) -> "AutotoolsPluginProperties": - """Populate autotools properties from the part specification. - - :param data: A dictionary containing part properties. - - :return: The populated plugin properties data object. - - :raise pydantic.ValidationError: If validation fails. - """ - plugin_data = extract_plugin_properties( - data, plugin_name="autotools", required=["source"] - ) - return cls(**plugin_data) - class AutotoolsPlugin(Plugin): """The autotools plugin is used for autotools-based parts. diff --git a/craft_parts/plugins/base.py b/craft_parts/plugins/base.py index f43fd94a..222f46a9 100644 --- a/craft_parts/plugins/base.py +++ b/craft_parts/plugins/base.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2020-2023 Canonical Ltd. +# Copyright 2020-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 @@ -16,13 +16,15 @@ """Plugin base class and definitions.""" +from __future__ import annotations + import abc from copy import deepcopy -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from craft_parts.actions import ActionProperties -from .properties import PluginProperties, PluginPropertiesModel +from .properties import PluginProperties from .validator import PluginEnvironmentValidator if TYPE_CHECKING: @@ -47,7 +49,7 @@ class Plugin(abc.ABC): """Plugins that can run in 'strict' mode must set this classvar to True.""" def __init__( - self, *, properties: PluginProperties, part_info: "infos.PartInfo" + self, *, properties: PluginProperties, part_info: infos.PartInfo ) -> None: self._options = properties self._part_info = part_info @@ -120,37 +122,3 @@ def _get_java_post_build_commands(self) -> list[str]: # pylint: enable=line-too-long return link_java + link_jars - - -class PluginModel(PluginPropertiesModel): - """Model for plugins using pydantic validation. - - Plugins with configuration properties can use pydantic validation to unmarshal - data from part specs. In this case, extract plugin-specific properties using - the :func:`extract_plugin_properties` helper. - """ - - -def extract_plugin_properties( - data: dict[str, Any], *, plugin_name: str, required: list[str] | None = None -) -> dict[str, Any]: - """Obtain plugin-specific entries from part properties. - - Plugin-specific properties must be prefixed with the name of the plugin. - - :param data: A dictionary containing all part properties. - :plugin_name: The name of the plugin. - - :return: A dictionary with plugin properties. - """ - if required is None: - required = [] - - plugin_data: dict[str, Any] = {} - prefix = f"{plugin_name}-" - - for key, value in data.items(): - if key.startswith(prefix) or key in required: - plugin_data[key] = value - - return plugin_data diff --git a/craft_parts/plugins/cmake_plugin.py b/craft_parts/plugins/cmake_plugin.py index 12174b36..ecf6f86a 100644 --- a/craft_parts/plugins/cmake_plugin.py +++ b/craft_parts/plugins/cmake_plugin.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2020-2022 Canonical Ltd. +# Copyright 2020-2022,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 @@ -16,39 +16,25 @@ """The cmake plugin.""" -from typing import Any, cast +from typing import Literal, cast from overrides import override -from .base import Plugin, PluginModel, extract_plugin_properties +from .base import Plugin from .properties import PluginProperties -class CMakePluginProperties(PluginProperties, PluginModel): +class CMakePluginProperties(PluginProperties, frozen=True): """The part properties used by the cmake plugin.""" + plugin: Literal["cmake"] = "cmake" + cmake_parameters: list[str] = [] cmake_generator: str = "Unix Makefiles" # part properties required by the plugin source: str - @classmethod - @override - def unmarshal(cls, data: dict[str, Any]) -> "CMakePluginProperties": - """Populate class attributes from the part specification. - - :param data: A dictionary containing part properties. - - :return: The populated plugin properties data object. - - :raise pydantic.ValidationError: If validation fails. - """ - plugin_data = extract_plugin_properties( - data, plugin_name="cmake", required=["source"] - ) - return cls(**plugin_data) - class CMakePlugin(Plugin): """The cmake plugin is useful for building cmake based parts. diff --git a/craft_parts/plugins/dotnet_plugin.py b/craft_parts/plugins/dotnet_plugin.py index d0de795d..16bd2a7d 100644 --- a/craft_parts/plugins/dotnet_plugin.py +++ b/craft_parts/plugins/dotnet_plugin.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2021 Canonical Ltd. +# Copyright 2021,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 @@ -17,42 +17,28 @@ """The Dotnet plugin.""" import logging -from typing import Any, cast +from typing import Literal, cast from overrides import override from . import validator -from .base import Plugin, PluginModel, extract_plugin_properties +from .base import Plugin from .properties import PluginProperties logger = logging.getLogger(__name__) -class DotnetPluginProperties(PluginProperties, PluginModel): +class DotnetPluginProperties(PluginProperties, frozen=True): """The part properties used by the Dotnet plugin.""" + plugin: Literal["dotnet"] = "dotnet" + dotnet_build_configuration: str = "Release" dotnet_self_contained_runtime_identifier: str | None = None # part properties required by the plugin source: str - @classmethod - @override - def unmarshal(cls, data: dict[str, Any]) -> "DotnetPluginProperties": - """Populate make properties from the part specification. - - :param data: A dictionary containing part properties. - - :return: The populated plugin properties data object. - - :raise pydantic.ValidationError: If validation fails. - """ - plugin_data = extract_plugin_properties( - data, plugin_name="dotnet", required=["source"] - ) - return cls(**plugin_data) - class DotPluginEnvironmentValidator(validator.PluginEnvironmentValidator): """Check the execution environment for the Dotnet plugin. diff --git a/craft_parts/plugins/dump_plugin.py b/craft_parts/plugins/dump_plugin.py index 54a00cdc..37d600c3 100644 --- a/craft_parts/plugins/dump_plugin.py +++ b/craft_parts/plugins/dump_plugin.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2020 Canonical Ltd. +# Copyright 2020,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 @@ -19,7 +19,7 @@ This plugin just dumps the content from a specified part source. """ -from typing import Any +from typing import Literal from overrides import override @@ -27,25 +27,11 @@ from .properties import PluginProperties -class DumpPluginProperties(PluginProperties): +class DumpPluginProperties(PluginProperties, frozen=True): """The part properties used by the dump plugin.""" - @classmethod - @override - def unmarshal(cls, data: dict[str, Any]) -> "DumpPluginProperties": - """Populate dump properties from the part specification. - - 'source' is a required part property. - - :param data: A dictionary containing part properties. - - :return: The populated plugin properties data object. - - :raise ValueError: If a required property is not found. - """ - if "source" not in data: - raise ValueError("'source' is required by the dump plugin") - return cls() + plugin: Literal["dump"] = "dump" + source: str class DumpPlugin(Plugin): diff --git a/craft_parts/plugins/go_plugin.py b/craft_parts/plugins/go_plugin.py index f5c01f29..227ccdb1 100644 --- a/craft_parts/plugins/go_plugin.py +++ b/craft_parts/plugins/go_plugin.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2020-2021 Canonical Ltd. +# Copyright 2020-2021,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 @@ -17,44 +17,30 @@ """The Go plugin.""" import logging -from typing import Any, cast +from typing import Literal, cast from overrides import override from craft_parts import errors from . import validator -from .base import Plugin, PluginModel, extract_plugin_properties +from .base import Plugin from .properties import PluginProperties logger = logging.getLogger(__name__) -class GoPluginProperties(PluginProperties, PluginModel): +class GoPluginProperties(PluginProperties, frozen=True): """The part properties used by the Go plugin.""" + plugin: Literal["go"] = "go" + go_buildtags: list[str] = [] go_generate: list[str] = [] # part properties required by the plugin source: str - @classmethod - @override - def unmarshal(cls, data: dict[str, Any]) -> "GoPluginProperties": - """Populate make properties from the part specification. - - :param data: A dictionary containing part properties. - - :return: The populated plugin properties data object. - - :raise pydantic.ValidationError: If validation fails. - """ - plugin_data = extract_plugin_properties( - data, plugin_name="go", required=["source"] - ) - return cls(**plugin_data) - class GoPluginEnvironmentValidator(validator.PluginEnvironmentValidator): """Check the execution environment for the Go plugin. diff --git a/craft_parts/plugins/make_plugin.py b/craft_parts/plugins/make_plugin.py index ed2beda3..8f5c3ec4 100644 --- a/craft_parts/plugins/make_plugin.py +++ b/craft_parts/plugins/make_plugin.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2020-2021 Canonical Ltd. +# Copyright 2020-2021,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 @@ -16,38 +16,24 @@ """The make plugin implementation.""" -from typing import Any, cast +from typing import Literal, cast from overrides import override -from .base import Plugin, PluginModel, extract_plugin_properties +from .base import Plugin from .properties import PluginProperties -class MakePluginProperties(PluginProperties, PluginModel): +class MakePluginProperties(PluginProperties, frozen=True): """The part properties used by the make plugin.""" + plugin: Literal["make"] = "make" + make_parameters: list[str] = [] # part properties required by the plugin source: str - @classmethod - @override - def unmarshal(cls, data: dict[str, Any]) -> "MakePluginProperties": - """Populate make properties from the part specification. - - :param data: A dictionary containing part properties. - - :return: The populated plugin properties data object. - - :raise pydantic.ValidationError: If validation fails. - """ - plugin_data = extract_plugin_properties( - data, plugin_name="make", required=["source"] - ) - return cls(**plugin_data) - class MakePlugin(Plugin): """A plugin useful for building make-based parts. diff --git a/craft_parts/plugins/maven_plugin.py b/craft_parts/plugins/maven_plugin.py index 47d499f7..9b2626d2 100644 --- a/craft_parts/plugins/maven_plugin.py +++ b/craft_parts/plugins/maven_plugin.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2015-2023 Canonical Ltd. +# Copyright 2015-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 @@ -19,7 +19,7 @@ import os import re from pathlib import Path -from typing import Any, cast +from typing import Literal, cast from urllib.parse import urlparse from xml.etree import ElementTree @@ -28,34 +28,20 @@ from craft_parts import errors from . import validator -from .base import JavaPlugin, PluginModel, extract_plugin_properties +from .base import JavaPlugin from .properties import PluginProperties -class MavenPluginProperties(PluginProperties, PluginModel): +class MavenPluginProperties(PluginProperties, frozen=True): """The part properties used by the maven plugin.""" + plugin: Literal["maven"] = "maven" + maven_parameters: list[str] = [] # part properties required by the plugin source: str - @classmethod - @override - def unmarshal(cls, data: dict[str, Any]) -> "MavenPluginProperties": - """Populate class attributes from the part specification. - - :param data: A dictionary containing part properties. - - :return: The populated plugin properties data object. - - :raise pydantic.ValidationError: If validation fails. - """ - plugin_data = extract_plugin_properties( - data, plugin_name="maven", required=["source"] - ) - return cls(**plugin_data) - class MavenPluginEnvironmentValidator(validator.PluginEnvironmentValidator): """Check the execution environment for the maven plugin. diff --git a/craft_parts/plugins/meson_plugin.py b/craft_parts/plugins/meson_plugin.py index 552a8c1a..64cdd949 100644 --- a/craft_parts/plugins/meson_plugin.py +++ b/craft_parts/plugins/meson_plugin.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2022 Canonical Ltd. +# Copyright 2022,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 @@ -19,41 +19,27 @@ import logging import shlex -from typing import Any, cast +from typing import Literal, cast from overrides import override from . import validator -from .base import Plugin, PluginModel, extract_plugin_properties +from .base import Plugin from .properties import PluginProperties logger = logging.getLogger(__name__) -class MesonPluginProperties(PluginProperties, PluginModel): +class MesonPluginProperties(PluginProperties, frozen=True): """The part properties used by the Meson plugin.""" + plugin: Literal["meson"] = "meson" + meson_parameters: list[str] = [] # part properties required by the plugin source: str - @classmethod - @override - def unmarshal(cls, data: dict[str, Any]) -> "MesonPluginProperties": - """Populate make properties from the part specification. - - :param data: A dictionary containing part properties. - - :return: The populated plugin properties data object. - - :raise pydantic.ValidationError: If validation fails. - """ - plugin_data = extract_plugin_properties( - data, plugin_name="meson", required=["source"] - ) - return cls(**plugin_data) - class MesonPluginEnvironmentValidator(validator.PluginEnvironmentValidator): """Check the execution environment for the Meson plugin. diff --git a/craft_parts/plugins/nil_plugin.py b/craft_parts/plugins/nil_plugin.py index a5957e2d..49806ec3 100644 --- a/craft_parts/plugins/nil_plugin.py +++ b/craft_parts/plugins/nil_plugin.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2020 Canonical Ltd. +# Copyright 2020,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 @@ -20,7 +20,7 @@ automatically included, e.g. stage-packages. """ -from typing import Any +from typing import Literal from overrides import override @@ -28,19 +28,10 @@ from .properties import PluginProperties -class NilPluginProperties(PluginProperties): +class NilPluginProperties(PluginProperties, frozen=True): """The part properties used by the nil plugin.""" - @classmethod - @override - def unmarshal(cls, data: dict[str, Any]) -> "NilPluginProperties": # noqa: ARG003 - """Populate class attributes from the part specification. - - :param data: A dictionary containing part properties. - - :return: The populated plugin properties data object. - """ - return cls() + plugin: Literal["nil"] = "nil" class NilPlugin(Plugin): diff --git a/craft_parts/plugins/npm_plugin.py b/craft_parts/plugins/npm_plugin.py index 1fbd9565..834029dd 100644 --- a/craft_parts/plugins/npm_plugin.py +++ b/craft_parts/plugins/npm_plugin.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2022 Canonical Ltd. +# Copyright 2022,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 @@ -21,7 +21,7 @@ import platform import re from textwrap import dedent -from typing import Any, cast +from typing import Any, Literal, cast import requests from overrides import override @@ -31,7 +31,7 @@ from craft_parts.errors import InvalidArchitecture from . import validator -from .base import Plugin, PluginModel, extract_plugin_properties +from .base import Plugin from .properties import PluginProperties logger = logging.getLogger(__name__) @@ -47,9 +47,11 @@ _NODE_ARCH_FROM_PLATFORM = {"x86_64": {"32bit": "x86", "64bit": "x64"}} -class NpmPluginProperties(PluginProperties, PluginModel): +class NpmPluginProperties(PluginProperties, frozen=True): """The part properties used by the npm plugin.""" + plugin: Literal["npm"] = "npm" + # part properties required by the plugin npm_include_node: bool = False npm_node_version: str | None = None @@ -66,22 +68,6 @@ def node_version_defined(self) -> Self: ) return self - @classmethod - @override - def unmarshal(cls, data: dict[str, Any]) -> "NpmPluginProperties": - """Populate class attributes from the part specification. - - :param data: A dictionary containing part properties. - - :return: The populated plugin properties data object. - - :raise pydantic.ValidationError: If validation fails. - """ - plugin_data = extract_plugin_properties( - data, plugin_name="npm", required=["source"] - ) - return cls(**plugin_data) - class NpmPluginEnvironmentValidator(validator.PluginEnvironmentValidator): """Check the execution environment for the npm plugin. diff --git a/craft_parts/plugins/properties.py b/craft_parts/plugins/properties.py index 7ccace90..2ddfc3a9 100644 --- a/craft_parts/plugins/properties.py +++ b/craft_parts/plugins/properties.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2021-2023 Canonical Ltd. +# Copyright 2021-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 @@ -16,23 +16,18 @@ """Definitions and helpers for plugin options.""" -from typing import Any +import functools +from typing import Any, cast -from pydantic import BaseModel, ConfigDict +import pydantic +from typing_extensions import Self -class PluginPropertiesModel(BaseModel): - """Model for plugins properties using pydantic validation.""" - - model_config = ConfigDict( - validate_assignment=True, - extra="forbid", - frozen=True, - alias_generator=lambda s: s.replace("_", "-"), - ) - - -class PluginProperties(PluginPropertiesModel): +# We set `frozen=True` here so that pyright knows to treat variable types as covariant +# rather than invariant, improving the readability of child classes. +# As a side effect, we have to tell mypy not to warn about setting this config item +# twice. +class PluginProperties(pydantic.BaseModel, frozen=True): # type: ignore[misc] """Options specific to a plugin. PluginProperties should be subclassed into plugin-specific property @@ -42,19 +37,48 @@ class PluginProperties(PluginPropertiesModel): build step is dirty. This can be overridden in each plugin if needed. """ + model_config = pydantic.ConfigDict( + alias_generator=lambda s: s.replace("_", "-"), + extra="forbid", + frozen=True, + validate_assignment=True, + ) + + plugin: str = "" + source: str | None = None + @classmethod - def unmarshal(cls, data: dict[str, Any]) -> "PluginProperties": # noqa: ARG003 + @functools.lru_cache(maxsize=1) + def model_properties(cls) -> dict[str, dict[str, Any]]: + """Get the properties for this model from the JSON schema.""" + return cast( + dict[str, dict[str, Any]], cls.model_json_schema().get("properties", {}) + ) + + @classmethod + def unmarshal(cls, data: dict[str, Any]) -> Self: """Populate class attributes from the part specification. :param data: A dictionary containing part properties. :return: The populated plugin properties data object. """ - return cls() + properties = cls.model_properties() + plugin_name = properties["plugin"].get("default", "") + + plugin_data = { + key: value + for key, value in data.items() + # Note: We also use startswith here in order to have the Properties object + # provide an "extra inputs are not permitted" error message. + if key in properties or key.startswith(f"{plugin_name}-") + } + + return cls.model_validate(plugin_data) def marshal(self) -> dict[str, Any]: """Obtain a dictionary containing the plugin properties.""" - return self.dict(by_alias=True) + return self.model_dump(mode="json", by_alias=True, exclude={"plugin"}) @classmethod def get_pull_properties(cls) -> list[str]: @@ -64,5 +88,4 @@ def get_pull_properties(cls) -> list[str]: @classmethod def get_build_properties(cls) -> list[str]: """Obtain the list of properties affecting the build stage.""" - properties = cls.schema(by_alias=True).get("properties") - return list(properties.keys()) if properties else [] + return [p for p in cls.model_properties() if p != "plugin"] diff --git a/craft_parts/plugins/python_plugin.py b/craft_parts/plugins/python_plugin.py index 322877ed..48cda7a7 100644 --- a/craft_parts/plugins/python_plugin.py +++ b/craft_parts/plugins/python_plugin.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2020-2023 Canonical Ltd. +# Copyright 2020-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 @@ -18,17 +18,19 @@ import shlex from textwrap import dedent -from typing import Any, cast +from typing import Literal, cast from overrides import override -from .base import Plugin, PluginModel, extract_plugin_properties +from .base import Plugin from .properties import PluginProperties -class PythonPluginProperties(PluginProperties, PluginModel): +class PythonPluginProperties(PluginProperties, frozen=True): """The part properties used by the python plugin.""" + plugin: Literal["python"] = "python" + python_requirements: list[str] = [] python_constraints: list[str] = [] python_packages: list[str] = ["pip", "setuptools", "wheel"] @@ -36,22 +38,6 @@ class PythonPluginProperties(PluginProperties, PluginModel): # part properties required by the plugin source: str - @classmethod - @override - def unmarshal(cls, data: dict[str, Any]) -> "PythonPluginProperties": - """Populate make properties from the part specification. - - :param data: A dictionary containing part properties. - - :return: The populated plugin properties data object. - - :raise pydantic.ValidationError: If validation fails. - """ - plugin_data = extract_plugin_properties( - data, plugin_name="python", required=["source"] - ) - return cls(**plugin_data) - class PythonPlugin(Plugin): """A plugin to build python parts.""" diff --git a/craft_parts/plugins/qmake_plugin.py b/craft_parts/plugins/qmake_plugin.py index d7ee1504..e9ea3308 100644 --- a/craft_parts/plugins/qmake_plugin.py +++ b/craft_parts/plugins/qmake_plugin.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2023 Canonical Ltd. +# Copyright 2023,2024 Canonical Ltd. # Copyright 2023 Scarlett Moore . # # This program is free software; you can redistribute it and/or @@ -18,18 +18,19 @@ """The qmake plugin.""" -from typing import Any, cast +from typing import Literal, cast from overrides import override -from typing_extensions import Self -from .base import Plugin, PluginModel, extract_plugin_properties +from .base import Plugin from .properties import PluginProperties -class QmakePluginProperties(PluginProperties, PluginModel): +class QmakePluginProperties(PluginProperties, frozen=True): """The part properties used by the qmake plugin.""" + plugin: Literal["qmake"] = "qmake" + qmake_parameters: list[str] = [] qmake_project_file: str = "" qmake_major_version: int = 5 @@ -37,21 +38,6 @@ class QmakePluginProperties(PluginProperties, PluginModel): # part properties required by the plugin source: str - @classmethod - def unmarshal(cls, data: dict[str, Any]) -> Self: - """Populate class attributes from the part specification. - - :param data: A dictionary containing part properties. - - :return: The populated plugin properties data object. - - :raise pydantic.ValidationError: If validation fails. - """ - plugin_data = extract_plugin_properties( - data, plugin_name="qmake", required=["source"] - ) - return cls(**plugin_data) - class QmakePlugin(Plugin): """The qmake plugin is useful for building qmake-based parts. diff --git a/craft_parts/plugins/rust_plugin.py b/craft_parts/plugins/rust_plugin.py index 3592d07d..43a8c613 100644 --- a/craft_parts/plugins/rust_plugin.py +++ b/craft_parts/plugins/rust_plugin.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2022-2023 Canonical Ltd. +# Copyright 2022-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 @@ -22,14 +22,14 @@ import re import subprocess from textwrap import dedent -from typing import Annotated, Any, cast +from typing import Annotated, Literal, cast from overrides import override from pydantic import AfterValidator from pydantic import validator as pydantic_validator from . import validator -from .base import Plugin, PluginModel, extract_plugin_properties +from .base import Plugin from .properties import PluginProperties logger = logging.getLogger(__name__) @@ -46,9 +46,11 @@ def _validate_list_is_unique(value: list) -> list: UniqueStrList = Annotated[list[str], AfterValidator(_validate_list_is_unique)] -class RustPluginProperties(PluginProperties, PluginModel): +class RustPluginProperties(PluginProperties, frozen=True): """The part properties used by the Rust plugin.""" + plugin: Literal["rust"] = "rust" + # part properties required by the plugin rust_features: UniqueStrList = [] rust_path: UniqueStrList = ["."] @@ -78,24 +80,6 @@ def validate_rust_channel(cls, value: str | None) -> str | None: return value raise ValueError(f"Invalid rust-channel: {value}") - @classmethod - @override - def unmarshal(cls, data: dict[str, Any]) -> "RustPluginProperties": - """Populate class attributes from the part specification. - - :param data: A dictionary containing part properties. - - :return: The populated plugin properties data object. - - :raise pydantic.ValidationError: If validation fails. - """ - plugin_data = extract_plugin_properties( - data, - plugin_name="rust", - required=["source"], - ) - return cls(**plugin_data) - class RustPluginEnvironmentValidator(validator.PluginEnvironmentValidator): """Check the execution environment for the Rust plugin. diff --git a/craft_parts/plugins/scons_plugin.py b/craft_parts/plugins/scons_plugin.py index 3b39cb3d..865ef936 100644 --- a/craft_parts/plugins/scons_plugin.py +++ b/craft_parts/plugins/scons_plugin.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2022 Canonical Ltd. +# Copyright 2022,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 @@ -16,41 +16,27 @@ """The SCons plugin.""" -from typing import Any, cast +from typing import Literal, cast from overrides import override from craft_parts import errors from . import validator -from .base import Plugin, PluginModel, extract_plugin_properties +from .base import Plugin from .properties import PluginProperties -class SConsPluginProperties(PluginProperties, PluginModel): +class SConsPluginProperties(PluginProperties, frozen=True): """The part properties used by the SCons plugin.""" + plugin: Literal["scons"] = "scons" + scons_parameters: list[str] = [] # part properties required by the plugin source: str - @classmethod - @override - def unmarshal(cls, data: dict[str, Any]) -> "SConsPluginProperties": - """Populate make properties from the part specification. - - :param data: A dictionary containing part properties. - - :return: The populated plugin properties data object. - - :raise pydantic.ValidationError: If validation fails. - """ - plugin_data = extract_plugin_properties( - data, plugin_name="scons", required=["source"] - ) - return cls(**plugin_data) - class SConsPluginEnvironmentValidator(validator.PluginEnvironmentValidator): """Check the execution environment for the SCons plugin. diff --git a/docs/common/craft-parts/craft-parts.wordlist.txt b/docs/common/craft-parts/craft-parts.wordlist.txt index aeef20dd..4f5c7718 100644 --- a/docs/common/craft-parts/craft-parts.wordlist.txt +++ b/docs/common/craft-parts/craft-parts.wordlist.txt @@ -89,6 +89,7 @@ InvalidSourceOptions InvalidSourceType Iterable JavaPlugin +JSON LDFLAGS LLVM LTO diff --git a/tests/integration/lifecycle/test_plugin_changesets.py b/tests/integration/lifecycle/test_plugin_changesets.py index 6a733d68..09a3b367 100644 --- a/tests/integration/lifecycle/test_plugin_changesets.py +++ b/tests/integration/lifecycle/test_plugin_changesets.py @@ -16,7 +16,7 @@ import textwrap from pathlib import Path -from typing import Any +from typing import Literal import craft_parts import yaml @@ -31,12 +31,10 @@ def teardown_module(): plugins.unregister_all() -class ExamplePluginProperties(plugins.PluginProperties, plugins.PluginModel): +class ExamplePluginProperties(plugins.PluginProperties, frozen=True): """The application-defined plugin properties.""" - @classmethod - def unmarshal(cls, data: dict[str, Any]): - return cls() + plugin: Literal["example"] = "example" class ExamplePlugin(plugins.Plugin): diff --git a/tests/integration/lifecycle/test_plugin_property_state.py b/tests/integration/lifecycle/test_plugin_property_state.py index 0da10ad9..e62338cf 100644 --- a/tests/integration/lifecycle/test_plugin_property_state.py +++ b/tests/integration/lifecycle/test_plugin_property_state.py @@ -16,7 +16,7 @@ import textwrap from pathlib import Path -from typing import Any +from typing import Literal import craft_parts import pytest @@ -34,16 +34,12 @@ def teardown_module(): plugins.unregister_all() -class ExamplePluginProperties(plugins.PluginProperties): +class ExamplePluginProperties(plugins.PluginProperties, frozen=True): """The application-defined plugin properties.""" - example_property: int | None + plugin: Literal["example"] = "example" - @classmethod - @override - def unmarshal(cls, data: dict[str, Any]) -> "ExamplePluginProperties": - plugin_data = plugins.extract_plugin_properties(data, plugin_name="example") - return cls(**plugin_data) + example_property: int | None class ExamplePlugin(plugins.Plugin): @@ -160,17 +156,11 @@ def test_plugin_property_build_dirty(new_dir): ] -class Example2PluginProperties(plugins.PluginProperties): +class Example2PluginProperties(plugins.PluginProperties, frozen=True): """The application-defined plugin properties.""" example_property: int | None - @classmethod - @override - def unmarshal(cls, data: dict[str, Any]) -> "Example2PluginProperties": - plugin_data = plugins.extract_plugin_properties(data, plugin_name="example") - return cls(**plugin_data) - @classmethod @override def get_pull_properties(cls) -> list[str]: diff --git a/tests/integration/plugins/test_application_plugin.py b/tests/integration/plugins/test_application_plugin.py index 59786dea..33fde347 100644 --- a/tests/integration/plugins/test_application_plugin.py +++ b/tests/integration/plugins/test_application_plugin.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2021-2023 Canonical Ltd. +# Copyright 2021-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 @@ -16,7 +16,7 @@ import textwrap from pathlib import Path -from typing import Any +from typing import Literal import craft_parts import pytest @@ -24,19 +24,13 @@ from craft_parts import Action, ActionType, Step, errors, plugins -class AppPluginProperties(plugins.PluginProperties, plugins.PluginModel): +class AppPluginProperties(plugins.PluginProperties, frozen=True): """The application-defined plugin properties.""" + plugin: Literal["app"] = "app" app_stuff: list[str] source: str - @classmethod - def unmarshal(cls, data: dict[str, Any]): - plugin_data = plugins.extract_plugin_properties( - data, plugin_name="app", required=["source"] - ) - return cls(**plugin_data) - class AppPlugin(plugins.Plugin): """Our application plugin.""" diff --git a/tests/integration/plugins/test_validate_environment.py b/tests/integration/plugins/test_validate_environment.py index 4c463a25..0a50e2c5 100644 --- a/tests/integration/plugins/test_validate_environment.py +++ b/tests/integration/plugins/test_validate_environment.py @@ -17,7 +17,7 @@ import subprocess import textwrap from pathlib import Path -from typing import Any +from typing import Literal import craft_parts import pytest @@ -52,12 +52,10 @@ def mytool_error(new_dir): return tool -class AppPluginProperties(plugins.PluginProperties, plugins.PluginModel): +class AppPluginProperties(plugins.PluginProperties, frozen=True): """The application-defined plugin properties.""" - @classmethod - def unmarshal(cls, data: dict[str, Any]): - return cls() + plugin: Literal["app"] = "app" class AppPluginEnvironmentValidator(plugins.PluginEnvironmentValidator): diff --git a/tests/unit/plugins/test_base.py b/tests/unit/plugins/test_base.py index 534865d4..31c822a3 100644 --- a/tests/unit/plugins/test_base.py +++ b/tests/unit/plugins/test_base.py @@ -14,23 +14,20 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . -from typing import Any, cast +from typing import Literal, cast import pytest from craft_parts.infos import PartInfo, ProjectInfo from craft_parts.parts import Part from craft_parts.plugins import Plugin, PluginProperties -from craft_parts.plugins.base import extract_plugin_properties -class FooPluginProperties(PluginProperties): +class FooPluginProperties(PluginProperties, frozen=True): """Test plugin properties.""" - name: str + plugin: Literal["foo"] = "foo" - @classmethod - def unmarshal(cls, data: dict[str, Any]): - return cls(name=data.get("foo-name", "nothing")) + foo_name: str class FooPlugin(Plugin): @@ -49,7 +46,7 @@ def get_build_environment(self) -> dict[str, str]: def get_build_commands(self) -> list[str]: options = cast(FooPluginProperties, self._options) - return ["hello", options.name] + return ["hello", options.foo_name] def test_plugin(new_dir): @@ -86,16 +83,3 @@ class FaultyPlugin(Plugin): with pytest.raises(TypeError, match=expected): FaultyPlugin(properties=None, part_info=part_info) # type: ignore[reportGeneralTypeIssues] - - -def test_extract_plugin_properties(): - data = { - "foo": True, - "test": "yes", - "test-one": 1, - "test-two": 2, - "not-test-three": 3, - } - - plugin_data = extract_plugin_properties(data, plugin_name="test") - assert plugin_data == {"test-one": 1, "test-two": 2} diff --git a/tests/unit/plugins/test_dump_plugin.py b/tests/unit/plugins/test_dump_plugin.py index 80561490..f55c9ff1 100644 --- a/tests/unit/plugins/test_dump_plugin.py +++ b/tests/unit/plugins/test_dump_plugin.py @@ -40,9 +40,8 @@ def setup_method_fixture(self, new_dir): self._plugin = DumpPlugin(properties=properties, part_info=part_info) def test_unmarshal_error(self): - with pytest.raises(ValueError) as raised: # noqa: PT011 + with pytest.raises(ValueError, match=r"source\n\s+Field required"): DumpPlugin.properties_class.unmarshal({}) - assert str(raised.value) == "'source' is required by the dump plugin" def test_get_build_packages(self): assert self._plugin.get_build_packages() == set() diff --git a/tests/unit/plugins/test_plugins.py b/tests/unit/plugins/test_plugins.py index 06503331..36e5b270 100644 --- a/tests/unit/plugins/test_plugins.py +++ b/tests/unit/plugins/test_plugins.py @@ -54,6 +54,7 @@ class TestGetPlugin: ("make", MakePlugin, {"source": "."}), ("meson", MesonPlugin, {"source": "."}), ("nil", NilPlugin, {}), + ("nil", NilPlugin, {"source": "."}), ("npm", NpmPlugin, {"source": "."}), ("python", PythonPlugin, {"source": "."}), ("qmake", QmakePlugin, {"source": "."}), diff --git a/tests/unit/plugins/test_properties.py b/tests/unit/plugins/test_properties.py index ddf6dba7..99ed3efb 100644 --- a/tests/unit/plugins/test_properties.py +++ b/tests/unit/plugins/test_properties.py @@ -14,30 +14,48 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . -from typing import Any +from typing import Literal +import pydantic +import pytest from craft_parts.plugins import PluginProperties -def test_properties_unmarshal(): - prop = PluginProperties.unmarshal({}) - assert isinstance(prop, PluginProperties) +class FooProperties(PluginProperties, frozen=True): + plugin: Literal["foo"] = "foo" + foo_parameters: list[str] | None = None -class FooProperties(PluginProperties): - foo_parameters: list[str] | None = None +VALID_FOO_DICTS = [ + {}, + {"foo-parameters": []}, + {"plugin": "foo", "foo-parameters": ["bar"]}, + {"source": "https://example.com/foo.git", "plugin": "foo"}, + {"ignored-property": True}, + {"foo": "also-ignored"}, +] + + +@pytest.mark.parametrize("data", VALID_FOO_DICTS) +def test_properties_unmarshal_valid(data): + prop = FooProperties.unmarshal(data) + assert isinstance(prop, PluginProperties) + - @classmethod - def unmarshal(cls, data: dict[str, Any]) -> "FooProperties": - return cls(**data) +@pytest.mark.parametrize("data", [{"foo-invalid": True}]) +def test_properties_unmarshal_invalid(data): + with pytest.raises( + pydantic.ValidationError, match="Extra inputs are not permitted" + ): + FooProperties.unmarshal(data) def test_properties_marshal(): prop = FooProperties.unmarshal({"foo-parameters": ["foo", "bar"]}) - assert prop.marshal() == {"foo-parameters": ["foo", "bar"]} + assert prop.marshal() == {"source": None, "foo-parameters": ["foo", "bar"]} def test_properties_defaults(): prop = FooProperties.unmarshal({}) assert prop.get_pull_properties() == [] - assert prop.get_build_properties() == ["foo-parameters"] + assert prop.get_build_properties() == ["source", "foo-parameters"] diff --git a/tests/unit/plugins/test_validator.py b/tests/unit/plugins/test_validator.py index 0494b1f7..efd55f18 100644 --- a/tests/unit/plugins/test_validator.py +++ b/tests/unit/plugins/test_validator.py @@ -15,9 +15,8 @@ # along with this program. If not, see . import subprocess -from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import Literal import pytest from craft_parts import errors @@ -48,17 +47,14 @@ def empty_foo_exe(new_dir): def part_info(new_dir): return PartInfo( project_info=ProjectInfo(application_name="test", cache_dir=new_dir), - part=Part("my-part", {}), + part=Part("my-part", {"plugin": "foo"}), ) -@dataclass -class FooPluginProperties(PluginProperties): +class FooPluginProperties(PluginProperties, frozen=True): """Test plugin properties.""" - @classmethod - def unmarshal(cls, data: dict[str, Any]): - return cls() + plugin: Literal["foo"] = "foo" class FooPluginEnvironmentValidator(PluginEnvironmentValidator):