Repository: tox-dev/tox

Issue:
native pyproject.toml config
This is a ticket to track and implement a native ``pyproject.toml`` configuration. Will update with the proposed plan and format.

Core goals:
- explicitly inheritance based (a lot of substitution at the moment is used to replicate inheritance), 
- substitution able,
- reasonably familiar (e.g. share most concepts with ini format),
- succinct (some form of factors needs to have).

We'll use this opportunity to rename some keys (e.g. ``skipsdist`` should be ``skip_packaging``, etc). 
 

Discussion:
gaborbernat: @ssbarnea @ziima this is now ready for first release, give it a go if you can.

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1561ff14..97e72814 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -39,12 +39,9 @@ repos:
     hooks:
       - id: rst-backticks
   - repo: https://github.com/rbubley/mirrors-prettier
-    rev: "v3.3.3" # Use the sha / tag you want to point at
+    rev: "v3.3.3"
     hooks:
       - id: prettier
-        additional_dependencies:
-          - prettier@3.3.3
-          - "@prettier/plugin-xml@3.4.1"
   - repo: local
     hooks:
       - id: changelogs-rst
diff --git a/docs/changelog/999.feature.rst b/docs/changelog/999.feature.rst
new file mode 100644
index 00000000..bd4aceb3
--- /dev/null
+++ b/docs/changelog/999.feature.rst
@@ -0,0 +1,1 @@
+Native TOML configuration support - by :user:`gaborbernat`.
diff --git a/docs/config.rst b/docs/config.rst
index ecbbd2d9..33c3698d 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -9,33 +9,47 @@ be set once and used for all tox environments, while environment options are app
 Discovery and file types
 ------------------------
 
-Out of box tox supports three configuration locations prioritized in the following order:
+With regards to the configuration format, at the moment we support the following formats:
 
-1. ``tox.ini``,
-2. ``pyproject.toml``,
-3. ``setup.cfg``.
+- `INI <https://en.wikipedia.org/wiki/INI_file>`_.
+- `TOML <https://toml.io/en/v1.0.0>`_.
 
-With regards to the configuration format, at the moment we only support *ini-style*. ``tox.ini`` and ``setup.cfg`` are
-by nature such files, while in ``pyproject.toml`` currently you can only inline the *ini-style* config.
+Out of box tox supports five configuration locations prioritized in the following order:
 
-Note that ``setup.cfg`` requires the content to be under the ``tox:tox`` and ``testenv`` sections and is otherwise
-ignored. ``pyproject.toml`` on the other hand is in TOML format. However, one can inline the *ini-style* format under
-the ``tool.tox.legacy_tox_ini`` key as a multi-line string.
+1. ``tox.ini`` (INI),
+2. ``setup.cfg`` (INI),
+3. ``pyproject.toml`` with the ``tool.tox`` table, having ``legacy_tox_ini`` key (containing INI),
+4. Native ``pyproject.toml`` under the ``tool.tox`` table (TOML),
+5. ``tox.toml`` (TOML).
+
+Historically, the INI format was created first, and TOML was added in 2024. The TOML format generally is more robust,
+however is less powerful and more verbose. You should use TOML unless you need some of the more advanced features
+that TOML does not support (such as conditional factors, generative environments to name a few -- however, PRs to
+add support for these in TOML is welcome).
+
+.. _tox-ini:
 
 ``tox.ini``
 ~~~~~~~~~~~
-The core settings are under the ``tox`` section while the environment sections are under the ``testenv:{env_name}``
-section. All tox environments by default inherit setting from the ``testenv`` section. This means if tox needs an option
-and is not available under ``testenv:{env_name}`` will first try to use the value from ``testenv``, before falling back
-to the default value for that setting. For example:
+
+This configuration file uses:
+
+- ``tox`` section to host core configuration,
+- ``testenv:{env_name}`` section to host environment configuration,
+- ``testenv`` section as base configuration for run environments (fallback location for missing values for a test/run
+  environment),
+- ``pkgenv`` section as base configuration for package environments (fallback location for missing values for a package
+  environment).
+
+For example:
 
 .. code-block:: ini
 
     [tox]
-    min_version = 4.0
+    min_version = 4.20
     env_list =
-        py310
-        py39
+        3.13
+        3.12
         type
 
     [testenv]
@@ -48,18 +62,22 @@ to the default value for that setting. For example:
 
 ``setup.cfg``
 ~~~~~~~~~~~~~
-The core settings are under the ``tox:tox`` section while the environment sections are under the ``testenv:{env_name}``
-section. All tox environments by default inherit setting from the ``testenv`` section. This means if tox needs an option
-and is not available under ``testenv:{env_name}`` will first try to use the value from ``testenv``, before falling back
-to the default value for that setting. For example:
+This configuration file uses:
+
+- ``tox:tox`` section to host core configuration,
+- ``testenv:{env_name}`` section to host environment configuration,
+- ``testenv`` section as base configuration for run environments (fallback location for missing values for a test/run
+  environment),
+- ``pkgenv`` section as base configuration for package environments (fallback location for missing values for a package
+  environment).
 
 .. code-block:: ini
 
     [tox:tox]
     min_version = 4.0
     env_list =
-        py310
-        py39
+        3.13
+        3.12
         type
 
     [testenv]
@@ -70,12 +88,10 @@ to the default value for that setting. For example:
     deps = mypy
     commands = mypy src
 
-``pyproject.toml``
-~~~~~~~~~~~~~~~~~~
-You can inline a ``tox.ini`` style configuration under the ``tool.tox`` section and ``legacy_tox_ini`` key.
-
-Below you find the specification for the *ini-style* format, but you might want to skim some
-examples first and use this page as a reference.
+``pyproject.toml`` - INI
+~~~~~~~~~~~~~~~~~~~~~~~~
+This configuration file is equivalent to :ref:`tox.ini <tox-ini>` format, with the difference that the text is stored
+instead inside the ``pyproject.toml`` file under the ``tool.tox`` table and ``legacy_tox_ini`` key:
 
 .. code-block:: toml
 
