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.
Files changed (45) hide show
  1. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/PKG-INFO +1 -1
  2. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/mcp_server.py +34 -0
  3. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/PKG-INFO +1 -1
  4. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/pyproject.toml +1 -1
  5. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_mcp_server.py +34 -1
  6. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/README.md +0 -0
  7. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/__init__.py +0 -0
  8. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/cli.py +0 -0
  9. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/diff.py +0 -0
  10. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/formatters.py +0 -0
  11. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/migrations_parser.py +0 -0
  12. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/models.py +0 -0
  13. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/parser.py +0 -0
  14. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/py.typed +0 -0
  15. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/query_analyzer.py +0 -0
  16. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/signals_parser.py +0 -0
  17. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens/workspace.py +0 -0
  18. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/SOURCES.txt +0 -0
  19. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/dependency_links.txt +0 -0
  20. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/entry_points.txt +0 -0
  21. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/requires.txt +0 -0
  22. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/django_orm_lens.egg-info/top_level.txt +0 -0
  23. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/setup.cfg +0 -0
  24. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_abstract_and_auth_user.py +0 -0
  25. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_aliased_module_and_third_party_fields.py +0 -0
  26. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_bom_prefix.py +0 -0
  27. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_cli_hint.py +0 -0
  28. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_cli_subcommands_smoke.py +0 -0
  29. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_cli_verbose.py +0 -0
  30. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_cross_module_bases.py +0 -0
  31. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_diff_command.py +0 -0
  32. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_golden_fixtures.py +0 -0
  33. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_kwarg_order_and_auth_user.py +0 -0
  34. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_list_command.py +0 -0
  35. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_mcp_workspace.py +0 -0
  36. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_migration_risk.py +0 -0
  37. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_migrations_parser.py +0 -0
  38. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_nplusone_detector.py +0 -0
  39. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_parity.py +0 -0
  40. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_pep526_type_hints.py +0 -0
  41. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_pep695_generic_classes.py +0 -0
  42. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_query_analyzer.py +0 -0
  43. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_scan_root.py +0 -0
  44. {django_orm_lens-1.3.0 → django_orm_lens-1.4.0}/tests/test_signals_parser.py +0 -0
  45. {django_orm_lens-1.3.0 → 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.0
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
@@ -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"])
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-orm-lens
3
- Version: 1.3.0
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "django-orm-lens"
7
- version = "1.3.0"
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 test_all_nine_tools_registered(self) -> None:
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