Ir para o conteúdo

Referência

Gerada a partir das docstrings do pacote via mkdocstrings.

Superfície de topo

tempest_cli

Framework-agnostic quality gate for Python projects.

tempest-cli runs ruff, mypy and pytest behind one command, with a typing-strictness dial read from [tool.tempest] in the project's pyproject.toml — and a generator for the prompt that makes an AI fill a pull-request description from the branch's own diff.

It knows nothing about any web framework. ruff ships with it, so the lint/format commands work the moment it is installed; mypy and pytest are left to the project, which pins the versions it wants. The tools are invoked from the project's environment first, so a pinned version always beats the bundled one.

tempest-cli check                 # lint + fmt-check + type + test
tempest-cli fix                   # every ruff autofix, then format
tempest-cli type -s strict        # override the configured strictness
tempest-cli pr-prompt | claude -p

tc check                          # `tc` is the short alias for the same CLI

Everything is importable too, for a project that would rather wire the gate into its own tooling:

from tempest_cli import load_tempest_config, run_full_check

config = load_tempest_config()
exit_code = run_full_check(".", config=config)

And :func:tempest_cli.main.register_commands mounts the whole gate onto an existing :class:typer.Typer, so another CLI can expose these commands under its own name without copying them.

DEFAULT_TYPING_STRICTNESS module-attribute

DEFAULT_TYPING_STRICTNESS: TypingStrictness = 'standard'

Level applied when the key is absent or no pyproject.toml is found.

TypingStrictness module-attribute

TypingStrictness = Literal['lenient', 'standard', 'strict']

Allowed values for [tool.tempest] typing_strictness.

TempestConfig dataclass

TempestConfig(typing_strictness: TypingStrictness = DEFAULT_TYPING_STRICTNESS)

Resolved [tool.tempest] settings.

Attributes:

Name Type Description
typing_strictness TypingStrictness

How strictly the CLI gates enforce typing. One of "lenient", "standard", "strict".

ruff_ann_select

ruff_ann_select() -> list[str]

Return the ANN rule codes to add to ruff for this level.

Returns:

Type Description
list[str]

list[str]: Rule codes for --extend-select (empty for

list[str]

"lenient"). ANN401 is never present.

Source code in tempest_cli/config.py
def ruff_ann_select(self) -> list[str]:
    """Return the ANN rule codes to add to ruff for this level.

    Returns:
        list[str]: Rule codes for ``--extend-select`` (empty for
        ``"lenient"``). ANN401 is never present.
    """
    return list(_RUFF_ANN_BY_LEVEL[self.typing_strictness])

mypy_flags

mypy_flags() -> list[str]

Return the extra mypy flags to add for this level.

Returns:

Type Description
list[str]

list[str]: Flags layered on top of the project's

list[str]

[tool.mypy] config (empty for "lenient").

Source code in tempest_cli/config.py
def mypy_flags(self) -> list[str]:
    """Return the extra mypy flags to add for this level.

    Returns:
        list[str]: Flags layered on top of the project's
        ``[tool.mypy]`` config (empty for ``"lenient"``).
    """
    return list(_MYPY_FLAGS_BY_LEVEL[self.typing_strictness])

GitError

Bases: RuntimeError

A git invocation failed, or the repository lacks what was asked.

Carries the command's own stderr so the caller can print the reason git gave instead of a generic failure.

PromptLanguage

Bases: StrEnum

Language of the bundled template and of the prompt's instructions.

Only the bundled template is translated: a repository template is used verbatim in whatever language it was written in, since it is that repository's contract.

find_pyproject

find_pyproject(start: Path | None = None) -> Path | None

Locate the nearest pyproject.toml walking up from start.

Parameters:

Name Type Description Default
start Path | None

Directory to begin the search. Defaults to the current working directory.

None

Returns:

Type Description
Path | None

Path | None: The path to the first pyproject.toml found in

Path | None

start or an ancestor, or None when none exists.

Source code in tempest_cli/config.py
def find_pyproject(start: Path | None = None) -> Path | None:
    """Locate the nearest ``pyproject.toml`` walking up from ``start``.

    Args:
        start (Path | None): Directory to begin the search. Defaults to
            the current working directory.

    Returns:
        Path | None: The path to the first ``pyproject.toml`` found in
        ``start`` or an ancestor, or ``None`` when none exists.
    """
    current = (start or Path.cwd()).resolve()
    for directory in (current, *current.parents):
        candidate = directory / "pyproject.toml"
        if candidate.is_file():
            return candidate
    return None

load_tempest_config

load_tempest_config(start: Path | None = None) -> TempestConfig

Load [tool.tempest] from the nearest pyproject.toml.

Parameters:

Name Type Description Default
start Path | None

Directory to begin the search. Defaults to the current working directory.

None

Returns:

Name Type Description
TempestConfig TempestConfig

The resolved config. Falls back to defaults when

TempestConfig

no pyproject.toml or no [tool.tempest] table is found.

Raises:

Type Description
ValueError

When typing_strictness is present but invalid.

Source code in tempest_cli/config.py
def load_tempest_config(start: Path | None = None) -> TempestConfig:
    """Load ``[tool.tempest]`` from the nearest ``pyproject.toml``.

    Args:
        start (Path | None): Directory to begin the search. Defaults to
            the current working directory.

    Returns:
        TempestConfig: The resolved config. Falls back to defaults when
        no ``pyproject.toml`` or no ``[tool.tempest]`` table is found.

    Raises:
        ValueError: When ``typing_strictness`` is present but invalid.
    """
    pyproject = find_pyproject(start)
    if pyproject is None:
        return TempestConfig()
    with pyproject.open("rb") as handle:
        data = tomllib.load(handle)
    table = data.get("tool", {}).get("tempest", {})
    strictness = (
        _coerce_strictness(table["typing_strictness"], source=str(pyproject))
        if "typing_strictness" in table
        else DEFAULT_TYPING_STRICTNESS
    )
    return TempestConfig(typing_strictness=strictness)

