scry-run 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.
scry_run/cli/apps.py ADDED
@@ -0,0 +1,396 @@
1
+ """App management commands for scry-run."""
2
+
3
+ import json
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ import click
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+ from rich.prompt import Confirm
15
+
16
+ from scry_run.home import get_apps_dir, get_app_dir
17
+ from scry_run.packages import ensure_scry_run_installed, get_venv_python, get_installed_packages
18
+
19
+ console = Console()
20
+
21
+
22
+ def get_all_apps() -> list[str]:
23
+ """Get list of all app names."""
24
+ apps_dir = get_apps_dir()
25
+ if not apps_dir.exists():
26
+ return []
27
+ return sorted([
28
+ d.name for d in apps_dir.iterdir()
29
+ if d.is_dir() and (d / "app.py").exists()
30
+ ])
31
+
32
+
33
+ def extract_description_from_app(app_dir: Path) -> str | None:
34
+ """Extract the description from an app's app.py file.
35
+
36
+ The description is stored in the module docstring and class docstring.
37
+ Returns the full description text (may be multi-line).
38
+ """
39
+ app_file = app_dir / "app.py"
40
+ if not app_file.exists():
41
+ return None
42
+
43
+ content = app_file.read_text()
44
+
45
+ # Try to extract from module docstring (first triple-quoted string)
46
+ # Format: """
47
+ # {description}
48
+ #
49
+ # Generated by scry-run...
50
+ # """
51
+ match = re.search(r'^"""[\s\n]*(.*?)(?:\n\nGenerated by scry-run|""")', content, re.MULTILINE | re.DOTALL)
52
+ if match:
53
+ return match.group(1).strip()
54
+
55
+ return None
56
+
57
+
58
+ @click.command("list")
59
+ def list_apps() -> None:
60
+ """List all scry-run apps."""
61
+ apps = get_all_apps()
62
+
63
+ if not apps:
64
+ console.print("[dim]No apps found.[/dim]")
65
+ console.print(f"[dim]Create one with:[/dim] [cyan]scry-run init[/cyan]")
66
+ return
67
+
68
+ table = Table(show_header=True, header_style="bold")
69
+ table.add_column("Name")
70
+ table.add_column("Description")
71
+ table.add_column("Path")
72
+
73
+ for app_name in apps:
74
+ app_dir = get_app_dir(app_name)
75
+ description = extract_description_from_app(app_dir) or "[dim]—[/dim]"
76
+ table.add_row(app_name, description, str(app_dir))
77
+
78
+ console.print(table)
79
+
80
+
81
+ @click.command("which")
82
+ @click.argument("app_name")
83
+ def which_app(app_name: str) -> None:
84
+ """Print command to run app in its environment.
85
+
86
+ Example:
87
+ scry-run which myapp
88
+ $(scry-run which myapp) --help
89
+ """
90
+ app_dir = get_app_dir(app_name)
91
+ app_file = app_dir / "app.py"
92
+
93
+ if not app_file.exists():
94
+ console.print(f"[red]Error:[/red] App '{app_name}' not found")
95
+ raise SystemExit(1)
96
+
97
+ # Print the full command to run the app
98
+ click.echo(f"uv run --directory {app_dir} python {app_file}")
99
+
100
+
101
+ @click.command("rm")
102
+ @click.argument("app_name")
103
+ @click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
104
+ def rm_app(app_name: str, force: bool) -> None:
105
+ """Remove an scry-run app.
106
+
107
+ This permanently deletes the app directory and all its contents.
108
+
109
+ Example:
110
+ scry-run rm myapp
111
+ scry-run rm myapp --force
112
+ """
113
+ app_dir = get_app_dir(app_name)
114
+
115
+ if not app_dir.exists():
116
+ console.print(f"[red]Error:[/red] App '{app_name}' not found")
117
+ raise SystemExit(1)
118
+
119
+ if not force:
120
+ if not Confirm.ask(f"[yellow]Delete app '{app_name}' and all its contents?[/yellow]"):
121
+ console.print("[dim]Aborted.[/dim]")
122
+ raise SystemExit(0)
123
+
124
+ shutil.rmtree(app_dir)
125
+ console.print(f"[green]Removed app '{app_name}'[/green]")
126
+
127
+
128
+ @click.command("reset")
129
+ @click.argument("app_name")
130
+ @click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
131
+ def reset_app(app_name: str, force: bool) -> None:
132
+ """Reset an app to initial state, preserving name and description.
133
+
134
+ This removes the generated code and cache, then reinitializes the app
135
+ as if 'scry-run init' was run with the same name and description.
136
+
137
+ Example:
138
+ scry-run reset myapp
139
+ scry-run reset myapp --force
140
+ """
141
+ from scry_run.cli.init import MAIN_TEMPLATE, to_class_name
142
+
143
+ app_dir = get_app_dir(app_name)
144
+
145
+ if not app_dir.exists():
146
+ console.print(f"[red]Error:[/red] App '{app_name}' not found")
147
+ raise SystemExit(1)
148
+
149
+ # Extract description before deleting
150
+ description = extract_description_from_app(app_dir)
151
+ if not description:
152
+ description = f"A {app_name} application powered by LLM code generation"
153
+
154
+ if not force:
155
+ console.print(f"[yellow]This will reset app '{app_name}':[/yellow]")
156
+ console.print(f" - Remove all generated code")
157
+ console.print(f" - Clear the cache")
158
+ console.print(f" - Keep name: [cyan]{app_name}[/cyan]")
159
+ console.print(f" - Keep description: [cyan]{description}[/cyan]")
160
+ if not Confirm.ask("Continue?"):
161
+ console.print("[dim]Aborted.[/dim]")
162
+ raise SystemExit(0)
163
+
164
+ # Remove existing directory
165
+ shutil.rmtree(app_dir)
166
+
167
+ # Recreate app directory
168
+ app_dir.mkdir(parents=True, exist_ok=True)
169
+
170
+ # Create virtual environment and install scry-run
171
+ ensure_scry_run_installed(app_dir)
172
+
173
+ class_name = to_class_name(app_name)
174
+
175
+ # Create app.py
176
+ app_file = app_dir / "app.py"
177
+ app_content = MAIN_TEMPLATE.format(
178
+ name=app_name,
179
+ description=description,
180
+ class_name=class_name,
181
+ )
182
+ app_file.write_text(app_content)
183
+ app_file.chmod(app_file.stat().st_mode | 0o111) # Make executable
184
+
185
+ # Create empty cache.json
186
+ cache_file = app_dir / "cache.json"
187
+ cache_file.write_text(json.dumps({}))
188
+
189
+ # Create logs directory
190
+ logs_dir = app_dir / "logs"
191
+ logs_dir.mkdir(exist_ok=True)
192
+
193
+ console.print(f"[green]Reset app '{app_name}' successfully[/green]")
194
+ console.print(f" [dim]Description:[/dim] {description}")
195
+
196
+
197
+ def _format_size(size_bytes: int) -> str:
198
+ """Format a size in bytes to human-readable format."""
199
+ if size_bytes < 1024:
200
+ return f"{size_bytes} B"
201
+ elif size_bytes < 1024 * 1024:
202
+ return f"{size_bytes / 1024:.1f} KB"
203
+ else:
204
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
205
+
206
+
207
+ def _format_timestamp(ts: float | None) -> str:
208
+ """Format a timestamp to human-readable format."""
209
+ if ts is None:
210
+ return "—"
211
+ dt = datetime.fromtimestamp(ts)
212
+ return dt.strftime("%Y-%m-%d %H:%M")
213
+
214
+
215
+ def _get_python_version(app_dir: Path) -> str | None:
216
+ """Get the Python version in the app's venv."""
217
+ python_path = get_venv_python(app_dir)
218
+ if not python_path.exists():
219
+ return None
220
+ try:
221
+ result = subprocess.run(
222
+ [str(python_path), "--version"],
223
+ capture_output=True,
224
+ text=True,
225
+ )
226
+ if result.returncode == 0:
227
+ # Output is "Python 3.12.12"
228
+ return result.stdout.strip().replace("Python ", "")
229
+ except Exception:
230
+ pass
231
+ return None
232
+
233
+
234
+ def _get_cache_stats(cache_file: Path) -> dict:
235
+ """Get statistics about the cache file."""
236
+ stats = {
237
+ "methods": 0,
238
+ "total_lines": 0,
239
+ "size_bytes": 0,
240
+ "last_cached": None,
241
+ "entries": [],
242
+ }
243
+
244
+ if not cache_file.exists():
245
+ return stats
246
+
247
+ stats["size_bytes"] = cache_file.stat().st_size
248
+
249
+ try:
250
+ with open(cache_file, "r", encoding="utf-8") as f:
251
+ data = json.load(f)
252
+
253
+ for class_name, methods in data.items():
254
+ for attr_name, entry in methods.items():
255
+ stats["methods"] += 1
256
+ code = entry.get("code", "")
257
+ stats["total_lines"] += code.count("\n") + 1
258
+ stats["entries"].append({
259
+ "class": class_name,
260
+ "attr": attr_name,
261
+ "type": entry.get("code_type", "method"),
262
+ "created_at": entry.get("created_at"),
263
+ })
264
+
265
+ # Track most recent cache entry
266
+ created_at = entry.get("created_at")
267
+ if created_at:
268
+ try:
269
+ ts = datetime.fromisoformat(created_at).timestamp()
270
+ if stats["last_cached"] is None or ts > stats["last_cached"]:
271
+ stats["last_cached"] = ts
272
+ except ValueError:
273
+ pass
274
+ except (json.JSONDecodeError, KeyError):
275
+ pass
276
+
277
+ return stats
278
+
279
+
280
+ def _get_venv_size(venv_path: Path) -> int:
281
+ """Get total size of venv directory."""
282
+ if not venv_path.exists():
283
+ return 0
284
+ total = 0
285
+ for f in venv_path.rglob("*"):
286
+ if f.is_file():
287
+ total += f.stat().st_size
288
+ return total
289
+
290
+
291
+ @click.command("info")
292
+ @click.argument("app_name")
293
+ def info_app(app_name: str) -> None:
294
+ """Show detailed information about an app.
295
+
296
+ Displays paths, cache statistics, environment info, and timestamps.
297
+
298
+ Example:
299
+ scry-run info myapp
300
+ """
301
+ app_dir = get_app_dir(app_name)
302
+ app_file = app_dir / "app.py"
303
+ cache_file = app_dir / "cache.json"
304
+ venv_path = app_dir / ".venv"
305
+
306
+ if not app_dir.exists():
307
+ console.print(f"[red]Error:[/red] App '{app_name}' not found")
308
+ raise SystemExit(1)
309
+
310
+ # Get description
311
+ description = extract_description_from_app(app_dir) or "[dim]No description[/dim]"
312
+
313
+ # Get cache stats
314
+ cache_stats = _get_cache_stats(cache_file)
315
+
316
+ # Get installed packages
317
+ packages = get_installed_packages(app_dir)
318
+ # Filter out scry-run itself and common built-in packages for display
319
+ user_packages = [p for p in packages if not p.startswith(("pip==", "setuptools==", "wheel=="))]
320
+
321
+ # Get Python version
322
+ python_version = _get_python_version(app_dir) or "—"
323
+
324
+ # Get timestamps
325
+ app_created = app_file.stat().st_ctime if app_file.exists() else None
326
+ app_modified = app_file.stat().st_mtime if app_file.exists() else None
327
+
328
+ # Build output
329
+ lines = []
330
+
331
+ # Description section
332
+ lines.append("[bold]Description[/bold]")
333
+ # Indent each line of the description
334
+ for line in description.split("\n"):
335
+ lines.append(f" {line}")
336
+ lines.append("")
337
+
338
+ # Paths section
339
+ lines.append("[bold]Paths[/bold]")
340
+ lines.append(f" [dim]App directory[/dim] {app_dir}")
341
+ lines.append(f" [dim]Main file[/dim] {app_file}")
342
+ lines.append(f" [dim]Cache file[/dim] {cache_file}")
343
+ lines.append("")
344
+
345
+ # Cache section
346
+ lines.append("[bold]Cache[/bold]")
347
+ if cache_stats["methods"] > 0:
348
+ lines.append(f" [dim]Methods[/dim] {cache_stats['methods']}")
349
+ lines.append(f" [dim]Total lines[/dim] {cache_stats['total_lines']}")
350
+ lines.append(f" [dim]Cache size[/dim] {_format_size(cache_stats['size_bytes'])}")
351
+
352
+ # Show method breakdown
353
+ if cache_stats["entries"]:
354
+ method_list = ", ".join(
355
+ f"{e['attr']}" for e in cache_stats["entries"][:5]
356
+ )
357
+ if len(cache_stats["entries"]) > 5:
358
+ method_list += f", ... (+{len(cache_stats['entries']) - 5} more)"
359
+ lines.append(f" [dim]Cached[/dim] {method_list}")
360
+ else:
361
+ lines.append(" [dim]No cached methods[/dim]")
362
+ lines.append("")
363
+
364
+ # Environment section
365
+ lines.append("[bold]Environment[/bold]")
366
+ if venv_path.exists():
367
+ venv_size = _get_venv_size(venv_path)
368
+ lines.append(f" [dim]Venv[/dim] {venv_path}")
369
+ lines.append(f" [dim]Venv size[/dim] {_format_size(venv_size)}")
370
+ lines.append(f" [dim]Python[/dim] {python_version}")
371
+ lines.append(f" [dim]Packages[/dim] {len(user_packages)}")
372
+
373
+ # Show top packages (excluding scry-run internals)
374
+ display_packages = [p.split("==")[0] for p in user_packages if not p.startswith("scry-run")][:5]
375
+ if display_packages:
376
+ pkg_list = ", ".join(display_packages)
377
+ if len(user_packages) > 5:
378
+ pkg_list += f", ... (+{len(user_packages) - 5} more)"
379
+ lines.append(f" [dim]Top packages[/dim] {pkg_list}")
380
+ else:
381
+ lines.append(" [dim]No virtual environment[/dim]")
382
+ lines.append("")
383
+
384
+ # Timestamps section
385
+ lines.append("[bold]Timestamps[/bold]")
386
+ lines.append(f" [dim]Created[/dim] {_format_timestamp(app_created)}")
387
+ lines.append(f" [dim]Last modified[/dim] {_format_timestamp(app_modified)}")
388
+ lines.append(f" [dim]Last cached[/dim] {_format_timestamp(cache_stats['last_cached'])}")
389
+
390
+ # Print as panel
391
+ panel = Panel(
392
+ "\n".join(lines),
393
+ title=f"[bold]{app_name}[/bold]",
394
+ border_style="blue",
395
+ )
396
+ console.print(panel)