Skip to content

Commit ff85df3

Browse files
jskladanclaude
andcommitted
feat(project_override): add add_dynamic_field to project_override
Allows declaratively adding PEP 621 dynamic fields to pyproject.toml via package settings YAML, replacing the need for custom prepare_source plugins for this common operation. - New `add_dynamic_field` list in `project_override` settings - Validates entries against PEP 621 allowed field names - Merges with existing `[project] dynamic` without duplicates Closes: #934 Co-Authored-By: Claude Code <noreply@anthropic.com> Signed-off-by: Josef Skladanka <jskladan@redhat.com>
1 parent 06e6317 commit ff85df3

7 files changed

Lines changed: 196 additions & 2 deletions

File tree

docs/customization.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,10 @@ Packages are matched by canonical name.
373373
- `update_build_requires` a list of requirement specifiers. Existing specs
374374
are replaced and missing specs are added. The option can be used to add,
375375
remove, or change a version constraint.
376+
- `add_dynamic_field` is a list of
377+
[PEP 621](https://peps.python.org/pep-0621/#dynamic) field names to add
378+
to `[project] dynamic`. New entries are merged with any existing dynamic
379+
fields without duplicates.
376380

377381
```yaml
378382
project_override:
@@ -382,6 +386,9 @@ project_override:
382386
- setuptools>=68.0.0
383387
- torch
384388
- triton
389+
add_dynamic_field:
390+
- readme
391+
- version
385392
```
386393
387394
Incoming `pyproject.toml`:
@@ -396,6 +403,9 @@ Output:
396403
```yaml
397404
[build-system]
398405
requires = ["setuptools>=68.0.0", "torch", "triton"]
406+
407+
[project]
408+
dynamic = ["readme", "version"]
399409
```
400410

401411
## Override plugins

src/fromager/packagesettings/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from ._hooks import default_update_extra_environ, get_extra_environ
44
from ._models import (
5+
PEP621_DYNAMIC_FIELDS,
56
BuildOptions,
67
DownloadSource,
78
GitOptions,
@@ -33,6 +34,7 @@
3334

3435
__all__ = (
3536
"MODEL_CONFIG",
37+
"PEP621_DYNAMIC_FIELDS",
3638
"Annotations",
3739
"BuildDirectory",
3840
"BuildOptions",

src/fromager/packagesettings/_models.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,27 @@ class BuildOptions(pydantic.BaseModel):
260260
"""If true, this package must be built on its own (not in parallel with other packages). Default: False."""
261261

262262

263+
PEP621_DYNAMIC_FIELDS: frozenset[str] = frozenset(
264+
{
265+
"version",
266+
"description",
267+
"readme",
268+
"requires-python",
269+
"license",
270+
"authors",
271+
"maintainers",
272+
"keywords",
273+
"classifiers",
274+
"urls",
275+
"scripts",
276+
"gui-scripts",
277+
"entry-points",
278+
"dependencies",
279+
"optional-dependencies",
280+
}
281+
)
282+
283+
263284
class ProjectOverride(pydantic.BaseModel):
264285
"""Override pyproject.toml settings
265286
@@ -271,6 +292,9 @@ class ProjectOverride(pydantic.BaseModel):
271292
- ninja
272293
requires_external:
273294
- openssl-libs
295+
add_dynamic_field:
296+
- readme
297+
- version
274298
"""
275299

276300
model_config = MODEL_CONFIG
@@ -296,13 +320,33 @@ class ProjectOverride(pydantic.BaseModel):
296320
``tomlkit.loads(dist(pkgname).read_text("fromager-build-settings"))``.
297321
"""
298322

323+
add_dynamic_field: list[str] = Field(default_factory=list)
324+
"""Add fields to ``[project] dynamic`` in pyproject.toml (PEP 621)
325+
326+
Each entry must be a valid PEP 621 dynamic field name.
327+
See https://peps.python.org/pep-0621/#dynamic
328+
"""
329+
299330
@pydantic.field_validator("update_build_requires")
300331
@classmethod
301332
def validate_update_build_requires(cls, v: list[str]) -> list[str]:
302333
for reqstr in v:
303334
Requirement(reqstr)
304335
return v
305336

337+
@pydantic.field_validator("add_dynamic_field")
338+
@classmethod
339+
def validate_add_dynamic_field(cls, v: list[str]) -> list[str]:
340+
invalid = sorted(set(v) - PEP621_DYNAMIC_FIELDS)
341+
if invalid:
342+
allowed = sorted(PEP621_DYNAMIC_FIELDS)
343+
raise ValueError(
344+
f"invalid dynamic field(s): {invalid}. "
345+
f"Allowed values per PEP 621: {allowed}. "
346+
f"See https://peps.python.org/pep-0621/#dynamic"
347+
)
348+
return v
349+
306350

307351
class VariantInfo(pydantic.BaseModel):
308352
"""Variant information for a package

src/fromager/pyproject.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,16 @@ class PyprojectFix:
2929
3030
- add missing pyproject.toml
3131
- add or update `[build-system] requires`
32+
- add fields to `[project] dynamic` (PEP 621)
3233
3334
Requirements in `update_build_requires` are added to
3435
`[build-system] requires`. If a requirement name matches an existing
3536
name, then the requirement is replaced.
3637
3738
Requirements in `remove_build_requires` are removed from
3839
`[build-system] requires`.
40+
41+
Entries in `add_dynamic_field` are merged into `[project] dynamic`.
3942
"""
4043

4144
def __init__(
@@ -45,18 +48,21 @@ def __init__(
4548
build_dir: pathlib.Path,
4649
update_build_requires: list[str],
4750
remove_build_requires: list[NormalizedName],
51+
add_dynamic_field: list[str] | None = None,
4852
) -> None:
4953
self.req = req
5054
self.build_dir = build_dir
5155
self.update_requirements = update_build_requires
5256
self.remove_requirements = remove_build_requires
57+
self.add_dynamic = add_dynamic_field or []
5358
self.pyproject_toml = self.build_dir / "pyproject.toml"
5459
self.setup_py = self.build_dir / "setup.py"
5560

5661
def run(self) -> None:
5762
doc = self._load()
5863
build_system = self._default_build_system(doc)
5964
self._update_build_requires(build_system)
65+
self._update_dynamic_fields(doc)
6066
logger.debug(
6167
"pyproject.toml %s: %s=%r, %s=%r",
6268
BUILD_SYSTEM,
@@ -132,6 +138,25 @@ def _update_build_requires(self, build_system: TomlDict) -> None:
132138
new_requires,
133139
)
134140

141+
def _update_dynamic_fields(self, doc: tomlkit.TOMLDocument) -> None:
142+
"""Merge new entries into ``[project] dynamic``."""
143+
if not self.add_dynamic:
144+
return
145+
project: TomlDict = doc.setdefault("project", {})
146+
old_dynamic: list[str] = project.get("dynamic", [])
147+
merged = list(old_dynamic)
148+
for field in self.add_dynamic:
149+
if field not in merged:
150+
merged.append(field)
151+
merged.sort()
152+
if set(merged) != set(old_dynamic):
153+
project["dynamic"] = merged
154+
logger.info(
155+
"changed project dynamic from %r to %r",
156+
old_dynamic,
157+
merged,
158+
)
159+
135160

136161
def apply_project_override(
137162
ctx: context.WorkContext, req: Requirement, sdist_root_dir: pathlib.Path
@@ -140,17 +165,20 @@ def apply_project_override(
140165
pbi = ctx.package_build_info(req)
141166
update_build_requires = pbi.project_override.update_build_requires
142167
remove_build_requires = pbi.project_override.remove_build_requires
143-
if update_build_requires or remove_build_requires:
168+
add_dynamic_field = pbi.project_override.add_dynamic_field
169+
if update_build_requires or remove_build_requires or add_dynamic_field:
144170
logger.debug(
145171
f"applying project_override: "
146-
f"{update_build_requires=}, {remove_build_requires=}"
172+
f"{update_build_requires=}, {remove_build_requires=}, "
173+
f"{add_dynamic_field=}"
147174
)
148175
build_dir = pbi.build_dir(sdist_root_dir)
149176
PyprojectFix(
150177
req,
151178
build_dir=build_dir,
152179
update_build_requires=update_build_requires,
153180
remove_build_requires=remove_build_requires,
181+
add_dynamic_field=add_dynamic_field,
154182
).run()
155183
else:
156184
logger.debug("no project_override")

tests/test_packagesettings.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from fromager import build_environment, context
1212
from fromager.packagesettings import (
13+
PEP621_DYNAMIC_FIELDS,
1314
Annotations,
1415
BuildDirectory,
1516
EnvVars,
@@ -77,6 +78,7 @@
7778
"remove_build_requires": ["cmake"],
7879
"update_build_requires": ["setuptools>=68.0.0", "torch"],
7980
"requires_external": ["openssl-libs"],
81+
"add_dynamic_field": ["readme"],
8082
},
8183
"resolver_dist": {
8284
"include_sdists": True,
@@ -139,6 +141,7 @@
139141
"remove_build_requires": [],
140142
"update_build_requires": [],
141143
"requires_external": [],
144+
"add_dynamic_field": [],
142145
},
143146
"resolver_dist": {
144147
"sdist_server_url": None,
@@ -180,6 +183,7 @@
180183
"remove_build_requires": [],
181184
"update_build_requires": [],
182185
"requires_external": [],
186+
"add_dynamic_field": [],
183187
},
184188
"resolver_dist": {
185189
"sdist_server_url": None,
@@ -902,3 +906,44 @@ def test_version_none_no_reference(
902906
result = pbi.get_extra_environ(template_env={}, version=None)
903907
assert result["FOO"] == "bar"
904908
assert "__version__" not in result
909+
910+
911+
def test_add_dynamic_field_from_yaml() -> None:
912+
ps = PackageSettings.from_string(
913+
"test-pkg",
914+
"""
915+
project_override:
916+
add_dynamic_field:
917+
- readme
918+
- version
919+
""",
920+
)
921+
assert ps.project_override.add_dynamic_field == ["readme", "version"]
922+
923+
924+
@pytest.mark.parametrize("invalid_field", ["name", "typo", "Name"])
925+
def test_add_dynamic_field_rejects_invalid(invalid_field: str) -> None:
926+
with pytest.raises(RuntimeError, match="Allowed values per PEP 621"):
927+
PackageSettings.from_string(
928+
"test-pkg",
929+
f"""
930+
project_override:
931+
add_dynamic_field:
932+
- {invalid_field}
933+
""",
934+
)
935+
936+
937+
def test_add_dynamic_field_accepts_all_pep621_fields() -> None:
938+
fields_yaml = "\n".join(f" - {f}" for f in sorted(PEP621_DYNAMIC_FIELDS))
939+
ps = PackageSettings.from_string(
940+
"test-pkg",
941+
f"""
942+
project_override:
943+
add_dynamic_field:
944+
{fields_yaml}
945+
""",
946+
)
947+
assert sorted(ps.project_override.add_dynamic_field) == sorted(
948+
PEP621_DYNAMIC_FIELDS
949+
)

tests/test_pyproject.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,66 @@ def test_pyproject_override_multiple_requires(tmp_path: pathlib.Path) -> None:
177177
"setuptools",
178178
]
179179
]
180+
181+
182+
def test_pyproject_add_dynamic_field_no_project_section(
183+
tmp_path: pathlib.Path,
184+
) -> None:
185+
tmp_path.joinpath("pyproject.toml").write_text(PYPROJECT_TOML)
186+
req = Requirement("testproject==1.0.0")
187+
fixer = pyproject.PyprojectFix(
188+
req,
189+
build_dir=tmp_path,
190+
update_build_requires=[],
191+
remove_build_requires=[],
192+
add_dynamic_field=["readme", "version"],
193+
)
194+
fixer.run()
195+
doc = tomlkit.loads(tmp_path.joinpath("pyproject.toml").read_text())
196+
assert isinstance(doc["project"], typing.Container)
197+
assert dict(doc["project"].items())["dynamic"] == ["readme", "version"]
198+
199+
200+
def test_pyproject_add_dynamic_field_merges_existing(
201+
tmp_path: pathlib.Path,
202+
) -> None:
203+
tmp_path.joinpath("pyproject.toml").write_text(
204+
textwrap.dedent("""
205+
[project]
206+
name = "testproject"
207+
dynamic = ["version", "description"]
208+
""")
209+
)
210+
req = Requirement("testproject==1.0.0")
211+
fixer = pyproject.PyprojectFix(
212+
req,
213+
build_dir=tmp_path,
214+
update_build_requires=[],
215+
remove_build_requires=[],
216+
add_dynamic_field=["readme", "version"],
217+
)
218+
fixer.run()
219+
doc = tomlkit.loads(tmp_path.joinpath("pyproject.toml").read_text())
220+
assert isinstance(doc["project"], typing.Container)
221+
assert dict(doc["project"].items())["dynamic"] == [
222+
"description",
223+
"readme",
224+
"version",
225+
]
226+
227+
228+
def test_pyproject_add_dynamic_field_empty_list(
229+
tmp_path: pathlib.Path,
230+
) -> None:
231+
tmp_path.joinpath("pyproject.toml").write_text(PYPROJECT_TOML)
232+
req = Requirement("testproject==1.0.0")
233+
fixer = pyproject.PyprojectFix(
234+
req,
235+
build_dir=tmp_path,
236+
update_build_requires=[],
237+
remove_build_requires=[],
238+
add_dynamic_field=[],
239+
)
240+
fixer.run()
241+
doc = tomlkit.loads(tmp_path.joinpath("pyproject.toml").read_text())
242+
assert "project" not in doc

tests/testdata/context/overrides/settings/test_pkg.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ project_override:
3636
- torch
3737
requires_external:
3838
- openssl-libs
39+
add_dynamic_field:
40+
- readme
3941
resolver_dist:
4042
sdist_server_url: https://sdist.test/egg
4143
include_sdists: true

0 commit comments

Comments
 (0)