Skip to content

Commit 834001e

Browse files
committed
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 <cheimes@redhat.com>
1 parent b8f4441 commit 834001e

3 files changed

Lines changed: 346 additions & 0 deletions

File tree

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import pathlib
5+
import re
6+
import typing
7+
8+
import pydantic
9+
from packaging.requirements import Requirement
10+
from packaging.version import Version
11+
12+
from ..pyproject import PyprojectFix
13+
from ._typedefs import (
14+
MODEL_CONFIG,
15+
Package,
16+
SpecifierSetType,
17+
)
18+
19+
if typing.TYPE_CHECKING:
20+
from .. import build_environment, context
21+
22+
logger = logging.getLogger(__name__)
23+
24+
SDIST_STEP = typing.Literal["sdist"]
25+
DIST_INFO_METADATA_STEP = typing.Literal["dist-info-metadata"]
26+
27+
28+
class PatchBase(pydantic.BaseModel):
29+
"""Base class for patch setting"""
30+
31+
model_config = MODEL_CONFIG
32+
33+
step: typing.ClassVar[SDIST_STEP | DIST_INFO_METADATA_STEP]
34+
"""In which step of the build process does the plugin run?
35+
36+
- ``sdist`` plugins run between unpackagin and repacking of source
37+
distributions
38+
- ``dist-info-metadata`` run when the final wheel file is assembled.
39+
They also affect ``get_install_dependencies_of_sdist`` hook.
40+
"""
41+
42+
op: str
43+
"""Operation name (discriminator field)"""
44+
45+
title: str
46+
"""Human-readable title for the config setting"""
47+
48+
when_version: SpecifierSetType | None = None
49+
"""Only patch when specifer set matches"""
50+
51+
ignore_missing: bool = False
52+
"""Don't fail when operation does not modify a file"""
53+
54+
55+
class SdistPatchBase(PatchBase):
56+
"""Base class for patching of sdists"""
57+
58+
step = "sdist"
59+
60+
def __call__(
61+
self,
62+
*,
63+
ctx: context.WorkContext,
64+
req: Requirement,
65+
version: Version,
66+
sdist_root_dir: pathlib.Path,
67+
) -> None:
68+
raise NotImplementedError
69+
70+
71+
class PatchReplaceLine(SdistPatchBase):
72+
"""Replace line in sources"""
73+
74+
op: typing.Literal["replace-line"]
75+
files: typing.Annotated[list[str], pydantic.Field(min_length=1)]
76+
search: re.Pattern
77+
replace: str
78+
79+
def __call__(
80+
self,
81+
*,
82+
ctx: context.WorkContext,
83+
req: Requirement,
84+
version: Version,
85+
sdist_root_dir: pathlib.Path,
86+
) -> None:
87+
# TODO
88+
raise NotImplementedError
89+
90+
91+
class PatchDeleteLine(SdistPatchBase):
92+
"""Delete line in sources"""
93+
94+
op: typing.Literal["delete-line"]
95+
files: typing.Annotated[list[str], pydantic.Field(min_length=1)]
96+
search: re.Pattern
97+
98+
def __call__(
99+
self,
100+
*,
101+
ctx: context.WorkContext,
102+
req: Requirement,
103+
version: Version,
104+
sdist_root_dir: pathlib.Path,
105+
) -> None:
106+
# TODO
107+
raise NotImplementedError
108+
109+
110+
class PatchPyProjectBuildSystem(SdistPatchBase):
111+
"""Modify pyproject.toml [build-system]
112+
113+
Replaces project_override setting
114+
"""
115+
116+
op: typing.Literal["pyproject-build-system"]
117+
118+
update_build_requires: list[str] = pydantic.Field(default_factory=list)
119+
"""Add / update requirements to pyproject.toml `[build-system] requires`
120+
"""
121+
122+
# TODO: use list[Package]
123+
remove_build_requires: list[Package] = pydantic.Field(default_factory=list)
124+
"""Remove requirement from pyproject.toml `[build-system] requires`
125+
"""
126+
127+
requires_external: list[str] = pydantic.Field(default_factory=list)
128+
"""Add / update Requires-External core metadata field
129+
130+
Each entry contains a string describing some dependency in the system
131+
that the distribution is to be used. See
132+
https://packaging.python.org/en/latest/specifications/core-metadata/#requires-external-multiple-use
133+
134+
.. note::
135+
Fromager does not modify ``METADATA`` file, yet. Read the information
136+
from an ``importlib.metadata`` distribution with
137+
``tomlkit.loads(dist(pkgname).read_text("fromager-build-settings"))``.
138+
"""
139+
140+
@pydantic.field_validator("update_build_requires")
141+
@classmethod
142+
def validate_update_build_requires(cls, v: list[str]) -> list[str]:
143+
"""update_build_requires fields must be valid requirements"""
144+
for reqstr in v:
145+
Requirement(reqstr)
146+
return v
147+
148+
def __call__(
149+
self,
150+
*,
151+
ctx: context.WorkContext,
152+
req: Requirement,
153+
version: Version,
154+
sdist_root_dir: pathlib.Path,
155+
) -> None:
156+
if self.update_build_requires or self.remove_build_requires:
157+
pbi = ctx.package_build_info(req)
158+
fixer = PyprojectFix(
159+
req,
160+
build_dir=pbi.build_dir(sdist_root_dir),
161+
update_build_requires=self.update_build_requires,
162+
remove_build_requires=self.remove_build_requires,
163+
)
164+
fixer.run()
165+
166+
167+
class FixPkgInfoVersion(SdistPatchBase):
168+
"""Fix PKG-INFO Metadata version of an sdist"""
169+
170+
op: typing.Literal["fix-pkg-info"]
171+
metadata_version: str = "2.4"
172+
173+
def __call__(
174+
self,
175+
*,
176+
ctx: context.WorkContext,
177+
req: Requirement,
178+
version: Version,
179+
sdist_root_dir: pathlib.Path,
180+
) -> None:
181+
# TODO
182+
raise NotImplementedError
183+
184+
185+
# ---------------------------------------------------------------------------
186+
187+
188+
class DistInfoMetadataPatchBase(PatchBase):
189+
"""Base class for patching of dist-info metadata
190+
191+
The patchers affect wheel metadata and outcome of
192+
``get_install_dependencies_of_sdist``.
193+
"""
194+
195+
step = "dist-info-metadata"
196+
197+
def __call__(
198+
self,
199+
*,
200+
ctx: context.WorkContext,
201+
req: Requirement,
202+
version: Version,
203+
dist_info_dir: pathlib.Path,
204+
build_env: build_environment.BuildEnvironment,
205+
) -> None:
206+
raise NotImplementedError
207+
208+
209+
class PinRequiresDistToConstraint(DistInfoMetadataPatchBase):
210+
"""Pin install requirements to constraint
211+
212+
Update an installation requirement version and pin it to the same
213+
version as configured in constraints.
214+
"""
215+
216+
op: typing.Literal["pin-requires-dist-to-constraint"]
217+
requirements: typing.Annotated[list[Package], pydantic.Field(min_length=1)]
218+
219+
def __call__(
220+
self,
221+
*,
222+
ctx: context.WorkContext,
223+
req: Requirement,
224+
version: Version,
225+
dist_info_dir: pathlib.Path,
226+
build_env: build_environment.BuildEnvironment,
227+
) -> None:
228+
# TODO
229+
raise NotImplementedError
230+
231+
232+
PatchUnion = typing.Annotated[
233+
PatchReplaceLine
234+
| PatchDeleteLine
235+
| PatchPyProjectBuildSystem
236+
| FixPkgInfoVersion
237+
| PinRequiresDistToConstraint,
238+
pydantic.Field(..., discriminator="op"),
239+
]
240+
241+
242+
class Patches(pydantic.RootModel[list[PatchUnion]]):
243+
def run_sdist_patcher(
244+
self,
245+
*,
246+
ctx: context.WorkContext,
247+
req: Requirement,
248+
version: Version,
249+
sdist_root_dir: pathlib.Path,
250+
) -> None:
251+
for patcher in self.root:
252+
if patcher == SDIST_STEP:
253+
assert isinstance(patcher, SdistPatchBase)
254+
patcher(
255+
ctx=ctx,
256+
req=req,
257+
version=version,
258+
sdist_root_dir=sdist_root_dir,
259+
)
260+
261+
def run_dist_info_metadata_patcher(
262+
self,
263+
*,
264+
ctx: context.WorkContext,
265+
req: Requirement,
266+
version: Version,
267+
dist_info_dir: pathlib.Path,
268+
build_env: build_environment.BuildEnvironment,
269+
) -> None:
270+
for patcher in self.root:
271+
if patcher.step == DIST_INFO_METADATA_STEP:
272+
assert isinstance(patcher, DistInfoMetadataPatchBase)
273+
patcher(
274+
ctx=ctx,
275+
req=req,
276+
version=version,
277+
dist_info_dir=dist_info_dir,
278+
build_env=build_env,
279+
)

