diff --git a/CHANGELOG b/CHANGELOG index afcf3d2..9549bac 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -86,6 +86,7 @@ stm32pio changelog: - New: add PyCharm to .gitignore - New: add clear TODOs for the next release (some sort of a roadmap) - New: single __version__ reference + - New: extended shebang - New: add some new tests (test_build_should_raise, test_file_not_found) - Fixed: options '--start-editor' and '--with-build' can now be used both for 'new' and 'generate' commands - Fixed: import scheme is now as it should be @@ -96,6 +97,31 @@ stm32pio changelog: - Changed: logging output in standard (non-verbose) mode is simpler - Changed: move tests in new location - Changed: revised and improved tests - - Changed: tests are now run by 'python -m unittest' and cannot be run as standalone - Changed: actualized .ioc file and clean-up the code according to the latest STM32CubeMX version (5.3.0 at the moment) - Changed: revised and improved util module + + ver. 0.9 (12.19): + - New: tested with Python3 version of PlatformIO + - New: '__main__.py' file (to run the app as module (python -m stm32pio)) + - New: 'init' subcommand (initialize the project only, useful for the preliminary tweaking) + - New: introducing the OOP pattern: we have now a Stm32pio class representing a single project (project path as a main identifier) + - New: projects now have a config file stm32pio.ini where the user can set the variety of parameters + - New: 'state' property calculating the estimated project state on every request to itself (beta). It is the concept for future releases + - New: STM32CubeMX is now started more silently (without a splash screen) + - New: add integration and CLI tests (sort of) + - New: testing with different Python versions using pyenv (3.6+ target) + - New: 'run_editor' test is now preliminary automatically check whether an editor is installed on the machine + - New: more typing annotations + - Fixed: the app has been failed to start as 'python app.py' (modify sys.path to fix) + - Changed: 'main' function is now fully modular: can be run from anywhere with given CLI arguments (will be piped forward to be parsed via 'argparse') + - Changed: rename stm32pio.py -> app.py (stm32pio is the name of the package as a whole) + - Changed: rename util.py -> lib.py (means main library) + - Changed: logging is now more modular: we do not set global 'basicConfig' and specify separated loggers for each module instead + - Changed: more clear description of steps to do for each user subcommand by the code + - Changed: get rid of 'print' calls leaving only logging messages (easy to turn on/off the console output in the outer code) + - Changed: re-imagined API behavior: where to raise exceptions, where to return values and so on + - Changed: more clean API, e.g. move out the board resolving procedure from the 'pio_init' method and so on + - Changed: test fixture is now moved out from the repo and is deployed temporarily on every test run + - Changed: set-up and tear-down stages are now done using 'unittest' API + - Changed: actualized .ioc file for the latest STM32CubeMX version (5.4.0 at the moment) + - Changed: improved help, docs, comments diff --git a/MANIFEST.in b/MANIFEST.in index d1bb712..3c52153 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,8 +1,8 @@ +include MANIFEST.in include README.md include LICENSE -include MANIFEST.in include CHANGELOG include TODO.md include .gitignore -recursive-include stm32pio/tests * +recursive-include stm32pio-test-project * include screenshots/*.png diff --git a/README.md b/README.md index 8c87e9d..d860d95 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,64 @@ # stm32pio Small cross-platform Python app that can create and update [PlatformIO](https://platformio.org) projects from [STM32CubeMX](https://www.st.com/en/development-tools/stm32cubemx.html) `.ioc` files. +It uses STM32CubeMX to generate a HAL-framework based code and alongside creates PlatformIO project with the compatible `stm32cube` framework specified. + ![Logo](/screenshots/logo.png) ## Features - Start the new project in a single directory using only an `.ioc` file - Update existing project after changing hardware options from CubeMX - - Clean-up the project (WARNING: it deletes ALL content of 'path' except the `.ioc` file!) + - Clean-up the project (WARNING: it deletes ALL content of project path except the `.ioc` file!) - *[optional]* Automatically run your favorite editor in the end - - *[optional]* Make an initial build of the project - - -## Restrictions - - The tool doesn't check for different parameters compatibility, e.g. CPU frequency, memory sizes and so on. It simply eases your workflow with these 2 programs (PlatformIO and STM32CubeMX) a little bit. - - CubeMX middlewares don't support yet because it's hard to be prepared for every possible configuration. You need to manually adjust them to build appropriately. For example, FreeRTOS can be added via PlatformIO' `lib` feature or be directly compiled in its own directory using `lib_extra_dirs` option: - ```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. + - *[optional]* Automatically make an initial build of the project ## Requirements: - For this app: - - Python 3.6+ + - Python 3.6 and above - For usage: - macOS, Linux, Windows - - STM32CubeMX (all recent versions) with downloaded necessary frameworks (F0, F1, etc.). Try to generate code in ordinary way (through the GUI) at least once before running stm32pio - - Java CLI (JRE) (likely is already installed if STM32CubeMX works) - - PlatformIO CLI. + - STM32CubeMX (all recent versions) with desired downloaded frameworks (F0, F1, etc.) + - Java CLI (JRE) (likely is already installed if the STM32CubeMX is working) + - PlatformIO CLI (already presented if you have installed PlatformIO via some package manager or need to be installed as the command line extension from IDE) + +A general recommendation there would be to try to generate and build a code manually (via the CubeMX GUI and PlatformIO CLI or IDE) at least once before using stm32pio to make sure that all tools are working properly without any "glue". + + +## Installation +Starting from v0.8 it is possible to install the utility to be able to run stm32pio from anywhere. Use +```shell script +stm32pio-repo/ $ pip3 install . +``` +command to launch the setup process. To uninstall run +```shell script +$ pip3 uninstall stm32pio +``` ## Usage Basically, you need to follow such a pattern: - 1. Create CubeMX project, set-up hardware configuration - 2. Run stm32pio that automatically invoke CubeMX to generate the code, create PlatformIO project, patch an '.ini' file and so on - 3. Work on the project in your editor, compile/upload/debug it + 1. Create CubeMX project, set-up your hardware configuration + 2. Run stm32pio that automatically invoke CubeMX to generate the code, create PlatformIO project, patch a 'platformio.ini' file and so on + 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. -stm32pio will create an accessory file `cubemx-script`'` in your project directory that contains commands passed to CubeMX. You can safely delete it (it will be created again on the next run) or edit corresponding to your goals. - -Check `settings.py` to make sure that all user-specific parameters are valid. Run +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 -$ python3 stm32pio.py --help +$ stm32pio init -d path/to/project ``` -to see help. +It may be useful to tweak some parameters before proceeding. The structure of the config is separated in two sections: `app` and `project`. Options of the first one is related to the global settings such as commands to invoke different instruments though they can be adjusted on the per-project base while the second section contains of project-related parameters. See the comments in the [`settings.py`](/stm32pio/settings.py) file for parameters description. - -## Installation -Starting from v0.8 it is possible to install the utility to be able to run stm32pio from anywhere. Use +You can always run ```shell script -stm32pio-repo/ $ pip3 install . -``` -command to launch the setup process. To uninstall run -```shell script -$ pip3 uninstall stm32pio +$ 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. ## Example @@ -74,26 +75,45 @@ $ pip3 uninstall stm32pio 5. Run `platformio boards` (`pio boards`) or go to [boards](https://docs.platformio.org/en/latest/boards) to list all supported devices. Pick one and use its ID as a `-b` argument (for example, `nucleo_f031k6`) 6. All done! You can now run ```shell script - $ python3 stm32pio.py new -d path/to/cubemx/project/ -b nucleo_f031k6 --start-editor=vscode --with-build + $ python3 app.py new -d path/to/cubemx/project/ -b nucleo_f031k6 --start-editor=code --with-build ``` - to complete generation, start the Visual Studio Code editor with opened folder and compile the project (as an example, not required). Make sure you have all tools in PATH (`java` (or set its path in `settings.py`), `python`, editor). You can use shorter form if you are already located in the project directory (also using shebang alias): + to complete generation, start the Visual Studio Code editor with opened folder and compile the project (as an example, not required). Make sure you have all tools in PATH (`java` (or set its path in `stm32pio.ini`), `platformio`, `python`, editor). You can use shorter form if you are already located in the project directory (also using shebang alias): ```shell script - path/to/cubemx/project/ $ stm32pio.py new -b nucleo_f031k6 + path/to/cubemx/project/ $ stm32pio new -b nucleo_f031k6 ``` 7. If you will be in need to update hardware configuration in the future, make all necessary stuff in CubeMX and run `generate` command in a similar way: ```shell script - $ python3 stm32pio.py generate -d /path/to/cubemx/project + $ python3 app.py generate -d /path/to/cubemx/project ``` 8. To clean-up the folder and keep only the `.ioc` file run `clean` command ## Testing -Since ver. 0.45 there are some unit-tests in file `stm32pio/tests/test.py` (based on the unittest module). Run +Since ver. 0.45 there are some tests in file [`test.py`](/stm32pio/tests/test.py) (based on the unittest module). Run ```shell script -stm32pio-repo/ $ python3 -m unittest discover -v +stm32pio-repo/ $ python3 -m unittest -b -v ``` or ```shell script -stm32pio-repo/ $ python3 -m stm32pio.tests.test -v +stm32pio-repo/ $ python3 -m stm32pio.tests.test -b -v +``` +to test the app. It uses STM32F0 framework to generate and build a code from the test [`stm32pio-test-project.ioc`](/stm32pio-test-project/stm32pio-test-project.ioc) project file. Please make sure that the test project folder is clean (i.e. contains only an .ioc file) before running the test. + +For specific test suite or case you can use +```shell script +stm32pio-repo/ $ python3 -m unittest stm32pio.tests.test.TestIntegration -b -v +stm32pio-repo/ $ python3 -m unittest stm32pio.tests.test.TestCLI.test_verbose -b -v ``` -to test the app. It uses STM32F0 framework to generate and build a code from the `stm32pio/tests/stm32pio-test-project/stm32pio-test-project.ioc` file. It's fine to fail an editor test as you not necessarily should have all the editors on your machine. CI is hard to implement for all target OSes during the requirement to have all tools (PlatformIO, Java, CubeMX, etc.) installed during the test. For example, ST doesn't even provide a direct link to CubeMX for downloading + +While testing was performed on different Python and OS versions, some older Windows versions had shown some 'glitches' and instability. [WinError 5] and others had appeared on such tests like `test_run_edtor` and on `tempfile` clean-up processes. So be ready to face off with them. + +CI is hard to implement for all target OSes during the requirement to have all the tools (PlatformIO, Java, CubeMX, etc.) installed during the test. For example, ST doesn't even provide a direct link to the CubeMX for downloading. + + +## Restrictions + - The tool doesn't check for different parameters compatibility, e.g. CPU frequency, memory sizes and so on. It simply eases your workflow with these 2 programs (PlatformIO and STM32CubeMX) a little bit. + - CubeMX middlewares are not supported yet because it's hard to be prepared for every possible configuration. You need to manually adjust them to build appropriately. For example, FreeRTOS can be added via PlatformIO' `lib` feature or be directly compiled in its own directory using `lib_extra_dirs` option: + ```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. diff --git a/TODO.md b/TODO.md index 32f3df7..8c618e8 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,34 @@ # TODOs + - [ ] Middleware support (FreeRTOS, etc.) + - [ ] Arduino framework support - [ ] Add more checks, for example when updating the project (`generate` command), check for boards matching and so on... - - [ ] Do we need some sort of GUI? For example, drop the folder into small window (with checkboxes corresponding with CLI options) and get the output - - [ ] remade as Class (constructor init(project_path)) - - [ ] test CLI (i.e. run stm32pio as subprocess) - - [ ] upload to PyPI + - [x] Function annotations + - [ ] GUI. For example, drop the folder into small window (with checkboxes corresponding with CLI options) and get the output. At left is a list of recent projects + - [ ] GUI. Indicate the progress as states goes forward during the run (see `scratch.py`) + - [x] Remade as Class (constructor `__init__(project_path)`) + - [x] Config file for every project instead of the `settings.py` (but we still sill be storing the default parameters there) + - [x] Test CLI (integration testing) + - [x] Move test fixtures out of the 'tests' so we can use it for multiple projects (for example while testing CLI and GUI versions). Set up test folder for every single test so we make sure the .ioc file is always present and not deleted after failed test + - [ ] Upload to PyPI + - [x] `__main__` + - [x] Abort `--with-build` if no platformio.ini file is present + - [x] Rename `stm32pio.py` -> `app.py` + - [x] Rename `util.py` -> `lib.py` (maybe) + - [x] Do not require matching of the project folder and .ioc file names (use first .ioc file found) + - [x] Remove casts to string where we can use path-like objects + - [x] Settings string templates and multi line + - [x] Smart `start_editor` test (detect editors in system, maybe use unittest `skipIf` decorator) + - [x] `init` command + - [x] New argparse algo cause now we have config file + - [x] Update `.ioc` file + - [x] `str(path)` -> `path` were possible + - [x] Check `start_editor()` for different input + - [x] Test on Python 3.6 (pyenv) + - [ ] Test for `get_state()` (as sequence of states (see scratch.py)) + - [x] Remake `get_state()` as property value (read-only getter with decorator) + - [ ] If the project path is a unique identifier of the project in our code maybe we can remake `Stm32pio` class as a subclass of `pathlib.Path` and then reference it like `self` and not `self.project_path`. It would be 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. + - [x] Try to invoke stm32pio as module (-m), from different paths... + - [ ] Logs format test (see prepared regular expressions) + - [ ] Some non-intrusive installation test (may be some sort of temp virtualenv...) + - [ ] We look for some snippets of strings in logs and output for the testing code but we hard-code them and this is not good diff --git a/setup.py b/setup.py index 40fc197..551700d 100644 --- a/setup.py +++ b/setup.py @@ -1,15 +1,15 @@ import setuptools -from stm32pio.stm32pio import __version__ +import stm32pio.app -with open("README.md", 'r') as fh: - long_description = fh.read() +with open("README.md", 'r') as readme: + long_description = readme.read() setuptools.setup( - name="stm32pio", - version=__version__, - author="ussserrr", - author_email="andrei4.2008@gmail.com", + name='stm32pio', + version=stm32pio.app.__version__, + author='ussserrr', + author_email='andrei4.2008@gmail.com', description="Small cross-platform Python app that can create and update PlatformIO projects from STM32CubeMX .ioc " "files.", long_description=long_description, @@ -17,14 +17,17 @@ url="https://github.com/ussserrr/stm32pio", packages=setuptools.find_packages(), classifiers=[ - "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Topic :: Software Development :: Embedded Systems" ], include_package_data=True, entry_points={ 'console_scripts': [ - 'stm32pio = stm32pio.stm32pio:main' + 'stm32pio = stm32pio.app:main' ] } ) diff --git a/stm32pio/tests/stm32pio-test-project/stm32pio-test-project.ioc b/stm32pio-test-project/stm32pio-test-project.ioc similarity index 94% rename from stm32pio/tests/stm32pio-test-project/stm32pio-test-project.ioc rename to stm32pio-test-project/stm32pio-test-project.ioc index 73411a1..834d9de 100644 --- a/stm32pio/tests/stm32pio-test-project/stm32pio-test-project.ioc +++ b/stm32pio-test-project/stm32pio-test-project.ioc @@ -19,8 +19,9 @@ Mcu.PinsNb=6 Mcu.ThirdPartyNb=0 Mcu.UserConstants= Mcu.UserName=STM32F031K6Tx -MxCube.Version=5.3.0 -MxDb.Version=DB.5.0.30 +MxCube.Version=5.4.0 +MxDb.Version=DB.5.0.40 +NVIC.ForceEnableDMAVector=true NVIC.HardFault_IRQn=true\:0\:0\:false\:false\:true\:false\:false NVIC.NonMaskableInt_IRQn=true\:0\:0\:false\:false\:true\:false\:false NVIC.PendSV_IRQn=true\:0\:0\:false\:false\:true\:false\:false @@ -67,7 +68,7 @@ ProjectManager.CustomerFirmwarePackage= ProjectManager.DefaultFWLocation=true ProjectManager.DeletePrevious=true ProjectManager.DeviceId=STM32F031K6Tx -ProjectManager.FirmwarePackage=STM32Cube FW_F0 V1.10.1 +ProjectManager.FirmwarePackage=STM32Cube FW_F0 V1.11.0 ProjectManager.FreePins=false ProjectManager.HalAssertFull=false ProjectManager.HeapSize=0x200 @@ -83,7 +84,7 @@ ProjectManager.ProjectName=stm32pio-test-project ProjectManager.StackSize=0x400 ProjectManager.TargetToolchain=Other Toolchains (GPDSC) ProjectManager.ToolChainLocation= -ProjectManager.UnderRoot=true +ProjectManager.UnderRoot=false ProjectManager.functionlistsort=1-MX_GPIO_Init-GPIO-false-HAL-true,2-SystemClock_Config-RCC-false-HAL-false,3-MX_USART1_UART_Init-USART1-false-HAL-true RCC.CECFreq_Value=32786.88524590164 RCC.FamilyName=M diff --git a/stm32pio/__main__.py b/stm32pio/__main__.py new file mode 100644 index 0000000..b424b14 --- /dev/null +++ b/stm32pio/__main__.py @@ -0,0 +1,6 @@ +import sys + +import stm32pio.app + +if __name__ == '__main__': + sys.exit(stm32pio.app.main()) diff --git a/stm32pio/app.py b/stm32pio/app.py new file mode 100755 index 0000000..f7fd620 --- /dev/null +++ b/stm32pio/app.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +__version__ = '0.9' + +import argparse +import logging +import sys +import pathlib +import traceback +from typing import Optional + + +def parse_args(args: list) -> Optional[argparse.Namespace]: + """ + Dedicated function to parse the arguments given via the CLI + + Args: + args: list of strings + + Returns: + argparse.Namespace or None if no arguments were given + """ + + 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)") + # 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) + + subparsers = parser.add_subparsers(dest='subcommand', title='subcommands', description="valid subcommands", + help="modes of operation") + + 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_clean = subparsers.add_parser('clean', help="clean-up the project (WARNING: it deletes ALL content of " + "'path' except the .ioc file)") + + # Common subparsers options + for p in [parser_init, parser_new, parser_generate, parser_clean]: + p.add_argument('-d', '--directory', dest='project_path', default=pathlib.Path.cwd(), required=True, + help="path to the project (current directory, if not given)") + for p in [parser_init, parser_new]: + p.add_argument('-b', '--board', dest='board', required=False, help="PlatformIO name of the board") + for p in [parser_init, parser_new, parser_generate]: + p.add_argument('--start-editor', dest='editor', required=False, + help="use specified editor to open PlatformIO project (e.g. subl, code, atom, etc.)") + for p in [parser_new, parser_generate]: + p.add_argument('--with-build', action='store_true', required=False, help="build a project after generation") + + if len(args) == 0: + parser.print_help() + return None + + return parser.parse_args(args) + + +def main(sys_argv: list = sys.argv[1:]) -> int: + """ + Can be used as 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 + """ + + 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') + handler = logging.StreamHandler() + logger.addHandler(handler) + if args is not None and args.verbose: + logger.setLevel(logging.DEBUG) + handler.setFormatter(logging.Formatter("%(levelname)-8s %(funcName)-26s %(message)s")) + logger.debug("debug logging enabled") + elif args is not None: + logger.setLevel(logging.INFO) + handler.setFormatter(logging.Formatter("%(levelname)-8s %(message)s")) + else: + logger.setLevel(logging.INFO) + handler.setFormatter(logging.Formatter("%(message)s")) + logger.info("\nNo arguments were given, exiting...") + return 0 + + # Main routine + import stm32pio.lib # import the module after sys.path modification + + try: + if args.subcommand == 'init': + project = stm32pio.lib.Stm32pio(args.project_path, parameters={'board': args.board}) + if not args.board: + logger.warning("STM32 PlatformIO board is not specified, it will be needed on PlatformIO project " + "creation") + logger.info('project has been initialized. You can now edit stm32pio.ini config file') + if args.editor: + project.start_editor(args.editor) + + elif args.subcommand == 'new': + project = stm32pio.lib.Stm32pio(args.project_path, parameters={'board': args.board}) + if project.config.get('project', 'board') == '': + raise Exception("STM32 PlatformIO board is not specified, it is needed for PlatformIO project creation") + project.generate_code() + project.pio_init() + project.patch() + if args.with_build: + project.pio_build() + if args.editor: + project.start_editor(args.editor) + + elif args.subcommand == 'generate': + project = stm32pio.lib.Stm32pio(args.project_path, save_on_destruction=False) + project.generate_code() + if args.with_build: + project.pio_build() + if args.editor: + project.start_editor(args.editor) + + elif args.subcommand == 'clean': + project = stm32pio.lib.Stm32pio(args.project_path, save_on_destruction=False) + project.clean() + + # library is designed to throw the exception in bad cases so we catch here globally + except Exception as e: + logger.error(e) + if logger.getEffectiveLevel() <= logging.DEBUG: # verbose + traceback.print_exception(*sys.exc_info()) + return -1 + + logger.info("exiting...") + return 0 + + +if __name__ == '__main__': + sys.path.insert(0, str(pathlib.Path(sys.path[0]).parent)) # hack to be able to run the app as 'python3 app.py' + sys.exit(main()) diff --git a/stm32pio/lib.py b/stm32pio/lib.py new file mode 100644 index 0000000..6b18ae6 --- /dev/null +++ b/stm32pio/lib.py @@ -0,0 +1,411 @@ +""" +Main library +""" + +import collections +import logging +import pathlib +import shutil +import subprocess +import enum +import configparser +import string +import sys +import tempfile +import traceback +import weakref + +import stm32pio.settings + +# Child logger +logger = logging.getLogger('stm32pio.util') + + +@enum.unique +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 + + 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 + Stm32pio class cannot be instantiated (constructor raises an exception) + INITIALIZED: ['project.ioc', 'stm32pio.ini'] + GENERATED: ['Inc', 'Src', 'project.ioc', 'stm32pio.ini'] + PIO_INITIALIZED (on case-sensitive FS): ['test', 'include', 'Inc', 'platformio.ini', '.gitignore', 'Src', 'lib', + 'project.ioc', '.travis.yml', 'src'] + PIO_INI_PATCHED: ['test', 'Inc', 'platformio.ini', '.gitignore', 'Src', 'lib', 'project.ioc', '.travis.yml'] + 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() + INITIALIZED = enum.auto() + GENERATED = enum.auto() + PIO_INITIALIZED = enum.auto() + PIO_INI_PATCHED = enum.auto() + BUILT = enum.auto() + + +class Stm32pio: + """ + Main class. + + Represents a single project, encapsulating file system path to the project (main mandatory identifier) and some + parameters in a configparser .ini file. As stm32pio can be installed via pip and has no global config we also + storing global parameters (such as Java or STM32CubeMX invoking commands) in this config .ini file so the user can + specify settings on a per-project base. The config can be saved in a non-disturbing way automatically on the + instance destruction (e.g. by garbage collecting it) (use save_on_destruction=True flag), otherwise user should + explicitly save the config if he wants to (using save_config() method). + + The typical life cycle consists of project creation, passing mandatory 'dirty_path' argument. If also 'parameters' + dictionary is specified also these settings are processed (white-list approach is used so we set only those + parameters that are listed in the constructor code) (currently only 'board' parameter is included). Then it is + possible to perform API operations. WARNING. Please be careful with the 'clean' method as it deletes all the content + of the project directory except the main .ioc file. + + Args: + dirty_path (str): path to the project + parameters (dict): additional parameters to set on initialization stage + save_on_destruction (bool): register or not the finalizer that saves the config to file + """ + + def __init__(self, dirty_path: str, parameters: dict = None, save_on_destruction: bool = True): + if parameters is None: + parameters = {} + + self.project_path = self._resolve_project_path(dirty_path) + self.config = self._load_settings_file() + + ioc_file = self._find_ioc_file() + self.config.set('project', 'ioc_file', str(ioc_file)) + + cubemx_script_template = string.Template(self.config.get('project', 'cubemx_script_content')) + cubemx_script_content = cubemx_script_template.substitute(project_path=self.project_path, + cubemx_ioc_full_filename=self.config.get('project', 'ioc_file')) + self.config.set('project', 'cubemx_script_content', cubemx_script_content) + + board = '' + if 'board' in parameters and parameters['board'] is not None: + try: + board = self._resolve_board(parameters['board']) + except Exception as e: + logger.warning(e) + self.config.set('project', 'board', board) + elif self.config.get('project', 'board', fallback=None) is None: + self.config.set('project', 'board', board) + + if save_on_destruction: + self._finalizer = weakref.finalize(self, self.save_config) + + + def save_config(self) -> int: + """ + Tries to save the configparser config to file and gently log if any error occurs + """ + + try: + with self.project_path.joinpath(stm32pio.settings.config_file_name).open(mode='w') as config_file: + self.config.write(config_file) + logger.debug("stm32pio.ini config file has been saved") + return 0 + except Exception as e: + logger.warning(f"cannot save the config: {e}") + if logger.getEffectiveLevel() <= logging.DEBUG: + traceback.print_exception(*sys.exc_info()) + return -1 + + + @property + def state(self) -> ProjectState: + """ + Property returning the current state of the project. Calculated at every request. + """ + + logger.debug("calculating the project state...") + logger.debug(f"project content: {[item.name for item in self.project_path.iterdir()]}") + + # Fill the ordered dictionary with conditions results + states_conditions = collections.OrderedDict() + states_conditions[ProjectState.UNDEFINED] = [True] + states_conditions[ProjectState.INITIALIZED] = [ + self.project_path.joinpath(stm32pio.settings.config_file_name).is_file()] + states_conditions[ProjectState.GENERATED] = [self.project_path.joinpath('Inc').is_dir() and + len(list(self.project_path.joinpath('Inc').iterdir())) > 0, + self.project_path.joinpath('Src').is_dir() and + 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] + states_conditions[ProjectState.PIO_INI_PATCHED] = [ + self.project_path.joinpath('platformio.ini').is_file() and + self.config.get('project', 'platformio_ini_patch_content') in + self.project_path.joinpath('platformio.ini').read_text(), + not self.project_path.joinpath('include').is_dir()] + states_conditions[ProjectState.BUILT] = [ + self.project_path.joinpath('.pio').is_dir() and + any([item.is_file() for item in self.project_path.joinpath('.pio').rglob('*firmware*')])] + + # Use (1,0) instead of (True,False) because on debug printing it looks better + conditions_results = [] + for state, conditions in states_conditions.items(): + conditions_results.append(1 if all(condition is True for condition in conditions) else 0) + + # Put away unnecessary processing as the string still will be formed even if the logging level doesn't allow + # propagation of this message + if logger.getEffectiveLevel() <= logging.DEBUG: + states_info_str = '\n'.join(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 + # [1,1,0,1,0,0] + # ^ + last_true_index = 0 # UNDEFINED is always True, use as a start value + for index, value in enumerate(conditions_results): + if value == 1: + last_true_index = index + else: + break + + # 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:]: + project_state = ProjectState(last_true_index + 1) + + return project_state + + + 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 + """ + + ioc_file = self.config.get('project', 'ioc_file', fallback=None) + if ioc_file: + return pathlib.Path(ioc_file).resolve() + else: + logger.debug("searching for any .ioc file...") + candidates = list(self.project_path.glob('*.ioc')) + if len(candidates) == 0: # good candidate for the new Python 3.8 assignment expressions feature :) + raise FileNotFoundError("Not found: CubeMX project .ioc file") + elif len(candidates) == 1: + logger.debug(f"{candidates[0].name} is selected") + return candidates[0] + else: + logger.warning(f"there are multiple .ioc files, {candidates[0].name} is selected") + return candidates[0] + + + def _load_settings_file(self) -> configparser.ConfigParser: + """ + Prepare configparser config for the project. First, read the default config and then mask these values with user + ones + """ + + logger.debug(f"searching for {stm32pio.settings.config_file_name}...") + stm32pio_ini = self.project_path.joinpath(stm32pio.settings.config_file_name) + + config = configparser.ConfigParser() + + # Fill with default values + config.read_dict(stm32pio.settings.config_default) + # Then override by user values (if exist) + config.read(str(stm32pio_ini)) + + # Put away unnecessary processing as the string still will be formed even if the logging level doesn't allow + # propagation of this message + if logger.getEffectiveLevel() <= logging.DEBUG: + debug_str = 'resolved config:' + for section in config.sections(): + debug_str += f"\n=========== {section} ===========\n" + for value in config.items(section): + debug_str += f"{value}\n" + logger.debug(debug_str) + + return config + + + @staticmethod + def _resolve_project_path(dirty_path: str) -> pathlib.Path: + """ + Handle 'path/to/proj' and 'path/to/proj/', '.' (current directory) and other cases + + Args: + dirty_path (str): some directory in the filesystem + """ + resolved_path = pathlib.Path(dirty_path).expanduser().resolve() + if not resolved_path.exists(): + raise FileNotFoundError(f"not found: {resolved_path}") + else: + return resolved_path + + + def _resolve_board(self, board: str) -> str: + """ + Check if given board is a correct board name in the PlatformIO database + + Returns: + same board that has been given, raise an exception otherwise + """ + + logger.debug("searching for PlatformIO board...") + result = subprocess.run([self.config.get('app', 'platformio_cmd'), 'boards'], encoding='utf-8', + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + # Or, for Python 3.7 and above: + # result = subprocess.run(['platformio', 'boards'], encoding='utf-8', capture_output=True) + if result.returncode == 0: + if board not in result.stdout.split(): + raise Exception("wrong PlatformIO STM32 board. Run 'platformio boards' for possible names") + else: + logger.debug(f"PlatformIO board {board} was found") + return board + else: + raise Exception("failed to search for PlatformIO boards") + + + def generate_code(self) -> None: + """ + 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 + """ + + # 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() + + + 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 + """ + + logger.info("starting PlatformIO project initialization...") + + # TODO: check whether there is already a platformio.ini file and warn in this case + + # 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: + command_arr.append('--silent') + + result = subprocess.run(command_arr, encoding='utf-8', stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + error_msg = "PlatformIO project initialization error" + if result.returncode == 0: + # PlatformIO returns 0 even on some errors (e.g. no '--board' argument) + for output in [result.stdout, result.stderr]: + if 'ERROR' in output.upper(): + logger.error(output) + raise Exception(error_msg) + logger.info("successful PlatformIO project initialization") + return result.returncode + else: + raise Exception(error_msg) + + + def patch(self) -> None: + """ + Patch platformio.ini file to use created earlier by CubeMX 'Src' and 'Inc' folders as sources + """ + + logger.debug("patching 'platformio.ini' file...") + + platformio_ini_file = self.project_path.joinpath('platformio.ini') + if platformio_ini_file.is_file(): + with platformio_ini_file.open(mode='a') as f: + # TODO: check whether there is already a patched platformio.ini file, warn in this case and do not + # proceed + f.write(self.config.get('project', 'platformio_ini_patch_content')) + logger.info("'platformio.ini' has been patched") + else: + raise Exception("'platformio.ini' file not found, the project cannot be patched successfully") + + shutil.rmtree(self.project_path.joinpath('include'), ignore_errors=True) + + if not self.project_path.joinpath('SRC').is_dir(): # check for case sensitive file system + shutil.rmtree(self.project_path.joinpath('src'), ignore_errors=True) + + + def start_editor(self, editor_command: str) -> int: + """ + Start the editor specified by 'editor_command' with the project opened + + Args: + editor_command: editor command as we start it in the terminal + + Returns: + return code of the editor on success, -1 otherwise + """ + + logger.info(f"starting an editor '{editor_command}'...") + + 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 + except subprocess.CalledProcessError as e: + logger.error(f"failed to start the editor {editor_command}: {e.stderr}") + return -1 + + + def pio_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 + + Returns: + 0 if success, raise an exception otherwise + """ + + command_arr = [self.config.get('app', 'platformio_cmd'), 'run', '-d', str(self.project_path)] + if logger.getEffectiveLevel() > logging.DEBUG: + command_arr.append('--silent') + + result = subprocess.run(command_arr) + if result.returncode == 0: + logger.info("successful PlatformIO build") + else: + logger.error("PlatformIO build error") + return result.returncode + + + def clean(self) -> None: + """ + Clean-up the project folder preserving only an '.ioc' file + """ + + for child in self.project_path.iterdir(): + if child.name != f"{self.project_path.name}.ioc": + if child.is_dir(): + shutil.rmtree(child, ignore_errors=True) + logger.debug(f"del {child}/") + elif child.is_file(): + child.unlink() + logger.debug(f"del {child}") + + logger.info("project has been cleaned") diff --git a/stm32pio/settings.py b/stm32pio/settings.py index 6384baa..87fd926 100644 --- a/stm32pio/settings.py +++ b/stm32pio/settings.py @@ -1,37 +1,36 @@ import platform import pathlib +import collections my_os = platform.system() -# (default is OK) How do you start Java from command line? (edit if Java not in PATH) -java_cmd = 'java' - -# (default is OK) How do you start PlatformIO from command line? (edit if not in PATH, check -# https://docs.platformio.org/en/latest/installation.html#install-shell-commands) -platformio_cmd = 'platformio' - -# (default is OK) We trying to guess STM32CubeMX location. You can just avoid this and hard-code it. -# Note that STM32CubeMX will be invoked as 'java -jar CUBEMX' -# macOS default: 'Applications' folder -if my_os == 'Darwin': - cubemx_path = "/Applications/STMicroelectronics/STM32CubeMX.app/Contents/Resources/STM32CubeMX" -# Linux (Ubuntu) default: -elif my_os == 'Linux': - cubemx_path = pathlib.Path.home().joinpath("STM32CubeMX/STM32CubeMX") -# Windows default: -elif my_os == 'Windows': - cubemx_path = "C:/Program Files/STMicroelectronics/STM32Cube/STM32CubeMX/STM32CubeMX.exe" - -# (default is OK) choose a file name in which we store the CubeMX script -cubemx_script_filename = 'cubemx-script' - -# (default is OK) see CubeMX user manual PDF to see other useful options -cubemx_script_content = "config load {cubemx_ioc_full_filename}\n" \ - "generate code {project_path}\n" \ - "exit\n" - -# (default is OK) -platformio_ini_patch_content = "\n[platformio]\n" \ - "include_dir = Inc\n" \ - "src_dir = Src\n" +config_default = collections.OrderedDict( + app={ + # (default is OK) How do you start Java from the command line? (edit if Java not in PATH) + 'java_cmd': 'java', + + # (default is OK) How do you start PlatformIO from the command line? (edit if not in PATH, if you use PlatformIO + # IDE check https://docs.platformio.org/en/latest/installation.html#install-shell-commands) + 'platformio_cmd': 'platformio', + + # (default is OK) Trying to guess the STM32CubeMX location. STM actually had changed the installation path + # several times already. Note that STM32CubeMX will be invoked as 'java -jar CUBEMX' + 'cubemx_cmd': + # macOS default: 'Applications' folder + "/Applications/STMicroelectronics/STM32CubeMX.app/Contents/Resources/STM32CubeMX" if my_os == 'Darwin' else + # Linux (Ubuntu) default: home directory + pathlib.Path.home().joinpath("STM32CubeMX/STM32CubeMX") if my_os == 'Linux' else + # Windows default: Program Files + "C:/Program Files/STMicroelectronics/STM32Cube/STM32CubeMX/STM32CubeMX.exe" if my_os == 'Windows' else None + }, + project={ + # (default is OK) See CubeMX user manual PDF (UM1718) to get other useful options + 'cubemx_script_content': "config load $cubemx_ioc_full_filename\ngenerate code $project_path\nexit", + + # Override the defaults to comply with CubeMX project structure + 'platformio_ini_patch_content': "[platformio]\ninclude_dir = Inc\nsrc_dir = Src\n" + } +) + +config_file_name = 'stm32pio.ini' diff --git a/stm32pio/stm32pio.py b/stm32pio/stm32pio.py deleted file mode 100755 index e63f706..0000000 --- a/stm32pio/stm32pio.py +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -__version__ = "0.8" - -import argparse -import logging -import sys -import pathlib - - -def main(): - parser = argparse.ArgumentParser(description="Automation of creating and updating STM32CubeMX-PlatformIO projects. " - "Requirements: Python 3.6+, STM32CubeMX, Java, PlatformIO CLI. Edit " - "settings.py to set path to the STM32CubeMX (if default doesn't work)") - # Global arguments (there is also an automatically added '-h, --help' option) - parser.add_argument('--version', action='version', version=f"%(prog)s v{__version__}") - parser.add_argument('-v', '--verbose', help="enable verbose output (default: INFO)", action='count', required=False) - - subparsers = parser.add_subparsers(dest='subcommand', title='subcommands', - description="valid subcommands", help="modes of operation") - - parser_new = subparsers.add_parser('new', - help="generate CubeMX code, create PlatformIO project [and start the editor]") - parser_generate = subparsers.add_parser('generate', help="generate CubeMX code") - parser_clean = subparsers.add_parser('clean', help="clean-up the project (WARNING: it deletes ALL content of " - "'path' except the .ioc file)") - - # Common subparsers options - for p in [parser_new, parser_generate, parser_clean]: - p.add_argument('-d', '--directory', dest='project_path', help="path to the project (current directory, if not " - "given)", default=pathlib.Path.cwd()) - for p in [parser_new, parser_generate]: - p.add_argument('--start-editor', dest='editor', help="use specified editor to open PlatformIO project (e.g. " - "subl, code, atom)", required=False) - p.add_argument('--with-build', action='store_true', help="build a project after generation", required=False) - - parser_new.add_argument('-b', '--board', dest='board', help="PlatformIO name of the board", required=True) - - args = parser.parse_args() - - - # Logger instance goes through the whole program. - # Currently only 2 levels of verbosity through the '-v' option are counted (INFO and DEBUG) - logger = logging.getLogger() - if args.verbose: - logging.basicConfig(format="%(levelname)-8s %(funcName)-16s %(message)s") - logger.setLevel(logging.DEBUG) - logger.debug("debug logging enabled") - else: - logging.basicConfig(format="%(levelname)-8s %(message)s") - logger.setLevel(logging.INFO) - - - # Show help and exit if no arguments were given - if not len(sys.argv) > 1: - parser.print_help() - sys.exit() - - # Main routine - else: - import stm32pio.util - - try: - if args.subcommand == 'new' or args.subcommand == 'generate': - stm32pio.util.generate_code(args.project_path) - if args.subcommand == 'new': - stm32pio.util.pio_init(args.project_path, args.board) - stm32pio.util.patch_platformio_ini(args.project_path) - - if args.with_build: - stm32pio.util.pio_build(args.project_path) - if args.editor: - stm32pio.util.start_editor(args.project_path, args.editor) - - elif args.subcommand == 'clean': - stm32pio.util.clean(args.project_path) - - except Exception as e: - print(e.__repr__()) - - - logger.info("exiting...") - - -if __name__ == '__main__': - main() diff --git a/stm32pio/tests/test.py b/stm32pio/tests/test.py index 65ea76f..3730f94 100755 --- a/stm32pio/tests/test.py +++ b/stm32pio/tests/test.py @@ -1,144 +1,289 @@ +""" +'pyenv' is recommended to use for testing with different Python versions +https://www.tecmint.com/pyenv-install-and-manage-multiple-python-versions-in-linux/ + +To get test coverage use 'coverage': + $ coverage run -m stm32pio.tests.test -b + $ coverage html +""" + +import unittest +import configparser import pathlib +import platform +import shutil import subprocess +import tempfile import time -import unittest +import inspect +import sys +import stm32pio.app import stm32pio.settings -import stm32pio.util +import stm32pio.lib + +STM32PIO_MAIN_SCRIPT = 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 -project_path = pathlib.Path('stm32pio/tests/stm32pio-test-project').resolve() -board = 'nucleo_f031k6' +# Test data +TEST_PROJECT_PATH = pathlib.Path('stm32pio-test-project').resolve() +if not TEST_PROJECT_PATH.is_dir() or not TEST_PROJECT_PATH.joinpath('stm32pio-test-project.ioc').is_file(): + raise FileNotFoundError("No test project is present") +# Make sure you have F0 framework installed (try to run code generation from STM32CubeMX manually at least once before +# proceeding) +TEST_PROJECT_BOARD = 'nucleo_f031k6' +# 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"Temp test fixture path: {FIXTURE_PATH}") -def clean_run(test): + +class CustomTestCase(unittest.TestCase): """ - The decorator that clean the project directory before the test (or any other function). Its functionality can also - be done using setUp method of unittest. Also, we assume that stm32pio.util.clean() does not contain any errors - itself :) + These pre- and post-tasks are common for all test cases """ - def wrapper(self): - stm32pio.util.clean(project_path) - return test(self) - return wrapper + def setUp(self): + """ + Copy the test project from the repo to our temp directory. WARNING: make sure the test project folder is clean + (i.e. contains only an .ioc file) before running the test + """ + shutil.rmtree(FIXTURE_PATH, ignore_errors=True) + shutil.copytree(TEST_PROJECT_PATH, FIXTURE_PATH) + def tearDown(self): + """ + Clean the temp directory + """ + shutil.rmtree(FIXTURE_PATH, ignore_errors=True) -class Test(unittest.TestCase): - @clean_run +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 + """ + def test_generate_code(self): """ - Check whether files and folders have been created + Check whether files and folders have been created by STM32CubeMX """ - stm32pio.util.generate_code(project_path) - # Assuming that the presence of these files indicates a success - files_should_be_present = [stm32pio.settings.cubemx_script_filename, 'Src/main.c', 'Inc/main.h'] - self.assertEqual([project_path.joinpath(file).is_file() for file in files_should_be_present], - [True] * len(files_should_be_present), - msg=f"At least one of {files_should_be_present} files haven't been created") + project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'board': TEST_PROJECT_BOARD}, + save_on_destruction=False) + project.generate_code() + # 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"): + self.assertEqual(FIXTURE_PATH.joinpath(file).is_file(), True) - @clean_run def test_pio_init(self): """ - Consider that existence of 'platformio.ini' file is displaying successful PlatformIO project initialization + 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 """ - stm32pio.util.pio_init(project_path, board) - self.assertTrue(project_path.joinpath('platformio.ini').is_file(), msg="platformio.ini is not there") + project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'board': TEST_PROJECT_BOARD}, + save_on_destruction=False) + result = project.pio_init() + self.assertEqual(result, 0, msg="Non-zero return code") + self.assertTrue(FIXTURE_PATH.joinpath('platformio.ini').is_file(), msg="platformio.ini is not there") + # TODO: check that platformio.ini is a correct configparser file and is not empty - @clean_run - def test_patch_platformio_ini(self): + def test_patch(self): """ - Compare contents of the patched string and the desired patch + Compare contents of the patched string and the patch itself """ test_content = "*** TEST PLATFORMIO.INI FILE ***" - project_path.joinpath('platformio.ini').write_text(test_content) + FIXTURE_PATH.joinpath('platformio.ini').write_text(test_content) - stm32pio.util.patch_platformio_ini(project_path) + project = stm32pio.lib.Stm32pio(FIXTURE_PATH, save_on_destruction=False) + project.patch() - after_patch_content = project_path.joinpath('platformio.ini').read_text() + self.assertFalse(FIXTURE_PATH.joinpath('include').is_dir(), msg="'include' has not been deleted") + + after_patch_content = FIXTURE_PATH.joinpath('platformio.ini').read_text() - # Initial content wasn't corrupted self.assertEqual(after_patch_content[:len(test_content)], test_content, msg="Initial content of platformio.ini is corrupted") - # Patch content is as expected - self.assertEqual(after_patch_content[len(test_content):], stm32pio.settings.platformio_ini_patch_content, - msg="patch content is not as expected") + self.assertEqual(after_patch_content[len(test_content):], + stm32pio.settings.config_default['project']['platformio_ini_patch_content'], + msg="Patch content is not as expected") + def test_build_should_handle_error(self): + """ + Build an empty project so PlatformIO should return the 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.assertTrue(next((True for item in logs.output if "PlatformIO build error" in item), False), + msg="Error message does not match") - @clean_run - def test_build_should_raise(self): + def test_run_editor(self): + """ + Call the editors + """ + project = stm32pio.lib.Stm32pio(FIXTURE_PATH, save_on_destruction=False) + + editors = { + 'atom': { + 'Windows': 'atom.exe', + 'Darwin': 'Atom', + 'Linux': 'atom' + }, + 'code': { + 'Windows': 'Code.exe', + 'Darwin': 'Visual Studio Code', + 'Linux': 'code' + }, + 'subl': { + 'Windows': 'sublime_text.exe', + 'Darwin': 'Sublime', + 'Linux': 'sublime' + } + } + + for command, name in editors.items(): + # Look for the command presence in the system so we test only installed editors + if platform.system() == 'Windows': + command_str = f"where {command} /q" + else: + command_str = f"command -v {command}" + editor_exists = False + if subprocess.run(command_str, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0: + editor_exists = True + + if editor_exists: + with self.subTest(command=command, name=name[platform.system()]): + project.start_editor(command) + + 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'] + # "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') + # Or, for Python 3.7 and above: + # result = subprocess.run(command_arr, capture_output=True, encoding='utf-8') + self.assertIn(name[platform.system()], result.stdout) + + def test_init_path_not_found_should_raise(self): """ - Build an empty project so PlatformIO should return non-zero code and we should throw the exception + Pass non-existing path and expect the error """ - stm32pio.util.pio_init(project_path, board) - with self.assertRaisesRegex(Exception, "PlatformIO build error", - msg="Build error exception hadn't been raised"): - stm32pio.util.pio_build(project_path) + path_does_not_exist_name = 'does_not_exist' + path_does_not_exist = FIXTURE_PATH.joinpath(path_does_not_exist_name) + with self.assertRaisesRegex(FileNotFoundError, path_does_not_exist_name, + msg="FileNotFoundError was not raised or doesn't contain a description"): + stm32pio.lib.Stm32pio(path_does_not_exist, save_on_destruction=False) - @clean_run - def test_build(self): + def test_save_config(self): """ - Initialize a new project and try to build it + Explicitly save the config to file and look did that actually happen and whether all the information was + preserved """ - stm32pio.util.generate_code(project_path) - stm32pio.util.pio_init(project_path, board) - stm32pio.util.patch_platformio_ini(project_path) + # 'board' is non-default, 'project'-section parameter + project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'board': TEST_PROJECT_BOARD}, + save_on_destruction=False) - result = stm32pio.util.pio_build(project_path) + project.save_config() - self.assertEqual(result, 0, msg="Build failed") + 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() + config.read(str(FIXTURE_PATH.joinpath(stm32pio.settings.config_file_name))) + 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"): + 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") - def test_run_editor(self): + +class TestIntegration(CustomTestCase): + """ + Sequence of methods that should work seamlessly + """ + + def test_config_priorities(self): """ - Call the editors + Test the compliance with priorities when reading the parameters """ - stm32pio.util.start_editor(project_path, 'atom') - stm32pio.util.start_editor(project_path, 'code') - stm32pio.util.start_editor(project_path, 'subl') - time.sleep(1) # wait a little bit for apps to start - - if stm32pio.settings.my_os == 'Windows': - result = subprocess.run(['wmic', 'process', 'get', 'description'], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8') - self.assertIn('atom.exe', result.stdout) - self.assertIn('Code.exe', result.stdout) - self.assertIn('sublime_text.exe', result.stdout) - else: - result = subprocess.run(['ps', '-A'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8') - # Or, for Python 3.7 and above: - # result = subprocess.run(['ps', '-A'], capture_output=True, encoding='utf-8') - if stm32pio.settings.my_os == 'Darwin': - self.assertIn('Atom', result.stdout) - self.assertIn('Visual Studio Code', result.stdout) - self.assertIn('Sublime', result.stdout) - if stm32pio.settings.my_os == 'Linux': - self.assertIn('atom', result.stdout) - self.assertIn('code', result.stdout) - self.assertIn('sublime', result.stdout) - - - @clean_run + + config_parameter_user_value = "SOME CUSTOM CONTENT" + cli_parameter_user_value = 'nucleo_f429zi' + + # Create test config + config = configparser.ConfigParser() + config.read_dict({ + 'project': { + 'platformio_ini_patch_content': config_parameter_user_value, + 'board': TEST_PROJECT_BOARD + } + }) + # ... save it + with FIXTURE_PATH.joinpath(stm32pio.settings.config_file_name).open(mode='w') as config_file: + 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 + 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 + 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") + self.assertIn(cli_parameter_user_value, after_patch_content, + msg="User CLI parameter has not been prioritized over the saved one") + + def test_build(self): + """ + Initialize a new project and try to build it + """ + project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'board': TEST_PROJECT_BOARD}, + save_on_destruction=False) + project.generate_code() + project.pio_init() + project.patch() + + result = project.pio_build() + + self.assertEqual(result, 0, msg="Build failed") + def test_regenerate_code(self): """ - Simulate new project creation, its changing and CubeMX code re-generation (for example, after adding new - hardware features and some new files) + Simulate a new project creation, its changing and CubeMX code re-generation (for example, after adding new + hardware features and some new files by a user) """ + project = stm32pio.lib.Stm32pio(FIXTURE_PATH, parameters={'board': TEST_PROJECT_BOARD}, + save_on_destruction=False) # Generate a new project ... - stm32pio.util.generate_code(project_path) - stm32pio.util.pio_init(project_path, board) - stm32pio.util.patch_platformio_ini(project_path) + project.generate_code() + project.pio_init() + project.patch() # ... change it: - test_file_1 = project_path.joinpath('Src', 'main.c') + test_file_1 = FIXTURE_PATH.joinpath('Src', 'main.c') test_content_1 = "*** TEST STRING 1 ***\n" - test_file_2 = project_path.joinpath('Inc', 'my_header.h') + test_file_2 = FIXTURE_PATH.joinpath('Inc', 'my_header.h') test_content_2 = "*** TEST STRING 2 ***\n" # - add some sample string inside CubeMX' /* BEGIN - END */ block main_c_content = test_file_1.read_text() @@ -149,32 +294,152 @@ def test_regenerate_code(self): test_file_2.write_text(test_content_2) # Re-generate CubeMX project - stm32pio.util.generate_code(project_path) + 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"{test_file_1} does not preserve user content after regeneration") + 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"{test_file_2} does not preserve user content after regeneration") + msg=f"User content hasn't been preserved after regeneration in {test_file_2}") + + +class TestCLI(CustomTestCase): + """ + Some tests to mimic the behavior of end-user tasks (CLI commands such as 'new', 'clean', etc.). Run main function + passing the arguments to it but sometimes even run as subprocess (to capture actual STDOUT/STDERR output) + """ + + def test_clean(self): + # Create files and folders + file_should_be_deleted = FIXTURE_PATH.joinpath('file.should.be.deleted') + dir_should_be_deleted = FIXTURE_PATH.joinpath('dir.should.be.deleted') + file_should_be_deleted.touch(exist_ok=False) + dir_should_be_deleted.mkdir(exist_ok=False) + + # Clean + return_code = stm32pio.app.main(sys_argv=['clean', '-d', str(FIXTURE_PATH)]) + 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") + # And .ioc file should be preserved + self.assertTrue(FIXTURE_PATH.joinpath(f"{FIXTURE_PATH.name}.ioc").is_file(), msg="Missing .ioc file") - def test_file_not_found(self): + def test_new(self): """ - Pass non-existing path and expect the error + Successful build is the best indicator that all went right so we use '--with-build' option """ - not_existing_path = project_path.joinpath('does_not_exist') - with self.assertRaises(FileNotFoundError, msg="FileNotFoundError was not raised"): - stm32pio.util._get_project_path(not_existing_path) + 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") -def tearDownModule(): - """ - Clean up after yourself - """ - stm32pio.util.clean(project_path) + # .ioc file should be preserved + self.assertTrue(FIXTURE_PATH.joinpath(f"{FIXTURE_PATH.name}.ioc").is_file(), msg="Missing .ioc file") + + 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") + + # .ioc file should be preserved + self.assertTrue(FIXTURE_PATH.joinpath(f"{FIXTURE_PATH.name}.ioc").is_file(), msg="Missing .ioc file") + + 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: + return_code = stm32pio.app.main(sys_argv=['init', '-d', str(path_not_exist)]) + self.assertNotEqual(return_code, 0, msg='Return code should be non-zero') + self.assertTrue(next((True for item in logs.output if str(path_not_exist) in item), False), + msg="'ERROR' logging message hasn't been printed") + + 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) + + with self.assertLogs(level='ERROR') as logs: + return_code = stm32pio.app.main(sys_argv=['init', '-d', str(dir_with_no_ioc_file)]) + self.assertNotEqual(return_code, 0, msg='Return code should be non-zero') + self.assertTrue(next((True for item in logs.output if "CubeMX project .ioc file" in item), False), + msg="'ERROR' logging message hasn't been printed") + + # TODO: test logs format + # non-verbose should satisfy: + # ^(?=(DEBUG|INFO|WARNING|ERROR|CRITICAL) {0,4})(?=.{8} [^ ]) + # + # verbose: + # ^(?=(DEBUG|INFO|WARNING|ERROR|CRITICAL) {0,4})(?=.{8} .{26} [^ ]) + # + # we can actually get possible function names in that 26-width block + + def test_verbose(self): + """ + Run as subprocess to capture the full output. Check for both 'DEBUG' logging messages and STM32CubeMX CLI output + """ + + result = subprocess.run([PYTHON_EXEC, STM32PIO_MAIN_SCRIPT, '-v', 'generate', '-d', str(FIXTURE_PATH)], + encoding='utf-8', stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + self.assertEqual(result.returncode, 0, msg="Non-zero return code") + # 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") + self.assertIn('Starting STM32CubeMX', result.stdout, msg="STM32CubeMX didn't print its logs") + + 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 + """ + + result = subprocess.run([PYTHON_EXEC, STM32PIO_MAIN_SCRIPT, 'generate', '-d', str(FIXTURE_PATH)], + encoding='utf-8', stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + self.assertEqual(result.returncode, 0, msg="Non-zero return code") + self.assertNotIn('DEBUG', result.stderr, msg="Verbose logging output has been enabled on stderr") + self.assertNotIn('DEBUG', result.stdout, msg="Verbose logging output has been enabled on stdout") + self.assertNotIn('Starting STM32CubeMX', result.stdout, msg="STM32CubeMX printed its logs") + + 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") + + 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() + config.read(str(FIXTURE_PATH.joinpath(stm32pio.settings.config_file_name))) + 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"): + self.assertIsNotNone(config.get(section, option, fallback=None)) + self.assertEqual(config.get('project', 'board', fallback="Not found"), TEST_PROJECT_BOARD, + msg="'board' has not been set") if __name__ == '__main__': diff --git a/stm32pio/util.py b/stm32pio/util.py deleted file mode 100644 index b89dc21..0000000 --- a/stm32pio/util.py +++ /dev/null @@ -1,207 +0,0 @@ -import logging -import pathlib -import shutil -import subprocess - -import stm32pio.settings - -logger = logging.getLogger() - - - -def _get_project_path(dirty_path): - """ - Handle '/path/to/proj' and '/path/to/proj/', . (current directory) and other cases - - Args: - dirty_path: some filesystem path - """ - correct_path = pathlib.Path(dirty_path).resolve() - if not correct_path.exists(): - logger.error("incorrect project path") - raise FileNotFoundError(correct_path) - else: - return correct_path - - - -def generate_code(dirty_path): - """ - Call STM32CubeMX app as a 'java -jar' file with the automatically prearranged 'cubemx-script' file - - Args: - dirty_path: path to the project (folder with a .ioc file) - """ - - project_path = _get_project_path(dirty_path) - - # Assuming the name of the '.ioc' file is the same as the project folder, we extract it from the given string - project_name = project_path.name - logger.debug(f"searching for {project_name}.ioc file...") - cubemx_ioc_full_filename = project_path.joinpath(f'{project_name}.ioc') - if cubemx_ioc_full_filename.exists(): - logger.debug(f"{project_name}.ioc file was found") - else: - logger.error(f"there is no {project_name}.ioc file") - raise FileNotFoundError(cubemx_ioc_full_filename) - - # Find/create 'cubemx-script' file - logger.debug(f"searching for '{stm32pio.settings.cubemx_script_filename}' file...") - cubemx_script_full_filename = project_path.joinpath(stm32pio.settings.cubemx_script_filename) - if not cubemx_script_full_filename.is_file(): - logger.debug(f"'{stm32pio.settings.cubemx_script_filename}' file wasn't found, creating one...") - cubemx_script_content = stm32pio.settings.cubemx_script_content.format( - project_path=project_path, cubemx_ioc_full_filename=cubemx_ioc_full_filename) - cubemx_script_full_filename.write_text(cubemx_script_content) - logger.debug(f"'{stm32pio.settings.cubemx_script_filename}' file has been successfully created") - else: - logger.debug(f"'{stm32pio.settings.cubemx_script_filename}' file is already there") - - logger.info("starting to generate a code from the CubeMX .ioc file...") - command_arr = [stm32pio.settings.java_cmd, '-jar', stm32pio.settings.cubemx_path, '-q', - str(cubemx_script_full_filename)] - if logger.level <= 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" - "Try to enable a verbose output or generate a code from the CubeMX itself.") - raise Exception("code generation error") - - -def pio_init(dirty_path, board): - """ - Call PlatformIO CLI to initialize a new project - - Args: - dirty_path: path to the project (folder with a .ioc file) - board: string displaying PlatformIO name of MCU/board (from 'pio boards' command) - """ - - project_path = _get_project_path(dirty_path) - - # Check board name - logger.debug("searching for PlatformIO board...") - result = subprocess.run([stm32pio.settings.platformio_cmd, 'boards'], encoding='utf-8', - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - # Or, for Python 3.7 and above: - # result = subprocess.run(['platformio', 'boards'], encoding='utf-8', capture_output=True) - if result.returncode == 0: - if board not in result.stdout.split(): - logger.error("wrong STM32 board. Run 'platformio boards' for possible names") - raise Exception("wrong STM32 board") - else: - logger.error("failed to start PlatformIO") - raise Exception("failed to start PlatformIO") - - logger.info("starting PlatformIO project initialization...") - command_arr = [stm32pio.settings.platformio_cmd, 'init', '-d', str(project_path), '-b', board, - '-O', 'framework=stm32cube'] - if logger.level > logging.DEBUG: - command_arr.append('--silent') - result = subprocess.run(command_arr) - if result.returncode == 0: - logger.info("successful PlatformIO project initialization") - else: - logger.error("PlatformIO project initialization error") - raise Exception("PlatformIO error") - - - -def patch_platformio_ini(dirty_path): - """ - Patch platformio.ini file to use created earlier by CubeMX 'Src' and 'Inc' folders as sources - - Args: - dirty_path: path to the project (folder with .ioc and platformio.ini files) - """ - - project_path = _get_project_path(dirty_path) - - logger.debug("patching 'platformio.ini' file...") - - platformio_ini_file = project_path.joinpath('platformio.ini') - if platformio_ini_file.is_file(): - with platformio_ini_file.open(mode='a') as f: - f.write(stm32pio.settings.platformio_ini_patch_content) - logger.info("'platformio.ini' patched") - else: - logger.warning("'platformio.ini' file not found") - - shutil.rmtree(str(project_path.joinpath('include')), ignore_errors=True) - if not project_path.joinpath('SRC').is_dir(): # case sensitive file system - shutil.rmtree(str(project_path.joinpath('src')), ignore_errors=True) - - - -def start_editor(dirty_path, editor_command): - """ - Start 'editor' with project at 'project_path' opened - - Args: - dirty_path: path to the project - editor: editor keyword - """ - - project_path = _get_project_path(dirty_path) - - logger.info("starting an editor...") - - try: - subprocess.run([editor_command, str(project_path)], check=True) - except subprocess.CalledProcessError as e: - logger.error(f"Failed to start the editor {editor_command}: {e.stderr}") - - - -def pio_build(dirty_path): - """ - Initiate a build of the PlatformIO project by the PlatformIO ('run' command) - - Args: - dirty_path: path to the project - Returns: - 0 if success, raise an exception otherwise - """ - - project_path = _get_project_path(dirty_path) - - logger.info("starting PlatformIO build...") - command_arr = [stm32pio.settings.platformio_cmd, 'run', '-d', str(project_path)] - if logger.level > logging.DEBUG: - command_arr.append('--silent') - result = subprocess.run(command_arr) - if result.returncode == 0: - logger.info("successful PlatformIO build") - return result.returncode - else: - logger.error("PlatformIO build error") - raise Exception("PlatformIO build error") - - - -def clean(dirty_path): - """ - Clean-up the project folder and preserve only an '.ioc' file - - Args: - dirty_path: path to the project - """ - - project_path = _get_project_path(dirty_path) - - for child in project_path.iterdir(): - if child.name != f"{project_path.name}.ioc": - if child.is_dir(): - shutil.rmtree(str(child), ignore_errors=True) - logger.debug(f"del {child}/") - elif child.is_file(): - child.unlink() - logger.debug(f"del {child}") - - logger.info("project has been cleaned")