Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a REPL to the CLI #2859

Merged
merged 18 commits into from Dec 13, 2023
Merged
2 changes: 1 addition & 1 deletion Makefile
Expand Up @@ -61,7 +61,7 @@ format:
ruff format ${RUFF_FORMATTED_FOLDERS}

.PHONY: pretty
pretty: fix format
pretty: format fix

.PHONY: docs-clean
docs-clean:
Expand Down
155 changes: 148 additions & 7 deletions guide/content/en/guide/running/development.md
Expand Up @@ -67,7 +67,138 @@ sanic server:app --host=0.0.0.0 --port=1234 --debug
sanic path.to:app -r -R /path/to/one -R /path/to/two
```

## Best of both worlds

## Development REPL

.. new:: v23.12

The Sanic CLI comes with a REPL (aka "read-eval-print loop") that can be used to interact with your application. This is useful for debugging and testing. A REPL is the interactive shell that you get when you run `python` without any arguments.

.. column::

You can start the REPL by passing the `--repl` argument to the Sanic CLI.

.. column::

```sh
sanic path.to.server:app --repl
```

.. column::

Or, perhaps more conveniently, when you run `--dev`, Sanic will automatically start the REPL for you. However, in this case you might be prompted to hit the "ENTER" key before actually starting the REPL.

.. column::

```sh
sanic path.to.server:app --dev
```

![](/assets/images/repl.png)

As seen in the screenshot above, the REPL will automatically add a few variables to the global namespace. These are:

- `app` - The Sanic application instance. This is the same instance that is passed to the `sanic` CLI.
- `sanic` - The `sanic` module. This is the same module that is imported when you run `import sanic`.
- `do` - A function that will create a mock `Request` object and pass it to your application. This is useful for testing your application from the REPL.
- `client` - An instance of `httpx.Client` that is configured to make requests to your application. This is useful for testing your application from the REPL. **Note:** This is only available if `httpx` is installed in your environment.

### Async/Await support

.. column::

The REPL supports `async`/`await` syntax. This means that you can use `await` in the REPL to wait for asynchronous operations to complete. This is useful for testing asynchronous code.

.. column::

```python
>>> await app.ctx.db.fetchval("SELECT 1")
1
```

### The `app` variable

You need to keep in mind that the `app` variable is your app instance as it existed when the REPL was started. It is the instance that is loaded when running the CLI command. This means that any changes that are made to your source code and subsequently reloaded in the workers will not be reflected in the `app` variable. If you want to interact with the reloaded app instance, you will need to restart the REPL.

However, it is also very useful to have access to the original app instance in the REPL for adhoc testing and debugging.

### The `client` variable

When [httpx](https://www.python-httpx.org/) is installed in your environment, the `client` variable will be available in the REPL. This is an instance of `httpx.Client` that is configured to make requests to your running application.

.. column::

To use it, simply call one of the HTTP methods on the client. See the [httpx documentation](https://www.python-httpx.org/api/#client) for more information.

.. column::

```python
>>> client.get("/")
<Response [200 OK]>
```

### The `do` function

As discussed above, the `app` instance exists as it did at the time the REPL was started, and as was modified inside the REPL. Any changes to the instance that cause a server to be reloaded will not be reflected in the `app` variable. This is where the `do` function comes in.

Let's say that you have modified your application inside the REPL to add a new route:

```python
>>> @app.get("/new-route")
... async def new_route(request):
... return sanic.json({"hello": "world"})
...
>>>
```

You can use the `do` function to mock out a request, and pass it to the application as if it were a real HTTP request. This will allow you to test your new route without having to restart the REPL.

```python
>>> await do("/new-route")
Result(request=<Request: GET /new-route>, response=<JSONResponse: 200 application/json>)
```

The `do` function returns a `Result` object that contains the `Request` and `Response` objects that were returned by your application. It is a `NamedTuple`, so you can access the values by name:

```python
>>> result = await do("/new-route")
>>> result.request
<Request: GET /new-route>
>>> result.response
<JSONResponse: 200 application/json>
```

Or, by destructuring the tuple:

```python
>>> request, response = await do("/new-route")
>>> request
<Request: GET /new-route>
>>> response
<JSONResponse: 200 application/json>
```

### When to use `do` vs `client`?

.. column::

**Use `do` when ...**

- You want to test a route that does not exist in the running application
- You want to test a route that has been modified in the REPL
- You make a change to your application inside the REPL

.. column::

**Use `client` when ...**

- You want to test a route that already exists in the running application
- You want to test a route that has been modified in your source code
- You want to send an actual HTTP request to your application

*Added in v23.12*

## Complete development mode

.. column::

Expand All @@ -85,6 +216,22 @@ sanic server:app --host=0.0.0.0 --port=1234 --debug
sanic path.to:app -d
```