@@ -97,6 +113,58 @@ examples first and use this page as a reference.
         commands = mypy src
     """
 
+
+.. _pyproject-toml-native:
+
+``pyproject.toml`` - native
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+We support native TOML configuration via the ``pyproject.toml`` files ``tool.tox`` table. This configuration file uses:
+
+- ``tool.tox`` table to host core configuration,
+- ``tool.tox.env.{env_name}`` table to host environment configuration,
+- ``tool.tox.env_run_base`` table as base configuration for run environments (fallback location for missing values for
+  a test/run environment),
+- ``tool.tox.env_pkg_base`` table as base configuration for package environments (fallback location for missing values
+  for a package environment).
+
+.. code-block:: toml
+
+    [tool.tox]
+    requires = ["tox>=4.19"]
+    env_list = ["3.13", "3.12", "type"]
+
+    [tool.tox.env_run_base]
+    description = "Run test under {base_python}"
+    commands = [["pytest"]]
+
+    [tool.tox.env.type]
+    description = "run type check on code base"
+    deps = ["mypy==1.11.2", "types-cachetools>=5.5.0.20240820", "types-chardet>=5.0.4.6"]
+    commands = [["mypy", "src{/}tox"], ["mypy", "tests"]]
+
+``tox.toml``
+~~~~~~~~~~~~
+
+This configuration file is equivalent to :ref:`pyproject.toml - native <pyproject-toml-native>` with the difference
+that it lives in a separate dedicated files and accordingly the ``tool.tox`` sub-table is no longer required.
+
+For example:
+
+.. code-block:: toml
+
+    requires = ["tox>=4.19"]
+    env_list = ["3.13", "3.12", "type"]
+
+    [env_run_base]
+    description = "Run test under {base_python}"
+    commands = [["pytest"]]
+
+    [env.type]
+    description = "run type check on code base"
+    deps = ["mypy==1.11.2", "types-cachetools>=5.5.0.20240820", "types-chardet>=5.0.4.6"]
+    commands = [["mypy", "src{/}tox"], ["mypy", "tests"]]
+
 .. _conf-core:
 
 Core
@@ -114,13 +182,24 @@ The following options are set in the ``[tox]`` section of ``tox.ini`` or the ``[
    environment that does not have this issue, and run the tox command within that environment. See
    :ref:`provision_tox_env` for more details.
 
-   .. code-block:: ini
+    .. tab:: TOML
+
+       .. code-block:: toml
+
+          [tool.tox.pyproject]
+          requires = [
+            "tox>=4",
+            "virtualenv>20.2",
+          ]
+
+    .. tab:: INI
+
+       .. code-block:: ini
 
         [tox]
         requires =
             tox>=4
             virtualenv>20.2
-
 .. conf::
    :keys: min_version, minversion
    :default: <current version of tox>
@@ -208,12 +287,21 @@ The following options are set in the ``[tox]`` section of ``tox.ini`` or the ``[
 
    A mapping of label names to environments it applies too. For example:
 
-   .. code-block:: ini
+    .. tab:: TOML
+
+       .. code-block:: toml
+
+          [tool.pyproject]
+          labels = { test = ["3.13", "3.12"], static = ["ruff", "mypy"] }
 
-      [tox]
-      labels =
-           test = py310, py39
-           static = flake8, mypy
+    .. tab:: INI
+
+       .. code-block:: ini
+
+          [tox]
+          labels =
+               test = 3.13, 3.12
+               static = ruff, mypy
 
 .. conf::
    :keys: on_platform
@@ -230,7 +318,7 @@ Python language core options
 
     .. versionadded:: 3.1.0
 
-    tox allows setting the Python version for an environment via the :ref:`basepython` setting. If that's not set tox
+    tox allows setting the Python version for an environment via the :ref:`base_python` setting. If that's not set tox
     can set a default value from the environment name (e.g. ``py310`` implies Python 3.10). Matching up the Python
     version with the environment name has became expected at this point, leading to surprises when some configs don't
     do so. To help with sanity of users, an error will be raised whenever the environment name version does not match
@@ -245,7 +333,7 @@ Python language core options
 tox environment
 ---------------
 
-The following options are set in the ``[testenv]`` or ``[testenv:*]`` sections of ``tox.ini`` or ``setup.cfg``.
+These are configuration for the tox environments (either packaging or run type).
 
 Base options
 ~~~~~~~~~~~~
@@ -494,12 +582,23 @@ Base options
 
    A list of labels to apply for this environment. For example:
 
-   .. code-block:: ini
+    .. tab:: TOML
+
+       .. code-block:: toml
+
+          [tool.pyproject.env_run_base]
+          labels = ["test", "core"]
+          [tool.pyproject.env.flake8]
+          labels = ["mypy"]
 
-      [testenv]
-      labels = test, core
-      [testenv:flake8]
-      labels = mypy
+    .. tab:: INI
+
+       .. code-block:: ini
+
+          [testenv]
+          labels = test, core
+          [testenv:flake8]
+          labels = mypy
 
 Execute
 ~~~~~~~
@@ -736,8 +835,20 @@ Python run
      ``-c`` (followed by a file path).
 
    For example:
+    .. tab:: TOML
 
-    .. code-block:: ini
+       .. code-block:: toml
+
+          [tool.pyproject.env_run_base]
+          deps = [
+            "pytest>=8",
+            "-r requirements.txt",
+            "-c constraints.txt",
+          ]
+
+    .. tab:: INI
+
+       .. code-block:: ini
 
         [testenv]
         deps =
@@ -1019,116 +1130,243 @@ changed via ``TOX_USER_CONFIG_FILE`` environment variable. 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.
+Set CLI flags via environment variables
+---------------------------------------
+All configuration can be overridden via environment variables too, the naming convention here is ``TOX_<option>``. E.g.
+``TOX_WORK_DIR`` sets the ``--workdir`` flag, or ``TOX_OVERRIDE`` sets the ``--override`` flag. For flags accepting more
+than one argument, use the ``;`` character to separate these values:
 
-The string inside the curly braces may reference a global or per-environment config key as described above.
+All configuration inside the configuration file may be overwritten via the ``TOX_OVERRIDE``, note in this case the
+configuration file and its access (section/table + key) are needed. Here we demonstrate with a ``tox.ini`` file:
 
-In substitutions, the backslash character ``\`` will act as an escape when preceding
-``{``, ``}``, ``:``, ``[``, or ``]``, otherwise the backslash will be
-reproduced literally::
+.. code-block:: bash
 
-    commands =
-        python -c 'print("\{posargs} = \{}".format("{posargs}"))'
-        python -c 'print("host: \{}".format("{env:HOSTNAME:host\: not set}")'
+   # set FOO and bar as passed environment variable
+   $ env 'TOX_OVERRIDE=testenv.pass_env=FOO,BAR' tox c -k pass_env -e py
+   [testenv:py]
+   pass_env =
+     BAR
+     FOO
+     <default pass_envs>
 
-Note that any backslashes remaining after substitution may be processed by ``shlex`` during command parsing. On POSIX
-platforms, the backslash will escape any following character; on windows, the backslash will escape any following quote,
-whitespace, or backslash character (since it normally acts as a path delimiter).
+.. code-block:: bash
 
-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).
+   # append FOO and bar as passed environment variable to the list already defined in
+   # the tox configuration
+   $ env 'TOX_OVERRIDE=testenv.pass_env+=FOO,BAR' tox c -k pass_env -e py
+   [testenv:py]
+   pass_env =
+     BAR
+     FOO
+     <pass_envs defined in configuration>
+     <default pass_envs>
 
-.. _`environment variable substitutions`:
+.. code-block:: bash
 
-Environment variable substitutions
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+   # set httpx and deps to and 3.12 as base_python
+   $ env 'TOX_OVERRIDE=testenv.deps=httpx;testenv.base_python=3.12' .tox/dev/bin/tox c \
+         -k deps base_python -e py
+   [testenv:py]
+   deps = httpx
+   base_python = 3.12
 
-If you specify a substitution string like this::
+Overriding configuration from the command line
+----------------------------------------------
 
-    {env:KEY}
+You can override options in the configuration file, from the command line. For example, given this config:
 
-then the value will be retrieved as ``os.environ['KEY']``
-and raise an Error if the environment variable
-does not exist.
+.. tab:: TOML
 
+   .. code-block:: toml
 
-Environment variable substitutions with default values
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+       # tox.toml
+      [env_run_base]
+      deps = ["pytest"]
+      set_env = { foo = "bar" }
+      commands = [[ "pytest", "tests" ]]
 
-If you specify a substitution string like this::
+.. tab:: INI
 
-    {env:KEY:DEFAULTVALUE}
+   .. code-block:: ini
 
-then the value will be retrieved as ``os.environ['KEY']``
-and replace with DEFAULTVALUE if the environment variable does not
-exist.
+    [testenv]
+    deps = pytest
+    set_env =
+      foo=bar
+    commands = pytest tests
 
-If you specify a substitution string like this::
+You could enable ``ignore_errors`` by running:
 
-    {env:KEY:}
+.. tab:: TOML
 
-then the value will be retrieved as ``os.environ['KEY']``
-and replace with an empty string if the environment variable does not
-exist.
+    .. code-block:: bash
 
-Substitutions can also be nested. In that case they are expanded starting
-from the innermost expression::
+       tox --override env_run_base.ignore_errors=True
 
-    {env:KEY:{env:DEFAULT_OF_KEY}}
+.. tab:: INI
 
-the above example is roughly equivalent to
-``os.environ.get('KEY', os.environ['DEFAULT_OF_KEY'])``
+    .. code-block:: bash
 
-Interactive shell substitution
+       tox --override testenv.ignore_errors=True
+
+
+You could add additional dependencies by running:
+
+.. tab:: TOML
+
+    .. code-block:: bash
+
+       tox --override env_run_base.deps+=pytest-xdist
+
+.. tab:: INI
+
+    .. code-block:: bash
+
+       tox --override testenv.deps+=pytest-xdist
+
+You could set additional environment variables by running:
+
+.. tab:: TOML
+
+    .. code-block:: bash
+
+       tox --override env_run_base.set_env+=baz=quux
+
+.. tab:: INI
+
+    .. code-block:: bash
+
+       tox --override testenv.set_env+=baz=quux
+
+You can specify overrides multiple times on the command line to append multiple items:
+
+.. tab:: TOML
+
+    .. code-block:: bash
+
+       tox -x env_run_base.set_env+=foo=bar -x env_run_base.set_env+=baz=quux
+       tox -x testenv_run_baseenv.deps+=pytest-xdist -x env_run_base.deps+=pytest-covt
+
+.. tab:: INI
+
+    .. code-block:: bash
+
+       tox -x testenv.set_env+=foo=bar -x testenv.set_env+=baz=quux
+       tox -x testenv.deps+=pytest-xdist -x testenv.deps+=pytest-covt
+
+Or reset override and append to that (note the first override is ``=`` and not ``+=``):
+
+.. tab:: TOML
+
+    .. code-block:: bash
+
+       tox -x env_run_base.deps=pytest-xdist -x env_run_base.deps+=pytest-cov
+
+.. tab:: INI
+
+    .. code-block:: bash
+
+       tox -x testenv.deps=pytest-xdist -x testenv.deps+=pytest-cov
+
+TOML only
+---------
+
+These additional rules are active for native TOML configuration files.
+
+String elements (excluding keys) will be transformed according to the :ref:`Substitutions <substitution>` section.
+
+String substitution references
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-.. versionadded:: 3.4.0
+.. versionadded:: 4.21
 
-It's possible to inject a config value only when tox is running in interactive shell (standard input)::
+Within strings values from other sections can be referred to via ``{[<table>]<key>}``:
 
-    {tty:ON_VALUE:OFF_VALUE}
+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:
 
-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 (optional). A good
-use case for this is e.g. passing in the ``--pdb`` flag for pytest.
+.. code-block:: toml
 
-.. _`command positional substitution`:
-.. _`positional substitution`:
+    [extra]
+    ok = "o"
+    [.env.B]
+    description = "{[extra]ok}"
 
-Substitutions for positional arguments in commands
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+If the target table is one of the tox environments variable substitution will be applied on the replaced value,
+otherwise the text will be inserted as is (e.g., here with extra).
 
-.. versionadded:: 1.0
+Positional argument reference
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+.. versionadded:: 4.21
 
-If you specify a substitution string like this::
+You can reference positional arguments via the ``posargs`` replacement:
 
-    {posargs:DEFAULTS}
+.. code-block:: toml
 
-then the value will be replaced with positional arguments as provided
-to the tox command::
+    [env.A]
+    commands = [["python", { replace = "posargs", default = ["a", "b"] } ]]
 
-    tox arg1 arg2
+If the positional arguments are not set commands will become ``python a b``, otherwise will be ``python posarg-set``.
+Note that:
+
+.. code-block:: toml
+
+    [env.A]
+    commands = [["python", "{posargs}" ]]
+
+Differs in sense that the positional arguments will be set as a single argument, while in the original example they are
+passed through as separate.
+
+Environment variable reference
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+.. versionadded:: 4.21
+
+You can reference environment variables via the ``env`` replacement:
+
+.. code-block:: toml
+
+    [env.A]
+    set_env.COVERAGE_FILE = { replace = "env", name = "COVERAGE_FILE", default = "ok" }
+
+If the environment variable is set the the ``COVERAGE_FILE`` will become that, otherwise will default to ``ok``.
+
+Other configuration reference
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+.. versionadded:: 4.21
+
+You can reference environment variables via the ``env`` replacement:
+
+.. code-block:: toml
+
+    [env_run_base]
+    extras = ["A", "{env_name}"]
+    [env.ab]
+    extras = [{ replace = "ref", raw = ["env_run_base", "extras"] }, "B"]
+
+In this case the ``extras`` for ``ab`` will be ``A``, ``B`` and ``ab``.
+
+Reference replacement rules
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+When the replacement happens within a list and the returned value is also of type list the content will be extending the
+list rather than replacing it. For example:
 
-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.,
+.. code-block:: toml
+
+    [env_run_base]
+    extras = ["A"]
+    [env.ab]
+    extras = [{ replace = "ref", raw = ["env_run_base", "extras"] }, "B"]
 
-Use a double ``--`` if you also want to pass options to an underlying
-test command, for example::
+In this case the ``extras`` will be ``'A', 'B'`` rather than ``['A'], 'B'``.  Otherwise the replacement is in-place.
 
-    tox -- --opt1 ARG1
+INI only
+--------
+These additional rules are active for native INI configuration.
 
-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.
+The value for each setting in an INI configuration will be transformed according to the
+:ref:`Substitutions <substitution>` section.
 
 Substitution for values from other sections
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -1139,8 +1377,8 @@ 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:
+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
 
@@ -1160,88 +1398,210 @@ You can put default values in one section and reference them in others to avoid
         mercurial
         {[base]deps}
 
-Other Substitutions
-~~~~~~~~~~~~~~~~~~~
+Conditional settings
+~~~~~~~~~~~~~~~~~~~~
 
-* ``{}`` - replaced as ``os.pathsep``
-* ``{/}`` - replaced as ``os.sep``
+- Configurations may be set conditionally within the ``tox.ini`` file. If a line starts with an environment name
+  or names, separated by a comma, followed by ``:`` the configuration will only be used if the
+  environment name(s) matches the executed tox environment. For example:
 
-Overriding configuration from the command line
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+  .. code-block:: ini
+
+     [testenv]
+     deps =
+        pip
+        format: black
+        py310,py39: pytest
 
-You can override options in the configuration file, from the command
-line.
+  Here pip will be always installed as the configuration value is not conditional. black is only used for the ``format``
+  environment, while ``pytest`` is only installed for the ``py310`` and ``py39`` environments.
 
-For example, given this config:
+Generative environment list
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+If you have a large matrix of dependencies, python versions and/or environments you can use a generative
+:ref:`env_list` and conditional settings to express that in a concise form:
 
 .. code-block:: ini
 
+    [tox]
+    env_list = py{311,310,39}-django{41,40}-{sqlite,mysql}
+
     [testenv]
-    deps = pytest
-    setenv =
-      foo=bar
-    commands = pytest tests
+    deps =
+        django41: Django>=4.1,<4.2
+        django40: Django>=4.0,<4.1
+        # use PyMySQL if factors "py311" and "mysql" are present in env name
+        py311-mysql: PyMySQL
+        # use urllib3 if any of "py311" or "py310" are present in env name
+        py311,py310: urllib3
+        # mocking sqlite on 3.11 and 3.10 if factor "sqlite" is present
+        py{311,310}-sqlite: mock
+
+This will generate the following tox environments:
+
+.. code-block:: shell
+
+    > tox l
+    default environments:
+    py311-django41-sqlite -> [no description]
+    py311-django41-mysql  -> [no description]
+    py311-django40-sqlite -> [no description]
+    py311-django40-mysql  -> [no description]
+    py310-django41-sqlite -> [no description]
+    py310-django41-mysql  -> [no description]
+    py310-django40-sqlite -> [no description]
+    py310-django40-mysql  -> [no description]
+    py39-django41-sqlite  -> [no description]
+    py39-django41-mysql   -> [no description]
+    py39-django40-sqlite  -> [no description]
+    py39-django40-mysql   -> [no description]
+
+Generative section names
+~~~~~~~~~~~~~~~~~~~~~~~~
 
-You could enable ``ignore_errors`` by running:
+Suppose you have some binary packages, and need to run tests both in 32 and 64 bits. You also want an environment to
+create your virtual env for the developers.
 
-.. code-block:: bash
+.. code-block:: ini
 
-   tox --override testenv.ignore_errors=True
+    [testenv]
+    base_python =
+        py311-x86: python3.11-32
+        py311-x64: python3.11-64
+    commands = pytest
 
-You could add additional dependencies by running:
+    [testenv:py311-{x86,x64}-venv]
+    envdir =
+        x86: .venv-x86
+        x64: .venv-x64
 
-.. code-block:: bash
+.. code-block:: shell
 
-   tox --override testenv.deps+=pytest-xdist
+    > tox l
+    default environments:
+    py          -> [no description]
 
-You could set additional environment variables by running:
+    additional environments:
+    py310-black -> [no description]
+    py310-lint  -> [no description]
+    py311-black -> [no description]
+    py311-lint  -> [no description]
 
-.. code-block:: bash
+.. _substitution:
 
-   tox --override testenv.setenv+=baz=quux
+Substitutions
+-------------
 
-You can specify overrides multiple times on the command line to append multiple items:
+**Value substitution** operates through the ``{...}`` string-substitution pattern. The string inside the curly braces
+may reference a global or per-environment config key as described above.
 
-.. code-block:: bash
+In substitutions, the backslash character ``\`` will act as an escape when preceding
+``{``, ``}``, ``:``, ``[``, or ``]``, otherwise the backslash will be reproduced literally:
 
-   tox -x testenv.seteenv+=foo=bar -x testenv.setenv+=baz=quux
-   tox -x testenv.deps+=pytest-xdist -x testenv.deps+=pytest-cov
+.. tab:: TOML
 
-Or reset override and append to that (note the first override is ``=`` and not ``+=``):
+    .. code-block:: toml
 
-.. code-block:: bash
+        commands = [
+          ["python", "-c", 'print("\{posargs} = \{}".format("{posargs}"))'],
+          ["python", "-c", 'print("host: \{}".format("{env:HOSTNAME:host\: not set}")'],
+        ]
 
-   tox -x testenv.deps=pytest-xdist -x testenv.deps+=pytest-cov
+.. tab:: INI
 
-Set CLI flags via environment variables
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-All CLI flags can be set via environment variables too, the naming convention here is ``TOX_<option>``. E.g.
-``TOX_WORK_DIR`` sets the ``--workdir`` flag, or ``TOX_OVERRIDE`` sets the ``--override`` flag. For flags accepting more
-than one argument, use the ``;`` character to separate these values:
+    .. code-block:: ini
 
-.. code-block:: bash
+        commands =
+          python -c 'print("\{posargs} = \{}".format("{posargs}"))'
+          python -c 'print("host: \{}".format("{env:HOSTNAME:host\: not set}")'
 
-   # set FOO and bar as passed environment variable
-   $ env 'TOX_OVERRIDE=testenv.pass_env=FOO,BAR' tox c -k pass_env -e py
-   [testenv:py]
-   pass_env =
-     BAR
-     FOO
-     <default pass_envs>
 
-   # append FOO and bar as passed environment variable to the list already defined in
-   # the tox configuration
-   $ env 'TOX_OVERRIDE=testenv.pass_env+=FOO,BAR' tox c -k pass_env -e py
-   [testenv:py]
-   pass_env =
-     BAR
-     FOO
-     <pass_envs defined in configuration>
-     <default pass_envs>
+Note that any backslashes remaining after substitution may be processed by ``shlex`` during command parsing. On POSIX
+platforms, the backslash will escape any following character; on windows, the backslash will escape any following quote,
+whitespace, or backslash character (since it normally acts as a path delimiter).
 
-   # set httpx and deps to and 3.12 as base_python
-   $ env 'TOX_OVERRIDE=testenv.deps=httpx;testenv.base_python=3.12' .tox/dev/bin/tox c \
-         -k deps base_python -e py
-   [testenv:py]
-   deps = httpx
-   base_python = 3.12
+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`:
+
+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 (optional). 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 run -e 3.13 -- --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.
+
+Other Substitutions
+~~~~~~~~~~~~~~~~~~~
+
+* ``{}`` - replaced as ``os.pathsep``
+* ``{/}`` - replaced as ``os.sep``
diff --git a/docs/installation.rst b/docs/installation.rst
index 07be16bc..d4601d1a 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -1,31 +1,42 @@
 Installation
 ============
 
-via pipx
+As tool
 --------
 
-:pypi:`tox` is a CLI tool that needs a Python interpreter (version 3.7 or higher) to run. We recommend :pypi:`pipx` to
-install tox into an isolated environment. This has the added benefit that later you'll be able to upgrade tox without
-affecting other parts of the system.
+:pypi:`tox` is a CLI tool that needs a Python interpreter (version 3.7 or higher) to run. We recommend either
+:pypi:`pipx` or  :pypi:`ruff` to install tox into an isolated environment. This has the added benefit that later you'll
+be able to upgrade tox without affecting other parts of the system. We provide method for ``pip`` too here but we
+discourage that path if you can:
 
-.. code-block:: bash
+.. tab:: uv
 
-    python -m pip install pipx-in-pipx --user
-    pipx install tox
-    tox --help
+    .. code-block:: bash
 
-via pip
--------
+        # install uv per https://docs.astral.sh/uv/#getting-started
+        uv tool install tox
+        tox --help
 
-Alternatively you can install it within the global Python interpreter itself (perhaps as a user package via the
-``--user`` flag). Be cautious if you are using a Python installation that is managed by your operating system or
-another package manager. ``pip`` might not coordinate with those tools, and may leave your system in an inconsistent
-state. Note, if you go down this path you need to ensure pip is new enough per the subsections below:
 
-.. code-block:: bash
+.. tab:: pipx
+
+    .. code-block:: bash
+
+        python -m pip install pipx-in-pipx --user
+        pipx install tox
+        tox --help
+
+.. tab:: pip
+
+    .. code-block:: bash
+
+        python -m pip install --user tox
+        python -m tox --help
 
-    python -m pip install --user tox
-    python -m tox --help
+    You can install it within the global Python interpreter itself (perhaps as a user package via the
+    ``--user`` flag). Be cautious if you are using a Python installation that is managed by your operating system or
+    another package manager. ``pip`` might not coordinate with those tools, and may leave your system in an inconsistent
+    state. Note, if you go down this path you need to ensure pip is new enough per the subsections below
 
 wheel
 ~~~~~
@@ -66,7 +77,7 @@ Python and OS Compatibility
 
 tox works with the following Python interpreter implementations:
 
-- `CPython <https://www.python.org/>`_ versions 3.7, 3.8, 3.9, 3.10, 3.11, 3.12
+- `CPython <https://www.python.org/>`_ versions 3.8, 3.9, 3.10, 3.11, 3.12, 3.13
 
 This means tox works on the latest patch version of each of these minor versions. Previous patch versions are supported
 on a best effort approach.
diff --git a/docs/user_guide.rst b/docs/user_guide.rst
index 7a3685bf..be5d0eca 100644
--- a/docs/user_guide.rst
+++ b/docs/user_guide.rst
@@ -18,57 +18,98 @@ Configuration
 -------------
 
 *tox* needs a configuration file where you define what tools you need to run and how to provision a test environment for
-these. The canonical file for this is the ``tox.ini`` file. For example:
-
-.. code-block:: ini
-
-    [tox]
-    requires =
-        tox>=4
-    env_list = lint, type, py{38,39,310,311}
-
-    [testenv]
-    description = run unit tests
-    deps =
-        pytest>=7
-        pytest-sugar
-    commands =
-        pytest {posargs:tests}
-
-    [testenv:lint]
-    description = run linters
-    skip_install = true
-    deps =
-        black==22.12
-    commands = black {posargs:.}
-
-    [testenv:type]
-    description = run type checks
-    deps =
-        mypy>=0.991
-    commands =
-        mypy {posargs:src tests}
+these. The canonical file for this is either a ``tox.toml`` or ``tox.ini`` file. For example:
+
+    .. tab:: TOML
+
+       .. code-block:: toml
+
+            requires = ["tox>=4"]
+            env_list = ["lint", "type", "3.13", "3.12", "3.11"]
+
+            [env_run_base]
+            description = "run unit tests"
+            deps = [
+                "pytest>=8",
+                "pytest-sugar"
+            ]
+            commands = [["pytest", { replace = "posargs", default = ["tests"] }]]
+
+            [env.lint]
+            description = "run linters"
+            skip_install = true
+            deps = ["black"]
+            commands = [["black", { replace = "posargs", default = ["."]} ]]
+
+            [env.type]
+            description = "run type checks"
+            deps = ["mypy"]
+            commands = [["mypy", { replace = "posargs", default = ["src", "tests"]} ]]
+
+
+    .. tab:: INI
+
+       .. code-block:: ini
+
+           [tox]
+            requires =
+                tox>=4
+            env_list = lint, type, 3.{3,2,1}
+
+            [testenv]
+            description = run unit tests
+            deps =
+                pytest>=8
+                pytest-sugar
+            commands =
+                pytest {posargs:tests}
+
+            [testenv:lint]
+            description = run linters
+            skip_install = true
+            deps =
+                black
+            commands = black {posargs:.}
+
+            [testenv:type]
+            description = run type checks
+            deps =
+                mypy
+            commands =
+                mypy {posargs:src tests}
 
 .. tip::
 
    You can also generate a ``tox.ini`` file automatically by running ``tox quickstart`` and then answering a few
    questions.
 
-The configuration is split into two type of configuration: core settings are hosted under a core ``tox`` section while
-per run environment settings hosted under ``testenv`` and ``testenv:<env_name>`` sections.
+The configuration is split into two types:
+
+- core settings
+- tox environment settings.
 
 Core settings
 ~~~~~~~~~~~~~
 
-Core settings that affect all test environments or configure how tox itself is invoked are defined under the ``tox``
-section.
+Core settings that affect all test environments or configure how tox itself is invoked are defined under the root table
+in ``tox.toml`` and ``tox`` table in ``tox.ini`` section.
+
+    .. tab:: TOML
+
+       .. code-block:: toml
 
-.. code-block:: ini
+          requires = ["tox>=4"]
+          env_list = ["lint", "type", "3.13", "3.12", "3.11"]
+
+    .. tab:: INI
+
+       .. code-block:: ini
+
+        [tox]
+        requires =
+            tox>=4
+        env_list = lint, type, 3.{3,2,1}
 
-    [tox]
-    requires =
-        tox>=4
-    env_list = lint, type, py{38,39,310,311}
 
 We can use it to specify things such as the minimum version of *tox* required or the location of the package under test.
 A list of all supported configuration options for the ``tox`` section can be found in the :ref:`configuration guide
@@ -77,15 +118,42 @@ A list of all supported configuration options for the ``tox`` section can be fou
 Test environments
 ~~~~~~~~~~~~~~~~~
 
-Test environments are defined under the ``testenv`` section and individual ``testenv:<env_name>`` sections, where
-``<env_name>`` is the name of a specific environment.
+When ``<env_name>`` is the name of a specific environment, test environment configurations are defined:
+
+- ``testenv`` section and individual ``testenv:<env_name>`` for ``tox.ini``,
+- ``env_run_base`` table and individual ``env.<env_name>`` for ``tox.toml``.
 
-.. code-block:: ini
+.. tab:: TOML
+
+   .. code-block:: toml
+
+        [env_run_base]
+        description = "run unit tests"
+        deps = [
+            "pytest>=8",
+            "pytest-sugar"
+        ]
+        commands = [["pytest", { replace = "posargs", default = ["tests"] }]]
+
+        [env.lint]
+        description = "run linters"
+        skip_install = true
+        deps = ["black"]
+        commands = [["black", { replace = "posargs", default = ["."]} ]]
+
+        [env.type]
+        description = "run type checks"
+        deps = ["mypy"]
+        commands = [["mypy", { replace = "posargs", default = ["src", "tests"]} ]]
+
+.. tab:: INI
+
+   .. code-block:: ini
 
     [testenv]
     description = run unit tests
     deps =
-        pytest>=7
+        pytest>=8
         pytest-sugar
     commands =
         pytest {posargs:tests}
@@ -94,21 +162,22 @@ Test environments are defined under the ``testenv`` section and individual ``tes
     description = run linters
     skip_install = true
     deps =
-        black==22.12
+        black
     commands = black {posargs:.}
 
     [testenv:type]
     description = run type checks
     deps =
-        mypy>=0.991
+        mypy
     commands =
         mypy {posargs:src tests}
 
-Settings defined in the top-level ``testenv`` section are automatically inherited by individual environments unless
-overridden. Test environment names can consist of alphanumeric characters and dashes; for example: ``py311-django42``.
-The name will be split on dashes into multiple factors, meaning ``py311-django42`` will be split into two factors:
-``py311`` and ``django42``. *tox* defines a number of default factors, which correspond to various versions and
-implementations of Python and provide default values for ``base_python``:
+Settings defined at the top-level (``env_run_base`` table in TOML and ``testenv`` section in INI configuration files)
+are automatically inherited by individual environments unless overridden. Test environment names can consist of
+alphanumeric characters and dashes; for example: ``py311-django42``. The name will be split on dashes into multiple
+factors, meaning ``py311-django42`` will be split into two factors: ``py311`` and ``django42``. *tox* defines a number
+of default factors, which correspond to various versions and implementations of Python and provide default values for
+``base_python``:
 
 - ``pyNM``: configures ``basepython = pythonN.M``
 - ``pypyNM``: configures ``basepython = pypyN.M``
@@ -121,38 +190,64 @@ implementations of Python and provide default values for ``base_python``:
 You can also specify these factors with a period between the major and minor versions (e.g. ``pyN.M``), without a minor
 version (e.g. ``pyN``), or without any version information whatsoever (e.g. ``py``)
 
-A list of all supported configuration options for the ``testenv`` and ``testenv:<env_name>`` sections can be found in
-the :ref:`configuration guide <conf-testenv>`.
+A list of all supported configuration options for the tox environments can be found in the
+:ref:`configuration guide <conf-testenv>`.
 
 Basic example
 -------------
 
-.. code-block:: ini
+.. tab:: TOML
 
-    [tox]
-    env_list =
-        format
-        py310
+   .. code-block:: toml
 
-    [testenv:format]
-    description = install black in a virtual environment and invoke it on the current folder
-    deps = black==22.3.0
-    skip_install = true
-    commands = black .
 
-    [testenv:py310]
-    description = install pytest in a virtual environment and invoke it on the tests folder
-    deps =
-        pytest>=7
-        pytest-sugar
-    commands = pytest tests {posargs}
+        env_list =  ["format", "3.13"]
+            format
+            py310
 
-This example contains a global ``tox`` section as well as two test environments. Taking the core section first, we use
-the :ref:`env_list` setting to indicate that this project has two run environments named ``format`` and ``py310`` that
-should be run by default when ``tox run`` is invoked without a specific environment.
+        [env.format]
+        description = "install black in a virtual environment and invoke it on the current folder"
+        deps = ["black==22.3.0"]
+        skip_install = true
+        commands = [[ "black", "." ]]
 
-The formatting environment and test environment are defined separately via the ``testenv:format`` and ``testenv:py310``
-sections, respectively. For example to format the project we:
+        [env."3.13"]
+        description = "install pytest in a virtual environment and invoke it on the tests folder"
+        deps = [
+            "pytest>=7",
+            "pytest-sugar",
+        ]
+        commands = [[ "pytest", "tests", { replace = "posargs"} ]]
+
+.. tab:: INI
+
+   .. code-block:: ini
+
+        [tox]
+        env_list =
+            format
+            3.13
+
+        [testenv:format]
+        description = install black in a virtual environment and invoke it on the current folder
+        deps = black==22.3.0
+        skip_install = true
+        commands = black .
+
+        [testenv:3.13]
+        description = install pytest in a virtual environment and invoke it on the tests folder
+        deps =
+            pytest>=7
+            pytest-sugar
+        commands = pytest tests {posargs}
+
+This example contains a core configuration (root table in TOML and ``tox`` in INI) section as well as two
+test environments. Taking the core section first, we use the :ref:`env_list` setting to indicate that this project has
+two run environments named ``format`` and ``3.13`` that should be run by default when ``tox run`` is invoked without a
+specific environment.
+
+The formatting environment and test environment are defined separately (via the ``env.format`` and ``env."3.13"`` in
+TOML file; ``testenv:format`` and ``testenv:py310`` in INI file). For example to format the project we:
 
 - add a description (visible when you type ``tox list`` into the command line) via the :ref:`description` setting
 - define that it requires the :pypi:`black` dependency with version ``22.3.0`` via the :ref:`deps` setting
@@ -160,25 +255,26 @@ sections, respectively. For example to format the project we:
   ``black`` does not need it installed
 - indicate the commands to be run via the :ref:`commands` setting
 
-For testing the project we use the ``py310`` environment. For this environment we:
+For testing the project we use the ``3.13`` environment. For this environment we:
 
 - define a text description of the environment via the :ref:`description` setting
 - specify that we should install :pypi:`pytest` v7.0 or later together with the :pypi:`pytest-sugar` project via the
   :ref:`deps` setting
 - indicate the command(s) to be run - in this case ``pytest tests`` - via the :ref:`commands` setting
 
-``{posargs}`` is a place holder part for the CLI command that allows us to pass additional flags to the pytest
-invocation, for example if we'd want to run ``pytest tests -v`` as a one off, instead of ``tox run -e py310`` we'd type
-``tox run -e py310 -- -v``. The ``--`` delimits flags for the tox tool and what should be forwarded to the tool within.
+``{ replace = "posargs"}`` in TOML and ``{posargs}`` in INI is a place holder part for the CLI command that allows us to
+pass additional flags to the pytest invocation, for example if we'd want to run ``pytest tests -v`` as a one off,
+instead of ``tox run -e 3.13`` we'd type ``tox run -e py310 -- -v``. The ``--`` delimits flags for the tox tool and
+what should be forwarded to the tool within.
 
 tox, by default, always creates a fresh virtual environment for every run environment. The Python version to use for a
 given environment can be controlled via the :ref:`base_python` configuration, however if not set tox will try to use the
-environment name to determine something sensible: if the name is in the format of ``pyxy`` then tox will create an environment with CPython
-with version ``x.y`` (for example ``py310`` means CPython ``3.10``). If the name does not match this pattern it will
-use a virtual environment with the same Python version as the one tox is installed into (this is the case for
-``format``).
+environment name to determine something sensible: if the name is in the format of ``pyxy`` (or ``x.y``) then tox will
+create an environment with CPython with version ``x.y`` (for example ``py310`` means CPython ``3.10``). If the name does
+not match this pattern it will use a virtual environment with the same Python version as the one tox is installed into
+(this is the case for ``format``).
 
-tox environments are reused between runs, so while the first ``tox run -e py310`` will take a while as tox needs to
+tox environments are reused between runs, so while the first ``tox run -e 3.13`` will take a while as tox needs to
 create a virtual environment and install ``pytest`` and ``pytest-sugar`` in it, subsequent runs only need to reinstall
 your project, as long as the environments dependency list does not change.
 
@@ -297,15 +393,25 @@ given you have at least version ``3.8.0`` of tox.
 
 For example given:
 
-.. code-block:: ini
+.. tab:: TOML
+
+   .. code-block:: toml
+
+        min_version = "4"
+        requires = ["tox-uv>=1"]
+
+.. tab:: INI
+
+   .. code-block:: ini
+
+        [tox]
+        min_version = 4
+        requires = tox-uv>=1
 
-    [tox]
-    min_version = 4
-    requires = tox-docker>=1
 
 if the user runs it with tox ``3.8`` or later the installed tox application will automatically ensure that both the minimum version and
 requires constraints are satisfied, by creating a virtual environment under ``.tox`` folder, and then installing into it
-``tox>=4`` and ``tox-docker>=1``. Afterwards all tox invocations are forwarded to the tox installed inside ``.tox\.tox``
+``tox>=4`` and ``tox-uv>=1``. Afterwards all tox invocations are forwarded to the tox installed inside ``.tox\.tox``
 folder (referred to as meta-tox or auto-provisioned tox).
 
 This allows tox to automatically setup itself with all its plugins for the current project.  If the host tox satisfies
@@ -339,21 +445,21 @@ CLI
 Config files
 ~~~~~~~~~~~~
 
-- Every tox environment has its own configuration section (e.g. in case of ``tox.ini`` configuration method the
-  ``py310`` tox environments configuration is read from the ``testenv:py310`` section). If the section is missing or does
-  not contain that configuration value, it will fall back to the section defined by the :ref:`base` configuration (for
-  ``tox.ini`` this is the ``testenv`` section). For example:
+- Every tox environment has its own configuration section (e.g. in case of ``tox.toml`` configuration method the
+  ``3.13`` tox environments configuration is read from the ``env_run_base."3.13"`` table). If the table is missing or
+  does not contain that configuration value, it will fall back to the section defined by the :ref:`base` configuration
+  (for ``tox.toml`` this is the ``env_run_base`` table). For example:
 
-  .. code-block:: ini
+  .. code-block:: toml
 
-    [testenv]
-    commands = pytest tests
+    [env_run_base]
+    commands = [["pytest", "tests"]]
 
-    [testenv:test]
-    description = run the test suite with pytest
+    [env.test]
+    description = "run the test suite with pytest"
 
-  Here the environment description for ``test`` is taken from ``testenv:test``. As ``commands`` is not specified,
-  the value defined under the ``testenv`` section will be used. If the base environment is also missing a
+  Here the environment description for ``test`` is taken from ``env_run_base``. As ``commands`` is not specified,
+  the value defined under the ``env_run_base`` section will be used. If the base environment is also missing a
   configuration value then the configuration default will be used (e.g. in case of the ``pass_env`` configuration here).
 
 - To change the current working directory for the commands run use :ref:`change_dir` (note this will make the change for
@@ -366,21 +472,6 @@ Config files
 
 - Setup operation can be configured via the :ref:`commands_pre`, while teardown commands via the :ref:`commands_post`.
 
-- Configurations may be set conditionally within the ``tox.ini`` file. If a line starts with an environment name
-  or names, separated by a comma, followed by ``:`` the configuration will only be used if the
-  environment name(s) matches the executed tox environment. For example:
-
-  .. code-block:: ini
-
-     [testenv]
-     deps =
-        pip
-        format: black
-        py310,py39: pytest
-
-  Here pip will be always installed as the configuration value is not conditional. black is only used for the ``format``
-  environment, while ``pytest`` is only installed for the ``py310`` and ``py39`` environments.
-
 .. _`parallel_mode`:
 
 Parallel mode
@@ -466,94 +557,27 @@ For pure Python projects (non C-Extension ones) it's recommended to set :ref:`wh
 Advanced features
 -----------------
 
-tox supports these features that 90 percent of the time you'll not need, but are very useful the other ten percent.
-
-Generative environments
-~~~~~~~~~~~~~~~~~~~~~~~
-
-Generative environment list
-+++++++++++++++++++++++++++
-
-If you have a large matrix of dependencies, python versions and/or environments you can use a generative
-:ref:`env_list` and conditional settings to express that in a concise form:
-
-.. code-block:: ini
-
-    [tox]
-    env_list = py{311,310,39}-django{41,40}-{sqlite,mysql}
-
-    [testenv]
-    deps =
-        django41: Django>=4.1,<4.2
-        django40: Django>=4.0,<4.1
-        # use PyMySQL if factors "py311" and "mysql" are present in env name
-        py311-mysql: PyMySQL
-        # use urllib3 if any of "py311" or "py310" are present in env name
-        py311,py310: urllib3
-        # mocking sqlite on 3.11 and 3.10 if factor "sqlite" is present
-        py{311,310}-sqlite: mock
-
-This will generate the following tox environments:
-
-.. code-block:: shell
-
-    > tox l
-    default environments:
-    py311-django41-sqlite -> [no description]
-    py311-django41-mysql  -> [no description]
-    py311-django40-sqlite -> [no description]
-    py311-django40-mysql  -> [no description]
-    py310-django41-sqlite -> [no description]
-    py310-django41-mysql  -> [no description]
-    py310-django40-sqlite -> [no description]
-    py310-django40-mysql  -> [no description]
-    py39-django41-sqlite  -> [no description]
-    py39-django41-mysql   -> [no description]
-    py39-django40-sqlite  -> [no description]
-    py39-django40-mysql   -> [no description]
-
-Generative section names
-++++++++++++++++++++++++
-
-Suppose you have some binary packages, and need to run tests both in 32 and 64 bits. You also want an environment to
-create your virtual env for the developers.
-
-.. code-block:: ini
-
-    [testenv]
-    base_python =
-        py311-x86: python3.11-32
-        py311-x64: python3.11-64
-    commands = pytest
-
-    [testenv:py311-{x86,x64}-venv]
-    envdir =
-        x86: .venv-x86
-        x64: .venv-x64
-
-.. code-block:: shell
-
-    > tox l
-    default environments:
-    py          -> [no description]
-
-    additional environments:
-    py310-black -> [no description]
-    py310-lint  -> [no description]
-    py311-black -> [no description]
-    py311-lint  -> [no description]
-
 Disallow command line environments which are not explicitly specified in the config file
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 Previously, any environment would be implicitly created even if no such environment was specified in the configuration
 file. For example, given this config:
 
-.. code-block:: ini
+.. tab:: TOML
+
+   .. code-block:: toml
+
+        [env.unit]
+        deps = [ "pytest" ]
+        commands = [[ "pytest" ]]
+
+.. tab:: INI
+
+   .. code-block:: ini
 
-    [testenv:unit]
-    deps = pytest
-    commands = pytest
+        [testenv:unit]
+        deps = pytest
+        commands = pytest
 
 Running ``tox -e unit`` would run our tests but running ``tox -e unt`` or ``tox -e unti`` would ultimately succeed
 without running any tests. A special exception is made for environments starting in ``py*``. In the above example
diff --git a/pyproject.toml b/pyproject.toml
index d0643293..4f9836d6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -53,19 +53,20 @@ dependencies = [
   "cachetools>=5.5",
   "chardet>=5.2",
   "colorama>=0.4.6",
-  "filelock>=3.15.4",
+  "filelock>=3.16.1",
   "packaging>=24.1",
-  "platformdirs>=4.2.2",
+  "platformdirs>=4.3.6",
   "pluggy>=1.5",
-  "pyproject-api>=1.7.1",
+  "pyproject-api>=1.8",
   "tomli>=2.0.1; python_version<'3.11'",
-  "virtualenv>=20.26.3",
+  "typing-extensions>=4.12.2; python_version<'3.11'",
+  "virtualenv>=20.26.6",
 ]
 optional-dependencies.docs = [
   "furo>=2024.8.6",
   "sphinx>=8.0.2",
-  "sphinx-argparse-cli>=1.17",
-  "sphinx-autodoc-typehints>=2.4",
+  "sphinx-argparse-cli>=1.18.2",
+  "sphinx-autodoc-typehints>=2.4.4",
   "sphinx-copybutton>=0.5.2",
   "sphinx-inline-tabs>=2023.4.21",
   "sphinxcontrib-towncrier>=0.2.1a0",
@@ -75,19 +76,19 @@ optional-dependencies.testing = [
   "build[virtualenv]>=1.2.2",
   "covdefaults>=2.3",
   "detect-test-pollution>=1.2",
-  "devpi-process>=1",
-  "diff-cover>=9.1.1",
+  "devpi-process>=1.0.2",
+  "diff-cover>=9.2",
   "distlib>=0.3.8",
   "flaky>=3.8.1",
   "hatch-vcs>=0.4",
   "hatchling>=1.25",
   "psutil>=6",
-  "pytest>=8.3.2",
+  "pytest>=8.3.3",
   "pytest-cov>=5",
   "pytest-mock>=3.14",
   "pytest-xdist>=3.6.1",
   "re-assert>=1.1",
-  "setuptools>=74.1.2",
+  "setuptools>=75.1",
   "time-machine>=2.15; implementation_name!='pypy'",
   "wheel>=0.44",
 ]
diff --git a/src/tox/config/loader/ini/__init__.py b/src/tox/config/loader/ini/__init__.py
index 5f0e5eda..9d97a331 100644
--- a/src/tox/config/loader/ini/__init__.py
+++ b/src/tox/config/loader/ini/__init__.py
@@ -6,7 +6,8 @@ from typing import TYPE_CHECKING, TypeVar
 
 from tox.config.loader.api import ConfigLoadArgs, Loader, Override
 from tox.config.loader.ini.factor import filter_for_env
-from tox.config.loader.ini.replace import replace
+from tox.config.loader.ini.replace import ReplaceReferenceIni
+from tox.config.loader.replacer import replace
 from tox.config.loader.str_convert import StrConvert
 from tox.config.set_env import SetEnv
 from tox.report import HandledError
@@ -71,8 +72,9 @@ class IniLoader(StrConvert, Loader[str]):
             if conf is None:
                 replaced = raw_  # no replacement supported in the core section
             else:
+                reference_replacer = ReplaceReferenceIni(conf, self)
                 try:
-                    replaced = replace(conf, self, raw_, args_)  # do replacements
+                    replaced = replace(conf, reference_replacer, raw_, args_)  # do replacements
                 except Exception as exception:
                     if isinstance(exception, HandledError):
                         raise
diff --git a/src/tox/config/loader/ini/replace.py b/src/tox/config/loader/ini/replace.py
index 9037f267..f225cedb 100644
--- a/src/tox/config/loader/ini/replace.py
+++ b/src/tox/config/loader/ini/replace.py
@@ -2,222 +2,84 @@
 
 from __future__ import annotations
 
-import logging
-import os
 import re
-import sys
 from configparser import SectionProxy
 from functools import lru_cache
-from typing import TYPE_CHECKING, Any, Iterator, Pattern, Sequence, Union
+from typing import TYPE_CHECKING, Iterator, Pattern
 
+from tox.config.loader.replacer import ReplaceReference
 from tox.config.loader.stringify import stringify
-from tox.execute.request import shell_cmd
 
 if TYPE_CHECKING:
-    from pathlib import Path
-
     from tox.config.loader.api import ConfigLoadArgs
     from tox.config.loader.ini import IniLoader
     from tox.config.main import Config
-    from tox.config.set_env import SetEnv
     from tox.config.sets import ConfigSet
 
 
-LOGGER = logging.getLogger(__name__)
-
-
-# split alongside :, unless it's preceded by a single capital letter (Windows drive letter in paths)
-ARG_DELIMITER = ":"
-REPLACE_START = "{"
-REPLACE_END = "}"
-BACKSLASH_ESCAPE_CHARS = [ARG_DELIMITER, REPLACE_START, REPLACE_END, "[", "]"]
-MAX_REPLACE_DEPTH = 100
-
-
-MatchArg = Sequence[Union[str, "MatchExpression"]]
-
-
-class MatchRecursionError(ValueError):
-    """Could not stabilize on replacement value."""
-
-
-class MatchError(Exception):
-    """Could not find end terminator in MatchExpression."""
-
-
-def find_replace_expr(value: str) -> 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, depth: int = 0) -> str:
-    """Replace all active tokens within value according to the config."""
-    if depth > MAX_REPLACE_DEPTH:
-        msg = f"Could not expand {value} after recursing {depth} frames"
-        raise MatchRecursionError(msg)
-    return Replacer(conf, loader, conf_args=args, depth=depth).join(find_replace_expr(value))
-
-
-class MatchExpression:  # noqa: PLW1641
-    """An expression that is handled specially by the Replacer."""
-
-    def __init__(self, expr: Sequence[MatchArg], term_pos: int | None = 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: object) -> 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] == "\\":
-                if 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
-                if value[pos + 1] == "\\":
-                    # backlash doesn't escape a backslash, but does prevent it from affecting the next char
-                    # a subsequent `shlex` pass will eat the double backslash during command splitting
-                    last_arg.append(value[pos : pos + 2])
-                    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:
-                msg = f"{terminator!r} remains unmatched in {value!r}"
-                raise MatchError(msg)
-        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, depth: int = 0) -> None:
+class ReplaceReferenceIni(ReplaceReference):
+    def __init__(self, conf: Config, loader: IniLoader) -> None:
         self.conf = conf
         self.loader = loader
-        self.conf_args = conf_args
-        self.depth = depth
 
-    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 __call__(self, value: str, conf_args: ConfigLoadArgs) -> str | None:  # noqa: C901
+        # a return value of None indicates could not replace
+        pattern = _replace_ref(self.loader.section.prefix or self.loader.section.name)
+        match = pattern.match(value)
+        if match:
+            settings = match.groupdict()
+
+            key = settings["key"]
+            if settings["section"] is None and settings["full_env"]:
+                settings["section"] = settings["full_env"]
+
+            exception: Exception | None = None
+            try:
+                for src in self._config_value_sources(settings["env"], settings["section"], conf_args.env_name):
+                    try:
+                        if isinstance(src, SectionProxy):
+                            return self.loader.process_raw(self.conf, conf_args.env_name, src[key])
+                        value = src.load(key, conf_args.chain)
+                    except KeyError as exc:  # if fails, keep trying maybe another source can satisfy # noqa: PERF203
+                        exception = exc
+                    else:
+                        as_str, _ = stringify(value)
+                        return as_str.replace("#", r"\#")  # escape comment characters as these will be stripped
+            except Exception as exc:  # noqa: BLE001
+                exception = exc
+            if exception is not None:
+                if isinstance(exception, KeyError):  # if the lookup failed replace - else keep
+                    default = settings["default"]
+                    if default is not None:
+                        return default
+                    # we cannot raise here as that would mean users could not write factorials:
+                    #   depends = {py39,py38}-{,b}
+                else:
+                    raise exception
+        return None
 
-    def _replace_match(self, value: MatchExpression) -> str:
-        # use a copy of conf_args so any changes from this replacement do NOT, affect adjacent substitutions (#2869)
-        conf_args = self.conf_args.copy()
-        flattened_args = [self.join(arg) for arg in value.expr]
-        of_type, *args = flattened_args
-        if of_type == "/":
-            replace_value: str | None = os.sep
-        elif not of_type and args == [""]:
-            replace_value = os.pathsep
-        elif of_type == "env":
-            replace_value = replace_env(self.conf, args, conf_args)
-        elif of_type == "tty":
-            replace_value = replace_tty(args)
-        elif of_type == "posargs":
-            replace_value = replace_pos_args(self.conf, args, conf_args)
-        else:
-            arg_value = ARG_DELIMITER.join(flattened_args)
-            replace_value = replace_reference(self.conf, self.loader, arg_value, conf_args)
-        if replace_value is not None:
-            needs_expansion = any(isinstance(m, MatchExpression) for m in find_replace_expr(replace_value))
-            if needs_expansion:
-                try:
-                    return replace(self.conf, self.loader, replace_value, conf_args, self.depth + 1)
-                except MatchRecursionError as err:
-                    LOGGER.warning(str(err))
-                    return replace_value
-            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)
+    def _config_value_sources(
+        self, env: str | None, section: str | None, current_env: str | None
+    ) -> Iterator[SectionProxy | ConfigSet]:
+        # if we have an env name specified take only from there
+        if env is not None and env in self.conf:
+            yield self.conf.get_env(env)
+
+        if section is None:
+            # if no section specified perhaps it's an unregistered config:
+            # 1. try first from core conf
+            yield self.conf.core
+            # 2. and then fallback to our own environment
+            if current_env is not None:
+                yield self.conf.get_env(current_env)
+            return
+
+        # if there's a section, special handle the core section
+        if section == self.loader.core_section.name:
+            yield self.conf.core  # try via registered configs
+        value = self.loader.get_section(section)  # fallback to section
+        if value is not None:
+            yield value
 
 
 @lru_cache(maxsize=None)
@@ -233,122 +95,6 @@ def _replace_ref(env: str | None) -> Pattern[str]:
     )
 
 
-def replace_reference(  # noqa: C901
-    conf: Config,
-    loader: IniLoader,
-    value: str,
-    conf_args: ConfigLoadArgs,
-) -> str | None:
-    # a return value of None indicates could not replace
-    pattern = _replace_ref(loader.section.prefix or loader.section.name)
-    match = pattern.match(value)
-    if match:
-        settings = match.groupdict()
-
-        key = settings["key"]
-        if settings["section"] is None and settings["full_env"]:
-            settings["section"] = settings["full_env"]
-
-        exception: Exception | None = None
-        try:
-            for src in _config_value_sources(settings["env"], settings["section"], conf_args.env_name, conf, loader):
-                try:
-                    if isinstance(src, SectionProxy):
-                        return loader.process_raw(conf, conf_args.env_name, src[key])
-                    value = src.load(key, conf_args.chain)
-                except KeyError as exc:  # if fails, keep trying maybe another source can satisfy  # noqa: PERF203
-                    exception = exc
-                else:
-                    as_str, _ = stringify(value)
-                    return as_str.replace("#", r"\#")  # escape comment characters as these will be stripped
-        except Exception as exc:  # noqa: BLE001
-            exception = exc
-        if exception is not None:
-            if isinstance(exception, KeyError):  # if the lookup failed replace - else keep
-                default = settings["default"]
-                if default is not None:
-                    return default
-                # we cannot raise here as that would mean users could not write factorials: depends = {py39,py38}-{,b}
-            else:
-                raise exception
-    return None
-
-
-def _config_value_sources(
-    env: str | None,
-    section: str | None,
-    current_env: str | None,
-    conf: Config,
-    loader: IniLoader,
-) -> Iterator[SectionProxy | ConfigSet]:
-    # if we have an env name specified take only from there
-    if env is not None and env in conf:
-        yield conf.get_env(env)
-
-    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
-
-    # if there's a section, special handle the core section
-    if section == loader.core_section.name:
-        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(conf: Config, args: list[str], conf_args: ConfigLoadArgs) -> str:
-    to_path: Path | None = None
-    if conf_args.env_name is not None:  # pragma: no branch
-        env_conf = conf.get_env(conf_args.env_name)
-        try:
-            if env_conf["args_are_paths"]:  # pragma: no branch
-                to_path = env_conf["change_dir"]
-        except KeyError:
-            pass
-    pos_args = conf.pos_args(to_path)
-    # if we use the defaults join back remaining args else take shell cmd
-    return ARG_DELIMITER.join(args) if pos_args is None else shell_cmd(pos_args)
-
-
-def replace_env(conf: Config, args: list[str], conf_args: ConfigLoadArgs) -> str:
-    if not args or not args[0]:
-        msg = "No variable name was supplied in {env} substitution"
-        raise MatchError(msg)
-    key = args[0]
-    new_key = f"env:{key}"
-
-    if conf_args.env_name is not None:  # on core no set env support # pragma: no branch
-        if new_key not in conf_args.chain:  # check if set env
-            conf_args.chain.append(new_key)
-            env_conf = conf.get_env(conf_args.env_name)
-            set_env: SetEnv = env_conf["set_env"]
-            if key in set_env:
-                return set_env.load(key, conf_args)
-        elif conf_args.chain[-1] != new_key:  # if there's a chain but only self-refers than use os.environ
-            circular = ", ".join(i[4:] for i in conf_args.chain[conf_args.chain.index(new_key) :])
-            msg = f"circular chain between set env {circular}"
-            raise MatchRecursionError(msg)
-
-    if key in os.environ:
-        return os.environ[key]
-
-    return "" if len(args) == 1 else ARG_DELIMITER.join(args[1:])
-
-
-def replace_tty(args: list[str]) -> str:
-    return (args[0] if len(args) > 0 else "") if sys.stdout.isatty() else args[1] if len(args) > 1 else ""
-
-
-__all__ = (
-    "MatchArg",
-    "MatchError",
-    "MatchExpression",
-    "find_replace_expr",
-    "replace",
-)
+__all__ = [
+    "ReplaceReferenceIni",
+]
diff --git a/src/tox/config/loader/replacer.py b/src/tox/config/loader/replacer.py
new file mode 100644
index 00000000..05e3c060
--- /dev/null
+++ b/src/tox/config/loader/replacer.py
@@ -0,0 +1,297 @@
+"""Apply value substitution (replacement) on tox strings."""
+
+from __future__ import annotations
+
+import logging
+import os
+import sys
+from abc import ABC, abstractmethod
+from typing import TYPE_CHECKING, Any, Final, Sequence, Union
+
+from tox.config.of_type import CircularChainError
+from tox.execute.request import shell_cmd
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+    from tox.config.loader.api import ConfigLoadArgs
+    from tox.config.main import Config
+    from tox.config.set_env import SetEnv
+
+
+LOGGER = logging.getLogger(__name__)
+
+# split alongside :, unless it is preceded by a single capital letter (Windows drive letters in paths)
+ARG_DELIMITER: Final[str] = ":"
+REPLACE_START: Final[str] = "{"
+REPLACE_END: Final[str] = "}"
+BACKSLASH_ESCAPE_CHARS: Final[tuple[str, ...]] = (ARG_DELIMITER, REPLACE_START, REPLACE_END, "[", "]")
+MAX_REPLACE_DEPTH: Final[int] = 100
+
+
+class MatchRecursionError(ValueError):
+    """Could not stabilize on replacement value."""
+
+    @staticmethod
+    def check(depth: int, value: Any) -> None:
+        if depth > MAX_REPLACE_DEPTH:
+            msg = f"Could not expand {value} after recursing {depth} frames"
+            raise MatchRecursionError(msg)
+
+
+class MatchError(Exception):
+    """Couldn't find end terminator in MatchExpression."""
+
+
+class ReplaceReference(ABC):
+    @abstractmethod
+    def __call__(self, value: str, conf_args: ConfigLoadArgs) -> str | None:
+        """
+        Perform a reference replacement.
+
+        :param value: the raw value
+        :param conf_args: the configuration loads argument object
+        :return: the replaced value, None if it can't do it.
+        """
+        raise NotImplementedError
+
+
+MatchArg = Sequence[Union[str, "MatchExpression"]]
+
+
+def find_replace_expr(value: str) -> MatchArg:
+    """Find all replaceable tokens within value."""
+    return MatchExpression.parse_and_split_to_terminator(value)[0][0]
+
+
+def replace(conf: Config, reference: ReplaceReference, value: str, args: ConfigLoadArgs, depth: int = 0) -> str:
+    """Replace all active tokens within value according to the config."""
+    MatchRecursionError.check(depth, value)
+    return Replacer(conf, reference, conf_args=args, depth=depth).join(find_replace_expr(value))
+
+
+class MatchExpression:  # noqa: PLW1641
+    """An expression that is handled specially by the Replacer."""
+
+    def __init__(self, expr: Sequence[MatchArg], term_pos: int | None = 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: object) -> 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:
+            # didn't 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] == "\\":
+                if 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
+                if value[pos + 1] == "\\":
+                    # backlash doesn't escape a backslash, but does prevent it from affecting the next char
+                    # a subsequent `shlex` pass will eat the double backslash during command splitting.
+                    last_arg.append(value[pos : pos + 2])
+                    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:
+                msg = f"{terminator!r} remains unmatched in {value!r}"
+                raise MatchError(msg)
+        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, reference: ReplaceReference, conf_args: ConfigLoadArgs, depth: int = 0) -> None:
+        self.conf = conf
+        self.reference = reference
+        self.conf_args = conf_args
+        self.depth = depth
+
+    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:
+        # use a copy of conf_args so any changes from this replacement don't, affect adjacent substitutions (#2869)
+        conf_args = self.conf_args.copy()
+        flattened_args = [self.join(arg) for arg in value.expr]
+        of_type, *args = flattened_args
+        if of_type == "/":
+            replace_value: str | None = os.sep
+        elif not of_type and args == [""]:
+            replace_value = os.pathsep
+        elif of_type == "env":
+            replace_value = replace_env(self.conf, args, conf_args)
+        elif of_type == "tty":
+            replace_value = replace_tty(args)
+        elif of_type == "posargs":
+            replace_value = replace_pos_args(self.conf, args, conf_args)
+        else:
+            arg_value = ARG_DELIMITER.join(flattened_args)
+            replace_value = self.reference(arg_value, conf_args)
+        if replace_value is not None:
+            needs_expansion = any(isinstance(m, MatchExpression) for m in find_replace_expr(replace_value))
+            if needs_expansion:
+                try:
+                    return replace(self.conf, self.reference, replace_value, conf_args, self.depth + 1)
+                except MatchRecursionError as err:
+                    LOGGER.warning(str(err))
+                    return replace_value
+            return replace_value
+        # else: fall through -- when replacement is impossible, treat `{` as if escaped.
+        #     If we can't replace, keep what was there, and continue looking for additional replaces
+        #     NOTE: can't raise because the content may be a factorial expression where we don't
+        #           want to enforce escaping curly braces, for example`env_list = {py39,py38}-{,dep}` should work
+        return f"{REPLACE_START}%s{REPLACE_END}" % ARG_DELIMITER.join(flattened_args)
+
+
+def replace_pos_args(conf: Config, args: list[str], conf_args: ConfigLoadArgs) -> str:
+    pos_args = load_posargs(conf, conf_args)
+    # if we use the defaults, join back remaining args else take shell cmd.
+    return ARG_DELIMITER.join(args) if pos_args is None else shell_cmd(pos_args)
+
+
+def load_posargs(conf: Config, conf_args: ConfigLoadArgs) -> tuple[str, ...] | None:
+    to_path: Path | None = None
+    if conf_args.env_name is not None:  # pragma: no branch
+        env_conf = conf.get_env(conf_args.env_name)
+        try:
+            if env_conf["args_are_paths"]:  # pragma: no branch
+                to_path = env_conf["change_dir"]
+        except KeyError:
+            pass
+    return conf.pos_args(to_path)
+
+
+def replace_env(conf: Config | None, args: list[str], conf_args: ConfigLoadArgs) -> str:
+    if not args or not args[0]:
+        msg = "No variable name was supplied in {env} substitution"
+        raise MatchError(msg)
+    key = args[0]
+    new_key = f"env:{key}"
+
+    if conf is not None and conf_args.env_name is not None:  # on core no set env support # pragma: no branch
+        if new_key not in conf_args.chain:  # check if set env
+            conf_args.chain.append(new_key)
+            env_conf = conf.get_env(conf_args.env_name)
+            try:
+                set_env: SetEnv = env_conf.load("set_env", chain=conf_args.chain)
+            except CircularChainError:
+                if not (
+                    conf_args.chain[-1].endswith(".set_env")
+                    and any(i.endswith(".set_env") for i in conf_args.chain[:-1])
+                ):  # pragma: no branch
+                    raise
+            else:
+                if key in set_env:
+                    return set_env.load(key, conf_args)
+        elif conf_args.chain[-1] != new_key:  # if there's a chain but only self-refers than use os.environ
+            circular = ", ".join(i[4:] for i in conf_args.chain[conf_args.chain.index(new_key) :])
+            msg = f"circular chain between set env {circular}"
+            raise MatchRecursionError(msg)
+
+    if key in os.environ:
+        return os.environ[key]
+
+    return "" if len(args) == 1 else ARG_DELIMITER.join(args[1:])
+
+
+def replace_tty(args: list[str]) -> str:
+    return (args[0] if len(args) > 0 else "") if sys.stdout.isatty() else args[1] if len(args) > 1 else ""
+
+
+__all__ = [
+    "MatchExpression",
+    "MatchRecursionError",
+    "find_replace_expr",
+    "load_posargs",
+    "replace",
+    "replace_env",
+]
diff --git a/src/tox/config/loader/stringify.py b/src/tox/config/loader/stringify.py
index 2ff026df..94d7d96a 100644
--- a/src/tox/config/loader/stringify.py
+++ b/src/tox/config/loader/stringify.py
@@ -21,8 +21,10 @@ def stringify(value: Any) -> tuple[str, bool]:  # noqa: PLR0911
         return str(value), False
     if isinstance(value, Mapping):
         return "\n".join(f"{stringify(k)[0]}={stringify(v)[0]}" for k, v in value.items()), True
-    if isinstance(value, (Sequence, Set)):
+    if isinstance(value, Sequence):
         return "\n".join(stringify(i)[0] for i in value), True
+    if isinstance(value, Set):  # sort it to make it stable
+        return "\n".join(sorted(stringify(i)[0] for i in value)), True
     if isinstance(value, EnvList):
         return "\n".join(e for e in value.envs), True
     if isinstance(value, Command):
diff --git a/src/tox/config/loader/toml/__init__.py b/src/tox/config/loader/toml/__init__.py
new file mode 100644
index 00000000..4cc8a86d
--- /dev/null
+++ b/src/tox/config/loader/toml/__init__.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import TYPE_CHECKING, Dict, Iterator, List, Mapping, TypeVar, cast
+
+from tox.config.loader.api import ConfigLoadArgs, Loader, Override
+from tox.config.types import Command, EnvList
+
+from ._api import TomlTypes
+from ._replace import Unroll
+from ._validate import validate
+
+if TYPE_CHECKING:
+    from tox.config.loader.convert import Factory
+    from tox.config.loader.section import Section
+    from tox.config.main import Config
+
+_T = TypeVar("_T")
+_V = TypeVar("_V")
+
+
+class TomlLoader(Loader[TomlTypes]):
+    """Load configuration from a pyproject.toml file."""
+
+    def __init__(
+        self,
+        section: Section,
+        overrides: list[Override],
+        content: Mapping[str, TomlTypes],
+        root_content: Mapping[str, TomlTypes],
+        unused_exclude: set[str],
+    ) -> None:
+        self.content = content
+        self._root_content = root_content
+        self._unused_exclude = unused_exclude
+        super().__init__(section, overrides)
+
+    def __repr__(self) -> str:
+        return f"{self.__class__.__name__}({self.section.name}, {self.content!r})"
+
+    def load_raw(self, key: str, conf: Config | None, env_name: str | None) -> TomlTypes:  # noqa: ARG002
+        return self.content[key]
+
+    def load_raw_from_root(self, path: str) -> TomlTypes:
+        current = cast(TomlTypes, self._root_content)
+        for key in path.split(self.section.SEP):
+            if isinstance(current, dict):
+                current = current[key]
+            else:
+                msg = f"Failed to load key {key} as not dictionary {current!r}"
+                logging.warning(msg)
+                raise KeyError(msg)
+        return current
+
+    def build(  # noqa: PLR0913
+        self,
+        key: str,  # noqa: ARG002
+        of_type: type[_T],
+        factory: Factory[_T],
+        conf: Config | None,
+        raw: TomlTypes,
+        args: ConfigLoadArgs,
+    ) -> _T:
+        exploded = Unroll(conf=conf, loader=self, args=args)(raw)
+        return self.to(exploded, of_type, factory)
+
+    def found_keys(self) -> set[str]:
+        return set(self.content.keys()) - self._unused_exclude
+
+    @staticmethod
+    def to_str(value: TomlTypes) -> str:
+        return validate(value, str)  # type: ignore[return-value] # no mypy support
+
+    @staticmethod
+    def to_bool(value: TomlTypes) -> bool:
+        return validate(value, bool)
+
+    @staticmethod
+    def to_list(value: TomlTypes, of_type: type[_T]) -> Iterator[_T]:
+        of = List[of_type]  # type: ignore[valid-type] # no mypy support
+        return iter(validate(value, of))  # type: ignore[call-overload,no-any-return]
+
+    @staticmethod
+    def to_set(value: TomlTypes, of_type: type[_T]) -> Iterator[_T]:
+        of = List[of_type]  # type: ignore[valid-type] # no mypy support
+        return iter(validate(value, of))  # type: ignore[call-overload,no-any-return]
+
+    @staticmethod
+    def to_dict(value: TomlTypes, of_type: tuple[type[_T], type[_V]]) -> Iterator[tuple[_T, _V]]:
+        of = Dict[of_type[0], of_type[1]]  # type: ignore[valid-type] # no mypy support
+        return validate(value, of).items()  # type: ignore[attr-defined,no-any-return]
+
+    @staticmethod
+    def to_path(value: TomlTypes) -> Path:
+        return Path(TomlLoader.to_str(value))
+
+    @staticmethod
+    def to_command(value: TomlTypes) -> Command:
+        return Command(args=cast(List[str], value))  # validated during load in _ensure_type_correct
+
+    @staticmethod
+    def to_env_list(value: TomlTypes) -> EnvList:
+        return EnvList(envs=list(TomlLoader.to_list(value, str)))
+
+
+__all__ = [
+    "TomlLoader",
+]
diff --git a/src/tox/config/loader/toml/_api.py b/src/tox/config/loader/toml/_api.py
new file mode 100644
index 00000000..9a9596df
--- /dev/null
+++ b/src/tox/config/loader/toml/_api.py
@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Dict, List, Union
+
+if TYPE_CHECKING:
+    import sys
+
+    if sys.version_info >= (3, 10):  # pragma: no cover (py310+)
+        from typing import TypeAlias
+    else:  # pragma: no cover (py310+)
+        from typing_extensions import TypeAlias
+
+TomlTypes: TypeAlias = Union[Dict[str, "TomlTypes"], List["TomlTypes"], str, int, float, bool, None]
+
+__all__ = [
+    "TomlTypes",
+]
diff --git a/src/tox/config/loader/toml/_replace.py b/src/tox/config/loader/toml/_replace.py
new file mode 100644
index 00000000..62470359
--- /dev/null
+++ b/src/tox/config/loader/toml/_replace.py
@@ -0,0 +1,147 @@
+from __future__ import annotations
+
+import re
+from typing import TYPE_CHECKING, Any, Iterator, List, cast
+
+from tox.config.loader.replacer import MatchRecursionError, ReplaceReference, load_posargs, replace, replace_env
+from tox.config.loader.stringify import stringify
+
+from ._api import TomlTypes
+from ._validate import validate
+
+if TYPE_CHECKING:
+    from tox.config.loader.api import ConfigLoadArgs
+    from tox.config.loader.toml import TomlLoader
+    from tox.config.main import Config
+    from tox.config.sets import ConfigSet
+    from tox.config.source.toml_pyproject import TomlSection
+
+
+class Unroll:
+    def __init__(self, conf: Config | None, loader: TomlLoader, args: ConfigLoadArgs) -> None:
+        self.conf = conf
+        self.loader = loader
+        self.args = args
+
+    def __call__(self, value: TomlTypes, depth: int = 0) -> TomlTypes:  # noqa: C901, PLR0912
+        """Replace all active tokens within value according to the config."""
+        depth += 1
+        MatchRecursionError.check(depth, value)
+        if isinstance(value, str):
+            if self.conf is not None:  # core config does not support string substitution
+                reference = TomlReplaceLoader(self.conf, self.loader)
+                value = replace(self.conf, reference, value, self.args)
+        elif isinstance(value, (int, float, bool)):
+            pass  # no reference or substitution possible
+        elif isinstance(value, list):
+            # need to inspect every entry of the list to check for reference.
+            res_list: list[TomlTypes] = []
+            for val in value:  # apply replacement for every entry
+                got = self(val, depth)
+                if isinstance(val, dict) and val.get("replace") in {"posargs", "ref"} and isinstance(got, (list, set)):
+                    res_list.extend(got)
+                else:
+                    res_list.append(got)
+            value = res_list
+        elif isinstance(value, dict):
+            # need to inspect every entry of the list to check for reference.
+            if replace_type := value.get("replace"):
+                if replace_type == "posargs" and self.conf is not None:
+                    got_posargs = load_posargs(self.conf, self.args)
+                    return (
+                        [self(v, depth) for v in cast(List[str], value.get("default", []))]
+                        if got_posargs is None
+                        else list(got_posargs)
+                    )
+                if replace_type == "env":
+                    return replace_env(
+                        self.conf,
+                        [
+                            cast(str, validate(value["name"], str)),
+                            cast(str, validate(self(value.get("default", ""), depth), str)),
+                        ],
+                        self.args,
+                    )
+                if replace_type == "ref":  # pragma: no branch
+                    if of := value.get("raw"):
+                        validated_of = cast(List[str], validate(of, List[str]))
+                        return self.loader.load_raw_from_root(self.loader.section.SEP.join(validated_of))
+                    if self.conf is not None:  # pragma: no branch # noqa: SIM102
+                        if (env := value.get("env")) and (key := value.get("key")):  # pragma: no branch
+                            return cast(TomlTypes, self.conf.get_env(cast(str, env))[cast(str, key)])
+            res_dict: dict[str, TomlTypes] = {}
+            for key, val in value.items():  # apply replacement for every entry
+                res_dict[key] = self(val, depth)
+            value = res_dict
+        return value
+
+
+_REFERENCE_PATTERN = re.compile(
+    r"""
+    (\[(?P<section>.*)])? # default value
+    (?P<key>[-a-zA-Z0-9_]+) # key
+    (:(?P<default>.*))? # default value
+    $
+""",
+    re.VERBOSE,
+)
+
+
+class TomlReplaceLoader(ReplaceReference):
+    def __init__(self, conf: Config, loader: TomlLoader) -> None:
+        self.conf = conf
+        self.loader = loader
+
+    def __call__(self, value: str, conf_args: ConfigLoadArgs) -> str | None:
+        if match := _REFERENCE_PATTERN.search(value):
+            settings = match.groupdict()
+            exception: Exception | None = None
+            try:
+                for src in self._config_value_sources(settings["section"], conf_args.env_name):
+                    try:
+                        value = src.load(settings["key"], conf_args.chain)
+                    except KeyError as exc:  # if fails, keep trying maybe another source can satisfy # noqa: PERF203
+                        exception = exc
+                    else:
+                        return stringify(value)[0]
+            except Exception as exc:  # noqa: BLE001
+                exception = exc
+            if exception is not None:
+                if isinstance(exception, KeyError):  # if the lookup failed replace - else keep
+                    default = settings["default"]
+                    if default is not None:
+                        return default
+                raise exception
+        return value
+
+    def _config_value_sources(self, sec: str | None, current_env: str | None) -> Iterator[ConfigSet | RawLoader]:
+        if sec is None:
+            if current_env is not None:  # pragma: no branch
+                yield self.conf.get_env(current_env)
+            yield self.conf.core
+            return
+
+        section: TomlSection = self.loader.section  # type: ignore[assignment]
+        core_prefix = section.core_prefix()
+        env_prefix = section.env_prefix()
+        if sec.startswith(env_prefix):
+            env = sec[len(env_prefix) + len(section.SEP) :]
+            yield self.conf.get_env(env)
+        else:
+            yield RawLoader(self.loader, sec)
+        if sec == core_prefix:
+            yield self.conf.core  # try via registered configs
+
+
+class RawLoader:
+    def __init__(self, loader: TomlLoader, section: str) -> None:
+        self._loader = loader
+        self._section = section
+
+    def load(self, item: str, chain: list[str] | None = None) -> Any:  # noqa: ARG002
+        return self._loader.load_raw_from_root(f"{self._section}{self._loader.section.SEP}{item}")
+
+
+__all__ = [
+    "Unroll",
+]
diff --git a/src/tox/config/loader/toml/_validate.py b/src/tox/config/loader/toml/_validate.py
new file mode 100644
index 00000000..590d3557
--- /dev/null
+++ b/src/tox/config/loader/toml/_validate.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+from inspect import isclass
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Dict,
+    List,
+    Literal,
+    TypeVar,
+    Union,
+    cast,
+)
+
+from tox.config.types import Command
+
+if TYPE_CHECKING:
+    import sys
+
+    from ._api import TomlTypes
+
+    if sys.version_info >= (3, 11):  # pragma: no cover (py311+)
+        from typing import TypeGuard
+    else:  # pragma: no cover (py311+)
+        from typing_extensions import TypeGuard
+
+T = TypeVar("T")
+
+
+def validate(val: TomlTypes, of_type: type[T]) -> TypeGuard[T]:  # noqa: C901, PLR0912
+    casting_to = getattr(of_type, "__origin__", of_type.__class__)
+    msg = ""
+    if casting_to in {list, List}:
+        entry_type = of_type.__args__[0]  # type: ignore[attr-defined]
+        if isinstance(val, list):
+            for va in val:
+                validate(va, entry_type)
+        else:
+            msg = f"{val!r} is not list"
+    elif isclass(of_type) and issubclass(of_type, Command):
+        # first we cast it to list then create commands, so for now validate it as a nested list
+        validate(val, List[str])
+    elif casting_to in {dict, Dict}:
+        key_type, value_type = of_type.__args__[0], of_type.__args__[1]  # type: ignore[attr-defined]
+        if isinstance(val, dict):
+            for va in val:
+                validate(va, key_type)
+            for va in val.values():
+                validate(va, value_type)
+        else:
+            msg = f"{val!r} is not dictionary"
+    elif casting_to == Union:  # handle Optional values
+        args: list[type[Any]] = of_type.__args__  # type: ignore[attr-defined]
+        for arg in args:
+            try:
+                validate(val, arg)
+                break
+            except TypeError:
+                pass
+        else:
+            msg = f"{val!r} is not union of {', '.join(a.__name__ for a in args)}"
+    elif casting_to in {Literal, type(Literal)}:
+        choice = of_type.__args__  # type: ignore[attr-defined]
+        if val not in choice:
+            msg = f"{val!r} is not one of literal {','.join(repr(i) for i in choice)}"
+    elif not isinstance(val, of_type):
+        msg = f"{val!r} is not of type {of_type.__name__!r}"
+    if msg:
+        raise TypeError(msg)
+    return cast(T, val)  # type: ignore[return-value] # logic too complicated for mypy
+
+
+__all__ = [
+    "validate",
+]
diff --git a/src/tox/config/of_type.py b/src/tox/config/of_type.py
index 27752ef0..fd978bb4 100644
--- a/src/tox/config/of_type.py
+++ b/src/tox/config/of_type.py
@@ -13,6 +13,10 @@ if TYPE_CHECKING:
     from tox.config.main import Config  # pragma: no cover
 
 
+class CircularChainError(ValueError):
+    """circular chain in config"""
+
+
 T = TypeVar("T")
 V = TypeVar("V")
 
@@ -94,11 +98,13 @@ class ConfigDynamicDefinition(ConfigDefinition[T]):  # noqa: PLW1641
         if self._cache is _PLACE_HOLDER:
             for key, loader in product(self.keys, loaders):
                 chain_key = f"{loader.section.key}.{key}"
-                if chain_key in args.chain:
-                    values = args.chain[args.chain.index(chain_key) :]
-                    msg = f"circular chain detected {', '.join(values)}"
-                    raise ValueError(msg)
-                args.chain.append(chain_key)
+                try:
+                    if chain_key in args.chain:
+                        values = args.chain[args.chain.index(chain_key) :]
+                        msg = f"circular chain detected {', '.join(values)}"
+                        raise CircularChainError(msg)
+                finally:
+                    args.chain.append(chain_key)
                 try:
                     value = loader.load(key, self.of_type, self.factory, conf, args)
                 except KeyError:
diff --git a/src/tox/config/set_env.py b/src/tox/config/set_env.py
index 47fb6223..e3f012ff 100644
--- a/src/tox/config/set_env.py
+++ b/src/tox/config/set_env.py
@@ -10,7 +10,7 @@ Replacer = Callable[[str, ConfigLoadArgs], str]
 
 
 class SetEnv:
