Repository: wntrblm/nox
Pull request #832 (2024-05-29 08:00:35)

feat: allow setting tags on parametrized sessions
To allow more fine-grained session selection, allow tags to be set on individual parametrized sessions via either a tags argument to the `@nox.parametrize()` decorator, or a tags argument to `nox.param()` (similar to how parametrized session IDs can be specified). Any tags specified this way will be added to any tags passed to the `@nox.session()` decorator.

A couple of examples of how this could be used:

    @nox.session
    @nox.parametrize(
        "db_engine", [
            nox.param("sqlite", tags=["sqlite"])
            nox.param("mysql", tags=["mysql"])
            nox.param("postgresql", tags=["psql"])
        ]
    )
    @nox.parametrize(
        "django", [
            nox.param(">=3,<4", tags=["django3"])
            nox.param(">=4,<5", tags=["django4"])
            nox.param(">=5,<6", tags=["django5"])
        ]
    )
    def test(session, db_engine, django):
        pass

In this case, a developer could run `nox -t sqlite` to run just the tests with all versions of Django but only using the SQLite backend.

Here's a more complex example:

    def generate_params():
        for python in ["3.10", "3.11", 3.12"]:
            for django in [3, 4, 5]:
                tags = []
                if python == "3.12" and django == 5:
                    tags.append("quick")
                if python == "3.12" or django == 5:
                    tags.append("standard")
                yield nox.param([python, django], tags)

    @nox.session
    @nox.parametrize(
        ["python", "django"], generate_params(),
    )
    def test(session, django):
        pass

This tags the Python 3.12/Django 5 combination with the `quick` tag, allowing the developer to run a quick sanity check on the code using a single entry from the test matrix. It also tags any combination of Python 3.12 *or* Django 5 with the `standard` tag, allowing the developer to run the tests using Python 3.12 with all versions of Django along with all versions of Python with Django 5, which should give fairly comprehensive test coverage while only having to run five entries from the test matrix instead of nine.


living180: If this feature/approach is acceptable, I will update the PR to add the necessary documentation updates as well.
henryiii: Seems reasonable.

Patch:
diff --git a/docs/config.rst b/docs/config.rst
index 1a08aa6..04c0997 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -397,6 +397,63 @@ Pythons:
         ...
 
 
+Assigning tags to parametrized sessions
+---------------------------------------
+
+Just as tags can be :ref:`assigned to normal sessions <session tags>`, they can also be assigned to parametrized sessions.  The following examples are both equivalent:
+
+.. code-block:: python
+
+    @nox.session
+    @nox.parametrize('dependency',
+        ['1.0', '2.0'],
+        tags=[['old'], ['new']])
+    @nox.parametrize('database'
+        ['postgres', 'mysql'],
+        tags=[['psql'], ['mysql']])
+    def tests(session, dependency, database):
+        ...
+
+.. code-block:: python
+
+    @nox.session
+    @nox.parametrize('dependency', [
+        nox.param('1.0', tags=['old']),
+        nox.param('2.0', tags=['new']),
+    ])
+    @nox.parametrize('database', [
+        nox.param('postgres', tags=['psql']),
+        nox.param('mysql', tags=['mysql']),
+    ])
+    def tests(session, dependency, database):
+        ...
+
+In either case, running ``nox --tags old`` will run the tests using version 1.0 of the dependency against both database backends, while running ``nox --tags psql`` will run the tests using both versions of the dependency, but only against PostgreSQL.
+
+More sophisticated tag assignment can be performed by passing a generator to the ``@nox.parametrize`` decorator, as seen in the following example:
+
+.. code-block:: python
+
+    def generate_params():
+        for dependency in ["1.0", "1.1", "2.0"]:
+            for database in ["sqlite", "postgresql", "mysql"]:
+                tags = []
+                if dependency == "2.0" and database == "sqlite":
+                    tags.append("quick")
+                if dependency == "2.0" or database == "sqlite":
+                    tags.append("standard")
+                yield nox.param((dependency, database), tags)
+
+    @nox.session
+    @nox.parametrize(
+        ["dependency", "database"], generate_params(),
+    )
+    def tests(session, dependency, database):
+        ...
+
+In this example, the ``quick`` tag is assigned to the single combination of the latest version of the dependency along with the SQLite database backend, allowing a developer to run the tests in a single configuration as a basic sanity test.  The ``standard`` tag, in contrast, selects combinations targeting either the latest version of the dependency *or* the SQLite database backend.  If the developer runs ``tox --tags standard``, the tests will be run against all supported versions of the dependency with the SQLite backend, as well as against all supported database backends under the latest version of the dependency, giving much more comprehensive test coverage while using only five of the potential nine test matrix combinations.
+
+
 The session object
 ------------------
 
diff --git a/docs/tutorial.rst b/docs/tutorial.rst
index 75677dd..2634bb4 100644
--- a/docs/tutorial.rst
+++ b/docs/tutorial.rst
@@ -496,6 +496,8 @@ read more about parametrization and see more examples over at
 .. _pytest's parametrize: https://pytest.org/latest/parametrize.html#_pytest.python.Metafunc.parametrize
 
 
+.. _session tags:
+
 Session tags
 ------------
 
diff --git a/nox/_decorators.py b/nox/_decorators.py
index 25f13de..c21f166 100644
--- a/nox/_decorators.py
+++ b/nox/_decorators.py
@@ -128,7 +128,7 @@ class Call(Func):
             func.venv_backend,
             func.venv_params,
             func.should_warn,
-            func.tags,
+            func.tags + param_spec.tags,
             default=func.default,
         )
         self.call_spec = call_spec
