Skip to content

feat(project_override): add add_dynamic_field to project_override#1105

Closed
jskladan wants to merge 2 commits intomainfrom
feature/add-dynamic-field-override
Closed

feat(project_override): add add_dynamic_field to project_override#1105
jskladan wants to merge 2 commits intomainfrom
feature/add-dynamic-field-override

Conversation

@jskladan
Copy link
Copy Markdown
Contributor

@jskladan jskladan commented May 4, 2026

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

[X] PR follows CONTRIBUTING.md guidelines

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>
@jskladan jskladan requested a review from a team as a code owner May 4, 2026 11:12
@mergify mergify Bot added the ci label May 4, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3e686034-fa1c-4035-afdc-9afaa24c1f11

📥 Commits

Reviewing files that changed from the base of the PR and between ff85df3 and 49a76c7.

📒 Files selected for processing (2)
  • src/fromager/pyproject.py
  • tests/test_pyproject.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/test_pyproject.py
  • src/fromager/pyproject.py

📝 Walkthrough

Walkthrough

Adds support for appending PEP 621 [project].dynamic fields via a new project_override.add_dynamic_field setting. Introduces a PEP621_DYNAMIC_FIELDS allowlist, exposes it from the package, and adds add_dynamic_field: list[str] to the ProjectOverride model with validation. PyprojectFix gains an add_dynamic_field parameter and a _update_dynamic_fields() method that merges, deduplicates, sorts, and writes project.dynamic when changes occur. Documentation, tests, and test fixtures updated to cover parsing, validation, and pyproject manipulation behavior.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: adding an add_dynamic_field feature to the project_override configuration, which is the primary objective of this PR.
Description check ✅ Passed The description clearly explains the feature being added, its validation, how it merges with existing data, and references the relevant issue (#934).
Linked Issues check ✅ Passed All coding requirements from issue #934 are met: add_dynamic_field accepts PEP 621 field names, validates against allowed values, merges without duplicates, and eliminates the need for custom prepare_source plugins.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing the add_dynamic_field feature across models, pyproject handling, documentation, and tests. No unrelated modifications are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/test_pyproject.py (1)

182-198: ⚡ Quick win

Assert the build-system stays untouched in the dynamic-field-only path.

This test only checks the new [project] table, so it would still pass if PyprojectFix rewrote [build-system].requires and injected setuptools behind the scenes. Please add an assertion for the existing build-system contents so this regression is caught.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_pyproject.py` around lines 182 - 198, The test should also assert
that the existing [build-system] table is unchanged after running PyprojectFix;
parse the original PYPROJECT_TOML to get the expected build-system and compare
it to the resulting document. In
test_pyproject_add_dynamic_field_no_project_section, after writing
PYPROJECT_TOML and before/after fixer.run(), compute expected_build_system =
tomlkit.loads(PYPROJECT_TOML)["build-system"] (or parse it once from the
constant) and add an assertion like assert doc["build-system"] ==
expected_build_system to ensure no unintended modifications to
build-system.requires or other keys by PyprojectFix.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/fromager/pyproject.py`:
- Around line 169-182: The current branch calls PyprojectFix(...).run() whenever
add_dynamic_field is set, which causes unnecessary [build-system] normalization;
change the logic so dynamic-only updates don't trigger the build-system rewrite:
if add_dynamic_field is true but both update_build_requires and
remove_build_requires are false, either (A) handle the dynamic-field update in a
separate path (e.g., call a new helper that only edits [project].dynamic) or (B)
extend PyprojectFix (class PyprojectFix and its run method) to accept a flag
like skip_build_system_normalize and pass that flag from the caller (the block
using pbi.build_dir(...) and PyprojectFix(...).run()) so when only
add_dynamic_field is requested PyprojectFix does not normalize or insert
setuptools into [build-system].
- Around line 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).

---

Nitpick comments:
In `@tests/test_pyproject.py`:
- Around line 182-198: The test should also assert that the existing
[build-system] table is unchanged after running PyprojectFix; parse the original
PYPROJECT_TOML to get the expected build-system and compare it to the resulting
document. In test_pyproject_add_dynamic_field_no_project_section, after writing
PYPROJECT_TOML and before/after fixer.run(), compute expected_build_system =
tomlkit.loads(PYPROJECT_TOML)["build-system"] (or parse it once from the
constant) and add an assertion like assert doc["build-system"] ==
expected_build_system to ensure no unintended modifications to
build-system.requires or other keys by PyprojectFix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1774ea49-574b-4d05-9fbc-22351aba2f80

📥 Commits

Reviewing files that changed from the base of the PR and between 06e6317 and ff85df3.

📒 Files selected for processing (7)
  • docs/customization.md
  • src/fromager/packagesettings/__init__.py
  • src/fromager/packagesettings/_models.py
  • src/fromager/pyproject.py
  • tests/test_packagesettings.py
  • tests/test_pyproject.py
  • tests/testdata/context/overrides/settings/test_pkg.yaml

