diff --git a/lib/spack/spack/package.py b/lib/spack/spack/package.py index 19a565e019..8ea6f4415c 100644 --- a/lib/spack/spack/package.py +++ b/lib/spack/spack/package.py @@ -33,9 +33,11 @@ rundown on spack and how it differs from homebrew, look at the README. """ +import base64 import contextlib import copy import functools +import hashlib import inspect import itertools import os @@ -74,6 +76,7 @@ from spack.util.executable import which from spack.stage import Stage, ResourceStage, StageComposite from spack.util.environment import dump_environment +from spack.util.package_hash import package_hash from spack.version import Version """Allowed URL schemes for spack packages.""" @@ -1161,6 +1164,28 @@ def patches_to_apply(self): if self.spec.satisfies(spec)) return sorted(patchesToApply, key=lambda p: p.path_or_url) + def content_hash(self, content=None): + """Create a hash based on the sources and logic used to build the + package. This includes the contents of all applied patches and the + contents of applicable functions in the package subclass.""" + hashContent = list() + source_id = fs.for_package_version(self, self.version).source_id() + if not source_id: + # TODO? in cases where a digest or source_id isn't available, + # should this attempt to download the source and set one? This + # probably only happens for source repositories which are + # referenced by branch name rather than tag or commit ID. + message = 'Missing a source id for {s.name}@{s.version}' + tty.warn(message.format(s=self)) + hashContent.append(''.encode('utf-8')) + else: + hashContent.append(source_id.encode('utf-8')) + hashContent.extend(':'.join((p.sha256, str(p.level))).encode('utf-8') + for p in self.patches_to_apply()) + hashContent.append(package_hash(self.spec, content)) + return base64.b32encode( + hashlib.sha256(bytes().join(sorted(hashContent))).digest()).lower() + @property def namespace(self): namespace, dot, module = self.__module__.rpartition('.') diff --git a/lib/spack/spack/test/packages.py b/lib/spack/spack/test/packages.py index 9533627b24..0fd115dac2 100644 --- a/lib/spack/spack/test/packages.py +++ b/lib/spack/spack/test/packages.py @@ -29,6 +29,7 @@ from spack.repository import Repo from spack.util.naming import mod_to_class from spack.spec import Spec +from spack.util.package_hash import package_content @pytest.mark.usefixtures('config', 'builtin_mock') @@ -67,6 +68,36 @@ def test_package_class_names(self): assert 'Pmgrcollective' == mod_to_class('PmgrCollective') assert '_3db' == mod_to_class('3db') + def test_content_hash_all_same_but_patch_contents(self): + spec1 = Spec("hash-test1@1.1") + spec2 = Spec("hash-test2@1.1") + content1 = package_content(spec1) + content1 = content1.replace(spec1.package.__class__.__name__, '') + content2 = package_content(spec2) + content2 = content2.replace(spec2.package.__class__.__name__, '') + assert spec1.package.content_hash(content=content1) != \ + spec2.package.content_hash(content=content2) + + def test_content_hash_different_variants(self): + spec1 = Spec("hash-test1@1.2 +variantx") + spec2 = Spec("hash-test2@1.2 ~variantx") + content1 = package_content(spec1) + content1 = content1.replace(spec1.package.__class__.__name__, '') + content2 = package_content(spec2) + content2 = content2.replace(spec2.package.__class__.__name__, '') + assert spec1.package.content_hash(content=content1) == \ + spec2.package.content_hash(content=content2) + + def test_all_same_but_archive_hash(self): + spec1 = Spec("hash-test1@1.3") + spec2 = Spec("hash-test2@1.3") + content1 = package_content(spec1) + content1 = content1.replace(spec1.package.__class__.__name__, '') + content2 = package_content(spec2) + content2 = content2.replace(spec2.package.__class__.__name__, '') + assert spec1.package.content_hash(content=content1) != \ + spec2.package.content_hash(content=content2) + # Below tests target direct imports of spack packages from the # spack.pkg namespace def test_import_package(self):