Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -382,6 +386,9 @@ project_override:
- setuptools>=68.0.0
- torch
- triton
add_dynamic_field:
- readme
- version
```

Incoming `pyproject.toml`:
Expand All @@ -396,6 +403,9 @@ Output:
```yaml
[build-system]
requires = ["setuptools>=68.0.0", "torch", "triton"]

[project]
dynamic = ["readme", "version"]
```

## Override plugins
Expand Down
2 changes: 2 additions & 0 deletions src/fromager/packagesettings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from ._hooks import default_update_extra_environ, get_extra_environ
from ._models import (
PEP621_DYNAMIC_FIELDS,
BuildOptions,
DownloadSource,
GitOptions,
Expand Down Expand Up @@ -33,6 +34,7 @@

__all__ = (
"MODEL_CONFIG",
"PEP621_DYNAMIC_FIELDS",
"Annotations",
"BuildDirectory",
"BuildOptions",
Expand Down
44 changes: 44 additions & 0 deletions src/fromager/packagesettings/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -271,6 +292,9 @@ class ProjectOverride(pydantic.BaseModel):
- ninja
requires_external:
- openssl-libs
add_dynamic_field:
- readme
- version
"""

model_config = MODEL_CONFIG
Expand All @@ -296,13 +320,33 @@ 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]:
for reqstr in v:
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
Expand Down
59 changes: 47 additions & 12 deletions src/fromager/pyproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,16 @@ 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
name, then the requirement is replaced.

Requirements in `remove_build_requires` are removed from
`[build-system] requires`.

Entries in `add_dynamic_field` are merged into `[project] dynamic`.
"""

def __init__(
Expand All @@ -45,26 +48,36 @@ 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"

def run(self) -> None:
doc = self._load()
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),
)
# 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:
Expand Down Expand Up @@ -132,6 +145,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,
)
Comment on lines +148 to +165

@coderabbitai coderabbitai Bot May 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize before comparing.

The current set(...) check hides changes that only remove duplicates or reorder the list, so pre-existing malformed [project].dynamic arrays never get cleaned up unless a new field is added.

♻️ Suggested fix
-        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):
+        old_dynamic: list[str] = list(project.get("dynamic", []))
+        merged = sorted(set(old_dynamic) | set(self.add_dynamic))
+        if merged != old_dynamic:
             project["dynamic"] = merged
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fromager/pyproject.py` around lines 141 - 158, In _update_dynamic_fields
ensure you normalize the existing project["dynamic"] before comparing:
deduplicate and sort old_dynamic (e.g., normalized_old =
sorted(set(old_dynamic))) and also ensure merged is deduplicated and sorted,
then compare normalized_old to merged; if they differ assign project["dynamic"]
= merged and log the change referencing old_dynamic/merged (use normalized_old
in the comparison/log so reordering/duplicates are fixed even when no new fields
are added).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't agree.

  • The set() comparison is intentional. You don't want to trigger a write (and a log message saying you "changed" something) just because the existing list wasn't sorted. The function should only touch the
    file when it actually adds a new field.
  • The suggested fix introduces a behavioral change: it would now rewrite dynamic even when add_dynamic contributes nothing new, purely to normalize ordering/duplicates

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jskladan, you're right — I'll withdraw the suggestion. The set() comparison correctly answers "did we actually add a new field?", since merged is always a superset of old_dynamic. My proposed merged != old_dynamic would have incorrectly triggered rewrites for pre-existing ordering or duplicate differences, which is outside the scope of this function. The current logic is sound.



def apply_project_override(
ctx: context.WorkContext, req: Requirement, sdist_root_dir: pathlib.Path
Expand All @@ -140,17 +172,20 @@ 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(
req,
build_dir=build_dir,
update_build_requires=update_build_requires,
remove_build_requires=remove_build_requires,
add_dynamic_field=add_dynamic_field,
).run()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
else:
logger.debug("no project_override")
45 changes: 45 additions & 0 deletions tests/test_packagesettings.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from fromager import build_environment, context
from fromager.packagesettings import (
PEP621_DYNAMIC_FIELDS,
Annotations,
BuildDirectory,
EnvVars,
Expand Down Expand Up @@ -77,6 +78,7 @@
"remove_build_requires": ["cmake"],
"update_build_requires": ["setuptools>=68.0.0", "torch"],
"requires_external": ["openssl-libs"],
"add_dynamic_field": ["readme"],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think we have any existing asserts testing this. I will suggest something like this to suitable tests assert dict(doc["project"].items())["dynamic"] == ["readme"]

},
"resolver_dist": {
"include_sdists": True,
Expand Down Expand Up @@ -139,6 +141,7 @@
"remove_build_requires": [],
"update_build_requires": [],
"requires_external": [],
"add_dynamic_field": [],
},
"resolver_dist": {
"sdist_server_url": None,
Expand Down Expand Up @@ -180,6 +183,7 @@
"remove_build_requires": [],
"update_build_requires": [],
"requires_external": [],
"add_dynamic_field": [],
},
"resolver_dist": {
"sdist_server_url": None,
Expand Down Expand Up @@ -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
)
87 changes: 87 additions & 0 deletions tests/test_pyproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,90 @@ 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_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:
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
Loading
Loading