← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/queue-api into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/queue-api into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1006173 in Launchpad itself: "Queue tool requires direct DB access"
  https://bugs.launchpad.net/launchpad/+bug/1006173

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/queue-api/+merge/108967

== Summary ==

Implement the next stage of https://code.launchpad.net/~cjwatson/launchpad/queue-api-accept-reject/+merge/107894, by exporting substantially more of PackageUpload.

== Proposed fix ==

Export enough of PackageUpload and DistroSeries.getPackageUploads to allow implementing an API client that replaces scripts/ftpmaster-tools/queue.

== Pre-implementation notes ==

I've gone round a few times with various people, particularly William Grant, on the exact way to export all of this stuff, because I gather that we want to avoid exposing the current data model in order that it can be rearranged in the future.  This has led to the following design choices:

 * Everything is on devel.  The only clients for this should be tools such as those in lp:ubuntu-archive-tools, which can be kept up to date if there's a need to change these interfaces.
 * Even though some of the underlying methods are on other objects, all the new exported methods are on PackageUpload rather than exporting anything else.
 * There are source packages with lots of binaries that sometimes need to be overridden individually (e.g. linux) and API requests aren't especially fast.  I've therefore arranged for properties (including overrides) of all binaries in an upload to come back as a list of dicts in a single JSON response, and I've amended Archive.overrideBinaries to take a similar list of dicts as a "changes" parameter, allowing many override changes to be made in a single request.

== LOC Rationale ==

+520, on top of a previous branch that was +91.  I think this is valid because this is part of an arc of work (resourced by Ubuntu Engineering) that will culminate in removing lib/lp/soyuz/scripts/queue.py and scripts/ftpmaster-tools/queue for at least -862.  While it's possible there'll be one or two more bits and pieces, they won't amount to any more than +251, so this whole arc will be LoC-negative.

== Tests ==

bin/test -vvct nascentupload-ddebs.txt -t distroseriesqueue.txt -t xx-packageupload.txt -t test_distroseriesqueue -t test_packageupload

== Demo and Q/A ==

http://paste.ubuntu.com/1026996/ is my prototype client; I plan to walk through all its functionality against qastaging (or dogfood if I need to make new uploads).
-- 
https://code.launchpad.net/~cjwatson/launchpad/queue-api/+merge/108967
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/queue-api into lp:launchpad.
=== modified file 'lib/lp/archiveuploader/tests/nascentupload-ddebs.txt'
--- lib/lp/archiveuploader/tests/nascentupload-ddebs.txt	2012-01-20 16:11:11 +0000
+++ lib/lp/archiveuploader/tests/nascentupload-ddebs.txt	2012-06-06 15:13:28 +0000
@@ -89,7 +89,8 @@
 
     >>> switch_dbuser('launchpad')
 
-    >>> bin.queue_root.overrideBinaries(main, devel, None, [main, universe])
+    >>> bin.queue_root.overrideBinaries(
+    ...     [{"component": main, "section": devel}], [main, universe])
     True
     >>> bin.queue_root.acceptFromQueue()
 

=== modified file 'lib/lp/registry/interfaces/distroseries.py'
--- lib/lp/registry/interfaces/distroseries.py	2012-01-10 09:55:24 +0000
+++ lib/lp/registry/interfaces/distroseries.py	2012-06-06 15:13:28 +0000
@@ -547,6 +547,13 @@
             description=_("Return only items with custom files of this "
                           "type."),
             required=False),
+        name=TextLine(title=_("Package or file name"), required=False),
+        version=TextLine(title=_("Package version"), required=False),
+        exact_match=Bool(
+            title=_("Exact match"),
+            description=_("Whether to filter name and version by exact "
+                          "matching."),
+            required=False),
         )
     # Really IPackageUpload, patched in _schema_circular_imports.py
     @operation_returns_collection_of(Interface)

=== modified file 'lib/lp/soyuz/browser/queue.py'
--- lib/lp/soyuz/browser/queue.py	2012-01-01 02:58:52 +0000
+++ lib/lp/soyuz/browser/queue.py	2012-06-06 15:13:28 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Browser views for package queue."""
@@ -385,9 +385,13 @@
             try:
                 source_overridden = queue_item.overrideSource(
                     new_component, new_section, allowed_components)
+                binary_changes = [{
+                    "component": new_component,
+                    "section": new_section,
+                    "priority": new_priority,
+                    }]
                 binary_overridden = queue_item.overrideBinaries(
-                    new_component, new_section, new_priority,
-                    allowed_components)
+                    binary_changes, allowed_components)
             except QueueInconsistentStateError, info:
                 failure.append("FAILED: %s (%s)" %
                                (queue_item.displayname, info))

=== modified file 'lib/lp/soyuz/configure.zcml'
--- lib/lp/soyuz/configure.zcml	2012-05-21 17:29:19 +0000
+++ lib/lp/soyuz/configure.zcml	2012-06-06 15:13:28 +0000
@@ -157,10 +157,12 @@
                 distroseries
                 pocket
                 changesfile
+                changes_file_url
                 signing_key
                 archive
                 sources
                 builds
+                binaries
                 customfiles
                 custom_file_urls
                 date_created
@@ -169,6 +171,7 @@
                 concrete_package_copy_job
                 contains_source
                 contains_build
+                contains_copy
                 contains_translation
                 contains_installer
                 contains_upgrader
@@ -183,7 +186,9 @@
                 package_name
                 package_version
                 section_name
-                components"/>
+                components
+                source_file_urls
+                binary_file_urls"/>
         <require
             permission="launchpad.Edit"
             attributes="

=== modified file 'lib/lp/soyuz/doc/distroseriesqueue.txt'
--- lib/lp/soyuz/doc/distroseriesqueue.txt	2012-01-06 11:08:30 +0000
+++ lib/lp/soyuz/doc/distroseriesqueue.txt	2012-06-06 15:13:28 +0000
@@ -648,7 +648,7 @@
 In addition to these parameters, you must also supply
 "allowed_components", which is a sequence of IComponent.  Any overrides
 must have the existing and new component in this sequence otherwise
-QueueInconsistentStateError is raised.
+QueueAdminUnauthorizedError is raised.
 
 The alsa-utils source is already in the queue with component "main"
 and section "base".
@@ -673,7 +673,7 @@
     ...     allowed_components=(universe,))
     Traceback (most recent call last):
     ...
-    QueueInconsistentStateError: No rights to override to restricted
+    QueueAdminUnauthorizedError: No rights to override to restricted
 
 Allowing "restricted" still won't work because the original component
 is "main":
@@ -683,7 +683,7 @@
     ...     allowed_components=(restricted,))
     Traceback (most recent call last):
     ...
-    QueueInconsistentStateError: No rights to override from main
+    QueueAdminUnauthorizedError: No rights to override from main
 
 Specifying both main and restricted allows the override to restricted/web.
 overrideSource() returns True if it completed the task.
@@ -710,29 +710,25 @@
     main/base/Important
 
     >>> from lp.soyuz.enums import PackagePublishingPriority
-    >>> print item.overrideBinaries(
-    ...     new_component=restricted,
-    ...     new_section=web,
-    ...     new_priority=PackagePublishingPriority.EXTRA,
-    ...     allowed_components=(universe,))
-    Traceback (most recent call last):
-    ...
-    QueueInconsistentStateError: No rights to override to restricted
-
-    >>> print item.overrideBinaries(
-    ...     new_component=restricted,
-    ...     new_section=web,
-    ...     new_priority=PackagePublishingPriority.EXTRA,
-    ...     allowed_components=(restricted,))
-    Traceback (most recent call last):
-    ...
-    QueueInconsistentStateError: No rights to override from main
-
-    >>> print item.overrideBinaries(
-    ...     new_component=restricted,
-    ...     new_section=web,
-    ...     new_priority=PackagePublishingPriority.EXTRA,
-    ...     allowed_components=(main,restricted))
+    >>> binary_changes = [{
+    ...     "component": restricted,
+    ...     "section": web,
+    ...     "priority": PackagePublishingPriority.EXTRA,
+    ...     }]
+    >>> print item.overrideBinaries(
+    ...     binary_changes, allowed_components=(universe,))
+    Traceback (most recent call last):
+    ...
+    QueueAdminUnauthorizedError: No rights to override to restricted
+
+    >>> print item.overrideBinaries(
+    ...     binary_changes, allowed_components=(restricted,))
+    Traceback (most recent call last):
+    ...
+    QueueAdminUnauthorizedError: No rights to override from main
+
+    >>> print item.overrideBinaries(
+    ...     binary_changes, allowed_components=(main,restricted))
     True
     >>> print "%s/%s/%s" % (
     ...     binary_package.component.name,

=== modified file 'lib/lp/soyuz/interfaces/archive.py'
--- lib/lp/soyuz/interfaces/archive.py	2012-05-29 15:54:57 +0000
+++ lib/lp/soyuz/interfaces/archive.py	2012-06-06 15:13:28 +0000
@@ -43,6 +43,8 @@
     'NoSuchPPA',
     'NoTokensForTeams',
     'PocketNotFound',
+    'PriorityNotFound',
+    'SectionNotFound',
     'VersionRequiresName',
     'default_name_by_purpose',
     'validate_external_dependencies',
@@ -156,7 +158,7 @@
 
 
 class ComponentNotFound(NameLookupFailed):
-    """Invalid source name."""
+    """Invalid component name."""
     _message_prefix = 'No such component'
 
 
@@ -165,6 +167,16 @@
     """Invalid component name."""
 
 
+class SectionNotFound(NameLookupFailed):
+    """Invalid section name."""
+    _message_prefix = "No such section"
+
+
+class PriorityNotFound(NameLookupFailed):
+    """Invalid priority name."""
+    _message_prefix = "No such priority"
+
+
 class NoSuchPPA(NameLookupFailed):
     """Raised when we try to look up an PPA that doesn't exist."""
     _message_prefix = "No such ppa"

