#!/usr/bin/python2
# Copyright 2003-2005 Gentoo Foundation
# Distributed under the terms of the GNU General Public License v2
# $Header: $

from __future__ import with_statement

###############################################################################
# Meta:
__author__ = "Thomas de Grenier de Latour (tgl)"
__email__ = "degrenier@easyconnect.fr"
__version__ = "0.4.1"
__productname__ = "eclean"
__description__ = "A cleaning tool for Gentoo distfiles and binaries."


###############################################################################
# Python imports:

import sys
import os, stat
import re
import time
import getopt
import fpformat
import signal
try:
	import portage
except ImportError:
	sys.path.insert(0, "/usr/lib/portage/pym")
	import portage
try:
	from portage.output import *
except ImportError:
	from output import *

listdir = portage.listdir

###############################################################################
# Misc. shortcuts to some portage stuff:
port_settings = portage.settings
distdir = port_settings["DISTDIR"]
pkgdir = port_settings["PKGDIR"]

###############################################################################
# printVersion:
def printVersion():
	print "%s (version %s) - %s" \
			% (__productname__, __version__, __description__)
	print "Author: %s <%s>" % (__author__,__email__)
	print "Copyright 2003-2005 Gentoo Foundation"
	print "Distributed under the terms of the GNU General Public License v2"


