Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions samcli/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ def cli(ctx):
import atexit

from samcli.lib.telemetry.metric import emit_all_metrics, send_installed_metric
from samcli.lib.utils.subprocess_utils import isolate_library_paths_for_subprocess

# When running from PyInstaller bundle, isolate library paths so external processes
# (npm, node, pip, etc.) use system libraries instead of bundled ones
isolate_library_paths_for_subprocess()

# if development version of SAM CLI is used, attach module proxy
# to catch missing configuration for dynamic/hidden imports
Expand Down
174 changes: 173 additions & 1 deletion samcli/lib/utils/subprocess_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,24 @@
from concurrent.futures.thread import ThreadPoolExecutor
from subprocess import PIPE, STDOUT, Popen
from time import sleep
from typing import Any, AnyStr, Callable, Dict, Optional, Union
from typing import Any, AnyStr, Callable, Dict, List, Optional, Union

from samcli.commands.exceptions import UserException
from samcli.lib.utils.stream_writer import StreamWriter

# Environment variables that control library loading paths
# These are set by PyInstaller and can cause conflicts with system binaries
LIBRARY_PATH_VARS = [
"LD_LIBRARY_PATH", # Linux
"DYLD_LIBRARY_PATH", # macOS
"DYLD_FALLBACK_LIBRARY_PATH", # macOS fallback
"DYLD_FRAMEWORK_PATH", # macOS frameworks
]

# Original library paths before cleanup (stored for debugging/restoration if needed)
# Using a mutable container to avoid global statement (PLW0603)
_library_path_state: Dict[str, Optional[Dict[str, str]]] = {"original_library_paths": None}

# These are the bytes that used as a prefix for a some string to color them in red to show errors.
TERRAFORM_ERROR_PREFIX = [27, 91, 51, 49]

Expand Down Expand Up @@ -148,3 +161,162 @@ def _check_and_process_bytes(check_value: AnyStr, preserve_whitespace=False) ->
return decoded_value
return decoded_value.strip()
return check_value


def is_pyinstaller_bundle() -> bool:
"""
Check if SAM CLI is running from a PyInstaller bundle.

PyInstaller sets the '_MEIPASS' attribute on the sys module to point
to the temporary directory where bundled files are extracted.

Returns
-------
bool
True if running from a PyInstaller bundle, False otherwise.
"""
return hasattr(sys, "_MEIPASS")


def get_pyinstaller_lib_path() -> Optional[str]:
"""
Get the PyInstaller internal library path if running from a bundle.

Returns
-------
Optional[str]
The path to PyInstaller's internal libraries, or None if not in a bundle.
"""
if is_pyinstaller_bundle():
meipass = getattr(sys, "_MEIPASS", None)
if meipass:
return os.path.join(meipass, "_internal")
return None


def _save_original_library_paths() -> None:
"""Save original library path values before modification."""
if _library_path_state["original_library_paths"] is None:
original_paths: Dict[str, str] = {}
for var in LIBRARY_PATH_VARS:
if var in os.environ:
original_paths[var] = os.environ[var]
_library_path_state["original_library_paths"] = original_paths


def _filter_pyinstaller_paths(path_value: str) -> str:
"""
Remove PyInstaller-related paths from a PATH-style environment variable.

Parameters
----------
path_value : str
The original value of the path variable (colon or semicolon separated).

Returns
-------
str
The filtered path value with PyInstaller paths removed.
"""
meipass = getattr(sys, "_MEIPASS", None)
if not meipass:
return path_value

separator = os.pathsep
paths = path_value.split(separator)

# Filter out paths that are inside the PyInstaller bundle
filtered_paths = [
p for p in paths if not p.startswith(meipass) and "_internal" not in p and "dist/_internal" not in p
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current filtering logic might be slightly too broad. The "_internal" not in p check could theoretically filter legitimate system paths containing "_internal". Consider making it more specific:

filtered_paths = [
    p for p in paths 
    if not (p.startswith(meipass) or 
            p.endswith("/_internal") or 
            "dist/_internal" in p)
]

]

return separator.join(filtered_paths)


def isolate_library_paths_for_subprocess() -> None:
"""
Remove or filter PyInstaller-bundled library paths from the environment.

This function should be called early in SAM CLI initialization when running
from a PyInstaller bundle. It ensures that external processes (npm, node, pip, etc.)
use system libraries instead of the bundled ones.

This is safe to call because:
1. Python and its C extensions are already loaded
2. The bundled libraries have served their purpose for the main process
3. External processes need system libraries for compatibility

Note: This modifies os.environ directly, affecting all subprocess calls
that inherit the environment.
"""
if not is_pyinstaller_bundle():
LOG.debug("Not running from PyInstaller bundle, skipping library path isolation")
return

_save_original_library_paths()

pyinstaller_path = get_pyinstaller_lib_path()
LOG.debug("Running from PyInstaller bundle at: %s", getattr(sys, "_MEIPASS", "unknown"))
LOG.debug("PyInstaller internal lib path: %s", pyinstaller_path)

for var in LIBRARY_PATH_VARS:
if var in os.environ:
original_value = os.environ[var]
filtered_value = _filter_pyinstaller_paths(original_value)

if filtered_value:
os.environ[var] = filtered_value
LOG.debug("Filtered %s: '%s' -> '%s'", var, original_value, filtered_value)
else:
del os.environ[var]
LOG.debug("Removed %s (was: '%s')", var, original_value)


def get_clean_env_for_subprocess(additional_vars_to_remove: Optional[List[str]] = None) -> Dict[str, str]:
"""
Get a copy of the current environment with library paths cleaned for subprocess use.

This is useful when you need to pass an explicit environment to subprocess calls
rather than relying on inheritance from os.environ.

Parameters
----------
additional_vars_to_remove : Optional[List[str]]
Additional environment variables to remove from the returned environment.

Returns
-------
Dict[str, str]
A copy of os.environ with library paths filtered/removed.
"""
env = os.environ.copy()

if is_pyinstaller_bundle():
for var in LIBRARY_PATH_VARS:
if var in env:
filtered = _filter_pyinstaller_paths(env[var])
if filtered:
env[var] = filtered
else:
del env[var]

if additional_vars_to_remove:
for var in additional_vars_to_remove:
env.pop(var, None)

return env


def get_original_library_paths() -> Dict[str, str]:
"""
Get the original library path values before isolation was applied.

This can be useful for debugging or if restoration is ever needed.

Returns
-------
Dict[str, str]
Dictionary of original library path environment variables.
"""
original = _library_path_state["original_library_paths"]
return dict(original) if original else {}
Loading