[doc] Add docstrings for core

This commit is contained in:
Cathy Hu 2020-07-11 12:54:07 +02:00 committed by acipm
parent a00a6b8497
commit 051dd062ac
10 changed files with 263 additions and 27 deletions

View file

@ -4,6 +4,19 @@
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
""" Configuration file and command line argument parser.
Gives a dictionary named `config` with configuration parsed either from
`/etc/kibicara.conf` or from a file given by command line argument `-f`.
If no configuration was found at all, the defaults are used.
Example:
```
from kibicara.config import config
print(config)
```
"""
from argparse import ArgumentParser from argparse import ArgumentParser
from pytoml import load from pytoml import load
from sys import argv from sys import argv
@ -14,6 +27,10 @@ config = {
'frontend_path': None, 'frontend_path': None,
'root_url': 'http://localhost:8000/', 'root_url': 'http://localhost:8000/',
} }
""" Default configuration.
The default configuration gets overwritten by a configuration file if one exists.
"""
if argv[0].endswith('kibicara'): if argv[0].endswith('kibicara'):
parser = ArgumentParser() parser = ArgumentParser()

View file

@ -3,6 +3,8 @@
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
""" E-Mail handling. """
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from logging import getLogger from logging import getLogger
@ -14,6 +16,22 @@ logger = getLogger(__name__)
def send_email(to, subject, sender='kibicara', body=''): def send_email(to, subject, sender='kibicara', body=''):
""" E-Mail sender.
Sends an E-Mail to a specified recipient with a body
Example:
```
from kibicara import email
email.send_email('abc@de.fg', 'My Email subject', body='Hi this is a mail body.')
```
Args:
to (str): Recipients' e-mail address
subject (str): The subject of the e-mail
sender (str): optional, Sender of the e-mail
body (str): The body of the e-mail
"""
msg = MIMEMultipart() msg = MIMEMultipart()
msg['From'] = 'Kibicara <%s@%s>' % (sender, getfqdn()) msg['From'] = 'Kibicara <%s@%s>' % (sender, getfqdn())
msg['To'] = to msg['To'] = to

View file

@ -3,6 +3,8 @@
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
""" Entrypoint of Kibicara. """
from asyncio import run as asyncio_run from asyncio import run as asyncio_run
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@ -19,17 +21,23 @@ logger = getLogger(__name__)
class Main: class Main:
def __init__(self): """ Entrypoint for Kibicara.
asyncio_run(self.run())
async def run(self): Initializes the platform bots and starts the hypercorn webserver serving the
Kibicara application and the specified frontend on port 8000.
"""
def __init__(self):
asyncio_run(self.__run())
async def __run(self):
basicConfig(level=DEBUG, format="%(asctime)s %(name)s %(message)s") basicConfig(level=DEBUG, format="%(asctime)s %(name)s %(message)s")
getLogger('aiosqlite').setLevel(WARNING) getLogger('aiosqlite').setLevel(WARNING)
Mapping.create_all() Mapping.create_all()
await Spawner.init_all() await Spawner.init_all()
await self._start_webserver() await self.__start_webserver()
async def _start_webserver(self): async def __start_webserver(self):
class SinglePageApplication(StaticFiles): class SinglePageApplication(StaticFiles):
async def get_response(self, path, scope): async def get_response(self, path, scope):
response = await super().get_response(path, scope) response = await super().get_response(path, scope)

View file

@ -3,6 +3,8 @@
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
""" ORM Models for core. """
from databases import Database from databases import Database
from kibicara.config import config from kibicara.config import config
from ormantic import Integer, ForeignKey, Model, Text from ormantic import Integer, ForeignKey, Model, Text

View file

