2
\$\begingroup\$

To learn more about design patterns, particularly the Model-View-Presenter (MVP) pattern, I am currently creating a CLI weight tracker application in Python. I would like you to review the code to determine if it adheres to the MVP pattern.

The code or business logic itself is not finished yet. The application can currently only display the version and initialize a database. However, for the purpose of reviewing the MVP implementation I believe the code is easier to follow without a ton of fucntions.

Additionally, the model.py module currently holds all the business logic. In a real-world application I would separate that into distinct modules. But in this self-made MVP tutorial, I have chosen to focus primarily on the three conceptual aspects of the pattern.

Thanks in advance!

Module __init__.py

__app_name__ = "Weight tracker"
__version__ = "0.1.0"

DEFAULT_DATABASE_PATH = "./data/storage.json"
DEFAULT_CONFIG_PATH = "./data/.config.ini"

Module __main__.py

from weight_tracker import view, presenter


def main() -> None:
    presenter.run()


if __name__ == "__main__":
    main()

Module view.py

import typer
from typing import Optional
from weight_tracker import presenter, DEFAULT_DATABASE_PATH

app = typer.Typer()


class TextColor:
    DEFAULT = typer.colors.BLUE
    ERROR = typer.colors.RED
    SUCCESS = typer.colors.GREEN


def mainloop() -> None:
    app()


def exit_console() -> None:
    raise typer.Exit()


def write_to_console(text: str, color=TextColor.DEFAULT) -> None:
    typer.secho(
        text,
        fg=color,
    )


@app.command(name="init")
def initialize_database(
        db_path: Optional[str] = typer.Option(
            DEFAULT_DATABASE_PATH,
            "--db_path",
            "-db",
            help="Create a new database.",
            callback=presenter.init_app,
        )
) -> None:
    return


@app.callback()
def global_options(
        version: Optional[bool] = typer.Option(
            None,
            "--version",
            "-v",
            help="Show installed application version.",
            callback=presenter.version_callback,
            is_eager=True,
        )
) -> None:
    return

Module presenter.py

from weight_tracker import view
from weight_tracker import model


def init_app(db_path: str) -> None:
    if db_path:
        initialize_database(db_path)
        initialize_configuration_file(db_path)


def initialize_database(db_path: str) -> None:
    try:
        model.create_empty_database(db_path)
        view.write_to_console(f"Created empty storage at {db_path}", view.TextColor.SUCCESS)
    except OSError:
        view.write_to_console("Failed to create storage.json. file", view.TextColor.ERROR)


def initialize_configuration_file(db_path: str) -> None:
    try:
        model.write_db_path_to_config(db_path)
        view.write_to_console(f"Updated database path to {db_path}", view.TextColor.SUCCESS)
    except OSError:
        view.write_to_console("Failed to create config.ini file.", view.TextColor.ERROR)


def version_callback(called: bool) -> None:
    if called:
        view.write_to_console(model.get_version())
        view.exit_console()


def run() -> None:
    view.mainloop()

Module model.py

from weight_tracker import __app_name__, __version__
from pathlib import Path


EMPTY_LIST = "[]"


# Application model
def get_version() -> str:
    return f"{__app_name__} v{__version__}"


# Database model
def create_empty_database(db_path: str) -> None:
    Path(db_path).write_text(EMPTY_LIST)


# Configuration model
def write_db_path_to_config(db_path: str) -> None:
    ...  # todo
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

It passes mypy, yay! Nice annotations.


A small quibble, a tiny nit. Say what you mean and mean what you say.

DEFAULT_DATABASE_PATH = "./data/storage.json"

That's a lovely identifier, very informative. Except that we're storing a str rather than a more expressive Path.

It is helpful to the Gentle Reader to see functions accepting a Path argument rather than a more ambiguous str, and helpful to the Author since .exists() and many other convenience methods are available. The / slash catenation operator is especially useful: folder / "ReadMe.txt"


I am skeptical that def mainloop() is pulling its weight, here, but OK whatever. The issue seems to be one of scope for app. It is needed for the decorator usages which look very nice, and for kicking things off. Consider shuffling things around a bit to avoid the awkward scope detail.

Maybe def exit_console() is good? But it, too, might be deleted. OTOH if there's some documented scheme you're adhering to which requires these two, great, just cite its URL so we know.


@app.command(name="init")
def initialize_database(

Consider renaming, so there's no need for the optional name parameter.

Consider deleting, as this doesn't appear to do anything.

As a command line user I would find -d (or --db) less confusing than -db which suggests it's equivalent to -d -b. Perhaps there's no need for a short form?

            callback=presenter.init_app,

I didn't notice a corresponding import for that reference.

            is_eager=True,

I'm trying to imagine what would happen if this were set to False. Yup, not seeing it, though the typer tutorial obliquely mentions it. This is a slightly subtle aspect. It would be worth a # comment, or a unit test which highlights what goes wrong if this parameter is absent.


    except OSError:
        view.write_to_console("Failed to create storage.json. file", view.TextColor.ERROR)

Imagine that this triggers. The first thing a customer or maintenance engineer is going to want to know is "which error?".

The happy path is super informative: at {db_path}. Make the sad path at least that informative. Volunteer the details, so we don't have to repeatedly reproduce the issue.

Similarly when writing config.ini.


def initialize_configuration_file(db_path: str) -> None:

No. This is just wrong. Clearly it's a cfg_path, not a database. It's bad when # comments lie, but worse when identifiers do that. I doubt you meant to print "Updated database path". Just chalk it up to copy-pasta -- happens to everyone. That's why we solicit reviews.


def get_version() -> str:
    return f"{__app_name__} v{__version__}"

Woooo, this is kind of interesting.

Almost an existential question. Is the version 1.0, or is it v1.0 ? I literally dealt with that issue in a CI/CD toolchain for more than a year across a dozen related products. Some tools would unconditionally add the letter (vv1.0!), some conditionally, some passed along the input unchanged. Finally they settled on one answer.

I recommend that you not "helpfully" insert a v there. Put a stake in the ground. Either we have (roughly) three SemVer numbers, or we define and document our own scheme which is free to insist on initial v if desired.

But don't try to support a pair of related versioning schemes. Life is too short.


These modules appear to achieve the project's design goals.

I would be willing to delegate or accept maintenance tasks on this codebase.

\$\endgroup\$
1
  • \$\begingroup\$ Thank you very much for your detailed and very valuable feedback! Regarding the function initialize_configuration_file(db_path: str) I indeed meant to pass and write the database path into a configuration file that has a kind of static path. But thanks to your feedback I can see that my naming is absolutely misleading and should therefore be changed to something like write_db_path_to_config(db_path: str). \$\endgroup\$
    – J. Lo
    Commented Jul 26, 2023 at 19:02

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.