devdoctor-cli 0.1.0__tar.gz → 0.1.1__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.
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/PKG-INFO +4 -3
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/README.md +3 -2
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/pyproject.toml +1 -1
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/__init__.py +1 -4
- devdoctor_cli-0.1.1/src/devdoctor/_version.py +3 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/imports.py +9 -6
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/cli.py +1 -1
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/models.py +1 -1
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/reporter.py +4 -2
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/utils/ast_imports.py +181 -28
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor_cli.egg-info/PKG-INFO +4 -3
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor_cli.egg-info/SOURCES.txt +2 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/tests/test_cli.py +1 -1
- devdoctor_cli-0.1.1/tests/test_import_checker.py +191 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/LICENSE +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/setup.cfg +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/__main__.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/api.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/__init__.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/base.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/dependencies.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/django.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/docker.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/environment.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/fastapi.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/filesystem.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/git.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/pip.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/ports.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/postgres.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/python.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/redis.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/virtualenv.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/fixer.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/plugin.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/project.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/scanner.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/utils/__init__.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/utils/env_parser.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/utils/platform_info.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/utils/process_utils.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor_cli.egg-info/dependency_links.txt +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor_cli.egg-info/entry_points.txt +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor_cli.egg-info/requires.txt +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor_cli.egg-info/top_level.txt +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/tests/test_checks.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/tests/test_env_and_ast.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/tests/test_fixer.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/tests/test_models.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/tests/test_plugins_api.py +0 -0
- {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/tests/test_project.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devdoctor-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Deterministic developer machine and project diagnostic doctor.
|
|
5
5
|
Author: Souran Jash
|
|
6
6
|
License-Expression: MIT
|
|
@@ -65,7 +65,7 @@ devdoctor scan
|
|
|
65
65
|
```text
|
|
66
66
|
$ devdoctor scan
|
|
67
67
|
|
|
68
|
-
DevDoctor 0.1.
|
|
68
|
+
DevDoctor 0.1.1
|
|
69
69
|
Project: Django + PostgreSQL + Docker
|
|
70
70
|
────────────────────────────
|
|
71
71
|
|
|
@@ -128,7 +128,8 @@ Run with `--verbose` to view the comprehensive breakdown:
|
|
|
128
128
|
```text
|
|
129
129
|
$ devdoctor scan --verbose
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
DevDoctor 0.1.1 (Detailed Diagnostic Report)
|
|
132
|
+
────────────────────────────────────────────────────────
|
|
132
133
|
|
|
133
134
|
PROBLEM:
|
|
134
135
|
Incompatible version of pydantic-core installed for Python runtime.
|
|
@@ -38,7 +38,7 @@ devdoctor scan
|
|
|
38
38
|
```text
|
|
39
39
|
$ devdoctor scan
|
|
40
40
|
|
|
41
|
-
DevDoctor 0.1.
|
|
41
|
+
DevDoctor 0.1.1
|
|
42
42
|
Project: Django + PostgreSQL + Docker
|
|
43
43
|
────────────────────────────
|
|
44
44
|
|
|
@@ -101,7 +101,8 @@ Run with `--verbose` to view the comprehensive breakdown:
|
|
|
101
101
|
```text
|
|
102
102
|
$ devdoctor scan --verbose
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
DevDoctor 0.1.1 (Detailed Diagnostic Report)
|
|
105
|
+
────────────────────────────────────────────────────────
|
|
105
106
|
|
|
106
107
|
PROBLEM:
|
|
107
108
|
Incompatible version of pydantic-core installed for Python runtime.
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
from devdoctor._version import __version__
|
|
3
2
|
from devdoctor.api import DevDoctor
|
|
4
3
|
from devdoctor.checks.base import Check, check, get_registered_checks, register_check
|
|
5
4
|
from devdoctor.fixer import FixAction, FixPlan, FixResult, Fixer
|
|
@@ -15,8 +14,6 @@ from devdoctor.project import detect_project
|
|
|
15
14
|
from devdoctor.reporter import Reporter
|
|
16
15
|
from devdoctor.scanner import Scanner
|
|
17
16
|
|
|
18
|
-
__version__ = "0.1.0"
|
|
19
|
-
|
|
20
17
|
__all__ = [
|
|
21
18
|
"DevDoctor",
|
|
22
19
|
"Diagnostic",
|
|
@@ -6,7 +6,7 @@ from typing import List
|
|
|
6
6
|
|
|
7
7
|
from devdoctor.checks.base import Check, register_check
|
|
8
8
|
from devdoctor.models import Confidence, Diagnostic, FixAction, ScanContext, Severity
|
|
9
|
-
from devdoctor.utils.ast_imports import find_missing_installed_imports
|
|
9
|
+
from devdoctor.utils.ast_imports import find_missing_installed_imports, get_distribution_name
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
@register_check
|
|
@@ -26,22 +26,25 @@ class BrokenImportsCheck(Check):
|
|
|
26
26
|
except Exception:
|
|
27
27
|
rel_path = file_path.name
|
|
28
28
|
|
|
29
|
+
dist_name = get_distribution_name(mod)
|
|
30
|
+
pip_cmd = f"pip install {dist_name}"
|
|
31
|
+
|
|
29
32
|
diagnostics.append(Diagnostic(
|
|
30
33
|
title=f"Missing module '{mod}'",
|
|
31
34
|
severity=Severity.ERROR,
|
|
32
35
|
message=f"Module '{mod}' is imported but not installed in current environment.",
|
|
33
36
|
why=f"Code in {rel_path} requires '{mod}', but the package is not found in site-packages.",
|
|
34
37
|
evidence=f"Found in:\n {rel_path} (line {line_no})",
|
|
35
|
-
fix=f"Install {
|
|
36
|
-
command=
|
|
38
|
+
fix=f"Install {dist_name} via pip:\n {pip_cmd}",
|
|
39
|
+
command=pip_cmd,
|
|
37
40
|
confidence=Confidence.HIGH,
|
|
38
41
|
fix_action=FixAction(
|
|
39
|
-
description=f"Install {
|
|
40
|
-
command=
|
|
42
|
+
description=f"Install {dist_name}",
|
|
43
|
+
command=pip_cmd,
|
|
41
44
|
safe=False,
|
|
42
45
|
),
|
|
43
46
|
check_id=f"{self.name}_{mod}",
|
|
44
|
-
metadata={"module": mod, "file": str(rel_path), "line": line_no},
|
|
47
|
+
metadata={"module": mod, "file": str(rel_path), "line": line_no, "distribution": dist_name},
|
|
45
48
|
))
|
|
46
49
|
|
|
47
50
|
return diagnostics
|
|
@@ -9,7 +9,7 @@ from typing import List, Optional
|
|
|
9
9
|
|
|
10
10
|
from rich.console import Console
|
|
11
11
|
|
|
12
|
-
from devdoctor import __version__
|
|
12
|
+
from devdoctor._version import __version__
|
|
13
13
|
from devdoctor.fixer import Fixer
|
|
14
14
|
from devdoctor.project import detect_project
|
|
15
15
|
from devdoctor.reporter import Reporter
|
|
@@ -8,6 +8,8 @@ from typing import Optional
|
|
|
8
8
|
|
|
9
9
|
from rich.console import Console
|
|
10
10
|
|
|
11
|
+
from devdoctor._version import __version__
|
|
12
|
+
|
|
11
13
|
# Ensure UTF-8 output on Windows terminals
|
|
12
14
|
if sys.stdout and hasattr(sys.stdout, "reconfigure"):
|
|
13
15
|
try:
|
|
@@ -51,7 +53,7 @@ class Reporter:
|
|
|
51
53
|
return
|
|
52
54
|
|
|
53
55
|
# Header
|
|
54
|
-
self.console.print("[bold cyan]DevDoctor
|
|
56
|
+
self.console.print(f"[bold cyan]DevDoctor {__version__}[/bold cyan]")
|
|
55
57
|
if report.project_info and report.project_info.summary_stack:
|
|
56
58
|
self.console.print(f"[dim]Project: {report.project_info.summary_stack}[/dim]")
|
|
57
59
|
self.console.print(f"{SYM_DIV}\n")
|
|
@@ -101,7 +103,7 @@ class Reporter:
|
|
|
101
103
|
|
|
102
104
|
def _render_verbose(self, report: DiagnosticReport) -> None:
|
|
103
105
|
"""Render full diagnostic detail with WHY, EVIDENCE, SAFE FIX, COMMAND, and Confidence."""
|
|
104
|
-
self.console.print("[bold cyan]DevDoctor
|
|
106
|
+
self.console.print(f"[bold cyan]DevDoctor {__version__} (Detailed Diagnostic Report)[/bold cyan]")
|
|
105
107
|
self.console.print(f"{SYM_DIV * 2}\n")
|
|
106
108
|
|
|
107
109
|
if report.passed:
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import ast
|
|
6
|
+
import importlib.metadata
|
|
6
7
|
import importlib.util
|
|
7
8
|
import os
|
|
8
9
|
import sys
|
|
@@ -18,10 +19,80 @@ SYSTEM_ENV_VARS = {
|
|
|
18
19
|
|
|
19
20
|
IGNORE_DIRS = {
|
|
20
21
|
".venv", "venv", "env", ".env_dir", ".git", ".tox", ".pytest_cache",
|
|
21
|
-
"__pycache__", "node_modules", "dist", "build", ".eggs", ".mypy_cache"
|
|
22
|
+
"__pycache__", "node_modules", "dist", "build", ".eggs", ".mypy_cache",
|
|
23
|
+
".idea", ".vscode", ".ruff_cache", ".coverage", "htmlcov", "site-packages",
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
|
|
27
|
+
def is_ignored_dir(path: Path) -> bool:
|
|
28
|
+
"""Check if directory is an ignored directory or a virtual environment."""
|
|
29
|
+
name = path.name
|
|
30
|
+
if name.startswith(".") or name in IGNORE_DIRS:
|
|
31
|
+
return True
|
|
32
|
+
if (path / "pyvenv.cfg").is_file():
|
|
33
|
+
return True
|
|
34
|
+
if (path / "Scripts" / "python.exe").is_file() or (path / "bin" / "python").is_file():
|
|
35
|
+
return True
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def should_skip_path(path: Path) -> bool:
|
|
40
|
+
"""Check if the path is within an ignored directory or a virtual environment."""
|
|
41
|
+
for part in path.parts:
|
|
42
|
+
if part.startswith(".") and part != "." and part != "..":
|
|
43
|
+
return True
|
|
44
|
+
if part in IGNORE_DIRS:
|
|
45
|
+
return True
|
|
46
|
+
curr = path if path.is_dir() else path.parent
|
|
47
|
+
while curr and curr != curr.parent:
|
|
48
|
+
if (curr / "pyvenv.cfg").is_file():
|
|
49
|
+
return True
|
|
50
|
+
curr = curr.parent
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def has_python_files(dir_path: Path) -> bool:
|
|
55
|
+
"""Check if dir_path contains at least one Python file directly or in a non-ignored subdirectory."""
|
|
56
|
+
if not dir_path.is_dir() or is_ignored_dir(dir_path):
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
# Check immediate files first for performance
|
|
60
|
+
try:
|
|
61
|
+
with os.scandir(dir_path) as it:
|
|
62
|
+
for entry in it:
|
|
63
|
+
if entry.is_file() and entry.name.endswith((".py", ".pyw", ".pyd", ".so")):
|
|
64
|
+
return True
|
|
65
|
+
except Exception:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
# Check subdirectories recursively, pruning ignored folders
|
|
69
|
+
try:
|
|
70
|
+
for root, dirs, files in os.walk(dir_path):
|
|
71
|
+
dirs[:] = [d for d in dirs if not is_ignored_dir(Path(root) / d)]
|
|
72
|
+
for file in files:
|
|
73
|
+
if file.endswith((".py", ".pyw", ".pyd", ".so")):
|
|
74
|
+
return True
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def is_package_dir(dir_path: Path) -> bool:
|
|
82
|
+
"""Check if a directory is a valid local package (regular with __init__.py or PEP 420 namespace package)."""
|
|
83
|
+
if not dir_path.is_dir():
|
|
84
|
+
return False
|
|
85
|
+
if not dir_path.name.isidentifier():
|
|
86
|
+
return False
|
|
87
|
+
if is_ignored_dir(dir_path):
|
|
88
|
+
return False
|
|
89
|
+
# Regular package has __init__.py
|
|
90
|
+
if (dir_path / "__init__.py").is_file():
|
|
91
|
+
return True
|
|
92
|
+
# Namespace package contains at least one Python source file
|
|
93
|
+
return has_python_files(dir_path)
|
|
94
|
+
|
|
95
|
+
|
|
25
96
|
@dataclass
|
|
26
97
|
class ImportUsage:
|
|
27
98
|
"""Record of a module import in a Python file."""
|
|
@@ -40,22 +111,14 @@ class EnvVarUsage:
|
|
|
40
111
|
is_required: bool # True if subscript like os.environ['VAR']
|
|
41
112
|
|
|
42
113
|
|
|
43
|
-
def should_skip_path(path: Path) -> bool:
|
|
44
|
-
"""Check if the path is within an ignored directory."""
|
|
45
|
-
for part in path.parts:
|
|
46
|
-
if part in IGNORE_DIRS:
|
|
47
|
-
return True
|
|
48
|
-
return False
|
|
49
|
-
|
|
50
|
-
|
|
51
114
|
def collect_python_files(project_path: Path, max_files: int = 500) -> List[Path]:
|
|
52
115
|
"""Find Python source files in the project, ignoring virtualenvs and caches."""
|
|
53
116
|
py_files: List[Path] = []
|
|
54
117
|
try:
|
|
55
118
|
for root, dirs, files in os.walk(project_path):
|
|
56
|
-
dirs[:] = [d for d in dirs if
|
|
119
|
+
dirs[:] = [d for d in dirs if not is_ignored_dir(Path(root) / d)]
|
|
57
120
|
for file in files:
|
|
58
|
-
if file.endswith(".py"):
|
|
121
|
+
if file.endswith((".py", ".pyw")):
|
|
59
122
|
full_path = Path(root) / file
|
|
60
123
|
py_files.append(full_path)
|
|
61
124
|
if len(py_files) >= max_files:
|
|
@@ -80,6 +143,8 @@ class SourceVisitor(ast.NodeVisitor):
|
|
|
80
143
|
self.generic_visit(node)
|
|
81
144
|
|
|
82
145
|
def visit_ImportFrom(self, node: ast.ImportFrom):
|
|
146
|
+
# node.level > 0 indicates relative imports (e.g. from . import X or from ..core import Y)
|
|
147
|
+
# Relative imports are project-internal and are never third-party PyPI dependencies.
|
|
83
148
|
if node.level == 0 and node.module:
|
|
84
149
|
top_level = node.module.split(".")[0]
|
|
85
150
|
self.imports.append(ImportUsage(module_name=top_level, file_path=self.file_path, line_number=node.lineno))
|
|
@@ -159,18 +224,65 @@ def scan_project_ast(project_path: Path) -> Tuple[List[ImportUsage], List[EnvVar
|
|
|
159
224
|
return all_imports, all_env_vars
|
|
160
225
|
|
|
161
226
|
|
|
162
|
-
def is_local_module(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
227
|
+
def is_local_module(
|
|
228
|
+
module_name: str,
|
|
229
|
+
project_path: Path,
|
|
230
|
+
file_path: Optional[Path] = None,
|
|
231
|
+
) -> bool:
|
|
232
|
+
"""Check if the module corresponds to a local file, regular package, or namespace package within the project."""
|
|
233
|
+
if not module_name or not module_name.isidentifier():
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
project_root = project_path.resolve()
|
|
237
|
+
|
|
238
|
+
# Collect source roots to check
|
|
239
|
+
source_roots: List[Path] = [project_root]
|
|
240
|
+
|
|
241
|
+
# Standard layout directories: src/, lib/
|
|
242
|
+
for sub in ("src", "lib"):
|
|
243
|
+
sub_dir = project_root / sub
|
|
244
|
+
if sub_dir.is_dir() and not is_ignored_dir(sub_dir):
|
|
245
|
+
source_roots.append(sub_dir)
|
|
246
|
+
|
|
247
|
+
# If file_path is given, also check its parent directory and ancestors up to project_root
|
|
248
|
+
if file_path is not None:
|
|
249
|
+
try:
|
|
250
|
+
curr = file_path.resolve().parent
|
|
251
|
+
while curr != project_root and curr.is_relative_to(project_root):
|
|
252
|
+
if curr not in source_roots and not is_ignored_dir(curr):
|
|
253
|
+
source_roots.append(curr)
|
|
254
|
+
curr = curr.parent
|
|
255
|
+
except Exception:
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
# Check each candidate source root
|
|
259
|
+
for root in source_roots:
|
|
260
|
+
# 1. Single-file module: root / module_name.py (or .pyw, .pyd, .so)
|
|
261
|
+
for ext in (".py", ".pyw", ".pyd", ".so"):
|
|
262
|
+
mod_file = root / f"{module_name}{ext}"
|
|
263
|
+
if mod_file.is_file():
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
# 2. Package directory: root / module_name (with __init__.py or namespace package)
|
|
267
|
+
pkg_dir = root / module_name
|
|
268
|
+
if is_package_dir(pkg_dir):
|
|
173
269
|
return True
|
|
270
|
+
|
|
271
|
+
# Also check if module_name exists as a module or package inside any top-level source package in project_root or src/
|
|
272
|
+
for base in (project_root, project_root / "src"):
|
|
273
|
+
if not base.is_dir():
|
|
274
|
+
continue
|
|
275
|
+
try:
|
|
276
|
+
for entry in base.iterdir():
|
|
277
|
+
if entry.is_dir() and not is_ignored_dir(entry) and entry.name.isidentifier():
|
|
278
|
+
for ext in (".py", ".pyw", ".pyd", ".so"):
|
|
279
|
+
if (entry / f"{module_name}{ext}").is_file():
|
|
280
|
+
return True
|
|
281
|
+
if is_package_dir(entry / module_name):
|
|
282
|
+
return True
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
|
|
174
286
|
return False
|
|
175
287
|
|
|
176
288
|
|
|
@@ -181,6 +293,51 @@ def is_stdlib_module(module_name: str) -> bool:
|
|
|
181
293
|
return module_name in sys.builtin_module_names
|
|
182
294
|
|
|
183
295
|
|
|
296
|
+
def is_installed_package(module_name: str) -> bool:
|
|
297
|
+
"""Check if a module is installed in the active Python environment."""
|
|
298
|
+
try:
|
|
299
|
+
spec = importlib.util.find_spec(module_name)
|
|
300
|
+
if spec is not None:
|
|
301
|
+
return True
|
|
302
|
+
except Exception:
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
dist_map = importlib.metadata.packages_distributions()
|
|
307
|
+
if module_name in dist_map:
|
|
308
|
+
return True
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def get_distribution_name(module_name: str) -> str:
|
|
316
|
+
"""Map top-level import name to its standard PyPI distribution name if different."""
|
|
317
|
+
known_mappings = {
|
|
318
|
+
"PIL": "pillow",
|
|
319
|
+
"cv2": "opencv-python",
|
|
320
|
+
"sklearn": "scikit-learn",
|
|
321
|
+
"bs4": "beautifulsoup4",
|
|
322
|
+
"yaml": "PyYAML",
|
|
323
|
+
"dotenv": "python-dotenv",
|
|
324
|
+
"dateutil": "python-dateutil",
|
|
325
|
+
"serial": "pyserial",
|
|
326
|
+
"jwt": "PyJWT",
|
|
327
|
+
"jose": "python-jose",
|
|
328
|
+
"magic": "python-magic",
|
|
329
|
+
"fitz": "PyMuPDF",
|
|
330
|
+
"docx": "python-docx",
|
|
331
|
+
"pptx": "python-pptx",
|
|
332
|
+
"git": "GitPython",
|
|
333
|
+
"github": "PyGithub",
|
|
334
|
+
"websocket": "websocket-client",
|
|
335
|
+
"OpenGL": "PyOpenGL",
|
|
336
|
+
"attr": "attrs",
|
|
337
|
+
}
|
|
338
|
+
return known_mappings.get(module_name, module_name)
|
|
339
|
+
|
|
340
|
+
|
|
184
341
|
def find_missing_installed_imports(project_path: Path) -> List[Tuple[str, Path, int]]:
|
|
185
342
|
"""Identify third-party imports in project code that are not installed in the active environment."""
|
|
186
343
|
imports, _ = scan_project_ast(project_path)
|
|
@@ -191,15 +348,11 @@ def find_missing_installed_imports(project_path: Path) -> List[Tuple[str, Path,
|
|
|
191
348
|
mod = item.module_name
|
|
192
349
|
if mod in checked:
|
|
193
350
|
continue
|
|
194
|
-
if is_stdlib_module(mod) or is_local_module(mod, project_path):
|
|
351
|
+
if is_stdlib_module(mod) or is_local_module(mod, project_path, item.file_path):
|
|
195
352
|
continue
|
|
196
353
|
|
|
197
354
|
checked.add(mod)
|
|
198
|
-
|
|
199
|
-
spec = importlib.util.find_spec(mod)
|
|
200
|
-
if spec is None:
|
|
201
|
-
missing.append((mod, item.file_path, item.line_number))
|
|
202
|
-
except Exception:
|
|
355
|
+
if not is_installed_package(mod):
|
|
203
356
|
missing.append((mod, item.file_path, item.line_number))
|
|
204
357
|
|
|
205
358
|
return missing
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devdoctor-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Deterministic developer machine and project diagnostic doctor.
|
|
5
5
|
Author: Souran Jash
|
|
6
6
|
License-Expression: MIT
|
|
@@ -65,7 +65,7 @@ devdoctor scan
|
|
|
65
65
|
```text
|
|
66
66
|
$ devdoctor scan
|
|
67
67
|
|
|
68
|
-
DevDoctor 0.1.
|
|
68
|
+
DevDoctor 0.1.1
|
|
69
69
|
Project: Django + PostgreSQL + Docker
|
|
70
70
|
────────────────────────────
|
|
71
71
|
|
|
@@ -128,7 +128,8 @@ Run with `--verbose` to view the comprehensive breakdown:
|
|
|
128
128
|
```text
|
|
129
129
|
$ devdoctor scan --verbose
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
DevDoctor 0.1.1 (Detailed Diagnostic Report)
|
|
132
|
+
────────────────────────────────────────────────────────
|
|
132
133
|
|
|
133
134
|
PROBLEM:
|
|
134
135
|
Incompatible version of pydantic-core installed for Python runtime.
|
|
@@ -3,6 +3,7 @@ README.md
|
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/devdoctor/__init__.py
|
|
5
5
|
src/devdoctor/__main__.py
|
|
6
|
+
src/devdoctor/_version.py
|
|
6
7
|
src/devdoctor/api.py
|
|
7
8
|
src/devdoctor/cli.py
|
|
8
9
|
src/devdoctor/fixer.py
|
|
@@ -42,6 +43,7 @@ tests/test_checks.py
|
|
|
42
43
|
tests/test_cli.py
|
|
43
44
|
tests/test_env_and_ast.py
|
|
44
45
|
tests/test_fixer.py
|
|
46
|
+
tests/test_import_checker.py
|
|
45
47
|
tests/test_models.py
|
|
46
48
|
tests/test_plugins_api.py
|
|
47
49
|
tests/test_project.py
|
|
@@ -13,7 +13,7 @@ def test_cli_scan_json(tmp_path: Path, capsys):
|
|
|
13
13
|
ret = main(["scan", str(tmp_path), "--json"])
|
|
14
14
|
assert ret in (0, 1)
|
|
15
15
|
captured = capsys.readouterr()
|
|
16
|
-
assert '"version": "0.1.
|
|
16
|
+
assert '"version": "0.1.1"' in captured.out
|
|
17
17
|
assert '"score":' in captured.out
|
|
18
18
|
|
|
19
19
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Regression tests for DevDoctor's import detection and namespace package handling."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from devdoctor.checks.imports import BrokenImportsCheck
|
|
5
|
+
from devdoctor.models import ScanContext, Severity
|
|
6
|
+
from devdoctor.utils.ast_imports import (
|
|
7
|
+
find_missing_installed_imports,
|
|
8
|
+
get_distribution_name,
|
|
9
|
+
is_installed_package,
|
|
10
|
+
is_local_module,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_a_local_namespace_packages(tmp_path: Path):
|
|
15
|
+
"""Test A — Local namespace packages:
|
|
16
|
+
Create a project with core/, ui/, and utils/ without __init__.py.
|
|
17
|
+
Verify that importing these local namespace packages is NOT reported as missing dependencies.
|
|
18
|
+
"""
|
|
19
|
+
# Create structure:
|
|
20
|
+
# project/
|
|
21
|
+
# ├── app.py
|
|
22
|
+
# ├── core/
|
|
23
|
+
# │ └── example.py
|
|
24
|
+
# ├── ui/
|
|
25
|
+
# │ └── example.py
|
|
26
|
+
# └── utils/
|
|
27
|
+
# └── example.py
|
|
28
|
+
(tmp_path / "core").mkdir()
|
|
29
|
+
(tmp_path / "core" / "example.py").write_text("def run_core(): pass\n", encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
(tmp_path / "ui").mkdir()
|
|
32
|
+
(tmp_path / "ui" / "example.py").write_text("def run_ui(): pass\n", encoding="utf-8")
|
|
33
|
+
|
|
34
|
+
(tmp_path / "utils").mkdir()
|
|
35
|
+
(tmp_path / "utils" / "example.py").write_text("def run_utils(): pass\n", encoding="utf-8")
|
|
36
|
+
|
|
37
|
+
app_py = tmp_path / "app.py"
|
|
38
|
+
app_py.write_text(
|
|
39
|
+
"""
|
|
40
|
+
import core
|
|
41
|
+
from core import example as core_example
|
|
42
|
+
import ui
|
|
43
|
+
from ui.example import run_ui
|
|
44
|
+
import utils
|
|
45
|
+
from utils import example as utils_example
|
|
46
|
+
""",
|
|
47
|
+
encoding="utf-8",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
chk = BrokenImportsCheck()
|
|
51
|
+
ctx = ScanContext(project_path=tmp_path)
|
|
52
|
+
diagnostics = chk.run(ctx)
|
|
53
|
+
|
|
54
|
+
# None of core, ui, utils should be flagged as missing
|
|
55
|
+
flagged = [d.metadata.get("module") for d in diagnostics]
|
|
56
|
+
assert "core" not in flagged
|
|
57
|
+
assert "ui" not in flagged
|
|
58
|
+
assert "utils" not in flagged
|
|
59
|
+
assert len(diagnostics) == 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_b_local_regular_packages(tmp_path: Path):
|
|
63
|
+
"""Test B — Local regular packages:
|
|
64
|
+
Verify that local packages with __init__.py are recognized as local packages.
|
|
65
|
+
"""
|
|
66
|
+
pkg_dir = tmp_path / "mypackage"
|
|
67
|
+
pkg_dir.mkdir()
|
|
68
|
+
(pkg_dir / "__init__.py").write_text("# regular package\n", encoding="utf-8")
|
|
69
|
+
(pkg_dir / "service.py").write_text("def serve(): pass\n", encoding="utf-8")
|
|
70
|
+
|
|
71
|
+
(tmp_path / "main.py").write_text("import mypackage\nfrom mypackage import service\n", encoding="utf-8")
|
|
72
|
+
|
|
73
|
+
chk = BrokenImportsCheck()
|
|
74
|
+
ctx = ScanContext(project_path=tmp_path)
|
|
75
|
+
diagnostics = chk.run(ctx)
|
|
76
|
+
|
|
77
|
+
flagged = [d.metadata.get("module") for d in diagnostics]
|
|
78
|
+
assert "mypackage" not in flagged
|
|
79
|
+
assert len(diagnostics) == 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_c_genuine_missing_dependency(tmp_path: Path):
|
|
83
|
+
"""Test C — Genuine missing dependency:
|
|
84
|
+
Import a deliberately nonexistent third-party module and verify DevDoctor reports it appropriately.
|
|
85
|
+
"""
|
|
86
|
+
(tmp_path / "app.py").write_text("import deliberately_nonexistent_pypi_pkg_98765\n", encoding="utf-8")
|
|
87
|
+
|
|
88
|
+
chk = BrokenImportsCheck()
|
|
89
|
+
ctx = ScanContext(project_path=tmp_path)
|
|
90
|
+
diagnostics = chk.run(ctx)
|
|
91
|
+
|
|
92
|
+
assert len(diagnostics) == 1
|
|
93
|
+
diag = diagnostics[0]
|
|
94
|
+
assert diag.severity == Severity.ERROR
|
|
95
|
+
assert "deliberately_nonexistent_pypi_pkg_98765" in diag.title
|
|
96
|
+
assert "pip install deliberately_nonexistent_pypi_pkg_98765" in diag.command
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_d_installed_third_party_dependency(tmp_path: Path):
|
|
100
|
+
"""Test D — Installed third-party dependency:
|
|
101
|
+
Verify that a dependency installed in the active environment (e.g. rich, packaging) is not reported as missing.
|
|
102
|
+
"""
|
|
103
|
+
(tmp_path / "app.py").write_text("import rich\nimport packaging\n", encoding="utf-8")
|
|
104
|
+
|
|
105
|
+
chk = BrokenImportsCheck()
|
|
106
|
+
ctx = ScanContext(project_path=tmp_path)
|
|
107
|
+
diagnostics = chk.run(ctx)
|
|
108
|
+
|
|
109
|
+
assert len(diagnostics) == 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_e_nested_local_imports(tmp_path: Path):
|
|
113
|
+
"""Test E — Nested local imports:
|
|
114
|
+
Verify that modules and subpackages in nested project directories are handled correctly.
|
|
115
|
+
"""
|
|
116
|
+
# project/
|
|
117
|
+
# ├── app.py
|
|
118
|
+
# └── core/
|
|
119
|
+
# ├── tracker.py
|
|
120
|
+
# └── detector/
|
|
121
|
+
# └── model.py
|
|
122
|
+
core_dir = tmp_path / "core"
|
|
123
|
+
detector_dir = core_dir / "detector"
|
|
124
|
+
detector_dir.mkdir(parents=True)
|
|
125
|
+
|
|
126
|
+
(detector_dir / "model.py").write_text("class Model: pass\n", encoding="utf-8")
|
|
127
|
+
(core_dir / "tracker.py").write_text("from detector.model import Model\n", encoding="utf-8")
|
|
128
|
+
(tmp_path / "app.py").write_text("from core.detector import model\nimport core.tracker\n", encoding="utf-8")
|
|
129
|
+
|
|
130
|
+
chk = BrokenImportsCheck()
|
|
131
|
+
ctx = ScanContext(project_path=tmp_path)
|
|
132
|
+
diagnostics = chk.run(ctx)
|
|
133
|
+
|
|
134
|
+
flagged = [d.metadata.get("module") for d in diagnostics]
|
|
135
|
+
assert "core" not in flagged
|
|
136
|
+
assert "detector" not in flagged
|
|
137
|
+
assert len(diagnostics) == 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_f_src_layout(tmp_path: Path):
|
|
141
|
+
"""Test F — src/ layout:
|
|
142
|
+
Verify that modules under the project's src/ directory are recognized.
|
|
143
|
+
"""
|
|
144
|
+
# project/
|
|
145
|
+
# ├── pyproject.toml
|
|
146
|
+
# └── src/
|
|
147
|
+
# └── myproject/
|
|
148
|
+
# ├── engine.py
|
|
149
|
+
# └── worker.py
|
|
150
|
+
src_pkg = tmp_path / "src" / "myproject"
|
|
151
|
+
src_pkg.mkdir(parents=True)
|
|
152
|
+
|
|
153
|
+
(src_pkg / "engine.py").write_text("def start_engine(): pass\n", encoding="utf-8")
|
|
154
|
+
(src_pkg / "worker.py").write_text("from myproject.engine import start_engine\n", encoding="utf-8")
|
|
155
|
+
(tmp_path / "main.py").write_text("import myproject.worker\n", encoding="utf-8")
|
|
156
|
+
|
|
157
|
+
chk = BrokenImportsCheck()
|
|
158
|
+
ctx = ScanContext(project_path=tmp_path)
|
|
159
|
+
diagnostics = chk.run(ctx)
|
|
160
|
+
|
|
161
|
+
flagged = [d.metadata.get("module") for d in diagnostics]
|
|
162
|
+
assert "myproject" not in flagged
|
|
163
|
+
assert len(diagnostics) == 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_arbitrary_directory_not_treated_as_package(tmp_path: Path):
|
|
167
|
+
"""Verify that arbitrary non-Python directories (e.g. templates/ with only html)
|
|
168
|
+
are NOT treated as local packages.
|
|
169
|
+
"""
|
|
170
|
+
templates_dir = tmp_path / "templates"
|
|
171
|
+
templates_dir.mkdir()
|
|
172
|
+
(templates_dir / "index.html").write_text("<h1>Hello</h1>\n", encoding="utf-8")
|
|
173
|
+
|
|
174
|
+
(tmp_path / "app.py").write_text("import templates\n", encoding="utf-8")
|
|
175
|
+
|
|
176
|
+
# templates should not be considered a local package because it contains no Python files
|
|
177
|
+
assert not is_local_module("templates", tmp_path)
|
|
178
|
+
|
|
179
|
+
chk = BrokenImportsCheck()
|
|
180
|
+
ctx = ScanContext(project_path=tmp_path)
|
|
181
|
+
diagnostics = chk.run(ctx)
|
|
182
|
+
assert any("templates" in d.title for d in diagnostics)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_distribution_name_mapping():
|
|
186
|
+
"""Verify that known import names map to their correct PyPI distribution names."""
|
|
187
|
+
assert get_distribution_name("PIL") == "pillow"
|
|
188
|
+
assert get_distribution_name("cv2") == "opencv-python"
|
|
189
|
+
assert get_distribution_name("yaml") == "PyYAML"
|
|
190
|
+
assert get_distribution_name("sklearn") == "scikit-learn"
|
|
191
|
+
assert get_distribution_name("unknown_pkg") == "unknown_pkg"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|