From 757b137df150ced431d00c1d1484e3cc4db59fdd Mon Sep 17 00:00:00 2001 From: goduni <37146584+goduni@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:16:57 +0000 Subject: [PATCH] fix: resolve ruff and mypy from the generator's own environment Both tools are invoked at runtime, but they were looked up with shutil.which(), which searches PATH. That is the wrong place: uv tool install and pipx expose only the console scripts a distribution declares, so the copies installed alongside the generator are not on PATH and an unrelated system install wins instead. The bug reproduced in this very repo, where PATH resolved to ruff 0.15.10 / mypy 1.20.0 while the pinned environment held 0.15.15 / 2.1.0 -- generated code was being formatted and checked by versions nobody selected. Add tooling.py, which searches the environment the generator was installed into before anything ambient. Its first candidate is derived from this module's own location, because ruff arrived through the same install; sysconfig's default scheme cannot stand in for it, since under `pip install --user` that names the system bin while the generator and its ruff live under the user scheme, letting a too-old system ruff win. The remaining candidates are the active environment, the interpreter's own directory, the user scheme and the base prefix, with PATH last. ruff is resolved to a binary path rather than run as `python -m ruff`, which measured 4.4x slower per call (12.7ms -> 56.5ms) and is invoked twice per generated file. mypy runs as `python -m mypy`, a single call where the interpreter also determines what it resolves imports against. Promote mypy from the `check` extra to a core dependency, since --check is documented as a headline feature and the extra left it absent by default. The extra stays, now empty, so existing [check] installs keep resolving. --check gains --python-executable pointing at an activated virtualenv when that differs from the generator's own environment. Without it, moving mypy off PATH would have regressed standalone installs: the generator's environment has ruff and mypy but not unihttp or the serializer, so every generated import would fail to resolve. Environment roots are compared rather than interpreters, because venvs built from one base python share a bin/python symlink target and would otherwise look identical. --- README.md | 22 ++- pyproject.toml | 12 +- src/unihttp_openapi_generator/pipeline.py | 43 +++-- src/unihttp_openapi_generator/postprocess.py | 16 +- src/unihttp_openapi_generator/tooling.py | 124 +++++++++++++ tests/conftest.py | 11 ++ tests/test_pipeline_cli_extra.py | 27 ++- tests/test_postprocess.py | 24 ++- tests/test_tooling.py | 182 +++++++++++++++++++ uv.lock | 8 +- 10 files changed, 411 insertions(+), 58 deletions(-) create mode 100644 src/unihttp_openapi_generator/tooling.py create mode 100644 tests/test_tooling.py diff --git a/README.md b/README.md index 94702c0..dcda0d5 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,7 @@ unihttp-openapi-generator generate SPEC [options] | `--optional` | `none` · `omitted` (`none`) — `omitted` distinguishes absent from null (adaptix) | | `--strip-prefix` | `auto` or a dotted prefix to drop from schema names (e.g. `io.k8s.api.core.v1.Pod` → `CoreV1Pod`) | | `--inheritance` | off by default — render `allOf: [$ref]` as a base class instead of merging its fields in | -| `--check` | run `ruff` and `mypy --strict` on the output | +| `--check` | run `ruff` and `mypy --strict` on the output ([details](#checking-the-output--check)) | | `--config` | TOML config file | ### Config file @@ -429,6 +429,26 @@ What to do with `allOf: [{$ref: Base}, ...]`. file uploads, typed responses, and `deprecated`. - Security: apiKey, http bearer/basic, oauth2, openIdConnect. +## Checking the output — `--check` + +`--check` runs `ruff check` and `mypy --strict` over the generated package. + +Both tools are ordinary dependencies of the generator, so installing it installs them — +there is nothing extra to add. They are also resolved from the generator's *own* +environment rather than from `PATH`, so an unrelated `ruff` or `mypy` installed +system-wide can never take over and lint the output by different rules. + +One thing to know if you installed the generator standalone (`uv tool install`, `pipx`): +`mypy --strict` has to resolve the generated code's imports — `unihttp` and your chosen +serializer — and a standalone install has neither. Activate the project virtualenv you +intend to install the client into before running with `--check`, and the generator points +mypy at it. Without an activated virtualenv, `--check` from a standalone install reports +`import-not-found`; install the generator into the project environment instead: + +```bash +uv add --dev unihttp-openapi-generator +``` + ## Limitations - Response headers are not exposed; methods return the response body. diff --git a/pyproject.toml b/pyproject.toml index 124879f..8f854f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,14 +44,18 @@ dependencies = [ "referencing>=0.35.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", - # ruff is invoked at runtime to import-sort and format every generated file. + # Both are invoked at runtime, so they are hard requirements rather than extras: + # ruff import-sorts and formats every generated file, and `--check` runs both over + # the result. The generator resolves them from its own environment (see + # `tooling.py`), never from PATH, so a co-installed copy must always exist. "ruff>=0.6.0", + "mypy>=1.10.0", ] [project.optional-dependencies] -# `--check` runs ruff + mypy --strict on the generated package. ruff is already a -# core dependency, so this extra only needs to add mypy. -check = ["mypy>=1.10.0"] +# Retained so existing `unihttp-openapi-generator[check]` installs keep resolving; +# mypy moved into the core dependencies, so this adds nothing. +check = [] [project.scripts] unihttp-openapi-generator = "unihttp_openapi_generator.cli:app" diff --git a/src/unihttp_openapi_generator/pipeline.py b/src/unihttp_openapi_generator/pipeline.py index 1173c1d..b6a7656 100644 --- a/src/unihttp_openapi_generator/pipeline.py +++ b/src/unihttp_openapi_generator/pipeline.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import shutil import subprocess from pathlib import Path @@ -12,6 +11,11 @@ from unihttp_openapi_generator.ir.builder import build_ir from unihttp_openapi_generator.loader import load_spec from unihttp_openapi_generator.refs import RefResolver +from unihttp_openapi_generator.tooling import ( + mypy_command, + ruff_executable, + target_python_executable, +) logger = logging.getLogger("unihttp_openapi_generator") @@ -20,31 +24,36 @@ class CheckError(Exception): """Raised when ``--check`` finds problems in the generated package.""" -def _run_check(tool: str, args: list[str], package_dir: Path) -> None: - found = shutil.which(tool) - if found is None: - raise CheckError(f"{tool} executable not found on PATH (required by --check)") - result = subprocess.run([found, *args, str(package_dir)], capture_output=True, text=True) +def _run_check(tool: str, command: list[str], package_dir: Path) -> None: + result = subprocess.run([*command, str(package_dir)], capture_output=True, text=True) if result.returncode != 0: raise CheckError(f"{tool} check failed for {package_dir}:\n{result.stdout}{result.stderr}") logger.info("%s check passed for %s", tool, package_dir) +def _mypy_args() -> list[str]: + args = [ + "--strict", + "--disable-error-code", + "no-untyped-call", + "--explicit-package-bases", + ] + # The generated code imports unihttp and the chosen serializer. Those resolve from + # the environment mypy runs in, which is the generator's -- and when the generator + # was installed standalone, that environment has neither. An activated virtualenv + # is the better answer, so hand it to mypy explicitly. + target = target_python_executable() + if target is not None: + args += ["--python-executable", target] + return args + + def _check_package(package_dir: Path) -> None: # Generated packages ship without a ``[tool.ruff]`` table and are meant to lint # under ruff's defaults; ``--isolated`` ignores any ambient config that ruff # would otherwise discover from the cwd/parent dirs. - _run_check("ruff", ["check", "--isolated"], package_dir) - _run_check( - "mypy", - [ - "--strict", - "--disable-error-code", - "no-untyped-call", - "--explicit-package-bases", - ], - package_dir, - ) + _run_check("ruff", [ruff_executable(), "check", "--isolated"], package_dir) + _run_check("mypy", [*mypy_command(), *_mypy_args()], package_dir) def run_generation(spec_source: str, config: GeneratorConfig) -> Path: diff --git a/src/unihttp_openapi_generator/postprocess.py b/src/unihttp_openapi_generator/postprocess.py index 7ee6299..ff5732c 100644 --- a/src/unihttp_openapi_generator/postprocess.py +++ b/src/unihttp_openapi_generator/postprocess.py @@ -2,25 +2,19 @@ from __future__ import annotations -import shutil import subprocess from pathlib import Path +from unihttp_openapi_generator.tooling import ruff_executable + class PostProcessError(Exception): """Raised when an external formatter/checker fails.""" -def _ruff() -> str: - found = shutil.which("ruff") - if found is None: - raise PostProcessError("ruff executable not found on PATH") - return found - - def _run(args: list[str], source: str, *, filename: str) -> str: result = subprocess.run( - [_ruff(), *args, "--stdin-filename", filename, "-"], + [ruff_executable(), *args, "--stdin-filename", filename, "-"], input=source, capture_output=True, text=True, @@ -46,14 +40,14 @@ def format_path(path: Path) -> None: """Run ruff import-sorting and formatting over files on disk (project-aware).""" target = str(path) fix = subprocess.run( - [_ruff(), "check", "--select", "I,F401", "--fix", "--quiet", target], + [ruff_executable(), "check", "--select", "I,F401", "--fix", "--quiet", target], capture_output=True, text=True, ) if fix.returncode not in (0, 1): # 1 == remaining lint findings, acceptable here raise PostProcessError(f"ruff check failed for {target}:\n{fix.stderr or fix.stdout}") fmt = subprocess.run( - [_ruff(), "format", "--quiet", target], + [ruff_executable(), "format", "--quiet", target], capture_output=True, text=True, ) diff --git a/src/unihttp_openapi_generator/tooling.py b/src/unihttp_openapi_generator/tooling.py new file mode 100644 index 0000000..3512cd6 --- /dev/null +++ b/src/unihttp_openapi_generator/tooling.py @@ -0,0 +1,124 @@ +"""Locate the ruff/mypy that ship with the generator, not whatever is on ``PATH``. + +``ruff`` and ``mypy`` are declared dependencies, so installing the generator installs +them into the generator's own environment. That environment's script directory is not +necessarily on ``PATH``: ``uv tool install`` and ``pipx`` expose only the console +scripts a distribution declares, so ``shutil.which("ruff")`` reaches past the pinned +copy and finds an unrelated system install -- a different version, formatting the +generated code by different rules -- or nothing at all. + +Every lookup here therefore starts from the interpreter running the generator and only +falls back to ``PATH`` as a last resort. +""" + +from __future__ import annotations + +import functools +import importlib.util +import os +import shutil +import sys +import sysconfig + + +class ToolNotFoundError(Exception): + """Raised when a tool the generator depends on cannot be located.""" + + +def _install_scripts_dir() -> str | None: + """Scripts directory of the environment *this module* is installed into. + + Searched first, because ruff arrived through the same install as the generator. + ``sysconfig``'s default scheme cannot stand in for this: under ``pip install + --user`` it names the system ``bin`` while the generator and its ruff live under + the user scheme, so a too-old system ruff would win. Returns ``None`` from a source + checkout, where the module does not sit under ``site-packages`` at all. + """ + site_packages = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + if os.path.basename(site_packages) not in ("site-packages", "dist-packages"): + return None + if os.name == "nt": # /Lib/site-packages -> /Scripts + return os.path.join(os.path.dirname(os.path.dirname(site_packages)), "Scripts") + # /lib/python3.X/site-packages -> /bin + prefix = os.path.dirname(os.path.dirname(os.path.dirname(site_packages))) + return os.path.join(prefix, "bin") + + +def _script_dirs() -> list[str]: + """Script directories to search, own-environment first, ambient last.""" + candidates = [ + # Where the generator itself was installed -- ruff came from the same install. + _install_scripts_dir(), + # The active environment (inside a venv, its ``bin``/``Scripts``). + sysconfig.get_path("scripts"), + # Alongside the interpreter itself, for layouts sysconfig describes oddly. + os.path.dirname(sys.executable) if sys.executable else "", + # ``pip install --user`` puts scripts in the user scheme, e.g. ``~/.local/bin``. + sysconfig.get_path("scripts", scheme=sysconfig.get_preferred_scheme("user")), + # The base interpreter, for a venv created with ``--system-site-packages``. + sysconfig.get_path("scripts", vars={"base": sys.base_prefix}), + ] + unique: list[str] = [] + for directory in candidates: + if directory and directory not in unique: + unique.append(directory) + return unique + + +def _find_executable(name: str) -> str | None: + exe = name + (sysconfig.get_config_var("EXE") or "") + for directory in _script_dirs(): + candidate = os.path.join(directory, exe) + if os.path.isfile(candidate): + return candidate + return shutil.which(name) + + +@functools.cache +def ruff_executable() -> str: + """Absolute path to ruff, preferring the copy installed with the generator.""" + found = _find_executable("ruff") + if found is None: + raise ToolNotFoundError( + "ruff was not found. It is a dependency of unihttp-openapi-generator, so " + "reinstalling the generator restores it: " + "pip install --force-reinstall unihttp-openapi-generator" + ) + return found + + +def mypy_command() -> list[str]: + """Command prefix that runs the mypy installed with the generator. + + ``python -m mypy`` rather than a resolved script path, because the interpreter is + also what mypy resolves the checked package's imports against by default. + """ + if importlib.util.find_spec("mypy") is None: + raise ToolNotFoundError( + "mypy was not found. It is a dependency of unihttp-openapi-generator, so " + "reinstalling the generator restores it: " + "pip install --force-reinstall unihttp-openapi-generator" + ) + return [sys.executable, "-m", "mypy"] + + +def target_python_executable() -> str | None: + """The interpreter whose site-packages the *generated* code should resolve against. + + ``--check`` type-checks generated code that imports unihttp and the chosen + serializer. Those live in the user's project environment, which is a different + environment from the generator's whenever the generator was installed as a + standalone tool. When an activated virtualenv says so, point mypy at it; otherwise + the interpreter running the generator is already the right answer. + """ + venv = os.environ.get("VIRTUAL_ENV") + if not venv: + return None + # Compare environment roots, not the interpreters: two venvs built from the same + # base python have ``bin/python`` symlinks that resolve to one shared binary, so + # ``samefile`` would call distinct environments identical. + if os.path.normpath(venv) == os.path.normpath(sys.prefix): + return None + bindir = "Scripts" if os.name == "nt" else "bin" + exe = os.path.join(venv, bindir, "python" + (sysconfig.get_config_var("EXE") or "")) + return exe if os.path.isfile(exe) else None diff --git a/tests/conftest.py b/tests/conftest.py index 9feb882..810e1f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,10 +2,21 @@ from __future__ import annotations +from collections.abc import Iterator from typing import Any import pytest +from unihttp_openapi_generator.tooling import ruff_executable + + +@pytest.fixture(autouse=True) +def _reset_tool_lookup_cache() -> Iterator[None]: + """Keep a monkeypatched ruff lookup in one test from leaking into the next.""" + ruff_executable.cache_clear() + yield + ruff_executable.cache_clear() + @pytest.fixture def sample_spec() -> dict[str, Any]: diff --git a/tests/test_pipeline_cli_extra.py b/tests/test_pipeline_cli_extra.py index e16683c..f50609b 100644 --- a/tests/test_pipeline_cli_extra.py +++ b/tests/test_pipeline_cli_extra.py @@ -2,7 +2,7 @@ from __future__ import annotations -import shutil +import sys from pathlib import Path import pytest @@ -10,15 +10,30 @@ from unihttp_openapi_generator import __version__ from unihttp_openapi_generator.cli import app -from unihttp_openapi_generator.pipeline import CheckError, _run_check +from unihttp_openapi_generator.pipeline import CheckError, _mypy_args, _run_check runner = CliRunner() -def test_run_check_tool_missing(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(shutil, "which", lambda name: None) - with pytest.raises(CheckError, match="not found on PATH"): - _run_check("ruff", ["check"], Path("/nonexistent")) +def test_run_check_reports_the_tool_output(tmp_path: Path) -> None: + script = "print('boom'); raise SystemExit(1)" + with pytest.raises(CheckError, match="boom"): + _run_check("ruff", [sys.executable, "-c", script], tmp_path) + + +def test_mypy_args_omit_python_executable_without_a_separate_venv( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + assert "--python-executable" not in _mypy_args() + + +def test_mypy_args_target_a_separate_activated_venv(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "unihttp_openapi_generator.pipeline.target_python_executable", + lambda: "/proj/.venv/bin/python", + ) + assert _mypy_args()[-2:] == ["--python-executable", "/proj/.venv/bin/python"] def test_version_flag() -> None: diff --git a/tests/test_postprocess.py b/tests/test_postprocess.py index 56baa4b..b53a72a 100644 --- a/tests/test_postprocess.py +++ b/tests/test_postprocess.py @@ -2,12 +2,12 @@ from __future__ import annotations -import shutil import subprocess from pathlib import Path import pytest +from unihttp_openapi_generator import postprocess from unihttp_openapi_generator.postprocess import ( PostProcessError, format_path, @@ -22,14 +22,12 @@ def __init__(self, returncode: int) -> None: self.stderr = "stderr" -def test_ruff_not_found(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(shutil, "which", lambda name: None) - with pytest.raises(PostProcessError, match="ruff executable not found"): - format_python("x = 1\n") +@pytest.fixture +def stub_ruff(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(postprocess, "ruff_executable", lambda: "/usr/bin/ruff") -def test_format_python_nonzero_returncode(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/ruff") +def test_format_python_nonzero_returncode(monkeypatch: pytest.MonkeyPatch, stub_ruff: None) -> None: def fake_run(args: list[str], **kwargs: object) -> _Completed: return _Completed(1) @@ -39,9 +37,9 @@ def fake_run(args: list[str], **kwargs: object) -> _Completed: format_python("x = 1\n") -def test_format_path_check_failure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/ruff") - +def test_format_path_check_failure( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, stub_ruff: None +) -> None: def fake_run(args: list[str], **kwargs: object) -> _Completed: # returncode 2 from ``ruff check`` is neither clean (0) nor lint-only (1) return _Completed(2 if "check" in args else 0) @@ -51,9 +49,9 @@ def fake_run(args: list[str], **kwargs: object) -> _Completed: format_path(tmp_path / "f.py") -def test_format_path_format_failure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/ruff") - +def test_format_path_format_failure( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, stub_ruff: None +) -> None: def fake_run(args: list[str], **kwargs: object) -> _Completed: # check passes (1 == remaining lint findings, tolerated); format then fails return _Completed(1 if "check" in args else 3) diff --git a/tests/test_tooling.py b/tests/test_tooling.py new file mode 100644 index 0000000..e192467 --- /dev/null +++ b/tests/test_tooling.py @@ -0,0 +1,182 @@ +"""The generator must use the ruff/mypy installed with it, never a system copy.""" + +from __future__ import annotations + +import importlib.util +import os +import shutil +import subprocess +import sys +import sysconfig +from pathlib import Path + +import pytest + +from unihttp_openapi_generator import tooling +from unihttp_openapi_generator.tooling import ( + ToolNotFoundError, + mypy_command, + ruff_executable, + target_python_executable, +) + + +def _make_executable(directory: Path, name: str) -> Path: + directory.mkdir(parents=True, exist_ok=True) + path = directory / name + path.write_text("#!/bin/sh\n") + path.chmod(0o755) + return path + + +def test_prefers_own_environment_over_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """The regression: a system ruff on PATH must not win over the co-installed one.""" + own = _make_executable(tmp_path / "venv" / "bin", "ruff") + system = _make_executable(tmp_path / "usr" / "bin", "ruff") + + monkeypatch.setattr(sysconfig, "get_path", lambda *a, **kw: str(own.parent)) + monkeypatch.setattr(shutil, "which", lambda name: str(system)) + + assert ruff_executable() == str(own) + + +def test_falls_back_to_path_when_env_has_none( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + system = _make_executable(tmp_path / "usr" / "bin", "ruff") + monkeypatch.setattr(sysconfig, "get_path", lambda *a, **kw: str(tmp_path / "empty")) + monkeypatch.setattr(sys, "executable", str(tmp_path / "empty" / "python")) + monkeypatch.setattr(shutil, "which", lambda name: str(system)) + + assert ruff_executable() == str(system) + + +def test_ruff_missing_everywhere_names_the_fix( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(sysconfig, "get_path", lambda *a, **kw: str(tmp_path / "empty")) + # sys.executable's own directory is a candidate too, and the real one has ruff. + monkeypatch.setattr(sys, "executable", str(tmp_path / "empty" / "python")) + monkeypatch.setattr(shutil, "which", lambda name: None) + + with pytest.raises(ToolNotFoundError, match="force-reinstall"): + ruff_executable() + + +def test_lookup_result_is_cached(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + own = _make_executable(tmp_path / "bin", "ruff") + calls = 0 + + def counting_get_path(*args: object, **kwargs: object) -> str: + nonlocal calls + calls += 1 + return str(own.parent) + + monkeypatch.setattr(sysconfig, "get_path", counting_get_path) + ruff_executable() + after_first = calls + ruff_executable() + assert calls == after_first + + +class TestInstallScriptsDir: + """The generator's own install location tells us where its ruff landed.""" + + @pytest.mark.parametrize( + ("module_file", "expected"), + [ + ("/env/lib/python3.12/site-packages/unihttp_openapi_generator/tooling.py", "/env/bin"), + ("/usr/lib/python3/dist-packages/unihttp_openapi_generator/tooling.py", "/usr/bin"), + ], + ) + def test_derives_the_prefix_bin( + self, monkeypatch: pytest.MonkeyPatch, module_file: str, expected: str + ) -> None: + monkeypatch.setattr(tooling, "__file__", module_file) + assert tooling._install_scripts_dir() == expected + + def test_windows_layout(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + tooling, "__file__", "/env/Lib/site-packages/unihttp_openapi_generator/tooling.py" + ) + monkeypatch.setattr(os, "name", "nt") + assert tooling._install_scripts_dir() == "/env/Scripts" + + def test_none_from_a_source_checkout(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(tooling, "__file__", "/repo/src/unihttp_openapi_generator/tooling.py") + assert tooling._install_scripts_dir() is None + + def test_beats_an_ambient_install_of_the_wrong_version( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """A too-old system ruff sits in the default scripts dir; ours must still win.""" + own = _make_executable(tmp_path / "userbase" / "bin", "ruff") + _make_executable(tmp_path / "usr" / "local" / "bin", "ruff") + monkeypatch.setattr( + tooling, + "__file__", + str(tmp_path / "userbase" / "lib" / "python3.12" / "site-packages") + + "/unihttp_openapi_generator/tooling.py", + ) + monkeypatch.setattr( + sysconfig, "get_path", lambda *a, **kw: str(tmp_path / "usr" / "local" / "bin") + ) + + assert ruff_executable() == str(own) + + +def test_script_dirs_survive_a_missing_sys_executable(monkeypatch: pytest.MonkeyPatch) -> None: + """A frozen/embedded interpreter reports no executable; lookup must not crash.""" + monkeypatch.setattr(sys, "executable", "") + assert "" not in tooling._script_dirs() + + +def test_script_dirs_are_deduplicated(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sysconfig, "get_path", lambda *a, **kw: "/same/bin") + monkeypatch.setattr(sys, "executable", "/same/bin/python") + assert tooling._script_dirs() == ["/same/bin"] + + +def test_mypy_runs_through_this_interpreter() -> None: + assert mypy_command() == [sys.executable, "-m", "mypy"] + + +def test_mypy_command_actually_runs() -> None: + result = subprocess.run([*mypy_command(), "--version"], capture_output=True, text=True) + assert result.returncode == 0 + assert result.stdout.startswith("mypy ") + + +def test_mypy_missing_names_the_fix(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(importlib.util, "find_spec", lambda name: None) + with pytest.raises(ToolNotFoundError, match="force-reinstall"): + mypy_command() + + +class TestTargetPython: + """``--check`` resolves the generated package's imports against the user's venv.""" + + def test_none_without_an_activated_venv(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("VIRTUAL_ENV", raising=False) + assert target_python_executable() is None + + def test_none_when_the_venv_is_the_generator_s_own( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("VIRTUAL_ENV", sys.prefix) + assert target_python_executable() is None + + def test_points_at_a_different_activated_venv( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + bindir = "Scripts" if os.name == "nt" else "bin" + python = _make_executable(tmp_path / "proj" / bindir, "python") + monkeypatch.setenv("VIRTUAL_ENV", str(tmp_path / "proj")) + + assert target_python_executable() == str(python) + + def test_none_when_the_venv_path_is_stale( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + monkeypatch.setenv("VIRTUAL_ENV", str(tmp_path / "deleted")) + assert target_python_executable() is None diff --git a/uv.lock b/uv.lock index 5f4eacc..0b55cb7 100644 --- a/uv.lock +++ b/uv.lock @@ -1569,6 +1569,7 @@ source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "jinja2" }, + { name = "mypy" }, { name = "openapi-spec-validator" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -1578,11 +1579,6 @@ dependencies = [ { name = "typer" }, ] -[package.optional-dependencies] -check = [ - { name = "mypy" }, -] - [package.dev-dependencies] dev = [ { name = "adaptix" }, @@ -1600,7 +1596,7 @@ dev = [ requires-dist = [ { name = "httpx", specifier = ">=0.28.0" }, { name = "jinja2", specifier = ">=3.1.0" }, - { name = "mypy", marker = "extra == 'check'", specifier = ">=1.10.0" }, + { name = "mypy", specifier = ">=1.10.0" }, { name = "openapi-spec-validator", specifier = ">=0.7.1" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" },