django-arch-check 0.1.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 (28) hide show
  1. django_arch_check-0.1.0/.gitignore +45 -0
  2. django_arch_check-0.1.0/PKG-INFO +61 -0
  3. django_arch_check-0.1.0/README.md +33 -0
  4. django_arch_check-0.1.0/django_arch_check/__init__.py +3 -0
  5. django_arch_check-0.1.0/django_arch_check/analyzer.py +68 -0
  6. django_arch_check-0.1.0/django_arch_check/cli.py +318 -0
  7. django_arch_check-0.1.0/django_arch_check/detectors/__init__.py +5 -0
  8. django_arch_check-0.1.0/django_arch_check/detectors/base.py +7 -0
  9. django_arch_check-0.1.0/django_arch_check/detectors/celery_tasks.py +212 -0
  10. django_arch_check-0.1.0/django_arch_check/detectors/circular_imports.py +339 -0
  11. django_arch_check-0.1.0/django_arch_check/detectors/direct_sql.py +150 -0
  12. django_arch_check-0.1.0/django_arch_check/detectors/fat_models.py +190 -0
  13. django_arch_check-0.1.0/django_arch_check/detectors/god_apps.py +205 -0
  14. django_arch_check-0.1.0/django_arch_check/detectors/missing_service_layer.py +240 -0
  15. django_arch_check-0.1.0/django_arch_check/detectors/n_plus_one.py +257 -0
  16. django_arch_check-0.1.0/django_arch_check/report.py +464 -0
  17. django_arch_check-0.1.0/pyproject.toml +54 -0
  18. django_arch_check-0.1.0/tests/__init__.py +0 -0
  19. django_arch_check-0.1.0/tests/conftest.py +44 -0
  20. django_arch_check-0.1.0/tests/test_celery_tasks.py +223 -0
  21. django_arch_check-0.1.0/tests/test_circular_imports.py +199 -0
  22. django_arch_check-0.1.0/tests/test_cli.py +216 -0
  23. django_arch_check-0.1.0/tests/test_direct_sql.py +162 -0
  24. django_arch_check-0.1.0/tests/test_fat_models.py +180 -0
  25. django_arch_check-0.1.0/tests/test_god_apps.py +152 -0
  26. django_arch_check-0.1.0/tests/test_missing_service_layer.py +200 -0
  27. django_arch_check-0.1.0/tests/test_n_plus_one.py +261 -0
  28. django_arch_check-0.1.0/tests/test_report.py +207 -0
