Repository: pdm-project/pdm
Pull request #1854 (2023-04-25 20:41:06)

feat(commands): added a `--json` option to `info` and `run` command
## Pull Request Checklist

- [x] A news fragment is added in `news/` describing what is new.
- [x] Test cases added for changed code.

## Describe what you have changed in this PR.

This PR adds a `--json` option (exclusive with other options) to `info` and `run` commands in order to dump those data as JSON.

On my current `pdm` workspace, it give those results

### `pdm run --json`
```json
{
  "complete": {
    "name": "complete",
    "kind": "call",
    "help": "Create autocomplete files for bash and fish",
    "args": "tasks.complete:main"
  },
  "doc": {
    "name": "doc",
    "kind": "shell",
    "help": "Start the dev server for doc preview",
    "args": "cd docs && mkdocs serve"
  },
  "lint": {
    "name": "lint",
    "kind": "cmd",
    "help": "pre-commit run --all-files",
    "args": "pre-commit run --all-files"
  },
  "pre_release": {
    "name": "pre_release",
    "kind": "cmd",
    "help": "python tasks/max_versions.py",
    "args": "python tasks/max_versions.py"
  },
  "release": {
    "name": "release",
    "kind": "cmd",
    "help": "python tasks/release.py",
    "args": "python tasks/release.py"
  },
  "test": {
    "name": "test",
    "kind": "cmd",
    "help": "pytest",
    "args": "pytest"
  },
  "tox": {
    "name": "tox",
    "kind": "cmd",
    "help": "tox",
    "args": "tox"
  }
}
```

### `pdm info --json`
```json
{
  "pdm": {
    "version": "2.5.4.dev5+g8d452e0d.d20230425.editable"
  },
  "python": {
    "interpreter": "/home/noirbizarre/Workspaces/_contribs/pdm/.venv/bin/python",
    "version": "3.11",
    "markers": {
      "implementation_name": "cpython",
      "implementation_version": "3.11.3",
      "os_name": "posix",
      "platform_machine": "x86_64",
      "platform_release": "6.2.12-arch1-1",
      "platform_system": "Linux",
      "platform_version": "#1 SMP PREEMPT_DYNAMIC Thu, 20 Apr 2023 16:11:55 +0000",
      "python_full_version": "3.11.3",
      "platform_python_implementation": "CPython",
      "python_version": "3.11",
      "sys_platform": "linux"
    }
  },
  "project": {
    "root": "/home/noirbizarre/Workspaces/_contribs/pdm",
    "pypackages": "/home/noirbizarre/Workspaces/_contribs/pdm/__pypackages__/3.11"
  }
}
```

## Use cases

Here some possible use cases:
- shell introspection using `jq`
- Conditional steps in CI (if command is present, then ...)
- automatic doc generation
- cross projects inventory/analysis

