Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/changelog/4046.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
A factor range can now carry a label by nesting it under one, as in ``factors = [{ py_version = { prefix = "3.", start =
12, stop = 14 } }]``, which makes ``{factor:py_version}`` available for ranges - by :user:`gaborbernat`.
6 changes: 6 additions & 0 deletions docs/explanation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,12 @@ The inheritance chain resolves bottom-up: ``env.{name}`` > ``env_base.{template}
Templates themselves are not runnable environments -- they exist only to define shared configuration. Only the generated
environments (template name + factor suffix) appear in ``tox list`` and can be run.

Nesting a group under a name labels it. That works for a list and for a range alike. The alternative was a ``label`` key
sitting beside ``prefix``, ``start`` and ``stop``, which suits a range but has nowhere to live in a plain list. It would
have labeled one shape and left the other out. Nesting covers both with one rule: a single-key table names the group,
and the value describes its factors. Adding a shape later means describing another value, not teaching every earlier
shape one more key.

For the configuration reference, see :ref:`env-base-templates`. For practical recipes, see :ref:`howto_env_base_matrix`.

.. _work-dir-placement:
Expand Down
12 changes: 12 additions & 0 deletions docs/how-to/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1164,6 +1164,18 @@ To override a specific generated environment, add an explicit ``[env.NAME]`` sec

The inheritance chain is: ``[env.{name}]`` > ``[env_base.{template}]`` > ``[env_run_base]``.

Nest a group under a name to label it, so ``{factor:label}`` reaches the value the current environment picked. Both
lists and range dicts take a label:

.. code-block:: toml

[env_base.django]
factors = [
{ py_version = { prefix = "py3", start = 13, stop = 14 } },
{ django_version = ["django42", "django50"] },
]
description = "Test {factor:django_version} on {factor:py_version}"

See :ref:`env-base-templates` for the full reference.

***************************
Expand Down
17 changes: 17 additions & 0 deletions docs/reference/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ This generates 4 environments: ``django-py312-django42``, ``django-py312-django5
- A list of strings: ``["a", "b"]``
- A range dict: ``{ prefix = "py3", start = 12, stop = 14 }`` (generates ``py312``, ``py313``, ``py314``)
- A labeled dict: ``{ ecosystem = ["oci", "python"] }`` (same as a list, but registers the label for substitution)
- A labeled range dict: ``{ py_version = { prefix = "3.", start = 12, stop = 14 } }`` (a range that also registers a
label)
- Mixed in the same ``factors`` list for Cartesian products

The template name itself does not appear as a runnable environment -- only the generated names do.
Expand Down Expand Up @@ -235,6 +237,21 @@ labeled group is an active factor in the current environment. Every factor group
For ``sync-oci-pw``, the description resolves to ``Sync oci artifacts to pw`` and the command receives ``--ecosystem
oci``.

A range dict carries a label when you nest it under one:

.. code-block:: toml

[env_base.test]
factors = [
{ py_version = { prefix = "3.", start = 12, stop = 14 } },
{ django_version = ["django42", "django50"] },
]
description = "Test on Python {factor:py_version} against {factor:django_version}"

.. versionadded:: 4.61

Labels on range dicts.

**Positional labels** -- plain lists automatically get index-based labels:

.. code-block:: toml
Expand Down
15 changes: 14 additions & 1 deletion docs/tutorial/getting-started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,20 @@ environments from factor combinations:
commands = [["pytest"]]

This generates ``test-3.13`` and ``test-3.14``, each inheriting deps and commands from the template. The template itself
inherits from ``env_run_base``, so global defaults still apply. See :ref:`env-base-templates` for details.
inherits from ``env_run_base``, so global defaults still apply.

Ranges save spelling out every version, and nesting one under a name lets the rest of the section refer back to
whichever value the environment picked:

.. code-block:: toml

[env_base.test]
factors = [{ py_version = { prefix = "3.", start = 13, stop = 14 } }]
deps = ["pytest>=8"]
commands = [["pytest"]]
description = "run the tests under Python {factor:py_version}"

See :ref:`env-base-templates` for details.

***************************
Running your environments
Expand Down
24 changes: 16 additions & 8 deletions src/tox/config/loader/toml/_product.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,7 @@ def expand_factor_group(group: TomlTypes) -> list[str]:
if "prefix" in group:
return _expand_range(group)
if len(group) == 1:
label, values = next(iter(group.items()))
if label in _RESERVED_LABELS:
msg = f"'{label}' is reserved and cannot be used as a factor label"
raise TypeError(msg)
if not isinstance(values, list):
msg = f"labeled factor group '{label}' must map to a list, got {type(values).__name__}"
raise TypeError(msg)
return [str(v) for v in values]
return _expand_labeled(*next(iter(group.items())))
msg = f"factor group must be a list, a range dict, or a labeled dict, got {type(group).__name__}"
raise TypeError(msg)

Expand All @@ -73,6 +66,21 @@ def extract_label(group: TomlTypes) -> str | None:
return None