resolve_tool

resolve_tool(executable: str) -> list[str] | None

Return an argv prefix invoking executable or None when absent.

Public because callers outside the gate need the same lookup — the SDK's OpenAPI code generator formats what it emits with the project's own ruff, and reimplementing the environment/uv run fallback there would be a second answer to the same question.

Preference order:

  1. the environments that belong to this run (see :func:_environment_dirs): the CLI's own interpreter directory, $VIRTUAL_ENV, then the nearest .venv;
  2. executable on PATH — skipped when it resolves to a version-manager shim that does not dispatch anywhere;
  3. uv run --with <executable> <executable> when uv is on the PATH: the project's own environment plus the tool, without requiring activation or a prior uv sync.

Step 3 carries --with on purpose. A plain uv run ruff falls back to PATH when the project environment has no ruff — landing right back on the dead shim this lookup just rejected. --with puts the tool in the run's own overlay, so the command is always the one that runs. When the project pins a version, uv resolves the overlay against the project's requirements, so the pin still wins.

Parameters:

Name Type Description Default
executable str

The command name (ruff/mypy/pytest).

required

Returns:

Type Description
list[str] | None

list[str] | None: argv prefix to extend with extra arguments, or

list[str] | None

None when no runner could be found.

Source code in tempest_cli/lint.py
def resolve_tool(executable: str) -> list[str] | None:
    """Return an argv prefix invoking ``executable`` or ``None`` when absent.

    Public because callers outside the gate need the same lookup — the
    SDK's OpenAPI code generator formats what it emits with the project's
    own ruff, and reimplementing the environment/``uv run`` fallback
    there would be a second answer to the same question.

    Preference order:

    1. the environments that belong to this run (see
       :func:`_environment_dirs`): the CLI's own interpreter directory,
       ``$VIRTUAL_ENV``, then the nearest ``.venv``;
    2. ``executable`` on ``PATH`` — skipped when it resolves to a
       version-manager shim that does not dispatch anywhere;
    3. ``uv run --with <executable> <executable>`` when ``uv`` is on the
       ``PATH``: the project's own environment plus the tool, without
       requiring activation or a prior ``uv sync``.

    Step 3 carries ``--with`` on purpose. A plain ``uv run ruff`` falls
    back to ``PATH`` when the project environment has no ruff — landing
    right back on the dead shim this lookup just rejected. ``--with``
    puts the tool in the run's own overlay, so the command is always the
    one that runs. When the project pins a version, uv resolves the
    overlay against the project's requirements, so the pin still wins.

    Args:
        executable (str): The command name (``ruff``/``mypy``/``pytest``).

    Returns:
        list[str] | None: argv prefix to extend with extra arguments, or
        ``None`` when no runner could be found.
    """
    for directory in _environment_dirs():
        local = shutil.which(executable, path=str(directory))
        if local is not None:
            return [local]
    direct = _path_lookup(executable)
    if direct is not None:
        return [direct]
    uv = _path_lookup("uv")
    if uv is not None:
        return [uv, "run", "--with", executable, executable]
    return None

run_full_check

run_full_check(target: str, *, config: TempestConfig | None = None) -> int

Run the entire quality gate sequentially.

Order: ruff checkruff format --checkmypypytest. Stops at the first non-zero exit code so failures surface fast.

Parameters:

Name Type Description Default
target str

The path inspected by ruff/mypy. Pytest always runs against the project's configured testpaths.

required
config TempestConfig | None

Resolved [tool.tempest] config controlling the ANN rules and mypy flags layered onto the ruff/mypy steps. When None the default level is used.

None

Returns:

Name Type Description
int int

The first non-zero exit code, or 0 when every gate passed.

Source code in tempest_cli/lint.py
def run_full_check(target: str, *, config: TempestConfig | None = None) -> int:
    """Run the entire quality gate sequentially.

    Order: ``ruff check`` → ``ruff format --check`` → ``mypy`` → ``pytest``.
    Stops at the first non-zero exit code so failures surface fast.

    Args:
        target (str): The path inspected by ruff/mypy. Pytest always runs
            against the project's configured ``testpaths``.
        config (TempestConfig | None): Resolved ``[tool.tempest]`` config
            controlling the ANN rules and mypy flags layered onto the
            ruff/mypy steps. When ``None`` the default level is used.

    Returns:
        int: The first non-zero exit code, or ``0`` when every gate passed.
    """
    resolved = config or TempestConfig()
    steps: list[tuple[str, list[str]]] = [
        ("ruff", ["check", *_ruff_ann_args(resolved), target]),
        ("ruff", ["format", "--check", target]),
        ("mypy", [*resolved.mypy_flags(), target]),
        ("pytest", []),
    ]
    for executable, args in steps:
        typer.echo(f"$ {executable} {' '.join(args)}", err=True)
        code = _execute(executable, args)
        if code != 0:
            return code
    return 0

run_mypy

run_mypy(target: str, *, config: TempestConfig | None = None) -> int

Invoke mypy <target> with the configured strictness flags.

Parameters:

Name Type Description Default
target str

The path passed verbatim to mypy.

required
config TempestConfig | None

Resolved [tool.tempest] config controlling the mypy strictness flags layered on top of the project's [tool.mypy]. When None the default level is used.

None

Returns:

Name Type Description
int int

The mypy exit code.

