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")