###############################################################################
# printUsage: print help message. May also print partial help to stderr if an
# error from {'options','actions'} is specified.
def printUsage(error=None,help=None):
	out = sys.stdout
	if error: out = sys.stderr
	if not error in ('actions', 'global-options', \
			'packages-options', 'distfiles-options', \
			'merged-packages-options', 'merged-distfiles-options', \
			'time', 'size'):
		error = None
	if not error and not help: help = 'all'
	if error == 'time':
		eerror("Wrong time specification")
		print >>out, "Time specification should be an integer followed by a"+ \
				" single letter unit."
		print >>out, "Available units are: y (years), m (months), w (weeks), "+ \
				"d (days) and h (hours)."
		print >>out, "For instance: \"1y\" is \"one year\", \"2w\" is \"two"+ \
				" weeks\", etc. "
		return
	if error == 'size':
		eerror("Wrong size specification")
		print >>out, "Size specification should be an integer followed by a"+ \
				" single letter unit."
		print >>out, "Available units are: G, M, K and B."
		print >>out, "For instance: \"10M\" is \"ten megabytes\", \"200K\" "+ \
				"is \"two hundreds kilobytes\", etc."
		return
	if error in ('global-options', 'packages-options', 'distfiles-options', \
			'merged-packages-options', 'merged-distfiles-options',):
		eerror("Wrong option on command line.")
		print >>out
	elif error == 'actions':
		eerror("Wrong or missing action name on command line.")
		print >>out
	print >>out, white("Usage:")
	if error in ('actions','global-options', 'packages-options', \
	'distfiles-options') or help == 'all':
		print >>out, " "+turquoise(__productname__), \
			yellow("[global-option] ..."), \
			green("<action>"), \
			yellow("[action-option] ...")
	if error == 'merged-distfiles-options' or help in ('all','distfiles'):
		print >>out, " "+turquoise(__productname__+'-dist'), \
			yellow("[global-option, distfiles-option] ...")
	if error == 'merged-packages-options' or help in ('all','packages'):
		print >>out, " "+turquoise(__productname__+'-pkg'), \
			yellow("[global-option, packages-option] ...")
	if error in ('global-options', 'actions'):
		print >>out, " "+turquoise(__productname__), \
			yellow("[--help, --version]")
	if help == 'all':
		print >>out, " "+turquoise(__productname__+"(-dist,-pkg)"), \
			yellow("[--help, --version]")
	if error == 'merged-packages-options' or help == 'packages':
		print >>out, " "+turquoise(__productname__+'-pkg'), \
			yellow("[--help, --version]")
	if error == 'merged-distfiles-options' or help == 'distfiles':
		print >>out, " "+turquoise(__productname__+'-dist'), \
			yellow("[--help, --version]")
	print >>out
	if error in ('global-options', 'merged-packages-options', \
	'merged-distfiles-options') or help:
		print >>out, "Available global", yellow("options")+":"
		print >>out, yellow(" -C, --nocolor")+ \
			"             - turn off colors on output"
		print >>out, yellow(" -d, --destructive")+ \
			"         - only keep the minimum for a reinstallation"
		print >>out, yellow(" -e, --exclude-file=<path>")+ \
			" - path to the exclusion file"
		print >>out, yellow(" -i, --interactive")+ \
			"         - ask confirmation before deletions"
		print >>out, yellow(" -n, --package-names")+ \
			"       - protect all versions (when --destructive)"
		print >>out, yellow(" -p, --pretend")+ \
			"             - only display what would be cleaned"
		print >>out, yellow(" -q, --quiet")+ \
			"               - be as quiet as possible"
		print >>out, yellow(" -t, --time-limit=<time>")+ \
			"   - don't delete files modified since "+yellow("<time>")
		print >>out, "   "+yellow("<time>"), "is a duration: \"1y\" is"+ \
				" \"one year\", \"2w\" is \"two weeks\", etc. "
		print >>out, "   "+"Units are: y (years), m (months), w (weeks), "+ \
				"d (days) and h (hours)."
		print >>out, yellow(" -h, --help")+ \
			"                - display the help screen"
		print >>out, yellow(" -V, --version")+ \
			"             - display version info"
		print >>out
	if error == 'actions' or help == 'all':
		print >>out, "Available", green("actions")+":"
		print >>out, green(" packages")+ \
			"      - clean outdated binary packages from:"
		print >>out, "                  ",teal(pkgdir)
		print >>out, green(" distfiles")+ \
			"     - clean outdated packages sources files from:"
		print >>out, "                  ",teal(distdir)
		print >>out
	if error in ('packages-options','merged-packages-options') \
	or help in ('all','packages'):
		print >>out, "Available", yellow("options"),"for the", \
				green("packages"),"action:"
		print >>out, yellow(" NONE  :)")
		print >>out
	if error in ('distfiles-options', 'merged-distfiles-options') \
	or help in ('all','distfiles'):
		print >>out, "Available", yellow("options"),"for the", \
				green("distfiles"),"action:"
		print >>out, yellow(" -f, --fetch-restricted")+ \
			"   - protect fetch-restricted files (when --destructive)"
		print >>out, yellow(" -s, --size-limit=<size>")+ \
			"  - don't delete distfiles bigger than "+yellow("<size>")
		print >>out, "   "+yellow("<size>"), "is a size specification: "+ \
				"\"10M\" is \"ten megabytes\", \"200K\" is"
		print >>out, "   "+"\"two hundreds kilobytes\", etc.  Units are: "+ \
				"G, M, K and B."
		print >>out
	print >>out, "More detailed instruction can be found in", \
			turquoise("`man %s`" % __productname__)


###############################################################################
# einfo: display an info message depending on a color mode
def einfo(message="", nocolor=False):
	if not nocolor: prefix = " "+green('*')
	else: prefix = ">>>"
	print prefix,message


###############################################################################
# eerror: display an error depending on a color mode
def eerror(message="", nocolor=False):
	if not nocolor: prefix = " "+red('*')
	else: prefix = "!!!"
	print >>sys.stderr,prefix,message


###############################################################################
# eprompt: display a user question depending on a color mode.
def eprompt(message, nocolor=False):
	if not nocolor: prefix = " "+red('>')+" "
	else: prefix = "??? "
	sys.stdout.write(prefix+message)
	sys.stdout.flush()


###############################################################################
# prettySize: integer -> byte/kilo/mega/giga converter. Optionnally justify the
# result. Output is a string.
def prettySize(size,justify=False):
	units = [" G"," M"," K"," B"]
	approx = 0
	while len(units) and size >= 1000:
		approx = 1
		size = size / 1024.
		units.pop()
	sizestr = fpformat.fix(size,approx)+units[-1]
	if justify: 
		sizestr = " " + blue("[ ") + " "*(7-len(sizestr)) \
		          + green(sizestr) + blue(" ]")
	return sizestr


