Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/changelog/4060.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Preserve UNC and extended-length paths in Windows commands, including quoted paths - by :user:`MohammedAlkindi`.
16 changes: 14 additions & 2 deletions docs/reference/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1392,6 +1392,17 @@ Run
The backslash ``\`` character can be used to escape quotes, whitespace, itself, and
other characters (except on Windows, where a backslash in a path will not be interpreted as an escape).
Unescaped single quote will disable the backslash escape until closed by another unescaped single quote.
On Windows, UNC and extended-length prefixes retain both leading backslashes, including in quoted paths and
option values such as ``--source=\\server\share``. Quote paths containing spaces:

.. code-block:: ini

[testenv]
commands = xcopy "\\server\share\file name.txt" .

Inside an unquoted or double-quoted path, double backslashes escape one backslash. Single-quoted text is
literal; TOML argument arrays bypass command-line splitting.

For more details, please see :doc:`shlex parsing rules <python:library/shlex>`.

.. note::
Expand Down Expand Up @@ -3210,8 +3221,9 @@ In substitutions, the backslash character ``\`` will act as an escape when prece
python -c 'print("host: \{}".format("{env:HOSTNAME:host\: not set}")'

Note that any backslashes remaining after substitution may be processed by ``shlex`` during command parsing. On POSIX
platforms, the backslash will escape any following character; on windows, the backslash will escape any following quote,
whitespace, or backslash character (since it normally acts as a path delimiter).
platforms, the backslash escapes the following character. On Windows, it escapes quotes and other backslashes;
backslashes before whitespace remain path separators. UNC prefixes retain their leading pair. Single-quoted text is
literal on both platforms.

