Skip to content

Commit d94c960

Browse files
ahopkinsgoodki-d
andauthored
Add REPL context (#3042)
* Add REPL context * fix(type): add missing return type * chore: rename method and add docstring * fix: handle null docstring * chore: add test case * Add direct attr setting --------- Co-authored-by: goodki-d <185676088+goodki-d@users.noreply.github.com>
1 parent 9bd557f commit d94c960

File tree

4 files changed

+166
-5
lines changed

4 files changed

+166
-5
lines changed

sanic/app.py

+3
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
from sanic.mixins.listeners import ListenerEvent
7171
from sanic.mixins.startup import StartupMixin
7272
from sanic.mixins.static import StaticHandleMixin
73+
from sanic.models.ctx_types import REPLContext
7374
from sanic.models.futures import (
7475
FutureException,
7576
FutureListener,
@@ -209,6 +210,7 @@ class to use for the application. Defaults to `None`.
209210
"multiplexer",
210211
"named_request_middleware",
211212
"named_response_middleware",
213+
"repl_ctx",
212214
"request_class",
213215
"request_middleware",
214216
"response_middleware",
@@ -372,6 +374,7 @@ def __init__(
372374
self.listeners: dict[str, list[ListenerType[Any]]] = defaultdict(list)
373375
self.named_request_middleware: dict[str, Deque[Middleware]] = {}
374376
self.named_response_middleware: dict[str, Deque[Middleware]] = {}
377+
self.repl_ctx: REPLContext = REPLContext()
375378
self.request_class = request_class or Request
376379
self.request_middleware: Deque[Middleware] = deque()
377380
self.response_middleware: Deque[Middleware] = deque()

sanic/cli/console.py

+46-5
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from sanic.helpers import Default
1919
from sanic.http.constants import Stage
2020
from sanic.log import Colors
21+
from sanic.models.ctx_types import REPLContext
2122
from sanic.models.protocol_types import TransportProtocol
2223
from sanic.response.types import HTTPResponse
2324

@@ -110,6 +111,13 @@ async def do(
110111
return Result(request, response)
111112

112113

114+
def _variable_description(name: str, desc: str, type_desc: str) -> str:
115+
return (
116+
f" - {Colors.BOLD + Colors.SANIC}{name}{Colors.END}: {desc} - "
117+
f"{Colors.BOLD + Colors.BLUE}{type_desc}{Colors.END}"
118+
)
119+
120+
113121
class SanicREPL(InteractiveConsole):
114122
def __init__(self, app: Sanic, start: Optional[Default] = None):
115123
global repl_app
@@ -119,24 +127,47 @@ def __init__(self, app: Sanic, start: Optional[Default] = None):
119127
"sanic": sanic,
120128
"do": do,
121129
}
130+
131+
user_locals = {
132+
user_local.name: user_local.var for user_local in app.repl_ctx
133+
}
134+
122135
client_availability = ""
123136
variable_descriptions = [
124-
f" - {Colors.BOLD + Colors.SANIC}app{Colors.END}: The Sanic application instance - {Colors.BOLD + Colors.BLUE}{str(app)}{Colors.END}", # noqa: E501
125-
f" - {Colors.BOLD + Colors.SANIC}sanic{Colors.END}: The Sanic module - {Colors.BOLD + Colors.BLUE}import sanic{Colors.END}", # noqa: E501
126-
f" - {Colors.BOLD + Colors.SANIC}do{Colors.END}: An async function to fake a request to the application - {Colors.BOLD + Colors.BLUE}Result(request, response){Colors.END}", # noqa: E501
137+
_variable_description(
138+
"app", REPLContext.BUILTINS["app"], str(app)
139+
),
140+
_variable_description(
141+
"sanic", REPLContext.BUILTINS["sanic"], "import sanic"
142+
),
143+
_variable_description(
144+
"do", REPLContext.BUILTINS["do"], "Result(request, response)"
145+
),
127146
]
147+
148+
user_locals_descriptions = [
149+
_variable_description(
150+
user_local.name, user_local.desc, str(type(user_local.var))
151+
)
152+
for user_local in app.repl_ctx
153+
]
154+
128155
if HTTPX_AVAILABLE:
129156
locals_available["client"] = SanicClient(app)
130157
variable_descriptions.append(
131-
f" - {Colors.BOLD + Colors.SANIC}client{Colors.END}: A client to access the Sanic app instance using httpx - {Colors.BOLD + Colors.BLUE}from httpx import Client{Colors.END}", # noqa: E501
158+
_variable_description(
159+
"client",
160+
REPLContext.BUILTINS["client"],
161+
"from httpx import Client",
162+
),
132163
)
133164
else:
134165
client_availability = (
135166
f"\n{Colors.YELLOW}The HTTP client has been disabled. "
136167
"To enable it, install httpx:\n\t"
137168
f"pip install httpx{Colors.END}\n"
138169
)
139-
super().__init__(locals=locals_available)
170+
super().__init__(locals={**locals_available, **user_locals})
140171
self.compile.compiler.flags |= PyCF_ALLOW_TOP_LEVEL_AWAIT
141172
self.loop = new_event_loop()
142173
self._start = start
@@ -163,6 +194,16 @@ def __init__(self, app: Sanic, start: Optional[Default] = None):
163194
client_availability,
164195
"The following objects are available for your convenience:", # noqa: E501
165196
*variable_descriptions,
197+
]
198+
+ (
199+
[
200+
"\nREPL Context:",
201+
*user_locals_descriptions,
202+
]
203+
if user_locals_descriptions
204+
else []
205+
)
206+
+ [
166207
"\nThe async/await keywords are available for use here.", # noqa: E501
167208
f"To exit, press {Colors.BOLD}CTRL+C{Colors.END}, "
168209
f"{Colors.BOLD}CTRL+D{Colors.END}, or type {Colors.BOLD}exit(){Colors.END}.\n", # noqa: E501

sanic/models/ctx_types.py

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from typing import Any, NamedTuple, Optional
2+
3+
4+
class REPLLocal(NamedTuple):
5+
var: Any
6+
name: str
7+
desc: str
8+
9+
10+
class REPLContext:
11+
BUILTINS = {
12+
"app": "The Sanic application instance",
13+
"sanic": "The Sanic module",
14+
"do": "An async function to fake a request to the application",
15+
"client": "A client to access the Sanic app instance using httpx",
16+
}
17+
18+
def __init__(self):
19+
self._locals: set[REPLLocal] = set()
20+
21+
def add(
22+
self,
23+
var: Any,
24+
name: Optional[str] = None,
25+
desc: Optional[str] = None,
26+
):
27+
"""Add a local variable to be available in REPL context.
28+
29+
Args:
30+
var (Any): A module, class, object or a class.
31+
name (Optional[str], optional): An alias for the local. Defaults to None.
32+
desc (Optional[str], optional): A brief description for the local. Defaults to None.
33+
""" # noqa: E501
34+
if name is None:
35+
try:
36+
name = var.__name__
37+
except AttributeError:
38+
name = var.__class__.__name__
39+
40+
if desc is None:
41+
try:
42+
desc = var.__doc__ or ""
43+
except AttributeError:
44+
desc = str(type(var))
45+
46+
assert isinstance(desc, str) and isinstance(
47+
name, str
48+
) # Just to make mypy happy
49+
50+
if name in self.BUILTINS:
51+
raise ValueError(f"Cannot override built-in variable: {name}")
52+
53+
desc = self._truncate(desc)
54+
55+
self._locals.add(REPLLocal(var, name, desc))
56+
57+
def __setattr__(self, name: str, value: Any):
58+
if name.startswith("_"):
59+
super().__setattr__(name, value)
60+
else:
61+
self.add(value, name)
62+
63+
def __iter__(self):
64+
return iter(self._locals)
65+
66+
@staticmethod
67+
def _truncate(s: str, limit: int = 40) -> str:
68+
s = s.replace("\n", " ")
69+
return s[:limit] + "..." if len(s) > limit else s

tests/test_cli.py

+48
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from sanic import __version__
1414
from sanic.__main__ import main
1515
from sanic.cli.inspector_client import InspectorClient
16+
from sanic.models.ctx_types import REPLLocal
1617

1718
from .conftest import get_port
1819

@@ -404,3 +405,50 @@ def test_command_with_renamed_command(caplog):
404405
with patch("sys.argv", ["sanic", *args]):
405406
lines = capture(args, caplog)
406407
assert "BAZ" in lines
408+
409+
410+
def test_add_local_method(app):
411+
def foo(): ...
412+
def bar():
413+
"""bar method docstring."""
414+
415+
class Luffy: ...
416+
417+
import os
418+
419+
app.repl_ctx.add(foo)
420+
app.repl_ctx.add(bar)
421+
app.repl_ctx.add(Luffy)
422+
app.repl_ctx.add(os, desc="Standard os module.")
423+
424+
assert REPLLocal(foo, "foo", "") in app.repl_ctx._locals
425+
assert (
426+
REPLLocal(bar, "bar", "bar method docstring.") in app.repl_ctx._locals
427+
)
428+
assert REPLLocal(Luffy, "Luffy", "") in app.repl_ctx._locals
429+
assert REPLLocal(os, "os", "Standard os module.") in app.repl_ctx._locals
430+
431+
432+
def test_add_local_attr(app):
433+
def foo(): ...
434+
def bar():
435+
"""bar method docstring."""
436+
437+
class Luffy: ...
438+
439+
import os
440+
441+
app.repl_ctx.foo = foo
442+
app.repl_ctx.bar = bar
443+
app.repl_ctx.Luffy = Luffy
444+
app.repl_ctx.os = os
445+
446+
assert REPLLocal(foo, "foo", "") in app.repl_ctx._locals
447+
assert (
448+
REPLLocal(bar, "bar", "bar method docstring.") in app.repl_ctx._locals
449+
)
450+
assert REPLLocal(Luffy, "Luffy", "") in app.repl_ctx._locals
451+
assert any(
452+
isinstance(item, REPLLocal) and item.var == os and item.name == "os"
453+
for item in app.repl_ctx._locals
454+
)

0 commit comments

Comments
 (0)