codebread 1.0.6__py3-none-any.whl → 1.1.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 CHANGED
@@ -2,17 +2,30 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import os
5
+ import re
5
6
 
6
7
 
7
8
  def _read_version() -> str:
8
9
  """Single source of truth: pyproject.toml's [project] version.
9
10
 
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.
11
+ A source checkout (pyproject.toml sitting next to the package) wins:
12
+ an editable `pip install -e .` freezes its metadata at install time,
13
+ so after a version bump importlib.metadata keeps reporting the old
14
+ number until you reinstall. Wheel installs have no pyproject.toml
15
+ beside them and fall through to their own (always correct) metadata.
16
+ No toml parser needed for one `version = "..."` line — keeps the
17
+ zero-dependency promise.
15
18
  """
19
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
20
+ try:
21
+ with open(os.path.join(root, "pyproject.toml"), encoding="utf-8") as f:
22
+ text = f.read()
23
+ if re.search(r"""^name\s*=\s*["']codebread["']""", text, re.M):
24
+ m = re.search(r"""^version\s*=\s*["']([^"']+)["']""", text, re.M)
25
+ if m:
26
+ return m.group(1)
27
+ except OSError:
28
+ pass
16
29
  try:
17
30
  from importlib.metadata import PackageNotFoundError, version
18
31
  try:
@@ -21,15 +34,6 @@ def _read_version() -> str:
21
34
  pass
22
35
  except ImportError:
23
36
  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
37
  return "0.0.0+unknown"
34
38
 
35
39
 
codebread/cli.py CHANGED
@@ -37,6 +37,11 @@ def main(argv=None) -> int:
37
37
  help="also export the full graph as JSON")
38
38
  parser.add_argument("--html", metavar="OUT.html",
39
39
  help="also export a self-contained static HTML file")
40
+ parser.add_argument("--md", nargs="?", const="", metavar="OUT.md",
41
+ help="also write an AI context brief (Markdown) - a "
42
+ "compact map of the project an assistant can read "
43
+ "instead of every file. Default: CODEBREAD.md in "
44
+ "the scanned folder")
40
45
  parser.add_argument("--no-open", action="store_true",
41
46
  help="don't auto-open the browser")
