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, thewscript
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 usefrom click import ...
, except when you areonly using
echo
and/orsecho
orare 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 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.py
is 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...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.
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 classTestFooInstantiation
function
add_two
implements its tests in classTestFooAddTwo
function
print_attr
implements 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")