diff --git a/conda_forge_tick/update_deps.py b/conda_forge_tick/update_deps.py index c5d61fd6c..db0da71d8 100644 --- a/conda_forge_tick/update_deps.py +++ b/conda_forge_tick/update_deps.py @@ -7,7 +7,7 @@ from collections import defaultdict from dataclasses import dataclass from pathlib import Path -from typing import Dict, Literal, Union +from typing import Dict, Literal, Union, cast import requests from grayskull.config import Configuration @@ -40,11 +40,19 @@ yaml.width = 4096 EnvDepComparison = dict[Literal["df_minus_cf", "cf_minus_df"], set[str]] -DepComparison = dict[Literal["host", "run"], EnvDepComparison] +DepComparison = dict[Literal["host", "run", "run_constrained"], EnvDepComparison] -SECTIONS_TO_PARSE = ["host", "run"] +SECTIONS_TO_PARSE = ["host", "run", "run_constrained"] SECTIONS_TO_UPDATE = ["run"] +# For run_constrained, we only update version constraints of existing packages. +# We do NOT add new packages (they're optional, maintainer's choice) or remove +# packages (maintainer may have specific reasons for including them). +# This prevents breaking changes while still keeping version constraints in sync. +SECTIONS_TO_UPDATE_CONSTRAINTS_ONLY = ["run_constrained"] +# Sections to show add/remove suggestions in hints (excludes run_constrained +# since we only update constraints there, not add/remove packages) +SECTIONS_TO_HINT = ["host", "run"] IGNORE_STUBS = ["doc", "example", "demo", "test", "unit_tests", "testing"] IGNORE_TEMPLATES = ["*/{z}/*", "*/{z}s/*"] @@ -632,15 +640,25 @@ def generate_dep_hint(dep_comparison, kind): "If you encounter issues with this feature please ping the bot team `conda-forge/bot`.\n\n" # noqa: E501 ) + # For host/run sections: show add/remove suggestions df_cf = "" - for sec in SECTIONS_TO_PARSE: + for sec in SECTIONS_TO_HINT: for k in dep_comparison.get(sec, {}).get("df_minus_cf", set()): df_cf += f"- {k}" + "\n" cf_df = "" - for sec in SECTIONS_TO_PARSE: + for sec in SECTIONS_TO_HINT: for k in dep_comparison.get(sec, {}).get("cf_minus_df", set()): cf_df += f"- {k}" + "\n" + # For run_constrained: only show version constraint updates (packages in both) + rc_updates = "" + rc_comp = dep_comparison.get("run_constrained", {}) + rc_cf = {dep.split(" ")[0]: dep for dep in rc_comp.get("cf_minus_df", set())} + rc_df = {dep.split(" ")[0]: dep for dep in rc_comp.get("df_minus_cf", set())} + for pkg in sorted(rc_cf.keys()): + if pkg in rc_df: + rc_updates += f"- {pkg}: `{rc_cf[pkg]}` -> `{rc_df[pkg]}`\n" + if len(df_cf) > 0 or len(cf_df) > 0: hint += ( f"Analysis by {kind} shows a discrepancy between it and the" @@ -658,6 +676,10 @@ def generate_dep_hint(dep_comparison, kind): ) else: hint += f"Analysis by {kind} shows **no discrepancy** with the stated requirements in the meta.yaml." # noqa: E501 + + if rc_updates: + hint += f"\n\n### Version constraint updates for run_constrained:\n{rc_updates}" + return hint @@ -666,7 +688,36 @@ def _ok_for_dep_updates(lines): return not is_multi_output -def _update_sec_deps(recipe, dep_comparison, sections_to_update, update_python=False): +def _update_sec_deps( + recipe, + dep_comparison, + sections_to_update, + update_python=False, + constraints_only=False, +): + """Update recipe dependencies based on comparison. + + Parameters + ---------- + recipe : CondaMetaYAML + The recipe to update. + dep_comparison : dict + The dependency comparison. + sections_to_update : list + The sections to update (e.g., ["run"] or ["run_constrained"]). + update_python : bool, optional + Whether to update python itself. Default is False. + constraints_only : bool, optional + If True, only update version constraints for packages that already exist + in the recipe. Do not add new packages. This is used for run_constrained + where we want to update stale version constraints but not modify the list + of optional dependencies. Default is False. + + Returns + ------- + bool + True if any dependencies were updated, False otherwise. + """ updated_deps = False rqkeys = list(_gen_key_selector(recipe.meta, "requirements")) @@ -677,6 +728,9 @@ def _update_sec_deps(recipe, dep_comparison, sections_to_update, update_python=F for section in sections_to_update: seckeys = list(_gen_key_selector(recipe.meta[rqkey], section)) if len(seckeys) == 0: + if constraints_only: + # Don't create section if it doesn't exist in constraints_only mode + continue recipe.meta[rqkey][section] = [] for seckey in _gen_key_selector(recipe.meta[rqkey], section): @@ -708,11 +762,18 @@ def _update_sec_deps(recipe, dep_comparison, sections_to_update, update_python=F if dep_pkg_nm == pkg_nm: loc = i break - if loc is None: - recipe.meta[rqkey][seckey].insert(0, dep) + + if constraints_only: + # Only update existing packages, don't add new ones + if loc is not None: + recipe.meta[rqkey][seckey][loc] = dep + updated_deps = True else: - recipe.meta[rqkey][seckey][loc] = dep - updated_deps = True + if loc is None: + recipe.meta[rqkey][seckey].insert(0, dep) + else: + recipe.meta[rqkey][seckey][loc] = dep + updated_deps = True return updated_deps @@ -749,9 +810,29 @@ def is_expression_requirement(dep: str) -> bool: def _apply_env_dep_comparison( - deps: list[str], env_dep_comparison: EnvDepComparison + deps: list[str], + env_dep_comparison: EnvDepComparison, + constraints_only: bool = False, ) -> list[str]: - """Apply updates to dependency list while maintaining original package order.""" + """Apply updates to dependency list while maintaining original package order. + + Parameters + ---------- + deps : list[str] + The current list of dependencies. + env_dep_comparison : EnvDepComparison + The comparison between grayskull and the recipe. + constraints_only : bool, optional + If True, only update version constraints for packages that exist in both + the recipe and grayskull. Do not add or remove packages. This is used for + run_constrained where we want to update stale version constraints but not + modify the list of optional dependencies. Default is False. + + Returns + ------- + list[str] + The updated list of dependencies. + """ new_deps = copy.copy(deps) patches = _env_dep_comparison_to_patches(env_dep_comparison) for package, patch in patches.items(): @@ -761,15 +842,21 @@ def _apply_env_dep_comparison( # Do not try to replace expressions. if patch.before is not None and is_expression_requirement(patch.before): continue - # Add new package. - if patch.before is None: - new_deps.append(patch.after) # type: ignore[arg-type] - # Remove old package. - elif patch.after is None: - new_deps.remove(patch.before) - # Update existing package. + + if constraints_only: + # For run_constrained: only update existing packages, don't add/remove + if patch.before is not None and patch.after is not None: + new_deps[new_deps.index(patch.before)] = patch.after else: - new_deps[new_deps.index(patch.before)] = patch.after + # Add new package. + if patch.before is None: + new_deps.append(patch.after) # type: ignore[arg-type] + # Remove old package. + elif patch.after is None: + new_deps.remove(patch.before) + # Update existing package. + else: + new_deps[new_deps.index(patch.before)] = patch.after return new_deps @@ -786,10 +873,26 @@ def _apply_dep_update_v1(recipe: dict, dep_comparison: DepComparison) -> dict: if not _is_v1_recipe_okay_for_dep_updates(recipe): return new_recipe + requirements = recipe.get("requirements", {}) + + # Update run section (add/remove/update) for section in SECTIONS_TO_UPDATE: - new_recipe["requirements"][section] = _apply_env_dep_comparison( - recipe["requirements"][section], - dep_comparison[section], # type: ignore[index] + if section in requirements and section in dep_comparison: + section_key = cast(Literal["host", "run", "run_constrained"], section) + new_recipe["requirements"][section] = _apply_env_dep_comparison( + requirements[section], + dep_comparison[section_key], + constraints_only=False, + ) + + # Update run_constraints section (v1 name for run_constrained) + # constraints only - no add/remove + # Note: v0 uses "run_constrained", v1 uses "run_constraints" + if "run_constraints" in requirements and "run_constrained" in dep_comparison: + new_recipe["requirements"]["run_constraints"] = _apply_env_dep_comparison( + requirements["run_constraints"], + dep_comparison["run_constrained"], + constraints_only=True, ) return new_recipe @@ -801,6 +904,19 @@ def _get_v1_recipe_file_if_exists(recipe_dir: Path) -> Path | None: return None +def _has_run_constrained_updates(dep_comparison: dict) -> bool: + """Check if there are version constraint updates for run_constrained. + + Only returns True if the same package exists in both cf_minus_df and df_minus_cf, + meaning there's a version constraint change (not an add or remove). + """ + rc_comp = dep_comparison.get("run_constrained", {}) + rc_cf = {dep.split(" ")[0] for dep in rc_comp.get("cf_minus_df", set())} + rc_df = {dep.split(" ")[0] for dep in rc_comp.get("df_minus_cf", set())} + # Only update if the same package exists in both (version constraint change) + return bool(rc_cf & rc_df) + + def apply_dep_update(recipe_dir, dep_comparison): """Update a recipe given a dependency comparison. @@ -821,16 +937,34 @@ def apply_dep_update(recipe_dir, dep_comparison): with open(recipe_pth) as fp: lines = fp.readlines() - if _ok_for_dep_updates(lines) and any( + has_run_updates = any( len(dep_comparison.get(s, {}).get("df_minus_cf", set())) > 0 for s in SECTIONS_TO_UPDATE - ): + ) + has_rc_updates = _has_run_constrained_updates(dep_comparison) + + if _ok_for_dep_updates(lines) and (has_run_updates or has_rc_updates): recipe = CondaMetaYAML("".join(lines)) - updated_deps = _update_sec_deps( - recipe, - dep_comparison, - SECTIONS_TO_UPDATE, - ) + updated_deps = False + + # Update run section (add/remove/update) + if has_run_updates: + updated_deps |= _update_sec_deps( + recipe, + dep_comparison, + SECTIONS_TO_UPDATE, + constraints_only=False, + ) + + # Update run_constrained section (constraints only - no add/remove) + if has_rc_updates: + updated_deps |= _update_sec_deps( + recipe, + dep_comparison, + SECTIONS_TO_UPDATE_CONSTRAINTS_ONLY, + constraints_only=True, + ) + # updated_deps is True if deps were updated, False otherwise. if updated_deps: with open(recipe_pth, "w") as fp: diff --git a/tests/test_update_deps.py b/tests/test_update_deps.py index c778483f6..2fcdc4480 100644 --- a/tests/test_update_deps.py +++ b/tests/test_update_deps.py @@ -802,7 +802,92 @@ def test_update_deps_version_pyquil(caplog, tmp_path, update_kind, out_yml): - andreyz4k - janjagusch """, - ) + ), + # Test case for run_constraints updates in v1 recipes + # Note: v1 recipes use "run_constraints", v0 uses "run_constrained" + ( + """schema_version: 1 + +context: + name: litellm + version: "1.55.8" + +package: + name: ${{ name|lower }} + version: ${{ version }} + +source: + url: https://pypi.org/packages/source/${{ name[0] }}/${{ name }}/${{ name }}-${{ version }}.tar.gz + sha256: abc123 + +build: + number: 0 + noarch: python + script: ${{ PYTHON }} -m pip install . --no-deps -vv + +requirements: + host: + - python ${{ python_min }}.* + - pip + run: + - python >=${{ python_min }} + - httpx >=0.23.0 + run_constraints: + - uvicorn >=0.31.1,<0.32.0 + +about: + license: MIT + summary: Test package + +extra: + recipe-maintainers: + - test-maintainer +""", + { + "host": {"cf_minus_df": set(), "df_minus_cf": set()}, + "run": {"cf_minus_df": set(), "df_minus_cf": set()}, + "run_constrained": { + "cf_minus_df": {"uvicorn >=0.31.1,<0.32.0"}, + "df_minus_cf": {"uvicorn >=0.32.1,<1.0.0"}, + }, + }, + """schema_version: 1 + +context: + name: litellm + version: 1.55.8 + +package: + name: ${{ name|lower }} + version: ${{ version }} + +source: + url: https://pypi.org/packages/source/${{ name[0] }}/${{ name }}/${{ name }}-${{ version }}.tar.gz + sha256: abc123 + +build: + number: 0 + noarch: python + script: ${{ PYTHON }} -m pip install . --no-deps -vv + +requirements: + host: + - python ${{ python_min }}.* + - pip + run: + - python >=${{ python_min }} + - httpx >=0.23.0 + run_constraints: + - uvicorn >=0.32.1,<1.0.0 +about: + license: MIT + summary: Test package + +extra: + recipe-maintainers: + - test-maintainer +""", + ), ], ) def test_apply_dep_update_v1( @@ -1255,3 +1340,208 @@ def test_get_grayskull_comparison_v1_python_min_mismatch(): # If we get here, the comparison should have valid results assert "run" in dep_comparison assert recipe != "" + + +# Tests for run_constrained support +class TestRunConstrainedUpdates: + """Tests for run_constrained dependency updates. + + These tests verify that the grayskull inspection feature correctly handles + run_constrained dependencies: + - Updates version constraints for packages that exist in both recipe and grayskull + - Does NOT add new packages to run_constrained + - Does NOT remove packages from run_constrained + """ + + def test_sections_constants_include_run_constrained(self): + """Test that run_constrained is in SECTIONS_TO_PARSE.""" + from conda_forge_tick.update_deps import ( + SECTIONS_TO_PARSE, + SECTIONS_TO_UPDATE, + SECTIONS_TO_UPDATE_CONSTRAINTS_ONLY, + ) + + assert "run_constrained" in SECTIONS_TO_PARSE + assert "host" in SECTIONS_TO_PARSE + assert "run" in SECTIONS_TO_PARSE + + # run_constrained should NOT be in SECTIONS_TO_UPDATE (prevents add/remove) + assert "run_constrained" not in SECTIONS_TO_UPDATE + assert "run" in SECTIONS_TO_UPDATE + + # run_constrained should be in SECTIONS_TO_UPDATE_CONSTRAINTS_ONLY + assert "run_constrained" in SECTIONS_TO_UPDATE_CONSTRAINTS_ONLY + + +class TestApplyEnvDepComparisonConstraintsOnly: + """Test _apply_env_dep_comparison with constraints_only=True.""" + + def test_updates_existing_package_version(self): + """Should update version constraint when package exists in both.""" + from conda_forge_tick.update_deps import _apply_env_dep_comparison + + deps = ["uvicorn >=0.31.1,<0.32.0", "rich =13.7.1"] + env_dep_comparison = { + "df_minus_cf": {"uvicorn >=0.32.1,<1.0.0"}, # New constraint from grayskull + "cf_minus_df": {"uvicorn >=0.31.1,<0.32.0"}, # Old constraint in recipe + } + + result = _apply_env_dep_comparison( + deps, env_dep_comparison, constraints_only=True + ) + + assert "uvicorn >=0.32.1,<1.0.0" in result + assert "uvicorn >=0.31.1,<0.32.0" not in result + assert "rich =13.7.1" in result # Unchanged + + def test_does_not_add_new_packages(self): + """Should NOT add packages that only exist in grayskull.""" + from conda_forge_tick.update_deps import _apply_env_dep_comparison + + deps = ["uvicorn >=0.31.1,<0.32.0"] + env_dep_comparison = { + "df_minus_cf": {"new-package >=1.0.0"}, # New package from grayskull + "cf_minus_df": set(), + } + + result = _apply_env_dep_comparison( + deps, env_dep_comparison, constraints_only=True + ) + + assert "new-package >=1.0.0" not in result + assert result == deps # Unchanged + + def test_does_not_remove_packages(self): + """Should NOT remove packages that only exist in recipe.""" + from conda_forge_tick.update_deps import _apply_env_dep_comparison + + deps = ["uvicorn >=0.31.1,<0.32.0", "custom-dep >=1.0.0"] + env_dep_comparison = { + "df_minus_cf": set(), + "cf_minus_df": { + "custom-dep >=1.0.0" + }, # Package in recipe but not grayskull + } + + result = _apply_env_dep_comparison( + deps, env_dep_comparison, constraints_only=True + ) + + assert "custom-dep >=1.0.0" in result # Should still be there + assert result == deps # Unchanged + + +class TestApplyEnvDepComparisonNormal: + """Test _apply_env_dep_comparison with constraints_only=False (default).""" + + def test_adds_new_packages(self): + """Should add packages that only exist in grayskull.""" + from conda_forge_tick.update_deps import _apply_env_dep_comparison + + deps = ["existing-pkg >=1.0.0"] + env_dep_comparison = { + "df_minus_cf": {"new-package >=1.0.0"}, + "cf_minus_df": set(), + } + + result = _apply_env_dep_comparison( + deps, env_dep_comparison, constraints_only=False + ) + + assert "new-package >=1.0.0" in result + assert "existing-pkg >=1.0.0" in result + + def test_removes_packages(self): + """Should remove packages that only exist in recipe.""" + from conda_forge_tick.update_deps import _apply_env_dep_comparison + + deps = ["keep-pkg >=1.0.0", "remove-pkg >=1.0.0"] + env_dep_comparison = { + "df_minus_cf": set(), + "cf_minus_df": {"remove-pkg >=1.0.0"}, + } + + result = _apply_env_dep_comparison( + deps, env_dep_comparison, constraints_only=False + ) + + assert "remove-pkg >=1.0.0" not in result + assert "keep-pkg >=1.0.0" in result + + +class TestGenerateDepHintRunConstrained: + """Test that generate_dep_hint shows run_constrained updates correctly.""" + + def test_shows_run_constrained_version_updates(self): + """Should show version constraint updates for run_constrained.""" + dep_comparison = { + "host": {"df_minus_cf": set(), "cf_minus_df": set()}, + "run": {"df_minus_cf": set(), "cf_minus_df": set()}, + "run_constrained": { + "df_minus_cf": {"uvicorn >=0.32.1,<1.0.0"}, + "cf_minus_df": {"uvicorn >=0.31.1,<0.32.0"}, + }, + } + + hint = generate_dep_hint(dep_comparison, "grayskull") + + # Should show the version update + assert "run_constrained" in hint + assert "uvicorn" in hint + + def test_no_run_constrained_add_remove_suggestions(self): + """Should NOT show add/remove suggestions for run_constrained.""" + dep_comparison = { + "host": {"df_minus_cf": set(), "cf_minus_df": set()}, + "run": {"df_minus_cf": set(), "cf_minus_df": set()}, + "run_constrained": { + "df_minus_cf": {"new-pkg >=1.0.0"}, # Only in grayskull + "cf_minus_df": {"old-pkg >=1.0.0"}, # Only in recipe + }, + } + + hint = generate_dep_hint(dep_comparison, "grayskull") + + # Should NOT suggest adding new-pkg or removing old-pkg + # (they don't share the same package name, so no constraint update) + assert ( + "new-pkg" not in hint or "run_constrained" not in hint.split("new-pkg")[0] + ) + + +class TestLitellmScenario: + """Test the specific litellm/uvicorn scenario that motivated this change.""" + + def test_litellm_uvicorn_update(self): + """ + Simulates the litellm feedstock scenario where upstream changed uvicorn + from ^0.31.1 (Poetry caret) to >=0.32.1,<1.0.0 (explicit range). + + The conda-forge recipe has the old constraint, and grayskull generates + the new constraint. The bot should update the version constraint. + """ + from conda_forge_tick.update_deps import _apply_env_dep_comparison + + deps = [ + "uvicorn >=0.31.1,<0.32.0", # Old Poetry caret translation + "rich =13.7.1", + "websockets >=15.0.1,<16.0.0", + "mcp >=1.25.0,<2.0.0", + ] + env_dep_comparison = { + "df_minus_cf": {"uvicorn >=0.32.1,<1.0.0"}, # New constraint + "cf_minus_df": {"uvicorn >=0.31.1,<0.32.0"}, # Old constraint + } + + result = _apply_env_dep_comparison( + deps, env_dep_comparison, constraints_only=True + ) + + # uvicorn should be updated to the new constraint + assert "uvicorn >=0.32.1,<1.0.0" in result + assert "uvicorn >=0.31.1,<0.32.0" not in result + + # Other deps should be unchanged + assert "rich =13.7.1" in result + assert "websockets >=15.0.1,<16.0.0" in result + assert "mcp >=1.25.0,<2.0.0" in result