Skip to content

feat(CLI): Yarn-like root scripts fallback #1159

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/docs/usage/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ pdm run flask run -p 54321

It will run `flask run -p 54321` in the environment that is aware of packages in `__pypackages__/` folder.

!!! note
There is a builtin shortcut making all scripts available as root commands
as long as the script does not conflict with any builtin or plugin-contributed command.
Said otherwise, if you have a `test` script, you can run both `pdm run test` and `pdm test`.
But if you have an `install` script, only `pdm run install` will run it,
`pdm install` will still run the builtin `install` command.

## User Scripts

PDM also supports custom script shortcuts in the optional `[tool.pdm.scripts]` section of `pyproject.toml`.
Expand Down
2 changes: 0 additions & 2 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,8 @@ nav:
- dev/benchmark.md

markdown_extensions:
- admonition
- pymdownx.highlight:
linenums: true
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
- pymdownx.details
Expand Down
1 change: 1 addition & 0 deletions news/1159.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Scripts are now available as root command if they don't conflict with any builtin or plugin-contributed command.
24 changes: 22 additions & 2 deletions pdm/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,24 @@ def main(
**extra: Any,
) -> None:
"""The main entry function"""
options = self.parser.parse_args(args or None)
# Ensure same behavior while testing and using the CLI
args = args or sys.argv[1:]
# Keep it for after project parsing to check if its a defined script
root_script = None
try:
options = self.parser.parse_args(args)
except SystemExit as e:
# Failed to parse, try to give all to `run` command as shortcut
# and keep to root script (first non-dashed param) to check existence
# as soon as the project is parsed
root_script = next((arg for arg in args if not arg.startswith("-")), None)
if not root_script:
raise
try:
options = self.parser.parse_args(["run", *args])
except SystemExit:
raise e

self.ui.set_verbosity(options.verbose)
if options.ignore_python:
os.environ["PDM_IGNORE_SAVED_PYTHON"] = "1"
Expand All @@ -152,10 +169,13 @@ def main(

self.ensure_project(options, obj)

if root_script and root_script not in options.project.scripts:
self.parser.error(f"Command unknown: {root_script}")

try:
f = options.handler
except AttributeError:
self.parser.print_help()
self.parser.print_help(sys.stderr)
sys.exit(1)
else:
try:
Expand Down
50 changes: 50 additions & 0 deletions tests/cli/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import pytest

from pdm import termui
from pdm.cli import actions
from pdm.cli.actions import PEP582_PATH
from pdm.utils import cd

Expand Down Expand Up @@ -549,3 +550,52 @@ def test_composite_can_have_commands(project, invoke, capfd):
out, _ = capfd.readouterr()
assert "Task CALLED" in out
assert "Command CALLED" in out


def test_run_shortcut(project, invoke, capfd):
project.tool_settings["scripts"] = {
"test": "echo 'Everything is fine'",
}
project.write_pyproject()
capfd.readouterr()
result = invoke(["test"], obj=project, strict=True)
assert result.exit_code == 0
out, _ = capfd.readouterr()
assert "Everything is fine" in out


def test_run_shortcuts_dont_override_commands(project, invoke, capfd, mocker):
do_lock = mocker.patch.object(actions, "do_lock")
do_sync = mocker.patch.object(actions, "do_sync")
project.tool_settings["scripts"] = {
"install": "echo 'Should not run'",
}
project.write_pyproject()
capfd.readouterr()
result = invoke(["install"], obj=project, strict=True)
assert result.exit_code == 0
out, _ = capfd.readouterr()
assert "Should not run" not in out
do_lock.assert_called_once()
do_sync.assert_called_once()


def test_run_shortcut_fail_with_usage_if_script_not_found(project, invoke):
result = invoke(["whatever"], obj=project)
assert result.exit_code != 0
assert "Command unknown: whatever" in result.stderr
assert "Usage" in result.stderr


@pytest.mark.parametrize(
"args",
[
pytest.param([], id="no args"),
pytest.param(["-ko"], id="unknown param"),
pytest.param(["pip", "--version"], id="not an user script"),
],
)
def test_empty_positionnal_args_still_display_usage(project, invoke, args):
result = invoke(args, obj=project)
assert result.exit_code != 0
assert "Usage" in result.stderr
67 changes: 57 additions & 10 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
import shutil
import sys
from contextlib import contextmanager
from io import BytesIO
from dataclasses import dataclass
from io import BytesIO, StringIO
from pathlib import Path
from typing import Callable, Dict, Iterable, List, Optional, Tuple
from typing import Callable, Dict, Iterable, List, Mapping, Optional, Tuple
from urllib.parse import unquote, urlparse

import pytest
import requests
from click.testing import CliRunner
from packaging.version import parse as parse_version
from unearth.vcs import Git, vcs_support

Expand All @@ -33,6 +33,7 @@
)
from pdm.models.session import PDMSession
from pdm.project.config import Config
from pdm.project.core import Project
from pdm.utils import normalize_name, path_to_url
from tests import FIXTURES

Expand Down Expand Up @@ -380,15 +381,61 @@ def is_dev(request):
return request.param


@pytest.fixture()
def invoke(core):
runner = CliRunner(mix_stderr=False)
@dataclass
class RunResult:
exit_code: int
stdout: str
stderr: str
exception: Optional[Exception] = None

@property
def output(self) -> str:
return self.stdout

@property
def outputs(self) -> str:
return self.stdout + self.stderr

def print(self):
print("# exit code:", self.exit_code)
print("# stdout:", self.stdout, sep="\n")
print("# stderr:", self.stderr, sep="\n")

def caller(args, strict=False, **kwargs):

@pytest.fixture()
def invoke(core, monkeypatch):
def caller(
args,
strict: bool = False,
input: Optional[str] = None,
obj: Optional[Project] = None,
env: Optional[Mapping[str, str]] = None,
**kwargs,
):
__tracebackhide__ = True
result = runner.invoke(
core, args, catch_exceptions=not strict, prog_name="pdm", **kwargs
)

stdin = StringIO(input)
stdout = StringIO()
stderr = StringIO()
exit_code = 0
exception = None

with monkeypatch.context() as m:
m.setattr("sys.stdin", stdin)
m.setattr("sys.stdout", stdout)
m.setattr("sys.stderr", stderr)
for key, value in (env or {}).items():
m.setenv(key, value)
try:
core.main(args, "pdm", obj=obj, **kwargs)
except SystemExit as e:
exit_code = e.code
except Exception as e:
exit_code = 1
exception = e

result = RunResult(exit_code, stdout.getvalue(), stderr.getvalue(), exception)

if strict and result.exit_code != 0:
raise RuntimeError(
f"Call command {args} failed({result.exit_code}): {result.stderr}"
Expand Down