###############################################################################
# yesNoAllPrompt: print a prompt until user answer in yes/no/all. Return a 
# boolean for answer, and also may affect the 'accept_all' option.
# Note: i gave up with getch-like functions, to much bugs in case of escape
# sequences. Back to raw_input.
def yesNoAllPrompt(myoptions,message="Do you want to proceed?"):
	user_string="xxx"
	while not user_string.lower() in ["","y","n","a","yes","no","all"]:
		eprompt(message+" [Y/n/a]: ", myoptions['nocolor'])
		user_string = raw_input()
	if user_string.lower() in ["a","all"]:
		myoptions['accept_all'] = True
	myanswer = user_string.lower() in ["","y","a","yes","all"]
	return myanswer


###############################################################################
# ParseArgsException: for parseArgs() -> main() communication
class ParseArgsException(Exception):
	def __init__(self, value):
		self.value = value
	def __str__(self):
		return repr(self.value)


###############################################################################
# parseSize: convert a file size "Xu" ("X" is an integer, and "u" in [G,M,K,B])
# into an integer (file size in Bytes). Raises ParseArgsException('size') in
# case of failure.
def parseSize(size):
	myunits = { \
		'G': (1024**3), \
		'M': (1024**2), \
		'K': 1024, \
		'B': 1 \
	}
	try:
		mymatch = re.match(r"^(?P<value>\d+)(?P<unit>[GMKBgmkb])?$",size)
		mysize = int(mymatch.group('value'))
		if mymatch.group('unit'):
			mysize *= myunits[mymatch.group('unit').capitalize()]
	except:
		raise ParseArgsException('size')
	return mysize


###############################################################################
# parseTime: convert a duration "Xu" ("X" is an int, and "u" a time unit in 
# [Y,M,W,D,H]) into an integer which is a past EPOCH date. 
# Raises ParseArgsException('time') in case of failure.
# (yep, big approximations inside... who cares?)
def parseTime(timespec):
	myunits = {'H' : (60 * 60)}
	myunits['D'] = myunits['H'] * 24
	myunits['W'] = myunits['D'] * 7
	myunits['M'] = myunits['D'] * 30
	myunits['Y'] = myunits['D'] * 365
	try:
		# parse the time specification
		mymatch = re.match(r"^(?P<value>\d+)(?P<unit>[YMWDHymwdh])?$",timespec)
		myvalue = int(mymatch.group('value'))
		if not mymatch.group('unit'): myunit = 'D'
		else: myunit = mymatch.group('unit').capitalize()
	except: raise ParseArgsException('time')
	# calculate the limit EPOCH date
	mytime = time.time() - (myvalue * myunits[myunit])
	return mytime


