Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion envs/textarena_env/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ dependencies = [
# "openspiel>=1.0.0",
# "smolagents>=1.22.0,<2",
"textarena>=0.6.1",
"nltk>=3.9.3",
"nltk>=3.10.3",
# For custom Gradio tab (server/gradio_ui.py) when ENABLE_WEB_INTERFACE=true
"gradio>=6.15.1",
]
Expand Down
45 changes: 43 additions & 2 deletions envs/textarena_env/server/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@

from __future__ import annotations

import os
import subprocess
import sys
import urllib.request
from typing import Any, Dict, Iterable, List, Optional
from uuid import uuid4

Expand Down Expand Up @@ -51,8 +54,46 @@ def _ensure_nltk_data() -> None:
"NLTK is required for TextArena environments. "
"Install textarena_env dependencies (including nltk)."
) from exc
nltk.download("words", quiet=True)
nltk.download("averaged_perceptron_tagger_eng", quiet=True)
packages = ("words", "averaged_perceptron_tagger_eng")
opener = urllib.request._opener
explicit_proxy = opener is not None and any(
isinstance(handler, urllib.request.ProxyHandler)
and any(scheme != "no" for scheme in handler.proxies)
for handler in opener.handlers
)
if (
nltk.__version__ == "3.10.3"
and set(urllib.request.getproxies()) == {"no"}
and not explicit_proxy
):
# NLTK #3748 mistakes NO_PROXY alone for a carrying proxy. Isolate the
# workaround so other server threads retain their proxy configuration.
# Real proxies still use NLTK's normal, security-enforcing download path.
download_env = os.environ.copy()
download_env.pop("NO_PROXY", None)
download_env.pop("no_proxy", None)
subprocess.run(
[
sys.executable,
"-m",
"nltk.downloader",
"--quiet",
"--exit-on-error",
"--dir",
nltk.downloader.Downloader().default_download_dir(),
*packages,
],
env=download_env,
check=True,
timeout=120,
)
else:
for package in packages:
nltk.download(package, quiet=True, raise_on_error=True)
# NLTK 3.10.3's CLI can exit zero after a failed download. Verify the
# resources in this process before caching successful initialization.
nltk.data.find("corpora/words")
nltk.data.find("taggers/averaged_perceptron_tagger_eng")
_NLTK_DOWNLOADED = True


Expand Down
36 changes: 23 additions & 13 deletions envs/textarena_env/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