Source code in tempest_cli/lint.py
def run_mypy(target: str, *, config: TempestConfig | None = None) -> int:
    """Invoke ``mypy <target>`` with the configured strictness flags.

    Args:
        target (str): The path passed verbatim to mypy.
        config (TempestConfig | None): Resolved ``[tool.tempest]`` config
            controlling the mypy strictness flags layered on top of the
            project's ``[tool.mypy]``. When ``None`` the default level is
            used.

    Returns:
        int: The mypy exit code.
    """
    flags = (config or TempestConfig()).mypy_flags()
    return _execute("mypy", [*flags, target])

run_pytest

run_pytest(target: str | None) -> int

Invoke pytest with an optional target.

Parameters:

Name Type Description Default
target str | None

Optional pytest path filter. None runs the default test suite.

required

Returns:

Name Type Description
int int

The pytest exit code.

Source code in tempest_cli/lint.py
def run_pytest(target: str | None) -> int:
    """Invoke ``pytest`` with an optional target.

    Args:
        target (str | None): Optional pytest path filter. ``None`` runs
            the default test suite.

    Returns:
        int: The pytest exit code.
    """
    args = [target] if target else []
    return _execute("pytest", args)

run_ruff_check

run_ruff_check(target: str, *, config: TempestConfig | None = None) -> int

Invoke ruff check <target> with the configured ANN rules.

Parameters:

Name Type Description Default
target str

The path passed verbatim to ruff.

required
config TempestConfig | None

Resolved [tool.tempest] config controlling the typing-strictness ANN rules layered on. When None the default level is used.

None

Returns:

Name Type Description
int int

The ruff exit code.

Source code in tempest_cli/lint.py
def run_ruff_check(target: str, *, config: TempestConfig | None = None) -> int:
    """Invoke ``ruff check <target>`` with the configured ANN rules.

    Args:
        target (str): The path passed verbatim to ruff.
        config (TempestConfig | None): Resolved ``[tool.tempest]`` config
            controlling the typing-strictness ANN rules layered on. When
            ``None`` the default level is used.

    Returns:
        int: The ruff exit code.
    """
    return _execute("ruff", ["check", *_ruff_ann_args(config), target])

run_ruff_fix

run_ruff_fix(
    target: str, *, unsafe: bool = False, config: TempestConfig | None = None
) -> int

Apply every automatic fix ruff can perform, then format the target.

Runs in two passes so the second one sees the rewritten file:

  1. ruff check --fix [--unsafe-fixes] <target> — autofix imports (sort + dedupe), remove unused imports, normalize string quotes, drop trailing whitespace, fix the rest of the lint rules that have safe (or, with unsafe=True, also unsafe) autofixers.
  2. ruff format <target> — normalize indentation, line length, blank lines and trailing newlines.

Both passes always run. ruff check --fix exits non-zero whenever any residual violation remains that it cannot autofix (an over-length string/comment, an undefined name, etc.) — even though it already rewrote everything it could. Short-circuiting on that exit code would skip ruff format entirely, leaving the file un-wrapped and its extra blank lines intact. So the formatter runs unconditionally; the lint exit code is surfaced afterwards so CI still fails on the leftover issues.

Parameters:

Name Type Description Default
target str

The path passed verbatim to ruff.

required
unsafe bool

When True, pass --unsafe-fixes so ruff also applies the fixes it would otherwise leave alone.

False
config TempestConfig | None

Resolved [tool.tempest] config controlling the typing-strictness ANN rules layered onto the fix pass. When None the default level is used.

None

Returns:

Name Type Description
int int

0 when both passes succeed with nothing left to fix;

int

otherwise the lint pass exit code (residual violations), or the

int

format pass exit code when the lint pass was clean.

Source code in tempest_cli/lint.py
def run_ruff_fix(
    target: str,
    *,
    unsafe: bool = False,
    config: TempestConfig | None = None,
) -> int:
    """Apply every automatic fix ruff can perform, then format the target.

    Runs in two passes so the second one sees the rewritten file:

    1. ``ruff check --fix [--unsafe-fixes] <target>`` — autofix imports
       (sort + dedupe), remove unused imports, normalize string quotes,
       drop trailing whitespace, fix the rest of the lint rules that
       have safe (or, with ``unsafe=True``, also unsafe) autofixers.
    2. ``ruff format <target>`` — normalize indentation, line length,
       blank lines and trailing newlines.

    Both passes always run. ``ruff check --fix`` exits non-zero whenever
    *any* residual violation remains that it cannot autofix (an
    over-length string/comment, an undefined name, etc.) — even though
    it already rewrote everything it could. Short-circuiting on that
    exit code would skip ``ruff format`` entirely, leaving the file
    un-wrapped and its extra blank lines intact. So the formatter runs
    unconditionally; the lint exit code is surfaced afterwards so CI
    still fails on the leftover issues.

    Args:
        target (str): The path passed verbatim to ruff.
        unsafe (bool): When True, pass ``--unsafe-fixes`` so ruff also
            applies the fixes it would otherwise leave alone.
        config (TempestConfig | None): Resolved ``[tool.tempest]`` config
            controlling the typing-strictness ANN rules layered onto the
            fix pass. When ``None`` the default level is used.

    Returns:
        int: ``0`` when both passes succeed with nothing left to fix;
        otherwise the lint pass exit code (residual violations), or the
        format pass exit code when the lint pass was clean.
    """
    check_args = ["check", "--fix", *_ruff_ann_args(config)]
    if unsafe:
        check_args.append("--unsafe-fixes")
    check_args.append(target)
    check_code = _execute("ruff", check_args)
    format_code = _execute("ruff", ["format", target])
    return check_code or format_code

run_ruff_format

run_ruff_format(target: str, *, check: bool) -> int

Invoke ruff format (write or check-only).

Parameters:

Name Type Description Default
target str

The path passed verbatim to ruff.

required
check bool

When True, run ruff format --check (read-only).

required

Returns:

Name Type Description
int int

The ruff exit code.

