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.
- tempest_cli/__init__.py +70 -0
- tempest_cli/_templates/pull_request_template.en-US.md +39 -0
- tempest_cli/_templates/pull_request_template.pt-BR.md +39 -0
- tempest_cli/config.py +164 -0
- tempest_cli/lint.py +238 -0
- tempest_cli/main.py +411 -0
- tempest_cli/pr_prompt.py +779 -0
- tempest_cli/py.typed +0 -0
- tempest_cli-0.1.0.dist-info/METADATA +167 -0
- tempest_cli-0.1.0.dist-info/RECORD +13 -0
- tempest_cli-0.1.0.dist-info/WHEEL +4 -0
- tempest_cli-0.1.0.dist-info/entry_points.txt +2 -0
- tempest_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
tempest_cli/main.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""Typer entry point for the ``tempest-cli`` quality gate.
|
|
2
|
+
|
|
3
|
+
The commands are registered through :func:`register_commands`, which
|
|
4
|
+
takes any :class:`typer.Typer` — the executable's own app is built by
|
|
5
|
+
calling it on a fresh one. That is what lets another CLI (the
|
|
6
|
+
``tempest-fastapi-sdk`` one, for instance) expose the very same gate
|
|
7
|
+
under its own name without copying a single command body: one call, and
|
|
8
|
+
``lint`` / ``fix`` / ``format`` / ``fmt-check`` / ``type`` / ``test`` /
|
|
9
|
+
``check`` / ``pr-prompt`` behave identically in both.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Annotated, cast, get_args
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from tempest_cli import lint as lint_module
|
|
20
|
+
from tempest_cli import pr_prompt as pr_prompt_module
|
|
21
|
+
from tempest_cli.config import TempestConfig, TypingStrictness, load_tempest_config
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _strictness_option() -> typer.models.OptionInfo:
|
|
25
|
+
"""Build a fresh ``--strictness`` option.
|
|
26
|
+
|
|
27
|
+
A new :class:`typer.models.OptionInfo` per command is required —
|
|
28
|
+
sharing a single instance across commands breaks Typer's option
|
|
29
|
+
parsing.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
typer.models.OptionInfo: The configured option.
|
|
33
|
+
"""
|
|
34
|
+
return cast(
|
|
35
|
+
"typer.models.OptionInfo",
|
|
36
|
+
typer.Option(
|
|
37
|
+
"--strictness",
|
|
38
|
+
"-s",
|
|
39
|
+
help=(
|
|
40
|
+
"Typing strictness for this run: lenient | standard | strict. "
|
|
41
|
+
"Overrides [tool.tempest] typing_strictness in pyproject.toml."
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _resolve_config(target: str, strictness: str | None) -> TempestConfig:
|
|
48
|
+
"""Resolve the typing config, applying a CLI ``--strictness`` override.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
target (str): The path being linted; its directory anchors the
|
|
52
|
+
``pyproject.toml`` lookup.
|
|
53
|
+
strictness (str | None): The ``--strictness`` flag value, or
|
|
54
|
+
``None`` to use ``[tool.tempest]`` / the default.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
TempestConfig: The resolved config.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
typer.BadParameter: When ``strictness`` is not a valid level.
|
|
61
|
+
"""
|
|
62
|
+
if strictness is None:
|
|
63
|
+
return load_tempest_config(Path(target))
|
|
64
|
+
allowed = get_args(TypingStrictness)
|
|
65
|
+
if strictness not in allowed:
|
|
66
|
+
allowed_str = ", ".join(allowed)
|
|
67
|
+
raise typer.BadParameter(
|
|
68
|
+
f"invalid strictness {strictness!r}; expected one of {allowed_str}."
|
|
69
|
+
)
|
|
70
|
+
return TempestConfig(typing_strictness=cast(TypingStrictness, strictness))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def register_commands(app: typer.Typer) -> None:
|
|
74
|
+
"""Register the quality gate on an existing Typer application.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
app (typer.Typer): The application to extend. Its own name and
|
|
78
|
+
help text are left untouched; only commands are added.
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
```python
|
|
82
|
+
import typer
|
|
83
|
+
|
|
84
|
+
from tempest_cli.main import register_commands
|
|
85
|
+
|
|
86
|
+
cli: typer.Typer = typer.Typer(name="mytool")
|
|
87
|
+
register_commands(cli)
|
|
88
|
+
```
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
@app.command("lint")
|
|
92
|
+
def lint_cmd(
|
|
93
|
+
target: Annotated[
|
|
94
|
+
str,
|
|
95
|
+
typer.Argument(help="Path to lint. Defaults to the current directory."),
|
|
96
|
+
] = ".",
|
|
97
|
+
strictness: Annotated[str | None, _strictness_option()] = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Run ``ruff check`` on the target."""
|
|
100
|
+
config = _resolve_config(target, strictness)
|
|
101
|
+
raise typer.Exit(lint_module.run_ruff_check(target, config=config))
|
|
102
|
+
|
|
103
|
+
@app.command("fix")
|
|
104
|
+
def fix_cmd(
|
|
105
|
+
target: Annotated[
|
|
106
|
+
str,
|
|
107
|
+
typer.Argument(help="Path to fix. Defaults to the current directory."),
|
|
108
|
+
] = ".",
|
|
109
|
+
unsafe: Annotated[
|
|
110
|
+
bool,
|
|
111
|
+
typer.Option(
|
|
112
|
+
"--unsafe",
|
|
113
|
+
help=(
|
|
114
|
+
"Also apply ruff's unsafe autofixes (rules with possible "
|
|
115
|
+
"behavior changes). Off by default — review the diff after "
|
|
116
|
+
"enabling."
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
] = False,
|
|
120
|
+
strictness: Annotated[str | None, _strictness_option()] = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Apply every ruff autofix + format the target in one pass.
|
|
123
|
+
|
|
124
|
+
Equivalent to running ``ruff check --fix`` followed by ``ruff
|
|
125
|
+
format``: sorts and dedupes imports, drops unused imports,
|
|
126
|
+
normalizes string quotes, removes trailing whitespace, normalizes
|
|
127
|
+
indentation, line length and blank lines.
|
|
128
|
+
"""
|
|
129
|
+
config = _resolve_config(target, strictness)
|
|
130
|
+
raise typer.Exit(lint_module.run_ruff_fix(target, unsafe=unsafe, config=config))
|
|
131
|
+
|
|
132
|
+
@app.command("format")
|
|
133
|
+
def format_cmd(
|
|
134
|
+
target: Annotated[
|
|
135
|
+
str,
|
|
136
|
+
typer.Argument(help="Path to format. Defaults to the current directory."),
|
|
137
|
+
] = ".",
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Run ``ruff format`` on the target (writes files)."""
|
|
140
|
+
raise typer.Exit(lint_module.run_ruff_format(target, check=False))
|
|
141
|
+
|
|
142
|
+
@app.command("fmt-check")
|
|
143
|
+
def fmt_check_cmd(
|
|
144
|
+
target: Annotated[
|
|
145
|
+
str,
|
|
146
|
+
typer.Argument(help="Path to inspect. Defaults to the current directory."),
|
|
147
|
+
] = ".",
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Run ``ruff format --check`` on the target (read-only)."""
|
|
150
|
+
raise typer.Exit(lint_module.run_ruff_format(target, check=True))
|
|
151
|
+
|
|
152
|
+
@app.command("type")
|
|
153
|
+
def type_cmd(
|
|
154
|
+
target: Annotated[
|
|
155
|
+
str,
|
|
156
|
+
typer.Argument(help="Package/path to type-check."),
|
|
157
|
+
] = ".",
|
|
158
|
+
strictness: Annotated[str | None, _strictness_option()] = None,
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Run ``mypy`` against the target."""
|
|
161
|
+
config = _resolve_config(target, strictness)
|
|
162
|
+
raise typer.Exit(lint_module.run_mypy(target, config=config))
|
|
163
|
+
|
|
164
|
+
@app.command("test")
|
|
165
|
+
def test_cmd(
|
|
166
|
+
target: Annotated[
|
|
167
|
+
str | None,
|
|
168
|
+
typer.Argument(help="Optional pytest path filter."),
|
|
169
|
+
] = None,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Run ``pytest`` (forwarding the optional path argument)."""
|
|
172
|
+
raise typer.Exit(lint_module.run_pytest(target))
|
|
173
|
+
|
|
174
|
+
@app.command("check")
|
|
175
|
+
def check_cmd(
|
|
176
|
+
target: Annotated[
|
|
177
|
+
str,
|
|
178
|
+
typer.Argument(help="Path to inspect. Defaults to the current directory."),
|
|
179
|
+
] = ".",
|
|
180
|
+
strictness: Annotated[str | None, _strictness_option()] = None,
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Run the full quality gate (lint + fmt-check + type + test)."""
|
|
183
|
+
config = _resolve_config(target, strictness)
|
|
184
|
+
raise typer.Exit(lint_module.run_full_check(target, config=config))
|
|
185
|
+
|
|
186
|
+
@app.command("pr-prompt")
|
|
187
|
+
def pr_prompt_cmd(
|
|
188
|
+
ctx: typer.Context,
|
|
189
|
+
base: Annotated[
|
|
190
|
+
str,
|
|
191
|
+
typer.Argument(
|
|
192
|
+
help="Base ref the pull request targets. When the local ref is "
|
|
193
|
+
"missing, 'origin/<base>' is tried before failing.",
|
|
194
|
+
),
|
|
195
|
+
] = pr_prompt_module.DEFAULT_BASE,
|
|
196
|
+
head: Annotated[
|
|
197
|
+
str | None,
|
|
198
|
+
typer.Option(
|
|
199
|
+
"--head",
|
|
200
|
+
help="Branch to describe. Defaults to the checked-out one "
|
|
201
|
+
"(the short sha when HEAD is detached).",
|
|
202
|
+
),
|
|
203
|
+
] = None,
|
|
204
|
+
out: Annotated[
|
|
205
|
+
Path | None,
|
|
206
|
+
typer.Option(
|
|
207
|
+
"--out",
|
|
208
|
+
"-o",
|
|
209
|
+
help="Write the prompt to this file instead of stdout.",
|
|
210
|
+
),
|
|
211
|
+
] = None,
|
|
212
|
+
template: Annotated[
|
|
213
|
+
Path | None,
|
|
214
|
+
typer.Option(
|
|
215
|
+
"--template",
|
|
216
|
+
"-t",
|
|
217
|
+
help="Template to fill in. Wins over the repository's own "
|
|
218
|
+
"template and over the bundled default.",
|
|
219
|
+
),
|
|
220
|
+
] = None,
|
|
221
|
+
language: Annotated[
|
|
222
|
+
pr_prompt_module.PromptLanguage,
|
|
223
|
+
typer.Option(
|
|
224
|
+
"--lang",
|
|
225
|
+
"-l",
|
|
226
|
+
help="Language of the instructions and of the bundled template. "
|
|
227
|
+
"A repository template is always used as written.",
|
|
228
|
+
),
|
|
229
|
+
] = pr_prompt_module.PromptLanguage.PT_BR,
|
|
230
|
+
full: Annotated[
|
|
231
|
+
bool,
|
|
232
|
+
typer.Option(
|
|
233
|
+
"--full",
|
|
234
|
+
help="Excerpt every changed file, with its whole patch. Lifts "
|
|
235
|
+
"both bounds at once; cannot be combined with --max-files / "
|
|
236
|
+
"--max-chars.",
|
|
237
|
+
),
|
|
238
|
+
] = False,
|
|
239
|
+
max_files: Annotated[
|
|
240
|
+
int,
|
|
241
|
+
typer.Option(
|
|
242
|
+
"--max-files",
|
|
243
|
+
min=0,
|
|
244
|
+
help="How many files contribute a patch excerpt, most-changed "
|
|
245
|
+
"first. 0 keeps the file list and drops every patch.",
|
|
246
|
+
),
|
|
247
|
+
] = pr_prompt_module.DEFAULT_MAX_FILES,
|
|
248
|
+
max_chars: Annotated[
|
|
249
|
+
int,
|
|
250
|
+
typer.Option(
|
|
251
|
+
"--max-chars",
|
|
252
|
+
min=1,
|
|
253
|
+
help="Characters kept per patch (cut on a line boundary).",
|
|
254
|
+
),
|
|
255
|
+
] = pr_prompt_module.DEFAULT_MAX_CHARS,
|
|
256
|
+
target: Annotated[
|
|
257
|
+
str,
|
|
258
|
+
typer.Option(
|
|
259
|
+
"--path",
|
|
260
|
+
"-p",
|
|
261
|
+
help="Directory inside the repository to read. Defaults to the "
|
|
262
|
+
"current working directory.",
|
|
263
|
+
),
|
|
264
|
+
] = ".",
|
|
265
|
+
) -> None:
|
|
266
|
+
"""Build the prompt that makes an AI fill this branch's PR description.
|
|
267
|
+
|
|
268
|
+
The prompt carries three things: the pull-request template (the
|
|
269
|
+
repository's own when it has one, otherwise the bundled PT-BR /
|
|
270
|
+
EN-US default), the rules that stop the model from returning the
|
|
271
|
+
template with its placeholders still in it, and the branch
|
|
272
|
+
context — commit subjects, the changed-file list and a bounded
|
|
273
|
+
excerpt of each file's patch.
|
|
274
|
+
|
|
275
|
+
It goes to stdout, so it pipes into whichever assistant you run::
|
|
276
|
+
|
|
277
|
+
tempest-cli pr-prompt | claude -p
|
|
278
|
+
tempest-cli pr-prompt develop --lang en --out pr_prompt.txt
|
|
279
|
+
|
|
280
|
+
Diffs are read as ``base...head`` — the merge-base diff the forge
|
|
281
|
+
shows on the pull request — so commits that landed on the base
|
|
282
|
+
after the branch started are not attributed to it.
|
|
283
|
+
|
|
284
|
+
The commit list and the changed-file list are always complete;
|
|
285
|
+
only the patch excerpts are bounded, and whatever ``--max-files``
|
|
286
|
+
/ ``--max-chars`` leave out is stated inside the prompt, so a
|
|
287
|
+
partial diff reads as partial instead of as the whole change.
|
|
288
|
+
``--full`` lifts both bounds for a branch small enough to send
|
|
289
|
+
whole.
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
typer.Exit: ``2`` when ``--full`` is combined with an explicit
|
|
293
|
+
bound, when git fails or when a ref does not resolve;
|
|
294
|
+
``1`` when the comparison holds no commit and no changed
|
|
295
|
+
file.
|
|
296
|
+
"""
|
|
297
|
+
if full:
|
|
298
|
+
conflicting = [
|
|
299
|
+
name
|
|
300
|
+
for option, name in (
|
|
301
|
+
("max_files", "--max-files"),
|
|
302
|
+
("max_chars", "--max-chars"),
|
|
303
|
+
)
|
|
304
|
+
if getattr(ctx.get_parameter_source(option), "name", "")
|
|
305
|
+
== "COMMANDLINE"
|
|
306
|
+
]
|
|
307
|
+
if conflicting:
|
|
308
|
+
typer.secho(
|
|
309
|
+
f"error: --full already lifts every bound; drop "
|
|
310
|
+
f"{' and '.join(conflicting)}.",
|
|
311
|
+
fg="red",
|
|
312
|
+
err=True,
|
|
313
|
+
)
|
|
314
|
+
raise typer.Exit(2)
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
prompt, context, resolved = pr_prompt_module.generate_pr_prompt(
|
|
318
|
+
base=base,
|
|
319
|
+
head=head,
|
|
320
|
+
cwd=Path(target).expanduser(),
|
|
321
|
+
template=template,
|
|
322
|
+
language=language,
|
|
323
|
+
max_files=None if full else max_files,
|
|
324
|
+
max_chars=None if full else max_chars,
|
|
325
|
+
)
|
|
326
|
+
except pr_prompt_module.GitError as exc:
|
|
327
|
+
typer.secho(f"error: {exc}", fg="red", err=True)
|
|
328
|
+
raise typer.Exit(2) from exc
|
|
329
|
+
|
|
330
|
+
if not context.commits and not context.files:
|
|
331
|
+
typer.secho(
|
|
332
|
+
f"error: `{context.head}` adds nothing over `{context.base}` — "
|
|
333
|
+
"no commits and no changed files. Check the base ref.",
|
|
334
|
+
fg="red",
|
|
335
|
+
err=True,
|
|
336
|
+
)
|
|
337
|
+
raise typer.Exit(1)
|
|
338
|
+
|
|
339
|
+
if out is not None:
|
|
340
|
+
destination = out.expanduser()
|
|
341
|
+
destination.write_text(prompt, encoding="utf-8")
|
|
342
|
+
typer.secho(f"wrote {destination}", fg="green", err=True)
|
|
343
|
+
else:
|
|
344
|
+
typer.echo(prompt, nl=False)
|
|
345
|
+
|
|
346
|
+
typer.secho(f"template: {resolved.source}", fg="cyan", err=True)
|
|
347
|
+
omitted = (
|
|
348
|
+
f", {context.omitted_files} file(s) without a patch"
|
|
349
|
+
if context.omitted_files
|
|
350
|
+
else ""
|
|
351
|
+
)
|
|
352
|
+
typer.secho(
|
|
353
|
+
f"{len(context.commits)} commit(s), {len(context.files)} changed "
|
|
354
|
+
f"file(s), {len(context.excerpts)} excerpt(s){omitted}.",
|
|
355
|
+
fg="cyan",
|
|
356
|
+
err=True,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _print_version(value: bool) -> None:
|
|
361
|
+
"""Print the package version and exit.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
value (bool): True when ``--version`` is passed.
|
|
365
|
+
|
|
366
|
+
Raises:
|
|
367
|
+
typer.Exit: Always when ``value`` is True.
|
|
368
|
+
"""
|
|
369
|
+
if value:
|
|
370
|
+
from tempest_cli import __version__
|
|
371
|
+
|
|
372
|
+
typer.echo(f"tempest-cli {__version__}")
|
|
373
|
+
raise typer.Exit()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
app: typer.Typer = typer.Typer(
|
|
377
|
+
name="tempest-cli",
|
|
378
|
+
help=(
|
|
379
|
+
"Quality gate for any Python project — ruff, mypy and pytest behind "
|
|
380
|
+
"one command, with a typing-strictness dial read from "
|
|
381
|
+
"[tool.tempest] in pyproject.toml."
|
|
382
|
+
),
|
|
383
|
+
no_args_is_help=True,
|
|
384
|
+
add_completion=False,
|
|
385
|
+
)
|
|
386
|
+
register_commands(app)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@app.callback()
|
|
390
|
+
def _root(
|
|
391
|
+
_version: Annotated[
|
|
392
|
+
bool,
|
|
393
|
+
typer.Option(
|
|
394
|
+
"--version",
|
|
395
|
+
"-v",
|
|
396
|
+
callback=_print_version,
|
|
397
|
+
is_eager=True,
|
|
398
|
+
help="Show the installed version and exit.",
|
|
399
|
+
),
|
|
400
|
+
] = False,
|
|
401
|
+
) -> None:
|
|
402
|
+
"""Run the quality gate."""
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def main() -> None:
|
|
406
|
+
"""Console-script entry point."""
|
|
407
|
+
app()
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
if __name__ == "__main__": # pragma: no cover - manual invocation only
|
|
411
|
+
main()
|