Skip to content

Commit 122f04c

Browse files
committed
Fixer for pyproject.toml build-system settings
- add missing pyproject.toml - add or update `[build-system] requires` Requirements in `update_build_requires` are added to `[build-system] requires`. If a requirement name matches an existing name, then the requirement is replaced. Requirements in `remove_build_requires` are removed from `[build-system] requires`. Fixes: #260 Fixes: #274 Signed-off-by: Christian Heimes <[email protected]>
1 parent d1deec8 commit 122f04c

File tree

7 files changed

+353
-4
lines changed

7 files changed

+353
-4
lines changed

docs/customization.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,42 @@ in subdirectories, with the filenames prefixed with the source directory name is
191191
also supported. The newer format, using subdirectories, is preferred because it
192192
avoids name collisions between variant source trees.
193193

194+
## `project_override` section
195+
196+
The `project_override` configures the `pyproject.toml` auto-fixer. It can
197+
automatically create a missing `pyproject.toml` or modify an existing file.
198+
Packages are matched by canonical name.
199+
200+
- `remove_build_requires` is a list of package names. Any build requirement
201+
in the list is removed
202+
- `update_build_requires` a list of requirement specifiers. Existing specs
203+
are replaced and missing specs are added. The option can be used to add,
204+
remove, or change a version constraint.
205+
206+
```yaml
207+
project_override:
208+
remove_build_requires:
209+
- cmake
210+
update_build_requires:
211+
- setuptools>=68.0.0
212+
- torch
213+
- triton
214+
```
215+
216+
Incoming `pyproject.toml`:
217+
218+
```yaml
219+
[build-system]
220+
requires = ["cmake", "setuptools>48.0", "torch>=2.3.0"]
221+
```
222+
223+
Output:
224+
225+
```yaml
226+
[build-system]
227+
requires = ["setuptools>=68.0.0", "torch", "triton"]
228+
```
229+
194230
## Override plugins
195231

196232
For more complex customization requirements, create an override plugin.

