Skip to content
Draft
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
250 changes: 250 additions & 0 deletions docs/how-tos/dependency-build-tags.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
Dependency-Chained Build Tags
==============================

When building packages that depend on system libraries or other packages, you may
want the build tag to automatically increment when any dependency's build
configuration changes. This ensures downstream packages are rebuilt when their
dependencies are updated, even if the downstream package itself hasn't changed.

Use Cases
---------

Dependency-chained build tags are particularly useful for:

* **Platform Dependencies**: Packages that depend on CUDA, ROCm, or other
accelerator stacks where the platform version changes independently of your
package code.

* **Midstream Builds**: When you maintain patches or configuration for upstream
packages and need downstream dependents to rebuild when you change those
patches.

* **Transitive Rebuilds**: Ensuring an entire dependency chain rebuilds when a
base library changes (e.g., when updating OpenSSL, all packages that depend
on it should get new build tags).

How It Works
------------

Build tags are calculated as:

.. code-block:: text

build_tag = own_changelog_count + sum(dependency_build_tags)

Dependencies are resolved **recursively** and **transitively**. If package A
depends on B, and B depends on C, then A's build tag includes changes from both
B and C.

Basic Example
-------------

Consider a simple dependency chain where ``torch`` depends on ``cuda-toolkit``:

**overrides/settings/cuda-toolkit.yaml**:

.. code-block:: yaml

changelog:
"12.8":
- "Initial CUDA 12.8 support"
"12.9":
- "Updated to CUDA 12.9"

**overrides/settings/torch.yaml**:

.. code-block:: yaml

dependencies:
- cuda-toolkit
changelog:
"2.0.0":
- "Initial build"

When you build ``torch==2.0.0`` with CUDA 12.9:

* ``cuda-toolkit`` has 1 changelog entry for version 12.9 → build tag = ``1``
* ``torch`` has 1 own changelog entry + 1 from cuda-toolkit → build tag = ``2``

If you later add another CUDA changelog entry:

.. code-block:: yaml

changelog:
"12.9":
- "Updated to CUDA 12.9"
- "Fixed memory issue" # New entry

Now:

* ``cuda-toolkit`` version 12.9 → build tag = ``2``
* ``torch`` version 2.0.0 → build tag = ``3`` (automatically incremented!)

The wheel filename becomes: ``torch-2.0.0-3-cp311-cp311-linux_x86_64.whl``

Transitive Dependencies
-----------------------

Dependencies are resolved transitively through the entire chain.

**overrides/settings/triton.yaml**:

.. code-block:: yaml

dependencies:
- cuda-toolkit
changelog:
"2.3.0":
- "Triton for CUDA 12.x"

**overrides/settings/torch.yaml**:

.. code-block:: yaml

dependencies:
- triton
changelog:
"2.0.0":
- "Initial build"

Build tags for ``torch==2.0.0``:

* ``cuda-toolkit`` (version 12.9): 2 changelog entries → build tag = ``2``
* ``triton`` (version 2.3.0): 1 own + 2 from cuda-toolkit → build tag = ``3``
* ``torch`` (version 2.0.0): 1 own + 3 from triton → build tag = ``4``

Notice that ``torch`` **automatically includes** ``cuda-toolkit``'s changes even
though it only directly lists ``triton`` as a dependency.

Multiple Dependencies
---------------------

A package can depend on multiple packages. All dependency build tags are summed.

**overrides/settings/vllm.yaml**:

.. code-block:: yaml

dependencies:
- torch
- triton
- cuda-toolkit
changelog:
"0.3.0":
- "Initial vLLM build"

If each dependency has a build tag of 2, then:

.. code-block:: text

vllm build_tag = 1 (own) + 2 (torch) + 2 (triton) + 2 (cuda-toolkit) = 7

"Fake Packages" for Platform Dependencies
------------------------------------------

You can create settings files for platform dependencies (like CUDA, ROCm) that
don't actually exist as Python packages. These act as markers to track platform
version changes.

**overrides/settings/cuda-toolkit.yaml**:

.. code-block:: yaml

# No source code - just a version marker
changelog:
"12.8":
- "CUDA 12.8.0 release"
"12.9":
- "CUDA 12.9.0 release"

**overrides/settings/rocm-runtime.yaml**:

.. code-block:: yaml

changelog:
"6.0":
- "ROCm 6.0 release"
"6.1":
- "ROCm 6.1 release"

Now packages can depend on these platform markers:

