diff --git a/.claude/rules/plain.md b/.claude/rules/plain.md index fa0ce4a14b..d097e7709a 100644 --- a/.claude/rules/plain.md +++ b/.claude/rules/plain.md @@ -115,8 +115,7 @@ Online docs URL pattern: `https://plainframework.com/docs///` | Execute a Python script with app context | -| `plain server` | Production-ready HTTP server | -| `plain preflight` | Validation checks before deployment | -| `plain create ` | Create a new local package | -| `plain settings` | View current settings | -| `plain urls` | List all URL patterns | -| `plain docs` | View package documentation | -| `plain install` | Install package dependencies | -| `plain upgrade` | Upgrade Plain packages | -| `plain memory` | Memory profiling tools | +| Command | Description | +| --------------------- | ----------------------------------- | +| `plain check` | Run core validation checks | +| `plain shell` | Interactive Python shell | +| `plain server` | Production-ready HTTP server | +| `plain preflight` | Validation checks before deployment | +| `plain create ` | Create a new local package | +| `plain settings` | View current settings | +| `plain urls` | List all URL patterns | +| `plain docs` | View package documentation | +| `plain install` | Install package dependencies | +| `plain upgrade` | Upgrade Plain packages | +| `plain memory` | Memory profiling tools | Additional commands are added by installed packages (like `plain migrations apply` from plain.postgres). diff --git a/plain/plain/cli/core.py b/plain/plain/cli/core.py index b5e0c35d01..f21278bf88 100644 --- a/plain/plain/cli/core.py +++ b/plain/plain/cli/core.py @@ -25,7 +25,7 @@ from .scaffold import create from .server import server from .settings import settings -from .shell import run, shell +from .shell import shell from .upgrade import upgrade from .urls import urls from .utils import utils @@ -58,7 +58,6 @@ def plain_cli() -> None: plain_cli.add_command(changelog) plain_cli.add_command(settings) plain_cli.add_command(shell) -plain_cli.add_command(run) plain_cli.add_command(install) plain_cli.add_command(upgrade) plain_cli.add_command(server) diff --git a/plain/plain/cli/shell.py b/plain/plain/cli/shell.py index f54fc37489..2bebb090e9 100644 --- a/plain/plain/cli/shell.py +++ b/plain/plain/cli/shell.py @@ -3,83 +3,89 @@ import os import subprocess import sys +import traceback +import types import click from plain.cli.runtime import common_command +_STARTUP = os.path.join(os.path.dirname(__file__), "startup.py") -@common_command -@click.command() -@click.option( - "-i", - "--interface", - type=click.Choice(["ipython", "bpython", "python"]), - help="Specify an interactive interpreter interface.", -) -@click.option( - "-c", - "--command", - help="Execute the given command and exit.", -) -def shell(interface: str | None, command: str | None) -> None: - """Interactive Python shell""" - if command: - # Execute the command and exit - before_script = "import plain.runtime; plain.runtime.setup()" - full_command = f"{before_script}; {command}" - result = subprocess.run(["python", "-c", full_command]) - if result.returncode: - sys.exit(result.returncode) - return +def _run_source(source: str, filename: str, argv0: str) -> None: + """Execute one-off code in this process, as the `__main__` module. - if not sys.stdin.isatty(): - # Piped/redirected stdin: PYTHONSTARTUP is only honored for interactive - # sessions, so call plain.runtime.setup() ourselves before exec'ing the - # script piped on stdin. The subprocess inherits stdin from us. - wrapper = ( - "import plain.runtime, sys; plain.runtime.setup(); exec(sys.stdin.read())" - ) - result = subprocess.run(["python", "-c", wrapper]) - if result.returncode: - sys.exit(result.returncode) - return + The CLI has already run plain.runtime.setup() before any command executes, + so the app is configured. Registering a real module as + sys.modules["__main__"] keeps `python -c` semantics: user code gets a + clean namespace, and objects it defines can be resolved through their + module (pickle). + """ + sys.argv = [argv0] + module = types.ModuleType("__main__") + sys.modules["__main__"] = module + try: + exec(compile(source, filename, "exec"), module.__dict__) + except Exception as e: + # Drop this function's frame so the traceback starts at the user's + # code, like `python -c`. + e.__traceback__ = e.__traceback__.tb_next if e.__traceback__ else None + traceback.print_exception(e) + sys.exit(1) - interface_list: list[str] - if interface: - interface_list = [interface] - else: - - def get_default_interface() -> list[str]: - try: - import IPython # noqa: F401 # ty: ignore[unresolved-import] - return ["python", "-m", "IPython"] - except ImportError: - pass +# Each interface runs under the same interpreter (`sys.executable`) that the +# CLI itself uses, so the REPL sees the project's installed packages. +_INTERFACES = { + "ipython": [sys.executable, "-m", "IPython"], + "bpython": [sys.executable, "-m", "bpython"], + "python": [sys.executable], +} - return ["python"] - interface_list = get_default_interface() +def _run_repl(interface: str | None) -> None: + """Enriched interactive REPL: banner + SHELL_IMPORT via PYTHONSTARTUP.""" + if interface is None: + try: + import IPython # noqa: F401 # ty: ignore[unresolved-import] + interface = "ipython" + except ImportError: + interface = "python" + # Plain's startup file must win over any PYTHONSTARTUP the user has exported, + # otherwise the banner + SHELL_IMPORT enrichment is silently replaced. result = subprocess.run( - interface_list, - env={ - "PYTHONSTARTUP": os.path.join(os.path.dirname(__file__), "startup.py"), - **os.environ, - }, + _INTERFACES[interface], + env={**os.environ, "PYTHONSTARTUP": _STARTUP}, ) if result.returncode: sys.exit(result.returncode) +@common_command @click.command() -@click.argument("script", nargs=1, type=click.Path(exists=True)) -def run(script: str) -> None: - """Execute Python scripts with app context""" - before_script = "import plain.runtime; plain.runtime.setup()" - command = f"{before_script}; exec(open('{script}').read())" - result = subprocess.run(["python", "-c", command]) - if result.returncode: - sys.exit(result.returncode) +@click.option( + "-i", + "--interface", + type=click.Choice(list(_INTERFACES)), + help="Specify an interactive interpreter interface.", +) +@click.option( + "-c", + "--command", + help="Execute the given code and exit.", +) +def shell(interface: str | None, command: str | None) -> None: + """Interactive Python shell with the app configured. + + Also runs one-off code and exits: `-c "..."` or piped stdin. + """ + if command is not None: + _run_source(command, "", argv0="-c") + elif not sys.stdin.isatty(): + # Piped input can't go through the REPL — PYTHONSTARTUP (and thus the + # enrichment) is only honored for interactive sessions. + _run_source(sys.stdin.read(), "", argv0="-") + else: + _run_repl(interface) diff --git a/plain/tests/internal/test_shell_cli.py b/plain/tests/internal/test_shell_cli.py new file mode 100644 index 0000000000..3a016e35b3 --- /dev/null +++ b/plain/tests/internal/test_shell_cli.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import subprocess +import sys +from unittest import mock + +from click.testing import CliRunner + +from plain.cli.shell import shell + + +def _invoke(args, input=None): + """Invoke the shell command, restoring what one-off execution mutates. + + `-c` and stdin code run in-process as a fresh `__main__` module with + sys.argv reset, so put both back afterward for the rest of the suite. + """ + saved_main = sys.modules["__main__"] + saved_argv = sys.argv + try: + return CliRunner().invoke(shell, args, input=input, prog_name="plain") + finally: + sys.modules["__main__"] = saved_main + sys.argv = saved_argv + + +def test_dash_c_executes_string(): + result = _invoke(["-c", "print(1 + 1)"]) + assert result.exit_code == 0, result.output + assert "2" in result.output + + +def test_dash_c_runs_in_clean_main_namespace(): + # No wrapper imports leak in, and sys.argv matches `python -c`. + result = _invoke(["-c", "import sys; print(sys.argv, 'plain' in dir())"]) + assert result.exit_code == 0, result.output + assert "['-c'] False" in result.output + + +def test_dash_c_defines_picklable_objects(): + # Classes defined in one-off code must resolve through + # sys.modules["__main__"], like `python -c` — pickle enforces this. + code = "import pickle\nclass A: pass\nprint(len(pickle.dumps(A())))" + result = _invoke(["-c", code]) + assert result.exit_code == 0, result.output + assert int(result.output.strip()) > 0 + + +def test_dash_c_error_traceback_starts_at_user_code(): + result = _invoke(["-c", "1/0"]) + assert result.exit_code == 1 + assert 'File ""' in result.output + assert "ZeroDivisionError" in result.output + assert "shell.py" not in result.output + + +def test_piped_stdin_executes(): + # Regression guard (#66): piped input must run with the app configured. + # It executes in-process, where the CLI has already done setup. + # CliRunner provides a non-tty stdin. + result = _invoke([], input="import sys; print(sys.argv)") + assert result.exit_code == 0, result.output + assert "['-']" in result.output + + +def test_tty_launches_enriched_repl(): + # On a tty, `shell` opens the enriched REPL in a subprocess. Enrichment + # (banner + SHELL_IMPORT) is delivered via PYTHONSTARTUP, which only the + # interactive interpreter honors — and Plain's startup file must win over + # any PYTHONSTARTUP the user has exported. + calls = [] + + def fake_run(cmd, **kwargs): + calls.append((cmd, kwargs)) + return subprocess.CompletedProcess(cmd, 0) + + fake_stdin = mock.Mock() + fake_stdin.isatty.return_value = True + assert shell.callback is not None + with ( + mock.patch("plain.cli.shell.subprocess.run", side_effect=fake_run), + mock.patch("plain.cli.shell.sys.stdin", fake_stdin), + mock.patch.dict("os.environ", {"PYTHONSTARTUP": "/home/user/.pythonrc"}), + ): + shell.callback(interface=None, command=None) + + (cmd, kwargs) = calls[0] + assert cmd[0] == sys.executable + assert kwargs["env"]["PYTHONSTARTUP"].endswith("startup.py")