tests: eliminate NLTK stopwords download race under pytest-xdist#644
Merged
Conversation
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 <noreply@anthropic.com>
🛡️ Jit Security Scan Results✅ No security findings were detected in this PR
Security scan by Jit
|
vishal-bala
marked this pull request as ready for review
July 2, 2026 12:54
Merged
rbs333
added a commit
that referenced
this pull request
Jul 2, 2026
# Drop NLTK dependency; serve stopwords statically ## Why RedisVL only used NLTK for one thing: default stopword lists in full-text query building. Pulling in the whole `nltk` package for that has cost us repeatedly — packaging/support friction and a recurring source of test flakiness (parallel pytest-xdist workers racing on `nltk.download()`, most recently patched in #644). The stopword lists are small, static, and change rarely. This PR bundles them directly and removes `nltk` entirely. ## What changed - **New static data** — `redisvl/utils/stopwords.json` (33 languages, ~11k words, ~120KB), extracted once from NLTK's `stopwords` corpus so the lists are byte-identical to what NLTK returned. **No behavior change** for any supported language. - **New loader** — `redisvl/utils/stopwords.py` exposes `get_stopwords(language)`, which lazily reads and caches the JSON via `importlib.resources`. - **Rewired consumers** — `full_text_query_helper.py` and `query.py` now call `get_stopwords()` instead of lazily importing NLTK and downloading the corpus. The download/race/retry blocks are gone. - **Removed vestigial imports** — `aggregate.py` had unused `nltk` lazy-imports (it already routes through the helper); deleted. - **Dropped the dependency** — removed the `nltk` extra and its entry in the `all` extra from `pyproject.toml`. - **Simplified tests** — deleted the `ensure_nltk_stopwords` session fixture from `tests/conftest.py`. The xdist download race it guarded against no longer exists: there is no download. - **Spellcheck** — added `stopwords.json` to codespell's `--skip` list (it's a multi-language word-list data file, not prose). ## Backward compatibility The public API is unchanged. All existing usages still work: - `stopwords="english"` (default), `"german"`, `"french"`, etc. — any of the 33 bundled languages - `stopwords={"custom", "set"}` / list / tuple - `stopwords=None` (no filtering) Error behavior is preserved: an unknown language string raises `ValueError` (now with the list of available languages), and a non-string/non-collection raises `TypeError`. The only user-visible change: `pip install redisvl[nltk]` no longer resolves (the extra was removed). Worth a changelog note. ## Verification - All stopword / query / aggregation / hybrid unit tests pass (945 passed); `german` still yields `der`/`die`/`und`, invalid language → `ValueError`, invalid type → `TypeError`. - Confirmed `stopwords.json` ships in the built wheel (`redisvl/utils/stopwords.json` present in `redisvl-*.whl`). - pre-commit (format, isort, mypy, codespell) passes. ## Downstream follow-up (separate, not in this PR) `redis-ai-resources` has two notebooks (`python-recipes/vector-search/01_redisvl.ipynb`, `02_hybrid_search.ipynb`) that carry `nltk` in their `%pip install` lines and one stale markdown reference. Nothing breaks (they never `import nltk`), but the `nltk` install is now dead weight. That cleanup should land **after** this release and bump the notebooks' `redisvl` floor to the version that bundles stopwords. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches default full-text tokenization for all string `stopwords` language codes; lists are meant to match NLTK but any drift or packaging omission of `stopwords.json` would change search behavior. Removing the `nltk` extra is a breaking install change for users who relied on it. > > **Overview** > **Removes the `nltk` optional dependency** and ships default full-text stopword lists as bundled `redisvl/utils/stopwords.json` (~33 languages), loaded via new `get_stopwords()` in `redisvl/utils/stopwords.py`. > > **Full-text query paths** (`query.py`, `full_text_query_helper.py`) now call `get_stopwords()` instead of lazy NLTK imports and runtime `nltk.download()`; unused NLTK imports in `aggregate.py` are removed. > > **Packaging and CI:** `nltk` is dropped from `pyproject.toml` extras/`all` and `uv.lock`; codespell skips `stopwords.json`. The pytest session fixture that pre-downloaded NLTK stopwords for xdist is deleted from `tests/conftest.py`. > > Public stopword API behavior for supported languages and custom sets is intended to stay the same; `pip install redisvl[nltk]` no longer applies. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 23a8c33. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
🚀 PR was released in |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
TextQuery,HybridQuery, andAggregateHybridQuerydefault tostopwords="english"and lazily download the NLTK corpus the first time they need it. Under pytest-xdist, several workers can hit the missing corpus at the same moment and callnltk.download()concurrently — their unzips clobber each other in the sharednltk_datadirectory, and a worker then reads a half-written corpus. It surfaces only on Python 3.14 because theresentence-transformersis skipped, so nothing warms the corpus serially before the parallel tests start; on 3.10–3.13 the HuggingFace path fetches it first and the race never opens. It's intermittent (only some 3.14 jobs, acrossredis:8.2andredis:latestalike), which is the giveaway that Redis has nothing to do with it.Fix
Two complementary changes, following the pattern the unit tests already use (they pass
stopwords=Noneto avoid NLTK entirely):Pre-download the corpus once, before the workers race for it. A new session-scoped autouse fixture fetches
stopwordsa single time, serialized across xdist workers with a file lock, so exactly one process does the download and the rest find it already present. This covers every test that legitimately needs english stopwords — including the ones that assert on stopword filtering (test_empty_query_string,test_hybrid_query_stopwords) and the count-sensitiveTextQuerytests intest_query.py, whose result counts depend on filtering and so must keep real stopwords. Addsfilelock(already present transitively) as an explicit dev dependency.Decouple the tests that don't actually need stopwords. The hybrid tests in
test_aggregation.pymatch text with an optional~@fieldclause, so the result set is driven by the vector and filters, not the text tokens — dropping stopwords there changes nothing about the assertions. Passingstopwords=Noneon those constructions removes NLTK from that file except the two tests that specifically exercise the feature, shrinking the surface that can ever race.Verification
Ran the affected tests under
-n 8with the stopwords corpus deleted beforehand to simulate a cold runner. The fixture downloads once, no worker sees a partial corpus, and all tests pass — including the count-sensitiveTextQuerytests, confirming english-stopword filtering is still intact.Independent of the
redis:latestWORKERS fix (#641); this addresses a separate, pre-existing flake.🤖 Generated with Claude Code
Note
Low Risk
Test-only and dev-dependency changes; no production library behavior is modified.
Overview
Fixes intermittent CI failures when parallel pytest workers concurrently download the NLTK stopwords corpus into a shared
nltk_datadirectory.A new session-scoped autouse fixture
ensure_nltk_stopwordsinconftest.pyensures the corpus exists once before tests run; under pytest-xdist, afilelockserializes the download so only one worker fetches it.filelockis added as an explicit dev dependency inpyproject.toml/uv.lock.Hybrid aggregation integration tests that do not assert on stopword behavior now pass
stopwords=NoneonAggregateHybridQueryso those cases skip NLTK entirely; tests that exercise stopword filtering keep default or explicit stopword lists.Reviewed by Cursor Bugbot for commit 17f5435. Bugbot is set up for automated code reviews on this repo. Configure here.