diff --git a/nox/_parametrize.py b/nox/_parametrize.py
index 865cb19..24b8786 100644
--- a/nox/_parametrize.py
+++ b/nox/_parametrize.py
@@ -29,6 +29,8 @@ class Param:
         arg_names (Sequence[str]): The names of the args.
         id (str): An optional ID for this set of parameters. If unspecified,
             it will be generated from the parameters.
+        tags (Sequence[str]): Optional tags to associate with this set of
+            parameters.
     """
 
     def __init__(
@@ -36,6 +38,7 @@ class Param:
         *args: Any,
         arg_names: Sequence[str] | None = None,
         id: str | None = None,
+        tags: Sequence[str] | None = None,
     ) -> None:
         self.args = args
         self.id = id
@@ -45,6 +48,11 @@ class Param:
 
         self.arg_names = tuple(arg_names)
 
+        if tags is None:
+            tags = []
+
+        self.tags = list(tags)
+
     @property
     def call_spec(self) -> dict[str, Any]:
         return dict(zip(self.arg_names, self.args))
@@ -60,13 +68,16 @@ class Param:
     __repr__ = __str__
 
     def copy(self) -> Param:
-        new = self.__class__(*self.args, arg_names=self.arg_names, id=self.id)
+        new = self.__class__(
+            *self.args, arg_names=self.arg_names, id=self.id, tags=self.tags
+        )
         return new
 
     def update(self, other: Param) -> None:
         self.id = ", ".join([str(self), str(other)])
         self.args = self.args + other.args
         self.arg_names = self.arg_names + other.arg_names
+        self.tags = self.tags + other.tags
 
     def __eq__(self, other: object) -> bool:
         if isinstance(other, self.__class__):
@@ -74,6 +85,7 @@ class Param:
                 self.args == other.args
                 and self.arg_names == other.arg_names
                 and self.id == other.id
+                and self.tags == other.tags
             )
         elif isinstance(other, dict):
             return dict(zip(self.arg_names, self.args)) == other
@@ -95,6 +107,7 @@ def parametrize_decorator(
     arg_names: str | Sequence[str],
     arg_values_list: Iterable[ArgValue] | ArgValue,
     ids: Iterable[str | None] | None = None,
+    tags: Iterable[Sequence[str]] | None = None,
 ) -> Callable[[Any], Any]:
     """Parametrize a session.
 
