Skip to content

Commit 372afca

Browse files
committed
feat(repl): --backend flag to force a specific input backend
Lets you exercise the readline (or plain) code path without uninstalling the prompt_toolkit extra: robotcode repl --backend=readline Values: auto (default cascade), prompt-toolkit, readline, plain. Also available as `ROBOTCODE_REPL_BACKEND`. When the requested backend isn't importable on the current Python, startup aborts with a clear error and a `pip install` hint — there is no silent fallback, so the explicit choice is always honoured (or visibly refused). `--plain` stays as a shorthand for `--backend=plain`; combining it with a non-`plain` `--backend` value is rejected as a usage error.
1 parent 75cbdfd commit 372afca

7 files changed

Lines changed: 268 additions & 54 deletions

File tree

docs/03_reference/repl.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,22 @@ Log To Console answer is ${x}
8383

8484
The prompt is a real line editor — arrow-keys for cursor movement, `Ctrl-R` for reverse history search, Tab for Robot-aware completion. On Unix and on Windows with Python 3.13+ this is wired up out of the box via Python's stdlib `readline`; on older Windows Pythons you only get plain `input()` unless you install `pyreadline3`.
8585

86-
### Disabling all enhancements (AI agents, automation)
86+
### Picking a specific input backend
8787

88-
Pass `--plain` (or set `ROBOTCODE_REPL_PLAIN=1`) to bypass every layer above and fall back to a bare `input()` prompt. That means no history, no completion, no candidate popup, no auto-suggest, no syntax highlighting — just a plain line read. Use this for AI-agent invocations or automation pipelines where ANSI escape sequences and completion popups would corrupt stdin/stdout capture.
88+
The REPL auto-picks the best available input backend on startup (`prompt_toolkit``readline` → bare `input()`). Pass `--backend` (or set `ROBOTCODE_REPL_BACKEND`) to force a specific one:
89+
90+
| Value | Effect |
91+
| ----- | ------ |
92+
| `auto` (default) | Run the fallback cascade. |
93+
| `prompt-toolkit` | Use the prompt_toolkit backend. Requires the `[prompt-toolkit]` extra. |
94+
| `readline` | Use the readline backend even when prompt_toolkit is installed. Useful for testing the readline code path or for users who prefer it. |
95+
| `plain` | Bypass every editor layer and fall back to a bare `input()` prompt. |
96+
97+
Requesting a backend that isn't importable on the current Python aborts startup with a clear error and a `pip install` hint — there is no silent fallback, so the explicit choice is always honoured (or visibly refused).
98+
99+
#### Disabling all enhancements (AI agents, automation)
100+
101+
`--plain` (or `ROBOTCODE_REPL_PLAIN=1`) is a shorthand for `--backend=plain`. It bypasses every layer above and falls back to a bare `input()` prompt — no history, no completion, no candidate popup, no auto-suggest, no syntax highlighting. Use this for AI-agent invocations or automation pipelines where ANSI escape sequences and completion popups would corrupt stdin/stdout capture.
89102

90103
```bash
91104
# AI-agent style: pipe input, capture clean output
@@ -94,7 +107,7 @@ Log To Console hello from agent
94107
EOF
95108
```
96109

97-
`--plain` and `--no-history` can be combined safely (plain mode has no history file anyway).
110+
Combining `--plain` with a non-`plain` `--backend` value is rejected as a usage error; combining it with `--no-history` is fine (plain mode has no history file anyway).
98111

99112
### History across sessions
100113

packages/repl/src/robotcode/repl/_input/__init__.py

