Skip to content
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
3 changes: 1 addition & 2 deletions .claude/rules/plain.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,7 @@ Online docs URL pattern: `https://plainframework.com/docs/<pip-name>/<module/pat

- `uv run plain check` — run linting, preflight, migration, and test checks (add `--skip-test` for faster iteration)
- `uv run plain pre-commit` — `check` plus commit-specific steps (custom commands, uv lock, build)
- `uv run plain shell` — interactive Python shell with Plain configured (`-c "..."` for one-off commands)
- `uv run plain run script.py` — run a script with Plain configured
- `uv run plain shell` — interactive Python REPL with the app configured (`-c "..."` for one-off code, piped stdin works). For standalone scripts, put `import plain.runtime; plain.runtime.setup()` at the top and run with `uv run python script.py`.
- `uv run plain request /path` — test HTTP request against the dev database (`--user`, `--method`, `--data`, `--header`, `--status`, `--contains`, `--not-contains`). Add `--json` for context-frugal output — response metadata and trace analysis (query counts, N+1s, span tree), no response body.

## Debugging and verifying changes
Expand Down
3 changes: 1 addition & 2 deletions plain/plain/agents/.claude/rules/plain.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,7 @@ Online docs URL pattern: `https://plainframework.com/docs/<pip-name>/<module/pat

- `uv run plain check` — run linting, preflight, migration, and test checks (add `--skip-test` for faster iteration)
- `uv run plain pre-commit` — `check` plus commit-specific steps (custom commands, uv lock, build)
- `uv run plain shell` — interactive Python shell with Plain configured (`-c "..."` for one-off commands)
- `uv run plain run script.py` — run a script with Plain configured
- `uv run plain shell` — interactive Python REPL with the app configured (`-c "..."` for one-off code, piped stdin works). For standalone scripts, put `import plain.runtime; plain.runtime.setup()` at the top and run with `uv run python script.py`.
- `uv run plain request /path` — test HTTP request against the dev database (`--user`, `--method`, `--data`, `--header`, `--status`, `--contains`, `--not-contains`). Add `--json` for context-frugal output — response metadata and trace analysis (query counts, N+1s, span tree), no response body.

## Debugging and verifying changes
Expand Down
56 changes: 32 additions & 24 deletions plain/plain/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
- [Mark commands as common](#mark-commands-as-common)
- [Bridge CLI options to settings](#bridge-cli-options-to-settings)
- [Shell](#shell)
- [Run a script with app context](#run-a-script-with-app-context)
- [Standalone scripts](#standalone-scripts)
- [SHELL_IMPORT](#shell_import)
- [Built-in commands](#built-in-commands)
- [FAQs](#faqs)
Expand Down Expand Up @@ -139,7 +139,7 @@ Pass `cls=SettingOption` and `setting="SETTING_NAME"` to any `@click.option`. Th

## Shell

The `plain shell` command starts an interactive Python shell with your Plain app already loaded.
The `plain shell` command starts an interactive Python REPL with your app already configured — settings, models, and packages are ready to use:

```bash
$ plain shell
Expand All @@ -153,25 +153,34 @@ $ plain shell --interface bpython
$ plain shell --interface python
```

For one-off commands, use the `-c` flag:
For one-off code, use the `-c` flag or pipe to stdin:

```bash
$ plain shell -c "from app.users.models import User; print(User.query.count())"
$ echo "print(User.query.count())" | plain shell
```

### Run a script with app context
Only the interactive REPL is enriched (see `SHELL_IMPORT` below). The `-c` and stdin modes run your code as-is with nothing auto-imported, matching how `python` only honors `PYTHONSTARTUP` for interactive sessions.

The `plain run` command executes a Python script with your app context already set up:
### Standalone scripts

```bash
$ plain run scripts/import_data.py
There's no wrapper command for running script files — configure the app yourself at the top of the script:

```python
import plain.runtime

plain.runtime.setup()

from app.users.models import User

print(User.query.count())
```

This is useful for one-off scripts that need access to your models and settings.
Then run it like any other Python script (`python scripts/import_data.py`), with normal `sys.argv` behavior for arguments. If a script becomes a keeper, register it as a CLI command instead (see [Adding commands](#adding-commands)).

### SHELL_IMPORT

Customize what gets imported automatically when the shell starts by setting `SHELL_IMPORT` in your settings:
Customize what gets imported automatically when the interactive REPL starts by setting `SHELL_IMPORT` in your settings:

```python
# app/settings.py
Expand All @@ -188,26 +197,25 @@ from app.users.models import User
__all__ = ["Project", "User"]
```

Now when you run `plain shell`, those objects will be automatically imported and available.
Now when you run `plain shell`, those objects will be automatically imported and available in the interactive REPL.

## Built-in commands

Plain includes several built-in commands:

| Command | Description |
| --------------------- | ---------------------------------------- |
| `plain check` | Run core validation checks |
| `plain shell` | Interactive Python shell |
| `plain run <script>` | Execute a Python script with app context |
| `plain server` | Production-ready HTTP server |
| `plain preflight` | Validation checks before deployment |
| `plain create <name>` | 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 <name>` | 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).

Expand Down
3 changes: 1 addition & 2 deletions plain/plain/cli/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
126 changes: 66 additions & 60 deletions plain/plain/cli/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<string>", 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(), "<stdin>", argv0="-")
else:
_run_repl(interface)
89 changes: 89 additions & 0 deletions plain/tests/internal/test_shell_cli.py
Original file line number Diff line number Diff line change
@@ -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 "<string>"' 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")
Loading