=== modified file 'lib/lp/soyuz/interfaces/binarypackagerelease.py'
--- lib/lp/soyuz/interfaces/binarypackagerelease.py	2011-12-24 16:54:44 +0000
+++ lib/lp/soyuz/interfaces/binarypackagerelease.py	2012-06-06 15:13:28 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 # pylint: disable-msg=E0211,E0213
@@ -98,6 +98,13 @@
         description=_("True if there binary version was never published for "
                       "the architeture it was built for. False otherwise."))
 
+    def properties():
+        """Returns the properties of this binary.
+
+        For fast retrieval over the webservice, this is returned as a
+        dictionary.
+        """
+
     def addFile(file):
         """Create a BinaryPackageFile record referencing this build
         and attach the provided library file alias (file).

=== modified file 'lib/lp/soyuz/interfaces/queue.py'
--- lib/lp/soyuz/interfaces/queue.py	2012-05-30 08:50:50 +0000
+++ lib/lp/soyuz/interfaces/queue.py	2012-06-06 15:13:28 +0000
@@ -16,6 +16,7 @@
     'IPackageUploadCustom',
     'IPackageUploadSet',
     'NonBuildableSourceUploadError',
+    'QueueAdminUnauthorizedError',
     'QueueBuildAcceptError',
     'QueueInconsistentStateError',
     'QueueSourceAcceptError',
@@ -26,11 +27,14 @@
 
 from lazr.enum import DBEnumeratedType
 from lazr.restful.declarations import (
+    call_with,
     error_status,
     export_as_webservice_entry,
     export_write_operation,
     exported,
     operation_for_version,
+    operation_parameters,
+    REQUEST_USER,
     )
 from lazr.restful.fields import Reference
 from zope.interface import (
@@ -38,12 +42,15 @@
     Interface,
     )
 from zope.schema import (
+    Bool,
     Choice,
     Datetime,
+    Dict,
     Int,
     List,
     TextLine,
     )
+from zope.security.interfaces import Unauthorized
 
 from lp import _
 from lp.soyuz.enums import PackageUploadStatus
@@ -67,6 +74,10 @@
     """
 
 
