From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: (qmail 80749 invoked by alias); 7 Jun 2019 10:16:39 -0000 Mailing-List: contact cygwin-apps-cvs-help@sourceware.org; run by ezmlm Precedence: bulk List-Id: List-Subscribe: List-Post: List-Help: , Sender: cygwin-apps-cvs-owner@sourceware.org Received: (qmail 80624 invoked by uid 9795); 7 Jun 2019 10:16:28 -0000 Date: Fri, 07 Jun 2019 10:16:00 -0000 Message-ID: <20190607101628.80613.qmail@sourceware.org> From: jturney@sourceware.org To: cygwin-apps-cvs@sourceware.org Subject: [calm - Cygwin server-side packaging maintenance script] branch master, updated. 20190530-15-g8118f97 X-Git-Refname: refs/heads/master X-Git-Reftype: branch X-Git-Oldrev: bc056955516aaaabfa70f02c77e87ddca474bf93 X-Git-Newrev: 8118f97177a0ffe74681ebe32ca05436eb3c0c78 X-SW-Source: 2019-q2/txt/msg00018.txt.bz2 https://sourceware.org/git/gitweb.cgi?p=cygwin-apps/calm.git;h=8118f97177a0ffe74681ebe32ca05436eb3c0c78 commit 8118f97177a0ffe74681ebe32ca05436eb3c0c78 Author: Jon Turney Date: Mon Jun 3 18:15:06 2019 +0100 Allow a package to appear in arch and noarch Use the existing package-dict merge() function to combine them, reporting non-permitted duplications. Improve merge() so it can handle a package appearing in more than one of the merged-in package-dicts Handle the merge reporting an error (by returning None) in validate_packages() https://sourceware.org/git/gitweb.cgi?p=cygwin-apps/calm.git;h=cd973f84cae0ec9598591eda8ffafa0e263fd713 commit cd973f84cae0ec9598591eda8ffafa0e263fd713 Author: Jon Turney Date: Tue May 28 21:07:37 2019 +0100 Stop storing relarea path in package The 'in package list' constraint becomes simply that path relative to /release starts with a package name in the package list Somewhat unfortunately, items in the movelist don't know what package they belong to, which makes things a bit awkward https://sourceware.org/git/gitweb.cgi?p=cygwin-apps/calm.git;h=67c8cfe73eb665685f4bc82ba4297d0c48d81e93 commit 67c8cfe73eb665685f4bc82ba4297d0c48d81e93 Author: Jon Turney Date: Sun Jun 2 14:08:33 2019 +0100 Store path to tar file in Tar object https://sourceware.org/git/gitweb.cgi?p=cygwin-apps/calm.git;h=9d0d2ba77b13b7a95755c631ddda65065a121d50 commit 9d0d2ba77b13b7a95755c631ddda65065a121d50 Author: Jon Turney Date: Sun Jun 2 13:13:46 2019 +0100 Add hint object to store path to hint file https://sourceware.org/git/gitweb.cgi?p=cygwin-apps/calm.git;h=5fbe4c85050c291b8976675e8cd9b36f004f1672 commit 5fbe4c85050c291b8976675e8cd9b36f004f1672 Author: Jon Turney Date: Wed May 29 13:18:30 2019 +0100 Factor out movelist as a separate class Wrap movelist handling up in an object. This shouldn't make any functional change. https://sourceware.org/git/gitweb.cgi?p=cygwin-apps/calm.git;h=a5d2e1a6877e12ad43affb312722a12bff11ff35 commit a5d2e1a6877e12ad43affb312722a12bff11ff35 Author: Jon Turney Date: Thu May 30 18:49:21 2019 +0100 Add some test coverage for movelist conflict Diff: --- calm/calm.py | 48 +++++--- calm/movelist.py | 112 ++++++++++++++++++ calm/package.py | 115 ++++++++++-------- calm/pkg2html.py | 6 +- calm/uploads.py | 85 ++------------ test/test_calm.py | 43 ++++++- test/testdata/conflict/homedir.expected | 7 + test/testdata/conflict/rel_area.expected | 124 ++++++++++++++++++++ test/testdata/conflict/vault.expected | 12 ++ .../staleversion/staleversion-230-1-src.tar.xz | Bin 0 -> 228 bytes .../release/staleversion/staleversion-230-1.hint | 4 + .../release/staleversion/staleversion-230-1.tar.xz | Bin 0 -> 228 bytes test/testdata/uploads/pkglist.expected | 8 +- 13 files changed, 405 insertions(+), 159 deletions(-) diff --git a/calm/calm.py b/calm/calm.py index 8627d1a..dc63eea 100755 --- a/calm/calm.py +++ b/calm/calm.py @@ -66,6 +66,7 @@ import time from .abeyance_handler import AbeyanceHandler from .buffering_smtp_handler import BufferingSMTPHandler +from .movelist import MoveList from . import common_constants from . import irk from . import maintainers @@ -116,7 +117,7 @@ def process_relarea(args): if stale_to_vault: for arch in common_constants.ARCHES + ['noarch', 'src']: logging.info("vaulting %d old package(s) for arch %s" % (len(stale_to_vault[arch]), arch)) - uploads.move_to_vault(args, stale_to_vault[arch]) + stale_to_vault[arch].move_to_vault(args) else: logging.error("error while evaluating stale packages") return None @@ -185,9 +186,7 @@ def process_uploads(args, state): break # remove files which are to be removed - for p in scan_result[arch].to_vault: - for f in scan_result[arch].to_vault[p]: - package.delete(merged_packages[arch], p, f) + scan_result[arch].to_vault.map(lambda p, f: package.delete(merged_packages[arch], p, f)) # validate the package set logging.debug("validating merged %s package set for maintainer %s" % (arch, name)) @@ -231,18 +230,21 @@ def process_uploads(args, state): # process the move lists if scan_result[arch].to_vault: logging.info("vaulting %d package(s) for arch %s, by request" % (len(scan_result[arch].to_vault), arch)) - uploads.move_to_vault(args, scan_result[arch].to_vault) + scan_result[arch].to_vault.move_to_vault(args) uploads.remove(args, scan_result[arch].remove_success) if scan_result[arch].to_relarea: logging.info("adding %d package(s) for arch %s" % (len(scan_result[arch].to_relarea), arch)) - uploads.move_to_relarea(m, args, scan_result[arch].to_relarea) + scan_result[arch].to_relarea.move_to_relarea(m, args) + # XXX: Note that there seems to be a separate process, not run + # from cygwin-admin's crontab, which changes the ownership of + # files in the release area to cyguser:cygwin # for each arch if args.stale: for arch in common_constants.ARCHES + ['noarch', 'src']: if stale_to_vault[arch]: logging.info("vaulting %d old package(s) for arch %s" % (len(stale_to_vault[arch]), arch)) - uploads.move_to_vault(args, stale_to_vault[arch]) + stale_to_vault[arch].move_to_vault(args) # for each arch for arch in common_constants.ARCHES: @@ -288,8 +290,8 @@ def process(args, state): def remove_stale_packages(args, packages): to_vault = {} - to_vault['noarch'] = defaultdict(list) - to_vault['src'] = defaultdict(list) + to_vault['noarch'] = MoveList() + to_vault['src'] = MoveList() for arch in common_constants.ARCHES: logging.debug("checking for stale packages for arch %s" % (arch)) @@ -298,9 +300,7 @@ def remove_stale_packages(args, packages): to_vault[arch] = package.stale_packages(packages[arch]) # remove stale packages from package set - for p in to_vault[arch]: - for f in to_vault[arch][p]: - package.delete(packages[arch], p, f) + to_vault[arch].map(lambda p, f: package.delete(packages[arch], p, f)) # if there are no stale packages, we don't have anything to do if not any([to_vault[a] for a in to_vault]): @@ -323,12 +323,19 @@ def remove_stale_packages(args, packages): # for each arch. # # de-duplicate these package moves, as rather awkward workaround for that - for path in list(to_vault[common_constants.ARCHES[0]]): + moved_list = [] + + def dedup(path, f): for prefix in ['noarch', 'src']: if path.startswith(prefix): - to_vault[prefix][path] = to_vault[common_constants.ARCHES[0]][path] - for arch in common_constants.ARCHES: - del to_vault[arch][path] + to_vault[prefix].add(path, f) + moved_list.append(path) + + to_vault[common_constants.ARCHES[0]].map(dedup) + + for path in moved_list: + for arch in common_constants.ARCHES: + to_vault[arch].remove(path) return to_vault @@ -340,11 +347,12 @@ def remove_stale_packages(args, packages): def report_movelist_conflicts(a, b, reason): conflicts = False - n = uploads.movelist_intersect(a, b) + n = MoveList.intersect(a, b) if n: - for p in n: - for f in n[p]: - logging.error("%s/%s is both uploaded and %s vaulted" % (p, f, reason)) + def report_conflict(p, f): + logging.error("%s/%s is both uploaded and %s vaulted" % (p, f, reason)) + + n.map(report_conflict) conflicts = True return conflicts diff --git a/calm/movelist.py b/calm/movelist.py new file mode 100644 index 0000000..8fb682a --- /dev/null +++ b/calm/movelist.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2015 Jon Turney +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# + +import logging +import os + +from collections import defaultdict + + +# +# movelist class +# + +class MoveList(object): + def __init__(self): + # a movelist is a dict with relative directory paths for keys and a list + # of filenames for each value + self.movelist = defaultdict(list) + + def __len__(self): + return len(self.movelist) + + def __bool__(self): + # empty movelists are false + return len(self.movelist) > 0 + + def add(self, relpath, f): + self.movelist[relpath].append(f) + + def remove(self, relpath): + del self.movelist[relpath] + + def _move(self, args, fromdir, todir): + for p in sorted(self.movelist): + logging.debug("mkdir %s" % os.path.join(todir, p)) + if not args.dryrun: + try: + os.makedirs(os.path.join(todir, p), exist_ok=True) + except FileExistsError: + pass + logging.debug("move from '%s' to '%s':" % (os.path.join(fromdir, p), os.path.join(todir, p))) + for f in sorted(self.movelist[p]): + if os.path.exists(os.path.join(fromdir, p, f)): + logging.info("%s" % os.path.join(p, f)) + if not args.dryrun: + os.rename(os.path.join(fromdir, p, f), os.path.join(todir, p, f)) + else: + logging.error("%s can't be moved as it doesn't exist" % (f)) + + def move_to_relarea(self, m, args): + if self.movelist: + logging.info("move from %s's upload area to release area:" % (m.name)) + self._move(args, m.homedir(), args.rel_area) + + def move_to_vault(self, args): + if self.movelist: + logging.info("move from release area to vault:") + self._move(args, args.rel_area, args.vault) + + # apply a function to all files in the movelists + def map(self, function): + for p in self.movelist: + for f in self.movelist[p]: + function(p, f) + + # compute the intersection of a pair of movelists + @staticmethod + def intersect(a, b): + i = MoveList() + for p in a.movelist.keys() & b.movelist.keys(): + pi = set(a.movelist[p]) & set(b.movelist[p]) + if pi: + i.movelist[p] = pi + return i + + # copy the files in a movelist + def copy(args, fromdir, todir): + for p in sorted(self.movelist): + logging.debug("mkdir %s" % os.path.join(todir, p)) + if not args.dryrun: + try: + os.makedirs(os.path.join(todir, p), exist_ok=True) + except FileExistsError: + pass + logging.debug("copy from '%s' to '%s':" % (os.path.join(fromdir, p), os.path.join(todir, p))) + for f in sorted(self.movelist[p]): + if os.path.exists(os.path.join(fromdir, p, f)): + logging.debug("%s" % (f)) + if not args.dryrun: + shutil.copy2(os.path.join(fromdir, p, f), os.path.join(todir, p, f)) + else: + logging.error("%s can't be copied as it doesn't exist" % (f)) diff --git a/calm/package.py b/calm/package.py index 76ddb85..838e4fc 100755 --- a/calm/package.py +++ b/calm/package.py @@ -39,6 +39,7 @@ import textwrap import time from .version import SetupVersion +from .movelist import MoveList from . import common_constants from . import hint from . import maintainers @@ -48,9 +49,9 @@ from . import past_mistakes # information we keep about a package class Package(object): def __init__(self): - self.path = '' # path to package, relative to release area + self.pkgpath = '' # path to package, relative to arch self.tars = {} - self.hint_files = {} + self.hints = {} self.is_used_by = set() self.version_hints = {} self.override_hints = {} @@ -59,7 +60,7 @@ class Package(object): def __repr__(self): return "Package('%s', %s, %s, %s, %s)" % ( - self.path, + self.pkgpath, pprint.pformat(self.tars), pprint.pformat(self.version_hints), pprint.pformat(self.override_hints), @@ -72,32 +73,44 @@ class Package(object): # information we keep about a tar file class Tar(object): def __init__(self): + self.path = None # path to tar, relative to release area + self.fn = None # filename self.sha512 = '' self.size = 0 self.is_empty = False self.is_used = False def __repr__(self): - return "Tar('%s', %d, %s)" % (self.sha512, self.size, self.is_empty) + return "Tar('%s', '%s', '%s', %d, %s)" % (self.fn, self.path, self.sha512, self.size, self.is_empty) + + +# information we keep about a hint file +class Hint(object): + def __init__(self): + self.path = None # path to hint, relative to release area + self.fn = None # filename of hint + self.hints = {} # XXX: duplicates version_hints, for the moment # # read a packages from a directory hierarchy # def read_packages(rel_area, arch): - packages = defaultdict(Package) + packages = {} # / noarch/ and src/ directories are considered for root in ['noarch', 'src', arch]: + packages[root] = defaultdict(Package) + releasedir = os.path.join(rel_area, root) logging.debug('reading packages from %s' % releasedir) for (dirpath, subdirs, files) in os.walk(releasedir, followlinks=True): - read_package(packages, rel_area, dirpath, files) + read_package(packages[root], rel_area, dirpath, files) - logging.debug("%d packages read" % len(packages)) + logging.debug("%d packages read" % len(packages[root])) - return packages + return merge({}, *packages.values()) # helper function to compute sha512 for a particular file @@ -175,9 +188,10 @@ def read_package(packages, basedir, dirpath, files, remove=[]): return True # check for duplicate package names at different paths + (_, _, pkgpath) = relpath.split(os.sep, 2) if p in packages: logging.error("duplicate package name at paths %s and %s" % - (dirpath, packages[p].path)) + (relpath, packages[p].pkgpath)) return True # determine version overrides @@ -267,6 +281,8 @@ def read_package(packages, basedir, dirpath, files, remove=[]): if not f.endswith('.hint'): # collect the attributes for each tar file t = Tar() + t.path = relpath + t.fn = f t.size = os.path.getsize(os.path.join(dirpath, f)) t.is_empty = tarfile_is_empty(os.path.join(dirpath, f)) t.mtime = os.path.getmtime(os.path.join(dirpath, f)) @@ -281,7 +297,7 @@ def read_package(packages, basedir, dirpath, files, remove=[]): # determine hints for each version we've encountered version_hints = {} - hint_files = {} + hints = {} actual_tars = {} for vr in vr_list: hint_fn = '%s-%s.hint' % (p, vr) @@ -304,8 +320,13 @@ def read_package(packages, basedir, dirpath, files, remove=[]): else: ovr = vr + hintobj = Hint() + hintobj.path = relpath + hintobj.fn = hint_fn + hintobj.hints = pvr_hint + version_hints[ovr] = pvr_hint - hint_files[ovr] = hint_fn + hints[ovr] = hintobj actual_tars[ovr] = tars[vr] # ignore dotfiles @@ -322,8 +343,8 @@ def read_package(packages, basedir, dirpath, files, remove=[]): packages[p].version_hints = version_hints packages[p].override_hints = override_hints packages[p].tars = actual_tars - packages[p].hint_files = hint_files - packages[p].path = relpath + packages[p].hints = hints + packages[p].pkgpath = pkgpath packages[p].skip = any(['skip' in version_hints[vr] for vr in version_hints]) elif (relpath.count(os.path.sep) > 1): @@ -391,6 +412,9 @@ def sort_key(k): def validate_packages(args, packages): error = False + if not packages: + return False + for p in sorted(packages.keys()): logging.log(5, "validating package '%s'" % (p)) has_requires = False @@ -789,7 +813,7 @@ def validate_package_maintainers(args, packages): # ignore obsolete packages if any(['_obsolete' in packages[p].version_hints[vr].get('category', '') for vr in packages[p].version_hints]): continue - if not is_in_package_list(packages[p].path, all_packages): + if not is_in_package_list(packages[p].pkgpath, all_packages): logging.error("package '%s' is not in the package list" % (p)) error = True @@ -975,10 +999,10 @@ def write_setup_ini(args, packages, arch): # helper function to output details for a particular tar file def tar_line(p, category, v, f): - t = p.vermap[v][category] - fn = os.path.join(p.path, t) - sha512 = p.tar(v, category).sha512 - size = p.tar(v, category).size + to = p.tar(v, category) + fn = os.path.join(to.path, to.fn) + sha512 = to.sha512 + size = to.size print("%s: %s %d %s" % (category, fn, size, sha512), file=f) @@ -1054,10 +1078,6 @@ def write_repo_json(args, packages, f): # - we combine the list of tarfiles, duplicates are not permitted # - we use the hints from b, and warn if they are different to the hints for a # -# (XXX: this implementation possibly assumes that a package is at most in a and -# one of b, which is currently true, but it could be written with more -# generality) -# def merge(a, *l): # start with a copy of a c = copy.deepcopy(a) @@ -1065,13 +1085,13 @@ def merge(a, *l): for b in l: for p in b: # if the package is in b but not in a, add it to the copy - if p not in a: + if p not in c: c[p] = b[p] # else, if the package is both in a and b, we have to do a merge else: # package must exist at same relative path - if a[p].path != b[p].path: - logging.error("package '%s' is at paths %s and %s" % (p, a[p].path, b[p].path)) + if c[p].pkgpath != b[p].pkgpath: + logging.error("package '%s' is at paths %s and %s" % (p, c[p].path, b[p].path)) return None else: for vr in b[p].tars: @@ -1087,13 +1107,12 @@ def merge(a, *l): # hints from b override hints from a, but warn if they have # changed - c[p].version_hints = a[p].version_hints for vr in b[p].version_hints: c[p].version_hints[vr] = b[p].version_hints[vr] - if vr in a[p].version_hints: - if a[p].version_hints[vr] != b[p].version_hints[vr]: + if vr in c[p].version_hints: + if c[p].version_hints[vr] != b[p].version_hints[vr]: diff = '\n'.join(difflib.ndiff( - pprint.pformat(a[p].version_hints[vr]).splitlines(), + pprint.pformat(c[p].version_hints[vr]).splitlines(), pprint.pformat(b[p].version_hints[vr]).splitlines())) logging.warning("package '%s' version '%s' hints changed\n%s" % (p, vr, diff)) @@ -1102,10 +1121,10 @@ def merge(a, *l): c[p].override_hints.update(b[p].override_hints) # merge hint file lists - c[p].hint_files.update(b[p].hint_files) + c[p].hints.update(b[p].hints) # skip if both a and b are skip - c[p].skip = a[p].skip and b[p].skip + c[p].skip = c[p].skip and b[p].skip return c @@ -1115,8 +1134,9 @@ def merge(a, *l): # def delete(packages, path, fn): + (_, _, pkgpath) = path.split(os.sep, 2) for p in packages: - if packages[p].path == path: + if packages[p].pkgpath == pkgpath: for vr in packages[p].tars: for t in packages[p].tars[vr]: if t == fn: @@ -1127,14 +1147,14 @@ def delete(packages, path, fn): if not packages[p].tars[vr]: packages[p].vermap.pop(vr, None) - for h in packages[p].hint_files: - if packages[p].hint_files[h] == fn: - del packages[p].hint_files[h] + for h in packages[p].hints: + if packages[p].hints[h].fn == fn: + del packages[p].hints[h] break # -# verify that the package ppath is in the list of packages plist +# verify that the package path starts with a package in the list of packages # # (This means that a maintainer can upload a package with any name, provided the # path contains one allowed for that maintainer) @@ -1147,20 +1167,10 @@ def delete(packages, path, fn): # arbitrary package upload. # -def package_list_re(plist): - if getattr(package_list_re, "_plist", []) != plist: - pattern = '|'.join(map(lambda p: r'/' + re.escape(p) + r'(?:/|$)', plist)) - package_list_re._regex = re.compile(pattern, re.IGNORECASE) - package_list_re._plist = plist - - return package_list_re._regex - def is_in_package_list(ppath, plist): - if package_list_re(plist).search(ppath): - return True - - return False + superpackage = ppath.split(os.sep, 1)[0] + return superpackage in plist # @@ -1231,14 +1241,15 @@ def stale_packages(packages): mark_package_fresh(packages, pn, v) # build a move list of stale versions - stale = defaultdict(list) + stale = MoveList() for pn, po in packages.items(): for v in sorted(po.vermap.keys(), key=lambda v: SetupVersion(v)): all_stale = True for category in ['source', 'install']: if category in po.vermap[v]: if not getattr(po.tar(v, category), 'fresh', False): - stale[po.path].append(po.vermap[v][category]) + to = po.tar(v, category) + stale.add(to.path, to.fn) logging.debug("package '%s' version '%s' %s is stale" % (pn, v, category)) else: all_stale = False @@ -1246,8 +1257,8 @@ def stale_packages(packages): # if there's a pvr.hint without a fresh source or install of the # same version, move it as well if all_stale: - if v in po.hint_files: - stale[po.path].append(po.hint_files[v]) + if v in po.hints: + stale.add(po.hints[v].path, po.hints[v].fn) logging.debug("package '%s' version '%s' hint is stale" % (pn, v)) # clean up freshness mark diff --git a/calm/pkg2html.py b/calm/pkg2html.py index c12d811..9370092 100755 --- a/calm/pkg2html.py +++ b/calm/pkg2html.py @@ -333,8 +333,8 @@ def write_arch_listing(args, packages, arch): listings = os.listdir(dir) listings.remove('.htaccess') - for t in itertools.chain.from_iterable([packages[p].tars[vr] for vr in packages[p].tars]): - fver = re.sub(r'\.tar.*$', '', t) + for tn, to in itertools.chain.from_iterable([packages[p].tars[vr].items() for vr in packages[p].tars]): + fver = re.sub(r'\.tar.*$', '', tn) listing = os.path.join(dir, fver) # ... if it doesn't already exist, or force @@ -365,7 +365,7 @@ def write_arch_listing(args, packages, arch):

