From: Michael Casadevall Date: Sun, 4 Jan 2009 00:29:01 +0000 (-0500) Subject: Merge commit 'origin/master' into content_generation X-Git-Url: https://git.decadent.org.uk/gitweb/?a=commitdiff_plain;h=d1d337ac1162793402ec207082be1161458b193d;hp=f681a81deaba0b5924bb4319ea3efab5a64934c5;p=dak.git Merge commit 'origin/master' into content_generation --- diff --git a/dak/dak.py b/dak/dak.py index e8a7df03..d04eebc2 100755 --- a/dak/dak.py +++ b/dak/dak.py @@ -105,6 +105,8 @@ def init(): "Generate lists of packages per suite for apt-ftparchive"), ("generate-releases", "Generate Release files"), + ("generate-contents", + "Generate contest files"), ("generate-index-diffs", "Generate .diff/Index files"), ("clean-suites", @@ -136,6 +138,8 @@ def init(): "Check for users with no packages in the archive"), ("import-archive", "Populate SQL database based from an archive tree"), + ("import-contents", + "Populate SQL database with Contents files"), ("import-keyring", "Populate fingerprint/uid table based on a new/updated keyring"), ("import-ldap-fingerprints", diff --git a/dak/dakdb/update2.py b/dak/dakdb/update2.py new file mode 100644 index 00000000..ec9650b1 --- /dev/null +++ b/dak/dakdb/update2.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python + +# Debian Archive Kit Database Update Script 2 +# Copyright (C) 2009 Michael Casadevall + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +################################################################################ + +# really, if we want to screw ourselves, let's find a better way. +# rm -rf /srv/ftp.debian.org + +################################################################################ + +import psycopg2, time + +################################################################################ + +def do_update(self): + print "Adding content fields to database" + + try: + c = self.db.cursor() + c.execute("""CREATE TABLE content_file_paths ( + id serial primary key not null, + path text unique not null + )""") + + c.execute("""CREATE TABLE content_file_names ( + id serial primary key not null, + file text unique not null + )""") + + c.execute("""CREATE TABLE content_associations ( + id serial not null, + binary_pkg int4 not null references binaries(id) on delete cascade, + filepath int4 not null references content_file_paths(id) on delete cascade, + filename int4 not null references content_file_names(id) on delete cascade + );""") + + c.execute("""CREATE FUNCTION comma_concat(text, text) RETURNS text + AS $_$select case + WHEN $2 is null or $2 = '' THEN $1 + WHEN $1 is null or $1 = '' THEN $2 + ELSE $1 || ',' || $2 + END$_$ + LANGUAGE sql""") + + c.execute("""CREATE AGGREGATE comma_separated_list ( + BASETYPE = text, + SFUNC = comma_concat, + STYPE = text, + INITCOND = '' + );""") + + c.execute("UPDATE config SET value = '2' WHERE name = 'db_revision'") + self.db.commit() + + print "REMINDER: Remember to fully regenerate the Contents files before running import-contents" + print "" + print "Pausing for five seconds ..." + time.sleep (5) + + except psycopg2.ProgrammingError, msg: + self.db.rollback() + print "FATAL: Unable to apply content table update 2!" + print "Error Message: " + str(msg) + print "Database changes have been rolled back." diff --git a/dak/generate_contents.py b/dak/generate_contents.py new file mode 100755 index 00000000..6d84d16b --- /dev/null +++ b/dak/generate_contents.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python +# Create all the contents files + +# Copyright (C) 2008, 2009 Michael Casadevall + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +################################################################################ +# there is the idea to slowly replace contents files +# with a new generation of such files. +# having more info. +# of course that wont help for now where we need to generate them :) +################################################################################ + +################################################################################ + +import sys, os, popen2, tempfile, stat, time, pg +import gzip, apt_pkg +from daklib import database, utils +from daklib.dak_exceptions import * + +################################################################################ + +Cnf = None +projectB = None +out = None +AptCnf = None + +################################################################################ + +def usage (exit_code=0): + print """Usage: dak generate-contents +Generate Contents files + + -h, --help show this help and exit + -s, --suite=SUITE only write file lists for this suite +""" + sys.exit(exit_code) + +################################################################################ + +def generate_contents(suites): + global projectB, Cnf + # Ok, the contents information is in the database + + # We need to work and get the contents, and print it out on a per + # architectual basis + + # Read in the contents file header + header = False + if Cnf.has_key("Generate-Contents::Header"): + h = open(Cnf["Generate-Contents::Header"], "r") + header = h.read() + h.close() + + # Get our suites, and the architectures + for s in [i.lower() for i in suites]: + suite_id = database.get_suite_id(s) + + q = projectB.query("SELECT s.architecture, a.arch_string FROM suite_architectures s JOIN architecture a ON (s.architecture=a.id) WHERE suite = '%d'" % suite_id) + + arch_list = [ ] + for r in q.getresult(): + if r[1] != "source" and r[1] != "all": + arch_list.append((r[0], r[1])) + + arch_all_id = database.get_architecture_id("all") + + # Time for the query from hell. Essentially, we need to get the assiocations, the filenames, the paths, + # and all that fun stuff from the database. + + for arch_id in arch_list: + q = projectB.query("""SELECT p.path||'/'||n.file, comma_separated_list(s.section||'/'||b.package) FROM content_associations c JOIN content_file_paths p ON (c.filepath=p.id) JOIN content_file_names n ON (c.filename=n.id) JOIN binaries b ON (b.id=c.binary_pkg) JOIN bin_associations ba ON (b.id=ba.bin) JOIN override o ON (o.package=b.package) JOIN section s ON (s.id=o.section) WHERE (b.architecture = '%d' OR b.architecture = '%d') AND ba.suite = '%d' AND b.type = 'deb' GROUP BY (p.path||'/'||n.file)""" % (arch_id[0], arch_all_id, suite_id)) + + f = gzip.open(Cnf["Dir::Root"] + "dists/%s/Contents-%s.gz" % (s, arch_id[1]), "w") + + if header: + f.write(header) + + for contents in q.getresult(): + f.write(contents[0] + "\t\t\t" + contents[-1] + "\n") + + f.close() + + # The MORE fun part. Ok, udebs need their own contents files, udeb, and udeb-nf (not-free) + # This is HORRIBLY debian specific :-/ + # First off, udeb + + section_id = database.get_section_id('debian-installer') # all udebs should be here) + + if section_id != -1: + q = projectB.query("""SELECT p.path||'/'||n.file, comma_separated_list(s.section||'/'||b.package) FROM content_associations c JOIN content_file_paths p ON (c.filepath=p.id) JOIN content_file_names n ON (c.filename=n.id) JOIN binaries b ON (b.id=c.binary_pkg) JOIN bin_associations ba ON (b.id=ba.bin) JOIN override o ON (o.package=b.package) JOIN section s ON (s.id=o.section) WHERE s.id = '%d' AND ba.suite = '%d' AND b.type = 'udeb' GROUP BY (p.path||'/'||n.file)""" % (section_id, suite_id)) + + f = gzip.open(Cnf["Dir::Root"] + "dists/%s/Contents-udeb.gz" % (s), "w") + + if header: + f.write(header) + + for contents in q.getresult(): + f.write(contents[0] + "\t\t\t" + contents[-1] + "\n") + + f.close() + + # Once more, with non-free + section_id = database.get_section_id('non-free/debian-installer') # all udebs should be here) + + if section_id != -1: + q = projectB.query("""SELECT p.path||'/'||n.file, comma_separated_list(s.section||'/'||b.package) FROM content_associations c JOIN content_file_paths p ON (c.filepath=p.id) JOIN content_file_names n ON (c.filename=n.id) JOIN binaries b ON (b.id=c.binary_pkg) JOIN bin_associations ba ON (b.id=ba.bin) JOIN override o ON (o.package=b.package) JOIN section s ON (s.id=o.section) WHERE s.id = '%d' AND ba.suite = '%d' AND b.type = 'udeb' GROUP BY (p.path||'/'||n.file)""" % (section_id, suite_id)) + + f = gzip.open(Cnf["Dir::Root"] + "dists/%s/Contents-udeb-nf.gz" % (s), "w") + + if header: + f.write(header) + + for contents in q.getresult(): + f.write(contents[0] + "\t\t\t" + contents[-1] + "\n") + + f.close() + +################################################################################ + +def main (): + global Cnf, projectB, out + out = sys.stdout + + Cnf = utils.get_conf() + + Arguments = [('h',"help","Generate-Contents::Options::Help"), + ('s',"suite","Generate-Contents::Options::Suite","HasArg"), + ] + + for i in [ "help", "suite" ]: + if not Cnf.has_key("Generate-Contents::Options::%s" % (i)): + Cnf["Generate-Contents::Options::%s" % (i)] = "" + + suites = apt_pkg.ParseCommandLine(Cnf,Arguments,sys.argv) + Options = Cnf.SubTree("Generate-Contents::Options") + + if Options["Help"]: + usage() + + if Options["Suite"]: + suites = utils.split_args(Options["Suite"]) + else: + suites = Cnf.SubTree("Suite").List() + + projectB = pg.connect(Cnf["DB::Name"], Cnf["DB::Host"], int(Cnf["DB::Port"])) + database.init(Cnf, projectB) + + generate_contents(suites) + +####################################################################################### + +if __name__ == '__main__': + main() diff --git a/dak/import_contents.py b/dak/import_contents.py new file mode 100755 index 00000000..06691957 --- /dev/null +++ b/dak/import_contents.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python +# Import contents files + +# Copyright (C) 2008, 2009 Michael Casadevall + +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +################################################################################ +################################################################################ + +################################################################################ + +import sys, os, popen2, tempfile, stat, time, pg +import re, gzip, apt_pkg +from daklib import database, utils +from daklib.dak_exceptions import * + +################################################################################ + +Cnf = None +projectB = None +out = None +AptCnf = None +content_path_id_cache = {} +content_file_id_cache = {} +insert_contents_file_cache = {} + +################################################################################ + +def usage (exit_code=0): + print """Usage: dak import-contents +Import Contents files + + -h, --help show this help and exit + -s, --suite=SUITE only write file lists for this suite +""" + sys.exit(exit_code) + +################################################################################ + + +def set_contents_file_id(file): + global content_file_id_cache + + if not content_file_id_cache.has_key(file): + # since this can be called within a transaction, we can't use currval + q = projectB.query("INSERT INTO content_file_names VALUES (DEFAULT, '%s') RETURNING id" % (file)) + content_file_id_cache[file] = int(q.getresult()[0][0]) + return content_file_id_cache[file] + +################################################################################ + +def set_contents_path_id(path): + global content_path_id_cache + + if not content_path_id_cache.has_key(path): + q = projectB.query("INSERT INTO content_file_paths VALUES (DEFAULT, '%s') RETURNING id" % (path)) + content_path_id_cache[path] = int(q.getresult()[0][0]) + return content_path_id_cache[path] + +################################################################################ + +def insert_content_path(bin_id, fullpath): + global insert_contents_file_cache + cache_key = "%s_%s" % (bin_id, fullpath) + + # have we seen this contents before? + # probably only revelant during package import + if insert_contents_file_cache.has_key(cache_key): + return + + # split the path into basename, and pathname + (path, file) = os.path.split(fullpath) + + # Get the necessary IDs ... + file_id = set_contents_file_id(file) + path_id = set_contents_path_id(path) + + # Put them into content_assiocations + projectB.query("INSERT INTO content_associations VALUES (DEFAULT, '%d', '%d', '%d')" % (bin_id, path_id, file_id)) + return + +################################################################################ + +def import_contents(suites): + global projectB, Cnf + + # Start transaction + projectB.query("BEGIN WORK") + + # Needed to make sure postgreSQL doesn't freak out on some of the data + projectB.query("SET CLIENT_ENCODING TO 'LATIN1'") + + # Prep regexs + line_regex = re.compile(r'^(.+?)\s+(\S+)$') + pkg_regex = re.compile(r'(\S+)/(\S+)$') + file_regex = re.compile('^FILE') + + # Get our suites, and the architectures + for s in suites: + suite_id = database.get_suite_id(s) + + q = projectB.query("SELECT s.architecture, a.arch_string FROM suite_architectures s JOIN architecture a ON (s.architecture=a.id) WHERE suite = '%d'" % suite_id) + + arch_list = [ ] + for r in q.getresult(): + if r[1] != "source" and r[1] != "all": + arch_list.append((r[0], r[1])) + + arch_all_id = database.get_architecture_id("all") + + for arch in arch_list: + print "Processing %s/%s" % (s, arch[1]) + arch_id = database.get_architecture_id(arch[1]) + f = gzip.open(Cnf["Dir::Root"] + "dists/%s/Contents-%s.gz" % (s, arch[1]), "r") + + # Get line count + lines = f.readlines() + num_of_lines = len(lines) + + # Ok, the file cursor is at the first entry, now comes the fun 'lets parse' bit + lines_processed = 0 + found_header = False + + for line in lines: + if found_header == False: + if not line: + print "Unable to find end of Contents-%s.gz header!" % ( arch[1]) + sys.exit(255) + + lines_processed += 1 + if file_regex.match(line): + found_header = True + continue + + # The format is simple enough, *filename*, *section/package1,section/package2,etc* + # Each file appears once per Contents file, so first, use some regex match + # to split the two bits + + # Print out progress bar + print "\rProcessed %d lines of %d (%%%.2f)" % (lines_processed, num_of_lines, ((float(lines_processed)/num_of_lines)*100)), + + # regex lifted from packages.d.o code + matchs = line_regex.findall(line) + filename = matchs[0][0] + packages = matchs[0][1].split(',') + + # Iterate through each file's packages + for package in packages: + matchs = pkg_regex.findall(package) + + # Needed since the DB is unicode, and these files + # are ASCII + section_name = matchs[0][0] + package_name = matchs[0][1] + + section_id = database.get_section_id(section_name) + package_id = database.get_latest_binary_version_id(package_name, section_id, suite_id, arch_id) + + if package_id == None: + # Likely got an arch all package + package_id = database.get_latest_binary_version_id(package_name, section_id, suite_id, arch_all_id) + + insert_content_path(package_id, filename) + + lines_processed += 1 + f.close() + + # Commit work + print "Committing to database ..." + projectB.query("COMMIT") + +################################################################################ + +def main (): + global Cnf, projectB, out + out = sys.stdout + + Cnf = utils.get_conf() + + Arguments = [('h',"help","Import-Contents::Options::Help"), + ('s',"suite","Import-Contents::Options::Suite","HasArg"), + ] + + for i in [ "help", "suite" ]: + if not Cnf.has_key("Import-Contents::Options::%s" % (i)): + Cnf["Import-Contents::Options::%s" % (i)] = "" + + suites = apt_pkg.ParseCommandLine(Cnf,Arguments,sys.argv) + Options = Cnf.SubTree("Import-Contents::Options") + + if Options["Help"]: + usage() + + if Options["Suite"]: + suites = utils.split_args(Options["Suite"]) + else: + suites = Cnf.SubTree("Suite").List() + + projectB = pg.connect(Cnf["DB::Name"], Cnf["DB::Host"], int(Cnf["DB::Port"])) + database.init(Cnf, projectB) + + import_contents(suites) + +####################################################################################### + +if __name__ == '__main__': + main() diff --git a/dak/process_accepted.py b/dak/process_accepted.py index ea238ef7..fa66a0c4 100755 --- a/dak/process_accepted.py +++ b/dak/process_accepted.py @@ -30,7 +30,7 @@ ############################################################################### import errno, fcntl, os, sys, time, re -import apt_pkg +import apt_pkg, tarfile, commands from daklib import database from daklib import logging from daklib import queue @@ -96,6 +96,43 @@ class Urgency_Log: else: os.unlink(self.log_filename) + +############################################################################### + +def generate_contents_information(filename): + # Generate all the contents for the database + cmd = "ar t %s" % (filename) + (result, output) = commands.getstatusoutput(cmd) + if result != 0: + reject("%s: 'ar t' invocation failed." % (filename)) + reject(utils.prefix_multi_line_string(output, " [ar output:] "), "") + + # Ugh ... this is ugly ... Code ripped from process_unchecked.py + chunks = output.split('\n') + cmd = "ar x %s %s" % (filename, chunks[2]) + (result, output) = commands.getstatusoutput(cmd) + if result != 0: + reject("%s: 'ar t' invocation failed." % (filename)) + reject(utils.prefix_multi_line_string(output, " [ar output:] "), "") + + # Got deb tarballs, now lets go through and determine what bits + # and pieces the deb had ... + if chunks[2] == "data.tar.gz": + data = tarfile.open("data.tar.gz", "r:gz") + elif data_tar == "data.tar.bz2": + data = tarfile.open("data.tar.bz2", "r:bz2") + else: + os.remove(chunks[2]) + reject("couldn't find data.tar.*") + + contents = [] + for tarinfo in data: + if not tarinfo.isdir(): + contents.append(tarinfo.name[2:]) + + os.remove(chunks[2]) + return contents + ############################################################################### def reject (str, prefix="Rejected: "): @@ -357,6 +394,7 @@ def install (): source = files[file]["source package"] source_version = files[file]["source version"] filename = files[file]["pool name"] + file + contents = generate_contents_information(file) if not files[file].has_key("location id") or not files[file]["location id"]: files[file]["location id"] = database.get_location_id(Cnf["Dir::Pool"],files[file]["component"],utils.where_am_i()) if not files[file].has_key("files id") or not files[file]["files id"]: @@ -371,6 +409,12 @@ def install (): suite_id = database.get_suite_id(suite) projectB.query("INSERT INTO bin_associations (suite, bin) VALUES (%d, currval('binaries_id_seq'))" % (suite_id)) + # insert contents into the database + q = projectB.query("SELECT currval('binaries_id_seq')") + bin_id = int(q.getresult()[0][0]) + for file in contents: + database.insert_content_path(bin_id, file) + # If the .orig.tar.gz is in a legacy directory we need to poolify # it, so that apt-get source (and anything else that goes by the # "Directory:" field in the Sources.gz file) works. @@ -433,7 +477,6 @@ def install (): utils.copy(pkg.changes_file, Cnf["Dir::Root"] + dest) for dest in copy_dot_dak.keys(): utils.copy(Upload.pkg.changes_file[:-8]+".dak", dest) - projectB.query("COMMIT WORK") # Move the .changes into the 'done' directory diff --git a/dak/update_db.py b/dak/update_db.py index e59a558c..7d89e6bf 100755 --- a/dak/update_db.py +++ b/dak/update_db.py @@ -36,7 +36,7 @@ from daklib import utils Cnf = None projectB = None -required_database_schema = 1 +required_database_schema = 2 ################################################################################ diff --git a/daklib/database.py b/daklib/database.py index 5c7bd838..f6bedf3d 100755 --- a/daklib/database.py +++ b/daklib/database.py @@ -19,7 +19,7 @@ ################################################################################ -import sys, time, types +import os, sys, time, types, apt_pkg ################################################################################ @@ -42,6 +42,10 @@ fingerprint_id_cache = {} queue_id_cache = {} uid_id_cache = {} suite_version_cache = {} +suite_bin_version_cache = {} +content_path_id_cache = {} +content_file_id_cache = {} +insert_contents_file_cache = {} ################################################################################ @@ -224,7 +228,7 @@ def get_source_id (source, version): return source_id -def get_suite_version(source, suite): +def get_suite_version(source, suite, arch): global suite_version_cache cache_key = "%s_%s" % (source, suite) @@ -247,6 +251,25 @@ def get_suite_version(source, suite): return version +def get_latest_binary_version_id(binary, section, suite, arch): + global suite_bin_version_cache + cache_key = "%s_%s_%s_%s" % (binary, section, suite, arch) + + if suite_bin_version_cache.has_key(cache_key): + return suite_bin_version_cache[cache_key] + + q = projectB.query("SELECT b.id, b.version FROM binaries b JOIN bin_associations ba ON (b.id = ba.bin) JOIN override o ON (o.package=b.package) WHERE b.package = '%s' AND b.architecture = '%d' AND ba.suite = '%d' AND o.section = '%d'" % (binary, int(arch), int(suite), int(section))) + + highest_bid, highest_version = None, None + + for bi in q.getresult(): + if highest_version == None or apt_pkg.VersionCompare(bi[1], highest_version) == 1: + highest_bid = bi[0] + highest_version = bi[1] + + suite_bin_version_cache[cache_key] = highest_bid + return highest_bid + ################################################################################ def get_or_set_maintainer_id (maintainer): @@ -397,3 +420,59 @@ def get_suites(pkgname, src=False): sql = "select suite_name from binaries, bin_associations,suite where binaries.id=bin_associations.bin and package='%s' and bin_associations.suite = suite.id"%pkgname q = projectB.query(sql) return map(lambda x: x[0], q.getresult()) + +################################################################################ + +def get_or_set_contents_file_id(file): + global content_file_id_cache + + if not content_file_id_cache.has_key(file): + sql_select = "SELECT id FROM content_file_names WHERE file = '%s'" % file + q = projectB.query(sql_select) + if not q.getresult(): + # since this can be called within a transaction, we can't use currval + q = projectB.query("INSERT INTO content_file_names VALUES (DEFAULT, '%s') RETURNING id" % (file)) + content_file_id_cache[file] = int(q.getresult()[0][0]) + return content_file_id_cache[file] + +################################################################################ + +def get_or_set_contents_path_id(path): + global content_path_id_cache + + if not content_path_id_cache.has_key(path): + sql_select = "SELECT id FROM content_file_paths WHERE path = '%s'" % path + q = projectB.query(sql_select) + if not q.getresult(): + # since this can be called within a transaction, we can't use currval + q = projectB.query("INSERT INTO content_file_paths VALUES (DEFAULT, '%s') RETURNING id" % (path)) + content_path_id_cache[path] = int(q.getresult()[0][0]) + return content_path_id_cache[path] + +################################################################################ + +def insert_content_path(bin_id, fullpath): + global insert_contents_file_cache + cache_key = "%s_%s" % (bin_id, fullpath) + + # have we seen this contents before? + # probably only revelant during package import + if insert_contents_file_cache.has_key(cache_key): + return + + # split the path into basename, and pathname + (path, file) = os.path.split(fullpath) + + # Get the necessary IDs ... + file_id = get_or_set_contents_file_id(file) + path_id = get_or_set_contents_path_id(path) + + # Determine if we're inserting a duplicate row + q = projectB.query("SELECT 1 FROM content_associations WHERE binary_pkg = '%d' AND filepath = '%d' AND filename = '%d'" % (int(bin_id), path_id, file_id)) + if q.getresult(): + # Yes we are, return without doing the insert + return + + # Put them into content_assiocations + projectB.query("INSERT INTO content_associations VALUES (DEFAULT, '%d', '%d', '%d')" % (bin_id, path_id, file_id)) + return diff --git a/docs/README.quotes b/docs/README.quotes index 3568ae7a..c696fbeb 100644 --- a/docs/README.quotes +++ b/docs/README.quotes @@ -344,3 +344,9 @@ Canadians: This is a lighthouse. Your call. elmo: I can't believe people pay you to fix computers %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +* Ganneff ponders how to best write the text to -devel. (need to tell em in + case they find more bugs). "We fixed the fucking idiotic broken implementation + to be less so" is probably not the nicest, even if perfect valid, way to say so + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%