@ -3,6 +3,8 @@
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
""" API classes for implementing bots for platforms. """
from asyncio import create_task, Queue from asyncio import create_task, Queue
from kibicara.model import BadWord, Trigger from kibicara.model import BadWord, Trigger
from logging import getLogger from logging import getLogger
@ -13,47 +15,116 @@ logger = getLogger(__name__)
class Message: class Message:
"""The Message object that is send through the censor.
Examples:
```
message = Message('Message sent by a user from platform xyz', xyz_message_id=123)
```
Args:
text (str): The message text
**kwargs (object, optional): Other platform-specific data.
Attributes:
text (str): The message text
**kwargs (object, optional): Other platform-specific data.
"""
def __init__(self, text, **kwargs): def __init__(self, text, **kwargs):
self.text = text self.text = text
self.__dict__.update(kwargs) self.__dict__.update(kwargs)
class Censor: class Censor:
instances = {} """ The superclass for a platform bot.
The censor is the superclass for every platform bot. It distributes a message to all
other bots from the same hood if it passes the message filter. It provides methods
to start and stop the bot and an overwritable stub for a starting routine.
Examples:
```
class XYZPlatform(Censor):
def __init__(self, xyz_model):
super().__init__(xyz_model.hood)
...
async def run(self):
await gather(self.poll(), self.push())
...
async def poll(self):
while True:
# XXX get text message from platform xyz
await self.publish(Message(text))
...
async def push(self):
while True:
message = await self.receive()
# XXX send message.text to platform xyz
```
Args:
hood (Hood): A Hood Model object
Attributes:
hood (Hood): A Hood Model object
"""
__instances = {}
def __init__(self, hood): def __init__(self, hood):
self.hood = hood self.hood = hood
self.inbox = Queue() self._inbox = Queue()
self.task = None self.__task = None
self.hood_censors = self.instances.setdefault(hood.id, []) self.__hood_censors = self.__instances.setdefault(hood.id, [])
self.hood_censors.append(self) self.__hood_censors.append(self)
def start(self): def start(self):
if self.task is None: """ Start the bot.
self.task = create_task(self.__run())
Note: This will be called by a spawner, a platform bot should not call this.
"""
if self.__task is None:
self.__task = create_task(self.__run())
def stop(self): def stop(self):
if self.task is not None: """ Stop the bot.
self.task.cancel()
self.task = None Note: This will be called by a spawner, a platform bot should not call this.
"""
if self.__task is not None:
self.__task.cancel()
self.__task = None
async def __run(self): async def __run(self):
await self.hood.load() await self.hood.load()
self.task.set_name('%s %s' % (self.__class__.__name__, self.hood.name)) self.__task.set_name('%s %s' % (self.__class__.__name__, self.hood.name))
await self.run() await self.run()
# override this in derived class
async def run(self): async def run(self):
""" Entry point for a bot.
Note: Override this in the derived bot class.
"""
pass pass
async def publish(self, message): async def publish(self, message):
""" Distribute a message to the bots in a hood.
Args:
message (Message): Message to distribute
"""
if not await self.__is_appropriate(message): if not await self.__is_appropriate(message):
return return
for censor in self.hood_censors: for censor in self.__hood_censors:
await censor.inbox.put(message) await censor._inbox.put(message)
async def receive(self): async def receive(self):
return await self.inbox.get() """ Receive a message.
Returns (Message): Received message
"""
return await self._inbox.get()
async def __is_appropriate(self, message): async def __is_appropriate(self, message):
for badword in await BadWord.objects.filter(hood=self.hood).all(): for badword in await BadWord.objects.filter(hood=self.hood).all():
@ -69,17 +140,40 @@ class Censor:
class Spawner: class Spawner:
instances = [] """ Spawns a bot with a specific bot model.
Examples:
```
class XYZPlatform(Censor):
# bot class
class XYZ(Model):
# bot model
spawner = Spawner(XYZ, XYZPlatform)
```
Args:
ORMClass (ORM Model subclass): A Bot Model object
BotClass (Censor subclass): A Bot Class object
Attributes:
ORMClass (ORM Model subclass): A Hood Model object
BotClass (Censor subclass): A Bot Class object
"""
__instances = []
def __init__(self, ORMClass, BotClass): def __init__(self, ORMClass, BotClass):
self.ORMClass = ORMClass self.ORMClass = ORMClass
self.BotClass = BotClass self.BotClass = BotClass
self.bots = {} self.__bots = {}
self.instances.append(self) self.__instances.append(self)
@classmethod @classmethod
async def init_all(cls): async def init_all(cls):
for spawner in cls.instances: """ Instantiate and start a bot for every row in the corresponding ORM model. """
for spawner in cls.__instances:
await spawner._init() await spawner._init()
async def _init(self): async def _init(self):
@ -87,13 +181,34 @@ class Spawner:
self.start(item) self.start(item)
def start(self, item): def start(self, item):
bot = self.bots.setdefault(item.pk, self.BotClass(item)) """ Instantiate and start a bot with the provided ORM object.
Example:
```
xyz = await XYZ.objects.create(hood=hood, **values.__dict__)
spawner.start(xyz)
```
Args:
item (ORM Model object): Argument to the bot constructor
"""
bot = self.__bots.setdefault(item.pk, self.BotClass(item))
bot.start() bot.start()
def stop(self, item): def stop(self, item):
bot = self.bots.pop(item.pk, None) """ Stop and delete a bot.
Args:
item (ORM Model object): ORM object corresponding to bot.
"""
bot = self.__bots.pop(item.pk, None)
if bot is not None: if bot is not None:
bot.stop() bot.stop()
def get(self, item): def get(self, item):
return self.bots.get(item.pk) """ Get a running bot.
Args:
item (ORM Model object): ORM object corresponding to bot.
"""
return self.__bots.get(item.pk)

