prgen-cli 0.2.1__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.
prgen/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from prgen.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
prgen/about.py ADDED
@@ -0,0 +1,16 @@
1
+ """Project attribution (shown in CLI and Rich output)."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ AUTHOR = "Jean Paul Fernandez"
6
+ REPO_URL = "https://github.com/jpxoi/prgen"
7
+
8
+ # PyPI distribution name (import package remains `prgen`).
9
+ _DISTRIBUTION_NAME = "prgen-cli"
10
+
11
+
12
+ def package_version() -> str:
13
+ try:
14
+ return version(_DISTRIBUTION_NAME)
15
+ except PackageNotFoundError:
16
+ return "0.0.0"
prgen/api_errors.py ADDED
@@ -0,0 +1,81 @@
1
+ """User-facing messages for LLM provider failures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ Backend = Literal["openai", "gemini"]
8
+
9
+
10
+ def _trim(msg: str, max_len: int = 220) -> str:
11
+ one = " ".join(msg.split())
12
+ if len(one) <= max_len:
13
+ return one
14
+ return one[: max_len - 1] + "…"
15
+
16
+
17
+ def format_provider_error(exc: BaseException, *, backend: Backend) -> str:
18
+ """Map SDK exceptions to a short stderr-safe line (no traceback)."""
19
+ if backend == "openai":
20
+ return _openai_user_message(exc)
21
+ return _gemini_user_message(exc)
22
+
23
+
24
+ def _openai_user_message(exc: BaseException) -> str:
25
+ try:
26
+ import openai
27
+ except ImportError:
28
+ return f"OpenAI: {_trim(str(exc))}"
29
+
30
+ if isinstance(exc, openai.AuthenticationError):
31
+ return "OpenAI: invalid or revoked API key — check OPENAI_API_KEY."
32
+ if isinstance(exc, openai.PermissionDeniedError):
33
+ return "OpenAI: permission denied — account or model may not be allowed."
34
+ if isinstance(exc, openai.RateLimitError):
35
+ return "OpenAI: rate limited — wait and retry, or check your plan."
36
+ if isinstance(exc, (openai.APIConnectionError, openai.APITimeoutError)):
37
+ return "OpenAI: network error — check your connection and try again."
38
+ if isinstance(exc, openai.NotFoundError):
39
+ return "OpenAI: model not found — check --model / --tier."
40
+ if isinstance(exc, openai.BadRequestError):
41
+ return f"OpenAI: bad request — {_trim(exc.message)}"
42
+ if isinstance(exc, openai.APIStatusError):
43
+ sc = exc.status_code
44
+ if sc == 401:
45
+ return "OpenAI: unauthorized — check your API key."
46
+ if sc == 403:
47
+ return "OpenAI: forbidden — model or org may not allow this request."
48
+ if sc == 404:
49
+ return "OpenAI: not found — model or endpoint unavailable."
50
+ if sc == 429:
51
+ return "OpenAI: too many requests — retry after a short wait."
52
+ if sc >= 500:
53
+ return "OpenAI: server error — try again later."
54
+ return f"OpenAI: HTTP {sc} — {_trim(exc.message)}"
55
+ if isinstance(exc, openai.APIError):
56
+ return f"OpenAI: {_trim(exc.message)}"
57
+ return f"OpenAI: {_trim(str(exc))}"
58
+
59
+
60
+ def _gemini_user_message(exc: BaseException) -> str:
61
+ try:
62
+ from google.genai import errors as genai_errors
63
+ except ImportError:
64
+ return f"Gemini: {_trim(str(exc))}"
65
+
66
+ if isinstance(exc, genai_errors.ClientError):
67
+ code = getattr(exc, "code", None)
68
+ if code in (401, 403):
69
+ return "Gemini: API key invalid or access denied — check GOOGLE_API_KEY."
70
+ if code == 404:
71
+ return "Gemini: model or resource not found — check --model / --tier."
72
+ if code == 429:
73
+ return "Gemini: rate limited — wait and retry."
74
+ if code is not None and 400 <= int(code) < 500:
75
+ return f"Gemini: request failed (HTTP {code}). {_trim(str(exc))}"
76
+ if isinstance(exc, genai_errors.ServerError):
77
+ return "Gemini: server error — try again later."
78
+ if isinstance(exc, genai_errors.APIError):
79
+ code = getattr(exc, "code", "?")
80
+ return f"Gemini: API error (HTTP {code}) — {_trim(str(exc))}"
81
+ return f"Gemini: {_trim(str(exc))}"
prgen/cli.py ADDED
@@ -0,0 +1,518 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Annotated, cast
8
+
9
+ import typer
10
+ from click.core import ParameterSource
11
+ from rich import box
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+ from rich.prompt import Prompt
15
+ from rich.table import Table
16
+ from rich.text import Text
17
+
18
+ from prgen.about import AUTHOR, REPO_URL, package_version
19
+ from prgen.api_errors import Backend, format_provider_error
20
+ from prgen.config import (
21
+ PersistedConfigKey,
22
+ load_prgen_env,
23
+ public_config_rows,
24
+ read_cli_defaults_from_config,
25
+ read_persisted_config,
26
+ set_user_key,
27
+ unset_user_key,
28
+ user_config_path,
29
+ )
30
+ from prgen.defaults import ModelTier, ProviderChoice, model_for_tier
31
+ from prgen.git_utils import (
32
+ GitNotFoundError,
33
+ GitRefError,
34
+ ensure_git_available,
35
+ get_commits,
36
+ get_diff,
37
+ git_repo_root,
38
+ verify_base_ref,
39
+ )
40
+ from prgen.prompting import build_prompt, load_prompt_template
41
+ from prgen.ui import loading, print_pr_summary
42
+
43
+
44
+ def _version_callback(value: bool) -> None:
45
+ if value:
46
+ typer.echo(f"prgen {package_version()}")
47
+ typer.echo(f"Author: {AUTHOR}")
48
+ typer.echo(REPO_URL)
49
+ raise typer.Exit(0)
50
+
51
+
52
+ def _api_key_setup_hint() -> str:
53
+ return (
54
+ "Run: prgen config\n"
55
+ "Keys live in ~/.config/prgen/config.json (prgen config set …, or edit that file)."
56
+ )
57
+
58
+
59
+ def extract_tag(text: str, tag: str) -> str | None:
60
+ match = re.search(rf"<{tag}>\s*(.*?)\s*</{tag}>", text, re.DOTALL | re.IGNORECASE)
61
+ return match.group(1).strip() if match else None
62
+
63
+
64
+ def _print_config_table(console: Console) -> None:
65
+ rows = public_config_rows()
66
+ path = user_config_path()
67
+ console.print(f"[dim]{path}[/dim]\n")
68
+ table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))
69
+ table.add_column("Key", style="bold")
70
+ table.add_column("Value")
71
+ for key, val, is_secret in rows:
72
+ if is_secret:
73
+ value_cell = (
74
+ Text("set", style="green bold") if val == "set" else Text("unset", style="yellow")
75
+ )
76
+ elif val == "unset":
77
+ value_cell = Text("unset", style="dim")
78
+ else:
79
+ value_cell = Text(val, style="cyan")
80
+ table.add_row(key, value_cell)
81
+ console.print(table)
82
+
83
+
84
+ def run_interactive_config() -> int:
85
+ """Interactive wizard for ~/.config/prgen/config.json (all persisted keys)."""
86
+ if not sys.stdin.isatty():
87
+ print(
88
+ "prgen: `config` needs an interactive terminal (stdin is not a TTY).",
89
+ file=sys.stderr,
90
+ )
91
+ return 1
92
+
93
+ console = Console(stderr=True)
94
+ try:
95
+ data = read_persisted_config()
96
+ except ValueError as exc:
97
+ print(f"prgen: {exc}", file=sys.stderr)
98
+ return 1
99
+
100
+ console.print(
101
+ Panel.fit(
102
+ "[bold]Interactive config[/bold]\n"
103
+ f"[dim]prgen v{package_version()} · {AUTHOR}[/dim]\n\n"
104
+ "[dim]• API keys: hidden; empty = leave unchanged.\n"
105
+ "• Any field: type [bold]-[/bold] to remove the saved value.\n"
106
+ "• Defaults: Enter accepts the suggested default.\n"
107
+ "• Omit saving implicit defaults (auto / default) unless you change them.[/dim]",
108
+ title="prgen",
109
+ border_style="dim",
110
+ )
111
+ )
112
+ console.print()
113
+
114
+ updates: dict[str, str] = {}
115
+ unsets: list[str] = []
116
+
117
+ console.print("[bold]API keys[/bold]\n")
118
+ for key, label in (
119
+ ("OPENAI_API_KEY", "OpenAI"),
120
+ ("GOOGLE_API_KEY", "Google AI / Gemini"),
121
+ ):
122
+ hint = " [dim](saved)[/dim]" if (data.get(key) or "").strip() else ""
123
+ try:
124
+ line = Prompt.ask(
125
+ f"{label}{hint}",
126
+ password=True,
127
+ )
128
+ except (EOFError, KeyboardInterrupt):
129
+ console.print("\n[yellow]Aborted.[/yellow]")
130
+ return 1
131
+ line = (line or "").strip()
132
+ if line == "-":
133
+ if (data.get(key) or "").strip():
134
+ unsets.append(key)
135
+ elif line:
136
+ updates[key] = line
137
+
138
+ console.print("\n[bold]CLI defaults[/bold]\n")
139
+
140
+ cur_base = (data.get("base") or "").strip()
141
+ try:
142
+ raw_base = Prompt.ask(
143
+ "Merge base ref (git)",
144
+ default=cur_base,
145
+ ).strip()
146
+ except (EOFError, KeyboardInterrupt):
147
+ console.print("\n[yellow]Aborted.[/yellow]")
148
+ return 1
149
+ if raw_base == "-":
150
+ if cur_base:
151
+ unsets.append("base")
152
+ elif raw_base != cur_base:
153
+ if raw_base:
154
+ updates["base"] = raw_base
155
+
156
+ cur_p = (data.get("provider") or "").strip()
157
+ if cur_p not in ("auto", "openai", "gemini"):
158
+ cur_p = ""
159
+ prompt_p = cur_p if cur_p else "auto"
160
+ while True:
161
+ try:
162
+ raw_p = Prompt.ask(
163
+ "Provider [auto|openai|gemini]",
164
+ default=prompt_p,
165
+ ).strip()
166
+ except (EOFError, KeyboardInterrupt):
167
+ console.print("\n[yellow]Aborted.[/yellow]")
168
+ return 1
169
+ if raw_p == "-":
170
+ if cur_p:
171
+ unsets.append("provider")
172
+ break
173
+ if raw_p in ("auto", "openai", "gemini"):
174
+ if raw_p == cur_p:
175
+ pass
176
+ elif not cur_p and raw_p == "auto":
177
+ pass
178
+ else:
179
+ updates["provider"] = raw_p
180
+ break
181
+ console.print("[red]Invalid: use auto, openai, gemini, or -[/red]")
182
+
183
+ cur_t = (data.get("tier") or "").strip()
184
+ if cur_t not in ("default", "pro"):
185
+ cur_t = ""
186
+ prompt_t = cur_t if cur_t else "default"
187
+ while True:
188
+ try:
189
+ raw_t = Prompt.ask(
190
+ "Model tier [default|pro]",
191
+ default=prompt_t,
192
+ ).strip()
193
+ except (EOFError, KeyboardInterrupt):
194
+ console.print("\n[yellow]Aborted.[/yellow]")
195
+ return 1
196
+ if raw_t == "-":
197
+ if cur_t:
198
+ unsets.append("tier")
199
+ break
200
+ if raw_t in ("default", "pro"):
201
+ if raw_t == cur_t:
202
+ pass
203
+ elif not cur_t and raw_t == "default":
204
+ pass
205
+ else:
206
+ updates["tier"] = raw_t
207
+ break
208
+ console.print("[red]Invalid: use default, pro, or -[/red]")
209
+
210
+ if not unsets and not updates:
211
+ console.print("\n[dim]No changes.[/dim]")
212
+ return 0
213
+
214
+ try:
215
+ for key in dict.fromkeys(unsets):
216
+ unset_user_key(key)
217
+ for key, value in updates.items():
218
+ set_user_key(key, value)
219
+ except ValueError as exc:
220
+ print(f"prgen: {exc}", file=sys.stderr)
221
+ return 1
222
+
223
+ console.print("\n[green]Saved.[/green]\n")
224
+ _print_config_table(console)
225
+ return 0
226
+
227
+
228
+ def resolve_credentials(provider: ProviderChoice) -> tuple[str, str]:
229
+ """Return (backend, api_key). Raises ValueError if misconfigured."""
230
+ openai_key = os.getenv("OPENAI_API_KEY")
231
+ google_key = os.getenv("GOOGLE_API_KEY")
232
+
233
+ if provider == "openai":
234
+ if not openai_key:
235
+ raise ValueError(
236
+ "Missing OPENAI_API_KEY (--provider openai).\n\n" + _api_key_setup_hint()
237
+ )
238
+ return "openai", openai_key
239
+ if provider == "gemini":
240
+ if not google_key:
241
+ raise ValueError(
242
+ "Missing GOOGLE_API_KEY (--provider gemini).\n\n" + _api_key_setup_hint()
243
+ )
244
+ return "gemini", google_key
245
+
246
+ if google_key:
247
+ return "gemini", google_key
248
+ if openai_key:
249
+ return "openai", openai_key
250
+ raise ValueError("No API keys configured.\n\n" + _api_key_setup_hint())
251
+
252
+
253
+ def run_summarize(
254
+ *,
255
+ repo: Path | None,
256
+ base: str,
257
+ provider: ProviderChoice,
258
+ tier: ModelTier,
259
+ model: str | None,
260
+ context: str,
261
+ ) -> int:
262
+ try:
263
+ ensure_git_available()
264
+ except GitNotFoundError as exc:
265
+ print(f"prgen: {exc}", file=sys.stderr)
266
+ return 1
267
+
268
+ git_cwd: Path | None = None
269
+ if repo is not None:
270
+ try:
271
+ git_cwd = git_repo_root(repo)
272
+ except ValueError as exc:
273
+ print(str(exc), file=sys.stderr)
274
+ return 1
275
+ try:
276
+ load_prgen_env()
277
+ except ValueError as exc:
278
+ print(f"prgen: {exc}", file=sys.stderr)
279
+ return 1
280
+
281
+ try:
282
+ backend, api_key = resolve_credentials(provider)
283
+ except ValueError as exc:
284
+ print(f"prgen: {exc}", file=sys.stderr)
285
+ return 1
286
+
287
+ if model is not None:
288
+ model_id = model
289
+ else:
290
+ model_id = model_for_tier(
291
+ "openai" if backend == "openai" else "gemini",
292
+ tier,
293
+ )
294
+
295
+ try:
296
+ try:
297
+ verify_base_ref(base, cwd=git_cwd)
298
+ except GitRefError as exc:
299
+ print(f"prgen: {exc}", file=sys.stderr)
300
+ return 1
301
+
302
+ diff = get_diff(base, cwd=git_cwd)
303
+ commits = get_commits(base, cwd=git_cwd)
304
+
305
+ if not diff and not commits:
306
+ print(
307
+ f"No commits or file changes vs {base}. Adjust --base or make changes.",
308
+ file=sys.stderr,
309
+ )
310
+ return 1
311
+
312
+ template = load_prompt_template()
313
+ prompt = build_prompt(
314
+ template=template,
315
+ diff=diff,
316
+ commits=commits,
317
+ additional_context=context,
318
+ )
319
+
320
+ try:
321
+ with loading(model=model_id):
322
+ if backend == "openai":
323
+ from prgen.providers.openai_provider import OpenAIProvider
324
+
325
+ output = OpenAIProvider(api_key=api_key, model=model_id).generate(prompt)
326
+ else:
327
+ from prgen.providers.gemini_provider import GeminiProvider
328
+
329
+ output = GeminiProvider(api_key=api_key, model=model_id).generate(prompt)
330
+ except ImportError as exc:
331
+ print(f"prgen: {exc}", file=sys.stderr)
332
+ return 1
333
+ except Exception as exc:
334
+ print(
335
+ f"prgen: {format_provider_error(exc, backend=cast(Backend, backend))}",
336
+ file=sys.stderr,
337
+ )
338
+ return 1
339
+
340
+ summary = extract_tag(output, "summary")
341
+ body = extract_tag(output, "body")
342
+
343
+ has_parts = bool(summary and body)
344
+ print_pr_summary(
345
+ summary if has_parts else None,
346
+ body if has_parts else None,
347
+ output,
348
+ )
349
+
350
+ return 0
351
+
352
+ except Exception as exc:
353
+ print(f"prgen: {exc}", file=sys.stderr)
354
+ return 1
355
+
356
+
357
+ _CREDIT_EPILOG = f"[dim]{AUTHOR} · {REPO_URL}[/dim]"
358
+
359
+ app = typer.Typer(
360
+ name="prgen",
361
+ help=(
362
+ "PR title and body from git diff and commits vs --base. "
363
+ "Optional defaults for --base, --provider, and --tier: ~/.config/prgen/config.json."
364
+ ),
365
+ invoke_without_command=True,
366
+ no_args_is_help=False,
367
+ epilog=_CREDIT_EPILOG,
368
+ )
369
+
370
+ config_app = typer.Typer(
371
+ name="config",
372
+ help="Configure ~/.config/prgen/config.json (interactive when no subcommand).",
373
+ invoke_without_command=True,
374
+ no_args_is_help=False,
375
+ epilog=_CREDIT_EPILOG,
376
+ )
377
+ app.add_typer(config_app, name="config")
378
+
379
+
380
+ @app.callback()
381
+ def _root(
382
+ ctx: typer.Context,
383
+ _version: Annotated[
384
+ bool,
385
+ typer.Option(
386
+ "--version",
387
+ callback=_version_callback,
388
+ is_eager=True,
389
+ help="Print version.",
390
+ ),
391
+ ] = False,
392
+ repo: Annotated[
393
+ Path | None,
394
+ typer.Option(
395
+ "-C",
396
+ "--repo",
397
+ metavar="PATH",
398
+ help="Git working directory (git -C).",
399
+ ),
400
+ ] = None,
401
+ base: Annotated[
402
+ str,
403
+ typer.Option(
404
+ "--base",
405
+ help="Merge base ref for diff and log vs HEAD.",
406
+ ),
407
+ ] = "origin/main",
408
+ provider: Annotated[
409
+ ProviderChoice,
410
+ typer.Option(
411
+ "--provider",
412
+ help="auto picks Gemini when both API keys exist in config.",
413
+ ),
414
+ ] = "auto",
415
+ tier: Annotated[
416
+ ModelTier,
417
+ typer.Option(
418
+ "--tier",
419
+ help="Cheaper/faster vs stronger models.",
420
+ ),
421
+ ] = "default",
422
+ model: Annotated[
423
+ str | None,
424
+ typer.Option(
425
+ "--model",
426
+ metavar="ID",
427
+ help="Overrides --tier preset.",
428
+ ),
429
+ ] = None,
430
+ context: Annotated[
431
+ str,
432
+ typer.Option(
433
+ "--context",
434
+ metavar="TEXT",
435
+ help="Extra text merged into the prompt.",
436
+ ),
437
+ ] = "none",
438
+ ) -> None:
439
+ if ctx.invoked_subcommand is not None:
440
+ return
441
+ cfg_base, cfg_provider, cfg_tier = read_cli_defaults_from_config()
442
+ if ctx.get_parameter_source("base") == ParameterSource.DEFAULT and cfg_base is not None:
443
+ base = cfg_base
444
+ if ctx.get_parameter_source("provider") == ParameterSource.DEFAULT and cfg_provider is not None:
445
+ provider = cfg_provider
446
+ if ctx.get_parameter_source("tier") == ParameterSource.DEFAULT and cfg_tier is not None:
447
+ tier = cfg_tier
448
+ raise typer.Exit(
449
+ run_summarize(
450
+ repo=repo,
451
+ base=base,
452
+ provider=provider,
453
+ tier=tier,
454
+ model=model,
455
+ context=context,
456
+ )
457
+ )
458
+
459
+
460
+ @config_app.callback()
461
+ def _config_entry(ctx: typer.Context) -> None:
462
+ if ctx.invoked_subcommand is not None:
463
+ return
464
+ raise typer.Exit(run_interactive_config())
465
+
466
+
467
+ @config_app.command("set", help="Set KEY to VALUE in config JSON.")
468
+ def _config_set(
469
+ key: Annotated[PersistedConfigKey, typer.Argument(help="Field name.")],
470
+ value: Annotated[str, typer.Argument(help="Value; use - for stdin")],
471
+ ) -> None:
472
+ try:
473
+ val = value
474
+ if val == "-":
475
+ val = sys.stdin.read()
476
+ val = val.strip("\n\r")
477
+ path = set_user_key(key, val)
478
+ print(f"Wrote {key} to {path}", file=sys.stderr)
479
+ except ValueError as exc:
480
+ print(f"prgen: {exc}", file=sys.stderr)
481
+ raise typer.Exit(1) from exc
482
+
483
+
484
+ @config_app.command("unset", help="Remove KEY from config JSON.")
485
+ def _config_unset(
486
+ key: Annotated[PersistedConfigKey, typer.Argument(help="Field name.")],
487
+ ) -> None:
488
+ try:
489
+ path = unset_user_key(key)
490
+ print(f"Removed {key} from {path}", file=sys.stderr)
491
+ except ValueError as exc:
492
+ print(f"prgen: {exc}", file=sys.stderr)
493
+ raise typer.Exit(1) from exc
494
+
495
+
496
+ @config_app.command(
497
+ "show",
498
+ help="Print config.json (non-secret values; API keys as set or unset only).",
499
+ )
500
+ def _config_show() -> None:
501
+ try:
502
+ _print_config_table(Console())
503
+ except ValueError as exc:
504
+ print(f"prgen: {exc}", file=sys.stderr)
505
+ raise typer.Exit(1) from exc
506
+
507
+
508
+ @config_app.command("path", help="Print config file path.")
509
+ def _config_path() -> None:
510
+ typer.echo(user_config_path())
511
+
512
+
513
+ def main() -> None:
514
+ app()
515
+
516
+
517
+ if __name__ == "__main__":
518
+ main()