adding spack diff command (#22283)
A `spack diff` will take two specs, and then use the spack.solver.asp.SpackSolverSetup to generate lists of facts about each (e.g., nodes, variants, etc.) and then take a set difference between the two to show the user the differences. Example output: $ spack diff python@2.7.8 python@3.8.11 ==> Warning: This interface is subject to change. --- python@2.7.8/tsxdi6gl4lihp25qrm4d6nys3nypufbf +++ python@3.8.11/yjtseru4nbpllbaxb46q7wfkyxbuvzxx @@ variant_value @@ - python patches a8c52415a8b03c0e5f28b5d52ae498f7a7e602007db2b9554df28cd5685839b8 + python patches 0d98e93189bc278fbc37a50ed7f183bd8aaf249a8e1670a465f0db6bb4f8cf87 @@ version @@ - openssl Version(1.0.2u) + openssl Version(1.1.1k) - python Version(2.7.8) + python Version(3.8.11) Currently this uses diff-like output but we will attempt to improve on this in the future. One use case for `spack diff` is whenever a user has a disambiguate situation and cannot remember how two different installs are different. The command can also output `--json` in the case of a more analysis type use case where we want to save complete data with all diffs and the intersection. However, the command is really more intended for a command line use case, and we likely will have an analyzer more suited to saving data Signed-off-by: vsoch <vsoch@users.noreply.github.com> Co-authored-by: vsoch <vsoch@users.noreply.github.com> Co-authored-by: Tamara Dahlgren <35777542+tldahlgren@users.noreply.github.com> Co-authored-by: Todd Gamblin <tgamblin@llnl.gov>
This commit is contained in:
parent
e8f284bf52
commit
54e8e19a60
5 changed files with 448 additions and 6 deletions
|
@ -695,6 +695,136 @@ structured the way you want:
|
|||
}
|
||||
|
||||
|
||||
^^^^^^^^^^^^^^
|
||||
``spack diff``
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
It's often the case that you have two versions of a spec that you need to
|
||||
disambiguate. Let's say that we've installed two variants of zlib, one with
|
||||
and one without the optimize variant:
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ spack install zlib
|
||||
$ spack install zlib -optimize
|
||||
|
||||
When we do ``spack find`` we see the two versions.
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ spack find zlib
|
||||
==> 2 installed packages
|
||||
-- linux-ubuntu20.04-skylake / gcc@9.3.0 ------------------------
|
||||
zlib@1.2.11 zlib@1.2.11
|
||||
|
||||
|
||||
Let's now say that we want to uninstall zlib. We run the command, and hit a problem
|
||||
real quickly since we have two!
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ spack uninstall zlib
|
||||
==> Error: zlib matches multiple packages:
|
||||
|
||||
-- linux-ubuntu20.04-skylake / gcc@9.3.0 ------------------------
|
||||
efzjziy zlib@1.2.11 sl7m27m zlib@1.2.11
|
||||
|
||||
==> Error: You can either:
|
||||
a) use a more specific spec, or
|
||||
b) specify the spec by its hash (e.g. `spack uninstall /hash`), or
|
||||
c) use `spack uninstall --all` to uninstall ALL matching specs.
|
||||
|
||||
Oh no! We can see from the above that we have two different versions of zlib installed,
|
||||
and the only difference between the two is the hash. This is a good use case for
|
||||
``spack diff``, which can easily show us the "diff" or set difference
|
||||
between properties for two packages. Let's try it out.
|
||||
Since the only difference we see in the ``spack find`` view is the hash, let's use
|
||||
``spack diff`` to look for more detail. We will provide the two hashes:
|
||||
|
||||
.. code-block::console
|
||||
|
||||
$ spack diff /efzjziy /sl7m27m
|
||||
==> Warning: This interface is subject to change.
|
||||
|
||||
--- zlib@1.2.11efzjziyc3dmb5h5u5azsthgbgog5mj7g
|
||||
+++ zlib@1.2.11sl7m27mzkbejtkrajigj3a3m37ygv4u2
|
||||
@@ variant_value @@
|
||||
- zlib optimize bool(False)
|
||||
+ zlib optimize bool(True)
|
||||
|
||||
|
||||
The output is colored, and written in the style of a git diff. This means that you
|
||||
can copy paste it into a GitHub markdown as a code block with language "diff" and it
|
||||
will render nicely! Here is an example:
|
||||
|
||||
.. code-block::markdown
|
||||
|
||||
```diff
|
||||
--- zlib@1.2.11/efzjziyc3dmb5h5u5azsthgbgog5mj7g
|
||||
+++ zlib@1.2.11/sl7m27mzkbejtkrajigj3a3m37ygv4u2
|
||||
@@ variant_value @@
|
||||
- zlib optimize bool(False)
|
||||
+ zlib optimize bool(True)
|
||||
```
|
||||
|
||||
Awesome! Now let's read the diff. It tells us that our first zlib was built without optimize (False)
|
||||
and the second was built with optimize (True). You can't see it in the docs here, but
|
||||
the output above is also colored based on the content being an addition (+) or subtraction (-).
|
||||
|
||||
This is a small example, but there are actually several kinds of differences that you can view, a variant value
|
||||
being just one of them. The first package that you provide (A)
|
||||
being diffed against B means that we see what is added to B but not in A (green) and what is present in A that is
|
||||
removed in B (red). Here is another example with an additional difference type, ``VERSION``:
|
||||
|
||||
.. code-block::console
|
||||
|
||||
$ spack diff python@2.7.8 python@3.8.11
|
||||
==> Warning: This interface is subject to change.
|
||||
|
||||
--- python@2.7.8/tsxdi6gl4lihp25qrm4d6nys3nypufbf
|
||||
+++ python@3.8.11/yjtseru4nbpllbaxb46q7wfkyxbuvzxx
|
||||
@@ variant_value @@
|
||||
- python patches a8c52415a8b03c0e5f28b5d52ae498f7a7e602007db2b9554df28cd5685839b8
|
||||
+ python patches 0d98e93189bc278fbc37a50ed7f183bd8aaf249a8e1670a465f0db6bb4f8cf87
|
||||
@@ version @@
|
||||
- openssl Version(1.0.2u)
|
||||
+ openssl Version(1.1.1k)
|
||||
- python Version(2.7.8)
|
||||
+ python Version(3.8.11)
|
||||
|
||||
Let's say that we were only interested in one kind of attribute above, versions!
|
||||
We can ask the command to only output this attribute. To do this, you'd add
|
||||
the ``-a`` for attribute parameter, which defaults to all.
|
||||
Here is how you would filter to show just versions:
|
||||
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ spack diff -a version python@2.7.8 python@3.8.11
|
||||
==> Warning: This interface is subject to change.
|
||||
|
||||
--- python@2.7.8/tsxdi6gl4lihp25qrm4d6nys3nypufbf
|
||||
+++ python@3.8.11/yjtseru4nbpllbaxb46q7wfkyxbuvzxx
|
||||
@@ version @@
|
||||
- openssl Version(1.0.2u)
|
||||
+ openssl Version(1.1.1k)
|
||||
- python Version(2.7.8)
|
||||
+ python Version(3.8.11)
|
||||
|
||||
And you can add as many attributes as you'd like with multiple `-a`.
|
||||
Finally, if you want to view the data as json (and possibly pipe into an output file)
|
||||
just add ``--json``:
|
||||
|
||||
|
||||
.. code-block:: console
|
||||
|
||||
$ spack diff --json python@2.7.8 python@3.8.11
|
||||
|
||||
|
||||
This data will be much longer because along with the differences for A vs. B and
|
||||
B vs. A, we also capture the intersection.
|
||||
|
||||
|
||||
------------------------
|
||||
Using installed packages
|
||||
------------------------
|
||||
|
|
225
lib/spack/spack/cmd/diff.py
Normal file
225
lib/spack/spack/cmd/diff.py
Normal file
|
@ -0,0 +1,225 @@
|
|||
# Copyright 2013-2021 Lawrence Livermore National Security, LLC and other
|
||||
# Spack Project Developers. See the top-level COPYRIGHT file for details.
|
||||
#
|
||||
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
|
||||
|
||||
|
||||
import sys
|
||||
|
||||
import llnl.util.tty as tty
|
||||
import llnl.util.tty.color as color
|
||||
|
||||
import spack.cmd
|
||||
import spack.cmd.common.arguments as arguments
|
||||
import spack.environment as ev
|
||||
import spack.solver.asp as asp
|
||||
import spack.util.environment
|
||||
import spack.util.spack_json as sjson
|
||||
|
||||
description = "compare two specs"
|
||||
section = "basic"
|
||||
level = "long"
|
||||
|
||||
|
||||
def setup_parser(subparser):
|
||||
arguments.add_common_arguments(
|
||||
subparser, ['specs'])
|
||||
|
||||
subparser.add_argument(
|
||||
'--json',
|
||||
action='store_true',
|
||||
default=False,
|
||||
dest='dump_json',
|
||||
help="Dump json output instead of pretty printing."
|
||||
)
|
||||
subparser.add_argument(
|
||||
'--first',
|
||||
action='store_true',
|
||||
default=False,
|
||||
dest='load_first',
|
||||
help="load the first match if multiple packages match the spec"
|
||||
)
|
||||
subparser.add_argument(
|
||||
'-a', '--attribute',
|
||||
action='append',
|
||||
help="select the attributes to show (defaults to all)"
|
||||
)
|
||||
|
||||
|
||||
def boldblue(string):
|
||||
"""
|
||||
Make a header string bold and blue we can easily see it
|
||||
"""
|
||||
return color.colorize("@*b{%s}" % string)
|
||||
|
||||
|
||||
def green(string):
|
||||
return color.colorize("@G{%s}" % string)
|
||||
|
||||
|
||||
def red(string):
|
||||
return color.colorize("@R{%s}" % string)
|
||||
|
||||
|
||||
def compare_specs(a, b, to_string=False, colorful=True):
|
||||
"""
|
||||
Generate a comparison, including diffs (for each side) and an intersection.
|
||||
|
||||
We can either print the result to the console, or parse
|
||||
into a json object for the user to save. We return an object that shows
|
||||
the differences, intersection, and names for a pair of specs a and b.
|
||||
|
||||
Arguments:
|
||||
a (spack.spec.Spec): the first spec to compare
|
||||
b (spack.spec.Spec): the second spec to compare
|
||||
a_name (str): the name of spec a
|
||||
b_name (str): the name of spec b
|
||||
to_string (bool): return an object that can be json dumped
|
||||
colorful (bool): do not format the names for the console
|
||||
"""
|
||||
# Prepare a solver setup to parse differences
|
||||
setup = asp.SpackSolverSetup()
|
||||
|
||||
a_facts = set(to_tuple(t) for t in setup.spec_clauses(a, body=True))
|
||||
b_facts = set(to_tuple(t) for t in setup.spec_clauses(b, body=True))
|
||||
|
||||
# We want to present them to the user as simple key: values
|
||||
intersect = list(a_facts.intersection(b_facts))
|
||||
spec1_not_spec2 = list(a_facts.difference(b_facts))
|
||||
spec2_not_spec1 = list(b_facts.difference(a_facts))
|
||||
|
||||
# Format the spec names to be colored
|
||||
fmt = "{name}{@version}{/hash}"
|
||||
a_name = a.format(fmt, color=color.get_color_when())
|
||||
b_name = b.format(fmt, color=color.get_color_when())
|
||||
|
||||
# We want to show what is the same, and then difference for each
|
||||
return {
|
||||
"intersect": flatten(intersect) if to_string else intersect,
|
||||
"a_not_b": flatten(spec1_not_spec2) if to_string else spec1_not_spec2,
|
||||
"b_not_a": flatten(spec2_not_spec1) if to_string else spec2_not_spec1,
|
||||
"a_name": a_name if colorful else a.format("{name}{@version}{/hash}"),
|
||||
"b_name": b_name if colorful else b.format("{name}{@version}{/hash}")
|
||||
}
|
||||
|
||||
|
||||
def to_tuple(asp_function):
|
||||
"""
|
||||
Prepare tuples of objects.
|
||||
|
||||
If we need to save to json, convert to strings
|
||||
See https://gist.github.com/tgamblin/83eba3c6d27f90d9fa3afebfc049ceaf
|
||||
"""
|
||||
args = []
|
||||
for arg in asp_function.args:
|
||||
if isinstance(arg, str):
|
||||
args.append(arg)
|
||||
continue
|
||||
args.append("%s(%s)" % (type(arg).__name__, str(arg)))
|
||||
return tuple([asp_function.name] + args)
|
||||
|
||||
|
||||
def flatten(tuple_list):
|
||||
"""
|
||||
Given a list of tuples, convert into a list of key: value tuples.
|
||||
|
||||
We are squashing whatever is after the first index into one string for
|
||||
easier parsing in the interface
|
||||
"""
|
||||
updated = []
|
||||
for item in tuple_list:
|
||||
updated.append([item[0], " ".join(item[1:])])
|
||||
return updated
|
||||
|
||||
|
||||
def print_difference(c, attributes="all", out=None):
|
||||
"""
|
||||
Print the difference.
|
||||
|
||||
Given a diffset for A and a diffset for B, print red/green diffs to show
|
||||
the differences.
|
||||
"""
|
||||
# Default to standard out unless another stream is provided
|
||||
out = out or sys.stdout
|
||||
|
||||
A = c['b_not_a']
|
||||
B = c['a_not_b']
|
||||
|
||||
out.write(red("--- %s\n" % c["a_name"]))
|
||||
out.write(green("+++ %s\n" % c["b_name"]))
|
||||
|
||||
# Cut out early if we don't have any differences!
|
||||
if not A and not B:
|
||||
print("No differences\n")
|
||||
return
|
||||
|
||||
def group_by_type(diffset):
|
||||
grouped = {}
|
||||
for entry in diffset:
|
||||
if entry[0] not in grouped:
|
||||
grouped[entry[0]] = []
|
||||
grouped[entry[0]].append(entry[1])
|
||||
|
||||
# Sort by second value to make comparison slightly closer
|
||||
for key, values in grouped.items():
|
||||
values.sort()
|
||||
return grouped
|
||||
|
||||
A = group_by_type(A)
|
||||
B = group_by_type(B)
|
||||
|
||||
# print a directionally relevant diff
|
||||
keys = list(A) + list(B)
|
||||
|
||||
category = None
|
||||
for key in keys:
|
||||
if "all" not in attributes and key not in attributes:
|
||||
continue
|
||||
|
||||
# Write the attribute, B is subtraction A is addition
|
||||
subtraction = [] if key not in B else B[key]
|
||||
addition = [] if key not in A else A[key]
|
||||
|
||||
# Bail out early if we don't have any entries
|
||||
if not subtraction and not addition:
|
||||
continue
|
||||
|
||||
# If we have a new category, create a new section
|
||||
if category != key:
|
||||
category = key
|
||||
|
||||
# print category in bold, colorized
|
||||
out.write(boldblue("@@ %s @@\n" % category))
|
||||
|
||||
# Print subtractions first
|
||||
while subtraction:
|
||||
out.write(red("- %s\n" % subtraction.pop(0)))
|
||||
if addition:
|
||||
out.write(green("+ %s\n" % addition.pop(0)))
|
||||
|
||||
# Any additions left?
|
||||
while addition:
|
||||
out.write(green("+ %s\n" % addition.pop(0)))
|
||||
|
||||
|
||||
def diff(parser, args):
|
||||
env = ev.get_env(args, 'diff')
|
||||
|
||||
if len(args.specs) != 2:
|
||||
tty.die("You must provide two specs to diff.")
|
||||
|
||||
specs = [spack.cmd.disambiguate_spec(spec, env, first=args.load_first)
|
||||
for spec in spack.cmd.parse_specs(args.specs)]
|
||||
|
||||
# Calculate the comparison (c)
|
||||
c = compare_specs(specs[0], specs[1], to_string=True,
|
||||
colorful=not args.dump_json)
|
||||
|
||||
# Default to all attributes
|
||||
attributes = args.attribute or ["all"]
|
||||
|
||||
if args.dump_json:
|
||||
print(sjson.dump(c))
|
||||
else:
|
||||
tty.warn("This interface is subject to change.\n")
|
||||
print_difference(c, attributes)
|
|
@ -96,7 +96,10 @@ def _id(thing):
|
|||
class AspFunction(AspObject):
|
||||
def __init__(self, name, args=None):
|
||||
self.name = name
|
||||
self.args = [] if args is None else args
|
||||
self.args = () if args is None else args
|
||||
|
||||
def _cmp_key(self):
|
||||
return (self.name, self.args)
|
||||
|
||||
def __call__(self, *args):
|
||||
return AspFunction(self.name, args)
|
||||
|
@ -112,10 +115,6 @@ def argify(arg):
|
|||
return clingo.Function(
|
||||
self.name, [argify(arg) for arg in self.args], positive=positive)
|
||||
|
||||
def __getitem___(self, *args):
|
||||
self.args[:] = args
|
||||
return self
|
||||
|
||||
def __str__(self):
|
||||
return "%s(%s)" % (
|
||||
self.name, ', '.join(str(_id(arg)) for arg in self.args))
|
||||
|
|
79
lib/spack/spack/test/cmd/diff.py
Normal file
79
lib/spack/spack/test/cmd/diff.py
Normal file
|
@ -0,0 +1,79 @@
|
|||
# Copyright 2013-2021 Lawrence Livermore National Security, LLC and other
|
||||
# Spack Project Developers. See the top-level COPYRIGHT file for details.
|
||||
#
|
||||
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
|
||||
|
||||
import pytest
|
||||
|
||||
import spack.cmd.diff
|
||||
import spack.config
|
||||
import spack.main
|
||||
import spack.store
|
||||
import spack.util.spack_json as sjson
|
||||
|
||||
install = spack.main.SpackCommand('install')
|
||||
diff = spack.main.SpackCommand('diff')
|
||||
|
||||
|
||||
def test_diff(install_mockery, mock_fetch, mock_archive, mock_packages):
|
||||
"""Test that we can install two packages and diff them"""
|
||||
|
||||
specA = spack.spec.Spec('mpileaks').concretized()
|
||||
specB = spack.spec.Spec('mpileaks+debug').concretized()
|
||||
|
||||
# Specs should be the same as themselves
|
||||
c = spack.cmd.diff.compare_specs(specA, specA, to_string=True)
|
||||
assert len(c['a_not_b']) == 0
|
||||
assert len(c['b_not_a']) == 0
|
||||
|
||||
# Calculate the comparison (c)
|
||||
c = spack.cmd.diff.compare_specs(specA, specB, to_string=True)
|
||||
assert len(c['a_not_b']) == 1
|
||||
assert len(c['b_not_a']) == 1
|
||||
assert c['a_not_b'][0] == ['variant_value', 'mpileaks debug bool(False)']
|
||||
assert c['b_not_a'][0] == ['variant_value', 'mpileaks debug bool(True)']
|
||||
|
||||
|
||||
def test_load_first(install_mockery, mock_fetch, mock_archive, mock_packages):
|
||||
"""Test with and without the --first option"""
|
||||
install('mpileaks')
|
||||
|
||||
# Only one version of mpileaks will work
|
||||
diff('mpileaks', 'mpileaks')
|
||||
|
||||
# 2 specs are required for a diff
|
||||
with pytest.raises(spack.main.SpackCommandError):
|
||||
diff('mpileaks')
|
||||
with pytest.raises(spack.main.SpackCommandError):
|
||||
diff('mpileaks', 'mpileaks', 'mpileaks')
|
||||
|
||||
# Ensure they are the same
|
||||
assert "No differences" in diff('mpileaks', 'mpileaks')
|
||||
output = diff('--json', 'mpileaks', 'mpileaks')
|
||||
result = sjson.load(output)
|
||||
|
||||
assert len(result['a_not_b']) == 0
|
||||
assert len(result['b_not_a']) == 0
|
||||
|
||||
assert 'mpileaks' in result['a_name']
|
||||
assert 'mpileaks' in result['b_name']
|
||||
assert "intersect" in result and len(result['intersect']) > 50
|
||||
|
||||
# After we install another version, it should ask us to disambiguate
|
||||
install('mpileaks+debug')
|
||||
|
||||
# There are two versions of mpileaks
|
||||
with pytest.raises(spack.main.SpackCommandError):
|
||||
diff('mpileaks', 'mpileaks+debug')
|
||||
|
||||
# But if we tell it to use the first, it won't try to disambiguate
|
||||
assert "variant" in diff('--first', 'mpileaks', 'mpileaks+debug')
|
||||
|
||||
# This matches them exactly
|
||||
output = diff("--json", "mpileaks@2.3/ysubb76", "mpileaks@2.3/ft5qff3")
|
||||
result = sjson.load(output)
|
||||
|
||||
assert len(result['a_not_b']) == 1
|
||||
assert len(result['b_not_a']) == 1
|
||||
assert result['a_not_b'][0] == ['variant_value', 'mpileaks debug bool(False)']
|
||||
assert result['b_not_a'][0] == ['variant_value', 'mpileaks debug bool(True)']
|
|
@ -333,7 +333,7 @@ _spack() {
|
|||
then
|
||||
SPACK_COMPREPLY="-h --help -H --all-help --color -c --config -C --config-scope -d --debug --timestamp --pdb -e --env -D --env-dir -E --no-env --use-env-repo -k --insecure -l --enable-locks -L --disable-locks -m --mock -p --profile --sorted-profile --lines -v --verbose --stacktrace -V --version --print-shell-vars"
|
||||
else
|
||||
SPACK_COMPREPLY="activate add analyze arch audit blame bootstrap build-env buildcache cd checksum ci clean clone commands compiler compilers concretize config containerize create deactivate debug dependencies dependents deprecate dev-build develop docs edit env extensions external fetch find flake8 gc gpg graph help info install license list load location log-parse maintainers mark mirror module monitor patch pkg providers pydoc python reindex remove rm repo resource restage solve spec stage style test test-env tutorial undevelop uninstall unit-test unload url verify versions view"
|
||||
SPACK_COMPREPLY="activate add analyze arch audit blame bootstrap build-env buildcache cd checksum ci clean clone commands compiler compilers concretize config containerize create deactivate debug dependencies dependents deprecate dev-build develop diff docs edit env extensions external fetch find flake8 gc gpg graph help info install license list load location log-parse maintainers mark mirror module monitor patch pkg providers pydoc python reindex remove rm repo resource restage solve spec stage style test test-env tutorial undevelop uninstall unit-test unload url verify versions view"
|
||||
fi
|
||||
}
|
||||
|
||||
|
@ -842,6 +842,15 @@ _spack_develop() {
|
|||
fi
|
||||
}
|
||||
|
||||
_spack_diff() {
|
||||
if $list_options
|
||||
then
|
||||
SPACK_COMPREPLY="-h --help --json --first -a --attribute"
|
||||
else
|
||||
_all_packages
|
||||
fi
|
||||
}
|
||||
|
||||
_spack_docs() {
|
||||
SPACK_COMPREPLY="-h --help"
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue