diff --git a/CHANGELOG b/CHANGELOG index 69c1a3f..8b08d6d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -126,7 +126,7 @@ stm32pio changelog: - Changed: actualized .ioc file for the latest STM32CubeMX version (5.4.0 at the moment) - Changed: improved help, docs, comments - ver. 0.95 (12.19): + ver. 0.95 (15.12.19): - New: re-made patch() method: it can intelligently parses platformio.ini and substitute necessary options. Patch can now be a general .INI-format config - New: test_get_state() - New: upload to PyPI @@ -143,3 +143,14 @@ stm32pio changelog: - Changed: check whether there is already a platformio.ini file and warn in this case on PlatformIO init stage - Changed: sort imports in the alphabetic order - Changed: use configparser to test project patching + + ver. 0.96 (17.12.19): + - Fix: generate_code() doesn't destroy the temp folder after execution + - Fix: improved and actualized docs, comments, annotations + - Changed: print Python interpreter information on testing + - Changed: move some asserts inside subTest context managers + - Changed: rename pio_build() => build() + - Changed: take out to the settings.py the width of field in a log format string + - Changed: use file statistic to check its size instead of reading the whole content + - Changed: more logging output + - Changed: change some methods signatures to return result value diff --git a/README.md b/README.md index d8ac821..715a31f 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,14 @@ Basically, you need to follow such a pattern: 3. Work on the project in your editor, compile/upload/debug etc. 4. Edit the configuration in CubeMX when necessary, then run stm32pio to regenerate the code. -Refer to Example section on more detailed steps. +Refer to Example section on more detailed steps. If you face off with some error try to enable a verbose output to get more information about a problem: +```shell script +$ stm32pio -v [command] [options] +``` + +Note, that the patch operation (which takes the CubeMX code and PlatformIO project to the compliance) erases all the comments (lines starting with `;`) inside the `platformio.ini` file. They are not required anyway, in general, but if you need them please consider to save the information somewhere else. + +Starting from v0.95, the patch can has a general-form .INI content so it is possible to modify several sections and apply composite patches. This works totally fine for almost every cases except some big complex patches involving the parameters interpolation feature. It is turned off for both `platformio.ini` and user's patch parsing by default. If there are some problems you've met due to a such behavior please modify the source code to match the parameters interpolation kind for the configs you need to. Seems like `platformio.ini` uses `ExtendedInterpolation` for its needs, by the way. On the first run stm32pio will create a config file `stm32pio.ini`, syntax of which is similar to the `platformio.ini`. You can also create this config without any following operations by initializing the project: ```shell script @@ -65,7 +72,7 @@ $ python3 app.py --help ``` to see help on available commands. -You can also use stm32pio as a package and embed it in your own application. See [`app.py`](/stm32pio/app.py) to see how to implement this. Basically you need to import `stm32pio.lib` module (where the main `Stm32pio` class resides), set up a logger and you are good to go. If you need higher-level API similar to the CLI version use `main()` function in `app.py` passing the same CLI arguments to it. +You can also use stm32pio as a package and embed it in your own application. See [`app.py`](/stm32pio/app.py) to see how to implement this. Basically you need to import `stm32pio.lib` module (where the main `Stm32pio` class resides), set up a logger and you are good to go. If you need higher-level API similar to the CLI version, use `main()` function in `app.py` passing the same CLI arguments to it (except the actual script name). ## Example @@ -123,4 +130,4 @@ CI is hard to implement for all target OSes during the requirement to have all t ```ini lib_extra_dirs = Middlewares/Third_Party/FreeRTOS ``` - You also need to move all `.c`/`.h` files to the `src`/`include` folders respectively. See PlatformIO documentation for more information. + You also need to move all `.c`/`.h` files to the appropriate folders respectively. See PlatformIO documentation for more information. diff --git a/TODO.md b/TODO.md index 16dafa2..fd0185a 100644 --- a/TODO.md +++ b/TODO.md @@ -33,3 +33,6 @@ - [x] Do we really need *sys.exc_info() ? - [x] See logging.exception and sys_exc argument for logging.debug - [x] Make `save_config()` a part of the `config` i.e. `project.config.save()` (subclass `ConfigParser`) + - [ ] Store an initial folder content in .ini config and ignore it on clean-up process. Allow the user to modify such list. Ask the confirmation of a user by-defualt and add additional option for quiet performance + - [ ] 'status' CLI subcommand, why not?.. + - [ ] exclude tests from the bundle (see `setup.py` options) diff --git a/stm32pio/app.py b/stm32pio/app.py index 0d1909a..bff187f 100755 --- a/stm32pio/app.py +++ b/stm32pio/app.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -__version__ = '0.95' +__version__ = '0.96' import argparse import logging @@ -15,7 +15,7 @@ def parse_args(args: list) -> Optional[argparse.Namespace]: Dedicated function to parse the arguments given via the CLI Args: - args: list of strings + args: list of strings CLI arguments Returns: argparse.Namespace or None if no arguments were given @@ -23,8 +23,8 @@ def parse_args(args: list) -> Optional[argparse.Namespace]: parser = argparse.ArgumentParser(description="Automation of creating and updating STM32CubeMX-PlatformIO projects. " "Requirements: Python 3.6+, STM32CubeMX, Java, PlatformIO CLI. Run " - "'init' command to create settings file and set the path to " - "STM32CubeMX and other tools (if defaults doesn't work)") + "'init' command to create config file and set the path to STM32CubeMX " + "and other tools (if defaults doesn't work for you)") # Global arguments (there is also an automatically added '-h, --help' option) parser.add_argument('--version', action='version', version=f"stm32pio v{__version__}") parser.add_argument('-v', '--verbose', help="enable verbose output (default: INFO)", action='count', required=False) @@ -35,7 +35,7 @@ def parse_args(args: list) -> Optional[argparse.Namespace]: parser_init = subparsers.add_parser('init', help="create config .ini file so you can tweak parameters before " "proceeding") parser_new = subparsers.add_parser('new', help="generate CubeMX code, create PlatformIO project") - parser_generate = subparsers.add_parser('generate', help="generate CubeMX code") + parser_generate = subparsers.add_parser('generate', help="generate CubeMX code only") parser_clean = subparsers.add_parser('clean', help="clean-up the project (WARNING: it deletes ALL content of " "'path' except the .ioc file)") @@ -60,28 +60,34 @@ def parse_args(args: list) -> Optional[argparse.Namespace]: def main(sys_argv=None) -> int: """ - Can be used as high-level wrapper to do complete tasks + Can be used as a high-level wrapper to do complete tasks Example: ret_code = stm32pio.app.main(sys_argv=['new', '-d', '~/path/to/project', '-b', 'nucleo_f031k6', '--with-build']) Args: - sys_argv: list of strings + sys_argv: list of strings CLI arguments + + Returns: + 0 on success, -1 otherwise """ if sys_argv is None: sys_argv = sys.argv[1:] + import stm32pio.settings + args = parse_args(sys_argv) - # Logger instance goes through the whole program. - # Currently only 2 levels of verbosity through the '-v' option are counted (INFO (default) and DEBUG (-v)) - logger = logging.getLogger('stm32pio') + logger = logging.getLogger('stm32pio') # the root (relatively to the possible outer scope) logger instance handler = logging.StreamHandler() logger.addHandler(handler) + # Currently only 2 levels of verbosity through the '-v' option are counted (INFO (default) and DEBUG (-v)) if args is not None and args.subcommand is not None and args.verbose: logger.setLevel(logging.DEBUG) - handler.setFormatter(logging.Formatter("%(levelname)-8s %(funcName)-26s %(message)s")) + handler.setFormatter(logging.Formatter("%(levelname)-8s " + f"%(funcName)-{stm32pio.settings.log_function_fieldwidth}s " + "%(message)s")) logger.debug("debug logging enabled") elif args is not None and args.subcommand is not None: logger.setLevel(logging.INFO) @@ -92,9 +98,9 @@ def main(sys_argv=None) -> int: logger.info("\nNo arguments were given, exiting...") return 0 - # Main routine - import stm32pio.lib # import the module after sys.path modification + import stm32pio.lib # import the module after sys.path modification and logger configuration + # Main routine try: if args.subcommand == 'init': project = stm32pio.lib.Stm32pio(args.project_path, parameters={'board': args.board}) @@ -113,7 +119,7 @@ def main(sys_argv=None) -> int: project.pio_init() project.patch() if args.with_build: - project.pio_build() + project.build() if args.editor: project.start_editor(args.editor) @@ -121,7 +127,7 @@ def main(sys_argv=None) -> int: project = stm32pio.lib.Stm32pio(args.project_path, save_on_destruction=False) project.generate_code() if args.with_build: - project.pio_build() + project.build() if args.editor: project.start_editor(args.editor) diff --git a/stm32pio/lib.py b/stm32pio/lib.py index dd0f0ec..35c81a5 100644 --- a/stm32pio/lib.py +++ b/stm32pio/lib.py @@ -15,7 +15,7 @@ import stm32pio.settings -# Child logger +# Child logger, inherits parameters of the parent that has been set in more high-level code logger = logging.getLogger('stm32pio.util') @@ -23,7 +23,7 @@ class ProjectState(enum.IntEnum): """ Codes indicating a project state at the moment. Should be the sequence of incrementing integers to be suited for - state determining algorithm + state determining algorithm. Starting from 1 Hint: Files/folders to be present on every project state: UNDEFINED: use this state to indicate none of the states below. Also, when we do not have any .ioc file the @@ -36,7 +36,7 @@ class ProjectState(enum.IntEnum): BUILT: same as above + '.pio' folder with build artifacts (such as .pio/build/nucleo_f031k6/firmware.bin, .pio/build/nucleo_f031k6/firmware.elf) """ - UNDEFINED = enum.auto() + UNDEFINED = enum.auto() # note: starts from 1 INITIALIZED = enum.auto() GENERATED = enum.auto() PIO_INITIALIZED = enum.auto() @@ -45,11 +45,20 @@ class ProjectState(enum.IntEnum): class Config(configparser.ConfigParser): + """ + A simple subclass that has additional save() method for the better logic encapsulation + """ + def __init__(self, location: pathlib.Path, *args, **kwargs): + """ + Args: + location: project path (where to store the config file) + *args, **kwargs: passes to the parent's constructor + """ super().__init__(*args, **kwargs) self._location = location - def save(self): + def save(self) -> int: """ Tries to save the config to the file and gently log if any error occurs """ @@ -93,11 +102,10 @@ def __init__(self, dirty_path: str, parameters: dict = None, save_on_destruction # The path is a unique identifier of the project so it would be great to remake Stm32pio class as a subclass of # pathlib.Path and then reference it like self and not self.project_path. It is more consistent also, as now # project_path is perceived like any other config parameter that somehow is appeared to exist outside of a - # config instance but then it will be a core identifier, a truly 'self' value. - # - # But currently pathlib.Path is not intended to be subclassable by-design, unfortunately. See - # https://bugs.python.org/issue24132 + # config instance but then it will be a core identifier, a truly 'self' value. But currently pathlib.Path is not + # intended to be subclassable by-design, unfortunately. See https://bugs.python.org/issue24132 self.project_path = self._resolve_project_path(dirty_path) + self.config = self._load_config_file() ioc_file = self._find_ioc_file() @@ -125,7 +133,10 @@ def __init__(self, dirty_path: str, parameters: dict = None, save_on_destruction @property def state(self) -> ProjectState: """ - Property returning the current state of the project. Calculated at every request. + Property returning the current state of the project. Calculated at every request + + Returns: + enum value representing a project state """ logger.debug("calculating the project state...") @@ -137,7 +148,7 @@ def state(self) -> ProjectState: platformio_ini_is_patched = False states_conditions = collections.OrderedDict() - # Fill the ordered dictionary with conditions results + # Fill the ordered dictionary with the conditions results states_conditions[ProjectState.UNDEFINED] = [True] states_conditions[ProjectState.INITIALIZED] = [ self.project_path.joinpath(stm32pio.settings.config_file_name).is_file()] @@ -147,7 +158,7 @@ def state(self) -> ProjectState: len(list(self.project_path.joinpath('Src').iterdir())) > 0] states_conditions[ProjectState.PIO_INITIALIZED] = [ self.project_path.joinpath('platformio.ini').is_file() and - len(self.project_path.joinpath('platformio.ini').read_text()) > 0] + self.project_path.joinpath('platformio.ini').stat().st_size > 0] states_conditions[ProjectState.PATCHED] = [ platformio_ini_is_patched, not self.project_path.joinpath('include').is_dir()] states_conditions[ProjectState.BUILT] = [ @@ -166,10 +177,11 @@ def state(self) -> ProjectState: f"{state.name:20}{conditions_results[state.value - 1]}" for state in ProjectState) logger.debug(f"determined states:\n{states_info_str}") - # Search for a consecutive raw of 1's and find the last of them. For example, if the array is + # Search for a consecutive sequence of 1's and find the last of them. For example, if the array is # [1,1,0,1,0,0] # ^ - last_true_index = 0 # UNDEFINED is always True, use as a start value + # we should consider 1 as the last index + last_true_index = 0 # ProjectState.UNDEFINED is always True, use as a start value for index, value in enumerate(conditions_results): if value == 1: last_true_index = index @@ -178,8 +190,9 @@ def state(self) -> ProjectState: # Fall back to the UNDEFINED state if we have breaks in conditions results array. For example, in [1,1,0,1,0,0] # we still return UNDEFINED as it doesn't look like a correct combination of files actually - project_state = ProjectState.UNDEFINED - if 1 not in conditions_results[last_true_index + 1:]: + if 1 in conditions_results[last_true_index + 1:]: + project_state = ProjectState.UNDEFINED + else: project_state = ProjectState(last_true_index + 1) return project_state @@ -189,11 +202,16 @@ def _find_ioc_file(self) -> pathlib.Path: """ Find and return an .ioc file. If there are more than one, return first. If no .ioc file is present raise FileNotFoundError exception + + Returns: + absolute path to the .ioc file """ ioc_file = self.config.get('project', 'ioc_file', fallback=None) if ioc_file: - return pathlib.Path(ioc_file).resolve() + ioc_file = pathlib.Path(ioc_file).resolve() + logger.debug(f"use {ioc_file.name} file from the INI config") + return ioc_file else: logger.debug("searching for any .ioc file...") candidates = list(self.project_path.glob('*.ioc')) @@ -211,6 +229,9 @@ def _load_config_file(self) -> Config: """ Prepare configparser config for the project. First, read the default config and then mask these values with user ones + + Returns: + custom configparser.ConfigParser instance """ logger.debug(f"searching for {stm32pio.settings.config_file_name}...") @@ -243,6 +264,9 @@ def _resolve_project_path(dirty_path: str) -> pathlib.Path: Args: dirty_path (str): some directory in the filesystem + + Returns: + expanded absolute pathlib.Path instance """ resolved_path = pathlib.Path(dirty_path).expanduser().resolve() if not resolved_path.exists(): @@ -255,8 +279,11 @@ def _resolve_board(self, board: str) -> str: """ Check if given board is a correct board name in the PlatformIO database + Args: + board: string representing PlatformIO board name (for example, 'nucleo_f031k6') + Returns: - same board that has been given, raise an exception otherwise + same board that has been given if it was found, raise an exception otherwise """ logger.debug("searching for PlatformIO board...") @@ -274,51 +301,62 @@ def _resolve_board(self, board: str) -> str: raise Exception("failed to search for PlatformIO boards") - def generate_code(self) -> None: + def generate_code(self) -> int: """ Call STM32CubeMX app as a 'java -jar' file to generate the code from the .ioc file. Pass commands to the STM32CubeMX in a temp file + + Returns: + return code on success, raises an exception otherwise """ # Use mkstemp() instead of higher-level API for compatibility with Windows (see tempfile docs for more details) cubemx_script_file, cubemx_script_name = tempfile.mkstemp() - # buffering=0 leads to the immediate flushing on writing - with open(cubemx_script_file, mode='w+b', buffering=0) as cubemx_script: - cubemx_script.write(self.config.get('project', 'cubemx_script_content').encode()) # encode since mode='w+b' - - logger.info("starting to generate a code from the CubeMX .ioc file...") - command_arr = [self.config.get('app', 'java_cmd'), '-jar', self.config.get('app', 'cubemx_cmd'), '-q', - cubemx_script_name, '-s'] # -q: read commands from file, -s: silent performance - if logger.getEffectiveLevel() <= logging.DEBUG: - result = subprocess.run(command_arr) - else: - result = subprocess.run(command_arr, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - # Or, for Python 3.7 and above: - # result = subprocess.run(command_arr, capture_output=True) - if result.returncode == 0: - logger.info("successful code generation") - else: - logger.error(f"code generation error (CubeMX return code is {result.returncode}).\n" - "Enable a verbose output or try to generate a code from the CubeMX itself.") - raise Exception("code generation error") - - pathlib.Path(cubemx_script_name).unlink() + # We should necessarily remove the temp directory, so do not let any exception break our plans + try: + # buffering=0 leads to the immediate flushing on writing + with open(cubemx_script_file, mode='w+b', buffering=0) as cubemx_script: + # encode since mode='w+b' + cubemx_script.write(self.config.get('project', 'cubemx_script_content').encode()) + + logger.info("starting to generate a code from the CubeMX .ioc file...") + command_arr = [self.config.get('app', 'java_cmd'), '-jar', self.config.get('app', 'cubemx_cmd'), '-q', + cubemx_script_name, '-s'] # -q: read commands from file, -s: silent performance + if logger.getEffectiveLevel() <= logging.DEBUG: + result = subprocess.run(command_arr) + else: + result = subprocess.run(command_arr, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + # Or, for Python 3.7 and above: + # result = subprocess.run(command_arr, capture_output=True) + except Exception as e: + raise e # re-raise an exception after the final block + finally: + pathlib.Path(cubemx_script_name).unlink() + if result.returncode == 0: + logger.info("successful code generation") + return result.returncode + else: + logger.error(f"code generation error (CubeMX return code is {result.returncode}).\n" + "Enable a verbose output or try to generate a code from the CubeMX itself.") + raise Exception("code generation error") def pio_init(self) -> int: """ Call PlatformIO CLI to initialize a new project. It uses parameters (path, board) collected before so the confirmation of the data presence is on a user + + Returns: + return code of the PlatformIO on success, raises an exception otherwise """ logger.info("starting PlatformIO project initialization...") platformio_ini_file = self.project_path.joinpath('platformio.ini') - if platformio_ini_file.is_file() and len(platformio_ini_file.read_text()) > 0: - logger.warning("'platformio.ini' file is already existing") + if platformio_ini_file.is_file() and platformio_ini_file.stat().st_size > 0: + logger.warning("'platformio.ini' file is already exist") - # TODO: move out to config a 'framework' option and to settings a 'platformio.ini' file name command_arr = [self.config.get('app', 'platformio_cmd'), 'init', '-d', str(self.project_path), '-b', self.config.get('project', 'board'), '-O', 'framework=stm32cube'] if logger.getEffectiveLevel() > logging.DEBUG: @@ -341,7 +379,11 @@ def pio_init(self) -> int: def platformio_ini_is_patched(self) -> bool: """ - Check whether 'platformio.ini' config file is patched or not. It doesn't check for unnecessary folders deletion + Check whether 'platformio.ini' config file is patched or not. It doesn't check for complete project patching + (e.g. unnecessary folders deletion). Throws an error on non-existing file and on incorrect patch or file + + Returns: + boolean indicating a result """ platformio_ini = configparser.ConfigParser(interpolation=None) @@ -362,9 +404,13 @@ def platformio_ini_is_patched(self) -> bool: for patch_section in patch_config.sections(): if platformio_ini.has_section(patch_section): for patch_key, patch_value in patch_config.items(patch_section): - if platformio_ini.get(patch_section, patch_key, fallback=None) != patch_value: + platformio_ini_value = platformio_ini.get(patch_section, patch_key, fallback=None) + if platformio_ini_value != patch_value: + logger.debug(f"[{patch_section}]{patch_key}: patch value is\n{patch_value}\nbut " + f"platformio.ini contains\n{platformio_ini_value}") return False else: + logger.debug(f"platformio.ini has not {patch_section} section") return False return True @@ -392,33 +438,41 @@ def patch(self) -> None: # Merge 2 configs for patch_section in patch_config.sections(): if not platformio_ini_config.has_section(patch_section): + logger.debug(f"[{patch_section}] section was added") platformio_ini_config.add_section(patch_section) for patch_key, patch_value in patch_config.items(patch_section): + logger.debug(f"set [{patch_section}]{patch_key} = {patch_value}") platformio_ini_config.set(patch_section, patch_key, patch_value) - # Save, overwriting the original file + # Save, overwriting the original file (deletes all comments!) with self.project_path.joinpath('platformio.ini').open(mode='w') as platformio_ini_file: platformio_ini_config.write(platformio_ini_file) logger.info("'platformio.ini' has been patched") - shutil.rmtree(self.project_path.joinpath('include'), ignore_errors=True) - + try: + shutil.rmtree(self.project_path.joinpath('include')) + except: + logger.info("cannot delete 'include' folder", exc_info=logger.getEffectiveLevel() <= logging.DEBUG) # Remove 'src' directory too but on case-sensitive file systems 'Src' == 'src' == 'SRC' so we need to check - # first if not self.project_path.joinpath('SRC').is_dir(): - shutil.rmtree(self.project_path.joinpath('src'), ignore_errors=True) + try: + shutil.rmtree(self.project_path.joinpath('src')) + except: + logger.info("cannot delete 'src' folder", exc_info=logger.getEffectiveLevel() <= logging.DEBUG) def start_editor(self, editor_command: str) -> int: """ - Start the editor specified by 'editor_command' with the project opened + Start the editor specified by 'editor_command' with the project opened (assume + $ [editor] [folder] + form works) Args: editor_command: editor command as we start it in the terminal Returns: - return code of the editor on success, -1 otherwise + passes a return code of the command """ logger.info(f"starting an editor '{editor_command}'...") @@ -426,20 +480,22 @@ def start_editor(self, editor_command: str) -> int: try: # Works unstable on some Windows 7 systems, but correct on latest Win7 and Win10... # result = subprocess.run([editor_command, str(self.project_path)], check=True) - result = subprocess.run(f"{editor_command} {str(self.project_path)}", check=True, shell=True) - return result.returncode if result.returncode != -1 else 0 + result = subprocess.run(f"{editor_command} {str(self.project_path)}", shell=True, check=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return result.returncode except subprocess.CalledProcessError as e: - logger.error(f"failed to start the editor {editor_command}: {e.stderr}") - return -1 + output = e.stdout if e.stderr is None else e.stderr + logger.error(f"failed to start the editor {editor_command}: {output}") + return e.returncode - def pio_build(self) -> int: + def build(self) -> int: """ - Initiate a build of the PlatformIO project by the PlatformIO ('run' command). PlatformIO prints error message - by itself to the STDERR so there is no need to catch it and outputs by us + Initiate a build of the PlatformIO project by the PlatformIO ('run' command). PlatformIO prints warning and + error messages by itself to the STDERR so there is no need to catch it and output by us Returns: - 0 if success, raise an exception otherwise + passes a return code of the PlatformIO """ command_arr = [self.config.get('app', 'platformio_cmd'), 'run', '-d', str(self.project_path)] @@ -463,7 +519,7 @@ def clean(self) -> None: if child.name != f"{self.project_path.name}.ioc": if child.is_dir(): shutil.rmtree(child, ignore_errors=True) - logger.debug(f"del {child}/") + logger.debug(f"del {child}") elif child.is_file(): child.unlink() logger.debug(f"del {child}") diff --git a/stm32pio/settings.py b/stm32pio/settings.py index f6f9db9..9e54f0c 100644 --- a/stm32pio/settings.py +++ b/stm32pio/settings.py @@ -43,3 +43,5 @@ ) config_file_name = 'stm32pio.ini' + +log_function_fieldwidth = 26 diff --git a/stm32pio/tests/test.py b/stm32pio/tests/test.py index 9c53e58..445ec8a 100755 --- a/stm32pio/tests/test.py +++ b/stm32pio/tests/test.py @@ -1,8 +1,8 @@ """ -'pyenv' is recommended to use for testing with different Python versions +'pyenv' was used to perform tests with different Python versions under Ubuntu: https://www.tecmint.com/pyenv-install-and-manage-multiple-python-versions-in-linux/ -To get test coverage use 'coverage': +To get the test coverage install and use 'coverage': $ coverage run -m stm32pio.tests.test -b $ coverage html """ @@ -24,9 +24,9 @@ import stm32pio.settings -STM32PIO_MAIN_SCRIPT = inspect.getfile(stm32pio.app) # absolute path to the main stm32pio script +STM32PIO_MAIN_SCRIPT: str = inspect.getfile(stm32pio.app) # absolute path to the main stm32pio script # absolute path to the Python executable (no need to guess whether it's python or python3 and so on) -PYTHON_EXEC = sys.executable +PYTHON_EXEC: str = sys.executable # Test data TEST_PROJECT_PATH = pathlib.Path('stm32pio-test-project').resolve() @@ -39,6 +39,8 @@ # Instantiate a temporary folder on every fixture run. It is used across all tests and is deleted on shutdown temp_dir = tempfile.TemporaryDirectory() FIXTURE_PATH = pathlib.Path(temp_dir.name).joinpath(TEST_PROJECT_PATH.name) +print(f"The file of 'stm32pio.app' module: {STM32PIO_MAIN_SCRIPT}") +print(f"Python executable: {PYTHON_EXEC} {sys.version}") print(f"Temp test fixture path: {FIXTURE_PATH}") @@ -66,12 +68,12 @@ class TestUnit(CustomTestCase): """ Test the single method. As we at some point decided to use a class instead of the set of scattered functions we need to do some preparations for almost every test (e.g. instantiate the class, create the PlatformIO project, etc.), - though + though, so the architecture now is way less modular """ def test_generate_code(self): """ - Check whether files and folders have been created by STM32CubeMX + Check whether files and folders have been created (by STM32CubeMX) """ project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'board': TEST_PROJECT_BOARD}, save_on_destruction=False) @@ -80,13 +82,13 @@ def test_generate_code(self): # Assuming that the presence of these files indicating a success files_should_be_present = ['Src/main.c', 'Inc/main.h'] for file in files_should_be_present: - with self.subTest(file_should_be_present=file, msg=f"{file} hasn't been created"): + with self.subTest(msg=f"{file} hasn't been created"): self.assertEqual(FIXTURE_PATH.joinpath(file).is_file(), True) def test_pio_init(self): """ Consider that existence of 'platformio.ini' file showing a successful PlatformIO project initialization. The - last one has another traces those can be checked too but we are interested only in a 'platformio.ini' anyway. + last one has another traces that can be checked too but we are interested only in a 'platformio.ini' anyway. Also, check that it is a correct configparser file and is not empty """ project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'board': TEST_PROJECT_BOARD}, @@ -105,10 +107,8 @@ def test_patch(self): Check that new parameters were added, modified were updated and existing parameters didn't gone. Also, check for unnecessary folders deletion """ - project = stm32pio.lib.Stm32pio(FIXTURE_PATH, save_on_destruction=False) - # We do not create a real project here so we don't depend on other possible issues test_content = inspect.cleandoc(''' ; This is a test config .ini file ; with a comment. It emulates a real @@ -128,7 +128,8 @@ def test_patch(self): project.patch() - self.assertFalse(FIXTURE_PATH.joinpath('include').is_dir(), msg="'include' has not been deleted") + with self.subTest(): + self.assertFalse(FIXTURE_PATH.joinpath('include').is_dir(), msg="'include' has not been deleted") original_test_config = configparser.ConfigParser(interpolation=None) original_test_config.read_string(test_content) @@ -157,14 +158,15 @@ def test_patch(self): def test_build_should_handle_error(self): """ - Build an empty project so PlatformIO should return the error + Build an empty project so PlatformIO should return an error """ project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'board': TEST_PROJECT_BOARD}, save_on_destruction=False) project.pio_init() with self.assertLogs(level='ERROR') as logs: - self.assertNotEqual(project.pio_build(), 0, msg="Build error was not indicated") + self.assertNotEqual(project.build(), 0, msg="Build error was not indicated") + # next() - Technique to find something in array, string, etc. (or to indicate that there is no) self.assertTrue(next((True for item in logs.output if "PlatformIO build error" in item), False), msg="Error message does not match") @@ -208,9 +210,10 @@ def test_run_editor(self): time.sleep(1) # wait a little bit for app to start - command_arr = ['ps', '-A'] if platform.system() == 'Windows': command_arr = ['wmic', 'process', 'get', 'description'] + else: + command_arr = ['ps', '-A'] # "encoding='utf-8'" is for "a bytes-like object is required, not 'str'" in "assertIn" result = subprocess.run(command_arr, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8') @@ -237,18 +240,20 @@ def test_save_config(self): # 'board' is non-default, 'project'-section parameter project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'board': TEST_PROJECT_BOARD}, save_on_destruction=False) - project.config.save() self.assertTrue(FIXTURE_PATH.joinpath(stm32pio.settings.config_file_name).is_file(), msg=f"{stm32pio.settings.config_file_name} file hasn't been created") config = configparser.ConfigParser(interpolation=None) - config.read(str(FIXTURE_PATH.joinpath(stm32pio.settings.config_file_name))) + self.assertGreater(len(config.read(str(FIXTURE_PATH.joinpath(stm32pio.settings.config_file_name)))), 0, + msg="Config is empty") for section, parameters in stm32pio.settings.config_default.items(): for option, value in parameters.items(): - with self.subTest(section=section, option=option, msg="Section/key is not found in saved config file"): + with self.subTest(section=section, option=option, + msg="Section/key is not found in the saved config file"): self.assertNotEqual(config.get(section, option, fallback="Not found"), "Not found") + self.assertEqual(config.get('project', 'board', fallback="Not found"), TEST_PROJECT_BOARD, msg="'board' has not been set") @@ -262,7 +267,6 @@ def test_config_priorities(self): """ Test the compliance with priorities when reading the parameters """ - # Sample user's custom patch value config_parameter_user_value = inspect.cleandoc(''' [test_section] @@ -284,13 +288,13 @@ def test_config_priorities(self): config.write(config_file) # On project creation we should interpret the CLI-provided values as superseding to the saved ones and - # saved ones as superseding to the default ones + # saved ones, in turn, as superseding to the default ones project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'board': cli_parameter_user_value}, save_on_destruction=False) project.pio_init() project.patch() - # Actually, we can parse platformio.ini via configparser but this is simpler + # Actually, we can parse platformio.ini via configparser but this is simpler in our case after_patch_content = FIXTURE_PATH.joinpath('platformio.ini').read_text() self.assertIn(config_parameter_user_value, after_patch_content, msg="User config parameter has not been prioritized over the default one") @@ -307,7 +311,7 @@ def test_build(self): project.pio_init() project.patch() - result = project.pio_build() + result = project.build() self.assertEqual(result, 0, msg="Build failed") @@ -341,18 +345,22 @@ def test_regenerate_code(self): project.generate_code() # Check if added information is preserved - main_c_after_regenerate_content = test_file_1.read_text() - my_header_h_after_regenerate_content = test_file_2.read_text() - self.assertIn(test_content_1, main_c_after_regenerate_content, - msg=f"User content hasn't been preserved after regeneration in {test_file_1}") - self.assertIn(test_content_2, my_header_h_after_regenerate_content, - msg=f"User content hasn't been preserved after regeneration in {test_file_2}") + for test_content, after_regenerate_content in [(test_content_1, test_file_1.read_text()), + (test_content_2, test_file_2.read_text())]: + with self.subTest(msg=f"User content hasn't been preserved in {after_regenerate_content}"): + self.assertIn(test_content, after_regenerate_content) + + # main_c_after_regenerate_content = test_file_1.read_text() + # my_header_h_after_regenerate_content = test_file_2.read_text() + # self.assertIn(test_content_1, main_c_after_regenerate_content, + # msg=f"User content hasn't been preserved after regeneration in {test_file_1}") + # self.assertIn(test_content_2, my_header_h_after_regenerate_content, + # msg=f"User content hasn't been preserved after regeneration in {test_file_2}") def test_get_state(self): """ Go through the sequence of states emulating the real-life project lifecycle """ - project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'board': TEST_PROJECT_BOARD}, save_on_destruction=False) self.assertEqual(project.state, stm32pio.lib.ProjectState.UNDEFINED) @@ -369,7 +377,7 @@ def test_get_state(self): project.patch() self.assertEqual(project.state, stm32pio.lib.ProjectState.PATCHED) - project.pio_build() + project.build() self.assertEqual(project.state, stm32pio.lib.ProjectState.BUILT) project.clean() @@ -394,17 +402,19 @@ def test_clean(self): self.assertEqual(return_code, 0, msg="Non-zero return code") # Look for remaining items - self.assertFalse(file_should_be_deleted.is_file(), msg=f"{file_should_be_deleted} is still there") - self.assertFalse(dir_should_be_deleted.is_dir(), msg=f"{dir_should_be_deleted} is still there") + with self.subTest(): + self.assertFalse(file_should_be_deleted.is_file(), msg=f"{file_should_be_deleted} is still there") + with self.subTest(): + self.assertFalse(dir_should_be_deleted.is_dir(), msg=f"{dir_should_be_deleted} is still there") # And .ioc file should be preserved - self.assertTrue(FIXTURE_PATH.joinpath(f"{FIXTURE_PATH.name}.ioc").is_file(), msg="Missing .ioc file") + with self.subTest(): + self.assertTrue(FIXTURE_PATH.joinpath(f"{FIXTURE_PATH.name}.ioc").is_file(), msg="Missing .ioc file") def test_new(self): """ - Successful build is the best indicator that all went right so we use '--with-build' option + Successful build is the best indicator that all went right so we use '--with-build' option here """ - return_code = stm32pio.app.main(sys_argv=['new', '-d', str(FIXTURE_PATH), '-b', TEST_PROJECT_BOARD, '--with-build']) self.assertEqual(return_code, 0, msg="Non-zero return code") @@ -416,13 +426,11 @@ def test_generate(self): return_code = stm32pio.app.main(sys_argv=['generate', '-d', str(FIXTURE_PATH)]) self.assertEqual(return_code, 0, msg="Non-zero return code") - inc_dir = 'Inc' - src_dir = 'Src' - - self.assertTrue(FIXTURE_PATH.joinpath(inc_dir).is_dir(), msg=f"Missing '{inc_dir}'") - self.assertTrue(FIXTURE_PATH.joinpath(src_dir).is_dir(), msg=f"Missing '{src_dir}'") - self.assertFalse(len(list(FIXTURE_PATH.joinpath(inc_dir).iterdir())) == 0, msg=f"'{inc_dir}' is empty") - self.assertFalse(len(list(FIXTURE_PATH.joinpath(src_dir).iterdir())) == 0, msg=f"'{src_dir}' is empty") + for directory in ['Inc', 'Src']: + with self.subTest(): + self.assertTrue(FIXTURE_PATH.joinpath(directory).is_dir(), msg=f"Missing '{directory}'") + self.assertNotEqual(len(list(FIXTURE_PATH.joinpath(directory).iterdir())), 0, + msg=f"'{directory}' is empty") # .ioc file should be preserved self.assertTrue(FIXTURE_PATH.joinpath(f"{FIXTURE_PATH.name}.ioc").is_file(), msg="Missing .ioc file") @@ -431,7 +439,6 @@ def test_incorrect_path_should_log_error(self): """ We should see an error log message and non-zero return code """ - path_not_exist = pathlib.Path('path/does/not/exist') with self.assertLogs(level='ERROR') as logs: @@ -444,7 +451,6 @@ def test_no_ioc_file_should_log_error(self): """ We should see an error log message and non-zero return code """ - dir_with_no_ioc_file = FIXTURE_PATH.joinpath('dir.with.no.ioc.file') dir_with_no_ioc_file.mkdir(exist_ok=False) @@ -459,10 +465,8 @@ def test_verbose(self): Run as subprocess to capture the full output. Check for both 'DEBUG' logging messages and STM32CubeMX CLI output. Verbose logs format should match such a regex: - ^(?=(DEBUG|INFO|WARNING|ERROR|CRITICAL) {0,4})(?=.{8} (?=(pio_build|pio_init|...) {0,26})(?=.{26} [^ ])) - + ^(?=(DEBUG|INFO|WARNING|ERROR|CRITICAL) {0,4})(?=.{8} (?=(build|pio_init|...) {0,26})(?=.{26} [^ ])) """ - project = stm32pio.lib.Stm32pio(FIXTURE_PATH, save_on_destruction=False) methods = [method[0] for method in inspect.getmembers(project, predicate=inspect.ismethod)] methods.append('main') @@ -474,10 +478,12 @@ def test_verbose(self): # Somehow stderr and not stdout contains the actual output but we check both self.assertTrue('DEBUG' in result.stderr or 'DEBUG' in result.stdout, msg="Verbose logging output hasn't been enabled on stderr") - regex = re.compile("^(?=(DEBUG) {0,4})(?=.{8} (?=(" + '|'.join(methods) + ") {0,26})(?=.{26} [^ ]))", + # Inject all methods' names in the regex. Inject the width of field in a log format string + regex = re.compile("^(?=(DEBUG) {0,4})(?=.{8} (?=(" + '|'.join(methods) + ") {0," + + str(stm32pio.settings.log_function_fieldwidth) + "})(?=.{" + + str(stm32pio.settings.log_function_fieldwidth) + "} [^ ]))", flags=re.MULTILINE) - self.assertGreaterEqual(len(re.findall(regex, result.stderr)), 1, - msg="Logs messages doesn't match the format") + self.assertGreaterEqual(len(re.findall(regex, result.stderr)), 1, msg="Logs messages doesn't match the format") self.assertIn('Starting STM32CubeMX', result.stdout, msg="STM32CubeMX didn't print its logs") @@ -486,10 +492,8 @@ def test_non_verbose(self): Run as subprocess to capture the full output. We should not see any 'DEBUG' logging messages or STM32CubeMX CLI output. Logs format should match such a regex: - ^(?=(INFO) {0,4})(?=.{8} ((?!( |pio_build|pio_init|...)))) - + ^(?=(INFO) {0,4})(?=.{8} ((?!( |build|pio_init|...)))) """ - project = stm32pio.lib.Stm32pio(FIXTURE_PATH, save_on_destruction=False) methods = [method[0] for method in inspect.getmembers(project, predicate=inspect.ismethod)] methods.append('main') @@ -511,7 +515,6 @@ def test_init(self): """ Check for config creation and parameters presence """ - result = subprocess.run([PYTHON_EXEC, STM32PIO_MAIN_SCRIPT, 'init', '-d', str(FIXTURE_PATH), '-b', TEST_PROJECT_BOARD]) self.assertEqual(result.returncode, 0, msg="Non-zero return code")