Repository: wntrblm/nox
Pull request #665 (2022-10-20 20:28:22)

feat: --json --list-sessions
Closes https://github.com/wntrblm/nox/issues/658.

No tests yet. Putting out an initial design.

Here's the output on nox itself:

```bash
nox --json -l -s cover lint | jq
```
```json
[
  {
    "session": "cover",
    "name": "cover",
    "description": "Coverage analysis.",
    "python": null,
    "tags": [],
    "call_spec": {}
  },
  {
    "session": "lint",
    "name": "lint",
    "description": "Run pre-commit linting.",
    "python": "3.9",
    "tags": [],
    "call_spec": {}
  }
]
```

And it can be processed by jq:

```console
$ nox --json -l -s tests | jq '.[].session'
"tests-3.7"
"tests-3.8"
"tests-3.9"
"tests-3.10"
"tests-3.11"
```

Thoughts?


henryiii: Any thoughts on the general design?
FollowTheProcess: Hey @henryiii sorry this one slipped by me! I like it and was actually surprised at how little needed to change to support this. For those wondering how it ends up looking:

<img width="506" alt="image" src="https://user-images.githubusercontent.com/62767721/198684792-7d67d9a3-cab4-4e66-95a1-bbce3e3524ba.png">

Just a couple of questions more than anything.

When sessions don't have a python the output from this is `null` e.g. from our own `docs` session:

<img width="346" alt="image" src="https://user-images.githubusercontent.com/62767721/198685283-88aff118-3a01-4399-a727-bdbdf9344be5.png">

I wonder if there's something more helpful we can put here? This seems to be when we use the `nox.session` decorator without calling it so Nox uses the python that called it to do the virtualenv, could we populate the JSON python version with the version from `sys.version_info`? A quick POC on my local copy of your branch seems to work okay:

```python
report.append(
      {
          "python": session.func.python or f"{sys.version_info.major}.{sys.version_info.minor}",
      }
  )
```

Finally a very minor one but I'm not sure of the usefulness of the name `call_spec` to anyone other than nox developers. I think a more user friendly term here would be good, something that makes it clearer this is to do with `parametrize`?

Other than that though this looks great!
henryiii: > Null output

This can be used to detect when the Python version doesn't matter. I'd assume a user would want to fill nulls with `3.x` on GitHub Actions, and possibly something else on other platforms. (I thought about injecting `3.x`, but that could be a bit GHA specific, whereas I'd want this to be general? You can always replaced a known, unique string, though, so this wouldn't be too bad.

I wouldn't want to inject the actual running Python because you lose the info that it doesn't matter, and you might not be running the listing on the same Python as you run the check later - that shouldn't matter.

> `call_spec`

I wasn't sure what to call it and being consistent seemed the simplest thing. `¯\_(ツ)_/¯`
FollowTheProcess: Ahh yeah good point re. "python doesn't matter"

> I wasn't sure what to call it and being consistent seemed the simplest thing

That's fair, naming is hard after all! None of this was a blocker for me, this looks great 👍🏻 
henryiii: That doesn't mean it has to be named "call_spec", I just was explaining where the name came from. :)
FollowTheProcess: Well I'm in a similar boat to be honest, I can't really think of a good name either 👀 
FollowTheProcess: When dumping the JSON is it worth prettying it with `indent` or something do you think? Obviously computers don't care but it makes it a bit more human readable without using e.g. `jq`?
henryiii: > is it worth prettying it with `indent`

I considered it, in fact I did do that at first, but dropped it when it's so easy to do it with jq, and the point of the output is to be processed, not to be human readable. But this is not something where saving a few whitespace characters matters, so indent is fine too.

My next plan is to try this on a repo somewhere, to see what processing this looks like. I've fixed my blog post's example something like 3-4 times because I missed things. That will probably make good documentation, too. :)
FollowTheProcess: Yeah agreed, I think it's fine to leave it out. Even if people pipe it to files as soon as they open them in e.g. VSCode it'll probably format it nicely anyway.

> That will probably make good documentation, too. :)

This would be great! If you can find some useful tricks it might be work making a new recipe in the [Cookbook](https://nox.thea.codes/en/stable/cookbook.html)

I'd be interested to see how people might use this actually, it feels like it could be quite powerful in e.g. CI systems but I can't quite think of a use case in anything I'm doing at the moment
FollowTheProcess: Where did you get to on this @henryiii? With some tests this should be ready to go as far as I can tell?, would be great to get this in for the next release :)
henryiii: Finally tried this out: https://github.com/scikit-build/scikit-build-sample-projects/pull/31 :)
henryiii: Okay, unless someone wants something like `--format=json` instead of `--json`, this is ready. Probably needs a cookbook example or two eventually.

