diff --git a/.gitignore b/.gitignore index c7e2b64770ad311fa0177732591f98bb2c93fc39..7f249738c56e97da80aaecaebb99c528eba78d3a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,11 @@ **/.DS_Store **/__pycache__ **/*.pyc +**/*.egg-info +**/.tox +**/.stestr +**/AUTHORS +**/ChangeLog **/env **/vscode-server.tar **/.project diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..12e0fd7696be27e41fe8141d11f5422373a0a697 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,29 @@ +# TODO(Corne): Update this image to use our own registry once building +# images is in place. +image: artefact.skatelescope.org/ska-tango-images/tango-itango:9.3.3.7 +variables: + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + # The PBR dependency requires a set version, not actually used + # Instead `util/lofar_git.py:get_version()` is used. + PBR_VERSION: "0.1" +cache: + paths: + - .cache/pip + - devices/.tox +stages: + - building + - linting + - unit-tests +linting: + stage: linting + script: + - cd devices + - tox -e pep8 +unit_test: + stage: unit-tests + before_script: + - sudo apt-get update + - sudo apt-get install -y git + script: + - cd devices + - tox -e py37 diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..b7b4398a9581bf0771fa2e8a669f1e53c92b75d2 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Tango Station Control + +Station Control software related to Tango devices. \ No newline at end of file diff --git a/devices/.stestr.conf b/devices/.stestr.conf new file mode 100644 index 0000000000000000000000000000000000000000..ddc59860d5117ed8bdc44faeea1d893760b5520e --- /dev/null +++ b/devices/.stestr.conf @@ -0,0 +1,3 @@ +[DEFAULT] +test_path=./test +top_dir=./ diff --git a/devices/README.md b/devices/README.md index 1d962a271e60cc125feb88d5b1ce1b7c5f3d627b..0604c3a4d1d90b7d99ab1f35cee1922c6ee99371 100644 --- a/devices/README.md +++ b/devices/README.md @@ -1,4 +1,4 @@ -# Device wrapper +# Tango Station Control Device wrappers This code provides an attribute_wrapper class in place of attributes for tango devices. the attribute wrappers contain additional code that moves a lot of the complexity and redundant code to the background. diff --git a/devices/__init__.py b/devices/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..82b2af0e96f75105253e501e47f8861218132f63 100644 --- a/devices/__init__.py +++ b/devices/__init__.py @@ -0,0 +1,4 @@ +from util.lofar_git import get_version + +__version__ = get_version() + diff --git a/devices/clients/__init__.py b/devices/clients/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/devices/requirements.txt b/devices/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..8e11e2f537bf59f3602379c853976696df7524f0 --- /dev/null +++ b/devices/requirements.txt @@ -0,0 +1,5 @@ +# the order of packages is of significance, because pip processes them in the +# order of appearance. Changing the order has an impact on the overall +# integration process, which may cause wedges in the gate later. + +pbr>=2.0 # Apache-2.0 diff --git a/devices/setup.cfg b/devices/setup.cfg new file mode 100644 index 0000000000000000000000000000000000000000..586aa190649d3c54b04ce586cdbaa4565570b1b1 --- /dev/null +++ b/devices/setup.cfg @@ -0,0 +1,30 @@ +[metadata] +name = TangoStationControl +summary = LOFAR 2.0 Station Control +description-file = + README.md +description-content-type = text/x-rst; charset=UTF-8 +author = ASTRON +home-page = https://astron.nl +project_urls = + Bug Tracker = https://support.astron.nl/jira/projects/L2SS/issues/ + Source Code = https://git.astron.nl/lofar2.0/tango +license = Apache-2 +classifier = + Environment :: Console + License :: Apache Software License + Operating System :: POSIX :: Linux + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + +[files] +package_dir=./ + +[entry_points] +console_scripts = + SDP = SDP:main + PCC = PCC:main diff --git a/devices/setup.py b/devices/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..4fa0ce44d0caa9b174fc65a699e63b31e43aee9b --- /dev/null +++ b/devices/setup.py @@ -0,0 +1,4 @@ +import setuptools + +# Requires: setup.cfg +setuptools.setup(setup_requires=['pbr>=2.0.0'], pbr=True) diff --git a/devices/test-requirements.txt b/devices/test-requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..c97375e938b0466da884581c339f2c5735472c62 --- /dev/null +++ b/devices/test-requirements.txt @@ -0,0 +1,15 @@ +# the order of packages is of significance, because pip processes them in the +# order of appearance. Changing the order has an impact on the overall +# integration process, which may cause wedges in the gate later. + +doc8>=0.8.0 # Apache-2.0 +flake8>=3.8.0 # MIT +bandit>=1.6.0 # Apache-2.0 +hacking>=3.2.0,<3.3.0 # Apache-2.0 +coverage>=5.2.0 # Apache-2.0 +python-subunit>=1.4.0 # Apache-2.0/BSD +Pygments>=2.6.0 +stestr>=3.0.0 # Apache-2.0 +testscenarios>=0.5.0 # Apache-2.0/BSD +testtools>=2.4.0 # MIT + diff --git a/devices/test/README.md b/devices/test/README.md new file mode 100644 index 0000000000000000000000000000000000000000..4007ef0a30b75869e04b0e69745947f73d4520ba --- /dev/null +++ b/devices/test/README.md @@ -0,0 +1,172 @@ +# Unit Testing + +Is the procedure of testing individual units of code for fit for use. Often +this entails isolating specific segments and testing the behavior given a +certain range of different inputs. Arguably, the most effective forms of unit +testing deliberately uses _edge cases_ and makes assumptions about any other +sections of code that are not currently under test. + +First the underlying technologies as well as how they are applied in Tango +Station Control are discussed. If this seems uninteresting, or you are already +familiar with these concepts you can skip to +[Running tox tasks](#running tox tasks) and +[Debugging unit tests](#debugging-unit-tests). + +### Table of Contents: + +- [Tox](#tox) +- [Testing approach](#testing-approach) +- [Mocking](#mocking) +- [Running tox tasks](#running-tox-tasks) +- [Debugging unit tests](#debugging-unit-tests) + +## Tox + +[Tox](https://tox.readthedocs.io/en/latest/) is a commandline tool to simplify +running Python tasks such as linting and unit testing. Using a simple +[configuration file](../tox.ini) it can setup a +[virtual environment](https://virtualenv.pypa.io/en/latest/) that automatically +installs dependencies before executing the task. These environments persist +after the task finishes preventing the excessive downloading and installation +of dependencies. + +The Tox environments in this project are configured to install any dependencies +listed in [test-requirements.txt](../test-requirements.txt) and +[lofar-requirements.txt](../../docker-compose/lofar-device-base/lofar-requirements.txt) +this can also easily be verified within our [configuration file](../tox.ini). + +## Testing approach + +For testing [stestr](https://stestr.readthedocs.io/en/latest/) is used this +tool has main advantage the utilization of a highly parallelized unit test +runner that automatically scales to the number of simultaneous threads +supported by the host processor. Other features include automatic test +discovery and pattern matching to only execute a subset of tests. + +However, stestr is incompatible with using breakpoints +(through [pdb](https://docs.python.org/3/library/pdb.html)) directly and will +simply fail the test upon encountering one. The +[debugging unit tests](#debugging-unit-tests) section describes how to mitigate +this and still debug individual unit tests effectively. + +All tests can be found in this test folder and all tests must inherit from +`TestCase`, found in [base.py](base.py). This ensures that any test fixtures run +before and after tests. These test fixtures allow to restore any global modified +state, such as those of static variables. It is the task of the developer +writing the unit test to ensure that any global modification is restored. + +When writing unit tests it is best practice to mimic the directory structure of +the original project inside the test directory. Similarly, copying the filenames +and adding _test__ to the beginning. Below is an example: + +* root + * database + * database_manager.py + * test + * base.py + * database + * test_database_manager.py + +## Mocking + +Contrary to many other programming languages, it is entirely possible to +modify **any** function, object, file, import at runtime. This allows for a +great deal of flexibility but also simplicity in the case of unit tests. + +Isolating functions is as simple as mocking any of the classes or functions it +uses and modifying its return values such as specific behavior can be tested. +Below is a simple demonstration mocking the return value of a function using the +mock decorator. For more detailed explanations see +[the official documentation](https://docs.python.org/3/library/unittest.mock.html). + +```python +from unittest import mock + +# We pretend that `our_custom_module` contains a function `special_char` +import our_custom_module + +def function_under_test(self): + if our_custom_module.special_char(): + return 12 + else: + return 0 + +@mock.patch.object(our_custom_module, "special_char") +def test_function_under_test(self, m_special_char): + """ Test functionality of `function_under_test` + + This mock decorator _temporarily_ overwrites the :py:func:`special_char` + function within :py:module:`our_custom_module`. We get access to this + mocked object as function argument. Concurrent dependencies of these mock + statements are automatically solved by stestr. + """ + + # Mock the return value of the mocked object to be None + m_special_char.return_value = None + + # Assert that function_under_test returns 0 when special_char returns None. + self.assertEqual(0, function_under_test()) + +``` + +## Running tox tasks + +Running tasks defined in Tox might be a little different than what you are used +to. This is due to the Tango devices running from Docker containers. Typically, +the dependencies are only available inside Docker and not on the host. + +The tasks can thus only be accessed by executing Tox from within a Tango device +Docker container. A simple interactive Docker exec is enough to access them: + +```sh +docker exec -it device-sdp /bin/bash +cd /opt/lofar2.0/tango/devices/ +tox +``` + +For specific tasks the `-e` parameter can be used, in addition, any arguments +can be appended after the tasks. These arguments can be interpreted within the +Tox configuration file as `{posargs}`. Below are a few examples: + +```sh +# Execute unit tests with Python 3.7 and only execute the test_get_version test +# from the TestLofarGit class found within util/test_lofar.py +# Noteworthy, this will also execute test_get_version_tag_dirty due to pattern +# matching. +tox -e py37 util.test_lofar.TestLofarGit.test_get_version_tag +# Execute linting +tox -e pep8 +``` + +## Debugging unit tests + +Debugging works by utilizing the +[virtual environment](https://virtualenv.pypa.io/en/latest/)that Tox creates. +These are placed in the .tox/ directory. Each of these environments carries +the same name as found in _tox.ini_, these match the names used for `-e` +arguments + +Debugging unit tests is done by inserting the following code segment just before +where you think issues occur: + +```python +import pdb; pdb.set_trace() +``` + +Now as said stestr will catch any breakpoints and reraise them so we need to +avoid using stestr while debugging. Simply source the virtual environment +created by tox `source .tox/py37/bin/activate`. You should now see that the +shell $PS1 prompt is modified to indicate the environment is active. + +From here execute `python -m testtools.run` and optionally the specific test +case as command line argument. These test will not run in parallel but support +all other features such as autodiscovery, test fixtures and mocking. + +Any breakpoint will be triggered and you can use the pdb interface +(very similar to gdb) to step through the code, modify and print variables. + +Afterwards simply execute `deactivate` to deactivate the virtual environment. +**DO NOT FORGOT TO REMOVE YOUR `pdb.set_trace()` STATEMENTS AFTERWARDS** + +The best approach to prevent committing `import pdb; pdb.set_trace()` is to +ensure that all unit tests succeed beforehand. diff --git a/devices/test/__init__.py b/devices/test/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/devices/test/base.py b/devices/test/base.py new file mode 100644 index 0000000000000000000000000000000000000000..2bcbf59b33b605ba15faa0ad71c0fd53d80274ff --- /dev/null +++ b/devices/test/base.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# +# This file is part of the LOFAR 2.0 Station Software +# +# +# +# Distributed under the terms of the APACHE license. +# See LICENSE.txt for more info. + +import unittest +import testscenarios + + +class BaseTestCase(testscenarios.WithScenarios, unittest.TestCase): + """Test base class.""" + + def setUp(self): + super().setUp() + + +class TestCase(BaseTestCase): + """Test case base class for all unit tests.""" + + def setUp(self): + super().setUp() diff --git a/devices/test/util/__init__.py b/devices/test/util/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/devices/test/util/test_lofar_git.py b/devices/test/util/test_lofar_git.py new file mode 100644 index 0000000000000000000000000000000000000000..863710541781e66bcd97083da25b47c044186bc9 --- /dev/null +++ b/devices/test/util/test_lofar_git.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +# +# This file is part of the LOFAR 2.0 Station Software +# +# +# +# Distributed under the terms of the APACHE license. +# See LICENSE.txt for more info. + +import git +from unittest import mock + +from util import lofar_git + +from test import base + + +class TestLofarGit(base.TestCase): + + def setUp(self): + super(TestLofarGit, self).setUp() + + # Clear the cache as this function of lofar_git uses LRU decorator + # This is a good demonstration of how unit tests in Python can have + # permanent effects, typically fixtures are needed to restore these. + lofar_git.get_version.cache_clear() + + def test_get_version(self): + """Test if attributes of get_repo are correctly used by get_version""" + + with mock.patch.object(lofar_git, 'get_repo') as m_get_repo: + m_commit = mock.Mock() + m_commit.return_value = "123456" + + m_is_dirty = mock.Mock() + m_is_dirty.return_value = True + + m_get_repo.return_value = mock.Mock( + active_branch="main", commit=m_commit, tags=[], + is_dirty=m_is_dirty) + + # No need for special string equal in Python + self.assertEqual("*main [123456]", lofar_git.get_version()) + + def test_get_version_tag(self): + """Test if get_version determines production_ready for tagged commit""" + + with mock.patch.object(lofar_git, 'get_repo') as m_get_repo: + m_commit = mock.Mock() + m_commit.return_value = "123456" + + m_is_dirty = mock.Mock() + m_is_dirty.return_value = False + + m_tag = mock.Mock(commit="123456") + m_tag.__str__ = mock.Mock(return_value= "version-1.2") + + m_get_repo.return_value = mock.Mock( + active_branch="main", commit=m_commit, + tags=[m_tag], is_dirty=m_is_dirty) + + self.assertEqual("version-1.2", lofar_git.get_version()) + + @mock.patch.object(lofar_git, 'get_repo') + def test_get_version_tag_dirty(self, m_get_repo): + """Test if get_version determines dirty tagged commit""" + + m_commit = mock.Mock() + m_commit.return_value = "123456" + + m_is_dirty = mock.Mock() + m_is_dirty.return_value = False + + m_tag = mock.Mock(commit="123456") + m_tag.__str__ = mock.Mock(return_value= "version-1.2") + + # Now m_get_repo is mocked using a decorator + m_get_repo.return_value = mock.Mock( + active_branch="main", commit=m_commit, + tags=[m_tag], is_dirty=m_is_dirty) + + self.assertEqual("version-1.2", lofar_git.get_version()) + + def test_catch_repo_error(self): + """Test if invalid git directories will raise error""" + + with mock.patch.object(lofar_git, 'get_repo') as m_get_repo: + + # Configure lofar_git.get_repo to raise InvalidGitRepositoryError + m_get_repo.side_effect = git.InvalidGitRepositoryError + + # Test that error is raised by get_version + self.assertRaises( + git.InvalidGitRepositoryError, lofar_git.get_version) diff --git a/devices/tox.ini b/devices/tox.ini new file mode 100644 index 0000000000000000000000000000000000000000..00ff854b27b47adc25927c3367026a07751e5d0c --- /dev/null +++ b/devices/tox.ini @@ -0,0 +1,31 @@ +[tox] +minversion = 2.0 +envlist = py36,py37,py38,py39,pep8 +skipsdist = True + +[testenv] +usedevelop = True +sitepackages = True +install_command = pip3 install {opts} {packages} +setenv = + VIRTUAL_ENV={envdir} + PYTHONWARNINGS=default::DeprecationWarning + OS_STDOUT_CAPTURE=1 + OS_STDERR_CAPTURE=1 + OS_TEST_TIMEOUT=60 +deps = -r{toxinidir}/test-requirements.txt + -r{toxinidir}/../docker-compose/lofar-device-base/lofar-requirements.txt +commands = stestr run {posargs} + +; TODO(Corne): Integrate Hacking to customize pep8 rules +[testenv:pep8] +commands = +; doc8 is only necessary in combination with Sphinx or ReStructuredText (RST) +; doc8 doc/source/ README.rst + flake8 + bandit -r devices/ clients/ common/ examples/ util/ -n5 -ll + +[flake8] +filename = *.py,.stestr.conf,.txt +select = W292 +exclude=.tox,.egg-info diff --git a/devices/util/__init__.py b/devices/util/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391