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.
- django_arch_check-0.1.0/.gitignore +45 -0
- django_arch_check-0.1.0/PKG-INFO +61 -0
- django_arch_check-0.1.0/README.md +33 -0
- django_arch_check-0.1.0/django_arch_check/__init__.py +3 -0
- django_arch_check-0.1.0/django_arch_check/analyzer.py +68 -0
- django_arch_check-0.1.0/django_arch_check/cli.py +318 -0
- django_arch_check-0.1.0/django_arch_check/detectors/__init__.py +5 -0
- django_arch_check-0.1.0/django_arch_check/detectors/base.py +7 -0
- django_arch_check-0.1.0/django_arch_check/detectors/celery_tasks.py +212 -0
- django_arch_check-0.1.0/django_arch_check/detectors/circular_imports.py +339 -0
- django_arch_check-0.1.0/django_arch_check/detectors/direct_sql.py +150 -0
- django_arch_check-0.1.0/django_arch_check/detectors/fat_models.py +190 -0
- django_arch_check-0.1.0/django_arch_check/detectors/god_apps.py +205 -0
- django_arch_check-0.1.0/django_arch_check/detectors/missing_service_layer.py +240 -0
- django_arch_check-0.1.0/django_arch_check/detectors/n_plus_one.py +257 -0
- django_arch_check-0.1.0/django_arch_check/report.py +464 -0
- django_arch_check-0.1.0/pyproject.toml +54 -0
- django_arch_check-0.1.0/tests/__init__.py +0 -0
- django_arch_check-0.1.0/tests/conftest.py +44 -0
- django_arch_check-0.1.0/tests/test_celery_tasks.py +223 -0
- django_arch_check-0.1.0/tests/test_circular_imports.py +199 -0
- django_arch_check-0.1.0/tests/test_cli.py +216 -0
- django_arch_check-0.1.0/tests/test_direct_sql.py +162 -0
- django_arch_check-0.1.0/tests/test_fat_models.py +180 -0
- django_arch_check-0.1.0/tests/test_god_apps.py +152 -0
- django_arch_check-0.1.0/tests/test_missing_service_layer.py +200 -0
- django_arch_check-0.1.0/tests/test_n_plus_one.py +261 -0
- 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,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)
|