codebread 1.0.2__py3-none-any.whl → 1.0.4__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 CHANGED
@@ -1,3 +1,36 @@
1
1
  """CodeBread — slice open a codebase and see its internal structure."""
2
+ from __future__ import annotations
2
3
 
3
- __version__ = "1.0.2"
4
+ import os
5
+
6
+
7
+ def _read_version() -> str:
8
+ """Single source of truth: pyproject.toml's [project] version.
9
+
10
+ Installed packages get it from the wheel's own metadata (always in
11
+ sync with what was actually published). Running from a source
12
+ checkout without installing falls back to reading pyproject.toml
13
+ directly — no parser dependency needed for one `version = "..."`
14
+ line, keeping the zero-dependency promise intact.
15
+ """
16
+ try:
17
+ from importlib.metadata import PackageNotFoundError, version
18
+ try:
19
+ return version("codebread")
20
+ except PackageNotFoundError:
21
+ pass
22
+ except ImportError:
23
+ pass
24
+ try:
25
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
26
+ with open(os.path.join(root, "pyproject.toml"), encoding="utf-8") as f:
27
+ for line in f:
28
+ line = line.strip()
29
+ if line.startswith("version"):
30
+ return line.split("=", 1)[1].strip().strip("\"'")
31
+ except OSError:
32
+ pass
33
+ return "0.0.0+unknown"
34
+
35
+
36
+ __version__ = _read_version()
codebread/server.py CHANGED
@@ -7,7 +7,7 @@ import socket
7
7
  import threading
8
8
  import webbrowser
9
9
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
10
- from typing import Dict
10
+ from typing import Dict, Optional
11
11
 
12
12
  WEB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
13
13
  WEB_ROOT = os.path.realpath(WEB_DIR)
@@ -21,7 +21,30 @@ MIME = {".html": "text/html; charset=utf-8",
21
21
  ".ico": "image/x-icon"}
22
22
 
23
23
 
24
- def _free_port(preferred: int) -> int:
24
+ def safe_web_path(rel: str) -> Optional[str]:
25
+ """Resolve a request path against WEB_DIR and return it only if the
26
+ *resolved* result is still inside WEB_DIR, else None.
27
+
28
+ String-prefix checks on `rel` alone aren't enough: on Windows,
29
+ os.path.join(WEB_DIR, "C:/some/file") silently discards WEB_DIR because
30
+ the second argument is drive-absolute, which would otherwise let a
31
+ request read any file on disk. Kept as a standalone function so the
32
+ traversal guard has a direct regression test, not just cli/browser use.
33
+ """
34
+ full = os.path.realpath(os.path.join(WEB_DIR, rel))
35
+ try:
36
+ inside = os.path.commonpath([full, WEB_ROOT]) == WEB_ROOT
37
+ except ValueError:
38
+ inside = False # e.g. different drives on Windows
39
+ return full if inside else None
40
+
41
+
42
+ def _free_port(preferred: int) -> Optional[int]:
43
+ """Find a bindable port near `preferred`, or None if none was free.
44
+ `preferred == 0` means "let the OS pick" and always succeeds — must
45
+ return that as a distinct value from "no port found", since 0 is a
46
+ falsy int and `if not port:` would otherwise treat a successful
47
+ OS-assigned bind as failure."""
25
48
  for port in [preferred] + list(range(preferred + 1, preferred + 30)):
26
49
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
27
50
  try:
@@ -29,12 +52,10 @@ def _free_port(preferred: int) -> int:
29
52
  return port
30
53
  except OSError:
31
54
  continue
32
- return 0
55
+ return None
33
56
 
34
57
 
35
- def serve(graph: Dict, port: int = 8137, open_browser: bool = True) -> None:
36
- data_bytes = json.dumps(graph, ensure_ascii=False).encode("utf-8")
37
-
58
+ def _make_handler(data_bytes: bytes):
38
59
  class Handler(BaseHTTPRequestHandler):
39
60
  def do_GET(self):
40
61
  path = self.path.split("?")[0]
