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 +3 -1
- siftcode/_version.py +48 -4
- siftcode/cli.py +149 -28
- siftcode/core/__init__.py +1 -1
- siftcode/core/analyzer.py +156 -88
- siftcode/core/cache.py +80 -21
- siftcode/core/config.py +91 -17
- siftcode/core/llm.py +62 -8
- siftcode/core/manual.py +234 -57
- siftcode/core/prompts.py +33 -0
- siftcode/data/hicolor/128x128/apps/siftcode-logo.png +0 -0
- siftcode/data/hicolor/256x256/apps/siftcode-logo.png +0 -0
- siftcode/data/hicolor/48x48/apps/siftcode-logo.png +0 -0
- siftcode/gui/__init__.py +4 -2
- siftcode/gui/app.py +54 -59
- {siftcode-0.3.0.dist-info → siftcode-0.3.1.dist-info}/METADATA +41 -15
- siftcode-0.3.1.dist-info/RECORD +34 -0
- {siftcode-0.3.0.dist-info → siftcode-0.3.1.dist-info}/WHEEL +1 -1
- siftcode-0.3.0.dist-info/RECORD +0 -31
- {siftcode-0.3.0.dist-info → siftcode-0.3.1.dist-info}/entry_points.txt +0 -0
- {siftcode-0.3.0.dist-info → siftcode-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {siftcode-0.3.0.dist-info → siftcode-0.3.1.dist-info}/top_level.txt +0 -0
siftcode/__init__.py
CHANGED
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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_{
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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(
|
|
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
|
-
#
|
|
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(
|
|
305
|
+
print("✓ Advanced specification generation complete")
|
|
210
306
|
print(f" Output directory: {specs_output_dir}")
|
|
211
|
-
print(f" Files processed: {
|
|
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
|
-
#
|
|
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_{
|
|
228
|
-
guide_path = output_dir / f"guide_{
|
|
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(
|
|
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
|
|
1
|
+
"""Core modules for Siftcode: analyzer, cache, config, LLM client, prompts."""
|