codebread 1.0.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.
- codebread/__init__.py +3 -0
- codebread/analyzer.py +84 -0
- codebread/classifier.py +102 -0
- codebread/cli.py +106 -0
- codebread/connections.py +407 -0
- codebread/diff.py +123 -0
- codebread/export.py +45 -0
- codebread/languages.py +110 -0
- codebread/models.py +119 -0
- codebread/parsers/__init__.py +53 -0
- codebread/parsers/generic_parser.py +288 -0
- codebread/parsers/javascript_parser.py +371 -0
- codebread/parsers/python_parser.py +291 -0
- codebread/scanner.py +206 -0
- codebread/server.py +86 -0
- codebread/web/app.js +1716 -0
- codebread/web/index.html +188 -0
- codebread/web/style.css +601 -0
- codebread-1.0.0.dist-info/METADATA +254 -0
- codebread-1.0.0.dist-info/RECORD +24 -0
- codebread-1.0.0.dist-info/WHEEL +5 -0
- codebread-1.0.0.dist-info/entry_points.txt +2 -0
- codebread-1.0.0.dist-info/licenses/LICENSE +21 -0
- codebread-1.0.0.dist-info/top_level.txt +1 -0
codebread/scanner.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Recursive, .gitignore-aware directory scanner.
|
|
2
|
+
|
|
3
|
+
Produces a file tree (for the sidebar) plus a flat list of scannable files.
|
|
4
|
+
Never crashes silently: unreadable entries become warnings.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import fnmatch
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from typing import Dict, List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from .languages import detect_language, looks_binary
|
|
14
|
+
|
|
15
|
+
SKIP_DIRS = {
|
|
16
|
+
"node_modules", ".git", ".hg", ".svn", "__pycache__", ".pytest_cache",
|
|
17
|
+
".mypy_cache", ".ruff_cache", ".tox", ".nox", "venv", ".venv", "env",
|
|
18
|
+
".env_dir", "virtualenv", "site-packages", "dist", "build", "out",
|
|
19
|
+
".next", ".nuxt", ".svelte-kit", ".angular", "coverage", ".coverage_html",
|
|
20
|
+
"target", "bin", "obj", ".idea", ".vscode", ".vs", "vendor", "bower_components",
|
|
21
|
+
".terraform", ".serverless", ".parcel-cache", ".turbo", ".cache", "eggs",
|
|
22
|
+
".eggs", "htmlcov", ".gradle", "cmake-build-debug",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _SimpleGitignore:
|
|
27
|
+
"""Minimal .gitignore matcher used when `pathspec` isn't installed.
|
|
28
|
+
|
|
29
|
+
Supports: comments, blank lines, `dir/` patterns, leading `/` anchors,
|
|
30
|
+
`*` / `?` globs, `**` and `!` negation (basic).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, lines: List[str]):
|
|
34
|
+
self.rules: List[Tuple[bool, str, bool]] = [] # (negated, regex, dir_only)
|
|
35
|
+
for raw in lines:
|
|
36
|
+
line = raw.rstrip("\n")
|
|
37
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
38
|
+
continue
|
|
39
|
+
neg = line.startswith("!")
|
|
40
|
+
if neg:
|
|
41
|
+
line = line[1:]
|
|
42
|
+
line = line.strip()
|
|
43
|
+
dir_only = line.endswith("/")
|
|
44
|
+
line = line.rstrip("/")
|
|
45
|
+
anchored = line.startswith("/") or "/" in line
|
|
46
|
+
line = line.lstrip("/")
|
|
47
|
+
pat = self._to_regex(line, anchored)
|
|
48
|
+
self.rules.append((neg, pat, dir_only))
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _to_regex(glob: str, anchored: bool) -> str:
|
|
52
|
+
out = []
|
|
53
|
+
i = 0
|
|
54
|
+
while i < len(glob):
|
|
55
|
+
c = glob[i]
|
|
56
|
+
if c == "*":
|
|
57
|
+
if glob[i:i + 2] == "**":
|
|
58
|
+
out.append(".*")
|
|
59
|
+
i += 2
|
|
60
|
+
if i < len(glob) and glob[i] == "/":
|
|
61
|
+
i += 1
|
|
62
|
+
continue
|
|
63
|
+
out.append("[^/]*")
|
|
64
|
+
elif c == "?":
|
|
65
|
+
out.append("[^/]")
|
|
66
|
+
else:
|
|
67
|
+
out.append(re.escape(c))
|
|
68
|
+
i += 1
|
|
69
|
+
body = "".join(out)
|
|
70
|
+
prefix = "^" if anchored else "(^|.*/)"
|
|
71
|
+
return prefix + body + "$"
|
|
72
|
+
|
|
73
|
+
def matches(self, rel_path: str, is_dir: bool) -> bool:
|
|
74
|
+
rel = rel_path.replace(os.sep, "/")
|
|
75
|
+
ignored = False
|
|
76
|
+
for neg, pat, dir_only in self.rules:
|
|
77
|
+
if dir_only and not is_dir:
|
|
78
|
+
# dir-only pattern still ignores files *inside* that dir
|
|
79
|
+
if not re.match(pat.rstrip("$") + "(/.*)?$", rel):
|
|
80
|
+
continue
|
|
81
|
+
elif not re.match(pat, rel):
|
|
82
|
+
continue
|
|
83
|
+
ignored = not neg
|
|
84
|
+
return ignored
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class GitignoreStack:
|
|
88
|
+
"""Loads the root .gitignore (via pathspec when available)."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, root: str):
|
|
91
|
+
self.root = root
|
|
92
|
+
self._spec = None
|
|
93
|
+
self._simple: Optional[_SimpleGitignore] = None
|
|
94
|
+
gi = os.path.join(root, ".gitignore")
|
|
95
|
+
if os.path.isfile(gi):
|
|
96
|
+
try:
|
|
97
|
+
with open(gi, "r", encoding="utf-8", errors="replace") as f:
|
|
98
|
+
lines = f.readlines()
|
|
99
|
+
except OSError:
|
|
100
|
+
lines = []
|
|
101
|
+
try:
|
|
102
|
+
import pathspec # type: ignore
|
|
103
|
+
self._spec = pathspec.PathSpec.from_lines("gitwildmatch", lines)
|
|
104
|
+
except ImportError:
|
|
105
|
+
self._simple = _SimpleGitignore(lines)
|
|
106
|
+
|
|
107
|
+
def ignored(self, rel_path: str, is_dir: bool) -> bool:
|
|
108
|
+
rel = rel_path.replace(os.sep, "/")
|
|
109
|
+
if self._spec is not None:
|
|
110
|
+
probe = rel + "/" if is_dir else rel
|
|
111
|
+
return self._spec.match_file(probe)
|
|
112
|
+
if self._simple is not None:
|
|
113
|
+
return self._simple.matches(rel, is_dir)
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def scan(root: str, max_file_kb: int = 1024) -> Dict:
|
|
118
|
+
"""Walk `root`, returning {'tree': ..., 'files': [...], 'warnings': [...]}.
|
|
119
|
+
|
|
120
|
+
Each entry in `files`: {'path': rel, 'abspath': abs, 'language': str}.
|
|
121
|
+
Tree nodes: {'name', 'path', 'type': 'dir'|'file', 'language', 'children'}.
|
|
122
|
+
"""
|
|
123
|
+
root = os.path.abspath(root)
|
|
124
|
+
gitignore = GitignoreStack(root)
|
|
125
|
+
warnings: List[Dict] = []
|
|
126
|
+
files: List[Dict] = []
|
|
127
|
+
|
|
128
|
+
def walk_dir(abs_dir: str, rel_dir: str) -> Dict:
|
|
129
|
+
name = os.path.basename(abs_dir) or abs_dir
|
|
130
|
+
node = {"name": name, "path": rel_dir.replace(os.sep, "/"),
|
|
131
|
+
"type": "dir", "children": []}
|
|
132
|
+
try:
|
|
133
|
+
entries = sorted(os.scandir(abs_dir),
|
|
134
|
+
key=lambda e: (e.is_file(), e.name.lower()))
|
|
135
|
+
except PermissionError:
|
|
136
|
+
warnings.append({"path": rel_dir or ".",
|
|
137
|
+
"message": "Permission denied — folder skipped. "
|
|
138
|
+
"Re-run with elevated permissions to include it."})
|
|
139
|
+
node["warning"] = "permission"
|
|
140
|
+
return node
|
|
141
|
+
except OSError as exc:
|
|
142
|
+
warnings.append({"path": rel_dir or ".", "message": f"Unreadable folder: {exc}"})
|
|
143
|
+
node["warning"] = "unreadable"
|
|
144
|
+
return node
|
|
145
|
+
|
|
146
|
+
for entry in entries:
|
|
147
|
+
rel = os.path.join(rel_dir, entry.name) if rel_dir else entry.name
|
|
148
|
+
try:
|
|
149
|
+
is_dir = entry.is_dir(follow_symlinks=False)
|
|
150
|
+
is_link = entry.is_symlink()
|
|
151
|
+
except OSError:
|
|
152
|
+
continue
|
|
153
|
+
if is_link:
|
|
154
|
+
continue
|
|
155
|
+
if is_dir:
|
|
156
|
+
if entry.name in SKIP_DIRS or entry.name.startswith(".git"):
|
|
157
|
+
continue
|
|
158
|
+
if gitignore.ignored(rel, True):
|
|
159
|
+
continue
|
|
160
|
+
child = walk_dir(entry.path, rel)
|
|
161
|
+
# keep dirs that contain anything (incl. warning stubs)
|
|
162
|
+
if child["children"] or child.get("warning"):
|
|
163
|
+
node["children"].append(child)
|
|
164
|
+
else:
|
|
165
|
+
if gitignore.ignored(rel, False):
|
|
166
|
+
continue
|
|
167
|
+
lang = detect_language(entry.path)
|
|
168
|
+
if lang == "lockfile":
|
|
169
|
+
continue
|
|
170
|
+
fnode = {"name": entry.name, "path": rel.replace(os.sep, "/"),
|
|
171
|
+
"type": "file", "language": lang}
|
|
172
|
+
try:
|
|
173
|
+
size = entry.stat().st_size
|
|
174
|
+
except OSError:
|
|
175
|
+
size = 0
|
|
176
|
+
if size > max_file_kb * 1024:
|
|
177
|
+
fnode["warning"] = "too-large"
|
|
178
|
+
warnings.append({"path": fnode["path"],
|
|
179
|
+
"message": f"File larger than {max_file_kb} KB — parsing skipped."})
|
|
180
|
+
node["children"].append(fnode)
|
|
181
|
+
continue
|
|
182
|
+
if looks_binary(entry.path):
|
|
183
|
+
if lang in ("unknown", "config"):
|
|
184
|
+
continue # true binaries stay out of the tree
|
|
185
|
+
fnode["warning"] = "binary"
|
|
186
|
+
node["children"].append(fnode)
|
|
187
|
+
continue
|
|
188
|
+
node["children"].append(fnode)
|
|
189
|
+
files.append({"path": fnode["path"], "abspath": entry.path,
|
|
190
|
+
"language": lang})
|
|
191
|
+
return node
|
|
192
|
+
|
|
193
|
+
tree = walk_dir(root, "")
|
|
194
|
+
tree["name"] = os.path.basename(root) or root
|
|
195
|
+
return {"tree": tree, "files": files, "warnings": warnings}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def read_text(abspath: str) -> Tuple[str, str]:
|
|
199
|
+
"""Read a file as text. Returns (text, error). Never raises."""
|
|
200
|
+
try:
|
|
201
|
+
with open(abspath, "r", encoding="utf-8", errors="replace") as f:
|
|
202
|
+
return f.read(), ""
|
|
203
|
+
except PermissionError:
|
|
204
|
+
return "", "Permission denied reading file."
|
|
205
|
+
except OSError as exc:
|
|
206
|
+
return "", f"Could not read file: {exc}"
|
codebread/server.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Tiny local web server (stdlib only) that serves the UI + graph JSON."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import threading
|
|
8
|
+
import webbrowser
|
|
9
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
10
|
+
from typing import Dict
|
|
11
|
+
|
|
12
|
+
WEB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
|
|
13
|
+
|
|
14
|
+
MIME = {".html": "text/html; charset=utf-8",
|
|
15
|
+
".js": "application/javascript; charset=utf-8",
|
|
16
|
+
".css": "text/css; charset=utf-8",
|
|
17
|
+
".json": "application/json; charset=utf-8",
|
|
18
|
+
".svg": "image/svg+xml",
|
|
19
|
+
".png": "image/png",
|
|
20
|
+
".ico": "image/x-icon"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _free_port(preferred: int) -> int:
|
|
24
|
+
for port in [preferred] + list(range(preferred + 1, preferred + 30)):
|
|
25
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
26
|
+
try:
|
|
27
|
+
s.bind(("127.0.0.1", port))
|
|
28
|
+
return port
|
|
29
|
+
except OSError:
|
|
30
|
+
continue
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def serve(graph: Dict, port: int = 8137, open_browser: bool = True) -> None:
|
|
35
|
+
data_bytes = json.dumps(graph, ensure_ascii=False).encode("utf-8")
|
|
36
|
+
|
|
37
|
+
class Handler(BaseHTTPRequestHandler):
|
|
38
|
+
def do_GET(self):
|
|
39
|
+
path = self.path.split("?")[0]
|
|
40
|
+
if path in ("/", "/index.html"):
|
|
41
|
+
self._file("index.html")
|
|
42
|
+
elif path == "/data.json":
|
|
43
|
+
self._bytes(data_bytes, "application/json; charset=utf-8")
|
|
44
|
+
else:
|
|
45
|
+
self._file(path.lstrip("/"))
|
|
46
|
+
|
|
47
|
+
def _file(self, rel: str):
|
|
48
|
+
rel = os.path.normpath(rel).replace("\\", "/")
|
|
49
|
+
if rel.startswith("..") or rel.startswith("/"):
|
|
50
|
+
self.send_error(403)
|
|
51
|
+
return
|
|
52
|
+
full = os.path.join(WEB_DIR, rel)
|
|
53
|
+
if not os.path.isfile(full):
|
|
54
|
+
self.send_error(404)
|
|
55
|
+
return
|
|
56
|
+
with open(full, "rb") as f:
|
|
57
|
+
body = f.read()
|
|
58
|
+
ext = os.path.splitext(full)[1].lower()
|
|
59
|
+
self._bytes(body, MIME.get(ext, "application/octet-stream"))
|
|
60
|
+
|
|
61
|
+
def _bytes(self, body: bytes, ctype: str):
|
|
62
|
+
self.send_response(200)
|
|
63
|
+
self.send_header("Content-Type", ctype)
|
|
64
|
+
self.send_header("Content-Length", str(len(body)))
|
|
65
|
+
self.send_header("Cache-Control", "no-store")
|
|
66
|
+
self.end_headers()
|
|
67
|
+
self.wfile.write(body)
|
|
68
|
+
|
|
69
|
+
def log_message(self, fmt, *args): # keep the console clean
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
port = _free_port(port)
|
|
73
|
+
if not port:
|
|
74
|
+
print("[codebread] No free port found near 8137 — aborting serve.")
|
|
75
|
+
return
|
|
76
|
+
server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
|
77
|
+
url = f"http://127.0.0.1:{port}/"
|
|
78
|
+
print(f"[codebread] Serving at {url} (Ctrl+C to stop)")
|
|
79
|
+
if open_browser:
|
|
80
|
+
threading.Timer(0.4, lambda: webbrowser.open(url)).start()
|
|
81
|
+
try:
|
|
82
|
+
server.serve_forever()
|
|
83
|
+
except KeyboardInterrupt:
|
|
84
|
+
print("\n[codebread] Stopped.")
|
|
85
|
+
finally:
|
|
86
|
+
server.server_close()
|