fix(tests): resolve the integration support package without run.py's PYTHONPATH

tests/integration/conftest.py imported the bare `integration` package. Because
tests/__init__.py and tests/integration/__init__.py both exist, pytest's default
prepend import mode puts only the repo root on sys.path, so that name resolved
only under the PYTHONPATH that tests/integration/run.py injects. Every other
invocation died at conftest import with
ModuleNotFoundError: No module named 'integration' and exit 4, including the
command test_oci_integration.py documents in its own docstring.

The imports now use the tests.integration._support path that pytest actually
resolves, matching the 120 other `from tests.` imports in the suite. run.py's
PYTHONPATH still works because it already puts the repo root on the path.

tests/code_coverage_tests/test_integration_suite_imports.py collects every file
under tests/integration with PYTHONPATH scrubbed and asserts a non-zero
collection count, so an unresolvable import fails the code-quality job instead
of only the developers who run these files by hand. CI runs the three
pre-existing files through the allowlist rather than executing them, which is
why nothing caught this.
This commit is contained in:
Yuneng Jiang 2026-09-15 21:10:08 -07:00
parent 174c1ac4ed
commit 3b0fbc426d
No known key found for this signature in database
11 changed files with 75 additions and 18 deletions

View file

@ -83,6 +83,9 @@ jobs:
- name: test_e2e_changed_gate
run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_e2e_changed_gate.py tests/code_coverage_tests/test_e2e_idp_stack.py
- name: test_integration_suite_imports
run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_integration_suite_imports.py
- name: router_code_coverage
run: uv run --no-sync python ./tests/code_coverage_tests/router_code_coverage.py

View file

@ -0,0 +1,54 @@
from __future__ import annotations
import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Final
import pytest
REPO_ROOT: Final = Path(__file__).resolve().parents[2]
INTEGRATION_ROOT: Final = REPO_ROOT / "tests" / "integration"
COLLECTED_COUNT: Final = re.compile(r"^(\d+) tests? collected", re.MULTILINE)
def _integration_test_files() -> tuple[Path, ...]:
return tuple(sorted(INTEGRATION_ROOT.rglob("test_*.py")))
def _collect_without_injected_pythonpath(target: str) -> subprocess.CompletedProcess[str]:
env: Final = {key: value for key, value in os.environ.items() if key != "PYTHONPATH"}
return subprocess.run(
(sys.executable, "-m", "pytest", target, "--collect-only", "-q", "-p", "no:cacheprovider"),
cwd=REPO_ROOT,
env=env,
capture_output=True,
text=True,
check=False,
)
def _assert_collected(result: subprocess.CompletedProcess[str], target: str) -> None:
assert result.returncode == 0, f"{target} exited {result.returncode}\n{result.stdout}\n{result.stderr}"
match: Final = COLLECTED_COUNT.search(result.stdout)
assert match is not None, f"{target} reported no collection summary\n{result.stdout}"
assert int(match.group(1)) > 0, f"{target} collected nothing, so nothing was verified\n{result.stdout}"
def test_the_integration_suite_still_has_files_to_guard() -> None:
assert _integration_test_files()
@pytest.mark.parametrize(
"target",
[str(path.relative_to(REPO_ROOT)) for path in _integration_test_files()],
)
def test_each_integration_file_collects_the_way_its_docs_document_it(target: str) -> None:
_assert_collected(_collect_without_injected_pythonpath(target), target)
def test_the_whole_integration_directory_collects_without_an_injected_pythonpath() -> None:
target: Final = "tests/integration"
_assert_collected(_collect_without_injected_pythonpath(target), target)

View file

@ -12,7 +12,7 @@ from typing import Final, TypeVar
import httpx
from pydantic import JsonValue, TypeAdapter
from integration._support.database import read_rows
from tests.integration._support.database import read_rows
JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
T = TypeVar("T")

View file

@ -6,7 +6,7 @@ from contextlib import contextmanager
import httpx
from hypothesis import Phase, settings
from integration._support.client import Gateway
from tests.integration._support.client import Gateway
LIFECYCLE_SETTINGS: Final = settings(
max_examples=20,

View file

@ -8,9 +8,9 @@ import pytest
from hypothesis import strategies as st
from hypothesis.stateful import RuleBasedStateMachine, invariant, rule, run_state_machine_as_test
from integration._support.client import Gateway, eventually, object_value
from integration._support.database import read_rows
from integration._support.generation import LIFECYCLE_SETTINGS, bounded_http_requests
from tests.integration._support.client import Gateway, eventually, object_value
from tests.integration._support.database import read_rows
from tests.integration._support.generation import LIFECYCLE_SETTINGS, bounded_http_requests
def assert_serving(gateway: Gateway, model: str, key: str, status: int, error_type: str = "auth_error") -> None:

View file

@ -4,8 +4,8 @@ from typing import Final
import httpx
import pytest
from integration._support.client import Gateway, object_value, string_value
from integration._support.database import read_rows
from tests.integration._support.client import Gateway, object_value, string_value
from tests.integration._support.database import read_rows
def model_identity(gateway: Gateway, alias: str) -> str:

View file

@ -11,9 +11,9 @@ import pytest
import httpx
from redis import Redis
from integration._support.client import Gateway, eventually, gateway_from_environment
from integration._support.manifest import OWNED_DIRECTORIES, contracts
from integration._support.generation import LIFECYCLE_SETTINGS
from tests.integration._support.client import Gateway, eventually, gateway_from_environment
from tests.integration._support.manifest import OWNED_DIRECTORIES, contracts
from tests.integration._support.generation import LIFECYCLE_SETTINGS
COLLECTED: Final = pytest.StashKey[tuple[str, ...]]()
REPORTS: Final = pytest.StashKey[list[pytest.TestReport]]()

View file

@ -3,8 +3,8 @@ from hashlib import sha256
import pytest
from integration._support.client import Gateway, object_value
from integration._support.database import read_rows
from tests.integration._support.client import Gateway, object_value
from tests.integration._support.database import read_rows
@pytest.mark.covers("mgmt.key.update.preserves_independent_fields")

View file

@ -7,9 +7,9 @@ from hypothesis import strategies as st
from hypothesis.stateful import RuleBasedStateMachine, invariant, rule, run_state_machine_as_test
from pydantic import JsonValue
from integration._support.client import Gateway, object_value
from integration._support.database import read_rows
from integration._support.generation import LIFECYCLE_SETTINGS, bounded_http_requests
from tests.integration._support.client import Gateway, object_value
from tests.integration._support.database import read_rows
from tests.integration._support.generation import LIFECYCLE_SETTINGS, bounded_http_requests
@pytest.mark.covers("mgmt.key.update.generated_sequences_preserve_state")

View file

@ -6,8 +6,8 @@ import uuid
import pytest
import yaml
from integration._support.client import Gateway, eventually, object_value, string_value
from integration._support.database import read_rows
from tests.integration._support.client import Gateway, eventually, object_value, string_value
from tests.integration._support.database import read_rows
@pytest.mark.covers("quota_management.spend_tracking.custom_price.matches_input_rates")

View file

@ -3,7 +3,7 @@ from typing import Final
import httpx
import pytest
from integration._support.client import Gateway, JSON_OBJECT, object_value
from tests.integration._support.client import Gateway, JSON_OBJECT, object_value
@pytest.mark.covers("other.provider_wire.internal_parameters_filtered")