siftcode 0.3.0__py3-none-any.whl → 0.4.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 CHANGED
@@ -1,3 +1,5 @@
1
1
  """Siftcode — local LLM-powered code analysis."""
2
2
 
3
- __version__ = "0.1.0"
3
+ from siftcode._version import __version__
4
+
5
+ __all__ = ["__version__"]
siftcode/_version.py CHANGED
@@ -7,9 +7,40 @@ version, name, author, license, and other identifying information.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- __version__ = "0.3.0"
10
+ from typing import Any
11
+
12
+
13
+ __all__ = [
14
+ "__version__",
15
+ "__version_info__",
16
+ "__release_date__",
17
+ "__status__",
18
+ "APP_ID",
19
+ "APP_NAME",
20
+ "APP_SLUG",
21
+ "APP_TAGLINE",
22
+ "APP_SUMMARY",
23
+ "AUTHOR_NAME",
24
+ "AUTHOR_EMAIL",
25
+ "COPYRIGHT",
26
+ "COPYRIGHT_YEAR",
27
+ "LICENSE_NAME",
28
+ "LICENSE_SPDX",
29
+ "LICENSE_URL",
30
+ "HOMEPAGE_URL",
31
+ "REPOSITORY_URL",
32
+ "ISSUES_URL",
33
+ "CHANGELOG_URL",
34
+ "DESCRIPTION",
35
+ "version_string",
36
+ "short_version",
37
+ "about_data",
38
+ ]
39
+
40
+
41
+ __version__ = "0.4.0"
11
42
  __version_info__ = tuple(int(p) for p in __version__.split(".") if p.isdigit())
12
- __release_date__ = "2026-10-16"
43
+ __release_date__ = "2026-11-12"
13
44
  __status__ = "beta"
14
45
 
15
46
  APP_ID = "io.codeberg.lahtis.Siftcode"
@@ -39,19 +70,32 @@ DESCRIPTION = (
39
70
  "Everything runs on your own hardware — no code leaves your machine."
40
71
  )
41
72
 
73
+
42
74
  def version_string() -> str:
75
+ """Return a human-readable version string, e.g. 'Siftcode 0.3.0 (beta)'."""
43
76
  return f"{APP_NAME} {__version__} ({__status__})"
44
77
 
78
+
45
79
  def short_version() -> str:
80
+ """Return just the version number, e.g. '0.3.0'."""
46
81
  return __version__
47
82
 
