django-orm-lens 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.
- django_orm_lens/__init__.py +20 -0
- django_orm_lens/cli.py +238 -0
- django_orm_lens/formatters.py +158 -0
- django_orm_lens/mcp_server.py +188 -0
- django_orm_lens/models.py +88 -0
- django_orm_lens/parser.py +314 -0
- django_orm_lens/py.typed +0 -0
- django_orm_lens-1.0.0.dist-info/METADATA +189 -0
- django_orm_lens-1.0.0.dist-info/RECORD +12 -0
- django_orm_lens-1.0.0.dist-info/WHEEL +5 -0
- django_orm_lens-1.0.0.dist-info/entry_points.txt +3 -0
- django_orm_lens-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Django ORM Lens — sidebar tree, ER diagrams, and JSON for Django models.
|
|
2
|
+
|
|
3
|
+
Terminal / editor-agnostic parser for Django models.py files.
|
|
4
|
+
Ships a CLI and an optional MCP server for AI coding agents.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "1.0.0"
|
|
8
|
+
|
|
9
|
+
from .models import ParsedApp, ParsedField, ParsedModel, WorkspaceIndex
|
|
10
|
+
from .parser import parse_models_file, scan_workspace
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ParsedApp",
|
|
14
|
+
"ParsedField",
|
|
15
|
+
"ParsedModel",
|
|
16
|
+
"WorkspaceIndex",
|
|
17
|
+
"parse_models_file",
|
|
18
|
+
"scan_workspace",
|
|
19
|
+
"__version__",
|
|
20
|
+
]
|
django_orm_lens/cli.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Command-line entry point for ``django-orm-lens``.
|
|
2
|
+
|
|
3
|
+
Zero-dep argparse CLI. Commands mirror what the VS Code extension does:
|
|
4
|
+
|
|
5
|
+
django-orm-lens scan — walk workspace, list every model
|
|
6
|
+
django-orm-lens describe — one model in detail
|
|
7
|
+
django-orm-lens er — Mermaid ER diagram (stdout or file)
|
|
8
|
+
django-orm-lens hover — compact hover card for one model
|
|
9
|
+
django-orm-lens list — flat list ``app.Model`` for shell piping
|
|
10
|
+
django-orm-lens mcp — start the MCP stdio server (extras required)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import sys
|
|
17
|
+
from typing import List, Optional, Sequence
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .formatters import format_hover, format_index, format_model
|
|
21
|
+
from .models import ParsedModel, WorkspaceIndex
|
|
22
|
+
from .parser import DEFAULT_EXCLUDES, scan_workspace
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _add_scan_flags(p: argparse.ArgumentParser) -> None:
|
|
26
|
+
p.add_argument(
|
|
27
|
+
"--path",
|
|
28
|
+
"-p",
|
|
29
|
+
default=".",
|
|
30
|
+
help="Workspace root to scan (default: current directory)",
|
|
31
|
+
)
|
|
32
|
+
p.add_argument(
|
|
33
|
+
"--exclude",
|
|
34
|
+
"-x",
|
|
35
|
+
action="append",
|
|
36
|
+
default=None,
|
|
37
|
+
help="Glob to exclude, repeatable (default: migrations/, venv/, node_modules/)",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve_excludes(cli_excludes: Optional[Sequence[str]]) -> Sequence[str]:
|
|
42
|
+
return tuple(cli_excludes) if cli_excludes else DEFAULT_EXCLUDES
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _find_model(index: WorkspaceIndex, ref: str) -> Optional[ParsedModel]:
|
|
46
|
+
if "." in ref:
|
|
47
|
+
app_name, model_name = ref.split(".", 1)
|
|
48
|
+
for app in index.apps:
|
|
49
|
+
if app.name == app_name:
|
|
50
|
+
for m in app.models:
|
|
51
|
+
if m.name == model_name:
|
|
52
|
+
return m
|
|
53
|
+
for app in index.apps:
|
|
54
|
+
for m in app.models:
|
|
55
|
+
if m.name == ref:
|
|
56
|
+
return m
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _cmd_scan(args: argparse.Namespace) -> int:
|
|
61
|
+
idx = scan_workspace(args.path, _resolve_excludes(args.exclude))
|
|
62
|
+
print(format_index(idx, args.format))
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _cmd_describe(args: argparse.Namespace) -> int:
|
|
67
|
+
idx = scan_workspace(args.path, _resolve_excludes(args.exclude))
|
|
68
|
+
model = _find_model(idx, args.model)
|
|
69
|
+
if model is None:
|
|
70
|
+
print(f"error: model {args.model!r} not found", file=sys.stderr)
|
|
71
|
+
return 2
|
|
72
|
+
print(format_model(model, args.format))
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _cmd_hover(args: argparse.Namespace) -> int:
|
|
77
|
+
idx = scan_workspace(args.path, _resolve_excludes(args.exclude))
|
|
78
|
+
model = _find_model(idx, args.model)
|
|
79
|
+
if model is None:
|
|
80
|
+
print(f"error: model {args.model!r} not found", file=sys.stderr)
|
|
81
|
+
return 2
|
|
82
|
+
print(format_hover(model))
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _cmd_list(args: argparse.Namespace) -> int:
|
|
87
|
+
idx = scan_workspace(args.path, _resolve_excludes(args.exclude))
|
|
88
|
+
for app in idx.apps:
|
|
89
|
+
for m in app.models:
|
|
90
|
+
print(f"{app.name}.{m.name}")
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _cmd_er(args: argparse.Namespace) -> int:
|
|
95
|
+
idx = scan_workspace(args.path, _resolve_excludes(args.exclude))
|
|
96
|
+
mermaid = _build_mermaid(idx)
|
|
97
|
+
if args.output:
|
|
98
|
+
with open(args.output, "w", encoding="utf-8") as fh:
|
|
99
|
+
fh.write(mermaid)
|
|
100
|
+
print(f"Wrote {args.output}", file=sys.stderr)
|
|
101
|
+
else:
|
|
102
|
+
print(mermaid)
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _build_mermaid(index: WorkspaceIndex) -> str:
|
|
107
|
+
lines: List[str] = ["erDiagram"]
|
|
108
|
+
model_names = {m.name for app in index.apps for m in app.models}
|
|
109
|
+
|
|
110
|
+
def safe(s: str) -> str:
|
|
111
|
+
return "".join(ch if (ch.isalnum() or ch == "_") else "_" for ch in s)
|
|
112
|
+
|
|
113
|
+
for app in index.apps:
|
|
114
|
+
for model in app.models:
|
|
115
|
+
lines.append(f" {safe(model.name)} {{")
|
|
116
|
+
for f in model.fields:
|
|
117
|
+
if f.is_relation:
|
|
118
|
+
continue
|
|
119
|
+
type_safe = "".join(ch for ch in f.type if ch.isalnum())
|
|
120
|
+
name_safe = safe(f.name)
|
|
121
|
+
lines.append(f" {type_safe} {name_safe}")
|
|
122
|
+
lines.append(" }")
|
|
123
|
+
|
|
124
|
+
for app in index.apps:
|
|
125
|
+
for model in app.models:
|
|
126
|
+
for f in model.fields:
|
|
127
|
+
if not f.is_relation or not f.related_model:
|
|
128
|
+
continue
|
|
129
|
+
target = (
|
|
130
|
+
model.name
|
|
131
|
+
if f.related_model == "self"
|
|
132
|
+
else f.related_model.split(".")[-1]
|
|
133
|
+
)
|
|
134
|
+
if target not in model_names:
|
|
135
|
+
continue
|
|
136
|
+
if f.relation_kind == "ManyToManyField":
|
|
137
|
+
arrow = "}o--o{"
|
|
138
|
+
elif f.relation_kind == "OneToOneField":
|
|
139
|
+
arrow = "||--||"
|
|
140
|
+
else:
|
|
141
|
+
arrow = "}o--||"
|
|
142
|
+
lines.append(
|
|
143
|
+
f' {safe(model.name)} {arrow} {safe(target)} : "{f.name}"'
|
|
144
|
+
)
|
|
145
|
+
return "\n".join(lines)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
149
|
+
p = argparse.ArgumentParser(
|
|
150
|
+
prog="django-orm-lens",
|
|
151
|
+
description="Static analysis for Django models. Terminal + AI agent friendly.",
|
|
152
|
+
)
|
|
153
|
+
p.add_argument(
|
|
154
|
+
"--version", action="version", version=f"django-orm-lens {__version__}"
|
|
155
|
+
)
|
|
156
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
157
|
+
|
|
158
|
+
scan = sub.add_parser("scan", help="Scan a workspace for Django models")
|
|
159
|
+
_add_scan_flags(scan)
|
|
160
|
+
scan.add_argument(
|
|
161
|
+
"--format", "-f", choices=("json", "markdown", "table"), default="json"
|
|
162
|
+
)
|
|
163
|
+
scan.set_defaults(func=_cmd_scan)
|
|
164
|
+
|
|
165
|
+
describe = sub.add_parser(
|
|
166
|
+
"describe", help="Describe a single model (app.Model or Model)"
|
|
167
|
+
)
|
|
168
|
+
_add_scan_flags(describe)
|
|
169
|
+
describe.add_argument("model", help="Model reference, e.g. blog.Post or Post")
|
|
170
|
+
describe.add_argument(
|
|
171
|
+
"--format", "-f", choices=("json", "markdown", "table"), default="markdown"
|
|
172
|
+
)
|
|
173
|
+
describe.set_defaults(func=_cmd_describe)
|
|
174
|
+
|
|
175
|
+
hover = sub.add_parser("hover", help="Compact hover-card markdown for a model")
|
|
176
|
+
_add_scan_flags(hover)
|
|
177
|
+
hover.add_argument("model", help="Model reference, e.g. blog.Post or Post")
|
|
178
|
+
hover.set_defaults(func=_cmd_hover)
|
|
179
|
+
|
|
180
|
+
lst = sub.add_parser("list", help="Flat list of app.Model — pipes well into shell")
|
|
181
|
+
_add_scan_flags(lst)
|
|
182
|
+
lst.set_defaults(func=_cmd_list)
|
|
183
|
+
|
|
184
|
+
er = sub.add_parser("er", help="Emit a Mermaid ER diagram (stdout or file)")
|
|
185
|
+
_add_scan_flags(er)
|
|
186
|
+
er.add_argument(
|
|
187
|
+
"--output", "-o", default=None, help="Write diagram to file instead of stdout"
|
|
188
|
+
)
|
|
189
|
+
er.set_defaults(func=_cmd_er)
|
|
190
|
+
|
|
191
|
+
mcp = sub.add_parser(
|
|
192
|
+
"mcp",
|
|
193
|
+
help="Run the MCP stdio server (requires 'pip install django-orm-lens[mcp]')",
|
|
194
|
+
)
|
|
195
|
+
mcp.set_defaults(func=_cmd_mcp)
|
|
196
|
+
|
|
197
|
+
return p
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _cmd_mcp(_args: argparse.Namespace) -> int:
|
|
201
|
+
try:
|
|
202
|
+
from . import mcp_server
|
|
203
|
+
except ImportError as e:
|
|
204
|
+
print(
|
|
205
|
+
"error: MCP server dependencies not installed.\n"
|
|
206
|
+
"install with: pip install 'django-orm-lens[mcp]'\n"
|
|
207
|
+
f"({e})",
|
|
208
|
+
file=sys.stderr,
|
|
209
|
+
)
|
|
210
|
+
return 3
|
|
211
|
+
return mcp_server.main()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _force_utf8_stdio() -> None:
|
|
215
|
+
for stream in (sys.stdout, sys.stderr):
|
|
216
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
217
|
+
if reconfigure is not None:
|
|
218
|
+
try:
|
|
219
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
220
|
+
except (OSError, ValueError):
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
225
|
+
_force_utf8_stdio()
|
|
226
|
+
parser = build_parser()
|
|
227
|
+
args = parser.parse_args(argv)
|
|
228
|
+
try:
|
|
229
|
+
return int(args.func(args))
|
|
230
|
+
except KeyboardInterrupt:
|
|
231
|
+
return 130
|
|
232
|
+
except Exception as e:
|
|
233
|
+
print(f"error: {e}", file=sys.stderr)
|
|
234
|
+
return 1
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
if __name__ == "__main__":
|
|
238
|
+
sys.exit(main())
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Output formatters for the CLI and MCP server.
|
|
2
|
+
|
|
3
|
+
Renders a :class:`WorkspaceIndex` (or a single :class:`ParsedModel`) as JSON,
|
|
4
|
+
GitHub-flavoured Markdown, or a plain ASCII table. Zero third-party deps.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import List
|
|
11
|
+
|
|
12
|
+
from .models import ParsedModel, WorkspaceIndex
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def format_index(index: WorkspaceIndex, fmt: str = "json") -> str:
|
|
16
|
+
fmt = fmt.lower()
|
|
17
|
+
if fmt == "json":
|
|
18
|
+
return json.dumps(index.to_dict(), indent=2, ensure_ascii=False)
|
|
19
|
+
if fmt == "markdown":
|
|
20
|
+
return _index_markdown(index)
|
|
21
|
+
if fmt == "table":
|
|
22
|
+
return _index_table(index)
|
|
23
|
+
raise ValueError(f"Unknown format: {fmt!r}. Use json | markdown | table.")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def format_model(model: ParsedModel, fmt: str = "markdown") -> str:
|
|
27
|
+
fmt = fmt.lower()
|
|
28
|
+
if fmt == "json":
|
|
29
|
+
return json.dumps(model.to_dict(), indent=2, ensure_ascii=False)
|
|
30
|
+
if fmt == "markdown":
|
|
31
|
+
return _model_markdown(model)
|
|
32
|
+
if fmt == "table":
|
|
33
|
+
return _model_table(model)
|
|
34
|
+
raise ValueError(f"Unknown format: {fmt!r}. Use json | markdown | table.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_hover(model: ParsedModel) -> str:
|
|
38
|
+
"""Compact hover-card markdown — identical spirit to the VS Code hover."""
|
|
39
|
+
lines: List[str] = []
|
|
40
|
+
bases = ", ".join(model.base_classes) or "models.Model"
|
|
41
|
+
lines.append(f"**{model.app_name}.{model.name}**")
|
|
42
|
+
lines.append(f"_{bases}_")
|
|
43
|
+
scalars = [f for f in model.fields if not f.is_relation]
|
|
44
|
+
relations = [f for f in model.fields if f.is_relation]
|
|
45
|
+
if scalars:
|
|
46
|
+
lines.append("")
|
|
47
|
+
lines.append("**Fields**")
|
|
48
|
+
for f in scalars[:12]:
|
|
49
|
+
lines.append(f"- `{f.name}` — {f.type}")
|
|
50
|
+
if len(scalars) > 12:
|
|
51
|
+
lines.append(f"- _…and {len(scalars) - 12} more_")
|
|
52
|
+
if relations:
|
|
53
|
+
lines.append("")
|
|
54
|
+
lines.append("**Relations**")
|
|
55
|
+
for f in relations:
|
|
56
|
+
lines.append(
|
|
57
|
+
f"- `{f.name}` → **{f.related_model or '?'}** _({f.relation_kind})_"
|
|
58
|
+
)
|
|
59
|
+
return "\n".join(lines)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _index_markdown(index: WorkspaceIndex) -> str:
|
|
63
|
+
if not index.apps:
|
|
64
|
+
return "_No Django models found._"
|
|
65
|
+
out: List[str] = ["# Django models\n"]
|
|
66
|
+
for app in index.apps:
|
|
67
|
+
out.append(
|
|
68
|
+
f"## {app.name} ({len(app.models)} model{'s' if len(app.models) != 1 else ''})"
|
|
69
|
+
)
|
|
70
|
+
for m in app.models:
|
|
71
|
+
out.append(_model_markdown(m))
|
|
72
|
+
out.append("")
|
|
73
|
+
return "\n".join(out).rstrip() + "\n"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _model_markdown(model: ParsedModel) -> str:
|
|
77
|
+
bases = ", ".join(model.base_classes) or "models.Model"
|
|
78
|
+
rows = [
|
|
79
|
+
f"### `{model.app_name}.{model.name}`",
|
|
80
|
+
f"_class {model.name}({bases})_",
|
|
81
|
+
f"`{model.file_path}:{model.line_number + 1}`",
|
|
82
|
+
]
|
|
83
|
+
scalars = [f for f in model.fields if not f.is_relation]
|
|
84
|
+
relations = [f for f in model.fields if f.is_relation]
|
|
85
|
+
if scalars:
|
|
86
|
+
rows.append("\n**Fields**\n")
|
|
87
|
+
rows.append("| Field | Type | Args |")
|
|
88
|
+
rows.append("| --- | --- | --- |")
|
|
89
|
+
for f in scalars:
|
|
90
|
+
args = _truncate(f.args, 60).replace("|", r"\|")
|
|
91
|
+
rows.append(f"| `{f.name}` | {f.type} | `{args}` |")
|
|
92
|
+
if relations:
|
|
93
|
+
rows.append("\n**Relations**\n")
|
|
94
|
+
rows.append("| Field | Kind | Target |")
|
|
95
|
+
rows.append("| --- | --- | --- |")
|
|
96
|
+
for f in relations:
|
|
97
|
+
rows.append(
|
|
98
|
+
f"| `{f.name}` | {f.relation_kind} | **{f.related_model or '?'}** |"
|
|
99
|
+
)
|
|
100
|
+
if model.meta:
|
|
101
|
+
rows.append("\n**Meta**\n")
|
|
102
|
+
for k, v in model.meta.items():
|
|
103
|
+
rows.append(f"- `{k}` = {_truncate(v, 80)}")
|
|
104
|
+
return "\n".join(rows)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _index_table(index: WorkspaceIndex) -> str:
|
|
108
|
+
header = ("App", "Model", "Fields", "Rels", "File")
|
|
109
|
+
rows: List[tuple] = []
|
|
110
|
+
for app in index.apps:
|
|
111
|
+
for m in app.models:
|
|
112
|
+
scalars = sum(1 for f in m.fields if not f.is_relation)
|
|
113
|
+
rels = sum(1 for f in m.fields if f.is_relation)
|
|
114
|
+
rows.append(
|
|
115
|
+
(
|
|
116
|
+
app.name,
|
|
117
|
+
m.name,
|
|
118
|
+
str(scalars),
|
|
119
|
+
str(rels),
|
|
120
|
+
f"{m.file_path}:{m.line_number + 1}",
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
return _render_table(header, rows)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _model_table(model: ParsedModel) -> str:
|
|
127
|
+
header = ("Field", "Type", "Rel", "Target")
|
|
128
|
+
rows: List[tuple] = []
|
|
129
|
+
for f in model.fields:
|
|
130
|
+
rows.append(
|
|
131
|
+
(
|
|
132
|
+
f.name,
|
|
133
|
+
f.type,
|
|
134
|
+
"yes" if f.is_relation else "",
|
|
135
|
+
f.related_model or "",
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
top = f"{model.app_name}.{model.name} ({model.file_path}:{model.line_number + 1})\n"
|
|
139
|
+
return top + _render_table(header, rows)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _render_table(header: tuple, rows: List[tuple]) -> str:
|
|
143
|
+
all_rows = [header] + rows
|
|
144
|
+
widths = [max(len(str(r[i])) for r in all_rows) for i in range(len(header))]
|
|
145
|
+
sep = " "
|
|
146
|
+
|
|
147
|
+
def fmt(r):
|
|
148
|
+
return sep.join(str(v).ljust(widths[i]) for i, v in enumerate(r))
|
|
149
|
+
|
|
150
|
+
lines = [fmt(header), sep.join("-" * w for w in widths)]
|
|
151
|
+
for r in rows:
|
|
152
|
+
lines.append(fmt(r))
|
|
153
|
+
return "\n".join(lines)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _truncate(s: str, n: int) -> str:
|
|
157
|
+
s = s.replace("\n", " ").strip()
|
|
158
|
+
return s if len(s) <= n else s[: n - 1] + "…"
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) stdio server for Django ORM Lens.
|
|
2
|
+
|
|
3
|
+
Exposes five read-only tools that any MCP-compatible AI agent can call:
|
|
4
|
+
|
|
5
|
+
* ``list_apps`` — list Django apps with model counts
|
|
6
|
+
* ``list_models`` — flat ``app.Model`` list, optional app filter
|
|
7
|
+
* ``describe_model`` — full field/relation/Meta detail for one model
|
|
8
|
+
* ``find_relations`` — inbound + outbound relations for one model
|
|
9
|
+
* ``er_diagram`` — Mermaid ER diagram string for the whole workspace
|
|
10
|
+
|
|
11
|
+
The ``mcp`` runtime package is loaded lazily so ``pip install django-orm-lens``
|
|
12
|
+
stays zero-dep. Install with the extras: ``pip install 'django-orm-lens[mcp]'``.
|
|
13
|
+
|
|
14
|
+
Config: workspace root taken from ``$DJANGO_ORM_LENS_ROOT`` if set, else ``cwd``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from typing import Any, Dict, List
|
|
23
|
+
|
|
24
|
+
from .cli import _build_mermaid
|
|
25
|
+
from .parser import DEFAULT_EXCLUDES, scan_workspace
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _workspace_root() -> str:
|
|
29
|
+
return os.environ.get("DJANGO_ORM_LENS_ROOT") or os.getcwd()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _tool_list_apps(_args: Dict[str, Any]) -> str:
|
|
33
|
+
idx = scan_workspace(_workspace_root(), DEFAULT_EXCLUDES)
|
|
34
|
+
return json.dumps(
|
|
35
|
+
[
|
|
36
|
+
{"name": a.name, "path": a.path, "models": len(a.models)}
|
|
37
|
+
for a in idx.apps
|
|
38
|
+
],
|
|
39
|
+
indent=2,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _tool_list_models(args: Dict[str, Any]) -> str:
|
|
44
|
+
idx = scan_workspace(_workspace_root(), DEFAULT_EXCLUDES)
|
|
45
|
+
only_app = (args or {}).get("app")
|
|
46
|
+
rows: List[str] = []
|
|
47
|
+
for app in idx.apps:
|
|
48
|
+
if only_app and app.name != only_app:
|
|
49
|
+
continue
|
|
50
|
+
for m in app.models:
|
|
51
|
+
rows.append(f"{app.name}.{m.name}")
|
|
52
|
+
return "\n".join(rows) if rows else "(no models)"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _find(idx, ref: str):
|
|
56
|
+
if "." in ref:
|
|
57
|
+
an, mn = ref.split(".", 1)
|
|
58
|
+
for app in idx.apps:
|
|
59
|
+
if app.name == an:
|
|
60
|
+
for m in app.models:
|
|
61
|
+
if m.name == mn:
|
|
62
|
+
return m
|
|
63
|
+
for app in idx.apps:
|
|
64
|
+
for m in app.models:
|
|
65
|
+
if m.name == ref:
|
|
66
|
+
return m
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _tool_describe_model(args: Dict[str, Any]) -> str:
|
|
71
|
+
idx = scan_workspace(_workspace_root(), DEFAULT_EXCLUDES)
|
|
72
|
+
ref = (args or {}).get("model", "")
|
|
73
|
+
m = _find(idx, ref)
|
|
74
|
+
if not m:
|
|
75
|
+
return f"error: model {ref!r} not found"
|
|
76
|
+
return json.dumps(m.to_dict(), indent=2)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _tool_find_relations(args: Dict[str, Any]) -> str:
|
|
80
|
+
idx = scan_workspace(_workspace_root(), DEFAULT_EXCLUDES)
|
|
81
|
+
ref = (args or {}).get("model", "")
|
|
82
|
+
m = _find(idx, ref)
|
|
83
|
+
if not m:
|
|
84
|
+
return f"error: model {ref!r} not found"
|
|
85
|
+
out: Dict[str, Any] = {"outbound": [], "inbound": []}
|
|
86
|
+
for f in m.fields:
|
|
87
|
+
if f.is_relation:
|
|
88
|
+
out["outbound"].append(
|
|
89
|
+
{
|
|
90
|
+
"field": f.name,
|
|
91
|
+
"kind": f.relation_kind,
|
|
92
|
+
"target": f.related_model,
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
tail = m.name
|
|
96
|
+
for app in idx.apps:
|
|
97
|
+
for other in app.models:
|
|
98
|
+
if other is m:
|
|
99
|
+
continue
|
|
100
|
+
for f in other.fields:
|
|
101
|
+
if not f.is_relation or not f.related_model:
|
|
102
|
+
continue
|
|
103
|
+
t = f.related_model.split(".")[-1]
|
|
104
|
+
if t == tail:
|
|
105
|
+
out["inbound"].append(
|
|
106
|
+
{
|
|
107
|
+
"from": f"{app.name}.{other.name}",
|
|
108
|
+
"field": f.name,
|
|
109
|
+
"kind": f.relation_kind,
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
return json.dumps(out, indent=2)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _tool_er_diagram(_args: Dict[str, Any]) -> str:
|
|
116
|
+
idx = scan_workspace(_workspace_root(), DEFAULT_EXCLUDES)
|
|
117
|
+
return _build_mermaid(idx)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
TOOLS = {
|
|
121
|
+
"list_apps": {
|
|
122
|
+
"handler": _tool_list_apps,
|
|
123
|
+
"description": "List every Django app in the workspace with model counts.",
|
|
124
|
+
},
|
|
125
|
+
"list_models": {
|
|
126
|
+
"handler": _tool_list_models,
|
|
127
|
+
"description": "Flat list of app.Model. Optional 'app' filter.",
|
|
128
|
+
},
|
|
129
|
+
"describe_model": {
|
|
130
|
+
"handler": _tool_describe_model,
|
|
131
|
+
"description": "Full JSON detail for one model: fields, relations, Meta, base classes, file path.",
|
|
132
|
+
},
|
|
133
|
+
"find_relations": {
|
|
134
|
+
"handler": _tool_find_relations,
|
|
135
|
+
"description": "Outbound (this model → others) and inbound (others → this) relations.",
|
|
136
|
+
},
|
|
137
|
+
"er_diagram": {
|
|
138
|
+
"handler": _tool_er_diagram,
|
|
139
|
+
"description": "Emit a Mermaid erDiagram for the whole workspace.",
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def main() -> int:
|
|
145
|
+
"""Start the MCP stdio server. Lazy-imports the ``mcp`` package."""
|
|
146
|
+
try:
|
|
147
|
+
from mcp.server.fastmcp import FastMCP # type: ignore
|
|
148
|
+
except ImportError:
|
|
149
|
+
print(
|
|
150
|
+
"django-orm-lens MCP requires the 'mcp' package.\n"
|
|
151
|
+
"install with: pip install 'django-orm-lens[mcp]'",
|
|
152
|
+
file=sys.stderr,
|
|
153
|
+
)
|
|
154
|
+
return 3
|
|
155
|
+
|
|
156
|
+
server = FastMCP("django-orm-lens")
|
|
157
|
+
|
|
158
|
+
def _register(name: str, description: str, handler):
|
|
159
|
+
if name == "list_apps":
|
|
160
|
+
@server.tool(name=name, description=description)
|
|
161
|
+
def list_apps() -> str:
|
|
162
|
+
return handler({})
|
|
163
|
+
elif name == "list_models":
|
|
164
|
+
@server.tool(name=name, description=description)
|
|
165
|
+
def list_models(app: str = "") -> str:
|
|
166
|
+
return handler({"app": app} if app else {})
|
|
167
|
+
elif name == "describe_model":
|
|
168
|
+
@server.tool(name=name, description=description)
|
|
169
|
+
def describe_model(model: str) -> str:
|
|
170
|
+
return handler({"model": model})
|
|
171
|
+
elif name == "find_relations":
|
|
172
|
+
@server.tool(name=name, description=description)
|
|
173
|
+
def find_relations(model: str) -> str:
|
|
174
|
+
return handler({"model": model})
|
|
175
|
+
elif name == "er_diagram":
|
|
176
|
+
@server.tool(name=name, description=description)
|
|
177
|
+
def er_diagram() -> str:
|
|
178
|
+
return handler({})
|
|
179
|
+
|
|
180
|
+
for name, spec in TOOLS.items():
|
|
181
|
+
_register(name, spec["description"], spec["handler"])
|
|
182
|
+
|
|
183
|
+
server.run()
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
sys.exit(main())
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Data classes for parsed Django models.
|
|
2
|
+
|
|
3
|
+
Mirrors the TypeScript ``types.ts`` shape so the CLI and the VS Code
|
|
4
|
+
extension emit the same camelCase JSON schema.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Dict, List, Literal, Optional
|
|
11
|
+
|
|
12
|
+
RelationKind = Literal["ForeignKey", "ManyToManyField", "OneToOneField"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ParsedField:
|
|
17
|
+
name: str
|
|
18
|
+
type: str
|
|
19
|
+
args: str
|
|
20
|
+
is_relation: bool
|
|
21
|
+
line_number: int
|
|
22
|
+
related_model: Optional[str] = None
|
|
23
|
+
relation_kind: Optional[RelationKind] = None
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict:
|
|
26
|
+
out = {
|
|
27
|
+
"name": self.name,
|
|
28
|
+
"type": self.type,
|
|
29
|
+
"args": self.args,
|
|
30
|
+
"isRelation": self.is_relation,
|
|
31
|
+
"lineNumber": self.line_number,
|
|
32
|
+
}
|
|
33
|
+
if self.related_model is not None:
|
|
34
|
+
out["relatedModel"] = self.related_model
|
|
35
|
+
if self.relation_kind is not None:
|
|
36
|
+
out["relationKind"] = self.relation_kind
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ParsedModel:
|
|
42
|
+
name: str
|
|
43
|
+
app_name: str
|
|
44
|
+
file_path: str
|
|
45
|
+
line_number: int
|
|
46
|
+
base_classes: List[str]
|
|
47
|
+
fields: List[ParsedField] = field(default_factory=list)
|
|
48
|
+
meta: Dict[str, str] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict:
|
|
51
|
+
return {
|
|
52
|
+
"name": self.name,
|
|
53
|
+
"appName": self.app_name,
|
|
54
|
+
"filePath": self.file_path,
|
|
55
|
+
"lineNumber": self.line_number,
|
|
56
|
+
"baseClasses": list(self.base_classes),
|
|
57
|
+
"fields": [f.to_dict() for f in self.fields],
|
|
58
|
+
"meta": dict(self.meta),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ParsedApp:
|
|
64
|
+
name: str
|
|
65
|
+
path: str
|
|
66
|
+
models: List[ParsedModel] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict:
|
|
69
|
+
return {
|
|
70
|
+
"name": self.name,
|
|
71
|
+
"path": self.path,
|
|
72
|
+
"models": [m.to_dict() for m in self.models],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class WorkspaceIndex:
|
|
78
|
+
apps: List[ParsedApp] = field(default_factory=list)
|
|
79
|
+
scanned_at: int = 0
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> dict:
|
|
82
|
+
return {
|
|
83
|
+
"apps": [a.to_dict() for a in self.apps],
|
|
84
|
+
"scannedAt": self.scanned_at,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
def total_models(self) -> int:
|
|
88
|
+
return sum(len(a.models) for a in self.apps)
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Django models.py parser — Python port of the VS Code extension parser.
|
|
2
|
+
|
|
3
|
+
Static regex-based parser. Zero third-party deps. Produces the same schema
|
|
4
|
+
as the TypeScript ``src/parser.ts`` so tools can consume either interchangeably.
|
|
5
|
+
|
|
6
|
+
Key semantics preserved from the TS parser:
|
|
7
|
+
* multi-line ``class X(A, B):`` inheritance;
|
|
8
|
+
* non-model class filter (``ModelAdmin`` / ``ModelForm`` / ``Serializer`` etc.);
|
|
9
|
+
* per-class indent width detection (2/4/tab all work);
|
|
10
|
+
* both ``models.CharField(...)`` and bare ``CharField(...)`` imports;
|
|
11
|
+
* balanced-paren reader for multi-line field arg blocks;
|
|
12
|
+
* first-arg extraction for FK/O2O/M2M ``related_model``;
|
|
13
|
+
* ``class Meta:`` attribute capture.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import time
|
|
21
|
+
from fnmatch import fnmatch
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Iterable, List, Sequence, Tuple
|
|
24
|
+
|
|
25
|
+
from .models import (
|
|
26
|
+
ParsedApp,
|
|
27
|
+
ParsedField,
|
|
28
|
+
ParsedModel,
|
|
29
|
+
WorkspaceIndex,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
RELATION_TYPES: Tuple[str, ...] = ("ForeignKey", "ManyToManyField", "OneToOneField")
|
|
33
|
+
|
|
34
|
+
CLASS_RE = re.compile(r"^class\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)\s*:")
|
|
35
|
+
CLASS_START_RE = re.compile(r"^class\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(")
|
|
36
|
+
|
|
37
|
+
NON_MODEL_TAIL = re.compile(
|
|
38
|
+
r"^(ModelAdmin|ModelForm|ModelSerializer|ModelChoiceField|"
|
|
39
|
+
r"ModelMultipleChoiceField|Serializer|Form|Admin|View|ViewSet|"
|
|
40
|
+
r"Manager|QuerySet|Config|AppConfig|Response|Handler|Middleware|"
|
|
41
|
+
r"Backend|Command)$"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
BARE_FIELD_TYPES = "|".join(
|
|
45
|
+
[
|
|
46
|
+
"CharField", "TextField", "SlugField", "EmailField", "URLField", "UUIDField",
|
|
47
|
+
"IntegerField", "BigIntegerField", "SmallIntegerField",
|
|
48
|
+
"PositiveIntegerField", "PositiveSmallIntegerField", "PositiveBigIntegerField",
|
|
49
|
+
"FloatField", "DecimalField",
|
|
50
|
+
"BooleanField", "NullBooleanField",
|
|
51
|
+
"DateTimeField", "DateField", "TimeField", "DurationField",
|
|
52
|
+
"JSONField", "BinaryField",
|
|
53
|
+
"FileField", "ImageField", "FilePathField",
|
|
54
|
+
"GenericIPAddressField",
|
|
55
|
+
"AutoField", "BigAutoField", "SmallAutoField",
|
|
56
|
+
"ForeignKey", "OneToOneField", "ManyToManyField",
|
|
57
|
+
]
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read_multiline_class(lines: Sequence[str], start: int):
|
|
62
|
+
buffer = lines[start]
|
|
63
|
+
depth = 0
|
|
64
|
+
saw_open = False
|
|
65
|
+
for i in range(start, len(lines)):
|
|
66
|
+
src = lines[i] if i == start else lines[i].lstrip()
|
|
67
|
+
for ch in src:
|
|
68
|
+
if ch == "(":
|
|
69
|
+
depth += 1
|
|
70
|
+
saw_open = True
|
|
71
|
+
elif ch == ")":
|
|
72
|
+
depth -= 1
|
|
73
|
+
if i > start:
|
|
74
|
+
buffer += " " + src
|
|
75
|
+
if saw_open and depth == 0:
|
|
76
|
+
m = CLASS_RE.match(buffer)
|
|
77
|
+
if m:
|
|
78
|
+
return m, i
|
|
79
|
+
return None
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _detect_class_indent(lines: Sequence[str], class_line_idx: int) -> int:
|
|
84
|
+
end = min(class_line_idx + 30, len(lines))
|
|
85
|
+
for i in range(class_line_idx + 1, end):
|
|
86
|
+
m = re.match(r"^([\t ]+)\S", lines[i])
|
|
87
|
+
if m:
|
|
88
|
+
return len(m.group(1))
|
|
89
|
+
return 4
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _build_body_regexes(indent: int):
|
|
93
|
+
w = r"\s{" + str(indent) + r"}"
|
|
94
|
+
w2 = r"\s{" + str(indent * 2) + r"}"
|
|
95
|
+
return {
|
|
96
|
+
"FIELD_RE": re.compile(
|
|
97
|
+
rf"^{w}([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*models\.([A-Za-z_][A-Za-z0-9_]*)\s*\("
|
|
98
|
+
),
|
|
99
|
+
"BARE_FIELD_RE": re.compile(
|
|
100
|
+
rf"^{w}([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*({BARE_FIELD_TYPES})\s*\("
|
|
101
|
+
),
|
|
102
|
+
"META_START_RE": re.compile(rf"^{w}class\s+Meta\s*(?:\([^)]*\))?\s*:"),
|
|
103
|
+
"META_ITEM_RE": re.compile(
|
|
104
|
+
rf"^{w2}([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+?)\s*(#.*)?$"
|
|
105
|
+
),
|
|
106
|
+
"META_BODY_RE": re.compile(r"^\s{" + str(indent * 2) + r",}"),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _extract_related(args_block: str):
|
|
111
|
+
stripped = args_block.strip()
|
|
112
|
+
m = re.match(
|
|
113
|
+
r"^(?:to\s*=\s*)?(?:'([^']+)'|\"([^\"]+)\"|([A-Za-z_][A-Za-z0-9_.]*))",
|
|
114
|
+
stripped,
|
|
115
|
+
)
|
|
116
|
+
if not m:
|
|
117
|
+
return None
|
|
118
|
+
raw = m.group(1) or m.group(2) or m.group(3)
|
|
119
|
+
if not raw:
|
|
120
|
+
return None
|
|
121
|
+
if raw == "self":
|
|
122
|
+
return "self"
|
|
123
|
+
return raw.strip("'\"")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _read_balanced_args(lines: Sequence[str], start: int):
|
|
127
|
+
open_idx = lines[start].index("(")
|
|
128
|
+
depth = 0
|
|
129
|
+
buf = ""
|
|
130
|
+
for i in range(start, len(lines)):
|
|
131
|
+
src = lines[i][open_idx:] if i == start else lines[i]
|
|
132
|
+
for ch in src:
|
|
133
|
+
if ch == "(":
|
|
134
|
+
depth += 1
|
|
135
|
+
elif ch == ")":
|
|
136
|
+
depth -= 1
|
|
137
|
+
if depth == 0:
|
|
138
|
+
return (buf + ")").lstrip("("), i
|
|
139
|
+
buf += ch
|
|
140
|
+
buf += "\n"
|
|
141
|
+
return buf.lstrip("("), len(lines) - 1
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _looks_like_model(base_classes: List[str]) -> bool:
|
|
145
|
+
for b in base_classes:
|
|
146
|
+
tail = b.split(".")[-1]
|
|
147
|
+
if NON_MODEL_TAIL.match(tail):
|
|
148
|
+
return False
|
|
149
|
+
for b in base_classes:
|
|
150
|
+
tail = b.split(".")[-1]
|
|
151
|
+
if NON_MODEL_TAIL.match(tail):
|
|
152
|
+
continue
|
|
153
|
+
if re.search(r"models\.Model$", b):
|
|
154
|
+
return True
|
|
155
|
+
if re.match(
|
|
156
|
+
r"^(Model|AbstractModel|AbstractBaseModel|TimeStampedModel|PolymorphicModel)$",
|
|
157
|
+
tail,
|
|
158
|
+
):
|
|
159
|
+
return True
|
|
160
|
+
if re.match(r"^Abstract[A-Z]", tail):
|
|
161
|
+
return True
|
|
162
|
+
if re.search(r"Mixin$", tail):
|
|
163
|
+
return True
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def parse_models_file(file_path: str, content: str) -> List[ParsedModel]:
|
|
168
|
+
"""Parse a single models.py-style file. Returns 0..N Django model classes."""
|
|
169
|
+
lines = content.splitlines()
|
|
170
|
+
models: List[ParsedModel] = []
|
|
171
|
+
parent = os.path.basename(os.path.dirname(file_path))
|
|
172
|
+
if parent == "models":
|
|
173
|
+
app_name = os.path.basename(os.path.dirname(os.path.dirname(file_path)))
|
|
174
|
+
else:
|
|
175
|
+
app_name = parent or "app"
|
|
176
|
+
|
|
177
|
+
i = 0
|
|
178
|
+
while i < len(lines):
|
|
179
|
+
line = lines[i]
|
|
180
|
+
class_match = CLASS_RE.match(line)
|
|
181
|
+
class_header_end = i
|
|
182
|
+
if not class_match and CLASS_START_RE.match(line):
|
|
183
|
+
joined = _read_multiline_class(lines, i)
|
|
184
|
+
if joined:
|
|
185
|
+
class_match, class_header_end = joined
|
|
186
|
+
if not class_match:
|
|
187
|
+
i += 1
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
model_name = class_match.group(1)
|
|
191
|
+
base_classes = [
|
|
192
|
+
s.strip() for s in class_match.group(2).split(",") if s.strip()
|
|
193
|
+
]
|
|
194
|
+
if not _looks_like_model(base_classes):
|
|
195
|
+
i += 1
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
model = ParsedModel(
|
|
199
|
+
name=model_name,
|
|
200
|
+
app_name=app_name,
|
|
201
|
+
file_path=file_path,
|
|
202
|
+
line_number=i,
|
|
203
|
+
base_classes=base_classes,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
indent = _detect_class_indent(lines, class_header_end)
|
|
207
|
+
rx = _build_body_regexes(indent)
|
|
208
|
+
|
|
209
|
+
j = class_header_end + 1
|
|
210
|
+
while j < len(lines):
|
|
211
|
+
inner = lines[j]
|
|
212
|
+
if re.match(r"^class\s+", inner) and not re.match(r"^\s+class", inner):
|
|
213
|
+
break
|
|
214
|
+
if not inner.strip():
|
|
215
|
+
j += 1
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
if rx["META_START_RE"].match(inner):
|
|
219
|
+
k = j + 1
|
|
220
|
+
while k < len(lines):
|
|
221
|
+
ml = lines[k]
|
|
222
|
+
if ml and not rx["META_BODY_RE"].match(ml) and ml.strip():
|
|
223
|
+
break
|
|
224
|
+
m2 = rx["META_ITEM_RE"].match(ml)
|
|
225
|
+
if m2:
|
|
226
|
+
model.meta[m2.group(1)] = m2.group(2).strip()
|
|
227
|
+
k += 1
|
|
228
|
+
j = k
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
fm = rx["FIELD_RE"].match(inner) or rx["BARE_FIELD_RE"].match(inner)
|
|
232
|
+
if fm:
|
|
233
|
+
fname, ftype = fm.group(1), fm.group(2)
|
|
234
|
+
args_block, end_idx = _read_balanced_args(lines, j)
|
|
235
|
+
is_rel = ftype in RELATION_TYPES
|
|
236
|
+
field = ParsedField(
|
|
237
|
+
name=fname,
|
|
238
|
+
type=ftype,
|
|
239
|
+
args=args_block.rstrip(")").strip(),
|
|
240
|
+
is_relation=is_rel,
|
|
241
|
+
line_number=j,
|
|
242
|
+
)
|
|
243
|
+
if is_rel:
|
|
244
|
+
field.relation_kind = ftype # type: ignore[assignment]
|
|
245
|
+
field.related_model = _extract_related(args_block.rstrip(")"))
|
|
246
|
+
model.fields.append(field)
|
|
247
|
+
j = end_idx + 1
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
j += 1
|
|
251
|
+
|
|
252
|
+
models.append(model)
|
|
253
|
+
i = j
|
|
254
|
+
|
|
255
|
+
return models
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _iter_python_files(root: Path, excludes: Sequence[str]) -> Iterable[Path]:
|
|
259
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
260
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
|
261
|
+
for name in filenames:
|
|
262
|
+
if name == "models.py" or (
|
|
263
|
+
os.path.basename(dirpath) == "models"
|
|
264
|
+
and name.endswith(".py")
|
|
265
|
+
and name != "__init__.py"
|
|
266
|
+
):
|
|
267
|
+
full = Path(dirpath) / name
|
|
268
|
+
rel_posix = full.relative_to(root).as_posix()
|
|
269
|
+
if any(fnmatch(rel_posix, pat) for pat in excludes):
|
|
270
|
+
continue
|
|
271
|
+
yield full
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _app_dir_for(fs_path: str) -> Tuple[str, str]:
|
|
275
|
+
parent = os.path.dirname(fs_path)
|
|
276
|
+
parent_name = os.path.basename(parent)
|
|
277
|
+
if parent_name == "models":
|
|
278
|
+
grand = os.path.dirname(parent)
|
|
279
|
+
return grand, os.path.basename(grand)
|
|
280
|
+
return parent, parent_name
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
DEFAULT_EXCLUDES = (
|
|
284
|
+
"**/migrations/**",
|
|
285
|
+
"**/node_modules/**",
|
|
286
|
+
"**/venv/**",
|
|
287
|
+
"**/.venv/**",
|
|
288
|
+
"**/env/**",
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def scan_workspace(
|
|
293
|
+
root: str, exclude_globs: Sequence[str] = DEFAULT_EXCLUDES
|
|
294
|
+
) -> WorkspaceIndex:
|
|
295
|
+
"""Walk ``root``, parse every ``models.py`` (or ``models/*.py``), return an index."""
|
|
296
|
+
root_path = Path(root).resolve()
|
|
297
|
+
apps: dict = {}
|
|
298
|
+
for py in _iter_python_files(root_path, exclude_globs):
|
|
299
|
+
try:
|
|
300
|
+
content = py.read_text(encoding="utf-8", errors="replace")
|
|
301
|
+
except OSError:
|
|
302
|
+
continue
|
|
303
|
+
models = parse_models_file(str(py), content)
|
|
304
|
+
if not models:
|
|
305
|
+
continue
|
|
306
|
+
app_dir, app_name = _app_dir_for(str(py))
|
|
307
|
+
app = apps.get(app_dir)
|
|
308
|
+
if app is None:
|
|
309
|
+
app = ParsedApp(name=app_name, path=app_dir)
|
|
310
|
+
apps[app_dir] = app
|
|
311
|
+
app.models.extend(models)
|
|
312
|
+
|
|
313
|
+
sorted_apps = sorted(apps.values(), key=lambda a: a.name)
|
|
314
|
+
return WorkspaceIndex(apps=sorted_apps, scanned_at=int(time.time() * 1000))
|
django_orm_lens/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-orm-lens
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Static analysis + MCP server for Django models. Sidebar tree, ER diagrams, and JSON output for terminals and AI coding agents.
|
|
5
|
+
Author: frowningdev
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/FROWNINGdev/django-orm-lens
|
|
8
|
+
Project-URL: Repository, https://github.com/FROWNINGdev/django-orm-lens
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/FROWNINGdev/django-orm-lens/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/FROWNINGdev/django-orm-lens/blob/main/CHANGELOG.md
|
|
11
|
+
Project-URL: VS Code Marketplace, https://marketplace.visualstudio.com/items?itemName=frowningdev.django-orm-lens
|
|
12
|
+
Keywords: django,orm,models,er-diagram,mermaid,mcp,ai-agents,cursor,aider,code-analysis,static-analysis
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Framework :: Django
|
|
16
|
+
Classifier: Framework :: Django :: 4.0
|
|
17
|
+
Classifier: Framework :: Django :: 4.1
|
|
18
|
+
Classifier: Framework :: Django :: 4.2
|
|
19
|
+
Classifier: Framework :: Django :: 5.0
|
|
20
|
+
Classifier: Framework :: Django :: 5.1
|
|
21
|
+
Classifier: Intended Audience :: Developers
|
|
22
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
23
|
+
Classifier: Operating System :: OS Independent
|
|
24
|
+
Classifier: Programming Language :: Python
|
|
25
|
+
Classifier: Programming Language :: Python :: 3
|
|
26
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
28
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
29
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
30
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
32
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
33
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
34
|
+
Classifier: Topic :: Utilities
|
|
35
|
+
Classifier: Typing :: Typed
|
|
36
|
+
Requires-Python: >=3.9
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
Provides-Extra: mcp
|
|
39
|
+
Requires-Dist: mcp>=1.0; extra == "mcp"
|
|
40
|
+
Provides-Extra: dev
|
|
41
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
42
|
+
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
43
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
44
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
45
|
+
Requires-Dist: twine>=5.1; extra == "dev"
|
|
46
|
+
|
|
47
|
+
# django-orm-lens
|
|
48
|
+
|
|
49
|
+
**Static analysis + MCP server for Django models.** Terminal- and AI-agent-friendly.
|
|
50
|
+
|
|
51
|
+
Ships with a zero-dependency parser, a JSON/Markdown/table CLI, and an optional
|
|
52
|
+
MCP (Model Context Protocol) server so any AI coding agent — Cursor, Aider,
|
|
53
|
+
Continue, and any other MCP client — can navigate your Django schema without
|
|
54
|
+
importing Django or spinning up your app.
|
|
55
|
+
|
|
56
|
+
Companion to the [Django ORM Lens VS Code
|
|
57
|
+
extension](https://marketplace.visualstudio.com/items?itemName=frowningdev.django-orm-lens).
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# Core CLI (zero third-party deps)
|
|
65
|
+
pip install django-orm-lens
|
|
66
|
+
|
|
67
|
+
# With the MCP server (adds the `mcp` package)
|
|
68
|
+
pip install "django-orm-lens[mcp]"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Requires Python **3.9+**. Works on Linux, macOS, and Windows.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## CLI usage
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Scan a Django project for models (JSON, Markdown, or table)
|
|
79
|
+
django-orm-lens scan -f json
|
|
80
|
+
django-orm-lens scan -f markdown
|
|
81
|
+
django-orm-lens scan -f table
|
|
82
|
+
|
|
83
|
+
# Describe one model
|
|
84
|
+
django-orm-lens describe blog.Post
|
|
85
|
+
django-orm-lens describe Post -f json
|
|
86
|
+
|
|
87
|
+
# Compact hover card (great for pipeing into your editor)
|
|
88
|
+
django-orm-lens hover blog.Post
|
|
89
|
+
|
|
90
|
+
# Flat list — pipes into fzf, grep, etc.
|
|
91
|
+
django-orm-lens list | fzf
|
|
92
|
+
|
|
93
|
+
# Emit a Mermaid ER diagram
|
|
94
|
+
django-orm-lens er > schema.mmd
|
|
95
|
+
django-orm-lens er -o schema.mmd
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Every command accepts `--path <dir>` and repeatable `--exclude <glob>`. Defaults
|
|
99
|
+
skip `migrations/`, `venv/`, `.venv/`, `env/`, and `node_modules/`.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## MCP server — for AI coding agents
|
|
104
|
+
|
|
105
|
+
The MCP server exposes five read-only tools that any MCP-compatible agent can
|
|
106
|
+
call while it edits your Django project:
|
|
107
|
+
|
|
108
|
+
| Tool | Purpose |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| `list_apps` | Every Django app in the workspace with model counts |
|
|
111
|
+
| `list_models` | Flat `app.Model` list, optional app filter |
|
|
112
|
+
| `describe_model` | Full field / relation / Meta detail for one model |
|
|
113
|
+
| `find_relations` | Inbound + outbound relations for one model |
|
|
114
|
+
| `er_diagram` | Mermaid `erDiagram` string for the whole workspace |
|
|
115
|
+
|
|
116
|
+
### Start the server manually
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
django-orm-lens-mcp # dedicated entry point
|
|
120
|
+
# or
|
|
121
|
+
django-orm-lens mcp # subcommand
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
By default the server scans the current working directory. Override with
|
|
125
|
+
`DJANGO_ORM_LENS_ROOT=/abs/path/to/project`.
|
|
126
|
+
|
|
127
|
+
### Register it with an agent
|
|
128
|
+
|
|
129
|
+
**Cursor** — add to `~/.cursor/mcp.json`:
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"mcpServers": {
|
|
134
|
+
"django-orm-lens": {
|
|
135
|
+
"command": "django-orm-lens-mcp",
|
|
136
|
+
"env": { "DJANGO_ORM_LENS_ROOT": "/abs/path/to/your/project" }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Aider / Continue.dev / Zed / any MCP client** — same shape, the tool is
|
|
143
|
+
generic. Point `command` at the installed `django-orm-lens-mcp` binary and set
|
|
144
|
+
the workspace root via env.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Why?
|
|
149
|
+
|
|
150
|
+
Django's ORM is Python, and Python is dynamic. AI agents that only see
|
|
151
|
+
`models.py` as raw text miss:
|
|
152
|
+
|
|
153
|
+
* which fields belong to which model;
|
|
154
|
+
* the direction and cardinality of every relation;
|
|
155
|
+
* what `Meta.ordering`, `unique_together`, and `constraints` actually contain;
|
|
156
|
+
* which app owns which model when the project uses split `models/` packages.
|
|
157
|
+
|
|
158
|
+
`django-orm-lens` gives them a **static, deterministic, JSON view** of the
|
|
159
|
+
schema — no Django boot, no database, no side effects. And you get a nice CLI
|
|
160
|
+
for humans too.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Programmatic API
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from django_orm_lens import scan_workspace
|
|
168
|
+
|
|
169
|
+
index = scan_workspace(".")
|
|
170
|
+
for app in index.apps:
|
|
171
|
+
for model in app.models:
|
|
172
|
+
print(f"{app.name}.{model.name} — {len(model.fields)} fields")
|
|
173
|
+
|
|
174
|
+
# The full parsed tree serialises to the same JSON schema the VS Code
|
|
175
|
+
# extension emits, so tools can share it interchangeably.
|
|
176
|
+
import json
|
|
177
|
+
json.dumps(index.to_dict(), indent=2)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Links
|
|
183
|
+
|
|
184
|
+
* Repo: <https://github.com/FROWNINGdev/django-orm-lens>
|
|
185
|
+
* Issues: <https://github.com/FROWNINGdev/django-orm-lens/issues>
|
|
186
|
+
* Changelog: <https://github.com/FROWNINGdev/django-orm-lens/blob/main/CHANGELOG.md>
|
|
187
|
+
* VS Code extension: <https://marketplace.visualstudio.com/items?itemName=frowningdev.django-orm-lens>
|
|
188
|
+
|
|
189
|
+
MIT licensed.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
django_orm_lens/__init__.py,sha256=HtZqQzZE51oetdnT6F7LClqDygBnu0EaTOfcpoQyuHY,514
|
|
2
|
+
django_orm_lens/cli.py,sha256=hBATgRTvghvFiAMcQGj44optc-2ZzHsLIIytOGTu4jg,7691
|
|
3
|
+
django_orm_lens/formatters.py,sha256=oebE8GemJSTwvaQZt_tj5KEhraQ8-4alTzLp54pUPHY,5307
|
|
4
|
+
django_orm_lens/mcp_server.py,sha256=XPiZmrzxKoag4xvTpoGU0JpoSXCpu4dr4GeqmIjn3Q8,6137
|
|
5
|
+
django_orm_lens/models.py,sha256=-T-rQy4098qtgQ_w-B0HJ_Q_jra6E776It0eo7x1Ees,2282
|
|
6
|
+
django_orm_lens/parser.py,sha256=PG0F1lygRFCyPyKZDS14_46gO9l-5s2DXTqy2QacluU,10265
|
|
7
|
+
django_orm_lens/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
django_orm_lens-1.0.0.dist-info/METADATA,sha256=uwu37iSfvEf6VVL99EhYH6qtw76hIVojO5VQbx9eUtg,6327
|
|
9
|
+
django_orm_lens-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
10
|
+
django_orm_lens-1.0.0.dist-info/entry_points.txt,sha256=uAzPJOMfKD9GBi2oauTuDuO4EiLg-DLCBolc7G95F4Y,115
|
|
11
|
+
django_orm_lens-1.0.0.dist-info/top_level.txt,sha256=2AUW2B4dABPbsiR418KRG8bIQLBlNtHfl26DCLSikX8,16
|
|
12
|
+
django_orm_lens-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
django_orm_lens
|