###############################################################################
# parseCmdLine: parse the command line arguments. Raise exceptions on errors or
# non-action modes (help/version). Returns an action, and affect the options
# dict.
def parseArgs(myoptions={}):

	# local function for interpreting command line options 
	# and setting myoptions accordingly
	def optionSwitch(myoption,opts,action=None):
		return_code = True
		for o, a in opts:
			if o in ("-h", "--help"):
				if action: raise ParseArgsException('help-'+action)
				else: raise ParseArgsException('help')
			elif o in ("-V", "--version"):
				raise ParseArgsException('version')
			elif o in ("-C", "--nocolor"):
				myoptions['nocolor'] = True
				nocolor()
			elif o in ("-d", "--destructive"):
				myoptions['destructive'] = True
			elif o in ("-i", "--interactive") and not myoptions['pretend']:
				myoptions['interactive'] = True
			elif o in ("-p", "--pretend"):
				myoptions['pretend'] = True
				myoptions['interactive'] = False
			elif o in ("-q", "--quiet"):
				myoptions['quiet'] = True
			elif o in ("-t", "--time-limit"):
				myoptions['time-limit'] = parseTime(a)
			elif o in ("-e", "--exclude-file"):
				myoptions['exclude-file'] = a
			elif o in ("-n", "--package-names"):
				myoptions['package-names'] = True
			elif o in ("-f", "--fetch-restricted"):
				myoptions['fetch-restricted'] = True
			elif o in ("-s", "--size-limit"):
				myoptions['size-limit'] = parseSize(a)
			else: return_code = False
		# sanity check of --destructive only options:
		for myopt in ('fetch-restricted', 'package-names'):
			if (not myoptions['destructive']) and myoptions[myopt]:
				if not myoptions['quiet']:	
					eerror("--%s only makes sense in --destructive mode." \
							% myopt, myoptions['nocolor'])
				myoptions[myopt] = False
		return return_code

	# here are the different allowed command line options (getopt args)
	getopt_options = {'short':{}, 'long':{}}
	getopt_options['short']['global'] = "Cdipqe:t:nhV"
	getopt_options['long']['global'] = ["nocolor", "destructive", \
			"interactive", "pretend", "quiet", "exclude-file=", "time-limit=", \
			"package-names", "help", "version"]
	getopt_options['short']['distfiles'] = "fs:"
	getopt_options['long']['distfiles'] = ["fetch-restricted", "size-limit="]
	getopt_options['short']['packages'] = ""
	getopt_options['long']['packages'] = [""]
	# set default options, except 'nocolor', which is set in main()
	myoptions['interactive'] = False
	myoptions['pretend'] = False
	myoptions['quiet'] = False
	myoptions['accept_all'] = False
	myoptions['destructive'] = False
	myoptions['time-limit'] = 0
	myoptions['package-names'] = False
	myoptions['fetch-restricted'] = False
	myoptions['size-limit'] = 0
	# if called by a well-named symlink, set the acction accordingly:
	myaction = None
	if os.path.basename(sys.argv[0]) in \
			(__productname__+'-pkg', __productname__+'-packages'):
		myaction = 'packages'
	elif os.path.basename(sys.argv[0]) in \
			(__productname__+'-dist', __productname__+'-distfiles'):
		myaction = 'distfiles'
	# prepare for the first getopt
	if myaction:
		short_opts = getopt_options['short']['global'] \
				+ getopt_options['short'][myaction]
		long_opts = getopt_options['long']['global'] \
				+ getopt_options['long'][myaction]
		opts_mode = 'merged-'+myaction
	else:
		short_opts = getopt_options['short']['global']
		long_opts = getopt_options['long']['global']
		opts_mode = 'global'
	# apply getopts to command line, show partial help on failure
	try: opts, args = getopt.getopt(sys.argv[1:], short_opts, long_opts)
	except: raise ParseArgsException(opts_mode+'-options')
	# set myoptions accordingly
	optionSwitch(myoptions,opts,action=myaction)
	# if action was already set, there should be no more args
	if myaction and len(args): raise ParseArgsException(opts_mode+'-options')
	# if action was set, there is nothing left to do
	if myaction: return myaction
	# So, we are in "eclean --foo action --bar" mode. Parse remaining args...
	# Only two actions are allowed: 'packages' and 'distfiles'.
	if not len(args) or not args[0] in ('packages','distfiles'):
		raise ParseArgsException('actions')
	myaction = args.pop(0)
	# parse the action specific options
	try: opts, args = getopt.getopt(args, \
			getopt_options['short'][myaction], \
			getopt_options['long'][myaction])
	except: raise ParseArgsException(myaction+'-options')
	# set myoptions again, for action-specific options
	optionSwitch(myoptions,opts,action=myaction)
	# any remaning args? Then die!
	if len(args): raise ParseArgsException(myaction+'-options')
	# returns the action. Options dictionary is modified by side-effect.
	return myaction

###############################################################################
# isValidCP: check wether a string is a valid cat/pkg-name
# This is for 2.0.51 vs. CVS HEAD compatibility, i've not found any function
# for that which would exists in both. Weird...
def isValidCP(cp):
	if not '/' in cp: return False
	try: portage.cpv_getkey(cp+"-0")
	except: return False
	else: return True


