chisel-checker 0.1.1__py3-none-any.whl
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.
- chisel/__init__.py +4 -0
- chisel/checker/__init__.py +11 -0
- chisel/checker/config.py +9 -0
- chisel/checker/controllers/check_controller.py +124 -0
- chisel/checker/errors.py +16 -0
- chisel/checker/factory.py +45 -0
- chisel/checker/models/__init__.py +20 -0
- chisel/checker/models/exemption.py +8 -0
- chisel/checker/models/file_info.py +14 -0
- chisel/checker/models/import_edge.py +10 -0
- chisel/checker/models/layer.py +19 -0
- chisel/checker/models/project_info.py +12 -0
- chisel/checker/models/result.py +17 -0
- chisel/checker/models/severity.py +8 -0
- chisel/checker/models/violation.py +13 -0
- chisel/checker/reporter.py +70 -0
- chisel/checker/repositories/exception_registry.py +60 -0
- chisel/checker/repositories/file_discovery.py +119 -0
- chisel/checker/repositories/file_reader.py +8 -0
- chisel/checker/repositories/import_graph.py +100 -0
- chisel/checker/repositories/protocols.py +25 -0
- chisel/checker/services/app_file.py +125 -0
- chisel/checker/services/check_test_structure.py +331 -0
- chisel/checker/services/complexity.py +203 -0
- chisel/checker/services/concurrency.py +80 -0
- chisel/checker/services/config_startup.py +67 -0
- chisel/checker/services/error_flow.py +95 -0
- chisel/checker/services/import_boundary.py +285 -0
- chisel/checker/services/project_structure.py +216 -0
- chisel/checker/services/protocols.py +72 -0
- chisel/checker/services/session.py +76 -0
- chisel/checker/services/structural.py +799 -0
- chisel/checker/services/suppression.py +98 -0
- chisel/cli/main.py +126 -0
- chisel/py.typed +0 -0
- chisel_checker-0.1.1.dist-info/METADATA +107 -0
- chisel_checker-0.1.1.dist-info/RECORD +39 -0
- chisel_checker-0.1.1.dist-info/WHEEL +4 -0
- chisel_checker-0.1.1.dist-info/entry_points.txt +2 -0
chisel/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
|
|
2
|
+
from chisel.checker.factory import CheckerFactory
|
|
3
|
+
from chisel.checker.reporter import Reporter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def check_project(project_path: str) -> None:
|
|
7
|
+
factory = CheckerFactory()
|
|
8
|
+
controller = factory.create_controller()
|
|
9
|
+
result = controller.check(project_path)
|
|
10
|
+
reporter = Reporter()
|
|
11
|
+
reporter.report(result)
|
chisel/checker/config.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
|
|
2
|
+
import ast
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from chisel.checker.errors import ImportGraphError
|
|
8
|
+
from chisel.checker.models.project_info import ProjectInfo
|
|
9
|
+
from chisel.checker.models.result import CheckResult
|
|
10
|
+
from chisel.checker.models.severity import Severity
|
|
11
|
+
from chisel.checker.models.violation import Violation
|
|
12
|
+
from chisel.checker.repositories.file_discovery import FileDiscovery
|
|
13
|
+
from chisel.checker.repositories.file_reader import FileReader
|
|
14
|
+
from chisel.checker.repositories.import_graph import ImportGraph
|
|
15
|
+
from chisel.checker.repositories.protocols import IImportGraph
|
|
16
|
+
from chisel.checker.services.protocols import ICheckerService, ISuppressionService
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
from chisel.checker.repositories.exception_registry import ExceptionRegistry
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class CheckController:
|
|
24
|
+
_suppression: ISuppressionService
|
|
25
|
+
_services: list[ICheckerService] = field(default_factory=list)
|
|
26
|
+
_import_graph: IImportGraph = field(default_factory=ImportGraph) # noqa
|
|
27
|
+
|
|
28
|
+
def check(self, project_path: str) -> CheckResult:
|
|
29
|
+
root = Path(project_path).resolve()
|
|
30
|
+
project = self._prepare_project(root)
|
|
31
|
+
violations: list[Violation] = []
|
|
32
|
+
|
|
33
|
+
if project.package_name:
|
|
34
|
+
try:
|
|
35
|
+
self._build_import_graph(root, project.package_name)
|
|
36
|
+
except ImportGraphError as exc:
|
|
37
|
+
violations.append(
|
|
38
|
+
Violation(
|
|
39
|
+
file="<import-graph>",
|
|
40
|
+
line=0,
|
|
41
|
+
severity=Severity.WARNING,
|
|
42
|
+
rule_id="import-graph:build-failed",
|
|
43
|
+
message=str(exc),
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
violations.extend(self._run_services(project))
|
|
48
|
+
violations = self._apply_exceptions(root, violations)
|
|
49
|
+
sources = self._collect_sources(project)
|
|
50
|
+
active = self._suppression.check(violations, sources)
|
|
51
|
+
return self._summarize(active, len(project.files))
|
|
52
|
+
|
|
53
|
+
def _prepare_project(self, root: Path) -> ProjectInfo:
|
|
54
|
+
discovery = FileDiscovery()
|
|
55
|
+
project = discovery.discover(root)
|
|
56
|
+
reader = FileReader()
|
|
57
|
+
populated = [
|
|
58
|
+
self._load_file(f, root, reader) for f in project.files
|
|
59
|
+
]
|
|
60
|
+
return ProjectInfo(
|
|
61
|
+
root_path=project.root_path,
|
|
62
|
+
files=populated,
|
|
63
|
+
package_name=project.package_name,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def _load_file(
|
|
67
|
+
self, file_info, root: Path, reader: FileReader
|
|
68
|
+
):
|
|
69
|
+
try:
|
|
70
|
+
source = reader.read(root / file_info.path)
|
|
71
|
+
return type(file_info)(
|
|
72
|
+
path=file_info.path,
|
|
73
|
+
layer=file_info.layer,
|
|
74
|
+
source=source,
|
|
75
|
+
ast_tree=ast.parse(source),
|
|
76
|
+
)
|
|
77
|
+
except Exception:
|
|
78
|
+
return file_info
|
|
79
|
+
|
|
80
|
+
def _build_import_graph(self, root: Path, package_name: str) -> None:
|
|
81
|
+
src = root / "src"
|
|
82
|
+
src_path = str(src) if src.is_dir() else str(root)
|
|
83
|
+
original_path = list(sys.path)
|
|
84
|
+
try:
|
|
85
|
+
if src_path not in sys.path:
|
|
86
|
+
sys.path.insert(0, src_path)
|
|
87
|
+
self._import_graph.build(root, package_name)
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
raise ImportGraphError(
|
|
90
|
+
f"Failed to build import graph for "
|
|
91
|
+
f"'{package_name}': {exc}"
|
|
92
|
+
) from exc
|
|
93
|
+
finally:
|
|
94
|
+
sys.path = original_path
|
|
95
|
+
|
|
96
|
+
def _run_services(self, project: ProjectInfo) -> list[Violation]:
|
|
97
|
+
violations: list[Violation] = []
|
|
98
|
+
for service in self._services:
|
|
99
|
+
violations.extend(service.check(project))
|
|
100
|
+
return violations
|
|
101
|
+
|
|
102
|
+
def _collect_sources(self, project: ProjectInfo) -> dict[str, str]:
|
|
103
|
+
return {str(f.path): f.source for f in project.files}
|
|
104
|
+
|
|
105
|
+
def _apply_exceptions(
|
|
106
|
+
self, root: Path, violations: list[Violation]
|
|
107
|
+
) -> list[Violation]:
|
|
108
|
+
registry = ExceptionRegistry()
|
|
109
|
+
registry.load(root)
|
|
110
|
+
return registry.filter(violations)
|
|
111
|
+
|
|
112
|
+
def _summarize(
|
|
113
|
+
self, violations: list[Violation], files_checked: int
|
|
114
|
+
) -> CheckResult:
|
|
115
|
+
errors = sum(1 for v in violations if v.severity == Severity.ERROR)
|
|
116
|
+
warnings = sum(1 for v in violations if v.severity == Severity.WARNING)
|
|
117
|
+
info = sum(1 for v in violations if v.severity == Severity.INFO)
|
|
118
|
+
return CheckResult(
|
|
119
|
+
violations=violations,
|
|
120
|
+
errors=errors,
|
|
121
|
+
warnings=warnings,
|
|
122
|
+
info=info,
|
|
123
|
+
files_checked=files_checked,
|
|
124
|
+
)
|
chisel/checker/errors.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
from chisel.checker.controllers.check_controller import CheckController
|
|
5
|
+
from chisel.checker.repositories.import_graph import ImportGraph
|
|
6
|
+
from chisel.checker.repositories.protocols import IImportGraph
|
|
7
|
+
from chisel.checker.services.app_file import AppFileService
|
|
8
|
+
from chisel.checker.services.complexity import ComplexityService
|
|
9
|
+
from chisel.checker.services.concurrency import ConcurrencyService
|
|
10
|
+
from chisel.checker.services.config_startup import ConfigStartupService
|
|
11
|
+
from chisel.checker.services.error_flow import ErrorFlowService
|
|
12
|
+
from chisel.checker.services.import_boundary import ImportBoundaryService
|
|
13
|
+
from chisel.checker.services.project_structure import ProjectStructureService
|
|
14
|
+
from chisel.checker.services.protocols import ICheckerService
|
|
15
|
+
from chisel.checker.services.session import SessionService
|
|
16
|
+
from chisel.checker.services.structural import StructuralService
|
|
17
|
+
from chisel.checker.services.suppression import SuppressionService
|
|
18
|
+
from chisel.checker.services.check_test_structure import CheckTestStructureService
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class CheckerFactory:
|
|
23
|
+
_import_graph: IImportGraph = field(default_factory=ImportGraph)
|
|
24
|
+
_suppression: SuppressionService = field(default_factory=SuppressionService)
|
|
25
|
+
strict: bool = True
|
|
26
|
+
|
|
27
|
+
def create_controller(self) -> CheckController:
|
|
28
|
+
services: list[ICheckerService] = [
|
|
29
|
+
ImportBoundaryService(_import_graph=self._import_graph),
|
|
30
|
+
StructuralService(),
|
|
31
|
+
ComplexityService(),
|
|
32
|
+
ConcurrencyService(),
|
|
33
|
+
SessionService(),
|
|
34
|
+
ErrorFlowService(),
|
|
35
|
+
ConfigStartupService(),
|
|
36
|
+
ProjectStructureService(strict=self.strict),
|
|
37
|
+
AppFileService(),
|
|
38
|
+
CheckTestStructureService(),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
return CheckController(
|
|
42
|
+
_services=services,
|
|
43
|
+
_suppression=self._suppression,
|
|
44
|
+
_import_graph=self._import_graph,
|
|
45
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
|
|
2
|
+
from chisel.checker.models.exemption import Exemption
|
|
3
|
+
from chisel.checker.models.file_info import FileInfo
|
|
4
|
+
from chisel.checker.models.import_edge import ImportEdge
|
|
5
|
+
from chisel.checker.models.layer import Layer
|
|
6
|
+
from chisel.checker.models.project_info import ProjectInfo
|
|
7
|
+
from chisel.checker.models.result import CheckResult
|
|
8
|
+
from chisel.checker.models.severity import Severity
|
|
9
|
+
from chisel.checker.models.violation import Violation
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"CheckResult",
|
|
13
|
+
"Exemption",
|
|
14
|
+
"FileInfo",
|
|
15
|
+
"ImportEdge",
|
|
16
|
+
"Layer",
|
|
17
|
+
"ProjectInfo",
|
|
18
|
+
"Severity",
|
|
19
|
+
"Violation",
|
|
20
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
|
|
2
|
+
import ast
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from chisel.checker.models.layer import Layer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class FileInfo:
|
|
11
|
+
path: Path
|
|
12
|
+
layer: Layer
|
|
13
|
+
source: str = ""
|
|
14
|
+
ast_tree: ast.Module | None = None
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Layer(Enum):
|
|
6
|
+
MODELS = "models"
|
|
7
|
+
ERRORS = "errors"
|
|
8
|
+
CONFIG = "config"
|
|
9
|
+
SERVICES = "services"
|
|
10
|
+
REPOSITORIES = "repositories"
|
|
11
|
+
CONTROLLERS = "controllers"
|
|
12
|
+
FACTORY = "factory"
|
|
13
|
+
ROUTES = "routes"
|
|
14
|
+
DEPENDENCIES = "dependencies"
|
|
15
|
+
ERROR_HANDLERS = "error_handlers"
|
|
16
|
+
APP_FILE = "app_file"
|
|
17
|
+
UTILS = "utils"
|
|
18
|
+
TESTS = "tests"
|
|
19
|
+
UNKNOWN = "unknown"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from chisel.checker.models.file_info import FileInfo
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class ProjectInfo:
|
|
10
|
+
root_path: Path
|
|
11
|
+
files: list[FileInfo] = field(default_factory=list)
|
|
12
|
+
package_name: str = ""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
from chisel.checker.models.violation import Violation
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class CheckResult:
|
|
9
|
+
violations: list[Violation] = field(default_factory=list)
|
|
10
|
+
errors: int = 0
|
|
11
|
+
warnings: int = 0
|
|
12
|
+
info: int = 0
|
|
13
|
+
files_checked: int = 0
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def has_errors(self) -> bool:
|
|
17
|
+
return self.errors > 0
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
|
|
2
|
+
import json
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from chisel.checker.models.result import CheckResult
|
|
9
|
+
from chisel.checker.models.severity import Severity
|
|
10
|
+
from chisel.checker.models.violation import Violation
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class Reporter:
|
|
15
|
+
_console: Console = Console()
|
|
16
|
+
|
|
17
|
+
def report(self, result: CheckResult) -> None:
|
|
18
|
+
table = Table(title="Chisel Architecture Check")
|
|
19
|
+
table.add_column("File", style="cyan", no_wrap=False)
|
|
20
|
+
table.add_column("Line", style="dim", justify="right")
|
|
21
|
+
table.add_column("Severity")
|
|
22
|
+
table.add_column("Rule")
|
|
23
|
+
table.add_column("Message", style="white")
|
|
24
|
+
|
|
25
|
+
for v in result.violations:
|
|
26
|
+
sev = self._severity_label(v.severity)
|
|
27
|
+
table.add_row(
|
|
28
|
+
v.file,
|
|
29
|
+
str(v.line),
|
|
30
|
+
sev,
|
|
31
|
+
v.rule_id,
|
|
32
|
+
v.message,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
self._console.print(table)
|
|
36
|
+
|
|
37
|
+
self._console.print(
|
|
38
|
+
f"\n[fine]: {result.files_checked} files checked | "
|
|
39
|
+
f"[red]{result.errors} errors[/red] | "
|
|
40
|
+
f"[yellow]{result.warnings} warnings[/yellow] | "
|
|
41
|
+
f"[blue]{result.info} info[/blue]"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def report_json(self, result: CheckResult) -> str:
|
|
45
|
+
data = {
|
|
46
|
+
"summary": {
|
|
47
|
+
"files_checked": result.files_checked,
|
|
48
|
+
"errors": result.errors,
|
|
49
|
+
"warnings": result.warnings,
|
|
50
|
+
"info": result.info,
|
|
51
|
+
},
|
|
52
|
+
"violations": [
|
|
53
|
+
{
|
|
54
|
+
"file": v.file,
|
|
55
|
+
"line": v.line,
|
|
56
|
+
"severity": v.severity.value,
|
|
57
|
+
"rule_id": v.rule_id,
|
|
58
|
+
"message": v.message,
|
|
59
|
+
}
|
|
60
|
+
for v in result.violations
|
|
61
|
+
],
|
|
62
|
+
}
|
|
63
|
+
return json.dumps(data, indent=2)
|
|
64
|
+
|
|
65
|
+
def _severity_label(self, severity: Severity) -> str:
|
|
66
|
+
if severity == Severity.ERROR:
|
|
67
|
+
return "[red]ERROR[/red]"
|
|
68
|
+
if severity == Severity.WARNING:
|
|
69
|
+
return "[yellow]WARNING[/yellow]"
|
|
70
|
+
return "[blue]INFO[/blue]"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import tomllib
|
|
7
|
+
except ImportError:
|
|
8
|
+
import tomli as tomllib
|
|
9
|
+
|
|
10
|
+
from chisel.checker.models.exemption import Exemption
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class ExceptionRegistry:
|
|
15
|
+
_exemptions: list[Exemption] = field(default_factory=list)
|
|
16
|
+
|
|
17
|
+
def load(self, root: Path) -> None:
|
|
18
|
+
config_path = root / "chisel-exceptions.toml"
|
|
19
|
+
if not config_path.exists():
|
|
20
|
+
return
|
|
21
|
+
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
22
|
+
for entry in data.get("exceptions", []):
|
|
23
|
+
self._exemptions.append(
|
|
24
|
+
Exemption(
|
|
25
|
+
file_patterns=list(entry.get("files", [])),
|
|
26
|
+
rule_ids=list(entry.get("rules", [])),
|
|
27
|
+
reason=entry.get("reason", ""),
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def is_exempted(self, file: str, rule_id: str) -> bool:
|
|
32
|
+
for exemption in self._exemptions:
|
|
33
|
+
if not self._file_matches(file, exemption.file_patterns):
|
|
34
|
+
continue
|
|
35
|
+
if not self._rule_matches(rule_id, exemption.rule_ids):
|
|
36
|
+
continue
|
|
37
|
+
return True
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
def _file_matches(self, file: str, patterns: list[str]) -> bool:
|
|
41
|
+
for pattern in patterns:
|
|
42
|
+
if fnmatch.fnmatch(file, pattern):
|
|
43
|
+
return True
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
def _rule_matches(self, rule_id: str, rules: list[str]) -> bool:
|
|
47
|
+
for rule in rules:
|
|
48
|
+
if rule == "*":
|
|
49
|
+
return True
|
|
50
|
+
if rule_id == rule:
|
|
51
|
+
return True
|
|
52
|
+
if rule_id.startswith(rule + ":") or rule_id.startswith(rule + "."):
|
|
53
|
+
return True
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
def filter(self, violations: list) -> list:
|
|
57
|
+
return [
|
|
58
|
+
v for v in violations
|
|
59
|
+
if not self.is_exempted(v.file, v.rule_id)
|
|
60
|
+
]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from chisel.checker.models.file_info import FileInfo
|
|
6
|
+
from chisel.checker.models.layer import Layer
|
|
7
|
+
from chisel.checker.models.project_info import ProjectInfo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class FileDiscovery:
|
|
12
|
+
_LAYER_MAP = {
|
|
13
|
+
"models": Layer.MODELS,
|
|
14
|
+
"errors": Layer.ERRORS,
|
|
15
|
+
"config": Layer.CONFIG,
|
|
16
|
+
"services": Layer.SERVICES,
|
|
17
|
+
"repositories": Layer.REPOSITORIES,
|
|
18
|
+
"controllers": Layer.CONTROLLERS,
|
|
19
|
+
"factory": Layer.FACTORY,
|
|
20
|
+
"routes": Layer.ROUTES,
|
|
21
|
+
"dependencies": Layer.DEPENDENCIES,
|
|
22
|
+
"error_handlers": Layer.ERROR_HANDLERS,
|
|
23
|
+
"utils": Layer.UTILS,
|
|
24
|
+
"tests": Layer.TESTS,
|
|
25
|
+
"app": Layer.APP_FILE,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_LAYER_FILENAMES = {
|
|
29
|
+
"errors.py": Layer.ERRORS,
|
|
30
|
+
"config.py": Layer.CONFIG,
|
|
31
|
+
"factory.py": Layer.FACTORY,
|
|
32
|
+
"dependencies.py": Layer.DEPENDENCIES,
|
|
33
|
+
"error_handlers.py": Layer.ERROR_HANDLERS,
|
|
34
|
+
"app.py": Layer.APP_FILE,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
def discover(self, root_path: Path) -> ProjectInfo:
|
|
38
|
+
root_path = root_path.resolve()
|
|
39
|
+
package_name = self._find_package_name(root_path)
|
|
40
|
+
src_root = self._find_src_root(root_path, package_name)
|
|
41
|
+
|
|
42
|
+
files: list[FileInfo] = []
|
|
43
|
+
for py_file in sorted(src_root.rglob("*.py")):
|
|
44
|
+
if self._is_ignored(py_file, src_root):
|
|
45
|
+
continue
|
|
46
|
+
relative = py_file.relative_to(root_path)
|
|
47
|
+
layer = self._classify_file(py_file, src_root, package_name)
|
|
48
|
+
files.append(FileInfo(path=relative, layer=layer))
|
|
49
|
+
|
|
50
|
+
tests_root = root_path / "tests"
|
|
51
|
+
if tests_root.is_dir():
|
|
52
|
+
for py_file in sorted(tests_root.rglob("*.py")):
|
|
53
|
+
if self._is_ignored(py_file, tests_root):
|
|
54
|
+
continue
|
|
55
|
+
relative = py_file.relative_to(root_path)
|
|
56
|
+
files.append(FileInfo(path=relative, layer=Layer.TESTS))
|
|
57
|
+
|
|
58
|
+
return ProjectInfo(root_path=root_path, files=files, package_name=package_name)
|
|
59
|
+
|
|
60
|
+
def _find_package_name(self, root_path: Path) -> str:
|
|
61
|
+
src = root_path / "src"
|
|
62
|
+
if src.is_dir():
|
|
63
|
+
for child in sorted(src.iterdir()):
|
|
64
|
+
if child.is_dir() and (child / "__init__.py").exists():
|
|
65
|
+
return child.name
|
|
66
|
+
|
|
67
|
+
for child in sorted(root_path.iterdir()):
|
|
68
|
+
if not child.is_dir():
|
|
69
|
+
continue
|
|
70
|
+
if child.name in ("tests", "__pycache__", ".mypy_cache", ".venv", "venv", "node_modules"):
|
|
71
|
+
continue
|
|
72
|
+
if child.name.startswith("."):
|
|
73
|
+
continue
|
|
74
|
+
if (child / "__init__.py").exists():
|
|
75
|
+
return child.name
|
|
76
|
+
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
def _find_src_root(self, root_path: Path, package_name: str) -> Path:
|
|
80
|
+
if package_name:
|
|
81
|
+
candidate = root_path / "src" / package_name
|
|
82
|
+
if candidate.is_dir():
|
|
83
|
+
return candidate
|
|
84
|
+
candidate = root_path / package_name
|
|
85
|
+
if candidate.is_dir():
|
|
86
|
+
return candidate
|
|
87
|
+
src = root_path / "src"
|
|
88
|
+
if src.is_dir():
|
|
89
|
+
return src
|
|
90
|
+
return root_path
|
|
91
|
+
|
|
92
|
+
def _classify_file(self, file_path: Path, src_root: Path, package_name: str) -> Layer:
|
|
93
|
+
relative = file_path.relative_to(src_root)
|
|
94
|
+
|
|
95
|
+
filename = file_path.name
|
|
96
|
+
if filename in self._LAYER_FILENAMES:
|
|
97
|
+
return self._LAYER_FILENAMES[filename]
|
|
98
|
+
|
|
99
|
+
parents = [p.name for p in file_path.parents if str(p) != str(src_root)]
|
|
100
|
+
parts = relative.parts
|
|
101
|
+
|
|
102
|
+
for part in parts[:-1]:
|
|
103
|
+
if part in self._LAYER_MAP:
|
|
104
|
+
return self._LAYER_MAP[part]
|
|
105
|
+
|
|
106
|
+
for part in reversed(parts[:-1]):
|
|
107
|
+
if part in self._LAYER_MAP:
|
|
108
|
+
return self._LAYER_MAP[part]
|
|
109
|
+
|
|
110
|
+
return Layer.UNKNOWN
|
|
111
|
+
|
|
112
|
+
def _is_ignored(self, path: Path, src_root: Path) -> bool:
|
|
113
|
+
parts = path.parts
|
|
114
|
+
for part in parts:
|
|
115
|
+
if part.startswith(".") or part in ("__pycache__", "node_modules", ".venv", "venv"):
|
|
116
|
+
return True
|
|
117
|
+
if part == "migrations":
|
|
118
|
+
return True
|
|
119
|
+
return False
|