Lines changed: 62 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Backend abstraction for the REPL's interactive line-input.
22
3-
`pick_backend()` walks a fallback cascade and returns the best
4-
available implementation:
3+
`pick_backend()` picks an explicit backend when one is named, else
4+
walks a fallback cascade and returns the best available implementation:
55
66
1. `PromptToolkitBackend` — if `prompt_toolkit>=3.0` is installed.
77
2. `ReadlineBackend` — if `readline` is importable (stdlib on Unix,
@@ -17,6 +17,14 @@
1717
from ._plain import PlainBackend
1818

1919

20+
class BackendUnavailableError(ImportError):
21+
"""Raised when an explicitly requested backend cannot be imported.
22+
23+
Subclasses `ImportError` so callers can catch either — `cli.py`
24+
translates it into a `click.UsageError` with a `pip install` hint.
25+
"""
26+
27+
2028
class InputBackend(Protocol):
2129
"""Protocol every line-input backend implements."""
2230

@@ -72,8 +80,11 @@ def delete_history_entry(self, idx: int) -> bool:
7280
...
7381

7482

75-
def pick_backend(*, no_history: bool = False, plain: bool = False) -> InputBackend:
76-
"""Return the best available input backend.
83+
BACKEND_CHOICES = ("auto", "prompt-toolkit", "readline", "plain")
84+
85+
86+
def pick_backend(*, no_history: bool = False, backend: str = "auto") -> InputBackend:
87+
"""Return an input backend.
7788
7889
Parameters
7990
----------
@@ -82,35 +93,59 @@ def pick_backend(*, no_history: bool = False, plain: bool = False) -> InputBacke
8293
and saving the persistent history file — in-session arrow-up
8394
recall still works, but nothing crosses session boundaries.
8495
PlainBackend is unaffected (it has no history either way).
85-
plain:
86-
When True, bypass the cascade entirely and return PlainBackend.
87-
Disables completion, syntax highlighting, popup, auto-suggest,
88-
history — the prompt becomes a bare `input()`. Recommended for
89-
AI-agent invocations and automation pipelines where ANSI
90-
escapes or completion popups would corrupt stdout capture.
91-
92-
The PlainBackend is always returnable, so this function never raises.
96+
backend:
97+
One of ``"auto"``, ``"prompt-toolkit"``, ``"readline"``,
98+
``"plain"``. ``"auto"`` runs the fallback cascade. The other
99+
values force a specific backend and raise
100+
`BackendUnavailableError` when that backend can't be imported
101+
— no silent fallback, so the caller learns that the explicit
102+
choice was not honoured.
93103
"""
94-
if plain:
104+
if backend == "plain":
95105
return PlainBackend()
96106

97-
# PromptToolkit (Stage 3) — wins if the user installed the extra.
98-
try:
99-
from ._prompt_toolkit import PromptToolkitBackend # type: ignore[import-not-found,import-untyped,unused-ignore]
100-
except ImportError:
101-
pass
102-
else:
107+
if backend == "prompt-toolkit":
108+
try:
109+
from ._prompt_toolkit import (
110+
PromptToolkitBackend, # type: ignore[import-not-found,import-untyped,unused-ignore]
111+
)
112+
except ImportError as exc:
113+
raise BackendUnavailableError(
114+
"prompt_toolkit backend requested but not installed. "
115+
"Install with: pip install 'robotcode-repl[prompt-toolkit]'"
116+
) from exc
103117
return PromptToolkitBackend(no_history=no_history) # type: ignore[no-any-return,unused-ignore]
104118

105-
# Readline (Stage 1+2) — stdlib on Unix, PyREPL on Win 3.13+.
106-
try:
107-
from ._readline import ReadlineBackend
108-
except ImportError:
109-
pass
110-
else:
119+
if backend == "readline":
120+
try:
121+
from ._readline import ReadlineBackend
122+
except ImportError as exc:
123+
raise BackendUnavailableError(
124+
"readline backend not available on this Python build. "
125+
"Install with: pip install 'robotcode-repl[gnureadline]'"
126+
) from exc
111127
return ReadlineBackend(no_history=no_history)
112128

113-
return PlainBackend()
129+
if backend == "auto":
130+
try:
131+
from ._prompt_toolkit import (
132+
PromptToolkitBackend, # type: ignore[import-not-found,import-untyped,unused-ignore]
133+
)
134+
except ImportError:
135+
pass
136+
else:
137+
return PromptToolkitBackend(no_history=no_history) # type: ignore[no-any-return,unused-ignore]
138+
139+
try:
140+
from ._readline import ReadlineBackend
141+
except ImportError:
142+
pass
143+
else:
144+
return ReadlineBackend(no_history=no_history)
145+
146+
return PlainBackend()
147+
148+
raise ValueError(f"Unknown backend: {backend!r}. Choose from {BACKEND_CHOICES}.")
114149

115150

116-
__all__ = ["InputBackend", "PlainBackend", "pick_backend"]
151+
__all__ = ["BACKEND_CHOICES", "BackendUnavailableError", "InputBackend", "PlainBackend", "pick_backend"]

packages/repl/src/robotcode/repl/cli.py

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from robotcode.plugin import Application, pass_application
77

88
from .__version__ import __version__
9+
from ._input import BACKEND_CHOICES, BackendUnavailableError
910
from .console_interpreter import ConsoleInterpreter
1011
from .run import run_repl
1112

@@ -60,16 +61,30 @@
6061
"Useful for AI-agent invocations or quick spike sessions you don't "
6162
"want polluting your shell's REPL history.",
6263
)
64+
@click.option(
65+
"--backend",
66+
type=click.Choice(list(BACKEND_CHOICES)),
67+
default="auto",
68+
show_default=True,
69+
envvar="ROBOTCODE_REPL_BACKEND",
70+
help="Force a specific input backend instead of auto-picking. "
71+
"`auto` runs the fallback cascade (prompt-toolkit → readline → plain). "
72+
"Use the explicit values to test the readline / plain code paths "
73+
"even when `prompt_toolkit` is installed. Requesting a backend that "
74+
"is not available aborts startup with a clear error — no silent "
75+
"fallback.",
76+
)
6377
@click.option(
6478
"--plain",
6579
is_flag=True,
6680
default=False,
6781
envvar="ROBOTCODE_REPL_PLAIN",
68-
help="Disable all prompt enhancements — completion, syntax highlighting, "
69-
"candidate popup, auto-suggest, history file. The prompt becomes a bare "
70-
"`input()` call. Recommended for AI-agent invocations, automation "
71-
"pipelines, and any context where ANSI escapes or completion popups "
72-
"would interfere with stdin/stdout capture.",
82+
help="Shorthand for `--backend=plain`. Disables all prompt enhancements — "
83+
"completion, syntax highlighting, candidate popup, auto-suggest, history "
84+
"file. The prompt becomes a bare `input()` call. Recommended for "
85+
"AI-agent invocations, automation pipelines, and any context where ANSI "
86+
"escapes or completion popups would interfere with stdin/stdout capture. "
87+
"Conflicts with `--backend=<other>`.",
7388
)
7489
@click.option(
7590
"-d",
@@ -133,6 +148,7 @@ def repl(
133148
show_keywords: bool,
134149
inspect: bool,
135150
no_history: bool,
151+
backend: str,
136152
plain: bool,
137153
outputdir: Optional[str],
138154
output: Optional[str],
@@ -148,14 +164,22 @@ def repl(
148164
if files:
149165
files = tuple(f.absolute() for f in files)
150166

151-
interpreter = ConsoleInterpreter(
152-
app,
153-
files=list(files),
154-
show_keywords=show_keywords,
155-
inspect=inspect,
156-
no_history=no_history,
157-
plain=plain,
158-
)
167+
if plain and backend not in ("auto", "plain"):
168+
raise click.UsageError(f"--plain conflicts with --backend={backend}. Use one or the other.")
169+
if plain:
170+
backend = "plain"
171+
172+
try:
173+
interpreter = ConsoleInterpreter(
174+
app,
175+
files=list(files),
176+
show_keywords=show_keywords,
177+
inspect=inspect,
178+
no_history=no_history,
179+
backend=backend,
180+
)
181+
except BackendUnavailableError as exc:
182+
raise click.UsageError(str(exc)) from exc
159183

160184
run_repl(
161185
interpreter=interpreter,

packages/repl/src/robotcode/repl/console_interpreter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def __init__(
2323
show_keywords: bool = False,
2424
inspect: Optional[bool] = False,
2525
no_history: bool = False,
26-
plain: bool = False,
26+
backend: str = "auto",
2727
) -> None:
2828
super().__init__()
2929

@@ -33,7 +33,7 @@ def __init__(
3333
self.inspect = inspect
3434

3535
self.executed_files: List[Path] = []
36-
self._input: InputBackend = pick_backend(no_history=no_history, plain=plain)
36+
self._input: InputBackend = pick_backend(no_history=no_history, backend=backend)
3737
# REPL inputs that parsed cleanly — `.save` exports them as a
3838
# runnable `.robot` file. Each entry may be multi-line.
3939
self._session_lines: List[str] = []

tests/robotcode/repl/test_backends.py

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -121,33 +121,74 @@ def test_pick_backend_prefers_prompt_toolkit_when_available() -> None:
121121

122122

123123
# ---------------------------------------------------------------------------
124-
# --plain escape hatch — forces PlainBackend regardless of installed extras.
124+
# Explicit `backend=` selection — forces a specific backend, errors on miss.
125125
# ---------------------------------------------------------------------------
126126

127127

128128
def test_pick_backend_plain_returns_plain_even_with_prompt_toolkit() -> None:
129-
"""`plain=True` must bypass the cascade — no popup, no readline, no
130-
ANSI codes leaking into AI-agent stdout capture."""
131-
backend = pick_backend(plain=True)
129+
"""`backend="plain"` bypasses the cascade — no popup, no readline,
130+
no ANSI codes leaking into AI-agent stdout capture."""
131+
backend = pick_backend(backend="plain")
132132
assert isinstance(backend, PlainBackendClass)
133133

134134

135135
def test_pick_backend_plain_is_orthogonal_to_no_history() -> None:
136136
"""Plain mode has no history file anyway, but setting both must
137137
still return PlainBackend (no conflict, no error)."""
138-
backend = pick_backend(plain=True, no_history=True)
138+
backend = pick_backend(backend="plain", no_history=True)
139139
assert isinstance(backend, PlainBackendClass)
140140

141141

142-
def test_pick_backend_plain_false_keeps_default_cascade(monkeypatch: pytest.MonkeyPatch) -> None:
143-
"""`plain=False` (the default) must NOT short-circuit; cascade as
142+
def test_pick_backend_auto_keeps_default_cascade(monkeypatch: pytest.MonkeyPatch) -> None:
143+
"""`backend="auto"` (the default) must NOT short-circuit; cascade as
144144
before."""
145145
pytest.importorskip("readline")
146146
_block_module(monkeypatch, "robotcode.repl._input._prompt_toolkit")
147-
backend = pick_backend(plain=False)
147+
backend = pick_backend(backend="auto")
148148
assert type(backend).__name__ == "ReadlineBackend"
149149

150150

151+
def test_pick_backend_explicit_readline_returns_readline_even_with_prompt_toolkit() -> None:
152+
"""Core feature: `backend="readline"` must ignore prompt_toolkit
153+
even when it's installed. Lets devs (and users) exercise the
154+
readline code path without uninstalling the prompt_toolkit extra."""
155+
pytest.importorskip("readline")
156+
pytest.importorskip("prompt_toolkit")
157+
backend = pick_backend(backend="readline")
158+
assert type(backend).__name__ == "ReadlineBackend"
159+
160+
161+
def test_pick_backend_explicit_prompt_toolkit_returns_prompt_toolkit() -> None:
162+
pytest.importorskip("prompt_toolkit")
163+
backend = pick_backend(backend="prompt-toolkit")
164+
assert type(backend).__name__ == "PromptToolkitBackend"
165+
166+
167+
def test_pick_backend_explicit_readline_raises_when_unavailable(monkeypatch: pytest.MonkeyPatch) -> None:
168+
"""An explicit request for an uninstalled backend is a hard error —
169+
silent fallback would defeat the purpose of the flag."""
170+
from robotcode.repl._input import BackendUnavailableError
171+
172+
_block_module(monkeypatch, "robotcode.repl._input._readline")
173+
with pytest.raises(BackendUnavailableError, match="readline backend not available"):
174+
pick_backend(backend="readline")
175+
176+
177+
def test_pick_backend_explicit_prompt_toolkit_raises_when_unavailable(monkeypatch: pytest.MonkeyPatch) -> None:
178+
from robotcode.repl._input import BackendUnavailableError
179+
180+
_block_module(monkeypatch, "robotcode.repl._input._prompt_toolkit")
181+
with pytest.raises(BackendUnavailableError, match="prompt_toolkit backend requested"):
182+
pick_backend(backend="prompt-toolkit")
183+
184+
185+
def test_pick_backend_unknown_value_raises() -> None:
186+
"""Defense in depth: the CLI uses `click.Choice` to filter values,
187+
but a direct API caller could still pass garbage. Surface it loudly."""
188+
with pytest.raises(ValueError, match="Unknown backend"):
189+
pick_backend(backend="xyz")
190+
191+
151192
# ---------------------------------------------------------------------------
152193
# History-protocol methods (InputBackend.get_history / clear / delete)
153194
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)