Repository: tox-dev/tox

Issue:
Doc request: how to do explicit invocations with multiple factors
## What's the problem this feature will solve?

Under tox v3, I was able to pass a string with multiple factors to `tox -e`.
For example, in a project with a `tomli` factor to control the installation of `tomli` for TOML support on py<3.11, the following invocation was valid in v3:
```
tox -e 'py{37,310}{,-tomli}'
```

The above expression would expand to an environment list in the same way that those factors would expand in the `envlist`.

This is particularly useful for declaring CI builds and trying to run a large test matrix efficiently.
A simple project with one factor may have an envlist which reads with multiple python versions and the factor enabled/disabled:
```
envlist = py{37,38,39,310,311}{,-tomli}
```
and in CI, `tox` may be invoked with `tox -e 'py{,-tomli}`.

I have tried to reimplement this kind of logic with tox v4 using labels and `tox r -m`, but for mildly complex cases it becomes very verbose.

The following usage fails with a `ValueError`:
```
tox r -e 'py37{,-tomli}'
```
As does `tox -m ci` with the config
```
labels =
    ci = py37{,-tomli}
```

## Describe the solution you'd like

On the assumption that this is possible and I simply haven't figured it out yet
- if someone could share the solution here, that would help
- let's get it into the documentation as part of the tox4 vs tox3 differences (I'm happy to open a PR once I know how to do this)

## Alternative Solutions

On the assumption that this is no longer possible and it isn't considered a desirable feature
- let's document that it's not supported anymore on the tox4 vs tox3 differences page
- some reasonable usage should be defined which covers this kind of usage, and offered in docs as an alternative

I think at a minimum, the docs should say that you used to be able to write `tox -e 'py{,-foo}'` and now you need to have an explicit `labels` config.

## Additional context

For a concrete example of a project where this broke, and the change which fixes it, refer to
https://github.com/python-jsonschema/check-jsonschema/pull/204

Discussion:

Patch:
diff --git a/docs/changelog/2766.feature.rst b/docs/changelog/2766.feature.rst
new file mode 100644
index 00000000..3e4d6710
--- /dev/null
+++ b/docs/changelog/2766.feature.rst
@@ -0,0 +1,1 @@
+``-f`` can be used multiple times and on hyphenated factors (e.g. ``-f py311-django -f py39``) - by :user:`sirosen`.
diff --git a/docs/changelog/2787.doc.rst b/docs/changelog/2787.doc.rst
new file mode 100644
index 00000000..2b60e96f
--- /dev/null
+++ b/docs/changelog/2787.doc.rst
@@ -0,0 +1,1 @@
+Fix a grammatical typo in docs/user_guide.rst.
diff --git a/docs/user_guide.rst b/docs/user_guide.rst
index 2a15ccac..9a221fee 100644
--- a/docs/user_guide.rst
+++ b/docs/user_guide.rst
@@ -48,7 +48,7 @@ The configuration is split into two type of configuration: core settings are hos
 environment settings hosted under ``testenv:<env_name>``. Under the core section we define that this project has two
 run environments named ``format`` and ``py310`` respectively (we use the ``envlist`` configuration key to do so).
 
-Then we define separately what should the formatting environment (``testenv:format`` section) and the test environment
+Then we define separately the formatting environment (``testenv:format`` section) and the test environment
 (``testenv:py310`` section). For example to format the project we:
 
 - add a description (visible when you type ``tox list`` into the command line),