-    def __init__(self, raw: str, name: str, env_name: str | None, root: Path) -> None:
+    def __init__(self, raw: str | dict[str, str], name: str, env_name: str | None, root: Path) -> None:
         self.changed = False
         self._materialized: dict[str, str] = {}  # env vars we already loaded
         self._raw: dict[str, str] = {}  # could still need replacement
@@ -18,8 +18,11 @@ class SetEnv:
         self._env_files: list[str] = []
         self._replacer: Replacer = lambda s, c: s  # noqa: ARG005
         self._name, self._env_name, self._root = name, env_name, root
-        from .loader.ini.replace import MatchExpression, find_replace_expr  # noqa: PLC0415
+        from .loader.replacer import MatchExpression, find_replace_expr  # noqa: PLC0415
 
+        if isinstance(raw, dict):
+            self._raw = raw
+            return
         for line in raw.splitlines():  # noqa: PLR1702
             if line.strip():
                 if line.startswith("file|"):
diff --git a/src/tox/config/sets.py b/src/tox/config/sets.py
index ebb46be6..5182043e 100644
--- a/src/tox/config/sets.py
+++ b/src/tox/config/sets.py
@@ -232,7 +232,10 @@ class EnvConfigSet(ConfigSet):
             return values
 
         def set_env_factory(raw: object) -> SetEnv:
