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/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()