48
- def about_data() -> dict:
83
+
84
+ def about_data() -> dict[str, Any]:
85
+ """
86
+ Return a dictionary of metadata suitable for Gtk.AboutDialog.
87
+
88
+ The 'license_type' value is a Gtk.License enum name; 'gpl-3-0-later'
89
+ matches LICENSE_SPDX = 'GPL-3.0-or-later'. Using 'gpl-3-0' here would
90
+ incorrectly claim GPL-3.0-only, which is stricter than the project's
91
+ actual license.
92
+ """
49
93
  return {
50
94
  "application_name": APP_NAME,
51
95
  "application_icon": APP_ID,
52
96
  "version": __version__,
53
97
  "developer_name": AUTHOR_NAME,
54
- "license_type": "gpl-3-0",
98
+ "license_type": "gpl-3-0-later",
55
99
  "comments": DESCRIPTION,
56
100
  "website": HOMEPAGE_URL,
57
101
  "issue_url": ISSUES_URL,
siftcode/cli.py CHANGED
@@ -6,6 +6,7 @@ Provides a thin layer over the core modules:
6
6
  - cache: analysis cache management
7
7
  - llm: Ollama client
8
8
  - analyzer: project analysis engine
9
+ - manual: advanced per-file specification generator
9
10
 
10
11
  No GTK dependencies.
11
12
  """
@@ -14,6 +15,7 @@ from __future__ import annotations
14
15
 
15
16
  import argparse
16
17
  import logging
18
+ import shutil
17
19
  import sys
18
20
  from datetime import datetime
19
21
  from pathlib import Path
@@ -36,13 +38,17 @@ from siftcode.core.analyzer import ProjectAnalyzer
36
38
  from siftcode.core.cache import AnalysisCache
37
39
  from siftcode.core.config import (
38
40
  PROJECTS_DIR,
41
+ ModelConfig,
39
42
  ProjectConfig,
40
43
  delete_project,
41
44
  list_projects,
42
45
  load_project,
43
46
  save_project,
44
47
  )
45
- from siftcode.core.llm import LLMError, OllamaClient
48
+ from siftcode.core.llm import (
49
+ LLMError,
50
+ OllamaClient,
51
+ )
46
52
  from siftcode.core.manual import SpecGenerator
47
53
 
48
54
 
@@ -86,34 +92,91 @@ def _resolve_project(name_or_path: str) -> ProjectConfig:
86
92
  f"hint: '{name_or_path}' is not a directory and no saved project "
87
93
  f"matches. Run '{APP_SLUG} list' to see saved projects."
88
94
  )
89
-
90
-
91
- def _check_ollama(client: OllamaClient) -> None:
92
- if not client.is_available():
95
+ except ValueError as e:
96
+ # load_project now raises ValueError for malformed TOML files.
93
97
  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"
98
+ f"error: saved project '{name_or_path}' is invalid: {e}\n"
99
+ f"hint: fix or delete the config file under {PROJECTS_DIR}"
97
100
  )
98
101
 
99
102
 
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
- )
103
+ def _parse_model_stages(specs: list[str]) -> dict[str, str]:
104
+ """
105
+ Parse --model-stage STAGE=MODEL arguments into a dict.
106
+
107
+ Raises ValueError with a user-friendly message if any spec is
108
+ malformed. Used by cmd_analyze before config.validate() so the
109
+ error is reported as a CLI error (exit 2) rather than a config
110
+ validation error.
111
+ """
112
+ stages: dict[str, str] = {}
113
+ for spec in specs:
114
+ if "=" not in spec:
115
+ raise ValueError(
116
+ f"--model-stage requires STAGE=MODEL, got {spec!r}.\n"
117
+ f"Valid stages: {', '.join(ModelConfig.VALID_STAGES)}"
118
+ )
119
+ stage, _, model_name = spec.partition("=")
120
+ stage = stage.strip()
121
+ model_name = model_name.strip()
122
+ if not stage or not model_name:
123
+ raise ValueError(
124
+ f"--model-stage requires STAGE=MODEL, got {spec!r}.\n"
125
+ f"Valid stages: {', '.join(ModelConfig.VALID_STAGES)}"
126
+ )
127
+ if stage not in ModelConfig.VALID_STAGES:
128
+ raise ValueError(
129
+ f"unknown stage '{stage}' in --model-stage.\n"
130
+ f"Valid stages: {', '.join(ModelConfig.VALID_STAGES)}"
131
+ )
132
+ stages[stage] = model_name
133
+ return stages
134
+
135
+
136
+ def _check_ollama(client: OllamaClient, config: ProjectConfig) -> None:
137
+ """
138
+ Verify Ollama is reachable AND all configured models are installed.
139
+
140
+ The actual diagnosis (DNS vs refused vs timeout) is done by
141
+ OllamaClient.check_connection() in llm.py, so both the CLI and
142
+ the GUI get the same user-friendly messages from one place.
143
+
144
+ All models referenced by config.model.stages are checked here
145
+ so a missing model fails at startup, not 20 minutes into a run
146
+ when the guide stage tries to load it.
147
+ """
148
+ try:
149
+ client.check_connection()
150
+ except LLMError as e:
151
+ raise SystemExit(f"error: {e}")
152
+
153
+ # Collect every model the run will use: the default plus any
154
+ # stage-specific overrides.
155
+ models = {config.model.name} | set(config.model.stages.values())
156
+ for model in sorted(models):
157
+ if not client.has_model(model):
158
+ raise SystemExit(
159
+ f"error: model '{model}' is not installed in Ollama\n"
160
+ f"hint: install it with: ollama pull {model}"
161
+ )
106
162
 
107
163
 
108
164
  def _print_progress(progress) -> None:
109
165
  bar_width = 30
110
166
  filled = int(bar_width * progress.fraction)
111
167
  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
- )
168
+ msg = f" [{bar}] {progress.fraction * 100:5.1f}% {progress.message}"
169
+
170
+ cols = shutil.get_terminal_size().columns
171
+ if len(msg) > cols - 1:
172
+ msg = msg[: cols - 4] + "..."
173
+
174
+ # Progress bar goes to stderr: visible on screen, but never mixed
175
+ # into a piped/logged stdout stream. \033[K clears the previous line.
176
+ print(f"\r\033[K{msg}", end="", file=sys.stderr, flush=True)
177
+
178
+ if progress.fraction >= 1.0:
179
+ print(file=sys.stderr)
117
180
 
118
181
 
119
182
  def _human_size(num_bytes: int) -> str:
@@ -124,6 +187,43 @@ def _human_size(num_bytes: int) -> str:
124
187
  return f"{num_bytes:.1f} TB"
125
188
 
126
189
 
190
+ def _safe_stem(name: str) -> str:
191
+ """
192
+ Convert a project name into a filesystem-safe stem.
193
+
194
+ Replaces path separators, spaces, and other problematic characters
195
+ so that a project name like "my/project" doesn't create subdirectories
196
+ or fail on Windows.
197
+ """
198
+ safe = "".join(
199
+ c if c.isalnum() or c in ("-", "_", ".") else "_"
200
+ for c in name
201
+ ).strip("._")
202
+ return safe or "project"
203
+
204
+
205
+ def _stamp() -> str:
206
+ """Single timestamp used for all output files of one run."""
207
+ return datetime.now().strftime("%Y%m%d_%H%M%S")
208
+
209
+
210
+ def _save_last_run(config: ProjectConfig) -> None:
211
+ """
212
+ Persist last_run after a successful analysis.
213
+
214
+ Failures are logged but never abort the command: the analysis
215
+ results are already on disk and more valuable than the timestamp.
216
+ """
217
+ config.last_run = datetime.now().isoformat(timespec="seconds")
218
+ try:
219
+ save_project(config)
220
+ except Exception as e:
221
+ logger.warning(
222
+ "Could not update last_run for project '%s': %s",
223
+ config.name, e,
224
+ )
225
+
226
+
127
227
  # ---------------------------------------------------------------------------
128
228
  # Commands
129
229
  # ---------------------------------------------------------------------------
@@ -139,79 +239,149 @@ def cmd_analyze(args: argparse.Namespace) -> int:
139
239
  config.model.name = args.model
140
240
  if args.num_ctx:
141
241
  config.model.num_ctx = args.num_ctx
242
+ if getattr(args, "spec_num_ctx", None):
243
+ config.model.spec_num_ctx = args.spec_num_ctx
142
244
  if args.timeout:
143
245
  config.model.timeout = args.timeout
144
246
 
247
+ # Parse --model-stage arguments into config.model.stages. Done
248
+ # before validate() so malformed specs are reported as CLI errors
249
+ # (exit 2) rather than config validation errors.
250
+ if getattr(args, "model_stage", None):
251
+ try:
252
+ config.model.stages.update(
253
+ _parse_model_stages(args.model_stage)
254
+ )
255
+ except ValueError as e:
256
+ print(f"error: {e}", file=sys.stderr)
257
+ return 2
258
+
259
+ # Validate BEFORE persisting, so we never save a broken config.
260
+ config.validate()
261
+
262
+ # Persist (only if the project already exists as a saved project).
263
+ # A malformed existing TOML is overwritten with the validated config.
145
264
  try:
146
265
  load_project(config.name)
147
266
  save_project(config)
148
- logger.info(f"Updated saved project '{config.name}'")
267
+ logger.info("Updated saved project '%s'", config.name)
149
268
  except FileNotFoundError:
150
269
  pass
151
-
152
- config.validate()
270
+ except ValueError as e:
271
+ logger.warning(
272
+ "Existing project config is invalid and will be overwritten: %s", e,
273
+ )
274
+ save_project(config)
153
275
 
154
276
  output_dir = Path(args.output).expanduser() if args.output else Path.cwd()
155
277
  output_dir.mkdir(parents=True, exist_ok=True)
156
278
 
157
279
  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()
280
+ _check_ollama(client, config)
281
+
282
+ logger.info("Analyzing: %s", config.path)
283
+ logger.info("Model: %s", config.model.name)
284
+ if config.model.stages:
285
+ for stage, model_name in sorted(config.model.stages.items()):
286
+ logger.info(" %-12s %s", f"{stage}:", model_name)
287
+ logger.info("Context: %d tokens", config.model.num_ctx)
288
+ if args.advanced:
289
+ logger.info("Spec ctx: %d tokens", config.model.spec_num_ctx)
290
+
291
+ # One timestamp for the whole run: findings/guide/specs all share it.
292
+ run_stamp = _stamp()
293
+ project_stem = _safe_stem(config.name)
294
+
295
+ # ------------------------------------------------------------------
296
+ # Advanced per-file specification mode
297
+ # ------------------------------------------------------------------
298
+ if args.advanced:
299
+ analyzer = ProjectAnalyzer(config, client)
300
+ files_to_analyze = analyzer.collect_files()
170
301
 
171
302
  if not files_to_analyze:
172
- print("No Python files found for advanced specification generation.")
303
+ print("No analyzable files found for advanced specification generation.")
173
304
  return 0
174
305
 
175
306
  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}"
307
+ specs_output_dir = output_dir / f"specs_{project_stem}_{run_stamp}"
177
308
  specs_output_dir.mkdir(parents=True, exist_ok=True)
178
309
 
179
- print(f"Running advanced per-file technical specification for {len(files_to_analyze)} files...")
310
+ print(f"Running advanced per-file technical specification "
311
+ f"for {len(files_to_analyze)} files...")
180
312
 
181
313
  def progress_cb(current: int, total: int, path: Path) -> None:
182
- rel = path.relative_to(config.path).as_posix()
314
+ try:
315
+ rel = path.relative_to(config.path).as_posix()
316
+ except ValueError:
317
+ rel = path.name
183
318
  if len(rel) > 35:
184
319
  rel = "..." + rel[-32:]
185
- fraction = current / total
320
+ fraction = current / total if total else 1.0
186
321
  bar_width = 20
187
322
  filled = int(bar_width * fraction)
188
323
  bar = "█" * filled + "░" * (bar_width - filled)
189
324
  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)
325
+
326
+ cols = shutil.get_terminal_size().columns
327
+ if len(msg) > cols - 1:
328
+ msg = msg[: cols - 4] + "..."
329
+
330
+ print(f"\r\033[K{msg}", end="", file=sys.stderr, flush=True)
331
+ if fraction >= 1.0:
332
+ print(file=sys.stderr)
192
333
 
193
334
  try:
194
- specs_dict = spec_gen.generate_project_specs(files_to_analyze, progress_callback=progress_cb)
335
+ specs_dict = spec_gen.generate_project_specs(
336
+ files_to_analyze, progress_callback=progress_cb,
337
+ )
195
338
  except KeyboardInterrupt:
196
339
  print("\n\ninterrupted by user", file=sys.stderr)
197
340
  return 130
341
+ except LLMError as e:
342
+ print(f"\nerror: {e}", file=sys.stderr)
343
+ return 1
198
344
  except Exception as e:
199
345
  print(f"\nerror: {e}", file=sys.stderr)
200
346
  return 1
201
-
202
- # Tallennetaan tiedostot levylle
347
+
348
+ # Write specs to disk. generate_project_specs already returns
349
+ # only successfully processed files, so specs_dict may be
350
+ # smaller than files_to_analyze. Count the difference and
351
+ # surface it to the user.
203
352
  for rel_path, markdown_content in specs_dict.items():
353
+ # Guard against rel_path containing '..' segments, which
354
+ # would let the write escape specs_output_dir.
355
+ safe_rel = Path(rel_path)
356
+ if safe_rel.is_absolute() or ".." in safe_rel.parts:
357
+ logger.warning(
358
+ "Skipping unsafe output path: %s", rel_path,
359
+ )
360
+ continue
204
361
  target_file = specs_output_dir / f"{rel_path}.md"
205
362
  target_file.parent.mkdir(parents=True, exist_ok=True)
206
363
  target_file.write_text(markdown_content, encoding="utf-8")
207
364
 
365
+ _save_last_run(config)
366
+
367
+ expected = len(files_to_analyze)
368
+ written = len(specs_dict)
369
+ missing = expected - written
370
+
208
371
  print()
209
- print(f"✓ Advanced specification generation complete")
372
+ print("✓ Advanced specification generation complete")
210
373
  print(f" Output directory: {specs_output_dir}")
211
- print(f" Files processed: {len(specs_dict)}")
374
+ print(f" Files processed: {written}")
375
+ if missing > 0:
376
+ print(
377
+ f"{missing} file(s) failed — see warnings above",
378
+ file=sys.stderr,
379
+ )
212
380
  return 0
213
381
 
214
- # NORMAALI ANALYYSI
382
+ # ------------------------------------------------------------------
383
+ # Normal analysis
384
+ # ------------------------------------------------------------------
215
385
  analyzer = ProjectAnalyzer(config, client)
216
386
 
217
387
  try:
@@ -224,14 +394,16 @@ def cmd_analyze(args: argparse.Namespace) -> int:
224
394
  print(f"\nerror: {e}", file=sys.stderr)
225
395
  return 1
226
396
 
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"
397
+ findings_path = output_dir / f"findings_{project_stem}_{run_stamp}.json"
398
+ guide_path = output_dir / f"guide_{project_stem}_{run_stamp}.md"
229
399
 
230
400
  analyzer.findings.save(findings_path)
231
401
  guide_path.write_text(analyzer.findings.guide, encoding="utf-8")
232
402
 
403
+ _save_last_run(config)
404
+
233
405
  print()
234
- print(f"✓ Analysis complete")
406
+ print("✓ Analysis complete")
235
407
  print(f" Findings: {findings_path}")
236
408
  print(f" Guide: {guide_path}")
237
409
  print(f" Errors: {len(analyzer.findings.errors)}")
@@ -264,6 +436,16 @@ def cmd_add(args: argparse.Namespace) -> int:
264
436
  return 1
265
437
  except FileNotFoundError:
266
438
  pass
439
+ except ValueError as e:
440
+ # Malformed existing config — overwrite it (the user asked to
441
+ # add this project, and --force semantics apply).
442
+ if not args.force:
443
+ print(
444
+ f"error: existing config for '{name}' is invalid: {e}\n"
445
+ f"hint: use --force to overwrite it",
446
+ file=sys.stderr,
447
+ )
448
+ return 1
267
449
 
268
450
  path = save_project(config)
269
451
  print(f"✓ Saved project '{name}'")
@@ -287,6 +469,11 @@ def cmd_list(args: argparse.Namespace) -> int:
287
469
  print(f" {marker} {p.name}")
288
470
  print(f" path: {p.path}")
289
471
  print(f" model: {p.model.name}")
472
+ if p.model.stages:
473
+ stages_str = ", ".join(
474
+ f"{s}={m}" for s, m in sorted(p.model.stages.items())
475
+ )
476
+ print(f" stages: {stages_str}")
290
477
  print(f" last run: {last}")
291
478
  if p.notes:
292
479
  print(f" notes: {p.notes}")
@@ -300,6 +487,12 @@ def cmd_show(args: argparse.Namespace) -> int:
300
487
  except FileNotFoundError:
301
488
  print(f"error: no saved project named '{args.project}'", file=sys.stderr)
302
489
  return 1
490
+ except ValueError as e:
491
+ print(
492
+ f"error: saved project '{args.project}' is invalid: {e}",
493
+ file=sys.stderr,
494
+ )
495
+ return 1
303
496
 
304
497
  print(f"Project: {config.name}")
305
498
  print(f" Path: {config.path}")
@@ -308,8 +501,17 @@ def cmd_show(args: argparse.Namespace) -> int:
308
501
  print()
309
502
  print("Model:")
310
503
  print(f" Name: {config.model.name}")
504
+ if config.model.stages:
505
+ print(" Stages:")
506
+ for stage in ModelConfig.VALID_STAGES:
507
+ override = config.model.stages.get(stage, "")
508
+ if override:
509
+ print(f" {stage:12s} {override}")
510
+ else:
511
+ print(f" {stage:12s} (default)")
311
512
  print(f" Ollama URL: {config.model.ollama_url}")
312
513
  print(f" num_ctx: {config.model.num_ctx}")
514
+ print(f" spec_num_ctx: {config.model.spec_num_ctx}")
313
515
  print(f" temperature: {config.model.temperature}")
314
516
  print(f" timeout: {config.model.timeout}s")
315
517
  print()
@@ -433,12 +635,28 @@ def build_parser() -> argparse.ArgumentParser:
433
635
  )
434
636
  p_analyze.add_argument(
435
637
  "-m", "--model",
436
- help="Override LLM model (e.g. qwen2.5-coder:14b)",
638
+ help="Override LLM model for all stages (e.g. qwen2.5-coder:14b). "
639
+ "With 24 GB+ VRAM, use --model-stage for per-stage overrides.",
640
+ )
641
+ p_analyze.add_argument(
642
+ "--model-stage",
643
+ action="append",
644
+ default=[],
645
+ metavar="STAGE=MODEL",
646
+ help="Override model for a specific stage (repeatable). "
647
+ "Valid stages: root, file, directories, guide. "
648
+ "Example: --model-stage guide=qwen2.5-coder:32b",
437
649
  )
438
650
  p_analyze.add_argument(
439
651
  "--num-ctx", type=int,
440
652
  help="Override context length in tokens",
441
653
  )
654
+ p_analyze.add_argument(
655
+ "--spec-num-ctx", type=int,
656
+ help="Context length for --advanced per-file specs "
657
+ "(default: 16384). Lower this on GPUs with limited VRAM "
658
+ "to keep the model fully on the GPU.",
659
+ )
442
660
  p_analyze.add_argument(
443
661
  "--timeout", type=int,
444
662
  help="Override per-request timeout in seconds",
siftcode/core/__init__.py CHANGED
@@ -1 +1 @@
1
- """Siftcode — local LLM-powered code analysis."""
1
+ """Core modules for Siftcode: analyzer, cache, config, LLM client, prompts."""