Repository: pdm-project/pdm
Pull request #1853 (2023-04-25 20:38:15)

doc(cli): improve cli reference display
## Pull Request Checklist

- [ ] A news fragment is added in `news/` describing what is new (I don't think this requires a dedicated changelog entry, but if this is the case, I'll add it)
- [x] Test cases added for changed code. (NA: doc generate properly)

## Describe what you have changed in this PR.

Improve cli reference display by:

- fixes dunders being rendered as bold markdown
- adds the missing spaces before `(default:` or `[env var`
- monospace some known reccuring references (`pyproject.toml`, `pdm.lock`, `.pdm-python`, `:all`, `:pre` and `:post`)
- apply the same casing to all help messages (Title)
- use the same format for all env vars
- use the same format (backticked) for parameters in help

codecov-commenter: ## [Codecov](https://codecov.io/gh/pdm-project/pdm/pull/1853?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) Report
Patch coverage: **`100.00`**% and no project coverage change.
> 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 [(`6278b78`)](https://codecov.io/gh/pdm-project/pdm/pull/1853?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) 85.32%.

: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    #1853   +/-   ##
=======================================
  Coverage   85.31%   85.32%           
=======================================
  Files          99       99           
  Lines        9037     9042    +5     
  Branches     1955     1955           
=======================================
+ Hits         7710     7715    +5     
  Misses        896      896           
  Partials      431      431           
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `85.13% <100.00%> (+<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/1853?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/base.py](https://codecov.io/gh/pdm-project/pdm/pull/1853?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvY29tbWFuZHMvYmFzZS5weQ==) | `100.00% <ø> (ø)` | |
| [src/pdm/cli/commands/build.py](https://codecov.io/gh/pdm-project/pdm/pull/1853?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvY29tbWFuZHMvYnVpbGQucHk=) | `84.61% <ø> (ø)` | |
| [src/pdm/cli/options.py](https://codecov.io/gh/pdm-project/pdm/pull/1853?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvb3B0aW9ucy5weQ==) | `93.80% <100.00%> (ø)` | |
| [src/pdm/cli/utils.py](https://codecov.io/gh/pdm-project/pdm/pull/1853?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvdXRpbHMucHk=) | `83.23% <100.00%> (+0.29%)` | :arrow_up: |
| [src/pdm/core.py](https://codecov.io/gh/pdm-project/pdm/pull/1853?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jb3JlLnB5) | `93.02% <100.00%> (ø)` | |

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/1853?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).


Patch:
diff --git a/docs/docs/reference/cli.md b/docs/docs/reference/cli.md
index ff01a4b9..797e4542 100644
--- a/docs/docs/reference/cli.md
+++ b/docs/docs/reference/cli.md
@@ -2,10 +2,22 @@
 
 ```python exec="1" idprefix=""
 import argparse
+import re
 from pdm.core import Core
 
 parser = Core().parser
 
+MONOSPACED = ("pyproject.toml", "pdm.lock", ".pdm-python", ":pre", ":post", ":all")
+
+def clean_help(help: str) -> str:
+    # Make dunders monospaced avoiding italic markdown rendering
+    help = re.sub(r"__([\w\d\_]+)__", r"`__\1__`", help)
+    # Make env vars monospaced
+    help = re.sub(r"env var: ([A-Z_]+)", r"env var: `\1`", help)
+    for monospaced in MONOSPACED:
+        help = re.sub(rf"\s(['\"]?{monospaced}['\"]?)", f"`{monospaced}`", help)
+    return help
+
 
 def render_parser(
     parser: argparse.ArgumentParser, title: str, heading_level: int = 2
@@ -39,9 +51,9 @@ def render_parser(
                 line = f"- {', '.join(opts)}"
             if action.metavar:
                 line += f" `{action.metavar}`"
-            line += f": {action.help}"
+            line += f": {clean_help(action.help)}"
             if action.default and action.default != argparse.SUPPRESS:
-                line += f"(default: `{action.default}`)"
+                line += f" (default: `{action.default}`)"
             result.append(line)
         result.append("")
 
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/base.py b/src/pdm/cli/commands/base.py
index edf1f25a..7618eb58 100644
--- a/src/pdm/cli/commands/base.py
+++ b/src/pdm/cli/commands/base.py
@@ -5,7 +5,6 @@ from argparse import _SubParsersAction
 from typing import Any
 
 from pdm.cli.options import Option, global_option, project_option, verbose_option
-from pdm.cli.utils import PdmFormatter
 from pdm.project import Project
 
 
@@ -40,7 +39,6 @@ class BaseCommand:
             name,
             description=help_text,
             help=help_text,
-            formatter_class=PdmFormatter,
             **kwargs,
         )
         command = cls(parser)
diff --git a/src/pdm/cli/commands/build.py b/src/pdm/cli/commands/build.py
index 7df67563..315ed7d4 100644
--- a/src/pdm/cli/commands/build.py
+++ b/src/pdm/cli/commands/build.py
@@ -45,8 +45,8 @@ class Command(BaseCommand):
             "-C",
             action="append",
             help="Pass options to the backend. options with a value must be "
-            'specified after "=": "--config-setting=--opt(=value)" '
-            'or "-C--opt(=value)"',
+            'specified after "=": `--config-setting=--opt(=value)` '
+            "or `-C--opt(=value)`",
         )
 
     def handle(self, project: Project, options: argparse.Namespace) -> None:
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:
diff --git a/src/pdm/cli/options.py b/src/pdm/cli/options.py
index 237c950a..62b94a47 100644
--- a/src/pdm/cli/options.py
+++ b/src/pdm/cli/options.py
@@ -127,7 +127,7 @@ verbose_option = Option(
     "--verbose",
     action="count",
     default=0,
-    help="-v for detailed output and -vv for more detailed",
+    help="Use `-v` for detailed output and `-vv` for more detailed",
 )
 
 
@@ -184,7 +184,7 @@ groups_group.add_argument(
     metavar="GROUP",
     action=split_lists(","),
     help="Select group of optional-dependencies separated by comma "
-    "or dev-dependencies(with -d). Can be supplied multiple times, "
+    "or dev-dependencies (with `-d`). Can be supplied multiple times, "
     'use ":all" to include all groups under the same species.',
     default=[],
 )
@@ -296,12 +296,12 @@ global_option = Option(
 )
 
 clean_group = ArgumentGroup("clean", is_mutually_exclusive=True)
-clean_group.add_argument("--clean", action="store_true", help="clean packages not in the lockfile")
-clean_group.add_argument("--only-keep", action="store_true", help="only keep the selected packages")
+clean_group.add_argument("--clean", action="store_true", help="Clean packages not in the lockfile")
+clean_group.add_argument("--only-keep", action="store_true", help="Only keep the selected packages")
 
 sync_group = ArgumentGroup("sync", is_mutually_exclusive=True)
-sync_group.add_argument("--sync", action="store_true", dest="sync", help="sync packages")
-sync_group.add_argument("--no-sync", action="store_false", dest="sync", help="don't sync packages")
+sync_group.add_argument("--sync", action="store_true", dest="sync", help="Sync packages")
+sync_group.add_argument("--no-sync", action="store_false", dest="sync", help="Don't sync packages")
 
 packages_group = ArgumentGroup("Package Arguments")
 packages_group.add_argument(
diff --git a/src/pdm/cli/utils.py b/src/pdm/cli/utils.py
index ad08ad97..5a024d1b 100644
--- a/src/pdm/cli/utils.py
+++ b/src/pdm/cli/utils.py
@@ -55,23 +55,6 @@ if TYPE_CHECKING:
     from pdm.project import Project
 
 
-class ErrorArgumentParser(argparse.ArgumentParser):
-    """A subclass of argparse.ArgumentParser that raises
-    parsing error rather than exiting.
-
-    This does the same as passing exit_on_error=False on Python 3.9+
-    """
-
-    def _parse_known_args(
-        self, arg_strings: list[str], namespace: argparse.Namespace
-    ) -> tuple[argparse.Namespace, list[str]]:
-        try:
-            return super()._parse_known_args(arg_strings, namespace)
-        except argparse.ArgumentError as e:
-            # We raise a dedicated error to avoid being caught by the caller
-            raise PdmArgumentError(e) from e
-
-
 class PdmFormatter(argparse.RawDescriptionHelpFormatter):
     def start_section(self, heading: str | None) -> None:
         return super().start_section(termui.style(heading.title() if heading else "", style="warning"))
@@ -137,6 +120,35 @@ class PdmFormatter(argparse.RawDescriptionHelpFormatter):
         return self._join_parts(parts)
 
 
+class ArgumentParser(argparse.ArgumentParser):
+    """A standard argument parser but with title-cased help."""
+
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        kwargs["formatter_class"] = PdmFormatter
+        kwargs["add_help"] = False
+        super().__init__(*args, **kwargs)
+        self.add_argument(
+            "-h", "--help", action="help", default=argparse.SUPPRESS, help="Show this help message and exit."
+        )
+
+
+class ErrorArgumentParser(ArgumentParser):
+    """A subclass of argparse.ArgumentParser that raises
+    parsing error rather than exiting.
+
+    This does the same as passing exit_on_error=False on Python 3.9+
+    """
+
+    def _parse_known_args(
+        self, arg_strings: list[str], namespace: argparse.Namespace
+    ) -> tuple[argparse.Namespace, list[str]]:
+        try:
+            return super()._parse_known_args(arg_strings, namespace)
+        except argparse.ArgumentError as e:
+            # We raise a dedicated error to avoid being caught by the caller
+            raise PdmArgumentError(e) from e
+
+
 class Package:
     """An internal class for the convenience of dependency graph building."""
 
diff --git a/src/pdm/core.py b/src/pdm/core.py
index 21d94dce..d8db8824 100644
--- a/src/pdm/core.py
+++ b/src/pdm/core.py
@@ -23,7 +23,7 @@ from pdm.__version__ import __version__
 from pdm.cli.actions import check_update
 from pdm.cli.commands.base import BaseCommand
 from pdm.cli.options import ignore_python_option, pep582_option, verbose_option
-from pdm.cli.utils import ErrorArgumentParser, PdmFormatter
+from pdm.cli.utils import ArgumentParser, ErrorArgumentParser
 from pdm.compat import importlib_metadata
 from pdm.exceptions import PdmArgumentError, PdmUsageError
 from pdm.installers import InstallManager, Synchronizer
@@ -57,7 +57,6 @@ class Core:
         self.parser = ErrorArgumentParser(
             prog="pdm",
             description=__doc__,
-            formatter_class=PdmFormatter,
         )
         self.parser.is_root = True  # type: ignore[attr-defined]
         self.parser.add_argument(
@@ -68,19 +67,19 @@ class Core:
                 termui.style("PDM", style="bold"),
                 termui.style(self.version, style="success"),
             ),
-            help="show the version and exit",
+            help="Show the version and exit",
         )
         self.parser.add_argument(
             "-c",
             "--config",
-            help="Specify another config file path(env var: PDM_CONFIG_FILE)",
+            help="Specify another config file path [env var: PDM_CONFIG_FILE] ",
         )
         self.parser._positionals.title = "Commands"
         verbose_option.add_to_parser(self.parser)
         ignore_python_option.add_to_parser(self.parser)
         pep582_option.add_to_parser(self.parser)
 
-        self.subparsers = self.parser.add_subparsers(parser_class=argparse.ArgumentParser)
+        self.subparsers = self.parser.add_subparsers(parser_class=ArgumentParser)
         for _, name, _ in pkgutil.iter_modules(COMMANDS_MODULE_PATH):
             module = importlib.import_module(f"pdm.cli.commands.{name}", __name__)
             try:


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):