Repository: theacodes/nox (python)

Issue:
sessions: empty list of default sessions could print help
**How would this feature be useful?**

Sometimes I'd like for the "default" run of nox (with no sessions) to print a help dialog instead; say if there's no clear "default" like `["lint", "tests"]`.

**Describe the solution you'd like**

I'd differentiate between `nox.options.sessions = None` and an empty iterable, `nox.options.sessions = []`. The former would be the default, and would run all sessions. The latter would instead print the `nox -l` help if no sessions were passed - it would mean "no sessions". Currently, `nox.options.sessions = []` does exactly the same thing as `nox.options.sessions = None` and runs all sessions, which is clearly not what would be expected from setting an empty list here.

**Describe alternatives you've considered**

A special token or setting could be used, but since `nox.options.sessions = []` misbehaves currently, I think that would be a natural place to put it.


Pull request description:
feat: support setting an empty session list
Closes #465.

Setting an empty session list in the noxfile will default to listing the available sessions.


Patch:
diff --git a/docs/config.rst b/docs/config.rst
index 7773da1..4e720f1 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -426,7 +426,7 @@ Or, if you wanted to provide a set of sessions that are run by default:
 The following options can be specified in the Noxfile:
 
 * ``nox.options.envdir`` is equivalent to specifying :ref:`--envdir <opt-envdir>`.
