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
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 8 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
43 changes: 26 additions & 17 deletions src/unihttp_openapi_generator/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from __future__ import annotations

import logging
import shutil
import subprocess
from pathlib import Path

Expand All @@ -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")

Expand All @@ -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:
Expand Down
16 changes: 5 additions & 11 deletions src/unihttp_openapi_generator/postprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down
124 changes: 124 additions & 0 deletions src/unihttp_openapi_generator/tooling.py
Original file line number Diff line number Diff line change
@@ -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": # <prefix>/Lib/site-packages -> <prefix>/Scripts
return os.path.join(os.path.dirname(os.path.dirname(site_packages)), "Scripts")
# <prefix>/lib/python3.X/site-packages -> <prefix>/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
11 changes: 11 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
27 changes: 21 additions & 6 deletions tests/test_pipeline_cli_extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,38 @@

from __future__ import annotations

import shutil
import sys
from pathlib import Path

import pytest
from typer.testing import CliRunner

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:
Expand Down
Loading
Loading