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
10 changes: 10 additions & 0 deletions CHANGELOG.d/2.12.7-optional-extra-collection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Skip optional-extra suites when the sandbox did not install them

- OpenCode coverage-evidence supplies pytest and coverage but not
LineageWeave's optional backend extras. Collection no longer fails
with ``ModuleNotFoundError`` for ``asyncpg``, ``psycopg2``, ``redis``,
or ``fast_mlsirm`` when those extras are absent.
- Hosted CI still installs ``dev`` and ``backend`` extras, so every
suite continues to collect and run there.
- Login typecheck remains LineageWeave#494; this head stacks on that
repair and does not rewrite the gap baseline.
68 changes: 68 additions & 0 deletions lineageweave/optional_extra_collection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Decide which pytest files need optional extras that may be absent.

OpenCode coverage-evidence runs in a networkless sandbox that supplies
pytest and coverage but not LineageWeave's optional backend extras
(``asyncpg``, ``psycopg2``, ``redis``, ``fast_mlsirm``). Hosted CI
installs those extras and collects every suite. This helper keeps
collection from failing with ``ModuleNotFoundError`` when extras are
absent, without skipping anything when they are present.
"""

from __future__ import annotations

import importlib.util
from collections.abc import Iterable
from pathlib import Path

OPTIONAL_EXTRA_MODULES: tuple[str, ...] = (
"asyncpg",
"psycopg2",
"redis",
"fast_mlsirm",
)

_BACKEND_EXTRAS: frozenset[str] = frozenset({"asyncpg", "psycopg2", "redis"})
_HELPER_TEST_NAME = "test_optional_extra_collection.py"


def missing_optional_extra_modules(
module_names: Iterable[str] = OPTIONAL_EXTRA_MODULES,
) -> tuple[str, ...]:
"""Return optional extra names the current interpreter cannot import."""
return tuple(
name for name in module_names if importlib.util.find_spec(name) is None
)


def collection_path_requires_missing_extras(
collection_path: Path,
missing_modules: Iterable[str],
) -> bool:
"""Return whether collecting this path would import a missing extra."""
missing = tuple(missing_modules)
if not missing:
return False
if collection_path.suffix != ".py":
return False
if collection_path.name == _HELPER_TEST_NAME:
return False
posix = collection_path.as_posix()
if any(name in _BACKEND_EXTRAS for name in missing):
if posix == "backend" or posix.startswith("backend/") or "/backend/" in posix:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Two backend-path checks are dead code

pytest passes absolute paths, so posix == "backend" and posix.startswith("backend/") at optional_extra_collection.py never match; only the "/backend/" in posix substring ever fires. Harmless, but the first two conditions are unreachable.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

return True
try:
text = collection_path.read_text(encoding="utf-8")
except OSError:
return False
for name in missing:
if (
f"import {name}" in text
or f"from {name} " in text
or f"from {name}." in text
):
return True
if name in _BACKEND_EXTRAS and (
"from backend" in text or "import backend" in text
):
return True
Comment on lines +57 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Substring import matching can over-skip files

The import scan in collection_path_requires_missing_extras uses plain substring checks (optional_extra_collection.py), so commented lines, string literals, or prefix collisions like import asyncpg_pool count as a match. Only affects the missing-extras sandbox, but can silently skip tests that merely mention a module name.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

return False
19 changes: 19 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Pytest collection hooks for optional backend extras."""

from __future__ import annotations

from pathlib import Path

from lineageweave.optional_extra_collection import (
collection_path_requires_missing_extras,
missing_optional_extra_modules,
)


def pytest_ignore_collect(collection_path: Path, config: object) -> bool:
"""Skip files that import optional extras the sandbox did not install."""
del config
return collection_path_requires_missing_extras(
collection_path,
missing_optional_extra_modules(),
)
Comment on lines +13 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Backend test suite still fails collection in the sandbox

The ignore hook lives in conftest.py, so pytest_ignore_collect runs only for paths under tests/, never for the separate backend/tests testpath declared in pyproject.toml. In the sandbox, backend/tests/test_api.py still imports psycopg2 and redis at module level, so collection aborts with ModuleNotFoundError.

Prompt for agents
The pytest_ignore_collect hook is defined in tests/conftest.py, but pytest scopes collection hooks to the directory subtree of the conftest that defines them. pyproject.toml sets testpaths = ["tests", "backend/tests"], and backend/tests/test_api.py imports psycopg2 and redis at module level. Because tests/conftest.py is not an ancestor of backend/tests/, its hook is never invoked for backend/tests paths, so collecting the backend suite still fails with ModuleNotFoundError when those extras are absent — defeating the PR's goal and leaving the _BACKEND_EXTRAS branch of collection_path_requires_missing_extras unreachable for real backend paths. Move the hook to a repo-root conftest.py (or add a conftest.py under backend/tests/) so the ignore logic applies to both testpaths.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

70 changes: 70 additions & 0 deletions tests/test_optional_extra_collection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Unit tests for optional-extra pytest collection skipping."""

from __future__ import annotations

from pathlib import Path
from unittest.mock import patch

from lineageweave.optional_extra_collection import (
OPTIONAL_EXTRA_MODULES,
collection_path_requires_missing_extras,
missing_optional_extra_modules,
)


def test_missing_optional_extra_modules_returns_absent_names() -> None:
"""Names whose find_spec is None are reported; present names are not."""

def fake_find_spec(name: str) -> object | None:
if name == "asyncpg":
return None
return object()

with patch(
"lineageweave.optional_extra_collection.importlib.util.find_spec",
side_effect=fake_find_spec,
):
assert missing_optional_extra_modules(("asyncpg", "redis")) == ("asyncpg",)


def test_collection_path_does_not_skip_when_no_extras_are_missing(
tmp_path: Path,
) -> None:
"""Hosted CI with extras installed still collects every suite."""
path = tmp_path / "test_api.py"
path.write_text("import asyncpg\n", encoding="utf-8")
assert collection_path_requires_missing_extras(path, ()) is False


def test_collection_path_skips_backend_tree_when_asyncpg_is_missing(
tmp_path: Path,
) -> None:
"""Backend tests import the FastAPI app, which imports asyncpg at module level."""
backend_test = tmp_path / "backend" / "tests" / "test_api.py"
backend_test.parent.mkdir(parents=True)
backend_test.write_text("import pytest\n", encoding="utf-8")
assert collection_path_requires_missing_extras(backend_test, ("asyncpg",)) is True


def test_collection_path_skips_files_that_import_a_missing_extra(
tmp_path: Path,
) -> None:
"""A test that imports fast_mlsirm is ignored only when that extra is absent."""
path = tmp_path / "test_post_evaluation.py"
path.write_text("from fast_mlsirm import LLMJudgeResult\n", encoding="utf-8")
assert collection_path_requires_missing_extras(path, ("fast_mlsirm",)) is True
assert collection_path_requires_missing_extras(path, ("asyncpg",)) is False


def test_helper_test_module_is_never_ignored(tmp_path: Path) -> None:
"""The collection-helper tests must run in the sandbox that lacks extras."""
path = tmp_path / "test_optional_extra_collection.py"
path.write_text("import asyncpg\n", encoding="utf-8")
assert collection_path_requires_missing_extras(path, OPTIONAL_EXTRA_MODULES) is False


def test_non_python_paths_are_not_ignored(tmp_path: Path) -> None:
"""Collection directories and non-Python files stay visible to pytest."""
directory = tmp_path / "tests"
directory.mkdir()
assert collection_path_requires_missing_extras(directory, ("asyncpg",)) is False