@@ -114,6 +127,8 @@ def parametrize_decorator(
             argument name, for example ``[(1, 'a'), (2, 'b')]``.
         ids (Sequence[str]): Optional sequence of test IDs to use for the
             parametrized arguments.
+        tags (Iterable[Sequence[str]]): Optional iterable of tags to associate
+            with the parametrized arguments.
     """
 
     # Allow args names to be specified as any of 'arg', 'arg,arg2' or ('arg', 'arg2')
@@ -143,14 +158,21 @@ def parametrize_decorator(
     if not ids:
         ids = []
 
+    if tags is None:
+        tags = []
+
     # Generate params for each item in the param_args_values list.
     param_specs: list[Param] = []
-    for param_arg_values, param_id in itertools.zip_longest(_arg_values_list, ids):
+    for param_arg_values, param_id, param_tags in itertools.zip_longest(
+        _arg_values_list, ids, tags
+    ):
         if isinstance(param_arg_values, Param):
             param_spec = param_arg_values
             param_spec.arg_names = tuple(arg_names)
         else:
-            param_spec = Param(*param_arg_values, arg_names=arg_names, id=param_id)
+            param_spec = Param(
+                *param_arg_values, arg_names=arg_names, id=param_id, tags=param_tags
+            )
 
         param_specs.append(param_spec)
 


Test patch:
diff --git a/tests/resources/noxfile_tags.py b/tests/resources/noxfile_tags.py
index 960ec52..10c85fa 100644
--- a/tests/resources/noxfile_tags.py
+++ b/tests/resources/noxfile_tags.py
@@ -16,3 +16,10 @@ def one_tag(unused_session):
 @nox.session(tags=["tag1", "tag2", "tag3"])
 def more_tags(unused_session):
     print("Some more tags here.")
+
+
+@nox.session(tags=["tag4"])
+@nox.parametrize("foo", [nox.param(1, tags=["tag5", "tag6"])])
+@nox.parametrize("bar", [2, 3], tags=[["tag7"]])
+def parametrized_tags(unused_session):
+    print("Parametrized tags here.")
diff --git a/tests/test__option_set.py b/tests/test__option_set.py
index c474d12..73f7aff 100644
--- a/tests/test__option_set.py
+++ b/tests/test__option_set.py
@@ -127,5 +127,5 @@ class TestOptionSet:
             prefix=None, parsed_args=parsed_args
         )
 
-        expected_tags = {"tag1", "tag2", "tag3"}
+        expected_tags = {f"tag{n}" for n in range(1, 8)}
         assert expected_tags == set(actual_tags_from_file)
diff --git a/tests/test__parametrize.py b/tests/test__parametrize.py
index 41ea423..a09c6e1 100644
--- a/tests/test__parametrize.py
+++ b/tests/test__parametrize.py
@@ -197,7 +197,7 @@ def test_generate_calls_simple():
 
 
 def test_generate_calls_multiple_args():
-    f = mock.Mock(should_warn=None, tags=None)
+    f = mock.Mock(should_warn=None, tags=[])
     f.__name__ = "f"
 
     arg_names = ("foo", "abc")
@@ -244,6 +244,44 @@ def test_generate_calls_ids():
     f.assert_called_with(foo=2)
 
 
+def test_generate_calls_tags():
+    f = mock.Mock(should_warn={}, tags=[])
+    f.__name__ = "f"
+
+    arg_names = ("foo",)
+    call_specs = [
+        _parametrize.Param(1, arg_names=arg_names, tags=["tag3"]),
+        _parametrize.Param(1, arg_names=arg_names),
+        _parametrize.Param(2, arg_names=arg_names, tags=["tag4", "tag5"]),
+    ]
+
+    calls = _decorators.Call.generate_calls(f, call_specs)
+
+    assert len(calls) == 3
+    assert calls[0].tags == ["tag3"]
+    assert calls[1].tags == []
+    assert calls[2].tags == ["tag4", "tag5"]
+
+
+def test_generate_calls_merge_tags():
+    f = mock.Mock(should_warn={}, tags=["tag1", "tag2"])
+    f.__name__ = "f"
+
+    arg_names = ("foo",)
+    call_specs = [
+        _parametrize.Param(1, arg_names=arg_names, tags=["tag3"]),
+        _parametrize.Param(1, arg_names=arg_names),
+        _parametrize.Param(2, arg_names=arg_names, tags=["tag4", "tag5"]),
+    ]
+
+    calls = _decorators.Call.generate_calls(f, call_specs)
+
+    assert len(calls) == 3
+    assert calls[0].tags == ["tag1", "tag2", "tag3"]
+    assert calls[1].tags == ["tag1", "tag2"]
+    assert calls[2].tags == ["tag1", "tag2", "tag4", "tag5"]
+
+
 def test_generate_calls_session_python():
     called_with = []
 
diff --git a/tests/test_tasks.py b/tests/test_tasks.py
index 122cc0e..fdfad62 100644
--- a/tests/test_tasks.py
+++ b/tests/test_tasks.py
@@ -254,13 +254,14 @@ def test_filter_manifest_keywords_syntax_error():
 @pytest.mark.parametrize(
     "tags,session_count",
     [
-        (None, 4),
-        (["foo"], 3),
-        (["bar"], 3),
-        (["baz"], 1),
-        (["foo", "bar"], 4),
-        (["foo", "baz"], 3),
-        (["foo", "bar", "baz"], 4),
+        (None, 8),
+        (["foo"], 7),
+        (["bar"], 5),
+        (["baz"], 3),
+        (["foo", "bar"], 8),
+        (["foo", "baz"], 7),
+        (["bar", "baz"], 6),
+        (["foo", "bar", "baz"], 8),
     ],
 )
 def test_filter_manifest_tags(tags, session_count):
@@ -280,6 +281,12 @@ def test_filter_manifest_tags(tags, session_count):
     def corge():
         pass
 
+    @nox.session(tags=["foo"])
+    @nox.parametrize("a", [1, nox.param(2, tags=["bar"])])
+    @nox.parametrize("b", [3, 4], tags=[["baz"]])
+    def grault():
+        pass
+
     config = _options.options.namespace(
         sessions=None, pythons=(), posargs=[], tags=tags
     )
@@ -289,6 +296,7 @@ def test_filter_manifest_tags(tags, session_count):
             "quux": quux,
             "quuz": quuz,
             "corge": corge,
+            "grault": grault,
         },
         config,
     )