Navegador

feat: Django framework enrichment — views, models, URLs, serializers, tasks, admin Closes #10

lmata 2026-03-23 05:21 trunk
Commit 396dc34f3bbf11f0ba0a618bacb1ff0aff2c1f2ff71a300e55c239896dc134ae
--- a/navegador/enrichment/django.py
+++ b/navegador/enrichment/django.py
@@ -0,0 +1,99 @@
1
+"""
2
+DjangoEnricher — post-ingestion enrichment for Django codebases.
3
+
4
+Promotes generic Function/Class nodes to Django-specific semantic types:
5
+ - View functions in views.py files
6
+ - Model classes that inherit from Model
7
+ - URLPattern functions in urls.py files
8
+ - Serializer classes in serializers.py files
9
+ - Task functions decorated with @task or in tasks.py
10
+ - Admin classes in admin.py files
11
+"""
12
+
13
+from navegador.enrichment.base import EnrichmentResult, FrameworkEnricher
14
+
15
+
16
+class DjangoEnricher(FrameworkEnricher):
17
+ """Enriches a navegador graph with Django framework semantics."""
18
+
19
+ @property
20
+ def framework_name(self) -> str:
21
+ return "django"
22
+
23
+ @property
24
+ def detection_patterns(self) -> list[str]:
25
+ return ["manage.py", "django..py"]
26
+
27
+ def enrich(self) -> EnrichmentResult:
28
+ result = EnrichmentResult()
29
+
30
+ # ── Views ────────────────────────────────────────────────────────────
31
+ rows = self._query_rows(
32
+ "MATCH (n:Function) WHERE n.file_path CONTAINS 'views.py' "
33
+ "RETURN n.name AS name, n.file_path AS file_path"
34
+ )
35
+ for name, file_path in rows:
36
+ self._promote_node(name, file_path, "View")
37
+ result.patterns_found["views"] = len(rows)
38
+ result.promoted += len(rows)
39
+
40
+ # ── Models ───────────────────────────────────────────────────────────
41
+ rows = self._query_rows(
42
+ "MATCH (n:Class)-[:INHERITS]->(b) WHERE b.name CONTAINS 'Model' "
43
+ "RETURN n.name AS name, n.file_path AS file_path"
44
+ )
45
+ for name, file_path in rows:
46
+ self._promote_node(name, file_path, "Model")
47
+ result.patterns_found["models"] = len(rows)
48
+ result.promoted += len(rows)
49
+
50
+ # ── URL patterns ─────────────────────────────────────────────────────
51
+ rows = self._query_rows(
52
+ "MATCH (n:Function) WHERE n.file_path CONTAINS 'urls.py' "
53
+ "RETURN n.name AS name, n.file_path AS file_path"
54
+ )
55
+ for name, file_path in rows:
56
+ self._promote_node(name, file_path, "URLPattern")
57
+ result.patterns_found["url_patterns"] = len(rows)
58
+ result.promoted += len(rows)
59
+
60
+ # ── Serializers ──────────────────────────────────────────────────────
61
+ rows = self._query_rows(
62
+ "MATCH (n:Class) WHERE n.file_path CONTAINS 'serializers.py' "
63
+ "RETURN n.name AS name, n.file_path AS file_path"
64
+ )
65
+ for name, file_path in rows:
66
+ self._promote_node(name, file_path, "Serializer")
67
+ result.patterns_found["serializers"] = len(rows)
68
+ result.promoted += len(rows)
69
+
70
+ # ── Tasks (decorator or tasks.py) ─────────────────────────────────
71
+ rows = self._query_rows(
72
+ "MATCH (n:Function) WHERE n.file_path CONTAINS 'tasks.py' "
73
+ "OR (n.decorators IS NOT NULL AND n.decorators CONTAINS 'task') "
74
+ "RETURN n.name AS name, n.file_path AS file_path"
75
+ )
76
+ for name, file_path in rows:
77
+ self._promote_node(name, file_path, "Task")
78
+ result.patterns_found["tasks"] = len(rows)
79
+ result.promoted += len(rows)
80
+
81
+ # ── Admin ─────────────────────────────────────────────────────────
82
+ rows = self._query_rows(
83
+ "MATCH (n:Class) WHERE n.file_path CONTAINS 'admin.py' "
84
+ "RETURN n.name AS name, n.file_path AS file_path"
85
+ )
86
+ for name, file_path in rows:
87
+ self._promote_node(name, file_path, "Admin")
88
+ result.patterns_found["admin"] = len(rows)
89
+ result.promoted += len(rows)
90
+
91
+ return result
92
+
93
+ # ── Internal helpers ──────────────────────────────────────────────────────
94
+
95
+ def _query_rows(self, cypher: str) -> list[tuple[str, str]]:
96
+ """Run a Cypher query and return (name, file_path) pairs."""
97
+ query_result = self.store.query(cypher)
98
+ rows = query_result.result_set or []
99
+ return [(row[0], row[1]) for row in rows]
--- a/navegador/enrichment/django.py
+++ b/navegador/enrichment/django.py
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/navegador/enrichment/django.py
+++ b/navegador/enrichment/django.py
@@ -0,0 +1,99 @@
1 """
2 DjangoEnricher — post-ingestion enrichment for Django codebases.
3
4 Promotes generic Function/Class nodes to Django-specific semantic types:
5 - View functions in views.py files
6 - Model classes that inherit from Model
7 - URLPattern functions in urls.py files
8 - Serializer classes in serializers.py files
9 - Task functions decorated with @task or in tasks.py
10 - Admin classes in admin.py files
11 """
12
13 from navegador.enrichment.base import EnrichmentResult, FrameworkEnricher
14
15
16 class DjangoEnricher(FrameworkEnricher):
17 """Enriches a navegador graph with Django framework semantics."""
18
19 @property
20 def framework_name(self) -> str:
21 return "django"
22
23 @property
24 def detection_patterns(self) -> list[str]:
25 return ["manage.py", "django..py"]
26
27 def enrich(self) -> EnrichmentResult:
28 result = EnrichmentResult()
29
30 # ── Views ────────────────────────────────────────────────────────────
31 rows = self._query_rows(
32 "MATCH (n:Function) WHERE n.file_path CONTAINS 'views.py' "
33 "RETURN n.name AS name, n.file_path AS file_path"
34 )
35 for name, file_path in rows:
36 self._promote_node(name, file_path, "View")
37 result.patterns_found["views"] = len(rows)
38 result.promoted += len(rows)
39
40 # ── Models ───────────────────────────────────────────────────────────
41 rows = self._query_rows(
42 "MATCH (n:Class)-[:INHERITS]->(b) WHERE b.name CONTAINS 'Model' "
43 "RETURN n.name AS name, n.file_path AS file_path"
44 )
45 for name, file_path in rows:
46 self._promote_node(name, file_path, "Model")
47 result.patterns_found["models"] = len(rows)
48 result.promoted += len(rows)
49
50 # ── URL patterns ─────────────────────────────────────────────────────
51 rows = self._query_rows(
52 "MATCH (n:Function) WHERE n.file_path CONTAINS 'urls.py' "
53 "RETURN n.name AS name, n.file_path AS file_path"
54 )
55 for name, file_path in rows:
56 self._promote_node(name, file_path, "URLPattern")
57 result.patterns_found["url_patterns"] = len(rows)
58 result.promoted += len(rows)
59
60 # ── Serializers ──────────────────────────────────────────────────────
61 rows = self._query_rows(
62 "MATCH (n:Class) WHERE n.file_path CONTAINS 'serializers.py' "
63 "RETURN n.name AS name, n.file_path AS file_path"
64 )
65 for name, file_path in rows:
66 self._promote_node(name, file_path, "Serializer")
67 result.patterns_found["serializers"] = len(rows)
68 result.promoted += len(rows)
69
70 # ── Tasks (decorator or tasks.py) ─────────────────────────────────
71 rows = self._query_rows(
72 "MATCH (n:Function) WHERE n.file_path CONTAINS 'tasks.py' "
73 "OR (n.decorators IS NOT NULL AND n.decorators CONTAINS 'task') "
74 "RETURN n.name AS name, n.file_path AS file_path"
75 )
76 for name, file_path in rows:
77 self._promote_node(name, file_path, "Task")
78 result.patterns_found["tasks"] = len(rows)
79 result.promoted += len(rows)
80
81 # ── Admin ─────────────────────────────────────────────────────────
82 rows = self._query_rows(
83 "MATCH (n:Class) WHERE n.file_path CONTAINS 'admin.py' "
84 "RETURN n.name AS name, n.file_path AS file_path"
85 )
86 for name, file_path in rows:
87 self._promote_node(name, file_path, "Admin")
88 result.patterns_found["admin"] = len(rows)
89 result.promoted += len(rows)
90
91 return result
92
93 # ── Internal helpers ──────────────────────────────────────────────────────
94
95 def _query_rows(self, cypher: str) -> list[tuple[str, str]]:
96 """Run a Cypher query and return (name, file_path) pairs."""
97 query_result = self.store.query(cypher)
98 rows = query_result.result_set or []
99 return [(row[0], row[1]) for row in rows]
--- a/tests/test_enrichment_django.py
+++ b/tests/test_enrichment_django.py
@@ -0,0 +1,465 @@
1
+"""Tests for navegador.enrichment.django — DjangoEnricher."""
2
+
3
+from unittest.mock import MagicMock, call
4
+
5
+import pytest
6
+
7
+from navegador.enrichment.base import EnrichmentResult, FrameworkEnricher
8
+from navegador.enrichment.django import DjangoEnricher
9
+from navegador.graph.store import GraphStore
10
+
11
+
12
+# ── Helpers ───────────────────────────────────────────────────────────────────
13
+
14
+
15
+def _mock_store(result_set=None):
16
+ """Return a GraphStore backed by a mock FalkorDB graph."""
17
+ client = MagicMock()
18
+ graph = MagicMock()
19
+ graph.query.return_value = MagicMock(result_set=result_set)
20
+ client.select_graph.return_value = graph
21
+ return GraphStore(client)
22
+
23
+
24
+def _store_with_side_effect(side_effect):
25
+ """Return a GraphStore whose graph.query uses the given side_effect callable."""
26
+ client = MagicMock()
27
+ graph = MagicMock()
28
+ graph.query.side_effect = side_effect
29
+ client.select_graph.return_value = graph
30
+ return GraphStore(client)
31
+
32
+
33
+# ── Metadata ──────────────────────────────────────────────────────────────────
34
+
35
+
36
+class TestDjangoEnricherMetadata:
37
+ def test_framework_name(self):
38
+ enricher = DjangoEnricher(_mock_store())
39
+ assert enricher.framework_name == "django"
40
+
41
+ def test_dmanage_py(self):
42
+ result_set=view_rows)
43
+ _mock_store())
44
+ assert "manage.py assert "django.conf" in enricher.detection_patterns
45
+
46
+ def test_detectioconf(self):
47
+ result_set=view_rows)
48
+ _mock_store())
49
+ assert ck_store())
50
+ assert "django.conf" in enricher.detection_patterns
51
+
52
+ def test_dsettings_py(self):
53
+ result_set=view_rows)
54
+ _mock_store())
55
+ assert "settings.py assert "django.conf" in enricher.detection_patterns
56
+
57
+ def test_durls_py(self):
58
+ result_set=view_rows)
59
+ _mock_store())
60
+ assert "urls.py" in enricher.detection_patterns
61
+
62
+ def test_detection_patterns_is_list_of_strings(self):
63
+ enricher = DjangoEnricher(_mock_store())
64
+ patterns = enricher.detection_patterns
65
+ assert isinstance(patterns, list)
66
+ assert all(isinstance(p, str) for p in patterns)
67
+
68
+ def test_is_subclass_of_framework_enricher(self):
69
+ assert issubclass(DjangoEnricher, FrameworkEnricher)
70
+
71
+ def test_enrich_returns_enrichment_result(self):
72
+ store = _mock_store(result_set=[])
73
+ enricher = DjangoEnricher(store)
74
+ result = enricher.enrich()
75
+ assert isinstance(result, EnrichmentResult)
76
+
77
+
78
+# ── detect() ──────────────────────────────────────────────────────────────────
79
+
80
+
81
+class TestDjangoEnricherDetect:
82
+ def test_detect_returns_true_when_urls_py_present(self):
83
+ store = _mock_store(result_set=[[1]])
84
+ enricher = DjangoEnricher(store)
85
+ assert enricher.detect() is True
86
+
87
+ def test_detect_returns_false_when_no_patterns_match(self):
88
+ store = _mock_store(result_set=[[0]])
89
+ enricher = DjangoEnricher(store)
90
+ assert enricher.detect() is False
91
+
92
+ def test_detect_returns_false_when_result_set_empty(self):
93
+ store = _mock_store(result_set=[])
94
+ enricher = DjangoEnricher(store)
95
+ assert enricher.detect() is False
96
+
97
+ def test_detect_returns_false_when_result_set_none(self):
98
+ store = _mock_store(result_set=None)
99
+ enricher = DjangoEnricher(store)
100
+ assert enricher.detect() is False
101
+
102
+ def test_detect_short_circuits_on_first_match(self):
103
+ store = _mock_store(result_set=[[5]])
104
+ enricher = DjangoEnricher(store)
105
+ assert enricher.detect() is True
106
+ # Should only query once (short-circuit)
107
+ assert store._graph.query.call_count == 1
108
+
109
+ def test_detect_tries_all_patterns_before_giving_up(self):
110
+ store = _mock_store(result_set=[[0]])
111
+ enricher = DjangoEnricher(store)
112
+ enricher.detect()
113
+ tore())
114
+ len(enricher.detection_patterns).mock import MagicMock, c"""T─ enrich() — serializers ────────────────────────────────────────────────────
115
+
116
+
117
+class TestDjangoEnricherSerializers:
118
+ def _make_store_for_serializers(self, serializer_rows):
119
+ call_count = [0]
120
+
121
+ def side_effect(cypher, params=None):
122
+ call_count[0] += 1
123
+ # Fourth enrich query targets serializers.py classes
124
+ if "serializers.py" in cypher and call_count[0] == 4:
125
+ return MagicMock(result_set=serializer_rows)
126
+ return MagicMock(result_set=[])
127
+
128
+ return _store_with_side_effect(side_effect)
129
+
130
+ def test_promotes_classes_in_serializers_py(self):
131
+ serializer_rows = [["UserSerializer", "api/serializers.py"]]
132
+ store = self._make_store_for_serializers(serializer_rows)
133
+ enricher = DjangoEnricher(store)
134
+ result = enricher.enrich()
135
+ assert result.patterns_found["serializers"] == 1
136
+ assert result.promoted >= 1
137
+
138
+ def test_no_serializers_produces_zero_count(self):
139
+ store = _mock_store(result_set=[])
140
+ enricher = DjangoEnricher(store)
141
+ result = enricher.enrich()
142
+ assert result.patterns_found["serializers"] == 0
143
+
144
+ def test_serializer_promote_node_called_with_semantic_type_serializer(self):
145
+ serializer_rows = [["PostSerializer", "blog/serializers.py"]]
146
+
147
+ call_count = [0]
148
+
149
+ def side_effect(cypher, params=None):
150
+ call_count[0] += 1
151
+ if "serializers.py" in cypher and call_count[0] == 4:
152
+ return MagicMock(result_set=serializer_rows)
153
+ return MagicMock(result_set=[])
154
+
155
+ store = _store_with_side_effect(side_effect)
156
+ enricher = DjangoEnricher(store)
157
+ enricher.enrich()
158
+
159
+ promote_calls = [
160
+ c for c in store._graph.query.call_args_list
161
+ if "SET n.semantic_type" in c[0][0]
162
+ and c[0][1].get("name") == "PostSerializer"
163
+ ]
164
+ assert len(promote_calls) == 1
165
+ assert promote_calls[0][0][1]["semantic_type"] == "Serializer"
166
+
167
+
168
+# ── enrich() — tasks ──────────────────────────────────────────────────────────
169
+
170
+
171
+class TestDjangoEnricherTasks:
172
+ def _make_store_for_tasks(self, task_rows):
173
+ call_count = [0]
174
+
175
+ def side_effect(cypher, params=None):
176
+ call_count[0] += 1
177
+ # Fifth enrich query targets tasks.py functions or @task decorator
178
+ if "tasks.py" in cypher and call_count[0] == 5:
179
+ return MagicMock(result_set=task_rows)
180
+ return MagicMock(result_set=[])
181
+
182
+ return _store_with_side_effect(side_effect)
183
+
184
+ def test_promotes_functions_in_tasks_py(self):
185
+ task_rows = [["send_email", "myapp/tasks.py"], ["process_order", "shop/tasks.py"]]
186
+ store = self._make_store_for_tasks(task_rows)
187
+ enricher = DjangoEnricher(store)
188
+ result = enricher.enrich()
189
+ assert result.patterns_found["tasks"] == 2
190
+ assert result.promoted >= 2
191
+
192
+ def test_no_tasks_produces_zero_count(self):
193
+ store = _mock_store(result_set=[])
194
+ enricher = DjangoEnricher(store)
195
+ result = enricher.enrich()
196
+ assert result.patterns_found["tasks"] == 0
197
+
198
+ def test_task_cypher_includes_decorator_check(self):
199
+ store = _mock_store(result_set=[])
200
+ enricher = DjangoEnricher(store)
201
+ enricher.enrich()
202
+
203
+ # Find the tasks query
204
+ tasks_queries = [
205
+ c[0][0] for c in store._graph.query.call_args_list
206
+ if "tasks.py" in c[0][0]
207
+ ]
208
+ assert len(tasks_queries) == 1
209
+ assert "decorators" in tasks_queries[0]
210
+ assert "task" in tasks_queries[0]
211
+
212
+ def test_task_promote_node_called_with_semantic_type_task(self):
213
+ task_rows = [["send_welcome_email", "users/tasks.py"]]
214
+
215
+ call_count = [0]
216
+
217
+ def side_effect(cypher, params=None):
218
+ call_count[0] += 1
219
+ if "tasks.py" in cypher and call_count[0] == 5:
220
+ return MagicMock(result_set=task_rows)
221
+ return MagicMock(result_set=[])
222
+
223
+ store = _store_with_side_effect(side_effect)
224
+ enricher = DjangoEnricher(store)
225
+ enricher.enrich()
226
+
227
+ promote_calls = [
228
+ c for c in store._graph.query.call_args_list
229
+ if "SET n.semantic_type" in c[0][0]
230
+ and c[0][1].get("name") == "send_welcome_email"
231
+ ]
232
+ assert len(promote_calls) == 1
233
+ assert promote_calls[0][0][1]["semantic_type"] == "Task"
234
+
235
+
236
+# ── enrich() — URL patterns ───────────────────────────────────────────────────
237
+
238
+
239
+class TestDjangoEnricherURLPatterns:
240
+ def test_url_patterns_count_tracked(self):
241
+ call_count = [0]
242
+
243
+ def side_effect(cypher, params=None):
244
+ call_count[0] += 1
245
+ if "urls.py" in cypher and call_count[0] == 3:
246
+ return MagicMock(result_set=[["urlconf", "myapp/urls.py"]])
247
+ return MagicMock(result_set=[])
248
+
249
+ store = _store_with_side_effect(side_effect)
250
+ enricher = DjangoEnricher(store)
251
+ result = enricher.enrich()
252
+ assert result.patterns_found["url_patterns"] == 1
253
+
254
+ def test_url_pattern_promoted_with_correct_semantic_type(self):
255
+ call_count = [0]
256
+
257
+ def side_effect(cypher, params=None):
258
+ call_count[0] += 1
259
+ if "urls.py" in cypher and call_count[0] == 3:
260
+ return MagicMock(result_set=[["urlconf", "myapp/urls.py"]])
261
+ return MagicMock(result_set=[])
262
+
263
+ store = _store_with_side_effect(side_effect)
264
+ enricher = DjangoEnricher(store)
265
+ enricher.enrich()
266
+
267
+ promote_calls = [
268
+ c for c in store._graph.query.call_args_list
269
+ if "SET n.semantic_type" in c[0][0]
270
+ and c[0][1].get("name") == "urlconf"
271
+ ]
272
+ assert len(promote_calls) == 1
273
+ assert promote_calls[0][0][1]["semantic_type"] == "URLPattern"
274
+
275
+
276
+# ── enrich() — admin ──────────────────────────────────────────────────────────
277
+
278
+
279
+class TestDjangoEnricherAdmin:
280
+ def test_admin_count_tracked(self):
281
+ call_count = [0]
282
+
283
+ def side_effect(cypher, params=None):
284
+ call_count[0] += 1
285
+ if "admin.py" in cypher and call_count[0] == 6:
286
+ return MagicMock(result_set=[["UserAdmin", "myapp/admin.py"]])
287
+ return MagicMock(result_set=[])
288
+
289
+ store = _store_with_side_effect(side_effect)
290
+ enricher = DjangoEnricher(store)
291
+ result = enricher.enrich()
292
+ assert result.patterns_found["admin"] == 1
293
+
294
+ def test_admin_promoted_with_correct_semantic_type(self):
295
+ call_count = [0]
296
+
297
+ def side_effect(cypher, params=None):
298
+ call_count[0] += 1
299
+ if "admin.py" in cypher and call_count[0] == 6:
300
+ return MagicMock(result_set=[["PostAdmin", "blog/admin.py"]])
301
+ return MagicMock(result_set=[])
302
+
303
+ store = _store_with_side_effect(side_effect)
304
+ enricher = DjangoEnricher(store)
305
+ enricher.enrich()
306
+
307
+ promote_calls = [
308
+ c for c in store._graph.query.call_args_list
309
+ if "SET n.semantic_type" in c[0][0]
310
+ and c[0][1].get("name") == "PostAdmin"
311
+ ]
312
+ assert len(promote_calls) == 1
313
+ assert promote_calls[0][0][1]["semantic_type"] == "Admin"
314
+
315
+
316
+# ── enrich() — aggregate result ───────────────────────────────────────────────
317
+
318
+
319
+class TestDjangoEnricherAggregateResult:
320
+ def test_patterns_found_has_all_expected_keys(self):
321
+ store = _mock_store(result_set=[])
322
+ enricher = DjangoEnricher(store)
323
+ result = enricher.enrich()
324
+ expected_keys = {"views", "models", "url_patterns", "serializers", "tasks", "admin"}
325
+ assert expected_keys == set(result.patterns_found.keys())
326
+
327
+ def test_promoted_count_is_sum_of_all_pattern_counts(self):
328
+ """With no matches, promoted should be 0."""
329
+ store = _mock_store(result_set=[])
330
+ enricher = DjangoEnricher(store)
331
+ result = enricher.enrich()
332
+ assert result.promoted == 0
333
+ assert sum(result.patterns_found.values()) == 0
334
+
335
+ def test_promoted_accumulates_across_all_patterns(self):
336
+ """Each query returns one row — promoted should equal 6 (one per pattern)."""
337
+ call_count = [0]
338
+
339
+ def side_effect(cypher, params=None):
340
+ call_count[0] += 1
341
+ # The 6 SELECT queries are calls 1, 2, 3, 4, 5, 6 (interleaved with SET calls)
342
+ # We track by call_count on the non-SET queries
343
+ if "SET n.semantic_type" not in cypher:
344
+ return MagicMock(result_set=[["some_node", "some_file.py"]])
345
+ return MagicMock(result_set=[])
346
+
347
+ store = _store_with_side_effect(side_effect)
348
+ enricher = DjangoEnricher(store)
349
+ result = enricher.enrich()
350
+ assert result.promoted == 6
351
+ assert sum(result.patterns_found.values()) == 6
352
+ntic_type"] == "TaView return _store_with_side_efviews(self, view_rows):
353
+ """
354
+ Return a store that yields view_rows for the views query and [] for everything else.
355
+ """
356
+ call_count = [0]
357
+
358
+ def side_effect(cypher, params=None):
359
+ call_count[0] += 1
360
+ # First enrich query targets views.py functions
361
+ if "views.py" in cypher and "Function" in cypher and call_count[0] == 1:
362
+ return MagicMock(result_set=view_rows)
363
+ return MagicMock(result_set=[])
364
+
365
+ return _store_with_side_effect(side_effect)
366
+
367
+ def test_promotes_functions_in_views_py(self):
368
+ view_rows = [["my_view", "app/views.py"], ["another_view", "app/views.py"]]
369
+ store = self._make_store_for_views(view_rows)
370
+ enricher = DjangoEnricher(store)
371
+ result = enricher.enrich()
372
+ assert result.patterns_found["views"] == 2
373
+ assert result.promoted >= 2
374
+
375
+ def test_no_views_produces_zero_count(self):
376
+ store = _mock_store(result_set=[])
377
+ enricher = DjangoEnricher(store)
378
+ result = enricher.enrich()
379
+ assert result.patterns_found["views"] == 0
380
+
381
+ def test_view_promote_node_called_with_semantic_type_view(self):
382
+ view_rows = [["index_view", "myapp/views.py"]]
383
+
384
+ call_count = [0]
385
+
386
+ def side_effect(cypher, params=None):
387
+ call_count[0] += 1
388
+ if "views.py" in cypher and "Function" in cypher and call_count[0] == 1:
389
+ return MagicMock(result_set=view_rows)
390
+ # _promote_node calls also go through store.query — return empty
391
+ return MagicMock(result_set=[])
392
+
393
+ store = _store_with_side_effect(side_effect)
394
+ enricher = DjangoEnricher(store)
395
+ enricher.enrich()
396
+
397
+ # Find the _promote_node call for "index_view"
398
+ promote_calls = [
399
+ c for c in store._graph.query.call_args_list
400
+ if "SET n.semantic_type" in c[0][0]
401
+ and c[0][1].get("name") == "index_view"
402
+ ]
403
+ assert len(promote_calls) == 1
404
+ assert promote_calls[0][0][1]["semantic_type"] == "View"
405
+
406
+
407
+# ── enrich() — models ─────────────────────────────────────────────────────────
408
+
409
+
410
+class TestDjangoEnricherModels:
411
+ def _make_store_for_models(self, model_rows):
412
+ call_count = [0]
413
+
414
+ def side_effect(cypher, params=None):
415
+ call_count[0] += 1
416
+ # Second enrich query targets Model-inheriting classes
417
+ if "INHERITS" in cypher and call_count[0] == 2:
418
+ return MagicMock(result_set=model_rows)
419
+ return MagicMock(result_set=[])
420
+
421
+ return _store_with_side_effect(side_effect)
422
+
423
+ def test_promotes_classes_inheriting_from_model(self):
424
+ model_rows = [["UserProfile", "app/models.py"], ["Post", "blog/models.py"]]
425
+ store = self._make_store_for_models(model_rows)
426
+ enricher = DjangoEnricher(store)
427
+ result = enricher.enrich()
428
+ assert result.patterns_found["models"] == 2
429
+ assert result.promoted >= 2
430
+
431
+ def test_no_models_produces_zero_count(self):
432
+ store = _mock_store(result_set=[])
433
+ enricher = DjangoEnricher(store)
434
+ result = enricher.enrich()
435
+ assert result.patterns_found["models"] == 0
436
+
437
+ def test_model_promote_node_called_with_semantic_type_model(self):
438
+ model_rows = [["Article", "news/models.py"]]
439
+
440
+ call_count = [0]
441
+
442
+ def side_effect(cypher, params=None):
443
+ call_count[0] += 1
444
+ if "INHERITS" in cypher and call_count[0] == 2:
445
+ return MagicMock(result_set=model_rows)
446
+ return MagicMock(result_set=[])
447
+
448
+ store = _store_with_side_effect(side_effect)
449
+ enricher = DjangoEnricher(store)
450
+ enricher.enrich()
451
+
452
+ promote_calls = [
453
+ c for c in store._graph.query.call_args_list
454
+ if "SET n.semantic_type" in c[0][0]
455
+ and c[0][1].get("name") == "Article"
456
+ ]
457
+ assert len(promote_calls) == 1
458
+ assert promote_calls[0][0][1]["semantic_type"] == "Model"
459
+
460
+
461
+# ── enrich() — serializers ────────────────────────────────────────────────────
462
+
463
+
464
+class TestDjangoEnricherSerializers:
465
+ def _make_
--- a/tests/test_enrichment_django.py
+++ b/tests/test_enrichment_django.py
@@ -0,0 +1,465 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_enrichment_django.py
+++ b/tests/test_enrichment_django.py
@@ -0,0 +1,465 @@
1 """Tests for navegador.enrichment.django — DjangoEnricher."""
2
3 from unittest.mock import MagicMock, call
4
5 import pytest
6
7 from navegador.enrichment.base import EnrichmentResult, FrameworkEnricher
8 from navegador.enrichment.django import DjangoEnricher
9 from navegador.graph.store import GraphStore
10
11
12 # ── Helpers ───────────────────────────────────────────────────────────────────
13
14
15 def _mock_store(result_set=None):
16 """Return a GraphStore backed by a mock FalkorDB graph."""
17 client = MagicMock()
18 graph = MagicMock()
19 graph.query.return_value = MagicMock(result_set=result_set)
20 client.select_graph.return_value = graph
21 return GraphStore(client)
22
23
24 def _store_with_side_effect(side_effect):
25 """Return a GraphStore whose graph.query uses the given side_effect callable."""
26 client = MagicMock()
27 graph = MagicMock()
28 graph.query.side_effect = side_effect
29 client.select_graph.return_value = graph
30 return GraphStore(client)
31
32
33 # ── Metadata ──────────────────────────────────────────────────────────────────
34
35
36 class TestDjangoEnricherMetadata:
37 def test_framework_name(self):
38 enricher = DjangoEnricher(_mock_store())
39 assert enricher.framework_name == "django"
40
41 def test_dmanage_py(self):
42 result_set=view_rows)
43 _mock_store())
44 assert "manage.py assert "django.conf" in enricher.detection_patterns
45
46 def test_detectioconf(self):
47 result_set=view_rows)
48 _mock_store())
49 assert ck_store())
50 assert "django.conf" in enricher.detection_patterns
51
52 def test_dsettings_py(self):
53 result_set=view_rows)
54 _mock_store())
55 assert "settings.py assert "django.conf" in enricher.detection_patterns
56
57 def test_durls_py(self):
58 result_set=view_rows)
59 _mock_store())
60 assert "urls.py" in enricher.detection_patterns
61
62 def test_detection_patterns_is_list_of_strings(self):
63 enricher = DjangoEnricher(_mock_store())
64 patterns = enricher.detection_patterns
65 assert isinstance(patterns, list)
66 assert all(isinstance(p, str) for p in patterns)
67
68 def test_is_subclass_of_framework_enricher(self):
69 assert issubclass(DjangoEnricher, FrameworkEnricher)
70
71 def test_enrich_returns_enrichment_result(self):
72 store = _mock_store(result_set=[])
73 enricher = DjangoEnricher(store)
74 result = enricher.enrich()
75 assert isinstance(result, EnrichmentResult)
76
77
78 # ── detect() ──────────────────────────────────────────────────────────────────
79
80
81 class TestDjangoEnricherDetect:
82 def test_detect_returns_true_when_urls_py_present(self):
83 store = _mock_store(result_set=[[1]])
84 enricher = DjangoEnricher(store)
85 assert enricher.detect() is True
86
87 def test_detect_returns_false_when_no_patterns_match(self):
88 store = _mock_store(result_set=[[0]])
89 enricher = DjangoEnricher(store)
90 assert enricher.detect() is False
91
92 def test_detect_returns_false_when_result_set_empty(self):
93 store = _mock_store(result_set=[])
94 enricher = DjangoEnricher(store)
95 assert enricher.detect() is False
96
97 def test_detect_returns_false_when_result_set_none(self):
98 store = _mock_store(result_set=None)
99 enricher = DjangoEnricher(store)
100 assert enricher.detect() is False
101
102 def test_detect_short_circuits_on_first_match(self):
103 store = _mock_store(result_set=[[5]])
104 enricher = DjangoEnricher(store)
105 assert enricher.detect() is True
106 # Should only query once (short-circuit)
107 assert store._graph.query.call_count == 1
108
109 def test_detect_tries_all_patterns_before_giving_up(self):
110 store = _mock_store(result_set=[[0]])
111 enricher = DjangoEnricher(store)
112 enricher.detect()
113 tore())
114 len(enricher.detection_patterns).mock import MagicMock, c"""T─ enrich() — serializers ────────────────────────────────────────────────────
115
116
117 class TestDjangoEnricherSerializers:
118 def _make_store_for_serializers(self, serializer_rows):
119 call_count = [0]
120
121 def side_effect(cypher, params=None):
122 call_count[0] += 1
123 # Fourth enrich query targets serializers.py classes
124 if "serializers.py" in cypher and call_count[0] == 4:
125 return MagicMock(result_set=serializer_rows)
126 return MagicMock(result_set=[])
127
128 return _store_with_side_effect(side_effect)
129
130 def test_promotes_classes_in_serializers_py(self):
131 serializer_rows = [["UserSerializer", "api/serializers.py"]]
132 store = self._make_store_for_serializers(serializer_rows)
133 enricher = DjangoEnricher(store)
134 result = enricher.enrich()
135 assert result.patterns_found["serializers"] == 1
136 assert result.promoted >= 1
137
138 def test_no_serializers_produces_zero_count(self):
139 store = _mock_store(result_set=[])
140 enricher = DjangoEnricher(store)
141 result = enricher.enrich()
142 assert result.patterns_found["serializers"] == 0
143
144 def test_serializer_promote_node_called_with_semantic_type_serializer(self):
145 serializer_rows = [["PostSerializer", "blog/serializers.py"]]
146
147 call_count = [0]
148
149 def side_effect(cypher, params=None):
150 call_count[0] += 1
151 if "serializers.py" in cypher and call_count[0] == 4:
152 return MagicMock(result_set=serializer_rows)
153 return MagicMock(result_set=[])
154
155 store = _store_with_side_effect(side_effect)
156 enricher = DjangoEnricher(store)
157 enricher.enrich()
158
159 promote_calls = [
160 c for c in store._graph.query.call_args_list
161 if "SET n.semantic_type" in c[0][0]
162 and c[0][1].get("name") == "PostSerializer"
163 ]
164 assert len(promote_calls) == 1
165 assert promote_calls[0][0][1]["semantic_type"] == "Serializer"
166
167
168 # ── enrich() — tasks ──────────────────────────────────────────────────────────
169
170
171 class TestDjangoEnricherTasks:
172 def _make_store_for_tasks(self, task_rows):
173 call_count = [0]
174
175 def side_effect(cypher, params=None):
176 call_count[0] += 1
177 # Fifth enrich query targets tasks.py functions or @task decorator
178 if "tasks.py" in cypher and call_count[0] == 5:
179 return MagicMock(result_set=task_rows)
180 return MagicMock(result_set=[])
181
182 return _store_with_side_effect(side_effect)
183
184 def test_promotes_functions_in_tasks_py(self):
185 task_rows = [["send_email", "myapp/tasks.py"], ["process_order", "shop/tasks.py"]]
186 store = self._make_store_for_tasks(task_rows)
187 enricher = DjangoEnricher(store)
188 result = enricher.enrich()
189 assert result.patterns_found["tasks"] == 2
190 assert result.promoted >= 2
191
192 def test_no_tasks_produces_zero_count(self):
193 store = _mock_store(result_set=[])
194 enricher = DjangoEnricher(store)
195 result = enricher.enrich()
196 assert result.patterns_found["tasks"] == 0
197
198 def test_task_cypher_includes_decorator_check(self):
199 store = _mock_store(result_set=[])
200 enricher = DjangoEnricher(store)
201 enricher.enrich()
202
203 # Find the tasks query
204 tasks_queries = [
205 c[0][0] for c in store._graph.query.call_args_list
206 if "tasks.py" in c[0][0]
207 ]
208 assert len(tasks_queries) == 1
209 assert "decorators" in tasks_queries[0]
210 assert "task" in tasks_queries[0]
211
212 def test_task_promote_node_called_with_semantic_type_task(self):
213 task_rows = [["send_welcome_email", "users/tasks.py"]]
214
215 call_count = [0]
216
217 def side_effect(cypher, params=None):
218 call_count[0] += 1
219 if "tasks.py" in cypher and call_count[0] == 5:
220 return MagicMock(result_set=task_rows)
221 return MagicMock(result_set=[])
222
223 store = _store_with_side_effect(side_effect)
224 enricher = DjangoEnricher(store)
225 enricher.enrich()
226
227 promote_calls = [
228 c for c in store._graph.query.call_args_list
229 if "SET n.semantic_type" in c[0][0]
230 and c[0][1].get("name") == "send_welcome_email"
231 ]
232 assert len(promote_calls) == 1
233 assert promote_calls[0][0][1]["semantic_type"] == "Task"
234
235
236 # ── enrich() — URL patterns ───────────────────────────────────────────────────
237
238
239 class TestDjangoEnricherURLPatterns:
240 def test_url_patterns_count_tracked(self):
241 call_count = [0]
242
243 def side_effect(cypher, params=None):
244 call_count[0] += 1
245 if "urls.py" in cypher and call_count[0] == 3:
246 return MagicMock(result_set=[["urlconf", "myapp/urls.py"]])
247 return MagicMock(result_set=[])
248
249 store = _store_with_side_effect(side_effect)
250 enricher = DjangoEnricher(store)
251 result = enricher.enrich()
252 assert result.patterns_found["url_patterns"] == 1
253
254 def test_url_pattern_promoted_with_correct_semantic_type(self):
255 call_count = [0]
256
257 def side_effect(cypher, params=None):
258 call_count[0] += 1
259 if "urls.py" in cypher and call_count[0] == 3:
260 return MagicMock(result_set=[["urlconf", "myapp/urls.py"]])
261 return MagicMock(result_set=[])
262
263 store = _store_with_side_effect(side_effect)
264 enricher = DjangoEnricher(store)
265 enricher.enrich()
266
267 promote_calls = [
268 c for c in store._graph.query.call_args_list
269 if "SET n.semantic_type" in c[0][0]
270 and c[0][1].get("name") == "urlconf"
271 ]
272 assert len(promote_calls) == 1
273 assert promote_calls[0][0][1]["semantic_type"] == "URLPattern"
274
275
276 # ── enrich() — admin ──────────────────────────────────────────────────────────
277
278
279 class TestDjangoEnricherAdmin:
280 def test_admin_count_tracked(self):
281 call_count = [0]
282
283 def side_effect(cypher, params=None):
284 call_count[0] += 1
285 if "admin.py" in cypher and call_count[0] == 6:
286 return MagicMock(result_set=[["UserAdmin", "myapp/admin.py"]])
287 return MagicMock(result_set=[])
288
289 store = _store_with_side_effect(side_effect)
290 enricher = DjangoEnricher(store)
291 result = enricher.enrich()
292 assert result.patterns_found["admin"] == 1
293
294 def test_admin_promoted_with_correct_semantic_type(self):
295 call_count = [0]
296
297 def side_effect(cypher, params=None):
298 call_count[0] += 1
299 if "admin.py" in cypher and call_count[0] == 6:
300 return MagicMock(result_set=[["PostAdmin", "blog/admin.py"]])
301 return MagicMock(result_set=[])
302
303 store = _store_with_side_effect(side_effect)
304 enricher = DjangoEnricher(store)
305 enricher.enrich()
306
307 promote_calls = [
308 c for c in store._graph.query.call_args_list
309 if "SET n.semantic_type" in c[0][0]
310 and c[0][1].get("name") == "PostAdmin"
311 ]
312 assert len(promote_calls) == 1
313 assert promote_calls[0][0][1]["semantic_type"] == "Admin"
314
315
316 # ── enrich() — aggregate result ───────────────────────────────────────────────
317
318
319 class TestDjangoEnricherAggregateResult:
320 def test_patterns_found_has_all_expected_keys(self):
321 store = _mock_store(result_set=[])
322 enricher = DjangoEnricher(store)
323 result = enricher.enrich()
324 expected_keys = {"views", "models", "url_patterns", "serializers", "tasks", "admin"}
325 assert expected_keys == set(result.patterns_found.keys())
326
327 def test_promoted_count_is_sum_of_all_pattern_counts(self):
328 """With no matches, promoted should be 0."""
329 store = _mock_store(result_set=[])
330 enricher = DjangoEnricher(store)
331 result = enricher.enrich()
332 assert result.promoted == 0
333 assert sum(result.patterns_found.values()) == 0
334
335 def test_promoted_accumulates_across_all_patterns(self):
336 """Each query returns one row — promoted should equal 6 (one per pattern)."""
337 call_count = [0]
338
339 def side_effect(cypher, params=None):
340 call_count[0] += 1
341 # The 6 SELECT queries are calls 1, 2, 3, 4, 5, 6 (interleaved with SET calls)
342 # We track by call_count on the non-SET queries
343 if "SET n.semantic_type" not in cypher:
344 return MagicMock(result_set=[["some_node", "some_file.py"]])
345 return MagicMock(result_set=[])
346
347 store = _store_with_side_effect(side_effect)
348 enricher = DjangoEnricher(store)
349 result = enricher.enrich()
350 assert result.promoted == 6
351 assert sum(result.patterns_found.values()) == 6
352 ntic_type"] == "TaView return _store_with_side_efviews(self, view_rows):
353 """
354 Return a store that yields view_rows for the views query and [] for everything else.
355 """
356 call_count = [0]
357
358 def side_effect(cypher, params=None):
359 call_count[0] += 1
360 # First enrich query targets views.py functions
361 if "views.py" in cypher and "Function" in cypher and call_count[0] == 1:
362 return MagicMock(result_set=view_rows)
363 return MagicMock(result_set=[])
364
365 return _store_with_side_effect(side_effect)
366
367 def test_promotes_functions_in_views_py(self):
368 view_rows = [["my_view", "app/views.py"], ["another_view", "app/views.py"]]
369 store = self._make_store_for_views(view_rows)
370 enricher = DjangoEnricher(store)
371 result = enricher.enrich()
372 assert result.patterns_found["views"] == 2
373 assert result.promoted >= 2
374
375 def test_no_views_produces_zero_count(self):
376 store = _mock_store(result_set=[])
377 enricher = DjangoEnricher(store)
378 result = enricher.enrich()
379 assert result.patterns_found["views"] == 0
380
381 def test_view_promote_node_called_with_semantic_type_view(self):
382 view_rows = [["index_view", "myapp/views.py"]]
383
384 call_count = [0]
385
386 def side_effect(cypher, params=None):
387 call_count[0] += 1
388 if "views.py" in cypher and "Function" in cypher and call_count[0] == 1:
389 return MagicMock(result_set=view_rows)
390 # _promote_node calls also go through store.query — return empty
391 return MagicMock(result_set=[])
392
393 store = _store_with_side_effect(side_effect)
394 enricher = DjangoEnricher(store)
395 enricher.enrich()
396
397 # Find the _promote_node call for "index_view"
398 promote_calls = [
399 c for c in store._graph.query.call_args_list
400 if "SET n.semantic_type" in c[0][0]
401 and c[0][1].get("name") == "index_view"
402 ]
403 assert len(promote_calls) == 1
404 assert promote_calls[0][0][1]["semantic_type"] == "View"
405
406
407 # ── enrich() — models ─────────────────────────────────────────────────────────
408
409
410 class TestDjangoEnricherModels:
411 def _make_store_for_models(self, model_rows):
412 call_count = [0]
413
414 def side_effect(cypher, params=None):
415 call_count[0] += 1
416 # Second enrich query targets Model-inheriting classes
417 if "INHERITS" in cypher and call_count[0] == 2:
418 return MagicMock(result_set=model_rows)
419 return MagicMock(result_set=[])
420
421 return _store_with_side_effect(side_effect)
422
423 def test_promotes_classes_inheriting_from_model(self):
424 model_rows = [["UserProfile", "app/models.py"], ["Post", "blog/models.py"]]
425 store = self._make_store_for_models(model_rows)
426 enricher = DjangoEnricher(store)
427 result = enricher.enrich()
428 assert result.patterns_found["models"] == 2
429 assert result.promoted >= 2
430
431 def test_no_models_produces_zero_count(self):
432 store = _mock_store(result_set=[])
433 enricher = DjangoEnricher(store)
434 result = enricher.enrich()
435 assert result.patterns_found["models"] == 0
436
437 def test_model_promote_node_called_with_semantic_type_model(self):
438 model_rows = [["Article", "news/models.py"]]
439
440 call_count = [0]
441
442 def side_effect(cypher, params=None):
443 call_count[0] += 1
444 if "INHERITS" in cypher and call_count[0] == 2:
445 return MagicMock(result_set=model_rows)
446 return MagicMock(result_set=[])
447
448 store = _store_with_side_effect(side_effect)
449 enricher = DjangoEnricher(store)
450 enricher.enrich()
451
452 promote_calls = [
453 c for c in store._graph.query.call_args_list
454 if "SET n.semantic_type" in c[0][0]
455 and c[0][1].get("name") == "Article"
456 ]
457 assert len(promote_calls) == 1
458 assert promote_calls[0][0][1]["semantic_type"] == "Model"
459
460
461 # ── enrich() — serializers ────────────────────────────────────────────────────
462
463
464 class TestDjangoEnricherSerializers:
465 def _make_

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button