azathoth 0.0.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.
azathoth/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """
2
+ Azathoth: AI Architect & Development Framework
3
+ """
4
+
5
+ from pathlib import Path
6
+ from dotenv import load_dotenv
7
+
8
+ # Load .env from azathoth's own directory, not the cwd
9
+ load_dotenv(Path(__file__).parent.parent.parent / ".env")
10
+
11
+ from azathoth.cli import init_cli
12
+
13
+
14
+ def main() -> None:
15
+ """Main entry point for the Azathoth CLI."""
16
+ init_cli()
17
+
18
+
19
+ if __name__ == "__main__":
20
+ main()
File without changes
File without changes
@@ -0,0 +1,6 @@
1
+ # cli/__init__.py — just re-export, nothing else
2
+ from azathoth.cli.main import app
3
+
4
+
5
+ def init_cli():
6
+ app()
File without changes
@@ -0,0 +1,273 @@
1
+ import asyncio
2
+ from pathlib import Path
3
+ from typing import Optional
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ from rich.panel import Panel
9
+ from rich.progress import Progress, SpinnerColumn, TextColumn
10
+
11
+ from azathoth.core.i18n import (
12
+ InlangConfig,
13
+ resolve_paths,
14
+ load_all_translations,
15
+ diff_against_base,
16
+ translate_locale,
17
+ merge_translations,
18
+ write_translations,
19
+ prune_orphans,
20
+ build_matrix,
21
+ export_registry,
22
+ import_registry,
23
+ )
24
+ from azathoth.core.exceptions import I18nError
25
+
26
+ app = typer.Typer(help="i18n translation automation commands.")
27
+ console = Console()
28
+
29
+
30
+ @app.command()
31
+ def translate(
32
+ settings_path: Path = typer.Argument(
33
+ ..., help="Path to project.inlang/settings.json"
34
+ ),
35
+ full: bool = typer.Option(
36
+ False, "--full", help="Retranslate all keys, not just missing ones."
37
+ ),
38
+ dry_run: bool = typer.Option(
39
+ False, "--dry-run", help="Preview changes without writing to files."
40
+ ),
41
+ prune: bool = typer.Option(
42
+ False, "--prune", help="Remove orphan keys from target files."
43
+ ),
44
+ ):
45
+ """Translate missing keys using AI."""
46
+ try:
47
+ config = InlangConfig.from_json(settings_path)
48
+ paths = resolve_paths(settings_path, config)
49
+ translations = load_all_translations(paths)
50
+
51
+ base_locale = config.base_locale
52
+ if base_locale not in translations:
53
+ console.print(
54
+ f"[red]Error: Base locale '{base_locale}' not found in translations.[/red]"
55
+ )
56
+ raise typer.Exit(1)
57
+
58
+ base_set = translations[base_locale]
59
+ target_locales = [l for l in config.locales if l != base_locale]
60
+
61
+ async def run_translations():
62
+ tasks = []
63
+ results = {}
64
+
65
+ with Progress(
66
+ SpinnerColumn(),
67
+ TextColumn("[progress.description]{task.description}"),
68
+ console=console,
69
+ ) as progress:
70
+ for locale in target_locales:
71
+ target_set = translations.get(locale)
72
+ diff = diff_against_base(base_set, target_set)
73
+
74
+ keys_to_translate = diff.missing_keys
75
+ if full:
76
+ keys_to_translate = list(base_set.messages.keys())
77
+
78
+ if not keys_to_translate:
79
+ console.print(
80
+ f"[yellow]Skipping {locale}: No keys to translate.[/yellow]"
81
+ )
82
+ continue
83
+
84
+ values_to_translate = [
85
+ base_set.messages[k] for k in keys_to_translate
86
+ ]
87
+
88
+ # Style samples (first 5 existing translations)
89
+ samples = []
90
+ existing_keys = [
91
+ k for k in base_set.messages.keys() if k in target_set.messages
92
+ ]
93
+ for k in existing_keys[:5]:
94
+ samples.append((base_set.messages[k], target_set.messages[k]))
95
+
96
+ task_id = progress.add_task(
97
+ description=f"Translating {locale} ({len(keys_to_translate)} keys)...",
98
+ total=None,
99
+ )
100
+
101
+ async def do_translate(
102
+ l=locale,
103
+ k=keys_to_translate,
104
+ v=values_to_translate,
105
+ s=samples,
106
+ t_id=task_id,
107
+ ):
108
+ try:
109
+ res = await translate_locale(l, k, v, s)
110
+ progress.update(
111
+ t_id,
112
+ completed=True,
113
+ description=f"[green]Finished {l}[/green]",
114
+ )
115
+ return l, k, res
116
+ except Exception as e:
117
+ progress.update(
118
+ t_id,
119
+ completed=True,
120
+ description=f"[red]Failed {l}[/red]",
121
+ )
122
+ return l, k, e
123
+
124
+ tasks.append(do_translate())
125
+
126
+ if not tasks:
127
+ console.print("[green]All translations up to date.[/green]")
128
+ return
129
+
130
+ batch_results = await asyncio.gather(*tasks)
131
+ for locale, keys, result in batch_results:
132
+ if isinstance(result, Exception):
133
+ console.print(
134
+ f"[red]Error translating {locale}: {str(result)}[/red]"
135
+ )
136
+ else:
137
+ results[locale] = (keys, result)
138
+ return results
139
+
140
+ results = asyncio.run(run_translations())
141
+
142
+ if not results:
143
+ return
144
+
145
+ # Apply changes
146
+ for locale, (keys, values) in results.items():
147
+ target_set = translations[locale]
148
+ new_set = merge_translations(target_set, keys, values)
149
+
150
+ if prune:
151
+ new_set = prune_orphans(new_set, base_set)
152
+
153
+ if not dry_run:
154
+ write_translations(paths[locale], new_set)
155
+ console.print(f"[green]Updated {paths[locale]}[/green]")
156
+ else:
157
+ console.print(
158
+ f"[yellow][DRY RUN] Would update {paths[locale]}[/yellow]"
159
+ )
160
+
161
+ except I18nError as e:
162
+ console.print(f"[red]Error: {str(e)}[/red]")
163
+ raise typer.Exit(1)
164
+
165
+
166
+ @app.command()
167
+ def audit(
168
+ settings_path: Path = typer.Argument(
169
+ ..., help="Path to project.inlang/settings.json"
170
+ ),
171
+ ):
172
+ """Display a translation coverage matrix."""
173
+ try:
174
+ config = InlangConfig.from_json(settings_path)
175
+ paths = resolve_paths(settings_path, config)
176
+ translations = load_all_translations(paths)
177
+
178
+ locales = config.locales
179
+ matrix = build_matrix(translations, locales)
180
+
181
+ table = Table(title="i18n Translation Audit")
182
+ table.add_column("Key", style="cyan", no_wrap=True)
183
+ for locale in locales:
184
+ table.add_column(locale, justify="center")
185
+
186
+ for key in matrix.keys:
187
+ row = [key]
188
+ for locale in locales:
189
+ val = matrix.matrix[key][locale]
190
+ if val:
191
+ row.append("[green]✓[/green]")
192
+ else:
193
+ row.append("[red]✗[/red]")
194
+ table.add_row(*row)
195
+
196
+ # Totals row
197
+ totals = ["TOTAL"]
198
+ for locale in locales:
199
+ count = sum(1 for k in matrix.keys if matrix.matrix[k][locale])
200
+ percent = (count / len(matrix.keys)) * 100 if matrix.keys else 0
201
+ color = "green" if percent == 100 else "yellow" if percent > 80 else "red"
202
+ totals.append(
203
+ f"[{color}]{count}/{len(matrix.keys)} ({percent:.0f}%)[/{color}]"
204
+ )
205
+ table.add_section()
206
+ table.add_row(*totals)
207
+
208
+ console.print(table)
209
+
210
+ except I18nError as e:
211
+ console.print(f"[red]Error: {str(e)}[/red]")
212
+ raise typer.Exit(1)
213
+
214
+
215
+ @app.command()
216
+ def export(
217
+ settings_path: Path = typer.Argument(
218
+ ..., help="Path to project.inlang/settings.json"
219
+ ),
220
+ output: Path = typer.Option(
221
+ "registry.json", "--output", "-o", help="Output file path."
222
+ ),
223
+ fmt: str = typer.Option("json", "--format", "-f", help="Export format (json, py)."),
224
+ ):
225
+ """Export all translations to a master registry file."""
226
+ try:
227
+ config = InlangConfig.from_json(settings_path)
228
+ paths = resolve_paths(settings_path, config)
229
+ translations = load_all_translations(paths)
230
+
231
+ matrix = build_matrix(translations, config.locales)
232
+ export_registry(matrix, output, fmt)
233
+ console.print(f"[green]Exported registry to {output}[/green]")
234
+
235
+ except I18nError as e:
236
+ console.print(f"[red]Error: {str(e)}[/red]")
237
+ raise typer.Exit(1)
238
+
239
+
240
+ @app.command()
241
+ def sync(
242
+ registry_path: Path = typer.Argument(..., help="Path to registry.json"),
243
+ settings_path: Path = typer.Argument(
244
+ ..., help="Path to project.inlang/settings.json"
245
+ ),
246
+ ):
247
+ """Sync a registry file back to individual locale files."""
248
+ try:
249
+ matrix = import_registry(registry_path)
250
+ config = InlangConfig.from_json(settings_path)
251
+ paths = resolve_paths(settings_path, config)
252
+
253
+ for locale in matrix.locales:
254
+ if locale not in paths:
255
+ console.print(
256
+ f"[yellow]Warning: Locale '{locale}' in registry not found in config. Skipping.[/yellow]"
257
+ )
258
+ continue
259
+
260
+ messages = {}
261
+ for key in matrix.keys:
262
+ val = matrix.matrix[key].get(locale)
263
+ if val:
264
+ messages[key] = val
265
+
266
+ write_translations(
267
+ paths[locale], TranslationSet(locale=locale, messages=messages)
268
+ )
269
+ console.print(f"[green]Synced {paths[locale]}[/green]")
270
+
271
+ except I18nError as e:
272
+ console.print(f"[red]Error: {str(e)}[/red]")
273
+ raise typer.Exit(1)
@@ -0,0 +1,316 @@
1
+ import asyncio
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+ from typing import Optional, Dict, Any
5
+
6
+ import typer
7
+ from rich.console import Console, RenderableType
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+ from rich import box
11
+ from rich.progress import (
12
+ Progress,
13
+ SpinnerColumn,
14
+ BarColumn,
15
+ TextColumn,
16
+ TimeElapsedColumn,
17
+ MofNCompleteColumn,
18
+ ProgressColumn,
19
+ Task,
20
+ )
21
+ from rich.text import Text
22
+
23
+ from azathoth.config import config
24
+ from azathoth.core.ingest import (
25
+ ingest,
26
+ IngestionResult,
27
+ detect_type,
28
+ IngestType,
29
+ fetch_user_repos,
30
+ get_subpath_context,
31
+ )
32
+ from azathoth.core.utils import format_size
33
+
34
+ # --- AGGRESSIVE LOG SILENCING ---
35
+ try:
36
+ from loguru import logger
37
+
38
+ logger.remove()
39
+ logger.disable("gitingest")
40
+ except ImportError:
41
+ pass
42
+
43
+ console = Console()
44
+ app = typer.Typer(help="Ingest codebases into a single file.", no_args_is_help=True)
45
+
46
+
47
+ class StatusSpinnerColumn(ProgressColumn):
48
+ """Morphs from spinner to ✓/✗ on completion."""
49
+
50
+ def __init__(self):
51
+ super().__init__()
52
+ self.spinner = SpinnerColumn(spinner_name="dots")
53
+
54
+ def render(self, task: "Task") -> RenderableType:
55
+ if task.finished:
56
+ icon = task.fields.get("status_icon", "[bold green]✓[/]")
57
+ return Text.from_markup(icon)
58
+ return self.spinner.render(task)
59
+
60
+
61
+ def _display_info_panel(
62
+ target: str, detected_type: IngestType, mode: str, ignore_gitignore: bool = False
63
+ ):
64
+ """The blue info panel at the start."""
65
+ table = Table(show_header=False, box=None, padding=(0, 1))
66
+ table.add_column(style="dim")
67
+ table.add_column(style="bold cyan")
68
+ table.add_row("Target:", target)
69
+ table.add_row("Type:", detected_type.name)
70
+ table.add_row("Mode:", mode)
71
+ if ignore_gitignore:
72
+ table.add_row("Ignore Gitignore:", "[bold yellow]Yes[/]")
73
+
74
+ panel = Panel(
75
+ table,
76
+ title="🚀 [bold]Ingestion Started[/bold]",
77
+ border_style="blue",
78
+ expand=False,
79
+ )
80
+ console.print(panel)
81
+
82
+
83
+ def _display_metrics_panel(result: IngestionResult, save_path: Optional[Path]):
84
+ """The green summary panel at the end."""
85
+ table = Table(show_header=False, box=None, padding=(0, 1))
86
+ table.add_column(style="dim")
87
+ table.add_column(style="bold cyan")
88
+
89
+ table.add_row("Files", str(result.metrics.file_count))
90
+ table.add_row("Tokens", f"{result.metrics.token_count:,}")
91
+ table.add_row("Size", format_size(result.metrics.size_bytes))
92
+ if save_path:
93
+ table.add_row("Saved to", f"@{save_path}")
94
+
95
+ panel = Panel(
96
+ table,
97
+ title="[bold green]✓ Ingestion Complete![/]",
98
+ expand=False,
99
+ border_style="green",
100
+ )
101
+ console.print(panel)
102
+
103
+
104
+ def list_reports():
105
+ """List all saved ingestion reports."""
106
+ reports = sorted(
107
+ config.reports_dir.glob("*.*"), key=lambda p: p.stat().st_mtime, reverse=True
108
+ )
109
+
110
+ if not reports:
111
+ console.print("[yellow]No reports found.[/]")
112
+ return
113
+
114
+ table = Table(title="Ingestion Reports", box=box.SIMPLE)
115
+ table.add_column("Filename", style="cyan")
116
+ table.add_column("Date", style="dim")
117
+ table.add_column("Size", style="green")
118
+
119
+ for r in reports:
120
+ size = format_size(r.stat().st_size)
121
+ mtime = datetime.fromtimestamp(r.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
122
+ table.add_row(r.name, mtime, size)
123
+
124
+ console.print(table)
125
+
126
+
127
+ async def _ingest_single(
128
+ target: str,
129
+ list_only: bool,
130
+ save: bool,
131
+ output: Optional[Path],
132
+ fmt: str,
133
+ clipboard: bool,
134
+ ignore_gitignore: bool = False,
135
+ ):
136
+ """Handles ingestion for a single target."""
137
+ target_path = Path(target).resolve() if Path(target).exists() else None
138
+
139
+ # Determine mode string for display
140
+ if target_path and target_path.is_file():
141
+ mode = f"Single file → [bold]{target_path.name}[/]"
142
+ elif list_only:
143
+ mode = "Structure only [dim](--list)[/]"
144
+ else:
145
+ mode = "Full ingest"
146
+
147
+ ctx = await get_subpath_context(target)
148
+ if ctx:
149
+ root_name, rel_path = ctx
150
+ console.print(f"[dim]Context:[/dim] Detected Git Root at [bold]{root_name}[/]")
151
+ console.print(
152
+ f"[dim]Scope:[/dim] Restricting ingestion to [bold]{rel_path}[/]"
153
+ )
154
+
155
+ itype = detect_type(target)
156
+ _display_info_panel(target, itype, mode, ignore_gitignore=ignore_gitignore)
157
+
158
+ with console.status(f"⠋ Ingesting [cyan]{target}[/cyan]...", spinner="dots"):
159
+ try:
160
+ result = await ingest(
161
+ target, list_only=list_only, ignore_gitignore=ignore_gitignore
162
+ )
163
+ except Exception as e:
164
+ console.print(f"[bold red]✗ Ingestion failed:[/] {e}")
165
+ raise typer.Exit(1)
166
+
167
+ # Determine save path
168
+ save_path = None
169
+ if save or output:
170
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
171
+ list_tag = "-list" if list_only else ""
172
+ filename = f"{result.suggested_filename}{list_tag}-{timestamp}.{fmt}"
173
+ save_path = output or (config.reports_dir / filename)
174
+
175
+ full_report = result.format_report(fmt=fmt)
176
+ save_path.write_text(full_report, encoding="utf-8")
177
+
178
+ if clipboard:
179
+ try:
180
+ import pyperclip
181
+
182
+ pyperclip.copy(result.format_report(fmt=fmt))
183
+ console.print("[dim]→ Copied to clipboard[/]")
184
+ except ImportError:
185
+ console.print("[yellow]→ Clipboard failed: pyperclip not installed[/]")
186
+
187
+ _display_metrics_panel(result, save_path)
188
+
189
+
190
+ async def _ingest_user(
191
+ target: str,
192
+ output_dir: Path,
193
+ fmt: str,
194
+ separate: bool,
195
+ ignore_gitignore: bool = False,
196
+ ):
197
+ """Concurrent multi-repo ingestion for GitHub users."""
198
+ username = target.rstrip("/").split("/")[-1]
199
+
200
+ try:
201
+ repos = await fetch_user_repos(username)
202
+ except Exception as e:
203
+ console.print(f"[bold red]✗ Error fetching repos for {username}:[/] {e}")
204
+ return
205
+
206
+ if not repos:
207
+ console.print(f"[yellow]No public source repositories found for {username}.[/]")
208
+ return
209
+
210
+ console.print(f"[bold green]✓[/] Found [bold]{len(repos)}[/] source repositories.")
211
+
212
+ semaphore = asyncio.Semaphore(5)
213
+ full_content = []
214
+
215
+ progress_cols = (
216
+ TextColumn(" "),
217
+ StatusSpinnerColumn(),
218
+ TextColumn("[bold blue]{task.description}[/]"),
219
+ BarColumn(bar_width=40),
220
+ MofNCompleteColumn(),
221
+ TimeElapsedColumn(),
222
+ )
223
+
224
+ with Progress(*progress_cols, console=console, expand=False) as progress:
225
+ main_task = progress.add_task(f"Ingesting {username}...", total=len(repos))
226
+
227
+ async def _work(repo: Dict[str, Any]):
228
+ async with semaphore:
229
+ try:
230
+ res = await ingest(
231
+ repo["clone_url"], ignore_gitignore=ignore_gitignore
232
+ )
233
+ if separate:
234
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
235
+ path = (
236
+ output_dir / f"{res.suggested_filename}-{timestamp}.{fmt}"
237
+ )
238
+ path.write_text(res.format_report(fmt=fmt), encoding="utf-8")
239
+ else:
240
+ full_content.append(
241
+ f"\n\n{'=' * 40}\nREPO: {res.suggested_filename}\n{'=' * 40}\n{res.content}"
242
+ )
243
+ progress.update(main_task, advance=1)
244
+ except Exception:
245
+ progress.update(main_task, advance=1, status_icon="[bold red]✗[/]")
246
+
247
+ await asyncio.gather(*[_work(r) for r in repos])
248
+
249
+ if not separate and full_content:
250
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
251
+ save_path = output_dir / f"{username}-profile-{timestamp}.{fmt}"
252
+ save_path.write_text("\n".join(full_content), encoding="utf-8")
253
+ console.print(
254
+ f"\n[bold green]✓[/] Profile digest saved to: [bold]{save_path}[/]"
255
+ )
256
+
257
+
258
+ def main(
259
+ ctx: typer.Context,
260
+ target: Optional[str] = typer.Argument(None, help="Path, GitHub URL, or Username"),
261
+ list_only: bool = typer.Option(
262
+ False, "--list", "-l", help="Structure only, no file content"
263
+ ),
264
+ ignore_gitignore: bool = typer.Option(
265
+ False,
266
+ "--no-git-ignore",
267
+ help="Ignore .gitignore patterns and ingest everything",
268
+ ),
269
+ save: bool = typer.Option(True, "--save/--no-save", help="Save report to file"),
270
+ output: Optional[Path] = typer.Option(
271
+ None, "--output", "-o", help="Custom output path"
272
+ ),
273
+ format: str = typer.Option("txt", "--format", "-f", help="txt, md, xml"),
274
+ clipboard: bool = typer.Option(
275
+ False, "--clipboard", "-c", help="Copy to clipboard"
276
+ ),
277
+ separate: bool = typer.Option(
278
+ False, "--separate", "-s", help="Split user repos into files"
279
+ ),
280
+ list_reports_flag: bool = typer.Option(
281
+ False, "--reports", help="List saved reports"
282
+ ),
283
+ ):
284
+ """
285
+ Ingest codebases into a single file.
286
+ """
287
+ if list_reports_flag:
288
+ list_reports()
289
+ return
290
+
291
+ if not target:
292
+ console.print(ctx.get_help())
293
+ return
294
+
295
+ async def _run():
296
+ itype = detect_type(target)
297
+ if itype == IngestType.GITHUB_USER:
298
+ await _ingest_user(
299
+ target,
300
+ output or config.reports_dir,
301
+ format,
302
+ separate,
303
+ ignore_gitignore=ignore_gitignore,
304
+ )
305
+ else:
306
+ await _ingest_single(
307
+ target,
308
+ list_only,
309
+ save,
310
+ output,
311
+ format,
312
+ clipboard,
313
+ ignore_gitignore=ignore_gitignore,
314
+ )
315
+
316
+ asyncio.run(_run())