fix(install): correctly detect already-installed dateutil/websocket-client

Address remaining coderabbitai findings on PR #369:
- check_package_installed() did __import__(package_name) directly, but
  python-dateutil and websocket-client import as dateutil/websocket. Both
  always failed the "already installed" check and were reinstalled on
  every run. Add an IMPORT_NAME_MAP for the mismatched names.
- _run() still read the entire temp file into memory before slicing the
  tail. Stream it line-by-line into a deque(maxlen=ERROR_TAIL_LINES)
  instead so memory use stays bounded for very chatty commands.
This commit is contained in:
Chuck
2026-06-11 16:21:56 -04:00
parent 60b64144a5
commit eb6687ceca

View File

@@ -8,6 +8,7 @@ import subprocess
import sys import sys
import tempfile import tempfile
import warnings import warnings
from collections import deque
from pathlib import Path from pathlib import Path
# How many trailing lines of a failed command's output to keep for the # How many trailing lines of a failed command's output to keep for the
@@ -27,8 +28,13 @@ def _run(cmd):
with tempfile.TemporaryFile(mode='w+b') as f: with tempfile.TemporaryFile(mode='w+b') as f:
result = subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT) # nosec B603 B607 - hardcoded apt/pip args # nosemgrep result = subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT) # nosec B603 B607 - hardcoded apt/pip args # nosemgrep
f.seek(0) f.seek(0)
lines = f.read().decode('utf-8', errors='replace').splitlines() # Stream line-by-line so only the last ERROR_TAIL_LINES are ever held
return result.returncode == 0, '\n'.join(lines[-ERROR_TAIL_LINES:]) # in memory, regardless of how much output the command produced.
tail = deque(
(line.decode('utf-8', errors='replace').rstrip('\n') for line in f),
maxlen=ERROR_TAIL_LINES,
)
return result.returncode == 0, '\n'.join(tail)
def install_via_apt(package_name): def install_via_apt(package_name):
@@ -84,14 +90,22 @@ def install_via_pip(package_name):
return False, output return False, output
# Distribution (pip/apt) names whose importable module name differs.
IMPORT_NAME_MAP = {
'python-dateutil': 'dateutil',
'websocket-client': 'websocket',
}
def check_package_installed(package_name): def check_package_installed(package_name):
"""Check if a package is already installed.""" """Check if a package is already installed."""
import_name = IMPORT_NAME_MAP.get(package_name, package_name)
# Suppress deprecation warnings when checking if packages are installed # Suppress deprecation warnings when checking if packages are installed
# (we're just checking, not using them) # (we're just checking, not using them)
with warnings.catch_warnings(): with warnings.catch_warnings():
warnings.filterwarnings('ignore', category=DeprecationWarning) warnings.filterwarnings('ignore', category=DeprecationWarning)
try: try:
__import__(package_name) __import__(import_name)
return True return True
except ImportError: except ImportError:
return False return False