Tutorial

In this tutorial, we’ll write a command that greets people in different ways.

We start with the scaffolding (shebang, imports, …) and with a class that subclasses MainCommand, is instantiated and its method run is called:

#!/usr/bin/env python3
import hazwaz

class Greet(hazwaz.MainCommand):
    """
    Greet people in different ways.
    """

if __name__ == "__main__":
    Greet().run()

Save this in a file called greeter.py and run it, and it will print an help message where you can already see a couple of options, --verbose and debug, as well as the first line of the docstring used as the usage.

Now we add our first subcommand: we write a new class, subclassing Command and writing some code in its main method:

class World(hazwaz.Command):
    """
    Greet the whole world.
    """

    def main(self):
        print("Hello world!")

And we add an instance to the tuple of subcommands in our MainCommand:

class Greet(hazwaz.MainCommand):
    """
    Greet people in different ways.
    """
    commands = (
        World(),
    )

now if we run the program as ./greeter.py we see that there is a possible choice for a positional argument, world, and if we run ./greeter.py world we get, as expected, a greeting Hello world!.

With ./greeter.py world --help we can see the help message for this subcommand, and notice that the first line in the docstring has again been used as the usage notes.

Of course, a subcommand can also have options: we write a second subclass of Command and this time we add some argparser option in the add_arguments method:

class Individual(hazwaz.Command):
    """
    Greet an individual.
    """

    def add_arguments(self, parser):
        parser.add_argument(
            "gretee",
            help="The person to be greeted",
        )

    def main(self):
        print("Hello {}".format(self.args.gretee))

And again we add it to the tuple of subcommands:

class Greet(hazwaz.MainCommand):
    """
    Greet people in different ways.
    """
    commands = (
        World(),
        Individual(),
    )

You can then run the program as ./greeter.py individual Bob to see the new greeting.

add_arguments requires an argparse.ArgumentParser as its second parameter, and uses it to add arbitrary arguments, giving access to all argparse features.

Testing

Hazwaz provides the module hazwaz.unittest with helpers based on unittest to write unit tests for command line behaviour.

