Skip to main content

Command line interface builder

Project description

📜 Cline

Cline is a Python package that helps you build command line applications by separating the concerns of understanding the command line arguments you receive and operating on strongly-typed arguments, and helps you to write clean, testable tasks.

Getting started

Installation

Cline requires Python 3.8 or later.

Install Cline via pip:

pip install cline

Usage

  1. Create a Cli class to parse arguments and orchestrate tasks.
  2. Create Task classes to describe the work that your application can perform.
  3. On startup, invoke your CLI's invoke_and_exit() method. Cline will discover and invoke the most appropriate Task for the given arguments.

The examples below go into detail.

Examples

Example 1: Summing two integers

The source code for this example is available at github.com/cariad/cline/blob/main/examples/example01. The tests are in github.com/cariad/cline/blob/main/tests/examples/example01.

In this example, we'll build a command line application that sums two integers then prints the result:

python -m examples.example01 1 3
4

1. Create a CLI class with an argument parser

For this example, we'll parse the command line arguments with Python's baked-in ArgumentParser. Cline provides an ArgumentParserCli base class for this.

Create a class named ExampleCli that inherits from cline.ArgumentParserCli. Override make_parser() to return an ArgumentParser that accepts two numbers.

For example:

from argparse import ArgumentParser
from cline import ArgumentParserCli

class ExampleCli(ArgumentParserCli):
    def make_parser(self) -> ArgumentParser:
        parser = ArgumentParser()
        parser.add_argument(
          "a",
          help="first number",
          nargs="?",
        )
        parser.add_argument(
          "b",
          help="second number",
          nargs="?",
        )
        return parser

2. Creating a task

A task does two things:

  1. Converts the command line arguments to strongly-typed task arguments
  2. Operates on strongly-typed task arguments

We'll start with the strongly-typed task arguments. Since we'll be summing two integers, let's define a dataclass that holds two integers:

from dataclasses import dataclass

@dataclass
class NumberArgs:
    a: int
    b: int

Now we need a way to convert command line arguments to these task arguments. Create a class named SumTask and inherit from cline.Task. Task is generic and requires your strongly-typed arguments type.

from dataclasses import dataclass
from cline import CommandLineArguments, Task

@dataclass
class NumberArgs:
    a: int
    b: int

class SumTask(Task[NumberArgs]):
    pass

To convert command line arguments to strongly-typed task arguments, override the make_args() method:

from dataclasses import dataclass
from cline import CommandLineArguments, Task

@dataclass
class NumberArgs:
    a: int
    b: int

class SumTask(Task[NumberArgs]):
    @classmethod
    def make_args(cls, args: CommandLineArguments) -> NumberArgs:
        return NumberArgs(
            a=args.get_integer("a"),
            b=args.get_integer("b"),
        )

CommandLineArguments wraps the parsed command line arguments with some handy functions for reading argument values.

For example, since we named the two numbers a and b in the parser earlier, we can refer to them by name and call get_integer() to get their values.

Note that CommandLineArguments will raise CannotMakeArguments if a or b aren't set or can't be parsed as integers. This is the correct way to indicate that task arguments cannot be made for a given task. You don't need to validate the arguments yourself.

Now that SumTask knows how to create strongly-typed arguments, we can operate on them.

Override the invoke() method to:

  1. Operate on self.args
  2. Write output to self.out
  3. Return the shell exit code

In this example, invoke() sums the two integer arguments, writes the result, then returns 0 to indicate success:

from dataclasses import dataclass
from cline import CommandLineArguments, Task

@dataclass
class NumberArgs:
    a: int
    b: int

class SumTask(Task[NumberArgs]):
    @classmethod
    def make_args(cls, args: CommandLineArguments) -> NumberArgs:
        return NumberArgs(
            a=args.get_integer("a"),
            b=args.get_integer("b"),
        )

    def invoke(self) -> int:
        result = self.args.a + self.args.b
        self.out.write(f"{result}\n")
        return 0

3. Registering the task with the CLI

Back in your CLI class, override register_tasks() to register SumTask:

