devdoctor-cli 0.1.0__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.
- devdoctor/__init__.py +40 -0
- devdoctor/__main__.py +7 -0
- devdoctor/api.py +96 -0
- devdoctor/checks/__init__.py +38 -0
- devdoctor/checks/base.py +69 -0
- devdoctor/checks/dependencies.py +125 -0
- devdoctor/checks/django.py +128 -0
- devdoctor/checks/docker.py +124 -0
- devdoctor/checks/environment.py +146 -0
- devdoctor/checks/fastapi.py +96 -0
- devdoctor/checks/filesystem.py +72 -0
- devdoctor/checks/git.py +151 -0
- devdoctor/checks/imports.py +47 -0
- devdoctor/checks/pip.py +67 -0
- devdoctor/checks/ports.py +90 -0
- devdoctor/checks/postgres.py +65 -0
- devdoctor/checks/python.py +105 -0
- devdoctor/checks/redis.py +70 -0
- devdoctor/checks/virtualenv.py +79 -0
- devdoctor/cli.py +319 -0
- devdoctor/fixer.py +160 -0
- devdoctor/models.py +257 -0
- devdoctor/plugin.py +89 -0
- devdoctor/project.py +223 -0
- devdoctor/reporter.py +193 -0
- devdoctor/scanner.py +98 -0
- devdoctor/utils/__init__.py +1 -0
- devdoctor/utils/ast_imports.py +205 -0
- devdoctor/utils/env_parser.py +57 -0
- devdoctor/utils/platform_info.py +70 -0
- devdoctor/utils/process_utils.py +167 -0
- devdoctor_cli-0.1.0.dist-info/METADATA +382 -0
- devdoctor_cli-0.1.0.dist-info/RECORD +37 -0
- devdoctor_cli-0.1.0.dist-info/WHEEL +5 -0
- devdoctor_cli-0.1.0.dist-info/entry_points.txt +2 -0
- devdoctor_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- devdoctor_cli-0.1.0.dist-info/top_level.txt +1 -0
devdoctor/fixer.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Fix engine for DevDoctor: safe automatic fixes vs action-required fixes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.prompt import Confirm
|
|
11
|
+
|
|
12
|
+
from devdoctor.models import Diagnostic, DiagnosticReport, FixAction, ScanContext
|
|
13
|
+
from devdoctor.utils.process_utils import free_port, get_port_listener_info
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class FixItem:
|
|
18
|
+
"""A single diagnostic that has an executable fix."""
|
|
19
|
+
diagnostic: Diagnostic
|
|
20
|
+
action: FixAction
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def is_safe(self) -> bool:
|
|
24
|
+
return self.action.safe
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def description(self) -> str:
|
|
28
|
+
return self.action.description or self.diagnostic.fix or "Apply suggested fix"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class FixPlan:
|
|
33
|
+
"""A collection of fixes ready for review or execution."""
|
|
34
|
+
items: List[FixItem] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def safe_items(self) -> List[FixItem]:
|
|
38
|
+
return [i for i in self.items if i.is_safe]
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def action_items(self) -> List[FixItem]:
|
|
42
|
+
return [i for i in self.items if not i.is_safe]
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def total_count(self) -> int:
|
|
46
|
+
return len(self.items)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class FixResult:
|
|
51
|
+
"""Outcome of applying a fix plan."""
|
|
52
|
+
applied: List[FixItem] = field(default_factory=list)
|
|
53
|
+
failed: List[FixItem] = field(default_factory=list)
|
|
54
|
+
skipped: List[FixItem] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Fixer:
|
|
58
|
+
"""Coordinates deterministic, safe fix application."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, console: Optional[Console] = None):
|
|
61
|
+
self.console = console or Console()
|
|
62
|
+
|
|
63
|
+
def build_plan(self, report: DiagnosticReport) -> FixPlan:
|
|
64
|
+
"""Create a FixPlan from report diagnostics that have fix actions."""
|
|
65
|
+
items: List[FixItem] = []
|
|
66
|
+
for d in report.problems:
|
|
67
|
+
if d.fix_action:
|
|
68
|
+
items.append(FixItem(diagnostic=d, action=d.fix_action))
|
|
69
|
+
return FixPlan(items=items)
|
|
70
|
+
|
|
71
|
+
def execute_plan(
|
|
72
|
+
self,
|
|
73
|
+
plan: FixPlan,
|
|
74
|
+
context: ScanContext,
|
|
75
|
+
safe_only: bool = False,
|
|
76
|
+
dry_run: bool = False,
|
|
77
|
+
auto_yes: bool = False,
|
|
78
|
+
interactive: bool = True,
|
|
79
|
+
) -> FixResult:
|
|
80
|
+
"""Execute or preview fixes in the plan."""
|
|
81
|
+
result = FixResult()
|
|
82
|
+
|
|
83
|
+
if plan.total_count == 0:
|
|
84
|
+
self.console.print("[dim]No automated fixes available for identified problems.[/dim]")
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
candidates = plan.safe_items if safe_only else plan.items
|
|
88
|
+
|
|
89
|
+
if not candidates:
|
|
90
|
+
self.console.print("[yellow]No safe automatic fixes found. Remaining fixes require manual confirmation.[/yellow]")
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
self.console.print(f"[bold cyan]Found {len(candidates)} problem(s) with available fixes:[/bold cyan]\n")
|
|
94
|
+
for i, item in enumerate(candidates, start=1):
|
|
95
|
+
badge = "[green][SAFE][/green]" if item.is_safe else "[yellow][ACTION][/yellow]"
|
|
96
|
+
self.console.print(f" {i}. {badge} {item.description}")
|
|
97
|
+
if item.action.command:
|
|
98
|
+
self.console.print(f" [dim]Command: {item.action.command}[/dim]")
|
|
99
|
+
|
|
100
|
+
self.console.print()
|
|
101
|
+
|
|
102
|
+
if dry_run:
|
|
103
|
+
self.console.print("[bold yellow][DRY RUN] No changes were made to your machine or project.[/bold yellow]")
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
# Confirm if interactive and not auto_yes and not safe_only
|
|
107
|
+
if interactive and not auto_yes and not safe_only:
|
|
108
|
+
try:
|
|
109
|
+
proceed = Confirm.ask("Apply fixes?", default=True)
|
|
110
|
+
if not proceed:
|
|
111
|
+
self.console.print("[dim]Aborted by user. No fixes applied.[/dim]")
|
|
112
|
+
result.skipped.extend(candidates)
|
|
113
|
+
return result
|
|
114
|
+
except (KeyboardInterrupt, EOFError):
|
|
115
|
+
self.console.print("\n[dim]Aborted.[/dim]")
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
# Apply fixes
|
|
119
|
+
self.console.print("\n[bold]Applying fixes...[/bold]\n")
|
|
120
|
+
for item in candidates:
|
|
121
|
+
try:
|
|
122
|
+
success = item.action.execute(context)
|
|
123
|
+
if success:
|
|
124
|
+
self.console.print(f"[green]✓ Fixed:[/green] {item.description}")
|
|
125
|
+
result.applied.append(item)
|
|
126
|
+
else:
|
|
127
|
+
self.console.print(f"[red]❌ Failed:[/red] {item.description}")
|
|
128
|
+
result.failed.append(item)
|
|
129
|
+
except Exception as e:
|
|
130
|
+
self.console.print(f"[red]❌ Error fixing {item.description}:[/red] {e}")
|
|
131
|
+
result.failed.append(item)
|
|
132
|
+
|
|
133
|
+
self.console.print(f"\n[bold green]Completed:[/bold green] {len(result.applied)} applied, {len(result.failed)} failed.")
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
def free_port_interactive(self, port: int, auto_yes: bool = False) -> bool:
|
|
137
|
+
"""Safely free an occupied port."""
|
|
138
|
+
info = get_port_listener_info(port)
|
|
139
|
+
if not info or info.pid <= 0:
|
|
140
|
+
self.console.print(f"[green]✓ Port {port} is already free.[/green]")
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
self.console.print(f"[yellow]Port {port} is occupied by {info.process_name} (PID: {info.pid}).[/yellow]")
|
|
144
|
+
if not auto_yes:
|
|
145
|
+
try:
|
|
146
|
+
proceed = Confirm.ask(f"Terminate process {info.process_name} (PID: {info.pid})?", default=False)
|
|
147
|
+
if not proceed:
|
|
148
|
+
self.console.print("[dim]Action cancelled.[/dim]")
|
|
149
|
+
return False
|
|
150
|
+
except (KeyboardInterrupt, EOFError):
|
|
151
|
+
self.console.print("\n[dim]Cancelled.[/dim]")
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
freed = free_port(port)
|
|
155
|
+
if freed:
|
|
156
|
+
self.console.print(f"[bold green]✓ Port {port} has been freed.[/bold green]")
|
|
157
|
+
return True
|
|
158
|
+
else:
|
|
159
|
+
self.console.print(f"[bold red]❌ Failed to terminate process on port {port}. Run with elevated permissions if required.[/bold red]")
|
|
160
|
+
return False
|
devdoctor/models.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Core data models for DevDoctor diagnostics, reports, and fix actions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Severity(str, Enum):
|
|
15
|
+
"""Diagnostic severity levels."""
|
|
16
|
+
OK = "ok"
|
|
17
|
+
INFO = "info"
|
|
18
|
+
WARNING = "warning"
|
|
19
|
+
ERROR = "error"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Confidence(str, Enum):
|
|
23
|
+
"""Confidence rating for diagnostic conclusions."""
|
|
24
|
+
HIGH = "HIGH"
|
|
25
|
+
MEDIUM = "MEDIUM"
|
|
26
|
+
LOW = "LOW"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class FixAction:
|
|
31
|
+
"""An executable or proposed fix action for an identified problem."""
|
|
32
|
+
description: str
|
|
33
|
+
command: Optional[str] = None
|
|
34
|
+
safe: bool = True
|
|
35
|
+
action_type: str = "command" # "command", "file_create", "file_append", "kill_process", "custom"
|
|
36
|
+
target_path: Optional[Path] = None
|
|
37
|
+
content: Optional[str] = None
|
|
38
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
handler: Optional[Callable[[ScanContext], bool]] = None
|
|
40
|
+
|
|
41
|
+
def execute(self, context: ScanContext) -> bool:
|
|
42
|
+
"""Execute the fix safely."""
|
|
43
|
+
if self.handler:
|
|
44
|
+
return self.handler(context)
|
|
45
|
+
|
|
46
|
+
if self.action_type == "file_create" and self.target_path:
|
|
47
|
+
if not self.target_path.exists():
|
|
48
|
+
self.target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
self.target_path.write_text(self.content or "", encoding="utf-8")
|
|
50
|
+
return True
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
if self.action_type == "file_append" and self.target_path:
|
|
54
|
+
self.target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
current = self.target_path.read_text(encoding="utf-8") if self.target_path.exists() else ""
|
|
56
|
+
if self.content and self.content not in current:
|
|
57
|
+
with open(self.target_path, "a", encoding="utf-8") as f:
|
|
58
|
+
if current and not current.endswith("\n"):
|
|
59
|
+
f.write("\n")
|
|
60
|
+
f.write(self.content + "\n")
|
|
61
|
+
return True
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
if self.action_type == "kill_process" and "pid" in self.metadata:
|
|
65
|
+
pid = int(self.metadata["pid"])
|
|
66
|
+
from devdoctor.utils.process_utils import kill_process_by_pid
|
|
67
|
+
return kill_process_by_pid(pid)
|
|
68
|
+
|
|
69
|
+
if self.action_type == "command" and self.command:
|
|
70
|
+
import subprocess
|
|
71
|
+
try:
|
|
72
|
+
subprocess.run(self.command, shell=True, check=True, cwd=str(context.project_path))
|
|
73
|
+
return True
|
|
74
|
+
except Exception:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
80
|
+
return {
|
|
81
|
+
"description": self.description,
|
|
82
|
+
"command": self.command,
|
|
83
|
+
"safe": self.safe,
|
|
84
|
+
"action_type": self.action_type,
|
|
85
|
+
"target_path": str(self.target_path) if self.target_path else None,
|
|
86
|
+
"metadata": self.metadata,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class Diagnostic:
|
|
92
|
+
"""Represents a single diagnostic result from a check."""
|
|
93
|
+
title: str
|
|
94
|
+
severity: Severity = Severity.OK
|
|
95
|
+
message: str = ""
|
|
96
|
+
why: Optional[str] = None
|
|
97
|
+
evidence: Optional[str] = None
|
|
98
|
+
fix: Optional[str] = None
|
|
99
|
+
command: Optional[str] = None
|
|
100
|
+
confidence: Confidence = Confidence.HIGH
|
|
101
|
+
fix_action: Optional[FixAction] = None
|
|
102
|
+
check_id: Optional[str] = None
|
|
103
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def is_ok(self) -> bool:
|
|
107
|
+
return self.severity == Severity.OK
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def is_warning(self) -> bool:
|
|
111
|
+
return self.severity == Severity.WARNING
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def is_error(self) -> bool:
|
|
115
|
+
return self.severity == Severity.ERROR
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def is_problem(self) -> bool:
|
|
119
|
+
return self.severity in (Severity.WARNING, Severity.ERROR)
|
|
120
|
+
|
|
121
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
122
|
+
return {
|
|
123
|
+
"title": self.title,
|
|
124
|
+
"severity": self.severity.value,
|
|
125
|
+
"message": self.message,
|
|
126
|
+
"why": self.why,
|
|
127
|
+
"evidence": self.evidence,
|
|
128
|
+
"fix": self.fix,
|
|
129
|
+
"command": self.command,
|
|
130
|
+
"confidence": self.confidence.value,
|
|
131
|
+
"check_id": self.check_id,
|
|
132
|
+
"has_fix": self.fix_action is not None,
|
|
133
|
+
"fix_action": self.fix_action.to_dict() if self.fix_action else None,
|
|
134
|
+
"metadata": self.metadata,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class ProjectInfo:
|
|
140
|
+
"""Metadata regarding the project under inspection."""
|
|
141
|
+
root: Path
|
|
142
|
+
name: str = "project"
|
|
143
|
+
project_type: Optional[str] = None
|
|
144
|
+
technologies: List[str] = field(default_factory=list)
|
|
145
|
+
frameworks: List[str] = field(default_factory=list)
|
|
146
|
+
config_files: List[str] = field(default_factory=list)
|
|
147
|
+
dependencies: Dict[str, str] = field(default_factory=dict)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def summary_stack(self) -> str:
|
|
151
|
+
"""Returns readable tech stack string like 'Django + PostgreSQL + Redis'."""
|
|
152
|
+
items = []
|
|
153
|
+
if self.project_type and self.project_type not in ("Generic Python", "Unknown"):
|
|
154
|
+
items.append(self.project_type)
|
|
155
|
+
for t in self.technologies:
|
|
156
|
+
if t not in items and t.lower() != "python":
|
|
157
|
+
items.append(t)
|
|
158
|
+
return " + ".join(items) if items else (self.project_type or "Python Project")
|
|
159
|
+
|
|
160
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
161
|
+
return {
|
|
162
|
+
"root": str(self.root),
|
|
163
|
+
"name": self.name,
|
|
164
|
+
"project_type": self.project_type,
|
|
165
|
+
"technologies": self.technologies,
|
|
166
|
+
"frameworks": self.frameworks,
|
|
167
|
+
"config_files": self.config_files,
|
|
168
|
+
"dependencies_count": len(self.dependencies),
|
|
169
|
+
"summary_stack": self.summary_stack,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class ScanContext:
|
|
175
|
+
"""Context passed to each diagnostic check during scanning."""
|
|
176
|
+
project_path: Path
|
|
177
|
+
python_executable: str = field(default_factory=lambda: sys.executable)
|
|
178
|
+
python_version: str = field(default_factory=lambda: f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")
|
|
179
|
+
is_venv: bool = False
|
|
180
|
+
project_info: Optional[ProjectInfo] = None
|
|
181
|
+
env_vars: Dict[str, str] = field(default_factory=dict)
|
|
182
|
+
cache: Dict[str, Any] = field(default_factory=dict)
|
|
183
|
+
|
|
184
|
+
def get_cached(self, key: str, default: Any = None) -> Any:
|
|
185
|
+
return self.cache.get(key, default)
|
|
186
|
+
|
|
187
|
+
def set_cached(self, key: str, value: Any) -> None:
|
|
188
|
+
self.cache[key] = value
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@dataclass
|
|
192
|
+
class DiagnosticReport:
|
|
193
|
+
"""Comprehensive diagnostic run report."""
|
|
194
|
+
project_path: Path
|
|
195
|
+
project_info: ProjectInfo
|
|
196
|
+
diagnostics: List[Diagnostic] = field(default_factory=list)
|
|
197
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
198
|
+
duration_ms: float = 0.0
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def passed(self) -> List[Diagnostic]:
|
|
202
|
+
return [d for d in self.diagnostics if d.is_ok]
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def warnings(self) -> List[Diagnostic]:
|
|
206
|
+
return [d for d in self.diagnostics if d.is_warning]
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def errors(self) -> List[Diagnostic]:
|
|
210
|
+
return [d for d in self.diagnostics if d.is_error]
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def problems(self) -> List[Diagnostic]:
|
|
214
|
+
return [d for d in self.diagnostics if d.is_problem]
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def total_problems(self) -> int:
|
|
218
|
+
return len(self.problems)
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def is_healthy(self) -> bool:
|
|
222
|
+
return len(self.errors) == 0
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def score(self) -> int:
|
|
226
|
+
"""Deterministic health score from 0 to 100.
|
|
227
|
+
|
|
228
|
+
Formula:
|
|
229
|
+
Base 100.
|
|
230
|
+
Each error subtracts 12 points.
|
|
231
|
+
Each warning subtracts 4 points.
|
|
232
|
+
Clamped to [0, 100].
|
|
233
|
+
If no problems exist, score is 100.
|
|
234
|
+
"""
|
|
235
|
+
if not self.problems:
|
|
236
|
+
return 100
|
|
237
|
+
penalty = (len(self.errors) * 12) + (len(self.warnings) * 4)
|
|
238
|
+
return max(0, min(100, 100 - penalty))
|
|
239
|
+
|
|
240
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
241
|
+
return {
|
|
242
|
+
"version": "0.1.0",
|
|
243
|
+
"timestamp": self.timestamp.isoformat(),
|
|
244
|
+
"duration_ms": round(self.duration_ms, 2),
|
|
245
|
+
"score": self.score,
|
|
246
|
+
"healthy": self.is_healthy,
|
|
247
|
+
"project_path": str(self.project_path),
|
|
248
|
+
"project": self.project_info.to_dict(),
|
|
249
|
+
"summary": {
|
|
250
|
+
"total": len(self.diagnostics),
|
|
251
|
+
"passed": len(self.passed),
|
|
252
|
+
"warnings": len(self.warnings),
|
|
253
|
+
"errors": len(self.errors),
|
|
254
|
+
"problems": self.total_problems,
|
|
255
|
+
},
|
|
256
|
+
"diagnostics": [d.to_dict() for d in self.diagnostics],
|
|
257
|
+
}
|
devdoctor/plugin.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Plugin discovery and check extension loader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import importlib.util
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from devdoctor.checks import get_registered_checks
|
|
12
|
+
from devdoctor.checks.base import Check, register_check
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("devdoctor.plugins")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_entrypoint_checks() -> List[Check]:
|
|
18
|
+
"""Discover checks registered via Python packaging entry-points ('devdoctor.checks')."""
|
|
19
|
+
discovered: List[Check] = []
|
|
20
|
+
try:
|
|
21
|
+
eps = importlib.metadata.entry_points()
|
|
22
|
+
if hasattr(eps, "select"):
|
|
23
|
+
check_eps = eps.select(group="devdoctor.checks")
|
|
24
|
+
else:
|
|
25
|
+
check_eps = eps.get("devdoctor.checks", [])
|
|
26
|
+
|
|
27
|
+
for ep in check_eps:
|
|
28
|
+
try:
|
|
29
|
+
obj = ep.load()
|
|
30
|
+
if isinstance(obj, type) and issubclass(obj, Check):
|
|
31
|
+
register_check(obj)
|
|
32
|
+
discovered.append(obj())
|
|
33
|
+
elif callable(obj):
|
|
34
|
+
# Check factory
|
|
35
|
+
inst = obj()
|
|
36
|
+
if isinstance(inst, Check):
|
|
37
|
+
discovered.append(inst)
|
|
38
|
+
except Exception as e:
|
|
39
|
+
logger.debug(f"Failed to load entry point {ep.name}: {e}")
|
|
40
|
+
except Exception as e:
|
|
41
|
+
logger.debug(f"Error querying entry points: {e}")
|
|
42
|
+
|
|
43
|
+
return discovered
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_local_checks(checks_dir: Path) -> List[Check]:
|
|
47
|
+
"""Load custom check modules from a local directory (e.g. .devdoctor/checks/*.py)."""
|
|
48
|
+
custom_checks: List[Check] = []
|
|
49
|
+
if not checks_dir.is_dir():
|
|
50
|
+
return custom_checks
|
|
51
|
+
|
|
52
|
+
for py_file in checks_dir.glob("*.py"):
|
|
53
|
+
if py_file.name.startswith("_"):
|
|
54
|
+
continue
|
|
55
|
+
try:
|
|
56
|
+
spec = importlib.util.spec_from_file_location(f"devdoctor_local_{py_file.stem}", py_file)
|
|
57
|
+
if spec and spec.loader:
|
|
58
|
+
mod = importlib.util.module_from_spec(spec)
|
|
59
|
+
spec.loader.exec_module(mod)
|
|
60
|
+
for attr_name in dir(mod):
|
|
61
|
+
val = getattr(mod, attr_name)
|
|
62
|
+
if isinstance(val, type) and issubclass(val, Check) and val is not Check:
|
|
63
|
+
register_check(val)
|
|
64
|
+
custom_checks.append(val())
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.debug(f"Failed to load local check from {py_file}: {e}")
|
|
67
|
+
|
|
68
|
+
return custom_checks
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def discover_all_checks(project_path: Optional[Path] = None, custom_dir: Optional[Path] = None) -> List[Check]:
|
|
72
|
+
"""Collect all builtin, entrypoint, and local directory checks."""
|
|
73
|
+
# 1. Built-in checks
|
|
74
|
+
all_checks = list(get_registered_checks())
|
|
75
|
+
|
|
76
|
+
# 2. Package entry points
|
|
77
|
+
load_entrypoint_checks()
|
|
78
|
+
|
|
79
|
+
# 3. Project-local .devdoctor/checks/
|
|
80
|
+
if project_path:
|
|
81
|
+
local_dir = project_path / ".devdoctor" / "checks"
|
|
82
|
+
if local_dir.is_dir():
|
|
83
|
+
load_local_checks(local_dir)
|
|
84
|
+
|
|
85
|
+
# 4. Explicit custom directory if provided
|
|
86
|
+
if custom_dir and custom_dir.is_dir():
|
|
87
|
+
load_local_checks(custom_dir)
|
|
88
|
+
|
|
89
|
+
return get_registered_checks()
|