tempest-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ """Framework-agnostic quality gate for Python projects.
2
+
3
+ ``tempest-cli`` runs ruff, mypy and pytest behind one command, with a
4
+ typing-strictness dial read from ``[tool.tempest]`` in the project's
5
+ ``pyproject.toml`` — and a generator for the prompt that makes an AI fill
6
+ a pull-request description from the branch's own diff.
7
+
8
+ It knows nothing about any web framework. The only runtime dependency is
9
+ ``typer``; the tools themselves are invoked from the active environment,
10
+ so a project pins the ruff and mypy versions it wants.
11
+
12
+ ```bash
13
+ tempest-cli check # lint + fmt-check + type + test
14
+ tempest-cli fix # every ruff autofix, then format
15
+ tempest-cli type -s strict # override the configured strictness
16
+ tempest-cli pr-prompt | claude -p
17
+ ```
18
+
19
+ Everything is importable too, for a project that would rather wire the
20
+ gate into its own tooling:
21
+
22
+ ```python
23
+ from tempest_cli import load_tempest_config, run_full_check
24
+
25
+ config = load_tempest_config()
26
+ exit_code = run_full_check(".", config=config)
27
+ ```
28
+
29
+ And :func:`tempest_cli.main.register_commands` mounts the whole gate onto
30
+ an existing :class:`typer.Typer`, so another CLI can expose these
31
+ commands under its own name without copying them.
32
+ """
33
+
34
+ from tempest_cli.config import DEFAULT_TYPING_STRICTNESS as DEFAULT_TYPING_STRICTNESS
35
+ from tempest_cli.config import TempestConfig as TempestConfig
36
+ from tempest_cli.config import TypingStrictness as TypingStrictness
37
+ from tempest_cli.config import find_pyproject as find_pyproject
38
+ from tempest_cli.config import load_tempest_config as load_tempest_config
39
+ from tempest_cli.lint import resolve_tool as resolve_tool
40
+ from tempest_cli.lint import run_full_check as run_full_check
41
+ from tempest_cli.lint import run_mypy as run_mypy
42
+ from tempest_cli.lint import run_pytest as run_pytest
43
+ from tempest_cli.lint import run_ruff_check as run_ruff_check
44
+ from tempest_cli.lint import run_ruff_fix as run_ruff_fix
45
+ from tempest_cli.lint import run_ruff_format as run_ruff_format
46
+ from tempest_cli.pr_prompt import GitError as GitError
47
+ from tempest_cli.pr_prompt import PromptLanguage as PromptLanguage
48
+ from tempest_cli.pr_prompt import generate_pr_prompt as generate_pr_prompt
49
+
50
+ __version__: str = "0.1.0"
51
+ """Installed package version."""
52
+
53
+ __all__: list[str] = [
54
+ "DEFAULT_TYPING_STRICTNESS",
55
+ "GitError",
56
+ "PromptLanguage",
57
+ "TempestConfig",
58
+ "TypingStrictness",
59
+ "__version__",
60
+ "find_pyproject",
61
+ "generate_pr_prompt",
62
+ "load_tempest_config",
63
+ "resolve_tool",
64
+ "run_full_check",
65
+ "run_mypy",
66
+ "run_pytest",
67
+ "run_ruff_check",
68
+ "run_ruff_fix",
69
+ "run_ruff_format",
70
+ ]
@@ -0,0 +1,39 @@
1
+ | Has Script? | New Env Vars |
2
+ | :---------: | :----------: |
3
+ | Yes/No | Yes/No |
4
+
5
+ > :warning: **NOTE**
6
+ > Use this section to highlight what the reviewer must not miss:
7
+ > - Dependency on other PRs
8
+ > - New environment variables
9
+ > - Reasons for extra attention
10
+
11
+ ## Problem
12
+
13
+ _What problem are you trying to solve?_
14
+
15
+ ## Solution
16
+
17
+ _How did you solve it?_
18
+
19
+ ## Screenshots
20
+
21
+ **Screenshot description**:
22
+ [insert screenshot here]
23
+
24
+ ## Other changes
25
+
26
+ (e.g. bugfixes, UI tweaks, improvements, refactors, etc)
27
+
28
+ ## Deploy notes
29
+
30
+ _Notes about deploying these changes._
31
+
32
+ **New Environment Variables**:
33
+ - `ENV_VAR`: details
34
+
35
+ **New Scripts and/or Background Tasks**:
36
+ - `script`: details
37
+
38
+ **New Dependencies**:
39
+ - `dependency`: details
@@ -0,0 +1,39 @@
1
+ | Tem Script? | Novas Env Vars |
2
+ | :---------: | :------------: |
3
+ | Sim/Não | Sim/Não |
4
+
5
+ > :warning: **NOTA**
6
+ > Use esta seção para destacar pontos importantes sobre o PR:
7
+ > - Dependência de outros PRs
8
+ > - Novas variáveis de ambiente
9
+ > - Motivos para atenção especial
10
+
11
+ ## Problema
12
+
13
+ _Qual é o problema que você está tentando resolver?_
14
+
15
+ ## Solução
16
+
17
+ _Como você resolveu o problema?_
18
+
19
+ ## Screenshots
20
+
21
+ **Descrição do Screenshot**:
22
+ [insira o screenshot aqui]
23
+
24
+ ## Outras mudanças
25
+
26
+ (e.g. bugfixes, alterações na UI, melhorias, refatorações, etc)
27
+
28
+ ## Notas sobre deploy
29
+
30
+ _Notas sobre a implantação das alterações._
31
+
32
+ **Novas Variáveis de Ambiente**:
33
+ - `ENV_VAR`: detalhes
34
+
35
+ **Novos Scripts e/ou Tarefas de Background**:
36
+ - `script`: detalhes
37
+
38
+ **Novas Dependências**:
39
+ - `dependency`: detalhes
tempest_cli/config.py ADDED
@@ -0,0 +1,164 @@
1
+ """``[tool.tempest]`` configuration read from ``pyproject.toml``.
2
+
3
+ The quality gates (``tempest-cli lint`` / ``fix`` / ``type`` / ``check``)
4
+ let a project dial how strictly typing is enforced without editing the
5
+ gate commands themselves. The single knob is ``typing_strictness`` under
6
+ ``[tool.tempest]``:
7
+
8
+ ```toml
9
+ [tool.tempest]
10
+ typing_strictness = "strict" # lenient | standard | strict
11
+ ```
12
+
13
+ Each level layers *additional* ruff and mypy flags on top of whatever
14
+ ``[tool.ruff]`` / ``[tool.mypy]`` already declares -- it never relaxes
15
+ the project's own configuration. ``Any`` is always a valid annotation,
16
+ so ANN401 is never enabled at any level: the levels enforce that things
17
+ *are* annotated, never that they avoid ``Any``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import tomllib
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Literal, get_args
26
+
27
+ TypingStrictness = Literal["lenient", "standard", "strict"]
28
+ """Allowed values for ``[tool.tempest] typing_strictness``."""
29
+
30
+ DEFAULT_TYPING_STRICTNESS: TypingStrictness = "standard"
31
+ """Level applied when the key is absent or no ``pyproject.toml`` is found."""
32
+
33
+ # ANN rules layered onto ruff per level. ANN401 (Any) is intentionally
34
+ # never included -- ``Any`` is a valid annotation. ANN002/ANN003
35
+ # (``*args`` / ``**kwargs``) are also left out as noise.
36
+ _RUFF_ANN_BY_LEVEL: dict[TypingStrictness, list[str]] = {
37
+ "lenient": [],
38
+ "standard": ["ANN001", "ANN201", "ANN202", "ANN205", "ANN206"],
39
+ "strict": [
40
+ "ANN001",
41
+ "ANN201",
42
+ "ANN202",
43
+ "ANN204",
44
+ "ANN205",
45
+ "ANN206",
46
+ ],
47
+ }
48
+
49
+ # mypy flags layered on per level (additive over [tool.mypy]).
50
+ _MYPY_FLAGS_BY_LEVEL: dict[TypingStrictness, list[str]] = {
51
+ "lenient": [],
52
+ "standard": ["--disallow-untyped-defs", "--disallow-incomplete-defs"],
53
+ "strict": ["--strict"],
54
+ }
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class TempestConfig:
59
+ """Resolved ``[tool.tempest]`` settings.
60
+
61
+ Attributes:
62
+ typing_strictness (TypingStrictness): How strictly the CLI gates
63
+ enforce typing. One of ``"lenient"``, ``"standard"``,
64
+ ``"strict"``.
65
+ """
66
+
67
+ typing_strictness: TypingStrictness = DEFAULT_TYPING_STRICTNESS
68
+
69
+ def ruff_ann_select(self) -> list[str]:
70
+ """Return the ANN rule codes to add to ruff for this level.
71
+
72
+ Returns:
73
+ list[str]: Rule codes for ``--extend-select`` (empty for
74
+ ``"lenient"``). ANN401 is never present.
75
+ """
76
+ return list(_RUFF_ANN_BY_LEVEL[self.typing_strictness])
77
+
78
+ def mypy_flags(self) -> list[str]:
79
+ """Return the extra mypy flags to add for this level.
80
+
81
+ Returns:
82
+ list[str]: Flags layered on top of the project's
83
+ ``[tool.mypy]`` config (empty for ``"lenient"``).
84
+ """
85
+ return list(_MYPY_FLAGS_BY_LEVEL[self.typing_strictness])
86
+
87
+
88
+ def find_pyproject(start: Path | None = None) -> Path | None:
89
+ """Locate the nearest ``pyproject.toml`` walking up from ``start``.
90
+
91
+ Args:
92
+ start (Path | None): Directory to begin the search. Defaults to
93
+ the current working directory.
94
+
95
+ Returns:
96
+ Path | None: The path to the first ``pyproject.toml`` found in
97
+ ``start`` or an ancestor, or ``None`` when none exists.
98
+ """
99
+ current = (start or Path.cwd()).resolve()
100
+ for directory in (current, *current.parents):
101
+ candidate = directory / "pyproject.toml"
102
+ if candidate.is_file():
103
+ return candidate
104
+ return None
105
+
106
+
107
+ def _coerce_strictness(value: object, *, source: str) -> TypingStrictness:
108
+ """Validate a raw ``typing_strictness`` value.
109
+
110
+ Args:
111
+ value (object): The raw value read from the TOML table.
112
+ source (str): Human-readable origin used in the error message.
113
+
114
+ Returns:
115
+ TypingStrictness: The validated level.
116
+
117
+ Raises:
118
+ ValueError: When ``value`` is not one of the allowed levels.
119
+ """
120
+ allowed = get_args(TypingStrictness)
121
+ if value not in allowed:
122
+ allowed_str = ", ".join(repr(level) for level in allowed)
123
+ raise ValueError(
124
+ f"{source}: invalid typing_strictness {value!r}; "
125
+ f"expected one of {allowed_str}."
126
+ )
127
+ return value # type: ignore[return-value]
128
+
129
+
130
+ def load_tempest_config(start: Path | None = None) -> TempestConfig:
131
+ """Load ``[tool.tempest]`` from the nearest ``pyproject.toml``.
132
+
133
+ Args:
134
+ start (Path | None): Directory to begin the search. Defaults to
135
+ the current working directory.
136
+
137
+ Returns:
138
+ TempestConfig: The resolved config. Falls back to defaults when
139
+ no ``pyproject.toml`` or no ``[tool.tempest]`` table is found.
140
+
141
+ Raises:
142
+ ValueError: When ``typing_strictness`` is present but invalid.
143
+ """
144
+ pyproject = find_pyproject(start)
145
+ if pyproject is None:
146
+ return TempestConfig()
147
+ with pyproject.open("rb") as handle:
148
+ data = tomllib.load(handle)
149
+ table = data.get("tool", {}).get("tempest", {})
150
+ strictness = (
151
+ _coerce_strictness(table["typing_strictness"], source=str(pyproject))
152
+ if "typing_strictness" in table
153
+ else DEFAULT_TYPING_STRICTNESS
154
+ )
155
+ return TempestConfig(typing_strictness=strictness)
156
+
157
+
158
+ __all__: list[str] = [
159
+ "DEFAULT_TYPING_STRICTNESS",
160
+ "TempestConfig",
161
+ "TypingStrictness",
162
+ "find_pyproject",
163
+ "load_tempest_config",
164
+ ]
tempest_cli/lint.py ADDED
@@ -0,0 +1,238 @@
1
+ """Quality-gate helpers backing ``tempest lint``/``check``/etc."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+
9
+ import typer
10
+
11
+ from tempest_cli.config import TempestConfig
12
+
13
+
14
+ def _ruff_ann_args(config: TempestConfig | None) -> list[str]:
15
+ """Build the ruff ``--extend-select`` args for ``config``'s level.
16
+
17
+ Args:
18
+ config (TempestConfig | None): The resolved config, or ``None``
19
+ to use the default level.
20
+
21
+ Returns:
22
+ list[str]: ``["--extend-select", "ANN001,..."]`` for a level that
23
+ adds ANN rules, or ``[]`` for the lenient level.
24
+ """
25
+ codes = (config or TempestConfig()).ruff_ann_select()
26
+ if not codes:
27
+ return []
28
+ return ["--extend-select", ",".join(codes)]
29
+
30
+
31
+ def resolve_tool(executable: str) -> list[str] | None:
32
+ """Return an argv prefix invoking ``executable`` or ``None`` when absent.
33
+
34
+ Public because callers outside the gate need the same lookup — the
35
+ SDK's OpenAPI code generator formats what it emits with the project's
36
+ own ruff, and reimplementing the PATH/``uv run`` fallback there would
37
+ be a second answer to the same question.
38
+
39
+ Preference order:
40
+
41
+ 1. ``executable`` available on ``PATH`` directly (already activated venv,
42
+ global install, etc.).
43
+ 2. ``uv run <executable>`` when ``uv`` is on the ``PATH`` (handles
44
+ project-local virtualenvs without requiring activation).
45
+
46
+ Args:
47
+ executable (str): The command name (``ruff``/``mypy``/``pytest``).
48
+
49
+ Returns:
50
+ list[str] | None: argv prefix to extend with extra arguments, or
51
+ ``None`` when no runner could be found.
52
+ """
53
+ direct = shutil.which(executable)
54
+ if direct is not None:
55
+ return [direct]
56
+ uv = shutil.which("uv")
57
+ if uv is not None:
58
+ return [uv, "run", executable]
59
+ return None
60
+
61
+
62
+ def _execute(executable: str, args: list[str]) -> int:
63
+ """Run ``executable args`` and return its exit code.
64
+
65
+ Args:
66
+ executable (str): The command to run.
67
+ args (list[str]): Extra arguments to forward.
68
+
69
+ Returns:
70
+ int: The child process exit code. Returns ``127`` when neither
71
+ the executable nor ``uv`` is available.
72
+ """
73
+ argv = resolve_tool(executable)
74
+ if argv is None:
75
+ typer.echo(
76
+ f"error: '{executable}' is not on PATH and 'uv' is unavailable. "
77
+ f"Install it (or activate the project venv) and retry.",
78
+ err=True,
79
+ )
80
+ return 127
81
+ return subprocess.call([*argv, *args])
82
+
83
+
84
+ def run_ruff_check(target: str, *, config: TempestConfig | None = None) -> int:
85
+ """Invoke ``ruff check <target>`` with the configured ANN rules.
86
+
87
+ Args:
88
+ target (str): The path passed verbatim to ruff.
89
+ config (TempestConfig | None): Resolved ``[tool.tempest]`` config
90
+ controlling the typing-strictness ANN rules layered on. When
91
+ ``None`` the default level is used.
92
+
93
+ Returns:
94
+ int: The ruff exit code.
95
+ """
96
+ return _execute("ruff", ["check", *_ruff_ann_args(config), target])
97
+
98
+
99
+ def run_ruff_fix(
100
+ target: str,
101
+ *,
102
+ unsafe: bool = False,
103
+ config: TempestConfig | None = None,
104
+ ) -> int:
105
+ """Apply every automatic fix ruff can perform, then format the target.
106
+
107
+ Runs in two passes so the second one sees the rewritten file:
108
+
109
+ 1. ``ruff check --fix [--unsafe-fixes] <target>`` — autofix imports
110
+ (sort + dedupe), remove unused imports, normalize string quotes,
111
+ drop trailing whitespace, fix the rest of the lint rules that
112
+ have safe (or, with ``unsafe=True``, also unsafe) autofixers.
113
+ 2. ``ruff format <target>`` — normalize indentation, line length,
114
+ blank lines and trailing newlines.
115
+
116
+ Both passes always run. ``ruff check --fix`` exits non-zero whenever
117
+ *any* residual violation remains that it cannot autofix (an
118
+ over-length string/comment, an undefined name, etc.) — even though
119
+ it already rewrote everything it could. Short-circuiting on that
120
+ exit code would skip ``ruff format`` entirely, leaving the file
121
+ un-wrapped and its extra blank lines intact. So the formatter runs
122
+ unconditionally; the lint exit code is surfaced afterwards so CI
123
+ still fails on the leftover issues.
124
+
125
+ Args:
126
+ target (str): The path passed verbatim to ruff.
127
+ unsafe (bool): When True, pass ``--unsafe-fixes`` so ruff also
128
+ applies the fixes it would otherwise leave alone.
129
+ config (TempestConfig | None): Resolved ``[tool.tempest]`` config
130
+ controlling the typing-strictness ANN rules layered onto the
131
+ fix pass. When ``None`` the default level is used.
132
+
133
+ Returns:
134
+ int: ``0`` when both passes succeed with nothing left to fix;
135
+ otherwise the lint pass exit code (residual violations), or the
136
+ format pass exit code when the lint pass was clean.
137
+ """
138
+ check_args = ["check", "--fix", *_ruff_ann_args(config)]
139
+ if unsafe:
140
+ check_args.append("--unsafe-fixes")
141
+ check_args.append(target)
142
+ check_code = _execute("ruff", check_args)
143
+ format_code = _execute("ruff", ["format", target])
144
+ return check_code or format_code
145
+
146
+
147
+ def run_ruff_format(target: str, *, check: bool) -> int:
148
+ """Invoke ``ruff format`` (write or check-only).
149
+
150
+ Args:
151
+ target (str): The path passed verbatim to ruff.
152
+ check (bool): When True, run ``ruff format --check`` (read-only).
153
+
154
+ Returns:
155
+ int: The ruff exit code.
156
+ """
157
+ args = ["format"]
158
+ if check:
159
+ args.append("--check")
160
+ args.append(target)
161
+ return _execute("ruff", args)
162
+
163
+
164
+ def run_mypy(target: str, *, config: TempestConfig | None = None) -> int:
165
+ """Invoke ``mypy <target>`` with the configured strictness flags.
166
+
167
+ Args:
168
+ target (str): The path passed verbatim to mypy.
169
+ config (TempestConfig | None): Resolved ``[tool.tempest]`` config
170
+ controlling the mypy strictness flags layered on top of the
171
+ project's ``[tool.mypy]``. When ``None`` the default level is
172
+ used.
173
+
174
+ Returns:
175
+ int: The mypy exit code.
176
+ """
177
+ flags = (config or TempestConfig()).mypy_flags()
178
+ return _execute("mypy", [*flags, target])
179
+
180
+
181
+ def run_pytest(target: str | None) -> int:
182
+ """Invoke ``pytest`` with an optional target.
183
+
184
+ Args:
185
+ target (str | None): Optional pytest path filter. ``None`` runs
186
+ the default test suite.
187
+
188
+ Returns:
189
+ int: The pytest exit code.
190
+ """
191
+ args = [target] if target else []
192
+ return _execute("pytest", args)
193
+
194
+
195
+ def run_full_check(target: str, *, config: TempestConfig | None = None) -> int:
196
+ """Run the entire quality gate sequentially.
197
+
198
+ Order: ``ruff check`` → ``ruff format --check`` → ``mypy`` → ``pytest``.
199
+ Stops at the first non-zero exit code so failures surface fast.
200
+
201
+ Args:
202
+ target (str): The path inspected by ruff/mypy. Pytest always runs
203
+ against the project's configured ``testpaths``.
204
+ config (TempestConfig | None): Resolved ``[tool.tempest]`` config
205
+ controlling the ANN rules and mypy flags layered onto the
206
+ ruff/mypy steps. When ``None`` the default level is used.
207
+
208
+ Returns:
209
+ int: The first non-zero exit code, or ``0`` when every gate passed.
210
+ """
211
+ resolved = config or TempestConfig()
212
+ steps: list[tuple[str, list[str]]] = [
213
+ ("ruff", ["check", *_ruff_ann_args(resolved), target]),
214
+ ("ruff", ["format", "--check", target]),
215
+ ("mypy", [*resolved.mypy_flags(), target]),
216
+ ("pytest", []),
217
+ ]
218
+ for executable, args in steps:
219
+ typer.echo(f"$ {executable} {' '.join(args)}", err=True)
220
+ code = _execute(executable, args)
221
+ if code != 0:
222
+ return code
223
+ return 0
224
+
225
+
226
+ __all__: list[str] = [
227
+ "resolve_tool",
228
+ "run_full_check",
229
+ "run_mypy",
230
+ "run_pytest",
231
+ "run_ruff_check",
232
+ "run_ruff_fix",
233
+ "run_ruff_format",
234
+ ]
235
+
236
+
237
+ if __name__ == "__main__": # pragma: no cover - manual invocation only
238
+ sys.exit(run_full_check("."))