diff options
-rw-r--r-- | doc/administration/installation.rst | 2 | ||||
-rw-r--r-- | doc/index.rst | 1 | ||||
-rw-r--r-- | doc/man.rst | 15 | ||||
-rw-r--r-- | doc/man/fietsctl.rst | 260 | ||||
-rw-r--r-- | fietsboek/data.py | 11 | ||||
-rw-r--r-- | fietsboek/scripts/fietsctl.py | 263 | ||||
-rw-r--r-- | fietsboek/util.py | 23 | ||||
-rw-r--r-- | tests/unit/test_util.py | 16 | ||||
-rw-r--r-- | tox.ini | 6 |
9 files changed, 542 insertions, 55 deletions
diff --git a/doc/administration/installation.rst b/doc/administration/installation.rst index 53734b5..fd0def5 100644 --- a/doc/administration/installation.rst +++ b/doc/administration/installation.rst @@ -96,7 +96,7 @@ You can use the ``fietsctl`` command line program to add administrator users: .. code:: bash - .venv/bin/fietsctl -c production.ini useradd --admin + .venv/bin/fietsctl user add -c production.ini --admin Running Fietsboek ----------------- diff --git a/doc/index.rst b/doc/index.rst index 0b69ef1..73dce3f 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -13,6 +13,7 @@ Welcome to Fietsboek's documentation! administration developer user + man .. toctree:: :maxdepth: 1 diff --git a/doc/man.rst b/doc/man.rst new file mode 100644 index 0000000..03c7f78 --- /dev/null +++ b/doc/man.rst @@ -0,0 +1,15 @@ +Manpages +======== + +.. toctree:: + :maxdepth: 1 + + man/fietsctl + +In this section, you will find the manpages for the various commands that are +provided in Fietsboek. + +Each of those pages can also be turned into a "proper" manpage using +``rst2man``:: + + rst2man.py doc/man/fietsctl.rst fietsctl.1 diff --git a/doc/man/fietsctl.rst b/doc/man/fietsctl.rst new file mode 100644 index 0000000..67cbed4 --- /dev/null +++ b/doc/man/fietsctl.rst @@ -0,0 +1,260 @@ +fietsctl +======== + +Control utility for Fietsboek +----------------------------- +:Manual section: 1 + +SYNPOSIS +******** + +.. code-block:: text + + fietsctl maintenance-mode + fietsctl track {del|list} + fietsctl user {add|del|hittekaart|list|modify|passwd} + fietsctl version + +DESCRIPTION +*********** + +The ``fietsctl`` script is provided for administrative purposes. It allows you +to manage the state and content of a Fietsboek instance from the command line. + +.. warning:: + + The ``fietsctl`` script does not do any access checking and does not + require and logins or passwords. You must use the permissions of your + system and database server to restrict the access to ``fietsctl`` by + restricting access to the data stores directly. + +Detailed versions of the commands are described below. + +Note that most commands expect the path to the configuration file to be given +(e.g. ``-c production.ini``). The default uses ``fietsboek.ini``. This can be +overridden using the ``-c``/``--config`` option. + +.. note:: + + All commands support the ``--help`` option, which will give you a quick + overview of how the command works and which options are available. + +USER MANAGEMENT +*************** + +You can use the ``fietsctl user`` subcommand to manage the users in the +Fietsboek system. + +Many functions which deal with existing users (delete, modify, ...) allow the +user to be specified either by their ID using the ``-i``/``--id`` option, or by +their email address using ``-e``/``--email``. You can obtain the IDs of users +using the ``fietsctl user list`` command. + +ADDING A USER +############# + +.. code-block:: text + + fietsctl user add [-c CONFIG] [--email EMAIL] [--password PASSWORD] [--admin] + +This command adds a user to the system. It can be called with no arguments, in +which case all required values are prompted for. + +If the new user should be made an admin, use the ``--admin`` flag. If not +given, the user will *not* be made an admin. In any case, the user is +automatically verified. If you want to change the admin or verification status +after creating a user, use the ``fietsctl user modify`` command (see below). + +It is advised that you do not supply the password on the command line to +prevent it from appearing in the command history. Either disable the history, +or leave out the password (in which case you will be prompted). + +Note that this function does not check the ``enable_account_registration`` +setting, so it can always be used to add new users to the system. + +Note further that this function does less checks then the web interface (e.g. +it does not require an email verification), so be careful which values you +enter. + +REMOVING A USER +############### + +.. code-block:: text + + fietsctl user del [-c CONFIG] [-f/--force] [-i/--id ID] [-e/--email EMAIL] + +Removes a user from the system. This will remove the user account and all +associated data (the user's tracks, comments, ...). + +By default, the command will ask for confirmation. You can specify the +``-f``/``--force`` flag to override this check. + +GENERATING HEATMAPS +################### + +.. code-block:: text + + fietsctl user hittekaart [-c CONFIG] [-i/--id ID] [-e/--email EMAIL] [--delete] [--mode heatmap|tilehunt] + +With ``fiettsctl user hittekaart`` you can force a hittekaart run for a +specific user. By default, only the heatmap is generated, but you can use +``--mode`` to select which overlay map you want to generate. You can also +specify ``--mode`` multiple times to generate multiple heat maps with a single +invocation. + +If you want to delete a heatmap, use the ``--delete`` option. It also respects +the ``--mode`` selection, so you can delete individual types of heatmaps. + +LISTING USERS +############# + +.. code-block:: text + + fietsctl user list [-c CONFIG] + +Outputs a list of all users in the system, one user per line. Each line +consists of: + +.. code-block:: text + + [av] ID - EMAIL - NAME + +The "a" indicates that the user has admin permissions. If the user has no admin +permissions, a "-" is shown instead. + +The "v" indicates that the user has their email address verified. If the user +has not verified their email address, a "-" is shown instead. + +MODIFYING USERS +############### + +.. code-block:: text + + fietsctl user modify [-c CONFIG] [-i/--id ID] [-e/--email EMAIL] [--admin/--no-admin] [--verified/--no-verified] + +Modifies a user. This can currently be used to set the admin and verification +status of a user. If you want to change the password, use ``fietsctl user +passwd`` (see below). You cannot currently change the email address or name of +a user with this command (note that the ``--email`` option is for user +*selection*, not *modification*). + +If you do not specifiy either ``--admin`` or ``--no-admin``, then the current +value of the user is not changed. The same goes for ``--verified`` and +``--no-verified``, if neither is given, the current value is not changed. + +CHANGING USER PASSWORDS +####################### + +.. code-block:: text + + fietsctl user passwd [-c CONFIG] [-i/--id ID] [-e/--email EMAIL] [--password PASSWORD] + +Changes the password of the specified user. If the password is not given via +``--password``, then the password is interactively prompted for. Be careful +when using ``--password`` as sensitive information might end up in the shell +history! + +Note that this function does fewer checks than the web interface, as such it is +possible to set passwords that users cannot set themselves (e.g. very short +ones). + +TRACK MANAGEMENT +**************** + +The ``fietsctl track`` subcommand can be used to manage the tracks. + +LISTING TRACKS +############## + +.. code-block:: text + + fietsctl track list [-c CONFIG] + +Lists all tracks in the system. This outputs one line per track, plus final +summary information. + +For each track, the following information is shown: + +* The track's ID +* The size of the track's data (this includes the size of the data directory, + but not the size of the database elements) +* The track's owner (both name and email address) +* The track's title. + +REMOVING A TRACK +################ + +.. code-block:: text + + fietsctl track del [-c CONFIG] -i/--id ID [-f/--force] + +Deletes the specified track. The right ID can be found via the ``track list`` +command, or via the web interface. + +This command deletes the track including its pictures and comments. + +By default, the command will ask for confirmation. You can specify the +``-f``/``--force`` flag to override this check. + +MAINTENANCE MODE +**************** + +The ``fietsctl maintenance-mode`` subcommand manages the maintenance mode. + +ACTIVATING MAINTENANCE +###################### + +.. code-block:: text + + fietsctl maintenance-mode [-c CONFIG] REASON + +Enables the maintenance mode with the given reason. The reason should be a +short string that describes why Fietsboek is disabled. It will be shown to the +users on the error page. + +CHECKING MAINTENANCE +#################### + +.. code-block:: text + + fietsctl maintenance-mode [-c CONFIG] + +Without the reason, the command will output the current status of the +maintenance mode. + +DEACTIVATING MAINTENANCE +######################## + +.. code-block:: text + + fietsctl maintenance-mode [-c CONFIG] --disable + +With the ``--disable`` option, the maintenance mode will be disabled. + +BUGS +**** + +Bugs can be reported either via the issue tracker +(https://gitlab.com/dunj3/fietsboek/-/issues) or via email (fietsboek at +kingdread dot de). + +AUTHOR +****** + +This program is part of Fietsboek, written by the Fietsboek authors. + +COPYRIGHT +********* + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License as published by the Free +Software Foundation, either version 3 of the License, or (at your option) any +later version. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +PARTICULAR PURPOSE. See the GNU Affero General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License along +with this program. If not, see <https://www.gnu.org/licenses/>. diff --git a/fietsboek/data.py b/fietsboek/data.py index debddf1..9e0d45d 100644 --- a/fietsboek/data.py +++ b/fietsboek/data.py @@ -5,6 +5,7 @@ the database itself. This module makes access to such data objects easier. """ import datetime import logging +import os import random import shutil import string @@ -242,6 +243,16 @@ class TrackDataDir: shutil.move(self.path, new_name) self.journal.append(("purge", new_name)) + def size(self) -> int: + """Returns the size of the data that this track entails. + + :return: The size of bytes that this track consumes. + """ + size = 0 + for root, _, files in os.walk(self.path): + size += sum(os.path.getsize(os.path.join(root, fname)) for fname in files) + return size + def gpx_path(self) -> Path: """Returns the path of the GPX file. diff --git a/fietsboek/scripts/fietsctl.py b/fietsboek/scripts/fietsctl.py index 4255b18..59462df 100644 --- a/fietsboek/scripts/fietsctl.py +++ b/fietsboek/scripts/fietsctl.py @@ -1,5 +1,6 @@ """Script to do maintenance work on a Fietsboek instance.""" # pylint: disable=too-many-arguments +import logging from typing import Optional import click @@ -8,13 +9,31 @@ from pyramid.paster import bootstrap, setup_logging from pyramid.scripting import AppEnvironment from sqlalchemy import select -from .. import __VERSION__, hittekaart, models +from .. import __VERSION__, hittekaart, models, util from ..data import DataManager from . import config_option +LOGGER = logging.getLogger("fietsctl") + EXIT_OKAY = 0 EXIT_FAILURE = 1 +FG_USER_NAME = "yellow" +FG_USER_EMAIL = "yellow" +FG_USER_TAG = "red" +FG_SIZE = "cyan" +FG_TRACK_TITLE = "green" +FG_TRACK_SIZE = "bright_red" + + +def human_bool(value: bool) -> str: + """Formats a boolean for CLI output. + + :param value: The value to format. + :return: The string representing the bool. + """ + return "yes" if value else "no" + def setup(config_path: str) -> AppEnvironment: """Sets up the logging and app environment for the scripts. @@ -38,10 +57,15 @@ def cli(): """CLI main entry point.""" -@cli.command("useradd") +@cli.group("user") +def cmd_user(): + """Management functions for user accounts.""" + + +@cmd_user.command("add") @config_option -@click.option("--email", help="email address of the user", prompt=True) -@click.option("--name", help="name of the user", prompt=True) +@click.option("--email", help="Email address of the user.", prompt=True) +@click.option("--name", help="Name of the user.", prompt=True) @click.option( "--password", help="password of the user", @@ -49,9 +73,16 @@ def cli(): hide_input=True, confirmation_prompt=True, ) -@click.option("--admin", help="make the new user an admin", is_flag=True) +@click.option("--admin", help="Make the new user an admin.", is_flag=True) @click.pass_context -def cmd_useradd(ctx: click.Context, config: str, email: str, name: str, password: str, admin: bool): +def cmd_user_add( + ctx: click.Context, + config: str, + email: str, + name: str, + password: str, + admin: bool, +): """Create a new user. This user creation bypasses the "enable_account_registration" setting. It @@ -89,14 +120,14 @@ def cmd_useradd(ctx: click.Context, config: str, email: str, name: str, password click.echo(user_id) -@cli.command("userdel") +@cmd_user.command("del") @config_option -@click.option("--force", "-f", help="override the safety check", is_flag=True) +@click.option("--force", "-f", help="Override the safety check.", is_flag=True) @optgroup.group("User selection", cls=RequiredMutuallyExclusiveOptionGroup) -@optgroup.option("--id", "-i", "id_", help="database ID of the user", type=int) -@optgroup.option("--email", "-e", help="email of the user") +@optgroup.option("--id", "-i", "id_", help="Database ID of the user.", type=int) +@optgroup.option("--email", "-e", help="Email address of the user.") @click.pass_context -def cmd_userdel( +def user_del( ctx: click.Context, config: str, force: bool, @@ -121,7 +152,7 @@ def cmd_userdel( if user is None: click.echo("Error: No such user found.", err=True) ctx.exit(EXIT_FAILURE) - click.echo(user.name) + click.secho(user.name, fg=FG_USER_NAME) click.echo(user.email) if not force: if not click.confirm("Really delete this user?"): @@ -131,9 +162,9 @@ def cmd_userdel( click.echo("User deleted") -@cli.command("userlist") +@cmd_user.command("list") @config_option -def cmd_userlist(config: str): +def cmd_user_list(config: str): """Prints a listing of all user accounts. The format is: @@ -153,17 +184,20 @@ def cmd_userlist(config: str): "a" if user.is_admin else "-", "v" if user.is_verified else "-", ) - click.echo(f"{tag} {user.id} - {user.email} - {user.name}") + tag = click.style(tag, fg=FG_USER_TAG) + user_email = click.style(user.email, fg=FG_USER_EMAIL) + user_name = click.style(user.name, fg=FG_USER_NAME) + click.echo(f"{tag} {user.id} - {user_email} - {user_name}") -@cli.command("passwd") +@cmd_user.command("passwd") @config_option -@click.option("--password", help="password of the user") +@click.option("--password", help="Password of the user.") @optgroup.group("User selection", cls=RequiredMutuallyExclusiveOptionGroup) -@optgroup.option("--id", "-i", "id_", help="database ID of the user", type=int) -@optgroup.option("--email", "-e", help="email of the user") +@optgroup.option("--id", "-i", "id_", help="Database ID of the user,", type=int) +@optgroup.option("--email", "-e", help="Email address of the user.") @click.pass_context -def cmd_passwd( +def cms_user_passwd( ctx: click.Context, config: str, password: Optional[str], @@ -186,56 +220,68 @@ def cmd_passwd( password = click.prompt("Password", hide_input=True, confirmation_prompt=True) user.set_password(password) - click.echo(f"Changed password of {user.name} ({user.email})") + user_name = click.style(user.name, fg=FG_USER_NAME) + user_email = click.style(user.email, fg=FG_USER_EMAIL) + click.echo(f"Changed password of {user_name} ({user_email})") -@cli.command("maintenance-mode") +@cmd_user.command("modify") @config_option -@click.option("--disable", help="disable the maintenance mode", is_flag=True) -@click.argument("reason", required=False) +@click.option("--admin/--no-admin", help="Make the user an admin.", default=None) +@click.option("--verified/--no-verified", help="Set the user verification status.", default=None) +@optgroup.group("User selection", cls=RequiredMutuallyExclusiveOptionGroup) +@optgroup.option("--id", "-i", "id_", help="Database ID of the user.", type=int) +@optgroup.option("--email", "-e", help="Email address of the user.") @click.pass_context -def cmd_maintenance_mode(ctx: click.Context, config: str, disable: bool, reason: Optional[str]): - """Check the status of the maintenance mode, or activate or deactive it. - - When REASON is given, enables the maintenance mode with the given text as - reason. - - With neither --disable nor REASON given, just checks the state of the - maintenance mode. - """ +def cms_user_modify( + ctx: click.Context, + config: str, + admin: Optional[bool], + verified: Optional[bool], + id_: Optional[int], + email: Optional[str], +): + """Modify a user.""" env = setup(config) - data_manager = env["request"].data_manager - if disable and reason: - click.echo("Cannot enable and disable maintenance mode at the same time", err=True) - ctx.exit(EXIT_FAILURE) - elif not disable and not reason: - maintenance = data_manager.maintenance_mode() - if maintenance is None: - click.echo("Maintenance mode is disabled") - else: - click.echo(f"Maintenance mode is enabled: {maintenance}") - elif disable: - (data_manager.data_dir / "MAINTENANCE").unlink() + if id_ is not None: + query = select(models.User).filter_by(id=id_) else: - (data_manager.data_dir / "MAINTENANCE").write_text(reason, encoding="utf-8") + query = models.User.query_by_email(email) + with env["request"].tm: + dbsession = env["request"].dbsession + user = dbsession.execute(query).scalar_one_or_none() + if user is None: + click.echo("Error: No such user found.", err=True) + ctx.exit(EXIT_FAILURE) + if admin is not None: + user.is_admin = admin + if verified is not None: + user.is_verified = verified -@cli.command("hittekaart") + user_name = click.style(user.name, fg=FG_USER_NAME) + user_email = click.style(user.email, fg=FG_USER_EMAIL) + click.echo(f"{user_name} - {user_email}") + click.echo(f"Is admin: {human_bool(user.is_admin)}") + click.echo(f"Is verified: {human_bool(user.is_verified)}") + + +@cmd_user.command("hittekaart") @config_option @click.option( "--mode", "modes", - help="Heatmap type to generate", + help="Heatmap type to generate.", type=click.Choice([mode.value for mode in hittekaart.Mode]), multiple=True, default=["heatmap"], ) -@click.option("--delete", help="Delete the specified heatmap", is_flag=True) +@click.option("--delete", help="Delete the specified heatmap.", is_flag=True) @optgroup.group("User selection", cls=RequiredMutuallyExclusiveOptionGroup) -@optgroup.option("--id", "-i", "id_", help="database ID of the user", type=int) -@optgroup.option("--email", "-e", help="email of the user") +@optgroup.option("--id", "-i", "id_", help="Database ID of the user.", type=int) +@optgroup.option("--email", "-e", help="Email address of the user.") @click.pass_context -def cmd_hittekaart( +def cmd_user_hittekaart( ctx: click.Context, config: str, modes: list[str], @@ -273,7 +319,7 @@ def cmd_hittekaart( user_manager.tilehunt_path().unlink(missing_ok=True) return - click.echo(f"Generating overlay maps for {user.name}...") + click.echo(f"Generating overlay maps for {click.style(user.name, fg=FG_USER_NAME)}...") for mode in modes: hittekaart.generate_for( @@ -282,8 +328,119 @@ def cmd_hittekaart( click.echo(f"Generated {mode.value}") +@cli.group("track") +def cmd_track(): + """Management functions for tracks.""" + + +@cmd_track.command("list") +@config_option +def cmd_track_list(config: str): + """List all tracks that are present in the system.""" + env = setup(config) + total_size = 0 + total_tracks = 0 + with env["request"].tm: + dbsession = env["request"].dbsession + data_manager: DataManager = env["request"].data_manager + tracks = dbsession.execute(select(models.Track)).scalars() + for track in tracks: + total_tracks += 1 + try: + track_size = data_manager.open(track.id).size() + except FileNotFoundError: + size = "---" + else: + total_size += track_size + size = util.human_size(track_size) + size = click.style(f"{size:>10}", fg=FG_TRACK_SIZE) + owner_name = click.style(track.owner.name, fg=FG_USER_NAME) + owner_email = click.style(track.owner.email, fg=FG_USER_EMAIL) + track_title = click.style(track.title, fg=FG_TRACK_TITLE) + click.echo(f"{track.id:>4} - {size} - {owner_name} <{owner_email}> - {track_title}") + click.echo("-" * 80) + click.echo( + f"Total: {total_tracks} - {click.style(util.human_size(total_size), fg=FG_TRACK_SIZE)}" + ) + + +@cmd_track.command("del") +@config_option +@click.option("--force", "-f", help="Override the safety check.", is_flag=True) +@click.option("--id", "-i", "id_", help="Database ID of the track.", type=int, required=True) +@click.pass_context +def cmd_track_del( + ctx: click.Context, + config: str, + force: bool, + id_: Optional[int], +): + """Delete a track. + + This command deletes the track as well as any images and comments + associated with it. + + This command is destructive and irreversibly deletes data. + """ + env = setup(config) + query = select(models.Track).filter_by(id=id_) + with env["request"].tm: + dbsession = env["request"].dbsession + track = dbsession.execute(query).scalar_one_or_none() + if track is None: + click.echo("Error: No such track found.", err=True) + ctx.exit(EXIT_FAILURE) + click.secho(track.title, fg=FG_TRACK_TITLE) + if not force: + if not click.confirm("Really delete this track?"): + click.echo("Aborted by user.") + ctx.exit(EXIT_FAILURE) + try: + data = env["request"].data_manager.open(track.id) + except FileNotFoundError: + LOGGER.warning("Data directory not found for track - ignoring") + else: + data.purge() + dbsession.delete(track) + click.echo("Track deleted") + + +@cli.command("maintenance-mode") +@config_option +@click.option("--disable", help="Disable the maintenance mode.", is_flag=True) +@click.argument("reason", required=False) +@click.pass_context +def cmd_maintenance_mode(ctx: click.Context, config: str, disable: bool, reason: Optional[str]): + """Controls the maintenance mode. + + When REASON is given, enables the maintenance mode with the given text as + reason. + + With neither --disable nor REASON given, just checks the state of the + maintenance mode. + """ + env = setup(config) + data_manager = env["request"].data_manager + if disable and reason: + click.echo("Cannot enable and disable maintenance mode at the same time", err=True) + ctx.exit(EXIT_FAILURE) + elif not disable and not reason: + maintenance = data_manager.maintenance_mode() + if maintenance is None: + click.echo("Maintenance mode is disabled") + else: + click.echo(f"Maintenance mode is enabled: {maintenance}") + elif disable: + (data_manager.data_dir / "MAINTENANCE").unlink() + else: + (data_manager.data_dir / "MAINTENANCE").write_text(reason, encoding="utf-8") + + @cli.command("version") def cmd_version(): """Show the installed fietsboek version.""" name = __name__.split(".", 1)[0] print(f"{name} {__VERSION__}") + + +__all__ = ["cli"] diff --git a/fietsboek/util.py b/fietsboek/util.py index 199baab..04f5551 100644 --- a/fietsboek/util.py +++ b/fietsboek/util.py @@ -203,6 +203,29 @@ def mps_to_kph(mps: float) -> float: return mps / 1000 * 60 * 60 +def human_size(num_bytes: int) -> str: + """Formats the amount of bytes for human consumption. + + :param num_bytes: The amount of bytes. + :return: The formatted amount. + """ + num_bytes = float(num_bytes) + suffixes = ["B", "KiB", "MiB", "GiB"] + prefix = "" + if num_bytes < 0: + prefix = "-" + num_bytes = -num_bytes + for suffix in suffixes: + if num_bytes < 1024 or suffix == suffixes[-1]: + if suffix == "B": + # Don't do the decimal point for bytes + return f"{prefix}{int(num_bytes)} {suffix}" + return f"{prefix}{num_bytes:.1f} {suffix}" + num_bytes /= 1024 + # Unreachable: + return "" + + def month_name(request: Request, month: int) -> str: """Returns the localized name for the month with the given number. diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py index 1a56911..6dc8e7d 100644 --- a/tests/unit/test_util.py +++ b/tests/unit/test_util.py @@ -88,6 +88,22 @@ def test_tour_metadata(gpx_file): def test_mps_to_kph(mps, kph): assert util.mps_to_kph(mps) == pytest.approx(kph, 0.1) +@pytest.mark.parametrize('num_bytes, expected', [ + (1, '1 B'), + (1023, '1023 B'), + (1024, '1.0 KiB'), + (1536, '1.5 KiB'), + (1024 ** 2, '1.0 MiB'), + (1024 ** 3, '1.0 GiB'), + (0, '0 B'), + # Negative sizes in itself are a bit weird, but they make sense as the + # difference between two size values, so they should still work. + (-1, '-1 B'), + (-1024, '-1.0 KiB'), +]) +def test_human_size(num_bytes, expected): + assert util.human_size(num_bytes) == expected + def test_tile_url(app_request): route_url = util.tile_url(app_request, "tile-proxy", provider="bobby") @@ -44,13 +44,17 @@ commands = flake8 fietsboek [testenv:sphinx] -allowlist_externals = make +allowlist_externals = + make + mkdir changedir={toxinidir}{/}doc commands_pre = poetry install -v --with docs commands = sphinx-apidoc -d 1 -f -M -e -o developer/module/ ../fietsboek "upd_*" make html + mkdir -p _build/man + rst2man.py man/fietsctl.rst _build/man/fietsctl.1 [testenv:mypy] commands_pre = |