From 210ee835662877c4e5a94450d0656680875e0e18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=B6rnquist?= Date: Tue, 25 Jul 2023 21:42:27 +0200 Subject: Fix and harden linter checks. Harden the requirements of mypy, and also check against flake8 docstrings. And fix all errors and warnings resulting from that. --- mu4web/__init__.py | 7 ++ mu4web/html_render.py | 14 ++- mu4web/maildir.py | 9 ++ mu4web/main.py | 227 ++++++++++++++++++++++++++++++++++++++++-------- mu4web/mu.py | 63 +++++++++----- mu4web/password.py | 11 ++- mu4web/user/__init__.py | 47 ++++++++++ mu4web/user/local.py | 13 ++- mu4web/user/pam.py | 10 ++- mu4web/util.py | 19 +++- mu4web/xapian.py | 25 ++++++ setup.cfg | 5 +- 12 files changed, 382 insertions(+), 68 deletions(-) diff --git a/mu4web/__init__.py b/mu4web/__init__.py index 72bdd01..cb6f3fc 100644 --- a/mu4web/__init__.py +++ b/mu4web/__init__.py @@ -1 +1,8 @@ +""" +Mu4web init file. + +Mu4web is a web frontend to the mu mail indexer (can be found by +searching for mu4e, which also inspired the name). +""" + VERSION = "0.1" diff --git a/mu4web/html_render.py b/mu4web/html_render.py index fc608fb..f9c9aa8 100644 --- a/mu4web/html_render.py +++ b/mu4web/html_render.py @@ -1,5 +1,15 @@ +""" +Render python structures into HTML strings. + +Instead of constructing HTML strings manually or through templates, +instead write python structures, and serialize them at the last +possible moment. See ``render_document`` for a detailed explanation of +valid types. +""" + import html from typing import ( + Any, Callable, Union, ) @@ -11,7 +21,9 @@ except ImportError: # TODO compare this against xml.etree.ElementTree, which appears to # have a HTML mode. -HTML: TypeAlias = Union[tuple, +# ``tuple[Any, ...]`` should really be ``tuple['HTML', ...]``, but +# that doesn't work for some reason. +HTML: TypeAlias = Union[tuple[Any, ...], list['HTML'], Callable[[], str], None, str, int, float] diff --git a/mu4web/maildir.py b/mu4web/maildir.py index dd5be1c..cbac7c4 100644 --- a/mu4web/maildir.py +++ b/mu4web/maildir.py @@ -1,3 +1,10 @@ +""" +Functions for finding and querying maildirs. + +A maildir here is a directory containing a cur, new, and tmp +directory. +""" + from dataclasses import dataclass import os.path @@ -21,12 +28,14 @@ except ModuleNotFoundError: @dataclass class MaildirEntry: """A single maildir, used by find_maildirs.""" + name: str @dataclass class MaildirGroup: """A group of maildir, which isn't a maildir in itself.""" + name: str children: list[Union[MaildirEntry, 'MaildirGroup']] diff --git a/mu4web/main.py b/mu4web/main.py index 5d2d975..e56a0df 100644 --- a/mu4web/main.py +++ b/mu4web/main.py @@ -1,3 +1,5 @@ +"""Web routes for mu4web.""" + from email.message import EmailMessage from email.headerregistry import Address from urllib.parse import urlencode @@ -13,6 +15,7 @@ from flask_login import ( logout_user, ) from typing import ( + Any, Optional, cast, ) @@ -33,7 +36,6 @@ from flask import ( get_flashed_messages ) - # # A few operations depend on the index of attachements. These index # are all pre-order traversal indexes of the attachement tree, which @@ -44,7 +46,7 @@ login_manager = LoginManager() def mailto(addr: str) -> HTML: - """Constructs a mailto anchor element.""" + """Construct a mailto anchor element.""" return ('a', {'href': f'mailto:{addr}'}, addr) @@ -54,7 +56,7 @@ def format_email(addr: Address) -> list[HTML]: return [addr.display_name, ' <', mailto(mail_addr), '>'] -def header_format(key: str, value) -> HTML: +def header_format(key: str, value: Any) -> HTML: """Format email headers to HTML.""" if key in ['to', 'cc', 'bcc']: return ('ul', *[('li', *format_email(addr)) @@ -66,10 +68,14 @@ def header_format(key: str, value) -> HTML: id = str(value).strip("<>") return ['<', ('a', {'href': '?' + urlencode({'id': id})}, id), '>'] else: - return value + # assert type(value) == str, f"Weird value in header {value!r}" + return str(value) -def attachement_tree(id: str, mail: EmailMessage, idx=0) -> tuple[HTML, int]: +def attachement_tree(id: str, + mail: EmailMessage, + idx: int = 0) -> tuple[HTML, int]: + """Construct a tree of all attachements for the given mail.""" ct = mail.get_content_type() fn = mail.get_filename() @@ -102,6 +108,7 @@ def attachement_tree(id: str, mail: EmailMessage, idx=0) -> tuple[HTML, int]: def login_page(returnto: Optional[str] = None) -> HTML: + """HTML form for the login page.""" return ('form', {'action': '/login', 'method': 'POST', 'class': 'loginform'}, ('label', {'for': 'username'}, 'Användarnamn'), ('input', {'id': 'username', 'name': 'username', 'placeholder': 'Användarnamn'}), @@ -121,21 +128,29 @@ def login_page(returnto: Optional[str] = None) -> HTML: def user_info(username: str) -> HTML: + """ + Return user info for top bar. + + Includes the users name, and a button for logging out. + """ return [('span', username), ('form', {'action': '/logout', 'method': 'POST'}, - ('input', {'type': 'submit', 'value': 'Logga ut'}))] + ('input', {'type': 'submit', 'value': 'Logga ut'}))] def login_prompt() -> HTML: + """Return link to the login page.""" return ('a', {'href': '/login'}, 'Logga in') def flashed_messages() -> HTML: + """Return Flasks flashed messages, formatted as a list.""" return ('ul', {'class': 'flashes'}, *[('li', msg) for msg in get_flashed_messages()]) -def include_stylesheet(path): +def include_stylesheet(path: str) -> HTML: + """Return HTML for including a stylesheet inside the .""" return ('link', {'type': 'text/css', 'rel': 'stylesheet', 'href': path}) @@ -143,12 +158,29 @@ def include_stylesheet(path): def page_base(title: Optional[str] = None, body: HTML = []) -> HTML: + """ + Build base layout for almost all pages. + + The base contents of our html page, from the tag and down. + + :param title: + Local pagetitle, will be suffixed with site suffix. + + :param body: + Contents of the page. Will work without this, but the page + would lack any actual contents. + """ + if title: + full_title = f'{title} — Mu4Web' + else: + full_title = 'Mu4Web' + return ('html', {'lang': 'sv'}, ('head', ('meta', {'charset': 'utf-8'}), ('meta', {'name': 'viewport', 'content': 'width=device-width, initial-scale=0.5'}), - ('title', title, ' — Mu4Web'), + ('title', full_title), include_stylesheet(url_for('static', filename='style.css')), ), ('body', @@ -177,11 +209,19 @@ def page_base(title: Optional[str] = None, )))) -def response_for(id: str) -> str: - - mail = cast(EmailMessage, get_mail(id)) +def response_for(id: str, mail: EmailMessage) -> str: + """ + Build response page for an email or a tree. + :param id: + The message id of the root message + :param mail: + Either the root component of a mail, or a sub-component of + type message/rfc822. + """ + # Setup headers headers = {} + print("mail", mail) for (key, value) in mail.items(): headers[key.lower()] = value @@ -200,11 +240,13 @@ def response_for(id: str) -> str: ('summary', 'Alla mailhuvuden'), ('dl', *all_heads)) + # Setup title if t := headers.get('subject'): title = f'Mail — {t}' else: title = 'Mail' + # Setup body body: list[HTML] = [] # Manual walk to preserve attachement index for idx, at in enumerate(mail.walk()): @@ -229,6 +271,7 @@ def response_for(id: str) -> str: body.append(('a', {'href': url}, at.get_filename() or at.get_content_type())) + # Setup attachements tree, idx = attachement_tree(id, mail) main_body: list[HTML] = [('dl', *head), @@ -247,6 +290,7 @@ def response_for(id: str) -> str: def search_field(q: str) -> HTML: + """Build large search form for search page.""" return ('form', {'id': 'searchform', 'action': '/search', 'method': 'GET'}, @@ -261,6 +305,18 @@ def search_field(q: str) -> HTML: def search_result(q: str, by: Optional[str], direction: str) -> HTML: + """ + Search database for query, and build resulting HTML body. + + :param q: + Mu search query. + + :param by: + Parameter to sort by. + + :param direction: + Direction to sort results in. One of 'rising' or 'falling'. + """ assert direction in ('rising', 'falling') # keys = ['from', 'to', 'subject', 'date', 'size', 'maildir', 'msgid'] @@ -269,16 +325,16 @@ def search_result(q: str, by: Optional[str], direction: str) -> HTML: by = app.config['DEFAULT_SORT_COLUMN'] rows = mu.search(q, by, direction == 'falling') - body: list[tuple] = [] + body: list[HTML] = [] for row in rows: - rowdata = [] + rowdata: list[HTML] = [] for key in keys: data = row.get(key, None) if data and key == 'date': dt = datetime.fromtimestamp(int(data)) data = dt.strftime('%Y-%m-%d %H:%M') - rowdata.append(('td', ('a', {'href': '/?id=' + row['msgid']}, data))) - body.append(('tr', rowdata)) + rowdata.append(['td', ('a', {'href': '/?id=' + row['msgid']}, data)]) + body.append(['tr', rowdata]) if len(rows) == 0: return "Inga träffar" @@ -311,6 +367,7 @@ def search_result(q: str, by: Optional[str], direction: str) -> HTML: def search_page(q: str, by: Optional[str], direction: str) -> str: + """Return rendered HTML for search page.""" main_body = [search_field(q)] # TODO pagination @@ -323,7 +380,8 @@ def search_page(q: str, by: Optional[str], body=main_body)) -def index_page(): +def index_page() -> str: + """Return rendered HTML for index page.""" data = mu.info() maildirs = find_maildirs(data['maildir'] + '/') @@ -334,9 +392,9 @@ def index_page(): rows.append(('tr', ('td', key), ('td', value))) - body = [('div', ('table', ('tbody', rows))), - ('div', entries), - ] + body: HTML = [('div', ('table', ('tbody', rows))), + ('div', entries), + ] return render_document(page_base(title='E-postindex', body=body)) @@ -361,18 +419,41 @@ app.config.from_pyfile('settings.py') login_manager.init_app(app) +def fix_id(id: str) -> str: + """Update an ID gotten through a GET parameter into something mu likes.""" + return ''.join(id).replace(' ', '+') + + @login_manager.user_loader def load_user(user_id): + """ + Find the user with the given id, and return its session object. + + :param user_id: + The string id of the user. + + .. todo:: + + Return None on invalid id. + """ # return User.get(user_id) return LocalUser(user_id) @app.route('/') def index(): + """ + Return index page, mail page, or redirect to login page. + + If the user isn't logged in, then the user is redirected to the login page. + Otherwise, if the ``id`` param is set, show the email with that message id, + and finally show the index page if nothing else matched. + """ if not current_user.is_authenticated: return redirect(url_for('login_page_', returnto=request.path)) if id := request.args.get('id'): - response = response_for(''.join(id).replace(' ', '+')) + fixed_id = fix_id(id) + response = response_for(fixed_id, cast(EmailMessage, get_mail(fixed_id))) else: response = index_page() return response @@ -381,6 +462,12 @@ def index(): @app.route('/search') @login_required def search_page_(): + """ + Search page response. + + :param q: + :param by: + """ direction = request.args.get('direction', app.config['DEFAULT_DIRECTION']) if direction not in ('rising', 'falling'): direction = app.config['DEFAULT_DIRECTION'] @@ -413,9 +500,9 @@ def multipart_page(msg_id: str, body=body)) -def attachement_response(attachement: EmailMessage): +def attachement_response(attachement: EmailMessage) -> flask.Response: """ - Builds a response for a given attachement. + Build a response for a given attachement. Gets content type and encoding from the attachements headers. """ @@ -432,6 +519,15 @@ def attachement_response(attachement: EmailMessage): @app.route('/raw') @login_required def raw_message(): + """ + Get the "raw" bytes of an email. + + Looks up a message from the mu database, and then returns the data + with no formatting, and a content type of message/rfc822. + + :param id: + Message id + """ msg_id = request.args.get('id', '') filename = mu.find_file(msg_id) if not filename: @@ -440,39 +536,53 @@ def raw_message(): class MutableString: - def __init__(self): + """ + A mutatable string. + + Strings are immutable by default in python. This works almost + exactly like a regular string, but ``+=`` actually changes the + object in place. + """ + + def __init__(self) -> None: self.str = '' - def __iadd__(self, other): + def __iadd__(self, other: str) -> 'MutableString': self.str += other return self - def __repr__(self): + def __repr__(self) -> str: return f'MutableString("{self.str}")' - def __str__(self): + def __str__(self) -> str: return self.str class IMGParser(HTMLParser): """ Rewrites HTML image tags to be safer/have more functionality. + Should only be fed the image tag. Everything else is assumed to be directly copied externaly. - [Parameters] - result - A mutable string which should be appended with the - (possibly changed) img tag. - msg_id - The Email Message ID field, used to construct some links. + :param result: + A mutable string which should be appended with the (possibly + changed) img tag. + :param msg_id: + The Email Message ID field, used to construct some links. """ + rx = re.compile('cid:(.*)') - def __init__(self, result, msg_id): + def __init__(self, result: MutableString, msg_id: str) -> None: super().__init__() self.result = result self.msg_id = msg_id - def handle_starttag(self, tag, attrs): + def handle_starttag(self, # noqa: D102 + tag: str, + attrs: list[tuple[str, Optional[str]]] + ) -> None: # TODO this will also get called for self closing tags # (), which will drop that slash. FIX @@ -484,6 +594,8 @@ class IMGParser(HTMLParser): # later added to unblock them on click self.result += '' @@ -533,6 +645,29 @@ class IMGParser(HTMLParser): @app.route('/part') @login_required def attachement_part_page(): + """ + Page for a specific attachment. + + :param id: + Message id for the message we want. + + :param idx: + Optional numeric index of the attachement of the message, + counted as the lines appear when printing the tree, one line + at a time.:: + + 0. ├── a + 1. │   ├── b + 2. │   └── c + 3. └── d + + Defaults to 0 (the root element). + + :param raw: + Optional boolean parameter, which if set, returns the + attachement verabtim as stored on disk, instead of rendered + into HTML. + """ msg_id = request.args.get('id') raw = request.args.get('raw') if not msg_id: @@ -541,7 +676,10 @@ def attachement_part_page(): mail = cast(EmailMessage, get_mail(msg_id)) attachement = list(mail.walk())[attachement_idx] - if attachement.is_multipart(): + if attachement.get_content_type() == 'message/rfc822': + return response_for(fix_id(msg_id), attachement) + + elif attachement.is_multipart(): return multipart_page(msg_id, attachement, attachement_idx) @@ -586,6 +724,18 @@ def attachement_part_page(): # Possibly change this to actually use that form of URI:s @app.route('/cid', methods=['GET']) def cid(): + """ + Get contents of a cid. + + :param id: + A message id. + :param cid: + A given content id. + + :return: + A response with the given object, with correct content-type + headers. + """ msg_id = request.args.get('id') cid = request.args.get('cid') if not msg_id: @@ -605,6 +755,7 @@ def cid(): @app.route('/login', methods=['GET']) def login_page_(): + """Login page.""" returnto = request.args.get('returnto') if current_user.is_authenticated: # Redirect away already logged in users @@ -620,6 +771,7 @@ def login_page_(): @app.route('/login', methods=['POST']) def login_form(): + """Login a user.""" resp = redirect(request.args.get('returnto', url_for('index'))) username = request.form['username'] @@ -636,9 +788,16 @@ def login_form(): @app.route('/logout', methods=['POST']) @login_required def logout_form(): + """Logout the currently logged in user.""" logout_user() return redirect(url_for('index')) +@app.errorhandler(500) +def internal_server_error(e: Any) -> tuple[str, int]: + """Fallback error page for 500 errors.""" + return ("error page", 500) + + if __name__ == '__main__': app.run(debug=True, port=8090) diff --git a/mu4web/mu.py b/mu4web/mu.py index 8efbd38..2d2d5ac 100644 --- a/mu4web/mu.py +++ b/mu4web/mu.py @@ -1,6 +1,4 @@ -""" -Wrapper for the `mu` command line. -""" +"""Wrapper for the `mu` command line.""" import email.message import email.policy @@ -24,7 +22,20 @@ from typing import ( parser = BytesParser(policy=email.policy.default) -def find_file(id: str) -> Optional[PathLike]: +def find_file(id: str) -> Optional[PathLike[str]]: + """ + Find the file system location for mail with given id. + + :param id: + A normalized message id. + + :returns: + Either the file system path for a message with that id, or + None if no matches were found. + + :raises MuError: + On all other failure modes. + """ cmd = subprocess.run(['mu', 'find', '-u', f'i:{id}', '--fields', 'l'], stdout=PIPE) @@ -41,8 +52,7 @@ def get_mail(id: str) -> email.message.Message: """ Lookup email by Message-ID. - [Raises] - MuError + :raises MuError: """ filename = find_file(id) if not filename: @@ -54,6 +64,8 @@ def get_mail(id: str) -> email.message.Message: class MuError(Exception): + """One of the errors which mu can return.""" + codes = { 1: 'General Error', 2: 'No Matches', @@ -75,21 +87,24 @@ def search(query: str, sortfield: Optional[str] = 'subject', reverse: bool = False) -> list[dict[str, str]]: """ - [Parameters] - query - Search query as per mu-find(1). - sortfield - Field to sort the values by - reverse - If the sort should be reversed - - [Returns] - >>> {'from': 'Hugo Hörnquist ', - 'date': '1585678375', - 'size': '377', - 'msgid': 'SAMPLE-ID@localhost', - 'path': '/home/hugo/.local/var/mail/INBOX/cur/filename', - 'maildir': '/INBOX' - } + Search the mu database for messages. + + :param query: + Search query as per mu-find(1). + :param sortfield: + Field to sort the values by + :param reverse: + If the sort should be reversed + + :returns: + >>> {'from': 'Hugo Hörnquist ', + 'date': '1585678375', + 'size': '377', + 'msgid': 'SAMPLE-ID@localhost', + 'path': '/home/hugo/.local/var/mail/INBOX/cur/filename', + 'maildir': '/INBOX' + } """ - if not query: raise ValueError('Query required for mu_search') cmdline = ['mu', 'find', @@ -125,9 +140,9 @@ def search(query: str, return message_list -def base_directory() -> PathLike: +def base_directory() -> PathLike[str]: """ - Returns where where mu stores its files. + Return where where mu stores its files. Defaults to $XDG_CACHE_HOME/mu, but can be changed through the environment variable MUHOME. @@ -138,6 +153,8 @@ def base_directory() -> PathLike: class MuInfo(TypedDict): + """Metadata about the mu database.""" + database_path: str changed: datetime created: datetime @@ -148,7 +165,7 @@ class MuInfo(TypedDict): def info() -> MuInfo: - + """Collect metadata about the mu database.""" db = os.path.join(base_directory(), "xapian") def f(key: str) -> datetime: diff --git a/mu4web/password.py b/mu4web/password.py index 26bc712..1741fd1 100755 --- a/mu4web/password.py +++ b/mu4web/password.py @@ -15,6 +15,7 @@ from typing import ( def gen_salt(length: int = 10) -> str: + """Generate a random salt.""" # urandom is stated to be suitable for cryptographic use. return bytearray(os.urandom(length)).hex() @@ -27,6 +28,8 @@ hash_methods = { class PasswordEntry(TypedDict): + """A single entry in the password store.""" + hash: str salt: str # One of the keys of hash_methods @@ -37,10 +40,11 @@ class Passwords: """ Simple password store. - [Parameters] - fname - Path of json file to load and store from. + :param fname: + Path of json file to load and store from. """ - def __init__(self, fname: os.PathLike): + + def __init__(self, fname: os.PathLike[str]): self.fname = fname self.db: dict[str, PasswordEntry] try: @@ -90,6 +94,7 @@ class Passwords: def main() -> None: + """Entry point for directly interfacing with the password store.""" import argparse parser = argparse.ArgumentParser() diff --git a/mu4web/user/__init__.py b/mu4web/user/__init__.py index bb14f67..c0dbda9 100644 --- a/mu4web/user/__init__.py +++ b/mu4web/user/__init__.py @@ -1,19 +1,66 @@ +""" +User authentication and sessions. + +This is modeled to work well together with flash_login. +""" + + class User: + """ + Default class for user session and authentication. + + This implements flask-login's User Class protocol. + + This abse implementation can construct any user, but no user can + by authenticated through it. + + https://flask-login.readthedocs.io/en/latest/ + """ + def __init__(self, username: str): self._username = username self._authenticated = False + # ---- User class protocoll ------------------------ + def is_authenticated(self) -> bool: + """ + Return whetever a user is authenticated. + + An authenticated user is someone who has provided valid + credentials (or similar) + """ return self._authenticated def is_active(self) -> bool: + """ + Return whetever a user is active. + + An active user is someone who has an active account, suspended + or deactivated accounts aren't active. + """ return True def is_anonymous(self) -> bool: + """Return true for anonymous users.""" return False def get_id(self) -> str: + """Get the unique identifier for this user.""" return self._username + # ---- Other stuff --------------------------------- + def validate(self, _: str) -> bool: + """ + Attempt to validate the users credentials. + + The username comes from the constructed object. + + :params password: + The attempted authentication token/password. + + :returns: + True if the given token is correct, false otherwise. + """ raise NotImplementedError() diff --git a/mu4web/user/local.py b/mu4web/user/local.py index 37e88cb..c7936d6 100644 --- a/mu4web/user/local.py +++ b/mu4web/user/local.py @@ -1,12 +1,21 @@ +""" +User authentication through local password store. + +Currently (hard codedly) loads the file password.json from the current +path. Take care. +""" + from .. import password from ..password import Passwords from typing import cast import os from . import User -passwords: Passwords = password.Passwords(cast(os.PathLike, 'passwords.json')) +passwords: Passwords = password.Passwords(cast(os.PathLike[str], 'passwords.json')) class LocalUser(User): - def validate(self, password: str) -> bool: + """Authenticate user through local password file.""" + + def validate(self, password: str) -> bool: # noqa: 201 return passwords.validate(self._username, password) diff --git a/mu4web/user/pam.py b/mu4web/user/pam.py index 55e868e..c641ff8 100644 --- a/mu4web/user/pam.py +++ b/mu4web/user/pam.py @@ -1,7 +1,13 @@ +"""User authentication through PAM.""" + from . import User import pam class PamUser(User): - def validate(self, password: str) -> bool: - return pam.authenticate(self._username, password) + """Authenticate user through pam.""" + + def validate(self, password: str) -> bool: # noqa: 201 + ret = pam.authenticate(self._username, password) + assert type(ret) == bool + return ret diff --git a/mu4web/util.py b/mu4web/util.py index 41601c5..7e2df8c 100644 --- a/mu4web/util.py +++ b/mu4web/util.py @@ -1,3 +1,5 @@ +"""Various misc. utilities.""" + import subprocess from os import PathLike from typing import ( @@ -5,9 +7,22 @@ from typing import ( ) -def find(basedir: PathLike, +def find(basedir: PathLike[str] | PathLike[bytes], **flags: str | bytes) -> list[bytes]: - cmdline: list[Union[str, bytes, PathLike]] = ['find', basedir] + """ + Run the shell command ``find``. + + :param basedir: + Directory to search under. + + :param flags: + Key-value pairs passed to find. Each key is prefixed with a + single dash. Compound searches might be possible with some + trickery, but extend this function instead of doing that. + """ + cmdline: list[Union[str, bytes, + PathLike[str], + PathLike[bytes]]] = ['find', basedir] for key, value in flags.items(): cmdline += [f'-{key}', value] cmdline.append('-print0') diff --git a/mu4web/xapian.py b/mu4web/xapian.py index d23b2d1..94e597d 100644 --- a/mu4web/xapian.py +++ b/mu4web/xapian.py @@ -1,8 +1,24 @@ +""" +Extra xapian procedures. + +The python-xapian library exists, but doesn't seem to export metadata. +""" + import subprocess from typing import Optional def metadata_list(db: str, prefix: Optional[str] = None) -> list[str]: + """ + Enumerate all metadata entries in the given database. + + :param db: + File system path to an Xapian "database". This is the + directory containing iamglass, flintlock, and .glass files. + + :param prefix: + Limit list to entries starting with this string. + """ cmdline = ['xapian-metadata', 'list', db] if prefix: cmdline.append(prefix) @@ -11,6 +27,15 @@ def metadata_list(db: str, prefix: Optional[str] = None) -> list[str]: def metadata_get(db: str, key: str) -> str: + """ + Get a xapian metadata value. + + :param db: + Same as for metadata_list + + :param key: + Exact name of key to look up. + """ cmd = subprocess.run(['xapian-metadata', 'get', db, key], capture_output=True, text=True) return cmd.stdout.strip() diff --git a/setup.cfg b/setup.cfg index b6ab484..b05edd9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,6 +32,9 @@ include_package_data = True [mypy] ignore_missing_imports = True disallow_untyped_defs = True +check_untyped_defs = True +strict = True +warn_unused_ignores = False [mypy-mu4web.main] # NOTE Flask endpoints aren't typed. @@ -40,5 +43,5 @@ disallow_untyped_defs = True disallow_untyped_defs = False [flake8] -ignore = E731 +ignore = E731,D105,D107 max-line-length = 100 -- cgit v1.2.3