From ff85df32ce050907ace67dcd6bf53fa561cd924a Mon Sep 17 00:00:00 2001 From: Josef Skladanka Date: Mon, 4 May 2026 12:12:45 +0200 Subject: [PATCH 1/2] 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 Signed-off-by: Josef Skladanka --- docs/customization.md | 10 +++ src/fromager/packagesettings/__init__.py | 2 + src/fromager/packagesettings/_models.py | 44 +++++++++++++ src/fromager/pyproject.py | 32 +++++++++- tests/test_packagesettings.py | 45 +++++++++++++ tests/test_pyproject.py | 63 +++++++++++++++++++ .../context/overrides/settings/test_pkg.yaml | 2 + 7 files changed, 196 insertions(+), 2 deletions(-) diff --git a/docs/customization.md b/docs/customization.md index eb2973ac..ac5e9eb5 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -373,6 +373,10 @@ Packages are matched by canonical name. - `update_build_requires` a list of requirement specifiers. Existing specs are replaced and missing specs are added. The option can be used to add, remove, or change a version constraint. +- `add_dynamic_field` is a list of + [PEP 621](https://peps.python.org/pep-0621/#dynamic) field names to add + to `[project] dynamic`. New entries are merged with any existing dynamic + fields without duplicates. ```yaml project_override: @@ -382,6 +386,9 @@ project_override: - setuptools>=68.0.0 - torch - triton + add_dynamic_field: + - readme + - version ``` Incoming `pyproject.toml`: @@ -396,6 +403,9 @@ Output: ```yaml [build-system] requires = ["setuptools>=68.0.0", "torch", "triton"] + +[project] +dynamic = ["readme", "version"] ``` ## Override plugins diff --git a/src/fromager/packagesettings/__init__.py b/src/fromager/packagesettings/__init__.py index 3b9e02c2..ad596d0b 100644 --- a/src/fromager/packagesettings/__init__.py +++ b/src/fromager/packagesettings/__init__.py @@ -2,6 +2,7 @@ from ._hooks import default_update_extra_environ, get_extra_environ from ._models import ( + PEP621_DYNAMIC_FIELDS, BuildOptions, DownloadSource, GitOptions, @@ -33,6 +34,7 @@ __all__ = ( "MODEL_CONFIG", + "PEP621_DYNAMIC_FIELDS", "Annotations", "BuildDirectory", "BuildOptions", diff --git a/src/fromager/packagesettings/_models.py b/src/fromager/packagesettings/_models.py index d0a06790..0e382a8a 100644 --- a/src/fromager/packagesettings/_models.py +++ b/src/fromager/packagesettings/_models.py @@ -260,6 +260,27 @@ class BuildOptions(pydantic.BaseModel): """If true, this package must be built on its own (not in parallel with other packages). Default: False.""" +PEP621_DYNAMIC_FIELDS: frozenset[str] = frozenset( + { + "version", + "description", + "readme", + "requires-python", + "license", + "authors", + "maintainers", + "keywords", + "classifiers", + "urls", + "scripts", + "gui-scripts", + "entry-points", + "dependencies", + "optional-dependencies", + } +) + + class ProjectOverride(pydantic.BaseModel): """Override pyproject.toml settings @@ -271,6 +292,9 @@ class ProjectOverride(pydantic.BaseModel): - ninja requires_external: - openssl-libs + add_dynamic_field: + - readme + - version """ model_config = MODEL_CONFIG @@ -296,6 +320,13 @@ class ProjectOverride(pydantic.BaseModel): ``tomlkit.loads(dist(pkgname).read_text("fromager-build-settings"))``. """ + add_dynamic_field: list[str] = Field(default_factory=list) + """Add fields to ``[project] dynamic`` in pyproject.toml (PEP 621) + + Each entry must be a valid PEP 621 dynamic field name. + See https://peps.python.org/pep-0621/#dynamic + """ + @pydantic.field_validator("update_build_requires") @classmethod def validate_update_build_requires(cls, v: list[str]) -> list[str]: @@ -303,6 +334,19 @@ def validate_update_build_requires(cls, v: list[str]) -> list[str]: Requirement(reqstr) return v + @pydantic.field_validator("add_dynamic_field") + @classmethod + def validate_add_dynamic_field(cls, v: list[str]) -> list[str]: + invalid = sorted(set(v) - PEP621_DYNAMIC_FIELDS) + if invalid: + allowed = sorted(PEP621_DYNAMIC_FIELDS) + raise ValueError( + f"invalid dynamic field(s): {invalid}. " + f"Allowed values per PEP 621: {allowed}. " + f"See https://peps.python.org/pep-0621/#dynamic" + ) + return v + class VariantInfo(pydantic.BaseModel): """Variant information for a package diff --git a/src/fromager/pyproject.py b/src/fromager/pyproject.py index 5148f1a4..31c3f8c6 100644 --- a/src/fromager/pyproject.py +++ b/src/fromager/pyproject.py @@ -29,6 +29,7 @@ class PyprojectFix: - add missing pyproject.toml - add or update `[build-system] requires` + - add fields to `[project] dynamic` (PEP 621) Requirements in `update_build_requires` are added to `[build-system] requires`. If a requirement name matches an existing @@ -36,6 +37,8 @@ class PyprojectFix: Requirements in `remove_build_requires` are removed from `[build-system] requires`. + + Entries in `add_dynamic_field` are merged into `[project] dynamic`. """ def __init__( @@ -45,11 +48,13 @@ def __init__( build_dir: pathlib.Path, update_build_requires: list[str], remove_build_requires: list[NormalizedName], + add_dynamic_field: list[str] | None = None, ) -> None: self.req = req self.build_dir = build_dir self.update_requirements = update_build_requires self.remove_requirements = remove_build_requires + self.add_dynamic = add_dynamic_field or [] self.pyproject_toml = self.build_dir / "pyproject.toml" self.setup_py = self.build_dir / "setup.py" @@ -57,6 +62,7 @@ def run(self) -> None: doc = self._load() build_system = self._default_build_system(doc) self._update_build_requires(build_system) + self._update_dynamic_fields(doc) logger.debug( "pyproject.toml %s: %s=%r, %s=%r", BUILD_SYSTEM, @@ -132,6 +138,25 @@ def _update_build_requires(self, build_system: TomlDict) -> None: new_requires, ) + def _update_dynamic_fields(self, doc: tomlkit.TOMLDocument) -> None: + """Merge new entries into ``[project] dynamic``.""" + if not self.add_dynamic: + return + project: TomlDict = doc.setdefault("project", {}) + old_dynamic: list[str] = project.get("dynamic", []) + merged = list(old_dynamic) + for field in self.add_dynamic: + if field not in merged: + merged.append(field) + merged.sort() + if set(merged) != set(old_dynamic): + project["dynamic"] = merged + logger.info( + "changed project dynamic from %r to %r", + old_dynamic, + merged, + ) + def apply_project_override( ctx: context.WorkContext, req: Requirement, sdist_root_dir: pathlib.Path @@ -140,10 +165,12 @@ def apply_project_override( pbi = ctx.package_build_info(req) update_build_requires = pbi.project_override.update_build_requires remove_build_requires = pbi.project_override.remove_build_requires - if update_build_requires or remove_build_requires: + add_dynamic_field = pbi.project_override.add_dynamic_field + if update_build_requires or remove_build_requires or add_dynamic_field: logger.debug( f"applying project_override: " - f"{update_build_requires=}, {remove_build_requires=}" + f"{update_build_requires=}, {remove_build_requires=}, " + f"{add_dynamic_field=}" ) build_dir = pbi.build_dir(sdist_root_dir) PyprojectFix( @@ -151,6 +178,7 @@ def apply_project_override( build_dir=build_dir, update_build_requires=update_build_requires, remove_build_requires=remove_build_requires, + add_dynamic_field=add_dynamic_field, ).run() else: logger.debug("no project_override") diff --git a/tests/test_packagesettings.py b/tests/test_packagesettings.py index f8cdee7b..180a714c 100644 --- a/tests/test_packagesettings.py +++ b/tests/test_packagesettings.py @@ -10,6 +10,7 @@ from fromager import build_environment, context from fromager.packagesettings import ( + PEP621_DYNAMIC_FIELDS, Annotations, BuildDirectory, EnvVars, @@ -77,6 +78,7 @@ "remove_build_requires": ["cmake"], "update_build_requires": ["setuptools>=68.0.0", "torch"], "requires_external": ["openssl-libs"], + "add_dynamic_field": ["readme"], }, "resolver_dist": { "include_sdists": True, @@ -139,6 +141,7 @@ "remove_build_requires": [], "update_build_requires": [], "requires_external": [], + "add_dynamic_field": [], }, "resolver_dist": { "sdist_server_url": None, @@ -180,6 +183,7 @@ "remove_build_requires": [], "update_build_requires": [], "requires_external": [], + "add_dynamic_field": [], }, "resolver_dist": { "sdist_server_url": None, @@ -902,3 +906,44 @@ def test_version_none_no_reference( result = pbi.get_extra_environ(template_env={}, version=None) assert result["FOO"] == "bar" assert "__version__" not in result + + +def test_add_dynamic_field_from_yaml() -> None: + ps = PackageSettings.from_string( + "test-pkg", + """ +project_override: + add_dynamic_field: + - readme + - version +""", + ) + assert ps.project_override.add_dynamic_field == ["readme", "version"] + + +@pytest.mark.parametrize("invalid_field", ["name", "typo", "Name"]) +def test_add_dynamic_field_rejects_invalid(invalid_field: str) -> None: + with pytest.raises(RuntimeError, match="Allowed values per PEP 621"): + PackageSettings.from_string( + "test-pkg", + f""" +project_override: + add_dynamic_field: + - {invalid_field} +""", + ) + + +def test_add_dynamic_field_accepts_all_pep621_fields() -> None: + fields_yaml = "\n".join(f" - {f}" for f in sorted(PEP621_DYNAMIC_FIELDS)) + ps = PackageSettings.from_string( + "test-pkg", + f""" +project_override: + add_dynamic_field: +{fields_yaml} +""", + ) + assert sorted(ps.project_override.add_dynamic_field) == sorted( + PEP621_DYNAMIC_FIELDS + ) diff --git a/tests/test_pyproject.py b/tests/test_pyproject.py index a242da1e..29ece6cb 100644 --- a/tests/test_pyproject.py +++ b/tests/test_pyproject.py @@ -177,3 +177,66 @@ def test_pyproject_override_multiple_requires(tmp_path: pathlib.Path) -> None: "setuptools", ] ] + + +def test_pyproject_add_dynamic_field_no_project_section( + tmp_path: pathlib.Path, +) -> None: + tmp_path.joinpath("pyproject.toml").write_text(PYPROJECT_TOML) + req = Requirement("testproject==1.0.0") + fixer = pyproject.PyprojectFix( + req, + build_dir=tmp_path, + update_build_requires=[], + remove_build_requires=[], + add_dynamic_field=["readme", "version"], + ) + fixer.run() + doc = tomlkit.loads(tmp_path.joinpath("pyproject.toml").read_text()) + assert isinstance(doc["project"], typing.Container) + assert dict(doc["project"].items())["dynamic"] == ["readme", "version"] + + +def test_pyproject_add_dynamic_field_merges_existing( + tmp_path: pathlib.Path, +) -> None: + tmp_path.joinpath("pyproject.toml").write_text( + textwrap.dedent(""" + [project] + name = "testproject" + dynamic = ["version", "description"] + """) + ) + req = Requirement("testproject==1.0.0") + fixer = pyproject.PyprojectFix( + req, + build_dir=tmp_path, + update_build_requires=[], + remove_build_requires=[], + add_dynamic_field=["readme", "version"], + ) + fixer.run() + doc = tomlkit.loads(tmp_path.joinpath("pyproject.toml").read_text()) + assert isinstance(doc["project"], typing.Container) + assert dict(doc["project"].items())["dynamic"] == [ + "description", + "readme", + "version", + ] + + +def test_pyproject_add_dynamic_field_empty_list( + tmp_path: pathlib.Path, +) -> None: + tmp_path.joinpath("pyproject.toml").write_text(PYPROJECT_TOML) + req = Requirement("testproject==1.0.0") + fixer = pyproject.PyprojectFix( + req, + build_dir=tmp_path, + update_build_requires=[], + remove_build_requires=[], + add_dynamic_field=[], + ) + fixer.run() + doc = tomlkit.loads(tmp_path.joinpath("pyproject.toml").read_text()) + assert "project" not in doc diff --git a/tests/testdata/context/overrides/settings/test_pkg.yaml b/tests/testdata/context/overrides/settings/test_pkg.yaml index a1d11352..95b92067 100644 --- a/tests/testdata/context/overrides/settings/test_pkg.yaml +++ b/tests/testdata/context/overrides/settings/test_pkg.yaml @@ -36,6 +36,8 @@ project_override: - torch requires_external: - openssl-libs + add_dynamic_field: + - readme resolver_dist: sdist_server_url: https://sdist.test/egg include_sdists: true From 49a76c72aa110e6f59a1a1ea09bdb0ae25a6f028 Mon Sep 17 00:00:00 2001 From: Josef Skladanka Date: Mon, 4 May 2026 14:14:50 +0200 Subject: [PATCH 2/2] fix(project_override): skip build-system normalization for dynamic-only updates Guard build-system path in PyprojectFix.run() to prevent setuptools injection when only add_dynamic_field is set. Addresses CodeRabbit review feedback. Co-Authored-By: Claude Code --- src/fromager/pyproject.py | 29 ++++++++++++++++++----------- tests/test_pyproject.py | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/fromager/pyproject.py b/src/fromager/pyproject.py index 31c3f8c6..0bce49e9 100644 --- a/src/fromager/pyproject.py +++ b/src/fromager/pyproject.py @@ -60,17 +60,24 @@ def __init__( def run(self) -> None: doc = self._load() - build_system = self._default_build_system(doc) - self._update_build_requires(build_system) - self._update_dynamic_fields(doc) - logger.debug( - "pyproject.toml %s: %s=%r, %s=%r", - BUILD_SYSTEM, - BUILD_BACKEND, - build_system.get(BUILD_BACKEND), - BUILD_REQUIRES, - build_system.get(BUILD_REQUIRES), - ) + # Previously run() was only called when update_requirements or + # remove_requirements was set. Now that add_dynamic can also + # trigger a run(), we guard the build-system path to avoid the + # side-effect of _default_build_system injecting setuptools and + # creating [build-system] when only dynamic fields are requested. + if self.update_requirements or self.remove_requirements: + build_system = self._default_build_system(doc) + self._update_build_requires(build_system) + logger.debug( + "pyproject.toml %s: %s=%r, %s=%r", + BUILD_SYSTEM, + BUILD_BACKEND, + build_system.get(BUILD_BACKEND), + BUILD_REQUIRES, + build_system.get(BUILD_REQUIRES), + ) + if self.add_dynamic: + self._update_dynamic_fields(doc) self._save(doc) def _load(self) -> tomlkit.TOMLDocument: diff --git a/tests/test_pyproject.py b/tests/test_pyproject.py index 29ece6cb..74ee82cc 100644 --- a/tests/test_pyproject.py +++ b/tests/test_pyproject.py @@ -225,6 +225,30 @@ def test_pyproject_add_dynamic_field_merges_existing( ] +def test_pyproject_add_dynamic_field_does_not_modify_build_system( + tmp_path: pathlib.Path, +) -> None: + tmp_path.joinpath("pyproject.toml").write_text( + textwrap.dedent(""" + [project] + name = "testproject" + """) + ) + req = Requirement("testproject==1.0.0") + fixer = pyproject.PyprojectFix( + req, + build_dir=tmp_path, + update_build_requires=[], + remove_build_requires=[], + add_dynamic_field=["version"], + ) + fixer.run() + doc = tomlkit.loads(tmp_path.joinpath("pyproject.toml").read_text()) + assert "build-system" not in doc + assert isinstance(doc["project"], typing.Container) + assert dict(doc["project"].items())["dynamic"] == ["version"] + + def test_pyproject_add_dynamic_field_empty_list( tmp_path: pathlib.Path, ) -> None: