Skip to content

Commit 8ef0326

Browse files
authored
✨ feat(toml): declare factor group defaults and per-run overrides (#4050)
1 parent e489adb commit 8ef0326

13 files changed

Lines changed: 287 additions & 25 deletions

File tree

docs/changelog/4045.feature.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
A labeled factor group can now declare a ``default`` for ``{factor:label}`` to fall back on when no factor of that group
2+
is active in the environment name. Setting ``TOX_FACTOR_<label>`` resolves that label to a given value for a single run
3+
- by :user:`gaborbernat`.

docs/explanation.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,12 @@ have labeled one shape and left the other out. Nesting covers both with one rule
345345
and the value describes its factors. Adding a shape later means describing another value, not teaching every earlier
346346
shape one more key.
347347

348+
A labeled factor value is part of the environment name, which is what makes ``tox run -e test-3.14-django50`` mean one
349+
thing. That leaves no way to name a version the matrix does not list on the command line, since ``>=4.2,<4.3`` cannot
350+
serve as a factor. ``TOX_FACTOR_<label>`` sidesteps that by leaving the name alone. The run goes through the environment
351+
the matrix generated, and only ``{factor:label}`` resolves elsewhere. The cost is a name that no longer tells the whole
352+
story, so the override suits a one-off check rather than a recorded configuration.
353+
348354
For the configuration reference, see :ref:`env-base-templates`. For practical recipes, see :ref:`howto_env_base_matrix`.
349355

350356
Overrides and substitution

docs/how-to/usage.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,6 +1183,12 @@ lists and range dicts take a label:
11831183
]
11841184
description = "Test {factor:django_version} on {factor:py_version}"
11851185
1186+
To try a version the matrix does not list, set ``TOX_FACTOR_<label>`` for that run:
1187+
1188+
.. code-block:: console
1189+
1190+
$ env TOX_FACTOR_django_version=django61 tox run -e django-py314-django50
1191+
11861192
See :ref:`env-base-templates` for the full reference.
11871193

11881194
***************************

docs/reference/config.rst

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ This generates 4 environments: ``django-py312-django42``, ``django-py312-django5
209209
- A labeled dict: ``{ ecosystem = ["oci", "python"] }`` (same as a list, but registers the label for substitution)
210210
- A labeled range dict: ``{ py_version = { prefix = "3.", start = 12, stop = 14 } }`` (a range that also registers a
211211
label)
212+
- A labeled values dict: ``{ django_version = { values = ["django42", "django50"], default = "django50" } }`` (a list
213+
with a declared default)
212214
- Mixed in the same ``factors`` list for Cartesian products
213215

214216
The template name itself does not appear as a runnable environment -- only the generated names do.
@@ -277,6 +279,42 @@ For ``task-oci-pw``, the description resolves to ``Run oci on pw``. Labeled dict
277279
**Defaults** -- ``{factor:label:fallback}`` uses ``fallback`` when the label is unknown, following the same convention
278280
as ``{env:VAR:default}``.
279281

282+
A factor group can declare its own default instead, so every use site stays short:
283+
284+
.. code-block:: toml
285+
286+
[env_base.test]
287+
factors = [
288+
{ py_version = { prefix = "3.", start = 12, stop = 14 } },
289+
{ django_version = { values = ["django42", "django50"], default = "django50" } },
290+
]
291+
description = "Test {factor:django_version} on Python {factor:py_version}"
292+
293+
A range dict takes ``default`` in the same table as ``prefix``.
294+
295+
The declared default must be one of the group's own factors. It applies when no factor of the group is active in the
296+
environment name, which happens in an ``[env.NAME]`` section outside the matrix. A ``{factor:label:fallback}`` written
297+
at the use site wins over it.
298+
299+
.. versionadded:: 4.61
300+
301+
The ``values`` and ``default`` keys.
302+
303+
**Overriding a label for one run** -- set ``TOX_FACTOR_<label>`` to make ``{factor:<label>}`` resolve to that value,
304+
whatever the environment name says:
305+
306+
.. code-block:: console
307+
308+
$ env TOX_FACTOR_django_version=django61 tox run -e test-3.14-django50
309+
310+
This installs ``Django61`` while still running the ``test-3.14-django50`` environment, which is useful for a one-off
311+
check against a version the matrix does not list. The variable only applies to labels the configuration declares, and it
312+
does not change which environments exist or what they are called.
313+
314+
.. versionadded:: 4.61
315+
316+
The ``TOX_FACTOR_<label>`` override.
317+
280318
**Comparison with conditionals** -- ``{factor:label}`` replaces nested ``replace = "if"`` chains when the substituted
281319
value equals the factor name itself. For values that don't match factor names (e.g., mapping ``pw`` to
282320
``production-west-cluster``), use ``replace = "if"`` with ``factor.NAME`` conditions.