165 changes: 165 additions & 0 deletions tests/envs/test_textarena_nltk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""NLTK startup compatibility without changing process-wide proxy settings."""

import os
import subprocess
import sys
import urllib.request
from types import SimpleNamespace
from unittest.mock import Mock

import pytest
from textarena_env.server import environment


@pytest.fixture
def nltk_download(monkeypatch, tmp_path):
nltk = SimpleNamespace(
__version__="3.10.3",
download=Mock(),
data=SimpleNamespace(find=Mock()),
downloader=SimpleNamespace(
Downloader=lambda: SimpleNamespace(
default_download_dir=lambda: str(tmp_path)
)
),
)
monkeypatch.setitem(sys.modules, "nltk", nltk)
monkeypatch.setattr(environment, "_NLTK_DOWNLOADED", False)
monkeypatch.setattr(urllib.request, "_opener", None)
monkeypatch.setattr(
urllib.request, "getproxies", urllib.request.getproxies_environment
)
for key in list(os.environ):
if key.lower().endswith("_proxy"):
monkeypatch.delenv(key)
return nltk


@pytest.mark.parametrize("proxy_key", ["NO_PROXY", "no_proxy"])
def test_exclusion_only_download_is_isolated(monkeypatch, nltk_download, proxy_key):
monkeypatch.setenv(proxy_key, "localhost,127.0.0.1")
original_env = dict(os.environ)
run = Mock()
monkeypatch.setattr(subprocess, "run", run)

environment._ensure_nltk_data()
environment._ensure_nltk_data()

run.assert_called_once()
command = run.call_args.args[0]
assert command[:3] == [sys.executable, "-m", "nltk.downloader"]
assert "--exit-on-error" in command
assert command[-2:] == ["words", "averaged_perceptron_tagger_eng"]
assert run.call_args.kwargs["check"] is True
assert run.call_args.kwargs["env"] == {
key: value for key, value in original_env.items() if key != proxy_key
}
assert dict(os.environ) == original_env
nltk_download.download.assert_not_called()


@pytest.mark.parametrize(
"proxy_key", ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"]
)
def test_carrying_proxy_keeps_nltk_security_checks(
monkeypatch, nltk_download, proxy_key
):
monkeypatch.setenv("NO_PROXY", "localhost")
monkeypatch.setenv(proxy_key, "http://proxy.example:3128")
nltk_download.download.side_effect = PermissionError("proxied fetch")
run = Mock()
monkeypatch.setattr(subprocess, "run", run)

with pytest.raises(PermissionError, match="proxied fetch"):
environment._ensure_nltk_data()

run.assert_not_called()
nltk_download.download.assert_called_once_with(
"words", quiet=True, raise_on_error=True
)
assert not environment._NLTK_DOWNLOADED


def test_explicit_proxy_is_not_bypassed(monkeypatch, nltk_download):
monkeypatch.setenv("NO_PROXY", "localhost")
monkeypatch.setattr(
urllib.request,
"_opener",
urllib.request.build_opener(
urllib.request.ProxyHandler({"https": "http://proxy.example:3128"})
),
)
nltk_download.download.side_effect = PermissionError("proxied fetch")
run = Mock()
monkeypatch.setattr(subprocess, "run", run)
with pytest.raises(PermissionError, match="proxied fetch"):
environment._ensure_nltk_data()
run.assert_not_called()


def test_failed_subprocess_does_not_cache_success(monkeypatch, nltk_download):
monkeypatch.setenv("NO_PROXY", "localhost")
run = Mock(side_effect=subprocess.CalledProcessError(1, "nltk.downloader"))
monkeypatch.setattr(subprocess, "run", run)
for _ in range(2):
with pytest.raises(subprocess.CalledProcessError):
environment._ensure_nltk_data()
assert not environment._NLTK_DOWNLOADED
assert run.call_count == 2


@pytest.mark.parametrize("version,proxies", [("3.10.3", False), ("3.10.4", True)])
def test_unaffected_download_uses_normal_path(
monkeypatch, nltk_download, version, proxies
):
nltk_download.__version__ = version
if proxies:
monkeypatch.setenv("NO_PROXY", "localhost")
run = Mock()
monkeypatch.setattr(subprocess, "run", run)
environment._ensure_nltk_data()
run.assert_not_called()
assert nltk_download.download.call_count == 2
assert environment._NLTK_DOWNLOADED


def test_exclusion_only_global_opener_is_not_a_carrying_proxy(
monkeypatch, nltk_download
):
monkeypatch.setenv("NO_PROXY", "localhost")
opener = urllib.request.build_opener(
urllib.request.ProxyHandler({"no": "localhost"})
)
monkeypatch.setattr(urllib.request, "_opener", opener)
run = Mock()
monkeypatch.setattr(subprocess, "run", run)

environment._ensure_nltk_data()

run.assert_called_once()
nltk_download.download.assert_not_called()
assert urllib.request._opener is opener


@pytest.mark.parametrize(
"missing_resource", ["corpora/words", "taggers/averaged_perceptron_tagger_eng"]
)
def test_cli_zero_exit_without_corpora_does_not_cache_success(
monkeypatch, nltk_download, missing_resource
):
monkeypatch.setenv("NO_PROXY", "localhost")
# NLTK's CLI can exit zero after reporting a download/security error.
run = Mock(return_value=subprocess.CompletedProcess([], 0))
monkeypatch.setattr(subprocess, "run", run)

def find(resource):
if resource == missing_resource:
raise LookupError("corpus missing")
return resource

nltk_download.data.find.side_effect = find
for _ in range(2):
with pytest.raises(LookupError, match="corpus missing"):
environment._ensure_nltk_data()
assert not environment._NLTK_DOWNLOADED
assert run.call_count == 2
Loading