Source code in tempest_cli/lint.py
def run_ruff_format(target: str, *, check: bool) -> int:
    """Invoke ``ruff format`` (write or check-only).

    Args:
        target (str): The path passed verbatim to ruff.
        check (bool): When True, run ``ruff format --check`` (read-only).

    Returns:
        int: The ruff exit code.
    """
    args = ["format"]
    if check:
        args.append("--check")
    args.append(target)
    return _execute("ruff", args)

generate_pr_prompt

generate_pr_prompt(
    *,
    base: str = DEFAULT_BASE,
    head: str | None = None,
    cwd: Path | None = None,
    template: Path | None = None,
    language: PromptLanguage = PT_BR,
    max_files: int | None = DEFAULT_MAX_FILES,
    max_chars: int | None = DEFAULT_MAX_CHARS,
) -> tuple[str, PullRequestContext, ResolvedTemplate]

Read the repository and render the prompt in one call.

Parameters:

Name Type Description Default
base str

The base ref the pull request targets.

DEFAULT_BASE
head str | None

The branch being described. Defaults to the checked-out one.

None
cwd Path | None

Any directory inside the repository.

None
template Path | None

An explicit template path.

None
language PromptLanguage

Language of the instructions and of the bundled fallback template.

PT_BR
max_files int | None

How many files contribute a patch excerpt. 0 drops every patch, None excerpts them all.

DEFAULT_MAX_FILES
max_chars int | None

Characters kept per patch, or None for the whole patch.

DEFAULT_MAX_CHARS

Returns:

Type Description
str

tuple[str, PullRequestContext, ResolvedTemplate]: The prompt plus

PullRequestContext

the context and template it was built from, so a caller can

ResolvedTemplate

report what was read and what was dropped.

Raises:

Type Description
GitError

When the repository, the base ref or the template path cannot be resolved.

Source code in tempest_cli/pr_prompt.py
def generate_pr_prompt(
    *,
    base: str = DEFAULT_BASE,
    head: str | None = None,
    cwd: Path | None = None,
    template: Path | None = None,
    language: PromptLanguage = PromptLanguage.PT_BR,
    max_files: int | None = DEFAULT_MAX_FILES,
    max_chars: int | None = DEFAULT_MAX_CHARS,
) -> tuple[str, PullRequestContext, ResolvedTemplate]:
    """Read the repository and render the prompt in one call.

    Args:
        base (str): The base ref the pull request targets.
        head (str | None): The branch being described. Defaults to the
            checked-out one.
        cwd (Path | None): Any directory inside the repository.
        template (Path | None): An explicit template path.
        language (PromptLanguage): Language of the instructions and of
            the bundled fallback template.
        max_files (int | None): How many files contribute a patch
            excerpt. ``0`` drops every patch, ``None`` excerpts them all.
        max_chars (int | None): Characters kept per patch, or ``None``
            for the whole patch.

    Returns:
        tuple[str, PullRequestContext, ResolvedTemplate]: The prompt plus
        the context and template it was built from, so a caller can
        report what was read and what was dropped.

    Raises:
        GitError: When the repository, the base ref or the template path
            cannot be resolved.
    """
    context = collect_context(
        base=base,
        head=head,
        cwd=cwd,
        max_files=max_files,
        max_chars=max_chars,
    )
    root = repository_root((cwd or Path.cwd()).expanduser().resolve())
    resolved_template = resolve_template(root, template=template, language=language)
    prompt = build_prompt(context, resolved_template, language=language)
    return prompt, context, resolved_template

Configuração

TempestConfig dataclass

TempestConfig(typing_strictness: TypingStrictness = DEFAULT_TYPING_STRICTNESS)

Resolved [tool.tempest] settings.

Attributes:

Name Type Description
typing_strictness TypingStrictness

How strictly the CLI gates enforce typing. One of "lenient", "standard", "strict".

ruff_ann_select

ruff_ann_select() -> list[str]

Return the ANN rule codes to add to ruff for this level.

Returns:

Type Description
list[str]

list[str]: Rule codes for --extend-select (empty for

list[str]

"lenient"). ANN401 is never present.

Source code in tempest_cli/config.py
def ruff_ann_select(self) -> list[str]:
    """Return the ANN rule codes to add to ruff for this level.

    Returns:
        list[str]: Rule codes for ``--extend-select`` (empty for
        ``"lenient"``). ANN401 is never present.
    """
    return list(_RUFF_ANN_BY_LEVEL[self.typing_strictness])

mypy_flags

mypy_flags() -> list[str]

Return the extra mypy flags to add for this level.

Returns:

Type Description
list[str]

list[str]: Flags layered on top of the project's

list[str]

[tool.mypy] config (empty for "lenient").

Source code in tempest_cli/config.py
def mypy_flags(self) -> list[str]:
    """Return the extra mypy flags to add for this level.

    Returns:
        list[str]: Flags layered on top of the project's
        ``[tool.mypy]`` config (empty for ``"lenient"``).
    """
    return list(_MYPY_FLAGS_BY_LEVEL[self.typing_strictness])

TypingStrictness module-attribute

TypingStrictness = Literal['lenient', 'standard', 'strict']

Allowed values for [tool.tempest] typing_strictness.

load_tempest_config

load_tempest_config(start: Path | None = None) -> TempestConfig

Load [tool.tempest] from the nearest pyproject.toml.

Parameters:

Name Type Description Default
start Path | None

Directory to begin the search. Defaults to the current working directory.

None

Returns:

Name Type Description
TempestConfig TempestConfig

The resolved config. Falls back to defaults when

TempestConfig

no pyproject.toml or no [tool.tempest] table is found.

Raises:

Type Description
ValueError

When typing_strictness is present but invalid.

