aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHugo Hörnquist <hugo@lysator.liu.se>2023-07-25 21:42:27 +0200
committerHugo Hörnquist <hugo@lysator.liu.se>2023-07-25 22:05:20 +0200
commit210ee835662877c4e5a94450d0656680875e0e18 (patch)
tree79823306a0aea842541a8b2586fcf47b0ced0eb5
parentConfigure linters. (diff)
downloadmu4web-210ee835662877c4e5a94450d0656680875e0e18.tar.gz
mu4web-210ee835662877c4e5a94450d0656680875e0e18.tar.xz
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.
-rw-r--r--mu4web/__init__.py7
-rw-r--r--mu4web/html_render.py14
-rw-r--r--mu4web/maildir.py9
-rw-r--r--mu4web/main.py227
-rw-r--r--mu4web/mu.py63
-rwxr-xr-xmu4web/password.py11
-rw-r--r--mu4web/user/__init__.py47
-rw-r--r--mu4web/user/local.py13
-rw-r--r--mu4web/user/pam.py10
-rw-r--r--mu4web/util.py19
-rw-r--r--mu4web/xapian.py25
-rw-r--r--setup.cfg5
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 <head>."""
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 <html/> 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
# (<img/>), which will drop that slash. FIX
@@ -484,6 +594,8 @@ class IMGParser(HTMLParser):
# later added to unblock them on click
self.result += '<img '
for key, value in attrs:
+ if not value:
+ value = key
if key == 'src':
if m := IMGParser.rx.match(value):
params = urlencode({'id': self.msg_id, 'cid': m[1]})
@@ -521,7 +633,7 @@ class IMGParser(HTMLParser):
# in iframe:s (where the content will probably be shown)
# to open in the current (top level) page, instead of
# inside the iframe.
- args = ' '.join(f'{html.escape(key)}={html.escape(value)}'
+ args = ' '.join(f'{html.escape(key)}={html.escape(value or key)}'
for (key, value)
in [*attrs, ('target', '_parent')])
self.result += f'<a {args}>'
@@ -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 <hugo@example.com>',
- '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 <hugo@example.com>',
+ '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