###############################################################################
# ParseExcludeFileException: for parseExcludeFile() -> main() communication
class ParseExcludeFileException(Exception):
	def __init__(self, value):
		self.value = value
	def __str__(self):
		return repr(self.value)


###############################################################################
# parseExcludeFile: parses an exclusion file, returns an exclusion dictionnary
# Raises ParseExcludeFileException in case of fatal error.
def parseExcludeFile(filepath):
	excl_dict = { \
			'categories':{}, \
			'packages':{}, \
			'anti-packages':{}, \
			'garbage':{} }
	try: file = open(filepath,"r")
	except IOError:
		raise ParseExcludeFileException("Could not open exclusion file.")
	filecontents = file.readlines()
	file.close()
	cat_re = re.compile('^(?P<cat>[a-zA-Z0-9]+-[a-zA-Z0-9]+)(/\*)?$')
	cp_re = re.compile('^(?P<cp>[-a-zA-Z0-9_]+/[-a-zA-Z0-9_]+)$')	
	for line in filecontents:
		line = line.strip()
		if not len(line): continue
		if line[0] == '#': continue
		try: mycat = cat_re.match(line).group('cat')
		except: pass
		else: 
			if not mycat in portage.settings.categories:
				raise ParseExcludeFileException("Invalid category: "+mycat)
			excl_dict['categories'][mycat] = None
			continue
		dict_key = 'packages'
		if line[0] == '!':
			dict_key = 'anti-packages'
			line = line[1:]
		try:
			mycp = cp_re.match(line).group('cp')
			if isValidCP(mycp):
				excl_dict[dict_key][mycp] = None
				continue
			else: raise ParseExcludeFileException("Invalid cat/pkg: "+mycp)
		except: pass
		#raise ParseExcludeFileException("Invalid line: "+line)
		try:
			excl_dict['garbage'][line] = re.compile(line)
		except:
			try:
				excl_dict['garbage'][line] = re.compile(re.escape(line))
			except:
				raise ParseExcludeFileException("Invalid file name/regular expression: "+line)
	return excl_dict


###############################################################################
# exclDictExpand: returns a dictionary of all CP from porttree which match
# the exclusion dictionary
def exclDictExpand(excl_dict):
	mydict = {}
	if 'categories' in excl_dict:
		# XXX: i smell an access to something which is really out of API...
		for mytree in portage.portdb.porttrees:
			for mycat in excl_dict['categories']:
				for mypkg in listdir(os.path.join(mytree,mycat),ignorecvs=1):
					mydict[mycat+'/'+mypkg] = None
	if 'packages' in excl_dict:
		for mycp in excl_dict['packages']:
			mydict[mycp] = None
	if 'anti-packages' in excl_dict:
		for mycp in excl_dict['anti-packages']:
			if mycp in mydict:
				del mydict[mycp]
	return mydict


###############################################################################
# exclDictMatch: checks whether a CP matches the exclusion rules
def exclDictMatch(excl_dict,pkg):
	if 'anti-packages' in excl_dict \
	   and pkg in excl_dict['anti-packages']:
		return False
	if 'packages' in excl_dict \
	   and pkg in excl_dict['packages']:
		return True
	mycat = pkg.split('/')[0]
	if 'categories' in excl_dict \
	   and mycat in excl_dict['categories']:
		return True
	return False


