django-orm-lens 1.2.2__tar.gz → 1.2.4__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.2 → django_orm_lens-1.2.4}/PKG-INFO +1 -1
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/django_orm_lens/parser.py +19 -5
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/django_orm_lens.egg-info/PKG-INFO +1 -1
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/django_orm_lens.egg-info/SOURCES.txt +2 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/pyproject.toml +1 -1
- django_orm_lens-1.2.4/tests/test_pep526_type_hints.py +97 -0
- django_orm_lens-1.2.4/tests/test_pep695_generic_classes.py +71 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/README.md +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/django_orm_lens/__init__.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/django_orm_lens/cli.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/django_orm_lens/diff.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/django_orm_lens/formatters.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/django_orm_lens/mcp_server.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/django_orm_lens/migrations_parser.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/django_orm_lens/models.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/django_orm_lens/py.typed +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/django_orm_lens/query_analyzer.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/django_orm_lens/signals_parser.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/django_orm_lens.egg-info/dependency_links.txt +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/django_orm_lens.egg-info/entry_points.txt +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/django_orm_lens.egg-info/requires.txt +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/django_orm_lens.egg-info/top_level.txt +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/setup.cfg +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/tests/test_abstract_and_auth_user.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/tests/test_cli_hint.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/tests/test_cli_subcommands_smoke.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/tests/test_cli_verbose.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/tests/test_cross_module_bases.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/tests/test_diff_command.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/tests/test_golden_fixtures.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/tests/test_kwarg_order_and_auth_user.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/tests/test_list_command.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/tests/test_migration_risk.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/tests/test_migrations_parser.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/tests/test_nplusone_detector.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/tests/test_parity.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/tests/test_query_analyzer.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/tests/test_scan_root.py +0 -0
- {django_orm_lens-1.2.2 → django_orm_lens-1.2.4}/tests/test_signals_parser.py +0 -0
|
@@ -37,8 +37,17 @@ from .models import (
|
|
|
37
37
|
|
|
38
38
|
RELATION_TYPES: Tuple[str, ...] = ("ForeignKey", "ManyToManyField", "OneToOneField")
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
# ``(?:\s*\[[^\]]*\])?`` — optional PEP-695 generic type parameter list
|
|
41
|
+
# introduced in Python 3.12, e.g. ``class Container[T](models.Model):``.
|
|
42
|
+
# Only handles a single-line, non-nested bracket group — nested brackets in
|
|
43
|
+
# class-header generics are vanishingly rare and don't warrant a full
|
|
44
|
+
# bracket matcher.
|
|
45
|
+
CLASS_RE = re.compile(
|
|
46
|
+
r"^class\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*\[[^\]]*\])?\s*\(([^)]*)\)\s*:"
|
|
47
|
+
)
|
|
48
|
+
CLASS_START_RE = re.compile(
|
|
49
|
+
r"^class\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*\[[^\]]*\])?\s*\("
|
|
50
|
+
)
|
|
42
51
|
|
|
43
52
|
NON_MODEL_TAIL = re.compile(
|
|
44
53
|
r"^(ModelAdmin|ModelForm|ModelSerializer|ModelChoiceField|"
|
|
@@ -121,16 +130,21 @@ def _build_body_regexes(indent: int):
|
|
|
121
130
|
return cached
|
|
122
131
|
w = r"\s{" + str(indent) + r"}"
|
|
123
132
|
w2 = r"\s{" + str(indent * 2) + r"}"
|
|
133
|
+
# ``(?:\s*:[^=]+)?`` — optional PEP-526 type annotation between the field
|
|
134
|
+
# name and ``=``, e.g. ``jti: CharField[str] = models.CharField(...)``.
|
|
135
|
+
# ``[^=]+`` is safe because ``=`` never appears inside a type expression
|
|
136
|
+
# (subscripts, unions, dotted refs, and generics all use other punctuation).
|
|
137
|
+
ann = r"(?:\s*:[^=]+)?"
|
|
124
138
|
result = {
|
|
125
139
|
"FIELD_RE": re.compile(
|
|
126
|
-
rf"^{w}([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*models\.([A-Za-z_][A-Za-z0-9_]*)\s*\("
|
|
140
|
+
rf"^{w}([a-zA-Z_][a-zA-Z0-9_]*){ann}\s*=\s*models\.([A-Za-z_][A-Za-z0-9_]*)\s*\("
|
|
127
141
|
),
|
|
128
142
|
"BARE_FIELD_RE": re.compile(
|
|
129
|
-
rf"^{w}([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*({BARE_FIELD_TYPES})\s*\("
|
|
143
|
+
rf"^{w}([a-zA-Z_][a-zA-Z0-9_]*){ann}\s*=\s*({BARE_FIELD_TYPES})\s*\("
|
|
130
144
|
),
|
|
131
145
|
"META_START_RE": re.compile(rf"^{w}class\s+Meta\s*(?:\([^)]*\))?\s*:"),
|
|
132
146
|
"META_ITEM_RE": re.compile(
|
|
133
|
-
rf"^{w2}([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*(.+?)\s*(#.*)?$"
|
|
147
|
+
rf"^{w2}([a-zA-Z_][a-zA-Z0-9_]*){ann}\s*=\s*(.+?)\s*(#.*)?$"
|
|
134
148
|
),
|
|
135
149
|
"META_BODY_RE": re.compile(r"^\s{" + str(indent * 2) + r",}"),
|
|
136
150
|
}
|
|
@@ -30,6 +30,8 @@ tests/test_migration_risk.py
|
|
|
30
30
|
tests/test_migrations_parser.py
|
|
31
31
|
tests/test_nplusone_detector.py
|
|
32
32
|
tests/test_parity.py
|
|
33
|
+
tests/test_pep526_type_hints.py
|
|
34
|
+
tests/test_pep695_generic_classes.py
|
|
33
35
|
tests/test_query_analyzer.py
|
|
34
36
|
tests/test_scan_root.py
|
|
35
37
|
tests/test_signals_parser.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "django-orm-lens"
|
|
7
|
-
version = "1.2.
|
|
7
|
+
version = "1.2.4"
|
|
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"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Regression test for issue #25 (jsabater):
|
|
2
|
+
|
|
3
|
+
Fields annotated with PEP-526 type hints — the modern Django Ninja / typed
|
|
4
|
+
Django style — used to disappear from the parsed model because the field
|
|
5
|
+
regex expected ``name = models.X(`` with no annotation between the name
|
|
6
|
+
and ``=``. When user code writes:
|
|
7
|
+
|
|
8
|
+
jti: CharField[str] = models.CharField(max_length=32, unique=True)
|
|
9
|
+
|
|
10
|
+
the pattern failed silently: no field parsed, sidebar tree empty, ER
|
|
11
|
+
diagram empty entity. Fixed by allowing an optional ``: <type>`` group
|
|
12
|
+
between the field name and the ``=`` in both ``FIELD_RE`` and
|
|
13
|
+
``BARE_FIELD_RE`` (and ``META_ITEM_RE`` for consistency).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import unittest
|
|
19
|
+
|
|
20
|
+
from django_orm_lens.parser import parse_models_file
|
|
21
|
+
|
|
22
|
+
SAMPLE = '''
|
|
23
|
+
from django.db import models
|
|
24
|
+
from django.db.models import CharField, IntegerField
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RevokedToken(models.Model):
|
|
28
|
+
"""A revoked JWT, keyed by its jti claim (the token denylist)."""
|
|
29
|
+
|
|
30
|
+
# Exact snippet from the bug report — models.-prefixed with generic type hint.
|
|
31
|
+
jti: CharField[str] = models.CharField(max_length=32, unique=True)
|
|
32
|
+
|
|
33
|
+
# Bare import form + generic type hint.
|
|
34
|
+
revoked_count: IntegerField[int] = IntegerField(default=0)
|
|
35
|
+
|
|
36
|
+
# Simple annotation (no generic).
|
|
37
|
+
label: str = models.CharField(max_length=64)
|
|
38
|
+
|
|
39
|
+
# Backwards-compatible untyped form still works.
|
|
40
|
+
note = models.TextField(blank=True)
|
|
41
|
+
'''
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Pep526TypeAnnotationTest(unittest.TestCase):
|
|
45
|
+
def setUp(self) -> None:
|
|
46
|
+
self.models = parse_models_file("auth/models.py", SAMPLE)
|
|
47
|
+
self.assertEqual(len(self.models), 1, "RevokedToken class parsed")
|
|
48
|
+
self.token = self.models[0]
|
|
49
|
+
|
|
50
|
+
def test_all_four_fields_present(self) -> None:
|
|
51
|
+
names = [f.name for f in self.token.fields]
|
|
52
|
+
# The bug: type-hinted fields vanished, only untyped `note` survived.
|
|
53
|
+
self.assertEqual(names, ["jti", "revoked_count", "label", "note"])
|
|
54
|
+
|
|
55
|
+
def test_type_hinted_field_type_extracted(self) -> None:
|
|
56
|
+
# The field type comes from `models.CharField(...)`, not the annotation.
|
|
57
|
+
jti = next(f for f in self.token.fields if f.name == "jti")
|
|
58
|
+
self.assertEqual(jti.type, "CharField")
|
|
59
|
+
|
|
60
|
+
def test_bare_import_form_with_annotation(self) -> None:
|
|
61
|
+
rc = next(f for f in self.token.fields if f.name == "revoked_count")
|
|
62
|
+
self.assertEqual(rc.type, "IntegerField")
|
|
63
|
+
|
|
64
|
+
def test_simple_annotation(self) -> None:
|
|
65
|
+
label = next(f for f in self.token.fields if f.name == "label")
|
|
66
|
+
self.assertEqual(label.type, "CharField")
|
|
67
|
+
|
|
68
|
+
def test_untyped_form_still_works(self) -> None:
|
|
69
|
+
note = next(f for f in self.token.fields if f.name == "note")
|
|
70
|
+
self.assertEqual(note.type, "TextField")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
META_SAMPLE = '''
|
|
74
|
+
from django.db import models
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Item(models.Model):
|
|
78
|
+
# Type-annotated field followed by Meta with type-annotated attribute.
|
|
79
|
+
title: str = models.CharField(max_length=100)
|
|
80
|
+
|
|
81
|
+
class Meta:
|
|
82
|
+
ordering: list[str] = ["title"]
|
|
83
|
+
'''
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Pep526MetaAnnotationTest(unittest.TestCase):
|
|
87
|
+
def test_meta_item_with_annotation(self) -> None:
|
|
88
|
+
models = parse_models_file("shop/models.py", META_SAMPLE)
|
|
89
|
+
item = models[0]
|
|
90
|
+
# The field itself must still be visible.
|
|
91
|
+
self.assertEqual([f.name for f in item.fields], ["title"])
|
|
92
|
+
# And the Meta annotation must not confuse the Meta reader.
|
|
93
|
+
self.assertEqual(item.meta.get("ordering"), '["title"]')
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
unittest.main()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Regression: PEP-695 generic classes (Python 3.12+) must parse.
|
|
2
|
+
|
|
3
|
+
Before this fix, ``class Container[T](models.Model):`` failed the
|
|
4
|
+
``CLASS_RE`` pattern entirely — the parser walked past it and reported
|
|
5
|
+
zero models for the whole file. Users on Python 3.12+ writing modern
|
|
6
|
+
typed Django models saw an empty sidebar/ER diagram with no error.
|
|
7
|
+
|
|
8
|
+
The fix adds an optional ``(?:\\s*\\[[^\\]]*\\])?`` group between the
|
|
9
|
+
class name and the opening ``(`` in both ``CLASS_RE`` and
|
|
10
|
+
``CLASS_START_RE``. Covers single/multi-param, bounded, variadic, and
|
|
11
|
+
paramspec generics — the four common PEP-695 shapes.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import unittest
|
|
17
|
+
|
|
18
|
+
from django_orm_lens.parser import parse_models_file
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Pep695GenericClassTest(unittest.TestCase):
|
|
22
|
+
def test_single_param_generic(self) -> None:
|
|
23
|
+
src = (
|
|
24
|
+
"class Container[T](models.Model):\n"
|
|
25
|
+
" label = models.CharField(max_length=50)\n"
|
|
26
|
+
)
|
|
27
|
+
models = parse_models_file("t.py", src)
|
|
28
|
+
self.assertEqual(len(models), 1)
|
|
29
|
+
self.assertEqual(models[0].name, "Container")
|
|
30
|
+
self.assertEqual([f.name for f in models[0].fields], ["label"])
|
|
31
|
+
|
|
32
|
+
def test_multi_param_generic(self) -> None:
|
|
33
|
+
src = (
|
|
34
|
+
"class Pair[K, V](models.Model):\n"
|
|
35
|
+
" key = models.CharField(max_length=10)\n"
|
|
36
|
+
" val = models.CharField(max_length=10)\n"
|
|
37
|
+
)
|
|
38
|
+
models = parse_models_file("t.py", src)
|
|
39
|
+
self.assertEqual(len(models), 1)
|
|
40
|
+
self.assertEqual([f.name for f in models[0].fields], ["key", "val"])
|
|
41
|
+
|
|
42
|
+
def test_bounded_generic(self) -> None:
|
|
43
|
+
src = (
|
|
44
|
+
"class Bounded[T: str](models.Model):\n"
|
|
45
|
+
" x = models.CharField(max_length=5)\n"
|
|
46
|
+
)
|
|
47
|
+
models = parse_models_file("t.py", src)
|
|
48
|
+
self.assertEqual(len(models), 1)
|
|
49
|
+
|
|
50
|
+
def test_variadic_and_paramspec(self) -> None:
|
|
51
|
+
src = (
|
|
52
|
+
"class Variadic[*Ts, **P](models.Model):\n"
|
|
53
|
+
" x = models.CharField(max_length=5)\n"
|
|
54
|
+
)
|
|
55
|
+
models = parse_models_file("t.py", src)
|
|
56
|
+
self.assertEqual(len(models), 1)
|
|
57
|
+
|
|
58
|
+
def test_non_generic_still_works(self) -> None:
|
|
59
|
+
# Backward-compat guard — the ``[T]`` group is optional, plain
|
|
60
|
+
# class headers must still parse identically.
|
|
61
|
+
src = (
|
|
62
|
+
"class Plain(models.Model):\n"
|
|
63
|
+
" x = models.CharField(max_length=5)\n"
|
|
64
|
+
)
|
|
65
|
+
models = parse_models_file("t.py", src)
|
|
66
|
+
self.assertEqual(len(models), 1)
|
|
67
|
+
self.assertEqual(models[0].name, "Plain")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
unittest.main()
|
|
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.2.2 → django_orm_lens-1.2.4}/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
|
|
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
|