from argparse import ArgumentParser
from cline import ArgumentParserCli
from examples.example01.sum import SumTask

class ExampleCli(ArgumentParserCli):
    def make_parser(self) -> ArgumentParser:
        parser = ArgumentParser()
        parser.add_argument(
          "a",
          help="first number",
          nargs="?",
        )
        parser.add_argument(
          "b",
          help="second number",
          nargs="?",
        )
        return parser

    def register_tasks(self) -> RegisteredTasks:
        return [
            SumTask,
        ]

4. Creating a CLI entry point

Finally, create a __main__.py script that calls your CLI's invoke_and_exit() method:

from examples.example01.cli import ExampleCli

def entry() -> None:
    ExampleCli.invoke_and_exit()

if __name__ == "__main__":
    entry()

And that's it! You can now sum integers on the command line:

python -m examples.example01 1 3
4

5. Unit testing

Cline was designed to support easy high coverage of your work.

To test your task's make_args() function, construct your own CommandLineArguments instance with the command line arguments to test, then verify that make_args() returns the strongly-typed arguments that you'd expect:

from cline import CommandLineArguments
from examples.example01.sum import SumTask, NumberArgs

def test_make_args() -> None:
    cli_args = CommandLineArguments(
        {
            "a": "1",
            "b": "2",
        }
    )

    args = SumTask.make_args(cli_args)
    assert args == NumberArgs(a=1, b=2)

To test your task's invoke() function, construct your own strongly-typed arguments and an output writer then assert that the shell exit code and written response are as you expect:

from io import StringIO
from examples.example01.sum import SumTask, NumberArgs

def test_invoke() -> None:
    out = StringIO()
    task = SumTask(args=NumberArgs(a=5, b=2), out=out)
    assert task.invoke() == 0
    assert out.getvalue() == "7\n"

To test that the CLI will invoke your task for a given set of command line arguments, you can instantiate your CLI with the arguments to test then assert that the .task and .task.args properties are as you expect:

from typing import List, Type

from pytest import mark

from cline import AnyTask
from examples.example01.cli import ExampleCli
from examples.example01.tasks import NumberArgs, SumTask

@mark.parametrize(
    "args, expect_task, expect_args",
    [
        (["1", "2"], SumTask, NumberArgs(a=1, b=2)),
    ],
)
def test(
    args: List[str],
    expect_task: Type[AnyTask],
    expect_args: NumberArgs,
) -> None:
    cli = ExampleCli(args=args)
    assert isinstance(cli.task, expect_task)
    assert cli.task.args == expect_args

Example 2: Adding support for subtraction

The source code for this example is available at github.com/cariad/cline/blob/main/examples/example02. The tests are in github.com/cariad/cline/blob/main/tests/examples/example02.

In this example, we'll build on Example 1 to allow integers to be subtracted. We'll add --sum and --sub flags to describe that we want to do.

For example:

python -m examples.example02 --sum 8 5
python -m examples.example02 --sub 8 5
13
3

1. Add the new flags to the argument parser

In your CLI class, update the argument parser to support --sum and --sub flags:

def make_parser(self) -> ArgumentParser:
    parser = ArgumentParser()
    parser.add_argument("a", help="first number", nargs="?")
    parser.add_argument("b", help="second number", nargs="?")
    parser.add_argument(
        "--sub",
        help="subtract",
        action="store_true",
    )
    parser.add_argument(
        "--sum",
        help="sum",
        action="store_true",
    )
    return parser

Note that the new arguments are presented as --sub and --sum here, but their names omit the dashes. The new arguments are named sub and sum.

2. Update SumTask to require the "--sum" flag

In the previous example, we wanted SumTask to always run if we gave it two numbers. Now we want it to run only if we give it two numbers and the --sum flag.

We can achieve this simply by calling assert_true() in the task's make_args() function, passing the name of the flag we want to assert is truthy:

@classmethod
def make_args(cls, args: CommandLineArguments) -> NumberArgs:
    args.assert_true("sum")

    return NumberArgs(
        a=args.get_integer("a"),
        b=args.get_integer("b"),
    )