-            if not isinstance(raw, str):
+            if not (
+                isinstance(raw, str)
+                or (isinstance(raw, dict) and all(isinstance(k, str) and isinstance(v, str) for k, v in raw.items()))
+            ):
                 raise TypeError(raw)
             return SetEnv(raw, self.name, self.env_name, root)
 
diff --git a/src/tox/config/source/api.py b/src/tox/config/source/api.py
index 068e7c5c..b8b703e6 100644
--- a/src/tox/config/source/api.py
+++ b/src/tox/config/source/api.py
@@ -23,6 +23,9 @@ class Source(ABC):
         self.path: Path = path  #: the path to the configuration source
         self._section_to_loaders: dict[str, list[Loader[Any]]] = {}
 
+    def __repr__(self) -> str:
+        return f"{self.__class__.__name__}(path={self.path})"
+
     def get_loaders(
         self,
         section: Section,
diff --git a/src/tox/config/source/discover.py b/src/tox/config/source/discover.py
index 7fcac23c..60cd2a83 100644
--- a/src/tox/config/source/discover.py
+++ b/src/tox/config/source/discover.py
@@ -9,12 +9,20 @@ from tox.report import HandledError
 
 from .legacy_toml import LegacyToml
 from .setup_cfg import SetupCfg
+from .toml_pyproject import TomlPyProject
+from .toml_tox import TomlTox
 from .tox_ini import ToxIni
 
 if TYPE_CHECKING:
     from .api import Source
 
-SOURCE_TYPES: tuple[type[Source], ...] = (ToxIni, SetupCfg, LegacyToml)
+SOURCE_TYPES: tuple[type[Source], ...] = (
+    ToxIni,
+    SetupCfg,
+    LegacyToml,
+    TomlPyProject,
+    TomlTox,
+)
 
 
 def discover_source(config_file: Path | None, root_dir: Path | None) -> Source:
@@ -79,7 +87,8 @@ def _create_default_source(root_dir: Path | None) -> Source:
                 break
     else:  # if not set use where we find pyproject.toml in the tree or cwd
         empty = root_dir
-    logging.warning("No %s found, assuming empty tox.ini at %s", " or ".join(i.FILENAME for i in SOURCE_TYPES), empty)
+    names = " or ".join({i.FILENAME: None for i in SOURCE_TYPES})
+    logging.warning("No %s found, assuming empty tox.ini at %s", names, empty)
     return ToxIni(empty / "tox.ini", content="")
 
 
diff --git a/src/tox/config/source/ini.py b/src/tox/config/source/ini.py
index 0d4f9840..90dd40f5 100644
--- a/src/tox/config/source/ini.py
+++ b/src/tox/config/source/ini.py
@@ -107,9 +107,6 @@ class IniSource(Source):
                 if set(env.split("-")) - 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/toml_pyproject.py b/src/tox/config/source/toml_pyproject.py
new file mode 100644
index 00000000..bf0ac0cb
--- /dev/null
+++ b/src/tox/config/source/toml_pyproject.py
@@ -0,0 +1,132 @@
+"""Load from a pyproject.toml file, native format."""
+
+from __future__ import annotations
+
+import sys
+from typing import TYPE_CHECKING, Any, Final, Iterator, Mapping, cast
+
+from tox.config.loader.section import Section
+from tox.config.loader.toml import TomlLoader
+from tox.report import HandledError
+
+from .api import Source
+
+if sys.version_info >= (3, 11):  # pragma: no cover (py311+)
+    import tomllib
+else:  # pragma: no cover (py311+)
+    import tomli as tomllib
+
+if TYPE_CHECKING:
+    from collections.abc import Iterable
+    from pathlib import Path
+
+    from tox.config.loader.api import Loader, OverrideMap
+    from tox.config.sets import CoreConfigSet
+
+
+class TomlSection(Section):
+    SEP: str = "."
+    PREFIX: tuple[str, ...]
+    ENV: Final[str] = "env"
+    RUN_ENV_BASE: Final[str] = "env_run_base"
+    PKG_ENV_BASE: Final[str] = "env_pkg_base"
+
+    @classmethod
+    def test_env(cls, name: str) -> TomlSection:
+        return cls(cls.env_prefix(), name)
+
+    @classmethod
+    def env_prefix(cls) -> str:
+        return cls.SEP.join((*cls.PREFIX, cls.ENV))
+
+    @classmethod
+    def core_prefix(cls) -> str:
+        return cls.SEP.join(cls.PREFIX)
+
+    @classmethod
+    def package_env_base(cls) -> str:
+        return cls.SEP.join((*cls.PREFIX, cls.PKG_ENV_BASE))
+
+    @classmethod
+    def run_env_base(cls) -> str:
+        return cls.SEP.join((*cls.PREFIX, cls.RUN_ENV_BASE))
+
+    @property
+    def keys(self) -> Iterable[str]:
+        key = self.key
+        keys = key.split(self.SEP) if self.key else []
+        if self.PREFIX and len(keys) >= len(self.PREFIX) and tuple(keys[: len(self.PREFIX)]) == self.PREFIX:
+            keys = keys[len(self.PREFIX) :]
+        return keys
+
+
+class TomlPyProjectSection(TomlSection):
+    PREFIX = ("tool", "tox")
+
+
+class TomlPyProject(Source):
+    """Configuration sourced from a pyproject.toml files."""
+
+    FILENAME = "pyproject.toml"
+    _Section: type[TomlSection] = TomlPyProjectSection
+
+    def __init__(self, path: Path) -> None:
+        if path.name != self.FILENAME or not path.exists():
+            raise ValueError
+        with path.open("rb") as file_handler:
+            self._content = tomllib.load(file_handler)
+        try:
+            our_content: Mapping[str, Any] = self._content
+            for key in self._Section.PREFIX:
+                our_content = our_content[key]
+            self._our_content = our_content
+        except KeyError as exc:
+            raise ValueError(path) from exc
+        super().__init__(path)
+
+    def get_core_section(self) -> Section:
+        return self._Section(prefix=None, name="")
+
+    def transform_section(self, section: Section) -> Section:
+        return self._Section(section.prefix, section.name)
+
+    def get_loader(self, section: Section, override_map: OverrideMap) -> Loader[Any] | None:
+        current = self._our_content
+        sec = cast(TomlSection, section)
+        for key in sec.keys:
+            if key in current:
+                current = current[key]
+            else:
+                return None
+        if not isinstance(current, Mapping):
+            msg = f"{sec.key} must be a table, is {current.__class__.__name__!r}"
+            raise HandledError(msg)
+        return TomlLoader(
+            section=section,
+            overrides=override_map.get(section.key, []),
+            content=current,
+            root_content=self._content,
+            unused_exclude={sec.ENV, sec.RUN_ENV_BASE, sec.PKG_ENV_BASE} if section.prefix is None else set(),
+        )
+
+    def envs(self, core_conf: CoreConfigSet) -> Iterator[str]:
+        yield from core_conf["env_list"]
+        yield from [i.key for i in self.sections()]
+
+    def sections(self) -> Iterator[Section]:
+        for env_name in self._our_content.get(self._Section.ENV, {}):
+            if not isinstance(env_name, str):
+                msg = f"Environment key must be string, got {env_name!r}"
+                raise HandledError(msg)
+            yield self._Section.from_key(env_name)
+
+    def get_base_sections(self, base: list[str], in_section: Section) -> Iterator[Section]:  # noqa: ARG002
+        yield from [self._Section.from_key(b) for b in base]
+
+    def get_tox_env_section(self, item: str) -> tuple[Section, list[str], list[str]]:
+        return self._Section.test_env(item), [self._Section.run_env_base()], [self._Section.package_env_base()]
+
+
+__all__ = [
+    "TomlPyProject",
+]
diff --git a/src/tox/config/source/toml_tox.py b/src/tox/config/source/toml_tox.py
new file mode 100644
index 00000000..58930139
--- /dev/null
+++ b/src/tox/config/source/toml_tox.py
@@ -0,0 +1,21 @@
+"""Load from a tox.toml file."""
+
+from __future__ import annotations
+
+from .toml_pyproject import TomlPyProject, TomlSection
+
+
+class TomlToxSection(TomlSection):
+    PREFIX = ()
+
+
+class TomlTox(TomlPyProject):
+    """Configuration sourced from a pyproject.toml files."""
+
+    FILENAME = "tox.toml"
+    _Section = TomlToxSection
+
+
+__all__ = [
+    "TomlTox",
+]
diff --git a/tox.ini b/tox.ini
index 60bd4e6c..89bb29d2 100644
--- a/tox.ini
+++ b/tox.ini
@@ -16,7 +16,7 @@ env_list =
 skip_missing_interpreters = true
 
 [testenv]
-description = run the tests with pytest under {envname}
+description = run the tests with pytest under {env_name}
 package = wheel
 wheel_build_env = .pkg
 extras =
@@ -25,29 +25,28 @@ pass_env =
     PYTEST_*
     SSL_CERT_FILE
 set_env =
-    COVERAGE_FILE = {env:COVERAGE_FILE:{toxworkdir}{/}.coverage.{envname}}
-    COVERAGE_PROCESS_START = {toxinidir}{/}pyproject.toml
+    COVERAGE_FILE = {env:COVERAGE_FILE:{work_dir}{/}.coverage.{env_name}}
+    COVERAGE_PROCESS_START = {tox_root}{/}pyproject.toml
 commands =
     pytest {posargs: \
-      --junitxml {toxworkdir}{/}junit.{envname}.xml --cov {envsitepackagesdir}{/}tox --cov {toxinidir}{/}tests \
-      --cov-config={toxinidir}{/}pyproject.toml --no-cov-on-fail --cov-report term-missing:skip-covered --cov-context=test \
-      --cov-report html:{envtmpdir}{/}htmlcov \
-      --cov-report xml:{toxworkdir}{/}coverage.{envname}.xml \
+      --junitxml {work_dir}{/}junit.{env_name}.xml --cov {env_site_packages_dir}{/}tox --cov {tox_root}{/}tests \
+      --cov-config={tox_root}{/}pyproject.toml --no-cov-on-fail --cov-report term-missing:skip-covered --cov-context=test \
+      --cov-report html:{env_tmp_dir}{/}htmlcov \
+      --cov-report xml:{work_dir}{/}coverage.{env_name}.xml \
       -n={env:PYTEST_XDIST_AUTO_NUM_WORKERS:auto} \
-      tests --durations 5 --run-integration}
-    diff-cover --compare-branch {env:DIFF_AGAINST:origin/main} {toxworkdir}{/}coverage.{envname}.xml
+      tests --durations 15 {env:INTEGRATION:--run-integration}}
+    diff-cover --compare-branch {env:DIFF_AGAINST:origin/main} {work_dir}{/}coverage.{env_name}.xml
 
 [testenv:fix]
 description = format the code base to adhere to our styles, and complain about what we cannot do automatically
 skip_install = true
 deps =
-    pre-commit-uv>=4.1
+    pre-commit-uv>=4.1.3
 pass_env =