+class QueueAdminUnauthorizedError(Unauthorized):
+    """User not permitted to perform a queue administration operation."""
+
+
 class NonBuildableSourceUploadError(QueueInconsistentStateError):
     """Source upload will not result in any build record.
 
@@ -141,6 +152,14 @@
 
     changesfile = Attribute("The librarian alias for the changes file "
                             "associated with this upload")
+    changes_file_url = exported(
+        TextLine(
+            title=_("Changes file URL"),
+            description=_("Librarian URL for the changes file associated with "
+                          "this upload. Will be None if the upload was copied "
+                          "from another series."),
+            required=False, readonly=True),
+        as_of="devel")
 
     signing_key = Attribute("Changesfile Signing Key.")
 
@@ -162,12 +181,19 @@
             title=_("Archive"), required=True, readonly=True))
     sources = Attribute("The queue sources associated with this queue item")
     builds = Attribute("The queue builds associated with the queue item")
+
+    binaries = exported(
+        List(
+            title=_("The binary overrides associated with this queue item."),
+            value_type=Dict(key_type=TextLine()),
+            readonly=True),
+        as_of="devel")
+
     customfiles = Attribute("Custom upload files associated with this "
                             "queue item")
-
     custom_file_urls = exported(
         List(
-            title=_("Custom File URLs"),
+            title=_("Custom file URLs"),
             description=_("Librarian URLs for all the custom files attached "
                           "to this upload."),
             value_type=TextLine(),
@@ -191,17 +217,59 @@
     sourcepackagerelease = Attribute(
         "The source package release for this item")
 
-    package_name = TextLine(
-        title=_("Name of the uploaded source package"), readonly=True)
-
-    package_version = TextLine(
-        title=_("Source package version"), readonly=True)
-
-    component_name = TextLine(
-        title=_("Source package component name"), readonly=True)
-
-    contains_source = Attribute("whether or not this upload contains sources")
-    contains_build = Attribute("whether or not this upload contains binaries")
+    package_name = exported(
+        TextLine(
+            title=_("Name of the uploaded source package"), readonly=True),
+        as_of="devel")
+
+    package_version = exported(
+        TextLine(title=_("Source package version"), readonly=True),
+        as_of="devel")
+
+    component_name = exported(
+        TextLine(title=_("Source package component name"), readonly=True),
+        as_of="devel")
+
+    section_name = exported(
+        TextLine(title=_("Source package section name"), readonly=True),
+        as_of="devel")
+
+    source_file_urls = exported(
+        List(
+            title=_("Source file URLs"),
+            description=_("Librarian URLs for all the source files attached "
+                          "to this upload."),
+            value_type=TextLine(),
+            required=False,
+            readonly=True),
+        as_of="devel")
+
+    binary_file_urls = exported(
+        List(
+            title=_("Binary file URLs"),
+            description=_("Librarian URLs for all the binary files attached "
+                          "to this upload."),
+            value_type=TextLine(),
+            required=False,
+            readonly=True),
+        as_of="devel")
+
+    contains_source = exported(
+        Bool(
+            title=_("Whether or not this upload contains sources"),
+            readonly=True),
+        as_of="devel")
+    contains_build = exported(
+        Bool(
+            title=_("Whether or not this upload contains binaries"),
+            readonly=True),
+        as_of="devel")
+    contains_copy = exported(
+        Bool(
+            title=_("Whether or not this upload contains a copy from another "
+                    "series."),
+            readonly=True),
+        as_of="devel")
     contains_installer = Attribute(
         "whether or not this upload contains installers images")
     contains_translation = Attribute(
@@ -223,9 +291,6 @@
         on all the binarypackagerelease records arising from the build.
         """)
 
-    section_name = TextLine(
-        title=_("Source package sectio name"), readonly=True)
-
     def setNew():
         """Set queue state to NEW."""
 
@@ -329,7 +394,14 @@
         :param logger: Specify a logger object if required.  Mainly for tests.
         """
 
-    def overrideSource(new_component, new_section, allowed_components):
+    @operation_parameters(
+        new_component=TextLine(title=u"The new component name."),
+        new_section=TextLine(title=u"The new section name."))
+    @call_with(allowed_components=None, user=REQUEST_USER)
+    @export_write_operation()
+    @operation_for_version('devel')
+    def overrideSource(new_component=None, new_section=None,
+                       allowed_components=None, user=None):
         """Override the source package contained in this queue item.
 
         :param new_component: An IComponent to replace the existing one
@@ -338,6 +410,8 @@
             in the upload's source.
         :param allowed_components: A sequence of components that the
             callsite is allowed to override from and to.
+        :param user: The user requesting the override change, used if
+            allowed_components is None.
 
         :raises QueueInconsistentStateError: if either the existing
             or the new_component are not in the allowed_components
@@ -349,27 +423,40 @@
         :return: True if the source was overridden.
         """
 
-    def overrideBinaries(new_component, new_section, new_priority,
-                         allowed_components):
-        """Override all the binaries in a binary queue item.
+    @operation_parameters(
+        changes=List(
+            title=u"A sequence of changes to apply.",
+            description=(
+                u"Each item may have a 'name' item which specifies the binary "
+                "package name to override; otherwise, the change applies to "
+                "all binaries in the upload. It may also have 'component', "
+                "'section', and 'priority' items which replace the "
+                "corresponding existing one in the upload's overridden "
+                "binaries."),
+            value_type=Dict(key_type=TextLine())))
+    @call_with(allowed_components=None, user=REQUEST_USER)
+    @export_write_operation()
+    @operation_for_version('devel')
+    def overrideBinaries(changes, allowed_components=None, user=None):
+        """Override binary packages in a binary queue item.
 
-        :param new_component: An IComponent to replace the existing one
-            in the upload's source.
-        :param new_section: An ISection to replace the existing one
-            in the upload's source.
-        :param new_priority: A valid PackagePublishingPriority to replace
-            the existing one in the upload's binaries.
+        :param changes: A sequence of mappings of changes to apply. Each
+            change mapping may have a "name" item which specifies the binary
+            package name to override; otherwise, the change applies to all
+            binaries in the upload. It may also have "component", "section",
+            and "priority" items which replace the corresponding existing
+            one in the upload's overridden binaries. Any missing items are
+            left unchanged.
         :param allowed_components: A sequence of components that the
             callsite is allowed to override from and to.
+        :param user: The user requesting the override change, used if
+            allowed_components is None.
 
         :raises QueueInconsistentStateError: if either the existing
             or the new_component are not in the allowed_components
             sequence.
 
-        The override values may be None, in which case they are not
-        changed.
-
-        :return: True if the binaries were overridden.
+        :return: True if any binaries were overridden.
         """
 
 
@@ -382,13 +469,20 @@
 
     packageupload = Int(
             title=_("PackageUpload"), required=True,
-            readonly=False,
+            readonly=True,
             )
 
     build = Int(
             title=_("The related build"), required=True, readonly=False,
             )
 