Source code in tempest_cli/config.py
def load_tempest_config(start: Path | None = None) -> TempestConfig:
    """Load ``[tool.tempest]`` from the nearest ``pyproject.toml``.

    Args:
        start (Path | None): Directory to begin the search. Defaults to
            the current working directory.

    Returns:
        TempestConfig: The resolved config. Falls back to defaults when
        no ``pyproject.toml`` or no ``[tool.tempest]`` table is found.

    Raises:
        ValueError: When ``typing_strictness`` is present but invalid.
    """
    pyproject = find_pyproject(start)
    if pyproject is None:
        return TempestConfig()
    with pyproject.open("rb") as handle:
        data = tomllib.load(handle)
    table = data.get("tool", {}).get("tempest", {})
    strictness = (
        _coerce_strictness(table["typing_strictness"], source=str(pyproject))
        if "typing_strictness" in table
        else DEFAULT_TYPING_STRICTNESS
    )
    return TempestConfig(typing_strictness=strictness)

find_pyproject

find_pyproject(start: Path | None = None) -> Path | None

Locate the nearest pyproject.toml walking up from start.

Parameters:

Name Type Description Default
start Path | None

Directory to begin the search. Defaults to the current working directory.

None

Returns:

Type Description
Path | None

Path | None: The path to the first pyproject.toml found in

Path | None

start or an ancestor, or None when none exists.

Source code in tempest_cli/config.py
def find_pyproject(start: Path | None = None) -> Path | None:
    """Locate the nearest ``pyproject.toml`` walking up from ``start``.

    Args:
        start (Path | None): Directory to begin the search. Defaults to
            the current working directory.

    Returns:
        Path | None: The path to the first ``pyproject.toml`` found in
        ``start`` or an ancestor, or ``None`` when none exists.
    """
    current = (start or Path.cwd()).resolve()
    for directory in (current, *current.parents):
        candidate = directory / "pyproject.toml"
        if candidate.is_file():
            return candidate
    return None

Runners

run_ruff_check

run_ruff_check(target: str, *, config: TempestConfig | None = None) -> int

Invoke ruff check <target> with the configured ANN rules.

Parameters:

Name Type Description Default
target str

The path passed verbatim to ruff.

required
config TempestConfig | None

Resolved [tool.tempest] config controlling the typing-strictness ANN rules layered on. When None the default level is used.

None

Returns:

Name Type Description
int int

The ruff exit code.

Source code in tempest_cli/lint.py
def run_ruff_check(target: str, *, config: TempestConfig | None = None) -> int:
    """Invoke ``ruff check <target>`` with the configured ANN rules.

    Args:
        target (str): The path passed verbatim to ruff.
        config (TempestConfig | None): Resolved ``[tool.tempest]`` config
            controlling the typing-strictness ANN rules layered on. When
            ``None`` the default level is used.

    Returns:
        int: The ruff exit code.
    """
    return _execute("ruff", ["check", *_ruff_ann_args(config), target])

run_ruff_fix

run_ruff_fix(
    target: str, *, unsafe: bool = False, config: TempestConfig | None = None
) -> int

Apply every automatic fix ruff can perform, then format the target.

Runs in two passes so the second one sees the rewritten file:

  1. ruff check --fix [--unsafe-fixes] <target> — autofix imports (sort + dedupe), remove unused imports, normalize string quotes, drop trailing whitespace, fix the rest of the lint rules that have safe (or, with unsafe=True, also unsafe) autofixers.
  2. ruff format <target> — normalize indentation, line length, blank lines and trailing newlines.

Both passes always run. ruff check --fix exits non-zero whenever any residual violation remains that it cannot autofix (an over-length string/comment, an undefined name, etc.) — even though it already rewrote everything it could. Short-circuiting on that exit code would skip ruff format entirely, leaving the file un-wrapped and its extra blank lines intact. So the formatter runs unconditionally; the lint exit code is surfaced afterwards so CI still fails on the leftover issues.

Parameters:

Name Type Description Default
target str

The path passed verbatim to ruff.

required
unsafe bool

When True, pass --unsafe-fixes so ruff also applies the fixes it would otherwise leave alone.

False
config TempestConfig | None

Resolved [tool.tempest] config controlling the typing-strictness ANN rules layered onto the fix pass. When None the default level is used.

None

Returns:

Name Type Description
int int

0 when both passes succeed with nothing left to fix;

int

otherwise the lint pass exit code (residual violations), or the

int

format pass exit code when the lint pass was clean.

Source code in tempest_cli/lint.py
def run_ruff_fix(
    target: str,
    *,
    unsafe: bool = False,
    config: TempestConfig | None = None,
) -> int:
    """Apply every automatic fix ruff can perform, then format the target.

    Runs in two passes so the second one sees the rewritten file:

    1. ``ruff check --fix [--unsafe-fixes] <target>`` — autofix imports
       (sort + dedupe), remove unused imports, normalize string quotes,
       drop trailing whitespace, fix the rest of the lint rules that
       have safe (or, with ``unsafe=True``, also unsafe) autofixers.
    2. ``ruff format <target>`` — normalize indentation, line length,
       blank lines and trailing newlines.

    Both passes always run. ``ruff check --fix`` exits non-zero whenever
    *any* residual violation remains that it cannot autofix (an
    over-length string/comment, an undefined name, etc.) — even though
    it already rewrote everything it could. Short-circuiting on that
    exit code would skip ``ruff format`` entirely, leaving the file
    un-wrapped and its extra blank lines intact. So the formatter runs
    unconditionally; the lint exit code is surfaced afterwards so CI
    still fails on the leftover issues.

    Args:
        target (str): The path passed verbatim to ruff.
        unsafe (bool): When True, pass ``--unsafe-fixes`` so ruff also
            applies the fixes it would otherwise leave alone.
        config (TempestConfig | None): Resolved ``[tool.tempest]`` config
            controlling the typing-strictness ANN rules layered onto the
            fix pass. When ``None`` the default level is used.

    Returns:
        int: ``0`` when both passes succeed with nothing left to fix;
        otherwise the lint pass exit code (residual violations), or the
        format pass exit code when the lint pass was clean.
    """
    check_args = ["check", "--fix", *_ruff_ann_args(config)]
    if unsafe:
        check_args.append("--unsafe-fixes")
    check_args.append(target)
    check_code = _execute("ruff", check_args)
    format_code = _execute("ruff", ["format", target])
    return check_code or format_code

