pw_package: Initial commit

Add pw_package module. This manages dependencies that aren't pulled in
through env setup. For now only nanopb is available through pw_package.

Change-Id: Ib8a20102baf27d5964bb275088c265f9334b6ff3
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/22020
Reviewed-by: Anthony DiGirolamo <tonymd@google.com>
Reviewed-by: Keir Mierle <keir@google.com>
Commit-Queue: Rob Mohr <mohrr@google.com>
This commit is contained in:
Rob Mohr 2020-10-22 11:10:24 -07:00 committed by CQ Bot Account
parent 407bdad920
commit 0b6a502162
11 changed files with 450 additions and 0 deletions

View File

@ -14,3 +14,4 @@
presubmit pw_presubmit.pigweed_presubmit main
heap-viewer pw_allocator.heap_viewer main
rpc pw_hdlc_lite.rpc_console main
package pw_package.pigweed_packages main

View File

@ -79,6 +79,7 @@ group("module_docs") {
"$dir_pw_metric:docs",
"$dir_pw_minimal_cpp_stdlib:docs",
"$dir_pw_module:docs",
"$dir_pw_package:docs",
"$dir_pw_polyfill:docs",
"$dir_pw_preprocessor:docs",
"$dir_pw_presubmit:docs",

View File

@ -51,6 +51,7 @@ declare_args() {
dir_pw_minimal_cpp_stdlib = get_path_info("pw_minimal_cpp_stdlib", "abspath")
dir_pw_module = get_path_info("pw_module", "abspath")
dir_pw_fuzzer = get_path_info("pw_fuzzer", "abspath")
dir_pw_package = get_path_info("pw_package", "abspath")
dir_pw_polyfill = get_path_info("pw_polyfill", "abspath")
dir_pw_preprocessor = get_path_info("pw_preprocessor", "abspath")
dir_pw_presubmit = get_path_info("pw_presubmit", "abspath")

21
pw_package/BUILD.gn Normal file
View File

@ -0,0 +1,21 @@
# Copyright 2020 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
import("//build_overrides/pigweed.gni")
import("$dir_pw_docgen/docs.gni")
pw_doc_group("docs") {
sources = [ "docs.rst" ]
}

110
pw_package/docs.rst Normal file
View File

@ -0,0 +1,110 @@
.. _module-pw_package:
==========
pw_package
==========
The package module provides a mechanism to install additional tools used by
Pigweed. Most Pigweed dependencies should be installed using
:ref:`module-pw_env_setup`. Examples of reasons packages should be managed using
this module instead are listed below.
* The dependency is extremely large and not commonly used.
* The dependency has a number of compatible versions and we want to allow
downstream projects to pick a version rather than being forced to use ours.
* The dependency has license issues that make it complicated for Google to
include it directly as a submodule or distribute it as a CIPD package.
* The dependency needs to be "installed" into the system in some manner beyond
just extraction and thus isn't a good match for distribution with CIPD.
-----
Usage
-----
The package module can be accessed through the ``pw package`` command. This
has several subcommands.
``pw package list``
Lists all the packages installed followed by all the packages available.
``pw package install <package-name>``
Installs ``<package-name>``. Exactly how this works is package-dependent,
and packages can decide to do nothing because the package is current, do an
incremental update, or delete the current version and install anew. Use
``--force`` to remove the package before installing.
``pw package status <package-name>``
Indicates whether ``<packagxe-name>`` is installed.
``pw package remove <package-name>``
Removes ``<package-name>``.
-----------
Configuring
-----------
Compatibility
~~~~~~~~~~~~~
Python 3
Adding a New Package
~~~~~~~~~~~~~~~~~~~~
To add a new package create a class that subclasses ``Package`` from
``pw_package/package_manager.py``.
.. code-block:: python
class Package:
"""Package to be installed.
Subclass this to implement installation of a specific package.
"""
def __init__(self, name):
self._name = name
@property
def name(self):
return self._name
def install(self, path: pathlib.Path) -> None:
"""Install the package at path.
Install the package in path. Cannot assume this directory is empty—it
may need to be deleted or updated.
"""
def remove(self, path: pathlib.Path) -> None:
"""Remove the package from path.
Removes the directory containing the package. For most packages this
should be sufficient to remove the package, and subclasses should not
need to override this package.
"""
if os.path.exists(path):
shutil.rmtree(path)
def status(self, path: pathlib.Path) -> bool:
"""Returns if package is installed at path and current.
This method will be skipped if the directory does not exist.
"""
There's also a helper class for retrieving specific revisions of Git
repositories in ``pw_package/git_repo.py``.
Then call ``pw_package.package_manager.register(PackageClass)`` to register
the class with the package manager.
Setting up a Project
~~~~~~~~~~~~~~~~~~~~
To set up the package manager for a new project create a file like below and
add it to the ``PW_PLUGINS`` file (see :ref:`module-pw_cli` for details). This
file is based off of ``pw_package/pigweed_packages.py``.
.. code-block:: python
from pw_package import package_manager
# These modules register themselves so must be imported despite appearing
# unused.
from pw_package.packages import nanopb
def main(argv=None) -> int:
return package_manager.run(**vars(package_manager.parse_args(argv)))

View File

@ -0,0 +1,71 @@
# Copyright 2020 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""Install and check status of Git repository-based packages."""
import os
import pathlib
import shutil
import subprocess
from typing import Union
import pw_package.package_manager
PathOrStr = Union[pathlib.Path, str]
def git_stdout(*args: PathOrStr,
show_stderr=False,
repo: PathOrStr = '.') -> str:
return subprocess.run(['git', '-C', repo, *args],
stdout=subprocess.PIPE,
stderr=None if show_stderr else subprocess.DEVNULL,
check=True).stdout.decode().strip()
def git(*args: PathOrStr,
repo: PathOrStr = '.') -> subprocess.CompletedProcess:
return subprocess.run(['git', '-C', repo, *args], check=True)
class GitRepo(pw_package.package_manager.Package):
"""Install and check status of Git repository-based packages."""
def __init__(self, url, commit, *args, **kwargs):
super().__init__(*args, **kwargs)
self._url = url
self._commit = commit
def status(self, path: pathlib.Path) -> bool:
if not os.path.isdir(path / '.git'):
return False
remote = git_stdout('remote', 'get-url', 'origin', repo=path)
commit = git_stdout('rev-parse', 'HEAD', repo=path)
status = git_stdout('status', '--porcelain=v1', repo=path)
return remote == self._url and commit == self._commit and not status
def install(self, path: pathlib.Path) -> None:
# If already installed and at correct version exit now.
if self.status(path):
return
# Otherwise delete current version and clone again.
if os.path.isdir(path):
shutil.rmtree(path)
# --filter=blob:none means we don't get history, just the current
# revision. If we later run commands that need history it will be
# retrieved on-demand. For small repositories the effect is negligible
# but for large repositories this should be a significant improvement.
git('clone', '--filter=blob:none', self._url, path)
git('reset', '--hard', self._commit, repo=path)

View File

@ -0,0 +1,147 @@
# Copyright 2020 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""Install and remove optional packages."""
import argparse
import logging
import os
import pathlib
import shutil
from typing import List
_LOG: logging.Logger = logging.getLogger(__name__)
class Package:
"""Package to be installed.
Subclass this to implement installation of a specific package.
"""
def __init__(self, name):
self._name = name
@property
def name(self):
return self._name
def install(self, path: pathlib.Path) -> None: # pylint: disable=no-self-use
"""Install the package at path.
Install the package in path. Cannot assume this directory is emptyit
may need to be deleted or updated.
"""
def remove(self, path: pathlib.Path) -> None: # pylint: disable=no-self-use
"""Remove the package from path.
Removes the directory containing the package. For most packages this
should be sufficient to remove the package, and subclasses should not
need to override this package.
"""
if os.path.exists(path):
shutil.rmtree(path)
def status(self, path: pathlib.Path) -> bool: # pylint: disable=no-self-use
"""Returns if package is installed at path and current.
This method will be skipped if the directory does not exist.
"""
_PACKAGES = {}
def register(package_class: type) -> None:
obj = package_class()
_PACKAGES[obj.name] = obj
class PackageManager:
"""Install and remove optional packages."""
def __init__(self):
self._pkg_root: pathlib.Path = None
def install(self, package: str, force=False):
pkg = _PACKAGES[package]
if force:
self.remove(package)
_LOG.info('Installing %s...', pkg.name)
pkg.install(self._pkg_root / pkg.name)
_LOG.info('Installing %s...done.', pkg.name)
return 0
def remove(self, package: str): # pylint: disable=no-self-use
pkg = _PACKAGES[package]
_LOG.info('Removing %s...', pkg.name)
pkg.remove(self._pkg_root / pkg.name)
_LOG.info('Removing %s...done.', pkg.name)
return 0
def status(self, package: str): # pylint: disable=no-self-use
pkg = _PACKAGES[package]
path = self._pkg_root / pkg.name
if os.path.isdir(path) and pkg.status(path):
_LOG.info('%s is installed.', pkg.name)
return 0
_LOG.info('%s is not installed.', pkg.name)
return -1
def list(self): # pylint: disable=no-self-use
_LOG.info('Installed packages:')
available = []
for package in sorted(_PACKAGES.keys()):
pkg = _PACKAGES[package]
if pkg.status(self._pkg_root / pkg.name):
_LOG.info(' %s', pkg.name)
else:
available.append(pkg.name)
_LOG.info('')
_LOG.info('Available packages:')
for pkg_name in available:
_LOG.info(' %s', pkg_name)
_LOG.info('')
return 0
def run(self, command: str, pkg_root: pathlib.Path, **kwargs):
os.makedirs(pkg_root, exist_ok=True)
self._pkg_root = pkg_root
return getattr(self, command)(**kwargs)
def parse_args(argv: List[str] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser("Manage packages.")
parser.add_argument(
'--package-root',
'-e',
dest='pkg_root',
type=pathlib.Path,
default=(pathlib.Path(os.environ['_PW_ACTUAL_ENVIRONMENT_ROOT']) /
'packages'),
)
subparsers = parser.add_subparsers(dest='command', required=True)
install = subparsers.add_parser('install')
install.add_argument('--force', '-f', action='store_true')
remove = subparsers.add_parser('remove')
status = subparsers.add_parser('status')
for cmd in (install, remove, status):
cmd.add_argument('package', choices=_PACKAGES.keys())
_ = subparsers.add_parser('list')
return parser.parse_args(argv)
def run(**kwargs):
return PackageManager().run(**kwargs)

View File

@ -0,0 +1,13 @@
# Copyright 2020 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.

View File

@ -0,0 +1,30 @@
# Copyright 2020 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""Install and check status of nanopb."""
import pw_package.git_repo
import pw_package.package_manager
class NanoPB(pw_package.git_repo.GitRepo):
"""Install and check status of nanopb."""
def __init__(self, *args, **kwargs):
super().__init__(*args,
name='nanopb',
url='https://github.com/nanopb/nanopb.git',
commit='9f57cc871d8a025039019c2d2fde217591f4e30d',
**kwargs)
pw_package.package_manager.register(NanoPB)

View File

@ -0,0 +1,29 @@
# Copyright 2020 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""Install and remove optional packages for Pigweed."""
import sys
from pw_package import package_manager
# These modules register themselves so must be imported despite appearing
# unused.
from pw_package.packages import nanopb # pylint: disable=unused-import
def main(argv=None) -> int:
return package_manager.run(**vars(package_manager.parse_args(argv)))
if __name__ == '__main__':
sys.exit(main())

26
pw_package/py/setup.py Normal file
View File

@ -0,0 +1,26 @@
# Copyright 2020 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""The pw_package package."""
import setuptools
setuptools.setup(
name='pw_package',
version='0.0.1',
author='Pigweed Authors',
author_email='pigweed-developers@googlegroups.com',
description='Tools for installing optional packages',
install_requires=[],
packages=setuptools.find_packages(),
)