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/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()