├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── apeer_dev_kit ├── __init__.py ├── _core.py ├── _utility.py └── adk.py ├── sample ├── apeer_main.py └── your_code.py ├── setup.py └── tests ├── __init__.py └── tests_core.py /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Environments 87 | .env 88 | .venv 89 | env/ 90 | venv/ 91 | ENV/ 92 | env.bak/ 93 | venv.bak/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.4" 5 | - "3.6" 6 | 7 | script: 8 | - python tests/tests_core.py 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 APEER 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # APEER Python SDK 2 | 3 | [![Build Status](https://travis-ci.com/apeer-micro/apeer-python-sdk.svg?branch=master)](https://travis-ci.com/apeer-micro/apeer-python-sdk) 4 | [![Python 2.7](https://img.shields.io/badge/python-2.7-blue.svg)](https://www.python.org/download/releases/2.7/) 5 | [![Python 3.6](https://img.shields.io/badge/python-3.6-blue.svg)](https://www.python.org/downloads/release/python-360/) 6 | [![PyPI Version](https://img.shields.io/pypi/v/apeer-dev-kit.svg)](https://pypi.org/project/apeer-dev-kit/) 7 | [![License](https://img.shields.io/badge/Code%20License-MIT-blue.svg)](https://github.com/apeer-micro/apeer-python-sdk/blob/master/LICENSE.txt) 8 | 9 | ## What it does 10 | 11 | Our APEER Python SDK aka. **a**peer-**d**ev-**k**it (ADK) is a Python library for reading inputs and writing outputs of [APEER](https://www.apeer.com) modules. The ADK will take care of reading inputs from previous modules in APEER and writing your outputs in the correct format for the next module. 12 | 13 | ## Installation 14 | 15 | ```shell 16 | $ pip install apeer-dev-kit 17 | ``` 18 | 19 | ## How to Use 20 | 21 | Your code (your_code.py) can be in it's seperate package and run totally independent of APEER if you use the following structure for `__main__`. 22 | 23 | ```python 24 | #### apeer_main.py #### 25 | 26 | from apeer_dev_kit import adk 27 | import your_code 28 | 29 | if __name__ == '__main__': 30 | inputs = adk.get_inputs() 31 | 32 | outputs = your_code.run(inputs['input_image_path'], inputs['red'], inputs['green'], inputs['blue']) 33 | 34 | adk.set_output('success', outputs['success']) 35 | adk.set_file_output('tinted_image', outputs['tinted_image']) 36 | adk.finalize() 37 | 38 | 39 | #### your_code.py ##### 40 | 41 | def run(input_image_path, red, green, blue): 42 | 43 | # your processing code goes here ... 44 | 45 | # Make sure you return the outputs as a dictionary containing all output 46 | # values as specified for your APEER module 47 | return {'success': True, 'tinted_image': output_file_path} 48 | 49 | ``` 50 | 51 | ## API 52 | ### Getting Inputs: 53 | * `get_inputs()`: This methods returns a dictionary containing your inputs. The keys in the dictionary are defined in your module_specification file. 54 | 55 | 56 | ### Setting Output: 57 | After your done with processing in your code. You want to pass your output to the next module. In order to pass a file output use `set_file_output()` and to pass every output type except `file` type, use `set_output()`. 58 | 59 | * `set_output`(): This method allows you to pass non-file output to the next module. 60 | Example: `adk.set_output('success', True)`. The first argument is the key, which you find in module_specification file. The second argument is the value that you have calculated. You can also pass a list as value. 61 | 62 | * `set_file_output()`: This method allows your to pass your file output to next module. 63 | Example: `adk.set_file_output('tinted_image', 'my_image.jpg')`. The first argument is the key, which you will find in your module_specification file. he second argument is the filepath to your file. If you have a list of files as output, you can simply pass the list of filepath to your files. `adk.set_file_output('output_images', ['image1.jpg', 'image2.jpg'])` 64 | -------------------------------------------------------------------------------- /apeer_dev_kit/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.2.2" -------------------------------------------------------------------------------- /apeer_dev_kit/_core.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import logging as log 4 | import pkg_resources 5 | 6 | from apeer_dev_kit import __version__ 7 | from ._utility import copyfile 8 | 9 | class _core: 10 | WFE_INPUT_ENV_VARIABLE = 'WFE_INPUT_JSON' 11 | 12 | def __init__(self): 13 | log.basicConfig(format='%(asctime)s [ADK:%(levelname)s] %(message)s', level=log.INFO, datefmt='%Y-%m-%d %H:%M:%S') 14 | log.info('Initializing ADK v' + __version__) 15 | self._outputs = {} 16 | self._wfe_output_params_file = '' 17 | if os.name == 'nt': 18 | self.output_dir = 'C:\\output\\' 19 | self.wfe_input_file_name = 'C:\\params\\WFE_input_params.json' 20 | else: 21 | self.output_dir = '/output/' 22 | self.wfe_input_file_name = '/params/WFE_input_params.json' 23 | self._input_json = self._read_inputs() 24 | log.info('Found module\'s inputs to be {}'.format(self._input_json)) 25 | 26 | def _read_inputs(self): 27 | ''' Read inputs either from file or from environment variable''' 28 | try: 29 | if self.WFE_INPUT_ENV_VARIABLE in os.environ: 30 | return json.loads(os.environ[self.WFE_INPUT_ENV_VARIABLE]) 31 | else: 32 | with open(self.wfe_input_file_name, 'r') as input_file: 33 | return json.load(input_file) 34 | except IOError: 35 | message = 'Input file {} not found'.format(self.wfe_input_file_name) 36 | log.error(message) 37 | raise IOError(message) 38 | except AttributeError: 39 | message = 'ADK not initialized' 40 | log.error(message) 41 | raise IOError(message) 42 | 43 | def _get_inputs(self): 44 | ''' Get the inputs''' 45 | try: 46 | self._wfe_output_params_file = self.output_dir + self._input_json.pop('WFE_output_params_file') 47 | log.info('Outputs will be written to {}'.format(self._wfe_output_params_file)) 48 | return self._input_json 49 | except KeyError: 50 | message = 'Key WFE_output_params_file not found' 51 | log.error(message) 52 | raise KeyError(message) 53 | 54 | def _set_output(self, key, value): 55 | log.info('Set output "{}" to "{}"'.format(key, value)) 56 | if (key is None) or (value is None): 57 | message = 'key or value cannot be None' 58 | log.error(message) 59 | raise TypeError(message) 60 | self._outputs[key] = value 61 | 62 | def _set_file_output(self, key, filepath): 63 | if isinstance(filepath, list): 64 | dsts = [] 65 | for f in filepath: 66 | if(not f or f.isspace()): 67 | log.warn('Empty filepath, skipping') 68 | continue 69 | if(f.startswith(self.output_dir)): 70 | dsts.append(f) 71 | else: 72 | dst = self.output_dir + os.path.basename(f) 73 | log.info('Copying file "{}" to "{}"'.format(os.path.basename(f), dst)) 74 | copyfile(f, dst) 75 | dsts.append(dst) 76 | if(len(dsts) > 0): 77 | self._set_output(key, dsts) 78 | else: 79 | if(not filepath or filepath.isspace()): 80 | log.warn('Empty filepath, skipping') 81 | return 82 | if(filepath.startswith(self.output_dir)): 83 | dst = filepath 84 | else: 85 | dst = self.output_dir + os.path.basename(filepath) 86 | log.info('Copying file "{}" to "{}"'.format(os.path.basename(filepath), dst)) 87 | copyfile(filepath, dst) 88 | self._set_output(key, dst) 89 | 90 | def _finalize(self): 91 | with open(self._wfe_output_params_file, 'w') as fp: 92 | json.dump(self._outputs, fp) 93 | log.info('Module finalized') 94 | -------------------------------------------------------------------------------- /apeer_dev_kit/_utility.py: -------------------------------------------------------------------------------- 1 | """Utilities methods""" 2 | 3 | import shutil 4 | 5 | def copyfile(src, dst): 6 | shutil.copyfile(src, dst) -------------------------------------------------------------------------------- /apeer_dev_kit/adk.py: -------------------------------------------------------------------------------- 1 | from apeer_dev_kit import _core 2 | 3 | _adk = _core._core() 4 | 5 | 6 | def get_inputs(): 7 | """ Get inputs inside your module """ 8 | return _adk._get_inputs() 9 | 10 | 11 | def set_output(key, value): 12 | """ Set the output """ 13 | _adk._set_output(key, value) 14 | 15 | 16 | def set_file_output(key, filepath): 17 | """ Set the output """ 18 | _adk._set_file_output(key, filepath) 19 | 20 | 21 | def finalize(): 22 | """ This method should be called at the end """ 23 | _adk._finalize() 24 | -------------------------------------------------------------------------------- /sample/apeer_main.py: -------------------------------------------------------------------------------- 1 | from apeer_dev_kit import adk 2 | import your_code 3 | 4 | if __name__ == '__main__': 5 | inputs = adk.get_inputs() 6 | 7 | outputs = your_code.run(inputs['input_image_path'], inputs['red'], inputs['green'], inputs['blue']) 8 | 9 | adk.set_output('success', outputs['success']) 10 | adk.set_file_output('tinted_image', outputs['tinted_image']) 11 | adk.finalize() 12 | -------------------------------------------------------------------------------- /sample/your_code.py: -------------------------------------------------------------------------------- 1 | from skimage import color, img_as_float, io 2 | 3 | 4 | def run(input_image_path, red, green, blue): 5 | 6 | original_image = io.imread(input_image_path) 7 | grayscale_image = img_as_float(original_image[::2, ::2]) 8 | image = color.gray2rgb(grayscale_image) 9 | multiplier = [float(red), float(green), float(blue)] 10 | output_file_path = 'tinted.png' 11 | io.imsave(output_file_path, multiplier * image) 12 | 13 | return {'success': True, 'tinted_image': output_file_path} 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | from apeer_dev_kit import __version__ 3 | 4 | setup(name='apeer-dev-kit', 5 | version=__version__, 6 | description='Development kit for creating modules on apeer', 7 | url='https://github.com/apeer-micro/apeer-python-sdk', 8 | author='apeer-micro', 9 | packages=['apeer_dev_kit'], 10 | classifiers=[ 11 | "Programming Language :: Python :: 2", 12 | "License :: OSI Approved :: MIT License", 13 | "Operating System :: OS Independent", 14 | ], 15 | long_description=""" 16 | APEER Python SDK aka (ADK) is a Python library for reading inputs and writing outputs of APEER(https://www.apeer.com) modules. 17 | The ADK will take care of reading inputs from previous modules in APEER and writing your outputs in the correct format for the next mod ule. 18 | This project is hosted at https://github.com/apeer-micro/apeer-python-sdk 19 | The documentation can be found at https://github.com/apeer-micro/apeer-python-sdk/blob/master/README.md 20 | """) -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apeer-micro/apeer-python-sdk/e75977d497a5b84f0ee7f60ee7a56a84245d036d/tests/__init__.py -------------------------------------------------------------------------------- /tests/tests_core.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import mock 3 | import sys 4 | import os 5 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 6 | 7 | from apeer_dev_kit import _core 8 | 9 | class TestsCore(unittest.TestCase): 10 | 11 | def __init__(self, *args, **kwargs): 12 | super(TestsCore, self).__init__(*args, **kwargs) 13 | if os.name == 'nt': 14 | self.output_dir = 'C:\\output\\' 15 | else: 16 | self.output_dir = '/output/' 17 | 18 | # __init__ 19 | def test_init_givenNoEnvironmentVariableAndNoInputFile_coreIsNotInitialized(self): 20 | with self.assertRaises(IOError): 21 | _core._core() 22 | 23 | def test_init_givenEmptyInputJson_coreIsInitialized(self): 24 | os.environ['WFE_INPUT_JSON'] = '{}' 25 | 26 | core = _core._core() 27 | 28 | self.assertTrue(core) 29 | self.assertEqual(core._wfe_output_params_file, '') 30 | self.assertEqual(core._outputs, {}) 31 | 32 | def test_init_givenInputJson_coreIsInitalized(self): 33 | os.environ['WFE_INPUT_JSON'] = '{"WFE_output_params_file":"param.json","red":0.2,"input_image":"test.jpg"}' 34 | 35 | core = _core._core() 36 | 37 | self.assertTrue(core) 38 | self.assertEqual(core._input_json['WFE_output_params_file'], 'param.json') 39 | 40 | # _get_inputs 41 | def test_get_inputs_givenNoParamFileKey_throwsKeyErrorException(self): 42 | os.environ['WFE_INPUT_JSON'] = '{"red":0.2,"input_image":"test.jpg"}' 43 | core = _core._core() 44 | 45 | with self.assertRaises(KeyError): 46 | core._get_inputs() 47 | 48 | def test_get_inputs_givenInputJson_paramFileIsNotPartOfInputs(self): 49 | os.environ['WFE_INPUT_JSON'] = '{"WFE_output_params_file":"param.json","red":0.2}' 50 | core = _core._core() 51 | 52 | inputs = core._get_inputs() 53 | 54 | self.assertFalse('WFE_output_params_file' in inputs) 55 | self.assertEqual(len(inputs), 1) 56 | 57 | def test_get_inputs_givenDecimal_isDeserialized(self): 58 | os.environ['WFE_INPUT_JSON'] = '{"WFE_output_params_file":"param.json","red":0.2}' 59 | core = _core._core() 60 | 61 | inputs = core._get_inputs() 62 | 63 | self.assertEqual(inputs['red'], 0.2) 64 | 65 | def test_get_inputs_givenInteger_isDeserialized(self): 66 | os.environ['WFE_INPUT_JSON'] = '{"WFE_output_params_file":"param.json","red":2}' 67 | core = _core._core() 68 | 69 | inputs = core._get_inputs() 70 | 71 | self.assertEqual(inputs['red'], 2) 72 | 73 | def test_get_inputs_givenString_isDeserialized(self): 74 | os.environ['WFE_INPUT_JSON'] = '{"WFE_output_params_file":"param.json","value":"testValue"}' 75 | core = _core._core() 76 | 77 | inputs = core._get_inputs() 78 | 79 | self.assertEqual(inputs['value'], 'testValue') 80 | 81 | def test_get_inputs_givenBoolean_isDeserialized(self): 82 | os.environ["WFE_INPUT_JSON"] = '{"WFE_output_params_file":"param.json","value":true}' 83 | core = _core._core() 84 | 85 | inputs = core._get_inputs() 86 | 87 | self.assertEqual(inputs["value"], True) 88 | 89 | def test_get_inputs_givenList_isDeserialized(self): 90 | os.environ["WFE_INPUT_JSON"] = '{"WFE_output_params_file":"param.json","value":["one", "two"]}' 91 | core = _core._core() 92 | 93 | inputs = core._get_inputs() 94 | 95 | self.assertEqual(inputs["value"], ["one", "two"]) 96 | 97 | # _set_output 98 | def test_set_output_givenNoneKey_raisesTypeError(self): 99 | os.environ["WFE_INPUT_JSON"] = '{}' 100 | core = _core._core() 101 | 102 | with self.assertRaises(TypeError): 103 | core._set_output(None, None) 104 | 105 | def test_set_output_givenStringValue_correctlyAdded(self): 106 | os.environ["WFE_INPUT_JSON"] = '{}' 107 | core = _core._core() 108 | 109 | core._set_output("test", "string") 110 | 111 | self.assertEqual(core._outputs["test"], "string") 112 | 113 | def test_set_output_givenDecimalValue_correctlyAdded(self): 114 | os.environ["WFE_INPUT_JSON"] = '{}' 115 | core = _core._core() 116 | 117 | core._set_output("test", 0.2) 118 | 119 | self.assertEqual(core._outputs["test"], 0.2) 120 | 121 | def test_set_output_givenIntegerValue_correctlyAdded(self): 122 | os.environ["WFE_INPUT_JSON"] = '{}' 123 | core = _core._core() 124 | 125 | core._set_output("test", 2) 126 | 127 | self.assertEqual(core._outputs["test"], 2) 128 | 129 | def test_set_output_givenBooleanValue_correctlyAdded(self): 130 | os.environ["WFE_INPUT_JSON"] = '{}' 131 | core = _core._core() 132 | 133 | core._set_output("test", True) 134 | 135 | self.assertEqual(core._outputs["test"], True) 136 | 137 | def test_set_output_givenListValue_correctlyAdded(self): 138 | os.environ["WFE_INPUT_JSON"] = '{}' 139 | core = _core._core() 140 | 141 | core._set_output("test", ["test1", "test2"]) 142 | 143 | self.assertEqual(core._outputs["test"], ["test1", "test2"]) 144 | 145 | # _set_file_output 146 | @mock.patch('apeer_dev_kit._utility.shutil') 147 | def test_set_file_output_givenEmptyFileName_FileNotCopied(self, mock_shutil): 148 | os.environ["WFE_INPUT_JSON"] = '{}' 149 | core = _core._core() 150 | 151 | core._set_file_output("file", "") 152 | 153 | mock_shutil.copyfile.assert_not_called() 154 | self.assertEqual(mock_shutil.copyfile.call_count, 0) 155 | self.assertEqual(len(core._outputs), 0) 156 | 157 | @mock.patch('apeer_dev_kit._utility.shutil') 158 | def test_set_file_output_givenEmptyFileNameList_FileNotCopied(self, mock_shutil): 159 | os.environ["WFE_INPUT_JSON"] = '{}' 160 | core = _core._core() 161 | 162 | core._set_file_output("file", ["", " ", ' ']) 163 | 164 | mock_shutil.copyfile.assert_not_called() 165 | self.assertEqual(len(core._outputs), 0) 166 | 167 | @mock.patch('apeer_dev_kit._utility.shutil') 168 | def test_set_file_output_givenFileNotInOutputFolder_FileCopied(self, mock_shutil): 169 | os.environ["WFE_INPUT_JSON"] = '{}' 170 | core = _core._core() 171 | 172 | core._set_file_output("file", "file.txt") 173 | 174 | mock_shutil.copyfile.assert_called_with("file.txt", os.path.join(self.output_dir, "file.txt")) 175 | self.assertEqual(core._outputs["file"], os.path.join(self.output_dir, "file.txt")) 176 | 177 | @mock.patch('apeer_dev_kit._utility.shutil') 178 | def test_set_file_output_givenFileInOutputFolder_FileNotCopied(self, mock_shutil): 179 | os.environ["WFE_INPUT_JSON"] = '{}' 180 | core = _core._core() 181 | 182 | core._set_file_output("file", os.path.join(self.output_dir, "file.txt")) 183 | 184 | mock_shutil.copyfile.assert_not_called() 185 | self.assertEqual(core._outputs["file"], os.path.join(self.output_dir, "file.txt")) 186 | 187 | @mock.patch('apeer_dev_kit._utility.shutil') 188 | def test_set_file_output_givenFileListInOutputFolder_FileNotCopied(self, mock_shutil): 189 | os.environ["WFE_INPUT_JSON"] = '{}' 190 | core = _core._core() 191 | 192 | core._set_file_output("file", [os.path.join(self.output_dir, "file1.txt"), os.path.join(self.output_dir, "file2.txt")]) 193 | 194 | mock_shutil.copyfile.assert_not_called() 195 | self.assertEqual(core._outputs["file"], [os.path.join(self.output_dir, "file1.txt"), os.path.join(self.output_dir, "file2.txt")]) 196 | 197 | @mock.patch('apeer_dev_kit._utility.shutil') 198 | def test_set_file_output_givenFileListNotInOutputFolder_FileCopied(self, mock_shutil): 199 | os.environ["WFE_INPUT_JSON"] = '{}' 200 | core = _core._core() 201 | 202 | core._set_file_output("file", ["file1.txt", "file2.txt"]) 203 | 204 | mock_shutil.copyfile.assert_has_calls([ 205 | mock.call("file1.txt", os.path.join(self.output_dir, "file1.txt")), 206 | mock.call("file2.txt", os.path.join(self.output_dir, "file2.txt"))]) 207 | self.assertEqual(core._outputs["file"], [os.path.join(self.output_dir, "file1.txt"), os.path.join(self.output_dir, "file2.txt")]) 208 | 209 | def tearDown(self): 210 | if 'WFE_INPUT_JSON' in os.environ: 211 | del os.environ['WFE_INPUT_JSON'] 212 | 213 | if __name__ == '__main__': 214 | unittest.main() 215 | --------------------------------------------------------------------------------