.. new:: v23.12

Added to the `--dev` flag in v23.12 is the ability to start a REPL. See the [Development REPL](./development.md#development-repl) section for more information.

As of v23.12, the `--dev` flag is roughly equivalent to `--debug --reload --repl`. Using `--dev` will require you to expressly begin the REPL by hitting "ENTER", while passing the `--repl` flag explicitly starts it.

.. column::

If you would like to disable the REPL while using the `--dev` flag, you can pass `--no-repl`.

.. column::

```sh
sanic path.to:app --dev --no-repl
```

## Automatic TLS certificate

When running in `DEBUG` mode, you can ask Sanic to handle setting up localhost temporary TLS certificates. This is helpful if you want to access your local development environment with `https://`.
Expand All @@ -103,8 +250,6 @@ This functionality is provided by either [mkcert](https://github.com/FiloSottile
app.config.LOCAL_CERT_CREATOR = "trustme"
```



.. column::

Automatic TLS can be enabled at Sanic server run time:
Expand All @@ -115,12 +260,8 @@ This functionality is provided by either [mkcert](https://github.com/FiloSottile
sanic path.to.server:app --auto-tls --debug
```



.. warning::

Localhost TLS certificates (like those generated by both `mkcert` and `trustme`) are **NOT** suitable for production environments. If you are not familiar with how to obtain a *real* TLS certificate, checkout the [How to...](../how-to/tls.md) section.



*Added in v22.6*
Binary file added guide/public/assets/images/repl.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion sanic/__version__.py
@@ -1 +1 @@
__version__ = "23.6.0"
__version__ = "23.12.0"
23 changes: 23 additions & 0 deletions sanic/cli/app.py
Expand Up @@ -11,8 +11,10 @@
from sanic.application.logo import get_logo
from sanic.cli.arguments import Group
from sanic.cli.base import SanicArgumentParser, SanicHelpFormatter
from sanic.cli.console import SanicREPL
from sanic.cli.inspector import make_inspector_parser
from sanic.cli.inspector_client import InspectorClient
from sanic.helpers import _default, is_atty
from sanic.log import error_logger
from sanic.worker.loader import AppLoader

Expand Down Expand Up @@ -108,6 +110,8 @@ def run(self, parse_args=None):
except ValueError as e:
error_logger.exception(f"Failed to run app: {e}")
else:
if self.args.repl:
self._repl(app)
for http_version in self.args.http:
app.prepare(**kwargs, version=http_version)
if self.args.single:
Expand Down Expand Up @@ -148,6 +152,20 @@ def _inspector(self):
kwargs["args"] = positional[1:]
InspectorClient(host, port, secure, raw, api_key).do(action, **kwargs)

def _repl(self, app: Sanic):
if is_atty():

@app.main_process_ready
async def start_repl(app):
SanicREPL(app, self.args.repl).run()
await app._startup()

elif self.args.repl is True:
error_logger.error(
"Can't start REPL in non-interactive mode. "
"You can only run with --repl in a TTY."
)

def _precheck(self):
# Custom TLS mismatch handling for better diagnostics
if self.main_process and (
Expand Down Expand Up @@ -226,6 +244,11 @@ def _build_run_kwargs(self):
if getattr(self.args, maybe_arg, False):
kwargs[maybe_arg] = True

if self.args.dev and all(
arg not in sys.argv for arg in ("--repl", "--no-repl")
):
self.args.repl = _default

if self.args.path:
kwargs["auto_reload"] = True
kwargs["reload_dir"] = self.args.path
Expand Down
4 changes: 4 additions & 0 deletions sanic/cli/arguments.py
Expand Up @@ -93,6 +93,10 @@ def attach(self):
"a directory\n(module arg should be a path)"
),
)
self.add_bool_arguments(
"--repl",
help="Run the server with an interactive shell session",
)


class HTTPVersionGroup(Group):
Expand Down