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
Directory Name  | 
Long Name  | 
Content Description  | 
|---|---|---|
  | 
Command_*  | 
Actual implementation of the specific CLI command  | 
  | 
Commands  | 
Implementation of the command line interface of each tool  | 
  | 
Fallback  | 
Fallback for the   | 
  | 
Helpers  | 
Helper functions that are used by several parts of the CLI tool  | 
  | 
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, thewscriptat 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__.pyAdd a new file
cli/cmd_my_command/my_command_impl.pywhich implements the main entrance function of this command.Add a new file
cli/commands/c_my_command.pythat 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.pyadd 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 clickand do not usefrom click import ..., except when you areonly using
echoand/orsechoorare testing click application using
CliRunner.
Use
cli/helpers/click_helpers.pyfor common click options (except for CAN, see next point)When the tool uses CAN communication use the
cli/helpers/fcan.pyto handle the CLI arguments.When using a verbosity flag,
@verbosity_optionSHALL be the second last decorator.@click.pass_contextSHALL 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__.pyAdd a new file
cmd_gui/frame_my_command/my_command_gui.pyAdd a new file
commands/c_my_command.pywith 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.pyas 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 CLIshall be implemented intests/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.pyis then tested intests/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...elsebranch, 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.
Bad  | 
Good  | 
|---|---|
  | 
  | 
For every test the following shall be considered to avoid boilerplate code:
6.4.1. fox CLI Unit Test Example
Consider the the file foo.py, which implements a class Foo and two
methods.
 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:
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 classTestFooInstantiationfunction
add_twoimplements its tests in classTestFooAddTwofunction
print_attrimplements its tests in classTestFooPrintAttr
Testing the __init__ method:
 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:
 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:
 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")