###############################################################################
# findDistfiles: find all obsolete distfiles.
# XXX: what about cvs ebuilds? i should install some to see where it goes...
def findDistfiles( \
		myoptions, \
		exclude_dict={}, \
		destructive=False,\
		fetch_restricted=False, \
		package_names=False, \
		time_limit=0, \
		size_limit=0,):
	# this regexp extracts files names from SRC_URI. It is not very precise,
	# but we don't care (may return empty strings, etc.), since it is fast.
	file_regexp = re.compile('([a-zA-Z0-9_,\.\-\+\~]*)[\s\)]')
	clean_dict = {}
	keep = []
	pkg_dict = {}

	# create a big CPV->SRC_URI dict of packages whose distfiles should be kept
	if (not destructive) or fetch_restricted:
		# list all CPV from portree (yeah, that takes time...)
		for package in portage.portdb.cp_all():
			for my_cpv in portage.portdb.cp_list(package):
				# get SRC_URI and RESTRICT from aux_get
				try: (src_uri,restrict) = \
					portage.portdb.aux_get(my_cpv,["SRC_URI","RESTRICT"])
				except KeyError: continue
				# keep either all or fetch-restricted only
				if (not destructive) or ('fetch' in restrict):
					pkg_dict[my_cpv] = src_uri
	if destructive:
		if not package_names:
			# list all CPV from vartree
			pkg_list = portage.db[portage.root]["vartree"].dbapi.cpv_all()
		else:
			# list all CPV from portree for CP in vartree
			pkg_list = []
			for package in portage.db[portage.root]["vartree"].dbapi.cp_all():
				pkg_list += portage.portdb.cp_list(package)
		for my_cp in exclDictExpand(exclude_dict):
			# add packages from the exclude file
			pkg_list += portage.portdb.cp_list(my_cp)
		for my_cpv in pkg_list:
			# skip non-existing CPV (avoids ugly aux_get messages)
			if not portage.portdb.cpv_exists(my_cpv): continue
			# get SRC_URI from aux_get
			try: pkg_dict[my_cpv] = \
					portage.portdb.aux_get(my_cpv,["SRC_URI"])[0]
			except KeyError: continue
		del pkg_list

	# create a dictionary of files which should be deleted
	if not (os.path.isdir(distdir)):
		eerror("%s does not appear to be a directory." % distdir, myoptions['nocolor'])
		eerror("Please set DISTDIR to a sane value.", myoptions['nocolor'])
		eerror("(Check your /etc/make.conf and environment).", myoptions['nocolor'])
		exit(1)
	for file in os.listdir(distdir):
		filepath = os.path.join(distdir, file)
		try: file_stat = os.stat(filepath)
		except: continue
		if not stat.S_ISREG(file_stat[stat.ST_MODE]): continue
		if size_limit and (file_stat[stat.ST_SIZE] >= size_limit):
			continue
		if time_limit and (file_stat[stat.ST_MTIME] >= time_limit):
			continue
		if 'garbage' in exclude_dict:
			# Try to match file name directly
			if file in exclude_dict['garbage']:
				file_match = True
			# See if file matches via regular expression matching
			else:
				file_match = False
				for file_entry in exclude_dict['garbage']:
					if exclude_dict['garbage'][file_entry].match(file):
						file_match = True
						break

			if file_match:
				continue
		# this is a candidate for cleaning
		clean_dict[file]=[filepath]
	# remove files owned by some protected packages
	for my_cpv in pkg_dict:
		for file in file_regexp.findall(pkg_dict[my_cpv]+"\n"):
			if file in clean_dict:
				del clean_dict[file]
		# no need to waste IO time if there is nothing left to clean
		if not len(clean_dict): return clean_dict
	return clean_dict