frostming: LGTM, only a small tweak, the environment markers should belong to `python` namespace
noirbizarre: Updated: the `markers` are now in the `python` object.
PR example is updated too 👆🏼 
frostming: Sorry to miss one: `pypackages` should belong to `project` object.
codecov-commenter: ## [Codecov](https://codecov.io/gh/pdm-project/pdm/pull/1854?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) Report
Patch coverage: **`93.10`**% and project coverage change: **`+0.01`** :tada:
> Comparison is base [(`0cf6578`)](https://codecov.io/gh/pdm-project/pdm/commit/0cf6578981e1f7bb3de055e0ec2dae321177882f?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) 85.31% compared to head [(`a311faa`)](https://codecov.io/gh/pdm-project/pdm/pull/1854?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) 85.33%.

:mega: This organization is not using Codecov’s [GitHub App Integration](https://github.com/apps/codecov). We recommend you install it so Codecov can continue to function properly for your repositories. [Learn more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project)

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1854      +/-   ##
==========================================
+ Coverage   85.31%   85.33%   +0.01%     
==========================================
  Files          99       99              
  Lines        9037     9062      +25     
  Branches     1955     1961       +6     
==========================================
+ Hits         7710     7733      +23     
- Misses        896      897       +1     
- Partials      431      432       +1     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `85.14% <93.10%> (+0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/pdm-project/pdm/pull/1854?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | Coverage Δ | |
|---|---|---|
| [src/pdm/cli/commands/run.py](https://codecov.io/gh/pdm-project/pdm/pull/1854?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvY29tbWFuZHMvcnVuLnB5) | `91.92% <92.00%> (-0.13%)` | :arrow_down: |
| [src/pdm/cli/commands/info.py](https://codecov.io/gh/pdm-project/pdm/pull/1854?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvY29tbWFuZHMvaW5mby5weQ==) | `94.28% <100.00%> (+0.73%)` | :arrow_up: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project)

</details>

[:umbrella: View full report in Codecov by Sentry](https://codecov.io/gh/pdm-project/pdm/pull/1854?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).

noirbizarre: No prb, it is updated 👍🏼 

Patch:
diff --git a/news/1854.feature.md b/news/1854.feature.md
new file mode 100644
index 00000000..f359b312
--- /dev/null
+++ b/news/1854.feature.md
@@ -0,0 +1,1 @@
+Added a `--json` flag to both `run` and `info` command allowing to dump scripts and infos as JSON.
diff --git a/src/pdm/cli/commands/info.py b/src/pdm/cli/commands/info.py
index f8345a97..acc710a2 100644
--- a/src/pdm/cli/commands/info.py
+++ b/src/pdm/cli/commands/info.py
@@ -1,6 +1,8 @@
 import argparse
 import json
 
+from rich import print_json
+
 from pdm.cli.commands.base import BaseCommand
 from pdm.cli.options import ArgumentGroup, venv_option
 from pdm.cli.utils import check_project_file
@@ -22,6 +24,7 @@ class Command(BaseCommand):
         )
         group.add_argument("--packages", action="store_true", help="Show the local packages root")
         group.add_argument("--env", action="store_true", help="Show PEP 508 environment markers")
+        group.add_argument("--json", action="store_true", help="Dump the information in JSON")
         group.add_to_parser(parser)
 
     def handle(self, project: Project, options: argparse.Namespace) -> None:
@@ -38,6 +41,21 @@ class Command(BaseCommand):
             project.core.ui.echo(str(packages_path))
         elif options.env:
             project.core.ui.echo(json.dumps(project.environment.marker_environment, indent=2))
+        elif options.json:
+            print_json(
+                data={
+                    "pdm": {"version": project.core.version},
+                    "python": {
+                        "interpreter": str(interpreter.executable),
+                        "version": interpreter.identifier,
+                        "markers": project.environment.marker_environment,
+                    },
+                    "project": {
+                        "root": str(project.root),
+                        "pypackages": str(packages_path),
+                    },
+                }
+            )
         else:
             for name, value in zip(
                 [
diff --git a/src/pdm/cli/commands/run.py b/src/pdm/cli/commands/run.py
index a0ceede9..cd6c8142 100644
--- a/src/pdm/cli/commands/run.py
+++ b/src/pdm/cli/commands/run.py
@@ -11,6 +11,8 @@ import sys
 from types import FrameType
 from typing import Any, Callable, Iterator, Mapping, NamedTuple, Sequence, cast
 
+from rich import print_json
+
 from pdm import termui
 from pdm.cli.commands.base import BaseCommand
 from pdm.cli.hooks import HookManager
@@ -300,6 +302,33 @@ class TaskRunner:
             )
         self.project.core.ui.display_columns(result, columns)
 
+    def as_json(self) -> dict[str, Any]:
+        out = {}
+        for name in sorted(self.project.scripts):
+            if name == "_":
+                data = out["_"] = dict(name="_", kind="shared", help="Shared options", **self.global_options)
+                _fix_env_file(data)
+                continue
+            task = self.get_task(name)
+            assert task is not None
+            data = out[name] = {
+                "name": name,
+                "kind": task.kind,
+                "help": task.short_description,
+                "args": task.args,  # type: ignore[dict-item]
+            }
+            data.update(**task.options)
+            _fix_env_file(data)
+        return out
+
+
+def _fix_env_file(data: dict[str, Any]) -> dict[str, Any]:
+    env_file = data.get("env_file")
+    if isinstance(env_file, dict):
+        del data["env_file"]
+        data["env_file.override"] = env_file.get("override")
+    return data
+
 
 class Command(BaseCommand):
     """Run commands or scripts with local packages loaded"""
@@ -308,20 +337,28 @@ class Command(BaseCommand):
     arguments = [*BaseCommand.arguments, skip_option, venv_option]
 
     def add_arguments(self, parser: argparse.ArgumentParser) -> None:
-        parser.add_argument(
+        action = parser.add_mutually_exclusive_group()
+        action.add_argument(
             "-l",
             "--list",
             action="store_true",
             help="Show all available scripts defined in pyproject.toml",
         )
-        parser.add_argument(
+        action.add_argument(
+            "-j",
+            "--json",
+            action="store_true",
+            help="Output all scripts infos in JSON",
+        )
+        exec = action.add_argument_group("execution", "Execution parameters")
+        exec.add_argument(
             "-s",
             "--site-packages",
             action="store_true",
             help="Load site-packages from the selected interpreter",
         )
-        parser.add_argument("script", nargs="?", help="The command to run")
-        parser.add_argument(
+        exec.add_argument("script", nargs="?", help="The command to run")
+        exec.add_argument(
             "args",
             nargs=argparse.REMAINDER,
             help="Arguments that will be passed to the command",
@@ -333,6 +370,8 @@ class Command(BaseCommand):
         runner = self.runner_cls(project, hooks=hooks)
         if options.list:
             return runner.show_list()
+        if options.json:
+            return print_json(data=runner.as_json())
         if options.site_packages:
             runner.global_options.update({"site_packages": options.site_packages})
         if not options.script:


Test patch:
diff --git a/tests/cli/test_others.py b/tests/cli/test_others.py
index ba00bca8..fd74f052 100644
--- a/tests/cli/test_others.py
+++ b/tests/cli/test_others.py
@@ -1,3 +1,4 @@
+import json
 from pathlib import Path
 
 import pytest
@@ -50,6 +51,19 @@ def test_info_command(project, pdm):
     assert result.exit_code == 0
 
 
+def test_info_command_json(project, pdm):
+    result = pdm(["info", "--json"], obj=project, strict=True)
+
+    data = json.loads(result.outputs)
+
+    assert data["pdm"]["version"] == project.core.version
+    assert data["python"]["version"] == project.environment.interpreter.identifier
+    assert data["python"]["interpreter"] == str(project.environment.interpreter.executable)
+    assert isinstance(data["python"]["markers"], dict)
+    assert data["project"]["root"] == str(project.root)
+    assert isinstance(data["project"]["pypackages"], str)
+
+
 def test_info_global_project(pdm, tmp_path):
     with cd(tmp_path):
         result = pdm(["info", "-g", "--where"])
diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py
index 43d93054..1cd2eb9b 100644
--- a/tests/cli/test_run.py
+++ b/tests/cli/test_run.py
@@ -386,6 +386,74 @@ def test_run_show_list_of_scripts(project, invoke):
     assert result_lines[4][1:-1].strip() == "test_shell     │ shell     │ shell command"
 
 
+def test_run_json_list_of_scripts(project, invoke):
+    project.pyproject.settings["scripts"] = {
+        "_": {"env_file": ".env"},
+        "test_composite": {"composite": ["test_cmd", "test_script", "test_shell"]},
+        "test_cmd": "flask db upgrade",
+        "test_multi": """\
+            I am a multilines
+            command
+        """,
+        "test_script": {"call": "test_script:main", "help": "call a python function"},
+        "test_shell": {"shell": "echo $FOO", "help": "shell command"},
+        "test_env": {"cmd": "true", "env": {"TEST": "value"}},
+        "test_env_file": {"cmd": "true", "env_file": ".env"},
+        "test_override": {"cmd": "true", "env_file": {"override": ".env"}},
+        "test_site_packages": {"cmd": "true", "site_packages": True},
+        "_private": "true",
+    }
+    project.pyproject.write()
+    result = invoke(["run", "--json"], obj=project, strict=True)
+
+    sep = termui.Emoji.ARROW_SEPARATOR
+    assert json.loads(result.outputs) == {
+        "_": {"name": "_", "help": "Shared options", "kind": "shared", "env_file": ".env"},
+        "test_cmd": {"name": "test_cmd", "help": "flask db upgrade", "kind": "cmd", "args": "flask db upgrade"},
+        "test_composite": {
+            "name": "test_composite",
+            "help": f"test_cmd {sep} test_script {sep} test_shell",
+            "kind": "composite",
+            "args": ["test_cmd", "test_script", "test_shell"],
+        },
+        "test_multi": {
+            "name": "test_multi",
+            "help": f"I am a multilines{termui.Emoji.ELLIPSIS}",
+            "kind": "cmd",
+            "args": "            I am a multilines\n            command\n        ",
+        },
+        "test_script": {
+            "name": "test_script",
+            "help": "call a python function",
+            "kind": "call",
+            "args": "test_script:main",
+        },
+        "test_shell": {"name": "test_shell", "help": "shell command", "kind": "shell", "args": "echo $FOO"},
+        "test_env": {"name": "test_env", "help": "true", "kind": "cmd", "args": "true", "env": {"TEST": "value"}},
+        "test_env_file": {"name": "test_env_file", "help": "true", "kind": "cmd", "args": "true", "env_file": ".env"},
+        "test_override": {
+            "name": "test_override",
+            "help": "true",
+            "kind": "cmd",
+            "args": "true",
+            "env_file.override": ".env",
+        },
+        "test_site_packages": {
+            "name": "test_site_packages",
+            "help": "true",
+            "kind": "cmd",
+            "args": "true",
+            "site_packages": True,
+        },
+        "_private": {
+            "name": "_private",
+            "help": "true",
+            "kind": "cmd",
+            "args": "true",
+        },
+    }
+
+
 @pytest.mark.usefixtures("local_finder")
 @pytest.mark.parametrize("explicit_python", [True, False])
 def test_run_with_another_project_root(project, invoke, capfd, explicit_python):