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/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()