siftcode 0.3.0__py3-none-any.whl → 0.3.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.
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.3.1"
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-10-21"
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
@@ -86,6 +88,12 @@ def _resolve_project(name_or_path: str) -> ProjectConfig:
86
88
  f"hint: '{name_or_path}' is not a directory and no saved project "
87
89
  f"matches. Run '{APP_SLUG} list' to see saved projects."
88
90
  )
91
+ except ValueError as e:
92
+ # load_project now raises ValueError for malformed TOML files.
93
+ raise SystemExit(
94
+ f"error: saved project '{name_or_path}' is invalid: {e}\n"
95
+ f"hint: fix or delete the config file under {PROJECTS_DIR}"
96
+ )
89
97
 
90
98
 
91
99
  def _check_ollama(client: OllamaClient) -> None:
@@ -109,11 +117,18 @@ def _print_progress(progress) -> None:
109
117
  bar_width = 30
110
118
  filled = int(bar_width * progress.fraction)
111
119
  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
- )
120
+ msg = f" [{bar}] {progress.fraction * 100:5.1f}% {progress.message}"
121
+
122
+ cols = shutil.get_terminal_size().columns
123
+ if len(msg) > cols - 1:
124
+ msg = msg[: cols - 4] + "..."
125
+
126
+ # Progress bar goes to stderr: visible on screen, but never mixed
127
+ # into a piped/logged stdout stream. \033[K clears the previous line.
128
+ print(f"\r\033[K{msg}", end="", file=sys.stderr, flush=True)
129
+
130
+ if progress.fraction >= 1.0:
131
+ print(file=sys.stderr)
117
132
 
118
133
 
119
134
  def _human_size(num_bytes: int) -> str:
@@ -124,6 +139,43 @@ def _human_size(num_bytes: int) -> str:
124
139
  return f"{num_bytes:.1f} TB"
125
140
 
126
141
 
142
+ def _safe_stem(name: str) -> str:
143
+ """
144
+ Convert a project name into a filesystem-safe stem.
145
+
146
+ Replaces path separators, spaces, and other problematic characters
147
+ so that a project name like "my/project" doesn't create subdirectories
148
+ or fail on Windows.
149
+ """
150
+ safe = "".join(
151
+ c if c.isalnum() or c in ("-", "_", ".") else "_"
152
+ for c in name
153
+ ).strip("._")
154
+ return safe or "project"
155
+
156
+
157
+ def _stamp() -> str:
158
+ """Single timestamp used for all output files of one run."""
159
+ return datetime.now().strftime("%Y%m%d_%H%M%S")
160
+
161
+
162
+ def _save_last_run(config: ProjectConfig) -> None:
163
+ """
164
+ Persist last_run after a successful analysis.
165
+
166
+ Failures are logged but never abort the command: the analysis
167
+ results are already on disk and more valuable than the timestamp.
168
+ """
169
+ config.last_run = datetime.now().isoformat(timespec="seconds")
170
+ try:
171
+ save_project(config)
172
+ except Exception as e:
173
+ logger.warning(
174
+ "Could not update last_run for project '%s': %s",
175
+ config.name, e,
176
+ )
177
+
178
+
127
179
  # ---------------------------------------------------------------------------
128
180
  # Commands
129
181
  # ---------------------------------------------------------------------------
@@ -142,14 +194,22 @@ def cmd_analyze(args: argparse.Namespace) -> int:
142
194
  if args.timeout:
143
195
  config.model.timeout = args.timeout
144
196
 
197
+ # Validate BEFORE persisting, so we never save a broken config.
198
+ config.validate()
199
+
200
+ # Persist (only if the project already exists as a saved project).
201
+ # A malformed existing TOML is overwritten with the validated config.
145
202
  try:
146
203
  load_project(config.name)
147
204
  save_project(config)
148
205
  logger.info(f"Updated saved project '{config.name}'")
149
206
  except FileNotFoundError:
150
207
  pass
151
-
152
- config.validate()
208
+ except ValueError as e:
209
+ logger.warning(
210
+ "Existing project config is invalid and will be overwritten: %s", e,
211
+ )
212
+ save_project(config)
153
213
 
154
214
  output_dir = Path(args.output).expanduser() if args.output else Path.cwd()
155
215
  output_dir.mkdir(parents=True, exist_ok=True)
@@ -162,56 +222,99 @@ def cmd_analyze(args: argparse.Namespace) -> int:
162
222
  logger.info(f"Model: {config.model.name}")
163
223
  logger.info(f"Context: {config.model.num_ctx} tokens")
164
224
 
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()
225
+ # One timestamp for the whole run: findings/guide/specs all share it.
226
+ run_stamp = _stamp()
227
+ project_stem = _safe_stem(config.name)
228
+ is_tty = sys.stdout.isatty()
229
+
230
+ # ------------------------------------------------------------------
231
+ # Advanced per-file specification mode
232
+ # ------------------------------------------------------------------
233
+ if args.advanced:
234
+ analyzer = ProjectAnalyzer(config, client)
235
+ files_to_analyze = analyzer.collect_files()
170
236
 
171
237
  if not files_to_analyze:
172
- print("No Python files found for advanced specification generation.")
238
+ print("No analyzable files found for advanced specification generation.")
173
239
  return 0
174
240
 
175
241
  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}"
242
+ specs_output_dir = output_dir / f"specs_{project_stem}_{run_stamp}"
177
243
  specs_output_dir.mkdir(parents=True, exist_ok=True)
178
244
 
