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/project.py ADDED
@@ -0,0 +1,223 @@
1
+ """Project detector: inspects files and config to determine project type and tech stack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Dict, List, Optional, Set
9
+
10
+ from devdoctor.models import ProjectInfo
11
+
12
+
13
+ def parse_requirements_txt(file_path: Path) -> Dict[str, str]:
14
+ """Extract package names and version specifiers from a requirements.txt file."""
15
+ deps: Dict[str, str] = {}
16
+ if not file_path.is_file():
17
+ return deps
18
+ try:
19
+ content = file_path.read_text(encoding="utf-8", errors="replace")
20
+ for line in content.splitlines():
21
+ line = line.strip()
22
+ if not line or line.startswith("#") or line.startswith("-"):
23
+ continue
24
+ # Match package name and optional version specifier
25
+ match = re.match(r"^([A-Za-z0-9_.\-]+)\s*([<>=!~].*)?$", line)
26
+ if match:
27
+ pkg = match.group(1).lower().replace("_", "-")
28
+ spec = match.group(2).strip() if match.group(2) else "*"
29
+ deps[pkg] = spec
30
+ except Exception:
31
+ pass
32
+ return deps
33
+
34
+
35
+ def parse_pyproject_deps(file_path: Path) -> Dict[str, str]:
36
+ """Parse dependencies from pyproject.toml."""
37
+ deps: Dict[str, str] = {}
38
+ if not file_path.is_file():
39
+ return deps
40
+ try:
41
+ content = file_path.read_text(encoding="utf-8", errors="replace")
42
+ # Match standard PEP 621 dependencies list: dependencies = [ ... ]
43
+ in_deps = False
44
+ for line in content.splitlines():
45
+ line = line.strip()
46
+ if re.match(r"^dependencies\s*=\s*\[", line):
47
+ in_deps = True
48
+ continue
49
+ if in_deps:
50
+ if line.startswith("]"):
51
+ in_deps = False
52
+ continue
53
+ # Match quoted string like "fastapi>=0.100.0"
54
+ match = re.search(r'["\']([A-Za-z0-9_.\-]+)([^"\']*)["\']', line)
55
+ if match:
56
+ pkg = match.group(1).lower().replace("_", "-")
57
+ spec = match.group(2).strip() or "*"
58
+ deps[pkg] = spec
59
+ except Exception:
60
+ pass
61
+ return deps
62
+
63
+
64
+ def detect_project(project_path: Path) -> ProjectInfo:
65
+ """Analyze the project directory and build a deterministic ProjectInfo profile."""
66
+ root = project_path.resolve()
67
+ name = root.name
68
+
69
+ config_files: List[str] = []
70
+ technologies: List[str] = ["Python"]
71
+ frameworks: List[str] = []
72
+ project_type: Optional[str] = None
73
+ dependencies: Dict[str, str] = {}
74
+
75
+ # Check key files
76
+ known_files = [
77
+ "pyproject.toml",
78
+ "requirements.txt",
79
+ "setup.py",
80
+ "manage.py",
81
+ "Dockerfile",
82
+ "docker-compose.yml",
83
+ "docker-compose.yaml",
84
+ "compose.yaml",
85
+ "compose.yml",
86
+ ".env",
87
+ ".env.example",
88
+ ".git",
89
+ "alembic.ini",
90
+ "package.json",
91
+ "Pipfile",
92
+ "poetry.lock",
93
+ ]
94
+
95
+ for fname in known_files:
96
+ if (root / fname).exists():
97
+ config_files.append(fname)
98
+
99
+ # Collect dependencies
100
+ if (root / "requirements.txt").exists():
101
+ dependencies.update(parse_requirements_txt(root / "requirements.txt"))
102
+ if (root / "pyproject.toml").exists():
103
+ dependencies.update(parse_pyproject_deps(root / "pyproject.toml"))
104
+
105
+ dep_keys = set(dependencies.keys())
106
+
107
+ # Detect Django
108
+ if (root / "manage.py").exists() or "django" in dep_keys:
109
+ project_type = "Django"
110
+ frameworks.append("Django")
111
+ if "Django" not in technologies:
112
+ technologies.append("Django")
113
+
114
+ # Detect FastAPI
115
+ elif "fastapi" in dep_keys:
116
+ project_type = "FastAPI"
117
+ frameworks.append("FastAPI")
118
+ if "FastAPI" not in technologies:
119
+ technologies.append("FastAPI")
120
+
121
+ # Detect Flask
122
+ elif "flask" in dep_keys:
123
+ project_type = "Flask"
124
+ frameworks.append("Flask")
125
+ if "Flask" not in technologies:
126
+ technologies.append("Flask")
127
+
128
+ # Check AST or code if not found via dependencies
129
+ if not project_type:
130
+ for py_file in ["main.py", "app.py", "api.py", "server.py"]:
131
+ candidate = root / py_file
132
+ if candidate.is_file():
133
+ try:
134
+ text = candidate.read_text(encoding="utf-8", errors="replace")
135
+ if "FastAPI(" in text or "from fastapi import" in text:
136
+ project_type = "FastAPI"
137
+ frameworks.append("FastAPI")
138
+ if "FastAPI" not in technologies:
139
+ technologies.append("FastAPI")
140
+ break
141
+ elif "Flask(" in text or "from flask import" in text:
142
+ project_type = "Flask"
143
+ frameworks.append("Flask")
144
+ if "Flask" not in technologies:
145
+ technologies.append("Flask")
146
+ break
147
+ except Exception:
148
+ pass
149
+
150
+ if not project_type:
151
+ project_type = "Generic Python"
152
+
153
+ # Detect Docker
154
+ has_docker = (
155
+ (root / "Dockerfile").exists()
156
+ or (root / "docker-compose.yml").exists()
157
+ or (root / "docker-compose.yaml").exists()
158
+ or (root / "compose.yaml").exists()
159
+ or (root / "compose.yml").exists()
160
+ )
161
+ if has_docker:
162
+ technologies.append("Docker")
163
+
164
+ # Detect Compose contents for database and cache services
165
+ compose_content = ""
166
+ for cfile in ["docker-compose.yml", "docker-compose.yaml", "compose.yaml", "compose.yml"]:
167
+ target = root / cfile
168
+ if target.is_file():
169
+ try:
170
+ compose_content += "\n" + target.read_text(encoding="utf-8", errors="replace").lower()
171
+ except Exception:
172
+ pass
173
+
174
+ # Read .env if present
175
+ env_content = ""
176
+ if (root / ".env").is_file():
177
+ try:
178
+ env_content = (root / ".env").read_text(encoding="utf-8", errors="replace").lower()
179
+ except Exception:
180
+ pass
181
+
182
+ # Detect PostgreSQL
183
+ if (
184
+ "postgres" in compose_content
185
+ or "psql" in compose_content
186
+ or "psycopg" in dep_keys
187
+ or "psycopg2" in dep_keys
188
+ or "psycopg2-binary" in dep_keys
189
+ or "asyncpg" in dep_keys
190
+ or "postgresql://" in env_content
191
+ or "postgres://" in env_content
192
+ ):
193
+ technologies.append("PostgreSQL")
194
+
195
+ # Detect Redis
196
+ if (
197
+ "redis" in compose_content
198
+ or "redis" in dep_keys
199
+ or "redis://" in env_content
200
+ ):
201
+ technologies.append("Redis")
202
+
203
+ # Detect Celery
204
+ if "celery" in dep_keys or "celery" in compose_content:
205
+ technologies.append("Celery")
206
+
207
+ # Detect Alembic
208
+ if (root / "alembic.ini").exists() or "alembic" in dep_keys:
209
+ technologies.append("Alembic")
210
+
211
+ # Detect Node.js
212
+ if (root / "package.json").exists():
213
+ technologies.append("Node.js")
214
+
215
+ return ProjectInfo(
216
+ root=root,
217
+ name=name,
218
+ project_type=project_type,
219
+ technologies=technologies,
220
+ frameworks=frameworks,
221
+ config_files=config_files,
222
+ dependencies=dependencies,
223
+ )
devdoctor/reporter.py ADDED
@@ -0,0 +1,193 @@
1
+ """DevDoctor output reporter: renders deterministic CLI views, verbose diagnostics, and JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Optional
8
+
9
+ from rich.console import Console
10
+
11
+ # Ensure UTF-8 output on Windows terminals
12
+ if sys.stdout and hasattr(sys.stdout, "reconfigure"):
13
+ try:
14
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
15
+ except Exception:
16
+ pass
17
+
18
+ if sys.stderr and hasattr(sys.stderr, "reconfigure"):
19
+ try:
20
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
21
+ except Exception:
22
+ pass
23
+
24
+ # Configure Console with legacy_windows=False to prevent charmap encoding errors
25
+ console = Console(legacy_windows=False)
26
+
27
+ # Unicode characters with safe ASCII fallbacks if required
28
+ def can_encode(char: str) -> bool:
29
+ try:
30
+ char.encode(sys.stdout.encoding or "utf-8")
31
+ return True
32
+ except Exception:
33
+ return False
34
+
35
+ SYM_OK = "✓" if can_encode("✓") else "[OK]"
36
+ SYM_WARN = "⚠" if can_encode("⚠") else "[!]"
37
+ SYM_ERR = "❌" if can_encode("❌") else "[X]"
38
+ SYM_DIV = "────────────────────────────" if can_encode("─") else "----------------------------"
39
+
40
+
41
+ class Reporter:
42
+ """Renders DiagnosticReports into terminal, verbose, JSON, or Markdown formats."""
43
+
44
+ def __init__(self, console_instance: Optional[Console] = None):
45
+ self.console = console_instance or console
46
+
47
+ def render_terminal(self, report: DiagnosticReport, verbose: bool = False) -> None:
48
+ """Render the default or verbose DevDoctor scan output to the terminal."""
49
+ if verbose:
50
+ self._render_verbose(report)
51
+ return
52
+
53
+ # Header
54
+ self.console.print("[bold cyan]DevDoctor 0.1.0[/bold cyan]")
55
+ if report.project_info and report.project_info.summary_stack:
56
+ self.console.print(f"[dim]Project: {report.project_info.summary_stack}[/dim]")
57
+ self.console.print(f"{SYM_DIV}\n")
58
+
59
+ # 1. Passed checks (compact green checkmarks)
60
+ if report.passed:
61
+ for diag in report.passed:
62
+ self.console.print(f"[green]{SYM_OK}[/green] {diag.title}")
63
+ self.console.print()
64
+
65
+ # 2. Problems (Warnings and Errors)
66
+ if report.problems:
67
+ for diag in report.problems:
68
+ sym = f"[red]{SYM_ERR}[/red]" if diag.is_error else f"[yellow]{SYM_WARN}[/yellow]"
69
+ self.console.print(f"{sym} [bold]{diag.title}[/bold]")
70
+ if diag.message:
71
+ self.console.print(f" {diag.message}\n")
72
+
73
+ if diag.evidence:
74
+ self.console.print(f" {diag.evidence}\n")
75
+
76
+ if diag.fix:
77
+ self.console.print(" Fix:")
78
+ for line in diag.fix.splitlines():
79
+ self.console.print(f" {line}")
80
+ self.console.print()
81
+
82
+ # 3. Score
83
+ score = report.score
84
+ if score >= 90:
85
+ score_color = "green"
86
+ elif score >= 70:
87
+ score_color = "yellow"
88
+ else:
89
+ score_color = "red"
90
+
91
+ self.console.print(f"[bold {score_color}]Score: {score}/100[/bold {score_color}]\n")
92
+
93
+ # 4. Summary
94
+ total_p = report.total_problems
95
+ if total_p == 0:
96
+ self.console.print(f"[bold green]{SYM_OK} All checks passed! Machine and project are healthy.[/bold green]")
97
+ else:
98
+ problem_word = "problem" if total_p == 1 else "problems"
99
+ color = "red" if report.errors else "yellow"
100
+ self.console.print(f"[{color}]{total_p} {problem_word} found.[/{color}]")
101
+
102
+ def _render_verbose(self, report: DiagnosticReport) -> None:
103
+ """Render full diagnostic detail with WHY, EVIDENCE, SAFE FIX, COMMAND, and Confidence."""
104
+ self.console.print("[bold cyan]DevDoctor 0.1.0 (Detailed Diagnostic Report)[/bold cyan]")
105
+ self.console.print(f"{SYM_DIV * 2}\n")
106
+
107
+ if report.passed:
108
+ self.console.print("[bold green]PASSED CHECKS:[/bold green]")
109
+ for diag in report.passed:
110
+ self.console.print(f" [green]{SYM_OK}[/green] {diag.title}: {diag.message}")
111
+ self.console.print()
112
+
113
+ if report.problems:
114
+ self.console.print("[bold red]IDENTIFIED PROBLEMS & DIAGNOSTICS:[/bold red]\n")
115
+ for diag in report.problems:
116
+ sym = SYM_ERR if diag.is_error else SYM_WARN
117
+ self.console.print(f"{sym} [bold underline]{diag.title}[/bold underline]\n")
118
+
119
+ if diag.message:
120
+ self.console.print(f"[bold]PROBLEM:[/bold]\n{diag.message}\n")
121
+
122
+ if diag.why:
123
+ self.console.print(f"[bold]WHY:[/bold]\n{diag.why}\n")
124
+
125
+ if diag.evidence:
126
+ self.console.print(f"[bold]EVIDENCE:[/bold]\n{diag.evidence}\n")
127
+
128
+ if diag.fix:
129
+ self.console.print(f"[bold]SAFE FIX:[/bold]\n{diag.fix}\n")
130
+
131
+ if diag.command:
132
+ self.console.print(f"[bold]COMMAND:[/bold]\n{diag.command}\n")
133
+
134
+ conf = diag.confidence.value if hasattr(diag.confidence, "value") else str(diag.confidence)
135
+ self.console.print(f"Confidence: [cyan]{conf}[/cyan]\n")
136
+ self.console.print(f"{SYM_DIV * 2}\n")
137
+
138
+ # Score & Summary
139
+ score = report.score
140
+ score_color = "green" if score >= 90 else ("yellow" if score >= 70 else "red")
141
+ self.console.print(f"[bold {score_color}]Health Score: {score}/100[/bold {score_color}] ({report.total_problems} problems found)")
142
+
143
+ def render_json(self, report: DiagnosticReport) -> str:
144
+ """Return JSON representation of the diagnostic report."""
145
+ return json.dumps(report.to_dict(), indent=2)
146
+
147
+ def render_markdown(self, report: DiagnosticReport) -> str:
148
+ """Render a GitHub-flavored Markdown diagnostic report."""
149
+ lines = [
150
+ f"# DevDoctor Diagnostic Report",
151
+ f"",
152
+ f"- **Target**: `{report.project_path}`",
153
+ f"- **Timestamp**: `{report.timestamp.isoformat()}`",
154
+ f"- **Health Score**: **{report.score}/100**",
155
+ f"- **Problems Found**: {report.total_problems} ({len(report.errors)} errors, {len(report.warnings)} warnings)",
156
+ f"- **Project Profile**: {report.project_info.summary_stack}",
157
+ f"",
158
+ f"---",
159
+ f"",
160
+ f"## Identified Problems",
161
+ f"",
162
+ ]
163
+
164
+ if not report.problems:
165
+ lines.append("All checks passed successfully! Machine and project are in a healthy state.")
166
+ else:
167
+ for diag in report.problems:
168
+ icon = "❌" if diag.is_error else "⚠️"
169
+ lines.append(f"### {icon} {diag.title}")
170
+ lines.append(f"")
171
+ lines.append(f"**Problem**: {diag.message}")
172
+ if diag.why:
173
+ lines.append(f"")
174
+ lines.append(f"> **Why**: {diag.why}")
175
+ if diag.evidence:
176
+ lines.append(f"")
177
+ lines.append(f"```\n{diag.evidence}\n```")
178
+ if diag.fix:
179
+ lines.append(f"")
180
+ lines.append(f"**Recommended Fix**:")
181
+ lines.append(f"```bash\n{diag.command or diag.fix}\n```")
182
+ lines.append(f"")
183
+ lines.append(f"*Confidence: {diag.confidence.value}*")
184
+ lines.append(f"")
185
+ lines.append("---")
186
+ lines.append("")
187
+
188
+ lines.append("## Passed Checks")
189
+ lines.append("")
190
+ for diag in report.passed:
191
+ lines.append(f"- ✓ **{diag.title}**: {diag.message}")
192
+
193
+ return "\n".join(lines)
devdoctor/scanner.py ADDED
@@ -0,0 +1,98 @@
1
+ """Scanner engine: coordinates project detection, check execution, and report generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+ from typing import List, Optional, Set
8
+
9
+ from devdoctor.checks import get_registered_checks
10
+ from devdoctor.models import Diagnostic, DiagnosticReport, ScanContext, Severity
11
+ from devdoctor.plugin import discover_all_checks
12
+ from devdoctor.project import detect_project
13
+ from devdoctor.utils.platform_info import get_python_version_str, is_in_virtualenv
14
+
15
+
16
+ class Scanner:
17
+ """Core diagnostic scanner for DevDoctor."""
18
+
19
+ def __init__(self, project_path: Optional[Path] = None, custom_checks_dir: Optional[Path] = None):
20
+ self.project_path = (project_path or Path.cwd()).resolve()
21
+ self.custom_checks_dir = custom_checks_dir
22
+
23
+ def create_context(self) -> ScanContext:
24
+ """Create and populate the ScanContext for this project."""
25
+ project_info = detect_project(self.project_path)
26
+ return ScanContext(
27
+ project_path=self.project_path,
28
+ is_venv=is_in_virtualenv(),
29
+ python_version=get_python_version_str(),
30
+ project_info=project_info,
31
+ )
32
+
33
+ def scan(
34
+ self,
35
+ categories: Optional[Set[str]] = None,
36
+ check_names: Optional[Set[str]] = None,
37
+ include_frameworks: bool = True,
38
+ ) -> DiagnosticReport:
39
+ """Run all diagnostic checks against the target project and produce a report."""
40
+ start_time = time.perf_counter()
41
+
42
+ context = self.create_context()
43
+ all_checks = discover_all_checks(self.project_path, self.custom_checks_dir)
44
+
45
+ diagnostics: List[Diagnostic] = []
46
+
47
+ for chk in all_checks:
48
+ # Check name filter
49
+ if check_names and chk.name not in check_names:
50
+ continue
51
+
52
+ # Category filter
53
+ if categories and chk.category not in categories:
54
+ continue
55
+
56
+ # Framework checks: only run automatically if the technology is detected in the project
57
+ # or if explicitly requested via check_names / category
58
+ if chk.category == "framework" and not check_names and not categories:
59
+ if not include_frameworks:
60
+ continue
61
+ # If framework not detected in project_info, skip
62
+ if chk.name == "django" and ("Django" not in (context.project_info.frameworks if context.project_info else [])):
63
+ continue
64
+ if chk.name == "fastapi" and ("FastAPI" not in (context.project_info.frameworks if context.project_info else [])):
65
+ continue
66
+ if chk.name == "docker" and ("Docker" not in (context.project_info.technologies if context.project_info else [])):
67
+ continue
68
+ if chk.name == "postgres" and ("PostgreSQL" not in (context.project_info.technologies if context.project_info else [])):
69
+ continue
70
+ if chk.name == "redis" and ("Redis" not in (context.project_info.technologies if context.project_info else [])):
71
+ continue
72
+
73
+ try:
74
+ result = chk.run(context)
75
+ if result is None:
76
+ continue
77
+ elif isinstance(result, list):
78
+ diagnostics.extend(result)
79
+ elif isinstance(result, Diagnostic):
80
+ diagnostics.append(result)
81
+ except Exception as e:
82
+ # Fallback diagnostic if check throws an unexpected error
83
+ diagnostics.append(Diagnostic(
84
+ title=f"Check '{chk.name}' failed to run",
85
+ severity=Severity.WARNING,
86
+ message=f"Internal check error: {e}",
87
+ why=f"An exception occurred inside the check runner for {chk.name}.",
88
+ check_id=f"error_{chk.name}",
89
+ ))
90
+
91
+ duration_ms = (time.perf_counter() - start_time) * 1000
92
+
93
+ return DiagnosticReport(
94
+ project_path=self.project_path,
95
+ project_info=context.project_info,
96
+ diagnostics=diagnostics,
97
+ duration_ms=duration_ms,
98
+ )
@@ -0,0 +1 @@
1
+ """Utility helpers for DevDoctor."""