-    {[testenv]passenv}
+    {[testenv]pass_env}
     PROGRAMDATA
 commands =
     pre-commit run --all-files --show-diff-on-failure {posargs}
-    python -c 'print(r"hint: run {envbindir}{/}pre-commit install to add checks as pre-commit hook")'
 
 [testenv:type]
 description = run type check on code base
@@ -64,9 +63,9 @@ description = build documentation
 extras =
     docs
 commands =
-    {posargs: sphinx-build -d "{envtmpdir}{/}doctree" docs "{toxworkdir}{/}docs_out" --color -b linkcheck}
-    sphinx-build -d "{envtmpdir}{/}doctree" docs "{toxworkdir}{/}docs_out" --color -b html -W
-    python -c 'print(r"documentation available under file://{toxworkdir}{/}docs_out{/}index.html")'
+    {posargs: sphinx-build -d "{env_tmp_dir}{/}docs_tree" docs "{work_dir}{/}docs_out" --color -b linkcheck}
+    sphinx-build -d "{env_tmp_dir}{/}docs_tree" docs "{work_dir}{/}docs_out" --color -b html -W
+    python -c 'print(r"documentation available under file://{work_dir}{/}docs_out{/}index.html")'
 
 [testenv:pkg_meta]
 description = check that the long description is valid
@@ -74,21 +73,21 @@ skip_install = true
 deps =
     check-wheel-contents>=0.6
     twine>=5.1.1
-    uv>=0.4.10
+    uv>=0.4.17
 commands =
-    uv build --sdist --wheel --out-dir {envtmpdir} .
-    twine check {envtmpdir}{/}*
-    check-wheel-contents --no-config {envtmpdir}
+    uv build --sdist --wheel --out-dir {env_tmp_dir} .
+    twine check {env_tmp_dir}{/}*
+    check-wheel-contents --no-config {env_tmp_dir}
 
 [testenv:release]
-description = do a release, required posarg of the version number
+description = do a release, required posargs of the version number
 skip_install = true
 deps =
     gitpython>=3.1.43
     packaging>=24.1
     towncrier>=24.8
 commands =
-    python {toxinidir}/tasks/release.py --version {posargs}
+    python {tox_root}/tasks/release.py --version {posargs}
 
 [testenv:dev]
 description = dev environment with all deps at {envdir}
@@ -100,5 +99,5 @@ extras =
     testing
 commands =
     python -m pip list --format=columns
-    python -c "print(r'{envpython}')"
+    python -c 'print(r"{env_python}")'
 uv_seed = true
diff --git a/tox.toml b/tox.toml
new file mode 100644
index 00000000..4d07a37b
--- /dev/null
+++ b/tox.toml
@@ -0,0 +1,131 @@
+requires = ["tox>=4.19"]
+env_list = ["fix", "3.13", "3.12", "3.11", "3.10", "3.9", "3.8", "cov", "type", "docs", "pkg_meta"]
+skip_missing_interpreters = true
+
+[env_run_base]
+description = "run the tests with pytest under {env_name}"
+package = "wheel"
+wheel_build_env = ".pkg"
+extras = ["testing"]
+pass_env = ["PYTEST_*", "SSL_CERT_FILE"]
+set_env.COVERAGE_FILE = { replace = "env", name = "COVERAGE_FILE", default = "{work_dir}{/}.coverage.{env_name}" }
+set_env.COVERAGE_FILECOVERAGE_PROCESS_START = "{tox_root}{/}pyproject.toml"
+commands = [
+  [
+    "pytest",
+    { replace = "posargs", default = [
+      "--junitxml",
+      "{work_dir}{/}junit.{env_name}.xml",
+      "--cov",
+      "{env_site_packages_dir}{/}tox",
+      "--cov",
+      "{tox_root}{/}tests",
+      "--cov-config={tox_root}{/}pyproject.toml",
+      "-no-cov-on-fail",
+      "--cov-report",
+      "term-missing:skip-covered",
+      "--cov-context=test",
+      "--cov-report",
+      "html:{env_tmp_dir}{/}htmlcov",
+      "--cov-report",
+      "xml:{work_dir}{/}coverage.{env_name}.xml",
+      "-n",
+      { replace = "env", name = "PYTEST_XDIST_AUTO_NUM_WORKERS", default = "auto" },
+      "tests",
+      "--durations",
+      "15",
+      "--run-integration",
+    ] },
+  ],
+  [
+    "diff-cover",
+    "--compare-branch",
+    { replace = "env", name = "DIFF_AGAINST", default = "origin/main" },
+    "{work_dir}{/}coverage.{env_name}.xml",
+  ],
+]
+
+[env.fix]
+description = "format the code base to adhere to our styles, and complain about what we cannot do automatically"
+skip_install = true
+deps = ["pre-commit-uv>=4.1.3"]
+pass_env = [{ replace = "ref", of = ["env_run_base", "pass_env"] }, "PROGRAMDATA"]
+commands = [["pre-commit", "run", "--all-files", "--show-diff-on-failure", { replace = "posargs" }]]
+
+[env.type]
+description = "run type check on code base"
+deps = ["mypy==1.11.2", "types-cachetools>=5.5.0.20240820", "types-chardet>=5.0.4.6"]
+commands = [["mypy", "src{/}tox"], ["mypy", "tests"]]
+
+[env.docs]
+description = "build documentation"
+extras = ["docs"]
+commands = [
+  { replace = "posargs", default = [
+    "sphinx-build",
+    "-d",
+    "{env_tmp_dir}{/}docs_tree",
+    "docs",
+    "{work_dir}{/}docs_out",
+    "--color",
+    "-b",
+    "linkcheck",
+  ] },
+  [
+    "sphinx-build",
+    "-d",
+    "{env_tmp_dir}{/}docs_tree",
+    "docs",
+    "{work_dir}{/}docs_out",
+    "--color",
+    "-b",
+    "html",
+    "-W",
+  ],
+  [
+    "python",
+    "-c",
+    'print(r"documentation available under file://{work_dir}{/}docs_out{/}index.html")',
+  ],
+]
+
+
+[env.pkg_meta]
+description = "check that the long description is valid"
+skip_install = true
+deps = ["check-wheel-contents>=0.6", "twine>=5.1.1", "uv>=0.4.17"]
+commands = [
+  [
+    "uv",
+    "build",
+    "--sdist",
+    "--wheel",
+    "--out-dir",
+    "{env_tmp_dir}",
+    ".",
+  ],
+  [
+    "twine",
+    "check",
+    "{env_tmp_dir}{/}*",
+  ],
+  [
+    "check-wheel-contents",
+    "--no-config",
+    "{env_tmp_dir}",
+  ],
+]
+
+[env.release]
+description = "do a release, required posargs of the version number"
+skip_install = true
+deps = ["gitpython>=3.1.43", "packaging>=24.1", "towncrier>=24.8"]
+commands = [["python", "{tox_root}/tasks/release.py", "--version", "{posargs}"]]
+
+[env.dev]
+description = "dev environment with all deps at {envdir}"
+package = "editable"
+deps = { replace = "ref", of = ["env", "release", "deps"] }
+extras = ["docs", "testing"]
+commands = [["python", "-m", "pip", "list", "--format=columns"], ["python", "-c", 'print(r"{env_python}")']]
+uv_seed = true


Test patch:
diff --git a/tests/config/loader/ini/replace/conftest.py b/tests/config/loader/conftest.py
similarity index 100%
rename from tests/config/loader/ini/replace/conftest.py
rename to tests/config/loader/conftest.py
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 6c8999f1..a0911ad6 100644
--- a/tests/config/loader/ini/replace/test_replace_env_var.py
+++ b/tests/config/loader/ini/replace/test_replace_env_var.py
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Generator
 import pytest
 
 if TYPE_CHECKING:
-    from tests.config.loader.ini.replace.conftest import ReplaceOne
+    from tests.config.loader.conftest import ReplaceOne
     from tox.pytest import LogCaptureFixture, MonkeyPatch
 
 
diff --git a/tests/config/loader/ini/replace/test_replace_os_pathsep.py b/tests/config/loader/ini/replace/test_replace_os_pathsep.py
index a407fced..7ef7f21e 100644
--- a/tests/config/loader/ini/replace/test_replace_os_pathsep.py
+++ b/tests/config/loader/ini/replace/test_replace_os_pathsep.py
@@ -4,7 +4,7 @@ import os
 from typing import TYPE_CHECKING
 
 if TYPE_CHECKING:
-    from tests.config.loader.ini.replace.conftest import ReplaceOne
+    from tests.config.loader.conftest import ReplaceOne
 
 
 def test_replace_os_pathsep(replace_one: ReplaceOne) -> None:
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 32f320c0..e59ee5c0 100644
--- a/tests/config/loader/ini/replace/test_replace_os_sep.py
+++ b/tests/config/loader/ini/replace/test_replace_os_sep.py
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING
 import pytest
 
 if TYPE_CHECKING:
-    from tests.config.loader.ini.replace.conftest import ReplaceOne
+    from tests.config.loader.conftest import ReplaceOne
     from tox.pytest import MonkeyPatch
 
 
diff --git a/tests/config/loader/ini/replace/test_replace_posargs.py b/tests/config/loader/ini/replace/test_replace_posargs.py
index d56a2c6c..6f780c67 100644
--- a/tests/config/loader/ini/replace/test_replace_posargs.py
+++ b/tests/config/loader/ini/replace/test_replace_posargs.py
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING
 import pytest
 
 if TYPE_CHECKING:
-    from tests.config.loader.ini.replace.conftest import ReplaceOne
+    from tests.config.loader.conftest import ReplaceOne
 
 
 @pytest.mark.parametrize("syntax", ["{posargs}", "[]"])
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 7d89d8cb..8fbfd01f 100644
--- a/tests/config/loader/ini/replace/test_replace_tox_env.py
+++ b/tests/config/loader/ini/replace/test_replace_tox_env.py
@@ -5,12 +5,12 @@ from typing import TYPE_CHECKING, Callable
 
 import pytest
 
-from tox.config.loader.ini.replace import MAX_REPLACE_DEPTH
+from tox.config.loader.replacer import MAX_REPLACE_DEPTH
 from tox.config.sets import ConfigSet
 from tox.report import HandledError
 
 if TYPE_CHECKING:
-    from tests.config.loader.ini.replace.conftest import ReplaceOne
+    from tests.config.loader.conftest import ReplaceOne
     from tests.conftest import ToxIniCreator
     from tox.pytest import LogCaptureFixture
 
diff --git a/tests/config/loader/ini/replace/test_replace_tty.py b/tests/config/loader/ini/replace/test_replace_tty.py
index 9ce3f286..755aea25 100644
--- a/tests/config/loader/ini/replace/test_replace_tty.py
+++ b/tests/config/loader/ini/replace/test_replace_tty.py
@@ -8,7 +8,7 @@ import pytest
 if TYPE_CHECKING:
     from pytest_mock import MockFixture
 
-    from tests.config.loader.ini.replace.conftest import ReplaceOne
+    from tests.config.loader.conftest import ReplaceOne
 
 
 @pytest.mark.parametrize("is_atty", [True, False])
diff --git a/tests/config/loader/ini/replace/test_replace.py b/tests/config/loader/test_replace.py
similarity index 95%
rename from tests/config/loader/ini/replace/test_replace.py
rename to tests/config/loader/test_replace.py
index 7b203adf..3544ac2b 100644
--- a/tests/config/loader/ini/replace/test_replace.py
+++ b/tests/config/loader/test_replace.py
@@ -4,11 +4,11 @@ from typing import TYPE_CHECKING
 
 import pytest
 
-from tox.config.loader.ini.replace import MatchExpression, find_replace_expr
+from tox.config.loader.replacer import MatchExpression, find_replace_expr
 from tox.report import HandledError
 
 if TYPE_CHECKING:
-    from tests.config.loader.ini.replace.conftest import ReplaceOne
+    from tests.config.loader.conftest import ReplaceOne
 
 
 @pytest.mark.parametrize(
diff --git a/tests/config/loader/test_toml_loader.py b/tests/config/loader/test_toml_loader.py
new file mode 100644
index 00000000..670edac3
--- /dev/null
+++ b/tests/config/loader/test_toml_loader.py
@@ -0,0 +1,144 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, Dict, List, Literal, Optional, TypeVar
+
+import pytest
+
+from tox.config.loader.api import ConfigLoadArgs
+from tox.config.loader.toml import TomlLoader
+from tox.config.source.toml_pyproject import TomlPyProjectSection
+from tox.config.types import Command, EnvList
+
+
+def test_toml_loader_load_raw() -> None:
+    loader = TomlLoader(TomlPyProjectSection.from_key("tox.env.A"), [], {"a": 1, "c": False}, {}, set())
+    assert loader.load_raw("a", None, "A") == 1
+
+
+def test_toml_loader_load_repr() -> None:
+    loader = TomlLoader(TomlPyProjectSection.from_key("tox.env.A"), [], {"a": 1}, {}, set())
+    assert repr(loader) == "TomlLoader(env.A, {'a': 1})"
+
+
+def test_toml_loader_found_keys() -> None:
+    loader = TomlLoader(TomlPyProjectSection.from_key("tox.env.A"), [], {"a": 1, "c": False}, {}, set())
+    assert loader.found_keys() == {"a", "c"}
+
+
+def factory_na(obj: object) -> None:
+    raise NotImplementedError
+
+
+V = TypeVar("V")
+
+
+def perform_load(value: Any, of_type: type[V]) -> V:
+    env_name, key = "A", "k"
+    loader = TomlLoader(TomlPyProjectSection.from_key(f"tox.env.{env_name}"), [], {key: value}, {}, set())
+    args = ConfigLoadArgs(None, env_name, env_name)
+    return loader.load(key, of_type, factory_na, None, args)  # type: ignore[arg-type]
+
+
+def test_toml_loader_str_ok() -> None:
+    assert perform_load("s", str) == "s"
+
+
+def test_toml_loader_str_nok() -> None:
+    with pytest.raises(TypeError, match="1 is not of type 'str'"):
+        perform_load(1, str)
+
+
+def test_toml_loader_bool_ok() -> None:
+    assert perform_load(True, bool) is True
+
+
+def test_toml_loader_bool_nok() -> None:
+    with pytest.raises(TypeError, match="'true' is not of type 'bool'"):
+        perform_load("true", bool)
+
+
+def test_toml_loader_list_ok() -> None:
+    assert perform_load(["a"], List[str]) == ["a"]
+
+
+def test_toml_loader_list_nok() -> None:
+    with pytest.raises(TypeError, match="{} is not list"):
+        perform_load({}, List[str])
+
+
+def test_toml_loader_list_nok_element() -> None:
+    with pytest.raises(TypeError, match="2 is not of type 'str'"):
+        perform_load(["a", 2], List[str])
+
+
+def test_toml_loader_dict_ok() -> None:
+    assert perform_load({"a": "1"}, Dict[str, str]) == {"a": "1"}
+
+
+def test_toml_loader_dict_nok() -> None:
+    with pytest.raises(TypeError, match="{'a'} is not dictionary"):
+        perform_load({"a"}, Dict[str, str])
+
+
+def test_toml_loader_dict_nok_key() -> None:
+    with pytest.raises(TypeError, match="1 is not of type 'str'"):
+        perform_load({"a": 1, 1: "2"}, Dict[str, int])
+
+
+def test_toml_loader_dict_nok_value() -> None:
+    with pytest.raises(TypeError, match="'2' is not of type 'int'"):
+        perform_load({"a": 1, "b": "2"}, Dict[str, int])
+
+
+def test_toml_loader_path_ok() -> None:
+    assert perform_load("/w", Path) == Path("/w")
+
+
+def test_toml_loader_path_nok() -> None:
+    with pytest.raises(TypeError, match="1 is not of type 'str'"):
+        perform_load(1, Path)
+
+
+def test_toml_loader_command_ok() -> None:
+    commands = perform_load([["a", "b"], ["c"]], List[Command])
+    assert isinstance(commands, list)
+    assert len(commands) == 2
+    assert all(isinstance(i, Command) for i in commands)
+
+    assert commands[0].args == ["a", "b"]
+    assert commands[1].args == ["c"]
+
+
+def test_toml_loader_command_nok() -> None:
+    with pytest.raises(TypeError, match="1 is not of type 'str'"):
+        perform_load([["a", 1]], List[Command])
+
+
+def test_toml_loader_env_list_ok() -> None:
+    res = perform_load(["a", "b"], EnvList)
+    assert isinstance(res, EnvList)
+    assert list(res) == ["a", "b"]
+
+
+def test_toml_loader_env_list_nok() -> None:
+    with pytest.raises(TypeError, match="1 is not of type 'str'"):
+        perform_load(["a", 1], EnvList)
+
+
+def test_toml_loader_list_optional_ok() -> None:
+    assert perform_load(["a", None], List[Optional[str]]) == ["a", None]
+
+
+def test_toml_loader_list_optional_nok() -> None:
+    with pytest.raises(TypeError, match="1 is not union of str, NoneType"):
+        perform_load(["a", None, 1], List[Optional[str]])
+
+
+def test_toml_loader_list_literal_ok() -> None:
+    assert perform_load(["a", "b"], List[Literal["a", "b"]]) == ["a", "b"]
+
+
+def test_toml_loader_list_literal_nok() -> None:
+    with pytest.raises(TypeError, match="'c' is not one of literal 'a','b'"):
+        perform_load(["a", "c"], List[Literal["a", "b"]])
diff --git a/tests/config/source/test_discover.py b/tests/config/source/test_discover.py
index d16927fe..86370e5c 100644
--- a/tests/config/source/test_discover.py
+++ b/tests/config/source/test_discover.py
@@ -10,7 +10,7 @@ if TYPE_CHECKING:
 
 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"ROOT: No tox.ini or setup.cfg or pyproject.toml or tox.toml found, assuming empty tox.ini at {path}\n"
         f"default environments:\npy -> [no description]\n"
     )
 
