Skip to content

Commit

Permalink
✨ switch from Flask to FastAPI for server-sent events; web chat
Browse files Browse the repository at this point in the history
  • Loading branch information
haliphax committed Oct 18, 2023
1 parent 9a51076 commit 7b408ef
Show file tree
Hide file tree
Showing 20 changed files with 253 additions and 140 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ character sets (UTF-8) and [terminal capabilities][] are taken advantage of.
- [x] Userland
- [x] Static files
- [ ] REST API
- [x] Web framework ([APIFlask][])
- [x] Web framework ([FastAPI][])
- [ ] Implementation
- [ ] IPC
- [x] Session events queue
Expand Down Expand Up @@ -141,12 +141,12 @@ userland web interface is at https://localhost/user.
⚠️ [Traefik][] will be using an untrusted certificate, and you will likely be
presented with a warning.

[apiflask]: https://apiflask.com
[asyncssh]: https://asyncssh.readthedocs.io/en/latest/
[blessed]: https://blessed.readthedocs.io/en/latest/intro.html
[bulletin boards]: https://archive.org/details/BBS.The.Documentary
[contributor guide]: ./CONTRIBUTING.md
[dos]: https://en.wikipedia.org/wiki/MS-DOS
[fastapi]: https://fastapi.tiangolo.com
[gino]: https://python-gino.org
[rich]: https://rich.readthedocs.io/en/latest/
[terminal capabilities]: https://en.wikipedia.org/wiki/Terminal_capabilities
Expand Down
2 changes: 1 addition & 1 deletion requirements/hiredis.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile --output-file=hiredis.txt hiredis.in
# pip-compile hiredis.in
#
hiredis==2.2.2
# via -r hiredis.in
3 changes: 2 additions & 1 deletion requirements/requirements.in
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
aiofiles
APIFlask[async]
asyncssh
bcrypt
Click
FastAPI
gino
redis
rich
sse-starlette
textual
toml
uvicorn[standard]
Expand Down
65 changes: 22 additions & 43 deletions requirements/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,13 @@
#
aiofiles==23.1.0
# via -r requirements.in
anyio==3.6.2
# via watchfiles
apiflask[async]==1.3.1
# via -r requirements.in
apispec==6.3.0
# via apiflask
asgiref==3.6.0
# via apiflask
annotated-types==0.6.0
# via pydantic
anyio==3.7.1
# via
# fastapi
# starlette
# watchfiles
async-timeout==4.0.2
# via redis
asyncpg==0.27.0
Expand All @@ -22,26 +21,16 @@ asyncssh==2.13.1
# via -r requirements.in
bcrypt==4.0.1
# via -r requirements.in
blinker==1.6.2
# via flask
cffi==1.15.1
# via cryptography
click==8.1.3
# via
# -r requirements.in
# flask
# uvicorn
cryptography==41.0.4
# via asyncssh
flask==2.3.2
# via
# apiflask
# flask-httpauth
# flask-marshmallow
flask-httpauth==4.7.0
# via apiflask
flask-marshmallow==0.14.0
# via apiflask
fastapi==0.103.2
# via -r requirements.in
gino==1.0.1
# via -r requirements.in
h11==0.14.0
Expand All @@ -52,36 +41,23 @@ idna==3.4
# via anyio
importlib-metadata==6.8.0
# via textual
itsdangerous==2.1.2
# via flask
jinja2==3.1.2
# via flask
linkify-it-py==2.0.2
# via markdown-it-py
markdown-it-py[linkify,plugins]==3.0.0
# via
# mdit-py-plugins
# rich
# textual
markupsafe==2.1.3
# via
# jinja2
# werkzeug
marshmallow==3.19.0
# via
# flask-marshmallow
# webargs
mdit-py-plugins==0.4.0
# via markdown-it-py
mdurl==0.1.2
# via markdown-it-py
packaging==23.0
# via
# apispec
# marshmallow
# webargs
pycparser==2.21
# via cffi
pydantic==2.4.2
# via fastapi
pydantic-core==2.10.1
# via pydantic
pygments==2.16.1
# via rich
python-dotenv==1.0.0
Expand All @@ -94,12 +70,16 @@ rich==13.6.0
# via
# -r requirements.in
# textual
six==1.16.0
# via flask-marshmallow
sniffio==1.3.0
# via anyio
sqlalchemy==1.3.24
# via gino
sse-starlette==1.6.5
# via -r requirements.in
starlette==0.27.0
# via
# fastapi
# sse-starlette
textual==0.38.1
# via -r requirements.in
toml==0.10.2
Expand All @@ -113,6 +93,9 @@ tree-sitter-languages==1.7.0
typing-extensions==4.8.0
# via
# asyncssh
# fastapi
# pydantic
# pydantic-core
# textual
uc-micro-py==1.0.2
# via linkify-it-py
Expand All @@ -124,12 +107,8 @@ uvloop==0.17.0
# uvicorn
watchfiles==0.19.0
# via uvicorn
webargs==8.2.0
# via apiflask
websockets==10.4
# via uvicorn
werkzeug==2.3.3
# via flask
wrapt==1.15.0
# via -r requirements.in
zipp==3.17.0
Expand Down
4 changes: 2 additions & 2 deletions tests/ssh/test_start_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from unittest.mock import AsyncMock, Mock, patch