Patch:
diff --git a/docs/usage.rst b/docs/usage.rst
index 6bcb0a0..9dbf72e 100644
--- a/docs/usage.rst
+++ b/docs/usage.rst
@@ -30,6 +30,11 @@ To list all available sessions, including parametrized sessions:
     nox --list
     nox --list-sessions
 
+If you'd like to use the output in later processing, you can add ``--json`` to
+get json output for the selected session. Fields include ``session`` (pretty
+name), ``name``, ``description``, ``python`` (null if not specified), ``tags``,
+and ``call_spec`` (for parametrized sessions).
+
 
 .. _session_execution_order:
 
diff --git a/nox/_options.py b/nox/_options.py
index d6ad071..c2506bc 100644
--- a/nox/_options.py
+++ b/nox/_options.py
@@ -264,6 +264,13 @@ options.add_options(
         action="store_true",
         help="List all available sessions and exit.",
     ),
+    _option_set.Option(
+        "json",
+        "--json",
+        group=options.groups["sessions"],
+        action="store_true",
+        help="JSON output formatting. Requires list-sessions currently.",
+    ),
     _option_set.Option(
         "sessions",
         "-s",
diff --git a/nox/tasks.py b/nox/tasks.py
index b92c7c4..a72d570 100644
--- a/nox/tasks.py
+++ b/nox/tasks.py
@@ -179,6 +179,7 @@ def filter_manifest(manifest: Manifest, global_config: Namespace) -> Manifest |
             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)
@@ -264,6 +265,23 @@ def _produce_listing(manifest: Manifest, global_config: Namespace) -> None:
     )
 
 
+def _produce_json_listing(manifest: Manifest, global_config: Namespace) -> None:
+    report = []
+    for session, selected in manifest.list_all_sessions():
+        if selected:
+            report.append(
+                {
+                    "session": session.friendly_name,
+                    "name": session.name,
+                    "description": session.description or "",
+                    "python": session.func.python,
+                    "tags": session.tags,
+                    "call_spec": getattr(session.func, "call_spec", {}),
+                }
+            )
+    print(json.dumps(report))
+
+
 def honor_list_request(manifest: Manifest, global_config: Namespace) -> Manifest | int:
     """If --list was passed, simply list the manifest and exit cleanly.
 
@@ -275,10 +293,18 @@ def honor_list_request(manifest: Manifest, global_config: Namespace) -> Manifest
         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:
+    if not (global_config.list_sessions or global_config.json):
         return manifest
 
-    _produce_listing(manifest, global_config)
+    # JSON output requires list sessions also be specified
+    if global_config.json and not global_config.list_sessions:
+        logger.error("Must specify --list-sessions with --json")
+        return 3
+
+    if global_config.json:
+        _produce_json_listing(manifest, global_config)
+    else:
+        _produce_listing(manifest, global_config)
 
     return 0
 


Test patch:
diff --git a/tests/test_tasks.py b/tests/test_tasks.py
index c6c88db..c6bdbfe 100644
--- a/tests/test_tasks.py
+++ b/tests/test_tasks.py
@@ -401,6 +401,64 @@ def test_honor_list_request_doesnt_print_docstring_if_not_present(capsys):
     assert "Hello I'm a docstring" not in out
 
 
+def test_honor_list_json_request(capsys):
+    config = _options.options.namespace(
+        list_sessions=True, noxfile="noxfile.py", json=True
+    )
+    manifest = mock.create_autospec(Manifest)
+    manifest.list_all_sessions.return_value = [
+        (
+            argparse.Namespace(
+                name="bar",
+                friendly_name="foo",
+                description="simple",
+                func=argparse.Namespace(python="123"),
+                tags=[],
+            ),
+            True,
+        ),
+        (
+            argparse.Namespace(),
+            False,
+        ),
+    ]
+    return_value = tasks.honor_list_request(manifest, global_config=config)
+    assert return_value == 0
+    assert json.loads(capsys.readouterr().out) == [
+        {
+            "session": "foo",
+            "name": "bar",
+            "description": "simple",
+            "python": "123",
+            "tags": [],
+            "call_spec": {},
+        }
+    ]
+
+
+def test_refuse_json_nolist_request(caplog):
+    config = _options.options.namespace(
+        list_sessions=False, noxfile="noxfile.py", json=True
+    )
+    manifest = mock.create_autospec(Manifest)
+    manifest.list_all_sessions.return_value = [
+        (
+            argparse.Namespace(
+                name="bar",
+                friendly_name="foo",
+                description="simple",
+                func=argparse.Namespace(python="123"),
+                tags=[],
+            ),
+            True,
+        )
+    ]
+    return_value = tasks.honor_list_request(manifest, global_config=config)
+    assert return_value == 3
+    (record,) = caplog.records
+    assert record.message == "Must specify --list-sessions with --json"
+
+
 def test_empty_session_list_in_noxfile(capsys):
     config = _options.options.namespace(noxfile="noxfile.py", sessions=(), posargs=[])
     manifest = Manifest({"session": session_func}, config)