diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index e8d89f175..d2a0d1cc6 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -931,14 +931,14 @@ jobs: done <<<"$configured_commands_json" else run_and_capture "Python coverage with missing-line report (${project_dir})" \ - bash -c 'cd "$1" && PYTHONPATH=. python3 -m coverage run -m pytest tests && python3 -m coverage report --show-missing' bash "$project_dir" + bash -c 'cd "$1" && PYTHONPATH="$([ -d src ] && printf src:. || printf .)" python3 -m coverage run -m pytest tests && python3 -m coverage report --show-missing' bash "$project_dir" fi done < <(tracked_python_projects_with_tests) if [ "$measured_projects" -eq 0 ]; then if has_tracked_files '*.py'; then run_and_capture "Python coverage with missing-line report" \ - bash -c 'PYTHONPATH=. python3 -m coverage run -m pytest && python3 -m coverage report --show-missing' + bash -c 'PYTHONPATH="$([ -d src ] && printf src:. || printf .)" python3 -m coverage run -m pytest && python3 -m coverage report --show-missing' elif python3 -I -c 'import pytest_cov' >/dev/null 2>&1; then run_and_capture "Python pytest-cov coverage" python3 -m pytest --cov=. --cov-report=term-missing else @@ -1057,7 +1057,7 @@ jobs: if [ -f "${project_dir}/tests/test_docstrings.py" ]; then measured_projects=1 run_and_capture "Python docstring coverage (${project_dir})" \ - bash -c 'cd "$1" && PYTHONPATH=. python3 -m pytest tests/test_docstrings.py' bash "$project_dir" + bash -c 'cd "$1" && PYTHONPATH="$([ -d src ] && printf src:. || printf .)" python3 -m pytest tests/test_docstrings.py' bash "$project_dir" fi done < <(tracked_python_projects_with_tests) [ "$measured_projects" -eq 1 ] diff --git a/scripts/ci/safe_pytest_command.py b/scripts/ci/safe_pytest_command.py index a5b1c655c..06c702ef7 100644 --- a/scripts/ci/safe_pytest_command.py +++ b/scripts/ci/safe_pytest_command.py @@ -72,12 +72,29 @@ def discover_commands(workflow_dir: pathlib.Path) -> list[list[str]]: return commands +def _project_python_path(project_dir: pathlib.Path) -> str: + """Return the ``PYTHONPATH`` for a project, honoring a ``src`` package layout. + + Repositories that keep their importable package under ``src/`` (a + ``src``-layout such as ``src/``) cannot import it with the project + root alone on the path, so an offline coverage run started from the project + root fails at collection with ``ModuleNotFoundError``. When a ``src`` + directory exists it is prepended to the path so both ``src``-layout and + flat-layout suites import correctly; otherwise the path is just the project + root, preserving the previous behavior. + """ + entries = ["."] + if (project_dir / "src").is_dir(): + entries.insert(0, "src") + return os.pathsep.join(entries) + + def execute_command(project_dir: pathlib.Path, argv: Sequence[str]) -> int: """Execute validated pytest argv directly in one project directory.""" if not _is_pytest_argv(argv) or any(_has_shell_control(arg) for arg in argv): raise ValueError("configured command is not a safe direct pytest invocation") env = os.environ.copy() - env["PYTHONPATH"] = "." + env["PYTHONPATH"] = _project_python_path(project_dir) virtualenv_bin = project_dir.resolve() / ".venv" / "bin" if virtualenv_bin.is_dir(): inherited_path = env.get("PATH") diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index f834cfe77..fb9e6d3fb 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -655,6 +655,16 @@ def test_opencode_python_coverage_never_resolves_pr_dependency_manifests(): assert "python3 -m coverage run -m pytest tests" in measure assert "python3 -m coverage report --show-missing" in measure assert "python3 -m pytest tests/test_docstrings.py" in measure + # src-layout packages (e.g. src/) must be importable from the project + # root; the coverage and docstring runners prepend src to PYTHONPATH when a + # src directory exists, falling back to the project root otherwise. + assert "PYTHONPATH=. python3 -m coverage run -m pytest tests" not in measure + assert "[ -d src ] && printf src:. || printf ." in measure + assert "PYTHONPATH=. python3 -m pytest tests/test_docstrings.py" not in measure + assert ( + 'PYTHONPATH="$([ -d src ] && printf src:. || printf .)" ' + "python3 -m pytest tests/test_docstrings.py" + ) in measure def test_opencode_coverage_prefers_preinstalled_declared_pnpm_before_npm(): diff --git a/tests/test_opencode_security_boundaries.py b/tests/test_opencode_security_boundaries.py index a194f0e68..44932447a 100644 --- a/tests/test_opencode_security_boundaries.py +++ b/tests/test_opencode_security_boundaries.py @@ -189,6 +189,22 @@ def fake_run(argv, *, cwd, env, shell, check): assert observed["env"]["PATH"].split(os.pathsep)[0] == str(virtualenv_bin) +def test_safe_pytest_executor_adds_src_layout_to_pythonpath( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A ``src``-layout project imports its package: ``src`` is prepended to PYTHONPATH.""" + observed: dict[str, object] = {} + (tmp_path / "src").mkdir() + + def fake_run(argv, *, cwd, env, shell, check): + observed.update(env=env) + return subprocess.CompletedProcess(argv, 0) + + monkeypatch.setattr(safe_pytest.subprocess, "run", fake_run) + assert safe_pytest.execute_command(tmp_path, ["pytest", "tests"]) == 0 + assert observed["env"]["PYTHONPATH"] == os.pathsep.join(("src", ".")) + + def test_configured_pytest_discovery_drops_injected_workflow_command(tmp_path: Path) -> None: """Only supported one-line pytest argv are returned from a PR-controlled workflow file.""" workflow_dir = tmp_path / ".github" / "workflows"