1
Fork 0
mirror of https://github.com/RYGhub/royalnet.git synced 2024-11-27 13:34:28 +00:00

Document and refactor constellation

This commit is contained in:
Steffo 2019-11-12 13:01:47 +01:00
parent e1b76fed6b
commit caf16ddf50
10 changed files with 141 additions and 81 deletions

View file

@ -3,5 +3,8 @@
<component name="JavaScriptSettings"> <component name="JavaScriptSettings">
<option name="languageLevel" value="ES6" /> <option name="languageLevel" value="ES6" />
</component> </component>
<component name="MacroExpansionManager">
<option name="directoryName" value="cqweyjJr" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.8" project-jdk-type="Python SDK" /> <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.8" project-jdk-type="Python SDK" />
</project> </project>

View file

@ -6,6 +6,6 @@
- [ ] interfaces - [ ] interfaces
- [ ] packs (almost) - [ ] packs (almost)
- [ ] utils - [ ] utils
- [ ] web - [x] constellation
- [ ] main - [ ] main
- [ ] dependencies - [ ] dependencies

View file

@ -33,6 +33,8 @@ sqlalchemy = {version="^1.3.10", optional=true}
psycopg2 = {version="^2.8.4", optional=true} # Requires quite a bit of stuff http://initd.org/psycopg/docs/install.html#install-from-source psycopg2 = {version="^2.8.4", optional=true} # Requires quite a bit of stuff http://initd.org/psycopg/docs/install.html#install-from-source
psycopg2_binary = {version="^2.8.4", optional=true} # Prebuilt alternative to psycopg2, not recommended psycopg2_binary = {version="^2.8.4", optional=true} # Prebuilt alternative to psycopg2, not recommended
starlette = {version="0.12.13", optional=true}
# Optional dependencies # Optional dependencies
[tool.poetry.extras] [tool.poetry.extras]
telegram = ["python_telegram_bot"] telegram = ["python_telegram_bot"]
@ -40,6 +42,7 @@ discord = ["discord_py", "pynacl"]
alchemy_easy = ["sqlalchemy", "psycopg2_binary"] alchemy_easy = ["sqlalchemy", "psycopg2_binary"]
alchemy_hard = ["sqlalchemy", "psycopg2"] alchemy_hard = ["sqlalchemy", "psycopg2"]
bard = ["ffmpeg_python", "youtube_dl"] bard = ["ffmpeg_python", "youtube_dl"]
constellation = ["starlette"]
# Development dependencies # Development dependencies
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]

View file