src/fromager/packagesettings.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import psutil
1010
import pydantic
1111
import yaml
12+
from packaging.requirements import Requirement
1213
from packaging.utils import BuildTag, NormalizedName, canonicalize_name
1314
from packaging.version import Version
1415
from pydantic import Field
@@ -171,6 +172,27 @@ class BuildOptions(pydantic.BaseModel):
171172
"""
172173

173174

175+
class ProjectOverride(pydantic.BaseModel):
176+
"""Override pyproject.toml settings"""
177+
178+
model_config = MODEL_CONFIG
179+
180+
update_build_requires: list[str] = Field(default_factory=list)
181+
"""Add / update requirements to pyproject.toml `[build-system] requires`
182+
"""
183+
184+
remove_build_requires: list[Package] = Field(default_factory=list)
185+
"""Remove requirement from pyproject.toml `[build-system] requires`
186+
"""
187+
188+
@pydantic.field_validator("update_build_requires")
189+
@classmethod
190+
def validate_update_build_requires(cls, v: list[str]) -> list[str]:
191+
for reqstr in v:
192+
Requirement(reqstr)
193+
return v
194+
195+
174196
class VariantInfo(pydantic.BaseModel):
175197
"""Variant information for a package"""
176198

@@ -241,6 +263,9 @@ class PackageSettings(pydantic.BaseModel):
241263
build_options: BuildOptions = Field(default_factory=BuildOptions)
242264
"""Build system options"""
243265

266+
project_override: ProjectOverride = Field(default_factory=ProjectOverride)
267+
"""Patch project settings"""
268+
244269
variants: Mapping[Variant, VariantInfo] = Field(default_factory=dict)
245270
"""Variant configuration"""
246271

@@ -577,6 +602,10 @@ def build_ext_parallel(self) -> bool:
577602
"""Configure [build_ext]parallel for setuptools?"""
578603
return self._ps.build_options.build_ext_parallel
579604

605+
@property
606+
def project_override(self) -> ProjectOverride:
607+
return self._ps.project_override
608+
580609
def serialize(self, **kwargs) -> dict[str, typing.Any]:
581610
return self._ps.serialize(**kwargs)
582611

src/fromager/pyproject.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""Tooling for pyproject.toml"""
2+
3+
import logging
4+
import pathlib
5+
import typing
6+
7+
import tomlkit
8+
from packaging.requirements import Requirement
9+
from packaging.utils import NormalizedName, canonicalize_name
10+
11+
from . import context
12+
13+
logger = logging.getLogger(__name__)
14+
15+
TomlDict = dict[str, typing.Any]
16+
17+
# section / key names
18+
BUILD_SYSTEM = "build-system"
19+
BUILD_BACKEND = "build-backend"
20+
BUILD_REQUIRES = "requires"
21+
22+
23+
class PyprojectFix:
24+
"""Auto-fixer for pyproject.toml settings
25+
26+
- add missing pyproject.toml
27+
- add or update `[build-system] requires`
28+
29+
Requirements in `update_build_requires` are added to
30+
`[build-system] requires`. If a requirement name matches an existing
31+
name, then the requirement is replaced.
32+
33+
Requirements in `remove_build_requires` are removed from
34+
`[build-system] requires`.
35+
"""
36+
37+
def __init__(
38+
self,
39+
req: Requirement,
40+
*,
41+
build_dir: pathlib.Path,
42+
update_build_requires: list[str],
43+
remove_build_requires: list[NormalizedName],
44+
) -> None:
45+
self.req = req
46+
self.build_dir = build_dir
47+
self.update_requirements = update_build_requires
48+
self.remove_requirements = remove_build_requires
49+
self.pyproject_toml = self.build_dir / "pyproject.toml"
50+
self.setup_py = self.build_dir / "setup.py"
51+
52+
def run(self) -> None:
53+
doc = self._load()
54+
build_system = self._default_build_system(doc)
55+
self._update_build_requires(build_system)
56+
logger.debug(
57+
"%s: pyproject.toml %s: %s=%r, %s=%r",
58+
self.req.name,
59+
BUILD_SYSTEM,
60+
BUILD_BACKEND,
61+
build_system.get(BUILD_BACKEND),
62+
BUILD_REQUIRES,
63+
build_system.get(BUILD_REQUIRES),
64+
)
65+
self._save(doc)
66+
67+
def _load(self) -> tomlkit.TOMLDocument:
68+
"""Load pyproject toml or create empty TOML doc"""
69+
try:
70+
doc = tomlkit.parse(self.pyproject_toml.read_bytes())
71+
logger.debug("%s: loaded pyproject.toml", self.req.name)
72+
except FileNotFoundError:
73+
logger.debug("%s: no pyproject.toml, create empty doc", self.req.name)
74+
doc = tomlkit.parse(b"")
75+
return doc
76+
77+
def _save(self, doc: tomlkit.TOMLDocument) -> None:
78+
"""Write pyproject.toml to build directory"""
79+
with self.pyproject_toml.open("w") as f:
80+
tomlkit.dump(doc, f)
81+
82+
def _default_build_system(self, doc: tomlkit.TOMLDocument) -> TomlDict:
83+
"""Add / fix basic 'build-system' dict"""
84+
build_system: TomlDict | None = doc.get(BUILD_SYSTEM)
85+
if build_system is None:
86+
logger.debug("%s: adding %s", self.req.name, BUILD_SYSTEM)
87+
build_system = doc.setdefault(BUILD_SYSTEM, {})
88+
# ensure `[build-system] requires` exists
89+
build_system.setdefault(BUILD_REQUIRES, [])
90+
return build_system
91+
92+
def _update_build_requires(self, build_system: TomlDict) -> None:
93+
old_requires = build_system[BUILD_REQUIRES]
94+
# always include setuptools
95+
req_map: dict[NormalizedName, Requirement] = {
96+
canonicalize_name("setuptools"): Requirement("setuptools"),
97+
}
98+
# parse original build reqirements (if available)
99+
for reqstr in old_requires:
100+
req = Requirement(reqstr)
101+
req_map[canonicalize_name(req.name)] = req
102+
# remove unwanted requirements
103+
for name in self.remove_requirements:
104+
req_map.pop(canonicalize_name(name), None)
105+
# add / update requirements
106+
for reqstr in self.update_requirements:
107+
req = Requirement(reqstr)
108+
req_map[canonicalize_name(req.name)] = req
109+
110+
new_requires = sorted(str(req) for req in req_map.values())
111+
if set(new_requires) != set(old_requires):
112+
# ignore order of items
113+
build_system[BUILD_REQUIRES] = new_requires
114+
logger.info(
115+
"%s: changed build-system requires from %r to %r",
116+
self.req.name,
117+
old_requires,
118+
new_requires,
119+
)
120+
121+
122+
def apply_project_override(
123+
ctx: context.WorkContext, req: Requirement, sdist_root_dir: pathlib.Path
124+
) -> None:
125+
"""Apply project_overrides"""
126+
pbi = ctx.package_build_info(req)
127+
update_build_requires = pbi.project_override.update_build_requires
128+
remove_build_requires = pbi.project_override.remove_build_requires
129+
if update_build_requires or remove_build_requires:
130+
logger.debug(
131+
f"{req.name}: applying project_override: "
132+
f"{update_build_requires=}, {remove_build_requires=}"
133+
)
134+
build_dir = pbi.build_dir(sdist_root_dir)
135+
PyprojectFix(
136+
req,
137+
build_dir=build_dir,
138+
update_build_requires=update_build_requires,
139+
remove_build_requires=remove_build_requires,
140+
).run()
141+
else:
142+
logger.debug(f"{req.name}: no project_override")

