Skip to content

Commit fa5e8d8

Browse files
feat: add a nox --usage <session> command to print full docstrings for provided sessions (#1064)
* Add `--usage` command scaffold Co-Authored-By: Thomas <5105354+thomaslapiana@users.noreply.github.com> * Get docstring of a session function * Add printing docstrings for usage CLI command * Make help message clearer * Fix bug with `default=False` sessions not working * Add tests for `SessionRunner.full_description` * Add some docs * More tests * Remove some lint * Coverage --------- Co-authored-by: Thomas <5105354+thomaslapiana@users.noreply.github.com>
1 parent f4e3191 commit fa5e8d8

File tree

9 files changed

+174
-0
lines changed

9 files changed

+174
-0
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ python3 -m pip install --user nox
6767
nox -l/--list
6868
```
6969

70+
### Get help on a particular session (if it has a docstring)
71+
72+
```shell
73+
nox --usage test
74+
```
75+
7076
### Run all sessions
7177

7278
```shell

docs/config.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,17 @@ The ``nox --list`` command will show:
5454
Available sessions:
5555
* tests -> Run the test suite.
5656
57+
The ``--list`` command shows the first line of the docstring as the session's description. You can also
58+
show the full docstring of a session using the ``--usage`` option, especially if it has multiple lines.
59+
For example:
60+
61+
.. code-block:: console
62+
63+
$ nox --usage tests
64+
Run the test suite.
65+
66+
The test suite consists of all tests in tests/.
67+
5768
5869
Session name
5970
------------

docs/usage.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ get json output for the selected session. Fields include ``session`` (pretty
3535
name), ``name``, ``description``, ``python`` (null if not specified), ``tags``,
3636
and ``call_spec`` (for parametrized sessions).
3737

38+
To get more information about one particular session at a time, you can use ``--usage``:
39+
40+
.. code-block:: console
41+
42+
nox --usage tests
3843
3944
.. _session_execution_order:
4045

nox/_cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def execute_workflow(args: Namespace) -> int:
6161
tasks.discover_manifest,
6262
tasks.filter_manifest,
6363
tasks.honor_list_request,
64+
tasks.honor_usage_request,
6465
tasks.run_manifest,
6566
tasks.print_summary,
6667
tasks.create_report,

nox/_options.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,13 @@ def _tag_completer(
364364
action="store_true",
365365
help="List all available sessions and exit.",
366366
),
367+
_option_set.Option(
368+
"usage",
369+
"--usage",
370+
group=options.groups["sessions"],
371+
nargs=1,
372+
help="Print the full docstring of a given session and exit. Raises if there is no docstring.",
373+
),
367374
_option_set.Option(
368375
"json",
369376
"--json",

nox/sessions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import datetime
1919
import enum
2020
import hashlib
21+
import inspect
2122
import os
2223
import pathlib
2324
import re
@@ -1011,6 +1012,13 @@ def description(self) -> str | None:
10111012
return doc.strip().split("\n")[0]
10121013
return None
10131014

1015+
@property
1016+
def full_description(self) -> str | None:
1017+
doc = self.func.__doc__
1018+
if doc:
1019+
return inspect.cleandoc(doc)
1020+
return None
1021+
10141022
def __str__(self) -> str:
10151023
sigs = ", ".join(self.signatures)
10161024
return f"Session(name={self.name}, signatures={sigs})"

nox/tasks.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,44 @@ def honor_list_request(manifest: Manifest, global_config: Namespace) -> Manifest
336336
return 0
337337

338338

339+
def honor_usage_request(manifest: Manifest, global_config: Namespace) -> Manifest | int:
340+
"""If --usage was passed, print the full docstring of the session and exit.
341+
Raise an error if the session does not exist or has no docstring to print.
342+
343+
Args:
344+
manifest (~.Manifest): The manifest of sessions to be run.
345+
global_config (~nox.main.GlobalConfig): The global configuration.
346+
347+
Returns:
348+
Union[~.Manifest,int]: ``0`` if usage is printed,
349+
the manifest otherwise (to be sent to the next task).
350+
"""
351+
if not global_config.usage:
352+
return manifest
353+
354+
name = global_config.usage[0]
355+
356+
# Search all sessions, not just the filtered queue, so that
357+
# non-default sessions can also be looked up.
358+
session = None
359+
for _ in manifest._all_sessions:
360+
if _.name == name or name in _.signatures:
361+
session = _
362+
break
363+
364+
if session is None:
365+
logger.error("Session %s not found.", name)
366+
return 1
367+
368+
full_description = session.full_description
369+
if full_description is None:
370+
logger.error("Session %s has no docstring.", name)
371+
return 1
372+
373+
print(full_description)
374+
return 0
375+
376+
339377
def run_manifest(manifest: Manifest, global_config: Namespace) -> list[Result]:
340378
"""Run the full manifest of sessions.
341379

tests/test_sessions.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1196,6 +1196,37 @@ def foo() -> None:
11961196
runner.func = foo # type: ignore[assignment]
11971197
assert runner.description is None
11981198

1199+
def test_full_description_property_one_line(self) -> None:
1200+
def foo() -> None:
1201+
"""Just one line"""
1202+
1203+
runner = self.make_runner()
1204+
runner.func = foo # type: ignore[assignment]
1205+
assert runner.full_description == "Just one line"
1206+
1207+
def test_full_description_property_multi_line(self) -> None:
1208+
def foo() -> None:
1209+
"""Multiline
1210+
1211+
Extra description
1212+
with more details
1213+
"""
1214+
1215+
runner = self.make_runner()
1216+
runner.func = foo # type: ignore[assignment]
1217+
assert (
1218+
runner.full_description
1219+
== "Multiline\n\nExtra description\nwith more details"
1220+
)
1221+
1222+
def test_full_description_property_no_doc(self) -> None:
1223+
def foo() -> None:
1224+
pass
1225+
1226+
runner = self.make_runner()
1227+
runner.func = foo # type: ignore[assignment]
1228+
assert runner.full_description is None
1229+
11991230
def test__create_venv_process_env(self) -> None:
12001231
runner = self.make_runner()
12011232
runner.func.python = False

tests/test_tasks.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,73 @@ def test_create_report() -> None:
786786
open_.assert_called_once_with("/path/to/report", "w", encoding="utf-8")
787787

788788

789+
def test_honor_usage_request_noop() -> None:
790+
config = _options.options.namespace(usage=None)
791+
manifest = typing.cast("Manifest", {"thing": mock.sentinel.THING})
792+
return_value = tasks.honor_usage_request(manifest, global_config=config)
793+
assert return_value is manifest
794+
795+
796+
def test_honor_usage_request_with_docstring(
797+
capsys: pytest.CaptureFixture[builtins.str],
798+
) -> None:
799+
config = _options.options.namespace(usage=["my_session"])
800+
manifest = mock.create_autospec(Manifest)
801+
session = argparse.Namespace(
802+
name="my_session",
803+
signatures=["my_session"],
804+
full_description="Full docstring\n\nWith details",
805+
)
806+
manifest._all_sessions = [session]
807+
return_value = tasks.honor_usage_request(manifest, global_config=config)
808+
assert return_value == 0
809+
out = capsys.readouterr().out
810+
assert "Full docstring\n\nWith details" in out
811+
812+
813+
def test_honor_usage_request_no_docstring() -> None:
814+
config = _options.options.namespace(usage=["my_session"])
815+
manifest = mock.create_autospec(Manifest)
816+
session = argparse.Namespace(
817+
name="my_session",
818+
signatures=["my_session"],
819+
full_description=None,
820+
)
821+
manifest._all_sessions = [session]
822+
return_value = tasks.honor_usage_request(manifest, global_config=config)
823+
assert return_value == 1
824+
825+
826+
def test_honor_usage_request_skips_non_matching_sessions(
827+
capsys: pytest.CaptureFixture[builtins.str],
828+
) -> None:
829+
config = _options.options.namespace(usage=["second"])
830+
manifest = mock.create_autospec(Manifest)
831+
first = argparse.Namespace(
832+
name="first",
833+
signatures=["first"],
834+
full_description="First docstring",
835+
)
836+
second = argparse.Namespace(
837+
name="second",
838+
signatures=["second"],
839+
full_description="Second docstring",
840+
)
841+
manifest._all_sessions = [first, second]
842+
return_value = tasks.honor_usage_request(manifest, global_config=config)
843+
assert return_value == 0
844+
out = capsys.readouterr().out
845+
assert "Second docstring" in out
846+
847+
848+
def test_honor_usage_request_session_not_found() -> None:
849+
config = _options.options.namespace(usage=["nonexistent"])
850+
manifest = mock.create_autospec(Manifest)
851+
manifest._all_sessions = []
852+
return_value = tasks.honor_usage_request(manifest, global_config=config)
853+
assert return_value == 1
854+
855+
789856
def test_final_reduce() -> None:
790857
config = argparse.Namespace()
791858
true = typing.cast("sessions.Result", True) # noqa: FBT003

0 commit comments

Comments
 (0)