Skip to content

Commit 3b56b5c

Browse files
authored
Allow extending lists with --override foo+=bar (#3088)
1 parent b40dc3f commit 3b56b5c

File tree

7 files changed

+121
-6
lines changed

7 files changed

+121
-6
lines changed

docs/changelog/3087.feature.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
``--override`` can now take options in the form of ``foo+=bar`` which
2+
will append ``bar`` to the end of an existing list/dict, rather than
3+
replacing it.

docs/config.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,3 +958,31 @@ Other Substitutions
958958

959959
* ``{}`` - replaced as ``os.pathsep``
960960
* ``{/}`` - replaced as ``os.sep``
961+
962+
Overriding configuration from the command line
963+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
964+
965+
You can override options in the configuration file, from the command
966+
line.
967+
968+
For example, given this config:
969+
970+
.. code-block:: ini
971+
972+
[testenv]
973+
deps = pytest
974+
setenv =
975+
foo=bar
976+
commands = pytest tests
977+
978+
You could enable ``ignore_errors`` by running::
979+
980+
tox --override testenv.ignore_errors=True
981+
982+
You could add additional dependencies by running::
983+
984+
tox --override testenv.deps+=pytest-xdist,pytest-cov
985+
986+
You could set additional environment variables by running::
987+
988+
tox --override testenv.setenv+=baz=quux

src/tox/config/loader/api.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ def __init__(self, value: str) -> None:
2424
if not equal:
2525
msg = f"override {value} has no = sign in it"
2626
raise ArgumentTypeError(msg)
27+
28+
self.append = False
29+
if key.endswith("+"): # key += value appends to a list
30+
key = key[:-1]
31+
self.append = True
32+
2733
self.namespace, _, self.key = key.rpartition(".")
2834

2935
def __repr__(self) -> str:
@@ -117,10 +123,25 @@ def load( # noqa: PLR0913
117123
:param args: the config load arguments
118124
:return: the converted type
119125
"""
120-
if key in self.overrides:
121-
return _STR_CONVERT.to(self.overrides[key].value, of_type, factory)
126+
from tox.config.set_env import SetEnv
127+
128+
override = self.overrides.get(key)
129+
if override and not override.append:
130+
return _STR_CONVERT.to(override.value, of_type, factory)
122131
raw = self.load_raw(key, conf, args.env_name)
123-
return self.build(key, of_type, factory, conf, raw, args)
132+
converted = self.build(key, of_type, factory, conf, raw, args)
133+
if override and override.append:
134+
appends = _STR_CONVERT.to(override.value, of_type, factory)
135+
if isinstance(converted, list) and isinstance(appends, list):
136+
converted += appends
137+
elif isinstance(converted, dict) and isinstance(appends, dict):
138+
converted.update(appends)
139+
elif isinstance(converted, SetEnv) and isinstance(appends, SetEnv):
140+
converted.update(appends, override=True)
141+
else:
142+
msg = "Only able to append to lists and dicts"
143+
raise ValueError(msg)
144+
return converted
124145

125146
def build( # noqa: PLR0913
126147
self,

src/tox/config/set_env.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,11 @@ def __iter__(self) -> Iterator[str]:
9595
self._raw.update(sub_raw)
9696
yield from sub_raw.keys()
9797

98-
def update(self, param: Mapping[str, str], *, override: bool = True) -> None:
99-
for key, value in param.items():
98+
def update(self, param: Mapping[str, str] | SetEnv, *, override: bool = True) -> None:
99+
for key in param:
100100
# do not override something already set explicitly
101101
if override or (key not in self._raw and key not in self._materialized):
102+
value = param.load(key) if isinstance(param, SetEnv) else param[key]
102103
self._materialized[key] = value
103104
self.changed = True
104105

tests/config/loader/test_loader.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@ def test_override_add(flag: str) -> None:
2828
assert value.key == "magic"
2929
assert value.value == "true"
3030
assert not value.namespace
31+
assert value.append is False
32+
33+
34+
@pytest.mark.parametrize("flag", ["-x", "--override"])
35+
def test_override_append(flag: str) -> None:
36+
parsed, _, __, ___, ____ = get_options(flag, "magic+=true")
37+
assert len(parsed.override) == 1
38+
value = parsed.override[0]
39+
assert value.key == "magic"
40+
assert value.value == "true"
41+
assert not value.namespace
42+
assert value.append is True
3143

3244

3345
def test_override_equals() -> None:

tests/config/test_main.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import os
44
from pathlib import Path
5-
from typing import TYPE_CHECKING
5+
from typing import TYPE_CHECKING, List
6+
7+
import pytest
68

79
from tox.config.loader.api import Override
810
from tox.config.loader.memory import MemoryLoader
@@ -64,6 +66,38 @@ def test_config_override_wins_memory_loader(tox_ini_conf: ToxIniCreator) -> None
6466
assert conf["c"] == "ok"
6567

6668

69+
def test_config_override_appends_to_list(tox_ini_conf: ToxIniCreator) -> None:
70+
example = """
71+
[testenv]
72+
passenv = foo
73+
"""
74+
conf = tox_ini_conf(example, override=[Override("testenv.passenv+=bar")]).get_env("testenv")
75+
conf.add_config("passenv", of_type=List[str], default=[], desc="desc")
76+
assert conf["passenv"] == ["foo", "bar"]
77+
78+
79+
def test_config_override_appends_to_setenv(tox_ini_conf: ToxIniCreator) -> None:
80+
example = """
81+
[testenv]
82+
setenv =
83+
foo = bar
84+
"""
85+
conf = tox_ini_conf(example, override=[Override("testenv.setenv+=baz=quux")]).get_env("testenv")
86+
assert conf["setenv"].load("foo") == "bar"
87+
assert conf["setenv"].load("baz") == "quux"
88+
89+
90+
def test_config_override_cannot_append(tox_ini_conf: ToxIniCreator) -> None:
91+
example = """
92+
[testenv]
93+
foo = 1
94+
"""
95+
conf = tox_ini_conf(example, override=[Override("testenv.foo+=2")]).get_env("testenv")
96+
conf.add_config("foo", of_type=int, default=0, desc="desc")
97+
with pytest.raises(ValueError, match="Only able to append to lists and dicts"):
98+
conf["foo"]
99+
100+
67101
def test_args_are_paths_when_disabled(tox_project: ToxProjectCreator) -> None:
68102
ini = "[testenv]\npackage=skip\ncommands={posargs}\nargs_are_paths=False"
69103
project = tox_project({"tox.ini": ini, "w": {"a.txt": "a"}})

tests/config/test_set_env.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,22 @@ def test_set_env_explicit() -> None:
3434
assert "MISS" not in set_env
3535

3636

37+
def test_set_env_merge() -> None:
38+
a = SetEnv("\nA=1\nB = 2\nC= 3\nD= 4", "py", "py", Path())
39+
b = SetEnv("\nA=2\nE = 5", "py", "py", Path())
40+
a.update(b, override=False)
41+
42+
keys = list(a)
43+
assert keys == ["E", "A", "B", "C", "D"]
44+
values = [a.load(k) for k in keys]
45+
assert values == ["5", "1", "2", "3", "4"]
46+
47+
a.update(b, override=True)
48+
49+
values = [a.load(k) for k in keys]
50+
assert values == ["5", "2", "2", "3", "4"]
51+
52+
3753
def test_set_env_bad_line() -> None:
3854
with pytest.raises(ValueError, match="A"):
3955
SetEnv("A", "py", "py", Path())

0 commit comments

Comments
 (0)