pw_env_setup: Pin Python package versions
Add a file to pass to the '--constraint' option of 'pip'. This allows
setup.cfg files to only set actual requirements on dependencies but
ensures the actual version retrieved for a given commit is always the
same.
Added a new command 'pw python-packages'. 'pw python-packages list'
writes the list of versions of installed packages to a file.
'pw python-packages diff' compares the list of installed packages to the
constraint file. If there are new packages or updates to package
versions 'pw python-packages diff' will fail.
Added an option to pw_env_setup, '--unpin-pip-packages'. If this is set
the constraint file defined in the top-level '.gn' file is ignored. This
should periodically be used to update versions of packages in the
constraint file.
Change-Id: I49337e029b0c5c82ac1414448d97d952d9df4dae
Bug: 459
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/58280
Pigweed-Auto-Submit: Rob Mohr <mohrr@google.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed.google.com.iam.gserviceaccount.com>
Reviewed-by: Wyatt Hepler <hepler@google.com>
diff --git a/.gn b/.gn
index 60ce6bd..f898dae 100644
--- a/.gn
+++ b/.gn
@@ -13,3 +13,8 @@
# the License.
buildconfig = "//BUILDCONFIG.gn"
+
+default_args = {
+ pw_build_PIP_CONSTRAINTS =
+ [ "//pw_env_setup/py/pw_env_setup/virtualenv_setup/constraint.list" ]
+}
diff --git a/pw_cli/py/pw_cli/pw_command_plugins.py b/pw_cli/py/pw_cli/pw_command_plugins.py
index a5c20df..845e007 100644
--- a/pw_cli/py/pw_cli/pw_command_plugins.py
+++ b/pw_cli/py/pw_cli/pw_command_plugins.py
@@ -30,6 +30,8 @@
# Register these by name to avoid circular dependencies.
registry.register_by_name('doctor', 'pw_doctor.doctor', 'main')
+ registry.register_by_name('python-packages',
+ 'pw_env_setup.python_packages', 'main')
registry.register_by_name('format', 'pw_presubmit.format_code', 'main')
registry.register_by_name('logdemo', 'pw_cli.log', 'main')
registry.register_by_name('module-check', 'pw_module.check', 'main')
diff --git a/pw_env_setup/docs.rst b/pw_env_setup/docs.rst
index cef79a6..9aca996 100644
--- a/pw_env_setup/docs.rst
+++ b/pw_env_setup/docs.rst
@@ -294,6 +294,24 @@
- ``PW_MYPROJECTNAME_CIPD_INSTALL_DIR``
- ``PW_PIGWEED_CIPD_INSTALL_DIR``
+Pinning Python Packages
+***********************
+Python modules usually express dependencies as ranges, which makes it easier to
+install many Python packages that might otherwise have conflicting dependencies.
+However, this means version of packages can often change underneath us and
+builds will not be hermetic.
+
+To ensure versions don't change without approval, run
+``pw python-packages list <path/to/constraints/file>`` and then add
+``pw_build_PIP_CONSTRAINTS = ["//path/to/constraints/file"]`` to your project's
+``.gn`` file (see `Pigweed's .gn file` for an example).
+
+.. _Pigweed's .gn file: https://cs.opensource.google/pigweed/pigweed/+/main:.gn
+
+To update packages, remove the ``pw_build_PIP_CONSTRAINTS`` line, delete the
+environment, and bootstrap again. Then run the ``list`` command from above
+again, and run ``pw presubmit``.
+
Environment Variables
*********************
The following environment variables affect env setup behavior. Most users will
diff --git a/pw_env_setup/py/BUILD.gn b/pw_env_setup/py/BUILD.gn
index 09055d2..1775ff9 100644
--- a/pw_env_setup/py/BUILD.gn
+++ b/pw_env_setup/py/BUILD.gn
@@ -33,6 +33,7 @@
"pw_env_setup/env_setup.py",
"pw_env_setup/environment.py",
"pw_env_setup/json_visitor.py",
+ "pw_env_setup/python_packages.py",
"pw_env_setup/shell_visitor.py",
"pw_env_setup/spinner.py",
"pw_env_setup/virtualenv_setup/__init__.py",
@@ -42,6 +43,7 @@
]
tests = [
"environment_test.py",
+ "python_packages_test.py",
"json_visitor_test.py",
]
pylintrc = "$dir_pigweed/.pylintrc"
diff --git a/pw_env_setup/py/pw_env_setup/env_setup.py b/pw_env_setup/py/pw_env_setup/env_setup.py
index dcab6c0..931a085 100755
--- a/pw_env_setup/py/pw_env_setup/env_setup.py
+++ b/pw_env_setup/py/pw_env_setup/env_setup.py
@@ -173,7 +173,8 @@
"""Run environment setup for Pigweed."""
def __init__(self, pw_root, cipd_cache_dir, shell_file, quiet, install_dir,
virtualenv_root, strict, virtualenv_gn_out_dir, json_file,
- project_root, config_file, use_existing_cipd):
+ project_root, config_file, use_existing_cipd,
+ use_pinned_pip_packages):
self._env = environment.Environment()
self._project_root = project_root
self._pw_root = pw_root
@@ -198,6 +199,7 @@
self._virtualenv_requirements = []
self._virtualenv_gn_targets = []
self._virtualenv_gn_args = []
+ self._use_pinned_pip_packages = use_pinned_pip_packages
self._optional_submodules = []
self._required_submodules = []
self._virtualenv_system_packages = False
@@ -534,6 +536,7 @@
python=new_python3,
env=self._env,
system_packages=self._virtualenv_system_packages,
+ use_pinned_pip_packages=self._use_pinned_pip_packages,
):
return result(_Result.Status.FAILED)
@@ -647,6 +650,13 @@
action='store_true',
)
+ parser.add_argument(
+ '--unpin-pip-packages',
+ dest='use_pinned_pip_packages',
+ help='Do not use pins of pip packages.',
+ action='store_false',
+ )
+
args = parser.parse_args(argv)
return args
diff --git a/pw_env_setup/py/pw_env_setup/python_packages.py b/pw_env_setup/py/pw_env_setup/python_packages.py
new file mode 100644
index 0000000..3f8d001
--- /dev/null
+++ b/pw_env_setup/py/pw_env_setup/python_packages.py
@@ -0,0 +1,148 @@
+#!/usr/bin/env python
+
+# Copyright 2021 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.
+"""Save list of installed packages and versions."""
+
+import argparse
+import subprocess
+import sys
+from typing import Dict, List, TextIO, Union
+
+
+def _installed_packages():
+ """Run pip python_packages and write to out."""
+ cmd = [
+ 'python',
+ '-m',
+ 'pip',
+ 'freeze',
+ '--exclude-editable',
+ '--local',
+ ]
+ proc = subprocess.run(cmd, capture_output=True)
+ for line in proc.stdout.decode().splitlines():
+ if ' @ ' not in line:
+ yield line
+
+
+def ls(out: TextIO) -> int: # pylint: disable=invalid-name
+ """Run pip python_packages and write to out."""
+ for package in _installed_packages():
+ print(package, file=out)
+
+ return 0
+
+
+class UpdateRequiredError(Exception):
+ pass
+
+
+def _stderr(*args, **kwargs):
+ return print(*args, file=sys.stderr, **kwargs)
+
+
+def diff(expected: TextIO) -> int:
+ """Report on differences between installed and expected versions."""
+ actual_lines = set(_installed_packages())
+ expected_lines = set(expected.read().splitlines())
+
+ if actual_lines == expected_lines:
+ _stderr('files are identical')
+ return 0
+
+ removed_entries: Dict[str, str] = dict(
+ x.split('==', 1) # type: ignore[misc]
+ for x in expected_lines - actual_lines)
+ added_entries: Dict[str, str] = dict(
+ x.split('==', 1) # type: ignore[misc]
+ for x in actual_lines - expected_lines)
+
+ new_packages = set(added_entries) - set(removed_entries)
+ removed_packages = set(removed_entries) - set(added_entries)
+ updated_packages = set(added_entries).intersection(set(removed_entries))
+
+ if removed_packages:
+ _stderr('Removed packages')
+ for package in removed_packages:
+ _stderr(f' {package}=={removed_entries[package]}')
+
+ if updated_packages:
+ _stderr('Updated packages')
+ for package in updated_packages:
+ _stderr(f' {package}=={added_entries[package]} (from '
+ f'{removed_entries[package]})')
+
+ if new_packages:
+ _stderr('New packages')
+ for package in new_packages:
+ _stderr(f' {package}=={added_entries[package]}')
+
+ if updated_packages or new_packages:
+ _stderr("Package versions don't match!")
+ _stderr(f"""
+Please do the following:
+
+* purge your environment directory
+ * Linux/Mac: 'rm -rf "$_PW_ACTUAL_ENVIRONMENT_ROOT"'
+ * Windows: 'rmdir /S %_PW_ACTUAL_ENVIRONMENT_ROOT%'
+* bootstrap
+ * Linux/Mac: '. ./bootstrap.sh'
+ * Windows: 'bootstrap.bat'
+* update the constraint file
+ * 'pw python-packages list {expected.name}'
+""")
+ return -1
+
+ return 0
+
+
+def parse(argv: Union[List[str], None] = None) -> argparse.Namespace:
+ """Parse command-line arguments."""
+ parser = argparse.ArgumentParser()
+ subparsers = parser.add_subparsers(dest='cmd')
+
+ list_parser = subparsers.add_parser(
+ 'list', aliases=('ls', ), help='List installed package versions.')
+ list_parser.add_argument('out',
+ type=argparse.FileType('w'),
+ default=sys.stdout,
+ nargs='?')
+
+ diff_parser = subparsers.add_parser(
+ 'diff',
+ help='Show differences between expected and actual package versions.',
+ )
+ diff_parser.add_argument('expected', type=argparse.FileType('r'))
+
+ return parser.parse_args(argv)
+
+
+def main() -> int:
+ try:
+ args = vars(parse())
+ cmd = args.pop('cmd')
+ if cmd == 'diff':
+ return diff(**args)
+ if cmd == 'list':
+ return ls(**args)
+ return -1
+ except subprocess.CalledProcessError as err:
+ print(file=sys.stderr)
+ print(err.output, file=sys.stderr)
+ raise
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/pw_env_setup/py/pw_env_setup/virtualenv_setup/constraint.list b/pw_env_setup/py/pw_env_setup/virtualenv_setup/constraint.list
new file mode 100644
index 0000000..0815425
--- /dev/null
+++ b/pw_env_setup/py/pw_env_setup/virtualenv_setup/constraint.list
@@ -0,0 +1,98 @@
+actdiag==2.0.0
+alabaster==0.7.12
+appdirs==1.4.4
+astroid==2.6.6
+Babel==2.9.1
+backcall==0.2.0
+beautifulsoup4==4.10.0
+blockdiag==2.0.1
+build==0.6.0.post1
+certifi==2021.5.30
+cffi==1.14.6
+charset-normalizer==2.0.4
+coloredlogs==15.0.1
+cryptography==3.4.8
+decorator==5.0.9
+docutils==0.16
+funcparserlib==0.3.5
+furo==2021.8.31
+future==0.18.2
+grpcio==1.39.0
+grpcio-tools==1.39.0
+httpwatcher==0.5.2
+humanfriendly==9.2
+idna==3.2
+imagesize==1.2.0
+ipython==7.27.0
+isort==5.9.3
+jedi==0.18.0
+Jinja2==3.0.1
+lazy-object-proxy==1.6.0
+MarkupSafe==2.0.1
+matplotlib-inline==0.1.3
+mccabe==0.6.1
+mypy==0.910
+mypy-extensions==0.4.3
+mypy-protobuf==2.9
+nwdiag==2.0.0
+packaging==21.0
+parameterized==0.8.1
+parso==0.8.2
+pep517==0.11.0
+pexpect==4.8.0
+pickleshare==0.7.5
+Pillow==8.2.0
+prompt-toolkit==3.0.20
+protobuf==3.17.3
+psutil==5.8.0
+ptpython==3.0.19
+ptyprocess==0.7.0
+pycparser==2.20
+pyelftools==0.27
+Pygments==2.10.0
+pygments-style-dracula==1.2.5.1
+pygments-style-tomorrow==1.0.0.1
+pylint==2.9.3
+pyparsing==2.4.7
+pyperclip==1.8.2
+pyserial==3.5
+pytz==2021.1
+PyYAML==5.4.1
+requests==2.26.0
+robotframework==3.1
+scan-build==2.0.19
+seqdiag==2.0.0
+six==1.16.0
+snowballstemmer==2.1.0
+soupsieve==2.2.1
+Sphinx==4.1.2
+sphinx-design==0.0.12
+sphinx-rtd-theme==0.5.2
+sphinxcontrib-actdiag==2.0.0
+sphinxcontrib-applehelp==1.0.2
+sphinxcontrib-blockdiag==2.0.0
+sphinxcontrib-devhelp==1.0.2
+sphinxcontrib-htmlhelp==2.0.0
+sphinxcontrib-jsmath==1.0.1
+sphinxcontrib-mermaid==0.7.1
+sphinxcontrib-nwdiag==2.0.0
+sphinxcontrib-qthelp==1.0.3
+sphinxcontrib-seqdiag==2.0.0
+sphinxcontrib-serializinghtml==1.1.5
+toml==0.10.2
+tomli==1.2.1
+tornado==4.5.3
+traitlets==5.1.0
+types-docutils==0.17.0
+types-futures==0.1.6
+types-protobuf==3.17.4
+types-Pygments==2.9.2
+types-setuptools==57.0.2
+types-six==1.16.1
+typing-extensions==3.10.0.2
+urllib3==1.26.6
+watchdog==2.1.5
+wcwidth==0.2.5
+webcolors==1.11.1
+wrapt==1.12.1
+yapf==0.31.0
diff --git a/pw_env_setup/py/pw_env_setup/virtualenv_setup/install.py b/pw_env_setup/py/pw_env_setup/virtualenv_setup/install.py
index af34ab9..96136bd 100644
--- a/pw_env_setup/py/pw_env_setup/virtualenv_setup/install.py
+++ b/pw_env_setup/py/pw_env_setup/virtualenv_setup/install.py
@@ -128,7 +128,7 @@
shutil.rmtree(venv_path)
-def install(
+def install( # pylint: disable=too-many-arguments
project_root,
venv_path,
full_envsetup=True,
@@ -139,6 +139,7 @@
python=sys.executable,
env=None,
system_packages=False,
+ use_pinned_pip_packages=True,
):
"""Creates a venv and installs all packages in this Git repo."""
@@ -249,6 +250,9 @@
gn_cmd = ['gn', 'gen', build_dir]
args = list(gn_args)
+ if not use_pinned_pip_packages:
+ args.append('pw_build_PIP_CONSTRAINTS=[]')
+
args.append('dir_pigweed="{}"'.format(pw_root))
gn_cmd.append('--args={}'.format(' '.join(args)))
diff --git a/pw_env_setup/py/python_packages_test.py b/pw_env_setup/py/python_packages_test.py
new file mode 100755
index 0000000..27720c6
--- /dev/null
+++ b/pw_env_setup/py/python_packages_test.py
@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+# 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.
+"""Tests the python_packages module."""
+
+import collections
+import io
+import unittest
+from unittest import mock
+
+from pw_env_setup import python_packages
+
+
+def _subprocess_run_stdout(stdout=b'foo==1.0\nbar==2.0\npw-foo @ file:...\n'):
+ def subprocess_run(*unused_args, **unused_kwargs):
+ CompletedProcess = collections.namedtuple('CompletedProcess', 'stdout')
+ return CompletedProcess(stdout=stdout)
+
+ return subprocess_run
+
+
+class TestPythonPackages(unittest.TestCase):
+ """Tests the python_packages module."""
+ @mock.patch('pw_env_setup.python_packages.subprocess.run',
+ side_effect=_subprocess_run_stdout())
+ def test_list(self, unused_mock):
+ buf = io.StringIO()
+ python_packages.ls(buf)
+ self.assertIn('foo==1.0', buf.getvalue())
+ self.assertIn('bar==2.0', buf.getvalue())
+ self.assertNotIn('pw-foo', buf.getvalue())
+
+ @mock.patch('pw_env_setup.python_packages.subprocess.run',
+ side_effect=_subprocess_run_stdout())
+ @mock.patch('pw_env_setup.python_packages._stderr')
+ def test_diff_removed(self, stderr_mock, unused_mock):
+ expected = io.StringIO('foo==1.0\nbar==2.0\nbaz==3.0\n')
+ expected.name = 'test.name'
+ self.assertFalse(python_packages.diff(expected))
+
+ stderr_mock.assert_any_call('Removed packages')
+ stderr_mock.assert_any_call(' baz==3.0')
+
+ @mock.patch('pw_env_setup.python_packages.subprocess.run',
+ side_effect=_subprocess_run_stdout())
+ @mock.patch('pw_env_setup.python_packages._stderr')
+ def test_diff_updated(self, stderr_mock, unused_mock):
+ expected = io.StringIO('foo==1.0\nbar==1.9\n')
+ expected.name = 'test.name'
+ self.assertTrue(python_packages.diff(expected))
+
+ stderr_mock.assert_any_call('Updated packages')
+ stderr_mock.assert_any_call(' bar==2.0 (from 1.9)')
+ stderr_mock.assert_any_call("Package versions don't match!")
+
+ @mock.patch('pw_env_setup.python_packages.subprocess.run',
+ side_effect=_subprocess_run_stdout())
+ @mock.patch('pw_env_setup.python_packages._stderr')
+ def test_diff_new(self, stderr_mock, unused_mock):
+ expected = io.StringIO('foo==1.0\n')
+ expected.name = 'test.name'
+ self.assertTrue(python_packages.diff(expected))
+
+ stderr_mock.assert_any_call('New packages')
+ stderr_mock.assert_any_call(' bar==2.0')
+ stderr_mock.assert_any_call("Package versions don't match!")
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py b/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
index a20b8ab..3defaf6 100755
--- a/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
+++ b/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
@@ -526,6 +526,7 @@
# Configuration
r'^(?:.+/)?\..+$',
r'\bPW_PLUGINS$',
+ r'\bconstraint.list$',
# Metadata
r'^docker/tag$',
r'\bAUTHORS$',