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.
Files changed (51) hide show
  1. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/PKG-INFO +4 -3
  2. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/README.md +3 -2
  3. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/pyproject.toml +1 -1
  4. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/__init__.py +1 -4
  5. devdoctor_cli-0.1.1/src/devdoctor/_version.py +3 -0
  6. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/imports.py +9 -6
  7. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/cli.py +1 -1
  8. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/models.py +1 -1
  9. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/reporter.py +4 -2
  10. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/utils/ast_imports.py +181 -28
  11. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor_cli.egg-info/PKG-INFO +4 -3
  12. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor_cli.egg-info/SOURCES.txt +2 -0
  13. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/tests/test_cli.py +1 -1
  14. devdoctor_cli-0.1.1/tests/test_import_checker.py +191 -0
  15. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/LICENSE +0 -0
  16. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/setup.cfg +0 -0
  17. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/__main__.py +0 -0
  18. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/api.py +0 -0
  19. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/__init__.py +0 -0
  20. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/base.py +0 -0
  21. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/dependencies.py +0 -0
  22. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/django.py +0 -0
  23. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/docker.py +0 -0
  24. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/environment.py +0 -0
  25. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/fastapi.py +0 -0
  26. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/filesystem.py +0 -0
  27. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/git.py +0 -0
  28. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/pip.py +0 -0
  29. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/ports.py +0 -0
  30. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/postgres.py +0 -0
  31. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/python.py +0 -0
  32. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/redis.py +0 -0
  33. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/checks/virtualenv.py +0 -0
  34. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/fixer.py +0 -0
  35. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/plugin.py +0 -0
  36. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/project.py +0 -0
  37. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/scanner.py +0 -0
  38. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/utils/__init__.py +0 -0
  39. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/utils/env_parser.py +0 -0
  40. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/utils/platform_info.py +0 -0
  41. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor/utils/process_utils.py +0 -0
  42. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor_cli.egg-info/dependency_links.txt +0 -0
  43. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor_cli.egg-info/entry_points.txt +0 -0
  44. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor_cli.egg-info/requires.txt +0 -0
  45. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/src/devdoctor_cli.egg-info/top_level.txt +0 -0
  46. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/tests/test_checks.py +0 -0
  47. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/tests/test_env_and_ast.py +0 -0
  48. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/tests/test_fixer.py +0 -0
  49. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/tests/test_models.py +0 -0
  50. {devdoctor_cli-0.1.0 → devdoctor_cli-0.1.1}/tests/test_plugins_api.py +0 -0
  51. {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.0
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.0
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
- ❌ Pydantic installation problem
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.0
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
- ❌ Pydantic installation problem
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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devdoctor-cli"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "Deterministic developer machine and project diagnostic doctor."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,5 +1,4 @@
1
- """DevDoctor: Deterministic developer machine and project diagnostic doctor."""
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",
@@ -0,0 +1,3 @@
1
+ """Version definition for DevDoctor."""
2
+
3
+ __version__ = "0.1.1"
@@ -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 {mod} via pip:\n pip install {mod}",
36
- command=f"pip install {mod}",
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 {mod}",
40
- command=f"pip install {mod}",
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
@@ -239,7 +239,7 @@ class DiagnosticReport:
239
239
 
240
240
  def to_dict(self) -> Dict[str, Any]:
241
241
  return {
242
- "version": "0.1.0",
242
+ "version": "0.1.1",
243
243
  "timestamp": self.timestamp.isoformat(),
244
244
  "duration_ms": round(self.duration_ms, 2),
245
245
  "score": self.score,
@@ -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 0.1.0[/bold cyan]")
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 0.1.0 (Detailed Diagnostic Report)[/bold cyan]")
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 d not in IGNORE_DIRS]
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(module_name: str, project_path: Path) -> bool:
163
- """Check if the module corresponds to a local file or package within the project."""
164
- if (project_path / f"{module_name}.py").is_file():
165
- return True
166
- if (project_path / module_name / "__init__.py").is_file():
167
- return True
168
- src_dir = project_path / "src"
169
- if src_dir.is_dir():
170
- if (src_dir / f"{module_name}.py").is_file():
171
- return True
172
- if (src_dir / module_name / "__init__.py").is_file():
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
- try:
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.0
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.0
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
- ❌ Pydantic installation problem
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.0"' in captured.out
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