mirror of
https://github.com/esphome/esphome.git
synced 2026-06-04 01:18:26 +08:00
[core] Strip \\?\ prefix from sys.executable for PlatformIO subprocess (#16158)
This commit is contained in:
@@ -14,6 +14,37 @@ from esphome.util import run_external_process
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_win_long_path_prefix(path: str) -> str:
|
||||||
|
r"""Strip the Windows extended-length path prefix from ``path``.
|
||||||
|
|
||||||
|
Handles both forms documented at
|
||||||
|
https://learn.microsoft.com/windows/win32/fileio/naming-a-file:
|
||||||
|
|
||||||
|
* ``\\?\C:\path\to\file`` -> ``C:\path\to\file``
|
||||||
|
* ``\\?\UNC\server\share\path`` -> ``\\server\share\path``
|
||||||
|
|
||||||
|
The NSIS-installed ``esphome.exe`` launcher on Windows starts Python with
|
||||||
|
``sys.executable`` already prefixed with ``\\?\``. That prefix propagates
|
||||||
|
into PlatformIO's ``$PYTHONEXE`` (PlatformIO reads ``PYTHONEXEPATH`` from
|
||||||
|
the environment, falling back to ``os.path.normpath(sys.executable)``)
|
||||||
|
and ends up baked into SCons-emitted command lines for build steps such
|
||||||
|
as the esp8266 ``elf2bin`` invocation. ``cmd.exe`` does not understand
|
||||||
|
the ``\\?\`` prefix, so the build fails with
|
||||||
|
"The system cannot find the path specified." Stripping the prefix early
|
||||||
|
keeps the path shell-quotable.
|
||||||
|
|
||||||
|
No-op on non-Windows platforms.
|
||||||
|
"""
|
||||||
|
if sys.platform != "win32":
|
||||||
|
return path
|
||||||
|
if path.startswith("\\\\?\\UNC\\"):
|
||||||
|
# \\?\UNC\server\share\... -> \\server\share\...
|
||||||
|
return "\\\\" + path[len("\\\\?\\UNC\\") :]
|
||||||
|
if path.startswith("\\\\?\\"):
|
||||||
|
return path[len("\\\\?\\") :]
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def run_platformio_cli(*args, **kwargs) -> str | int:
|
def run_platformio_cli(*args, **kwargs) -> str | int:
|
||||||
os.environ["PLATFORMIO_FORCE_COLOR"] = "true"
|
os.environ["PLATFORMIO_FORCE_COLOR"] = "true"
|
||||||
os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute())
|
os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute())
|
||||||
@@ -24,7 +55,18 @@ def run_platformio_cli(*args, **kwargs) -> str | int:
|
|||||||
os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning")
|
os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning")
|
||||||
# Increase uv retry count to handle transient network errors (default is 3)
|
# Increase uv retry count to handle transient network errors (default is 3)
|
||||||
os.environ.setdefault("UV_HTTP_RETRIES", "10")
|
os.environ.setdefault("UV_HTTP_RETRIES", "10")
|
||||||
cmd = [sys.executable, "-m", "esphome.platformio_runner"] + list(args)
|
# Strip the Windows extended-length path prefix from sys.executable so it
|
||||||
|
# doesn't propagate into PlatformIO's $PYTHONEXE and break SCons-emitted
|
||||||
|
# command lines run through cmd.exe.
|
||||||
|
python_exe = _strip_win_long_path_prefix(sys.executable)
|
||||||
|
if python_exe != sys.executable:
|
||||||
|
# Only override PYTHONEXEPATH when we actually stripped a prefix.
|
||||||
|
# PlatformIO's get_pythonexe_path() reads this and falls back to
|
||||||
|
# sys.executable otherwise; setting it unconditionally would clobber
|
||||||
|
# a user-provided value (or the unmodified path on platforms that
|
||||||
|
# don't need the strip).
|
||||||
|
os.environ["PYTHONEXEPATH"] = python_exe
|
||||||
|
cmd = [python_exe, "-m", "esphome.platformio_runner"] + list(args)
|
||||||
|
|
||||||
return run_external_process(*cmd, **kwargs)
|
return run_external_process(*cmd, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -311,6 +311,105 @@ def test_run_platformio_cli_sets_environment_variables(
|
|||||||
assert "arg" in args
|
assert "arg" in args
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("platform", "input_path", "expected"),
|
||||||
|
[
|
||||||
|
# win32: drive-letter extended-length prefix is stripped
|
||||||
|
(
|
||||||
|
"win32",
|
||||||
|
"\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||||
|
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||||
|
),
|
||||||
|
# win32: UNC extended-length prefix is translated to a regular UNC path
|
||||||
|
(
|
||||||
|
"win32",
|
||||||
|
"\\\\?\\UNC\\server\\share\\python.exe",
|
||||||
|
"\\\\server\\share\\python.exe",
|
||||||
|
),
|
||||||
|
# win32: paths without the prefix are returned unchanged
|
||||||
|
(
|
||||||
|
"win32",
|
||||||
|
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||||
|
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe",
|
||||||
|
),
|
||||||
|
# non-win32: prefix is left alone (no-op)
|
||||||
|
("linux", "\\\\?\\C:\\python.exe", "\\\\?\\C:\\python.exe"),
|
||||||
|
("darwin", "/usr/bin/python3", "/usr/bin/python3"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_strip_win_long_path_prefix(
|
||||||
|
platform: str, input_path: str, expected: str
|
||||||
|
) -> None:
|
||||||
|
r"""``\\?\`` and ``\\?\UNC\`` prefixes are stripped only on win32."""
|
||||||
|
with patch("esphome.platformio_api.sys.platform", platform):
|
||||||
|
assert platformio_api._strip_win_long_path_prefix(input_path) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_platformio_cli_strips_win_long_path_prefix(
|
||||||
|
setup_core: Path, mock_run_external_process: Mock
|
||||||
|
) -> None:
|
||||||
|
r"""Windows ``\\?\`` prefix on sys.executable does not leak into the subprocess.
|
||||||
|
|
||||||
|
The NSIS-installed esphome.exe launcher starts Python with
|
||||||
|
``sys.executable`` already prefixed by the extended-length path marker.
|
||||||
|
That prefix would otherwise propagate into PlatformIO's ``PYTHONEXE`` and
|
||||||
|
break SCons-emitted command lines run through ``cmd.exe``.
|
||||||
|
"""
|
||||||
|
CORE.build_path = str(setup_core / "build" / "test")
|
||||||
|
prefixed_exe = (
|
||||||
|
"\\\\?\\C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe"
|
||||||
|
)
|
||||||
|
stripped_exe = (
|
||||||
|
"C:\\Users\\jesse\\AppData\\Local\\ESPHome Builder\\python\\python.exe"
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {}, clear=False),
|
||||||
|
patch("esphome.platformio_api.sys.platform", "win32"),
|
||||||
|
patch("esphome.platformio_api.sys.executable", prefixed_exe),
|
||||||
|
):
|
||||||
|
# Pop any pre-existing PYTHONEXEPATH so the assertion below reflects
|
||||||
|
# what run_platformio_cli set, not whatever the test runner's
|
||||||
|
# environment happened to contain.
|
||||||
|
os.environ.pop("PYTHONEXEPATH", None)
|
||||||
|
mock_run_external_process.return_value = 0
|
||||||
|
platformio_api.run_platformio_cli("test", "arg")
|
||||||
|
|
||||||
|
# The subprocess is invoked with the stripped executable path.
|
||||||
|
mock_run_external_process.assert_called_once()
|
||||||
|
args = mock_run_external_process.call_args[0]
|
||||||
|
assert args[0] == stripped_exe
|
||||||
|
# PYTHONEXEPATH is exported with the stripped path so PlatformIO's
|
||||||
|
# get_pythonexe_path() picks it up in the subprocess.
|
||||||
|
assert os.environ["PYTHONEXEPATH"] == stripped_exe
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_platformio_cli_does_not_set_pythonexepath_without_strip(
|
||||||
|
setup_core: Path, mock_run_external_process: Mock
|
||||||
|
) -> None:
|
||||||
|
r"""PYTHONEXEPATH is not touched when sys.executable has no ``\\?\`` prefix.
|
||||||
|
|
||||||
|
Setting it unconditionally would clobber a user-provided value (or
|
||||||
|
interfere with non-Windows tooling that has no prefix to strip).
|
||||||
|
"""
|
||||||
|
CORE.build_path = str(setup_core / "build" / "test")
|
||||||
|
plain_exe = "/usr/bin/python3"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {}, clear=False),
|
||||||
|
patch("esphome.platformio_api.sys.platform", "linux"),
|
||||||
|
patch("esphome.platformio_api.sys.executable", plain_exe),
|
||||||
|
):
|
||||||
|
os.environ.pop("PYTHONEXEPATH", None)
|
||||||
|
mock_run_external_process.return_value = 0
|
||||||
|
platformio_api.run_platformio_cli("test", "arg")
|
||||||
|
|
||||||
|
mock_run_external_process.assert_called_once()
|
||||||
|
args = mock_run_external_process.call_args[0]
|
||||||
|
assert args[0] == plain_exe
|
||||||
|
assert "PYTHONEXEPATH" not in os.environ
|
||||||
|
|
||||||
|
|
||||||
def test_run_platformio_cli_run_builds_command(
|
def test_run_platformio_cli_run_builds_command(
|
||||||
setup_core: Path, mock_run_platformio_cli: Mock
|
setup_core: Path, mock_run_platformio_cli: Mock
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user