Comment thread src/fromager/pyproject.py
Comment on lines +141 to +158
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,
)
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 4, 2026

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.

Comment thread src/fromager/pyproject.py
@dhellmann
Copy link
Copy Markdown
Member

The implementation here looks basically good, but the design raises UX questions. Do we need to edit any "static" fields? Or other fields in the pyproject.toml? Will we have separate fields in the settings file for each of those things? That seems like it's going to be cumbersome for a user, since they will have to use separate inputs in our settings to modify different settings in the pyproject data. How could we express the modifications more generally?

@jskladan
Copy link
Copy Markdown
Contributor Author

jskladan commented May 4, 2026

design raises UX questions. Do we need to edit any "static" fields? Or other fields in the pyproject.toml? Will we have separate fields in the settings file for each of those things?

That is probably a question for @Lanceypantsy but personaly, I think the add_dynamic_field (which is specified in the #934, that being input for this MR) is it's well within the current usage pattern of project_override. If a new use case comes up, adding another typed field is IMO a small code change, and will keep the readability of "what's this supposed to do" better than, IDK, creating a DSL like this (forgive the rough example, this is obviously not well-thought-out)

pyproject_patches:
    - path: "build-system.requires"
      op: "merge"
      value: ["setuptools>=68"]
    - path: "project.dynamic"
      op: "append"
      value: ["version"]

That said, I'd like to stress once again, that I was coming at this based on what the #934 contains, and If the scope should be broader (e.g. a generic pyproject patching mechanism), I think that's a separate design discussion and a separate ticket may be worth it. I'd rather not overengineer this MR beyond what was asked for, as I certainly do not have the required in-depth understanding and expertise WRT Fromager.

…ly 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 <noreply@anthropic.com>
@tiran
Copy link
Copy Markdown
Collaborator

tiran commented May 4, 2026

  • How often do we need this fixer in downstream? How often does a package have bad metadata?
  • Why are we not filing upstream tickets and get upstream to fix the issue?

@dhellmann
Copy link
Copy Markdown
Member

design raises UX questions. Do we need to edit any "static" fields? Or other fields in the pyproject.toml? Will we have separate fields in the settings file for each of those things?

That is probably a question for @Lanceypantsy but personaly, I think the add_dynamic_field (which is specified in the #934, that being input for this MR) is it's well within the current usage pattern of project_override. If a new use case comes up, adding another typed field is IMO a small code change, and will keep the readability of "what's this supposed to do" better than, IDK, creating a DSL like this (forgive the rough example, this is obviously not well-thought-out)

That's fair, although it depends on how many of these we accumulate over time. If we have 10 of them for modifying different parts of the pyproject data structure and they have no coherent unified design, it's going to be hard for a new user to figure out how to use them.

pyproject_patches:
    - path: "build-system.requires"
      op: "merge"
      value: ["setuptools>=68"]
    - path: "project.dynamic"
      op: "append"
      value: ["version"]

That's more or less what I was thinking of as a more general solution, yeah.

That said, I'd like to stress once again, that I was coming at this based on what the #934 contains, and If the scope should be broader (e.g. a generic pyproject patching mechanism), I think that's a separate design discussion and a separate ticket may be worth it. I'd rather not overengineer this MR beyond what was asked for, as I certainly do not have the required in-depth understanding and expertise WRT Fromager.

I can understand that. At the same time, I want to solve the underlying problem, not just the one described in the ticket. That's why I was asking questions about other types of fields we might need to modify, to get a sense of how broad the problem is.

"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"]

@LalatenduMohanty
Copy link
Copy Markdown
Member

I crosschecked downstream and this will replace 1 existing plugin (pydeck.py) . cc @tiran

@tiran
Copy link
Copy Markdown
Collaborator

tiran commented May 5, 2026

I crosschecked downstream and this will replace 1 existing plugin (pydeck.py) . cc @tiran

We don't need that plugin any more. Lance's upstream PR visgl/deck.gl#10048 was accepted and merged. The fix is in the latest releases of pydeck.

@jskladan
Copy link
Copy Markdown
Contributor Author

jskladan commented May 5, 2026

So, given this will replace no actual plugins, should we just put it to ice, and have a discussion about possibly changing the project_override pattern, to something more generic (a better version of the DSL maybe)?

In the mean time, I made AI do some analysis (for whatever it is worth, I did not verify the numbers):

Inspect the overrides in ../builder (YAML settings, patches, and Python plugins) and create a statistical breakdown of what kinds of pyproject.toml modifications we're actually making across the project

For YAML settings: how many packages use project_override and which sub-fields (update_build_requires, remove_build_requires, requires_external)? What are the most common requirements being added/removed?

For patches: categorize each patch by what it touches (pyproject.toml, setup.py, CMake, C/C++ source, Python source, requirements files, etc.). Specifically call out which patches modify pyproject.toml and what they change.

For Python plugins: which prepare_source overrides are effectively doing dependency/pyproject.toml modifications that could theoretically be declarative instead of code?

I want to understand: of all the ways we currently tweak builds, how much is already covered by project_override YAML, how much is in patches or plugin code that could be YAML-driven, and how much is inherently too complex for a declarative/DSL approach? Is there a case for a more general pyproject.toml diffing DSL, or do a few more named fields on ProjectOverride cover the real gaps?

tldr (complete thing attached here ):

What actually modifies pyproject.toml / dependencies?

This is the key question — collapsing across all three mechanisms:

Already handled by YAML project_override (47 packages)

  • 39 add/update build requirements
  • 12 remove build requirements
  • Working well, no code needed

Patches that touch pyproject.toml (2-3 patches)

  • pyzstd: remove setuptools version pin — could be covered by existing update_build_requires
  • ninja: restructure project — too complex for a DSL
  • faiss_cpu: full build-system migration — too complex for a DSL

prepare_source plugins that modify pyproject.toml or deps (~6 plugins)

  • amdsmi: comments out clang from pyproject.toml → remove_build_requires would cover this
  • docling: removes easyocr and rapidocr from [project] dependencies → not covered (runtime deps, not build)
  • kfp: empties requirements.in (removes all runtime deps) → not covered
  • nixl: processes pyproject.toml.in template (version/wheel-dep substitution) → too dynamic for a DSL
  • tpu_inference: updates torchvision dep version in setup.py → partially coverable (runtime dep version change)
  • vllm: fixes torch CPU dep, removes xformers/git deps → partially coverable (runtime dep removal/update)

Patches that modify requirements files (7 patches)

  • torch (3 patches): remove cmake, add triton to requirements.txt
  • vllm (3 patches): remove ray, add triton to requirements files
  • fairlearn: add missing requirements.txt

...

  • Current project_override already covers ~47 packages declaratively
  • A generic DSL would additionally replace maybe 5-8 cases across plugins and patches
  • The remaining ~120 patches and ~50 plugins do things a TOML DSL can't touch (CMake, C++ source, git clones, custom build processes, vendoring)
  • 12 plugins just create PKG-INFO — a separate concern entirely

What would actually have the most impact

Instead of a generic DSL, two named fields on ProjectOverride would cover the real gap:

project_override:
remove_dependencies: # [project] dependencies
- easyocr # docling
- rapidocr # docling
- ray # vllm
update_dependencies: # [project] dependencies
- "torchvision>=0.20" # tpu_inference

This mirrors the existing remove_build_requires / update_build_requires pattern, is easy to validate with Pydantic, and covers the actual unmet need. A generic diffing DSL would be over-engineered for 2-3
extra edge cases while being harder to validate and review.

And TBH, I don't believe even the proposed remove/update_dependencies make sense, with how little impact that would have (even though my brain would like the consolidation of touching the pyproject.toml just form one place...).

@tiran
Copy link
Copy Markdown
Collaborator

tiran commented May 5, 2026

In downstream, we deliberately do not support structured patching of installation requirements. Our builds should have the same dependencies with the same version constraints as upstream.

We only support modifications of build requirements to address custom build systems and breaking changes in setuptools. For example, some upstream projects use a custom build script or build environment instead of using Python's build system requirements. It took us a while to convince projects to declare a build dependency on Torch, too.

The fact is also covered by https://fromager.readthedocs.io/en/latest/proposals/new-patcher-config.html

patching of installation requirements (requires-dist) beyond pinning to constraints. Dependency issues should be fixed in upstream projects.

Installation requirements need to be patched in a different way. Patching pyproject.toml dependencies only works for static dependencies. Build backends can provide dynamic installation dependencies, too. A portable and generic solution has to patch the METADATA file after PEP 517 prepare_metadata_for_build_wheel and build_wheel hooks.

@jskladan
Copy link
Copy Markdown
Contributor Author

jskladan commented May 5, 2026

Yeah, so what I'm reading here is, that the original requirement from #934 is no longer one, and we don't have a use-case for any other changes to the pyproject.toml on top of what we already support.

So... Let's close both the ticket and the pr as obsolete/wontfix or something?

@Lanceypantsy
Copy link
Copy Markdown

Lanceypantsy commented May 5, 2026

I'm coming in late to this conversation, and only have a small amount of context to add.

When I wrote the pydeck plugin originally, @EmilienM asked me to file the issue to be able to do this programatically with a setting instead of plugin.

If analysis shows that this is not a common pattern that we need to support to minimize plugins, I concur that the issue and PR should be closed.

@jskladan
Copy link
Copy Markdown
Contributor Author

jskladan commented May 5, 2026

OK, feels like the concsensus is to close this. We can discuss a more holistic approach in a new ticket, if necessary.

@jskladan jskladan closed this May 5, 2026
@jskladan jskladan deleted the feature/add-dynamic-field-override branch May 5, 2026 18:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create method to add and populate the dynamic field in pyproject.toml when missing

5 participants