From 17f5435a8be1ba6e56f2ded3943aab2f471e1883 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Thu, 2 Jul 2026 14:39:40 +0200 Subject: [PATCH] tests: eliminate NLTK stopwords download race under pytest-xdist Query classes that take a string stopwords value ("english" by default) lazily download the NLTK stopwords corpus on first use. Under pytest-xdist several workers can hit the missing corpus at once and call nltk.download() concurrently, corrupting the shared nltk_data directory and producing intermittent "stopwords/english not found" failures. This only surfaced on Python 3.14, where sentence-transformers is skipped and the corpus is no longer warmed serially before the parallel tests run. Two complementary changes: - Add a session-scoped autouse fixture that pre-downloads the corpus once, serialized across xdist workers with a file lock, so exactly one process performs the download. Adds filelock as a dev dependency. - Decouple the retrieval-only hybrid tests in test_aggregation.py from NLTK by passing stopwords=None. These use optional (~@field) text matching, so result counts are driven by the vector/filter and the assertions are unaffected. The tests that genuinely exercise stopword behavior (test_empty_query_string, test_hybrid_query_stopwords) are left intact and covered by the pre-download fixture, as are the count-sensitive TextQuery tests in test_query.py. Co-Authored-By: Claude Opus 4.8 --- pyproject.toml | 1 + tests/conftest.py | 37 +++++++++++++++++++++++++++ tests/integration/test_aggregation.py | 11 ++++++++ uv.lock | 6 +++-- 4 files changed, 53 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eea35aed..c7dc6331 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,6 +105,7 @@ dev = [ "types-pyyaml", "types-pyopenssl", "testcontainers>=4.3.1,<5", + "filelock>=3.12,<4", "cryptography>=44.0.1", "codespell>=2.4.1,<3", "langcache>=0.9.0", diff --git a/tests/conftest.py b/tests/conftest.py index c3ac61ba..bb010083 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -53,6 +53,43 @@ def set_tokenizers_parallelism(): os.environ["TOKENIZERS_PARALLELISM"] = "false" +@pytest.fixture(scope="session", autouse=True) +def ensure_nltk_stopwords(tmp_path_factory, worker_id): + """Pre-download the NLTK ``stopwords`` corpus once, before tests run. + + Query classes that take a string ``stopwords`` value ("english" by + default) lazily download the corpus on first use. Under pytest-xdist + several workers can hit the missing corpus simultaneously and call + ``nltk.download()`` concurrently, corrupting the shared ``nltk_data`` + directory and producing intermittent "stopwords/english not found" + failures. Fetch it here once, serializing across workers with a file lock + so exactly one process performs the download. + """ + try: + import nltk + except ImportError: + return + + def _ensure() -> None: + try: + nltk.data.find("corpora/stopwords") + except LookupError: + nltk.download("stopwords", quiet=True) + + # Not running under xdist: download in-process. + if worker_id == "master": + _ensure() + return + + # Under xdist: the first worker to grab the lock downloads; the others + # wait, then find the corpus already present. + from filelock import FileLock + + root_tmp = tmp_path_factory.getbasetemp().parent + with FileLock(str(root_tmp / "nltk_stopwords.lock")): + _ensure() + + @pytest.fixture(scope="session", autouse=True) def redis_container(worker_id): """ diff --git a/tests/integration/test_aggregation.py b/tests/integration/test_aggregation.py index 02f051ec..2c899819 100644 --- a/tests/integration/test_aggregation.py +++ b/tests/integration/test_aggregation.py @@ -96,6 +96,7 @@ def test_hybrid_query(index): vector=vector, vector_field_name=vector_field, return_fields=return_fields, + stopwords=None, ) results = index.query(hybrid_query) @@ -122,6 +123,7 @@ def test_hybrid_query(index): vector=vector, vector_field_name=vector_field, num_results=3, + stopwords=None, ) results = index.query(hybrid_query) @@ -177,6 +179,7 @@ def test_hybrid_query_with_filter(index): vector_field_name=vector_field, filter_expression=filter_expression, return_fields=return_fields, + stopwords=None, ) results = index.query(hybrid_query) @@ -203,6 +206,7 @@ def test_hybrid_query_with_geo_filter(index): vector_field_name=vector_field, filter_expression=filter_expression, return_fields=return_fields, + stopwords=None, ) results = index.query(hybrid_query) @@ -226,6 +230,7 @@ def test_hybrid_query_alpha(index, alpha): vector=vector, vector_field_name=vector_field, alpha=alpha, + stopwords=None, ) results = index.query(hybrid_query) @@ -291,6 +296,7 @@ def test_hybrid_query_with_text_filter(index): alpha=0.5, filter_expression=filter_expression, return_fields=["job", "description"], + stopwords=None, ) results = index.query(hybrid_query) @@ -309,6 +315,7 @@ def test_hybrid_query_with_text_filter(index): alpha=0.5, filter_expression=filter_expression, return_fields=["description"], + stopwords=None, ) results = index.query(hybrid_query) @@ -339,6 +346,7 @@ def test_hybrid_query_word_weights(index, scorer): return_fields=return_fields, text_scorer=scorer, text_weights=weights, + stopwords=None, ) weighted_results = index.query(weighted_query) @@ -353,6 +361,7 @@ def test_hybrid_query_word_weights(index, scorer): return_fields=return_fields, text_scorer=scorer, text_weights={}, + stopwords=None, ) unweighted_results = index.query(unweighted_query) @@ -372,6 +381,7 @@ def test_hybrid_query_word_weights(index, scorer): return_fields=return_fields, text_scorer=scorer, text_weights=weights, + stopwords=None, ) weighted_results = index.query(weighted_query) @@ -386,6 +396,7 @@ def test_hybrid_query_word_weights(index, scorer): return_fields=return_fields, text_scorer=scorer, text_weights=None, + stopwords=None, ) new_query.set_text_weights(weights) diff --git a/uv.lock b/uv.lock index 00db5f0f..cc8a324c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10, <3.15" resolution-markers = [ "python_full_version >= '3.14'", @@ -4869,7 +4869,7 @@ wheels = [ [[package]] name = "redisvl" -version = "0.20.0" +version = "0.22.0" source = { editable = "." } dependencies = [ { name = "jsonpath-ng" }, @@ -4950,6 +4950,7 @@ dev = [ { name = "black" }, { name = "codespell" }, { name = "cryptography" }, + { name = "filelock" }, { name = "isort" }, { name = "langcache" }, { name = "mypy" }, @@ -5024,6 +5025,7 @@ dev = [ { name = "black", specifier = ">=25.1.0,<26" }, { name = "codespell", specifier = ">=2.4.1,<3" }, { name = "cryptography", specifier = ">=44.0.1" }, + { name = "filelock", specifier = ">=3.12,<4" }, { name = "isort", specifier = ">=5.6.4,<6" }, { name = "langcache", specifier = ">=0.9.0" }, { name = "mypy", specifier = ">=1.11.0,<2" },