diff --git a/news/1854.feature.md b/news/1854.feature.md new file mode 100644 index 0000000000..f359b31228 --- /dev/null +++ b/news/1854.feature.md @@ -0,0 +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 f8345a9767..acc710a2e0 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 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: ) 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 @@ def handle(self, project: Project, options: argparse.Namespace) -> None: 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 a0ceede97d..cd6c81425a 100644 --- a/src/pdm/cli/commands/run.py +++ b/src/pdm/cli/commands/run.py @@ -11,6 +11,8 @@ 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 @@ def show_list(self) -> None: ) 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 @@ def handle(self, project: Project, options: argparse.Namespace) -> None: 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/tests/cli/test_others.py b/tests/cli/test_others.py index ba00bca8c6..fd74f052a8 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 43d93054b7..1cd2eb9b4a 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):