179
- print(f"Running advanced per-file technical specification for {len(files_to_analyze)} files...")
245
+ print(f"Running advanced per-file technical specification "
246
+ f"for {len(files_to_analyze)} files...")
180
247
 
181
248
  def progress_cb(current: int, total: int, path: Path) -> None:
182
- rel = path.relative_to(config.path).as_posix()
249
+ try:
250
+ rel = path.relative_to(config.path).as_posix()
251
+ except ValueError:
252
+ rel = path.name
183
253
  if len(rel) > 35:
184
254
  rel = "..." + rel[-32:]
185
- fraction = current / total
255
+ fraction = current / total if total else 1.0
186
256
  bar_width = 20
187
257
  filled = int(bar_width * fraction)
188
258
  bar = "█" * filled + "░" * (bar_width - filled)
189
259
  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)
260
+ if is_tty:
261
+ print(f"\r\033[K{msg}", end="", flush=True)
262
+ if fraction >= 1.0:
263
+ print()
264
+ else:
265
+ print(msg, flush=True)
192
266
 
193
267
  try:
194
- specs_dict = spec_gen.generate_project_specs(files_to_analyze, progress_callback=progress_cb)
268
+ specs_dict = spec_gen.generate_project_specs(
269
+ files_to_analyze, progress_callback=progress_cb,
270
+ )
195
271
  except KeyboardInterrupt:
196
272
  print("\n\ninterrupted by user", file=sys.stderr)
197
273
  return 130
274
+ except LLMError as e:
275
+ print(f"\nerror: {e}", file=sys.stderr)
276
+ return 1
198
277
  except Exception as e:
199
278
  print(f"\nerror: {e}", file=sys.stderr)
200
279
  return 1
201
-
202
- # Tallennetaan tiedostot levylle
280
+
281
+ # Write specs to disk. generate_project_specs already returns
282
+ # only successfully processed files, so specs_dict may be
283
+ # smaller than files_to_analyze. Count the difference and
284
+ # surface it to the user.
203
285
  for rel_path, markdown_content in specs_dict.items():
286
+ # Guard against rel_path containing '..' segments, which
287
+ # would let the write escape specs_output_dir.
288
+ safe_rel = Path(rel_path)
289
+ if safe_rel.is_absolute() or ".." in safe_rel.parts:
290
+ logger.warning(
291
+ "Skipping unsafe output path: %s", rel_path,
292
+ )
293
+ continue
204
294
  target_file = specs_output_dir / f"{rel_path}.md"
205
295
  target_file.parent.mkdir(parents=True, exist_ok=True)
206
296
  target_file.write_text(markdown_content, encoding="utf-8")
207
297
 
298
+ _save_last_run(config)
299
+
300
+ expected = len(files_to_analyze)
301
+ written = len(specs_dict)
302
+ missing = expected - written
303
+
208
304
  print()
209
- print(f"✓ Advanced specification generation complete")
305
+ print("✓ Advanced specification generation complete")
210
306
  print(f" Output directory: {specs_output_dir}")
211
- print(f" Files processed: {len(specs_dict)}")
307
+ print(f" Files processed: {written}")
308
+ if missing > 0:
309
+ print(
310
+ f"{missing} file(s) failed — see warnings above",
311
+ file=sys.stderr,
312
+ )
212
313
  return 0
213
314
 
214
- # NORMAALI ANALYYSI
315
+ # ------------------------------------------------------------------
316
+ # Normal analysis
317
+ # ------------------------------------------------------------------
215
318
  analyzer = ProjectAnalyzer(config, client)
216
319
 
217
320
  try:
@@ -224,14 +327,16 @@ def cmd_analyze(args: argparse.Namespace) -> int:
224
327
  print(f"\nerror: {e}", file=sys.stderr)
225
328
  return 1
226
329
 
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"
330
+ findings_path = output_dir / f"findings_{project_stem}_{run_stamp}.json"
331
+ guide_path = output_dir / f"guide_{project_stem}_{run_stamp}.md"
229
332
 
230
333
  analyzer.findings.save(findings_path)
231
334
  guide_path.write_text(analyzer.findings.guide, encoding="utf-8")
232
335
 
336
+ _save_last_run(config)
337
+
233
338
  print()
234
- print(f"✓ Analysis complete")
339
+ print("✓ Analysis complete")
235
340
  print(f" Findings: {findings_path}")
236
341
  print(f" Guide: {guide_path}")
237
342
  print(f" Errors: {len(analyzer.findings.errors)}")
@@ -264,6 +369,16 @@ def cmd_add(args: argparse.Namespace) -> int:
264
369
  return 1
265
370
  except FileNotFoundError:
266
371
  pass
372
+ except ValueError as e:
373
+ # Malformed existing config — overwrite it (the user asked to
374
+ # add this project, and --force semantics apply).
375
+ if not args.force:
376
+ print(
377
+ f"error: existing config for '{name}' is invalid: {e}\n"
378
+ f"hint: use --force to overwrite it",
379
+ file=sys.stderr,
380
+ )
381
+ return 1
267
382
 
268
383
  path = save_project(config)
269
384
  print(f"✓ Saved project '{name}'")
@@ -300,6 +415,12 @@ def cmd_show(args: argparse.Namespace) -> int:
300
415
  except FileNotFoundError:
301
416
  print(f"error: no saved project named '{args.project}'", file=sys.stderr)
302
417
  return 1
418
+ except ValueError as e:
419
+ print(
420
+ f"error: saved project '{args.project}' is invalid: {e}",
421
+ file=sys.stderr,
422
+ )
423
+ return 1
303
424
 
304
425
  print(f"Project: {config.name}")
305
426
  print(f" Path: {config.path}")
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."""