Merge lp:~jtv/launchpad/bug-659769 into lp:launchpad

Proposed by Jeroen T. Vermeulen
Status: Merged
Approved by: Graham Binns
Approved revision: no longer in the source branch.
Merged at revision: 13734
Proposed branch: lp:~jtv/launchpad/bug-659769
Merge into: lp:launchpad
Diff against target: 873 lines (+697/-36)
8 files modified
lib/lp/archivepublisher/scripts/publish_ftpmaster.py (+23/-0)
lib/lp/archivepublisher/tests/test_publish_ftpmaster.py (+24/-0)
lib/lp/archiveuploader/nascentupload.py (+9/-6)
lib/lp/registry/interfaces/distroseries.py (+25/-11)
lib/lp/registry/model/distroseries.py (+25/-18)
lib/lp/soyuz/scripts/custom_uploads_copier.py (+157/-0)
lib/lp/soyuz/scripts/tests/test_custom_uploads_copier.py (+433/-0)
lib/lp/soyuz/tests/test_publishing.py (+1/-1)
To merge this branch: bzr merge lp:~jtv/launchpad/bug-659769
Reviewer Review Type Date Requested Status
Graham Binns (community) code Approve
Brad Crittenden code Pending
Review via email: mp+71505@code.launchpad.net

This proposal supersedes a proposal from 2011-08-11.

Commit message

[r=gmb][bug=659769] Copy selected types of custom uploads from predecessor to each new distroseries.

Description of the change

= Summary =

Every time the Ubuntu people (particularly Colin) set up a new release, they need to copy some of the archive's custom-upload files to the new release. Custom uploads are almost entirely unmanaged, so they would probably do this by copying direcetly in the filesystem. They have asked for an easier, more integrated way to get it done.

== Proposed fix ==

As far as I can make out, the only types of custom upload that need to be copied are for the Debian installer and the dist upgrader. The Rosetta translations are shared within the database, and presumably both kinds of translations for a package will soon be re-uploaded anyway. So I focused on the installers and upgraders.