+    def binaries():
+        """Returns the properties of the binaries in this build.
+
+        For fast retrieval over the webservice, these are returned as a list
+        of dictionaries, one per binary.
+        """
+
     def publish(logger=None):
         """Publish this queued source in the distroseries referred to by
         the parent queue item.

=== modified file 'lib/lp/soyuz/model/binarypackagerelease.py'
--- lib/lp/soyuz/model/binarypackagerelease.py	2012-04-16 23:02:44 +0000
+++ lib/lp/soyuz/model/binarypackagerelease.py	2012-06-06 15:13:28 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 # pylint: disable-msg=E0611,W0212
@@ -131,6 +131,18 @@
             self.binarypackagename)
         return distroarchseries_binary_package.currentrelease is None
 
+    @property
+    def properties(self):
+        return {
+            "name": self.name,
+            "version": self.version,
+            "is_new": self.is_new,
+            "architecture": self.build.arch_tag,
+            "component": self.component.name,
+            "section": self.section.name,
+            "priority": self.priority.name,
+            }
+
     @cachedproperty
     def files(self):
         return list(
@@ -201,4 +213,3 @@
     def binary_package_version(self):
         """See `IBinaryPackageReleaseDownloadCount`."""
         return self.binary_package_release.version
-

=== modified file 'lib/lp/soyuz/model/queue.py'
--- lib/lp/soyuz/model/queue.py	2012-05-25 15:31:50 +0000
+++ lib/lp/soyuz/model/queue.py	2012-06-06 15:13:28 +0000
@@ -13,6 +13,7 @@
     'PackageUploadSet',
     ]
 
+from itertools import chain
 import os
 import shutil
 import StringIO
@@ -72,11 +73,19 @@
     PackageUploadCustomFormat,
     PackageUploadStatus,
     )
-from lp.soyuz.interfaces.archive import MAIN_ARCHIVE_PURPOSES
+from lp.soyuz.interfaces.archive import (
+    ComponentNotFound,
+    MAIN_ARCHIVE_PURPOSES,
+    PriorityNotFound,
+    SectionNotFound,
+    )
+from lp.soyuz.interfaces.archivepermission import IArchivePermissionSet
+from lp.soyuz.interfaces.component import IComponentSet
 from lp.soyuz.interfaces.packagecopyjob import IPackageCopyJobSource
 from lp.soyuz.interfaces.publishing import (
     IPublishingSet,
     ISourcePackagePublishingHistory,
+    name_priority_map,
     )
 from lp.soyuz.interfaces.queue import (
     IPackageUpload,
@@ -86,11 +95,13 @@
     IPackageUploadSet,
     IPackageUploadSource,
     NonBuildableSourceUploadError,
+    QueueAdminUnauthorizedError,
     QueueBuildAcceptError,
     QueueInconsistentStateError,
     QueueSourceAcceptError,
     QueueStateWriteProtectedError,
     )
+from lp.soyuz.interfaces.section import ISectionSet
 from lp.soyuz.model.binarypackagename import BinaryPackageName
 from lp.soyuz.model.binarypackagerelease import BinaryPackageRelease
 from lp.soyuz.pas import BuildDaemonPackagesArchSpecific
@@ -236,6 +247,18 @@
     builds = SQLMultipleJoin('PackageUploadBuild',
                              joinColumn='packageupload')
 
+    @property
+    def changes_file_url(self):
+        if self.changesfile is not None:
+            return self.changesfile.getURL()
+        else:
+            return None
+
+    @property
+    def binaries(self):
+        return list(chain.from_iterable(
+            build.binaries for build in self.builds))
+
     def getSourceBuild(self):
         #avoid circular import
         from lp.code.model.sourcepackagerecipebuild import (
@@ -590,12 +613,17 @@
     @cachedproperty
     def contains_source(self):
         """See `IPackageUpload`."""
-        return self.sources
+        return bool(self.sources)
 
     @cachedproperty
     def contains_build(self):
         """See `IPackageUpload`."""
-        return self.builds
+        return bool(self.builds)
+
+    @cachedproperty
+    def contains_copy(self):
+        """See `IPackageUpload`."""
+        return self.package_copy_job_id is not None
 
     @cachedproperty
     def from_build(self):
@@ -671,6 +699,23 @@
             return None
 
     @cachedproperty
+    def source_file_urls(self):
+        """See `IPackageUpload`."""
+        if self.contains_source:
+            return [file.libraryfile.getURL()
+                    for file in self.sourcepackagerelease.files]
+        else:
+            return []
+
+    @cachedproperty
+    def binary_file_urls(self):
+        """See `IPackageUpload`."""
+        return [file.libraryfile.getURL()
+                for build in self.builds
+                for bpr in build.build.binarypackages
+                for file in bpr.files]
+
+    @cachedproperty
     def displayname(self):
         """See `IPackageUpload`."""
         names = []
@@ -915,6 +960,33 @@
         """See `IPackageUpload`."""
         return getUtility(IPackageCopyJobSource).wrap(self.package_copy_job)
 
+    def _nameToComponent(self, component):
+        """Helper to convert a possible string component to IComponent."""
+        try:
+            if isinstance(component, basestring):
+                component = getUtility(IComponentSet)[component]
+            return component
+        except NotFoundError:
+            raise ComponentNotFound(component)
+
+    def _nameToSection(self, section):
+        """Helper to convert a possible string section to ISection."""
+        try:
+            if isinstance(section, basestring):
+                section = getUtility(ISectionSet)[section]
+            return section
+        except NotFoundError:
+            raise SectionNotFound(section)
+
+    def _nameToPriority(self, priority):
+        """Helper to convert a possible string priority to its enum."""
+        try:
+            if isinstance(priority, basestring):
+                priority = name_priority_map[priority]
+            return priority
+        except KeyError:
+            raise PriorityNotFound(priority)
+
     def _overrideSyncSource(self, new_component, new_section,
                             allowed_components):
         """Override source on the upload's `PackageCopyJob`, if any."""
@@ -925,7 +997,7 @@
         allowed_component_names = [
             component.name for component in allowed_components]
         if copy_job.component_name not in allowed_component_names:
-            raise QueueInconsistentStateError(
+            raise QueueAdminUnauthorizedError(
                 "No rights to override from %s" % copy_job.component_name)
         copy_job.addSourceOverride(SourceOverride(
             copy_job.package_name, new_component, new_section))
@@ -942,7 +1014,7 @@
             if old_component not in allowed_components:
                 # The old component is not in the list of allowed components
                 # to override.
-                raise QueueInconsistentStateError(
+                raise QueueAdminUnauthorizedError(
                     "No rights to override from %s" % old_component.name)
             source.sourcepackagerelease.override(
                 component=new_component, section=new_section)
@@ -955,14 +1027,29 @@
 
         return made_changes
 
-    def overrideSource(self, new_component, new_section, allowed_components):
+    def overrideSource(self, new_component=None, new_section=None,
+                       allowed_components=None, user=None):
         """See `IPackageUpload`."""
         if new_component is None and new_section is None:
             # Nothing needs overriding, bail out.
             return False
 
+        new_component = self._nameToComponent(new_component)
+        new_section = self._nameToSection(new_section)
+
+        if allowed_components is None and user is not None:
+            # Get a list of components for which the user has rights to
+            # override to or from.
+            permission_set = getUtility(IArchivePermissionSet)
+            permissions = permission_set.componentsForQueueAdmin(
+                self.distroseries.main_archive, user)
+            allowed_components = set(
+                permission.component for permission in permissions)
+        assert allowed_components is not None, (
+            "Must provide allowed_components for non-webservice calls.")
+
         if new_component not in list(allowed_components) + [None]:
-            raise QueueInconsistentStateError(
+            raise QueueAdminUnauthorizedError(
                 "No rights to override to %s" % new_component.name)
 
         return (
@@ -971,35 +1058,94 @@
             self._overrideNonSyncSource(
                 new_component, new_section, allowed_components))
 
-    def overrideBinaries(self, new_component, new_section, new_priority,
-                         allowed_components):
+    def _filterBinaryChanges(self, changes):
+        """Process a binary changes mapping into a more convenient form."""
+        changes_by_name = {}
+        changes_for_all = None
+
+        for change in changes:
+            filtered_change = {}
+            if "component" in change:
+                filtered_change["component"] = self._nameToComponent(
+                    change["component"])
+            if "section" in change:
+                filtered_change["section"] = self._nameToSection(
+                    change["section"])
+            if "priority" in change:
+                filtered_change["priority"] = self._nameToPriority(
+                    change["priority"])
+
+            if "name" in change:
+                changes_by_name[change["name"]] = filtered_change
+            else:
+                # Changes with no "name" item provide a default for all
+                # binaries.
+                changes_for_all = filtered_change
+
+        return changes_by_name, changes_for_all
+
+    def overrideBinaries(self, changes, allowed_components=None, user=None):
         """See `IPackageUpload`."""
         if not self.contains_build:
             return False
 
-        if (new_component is None and new_section is None and
-            new_priority is None):
+        if not changes:
             # Nothing needs overriding, bail out.
             return False
 
-        if new_component not in allowed_components:
-            raise QueueInconsistentStateError(
-                "No rights to override to %s" % new_component.name)
-
+        if allowed_components is None and user is not None:
+            # Get a list of components for which the user has rights to
+            # override to or from.
+            permission_set = getUtility(IArchivePermissionSet)
+            permissions = permission_set.componentsForQueueAdmin(
+                self.distroseries.main_archive, user)
+            allowed_components = set(
+                permission.component for permission in permissions)
+        assert allowed_components is not None, (
+            "Must provide allowed_components for non-webservice calls.")
+
+        changes_by_name, changes_for_all = self._filterBinaryChanges(changes)
+
+        new_components = set()
+        for change in changes_by_name.values():
+            if "component" in change:
+                new_components.add(change["component"])
+        if changes_for_all is not None and "component" in changes_for_all:
+            new_components.add(changes_for_all["component"])
+        disallowed_components = sorted(
+            component.name
+            for component in new_components.difference(allowed_components))
+        if disallowed_components:
+            raise QueueAdminUnauthorizedError(
+                "No rights to override to %s" %
+                ", ".join(disallowed_components))
+
+        made_changes = False
         for build in self.builds:
+            # See if the new component requires a new archive on the build.
+            for component in new_components:
+                distroarchseries = build.build.distro_arch_series
+                distribution = distroarchseries.distroseries.distribution
+                new_archive = distribution.getArchiveByComponent(component)
+                if new_archive != build.build.archive:
+                    raise QueueInconsistentStateError(
+                        "Overriding component to '%s' failed because it "
+                        "would require a new archive." % component.name)
+
             for binarypackage in build.build.binarypackages:
-                if binarypackage.component not in allowed_components:
-                    # The old or the new component is not in the list of
-                    # allowed components to override.
-                    raise QueueInconsistentStateError(
-                        "No rights to override from %s" % (
-                            binarypackage.component.name))
-                binarypackage.override(
-                    component=new_component,
-                    section=new_section,
-                    priority=new_priority)
+                change = changes_by_name.get(
+                    binarypackage.name, changes_for_all)
+                if change is not None:
+                    if binarypackage.component not in allowed_components:
+                        # The old component is not in the list of allowed
+                        # components to override.
+                        raise QueueAdminUnauthorizedError(
+                            "No rights to override from %s" % (
+                                binarypackage.component.name))
+                    binarypackage.override(**change)
+                    made_changes = True
 
-        return bool(self.builds)
+        return made_changes
 
 
 class PackageUploadBuild(SQLBase):
@@ -1014,6 +1160,12 @@
 
     build = ForeignKey(dbName='build', foreignKey='BinaryPackageBuild')
 
+    @property
+    def binaries(self):
+        """See `IPackageUploadBuild`."""
+        for binary in self.build.binarypackages:
+            yield binary.properties
+
     def checkComponentAndSection(self):
         """See `IPackageUploadBuild`."""
         distroseries = self.packageupload.distroseries

=== modified file 'lib/lp/soyuz/scripts/queue.py'
--- lib/lp/soyuz/scripts/queue.py	2012-02-10 10:50:03 +0000
+++ lib/lp/soyuz/scripts/queue.py	2012-06-06 15:13:28 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 # pylint: disable-msg=W0231
@@ -260,8 +260,8 @@
             False: '-',
         }
         return (
-            source_tag[bool(queue_item.contains_source)] +
-            binary_tag[bool(queue_item.contains_build)])
+            source_tag[queue_item.contains_source] +
+            binary_tag[queue_item.contains_build])
 
     def displayItem(self, queue_item):
         """Display one line summary of the queue item provided."""

=== modified file 'lib/lp/soyuz/stories/webservice/xx-packageupload.txt'
--- lib/lp/soyuz/stories/webservice/xx-packageupload.txt	2012-05-30 14:12:44 +0000
+++ lib/lp/soyuz/stories/webservice/xx-packageupload.txt	2012-06-06 15:13:28 +0000
@@ -31,6 +31,19 @@
     self_link: u'http://.../ubuntu/warty/+upload/11'
     status: u'Done'
 
+getPackageUploads can filter on package names.
+
+    >>> uploads = webservice.named_get(
+    ...     warty['self_link'], 'getPackageUploads',
+    ...     name='mozilla').jsonBody()
+    >>> len(uploads['entries'])
+    1
+    >>> uploads = webservice.named_get(
+    ...     warty['self_link'], 'getPackageUploads',
+    ...     name='missing').jsonBody()
+    >>> len(uploads['entries'])
+    0
+
 
 Retrieving Static Translation Files
 ===================================

=== modified file 'lib/lp/soyuz/tests/test_distroseriesqueue_ddtp_tarball.py'
--- lib/lp/soyuz/tests/test_distroseriesqueue_ddtp_tarball.py	2012-05-25 13:28:31 +0000
+++ lib/lp/soyuz/tests/test_distroseriesqueue_ddtp_tarball.py	2012-06-06 15:13:28 +0000
@@ -27,10 +27,6 @@
     getPolicy,
     )
 from lp.services.log.logger import DevNullLogger
-from lp.soyuz.scripts.queue import (
-    CommandRunner,
-    name_queue_map,
-    )
 from lp.soyuz.tests.test_publishing import TestNativePublishingBase
 from lp.testing.gpgkeys import import_public_test_keys
 
@@ -68,30 +64,6 @@
     def test_accepts_correct_upload(self):
         self.uploadTestData("20060728")
 
-    def runQueueCommand(self, queue_name, args):
-        def null_display(text):
-            pass
-
-        queue = name_queue_map[queue_name]
-        runner = CommandRunner(
-            queue, "ubuntutest", "breezy-autotest", True, None, None, None,
-            display=null_display)
-        runner.execute(args)
-
-    def test_queue_tool_behaviour(self):
-        # The queue tool can fetch ddtp-tarball uploads.
-        self.uploadTestData("20060728")
-        # Make sure that we can use the librarian files.
-        transaction.commit()
-        # Fetch upload into a temporary directory.
-        self.useTempDir()
-        self.runQueueCommand("accepted", ["fetch", "trans"])
-        expected_entries = [
-            "translations-main_20060728_all.changes",
-            "translations_main_20060728.tar.gz",
-            ]
-        self.assertContentEqual(expected_entries, os.listdir("."))
-
     def test_publish(self):
         upload = self.uploadTestData("20060728")
         transaction.commit()

