Skip to content

Commit 3e1fd5b

Browse files
committed
Add test coverage, fix circular reference config
Signed-off-by: Bernát Gábor <[email protected]>
1 parent 56a91db commit 3e1fd5b

18 files changed

+187
-48
lines changed

src/tox/config/cli/ini.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def get(self, key: str, of_type: Type[Any]) -> Any:
5151
result = None
5252
else:
5353
source = "file"
54-
value = self.ini.load(key, of_type=of_type, conf=None, env_name="tox")
54+
value = self.ini.load(key, of_type=of_type, conf=None, env_name="tox", chain=[key])
5555
result = value, source
5656
except KeyError: # just not found
5757
result = None

src/tox/config/loader/api.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ def found_keys(self) -> Set[str]:
7171
def __repr__(self) -> str:
7272
return f"{type(self).__name__}"
7373

74-
def load(self, key: str, of_type: Type[V], conf: Optional["Config"], env_name: Optional[str]) -> V:
74+
def load(
75+
self, key: str, of_type: Type[V], conf: Optional["Config"], env_name: Optional[str], chain: List[str]
76+
) -> V:
7577
"""
7678
Load a value.
7779
@@ -85,14 +87,21 @@ def load(self, key: str, of_type: Type[V], conf: Optional["Config"], env_name: O
8587
return _STR_CONVERT.to(self.overrides[key].value, of_type)
8688
raw = self.load_raw(key, conf, env_name)
8789
future: "Future[V]" = Future()
88-
with self.build(future, key, of_type, conf, env_name, raw) as prepared:
90+
with self.build(future, key, of_type, conf, env_name, raw, chain) as prepared:
8991
converted = self.to(prepared, of_type)
9092
future.set_result(converted)
9193
return converted
9294

9395
@contextmanager
9496
def build(
95-
self, future: "Future[V]", key: str, of_type: Type[V], conf: Optional["Config"], env_name: Optional[str], raw: T
97+
self,
98+
future: "Future[V]",
99+
key: str,
100+
of_type: Type[V],
101+
conf: Optional["Config"],
102+
env_name: Optional[str],
103+
raw: T,
104+
chain: List[str],
96105
) -> Generator[T, None, None]:
97106
yield raw
98107

src/tox/config/loader/ini/__init__.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,26 +46,27 @@ def build(
4646
conf: Optional["Config"],
4747
env_name: Optional[str],
4848
raw: str,
49+
chain: List[str],
4950
) -> Generator[str, None, None]:
5051
delay_replace = inspect.isclass(of_type) and issubclass(of_type, SetEnv)
5152

52-
def replacer(raw_: str, chain: List[str]) -> str:
53+
def replacer(raw_: str, chain_: List[str]) -> str:
5354
if conf is None:
5455
replaced = raw_ # no replacement supported in the core section
5556
else:
5657
try:
57-
replaced = replace(conf, env_name, self, raw_, chain) # do replacements
58+
replaced = replace(conf, env_name, self, raw_, chain_) # do replacements
5859
except Exception as exception:
5960
msg = f"replace failed in {'tox' if env_name is None else env_name}.{key} with {exception!r}"
6061
raise HandledError(msg) from exception
6162
return replaced
6263

6364
if not delay_replace:
64-
raw = replacer(raw, [key])
65+
raw = replacer(raw, chain)
6566
yield raw
6667
if delay_replace:
6768
converted = future.result()
68-
if hasattr(converted, "replacer"):
69+
if hasattr(converted, "replacer"): # pragma: no branch
6970
converted.replacer = replacer # type: ignore[attr-defined]
7071

7172
def found_keys(self) -> Set[str]:

src/tox/config/loader/ini/replace.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,7 @@ def _replace_match(
8383
elif of_type == "posargs":
8484
replace_value = replace_pos_args(args, conf.pos_args)
8585
else:
86-
if of_type in chain:
87-
raise ValueError(f"circular chain detected {', '.join(chain[chain.index(of_type):])}")
88-
chain.append(of_type)
89-
replace_value = replace_reference(conf, current_env, loader, value)
86+
replace_value = replace_reference(conf, current_env, loader, value, chain)
9087
return replace_value
9188

9289

@@ -105,6 +102,7 @@ def replace_reference(
105102
current_env: Optional[str],
106103
loader: "IniLoader",
107104
value: str,
105+
chain: List[str],
108106
) -> Optional[str]:
109107
# a return value of None indicates could not replace
110108
match = _REPLACE_REF.match(value)
@@ -121,7 +119,7 @@ def replace_reference(
121119
try:
122120
if isinstance(src, SectionProxy):
123121
return src[key]
124-
value = src[key]
122+
value = src.load(key, chain)
125123
as_str, _ = stringify(value)
126124
return as_str
127125
except KeyError as exc: # if fails, keep trying maybe another source can satisfy
@@ -185,12 +183,15 @@ def replace_env(conf: Config, env_name: Optional[str], args: List[str], chain: L
185183
key = args[0]
186184
new_key = f"env:{key}"
187185

188-
if env_name is not None and new_key not in chain: # check if set env
189-
chain.append(new_key)
190-
env_conf = conf.get_env(env_name)
191-
set_env: SetEnv = env_conf["set_env"]
192-
if key in set_env:
193-
return set_env.load(key, chain)
186+
if env_name is not None: # on core no set env support # pragma: no branch
187+
if new_key not in chain: # check if set env
188+
chain.append(new_key)
189+
env_conf = conf.get_env(env_name)
190+
set_env: SetEnv = env_conf["set_env"]
191+
if key in set_env:
192+
return set_env.load(key, chain)
193+
elif chain[-1] != new_key: # if there's a chain but only self-refers than use os.environ
194+
raise ValueError(f"circular chain between set env {', '.join(i[4:] for i in chain[chain.index(new_key):])}")
194195

195196
if key in os.environ:
196197
return os.environ[key]

src/tox/config/loader/str_convert.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def to_command(value: str) -> Command:
5353
is_win = sys.platform == "win32"
5454
splitter = shlex.shlex(value, posix=not is_win)
5555
splitter.whitespace_split = True
56-
if is_win:
56+
if is_win: # pragma: win32 cover
5757
args: List[str] = []
5858
for arg in splitter:
5959
# on Windows quoted arguments will remain quoted, strip it

src/tox/config/loader/stringify.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@ def stringify(value: Any) -> Tuple[str, bool]:
2828
if isinstance(value, Command):
2929
return value.shell, True
3030
if isinstance(value, SetEnv):
31-
return "\n".join(f"{stringify(k)[0]}={stringify(value.load(k))[0]}" for k in sorted(list(value))), True
32-
31+
return stringify({k: value.load(k) for k in sorted(list(value))})
3332
return str(value), False
3433

3534

src/tox/config/of_type.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def __init__(self, keys: Iterable[str], desc: str, env_name: Optional[str]) -> N
2323
self.env_name = env_name
2424

2525
@abstractmethod
26-
def __call__(self, conf: "Config", key: Optional[str], loaders: List[Loader[T]]) -> T:
26+
def __call__(self, conf: "Config", key: Optional[str], loaders: List[Loader[T]], chain: List[str]) -> T:
2727
raise NotImplementedError
2828

2929
def __eq__(self, o: Any) -> bool:
@@ -46,7 +46,7 @@ def __init__(
4646
super().__init__(keys, desc, env_name)
4747
self.value = value
4848

49-
def __call__(self, conf: "Config", name: Optional[str], loaders: List[Loader[T]]) -> T:
49+
def __call__(self, conf: "Config", name: Optional[str], loaders: List[Loader[T]], chain: List[str]) -> T:
5050
if callable(self.value):
5151
value = self.value()
5252
else:
@@ -78,13 +78,13 @@ def __init__(
7878
self.post_process = post_process
7979
self._cache: Union[object, T] = _PLACE_HOLDER
8080

81-
def __call__(self, conf: "Config", name: Optional[str], loaders: List[Loader[T]]) -> T:
81+
def __call__(self, conf: "Config", name: Optional[str], loaders: List[Loader[T]], chain: List[str]) -> T:
8282
if self._cache is _PLACE_HOLDER:
8383
found = False
8484
for key in self.keys:
8585
for loader in loaders:
8686
try:
87-
value = loader.load(key, self.of_type, conf, self.env_name)
87+
value = loader.load(key, self.of_type, conf, self.env_name, chain)
8888
found = True
8989
except KeyError:
9090
continue

src/tox/config/set_env.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from typing import Callable, Dict, Iterator, List, Mapping, Optional, Tuple
22

3+
Replacer = Callable[[str, List[str]], str]
4+
35

46
class SetEnv:
57
def __init__(self, raw: str) -> None:
6-
self.replacer: Callable[[str, List[str]], str] = lambda s, c: s
7-
# resolve tty, posargs, etc?
8+
self.replacer: Replacer = lambda s, c: s
89
lines = raw.splitlines()
910
self._later: List[str] = []
1011
self._raw: Dict[str, str] = {}
@@ -15,7 +16,7 @@ def __init__(self, raw: str) -> None:
1516
try:
1617
key, value = self._extract_key_value(line)
1718
if "{" in key:
18-
raise ValueError
19+
raise ValueError(f"invalid line {line!r} in set_env")
1920
except ValueError:
2021
_, __, match = find_replace_part(line, 0, 0)
2122
if match:
@@ -28,9 +29,10 @@ def __init__(self, raw: str) -> None:
2829

2930
@staticmethod
3031
def _extract_key_value(line: str) -> Tuple[str, str]:
31-
at = line.index("=")
32-
if at == -1:
33-
raise ValueError(line)
32+
try:
33+
at = line.index("=")
34+
except ValueError:
35+
raise ValueError(f"invalid line {line!r} in set_env")
3436
key, value = line[:at], line[at + 1 :]
3537
return key.strip(), value.strip()
3638

src/tox/config/sets.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,16 @@ def _add_conf(self, keys: Sequence[str], definition: ConfigDefinition[V]) -> Con
8181
return definition
8282

8383
def __getitem__(self, item: str) -> Any:
84+
return self.load(item)
85+
86+
def load(self, item: str, chain: Optional[List[str]] = None) -> Any:
8487
config_definition = self._defined[item]
85-
return config_definition(self._conf, item, self.loaders)
88+
if chain is None:
89+
chain = []
90+
if item in chain:
91+
raise ValueError(f"circular chain detected {', '.join(chain[chain.index(item):])}")
92+
chain.append(item)
93+
return config_definition(self._conf, item, self.loaders, chain)
8694

8795
def __repr__(self) -> str:
8896
values = (v for v in (f"name={self.name!r}" if self.name else "", f"loaders={self.loaders!r}") if v)

src/tox/provision.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def provision(state: State) -> Union[int, bool]:
7373
for package in requires:
7474
package_name = canonicalize_name(package.name)
7575
try:
76-
dist = distribution(package_name)
76+
dist = distribution(package_name) # type: ignore[no-untyped-call]
7777
if not package.specifier.contains(dist.version, prereleases=True):
7878
missing.append((package, dist.version))
7979
except PackageNotFoundError:

src/tox/pytest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from virtualenv.info import IS_WIN, fs_supports_symlink
3333

3434
import tox.run
35+
from tox.config.sets import EnvConfigSet
3536
from tox.execute.api import Execute, ExecuteInstance, ExecuteStatus, Outcome
3637
from tox.execute.request import ExecuteRequest, shell_cmd
3738
from tox.execute.stream import SyncWrite
@@ -299,6 +300,9 @@ def state(self) -> State:
299300
raise RuntimeError("no state")
300301
return self._state
301302

303+
def env_conf(self, name: str) -> EnvConfigSet:
304+
return self.state.conf.get_env(name)
305+
302306
@property
303307
def success(self) -> bool:
304308
return self.code == Outcome.OK

src/tox/tox_env/python/virtual_env/package/api.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def _ensure_meta_present(self) -> None:
122122
return # pragma: no cover
123123
self.ensure_setup()
124124
dist_info = self.prepare_metadata_for_build_wheel(self.meta_folder).metadata
125-
self._distribution_meta = Distribution.at(str(dist_info))
125+
self._distribution_meta = Distribution.at(str(dist_info)) # type: ignore[no-untyped-call]
126126

127127
@abstractmethod
128128
def _build_artifact(self) -> Path:
@@ -163,18 +163,16 @@ def discover_package_dependencies( # type: ignore[no-any-unimported]
163163
):
164164
if marker_key.value == "extra" and op.value == "==": # pragma: no branch
165165
extra = marker_value.value
166+
del markers[_at]
167+
_at -= 1
168+
if _at > 0 and (isinstance(markers[_at], str) and markers[_at] in ("and", "or")):
169+
del markers[_at]
170+
if len(markers) == 0:
171+
req.marker = None
166172
break
167173
# continue only if this extra should be included
168174
if not (extra is None or extra in extras):
169175
continue
170-
# delete the extra marker if present
171-
if _at is not None:
172-
del markers[_at]
173-
_at -= 1
174-
if _at > 0 and (isinstance(markers[_at], str) and markers[_at] in ("and", "or")):
175-
del markers[_at]
176-
if len(markers) == 0:
177-
req.marker = None
178176
result.append(req)
179177
return result
180178

tests/config/loader/ini/replace/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,6 @@ def example(conf: str, pos_args: Optional[List[str]] = None) -> str:
2727
tox_ini = ToxIni(tox_ini_file)
2828
config = Config(tox_ini, overrides=[], root=tmp_path, pos_args=pos_args, work_dir=tmp_path)
2929
loader = config.get_env("py").loaders[0]
30-
return loader.load(key="env", of_type=str, conf=config, env_name="a")
30+
return loader.load(key="env", of_type=str, conf=config, env_name="a", chain=[])
3131

3232
return example # noqa
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import os
2+
3+
from tests.config.loader.ini.replace.conftest import ReplaceOne
4+
5+
6+
def test_replace_os_sep(replace_one: ReplaceOne) -> None:
7+
result = replace_one("{/}")
8+
assert result == os.sep

tests/config/loader/ini/replace/test_replace_tox_env.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,15 @@ def test_replace_from_tox_section_missing_section(tox_ini_conf: ToxIniCreator) -
119119
assert conf_a["x"] == "{[magic]a}"
120120

121121

122+
def test_replace_circular(tox_ini_conf: ToxIniCreator) -> None:
123+
conf_a = tox_ini_conf("[testenv:a]\nx = {y}\ny = {x}").get_env("a")
124+
conf_a.add_config(keys="x", of_type=str, default="o", desc="o")
125+
conf_a.add_config(keys="y", of_type=str, default="n", desc="n")
126+
with pytest.raises(HandledError) as exc:
127+
assert conf_a["x"]
128+
assert "circular chain detected x, y" in str(exc.value)
129+
130+
122131
def test_replace_from_tox_section_missing_value(tox_ini_conf: ToxIniCreator) -> None:
123132
conf_a = tox_ini_conf("[testenv:e]\nx = {[m]a}\n[m]").get_env("e")
124133
conf_a.add_config(keys="x", of_type=str, default="o", desc="d")

tests/config/loader/ini/test_ini_loader.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ def test_ini_loader_has_no_section(mk_ini_conf: Callable[[str], ConfigParser]) -
2929

3030
def test_ini_loader_raw(mk_ini_conf: Callable[[str], ConfigParser]) -> None:
3131
loader = IniLoader("tox", mk_ini_conf("[tox]\na=b"), [])
32-
result = loader.load(key="a", of_type=str, conf=None, env_name=None)
32+
result = loader.load(key="a", of_type=str, conf=None, env_name=None, chain=[])
3333
assert result == "b"
3434

3535

3636
@pytest.mark.parametrize("sep", ["\n", "\r\n"])
3737
def test_ini_loader_raw_strip_escaped_newline(mk_ini_conf: Callable[[str], ConfigParser], sep: str) -> None:
3838
loader = IniLoader("tox", mk_ini_conf(f"[tox]{sep}a=b\\{sep} c"), [])
39-
result = loader.load(key="a", of_type=str, conf=None, env_name=None)
39+
result = loader.load(key="a", of_type=str, conf=None, env_name=None, chain=[])
4040
assert result == "bc"

tests/config/loader/test_memory_loader.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def test_memory_loader_repr() -> None:
1616
def test_memory_loader_override() -> None:
1717
loader = MemoryLoader(a=1)
1818
loader.overrides["a"] = Override("a=2")
19-
loaded = loader.load("a", of_type=int, conf=None, env_name=None)
19+
loaded = loader.load("a", of_type=int, conf=None, env_name=None, chain=[])
2020
assert loaded == 2
2121

2222

@@ -38,7 +38,7 @@ def test_memory_loader_override() -> None:
3838
)
3939
def test_memory_loader(value: Any, of_type: Type[Any]) -> None:
4040
loader = MemoryLoader(**{"a": value})
41-
loaded = loader.load("a", of_type=of_type, conf=None, env_name=None) # noqa
41+
loaded = loader.load("a", of_type=of_type, conf=None, env_name=None, chain=[]) # noqa
4242
assert loaded == value
4343

4444

0 commit comments

Comments
 (0)