@@ -0,0 +1,45 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ *.so
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .eggs/
12
+ MANIFEST
13
+
14
+ # Virtual environments
15
+ .venv/
16
+ venv/
17
+ env/
18
+ ENV/
19
+
20
+ # Testing
21
+ .pytest_cache/
22
+ .coverage
23
+ coverage.xml
24
+ htmlcov/
25
+
26
+ # Type checking
27
+ .mypy_cache/
28
+
29
+ # Linting
30
+ .ruff_cache/
31
+
32
+ # IDEs
33
+ .vscode/
34
+ .idea/
35
+ *.swp
36
+ *.swo
37
+
38
+ # OS
39
+ .DS_Store
40
+ Thumbs.db
41
+
42
+ # PyPI / build
43
+ *.whl
44
+ *.tar.gz
45
+ arch-report.html
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-arch-check
3
+ Version: 0.1.0
4
+ Summary: A CLI tool to detect architectural problems in Django projects.
5
+ Project-URL: Homepage, https://github.com/RJ-Gamer/django-arch-check
6
+ Project-URL: Issues, https://github.com/RJ-Gamer/django-arch-check/issues
7
+ Author-email: Rajat Jog <rajatjog1294@gmail.com>
8
+ License: MIT
9
+ Keywords: architecture,cli,django,linting
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Framework :: Django
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Topic :: Software Development :: Quality Assurance
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: click>=8.1
20
+ Provides-Extra: dev
21
+ Requires-Dist: build>=1.2; extra == 'dev'
22
+ Requires-Dist: mypy>=1.9; extra == 'dev'
23
+ Requires-Dist: pytest-cov>=4.1; extra == 'dev'
24
+ Requires-Dist: pytest>=7.4; extra == 'dev'
25
+ Requires-Dist: ruff>=0.4; extra == 'dev'
26
+ Requires-Dist: twine>=5.0; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # django-arch-check
30
+
31
+ A CLI tool that analyzes Django projects and detects architectural problems such as fat models, god apps, circular imports, missing service layers, and more.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install django-arch-check
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ```bash
42
+ django-arch-check analyze /path/to/your/django/project
43
+ ```
44
+
45
+ ## Development
46
+
47
+ ```bash
48
+ # Create and activate a virtual environment
49
+ python -m venv .venv
50
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
51
+
52
+ # Install in editable mode with dev dependencies
53
+ pip install -e ".[dev]"
54
+
55
+ # Run the CLI
56
+ django-arch-check analyze /path/to/project
57
+ ```
58
+
59
+ ## License
60
+
61
+ MIT
@@ -0,0 +1,33 @@
1
+ # django-arch-check
2
+
3
+ A CLI tool that analyzes Django projects and detects architectural problems such as fat models, god apps, circular imports, missing service layers, and more.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install django-arch-check
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ django-arch-check analyze /path/to/your/django/project
15
+ ```
16
+
17
+ ## Development
18
+
19
+ ```bash
20
+ # Create and activate a virtual environment
21
+ python -m venv .venv
22
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
23
+
24
+ # Install in editable mode with dev dependencies
25
+ pip install -e ".[dev]"
26
+
27
+ # Run the CLI
28
+ django-arch-check analyze /path/to/project
29
+ ```
30
+
31
+ ## License
32
+
33
+ MIT
@@ -0,0 +1,3 @@
1
+ """django-arch-check: Architectural health checker for Django projects."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,68 @@
1
+ """Main analysis orchestrator.
2
+
3
+ This module is responsible for running all registered detectors against a
4
+ Django project and returning their aggregated results to the CLI layer.
5
+
6
+ To add a new detector:
7
+ 1. Implement it in ``detectors/<name>.py`` with a ``detect(project_path)``
8
+ function that returns a list of finding dataclasses.
9
+ 2. Import the detector module and its Finding type below.
10
+ 3. Add a field to :class:`AnalysisResult` for the new findings list.
11
+ 4. Call the detector inside :func:`run_analysis` and populate the field.
12
+ 5. Add any new threshold parameters to :func:`run_analysis` and pass them
13
+ through from the CLI layer.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+
20
+ from django_arch_check.detectors import celery_tasks, circular_imports, direct_sql, fat_models, god_apps, missing_service_layer, n_plus_one
21
+ from django_arch_check.detectors.circular_imports import CircularImportFinding
22
+ from django_arch_check.detectors.fat_models import FatModelFinding
23
+ from django_arch_check.detectors.god_apps import GodAppFinding
24
+ from django_arch_check.detectors.celery_tasks import CeleryTaskFinding
25
+ from django_arch_check.detectors.direct_sql import DirectSQLFinding
26
+ from django_arch_check.detectors.n_plus_one import NPlusOneFinding
27
+ from django_arch_check.detectors.missing_service_layer import MissingServiceLayerFinding
28
+
29
+
30
+ @dataclass
31
+ class AnalysisResult:
32
+ """Aggregated output of all detectors for a single project run."""
33
+
34
+ fat_models: list[FatModelFinding] = field(default_factory=list)
35
+ god_apps: list[GodAppFinding] = field(default_factory=list)
36
+ circular_imports: list[CircularImportFinding] = field(default_factory=list)
37
+ missing_service_layer: list[MissingServiceLayerFinding] = field(default_factory=list)
38
+ celery_tasks: list[CeleryTaskFinding] = field(default_factory=list)
39
+ direct_sql: list[DirectSQLFinding] = field(default_factory=list)
40
+ n_plus_one: list[NPlusOneFinding] = field(default_factory=list)
41
+
42
+
43
+ def run_analysis(
44
+ project_path: str,
45
+ fat_model_threshold: int = 10,
46
+ god_app_threshold: int = 30,
47
+ service_layer_threshold: int = 10,
48
+ ) -> AnalysisResult:
49
+ """Run all registered detectors against *project_path*.
50
+
51
+ Args:
52
+ project_path: Root directory of the Django project.
53
+ fat_model_threshold: Minimum method count to flag a model as fat.
54
+ god_app_threshold: Minimum LOC-% share to flag an app as a god app.
55
+ service_layer_threshold: Function body lines above which an ORM view is critical.
56
+
57
+ Returns:
58
+ An :class:`AnalysisResult` containing findings from every detector.
59
+ """
60
+ return AnalysisResult(
61
+ fat_models=fat_models.detect(project_path, threshold=fat_model_threshold),
62
+ god_apps=god_apps.detect(project_path, threshold=god_app_threshold),
63
+ circular_imports=circular_imports.detect(project_path),
64
+ missing_service_layer=missing_service_layer.detect(project_path, line_threshold=service_layer_threshold),
65
+ celery_tasks=celery_tasks.detect(project_path),
66
+ direct_sql=direct_sql.detect(project_path),
67
+ n_plus_one=n_plus_one.detect(project_path),
68
+ )
@@ -0,0 +1,318 @@
1
+ """CLI entry point for django-arch-check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+
9
+ from django_arch_check import __version__
10
+ from django_arch_check.analyzer import AnalysisResult, run_analysis
11
+ from django_arch_check.report import generate_html
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Severity styling
15
+ # ---------------------------------------------------------------------------
16
+
17
+ _SEVERITY_STYLE: dict[str, dict[str, object]] = {
18
+ "critical": {"fg": "red", "bold": True},
19
+ "warning": {"fg": "yellow", "bold": False},
20
+ }
21
+
22
+
23
+ def _severity_label(severity: str) -> str:
24
+ """Return a fixed-width, coloured severity label."""
25
+ label = f"[{severity.upper()}]"
26
+ # Pad to 10 chars so WARNING and CRITICAL columns align.
27
+ label = f"{label:<10}"
28
+ return click.style(label, **_SEVERITY_STYLE.get(severity, {})) # type: ignore[arg-type]
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Section printers
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ def _print_fat_models(result: AnalysisResult) -> None:
37
+ """Print fat-model findings and their section summary."""
38
+ findings = result.fat_models
39
+
40
+ click.echo()
41
+ click.echo(click.style("── Fat Models ──────────────────────────────", bold=True))
42
+
43
+ if not findings:
44
+ click.echo(click.style(" No fat models found.", fg="green"))
45
+ return
46
+
47
+ for f in findings:
48
+ label = _severity_label(f.severity)
49
+ click.echo(
50
+ f" {label} {f.file_path} → "
51
+ + click.style(f.class_name, bold=True)
52
+ + f" ({f.method_count} methods)"
53
+ )
54
+
55
+ count = len(findings)
56
+ click.echo()
57
+ click.echo(
58
+ click.style(f" Found {count} fat model(s).", fg="red" if count else "green")
59
+ )
60
+
61
+
62
+ def _print_god_apps(result: AnalysisResult) -> None:
63
+ """Print god-app findings and their section summary."""
64
+ findings = result.god_apps
65
+
66
+ click.echo()
67
+ click.echo(click.style("── God Apps ────────────────────────────────", bold=True))
68
+
69
+ if not findings:
70
+ click.echo(click.style(" No god apps found.", fg="green"))
71
+ return
72
+
73
+ for f in findings:
74
+ label = _severity_label(f.severity)
75
+ click.echo(
76
+ f" {label} "
77
+ + click.style(f.app_path, bold=True)
78
+ + f" owns {f.percentage}% of total project code"
79
+ + f" ({f.app_loc:,} / {f.total_loc:,} lines)"
80
+ )
81
+
82
+ count = len(findings)
83
+ click.echo()
84
+ click.echo(
85
+ click.style(f" Found {count} god app(s).", fg="red" if count else "green")
86
+ )
87
+
88
+
89
+ def _print_circular_imports(result: AnalysisResult) -> None:
90
+ """Print circular-import findings and their section summary."""
91
+ findings = result.circular_imports
92
+
93
+ click.echo()
94
+ click.echo(click.style("── Circular Imports ────────────────────────", bold=True))
95
+
96
+ if not findings:
97
+ click.echo(click.style(" No circular imports found.", fg="green"))
98
+ return
99
+
100
+ for f in findings:
101
+ label = _severity_label(f.severity)
102
+ click.echo(
103
+ f" {label} Circular import detected: "
104
+ + click.style(f.cycle_display, bold=True)
105
+ )
106
+
107
+ count = len(findings)
108
+ click.echo()
109
+ click.echo(click.style(f" Found {count} circular import(s).", fg="red"))
110
+
111
+
112
+ def _print_missing_service_layer(result: AnalysisResult) -> None:
113
+ """Print missing-service-layer findings and their section summary."""
114
+ findings = result.missing_service_layer
115
+
116
+ click.echo()
117
+ click.echo(click.style("── Missing Service Layer ────────────────────", bold=True))
118
+
119
+ if not findings:
120
+ click.echo(click.style(" No missing service layer issues found.", fg="green"))
121
+ return
122
+
123
+ for f in findings:
124
+ label = _severity_label(f.severity)
125
+ if f.severity == "critical":
126
+ detail = f"contains {f.line_count} lines of business logic"
127
+ else:
128
+ detail = "makes direct ORM calls"
129
+ click.echo(
130
+ f" {label} {f.file_path} → "
131
+ + click.style(f.view_name + "()", bold=True)
132
+ + f" {detail}"
133
+ )
134
+
135
+ count = len(findings)
136
+ click.echo()
137
+ click.echo(
138
+ click.style(
139
+ f" Found {count} missing service layer issue(s).",
140
+ fg="red" if count else "green",
141
+ )
142
+ )
143
+
144
+
145
+ def _print_celery_tasks(result: AnalysisResult) -> None:
146
+ """Print Celery task findings and their section summary."""
147
+ findings = result.celery_tasks
148
+
149
+ click.echo()
150
+ click.echo(click.style("── Celery Tasks Without Retry ──────────────", bold=True))
151
+
152
+ if not findings:
153
+ click.echo(click.style(" No Celery tasks missing retry config.", fg="green"))
154
+ return
155
+
156
+ for f in findings:
157
+ label = _severity_label(f.severity)
158
+ if f.severity == "critical":
159
+ detail = "high-stakes task, no retry configured"
160
+ else:
161
+ detail = "no retry configured"
162
+ click.echo(
163
+ f" {label} {f.file_path} → "
164
+ + click.style(f.task_name + "()", bold=True)
165
+ + f" — {detail}"
166
+ )
167
+
168
+ count = len(findings)
169
+ click.echo()
170
+ click.echo(
171
+ click.style(
172
+ f" Found {count} Celery task(s) without retry.",
173
+ fg="red" if count else "green",
174
+ )
175
+ )
176
+
177
+
178
+ def _print_direct_sql(result: AnalysisResult) -> None:
179
+ """Print direct-SQL findings and their section summary."""
180
+ findings = result.direct_sql
181
+
182
+ click.echo()
183
+ click.echo(click.style("── Direct SQL ──────────────────────────────", bold=True))
184
+
185
+ if not findings:
186
+ click.echo(click.style(" No direct SQL usage found.", fg="green"))
187
+ return
188
+
189
+ for f in findings:
190
+ label = _severity_label(f.severity)
191
+ click.echo(
192
+ f" {label} {f.file_path}:{f.line_number} → raw SQL detected: "
193
+ + click.style(f.pattern, bold=True)
194
+ )
195
+
196
+ count = len(findings)
197
+ click.echo()
198
+ click.echo(click.style(f" Found {count} direct SQL usage(s).", fg="yellow"))
199
+
200
+
201
+ def _print_n_plus_one(result: AnalysisResult) -> None:
202
+ """Print N+1 query-risk findings and their section summary."""
203
+ findings = result.n_plus_one
204
+
205
+ click.echo()
206
+ click.echo(click.style("── N+1 Query Risks ─────────────────────────", bold=True))
207
+
208
+ if not findings:
209
+ click.echo(click.style(" No N+1 query risks found.", fg="green"))
210
+ return
211
+
212
+ for f in findings:
213
+ label = _severity_label(f.severity)
214
+ click.echo(
215
+ f" {label} {f.file_path}:{f.line_number} → "
216
+ + click.style("ORM call inside loop", bold=True)
217
+ + " — possible N+1 query risk"
218
+ )
219
+
220
+ count = len(findings)
221
+ click.echo()
222
+ click.echo(click.style(f" Found {count} potential N+1 issue(s).", fg="yellow"))
223
+
224
+
225
+ def _write_html_report(result: AnalysisResult, project_path: str) -> None:
226
+ """Generate arch-report.html and write it to the project root."""
227
+ import os
228
+
229
+ from django_arch_check.report import compute_score
230
+
231
+ html_content = generate_html(result, project_path)
232
+ out_path = os.path.join(project_path, "arch-report.html")
233
+ with open(out_path, "w", encoding="utf-8") as fh:
234
+ fh.write(html_content)
235
+ score = compute_score(result)
236
+ click.echo(click.style(f" Health score: {score}/100", bold=True))
237
+ click.echo(click.style(f" Report saved: {out_path}", fg="cyan"))
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # CLI definition
242
+ # ---------------------------------------------------------------------------
243
+
244
+
245
+ @click.group()
246
+ @click.version_option(version=__version__, prog_name="django-arch-check")
247
+ def main() -> None:
248
+ """Django Architectural Health Checker.
249
+
250
+ Analyzes a Django project for structural and architectural issues.
251
+ """
252
+
253
+
254
+ @main.command()
255
+ @click.argument(
256
+ "project_path",
257
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, readable=True),
258
+ )
259
+ @click.option(
260
+ "--fat-model-threshold",
261
+ default=10,
262
+ show_default=True,
263
+ metavar="N",
264
+ help="Flag models with >= N non-dunder methods.",
265
+ )
266
+ @click.option(
267
+ "--god-app-threshold",
268
+ default=30,
269
+ show_default=True,
270
+ metavar="PCT",
271
+ help="Flag apps owning >= PCT% of total project LOC.",
272
+ )
273
+ @click.option(
274
+ "--format",
275
+ "output_format",
276
+ type=click.Choice(["text", "html"], case_sensitive=False),
277
+ default="text",
278
+ show_default=True,
279
+ help="Output format: text (stdout) or html (arch-report.html).",
280
+ )
281
+ def analyze(
282
+ project_path: str,
283
+ fat_model_threshold: int,
284
+ god_app_threshold: int,
285
+ output_format: str,
286
+ ) -> None:
287
+ """Analyze a Django project at PROJECT_PATH for architectural issues."""
288
+ click.echo(click.style(f"Analyzing: {project_path}", bold=True))
289
+
290
+ result = run_analysis(
291
+ project_path,
292
+ fat_model_threshold=fat_model_threshold,
293
+ god_app_threshold=god_app_threshold,
294
+ )
295
+
296
+ if output_format == "html":
297
+ _write_html_report(result, project_path)
298
+ return
299
+
300
+ _print_fat_models(result)
301
+ _print_god_apps(result)
302
+ _print_circular_imports(result)
303
+ _print_missing_service_layer(result)
304
+ _print_celery_tasks(result)
305
+ _print_direct_sql(result)
306
+ _print_n_plus_one(result)
307
+
308
+ # Exit non-zero if any critical findings exist across all detectors —
309
+ # allows the tool to act as a CI gate.
310
+ has_critical = (
311
+ any(f.severity == "critical" for f in result.fat_models)
312
+ or any(f.severity == "critical" for f in result.god_apps)
313
+ or any(f.severity == "critical" for f in result.circular_imports)
314
+ or any(f.severity == "critical" for f in result.missing_service_layer)
315
+ or any(f.severity == "critical" for f in result.celery_tasks)
316
+ )
317
+ if has_critical:
318
+ sys.exit(1)
@@ -0,0 +1,5 @@
1
+ """Detector plugins for django-arch-check.
2
+
3
+ Each module in this package implements a specific architectural check.
4
+ All detectors will be discovered and invoked by the analyzer.
5
+ """
@@ -0,0 +1,7 @@
1
+ """Base class for all architectural detectors.
2
+
3
+ All concrete detectors will inherit from BaseDetector and implement
4
+ the `run()` method.
5
+ """
6
+
7
+ # TODO: define BaseDetector protocol / abstract base class