django-orm-lens 1.2.7__tar.gz → 1.4.0__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.
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/PKG-INFO +22 -6
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/README.md +21 -5
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/mcp_server.py +245 -95
- django_orm_lens-1.4.0/django_orm_lens/workspace.py +348 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/PKG-INFO +22 -6
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/SOURCES.txt +3 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/pyproject.toml +1 -1
- django_orm_lens-1.4.0/tests/test_mcp_server.py +315 -0
- django_orm_lens-1.4.0/tests/test_mcp_workspace.py +364 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/__init__.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/cli.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/diff.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/formatters.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/migrations_parser.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/models.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/parser.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/py.typed +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/query_analyzer.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens/signals_parser.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/dependency_links.txt +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/entry_points.txt +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/requires.txt +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/top_level.txt +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/setup.cfg +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_abstract_and_auth_user.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_aliased_module_and_third_party_fields.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_bom_prefix.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_cli_hint.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_cli_subcommands_smoke.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_cli_verbose.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_cross_module_bases.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_diff_command.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_golden_fixtures.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_kwarg_order_and_auth_user.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_list_command.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_migration_risk.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_migrations_parser.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_nplusone_detector.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_parity.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_pep526_type_hints.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_pep695_generic_classes.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_query_analyzer.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_scan_root.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_signals_parser.py +0 -0
- {django_orm_lens-1.2.7 → django_orm_lens-1.4.0}/tests/test_tab_indented_models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-orm-lens
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Static analysis + MCP server for Django models. Sidebar tree, ER diagrams, and JSON output for terminals and AI coding agents.
|
|
5
5
|
Author: frowningdev
|
|
6
6
|
License: MIT
|
|
@@ -128,8 +128,16 @@ django-orm-lens-mcp # dedicated entry point
|
|
|
128
128
|
django-orm-lens mcp # subcommand
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
|
|
132
|
-
`DJANGO_ORM_LENS_ROOT
|
|
131
|
+
**Workspace resolution (py-1.3.0+).** Priority: explicit `workspace_root`
|
|
132
|
+
argument on the tool call → `DJANGO_ORM_LENS_ROOT` env var → current working
|
|
133
|
+
directory. If none resolves to a Django project (`manage.py`, `django` in
|
|
134
|
+
`pyproject.toml`, or any `models.py`) you get a structured error envelope
|
|
135
|
+
back — `{"error": "WORKSPACE_NOT_DJANGO", "hint": "…"}` — instead of an
|
|
136
|
+
empty list, so the agent knows what to do next.
|
|
137
|
+
|
|
138
|
+
Optional sandbox: set `DJANGO_ORM_LENS_ALLOWED_ROOTS` (`;`-separated on
|
|
139
|
+
Windows, `:`-separated elsewhere) to a whitelist of prefixes; any path
|
|
140
|
+
outside them is rejected with `WORKSPACE_NOT_ALLOWED`.
|
|
133
141
|
|
|
134
142
|
### Register it with an agent
|
|
135
143
|
|
|
@@ -146,9 +154,17 @@ By default the server scans the current working directory. Override with
|
|
|
146
154
|
}
|
|
147
155
|
```
|
|
148
156
|
|
|
149
|
-
**
|
|
150
|
-
|
|
151
|
-
|
|
157
|
+
**Any MCP client** — same shape, generic tool. Point `command` at the
|
|
158
|
+
installed `django-orm-lens-mcp` binary. Two ways to tell it which Django
|
|
159
|
+
project to scan:
|
|
160
|
+
|
|
161
|
+
1. **Set `DJANGO_ORM_LENS_ROOT` in `env`** — the whole session uses one
|
|
162
|
+
project. Simplest for single-repo workflows.
|
|
163
|
+
2. **Pass `workspace_root` on each tool call** — the agent switches
|
|
164
|
+
projects per call. Useful for mono-repos and multi-workspace setups.
|
|
165
|
+
|
|
166
|
+
The tool signatures include `workspace_root: str = ""` as an optional
|
|
167
|
+
parameter, so any agent inspecting `tools/list` sees it and can supply it.
|
|
152
168
|
|
|
153
169
|
---
|
|
154
170
|
|
|
@@ -79,8 +79,16 @@ django-orm-lens-mcp # dedicated entry point
|
|
|
79
79
|
django-orm-lens mcp # subcommand
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
`DJANGO_ORM_LENS_ROOT
|
|
82
|
+
**Workspace resolution (py-1.3.0+).** Priority: explicit `workspace_root`
|
|
83
|
+
argument on the tool call → `DJANGO_ORM_LENS_ROOT` env var → current working
|
|
84
|
+
directory. If none resolves to a Django project (`manage.py`, `django` in
|
|
85
|
+
`pyproject.toml`, or any `models.py`) you get a structured error envelope
|
|
86
|
+
back — `{"error": "WORKSPACE_NOT_DJANGO", "hint": "…"}` — instead of an
|
|
87
|
+
empty list, so the agent knows what to do next.
|
|
88
|
+
|
|
89
|
+
Optional sandbox: set `DJANGO_ORM_LENS_ALLOWED_ROOTS` (`;`-separated on
|
|
90
|
+
Windows, `:`-separated elsewhere) to a whitelist of prefixes; any path
|
|
91
|
+
outside them is rejected with `WORKSPACE_NOT_ALLOWED`.
|
|
84
92
|
|
|
85
93
|
### Register it with an agent
|
|
86
94
|
|
|
@@ -97,9 +105,17 @@ By default the server scans the current working directory. Override with
|
|
|
97
105
|
}
|
|
98
106
|
```
|
|
99
107
|
|
|
100
|
-
**
|
|
101
|
-
|
|
102
|
-
|
|
108
|
+
**Any MCP client** — same shape, generic tool. Point `command` at the
|
|
109
|
+
installed `django-orm-lens-mcp` binary. Two ways to tell it which Django
|
|
110
|
+
project to scan:
|
|
111
|
+
|
|
112
|
+
1. **Set `DJANGO_ORM_LENS_ROOT` in `env`** — the whole session uses one
|
|
113
|
+
project. Simplest for single-repo workflows.
|
|
114
|
+
2. **Pass `workspace_root` on each tool call** — the agent switches
|
|
115
|
+
projects per call. Useful for mono-repos and multi-workspace setups.
|
|
116
|
+
|
|
117
|
+
The tool signatures include `workspace_root: str = ""` as an optional
|
|
118
|
+
parameter, so any agent inspecting `tools/list` sees it and can supply it.
|
|
103
119
|
|
|
104
120
|
---
|
|
105
121
|
|
|
@@ -15,87 +15,67 @@ Exposes nine read-only tools that any MCP-compatible AI agent can call:
|
|
|
15
15
|
The ``mcp`` runtime package is loaded lazily so ``pip install django-orm-lens``
|
|
16
16
|
stays zero-dep. Install with the extras: ``pip install 'django-orm-lens[mcp]'``.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Workspace discovery (py-1.3.0+)
|
|
19
|
+
-------------------------------
|
|
20
|
+
Every tool accepts an optional ``workspace_root`` argument. Resolution
|
|
21
|
+
priority — explicit arg, then ``$DJANGO_ORM_LENS_ROOT`` env var, then ``cwd``
|
|
22
|
+
— is implemented once in :mod:`django_orm_lens.workspace`. Before py-1.3.0
|
|
23
|
+
the argument was silently dropped by FastMCP because the tool signatures did
|
|
24
|
+
not declare it; the fix is to declare the argument on every tool and delegate
|
|
25
|
+
resolution to a single hardened helper. On failure the tool returns a JSON
|
|
26
|
+
envelope ``{"error": "...", "hint": "..."}`` so the agent gets an actionable
|
|
27
|
+
message instead of an empty list.
|
|
19
28
|
"""
|
|
20
29
|
|
|
21
30
|
from __future__ import annotations
|
|
22
31
|
|
|
23
32
|
import json
|
|
24
|
-
import os
|
|
25
33
|
import sys
|
|
26
|
-
import
|
|
27
|
-
from typing import Any, Dict, List,
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Dict, List, Union
|
|
28
36
|
|
|
29
37
|
from .cli import _build_mermaid
|
|
30
38
|
from .migrations_parser import describe_migration_dependency
|
|
31
39
|
from .models import WorkspaceIndex, find_model, find_user_model, resolve_related_tail
|
|
32
|
-
from .
|
|
40
|
+
from .query_analyzer import scan_for_nplusone as _scan_for_nplusone
|
|
33
41
|
from .query_analyzer import suggest_indexes as _suggest_indexes
|
|
34
42
|
from .signals_parser import signal_graph as _signal_graph
|
|
43
|
+
from .workspace import WorkspaceError, get_index, resolve_workspace
|
|
35
44
|
|
|
36
45
|
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Resolution helper — every tool starts here
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
39
49
|
|
|
40
50
|
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
def _resolve(args: Dict[str, Any]) -> Union[Path, WorkspaceError]:
|
|
52
|
+
"""Extract ``workspace_root`` from tool args and delegate to workspace.py.
|
|
43
53
|
|
|
54
|
+
Centralised so every tool has identical resolution semantics. Empty string,
|
|
55
|
+
missing key, and ``None`` all fall through to env var / cwd — same as
|
|
56
|
+
calling the tool with no argument at all.
|
|
57
|
+
"""
|
|
58
|
+
raw = (args or {}).get("workspace_root") or ""
|
|
59
|
+
return resolve_workspace(raw)
|
|
44
60
|
|
|
45
|
-
def _get_index() -> WorkspaceIndex:
|
|
46
|
-
"""Cache-backed workspace index — 30s TTL, keyed by workspace root.
|
|
47
61
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
edits between calls still pick up on the next scan.
|
|
52
|
-
"""
|
|
53
|
-
root = _workspace_root()
|
|
54
|
-
now_ms = int(time.time() * 1000)
|
|
55
|
-
entry = _INDEX_CACHE.get(root)
|
|
56
|
-
if entry is not None and entry[1] > now_ms:
|
|
57
|
-
return entry[0]
|
|
58
|
-
idx = scan_workspace(root, DEFAULT_EXCLUDES)
|
|
59
|
-
_INDEX_CACHE[root] = (idx, now_ms + _CACHE_TTL_MS)
|
|
60
|
-
return idx
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _tool_list_apps(_args: Dict[str, Any]) -> str:
|
|
64
|
-
idx = _get_index()
|
|
65
|
-
return json.dumps(
|
|
66
|
-
[
|
|
67
|
-
{"name": a.name, "path": a.path, "models": len(a.models)}
|
|
68
|
-
for a in idx.apps
|
|
69
|
-
],
|
|
70
|
-
indent=2,
|
|
71
|
-
)
|
|
62
|
+
def _error_json(err: WorkspaceError) -> str:
|
|
63
|
+
"""Serialize a :class:`WorkspaceError` as an indent-2 JSON envelope."""
|
|
64
|
+
return json.dumps(err.to_dict(), indent=2)
|
|
72
65
|
|
|
73
66
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if only_app and not any(a.name == only_app for a in idx.apps):
|
|
78
|
-
available = ", ".join(sorted(a.name for a in idx.apps)) or "(none)"
|
|
79
|
-
return (
|
|
80
|
-
f"(app '{only_app}' not found in workspace; "
|
|
81
|
-
f"available apps: {available})"
|
|
82
|
-
)
|
|
83
|
-
rows: List[str] = []
|
|
84
|
-
for app in idx.apps:
|
|
85
|
-
if only_app and app.name != only_app:
|
|
86
|
-
continue
|
|
87
|
-
for m in app.models:
|
|
88
|
-
rows.append(f"{app.name}.{m.name}")
|
|
89
|
-
return "\n".join(rows) if rows else "(no models)"
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Model lookup helpers — shared by describe_model / find_relations / cascade
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
90
70
|
|
|
91
71
|
|
|
92
|
-
def _find(idx, ref: str):
|
|
93
|
-
|
|
94
|
-
|
|
72
|
+
def _find(idx: WorkspaceIndex, ref: str):
|
|
73
|
+
"""Delegate to shared ``find_model`` so MCP and CLI lookup semantics stay
|
|
74
|
+
in sync (they previously drifted — hence the shared helper)."""
|
|
95
75
|
return find_model(idx, ref)
|
|
96
76
|
|
|
97
77
|
|
|
98
|
-
def _workspace_user_model(idx):
|
|
78
|
+
def _workspace_user_model(idx: WorkspaceIndex):
|
|
99
79
|
"""Return ``(app_name, model)`` for the workspace's Django User model.
|
|
100
80
|
|
|
101
81
|
Thin wrapper around :func:`models.find_user_model` that preserves the
|
|
@@ -111,7 +91,7 @@ def _workspace_user_model(idx):
|
|
|
111
91
|
return None, user_model
|
|
112
92
|
|
|
113
93
|
|
|
114
|
-
def _rel_matches_target(related_model, target_name, user_model):
|
|
94
|
+
def _rel_matches_target(related_model, target_name, user_model) -> bool:
|
|
115
95
|
"""True iff ``related_model`` string points at ``target_name``. Handles
|
|
116
96
|
``settings.AUTH_USER_MODEL`` via :func:`models.resolve_related_tail`."""
|
|
117
97
|
if not related_model:
|
|
@@ -120,8 +100,51 @@ def _rel_matches_target(related_model, target_name, user_model):
|
|
|
120
100
|
return resolve_related_tail(related_model, user_name) == target_name
|
|
121
101
|
|
|
122
102
|
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Nine tool handlers
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _tool_list_apps(args: Dict[str, Any]) -> str:
|
|
109
|
+
ws = _resolve(args)
|
|
110
|
+
if isinstance(ws, WorkspaceError):
|
|
111
|
+
return _error_json(ws)
|
|
112
|
+
idx = get_index(ws)
|
|
113
|
+
return json.dumps(
|
|
114
|
+
[
|
|
115
|
+
{"name": a.name, "path": a.path, "models": len(a.models)}
|
|
116
|
+
for a in idx.apps
|
|
117
|
+
],
|
|
118
|
+
indent=2,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _tool_list_models(args: Dict[str, Any]) -> str:
|
|
123
|
+
ws = _resolve(args)
|
|
124
|
+
if isinstance(ws, WorkspaceError):
|
|
125
|
+
return _error_json(ws)
|
|
126
|
+
idx = get_index(ws)
|
|
127
|
+
only_app = (args or {}).get("app")
|
|
128
|
+
if only_app and not any(a.name == only_app for a in idx.apps):
|
|
129
|
+
available = ", ".join(sorted(a.name for a in idx.apps)) or "(none)"
|
|
130
|
+
return (
|
|
131
|
+
f"(app '{only_app}' not found in workspace; "
|
|
132
|
+
f"available apps: {available})"
|
|
133
|
+
)
|
|
134
|
+
rows: List[str] = []
|
|
135
|
+
for app in idx.apps:
|
|
136
|
+
if only_app and app.name != only_app:
|
|
137
|
+
continue
|
|
138
|
+
for m in app.models:
|
|
139
|
+
rows.append(f"{app.name}.{m.name}")
|
|
140
|
+
return "\n".join(rows) if rows else "(no models)"
|
|
141
|
+
|
|
142
|
+
|
|
123
143
|
def _tool_describe_model(args: Dict[str, Any]) -> str:
|
|
124
|
-
|
|
144
|
+
ws = _resolve(args)
|
|
145
|
+
if isinstance(ws, WorkspaceError):
|
|
146
|
+
return _error_json(ws)
|
|
147
|
+
idx = get_index(ws)
|
|
125
148
|
ref = (args or {}).get("model", "")
|
|
126
149
|
m = _find(idx, ref)
|
|
127
150
|
if not m:
|
|
@@ -130,7 +153,10 @@ def _tool_describe_model(args: Dict[str, Any]) -> str:
|
|
|
130
153
|
|
|
131
154
|
|
|
132
155
|
def _tool_find_relations(args: Dict[str, Any]) -> str:
|
|
133
|
-
|
|
156
|
+
ws = _resolve(args)
|
|
157
|
+
if isinstance(ws, WorkspaceError):
|
|
158
|
+
return _error_json(ws)
|
|
159
|
+
idx = get_index(ws)
|
|
134
160
|
ref = (args or {}).get("model", "")
|
|
135
161
|
m = _find(idx, ref)
|
|
136
162
|
if not m:
|
|
@@ -167,7 +193,10 @@ def _tool_find_relations(args: Dict[str, Any]) -> str:
|
|
|
167
193
|
|
|
168
194
|
|
|
169
195
|
def _tool_cascade_preview(args: Dict[str, Any]) -> str:
|
|
170
|
-
|
|
196
|
+
ws = _resolve(args)
|
|
197
|
+
if isinstance(ws, WorkspaceError):
|
|
198
|
+
return _error_json(ws)
|
|
199
|
+
idx = get_index(ws)
|
|
171
200
|
app_label = (args or {}).get("app_label", "")
|
|
172
201
|
model_name = (args or {}).get("model_name", "")
|
|
173
202
|
ref = f"{app_label}.{model_name}" if app_label else model_name
|
|
@@ -210,8 +239,11 @@ def _tool_cascade_preview(args: Dict[str, Any]) -> str:
|
|
|
210
239
|
return json.dumps(out, indent=2)
|
|
211
240
|
|
|
212
241
|
|
|
213
|
-
def _tool_er_diagram(
|
|
214
|
-
|
|
242
|
+
def _tool_er_diagram(args: Dict[str, Any]) -> str:
|
|
243
|
+
ws = _resolve(args)
|
|
244
|
+
if isinstance(ws, WorkspaceError):
|
|
245
|
+
return _error_json(ws)
|
|
246
|
+
idx = get_index(ws)
|
|
215
247
|
return _build_mermaid(idx)
|
|
216
248
|
|
|
217
249
|
|
|
@@ -222,10 +254,13 @@ def _tool_describe_migration_dependency(args: Dict[str, Any]) -> str:
|
|
|
222
254
|
Unique to django-orm-lens across the MCP + Django-graph tool ecosystem
|
|
223
255
|
(competitors require a running Django process).
|
|
224
256
|
"""
|
|
257
|
+
ws = _resolve(args)
|
|
258
|
+
if isinstance(ws, WorkspaceError):
|
|
259
|
+
return _error_json(ws)
|
|
225
260
|
app_label = (args or {}).get("app_label", "")
|
|
226
261
|
if not app_label:
|
|
227
262
|
raise ValueError("app_label is required")
|
|
228
|
-
result = describe_migration_dependency(app_label,
|
|
263
|
+
result = describe_migration_dependency(app_label, str(ws))
|
|
229
264
|
return json.dumps(result, indent=2)
|
|
230
265
|
|
|
231
266
|
|
|
@@ -238,16 +273,19 @@ def _tool_suggest_indexes(args: Dict[str, Any]) -> str:
|
|
|
238
273
|
Cross-references with the model's existing ``Meta.indexes`` so
|
|
239
274
|
already-covered combos are not re-proposed.
|
|
240
275
|
"""
|
|
276
|
+
ws = _resolve(args)
|
|
277
|
+
if isinstance(ws, WorkspaceError):
|
|
278
|
+
return _error_json(ws)
|
|
241
279
|
app_label = (args or {}).get("app_label", "")
|
|
242
280
|
model_name = (args or {}).get("model_name", "")
|
|
243
281
|
if not app_label or not model_name:
|
|
244
282
|
raise ValueError("app_label and model_name are required")
|
|
245
|
-
index =
|
|
246
|
-
result = _suggest_indexes(app_label, model_name,
|
|
283
|
+
index = get_index(ws)
|
|
284
|
+
result = _suggest_indexes(app_label, model_name, str(ws), index=index)
|
|
247
285
|
return json.dumps(result, indent=2)
|
|
248
286
|
|
|
249
287
|
|
|
250
|
-
def _tool_signal_graph(
|
|
288
|
+
def _tool_signal_graph(args: Dict[str, Any]) -> str:
|
|
251
289
|
"""Return the sender→signal→handler DAG for the workspace.
|
|
252
290
|
|
|
253
291
|
Static AST parse of every ``@receiver(...)`` decorator plus every
|
|
@@ -255,51 +293,128 @@ def _tool_signal_graph(_args: Dict[str, Any]) -> str:
|
|
|
255
293
|
``.send(...)`` call-sites. Surfaces cross-model side effects and
|
|
256
294
|
orphan handlers whose sender model isn't in the workspace.
|
|
257
295
|
"""
|
|
258
|
-
|
|
259
|
-
|
|
296
|
+
ws = _resolve(args)
|
|
297
|
+
if isinstance(ws, WorkspaceError):
|
|
298
|
+
return _error_json(ws)
|
|
299
|
+
index = get_index(ws)
|
|
300
|
+
result = _signal_graph(str(ws), index=index)
|
|
260
301
|
return json.dumps(result, indent=2)
|
|
261
302
|
|
|
262
303
|
|
|
304
|
+
def _tool_nplusone_scan(args: Dict[str, Any]) -> str:
|
|
305
|
+
"""Static scan for Django ORM N+1 anti-patterns across the workspace.
|
|
306
|
+
|
|
307
|
+
Walks every ``.py`` file, finds ``for ... in <queryset>:`` loops, and
|
|
308
|
+
flags attribute-chain accesses against the loop target that touch a
|
|
309
|
+
related object (FK / O2O / M2M / reverse FK) without a matching
|
|
310
|
+
``select_related`` / ``prefetch_related`` clause on the source queryset.
|
|
311
|
+
Uses the parsed WorkspaceIndex to classify relations; falls back to
|
|
312
|
+
a schema-less heuristic when a model is unknown. Zero-runtime, no DB.
|
|
313
|
+
"""
|
|
314
|
+
ws = _resolve(args)
|
|
315
|
+
if isinstance(ws, WorkspaceError):
|
|
316
|
+
return _error_json(ws)
|
|
317
|
+
index = get_index(ws)
|
|
318
|
+
findings = _scan_for_nplusone(str(ws), index)
|
|
319
|
+
return json.dumps([f.to_dict() for f in findings], indent=2)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
# Tool registry — descriptions ship in tools/list so agents see them
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
_WORKSPACE_HINT = (
|
|
327
|
+
" Optional 'workspace_root' argument overrides the DJANGO_ORM_LENS_ROOT "
|
|
328
|
+
"env var and current directory — pass the absolute path to your Django "
|
|
329
|
+
"project root (the directory containing manage.py)."
|
|
330
|
+
)
|
|
331
|
+
|
|
263
332
|
TOOLS = {
|
|
264
333
|
"list_apps": {
|
|
265
334
|
"handler": _tool_list_apps,
|
|
266
|
-
"description":
|
|
335
|
+
"description": (
|
|
336
|
+
"List every Django app in the workspace with model counts." + _WORKSPACE_HINT
|
|
337
|
+
),
|
|
267
338
|
},
|
|
268
339
|
"list_models": {
|
|
269
340
|
"handler": _tool_list_models,
|
|
270
|
-
"description":
|
|
341
|
+
"description": (
|
|
342
|
+
"Flat list of app.Model. Optional 'app' filter." + _WORKSPACE_HINT
|
|
343
|
+
),
|
|
271
344
|
},
|
|
272
345
|
"describe_model": {
|
|
273
346
|
"handler": _tool_describe_model,
|
|
274
|
-
"description":
|
|
347
|
+
"description": (
|
|
348
|
+
"Full JSON detail for one model: fields, relations, Meta, base "
|
|
349
|
+
"classes, file path." + _WORKSPACE_HINT
|
|
350
|
+
),
|
|
275
351
|
},
|
|
276
352
|
"find_relations": {
|
|
277
353
|
"handler": _tool_find_relations,
|
|
278
|
-
"description":
|
|
354
|
+
"description": (
|
|
355
|
+
"Outbound (this model -> others) and inbound (others -> this) "
|
|
356
|
+
"relations. Inbound entries include on_delete behavior." + _WORKSPACE_HINT
|
|
357
|
+
),
|
|
279
358
|
},
|
|
280
359
|
"cascade_preview": {
|
|
281
360
|
"handler": _tool_cascade_preview,
|
|
282
|
-
"description":
|
|
361
|
+
"description": (
|
|
362
|
+
"Blast radius of deleting a row: inbound relations grouped by "
|
|
363
|
+
"on_delete behavior (cascade_kills, set_null, protected)." + _WORKSPACE_HINT
|
|
364
|
+
),
|
|
283
365
|
},
|
|
284
366
|
"er_diagram": {
|
|
285
367
|
"handler": _tool_er_diagram,
|
|
286
|
-
"description":
|
|
368
|
+
"description": (
|
|
369
|
+
"Emit a Mermaid erDiagram for the whole workspace." + _WORKSPACE_HINT
|
|
370
|
+
),
|
|
287
371
|
},
|
|
288
372
|
"describe_migration_dependency": {
|
|
289
373
|
"handler": _tool_describe_migration_dependency,
|
|
290
|
-
"description":
|
|
374
|
+
"description": (
|
|
375
|
+
"Return per-app migration DAG (dependencies, roots, leaves, "
|
|
376
|
+
"cross-app deps) from static AST parse of migrations/*.py. No "
|
|
377
|
+
"Django boot, no DB. Lets agents debug migration conflicts "
|
|
378
|
+
"safely." + _WORKSPACE_HINT
|
|
379
|
+
),
|
|
291
380
|
},
|
|
292
381
|
"suggest_indexes": {
|
|
293
382
|
"handler": _tool_suggest_indexes,
|
|
294
|
-
"description":
|
|
383
|
+
"description": (
|
|
384
|
+
"Static analysis of every filter/exclude/get/order_by/aggregate "
|
|
385
|
+
"usage of a model across the workspace. Returns field-usage "
|
|
386
|
+
"frequency and proposes Meta.indexes covering entries. "
|
|
387
|
+
"Zero-runtime, no DB, no Django boot." + _WORKSPACE_HINT
|
|
388
|
+
),
|
|
295
389
|
},
|
|
296
390
|
"signal_graph": {
|
|
297
391
|
"handler": _tool_signal_graph,
|
|
298
|
-
"description":
|
|
392
|
+
"description": (
|
|
393
|
+
"Parse every @receiver() decorator and Signal() definition in "
|
|
394
|
+
"the workspace. Returns sender->signal->handler DAG plus custom-"
|
|
395
|
+
"signal send-sites and orphan handlers. Zero-runtime, no DB, no "
|
|
396
|
+
"Django boot." + _WORKSPACE_HINT
|
|
397
|
+
),
|
|
398
|
+
},
|
|
399
|
+
"nplusone_scan": {
|
|
400
|
+
"handler": _tool_nplusone_scan,
|
|
401
|
+
"description": (
|
|
402
|
+
"Static scan for Django ORM N+1 anti-patterns. Walks every .py "
|
|
403
|
+
"file, finds 'for x in <queryset>:' loops that touch related "
|
|
404
|
+
"objects (FK/O2O/M2M/reverse) without matching select_related "
|
|
405
|
+
"or prefetch_related. Returns findings with file, line, "
|
|
406
|
+
"queryset_var, accessed relations, suggested_fix, and confidence "
|
|
407
|
+
"(high/medium). Zero-runtime, no DB, no Django boot." + _WORKSPACE_HINT
|
|
408
|
+
),
|
|
299
409
|
},
|
|
300
410
|
}
|
|
301
411
|
|
|
302
412
|
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
# Server bootstrap — lazy-imports mcp so the base install stays zero-dep
|
|
415
|
+
# ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
|
|
303
418
|
def main() -> int:
|
|
304
419
|
"""Start the MCP stdio server. Lazy-imports the ``mcp`` package."""
|
|
305
420
|
try:
|
|
@@ -319,49 +434,84 @@ def main() -> int:
|
|
|
319
434
|
# surface server stderr (Cursor, Aider, mcp-inspector) show this once
|
|
320
435
|
# per session; silent clients ignore it. Zero effect on tool protocol.
|
|
321
436
|
print(
|
|
322
|
-
"django-orm-lens MCP ready
|
|
437
|
+
"django-orm-lens MCP ready - "
|
|
323
438
|
"if it saves your agent time, a star helps: "
|
|
324
439
|
"https://github.com/FROWNINGdev/django-orm-lens",
|
|
325
440
|
file=sys.stderr,
|
|
326
441
|
)
|
|
327
442
|
|
|
328
443
|
def _register(name: str, description: str, handler):
|
|
444
|
+
"""Register one tool.
|
|
445
|
+
|
|
446
|
+
Every tool declares ``workspace_root: str = ""`` in its Python
|
|
447
|
+
signature so FastMCP includes it in the ``inputSchema`` served to
|
|
448
|
+
``tools/list``. Agents that pass the argument reach the handler with
|
|
449
|
+
the value; agents that omit it fall through to env var / cwd via the
|
|
450
|
+
resolution chain in :mod:`django_orm_lens.workspace`.
|
|
451
|
+
"""
|
|
329
452
|
if name == "list_apps":
|
|
330
453
|
@server.tool(name=name, description=description)
|
|
331
|
-
def list_apps() -> str:
|
|
332
|
-
return handler({})
|
|
454
|
+
def list_apps(workspace_root: str = "") -> str:
|
|
455
|
+
return handler({"workspace_root": workspace_root})
|
|
333
456
|
elif name == "list_models":
|
|
334
457
|
@server.tool(name=name, description=description)
|
|
335
|
-
def list_models(app: str = "") -> str:
|
|
336
|
-
|
|
458
|
+
def list_models(app: str = "", workspace_root: str = "") -> str:
|
|
459
|
+
args: Dict[str, Any] = {"workspace_root": workspace_root}
|
|
460
|
+
if app:
|
|
461
|
+
args["app"] = app
|
|
462
|
+
return handler(args)
|
|
337
463
|
elif name == "describe_model":
|
|
338
464
|
@server.tool(name=name, description=description)
|
|
339
|
-
def describe_model(model: str) -> str:
|
|
340
|
-
return handler({"model": model})
|
|
465
|
+
def describe_model(model: str, workspace_root: str = "") -> str:
|
|
466
|
+
return handler({"model": model, "workspace_root": workspace_root})
|
|
341
467
|
elif name == "find_relations":
|
|
342
468
|
@server.tool(name=name, description=description)
|
|
343
|
-
def find_relations(model: str) -> str:
|
|
344
|
-
return handler({"model": model})
|
|
469
|
+
def find_relations(model: str, workspace_root: str = "") -> str:
|
|
470
|
+
return handler({"model": model, "workspace_root": workspace_root})
|
|
345
471
|
elif name == "cascade_preview":
|
|
346
472
|
@server.tool(name=name, description=description)
|
|
347
|
-
def cascade_preview(
|
|
348
|
-
|
|
473
|
+
def cascade_preview(
|
|
474
|
+
app_label: str, model_name: str, workspace_root: str = ""
|
|
475
|
+
) -> str:
|
|
476
|
+
return handler(
|
|
477
|
+
{
|
|
478
|
+
"app_label": app_label,
|
|
479
|
+
"model_name": model_name,
|
|
480
|
+
"workspace_root": workspace_root,
|
|
481
|
+
}
|
|
482
|
+
)
|
|
349
483
|
elif name == "er_diagram":
|
|
350
484
|
@server.tool(name=name, description=description)
|
|
351
|
-
def er_diagram() -> str:
|
|
352
|
-
return handler({})
|
|
485
|
+
def er_diagram(workspace_root: str = "") -> str:
|
|
486
|
+
return handler({"workspace_root": workspace_root})
|
|
353
487
|
elif name == "describe_migration_dependency":
|
|
354
488
|
@server.tool(name=name, description=description)
|
|
355
|
-
def describe_migration_dependency(
|
|
356
|
-
|
|
489
|
+
def describe_migration_dependency(
|
|
490
|
+
app_label: str, workspace_root: str = ""
|
|
491
|
+
) -> str:
|
|
492
|
+
return handler(
|
|
493
|
+
{"app_label": app_label, "workspace_root": workspace_root}
|
|
494
|
+
)
|
|
357
495
|
elif name == "suggest_indexes":
|
|
358
496
|
@server.tool(name=name, description=description)
|
|
359
|
-
def suggest_indexes(
|
|
360
|
-
|
|
497
|
+
def suggest_indexes(
|
|
498
|
+
app_label: str, model_name: str, workspace_root: str = ""
|
|
499
|
+
) -> str:
|
|
500
|
+
return handler(
|
|
501
|
+
{
|
|
502
|
+
"app_label": app_label,
|
|
503
|
+
"model_name": model_name,
|
|
504
|
+
"workspace_root": workspace_root,
|
|
505
|
+
}
|
|
506
|
+
)
|
|
361
507
|
elif name == "signal_graph":
|
|
362
508
|
@server.tool(name=name, description=description)
|
|
363
|
-
def signal_graph() -> str:
|
|
364
|
-
return handler({})
|
|
509
|
+
def signal_graph(workspace_root: str = "") -> str:
|
|
510
|
+
return handler({"workspace_root": workspace_root})
|
|
511
|
+
elif name == "nplusone_scan":
|
|
512
|
+
@server.tool(name=name, description=description)
|
|
513
|
+
def nplusone_scan(workspace_root: str = "") -> str:
|
|
514
|
+
return handler({"workspace_root": workspace_root})
|
|
365
515
|
|
|
366
516
|
for name, spec in TOOLS.items():
|
|
367
517
|
_register(name, spec["description"], spec["handler"])
|