From f2be46a7f05b18588bcd51b3f0f7485bdae9a064 Mon Sep 17 00:00:00 2001 From: Daniel Harding Date: Tue, 1 Oct 2024 07:13:08 +0300 Subject: [PATCH] feat: allow setting tags on parametrized sessions (#832) 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. --- docs/config.rst | 57 +++++++++++++++++++++++++++++++++ docs/tutorial.rst | 2 ++ nox/_decorators.py | 2 +- nox/_parametrize.py | 28 ++++++++++++++-- tests/resources/noxfile_tags.py | 7 ++++ tests/test__option_set.py | 2 +- tests/test__parametrize.py | 40 ++++++++++++++++++++++- tests/test_tasks.py | 22 +++++++++---- 8 files changed, 147 insertions(+), 13 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 1a08aa6c..04c0997c 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 `, 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 75677dd2..2634bb41 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 25f13de6..c21f1669 100644 --- a/nox/_decorators.py +++ b/nox/_decorators.py @@ -128,7 +128,7 @@ def __init__(self, func: Func, param_spec: Param) -> None: 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 865cb19b..24b87868 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 @@ def __init__( *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 @@ def __init__( 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 @@ def __str__(self) -> str: __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 @@ def __eq__(self, other: object) -> bool: 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) diff --git a/tests/resources/noxfile_tags.py b/tests/resources/noxfile_tags.py index 960ec52d..10c85faa 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 c474d12f..73f7affb 100644 --- a/tests/test__option_set.py +++ b/tests/test__option_set.py @@ -127,5 +127,5 @@ def test_tag_completer(self): 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 41ea4236..a09c6e11 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 122cc0e8..fdfad628 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 quuz(): 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 corge(): "quux": quux, "quuz": quuz, "corge": corge, + "grault": grault, }, config, )