6. Developing fox CLI

fox CLI is primary a tool to interact with the foxBMS 2 repository through the command line. All commands must therefore be implemented as a part of it. The fox CLI implementation is found in the cli/-directory at the root of the repository.

6.1. Directory Description

6.1.1. Directories

Table 6.8 Directory description of the cli directory

Directory Name

Long Name

Content Description

cmd_*

Command_*

Actual implementation of the specific CLI command

commands

Commands

Implementation of the command line interface of each tool

fallback

Fallback

Fallback for the fox CLI wrappers when the environment is missing

helpers

Helpers

Helper functions that are used by several parts of the CLI tool

pre-commit

pre-commit

Scripts that are run as part of the pre-commit framework

6.1.2. Files

  • cli/cli.py: registers all commands.

  • cli/foxbms_version.py: Reads the foxBMS 2 version information from the single source of truth for the version information, the wscript at the root of the repository.

6.2. How to implement a new command?

When a new tool, i.e., a command needs to be implemented the following steps need to be done (exemplary new command my-command):

  • Add a new file cli/cmd_my_command/__init__.py

  • Add a new file cli/cmd_my_command/my_command_impl.py which implements the main entrance function of this command.

  • Add a new file cli/commands/c_my_command.py that implements the command line interface of the tool.

    import click
    
    from ..cmd_my_command import my_command_impl
    
    # Add the new CLI commands afterwards using click, e.g., as follows:
    @click.command("my-command")
    @click.pass_context
    def run_my_command(ctx: click.Context) -> None:
        """Add help message here"""
        # my_command_impl.do_something must return a `SubprocessResult` object
        # pass the CLI arguments to `do_something` if additional arguments
        # and/or options are needed
        ret = my_command_impl.do_something()
        ctx.exit(ret.returncode)
    
  • In cli/cli.py add the new command to the cli:

    # add import of the command
    from .commands.c_my_command import my_command
    # add the command to the application
    # ...
    main.add_command(my_command)
    # ...
    

Adhere to the following rules when using click:

  • Always import click as import click and do not use from click import ..., except when you are

    • only using echo and/or secho or

    • are testing click application using CliRunner.

  • Use cli/helpers/click_helpers.py for common click options (except for CAN, see next point)

  • When the tool uses CAN communication use the cli/helpers/fcan.py to handle the CLI arguments.

  • When using a verbosity flag, @verbosity_option SHALL be the second last decorator.

  • @click.pass_context SHALL be the last decorator.

6.3. ## How to add a command to the GUI?

The command that shall be added is my-command from the example above.

  • Add a new file cmd_gui/frame_my_command/__init__.py

  • Add a new file cmd_gui/frame_my_command/my_command_gui.py

  • Add a new file commands/c_my_command.py with the following structure as starting point

    """Implements the 'my_command' frame"""
    
    from tkinter import ttk
    
    
    # pylint: disable-next=too-many-instance-attributes, too-many-ancestors
    class MyCommandFrame(ttk.Frame):
        """'My Command' frame"""
    
        def __init__(self, parent) -> None:
            super().__init__(parent)
            # ...
    
  • Add the new frame to the main GUI in cli/cmd_gui/gui_impl.py as follows:

    from .frame_my_command.my_command_gui import MyCommandFrame
    
    # Add a 'Notebook' (i.e., tab support)
    self.notebook = ttk.Notebook(self)
    
    # other frames are already added here
    # add the new one at the appropiate position
    tab_my_command = MyCommandFrame(self.notebook)
    self.notebook.add(tab_my_command, text="My Command")
    