###############################################################################
# findPackages: find all obsolete binary packages.
# XXX: packages are found only by symlinks. Maybe i should also return .tbz2
#      files from All/ that have no corresponding symlinks.
def findPackages( \
		myoptions, \
		exclude_dict={}, \
		destructive=False, \
		time_limit=0, \
		package_names=False):
	clean_dict = {}
	# create a full package dictionary
	
	if not (os.path.isdir(pkgdir)):
		eerror("%s does not appear to be a directory." % pkgdir, myoptions['nocolor'])
		eerror("Please set PKGDIR to a sane value.", myoptions['nocolor'])
		eerror("(Check your /etc/make.conf and environment).", myoptions['nocolor'])
		exit(1)
	for root, dirs, files in os.walk(pkgdir):
		if root[-3:] == 'All': continue
		for file in files:
			if not file[-5:] == ".tbz2":
				# ignore non-tbz2 files
				continue
			path = os.path.join(root, file)
			category = os.path.split(root)[-1]
			cpv = category+"/"+file[:-5]
			mystat = os.lstat(path)
			if time_limit and (mystat[stat.ST_MTIME] >= time_limit):
				# time-limit exclusion
				continue
			# dict is cpv->[files] (2 files in general, because of symlink)
			clean_dict[cpv] = [path]
			#if os.path.islink(path):
			if stat.S_ISLNK(mystat[stat.ST_MODE]):
				clean_dict[cpv].append(os.path.realpath(path))
	# keep only obsolete ones
	if destructive:
		mydbapi = portage.db[portage.root]["vartree"].dbapi
		if package_names: cp_all = dict.fromkeys(mydbapi.cp_all())
		else: cp_all = {}
	else:
		mydbapi = portage.db[portage.root]["porttree"].dbapi
		cp_all = {}
	for mycpv in clean_dict.keys():
		if exclDictMatch(exclude_dict,portage.cpv_getkey(mycpv)):
			# exclusion because of the exclude file
			del clean_dict[mycpv]
			continue
		if mydbapi.cpv_exists(mycpv):
			# exclusion because pkg still exists (in porttree or vartree)
			del clean_dict[mycpv]
			continue
		if portage.cpv_getkey(mycpv) in cp_all:
			# exlusion because of --package-names
			del clean_dict[mycpv]

	return clean_dict


###############################################################################
# doCleanup: takes a dictionnary {'display name':[list of files]}. Calculate
# size of each entry for display, prompt user if needed, delete files if needed
# and return the total size of files that [have been / would be] deleted.
def doCleanup(clean_dict,action,myoptions):
	# define vocabulary of this action
	if action == 'distfiles': file_type = 'file'
	else: file_type = 'binary package'
	# sorting helps reading
	clean_keys = clean_dict.keys()
	clean_keys.sort()
	clean_size = 0
	# clean all entries one by one
	for mykey in clean_keys:
		key_size = 0
		for file in clean_dict[mykey]:
			# get total size for an entry (may be several files, and
			# symlinks count zero)
			if os.path.islink(file): continue
			try: key_size += os.path.getsize(file)
			except: eerror("Could not read size of "+file, \
			               myoptions['nocolor'])
		if not myoptions['quiet']:
			# pretty print mode
			print prettySize(key_size,True),teal(mykey)
		elif myoptions['pretend'] or myoptions['interactive']:
			# file list mode
			for file in clean_dict[mykey]: print file
		#else: actually delete stuff, but don't print anything
		if myoptions['pretend']: clean_size += key_size
		elif not myoptions['interactive'] \
		     or myoptions['accept_all'] \
		     or yesNoAllPrompt(myoptions, \
		                       "Do you want to delete this " \
				       + file_type+"?"):
			# non-interactive mode or positive answer. 
			# For each file, try to delete the file and clean it out
			# of Packages metadata file
			if action == 'packages':
				metadata = portage.getbinpkg.PackageIndex()
				with open(os.path.join(pkgdir, 'Packages')) as metadata_file:
					metadata.read(metadata_file)
			for file in clean_dict[mykey]:
				# ...get its size...
				filesize = 0
				if not os.path.exists(file): continue
				if not os.path.islink(file):
					try: filesize = os.path.getsize(file)
					except: eerror("Could not read size of "\
					               +file, myoptions['nocolor'])
				# ...and try to delete it.
				try:
					os.unlink(file)
				except:
					eerror("Could not delete "+file, \
				         myoptions['nocolor'])
				# only count size if successfully deleted
				else:
					clean_size += filesize
					if action == 'packages':
						metadata.packages[:] = [p for p in metadata.packages if 'CPV' in p and p['CPV'] != file]
				
			if action == 'packages':
				with open(os.path.join(pkgdir, 'Packages'), 'w') as metadata_file:
					metadata.write(metadata_file)

	# return total size of deleted or to delete files
	return clean_size


