crespo 1.0.0__tar.gz

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.
File without changes
@@ -0,0 +1,504 @@
1
+
2
+
3
+ import sys
4
+ import os
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
12
+ from rich.text import Text
13
+ from rich.columns import Columns
14
+ from rich.rule import Rule
15
+ from rich.align import Align
16
+ from rich import print as rprint
17
+ from rich.tree import Tree
18
+ from rich import box
19
+ from contextlib import contextmanager
20
+
21
+ console = Console()
22
+
23
+ # ─── Cresbee pixel art renderer ───────────────────────────────────────────────
24
+
25
+ def _ansi_fg(r, g, b): return f"\033[38;2;{r};{g};{b}m"
26
+ def _ansi_bg(r, g, b): return f"\033[48;2;{r};{g};{b}m"
27
+ RESET = "\033[0m"
28
+
29
+
30
+ def render_cresbee(image_path: str, width: int = 38) -> list[str]:
31
+ """
32
+ Render Cresbee PNG as half-block (▀) terminal art.
33
+ Uses NEAREST resampling for sharp pixel-art edges.
34
+ Alpha threshold 80 keeps only solid pixels — no blurry anti-alias halo.
35
+ Returns a list of ANSI strings, one per terminal row.
36
+ Falls back to ASCII art on any error.
37
+ """
38
+ try:
39
+ from PIL import Image
40
+ import numpy as np
41
+
42
+ img = Image.open(image_path).convert("RGBA")
43
+ arr = np.array(img).copy()
44
+
45
+ # ── strip near-black background ──────────────────────────────────────
46
+ r, g, b = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2]
47
+ bg = (r < 22) & (g < 22) & (b < 22)
48
+ arr[bg, 3] = 0
49
+
50
+ clean = Image.fromarray(arr, "RGBA")
51
+ bbox = clean.getbbox()
52
+ if not bbox:
53
+ return _fallback_cresbee()
54
+
55
+ cropped = clean.crop(bbox)
56
+
57
+ # ── resize with NEAREST for hard pixel-art edges ─────────────────────
58
+ aspect = cropped.height / cropped.width
59
+ px_height = int(width * aspect)
60
+ # must be even for ▀/▄ pairing
61
+ if px_height % 2:
62
+ px_height += 1
63
+
64
+ resized = cropped.resize((width, px_height), Image.NEAREST)
65
+ px = np.array(resized)
66
+
67
+ ALPHA_THRESH = 80 # pixels below this alpha → transparent
68
+
69
+ lines = []
70
+ for y in range(0, px_height, 2):
71
+ row = ""
72
+ for x in range(width):
73
+ top = px[y, x]
74
+ bot = px[y + 1, x] if (y + 1) < px_height else (0, 0, 0, 0)
75
+
76
+ t_on = int(top[3]) >= ALPHA_THRESH
77
+ b_on = int(bot[3]) >= ALPHA_THRESH
78
+
79
+ if t_on and b_on:
80
+ tr, tg, tb = int(top[0]), int(top[1]), int(top[2])
81
+ br, bg_c, bb = int(bot[0]), int(bot[1]), int(bot[2])
82
+ row += (
83
+ _ansi_fg(tr, tg, tb)
84
+ + _ansi_bg(br, bg_c, bb)
85
+ + "▀"
86
+ + RESET
87
+ )
88
+ elif t_on:
89
+ tr, tg, tb = int(top[0]), int(top[1]), int(top[2])
90
+ row += _ansi_fg(tr, tg, tb) + "▀" + RESET
91
+ elif b_on:
92
+ br, bg_c, bb = int(bot[0]), int(bot[1]), int(bot[2])
93
+ row += _ansi_fg(br, bg_c, bb) + "▄" + RESET
94
+ else:
95
+ row += " "
96
+ lines.append(row)
97
+
98
+ return lines
99
+
100
+ except Exception:
101
+ return _fallback_cresbee()
102
+
103
+
104
+ def _fallback_cresbee() -> list[str]:
105
+ """ASCII fallback when PIL unavailable or image missing."""
106
+ return [
107
+ " \033[38;2;130;110;200m╭──╮ ╭──╮\033[0m",
108
+ " \033[38;2;100;200;140m╭╯\033[0m\033[38;2;130;110;200m ╰─╯ \033[0m\033[38;2;100;200;140m╰╮\033[0m",
109
+ " \033[38;2;100;200;140m│\033[0m \033[1;37m◉\033[0m \033[1;37m◉\033[0m \033[38;2;100;200;140m│\033[0m",
110
+ " \033[38;2;100;200;140m│\033[0m \033[38;2;130;110;200m──\033[0m \033[38;2;100;200;140m│\033[0m",
111
+ " \033[38;2;100;200;140m╰──┬\033[0m\033[38;2;255;200;50m████\033[0m\033[38;2;100;200;140m┬──╯\033[0m",
112
+ " \033[38;2;100;200;140m│\033[0m\033[38;2;255;200;50m 🍯 \033[0m\033[38;2;100;200;140m│\033[0m",
113
+ " \033[38;2;100;200;140m╰────╯\033[0m",
114
+ ]
115
+
116
+
117
+ def print_cresbee(image_path: str | None = None, width: int = 34):
118
+ """Print Cresbee to terminal."""
119
+ path = image_path or "cresbee.png"
120
+ lines = render_cresbee(path, width=width)
121
+ for line in lines:
122
+ print(line)
123
+ print("\033[0m", end="")
124
+
125
+
126
+ # ─── Header ───────────────────────────────────────────────────────────────────
127
+
128
+ def print_header(image_path: str | None = None):
129
+ """Full startup header with Cresbee + branding."""
130
+ console.print()
131
+
132
+ # Cresbee on left, branding on right
133
+ cresbee_lines = render_cresbee(image_path or "cresbee.png", width=30)
134
+
135
+ # build branding text
136
+ brand_lines = [
137
+ "",
138
+ "\033[1;38;2;100;200;140m ██████╗██████╗ ███████╗███████╗██████╗ ██████╗ \033[0m",
139
+ "\033[38;2;100;200;140m ██╔════╝ ██╔══██╗██╔════╝██╔════╝██╔══██╗██╔═══██╗\033[0m",
140
+ "\033[38;2;110;180;160m ██║ ██████╔╝█████╗ ███████╗██████╔╝██║ ██║\033[0m",
141
+ "\033[38;2;120;160;180m ██║ ██╔══██╗██╔══╝ ╚════██║██╔═══╝ ██║ ██║\033[0m",
142
+ "\033[38;2;130;110;200m ╚██████╗ ██║ ██║███████╗███████║██║ ╚██████╔╝\033[0m",
143
+ "\033[38;2;140;100;220m ╚═════╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═════╝ \033[0m",
144
+ "",
145
+ "\033[38;2;100;200;140m Crisp repos. Sharp AI.\033[0m",
146
+ "\033[38;2;90;90;120m Give AI the blueprint, not the code.\033[0m",
147
+ "",
148
+ "\033[38;2;100;200;140m v1.0.0\033[0m \033[38;2;90;90;120m• MIT License • pip install crespo\033[0m",
149
+ ]
150
+
151
+ # print side by side
152
+ max_lines = max(len(cresbee_lines), len(brand_lines))
153
+
154
+ for i in range(max_lines):
155
+ left = cresbee_lines[i] if i < len(cresbee_lines) else ""
156
+ right = brand_lines[i] if i < len(brand_lines) else ""
157
+ # pad left column to fixed width (accounting for ANSI codes)
158
+ visible_len = len(left.encode("utf-8").decode("utf-8"))
159
+ # rough padding — cresbee art is ~30 chars wide
160
+ print(f" {left} {right}")
161
+
162
+ print("\033[0m")
163
+
164
+ # separator
165
+ console.print(
166
+ Rule(style="#8c64dc"),
167
+ )
168
+
169
+
170
+ def print_usage():
171
+ """Print usage panel."""
172
+ console.print(
173
+ Panel(
174
+ Text.from_markup(
175
+ "[bold green]Usage[/bold green]\n"
176
+ " [cyan]crespo[/cyan] [purple]<path|url>[/purple] "
177
+ "[[green]--mode[/green] structure|summary|concat] "
178
+ "[[green]--output[/green] file.xml]\n\n"
179
+ "[bold green]Examples[/bold green]\n"
180
+ " [green]crespo[/green] ./myproject\n"
181
+ " [green]crespo[/green] ./myproject [green]--mode[/green] summary\n"
182
+ " [green]crespo[/green] [green]--git[/green] https://github.com/user/repo\n"
183
+ " [green]crespo[/green] ./myproject [green]--mode[/green] concat "
184
+ "[green]--output[/green] full.xml\n\n"
185
+ "[bold green]Modes[/bold green]\n"
186
+ " [green]structure[/green] :\tAST skeleton only — ~84% token reduction "
187
+ "[dim](default)[/dim]\n"
188
+ " [purple]summary[/purple] :\tstructure + AI function descriptions via Groq\n"
189
+ " [cyan]concat[/cyan] :\tfull source + secrets redacted + structure header\n\n"
190
+ "[bold green]Options[/bold green]\n"
191
+ " [green]--mode[/green] structure | summary | concat\n"
192
+ " [green]--output[/green] Output filename [dim](default: blueprint.xml)[/dim]\n"
193
+ " [green]--groq[/green] Groq API key for summary mode [dim]or set CRESPO_GROQ_KEY env var[/dim]\n"
194
+ " [green]--git[/green] Using git URL.\n"
195
+ " [green]--help[/green] Show this message"
196
+ ),
197
+ title="[bold green]Crespo[/bold green]",
198
+ border_style="green",
199
+ padding=(1, 2),
200
+ )
201
+ )
202
+
203
+
204
+ # ─── Scan phase ───────────────────────────────────────────────────────────────
205
+
206
+ def print_scan_start(source: str, mode: str):
207
+ """Print scan start info."""
208
+ mode_colors = {
209
+ "structure": "green",
210
+ "summary": "purple",
211
+ "concat": "cyan",
212
+ }
213
+ color = mode_colors.get(mode, "green")
214
+ console.print(
215
+ f" [dim]Source[/dim] [white]{source}[/white]"
216
+ )
217
+ console.print(
218
+ f" [dim]Mode[/dim] [{color}]{mode}[/{color}]"
219
+ )
220
+ console.print(Rule(title="[green][b]DETAILS[/b][/green]",style="#8c64dc"))
221
+
222
+
223
+ LANG_COLORS = {
224
+ "py": "blue", "js": "yellow", "ts": "cyan",
225
+ "jsx": "yellow", "tsx": "cyan", "rs": "red",
226
+ "go": "cyan", "java": "red", "c": "green", "cpp": "green",
227
+ }
228
+
229
+ def _lang_badge(lang: str) -> str:
230
+ color = LANG_COLORS.get(lang, "white")
231
+ return f"[{color}]{lang}[/{color}]"
232
+
233
+ def _ext(relpath: str) -> str:
234
+ return Path(relpath).suffix.lstrip(".")
235
+
236
+
237
+ # ── Style 1: Classic Rich Tree (├── branches) ─────────────────────────────────
238
+
239
+ def print_tree_classic(found: list[str],root:str = "project"):
240
+ """Builds a Rich Tree with ├── / └── branch lines."""
241
+ tree = Tree(f"[bold green]{root}[/bold green]")
242
+ nodes: dict[str, Tree] = {}
243
+
244
+ def get_dir_node(parts: tuple) -> Tree:
245
+ if not parts:
246
+ return tree
247
+ if parts in nodes:
248
+ return nodes[parts]
249
+ parent = get_dir_node(parts[:-1])
250
+ node = parent.add(f"[#8c64dc][b]{parts[-1]}[b][/#8c64dc]")
251
+ nodes[parts] = node
252
+ return node
253
+
254
+ for relpath in sorted(found):
255
+ p = Path(relpath)
256
+ lang = _ext(relpath)
257
+ parent = get_dir_node(p.parts[:-1])
258
+ parent.add(f"{p.name} {_lang_badge(lang)}")
259
+
260
+ console.print(Panel(tree,box=box.SIMPLE,border_style="dim"))
261
+
262
+
263
+ # ─── Parse phase ──────────────────────────────────────────────────────────────
264
+
265
+ def run_with_progress(files: list[dict], label: str = "Parsing") -> None:
266
+ """Show progress bar while parsing files."""
267
+ with Progress(
268
+ SpinnerColumn(style="green"),
269
+ TextColumn(" [green]{task.description}[/green]"),
270
+ BarColumn(bar_width=30, style="green", complete_style="bright_green"),
271
+ TaskProgressColumn(),
272
+ TextColumn("[dim]{task.fields[filename]}[/dim]"),
273
+ console=console,
274
+ transient=True,
275
+ ) as progress:
276
+ task = progress.add_task(
277
+ label,
278
+ total=len(files),
279
+ filename=""
280
+ )
281
+ for file in files:
282
+ name = Path(file.get("relpath", "")).name
283
+ progress.update(task, advance=1, filename=name)
284
+ time.sleep(0.1) # remove this in real implementation
285
+
286
+ console.print()
287
+ console.print(Rule(style="#8c64dc"))
288
+ console.print(f" [green]✓[/green] [dim]Parsed {len(files)} files[/dim]")
289
+ console.print(Rule(style="#8c64dc"))
290
+
291
+
292
+ def run_summary_progress(files: list[dict]) -> None:
293
+ """Show Groq API call progress for summary mode."""
294
+
295
+ console.print(" [purple]Calling Groq API...[/purple]")
296
+ console.print()
297
+
298
+ with Progress(
299
+ SpinnerColumn(style="purple"),
300
+ TextColumn(" [purple]{task.description}[/purple]"),
301
+ BarColumn(bar_width=25, style="purple", complete_style="bright_magenta"),
302
+ TaskProgressColumn(),
303
+ console=console,
304
+ transient=True,
305
+ ) as progress:
306
+ task = progress.add_task("Summarising", total=len(files))
307
+ for file in files:
308
+ name = Path(file.get("relpath", "")).name
309
+ progress.update(task, description=f"summarising {name}", advance=1)
310
+ time.sleep(0.08)
311
+
312
+ console.print(f" [purple]✓[/purple] [dim]Summaries generated[/dim]")
313
+
314
+
315
+ # ─── Security phase ───────────────────────────────────────────────────────────
316
+
317
+ def print_security_result(secrets_found: int, files_scanned: int):
318
+ """Print security scan result."""
319
+ if secrets_found == 0:
320
+ console.print(
321
+ f" [green]✓[/green] [dim]Security scan — {files_scanned} files — "
322
+ f"no secrets detected[/dim]"
323
+ )
324
+ else:
325
+ console.print(
326
+ f" [yellow]⚠[/yellow] [yellow]Security scan — "
327
+ f"{secrets_found} secret(s) redacted[/yellow]"
328
+ )
329
+ console.print(Rule(style="#8c64dc"))
330
+
331
+
332
+ # ─── Stats ────────────────────────────────────────────────────────────────────
333
+
334
+ def print_token_stats(
335
+ original_tokens: int,
336
+ output_tokens: int,
337
+ mode: str,
338
+ output_file: str,
339
+ elapsed: float,
340
+ extra_stats: dict | None = None,
341
+ ):
342
+ """Print final token statistics table."""
343
+ reduction = round((1 - output_tokens / max(original_tokens, 1)) * 100)
344
+
345
+ console.print(Rule(title="[green][b]ANALYSIS[/b][/green]",style="#8c64dc"))
346
+ console.print()
347
+
348
+ # stats table
349
+ table = Table(
350
+ box=box.ROUNDED,
351
+ padding=(0, 2),
352
+ border_style="green",
353
+ )
354
+ table.add_column("Metric", style="dim", width=20)
355
+ table.add_column("Value", justify="right")
356
+
357
+ table.add_row(
358
+ "Original Tokens",
359
+ f"[red]{original_tokens:,}[/red]"
360
+ )
361
+ table.add_row(
362
+ "Crespo Output",
363
+ f"[green]{output_tokens:,}[/green]"
364
+ )
365
+ table.add_row(
366
+ "Token Reduction",
367
+ f"[bold purple]{reduction}%[/bold purple]"
368
+ )
369
+ table.add_row(
370
+ "Mode",
371
+ f"[cyan]{mode}[/cyan]"
372
+ )
373
+
374
+ if extra_stats:
375
+ for k, v in extra_stats.items():
376
+ table.add_row(k, str(v))
377
+
378
+ table.add_row("", "")
379
+ table.add_row(
380
+ "Output File",
381
+ f"[#8c64dc]{output_file}[/#8c64dc]"
382
+ )
383
+ table.add_row(
384
+ "Time Elapsed",
385
+ f"[dim]{elapsed:.1f}s[/dim]"
386
+ )
387
+
388
+ console.print(Align(table, align="center", pad=True))
389
+
390
+ # reduction bar
391
+ console.print()
392
+ bar_width = 40
393
+ filled = max(1, int(bar_width * (1 - output_tokens / max(original_tokens, 1))))
394
+ empty = bar_width - filled
395
+
396
+ console.print(Align(
397
+ f" [dim]Original[/dim] "
398
+ f"[red]{'█' * bar_width}[/red] "
399
+ f"[cyan]{original_tokens:,}[/cyan]",align="center")
400
+ )
401
+ console.print("\n")
402
+ console.print(Align(
403
+ f" [dim]Crespo[/dim] "
404
+ f"[green]{'█' * empty}[/green][dim]{'░' * filled}[/dim] "
405
+ f"[green]{output_tokens:,}[/green]",align="center")
406
+ )
407
+ console.print()
408
+
409
+ # done
410
+ console.print(Align(
411
+ f"[green]Blueprint saved →[/green] "
412
+ f"[#8c64dc]{output_file}[/#8c64dc]","center")
413
+ )
414
+ console.print()
415
+ console.print(Rule(style="#8c64dc"))
416
+
417
+
418
+ # ─── Error / warning helpers ──────────────────────────────────────────────────
419
+
420
+ def print_error(message: str):
421
+ console.print(f"\n [red]✗[/red] [red]{message}[/red]\n")
422
+
423
+
424
+ def print_warning(message: str):
425
+ console.print(f" [yellow]⚠[/yellow] [yellow]{message}[/yellow]")
426
+
427
+ def print_loc(output_file):
428
+ console.print(Align(
429
+ f"[green]Blueprint saved →[/green] "
430
+ f"[#8c64dc]{output_file}[/#8c64dc]","center"))
431
+ console.print(Rule(style="#8c64dc"))
432
+
433
+
434
+ def print_info(message: str):
435
+ console.print(Align(f" [dim]{message}[/dim]","center"))
436
+
437
+
438
+ def print_groq_fallback():
439
+ """Warn when Groq rate limit hit and falling back to structure."""
440
+ console.print()
441
+ console.print(
442
+ Panel(
443
+ "[yellow]Groq rate limit reached.[/yellow]\n"
444
+ "Remaining files will use [green]structure mode[/green] "
445
+ "(no summaries).\n"
446
+ "[dim]Files already summarised are preserved.[/dim]",
447
+ border_style="yellow",
448
+ padding=(0, 2),
449
+ )
450
+ )
451
+ console.print()
452
+
453
+
454
+ def print_no_groq_key():
455
+ """Warn when no Groq key and falling back."""
456
+ console.print()
457
+ console.print(
458
+ Panel(
459
+ "[yellow]No Groq API key found.[/yellow]\n"
460
+ "Falling back to [green]structure mode[/green].\n\n"
461
+ "[dim]To use summary mode:[/dim]\n"
462
+ " [green]export CRESPO_GROQ_KEY=your_key_here[/green]\n"
463
+ " [dim]Get a free key at[/dim] [cyan]https://console.groq.com[/cyan]",
464
+ border_style="yellow",
465
+ padding=(0, 2),
466
+ )
467
+ )
468
+ console.print()
469
+
470
+ @contextmanager
471
+ def summary_progress_context(total_files: int):
472
+ """
473
+ Context manager that keeps the Groq spinner alive until the caller exits.
474
+ Yields an `advance(n)` callable so batch completions can tick the bar.
475
+
476
+ Usage:
477
+ with cli.summary_progress_context(len(files)) as advance:
478
+ for chunk in batches:
479
+ summaries = summariser.summarise_files_batch(chunk)
480
+ advance(len(chunk))
481
+ """
482
+ console.print(" [purple]Calling Groq API...[/purple]")
483
+ console.print()
484
+
485
+ with Progress(
486
+ SpinnerColumn(style="purple"),
487
+ TextColumn(" [purple]{task.description}[/purple]"),
488
+ TaskProgressColumn(),
489
+ TextColumn("[dim]{task.fields[current]}[/dim]"),
490
+ console=console,
491
+ transient=True,
492
+ ) as progress:
493
+ task = progress.add_task(
494
+ "Summarising",
495
+ total=total_files,
496
+ current=""
497
+ )
498
+
499
+ def advance(n: int, label: str = ""):
500
+ progress.update(task, advance=n, current=label)
501
+
502
+ yield advance # caller runs here; progress bar stays alive
503
+
504
+ console.print(f" [purple]✓[/purple] [dim]Summaries generated[/dim]")