A Python Script Template, with and without Type Hints and Async

2021-10-09 Read on for a “cat trick”!

Python is great for writing scripts for the command line. In this post we’ll look at my script template, an example script, and some alternative versions (without type hints, and in async flavour).

The Template

Voici:

#!/usr/bin/env python
from __future__ import annotations

import argparse
from collections.abc import Sequence


def main(argv: Sequence[str] | None = None) -> int:
    parser = argparse.ArgumentParser()
    # Add arguments here
    args = parser.parse_args(argv)

    # Implement behaviour here

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

This works with Python 3.7+.

Alternative versions below:

Here’s what’s going on (in detail):

An Example Cat

Here’s an example script that partially replicates the cat utility. cat outputs the content of one or more files, with several options. Our example implements this core functionality, plus the -n flag to add line numbering:

#!/usr/bin/env python
from __future__ import annotations

import argparse
from collections.abc import Sequence
from io import TextIOWrapper


def main(argv: Sequence[str] | None = None) -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("-n", action="store_true", dest="number")
    parser.add_argument("file", type=argparse.FileType("r"), nargs="+")
    args = parser.parse_args(argv)

    number: bool = args.number
    files: list[TextIOWrapper] = args.file

    for file in files:
        for i, line in enumerate(file, start=1):
            if number:
                print(f"{i: >6} ", end="")
            print(line, end="")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

We can see:

Here are cat and our example in action:

$ cat hello1.txt
Hello:
    World!
    Adam!
$ ./example.py hello1.txt
Hello:
    World!
    Adam!

And with line numbering:

$ ./example.py -n hello1.txt hello2.txt
     1 Hello:
     2     World!
     3     Adam!
     1 Hello:
     2     Pythonista!
     3     Reader!

Sah-weet.

Testing Our Example Script

We can write tests for our script by importing main() and calling it with a list of arguments. Here are some example tests using pytest:

from example import main


def test_single_file(capsys, tmp_path):
    file = tmp_path / "test.txt"
    file.write_text("Hello world!\n")

    result = main([str(file)])

    assert result == 0
    out, err = capsys.readouterr()
    assert out == "Hello world!\n"
    assert err == ""


def test_single_file_with_numbering(capsys, tmp_path):
    file = tmp_path / "test.txt"
    file.write_text("Hello world!\n")

    result = main(["-n", str(file)])

    assert result == 0
    out, err = capsys.readouterr()
    assert out == "     1 Hello world!\n"
    assert err == ""

Note:

Running the tests we see:

$ pytest -v test_example.py
========================= test session starts =========================
platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.10.0, pluggy-1.0.0
cachedir: .pytest_cache
rootdir: /Users/chainz/tmp/script-templates
collected 2 items

test_example.py::test_single_file PASSED                        [ 50%]
test_example.py::test_single_file_with_numbering PASSED         [100%]

========================== 2 passed in 0.01s ==========================

😎

The Template Sans Type Hints

If you’re not (yet) comfortable writing type hints, here’s a version without them:

#!/usr/bin/env python
import argparse


def main(argv=None):
    parser = argparse.ArgumentParser()
    # Add arguments here
    args = parser.parse_args(argv)

    # Implement behaviour here

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

This should work on Python 2.7, or even earlier (I didn’t check).

(Also, learn more about type hints with my Mypy posts!)

The Template But In Async Flavour

This async version uses async def for main(), and asyncio.run() to run it:

#!/usr/bin/env python
from __future__ import annotations

import argparse
import asyncio
from collections.abc import Sequence


async def main(argv: Sequence[str] | None = None) -> int:
    parser = argparse.ArgumentParser()
    # Add arguments here
    args = parser.parse_args(argv)

    # Implement behaviour here

    return 0


if __name__ == "__main__":
    raise SystemExit(asyncio.run(main()))

This works on Python 3.7+.

(We can also swap to the run() functions of one of the alternative async libraries AnyIO and Trio.)

Fin

May this template help you write some awesome scripts,

—Adam


If your Django project’s long test runs bore you, my book can help.


Subscribe via RSS, Twitter, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: mypy, python