3. Create the SubtractTask class

To perform subtraction, we'll need a SubtractTask class. This is almost identical to SumTask, expect:

  1. We want to assert that the sub (not sum) flag is truthy
  2. We want to subtract (not sum) the arguments

So, for example:

class SubtractTask(Task[NumberArgs]):
    @classmethod
    def make_args(cls, args: CommandLineArguments) -> NumberArgs:
        args.assert_true("sub")

        return NumberArgs(
            a=args.get_integer("a"),
            b=args.get_integer("b"),
        )

    def invoke(self) -> int:
        result = self.args.a - self.args.b
        self.out.write(f"{result}\n")
        return 0

4. Register the SubtractTask class

Open your CLI class and register SubtractTask:

def register_tasks(self) -> RegisteredTasks:
    return [
        examples.example02.tasks.SubtractTask,
        examples.example02.tasks.SumTask,
    ]

...and that's it! When your application runs, Cline will try each registered task in order until one is found that accepts the given command line arguments, then executes it:

python -m examples.example02 --sum 8 5
python -m examples.example02 --sub 8 5
13
3

Example 3: Supporting help and versions

The source code for this example is available at github.com/cariad/cline/blob/main/examples/example03. The tests are in github.com/cariad/cline/blob/main/tests/examples/example03.

Cline has baked-in support for printing your application's help and version on the command line.

Enabling support for help is easy enough: it's already enabled. If Cline cannot find any registered tasks that can handle the given command line arguments then it will print the argument parser's help.

For example, if we run one of the above examples without specifying any numbers then each task's make_args() call will fail and Cline will fall back to displaying the help:

python -m examples.example01
usage: __main__.py [-h] [a] [b]

positional arguments:
  a           first number
  b           second number

options:
  -h, --help  show this help message and exit

Adding support for version printing is a two-step process. First, add a --version flag to your argument parser:

def make_parser(self) -> ArgumentParser:
    parser = ArgumentParser()
    parser.add_argument("a", help="first number", nargs="?")
    parser.add_argument("b", help="second number", nargs="?")
    parser.add_argument(
        "--sub",
        help="subtracts numbers",
        action="store_true",
    )
    parser.add_argument(
        "--sum",
        help="sums numbers",
        action="store_true",
    )
    parser.add_argument(
        "--version",
        help="show version",
        action="store_true",
    )
    return parser

Then in __main__.py, pass your application's version into invoke_and_exit():

from examples.full import __version__
from examples.full.cli import ExampleCli

def entry() -> None:
    ExampleCli.invoke_and_exit(app_version=__version__)

if __name__ == "__main__":
    entry()

Now you can pass --version on the command line and get your application's version:

python -m examples.example03 --version
1.2.3

Note that this code depends on your own implementation of versioning. I use __version__ but you don't have to. If your application's version is gettable via a property other than __version__ then pass that instead.

Example 4: Making your package executable after installing

This isn't Cline functionality, but I'll preempt the question by answering it now.

In your setup.py script, pass an entry_points value into setup():

setup(
    # ...
    entry_points={
        "console_scripts": [
            "foo=bar.__main__:entry",
        ],
    },
    # ...
)

Note that:

  • foo is the name of the command you want your users to run (i.e. foo --help)
  • bar is your package name
  • entry is the name of the function to run inside __main__.py

Project

Contributing

To contribute a bug report, enhancement or feature request, please raise an issue at github.com/cariad/cline/issues.

If you want to contribute a code change, please raise an issue first so we can chat about the direction you want to take.

Licence

Edition is released at github.com/cariad/cline under the MIT Licence.

See LICENSE for more information.

Author

Hello! 👋 I'm Cariad Eccleston and I'm a freelance DevOps and backend engineer. My contact details are available on my personal wiki at cariad.earth.

Please consider supporting my open source projects by sponsoring me on GitHub.

Acknowledgements

  • This documentation was pressed with Edition.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distribution

cline-1.2.2-py3-none-any.whl (13.8 kB view hashes)

Uploaded Python 3

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page