On Sun, Nov 12, 2017 at 6:48 AM, Zac Medico <zmed...@gentoo.org> wrote:

> Deps like these:
>
>   || ( foo bar ) || ( bar baz )
>
> Translate to disjunctive normal form (DNF):
>
>   || ( ( foo bar ) ( foo baz ) ( bar bar ) ( bar baz ) )
>
> Using DNF, if none of the packages are currently installed,
> then the ( bar bar ) choice will be automatically preferred
> since it is satisfied by the fewest number of packages.
> If the ( foo baz ) choice is already satisfied, then that
> choice will be preferred instead.
>
> Since DNF results in exponential explosion of the formula,
> only use DNF for the parts of the dependencies that have
> overlapping atoms.
>
> In order to simplify the implementation of the dnf_convert
> function, this patch also fixes _expand_new_virtuals to
> normalize results in the same way as use_reduce (with no
> redundant nested lists).
>
> Bug: https://bugs.gentoo.org/632026
> ---
>  pym/portage/dep/_dnf.py                            |  97 ++++++++++++++++
>  pym/portage/dep/dep_check.py                       | 127
> +++++++++++++++++++--
>  pym/portage/tests/dep/test_dnf_convert.py          |  48 ++++++++
>  pym/portage/tests/dep/test_overlap_dnf.py          |  28 +++++
>  .../resolver/test_virtual_minimize_children.py     |  92 +++++++++++++++
>  5 files changed, 385 insertions(+), 7 deletions(-)
>  create mode 100644 pym/portage/dep/_dnf.py
>  create mode 100644 pym/portage/tests/dep/test_dnf_convert.py
>  create mode 100644 pym/portage/tests/dep/test_overlap_dnf.py
>  create mode 100644 pym/portage/tests/resolver/test_virtual_minimize_
> children.py
>
> diff --git a/pym/portage/dep/_dnf.py b/pym/portage/dep/_dnf.py
> new file mode 100644
> index 000000000..503b209c2
> --- /dev/null
> +++ b/pym/portage/dep/_dnf.py
> @@ -0,0 +1,97 @@
> +# Copyright 2017 Gentoo Foundation
> +# Distributed under the terms of the GNU General Public License v2
> +
> +from __future__ import unicode_literals
> +
> +import itertools
> +
> +
> +def dnf_convert(dep_struct):
> +       """
> +       Convert dep_struct to disjunctive normal form (DNF), where
> dep_struct
> +       is either a conjunction or disjunction of the form produced by
> +       use_reduce(opconvert=True).
> +       """
> +       # Normalize input to have a top-level conjunction.
> +       if isinstance(dep_struct, list):
> +               if dep_struct and dep_struct[0] == '||':
> +                       dep_struct = [dep_struct]
> +       else:
> +               dep_struct = [dep_struct]
> +
> +       conjunction = []
> +       disjunctions = []
> +
> +       for x in dep_struct:
> +               if isinstance (x, list):
> +                       assert x
>


I'm not a huge fan of asserts, but if we use them can we use them in the
expr, message form?

assert x, "Assertion failed, wanted x in list form in dep: %s" % dep_struct

or whatever.


