codedocent 0.2.1__tar.gz → 0.4.0__tar.gz
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.
- codedocent-0.4.0/PKG-INFO +69 -0
- codedocent-0.4.0/README.md +59 -0
- {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/analyzer.py +75 -18
- {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/cli.py +14 -4
- codedocent-0.4.0/codedocent/editor.py +173 -0
- {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/gui.py +28 -9
- {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/parser.py +20 -2
- {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/quality.py +7 -55
- {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/renderer.py +6 -4
- {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/scanner.py +10 -2
- {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/server.py +179 -37
- {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/templates/interactive.html +24 -15
- codedocent-0.4.0/codedocent.egg-info/PKG-INFO +69 -0
- {codedocent-0.2.1 → codedocent-0.4.0}/pyproject.toml +3 -2
- {codedocent-0.2.1 → codedocent-0.4.0}/tests/test_analyzer.py +130 -125
- codedocent-0.4.0/tests/test_editor.py +278 -0
- {codedocent-0.2.1 → codedocent-0.4.0}/tests/test_parser.py +18 -0
- {codedocent-0.2.1 → codedocent-0.4.0}/tests/test_renderer.py +40 -0
- {codedocent-0.2.1 → codedocent-0.4.0}/tests/test_scanner.py +22 -0
- {codedocent-0.2.1 → codedocent-0.4.0}/tests/test_server.py +270 -13
- codedocent-0.2.1/PKG-INFO +0 -16
- codedocent-0.2.1/README.md +0 -183
- codedocent-0.2.1/codedocent/editor.py +0 -89
- codedocent-0.2.1/codedocent.egg-info/PKG-INFO +0 -16
- codedocent-0.2.1/tests/test_editor.py +0 -113
- {codedocent-0.2.1 → codedocent-0.4.0}/LICENSE +0 -0
- {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/__init__.py +0 -0
- {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/__main__.py +0 -0
- {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/ollama_utils.py +0 -0
- {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/templates/base.html +0 -0
- {codedocent-0.2.1 → codedocent-0.4.0}/codedocent.egg-info/SOURCES.txt +0 -0
- {codedocent-0.2.1 → codedocent-0.4.0}/codedocent.egg-info/dependency_links.txt +0 -0
- {codedocent-0.2.1 → codedocent-0.4.0}/codedocent.egg-info/entry_points.txt +0 -0
- {codedocent-0.2.1 → codedocent-0.4.0}/codedocent.egg-info/requires.txt +4 -4
- {codedocent-0.2.1 → codedocent-0.4.0}/codedocent.egg-info/top_level.txt +0 -0
- {codedocent-0.2.1 → codedocent-0.4.0}/setup.cfg +0 -0
- {codedocent-0.2.1 → codedocent-0.4.0}/tests/test_cli.py +0 -0
- {codedocent-0.2.1 → codedocent-0.4.0}/tests/test_gui.py +0 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: codedocent
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Code visualization for non-programmers
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
|
|
11
|
+
# codedocent
|
|
12
|
+
|
|
13
|
+
<img width="1658" height="2158" alt="Screenshot_2026-02-09_13-17-06" src="https://github.com/user-attachments/assets/ff097ead-69ec-4618-b7b7-2b99c60ac57e" />
|
|
14
|
+
|
|
15
|
+
**Code visualization for non-programmers.**
|
|
16
|
+
|
|
17
|
+
A docent is a guide who explains things to people who aren't experts. Codedocent does that for code.
|
|
18
|
+
|
|
19
|
+
## The problem
|
|
20
|
+
|
|
21
|
+
You're staring at a codebase you didn't write — maybe thousands of files across dozens of directories — and you need to understand what it does. Reading every file isn't realistic. You need a way to visualize the code structure, get a high-level map of what's where, and drill into the parts that matter without losing context.
|
|
22
|
+
|
|
23
|
+
Codedocent parses the codebase into a navigable, visual block structure and explains each piece in plain English. It's an AI code analysis tool that runs entirely on your machine — no API keys, no cloud, no data leaving your laptop. Point it at any codebase and get a structural overview you can explore interactively, understand quickly, and share as a static HTML file.
|
|
24
|
+
|
|
25
|
+
## Who this is for
|
|
26
|
+
|
|
27
|
+
- **Developers onboarding onto an unfamiliar codebase** — get oriented in minutes instead of days
|
|
28
|
+
- **Non-programmers** (managers, designers, PMs) who need to understand what code does without reading it
|
|
29
|
+
- **Solo developers inheriting legacy code** — map out the structure before making changes
|
|
30
|
+
- **Code reviewers** who want a high-level overview before diving into details
|
|
31
|
+
- **Security reviewers** who need a structural map of an application
|
|
32
|
+
- **Students** learning to read and navigate real-world codebases
|
|
33
|
+
|
|
34
|
+
## What you see
|
|
35
|
+
|
|
36
|
+
Nested, color-coded blocks representing directories, files, classes, and functions — the entire structure of a codebase laid out visually. Each block shows a plain English summary, a pseudocode translation, and quality warnings (green/yellow/red). Click any block to drill down; breadcrumbs navigate you back up. You can export code from any block or paste replacement code back into the source file. All AI runs locally through Ollama — nothing leaves your machine.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install codedocent
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Requires Python 3.10+ and [Ollama](https://ollama.com) running locally for AI features. Works without AI too (`--no-ai`).
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
codedocent # setup wizard — walks you through everything
|
|
50
|
+
codedocent /path/to/code # interactive mode (recommended)
|
|
51
|
+
codedocent /path/to/code --full # full analysis, static HTML output
|
|
52
|
+
codedocent --gui # graphical launcher
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## How it works
|
|
56
|
+
|
|
57
|
+
Parses code structure with tree-sitter, scores quality with static analysis, and sends individual blocks to a local Ollama model for plain English summaries and pseudocode. Interactive mode analyzes on click — typically 1-2 seconds per block. Full mode analyzes everything upfront into a self-contained HTML file you can share.
|
|
58
|
+
|
|
59
|
+
## Why local
|
|
60
|
+
|
|
61
|
+
All AI processing runs through Ollama on your machine. Your code is never uploaded, transmitted, or stored anywhere external. No API keys, no accounts, no cloud services. This matters when you're working with proprietary code, client projects, or anything you can't share — codedocent works fully air-gapped. The `--no-ai` mode removes the AI dependency entirely while keeping the structural visualization and quality scoring.
|
|
62
|
+
|
|
63
|
+
## Supported languages
|
|
64
|
+
|
|
65
|
+
Full AST parsing for Python and JavaScript/TypeScript (functions, classes, methods, imports). File-level detection for 23 extensions including C, C++, Rust, Go, Java, Ruby, PHP, Swift, Kotlin, Scala, HTML, CSS, and config formats.
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# codedocent
|
|
2
|
+
|
|
3
|
+
<img width="1658" height="2158" alt="Screenshot_2026-02-09_13-17-06" src="https://github.com/user-attachments/assets/ff097ead-69ec-4618-b7b7-2b99c60ac57e" />
|
|
4
|
+
|
|
5
|
+
**Code visualization for non-programmers.**
|
|
6
|
+
|
|
7
|
+
A docent is a guide who explains things to people who aren't experts. Codedocent does that for code.
|
|
8
|
+
|
|
9
|
+
## The problem
|
|
10
|
+
|
|
11
|
+
You're staring at a codebase you didn't write — maybe thousands of files across dozens of directories — and you need to understand what it does. Reading every file isn't realistic. You need a way to visualize the code structure, get a high-level map of what's where, and drill into the parts that matter without losing context.
|
|
12
|
+
|
|
13
|
+
Codedocent parses the codebase into a navigable, visual block structure and explains each piece in plain English. It's an AI code analysis tool that runs entirely on your machine — no API keys, no cloud, no data leaving your laptop. Point it at any codebase and get a structural overview you can explore interactively, understand quickly, and share as a static HTML file.
|
|
14
|
+
|
|
15
|
+
## Who this is for
|
|
16
|
+
|
|
17
|
+
- **Developers onboarding onto an unfamiliar codebase** — get oriented in minutes instead of days
|
|
18
|
+
- **Non-programmers** (managers, designers, PMs) who need to understand what code does without reading it
|
|
19
|
+
- **Solo developers inheriting legacy code** — map out the structure before making changes
|
|
20
|
+
- **Code reviewers** who want a high-level overview before diving into details
|
|
21
|
+
- **Security reviewers** who need a structural map of an application
|
|
22
|
+
- **Students** learning to read and navigate real-world codebases
|
|
23
|
+
|
|
24
|
+
## What you see
|
|
25
|
+
|
|
26
|
+
Nested, color-coded blocks representing directories, files, classes, and functions — the entire structure of a codebase laid out visually. Each block shows a plain English summary, a pseudocode translation, and quality warnings (green/yellow/red). Click any block to drill down; breadcrumbs navigate you back up. You can export code from any block or paste replacement code back into the source file. All AI runs locally through Ollama — nothing leaves your machine.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install codedocent
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Requires Python 3.10+ and [Ollama](https://ollama.com) running locally for AI features. Works without AI too (`--no-ai`).
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
codedocent # setup wizard — walks you through everything
|
|
40
|
+
codedocent /path/to/code # interactive mode (recommended)
|
|
41
|
+
codedocent /path/to/code --full # full analysis, static HTML output
|
|
42
|
+
codedocent --gui # graphical launcher
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## How it works
|
|
46
|
+
|
|
47
|
+
Parses code structure with tree-sitter, scores quality with static analysis, and sends individual blocks to a local Ollama model for plain English summaries and pseudocode. Interactive mode analyzes on click — typically 1-2 seconds per block. Full mode analyzes everything upfront into a self-contained HTML file you can share.
|
|
48
|
+
|
|
49
|
+
## Why local
|
|
50
|
+
|
|
51
|
+
All AI processing runs through Ollama on your machine. Your code is never uploaded, transmitted, or stored anywhere external. No API keys, no accounts, no cloud services. This matters when you're working with proprietary code, client projects, or anything you can't share — codedocent works fully air-gapped. The `--no-ai` mode removes the AI dependency entirely while keeping the structural visualization and quality scoring.
|
|
52
|
+
|
|
53
|
+
## Supported languages
|
|
54
|
+
|
|
55
|
+
Full AST parsing for Python and JavaScript/TypeScript (functions, classes, methods, imports). File-level detection for 23 extensions including C, C++, Rust, Go, Java, Ruby, PHP, Swift, Kotlin, Scala, HTML, CSS, and config formats.
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT
|
|
@@ -7,6 +7,7 @@ import json
|
|
|
7
7
|
import os
|
|
8
8
|
import re
|
|
9
9
|
import sys
|
|
10
|
+
import tempfile
|
|
10
11
|
import threading
|
|
11
12
|
import time
|
|
12
13
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
@@ -28,6 +29,14 @@ MAX_SOURCE_LINES = 200
|
|
|
28
29
|
MIN_LINES_FOR_AI = 3
|
|
29
30
|
|
|
30
31
|
|
|
32
|
+
def _md5(data: bytes) -> "hashlib._Hash":
|
|
33
|
+
"""Create an MD5 hash, tolerating FIPS-mode Python builds."""
|
|
34
|
+
try:
|
|
35
|
+
return hashlib.md5(data, usedforsecurity=False)
|
|
36
|
+
except TypeError:
|
|
37
|
+
return hashlib.md5(data) # nosec B324
|
|
38
|
+
|
|
39
|
+
|
|
31
40
|
def _count_nodes(node: CodeNode) -> int:
|
|
32
41
|
"""Recursive count of all nodes in tree."""
|
|
33
42
|
return 1 + sum(_count_nodes(c) for c in node.children)
|
|
@@ -103,15 +112,34 @@ def _parse_ai_response(text: str) -> tuple[str, str]:
|
|
|
103
112
|
return summary, pseudocode
|
|
104
113
|
|
|
105
114
|
|
|
115
|
+
_AI_TIMEOUT = 120
|
|
116
|
+
|
|
117
|
+
|
|
106
118
|
def _summarize_with_ai(
|
|
107
119
|
node: CodeNode, model: str
|
|
108
|
-
) -> tuple[str, str]:
|
|
109
|
-
"""Call ollama to get summary and pseudocode for a node.
|
|
120
|
+
) -> tuple[str, str] | None:
|
|
121
|
+
"""Call ollama to get summary and pseudocode for a node.
|
|
122
|
+
|
|
123
|
+
Returns ``None`` if the AI call times out.
|
|
124
|
+
"""
|
|
110
125
|
prompt = _build_prompt(node, model)
|
|
111
|
-
|
|
112
|
-
|
|
126
|
+
pool = ThreadPoolExecutor(max_workers=1)
|
|
127
|
+
future = pool.submit(
|
|
128
|
+
ollama.chat,
|
|
129
|
+
model=model,
|
|
130
|
+
messages=[{"role": "user", "content": prompt}],
|
|
113
131
|
)
|
|
114
|
-
|
|
132
|
+
try:
|
|
133
|
+
response = future.result(timeout=_AI_TIMEOUT)
|
|
134
|
+
except TimeoutError:
|
|
135
|
+
future.cancel()
|
|
136
|
+
pool.shutdown(wait=False, cancel_futures=True)
|
|
137
|
+
return None
|
|
138
|
+
pool.shutdown(wait=False)
|
|
139
|
+
msg = getattr(response, "message", None)
|
|
140
|
+
if msg is None:
|
|
141
|
+
raise ValueError("Unexpected Ollama response format")
|
|
142
|
+
raw = getattr(msg, "content", None) or ""
|
|
115
143
|
raw = _strip_think_tags(raw)
|
|
116
144
|
# Garbage response fallback: empty or very short after stripping
|
|
117
145
|
if not raw or len(raw) < 10:
|
|
@@ -130,9 +158,7 @@ def _summarize_with_ai(
|
|
|
130
158
|
|
|
131
159
|
def _cache_key(node: CodeNode) -> str:
|
|
132
160
|
"""Generate a cache key based on filepath, name, and source hash."""
|
|
133
|
-
source_hash =
|
|
134
|
-
node.source.encode(), usedforsecurity=False
|
|
135
|
-
).hexdigest()
|
|
161
|
+
source_hash = _md5(node.source.encode()).hexdigest()
|
|
136
162
|
return f"{node.filepath}::{node.name}::{source_hash}"
|
|
137
163
|
|
|
138
164
|
|
|
@@ -149,12 +175,34 @@ def _load_cache(path: str) -> dict:
|
|
|
149
175
|
|
|
150
176
|
|
|
151
177
|
def _save_cache(path: str, data: dict) -> None:
|
|
152
|
-
"""Save cache to JSON file."""
|
|
178
|
+
"""Save cache to JSON file atomically."""
|
|
179
|
+
parent = os.path.dirname(os.path.abspath(path))
|
|
180
|
+
tmp_path: str | None = None
|
|
153
181
|
try:
|
|
154
|
-
|
|
155
|
-
|
|
182
|
+
fd = tempfile.NamedTemporaryFile( # pylint: disable=consider-using-with # noqa: E501
|
|
183
|
+
mode="w", encoding="utf-8",
|
|
184
|
+
dir=parent, delete=False, suffix=".tmp",
|
|
185
|
+
)
|
|
186
|
+
tmp_path = fd.name
|
|
187
|
+
try:
|
|
188
|
+
json.dump(data, fd, indent=2)
|
|
189
|
+
fd.flush()
|
|
190
|
+
os.fsync(fd.fileno())
|
|
191
|
+
finally:
|
|
192
|
+
fd.close()
|
|
193
|
+
os.replace(tmp_path, path)
|
|
194
|
+
tmp_path = None # success — don't clean up
|
|
156
195
|
except OSError as e:
|
|
157
|
-
print(
|
|
196
|
+
print(
|
|
197
|
+
f"Warning: could not save cache: {e}",
|
|
198
|
+
file=sys.stderr,
|
|
199
|
+
)
|
|
200
|
+
finally:
|
|
201
|
+
if tmp_path is not None:
|
|
202
|
+
try:
|
|
203
|
+
os.unlink(tmp_path)
|
|
204
|
+
except OSError:
|
|
205
|
+
pass
|
|
158
206
|
|
|
159
207
|
|
|
160
208
|
# ---------------------------------------------------------------------------
|
|
@@ -171,9 +219,7 @@ def assign_node_ids(root: CodeNode) -> dict[str, CodeNode]:
|
|
|
171
219
|
|
|
172
220
|
def _walk(node: CodeNode, path_parts: list[str]) -> None:
|
|
173
221
|
key = "::".join(path_parts)
|
|
174
|
-
node_id =
|
|
175
|
-
key.encode(), usedforsecurity=False
|
|
176
|
-
).hexdigest()[:12]
|
|
222
|
+
node_id = _md5(key.encode()).hexdigest()[:12]
|
|
177
223
|
node.node_id = node_id
|
|
178
224
|
lookup[node_id] = node
|
|
179
225
|
for child in node.children:
|
|
@@ -228,12 +274,19 @@ def analyze_single_node(node: CodeNode, model: str, cache_dir: str) -> None:
|
|
|
228
274
|
return
|
|
229
275
|
|
|
230
276
|
try:
|
|
231
|
-
|
|
277
|
+
result = _summarize_with_ai(node, model)
|
|
278
|
+
if result is None:
|
|
279
|
+
node.summary = "Summary timed out"
|
|
280
|
+
return
|
|
281
|
+
summary, pseudocode = result
|
|
232
282
|
node.summary = summary
|
|
233
283
|
node.pseudocode = pseudocode
|
|
234
284
|
cache["entries"][key] = {"summary": summary, "pseudocode": pseudocode}
|
|
235
285
|
_save_cache(cache_path, cache)
|
|
236
|
-
except (
|
|
286
|
+
except (
|
|
287
|
+
ConnectionError, RuntimeError, ValueError,
|
|
288
|
+
OSError, AttributeError, TypeError,
|
|
289
|
+
) as e:
|
|
237
290
|
node.summary = f"Summary generation failed: {e}"
|
|
238
291
|
|
|
239
292
|
|
|
@@ -331,7 +384,11 @@ def _run_ai_batch(
|
|
|
331
384
|
return
|
|
332
385
|
_progress(f"Analyzing {node.name}")
|
|
333
386
|
try:
|
|
334
|
-
|
|
387
|
+
result = _summarize_with_ai(node, model)
|
|
388
|
+
if result is None:
|
|
389
|
+
node.summary = "Summary timed out"
|
|
390
|
+
return
|
|
391
|
+
summary, pseudocode = result
|
|
335
392
|
with cache_lock:
|
|
336
393
|
node.summary = summary
|
|
337
394
|
node.pseudocode = pseudocode
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import os
|
|
7
|
+
import sys
|
|
7
8
|
|
|
8
9
|
from codedocent.ollama_utils import check_ollama, fetch_ollama_models
|
|
9
10
|
from codedocent.parser import CodeNode, parse_directory
|
|
@@ -15,6 +16,15 @@ _check_ollama = check_ollama
|
|
|
15
16
|
_fetch_ollama_models = fetch_ollama_models
|
|
16
17
|
|
|
17
18
|
|
|
19
|
+
def _safe_input(prompt: str) -> str:
|
|
20
|
+
"""Wrap input() to handle EOF gracefully."""
|
|
21
|
+
try:
|
|
22
|
+
return input(prompt)
|
|
23
|
+
except EOFError:
|
|
24
|
+
print("\nInput closed. Exiting.")
|
|
25
|
+
sys.exit(0)
|
|
26
|
+
|
|
27
|
+
|
|
18
28
|
def print_tree(node: CodeNode, indent: int = 0) -> None:
|
|
19
29
|
"""Print a text representation of the code tree."""
|
|
20
30
|
prefix = " " * indent
|
|
@@ -44,7 +54,7 @@ def print_tree(node: CodeNode, indent: int = 0) -> None:
|
|
|
44
54
|
def _ask_folder() -> str:
|
|
45
55
|
"""Prompt for a valid folder path, re-asking on invalid input."""
|
|
46
56
|
while True:
|
|
47
|
-
path =
|
|
57
|
+
path = _safe_input("What folder do you want to analyze? ").strip()
|
|
48
58
|
path = os.path.expanduser(path)
|
|
49
59
|
if os.path.isdir(path):
|
|
50
60
|
file_count = len(list(scan_directory(path)))
|
|
@@ -55,7 +65,7 @@ def _ask_folder() -> str:
|
|
|
55
65
|
|
|
56
66
|
def _ask_no_ai_fallback() -> bool:
|
|
57
67
|
"""Ask user whether to continue without AI. Returns True for no-ai."""
|
|
58
|
-
fallback =
|
|
68
|
+
fallback = _safe_input("Continue without AI? [Y/n]: ").strip().lower()
|
|
59
69
|
if fallback in ("", "y", "yes"):
|
|
60
70
|
return True
|
|
61
71
|
raise SystemExit(0)
|
|
@@ -66,7 +76,7 @@ def _pick_model(models: list[str]) -> str:
|
|
|
66
76
|
print("Available models:")
|
|
67
77
|
for i, m in enumerate(models, 1):
|
|
68
78
|
print(f" {i}. {m}")
|
|
69
|
-
choice =
|
|
79
|
+
choice = _safe_input("Which model? [1]: ").strip()
|
|
70
80
|
if choice == "":
|
|
71
81
|
return models[0]
|
|
72
82
|
try:
|
|
@@ -106,7 +116,7 @@ def _run_wizard() -> argparse.Namespace:
|
|
|
106
116
|
print(" 1. Interactive \u2014 browse in browser [default]")
|
|
107
117
|
print(" 2. Full export \u2014 analyze everything, save HTML")
|
|
108
118
|
print(" 3. Text tree \u2014 plain text in terminal")
|
|
109
|
-
mode_choice =
|
|
119
|
+
mode_choice = _safe_input("Choice [1]: ").strip()
|
|
110
120
|
|
|
111
121
|
text = mode_choice == "3"
|
|
112
122
|
full = mode_choice == "2"
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Code replacement: write modified source back into a file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _read_and_validate(
|
|
12
|
+
filepath: str, start_line: int, end_line: int,
|
|
13
|
+
) -> tuple[list[str] | None, str | None, tuple[int, int], str]:
|
|
14
|
+
"""Read *filepath* and validate the line range.
|
|
15
|
+
|
|
16
|
+
Returns ``(lines, None, (mtime_ns, size), line_ending)`` on success,
|
|
17
|
+
or ``(None, error_message, (0, 0), "\\n")`` on failure.
|
|
18
|
+
"""
|
|
19
|
+
if not os.path.isfile(filepath):
|
|
20
|
+
return (None, f"File not found: {filepath}", (0, 0), "\n")
|
|
21
|
+
if (
|
|
22
|
+
not isinstance(start_line, int)
|
|
23
|
+
or not isinstance(end_line, int)
|
|
24
|
+
or start_line < 1
|
|
25
|
+
or end_line < 1
|
|
26
|
+
or start_line > end_line
|
|
27
|
+
):
|
|
28
|
+
return (
|
|
29
|
+
None,
|
|
30
|
+
f"Invalid line range: {start_line}-{end_line}",
|
|
31
|
+
(0, 0), "\n",
|
|
32
|
+
)
|
|
33
|
+
try:
|
|
34
|
+
with open(filepath, "rb") as f:
|
|
35
|
+
raw = f.read()
|
|
36
|
+
_stat = os.stat(filepath)
|
|
37
|
+
file_stamp = (_stat.st_mtime_ns, _stat.st_size)
|
|
38
|
+
text = raw.decode("utf-8")
|
|
39
|
+
except UnicodeDecodeError:
|
|
40
|
+
return (None, "File is not valid UTF-8 text", (0, 0), "\n")
|
|
41
|
+
|
|
42
|
+
# Detect line ending style: CRLF vs LF
|
|
43
|
+
crlf_count = text.count("\r\n")
|
|
44
|
+
lf_count = text.count("\n") - crlf_count
|
|
45
|
+
line_ending = "\r\n" if crlf_count > lf_count else "\n"
|
|
46
|
+
|
|
47
|
+
lines = text.splitlines(True)
|
|
48
|
+
if end_line > len(lines):
|
|
49
|
+
return (
|
|
50
|
+
None,
|
|
51
|
+
f"end_line {end_line} exceeds file length"
|
|
52
|
+
f" ({len(lines)} lines)",
|
|
53
|
+
(0, 0), "\n",
|
|
54
|
+
)
|
|
55
|
+
return (lines, None, file_stamp, line_ending)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _write_with_backup(
|
|
59
|
+
filepath: str, lines: list[str], file_stamp: tuple[int, int],
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Create a timestamped ``.bak`` backup and write *lines* back.
|
|
62
|
+
|
|
63
|
+
*file_stamp* is ``(st_mtime_ns, st_size)`` from the initial read.
|
|
64
|
+
Raises ``OSError`` if the file was modified externally since the
|
|
65
|
+
last read, if the backup could not be created, or on write failure.
|
|
66
|
+
"""
|
|
67
|
+
_stat = os.stat(filepath)
|
|
68
|
+
if (_stat.st_mtime_ns, _stat.st_size) != file_stamp:
|
|
69
|
+
raise OSError("File was modified externally since last read")
|
|
70
|
+
|
|
71
|
+
now = datetime.now()
|
|
72
|
+
backup_path = (
|
|
73
|
+
filepath + ".bak."
|
|
74
|
+
+ now.strftime("%Y%m%dT%H%M%S") + f".{now.microsecond:06d}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
|
78
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
79
|
+
flags |= os.O_NOFOLLOW
|
|
80
|
+
try:
|
|
81
|
+
fd_bak = os.open(backup_path, flags, 0o600)
|
|
82
|
+
os.close(fd_bak)
|
|
83
|
+
except FileExistsError:
|
|
84
|
+
for i in range(1, 100):
|
|
85
|
+
candidate = backup_path + f".{i}"
|
|
86
|
+
try:
|
|
87
|
+
fd_bak = os.open(candidate, flags, 0o600)
|
|
88
|
+
os.close(fd_bak)
|
|
89
|
+
backup_path = candidate
|
|
90
|
+
break
|
|
91
|
+
except FileExistsError:
|
|
92
|
+
continue
|
|
93
|
+
else:
|
|
94
|
+
raise OSError("Cannot create unique backup path") from None
|
|
95
|
+
|
|
96
|
+
shutil.copy2(filepath, backup_path)
|
|
97
|
+
|
|
98
|
+
if not os.path.exists(backup_path):
|
|
99
|
+
raise OSError(
|
|
100
|
+
"Backup creation failed: "
|
|
101
|
+
f"{backup_path} does not exist"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
parent_dir = os.path.dirname(os.path.abspath(filepath))
|
|
105
|
+
fd = tempfile.NamedTemporaryFile( # pylint: disable=consider-using-with
|
|
106
|
+
mode="wb",
|
|
107
|
+
dir=parent_dir, delete=False, suffix=".tmp",
|
|
108
|
+
)
|
|
109
|
+
tmp_path = fd.name
|
|
110
|
+
try:
|
|
111
|
+
for line in lines:
|
|
112
|
+
fd.write(line.encode("utf-8"))
|
|
113
|
+
fd.flush()
|
|
114
|
+
os.fsync(fd.fileno())
|
|
115
|
+
fd.close()
|
|
116
|
+
orig_mode = os.stat(filepath).st_mode
|
|
117
|
+
os.chmod(tmp_path, orig_mode)
|
|
118
|
+
os.replace(tmp_path, filepath)
|
|
119
|
+
except BaseException:
|
|
120
|
+
fd.close()
|
|
121
|
+
try:
|
|
122
|
+
os.unlink(tmp_path)
|
|
123
|
+
except OSError:
|
|
124
|
+
pass
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def replace_block_source(
|
|
129
|
+
filepath: str,
|
|
130
|
+
start_line: int,
|
|
131
|
+
end_line: int,
|
|
132
|
+
new_source: str,
|
|
133
|
+
) -> dict:
|
|
134
|
+
"""Replace lines *start_line* through *end_line* (1-indexed, inclusive).
|
|
135
|
+
|
|
136
|
+
Creates a timestamped ``.bak`` backup before writing. Returns a
|
|
137
|
+
result dict with ``success``, ``lines_before``, ``lines_after`` on
|
|
138
|
+
success, or ``success=False`` and ``error`` on failure.
|
|
139
|
+
"""
|
|
140
|
+
if not isinstance(new_source, str):
|
|
141
|
+
return {"success": False, "error": "new_source must be a string"}
|
|
142
|
+
|
|
143
|
+
lines, error, file_stamp, line_ending = _read_and_validate(
|
|
144
|
+
filepath, start_line, end_line,
|
|
145
|
+
)
|
|
146
|
+
if lines is None:
|
|
147
|
+
return {"success": False, "error": error}
|
|
148
|
+
|
|
149
|
+
old_count = end_line - start_line + 1
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
# Build replacement lines
|
|
153
|
+
if new_source == "":
|
|
154
|
+
new_lines: list[str] = []
|
|
155
|
+
else:
|
|
156
|
+
raw_lines = new_source.splitlines(True)
|
|
157
|
+
new_lines = [
|
|
158
|
+
ln.rstrip("\r\n") + line_ending for ln in raw_lines
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
new_count = len(new_lines)
|
|
162
|
+
lines[start_line - 1:end_line] = new_lines
|
|
163
|
+
|
|
164
|
+
_write_with_backup(filepath, lines, file_stamp)
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
"success": True,
|
|
168
|
+
"lines_before": old_count,
|
|
169
|
+
"lines_after": new_count,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
except OSError as exc:
|
|
173
|
+
return {"success": False, "error": str(exc)}
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import subprocess # nosec B404
|
|
6
6
|
import sys
|
|
7
|
+
import threading
|
|
7
8
|
|
|
8
9
|
from codedocent.ollama_utils import check_ollama, fetch_ollama_models
|
|
9
10
|
|
|
@@ -41,19 +42,35 @@ def _create_folder_row(frame: ttk.Frame) -> tk.StringVar:
|
|
|
41
42
|
return folder_var
|
|
42
43
|
|
|
43
44
|
|
|
44
|
-
def _create_model_row(
|
|
45
|
+
def _create_model_row(
|
|
46
|
+
frame: ttk.Frame, root: tk.Tk,
|
|
47
|
+
) -> tk.StringVar:
|
|
45
48
|
"""Create the model-dropdown row and return the StringVar."""
|
|
46
49
|
ttk.Label(frame, text="Model:").grid(
|
|
47
50
|
row=2, column=0, sticky="w", pady=(12, 4),
|
|
48
51
|
)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
model_var = tk.StringVar(value=model_values[0])
|
|
53
|
-
ttk.Combobox(
|
|
54
|
-
frame, textvariable=model_var, values=model_values,
|
|
52
|
+
model_var = tk.StringVar(value="Checking...")
|
|
53
|
+
combo = ttk.Combobox(
|
|
54
|
+
frame, textvariable=model_var, values=["Checking..."],
|
|
55
55
|
state="readonly", width=37,
|
|
56
|
-
)
|
|
56
|
+
)
|
|
57
|
+
combo.grid(row=3, column=0, columnspan=2, sticky="ew")
|
|
58
|
+
|
|
59
|
+
def _bg_fetch() -> None:
|
|
60
|
+
try:
|
|
61
|
+
ollama_ok = _check_ollama()
|
|
62
|
+
models = _fetch_ollama_models() if ollama_ok else []
|
|
63
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
64
|
+
models = []
|
|
65
|
+
model_values = models if models else ["No AI"]
|
|
66
|
+
|
|
67
|
+
def _update_ui() -> None:
|
|
68
|
+
combo["values"] = model_values
|
|
69
|
+
model_var.set(model_values[0])
|
|
70
|
+
|
|
71
|
+
root.after(0, _update_ui)
|
|
72
|
+
|
|
73
|
+
threading.Thread(target=_bg_fetch, daemon=True).start()
|
|
57
74
|
return model_var
|
|
58
75
|
|
|
59
76
|
|
|
@@ -90,6 +107,8 @@ def _create_go_button(
|
|
|
90
107
|
cmd = [sys.executable, "-m", "codedocent", folder]
|
|
91
108
|
|
|
92
109
|
selected_model = model_var.get()
|
|
110
|
+
if selected_model == "Checking...":
|
|
111
|
+
return
|
|
93
112
|
if selected_model == "No AI":
|
|
94
113
|
cmd.append("--no-ai")
|
|
95
114
|
else:
|
|
@@ -119,7 +138,7 @@ def _build_gui() -> None:
|
|
|
119
138
|
frame.grid(row=0, column=0, sticky="nsew")
|
|
120
139
|
|
|
121
140
|
folder_var = _create_folder_row(frame)
|
|
122
|
-
model_var = _create_model_row(frame)
|
|
141
|
+
model_var = _create_model_row(frame, root)
|
|
123
142
|
mode_var = _create_mode_row(frame)
|
|
124
143
|
_create_go_button(frame, root, folder_var, model_var, mode_var)
|
|
125
144
|
|
|
@@ -66,6 +66,19 @@ _METHOD_TYPES: dict[str, dict[str, str]] = {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
def _unwrap_exports(root_node) -> list:
|
|
70
|
+
"""Yield top-level children, unwrapping export_statement nodes."""
|
|
71
|
+
result = []
|
|
72
|
+
for child in root_node.children:
|
|
73
|
+
if child.type == "export_statement":
|
|
74
|
+
for inner in child.children:
|
|
75
|
+
if inner.type not in ("export", "default", ",", ";"):
|
|
76
|
+
result.append(inner)
|
|
77
|
+
else:
|
|
78
|
+
result.append(child)
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
69
82
|
def _rules_for(language: str) -> dict[str, tuple[str, str]]:
|
|
70
83
|
"""Return AST extraction rules for the given language."""
|
|
71
84
|
if language == "python":
|
|
@@ -126,7 +139,7 @@ def _extract_arrow_functions(root_node, language: str) -> list[CodeNode]:
|
|
|
126
139
|
if language not in ("javascript", "typescript", "tsx"):
|
|
127
140
|
return []
|
|
128
141
|
results: list[CodeNode] = []
|
|
129
|
-
for child in root_node
|
|
142
|
+
for child in _unwrap_exports(root_node):
|
|
130
143
|
if child.type != "lexical_declaration":
|
|
131
144
|
continue
|
|
132
145
|
for decl in child.children:
|
|
@@ -210,7 +223,12 @@ def _extract_top_level_nodes(
|
|
|
210
223
|
) -> list[CodeNode]:
|
|
211
224
|
"""Walk AST top-level children, create CodeNodes, attach methods."""
|
|
212
225
|
children: list[CodeNode] = []
|
|
213
|
-
|
|
226
|
+
top_children = (
|
|
227
|
+
_unwrap_exports(root_node)
|
|
228
|
+
if language in ("javascript", "typescript", "tsx")
|
|
229
|
+
else root_node.children
|
|
230
|
+
)
|
|
231
|
+
for child in top_children:
|
|
214
232
|
if child.type in rules:
|
|
215
233
|
our_type, name_child = rules[child.type]
|
|
216
234
|
node = CodeNode(
|