Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,12 @@ Then, run the test suite locally from the top level directory::
# Recommended in an active virtual environment
poe all

# Manually
# Manually (test the installed west version)
pytest

# Manually (test the local copy)
pytest -o pythonpath=src

The ``all`` target from ``poe`` runs multiple tasks sequentially. Run ``poe -h``
to get the list of configured tasks.
You can pass arguments to the task running ``poe``. This is especially useful
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ omit = [
[tool.coverage.report]
omit = [
"*/tmp/*",
"net-tools/scripts/test.py",
"subdir/Kconfiglib/scripts/test.py",
"*/net-tools/scripts/test.py",
"*/subdir/Kconfiglib/scripts/test.py",
]

[tool.coverage.paths]
Expand Down
7 changes: 7 additions & 0 deletions src/west/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@

import colorama

if __name__ == "__main__":
# Prepend the west src directory to sys.path so that running this script
# directly in a local tree always uses the according local 'west' modules
# instead of any installed modules.
src_dir = Path(__file__).resolve().parents[2]
sys.path.insert(0, os.fspath(src_dir))

import west.configuration
from west import log
from west.app.config import Config
Expand Down
19 changes: 19 additions & 0 deletions src/west/manifest-schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,25 @@ mapping:
required: true
type: str

# allow some modification of imported projects by downstream projects
import-modifications:
required: false
type: map
mapping:
# search-replace within URLs (e.g. to use mirror URLs)
url-replace:
required: false
type: seq
sequence:
- type: map
mapping:
old:
required: true
type: str
new:
required: true
type: str

# The "projects" key specifies a sequence of "projects", each of which has a
# remote, and may specify additional configuration.
#
Expand Down
46 changes: 46 additions & 0 deletions src/west/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
Parser and abstract data types for west manifests.
'''

import copy
import enum
import errno
import logging
Expand Down Expand Up @@ -182,6 +183,34 @@ def _err(message):
_logger = logging.getLogger(__name__)


# Representation of import-modifications


class ImportModifications:
"""Represents `import-modifications` within a manifest."""

def __init__(self, manifest_data: dict | None = None):
"""Initialize a new ImportModifications instance."""
self.url_replaces: list[tuple[str, str]] = []
self.append(manifest_data or {})

def append(self, manifest_data: dict):
"""Append values from a manifest data (dictionary) to this instance."""
for kind, values in manifest_data.get('import-modifications', {}).items():
if kind == 'url-replace':
self.url_replaces += [(v['old'], v['new']) for v in values]

def merge(self, other):
"""Merge another ImportModifications instance into this one."""
if not isinstance(other, ImportModifications):
raise TypeError(f"Unsupported type'{type(other).__name__}'")
self.url_replaces += other.url_replaces

def copy(self):
"""Return a deep copy of this instance."""
return copy.deepcopy(self)


# Type for the submodule value passed through the manifest file.
class Submodule(NamedTuple):
'''Represents a Git submodule within a project.'''
Expand Down Expand Up @@ -456,6 +485,9 @@ class _import_ctx(NamedTuple):
# Bit vector of flags that modify import behavior.
import_flags: 'ImportFlag'

# import-modifications
modifications: ImportModifications


def _imap_filter_allows(imap_filter: ImapFilterFnType, project: 'Project') -> bool:
# imap_filter(project) if imap_filter is not None; True otherwise.
Expand Down Expand Up @@ -2052,6 +2084,7 @@ def get_option(option, default=None):
current_repo_abspath=current_repo_abspath,
project_importer=project_importer,
import_flags=import_flags,
modifications=ImportModifications(),
)

def _recursive_init(self, ctx: _import_ctx):
Expand All @@ -2074,6 +2107,10 @@ def _load_validated(self) -> None:

manifest_data = self._ctx.current_data['manifest']

# append values from resolved manifest_data to current context
new_modifications = ImportModifications(manifest_data)
self._ctx.modifications.merge(new_modifications)

schema_version = str(manifest_data.get('version', SCHEMA_VERSION))

# We want to make an ordered map from project names to
Expand Down Expand Up @@ -2322,6 +2359,7 @@ def _import_pathobj_from_self(self, pathobj_abs: Path, pathobj: Path) -> None:
current_abspath=pathobj_abs,
current_relpath=pathobj,
current_data=pathobj_abs.read_text(encoding=Manifest.encoding),
modifications=self._ctx.modifications.copy(),
)
try:
Manifest(topdir=self.topdir, internal_import_ctx=child_ctx)
Expand Down Expand Up @@ -2452,6 +2490,13 @@ def _load_project(self, pd: dict, url_bases: dict[str, str], defaults: _defaults
else:
self._malformed(f'project {name} has no remote or url and no default remote is set')

# modify the url
if url:
url_replaces = self._ctx.modifications.url_replaces
for url_replace in reversed(url_replaces):
old, new = url_replace
url = url.replace(old, new)

# The project's path needs to respect any import: path-prefix,
# regardless of self._ctx.import_flags. The 'ignore' type flags
# just mean ignore the imported data. The path-prefix in this
Expand Down Expand Up @@ -2672,6 +2717,7 @@ def _import_data_from_project(
# We therefore use a separate list for tracking them
# from our current list.
manifest_west_commands=[],
modifications=self._ctx.modifications.copy(),
)
try:
submanifest = Manifest(topdir=self.topdir, internal_import_ctx=child_ctx)
Expand Down
113 changes: 71 additions & 42 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# SPDX-License-Identifier: Apache-2.0

import contextlib
import io
import os
import platform
import shutil
Expand All @@ -14,6 +15,8 @@

import pytest

from west.app import main

GIT = shutil.which('git')

# Git capabilities are discovered at runtime in
Expand Down Expand Up @@ -366,48 +369,74 @@ def check_output(*args, **kwargs):
return out_bytes.decode(sys.getdefaultencoding())


def cmd(cmd, cwd=None, stderr=None, env=None):
# Run a west command in a directory (cwd defaults to os.getcwd()).
#
# This helper takes the command as a string.
#
# This helper relies on the test environment to ensure that the
# 'west' executable is a bootstrapper installed from the current
# west source code.
#
# stdout from cmd is captured and returned. The command is run in
# a python subprocess so that program-level setup and teardown
# happen fresh.

# If you have quoting issues: do NOT quote. It's not portable.
# Instead, pass `cmd` as a list.
cmd = ['west'] + (cmd.split() if isinstance(cmd, str) else cmd)

print('running:', cmd)
if env:
print('with non-default environment:')
for k in env:
if k not in os.environ or env[k] != os.environ[k]:
print(f'\t{k}={env[k]}')
for k in os.environ:
if k not in env:
print(f'\t{k}: deleted, was: {os.environ[k]}')
if cwd is not None:
cwd = os.fspath(cwd)
print(f'in {cwd}')
try:
return check_output(cmd, cwd=cwd, stderr=stderr, env=env)
except subprocess.CalledProcessError:
print('cmd: west:', shutil.which('west'), file=sys.stderr)
raise


def cmd_raises(cmd_str_or_list, expected_exception_type, cwd=None, env=None):
# Similar to 'cmd' but an expected exception is caught.
# Returns the output together with stderr data
with pytest.raises(expected_exception_type) as exc_info:
cmd(cmd_str_or_list, stderr=subprocess.STDOUT, cwd=cwd, env=env)
return exc_info.value.output.decode("utf-8")
def _cmd(cmd, cwd=None, env=None):
# Executes a west command by invoking the `main()` function with the
# provided command arguments.
# Parameters:
# cwd: The working directory in which to execute the command.
# env: A dictionary of extra environment variables to apply temporarily
# during execution.

# ensure that cmd is a list of strings
cmd = cmd.split() if isinstance(cmd, str) else cmd
cmd = [str(c) for c in cmd]

# run main()
with (
chdir(cwd or Path.cwd()),
update_env(env or {}),
):
try:
main.main(cmd)
except SystemExit as e:
if e.code:
raise e
except Exception as e:
print(f'Uncaught exception type {e}', file=sys.stderr)
raise e


def cmd(cmd: list | str, cwd=None, stderr: io.StringIO | None = None, env=None):
# Same as _cmd(), but it captures and returns combined stdout and stderr.
# Optionally stderr can be captured separately into given stderr.
# Note that this function does not capture any stdout or stderr from an
# internally invoked subprocess.
stdout_buf = io.StringIO()
stderr_buf = stderr or stdout_buf
with contextlib.redirect_stdout(stdout_buf), contextlib.redirect_stderr(stderr_buf):
_cmd(cmd, cwd, env)
return stdout_buf.getvalue()


def cmd_raises(cmd: list | str, expected_exception_type, stdout=None, cwd=None, env=None):
# Similar to '_cmd' but an expected exception is caught.
# The exception is returned together with stderr.
# Optionally stdout is captured into given stdout (io.StringIO)
stdout_buf = stdout or sys.stdout
stderr_buf = io.StringIO()
with (
contextlib.redirect_stdout(stdout_buf),
contextlib.redirect_stderr(stderr_buf),
pytest.raises(expected_exception_type) as exc_info,
):
_cmd(cmd, cwd=cwd, env=env)
return exc_info, stderr_buf.getvalue()


def cmd_subprocess(cmd: list | str, *args, **kwargs):
# This function behaves similarly to `cmd()`, but executes the command in a
# separate Python subprocess, capturing all stdout output.
# The captured stdout includes both Python-level output and the output of
# any subprocesses spawned internally. This makes the function particularly
# useful in test cases where the code under test launches subprocesses and
# the combined stdout needs to be verified.
# The main drawback is that it cannot be debugged within Python, so it
# should only be used sparingly in tests.
cmd = cmd if isinstance(cmd, list) else cmd.split()
cmd = [sys.executable, main.__file__] + cmd
print('running (subprocess):', cmd)
ret = check_output(cmd, *args, **kwargs)
return ret


def create_workspace(workspace_dir, and_git=True):
Expand Down
16 changes: 5 additions & 11 deletions tests/test_alias.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@
#
# SPDX-License-Identifier: Apache-2.0

import subprocess

import pytest
from conftest import cmd
from conftest import cmd, cmd_raises


@pytest.fixture(autouse=True)
Expand Down Expand Up @@ -50,10 +48,8 @@ def test_alias_infinite_recursion():
cmd('config alias.test2 test3')
cmd('config alias.test3 test1')

with pytest.raises(subprocess.CalledProcessError) as excinfo:
cmd('test1', stderr=subprocess.STDOUT)

assert 'unknown command "test1";' in str(excinfo.value.stdout)
exc, _ = cmd_raises('test1', SystemExit)
assert 'unknown command "test1";' in str(exc.value)


def test_alias_empty():
Expand All @@ -62,10 +58,8 @@ def test_alias_empty():
# help command shouldn't fail
cmd('help')

with pytest.raises(subprocess.CalledProcessError) as excinfo:
cmd('empty', stderr=subprocess.STDOUT)

assert 'empty alias "empty"' in str(excinfo.value.stdout)
exc, _ = cmd_raises('empty', SystemExit)
assert 'empty alias "empty"' in str(exc.value)


def test_alias_early_args():
Expand Down
Loading