Because custom uploads are so lightly managed (little or no metadata is kept, and we'd have to parse tarballs from the Librarian just to see what files came out of which upload), it's hard to figure out exactly what should or should not be copied. Some of the files may be obsolete and if we copy them along, they'll be with us forever.

The uploads contain tarballs with names in just one of two formats: <package>_<version>_<architecture>.tar.gz and <package>_<version>.tar.gz. A tarball for a given installer version, say, should contain all files that that version needs; there's no finicky merging of individual files that may each be current or obsolete. We can just identify the upload with the current tarball, and copy that upload into the new series.

Rather than get into the full hairy detail of version parsing (some of the versions look a little ad-hoc), I'm just copying the latest custom uploads for each (upload type, <package>, <architecture>). The <architecture> defaults to "all" because that's what seems to be meant when it is omitted.

As far as I've seen, the scheme I implemented here would actually work just fine for the other upload types. It will also work for derived distributions, with two limitations:

1. The custom uploads are copied only between consecutive releases of the same distribution, not across inheritance lines.

2. Uploads have to follow this naming scheme in order to be copied. This is something the package defines.

Having this supported for derived distributions could help save manual maintenance and system access needs for derived distros. We may want to add cross-distro inheritance of custom uploads later, but then we'll have to solve the conflict-resolution problem: should we copy the previous series' latest installer, or the one from the parent distro in the series that we're deriving from? What if there are multiple parent distros or releases?

== Pre-implementation notes ==

Discussed with various people, but alas not with Colin at the time of writing. I'm going to hold off on landing until he confirms that this will give him what he needs. Watch this space for updates.

== Implementation details ==

== Tests ==

{{{
./bin/test -vvc lp.soyuz -t test_publishing -t test_custom_uploads_copier
./bin/test -vvc lp.archivepublisher.tests.test_publish_ftpmaster
}}}

== Demo and Q/A ==

We'll have to do this hand in hand with the distro gurus. Not only to validate the result, but also to help set up the test scenario!

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/soyuz/tests/test_publishing.py
  lib/lp/archivepublisher/scripts/publish_ftpmaster.py
  lib/lp/registry/interfaces/distroseries.py
  lib/lp/soyuz/scripts/tests/test_custom_uploads_copier.py
  lib/lp/soyuz/scripts/custom_uploads_copier.py
  lib/lp/archivepublisher/tests/test_publish_ftpmaster.py
  lib/lp/registry/model/distroseries.py

To post a comment you must log in.
Revision history for this message
Brad Crittenden (bac) wrote : Posted in a previous version of this proposal

Hi Jeroen,

I tried to run your tests but there are failures due to the removal of 'safe_open' which is still referenced in many places throughout the code base. Was its removal an accident? Did you move it to another file that you forgot to add to version control?

Brad

review: Needs Information (code)
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote : Posted in a previous version of this proposal

No idea what safe_open is or how it got into this MP. I'll have to resubmit.

Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

Re-did the branch & re-submitted it to avoid the mysterious safe_open change.

Revision history for this message
Graham Binns (gmb) wrote :

Looks good! r=me

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/archivepublisher/scripts/publish_ftpmaster.py'
2--- lib/lp/archivepublisher/scripts/publish_ftpmaster.py 2011-08-17 07:39:58 +0000
3+++ lib/lp/archivepublisher/scripts/publish_ftpmaster.py 2011-08-19 08:29:38 +0000
4@@ -27,6 +27,7 @@
5 )
6 from lp.services.utils import file_exists
7 from lp.soyuz.enums import ArchivePurpose
8+from lp.soyuz.scripts.custom_uploads_copier import CustomUploadsCopier
9 from lp.soyuz.scripts.ftpmaster import LpQueryDistro
10 from lp.soyuz.scripts.processaccepted import ProcessAccepted
11 from lp.soyuz.scripts.publishdistro import PublishDistro
12@@ -565,6 +566,24 @@
13 self.recoverWorkingDists()
14 raise
15
16+ def prepareFreshSeries(self, distribution):
17+ """If there are any new distroseries, prepare them for publishing.
18+
19+ :return: True if a series did indeed still need some preparation,
20+ of False for the normal case.
21+ """
22+ have_fresh_series = False
23+ for series in distribution.series:
24+ suites_needing_indexes = self.listSuitesNeedingIndexes(series)
25+ if len(suites_needing_indexes) != 0:
26+ # This is a fresh series.
27+ have_fresh_series = True
28+ if series.previous_series is not None:
29+ CustomUploadsCopier(series).copy(series.previous_series)
30+ self.createIndexes(distribution, suites_needing_indexes)
31+
32+ return have_fresh_series
33+
34 def setUp(self):
35 """Process options, and set up internal state."""
36 self.processOptions()
37@@ -572,6 +591,10 @@
38
39 def processDistro(self, distribution):
40 """Process `distribution`."""
41+ if self.prepareFreshSeries(distribution):
42+ # We've done enough here. Leave some server time for others.
43+ return
44+
45 for series in distribution.series:
46 suites_needing_indexes = self.listSuitesNeedingIndexes(series)
47 if len(suites_needing_indexes) > 0:
48
49=== modified file 'lib/lp/archivepublisher/tests/test_publish_ftpmaster.py'
50--- lib/lp/archivepublisher/tests/test_publish_ftpmaster.py 2011-08-17 07:39:58 +0000
51+++ lib/lp/archivepublisher/tests/test_publish_ftpmaster.py 2011-08-19 08:29:38 +0000
52@@ -47,6 +47,7 @@
53 from lp.soyuz.enums import (
54 ArchivePurpose,
55 PackagePublishingStatus,
56+ PackageUploadCustomFormat,
57 )
58 from lp.soyuz.tests.test_publishing import SoyuzTestPublisher
59 from lp.testing import (
60@@ -1030,6 +1031,29 @@
61 self.assertEqual([suite], kwargs['suites'])
62 self.assertThat(kwargs['suites'][0], StartsWith(series.name))
63
64+ def test_prepareFreshSeries_copies_custom_uploads(self):
65+ distro = self.makeDistroWithPublishDirectory()
66+ old_series = self.factory.makeDistroSeries(
67+ distribution=distro, status=SeriesStatus.CURRENT)
68+ new_series = self.factory.makeDistroSeries(
69+ distribution=distro, previous_series=old_series,
70+ status=SeriesStatus.FROZEN)
71+ custom_upload = self.factory.makeCustomPackageUpload(
72+ distroseries=old_series,
73+ custom_type=PackageUploadCustomFormat.DEBIAN_INSTALLER,
74+ filename='debian-installer-images_1.0-20110805_i386.tar.gz')
75+ script = self.makeScript(distro)
76+ script.createIndexes = FakeMethod()
77+ script.setUp()
78+ have_fresh_series = script.prepareFreshSeries(distro)
79+ self.assertTrue(have_fresh_series)
80+ [copied_upload] = new_series.getPackageUploads(
81+ name=u'debian-installer-images', exact_match=False)
82+ [copied_custom] = copied_upload.customfiles
83+ self.assertEqual(
84+ custom_upload.customfiles[0].libraryfilealias.filename,
85+ copied_custom.libraryfilealias.filename)
86+
87 def test_script_creates_indexes(self):
88 # End-to-end test: the script creates indexes for distroseries
89 # that need them.
90
91=== modified file 'lib/lp/archiveuploader/nascentupload.py'
92--- lib/lp/archiveuploader/nascentupload.py 2011-06-09 10:50:25 +0000
93+++ lib/lp/archiveuploader/nascentupload.py 2011-08-19 08:29:38 +0000
94@@ -1,4 +1,4 @@
95-# Copyright 2009 Canonical Ltd. This software is licensed under the
96+# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
97 # GNU Affero General Public License version 3 (see the file LICENSE).
98
99 """The processing of nascent uploads.
100@@ -772,7 +772,7 @@
101 if isinstance(uploaded_file, DSCFile):
102 self.logger.debug(
103 "Checking for %s/%s source ancestry"
104- %(uploaded_file.package, uploaded_file.version))
105+ % (uploaded_file.package, uploaded_file.version))
106 ancestry = self.getSourceAncestry(uploaded_file)
107 if ancestry is not None:
108 self.checkSourceVersion(uploaded_file, ancestry)
109@@ -792,8 +792,11 @@
110 elif isinstance(uploaded_file, BaseBinaryUploadFile):
111 self.logger.debug(
112 "Checking for %s/%s/%s binary ancestry"
113- %(uploaded_file.package, uploaded_file.version,
114- uploaded_file.architecture))
115+ % (
116+ uploaded_file.package,
117+ uploaded_file.version,
118+ uploaded_file.architecture,
119+ ))
120 try:
121 ancestry = self.getBinaryAncestry(uploaded_file)
122 except NotFoundError:
123@@ -928,12 +931,12 @@
124 return distroseries.createQueueEntry(
125 PackagePublishingPocket.RELEASE,
126 distroseries.main_archive, self.changes.filename,
127- self.changes.raw_content, self.changes.signingkey)
128+ self.changes.raw_content, signing_key=self.changes.signingkey)
129 else:
130 return distroseries.createQueueEntry(
131 self.policy.pocket, self.policy.archive,
132 self.changes.filename, self.changes.raw_content,
133- self.changes.signingkey)
134+ signing_key=self.changes.signingkey)
135
136 #
137 # Inserting stuff in the database
138
139=== modified file 'lib/lp/registry/interfaces/distroseries.py'
140--- lib/lp/registry/interfaces/distroseries.py 2011-08-11 14:47:01 +0000
141+++ lib/lp/registry/interfaces/distroseries.py 2011-08-19 08:29:38 +0000
142@@ -787,20 +787,34 @@
143 DistroSeriesBinaryPackage objects that match the given text.
144 """
145
146- def createQueueEntry(pocket, archive, changesfilename, changesfilecontent,
147+ def createQueueEntry(pocket, archive, changesfilename=None,
148+ changesfilecontent=None, changes_file_alias=None,
149 signingkey=None, package_copy_job=None):
150 """Create a queue item attached to this distroseries.
151
152- Create a new records respecting the given pocket and archive.
153-
154- The default state is NEW, sorted sqlobject declaration, any
155- modification should be performed via Queue state-machine.
156-
157- The changesfile argument should be the text of the .changes for this
158- upload. The contents of this may be used later.
159-
160- 'signingkey' is the IGPGKey used to sign the changesfile or None if
161- the changesfile is unsigned.
162+ Create a new `PackageUpload` to the given pocket and archive.
163+
164+ The default state is NEW. Any further state changes go through
165+ the Queue state-machine.
166+
167+ :param pocket: The `PackagePublishingPocket` to upload to.
168+ :param archive: The `Archive` to upload to. Must be for the same
169+ `Distribution` as this series.
170+ :param changesfilename: Name for the upload's .changes file. You may
171+ specify a changes file by passing both `changesfilename` and
172+ `changesfilecontent`, or by passing `changes_file_alias`.
173+ :param changesfilecontent: Text for the changes file. It will be
174+ signed and stored in the Librarian. Must be passed together with
175+ `changesfilename`; alternatively, you may provide a
176+ `changes_file_alias` to replace both of these.
177+ :param changes_file_alias: A `LibraryFileAlias` containing the
178+ .changes file. Security warning: unless the file has already
179+ been checked, this may open us up to replay attacks as per bugs
180+ 159304 and 451396. Use `changes_file_alias` only if you know
181+ this can't happen.
182+ :param signingkey: `IGPGKey` used to sign the changes file, or None if
183+ it is unsigned.
184+ :return: A new `PackageUpload`.
185 """
186
187 def newArch(architecturetag, processorfamily, official, owner,
188
189=== modified file 'lib/lp/registry/model/distroseries.py'
190--- lib/lp/registry/model/distroseries.py 2011-08-12 14:39:51 +0000
191+++ lib/lp/registry/model/distroseries.py 2011-08-19 08:29:38 +0000
192@@ -1585,25 +1585,34 @@
193 get_property_cache(spph).newer_distroseries_version = version
194
195 def createQueueEntry(self, pocket, archive, changesfilename=None,
196- changesfilecontent=None, signing_key=None,
197- package_copy_job=None):
198+ changesfilecontent=None, changes_file_alias=None,
199+ signing_key=None, package_copy_job=None):
200 """See `IDistroSeries`."""
201- # We store the changes file in the librarian to avoid having to
202- # deal with broken encodings in these files; this will allow us
203- # to regenerate these files as necessary.
204- #
205- # The use of StringIO here should be safe: we do not encoding of
206- # the content in the changes file (as doing so would be guessing
207- # at best, causing unpredictable corruption), and simply pass it
208- # off to the librarian.
209-
210- if package_copy_job is None and (
211- changesfilename is None or changesfilecontent is None):
212+ if (changesfilename is None) != (changesfilecontent is None):
213+ raise AssertionError(
214+ "Inconsistent changesfilename and changesfilecontent. "
215+ "Pass either both, or neither.")
216+ if changes_file_alias is not None and changesfilename is not None:
217+ raise AssertionError(
218+ "Conflicting options: "
219+ "Both changesfilename and changes_file_alias were given.")
220+ have_changes_file = not (
221+ changesfilename is None and changes_file_alias is None)
222+ if package_copy_job is None and not have_changes_file:
223 raise AssertionError(
224 "changesfilename and changesfilecontent must be supplied "
225 "if there is no package_copy_job")
226
227- if package_copy_job is None:
228+ if changesfilename is not None:
229+ # We store the changes file in the librarian to avoid having to
230+ # deal with broken encodings in these files; this will allow us
231+ # to regenerate these files as necessary.
232+ #
233+ # The use of StringIO here should be safe: we do not encoding of
234+ # the content in the changes file (as doing so would be guessing
235+ # at best, causing unpredictable corruption), and simply pass it
236+ # off to the librarian.
237+
238 # The PGP signature is stripped from all changesfiles
239 # to avoid replay attacks (see bugs 159304 and 451396).
240 signed_message = signed_message_from_string(changesfilecontent)
241@@ -1614,17 +1623,15 @@
242 if new_content is not None:
243 changesfilecontent = signed_message.signedContent
244
245- changes_file = getUtility(ILibraryFileAliasSet).create(
246+ changes_file_alias = getUtility(ILibraryFileAliasSet).create(
247 changesfilename, len(changesfilecontent),
248 StringIO(changesfilecontent), 'text/plain',
249 restricted=archive.private)
250- else:
251- changes_file = None
252
253 return PackageUpload(
254 distroseries=self, status=PackageUploadStatus.NEW,
255 pocket=pocket, archive=archive,
256- changesfile=changes_file, signing_key=signing_key,
257+ changesfile=changes_file_alias, signing_key=signing_key,
258 package_copy_job=package_copy_job)
259
260 def getPackageUploadQueue(self, state):
261
262=== added file 'lib/lp/soyuz/scripts/custom_uploads_copier.py'
263--- lib/lp/soyuz/scripts/custom_uploads_copier.py 1970-01-01 00:00:00 +0000
264+++ lib/lp/soyuz/scripts/custom_uploads_copier.py 2011-08-19 08:29:38 +0000
265@@ -0,0 +1,157 @@
266+# Copyright 2011 Canonical Ltd. This software is licensed under the
267+# GNU Affero General Public License version 3 (see the file LICENSE).
268+
269+"""Copy latest custom uploads into a distribution release series.
270+
271+Use this when initializing the installer and dist upgrader for a new release
272+series based on the latest uploads from its preceding series.
273+"""
274+
275+__metaclass__ = type
276+__all__ = [
277+ 'CustomUploadsCopier',
278+ ]
279+
280+from operator import attrgetter
281+import re
282+
283+from zope.component import getUtility
284+
285+from lp.registry.interfaces.pocket import PackagePublishingPocket
286+from lp.services.database.bulk import load_referencing
287+from lp.soyuz.enums import PackageUploadCustomFormat
288+from lp.soyuz.interfaces.archive import (
289+ IArchiveSet,
290+ MAIN_ARCHIVE_PURPOSES,
291+ )
292+from lp.soyuz.model.queue import PackageUploadCustom
293+
294+
295+class CustomUploadsCopier:
296+ """Copy `PackageUploadCustom` objects into a new `DistroSeries`."""
297+
298+ copyable_types = [
299+ PackageUploadCustomFormat.DEBIAN_INSTALLER,
300+ PackageUploadCustomFormat.DIST_UPGRADER,
301+ ]
302+
303+ def __init__(self, target_series):
304+ self.target_series = target_series
305+
306+ def isCopyable(self, upload):
307+ """Is `upload` the kind of `PackageUploadCustom` that we can copy?"""
308+ return upload.customformat in self.copyable_types
309+
310+ def getCandidateUploads(self, source_series):
311+ """Find custom uploads that may need copying."""
312+ uploads = source_series.getPackageUploads(
313+ custom_type=self.copyable_types)
314+ load_referencing(PackageUploadCustom, uploads, ['packageuploadID'])
315+ customs = sum([list(upload.customfiles) for upload in uploads], [])
316+ customs = filter(self.isCopyable, customs)
317+ customs.sort(key=attrgetter('id'), reverse=True)
318+ return customs
319+
320+ def extractNameFields(self, filename):
321+ """Get the relevant fields out of `filename`.
322+
323+ Scans filenames of any of these forms:
324+
325+ <package>_<version>_<architecture>.tar.<compression_suffix>
326+ <package>_<version>.tar[.<compression_suffix>]
327+
328+ Versions may contain dots, dashes etc. but no underscores.
329+
330+ :return: A tuple of (<architecture>, version); or None if the
331+ filename does not match the expected pattern. If no
332+ architecture is found in the filename, it defaults to 'all'.
333+ """
334+ # XXX JeroenVemreulen 2011-08-17, bug=827973: Push this down
335+ # into the CustomUpload-derived classes, and share it with their
336+ # constructors.
337+ regex_parts = {
338+ 'package': "[^_]+",
339+ 'version': "[^_]+",
340+ 'arch': "[^._]+",
341+ }
342+ filename_regex = (
343+ "%(package)s_(%(version)s)(?:_(%(arch)s))?.tar" % regex_parts)
344+ match = re.match(filename_regex, filename)
345+ if match is None:
346+ return None
347+ default_arch = 'all'
348+ fields = match.groups(default_arch)
349+ if len(fields) != 2:
350+ return None
351+ version, architecture = fields
352+ return (architecture, version)
353+
354+ def getKey(self, upload):
355+ """Get an indexing key for `upload`."""
356+ # XXX JeroenVermeulen 2011-08-17, bug=827941: For ddtp
357+ # translations tarballs, we'll have to include the component
358+ # name as well.
359+ custom_format = upload.customformat
360+ name_fields = self.extractNameFields(upload.libraryfilealias.filename)
361+ if name_fields is None:
362+ return None
363+ else:
364+ arch, version = name_fields
365+ return (custom_format, arch)
366+
367+ def getLatestUploads(self, source_series):
368+ """Find the latest uploads.
369+
370+ :param source_series: The `DistroSeries` whose uploads to get.
371+ :return: A dict containing the latest uploads, indexed by keys as
372+ returned by `getKey`.
373+ """
374+ latest_uploads = {}
375+ for upload in self.getCandidateUploads(source_series):
376+ key = self.getKey(upload)
377+ if key is not None:
378+ latest_uploads.setdefault(key, upload)
379+ return latest_uploads
380+
381+ def getTargetArchive(self, original_archive):
382+ """Find counterpart of `original_archive` in `self.target_series`.
383+
384+ :param original_archive: The `Archive` that the original upload went
385+ into. If this is not a primary, partner, or debug archive,
386+ None is returned.
387+ :return: The `Archive` of the same purpose for `self.target_series`.
388+ """
389+ if original_archive.purpose not in MAIN_ARCHIVE_PURPOSES:
390+ return None
391+ return getUtility(IArchiveSet).getByDistroPurpose(
392+ self.target_series.distribution, original_archive.purpose)
393+
394+ def isObsolete(self, upload, target_uploads):
395+ """Is `upload` superseded by one that the target series already has?
396+
397+ :param upload: A `PackageUploadCustom` from the source series.
398+ :param target_uploads:
399+ """
400+ existing_upload = target_uploads.get(self.getKey(upload))
401+ return existing_upload is not None and existing_upload.id >= upload.id
402+
403+ def copyUpload(self, original_upload):
404+ """Copy `original_upload` into `self.target_series`."""
405+ target_archive = self.getTargetArchive(
406+ original_upload.packageupload.archive)
407+ if target_archive is None:
408+ return None
409+ package_upload = self.target_series.createQueueEntry(
410+ PackagePublishingPocket.RELEASE, target_archive,
411+ changes_file_alias=original_upload.packageupload.changesfile)
412+ custom = package_upload.addCustom(
413+ original_upload.libraryfilealias, original_upload.customformat)
414+ package_upload.setAccepted()
415+ return custom
416+
417+ def copy(self, source_series):
418+ """Copy uploads from `source_series`."""
419+ target_uploads = self.getLatestUploads(self.target_series)
420+ for upload in self.getLatestUploads(source_series).itervalues():
421+ if not self.isObsolete(upload, target_uploads):
422+ self.copyUpload(upload)
423
424=== added file 'lib/lp/soyuz/scripts/tests/test_custom_uploads_copier.py'
425--- lib/lp/soyuz/scripts/tests/test_custom_uploads_copier.py 1970-01-01 00:00:00 +0000
426+++ lib/lp/soyuz/scripts/tests/test_custom_uploads_copier.py 2011-08-19 08:29:38 +0000
427@@ -0,0 +1,433 @@
428+# Copyright 2011 Canonical Ltd. This software is licensed under the
429+# GNU Affero General Public License version 3 (see the file LICENSE).
430+
431+"""Test copying of custom package uploads for a new `DistroSeries`."""
432+
433+__metaclass__ = type
434+
435+from canonical.testing.layers import (
436+ LaunchpadZopelessLayer,
437+ ZopelessLayer,
438+ )
439+from lp.registry.interfaces.pocket import PackagePublishingPocket
440+from lp.soyuz.enums import (
441+ ArchivePurpose,
442+ PackageUploadCustomFormat,
443+ PackageUploadStatus,
444+ )
445+from lp.soyuz.interfaces.archive import MAIN_ARCHIVE_PURPOSES
446+from lp.soyuz.scripts.custom_uploads_copier import CustomUploadsCopier
447+from lp.testing import TestCaseWithFactory
448+from lp.testing.fakemethod import FakeMethod
449+
450+
451+def list_custom_uploads(distroseries):
452+ """Return a list of all `PackageUploadCustom`s for `distroseries`."""
453+ return sum(
454+ [
455+ list(upload.customfiles)
456+ for upload in distroseries.getPackageUploads()],
457+ [])
458+
459+
460+class FakeDistroSeries:
461+ """Fake `DistroSeries` for test copiers that don't really need one."""
462+
463+
464+class FakeLibraryFileAlias:
465+ def __init__(self, filename):
466+ self.filename = filename
467+
468+
469+class FakeUpload:
470+ def __init__(self, customformat, filename):
471+ self.customformat = customformat
472+ self.libraryfilealias = FakeLibraryFileAlias(filename)
473+
474+
475+class CommonTestHelpers:
476+ """Helper(s) for these tests."""
477+ def makeVersion(self):
478+ """Create a fake version string."""
479+ return "%d.%d-%s" % (
480+ self.factory.getUniqueInteger(),
481+ self.factory.getUniqueInteger(),
482+ self.factory.getUniqueString())
483+
484+
485+class TestCustomUploadsCopierLite(TestCaseWithFactory, CommonTestHelpers):
486+ """Light-weight low-level tests for `CustomUploadsCopier`."""
487+
488+ layer = ZopelessLayer
489+
490+ def test_isCopyable_matches_copyable_types(self):
491+ # isCopyable checks a custom upload's customformat field to
492+ # determine whether the upload is a candidate for copying. It
493+ # approves only those whose customformats are in copyable_types.
494+ class FakePackageUploadCustom:
495+ def __init__(self, customformat):
496+ self.customformat = customformat
497+
498+ uploads = [
499+ FakePackageUploadCustom(custom_type)
500+ for custom_type in PackageUploadCustomFormat.items]
501+
502+ copier = CustomUploadsCopier(FakeDistroSeries())
503+ copied_uploads = filter(copier.isCopyable, uploads)
504+ self.assertContentEqual(
505+ CustomUploadsCopier.copyable_types,
506+ [upload.customformat for upload in copied_uploads])
507+
508+ def test_extractNameFields_extracts_architecture_and_version(self):
509+ # extractNameFields picks up the architecture and version out
510+ # of an upload's filename field.
511+ # XXX JeroenVermeulen 2011-08-17, bug=827941: For ddtp
512+ # translations tarballs, we'll have to include the component
513+ # name as well.
514+ package_name = self.factory.getUniqueString('package')
515+ version = self.makeVersion()
516+ architecture = self.factory.getUniqueString('arch')
517+ filename = '%s_%s_%s.tar.gz' % (package_name, version, architecture)
518+ copier = CustomUploadsCopier(FakeDistroSeries())
519+ self.assertEqual(
520+ (architecture, version), copier.extractNameFields(filename))
521+
522+ def test_extractNameFields_does_not_require_architecture(self):
523+ # When extractNameFields does not see an architecture, it
524+ # defaults to 'all'.
525+ package_name = self.factory.getUniqueString('package')
526+ version = self.makeVersion()
527+ filename = '%s_%s.tar.gz' % (package_name, version)
528+ copier = CustomUploadsCopier(FakeDistroSeries())
529+ self.assertEqual(
530+ ('all', version), copier.extractNameFields(filename))
531+
532+ def test_extractNameFields_returns_None_on_mismatch(self):
533+ # If the filename does not match the expected pattern,
534+ # extractNameFields returns None.
535+ copier = CustomUploadsCopier(FakeDistroSeries())
536+ self.assertIs(None, copier.extractNameFields('argh_1.0.jpg'))
537+
538+ def test_extractNameFields_ignores_names_with_too_many_fields(self):
539+ # As one particularly nasty case that might break
540+ # extractNameFields, a name with more underscore-seprated fields
541+ # than the search pattern allows for is sensibly rejected.
542+ copier = CustomUploadsCopier(FakeDistroSeries())
543+ self.assertIs(
544+ None, copier.extractNameFields('one_two_three_four_5.tar.gz'))
545+
546+ def test_getKey_returns_None_on_name_mismatch(self):
547+ # If extractNameFields returns None, getKey also returns None.
548+ copier = CustomUploadsCopier(FakeDistroSeries())
549+ copier.extractNameFields = FakeMethod()
550+ self.assertIs(
551+ None,
552+ copier.getKey(FakeUpload(
553+ PackageUploadCustomFormat.DEBIAN_INSTALLER,
554+ "bad-filename.tar")))
555+
556+
557+class TestCustomUploadsCopier(TestCaseWithFactory, CommonTestHelpers):
558+ """Heavyweight `CustomUploadsCopier` tests."""
559+
560+ # Alas, PackageUploadCustom relies on the Librarian.
561+ layer = LaunchpadZopelessLayer
562+
563+ def makeUpload(self, distroseries=None,
564+ custom_type=PackageUploadCustomFormat.DEBIAN_INSTALLER,
565+ version=None, arch=None):
566+ """Create a `PackageUploadCustom`."""
567+ if distroseries is None:
568+ distroseries = self.factory.makeDistroSeries()
569+ package_name = self.factory.getUniqueString("package")
570+ if version is None:
571+ version = self.makeVersion()
572+ filename = "%s.tar.gz" % '_'.join(
573+ filter(None, [package_name, version, arch]))
574+ package_upload = self.factory.makeCustomPackageUpload(
575+ distroseries=distroseries, custom_type=custom_type,
576+ filename=filename)
577+ return package_upload.customfiles[0]
578+
579+ def test_copies_custom_upload(self):
580+ # CustomUploadsCopier copies custom uploads from one series to
581+ # another.
582+ current_series = self.factory.makeDistroSeries()
583+ original_upload = self.makeUpload(current_series)
584+ new_series = self.factory.makeDistroSeries(
585+ distribution=current_series.distribution,
586+ previous_series=current_series)
587+
588+ CustomUploadsCopier(new_series).copy(current_series)
589+
590+ [copied_upload] = list_custom_uploads(new_series)
591+ self.assertEqual(
592+ original_upload.libraryfilealias, copied_upload.libraryfilealias)
593+
594+ def test_is_idempotent(self):
595+ # It's safe to perform the same copy more than once; the uploads
596+ # get copied only once.
597+ current_series = self.factory.makeDistroSeries()
598+ self.makeUpload(current_series)
599+ new_series = self.factory.makeDistroSeries(
600+ distribution=current_series.distribution,
601+ previous_series=current_series)
602+
603+ copier = CustomUploadsCopier(new_series)
604+ copier.copy(current_series)
605+ uploads_after_first_copy = list_custom_uploads(new_series)
606+ copier.copy(current_series)
607+ uploads_after_redundant_copy = list_custom_uploads(new_series)
608+
609+ self.assertEqual(
610+ uploads_after_first_copy, uploads_after_redundant_copy)
611+
612+ def test_getCandidateUploads_filters_by_distroseries(self):
613+ # getCandidateUploads ignores uploads for other distroseries.
614+ source_series = self.factory.makeDistroSeries()
615+ matching_upload = self.makeUpload(source_series)
616+ nonmatching_upload = self.makeUpload()
617+ copier = CustomUploadsCopier(FakeDistroSeries())
618+ candidate_uploads = copier.getCandidateUploads(source_series)
619+ self.assertContentEqual([matching_upload], candidate_uploads)
620+ self.assertNotIn(nonmatching_upload, candidate_uploads)
621+
622+ def test_getCandidateUploads_filters_upload_types(self):
623+ # getCandidateUploads returns only uploads of the types listed
624+ # in copyable_types; other types of upload are ignored.
625+ source_series = self.factory.makeDistroSeries()
626+ for custom_format in PackageUploadCustomFormat.items:
627+ self.makeUpload(source_series, custom_type=custom_format)
628+
629+ copier = CustomUploadsCopier(FakeDistroSeries())
630+ candidate_uploads = copier.getCandidateUploads(source_series)
631+ copied_types = [upload.customformat for upload in candidate_uploads]
632+ self.assertContentEqual(
633+ CustomUploadsCopier.copyable_types, copied_types)
634+
635+ def test_getCandidateUploads_ignores_other_attachments(self):
636+ # A PackageUpload can have multiple PackageUploadCustoms
637+ # attached, potentially of different types. getCandidateUploads
638+ # ignores PackageUploadCustoms of types that aren't supposed to
639+ # be copied, even if they are attached to PackageUploads that
640+ # also have PackageUploadCustoms that do need to be copied.
641+ source_series = self.factory.makeDistroSeries()
642+ package_upload = self.factory.makePackageUpload(
643+ distroseries=source_series, archive=source_series.main_archive)
644+ library_file = self.factory.makeLibraryFileAlias()
645+ matching_upload = package_upload.addCustom(
646+ library_file, PackageUploadCustomFormat.DEBIAN_INSTALLER)
647+ nonmatching_upload = package_upload.addCustom(
648+ library_file, PackageUploadCustomFormat.ROSETTA_TRANSLATIONS)
649+ copier = CustomUploadsCopier(FakeDistroSeries())
650+ candidates = copier.getCandidateUploads(source_series)
651+ self.assertContentEqual([matching_upload], candidates)
652+ self.assertNotIn(nonmatching_upload, candidates)
653+
654+ def test_getCandidateUploads_orders_newest_to_oldest(self):
655+ # getCandidateUploads returns its PackageUploadCustoms ordered
656+ # from newest to oldest.
657+ # XXX JeroenVermeulen 2011-08-17, bug=827967: Should compare by
658+ # Debian version string, not id.
659+ source_series = self.factory.makeDistroSeries()
660+ for counter in xrange(5):
661+ self.makeUpload(source_series)
662+ copier = CustomUploadsCopier(FakeDistroSeries())
663+ candidate_ids = [
664+ upload.id for upload in copier.getCandidateUploads(source_series)]
665+ self.assertEqual(sorted(candidate_ids, reverse=True), candidate_ids)
666+
667+ def test_getKey_includes_format_and_architecture(self):
668+ # The key returned by getKey consists of custom upload type,
669+ # and architecture.
670+ # XXX JeroenVermeulen 2011-08-17, bug=827941: To support
671+ # ddtp-translations uploads, this will have to include the
672+ # component name as well.
673+ source_series = self.factory.makeDistroSeries()
674+ upload = self.makeUpload(
675+ source_series, PackageUploadCustomFormat.DIST_UPGRADER,
676+ arch='mips')
677+ copier = CustomUploadsCopier(FakeDistroSeries())
678+ expected_key = (
679+ PackageUploadCustomFormat.DIST_UPGRADER,
680+ 'mips',
681+ )
682+ self.assertEqual(expected_key, copier.getKey(upload))
683+
684+ def test_getLatestUploads_indexes_uploads_by_key(self):
685+ # getLatestUploads returns a dict of uploads, indexed by keys
686+ # returned by getKey.
687+ source_series = self.factory.makeDistroSeries()
688+ upload = self.makeUpload(source_series)
689+ copier = CustomUploadsCopier(FakeDistroSeries())
690+ self.assertEqual(
691+ {copier.getKey(upload): upload},
692+ copier.getLatestUploads(source_series))
693+
694+ def test_getLatestUploads_filters_superseded_uploads(self):
695+ # getLatestUploads returns only the latest upload for a given
696+ # distroseries, type, package, and architecture. Any older
697+ # uploads with the same distroseries, type, package name, and
698+ # architecture are ignored.
699+ source_series = self.factory.makeDistroSeries()
700+ uploads = [
701+ self.makeUpload(
702+ source_series, version='1.0.%d' % counter, arch='ppc')
703+ for counter in xrange(3)]
704+
705+ copier = CustomUploadsCopier(FakeDistroSeries())
706+ self.assertContentEqual(
707+ uploads[-1:], copier.getLatestUploads(source_series).values())
708+
709+ def test_getLatestUploads_bundles_versions(self):
710+ # getLatestUploads sees an upload as superseding an older one
711+ # for the same distroseries, type, package name, and
712+ # architecture even if they have different versions.
713+ source_series = self.factory.makeDistroSeries()
714+ uploads = [
715+ self.makeUpload(source_series, arch='i386')
716+ for counter in xrange(2)]
717+ copier = CustomUploadsCopier(FakeDistroSeries())
718+ self.assertContentEqual(
719+ uploads[-1:], copier.getLatestUploads(source_series).values())
720+
721+ def test_getTargetArchive_on_same_distro_is_same_archive(self):
722+ # When copying within the same distribution, getTargetArchive
723+ # always returns the same archive you feed it.
724+ distro = self.factory.makeDistribution()
725+ archives = [
726+ self.factory.makeArchive(distribution=distro, purpose=purpose)
727+ for purpose in MAIN_ARCHIVE_PURPOSES]
728+ copier = CustomUploadsCopier(self.factory.makeDistroSeries(distro))
729+ self.assertEqual(
730+ archives,
731+ [copier.getTargetArchive(archive) for archive in archives])
732+
733+ def test_getTargetArchive_returns_None_if_not_distribution_archive(self):
734+ # getTargetArchive returns None for any archive that is not a
735+ # distribution archive, regardless of whether the target series
736+ # has an equivalent.
737+ distro = self.factory.makeDistribution()
738+ archives = [
739+ self.factory.makeArchive(distribution=distro, purpose=purpose)
740+ for purpose in ArchivePurpose.items
741+ if purpose not in MAIN_ARCHIVE_PURPOSES]
742+ copier = CustomUploadsCopier(self.factory.makeDistroSeries(distro))
743+ self.assertEqual(
744+ [None] * len(archives),
745+ [copier.getTargetArchive(archive) for archive in archives])
746+
747+ def test_getTargetArchive_finds_matching_archive(self):
748+ # When copying across archives, getTargetArchive looks for an
749+ # archive for the target series with the same purpose as the
750+ # original archive.
751+ source_series = self.factory.makeDistroSeries()
752+ source_archive = self.factory.makeArchive(
753+ distribution=source_series.distribution,
754+ purpose=ArchivePurpose.PARTNER)
755+ target_series = self.factory.makeDistroSeries()
756+ target_archive = self.factory.makeArchive(
757+ distribution=target_series.distribution,
758+ purpose=ArchivePurpose.PARTNER)
759+
760+ copier = CustomUploadsCopier(target_series)
761+ self.assertEqual(
762+ target_archive, copier.getTargetArchive(source_archive))
763+
764+ def test_getTargetArchive_returns_None_if_no_archive_matches(self):
765+ # If the target series has no archive to match the archive that
766+ # the original upload was far, it returns None.
767+ source_series = self.factory.makeDistroSeries()
768+ source_archive = self.factory.makeArchive(
769+ distribution=source_series.distribution,
770+ purpose=ArchivePurpose.PARTNER)
771+ target_series = self.factory.makeDistroSeries()
772+ copier = CustomUploadsCopier(target_series)
773+ self.assertIs(None, copier.getTargetArchive(source_archive))
774+
775+ def test_isObsolete_returns_False_if_no_equivalent_in_target(self):
776+ # isObsolete returns False if the upload in question has no
777+ # equivalent in the target series.
778+ source_series = self.factory.makeDistroSeries()
779+ upload = self.makeUpload(source_series)
780+ target_series = self.factory.makeDistroSeries()
781+ copier = CustomUploadsCopier(target_series)
782+ self.assertFalse(
783+ copier.isObsolete(upload, copier.getLatestUploads(target_series)))
784+
785+ def test_isObsolete_returns_False_if_target_has_older_equivalent(self):
786+ # isObsolete returns False if the target has an equivlalent of
787+ # the upload in question, but it's older than the version the
788+ # source series has.
789+ source_series = self.factory.makeDistroSeries()
790+ target_series = self.factory.makeDistroSeries()
791+ self.makeUpload(target_series, arch='ppc64')
792+ source_upload = self.makeUpload(source_series, arch='ppc64')
793+ copier = CustomUploadsCopier(target_series)
794+ self.assertFalse(
795+ copier.isObsolete(
796+ source_upload, copier.getLatestUploads(target_series)))
797+
798+ def test_isObsolete_returns_True_if_target_has_newer_equivalent(self):
799+ # isObsolete returns False if the target series already has a
800+ # newer equivalent of the upload in question (as would be the
801+ # case, for instance, if the upload had already been copied).
802+ source_series = self.factory.makeDistroSeries()
803+ source_upload = self.makeUpload(source_series, arch='alpha')
804+ target_series = self.factory.makeDistroSeries()
805+ self.makeUpload(target_series, arch='alpha')
806+ copier = CustomUploadsCopier(target_series)
807+ self.assertTrue(
808+ copier.isObsolete(
809+ source_upload, copier.getLatestUploads(target_series)))
810+
811+ def test_copyUpload_creates_upload(self):
812+ # copyUpload creates a new upload that's very similar to the
813+ # original, but for the target series.
814+ original_upload = self.makeUpload()
815+ target_series = self.factory.makeDistroSeries()
816+ copier = CustomUploadsCopier(target_series)
817+ copied_upload = copier.copyUpload(original_upload)
818+ self.assertEqual([copied_upload], list_custom_uploads(target_series))
819+ self.assertNotEqual(
820+ original_upload.packageupload, copied_upload.packageupload)
821+ self.assertEqual(
822+ original_upload.customformat, copied_upload.customformat)
823+ self.assertEqual(
824+ original_upload.libraryfilealias, copied_upload.libraryfilealias)
825+ self.assertEqual(
826+ original_upload.packageupload.changesfile,
827+ copied_upload.packageupload.changesfile)
828+
829+ def test_copyUpload_copies_into_release_pocket(self):
830+ # copyUpload copies the original upload into the release pocket,
831+ # even though the original is more likely to be in another
832+ # pocket.
833+ original_upload = self.makeUpload()
834+ original_upload.packageupload.pocket = PackagePublishingPocket.UPDATES
835+ target_series = self.factory.makeDistroSeries()
836+ copier = CustomUploadsCopier(target_series)
837+ copied_upload = copier.copyUpload(original_upload)
838+ self.assertEqual(
839+ PackagePublishingPocket.RELEASE,
840+ copied_upload.packageupload.pocket)
841+
842+ def test_copyUpload_accepts_upload(self):
843+ # Uploads created by copyUpload are automatically accepted.
844+ original_upload = self.makeUpload()
845+ target_series = self.factory.makeDistroSeries()
846+ copier = CustomUploadsCopier(target_series)
847+ copied_upload = copier.copyUpload(original_upload)
848+ self.assertEqual(
849+ PackageUploadStatus.ACCEPTED, copied_upload.packageupload.status)
850+
851+ def test_copyUpload_does_not_copy_if_no_archive_matches(self):
852+ # If getTargetArchive does not find an appropriate target
853+ # archive, copyUpload does nothing.
854+ source_series = self.factory.makeDistroSeries()
855+ upload = self.makeUpload(distroseries=source_series)
856+ target_series = self.factory.makeDistroSeries()
857+ copier = CustomUploadsCopier(target_series)
858+ copier.getTargetArchive = FakeMethod(result=None)
859+ self.assertIs(None, copier.copyUpload(upload))
860+ self.assertEqual([], list_custom_uploads(target_series))
861
862=== modified file 'lib/lp/soyuz/tests/test_publishing.py'
863--- lib/lp/soyuz/tests/test_publishing.py 2011-08-12 05:06:21 +0000
864+++ lib/lp/soyuz/tests/test_publishing.py 2011-08-19 08:29:38 +0000
865@@ -159,7 +159,7 @@
866 signing_key = self.person.gpg_keys[0]
867 package_upload = distroseries.createQueueEntry(
868 pocket, archive, changes_file_name, changes_file_content,
869- signing_key)
870+ signing_key=signing_key)
871
872 status_to_method = {
873 PackageUploadStatus.DONE: 'setDone',