Repository: tox-dev/tox

Issue:
tox4: pyproject.toml legacy support
👍🏻 

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/tox-dev/tox/pull/1927?src=pr&el=h1) Report
> Merging [#1927](https://codecov.io/gh/tox-dev/tox/pull/1927?src=pr&el=desc) (30820fb) into [rewrite](https://codecov.io/gh/tox-dev/tox/commit/459dd90c5d1ff0254e76cfe546100647b6dd5e27?el=desc) (459dd90) will **decrease** coverage by `0.07%`.
> The diff coverage is `89.01%`.

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

```diff
@@             Coverage Diff             @@
##           rewrite    #1927      +/-   ##
===========================================
- Coverage    99.81%   99.73%   -0.08%     
===========================================
  Files          135      138       +3     
  Lines         7733     7684      -49     
  Branches       784      764      -20     
===========================================
- Hits          7719     7664      -55     
- Misses           3       15      +12     
+ Partials        11        5       -6     
```

| Flag | Coverage Δ | |
|---|---|---|
| tests | `99.73% <89.01%> (-0.08%)` | :arrow_down: |

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/1927?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/tox/config/source/toml.py](https://codecov.io/gh/tox-dev/tox/pull/1927/diff?src=pr&el=tree#diff-c3JjL3RveC9jb25maWcvc291cmNlL3RvbWwucHk=) | `46.66% <46.66%> (ø)` | |
| [src/tox/config/source/setup\_cfg.py](https://codecov.io/gh/tox-dev/tox/pull/1927/diff?src=pr&el=tree#diff-c3JjL3RveC9jb25maWcvc291cmNlL3NldHVwX2NmZy5weQ==) | `80.00% <80.00%> (ø)` | |
| [src/tox/config/discover.py](https://codecov.io/gh/tox-dev/tox/pull/1927/diff?src=pr&el=tree#diff-c3JjL3RveC9jb25maWcvZGlzY292ZXIucHk=) | `81.81% <81.81%> (ø)` | |
| [src/tox/config/cli/ini.py](https://codecov.io/gh/tox-dev/tox/pull/1927/diff?src=pr&el=tree#diff-c3JjL3RveC9jb25maWcvY2xpL2luaS5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/tox/config/loader/ini/\_\_init\_\_.py](https://codecov.io/gh/tox-dev/tox/pull/1927/diff?src=pr&el=tree#diff-c3JjL3RveC9jb25maWcvbG9hZGVyL2luaS9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/tox/config/loader/ini/replace.py](https://codecov.io/gh/tox-dev/tox/pull/1927/diff?src=pr&el=tree#diff-c3JjL3RveC9jb25maWcvbG9hZGVyL2luaS9yZXBsYWNlLnB5) | `100.00% <100.00%> (ø)` | |
| [src/tox/config/source/\_\_init\_\_.py](https://codecov.io/gh/tox-dev/tox/pull/1927/diff?src=pr&el=tree#diff-c3JjL3RveC9jb25maWcvc291cmNlL19faW5pdF9fLnB5) | `100.00% <100.00%> (ø)` | |
| [src/tox/config/source/ini.py](https://codecov.io/gh/tox-dev/tox/pull/1927/diff?src=pr&el=tree#diff-c3JjL3RveC9jb25maWcvc291cmNlL2luaS5weQ==) | `100.00% <100.00%> (ø)` | |
| [src/tox/config/source/tox\_ini.py](https://codecov.io/gh/tox-dev/tox/pull/1927/diff?src=pr&el=tree#diff-c3JjL3RveC9jb25maWcvc291cmNlL3RveF9pbmkucHk=) | `100.00% <100.00%> (ø)` | |
| [src/tox/run.py](https://codecov.io/gh/tox-dev/tox/pull/1927/diff?src=pr&el=tree#diff-c3JjL3RveC9ydW4ucHk=) | `100.00% <100.00%> (ø)` | |
| ... and [20 more](https://codecov.io/gh/tox-dev/tox/pull/1927/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/tox-dev/tox/pull/1927?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/1927?src=pr&el=footer). Last update [459dd90...30820fb](https://codecov.io/gh/tox-dev/tox/pull/1927?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 4931ef04..6aa4f6c3 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -48,7 +48,7 @@ repos:
         additional_dependencies:
           - black==20.8b1
   - repo: https://github.com/pre-commit/pygrep-hooks
-    rev: v1.7.1
+    rev: v1.8.0
     hooks:
       - id: rst-backticks
   - repo: local
diff --git a/docs/changelog/1836.feature.rst b/docs/changelog/1836.feature.rst
new file mode 100644
index 00000000..1379979d
--- /dev/null
+++ b/docs/changelog/1836.feature.rst
@@ -0,0 +1,1 @@
+Add support for configuration taken from the ``setup.cfg`` file -by :user:`gaborbernat`.
diff --git a/docs/changelog/1837.feature.rst b/docs/changelog/1837.feature.rst
new file mode 100644
index 00000000..c351dbeb
--- /dev/null
+++ b/docs/changelog/1837.feature.rst
@@ -0,0 +1,2 @@
+Add support for configuration taken from the ``pyproject.toml`` file, ``tox`` section ``legacy_tox_ini`` key - by
+:user:`gaborbernat`.
diff --git a/src/tox/config/cli/ini.py b/src/tox/config/cli/ini.py
index b6f1cabc..4067d92d 100644
--- a/src/tox/config/cli/ini.py
+++ b/src/tox/config/cli/ini.py
@@ -36,7 +36,7 @@ class IniConfig:
                 with self.config_file.open() as file_handler:
                     parser.read_file(file_handler)
                 self.has_tox_section = parser.has_section("tox")
-                self.ini = IniLoader("tox", parser, overrides=[]) if self.has_tox_section else None
+                self.ini = IniLoader("tox", parser, overrides=[], core_prefix="") if self.has_tox_section else None
             except Exception as exception:
                 logging.error("failed to read config file %s because %r", config_file, exception)
                 self.has_config_file = None
diff --git a/src/tox/config/loader/ini/__init__.py b/src/tox/config/loader/ini/__init__.py
index 55361dfa..b9e6d243 100644
--- a/src/tox/config/loader/ini/__init__.py
+++ b/src/tox/config/loader/ini/__init__.py
@@ -3,28 +3,31 @@ import re
 from concurrent.futures import Future
 from configparser import ConfigParser, SectionProxy
 from contextlib import contextmanager
-from typing import Generator, List, Optional, Set, Type, TypeVar
+from typing import TYPE_CHECKING, Generator, List, Optional, Set, Type, TypeVar
 
 from tox.config.loader.api import Loader, Override
 from tox.config.loader.ini.factor import filter_for_env
 from tox.config.loader.ini.replace import replace
 from tox.config.loader.str_convert import StrConvert
-from tox.config.main import Config
 from tox.config.set_env import SetEnv
 from tox.report import HandledError
 
+if TYPE_CHECKING:
+    from tox.config.main import Config
+
 V = TypeVar("V")
 
 
 class IniLoader(StrConvert, Loader[str]):
     """Load configuration from an ini section (ini file is a string to string dictionary)"""
 
-    def __init__(self, section: str, parser: ConfigParser, overrides: List[Override]) -> None:
+    def __init__(self, section: str, parser: ConfigParser, overrides: List[Override], core_prefix: str) -> None:
         self._section: SectionProxy = parser[section]
         self._parser = parser
+        self.core_prefix = core_prefix
         super().__init__(overrides)
 
-    def load_raw(self, key: str, conf: Optional[Config], env_name: Optional[str]) -> str:
+    def load_raw(self, key: str, conf: Optional["Config"], env_name: Optional[str]) -> str:
         value = self._section[key]
         collapsed_newlines = value.replace("\r", "").replace("\\\n", "")  # collapse explicit new-line escape
         # strip comments
diff --git a/src/tox/config/loader/ini/replace.py b/src/tox/config/loader/ini/replace.py
index b0d4c445..91d3c032 100644
--- a/src/tox/config/loader/ini/replace.py
+++ b/src/tox/config/loader/ini/replace.py
@@ -8,22 +8,21 @@ from configparser import SectionProxy
 from typing import TYPE_CHECKING, Iterator, List, Optional, Sequence, Tuple, Union
 
 from tox.config.loader.stringify import stringify
-from tox.config.main import Config
 from tox.config.set_env import SetEnv
 from tox.config.sets import ConfigSet
 from tox.execute.request import shell_cmd
 
 if TYPE_CHECKING:
     from tox.config.loader.ini import IniLoader
+    from tox.config.main import Config
 
-CORE_PREFIX = "tox"
 BASE_TEST_ENV = "testenv"
 
 # split alongside :, unless it's esscaped, or it's preceded by a single capital letter (Windows drive letter in paths)
 ARGS_GROUP = re.compile(r"(?<!\\\\|:[A-Z]):")
 
 
-def replace(conf: Config, name: Optional[str], loader: "IniLoader", value: str, chain: List[str]) -> str:
+def replace(conf: "Config", name: Optional[str], loader: "IniLoader", value: str, chain: List[str]) -> str:
     # perform all non-escaped replaces
     start, end = 0, 0
     while True:
@@ -71,7 +70,7 @@ def find_replace_part(value: str, start: int, end: int) -> Tuple[int, int, bool]
 
 
 def _replace_match(
-    conf: Config, current_env: Optional[str], loader: "IniLoader", value: str, chain: List[str]
+    conf: "Config", current_env: Optional[str], loader: "IniLoader", value: str, chain: List[str]
 ) -> Optional[str]:
     of_type, *args = ARGS_GROUP.split(value)
     if of_type == "/":
@@ -98,7 +97,7 @@ _REPLACE_REF = re.compile(
 
 
 def replace_reference(
-    conf: Config,
+    conf: "Config",
     current_env: Optional[str],
     loader: "IniLoader",
     value: str,
@@ -141,7 +140,7 @@ def _config_value_sources(
     env: Optional[str],
     section: Optional[str],
     current_env: Optional[str],
-    conf: Config,
+    conf: "Config",
     loader: "IniLoader",
 ) -> Iterator[Union[SectionProxy, ConfigSet]]:
     # if we have an env name specified take only from there
@@ -162,7 +161,7 @@ def _config_value_sources(
         return
 
     # if there's a section, special handle the core section under name tox
-    if section == CORE_PREFIX:
+    if section == loader.core_prefix:
         yield conf.core  # try via registered configs
     value = loader.get_section(section)  # fallback to section
     if value is not None:
@@ -180,7 +179,7 @@ def replace_pos_args(args: List[str], pos_args: Optional[Sequence[str]]) -> str:
     return replace_value
 
 
-def replace_env(conf: Config, env_name: Optional[str], args: List[str], chain: List[str]) -> str:
+def replace_env(conf: "Config", env_name: Optional[str], args: List[str], chain: List[str]) -> str:
     key = args[0]
     new_key = f"env:{key}"
 
@@ -209,7 +208,6 @@ def replace_tty(args: List[str]) -> str:
 
 
 __all__ = (
-    "CORE_PREFIX",
     "BASE_TEST_ENV",
     "replace",
     "find_replace_part",
diff --git a/src/tox/config/main.py b/src/tox/config/main.py
index 150002c9..4ef31c5b 100644
--- a/src/tox/config/main.py
+++ b/src/tox/config/main.py
@@ -1,11 +1,14 @@
 from collections import OrderedDict, defaultdict
 from pathlib import Path
-from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence
+from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Sequence
 
 from tox.config.loader.api import Loader, Override, OverrideMap
-from tox.config.source import Source
 
 from .sets import CoreConfigSet, EnvConfigSet
+from .source import Source, discover_source
+
+if TYPE_CHECKING:
+    from .cli.parser import Parsed
 
 
 class Config:
@@ -30,6 +33,22 @@ class Config:
         self._core_set: Optional[CoreConfigSet] = None
         self.register_config_set: Callable[[str, EnvConfigSet], Any] = lambda n, e: None
 
+    @classmethod
+    def make(cls, parsed: "Parsed", pos_args: Optional[Sequence[str]]) -> "Config":
+        """Make a tox configuration object."""
+        source = discover_source(parsed.config_file, parsed.root_dir)
+        # root is the project root, where the configuration file is at
+        # work dir is where we put our own files
+        root: Path = source.path.parent if parsed.root_dir is None else parsed.root_dir
+        work_dir: Path = source.path.parent if parsed.work_dir is None else parsed.work_dir
+        return cls(
+            config_source=source,
+            overrides=parsed.override,
+            pos_args=pos_args,
+            root=root,
+            work_dir=work_dir,
+        )
+
     @property
     def core(self) -> CoreConfigSet:
         if self._core_set is not None:
@@ -69,3 +88,7 @@ class Config:
 
     def __contains__(self, item: str) -> bool:
         return any(name for name in self if name == item)
+
+    @property
+    def src_path(self) -> Path:
+        return self._src.path
diff --git a/src/tox/config/source/__init__.py b/src/tox/config/source/__init__.py
index c7552514..be6b18fa 100644
--- a/src/tox/config/source/__init__.py
+++ b/src/tox/config/source/__init__.py
@@ -1,30 +1,7 @@
-"""Sources."""
-from abc import ABC, abstractmethod
-from typing import Any, Iterator
+from .api import Source
+from .discover import discover_source
 
-from tox.config.loader.api import Loader, OverrideMap
-
-from ..sets import ConfigSet, CoreConfigSet
-
-
-class Source(ABC):
-    """
-    Source is able to return a configuration value (for either the core or per environment source).
-    """
-
-    @abstractmethod
-    def get_core(self, override_map: OverrideMap) -> Iterator[Loader[Any]]:  # noqa: U100
-        """Return the core loader from this source."""
-        raise NotImplementedError
-
-    @abstractmethod
-    def get_env_loaders(
-        self, env_name: str, override_map: OverrideMap, package: bool, conf: ConfigSet  # noqa: U100
-    ) -> Iterator[Loader[Any]]:
-        """Return the load for this environment."""
-        raise NotImplementedError
-
-    @abstractmethod
-    def envs(self, core_conf: "CoreConfigSet") -> Iterator[str]:  # noqa: U100
-        """Return a list of environments defined within this source"""
-        raise NotImplementedError
+__all__ = (
+    "Source",
+    "discover_source",
+)
diff --git a/src/tox/config/source/api.py b/src/tox/config/source/api.py
new file mode 100644
index 00000000..c2b03bbe
--- /dev/null
+++ b/src/tox/config/source/api.py
@@ -0,0 +1,39 @@
+"""Sources."""
+from abc import ABC, abstractmethod
+from pathlib import Path
+from typing import Any, Iterator
+
+from tox.config.loader.api import Loader, OverrideMap
+
+from ..sets import ConfigSet, CoreConfigSet
+
+
+class Source(ABC):
+    """
+    Source is able to return a configuration value (for either the core or per environment source).
+    """
+
+    FILENAME = ""
+
+    def __init__(self, path: Path) -> None:
+        self.path: Path = path
+
+    @abstractmethod
+    def get_core(self, override_map: OverrideMap) -> Iterator[Loader[Any]]:  # noqa: U100
+        """Return the core loader from this source."""
+        raise NotImplementedError
+
+    @abstractmethod
+    def get_env_loaders(
+        self, env_name: str, override_map: OverrideMap, package: bool, conf: ConfigSet  # noqa: U100
+    ) -> Iterator[Loader[Any]]:
+        """Return the load for this environment."""
+        raise NotImplementedError
+
+    @abstractmethod
+    def envs(self, core_conf: "CoreConfigSet") -> Iterator[str]:  # noqa: U100
+        """Return a list of environments defined within this source"""
+        raise NotImplementedError
+
+
+__all__ = ("Source",)
diff --git a/src/tox/config/source/discover.py b/src/tox/config/source/discover.py
new file mode 100644
index 00000000..320074d2
--- /dev/null
+++ b/src/tox/config/source/discover.py
@@ -0,0 +1,69 @@
+import logging
+from itertools import chain
+from pathlib import Path
+from typing import Optional, Tuple, Type
+
+from tox.report import HandledError
+
+from .api import Source
+from .legacy_toml import LegacyToml
+from .setup_cfg import SetupCfg
+from .tox_ini import ToxIni
+
+SOURCE_TYPES: Tuple[Type[Source], ...] = (ToxIni, SetupCfg, LegacyToml)
+
+
+def discover_source(config_file: Optional[Path], root_dir: Optional[Path]) -> Source:
+    """
+    Discover a source for configuration.
+
+    :param config_file: the file storing the source
+    :param root_dir: the root directory as set by the user (None means not set)
+    :return: the source of the config
+    """
+    if config_file is None:
+        src = _locate_source()
+        if src is None:
+            src = _create_default_source(root_dir)
+    else:
+        src = _load_exact_source(config_file)
+    return src
+
+
+def _locate_source() -> Optional[Source]:
+    folder = Path.cwd()
+    for base in chain([folder], folder.parents):
+        for src_type in SOURCE_TYPES:
+            candidate: Path = base / src_type.FILENAME
+            try:
+                return src_type(candidate)
+            except ValueError:
+                pass
+    return None
+
+
+def _load_exact_source(config_file: Path) -> Source:
+    for src_type in SOURCE_TYPES:  # pragma: no branch # SOURCE_TYPES will never be empty
+        if src_type.FILENAME == config_file.name:
+            try:
+                return src_type(config_file)
+            except ValueError:
+                pass
+    raise HandledError(f"could not recognize config file {config_file}")
+
+
+def _create_default_source(root_dir: Optional[Path]) -> Source:
+    if root_dir is None:  # if set use that
+        empty = Path.cwd()
+        for base in chain([empty], empty.parents):
+            if (base / "pyproject.toml").exists():
+                empty = base
+                break
+    else:  # if not set use where we find pyproject.toml in the tree or cwd
+        empty = root_dir
+    logging.warning(f"No {' or '.join(i.FILENAME for i in SOURCE_TYPES)} found, assuming empty tox.ini at {empty}")
+    src = ToxIni(empty / "tox.ini", content="")
+    return src
+
+
+__all__ = ("discover_source",)
diff --git a/src/tox/config/source/ini.py b/src/tox/config/source/ini.py
new file mode 100644
index 00000000..0008fa24
--- /dev/null
+++ b/src/tox/config/source/ini.py
@@ -0,0 +1,125 @@
+"""Load """
+from abc import ABC
+from configparser import ConfigParser
+from itertools import chain
+from pathlib import Path
+from typing import Dict, Iterator, List, Optional, Set
+
+from tox.config.loader.ini.factor import find_envs
+
+from ..loader.api import OverrideMap
+from ..loader.ini import IniLoader
+from ..loader.ini.replace import BASE_TEST_ENV
+from ..sets import ConfigSet
+from .api import Source
+
+TEST_ENV_PREFIX = f"{BASE_TEST_ENV}:"
+
+
+class IniSource(Source, ABC):
+    """Configuration sourced from a ini file (such as tox.ini)"""
+
+    CORE_PREFIX = "tox"
+
+    def __init__(self, path: Path, content: Optional[str] = None) -> None:
+        super().__init__(path)
+        self._parser = ConfigParser()
+        if content is None:
+            if not path.exists():
+                raise ValueError
+            content = path.read_text()
+        self._parser.read_string(content, str(path))
+        self._envs: Dict[Optional[str], List[IniLoader]] = {}
+
+    def get_core(self, override_map: OverrideMap) -> Iterator[IniLoader]:
+        if None in self._envs:
+            yield from self._envs[None]
+            return
+        core = []
+        if self._parser.has_section(self.CORE_PREFIX):
+            core.append(
+                IniLoader(
+                    section=self.CORE_PREFIX,
+                    parser=self._parser,
+                    overrides=override_map.get(self.CORE_PREFIX, []),
+                    core_prefix=self.CORE_PREFIX,
+                )
+            )
+        self._envs[None] = core
+        yield from core
+
+    def get_env_loaders(
+        self, env_name: str, override_map: OverrideMap, package: bool, conf: ConfigSet
+    ) -> Iterator[IniLoader]:
+        section = f"{TEST_ENV_PREFIX}{env_name}"
+        try:
+            yield from self._envs[section]
+        except KeyError:
+            loaders: List[IniLoader] = []
+            self._envs[section] = loaders
+            loader: Optional[IniLoader] = None
+            if self._parser.has_section(section):
+                loader = IniLoader(
+                    section=section,
+                    parser=self._parser,
+                    overrides=override_map.get(section, []),
+                    core_prefix=self.CORE_PREFIX,
+                )
+                yield loader
+                loaders.append(loader)
+
+            if package is False:
+                conf.add_config(  # base may be override within the testenv:py section
+                    keys="base",
+                    of_type=List[str],
+                    desc="inherit missing keys from these sections",
+                    default=[BASE_TEST_ENV],
+                )
+                for base in conf["base"]:
+                    for section in (base, f"{TEST_ENV_PREFIX}{base}"):
+                        if self._parser.has_section(section):
+                            child = loader
+                            loader = IniLoader(
+                                section=section,
+                                parser=self._parser,
+                                overrides=override_map.get(section, []),
+                                core_prefix=self.CORE_PREFIX,
+                            )
+                            if child is not None:
+                                child.parent = loader
+                            yield loader
+                            loaders.append(loader)
+                            break
+
+    def envs(self, core_config: ConfigSet) -> Iterator[str]:
+        seen = set()
+        for name in self._discover_tox_envs(core_config):
+            if name not in seen:
+                seen.add(name)
+                yield name
+
+    def _discover_tox_envs(self, core_config: ConfigSet) -> Iterator[str]:
+        explicit = list(core_config["env_list"])
+        yield from explicit
+        known_factors = None
+        for section in self._parser.sections():
+            if section.startswith(BASE_TEST_ENV):
+                is_base_section = section == BASE_TEST_ENV
+                name = BASE_TEST_ENV if is_base_section else section[len(TEST_ENV_PREFIX) :]
+                if not is_base_section:
+                    yield name
+                if known_factors is None:
+                    known_factors = set(chain.from_iterable(e.split("-") for e in explicit))
+                yield from self._discover_from_section(section, known_factors)
+
+    def _discover_from_section(self, section: str, known_factors: Set[str]) -> Iterator[str]:
+        for value in self._parser[section].values():
+            for env in find_envs(value):
+                if env not in known_factors:
+                    yield env
+
+    def __repr__(self) -> str:
+        return f"{type(self).__name__}(path={self.path})"
+
+
+__all__ = ("IniSource",)
diff --git a/src/tox/config/source/legacy_toml.py b/src/tox/config/source/legacy_toml.py
new file mode 100644
index 00000000..527a0860
--- /dev/null
+++ b/src/tox/config/source/legacy_toml.py
@@ -0,0 +1,22 @@
+from pathlib import Path
+
+import toml
+
+from .ini import IniSource
+
+
+class LegacyToml(IniSource):
+    FILENAME = "pyproject.toml"
+
+    def __init__(self, path: Path):
+        if not path.exists():
+            raise ValueError
+        toml_content = toml.loads(path.read_text())
+        try:
+            content = toml_content["tool"]["tox"]["legacy_tox_ini"]
+        except KeyError:
+            raise ValueError
+        super().__init__(path, content=content)
+
+
+__all__ = ("LegacyToml",)
diff --git a/src/tox/config/source/setup_cfg.py b/src/tox/config/source/setup_cfg.py
new file mode 100644
index 00000000..4f4c39f0
--- /dev/null
+++ b/src/tox/config/source/setup_cfg.py
@@ -0,0 +1,18 @@
+from pathlib import Path
+
+from .ini import IniSource
+
+
+class SetupCfg(IniSource):
+    """Configuration sourced from a tox.ini file"""
+
+    FILENAME = "setup.cfg"
+    CORE_PREFIX = "tox:tox"
+
+    def __init__(self, path: Path):
+        super().__init__(path)
+        if not self._parser.has_section(self.CORE_PREFIX):
+            raise ValueError
+
+
+__all__ = ("SetupCfg",)
diff --git a/src/tox/config/source/tox_ini.py b/src/tox/config/source/tox_ini.py
index 02f97c01..2f93525a 100644
--- a/src/tox/config/source/tox_ini.py
+++ b/src/tox/config/source/tox_ini.py
@@ -1,119 +1,10 @@
-"""Load """
-from configparser import ConfigParser
-from itertools import chain
-from pathlib import Path
-from typing import Dict, Iterator, List, Optional, Set
+from .ini import IniSource
 
-from tox.config.loader.ini.factor import find_envs
 
-from ..loader.api import OverrideMap
-from ..loader.ini import IniLoader
-from ..loader.ini.replace import BASE_TEST_ENV, CORE_PREFIX
-from ..sets import ConfigSet
-from . import Source
+class ToxIni(IniSource):
+    """Configuration sourced from a tox.ini file"""
 
-TEST_ENV_PREFIX = f"{BASE_TEST_ENV}:"
-
-
-class ToxIni(Source):
-    """Configuration sourced from a ini file (such as tox.ini)"""
-
-    def __init__(self, path: Path) -> None:
-        self._path = path
-        self._parser = ConfigParser()
-        if self._path.exists():  # if does not exist keep it empty
-            with self._path.open() as file_handler:
-                self._parser.read_file(file_handler)
-
-        super().__init__()
-        self._envs: Dict[Optional[str], List[IniLoader]] = {}
-
-    def get_core(self, override_map: OverrideMap) -> Iterator[IniLoader]:
-        if None in self._envs:
-            yield from self._envs[None]
-            return
-        core = []
-        if self._parser.has_section(CORE_PREFIX):
-            core.append(
-                IniLoader(
-                    section=CORE_PREFIX,
-                    parser=self._parser,
-                    overrides=override_map.get(CORE_PREFIX, []),
-                )
-            )
-        self._envs[None] = core
-        yield from core
-
-    def get_env_loaders(
-        self, env_name: str, override_map: OverrideMap, package: bool, conf: ConfigSet
-    ) -> Iterator[IniLoader]:
-        section = f"{TEST_ENV_PREFIX}{env_name}"
-        try:
-            yield from self._envs[section]
-        except KeyError:
-            loaders: List[IniLoader] = []
-            self._envs[section] = loaders
-            loader: Optional[IniLoader] = None
-            if self._parser.has_section(section):
-                loader = IniLoader(
-                    section=section,
-                    parser=self._parser,
-                    overrides=override_map.get(section, []),
-                )
-                yield loader
-                loaders.append(loader)
-
-            if package is False:
-                conf.add_config(  # base may be override within the testenv:py section
-                    keys="base",
-                    of_type=List[str],
-                    desc="inherit missing keys from these sections",
-                    default=[BASE_TEST_ENV],
-                )
-                for base in conf["base"]:
-                    for section in (base, f"{TEST_ENV_PREFIX}{base}"):
-                        if self._parser.has_section(section):
-                            child = loader
-                            loader = IniLoader(
-                                section=section,
-                                parser=self._parser,
-                                overrides=override_map.get(section, []),
-                            )
-                            if child is not None:
-                                child.parent = loader
-                            yield loader
-                            loaders.append(loader)
-                            break
-
-    def envs(self, core_config: ConfigSet) -> Iterator[str]:
-        seen = set()
-        for name in self._discover_tox_envs(core_config):
-            if name not in seen:
-                seen.add(name)
-                yield name
-
-    def _discover_tox_envs(self, core_config: ConfigSet) -> Iterator[str]:
-        explicit = list(core_config["env_list"])
-        yield from explicit
-        known_factors = None
-        for section in self._parser.sections():
-            if section.startswith(BASE_TEST_ENV):
-                is_base_section = section == BASE_TEST_ENV
-                name = BASE_TEST_ENV if is_base_section else section[len(TEST_ENV_PREFIX) :]
-                if not is_base_section:
-                    yield name
-                if known_factors is None:
-                    known_factors = set(chain.from_iterable(e.split("-") for e in explicit))
-                yield from self._discover_from_section(section, known_factors)
-
-    def _discover_from_section(self, section: str, known_factors: Set[str]) -> Iterator[str]:
-        for value in self._parser[section].values():
-            for env in find_envs(value):
-                if env not in known_factors:
-                    yield env
-
-    def __repr__(self) -> str:
-        return f"{type(self).__name__}(path={self._path})"
+    FILENAME = "tox.ini"
 
 
 __all__ = ("ToxIni",)
diff --git a/src/tox/run.py b/src/tox/run.py
index eb2222ee..016cbcee 100644
--- a/src/tox/run.py
+++ b/src/tox/run.py
@@ -3,14 +3,10 @@ import logging
 import os
 import sys
 import time
-from itertools import chain
-from pathlib import Path
 from typing import Optional, Sequence
 
 from tox.config.cli.parse import get_options
-from tox.config.cli.parser import Parsed
 from tox.config.main import Config
-from tox.config.source.tox_ini import ToxIni
 from tox.provision import provision
 from tox.report import HandledError, ToxHandler
 from tox.session.state import State
@@ -55,44 +51,7 @@ def setup_state(args: Sequence[str]) -> State:
     parsed, handlers, pos_args, log_handler = get_options(*args)
     parsed.start = start
     # parse configuration file
-    config = make_config(parsed, pos_args)
+    config = Config.make(parsed, pos_args)
     # build tox environment config objects
     state = State(config, (parsed, handlers), args, log_handler)
     return state
-
-
-def make_config(parsed: Parsed, pos_args: Optional[Sequence[str]]) -> Config:
-    """Make a tox configuration object."""
-    # for now only tox.ini supported, assume empty tox.ini where pyproject.toml or in cwd
-    tox_ini: Optional[Path] = parsed.config_file
-    work_dir: Optional[Path] = parsed.work_dir
-    if work_dir is not None:
-        work_dir = work_dir.absolute()
-    if tox_ini is None:
-        folder = Path.cwd() if work_dir is None else work_dir
-        candidate_names = "tox.ini", "pyproject.toml"
-        for name in candidate_names:
-            for base in chain([folder], folder.parents):
-                candidate: Path = base / name
-                if candidate.exists() and candidate.is_file():
-                    tox_ini = candidate
-                    break
-            if tox_ini is not None:
-                break
-        if tox_ini is None:
-            tox_ini = folder / "tox.ini"
-            logging.warning(f"No {' or '.join(candidate_names)} found, assuming empty tox.ini at {tox_ini}")
-        if work_dir is None:
-            work_dir = tox_ini.parent
-    else:
-        tox_ini = tox_ini.absolute()
-        work_dir = tox_ini.parent if work_dir is None else work_dir
-
-    ini_loader = ToxIni(tox_ini)
-    return Config(
-        config_source=ini_loader,
-        overrides=parsed.override,
-        pos_args=pos_args,
-        root=parsed.root_dir if parsed.root_dir is not None else tox_ini.parent,
-        work_dir=work_dir,
-    )


Test patch:
diff --git a/tests/config/loader/ini/test_ini_loader.py b/tests/config/loader/ini/test_ini_loader.py
index e62ec3da..9aec917a 100644
--- a/tests/config/loader/ini/test_ini_loader.py
+++ b/tests/config/loader/ini/test_ini_loader.py
@@ -8,34 +8,34 @@ from tox.config.loader.ini import IniLoader
 
 
 def test_ini_loader_keys(mk_ini_conf: Callable[[str], ConfigParser]) -> None:
-    loader = IniLoader("tox", mk_ini_conf("\n[tox]\n\na=b\nc=d\n\n"), [])
+    loader = IniLoader("tox", mk_ini_conf("\n[tox]\n\na=b\nc=d\n\n"), [], core_prefix="tox")
     assert loader.found_keys() == {"a", "c"}
 
 
 def test_ini_loader_repr(mk_ini_conf: Callable[[str], ConfigParser]) -> None:
-    loader = IniLoader("tox", mk_ini_conf("\n[tox]\n\na=b\nc=d\n\n"), [Override("tox.a=1")])
+    loader = IniLoader("tox", mk_ini_conf("\n[tox]\n\na=b\nc=d\n\n"), [Override("tox.a=1")], core_prefix="tox")
     assert repr(loader) == "IniLoader(section=<Section: tox>, overrides={'a': Override('tox.a=1')})"
 
 
 def test_ini_loader_has_section(mk_ini_conf: Callable[[str], ConfigParser]) -> None:
-    loader = IniLoader("tox", mk_ini_conf("[magic]\n[tox]\n\na=b\nc=d\n\n"), [])
+    loader = IniLoader("tox", mk_ini_conf("[magic]\n[tox]\n\na=b\nc=d\n\n"), [], core_prefix="tox")
     assert loader.get_section("magic") is not None
 
 
 def test_ini_loader_has_no_section(mk_ini_conf: Callable[[str], ConfigParser]) -> None:
-    loader = IniLoader("tox", mk_ini_conf("[tox]\n\na=b\nc=d\n\n"), [])
+    loader = IniLoader("tox", mk_ini_conf("[tox]\n\na=b\nc=d\n\n"), [], core_prefix="tox")
     assert loader.get_section("magic") is None
 
 
 def test_ini_loader_raw(mk_ini_conf: Callable[[str], ConfigParser]) -> None:
-    loader = IniLoader("tox", mk_ini_conf("[tox]\na=b"), [])
+    loader = IniLoader("tox", mk_ini_conf("[tox]\na=b"), [], core_prefix="tox")
     result = loader.load(key="a", of_type=str, conf=None, env_name=None, chain=[], kwargs={})
     assert result == "b"
 
 
 @pytest.mark.parametrize("sep", ["\n", "\r\n"])
 def test_ini_loader_raw_strip_escaped_newline(mk_ini_conf: Callable[[str], ConfigParser], sep: str) -> None:
-    loader = IniLoader("tox", mk_ini_conf(f"[tox]{sep}a=b\\{sep} c"), [])
+    loader = IniLoader("tox", mk_ini_conf(f"[tox]{sep}a=b\\{sep} c"), [], core_prefix="tox")
     result = loader.load(key="a", of_type=str, conf=None, env_name=None, chain=[], kwargs={})
     assert result == "bc"
 
@@ -53,6 +53,6 @@ def test_ini_loader_raw_strip_escaped_newline(mk_ini_conf: Callable[[str], Confi
     ],
 )
 def test_ini_loader_strip_comments(mk_ini_conf: Callable[[str], ConfigParser], case: str, result: str) -> None:
-    loader = IniLoader("tox", mk_ini_conf(f"[tox]\na={case}"), [])
+    loader = IniLoader("tox", mk_ini_conf(f"[tox]\na={case}"), [], core_prefix="tox")
     outcome = loader.load(key="a", of_type=str, conf=None, env_name=None, chain=[], kwargs={})
     assert outcome == result
diff --git a/tests/config/source/test_discover.py b/tests/config/source/test_discover.py
new file mode 100644
index 00000000..7ec9bee4
--- /dev/null
+++ b/tests/config/source/test_discover.py
@@ -0,0 +1,45 @@
+from pathlib import Path
+
+from tox.pytest import ToxProjectCreator
+
+
+def out_no_src(path: Path) -> str:
+    return (
+        f"ROOT: No tox.ini or setup.cfg or pyproject.toml found, assuming empty tox.ini at {path}\n"
+        f"additional environments:\npy -> [no description]\n"
+    )
+
+
+def test_no_src_cwd(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({})
+    outcome = project.run("l")
+    outcome.assert_success()
+    assert outcome.out == out_no_src(project.path)
+    assert outcome.state.conf.src_path == (project.path / "tox.ini")
+
+
+def test_no_src_has_py_project_toml_above(tox_project: ToxProjectCreator, tmp_path: Path) -> None:
+    (tmp_path / "pyproject.toml").write_text("")
+    project = tox_project({})
+    outcome = project.run("l")
+    outcome.assert_success()
+    assert outcome.out == out_no_src(tmp_path)
+    assert outcome.state.conf.src_path == (tmp_path / "tox.ini")
+
+
+def test_no_src_root_dir(tox_project: ToxProjectCreator, tmp_path: Path) -> None:
+    root = tmp_path / "root"
+    root.mkdir()
+    project = tox_project({})
+    outcome = project.run("l", "--root", str(root))
+    outcome.assert_success()
+    assert outcome.out == out_no_src(root)
+    assert outcome.state.conf.src_path == (root / "tox.ini")
+
+
+def test_bad_src_content(tox_project: ToxProjectCreator, tmp_path: Path) -> None:
+    project = tox_project({})
+
+    outcome = project.run("l", "-c", str(tmp_path / "setup.cfg"))
+    outcome.assert_failed()
+    assert outcome.out == f"ROOT: HandledError| could not recognize config file {tmp_path / 'setup.cfg'}\n"
diff --git a/tests/config/source/test_legacy_toml.py b/tests/config/source/test_legacy_toml.py
new file mode 100644
index 00000000..c0998e54
--- /dev/null
+++ b/tests/config/source/test_legacy_toml.py
@@ -0,0 +1,9 @@
+from tox.pytest import ToxProjectCreator
+
+
+def test_conf_in_legacy_toml(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({"pyproject.toml": '[tool.tox]\nlegacy_tox_ini="""[tox]\nenv_list=\n a\n b\n"""'})
+
+    outcome = project.run("l")
+    outcome.assert_success()
+    assert outcome.out == "default environments:\na -> [no description]\nb -> [no description]\n"
diff --git a/tests/config/source/test_setup_cfg.py b/tests/config/source/test_setup_cfg.py
new file mode 100644
index 00000000..7d625014
--- /dev/null
+++ b/tests/config/source/test_setup_cfg.py
@@ -0,0 +1,16 @@
+from tox.pytest import ToxProjectCreator
+
+
+def test_conf_in_setup_cfg(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({"setup.cfg": "[tox:tox]\nenv_list=\n a\n b"})
+
+    outcome = project.run("l")
+    outcome.assert_success()
+    assert outcome.out == "default environments:\na -> [no description]\nb -> [no description]\n"
+
+
+def test_bad_conf_setup_cfg(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({"setup.cfg": "[tox]\nenv_list=\n a\n b"})
+
+    outcome = project.run("l", "-c", str(project.path / "setup.cfg"))
+    outcome.assert_failed()
diff --git a/tests/conftest.py b/tests/conftest.py
index c3fc9360..12a83ff6 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -12,7 +12,6 @@ from virtualenv import cli_run
 from tox.config.cli.parser import Parsed
 from tox.config.loader.api import Override
 from tox.config.main import Config
-from tox.run import make_config
 from tox.tox_env.python.api import PythonInfo, VersionInfo
 from tox.tox_env.python.virtual_env.api import VirtualEnv
 
@@ -48,7 +47,7 @@ def tox_ini_conf(tmp_path: Path, monkeypatch: MonkeyPatch) -> ToxIniCreator:
         config_file.write_bytes(conf.encode("utf-8"))
         with monkeypatch.context() as context:
             context.chdir(tmp_path)
-        return make_config(
+        return Config.make(
             Parsed(work_dir=dest, override=override or [], config_file=config_file, root_dir=None),
             pos_args=[],
         )
diff --git a/tests/test_run.py b/tests/test_run.py
index ccbf3ee9..e8e70137 100644
--- a/tests/test_run.py
+++ b/tests/test_run.py
@@ -20,13 +20,6 @@ def test_re_raises_on_unexpected_exit(mocker: MockerFixture) -> None:
         run()
 
 
-def test_no_tox_ini(tox_project: ToxProjectCreator) -> None:
-    project = tox_project({})
-    outcome = project.run("l")
-    # assume an empty tox.ini at the cwd level
-    assert outcome.state.options.work_dir is None
-
-
 def test_custom_work_dir(tox_project: ToxProjectCreator) -> None:
     project = tox_project({})
     outcome = project.run("c", "--workdir", str(project.path.parent))
diff --git a/tests/util/test_pep517.py b/tests/util/test_pep517.py
index ed9e0fdc..5f7adb2f 100644
--- a/tests/util/test_pep517.py
+++ b/tests/util/test_pep517.py
@@ -71,7 +71,7 @@ def test_pep517_setuptools_prepare_metadata_for_build_wheel(
     meta = tmp_path / "meta"
     result = frontend_setuptools.prepare_metadata_for_build_wheel(metadata_directory=meta)
     dist = Distribution.at(str(result.metadata))
-    assert dist.entry_points == [EntryPoint(name="demo_exe", value="demo:a", group="console_scripts")]
+    assert list(dist.entry_points) == [EntryPoint(name="demo_exe", value="demo:a", group="console_scripts")]
     assert dist.version == "1.0"
     assert dist.metadata["Name"] == "demo"
     assert [v for k, v in dist.metadata.items() if k == "Requires-Dist"] == ["requests (>2)", "magic (>3)"]