diff --git a/piptools/sync.py b/piptools/sync.py index 5c473916d..f4f1a23f8 100644 --- a/piptools/sync.py +++ b/piptools/sync.py @@ -1,11 +1,12 @@ import collections import os import sys +import tempfile from subprocess import check_call from . import click from .exceptions import IncompatibleRequirements, UnsupportedConstraint -from .utils import flat_map, format_requirement, key_from_ireq, key_from_req +from .utils import flat_map, format_requirement, key_from_ireq, key_from_req, get_hashes_from_ireq PACKAGES_TO_IGNORE = [ '-markerlib', @@ -156,11 +157,16 @@ def sync(to_install, to_uninstall, verbose=False, dry_run=False, pip_flags=None, for ireq in to_install: click.echo(" {}".format(format_requirement(ireq))) else: - package_args = [] + # prepare requirement lines + req_lines = [] for ireq in sorted(to_install, key=key_from_ireq): - if ireq.editable: - package_args.extend(['-e', str(ireq.link or ireq.req)]) - else: - package_args.append(str(ireq.req)) - check_call([pip, 'install'] + pip_flags + install_flags + package_args) + ireq_hashes = get_hashes_from_ireq(ireq) + req_lines.append(format_requirement(ireq, hashes=ireq_hashes)) + + # save requirement lines to a temporary file + with tempfile.NamedTemporaryFile(mode='wt') as tmp_req_file: + tmp_req_file.write('\n'.join(req_lines)) + tmp_req_file.flush() + + check_call([pip, 'install', '-r', tmp_req_file.name] + pip_flags + install_flags) return 0 diff --git a/piptools/utils.py b/piptools/utils.py index 2360a04d5..c8142245c 100644 --- a/piptools/utils.py +++ b/piptools/utils.py @@ -53,7 +53,7 @@ def make_install_requirement(name, version, extras, constraint=False): constraint=constraint) -def format_requirement(ireq, marker=None): +def format_requirement(ireq, marker=None, hashes=None): """ Generic formatter for pretty printing InstallRequirements to the terminal in a less verbose way than using its `__str__` method. @@ -63,6 +63,10 @@ def format_requirement(ireq, marker=None): else: line = str(ireq.req).lower() + if hashes: + for hash_ in sorted(hashes): + line += " \\\n --hash={}".format(hash_) + if marker: line = '{} ; {}'.format(line, marker) @@ -249,3 +253,16 @@ def temp_environ(): finally: os.environ.clear() os.environ.update(environ) + + +def get_hashes_from_ireq(ireq): + """ + Given an InstallRequirement, return a list of string hashes in the format "{algorithm}:{hash}". + Return an empty list if there are no hashes in the requirement options. + """ + result = [] + ireq_hashes = ireq.options.get('hashes', {}) + for algorithm, hexdigests in ireq_hashes.items(): + for hash_ in hexdigests: + result.append("{}:{}".format(algorithm, hash_)) + return result diff --git a/piptools/writer.py b/piptools/writer.py index 97b6df941..d57a602f1 100644 --- a/piptools/writer.py +++ b/piptools/writer.py @@ -130,12 +130,9 @@ def write(self, results, unsafe_requirements, reverse_dependencies, f.write(os.linesep.encode('utf-8')) def _format_requirement(self, ireq, reverse_dependencies, primary_packages, marker=None, hashes=None): - line = format_requirement(ireq, marker=marker) - ireq_hashes = (hashes if hashes is not None else {}).get(ireq) - if ireq_hashes: - for hash_ in sorted(ireq_hashes): - line += " \\\n --hash={}".format(hash_) + + line = format_requirement(ireq, marker=marker, hashes=ireq_hashes) if not self.annotate or key_from_req(ireq.req) in primary_packages: return line diff --git a/tests/test_sync.py b/tests/test_sync.py index 4578c1b26..72b1e046e 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -2,6 +2,7 @@ import os import platform import sys +import tempfile import mock import pytest @@ -10,6 +11,13 @@ from piptools.sync import dependency_tree, diff, merge, sync +@pytest.fixture +def mocked_tmp_req_file(): + with mock.patch.object(tempfile, 'NamedTemporaryFile') as m: + m.return_value.__enter__.return_value.name = 'requirements.txt' + yield m.return_value.__enter__.return_value + + @pytest.mark.parametrize( ('installed', 'root', 'expected'), @@ -211,25 +219,71 @@ def test_diff_with_editable(fake_dist, from_editable): assert str(package.link) == _get_file_url(path_to_package) -@pytest.mark.parametrize( - 'lines', - [ - ['django==1.8'], - ['django==1.8', 'click==4.0'], - ] -) -def test_sync_install(from_line, lines): +def test_sync_install_temporary_requirement_file(from_line, from_editable, mocked_tmp_req_file): with mock.patch('piptools.sync.check_call') as check_call: - to_install = {from_line(line) for line in lines} - + to_install = {from_line('django==1.8')} sync(to_install, set()) - check_call.assert_called_once_with(['pip', 'install', '-q'] + sorted(lines)) + check_call.assert_called_once_with(['pip', 'install', '-r', mocked_tmp_req_file.name, '-q']) -def test_sync_with_editable(from_editable): - with mock.patch('piptools.sync.check_call') as check_call: - path_to_package = os.path.join(os.path.dirname(__file__), 'test_data', 'small_fake_package') - to_install = {from_editable(path_to_package)} +def test_sync_requirement_file(from_line, from_editable, mocked_tmp_req_file): + with mock.patch('piptools.sync.check_call'): + to_install = { + from_line('django==1.8'), + from_editable('git+git://fake.org/x/y.git#egg=y'), + from_line('click==4.0'), + from_editable('git+git://fake.org/i/j.git#egg=j'), + from_line('pytz==2017.2'), + } + + sync(to_install, set()) + + expected = ( + 'click==4.0\n' + 'django==1.8\n' + '-e git+git://fake.org/i/j.git#egg=j\n' + 'pytz==2017.2\n' + '-e git+git://fake.org/x/y.git#egg=y' + ) + mocked_tmp_req_file.write.assert_called_once_with(expected) + + +def test_sync_requirement_file_with_hashes(from_line, from_editable, mocked_tmp_req_file): + with mock.patch('piptools.sync.check_call'): + to_install = { + from_line('django==1.8', options={ + 'hashes': { + 'sha256': [ + '6a03ce2feafdd193a0ba8a26dbd9773e757d2e5d5e7933a62eac129813bd381a', + ] + } + }), + from_line('click==4.0', options={ + 'hashes': { + 'sha256': [ + '9ab1d313f99b209f8f71a629f36833030c8d7c72282cf7756834baf567dca662', + ] + } + }), + from_line('pytz==2017.2', options={ + 'hashes': { + 'sha256': [ + 'd1d6729c85acea5423671382868627129432fba9a89ecbb248d8d1c7a9f01c67', + 'f5c056e8f62d45ba8215e5cb8f50dfccb198b4b9fbea8500674f3443e4689589' + ] + } + }) + } sync(to_install, set()) - check_call.assert_called_once_with(['pip', 'install', '-q', '-e', _get_file_url(path_to_package)]) + + expected = ( + 'click==4.0 \\\n' + ' --hash=sha256:9ab1d313f99b209f8f71a629f36833030c8d7c72282cf7756834baf567dca662\n' + 'django==1.8 \\\n' + ' --hash=sha256:6a03ce2feafdd193a0ba8a26dbd9773e757d2e5d5e7933a62eac129813bd381a\n' + 'pytz==2017.2 \\\n' + ' --hash=sha256:d1d6729c85acea5423671382868627129432fba9a89ecbb248d8d1c7a9f01c67 \\\n' + ' --hash=sha256:f5c056e8f62d45ba8215e5cb8f50dfccb198b4b9fbea8500674f3443e4689589' + ) + mocked_tmp_req_file.write.assert_called_once_with(expected) diff --git a/tests/test_utils.py b/tests/test_utils.py index 78206c3a7..e3fe3e787 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,7 @@ from pytest import raises from piptools.utils import ( - as_tuple, format_requirement, format_specifier, flat_map, dedup) + as_tuple, format_requirement, format_specifier, flat_map, dedup, get_hashes_from_ireq) def test_format_requirement(from_line): @@ -14,6 +14,21 @@ def test_format_requirement_editable(from_editable): assert format_requirement(ireq) == '-e git+git://fake.org/x/y.git#egg=y' +def test_format_requirement_ireq_with_hashes(from_line): + ireq = from_line('pytz==2017.2') + ireq_hashes = [ + 'sha256:d1d6729c85acea5423671382868627129432fba9a89ecbb248d8d1c7a9f01c67', + 'sha256:f5c056e8f62d45ba8215e5cb8f50dfccb198b4b9fbea8500674f3443e4689589', + ] + + expected = ( + 'pytz==2017.2 \\\n' + ' --hash=sha256:d1d6729c85acea5423671382868627129432fba9a89ecbb248d8d1c7a9f01c67 \\\n' + ' --hash=sha256:f5c056e8f62d45ba8215e5cb8f50dfccb198b4b9fbea8500674f3443e4689589' + ) + assert format_requirement(ireq, hashes=ireq_hashes) == expected + + def test_format_specifier(from_line): ireq = from_line('foo') assert format_specifier(ireq) == '' @@ -58,3 +73,19 @@ def test_flat_map(): def test_dedup(): assert list(dedup([3, 1, 2, 4, 3, 5])) == [3, 1, 2, 4, 5] + + +def test_get_hashes_from_ireq(from_line): + ireq = from_line('pytz==2017.2', options={ + 'hashes': { + 'sha256': [ + 'd1d6729c85acea5423671382868627129432fba9a89ecbb248d8d1c7a9f01c67', + 'f5c056e8f62d45ba8215e5cb8f50dfccb198b4b9fbea8500674f3443e4689589' + ] + } + }) + expected = [ + 'sha256:d1d6729c85acea5423671382868627129432fba9a89ecbb248d8d1c7a9f01c67', + 'sha256:f5c056e8f62d45ba8215e5cb8f50dfccb198b4b9fbea8500674f3443e4689589', + ] + assert get_hashes_from_ireq(ireq) == expected