diff --git a/pyproject.toml b/pyproject.toml index 21cd6a1f11..a080ef9db9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -148,6 +148,7 @@ optional-dependencies.eval = [ "google-cloud-aiplatform[evaluation]>=1.148", "google-cloud-texttospeech>=2.37", "jinja2>=3.1.4,<4", # For eval template rendering + "nltk!=3.10.1", # Transitive via rouge-score; 3.10.1 ships an import hook that breaks in-project venvs. "pandas>=2.2.3", "rouge-score>=0.1.2", "tabulate>=0.9", @@ -244,6 +245,7 @@ optional-dependencies.test = [ "llama-index-readers-file>=0.4", "lxml>=5.3", "mcp>=1.24,<2", + "nltk!=3.10.1", # Transitive via rouge-score; 3.10.1 ships an import hook that breaks in-project venvs. "openai>=2.20,<3", "opentelemetry-exporter-gcp-logging>=1.9.0a0,<=1.12.0a0", "opentelemetry-exporter-gcp-monitoring>=1.9.0a0,<2", diff --git a/tests/unittests/test_release_dependencies.py b/tests/unittests/test_release_dependencies.py index 27d90b4df0..6192ecc717 100644 --- a/tests/unittests/test_release_dependencies.py +++ b/tests/unittests/test_release_dependencies.py @@ -27,6 +27,9 @@ 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 extras that pull ``rouge-score`` MUST exclude ``nltk`` 3.10.1, whose + import hook breaks any in-project virtualenv and leaks ``PYTHONSAFEPATH`` + into the host process. """ from __future__ import annotations @@ -52,6 +55,15 @@ 'langgraph-checkpoint': (('2.1.0', '3.0.0', '4.0.0', '4.1.0'), '4.1.1'), } +# nltk 3.10.1 added nltk/inisec.py, whose import hook (a) refuses imports whose +# origin resolves under the CWD -- which includes site-packages for every +# in-project virtualenv -- and (b) sets PYTHONSAFEPATH=1 in the *host* process +# environment, so subprocesses inherit it. Extras that pull rouge-score (which +# depends on nltk) must resolve around it. See nltk/nltk#3730 and nltk/nltk#3732. +_NLTK_BROKEN_IMPORT_HOOK_RELEASE = '3.10.1' +_NLTK_LAST_GOOD_RELEASE = '3.10.0' +_NLTK_EXTRAS = ('eval', 'test') + def _find_pyproject() -> Path: """Locates pyproject.toml by walking up from this file's directory. @@ -164,6 +176,38 @@ def test_langgraph_extras_exclude_unsafe_checkpoint_releases( ) +@pytest.mark.parametrize('extra', _NLTK_EXTRAS) +def test_rouge_score_extras_exclude_broken_nltk_import_hook( + pyproject: dict, extra: str +) -> None: + """Every extra that pulls rouge-score resolves around nltk 3.10.1. + + rouge-score depends on nltk without an upper bound, so the exclusion has to + be declared by each extra that pulls it. + """ + specifier = _requirement_specifier( + pyproject['project']['optional-dependencies'][extra], 'nltk' + ) + + assert specifier is not None, ( + f'The {extra!r} extra pulls rouge-score, which depends on nltk without ' + 'an upper bound, so it must declare nltk itself to keep 3.10.1 out.' + ) + assert not specifier.contains(_NLTK_BROKEN_IMPORT_HOOK_RELEASE), ( + f'The {extra!r} extra admits nltk ' + f'{_NLTK_BROKEN_IMPORT_HOOK_RELEASE}, whose nltk/inisec.py import hook ' + 'blocks imports resolved from under the CWD (which includes ' + 'site-packages in any in-project virtualenv) and sets PYTHONSAFEPATH=1 ' + 'in the host process environment, changing import behavior for every ' + 'subprocess the host later spawns.' + ) + assert specifier.contains(_NLTK_LAST_GOOD_RELEASE), ( + f'The {extra!r} extra must still admit nltk ' + f'{_NLTK_LAST_GOOD_RELEASE}; excluding it too would leave the resolver ' + 'with no working nltk for rouge-score.' + ) + + def test_main_deps_require_lazy_mcp_google_genai_release( pyproject: dict, ) -> None: