Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

retry: bugfix: package requirements with git commits (#35057) #36347

Merged
merged 9 commits into from Mar 28, 2023
95 changes: 69 additions & 26 deletions lib/spack/spack/solver/asp.py
Expand Up @@ -103,6 +103,7 @@ def getter(node):
"dev_spec",
"external",
"packages_yaml",
"package_requirements",
"package_py",
"installed",
]
Expand All @@ -115,9 +116,10 @@ def getter(node):
"VersionProvenance", version_origin_fields
)(**{name: i for i, name in enumerate(version_origin_fields)})

#: Named tuple to contain information on declared versions

DeclaredVersion = collections.namedtuple("DeclaredVersion", ["version", "idx", "origin"])


# Below numbers are used to map names of criteria to the order
# they appear in the solution. See concretize.lp

Expand Down Expand Up @@ -872,30 +874,6 @@ def key_fn(version):
)
)

for v in most_to_least_preferred:
# There are two paths for creating the ref_version in GitVersions.
# The first uses a lookup to supply a tag and distance as a version.
# The second is user specified and can be resolved as a standard version.
# This second option is constrained such that the user version must be known to Spack
if (
isinstance(v.version, spack.version.GitVersion)
and v.version.user_supplied_reference
):
ref_version = spack.version.Version(v.version.ref_version_str)
self.gen.fact(fn.version_equivalent(pkg.name, v.version, ref_version))
# disqualify any git supplied version from user if they weren't already known
# versions in spack
if not any(ref_version == dv.version for dv in most_to_least_preferred if v != dv):
msg = (
"The reference version '{version}' for package '{package}' is not defined."
" Either choose another reference version or define '{version}' in your"
" version preferences or package.py file for {package}.".format(
package=pkg.name, version=str(ref_version)
)
)

raise UnsatisfiableSpecError(msg)

# Declare deprecated versions for this package, if any
deprecated = self.deprecated_versions[pkg.name]
for v in sorted(deprecated):
Expand Down Expand Up @@ -1651,6 +1629,15 @@ def add_concrete_versions_from_specs(self, specs, origin):
DeclaredVersion(version=dep.version, idx=0, origin=origin)
)
self.possible_versions[dep.name].add(dep.version)
if (
isinstance(dep.version, spack.version.GitVersion)
and dep.version.user_supplied_reference
):
defined_version = spack.version.Version(dep.version.ref_version_str)
self.declared_versions[dep.name].append(
DeclaredVersion(version=defined_version, idx=0, origin=origin)
scheibelp marked this conversation as resolved.
Show resolved Hide resolved
)
self.possible_versions[dep.name].add(defined_version)

def _supported_targets(self, compiler_name, compiler_version, targets):
"""Get a list of which targets are supported by the compiler.
Expand Down Expand Up @@ -1889,7 +1876,11 @@ def define_version_constraints(self):

# This is needed to account for a variable number of
# numbers e.g. if both 1.0 and 1.0.2 are possible versions
exact_match = [v for v in allowed_versions if v == versions]
exact_match = [
v
for v in allowed_versions
if v == versions and not isinstance(v, spack.version.GitVersion)
haampie marked this conversation as resolved.
Show resolved Hide resolved
]
if exact_match:
allowed_versions = exact_match

Expand Down Expand Up @@ -2091,6 +2082,9 @@ def setup(self, driver, specs, reuse=None):
self.add_concrete_versions_from_specs(specs, version_provenance.spec)
self.add_concrete_versions_from_specs(dev_specs, version_provenance.dev_spec)

req_version_specs = _get_versioned_specs_from_pkg_requirements()
self.add_concrete_versions_from_specs(req_version_specs, version_provenance.package_requirements)

self.gen.h1("Concrete input spec definitions")
self.define_concrete_input_specs(specs, possible)

Expand Down Expand Up @@ -2165,6 +2159,55 @@ def literal_specs(self, specs):
self.gen.fact(fn.concretize_everything())


def _get_versioned_specs_from_pkg_requirements():
"""If package requirements mention versions that are not mentioned
elsewhere, then we need to collect those to mark them as possible
versions.
"""
Comment on lines +2163 to +2166
Copy link
Member

@alalazo alalazo Mar 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tgamblin @scheibelp Sorry if I came late to reviewing this, and previously stopped at reading the title. My understanding was that this PR allowed unknown git versions to be used in package requirements, which is 👍 .

Reading the implementation (while resolving conflicts in other PRs) it seems to me that this PR makes any non-existing version in package requirements an allowed one. I think this is a bug. For example:

packages:
  zlib:
    require: "@2.0" # This version doesn't exist and might be the result of a typo

With the previous behavior on develop we would get:

$ spack spec zlib
==> Error: Cannot satisfy the requirements in packages.yaml for the 'zlib' package. You may want to delete them to proceed with concretization. To check where the requirements are defined run 'spack config blame packages'

which, despite the error message that can be improved, is what I expect by a requirement. This PR made it such that:

$ spack spec zlib
Input spec
--------------------------------
zlib

Concretized
--------------------------------
zlib@2.0%gcc@9.4.0+optimize+pic+shared build_system=makefile arch=linux-ubuntu20.04-icelake

which in my opinion is wrong, since no version 2.0 exists for zlib. Was that on purpose? Shouldn't we restrict this logic to only git versions?

Copy link
Member

@scheibelp scheibelp Mar 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This version doesn't exist and might be the result of a typo

which in my opinion is wrong, since no version 2.0 exists for zlib

If the user says it must be zlib@2.0 then I think that's what it should be: if you say spack spec zlib@2.0 on the command line then that's what we get (and Spack tries to find 2.0 from zlib's download link).

If nothing else, right now if a user says require: foo@hash=1.5 and 1.5 doesn't exist in the package.py, then we want Spack to still allow it. I think it's consistent to extend that to all versions mandated in the config.

Printing a warning message might be in order if the package can't be fetched, but otherwise I would say this is the desired behavior. If you want to prevent declaration of number versions that don't exist (e.g. when not associated with a git hash), then that would work for the use cases I created it for, but would be inconsistent (for the reasons outlined above).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's inconsistent somehow. If the user says +shared we don't allow a shared variant where it doesn't exist. I think the only case where popping a non-git version into existence make sense are externals, since it's software already installed - and somebody put the information on its version in packages.yaml.

Copy link
Member

@alalazo alalazo Mar 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If nothing else, right now if a user says require: foo@hash=1.5 and 1.5 doesn't exist in the package.py, then we want Spack to still allow it.

That's a git version, so I'm not questioning that. It's really "regular" versions that shouldn't be added in my opinion to the allowed declared ones if they appear in requirements.

Copy link
Member

@scheibelp scheibelp Mar 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I do

$ spack spec zlib@2.0
Input spec
--------------------------------
zlib@2.0

Concretized
--------------------------------
zlib@2.0%apple-clang@12.0.0+optimize+pic+shared build_system=makefile arch=darwin-bigsur-skylake

do to make sure, are you also arguing for not doing that anymore?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we define an environment, we also validate that the concretization output matches all of the versions we specify. So that should, in theory, catch the fact that the defined default in packages doesn't match what we get.

A warning would be nice though, that way we see the discrepancy faster.

Also, having packages define default versions that are undefined in the package causes concretization errors. The version doesn't end up in the concretization facts, version checks in when clauses all fail for that version.

Copy link
Member

@scheibelp scheibelp Mar 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alalazo

But that shouldn't be defining a version 2.0 if it doesn't exist somewhere else.

To be clear, that was the behavior in Spack prior to this PR: putting versions in the preferences defines them as possible versions (i.e. if 2.0 is a version preference, spack spec zlib will give you zlib@2.0). Not to say you can't undo that You can undo that, but it will take (slightly more) than reverting parts of this PR to get what you want.

Therefore

Get back the previous behavior for non-git versions

the "previous behavior" doesn't seem to be a valid given your own stated desires. I suggest outlining the things you want to be true (e.g. as done in #36347 (comment)) and then we can make them true (ignoring however they were behaving in the past).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear, that was the behavior in Spack prior to this PR [ .. ]

I know, sorry if the reply was confusing but:

  1. My immediate worry is if we can get back the old behavior for non-git versions in requirements
  2. Then you asked what I thought about the other parts (preferences, CLI) so I gave an answer to that too - but changes there can definitely wait

Also, see this issue we got today #36574 which is related to the discussion.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is related but FWIW if you check out 9a93f22 (the commit before this was merged) you can still reproduce #36574. In some sense, my goal will be to extend this to do the opposite of what was decided there (for hash=number versions at least).

Preventing users from introducing new non-hash versions in preferences will not conflict with this PR or the users I know of that would benefit from it, so I only oppose it on account of it being inconsistent (I am willing to be overridden on that).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is related

I said this because I assumed that package requirements would have the same problem, but they don't: #36589; therefore that issue is not related to this PR (or at least not caused by it).

req_version_specs = list()
config = spack.config.get("packages")
for pkg_name, d in config.items():
if pkg_name == "all":
continue
if "require" in d:
req_version_specs.extend(_specs_from_requires(pkg_name, d["require"]))
return req_version_specs


def _specs_from_requires(pkg_name, section):
if isinstance(section, str):
spec = spack.spec.Spec(section)
if not spec.name:
spec.name = pkg_name
extracted_specs = [spec]
else:
spec_strs = []
for spec_group in section:
if isinstance(spec_group, str):
spec_strs.append(spec_group)
else:
# Otherwise it is a one_of or any_of: get the values
(x,) = spec_group.values()
spec_strs.extend(x)

extracted_specs = []
for spec_str in spec_strs:
spec = spack.spec.Spec(spec_str)
if not spec.name:
spec.name = pkg_name
extracted_specs.append(spec)

version_specs = []
for spec in extracted_specs:
try:
spec.version
version_specs.append(spec)
except spack.error.SpecError:
pass

return version_specs


class SpecBuilder(object):
"""Class with actions to rebuild a spec from ASP results."""

Expand Down
10 changes: 3 additions & 7 deletions lib/spack/spack/test/concretize.py
Expand Up @@ -22,7 +22,6 @@
import spack.repo
import spack.variant as vt
from spack.concretize import find_spec
from spack.solver.asp import UnsatisfiableSpecError
from spack.spec import Spec
from spack.version import ver

Expand Down Expand Up @@ -1845,16 +1844,13 @@ def test_git_ref_version_is_equivalent_to_specified_version(self, git_ref):
assert s.satisfies("@0.1:")

@pytest.mark.parametrize("git_ref", ("a" * 40, "0.2.15", "fbranch"))
def test_git_ref_version_errors_if_unknown_version(self, git_ref):
def test_git_ref_version_succeeds_with_unknown_version(self, git_ref):
if spack.config.get("config:concretizer") == "original":
pytest.skip("Original concretizer cannot account for git hashes")
# main is not defined in the package.py for this file
s = Spec("develop-branch-version@git.%s=main" % git_ref)
with pytest.raises(
UnsatisfiableSpecError,
match="The reference version 'main' for package 'develop-branch-version'",
):
s.concretized()
s.concretize()
assert s.satisfies("develop-branch-version@main")

@pytest.mark.regression("31484")
def test_installed_externals_are_reused(self, mutable_database, repo_with_changing_recipe):
Expand Down
143 changes: 143 additions & 0 deletions lib/spack/spack/test/concretize_requirements.py
Expand Up @@ -140,6 +140,149 @@ def test_requirement_isnt_optional(concretize_scope, test_repo):
Spec("x@1.1").concretize()


def test_git_user_supplied_reference_satisfaction(
concretize_scope, test_repo, mock_git_version_info, monkeypatch
):
repo_path, filename, commits = mock_git_version_info

monkeypatch.setattr(
spack.package_base.PackageBase, "git", "file://%s" % repo_path, raising=False
)

specs = ["v@{commit0}=2.2", "v@{commit0}", "v@2.2", "v@{commit0}=2.3"]

format_info = {"commit0": commits[0]}

hash_eq_ver, just_hash, just_ver, hash_eq_other_ver = [
Spec(x.format(**format_info)) for x in specs
]

assert hash_eq_ver.satisfies(just_hash)
assert not just_hash.satisfies(hash_eq_ver)
assert hash_eq_ver.satisfies(just_ver)
assert not just_ver.satisfies(hash_eq_ver)
assert not hash_eq_ver.satisfies(hash_eq_other_ver)
assert not hash_eq_other_ver.satisfies(hash_eq_ver)


def test_requirement_adds_new_version(
concretize_scope, test_repo, mock_git_version_info, monkeypatch
):
if spack.config.get("config:concretizer") == "original":
pytest.skip("Original concretizer does not support configuration" " requirements")

repo_path, filename, commits = mock_git_version_info
monkeypatch.setattr(
spack.package_base.PackageBase, "git", "file://%s" % repo_path, raising=False
)

a_commit_hash = commits[0]
conf_str = """\
packages:
v:
require: "@{0}=2.2"
""".format(
a_commit_hash
)
update_packages_config(conf_str)

s1 = Spec("v").concretized()
assert s1.satisfies("@2.2")
assert s1.satisfies("@{0}".format(a_commit_hash))
# Make sure the git commit info is retained
assert isinstance(s1.version, spack.version.GitVersion)
assert s1.version.ref == a_commit_hash


def test_requirement_adds_git_hash_version(
concretize_scope, test_repo, mock_git_version_info, monkeypatch
):
if spack.config.get("config:concretizer") == "original":
pytest.skip("Original concretizer does not support configuration" " requirements")

repo_path, filename, commits = mock_git_version_info
monkeypatch.setattr(
spack.package_base.PackageBase, "git", "file://%s" % repo_path, raising=False
)

a_commit_hash = commits[0]
conf_str = """\
packages:
v:
require: "@{0}"
""".format(
a_commit_hash
)
update_packages_config(conf_str)

s1 = Spec("v").concretized()
assert s1.satisfies("@{0}".format(a_commit_hash))


def test_requirement_adds_multiple_new_versions(
concretize_scope, test_repo, mock_git_version_info, monkeypatch
):
if spack.config.get("config:concretizer") == "original":
pytest.skip("Original concretizer does not support configuration" " requirements")

repo_path, filename, commits = mock_git_version_info
monkeypatch.setattr(
spack.package_base.PackageBase, "git", "file://%s" % repo_path, raising=False
)

conf_str = """\
packages:
v:
require:
- one_of: ["@{0}=2.2", "@{1}=2.3"]
""".format(
commits[0], commits[1]
)
update_packages_config(conf_str)

s1 = Spec("v").concretized()
assert s1.satisfies("@2.2")

s2 = Spec("v@{0}".format(commits[1])).concretized()
assert s2.satisfies("@{0}".format(commits[1]))
assert s2.satisfies("@2.3")


def test_preference_adds_new_version(
concretize_scope, test_repo, mock_git_version_info, monkeypatch
):
if spack.config.get("config:concretizer") == "original":
pytest.skip("Original concretizer does not support configuration" " requirements")

repo_path, filename, commits = mock_git_version_info
monkeypatch.setattr(
spack.package_base.PackageBase, "git", "file://%s" % repo_path, raising=False
scheibelp marked this conversation as resolved.
Show resolved Hide resolved
)

conf_str = """\
packages:
v:
version: ["{0}=2.2", "{1}=2.3"]
""".format(
commits[0], commits[1]
)
update_packages_config(conf_str)

s1 = Spec("v").concretized()
assert s1.satisfies("@2.2")
assert s1.satisfies("@{0}".format(commits[0]))

s2 = Spec("v@2.3").concretized()
# Note: this check will fail: the command-line spec version is preferred
# assert s2.satisfies("@{0}".format(commits[1]))
assert s2.satisfies("@2.3")

s3 = Spec("v@{0}".format(commits[1])).concretized()
assert s3.satisfies("@{0}".format(commits[1]))
# Note: this check will fail: the command-line spec version is preferred
# assert s3.satisfies("@2.3")


def test_requirement_is_successfully_applied(concretize_scope, test_repo):
"""If a simple requirement can be satisfied, make sure the
concretization succeeds and the requirement spec is applied.
Expand Down
18 changes: 17 additions & 1 deletion lib/spack/spack/version.py
Expand Up @@ -506,6 +506,9 @@ def intersection(self, other):
return VersionList()