###############################################################################
# doAction: execute one action, ie display a few message, call the right find*
# function, and then call doCleanup with its result.
def doAction(action,myoptions,exclude_dict={}):
	# define vocabulary for the output
	if action == 'packages': files_type = "binary packages"
	else: files_type = "distfiles"
	# find files to delete, depending on the action
	if not myoptions['quiet']:
		einfo("Building file list for "+action+" cleaning...", \
		      myoptions['nocolor'])
	if action == 'packages':
		clean_dict = findPackages(
			myoptions, \
			exclude_dict=exclude_dict, \
			destructive=myoptions['destructive'], \
			package_names=myoptions['package-names'], \
			time_limit=myoptions['time-limit'])
	else: 
		clean_dict = findDistfiles( \
			myoptions, \
			exclude_dict=exclude_dict, \
			destructive=myoptions['destructive'], \
			fetch_restricted=myoptions['fetch-restricted'], \
			package_names=myoptions['package-names'], \
			time_limit=myoptions['time-limit'], \
			size_limit=myoptions['size-limit'])
	# actually clean files if something was found
	if len(clean_dict.keys()):
		# verbose pretend message
		if myoptions['pretend'] and not myoptions['quiet']:
			einfo("Here are "+files_type+" that would be deleted:", \
			      myoptions['nocolor'])
		# verbose non-pretend message
		elif not myoptions['quiet']:
			einfo("Cleaning "+files_type+"...",myoptions['nocolor'])
		# do the cleanup, and get size of deleted files
		clean_size = doCleanup(clean_dict,action,myoptions)
		# vocabulary for final message
		if myoptions['pretend']: verb = "would be"
		else: verb = "has been"
		# display freed space
		if not myoptions['quiet']:
			einfo("Total space that "+verb+" freed in " \
			      + action + " directory: " \
			      + red(prettySize(clean_size)), \
			      myoptions['nocolor'])
	# nothing was found, return
	elif not myoptions['quiet']:
		einfo("Your "+action+" directory was already clean.", \
		      myoptions['nocolor'])


###############################################################################
# main: parse command line and execute all actions
def main():
	# set default options
	myoptions = {}
	myoptions['nocolor'] = port_settings["NOCOLOR"] in ('yes','true') \
	                       and sys.stdout.isatty()
	if myoptions['nocolor']: nocolor()
	# parse command line options and actions
	try: myaction = parseArgs(myoptions)
	# filter exception to know what message to display
	except ParseArgsException, e:
		if e.value == 'help':
			printUsage(help='all')
			sys.exit(0)
		elif e.value[:5] == 'help-':
			printUsage(help=e.value[5:])
			sys.exit(0)
		elif e.value == 'version':
			printVersion()
			sys.exit(0)
		else: 
			printUsage(e.value)
			sys.exit(2)
	# parse the exclusion file
	if not 'exclude-file' in myoptions:
		my_exclude_file = "/etc/%s/%s.exclude" % (__productname__ , myaction)
		if os.path.isfile(my_exclude_file):
			myoptions['exclude-file'] = my_exclude_file
	if 'exclude-file' in myoptions:
		try: exclude_dict = parseExcludeFile(myoptions['exclude-file'])
		except ParseExcludeFileException, e:
			eerror(e, myoptions['nocolor'])
			eerror("Invalid exclusion file: %s" % myoptions['exclude-file'], \
					myoptions['nocolor'])
			eerror("See format of this file in `man %s`" % __productname__, \
					myoptions['nocolor'])	
			sys.exit(1)
	else: exclude_dict={}
	# security check for non-pretend mode
	if not myoptions['pretend'] and portage.secpass == 0:
		eerror("Permission denied: you must be root or belong to the portage group.", \
		       myoptions['nocolor'])
		sys.exit(1)
	# execute action
	doAction(myaction, myoptions, exclude_dict=exclude_dict)


###############################################################################
# actually call main() if launched as a script
if __name__ == "__main__":
	try: main()
	except KeyboardInterrupt:
		print "Aborted."
		sys.exit(130)
	sys.exit(0)