42
47
  parser.add_argument("--no-serve", action="store_true",
@@ -89,6 +94,14 @@ def main(argv=None) -> int:
89
94
  print(f"[codebread] {graph['stats']['warnings']} warning(s) - "
90
95
  f"they are shown as badges in the UI, nothing was hidden.")
91
96
 
97
+ from .context import export_markdown, to_markdown
98
+ graph["contextMd"] = to_markdown(graph) # powers the report's "Copy for AI" / download
99
+ if args.md is not None:
100
+ default_dir = graph.get("meta", {}).get("root") or os.getcwd()
101
+ if not os.path.isdir(default_dir): # --load of a graph scanned elsewhere
102
+ default_dir = os.getcwd()
103
+ p = export_markdown(graph, args.md or os.path.join(default_dir, "CODEBREAD.md"))
104
+ print(f"[codebread] AI context brief written to {p}")
92
105
  if args.json:
93
106
  p = export_json(graph, args.json)
94
107
  print(f"[codebread] Graph JSON written to {p}")
codebread/context.py ADDED
@@ -0,0 +1,170 @@
1
+ """AI context brief: the whole graph as compact Markdown.
2
+
3
+ An assistant that reads every source file to learn a project burns tokens
4
+ on code it doesn't need. This brief gives it the map instead: what each
5
+ file does, its functions, routes, tables, and who calls whom. It reads
6
+ it once, then opens only the files that matter for the task.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from collections import Counter, defaultdict
12
+ from typing import Dict, Iterable
13
+
14
+ LAYER_ORDER = ["frontend", "backend", "database", "config", "unknown"]
15
+ LAYER_NAME = {"frontend": "Frontend", "backend": "Backend", "database": "Database",
16
+ "config": "Config", "unknown": "Unclassified"}
17
+ FNS_PER_FILE = 40 # ponytail: hard cap keeps huge files from flooding the brief
18
+ LIST_CAP = 8
19
+
20
+
21
+ def _short(items: Iterable[str], cap: int = LIST_CAP) -> str:
22
+ items = list(dict.fromkeys(items)) # dedupe, keep order
23
+ more = f" (+{len(items) - cap} more)" if len(items) > cap else ""
24
+ return ", ".join(items[:cap]) + more
25
+
26
+
27
+ def to_markdown(graph: Dict) -> str:
28
+ nodes = {n["id"]: n for n in graph.get("nodes", [])}
29
+ edges = [e for e in graph.get("edges", [])
30
+ if e["kind"] != "contains" and e["source"] in nodes and e["target"] in nodes]
31
+ meta, stats = graph.get("meta", {}), graph.get("stats", {})
32
+ files = sorted((n for n in nodes.values() if n["kind"] == "file"), key=lambda n: n["file"])
33
+ tables = sorted((n for n in nodes.values() if n["kind"] == "table"), key=lambda n: n["label"])
34
+ fns = defaultdict(list)
35
+ for n in nodes.values():
36
+ if n["kind"] in ("function", "method") and n.get("file"):
37
+ fns[n["file"]].append(n)
38
+ for fl in fns.values():
39
+ fl.sort(key=lambda n: n.get("line") or 0)
40
+ out_e, in_e = defaultdict(list), defaultdict(list)
41
+ for e in edges:
42
+ out_e[e["source"]].append(e)
43
+ in_e[e["target"]].append(e)
44
+
45
+ def ref(nid: str, here: str = "") -> str:
46
+ n = nodes[nid]
47
+ if n["kind"] in ("function", "method"):
48
+ return f"`{n['label']}()`" if n.get("file") == here else f"`{n.get('file')}:{n['label']}()`"
49
+ if n["kind"] == "table":
50
+ return f"`{n['label']}` (table)"
51
+ return f"`{n.get('file') or n['label']}`"
52
+
53
+ def owner(nid: str) -> str:
54
+ n = nodes[nid]
55
+ return n["id"] if n["kind"] in ("file", "table") else (n.get("file") or n["id"])
56
+
57
+ name = meta.get("name") or "project"
58
+ L = [f"# {name}: codebase context", "",
59
+ f"> Generated by CodeBread {meta.get('version', '')} on {str(meta.get('scannedAt', ''))[:10]}. "
60
+ "This is a map of the whole project: what each file does, its functions, and how "
61
+ "they connect. Read it first and open source files only when you need exact code.", ""]
62
+
63
+ # ---- at a glance
64
+ langs = Counter(f.get("language") or "?" for f in files)
65
+ layers = Counter(f.get("layer") or "unknown" for f in files)
66
+
67
+ def layer_rank(k: str) -> int:
68
+ return LAYER_ORDER.index(k) if k in LAYER_ORDER else len(LAYER_ORDER)
69
+
70
+ L += ["## At a glance", "",
71
+ f"- {stats.get('files', len(files))} files, {stats.get('functions', 0)} functions, "
72
+ f"{stats.get('tables', len(tables))} tables, {stats.get('connections', len(edges))} connections",
73
+ "- Languages: " + ", ".join(f"{k} ({v})" for k, v in langs.most_common()),
74
+ "- Layers: " + ", ".join(f"{LAYER_NAME.get(k, k)} ({v})"
75
+ for k, v in sorted(layers.items(), key=lambda kv: layer_rank(kv[0]))),
76
+ ""]
77
+
78
+ # ---- entry points
79
+ routes = sorted(((r["method"], r["path"], fn) for fl in fns.values() for fn in fl
80
+ for r in fn.get("routes") or []), key=lambda x: (x[1], x[0]))
81
+ if routes:
82
+ L += ["## HTTP routes", "", "| Method | Path | Handler |", "|---|---|---|"]
83
+ L += [f"| {m} | `{p}` | `{fn['file']}:{fn['label']}()` |" for m, p, fn in routes]
84
+ L.append("")
85
+
86
+ # ---- data model
87
+ if tables:
88
+ L += ["## Database tables", ""]
89
+ for t in tables:
90
+ bits = []
91
+ if t.get("fields"):
92
+ bits.append("fields: " + _short(t["fields"], 15))
93
+ if t.get("model"):
94
+ bits.append(f"model `{t['model']}`")
95
+ users = [ref(e["source"]) for e in in_e[t["id"]]]
96
+ if users:
97
+ bits.append("used by " + _short(users))
98
+ where = f" in `{t['file']}`" if t.get("file") else ""
99
+ L.append(f"- **{t['label']}**{where}" + (": " + "; ".join(bits) if bits else ""))
100
+ L.append("")
101
+
102
+ # ---- files, grouped by layer
103
+ L += ["## Files", ""]
104
+ by_layer = defaultdict(list)
105
+ for f in files:
106
+ by_layer[f.get("layer") or "unknown"].append(f)
107
+ for layer in sorted(by_layer, key=layer_rank):
108
+ L += [f"### {LAYER_NAME.get(layer, layer)}", ""]
109
+ for f in by_layer[layer]:
110
+ path = f["file"]
111
+ members = [f["id"]] + [fn["id"] for fn in fns.get(path, [])]
112
+ talks = [ref(owner(e["target"])) for m in members for e in out_e[m]
113
+ if owner(e["target"]) != f["id"]]
114
+ heard = [ref(owner(e["source"])) for m in members for e in in_e[m]
115
+ if owner(e["source"]) != f["id"]]
116
+ L.append(f"#### `{path}` ({f.get('language', '?')}, {f.get('loc', 0)} lines)")
117
+ if talks:
118
+ L.append("Uses: " + _short(talks))
119
+ if heard:
120
+ L.append("Used by: " + _short(heard))
121
+ if f.get("warnings"):
122
+ L.append("Warnings: " + "; ".join(f["warnings"][:3]))
123
+ fl = fns.get(path, [])
124
+ for fn in fl[:FNS_PER_FILE]:
125
+ params = ", ".join(fn.get("params") or [])
126
+ cls = f"{fn['parentClass']}." if fn.get("parentClass") else ""
127
+ line = f"- `{cls}{fn['label']}({params})` L{fn.get('line', '?')}"
128
+ if fn.get("description"):
129
+ line += f": {fn['description']}"
130
+ calls = [ref(e["target"], path) for e in out_e[fn["id"]] if e["kind"] != "db"]
131
+ tbls = [nodes[e["target"]]["label"] for e in out_e[fn["id"]] if e["kind"] == "db"]
132
+ callers = [ref(e["source"], path) for e in in_e[fn["id"]]]
133
+ extra = []
134
+ if calls:
135
+ extra.append("calls " + _short(calls))
136
+ if tbls and not fn.get("description"): # descriptions already name their tables
137
+ extra.append("tables " + _short(tbls))
138
+ if callers:
139
+ extra.append("called by " + _short(callers))
140
+ if fn.get("cycle"):
141
+ extra.append("in a call cycle")
142
+ elif fn.get("orphan"):
143
+ extra.append("no callers found")
144
+ if extra:
145
+ line += " | " + "; ".join(extra)
146
+ L.append(line)
147
+ if len(fl) > FNS_PER_FILE:
148
+ L.append(f"- … {len(fl) - FNS_PER_FILE} more functions")
149
+ L.append("")
150
+
151
+ # ---- things to be careful about
152
+ notes = []
153
+ for c in graph.get("cycles", [])[:20]:
154
+ notes.append("Call cycle: " + " → ".join(f"`{x}()`" for x in c["labels"] + c["labels"][:1]))
155
+ for w in graph.get("warnings", [])[:20]:
156
+ notes.append(f"Warning in `{w.get('path', '')}`: {w.get('message', '')}")
157
+ obf = [f["file"] for f in files if f.get("obfuscation")]
158
+ if obf:
159
+ notes.append("Obfuscated code in: " + _short(f"`{p}`" for p in obf))
160
+ if notes:
161
+ L += ["## Watch out", ""] + [f"- {n}" for n in notes] + [""]
162
+
163
+ return "\n".join(L).rstrip() + "\n"
164
+
165
+
166
+ def export_markdown(graph: Dict, out_path: str) -> str:
167
+ out_path = os.path.abspath(out_path)
168
+ with open(out_path, "w", encoding="utf-8") as f:
169
+ f.write(to_markdown(graph))
170
+ return out_path