diff --git a/.idea/misc.xml b/.idea/misc.xml index 6649a8c6..0422079e 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,5 +3,8 @@ + + \ No newline at end of file diff --git a/TODO.md b/TODO.md index 3bb48099..38d37ee7 100644 --- a/TODO.md +++ b/TODO.md @@ -6,6 +6,6 @@ - [ ] interfaces - [ ] packs (almost) - [ ] utils -- [ ] web +- [x] constellation - [ ] main - [ ] dependencies \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 36215b2a..2b4edfb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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_binary = {version="^2.8.4", optional=true} # Prebuilt alternative to psycopg2, not recommended +starlette = {version="0.12.13", optional=true} + # Optional dependencies [tool.poetry.extras] telegram = ["python_telegram_bot"] @@ -40,6 +42,7 @@ discord = ["discord_py", "pynacl"] alchemy_easy = ["sqlalchemy", "psycopg2_binary"] alchemy_hard = ["sqlalchemy", "psycopg2"] bard = ["ffmpeg_python", "youtube_dl"] +constellation = ["starlette"] # Development dependencies [tool.poetry.dev-dependencies] diff --git a/royalnet/constellation/README.md b/royalnet/constellation/README.md new file mode 100644 index 00000000..179a0740 --- /dev/null +++ b/royalnet/constellation/README.md @@ -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). diff --git a/royalnet/web/__init__.py b/royalnet/constellation/__init__.py similarity index 52% rename from royalnet/web/__init__.py rename to royalnet/constellation/__init__.py index f1fd3545..effea7b3 100644 --- a/royalnet/web/__init__.py +++ b/royalnet/constellation/__init__.py @@ -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 .star import Star, PageStar, ExceptionStar -from .error import error +from .shoot import shoot __all__ = [ "Constellation", "Star", "PageStar", "ExceptionStar", - "error", + "shoot", ] diff --git a/royalnet/web/constellation.py b/royalnet/constellation/constellation.py similarity index 62% rename from royalnet/web/constellation.py rename to royalnet/constellation/constellation.py index e9de9dbd..bbbb23dc 100644 --- a/royalnet/web/constellation.py +++ b/royalnet/constellation/constellation.py @@ -15,6 +15,11 @@ log = logging.getLogger(__name__) 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, secrets_name: str, database_uri: str, @@ -31,35 +36,48 @@ class Constellation: 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) - 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) - log.info("Registering page_stars...") + log.info("Registering PageStars...") for SelectedPageStar in page_stars: + log.info(f"Registering: {page_star_instance.path} -> {page_star_instance.__class__.__name__}") try: page_star_instance = SelectedPageStar(constellation=self) except Exception as e: - log.error(f"{e.__class__.__qualname__} during the registration of {SelectedPageStar.__qualname__}") - sentry_sdk.capture_exception(e) - continue - log.info(f"Registering: {page_star_instance.path} -> {page_star_instance.__class__.__name__}") + log.error(f"{e.__class__.__qualname__} during the registration of {SelectedPageStar.__qualname__}!") + raise 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: + log.info(f"Registering: {exc_star_instance.error} -> {exc_star_instance.__class__.__name__}") try: exc_star_instance = SelectedExcStar(constellation=self) except Exception as e: - log.error(f"{e.__class__.__qualname__} during the registration of {SelectedExcStar.__qualname__}") - sentry_sdk.capture_exception(e) - continue - log.info(f"Registering: {exc_star_instance.error} -> {exc_star_instance.__class__.__name__}") + log.error(f"{e.__class__.__qualname__} during the registration of {SelectedExcStar.__qualname__}!") + raise 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") if sentry_dsn: # noinspection PyUnreachableCode @@ -68,28 +86,13 @@ class Constellation: else: release = royalnet.version.semantic log.info(f"Sentry: enabled (Royalnet {release})") - self.sentry = sentry_sdk.init(sentry_dsn, - integrations=[AioHttpIntegration(), - SqlalchemyIntegration(), - LoggingIntegration(event_level=None)], - release=release) + sentry_sdk.init(sentry_dsn, + integrations=[AioHttpIntegration(), + SqlalchemyIntegration(), + LoggingIntegration(event_level=None)], + release=release) else: log.info("Sentry: disabled") - - 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() + # Run the server log.info(f"Running constellation server on {address}:{port}...") uvicorn.run(self.starlette, host=address, port=port) diff --git a/royalnet/constellation/shoot.py b/royalnet/constellation/shoot.py new file mode 100644 index 00000000..fa600b72 --- /dev/null +++ b/royalnet/constellation/shoot.py @@ -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) diff --git a/royalnet/constellation/star.py b/royalnet/constellation/star.py new file mode 100644 index 00000000..9469c2a2 --- /dev/null +++ b/royalnet/constellation/star.py @@ -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.""" diff --git a/royalnet/web/error.py b/royalnet/web/error.py deleted file mode 100644 index 239e34ee..00000000 --- a/royalnet/web/error.py +++ /dev/null @@ -1,7 +0,0 @@ -from starlette.responses import JSONResponse - - -def error(code: int, description: str) -> JSONResponse: - return JSONResponse({ - "error": description - }, status_code=code) diff --git a/royalnet/web/star.py b/royalnet/web/star.py deleted file mode 100644 index bc8c5d24..00000000 --- a/royalnet/web/star.py +++ /dev/null @@ -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]