run_ruff_format

run_ruff_format(target: str, *, check: bool) -> int

Invoke ruff format (write or check-only).

Parameters:

Name Type Description Default
target str

The path passed verbatim to ruff.

required
check bool

When True, run ruff format --check (read-only).

required

Returns:

Name Type Description
int int

The ruff exit code.

Source code in tempest_cli/lint.py
def run_ruff_format(target: str, *, check: bool) -> int:
    """Invoke ``ruff format`` (write or check-only).

    Args:
        target (str): The path passed verbatim to ruff.
        check (bool): When True, run ``ruff format --check`` (read-only).

    Returns:
        int: The ruff exit code.
    """
    args = ["format"]
    if check:
        args.append("--check")
    args.append(target)
    return _execute("ruff", args)

run_mypy

run_mypy(target: str, *, config: TempestConfig | None = None) -> int

Invoke mypy <target> with the configured strictness flags.

Parameters:

Name Type Description Default
target str

The path passed verbatim to mypy.

required
config TempestConfig | None

Resolved [tool.tempest] config controlling the mypy strictness flags layered on top of the project's [tool.mypy]. When None the default level is used.

None

Returns:

Name Type Description
int int

The mypy exit code.

Source code in tempest_cli/lint.py
def run_mypy(target: str, *, config: TempestConfig | None = None) -> int:
    """Invoke ``mypy <target>`` with the configured strictness flags.

    Args:
        target (str): The path passed verbatim to mypy.
        config (TempestConfig | None): Resolved ``[tool.tempest]`` config
            controlling the mypy strictness flags layered on top of the
            project's ``[tool.mypy]``. When ``None`` the default level is
            used.

    Returns:
        int: The mypy exit code.
    """
    flags = (config or TempestConfig()).mypy_flags()
    return _execute("mypy", [*flags, target])

run_pytest

run_pytest(target: str | None) -> int

Invoke pytest with an optional target.

Parameters:

Name Type Description Default
target str | None

Optional pytest path filter. None runs the default test suite.

required

Returns:

Name Type Description
int int

The pytest exit code.

Source code in tempest_cli/lint.py
def run_pytest(target: str | None) -> int:
    """Invoke ``pytest`` with an optional target.

    Args:
        target (str | None): Optional pytest path filter. ``None`` runs
            the default test suite.

    Returns:
        int: The pytest exit code.
    """
    args = [target] if target else []
    return _execute("pytest", args)

run_full_check

run_full_check(target: str, *, config: TempestConfig | None = None) -> int

Run the entire quality gate sequentially.

Order: ruff checkruff format --checkmypypytest. Stops at the first non-zero exit code so failures surface fast.

Parameters:

Name Type Description Default
target str

The path inspected by ruff/mypy. Pytest always runs against the project's configured testpaths.

required
config TempestConfig | None

Resolved [tool.tempest] config controlling the ANN rules and mypy flags layered onto the ruff/mypy steps. When None the default level is used.

None

Returns:

Name Type Description
int int

The first non-zero exit code, or 0 when every gate passed.

Source code in tempest_cli/lint.py
def run_full_check(target: str, *, config: TempestConfig | None = None) -> int:
    """Run the entire quality gate sequentially.

    Order: ``ruff check`` → ``ruff format --check`` → ``mypy`` → ``pytest``.
    Stops at the first non-zero exit code so failures surface fast.

    Args:
        target (str): The path inspected by ruff/mypy. Pytest always runs
            against the project's configured ``testpaths``.
        config (TempestConfig | None): Resolved ``[tool.tempest]`` config
            controlling the ANN rules and mypy flags layered onto the
            ruff/mypy steps. When ``None`` the default level is used.

    Returns:
        int: The first non-zero exit code, or ``0`` when every gate passed.
    """
    resolved = config or TempestConfig()
    steps: list[tuple[str, list[str]]] = [
        ("ruff", ["check", *_ruff_ann_args(resolved), target]),
        ("ruff", ["format", "--check", target]),
        ("mypy", [*resolved.mypy_flags(), target]),
        ("pytest", []),
    ]
    for executable, args in steps:
        typer.echo(f"$ {executable} {' '.join(args)}", err=True)
        code = _execute(executable, args)
        if code != 0:
            return code
    return 0

Prompt de PR

generate_pr_prompt

generate_pr_prompt(
    *,
    base: str = DEFAULT_BASE,
    head: str | None = None,
    cwd: Path | None = None,
    template: Path | None = None,
    language: PromptLanguage = PT_BR,
    max_files: int | None = DEFAULT_MAX_FILES,
    max_chars: int | None = DEFAULT_MAX_CHARS,
) -> tuple[str, PullRequestContext, ResolvedTemplate]

Read the repository and render the prompt in one call.

Parameters:

Name Type Description Default
base str

The base ref the pull request targets.

DEFAULT_BASE
head str | None

The branch being described. Defaults to the checked-out one.

None
cwd Path | None

Any directory inside the repository.

None
template Path | None

An explicit template path.

None
language PromptLanguage

Language of the instructions and of the bundled fallback template.

PT_BR
max_files int | None

How many files contribute a patch excerpt. 0 drops every patch, None excerpts them all.

DEFAULT_MAX_FILES
max_chars int | None

Characters kept per patch, or None for the whole patch.