.. code-block:: yaml

dependencies:
- cuda-toolkit # or rocm-runtime for ROCm builds

Circular Dependency Detection
------------------------------

Fromager automatically detects and prevents circular dependencies:

**overrides/settings/package-a.yaml**:

.. code-block:: yaml

dependencies:
- package-b

**overrides/settings/package-b.yaml**:

.. code-block:: yaml

dependencies:
- package-a

This will raise an error:

.. code-block:: text

ValueError: Circular dependency detected: package-a appears in
dependency chain: package-a -> package-b -> package-a

Scope and Limitations
----------------------

**Version-Independent**

Dependencies apply to **all versions** of a package. You cannot specify
different dependencies for different versions, or use version constraints:

.. code-block:: yaml

dependencies:
- torch # ✓ Correct
- torch>=2.0 # ✗ Not supported
- torch; sys_platform=='linux' # ✗ Not supported

**Per-Package, Not Per-Variant**

Dependencies are package-level, not variant-level. If you need different
dependencies for CUDA vs ROCm variants, use separate packages or platform markers.

**Build Tags Only**

The ``dependencies`` field only affects build tag calculation. It does **not**:

* Add runtime dependencies to wheel metadata
* Affect dependency resolution during bootstrap
* Change the build environment or compilation flags

Best Practices
--------------

1. **Use Semantic Changelog Entries**: Write clear changelog entries that
explain what changed and why a rebuild is needed.

2. **Minimize Dependencies**: Only list direct dependencies that actually affect
the build. Transitive dependencies are included automatically.

3. **Platform Markers**: Use fake packages for system dependencies (CUDA, ROCm,
OpenSSL) to track platform version changes separately from Python packages.

4. **Testing**: When adding a dependency, verify the build tag increments as
expected by checking the wheel filename.

See Also
--------

* :doc:`/reference/config-reference` - Full configuration reference
* :doc:`/customization` - Comprehensive customization guide
1 change: 1 addition & 0 deletions docs/how-tos/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Customize builds with overrides, variants, and version handling.
:maxdepth: 1

pyproject-overrides
dependency-build-tags
multiple-versions
pre-release-versions

Expand Down
15 changes: 15 additions & 0 deletions src/fromager/packagesettings/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,21 @@ class PackageSettings(pydantic.BaseModel):
changelog: VariantChangelog = Field(default_factory=dict)
"""Changelog entries"""

dependencies: list[Package] = Field(default_factory=list)
"""Package dependencies for build tag calculation

When any dependency's build tag changes, this package's build tag
increases accordingly. Applies to all versions of the package.
Supports transitive dependencies and "fake packages" (platform
dependencies like CUDA, ROCm that have settings but no source).

Example::

dependencies:
- torch
- cuda-toolkit
"""

config_settings: dict[str, str | list[str]] = Field(default_factory=dict)
"""PEP 517 arbitrary configuration for wheel builds

Expand Down
59 changes: 54 additions & 5 deletions src/fromager/packagesettings/_pbi.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class PackageBuildInfo:
"""

def __init__(self, settings: Settings, ps: PackageSettings) -> None:
self._settings = settings
self._variant = typing.cast(Variant, settings.variant)
self._patches_dir = settings.patches_dir
self._variant_changelog = settings.variant_changelog()
Expand Down Expand Up @@ -284,24 +285,72 @@ def get_changelog(self, version: Version) -> list[str]:
return variant_changelog + package_changelog

def build_tag(self, version: Version) -> BuildTag:
"""Build tag for version's changelog and this variant
"""Build tag for version's changelog and dependencies

The build tag is calculated as:
own_changelog_count + sum(dependency_build_tags)

Dependencies are resolved recursively and transitively.

Args:
version: Package version to calculate build tag for

Raises:
ValueError: If circular dependency detected

.. versionchanged 0.54.0::

Fromager ignores local version suffix of a package to determinate
the build tag from changelog, e.g. version `1.0.3+local.suffix`
uses `1.0.3`.
"""
return self._calculate_build_tag(version, visited=set())

def _calculate_build_tag(
self, version: Version, visited: set[NormalizedName]
) -> BuildTag:
"""Recursively calculate build tag including dependencies

Args:
version: Package version to calculate build tag for
visited: Set of already-visited packages for cycle detection