-* ``nox.options.sessions`` is equivalent to specifying :ref:`-s or --sessions <opt-sessions-pythons-and-keywords>`.
+* ``nox.options.sessions`` is equivalent to specifying :ref:`-s or --sessions <opt-sessions-pythons-and-keywords>`. 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 <opt-sessions-pythons-and-keywords>`.
 * ``nox.options.keywords`` is equivalent to specifying :ref:`-k or --keywords <opt-sessions-pythons-and-keywords>`.
 * ``nox.options.default_venv_backend`` is equivalent to specifying :ref:`-db or --default-venv-backend <opt-default-venv-backend>`.
diff --git a/docs/tutorial.rst b/docs/tutorial.rst
index 3441036..f4aac4a 100644
--- a/docs/tutorial.rst
+++ b/docs/tutorial.rst
@@ -258,6 +258,20 @@ And if you run ``nox --sessions lint`` Nox will just run the lint session:
     nox > ...
     nox > Session lint was successful.
 
+
+In the noxfile, you can specify a default set of sessions to run. If so, a plain
+``nox`` call will only trigger certain sessions:
+
+.. code-block:: python
+
+    import nox
+
+    nox.options.sessions = ["lint", "test"]
+
+If you set this to an empty list, Nox will not run any sessions by default, and
+will print a helpful message with the ``--list`` output when a user does not
+specify a session to run.
+
 There are many more ways to select and run sessions! You can read more about
 invoking Nox in :doc:`usage`.
 
diff --git a/nox/__main__.py b/nox/__main__.py
index d6043ac..34786a6 100644
--- a/nox/__main__.py
+++ b/nox/__main__.py
@@ -50,7 +50,6 @@ def main() -> None:
             tasks.discover_manifest,
             tasks.filter_manifest,
             tasks.honor_list_request,
-            tasks.verify_manifest_nonempty,
             tasks.run_manifest,
             tasks.print_summary,
             tasks.create_report,
diff --git a/nox/tasks.py b/nox/tasks.py
index 9b149af..a33f313 100644
--- a/nox/tasks.py
+++ b/nox/tasks.py
@@ -167,22 +167,33 @@ def filter_manifest(
             the manifest otherwise (to be sent to the next task).
 
     """
+    # Shouldn't happen unless the noxfile is empty
+    if not manifest:
+        logger.error(f"No sessions found in {global_config.noxfile}.")
+        return 3
+
     # Filter by the name of any explicit sessions.
     # This can raise KeyError if a specified session does not exist;
-    # log this if it happens.
-    if global_config.sessions:
+    # log this if it happens. The sessions does not come from the noxfile
+    # if keywords is not empty.
+    if global_config.sessions is not None:
         try:
             manifest.filter_by_name(global_config.sessions)
         except KeyError as exc:
             logger.error("Error while collecting sessions.")
             logger.error(exc.args[0])
             return 3
+    if not manifest and not global_config.list_sessions:
+        print("No sessions selected. Please select a session with -s <session name>.\n")
+        _produce_listing(manifest, global_config)
+        return 0
 
     # Filter by python interpreter versions.
-    # This function never errors, but may cause an empty list of sessions
-    # (which is an error condition later).
     if global_config.pythons:
         manifest.filter_by_python_interpreter(global_config.pythons)
+        if not manifest and not global_config.list_sessions:
+            logger.error("Python version selection caused no sessions to be selected.")
+            return 3
 
     # Filter by keywords.
     if global_config.keywords:
@@ -198,28 +209,19 @@ def filter_manifest(
         # (which is an error condition later).
         manifest.filter_by_keywords(global_config.keywords)
 
+    if not manifest and not global_config.list_sessions:
+        logger.error("No sessions selected after filtering by keyword.")
+        return 3
+
     # Return the modified manifest.
     return manifest
 
 
-def honor_list_request(
-    manifest: Manifest, global_config: Namespace
-) -> Union[Manifest, int]:
-    """If --list was passed, simply list the manifest and exit cleanly.
-
-    Args:
-        manifest (~.Manifest): The manifest of sessions to be run.
-        global_config (~nox.main.GlobalConfig): The global configuration.
-
-    Returns:
-        Union[~.Manifest,int]: ``0`` if a listing is all that is requested,
-            the manifest otherwise (to be sent to the next task).
-    """
-    if not global_config.list_sessions:
-        return manifest
-
+def _produce_listing(manifest: Manifest, global_config: Namespace) -> None:
     # If the user just asked for a list of sessions, print that
-    # and any docstring specified in noxfile.py and be done.
+    # and any docstring specified in noxfile.py and be done. This
+    # can also be called if noxfile sessions is an empty list.
+
     if manifest.module_docstring:
         print(manifest.module_docstring.strip(), end="\n\n")
 
@@ -255,25 +257,27 @@ def honor_list_request(
     print(
         f"\nsessions marked with {selected_color}*{reset} are selected, sessions marked with {skipped_color}-{reset} are skipped."
     )
-    return 0
 
 
-def verify_manifest_nonempty(
+def honor_list_request(
     manifest: Manifest, global_config: Namespace
 ) -> Union[Manifest, int]:
-    """Abort with an error code if the manifest is empty.
+    """If --list was passed, simply list the manifest and exit cleanly.
 
     Args:
         manifest (~.Manifest): The manifest of sessions to be run.
         global_config (~nox.main.GlobalConfig): The global configuration.
 
     Returns:
-        Union[~.Manifest,int]: ``3`` on an empty manifest, the manifest
-            otherwise.
+        Union[~.Manifest,int]: ``0`` if a listing is all that is requested,
+            the manifest otherwise (to be sent to the next task).
     """
-    if not manifest:
-        return 3
-    return manifest
+    if not global_config.list_sessions:
+        return manifest
+
+    _produce_listing(manifest, global_config)
+
+    return 0
 
 
 def run_manifest(manifest: Manifest, global_config: Namespace) -> List[Result]:


Test patch:
diff --git a/tests/test_tasks.py b/tests/test_tasks.py
index 906ef24..50f7f86 100644
--- a/tests/test_tasks.py
+++ b/tests/test_tasks.py
@@ -209,7 +209,7 @@ def test_discover_session_functions_decorator():
 
 def test_filter_manifest():
     config = _options.options.namespace(
-        sessions=(), pythons=(), keywords=(), posargs=[]
+        sessions=None, pythons=(), keywords=(), posargs=[]
     )
     manifest = Manifest({"foo": session_func, "bar": session_func}, config)
     return_value = tasks.filter_manifest(manifest, config)
@@ -228,7 +228,7 @@ def test_filter_manifest_not_found():
 
 def test_filter_manifest_pythons():
     config = _options.options.namespace(
-        sessions=(), pythons=("3.8",), keywords=(), posargs=[]
+        sessions=None, pythons=("3.8",), keywords=(), posargs=[]
     )
     manifest = Manifest(
         {"foo": session_func_with_python, "bar": session_func, "baz": session_func},
@@ -239,9 +239,22 @@ def test_filter_manifest_pythons():
     assert len(manifest) == 1
 
 
+def test_filter_manifest_pythons_not_found(caplog):
+    config = _options.options.namespace(
+        sessions=None, pythons=("1.2",), keywords=(), posargs=[]
+    )
+    manifest = Manifest(
+        {"foo": session_func_with_python, "bar": session_func, "baz": session_func},
+        config,
+    )
+    return_value = tasks.filter_manifest(manifest, config)
+    assert return_value == 3
+    assert "Python version selection caused no sessions to be selected." in caplog.text
+
+
 def test_filter_manifest_keywords():
     config = _options.options.namespace(
-        sessions=(), pythons=(), keywords="foo or bar", posargs=[]
+        sessions=None, pythons=(), keywords="foo or bar", posargs=[]
     )
     manifest = Manifest(
         {"foo": session_func, "bar": session_func, "baz": session_func}, config
@@ -251,9 +264,21 @@ def test_filter_manifest_keywords():
     assert len(manifest) == 2
 
 
+def test_filter_manifest_keywords_not_found(caplog):
+    config = _options.options.namespace(
+        sessions=None, pythons=(), keywords="mouse or python", posargs=[]
+    )
+    manifest = Manifest(
+        {"foo": session_func, "bar": session_func, "baz": session_func}, config
+    )
+    return_value = tasks.filter_manifest(manifest, config)
+    assert return_value == 3
+    assert "No sessions selected after filtering by keyword." in caplog.text
+
+
 def test_filter_manifest_keywords_syntax_error():
     config = _options.options.namespace(
-        sessions=(), pythons=(), keywords="foo:bar", posargs=[]
+        sessions=None, pythons=(), keywords="foo:bar", posargs=[]
     )
     manifest = Manifest({"foo_bar": session_func, "foo_baz": session_func}, config)
     return_value = tasks.filter_manifest(manifest, config)
@@ -346,20 +371,43 @@ def test_honor_list_request_doesnt_print_docstring_if_not_present(capsys):
     assert "Hello I'm a docstring" not in out
 
 
+def test_empty_session_list_in_noxfile(capsys):
+    config = _options.options.namespace(noxfile="noxfile.py", sessions=(), posargs=[])
+    manifest = Manifest({"session": session_func}, config)
+    return_value = tasks.filter_manifest(manifest, global_config=config)
+    assert return_value == 0
+    assert "No sessions selected." in capsys.readouterr().out
+
+
+def test_empty_session_None_in_noxfile(capsys):
+    config = _options.options.namespace(noxfile="noxfile.py", sessions=None, posargs=[])
+    manifest = Manifest({"session": session_func}, config)
+    return_value = tasks.filter_manifest(manifest, global_config=config)
+    assert return_value == manifest
+
+
 def test_verify_manifest_empty():
     config = _options.options.namespace(sessions=(), keywords=())
     manifest = Manifest({}, config)
-    return_value = tasks.verify_manifest_nonempty(manifest, global_config=config)
+    return_value = tasks.filter_manifest(manifest, global_config=config)
     assert return_value == 3
 
 
 def test_verify_manifest_nonempty():
-    config = _options.options.namespace(sessions=(), keywords=(), posargs=[])
+    config = _options.options.namespace(sessions=None, keywords=(), posargs=[])
     manifest = Manifest({"session": session_func}, config)
-    return_value = tasks.verify_manifest_nonempty(manifest, global_config=config)
+    return_value = tasks.filter_manifest(manifest, global_config=config)
     assert return_value == manifest
 
 
+def test_verify_manifest_list(capsys):
+    config = _options.options.namespace(sessions=(), keywords=(), posargs=[])
+    manifest = Manifest({"session": session_func}, config)
+    return_value = tasks.filter_manifest(manifest, global_config=config)
+    assert return_value == 0
+    assert "Please select a session" in capsys.readouterr().out
+
+
 @pytest.mark.parametrize("with_warnings", [False, True], ids="with_warnings={}".format)
 def test_run_manifest(with_warnings):
     # Set up a valid manifest.