src/fromager/sources.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
dependencies,
2020
external_commands,
2121
overrides,
22+
pyproject,
2223
resolver,
2324
tarballs,
2425
vendor_rust,
@@ -367,11 +368,34 @@ def default_prepare_source(
367368
) -> tuple[pathlib.Path, bool]:
368369
source_root_dir, is_new = unpack_source(ctx, source_filename)
369370
if is_new:
370-
patch_source(ctx, source_root_dir, req)
371-
vendor_rust.vendor_rust(req, source_root_dir)
371+
prepare_new_source(
372+
ctx=ctx,
373+
req=req,
374+
source_root_dir=source_root_dir,
375+
version=version,
376+
)
372377
return source_root_dir, is_new
373378

374379

380+
def prepare_new_source(
381+
ctx: context.WorkContext,
382+
req: Requirement,
383+
source_root_dir: pathlib.Path,
384+
version: Version,
385+
) -> None:
386+
"""Default steps for new sources
387+
388+
`default_prepare_source` runs this function when the sources are new.
389+
"""
390+
patch_source(ctx, source_root_dir, req)
391+
pyproject.apply_project_override(
392+
ctx=ctx,
393+
req=req,
394+
sdist_root_dir=source_root_dir,
395+
)
396+
vendor_rust.vendor_rust(req, source_root_dir)
397+
398+
375399
def build_sdist(
376400
ctx: context.WorkContext,
377401
req: Requirement,

tests/test_packagesettings.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import pathlib
2+
import typing
23
from unittest.mock import Mock, patch
34

45
import pydantic
@@ -20,7 +21,7 @@
2021
TEST_EMPTY_PKG = "test-empty-pkg"
2122
TEST_OTHER_PKG = "test-other-pkg"
2223

23-
FULL_EXPECTED = {
24+
FULL_EXPECTED: dict[str, typing.Any] = {
2425
"build_dir": pathlib.Path("python"),
2526
"build_options": {
2627
"build_ext_parallel": True,
@@ -43,6 +44,10 @@
4344
},
4445
"name": "test-pkg",
4546
"has_config": True,
47+
"project_override": {
48+
"remove_build_requires": ["cmake"],
49+
"update_build_requires": ["setuptools>=68.0.0", "torch"],
50+
},
4651
"resolver_dist": {
4752
"include_sdists": True,
4853
"include_wheels": False,
@@ -67,7 +72,7 @@
6772
},
6873
}
6974

70-
EMPTY_EXPECTED = {
75+
EMPTY_EXPECTED: dict[str, typing.Any] = {
7176
"name": "test-empty-pkg",
7277
"build_dir": None,
7378
"build_options": {
@@ -82,6 +87,10 @@
8287
"destination_filename": None,
8388
},
8489
"has_config": True,
90+
"project_override": {
91+
"remove_build_requires": [],
92+
"update_build_requires": [],
93+
},
8594
"resolver_dist": {
8695
"sdist_server_url": None,
8796
"include_sdists": True,

0 commit comments

Comments
 (0)