From 834001e690344324a29b734770635413caac4157 Mon Sep 17 00:00:00 2001 From: Christian Heimes Date: Mon, 13 Apr 2026 12:43:46 +0200 Subject: [PATCH] feat: Configuration for new patch settings Add scaffolding for new patcher configuration and framework. The commit implements the core of the configuration system. The patcher logic is not implemented, yet. Patchers are not hooked up to package build settings and build logic. See: #939 Signed-off-by: Christian Heimes --- src/fromager/packagesettings/_patch.py | 279 ++++++++++++++++++++++ src/fromager/packagesettings/_typedefs.py | 16 ++ tests/test_patchsettings.py | 51 ++++ 3 files changed, 346 insertions(+) create mode 100644 src/fromager/packagesettings/_patch.py create mode 100644 tests/test_patchsettings.py diff --git a/src/fromager/packagesettings/_patch.py b/src/fromager/packagesettings/_patch.py new file mode 100644 index 00000000..73718fbe --- /dev/null +++ b/src/fromager/packagesettings/_patch.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +import logging +import pathlib +import re +import typing + +import pydantic +from packaging.requirements import Requirement +from packaging.version import Version + +from ..pyproject import PyprojectFix +from ._typedefs import ( + MODEL_CONFIG, + Package, + SpecifierSetType, +) + +if typing.TYPE_CHECKING: + from .. import build_environment, context + +logger = logging.getLogger(__name__) + +SDIST_STEP = typing.Literal["sdist"] +DIST_INFO_METADATA_STEP = typing.Literal["dist-info-metadata"] + + +class PatchBase(pydantic.BaseModel): + """Base class for patch setting""" + + model_config = MODEL_CONFIG + + step: typing.ClassVar[SDIST_STEP | DIST_INFO_METADATA_STEP] + """In which step of the build process does the plugin run? + + - ``sdist`` plugins run between unpackagin and repacking of source + distributions + - ``dist-info-metadata`` run when the final wheel file is assembled. + They also affect ``get_install_dependencies_of_sdist`` hook. + """ + + op: str + """Operation name (discriminator field)""" + + title: str + """Human-readable title for the config setting""" + + when_version: SpecifierSetType | None = None + """Only patch when specifer set matches""" + + ignore_missing: bool = False + """Don't fail when operation does not modify a file""" + + +class SdistPatchBase(PatchBase): + """Base class for patching of sdists""" + + step = "sdist" + + def __call__( + self, + *, + ctx: context.WorkContext, + req: Requirement, + version: Version, + sdist_root_dir: pathlib.Path, + ) -> None: + raise NotImplementedError + + +class PatchReplaceLine(SdistPatchBase): + """Replace line in sources""" + + op: typing.Literal["replace-line"] + files: typing.Annotated[list[str], pydantic.Field(min_length=1)] + search: re.Pattern + replace: str + + def __call__( + self, + *, + ctx: context.WorkContext, + req: Requirement, + version: Version, + sdist_root_dir: pathlib.Path, + ) -> None: + # TODO + raise NotImplementedError + + +class PatchDeleteLine(SdistPatchBase): + """Delete line in sources""" + + op: typing.Literal["delete-line"] + files: typing.Annotated[list[str], pydantic.Field(min_length=1)] + search: re.Pattern + + def __call__( + self, + *, + ctx: context.WorkContext, + req: Requirement, + version: Version, + sdist_root_dir: pathlib.Path, + ) -> None: + # TODO + raise NotImplementedError + + +class PatchPyProjectBuildSystem(SdistPatchBase): + """Modify pyproject.toml [build-system] + + Replaces project_override setting + """ + + op: typing.Literal["pyproject-build-system"] + + update_build_requires: list[str] = pydantic.Field(default_factory=list) + """Add / update requirements to pyproject.toml `[build-system] requires` + """ + + # TODO: use list[Package] + remove_build_requires: list[Package] = pydantic.Field(default_factory=list) + """Remove requirement from pyproject.toml `[build-system] requires` + """ + + requires_external: list[str] = pydantic.Field(default_factory=list) + """Add / update Requires-External core metadata field + + Each entry contains a string describing some dependency in the system + that the distribution is to be used. See + https://packaging.python.org/en/latest/specifications/core-metadata/#requires-external-multiple-use + + .. note:: + Fromager does not modify ``METADATA`` file, yet. Read the information + from an ``importlib.metadata`` distribution with + ``tomlkit.loads(dist(pkgname).read_text("fromager-build-settings"))``. + """ + + @pydantic.field_validator("update_build_requires") + @classmethod + def validate_update_build_requires(cls, v: list[str]) -> list[str]: + """update_build_requires fields must be valid requirements""" + for reqstr in v: + Requirement(reqstr) + return v + + def __call__( + self, + *, + ctx: context.WorkContext, + req: Requirement, + version: Version, + sdist_root_dir: pathlib.Path, + ) -> None: + if self.update_build_requires or self.remove_build_requires: + pbi = ctx.package_build_info(req) + fixer = PyprojectFix( + req, + build_dir=pbi.build_dir(sdist_root_dir), + update_build_requires=self.update_build_requires, + remove_build_requires=self.remove_build_requires, + ) + fixer.run() + + +class FixPkgInfoVersion(SdistPatchBase): + """Fix PKG-INFO Metadata version of an sdist""" + + op: typing.Literal["fix-pkg-info"] + metadata_version: str = "2.4" + + def __call__( + self, + *, + ctx: context.WorkContext, + req: Requirement, + version: Version, + sdist_root_dir: pathlib.Path, + ) -> None: + # TODO + raise NotImplementedError + + +# --------------------------------------------------------------------------- + + +class DistInfoMetadataPatchBase(PatchBase): + """Base class for patching of dist-info metadata + + The patchers affect wheel metadata and outcome of + ``get_install_dependencies_of_sdist``. + """ + + step = "dist-info-metadata" + + def __call__( + self, + *, + ctx: context.WorkContext, + req: Requirement, + version: Version, + dist_info_dir: pathlib.Path, + build_env: build_environment.BuildEnvironment, + ) -> None: + raise NotImplementedError + + +class PinRequiresDistToConstraint(DistInfoMetadataPatchBase): + """Pin install requirements to constraint + + Update an installation requirement version and pin it to the same + version as configured in constraints. + """ + + op: typing.Literal["pin-requires-dist-to-constraint"] + requirements: typing.Annotated[list[Package], pydantic.Field(min_length=1)] + + def __call__( + self, + *, + ctx: context.WorkContext, + req: Requirement, + version: Version, + dist_info_dir: pathlib.Path, + build_env: build_environment.BuildEnvironment, + ) -> None: + # TODO + raise NotImplementedError + + +PatchUnion = typing.Annotated[ + PatchReplaceLine + | PatchDeleteLine + | PatchPyProjectBuildSystem + | FixPkgInfoVersion + | PinRequiresDistToConstraint, + pydantic.Field(..., discriminator="op"), +] + + +class Patches(pydantic.RootModel[list[PatchUnion]]): + def run_sdist_patcher( + self, + *, + ctx: context.WorkContext, + req: Requirement, + version: Version, + sdist_root_dir: pathlib.Path, + ) -> None: + for patcher in self.root: + if patcher == SDIST_STEP: + assert isinstance(patcher, SdistPatchBase) + patcher( + ctx=ctx, + req=req, + version=version, + sdist_root_dir=sdist_root_dir, + ) + + def run_dist_info_metadata_patcher( + self, + *, + ctx: context.WorkContext, + req: Requirement, + version: Version, + dist_info_dir: pathlib.Path, + build_env: build_environment.BuildEnvironment, + ) -> None: + for patcher in self.root: + if patcher.step == DIST_INFO_METADATA_STEP: + assert isinstance(patcher, DistInfoMetadataPatchBase) + patcher( + ctx=ctx, + req=req, + version=version, + dist_info_dir=dist_info_dir, + build_env=build_env, + ) diff --git a/src/fromager/packagesettings/_typedefs.py b/src/fromager/packagesettings/_typedefs.py index aeae5909..802f5ecc 100644 --- a/src/fromager/packagesettings/_typedefs.py +++ b/src/fromager/packagesettings/_typedefs.py @@ -7,6 +7,7 @@ from collections.abc import Mapping import pydantic +from packaging.specifiers import SpecifierSet from packaging.utils import NormalizedName, canonicalize_name from packaging.version import Version from pydantic_core import CoreSchema, core_schema @@ -58,6 +59,21 @@ def __get_pydantic_core_schema__( ) +class SpecifierSetType(SpecifierSet): + """Pydantic-aware specifier set""" + + @classmethod + def __get_pydantic_core_schema__( + cls, source_type: typing.Any, handler: pydantic.GetCoreSchemaHandler + ) -> CoreSchema: + return core_schema.with_info_plain_validator_function( + lambda v, _: SpecifierSet(v), + serialization=core_schema.plain_serializer_function_ser_schema( + str, when_used="json" + ), + ) + + # environment variables map def _validate_envkey(v: typing.Any) -> str: """Validate env key, converts int, float, bool""" diff --git a/tests/test_patchsettings.py b/tests/test_patchsettings.py new file mode 100644 index 00000000..1fc30ff3 --- /dev/null +++ b/tests/test_patchsettings.py @@ -0,0 +1,51 @@ +import pydantic +import yaml + +from fromager.packagesettings import MODEL_CONFIG +from fromager.packagesettings._patch import Patches + +# example from new patcher proposal +EXAMPLE = """ +patch: + - title: Comment out 'foo' requirement for version >= 1.2 + op: replace-line + files: + - 'requirements.txt' + search: '^(foo.*)$' + replace: '# \\1' + when_version: '>=1.2' + ignore_missing: true + + - title: Remove 'bar' from constraints.txt + op: delete-line + files: + - 'constraints.txt' + search: 'bar.*' + + - title: Fix PKG-INFO metadata version + op: fix-pkg-info + metadata_version: '2.4' + when_version: '<1.0' + + - title: Add missing setuptools to pyproject.toml + op: pyproject-build-system + update_build_requires: + - setuptools + + - title: Update Torch install requirement to version in build env + op: pin-requires-dist-to-constraint + requirements: + - torch +""" + + +def test_patch_settings_basics() -> None: + # temporary test case until patch settings are hooked up to PBI + + class Settings(pydantic.BaseModel): + model_config = MODEL_CONFIG + patch: Patches + + settings = Settings(**yaml.safe_load(EXAMPLE)) + patchers = settings.patch.root + assert len(patchers) == 5