Skip to content
Merged
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
7 changes: 7 additions & 0 deletions docs/concepts/resolver-architecture.rst
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ CLI commands interact with providers through a common
``get_resolver_provider`` hook as ``sdist_server_url``. ``hook-sdist``
defaults to PyPI, while ``hook-prebuilt`` requires an explicit index.

``find-updates`` uses the configured source resolver when a package has
``source:`` settings. The source profile determines distribution types and
the index URL. Packages without ``source:`` settings retain the legacy
provider hook, distribution options, and index URL overrides.
Update reports ignore global and per-package release-age cooldowns so
recent releases remain visible with either configuration style.

Per-package settings in YAML can select which provider to use and
configure its parameters (index URL, tag pattern, etc.). When a package
has a ``source:`` resolver configured, that resolver creates the provider.
Expand Down
47 changes: 30 additions & 17 deletions src/fromager/commands/find_updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from packaging.requirements import Requirement
from packaging.version import Version

from .. import constraints, context, overrides, resolver
from .. import constraints, context, overrides, resolver, sources
from . import package

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -79,6 +79,10 @@ def find_updates(
- "csv": CSV format with "name" and "version" columns

Use --output to write results to a file instead of stdout.

Packages with source configuration use their configured resolver.
Distribution type options apply to legacy configuration.
Update reports include recent releases regardless of release-age cooldowns.
"""
# Load the constraints file
constraint_loader = constraints.Constraints()
Expand Down Expand Up @@ -185,24 +189,33 @@ def _find_newer_versions(
"""Find versions that would be updates beyond the constraint for a package."""
# Get package build info to determine distribution preferences
pbi = wkctx.package_build_info(constraint)
override_sdist_server_url = pbi.resolver_sdist_server_url(sdist_server_url)
if pbi.source_resolver is not None:
Comment thread
jlarkin09 marked this conversation as resolved.
provider = sources.get_source_provider(
ctx=wkctx,
req=constraint,
sdist_server_url=sdist_server_url,
)
# Update discovery includes releases that are too recent to build.
provider.cooldown = None
else:
override_sdist_server_url = pbi.resolver_sdist_server_url(sdist_server_url)

# Determine include flags based on distribution type
include_sdists, include_wheels = package.parse_distribution_option(
distribution_type, pbi
)
# Determine include flags based on distribution type
include_sdists, include_wheels = package.parse_distribution_option(
distribution_type, pbi
)

# Get resolver provider
provider = overrides.find_and_invoke(
constraint.name,
"get_resolver_provider",
resolver.default_resolver_provider,
ctx=wkctx,
req=constraint,
include_sdists=include_sdists,
include_wheels=include_wheels,
sdist_server_url=override_sdist_server_url,
)
# Get resolver provider
provider = overrides.find_and_invoke(
constraint.name,
"get_resolver_provider",
resolver.default_resolver_provider,
ctx=wkctx,
req=constraint,
include_sdists=include_sdists,
include_wheels=include_wheels,
sdist_server_url=override_sdist_server_url,
)

# Create a requirement without version constraints to get all available versions
unconstrained_req = Requirement(constraint.name)
Expand Down
246 changes: 240 additions & 6 deletions tests/test_find_updates.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,245 @@
"""Tests for constraint parsing and update resolution."""

import datetime
import pathlib
from unittest.mock import patch

import pytest
import requests_mock
from click.testing import CliRunner
from packaging.requirements import Requirement
from packaging.version import Version

from fromager.commands.find_updates import _get_constraint_version
from fromager import context, packagesettings, resolver
from fromager.candidate import Candidate, Cooldown
from fromager.commands.find_updates import (
_find_newer_versions,
_get_constraint_version,
find_updates,
)


@pytest.mark.parametrize(
("config", "distribution_type", "index_url", "expected_versions"),
[
(
"source:\n provider: pypi-sdist\n index_url: https://source.test/simple/\n",
"default",
"https://source.test/simple/",
["2.0.0"],
),
(
"source:\n provider: pypi-prebuilt\n index_url: https://source.test/simple/\n",
"default",
"https://source.test/simple/",
["3.0.0"],
),
(
"source:\n provider: pypi-sdist\n index_url: https://source.test/simple/\n",
"wheel",
"https://source.test/simple/",
["2.0.0"],
),
(
"source:\n provider: pypi-prebuilt\n index_url: https://source.test/simple/\n",
"sdist",
"https://source.test/simple/",
["3.0.0"],
),
(
"source:\n provider: pypi-sdist\n"
"variants:\n cpu:\n source:\n provider: pypi-prebuilt\n"
" index_url: https://source.test/simple/\n",
"default",
"https://source.test/simple/",
["3.0.0"],
),
("", "default", "https://caller.test/simple/", ["2.0.0"]),
*[
(
"resolver_dist:\n sdist_server_url: https://legacy.test/simple/\n"
" include_sdists: false\n include_wheels: true\n",
distribution_type,
"https://legacy.test/simple/",
expected_versions,
)
for distribution_type, expected_versions in [
("default", ["3.0.0"]),
("sdist", ["2.0.0"]),
("wheel", ["3.0.0"]),
("both", ["2.0.0", "3.0.0"]),
]
],
],
ids=[
"source-sdist",
"source-prebuilt",
"source-sdist-overrides-distribution-option",
"source-prebuilt-overrides-distribution-option",
"source-variant",
"legacy-caller-index",
"legacy-default",
"legacy-sdist",
"legacy-wheel",
"legacy-both",
],
)
def test_find_updates_resolves_configured_package(
config: str,
distribution_type: str,
index_url: str,
expected_versions: list[str],
tmp_context: context.WorkContext,
tmp_path: pathlib.Path,
cli_runner: CliRunner,
requests_mock: requests_mock.Mocker,
) -> None:
"""Resolve updates using source profiles or legacy distribution settings."""
settings_dir = tmp_path / "settings"
settings_dir.mkdir()
(settings_dir / "test-pkg.yaml").write_text(config)
tmp_context.settings = packagesettings.Settings.from_files(
settings_file=tmp_path / "settings.yaml",
settings_dir=settings_dir,
patches_dir=tmp_path / "patches",
variant="cpu",
max_jobs=None,
)
constraints_file = tmp_path / "constraints.txt"
constraints_file.write_text("test-pkg==1.0.0\n")
resolver.BaseProvider.clear_cache()
requests_mock.get(
f"{index_url}test-pkg/",
text=(
'<a href="test_pkg-1.0.0.tar.gz">test_pkg-1.0.0.tar.gz</a>'
'<a href="test_pkg-2.0.0.tar.gz">test_pkg-2.0.0.tar.gz</a>'
'<a href="test_pkg-3.0.0-py3-none-any.whl">test_pkg-3.0.0-py3-none-any.whl</a>'
),
)

result = cli_runner.invoke(
find_updates,
[
"--sdist-server-url",
"https://caller.test/simple/",
"--distribution-type",
distribution_type,
str(constraints_file),
],
obj=tmp_context,
)

assert result.exit_code == 0, result.output
assert result.output.splitlines() == [
f"test-pkg=={version}" for version in expected_versions
]
assert requests_mock.call_count == 1


@pytest.mark.parametrize("source_provider", [None, "pypi-sdist", "pypi-prebuilt"])
@pytest.mark.parametrize(
("global_days", "package_days"),
[(7, None), (None, 7)],
ids=["global-cooldown", "package-cooldown"],
)
@pytest.mark.parametrize(
"include_old_release", [True, False], ids=["mixed", "all-recent"]
)
def test_find_updates_ignores_release_age_cooldown(
source_provider: str | None,
global_days: int | None,
package_days: int | None,
include_old_release: bool,
tmp_context: context.WorkContext,
tmp_path: pathlib.Path,
cli_runner: CliRunner,
requests_mock: requests_mock.Mocker,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Report recent updates with either source or legacy configuration."""
profile: dict[str, str | int] = {}
if source_provider is not None:
profile["provider"] = source_provider
if package_days is not None:
profile["min_release_age"] = package_days
package_settings = packagesettings.PackageSettings.from_mapping(
"test-pkg",
{"source" if source_provider is not None else "resolver_dist": profile},
source="test",
has_config=True,
)
tmp_context.settings = packagesettings.Settings(
settings=packagesettings.SettingsFile(),
package_settings=[package_settings],
patches_dir=tmp_path / "patches",
variant="cpu",
max_jobs=None,
)
now = datetime.datetime.now(datetime.UTC)
if global_days is not None:
tmp_context.cooldown = Cooldown(
min_age=datetime.timedelta(days=global_days),
bootstrap_time=now,
)
constraints_file = tmp_path / "constraints.txt"
constraints_file.write_text("test-pkg==1.0.0\n")
suffix = "-py3-none-any.whl" if source_provider == "pypi-prebuilt" else ".tar.gz"
releases = [("2.0.0", 1)]
if include_old_release:
releases.append(("1.0.0", 30))
resolver.BaseProvider.clear_cache()
requests_mock.get(
"https://pypi.org/simple/test-pkg/",
headers={"Content-Type": "application/vnd.pypi.simple.v1+json"},
json={
"meta": {"api-version": "1.1"},
"name": "test-pkg",
"files": [
{
"filename": f"test_pkg-{version}{suffix}",
"url": f"https://files.test/test_pkg-{version}{suffix}",
"hashes": {},
"upload-time": (now - datetime.timedelta(days=days)).isoformat(),
}
for version, days in releases
],
},
)

result = cli_runner.invoke(find_updates, [str(constraints_file)], obj=tmp_context)

assert result.exit_code == 0, result.output
assert result.output == "test-pkg==2.0.0\n"
assert "Could not check versions" not in caplog.text
assert "cooldown blocked" not in caplog.text


def test_find_newer_versions_preserves_legacy_provider_hook(
tmp_context: context.WorkContext,
) -> None:
"""Continue resolving legacy packages through their provider override."""
candidate = Candidate(
name="test-pkg",
version=Version("2.0.0"),
url="https://hook.test/test_pkg-2.0.0.tar.gz",
)
provider = resolver.GenericProvider(
version_source=lambda identifier: [candidate],
use_resolver_cache=False,
)
with patch(
"fromager.overrides.find_override_method",
return_value=lambda **kwargs: provider,
):
versions = _find_newer_versions(
tmp_context,
Requirement("test-pkg==1.0.0"),
Version("1.0.0"),
"default",
"https://caller.test/simple/",
)

assert versions == [Version("2.0.0")]


class TestGetConstraintVersion:
Expand Down Expand Up @@ -77,13 +315,9 @@ def test_invalid_version_format(self) -> None:
"""Test that invalid version formats return None."""
# We need to test the case where Version() construction fails
# Let's create a requirement and then mock the Version parsing
import unittest.mock

req = Requirement("package==1.0.0")

with unittest.mock.patch(
"fromager.commands.find_updates.Version"
) as mock_version:
with patch("fromager.commands.find_updates.Version") as mock_version:
mock_version.side_effect = ValueError("Invalid version")
result = _get_constraint_version(req)
assert result is None
Loading