The class hazwaz.unittest.HazwazTestCase can be used instead of unittest.TestCase and works just as its parent: methods whose name start with test are run as individual tests, and you can use all the usual unittest assert methods [https://docs.python.org/3/library/unittest.html#assert-methods].

To write a test that runs the command as if from the command line, with certain parameters, you can use the method hazwaz.unittest.HazwazTestCase.run_with_argv() as in the following example:

import hazwaz.unittest

import greeter

class testGreeter(hazwaz.unittest.HazwazTestCase):
    def test_greet_world(self):
        cmd = greeter.Greet()
        stream = self.run_with_argv(cmd, [
            "./greeter.py",
            "world",
        ])

        self.assertEqual(
            stream["stdout"].getvalue(),
            "Hello world!\n"
        )

The first parameter should be the name of the command itself, as if this was the full command line.

If the tests are in their own module, there is a convienence function hazwaz.unittest.main() that runs unittest.main(), to be used e.g.:

if __name__ == "__main__":
    hazwaz.unittest.main()

However, if you’re writing a self-contained script you can use the command hazwaz.unittest.TestCommand to add a subcommand called test which runs all tests from a list of unittest.TestCase:

class Greet(hazwaz.MainCommand):
    """
    Greet people in different ways.
    """
    commands = (
        World(),
        Individual(),
        hazwaz.unittest.TestCommand([TestGreeter]),
    )

hazwaz

hazwaz package

Submodules

hazwaz.command module

Command

MainCommand

hazwaz.mixins module

ExternalEditorMixin

hazwaz.unittest module

HazwazTestCase

TestCommand

main()

hazwaz package

Submodules

hazwaz.command module

Command

Command.add_arguments()

Command.main()

Command.name

MainCommand

MainCommand.add_arguments()

MainCommand.coloredlogs

MainCommand.commands

MainCommand.logformat

MainCommand.main()

MainCommand.run()

MainCommand.setup_logging()

hazwaz.mixins module

ExternalEditorMixin

ExternalEditorMixin.edit_file_in_external_editor()

ExternalEditorMixin.editors

hazwaz.unittest module

HazwazTestCase

HazwazTestCase.run_with_argv()

TestCommand

TestCommand.main()

TestCommand.name

main()

hazwaz.command module

class hazwaz.command.Command

Bases: object

A subcommand to a MainCommand.

Every subcommand of your script will be a subclass of this, added to the MainCommand.subcommands.

add_arguments(parser: ArgumentParser)

Add argparse arguments to an existing parser.

Override this method to add arguments to a subcommand.

main()

Main code of this subcommand.

Override this method to implement the actual program.

name: Optional[str] = None

The name used to call this subcommand from the command line.

If this property is none, the default is the name of the class set to lowercase.

class hazwaz.command.MainCommand

Bases: object

The main class for a command line command.

Your script will have to subclass this once, instantiate and run its run() e.g. as:

class MyCommand(MainCommand):
    """
    A description that will be used in the help.
    """

if __name__ == "__main__":
    MyCommand().run()

add_arguments(parser: ArgumentParser)

Add argparse arguments to an existing parser.

If you need to override this method, you probably want to call super().add_arguments(parser) to add the default arguments.

coloredlogs: bool = True

Whether coloredlogs is used (if available)

commands: Iterable[Command] = ()

The subcommands: a tuple of Command subclasses.

logformat: str = '%(levelname)s:%(name)s: %(message)s'

The format passed to logging.Formatter.

main()

The main function for a command with no subcommands.

This default implementation that simply prints the help is good for most cases when there are subcommands and running the bare command doesn’t do anything.

run()

Run the command.

This is the method called to start running the command.

setup_logging()

hazwaz.mixins module

class hazwaz.mixins.ExternalEditorMixin

Bases: object

Add facilities to open a file in an external editor to a Command.

edit_file_in_external_editor(filepath: str) → bool

Open filepath in an external editor and wait for it to be closed.

Return whether opening the file was succesful. This tries to cycle through all editors listed in self.editors.

editors = [(None, '$EDITOR (set to {editor})'), ('sensible-editor', 'sensible-editor'), ('vi', 'vi')]

A list of editors to try.

Defaults to the value of $EDITOR, followed by sensible-editor, followed by vi as a last resort.

Each editor should be a tuple (<executable>, <name>), where <name> is printed in case of errors.

To write unittests that use this mixin, you can override this attribute with [("true", "true")].

hazwaz.unittest module

class hazwaz.unittest.HazwazTestCase(methodName='runTest')

Bases: TestCase

run_with_argv(cmd, argv: List[str]) → Dict[str, StringIO]

Run a command with a list of command line options.

Parameters:

argv – the full command line except for the program name, as a list of strings; e.g. ["subcommand", "--help"] or ["subcommand", "--option", "value"].

Returns:

stdout and stderr resulting from the command.

class hazwaz.unittest.TestCommand(test_cases: Iterable[TestCase])

Bases: Command

Run unittests.

main()

Main code of this subcommand.

Override this method to implement the actual program.

name: Optional[str] = 'test'

The name used to call this subcommand from the command line.

If this property is none, the default is the name of the class set to lowercase.

hazwaz.unittest.main()

Python Module Index

h

| h |
- | hazwaz |
|     hazwaz.command |
|     hazwaz.mixins |
|     hazwaz.unittest |

Index

A | C | E | H | L | M | N | R | S | T

A

add_arguments() (hazwaz.command.Command method)

(hazwaz.command.MainCommand method)

C

coloredlogs (hazwaz.command.MainCommand attribute)

Command (class in hazwaz.command)

commands (hazwaz.command.MainCommand attribute)

E

edit_file_in_external_editor() (hazwaz.mixins.ExternalEditorMixin method)

editors (hazwaz.mixins.ExternalEditorMixin attribute)

ExternalEditorMixin (class in hazwaz.mixins)

H

hazwaz

module

hazwaz.command

module

hazwaz.mixins

module

hazwaz.unittest

module

HazwazTestCase (class in hazwaz.unittest)

L

logformat (hazwaz.command.MainCommand attribute)

M

main() (hazwaz.command.Command method)

(hazwaz.command.MainCommand method)

(hazwaz.unittest.TestCommand method)

(in module hazwaz.unittest)

MainCommand (class in hazwaz.command)

module

hazwaz

hazwaz.command

hazwaz.mixins

hazwaz.unittest

N

name (hazwaz.command.Command attribute)

(hazwaz.unittest.TestCommand attribute)

R

run() (hazwaz.command.MainCommand method)

run_with_argv() (hazwaz.unittest.HazwazTestCase method)

S

setup_logging() (hazwaz.command.MainCommand method)

T

TestCommand (class in hazwaz.unittest)

All modules for which code is available

hazwaz.command

hazwaz.mixins

hazwaz.unittest

Source code for hazwaz.command

import argparse
import logging
import typing

try:
    import coloredlogs  # type: ignore
except ModuleNotFoundError:
    coloredlogs = None

def _get_first_docstring_line(obj):
    try:
        return obj.__doc__.split('\n')[1].strip()
    except (AttributeError, IndexError):
        return None

def _get_remaining_docstring_lines(obj):
    try:
        return "\n".join(obj.__doc__.split('\n')[2:]).strip()
    except (AttributeError, IndexError):
        return None

[docs]class MainCommand:
    '''
    The main class for a command line command.

    Your script will have to subclass this once, instantiate and run its
    :py:meth:`run()` e.g. as::

       class MyCommand(MainCommand):
           """
           A description that will be used in the help.
           """

       if __name__ == "__main__":
           MyCommand().run()
    '''

    commands: typing.Iterable["Command"] = ()
    """
    The subcommands: a tuple of :py:class:`Command` subclasses.
    """
    logformat: str = "%(levelname)s:%(name)s: %(message)s"
    """
    The format passed to logging.Formatter.
    """
    coloredlogs: bool = True
    """
    Whether coloredlogs is used (if available)
    """

    def __init__(self):
        desc = _get_first_docstring_line(self)
        epilog = _get_remaining_docstring_lines(self)
        self.parser = argparse.ArgumentParser(
            description=desc,
            epilog=epilog,
        )
        self.add_arguments(self.parser)
        self.parser.set_defaults(subcommand=self)
        self.subparsers = self.parser.add_subparsers()
        for sub in self.commands:
            sub_help = _get_first_docstring_line(sub)
            sub_epilog = _get_remaining_docstring_lines(sub)
            sub_parser = self.subparsers.add_parser(
                sub.name,
                description=sub_help,
                epilog=sub_epilog,
            )
            sub.add_arguments(sub_parser)
            sub_parser.set_defaults(subcommand=sub)

[docs]    def main(self):
        """
        The main function for a command with no subcommands.

        This default implementation that simply prints the help is good
        for most cases when there are subcommands and running the bare
        command doesn't do anything.
        """
        self.parser.print_help()

[docs]    def add_arguments(self, parser: argparse.ArgumentParser):
        """
        Add argparse arguments to an existing parser.

        If you need to override this method, you probably want to call
        super().add_arguments(parser) to add the default arguments.
        """
        group = parser.add_mutually_exclusive_group()
        group.add_argument(
            '--verbose', '-v',
            action='store_true',
            help="Show more details",
        )
        group.add_argument(
            '--debug',
            action='store_true',
            help="Show debug messages",
        )

[docs]    def setup_logging(self):
        if getattr(self.args, "debug", False):
            level = logging.DEBUG
        elif getattr(self.args, "verbose", False):
            level = logging.INFO
        else:
            level = logging.WARNING

        if self.coloredlogs and coloredlogs:
            coloredlogs.install(level=level, fmt=self.logformat)
        else:
            logger = logging.getLogger()
            handler = logging.StreamHandler()
            formatter = logging.Formatter(self.logformat)
            handler.setFormatter(formatter)
            logger.addHandler(handler)
            logger.setLevel(level)

[docs]    def run(self):
        """
        Run the command.

        This is the method called to start running the command.
        """
        self.args = self.parser.parse_args()
        self.setup_logging()

        self.args.subcommand.args = self.args
        self.args.subcommand.main()

[docs]class Command:
    """
    A subcommand to a MainCommand.

    Every subcommand of your script will be a subclass of this, added to
    the :py:attr:`MainCommand.subcommands`.
    """

    name: typing.Optional[str] = None
    """
    The name used to call this subcommand from the command line.

    If this property is none, the default is the name of the class set
    to lowercase.
    """

    def __init__(self):
        if self.name is None:
            self.name = self.__class__.__name__.lower()

[docs]    def add_arguments(self, parser: argparse.ArgumentParser):
        """
        Add argparse arguments to an existing parser.

        Override this method to add arguments to a subcommand.
        """
        pass

[docs]    def main(self):
        """
        Main code of this subcommand.

        Override this method to implement the actual program.
        """
        raise NotImplementedError

Source code for hazwaz.mixins

import logging
import os
import subprocess

[docs]class ExternalEditorMixin:
    """
    Add facilities to open a file in an external editor to a Command.
    """

    #: A list of editors to try.
    #:
    #: Defaults to the value of ``$EDITOR``, followed by
    #: ``sensible-editor``, followed by ``vi`` as a last resort.
    #:
    #: Each editor should be a tuple ``(<executable>, <name>)``, where
    #: ``<name>`` is printed in case of errors.
    #:
    #: To write unittests that use this mixin, you can override this
    #: attribute with ``[("true"), ("true")]``.
    editors = [
        (os.environ.get("EDITOR"), "$EDITOR (set to {editor})"),
        ("sensible-editor", "sensible-editor"),
        ("vi", "vi"),
    ]

[docs]    def edit_file_in_external_editor(self, filepath: str) -> bool:
        """
        Open filepath in an external editor and wait for it to be closed.

        Return whether opening the file was succesful.
        This tries to cycle through all editors listed in self.editors.
        """
        for editor, e_name in self.editors:
            if editor:
                try:
                    res = subprocess.call([editor, filepath])
                except FileNotFoundError:
                    logging.info('Could not open file {} with {}'.format(
                        filepath, e_name
                    ))
                else:
                    if res == 0:
                        return True
                    else:
                        return False
        return False

Source code for hazwaz.unittest

import contextlib
import io
import sys
import typing
import unittest

from .command import Command

[docs]class HazwazTestCase(unittest.TestCase):

[docs]    def run_with_argv(
            self, cmd, argv: typing.List[str]
    ) -> typing.Dict[str, io.StringIO]:
        """
        Run a command with a list of command line options.

        :param argv: the full command line except for the program name,
                     as a list of strings; e.g. ``["subcommand",
                     "--help"]`` or ``["subcommand", "--option",
                     "value"]``.

        :return: stdout and stderr resulting from the command.
        """
        stream = {
            'stdout': io.StringIO(),
            'stderr': io.StringIO(),
        }
        old_argv = sys.argv
        sys.argv = argv
        with contextlib.redirect_stdout(stream['stdout']):
            with contextlib.redirect_stderr(stream['stderr']):
                cmd.run()
        sys.argv = old_argv
        return stream

[docs]class TestCommand(Command):
    """
    Run unittests.
    """
    name = "test"

    def __init__(self, test_cases: typing.Iterable[unittest.TestCase]):
        self.test_cases = test_cases
        super().__init__()

[docs]    def main(self):
        suite = unittest.TestSuite()
        for test_case in self.test_cases:
            suite.addTests(
                unittest.TestLoader().loadTestsFromTestCase(test_case)
            )
        unittest.TextTestRunner(verbosity=1).run(suite)

[docs]def main():
    unittest.main()