django-orm-lens 1.3.0__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.3.0 → django_orm_lens-1.4.0}/PKG-INFO +1 -1
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/mcp_server.py +34 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/PKG-INFO +1 -1
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/pyproject.toml +1 -1
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_mcp_server.py +34 -1
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/README.md +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/__init__.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/cli.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/diff.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/formatters.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/migrations_parser.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/models.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/parser.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/py.typed +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/query_analyzer.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/signals_parser.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/workspace.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/SOURCES.txt +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/dependency_links.txt +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/entry_points.txt +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/requires.txt +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/top_level.txt +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/setup.cfg +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_abstract_and_auth_user.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_aliased_module_and_third_party_fields.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_bom_prefix.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_cli_hint.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_cli_subcommands_smoke.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_cli_verbose.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_cross_module_bases.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_diff_command.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_golden_fixtures.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_kwarg_order_and_auth_user.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_list_command.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_mcp_workspace.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_migration_risk.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_migrations_parser.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_nplusone_detector.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_parity.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_pep526_type_hints.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_pep695_generic_classes.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_query_analyzer.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_scan_root.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_signals_parser.py +0 -0
- {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_tab_indented_models.py +0 -0
|
@@ -37,6 +37,7 @@ from typing import Any, Dict, List, Union
|
|
|
37
37
|
from .cli import _build_mermaid
|
|
38
38
|
from .migrations_parser import describe_migration_dependency
|
|
39
39
|
from .models import WorkspaceIndex, find_model, find_user_model, resolve_related_tail
|
|
40
|
+
from .query_analyzer import scan_for_nplusone as _scan_for_nplusone
|
|
40
41
|
from .query_analyzer import suggest_indexes as _suggest_indexes
|
|
41
42
|
from .signals_parser import signal_graph as _signal_graph
|
|
42
43
|
from .workspace import WorkspaceError, get_index, resolve_workspace
|
|
@@ -300,6 +301,24 @@ def _tool_signal_graph(args: Dict[str, Any]) -> str:
|
|
|
300
301
|
return json.dumps(result, indent=2)
|
|
301
302
|
|
|
302
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
|
+
|
|
303
322
|
# ---------------------------------------------------------------------------
|
|
304
323
|
# Tool registry — descriptions ship in tools/list so agents see them
|
|
305
324
|
# ---------------------------------------------------------------------------
|
|
@@ -377,6 +396,17 @@ TOOLS = {
|
|
|
377
396
|
"Django boot." + _WORKSPACE_HINT
|
|
378
397
|
),
|
|
379
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
|
+
),
|
|
409
|
+
},
|
|
380
410
|
}
|
|
381
411
|
|
|
382
412
|
|
|
@@ -478,6 +508,10 @@ def main() -> int:
|
|
|
478
508
|
@server.tool(name=name, description=description)
|
|
479
509
|
def signal_graph(workspace_root: str = "") -> str:
|
|
480
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})
|
|
481
515
|
|
|
482
516
|
for name, spec in TOOLS.items():
|
|
483
517
|
_register(name, spec["description"], spec["handler"])
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "django-orm-lens"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.4.0"
|
|
8
8
|
description = "Static analysis + MCP server for Django models. Sidebar tree, ER diagrams, and JSON output for terminals and AI coding agents."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -34,6 +34,7 @@ from django_orm_lens.mcp_server import (
|
|
|
34
34
|
_tool_find_relations,
|
|
35
35
|
_tool_list_apps,
|
|
36
36
|
_tool_list_models,
|
|
37
|
+
_tool_nplusone_scan,
|
|
37
38
|
_tool_signal_graph,
|
|
38
39
|
_tool_suggest_indexes,
|
|
39
40
|
)
|
|
@@ -66,7 +67,7 @@ def _write_django_project(root: Path) -> None:
|
|
|
66
67
|
class ToolsRegistryTest(unittest.TestCase):
|
|
67
68
|
"""The ``TOOLS`` dict is what feeds ``tools/list`` — schema-level asserts."""
|
|
68
69
|
|
|
69
|
-
def
|
|
70
|
+
def test_all_ten_tools_registered(self) -> None:
|
|
70
71
|
expected = {
|
|
71
72
|
"list_apps",
|
|
72
73
|
"list_models",
|
|
@@ -77,6 +78,7 @@ class ToolsRegistryTest(unittest.TestCase):
|
|
|
77
78
|
"describe_migration_dependency",
|
|
78
79
|
"suggest_indexes",
|
|
79
80
|
"signal_graph",
|
|
81
|
+
"nplusone_scan",
|
|
80
82
|
}
|
|
81
83
|
self.assertEqual(set(TOOLS), expected)
|
|
82
84
|
|
|
@@ -213,6 +215,9 @@ class ErrorEnvelopeTest(unittest.TestCase):
|
|
|
213
215
|
def test_signal_graph_envelope(self) -> None:
|
|
214
216
|
self._assert_envelope(_tool_signal_graph({"workspace_root": str(self.tmp)}))
|
|
215
217
|
|
|
218
|
+
def test_nplusone_scan_envelope(self) -> None:
|
|
219
|
+
self._assert_envelope(_tool_nplusone_scan({"workspace_root": str(self.tmp)}))
|
|
220
|
+
|
|
216
221
|
def test_missing_workspace_returns_not_found_envelope(self) -> None:
|
|
217
222
|
raw = _tool_list_apps({"workspace_root": str(self.tmp / "no-such-dir")})
|
|
218
223
|
self._assert_envelope(raw, expected_code="WORKSPACE_NOT_FOUND")
|
|
@@ -266,6 +271,34 @@ class HappyPathTest(unittest.TestCase):
|
|
|
266
271
|
self.assertIsInstance(payload, list)
|
|
267
272
|
self.assertEqual(len(payload), 1)
|
|
268
273
|
|
|
274
|
+
def test_nplusone_scan_flags_intentional_loop(self) -> None:
|
|
275
|
+
# Plant a synthetic views.py with a textbook N+1 pattern —
|
|
276
|
+
# for book in Book.objects.all(): print(book.author.name) — and
|
|
277
|
+
# confirm the tool surfaces at least one finding with the shape
|
|
278
|
+
# the CHANGELOG documents (file, line, queryset_var, accessed,
|
|
279
|
+
# suggested_fix, confidence).
|
|
280
|
+
views = self.tmp / "myapp" / "views.py"
|
|
281
|
+
views.write_text(
|
|
282
|
+
"from myapp.models import Book\n"
|
|
283
|
+
"def index(request):\n"
|
|
284
|
+
" books = Book.objects.all()\n"
|
|
285
|
+
" for book in books:\n"
|
|
286
|
+
" print(book.author.name)\n",
|
|
287
|
+
encoding="utf-8",
|
|
288
|
+
)
|
|
289
|
+
raw = _tool_nplusone_scan({"workspace_root": str(self.tmp)})
|
|
290
|
+
payload = json.loads(raw)
|
|
291
|
+
self.assertIsInstance(payload, list, f"expected list, got {type(payload).__name__}: {payload!r}")
|
|
292
|
+
self.assertGreaterEqual(
|
|
293
|
+
len(payload), 1, f"no findings emitted for a textbook N+1 pattern: {payload!r}"
|
|
294
|
+
)
|
|
295
|
+
f = payload[0]
|
|
296
|
+
for key in ("file", "line", "loop_var", "queryset_var", "accessed",
|
|
297
|
+
"suggested_fix", "confidence"):
|
|
298
|
+
self.assertIn(key, f, f"finding missing key {key!r}: {f!r}")
|
|
299
|
+
self.assertIn(f["confidence"], ("high", "medium"))
|
|
300
|
+
self.assertIn("select_related", f["suggested_fix"])
|
|
301
|
+
|
|
269
302
|
|
|
270
303
|
class SentinelDataclassTest(unittest.TestCase):
|
|
271
304
|
"""The WorkspaceError sentinel is what powers the envelope. Keep the
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_aliased_module_and_third_party_fields.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|