=== modified file 'lib/lp/soyuz/tests/test_distroseriesqueue_dist_upgrader.py'
--- lib/lp/soyuz/tests/test_distroseriesqueue_dist_upgrader.py	2012-05-25 13:27:41 +0000
+++ lib/lp/soyuz/tests/test_distroseriesqueue_dist_upgrader.py	2012-06-06 15:13:28 +0000
@@ -23,10 +23,6 @@
     )
 from lp.services.config import config
 from lp.services.log.logger import DevNullLogger
-from lp.soyuz.scripts.queue import (
-    CommandRunner,
-    name_queue_map,
-    )
 from lp.soyuz.tests.test_publishing import TestNativePublishingBase
 from lp.testing.gpgkeys import import_public_test_keys
 
@@ -69,37 +65,18 @@
     def test_accepts_correct_upload(self):
         self.uploadTestData("20060302.0120")
 
-    def runQueueCommand(self, queue_name, args):
-        def null_display(text):
-            pass
-
-        queue = name_queue_map[queue_name]
-        runner = CommandRunner(
-            queue, "ubuntutest", "breezy-autotest", True, None, None, None,
-            display=null_display)
-        runner.execute(args)
-
-    def test_queue_tool_behaviour(self):
-        # The queue tool can accept, reject, and fetch dist-upgrader
-        # uploads.  See bug #54649.
+    def test_accept_reject(self):
+        # We can accept and reject dist-upgrader uploads.
         upload = self.uploadTestData("20060302.0120")
         # Make sure that we can use the librarian files.
         transaction.commit()
         # Reject from accepted queue (unlikely, would normally be from
         # unapproved or new).
-        self.runQueueCommand("accepted", ["reject", "dist"])
+        upload.queue_root.rejectFromQueue(logger=self.logger)
         self.assertEqual("REJECTED", upload.queue_root.status.name)
         # Accept from rejected queue (also unlikely, but only for testing).
-        self.runQueueCommand("rejected", ["accept", "dist"])
+        upload.queue_root.acceptFromQueue(logger=self.logger)
         self.assertEqual("ACCEPTED", upload.queue_root.status.name)
-        # Fetch upload into a temporary directory.
-        self.useTempDir()
-        self.runQueueCommand("accepted", ["fetch", "dist"])
-        expected_entries = [
-            "dist-upgrader_20060302.0120_all.changes",
-            "dist-upgrader_20060302.0120_all.tar.gz",
-            ]
-        self.assertContentEqual(expected_entries, os.listdir("."))
 
     def test_bad_upload_remains_in_accepted(self):
         # Bad dist-upgrader uploads remain in ACCEPTED.

=== modified file 'lib/lp/soyuz/tests/test_packageupload.py'
--- lib/lp/soyuz/tests/test_packageupload.py	2012-05-30 08:50:50 +0000
+++ lib/lp/soyuz/tests/test_packageupload.py	2012-06-06 15:13:28 +0000
@@ -38,6 +38,7 @@
 from lp.soyuz.interfaces.component import IComponentSet
 from lp.soyuz.interfaces.queue import (
     IPackageUploadSet,
+    QueueAdminUnauthorizedError,
     QueueInconsistentStateError,
     )
 from lp.soyuz.interfaces.section import ISectionSet
@@ -440,8 +441,7 @@
         only_allowed_component = self.factory.makeComponent()
         section = self.factory.makeSection()
         self.assertRaises(
-            QueueInconsistentStateError,
-            pu.overrideSource,
+            QueueAdminUnauthorizedError, pu.overrideSource,
             only_allowed_component, section, [only_allowed_component])
 
     def test_overrideSource_checks_permission_for_new_component(self):
@@ -450,8 +450,7 @@
         disallowed_component = self.factory.makeComponent()
         section = self.factory.makeSection()
         self.assertRaises(
-            QueueInconsistentStateError,
-            pu.overrideSource,
+            QueueAdminUnauthorizedError, pu.overrideSource,
             disallowed_component, section, [current_component])
 
     def test_overrideSource_ignores_None_component_change(self):
@@ -880,12 +879,13 @@
     def setUp(self):
         super(TestPackageUploadWebservice, self).setUp()
         self.webservice = None
-
-    def makeDistroSeries(self):
         self.distroseries = self.factory.makeDistroSeries()
         self.main = self.factory.makeComponent("main")
         self.factory.makeComponentSelection(
             distroseries=self.distroseries, component=self.main)
+        self.universe = self.factory.makeComponent("universe")
+        self.factory.makeComponentSelection(
+            distroseries=self.distroseries, component=self.universe)
 
     def makeQueueAdmin(self, components):
         person = self.factory.makePerson()
@@ -902,57 +902,325 @@
             self.webservice = launchpadlib_for("testing", person)
         return self.webservice.load(api_url(obj))
 
+    def makeSourcePackageUpload(self, person, **kwargs):
+        with person_logged_in(person):
+            upload = self.factory.makeSourcePackageUpload(
+                distroseries=self.distroseries, **kwargs)
+            transaction.commit()
+            spr = upload.sourcepackagerelease
+            for extension in ("dsc", "tar.gz"):
+                filename = "%s_%s.%s" % (spr.name, spr.version, extension)
+                lfa = self.factory.makeLibraryFileAlias(filename=filename)
+                spr.addFile(lfa)
+        transaction.commit()
+        return upload, self.load(upload, person)
+
+    def makeBinaryPackageUpload(self, person, binarypackagename=None,
+                                component=None):
+        with person_logged_in(person):
+            upload = self.factory.makeBuildPackageUpload(
+                distroseries=self.distroseries,
+                binarypackagename=binarypackagename, component=component)
+            self.factory.makeBinaryPackageRelease(
+                build=upload.builds[0].build, component=component)
+            transaction.commit()
+            for build in upload.builds:
+                for bpr in build.build.binarypackages:
+                    filename = "%s_%s_%s.deb" % (
+                        bpr.name, bpr.version, bpr.build.arch_tag)
+                    lfa = self.factory.makeLibraryFileAlias(filename=filename)
+                    bpr.addFile(lfa)
+        transaction.commit()
+        return upload, self.load(upload, person)
+
+    def makeCustomPackageUpload(self, person, **kwargs):
+        with person_logged_in(person):
+            upload = self.factory.makeCustomPackageUpload(
+                distroseries=self.distroseries, **kwargs)
+        transaction.commit()
+        return upload, self.load(upload, person)
+
     def assertRequiresEdit(self, method_name, **kwargs):
         """Test that a web service queue method requires launchpad.Edit."""
         with admin_logged_in():
             upload = self.factory.makeSourcePackageUpload()
         transaction.commit()
         ws_upload = self.load(upload)
