Repository: tox-dev/tox

Issue:
tox4: requirements file in config rendered incorrectly
It's rendered as one multi-line string rather than a multiline config.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/tox-dev/tox/pull/1821?src=pr&el=h1) Report
> Merging [#1821](https://codecov.io/gh/tox-dev/tox/pull/1821?src=pr&el=desc) (67c4649) into [rewrite](https://codecov.io/gh/tox-dev/tox/commit/41c7eb4af57f5917926e0e0b348e1bc4b8c3bb70?el=desc) (41c7eb4) will **increase** coverage by `0.15%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/tox-dev/tox/pull/1821/graphs/tree.svg?width=650&height=150&src=pr&token=DYodAwDCZ5)](https://codecov.io/gh/tox-dev/tox/pull/1821?src=pr&el=tree)

```diff
@@             Coverage Diff             @@
##           rewrite    #1821      +/-   ##
===========================================
+ Coverage    99.76%   99.91%   +0.15%     
===========================================
  Files          133      132       -1     
  Lines         7125     7052      -73     
  Branches       712      689      -23     
===========================================
- Hits          7108     7046      -62     
+ Misses           6        5       -1     
+ Partials        11        1      -10     
```

| Flag | Coverage Δ | |
|---|---|---|
| tests | `99.91% <100.00%> (+0.15%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/tox-dev/tox/pull/1821?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/tox/config/loader/ini/replace.py](https://codecov.io/gh/tox-dev/tox/pull/1821/diff?src=pr&el=tree#diff-c3JjL3RveC9jb25maWcvbG9hZGVyL2luaS9yZXBsYWNlLnB5) | `100.00% <100.00%> (ø)` | |
| [src/tox/config/loader/stringify.py](https://codecov.io/gh/tox-dev/tox/pull/1821/diff?src=pr&el=tree#diff-c3JjL3RveC9jb25maWcvbG9hZGVyL3N0cmluZ2lmeS5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/tox/config/sets.py](https://codecov.io/gh/tox-dev/tox/pull/1821/diff?src=pr&el=tree#diff-c3JjL3RveC9jb25maWcvc2V0cy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/tox/session/cmd/show\_config.py](https://codecov.io/gh/tox-dev/tox/pull/1821/diff?src=pr&el=tree#diff-c3JjL3RveC9zZXNzaW9uL2NtZC9zaG93X2NvbmZpZy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/tox/tox\_env/python/api.py](https://codecov.io/gh/tox-dev/tox/pull/1821/diff?src=pr&el=tree#diff-c3JjL3RveC90b3hfZW52L3B5dGhvbi9hcGkucHk=) | `100.00% <100.00%> (+0.68%)` | :arrow_up: |
| [src/tox/tox\_env/runner.py](https://codecov.io/gh/tox-dev/tox/pull/1821/diff?src=pr&el=tree#diff-c3JjL3RveC90b3hfZW52L3J1bm5lci5weQ==) | `100.00% <100.00%> (ø)` | |
| [.../config/loader/ini/replace/test\_replace\_tox\_env.py](https://codecov.io/gh/tox-dev/tox/pull/1821/diff?src=pr&el=tree#diff-dGVzdHMvY29uZmlnL2xvYWRlci9pbmkvcmVwbGFjZS90ZXN0X3JlcGxhY2VfdG94X2Vudi5weQ==) | `100.00% <100.00%> (ø)` | |
| [tests/tox\_env/python/test\_req\_file.py](https://codecov.io/gh/tox-dev/tox/pull/1821/diff?src=pr&el=tree#diff-dGVzdHMvdG94X2Vudi9weXRob24vdGVzdF9yZXFfZmlsZS5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/tox/tox\_env/python/req\_file.py](https://codecov.io/gh/tox-dev/tox/pull/1821/diff?src=pr&el=tree#diff-c3JjL3RveC90b3hfZW52L3B5dGhvbi9yZXFfZmlsZS5weQ==) | `98.88% <0.00%> (-1.12%)` | :arrow_down: |
| [src/tox/tox\_env/api.py](https://codecov.io/gh/tox-dev/tox/pull/1821/diff?src=pr&el=tree#diff-c3JjL3RveC90b3hfZW52L2FwaS5weQ==) | `100.00% <0.00%> (ø)` | |
| ... and [6 more](https://codecov.io/gh/tox-dev/tox/pull/1821/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/tox-dev/tox/pull/1821?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/tox-dev/tox/pull/1821?src=pr&el=footer). Last update [41c7eb4...9f20f71](https://codecov.io/gh/tox-dev/tox/pull/1821?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/docs/changelog/1820.bugfix.rst b/docs/changelog/1820.bugfix.rst
new file mode 100644
index 00000000..108327ab
--- /dev/null
+++ b/docs/changelog/1820.bugfix.rst
@@ -0,0 +1,1 @@
+Fix ``RequirementsFile`` from tox is rendered incorrectly in ``config`` command - by :user:`gaborbernat`.
diff --git a/docs/changelog/1821.bugfix.rst b/docs/changelog/1821.bugfix.rst
new file mode 100644
index 00000000..6ee21a21
--- /dev/null
+++ b/docs/changelog/1821.bugfix.rst
@@ -0,0 +1,2 @@
+Fix a bug in the configuration system where referring to the same named key in another env/section causes circular
+dependency error - by :user:`gaborbernat`.
diff --git a/docs/changelog/1821.feature.rst b/docs/changelog/1821.feature.rst
new file mode 100644
index 00000000..8fdc0345
--- /dev/null
+++ b/docs/changelog/1821.feature.rst
@@ -0,0 +1,2 @@
+Colorize the ``config`` command: section headers are yellow, keys are green, values remained white, exceptions are light
+red and comments are cyan - by :user:`gaborbernat`.
diff --git a/src/tox/config/loader/ini/replace.py b/src/tox/config/loader/ini/replace.py
index f590b3dd..b0d4c445 100644
--- a/src/tox/config/loader/ini/replace.py
+++ b/src/tox/config/loader/ini/replace.py
@@ -110,8 +110,8 @@ def replace_reference(
         settings = match.groupdict()
 
         key = settings["key"]
-        if settings["section"] is None and settings["full_env"] == BASE_TEST_ENV:
-            settings["section"] = BASE_TEST_ENV
+        if settings["section"] is None and settings["full_env"]:
+            settings["section"] = settings["full_env"]
 
         exception: Optional[Exception] = None
         try:
@@ -152,20 +152,21 @@ def _config_value_sources(
         else:
             raise KeyError(f"missing tox environment with name {env}")
 
-    # if we have a section name specified take only from there
-    if section is not None:
-        # special handle the core section under name tox
-        if section == CORE_PREFIX:
-            yield conf.core  # try via registered configs
-        value = loader.get_section(section)  # fallback to section
-        if value is not None:
-            yield value
+    if section is None:
+        # if no section specified perhaps it's an unregistered config:
+        # 1. try first from core conf
+        yield conf.core
+        # 2. and then fallback to our own environment
+        if current_env is not None:
+            yield conf.get_env(current_env)
         return
 
-    # otherwise try first from core conf, and fallback to our own environment
-    yield conf.core
-    if current_env is not None:
-        yield conf.get_env(current_env)
+    # if there's a section, special handle the core section under name tox
+    if section == CORE_PREFIX:
+        yield conf.core  # try via registered configs
+    value = loader.get_section(section)  # fallback to section
+    if value is not None:
+        yield value
 
 
 def replace_pos_args(args: List[str], pos_args: Optional[Sequence[str]]) -> str:
diff --git a/src/tox/config/loader/stringify.py b/src/tox/config/loader/stringify.py
index da432252..0305534e 100644
--- a/src/tox/config/loader/stringify.py
+++ b/src/tox/config/loader/stringify.py
@@ -4,6 +4,7 @@ from typing import Any, Mapping, Sequence, Set, Tuple
 
 from tox.config.set_env import SetEnv
 from tox.config.types import Command, EnvList
+from tox.tox_env.python.req_file import RequirementsFile
 
 
 def stringify(value: Any) -> Tuple[str, bool]:
@@ -29,6 +30,8 @@ def stringify(value: Any) -> Tuple[str, bool]:
         return value.shell, True
     if isinstance(value, SetEnv):
         return stringify({k: value.load(k) for k in sorted(list(value))})
+    if isinstance(value, RequirementsFile):
+        return stringify(value.validate_and_expand())
     return str(value), False
 
 
diff --git a/src/tox/config/sets.py b/src/tox/config/sets.py
index d8726d34..7d5d062e 100644
--- a/src/tox/config/sets.py
+++ b/src/tox/config/sets.py
@@ -87,9 +87,11 @@ class ConfigSet:
         config_definition = self._defined[item]
         if chain is None:
             chain = []
-        if item in chain:
-            raise ValueError(f"circular chain detected {', '.join(chain[chain.index(item):])}")
-        chain.append(item)
+        env_name = "tox" if self._name is None else f"testenv:{self._name}"
+        key = f"{env_name}.{item}"
+        if key in chain:
+            raise ValueError(f"circular chain detected {', '.join(chain[chain.index(key):])}")
+        chain.append(key)
         return config_definition(self._conf, item, self.loaders, chain)
 
     def __repr__(self) -> str:
diff --git a/src/tox/session/cmd/show_config.py b/src/tox/session/cmd/show_config.py
index d31606b7..18978df3 100644
--- a/src/tox/session/cmd/show_config.py
+++ b/src/tox/session/cmd/show_config.py
@@ -5,6 +5,8 @@ Show materialized configuration of tox environments.
 from textwrap import indent
 from typing import Iterable, List, Set
 
+from colorama import Fore
+
 from tox.config.cli.parser import ToxParser
 from tox.config.loader.stringify import stringify
 from tox.config.sets import ConfigSet
@@ -29,6 +31,7 @@ def tox_add_option(parser: ToxParser) -> None:
 
 
 def show_config(state: State) -> int:
+    is_colored = state.options.is_colored
     keys: List[str] = state.options.list_keys_only
     is_first = True
     selected = state.options.env
@@ -39,10 +42,10 @@ def show_config(state: State) -> int:
             is_first = False
         else:
             print("")
-        print(f"[testenv:{tox_env.conf.name}]")
+        print_section_header(is_colored, f"[testenv:{tox_env.conf.name}]")
         if not keys:
-            print(f"type = {type(tox_env).__name__}")
-        print_conf(tox_env.conf, keys)
+            print_key_value(is_colored, "type", type(tox_env).__name__)
+        print_conf(is_colored, tox_env.conf, keys)
 
     envs = list(state.env_list(everything=True))
     done_pkg_envs: Set[str] = set()
@@ -63,27 +66,48 @@ def show_config(state: State) -> int:
     # environments may define core configuration flags, so we must exhaust first the environments to tell the core part
     if selected.all or state.options.show_core:
         print("")
-        print("[tox]")
-        print_conf(state.conf.core, keys)
+        print_section_header(is_colored, "[tox]")
+        print_conf(is_colored, state.conf.core, keys)
     return 0
 
 
-def print_conf(conf: ConfigSet, keys: Iterable[str]) -> None:
+def _colored(is_colored: bool, color: int, msg: str) -> str:
+    return f"{color}{msg}{Fore.RESET}" if is_colored else msg
+
+
+def print_section_header(is_colored: bool, name: str) -> None:
+    print(_colored(is_colored, Fore.YELLOW, name))
+
+
+def print_comment(is_colored: bool, comment: str) -> None:
+    print(_colored(is_colored, Fore.CYAN, comment))
+
+
+def print_key_value(is_colored: bool, key: str, value: str, multi_line: bool = False) -> None:
+    print(_colored(is_colored, Fore.GREEN, key), end="")
+    print(" =", end="")
+    if multi_line:
+        print("")
+        value_str = indent(value, prefix="  ")
+    else:
+        print(" ", end="")
+        value_str = value
+    print(value_str)
+
+
+def print_conf(is_colored: bool, conf: ConfigSet, keys: Iterable[str]) -> None:
     for key in keys if keys else conf:
         if key not in conf:
             continue
         try:
             value = conf[key]
         except Exception as exception:  # because e.g. the interpreter cannot be found
-            as_str, multi_line = f"# Exception: {exception!r}", False
+            as_str, multi_line = _colored(is_colored, Fore.LIGHTRED_EX, f"# Exception: {exception!r}"), False
         else:
             as_str, multi_line = stringify(value)
         if multi_line and "\n" not in as_str:
             multi_line = False
-        if multi_line and as_str.strip():
-            print(f"{key} =\n{indent(as_str, prefix='  ')}")
-        else:
-            print(f"{key} ={' ' if as_str else ''}{as_str}")
+        print_key_value(is_colored, key, as_str, multi_line=multi_line)
     unused = conf.unused()
     if unused and not keys:
-        print(f"# !!! unused: {', '.join(unused)}")
+        print_comment(is_colored, f"# !!! unused: {', '.join(unused)}")
diff --git a/src/tox/tox_env/python/api.py b/src/tox/tox_env/python/api.py
index fa5297a9..f2950dd7 100644
--- a/src/tox/tox_env/python/api.py
+++ b/src/tox/tox_env/python/api.py
@@ -79,6 +79,7 @@ class Python(ToxEnv, ABC):
         super().__init__(conf, core, options, journal, log_handler)
 
     def register_config(self) -> None:
+        super().register_config()
         self.conf.add_config(
             keys=["base_python", "basepython"],
             of_type=List[str],
@@ -100,7 +101,6 @@ class Python(ToxEnv, ABC):
             desc="python executable from within the tox environment",
             value=lambda: self.env_python(),
         )
-        super().register_config()
 
     def default_pass_env(self) -> List[str]:
         env = super().default_pass_env()
diff --git a/src/tox/tox_env/runner.py b/src/tox/tox_env/runner.py
index 904d770c..1a44d7c0 100644
--- a/src/tox/tox_env/runner.py
+++ b/src/tox/tox_env/runner.py
@@ -24,7 +24,6 @@ class RunToxEnv(ToxEnv, ABC):
         super().__init__(conf, core, options, journal, log_handler)
 
     def register_config(self) -> None:
-        super().register_config()
         self.conf.add_config(
             keys=["description"],
             of_type=str,
@@ -32,17 +31,24 @@ class RunToxEnv(ToxEnv, ABC):
             desc="description attached to the tox environment",
         )
         self.conf.add_config(
-            keys=["commands"],
-            of_type=List[Command],
-            default=[],
-            desc="the commands to be called for testing",
+            "depends",
+            of_type=EnvList,
+            desc="tox environments that this environment depends on (must be run after those)",
+            default=EnvList([]),
         )
+        super().register_config()
         self.conf.add_config(
             keys=["commands_pre"],
             of_type=List[Command],
             default=[],
             desc="the commands to be called before testing",
         )
+        self.conf.add_config(
+            keys=["commands"],
+            of_type=List[Command],
+            default=[],
+            desc="the commands to be called for testing",
+        )
         self.conf.add_config(
             keys=["commands_post"],
             of_type=List[Command],
@@ -55,12 +61,6 @@ class RunToxEnv(ToxEnv, ABC):
             default=lambda conf, name: cast(Path, conf.core["tox_root"]),
             desc="Change to this working directory when executing the test command.",
         )
-        self.conf.add_config(
-            "depends",
-            of_type=EnvList,
-            desc="tox environments that this environment depends on (must be run after those)",
-            default=EnvList([]),
-        )
         self.has_package = self.add_package_conf()
 
     def add_package_conf(self) -> bool:


Test patch:
diff --git a/tests/config/loader/ini/replace/test_replace_tox_env.py b/tests/config/loader/ini/replace/test_replace_tox_env.py
index ce4eb38f..7949d65b 100644
--- a/tests/config/loader/ini/replace/test_replace_tox_env.py
+++ b/tests/config/loader/ini/replace/test_replace_tox_env.py
@@ -125,7 +125,7 @@ def test_replace_circular(tox_ini_conf: ToxIniCreator) -> None:
     conf_a.add_config(keys="y", of_type=str, default="n", desc="n")
     with pytest.raises(HandledError) as exc:
         assert conf_a["x"]
-    assert "circular chain detected x, y" in str(exc.value)
+    assert "circular chain detected testenv:a.x, testenv:a.y" in str(exc.value)
 
 
 def test_replace_from_tox_section_missing_value(tox_ini_conf: ToxIniCreator) -> None:
@@ -145,3 +145,9 @@ def test_replace_from_tox_section_registered(tox_ini_conf: ToxIniCreator, tmp_pa
     conf_a = tox_ini_conf("[testenv:a]\nx = {[tox]tox_root}").get_env("a")
     conf_a.add_config(keys="x", of_type=Path, default=Path.cwd() / "magic", desc="d")
     assert conf_a["x"] == (tmp_path / "c")
+
+
+def test_replace_from_tox_other_tox_section_same_name(tox_ini_conf: ToxIniCreator, tmp_path: Path) -> None:
+    conf_a = tox_ini_conf("[testenv:a]\nx={[testenv:b]c}\nc=d\n[testenv:b]}").get_env("a")
+    conf_a.add_config(keys="x", of_type=str, default="", desc="d")
+    assert conf_a["x"] == "{[testenv:b]c}"
diff --git a/tests/tox_env/python/test_req_file.py b/tests/tox_env/python/test_req_file.py
index 50829785..40dba1f6 100644
--- a/tests/tox_env/python/test_req_file.py
+++ b/tests/tox_env/python/test_req_file.py
@@ -67,6 +67,7 @@ from tox.tox_env.python.req_file import RequirementsFile
 )
 def test_requirements_txt(tmp_path: Path, req: str, key: str) -> None:
     req_file = RequirementsFile(req, root=tmp_path)
+    assert req == str(req_file)
     expanded = req_file.validate_and_expand()
     if key:
         assert len(expanded) == 1