diff --git a/doc/develop/west/build-flash-debug.rst b/doc/develop/west/build-flash-debug.rst index 00cf29191c6b1..10c63713ebef5 100644 --- a/doc/develop/west/build-flash-debug.rst +++ b/doc/develop/west/build-flash-debug.rst @@ -395,10 +395,23 @@ You can :ref:`configure ` ``west build`` using these options. west whenever it needs to create or locate a build folder. The currently available arguments are: + - ``west_topdir``: The absolute path to the west workspace, as + returned by the ``west_topdir`` command - ``board``: The board name - - ``source_dir``: The relative path from the current working directory - to the source directory. If the current working directory is inside - the source directory this will be set to an empty string. + - ``source_dir``: Path to the CMake source directory, relative to the + current working directory. If the current working directory is + inside the source directory, this is an empty string. If no source + directory is specified, it defaults to current working directory. + E.g. if ``west build ../app`` is run from ``/app1``, + ``source_dir`` resolves to ``../app`` (which is the relative path + to the current working dir). + - ``source_dir_workspace``: Path to the source directory, relative to + ``west_topdir`` (if it is inside the workspace). Otherwise, it is + relative to the filesystem root (``/`` on Unix, respectively + ``C:/`` on Windows). + E.g. if ``west build ../app`` is run from ``/app1``, + ``source_dir`` resolves to ``app`` (which is the relative path to + the west workspace dir). - ``app``: The name of the source directory. * - ``build.generator`` - String, default ``Ninja``. The `CMake Generator`_ to use to create a diff --git a/scripts/west_commands/build.py b/scripts/west_commands/build.py index 8a8b3a23ebeef..e21e84a3e3a6d 100644 --- a/scripts/west_commands/build.py +++ b/scripts/west_commands/build.py @@ -13,7 +13,7 @@ from build_helpers import FIND_BUILD_DIR_DESCRIPTION, find_build_dir, is_zephyr_build, load_domains from west.commands import Verbosity from west.configuration import config -from west.util import west_topdir +from west.util import WestNotFound, west_topdir from west.version import __version__ from zcmake import DEFAULT_CMAKE_GENERATOR, CMakeCache, run_build, run_cmake from zephyr_ext_common import Forceable @@ -287,11 +287,11 @@ def _find_board(self): if board is not None: return (board, origin) - if self.args.board: + if getattr(self.args, 'board', None): board, origin = self.args.board, 'command line' elif 'BOARD' in os.environ: board, origin = os.environ['BOARD'], 'env' - elif self.config_board is not None: + elif getattr(self, 'config_board', None) and self.config_board is not None: board, origin = self.config_board, 'configfile' return board, origin @@ -456,16 +456,37 @@ def _update_cache(self): with contextlib.suppress(FileNotFoundError): self.cmake_cache = CMakeCache.from_build_dir(self.build_dir) + def _get_dir_fmt_context(self): + # Return a dictionary of build attributes which are used while + # substituting the placeholders in the build.dir-fmt format string. + source_dir = pathlib.Path(self._find_source_dir()) + app = source_dir.name + board, _ = self._find_board() + try: + west_top_dir = west_topdir(source_dir) + except WestNotFound: + west_top_dir = pathlib.Path.cwd() + context = { + "west_topdir" : str(west_top_dir), + "source_dir" : str(source_dir), + "app" : app, + "board" : board + } + if source_dir.is_relative_to(west_top_dir): + context['source_dir_workspace'] = str(source_dir.relative_to(west_top_dir)) + else: + context['source_dir_workspace'] = str(source_dir.relative_to(source_dir.anchor)) + self.dbg(f'dir-fmt context: {context}', level=Verbosity.DBG_EXTREME) + return context + def _setup_build_dir(self): # Initialize build_dir and created_build_dir attributes. # If we created the build directory, we must run CMake. self.dbg('setting up build directory', level=Verbosity.DBG_EXTREME) # The CMake Cache has not been loaded yet, so this is safe - board, _ = self._find_board() - source_dir = self._find_source_dir() - app = os.path.split(source_dir)[1] - build_dir = find_build_dir(self.args.build_dir, board=board, - source_dir=source_dir, app=app) + + context = self._get_dir_fmt_context() + build_dir = find_build_dir(self.args.build_dir, **context) if not build_dir: self.die('Unable to determine a default build folder. Check ' 'your build.dir-fmt configuration option') diff --git a/scripts/west_commands/tests/west_build/test_dir_fmt.py b/scripts/west_commands/tests/west_build/test_dir_fmt.py new file mode 100644 index 0000000000000..871c0c98653eb --- /dev/null +++ b/scripts/west_commands/tests/west_build/test_dir_fmt.py @@ -0,0 +1,176 @@ +# Copyright (c) 2018 Nordic Semiconductor ASA +# +# SPDX-License-Identifier: Apache-2.0 + +import configparser +import copy +import os +from argparse import Namespace +from pathlib import Path + +import pytest +from build import Build + +ROOT = Path(Path.cwd().anchor) +TEST_CWD = ROOT / 'path' / 'to' / 'my' / 'current' / 'cwd' +TEST_CWD_RELATIVE_TO_ROOT = TEST_CWD.relative_to(ROOT) + + +def setup_test_build(monkeypatch, test_args=None): + # mock configparser read method to keep tests independent from actual configs + monkeypatch.setattr(configparser.ConfigParser, 'read', lambda self, filenames: None) + # mock os.makedirs to be independent from actual filesystem + monkeypatch.setattr('os.makedirs', lambda *a, **kw: None) + # mock west_topdir so that tests run independent from user machine + monkeypatch.setattr('build.west_topdir', lambda *a, **kw: str(west_topdir)) + # mock os.getcwd and Path.cwd to use TEST_CWD + monkeypatch.setattr('os.getcwd', lambda *a, **kw: str(TEST_CWD)) + monkeypatch.setattr('pathlib.Path.cwd', lambda *a, **kw: TEST_CWD) + # mock os.environ to ignore all environment variables during test + monkeypatch.setattr('os.environ', {}) + + # set up Build + b = Build() + + # apply test args + b.args = copy.copy(DEFAULT_TEST_ARGS) + if test_args: + for k, v in vars(test_args).items(): + setattr(b.args, k, v) + + return b + + +# Use a hardcoded west_topdir to ensure that the tests run independently +# from the actual user machine +west_topdir = ROOT / 'any' / 'west' / 'workspace' + +DEFAULT_TEST_ARGS = Namespace( + board=None, source_dir=west_topdir / 'subdir' / 'project' / 'app', build_dir=None +) + +TEST_CASES_GET_DIR_FMT_CONTEXT = [ + # (test_args, source_dir, expected) + # fallback to cwd in default case + ( + {}, + None, + { + 'board': None, + 'west_topdir': str(west_topdir), + 'app': TEST_CWD.name, + 'source_dir': str(TEST_CWD), + 'source_dir_workspace': str(TEST_CWD_RELATIVE_TO_ROOT), + }, + ), + # check for correct source_dir and source_dir_workspace (if inside west_topdir) + ( + {}, + west_topdir / 'my' / 'project', + { + 'board': None, + 'west_topdir': str(west_topdir), + 'app': 'project', + 'source_dir': str(west_topdir / 'my' / 'project'), + 'source_dir_workspace': str(Path('my') / 'project'), + }, + ), + # check for correct source_dir and source_dir_workspace (if outside west_topdir) + ( + {}, + ROOT / 'path' / 'to' / 'my-project', + { + 'board': None, + 'west_topdir': str(west_topdir), + 'app': 'my-project', + 'source_dir': str(ROOT / 'path' / 'to' / 'my-project'), + 'source_dir_workspace': str(Path('path') / 'to' / 'my-project'), + }, + ), + # check for correct board + ( + Namespace(board='native_sim'), + None, + { + 'board': 'native_sim', + 'west_topdir': str(west_topdir), + 'app': TEST_CWD.name, + 'source_dir': str(TEST_CWD), + 'source_dir_workspace': str(TEST_CWD_RELATIVE_TO_ROOT), + }, + ), +] + + +@pytest.mark.parametrize('test_case', TEST_CASES_GET_DIR_FMT_CONTEXT) +def test_get_dir_fmt_context(monkeypatch, test_case): + # extract data from the test case + test_args, source_dir, expected = test_case + + # set up and run _get_dir_fmt_context + b = setup_test_build(monkeypatch, test_args) + b.args.source_dir = source_dir + actual = b._get_dir_fmt_context() + assert expected == actual + + +TEST_CASES_BUILD_DIR = [ + # (config_build, test_args, expected) + # default build directory if no args and dir-fmt are specified + ({}, None, Path('build')), + # build_dir from args should always be preferred (if it is specified) + ({}, Namespace(build_dir='from-args'), Path('from-args')), + ({'dir-fmt': 'from-dir-fmt'}, Namespace(build_dir='from-args'), Path('from-args')), + # build_dir is determined by resolving dir-fmt format string + # must be able to resolve a simple string + ({'dir-fmt': 'from-dir-fmt'}, None, 'from-dir-fmt'), + # must be able to resolve west_topdir + ({'dir-fmt': '{west_topdir}/build'}, None, west_topdir / 'build'), + # must be able to resolve app + ({'dir-fmt': '{app}'}, None, 'app'), + # must be able to resolve source_dir (when it is inside west workspace) + ( + {'dir-fmt': '{source_dir}'}, + None, + # source_dir resolves to relative path (relative to cwd), so build + # directory is depending on cwd and ends up outside 'build' + os.path.relpath(DEFAULT_TEST_ARGS.source_dir, TEST_CWD), + ), + # source_dir dir is outside west workspace, so it is absolute path + ( + {'dir-fmt': '{source_dir}'}, + Namespace(source_dir=ROOT / 'outside' / 'living' / 'app'), + os.path.relpath(ROOT / 'outside' / 'living' / 'app', TEST_CWD), + ), + # must be able to resolve source_dir_workspace (when source_dir is inside west workspace) + ({'dir-fmt': '{source_dir_workspace}'}, None, Path('subdir') / 'project' / 'app'), + # must be able to resolve source_dir_workspace (when source_dir is outside west workspace) + ( + {'dir-fmt': 'build/{source_dir_workspace}'}, + Namespace(source_dir=ROOT / 'outside' / 'living' / 'app'), + Path('build') / 'outside' / 'living' / 'app', + ), + # must be able to resolve board (must be specified) + ({'dir-fmt': '{board}'}, Namespace(board='native_sim'), 'native_sim'), +] + + +@pytest.mark.parametrize('test_case', TEST_CASES_BUILD_DIR) +def test_dir_fmt(monkeypatch, test_case): + # extract data from the test case + config_build, test_args, expected = test_case + + # apply given config_build + config = configparser.ConfigParser() + config.add_section("build") + for k, v in config_build.items(): + config.set('build', k, v) + monkeypatch.setattr("build_helpers.config", config) + monkeypatch.setattr("build.config", config) + + # set up and run _setup_build_dir + b = setup_test_build(monkeypatch, test_args) + b._setup_build_dir() + + # check for expected build-dir + assert os.path.abspath(expected) == b.build_dir diff --git a/scripts/west_commands/tests/west_build/test_resolve_build_dir.py b/scripts/west_commands/tests/west_build/test_resolve_build_dir.py new file mode 100644 index 0000000000000..8ff301a0eb797 --- /dev/null +++ b/scripts/west_commands/tests/west_build/test_resolve_build_dir.py @@ -0,0 +1,40 @@ +# Copyright (c) 2018 Nordic Semiconductor ASA +# +# SPDX-License-Identifier: Apache-2.0 + +from pathlib import Path + +import pytest +from build_helpers import _resolve_build_dir + +cwd = Path.cwd() +root = Path(cwd.anchor) +TEST_CWD = root / 'path' / 'to' / 'my' / 'current' / 'cwd' + +TEST_CASES_RESOLVE_BUILD_DIR = [ + # (fmt, kwargs, expected) + # simple string (no format string) + ('simple/string', {}, 'simple/string'), + # source_dir is inside cwd + ('{source_dir}', {'source_dir': TEST_CWD / 'subdir'}, 'subdir'), + # source_dir is outside cwd + ('{source_dir}', {'source_dir': TEST_CWD / '..' / 'subdir'}, str(Path('..') / 'subdir')), + # cwd is inside source dir + ('{source_dir}', {'source_dir': TEST_CWD / '..'}, ''), + # source dir not resolvable by default + ('{source_dir}', {}, None), + # invalid format arg + ('{invalid}', {}, None), + # app is defined by default + ('{app}', {}, None), +] + + +@pytest.mark.parametrize('test_case', TEST_CASES_RESOLVE_BUILD_DIR) +def test_resolve_build_dir(test_case): + fmt, kwargs, expected = test_case + + # test both guess=True and guess=False + for guess in [True, False]: + actual = _resolve_build_dir(cwd=TEST_CWD, guess=guess, fmt=fmt, **kwargs) + assert actual == expected