1
Fork 0
mirror of https://github.com/RYGhub/royalnet.git synced 2024-11-23 19:44:20 +00:00

Merge branch '5.5'

# Conflicts:
#	pyproject.toml
#	royalnet/version.py
This commit is contained in:
Steffo 2020-02-11 23:05:19 +01:00
commit 6db81c689c
41 changed files with 622 additions and 336 deletions

41
poetry.lock generated
View file

@ -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"},

1
publish.bat Normal file
View file

@ -0,0 +1 @@
git commit -am "publish: %1" && git push && poetry build && poetry publish && hub release create "%1" -m "Royalnet %1"

View file

@ -1,5 +1,4 @@
from typing import *
import royalnet
import royalnet.commands as rc

View file

@ -1,5 +1,4 @@
from typing import *
import royalnet
import royalnet.commands as rc

View file

@ -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}"

View file

@ -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 <ste.pigozzi@gmail.com>"]
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"]

View file

@ -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:

View file

@ -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",
]

View file

@ -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]

View file

@ -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")

View file

@ -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!")

View file

@ -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.")

View file

@ -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}")

View file

@ -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]

View file

@ -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

View file

@ -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
}
})

View file

@ -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()

View file

@ -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()

View file

@ -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
}

View file

@ -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__

View file

@ -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"<Alias {str(self)}>"
def __str__(self):
return f"{self.alias}->{self.royal_id}"
return f"{self.alias}->{self.user_id}"

View file

@ -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"<Discord {str(self)}>"

View file

@ -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"<Telegram {str(self)}>"

View file

@ -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)

View file

@ -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}>"

View file

@ -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

View file

@ -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",
]

View file

@ -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",
]

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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()

View file

@ -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}>"

View file

@ -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)

View file

@ -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}>"

View file

@ -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("<!--This documentation was autogenerated with `python -m royalnet.generate -f markdown`.-->")
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")

View file

@ -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:

View file

@ -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",
]

View file

@ -0,0 +1,3 @@
from typing import *
JSON = Union[None, int, str, List["JSON"], Dict[str, "JSON"]]

View file

@ -1 +1 @@
semantic = "5.4.1"
semantic = "5.5"