diff --git a/docs/changelog/4046.feature.rst b/docs/changelog/4046.feature.rst new file mode 100644 index 000000000..7ab2e8662 --- /dev/null +++ b/docs/changelog/4046.feature.rst @@ -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`. diff --git a/docs/explanation.rst b/docs/explanation.rst index 3f53e9b1e..d1094115e 100644 --- a/docs/explanation.rst +++ b/docs/explanation.rst @@ -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: diff --git a/docs/how-to/usage.rst b/docs/how-to/usage.rst index 948c9ceae..ed733adf5 100644 --- a/docs/how-to/usage.rst +++ b/docs/how-to/usage.rst @@ -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. *************************** diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 4487ef0fe..85a03364a 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -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. @@ -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 diff --git a/docs/tutorial/getting-started.rst b/docs/tutorial/getting-started.rst index ecb47768b..f07537b0e 100644 --- a/docs/tutorial/getting-started.rst +++ b/docs/tutorial/getting-started.rst @@ -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 diff --git a/src/tox/config/loader/toml/_product.py b/src/tox/config/loader/toml/_product.py index 7852a728c..eb87cf051 100644 --- a/src/tox/config/loader/toml/_product.py +++ b/src/tox/config/loader/toml/_product.py @@ -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) @@ -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 diff --git a/src/tox/session/cmd/schema.py b/src/tox/session/cmd/schema.py index 4ba95cf09..47794b378 100644 --- a/src/tox/session/cmd/schema.py +++ b/src/tox/session/cmd/schema.py @@ -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": { diff --git a/src/tox/tox.schema.json b/src/tox/tox.schema.json index 85bbf6ea5..beca7dfb4 100644 --- a/src/tox/tox.schema.json +++ b/src/tox/tox.schema.json @@ -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" }, diff --git a/tests/config/loader/test_toml_product.py b/tests/config/loader/test_toml_product.py index fa92fe1be..6f4767ccc 100644 --- a/tests/config/loader/test_toml_product.py +++ b/tests/config/loader/test_toml_product.py @@ -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"]}) diff --git a/tests/config/source/test_toml_env_base.py b/tests/config/source/test_toml_env_base.py index cbb8fbc46..d0181e6e9 100644 --- a/tests/config/source/test_toml_env_base.py +++ b/tests/config/source/test_toml_env_base.py @@ -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", "")