Files
google--adk-python/tests/unittests/test_release_dependencies.py
George Weale 15e090da49 test(deps): require an inclusive dependency ceiling to name an installed release
Co-authored-by: George Weale <gweale@google.com>
PiperOrigin-RevId: 966225997
2026-08-17 15:51:19 -07:00

464 lines
18 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Guard tests for the release-cut dependency contract.
These tests pin the public dependency surface so that release-blocking
regressions documented in the bare-install audit cannot silently re-emerge:
* ``packaging`` MUST be declared in main deps (used at import-time by
``utils/model_name_utils.py`` and ``cli/cli_deploy.py``; reachable from
``from google.adk import Runner`` and from ``adk --help``).
* ``ValidationError`` in ``environment_simulation_config`` MUST come from
``pydantic`` (which always installs alongside the package), NOT from the
undeclared ``pydantic_core``.
* The LangGraph extras MUST exclude the releases that reconstruct unsafe
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.
* Every ``<=`` upper bound MUST name the release the tests run against, so
that raising one cannot claim support for a release nothing installed.
"""
from __future__ import annotations
import importlib.metadata
import importlib.util
from pathlib import Path
try:
import tomllib
except ImportError:
import tomli as tomllib
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
# Releases that can reconstruct unsafe objects while deserializing checkpoint
# data, mapped to the first release of the same distribution without it.
_UNSAFE_CHECKPOINT_RELEASES = {
'langgraph': (('0.2.60', '0.4.7', '1.0.9'), '1.0.10'),
'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.
Handles layouts where pyproject.toml is at an ancestor directory as well as
layouts where it lives in a sibling build directory next to the package. The
test tree may be symlinked, so the walk avoids ``.resolve()``.
"""
start = Path(__file__).parent
for candidate in [start, *start.parents]:
direct = candidate / 'pyproject.toml'
if direct.is_file():
return direct
try:
children = sorted(p for p in candidate.iterdir() if p.is_dir())
except OSError:
continue
for child in children:
sibling = child / 'pyproject.toml'
if sibling.is_file():
return sibling
raise FileNotFoundError(
f'Could not find pyproject.toml walking up from {start}.'
)
_PYPROJECT_PATH = _find_pyproject()
@pytest.fixture(scope='module')
def pyproject() -> dict:
"""Parses the project's pyproject.toml exactly once for the module."""
with _PYPROJECT_PATH.open('rb') as fh:
return tomllib.load(fh)
def _requirement_names(requirements: list[str]) -> set[str]:
"""Returns the lowercased PEP 508 distribution names from ``requirements``.
Strips extras specifiers, version specifiers, and environment markers so the
caller can do exact-name membership checks.
"""
names: set[str] = set()
for req in requirements:
# Drop everything after a marker, version specifier, or extras block.
head = req.split(';', 1)[0].strip()
for sep in ('[', '>', '<', '=', '!', '~', ' '):
head = head.split(sep, 1)[0]
names.add(head.strip().lower())
return names
def _requirement_specifier(
requirements: list[str], distribution: str
) -> SpecifierSet | None:
"""Returns the version specifier ``requirements`` declares for a dependency.
Returns ``None`` when the distribution is not declared at all, so callers can
tell "unconstrained" apart from "absent".
"""
wanted = canonicalize_name(distribution)
for requirement in requirements:
parsed = Requirement(requirement)
if canonicalize_name(parsed.name) == wanted:
return parsed.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 _inclusive_upper_bounds(pyproject: dict) -> dict[str, Version]:
"""Returns the release each ``<=`` bound names, keyed by distribution.
Covers the main dependencies and every extra. Exclusive ``<`` bounds are
left out on purpose: they rule a release out without saying anything about
any particular release below it. Where two extras disagree, the higher bound
wins, because installing that extra on its own admits that release.
"""
groups = [pyproject['project']['dependencies']]
groups.extend(pyproject['project']['optional-dependencies'].values())
bounds: dict[str, Version] = {}
for group in groups:
for entry in group:
requirement = Requirement(entry)
name = canonicalize_name(requirement.name)
for clause in requirement.specifier:
if clause.operator != '<=':
continue
# PEP 440 allows a wildcard only with == and !=, so a <= clause always
# names a parseable release and a failure here is worth hearing about.
bound = Version(clause.version)
bounds[name] = max(bound, bounds.get(name, bound))
return bounds
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'])
assert 'packaging' in main_deps, (
'packaging must be declared in [project] dependencies because '
'src/google/adk/utils/model_name_utils.py and '
'src/google/adk/cli/cli_deploy.py import it unguarded at module top '
'level. Without this declaration, `pip install google-adk` is one '
'transitive resolver change away from breaking on `import google.adk`.'
)
@pytest.mark.parametrize('extra', ['extensions', 'test'])
@pytest.mark.parametrize('distribution', sorted(_UNSAFE_CHECKPOINT_RELEASES))
def test_langgraph_extras_exclude_unsafe_checkpoint_releases(
pyproject: dict, extra: str, distribution: str
) -> None:
"""Both LangGraph extras resolve past the unsafe-deserialization releases.
``langgraph`` does not constrain ``langgraph-checkpoint`` tightly enough to
rule the unsafe releases out on its own, so each extra must declare both.
"""
unsafe_versions, first_safe = _UNSAFE_CHECKPOINT_RELEASES[distribution]
specifier = _requirement_specifier(
pyproject['project']['optional-dependencies'][extra], distribution
)
assert specifier is not None, (
f'The {extra!r} extra must declare {distribution}; without it the '
'resolver is free to install a release that can reconstruct unsafe '
'objects from checkpoint data.'
)
admitted = [v for v in unsafe_versions if specifier.contains(v)]
assert not admitted, (
f'The {extra!r} extra admits {distribution} {admitted}, which can '
'reconstruct unsafe objects from checkpoint data. Require '
f'{distribution}>={first_safe}.'
)
assert specifier.contains(first_safe), (
f'The {extra!r} extra excludes {distribution} {first_safe}, the first '
'release without the unsafe behavior.'
)
def test_main_deps_require_lazy_mcp_google_genai_release(
pyproject: dict,
) -> None:
"""The google-genai floor preserves its lazy optional-MCP boundary."""
requirements = [
Requirement(raw) for raw in pyproject['project']['dependencies']
]
google_genai = next(
requirement
for requirement in requirements
if requirement.name == 'google-genai'
)
assert Version('2.11.0') not in google_genai.specifier
assert Version('2.12.1') in google_genai.specifier
def test_inclusive_upper_bounds_ignores_other_operators() -> None:
"""Only ``<=`` names a release as supported, and the highest one wins."""
bounds = _inclusive_upper_bounds({
'project': {
'dependencies': [
'named<=1.43',
'floored>=1.39',
'capped<2',
'excluded!=3.1',
],
'optional-dependencies': {
'low': ['spread>=1,<=1.0'],
'high': ['spread>=1,<=2.0'],
},
}
})
assert bounds == {'named': Version('1.43'), 'spread': Version('2.0')}
def test_inclusive_upper_bounds_name_an_installed_release(
pyproject: dict,
) -> None:
"""Every ``<=`` bound names the release the tests are running against.
``opentelemetry-api<=1.43`` claims release 1.43 works. ``<1.44`` claims
release 1.44 does not. The second is knowable from an upstream changelog;
the first is only knowable by running it. So an inclusive bound that names a
release the environment never installed is a claim no test stands behind,
and raising one ships that claim to users on the next release.
"""
bounds = _inclusive_upper_bounds(pyproject)
checked = 0
unexercised: list[str] = []
for distribution, bound in sorted(bounds.items()):
try:
installed = Version(importlib.metadata.version(distribution))
except importlib.metadata.PackageNotFoundError:
# Declared by an extra this environment does not install.
continue
checked += 1
if installed < bound:
unexercised.append(f'{distribution}<={bound}, running {installed}')
assert checked, (
'No declared <= bound was compared against an installed release, so '
'this test proved nothing. Either pyproject.toml stopped using '
'inclusive upper bounds, or _inclusive_upper_bounds stopped finding '
'them.'
)
assert not unexercised, (
'These bounds name a release the tests never ran: '
+ '; '.join(unexercised)
+ '. Install the named release so the suite covers what the bound '
'promises, or state what is actually known with an exclusive bound '
'(<1.44 rules out the 1.44 line without claiming 1.43 was exercised).'
)
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
):
"""The ValidationError used by the config module must come from pydantic.
pydantic-core is undeclared; importing from it directly is fragile. pydantic
re-exports ValidationError, so use that.
"""
# Use importlib to locate the source file so the test is independent of the
# on-disk package layout.
spec = importlib.util.find_spec(
'google.adk.tools.environment_simulation.environment_simulation_config'
)
assert (
spec is not None and spec.origin is not None
), 'environment_simulation_config module is not importable.'
source_path = Path(spec.origin)
source = source_path.read_text(encoding='utf-8')
assert 'from pydantic import ValidationError' in source, (
'environment_simulation_config.py must import ValidationError from '
'pydantic, not pydantic_core. pydantic_core is undeclared as a main '
'dep and pydantic re-exports the same class.'
)
assert 'from pydantic_core import ValidationError' not in source, (
'environment_simulation_config.py must not import ValidationError '
'from pydantic_core (undeclared dep).'
)
def test_injection_config_validation_raises_pydantic_validation_error() -> None:
"""Behavioral check: invalid config raises the pydantic ValidationError."""
# Local import keeps this test focused on the post-fix code path and
# surfaces ImportError clearly if the module's import block regresses.
from google.adk.tools.environment_simulation.environment_simulation_config import InjectedError
from pydantic import ValidationError
with pytest.raises(ValidationError):
# Both required fields missing — pydantic must reject the construction.
InjectedError() # type: ignore[call-arg]