def _expand_labeled(label: str, values: TomlTypes) -> list[str]:
if label in _RESERVED_LABELS:
msg = f"'{label}' is reserved and cannot be used as a factor label"
raise TypeError(msg)
if isinstance(values, dict):
if "prefix" not in values:
msg = f"labeled factor group '{label}' maps to a dict without a 'prefix' key, so it is not a range"
raise TypeError(msg)
return _expand_range(values)
if not isinstance(values, list):
msg = f"labeled factor group '{label}' must map to a list or a range dict, got {type(values).__name__}"
raise TypeError(msg)
return [str(v) for v in values]


def _expand_range(range_dict: dict[str, TomlTypes]) -> list[str]:
prefix: str = str(range_dict["prefix"])
has_start = "start" in range_dict
Expand Down
7 changes: 6 additions & 1 deletion src/tox/session/cmd/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,12 @@ def gen_schema(state: State) -> int:
"minProperties": 1,
"maxProperties": 1,
"not": {"required": ["prefix"]},
"additionalProperties": {"type": "array", "items": {"type": "string"}},
"additionalProperties": {
"oneOf": [
{"type": "array", "items": {"type": "string"}},
{"$ref": "#/definitions/factor_range_dict"},
]
},
"description": "labeled factor group for {factor:label} substitution",
},
"product_factor_group": {
Expand Down
15 changes: 11 additions & 4 deletions src/tox/tox.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -787,10 +787,17 @@
"required": ["prefix"]
},
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
"oneOf": [
{
"type": "array",
"items": {
"type": "string"
}
},
{
"$ref": "#/definitions/factor_range_dict"
}
]
},
"description": "labeled factor group for {factor:label} substitution"
},
Expand Down
15 changes: 14 additions & 1 deletion tests/config/loader/test_toml_product.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,23 @@ def test_expand_factor_group_keyed_dict() -> None:


def test_expand_factor_group_keyed_dict_bad_value_type() -> None:
with pytest.raises(TypeError, match="labeled factor group 'ecosystem' must map to a list"):
with pytest.raises(TypeError, match="labeled factor group 'ecosystem' must map to a list or a range dict"):
expand_factor_group({"ecosystem": "oci"})


def test_expand_factor_group_keyed_range_dict() -> None:
assert expand_factor_group({"py_version": {"prefix": "3.", "start": 12, "stop": 14}}) == ["3.12", "3.13", "3.14"]


def test_expand_factor_group_keyed_dict_without_prefix() -> None:
with pytest.raises(TypeError, match="labeled factor group 'py_version' maps to a dict without a 'prefix' key"):
expand_factor_group({"py_version": {"start": 12, "stop": 14}})


def test_extract_label_keyed_range_dict() -> None:
assert extract_label({"py_version": {"prefix": "3.", "start": 12}}) == "py_version"


def test_expand_factor_group_reserved_label() -> None:
with pytest.raises(TypeError, match="'env' is reserved and cannot be used as a factor label"):
expand_factor_group({"env": ["a", "b"]})
Expand Down
54 changes: 54 additions & 0 deletions tests/config/source/test_toml_env_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,3 +515,57 @@ def test_env_base_factor_label_with_default(tox_project: ToxProjectCreator) -> N
outcome = project.run("c", "-e", "task-oci", "-k", "description")
outcome.assert_success()
outcome.assert_out_err("[testenv:task-oci]\ndescription = Type is fallback\n", "")


def test_env_base_labeled_range_factor_group(tox_project: ToxProjectCreator) -> None:
project = tox_project({
"tox.toml": textwrap.dedent("""\
[env_base.task]
factors = [
{py_version = {prefix = "3.", start = 12, stop = 13}},
{django_version = ["django42", "django50"]},
]
package = "skip"
description = "{factor:py_version} with {factor:django_version}"
commands = [["python", "-c", "print('ok')"]]
"""),
})
outcome = project.run("c", "-e", "task-3.12-django42", "-k", "description")
outcome.assert_success()
outcome.assert_out_err("[testenv:task-3.12-django42]\ndescription = 3.12 with django42\n", "")
outcome = project.run("c", "-e", "task-3.13-django50", "-k", "description")
outcome.assert_success()
outcome.assert_out_err("[testenv:task-3.13-django50]\ndescription = 3.13 with django50\n", "")


def test_env_base_labeled_range_factor_group_generates_envs(tox_project: ToxProjectCreator) -> None:
project = tox_project({
"tox.toml": textwrap.dedent("""\
[env_base.task]
factors = [{py_version = {prefix = "py3", start = 12, stop = 14}}]
package = "skip"
commands = [["python", "-c", "print('ok')"]]
"""),
})
result = project.run("l")
result.assert_success()
for env in ("task-py312", "task-py313", "task-py314"):
assert env in result.out


def test_env_list_product_labeled_range_factor_group(tox_project: ToxProjectCreator) -> None:
project = tox_project({
"tox.toml": textwrap.dedent("""\
env_list = [
{ product = [["sync"], {py_version = {prefix = "3.", start = 12, stop = 13}}] },
]

[env_run_base]
package = "skip"
description = "Sync on {factor:py_version}"
commands = [["python", "-c", "print('ok')"]]
"""),
})
outcome = project.run("c", "-e", "sync-3.13", "-k", "description")
outcome.assert_success()
outcome.assert_out_err("[testenv:sync-3.13]\ndescription = Sync on 3.13\n", "")