@@ -46,17 +67,8 @@ def serve(graph: Dict, port: int = 8137, open_browser: bool = True) -> None:
46
67
  self._file(path.lstrip("/"))
47
68
 
48
69
  def _file(self, rel: str):
49
- # Resolve against WEB_DIR and verify the *result* is still inside
50
- # it. String-prefix checks on `rel` alone aren't enough: on
51
- # Windows, os.path.join(WEB_DIR, "C:/some/file") silently
52
- # discards WEB_DIR because the second argument is drive-absolute,
53
- # which would otherwise let a request read any file on disk.
54
- full = os.path.realpath(os.path.join(WEB_DIR, rel))
55
- try:
56
- inside = os.path.commonpath([full, WEB_ROOT]) == WEB_ROOT
57
- except ValueError:
58
- inside = False # e.g. different drives on Windows
59
- if not inside:
70
+ full = safe_web_path(rel)
71
+ if full is None:
60
72
  self.send_error(403)
61
73
  return
62
74
  if not os.path.isfile(full):
@@ -78,12 +90,26 @@ def serve(graph: Dict, port: int = 8137, open_browser: bool = True) -> None:
78
90
  def log_message(self, fmt, *args): # keep the console clean
79
91
  pass
80
92
 
81
- port = _free_port(port)
82
- if not port:
93
+ return Handler
94
+
95
+
96
+ def build_server(graph: Dict, port: int = 8137) -> Optional[ThreadingHTTPServer]:
97
+ """Bind a ready-to-serve ThreadingHTTPServer, or None if no port was
98
+ free. Split out from `serve()` so tests (and other embedders) can start
99
+ and cleanly `.shutdown()` a server instead of blocking forever."""
100
+ data_bytes = json.dumps(graph, ensure_ascii=False).encode("utf-8")
101
+ bound_port = _free_port(port)
102
+ if bound_port is None:
103
+ return None
104
+ return ThreadingHTTPServer(("127.0.0.1", bound_port), _make_handler(data_bytes))
105
+
106
+
107
+ def serve(graph: Dict, port: int = 8137, open_browser: bool = True) -> None:
108
+ server = build_server(graph, port)
109
+ if server is None:
83
110
  print("[codebread] No free port found near 8137 — aborting serve.")
84
111
  return
85
- server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
86
- url = f"http://127.0.0.1:{port}/"
112
+ url = f"http://127.0.0.1:{server.server_port}/"
87
113
  print(f"[codebread] Serving at {url} (Ctrl+C to stop)")
88
114
  if open_browser:
89
115
  threading.Timer(0.4, lambda: webbrowser.open(url)).start()
codebread/web/app.js CHANGED
@@ -348,36 +348,61 @@ function visibleEdges() {
348
348
  }
349
349
 
350
350
  /* ---------------- DOM (re)build ---------------- */