Raises:
ValueError: If circular dependency detected
"""
if self.pre_built:
# pre-built wheels have no built tag
return ()

# Check for circular dependency
if self.package in visited:
raise ValueError(
f"Circular dependency detected: {self.package} appears in "
f"dependency chain: {' -> '.join(sorted(visited))} -> {self.package}"
)

# Add current package to visited set (immutable update)
visited = visited | {self.package}

# Calculate own changelog count
pv = typing.cast(PackageVersion, version)
release = len(self.get_changelog(pv))
if release == 0:
own_changelog_count = len(self.get_changelog(pv))

# Calculate dependency contribution
dependency_contribution = 0
for dep_pkg in self._ps.dependencies:
dep_pbi = self._settings.package_build_info(dep_pkg)
dep_tag = dep_pbi._calculate_build_tag(version, visited=visited)
Comment on lines +341 to +343
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Apr 13, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Resolve each dependency against its own version.

Line 343 passes the parent package's version into every dependency lookup. That means a build like torch==2.0.0 can only see changelog entries keyed as 2.0.0 in cuda-toolkit or triton, so the mixed-version / fake-package cases described in this PR never contribute to the tag. build_tag() needs dependency-specific versions (or resolved requirements), not a single root version threaded through the whole recursion.

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

In `@src/fromager/packagesettings/_pbi.py` around lines 341 - 343, The loop is
passing the parent package's version into dependency tag calculation
(self._ps.dependencies -> package_build_info ->
_calculate_build_tag(version,...)), which prevents dependencies from using their
own resolved versions; change it so each dep_pbi is called with its own resolved
version (e.g., obtain dep_version from the dependency build-info object or
resolved-requirements API on self._settings/dep_pbi and pass that to
dep_pbi._calculate_build_tag(dep_version, visited=visited)) so each dependency
is resolved against its correct version rather than the root package's version.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this is something we should fix

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

Let me look at the Settings class and the test cases to understand the intended behavior and available APIs.


🏁 Script executed:

#!/bin/bash
# Look at the Settings class to understand package_build_info and version resolution
fd '_settings.py' --type f | head -5

Length of output: 41


🏁 Script executed:

#!/bin/bash
ast-grep --pattern 'def package_build_info($$$)'

Length of output: 1391


🏁 Script executed:

#!/bin/bash
# Look at the test for fake_cuda to understand the expected behavior
rg -n 'fake_cuda\|dep_chain\|test_build_tag' tests/test_packagesettings.py -A 10

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Find build_tag related tests
grep -n "build_tag\|fake_cuda\|dep_chain\|circular" tests/test_packagesettings.py | head -60

Length of output: 2209


🏁 Script executed:

#!/bin/bash
# Look at the fake_cuda test data
cat tests/testdata/context/overrides/settings/test_fake_cuda.yaml
echo "---"
cat tests/testdata/context/overrides/settings/test_dep_chain_a.yaml
echo "---"
cat tests/testdata/context/overrides/settings/test_dep_chain_b.yaml
echo "---"
cat tests/testdata/context/overrides/settings/test_dep_chain_c.yaml

Length of output: 314


🏁 Script executed:

#!/bin/bash
# Show the new test functions in full
grep -n "def test_" tests/test_packagesettings.py | tail -30

Length of output: 1850


🏁 Script executed:

#!/bin/bash
# Check PackageSettings.changelog field type and structure
grep -n "changelog" src/fromager/packagesettings/_models.py | head -30

Length of output: 88


🏁 Script executed:

#!/bin/bash
# Show the full test functions for build_tag
sed -n '919,1025p' tests/test_packagesettings.py

Length of output: 4068


@rd4398, the bug is confirmed and worth fixing. Here's the full analysis:

The problem is real but currently hidden. All dep_chain tests use Version("1.0.0") which happens to match the changelog keys in all dep_chain YAML fixtures — so the version is coincidentally correct. The fake_cuda tests only exercise that package directly; there's no test for a package that depends on test-fake-cuda. If torch==2.0.0 listed test-fake-cuda as a dependency, _calculate_build_tag would call dep_pbi._calculate_build_tag(Version("2.0.0"), ...), find no entry for "2.0.0" in the CUDA changelog, and contribute 0.

The right fix: For dependency contributions, don't pass the parent's version at all — instead sum all changelog entries across all versions of the dependency. The semantics shift from "what's the build tag of this dep at version X" to "how much has this dep chain changed in total". This is version-agnostic, which is consistent with the dependencies field having no version specifier.

Here's the suggested implementation:

def _calculate_build_tag(
    self, version: Version, visited: set[NormalizedName]
) -> BuildTag:
    if self.pre_built:
        return ()

    if self.package in visited:
        raise ValueError(
            f"Circular dependency detected: {self.package} appears in "
            f"dependency chain: {' -> '.join(sorted(visited))} -> {self.package}"
        )

    visited = visited | {self.package}

    # Own changelog: version-specific (existing behavior preserved)
    pv = typing.cast(PackageVersion, version)
    own_changelog_count = len(self.get_changelog(pv))

    # Dependency contribution: version-agnostic total across all versions
    dependency_contribution = self._dependency_changelog_total(visited)

    total = own_changelog_count + dependency_contribution
    if total == 0:
        return ()
    return total, ""


def _dependency_changelog_total(self, visited: set[NormalizedName]) -> int:
    """Sum of all changelog entries across all versions for this package's
    dependencies, used to compute version-agnostic dependency contributions."""
    total = 0
    for dep_pkg in self._ps.dependencies:
        dep_pbi = self._settings.package_build_info(dep_pkg)
        total += dep_pbi._own_total_changelog_count(visited)
    return total


def _own_total_changelog_count(self, visited: set[NormalizedName]) -> int:
    """Recursively sum all changelog entries across all versions for
    cycle-safe, version-agnostic dependency contribution calculation."""
    if self.pre_built:
        return 0

    if self.package in visited:
        raise ValueError(
            f"Circular dependency detected: {self.package} appears in "
            f"dependency chain: {' -> '.join(sorted(visited))} -> {self.package}"
        )

    visited = visited | {self.package}

    # Sum own entries across ALL versions (version-agnostic for dep chains)
    own_count = len(self._variant_changelog)
    own_count += sum(len(entries) for entries in self._ps.changelog.values())

    # Recurse into transitive dependencies
    for dep_pkg in self._ps.dependencies:
        dep_pbi = self._settings.package_build_info(dep_pkg)
        own_count += dep_pbi._own_total_changelog_count(visited)

    return own_count

Why this is correct for each case:

  • test_dep_chain_c (no deps, 1 entry for "1.0.0"): _own_total_changelog_count = 1 → same as before
  • test_dep_chain_b (depends on C, 1 own entry): 1 + 1 = 2 → same as before
  • test-fake-cuda as a dependency: sums entries for "12.9" + "12.8" = 2, increasing monotonically as new CUDA versions are added

You'd also need a new test like:

def test_build_tag_with_fake_package_dependency(testdata_context):
    """A package depending on fake-cuda should accumulate total CUDA changelog entries."""
    # Create a package that lists test-fake-cuda as a dependency
    ps = PackageSettings.from_string("test-torch", """