_version_debug = False


scheibelp marked this conversation as resolved.
Show resolved Hide resolved
class GitVersion(VersionBase):
"""Class to represent versions interpreted from git refs.

Expand Down Expand Up @@ -627,7 +630,20 @@ def satisfies(self, other):
self_cmp = self._cmp(other.ref_lookup)
other_cmp = other._cmp(self.ref_lookup)

if other.is_ref:
if self.is_ref and other.is_ref:
if self.ref != other.ref:
return False
elif self.user_supplied_reference and other.user_supplied_reference:
return self.ref_version == other.ref_version
elif other.user_supplied_reference:
return False
else:
# In this case, 'other' does not supply a version equivalence
# with "=" and the commit strings are equal. 'self' may specify
# a version equivalence, but that is extra info and will
# satisfy no matter what it is.
return True
Copy link
Member

@haampie haampie Mar 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very bug prone. If no version is imposed by the user, it's looked up and something is assigned. So you can have v1 = abcdef=1.2.3 and v2 = abcdef for which v1.satisfies(v2) but v2 < v1 (as it causes a lookup of ref_version).

I maintain that we should just remove this lookup nonsense, it's just introducing too many edge cases, it's too opaque, and generally useless.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather have a centralized conversation on this (if there is such a conversation, can you link to it here?).

In this particular branch of the if statement, neither version is supplied by the user. So we are comparing things like foo@abcdef... and foo@abcdef..., they are equal if the git IDs are equal as presented by the user.

There is a bug if the lookups would render to the same git ID: (e.g. a commit and a tag) e.g. foo@abcdef... and foo@git.tag1 (if tag1 resolves to abcdef...), although that's separate from what you mention here.

elif other.is_ref:
# if other is a ref then satisfaction requires an exact version match
# i.e. the GitRef must match this.version for satisfaction
# this creates an asymmetric comparison:
Expand Down