codebread 1.0.3__tar.gz → 1.0.4__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.
Files changed (39) hide show
  1. {codebread-1.0.3/codebread.egg-info → codebread-1.0.4}/PKG-INFO +30 -5
  2. codebread-1.0.3/PKG-INFO → codebread-1.0.4/README.md +27 -18
  3. codebread-1.0.4/codebread/__init__.py +36 -0
  4. {codebread-1.0.3 → codebread-1.0.4}/codebread/server.py +47 -21
  5. codebread-1.0.3/README.md → codebread-1.0.4/codebread.egg-info/PKG-INFO +43 -4
  6. {codebread-1.0.3 → codebread-1.0.4}/codebread.egg-info/SOURCES.txt +10 -1
  7. {codebread-1.0.3 → codebread-1.0.4}/codebread.egg-info/requires.txt +3 -0
  8. {codebread-1.0.3 → codebread-1.0.4}/pyproject.toml +5 -1
  9. codebread-1.0.4/tests/test_analyzer.py +71 -0
  10. codebread-1.0.4/tests/test_config_parser.py +36 -0
  11. codebread-1.0.4/tests/test_connections.py +96 -0
  12. codebread-1.0.4/tests/test_generic_parser.py +104 -0
  13. codebread-1.0.4/tests/test_javascript_parser.py +82 -0
  14. codebread-1.0.4/tests/test_python_parser.py +59 -0
  15. codebread-1.0.4/tests/test_scanner.py +84 -0
  16. codebread-1.0.4/tests/test_server.py +83 -0
  17. codebread-1.0.4/tests/test_version.py +14 -0
  18. codebread-1.0.3/codebread/__init__.py +0 -3
  19. {codebread-1.0.3 → codebread-1.0.4}/LICENSE +0 -0
  20. {codebread-1.0.3 → codebread-1.0.4}/codebread/analyzer.py +0 -0
  21. {codebread-1.0.3 → codebread-1.0.4}/codebread/classifier.py +0 -0
  22. {codebread-1.0.3 → codebread-1.0.4}/codebread/cli.py +0 -0
  23. {codebread-1.0.3 → codebread-1.0.4}/codebread/connections.py +0 -0
  24. {codebread-1.0.3 → codebread-1.0.4}/codebread/diff.py +0 -0
  25. {codebread-1.0.3 → codebread-1.0.4}/codebread/export.py +0 -0
  26. {codebread-1.0.3 → codebread-1.0.4}/codebread/languages.py +0 -0
  27. {codebread-1.0.3 → codebread-1.0.4}/codebread/models.py +0 -0
  28. {codebread-1.0.3 → codebread-1.0.4}/codebread/parsers/__init__.py +0 -0
  29. {codebread-1.0.3 → codebread-1.0.4}/codebread/parsers/generic_parser.py +0 -0
  30. {codebread-1.0.3 → codebread-1.0.4}/codebread/parsers/javascript_parser.py +0 -0
  31. {codebread-1.0.3 → codebread-1.0.4}/codebread/parsers/python_parser.py +0 -0
  32. {codebread-1.0.3 → codebread-1.0.4}/codebread/scanner.py +0 -0
  33. {codebread-1.0.3 → codebread-1.0.4}/codebread/web/app.js +0 -0
  34. {codebread-1.0.3 → codebread-1.0.4}/codebread/web/index.html +0 -0
  35. {codebread-1.0.3 → codebread-1.0.4}/codebread/web/style.css +0 -0
  36. {codebread-1.0.3 → codebread-1.0.4}/codebread.egg-info/dependency_links.txt +0 -0
  37. {codebread-1.0.3 → codebread-1.0.4}/codebread.egg-info/entry_points.txt +0 -0
  38. {codebread-1.0.3 → codebread-1.0.4}/codebread.egg-info/top_level.txt +0 -0
  39. {codebread-1.0.3 → codebread-1.0.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebread
3
- Version: 1.0.3
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">
@@ -238,12 +241,30 @@ codebread/
238
241
  server.py ← stdlib local web server
239
242
  export.py ← JSON + single-file HTML export
240
243
  web/ ← the UI (vanilla JS + SVG, zero dependencies)
244
+ tests/ ← pytest suite + per-language fixture corpus
241
245
  assets/ ← logo (.svg source + .png for the README —
242
246
  GitHub blocks same-repo SVG <img> embeds)
243
247
  ```
244
248
 
245
249
  ---
246
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
+
247
268
  ## Roadmap
248
269
 
249
270
  Already done as of v1.0:
@@ -251,13 +272,13 @@ Already done as of v1.0:
251
272
  - [x] Orphaned-function and circular-dependency detection
252
273
  - [x] Diff mode between two scans
253
274
  - [x] Focus mode, orbit layout, right-click actions, minimap, breadcrumb
275
+ - [x] Automated test suite + CI
254
276
 
255
277
  Not yet, but planned:
256
278
 
257
279
  - [ ] "Explain this function" — a plain-language AI summary button
258
280
  - [ ] More precise multi-language parsing (currently structural regex for
259
281
  everything but Python)
260
- - [ ] An automated test suite
261
282
 
262
283
  Have an idea? Open an issue.
263
284
 
@@ -271,8 +292,12 @@ but contributions are genuinely welcome:
271
292
  or UI polish. Keep the zero-dependency philosophy: the core tool should
272
293
  keep running on nothing but the Python standard library, and the UI
273
294
  should keep running on vanilla JS with no build step.
274
- - No formal test suite yet, so please describe how you manually verified a
275
- 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.
276
301
 
277
302
  ## License
278
303
 
@@ -1,17 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: codebread
3
- Version: 1.0.3
4
- Summary: Interactive codebase analyzer & visualizer — slice open a project and see how it's wired together.
5
- Author: Danial Irsyad
6
- License: MIT
7
- Keywords: codebase,analyzer,visualizer,call-graph,architecture
8
- Requires-Python: >=3.9
9
- Description-Content-Type: text/markdown
10
- License-File: LICENSE
11
- Provides-Extra: full
12
- Requires-Dist: pathspec>=0.11; extra == "full"
13
- Dynamic: license-file
14
-
15
1
  <p align="center">
16
2
  <img src="https://cdn.jsdelivr.net/gh/honow48-tech/CodeBread@main/assets/logo-wordmark.png" alt="CodeBread" width="360">
17
3
  </p>
@@ -22,7 +8,8 @@ Dynamic: license-file
22
8
  </p>
23
9
 
24
10
  <p align="center">
25
- <img alt="version" src="https://img.shields.io/badge/version-1.0.2-2dd4bf">
11
+ <a href="https://pypi.org/project/codebread/"><img alt="PyPI" src="https://img.shields.io/pypi/v/codebread?color=2dd4bf&label=version"></a>
12
+ <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
13
  <img alt="python" src="https://img.shields.io/badge/python-3.9%2B-0284c7">
27
14
  <img alt="deps" src="https://img.shields.io/badge/required%20dependencies-none-2dd4bf">
28
15
  <img alt="license" src="https://img.shields.io/badge/license-MIT-8b5cf6">
@@ -238,12 +225,30 @@ codebread/
238
225
  server.py ← stdlib local web server
239
226
  export.py ← JSON + single-file HTML export
240
227
  web/ ← the UI (vanilla JS + SVG, zero dependencies)
228
+ tests/ ← pytest suite + per-language fixture corpus
241
229
  assets/ ← logo (.svg source + .png for the README —
242
230
  GitHub blocks same-repo SVG <img> embeds)
243
231
  ```
244
232
 
245
233
  ---
246
234
 
235
+ ## Testing
236
+
237
+ ```bash
238
+ pip install -e ".[full,test]"
239
+ pytest -q
240
+ ```
241
+
242
+ The suite covers every parser (a small fixture file per language under
243
+ `tests/fixtures/`), the connection/graph builder (call resolution,
244
+ API↔route matching, DB edges, orphan + cycle detection), the scanner
245
+ (`.gitignore` handling, language detection), the local server (including a
246
+ regression test for the path-traversal fix), and an end-to-end analyze
247
+ pass — including a regression test that secrets in scanned `.env` files
248
+ never leak into an exported graph. CI (`.github/workflows/ci.yml`) runs it
249
+ on every push/PR across the minimum and latest supported Python versions,
250
+ and the PyPI release workflow won't publish if it fails.
251
+
247
252
  ## Roadmap
248
253
 
249
254
  Already done as of v1.0:
@@ -251,13 +256,13 @@ Already done as of v1.0:
251
256
  - [x] Orphaned-function and circular-dependency detection
252
257
  - [x] Diff mode between two scans
253
258
  - [x] Focus mode, orbit layout, right-click actions, minimap, breadcrumb
259
+ - [x] Automated test suite + CI
254
260
 
255
261
  Not yet, but planned:
256
262
 
257
263
  - [ ] "Explain this function" — a plain-language AI summary button
258
264
  - [ ] More precise multi-language parsing (currently structural regex for
259
265
  everything but Python)
260
- - [ ] An automated test suite
261
266
 
262
267
  Have an idea? Open an issue.
263
268
 
@@ -271,8 +276,12 @@ but contributions are genuinely welcome:
271
276
  or UI polish. Keep the zero-dependency philosophy: the core tool should
272
277
  keep running on nothing but the Python standard library, and the UI
273
278
  should keep running on vanilla JS with no build step.
274
- - No formal test suite yet, so please describe how you manually verified a
275
- change in your PR.
279
+ - Run `pytest -q` before opening a PR (see [Testing](#testing) above); add
280
+ a fixture + test case under `tests/` for new parser behavior where you
281
+ can.
282
+
283
+ Security issue instead? See [SECURITY.md](SECURITY.md) rather than a
284
+ public issue.
276
285
 
277
286
  ## License
278
287
 
@@ -0,0 +1,36 @@
1
+ """CodeBread — slice open a codebase and see its internal structure."""
2
+ from __future__ import annotations
3
+
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()
@@ -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()
@@ -1,3 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: codebread
3
+ Version: 1.0.4
4
+ Summary: Interactive codebase analyzer & visualizer — slice open a project and see how it's wired together.
5
+ Author: Danial Irsyad
6
+ License: MIT
7
+ Keywords: codebase,analyzer,visualizer,call-graph,architecture
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Provides-Extra: full
12
+ Requires-Dist: pathspec>=0.11; extra == "full"
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest>=7; extra == "test"
15
+ Dynamic: license-file
16
+
1
17
  <p align="center">
2
18
  <img src="https://cdn.jsdelivr.net/gh/honow48-tech/CodeBread@main/assets/logo-wordmark.png" alt="CodeBread" width="360">
3
19
  </p>
@@ -8,7 +24,8 @@
8
24
  </p>
9
25
 
10
26
  <p align="center">
11
- <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>
12
29
  <img alt="python" src="https://img.shields.io/badge/python-3.9%2B-0284c7">
13
30
  <img alt="deps" src="https://img.shields.io/badge/required%20dependencies-none-2dd4bf">
14
31
  <img alt="license" src="https://img.shields.io/badge/license-MIT-8b5cf6">
@@ -224,12 +241,30 @@ codebread/
224
241
  server.py ← stdlib local web server
225
242
  export.py ← JSON + single-file HTML export
226
243
  web/ ← the UI (vanilla JS + SVG, zero dependencies)
244
+ tests/ ← pytest suite + per-language fixture corpus
227
245
  assets/ ← logo (.svg source + .png for the README —
228
246
  GitHub blocks same-repo SVG <img> embeds)
229
247
  ```
230
248
 
231
249
  ---
232
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
+
233
268
  ## Roadmap
234
269
 
235
270
  Already done as of v1.0:
@@ -237,13 +272,13 @@ Already done as of v1.0:
237
272
  - [x] Orphaned-function and circular-dependency detection
238
273
  - [x] Diff mode between two scans
239
274
  - [x] Focus mode, orbit layout, right-click actions, minimap, breadcrumb
275
+ - [x] Automated test suite + CI
240
276
 
241
277
  Not yet, but planned:
242
278
 
243
279
  - [ ] "Explain this function" — a plain-language AI summary button
244
280
  - [ ] More precise multi-language parsing (currently structural regex for
245
281
  everything but Python)
246
- - [ ] An automated test suite
247
282
 
248
283
  Have an idea? Open an issue.
249
284
 
@@ -257,8 +292,12 @@ but contributions are genuinely welcome:
257
292
  or UI polish. Keep the zero-dependency philosophy: the core tool should
258
293
  keep running on nothing but the Python standard library, and the UI
259
294
  should keep running on vanilla JS with no build step.
260
- - No formal test suite yet, so please describe how you manually verified a
261
- 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.
262
301
 
263
302
  ## License
264
303
 
@@ -24,4 +24,13 @@ codebread/parsers/javascript_parser.py
24
24
  codebread/parsers/python_parser.py
25
25
  codebread/web/app.js
26
26
  codebread/web/index.html
27
- codebread/web/style.css
27
+ codebread/web/style.css
28
+ tests/test_analyzer.py
29
+ tests/test_config_parser.py
30
+ tests/test_connections.py
31
+ tests/test_generic_parser.py
32
+ tests/test_javascript_parser.py
33
+ tests/test_python_parser.py
34
+ tests/test_scanner.py
35
+ tests/test_server.py
36
+ tests/test_version.py
@@ -1,3 +1,6 @@
1
1
 
2
2
  [full]
3
3
  pathspec>=0.11
4
+
5
+ [test]
6
+ pytest>=7
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codebread"
7
- version = "1.0.3"
7
+ version = "1.0.4"
8
8
  description = "Interactive codebase analyzer & visualizer — slice open a project and see how it's wired together."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -15,6 +15,7 @@ dependencies = []
15
15
 
16
16
  [project.optional-dependencies]
17
17
  full = ["pathspec>=0.11"]
18
+ test = ["pytest>=7"]
18
19
 
19
20
  [project.scripts]
20
21
  codebread = "codebread.cli:main"
@@ -24,3 +25,6 @@ include = ["codebread*"]
24
25
 
25
26
  [tool.setuptools.package-data]
26
27
  codebread = ["web/*"]
28
+
29
+ [tool.pytest.ini_options]
30
+ testpaths = ["tests"]
@@ -0,0 +1,71 @@
1
+ import json
2
+ import os
3
+
4
+ from codebread.analyzer import analyze
5
+
6
+
7
+ def _write(root, rel_path, content):
8
+ full = os.path.join(root, rel_path)
9
+ os.makedirs(os.path.dirname(full), exist_ok=True)
10
+ with open(full, "w", encoding="utf-8") as f:
11
+ f.write(content)
12
+
13
+
14
+ def test_analyze_end_to_end_small_project(tmp_path):
15
+ root = str(tmp_path)
16
+ _write(root, "app.py", (
17
+ "from flask import Flask\n"
18
+ "app = Flask(__name__)\n\n"
19
+ "@app.route('/users')\n"
20
+ "def list_users():\n"
21
+ " return get_users()\n\n"
22
+ "def get_users():\n"
23
+ " return []\n"
24
+ ))
25
+ _write(root, "frontend/main.js", (
26
+ "async function loadUsers() {\n"
27
+ " const res = await fetch('/users');\n"
28
+ " return res.json();\n"
29
+ "}\n"
30
+ ))
31
+
32
+ graph = analyze(root)
33
+
34
+ assert graph["stats"]["files"] == 2
35
+ assert graph["stats"]["functions"] >= 3
36
+ assert graph["meta"]["name"] == os.path.basename(root)
37
+ # the graph must be JSON-serializable end to end (what --json/server ship)
38
+ json.dumps(graph)
39
+
40
+
41
+ def test_analyze_never_leaks_env_secrets_into_exported_graph(tmp_path):
42
+ root = str(tmp_path)
43
+ _write(root, ".env", (
44
+ "DATABASE_URL=postgres://admin:hunter2@db.example.com:5432/prod\n"
45
+ "API_KEY=sk-abcdef123456\n"
46
+ "DEBUG=true\n"
47
+ ))
48
+ _write(root, "app.py", "def hello():\n return 'hi'\n")
49
+
50
+ graph = analyze(root)
51
+ dumped = json.dumps(graph)
52
+
53
+ assert "hunter2" not in dumped
54
+ assert "sk-abcdef123456" not in dumped
55
+ # non-secret content should still be visible (regression against
56
+ # over-redaction hiding everything)
57
+ assert "DEBUG=true" in dumped
58
+
59
+
60
+ def test_analyze_handles_syntax_errors_without_crashing(tmp_path):
61
+ root = str(tmp_path)
62
+ _write(root, "broken.py", "def broken(:\n pass\n")
63
+
64
+ graph = analyze(root)
65
+ assert graph["stats"]["warnings"] >= 1
66
+
67
+
68
+ def test_analyze_empty_directory(tmp_path):
69
+ graph = analyze(str(tmp_path))
70
+ assert graph["stats"]["files"] == 0
71
+ assert graph["nodes"] == []
@@ -0,0 +1,36 @@
1
+ from codebread.models import FileInfo
2
+ from codebread.parsers.generic_parser import parse_config
3
+
4
+
5
+ def _parse(text):
6
+ info = FileInfo(path=".env", language="config")
7
+ parse_config(info, text)
8
+ return info
9
+
10
+
11
+ def test_secret_keys_are_masked_in_summary():
12
+ info = _parse("PASSWORD=hunter2\nAPI_KEY=sk-live-abc123\n")
13
+ joined = "\n".join(info.db_config)
14
+ assert "hunter2" not in joined
15
+ assert "sk-live-abc123" not in joined
16
+ assert "masked" in joined
17
+
18
+
19
+ def test_db_url_credentials_masked_but_host_kept():
20
+ info = _parse("DATABASE_URL=postgres://admin:hunter2@db.example.com:5432/prod\n")
21
+ joined = "\n".join(info.db_config)
22
+ assert "hunter2" not in joined
23
+ assert "db.example.com" in joined
24
+
25
+
26
+ def test_non_db_non_secret_keys_ignored():
27
+ info = _parse("DEBUG=true\nPORT=8080\n")
28
+ assert info.db_config == []
29
+
30
+
31
+ def test_comments_and_blank_lines_skipped():
32
+ info = _parse("# PASSWORD=shouldnotmatch\n\nPASSWORD=realsecret\n")
33
+ joined = "\n".join(info.db_config)
34
+ assert "shouldnotmatch" not in joined
35
+ assert "realsecret" not in joined # masked either way
36
+ assert joined.count("PASSWORD") == 1
@@ -0,0 +1,96 @@
1
+ from codebread.connections import build_graph
2
+ from codebread.models import ApiCall, DbRef, FileInfo, FunctionInfo, Route, TableInfo
3
+
4
+
5
+ def _fn(name, calls=None, routes=None, api_calls=None, db_refs=None, line=1):
6
+ return FunctionInfo(
7
+ name=name, line=line, end_line=line + 5,
8
+ calls=calls or [], routes=routes or [],
9
+ api_calls=api_calls or [], db_refs=db_refs or [],
10
+ )
11
+
12
+
13
+ def build(files):
14
+ return build_graph(files, tree={}, warnings=[])
15
+
16
+
17
+ def test_same_file_call_edge():
18
+ f = FileInfo(path="app.py", language="python", layer="backend",
19
+ functions=[_fn("main", calls=["helper"]), _fn("helper", line=10)])
20
+ g = build([f])
21
+ call_edges = [e for e in g["edges"] if e["kind"] == "call"]
22
+ assert any(e["source"].endswith("::main@1") and e["target"].endswith("::helper@10")
23
+ for e in call_edges)
24
+
25
+
26
+ def test_cross_file_call_resolves_unique_name():
27
+ a = FileInfo(path="app.py", language="python", layer="backend",
28
+ functions=[_fn("main", calls=["fetch_user"])])
29
+ b = FileInfo(path="utils.py", language="python", layer="backend",
30
+ functions=[_fn("fetch_user", line=1)])
31
+ g = build([a, b])
32
+ call_edges = [e for e in g["edges"] if e["kind"] == "call"]
33
+ assert any(e["target"] == "utils.py::fetch_user@1" for e in call_edges)
34
+
35
+
36
+ def test_api_call_matches_route_despite_param_syntax_difference():
37
+ frontend = FileInfo(path="frontend.js", language="javascript", layer="frontend",
38
+ api_calls=[ApiCall(method="GET", url="/api/users/:id")])
39
+ backend = FileInfo(path="backend.py", language="python", layer="backend",
40
+ functions=[_fn("get_user",
41
+ routes=[Route(method="GET", path="/api/users/<id>")])])
42
+ g = build([frontend, backend])
43
+ api_edges = [e for e in g["edges"] if e["kind"] == "api"]
44
+ assert len(api_edges) == 1
45
+ assert api_edges[0]["target"] == "backend.py::get_user@1"
46
+
47
+
48
+ def test_db_edge_links_function_to_table():
49
+ models = FileInfo(path="models.py", language="python", layer="database",
50
+ tables=[TableInfo(name="users", model="User")])
51
+ backend = FileInfo(path="backend.py", language="python", layer="backend",
52
+ functions=[_fn("get_user",
53
+ db_refs=[DbRef(table="User", op="query", via="orm")])])
54
+ g = build([models, backend])
55
+ db_edges = [e for e in g["edges"] if e["kind"] == "db"]
56
+ assert len(db_edges) == 1
57
+ assert db_edges[0]["target"] == "table::users"
58
+
59
+
60
+ def test_include_edge_resolves_python_import():
61
+ idx = FileInfo(path="index.py", language="python", layer="backend",
62
+ imports=["helperlib"])
63
+ lib = FileInfo(path="helperlib.py", language="python", layer="backend")
64
+ g = build([idx, lib])
65
+ include_edges = [e for e in g["edges"] if e["kind"] == "include"]
66
+ assert any(e["source"] == "index.py" and e["target"] == "helperlib.py"
67
+ for e in include_edges)
68
+
69
+
70
+ def test_orphan_detection_respects_entry_names():
71
+ f = FileInfo(path="app.py", language="python", layer="backend",
72
+ functions=[_fn("main"), _fn("never_called", line=10)])
73
+ g = build([f])
74
+ by_label_line = {(n["label"], n.get("line")): n for n in g["nodes"]
75
+ if n["kind"] == "function"}
76
+ assert not by_label_line[("main", 1)].get("orphan")
77
+ assert by_label_line[("never_called", 10)].get("orphan") is True
78
+
79
+
80
+ def test_cycle_detection_flags_both_functions_and_reports_cycle():
81
+ f = FileInfo(path="cycle.py", language="python", layer="backend",
82
+ functions=[_fn("a", calls=["b"]), _fn("b", calls=["a"], line=10)])
83
+ g = build([f])
84
+ fn_nodes = [n for n in g["nodes"] if n["kind"] == "function"]
85
+ assert all(n.get("cycle") for n in fn_nodes)
86
+ assert g["stats"]["cycles"] == 1
87
+ assert len(g["cycles"]) == 1
88
+
89
+
90
+ def test_stats_counts_are_consistent():
91
+ f = FileInfo(path="app.py", language="python", layer="backend",
92
+ functions=[_fn("main", calls=["helper"]), _fn("helper", line=10)])
93
+ g = build([f])
94
+ assert g["stats"]["files"] == 1
95
+ assert g["stats"]["functions"] == 2
96
+ assert g["stats"]["connections"] == len(g["edges"])
@@ -0,0 +1,104 @@
1
+ from conftest import read_fixture
2
+
3
+ from codebread.models import FileInfo
4
+ from codebread.parsers.generic_parser import (parse_generic, parse_sql,
5
+ redact_secrets)
6
+
7
+
8
+ def _parse(language, *fixture_parts):
9
+ info = FileInfo(path="/".join(fixture_parts), language=language)
10
+ parse_generic(info, read_fixture(*fixture_parts), language)
11
+ return info
12
+
13
+
14
+ def test_java_functions_class_and_route():
15
+ info = _parse("java", "java", "UserController.java")
16
+ names = {f.name for f in info.functions}
17
+ assert {"getUser", "createUser"} <= names
18
+ by_name = {f.name: f for f in info.functions}
19
+ assert by_name["getUser"].routes[0].method == "GET"
20
+ assert by_name["getUser"].routes[0].path == "/users/{id}"
21
+
22
+
23
+ def test_java_raw_sql_detected():
24
+ info = _parse("java", "java", "UserController.java")
25
+ by_name = {f.name: f for f in info.functions}
26
+ assert any(r.table == "users" and r.op == "insert"
27
+ for r in by_name["createUser"].db_refs)
28
+
29
+
30
+ def test_csharp_functions_and_route():
31
+ info = _parse("csharp", "csharp", "UsersController.cs")
32
+ names = {f.name for f in info.functions}
33
+ assert {"GetUser", "CreateUser"} <= names
34
+ by_name = {f.name: f for f in info.functions}
35
+ assert by_name["GetUser"].routes[0].method == "GET"
36
+
37
+
38
+ def test_go_functions_and_struct():
39
+ info = _parse("go", "go", "sample.go")
40
+ names = {f.name for f in info.functions}
41
+ assert {"getUser", "createUser", "main"} <= names
42
+ # structs are extracted as classes via a separate pass in analyzer/UI —
43
+ # parse_generic itself doesn't record "class" nodes for Go, only imports
44
+ # and functions, so we just check the struct's type line didn't get
45
+ # mistaken for a function.
46
+ assert "User" not in names
47
+
48
+
49
+ def test_go_raw_sql_detected():
50
+ info = _parse("go", "go", "sample.go")
51
+ by_name = {f.name: f for f in info.functions}
52
+ assert any(r.table == "users" and r.op == "insert"
53
+ for r in by_name["createUser"].db_refs)
54
+
55
+
56
+ def test_php_functions_route_and_page_link():
57
+ info = _parse("php", "php", "users.php")
58
+ names = {f.name for f in info.functions}
59
+ assert {"find", "render_user_page"} <= names
60
+ assert any(r.method == "GET" and r.path == "/users/{id}"
61
+ for r in info.routes)
62
+ assert "dashboard.php" in info.links
63
+
64
+
65
+ def test_php_import_extracted():
66
+ info = _parse("php", "php", "users.php")
67
+ assert "db.php" in info.imports
68
+
69
+
70
+ def test_ruby_functions_and_route():
71
+ info = _parse("ruby", "ruby", "users_controller.rb")
72
+ names = {f.name for f in info.functions}
73
+ assert {"show", "create", "helper_method"} <= names
74
+ assert any(r.method == "GET" and r.path == "/users/:id"
75
+ for r in info.routes)
76
+
77
+
78
+ def test_sql_create_table_schema():
79
+ info = FileInfo(path="schema.sql", language="sql")
80
+ parse_sql(info, read_fixture("sql", "schema.sql"))
81
+ by_name = {t.name: t for t in info.tables}
82
+ assert set(by_name) == {"users", "orders"}
83
+ assert set(by_name["users"].fields) >= {"id", "name", "email"}
84
+ # constraint lines must not be mistaken for columns
85
+ assert "FOREIGN" not in by_name["orders"].fields
86
+
87
+
88
+ def test_redact_secrets_masks_password_and_url_creds():
89
+ text = (
90
+ "DATABASE_URL=postgres://admin:hunter2@db.example.com:5432/prod\n"
91
+ "API_KEY=sk-abcdef123456\n"
92
+ "DEBUG=true\n"
93
+ )
94
+ out = redact_secrets(text)
95
+ assert "hunter2" not in out
96
+ assert "sk-abcdef123456" not in out
97
+ assert "DEBUG=true" in out
98
+ assert "db.example.com" in out # non-secret parts stay readable
99
+
100
+
101
+ def test_redact_secrets_preserves_line_count():
102
+ text = "A=1\nB=2\nPASSWORD=x\nC=3\n"
103
+ out = redact_secrets(text)
104
+ assert len(out.splitlines()) == len(text.splitlines())
@@ -0,0 +1,82 @@
1
+ from conftest import read_fixture
2
+
3
+ from codebread.models import FileInfo
4
+ from codebread.parsers.javascript_parser import parse_javascript
5
+
6
+
7
+ def _parse_express():
8
+ info = FileInfo(path="server.js", language="javascript")
9
+ parse_javascript(info, read_fixture("javascript", "sample_express.js"), "javascript")
10
+ return info
11
+
12
+
13
+ def _parse_ts():
14
+ info = FileInfo(path="app.ts", language="typescript")
15
+ parse_javascript(info, read_fixture("javascript", "sample_types.ts"), "typescript")
16
+ return info
17
+
18
+
19
+ def _parse_vue():
20
+ info = FileInfo(path="Counter.vue", language="vue")
21
+ parse_javascript(info, read_fixture("vue", "sample.vue"), "vue")
22
+ return info
23
+
24
+
25
+ def test_extracts_functions_and_class_method():
26
+ info = _parse_express()
27
+ names = {f.name for f in info.functions}
28
+ assert {"fetchExternalProfile", "getUser", "createUser", "getById"} <= names
29
+ by_name = {f.name: f for f in info.functions}
30
+ assert by_name["getById"].parent_class == "UserService"
31
+ assert by_name["getById"].kind == "method"
32
+
33
+
34
+ def test_extracts_mongoose_model():
35
+ info = _parse_express()
36
+ assert len(info.tables) == 1
37
+ table = info.tables[0]
38
+ assert table.model == "User"
39
+ assert set(table.fields) >= {"name", "email"}
40
+
41
+
42
+ def test_extracts_route_attached_to_named_handler():
43
+ info = _parse_express()
44
+ all_routes = list(info.routes)
45
+ for f in info.functions:
46
+ all_routes.extend(f.routes)
47
+ methods_paths = {(r.method, r.path) for r in all_routes}
48
+ assert ("GET", "/users/:id") in methods_paths
49
+ assert ("POST", "/users") in methods_paths
50
+
51
+
52
+ def test_extracts_fetch_api_call():
53
+ info = _parse_express()
54
+ by_name = {f.name: f for f in info.functions}
55
+ calls = by_name["fetchExternalProfile"].api_calls
56
+ assert len(calls) == 1
57
+ assert calls[0].method == "GET"
58
+ assert "api.example.com" in calls[0].url
59
+
60
+
61
+ def test_extracts_orm_usage_and_raw_sql():
62
+ info = _parse_express()
63
+ by_name = {f.name: f for f in info.functions}
64
+ assert any(r.table == "User" for r in by_name["getUser"].db_refs)
65
+ assert any(r.table == "users" and r.via == "sql" and r.op == "insert"
66
+ for r in by_name["createUser"].db_refs)
67
+
68
+
69
+ def test_typescript_type_annotations_dont_break_extraction():
70
+ info = _parse_ts()
71
+ names = {f.name for f in info.functions}
72
+ assert {"getUserName", "double", "greet"} <= names
73
+ by_name = {f.name: f for f in info.functions}
74
+ assert by_name["greet"].parent_class == "Greeter"
75
+
76
+
77
+ def test_vue_script_setup_functions_extracted_and_template_excluded():
78
+ info = _parse_vue()
79
+ names = {f.name for f in info.functions}
80
+ assert {"increment", "logChange"} <= names
81
+ # template-only content shouldn't leak in as a function name
82
+ assert "button" not in names
@@ -0,0 +1,59 @@
1
+ from conftest import read_fixture
2
+
3
+ from codebread.models import FileInfo
4
+ from codebread.parsers.python_parser import parse_python
5
+
6
+
7
+ def _parse():
8
+ info = FileInfo(path="app.py", language="python")
9
+ parse_python(info, read_fixture("python", "sample_flask.py"))
10
+ return info
11
+
12
+
13
+ def test_extracts_functions():
14
+ info = _parse()
15
+ names = {f.name for f in info.functions}
16
+ assert {"fetch_remote_profile", "get_user", "create_user"} <= names
17
+
18
+
19
+ def test_extracts_orm_model():
20
+ info = _parse()
21
+ assert len(info.tables) == 1
22
+ table = info.tables[0]
23
+ assert table.name == "users"
24
+ assert table.model == "User"
25
+ assert set(table.fields) >= {"id", "name"}
26
+
27
+
28
+ def test_extracts_routes():
29
+ info = _parse()
30
+ by_name = {f.name: f for f in info.functions}
31
+ assert by_name["get_user"].routes[0].method == "GET"
32
+ assert by_name["get_user"].routes[0].path == "/users/<int:user_id>"
33
+ assert by_name["create_user"].routes[0].method == "POST"
34
+
35
+
36
+ def test_extracts_outgoing_api_call():
37
+ info = _parse()
38
+ by_name = {f.name: f for f in info.functions}
39
+ calls = by_name["fetch_remote_profile"].api_calls
40
+ assert len(calls) == 1
41
+ assert calls[0].method == "GET"
42
+ assert "/profiles/" in calls[0].url
43
+
44
+
45
+ def test_extracts_orm_query_and_raw_sql():
46
+ info = _parse()
47
+ by_name = {f.name: f for f in info.functions}
48
+ get_refs = by_name["get_user"].db_refs
49
+ assert any(r.table == "User" and r.via == "orm" for r in get_refs)
50
+ create_refs = by_name["create_user"].db_refs
51
+ assert any(r.table == "users" and r.via == "sql" and r.op == "insert"
52
+ for r in create_refs)
53
+
54
+
55
+ def test_syntax_error_is_reported_not_raised():
56
+ info = FileInfo(path="broken.py", language="python")
57
+ parse_python(info, "def broken(:\n pass\n")
58
+ assert info.functions == []
59
+ assert any("syntax error" in w.lower() for w in info.warnings)
@@ -0,0 +1,84 @@
1
+ import os
2
+
3
+ from codebread.languages import detect_language, looks_binary
4
+ from codebread.scanner import scan
5
+
6
+
7
+ def _write(root, rel_path, content=""):
8
+ full = os.path.join(root, rel_path)
9
+ os.makedirs(os.path.dirname(full), exist_ok=True)
10
+ with open(full, "w", encoding="utf-8") as f:
11
+ f.write(content)
12
+ return full
13
+
14
+
15
+ def test_scan_respects_gitignore(tmp_path):
16
+ root = str(tmp_path)
17
+ _write(root, ".gitignore", "ignored.py\nbuild/\n")
18
+ _write(root, "keep.py", "def f(): pass\n")
19
+ _write(root, "ignored.py", "def g(): pass\n")
20
+ _write(root, "build/output.py", "def h(): pass\n")
21
+
22
+ result = scan(root)
23
+ paths = {f["path"] for f in result["files"]}
24
+ assert "keep.py" in paths
25
+ assert "ignored.py" not in paths
26
+ assert "build/output.py" not in paths
27
+
28
+
29
+ def test_scan_skips_known_noise_dirs(tmp_path):
30
+ root = str(tmp_path)
31
+ _write(root, "app.py", "def f(): pass\n")
32
+ _write(root, "node_modules/pkg/index.js", "module.exports = {};\n")
33
+ _write(root, ".git/HEAD", "ref: refs/heads/main\n")
34
+
35
+ result = scan(root)
36
+ paths = {f["path"] for f in result["files"]}
37
+ assert "app.py" in paths
38
+ assert not any(p.startswith("node_modules/") for p in paths)
39
+ assert not any(p.startswith(".git/") for p in paths)
40
+
41
+
42
+ def test_scan_flags_oversized_files_as_warning(tmp_path):
43
+ root = str(tmp_path)
44
+ big = "x = 1\n" * 200_000 # comfortably over the 1MB default cap
45
+ _write(root, "huge.py", big)
46
+
47
+ result = scan(root, max_file_kb=1)
48
+ assert any(w["path"] == "huge.py" for w in result["warnings"])
49
+ assert not any(f["path"] == "huge.py" for f in result["files"])
50
+
51
+
52
+ def test_detect_language_env_files_are_config():
53
+ assert detect_language(".env") == "config"
54
+ assert detect_language(".env.production") == "config"
55
+
56
+
57
+ def test_detect_language_settings_py_stays_python():
58
+ # CONFIG_BASENAMES includes settings.py, but it should still be parsed
59
+ # as real Python (full extraction), not routed into the generic
60
+ # config-key-scan bucket.
61
+ assert detect_language("settings.py") == "python"
62
+
63
+
64
+ def test_detect_language_common_extensions():
65
+ assert detect_language("app.py") == "python"
66
+ assert detect_language("component.tsx") == "typescript"
67
+ assert detect_language("main.go") == "go"
68
+ assert detect_language("style.css") == "css"
69
+
70
+
71
+ def test_looks_binary_by_extension():
72
+ assert looks_binary("photo.png") is True
73
+
74
+
75
+ def test_looks_binary_by_null_byte(tmp_path):
76
+ p = tmp_path / "weird.dat"
77
+ p.write_bytes(b"\x00\x01\x02binary-ish")
78
+ assert looks_binary(str(p)) is True
79
+
80
+
81
+ def test_looks_binary_false_for_text(tmp_path):
82
+ p = tmp_path / "note.unknownext"
83
+ p.write_text("just plain text", encoding="utf-8")
84
+ assert looks_binary(str(p)) is False
@@ -0,0 +1,83 @@
1
+ import os
2
+ import threading
3
+ import urllib.error
4
+ import urllib.request
5
+
6
+ import pytest
7
+
8
+ from codebread.server import WEB_DIR, build_server, safe_web_path
9
+
10
+
11
+ def test_safe_web_path_allows_files_inside_web_dir():
12
+ resolved = safe_web_path("app.js")
13
+ assert resolved is not None
14
+ assert os.path.isfile(resolved)
15
+
16
+
17
+ def test_safe_web_path_blocks_relative_traversal():
18
+ assert safe_web_path("../../../../Windows/win.ini") is None
19
+
20
+
21
+ @pytest.mark.skipif(os.name != "nt",
22
+ reason="drive-absolute os.path.join discard is an "
23
+ "ntpath-only quirk; on POSIX there's no drive "
24
+ "letter so the path safely stays under WEB_DIR")
25
+ def test_safe_web_path_blocks_windows_drive_absolute_traversal():
26
+ # The bug this locks in: os.path.join(WEB_DIR, "C:/x") on Windows
27
+ # discards WEB_DIR entirely because the second arg is drive-absolute.
28
+ drive = os.path.splitdrive(WEB_DIR)[0] or "C:"
29
+ assert safe_web_path(f"{drive}/Windows/win.ini") is None
30
+
31
+
32
+ def test_safe_web_path_blocks_unc_style_path():
33
+ assert safe_web_path("//server/share/file.txt") is None
34
+
35
+
36
+ def test_build_server_with_port_zero_lets_os_assign_a_port():
37
+ # Regression: port 0 ("let the OS pick") is a falsy int, and an earlier
38
+ # version of _free_port() returned 0 for both "OS-assigned" and "no
39
+ # free port found", so `if not port:` treated a successful bind as
40
+ # failure.
41
+ server = build_server({"nodes": [], "edges": [], "tree": {}, "stats": {}}, port=0)
42
+ try:
43
+ assert server is not None
44
+ assert server.server_port > 0
45
+ finally:
46
+ if server is not None:
47
+ server.server_close()
48
+
49
+
50
+ @pytest.fixture
51
+ def live_server():
52
+ server = build_server({"nodes": [], "edges": [], "tree": {}, "stats": {}}, port=0)
53
+ assert server is not None
54
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
55
+ thread.start()
56
+ try:
57
+ yield f"http://127.0.0.1:{server.server_port}"
58
+ finally:
59
+ server.shutdown()
60
+ server.server_close()
61
+ thread.join(timeout=5)
62
+
63
+
64
+ def test_live_server_serves_legit_asset(live_server):
65
+ with urllib.request.urlopen(f"{live_server}/app.js") as resp:
66
+ assert resp.status == 200
67
+ assert len(resp.read()) > 0
68
+
69
+
70
+ def test_live_server_serves_data_json(live_server):
71
+ with urllib.request.urlopen(f"{live_server}/data.json") as resp:
72
+ assert resp.status == 200
73
+
74
+
75
+ @pytest.mark.skipif(os.name != "nt",
76
+ reason="drive-absolute os.path.join discard is an "
77
+ "ntpath-only quirk; on POSIX the request just "
78
+ "404s as a normal missing file under WEB_DIR")
79
+ def test_live_server_rejects_drive_absolute_traversal(live_server):
80
+ drive = os.path.splitdrive(WEB_DIR)[0] or "C:"
81
+ with pytest.raises(urllib.error.HTTPError) as exc:
82
+ urllib.request.urlopen(f"{live_server}/{drive}/Windows/win.ini")
83
+ assert exc.value.code == 403
@@ -0,0 +1,14 @@
1
+ import re
2
+
3
+ import codebread
4
+
5
+
6
+ def test_version_is_a_plain_semver_like_string():
7
+ assert re.match(r"^\d+\.\d+\.\d+", codebread.__version__)
8
+
9
+
10
+ def test_version_is_not_the_unresolved_fallback():
11
+ # "0.0.0+unknown" only happens if codebread is neither installed nor
12
+ # runnable from a checkout with a readable pyproject.toml — shouldn't
13
+ # happen in this repo's own test environment.
14
+ assert codebread.__version__ != "0.0.0+unknown"
@@ -1,3 +0,0 @@
1
- """CodeBread — slice open a codebase and see its internal structure."""
2
-
3
- __version__ = "1.0.2"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes