siftcode 0.3.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.
siftcode/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Siftcode — local LLM-powered code analysis."""
2
+
3
+ __version__ = "0.1.0"
siftcode/__main__.py ADDED
@@ -0,0 +1,11 @@
1
+ """
2
+ Entry point for `python -m siftcode` and the `siftcode` console script.
3
+ """
4
+
5
+ import sys
6
+
7
+ from siftcode.cli import main
8
+
9
+
10
+ if __name__ == "__main__":
11
+ sys.exit(main())
siftcode/_version.py ADDED
@@ -0,0 +1,59 @@
1
+ """
2
+ Central metadata for Siftcode.
3
+
4
+ This module is the single source of truth for the application's
5
+ version, name, author, license, and other identifying information.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __version__ = "0.3.0"
11
+ __version_info__ = tuple(int(p) for p in __version__.split(".") if p.isdigit())
12
+ __release_date__ = "2026-10-16"
13
+ __status__ = "beta"
14
+
15
+ APP_ID = "io.codeberg.lahtis.Siftcode"
16
+ APP_NAME = "Siftcode"
17
+ APP_SLUG = "siftcode"
18
+ APP_TAGLINE = "Local LLM-powered code analysis"
19
+ APP_SUMMARY = "Local LLM-powered code analysis and developer guide generator"
20
+
21
+ AUTHOR_NAME = "Tuomas Lähteenmäki"
22
+ AUTHOR_EMAIL = "lahtis@gmail.com"
23
+ COPYRIGHT = "© 2026 Tuomas Lähteenmäki"
24
+ COPYRIGHT_YEAR = 2026
25
+
26
+ LICENSE_NAME = "GNU General Public License v3.0 or later"
27
+ LICENSE_SPDX = "GPL-3.0-or-later"
28
+ LICENSE_URL = "https://www.gnu.org/licenses/gpl-3.0.html"
29
+
30
+ HOMEPAGE_URL = "https://codeberg.org/lahtis/siftcode"
31
+ REPOSITORY_URL = "https://codeberg.org/lahtis/siftcode"
32
+ ISSUES_URL = "https://codeberg.org/lahtis/siftcode/issues"
33
+ CHANGELOG_URL = "https://codeberg.org/lahtis/siftcode/src/branch/main/CHANGELOG.md"
34
+
35
+ DESCRIPTION = (
36
+ "Siftcode analyzes software projects using a locally-running "
37
+ "language model (via Ollama) and produces per-file summaries, "
38
+ "per-directory summaries, and a comprehensive Markdown developer guide.\n\n"
39
+ "Everything runs on your own hardware — no code leaves your machine."
40
+ )
41
+
42
+ def version_string() -> str:
43
+ return f"{APP_NAME} {__version__} ({__status__})"
44
+
45
+ def short_version() -> str:
46
+ return __version__
47
+
48
+ def about_data() -> dict:
49
+ return {
50
+ "application_name": APP_NAME,
51
+ "application_icon": APP_ID,
52
+ "version": __version__,
53
+ "developer_name": AUTHOR_NAME,
54
+ "license_type": "gpl-3-0",
55
+ "comments": DESCRIPTION,
56
+ "website": HOMEPAGE_URL,
57
+ "issue_url": ISSUES_URL,
58
+ "copyright": COPYRIGHT,
59
+ }
siftcode/cli.py ADDED
@@ -0,0 +1,531 @@
1
+ """
2
+ Command-line interface for Siftcode.
3
+
4
+ Provides a thin layer over the core modules:
5
+ - config: project save/load/list/delete
6
+ - cache: analysis cache management
7
+ - llm: Ollama client
8
+ - analyzer: project analysis engine
9
+
10
+ No GTK dependencies.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import logging
17
+ import sys
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+
21
+ from siftcode._version import ( # noqa: E402
22
+ APP_NAME,
23
+ APP_SLUG,
24
+ APP_SUMMARY,
25
+ AUTHOR_NAME,
26
+ COPYRIGHT,
27
+ HOMEPAGE_URL,
28
+ LICENSE_NAME,
29
+ LICENSE_SPDX,
30
+ LICENSE_URL,
31
+ __release_date__,
32
+ __status__,
33
+ __version__,
34
+ )
35
+ from siftcode.core.analyzer import ProjectAnalyzer
36
+ from siftcode.core.cache import AnalysisCache
37
+ from siftcode.core.config import (
38
+ PROJECTS_DIR,
39
+ ProjectConfig,
40
+ delete_project,
41
+ list_projects,
42
+ load_project,
43
+ save_project,
44
+ )
45
+ from siftcode.core.llm import LLMError, OllamaClient
46
+ from siftcode.core.manual import SpecGenerator
47
+
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Helpers
54
+ # ---------------------------------------------------------------------------
55
+
56
+ def _setup_logging(verbose: bool, quiet: bool) -> None:
57
+ if quiet:
58
+ level = logging.ERROR
59
+ elif verbose:
60
+ level = logging.INFO
61
+ else:
62
+ level = logging.WARNING
63
+
64
+ logging.basicConfig(
65
+ level=level,
66
+ format="%(asctime)s - %(levelname)s - %(message)s",
67
+ datefmt="%H:%M:%S",
68
+ )
69
+
70
+
71
+ def _resolve_project(name_or_path: str) -> ProjectConfig:
72
+ candidate = Path(name_or_path).expanduser()
73
+
74
+ if candidate.is_dir():
75
+ return ProjectConfig(
76
+ name=candidate.name,
77
+ path=candidate.resolve(),
78
+ created=datetime.now().isoformat(timespec="seconds"),
79
+ )
80
+
81
+ try:
82
+ return load_project(name_or_path)
83
+ except FileNotFoundError:
84
+ raise SystemExit(
85
+ f"error: no project found for '{name_or_path}'\n"
86
+ f"hint: '{name_or_path}' is not a directory and no saved project "
87
+ f"matches. Run '{APP_SLUG} list' to see saved projects."
88
+ )
89
+
90
+
91
+ def _check_ollama(client: OllamaClient) -> None:
92
+ if not client.is_available():
93
+ raise SystemExit(
94
+ f"error: cannot reach Ollama at {client.config.ollama_url}\n"
95
+ f"hint: make sure the Ollama server is running.\n"
96
+ f" Try: ollama serve"
97
+ )
98
+
99
+
100
+ def _ensure_model(client: OllamaClient, model: str) -> None:
101
+ if not client.has_model(model):
102
+ raise SystemExit(
103
+ f"error: model '{model}' is not installed in Ollama\n"
104
+ f"hint: install it with: ollama pull {model}"
105
+ )
106
+
107
+
108
+ def _print_progress(progress) -> None:
109
+ bar_width = 30
110
+ filled = int(bar_width * progress.fraction)
111
+ bar = "█" * filled + "░" * (bar_width - filled)
112
+ print(
113
+ f" [{bar}] {progress.fraction * 100:5.1f}% {progress.message}",
114
+ end="\r" if progress.fraction < 1.0 else "\n",
115
+ flush=True,
116
+ )
117
+
118
+
119
+ def _human_size(num_bytes: int) -> str:
120
+ for unit in ("B", "KB", "MB", "GB"):
121
+ if num_bytes < 1024:
122
+ return f"{num_bytes:.1f} {unit}"
123
+ num_bytes /= 1024
124
+ return f"{num_bytes:.1f} TB"
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Commands
129
+ # ---------------------------------------------------------------------------
130
+
131
+ def cmd_analyze(args: argparse.Namespace) -> int:
132
+ config = _resolve_project(args.project)
133
+
134
+ if not config.path.is_dir():
135
+ print(f"error: project path does not exist: {config.path}", file=sys.stderr)
136
+ return 1
137
+
138
+ if args.model:
139
+ config.model.name = args.model
140
+ if args.num_ctx:
141
+ config.model.num_ctx = args.num_ctx
142
+ if args.timeout:
143
+ config.model.timeout = args.timeout
144
+
145
+ try:
146
+ load_project(config.name)
147
+ save_project(config)
148
+ logger.info(f"Updated saved project '{config.name}'")
149
+ except FileNotFoundError:
150
+ pass
151
+
152
+ config.validate()
153
+
154
+ output_dir = Path(args.output).expanduser() if args.output else Path.cwd()
155
+ output_dir.mkdir(parents=True, exist_ok=True)
156
+
157
+ client = OllamaClient(config.model)
158
+ _check_ollama(client)
159
+ _ensure_model(client, config.model.name)
160
+
161
+ logger.info(f"Analyzing: {config.path}")
162
+ logger.info(f"Model: {config.model.name}")
163
+ logger.info(f"Context: {config.model.num_ctx} tokens")
164
+
165
+ # JOS ADVANCED-LIPPU ON PÄÄLLÄ
166
+ if getattr(args, "advanced", False):
167
+ # Kerätään samat tiedostot kuin ProjectAnalyzer (käyttäen sen sääntöjä)
168
+ temp_analyzer = ProjectAnalyzer(config, client)
169
+ files_to_analyze = temp_analyzer._collect_files()
170
+
171
+ if not files_to_analyze:
172
+ print("No Python files found for advanced specification generation.")
173
+ return 0
174
+
175
+ spec_gen = SpecGenerator(config=config, client=client)
176
+ specs_output_dir = output_dir / f"specs_{config.path.name}_{datetime.now():%Y%m%d_%H%M%S}"
177
+ specs_output_dir.mkdir(parents=True, exist_ok=True)
178
+
179
+ print(f"Running advanced per-file technical specification for {len(files_to_analyze)} files...")
180
+
181
+ def progress_cb(current: int, total: int, path: Path) -> None:
182
+ rel = path.relative_to(config.path).as_posix()
183
+ if len(rel) > 35:
184
+ rel = "..." + rel[-32:]
185
+ fraction = current / total
186
+ bar_width = 20
187
+ filled = int(bar_width * fraction)
188
+ bar = "█" * filled + "░" * (bar_width - filled)
189
+ msg = f" [{bar}] {fraction * 100:5.1f}% ({current}/{total}) {rel}"
190
+ end_char = "\n" if fraction >= 1.0 else ""
191
+ print(f"\r\033[K{msg}", end=end_char, flush=True)
192
+
193
+ try:
194
+ specs_dict = spec_gen.generate_project_specs(files_to_analyze, progress_callback=progress_cb)
195
+ except KeyboardInterrupt:
196
+ print("\n\ninterrupted by user", file=sys.stderr)
197
+ return 130
198
+ except Exception as e:
199
+ print(f"\nerror: {e}", file=sys.stderr)
200
+ return 1
201
+
202
+ # Tallennetaan tiedostot levylle
203
+ for rel_path, markdown_content in specs_dict.items():
204
+ target_file = specs_output_dir / f"{rel_path}.md"
205
+ target_file.parent.mkdir(parents=True, exist_ok=True)
206
+ target_file.write_text(markdown_content, encoding="utf-8")
207
+
208
+ print()
209
+ print(f"✓ Advanced specification generation complete")
210
+ print(f" Output directory: {specs_output_dir}")
211
+ print(f" Files processed: {len(specs_dict)}")
212
+ return 0
213
+
214
+ # NORMAALI ANALYYSI
215
+ analyzer = ProjectAnalyzer(config, client)
216
+
217
+ try:
218
+ for progress in analyzer.analyze_project():
219
+ _print_progress(progress)
220
+ except KeyboardInterrupt:
221
+ print("\n\ninterrupted by user", file=sys.stderr)
222
+ return 130
223
+ except LLMError as e:
224
+ print(f"\nerror: {e}", file=sys.stderr)
225
+ return 1
226
+
227
+ findings_path = output_dir / f"findings_{config.path.name}_{datetime.now():%Y%m%d_%H%M%S}.json"
228
+ guide_path = output_dir / f"guide_{config.path.name}_{datetime.now():%Y%m%d_%H%M%S}.md"
229
+
230
+ analyzer.findings.save(findings_path)
231
+ guide_path.write_text(analyzer.findings.guide, encoding="utf-8")
232
+
233
+ print()
234
+ print(f"✓ Analysis complete")
235
+ print(f" Findings: {findings_path}")
236
+ print(f" Guide: {guide_path}")
237
+ print(f" Errors: {len(analyzer.findings.errors)}")
238
+
239
+ return 0
240
+
241
+
242
+ def cmd_add(args: argparse.Namespace) -> int:
243
+ project_path = Path(args.path).expanduser().resolve()
244
+ if not project_path.is_dir():
245
+ print(f"error: not a directory: {project_path}", file=sys.stderr)
246
+ return 1
247
+
248
+ name = args.name or project_path.name
249
+ config = ProjectConfig(
250
+ name=name,
251
+ path=project_path,
252
+ created=datetime.now().isoformat(timespec="seconds"),
253
+ notes=args.notes or "",
254
+ )
255
+
256
+ try:
257
+ existing = load_project(name)
258
+ if not args.force:
259
+ print(
260
+ f"error: project '{name}' already exists at {existing.path}\n"
261
+ f"hint: use --force to overwrite, or pick another name",
262
+ file=sys.stderr,
263
+ )
264
+ return 1
265
+ except FileNotFoundError:
266
+ pass
267
+
268
+ path = save_project(config)
269
+ print(f"✓ Saved project '{name}'")
270
+ print(f" Config: {path}")
271
+ print(f" Path: {project_path}")
272
+ return 0
273
+
274
+
275
+ def cmd_list(args: argparse.Namespace) -> int:
276
+ projects = list_projects()
277
+
278
+ if not projects:
279
+ print("No saved projects.")
280
+ print(f"Config directory: {PROJECTS_DIR}")
281
+ return 0
282
+
283
+ print(f"{len(projects)} saved project(s):\n")
284
+ for p in projects:
285
+ marker = "●" if p.path.is_dir() else "✗"
286
+ last = p.last_run[:10] if p.last_run else "never"
287
+ print(f" {marker} {p.name}")
288
+ print(f" path: {p.path}")
289
+ print(f" model: {p.model.name}")
290
+ print(f" last run: {last}")
291
+ if p.notes:
292
+ print(f" notes: {p.notes}")
293
+ print()
294
+ return 0
295
+
296
+
297
+ def cmd_show(args: argparse.Namespace) -> int:
298
+ try:
299
+ config = load_project(args.project)
300
+ except FileNotFoundError:
301
+ print(f"error: no saved project named '{args.project}'", file=sys.stderr)
302
+ return 1
303
+
304
+ print(f"Project: {config.name}")
305
+ print(f" Path: {config.path}")
306
+ print(f" Created: {config.created or '(unknown)'}")
307
+ print(f" Last run: {config.last_run or 'never'}")
308
+ print()
309
+ print("Model:")
310
+ print(f" Name: {config.model.name}")
311
+ print(f" Ollama URL: {config.model.ollama_url}")
312
+ print(f" num_ctx: {config.model.num_ctx}")
313
+ print(f" temperature: {config.model.temperature}")
314
+ print(f" timeout: {config.model.timeout}s")
315
+ print()
316
+ print("Analysis:")
317
+ print(f" use_cache: {config.analysis.use_cache}")
318
+ print(f" max_file_chars: {config.analysis.max_file_chars}")
319
+ print(f" exclude: {len(config.analysis.exclude)} entries")
320
+ print(f" {', '.join(config.analysis.exclude[:5])}...")
321
+ if config.notes:
322
+ print()
323
+ print(f"Notes: {config.notes}")
324
+ return 0
325
+
326
+
327
+ def cmd_delete(args: argparse.Namespace) -> int:
328
+ if not args.yes:
329
+ answer = input(f"Delete project '{args.project}'? [y/N] ").strip().lower()
330
+ if answer not in ("y", "yes"):
331
+ print("Aborted.")
332
+ return 0
333
+
334
+ if delete_project(args.project):
335
+ print(f"✓ Deleted project '{args.project}'")
336
+ return 0
337
+ print(f"error: no saved project named '{args.project}'", file=sys.stderr)
338
+ return 1
339
+
340
+
341
+ def cmd_cache_stats(args: argparse.Namespace) -> int:
342
+ cache = AnalysisCache()
343
+ s = cache.stats()
344
+
345
+ print(f"Cache directory: {s['base_dir']}")
346
+ print()
347
+ print(f"{'Category':<15} {'Entries':>10} {'Size':>12}")
348
+ print(f"{'-' * 15} {'-' * 10} {'-' * 12}")
349
+ for name in ("files", "directories", "roots"):
350
+ info = s[name]
351
+ print(f"{name:<15} {info['entries']:>10} {_human_size(info['bytes']):>12}")
352
+ print(f"{'-' * 15} {'-' * 10} {'-' * 12}")
353
+ print(f"{'Total':<15} {s['total']['entries']:>10} {s['total']['human_size']:>12}")
354
+ return 0
355
+
356
+
357
+ def cmd_cache_clear(args: argparse.Namespace) -> int:
358
+ cache = AnalysisCache()
359
+
360
+ if args.model:
361
+ if not args.yes:
362
+ answer = input(f"Clear cache for model '{args.model}'? [y/N] ").strip().lower()
363
+ if answer not in ("y", "yes"):
364
+ print("Aborted.")
365
+ return 0
366
+ removed = cache.clear(model=args.model)
367
+ print(f"✓ Removed {removed} cache entries for model '{args.model}'")
368
+ else:
369
+ if not args.yes:
370
+ answer = input("Clear the ENTIRE cache? [y/N] ").strip().lower()
371
+ if answer not in ("y", "yes"):
372
+ print("Aborted.")
373
+ return 0
374
+ removed = cache.clear()
375
+ print(f"✓ Removed {removed} cache entries")
376
+
377
+ return 0
378
+
379
+
380
+ def cmd_cache_gc(args: argparse.Namespace) -> int:
381
+ cache = AnalysisCache()
382
+ removed = cache.gc(max_age_days=args.days)
383
+ print(f"✓ Removed {removed} cache entries older than {args.days} days")
384
+ return 0
385
+
386
+
387
+ def cmd_about(args: argparse.Namespace) -> int:
388
+ """Print version, author, and license info."""
389
+ print(f"{APP_NAME} {__version__} ({__status__})")
390
+ print(f"Released: {__release_date__}")
391
+ print()
392
+ print(APP_SUMMARY)
393
+ print()
394
+ print(f"Author: {AUTHOR_NAME}")
395
+ print(f"Copyright: {COPYRIGHT}")
396
+ print(f"License: {LICENSE_NAME}")
397
+ print(f" {LICENSE_URL}")
398
+ print()
399
+ print(f"Homepage: {HOMEPAGE_URL}")
400
+ return 0
401
+
402
+
403
+ # ---------------------------------------------------------------------------
404
+ # Parser
405
+ # ---------------------------------------------------------------------------
406
+
407
+ def build_parser() -> argparse.ArgumentParser:
408
+ parser = argparse.ArgumentParser(
409
+ prog=APP_SLUG,
410
+ description=APP_SUMMARY,
411
+ epilog=f"Licensed under {LICENSE_SPDX}. See '{APP_SLUG} about' for details.",
412
+ )
413
+ parser.add_argument(
414
+ "--version", action="version",
415
+ version=f"{APP_SLUG} {__version__}",
416
+ )
417
+ parser.add_argument(
418
+ "-v", "--verbose", action="store_true",
419
+ help="enable debug logging",
420
+ )
421
+ parser.add_argument(
422
+ "-q", "--quiet", action="store_true",
423
+ help="only show warnings and errors",
424
+ )
425
+
426
+ sub = parser.add_subparsers(dest="command", required=True)
427
+
428
+ # analyze
429
+ p_analyze = sub.add_parser("analyze", help="Analyze a project")
430
+ p_analyze.add_argument(
431
+ "project",
432
+ help="Path to project directory, or name of a saved project",
433
+ )
434
+ p_analyze.add_argument(
435
+ "-m", "--model",
436
+ help="Override LLM model (e.g. qwen2.5-coder:14b)",
437
+ )
438
+ p_analyze.add_argument(
439
+ "--num-ctx", type=int,
440
+ help="Override context length in tokens",
441
+ )
442
+ p_analyze.add_argument(
443
+ "--timeout", type=int,
444
+ help="Override per-request timeout in seconds",
445
+ )
446
+ p_analyze.add_argument(
447
+ "-o", "--output", default=".",
448
+ help="Directory for findings.json and guide.md (default: .)",
449
+ )
450
+ p_analyze.add_argument(
451
+ "--advanced", action="store_true",
452
+ help="Run advanced per-file technical specification generator.",
453
+ )
454
+ p_analyze.set_defaults(func=cmd_analyze)
455
+
456
+ # add
457
+ p_add = sub.add_parser("add", help="Register a project directory")
458
+ p_add.add_argument("path", help="Path to project directory")
459
+ p_add.add_argument("-n", "--name", help="Name to save under (default: dir name)")
460
+ p_add.add_argument("--notes", help="Optional notes")
461
+ p_add.add_argument("--force", action="store_true", help="Overwrite existing")
462
+ p_add.set_defaults(func=cmd_add)
463
+
464
+ # list
465
+ p_list = sub.add_parser("list", help="List saved projects")
466
+ p_list.set_defaults(func=cmd_list)
467
+
468
+ # show
469
+ p_show = sub.add_parser("show", help="Show a project's configuration")
470
+ p_show.add_argument("project", help="Saved project name")
471
+ p_show.set_defaults(func=cmd_show)
472
+
473
+ # delete
474
+ p_del = sub.add_parser("delete", help="Delete a saved project")
475
+ p_del.add_argument("project", help="Saved project name")
476
+ p_del.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
477
+ p_del.set_defaults(func=cmd_delete)
478
+
479
+ # cache
480
+ p_cache = sub.add_parser("cache", help="Manage the analysis cache")
481
+ cache_sub = p_cache.add_subparsers(dest="cache_command", required=True)
482
+
483
+ p_cache_stats = cache_sub.add_parser("stats", help="Show cache statistics")
484
+ p_cache_stats.set_defaults(func=cmd_cache_stats)
485
+
486
+ p_cache_clear = cache_sub.add_parser("clear", help="Clear cache entries")
487
+ p_cache_clear.add_argument(
488
+ "--model",
489
+ help="Only clear entries for this model (default: all)",
490
+ )
491
+ p_cache_clear.add_argument(
492
+ "-y", "--yes", action="store_true",
493
+ help="Skip confirmation prompt",
494
+ )
495
+ p_cache_clear.set_defaults(func=cmd_cache_clear)
496
+
497
+ p_cache_gc = cache_sub.add_parser("gc", help="Remove old cache entries")
498
+ p_cache_gc.add_argument(
499
+ "--days", type=float, default=30.0,
500
+ help="Remove entries not used in N days (default: 30)",
501
+ )
502
+ p_cache_gc.set_defaults(func=cmd_cache_gc)
503
+
504
+ # about
505
+ p_about = sub.add_parser("about", help="Show version, author, and license")
506
+ p_about.set_defaults(func=cmd_about)
507
+
508
+ return parser
509
+
510
+
511
+ # ---------------------------------------------------------------------------
512
+ # Entry point
513
+ # ---------------------------------------------------------------------------
514
+
515
+ def main(argv: list[str] | None = None) -> int:
516
+ parser = build_parser()
517
+ args = parser.parse_args(argv)
518
+
519
+ _setup_logging(getattr(args, "verbose", False), getattr(args, "quiet", False))
520
+
521
+ try:
522
+ return args.func(args)
523
+ except SystemExit as e:
524
+ if isinstance(e.code, str):
525
+ print(e.code, file=sys.stderr)
526
+ return 2
527
+ return e.code if isinstance(e.code, int) else 1
528
+
529
+
530
+ if __name__ == "__main__":
531
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Siftcode — local LLM-powered code analysis."""