pacgraph/pacgraph

1157 lines
41 KiB
Python
Executable File

#! /usr/bin/env python3
import io, os, copy, math, codecs, tarfile, optparse, subprocess
from math import cos, sin, atan2, hypot
from random import random, randint
from itertools import *
from collections import deque, defaultdict
tau = math.pi * 2
pj = os.path.join
# depends contains %CONFLICTS%, %DEPENDS%, %OPTDEPENDS%, %PROVIDES%
# desc contains %URL%, %REPLACES%, %LICENSE%,
# %NAME%, %GROUPS%, %BUILDDATE%, %REASON%, %DESC%,
# %SIZE%, %PACKAGER%, %ARCH%, %INSTALLDATE%, %VERSION%
class Node(object):
def __init__(self, **kwargs):
self.name = '' # matches dict key
self.links = set() # of name strings
self.inverse = set() # of name strings
self.size = 0 # bytes, usually
self.font_pt = 0 # scaled from size
self.explicit = False # True if explicitly installed package
self.center = (0,0) # (x,y)
self.color = '#000000'
self.__dict__.update(kwargs)
def __repr__(self):
return repr({'links' : self.links,
'size' : self.size})
# cache the slowest of these?
@property
def all(self):
return self.links | self.inverse
@property
def dim(self):
return pt2dim(self.name, self.font_pt)
@property
def box(self):
return bbox(self.center, self.dim)
@property
def cd(self):
# phase this out
return [self.center, self.dim]
class MockOptions(object):
def __init__(self):
self.opt_deps = False
def unrip(path):
"path -> dict of Nodes"
tree = eval(open(path).read())
return bilink_tree(dict((p,Node(**n)) for p,n in list(tree.items())))
class Arch(object):
def clean(self, n):
n = n.strip()
for c in '><:=':
n = n.partition(c)[0]
return n
def load_info(self, arch_file):
info = defaultdict(list)
mode = None
for line in (self.clean(l) for l in arch_file):
if not line:
continue
if line.startswith('%'):
mode = line
continue
info[mode].append(line)
arch_file.close()
return info
def strip_info(self, info):
keep = set(['DEPENDS', 'OPTDEPENDS', 'PROVIDES', 'SIZE', 'ISIZE', 'REASON'])
info = dict((k.strip('%'),v) for k,v in info.items())
name = info['NAME'][0]
info = dict((k,v) for k,v in info.items() if k in keep)
if 'ISIZE' in info:
info['SIZE'] = info['ISIZE']
if 'SIZE' in info:
info['SIZE'] = int(info['SIZE'][0], 10)
else:
info['SIZE'] = 0
if 'REASON' in info:
info['REASON'] = info['REASON'] == '1'
else:
info['REASON'] = False
return name, info
def load_tarball(self, tgz):
db = tarfile.open(tgz)
packages = defaultdict(dict)
for member in db.getmembers():
if not member.isfile():
continue
path = member.name
#raw = codecs.getreader('utf8')(db.extractfile(member))
raw = io.StringIO(db.extractfile(member).read().decode('utf8'))
name,sub = os.path.split(path)
packages[name][sub] = raw
tree = {}
for p in packages.values():
info = {}
[info.update(self.load_info(v)) for v in list(p.values())]
name,info = self.strip_info(info)
tree[name] = info
return tree
def load_tree(self, dirs):
dirs = set(filter(os.path.isdir, dirs))
packages = set(p for r in dirs for p,d,f in os.walk(r) if f)
tree = {}
for p in packages - dirs:
try:
info = {}
if os.path.isfile(pj(p, 'depends')):
arch_file = open(pj(p,'depends'), 'r')
info.update(self.load_info(arch_file))
arch_file = open(pj(p,'desc'), 'r')
info.update(self.load_info(arch_file))
name, info = self.strip_info(info)
tree[name] = info
except KeyboardInterrupt:
raise
except:
print('Error reading package', p)
return tree
def actually_installed_fn(self, tree):
"use only on load_tree data, returns function"
p_set = set(tree)
errors = {}
p_tree = dict((p,tree[p]['PROVIDES']) for p in tree if 'PROVIDES' in tree[p])
def actually(packages):
installed = set(packages) & p_set
maybe = set(packages) - installed
for pack in maybe:
if pack in errors:
installed.add(errors[pack])
continue
provides = sorted(p for p in p_tree if pack in p_tree[p])
if len(provides) > 1:
print('warning: %s found in %s, assuming %s' % (pack, provides, provides[0]))
errors[pack] = provides[0]
if provides:
installed.add(provides[0])
# len 0 means not installed optdep
return list(installed)
return actually
def merge_tree(self, tree):
"merge provides, depends, optdepends"
options, args = parse()
tree2 = {}
actually_installed = self.actually_installed_fn(tree)
# merge
for p,info in tree.items():
tp = defaultdict(list, info)
deps = tp['DEPENDS']
if options.opt_deps:
deps += tp['OPTDEPENDS']
# remove unused optdeps
deps = actually_installed(deps)
tree2[p] = Node(name=p, size=info['SIZE'], links=set(deps), explicit=info['REASON'])
return tree2
def dbpath(self):
"super hacky"
if not os.path.isfile('/etc/pacman.conf'):
return '/var/lib/pacman/'
for line in open('/etc/pacman.conf'):
line = line.strip()
if not line.startswith('DBPath'):
continue
return line.partition('=')[2].strip()
return '/var/lib/pacman/'
def local_load(self):
dbpath = self.dbpath()
dirs = ['local']
dirs = [pj(dbpath, d) for d in dirs]
tree = compress_chains(bilink_tree(self.merge_tree(self.load_tree(dirs))))
return legal_links(tree)
def repo_load(self):
options, args = parse()
packages = args
dbpath = self.dbpath()
dirs = ['community', 'core', 'extra', 'multilib']
dirs = [pj(dbpath, 'sync', d) for d in dirs]
tars = ['community', 'core', 'extra', 'multilib']
tars = [pj(dbpath, 'sync', '%s.db' % t) for t in tars]
#tars = [pj(dbpath, '%s.db.tar.gz' % t) for t in tars]
tars = [t for t in tars if os.path.isfile(t)]
if tars:
tree = {}
[tree.update(self.load_tarball(t)) for t in tars]
else:
tree = self.load_tree(dirs)
tree = self.merge_tree(tree)
if not packages:
return legal_links(compress_chains(bilink_tree(tree)))
reqs = set()
if options.show_req:
reqs = set(k for k,v in tree.items() if set(packages) & v.links)
deps = set()
for p in set(packages) | reqs:
deps.update(full_deps(p, tree))
tree2 = dict((k,v) for k,v in tree.items() if k in deps | reqs)
return legal_links(bilink_tree(tree2))
class Frugal(Arch):
def dbpath(self):
"super hacky"
if not os.path.isfile('/etc/pacman-g2.conf'):
return '/var/lib/pacman-g2/'
for line in open('/etc/pacman-g2.conf'):
line = line.strip()
if not line.startswith('DBPath'):
continue
return line.partition('=')[2].strip()
return '/var/lib/pacman-g2/'
class Debian(object):
"Expect lots of 'error: unknown' messages. May be due to pseudo-dependencies being filled by 'Provides:' entries."
def load_tree(self, status_path = None):
"New implementation not extensively tested, your mileage may vary"
tree = {}
installed = False
deps = set()
name = ''
size = 0
if status_path is None:
status_path = '/var/lib/dpkg/status'
data = open(status_path, 'r')
for line in data:
if line.startswith('Package:'):
name = line.split(':')[1].strip()
elif line.startswith('Status'):
installed = ('install ok installed' in line) or ('install user installed' in line)
elif line.startswith('Installed-Size:') or line.startswith('Size:'):
size = int(line.split(':')[1].strip(), 10) * 1024
elif line.startswith('Depends:'):
deps = line.partition(':')[2]
deps = [d for e in deps.split(',') for d in e.split('|')]
deps = [d.partition('(')[0].strip() for d in deps]
deps = set(deps) - set('')
elif line == '\n' and installed and name and size:
tree[name] = Node(name = name, links = deps, size = size)
installed = False
deps = set()
name = ''
size = 0
data.close()
return tree
def local_load(self, status_path=None):
tree = legal_links(self.load_tree(status_path))
tree = compress_chains(bilink_tree(tree))
return legal_links(tree)
def repo_load(self):
print('not implemented')
raise
class Redhat(object):
"RPM uses a capability/provider model. load_tree() has room for improvement"
def load_tree(self):
stdout = call_stdout('rpm --query --all --queryformat "%{NAME}<(^_^)>%{SIZE}<(^_^)>[%{REQUIRENAME}, ]<(^_^)>[%{PROVIDENAME}, ]\n"')
tree = {}
requirer_lookup = {}
provider_lookup = {}
for line in stdout:
if line == '':
break
name,nullo,line = line.partition('<(^_^)>')
size,nullo,line = line.partition('<(^_^)>')
size = int(size, 10)
requires,nullo,provides = line.partition('<(^_^)>')
requires = set(capability.strip() for capability in requires.split(',')) - set([''])
for capability in requires:
if capability not in requirer_lookup:
requirer_lookup[capability] = set()
requirer_lookup[capability] |= set([name])
provides = set(capability.strip() for capability in provides.split(',')) - set([''])
for capability in provides:
if capability not in provider_lookup:
provider_lookup[capability] = set()
provider_lookup[capability].add(name)
if name in tree:
tree[name].size += size
else:
tree[name] = Node(name = name, links = set(), size = size)
for capability in set(requirer_lookup) & set(provider_lookup):
for package in requirer_lookup[capability]:
tree[package].links |= provider_lookup[capability]
return tree
def local_load(self):
tree = legal_links(self.load_tree())
tree = compress_chains(bilink_tree(tree))
return legal_links(tree)
def repo_load(self):
print('not implemented')
raise
class Crux(object):
"based entirely on docs, needs to be tested"
def find_all(self, package):
raw = call_stdout('pkginfo -i')
for line in raw.split('\n'):
yield line.split(' ')[0]
def find_size(self, package):
return call_stdout('pkgsize %s' % package)
def find_deps(self, package):
return call_stdout('finddeps %s' % package)
def local_load(self):
"unfinished"
tree = {}
all_packages = []
for name in all_packages:
size = self.find_size(name)
deps = self.find_deps(name)
tree[name] = Node(name=name, size=size, links=set(deps))
return tree
class Gentoo(object):
def get_name(self, pkg):
name = pkg.strip()
name = pkg.partition('/')[2]
ver = name.rfind('-')
if name[ver+1] == 'r':
name = name[:name.rfind('-', 0, ver)]
else:
name = name[:ver]
return name
def load_tree(self):
print("Getting packages size")
tree = {}
out = call_stdout('qsize --bytes --nocolor --all')
for line in out:
if line == '':
continue
words = line.split()
name = self.get_name(words[0])
size = words[5]
tree[name] = Node(name=name, size=int(size), link=set())
print("Getting dependencies")
out = call_stdout('emerge --pretend --verbose --depclean')
for line in out:
if line[:4] == ' ':
if line[4] == '@':
if line[4:] == '@selected':
tree[name].explicit = True
else:
name = self.get_name(line)
tree[name].links.add(dep)
elif line[:2] == ' ':
dep = self.get_name(line)
elif line[:3] == '>>>':
break
return tree
def local_load(self):
tree = legal_links(self.load_tree())
tree = compress_chains(bilink_tree(tree))
return legal_links(tree)
def repo_load(self):
print('not implemented')
raise
class Textfile(object):
""" VERY ALPHA, frequent to change!
loads a space delineated text file
file should be named "data", each line looks like
node_name size link1 link2 link3 ..."""
def load_tree(self):
tree = {}
filename = 'data'
for line in open(filename):
words = line.split()
node = words[0]
size = int(words[1])
links = words[2:]
tree[node] = Node(name=node, size=size, links=set(links))
return tree
def local_load(self):
tree = legal_links(self.load_tree())
tree = compress_chains(bilink_tree(tree))
return legal_links(tree)
class Rtree(object):
"Why? Pyrtree is insert-then-query (but mine has no delete), Rtree has crazy C deps."
def __init__(self, parent=None, box=None, name=None):
"Only root has no parent, only leaves have names."
# Half an R-tree. Not height balanced! Missing delete().
bigm = 5
self.parent = parent
self.name = name
self.bigm = bigm
self.lilm = bigm//2
self.children = []
self.box = (0,0,0,0)
self.last_hit = None
if box:
self.box = tuple(box)
def __lt__(self, other):
return True # haaaack
def show(self, depth=0):
head = ' ' * depth + str(self.box) + ' '
if self.name:
return head + self.name + '\n'
head += str(len(self.children))
return head + '\n' + ''.join(c.show(depth+1) for c in self.children)
def root(self):
if self.parent is None:
return self
return self.parent.root()
def best_node(self, to_add):
"smallest place for box to_add, does not change anything"
return smallest_merge(self.children, to_add)
def search(self, box=None):
"yields matching leaf objects, no box for all leaves"
todo = [self]
while todo:
node = todo.pop()
if box and not in_box(node.box, box):
continue
if node.name:
self.last_hit = node
yield node
todo.extend(node.children)
def search_cache(self, box):
"assumes consecutive searches will be near each other"
todo = [self.root()]
if self.last_hit:
todo.append(self.last_hit.parent)
todo.append(self.last_hit)
#while todo[0].parent:
# todo.insert(0, todo[0].parent)
while todo:
node = todo.pop()
if not in_box(node.box, box):
continue
if node.name:
self.last_hit = node
yield node
todo.extend(node.children)
def insert(self, box, name=None):
"makes a new node"
target = self.root().choose_leaf(box)
target.children.append(Rtree(target, box, name))
target.merge_up(box)
if len(target.children) > target.bigm:
target.divide_children()
def merge_up(self, box):
# only grows
new_box = merge(self.box, box)
if new_box == self.box:
return
self.box = new_box
if self.parent:
self.parent.merge_up(self.box)
def is_leaf_node(self):
return not self.children or bool(self.children[0].name)
def choose_leaf(self, box):
n = self
while not n.is_leaf_node():
n = n.best_node(box)
return n
def adjust(self):
if len(self.children) > self.bigm:
self.overflow()
if self.parent and len(self.children) < self.lilm:
self.underflow()
def divide_children(self):
children2 = [c for c in self.children]
self.children = []
while children2:
c1 = children2.pop()
newp = Rtree(self, c1.box)
newp.children.append(c1)
c1.parent = newp
self.children.append(newp)
if not children2:
break
c2 = smallest_merge(children2, newp.box)
newp.children.append(c2)
children2.remove(c2)
c2.parent = newp
newp.box = merge(c1.box, c2.box)
self.box = merge(*(c.box for c in self.children))
self.merge_up(self.box)
# Leaves are too small, but grow soon. Probably.
def underflow(self):
"Not implemented"
raise
def delete(self, box):
"Not implemented."
raise
def unbalance(self):
"histogram of leaf depths"
hist = dict((i,0) for i in range(100))
stack = [[self]]
depth = 0
while stack:
if not stack[-1]:
depth -= 1
stack.pop()
continue
c = stack[-1].pop()
assert bool(c.name) ^ bool(c.children)
if c.name:
hist[depth] += 1
else:
stack.append(c.children)
depth += 1
return dict((k,v) for k,v in hist.items() if v)
def leafiness(self):
"histogram of branch size"
hist = dict((i,0) for i in range(100))
stack = [self]
while stack:
c = stack.pop()
if c.name:
continue
hist[len(c.children)] += 1
stack.extend(c.children)
return dict((k,v) for k,v in hist.items() if v)
def merge(*boxes):
"new box surrounding inputs"
fns = (min, min, max, max)
return tuple(f(p) for f,p in zip(fns, list(zip(*boxes))))
def area(box):
return box[2]-box[0] * box[3]-box[1]
def smallest_merge(nodes, box):
"return best node from nodes"
return min((area(merge(n.box, box)), area(n.box), n) for n in nodes)[2]
def full_deps(package, tree, reverse=False):
"returns every package in dep tree"
deps = set()
to_crawl = deque([package])
while to_crawl:
current = to_crawl.popleft()
if current in deps:
continue
deps.add(current)
if not reverse:
current_deps = tree[current].links
else:
current_deps = tree[current].inverse
to_crawl.extend(current_deps - deps)
return list(deps)
def bilink_tree(tree):
"adds inverse from links"
for p in tree:
deps = tree[p].links
[tree[d].inverse.add(p) for d in deps]
return tree
def flatten(list_of_lists):
return list(chain(*list_of_lists))
def single_depends(tree, preserve_explicit=False):
if preserve_explicit:
return (p for p in tree if len(tree[p].inverse) == 1 and not tree[p].explicit)
else:
return (p for p in tree if len(tree[p].inverse) == 1)
def compress_chains(tree):
"single depends are absorbed into parent"
options, args = parse()
if not options.use_compression:
return tree
while True:
singles = single_depends(tree, options.preserve_explicit)
try:
s = next(singles)
except StopIteration:
return tree
parent = list(tree[s].inverse)[0]
if s == parent:
#print 'self loop', s
tree[s].links.remove(s)
tree[s].inverse.remove(s)
continue
#print 'merge', s, 'into', parent
tree[parent].size += tree[s].size
#tree[parent].name += ' ' + tree[s].name
for dep in tree[s].links:
tree[dep].inverse.remove(s)
tree[dep].inverse.add(parent)
tree[parent].links.update(tree[s].links)
tree[parent].links.remove(s)
tree.pop(s)
def sum_sizes(packages, tree):
return sum(tree[p].size for p in packages if p in tree)
def shared_size(package, tree):
"package and all deps"
return sum_sizes(full_deps(package, tree), tree)
def biggest_packs(tree):
packs = [(shared_size(p, tree), p) for p in tree]
return [p for s,p in sorted(packs, reverse=True)]
def toplevel_packs(tree):
return set(p for p in tree if not tree[p].inverse)
def packs_by_size(tree, pack_list):
by_sizes = [(tree[n].size, n) for n in pack_list]
return sorted(by_sizes, reverse=True)
def legal_links(tree):
"removes/reports dangling references"
valid = set(tree)
for k,v in tree.items():
invalid1 = v.links - valid
invalid2 = v.inverse - valid
if invalid1:
print('error: unknown', list(invalid1), 'in', k)
v.links = v.links - invalid1
if invalid2:
print('error: unknown', list(invalid2), 'in', k)
v.inverse = v.inverse - invalid2
return tree
#print('worst shared packages:', biggest_packs(tree)[:20])
#print('most crucial packages:', biggest_packs(invert_tree(tree))[:20])
def pt_sizes(tree, min_pt=10, max_pt=100, by_area=False):
"size in bytes -> size in points"
norm = lambda n: n.size
if by_area:
norm = lambda n: n.size / len(n.name)
sizes = [norm(node) for p,node in tree.items()]
min_s,max_s = min(sizes), max(sizes)
convert = lambda s: int((max_pt-min_pt)*(s-min_s)/(max_s-min_s) + min_pt)
for p, node in tree.items():
tree[p].font_pt = convert(norm(node))
return tree
def prioritized(packs):
"iter of names, sorted by priority"
# first are the most central, with lots of links
stats = [(len(node.all), k) for k,node in packs.items()]
stats = [n for l,n in sorted(stats, reverse=True)]
# but slip in anyone who's deps are met early
plotted = set()
for n in (n for n in stats if n not in plotted):
yield n
plotted.add(n)
deps_met = set(k for k,node in packs.items() if node.all <= plotted)
for n2 in deps_met - plotted:
yield n2
plotted.update(deps_met)
def ran_rad():
return random() * tau
def bbox(center, dim):
c,d = center,dim
cx,cy = center
dx,dy = dim
x1,x2 = cx - dx//2, cx + dx//2
y1,y2 = cy - dy , cy + dy//3
return [x1, y1, x2, y2]
def in_box(bboxA, bboxB):
a1x,a2y,a3x,a4y = bboxA
b1x,b2y,b3x,b4y = bboxB
return (a3x > b1x if a1x<b1x else b3x>a1x) and (a4y > b2y if a2y<b2y else b4y>a2y)
def all_bboxes(packs):
return [packs[name].box for name in packs]
def normalize(point, origin):
"weighted"
dx,dy = point[0]-origin[0], point[1]-origin[1]
length_sq = dx**2 + dy**2
return dx/length_sq, dy/length_sq
def link_pull(name, origin_n, packs):
"average of angles of links"
origin = packs[origin_n].center
norm_ps = lambda ps: [normalize(c, origin) for c in ps if c not in [(0,0), origin]]
rot90 = lambda x,y: (-y,x) if randint(0,1) else (y,-x)
good_links = packs[name].all
skew_links = set(packs.keys()) - good_links
g_centers = norm_ps(packs[l].center for l in good_links)
s_centers = norm_ps(packs[l].center for l in skew_links)
s_centers = [rot90(*xy) for xy in s_centers]
centers = g_centers + s_centers
if not centers:
# new branch, try to avoid existing branches
centers = norm_ps(packs[l].center for l in list(packs.keys()))
if not centers:
return (0,0)
centers = [(-x,-y) for x,y in centers]
return list(map(sum, list(zip(*centers))))
def xy2rad(x, y):
if (x,y) == (0,0):
return ran_rad()
return math.atan2(y,x)
def pol2xy(o, a, r):
return int(o[0] + r * cos(a)), int(o[1] + r * sin(a))
def pt2dim(name, pt):
x_scale = 0.65
y_scale = 0.85
return int(len(name)*pt*x_scale), int(pt*y_scale)
def best_origin(name, pri_hist, packs):
"returns largest sibling, or root"
possible = [n for n in pri_hist if n in packs[name].all]
if not possible:
return pri_hist[0] # root package
return max((packs[n].size, n) for n in possible)[1]
# add search_hyperbola and means of switching
def search(cd, origin, heading, scale, rt):
"linear binary search recursive closure thingy, returns radius"
history = []
def probe(r):
"returns true if clear"
cd[0] = pol2xy(origin, heading, r)
history.append(cd[0])
bb1 = bbox(*cd)
#return not any(rt.search_cache(bb1))
return not any(rt.search(bb1))
def search3(step, r):
s2 = step // 2
if probe(r - s2):
if step < scale:
return r-s2
else:
return search3(s2, r-s2)
if probe(r + s2):
step = s2
else:
step *= 2
return search3(step, r + step)
return search3(scale, scale), history
def search_spiral(cd, origin, scale, rt, step=5, aspect=0.5):
"returns angle, radius, history"
# step controls speed/granularity
# really should be a logarithmic spiral
# spiral aspect -> final aspect... holy grail?
history = []
def probe(heading, r):
"returns true if clear"
cd[0] = pol2xy(origin, heading, r)
history.append(cd[0])
bb1 = bbox(*cd)
return not any(rt.search(bb1))
angle = ran_rad()
r = scale
while not probe(angle, r):
x = cos(angle) * r
y = sin(angle) * r
m = angle + tau/4
x += cos(m) * scale * step / aspect
y += sin(m) * scale * step
angle = xy2rad(x, y)
r = hypot(x, y)
return angle, r, history
def place(packs, detail=False):
"radial placement algo, returns non-overlapping coords"
# maybe try two different link_pulls for each placement?
# maybe some control inversion to avoid queue checks?
pri_cache = []
def pri():
for p in prioritized(packs):
pri_cache.append(p)
yield p
prii = pri()
rt = Rtree()
p = next(prii)
rt.insert(bbox((0,0), packs[p].dim), p)
if detail:
yield pri_cache[0], [(0,0)]
else:
yield pri_cache[0], (0,0)
for name in prii:
node = packs[name]
origin_name = best_origin(name, pri_cache, packs)
#print('placing', name, 'around', origin_name)
origin = packs[origin_name].center
# unplaced links and big text need more room
scale = len(node.all-set(pri_cache))+1
scale = max(scale, node.dim[1])
#heading = xy2rad(*link_pull(name, origin_name, packs))
#r,history = search(node.cd, origin, heading, scale, rt)
heading,r,history = search_spiral(node.cd, origin, scale, rt)
center = pol2xy(origin, heading, r)
rt.insert(bbox(center, packs[name].dim), name)
if detail:
yield name, history
else:
yield name, center
#print(rt.show())
#print(copy.deepcopy(rt).unbalance())
#print(copy.deepcopy(rt).leafiness())
def offset_coord(c, d):
"corrects textbox origin"
return c[0]-d[0]//2, c[1] #+d[1]//2
def xml_wrap(tag, inner=None, **kwargs):
kw = ' '.join('%s="%s"' % (str(k), str(v)) for k,v in kwargs.items())
if inner is None:
return '<%s %s/>' % (tag, kw)
return '<%s %s>%s</%s>' % (tag, kw, inner, tag)
def control_point(p1, p2, drop=None):
dx = abs(p2[0] - p1[0])
lower = (p1,p2)[p1[1]<p2[1]]
higher = (p2,p1)[p1[1]<p2[1]]
if drop is None:
drop = 0.5 + 0.5 * random()
return (lower[0]+higher[0])//2, lower[1]+dx*drop//2
def quad_spline(p1, p2):
"boofor DSL in XML"
c = control_point(p1, p2)
return 'M%i,%i Q%i,%i %i,%i' % (p1 + c + p2)
def svg_spline(point1, point2):
return xml_wrap('path', None, d=quad_spline(point1, point2))
def arc_path(p1, p2):
"boofor DSL in XML"
# http://paulbourke.net/geometry/circlefrom3/
return 'M%i,%i A%i,%i %i,%i' % (p1 + c + p2)
def svg_arc(point1, point2):
return xml_wrap('path', None, d=arc_path(point1, point2))
def svg_text(node):
x,y = offset_coord(node.center, node.dim)
kw = {'font-size': node.font_pt}
return xml_wrap('text', node.name, x=x, y=y, fill=node.color, **kw)
def all_points(packs):
"slightly incomplete, guestimates the splines"
all_bb = flatten((bb[:2],bb[2:]) for bb in all_bboxes(packs))
all_endpoints = [(packs[p].center, packs[l].center) for p in packs for l in packs[p].all]
all_controls = [control_point(ep[0], ep[1], 0.5) for ep in all_endpoints]
return all_bb + all_controls
def recenter(packs, points):
"shift everything into quadrant 1"
min_x,min_y = list(map(min, list(zip(*points))))
for node in packs.values():
x,y = node.center
node.center = x-min_x, y-min_y
return packs
def window_size(points):
xs,ys = list(zip(*points))
return max(xs)-min(xs), max(ys)-min(ys)
def all_links(packs):
paths = []
for pack in packs:
links = packs[pack].all
p1 = packs[pack].center
paths.extend((p1,packs[l].center) for l in links if l<pack)
return paths
def svgify(packs):
options, args = parse()
toplevel = toplevel_packs(packs)
bottomlevel = set(packs) - toplevel
needs, needed_by = [], []
if args and ((options.mode != 'arch-repo') ^ options.show_req):
needs = flatten(full_deps(p, packs, reverse=False) for p in args)
needed_by = flatten(full_deps(p, packs, reverse=True) for p in args)
all_ps = all_points(packs)
packs = recenter(packs, all_ps)
width,height = window_size(all_ps)
for pack,node in packs.items():
# replace with a lookup structure of some sort
# { test_fn(): 'color', ... }? [ (pack_set, 'color'), ... ]?
# also, order sensitive
if pack in bottomlevel:
node.color = options.dependency
if pack in toplevel:
node.color = options.toplevel
if pack in needs:
node.color = options.highlight[1]
if pack in needed_by:
node.color = options.highlight[2]
if pack in args and ((options.mode != 'arch-repo') ^ options.show_req):
node.color = options.highlight[0]
texts = [svg_text(packs[p]) for p in packs]
paths = [svg_spline(*line) for line in all_links(packs)]
svg = xml_wrap('rect', None, x=0, y=0, width=width, height=height, style='fill:%s;' % options.background)
svg += xml_wrap('g', '\n'.join(paths), style='stroke:%s; stroke-opacity:0.15; fill:none;' % options.link)
svg += xml_wrap('g', '\n'.join(texts), **{'font-family':'Monospace'})
svg = xml_wrap('svg', svg, width=width, height=height, xmlns='http://www.w3.org/2000/svg')
svg = '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/SVG/DTD/svg10.dtd">\n' + svg
svg = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + svg
filename = os.path.expanduser(options.filename)
open(filename + '.svg', 'w').write(svg)
def call_status(cmd):
"returns exit status"
spp = subprocess.PIPE
return subprocess.Popen(cmd, shell=True, stdout=spp, stderr=spp).wait()
def call_stdout(cmd):
"returns stdout"
subp = subprocess.Popen(cmd, shell=True, stdin=None, stdout=subprocess.PIPE)
return subp.communicate()[0].decode().split('\n')
def parse():
default_action = 'autodetect'
parser = optparse.OptionParser(description='Produces two files, pacgraph.svg and pacgraph.png. Colors should be entered as hex values like "#ffffff". SVG named colors may also work, see http://en.wikipedia.org/wiki/Web_colors . Packages listed in the args are highlighted.')
parser.add_option('-s', '--svg', dest='svg_only', action='store_true', default=False,
help='Produce the SVG but do not attempt to rasterize it.')
parser.add_option('-o', '--opt-deps', dest='opt_deps', action='store_true', default=False,
help='Include optional dependencies. May produce a less compact graph.')
parser.add_option('-e', '--explicits', dest='preserve_explicit', action='store_true', default=False,
help='Preserve explicitly installed applications from dependency compression. May produce a less compact graph.')
parser.add_option('-c', '--console', dest='console', action='store_true', default=False,
help='Print summary to console, does not draw a graph. Good for very slow computers.')
parser.add_option('-r', '--rip', dest='rip', action='store_true', default=False,
help='Rips a copy of your installed packages to pacgraph.txt. Good for debugging.')
parser.add_option('-f', '--file', dest='filename', default='pacgraph',
help='Override default filename/location. Do not specify an extension.')
parser.add_option('--disable-palette', dest='indexed', action='store_false', default=True,
help='Disables lossy palette compression.')
colors = optparse.OptionGroup(parser, "Theming Options")
colors.add_option('-b', '--background', dest='background', metavar='COLOR', default='#ffffff',
help='Background color.')
colors.add_option('-l', '--link', dest='link', metavar='COLOR', default='#606060',
help='Color of links between packages.')
colors.add_option('-t', '--top', dest='toplevel', metavar='COLOR', default='#0000ff',
help='Color of packages which are not dependencies.')
colors.add_option('-d', '--dep', dest='dependency', metavar='COLOR', default='#6a6aa2',
help='Color of packages which are dependencies.')
colors.add_option('-i', '--highlight', dest='highlight', metavar='COLOR COLOR COLOR', nargs=3,
default=('#00cc00', '#ff0000', '#700060'),
help='Color of selected package, selected dependencies, selected needed-by.')
colors.add_option('-p', '--point', dest='point_size', metavar='INT INT', type='int', nargs=2, default=(10,100),
help='Takes two integers, for the smallest and largest font size. Default is -p 10 100.')
parser.add_option_group(colors)
beta = optparse.OptionGroup(parser, "Experimental Options")
beta.add_option('-m', '--mode', dest='mode', default=default_action,
help='Curently supported modes are arch, arch-repo, debian, redhat, ipkg, gentoo, frugalware and frugal-repo. Default is %s.' % default_action)
beta.add_option('-n', '--no-compression', dest='use_compression', action='store_false', default=True,
help='Disable all chain compression.')
beta.add_option('--shared', dest='shared', action='store_true', default=False,
help='Compare shared libraries.')
beta.add_option('--show-req-by', dest='show_req', action='store_true', default=False,
help='Includes required-by of specified packages. Only works for arch-repo.')
beta.add_option('--by-area', dest='by_area', action='store_true', default=False,
help='Represent sizes by the area of the name, not the pt of the name.')
parser.add_option_group(beta)
options, args = parser.parse_args()
return options, args
def human_si(number):
powers = [(2**10,''), (2**20,'k'), (2**30,'M')]
limit,si = ([p for p in powers if p[0] > number] + powers[-1:])[0]
n2 = str(round(number / (limit//2**10)))
if si == 'M':
n2 = '%0.1f' % round((number / float(limit//2**10)), 1)
return n2 + ' ' + si +'B'
def console_dump(tree):
options, args = parse()
stat_line = human_si(sum(node.size for node in tree.values()))
print('Total size:', stat_line)
if options.use_compression:
tops = toplevel_packs(tree)
else:
tops = list(tree.keys())
if len(tops) == 1:
tops = list(tree.keys())
for s,n in packs_by_size(tree, tops):
print(human_si(s), n)
return
def secondlevel_sizes(tree):
# this is slightly lazy and only handles the common case
# it ignores oddly shaped dep trees
tops = toplevel_packs(tree)
seconds = set()
[seconds.update(tree[t].links) for t in tops]
sec_info = []
for s in seconds:
aboves = tree[s].inverse & tops # lazy!
size = tree[s].size + sum(tree[i].size for i in aboves)
sec_info.append((size/len(aboves), s, size, aboves))
sec_info.sort(reverse=True)
for null, s, size, aboves in sec_info:
print('%s: %s ' % (s, human_si(size)))
print('\t', ' '.join(sorted(aboves)))
def exists(app):
return call_status('which %s' % app) == 0
def distro_detect():
# todo, replace this with an /etc/issue scanner
if exists('pacman'):
print ('Autodetected Arch.')
return 'arch'
if exists('ipkg'):
print ('Autodetected ipkg.')
return 'ipkg'
if any(map(exists, 'dpkg apt-get aptitude'.split())):
print ('Autodetected deb.')
return 'debian'
if exists('rpm'):
print ('Autodetected rpm.')
return 'redhat'
if exists('poldek'):
print ('Autodetected PLD.')
return 'redhat'
if exists('emerge'):
print ('Autodetected gentoo.')
return 'gentoo'
return None
def distro_detect2():
"prototype /etc scanner"
is_r = lambda f: f.endswith('release') or f.endswith('version')
releases = [f for f in next(os.walk('/etc/'))[2] if is_r(f)]
releases = [f.partition('_')[0] for f in releases]
releases = [f.partition('-')[0] for f in releases]
if 'arch' in releases:
print ('Autodetected Arch')
return 'arch'
if 'redhat' in releases or 'fedora' in releases:
print ('Autodetected rpm')
return 'redhat'
if 'debian' in releases:
print ('Autodetected deb')
return 'debian'
if 'gentoo' in releases:
print ('Autodetected gentoo')
return 'gentoo'
print ('Warning: Could not autodetect from /etc')
return None
def main():
arch = Arch()
debian = Debian()
redhat = Redhat()
gentoo = Gentoo()
frugal = Frugal()
textfile = Textfile()
options, args = parse()
filename = os.path.expanduser(options.filename)
tree = None
if options.mode == 'autodetect':
options.mode = distro_detect2()
if not options.mode:
print ('Trying fallback package manager detection')
options.mode = distro_detect()
print('Loading package info')
if options.mode == 'arch':
tree = arch.local_load()
if options.mode == 'arch-repo':
tree = arch.repo_load()
if options.mode == 'debian':
tree = debian.local_load()
if options.mode == 'redhat':
tree = redhat.local_load()
if options.mode == 'gentoo':
tree = gentoo.local_load()
if options.mode == 'frugalware':
tree = frugal.local_load()
if options.mode == 'frugal-repo':
tree = frugal.repo_load()
if options.mode == 'textfile':
tree = textfile.local_load()
if options.mode == 'ipkg':
tree = debian.local_load(status_path='/usr/lib/ipkg/status')
# ipkg reports bytes, not KB
for node in tree.values():
node.size /= 1024
if tree is None:
print('Could not load packages. Manually specify --mode.')
return
stat_line = human_si(sum(v.size for k,v in list(tree.items())))
if options.rip:
open(filename + '.txt', 'w').write(repr(tree))
return
if options.console:
console_dump(tree)
return
if options.shared:
secondlevel_sizes(tree)
return
print('Placing %i nodes' % len(tree))
tree = pt_sizes(tree, *options.point_size)
for n,c in place(tree):
tree[n].center = c
tree = recenter(tree, all_points(tree))
tree[stat_line] = Node(name=stat_line, font_pt=sum(options.point_size)//2)
print('Saving SVG')
svgify(tree)
if options.svg_only:
return
print('Rendering PNG')
png_conversion = ['inkscape -D -e %(fn)s.png %(fn)s.svg',
'svg2png %(fn)s.svg %(fn)s.png',
'convert %(fn)s.svg %(fn)s.png']
for cmd_line in png_conversion:
app = cmd_line.split()[0]
if not exists(app):
continue
call_status(cmd_line % {'fn':filename})
if options.indexed and exists('mogrify'):
print('Indexing PNG')
call_status('mogrify -quality 100 -type optimize -flatten +matte -colors 127 %s.png' % filename)
return # after first success
print('No way to convert SVG to PNG.')
print('Inkscape, svg2png or imagemagick would be nice.')
print('Alternatives: "pacgraph-tk" or "pacgraph --console".')
print()
console_dump(tree)
if __name__ == "__main__":
main()
#import cProfile; cProfile.run("main()", sort=1)