351
+ /* Incremental: only the nodes/edges that actually entered or left the
352
+ visible set touch the DOM. A full teardown+rebuild here would mean every
353
+ single click in a large, densely-linked graph re-creates *everything*
354
+ already on screen (elements + listeners) just to add one more node —
355
+ that's the "too many links = lag" complaint this replaced. */
351
356
  function rebuild() {
352
- nodesG.innerHTML = ""; edgesG.innerHTML = "";
353
- S.elNodes.clear(); S.elEdges.clear();
354
- S.drawnEdges = [];
357
+ const newEdges = visibleEdges();
358
+ const newEdgeKeys = new Set(newEdges.map(edgeKey));
355
359
 
356
- for (const e of visibleEdges()) {
360
+ for (const { key } of S.drawnEdges) {
361
+ if (newEdgeKeys.has(key)) continue;
362
+ const els = S.elEdges.get(key);
363
+ if (!els) continue;
364
+ els.vis.remove(); els.hit.remove();
365
+ S.elEdges.delete(key);
366
+ }
367
+
368
+ const drawnEdges = [];
369
+ for (const e of newEdges) {
357
370
  const key = edgeKey(e);
358
- const vis = document.createElementNS(SVGNS, "path");
359
- vis.setAttribute("class", "edge k-" + e.kind + (e.cycle ? " cycle" : ""));
360
- const hit = document.createElementNS(SVGNS, "path");
361
- hit.setAttribute("class", "edge-hit");
362
- hit.addEventListener("mousemove", (ev) => showEdgeTooltip(e, ev));
363
- hit.addEventListener("mouseleave", hideTooltip);
364
- hit.addEventListener("contextmenu", (ev) => {
365
- ev.preventDefault(); ev.stopPropagation();
366
- onEdgeContextMenu(e, ev);
367
- });
368
- edgesG.appendChild(vis); edgesG.appendChild(hit);
369
- S.elEdges.set(key, { vis, hit });
370
- S.drawnEdges.push({ edge: e, key });
371
+ let els = S.elEdges.get(key);
372
+ if (!els) {
373
+ const vis = document.createElementNS(SVGNS, "path");
374
+ vis.setAttribute("class", "edge k-" + e.kind + (e.cycle ? " cycle" : ""));
375
+ const hit = document.createElementNS(SVGNS, "path");
376
+ hit.setAttribute("class", "edge-hit");
377
+ hit.addEventListener("mousemove", (ev) => showEdgeTooltip(e, ev));
378
+ hit.addEventListener("mouseleave", hideTooltip);
379
+ hit.addEventListener("contextmenu", (ev) => {
380
+ ev.preventDefault(); ev.stopPropagation();
381
+ onEdgeContextMenu(e, ev);
382
+ });
383
+ edgesG.appendChild(vis); edgesG.appendChild(hit);
384
+ els = { vis, hit };
385
+ S.elEdges.set(key, els);
386
+ }
387
+ drawnEdges.push({ edge: e, key });
371
388
  }
389
+ S.drawnEdges = drawnEdges;
372
390
 
391
+ for (const [id, g] of S.elNodes) {
392
+ const n = S.nodesById.get(id);
393
+ if (S.visible.has(id) && n && passesFilter(n)) continue;
394
+ g.remove();
395
+ S.elNodes.delete(id);
396
+ }
373
397
  for (const id of S.visible) {
374
398
  const n = S.nodesById.get(id);
375
- if (!n || !passesFilter(n)) continue;
399
+ if (!n || !passesFilter(n) || S.elNodes.has(id)) continue;
376
400
  ensurePos(id);
377
401
  const g = makeNodeEl(n);
378
402
  nodesG.appendChild(g);
379
403
  S.elNodes.set(id, g);
380
404
  }
405
+
381
406
  applyHighlight();
382
407
  updatePositions();
383
408
  renderMinimap();
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebread
3
- Version: 1.0.2
3
+ Version: 1.0.4
4
4
  Summary: Interactive codebase analyzer & visualizer — slice open a project and see how it's wired together.
5
5
  Author: Danial Irsyad
6
6
  License: MIT
@@ -10,6 +10,8 @@ Description-Content-Type: text/markdown
10
10
  License-File: LICENSE
11
11
  Provides-Extra: full
12
12
  Requires-Dist: pathspec>=0.11; extra == "full"
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest>=7; extra == "test"
13
15
  Dynamic: license-file
14
16
 
15
17
  <p align="center">
@@ -22,7 +24,8 @@ Dynamic: license-file
22
24
  </p>
23
25
 
24
26
  <p align="center">
25
- <img alt="version" src="https://img.shields.io/badge/version-1.0.2-2dd4bf">
27
+ <a href="https://pypi.org/project/codebread/"><img alt="PyPI" src="https://img.shields.io/pypi/v/codebread?color=2dd4bf&label=version"></a>
28
+ <a href="https://github.com/honow48-tech/CodeBread/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/honow48-tech/CodeBread/actions/workflows/ci.yml/badge.svg"></a>
26
29
  <img alt="python" src="https://img.shields.io/badge/python-3.9%2B-0284c7">
27
30
  <img alt="deps" src="https://img.shields.io/badge/required%20dependencies-none-2dd4bf">
28
31
  <img alt="license" src="https://img.shields.io/badge/license-MIT-8b5cf6">
@@ -164,14 +167,38 @@ the map already scanned.
164
167
 
165
168
  ## Language support
166
169
 
167
- | Language | Parser | Extracted |
168
- |---|---|---|
169
- | Python | stdlib `ast` (precise) | functions, classes, params/annotations, docstrings, Flask/FastAPI/Django routes, SQLAlchemy/Django models, raw SQL, `requests`/`httpx` calls |
170
- | JavaScript / TypeScript / JSX / TSX / Vue / Svelte | structural regex + brace matching | functions, arrow fns, classes, Express/Nest/Fastify routes, `fetch`/`axios` calls, Mongoose/Prisma/Sequelize/Knex/TypeORM, raw SQL |
171
- | Java, C#, Go, PHP, Ruby | structural regex | functions/methods, classes, Spring/ASP.NET/Laravel/Rails/Gin routes, raw SQL |
172
- | SQL | regex | `CREATE TABLE` schemas with columns |
173
- | Config (`.env`, json/yaml/toml/ini, settings.py…) | key scan | DB connection settings **credentials always masked** |
174
- | Anything else (Rust, Kotlin, Swift, C/C++…) | | shown in the UI as "⚠ Unsupported parsing skipped", never silently dropped |
170
+ Parser used per language, then exactly what gets extracted — checked feature
171
+ by feature against the actual parser code, not a marketing table:
172
+
173
+ | Language | Parser | Functions / methods | Classes / structs | Backend routes | Outgoing API calls | ORM / DB models | Raw SQL | Call graph | Page-nav links |
174
+ |---|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
175
+ | Python | stdlib `ast` (precise) | ✅ | ✅ | ✅ Flask / FastAPI / Django | ✅ `requests` / `httpx` / `aiohttp` / `urllib` | ✅ SQLAlchemy / Django / peewee / tortoise | ✅ | ✅ | – |
176
+ | JavaScript / TypeScript / JSX / TSX | regex + brace matching | ✅ | ✅ | ✅ Express / Fastify / Koa / NestJS | `fetch` / `axios` | Mongoose / Prisma / Sequelize / Knex / TypeORM | ✅ | ✅ | – |
177
+ | Vue / Svelte | regex, `<script>` block only¹ | | | (same as JS) | (same as JS) | (same as JS) | ✅ | ✅ | – |
178
+ | Java | regex | ✅ | ✅ | ✅ Spring `@*Mapping` | – | – | ✅ | ✅ | – |
179
+ | C# | regex | ✅ | ✅ | ✅ ASP.NET `[Http*]` | – | – | ✅ | ✅ | – |
180
+ | Go | regex | ✅ | ✅ (structs) | ✅ Gin / net-http style | – | – | ✅ | ✅ | – |
181
+ | PHP | regex | ✅ | ✅ | ✅ Laravel `Route::` | – | – | ✅ | ✅ | ✅ |
182
+ | Ruby | regex | ✅ | ✅ | ✅ Rails / Sinatra | – | – | ✅ | ✅ | ✅ |
183
+ | SQL (`.sql` files) | regex | – | – | – | – | ✅ `CREATE TABLE` schemas + columns | ✅ | – | – |
184
+ | Config (`.env`, json/yaml/toml/ini/xml…)² | key scan | – | – | – | – | – | – | – | – |
185
+ | Anything else (Rust, Kotlin, Swift, C/C++, Scala, Elixir, Erlang, Lua, R, Perl, Dart, Zig…) | – | – | – | – | – | – | – | – | – |
186
+
187
+ Unsupported languages still show up in the Explorer tree with an
188
+ "⚠ Unsupported — parsing skipped" badge — never silently dropped — they just
189
+ have no functions/edges to draw.
190
+
191
+ ¹ Vue/Svelte only parse the `<script>` block — template-only bindings
192
+ (`@click`, `v-on`, Svelte reactive markup) aren't extracted.
193
+ ² Config-format files get a DB-connection-settings scan instead of code
194
+ extraction — **credentials always masked**, see [export.py](codebread/export.py)-embedded
195
+ source too. Files like `settings.py` or `config.js` are parsed as their real
196
+ language (full extraction above), not this bucket — only non-code formats
197
+ (json/yaml/toml/ini/`.env`/xml/properties) land here.
198
+
199
+ Only Python's `ast`-based parser captures real parameter type annotations,
200
+ return types, and docstrings — every regex-based parser above extracts
201
+ parameter *names* only.
175
202
 
176
203
  ## How it classifies layers
177
204
 
@@ -214,12 +241,30 @@ codebread/
214
241
  server.py ← stdlib local web server
215
242
  export.py ← JSON + single-file HTML export
216
243
  web/ ← the UI (vanilla JS + SVG, zero dependencies)
244
+ tests/ ← pytest suite + per-language fixture corpus
217
245
  assets/ ← logo (.svg source + .png for the README —
218
246
  GitHub blocks same-repo SVG <img> embeds)
219
247
  ```
220
248
 
221
249
  ---
222
250
 
251
+ ## Testing
252
+
253
+ ```bash
254
+ pip install -e ".[full,test]"
255
+ pytest -q
256
+ ```
257
+
258
+ The suite covers every parser (a small fixture file per language under
259
+ `tests/fixtures/`), the connection/graph builder (call resolution,
260
+ API↔route matching, DB edges, orphan + cycle detection), the scanner
261
+ (`.gitignore` handling, language detection), the local server (including a
262
+ regression test for the path-traversal fix), and an end-to-end analyze
263
+ pass — including a regression test that secrets in scanned `.env` files
264
+ never leak into an exported graph. CI (`.github/workflows/ci.yml`) runs it
265
+ on every push/PR across the minimum and latest supported Python versions,
266
+ and the PyPI release workflow won't publish if it fails.
267
+
223
268
  ## Roadmap
224
269
 
225
270
  Already done as of v1.0:
@@ -227,13 +272,13 @@ Already done as of v1.0:
227
272
  - [x] Orphaned-function and circular-dependency detection
228
273
  - [x] Diff mode between two scans
229
274
  - [x] Focus mode, orbit layout, right-click actions, minimap, breadcrumb
275
+ - [x] Automated test suite + CI
230
276
 
231
277
  Not yet, but planned:
232
278
 
233
279
  - [ ] "Explain this function" — a plain-language AI summary button
234
280
  - [ ] More precise multi-language parsing (currently structural regex for
235
281
  everything but Python)
236
- - [ ] An automated test suite
237
282
 
238
283
  Have an idea? Open an issue.
239
284
 
@@ -247,8 +292,12 @@ but contributions are genuinely welcome:
247
292
  or UI polish. Keep the zero-dependency philosophy: the core tool should
248
293
  keep running on nothing but the Python standard library, and the UI
249
294
  should keep running on vanilla JS with no build step.
250
- - No formal test suite yet, so please describe how you manually verified a
251
- change in your PR.
295
+ - Run `pytest -q` before opening a PR (see [Testing](#testing) above); add
296
+ a fixture + test case under `tests/` for new parser behavior where you
297
+ can.
298
+
299
+ Security issue instead? See [SECURITY.md](SECURITY.md) rather than a
300
+ public issue.
252
301
 
253
302
  ## License
254
303
 
@@ -1,4 +1,4 @@
1
- codebread/__init__.py,sha256=InwUkpEmPv-mS3-33Vu1RXBTw1Zm4tYbe9RB4DAsW5s,97
1
+ codebread/__init__.py,sha256=mwaD5s8k8q32ofSXrl6_tMjBJk0U1U2lrxwP3PPNtCs,1215
2
2
  codebread/analyzer.py,sha256=tCsV0Cwi1l-If5zbNLbMtsuKW3o-rl8mekNG-B7MXpY,3234
3
3
  codebread/classifier.py,sha256=jehD-XQ80CP_O4x9YRi4JQqzd4AaHxKpzI83wLG1yvM,4067
4
4
  codebread/cli.py,sha256=AMvwk29cOJDjsI-I07pCGbqogdtq1Y6HWnT52FHYyLs,4072
@@ -8,17 +8,17 @@ codebread/export.py,sha256=rKYmzuSEXYDrgcItnhdyXI_lo0LN2PSoMe0hYElzU88,1512
8
8
  codebread/languages.py,sha256=SKHy9v7QPreWZG6FbuX3B0-kq1jqVn-rmwNqJ9ESFqo,4494
9
9
  codebread/models.py,sha256=Sw2PJW63dKyl5RpWwfY2ZvCJLYttmjoQg4O5H5DtLgM,4214
10
10
  codebread/scanner.py,sha256=bEXSaoENmJ-C_oabj2IF1aWs9-LaIqMc80xXQvo7-eg,8020
11
- codebread/server.py,sha256=H3kUXJaH8FkExNUwdwSD2HxBMceC_24o6pVSjJjFh8g,3543
11
+ codebread/server.py,sha256=Uh7L2n0pumpt1UNSlC1mvcjg6yBl1LAxTmbz9cmsilA,4597
12
12
  codebread/parsers/__init__.py,sha256=ovsvmXGClshiUlDdx6mkBmRu8__OZIqYos6-1eLb9eM,2285
13
13
  codebread/parsers/generic_parser.py,sha256=lz-wLsmQYERoDRM27lhEP3cvNYbomHfA--VI_3LmJ5E,11966
14
14
  codebread/parsers/javascript_parser.py,sha256=lVIlydQ3--Owd78816XIM77NntfQ97DeLzYvPrjd5GE,15916
15
15
  codebread/parsers/python_parser.py,sha256=QnhCLx01bKYLYqav33cI27qbEQNPUiXnS5uN7cb8poQ,11671
16
- codebread/web/app.js,sha256=P1zpCHNMyczAuLWL9zM5htl9E3wG3jJdpZ6orLhixTM,65672
16
+ codebread/web/app.js,sha256=bsm7cpji0mJrnpr0TPNhsL6w2bQw4RMwA8lS_T57G1s,66567
17
17
  codebread/web/index.html,sha256=6YqjC_wO3pfqyG_11uIhvRM16Wagu2l8eSOs1FNc-UY,8693
18
18
  codebread/web/style.css,sha256=fiq1bC-I1bC6-6MxRn0blQwGOjO_fdClPrhf5W7zqPw,23729
19
- codebread-1.0.2.dist-info/licenses/LICENSE,sha256=okINupIvMgiuOlvMVpsM9DT5jb6QQmKBd--yfZq4bjc,1070
20
- codebread-1.0.2.dist-info/METADATA,sha256=zWaglNGGhQUvXpPnyXwNdS_a6Og2jdIFhWImFNlGvAY,11367
21
- codebread-1.0.2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
22
- codebread-1.0.2.dist-info/entry_points.txt,sha256=ItG61aUP1YTvA_zA-jlUshBc5BPpJDpCyVnsyaxNXKA,49
23
- codebread-1.0.2.dist-info/top_level.txt,sha256=KctFB7UQb4tN9YotaJD95GuHWIQuoO5VqyLdo_gEswQ,10
24
- codebread-1.0.2.dist-info/RECORD,,
19
+ codebread-1.0.4.dist-info/licenses/LICENSE,sha256=okINupIvMgiuOlvMVpsM9DT5jb6QQmKBd--yfZq4bjc,1070
20
+ codebread-1.0.4.dist-info/METADATA,sha256=KY847kSXgZNds5HE-a9UJdGXazfY1nhrWW661_1lIjg,14262
21
+ codebread-1.0.4.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
22
+ codebread-1.0.4.dist-info/entry_points.txt,sha256=ItG61aUP1YTvA_zA-jlUshBc5BPpJDpCyVnsyaxNXKA,49
23
+ codebread-1.0.4.dist-info/top_level.txt,sha256=KctFB7UQb4tN9YotaJD95GuHWIQuoO5VqyLdo_gEswQ,10
24
+ codebread-1.0.4.dist-info/RECORD,,