Skip to content

Commit 007f32b

Browse files
committed
first draft
1 parent 7c9d0e0 commit 007f32b

File tree

7 files changed

+345
-1
lines changed

7 files changed

+345
-1
lines changed

litestar/cli/_shell_startup.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import os
2+
3+
from litestar.cli._utils import _load_app_from_path, populate_repl_globals
4+
5+
app = _load_app_from_path(os.environ["LITESTAR_APP"]).app
6+
new_locals, _ = populate_repl_globals(app=app)
7+
globals().update(new_locals)

litestar/cli/_utils.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,3 +581,16 @@ def isatty() -> bool:
581581
This is a convenience wrapper around the built in system methods. This allows for easier testing of TTY/non-TTY modes.
582582
"""
583583
return sys.stdout.isatty()
584+
585+
586+
def populate_repl_globals(app: Litestar) -> tuple[dict[str, Any], str]:
587+
repl_locals = {"app": Litestar}
588+
banner = "app = Litestar(...)\n\n"
589+
for plugin in app.plugins.cli:
590+
plugin_provided = plugin.populate_repl_namespace(app=app)
591+
if plugin_provided:
592+
banner += f"Plugin {type(plugin).__name__!r} provided:"
593+
for name, value in plugin_provided.items():
594+
repl_locals[name] = value
595+
banner += f"name = {value!r}\n"
596+
return repl_locals, banner

litestar/cli/commands/core.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from __future__ import annotations
22

3+
import importlib.resources
34
import inspect
45
import multiprocessing
56
import os
67
import subprocess
78
import sys
89
from contextlib import AbstractContextManager, ExitStack, contextmanager
9-
from typing import TYPE_CHECKING, Any
10+
from typing import TYPE_CHECKING, Any, Literal
1011

1112
try:
1213
import rich_click as click
@@ -21,6 +22,7 @@
2122
console,
2223
create_ssl_files,
2324
isatty,
25+
populate_repl_globals,
2426
remove_default_schema_routes,
2527
remove_routes_with_patterns,
2628
show_app_info,
@@ -365,3 +367,86 @@ def _handle_http_route(self, route: HTTPRoute) -> None:
365367
branch.add(" ".join([f"[green]{path}[green]", *handler_info]))
366368
else:
367369
branch.add(" ".join(handler_info))
370+
371+
372+
def _autoselect_repl_module() -> Literal["repl", "asyncio", "ipython"]:
373+
import importlib.util
374+
375+
if importlib.util.find_spec("IPython"):
376+
return "ipython"
377+
378+
if sys.version_info >= (3, 13):
379+
return "asyncio"
380+
381+
return "repl"
382+
383+
384+
@click.command(name="shell")
385+
@click.option(
386+
"--repl",
387+
default=None,
388+
type=click.Choice(("repl", "asyncio", "ipython")),
389+
envvar="LITESTAR_REPL",
390+
required=False,
391+
help="Start a Python shell with the Litestar application and other values provided by plugins preloaded",
392+
)
393+
def shell_command(
394+
app: Litestar,
395+
repl: Literal["repl", "asyncio", "ipython"] | None = None,
396+
) -> None: # pragma: no cover
397+
if repl is None:
398+
repl = _autoselect_repl_module()
399+
click.secho(f"Starting Litestar shell using autoselected REPL {repl!r}", fg="blue")
400+
401+
if repl == "asyncio":
402+
if sys.version_info < (3, 13):
403+
click.secho("Litestar shell using the asyncio REPL requires Python 3.13 or greater", fg="red")
404+
click.secho(
405+
"To use the Litestar shell with an async REPL in this version of Python, y"
406+
"ou can install Litestar with the 'ipython' extra (litestar[ipython]) "
407+
"and then select ipython as the repl, either by starting he Litestar "
408+
"shell with 'litestar shell --repl=ipython' or setting the "
409+
"'LITESTAR_REPL=ipython' environment variable",
410+
fg="blue",
411+
)
412+
quit(1)
413+
414+
# since it's currently not possible to customise e.g. the namespace of the
415+
# asyncio REPL, we have to get a bit creative here
416+
417+
if "PYTHONSTARTUP" in os.environ: # type: ignore[unreachable]
418+
click.secho(
419+
"Cannot run Litestar shell with asyncio REPL when PYTHONSTARTUP is "
420+
"set. PYTHONSTARTUP is currently set to "
421+
f"{os.environ['PYTHONSTARTUP']!r}. Either unset PYTHONSTARTUP or use a "
422+
"different REPL ('repl' or 'ipython').",
423+
fg="red",
424+
)
425+
426+
subprocess.run( # noqa: S603
427+
[sys.executable, "-m", "asyncio"],
428+
check=False,
429+
env={
430+
**os.environ,
431+
"PYTHONSTARTUP": str(importlib.resources.files("litestar.cli").joinpath("_shell_startup")) + ".py",
432+
},
433+
)
434+
elif repl == "repl":
435+
import code
436+
437+
repl_locals, banner = populate_repl_globals(app=app)
438+
interpreter = code.InteractiveConsole(locals=repl_locals)
439+
interpreter.interact(banner=banner)
440+
elif repl == "ipython":
441+
import IPython
442+
from traitlets.config.loader import Config
443+
444+
repl_locals, banner = populate_repl_globals(app=app)
445+
446+
config = Config()
447+
config.TerminalInteractiveShell.banner2 = banner
448+
449+
IPython.start_ipython(argv=[], user_ns=repl_locals, config=config) # type: ignore[no-untyped-call]
450+
else:
451+
click.secho(f"Unsupported REPL {repl!r}") # type: ignore[unreachable]
452+
quit(1)

litestar/cli/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,4 @@ def litestar_group(ctx: click.Context, app_path: str | None, app_dir: Path | Non
5151
litestar_group.add_command(core.version_command) # pyright: ignore
5252
litestar_group.add_command(sessions.sessions_group) # pyright: ignore
5353
litestar_group.add_command(schema.schema_group) # pyright: ignore
54+
litestar_group.add_command(core.shell_command) # pyright: ignore

litestar/plugins/base.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,12 @@ def is_debug_mode(app: Litestar):
164164
def server_lifespan(self, app: Litestar) -> Iterator[None]:
165165
yield
166166

167+
def populate_repl_namespace(self, app: Litestar) -> dict[str, Any]:
168+
"""Return a dict that will be used to populate the REPL namespace of the
169+
Litestar shell.
170+
"""
171+
return {}
172+
167173

168174
class SerializationPlugin(abc.ABC):
169175
"""Abstract base class for plugins that extend DTO functionality"""

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ dev = [
185185
"daphne>=4.0.0",
186186
"opentelemetry-sdk",
187187
"httpx-sse",
188+
"ipython",
188189
]
189190

190191
docs = [

0 commit comments

Comments
 (0)