%s

''' % (header, header)), file=f)
 
-                        tf = os.path.join(args.rel_area, packages[p].path, t)
+                        tf = os.path.join(args.rel_area, to.path, to.fn)
                         if not os.path.exists(tf):
                             # this shouldn't happen with a full mirror
                             logging.error("tarfile %s not found" % (tf))
diff --git a/calm/uploads.py b/calm/uploads.py
index 5b765cb..587aa9b 100644
--- a/calm/uploads.py
+++ b/calm/uploads.py
@@ -34,6 +34,7 @@ import shutil
 import tarfile
 import time
 
+from .movelist import MoveList
 from . import common_constants
 from . import package
 
@@ -54,8 +55,8 @@ def scan(m, all_packages, arch, args):
     basedir = os.path.join(m.homedir(), arch)
 
     packages = defaultdict(package.Package)
-    move = defaultdict(list)
-    vault = defaultdict(list)
+    move = MoveList()
+    vault = MoveList()
     remove = []
     remove_success = []
     error = False
@@ -129,12 +130,13 @@ def scan(m, all_packages, arch, args):
             continue
 
         # package doesn't appear in package list at all
-        if not package.is_in_package_list(relpath, all_packages):
+        (_, _, pkgpath) = relpath.split(os.sep, 2)
+        if not package.is_in_package_list(pkgpath, all_packages):
             logging.error("package '%s' is not in the package list" % dirpath)
             continue
 
         # only process packages for which we are listed as a maintainer
-        if not package.is_in_package_list(relpath, m.pkgs):
+        if not package.is_in_package_list(pkgpath, m.pkgs):
             logging.warning("package '%s' is not in the package list for maintainer %s" % (dirpath, m.name))
             continue
 
@@ -196,7 +198,7 @@ def scan(m, all_packages, arch, args):
                     logging.error("remove file %s is not empty" % fn)
                     error = True
                 else:
-                    vault[relpath].append(f[1:])
+                    vault.add(relpath, f[1:])
                     remove_success.append(fn)
                     removed_files.append(f[1:])
                 files.remove(f)
@@ -249,9 +251,9 @@ def scan(m, all_packages, arch, args):
                         logging.debug("different %s is already in release area" % fn)
                     # we always consider .hint files as needing to be moved, as
                     # we currently can't have a valid package without one
-                    move[relpath].append(f)
+                    move.add(relpath, f)
             else:
-                move[relpath].append(f)
+                move.add(relpath, f)
 
         # read and validate package
         if files:
@@ -286,72 +288,3 @@ def remove(args, remove):
                 os.unlink(f)
             except FileNotFoundError:
                 logging.error("%s can't be deleted as it doesn't exist" % (f))
-
-
-#
-#
-#
-
-def move(args, movelist, fromdir, todir):
-    for p in sorted(movelist):
-        logging.debug("mkdir %s" % os.path.join(todir, p))
-        if not args.dryrun:
-            try:
-                os.makedirs(os.path.join(todir, p), exist_ok=True)
-            except FileExistsError:
-                pass
-        logging.debug("move from '%s' to '%s':" % (os.path.join(fromdir, p), os.path.join(todir, p)))
-        for f in sorted(movelist[p]):
-            if os.path.exists(os.path.join(fromdir, p, f)):
-                logging.info("%s" % os.path.join(p, f))
-                if not args.dryrun:
-                    os.rename(os.path.join(fromdir, p, f), os.path.join(todir, p, f))
-            else:
-                logging.error("%s can't be moved as it doesn't exist" % (f))
-
-
-def move_to_relarea(m, args, movelist):
-    if movelist:
-        logging.info("move from %s's upload area to release area:" % (m.name))
-    move(args, movelist, m.homedir(), args.rel_area)
-    # XXX: Note that there seems to be a separate process, not run from
-    # cygwin-admin's crontab, which changes the ownership of files in the
-    # release area to cyguser:cygwin
-
-
-def move_to_vault(args, movelist):
-    if movelist:
-        logging.info("move from release area to vault:")
-    move(args, movelist, args.rel_area, args.vault)
-
-
-# compute the intersection of a pair of movelists
-def movelist_intersect(a, b):
-    i = defaultdict(list)
-    for p in a.keys() & b.keys():
-        pi = set(a[p]) & set(b[p])
-        if pi:
-            i[p] = pi
-    return i
-
-
-#
-#
-#
-
-def copy(args, movelist, fromdir, todir):
-    for p in sorted(movelist):
-        logging.debug("mkdir %s" % os.path.join(todir, p))
-        if not args.dryrun:
-            try:
-                os.makedirs(os.path.join(todir, p), exist_ok=True)
-            except FileExistsError:
-                pass
-        logging.debug("copy from '%s' to '%s':" % (os.path.join(fromdir, p), os.path.join(todir, p)))
-        for f in sorted(movelist[p]):
-            if os.path.exists(os.path.join(fromdir, p, f)):
-                logging.debug("%s" % (f))
-                if not args.dryrun:
-                    shutil.copy2(os.path.join(fromdir, p, f), os.path.join(todir, p, f))
-            else:
-                logging.error("%s can't be copied as it doesn't exist" % (f))
diff --git a/test/test_calm.py b/test/test_calm.py
index a44517b..569c81c 100755
--- a/test/test_calm.py
+++ b/test/test_calm.py
@@ -333,8 +333,8 @@ class CalmTest(unittest.TestCase):
         shutil.rmtree(test_root)
 
         self.assertEqual(scan_result.error, False)
-        compare_with_expected_file(self, 'testdata/uploads', dict(scan_result.to_relarea), 'move')
-        self.assertCountEqual(scan_result.to_vault, {'x86/release/testpackage': ['x86/release/testpackage/testpackage-0.1-1.tar.bz2']})
+        compare_with_expected_file(self, 'testdata/uploads', dict(scan_result.to_relarea.movelist), 'move')
+        self.assertCountEqual(scan_result.to_vault.movelist, {'x86/release/testpackage': ['x86/release/testpackage/testpackage-0.1-1.tar.bz2']})
         self.assertCountEqual(scan_result.remove_always, [f for (f, t) in ready_fns])
         self.assertEqual(scan_result.remove_success, ['testdata/homes/Blooey McFooey/x86/release/testpackage/-testpackage-0.1-1-src.tar.bz2', 'testdata/homes/Blooey McFooey/x86/release/testpackage/-testpackage-0.1-1.tar.bz2'])
         with pprint_patch():
@@ -366,6 +366,37 @@ class CalmTest(unittest.TestCase):
 
         # XXX: delete a needed package, and check validate fails
 
+    def test_process_uploads_conflict(self):
+        args = types.SimpleNamespace()
+
+        for d in ['rel_area', 'homedir', 'vault']:
+            setattr(args, d, tempfile.mktemp())
+            logging.info('%s = %s', d, getattr(args, d))
+
+        shutil.copytree('testdata/relarea', getattr(args, 'rel_area'))
+        shutil.copytree('testdata/homes.conflict', getattr(args, 'homedir'))
+
+        setattr(args, 'dryrun', False)
+        setattr(args, 'email', None)
+        setattr(args, 'force', False)
+        setattr(args, 'pkglist', 'testdata/pkglist/cygwin-pkg-maint')
+        setattr(args, 'stale', True)
+
+        # set appropriate !ready
+        m_homedir = os.path.join(getattr(args, 'homedir'), 'Blooey McFooey')
+        os.system('touch "%s"' % (os.path.join(m_homedir, 'x86', 'release', 'staleversion', '!ready')))
+
+        state = calm.calm.CalmState()
+        state.packages = calm.calm.process_relarea(args)
+        state.packages = calm.calm.process_uploads(args, state)
+        self.assertTrue(state.packages)
+
+        for d in ['rel_area', 'homedir', 'vault']:
+            with self.subTest(directory=d):
+                dirlist = capture_dirtree(getattr(args, d))
+                compare_with_expected_file(self, 'testdata/conflict', dirlist, d)
+                shutil.rmtree(getattr(args, d))
+
     def test_process(self):
         self.maxDiff = None
 
@@ -436,6 +467,7 @@ class CalmTest(unittest.TestCase):
         # (git doesn't store timestamps, so they will all be dated the time of checkout)
         relarea_x86 = os.path.join('testdata', 'relarea', 'x86', 'release')
         relarea_noarch = os.path.join('testdata', 'relarea', 'noarch', 'release')
+        home_conflict = os.path.join('testdata', 'homes.conflict', 'Blooey McFooey', 'x86', 'release')
         touches = [(os.path.join(relarea_x86, 'cygwin', 'cygwin-2.2.0-1.tar.xz'), '2016-11-01'),
                    (os.path.join(relarea_x86, 'cygwin', 'cygwin-2.2.0-1-src.tar.xz'), '2016-11-01'),
                    (os.path.join(relarea_x86, 'cygwin', 'cygwin-2.2.1-1.tar.xz'), '2016-11-02'),
@@ -464,9 +496,12 @@ class CalmTest(unittest.TestCase):
                    (os.path.join(relarea_x86, 'keychain', 'keychain-2.6.8-1.tar.bz2'), '2016-11-02'),
                    (os.path.join(relarea_x86, 'keychain', 'keychain-2.6.8-1-src.tar.bz2'), '2016-11-02'),
                    (os.path.join(relarea_noarch, 'perl-Net-SMTP-SSL', 'perl-Net-SMTP-SSL-1.03-1.tar.xz'), '2016-11-01'),
-                   (os.path.join(relarea_noarch, 'perl-Net-SMTP-SSL', 'perl-Net-SMTP-SSL-1.03-1-src.tar.xz'), '2016-11-01')]
+                   (os.path.join(relarea_noarch, 'perl-Net-SMTP-SSL', 'perl-Net-SMTP-SSL-1.03-1-src.tar.xz'), '2016-11-01'),
+                   (os.path.join(home_conflict, 'staleversion', 'staleversion-230-1.hint'), '2017-04-06'),
+                   (os.path.join(home_conflict, 'staleversion', 'staleversion-230-1.tar.xz'), '2017-04-06'),
+                   (os.path.join(home_conflict, 'staleversion', 'staleversion-230-1-src.tar.xz'), '2017-04-06')]
         for (f, t) in touches:
-            os.system('touch %s -d %s' % (f, t))
+            os.system('touch "%s" -d %s' % (f, t))
 
         # ensure !reminder-timestamp is created for uploads
         home = os.path.join('testdata', 'homes', 'Blooey McFooey')
diff --git a/test/testdata/conflict/homedir.expected b/test/testdata/conflict/homedir.expected
new file mode 100644
index 0000000..729c6b9
--- /dev/null
+++ b/test/testdata/conflict/homedir.expected
@@ -0,0 +1,7 @@
+{'.': [],
+ 'Blooey McFooey': [],
+ 'Blooey McFooey/x86': [],
+ 'Blooey McFooey/x86/release': [],
+ 'Blooey McFooey/x86/release/staleversion': ['staleversion-230-1-src.tar.xz',
+                                             'staleversion-230-1.hint',
+                                             'staleversion-230-1.tar.xz']}
diff --git a/test/testdata/conflict/rel_area.expected b/test/testdata/conflict/rel_area.expected
new file mode 100644
index 0000000..a23f44d
--- /dev/null
+++ b/test/testdata/conflict/rel_area.expected
@@ -0,0 +1,124 @@
+{'.': [],
+ 'noarch': ['sha512.sum'],
+ 'noarch/release': ['sha512.sum'],
+ 'noarch/release/obs-a': ['obs-a-1.0-1-src.tar.xz', 'obs-a-1.0-1.hint', 'obs-a-1.0-1.tar.xz', 'sha512.sum'],
+ 'noarch/release/obs-b': ['obs-b-1.0-1-src.tar.xz', 'obs-b-1.0-1.hint', 'obs-b-1.0-1.tar.xz', 'sha512.sum'],
+ 'noarch/release/perl-Net-SMTP-SSL': ['perl-Net-SMTP-SSL-1.03-1-src.tar.xz',
+                                      'perl-Net-SMTP-SSL-1.03-1.hint',
+                                      'perl-Net-SMTP-SSL-1.03-1.tar.xz',
+                                      'sha512.sum'],
+ 'noarch/release/test-c': ['sha512.sum', 'test-c-1.0-1-src.tar.xz', 'test-c-1.0-1.hint', 'test-c-1.0-1.tar.xz'],
+ 'noarch/release/test-d': ['sha512.sum', 'test-d-1.0-1-src.tar.xz', 'test-d-1.0-1.hint', 'test-d-1.0-1.tar.xz'],
+ 'noarch/release/test-e': ['sha512.sum', 'test-e-1.0-1-src.tar.xz', 'test-e-1.0-1.hint', 'test-e-1.0-1.tar.xz'],
+ 'x86': ['sha512.sum'],
+ 'x86/release': ['sha512.sum'],
+ 'x86/release/arc': ['arc-4.32.7-10-src.tar.bz2', 'arc-4.32.7-10.hint', 'arc-4.32.7-10.tar.bz2'],
+ 'x86/release/base-cygwin': ['base-cygwin-3.6-1.hint',
+                             'base-cygwin-3.6-1.tar.xz',
+                             'base-cygwin-3.8-1.hint',
+                             'base-cygwin-3.8-1.tar.xz',
+                             'sha512.sum'],
+ 'x86/release/corrupt': ['corrupt-2.0.0-1-src.tar.xz', 'corrupt-2.0.0-1.hint', 'corrupt-2.0.0-1.tar.xz', 'sha512.sum'],
+ 'x86/release/cygwin': ['.this-should-be-ignored',
+                        'cygwin-2.2.0-1-src.tar.xz',
+                        'cygwin-2.2.0-1.hint',
+                        'cygwin-2.2.0-1.tar.xz',
+                        'cygwin-2.2.1-1-src.tar.xz',
+                        'cygwin-2.2.1-1.hint',
+                        'cygwin-2.2.1-1.tar.xz',
+                        'cygwin-2.3.0-0.3-src.tar.xz',
+                        'cygwin-2.3.0-0.3.hint',
+                        'cygwin-2.3.0-0.3.tar.xz',
+                        'override.hint',
+                        'sha512.sum'],
+ 'x86/release/cygwin/cygwin-debuginfo': ['cygwin-debuginfo-2.2.0-1.hint',
+                                         'cygwin-debuginfo-2.2.0-1.tar.xz',
+                                         'cygwin-debuginfo-2.2.1-1.hint',
+                                         'cygwin-debuginfo-2.2.1-1.tar.xz',
+                                         'cygwin-debuginfo-2.3.0-0.3.hint',
+                                         'cygwin-debuginfo-2.3.0-0.3.tar.xz',
+                                         'override.hint',
+                                         'sha512.sum'],
+ 'x86/release/cygwin/cygwin-devel': ['cygwin-devel-2.2.0-1.hint',
+                                     'cygwin-devel-2.2.0-1.tar.xz',
+                                     'cygwin-devel-2.2.1-1.hint',
+                                     'cygwin-devel-2.2.1-1.tar.xz',
+                                     'cygwin-devel-2.3.0-0.3.hint',
+                                     'cygwin-devel-2.3.0-0.3.tar.xz',
+                                     'override.hint',
+                                     'sha512.sum'],
+ 'x86/release/invalid': ['invalid-0.hint', 'sha512.sum'],
+ 'x86/release/keychain': ['keychain-2.6.8-1-src.tar.bz2',
+                          'keychain-2.6.8-1.hint',
+                          'keychain-2.6.8-1.tar.bz2',
+                          'keychain-2.7.1-1-src.tar.bz2',
+                          'keychain-2.7.1-1.hint',
+                          'keychain-2.7.1-1.tar.bz2',
+                          'sha512.sum'],
+ 'x86/release/libspiro': ['libspiro-20071029-1.hint', 'sha512.sum'],
+ 'x86/release/libspiro/libspiro-devel': ['libspiro-devel-20071029-1.hint', 'sha512.sum'],
+ 'x86/release/libspiro/libspiro0': ['libspiro0-20071029-1.hint', 'sha512.sum'],
+ 'x86/release/libtextcat': ['libtextcat-2.2-2-src.tar.bz2',
+                            'libtextcat-2.2-2.hint',
+                            'libtextcat-2.2-2.tar.bz2',
+                            'sha512.sum'],
+ 'x86/release/libtextcat/libtextcat-devel': ['libtextcat-devel-2.2-2.hint',
+                                             'libtextcat-devel-2.2-2.tar.bz2',
+                                             'sha512.sum'],
+ 'x86/release/libtextcat/libtextcat0': ['libtextcat0-2.2-2.hint', 'libtextcat0-2.2-2.tar.bz2', 'sha512.sum'],
+ 'x86/release/mDNSResponder': ['mDNSResponder-379.32.1-1-src.tar.bz2',
+                               'mDNSResponder-379.32.1-1.hint',
+                               'mDNSResponder-379.32.1-1.tar.bz2',
+                               'sha512.sum'],
+ 'x86/release/mDNSResponder/libdns_sd-devel': ['libdns_sd-devel-379.32.1-1.hint',
+                                               'libdns_sd-devel-379.32.1-1.tar.bz2',
+                                               'sha512.sum'],
+ 'x86/release/mDNSResponder/libdns_sd1': ['libdns_sd1-379.32.1-1.hint', 'libdns_sd1-379.32.1-1.tar.bz2', 'sha512.sum'],
+ 'x86/release/mingw64-i686-binutils': ['mingw64-i686-binutils-2.29.1.787c9873-1.hint', 'sha512.sum'],
+ 'x86/release/mingw64-i686-binutils/mingw64-i686-binutils-debuginfo': ['mingw64-i686-binutils-debuginfo-2.29.1.787c9873-1.hint',
+                                                                       'sha512.sum'],
+ 'x86/release/openssh': ['openssh-7.2p2-1-src.tar.xz', 'openssh-7.2p2-1.hint', 'openssh-7.2p2-1.tar.xz', 'sha512.sum'],
+ 'x86/release/per-version': ['override.hint',
+                             'per-version-4.0-1-src.tar.xz',
+                             'per-version-4.0-1.hint',
+                             'per-version-4.0-1.tar.xz',
+                             'per-version-4.8-1-src.tar.xz',
+                             'per-version-4.8-1.hint',
+                             'per-version-4.8-1.tar.xz',
+                             'sha512.sum'],
+ 'x86/release/per-version-incomplete': ['override.hint',
+                                        'per-version-incomplete-36-1-src.tar.xz',
+                                        'per-version-incomplete-36-1.hint',
+                                        'per-version-incomplete-36-1.tar.xz',
+                                        'per-version-incomplete-39-1-src.tar.xz',
+                                        'per-version-incomplete-39-1.tar.xz',
+                                        'sha512.sum'],
+ 'x86/release/per-version-replacement-hint-only': ['per-version-replacement-hint-only-1.0-1-src.tar.xz',
+                                                   'per-version-replacement-hint-only-1.0-1.hint',
+                                                   'per-version-replacement-hint-only-1.0-1.tar.xz',
+                                                   'sha512.sum'],
+ 'x86/release/proj': ['proj-4.8.0-1.hint', 'sha512.sum'],
+ 'x86/release/proj/libproj-devel': ['libproj-devel-4.8.0-1.hint', 'sha512.sum'],
+ 'x86/release/proj/libproj1': ['libproj1-4.8.0-1.hint', 'sha512.sum'],
+ 'x86/release/rpm-doc': ['rpm-doc-4.1-2-src.tar.bz2',
+                         'rpm-doc-4.1-2.hint',
+                         'rpm-doc-4.1-2.tar.bz2',
+                         'rpm-doc-999-1.hint',
+                         'rpm-doc-999-1.tar.bz2',
+                         'sha512.sum'],
+ 'x86/release/splint': ['sha512.sum', 'splint-3.1.2-1.hint'],
+ 'x86/release/staleversion': ['override.hint',
+                              'sha512.sum',
+                              'staleversion-243-0-src.tar.xz',
+                              'staleversion-243-0.hint',
+                              'staleversion-243-0.tar.xz',
+                              'staleversion-250-0-src.tar.xz',
+                              'staleversion-250-0.hint',
+                              'staleversion-250-0.tar.xz',
+                              'staleversion-260-0-src.tar.xz',
+                              'staleversion-260-0.hint',
+                              'staleversion-260-0.tar.xz'],
+ 'x86/release/testpackage': ['sha512.sum',
+                             'testpackage-0.1-1-src.tar.bz2',
+                             'testpackage-0.1-1.hint',
+                             'testpackage-0.1-1.tar.bz2']}
diff --git a/test/testdata/conflict/vault.expected b/test/testdata/conflict/vault.expected
new file mode 100644
index 0000000..6f418e2
--- /dev/null
+++ b/test/testdata/conflict/vault.expected
@@ -0,0 +1,12 @@
+{'.': [],
+ 'x86': [],
+ 'x86/release': [],
+ 'x86/release/staleversion': ['staleversion-240-1-src.tar.xz',
+                              'staleversion-240-1.hint',
+                              'staleversion-240-1.tar.xz',
+                              'staleversion-242-0-src.tar.xz',
+                              'staleversion-242-0.hint',
+                              'staleversion-242-0.tar.xz',
+                              'staleversion-251-0-src.tar.xz',
+                              'staleversion-251-0.hint',
+                              'staleversion-251-0.tar.xz']}
diff --git a/test/testdata/homes.conflict/Blooey McFooey/x86/release/staleversion/staleversion-230-1-src.tar.xz b/test/testdata/homes.conflict/Blooey McFooey/x86/release/staleversion/staleversion-230-1-src.tar.xz
new file mode 100644
index 0000000..0e6f1e8
Binary files /dev/null and b/test/testdata/homes.conflict/Blooey McFooey/x86/release/staleversion/staleversion-230-1-src.tar.xz differ
diff --git a/test/testdata/homes.conflict/Blooey McFooey/x86/release/staleversion/staleversion-230-1.hint b/test/testdata/homes.conflict/Blooey McFooey/x86/release/staleversion/staleversion-230-1.hint
new file mode 100644
index 0000000..7f7f48a
--- /dev/null
+++ b/test/testdata/homes.conflict/Blooey McFooey/x86/release/staleversion/staleversion-230-1.hint	
@@ -0,0 +1,4 @@
+sdesc: "Test package for stale version removal"
+ldesc: "Test package for stale version removal"
+category: Shells Base
+requires:
diff --git a/test/testdata/homes.conflict/Blooey McFooey/x86/release/staleversion/staleversion-230-1.tar.xz b/test/testdata/homes.conflict/Blooey McFooey/x86/release/staleversion/staleversion-230-1.tar.xz
new file mode 100644
index 0000000..0e6f1e8
Binary files /dev/null and b/test/testdata/homes.conflict/Blooey McFooey/x86/release/staleversion/staleversion-230-1.tar.xz differ
diff --git a/test/testdata/uploads/pkglist.expected b/test/testdata/uploads/pkglist.expected
index 8498ce9..a9d9bfd 100644
--- a/test/testdata/uploads/pkglist.expected
+++ b/test/testdata/uploads/pkglist.expected
@@ -1,5 +1,5 @@
-{'testpackage': Package('x86/release/testpackage', {'1.0-1': {'testpackage-1.0-1-src.tar.bz2': Tar('aff488008bee3486e25b539fe6ccd1397bd3c5c0ba2ee2cf34af279554baa195af7493ee51d6f8510735c9a2ea54436d776a71e768165716762aec286abbbf83', 195, False),
-           'testpackage-1.0-1.tar.bz2': Tar('aff488008bee3486e25b539fe6ccd1397bd3c5c0ba2ee2cf34af279554baa195af7493ee51d6f8510735c9a2ea54436d776a71e768165716762aec286abbbf83', 195, False)}}, {'1.0-1': {'sdesc': '"A test package"',
+{'testpackage': Package('testpackage', {'1.0-1': {'testpackage-1.0-1-src.tar.bz2': Tar('testpackage-1.0-1-src.tar.bz2', 'x86/release/testpackage', 'aff488008bee3486e25b539fe6ccd1397bd3c5c0ba2ee2cf34af279554baa195af7493ee51d6f8510735c9a2ea54436d776a71e768165716762aec286abbbf83', 195, False),
+           'testpackage-1.0-1.tar.bz2': Tar('testpackage-1.0-1.tar.bz2', 'x86/release/testpackage', 'aff488008bee3486e25b539fe6ccd1397bd3c5c0ba2ee2cf34af279554baa195af7493ee51d6f8510735c9a2ea54436d776a71e768165716762aec286abbbf83', 195, False)}}, {'1.0-1': {'sdesc': '"A test package"',
            'ldesc': '"A test package\n'
                     "It's description might contains some unicode junk\n"
                     'Like it’s you’re Markup Language™ Nokogiri’s tool―that '
@@ -7,10 +7,10 @@
            'category': 'Devel',
            'requires': 'cygwin',
            'depends': 'cygwin'}}, {}, False),
- 'testpackage-subpackage': Package('x86/release/testpackage/testpackage-subpackage', {'1.0-1': {'testpackage-subpackage-1.0-1.tar.bz2': Tar('aff488008bee3486e25b539fe6ccd1397bd3c5c0ba2ee2cf34af279554baa195af7493ee51d6f8510735c9a2ea54436d776a71e768165716762aec286abbbf83', 195, False)}}, {'1.0-1': {'sdesc': '"A test subpackage"',
+ 'testpackage-subpackage': Package('testpackage/testpackage-subpackage', {'1.0-1': {'testpackage-subpackage-1.0-1.tar.bz2': Tar('testpackage-subpackage-1.0-1.tar.bz2', 'x86/release/testpackage/testpackage-subpackage', 'aff488008bee3486e25b539fe6ccd1397bd3c5c0ba2ee2cf34af279554baa195af7493ee51d6f8510735c9a2ea54436d776a71e768165716762aec286abbbf83', 195, False)}}, {'1.0-1': {'sdesc': '"A test subpackage"',
            'ldesc': '"A test subpackage"',
            'category': 'Devel',
            'external-source': 'testpackage'}}, {}, False),
- 'testpackage2-subpackage': Package('x86/release/testpackage2/testpackage2-subpackage', {'1.0-1': {'testpackage2-subpackage-1.0-1.tar.bz2': Tar('6de201dfed1d45412509c65deb34690dc2d09c6aafccfe491fd2f440f92842b9c755b61dc7bcdd4cc0c9f18cf46c2b3a1241e99c4c2a33fff5555e7b2f0b6348', 14, True)}}, {'1.0-1': {'sdesc': '"A test subpackage 2"',
+ 'testpackage2-subpackage': Package('testpackage2/testpackage2-subpackage', {'1.0-1': {'testpackage2-subpackage-1.0-1.tar.bz2': Tar('testpackage2-subpackage-1.0-1.tar.bz2', 'x86/release/testpackage2/testpackage2-subpackage', '6de201dfed1d45412509c65deb34690dc2d09c6aafccfe491fd2f440f92842b9c755b61dc7bcdd4cc0c9f18cf46c2b3a1241e99c4c2a33fff5555e7b2f0b6348', 14, True)}}, {'1.0-1': {'sdesc': '"A test subpackage 2"',
            'ldesc': '"A test subpackage 2"',
            'category': 'Devel'}}, {}, False)}