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.
@@ -0,0 +1,205 @@
1
+ """AST-based inspection for Python imports and environment variable accesses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import importlib.util
7
+ import os
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Dict, List, Optional, Set, Tuple
12
+
13
+ SYSTEM_ENV_VARS = {
14
+ "PATH", "PYTHONPATH", "VIRTUAL_ENV", "CONDA_PREFIX", "HOME", "USER",
15
+ "USERNAME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "TEMP", "TMP",
16
+ "OS", "SHELL", "TERM", "LANG", "LC_ALL", "SYSTEMROOT", "WINDIR",
17
+ }
18
+
19
+ IGNORE_DIRS = {
20
+ ".venv", "venv", "env", ".env_dir", ".git", ".tox", ".pytest_cache",
21
+ "__pycache__", "node_modules", "dist", "build", ".eggs", ".mypy_cache"
22
+ }
23
+
24
+
25
+ @dataclass
26
+ class ImportUsage:
27
+ """Record of a module import in a Python file."""
28
+ module_name: str
29
+ file_path: Path
30
+ line_number: int
31
+
32
+
33
+ @dataclass
34
+ class EnvVarUsage:
35
+ """Record of an environment variable read in a Python file."""
36
+ var_name: str
37
+ file_path: Path
38
+ line_number: int
39
+ has_default: bool
40
+ is_required: bool # True if subscript like os.environ['VAR']
41
+
42
+
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
+ def collect_python_files(project_path: Path, max_files: int = 500) -> List[Path]:
52
+ """Find Python source files in the project, ignoring virtualenvs and caches."""
53
+ py_files: List[Path] = []
54
+ try:
55
+ for root, dirs, files in os.walk(project_path):
56
+ dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
57
+ for file in files:
58
+ if file.endswith(".py"):
59
+ full_path = Path(root) / file
60
+ py_files.append(full_path)
61
+ if len(py_files) >= max_files:
62
+ return py_files
63
+ except Exception:
64
+ pass
65
+ return py_files
66
+
67
+
68
+ class SourceVisitor(ast.NodeVisitor):
69
+ """AST visitor that collects imported top-level modules and environment variable lookups."""
70
+
71
+ def __init__(self, file_path: Path):
72
+ self.file_path = file_path
73
+ self.imports: List[ImportUsage] = []
74
+ self.env_vars: List[EnvVarUsage] = []
75
+
76
+ def visit_Import(self, node: ast.Import):
77
+ for alias in node.names:
78
+ top_level = alias.name.split(".")[0]
79
+ self.imports.append(ImportUsage(module_name=top_level, file_path=self.file_path, line_number=node.lineno))
80
+ self.generic_visit(node)
81
+
82
+ def visit_ImportFrom(self, node: ast.ImportFrom):
83
+ if node.level == 0 and node.module:
84
+ top_level = node.module.split(".")[0]
85
+ self.imports.append(ImportUsage(module_name=top_level, file_path=self.file_path, line_number=node.lineno))
86
+ self.generic_visit(node)
87
+
88
+ def visit_Subscript(self, node: ast.Subscript):
89
+ # Detect os.environ['VAR'] or environ['VAR'] -> strictly required, throws KeyError if missing
90
+ var_name = None
91
+ if isinstance(node.value, ast.Attribute) and node.value.attr == "environ":
92
+ var_name = self._extract_string_value(node.slice)
93
+ elif isinstance(node.value, ast.Name) and node.value.id == "environ":
94
+ var_name = self._extract_string_value(node.slice)
95
+
96
+ if var_name and var_name not in SYSTEM_ENV_VARS:
97
+ self.env_vars.append(EnvVarUsage(
98
+ var_name=var_name,
99
+ file_path=self.file_path,
100
+ line_number=node.lineno,
101
+ has_default=False,
102
+ is_required=True,
103
+ ))
104
+ self.generic_visit(node)
105
+
106
+ def visit_Call(self, node: ast.Call):
107
+ # Detect os.getenv('VAR', default) or os.environ.get('VAR', default)
108
+ var_name = None
109
+ has_default = len(node.args) > 1 or any(kw.arg == "default" for kw in node.keywords)
110
+
111
+ if isinstance(node.func, ast.Attribute):
112
+ if node.func.attr == "getenv" and isinstance(node.func.value, ast.Name) and node.func.value.id == "os":
113
+ if node.args:
114
+ var_name = self._extract_string_value(node.args[0])
115
+ elif node.func.attr == "get" and isinstance(node.func.value, ast.Attribute) and node.func.value.attr == "environ":
116
+ if node.args:
117
+ var_name = self._extract_string_value(node.args[0])
118
+ elif node.func.attr == "get" and isinstance(node.func.value, ast.Name) and node.func.value.id == "environ":
119
+ if node.args:
120
+ var_name = self._extract_string_value(node.args[0])
121
+ elif isinstance(node.func, ast.Name) and node.func.id == "getenv":
122
+ if node.args:
123
+ var_name = self._extract_string_value(node.args[0])
124
+
125
+ if var_name and var_name not in SYSTEM_ENV_VARS:
126
+ self.env_vars.append(EnvVarUsage(
127
+ var_name=var_name,
128
+ file_path=self.file_path,
129
+ line_number=node.lineno,
130
+ has_default=has_default,
131
+ is_required=not has_default,
132
+ ))
133
+
134
+ self.generic_visit(node)
135
+
136
+ def _extract_string_value(self, node: ast.AST) -> Optional[str]:
137
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
138
+ return node.value
139
+ return None
140
+
141
+
142
+ def scan_project_ast(project_path: Path) -> Tuple[List[ImportUsage], List[EnvVarUsage]]:
143
+ """Scan all Python files in the project for imports and environment variable references."""
144
+ py_files = collect_python_files(project_path)
145
+ all_imports: List[ImportUsage] = []
146
+ all_env_vars: List[EnvVarUsage] = []
147
+
148
+ for file_path in py_files:
149
+ try:
150
+ source = file_path.read_text(encoding="utf-8", errors="replace")
151
+ tree = ast.parse(source, filename=str(file_path))
152
+ visitor = SourceVisitor(file_path)
153
+ visitor.visit(tree)
154
+ all_imports.extend(visitor.imports)
155
+ all_env_vars.extend(visitor.env_vars)
156
+ except Exception:
157
+ continue
158
+
159
+ return all_imports, all_env_vars
160
+
161
+
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():
173
+ return True
174
+ return False
175
+
176
+
177
+ def is_stdlib_module(module_name: str) -> bool:
178
+ """Check if the module is part of Python's standard library."""
179
+ if hasattr(sys, "stdlib_module_names"):
180
+ return module_name in sys.stdlib_module_names
181
+ return module_name in sys.builtin_module_names
182
+
183
+
184
+ def find_missing_installed_imports(project_path: Path) -> List[Tuple[str, Path, int]]:
185
+ """Identify third-party imports in project code that are not installed in the active environment."""
186
+ imports, _ = scan_project_ast(project_path)
187
+ missing: List[Tuple[str, Path, int]] = []
188
+ checked: Set[str] = set()
189
+
190
+ for item in imports:
191
+ mod = item.module_name
192
+ if mod in checked:
193
+ continue
194
+ if is_stdlib_module(mod) or is_local_module(mod, project_path):
195
+ continue
196
+
197
+ 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:
203
+ missing.append((mod, item.file_path, item.line_number))
204
+
205
+ return missing
@@ -0,0 +1,57 @@
1
+ """Parsing utilities for .env and .env.example files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Dict, List, Set
8
+
9
+
10
+ def parse_env_content(content: str) -> Dict[str, str]:
11
+ """Parse .env formatted text into key-value pairs."""
12
+ result: Dict[str, str] = {}
13
+ for line in content.splitlines():
14
+ line = line.strip()
15
+ if not line or line.startswith("#"):
16
+ continue
17
+ # Support 'export KEY=VAL'
18
+ if line.startswith("export "):
19
+ line = line[len("export "):].strip()
20
+
21
+ # Match KEY=VALUE or KEY="VALUE" or KEY='VALUE'
22
+ match = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$", line)
23
+ if match:
24
+ key = match.group(1).strip()
25
+ val = match.group(2).strip()
26
+ # Unquote if enclosed in matching quotes
27
+ if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
28
+ val = val[1:-1]
29
+ else:
30
+ # Strip inline comments: KEY=foo # comment
31
+ val = re.sub(r"\s+#.*$", "", val).strip()
32
+ result[key] = val
33
+ return result
34
+
35
+
36
+ def parse_env_file(file_path: Path) -> Dict[str, str]:
37
+ """Read and parse a .env file."""
38
+ if not file_path.is_file():
39
+ return {}
40
+ try:
41
+ content = file_path.read_text(encoding="utf-8", errors="replace")
42
+ return parse_env_content(content)
43
+ except Exception:
44
+ return {}
45
+
46
+
47
+ def get_env_keys(file_path: Path) -> Set[str]:
48
+ """Return all defined keys in an environment file."""
49
+ return set(parse_env_file(file_path).keys())
50
+
51
+
52
+ def find_missing_env_keys(example_file: Path, target_env_file: Path) -> List[str]:
53
+ """Compare keys in .env.example against .env and return missing keys."""
54
+ example_keys = get_env_keys(example_file)
55
+ target_keys = get_env_keys(target_env_file)
56
+ missing = example_keys - target_keys
57
+ return sorted(list(missing))
@@ -0,0 +1,70 @@
1
+ """Platform and Python environment helper utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Optional, Tuple
10
+
11
+
12
+ def is_windows() -> bool:
13
+ """Return True if running on Windows."""
14
+ return sys.platform.startswith("win") or platform.system() == "Windows"
15
+
16
+
17
+ def is_macos() -> bool:
18
+ """Return True if running on macOS."""
19
+ return sys.platform == "darwin" or platform.system() == "Darwin"
20
+
21
+
22
+ def is_linux() -> bool:
23
+ """Return True if running on Linux."""
24
+ return sys.platform.startswith("linux") or platform.system() == "Linux"
25
+
26
+
27
+ def is_in_virtualenv() -> bool:
28
+ """Deterministically check if current Python is running inside a virtual environment.
29
+
30
+ Checks both sys.prefix != sys.base_prefix (standard Python 3.3+ venv behavior)
31
+ and common environment variables like VIRTUAL_ENV or CONDA_PREFIX.
32
+ """
33
+ has_base = hasattr(sys, "base_prefix") and sys.prefix != sys.base_prefix
34
+ has_real = hasattr(sys, "real_prefix") # old virtualenv
35
+ has_env = bool(os.environ.get("VIRTUAL_ENV") or os.environ.get("CONDA_PREFIX"))
36
+ return has_base or has_real or has_env
37
+
38
+
39
+ def find_local_venv(project_path: Path) -> Optional[Path]:
40
+ """Look for standard virtualenv directories in the project root (.venv, venv, env)."""
41
+ candidates = [".venv", "venv", "env", ".env_dir"]
42
+ for name in candidates:
43
+ candidate = project_path / name
44
+ # Check for scripts/bin inside candidate
45
+ if candidate.is_dir():
46
+ if is_windows() and (candidate / "Scripts" / "python.exe").exists():
47
+ return candidate
48
+ elif (candidate / "bin" / "python").exists():
49
+ return candidate
50
+ elif (candidate / "pyvenv.cfg").exists():
51
+ return candidate
52
+ return None
53
+
54
+
55
+ def get_venv_activation_command(venv_dir: Path | str = ".venv") -> str:
56
+ """Return the platform-specific shell command to activate the virtualenv."""
57
+ venv_path = Path(venv_dir)
58
+ if is_windows():
59
+ return f"{venv_path}\\Scripts\\activate"
60
+ return f"source {venv_path}/bin/activate"
61
+
62
+
63
+ def get_python_version_tuple() -> Tuple[int, int, int]:
64
+ """Return Python version as a tuple of (major, minor, micro)."""
65
+ return (sys.version_info.major, sys.version_info.minor, sys.version_info.micro)
66
+
67
+
68
+ def get_python_version_str() -> str:
69
+ """Return Python version as 'X.Y.Z'."""
70
+ return f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
@@ -0,0 +1,167 @@
1
+ """Cross-platform port scanning, process inspection, and process termination utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ import os
8
+ import re
9
+ import signal
10
+ import socket
11
+ import subprocess
12
+ import sys
13
+ from dataclasses import dataclass
14
+ from typing import Optional
15
+
16
+ from devdoctor.utils.platform_info import is_windows
17
+
18
+
19
+ @dataclass
20
+ class PortListenerInfo:
21
+ """Information about a process listening on a network port."""
22
+ port: int
23
+ pid: int
24
+ process_name: str
25
+ command_line: Optional[str] = None
26
+
27
+
28
+ def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
29
+ """Check whether a port is currently occupied or listening."""
30
+ # First, test connecting to host:port
31
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
32
+ s.settimeout(0.3)
33
+ result = s.connect_ex((host, port))
34
+ if result == 0:
35
+ return True
36
+
37
+ # Check 0.0.0.0
38
+ if host == "127.0.0.1":
39
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
40
+ s.settimeout(0.3)
41
+ if s.connect_ex(("0.0.0.0", port)) == 0:
42
+ return True
43
+
44
+ # Also test binding: if bind fails with EADDRINUSE, it is in use
45
+ try:
46
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
47
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
48
+ s.bind((host, port))
49
+ return False
50
+ except OSError:
51
+ return True
52
+
53
+
54
+ def get_process_name_by_pid(pid: int) -> str:
55
+ """Look up the process name for a given PID."""
56
+ if pid <= 0:
57
+ return "unknown"
58
+
59
+ if is_windows():
60
+ try:
61
+ cmd = f'tasklist /FI "PID eq {pid}" /FO CSV /NH'
62
+ out = subprocess.check_output(cmd, shell=True, text=True, stderr=subprocess.DEVNULL)
63
+ reader = csv.reader(io.StringIO(out.strip()))
64
+ for row in reader:
65
+ if row and len(row) > 0 and row[0].strip():
66
+ return row[0].strip()
67
+ except Exception:
68
+ pass
69
+ return "unknown"
70
+
71
+ # Unix-like (macOS / Linux)
72
+ try:
73
+ out = subprocess.check_output(["ps", "-p", str(pid), "-o", "comm="], text=True, stderr=subprocess.DEVNULL)
74
+ return out.strip() or "unknown"
75
+ except Exception:
76
+ return "unknown"
77
+
78
+
79
+ def get_port_listener_info(port: int) -> Optional[PortListenerInfo]:
80
+ """Identify the PID and process name listening on a specific port."""
81
+ if not is_port_in_use(port):
82
+ return None
83
+
84
+ if is_windows():
85
+ try:
86
+ out = subprocess.check_output("netstat -ano -p tcp", shell=True, text=True, stderr=subprocess.DEVNULL)
87
+ for line in out.splitlines():
88
+ line = line.strip()
89
+ if not line or "LISTENING" not in line:
90
+ continue
91
+ parts = re.split(r"\s+", line)
92
+ if len(parts) >= 5:
93
+ local_addr = parts[1]
94
+ state = parts[3]
95
+ pid_str = parts[4]
96
+ if state == "LISTENING":
97
+ port_match = re.search(r":(\d+)$", local_addr)
98
+ if port_match and int(port_match.group(1)) == port:
99
+ try:
100
+ pid = int(pid_str)
101
+ proc_name = get_process_name_by_pid(pid)
102
+ return PortListenerInfo(port=port, pid=pid, process_name=proc_name)
103
+ except ValueError:
104
+ pass
105
+ except Exception:
106
+ pass
107
+
108
+ else:
109
+ # macOS / Linux: use lsof or ss
110
+ try:
111
+ out = subprocess.check_output(["lsof", "-i", f":{port}", "-sTCP:LISTEN", "-Fp,c"], text=True, stderr=subprocess.DEVNULL)
112
+ pid = 0
113
+ proc_name = "unknown"
114
+ for line in out.splitlines():
115
+ if line.startswith("p"):
116
+ pid = int(line[1:])
117
+ elif line.startswith("c"):
118
+ proc_name = line[1:]
119
+ if pid > 0:
120
+ return PortListenerInfo(port=port, pid=pid, process_name=proc_name)
121
+ except Exception:
122
+ pass
123
+
124
+ try:
125
+ # Fallback to ss on Linux
126
+ out = subprocess.check_output(["ss", "-lptn", f"sport = :{port}"], text=True, stderr=subprocess.DEVNULL)
127
+ pid_match = re.search(r'pid=(\d+)', out)
128
+ proc_match = re.search(r'"([^"]+)"', out)
129
+ if pid_match:
130
+ pid = int(pid_match.group(1))
131
+ proc_name = proc_match.group(1) if proc_match else get_process_name_by_pid(pid)
132
+ return PortListenerInfo(port=port, pid=pid, process_name=proc_name)
133
+ except Exception:
134
+ pass
135
+
136
+ return PortListenerInfo(port=port, pid=0, process_name="unknown")
137
+
138
+
139
+ def kill_process_by_pid(pid: int, force: bool = True) -> bool:
140
+ """Terminate a process by its PID."""
141
+ if pid <= 0:
142
+ return False
143
+
144
+ if is_windows():
145
+ try:
146
+ flag = "/F" if force else ""
147
+ cmd = f"taskkill /PID {pid} {flag} /T"
148
+ subprocess.run(cmd, shell=True, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
149
+ return True
150
+ except Exception:
151
+ return False
152
+
153
+ # Unix
154
+ try:
155
+ sig = signal.SIGKILL if force else signal.SIGTERM
156
+ os.kill(pid, sig)
157
+ return True
158
+ except Exception:
159
+ return False
160
+
161
+
162
+ def free_port(port: int) -> bool:
163
+ """Find the process listening on port and terminate it."""
164
+ info = get_port_listener_info(port)
165
+ if not info or info.pid <= 0:
166
+ return False
167
+ return kill_process_by_pid(info.pid, force=True)