> +                       if x[0] == '||':
> +                               if any(isinstance(element, list) for
> element in x):
> +                                       x_dnf = ['||']
> +                                       for element in x[1:]:
> +                                               if isinstance(element,
> list):
> +                                                       # Due to
> normalization, a disjunction must not be
> +                                                       # nested directly
> in another disjunction, so this
> +                                                       # must be a
> conjunction.
> +                                                       assert element and
> element[0] != '||'
> +                                                       element =
> dnf_convert(element)
> +                                                       if
> contains_disjunction(element):
> +                                                               assert
> (len(element) == 1 and
> +
>  element[0] and element[0][0] == '||')
> +
>  x_dnf.extend(element[0][1:])
> +                                                       else:
> +
>  x_dnf.append(element)
> +                                               else:
> +
>  x_dnf.append(element)
> +                                       x = x_dnf
> +                               disjunctions.append(x)
> +                       else:
> +                               for x in dnf_convert(x):
> +                                       if isinstance (x, list):
> +                                               # Due to normalization, a
> conjunction must not be
> +                                               # nested directly in
> another conjunction, so this
> +                                               # must be a disjunction.
> +                                               assert x and x[0] == '||'
> +                                               disjunctions.append(x)
> +                                       else:
> +                                               conjunction.append(x)
> +               else:
> +                       conjunction.append(x)
> +
> +       if disjunctions and (conjunction or len(disjunctions) > 1):
> +               dnf_form = ['||']
> +               for x in itertools.product(*[x[1:] for x in disjunctions]):
> +                       normalized = conjunction[:]
> +                       for element in x:
> +                               if isinstance(element, list):
> +                                       normalized.extend(element)
> +                               else:
> +                                       normalized.append(element)
> +                       dnf_form.append(normalized)
> +               result = [dnf_form]
> +       else:
> +               result = conjunction + disjunctions
> +
> +       return result
> +
> +
> +def contains_disjunction(dep_struct):
> +       """
> +       Search for a disjunction contained in dep_struct, where dep_struct
> +       is either a conjunction or disjunction of the form produced by
> +       use_reduce(opconvert=True). If dep_struct is a disjunction, then
> +       this only returns True if there is a nested disjunction. Due to
> +       normalization, recursion is only needed when dep_struct is a
> +       disjunction containing a conjunction. If dep_struct is a
> conjunction,
> +       then it is assumed that normalization has elevated any nested
> +       disjunctions to the top-level.
> +       """
> +       is_disjunction = dep_struct and dep_struct[0] == '||'
> +       for x in dep_struct:
> +               if isinstance(x, list):
> +                       assert x
> +                       if x[0] == '||':
> +                               return True
> +                       elif is_disjunction and contains_disjunction(x):
> +                               return True
> +       return False
> diff --git a/pym/portage/dep/dep_check.py b/pym/portage/dep/dep_check.py
> index b33f7e5db..6db4cc42e 100644
> --- a/pym/portage/dep/dep_check.py
> +++ b/pym/portage/dep/dep_check.py
> @@ -6,14 +6,20 @@ from __future__ import unicode_literals
>  __all__ = ['dep_check', 'dep_eval', 'dep_wordreduce', 'dep_zapdeps']
>
>  import collections
> +import itertools
>  import logging
>  import operator
>
>  import portage
>  from portage.dep import Atom, match_from_list, use_reduce
> +from portage.dep._dnf import (
> +       dnf_convert as _dnf_convert,
> +       contains_disjunction as _contains_disjunction,
> +)
>  from portage.exception import InvalidDependString, ParseError
>  from portage.localization import _
>  from portage.util import writemsg, writemsg_level
> +from portage.util.digraph import digraph
>  from portage.util.SlotObject import SlotObject
>  from portage.versions import vercmp, _pkg_str
>
> @@ -28,7 +34,11 @@ def _expand_new_virtuals(mysplit, edebug, mydbapi,
> mysettings, myroot="/",
>         atom because it wouldn't necessarily make sense to block all the
> components
>         of a compound virtual.  When more than one new-style virtual is
> matched,
>         the matches are sorted from highest to lowest versions and the
> atom is
> -       expanded to || ( highest match ... lowest match )."""
> +       expanded to || ( highest match ... lowest match ).
> +
> +       The result is normalized in the same way as use_reduce, having a
> top-level
> +       conjuction, and no redundant nested lists.
> +       """
>         newsplit = []
>         mytrees = trees[myroot]
>         portdb = mytrees["porttree"].dbapi
> @@ -54,14 +64,30 @@ def _expand_new_virtuals(mysplit, edebug, mydbapi,
> mysettings, myroot="/",
>                 portdb = trees[myroot]["bintree"].dbapi
>         pprovideddict = mysettings.pprovideddict
>         myuse = kwargs["myuse"]
> +       is_disjunction = mysplit and mysplit[0] == '||'
>         for x in mysplit:
>                 if x == "||":
>                         newsplit.append(x)
>                         continue
>                 elif isinstance(x, list):
> -                       newsplit.append(_expand_new_virtuals(x, edebug,
> mydbapi,
> +                       x = _expand_new_virtuals(x, edebug, mydbapi,
>                                 mysettings, myroot=myroot, trees=trees,
> use_mask=use_mask,
> -                               use_force=use_force, **kwargs))
> +                               use_force=use_force, **kwargs)
> +                       if is_disjunction:
> +                               if len(x) == 1:
> +                                       x = x[0]
> +                                       if isinstance(x, list):
> +                                               # Due to normalization, a
> conjunction must not be
> +                                               # nested directly in
> another conjunction, so this
> +                                               # must be a disjunction.
> +                                               assert x and x[0] == '||'
> +                                               newsplit.extend(x[1:])
> +                                       else:
> +                                               newsplit.append(x)
> +                               else:
> +                                       newsplit.append(x)
> +                       else:
> +                               newsplit.extend(x)
>                         continue
>
>                 if not isinstance(x, Atom):
> @@ -101,6 +127,8 @@ def _expand_new_virtuals(mysplit, edebug, mydbapi,
> mysettings, myroot="/",
>                                         a.append(Atom(x.replace(x.cp,
> y.cp, 1)))
>                                 if not a:
>                                         newsplit.append(x)
> +                               elif is_disjunction:
> +                                       newsplit.extend(a)
>                                 elif len(a) == 1:
>                                         newsplit.append(a[0])
>                                 else:
> @@ -218,11 +246,18 @@ def _expand_new_virtuals(mysplit, edebug, mydbapi,
> mysettings, myroot="/",
>                         newsplit.append(x)
>                         if atom_graph is not None:
>                                 atom_graph.add((x, id(x)), graph_parent)
> +               elif is_disjunction:
> +                       newsplit.extend(a)
>                 elif len(a) == 1:
> -                       newsplit.append(a[0])
> +                       newsplit.extend(a[0])
>                 else:
>                         newsplit.append(['||'] + a)
>
> +       # For consistency with related functions like use_reduce, always
> +       # normalize the result to have a top-level conjunction.
> +       if is_disjunction:
> +               newsplit = [newsplit]
> +
>         return newsplit
>
>  def dep_eval(deplist):
> @@ -612,9 +647,9 @@ def dep_zapdeps(unreduced, reduced, myroot,
> use_binaries=0, trees=None):
>         for choices in choice_bins:
>                 if len(choices) < 2:
>                         continue
> -               # Prefer choices with all_installed_slots for bug #480736.
> -               choices.sort(key=operator.attrgetter('all_installed_
> slots'),
> -                       reverse=True)
> +               # Prefer choices with all_installed_slots for bug #480736,
> and
> +               # choices with a smaller number of packages for bug
> #632026.
> +               choices.sort(key=lambda x: (not x.all_installed_slots,
> len(x.slot_map)))
>                 for choice_1 in choices[1:]:
>                         cps = set(choice_1.cp_map)
>                         for choice_2 in choices:
> @@ -741,6 +776,9 @@ def dep_check(depstring, mydbapi, mysettings,
> use="yes", mode=None, myuse=None,
>         except ParseError as e:
>                 return [0, "%s" % (e,)]
>
> +       if mysettings.local_config: # if not repoman
> +               mysplit = _overlap_dnf(mysplit)
> +
>         mysplit2 = dep_wordreduce(mysplit,
>                 mysettings, mydbapi, mode, use_cache=use_cache)
>         if mysplit2 is None:
> @@ -755,6 +793,81 @@ def dep_check(depstring, mydbapi, mysettings,
> use="yes", mode=None, myuse=None,
>
>         return [1, selected_atoms]
>
> +
> +def _overlap_dnf(dep_struct):
> +       """
> +       Combine overlapping || groups using disjunctive normal form (DNF),
> in
> +       order to minimize the number of packages chosen to satisfy cases
> like
> +       "|| ( foo bar ) || ( bar baz )" as in bug #632026. Non-overlapping
> +       groups are excluded from the conversion, since DNF leads to
> exponential
> +       explosion of the formula.
> +       """
> +       if not _contains_disjunction(dep_struct):
> +               return dep_struct
> +
> +       # map atom.cp to disjunctions
> +       cp_map = collections.defaultdict(list)
> +       # graph atom.cp, with edges connecting atoms in the same
> disjunction
> +       overlap_graph = digraph()
> +       # map id(disjunction) to index in dep_struct, for deterministic
> output
> +       order_map = {}
> +       order_key = lambda x: order_map[id(x)]
> +       result = []
> +       for i, x in enumerate(dep_struct):
> +               if isinstance(x, list):
> +                       assert x and x[0] == '||'
> +                       order_map[id(x)] = i
> +                       prev_cp = None
> +                       for atom in _iter_flatten(x):
> +                               if isinstance(atom, Atom) and not
> atom.blocker:
> +                                       cp_map[atom.cp].append(x)
> +                                       overlap_graph.add(atom.cp,
> parent=prev_cp)
> +                                       prev_cp = atom.cp
> +                       if prev_cp is None: # only contains blockers
> +                               result.append(x)
> +               else:
> +                       result.append(x)
> +
> +       # group together disjunctions having atom.cp overlap
> +       traversed = set()
> +       for cp in overlap_graph:
> +               if cp in traversed:
> +                       continue
> +               disjunctions = {}
> +               stack = [cp]
> +               while stack:
> +                       cp = stack.pop()
> +                       traversed.add(cp)
> +                       for x in cp_map[cp]:
> +                               disjunctions[id(x)] = x
> +                       for other_cp in itertools.chain(overlap_graph.
> child_nodes(cp),
> +                               overlap_graph.parent_nodes(cp)):
> +                               if other_cp not in traversed:
> +                                       stack.append(other_cp)
> +
> +               if len(disjunctions) > 1:
> +                       # convert overlapping disjunctions to DNF
> +                       result.extend(_dnf_convert(
> +                               sorted(disjunctions.values(),
> key=order_key)))
> +               else:
> +                       # pass through non-overlapping disjunctions
> +                       result.append(disjunctions.popitem()[1])
> +
> +       return result
> +
> +
> +def _iter_flatten(dep_struct):
> +       """
> +       Yield nested elements of dep_struct.
> +       """
> +       for x in dep_struct:
> +               if isinstance(x, list):
> +                       for x in _iter_flatten(x):
> +                               yield x
> +               else:
> +                       yield x
> +
> +
>  def dep_wordreduce(mydeplist,mysettings,mydbapi,mode,use_cache=1):
>         "Reduces the deplist to ones and zeros"
>         deplist=mydeplist[:]
> diff --git a/pym/portage/tests/dep/test_dnf_convert.py
> b/pym/portage/tests/dep/test_dnf_convert.py
> new file mode 100644
> index 000000000..b92778d4a
> --- /dev/null
> +++ b/pym/portage/tests/dep/test_dnf_convert.py
> @@ -0,0 +1,48 @@
> +# Copyright 2017 Gentoo Foundation
> +# Distributed under the terms of the GNU General Public License v2
> +
> +from portage.tests import TestCase
> +from portage.dep import use_reduce
> +from portage.dep._dnf import dnf_convert
> +
> +class DNFConvertTestCase(TestCase):
> +
> +       def testDNFConvert(self):
> +
> +               test_cases = (
> +                       (
> +                               '|| ( A B ) || ( C D )',
> +                               [['||', ['A', 'C'], ['A', 'D'], ['B',
> 'C'], ['B', 'D']]],
> +                       ),
> +                       (
> +                               '|| ( A B ) || ( B C )',
> +                               [['||', ['A', 'B'], ['A', 'C'], ['B',
> 'B'], ['B', 'C']]],
> +                       ),
> +                       (
> +                               '|| ( A ( B C D ) )',
> +                               [['||', 'A', ['B', 'C', 'D']]],
> +                       ),
> +                       (
> +                               '|| ( A ( B C D ) ) E',
> +                               [['||', ['E', 'A'], ['E', 'B', 'C', 'D']]],
> +                       ),
> +                       (
> +                               '|| ( A ( B C ) ) || ( D E ) F',
> +                               [['||', ['F', 'A', 'D'], ['F', 'A', 'E'],
> ['F', 'B', 'C', 'D'], ['F', 'B', 'C', 'E']]],
> +                       ),
> +                       (
> +                               '|| ( A ( B C || ( D E ) ) ( F G ) H )',
> +                               [['||', 'A', ['B', 'C', 'D'], ['B', 'C',
> 'E'], ['F', 'G'], 'H']],
> +                       ),
> +                       (
> +                               '|| ( A ( B C || ( D E ) ) F )',
> +                               [['||', 'A', ['B', 'C', 'D'], ['B', 'C',
> 'E'], 'F']],
> +                       ),
> +                       (
> +                               '|| ( A ( C || ( D E ) || ( F G ) ) H )',
> +                               [['||', 'A', ['C', 'D', 'F'], ['C', 'D',
> 'G'], ['C', 'E', 'F'], ['C', 'E', 'G'], 'H']],
> +                       ),
> +               )
> +
> +               for dep_str, result in test_cases:
> +                       self.assertEqual(dnf_convert(use_reduce(dep_str,
> opconvert=True)), result)
> diff --git a/pym/portage/tests/dep/test_overlap_dnf.py
> b/pym/portage/tests/dep/test_overlap_dnf.py
> new file mode 100644
> index 000000000..79b3e8e46
> --- /dev/null
> +++ b/pym/portage/tests/dep/test_overlap_dnf.py
> @@ -0,0 +1,28 @@
> +# Copyright 2017 Gentoo Foundation
> +# Distributed under the terms of the GNU General Public License v2
> +
> +from portage.tests import TestCase
> +from portage.dep import Atom, use_reduce
> +from portage.dep.dep_check import _overlap_dnf
> +
> +class OverlapDNFTestCase(TestCase):
> +
> +       def testOverlapDNF(self):
> +
> +               test_cases = (
> +                       (
> +                               '|| ( cat/A cat/B ) cat/E || ( cat/C cat/D
> )',
> +                               ['cat/E', ['||', 'cat/A', 'cat/B'], ['||',
> 'cat/C', 'cat/D']],
> +                       ),
> +                       (
> +                               '|| ( cat/A cat/B ) cat/D || ( cat/B cat/C
> )',
> +                               ['cat/D', ['||', ['cat/A', 'cat/B'],
> ['cat/A', 'cat/C'], ['cat/B', 'cat/B'], ['cat/B', 'cat/C']]],
> +                       ),
> +                       (
> +                               '|| ( cat/A cat/B ) || ( cat/C cat/D )  ||
> ( ( cat/B cat/E ) cat/F )',
> +                               [['||', ['cat/A', 'cat/B', 'cat/E'],
> ['cat/A', 'cat/F'], ['cat/B', 'cat/B', 'cat/E'], ['cat/B', 'cat/F']],
> ['||', 'cat/C', 'cat/D']],
> +                       ),
> +               )
> +
> +               for dep_str, result in test_cases:
> +                       self.assertEqual(_overlap_dnf(use_reduce(dep_str,
> token_class=Atom, opconvert=True)), result)
> diff --git a/pym/portage/tests/resolver/test_virtual_minimize_children.py
> b/pym/portage/tests/resolver/test_virtual_minimize_children.py
> new file mode 100644
> index 000000000..35b881bfb
> --- /dev/null
> +++ b/pym/portage/tests/resolver/test_virtual_minimize_children.py
> @@ -0,0 +1,92 @@
> +# Copyright 2017 Gentoo Foundation
> +# Distributed under the terms of the GNU General Public License v2
> +
> +from portage.tests import TestCase
> +from portage.tests.resolver.ResolverPlayground import (
> +       ResolverPlayground,
> +       ResolverPlaygroundTestCase,
> +)
> +
> +class VirtualMinimizeChildrenTestCase(TestCase):
> +
> +       def testVirtualMinimizeChildren(self):
> +               ebuilds = {
> +                       'app-misc/bar-1': {
> +                               'EAPI': '6',
> +                               'RDEPEND': 'virtual/foo'
> +                       },
> +                       'virtual/foo-1': {
> +                               'EAPI': '6',
> +                               'RDEPEND': '|| ( app-misc/A app-misc/B )
> || ( app-misc/B app-misc/C )'
> +                       },
> +                       'app-misc/A-1': {
> +                               'EAPI': '6',
> +                       },
> +                       'app-misc/B-1': {
> +                               'EAPI': '6',
> +                       },
> +                       'app-misc/C-1': {
> +                               'EAPI': '6',
> +                       },
> +               }
> +
> +               test_cases = (
> +                       # Test bug 632026, where we want to minimize the
> number of
> +                       # packages chosen to satisfy overlapping || deps
> like
> +                       # "|| ( foo bar ) || ( bar baz )".
> +                       ResolverPlaygroundTestCase(
> +                               ['app-misc/bar'],
> +                               success=True,
> +                               mergelist=[
> +                                       'app-misc/B-1',
> +                                       'virtual/foo-1',
> +                                       'app-misc/bar-1',
> +                               ],
> +                       ),
> +               )
> +
> +               playground = ResolverPlayground(debug=False,
> +                       ebuilds=ebuilds)
> +
> +               try:
> +                       for test_case in test_cases:
> +                               playground.run_TestCase(test_case)
> +                               self.assertEqual(test_case.test_success,
> True,
> +                                       test_case.fail_msg)
> +               finally:
> +                       playground.debug = False
> +                       playground.cleanup()
> +
> +               # If app-misc/A and app-misc/C are installed then
> +               # that choice should be preferred over app-misc/B.
> +               installed = {
> +                       'app-misc/A-1': {
> +                               'EAPI': '6',
> +                       },
> +                       'app-misc/C-1': {
> +                               'EAPI': '6',
> +                       },
> +               }
> +
> +               test_cases = (
> +                       ResolverPlaygroundTestCase(
> +                               ['app-misc/bar'],
> +                               success=True,
> +                               mergelist=[
> +                                       'virtual/foo-1',
> +                                       'app-misc/bar-1',
> +                               ],
> +                       ),
> +               )
> +
> +               playground = ResolverPlayground(debug=False,
> +                       ebuilds=ebuilds, installed=installed)
> +
> +               try:
> +                       for test_case in test_cases:
> +                               playground.run_TestCase(test_case)
> +                               self.assertEqual(test_case.test_success,
> True,
> +                                       test_case.fail_msg)
> +               finally:
> +                       playground.debug = False
> +                       playground.cleanup()
> --
> 2.13.5
>
>
>

Reply via email to