diff --git a/poetry.lock b/poetry.lock index 4fa9e774..c7f408d5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -66,6 +66,21 @@ version = "2.8.0" [package.dependencies] pytz = ">=2015.7" +[[package]] +category = "main" +description = "Modern password hashing for your software and your servers" +name = "bcrypt" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "3.1.7" + +[package.dependencies] +cffi = ">=1.1" +six = ">=1.4.1" + +[package.extras] +tests = ["pytest (>=3.2.1,<3.3.0 || >3.3.0)"] + [[package]] category = "main" description = "Python package for providing Mozilla's CA Bundle." @@ -913,8 +928,8 @@ python-versions = "*" version = "2020.1.24" [extras] -alchemy_easy = ["sqlalchemy", "psycopg2_binary"] -alchemy_hard = ["sqlalchemy", "psycopg2"] +alchemy_easy = ["sqlalchemy", "psycopg2_binary", "bcrypt"] +alchemy_hard = ["sqlalchemy", "psycopg2", "bcrypt"] bard = ["ffmpeg_python", "youtube_dl", "eyed3"] coloredlogs = ["coloredlogs"] constellation = ["starlette", "uvicorn", "python-multipart"] @@ -925,7 +940,7 @@ sentry = ["sentry_sdk"] telegram = ["python_telegram_bot"] [metadata] -content-hash = "f275cd948fe28423a90d37d2825eabfec97e8ac0cdf52ee2d20f803d61987b40" +content-hash = "218f4a253a7ef17bb871abf541ae7182f1626cd43d55417de12f9561c58ca0e9" python-versions = "^3.8" [metadata.files] @@ -963,6 +978,26 @@ babel = [ {file = "Babel-2.8.0-py2.py3-none-any.whl", hash = "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4"}, {file = "Babel-2.8.0.tar.gz", hash = "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38"}, ] +bcrypt = [ + {file = "bcrypt-3.1.7-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:d7bdc26475679dd073ba0ed2766445bb5b20ca4793ca0db32b399dccc6bc84b7"}, + {file = "bcrypt-3.1.7-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:69361315039878c0680be456640f8705d76cb4a3a3fe1e057e0f261b74be4b31"}, + {file = "bcrypt-3.1.7-cp27-cp27m-win32.whl", hash = "sha256:5432dd7b34107ae8ed6c10a71b4397f1c853bd39a4d6ffa7e35f40584cffd161"}, + {file = "bcrypt-3.1.7-cp27-cp27m-win_amd64.whl", hash = "sha256:9fe92406c857409b70a38729dbdf6578caf9228de0aef5bc44f859ffe971a39e"}, + {file = "bcrypt-3.1.7-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:763669a367869786bb4c8fcf731f4175775a5b43f070f50f46f0b59da45375d0"}, + {file = "bcrypt-3.1.7-cp34-abi3-macosx_10_6_intel.whl", hash = "sha256:a190f2a5dbbdbff4b74e3103cef44344bc30e61255beb27310e2aec407766052"}, + {file = "bcrypt-3.1.7-cp34-abi3-manylinux1_x86_64.whl", hash = "sha256:c9457fa5c121e94a58d6505cadca8bed1c64444b83b3204928a866ca2e599105"}, + {file = "bcrypt-3.1.7-cp34-cp34m-win32.whl", hash = "sha256:8b10acde4e1919d6015e1df86d4c217d3b5b01bb7744c36113ea43d529e1c3de"}, + {file = "bcrypt-3.1.7-cp34-cp34m-win_amd64.whl", hash = "sha256:cb93f6b2ab0f6853550b74e051d297c27a638719753eb9ff66d1e4072be67133"}, + {file = "bcrypt-3.1.7-cp35-cp35m-win32.whl", hash = "sha256:6fe49a60b25b584e2f4ef175b29d3a83ba63b3a4df1b4c0605b826668d1b6be5"}, + {file = "bcrypt-3.1.7-cp35-cp35m-win_amd64.whl", hash = "sha256:a595c12c618119255c90deb4b046e1ca3bcfad64667c43d1166f2b04bc72db09"}, + {file = "bcrypt-3.1.7-cp36-cp36m-win32.whl", hash = "sha256:74a015102e877d0ccd02cdeaa18b32aa7273746914a6c5d0456dd442cb65b99c"}, + {file = "bcrypt-3.1.7-cp36-cp36m-win_amd64.whl", hash = "sha256:0258f143f3de96b7c14f762c770f5fc56ccd72f8a1857a451c1cd9a655d9ac89"}, + {file = "bcrypt-3.1.7-cp37-cp37m-win32.whl", hash = "sha256:19a4b72a6ae5bb467fea018b825f0a7d917789bcfe893e53f15c92805d187294"}, + {file = "bcrypt-3.1.7-cp37-cp37m-win_amd64.whl", hash = "sha256:ff032765bb8716d9387fd5376d987a937254b0619eff0972779515b5c98820bc"}, + {file = "bcrypt-3.1.7-cp38-cp38-win32.whl", hash = "sha256:ce4e4f0deb51d38b1611a27f330426154f2980e66582dc5f438aad38b5f24fc1"}, + {file = "bcrypt-3.1.7-cp38-cp38-win_amd64.whl", hash = "sha256:6305557019906466fc42dbc53b46da004e72fd7a551c044a827e572c82191752"}, + {file = "bcrypt-3.1.7.tar.gz", hash = "sha256:0b0069c752ec14172c5f78208f1863d7ad6755a6fae6fe76ec2c80d13be41e42"}, +] certifi = [ {file = "certifi-2019.11.28-py2.py3-none-any.whl", hash = "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3"}, {file = "certifi-2019.11.28.tar.gz", hash = "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"}, diff --git a/publish.bat b/publish.bat new file mode 100644 index 00000000..46fa4128 --- /dev/null +++ b/publish.bat @@ -0,0 +1 @@ +git commit -am "publish: %1" && git push && poetry build && poetry publish && hub release create "%1" -m "Royalnet %1" \ No newline at end of file diff --git a/pycharm_templates/command.vm b/pycharm_templates/command.vm index e96b214a..af1a69a3 100644 --- a/pycharm_templates/command.vm +++ b/pycharm_templates/command.vm @@ -1,5 +1,4 @@ from typing import * -import royalnet import royalnet.commands as rc diff --git a/pycharm_templates/event.vm b/pycharm_templates/event.vm index 1182ac76..495b8150 100644 --- a/pycharm_templates/event.vm +++ b/pycharm_templates/event.vm @@ -1,5 +1,4 @@ from typing import * -import royalnet import royalnet.commands as rc diff --git a/pycharm_templates/table.vm b/pycharm_templates/table.vm index c6134933..90eb3d5a 100644 --- a/pycharm_templates/table.vm +++ b/pycharm_templates/table.vm @@ -3,6 +3,7 @@ from sqlalchemy.orm import * from sqlalchemy.ext.declarative import declared_attr +# noinspection PyAttributeOutsideInit #set($CAPITALIZED_NAME = $NAME.substring(0,1).toUpperCase() + $NAME.substring(1)) class ${CAPITALIZED_NAME}: __tablename__ = "${NAME}" diff --git a/pyproject.toml b/pyproject.toml index 5363191b..33b91041 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ [tool.poetry] name = "royalnet" - version = "5.4.1" + version = "5.5" description = "A multipurpose bot and web framework" authors = ["Stefano Pigozzi "] license = "AGPL-3.0+" @@ -44,6 +44,7 @@ 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 + bcrypt = {version="^3.1.7", optional=true} # constellation starlette = {version="^0.12.13", optional=true} @@ -71,8 +72,8 @@ telegram = ["python_telegram_bot"] discord = ["discord.py", "pynacl"] matrix = ["matrix-nio"] - alchemy_easy = ["sqlalchemy", "psycopg2_binary"] - alchemy_hard = ["sqlalchemy", "psycopg2"] + alchemy_easy = ["sqlalchemy", "psycopg2_binary", "bcrypt"] + alchemy_hard = ["sqlalchemy", "psycopg2", "bcrypt"] bard = ["ffmpeg_python", "youtube_dl", "eyed3"] constellation = ["starlette", "uvicorn", "python-multipart"] sentry = ["sentry_sdk"] diff --git a/royalnet/alchemy/alchemy.py b/royalnet/alchemy/alchemy.py index 27704632..b4c1c2aa 100644 --- a/royalnet/alchemy/alchemy.py +++ b/royalnet/alchemy/alchemy.py @@ -1,4 +1,4 @@ -from typing import Set, Dict, Union +from typing import * from contextlib import contextmanager, asynccontextmanager from royalnet.utils import asyncify from royalnet.alchemy.errors import TableNotFoundError @@ -6,7 +6,7 @@ from sqlalchemy import create_engine from sqlalchemy.engine import Engine from sqlalchemy.schema import Table from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.ext.declarative.api import DeclarativeMeta +from sqlalchemy.ext.declarative.api import DeclarativeMeta, AbstractConcreteBase from sqlalchemy.orm import sessionmaker @@ -26,7 +26,7 @@ class Alchemy: raise NotImplementedError("sqlite databases aren't supported, as they can't be used in multithreaded" " applications") self._engine: Engine = create_engine(database_uri) - self._Base: DeclarativeMeta = declarative_base(bind=self._engine) + self._Base = declarative_base(bind=self._engine) self.Session: sessionmaker = sessionmaker(bind=self._engine) self._tables: Dict[str, Table] = {} for table in tables: @@ -38,7 +38,7 @@ class Alchemy: self._tables[name] = bound_table self._Base.metadata.create_all() - def get(self, table: Union[str, type]) -> Table: + def get(self, table: Union[str, type]) -> DeclarativeMeta: """Get the table with a specified name or class. Args: diff --git a/royalnet/backpack/__init__.py b/royalnet/backpack/__init__.py index a51af2ed..3d320ba5 100644 --- a/royalnet/backpack/__init__.py +++ b/royalnet/backpack/__init__.py @@ -1,19 +1 @@ """A Pack that is imported by default by all Royalnet instances.""" - -from . import commands, tables, stars, events -from .commands import available_commands -from .tables import available_tables -from .stars import available_page_stars, available_exception_stars -from .events import available_events - -__all__ = [ - "commands", - "tables", - "stars", - "events", - "available_commands", - "available_tables", - "available_page_stars", - "available_exception_stars", - "available_events", -] diff --git a/royalnet/backpack/commands/__init__.py b/royalnet/backpack/commands/__init__.py index 3b99caa5..50c1aa68 100644 --- a/royalnet/backpack/commands/__init__.py +++ b/royalnet/backpack/commands/__init__.py @@ -1,22 +1,10 @@ # Imports go here! from .version import VersionCommand -from .exception import ExceptionCommand -from .excevent import ExceventCommand -from .keyboardtest import KeyboardtestCommand # Enter the commands of your Pack here! available_commands = [ VersionCommand, ] -# noinspection PyUnreachableCode -if __debug__: - available_commands = [ - *available_commands, - ExceptionCommand, - ExceventCommand, - KeyboardtestCommand, - ] - # Don't change this, it should automatically generate __all__ __all__ = [command.__name__ for command in available_commands] diff --git a/royalnet/backpack/commands/exception.py b/royalnet/backpack/commands/exception.py deleted file mode 100644 index 5419b0b3..00000000 --- a/royalnet/backpack/commands/exception.py +++ /dev/null @@ -1,11 +0,0 @@ -import royalnet -from royalnet.commands import * - - -class ExceptionCommand(Command): - name: str = "exception" - - description: str = "Raise an exception in the command." - - async def run(self, args: CommandArgs, data: CommandData) -> None: - raise Exception(f"{self.interface.prefix}{self.name} was called") diff --git a/royalnet/backpack/commands/excevent.py b/royalnet/backpack/commands/excevent.py deleted file mode 100644 index 38220942..00000000 --- a/royalnet/backpack/commands/excevent.py +++ /dev/null @@ -1,12 +0,0 @@ -import royalnet -from royalnet.commands import * - - -class ExceventCommand(Command): - name: str = "excevent" - - description: str = "Call an event that raises an exception." - - async def run(self, args: CommandArgs, data: CommandData) -> None: - await self.interface.call_herald_event(self.interface.name, "exception") - await data.reply("✅ Event called!") diff --git a/royalnet/backpack/commands/keyboardtest.py b/royalnet/backpack/commands/keyboardtest.py deleted file mode 100644 index 4f24fade..00000000 --- a/royalnet/backpack/commands/keyboardtest.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import * -from royalnet.commands import * -import functools -import asyncio - - -class KeyboardtestCommand(Command): - name: str = "keyboardtest" - - description: str = "Create a new keyboard with the specified keys." - - syntax: str = "{keys}+" - - @staticmethod - async def echo(data: CommandData, echo: str): - await data.reply(echo) - - async def run(self, args: CommandArgs, data: CommandData) -> None: - keys = [] - for arg in args: - # noinspection PyTypeChecker - keys.append(KeyboardKey(interface=self.interface, - short=arg[0], - text=arg, - callback=functools.partial(self.echo, echo=arg))) - async with data.keyboard("This is a test keyboard.", keys): - await asyncio.sleep(10) - await data.reply("The keyboard is no longer in scope.") diff --git a/royalnet/backpack/commands/link.py b/royalnet/backpack/commands/link.py new file mode 100644 index 00000000..30c38730 --- /dev/null +++ b/royalnet/backpack/commands/link.py @@ -0,0 +1,86 @@ +from typing import * +import royalnet +import royalnet.commands as rc +import royalnet.utils as ru +from ..tables.telegram import Telegram +from ..tables.discord import Discord + + +class SyncCommand(rc.Command): + name: str = "sync" + + description: str = "Connect your chat account to Royalnet!" + + syntax: str = "{username} {password}" + + async def run(self, args: rc.CommandArgs, data: rc.CommandData) -> None: + username = args[0] + password = " ".join(args[1:]) + + author = await data.get_author(error_if_none=True) + + user = await data.find_user(username) + try: + successful = user.test_password(password) + except ValueError: + raise rc.UserError(f"User {user} has no password set!") + if not successful: + raise rc.InvalidInputError(f"Invalid password!") + + if self.interface.name == "telegram": + import telegram + message: telegram.Message = data.message + from_user: telegram.User = message.from_user + TelegramT = self.alchemy.get(Telegram) + tg_user: Telegram = await ru.asyncify( + data.session.query(TelegramT).filter_by(tg_id=from_user.id).one_or_none + ) + if tg_user is None: + # Create + tg_user = TelegramT( + user=author, + tg_id=from_user.id, + first_name=from_user.first_name, + last_name=from_user.last_name, + username=from_user.username + ) + data.session.add(tg_user) + else: + # Edit + tg_user.first_name = from_user.first_name + tg_user.last_name = from_user.last_name + tg_user.username = from_user.username + await data.session_commit() + await data.reply(f"↔️ Account {tg_user} synced to {author}!") + + elif self.interface.name == "discord": + import discord + message: discord.Message = data.message + author: discord.User = message.author + DiscordT = self.alchemy.get(Discord) + ds_user: Discord = await ru.asyncify( + data.session.query(DiscordT).filter_by(discord_id=author.id).one_or_none + ) + if ds_user is None: + # Create + ds_user = DiscordT( + user=author, + discord_id=author.id, + username=author.name, + discriminator=author.discriminator, + avatar_url=author.avatar_url + ) + data.session.add(ds_user) + else: + # Edit + ds_user.username = author.name + ds_user.discriminator = author.discriminator + ds_user.avatar_url = author.avatar_url + await data.session_commit() + await data.reply(f"↔️ Account {ds_user} synced to {author}!") + + elif self.interface.name == "matrix": + raise rc.UnsupportedError(f"{self} hasn't been implemented for Matrix yet") + + else: + raise rc.UnsupportedError(f"Unknown interface: {self.interface.name}") diff --git a/royalnet/backpack/stars/__init__.py b/royalnet/backpack/stars/__init__.py index 49192e58..3c869821 100644 --- a/royalnet/backpack/stars/__init__.py +++ b/royalnet/backpack/stars/__init__.py @@ -1,16 +1,18 @@ # Imports go here! from .api_royalnet_version import ApiRoyalnetVersionStar - +from .api_login_royalnet import ApiLoginRoyalnetStar +from .api_token_info import ApiTokenInfoStar +from .api_token_passwd import ApiTokenPasswdStar +from .api_token_create import ApiTokenCreateStar # Enter the PageStars of your Pack here! available_page_stars = [ ApiRoyalnetVersionStar, -] - -# Enter the ExceptionStars of your Pack here! -available_exception_stars = [ - + ApiLoginRoyalnetStar, + ApiTokenInfoStar, + ApiTokenPasswdStar, + ApiTokenCreateStar, ] # Don't change this, it should automatically generate __all__ -__all__ = [star.__name__ for star in [*available_page_stars, *available_exception_stars]] +__all__ = [star.__name__ for star in available_page_stars] diff --git a/royalnet/backpack/stars/api_login_royalnet.py b/royalnet/backpack/stars/api_login_royalnet.py new file mode 100644 index 00000000..32c04d28 --- /dev/null +++ b/royalnet/backpack/stars/api_login_royalnet.py @@ -0,0 +1,34 @@ +import datetime +import royalnet.utils as ru +from royalnet.constellation.api import * +from ..tables.users import User +from ..tables.aliases import Alias +from ..tables.tokens import Token + + +class ApiLoginRoyalnetStar(ApiStar): + path = "/api/login/royalnet/v1" + + methods = ["POST"] + + async def api(self, data: ApiData) -> ru.JSON: + TokenT = self.alchemy.get(Token) + UserT = self.alchemy.get(User) + AliasT = self.alchemy.get(Alias) + + username = data["username"] + password = data["password"] + + async with self.session_acm() as session: + user: User = await ru.asyncify(session.query(UserT).filter_by(username=username).one_or_none) + if user is None: + raise NotFoundError("User not found") + pswd_check = user.test_password(password) + if not pswd_check: + raise ApiError("Invalid password") + token: Token = TokenT.generate(alchemy=self.alchemy, user=user, expiration_delta=datetime.timedelta(days=7)) + session.add(token) + await ru.asyncify(session.commit) + response = token.json() + + return response diff --git a/royalnet/backpack/stars/api_royalnet_version.py b/royalnet/backpack/stars/api_royalnet_version.py index 26f3b9d0..74092984 100644 --- a/royalnet/backpack/stars/api_royalnet_version.py +++ b/royalnet/backpack/stars/api_royalnet_version.py @@ -1,15 +1,12 @@ -import royalnet -from starlette.requests import Request -from starlette.responses import * -from royalnet.constellation import PageStar +import royalnet.version as rv +from royalnet.constellation.api import * +import royalnet.utils as ru -class ApiRoyalnetVersionStar(PageStar): - path = "/api/royalnet/version" +class ApiRoyalnetVersionStar(ApiStar): + path = "/api/royalnet/version/v1" - async def page(self, request: Request) -> JSONResponse: - return JSONResponse({ - "version": { - "semantic": royalnet.__version__, - } - }) + async def api(self, data: ApiData) -> ru.JSON: + return { + "semantic": rv.semantic + } diff --git a/royalnet/backpack/stars/api_token_create.py b/royalnet/backpack/stars/api_token_create.py new file mode 100644 index 00000000..4c1f6211 --- /dev/null +++ b/royalnet/backpack/stars/api_token_create.py @@ -0,0 +1,22 @@ +from typing import * +import datetime +import royalnet.utils as ru +from royalnet.constellation.api import * +from ..tables.tokens import Token + + +class ApiTokenCreateStar(ApiStar): + path = "/api/token/create/v1" + + methods = ["POST"] + + async def api(self, data: ApiData) -> ru.JSON: + user = await data.user() + try: + duration = int(data["duration"]) + except ValueError: + raise InvalidParameterError("Duration is not a valid integer") + new_token = Token.generate(self.alchemy, user, datetime.timedelta(seconds=duration)) + data.session.add(new_token) + await data.session_commit() + return new_token.json() diff --git a/royalnet/backpack/stars/api_token_info.py b/royalnet/backpack/stars/api_token_info.py new file mode 100644 index 00000000..7174df36 --- /dev/null +++ b/royalnet/backpack/stars/api_token_info.py @@ -0,0 +1,10 @@ +import royalnet.utils as ru +from royalnet.constellation.api import * + + +class ApiTokenInfoStar(ApiStar): + path = "/api/token/info/v1" + + async def api(self, data: ApiData) -> ru.JSON: + token = await data.token() + return token.json() diff --git a/royalnet/backpack/stars/api_token_passwd.py b/royalnet/backpack/stars/api_token_passwd.py new file mode 100644 index 00000000..323dd930 --- /dev/null +++ b/royalnet/backpack/stars/api_token_passwd.py @@ -0,0 +1,35 @@ +from typing import * +import datetime +import royalnet.utils as ru +from royalnet.constellation.api import * +from sqlalchemy import and_ +from ..tables.tokens import Token + + +class ApiTokenPasswdStar(ApiStar): + path = "/api/token/passwd/v1" + + methods = ["POST"] + + async def api(self, data: ApiData) -> ru.JSON: + TokenT = self.alchemy.get(Token) + token = await data.token() + user = token.user + user.set_password(data["new_password"]) + tokens: List[Token] = await ru.asyncify( + data.session + .query(self.alchemy.get(Token)) + .filter( + and_( + TokenT.user == user, + TokenT.expiration >= datetime.datetime.now() + )) + .all + ) + for t in tokens: + if t.token != token.token: + t.expired = True + await data.session_commit() + return { + "revoked_tokens": len(tokens) - 1 + } diff --git a/royalnet/backpack/tables/__init__.py b/royalnet/backpack/tables/__init__.py index d4c750e9..3a18b96f 100644 --- a/royalnet/backpack/tables/__init__.py +++ b/royalnet/backpack/tables/__init__.py @@ -4,6 +4,7 @@ from .telegram import Telegram from .discord import Discord from .matrix import Matrix from .aliases import Alias +from .tokens import Token # Enter the tables of your Pack here! available_tables = { @@ -11,7 +12,8 @@ available_tables = { Telegram, Discord, Matrix, - Alias + Alias, + Token, } # Don't change this, it should automatically generate __all__ diff --git a/royalnet/backpack/tables/aliases.py b/royalnet/backpack/tables/aliases.py index 0f4ec1f8..d153698a 100644 --- a/royalnet/backpack/tables/aliases.py +++ b/royalnet/backpack/tables/aliases.py @@ -4,32 +4,37 @@ from sqlalchemy import Column, \ ForeignKey from sqlalchemy.orm import relationship from sqlalchemy.ext.declarative import declared_attr +import royalnet.utils as ru class Alias: __tablename__ = "aliases" @declared_attr - def royal_id(self): - return Column(Integer, ForeignKey("users.uid")) + def user_id(self): + return Column(Integer, ForeignKey("users.uid"), primary_key=True) @declared_attr def alias(self): return Column(String, primary_key=True) @declared_attr - def royal(self): + def user(self): return relationship("User", backref="aliases") @classmethod - def find_by_alias(cls, alchemy, session, alias: str): - result = session.query(alchemy.get(cls)).filter_by(alias=alias.lower()).one_or_none() + async def find_user(cls, alchemy, session, alias: str): + result = await ru.asyncify(session.query(alchemy.get(cls)).filter_by(alias=alias.lower()).one_or_none) if result is not None: - result = result.royal + result = result.user return result + def __init__(self, user: str, alias: str): + self.user = user + self.alias = alias.lower() + def __repr__(self): return f"" def __str__(self): - return f"{self.alias}->{self.royal_id}" + return f"{self.alias}->{self.user_id}" diff --git a/royalnet/backpack/tables/discord.py b/royalnet/backpack/tables/discord.py index 2de86e7a..4f92dbdf 100644 --- a/royalnet/backpack/tables/discord.py +++ b/royalnet/backpack/tables/discord.py @@ -13,9 +13,13 @@ class Discord: __tablename__ = "discord" @declared_attr - def royal_id(self): + def user_id(self): return Column(Integer, ForeignKey("users.uid")) + @declared_attr + def user(self): + return relationship("User", backref="discord") + @declared_attr def discord_id(self): return Column(BigInteger, primary_key=True) @@ -29,13 +33,9 @@ class Discord: return Column(String) @declared_attr - def avatar_hash(self): + def avatar_url(self): return Column(String) - @declared_attr - def royal(self): - return relationship("User", backref="discord") - def __repr__(self): return f"" diff --git a/royalnet/backpack/tables/telegram.py b/royalnet/backpack/tables/telegram.py index d3798f6e..54de8090 100644 --- a/royalnet/backpack/tables/telegram.py +++ b/royalnet/backpack/tables/telegram.py @@ -13,9 +13,13 @@ class Telegram: __tablename__ = "telegram" @declared_attr - def royal_id(self): + def user_id(self): return Column(Integer, ForeignKey("users.uid")) + @declared_attr + def user(self): + return relationship("User", backref="telegram") + @declared_attr def tg_id(self): return Column(BigInteger, primary_key=True) @@ -32,10 +36,6 @@ class Telegram: def username(self): return Column(String) - @declared_attr - def royal(self): - return relationship("User", backref="telegram") - def __repr__(self): return f"" diff --git a/royalnet/backpack/tables/tokens.py b/royalnet/backpack/tables/tokens.py new file mode 100644 index 00000000..f9e80cd8 --- /dev/null +++ b/royalnet/backpack/tables/tokens.py @@ -0,0 +1,55 @@ +import datetime +import secrets +from sqlalchemy import * +from sqlalchemy.orm import * +from sqlalchemy.ext.declarative import declared_attr +import royalnet.utils as ru + + +class Token: + __tablename__ = "tokens" + + @declared_attr + def token(self): + return Column(String, primary_key=True) + + @declared_attr + def user_id(self): + return Column(Integer, ForeignKey("users.uid"), nullable=False) + + @declared_attr + def user(self): + return relationship("User", backref="tokens") + + @declared_attr + def expiration(self): + return Column(DateTime, nullable=False) + + @property + def expired(self): + return datetime.datetime.now() > self.expiration + + @expired.setter + def expired(self, value): + if value is True: + self.expiration = datetime.datetime.fromtimestamp(0) + else: + raise ValueError("'expired' can only be set to True.") + + @classmethod + def generate(cls, alchemy, user, expiration_delta: datetime.timedelta): + # noinspection PyArgumentList + TokenT = alchemy.get(cls) + token = TokenT(user=user, expiration=datetime.datetime.now() + expiration_delta, token=secrets.token_urlsafe()) + return token + + def json(self) -> dict: + return { + "user": self.user.json(), + "token": self.token, + "expiration": self.expiration.isoformat() + } + + @classmethod + async def authenticate(cls, alchemy, session, token: str) -> "Token": + return await ru.asyncify(session.query(alchemy.get(cls)).filter_by(token=token).one_or_none) diff --git a/royalnet/backpack/tables/users.py b/royalnet/backpack/tables/users.py index 88ca5f87..d63a33a5 100644 --- a/royalnet/backpack/tables/users.py +++ b/royalnet/backpack/tables/users.py @@ -1,3 +1,4 @@ +import bcrypt from sqlalchemy import Column, \ Integer, \ String, \ @@ -5,6 +6,7 @@ from sqlalchemy import Column, \ from sqlalchemy.ext.declarative import declared_attr +# noinspection PyAttributeOutsideInit class User: __tablename__ = "users" @@ -36,6 +38,16 @@ class User: "avatar": self.avatar } + def set_password(self, password: str): + byte_password: bytes = bytes(password, encoding="UTF8") + self.password = bcrypt.hashpw(byte_password, bcrypt.gensalt(14)) + + def test_password(self, password: str): + if self.password is None: + raise ValueError("No password is set") + byte_password: bytes = bytes(password, encoding="UTF8") + return bcrypt.checkpw(byte_password, self.password) + def __repr__(self): return f"<{self.__class__.__qualname__} {self.username}>" diff --git a/royalnet/commands/commanddata.py b/royalnet/commands/commanddata.py index 93ee1213..bee9d885 100644 --- a/royalnet/commands/commanddata.py +++ b/royalnet/commands/commanddata.py @@ -5,9 +5,11 @@ import asyncio as aio import royalnet.utils as ru from .errors import UnsupportedError from .commandinterface import CommandInterface +from royalnet.backpack.tables.aliases import Alias if TYPE_CHECKING: from .keyboardkey import KeyboardKey + from royalnet.backpack.tables.users import User log = logging.getLogger(__name__) @@ -24,6 +26,7 @@ class CommandData: if self._session is None: if self._interface.alchemy is None: raise UnsupportedError("'alchemy' is not enabled on this Royalnet instance") + log.debug("Creating Session...") self._session = self._interface.alchemy.Session() return self._session @@ -32,11 +35,13 @@ class CommandData: if self._session: log.warning("Session had to be created to be committed") # noinspection PyUnresolvedReferences + log.debug("Committing Session...") await ru.asyncify(self.session.commit) async def session_close(self): """Asyncronously close the :attr:`.session` of this object.""" if self._session is not None: + log.debug("Closing Session...") await ru.asyncify(self._session.close) async def reply(self, text: str) -> None: @@ -64,6 +69,13 @@ class CommandData: if error_if_unavailable: raise UnsupportedError(f"'{self.delete_invoking.__name__}' is not supported") + async def find_user(self, alias: str) -> Optional["User"]: + """Find the User having a specific Alias. + + Parameters: + alias: the Alias to search for.""" + return await Alias.find_user(self._interface.alchemy, self.session, alias) + @contextlib.asynccontextmanager async def keyboard(self, text, keys: List["KeyboardKey"]): yield diff --git a/royalnet/constellation/__init__.py b/royalnet/constellation/__init__.py index 69c62af0..c1e373a5 100644 --- a/royalnet/constellation/__init__.py +++ b/royalnet/constellation/__init__.py @@ -15,13 +15,11 @@ You can install them with: :: """ from .constellation import Constellation -from .star import Star, PageStar, ExceptionStar -from .shoot import shoot +from .star import Star +from .pagestar import PageStar __all__ = [ "Constellation", "Star", "PageStar", - "ExceptionStar", - "shoot", ] diff --git a/royalnet/constellation/api/__init__.py b/royalnet/constellation/api/__init__.py new file mode 100644 index 00000000..fd04cb9e --- /dev/null +++ b/royalnet/constellation/api/__init__.py @@ -0,0 +1,34 @@ +from .apistar import ApiStar +from .jsonapi import api_response, api_success, api_error +from .apidata import ApiData +from .apierrors import \ + ApiError, \ + NotFoundError, \ + ForbiddenError, \ + BadRequestError, \ + ParameterError, \ + MissingParameterError, \ + InvalidParameterError, \ + NotImplementedError, \ + UnsupportedError + + +__all__ = [ + "ApiStar", + "api_response", + "api_success", + "api_error", + "ApiData", + "ApiError", + "MissingParameterError", + "NotFoundError", + "ApiError", + "NotFoundError", + "ForbiddenError", + "BadRequestError", + "ParameterError", + "MissingParameterError", + "InvalidParameterError", + "NotImplementedError", + "UnsupportedError", +] diff --git a/royalnet/constellation/api/apidata.py b/royalnet/constellation/api/apidata.py new file mode 100644 index 00000000..a8915c50 --- /dev/null +++ b/royalnet/constellation/api/apidata.py @@ -0,0 +1,50 @@ +import logging +from .apierrors import MissingParameterError +from royalnet.backpack.tables.tokens import Token +from royalnet.backpack.tables.users import User +from .apierrors import * +import royalnet.utils as ru + +log = logging.getLogger(__name__) + + +class ApiData(dict): + def __init__(self, data, star): + super().__init__(data) + self.star = star + self._session = None + + def __missing__(self, key): + raise MissingParameterError(f"Missing '{key}'") + + async def token(self) -> Token: + token = await Token.authenticate(self.star.alchemy, self.session, self["token"]) + if token is None: + raise ForbiddenError("'token' is invalid") + return token + + async def user(self) -> User: + return (await self.token()).user + + @property + def session(self): + if self._session is None: + if self.star.alchemy is None: + raise UnsupportedError("'alchemy' is not enabled on this Royalnet instance") + log.debug("Creating Session...") + self._session = self.star.alchemy.Session() + return self._session + + async def session_commit(self): + """Asyncronously commit the :attr:`.session` of this object.""" + if self._session: + log.warning("Session had to be created to be committed") + # noinspection PyUnresolvedReferences + log.debug("Committing Session...") + await ru.asyncify(self.session.commit) + + async def session_close(self): + """Asyncronously close the :attr:`.session` of this object.""" + if self._session is not None: + log.debug("Closing Session...") + await ru.asyncify(self._session.close) diff --git a/royalnet/constellation/api/apierrors.py b/royalnet/constellation/api/apierrors.py new file mode 100644 index 00000000..b566a602 --- /dev/null +++ b/royalnet/constellation/api/apierrors.py @@ -0,0 +1,34 @@ +class ApiError(Exception): + pass + + +class NotFoundError(ApiError): + pass + + +class ForbiddenError(ApiError): + pass + + +class BadRequestError(ApiError): + pass + + +class ParameterError(BadRequestError): + pass + + +class MissingParameterError(ParameterError): + pass + + +class InvalidParameterError(ParameterError): + pass + + +class NotImplementedError(ApiError): + pass + + +class UnsupportedError(NotImplementedError): + pass diff --git a/royalnet/constellation/api/apistar.py b/royalnet/constellation/api/apistar.py new file mode 100644 index 00000000..afa58fc2 --- /dev/null +++ b/royalnet/constellation/api/apistar.py @@ -0,0 +1,42 @@ +from typing import * +from json import JSONDecodeError +from abc import * +from starlette.requests import Request +from starlette.responses import JSONResponse +from ..pagestar import PageStar +from .jsonapi import api_error, api_success +from .apidata import ApiData +from .apierrors import * +import royalnet.utils as ru + + +class ApiStar(PageStar, ABC): + async def page(self, request: Request) -> JSONResponse: + if request.query_params: + data = request.query_params + else: + try: + data = await request.json() + except JSONDecodeError: + data = {} + apidata = ApiData(data, self) + try: + response = await self.api(apidata) + except NotFoundError as e: + return api_error(e, code=404) + except ForbiddenError as e: + return api_error(e, code=403) + except NotImplementedError as e: + return api_error(e, code=501) + except BadRequestError as e: + return api_error(e, code=400) + except Exception as e: + ru.sentry_exc(e) + return api_error(e, code=500) + else: + return api_success(response) + finally: + await apidata.session_close() + + async def api(self, data: ApiData) -> ru.JSON: + raise NotImplementedError() diff --git a/royalnet/constellation/api/jsonapi.py b/royalnet/constellation/api/jsonapi.py new file mode 100644 index 00000000..da540729 --- /dev/null +++ b/royalnet/constellation/api/jsonapi.py @@ -0,0 +1,33 @@ +from typing import * +try: + from starlette.responses import JSONResponse +except ImportError: + JSONResponse = None + + +def api_response(data: dict, code: int, headers: dict = None) -> JSONResponse: + if headers is None: + headers = {} + full_headers = { + **headers, + "Access-Control-Allow-Origin": "*", + } + return JSONResponse(data, status_code=code, headers=full_headers) + + +def api_success(data: dict) -> JSONResponse: + result = { + "success": True, + "data": data + } + return api_response(result, code=200) + + +def api_error(error: Exception, code: int = 500) -> JSONResponse: + result = { + "success": False, + "error_type": error.__class__.__qualname__, + "error_args": list(error.args), + "error_code": code, + } + return api_response(result, code=code) diff --git a/royalnet/constellation/constellation.py b/royalnet/constellation/constellation.py index 6fad39b9..2cdc94e9 100644 --- a/royalnet/constellation/constellation.py +++ b/royalnet/constellation/constellation.py @@ -8,7 +8,7 @@ import royalnet.alchemy as ra import royalnet.herald as rh import royalnet.utils as ru import royalnet.commands as rc -from .star import PageStar, ExceptionStar +from .pagestar import PageStar from ..utils import init_logging @@ -42,7 +42,12 @@ class Constellation: for pack_name in pack_names: log.debug(f"Importing pack: {pack_name}") try: - packs[pack_name] = importlib.import_module(pack_name) + packs[pack_name] = { + "commands": importlib.import_module(f"{pack_name}.commands"), + "events": importlib.import_module(f"{pack_name}.events"), + "stars": importlib.import_module(f"{pack_name}.stars"), + "tables": importlib.import_module(f"{pack_name}.tables"), + } except ImportError as e: log.error(f"Error during the import of {pack_name}: {e}") log.info(f"Packs: {len(packs)} imported") @@ -60,7 +65,7 @@ class Constellation: tables = set() for pack in packs.values(): try: - tables = tables.union(pack.available_tables) + tables = tables.union(pack["tables"].available_tables) except AttributeError: log.warning(f"Pack `{pack}` does not have the `available_tables` attribute.") continue @@ -99,7 +104,7 @@ class Constellation: pack = packs[pack_name] pack_cfg = packs_cfg.get(pack_name, {}) try: - events = pack.available_events + events = pack["events"].available_events except AttributeError: log.warning(f"Pack `{pack}` does not have the `available_events` attribute.") else: @@ -118,17 +123,11 @@ class Constellation: pack = packs[pack_name] pack_cfg = packs_cfg.get(pack_name, {}) try: - page_stars = pack.available_page_stars + page_stars = pack["stars"].available_page_stars except AttributeError: log.warning(f"Pack `{pack}` does not have the `available_page_stars` attribute.") else: self.register_page_stars(page_stars, pack_cfg) - try: - exc_stars = pack.available_exception_stars - except AttributeError: - log.warning(f"Pack `{pack}` does not have the `available_exception_stars` attribute.") - else: - self.register_exc_stars(exc_stars, pack_cfg) log.info(f"PageStars: {len(self.starlette.routes)} stars") log.info(f"ExceptionStars: {len(self.starlette.exception_handlers)} stars") @@ -259,14 +258,6 @@ class Constellation: return page_star.path, f, page_star.methods - def _exc_star_wrapper(self, exc_star: ExceptionStar): - async def f(request): - self._first_page_check() - log.info(f"Running {exc_star}") - return await exc_star.page(request) - - return exc_star.error, f - def register_page_stars(self, page_stars: List[Type[PageStar]], pack_cfg: Dict[str, Any]): for SelectedPageStar in page_stars: log.debug(f"Registering: {SelectedPageStar.path} -> {SelectedPageStar.__qualname__}") @@ -279,18 +270,6 @@ class Constellation: continue self.starlette.add_route(*self._page_star_wrapper(page_star_instance)) - def register_exc_stars(self, exc_stars: List[Type[ExceptionStar]], pack_cfg: Dict[str, Any]): - for SelectedExcStar in exc_stars: - log.debug(f"Registering: {SelectedExcStar.error} -> {SelectedExcStar.__qualname__}") - try: - exc_star_instance = SelectedExcStar(interface=self.Interface(pack_cfg)) - except Exception as e: - log.error(f"Skipping: " - f"{SelectedExcStar.__qualname__} - {e.__class__.__qualname__} in the initialization.") - ru.sentry_exc(e) - continue - self.starlette.add_exception_handler(*self._exc_star_wrapper(exc_star_instance)) - def run_blocking(self): log.info(f"Running Constellation on https://{self.address}:{self.port}/...") loop: aio.AbstractEventLoop = aio.get_event_loop() diff --git a/royalnet/constellation/pagestar.py b/royalnet/constellation/pagestar.py new file mode 100644 index 00000000..79da65ab --- /dev/null +++ b/royalnet/constellation/pagestar.py @@ -0,0 +1,34 @@ +from typing import * +from .star import Star + + +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`, :attr:`.path` and optionally :attr:`.methods`.""" + + 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"] + + """ + + def __repr__(self): + return f"<{self.__class__.__qualname__}: {self.path}>" diff --git a/royalnet/constellation/shoot.py b/royalnet/constellation/shoot.py deleted file mode 100644 index f5c8471e..00000000 --- a/royalnet/constellation/shoot.py +++ /dev/null @@ -1,13 +0,0 @@ -try: - from starlette.responses import JSONResponse -except ImportError: - JSONResponse = None - - -def shoot(code: int, description: str) -> JSONResponse: - """Create a error :class:`~starlette.response.JSONResponse` with the passed error code and description.""" - if JSONResponse is None: - raise ImportError("'constellation' extra is not installed") - return JSONResponse({ - "error": description - }, status_code=code) diff --git a/royalnet/constellation/star.py b/royalnet/constellation/star.py index 657968dc..c56fa27e 100644 --- a/royalnet/constellation/star.py +++ b/royalnet/constellation/star.py @@ -48,63 +48,3 @@ class Star: def __repr__(self): return f"<{self.__class__.__qualname__}>" - - -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`.""" - 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"] - - """ - - def __repr__(self): - return f"<{self.__class__.__qualname__}: {self.path}>" - - -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 value of :attr:`.error`.""" - 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. - - Examples: - :: - - error: int = 404 - - :: - - error: Type[Exception] = ValueError - """ - - def __repr__(self): - return f"<{self.__class__.__qualname__}: handles {self.error}>" diff --git a/royalnet/generate.py b/royalnet/generate.py index 5462d22d..80e18944 100644 --- a/royalnet/generate.py +++ b/royalnet/generate.py @@ -19,7 +19,12 @@ def run(config_filename, file_format): packs = {} for pack_name in pack_names: try: - packs[pack_name] = importlib.import_module(pack_name) + packs[pack_name] = { + "commands": importlib.import_module(f"{pack_name}.commands"), + "events": importlib.import_module(f"{pack_name}.events"), + "stars": importlib.import_module(f"{pack_name}.stars"), + "tables": importlib.import_module(f"{pack_name}.tables"), + } except ImportError as e: p(f"Skipping `{pack_name}`: {e}", err=True) continue @@ -30,7 +35,7 @@ def run(config_filename, file_format): lines = [] try: - commands = pack.available_commands + commands = pack["commands"].available_commands except AttributeError: p(f"Pack `{pack}` does not have the `available_commands` attribute.", err=True) continue @@ -41,93 +46,6 @@ def run(config_filename, file_format): for line in lines: p(line) - elif file_format == "markdown": - p("") - p("") - for pack_name in packs: - pack = packs[pack_name] - p(f"# `{pack_name}`") - p("") - if pack.__doc__: - p(f"{pack.__doc__}") - p("") - - try: - commands = pack.available_commands - except AttributeError: - p(f"Pack `{pack}` does not have the `available_commands` attribute.", err=True) - else: - p(f"## Commands") - p("") - for command in commands: - p(f"### `{command.name}`") - p("") - p(f"{command.description}") - p("") - if command.__doc__: - p(f"{command.__doc__}") - p("") - if len(command.aliases) > 0: - p(f"> Aliases: {''.join(['`' + alias + '` ' for alias in command.aliases])}") - p("") - - try: - events = pack.available_events - except AttributeError: - p(f"Pack `{pack}` does not have the `available_events` attribute.", err=True) - else: - p(f"## Events") - p("") - for event in events: - p(f"### `{event.name}`") - p("") - if event.__doc__: - p(f"{event.__doc__}") - p("") - - try: - page_stars = pack.available_page_stars - except AttributeError: - p(f"Pack `{pack}` does not have the `available_page_stars` attribute.", err=True) - else: - p(f"## Page Stars") - p("") - for page_star in page_stars: - p(f"### `{page_star.path}`") - p("") - if page_star.__doc__: - p(f"{page_star.__doc__}") - p("") - - try: - exc_stars = pack.available_exception_stars - except AttributeError: - p(f"Pack `{pack}` does not have the `available_exception_stars` attribute.", err=True) - else: - p(f"## Exception Stars") - p("") - for exc_star in exc_stars: - p(f"### `{exc_star.error}`") - p("") - if exc_star.__doc__: - p(f"{exc_star.__doc__}") - p("") - - try: - tables = pack.available_tables - except AttributeError: - p(f"Pack `{pack}` does not have the `available_tables` attribute.", err=True) - else: - p(f"## Tables") - p("") - for table in tables: - p(f"### `{table.__tablename__}`") - p("") - # TODO: list columns - if table.__doc__: - p(f"{table.__doc__}") - p("") - else: raise click.ClickException("Unknown format") diff --git a/royalnet/serf/serf.py b/royalnet/serf/serf.py index 39c5c3d6..db22afca 100644 --- a/royalnet/serf/serf.py +++ b/royalnet/serf/serf.py @@ -7,7 +7,7 @@ from sqlalchemy.schema import Table from royalnet.commands import * import royalnet.utils as ru import royalnet.alchemy as ra -import royalnet.backpack as rb +import royalnet.backpack.tables as rbt import royalnet.herald as rh import traceback @@ -20,7 +20,7 @@ class Serf: Discord).""" interface_name = NotImplemented - _master_table: type = rb.tables.User + _master_table: type = rbt.User _identity_table: type = NotImplemented _identity_column: str = NotImplemented @@ -39,10 +39,15 @@ class Serf: for pack_name in pack_names: log.debug(f"Importing pack: {pack_name}") try: - packs[pack_name] = importlib.import_module(pack_name) + packs[pack_name] = { + "commands": importlib.import_module(f"{pack_name}.commands"), + "events": importlib.import_module(f"{pack_name}.events"), + "stars": importlib.import_module(f"{pack_name}.stars"), + "tables": importlib.import_module(f"{pack_name}.tables"), + } except ImportError as e: log.error(f"{e.__class__.__name__} during the import of {pack_name}:\n" - f"{traceback.format_exception(*sys.exc_info())}") + f"{''.join(traceback.format_exception(*sys.exc_info()))}") log.info(f"Packs: {len(packs)} imported") self.alchemy: Optional[ra.Alchemy] = None @@ -68,7 +73,7 @@ class Serf: tables = set() for pack in packs.values(): try: - tables = tables.union(pack.available_tables) + tables = tables.union(pack["tables"].available_tables) except AttributeError: log.warning(f"Pack `{pack}` does not have the `available_tables` attribute.") continue @@ -95,13 +100,13 @@ class Serf: pack = packs[pack_name] pack_cfg = packs_cfg.get(pack_name, {}) try: - events = pack.available_events + events = pack["events"].available_events except AttributeError: log.warning(f"Pack `{pack}` does not have the `available_events` attribute.") else: self.register_events(events, pack_cfg) try: - commands = pack.available_commands + commands = pack["commands"].available_commands except AttributeError: log.warning(f"Pack `{pack}` does not have the `available_commands` attribute.") else: diff --git a/royalnet/utils/__init__.py b/royalnet/utils/__init__.py index deb9539b..88893c75 100644 --- a/royalnet/utils/__init__.py +++ b/royalnet/utils/__init__.py @@ -5,6 +5,7 @@ from .urluuid import to_urluuid, from_urluuid from .multilock import MultiLock from .sentry import init_sentry, sentry_exc from .log import init_logging +from .royaltyping import JSON __all__ = [ "asyncify", @@ -20,4 +21,5 @@ __all__ = [ "init_sentry", "sentry_exc", "init_logging", + "JSON", ] diff --git a/royalnet/utils/royaltyping.py b/royalnet/utils/royaltyping.py new file mode 100644 index 00000000..93eca407 --- /dev/null +++ b/royalnet/utils/royaltyping.py @@ -0,0 +1,3 @@ +from typing import * + +JSON = Union[None, int, str, List["JSON"], Dict[str, "JSON"]] diff --git a/royalnet/version.py b/royalnet/version.py index 1edc9d1e..63bf6999 100644 --- a/royalnet/version.py +++ b/royalnet/version.py @@ -1 +1 @@ -semantic = "5.4.1" +semantic = "5.5"