diff --git a/src/tox/session/env_select.py b/src/tox/session/env_select.py
index c746c25b..f926beda 100644
--- a/src/tox/session/env_select.py
+++ b/src/tox/session/env_select.py
@@ -91,8 +91,19 @@ def register_env_select_flags(
     if multiple:
         help_msg = "labels to evaluate"
         add_to.add_argument("-m", dest="labels", metavar="label", help=help_msg, default=[], type=str, nargs="+")
-        help_msg = "factors to evaluate"
-        add_to.add_argument("-f", dest="factors", metavar="factor", help=help_msg, default=[], type=str, nargs="+")
+        help_msg = (
+            "factors to evaluate (passing multiple factors means 'AND', passing this option multiple times means 'OR')"
+        )
+        add_to.add_argument(
+            "-f",
+            dest="factors",
+            metavar="factor",
+            help=help_msg,
+            default=[],
+            type=str,
+            nargs="+",
+            action="append",
+        )
     help_msg = "exclude all environments selected that match this regular expression"
     add_to.add_argument("--skip-env", dest="skip_env", metavar="re", help=help_msg, default="", type=str)
     return add_to
@@ -288,9 +299,17 @@ class EnvSelector:
         self._manager.tox_add_env_config(pkg_conf, self._state)
         return pkg_env
 
+    def _parse_factors(self) -> tuple[set[str], ...]:
+        # factors is a list of lists, from the combination of nargs="+" and action="append"
+        # also parse hyphenated factors into lists of factors
+        # so that `-f foo-bar` and `-f foo bar` are treated equivalently
+        raw_factors = getattr(self._state.conf.options, "factors", [])
+        return tuple({f for factor in factor_list for f in factor.split("-")} for factor_list in raw_factors)
+
     def _mark_active(self) -> None:
         labels = set(getattr(self._state.conf.options, "labels", []))
-        factors = set(getattr(self._state.conf.options, "factors", []))
+        factors = self._parse_factors()
+
         assert self._defined_envs_ is not None
         if labels or factors:
             for env_info in self._defined_envs_.values():
@@ -302,10 +321,12 @@ class EnvSelector:
                 for env_info in self._defined_envs_.values():
                     if labels.intersection(env_info.env.conf["labels"]):
                         env_info.is_active = True
-            if self._state.conf.options.factors:  # if matches mark it active
+            if factors:  # if matches mark it active
                 for name, env_info in self._defined_envs_.items():
-                    if factors.issubset(set(name.split("-"))):
-                        env_info.is_active = True
+                    for factor_set in factors:
+                        if factor_set.issubset(set(name.split("-"))):
+                            env_info.is_active = True
+                            break
 
     def __getitem__(self, item: str) -> RunToxEnv | PackageToxEnv:
         """


Test patch:
diff --git a/tests/session/test_env_select.py b/tests/session/test_env_select.py
index b6683541..496107a3 100644
--- a/tests/session/test_env_select.py
+++ b/tests/session/test_env_select.py
@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+import pytest
+
 from tox.pytest import MonkeyPatch, ToxProjectCreator
 
 
@@ -61,15 +63,36 @@ def test_label_core_and_trait(tox_project: ToxProjectCreator) -> None:
     outcome.assert_out_err("py310\npy39\nflake8\ntype\n", "")
 
 
-def test_factor_select(tox_project: ToxProjectCreator) -> None:
+@pytest.mark.parametrize(
+    ("selection_arguments", "expect_envs"),
+    [
+        (
+            ("-f", "cov", "django20"),
+            ("py310-django20-cov", "py39-django20-cov"),
+        ),
+        (
+            ("-f", "cov-django20"),
+            ("py310-django20-cov", "py39-django20-cov"),
+        ),
+        (
+            ("-f", "py39", "django20", "-f", "py310", "django21"),
+            ("py310-django21-cov", "py310-django21", "py39-django20-cov", "py39-django20"),
+        ),
+    ],
+)
+def test_factor_select(
+    tox_project: ToxProjectCreator,
+    selection_arguments: tuple[str, ...],
+    expect_envs: tuple[str, ...],
+) -> None:
     ini = """
         [tox]
         env_list = py3{10,9}-{django20,django21}{-cov,}
         """
     project = tox_project({"tox.ini": ini})
-    outcome = project.run("l", "--no-desc", "-f", "cov", "django20")
+    outcome = project.run("l", "--no-desc", *selection_arguments)
     outcome.assert_success()
-    outcome.assert_out_err("py310-django20-cov\npy39-django20-cov\n", "")
+    outcome.assert_out_err("{}\n".format("\n".join(expect_envs)), "")
 
 
 def test_tox_skip_env(tox_project: ToxProjectCreator, monkeypatch: MonkeyPatch) -> None: