diff --git a/tests/unittests/test_release_dependencies.py b/tests/unittests/test_release_dependencies.py index 27d90b4d..267c93ef 100644 --- a/tests/unittests/test_release_dependencies.py +++ b/tests/unittests/test_release_dependencies.py @@ -27,6 +27,9 @@ regressions documented in the bare-install audit cannot silently re-emerge: objects while deserializing checkpoint data. * ``google-genai`` MUST exclude 2.11 and include 2.12.1, whose types module defers the optional MCP server stack instead of importing it at Agent startup. +* The ``all`` extra MUST stay the union of every extra that unlocks a runtime + feature, so that ``pip install "google-adk[all]"`` cannot silently stop + installing a feature's dependencies. """ from __future__ import annotations @@ -42,6 +45,7 @@ except ImportError: from packaging.requirements import Requirement from packaging.specifiers import SpecifierSet from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion from packaging.version import Version import pytest @@ -52,6 +56,18 @@ _UNSAFE_CHECKPOINT_RELEASES = { 'langgraph-checkpoint': (('2.1.0', '3.0.0', '4.0.0', '4.1.0'), '4.1.1'), } +# Extras that ``all`` deliberately leaves out, for the reason recorded in the +# comment above ``optional-dependencies.all`` in pyproject.toml. Every other +# extra is part of the ``all`` contract, so a new extra joins that contract +# unless it is also listed here. +_NON_RUNTIME_EXTRAS = frozenset({ + 'benchmark', + 'community', + 'dev', + 'docs', + 'test', +}) + def _find_pyproject() -> Path: """Locates pyproject.toml by walking up from this file's directory. @@ -120,6 +136,58 @@ def _requirement_specifier( return None +def _runtime_extra_requirements( + pyproject: dict, +) -> dict[str, list[tuple[str, Requirement]]]: + """Returns what the runtime extras require, keyed by distribution name. + + Each value lists the extras that ask for the distribution, paired with the + requirement that extra declares, so a failure can name the extra that ``all`` + drifted away from. + """ + contributors: dict[str, list[tuple[str, Requirement]]] = {} + for extra, entries in pyproject['project']['optional-dependencies'].items(): + if extra == 'all' or extra in _NON_RUNTIME_EXTRAS: + continue + for entry in entries: + requirement = Requirement(entry) + key = canonicalize_name(requirement.name) + contributors.setdefault(key, []).append((extra, requirement)) + return contributors + + +def _all_extra_requirements(pyproject: dict) -> dict[str, Requirement]: + """Returns the ``all`` extra's requirements, keyed by distribution name.""" + entries = pyproject['project']['optional-dependencies']['all'] + requirements = (Requirement(entry) for entry in entries) + return {canonicalize_name(req.name): req for req in requirements} + + +def _specifier_versions(specifier: SpecifierSet) -> set[Version]: + """Returns the version literals a specifier mentions. + + Wildcard clauses such as ``==1.2.*`` name no single version and are skipped. + """ + versions: set[Version] = set() + for clause in specifier: + try: + versions.add(Version(clause.version)) + except InvalidVersion: + continue + return versions + + +def _expected_marker(requirements: list[Requirement]) -> str: + """Returns the marker text the union of ``requirements`` should carry. + + An empty string means the union must be unmarked. Contributors that disagree + about a marker install the distribution between them on every environment any + of them names, so the union goes unmarked rather than under-installing. + """ + markers = {str(req.marker) if req.marker else '' for req in requirements} + return markers.pop() if len(markers) == 1 else '' + + def test_main_deps_include_packaging(pyproject: dict) -> None: """``packaging`` is imported unguarded by core ADK; it must be a main dep.""" main_deps = _requirement_names(pyproject['project']['dependencies']) @@ -181,6 +249,90 @@ def test_main_deps_require_lazy_mcp_google_genai_release( assert Version('2.12.1') in google_genai.specifier +def test_all_extra_covers_every_runtime_extra(pyproject: dict) -> None: + """``all`` names exactly the distributions the runtime extras name. + + This is the guard against the failure that motivated the union: an extra + gains a dependency, nobody mirrors it into ``all``, and users who installed + ``google-adk[all]`` hit an ImportError for a feature they believed they had. + """ + contributors = _runtime_extra_requirements(pyproject) + all_extra = _all_extra_requirements(pyproject) + + missing = sorted(set(contributors) - set(all_extra)) + assert not missing, 'The all extra is missing ' + ', '.join( + f'{name} (required by' + f' {", ".join(sorted(e for e, _ in contributors[name]))})' + for name in missing + ) + + orphaned = sorted(set(all_extra) - set(contributors)) + assert not orphaned, ( + f'The all extra requires {", ".join(orphaned)}, which no runtime extra ' + 'declares. Every entry in all belongs to the extra that owns its ' + 'feature, so either declare it there or drop it from all.' + ) + + +def test_all_extra_preserves_runtime_extra_constraints(pyproject: dict) -> None: + """``all`` asks for each distribution on the same terms its extras do. + + Installing several extras at once yields the union of their distributions + and the intersection of their version constraints, so ``all`` must request + the union of the sub-extras named, admit a version exactly when every + contributing extra admits it, and carry the environment marker its + contributors agree on. + """ + contributors = _runtime_extra_requirements(pyproject) + all_extra = _all_extra_requirements(pyproject) + problems: list[str] = [] + + for name, sources in sorted(contributors.items()): + combined = all_extra.get(name) + if combined is None: + continue # Already reported as missing by the coverage test. + extras = sorted(extra for extra, _ in sources) + requirements = [requirement for _, requirement in sources] + + wanted_extras = set().union(*(req.extras for req in requirements)) + if combined.extras != wanted_extras: + problems.append( + f'{name}: all requests sub-extras {sorted(combined.extras)}, but ' + f'{extras} together require {sorted(wanted_extras)}' + ) + + wanted_marker = _expected_marker(requirements) + actual_marker = str(combined.marker) if combined.marker else '' + if actual_marker != wanted_marker: + problems.append( + f'{name}: all is gated on {actual_marker or "nothing"}, but ' + f'{extras} require {wanted_marker or "no marker"}' + ) + + candidates = _specifier_versions(combined.specifier) + for requirement in requirements: + candidates |= _specifier_versions(requirement.specifier) + for version in sorted(candidates): + admitted_by_all = combined.specifier.contains(version, prereleases=True) + admitted_by_extras = all( + req.specifier.contains(version, prereleases=True) + for req in requirements + ) + if admitted_by_all != admitted_by_extras: + problems.append( + f'{name}: all and {extras} disagree about version {version}. all ' + f'declares {combined.specifier or "no constraint"}, against ' + + ', '.join( + f'{extra}: {req.specifier or "no constraint"}' + for extra, req in sources + ) + ) + + assert not problems, 'The all extra diverges from its extras:\n' + '\n'.join( + problems + ) + + def test_environment_simulation_config_imports_validation_error_from_pydantic() -> ( None ):