@ -0,0 +1,5 @@
# `royalnet.constellation`
The part of `royalnet` that handles the webserver and webpages.
It uses many features of [`starlette`](https://www.starlette.io).

View file

@ -1,11 +1,15 @@
"""The part of :mod:`royalnet` that handles the webserver and webpages.
It uses many features of :mod:`starlette`."""
from .constellation import Constellation from .constellation import Constellation
from .star import Star, PageStar, ExceptionStar from .star import Star, PageStar, ExceptionStar
from .error import error from .shoot import shoot
__all__ = [ __all__ = [
"Constellation", "Constellation",
"Star", "Star",
"PageStar", "PageStar",
"ExceptionStar", "ExceptionStar",
"error", "shoot",
] ]

View file

@ -15,6 +15,11 @@ log = logging.getLogger(__name__)
class Constellation: class Constellation:
"""A Constellation is the class that represents the webserver.
It runs multiple :class:`Star`s, which represent the routes of the website.
It also handles the :class:`Alchemy` connection, and it _will_ support Herald connections too."""
def __init__(self, def __init__(self,
secrets_name: str, secrets_name: str,
database_uri: str, database_uri: str,
@ -31,35 +36,48 @@ class Constellation:
self.secrets_name: str = secrets_name self.secrets_name: str = secrets_name
log.info("Creating starlette app...") log.info(f"Creating Starlette in {'Debug' if __debug__ else 'Production'} mode...")
self.starlette = Starlette(debug=debug) self.starlette = Starlette(debug=debug)
log.info(f"Creating alchemy with tables: {' '.join([table.__name__ for table in tables])}") log.info(f"Creating Alchemy with Tables: {' '.join([table.__name__ for table in tables])}")
self.alchemy: royalnet.database.Alchemy = royalnet.database.Alchemy(database_uri=database_uri, tables=tables) self.alchemy: royalnet.database.Alchemy = royalnet.database.Alchemy(database_uri=database_uri, tables=tables)
log.info("Registering page_stars...") log.info("Registering PageStars...")
for SelectedPageStar in page_stars: for SelectedPageStar in page_stars:
log.info(f"Registering: {page_star_instance.path} -> {page_star_instance.__class__.__name__}")
try: try:
page_star_instance = SelectedPageStar(constellation=self) page_star_instance = SelectedPageStar(constellation=self)
except Exception as e: except Exception as e:
log.error(f"{e.__class__.__qualname__} during the registration of {SelectedPageStar.__qualname__}") log.error(f"{e.__class__.__qualname__} during the registration of {SelectedPageStar.__qualname__}!")
sentry_sdk.capture_exception(e) raise
continue
log.info(f"Registering: {page_star_instance.path} -> {page_star_instance.__class__.__name__}")
self.starlette.add_route(page_star_instance.path, page_star_instance.page, page_star_instance.methods) self.starlette.add_route(page_star_instance.path, page_star_instance.page, page_star_instance.methods)
log.info("Registering exc_stars...") log.info("Registering ExceptionStars...")
for SelectedExcStar in exc_stars: for SelectedExcStar in exc_stars:
log.info(f"Registering: {exc_star_instance.error} -> {exc_star_instance.__class__.__name__}")
try: try:
exc_star_instance = SelectedExcStar(constellation=self) exc_star_instance = SelectedExcStar(constellation=self)
except Exception as e: except Exception as e:
log.error(f"{e.__class__.__qualname__} during the registration of {SelectedExcStar.__qualname__}") log.error(f"{e.__class__.__qualname__} during the registration of {SelectedExcStar.__qualname__}!")
sentry_sdk.capture_exception(e) raise
continue
log.info(f"Registering: {exc_star_instance.error} -> {exc_star_instance.__class__.__name__}")
self.starlette.add_exception_handler(exc_star_instance.error, exc_star_instance.page) self.starlette.add_exception_handler(exc_star_instance.error, exc_star_instance.page)
def _init_sentry(self): def get_secret(self, username: str) -> typing.Optional[str]:
"""Get a Royalnet secret from the keyring.
Args:
username: the name of the secret that should be retrieved."""
return keyring.get_password(f"Royalnet/{self.secrets_name}", username)
def run_blocking(self, address: str, port: int):
"""Blockingly run the Constellation.
This should be used as the target of a :class:`multiprocessing.Process`.
Args:
address: The IP address this Constellation should bind to.
port: The port this Constellation should listen for requests on."""
# Initialize Sentry on the process
sentry_dsn = self.get_secret("sentry") sentry_dsn = self.get_secret("sentry")
if sentry_dsn: if sentry_dsn:
# noinspection PyUnreachableCode # noinspection PyUnreachableCode
@ -68,28 +86,13 @@ class Constellation:
else: else:
release = royalnet.version.semantic release = royalnet.version.semantic
log.info(f"Sentry: enabled (Royalnet {release})") log.info(f"Sentry: enabled (Royalnet {release})")
self.sentry = sentry_sdk.init(sentry_dsn, sentry_sdk.init(sentry_dsn,
integrations=[AioHttpIntegration(), integrations=[AioHttpIntegration(),
SqlalchemyIntegration(), SqlalchemyIntegration(),
LoggingIntegration(event_level=None)], LoggingIntegration(event_level=None)],
release=release) release=release)
else: else:
log.info("Sentry: disabled") log.info("Sentry: disabled")
# Run the server
def get_secret(self, username: str):
return keyring.get_password(f"Royalnet/{self.secrets_name}", username)
def set_secret(self, username: str, password: str):
return keyring.set_password(f"Royalnet/{self.secrets_name}", username, password)
def run_blocking(self, address: str, port: int, verbose: bool):
if verbose:
core_logger = logging.root
core_logger.setLevel(logging.DEBUG)
stream_handler = logging.StreamHandler()
stream_handler.formatter = logging.Formatter("{asctime}\t{name}\t{levelname}\t{message}", style="{")
core_logger.addHandler(stream_handler)
core_logger.debug("Logging setup complete.")
self._init_sentry()
log.info(f"Running constellation server on {address}:{port}...") log.info(f"Running constellation server on {address}:{port}...")
uvicorn.run(self.starlette, host=address, port=port) uvicorn.run(self.starlette, host=address, port=port)

View file

@ -0,0 +1,8 @@
from starlette.responses import JSONResponse
def shoot(code: int, description: str) -> JSONResponse:
"""Create a error :class:`JSONResponse` with the passed error code and description."""
return JSONResponse({
"error": description
}, status_code=code)

View file

@ -0,0 +1,78 @@
from typing import Type, TYPE_CHECKING, List, Union
from starlette.requests import Request
from starlette.responses import Response
if TYPE_CHECKING:
from .constellation import Constellation
class Star:
"""A Star is a class representing a part of the website.
It shouldn't be used directly: please use :class:`PageStar` and :class:`ExceptionStar` instead!"""
tables: set = {}
"""The set of :class`Alchemy` :class:`Table` classes required by this Star to function."""
def __init__(self, constellation: "Constellation"):
self.constellation: "Constellation" = constellation
async def page(self, request: Request) -> Response:
"""The function generating the :class:`Response` to a web :class:`Request`.
If it raises an error, the corresponding :class:`ExceptionStar` will be used to handle the request instead."""
raise NotImplementedError()
@property
def alchemy(self):
return self.constellation.alchemy
@property
def Session(self):
return self.constellation.alchemy.Session
@property
def session_acm(self):
return self.constellation.alchemy.session_acm
class PageStar(Star):
"""A PageStar is a class representing a single route of the website (for example, ``/api/user/get``).
To create a new website route you should create a new class inheriting from this class with a function overriding
:meth:`page` and changing the values of :attr:`path` and optionally :attr:`methods` and :attr:`tables`."""
path: str = NotImplemented
"""The route of the star.
Example:
::
path: str = '/api/user/get'
"""
methods: List[str] = ["GET"]
"""The HTTP methods supported by the Star, in form of a list.
By default, a Star only supports the ``GET`` method, but more can be added.
Example:
::
methods: List[str] = ["GET", "POST", "PUT", "DELETE"]
"""
class ExceptionStar(Star):
"""An ExceptionStar is a class that handles an :class:`Exception` raised by another star by returning a different
response than the one originally intended.
The handled exception type is specified in the :attr:`error`.
It can also handle standard webserver errors, such as ``404 Not Found``:
to handle them, set :attr:`error` to an :class:`int` of the corresponding error code.
To create a new exception handler you should create a new class inheriting from this class with a function
overriding :meth:`page` and changing the values of :attr:`error` and optionally :attr:`tables`."""
error: Union[Type[Exception], int]
"""The error that should be handled by this star. It should be either a subclass of :exc:`Exception`,
or the :class:`int` of an HTTP error code."""

View file

@ -1,7 +0,0 @@
from starlette.responses import JSONResponse
def error(code: int, description: str) -> JSONResponse:
return JSONResponse({
"error": description
}, status_code=code)

View file

@ -1,37 +0,0 @@
import typing
from starlette.requests import Request
from starlette.responses import Response
if typing.TYPE_CHECKING:
from .constellation import Constellation
class Star:
tables: set = {}
def __init__(self, constellation: "Constellation"):
self.constellation: "Constellation" = constellation
async def page(self, request: Request) -> Response:
raise NotImplementedError()
@property
def alchemy(self):
return self.constellation.alchemy
@property
def Session(self):
return self.constellation.alchemy.Session
@property
def session_acm(self):
return self.constellation.alchemy.session_acm
class PageStar(Star):
path: str = NotImplemented
methods: typing.List[str] = ["GET"]
class ExceptionStar(Star):
error: typing.Union[typing.Type[Exception], int]