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]