Von Click zurück zu argparse

Um meinen Python-Programmen ein Kommandozeileninterface zu geben, habe ich in letzter Zeit Click genutzt. Das ist als Ersatz für das Modul argparse aus der Python-Standardbibliothek gedacht. Zuerst war es mir suspekt, weil man es mit Dekoratoren baut. Aber mit der Zeit fand ich es ziemlich elegant. So sieht das hier für die Skripte mit denen ich meinen Blog verwalte aus:

@click.group()
@click.option(
    "--loglevel",
    type=click.Choice(["debug", "info", "warning", "error", "critical"]),
    default="info",
    show_default=True,
    help="Controls the verbosity of logging.",
)
def main(loglevel: str):
    """
    Utilities for my Nikola blog.
    """
    coloredlogs.install(level=loglevel.upper())


@main.command()
@click.option("--fix/--no-fix", default=True, help="Apply automatic fixes.", show_default=True)
@click.option("--validate/--no-validate", default=True, help="Perform validation of post meta-data.", show_default=True)
@click.option("--publish/--no-publish", default=False, help="Publish an automatically determined amount of posts today.", show_default=True)
@click.option("--build/--no-build", default=True, help="Build the blog with Nikola.", show_default=True)
@click.option("--upload/--no-upload", default=False, help="Upload the generated files to the live server.", show_default=True)
def make(fix: bool, validate: bool, publish: bool, build: bool, upload: bool) -> None:
    """
    Build the website and other auxiliary files.

    There are a couple of stages which can be enabled or skipped with the additional flags.
    """

    # …


@main.command()
def gui():
    """
    Start the GUI with Kanban board.
    """
    from .qtgui import main
    main()


if __name__ == "__main__":
    main()

Schaut man sich die Hilfe zum Haupt-Kommando an, dann bekommt man das hier:

❯ blog --help
Usage: blog [OPTIONS] COMMAND [ARGS]...

  Utilities for my Nikola blog.

Options:
  --loglevel [debug|info|warning|error|critical]
                                  Controls the verbosity of logging.
                                  [default: info]
  --help                          Show this message and exit.

Commands:
  append-images
  auto-move
  gui            Start the GUI with Kanban board.
  make           Build the website and other auxiliary files.
  move
  new
  shrink-images  Shrink images in the `raw` directory to just 2 megapixel...
  tag-cloud

Aus den Docstrings wird automatisch die Hilfe für das Hauptkommando extrahiert, sowie für die Unterkommandos die Zusammenfassung. Man muss also relativ wenig machen und hat schon eine vernünftige Hilfe.

Auch bei den Unterkommandos bekommt man eine Hilfe. Hier wird dann der komplette Docstring angezeigt.

❯ blog make --help
Usage: blog make [OPTIONS]

  Build the website and other auxiliary files.

  There are a couple of stages which can be enabled or skipped with the
  additional flags.

Options:
  --fix / --no-fix            Apply automatic fixes.  [default: fix]
  --validate / --no-validate  Perform validation of post meta-data.  [default:
                              validate]
  --publish / --no-publish    Publish an automatically determined amount of
                              posts today.  [default: no-publish]
  --build / --no-build        Build the blog with Nikola.  [default: build]
  --upload / --no-upload      Upload the generated files to the live server.
                              [default: no-upload]
  --help                      Show this message and exit.

Das ist alles ziemlich hübsch aufbereitet. Auch toll sind die Flag-Kombinationen aus --fix und --no-fix, mit denen man umschalten kann.

Praktisch ist, dass man nur ein paar Dekoratoren nutzen muss, und schon hat man ein Interface mit mehreren Unterkommandos. Auch die Docstrings werden entsprechend verteilt. Das unschöne daran ist allerdings, dass das Kommandozeileninterface damit dann aber so verteilt ist. Man muss sich hier wohl entscheiden, was man zusammengruppieren möchte: Soll das Kommandozeileninterface in einem zusammengepackt sein, oder will man lieber den Code und das Interface stärker zusammen binden?

Das gleiche mit argparse

Generell stört mich an Click, dass es einfach eine Abhängigkeit mehr ist. Und dadurch ergeben sich dann mehr Versionskonflikte, als nötig. Steamlit nutzt auch Click, und zwar meist in einer etwas älteren Version. Damit war ich dann auf Click 7 festgenagelt, obwohl Click 8 auch schon erschienen war. Ich versuche eigentlich solange mit der Standardbibliothek zu arbeiten, solange es geht. Und eigentlich ist argparse in Python auch ziemlich ordentlich.

Hier ist das gleiche mit argparse implementiert. Da haben wir alle Unterkommandos in einem definiert, man hat das ganze Interface in einer Funktion gesammelt.