# 3rd party
from apiflask import APIFlask
from fastapi import FastAPI
from gino import Gino
from redis import Redis

Expand All @@ -19,7 +19,7 @@


class Resources:
app = Mock(APIFlask)
app = Mock(FastAPI)
cache = Mock(Redis)
config: dict[str, Any]
db = Mock(Gino)
Expand Down
4 changes: 2 additions & 2 deletions tests/test_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from unittest.mock import Mock, patch

# 3rd party
from apiflask import APIFlask
from fastapi import FastAPI
from gino import Gino
from redis import Redis
from parameterized import parameterized
Expand Down Expand Up @@ -56,7 +56,7 @@ def test_config_file_from_env_used(self, mock_load: Mock):
mock_load.assert_called_once_with("test")

@parameterized.expand(
(("app", APIFlask), ("cache", Redis), ("db", Gino)),
(("app", FastAPI), ("cache", Redis), ("db", Gino)),
)
@patch("xthulu.resources.load", lambda *_: {})
@patch("xthulu.resources.exists", mock_exists)
Expand Down
25 changes: 6 additions & 19 deletions tests/web/test_start_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from unittest.mock import Mock, patch

# 3rd party
from apiflask import APIFlask
from fastapi import FastAPI
from gino import Gino
from redis import Redis

Expand All @@ -18,13 +18,12 @@


class Resources:
app = Mock(APIFlask)
app = Mock(FastAPI)
cache = Mock(Redis)
config: dict[str, Any]
db = Mock(Gino)


@patch("xthulu.web.get_event_loop", Mock())
@patch("xthulu.web.get_config", patch_get_config(test_config))
class TestStartWebServer(TestCase):

Expand All @@ -50,32 +49,20 @@ def test_uses_config(self, mock_run: Mock):

# assert
mock_run.assert_called_once_with(
"xthulu.web.asgi:asgi_app",
"xthulu.web.asgi:app",
host=test_web_config["host"],
port=test_web_config["port"],
lifespan="off",
)

def test_db_bind(self):
"""Server should bind database connection."""
def test_includes_router(self):
"""Server should include the API router."""

# act
create_app()

# assert
self.mock_resources.db.set_bind.assert_called_once_with(
test_config["db"]["bind"]
)

@patch("xthulu.web.api")
def test_registers_blueprint(self, mock_api: Mock):
"""Server should register the API blueprint."""

# act
create_app()

# assert
self.mock_resources.app.register_blueprint.assert_called_with(mock_api)
self.mock_resources.app.include_router.assert_called()

def test_imports_userland_modules(self):
"""Server should import userland modules."""
Expand Down
1 change: 1 addition & 0 deletions userland/scripts/top.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ async def main(cx: SSHContext):
)
return

cx.goto("chat")
cx.console.set_window_title(f"{cx.username}@79columns")
await scroll_art(cx, "userland/artwork/login.ans", "cp437")
await cx.inkey("Press any key to continue", "dots8Bit")
Expand Down
22 changes: 6 additions & 16 deletions userland/web/__init__.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,10 @@
"""Default userland web module"""

# 3rd party
from apiflask import APIBlueprint

# api
from xthulu.models.user import User
from xthulu.web.auth import auth

api = APIBlueprint("userland", __name__, url_prefix="/user")
# stdlib
from importlib import import_module

# 3rd party
from fastapi import APIRouter

@api.route("/")
@api.auth_required(auth)
def userland_demo():
user: User = auth.current_user # type: ignore
return {
"userland": True,
"whoami": user.name,
}
api = APIRouter(prefix="/user")
import_module(".routes", __name__)
8 changes: 8 additions & 0 deletions userland/web/routes/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Routes"""

from . import chat, index

__all__ = (
"chat",
"index",
)
62 changes: 62 additions & 0 deletions userland/web/routes/chat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Web chat"""

# typing
from typing import Annotated

# stdlib
from asyncio import sleep
import json

# 3rd party
from fastapi import Depends, Request
from sse_starlette.sse import EventSourceResponse

# api
from xthulu.models.user import User
from xthulu.resources import Resources
from xthulu.web.auth import login_user

# local
from ...scripts.chat import ChatMessage
from .. import api


@api.get("/chat/")
def chat(user: Annotated[User, Depends(login_user)], request: Request):
async def generate():
redis = Resources().cache
pubsub = redis.pubsub()
pubsub.subscribe("chat")
redis.publish(
"chat",
json.dumps(
ChatMessage(
user=None, message=f"{user.name} has joined"
).__dict__
),
)

try:
while not await request.is_disconnected():
message = pubsub.get_message(True)
data: bytes

if not message:
await sleep(0.1)
continue

data = message["data"]
yield data.decode("utf-8")
finally:
redis.publish(
"chat",
json.dumps(
ChatMessage(
user=None, message=f"{user.name} has left"
).__dict__
),
)
pubsub.close()
redis.close()

return EventSourceResponse(generate())
Loading

0 comments on commit 7b408ef

Please sign in to comment.