docs/tutorial/getting-started.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,10 @@ whichever value the environment picked:
222222
commands = [["pytest"]]
223223
description = "run the tests under Python {factor:py_version}"
224224
225+
A group can also name the value to assume when an environment carries none of its factors, which saves repeating a
226+
fallback at every mention. To check one run against a version the matrix does not list, set ``TOX_FACTOR_py_version``
227+
for that run.
228+
225229
See :ref:`env-base-templates` for details.
226230

227231
***************************

src/tox/config/loader/replacer.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
REPLACE_END: Final[str] = "}"
2929
BACKSLASH_ESCAPE_CHARS: Final[tuple[str, ...]] = (ARG_DELIMITER, REPLACE_START, REPLACE_END, "[", "]")
3030
MAX_REPLACE_DEPTH: Final[int] = 100
31+
# lets a run resolve a labeled factor to a value the configuration does not list, without renaming environments
32+
_FACTOR_ENV_PREFIX: Final[str] = "TOX_FACTOR_"
3133

3234

3335
class MatchRecursionError(ValueError):
@@ -323,14 +325,16 @@ def replace_factor(conf: Config, args: list[str], conf_args: ConfigLoadArgs) ->
323325
raise MatchError(msg)
324326
label = args[0]
325327
default = ARG_DELIMITER.join(args[1:]) if len(args) > 1 else ""
326-
labels = conf.factor_labels
327-
if label not in labels or conf_args.env_name is None:
328+
group = conf.factor_labels.get(label)
329+
if group is None or conf_args.env_name is None:
328330
return default
331+
if override := os.environ.get(f"{_FACTOR_ENV_PREFIX}{label}"):
332+
return override
329333
env_factors = set(conf_args.env_name.split("-"))
330-
for value in labels[label]:
334+
for value in group.values:
331335
if value in env_factors:
332336
return value
333-
return default
337+
return default or group.default or ""
334338

335339

