diff --git a/sendria/cli.py b/sendria/cli.py index d7288ad..bfbcb19 100644 --- a/sendria/cli.py +++ b/sendria/cli.py @@ -7,7 +7,7 @@ import pathlib import signal import sys -from typing import NoReturn +from typing import NoReturn, List, IO import aiohttp.web import daemon @@ -27,12 +27,12 @@ SHUTDOWN = [] -def exit_err(msg, exit_code=1, **kwargs): +def exit_err(msg: str, exit_code: int = 1, **kwargs) -> NoReturn: logger.error(msg, **kwargs) sys.exit(exit_code) -def parse_argv(argv): +def parse_argv(argv: List) -> argparse.Namespace: parser = argparse.ArgumentParser('Sendria') version = f'%(prog)s {__version__} (https://github.com/msztolcman/sendria (c) 2018 Marcin Sztolcman)' parser.add_argument('-v', '--version', action='version', version=version, @@ -123,7 +123,7 @@ def parse_argv(argv): return args -def configure_logger(log_handler): +def configure_logger(log_handler: IO) -> NoReturn: if log_handler.name == '': processors = ( structlog.dev.ConsoleRenderer(), @@ -149,7 +149,7 @@ def configure_logger(log_handler): ) -def pid_exists(pid: int): +def pid_exists(pid: int) -> bool: """Check whether pid exists in the current process table. UNIX only. Source: https://stackoverflow.com/a/6940314/116153 @@ -201,7 +201,7 @@ async def terminate_server(sig: int, loop: asyncio.AbstractEventLoop) -> NoRetur loop.stop() -def run_sendria_servers(loop, args: argparse.Namespace) -> NoReturn: +def run_sendria_servers(loop: asyncio.AbstractEventLoop, args: argparse.Namespace) -> NoReturn: # initialize db loop.run_until_complete(db.setup(args.db)) @@ -223,7 +223,7 @@ def run_sendria_servers(loop, args: argparse.Namespace) -> NoReturn: logger.info('smtp server started', host=args.smtp_ip, port=args.smtp_port, auth='enabled' if args.smtp_auth else 'disabled', password_file=str(args.smtp_auth.path) if args.smtp_auth else None, - url=f'smtp://{args.smtp_ip}:{args.smtp_port}' + url=f'smtp://{args.smtp_ip}:{args.smtp_port}', ) # initialize and start web server @@ -244,7 +244,7 @@ def run_sendria_servers(loop, args: argparse.Namespace) -> NoReturn: ) # prepare for clean terminate - async def _initialize_aiohttp_services__stop(): + async def _initialize_aiohttp_services__stop() -> NoReturn: for ws in set(app['websockets']): await ws.close(code=aiohttp.WSCloseCode.GOING_AWAY, message='Server shutdown') await app.shutdown() @@ -271,7 +271,7 @@ def stop(pidfile: pathlib.Path) -> NoReturn: exit_err('Could not send SIGTERM: %s' % e) -def main(): +def main() -> NoReturn: args = parse_argv(sys.argv[1:]) configure_logger(args.log_handler) diff --git a/sendria/config.py b/sendria/config.py index b03579b..6079d5f 100644 --- a/sendria/config.py +++ b/sendria/config.py @@ -1,5 +1,6 @@ +import argparse import pathlib -from typing import Optional +from typing import Optional, NoReturn import attr from passlib.apache import HtpasswdFile @@ -39,7 +40,7 @@ def get(key: Optional[str] = None) -> Optional[Config]: return getattr(_CONFIG, key) -def setup(args): +def setup(args: argparse.Namespace) -> NoReturn: global _CONFIG _CONFIG = Config() diff --git a/sendria/db.py b/sendria/db.py index f918e7a..364db86 100644 --- a/sendria/db.py +++ b/sendria/db.py @@ -8,6 +8,7 @@ import pathlib import sqlite3 from contextlib import asynccontextmanager +from email.message import Message as EmailMessage from typing import Iterable, Optional, Union, List, NoReturn import aiosqlite @@ -118,7 +119,7 @@ async def store_message(conn: aiosqlite.Connection, message: Message) -> int: message.type, message.size, message.peer, - ) + ), ) message.id = cur.lastrowid # Store parts (why do we do this for non-multipart at all?!) @@ -136,7 +137,7 @@ async def store_message(conn: aiosqlite.Connection, message: Message) -> int: return message.id -async def _save_message_part(cur: aiosqlite.Cursor, message_id: int, cid: str, part) -> int: +async def _save_message_part(cur: aiosqlite.Cursor, message_id: int, cid: str, part: EmailMessage) -> int: sql = """ INSERT INTO message_part (message_id, cid, type, is_attachment, filename, charset, body, size, created_at) @@ -156,8 +157,8 @@ async def _save_message_part(cur: aiosqlite.Cursor, message_id: int, cid: str, p part.get_filename(), part.get_content_charset(), body, - body_len - ) + body_len, + ), ) return cur.lastrowid @@ -216,7 +217,7 @@ async def _get_message_part_types(conn: aiosqlite.Connection, message_id: int, t is_attachment = 0 LIMIT 1 - """.format(','.join('?' * len(types))) + """.format(','.join('?' * len(types))) # noqa: S608 async with conn.execute(sql, (message_id,) + types) as cur: data = await cur.fetchone() @@ -249,7 +250,7 @@ async def _message_has_types(conn: aiosqlite.Connection, message_id: int, types: type IN ({0}) LIMIT 1 - """.format(','.join('?' * len(types))) + """.format(','.join('?' * len(types))) # noqa: S608 async with conn.execute(sql, (message_id,) + types) as cur: data = await cur.fetchone() return data is not None diff --git a/sendria/errors.py b/sendria/errors.py index 9eeb0f7..ae0783c 100644 --- a/sendria/errors.py +++ b/sendria/errors.py @@ -1,20 +1,21 @@ import re +from typing import Optional, NoReturn class SendriaException(Exception): http_code = 400 _response_code_rxp = re.compile(r'[A-Z]+[a-z0-9]+') - def __init__(self, message=None): + def __init__(self, message: Optional[str] = None) -> NoReturn: self.message = message - def get_response_code(self): + def get_response_code(self) -> str: if hasattr(self, 'response_code'): return self.response_code cl = self.__class__.__name__[:-9] - def repl(m): + def repl(m: re.Match) -> str: return m.group(0).upper() + '_' cl = self._response_code_rxp.sub(repl, cl) @@ -22,5 +23,5 @@ def repl(m): return cl - def get_message(self): + def get_message(self) -> Optional[str]: return self.message diff --git a/sendria/http/__init__.py b/sendria/http/__init__.py index d2daa39..9284b1a 100644 --- a/sendria/http/__init__.py +++ b/sendria/http/__init__.py @@ -1 +1 @@ -from .core import setup +from .core import setup # noqa: F401 diff --git a/sendria/http/core.py b/sendria/http/core.py index cfaad75..597644e 100644 --- a/sendria/http/core.py +++ b/sendria/http/core.py @@ -4,7 +4,7 @@ import asyncio import re import weakref -from typing import Union, NoReturn +from typing import Union, NoReturn, Optional import aiohttp.web import aiohttp_jinja2 @@ -81,11 +81,13 @@ async def delete_message(rq: aiohttp.web.Request) -> WebHandlerResponse: return {} -async def _part_url(rq: aiohttp.web.Request, part) -> yarl.URL: +async def _part_url(rq: aiohttp.web.Request, part: dict) -> yarl.URL: return rq.app.router['get-message-part'].url_for(message_id=str(part['message_id']), cid=part['cid']) -async def _part_response(rq: aiohttp.web.Request, part, body=None, charset=None) -> WebHandlerResponse: +async def _part_response(rq: aiohttp.web.Request, part: dict, body: Optional[Union[str, bytes]] = None, + charset: Optional[str] = None, +) -> WebHandlerResponse: charset = charset or part['charset'] or 'utf-8' if body is None: body = part['body'] @@ -127,8 +129,8 @@ async def get_message_plain(rq: aiohttp.web.Request) -> WebHandlerResponse: return await _part_response(rq, part) or {} -async def _fix_cid_links(rq: aiohttp.web.Request, soup, message_id) -> NoReturn: - def _url_from_cid_match(m): +async def _fix_cid_links(rq: aiohttp.web.Request, soup: bs4.BeautifulSoup, message_id: Union[str, bytes]) -> NoReturn: + def _url_from_cid_match(m: re.Match) -> str: url = rq.app.router['get-message-part'].url_for(message_id=str(message_id), cid=m.group('cid')) return m.group().replace(m.group('replace'), str(url)) @@ -148,7 +150,7 @@ def _url_from_cid_match(m): tag.string = RE_CID_URL.sub(_url_from_cid_match, tag.string) -def _links_target_blank(soup) -> NoReturn: +def _links_target_blank(soup: bs4.BeautifulSoup) -> NoReturn: for tag in soup.descendants: if isinstance(tag, bs4.Tag) and tag.name == 'a': tag.attrs['target'] = 'blank' diff --git a/sendria/http/json_encoder.py b/sendria/http/json_encoder.py index c3ef318..80091d4 100644 --- a/sendria/http/json_encoder.py +++ b/sendria/http/json_encoder.py @@ -2,13 +2,14 @@ import datetime import json +from typing import Any import aiohttp.web import yarl class JSONEncoder(json.JSONEncoder): - def default(self, o): + def default(self, o: Any) -> Any: if isinstance(o, datetime.datetime): return o.isoformat() elif isinstance(o, yarl.URL): @@ -18,7 +19,7 @@ def default(self, o): def json_response(*args, **kwargs) -> aiohttp.web.Response: - def dumps(*a, **b): + def dumps(*a, **b) -> str: b['cls'] = JSONEncoder return json.dumps(*a, **b) kwargs['dumps'] = dumps diff --git a/sendria/http/middlewares.py b/sendria/http/middlewares.py index 481a414..41f076d 100644 --- a/sendria/http/middlewares.py +++ b/sendria/http/middlewares.py @@ -1,7 +1,7 @@ __all__ = [] import traceback -from typing import Callable +from typing import Callable, NoReturn import aiohttp.web from aiohttp_basicauth import BasicAuthMiddleware @@ -67,7 +67,7 @@ async def set_default_headers(rq: aiohttp.web.Request, handler: Callable) -> aio class BasicAuth(BasicAuthMiddleware): - def __init__(self, http_auth: HtpasswdFile, *args, **kwargs): + def __init__(self, http_auth: HtpasswdFile, *args, **kwargs) -> NoReturn: self._http_auth = http_auth kwargs['realm'] = 'Sendria' kwargs['force'] = False @@ -82,7 +82,7 @@ async def authenticate(self, rq: aiohttp.web.Request) -> bool: logger.info( 'request authentication failed', uri=rq.url.human_repr(), - header=rq.headers.get('authorization', None) + header=rq.headers.get('authorization', None), ) return res diff --git a/sendria/http/notifier.py b/sendria/http/notifier.py index 7d97e67..25d8872 100644 --- a/sendria/http/notifier.py +++ b/sendria/http/notifier.py @@ -13,7 +13,7 @@ def setup(*, websockets: weakref.WeakSet, - debug_mode: bool + debug_mode: bool, ) -> NoReturn: global WSHandlers, WebsocketMessagesQueue, DEBUG diff --git a/sendria/message.py b/sendria/message.py index 5ee08e3..9066a91 100644 --- a/sendria/message.py +++ b/sendria/message.py @@ -1,10 +1,10 @@ -__all__ = ['Message', ] +__all__ = ['Message'] import uuid from email.header import decode_header as _decode_header from email.message import Message as EmailMessage from email.utils import getaddresses -from typing import Union, List +from typing import Union, List, Dict, Any class Message: @@ -17,7 +17,7 @@ class Message: 'source', 'size', 'type', 'peer', 'parts', - 'created_at' + 'created_at', ) @classmethod @@ -46,13 +46,13 @@ def from_email(cls, email: EmailMessage) -> 'Message': return o - def to_dict(self): + def to_dict(self) -> Dict[str, Any]: return { k: getattr(self, k) for k in self.__slots__ } - def __repr__(self): + def __repr__(self) -> str: r = [] for k in self.__slots__: if k not in ('source', 'parts'): @@ -74,15 +74,15 @@ def decode_header(cls, value: Union[str, bytes, None]) -> str: return (b''.join(headers)).decode() @classmethod - def split_addresses(cls, value) -> List[str]: + def split_addresses(cls, value: str) -> List[str]: return [('{0} <{1}>'.format(name, addr) if name else addr) for name, addr in getaddresses([value])] @classmethod - def iter_message_parts(cls, email: EmailMessage): + def iter_message_parts(cls, email: EmailMessage) -> EmailMessage: if email.is_multipart(): - for email in email.get_payload(): - for part in cls.iter_message_parts(email): + for payload in email.get_payload(): + for part in cls.iter_message_parts(payload): yield part else: yield email diff --git a/sendria/smtp.py b/sendria/smtp.py index 832759e..3b62f37 100644 --- a/sendria/smtp.py +++ b/sendria/smtp.py @@ -1,7 +1,7 @@ __all__ = [] from email.message import Message as EmailMessage -from typing import Optional +from typing import Optional, NoReturn import aiosmtpd.controller import aiosmtpd.handlers @@ -16,12 +16,12 @@ class AsyncMessage(aiosmtpd.handlers.AsyncMessage): - def __init__(self, *args, smtp_auth=None, **kwargs): + def __init__(self, *args, smtp_auth: Optional[HtpasswdFile] = None, **kwargs) -> NoReturn: self._smtp_auth = smtp_auth super().__init__(*args, **kwargs) - async def handle_message(self, email: EmailMessage): + async def handle_message(self, email: EmailMessage) -> NoReturn: logger.debug("message received", envelope_from=email['X-MailFrom'], envelope_to=email['X-RcptTo'], @@ -31,7 +31,7 @@ async def handle_message(self, email: EmailMessage): class SMTP(aiosmtpd.smtp.SMTP): - def __init__(self, handler, smtp_auth, *args, **kwargs): + def __init__(self, handler: AsyncMessage, smtp_auth: Optional[HtpasswdFile], *args, **kwargs) -> NoReturn: self._smtp_auth = smtp_auth self._username = None @@ -40,17 +40,17 @@ def __init__(self, handler, smtp_auth, *args, **kwargs): auth_required=smtp_auth is not None, auth_require_tls=False, auth_callback=self.authenticate, - *args, **kwargs + *args, **kwargs, ) - def authenticate(self, mechanism, login, password): + def authenticate(self, mechanism: str, login: str, password: str) -> bool: if not self._smtp_auth: return True return self._smtp_auth.check_password(login, password) class Controller(aiosmtpd.controller.Controller): - def __init__(self, handler, smtp_auth, debug, *args, **kwargs): + def __init__(self, handler: AsyncMessage, smtp_auth: Optional[HtpasswdFile], debug: bool, *args, **kwargs) -> NoReturn: self.smtp_auth = smtp_auth self.debug = debug self.ident = kwargs.pop('ident') @@ -58,11 +58,11 @@ def __init__(self, handler, smtp_auth, debug, *args, **kwargs): # TODO: extract connect and total to some kind of settings/cli params super().__init__(handler, ready_timeout=5.0, *args, **kwargs) - def factory(self): + def factory(self) -> SMTP: return SMTP(self.handler, self.smtp_auth, ident=self.ident, hostname=self.hostname) -def run(smtp_host: str, smtp_port: int, smtp_auth: Optional[HtpasswdFile], ident: Optional[str], debug: bool): +def run(smtp_host: str, smtp_port: int, smtp_auth: Optional[HtpasswdFile], ident: Optional[str], debug: bool) -> Controller: message = AsyncMessage(smtp_auth=smtp_auth) controller = Controller(message, smtp_auth, debug, hostname=smtp_host, port=smtp_port, ident=ident) controller.start()