From 08c09a692a3c399c8bb442b77f0e6be675ca3680 Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Mon, 26 Oct 2026 23:18:16 +0000 Subject: [PATCH 1/3] [ci] Expand dependency group includes in the security checker --- scripts/check-dependency-security.py | 24 +++++++++- tests/test_uv_workflows.py | 66 ++++++++++++++++++++++++++-- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/scripts/check-dependency-security.py b/scripts/check-dependency-security.py index b6905f5f69..16e22e5493 100644 --- a/scripts/check-dependency-security.py +++ b/scripts/check-dependency-security.py @@ -183,6 +183,26 @@ def expand(node: ast.expr, depth: int = 0) -> list[MarkerContext]: return tuple(unique) +def dependency_group_requirements(project: dict[str, Any], name: str, active: tuple[str, ...] = ()) -> list[str]: + groups = project.get("dependency-groups", {}) + normalized = {canonical(group): group for group in groups} + key = canonical(name) + if len(normalized) != len(groups) or key not in normalized or key in active: + raise SystemExit("Invalid or cyclic security dependency group inclusion") + entries = groups[normalized[key]] + if not isinstance(entries, list): + raise SystemExit("Invalid security dependency group") + result: list[str] = [] + for entry in entries: + if isinstance(entry, str): + result.append(entry) + elif isinstance(entry, dict) and set(entry) == {"include-group"} and isinstance(entry["include-group"], str): + result.extend(dependency_group_requirements(project, entry["include-group"], (*active, key))) + else: + raise SystemExit("Invalid security dependency group entry") + return result + + def direct(project: dict[str, Any], *, protected: bool = False) -> tuple[RequirementMap, ContextsByName]: if protected: uv = project.get("tool", {}).get("uv", {}) @@ -191,8 +211,8 @@ def direct(project: dict[str, Any], *, protected: bool = False) -> tuple[Require ("uv-build-constraint", "", uv.get("build-constraint-dependencies", [])), ] groups.extend( - ("dependency-group", canonical(group), requirements) - for group, requirements in project.get("dependency-groups", {}).items() + ("dependency-group", canonical(group), dependency_group_requirements(project, group)) + for group in project.get("dependency-groups", {}) ) else: groups = [("runtime", "", project["project"].get("dependencies", []))] diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index 61bc7e9e41..425896f5ef 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -1401,8 +1401,8 @@ def run_security_dependency_floor_check( head_constraints: list[str] | None = None, base_build_constraints: list[str] | None = None, head_build_constraints: list[str] | None = None, - base_dependency_groups: dict[str, list[str]] | None = None, - head_dependency_groups: dict[str, list[str]] | None = None, + base_dependency_groups: dict[str, list[Any]] | None = None, + head_dependency_groups: dict[str, list[Any]] | None = None, base_resolution_markers: dict[tuple[str, str], list[str]] | None = None, head_resolution_markers: dict[tuple[str, str], list[str]] | None = None, base_lock_dependencies: dict[tuple[str, str], list[dict[str, object]]] | None = None, @@ -1417,7 +1417,7 @@ def project( groups: dict[str, list[str]] | None, constraints: list[str] | None, build_constraints: list[str] | None, - dependency_groups: dict[str, list[str]] | None, + dependency_groups: dict[str, list[Any]] | None, ) -> str: if optional: groups = {"feature": requirements} @@ -1430,7 +1430,15 @@ def project( if dependency_groups: result += "[dependency-groups]\n" for group, dependencies in dependency_groups.items(): - result += group + " = " + json.dumps(dependencies) + "\n" + entries = [ + json.dumps(entry) + if not isinstance(entry, dict) + else "{ " + + ", ".join(json.dumps(key) + " = " + json.dumps(value) for key, value in entry.items()) + + " }" + for entry in dependencies + ] + result += json.dumps(group) + " = [" + ", ".join(entries) + "]\n" if constraints is not None or build_constraints is not None: result += "[tool.uv]\n" if constraints is not None: @@ -8425,3 +8433,53 @@ def test_new_arbitrary_equality_bound_cannot_narrow_existing_published_source( optional=optional, ) assert result.returncode != 0 + + +@pytest.mark.parametrize("minimum,accepted", [("danger>=2", True), ("danger>=1", False)]) +def test_security_floor_checker_expands_nested_dependency_groups(tmp_path: Path, minimum: str, accepted: bool) -> None: + def groups(requirement: str) -> dict[str, list[Any]]: + return { + "provider-browser": [requirement], + "browser-demo": [{"include-group": "Provider_Browser"}], + "all-demos": [{"include-group": "browser-demo"}], + } + + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[], + head_requirements=[], + base_packages=[("danger", "1")], + head_packages=[("danger", "2")], + base_dependency_groups=groups("danger>=1"), + head_dependency_groups=groups(minimum), + run_in_subprocess=True, + ) + assert (result.returncode == 0) is accepted, result.stderr + + +@pytest.mark.parametrize( + "groups", + [ + {"demo": [{"include-group": "missing"}]}, + {"demo": [{"include-group": "demo"}]}, + {"demo": [{"include-group": "other"}], "other": [{"include-group": "demo"}]}, + {"demo": [{"include-group": "other", "unexpected": "value"}], "other": []}, + {"demo": [{"include-group": 123}]}, + {"demo": [123]}, + {"demo-one": [], "demo_one": []}, + ], +) +def test_security_floor_checker_rejects_invalid_dependency_group_includes( + tmp_path: Path, groups: dict[str, list[Any]] +) -> None: + result = run_security_dependency_floor_check( + tmp_path, + base_requirements=[], + head_requirements=[], + base_packages=[("unchanged", "1")], + head_packages=[("unchanged", "1")], + base_dependency_groups=groups, + head_dependency_groups=groups, + ) + assert result.returncode != 0 + assert "Invalid" in result.stderr From f5a55a4566fe4f1da49937821dedcbedc488743f Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Mon, 26 Oct 2026 23:21:12 +0000 Subject: [PATCH 2/3] [ci] Type dependency group entries explicitly --- scripts/check-dependency-security.py | 8 ++++++-- tests/test_uv_workflows.py | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/check-dependency-security.py b/scripts/check-dependency-security.py index 16e22e5493..07a2fa76ce 100644 --- a/scripts/check-dependency-security.py +++ b/scripts/check-dependency-security.py @@ -193,10 +193,14 @@ def dependency_group_requirements(project: dict[str, Any], name: str, active: tu if not isinstance(entries, list): raise SystemExit("Invalid security dependency group") result: list[str] = [] - for entry in entries: + for entry in cast(list[Any], entries): if isinstance(entry, str): result.append(entry) - elif isinstance(entry, dict) and set(entry) == {"include-group"} and isinstance(entry["include-group"], str): + elif ( + isinstance(entry, dict) + and set(cast(dict[str, Any], entry)) == {"include-group"} + and isinstance(entry["include-group"], str) + ): result.extend(dependency_group_requirements(project, entry["include-group"], (*active, key))) else: raise SystemExit("Invalid security dependency group entry") diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index 425896f5ef..7250573a71 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -1434,7 +1434,10 @@ def project( json.dumps(entry) if not isinstance(entry, dict) else "{ " - + ", ".join(json.dumps(key) + " = " + json.dumps(value) for key, value in entry.items()) + + ", ".join( + json.dumps(key) + " = " + json.dumps(value) + for key, value in cast(dict[str, Any], entry).items() + ) + " }" for entry in dependencies ] From 5aa0852ee2dbcca00dcdced3bab432a2e21ab80c Mon Sep 17 00:00:00 2001 From: Marcus Wood Date: Mon, 26 Oct 2026 23:33:48 +0000 Subject: [PATCH 3/3] [ci] Replace the browser demo group alias with its explicit dependency --- pyproject.toml | 2 +- scripts/check-dependency-security.py | 28 +---------- tests/test_uv_workflows.py | 69 ++-------------------------- 3 files changed, 7 insertions(+), 92 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 21884bc661..564175aca0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ pydantic-v1 = ["pydantic>=1.10.26,<2"] pydantic-v2 = ["pydantic>=2,<3"] provider-computer = ["Pillow==12.3.0"] provider-browser = ["playwright==1.63.0"] -browser-demo = [{ include-group = "provider-browser" }] +browser-demo = ["playwright==1.63.0"] # Isolated build requirements are exported with hashes by scripts/build. build = [ "hatchling==1.32.4", diff --git a/scripts/check-dependency-security.py b/scripts/check-dependency-security.py index 07a2fa76ce..b6905f5f69 100644 --- a/scripts/check-dependency-security.py +++ b/scripts/check-dependency-security.py @@ -183,30 +183,6 @@ def expand(node: ast.expr, depth: int = 0) -> list[MarkerContext]: return tuple(unique) -def dependency_group_requirements(project: dict[str, Any], name: str, active: tuple[str, ...] = ()) -> list[str]: - groups = project.get("dependency-groups", {}) - normalized = {canonical(group): group for group in groups} - key = canonical(name) - if len(normalized) != len(groups) or key not in normalized or key in active: - raise SystemExit("Invalid or cyclic security dependency group inclusion") - entries = groups[normalized[key]] - if not isinstance(entries, list): - raise SystemExit("Invalid security dependency group") - result: list[str] = [] - for entry in cast(list[Any], entries): - if isinstance(entry, str): - result.append(entry) - elif ( - isinstance(entry, dict) - and set(cast(dict[str, Any], entry)) == {"include-group"} - and isinstance(entry["include-group"], str) - ): - result.extend(dependency_group_requirements(project, entry["include-group"], (*active, key))) - else: - raise SystemExit("Invalid security dependency group entry") - return result - - def direct(project: dict[str, Any], *, protected: bool = False) -> tuple[RequirementMap, ContextsByName]: if protected: uv = project.get("tool", {}).get("uv", {}) @@ -215,8 +191,8 @@ def direct(project: dict[str, Any], *, protected: bool = False) -> tuple[Require ("uv-build-constraint", "", uv.get("build-constraint-dependencies", [])), ] groups.extend( - ("dependency-group", canonical(group), dependency_group_requirements(project, group)) - for group in project.get("dependency-groups", {}) + ("dependency-group", canonical(group), requirements) + for group, requirements in project.get("dependency-groups", {}).items() ) else: groups = [("runtime", "", project["project"].get("dependencies", []))] diff --git a/tests/test_uv_workflows.py b/tests/test_uv_workflows.py index 7250573a71..61bc7e9e41 100644 --- a/tests/test_uv_workflows.py +++ b/tests/test_uv_workflows.py @@ -1401,8 +1401,8 @@ def run_security_dependency_floor_check( head_constraints: list[str] | None = None, base_build_constraints: list[str] | None = None, head_build_constraints: list[str] | None = None, - base_dependency_groups: dict[str, list[Any]] | None = None, - head_dependency_groups: dict[str, list[Any]] | None = None, + base_dependency_groups: dict[str, list[str]] | None = None, + head_dependency_groups: dict[str, list[str]] | None = None, base_resolution_markers: dict[tuple[str, str], list[str]] | None = None, head_resolution_markers: dict[tuple[str, str], list[str]] | None = None, base_lock_dependencies: dict[tuple[str, str], list[dict[str, object]]] | None = None, @@ -1417,7 +1417,7 @@ def project( groups: dict[str, list[str]] | None, constraints: list[str] | None, build_constraints: list[str] | None, - dependency_groups: dict[str, list[Any]] | None, + dependency_groups: dict[str, list[str]] | None, ) -> str: if optional: groups = {"feature": requirements} @@ -1430,18 +1430,7 @@ def project( if dependency_groups: result += "[dependency-groups]\n" for group, dependencies in dependency_groups.items(): - entries = [ - json.dumps(entry) - if not isinstance(entry, dict) - else "{ " - + ", ".join( - json.dumps(key) + " = " + json.dumps(value) - for key, value in cast(dict[str, Any], entry).items() - ) - + " }" - for entry in dependencies - ] - result += json.dumps(group) + " = [" + ", ".join(entries) + "]\n" + result += group + " = " + json.dumps(dependencies) + "\n" if constraints is not None or build_constraints is not None: result += "[tool.uv]\n" if constraints is not None: @@ -8436,53 +8425,3 @@ def test_new_arbitrary_equality_bound_cannot_narrow_existing_published_source( optional=optional, ) assert result.returncode != 0 - - -@pytest.mark.parametrize("minimum,accepted", [("danger>=2", True), ("danger>=1", False)]) -def test_security_floor_checker_expands_nested_dependency_groups(tmp_path: Path, minimum: str, accepted: bool) -> None: - def groups(requirement: str) -> dict[str, list[Any]]: - return { - "provider-browser": [requirement], - "browser-demo": [{"include-group": "Provider_Browser"}], - "all-demos": [{"include-group": "browser-demo"}], - } - - result = run_security_dependency_floor_check( - tmp_path, - base_requirements=[], - head_requirements=[], - base_packages=[("danger", "1")], - head_packages=[("danger", "2")], - base_dependency_groups=groups("danger>=1"), - head_dependency_groups=groups(minimum), - run_in_subprocess=True, - ) - assert (result.returncode == 0) is accepted, result.stderr - - -@pytest.mark.parametrize( - "groups", - [ - {"demo": [{"include-group": "missing"}]}, - {"demo": [{"include-group": "demo"}]}, - {"demo": [{"include-group": "other"}], "other": [{"include-group": "demo"}]}, - {"demo": [{"include-group": "other", "unexpected": "value"}], "other": []}, - {"demo": [{"include-group": 123}]}, - {"demo": [123]}, - {"demo-one": [], "demo_one": []}, - ], -) -def test_security_floor_checker_rejects_invalid_dependency_group_includes( - tmp_path: Path, groups: dict[str, list[Any]] -) -> None: - result = run_security_dependency_floor_check( - tmp_path, - base_requirements=[], - head_requirements=[], - base_packages=[("unchanged", "1")], - head_packages=[("unchanged", "1")], - base_dependency_groups=groups, - head_dependency_groups=groups, - ) - assert result.returncode != 0 - assert "Invalid" in result.stderr