zephyr/scripts/meta/west/commands/project.py

877 lines
30 KiB
Python
Raw Normal View History

# Copyright (c) 2018, Nordic Semiconductor ASA
#
# SPDX-License-Identifier: Apache-2.0
'''West project commands'''
import argparse
import collections
import os
import shutil
import subprocess
import textwrap
import pykwalify.core
import yaml
import log
import util
from commands import WestCommand
# Branch that points to the revision specified in the manifest (which might be
# an SHA). Local branches created with 'west branch' are set to track this
# branch.
_MANIFEST_REV_BRANCH = 'manifest-rev'
class ListProjects(WestCommand):
def __init__(self):
super().__init__(
'list-projects',
_wrap('''
List projects.
Prints the path to the manifest file and lists all projects along
with their clone paths and manifest revisions. Also includes
information on which projects are currently cloned.
'''))
def do_add_parser(self, parser_adder):
return _add_parser(parser_adder, self)
def do_run(self, args, user_args):
log.inf("Manifest path: {}\n".format(_manifest_path(args)))
for project in _all_projects(args):
log.inf('{:15} {:30} {:15} {}'.format(
project.name,
os.path.join(project.path, ''), # Add final '/' if missing
project.revision,
"(cloned)" if _cloned(project) else "(not cloned)"))
class Fetch(WestCommand):
def __init__(self):
super().__init__(
'fetch',
_wrap('''
Clone/fetch projects.
Fetches upstream changes in each of the specified projects
(default: all projects). Repositories that do not already exist are
cloned.
Unless --no-update is passed, the manifest and West source code
repositories are updated prior to fetching. See the 'update'
command.
''' + _MANIFEST_REV_HELP))
def do_add_parser(self, parser_adder):
return _add_parser(parser_adder, self, _no_update_arg,
_project_list_arg)
def do_run(self, args, user_args):
if args.update:
_update(True, True)
for project in _projects(args, listed_must_be_cloned=False):
log.dbg('fetching:', project, level=log.VERBOSE_VERY)
_fetch(project)
class Pull(WestCommand):
def __init__(self):
super().__init__(
'pull',
_wrap('''
Clone/fetch and rebase projects.
Fetches upstream changes in each of the specified projects
(default: all projects) and rebases the checked-out branch (or
detached HEAD state) on top of '{}', effectively bringing the
branch up to date. Repositories that do not already exist are
cloned.
Unless --no-update is passed, the manifest and West source code
repositories are updated prior to pulling. See the 'update'
command.
'''.format(_MANIFEST_REV_BRANCH) + _MANIFEST_REV_HELP))
def do_add_parser(self, parser_adder):
return _add_parser(parser_adder, self, _no_update_arg,
_project_list_arg)
def do_run(self, args, user_args):
if args.update:
_update(True, True)
for project in _projects(args, listed_must_be_cloned=False):
if _fetch(project):
_rebase(project)
class Rebase(WestCommand):
def __init__(self):
super().__init__(
'rebase',
_wrap('''
Rebase projects.
Rebases the checked-out branch (or detached HEAD) on top of '{}' in
each of the specified projects (default: all cloned projects),
effectively bringing the branch up to date.
'''.format(_MANIFEST_REV_BRANCH) + _MANIFEST_REV_HELP))
def do_add_parser(self, parser_adder):
return _add_parser(parser_adder, self, _project_list_arg)
def do_run(self, args, user_args):
for project in _cloned_projects(args):
_rebase(project)
class Branch(WestCommand):
def __init__(self):
super().__init__(
'branch',
_wrap('''
Create a branch or list branches, in multiple projects.
Creates a branch in each of the specified projects (default: all
cloned projects). The new branches are set to track '{}'.
With no arguments, lists all local branches along with the
repositories they appear in.
'''.format(_MANIFEST_REV_BRANCH) + _MANIFEST_REV_HELP))
def do_add_parser(self, parser_adder):
return _add_parser(
parser_adder, self,
_arg('branch', nargs='?', metavar='BRANCH_NAME'),
_project_list_arg)
def do_run(self, args, user_args):
if args.branch:
# Create a branch in the specified projects
for project in _cloned_projects(args):
_create_branch(project, args.branch)
else:
# No arguments. List local branches from all cloned projects along
# with the projects they appear in.
branch2projs = collections.defaultdict(list)
for project in _cloned_projects(args):
for branch in _branches(project):
branch2projs[branch].append(project.name)
for branch, projs in sorted(branch2projs.items()):
log.inf('{:18} {}'.format(branch, ", ".join(projs)))
class Checkout(WestCommand):
def __init__(self):
super().__init__(
'checkout',
_wrap('''
Check out topic branch.
Checks out the specified branch in each of the specified projects
(default: all cloned projects). Projects that do not have the
branch are left alone.
'''))
def do_add_parser(self, parser_adder):
return _add_parser(
parser_adder, self,
_arg('-b',
dest='create_branch',
action='store_true',
help='create the branch before checking it out'),
_arg('branch', metavar='BRANCH_NAME'),
_project_list_arg)
def do_run(self, args, user_args):
branch_exists = False
for project in _cloned_projects(args):
if args.create_branch:
_create_branch(project, args.branch)
_checkout(project, args.branch)
branch_exists = True
elif _has_branch(project, args.branch):
_checkout(project, args.branch)
branch_exists = True
if not branch_exists:
msg = 'No branch {} exists in any '.format(args.branch)
if args.projects:
log.die(msg + 'of the listed projects')
else:
log.die(msg + 'cloned project')
class Diff(WestCommand):
def __init__(self):
super().__init__(
'diff',
_wrap('''
'git diff' projects.
Runs 'git diff' for each of the specified projects (default: all
cloned projects).
Extra arguments are passed as-is to 'git diff'.
'''),
accepts_unknown_args=True)
def do_add_parser(self, parser_adder):
return _add_parser(parser_adder, self, _project_list_arg)
def do_run(self, args, user_args):
for project in _cloned_projects(args):
# Use paths that are relative to the base directory to make it
# easier to see where the changes are
_git(project, 'diff --src-prefix=(path)/ --dst-prefix=(path)/',
extra_args=user_args)
class Status(WestCommand):
def __init__(self):
super().__init__(
'status',
_wrap('''
Runs 'git status' for each of the specified projects (default: all
cloned projects). Extra arguments are passed as-is to 'git status'.
'''),
accepts_unknown_args=True)
def do_add_parser(self, parser_adder):
return _add_parser(parser_adder, self, _project_list_arg)
def do_run(self, args, user_args):
for project in _cloned_projects(args):
_inf(project, 'status of (name-and-path)')
_git(project, 'status', extra_args=user_args)
class Update(WestCommand):
def __init__(self):
super().__init__(
'update',
_wrap('''
Updates the manifest repository and/or the West source code
repository.
There is normally no need to run this command manually, because
'west fetch' and 'west pull' automatically update the West and
manifest repositories to the latest version before doing anything
else.
Pass --update-west or --update-manifest to update just that
repository. With no arguments, both are updated.
'''))
def do_add_parser(self, parser_adder):
return _add_parser(
parser_adder, self,
_arg('--update-west',
dest='update_west',
action='store_true',
help='update the West source code repository'),
_arg('--update-manifest',
dest='update_manifest',
action='store_true',
help='update the manifest repository'))
def do_run(self, args, user_args):
if not args.update_west and not args.update_manifest:
_update(True, True)
else:
_update(args.update_west, args.update_manifest)
class ForAll(WestCommand):
def __init__(self):
super().__init__(
'forall',
_wrap('''
Runs a shell (Linux) or batch (Windows) command within the
repository of each of the specified projects (default: all cloned
projects). Note that you have to quote the command if it consists
of more than one word, to prevent the shell you use to run 'west'
from splitting it up.
Since the command is run through the shell, you can use wildcards
and the like.
For example, the following command will list the contents of
proj-1's and proj-2's repositories on Linux, in long form:
west forall -c 'ls -l' proj-1 proj-2
'''))
def do_add_parser(self, parser_adder):
return _add_parser(
parser_adder, self,
_arg('-c',
dest='command',
metavar='COMMAND',
required=True),
_project_list_arg)
def do_run(self, args, user_args):
for project in _cloned_projects(args):
_inf(project, "Running '{}' in (name-and-path)"
.format(args.command))
subprocess.Popen(args.command, shell=True, cwd=project.abspath) \
.wait()
def _arg(*args, **kwargs):
# Helper for creating a new argument parser for a single argument,
# later passed in parents= to add_parser()
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument(*args, **kwargs)
return parser
# Arguments shared between more than one command
_manifest_arg = _arg(
'-m', '--manifest',
help='path to manifest file (default: west/manifest/default.yml)')
# For 'fetch' and 'pull'
_no_update_arg = _arg(
'--no-update',
dest='update',
action='store_false',
help='do not update the manifest or West before fetching project data')
# List of projects
_project_list_arg = _arg('projects', metavar='PROJECT', nargs='*')
def _add_parser(parser_adder, cmd, *extra_args):
# Adds and returns a subparser for the project-related WestCommand 'cmd'.
# All of these commands (currently) take the manifest path flag, so it's
# hardcoded here.
return parser_adder.add_parser(
cmd.name,
description=cmd.description,
formatter_class=argparse.RawDescriptionHelpFormatter,
parents=(_manifest_arg,) + extra_args)
def _wrap(s):
# Wraps help texts for commands. Some of them have variable length (due to
# _MANIFEST_REV_BRANCH), so just a textwrap.dedent() can look a bit wonky.
# [1:] gets rid of the initial newline. It's turned into a space by
# textwrap.fill() otherwise.
paragraphs = textwrap.dedent(s[1:]).split("\n\n")
return "\n\n".join(textwrap.fill(paragraph) for paragraph in paragraphs)
_MANIFEST_REV_HELP = """
The '{}' branch points to the revision that the manifest specified for the
project as of the most recent 'west fetch'/'west pull'.
""".format(_MANIFEST_REV_BRANCH)[1:].replace("\n", " ")
# Holds information about a project, taken from the manifest file (or
# constructed manually for "special" projects)
Project = collections.namedtuple(
'Project',
'name url revision path abspath clone_depth')
def _cloned_projects(args):
# Returns _projects(args, listed_must_be_cloned=True) if a list of projects
# was given by the user (i.e., listed projects are required to be cloned).
# If no projects were listed, returns all cloned projects.
# This approach avoids redundant _cloned() checks
return _projects(args) if args.projects else \
[project for project in _all_projects(args) if _cloned(project)]
def _projects(args, listed_must_be_cloned=True):
# Returns a list of project instances for the projects requested in 'args'
# (the command-line arguments), in the same order that they were listed by
# the user. If args.projects is empty, no projects were listed, and all
# projects will be returned. If a non-existent project was listed by the
# user, an error is raised.
#
# Before the manifest is parsed, it is validated agains a pykwalify schema.
# An error is raised on validation errors.
#
# listed_must_be_cloned (default: True):
# If True, an error is raised if an uncloned project was listed. This
# only applies to projects listed explicitly on the command line.
projects = _all_projects(args)
if not args.projects:
# No projects specified. Return all projects.
return projects
# Got a list of projects on the command line. First, check that they exist
# in the manifest.
project_names = [project.name for project in projects]
nonexistent = set(args.projects) - set(project_names)
if nonexistent:
log.die('Unknown project{} {} (available projects: {})'
.format('s' if len(nonexistent) > 1 else '',
', '.join(nonexistent),
', '.join(project_names)))
# Return the projects in the order they were listed
res = []
for name in args.projects:
for project in projects:
if project.name == name:
res.append(project)
break
# Check that all listed repositories are cloned, if requested
if listed_must_be_cloned:
uncloned = [prj.name for prj in res if not _cloned(prj)]
if uncloned:
log.die('The following projects are not cloned: {}. Please clone '
"them first (with 'west fetch')."
.format(", ".join(uncloned)))
return res
def _all_projects(args):
# Parses the manifest file, returning a list of Project instances.
#
# Before the manifest is parsed, it is validated against a pykwalify
# schema. An error is raised on validation errors.
manifest_path = _manifest_path(args)
_validate_manifest(manifest_path)
with open(manifest_path) as f:
manifest = yaml.safe_load(f)['manifest']
projects = []
# Manifest "defaults" keys whose values get copied to each project
# that doesn't specify its own value.
project_defaults = ('remote', 'revision')
# mp = manifest project (dictionary with values parsed from the manifest)
for mp in manifest['projects']:
# Fill in any missing fields in 'mp' with values from the 'defaults'
# dictionary
if 'defaults' in manifest:
for key, val in manifest['defaults'].items():
if key in project_defaults:
mp.setdefault(key, val)
# Add the repository URL to 'mp'
for remote in manifest['remotes']:
if remote['name'] == mp['remote']:
mp['url'] = remote['url'] + '/' + mp['name']
break
else:
log.die('Remote {} not defined in {}'
.format(mp['remote'], manifest_path))
# If no clone path is specified, the project's name is used
clone_path = mp.get('path', mp['name'])
# Use named tuples to store project information. That gives nicer
# syntax compared to a dict (project.name instead of project['name'],
# etc.)
projects.append(Project(
mp['name'],
mp['url'],
# If no revision is specified, 'master' is used
mp.get('revision', 'master'),
clone_path,
# Absolute clone path
os.path.join(util.west_topdir(), clone_path),
# If no clone depth is specified, we fetch the entire history
mp.get('clone-depth', None)))
return projects
def _validate_manifest(manifest_path):
# Validates the manifest with pykwalify. schema.yml holds the schema.
schema_path = os.path.join(os.path.dirname(__file__), "schema.yml")
try:
pykwalify.core.Core(
source_file=manifest_path,
schema_files=[schema_path]
).validate()
except pykwalify.errors.SchemaError as e:
log.die('{} malformed (schema: {}):\n{}'
.format(manifest_path, schema_path, e))
def _manifest_path(args):
# Returns the path to the manifest file. Defaults to
# .west/manifest/default.yml if the user didn't specify a manifest.
return args.manifest or os.path.join(util.west_dir(), 'manifest',
'default.yml')
def _fetch(project):
# Fetches upstream changes for 'project' and updates the 'manifest-rev'
# branch to point to the revision specified in the manifest. If the
# project's repository does not already exist, it is created first.
#
# Returns True if the project's repository already existed.
exists = _cloned(project)
if not exists:
_inf(project, 'Creating repository for (name-and-path)')
_git_base(project, 'init (abspath)')
_git(project, 'remote add origin (url)')
if project.clone_depth:
_inf(project,
'Fetching changes for (name-and-path) with --depth (clone-depth)')
# If 'clone-depth' is specified, fetch just the specified revision
# (probably a branch). That will download the minimum amount of data,
# which is probably what's wanted whenever 'clone-depth is used. The
# default 'git fetch' behavior is to do a shallow clone of all branches
# on the remote.
#
# Note: Many servers won't allow fetching arbitrary commits by SHA.
# Combining --depth with an SHA will break for those.
# Qualify branch names with refs/heads/, just to be safe. Just the
# branch name is likely to work as well though.
_git(project,
'fetch --depth=(clone-depth) origin ' +
(project.revision if _is_sha(project.revision) else \
'refs/heads/' + project.revision))
else:
_inf(project, 'Fetching changes for (name-and-path)')
# If 'clone-depth' is not specified, fetch all branches on the
# remote. This gives a more usable repository.
_git(project, 'fetch origin')
# Create/update the 'manifest-rev' branch
_git(project,
'update-ref refs/heads/(manifest-rev-branch) ' +
(project.revision if _is_sha(project.revision) else
'remotes/origin/' + project.revision))
if not exists:
# If we just initialized the repository, check out 'manifest-rev' in a
# detached HEAD state.
#
# Otherwise, the initial state would have nothing checked out, and HEAD
# would point to a non-existent refs/heads/master branch (that would
# get created if the user makes an initial commit). That state causes
# e.g. 'west rebase' to fail, and might look confusing.
#
# (The --detach flag is strictly redundant here, because the
# refs/heads/<branch> form already detaches HEAD, but it avoids a
# spammy detached HEAD warning from Git.)
_git(project, 'checkout --detach refs/heads/(manifest-rev-branch)')
return exists
def _rebase(project):
_inf(project, 'Rebasing (name-and-path) to (manifest-rev-branch)')
_git(project, 'rebase (manifest-rev-branch)')
def _cloned(project):
# Returns True if the project's path is a directory that looks
# like the top-level directory of a Git repository, and False
# otherwise.
def handle(result):
log.dbg('project', project.name,
'is {}cloned'.format('' if result else 'not '),
level=log.VERBOSE_EXTREME)
return result
if not os.path.isdir(project.abspath):
return handle(False)
# --is-inside-work-tree doesn't require that the directory is the top-level
# directory of a Git repository. Use --show-cdup instead, which prints an
# empty string (i.e., just a newline, which we strip) for the top-level
# directory.
res = _git(project, 'rev-parse --show-cdup', capture_stdout=True,
check=False)
return handle(not (res.returncode or res.stdout))
def _branches(project):
# Returns a sorted list of all local branches in 'project'
# refname:lstrip=-1 isn't available before Git 2.8 (introduced by commit
# 'tag: do not show ambiguous tag names as "tags/foo"'). Strip
# 'refs/heads/' manually instead.
return [ref[len('refs/heads/'):] for ref in
_git(project,
'for-each-ref --sort=refname --format=%(refname) refs/heads',
capture_stdout=True).stdout.split('\n')]
def _create_branch(project, branch):
if _has_branch(project, branch):
_inf(project, "Branch '{}' already exists in (name-and-path)"
.format(branch))
else:
_inf(project, "Creating branch '{}' in (name-and-path)"
.format(branch))
_git(project, 'branch --quiet --track {} (manifest-rev-branch)'
.format(branch))
def _has_branch(project, branch):
return _git(project, 'show-ref --quiet --verify refs/heads/' + branch,
check=False).returncode == 0
def _checkout(project, branch):
_inf(project, "Checking out branch '{}' in (name-and-path)".format(branch))
_git(project, 'checkout ' + branch)
def _special_project(name):
# Returns a Project instance for one of the special repositories in west/,
# so that we can reuse the project-related functions for them
return Project(
name,
'dummy URL for {} repository'.format(name),
'master',
os.path.join('west', name.lower()), # Path
os.path.join(util.west_dir(), name.lower()), # Absolute path
None # Clone depth
)
def _update(update_west, update_manifest):
# 'try' is a keyword
def attempt(project, cmd):
res = _git(project, cmd, capture_stdout=True, check=False)
if res.returncode:
# The Git command's stderr isn't redirected and will also be
# available
_die(project, _FAILED_UPDATE_MSG.format(cmd))
return res.stdout
projects = []
if update_west:
projects.append(_special_project('West'))
if update_manifest:
projects.append(_special_project('manifest'))
for project in projects:
_dbg(project, 'Updating (name-and-path)', level=log.VERBOSE_NORMAL)
# Fetch changes from upstream
attempt(project, 'fetch')
# Get the SHA of the last commit in common with the upstream branch
merge_base = attempt(project, 'merge-base HEAD remotes/origin/master')
# Get the current SHA of the upstream branch
head_sha = attempt(project, 'show-ref --hash remotes/origin/master')
# If they differ, we need to rebase
if merge_base != head_sha:
attempt(project, 'rebase remotes/origin/master')
_inf(project, 'Updated (rebased) (name-and-path) to the '
'latest version')
if project.name == 'west':
# Signal self-update, which will cause a restart. This is a bit
# nicer than doing the restart here, as callers will have a
# chance to flush file buffers, etc.
raise WestUpdated()
_FAILED_UPDATE_MSG = """
Failed to update (name-and-path), while running command '{}'. Please fix the
state of the repository, or pass --no-update to 'west fetch/pull' to skip
updating the manifest and West for the duration of the command."""[1:]
class WestUpdated(Exception):
'''Raised after West has updated its own source code'''
def _is_sha(s):
try:
int(s, 16)
except ValueError:
return False
return len(s) == 40
def _git_base(project, cmd, *, extra_args=(), capture_stdout=False,
check=True):
# Runs a git command in the West top directory. See _git_helper() for
# parameter documentation.
#
# Returns a CompletedProcess instance (see below).
return _git_helper(project, cmd, extra_args, util.west_topdir(),
capture_stdout, check)
def _git(project, cmd, *, extra_args=(), capture_stdout=False, check=True):
# Runs a git command within a particular project. See _git_helper() for
# parameter documentation.
#
# Returns a CompletedProcess instance (see below).
return _git_helper(project, cmd, extra_args, project.abspath,
capture_stdout, check)
def _git_helper(project, cmd, extra_args, cwd, capture_stdout, check):
# Runs a git command.
#
# project:
# The Project instance for the project, derived from the manifest file.
#
# cmd:
# String with git arguments. Supports some "(foo)" shorthands. See below.
#
# extra_args:
# List of additional arguments to pass to the git command (e.g. from the
# user).
#
# cwd:
# Directory to switch to first (None = current directory)
#
# capture_stdout:
# True if stdout should be captured into the returned
# subprocess.CompletedProcess instance instead of being printed.
#
# We never capture stderr, to prevent error messages from being eaten.
#
# check:
# True if an error should be raised if the git command finishes with a
# non-zero return code.
#
# Returns a subprocess.CompletedProcess instance.
# TODO: Run once somewhere?
if shutil.which('git') is None:
log.die('Git is not installed or cannot be found')
args = (('git',) +
tuple(_expand_shorthands(project, arg) for arg in cmd.split()) +
tuple(extra_args))
cmd_str = util.quote_sh_list(args)
log.dbg("running '{}'".format(cmd_str), 'in', cwd, level=log.VERBOSE_VERY)
popen = subprocess.Popen(
args, stdout=subprocess.PIPE if capture_stdout else None, cwd=cwd)
stdout, _ = popen.communicate()
dbg_msg = "'{}' in {} finished with exit status {}" \
.format(cmd_str, cwd, popen.returncode)
if capture_stdout:
dbg_msg += " and wrote {} to stdout".format(stdout)
log.dbg(dbg_msg, level=log.VERBOSE_VERY)
if check and popen.returncode:
_die(project, "Command '{}' failed for (name-and-path)"
.format(cmd_str))
if capture_stdout:
# Manual UTF-8 decoding and universal newlines. Before Python 3.6,
# Popen doesn't seem to allow using universal newlines mode (which
# enables decoding) with a specific encoding (because the encoding=
# parameter is missing).
#
# Also strip all trailing newlines as convenience. The splitlines()
# already means we lose a final '\n' anyway.
stdout = "\n".join(stdout.decode('utf-8').splitlines()).rstrip("\n")
return CompletedProcess(popen.args, popen.returncode, stdout)
def _expand_shorthands(project, s):
# Expands project-related shorthands in 's' to their values,
# returning the expanded string
return s.replace('(name)', project.name) \
.replace('(name-and-path)',
'{} ({})'.format(
project.name, os.path.join(project.path, ""))) \
.replace('(url)', project.url) \
.replace('(path)', project.path) \
.replace('(abspath)', project.abspath) \
.replace('(revision)', project.revision) \
.replace('(manifest-rev-branch)', _MANIFEST_REV_BRANCH) \
.replace('(clone-depth)', str(project.clone_depth))
def _inf(project, msg):
# Print '=== msg' (to clearly separate it from Git output). Supports the
# same (foo) shorthands as the git commands.
#
# Prints the message in green if stdout is a terminal, to clearly separate
# it from command (usually Git) output.
log.inf('=== ' + _expand_shorthands(project, msg), colorize=True)
def _wrn(project, msg):
# Warn with 'msg'. Supports the same (foo) shorthands as the git commands.
log.wrn(_expand_shorthands(project, msg))
def _dbg(project, msg, level):
# Like _wrn(), for debug messages
log.dbg(_expand_shorthands(project, msg), level=level)
def _die(project, msg):
# Like _wrn(), for dying
log.die(_expand_shorthands(project, msg))
# subprocess.CompletedProcess-alike, used instead of the real deal for Python
# 3.4 compatibility, and with two small differences:
#
# - Trailing newlines are stripped from stdout
#
# - The 'stderr' attribute is omitted, because we never capture stderr
CompletedProcess = collections.namedtuple(
'CompletedProcess', 'args returncode stdout')