diff options
| author | Daniel Schadt <kingdread@gmx.de> | 2023-05-25 21:22:42 +0200 | 
|---|---|---|
| committer | Daniel Schadt <kingdread@gmx.de> | 2023-05-25 21:22:42 +0200 | 
| commit | fb3eabd184cd5b75653d7cb1e0fe3a1ac4b748e3 (patch) | |
| tree | 7183f06bf835bc63a98b5c247fa8d783a9bbcec8 | |
| parent | e1aa29d92094aafe5109c9fb42e9146bc25ce380 (diff) | |
| parent | 8160c375f448f4e6e32518d632e3bc0d139f0130 (diff) | |
| download | fietsboek-fb3eabd184cd5b75653d7cb1e0fe3a1ac4b748e3.tar.gz fietsboek-fb3eabd184cd5b75653d7cb1e0fe3a1ac4b748e3.tar.bz2 fietsboek-fb3eabd184cd5b75653d7cb1e0fe3a1ac4b748e3.zip  | |
Merge branch 'fietsctl-commands'
| -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 =  | 