DEFAULT_MAX_CHARS

Returns:

Type Description
str

tuple[str, PullRequestContext, ResolvedTemplate]: The prompt plus

PullRequestContext

the context and template it was built from, so a caller can

ResolvedTemplate

report what was read and what was dropped.

Raises:

Type Description
GitError

When the repository, the base ref or the template path cannot be resolved.

Source code in tempest_cli/pr_prompt.py
def generate_pr_prompt(
    *,
    base: str = DEFAULT_BASE,
    head: str | None = None,
    cwd: Path | None = None,
    template: Path | None = None,
    language: PromptLanguage = PromptLanguage.PT_BR,
    max_files: int | None = DEFAULT_MAX_FILES,
    max_chars: int | None = DEFAULT_MAX_CHARS,
) -> tuple[str, PullRequestContext, ResolvedTemplate]:
    """Read the repository and render the prompt in one call.

    Args:
        base (str): The base ref the pull request targets.
        head (str | None): The branch being described. Defaults to the
            checked-out one.
        cwd (Path | None): Any directory inside the repository.
        template (Path | None): An explicit template path.
        language (PromptLanguage): Language of the instructions and of
            the bundled fallback template.
        max_files (int | None): How many files contribute a patch
            excerpt. ``0`` drops every patch, ``None`` excerpts them all.
        max_chars (int | None): Characters kept per patch, or ``None``
            for the whole patch.

    Returns:
        tuple[str, PullRequestContext, ResolvedTemplate]: The prompt plus
        the context and template it was built from, so a caller can
        report what was read and what was dropped.

    Raises:
        GitError: When the repository, the base ref or the template path
            cannot be resolved.
    """
    context = collect_context(
        base=base,
        head=head,
        cwd=cwd,
        max_files=max_files,
        max_chars=max_chars,
    )
    root = repository_root((cwd or Path.cwd()).expanduser().resolve())
    resolved_template = resolve_template(root, template=template, language=language)
    prompt = build_prompt(context, resolved_template, language=language)
    return prompt, context, resolved_template

PromptLanguage

Bases: StrEnum

Language of the bundled template and of the prompt's instructions.

Only the bundled template is translated: a repository template is used verbatim in whatever language it was written in, since it is that repository's contract.

GitError

Bases: RuntimeError

A git invocation failed, or the repository lacks what was asked.

Carries the command's own stderr so the caller can print the reason git gave instead of a generic failure.


CLI

register_commands

register_commands(app: Typer) -> None

Register the quality gate on an existing Typer application.

Parameters:

Name Type Description Default
app Typer

The application to extend. Its own name and help text are left untouched; only commands are added.

required
Example
import typer

from tempest_cli.main import register_commands