dependencies:
  - test-fake-cuda
changelog:
  "2.0.0":
    - "torch release"
""")
    settings = Settings(
        settings=SettingsFile(),
        package_settings=[ps],
        variant="cpu",
        patches_dir=testdata_context.settings.patches_dir,
        max_jobs=1,
    )
    # fake-cuda has 2 total entries (12.9 + 12.8), torch has 1 own = 3
    pbi = settings.package_build_info("test-torch")
    assert pbi.build_tag(Version("2.0.0")) == (3, "")
  • Refactor _calculate_build_tag to delegate dependency contribution to a new version-agnostic helper
  • Add _dependency_changelog_total and _own_total_changelog_count helpers in _pbi.py
  • Add a missing test covering a package that depends on a "fake" platform package like test-fake-cuda

🏁 Script executed:

#!/bin/bash
# Confirm the exact line range of _calculate_build_tag and related methods
grep -n "_calculate_build_tag\|_dependency_changelog_total\|_own_total_changelog_count\|def build_tag" src/fromager/packagesettings/_pbi.py

Length of output: 242


Plan: Fix version-agnostic dependency changelog accumulation

  • Refactor _calculate_build_tag in _pbi.py to separate own (versioned) count from dependency (version-agnostic) count
  • Add _own_total_changelog_count recursive helper for summing all versions of a dep
  • Add test fixture YAML for a package that depends on test-fake-cuda
  • Add a test asserting the correct cross-version accumulation

Failed to handle agent chat message. Please try again.

if dep_tag: # Only count if dependency has a build tag
dependency_contribution += dep_tag[0]

total = own_changelog_count + dependency_contribution

if total == 0:
return ()
# suffix = "." + self.variant.replace("-", "_")

suffix = ""
return release, suffix
return total, suffix

def get_extra_environ(
self,
Expand Down
Loading
Loading