Skip to content

Commit 0b5f95a

Browse files
authored
Support for generative section names (#2557)
Resolves #2362
1 parent c669b4e commit 0b5f95a

File tree

8 files changed

+138
-25
lines changed

8 files changed

+138
-25
lines changed

docs/changelog/2362.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for generative section headers - by :user:`gaborbernat`.

docs/user_guide.rst

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ tool can be:
1010
- a test runner (such as :pypi:`pytest`),
1111
- a linter (e.g., :pypi:`flake8`),
1212
- a formatter (for example :pypi:`black` or :pypi:`isort`),
13-
- a documentation generator (e.g., :pypi:`sphinx`),
13+
- a documentation generator (e.g., :pypi:`Sphinx`),
1414
- library builder and publisher (e.g., :pypi:`build` with :pypi:`twine`),
1515
- or anything else you may need to execute.
1616

@@ -176,10 +176,10 @@ Related projects
176176
tox has influenced several other projects in the Python test automation space. If tox doesn't quite fit your needs or
177177
you want to do more research, we recommend taking a look at these projects:
178178

179-
- `nox <https://nox.thea.codes>`__ is a project similar in spirit to tox but different in approach. The primary key
180-
difference is that it uses Python scripts instead of a configuration file. It might be useful if you find tox
181-
configuration too limiting but aren't looking to move to something as general-purpose as ``Invoke`` or ``make``.
182-
Please note that tox will support defining configuration in a Python file soon, too.
179+
- `nox <https://nox.thea.codes/en/stable/>`__ is a project similar in spirit to tox but different in approach. The
180+
primary key difference is that it uses Python scripts instead of a configuration file. It might be useful if you
181+
find tox configuration too limiting but aren't looking to move to something as general-purpose as ``Invoke`` or
182+
``make``. Please note that tox will support defining configuration in a Python file soon, too.
183183
- `Invoke <https://www.pyinvoke.org/>`__ is a general-purpose task execution library, similar to Make. Invoke is far
184184
more general-purpose than tox but it does not contain the Python testing-specific features that tox specializes in.
185185

@@ -364,4 +364,74 @@ tox supports these features that 90 percent of the time you'll not need, but are
364364
Generative environments
365365
~~~~~~~~~~~~~~~~~~~~~~~
366366

367-
Django.
367+
Generative environment list
368+
+++++++++++++++++++++++++++
369+
370+
If you have a large matrix of dependencies, python versions and/or environments you can use a generative
371+
:ref:`env_list` and conditional settings to express that in a concise form:
372+
373+
.. code-block:: ini
374+
375+
[tox]
376+
env_list = py{311,310,39}-django{41,40}-{sqlite,mysql}
377+
378+
[testenv]
379+
deps =
380+
django41: Django>=4.1,<4.2
381+
django40: Django>=4.0,<4.1
382+
# use PyMySQL if factors "py311" and "mysql" are present in env name
383+
py311-mysql: PyMySQL
384+
# use urllib3 if any of "py311" or "py310" are present in env name
385+
py311,py310: urllib3
386+
# mocking sqlite on 3.11 and 3.10 if factor "sqlite" is present
387+
py{311,310}-sqlite: mock
388+
389+
This will generate the following tox environments:
390+
391+
.. code-block:: shell
392+
393+
> tox l
394+
default environments:
395+
py311-django41-sqlite -> [no description]
396+
py311-django41-mysql -> [no description]
397+
py311-django40-sqlite -> [no description]
398+
py311-django40-mysql -> [no description]
399+
py310-django41-sqlite -> [no description]
400+
py310-django41-mysql -> [no description]
401+
py310-django40-sqlite -> [no description]
402+
py310-django40-mysql -> [no description]
403+
py39-django41-sqlite -> [no description]
404+
py39-django41-mysql -> [no description]
405+
py39-django40-sqlite -> [no description]
406+
py39-django40-mysql -> [no description]
407+
408+
Generative section names
409+
++++++++++++++++++++++++
410+
411+
Suppose you have some binary packages, and need to run tests both in 32 and 64 bits. You also want an environment to
412+
create your virtual env for the developers.
413+
414+
.. code-block:: ini
415+
416+
[testenv]
417+
base_python =
418+
py311-x86: python3.11-32
419+
py311-x64: python3.11-64
420+
commands = pytest
421+
422+
[testenv:py311-{x86,x64}-venv]
423+
envdir =
424+
x86: .venv-x86
425+
x64: .venv-x64
426+
427+
.. code-block:: shell
428+
429+
> tox l
430+
default environments:
431+
py -> [no description]
432+
433+
additional environments:
434+
py310-black -> [no description]
435+
py310-lint -> [no description]
436+
py311-black -> [no description]
437+
py311-lint -> [no description]

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ def __init__(
3131
parser: ConfigParser,
3232
overrides: list[Override],
3333
core_section: Section,
34+
section_key: str | None = None,
3435
) -> None:
35-
self._section_proxy: SectionProxy = parser[section.key]
36+
self._section_proxy: SectionProxy = parser[section_key or section.key]
3637
self._parser = parser
3738
self.core_section = core_section
3839
super().__init__(section, overrides)

src/tox/config/source/ini.py

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""Load """
22
from __future__ import annotations
33

4+
from collections import defaultdict
45
from configparser import ConfigParser
56
from itertools import chain
67
from pathlib import Path
7-
from typing import Iterator
8+
from typing import DefaultDict, Iterable, Iterator
89

910
from tox.config.loader.ini.factor import find_envs
1011

@@ -29,7 +30,7 @@ def __init__(self, path: Path, content: str | None = None) -> None:
2930
raise ValueError
3031
content = path.read_text()
3132
self._parser.read_string(content, str(path))
32-
self._sections: dict[str, list[IniLoader]] = {}
33+
self._section_mapping: DefaultDict[str, list[str]] = defaultdict(list)
3334

3435
def transform_section(self, section: Section) -> Section:
3536
return IniSection(section.prefix, section.name)
@@ -39,14 +40,17 @@ def sections(self) -> Iterator[IniSection]:
3940
yield IniSection.from_key(section)
4041

4142
def get_loader(self, section: Section, override_map: OverrideMap) -> IniLoader | None:
42-
if not self._parser.has_section(section.key):
43-
return None
44-
return IniLoader(
45-
section=section,
46-
parser=self._parser,
47-
overrides=override_map.get(section.key, []),
48-
core_section=self.CORE_SECTION,
49-
)
43+
sections = self._section_mapping.get(section.name)
44+
key = sections[0] if sections else section.key
45+
if self._parser.has_section(key):
46+
return IniLoader(
47+
section=section,
48+
parser=self._parser,
49+
overrides=override_map.get(section.key, []),
50+
core_section=self.CORE_SECTION,
51+
section_key=key,
52+
)
53+
return None
5054

5155
def get_core_section(self) -> Section:
5256
return self.CORE_SECTION
@@ -69,20 +73,29 @@ def envs(self, core_config: ConfigSet) -> Iterator[str]:
6973
yield name
7074

7175
def _discover_tox_envs(self, core_config: ConfigSet) -> Iterator[str]:
76+
def register_factors(envs: Iterable[str]) -> None:
77+
known_factors.update(chain.from_iterable(e.split("-") for e in envs))
78+
7279
explicit = list(core_config["env_list"])
7380
yield from explicit
74-
known_factors = None
81+
known_factors: set[str] = set()
82+
register_factors(explicit)
83+
84+
# discover all additional defined environments, including generative section headers
85+
for section in self.sections():
86+
register_factors(section.names)
87+
for name in section.names:
88+
self._section_mapping[name].append(section.key)
89+
if section.is_test_env:
90+
yield name
91+
# add all conditional markers that are not part of the explicitly defined sections
7592
for section in self.sections():
76-
if section.is_test_env:
77-
yield section.name
78-
if known_factors is None:
79-
known_factors = set(chain.from_iterable(e.split("-") for e in explicit))
8093
yield from self._discover_from_section(section, known_factors)
8194

8295
def _discover_from_section(self, section: IniSection, known_factors: set[str]) -> Iterator[str]:
8396
for value in self._parser[section.key].values():
8497
for env in find_envs(value):
85-
if env not in known_factors:
98+
if set(env.split("-")) - known_factors:
8699
yield env
87100

88101
def __repr__(self) -> str:

src/tox/config/source/ini_section.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from tox.config.loader.ini.factor import extend_factors
34
from tox.config.loader.section import Section
45

56

@@ -12,6 +13,11 @@ def test_env(cls, name: str) -> IniSection:
1213
def is_test_env(self) -> bool:
1314
return self.prefix == TEST_ENV_PREFIX
1415

16+
@property
17+
def names(self) -> list[str]:
18+
elements = list(extend_factors(self.name))
19+
return elements
20+
1521

1622
TEST_ENV_PREFIX = "testenv"
1723
CORE = IniSection(None, "tox")

tests/config/loader/ini/test_factor.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,25 @@ def test_ini_loader_raw_with_factors(
176176
)
177177
outcome = loader.load_raw(key="commands", conf=empty_config, env_name=env)
178178
assert outcome == result
179+
180+
181+
def test_generative_section_name(tox_ini_conf: ToxIniCreator) -> None:
182+
config = tox_ini_conf(
183+
"""
184+
[testenv:{py311,py310}-{black,lint}]
185+
deps-x =
186+
black: black
187+
lint: flake8
188+
""",
189+
)
190+
assert list(config) == ["py311-black", "py311-lint", "py310-black", "py310-lint"]
191+
192+
env_config = config.get_env("py311-black")
193+
env_config.add_config(keys="deps-x", of_type=List[str], default=[], desc="deps")
194+
deps = env_config["deps-x"]
195+
assert deps == ["black"]
196+
197+
env_config = config.get_env("py311-lint")
198+
env_config.add_config(keys="deps-x", of_type=List[str], default=[], desc="deps")
199+
deps = env_config["deps-x"]
200+
assert deps == ["flake8"]

tests/config/test_main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def test_config_some_envs(tox_ini_conf: ToxIniCreator) -> None:
3939
"""
4040
config = tox_ini_conf(example)
4141
tox_env_keys = list(config)
42-
assert tox_env_keys == ["py38", "py37", "other", "magic"]
42+
assert tox_env_keys == ["py38", "py37", "magic", "other"]
4343

4444
config_set = config.get_env("py38")
4545
assert repr(config_set)

tests/plugin/test_plugin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ def tox_add_core_config(core_conf: CoreConfigSet, state: State) -> None: # noqa
102102
"""
103103
project = tox_project({"tox.ini": ini})
104104
result = project.run()
105-
assert "ROOT: All envs: explicit, implicit, section" in result.out
105+
assert "ROOT: All envs: explicit, section, implicit" in result.out
106106
assert "ROOT: Default envs: explicit" in result.out
107107

108108

0 commit comments

Comments
 (0)