From f48416a285bddec6cdf6c1fb88d317b19f1769c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Rami=CC=81rez=20Mondrago=CC=81n?= Date: Wed, 15 Jun 2022 01:24:40 -0500 Subject: [PATCH 1/7] feat: Add session tags --- docs/config.rst | 1 + docs/tutorial.rst | 52 +++++++++++++++++++++++++++++++++++ docs/usage.rst | 5 +++- nox/_decorators.py | 4 +++ nox/_options.py | 9 +++++++ nox/manifest.py | 16 ++++++++++- nox/registry.py | 5 +++- nox/sessions.py | 4 +++ nox/tasks.py | 7 +++++ tests/test_manifest.py | 30 +++++++++++++++++++-- tests/test_registry.py | 8 ++++++ tests/test_tasks.py | 61 ++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 197 insertions(+), 5 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 4e720f1c..ff4a58cf 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -429,6 +429,7 @@ The following options can be specified in the Noxfile: * ``nox.options.sessions`` is equivalent to specifying :ref:`-s or --sessions `. If set to an empty list, no sessions will be run if no sessions were given on the command line, and the list of available sessions will be shown instead. * ``nox.options.pythons`` is equivalent to specifying :ref:`-p or --pythons `. * ``nox.options.keywords`` is equivalent to specifying :ref:`-k or --keywords `. +* ``nox.options.tags`` is equivalent to specifying :ref:`-t or --tags `. * ``nox.options.default_venv_backend`` is equivalent to specifying :ref:`-db or --default-venv-backend `. * ``nox.options.force_venv_backend`` is equivalent to specifying :ref:`-fb or --force-venv-backend `. * ``nox.options.reuse_existing_virtualenvs`` is equivalent to specifying :ref:`--reuse-existing-virtualenvs `. You can force this off by specifying ``--no-reuse-existing-virtualenvs`` during invocation. diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 83bc1909..e8d5f160 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -432,6 +432,58 @@ 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 +------------ + +You can add tags to your sessions to help you organize your test suite: + +.. code-block:: python + + @nox.session(tags=["style", "fix"]) + def black(session): + session.install("black") + session.run("black", "my_package") + + @nox.session(tags=["style", "fix"]) + def isort(session): + session.install("isort") + session.run("isort", "my_package") + + @nox.session(tags=["style"]) + def flake8(session): + session.install("flake8") + session.run("flake8", "my_package") + + +If you run ``nox -t style``, Nox will run all three sessions: + +.. code-block:: console + + * black + * isort + * flake8 + + +If you run ``nox -t fix``, Nox will only run the ``black`` and ``isort`` +sessions: + +.. code-block:: console + + * black + * isort + - flake8 + + +If you run ``nox -t style fix``, Nox will all sessions that match *any* of +the tags, so all three sessions: + +.. code-block:: console + + * black + * isort + * flake8 + + Next steps ---------- diff --git a/docs/usage.rst b/docs/usage.rst index c51bf05b..4276fa74 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -74,12 +74,15 @@ If you have a :ref:`configured session's virtualenv `, you ca nox --python 3.8 nox -p 3.7 3.8 -You can also use `pytest-style keywords`_ to filter test sessions: +You can also use `pytest-style keywords`_ using ``-k`` or ``--keywords``, and +tags using ``-t`` or ``--tags`` to filter test sessions: .. code-block:: console nox -k "not lint" nox -k "tests and not lint" + nox -k "not my_tag" + nox -t "my_tag" "my_other_tag" .. _pytest-style keywords: https://docs.pytest.org/en/latest/usage.html#specifying-tests-selecting-tests diff --git a/nox/_decorators.py b/nox/_decorators.py index 19e29622..f8c1b541 100644 --- a/nox/_decorators.py +++ b/nox/_decorators.py @@ -61,6 +61,7 @@ def __init__( venv_backend: Any = None, venv_params: Any = None, should_warn: dict[str, Any] | None = None, + tags: list[str] | None = None, ): self.func = func self.python = python @@ -68,6 +69,7 @@ def __init__( self.venv_backend = venv_backend self.venv_params = venv_params self.should_warn = should_warn or dict() + self.tags = tags or [] def __call__(self, *args: Any, **kwargs: Any) -> Any: return self.func(*args, **kwargs) @@ -81,6 +83,7 @@ def copy(self, name: str | None = None) -> Func: self.venv_backend, self.venv_params, self.should_warn, + self.tags, ) @@ -109,6 +112,7 @@ def __init__(self, func: Func, param_spec: Param) -> None: func.venv_backend, func.venv_params, func.should_warn, + func.tags, ) self.call_spec = call_spec self.session_signature = session_signature diff --git a/nox/_options.py b/nox/_options.py index ce4290ed..4f6b3ad7 100644 --- a/nox/_options.py +++ b/nox/_options.py @@ -285,6 +285,15 @@ def _session_completer( merge_func=functools.partial(_sessions_and_keywords_merge_func, "keywords"), help="Only run sessions that match the given expression.", ), + _option_set.Option( + "tags", + "-t", + "--tags", + group=options.groups["sessions"], + noxfile=True, + nargs="*", + help="Only run sessions with the given tags.", + ), _option_set.Option( "posargs", "posargs", diff --git a/nox/manifest.py b/nox/manifest.py index 5cd4bc14..24bec35e 100644 --- a/nox/manifest.py +++ b/nox/manifest.py @@ -172,9 +172,23 @@ def filter_by_keywords(self, keywords: str) -> None: session names are checked against. """ self._queue = [ - x for x in self._queue if keyword_match(keywords, x.signatures + [x.name]) + x + for x in self._queue + if keyword_match(keywords, x.signatures + x.tags + [x.name]) ] + def filter_by_tags(self, tags: str) -> None: + """Filter sessions using pytest-like tag expressions. + + Args: + tags (str): A Python expression of tags which session names + are checked against. + + Raises: + SyntaxError: If the tag expression is invalid. + """ + self._queue = [x for x in self._queue if set(x.tags).intersection(tags)] + def make_session( self, name: str, func: Func, multi: bool = False ) -> list[SessionRunner]: diff --git a/nox/registry.py b/nox/registry.py index 3c54c8b2..376c55ef 100644 --- a/nox/registry.py +++ b/nox/registry.py @@ -41,6 +41,7 @@ def session_decorator( name: str | None = ..., venv_backend: Any = ..., venv_params: Any = ..., + tags: list[str] | None = ..., ) -> Callable[[F], F]: ... @@ -53,6 +54,7 @@ def session_decorator( name: str | None = None, venv_backend: Any = None, venv_params: Any = None, + tags: list[str] | None = None, ) -> F | Callable[[F], F]: """Designate the decorated function as a session.""" # If `func` is provided, then this is the decorator call with the function @@ -71,6 +73,7 @@ def session_decorator( name=name, venv_backend=venv_backend, venv_params=venv_params, + tags=tags, ) if py is not None and python is not None: @@ -82,7 +85,7 @@ def session_decorator( if python is None: python = py - fn = Func(func, python, reuse_venv, name, venv_backend, venv_params) + fn = Func(func, python, reuse_venv, name, venv_backend, venv_params, tags=tags) _REGISTRY[name or func.__name__] = fn return fn diff --git a/nox/sessions.py b/nox/sessions.py index 4a2bf4cc..d017dc83 100644 --- a/nox/sessions.py +++ b/nox/sessions.py @@ -657,6 +657,10 @@ def __str__(self) -> str: def friendly_name(self) -> str: return self.signatures[0] if self.signatures else self.name + @property + def tags(self) -> list[str]: + return self.func.tags + @property def envdir(self) -> str: return _normalize_path(self.global_config.envdir, self.friendly_name) diff --git a/nox/tasks.py b/nox/tasks.py index 177ba84c..b92c7c44 100644 --- a/nox/tasks.py +++ b/nox/tasks.py @@ -191,6 +191,13 @@ def filter_manifest(manifest: Manifest, global_config: Namespace) -> Manifest | logger.error("Python version selection caused no sessions to be selected.") return 3 + # Filter by tags. + if global_config.tags is not None: + manifest.filter_by_tags(global_config.tags) + if not manifest and not global_config.list_sessions: + logger.error("Tag selection caused no sessions to be selected.") + return 3 + # Filter by keywords. if global_config.keywords: try: diff --git a/tests/test_manifest.py b/tests/test_manifest.py index ff84e8be..97e97ccf 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -33,8 +33,13 @@ def create_mock_sessions(): sessions = collections.OrderedDict() - sessions["foo"] = mock.Mock(spec=(), python=None, venv_backend=None) - sessions["bar"] = mock.Mock(spec=(), python=None, venv_backend=None) + sessions["foo"] = mock.Mock(spec=(), python=None, venv_backend=None, tags=["baz"]) + sessions["bar"] = mock.Mock( + spec=(), + python=None, + venv_backend=None, + tags=["baz", "qux"], + ) return sessions @@ -190,6 +195,27 @@ def test_filter_by_keyword(): assert len(manifest) == 2 manifest.filter_by_keywords("foo") assert len(manifest) == 1 + # Match tags + manifest.filter_by_keywords("not baz") + assert len(manifest) == 0 + + +@pytest.mark.parametrize( + "tags,session_count", + [ + (["baz", "qux"], 2), + (["baz"], 2), + (["qux"], 1), + (["missing"], 0), + (["baz", "missing"], 2), + ], +) +def test_filter_by_tags(tags: list[str], session_count: int): + sessions = create_mock_sessions() + manifest = Manifest(sessions, create_mock_config()) + assert len(manifest) == 2 + manifest.filter_by_tags(tags) + assert len(manifest) == session_count def test_list_all_sessions_with_filter(): diff --git a/tests/test_registry.py b/tests/test_registry.py index aef09527..304b969c 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -60,6 +60,14 @@ def unit_tests(session): assert unit_tests.python == ["3.5", "3.6"] +def test_session_decorator_tags(cleanup_registry): + @registry.session_decorator(tags=["tag-1", "tag-2"]) + def unit_tests(session): + pass + + assert unit_tests.tags == ["tag-1", "tag-2"] + + def test_session_decorator_py_alias(cleanup_registry): @registry.session_decorator(py=["3.5", "3.6"]) def unit_tests(session): diff --git a/tests/test_tasks.py b/tests/test_tasks.py index ce8cb9b0..cc603fe4 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -39,6 +39,7 @@ def session_func(): session_func.python = None session_func.venv_backend = None session_func.should_warn = dict() +session_func.tags = [] def session_func_with_python(): @@ -243,6 +244,66 @@ def test_filter_manifest_keywords_syntax_error(): assert return_value == 3 +@pytest.mark.parametrize( + "tags,session_count", + [ + (None, 4), + (["foo"], 3), + (["bar"], 3), + (["baz"], 1), + (["foo", "bar"], 4), + (["foo", "baz"], 3), + (["foo", "bar", "baz"], 4), + ], +) +def test_filter_manifest_tags(tags, session_count): + @nox.session(tags=["foo"]) + def foo(): + pass + + @nox.session(tags=["bar"]) + def bar(): + pass + + @nox.session(tags=["foo", "bar"]) + def foo_bar(): + pass + + @nox.session(tags=["foo", "bar", "baz"]) + def foo_bar_baz(): + pass + + config = _options.options.namespace( + sessions=None, pythons=(), posargs=[], tags=tags + ) + manifest = Manifest( + { + "foo": foo, + "bar": bar, + "foo_bar": foo_bar, + "foo_bar_baz": foo_bar_baz, + }, + config, + ) + return_value = tasks.filter_manifest(manifest, config) + assert return_value is manifest + assert len(manifest) == session_count + + +def test_filter_manifest_tags_not_found(caplog): + @nox.session(tags=["foo"]) + def foo(): + pass + + config = _options.options.namespace( + sessions=None, pythons=(), posargs=[], tags=["not-found"] + ) + manifest = Manifest({"foo": foo}, config) + return_value = tasks.filter_manifest(manifest, config) + assert return_value == 3 + assert "Tag selection caused no sessions to be selected." in caplog.text + + def test_honor_list_request_noop(): config = _options.options.namespace(list_sessions=False) manifest = {"thing": mock.sentinel.THING} From 3d068a0a85273fabe1e9cca493f12e4f87afafa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Rami=CC=81rez=20Mondrago=CC=81n?= Date: Thu, 16 Jun 2022 20:01:26 -0500 Subject: [PATCH 2/7] Fix type annotation in Manifest.filter_by_tags --- nox/manifest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nox/manifest.py b/nox/manifest.py index 24bec35e..734d7095 100644 --- a/nox/manifest.py +++ b/nox/manifest.py @@ -177,7 +177,7 @@ def filter_by_keywords(self, keywords: str) -> None: if keyword_match(keywords, x.signatures + x.tags + [x.name]) ] - def filter_by_tags(self, tags: str) -> None: + def filter_by_tags(self, tags: list[str]) -> None: """Filter sessions using pytest-like tag expressions. Args: From 9c0fd26dce8c646ecf340958d23995fc36e650dd Mon Sep 17 00:00:00 2001 From: "Edgar R. M" Date: Fri, 17 Jun 2022 17:03:21 -0500 Subject: [PATCH 3/7] Fix filter_by_tags docstring Co-authored-by: Tom Fleet --- nox/manifest.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/nox/manifest.py b/nox/manifest.py index 734d7095..549a0980 100644 --- a/nox/manifest.py +++ b/nox/manifest.py @@ -181,11 +181,8 @@ def filter_by_tags(self, tags: list[str]) -> None: """Filter sessions using pytest-like tag expressions. Args: - tags (str): A Python expression of tags which session names + tags (list[str]): A list of tags which session names are checked against. - - Raises: - SyntaxError: If the tag expression is invalid. """ self._queue = [x for x in self._queue if set(x.tags).intersection(tags)] From e8fbfc5739a621be65e1e3967b5ba95b8059ae00 Mon Sep 17 00:00:00 2001 From: "Edgar R. M" Date: Fri, 17 Jun 2022 17:08:27 -0500 Subject: [PATCH 4/7] Update nox/manifest.py --- nox/manifest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nox/manifest.py b/nox/manifest.py index 549a0980..cbd80f64 100644 --- a/nox/manifest.py +++ b/nox/manifest.py @@ -178,7 +178,7 @@ def filter_by_keywords(self, keywords: str) -> None: ] def filter_by_tags(self, tags: list[str]) -> None: - """Filter sessions using pytest-like tag expressions. + """Filter sessions by their tags. Args: tags (list[str]): A list of tags which session names From 967316c0bdcfaf6b916972fb3cbdae5a44dd3186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Rami=CC=81rez=20Mondrago=CC=81n?= Date: Fri, 17 Jun 2022 21:12:55 -0500 Subject: [PATCH 5/7] Change test suite -> dev tasks --- docs/tutorial.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial.rst b/docs/tutorial.rst index e8d5f160..b617f3e4 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -435,7 +435,7 @@ read more about parametrization and see more examples over at Session tags ------------ -You can add tags to your sessions to help you organize your test suite: +You can add tags to your sessions to help you organize your development tasks: .. code-block:: python From ad7a9faf774e6b010f3bb0c1e07964374a318d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Rami=CC=81rez=20Mondrago=CC=81n?= Date: Fri, 17 Jun 2022 21:33:34 -0500 Subject: [PATCH 6/7] Better test cases for tags - Name the functions something completely different from the tags to ensure only tags are being to filter. - Confirm that tags are case-insensitive. --- nox/manifest.py | 4 ++-- tests/test_tasks.py | 35 +++++++++++++++++++++++------------ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/nox/manifest.py b/nox/manifest.py index cbd80f64..4d941cc2 100644 --- a/nox/manifest.py +++ b/nox/manifest.py @@ -177,11 +177,11 @@ def filter_by_keywords(self, keywords: str) -> None: if keyword_match(keywords, x.signatures + x.tags + [x.name]) ] - def filter_by_tags(self, tags: list[str]) -> None: + def filter_by_tags(self, tags: Sequence[str]) -> None: """Filter sessions by their tags. Args: - tags (list[str]): A list of tags which session names + tags (Sequence[str]): A list of tags which session names are checked against. """ self._queue = [x for x in self._queue if set(x.tags).intersection(tags)] diff --git a/tests/test_tasks.py b/tests/test_tasks.py index cc603fe4..926b52d9 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -258,19 +258,19 @@ def test_filter_manifest_keywords_syntax_error(): ) def test_filter_manifest_tags(tags, session_count): @nox.session(tags=["foo"]) - def foo(): + def qux(): pass @nox.session(tags=["bar"]) - def bar(): + def quux(): pass @nox.session(tags=["foo", "bar"]) - def foo_bar(): + def quuz(): pass @nox.session(tags=["foo", "bar", "baz"]) - def foo_bar_baz(): + def corge(): pass config = _options.options.namespace( @@ -278,10 +278,10 @@ def foo_bar_baz(): ) manifest = Manifest( { - "foo": foo, - "bar": bar, - "foo_bar": foo_bar, - "foo_bar_baz": foo_bar_baz, + "qux": qux, + "quux": quux, + "quuz": quuz, + "corge": corge, }, config, ) @@ -290,15 +290,26 @@ def foo_bar_baz(): assert len(manifest) == session_count -def test_filter_manifest_tags_not_found(caplog): +@pytest.mark.parametrize( + "tags", + [ + ["Foo"], + ["not-found"], + ], + ids=[ + "tags-are-case-insensitive", + "tag-does-not-exist", + ], +) +def test_filter_manifest_tags_not_found(tags, caplog): @nox.session(tags=["foo"]) - def foo(): + def quux(): pass config = _options.options.namespace( - sessions=None, pythons=(), posargs=[], tags=["not-found"] + sessions=None, pythons=(), posargs=[], tags=tags ) - manifest = Manifest({"foo": foo}, config) + manifest = Manifest({"quux": quux}, config) return_value = tasks.filter_manifest(manifest, config) assert return_value == 3 assert "Tag selection caused no sessions to be selected." in caplog.text From 459b21ce96012a5921fc20c328a3de13771a7ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Rami=CC=81rez=20Mondrago=CC=81n?= Date: Mon, 20 Jun 2022 23:08:13 -0500 Subject: [PATCH 7/7] Revert to list[str] type annotation --- nox/manifest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nox/manifest.py b/nox/manifest.py index 4d941cc2..cbd80f64 100644 --- a/nox/manifest.py +++ b/nox/manifest.py @@ -177,11 +177,11 @@ def filter_by_keywords(self, keywords: str) -> None: if keyword_match(keywords, x.signatures + x.tags + [x.name]) ] - def filter_by_tags(self, tags: Sequence[str]) -> None: + def filter_by_tags(self, tags: list[str]) -> None: """Filter sessions by their tags. Args: - tags (Sequence[str]): A list of tags which session names + tags (list[str]): A list of tags which session names are checked against. """ self._queue = [x for x in self._queue if set(x.tags).intersection(tags)]