-        self.assertRaises(Unauthorized, getattr(ws_upload, method_name),
-                          **kwargs)
+        self.assertRaises(
+            Unauthorized, getattr(ws_upload, method_name), **kwargs)
 
     def test_edit_permissions(self):
         self.assertRequiresEdit("acceptFromQueue")
         self.assertRequiresEdit("rejectFromQueue")
+        self.assertRequiresEdit("overrideSource", new_component="main")
+        self.assertRequiresEdit(
+            "overrideBinaries", changes=[{"component": "main"}])
 
     def test_acceptFromQueue_archive_admin(self):
         # acceptFromQueue as an archive admin accepts the upload.
-        self.makeDistroSeries()
         person = self.makeQueueAdmin([self.main])
-        with person_logged_in(person):
-            upload = self.factory.makeSourcePackageUpload(
-                distroseries=self.distroseries, component=self.main)
-        transaction.commit()
+        upload, ws_upload = self.makeSourcePackageUpload(
+            person, component=self.main)
 
-        ws_upload = self.load(upload, person)
         self.assertEqual("New", ws_upload.status)
         ws_upload.acceptFromQueue()
         self.assertEqual("Done", ws_upload.status)
 
     def test_double_accept_raises_BadRequest(self):
         # Trying to accept an upload twice returns 400 instead of OOPSing.
-        self.makeDistroSeries()
         person = self.makeQueueAdmin([self.main])
+        upload, _ = self.makeSourcePackageUpload(person, component=self.main)
+
         with person_logged_in(person):
-            upload = self.factory.makeSourcePackageUpload(
-                distroseries=self.distroseries, component=self.main)
             upload.setAccepted()
-        transaction.commit()
-
         ws_upload = self.load(upload, person)
         self.assertEqual("Accepted", ws_upload.status)
         self.assertRaises(BadRequest, ws_upload.acceptFromQueue)
 
     def test_rejectFromQueue_archive_admin(self):
         # rejectFromQueue as an archive admin rejects the upload.
-        self.makeDistroSeries()
         person = self.makeQueueAdmin([self.main])
-        with person_logged_in(person):
-            upload = self.factory.makeSourcePackageUpload(
-                distroseries=self.distroseries, component=self.main)
-        transaction.commit()
+        upload, ws_upload = self.makeSourcePackageUpload(
+            person, component=self.main)
 
-        ws_upload = self.load(upload, person)
         self.assertEqual("New", ws_upload.status)
         ws_upload.rejectFromQueue()
         self.assertEqual("Rejected", ws_upload.status)