src/fromager/packagesettings/_typedefs.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from collections.abc import Mapping
88

99
import pydantic
10+
from packaging.specifiers import SpecifierSet
1011
from packaging.utils import NormalizedName, canonicalize_name
1112
from packaging.version import Version
1213
from pydantic_core import CoreSchema, core_schema
@@ -58,6 +59,21 @@ def __get_pydantic_core_schema__(
5859
)
5960

6061

62+
class SpecifierSetType(SpecifierSet):
63+
"""Pydantic-aware specifier set"""
64+
65+
@classmethod
66+
def __get_pydantic_core_schema__(
67+
cls, source_type: typing.Any, handler: pydantic.GetCoreSchemaHandler
68+
) -> CoreSchema:
69+
return core_schema.with_info_plain_validator_function(
70+
lambda v, _: SpecifierSet(v),
71+
serialization=core_schema.plain_serializer_function_ser_schema(
72+
str, when_used="json"
73+
),
74+
)
75+
76+
6177
# environment variables map
6278
def _validate_envkey(v: typing.Any) -> str:
6379
"""Validate env key, converts int, float, bool"""

tests/test_patchsettings.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import pydantic
2+
import yaml
3+
4+
from fromager.packagesettings import MODEL_CONFIG
5+
from fromager.packagesettings._patch import Patches
6+
7+
# example from new patcher proposal
8+
EXAMPLE = """
9+
patch:
10+
- title: Comment out 'foo' requirement for version >= 1.2
11+
op: replace-line
12+
files:
13+
- 'requirements.txt'
14+
search: '^(foo.*)$'
15+
replace: '# \\1'
16+
when_version: '>=1.2'
17+
ignore_missing: true
18+
19+
- title: Remove 'bar' from constraints.txt
20+
op: delete-line
21+
files:
22+
- 'constraints.txt'
23+
search: 'bar.*'
24+
25+
- title: Fix PKG-INFO metadata version
26+
op: fix-pkg-info
27+
metadata_version: '2.4'
28+
when_version: '<1.0'
29+
30+
- title: Add missing setuptools to pyproject.toml
31+
op: pyproject-build-system
32+
update_build_requires:
33+
- setuptools
34+
35+
- title: Update Torch install requirement to version in build env
36+
op: pin-requires-dist-to-constraint
37+
requirements:
38+
- torch
39+
"""
40+
41+
42+
def test_patch_settings_basics() -> None:
43+
# temporary test case until patch settings are hooked up to PBI
44+
45+
class Settings(pydantic.BaseModel):
46+
model_config = MODEL_CONFIG
47+
patch: Patches
48+
49+
settings = Settings(**yaml.safe_load(EXAMPLE))
50+
patchers = settings.patch.root
51+
assert len(patchers) == 5

0 commit comments

Comments
 (0)