6.4. Unit Tests

  • Unit tests for the fox CLI shall be implemented in tests/cli.

  • Functions called by the function under test should be mocked.

  • The command line interface for each command shall be tested in tests/cli/commands/*, where each command uses its own file to tests its interface.

  • Each tool shall then be tested in the appropiate subdirectory, e.g., cli/cmd_etl/batetl/etl/can_decode.py is then tested in tests/cli/cmd_etl/batetl/etl/test_can_decode.py.

  • Every test case shall only test one test, i.e., if a function has an if...else branch, two test functions shall be used.

  • Foreach test appropriate assert methods shall be used to provide a verbose error message in case a test fails.

Table 6.9 Assert Usage

Bad

Good

assertTrue("b" in ["a", "b", "c"])

assertIn("b", ["a", "b", "c"])

  • For every test the following shall be considered to avoid boilerplate code:

    • In every test case it shall be considered to use the setUp and tearDown method.

    • If similar patch decorators are used in each test of a test case, it shall be considered to apply the patch decorators on the test class.

6.4.1. fox CLI Unit Test Example

Consider the the file foo.py, which implements a class Foo and two methods.

Listing 6.2 foo.py
 1class Foo:
 2    """This is the Foo class"""
 3
 4    def __init__(self, attr: int) -> None:
 5        if attr == 0:
 6            raise SystemExit("foo")
 7        self.attr = attr
 8
 9    def add_two(self) -> int:
10        """Add 2"""
11        return self.attr + 2
12
13    def print_attr(self) -> None:
14        """Print the attribute"""
15        print(self.attr)

The include section should look something like this:

Listing 6.3 test_foo.py
1import io  # when stdout/stderr need to be captured
2import unittest
3from contextlib import redirect_stderr, redirect_stdout  # to capture stdout/stderr
4
5from foo import Foo  # module under test
6

Each method, include the dunder methods, shall have a separate test case implemented through a unittest.TestCase class. In this example, there are then three test cases

  • function __init__ implements its tests in class TestFooInstantiation

  • function add_two implements its tests in class TestFooAddTwo

  • function print_attr implements its tests in class TestFooPrintAttr

Testing the __init__ method:

Listing 6.4 test_foo.py
 1# Separate unit test class per function, starting with the object instantiation
 2class TestFooInstantiation(unittest.TestCase):
 3    """Test 'Foo'-object instantiation."""
 4
 5    def test_foo_instantiation_ok(self):
 6        """The object can be instantiated."""
 7        Foo(1)
 8        # nothing to assert for in this case.
 9
10    def test_foo_instantiation_wrong_initialization_value(self):
11        """The object can not be instantiated because some reason."""
12        # as the instantiation throws an exception, we need to capture it and
13        # check that we raise the expected exception
14        err = io.StringIO()
15        out = io.StringIO()
16        # always capture stderr/stdout for later assert, to make sure we really
17        # get the output we expect
18        with redirect_stderr(err), redirect_stdout(out):
19            with self.assertRaises(SystemExit) as cm:
20                Foo(0)
21        # assert that the correct exception is thrown
22        self.assertEqual(cm.exception.code, "foo")
23        self.assertEqual(err.getvalue(), "")
24        self.assertEqual(out.getvalue(), "")

Testing the add_two method:

Listing 6.5 test_foo.py
 1# Next, test some method of the class
 2class TestFooAddTwo(unittest.TestCase):
 3    """Test 'add_two' method of the 'Foo' class."""
 4
 5    def test_add_two(self):
 6        """Calling 'add_two' on an 'Foo' instance shall add 2 to its 'attr'
 7        attribute."""
 8        err = io.StringIO()
 9        out = io.StringIO()
10        with redirect_stderr(err), redirect_stdout(out):
11            # call the function under test and store the return value in a
12            # variable called 'ret'
13            ret = Foo(1).add_two()
14        # assert on the return value
15        self.assertEqual(3, ret)
16        self.assertEqual(err.getvalue(), "")
17        self.assertEqual(out.getvalue(), "")

Testing the print_attr method:

Listing 6.6 test_foo.py
 1# Next, capture output to stdout and compare it, i.e., running tests shall not
 2# write to the console
 3class TestFooPrintAttr(unittest.TestCase):
 4    """Test 'print_attr' method of the 'Foo' class."""
 5
 6    def test_print_attr(self):
 7        """The string-representation of the 'attr' attribute shall be printed
 8        to stdout."""
 9        bla = Foo(1)
10        err = io.StringIO()
11        out = io.StringIO()
12        with redirect_stderr(err), redirect_stdout(out):
13            # call the function under test and store the return value in a
14            # variable called 'ret'
15            bla.print_attr()
16        # assert on stdout
17        self.assertEqual(err.getvalue(), "")
18        self.assertEqual(out.getvalue(), "1\n")