Special substitutions that accept additional colon-delimited ``:`` parameters cannot have a space after the ``:`` at the
beginning of line (e.g. ``{posargs: magic}`` would be parsed as factorial ``{posargs``, having value magic).
Expand Down
61 changes: 41 additions & 20 deletions src/tox/config/loader/str_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,26 +53,6 @@ def to_dict(value: str, of_type: tuple[type[Any], type[Any]]) -> Iterator[tuple[
msg = f"dictionary lines must be of form key=value, found {row!r}"
raise TypeError(msg)

@staticmethod
def _win32_process_path_backslash(value: str, escape: str, special_chars: str) -> str:
"""Escape backslash in value that is not followed by a special character.

This allows windows paths to be written without double backslash, while retaining the POSIX backslash escape
semantics for quotes and escapes.

"""
result = []
for ix, char in enumerate(value):
result.append(char)
if char == escape:
last_char = value[ix - 1 : ix]
if last_char == escape:
continue
next_char = value[ix + 1 : ix + 2]
if next_char not in {escape, *special_chars}:
result.append(escape) # escape escapes that are not themselves escaping a special character
return "".join(result)

@staticmethod
def to_command(value: str) -> Command:
"""At this point, ``value`` has already been substituted out, and all punctuation / escapes are final.
Expand Down Expand Up @@ -111,6 +91,47 @@ def to_command(value: str) -> Command:
args = ["-", *args]
return Command(args)

@staticmethod
def _win32_process_path_backslash(value: str, escape: str, special_chars: str) -> str:
"""Allow Windows paths while retaining shlex quote and backslash escapes."""
result: Final[list[str]] = []
quote = ""
path_start = True
index = 0
while index < len(value):
char = value[index]
if quote == "'":
result.append(char)
if char == quote:
quote = ""
path_start = False
elif char == escape:
following = value[index + 1 : index + 2]
if following == escape:
after_pair = value[index + 2 : index + 3]
# UNC prefixes need two literal backslashes; a bare pair still escapes one.
is_prefix = path_start and bool(after_pair) and after_pair not in escape + special_chars + " \t\r\n"
result.append(escape * (4 if is_prefix else 2))
index += 1
elif following and following in special_chars:
result.extend((escape, following))
index += 1
else:
result.append(escape if value[index - 1 : index] == escape else escape * 2)
path_start = False
else:
result.append(char)
if char in special_chars and not quote:
quote = char
path_start = True
elif char == quote:
quote = ""
path_start = False
else:
path_start = (not quote and char in " \t\r\n") or char == "="
index += 1
return "".join(result)

@staticmethod
def to_env_list(value: str) -> EnvList:
from tox.config.loader.ini.factor import extend_factors # ruff:ignore[import-outside-top-level]
Expand Down
70 changes: 47 additions & 23 deletions tests/config/loader/test_str_convert.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
from __future__ import annotations

import importlib
import sys
from pathlib import Path
from textwrap import dedent
from types import ModuleType
from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union

import pytest
Expand All @@ -13,6 +11,10 @@
from tox.config.types import Command, EnvList

if TYPE_CHECKING:
from typing import Final

from pytest_mock import MockerFixture

from tox.pytest import MonkeyPatch, SubRequest, ToxProjectCreator

from typing import Literal
Expand Down Expand Up @@ -143,14 +145,16 @@ def test_invalid_shell_expression(value: str, expected: list[str]) -> None:
('cc --arg "C:\\\\Users\\\\"', ["cc", "--arg", "C:\\Users\\"]),
('cc --arg "C:\\\\Users\\\\ "', ["cc", "--arg", "C:\\Users\\ "]),
(
r'cc --arg C:\\Users\\ --arg2 "SPECIAL:\Temp\f o o" --arg3="\\FOO\share\Path name" --arg4 SPECIAL:\Temp\ '[:-1],
r'cc --arg C:\\Users\\ --arg2 "SPECIAL:\Temp\f o o" --arg3="\\\\FOO\share\Path name" --arg4 SPECIAL:\Temp\ '[
:-1
],
[
"cc",
"--arg",
"C:\\Users\\",
"--arg2",
"SPECIAL:\\Temp\\f o o",
"--arg3=\\FOO\\share\\Path name",
r"--arg3=\\FOO\share\Path name",
"--arg4",
"SPECIAL:\\Temp\\",
],
Expand Down Expand Up @@ -178,25 +182,8 @@ def test_invalid_shell_expression(value: str, expected: list[str]) -> None:


@pytest.fixture(params=["win32", "linux2"])
def sys_platform(request: SubRequest, monkeypatch: MonkeyPatch) -> str:
class _SelectiveSys(ModuleType):
"""A sys-like proxy that only overrides `platform`."""

def __init__(self, patched_platform: str) -> None:
super().__init__("sys")
self.__dict__["_real"] = sys
self.__dict__["_patched_platform"] = patched_platform

def __getattr__(self, name: str) -> Any:
if name == "platform":
return self.__dict__["_patched_platform"]
return getattr(self.__dict__["_real"], name)

# Patches sys.platform only for the tox.config.loader.str_convert module.
# Everywhere else, sys.platform remains the real value.
mod = importlib.import_module("tox.config.loader.str_convert")
proxy = _SelectiveSys(str(request.param))
monkeypatch.setattr(mod, "sys", proxy, raising=True)
def sys_platform(request: SubRequest, mocker: MockerFixture) -> str:
mocker.patch("tox.config.loader.str_convert.sys", mocker.create_autospec(sys, platform=request.param))
return str(request.param)


Expand Down Expand Up @@ -229,6 +216,43 @@ def test_shlex_win32_trailing_sep(sys_platform: str, value: str, expected: list[
assert result.args == expected


@pytest.mark.parametrize(
("value", "expected"),
[
pytest.param(r"xcopy \\server\share\file.txt .", ["xcopy", r"\\server\share\file.txt", "."], id="unc-argument"),
pytest.param(r"\\server\share", [r"\\server\share"], id="unc-command"),
pytest.param(
r'copy "\\server\share\file name" .', ["copy", r"\\server\share\file name", "."], id="double-quoted"
),
pytest.param(
r"copy '\\server\share\file name' .", ["copy", r"\\server\share\file name", "."], id="single-quoted"
),
pytest.param(r'copy --source="\\server\share"', ["copy", r"--source=\\server\share"], id="quoted-option"),
pytest.param(r"copy --source=\\server\share", ["copy", r"--source=\\server\share"], id="unquoted-option"),
pytest.param(r"copy \\?\C:\file .", ["copy", r"\\?\C:\file", "."], id="extended-drive"),
pytest.param(r"copy \\?\UNC\server\share .", ["copy", r"\\?\UNC\server\share", "."], id="extended-unc"),
pytest.param(r"copy \\.\pipe\name .", ["copy", r"\\.\pipe\name", "."], id="device"),
pytest.param(r"copy \\\\server\share .", ["copy", r"\\server\share", "."], id="escaped-prefix"),
pytest.param("copy\t\\\\server\\share", ["copy", r"\\server\share"], id="tab-separator"),
pytest.param(r'copy "\\" \\', ["copy", "\\", "\\"], id="bare-pairs"),
pytest.param(r'copy "text \\server"', ["copy", r"text \server"], id="quoted-interior"),
pytest.param(r"copy path\\part", ["copy", r"path\part"], id="interior-pair"),
],
)
@pytest.mark.parametrize("sys_platform", ["win32"], indirect=True)
@pytest.mark.usefixtures("sys_platform")
@pytest.mark.parametrize("via_config", [False, True], ids=["direct", "ini"])
def test_shlex_win32_unc_path(
tox_project: ToxProjectCreator, value: str, expected: list[str], via_config: bool
) -> None:
if via_config:
outcome: Final = tox_project({"tox.ini": f"[testenv]\ncommands = {value}"}).run("c", "-k", "commands")
outcome.assert_success()
assert outcome.env_conf("py")["commands"] == [Command(args=expected)]
else:
assert StrConvert().to_command(value).args == expected


@pytest.mark.parametrize(
("value", "expected"),
[
Expand Down