diff --git a/docs/changelog/2732.feature.rst b/docs/changelog/2732.feature.rst new file mode 100644 index 000000000..489edf9f0 --- /dev/null +++ b/docs/changelog/2732.feature.rst @@ -0,0 +1,14 @@ +Rewrite substitution replacement parser - by :user:`masenf` + +* ``\`` acts as a proper escape for ``\`` in ini-style substitutions +* The resulting value of a substitution is no longer reprocessed in the context + of the broader string. (Prior to this change, ini-values were repeatedly re-substituted until + the expression no longer had modifications) +* Migrate and update "Substitutions" section of Configuration page from v3 docs. +* ```find_replace_part`` is removed from ``tox.config.loader.ini.replace`` +* New names exported from ``tox.config.loader.ini.replace``: + * ``find_replace_expr`` + * ``MatchArg`` + * ``MatchError`` + * ``MatchExpression`` + * Note: the API for ``replace`` itself is unchanged. diff --git a/docs/config.rst b/docs/config.rst index 4ee3cefec..f2fff5ef2 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -763,3 +763,144 @@ Example configuration: [tox] skip_missing_interpreters = true + +Substitutions +------------- + +Any ``key=value`` setting in an ini-file can make use of **value substitution** +through the ``{...}`` string-substitution pattern. + +The string inside the curly braces may reference a global or per-environment config key as described above. + +The backslash character ``\`` will act as an escape for a following: ``\``, +``{``, ``}``, ``:``, ``[``, or ``]``, otherwise the backslash will be +reproduced literally:: + + commands = + python -c 'print("\{posargs} = \{}".format("{posargs}"))' + python -c 'print("host: \{}".format("{env:HOSTNAME:host\: not set}")' + +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). + +Environment variable substitutions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you specify a substitution string like this:: + + {env:KEY} + +then the value will be retrieved as ``os.environ['KEY']`` +and raise an Error if the environment variable +does not exist. + + +Environment variable substitutions with default values +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you specify a substitution string like this:: + + {env:KEY:DEFAULTVALUE} + +then the value will be retrieved as ``os.environ['KEY']`` +and replace with DEFAULTVALUE if the environment variable does not +exist. + +If you specify a substitution string like this:: + + {env:KEY:} + +then the value will be retrieved as ``os.environ['KEY']`` +and replace with an empty string if the environment variable does not +exist. + +Substitutions can also be nested. In that case they are expanded starting +from the innermost expression:: + + {env:KEY:{env:DEFAULT_OF_KEY}} + +the above example is roughly equivalent to +``os.environ.get('KEY', os.environ['DEFAULT_OF_KEY'])`` + +Interactive shell substitution +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 3.4.0 + +It's possible to inject a config value only when tox is running in interactive shell (standard input):: + + {tty:ON_VALUE:OFF_VALUE} + +The first value is the value to inject when the interactive terminal is +available, the second value is the value to use when it's not (optiona). A good +use case for this is e.g. passing in the ``--pdb`` flag for pytest. + +.. _`command positional substitution`: +.. _`positional substitution`: + +Substitutions for positional arguments in commands +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 1.0 + +If you specify a substitution string like this:: + + {posargs:DEFAULTS} + +then the value will be replaced with positional arguments as provided +to the tox command:: + + tox arg1 arg2 + +In this instance, the positional argument portion will be replaced with +``arg1 arg2``. If no positional arguments were specified, the value of +DEFAULTS will be used instead. If DEFAULTS contains other substitution +strings, such as ``{env:*}``, they will be interpreted., + +Use a double ``--`` if you also want to pass options to an underlying +test command, for example:: + + tox -- --opt1 ARG1 + +will make the ``--opt1 ARG1`` appear in all test commands where ``[]`` or +``{posargs}`` was specified. By default (see ``args_are_paths`` +setting), ``tox`` rewrites each positional argument if it is a relative +path and exists on the filesystem to become a path relative to the +``changedir`` setting. + +Substitution for values from other sections +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 1.4 + +Values from other sections can be referred to via:: + + {[sectionname]valuename} + +which you can use to avoid repetition of config values. +You can put default values in one section and reference them in others to avoid repeating the same values: + +.. code-block:: ini + + [base] + deps = + pytest + mock + pytest-xdist + + [testenv:dulwich] + deps = + dulwich + {[base]deps} + + [testenv:mercurial] + deps = + mercurial + {[base]deps} + +Other Substitutions +~~~~~~~~~~~~~~~~~~~ + +* ``{}`` - replaced as ``os.pathsep`` +* ``{/}`` - replaced as ``os.sep`` diff --git a/src/tox/config/loader/ini/replace.py b/src/tox/config/loader/ini/replace.py index cb0174e3e..a1d3846e4 100644 --- a/src/tox/config/loader/ini/replace.py +++ b/src/tox/config/loader/ini/replace.py @@ -9,7 +9,7 @@ from configparser import SectionProxy from functools import lru_cache from pathlib import Path -from typing import TYPE_CHECKING, Iterator, Pattern +from typing import TYPE_CHECKING, Any, Iterator, Pattern, Sequence, Union from tox.config.loader.api import ConfigLoadArgs from tox.config.loader.stringify import stringify @@ -21,74 +21,175 @@ from tox.config.loader.ini import IniLoader from tox.config.main import Config -# split alongside :, unless it's escaped, or it's preceded by a single capital letter (Windows drive letter in paths) -ARGS_GROUP = re.compile(r"(? MatchArg: + """Find all replaceable tokens within value.""" + return MatchExpression.parse_and_split_to_terminator(value)[0][0] def replace(conf: Config, loader: IniLoader, value: str, args: ConfigLoadArgs) -> str: - # perform all non-escaped replaces - end = 0 - while True: - start, end, to_replace = find_replace_part(value, end) - if to_replace is None: - break - replaced = _replace_match(conf, loader, to_replace, args.copy()) - if replaced is None: - # if we cannot replace, keep what was there, and continue looking for additional replaces following - # note, here we cannot raise because the content may be a factorial expression, and in those case we don't - # want to enforce escaping curly braces, e.g. it should work to write: env_list = {py39,py38}-{,dep} - end = end + 1 - continue - new_value = f"{value[:start]}{replaced}{value[end + 1:]}" - end = 0 # if we performed a replacement start over - if new_value == value: # if we're not making progress stop (circular reference?) - break - value = new_value - # remove escape sequences - value = value.replace("\\{", "{") - value = value.replace("\\}", "}") - value = value.replace("\\[", "[") - value = value.replace("\\]", "]") - return value - - -REPLACE_PART = re.compile( - r""" - (? tuple[int, int, str | None]: - match = REPLACE_PART.search(value, end) - if match is None: - return -1, -1, None - if match.group() == "[]": - return match.start(), match.end() - 1, "posargs" # brackets is an alias for positional arguments - matched_part = match.group()[1:-1] - return match.start(), match.end() - 1, matched_part - - -def _replace_match(conf: Config, loader: IniLoader, value: str, conf_args: ConfigLoadArgs) -> str | None: - of_type, *args = ARGS_GROUP.split(value) - if of_type == "/": - replace_value: str | None = os.sep - elif of_type == "" and args == [""]: - replace_value = os.pathsep - elif of_type == "env": - replace_value = replace_env(conf, args, conf_args) - elif of_type == "tty": - replace_value = replace_tty(args) - elif of_type == "posargs": - replace_value = replace_pos_args(conf, args, conf_args) - else: - replace_value = replace_reference(conf, loader, value, conf_args) - return replace_value +class MatchError(Exception): + """Could not find end terminator in MatchExpression.""" + + +class MatchExpression: + """An expression that is handled specially by the Replacer.""" + + def __init__(self, expr: Sequence[MatchArg], term_pos: int | None = None): + self.expr = expr + self.term_pos = term_pos + + def __repr__(self) -> str: + return f"MatchExpression(expr={self.expr!r}, term_pos={self.term_pos!r})" + + def __eq__(self, other: Any) -> bool: + if isinstance(other, type(self)): + return self.expr == other.expr + return NotImplemented + + @classmethod + def _next_replace_expression(cls, value: str) -> MatchExpression | None: + """Process a curly brace replacement expression.""" + if value.startswith("[]"): + # `[]` is shorthand for `{posargs}` + return MatchExpression(expr=[["posargs"]], term_pos=1) + if not value.startswith(REPLACE_START): + return None + try: + # recursively handle inner expression + rec_expr, term_pos = cls.parse_and_split_to_terminator( + value[1:], + terminator=REPLACE_END, + split=ARG_DELIMITER, + ) + except MatchError: + # did NOT find the expected terminator character, so treat `{` as if escaped + pass + else: + return MatchExpression(expr=rec_expr, term_pos=term_pos) + return None + + @classmethod + def parse_and_split_to_terminator( + cls, + value: str, + terminator: str = "", + split: str | None = None, + ) -> tuple[Sequence[MatchArg], int]: + """ + Tokenize `value` to up `terminator` character. + + If `split` is given, multiple arguments will be returned. + + Returns list of arguments (list of str or MatchExpression) and final character position examined in value. + + This function recursively calls itself via `_next_replace_expression`. + """ + args = [] + last_arg: list[str | MatchExpression] = [] + pos = 0 + + while pos < len(value): + if len(value) > pos + 1 and value[pos] == "\\" and value[pos + 1] in BACKSLASH_ESCAPE_CHARS: + # backslash escapes the next character from a special set + last_arg.append(value[pos + 1]) + pos += 2 + continue + fragment = value[pos:] + if terminator and fragment.startswith(terminator): + pos += len(terminator) + break + if split and fragment.startswith(split): + # found a new argument + args.append(last_arg) + last_arg = [] + pos += len(split) + continue + expr = cls._next_replace_expression(fragment) + if expr is not None: + pos += (expr.term_pos or 0) + 1 + last_arg.append(expr) + continue + # default case: consume the next character + last_arg.append(value[pos]) + pos += 1 + else: # fell out of the loop + if terminator: + raise MatchError(f"{terminator!r} remains unmatched in {value!r}") + args.append(last_arg) + return [_flatten_string_fragments(a) for a in args], pos + + +def _flatten_string_fragments(seq_of_str_or_other: Sequence[str | Any]) -> Sequence[str | Any]: + """Join runs of contiguous str values in a sequence; nny non-str items in the sequence are left as-is.""" + result = [] + last_str = [] + for obj in seq_of_str_or_other: + if isinstance(obj, str): + last_str.append(obj) + else: + if last_str: + result.append("".join(last_str)) + last_str = [] + result.append(obj) + if last_str: + result.append("".join(last_str)) + return result + + +class Replacer: + """Recursively expand MatchExpression against the config and loader.""" + + def __init__(self, conf: Config, loader: IniLoader, conf_args: ConfigLoadArgs): + self.conf = conf + self.loader = loader + self.conf_args = conf_args + + def __call__(self, value: MatchArg) -> Sequence[str]: + return [self._replace_match(me) if isinstance(me, MatchExpression) else str(me) for me in value] + + def join(self, value: MatchArg) -> str: + return "".join(self(value)) + + def _replace_match(self, value: MatchExpression) -> str: + of_type, *args = flattened_args = [self.join(arg) for arg in value.expr] + if of_type == "/": + replace_value: str | None = os.sep + elif of_type == "" and args == [""]: + replace_value = os.pathsep + elif of_type == "env": + replace_value = replace_env(self.conf, args, self.conf_args) + elif of_type == "tty": + replace_value = replace_tty(args) + elif of_type == "posargs": + replace_value = replace_pos_args(self.conf, args, self.conf_args) + else: + replace_value = replace_reference( + self.conf, + self.loader, + ARG_DELIMITER.join(flattened_args), + self.conf_args, + ) + if replace_value is not None: + return replace_value + # else: fall through -- when replacement is not possible, treat `{` as if escaped. + # If we cannot replace, keep what was there, and continue looking for additional replaces + # NOTE: cannot raise because the content may be a factorial expression where we don't + # want to enforce escaping curly braces, e.g. `env_list = {py39,py38}-{,dep}` should work + return f"{REPLACE_START}%s{REPLACE_END}" % ARG_DELIMITER.join(flattened_args) @lru_cache(maxsize=None) @@ -98,6 +199,7 @@ def _replace_ref(env: str | None) -> Pattern[str]: (\[(?P{re.escape(env or '.*')}(:(?P[^]]+))?|(?P
[-\w]+))])? # env/section (?P[-a-zA-Z0-9_]+) # key (:(?P.*))? # default value + $ """, re.VERBOSE, ) @@ -179,13 +281,15 @@ def replace_pos_args(conf: Config, args: list[str], conf_args: ConfigLoadArgs) - pass pos_args = conf.pos_args(to_path) if pos_args is None: - replace_value = ":".join(args) # if we use the defaults join back remaining args + replace_value = ARG_DELIMITER.join(args) # if we use the defaults join back remaining args else: replace_value = shell_cmd(pos_args) return replace_value def replace_env(conf: Config, args: list[str], conf_args: ConfigLoadArgs) -> str: + if not args or not args[0]: + raise MatchError("No variable name was supplied in {env} substitution") key = args[0] new_key = f"env:{key}" @@ -203,7 +307,7 @@ def replace_env(conf: Config, args: list[str], conf_args: ConfigLoadArgs) -> str if key in os.environ: return os.environ[key] - return "" if len(args) == 1 else ":".join(args[1:]) + return "" if len(args) == 1 else ARG_DELIMITER.join(args[1:]) def replace_tty(args: list[str]) -> str: @@ -215,6 +319,9 @@ def replace_tty(args: list[str]) -> str: __all__ = ( + "find_replace_expr", + "MatchArg", + "MatchError", + "MatchExpression", "replace", - "find_replace_part", ) diff --git a/src/tox/config/set_env.py b/src/tox/config/set_env.py index fe5add489..b9cc94764 100644 --- a/src/tox/config/set_env.py +++ b/src/tox/config/set_env.py @@ -18,7 +18,7 @@ def __init__(self, raw: str, name: str, env_name: str | None, root: Path) -> Non self._env_files: list[str] = [] self._replacer: Replacer = lambda s, c: s # noqa: U100 self._name, self._env_name, self._root = name, env_name, root - from .loader.ini.replace import find_replace_part + from .loader.ini.replace import MatchExpression, find_replace_expr for line in raw.splitlines(): if line.strip(): @@ -30,9 +30,10 @@ def __init__(self, raw: str, name: str, env_name: str | None, root: Path) -> Non if "{" in key: raise ValueError(f"invalid line {line!r} in set_env") except ValueError: - _, __, match = find_replace_part(line, 0) - if match: - self._needs_replacement.append(line) + for expr in find_replace_expr(line): + if isinstance(expr, MatchExpression): + self._needs_replacement.append(line) + break else: raise else: diff --git a/tests/config/loader/ini/replace/test_replace.py b/tests/config/loader/ini/replace/test_replace.py index 39aab9d36..54bf2a7e5 100644 --- a/tests/config/loader/ini/replace/test_replace.py +++ b/tests/config/loader/ini/replace/test_replace.py @@ -2,26 +2,92 @@ import pytest -from tox.config.loader.ini.replace import find_replace_part +from tests.config.loader.ini.replace.conftest import ReplaceOne +from tox.config.loader.ini.replace import MatchExpression, find_replace_expr +from tox.report import HandledError @pytest.mark.parametrize( - ("value", "result"), + ("value", "exp_output"), [ - ("[]", (0, 1, "posargs")), - ("123[]", (3, 4, "posargs")), - ("[]123", (0, 1, "posargs")), - (r"\[\] []", (5, 6, "posargs")), - (r"[\] []", (4, 5, "posargs")), - (r"\[] []", (4, 5, "posargs")), - ("{foo}", (0, 4, "foo")), - (r"\{foo} {bar}", (7, 11, "bar")), - ("{foo} {bar}", (0, 4, "foo")), - (r"{foo\} {bar}", (7, 11, "bar")), - (r"{foo:{bar}}", (5, 9, "bar")), - (r"{\{}", (0, 3, r"\{")), - (r"{\}}", (0, 3, r"\}")), + ("[]", [MatchExpression([["posargs"]])]), + ("123[]", ["123", MatchExpression([["posargs"]])]), + ("[]123", [MatchExpression([["posargs"]]), "123"]), + (r"\[\] []", ["[] ", MatchExpression([["posargs"]])]), + (r"[\] []", ["[] ", MatchExpression([["posargs"]])]), + (r"\[] []", ["[] ", MatchExpression([["posargs"]])]), + ("{foo}", [MatchExpression([["foo"]])]), + (r"\{foo} {bar}", ["{foo} ", MatchExpression([["bar"]])]), + ("{foo} {bar}", [MatchExpression([["foo"]]), " ", MatchExpression([["bar"]])]), + (r"{foo\} {bar}", ["{foo} ", MatchExpression([["bar"]])]), + (r"{foo:{bar}}", [MatchExpression([["foo"], [MatchExpression([["bar"]])]])]), + (r"{foo\::{bar}}", [MatchExpression([["foo:"], [MatchExpression([["bar"]])]])]), + (r"{foo:B:c:D:e}", [MatchExpression([["foo"], ["B"], ["c"], ["D"], ["e"]])]), + (r"{\{}", [MatchExpression([["{"]])]), + (r"{\}}", [MatchExpression([["}"]])]), + ( + r"p{foo:b{a{r}:t}:{ba}z}s", + [ + "p", + MatchExpression( + [ + ["foo"], + [ + "b", + MatchExpression( + [ + ["a", MatchExpression([["r"]])], + ["t"], + ], + ), + ], + [ + MatchExpression( + [["ba"]], + ), + "z", + ], + ], + ), + "s", + ], + ), + ("\\", ["\\"]), + (r"\d", ["\\d"]), + (r"C:\WINDOWS\foo\bar", [r"C:\WINDOWS\foo\bar"]), ], ) -def test_match(value: str, result: tuple[int, int, str]) -> None: - assert find_replace_part(value, 0) == result +def test_match_expr(value: str, exp_output: list[str | MatchExpression]) -> None: + assert find_replace_expr(value) == exp_output + + +@pytest.mark.parametrize( + ("value", "exp_exception"), + [ + ("py-{foo,bar}", None), + ("py37-{base,i18n},b", None), + ("py37-{i18n,base},b", None), + ("{toxinidir,}", None), + ("{env}", r"MatchError\('No variable name was supplied in {env} substitution'\)"), + ], +) +def test_dont_replace(replace_one: ReplaceOne, value: str, exp_exception: str | None) -> None: + """Test that invalid expressions are not replaced.""" + if exp_exception: + with pytest.raises(HandledError, match=exp_exception): + replace_one(value) + else: + assert replace_one(value) == value + + +@pytest.mark.parametrize( + ("match_expression", "exp_repr"), + [ + (MatchExpression([["posargs"]]), "MatchExpression(expr=[['posargs']], term_pos=None)"), + (MatchExpression([["posargs"]], 1), "MatchExpression(expr=[['posargs']], term_pos=1)"), + (MatchExpression("foo", -42), "MatchExpression(expr='foo', term_pos=-42)"), + ], +) +def test_match_expression_repr(match_expression: MatchExpression, exp_repr: str) -> None: + print(match_expression) + assert repr(match_expression) == exp_repr diff --git a/tests/config/loader/ini/replace/test_replace_env_var.py b/tests/config/loader/ini/replace/test_replace_env_var.py index 9164a1e7c..c9f26400d 100644 --- a/tests/config/loader/ini/replace/test_replace_env_var.py +++ b/tests/config/loader/ini/replace/test_replace_env_var.py @@ -1,5 +1,10 @@ from __future__ import annotations +import threading +from typing import Generator + +import pytest + from tests.config.loader.ini.replace.conftest import ReplaceOne from tox.pytest import MonkeyPatch @@ -11,6 +16,43 @@ def test_replace_env_set(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> N assert result == "something good" +def test_replace_env_set_double_bs(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None: + """Double backslash should escape to single backslash and not affect surrounding replacements.""" + monkeypatch.setenv("MAGIC", "something good") + result = replace_one(r"{env:MAGIC}\\{env:MAGIC}") + assert result == r"something good\something good" + + +def test_replace_env_set_triple_bs(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None: + """Triple backslash should escape to single backslash also escape subsequent replacement.""" + monkeypatch.setenv("MAGIC", "something good") + result = replace_one(r"{env:MAGIC}\\\{env:MAGIC}") + assert result == r"something good\{env:MAGIC}" + + +def test_replace_env_set_quad_bs(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None: + """Quad backslash should escape to two backslashes and not affect surrounding replacements.""" + monkeypatch.setenv("MAGIC", "something good") + result = replace_one(r"\\{env:MAGIC}\\\\{env:MAGIC}\\") + assert result == r"\something good\\something good" + "\\" + + +def test_replace_env_when_value_is_backslash(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None: + """When the replacement value is backslash, it shouldn't affect the next replacement.""" + monkeypatch.setenv("MAGIC", "tragic") + monkeypatch.setenv("BS", "\\") + result = replace_one(r"{env:BS}{env:MAGIC}") + assert result == r"\tragic" + + +def test_replace_env_when_value_is_stuff_then_backslash(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None: + """When the replacement value is a string containing backslash, it shouldn't affect the next replacement.""" + monkeypatch.setenv("MAGIC", "tragic") + monkeypatch.setenv("BS", "stuff\\") + result = replace_one(r"{env:BS}{env:MAGIC}") + assert result == r"stuff\tragic" + + def test_replace_env_missing(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None: """If we have a factor that is not specified within the core env-list then that's also an environment""" monkeypatch.delenv("MAGIC", raising=False) @@ -34,14 +76,60 @@ def test_replace_env_missing_default_from_env(replace_one: ReplaceOne, monkeypat def test_replace_env_var_circular(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None: - """If we have a factor that is not specified within the core env-list then that's also an environment""" + """Replacement values will not infinitely loop""" monkeypatch.setenv("MAGIC", "{env:MAGIC}") result = replace_one("{env:MAGIC}") assert result == "{env:MAGIC}" +@pytest.fixture() +def reset_env_var_after_delay(monkeypatch: MonkeyPatch) -> Generator[threading.Thread, None, None]: + timeout = 2 + + def avoid_infinite_loop() -> None: # pragma: no cover + monkeypatch.setenv("TRAGIC", f"envvar forcibly reset after {timeout} sec") + + timer = threading.Timer(2, avoid_infinite_loop) + timer.start() + yield timer + timer.cancel() + timer.join() + + +@pytest.mark.usefixtures("reset_env_var_after_delay") +def test_replace_env_var_circular_flip_flop(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None: + """Replacement values will not infinitely loop back and forth""" + monkeypatch.setenv("TRAGIC", "{env:MAGIC}") + monkeypatch.setenv("MAGIC", "{env:TRAGIC}") + result = replace_one("{env:MAGIC}") + assert result == "{env:TRAGIC}" + + +@pytest.mark.parametrize("fallback", [True, False]) +def test_replace_env_var_chase(replace_one: ReplaceOne, monkeypatch: MonkeyPatch, fallback: bool) -> None: + """Resolve variable to be replaced and default value via indirection.""" + monkeypatch.setenv("WALK", "THIS") + def_val = "or that one" + monkeypatch.setenv("DEF", def_val) + if fallback: + monkeypatch.delenv("THIS", raising=False) + exp_result = def_val + else: + this_val = "path" + monkeypatch.setenv("THIS", this_val) + exp_result = this_val + result = replace_one("{env:{env:WALK}:{env:DEF}}") + assert result == exp_result + + def test_replace_env_default_with_colon(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None: """If we have a factor that is not specified within the core env-list then that's also an environment""" monkeypatch.delenv("MAGIC", raising=False) result = replace_one("{env:MAGIC:https://some.url.org}") assert result == "https://some.url.org" + + +def test_replace_env_default_deep(replace_one: ReplaceOne, monkeypatch: MonkeyPatch) -> None: + """Get the value through a long tree of nested defaults.""" + monkeypatch.delenv("M", raising=False) + assert replace_one("{env:M:{env:M:{env:M:{env:M:{env:M:foo}}}}}") == "foo" diff --git a/tests/config/loader/ini/replace/test_replace_os_sep.py b/tests/config/loader/ini/replace/test_replace_os_sep.py index 04920c7ba..2ab0e4670 100644 --- a/tests/config/loader/ini/replace/test_replace_os_sep.py +++ b/tests/config/loader/ini/replace/test_replace_os_sep.py @@ -2,9 +2,29 @@ import os +import pytest + from tests.config.loader.ini.replace.conftest import ReplaceOne +from tox.pytest import MonkeyPatch def test_replace_os_sep(replace_one: ReplaceOne) -> None: result = replace_one("{/}") assert result == os.sep + + +@pytest.mark.parametrize("sep", ["/", "\\"]) +def test_replace_os_sep_before_curly(monkeypatch: MonkeyPatch, replace_one: ReplaceOne, sep: str) -> None: + """Explicit test case for issue #2732 (windows only).""" + monkeypatch.setattr(os, "sep", sep) + monkeypatch.delenv("_", raising=False) + result = replace_one("{/}{env:_:foo}") + assert result == os.sep + "foo" + + +@pytest.mark.parametrize("sep", ["/", "\\"]) +def test_replace_os_sep_sub_exp_regression(monkeypatch: MonkeyPatch, replace_one: ReplaceOne, sep: str) -> None: + monkeypatch.setattr(os, "sep", sep) + monkeypatch.delenv("_", raising=False) + result = replace_one("{env:_:{posargs}{/}.{posargs}}", ["foo"]) + assert result == f"foo{os.sep}.foo"