View file

@ -3,6 +3,12 @@
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
""" Routing definitions for the REST API.
A platform bot shall add its API router in this `__init__.py`
file to get included into the main application.
"""
from fastapi import APIRouter from fastapi import APIRouter
from kibicara.platforms.test.webapi import router as test_router from kibicara.platforms.test.webapi import router as test_router
from kibicara.platforms.telegram.webapi import router as telegram_router from kibicara.platforms.telegram.webapi import router as telegram_router

View file

@ -3,6 +3,8 @@
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
""" REST API endpoints for hood admins. """
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from kibicara import email from kibicara import email
@ -71,8 +73,14 @@ router = APIRouter()
@router.post('/register/', status_code=status.HTTP_202_ACCEPTED) @router.post('/register/', status_code=status.HTTP_202_ACCEPTED)
async def admin_register(values: BodyAdmin): async def admin_register(values: BodyAdmin):
""" Sends an email with a confirmation link.
- **email**: E-Mail Address of new hood admin
- **password**: Password of new hood admin
"""
register_token = to_token(**values.__dict__) register_token = to_token(**values.__dict__)
logger.debug(f'register_token={register_token}') logger.debug(f'register_token={register_token}')
# TODO implement check to see if email already is in database
try: try:
email.send_email( email.send_email(
to=values.email, to=values.email,
@ -88,6 +96,10 @@ async def admin_register(values: BodyAdmin):
@router.post('/confirm/{register_token}') @router.post('/confirm/{register_token}')
async def admin_confirm(register_token: str): async def admin_confirm(register_token: str):
""" Registration confirmation and account creation.
- **register_token**: Registration token received in email from /register
"""
try: try:
values = from_token(register_token) values = from_token(register_token)
passhash = argon2.hash(values['password']) passhash = argon2.hash(values['password'])
@ -99,6 +111,11 @@ async def admin_confirm(register_token: str):
@router.post('/login/') @router.post('/login/')
async def admin_login(form_data: OAuth2PasswordRequestForm = Depends()): async def admin_login(form_data: OAuth2PasswordRequestForm = Depends()):
""" Get an access token.
- **username**: Email of a registered hood admin
- **password**: Password of a registered hood admin
"""
try: try:
await get_auth(form_data.username, form_data.password) await get_auth(form_data.username, form_data.password)
except ValueError: except ValueError:
@ -112,6 +129,7 @@ async def admin_login(form_data: OAuth2PasswordRequestForm = Depends()):
@router.get('/hoods/') @router.get('/hoods/')
async def admin_hood_read_all(admin=Depends(get_admin)): async def admin_hood_read_all(admin=Depends(get_admin)):
""" Get a list of all hoods of a given admin. """
return ( return (
await AdminHoodRelation.objects.select_related('hood').filter(admin=admin).all() await AdminHoodRelation.objects.select_related('hood').filter(admin=admin).all()
) )

View file

@ -3,6 +3,8 @@
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
""" REST API Endpoints for managing hoods. """
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from kibicara.model import AdminHoodRelation, Hood from kibicara.model import AdminHoodRelation, Hood
from kibicara.webapi.admin import get_admin from kibicara.webapi.admin import get_admin
@ -38,11 +40,17 @@ router = APIRouter()
@router.get('/') @router.get('/')
async def hood_read_all(): async def hood_read_all():
""" Get all existing hoods. """
return await Hood.objects.all() return await Hood.objects.all()
@router.post('/', status_code=status.HTTP_201_CREATED) @router.post('/', status_code=status.HTTP_201_CREATED)
async def hood_create(values: BodyHood, response: Response, admin=Depends(get_admin)): async def hood_create(values: BodyHood, response: Response, admin=Depends(get_admin)):
""" Creates a hood.
- **name**: Name of the hood
- **landingpage**: Markdown formatted description of the hood
"""
try: try:
hood = await Hood.objects.create(**values.__dict__) hood = await Hood.objects.create(**values.__dict__)
await AdminHoodRelation.objects.create(admin=admin.id, hood=hood.id) await AdminHoodRelation.objects.create(admin=admin.id, hood=hood.id)
@ -54,16 +62,23 @@ async def hood_create(values: BodyHood, response: Response, admin=Depends(get_ad
@router.get('/{hood_id}') @router.get('/{hood_id}')
async def hood_read(hood=Depends(get_hood)): async def hood_read(hood=Depends(get_hood)):
""" Get hood with id **hood_id**. """
return hood return hood
@router.put('/{hood_id}', status_code=status.HTTP_204_NO_CONTENT) @router.put('/{hood_id}', status_code=status.HTTP_204_NO_CONTENT)
async def hood_update(values: BodyHood, hood=Depends(get_hood)): async def hood_update(values: BodyHood, hood=Depends(get_hood)):
""" Updates hood with id **hood_id**.
- **name**: New name of the hood
- **landingpage**: New Markdown formatted description of the hood
"""
await hood.update(**values.__dict__) await hood.update(**values.__dict__)
@router.delete('/{hood_id}', status_code=status.HTTP_204_NO_CONTENT) @router.delete('/{hood_id}', status_code=status.HTTP_204_NO_CONTENT)
async def hood_delete(hood=Depends(get_hood)): async def hood_delete(hood=Depends(get_hood)):
""" Deletes hood with id **hood_id**. """
for relation in await AdminHoodRelation.objects.filter(hood=hood).all(): for relation in await AdminHoodRelation.objects.filter(hood=hood).all():
await relation.delete() await relation.delete()
await hood.delete() await hood.delete()

View file

@ -3,6 +3,13 @@
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
""" REST API endpoints for managing badwords.
Provides API endpoints for adding, removing and reading regular expressions that block a
received message to be dropped by a censor. This provides a message filter customizable
by the hood admins.
"""
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from kibicara.model import BadWord from kibicara.model import BadWord
from kibicara.webapi.hoods import get_hood from kibicara.webapi.hoods import get_hood
@ -28,6 +35,7 @@ router = APIRouter()
@router.get('/') @router.get('/')
async def badword_read_all(hood=Depends(get_hood)): async def badword_read_all(hood=Depends(get_hood)):
""" Get all badwords of hood with id **hood_id**. """
return await BadWord.objects.filter(hood=hood).all() return await BadWord.objects.filter(hood=hood).all()
@ -35,6 +43,10 @@ async def badword_read_all(hood=Depends(get_hood)):
async def badword_create( async def badword_create(
values: BodyBadWord, response: Response, hood=Depends(get_hood) values: BodyBadWord, response: Response, hood=Depends(get_hood)
): ):
""" Creates a new badword for hood with id **hood_id**.
- **pattern**: Regular expression which is used to match a badword.
"""
try: try:
regex_compile(values.pattern) regex_compile(values.pattern)
badword = await BadWord.objects.create(hood=hood, **values.__dict__) badword = await BadWord.objects.create(hood=hood, **values.__dict__)
@ -48,14 +60,20 @@ async def badword_create(
@router.get('/{badword_id}') @router.get('/{badword_id}')
async def badword_read(badword=Depends(get_badword)): async def badword_read(badword=Depends(get_badword)):
""" Reads badword with id **badword_id** for hood with id **hood_id**. """
return badword return badword
@router.put('/{badword_id}', status_code=status.HTTP_204_NO_CONTENT) @router.put('/{badword_id}', status_code=status.HTTP_204_NO_CONTENT)
async def badword_update(values: BodyBadWord, badword=Depends(get_badword)): async def badword_update(values: BodyBadWord, badword=Depends(get_badword)):
""" Updates badword with id **badword_id** for hood with id **hood_id**.
- **pattern**: Regular expression which is used to match a badword
"""
await badword.update(**values.__dict__) await badword.update(**values.__dict__)
@router.delete('/{badword_id}', status_code=status.HTTP_204_NO_CONTENT) @router.delete('/{badword_id}', status_code=status.HTTP_204_NO_CONTENT)
async def badword_delete(badword=Depends(get_badword)): async def badword_delete(badword=Depends(get_badword)):
""" Deletes badword with id **badword_id** for hood with id **hood_id**. """
await badword.delete() await badword.delete()

View file

@ -3,6 +3,14 @@
# #
# SPDX-License-Identifier: 0BSD # SPDX-License-Identifier: 0BSD
""" REST API endpoints for managing triggers.
Provides API endpoints for adding, removing and reading regular expressions that allow a
message to be passed through by a censor. A published message must match one of these
regular expressions otherwise it gets dropped by the censor. This provides a message
filter customizable by the hood admins.
"""
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from kibicara.model import Trigger from kibicara.model import Trigger
from kibicara.webapi.hoods import get_hood from kibicara.webapi.hoods import get_hood
@ -28,6 +36,7 @@ router = APIRouter()
@router.get('/') @router.get('/')
async def trigger_read_all(hood=Depends(get_hood)): async def trigger_read_all(hood=Depends(get_hood)):
""" Get all triggers of hood with id **hood_id**. """
return await Trigger.objects.filter(hood=hood).all() return await Trigger.objects.filter(hood=hood).all()
@ -35,6 +44,10 @@ async def trigger_read_all(hood=Depends(get_hood)):
async def trigger_create( async def trigger_create(
values: BodyTrigger, response: Response, hood=Depends(get_hood) values: BodyTrigger, response: Response, hood=Depends(get_hood)
): ):
""" Creates a new trigger for hood with id **hood_id**.
- **pattern**: Regular expression which is used to match a trigger.
"""
try: try:
regex_compile(values.pattern) regex_compile(values.pattern)
trigger = await Trigger.objects.create(hood=hood, **values.__dict__) trigger = await Trigger.objects.create(hood=hood, **values.__dict__)
@ -48,14 +61,20 @@ async def trigger_create(
@router.get('/{trigger_id}') @router.get('/{trigger_id}')
async def trigger_read(trigger=Depends(get_trigger)): async def trigger_read(trigger=Depends(get_trigger)):
""" Reads trigger with id **trigger_id** for hood with id **hood_id**. """
return trigger return trigger
@router.put('/{trigger_id}', status_code=status.HTTP_204_NO_CONTENT) @router.put('/{trigger_id}', status_code=status.HTTP_204_NO_CONTENT)
async def trigger_update(values: BodyTrigger, trigger=Depends(get_trigger)): async def trigger_update(values: BodyTrigger, trigger=Depends(get_trigger)):
""" Updates trigger with id **trigger_id** for hood with id **hood_id**.
- **pattern**: Regular expression which is used to match a trigger
"""
await trigger.update(**values.__dict__) await trigger.update(**values.__dict__)
@router.delete('/{trigger_id}', status_code=status.HTTP_204_NO_CONTENT) @router.delete('/{trigger_id}', status_code=status.HTTP_204_NO_CONTENT)
async def trigger_delete(trigger=Depends(get_trigger)): async def trigger_delete(trigger=Depends(get_trigger)):
""" Deletes trigger with id **trigger_id** for hood with id **hood_id**. """
await trigger.delete() await trigger.delete()