def main() -> None:
    parser = argparse.ArgumentParser(
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        description='Utilities for my Nikola blog.'
    )
    parser.add_argument(
        "--loglevel",
        choices=["debug", "info", "warning", "error", "critical"],
        default="info",
    )
    parser.set_defaults(func=None)

    sub_parsers = parser.add_subparsers(help="Sub-commands")

    new_parser = sub_parsers.add_parser("new", help="Create a new blog entry.")
    new_parser.add_argument("title")
    new_parser.set_defaults(func=lambda options: templates.make_post(options.title))

    make_parser = sub_parsers.add_parser(
        "make", help="Build the website and other auxiliary files."
    )
    make_parser.add_argument(
        "--fix",
        default=True,
        action=argparse.BooleanOptionalAction,
        help="Apply automatic fixes.",
    )
    make_parser.add_argument(
        "--validate",
        default=True,
        action=argparse.BooleanOptionalAction,
        help="Perform validation of post meta-data.",
    )
    make_parser.add_argument(
        "--publish",
        default=False,
        action=argparse.BooleanOptionalAction,
        help="Publish an automatically determined amount of posts today.",
    )
    make_parser.add_argument(
        "--build",
        default=True,
        action=argparse.BooleanOptionalAction,
        help="Build the blog with Nikola.",
    )
    make_parser.add_argument(
        "--upload",
        default=False,
        action=argparse.BooleanOptionalAction,
        help="Upload the generated files to the live server.",
    )
    make_parser.set_defaults(
        func=lambda options: make(
            options.fix,
            options.validate,
            options.publish,
            options.build,
            options.upload,
        )
    )

    move_parser = sub_parsers.add_parser("move", help="Rename a blog post.")
    move_parser.add_argument("source")
    move_parser.add_argument("destination")
    move_parser.set_defaults(
        func=lambda options: moving.move(options.source, options.destination)
    )

    auto_move_parser = sub_parsers.add_parser(
        "auto-move", help="Automatically move blog posts to match their titles."
    )
    auto_move_parser.set_defaults(func=lambda options: auto_move())

    append_images_parser = sub_parsers.add_parser(
        "append-images", help="Append given images to a blog post."
    )
    append_images_parser.add_argument("post")
    append_images_parser.add_argument("images", metavar="image", nargs=-1)
    append_images_parser.set_defaults(
        func=lambda options: append_images(options.post, options.images)
    )

    shrink_images_parser = sub_parsers.add_parser(
        "shrink-images", help="Shrink images in the `raw` directory."
    )
    shrink_images_parser.set_defaults(func=lambda options: shrink_images())

    gui_parser = sub_parsers.add_parser("gui", help="Start the Kanban board GUI.")
    gui_parser.set_defaults(func=lambda options: gui())

    options = parser.parse_args()

    coloredlogs.install(level=options.loglevel.upper())

    options.func(options)

Der restliche Code weiß jetzt nichts mehr vom Interface. Das kann besser sein, das kann aber auch schlechter sein. Meine Hoffnung in Click war, dass man damit wirklich auch Unterkommandos in anderen Dateien definieren kann. Das klappt aber nicht so wirklich. Mit Argparse wäre das sogar noch eher möglich, man könnte den Sub-Parser in einer anderen Datei zusammenbauen und das Lambda für den Aufruf dort einhängen. An sich wäre das also sogar noch flexibler, glaube ich.

Die Ausgabe ist allerdings nicht ganz so hübsch, wie bei Click:

❯ poetry run blog --help
usage: blog [-h] [--loglevel {debug,info,warning,error,critical}] {new,make,move,auto-move,append-images,shrink-images,gui} ...

Utilities for my Nikola blog.

positional arguments:
  {new,make,move,auto-move,append-images,shrink-images,gui}
                        Sub-commands
    new                 Create a new blog entry.
    make                Build the website and other auxiliary files.
    move                Rename a blog post.
    auto-move           Automatically move blog posts to match their titles.
    append-images       Append given images to a blog post.
    shrink-images       Shrink images in the `raw` directory.
    gui                 Start the Kanban board GUI.

options:
  -h, --help            show this help message and exit
  --loglevel {debug,info,warning,error,critical}

Und für das Unterkommando:

 poetry run blog make --help
usage: blog make [-h] [--fix | --no-fix] [--validate | --no-validate] [--publish | --no-publish] [--build | --no-build] [--upload | --no-upload]

options:
  -h, --help            show this help message and exit
  --fix, --no-fix       Apply automatic fixes. (default: True)
  --validate, --no-validate
                        Perform validation of post meta-data. (default: True)
  --publish, --no-publish
                        Publish an automatically determined amount of posts today. (default: False)
  --build, --no-build   Build the blog with Nikola. (default: True)
  --upload, --no-upload
                        Upload the generated files to the live server. (default: False)

So richtig ersichtlich wird das mit den Unterkommandos bei Argparse nicht, das hat Click deutlich besser gemacht. Das kann man bei Argparse im Prinzip auch mit angepassten Formatierern ändern, bei Click ist das einfach nicht nötig.

Ob einem das jetzt eine zusätzliche Abhängigkeit wert ist, muss man selbst entscheiden. Ich denke, ich werde versuchen möglichst darauf zu verzichten, um die Abhängigkeiten möglichst schlank zu halten.