cli: typer.Typer = typer.Typer(name="mytool")
register_commands(cli)
Source code in tempest_cli/main.py
def register_commands(app: typer.Typer) -> None:
    """Register the quality gate on an existing Typer application.

    Args:
        app (typer.Typer): The application to extend. Its own name and
            help text are left untouched; only commands are added.

    Example:
        ```python
        import typer

        from tempest_cli.main import register_commands

        cli: typer.Typer = typer.Typer(name="mytool")
        register_commands(cli)
        ```
    """

    @app.command("lint")
    def lint_cmd(
        target: Annotated[
            str,
            typer.Argument(help="Path to lint. Defaults to the current directory."),
        ] = ".",
        strictness: Annotated[str | None, _strictness_option()] = None,
    ) -> None:
        """Run ``ruff check`` on the target."""
        config = _resolve_config(target, strictness)
        raise typer.Exit(lint_module.run_ruff_check(target, config=config))

    @app.command("fix")
    def fix_cmd(
        target: Annotated[
            str,
            typer.Argument(help="Path to fix. Defaults to the current directory."),
        ] = ".",
        unsafe: Annotated[
            bool,
            typer.Option(
                "--unsafe",
                help=(
                    "Also apply ruff's unsafe autofixes (rules with possible "
                    "behavior changes). Off by default — review the diff after "
                    "enabling."
                ),
            ),
        ] = False,
        strictness: Annotated[str | None, _strictness_option()] = None,
    ) -> None:
        """Apply every ruff autofix + format the target in one pass.

        Equivalent to running ``ruff check --fix`` followed by ``ruff
        format``: sorts and dedupes imports, drops unused imports,
        normalizes string quotes, removes trailing whitespace, normalizes
        indentation, line length and blank lines.
        """
        config = _resolve_config(target, strictness)
        raise typer.Exit(lint_module.run_ruff_fix(target, unsafe=unsafe, config=config))

    @app.command("format")
    def format_cmd(
        target: Annotated[
            str,
            typer.Argument(help="Path to format. Defaults to the current directory."),
        ] = ".",
    ) -> None:
        """Run ``ruff format`` on the target (writes files)."""
        raise typer.Exit(lint_module.run_ruff_format(target, check=False))

    @app.command("fmt-check")
    def fmt_check_cmd(
        target: Annotated[
            str,
            typer.Argument(help="Path to inspect. Defaults to the current directory."),
        ] = ".",
    ) -> None:
        """Run ``ruff format --check`` on the target (read-only)."""
        raise typer.Exit(lint_module.run_ruff_format(target, check=True))

    @app.command("type")
    def type_cmd(
        target: Annotated[
            str,
            typer.Argument(help="Package/path to type-check."),
        ] = ".",
        strictness: Annotated[str | None, _strictness_option()] = None,
    ) -> None:
        """Run ``mypy`` against the target."""
        config = _resolve_config(target, strictness)
        raise typer.Exit(lint_module.run_mypy(target, config=config))

    @app.command("test")
    def test_cmd(
        target: Annotated[
            str | None,
            typer.Argument(help="Optional pytest path filter."),
        ] = None,
    ) -> None:
        """Run ``pytest`` (forwarding the optional path argument)."""
        raise typer.Exit(lint_module.run_pytest(target))

    @app.command("check")
    def check_cmd(
        target: Annotated[
            str,
            typer.Argument(help="Path to inspect. Defaults to the current directory."),
        ] = ".",
        strictness: Annotated[str | None, _strictness_option()] = None,
    ) -> None:
        """Run the full quality gate (lint + fmt-check + type + test)."""
        config = _resolve_config(target, strictness)
        raise typer.Exit(lint_module.run_full_check(target, config=config))

    @app.command("pr-prompt")
    def pr_prompt_cmd(
        ctx: typer.Context,
        base: Annotated[
            str,
            typer.Argument(
                help="Base ref the pull request targets. When the local ref is "
                "missing, 'origin/<base>' is tried before failing.",
            ),
        ] = pr_prompt_module.DEFAULT_BASE,
        head: Annotated[
            str | None,
            typer.Option(
                "--head",
                help="Branch to describe. Defaults to the checked-out one "
                "(the short sha when HEAD is detached).",
            ),
        ] = None,
        out: Annotated[
            Path | None,
            typer.Option(
                "--out",
                "-o",
                help="Write the prompt to this file instead of stdout.",
            ),
        ] = None,
        template: Annotated[
            Path | None,
            typer.Option(
                "--template",
                "-t",
                help="Template to fill in. Wins over the repository's own "
                "template and over the bundled default.",
            ),
        ] = None,
        language: Annotated[
            pr_prompt_module.PromptLanguage,
            typer.Option(
                "--lang",
                "-l",
                help="Language of the instructions and of the bundled template. "
                "A repository template is always used as written.",
            ),
        ] = pr_prompt_module.PromptLanguage.PT_BR,
        full: Annotated[
            bool,
            typer.Option(
                "--full",
                help="Excerpt every changed file, with its whole patch. Lifts "
                "both bounds at once; cannot be combined with --max-files / "
                "--max-chars.",
            ),
        ] = False,
        max_files: Annotated[
            int,
            typer.Option(
                "--max-files",
                min=0,
                help="How many files contribute a patch excerpt, most-changed "
                "first. 0 keeps the file list and drops every patch.",
            ),
        ] = pr_prompt_module.DEFAULT_MAX_FILES,
        max_chars: Annotated[
            int,
            typer.Option(
                "--max-chars",
                min=1,
                help="Characters kept per patch (cut on a line boundary).",
            ),
        ] = pr_prompt_module.DEFAULT_MAX_CHARS,
        target: Annotated[
            str,
            typer.Option(
                "--path",
                "-p",
                help="Directory inside the repository to read. Defaults to the "
                "current working directory.",
            ),
        ] = ".",
    ) -> None:
        """Build the prompt that makes an AI fill this branch's PR description.

        The prompt carries three things: the pull-request template (the
        repository's own when it has one, otherwise the bundled PT-BR /
        EN-US default), the rules that stop the model from returning the
        template with its placeholders still in it, and the branch
        context — commit subjects, the changed-file list and a bounded
        excerpt of each file's patch.

        It goes to stdout, so it pipes into whichever assistant you run::

            tempest-cli pr-prompt | claude -p
            tempest-cli pr-prompt develop --lang en --out pr_prompt.txt

        Diffs are read as ``base...head`` — the merge-base diff the forge
        shows on the pull request — so commits that landed on the base
        after the branch started are not attributed to it.

        The commit list and the changed-file list are always complete;
        only the patch excerpts are bounded, and whatever ``--max-files``
        / ``--max-chars`` leave out is stated inside the prompt, so a
        partial diff reads as partial instead of as the whole change.
        ``--full`` lifts both bounds for a branch small enough to send
        whole.

        Raises:
            typer.Exit: ``2`` when ``--full`` is combined with an explicit
                bound, when git fails or when a ref does not resolve;
                ``1`` when the comparison holds no commit and no changed
                file.
        """
        if full:
            conflicting = [
                name
                for option, name in (
                    ("max_files", "--max-files"),
                    ("max_chars", "--max-chars"),
                )
                if getattr(ctx.get_parameter_source(option), "name", "")
                == "COMMANDLINE"
            ]
            if conflicting:
                typer.secho(
                    f"error: --full already lifts every bound; drop "
                    f"{' and '.join(conflicting)}.",
                    fg="red",
                    err=True,
                )
                raise typer.Exit(2)

        try:
            prompt, context, resolved = pr_prompt_module.generate_pr_prompt(
                base=base,
                head=head,
                cwd=Path(target).expanduser(),
                template=template,
                language=language,
                max_files=None if full else max_files,
                max_chars=None if full else max_chars,
            )
        except pr_prompt_module.GitError as exc:
            typer.secho(f"error: {exc}", fg="red", err=True)
            raise typer.Exit(2) from exc

        if not context.commits and not context.files:
            typer.secho(
                f"error: `{context.head}` adds nothing over `{context.base}` — "
                "no commits and no changed files. Check the base ref.",
                fg="red",
                err=True,
            )
            raise typer.Exit(1)

        if out is not None:
            destination = out.expanduser()
            destination.write_text(prompt, encoding="utf-8")
            typer.secho(f"wrote {destination}", fg="green", err=True)
        else:
            typer.echo(prompt, nl=False)

        typer.secho(f"template: {resolved.source}", fg="cyan", err=True)
        omitted = (
            f", {context.omitted_files} file(s) without a patch"
            if context.omitted_files
            else ""
        )
        typer.secho(
            f"{len(context.commits)} commit(s), {len(context.files)} changed "
            f"file(s), {len(context.excerpts)} excerpt(s){omitted}.",
            fg="cyan",
            err=True,
        )