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/__init__.py +40 -0
- devdoctor/__main__.py +7 -0
- devdoctor/api.py +96 -0
- devdoctor/checks/__init__.py +38 -0
- devdoctor/checks/base.py +69 -0
- devdoctor/checks/dependencies.py +125 -0
- devdoctor/checks/django.py +128 -0
- devdoctor/checks/docker.py +124 -0
- devdoctor/checks/environment.py +146 -0
- devdoctor/checks/fastapi.py +96 -0
- devdoctor/checks/filesystem.py +72 -0
- devdoctor/checks/git.py +151 -0
- devdoctor/checks/imports.py +47 -0
- devdoctor/checks/pip.py +67 -0
- devdoctor/checks/ports.py +90 -0
- devdoctor/checks/postgres.py +65 -0
- devdoctor/checks/python.py +105 -0
- devdoctor/checks/redis.py +70 -0
- devdoctor/checks/virtualenv.py +79 -0
- devdoctor/cli.py +319 -0
- devdoctor/fixer.py +160 -0
- devdoctor/models.py +257 -0
- devdoctor/plugin.py +89 -0
- devdoctor/project.py +223 -0
- devdoctor/reporter.py +193 -0
- devdoctor/scanner.py +98 -0
- devdoctor/utils/__init__.py +1 -0
- devdoctor/utils/ast_imports.py +205 -0
- devdoctor/utils/env_parser.py +57 -0
- devdoctor/utils/platform_info.py +70 -0
- devdoctor/utils/process_utils.py +167 -0
- devdoctor_cli-0.1.0.dist-info/METADATA +382 -0
- devdoctor_cli-0.1.0.dist-info/RECORD +37 -0
- devdoctor_cli-0.1.0.dist-info/WHEEL +5 -0
- devdoctor_cli-0.1.0.dist-info/entry_points.txt +2 -0
- devdoctor_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- devdoctor_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|