Packages and their dependencies change over time
If A and B require different versions of C it might not be possible to use A and B together
Need a systematic way to find globally-consistent sets of packages
Semantic versioning uses three integers X.Y.Z
X is the major version (breaking changes)
Y is the minor version (new features)
Z is the patch (bug fixes)
Notation
>=1.2.3 means "any version from 1.2.3 onward"
<4 means "any version before 4.anything"
1.0-3.1 means "any version in the specified range"
{
"A": {
"3": {"B": ["3", "2"], "C": ["2"]},
"2": {"B": ["2"], "C": ["2", "1"]},
"1": {"B": ["1"]}
},
"B": {
"3": {"C": ["2"]},
"2": {"C": ["1"]},
"1": {"C": ["1"]}
},
"C": {
"2": [],
"1": []
}
}
Imagine a multi-dimensional grid with one axis per package
Possible combinations are points in this grid
Our example has 3×3×2=18 combinations
Adding one more package with two versions doubles the search space
Brute force solutions are impractical even for small problems
But worth implementing as a starting point and for testing
Generate all possible combinations of package versions
Then eliminate ones that aren't compatible with the manifest
def main():
manifest = json.load(sys.stdin)
possible = make_possibilities(manifest)
print(f"{len(possible)} possibilities")
allowed = [p for p in possible if compatible(manifest, p)]
print(f"{len(allowed)} allowed")
for a in allowed:
print(a)
Create a list of the available versions of each package
Generate their cross product
def make_possibilities(manifest):
available = []
for package, versions in manifest.items():
available.append([(package, v) for v in versions])
return list(itertools.product(*available))
Compare every entry X against every other entry Y
If they are the same package, keep looking
If package X's requirements say nothing about package Y, keep searching
If X does depend on Y but this particular version of X doesn't list this particular version of Y as a dependency, rule out this combination
If combination is still a candidate, add to list
def compatible(manifest, combination):
for package_i, version_i in combination:
lookup_i = manifest[package_i][version_i]
for package_j, version_j in combination:
if package_i == package_j:
continue
if package_j not in lookup_i:
continue
if version_j not in lookup_i[package_j]:
return False
return True
18 possibilities
3 allowed
(('A', '3'), ('B', '3'), ('C', '2'))
(('A', '2'), ('B', '2'), ('C', '1'))
(('A', '1'), ('B', '1'), ('C', '1'))
def make_possibilities(manifest):
available = []
for package, versions in manifest.items():
available.append([(package, v) for v in versions])
accum = []
_make_possible(available, [], accum)
return accum
Create a list of lists of available versions
Create an empty accumulator
Do some magic
Each call to _make_possible handles one package's worth of work
Loop over available versions of current package
Add that package to the combination in progress
If more packages, recurse
Otherwise, append to accumulator
def _make_possible(remaining, current, accum):
if not remaining:
accum.append(current)
else:
head, tail = remaining[0], remaining[1:]
for h in head:
_make_possible(tail, current + [h], accum)
Generate-and-discard is inefficient
Stop immediately if a partial combination of packages is illegal
def main():
manifest = json.load(sys.stdin)
packages = list(manifest.keys())
if len(sys.argv) > 1:
packages.reverse()
accum = []
count = find(manifest, packages, accum, [], 0)
print(f"count {count}")
for a in accum:
print(a)
reverse to allow experimentationdef find(manifest, remaining, accum, current, count):
count += 1
if not remaining:
accum.append(current)
else:
head, tail = remaining[0], remaining[1:]
for version in manifest[head]:
candidate = current + [(head, version)]
if compatible(manifest, candidate):
count = find(
manifest, tail, accum, candidate, count
)
return count
The manifest that tells us what's compatible with what.
The names of the packages we've haven't considered yet.
An accumulator to hold all the valid combinations we've found so far.
The partially-completed combination we're going to extend next.
A count of the number of combinations we've considered so far, which we will use as a measure of efficiency.
python incremental.py < triple.json
count 11
[('A', '3'), ('B', '3'), ('C', '2')]
[('A', '2'), ('B', '2'), ('C', '1')]
[('A', '1'), ('B', '1'), ('C', '1')]
python incremental.py reversed < triple.json
count 9
[('C', '2'), ('B', '3'), ('A', '3')]
[('C', '1'), ('B', '2'), ('A', '2')]
[('C', '1'), ('B', '1'), ('A', '1')]
An automated theorem prover can do much better
Prove that a set of logical propositions (e.g., dependencies) are satisfiable
We will use the Z3 theorem prover
from z3 import Bool, Solver
A = Bool("A")
B = Bool("B")
C = Bool("C")
A, B, and C don't have values
Instead, each represents the set of possible Boolean values
A == Bsolver = Solver()
solver.add(A == B)
solver.add(B == C)
report("A == B & B == C", solver.check())
A == B & B == C: sat
A False
B False
C False
A to equal B and B to equal C
but A and C to be unequalsolver = Solver()
solver.add(A == B)
solver.add(B == C)
solver.add(A != C)
report("A == B & B == C & B != C", solver.check())
A == B & B == C & B != C: unsat
A1 = Bool("A.1")
A2 = Bool("A.2")
A3 = Bool("A.3")
B1 = Bool("B.1")
B2 = Bool("B.2")
B3 = Bool("B.3")
C1 = Bool("C.1")
C2 = Bool("C.2")
Asolver = Solver()
solver.add(Or(A1, A2, A3))
A are mutually exclusivesolver.add(Implies(A1, Not(Or(A2, A3))))
solver.add(Implies(A2, Not(Or(A1, A3))))
solver.add(Implies(A3, Not(Or(A1, A2))))
B and Csolver.add(Implies(A3, And(Or(B3, B2), C2)))
solver.add(Implies(A2, And(B2, Or(C2, C1))))
solver.add(Implies(A1, B1))
solver.add(Implies(B3, C2))
solver.add(Implies(B2, C1))
solver.add(Implies(B1, C1))
print("result", solver.check(), solver.model())
result sat [B.3 = True,
A.1 = False,
C.2 = True,
C.1 = False,
B.2 = False,
A.3 = True,
A.2 = False,
B.1 = False]