336340
__all__ = [

src/tox/config/loader/toml/_product.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from dataclasses import dataclass
56
from itertools import product
67
from typing import TYPE_CHECKING
78

@@ -11,6 +12,14 @@
1112
from tox.config.loader.toml._api import TomlTypes
1213

1314

15+
@dataclass(frozen=True)
16+
class FactorGroup:
17+
"""``default`` stands in wherever an environment name carries none of ``values``."""
18+
19+
values: list[str]
20+
default: str | None = None
21+
22+
1423
def expand_product(value: dict[str, TomlTypes]) -> list[str]:
1524
"""Expand a product dict into a flat list of environment names.
1625
@@ -60,6 +69,21 @@ def expand_factor_group(group: TomlTypes) -> list[str]:
6069
raise TypeError(msg)
6170

6271

72+
def extract_default(group: TomlTypes, values: list[str]) -> str | None:
73+
if not isinstance(group, dict):
74+
return None
75+
table = group if "prefix" in group else next(iter(group.values()), None)
76+
if not isinstance(table, dict) or (default := table.get("default")) is None:
77+
return None
78+
if not isinstance(default, str):
79+
msg = f"factor group 'default' must be a string, got {type(default).__name__}"
80+
raise TypeError(msg)
81+
if default not in values:
82+
msg = f"factor group 'default' {default!r} is not one of its factors: {', '.join(values)}"
83+
raise TypeError(msg)
84+
return default
85+
86+
6387
def extract_label(group: TomlTypes) -> str | None:
6488
if isinstance(group, dict) and "prefix" not in group and len(group) == 1:
6589
return str(next(iter(group)))
@@ -71,12 +95,18 @@ def _expand_labeled(label: str, values: TomlTypes) -> list[str]:
7195
msg = f"'{label}' is reserved and cannot be used as a factor label"
7296
raise TypeError(msg)
7397
if isinstance(values, dict):
74-
if "prefix" not in values:
75-
msg = f"labeled factor group '{label}' maps to a dict without a 'prefix' key, so it is not a range"
76-
raise TypeError(msg)
77-
return _expand_range(values)
98+
if "prefix" in values:
99+
return _expand_range(values)
100+
if (listed := values.get("values")) is not None:
101+
if not isinstance(listed, list):
102+
msg = f"labeled factor group '{label}' 'values' must be a list, got {type(listed).__name__}"
103+
raise TypeError(msg)
104+
return [str(v) for v in listed]
105+
msg = f"labeled factor group '{label}' maps to a dict with neither a 'prefix' nor a 'values' key"
106+
raise TypeError(msg)
78107
if not isinstance(values, list):
79-
msg = f"labeled factor group '{label}' must map to a list or a range dict, got {type(values).__name__}"
108+
msg = f"labeled factor group '{label}' must map to a list, a range dict, or a values dict, "
109+
msg += f"got {type(values).__name__}"
80110
raise TypeError(msg)
81111
return [str(v) for v in values]
82112

@@ -101,7 +131,9 @@ def _expand_range(range_dict: dict[str, TomlTypes]) -> list[str]:
101131

102132
__all__ = [
103133
"_RESERVED_LABELS",
134+
"FactorGroup",
104135
"expand_factor_group",
105136
"expand_product",
137+
"extract_default",
106138
"extract_label",
107139
]

src/tox/config/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from collections.abc import Iterable, Iterator, Sequence
1414

1515
from tox.config.loader.api import Loader, OverrideMap
16+
from tox.config.loader.toml._product import FactorGroup
1617

1718
from .cli.parser import Parsed
1819
from .loader.memory import MemoryLoader
@@ -139,7 +140,7 @@ def overrides(self) -> OverrideMap:
139140
return self._overrides
140141

141142
@property
142-
def factor_labels(self) -> dict[str, list[str]]:
143+
def factor_labels(self) -> dict[str, FactorGroup]:
143144
return getattr(self._src, "_factor_labels", {})
144145

145146
@property

src/tox/config/source/toml_pyproject.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from tox.config.loader.section import Section
1010
from tox.config.loader.toml import TomlLoader
11-
from tox.config.loader.toml._product import expand_factor_group, extract_label
11+
from tox.config.loader.toml._product import FactorGroup, expand_factor_group, extract_default, extract_label
1212
from tox.config.types import MissingRequiredConfigKeyError
1313
from tox.report import HandledError
1414

@@ -183,9 +183,9 @@ def _table_at(content: dict[str, TomlTypes], keys: tuple[str, ...]) -> dict[str,
183183
return content
184184

185185

186-
def _build_env_base_map(env_base_content: dict[str, TomlTypes]) -> tuple[dict[str, str], dict[str, list[str]]]:
186+
def _build_env_base_map(env_base_content: dict[str, TomlTypes]) -> tuple[dict[str, str], dict[str, FactorGroup]]:
187187
result: dict[str, str] = {}
188-
all_labels: dict[str, list[str]] = {}
188+
all_labels: dict[str, FactorGroup] = {}
189189
for base_name, config in env_base_content.items():
190190
if not isinstance(config, dict):
191191
msg = f"env_base.{base_name} must be a table"
@@ -202,9 +202,10 @@ def _build_env_base_map(env_base_content: dict[str, TomlTypes]) -> tuple[dict[st
202202
for idx, g in enumerate(factors_raw):
203203
values = expand_factor_group(g)
204204
expanded.append(values)
205-
all_labels[str(idx)] = values
205+
group = FactorGroup(values=values, default=extract_default(g, values))
206+
all_labels[str(idx)] = group
206207
if (label := extract_label(g)) is not None:
207-
all_labels[label] = values
208+
all_labels[label] = group
208209
names = ["-".join(combo) for combo in product(*expanded)]
209210
else:
210211
names = [str(f) for f in factors_raw]
@@ -213,20 +214,21 @@ def _build_env_base_map(env_base_content: dict[str, TomlTypes]) -> tuple[dict[st
213214
return result, all_labels
214215

215216

216-
def _extract_env_list_labels(env_list_raw: TomlTypes) -> dict[str, list[str]]:
217+
def _extract_env_list_labels(env_list_raw: TomlTypes) -> dict[str, FactorGroup]:
217218
if not isinstance(env_list_raw, list):
218219
return {}
219-
labels: dict[str, list[str]] = {}
220+
labels: dict[str, FactorGroup] = {}
220221
for item in env_list_raw:
221222
if isinstance(item, dict) and "product" in item:
222223
raw_groups = item["product"]
223224
if not isinstance(raw_groups, list):
224225
continue
225226
for idx, g in enumerate(raw_groups):
226227
values = expand_factor_group(g)
227-
labels[str(idx)] = values
228+
group = FactorGroup(values=values, default=extract_default(g, values))
229+
labels[str(idx)] = group
228230
if (label := extract_label(g)) is not None:
229-
labels[label] = values
231+
labels[label] = group
230232
return labels
231233

232234

src/tox/session/cmd/schema.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,21 @@ def gen_schema(state: State) -> int:
204204
"prefix": {"type": "string"},
205205
"start": {"type": "integer"},
206206
"stop": {"type": "integer"},
207+
"default": {"type": "string"},
207208
},
208209
"additionalProperties": False,
209210
"description": "range factor group: expands to prefix+N for N in [start, stop]",
210211
},
212+
"factor_values_dict": {
213+
"type": "object",
214+
"required": ["values"],
215+
"properties": {
216+
"values": {"type": "array", "items": {"type": "string"}},
217+
"default": {"type": "string"},
218+
},
219+
"additionalProperties": False,
220+
"description": "factor group with an explicit list and a default used when none of it is active",
221+
},
211222
"factor_labeled_dict": {
212223
"type": "object",
213224
"minProperties": 1,
@@ -217,6 +228,7 @@ def gen_schema(state: State) -> int:
217228
"oneOf": [
218229
{"type": "array", "items": {"type": "string"}},
219230
{"$ref": "#/definitions/factor_range_dict"},
231+
{"$ref": "#/definitions/factor_values_dict"},
220232
]
221233
},
222234
"description": "labeled factor group for {factor:label} substitution",

0 commit comments

Comments
 (0)