diff --git a/tests/config/source/test_toml_pyproject.py b/tests/config/source/test_toml_pyproject.py
new file mode 100644
index 00000000..4b597253
--- /dev/null
+++ b/tests/config/source/test_toml_pyproject.py
@@ -0,0 +1,315 @@
+from __future__ import annotations
+
+import sys
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    import pytest
+
+    from tox.pytest import ToxProjectCreator
+
+
+def test_config_in_toml_core(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "pyproject.toml": """
+    [tool.tox]
+    env_list = [ "A", "B"]
+
+    [tool.tox.env_run_base]
+    description = "Do magical things"
+    commands = [
+        ["python", "--version"],
+        ["python", "-c", "import sys; print(sys.executable)"]
+    ]
+    """
+    })
+
+    outcome = project.run("c", "--core")
+    outcome.assert_success()
+    assert "# Exception: " not in outcome.out, outcome.out
+    assert "# !!! unused: " not in outcome.out, outcome.out
+
+
+def test_config_in_toml_non_default(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "pyproject.toml": """
+    [tool.tox.env.C]
+    description = "Do magical things in C"
+    commands = [
+        ["python", "--version"]
+    ]
+    """
+    })
+
+    outcome = project.run("c", "-e", "C", "--core")
+    outcome.assert_success()
+    assert "# Exception: " not in outcome.out, outcome.out
+    assert "# !!! unused: " not in outcome.out, outcome.out
+
+
+def test_config_in_toml_extra(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "pyproject.toml": """
+    [tool.tox.env_run_base]
+    description = "Do magical things"
+    commands = [
+        ["python", "--version"]
+    ]
+    """
+    })
+
+    outcome = project.run("c", "-e", ".".join(str(i) for i in sys.version_info[0:2]))
+    outcome.assert_success()
+    assert "# Exception: " not in outcome.out, outcome.out
+    assert "# !!! unused: " not in outcome.out, outcome.out
+
+
+def test_config_in_toml_replace_default(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({"pyproject.toml": '[tool.tox.env_run_base]\ndescription = "{missing:miss}"'})
+    outcome = project.run("c", "-k", "description")
+    outcome.assert_success()
+    outcome.assert_out_err("[testenv:py]\ndescription = miss\n", "")
+
+
+def test_config_in_toml_replace_env_name_via_env(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "pyproject.toml": '[tool.tox.env_run_base]\ndescription = "Magic in {env:MAGICAL:{env_name}}"'
+    })
+    outcome = project.run("c", "-k", "description")
+    outcome.assert_success()
+    outcome.assert_out_err("[testenv:py]\ndescription = Magic in py\n", "")
+
+
+def test_config_in_toml_replace_env_name_via_env_set(
+    tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch
+) -> None:
+    monkeypatch.setenv("MAGICAL", "YEAH")
+    project = tox_project({
+        "pyproject.toml": '[tool.tox.env_run_base]\ndescription = "Magic in {env:MAGICAL:{env_name}}"'
+    })
+    outcome = project.run("c", "-k", "description")
+    outcome.assert_success()
+    outcome.assert_out_err("[testenv:py]\ndescription = Magic in YEAH\n", "")
+
+
+def test_config_in_toml_replace_from_env_section_absolute(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "pyproject.toml": """
+        [tool.tox.env.A]
+        description = "a"
+        [tool.tox.env.B]
+        description = "{[tool.tox.env.A]env_name}"
+        """
+    })
+    outcome = project.run("c", "-e", "B", "-k", "description")
+    outcome.assert_success()
+    outcome.assert_out_err("[testenv:B]\ndescription = A\n", "")
+
+
+def test_config_in_toml_replace_from_section_absolute(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "pyproject.toml": """
+        [tool.tox.extra]
+        ok = "o"
+        [tool.tox.env.B]
+        description = "{[tool.tox.extra]ok}"
+        """
+    })
+    outcome = project.run("c", "-e", "B", "-k", "description")
+    outcome.assert_success()
+    outcome.assert_out_err("[testenv:B]\ndescription = o\n", "")
+
+
+def test_config_in_toml_replace_from_section_absolute_nok(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "pyproject.toml": """
+        [tool.tox]
+        extra = []
+        [tool.tox.env.B]
+        description = "{[tool.tox.extra.more]ok:failed}"
+        """
+    })
+    outcome = project.run("c", "-e", "B", "-k", "description")
+    outcome.assert_success()
+    outcome.assert_out_err(
+        "[testenv:B]\nROOT: Failed to load key more as not dictionary []\ndescription = failed\n", ""
+    )
+
+
+def test_config_in_toml_replace_posargs_default(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "pyproject.toml": """
+        [tool.tox.env.A]
+        commands = [["python", { replace = "posargs", default = ["a", "b"] } ]]
+        """
+    })
+    outcome = project.run("c", "-e", "A", "-k", "commands")
+    outcome.assert_success()
+    outcome.assert_out_err("[testenv:A]\ncommands = python a b\n", "")
+
+
+def test_config_in_toml_replace_posargs_set(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "pyproject.toml": """
+        [tool.tox.env.A]
+        commands = [["python", { replace = "posargs", default = ["a", "b"] } ]]
+        """
+    })
+    outcome = project.run("c", "-e", "A", "-k", "commands", "--", "c", "d")
+    outcome.assert_success()
+    outcome.assert_out_err("[testenv:A]\ncommands = python c d\n", "")
+
+
+def test_config_in_toml_replace_env_default(tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch) -> None:
+    project = tox_project({
+        "pyproject.toml": """
+        [tool.tox.env.A]
+        description = { replace = "env", name = "NAME", default = "OK" }
+        """
+    })
+    monkeypatch.delenv("NAME", raising=False)
+
+    outcome = project.run("c", "-e", "A", "-k", "description")
+    outcome.assert_success()
+    outcome.assert_out_err("[testenv:A]\ndescription = OK\n", "")
+
+
+def test_config_in_toml_replace_env_set(tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch) -> None:
+    project = tox_project({
+        "pyproject.toml": """
+        [tool.tox.env.A]
+        description = { replace = "env", name = "NAME", default = "OK" }
+        """
+    })
+    monkeypatch.setenv("NAME", "OK2")
+
+    outcome = project.run("c", "-e", "A", "-k", "description")
+    outcome.assert_success()
+    outcome.assert_out_err("[testenv:A]\ndescription = OK2\n", "")
+
+
+def test_config_in_toml_replace_ref_raw(tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch) -> None:
+    project = tox_project({
+        "pyproject.toml": """
+        [tool.tox.env_run_base]
+        extras = ["A", "{env_name}"]
+        [tool.tox.env.a]
+        extras = [{ replace = "ref", raw = ["tool", "tox", "env_run_base", "extras"] }, "B"]
+        """
+    })
+    monkeypatch.setenv("NAME", "OK2")
+
+    outcome = project.run("c", "-e", "a", "-k", "extras")
+    outcome.assert_success()
+    outcome.assert_out_err("[testenv:a]\nextras =\n  a\n  b\n  {env-name}\n", "")
+
+
+def test_config_in_toml_replace_ref_env(tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch) -> None:
+    project = tox_project({
+        "pyproject.toml": """
+        [tool.tox.env.b]
+        extras = ["{env_name}"]
+        [tool.tox.env.a]
+        extras = [{ replace = "ref", env = "b", "key" = "extras" }, "a"]
+        """
+    })
+    monkeypatch.setenv("NAME", "OK2")
+
+    outcome = project.run("c", "-e", "a", "-k", "extras")
+    outcome.assert_success()
+    outcome.assert_out_err("[testenv:a]\nextras =\n  a\n  b\n", "")
+
+
+def test_config_in_toml_replace_env_circular_set(
+    tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch
+) -> None:
+    project = tox_project({
+        "pyproject.toml": """
+        [tool.tox.env.a]
+        set_env.COVERAGE_FILE = { replace = "env", name = "COVERAGE_FILE", default = "{env_name}" }
+        """
+    })
+    monkeypatch.setenv("COVERAGE_FILE", "OK")
+
+    outcome = project.run("c", "-e", "a", "-k", "set_env")
+    outcome.assert_success()
+    assert "COVERAGE_FILE=OK" in outcome.out, outcome.out
+
+
+def test_config_in_toml_replace_env_circular_unset(
+    tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch
+) -> None:
+    project = tox_project({
+        "pyproject.toml": """
+        [tool.tox.env.a]
+        set_env.COVERAGE_FILE = { replace = "env", name = "COVERAGE_FILE", default = "{env_name}" }
+        """
+    })
+    monkeypatch.delenv("COVERAGE_FILE", raising=False)
+
+    outcome = project.run("c", "-e", "a", "-k", "set_env")
+    outcome.assert_success()
+    assert "COVERAGE_FILE=a" in outcome.out, outcome.out
+
+
+def test_config_in_toml_replace_fails(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "pyproject.toml": """
+        [tool.tox.env.B]
+        description = "{[tool.tox.extra]ok:d}"
+        """
+    })
+    outcome = project.run("c", "-e", "B", "-k", "description")
+    outcome.assert_success()
+    outcome.assert_out_err("[testenv:B]\ndescription = d\n", "")
+
+
+def test_config_in_toml_replace_from_core(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "pyproject.toml": """
+        [tool.tox.env.B]
+        description = "{[tool.tox]no_package}"
+        """
+    })
+    outcome = project.run("c", "-e", "B", "-k", "description")
+    outcome.assert_success()
+    outcome.assert_out_err("[testenv:B]\ndescription = False\n", "")
+
+
+def test_config_in_toml_with_legacy(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "pyproject.toml": """
+        [tool.tox]
+        legacy_tox_ini = '''
+            [testenv]
+            description=legacy
+        '''
+        """
+    })
+    outcome = project.run("c", "-e", "py", "-k", "description")
+    outcome.assert_success()
+    outcome.assert_out_err("[testenv:py]\ndescription = legacy\n", "")
+
+
+def test_config_in_toml_bad_type_env_name(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "pyproject.toml": """
+        [tool.tox]
+        env = [1]
+        """
+    })
+    outcome = project.run("l")
+    outcome.assert_failed()
+    outcome.assert_out_err("ROOT: HandledError| Environment key must be string, got 1\n", "")
+
+
+def test_config_in_toml_bad_type_env(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "pyproject.toml": """
+        [tool.tox]
+        env = {a = 1}
+        """
+    })
+    outcome = project.run("l")
+    outcome.assert_failed()
+    outcome.assert_out_err("ROOT: HandledError| tool.tox.env.a must be a table, is 'int'\n", "")
diff --git a/tests/config/source/test_toml_tox.py b/tests/config/source/test_toml_tox.py
new file mode 100644
index 00000000..7c3c24a0
--- /dev/null
+++ b/tests/config/source/test_toml_tox.py
@@ -0,0 +1,115 @@
+from __future__ import annotations
+
+import sys
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    import pytest
+
+    from tox.pytest import ToxProjectCreator
+
+
+def test_config_in_toml_core(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "tox.toml": """
+    env_list = [ "A", "B"]
+
+    [env_run_base]
+    description = "Do magical things"
+    commands = [
+        ["python", "--version"],
+        ["python", "-c", "import sys; print(sys.executable)"]
+    ]
+    """
+    })
+
+    outcome = project.run("c", "--core")
+    outcome.assert_success()
+    assert "# Exception: " not in outcome.out, outcome.out
+    assert "# !!! unused: " not in outcome.out, outcome.out
+
+
+def test_config_in_toml_non_default(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "tox.toml": """
+    [env.C]
+    description = "Do magical things in C"
+    commands = [
+        ["python", "--version"]
+    ]
+    """
+    })
+
+    outcome = project.run("c", "-e", "C", "--core")
+    outcome.assert_success()
+    assert "# Exception: " not in outcome.out, outcome.out
+    assert "# !!! unused: " not in outcome.out, outcome.out
+
+
+def test_config_in_toml_extra(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "tox.toml": """
+    [env_run_base]
+    description = "Do magical things"
+    commands = [
+        ["python", "--version"]
+    ]
+    """
+    })
+
+    outcome = project.run("c", "-e", ".".join(str(i) for i in sys.version_info[0:2]))
+    outcome.assert_success()
+    assert "# Exception: " not in outcome.out, outcome.out
+    assert "# !!! unused: " not in outcome.out, outcome.out
+
+
+def test_config_in_toml_replace_default(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({"tox.toml": '[env_run_base]\ndescription = "{missing:miss}"'})
+    outcome = project.run("c", "-k", "description")
+    outcome.assert_success()
+    outcome.assert_out_err("[testenv:py]\ndescription = miss\n", "")
+
+
+def test_config_in_toml_replace_env_name_via_env(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({"tox.toml": '[env_run_base]\ndescription = "Magic in {env:MAGICAL:{env_name}}"'})
+    outcome = project.run("c", "-k", "description")
+    outcome.assert_success()
+    outcome.assert_out_err("[testenv:py]\ndescription = Magic in py\n", "")
+
+
+def test_config_in_toml_replace_env_name_via_env_set(
+    tox_project: ToxProjectCreator, monkeypatch: pytest.MonkeyPatch
+) -> None:
+    monkeypatch.setenv("MAGICAL", "YEAH")
+    project = tox_project({"tox.toml": '[env_run_base]\ndescription = "Magic in {env:MAGICAL:{env_name}}"'})
+    outcome = project.run("c", "-k", "description")
+    outcome.assert_success()
+    outcome.assert_out_err("[testenv:py]\ndescription = Magic in YEAH\n", "")
+
+
+def test_config_in_toml_replace_from_env_section_absolute(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "tox.toml": """
+        [env.A]
+        description = "a"
+        [env.B]
+        description = "{[env.A]env_name}"
+        """
+    })
+    outcome = project.run("c", "-e", "B", "-k", "description")
+    outcome.assert_success()
+    outcome.assert_out_err("[testenv:B]\ndescription = A\n", "")
+
+
+def test_config_in_toml_replace_from_section_absolute(tox_project: ToxProjectCreator) -> None:
+    project = tox_project({
+        "tox.toml": """
+        [extra]
+        ok = "o"
+        [env.B]
+        description = "{[extra]ok}"
+        """
+    })
+    outcome = project.run("c", "-e", "B", "-k", "description")
+    outcome.assert_success()
+    outcome.assert_out_err("[testenv:B]\ndescription = o\n", "")
diff --git a/tests/session/cmd/test_legacy.py b/tests/session/cmd/test_legacy.py
index 508e4ec9..0d04dc6a 100644
--- a/tests/session/cmd/test_legacy.py
+++ b/tests/session/cmd/test_legacy.py
@@ -65,7 +65,9 @@ def test_legacy_list_env_with_no_tox_file(tox_project: ToxProjectCreator) -> Non
     project = tox_project({})
     outcome = project.run("le", "-l")
     outcome.assert_success()
-    out = f"ROOT: No tox.ini or setup.cfg or pyproject.toml found, assuming empty tox.ini at {project.path}\n"
+    out = (
+        f"ROOT: No tox.ini or setup.cfg or pyproject.toml or tox.toml found, assuming empty tox.ini at {project.path}\n"
+    )
     assert not outcome.err
     assert outcome.out == out