+
+    def test_source_info(self):
+        # API clients can inspect properties of source uploads.
+        person = self.makeQueueAdmin([self.universe])
+        upload, ws_upload = self.makeSourcePackageUpload(
+            person, sourcepackagename="hello", component=self.universe)
+
+        self.assertTrue(ws_upload.contains_source)
+        self.assertFalse(ws_upload.contains_build)
+        self.assertFalse(ws_upload.contains_copy)
+        self.assertEqual("hello", ws_upload.display_name)
+        self.assertEqual(upload.package_version, ws_upload.display_version)
+        self.assertEqual("source", ws_upload.display_arches)
+        self.assertEqual("hello", ws_upload.package_name)
+        self.assertEqual(upload.package_version, ws_upload.package_version)
+        self.assertEqual("universe", ws_upload.component_name)
+        self.assertEqual(upload.section_name, ws_upload.section_name)
+
+    def test_source_fetch(self):
+        # API clients can fetch files attached to source uploads.
+        person = self.makeQueueAdmin([self.universe])
+        upload, ws_upload = self.makeSourcePackageUpload(
+            person, component=self.universe)
+        self.assertNotEqual(0, len(ws_upload.source_file_urls))
+        with person_logged_in(person):
+            source_file_urls = [
+                file.libraryfile.getURL()
+                for file in upload.sourcepackagerelease.files]
+        self.assertContentEqual(source_file_urls, ws_upload.source_file_urls)
+
+    def test_overrideSource_limited_component_permissions(self):
+        # Overriding between two components requires queue admin of both.
+        person = self.makeQueueAdmin([self.universe])
+        upload, ws_upload = self.makeSourcePackageUpload(
+            person, component=self.universe)
+
+        self.assertEqual("New", ws_upload.status)
+        self.assertEqual("universe", ws_upload.component_name)
+        self.assertRaises(Unauthorized, ws_upload.overrideSource,
+                          new_component="main")
+
+        with admin_logged_in():
+            upload.overrideSource(
+                new_component=self.main,
+                allowed_components=[self.main, self.universe])
+        transaction.commit()
+        self.assertEqual("main", upload.component_name)
+        self.assertRaises(Unauthorized, ws_upload.overrideSource,
+                          new_component="universe")
+
+    def test_overrideSource_changes_properties(self):
+        # Running overrideSource changes the corresponding properties.
+        person = self.makeQueueAdmin([self.main, self.universe])
+        upload, ws_upload = self.makeSourcePackageUpload(
+            person, component=self.universe)
+        with person_logged_in(person):
+            new_section = self.factory.makeSection()
+        transaction.commit()
+
+        self.assertEqual("New", ws_upload.status)
+        self.assertEqual("universe", ws_upload.component_name)
+        self.assertNotEqual(new_section.name, ws_upload.section_name)
+        ws_upload.overrideSource(
+            new_component="main", new_section=new_section.name)
+        self.assertEqual("main", ws_upload.component_name)
+        self.assertEqual(new_section.name, ws_upload.section_name)
+        ws_upload.overrideSource(new_component="universe")
+        self.assertEqual("universe", ws_upload.component_name)
+
+    def test_binary_info(self):
+        # API clients can inspect properties of binary uploads.
+        person = self.makeQueueAdmin([self.universe])
+        upload, ws_upload = self.makeBinaryPackageUpload(
+            person, component=self.universe)
+        with person_logged_in(person):
+            arch = upload.builds[0].build.arch_tag
+            bprs = upload.builds[0].build.binarypackages
+
+        self.assertFalse(ws_upload.contains_source)
+        self.assertTrue(ws_upload.contains_build)
+        self.assertEqual(len(list(bprs)), len(ws_upload.binaries))
+        for bpr, binary in zip(bprs, ws_upload.binaries):
+            expected_binary = {
+                "is_new": True,
+                "name": bpr.name,
+                "version": bpr.version,
+                "architecture": arch,
+                "component": "universe",
+                "section": bpr.section.name,
+                "priority": bpr.priority.name,
+                }
+            self.assertContentEqual(expected_binary.keys(), binary.keys())
+            for key, value in expected_binary.items():
+                self.assertEqual(value, binary[key])
+
+    def test_binary_fetch(self):
+        # API clients can fetch files attached to binary uploads.
+        person = self.makeQueueAdmin([self.universe])
+        upload, ws_upload = self.makeBinaryPackageUpload(
+            person, component=self.universe)
+
+        self.assertNotEqual(0, len(ws_upload.binary_file_urls))
+        with person_logged_in(person):
+            binary_file_urls = [
+                file.libraryfile.getURL()
+                for bpr in upload.builds[0].build.binarypackages
+                for file in bpr.files]
+        self.assertContentEqual(binary_file_urls, ws_upload.binary_file_urls)
+
+    def test_overrideBinaries_limited_component_permissions(self):
+        # Overriding between two components requires queue admin of both.
+        person = self.makeQueueAdmin([self.universe])
+        upload, ws_upload = self.makeBinaryPackageUpload(
+            person, binarypackagename="hello", component=self.universe)
+
+        self.assertEqual("New", ws_upload.status)
+        self.assertEqual(
+            set(["universe"]),
+            set(binary["component"] for binary in ws_upload.binaries))
+        self.assertRaises(
+            Unauthorized, ws_upload.overrideBinaries,
+            changes=[{"component": "main"}])
+
+        with admin_logged_in():
+            upload.overrideBinaries(
+                [{"component": self.main}],
+                allowed_components=[self.main, self.universe])
+        transaction.commit()
+
+        ws_upload = self.load(upload, person)
+        self.assertEqual(
+            set(["main"]),
+            set(binary["component"] for binary in ws_upload.binaries))
+        self.assertRaises(
+            Unauthorized, ws_upload.overrideBinaries,
+            changes=[{"component": "universe"}])
+
+    def test_overrideBinaries_disallows_new_archive(self):
+        # overrideBinaries refuses to override the component to something
+        # that requires a different archive.
+        partner = self.factory.makeComponent("partner")
+        self.factory.makeComponentSelection(
+            distroseries=self.distroseries, component=partner)
+        person = self.makeQueueAdmin([self.universe, partner])
+        upload, ws_upload = self.makeBinaryPackageUpload(
+            person, component=self.universe)
+
+        self.assertEqual("universe", ws_upload.binaries[0]["component"])
+        self.assertRaises(
+            BadRequest, ws_upload.overrideBinaries,
+            changes=[{"component": "partner"}])
+
+    def test_overrideBinaries_without_name_changes_all_properties(self):
+        # Running overrideBinaries with a change entry containing no "name"
+        # field changes the corresponding properties of all binaries.
+        person = self.makeQueueAdmin([self.main, self.universe])
+        upload, ws_upload = self.makeBinaryPackageUpload(
+            person, component=self.universe)
+        with person_logged_in(person):
+            new_section = self.factory.makeSection()
+        transaction.commit()
+
+        self.assertEqual("New", ws_upload.status)
+        for binary in ws_upload.binaries:
+            self.assertEqual("universe", binary["component"])
+            self.assertNotEqual(new_section.name, binary["section"])
+            self.assertEqual("OPTIONAL", binary["priority"])
+        changes = [{
+            "component": "main",
+            "section": new_section.name,
+            "priority": "extra",
+            }]
+        ws_upload.overrideBinaries(changes=changes)
+        for binary in ws_upload.binaries:
+            self.assertEqual("main", binary["component"])
+            self.assertEqual(new_section.name, binary["section"])
+            self.assertEqual("EXTRA", binary["priority"])
+
+    def test_overrideBinaries_with_name_changes_selected_properties(self):
+        # Running overrideBinaries with change entries containing "name"
+        # fields changes the corresponding properties of only the selected
+        # binaries.
+        person = self.makeQueueAdmin([self.main, self.universe])
+        upload, ws_upload = self.makeBinaryPackageUpload(
+            person, component=self.universe)
+        with person_logged_in(person):
+            new_section = self.factory.makeSection()
+        transaction.commit()
+
+        self.assertEqual("New", ws_upload.status)
+        for binary in ws_upload.binaries:
+            self.assertEqual("universe", binary["component"])
+            self.assertNotEqual(new_section.name, binary["section"])
+            self.assertEqual("OPTIONAL", binary["priority"])
+        change_one = {
+            "name": ws_upload.binaries[0]["name"],
+            "component": "main",
+            "priority": "standard",
+            }
+        change_two = {
+            "name": ws_upload.binaries[1]["name"],
+            "section": new_section.name,
+            }
+        ws_upload.overrideBinaries(changes=[change_one, change_two])
+        ws_upload = self.load(upload, person)
+        self.assertEqual("main", ws_upload.binaries[0]["component"])
+        self.assertNotEqual(new_section.name, ws_upload.binaries[0]["section"])
+        self.assertEqual("STANDARD", ws_upload.binaries[0]["priority"])
+        self.assertEqual("universe", ws_upload.binaries[1]["component"])
+        self.assertEqual(new_section.name, ws_upload.binaries[1]["section"])
+        self.assertEqual("OPTIONAL", ws_upload.binaries[1]["priority"])
+
+    def test_custom_info(self):
+        # API clients can inspect properties of custom uploads.
+        person = self.makeQueueAdmin([self.universe])
+        upload, ws_upload = self.makeCustomPackageUpload(
+            person, custom_type=PackageUploadCustomFormat.DEBIAN_INSTALLER,
+            filename="debian-installer-images_1.tar.gz")
+
+        self.assertFalse(ws_upload.contains_source)
+        self.assertFalse(ws_upload.contains_build)
+        self.assertFalse(ws_upload.contains_copy)
+        self.assertEqual(
+            "debian-installer-images_1.tar.gz", ws_upload.display_name)
+        self.assertEqual("-", ws_upload.display_version)
+        self.assertEqual("raw-installer", ws_upload.display_arches)
+
+    def test_custom_fetch(self):
+        # API clients can fetch files attached to custom uploads.
+        person = self.makeQueueAdmin([self.universe])
+        upload, ws_upload = self.makeCustomPackageUpload(
+            person, custom_type=PackageUploadCustomFormat.DEBIAN_INSTALLER,
+            filename="debian-installer-images_1.tar.gz")
+        self.assertNotEqual(0, len(ws_upload.custom_file_urls))
+        with person_logged_in(person):
+            custom_file_urls = [
+                file.libraryfilealias.getURL() for file in upload.customfiles]
+        self.assertContentEqual(custom_file_urls, ws_upload.custom_file_urls)

=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py	2012-06-03 23:11:40 +0000
+++ lib/lp/testing/factory.py	2012-06-06 15:13:28 +0000
@@ -3494,7 +3494,7 @@
         return upload
 
     def makeBuildPackageUpload(self, distroseries=None,
-                               binarypackagename=None):
+                               binarypackagename=None, component=None):
         """Make a `PackageUpload` with a `PackageUploadBuild` attached."""
         if distroseries is None:
             distroseries = self.makeDistroSeries()
@@ -3503,7 +3503,8 @@
         build = self.makeBinaryPackageBuild()
         upload.addBuild(build)
         self.makeBinaryPackageRelease(
-            binarypackagename=binarypackagename, build=build)
+            binarypackagename=binarypackagename, build=build,
+            component=component)
         return upload
 
     def makeCustomPackageUpload(self, distroseries=None, custom_type=None,


Follow ups