Merge lp:~cjwatson/launchpad/series-alias into lp:launchpad

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: no longer in the source branch.
Merged at revision: 16747
Proposed branch: lp:~cjwatson/launchpad/series-alias
Merge into: lp:launchpad
Diff against target: 1011 lines (+383/-98)
22 files modified
lib/lp/archivepublisher/publishing.py (+106/-11)
lib/lp/archivepublisher/tests/test_generate_extra_overrides.py (+9/-8)
lib/lp/archivepublisher/tests/test_publisher.py (+43/-2)
lib/lp/archivepublisher/tests/test_repositoryindexfile.py (+4/-4)
lib/lp/archivepublisher/utils.py (+7/-6)
lib/lp/archiveuploader/tests/test_uploadpolicy.py (+16/-1)
lib/lp/archiveuploader/uploadpolicy.py (+3/-3)
lib/lp/archiveuploader/uploadprocessor.py (+2/-2)
lib/lp/registry/browser/distribution.py (+7/-0)
lib/lp/registry/configure.zcml (+1/-0)
lib/lp/registry/doc/distroseries.txt (+11/-0)
lib/lp/registry/interfaces/distribution.py (+19/-3)
lib/lp/registry/interfaces/distroseries.py (+4/-2)
lib/lp/registry/model/distribution.py (+26/-9)
lib/lp/registry/model/distroseries.py (+11/-2)
lib/lp/registry/stories/distribution/xx-distribution-overview.txt (+60/-32)
lib/lp/registry/stories/webservice/xx-distribution.txt (+5/-2)
lib/lp/registry/tests/test_distribution.py (+17/-7)
lib/lp/soyuz/interfaces/archive.py (+24/-0)
lib/lp/soyuz/model/archive.py (+3/-1)
lib/lp/soyuz/scripts/publishdistro.py (+3/-1)
lib/lp/soyuz/scripts/tests/test_publishdistro.py (+2/-2)
To merge this branch: bzr merge lp:~cjwatson/launchpad/series-alias
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+178103@code.launchpad.net

Commit message

Implement Distribution.development_series_alias. This refers to the latest series with any active publications for archive publication, and to Distribution.currentseries for uploads and web redirects.

Description of the change

== Summary ==

We want to be able to set an alias for the development series of Ubuntu, and have that be automatically resolved for the purpose of downloads (i.e. symlinks in the archive) and uploads. Bug 1198279.

== Proposed fix ==

We have a new Distribution.development_series_alias DB column, which we'll use for this. When publishing an archive, symlink this to the latest series with any active publications. When uploading a package, automatically map the alias to Distribution.currentseries. When traversing /<distribution>/<alias> on the web UI, redirect to /<distribution>/<currentseries>.

== Pre-implementation notes ==

This was rather more complex than initially supposed, mainly due to difficulties with PPAs, where the distribution's current series doesn't necessarily exist at all. We want people to be able to assume that the alias exists, but don't want to have to copy packages forward in PPAs or forcibly republish all PPAs any time the series changes. After some debate with William, we ended up with the compromise that we'll make the alias point to the latest series with any active publications, which should generally correspond to where most work is happening.

Otherwise, it's largely straightforward, although William dissuaded me from making the relevant internal APIs follow aliases in all situations. Instead, I've added a follow_aliases keyword argument in a few places so that those callers that explicitly want alias resolution can ask for it. Distribution.getSeries forces follow_aliases=True on the webservice.

== LOC Rationale ==

+253. I claim credit and will try to find something to offset this later ...

== Tests ==

Touches lots of registry code. Probably best to run the lot. I ran registry, soyuz, archiveuploader, and archivepublisher.

== Demo and Q/A ==

We should QA at least the following paths:

 * Publish primary archive, check symlinks
 * Publish PPAs with various series/publication combinations, check symlinks
 * Upload to devel
 * Visit /ubuntu/devel and some URLs underneath it in a web browser

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) wrote :

20 + def _allIndexFiles(self, distroseries):
21 + """Return all index files on disk for a distroseries."""

This method seems like it should mostly be elsewhere. It's not reasonably practical to factor out the commonalities somehow?

81 + if pocket == PackagePublishingPocket.RELEASE:
82 + alias_suite = alias
83 + else:
84 + alias_suite = "%s%s" % (alias, pocketsuffix[pocket])

This need not be special-cased; pocketsuffix[RELEASE] == "".

57 + def createSeriesAliases(self):

This has the slightly weird behavior that a single missing current suite will cause just that alias to point to the old series, while the other suites point to the new one. This probably can't happen in production, because primary archive indices are always all generated for all suites on series creation, even if empty.

732 distribution = self.distribution
733 if to_series is not None:
734 result = getUtility(IDistroSeriesSet).queryByName(
735 - distribution, to_series)
736 + distribution, to_series, follow_aliases=True)
737 if result is None:
738 raise NoSuchDistroSeries(to_series)
739 series = result

This is probably fairly confusing for PPAs. You can copy to devel and it'll actually be different from the current devel symlink in the PPA, with no way to tell beforehand. But we probably can't avoid it given the current fairly braindead method signature.

review: Approve (code)
Revision history for this message
Colin Watson (cjwatson) wrote :

On Wed, Aug 28, 2013 at 05:47:36AM -0000, William Grant wrote:
> 20 + def _allIndexFiles(self, distroseries):
> 21 + """Return all index files on disk for a distroseries."""
>
> This method seems like it should mostly be elsewhere. It's not reasonably practical to factor out the commonalities somehow?

I refactored this method a little bit internally to make it less
repetitive, but I'm not sure what you mean in general. The Publisher
class knows about paths to the various index files, so it seems to make
sense for a method that returns them all to live there.

Do you just mean that _allIndexFiles should be split up or folded in
somewhere else? I could have written it inline in
_latestNonEmptySeries, but it was pretty verbose when open-coded, and it
seemed nicer to use a generator than to build up a list.

> 57 + def createSeriesAliases(self):
>
> This has the slightly weird behavior that a single missing current suite will cause just that alias to point to the old series, while the other suites point to the new one. This probably can't happen in production, because primary archive indices are always all generated for all suites on series creation, even if empty.

The only alternatives I can think of here are:

 1) this behaviour
 2) sometimes have aliases which are dangling symlinks
 3) hold all the aliases back to the older series
 4) don't create any aliases at all on mismatch

I don't think 2) should be allowed. 3) has the problem that if we're
considering cases that ought not to happen in production then it's
entirely possible that there's no consistent alias to select. So the
alternatives appear to be 1) or 4). 4) would be confusing in a
different way ("why are there no aliases?") - what do you think?

> 732 distribution = self.distribution
> 733 if to_series is not None:
> 734 result = getUtility(IDistroSeriesSet).queryByName(
> 735 - distribution, to_series)
> 736 + distribution, to_series, follow_aliases=True)
> 737 if result is None:
> 738 raise NoSuchDistroSeries(to_series)
> 739 series = result
>
> This is probably fairly confusing for PPAs. You can copy to devel and it'll actually be different from the current devel symlink in the PPA, with no way to tell beforehand. But we probably can't avoid it given the current fairly braindead method signature.

Yes. As discussed at the releng sprint, I think we do want this
behaviour as it has the right properties of encouraging developers to
stick with the current development series, but it's true that it can be
confusing.

I've added a set of comments to the interfaces, which is probably the
best I can do.

Revision history for this message
William Grant (wgrant) wrote :

On 29/08/13 02:42, Colin Watson wrote:
> On Wed, Aug 28, 2013 at 05:47:36AM -0000, William Grant wrote:
>> 20 + def _allIndexFiles(self, distroseries):
>> 21 + """Return all index files on disk for a distroseries."""
>>
>> This method seems like it should mostly be elsewhere. It's not reasonably practical to factor out the commonalities somehow?
>
> I refactored this method a little bit internally to make it less
> repetitive, but I'm not sure what you mean in general. The Publisher
> class knows about paths to the various index files, so it seems to make
> sense for a method that returns them all to live there.
>
> Do you just mean that _allIndexFiles should be split up or folded in
> somewhere else? I could have written it inline in
> _latestNonEmptySeries, but it was pretty verbose when open-coded, and it
> seemed nicer to use a generator than to build up a list.

Other parts of the publisher know how to generate the paths to all those
indices, but I guess it's probably impractical to factor it out.

>> 57 + def createSeriesAliases(self):
>>
>> This has the slightly weird behavior that a single missing current suite will cause just that alias to point to the old series, while the other suites point to the new one. This probably can't happen in production, because primary archive indices are always all generated for all suites on series creation, even if empty.
>
> The only alternatives I can think of here are:
>
> 1) this behaviour
> 2) sometimes have aliases which are dangling symlinks
> 3) hold all the aliases back to the older series
> 4) don't create any aliases at all on mismatch
>
> I don't think 2) should be allowed. 3) has the problem that if we're
> considering cases that ought not to happen in production then it's
> entirely possible that there's no consistent alias to select. So the
> alternatives appear to be 1) or 4). 4) would be confusing in a
> different way ("why are there no aliases?") - what do you think?

I would probably have done 4, but 1 is fine.

>> 732 distribution = self.distribution
>> 733 if to_series is not None:
>> 734 result = getUtility(IDistroSeriesSet).queryByName(
>> 735 - distribution, to_series)
>> 736 + distribution, to_series, follow_aliases=True)
>> 737 if result is None:
>> 738 raise NoSuchDistroSeries(to_series)
>> 739 series = result
>>
>> This is probably fairly confusing for PPAs. You can copy to devel and it'll actually be different from the current devel symlink in the PPA, with no way to tell beforehand. But we probably can't avoid it given the current fairly braindead method signature.
>
> Yes. As discussed at the releng sprint, I think we do want this
> behaviour as it has the right properties of encouraging developers to
> stick with the current development series, but it's true that it can be
> confusing.
>
> I've added a set of comments to the interfaces, which is probably the
> best I can do.

Thanks, looks good.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/archivepublisher/publishing.py'
2--- lib/lp/archivepublisher/publishing.py 2013-07-05 15:01:08 +0000
3+++ lib/lp/archivepublisher/publishing.py 2013-08-29 10:30:37 +0000
4@@ -41,7 +41,10 @@
5 get_ppa_reference,
6 RepositoryIndexFile,
7 )
8-from lp.registry.interfaces.pocket import PackagePublishingPocket
9+from lp.registry.interfaces.pocket import (
10+ PackagePublishingPocket,
11+ pocketsuffix,
12+ )
13 from lp.registry.interfaces.series import SeriesStatus
14 from lp.services.database.constants import UTC_NOW
15 from lp.services.database.sqlbase import sqlvalues
16@@ -158,6 +161,22 @@
17 return Publisher(log, pubconf, disk_pool, archive, allowed_suites)
18
19
20+def get_sources_path(config, suite_name, component):
21+ """Return path to Sources file for the given arguments."""
22+ return os.path.join(
23+ config.distsroot, suite_name, component.name, "source", "Sources")
24+
25+
26+def get_packages_path(config, suite_name, component, arch, subcomp=None):
27+ """Return path to Packages file for the given arguments."""
28+ component_root = os.path.join(config.distsroot, suite_name, component.name)
29+ arch_path = "binary-%s" % arch.architecturetag
30+ if subcomp is None:
31+ return os.path.join(component_root, arch_path, "Packages")
32+ else:
33+ return os.path.join(component_root, subcomp, arch_path, "Packages")
34+
35+
36 class I18nIndex(_multivalued):
37 """Represents an i18n/Index file."""
38 _multivalued_fields = {
39@@ -417,6 +436,85 @@
40 self.checkDirtySuiteBeforePublishing(distroseries, pocket)
41 self._writeSuite(distroseries, pocket)
42
43+ def _allIndexFiles(self, distroseries):
44+ """Return all index files on disk for a distroseries."""
45+ components = self.archive.getComponentsForSeries(distroseries)
46+ for pocket in self.archive.getPockets():
47+ suite_name = distroseries.getSuite(pocket)
48+ for component in components:
49+ yield get_sources_path(self._config, suite_name, component)
50+ for arch in distroseries.architectures:
51+ if not arch.enabled:
52+ continue
53+ arch_path = "binary-%s" % arch.architecturetag
54+ yield get_packages_path(
55+ self._config, suite_name, component, arch)
56+ for subcomp in self.subcomponents:
57+ yield get_packages_path(
58+ self._config, suite_name, component, arch, subcomp)
59+
60+ def _latestNonEmptySeries(self):
61+ """Find the latest non-empty series in an archive.
62+
63+ Doing this properly (series with highest version and any active
64+ publications) is expensive. However, we just went to the effort of
65+ publishing everything; so a quick-and-dirty approach is to look
66+ through what we published on disk.
67+ """
68+ for distroseries in self.distro:
69+ for index in self._allIndexFiles(distroseries):
70+ try:
71+ if os.path.getsize(index) > 0:
72+ return distroseries
73+ except OSError:
74+ pass
75+
76+ def createSeriesAliases(self):
77+ """Ensure that any series aliases exist.
78+
79+ The natural implementation would be to point the alias at
80+ self.distro.currentseries, but that works poorly for PPAs, where
81+ it's possible that no packages have been published for the current
82+ series. We also don't want to have to go through and republish all
83+ PPAs when we create a new series. Thus, we instead do the best we
84+ can by pointing the alias at the latest series with any publications
85+ in the archive, which is the best approximation to a development
86+ series for that PPA.
87+
88+ This does mean that the published alias might point to an older
89+ series, then you upload something to the alias and find that the
90+ alias has now moved to a newer series. What can I say? The
91+ requirements are not entirely coherent for PPAs given that packages
92+ are not automatically copied forward.
93+ """
94+ alias = self.distro.development_series_alias
95+ if alias is not None:
96+ current = self._latestNonEmptySeries()
97+ if current is None:
98+ return
99+ for pocket in self.archive.getPockets():
100+ alias_suite = "%s%s" % (alias, pocketsuffix[pocket])
101+ current_suite = current.getSuite(pocket)
102+ current_suite_path = os.path.join(
103+ self._config.distsroot, current_suite)
104+ if not os.path.isdir(current_suite_path):
105+ continue
106+ alias_suite_path = os.path.join(
107+ self._config.distsroot, alias_suite)
108+ if os.path.islink(alias_suite_path):
109+ if os.readlink(alias_suite_path) == current_suite:
110+ continue
111+ elif os.path.isdir(alias_suite_path):
112+ # Perhaps somebody did something misguided ...
113+ self.log.warning(
114+ "Alias suite path %s is a directory!" % alias_suite)
115+ continue
116+ try:
117+ os.unlink(alias_suite_path)
118+ except OSError:
119+ pass
120+ os.symlink(current_suite, alias_suite_path)
121+
122 def _writeComponentIndexes(self, distroseries, pocket, component):
123 """Write Index files for single distroseries + pocket + component.
124
125@@ -431,10 +529,9 @@
126
127 self.log.debug("Generating Sources")
128
129- source_index_root = os.path.join(
130- self._config.distsroot, suite_name, component.name, 'source')
131 source_index = RepositoryIndexFile(
132- source_index_root, self._config.temproot, 'Sources')
133+ get_sources_path(self._config, suite_name, component),
134+ self._config.temproot)
135
136 for spp in distroseries.getSourcePackagePublishing(
137 pocket, component, self.archive):
138@@ -452,17 +549,15 @@
139 self.log.debug("Generating Packages for %s" % arch_path)
140
141 indices = {}
142- package_index_root = os.path.join(
143- self._config.distsroot, suite_name, component.name, arch_path)
144 indices[None] = RepositoryIndexFile(
145- package_index_root, self._config.temproot, 'Packages')
146+ get_packages_path(self._config, suite_name, component, arch),
147+ self._config.temproot)
148
149 for subcomp in self.subcomponents:
150- sub_index_root = os.path.join(
151- self._config.distsroot, suite_name, component.name,
152- subcomp, arch_path)
153 indices[subcomp] = RepositoryIndexFile(
154- sub_index_root, self._config.temproot, 'Packages')
155+ get_packages_path(
156+ self._config, suite_name, component, arch, subcomp),
157+ self._config.temproot)
158
159 for bpp in distroseries.getBinaryPackagePublishing(
160 arch.architecturetag, pocket, component, self.archive):
161
162=== modified file 'lib/lp/archivepublisher/tests/test_generate_extra_overrides.py'
163--- lib/lp/archivepublisher/tests/test_generate_extra_overrides.py 2013-05-23 07:06:42 +0000
164+++ lib/lp/archivepublisher/tests/test_generate_extra_overrides.py 2013-08-29 10:30:37 +0000
165@@ -18,6 +18,10 @@
166 )
167 import transaction
168
169+from lp.archivepublisher.publishing import (
170+ get_packages_path,
171+ get_sources_path,
172+ )
173 from lp.archivepublisher.scripts.generate_extra_overrides import (
174 AtomicFile,
175 GenerateExtraOverrides,
176@@ -167,12 +171,9 @@
177 ensure_directory_exists(script.config.temproot)
178
179 for component in distroseries.components:
180- index_root = os.path.join(
181- script.config.distsroot, distroseries.name, component.name)
182-
183- source_index_root = os.path.join(index_root, "source")
184 source_index = RepositoryIndexFile(
185- source_index_root, script.config.temproot, "Sources")
186+ get_sources_path(script.config, distroseries.name, component),
187+ script.config.temproot)
188 for spp in distroseries.getSourcePackagePublishing(
189 PackagePublishingPocket.RELEASE, component,
190 distroseries.main_archive):
191@@ -181,10 +182,10 @@
192 source_index.close()
193
194 for arch in distroseries.architectures:
195- package_index_root = os.path.join(
196- index_root, "binary-%s" % arch.architecturetag)
197 package_index = RepositoryIndexFile(
198- package_index_root, script.config.temproot, "Packages")
199+ get_packages_path(
200+ script.config, distroseries.name, component, arch),
201+ script.config.temproot)
202 for bpp in distroseries.getBinaryPackagePublishing(
203 arch.architecturetag, PackagePublishingPocket.RELEASE,
204 component, distroseries.main_archive):
205
206=== modified file 'lib/lp/archivepublisher/tests/test_publisher.py'
207--- lib/lp/archivepublisher/tests/test_publisher.py 2013-06-18 07:17:20 +0000
208+++ lib/lp/archivepublisher/tests/test_publisher.py 2013-08-29 10:30:37 +0000
209@@ -1,4 +1,4 @@
210-# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
211+# Copyright 2009-2013 Canonical Ltd. This software is licensed under the
212 # GNU Affero General Public License version 3 (see the file LICENSE).
213
214 """Tests for publisher class."""
215@@ -1173,6 +1173,47 @@
216 self.assertReleaseContentsMatch(
217 release, 'main/i18n/Index', i18n_index_file.read())
218
219+ def testCreateSeriesAliasesNoAlias(self):
220+ """createSeriesAliases has nothing to do by default."""
221+ publisher = Publisher(
222+ self.logger, self.config, self.disk_pool,
223+ self.ubuntutest.main_archive)
224+ publisher.createSeriesAliases()
225+ self.assertEqual([], os.listdir(self.config.distsroot))
226+
227+ def _assertPublishesSeriesAlias(self, publisher, expected):
228+ publisher.A_publish(False)
229+ publisher.C_writeIndexes(False)
230+ publisher.createSeriesAliases()
231+ self.assertTrue(os.path.exists(os.path.join(
232+ self.config.distsroot, expected)))
233+ for pocket, suffix in pocketsuffix.items():
234+ path = os.path.join(self.config.distsroot, "devel%s" % suffix)
235+ expected_path = os.path.join(
236+ self.config.distsroot, expected + suffix)
237+ # A symlink for the RELEASE pocket exists. Symlinks for other
238+ # pockets only exist if the respective targets exist.
239+ if not suffix or os.path.exists(expected_path):
240+ self.assertTrue(os.path.islink(path))
241+ self.assertEqual(expected + suffix, os.readlink(path))
242+ else:
243+ self.assertFalse(os.path.islink(path))
244+
245+ def testCreateSeriesAliasesChangesAlias(self):
246+ """createSeriesAliases tracks the latest published series."""
247+ publisher = Publisher(
248+ self.logger, self.config, self.disk_pool,
249+ self.ubuntutest.main_archive)
250+ self.ubuntutest.development_series_alias = "devel"
251+ # Oddly, hoary-test has a higher version than breezy-autotest.
252+ self.getPubSource(distroseries=self.ubuntutest["breezy-autotest"])
253+ self._assertPublishesSeriesAlias(publisher, "breezy-autotest")
254+ hoary_pub = self.getPubSource(
255+ distroseries=self.ubuntutest["hoary-test"])
256+ self._assertPublishesSeriesAlias(publisher, "hoary-test")
257+ hoary_pub.requestDeletion(self.ubuntutest.owner)
258+ self._assertPublishesSeriesAlias(publisher, "breezy-autotest")
259+
260 def testHtaccessForPrivatePPA(self):
261 # A htaccess file is created for new private PPA's.
262
263@@ -1222,7 +1263,7 @@
264 # Write a zero-length Translation-en file and compressed versions of
265 # it.
266 translation_en_index = RepositoryIndexFile(
267- i18n_root, self.config.temproot, 'Translation-en')
268+ os.path.join(i18n_root, 'Translation-en'), self.config.temproot)
269 translation_en_index.close()
270
271 all_files = set()
272
273=== modified file 'lib/lp/archivepublisher/tests/test_repositoryindexfile.py'
274--- lib/lp/archivepublisher/tests/test_repositoryindexfile.py 2010-07-18 00:24:06 +0000
275+++ lib/lp/archivepublisher/tests/test_repositoryindexfile.py 2013-08-29 10:30:37 +0000
276@@ -39,7 +39,7 @@
277 'temp_root'.
278 """
279 return RepositoryIndexFile(
280- self.root, self.temp_root, filename)
281+ os.path.join(self.root, filename), self.temp_root)
282
283 def testWorkflow(self):
284 """`RepositoryIndexFile` workflow.
285@@ -116,7 +116,7 @@
286 """`RepositoryIndexFile` creates given 'root' path if necessary."""
287 missing_root = os.path.join(self.root, 'donotexist')
288 repo_file = RepositoryIndexFile(
289- missing_root, self.temp_root, 'boing')
290+ os.path.join(missing_root, 'boing'), self.temp_root)
291
292 self.assertFalse(os.path.exists(missing_root))
293
294@@ -130,5 +130,5 @@
295 """`RepositoryIndexFile` cannot be given a missing 'temp_root'."""
296 missing_temp_root = os.path.join(self.temp_root, 'donotexist')
297 self.assertRaises(
298- AssertionError,
299- RepositoryIndexFile, self.root, missing_temp_root, 'boing')
300+ AssertionError, RepositoryIndexFile,
301+ os.path.join(self.root, 'boing'), missing_temp_root)
302
303=== modified file 'lib/lp/archivepublisher/utils.py'
304--- lib/lp/archivepublisher/utils.py 2012-08-17 11:13:39 +0000
305+++ lib/lp/archivepublisher/utils.py 2013-08-29 10:30:37 +0000
306@@ -1,7 +1,7 @@
307 # Copyright 2009-2011 Canonical Ltd. This software is licensed under the
308 # GNU Affero General Public License version 3 (see the file LICENSE).
309
310-"""Miscelaneous functions for publisher."""
311+"""Miscellaneous functions for publisher."""
312
313 __metaclass__ = type
314
315@@ -101,16 +101,17 @@
316 (plain, gzip and bzip2) transparently and atomically.
317 """
318
319- def __init__(self, root, temp_root, filename):
320+ def __init__(self, path, temp_root):
321 """Store repositories destinations and filename.
322
323- The given 'temp_root' needs to exist, on the other hand, 'root'
324- will be created on `close` if it doesn't exist.
325+ The given 'temp_root' needs to exist; on the other hand, the
326+ directory containing 'path' will be created on `close` if it doesn't
327+ exist.
328
329- Additionally creates the needs temporary files in the given
330+ Additionally creates the needed temporary files in the given
331 'temp_root'.
332 """
333- self.root = root
334+ self.root, filename = os.path.split(path)
335 assert os.path.exists(temp_root), 'Temporary root does not exist.'
336
337 self.index_files = (
338
339=== modified file 'lib/lp/archiveuploader/tests/test_uploadpolicy.py'
340--- lib/lp/archiveuploader/tests/test_uploadpolicy.py 2012-10-25 09:02:11 +0000
341+++ lib/lp/archiveuploader/tests/test_uploadpolicy.py 2013-08-29 10:30:37 +0000
342@@ -1,6 +1,6 @@
343 #!/usr/bin/python
344 #
345-# Copyright 2010-2012 Canonical Ltd. This software is licensed under the
346+# Copyright 2010-2013 Canonical Ltd. This software is licensed under the
347 # GNU Affero General Public License version 3 (see the file LICENSE).
348
349 from zope.component import getUtility
350@@ -21,6 +21,7 @@
351 from lp.services.database.sqlbase import flush_database_updates
352 from lp.testing import (
353 celebrity_logged_in,
354+ person_logged_in,
355 TestCase,
356 TestCaseWithFactory,
357 )
358@@ -194,6 +195,20 @@
359 NotFoundError, policy.setDistroSeriesAndPocket,
360 'nonexistent_security')
361
362+ def test_setDistroSeriesAndPocket_honours_aliases(self):
363+ # setDistroSeriesAndPocket honours uploads to the development series
364+ # alias, if set.
365+ policy = AbstractUploadPolicy()
366+ policy.distro = self.factory.makeDistribution()
367+ series = self.factory.makeDistroSeries(
368+ distribution=policy.distro, status=SeriesStatus.DEVELOPMENT)
369+ self.assertRaises(
370+ NotFoundError, policy.setDistroSeriesAndPocket, "devel")
371+ with person_logged_in(policy.distro.owner):
372+ policy.distro.development_series_alias = "devel"
373+ policy.setDistroSeriesAndPocket("devel")
374+ self.assertEqual(series, policy.distroseries)
375+
376 def test_redirect_release_uploads_primary(self):
377 # With the insecure policy, the
378 # Distribution.redirect_release_uploads flag causes uploads to the
379
380=== modified file 'lib/lp/archiveuploader/uploadpolicy.py'
381--- lib/lp/archiveuploader/uploadpolicy.py 2012-10-25 09:02:11 +0000
382+++ lib/lp/archiveuploader/uploadpolicy.py 2013-08-29 10:30:37 +0000
383@@ -1,4 +1,4 @@
384-# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
385+# Copyright 2009-2013 Canonical Ltd. This software is licensed under the
386 # GNU Affero General Public License version 3 (see the file LICENSE).
387
388 """Policy management for the upload handler."""
389@@ -145,8 +145,8 @@
390 return
391
392 self.distroseriesname = dr_name
393- (self.distroseries,
394- self.pocket) = self.distro.getDistroSeriesAndPocket(dr_name)
395+ self.distroseries, self.pocket = self.distro.getDistroSeriesAndPocket(
396+ dr_name, follow_aliases=True)
397
398 if self.archive is None:
399 self.archive = self.distroseries.main_archive
400
401=== modified file 'lib/lp/archiveuploader/uploadprocessor.py'
402--- lib/lp/archiveuploader/uploadprocessor.py 2013-06-26 06:02:00 +0000
403+++ lib/lp/archiveuploader/uploadprocessor.py 2013-08-29 10:30:37 +0000
404@@ -1,4 +1,4 @@
405-# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
406+# Copyright 2009-2013 Canonical Ltd. This software is licensed under the
407 # GNU Affero General Public License version 3 (see the file LICENSE).
408
409 """Code for 'processing' 'uploads'. Also see nascentupload.py.
410@@ -743,7 +743,7 @@
411
412 suite_name = parts[1]
413 try:
414- distribution.getDistroSeriesAndPocket(suite_name)
415+ distribution.getDistroSeriesAndPocket(suite_name, follow_aliases=True)
416 except NotFoundError:
417 raise exc_type("Could not find suite '%s'." % suite_name)
418
419
420=== modified file 'lib/lp/registry/browser/distribution.py'
421--- lib/lp/registry/browser/distribution.py 2013-04-10 08:35:47 +0000
422+++ lib/lp/registry/browser/distribution.py 2013-08-29 10:30:37 +0000
423@@ -174,6 +174,13 @@
424 def traverse_archive(self, name):
425 return self.context.getArchive(name)
426
427+ def traverse(self, name):
428+ try:
429+ return super(DistributionNavigation, self).traverse(name)
430+ except NotFoundError:
431+ resolved = self.context.resolveSeriesAlias(name)
432+ return self.redirectSubTree(canonical_url(resolved), status=303)
433+
434
435 class DistributionSetNavigation(Navigation):
436
437
438=== modified file 'lib/lp/registry/configure.zcml'
439--- lib/lp/registry/configure.zcml 2013-08-19 06:43:04 +0000
440+++ lib/lp/registry/configure.zcml 2013-08-29 10:30:37 +0000
441@@ -1714,6 +1714,7 @@
442 bug_reported_acknowledgement
443 bug_reporting_guidelines
444 description
445+ development_series_alias
446 displayname
447 driver
448 enable_bug_expiration
449
450=== modified file 'lib/lp/registry/doc/distroseries.txt'
451--- lib/lp/registry/doc/distroseries.txt 2013-05-01 21:23:16 +0000
452+++ lib/lp/registry/doc/distroseries.txt 2013-08-29 10:30:37 +0000
453@@ -56,6 +56,17 @@
454 >>> print distroseriesset.queryByVersion(ubuntu, "5.05")
455 None
456
457+queryByName works on series aliases too if follow_aliases is True.
458+
459+ >>> ignored = login_person(ubuntu.owner.activemembers[0])
460+ >>> ubuntu.development_series_alias = "devel"
461+ >>> login(ANONYMOUS)
462+ >>> print distroseriesset.queryByName(ubuntu, "devel")
463+ None
464+ >>> print distroseriesset.queryByName(
465+ ... ubuntu, "devel", follow_aliases=True).name
466+ hoary
467+
468 We verify that a distroseries does in fact fully provide IDistroSeries:
469
470 >>> verifyObject(IDistroSeries, warty)
471
472=== modified file 'lib/lp/registry/interfaces/distribution.py'
473--- lib/lp/registry/interfaces/distribution.py 2012-12-18 02:24:43 +0000
474+++ lib/lp/registry/interfaces/distribution.py 2013-08-29 10:30:37 +0000
475@@ -1,4 +1,4 @@
476-# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
477+# Copyright 2009-2013 Canonical Ltd. This software is licensed under the
478 # GNU Affero General Public License version 3 (see the file LICENSE).
479
480 """Interfaces including and related to IDistribution."""
481@@ -76,6 +76,7 @@
482 )
483 from lp.registry.interfaces.announcement import IMakesAnnouncements
484 from lp.registry.interfaces.distributionmirror import IDistributionMirror
485+from lp.registry.interfaces.distroseries import DistroSeriesNameField
486 from lp.registry.interfaces.karma import IKarmaContext
487 from lp.registry.interfaces.milestone import (
488 ICanGetMilestonesDirectly,
489@@ -362,6 +363,13 @@
490 description=_("Redirect release pocket uploads to proposed pocket"),
491 readonly=False, required=True))
492
493+ development_series_alias = exported(DistroSeriesNameField(
494+ title=_("Alias for development series"),
495+ description=_(
496+ "If set, an alias for the current development series in this "
497+ "distribution."),
498+ constraint=name_validator, readonly=False, required=False))
499+
500 def getArchiveIDList(archive=None):
501 """Return a list of archive IDs suitable for sqlvalues() or quote().
502
503@@ -396,12 +404,20 @@
504 def getDevelopmentSeries():
505 """Return the DistroSeries which are marked as in development."""
506
507+ def resolveSeriesAlias(name):
508+ """Resolve a series alias.
509+
510+ :param name: The name to resolve.
511+ :raises NoSuchDistroSeries: If there is no match.
512+ """
513+
514 @operation_parameters(
515 name_or_version=TextLine(title=_("Name or version"), required=True))
516 # Really IDistroSeries, see _schema_circular_imports.py.
517 @operation_returns_entry(Interface)
518+ @call_with(follow_aliases=True)
519 @export_read_operation()
520- def getSeries(name_or_version):
521+ def getSeries(name_or_version, follow_aliases=False):
522 """Return the series with the name or version given.
523
524 :param name_or_version: The `IDistroSeries.name` or
525@@ -494,7 +510,7 @@
526 and the value is a `IDistributionSourcePackageRelease`.
527 """
528
529- def getDistroSeriesAndPocket(distroseriesname):
530+ def getDistroSeriesAndPocket(distroseriesname, follow_aliases=False):
531 """Return a (distroseries,pocket) tuple which is the given textual
532 distroseriesname in this distribution."""
533
534
535=== modified file 'lib/lp/registry/interfaces/distroseries.py'
536--- lib/lp/registry/interfaces/distroseries.py 2013-05-01 18:13:17 +0000
537+++ lib/lp/registry/interfaces/distroseries.py 2013-08-29 10:30:37 +0000
538@@ -1,4 +1,4 @@
539-# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
540+# Copyright 2009-2013 Canonical Ltd. This software is licensed under the
541 # GNU Affero General Public License version 3 (see the file LICENSE).
542
543 """Interfaces including and related to IDistroSeries."""
544@@ -7,6 +7,7 @@
545
546 __all__ = [
547 'DerivationError',
548+ 'DistroSeriesNameField',
549 'IDistroSeries',
550 'IDistroSeriesEditRestricted',
551 'IDistroSeriesPublic',
552@@ -997,11 +998,12 @@
553 """Return a set of distroseriess that can be translated in
554 rosetta."""
555
556- def queryByName(distribution, name):
557+ def queryByName(distribution, name, follow_aliases=False):
558 """Query a DistroSeries by name.
559
560 :distribution: An IDistribution.
561 :name: A string.
562+ :follow_aliases: If True, follow series aliases.
563
564 Returns the matching DistroSeries, or None if not found.
565 """
566
567=== modified file 'lib/lp/registry/model/distribution.py'
568--- lib/lp/registry/model/distribution.py 2013-08-19 06:43:04 +0000
569+++ lib/lp/registry/model/distribution.py 2013-08-29 10:30:37 +0000
570@@ -248,6 +248,7 @@
571 active = True
572 package_derivatives_email = StringCol(notNull=False, default=None)
573 redirect_release_uploads = BoolCol(notNull=True, default=False)
574+ development_series_alias = StringCol(notNull=False, default=None)
575
576 def __repr__(self):
577 displayname = self.displayname.encode('ASCII', 'backslashreplace')
578@@ -823,15 +824,25 @@
579 return getUtility(
580 IArchiveSet).getByDistroAndName(self, name)
581
582- def getSeries(self, name_or_version):
583+ def resolveSeriesAlias(self, name):
584+ """See `IDistribution`."""
585+ if self.development_series_alias == name:
586+ currentseries = self.currentseries
587+ if currentseries is not None:
588+ return currentseries
589+ raise NoSuchDistroSeries(name)
590+
591+ def getSeries(self, name_or_version, follow_aliases=False):
592 """See `IDistribution`."""
593 distroseries = Store.of(self).find(DistroSeries,
594 Or(DistroSeries.name == name_or_version,
595 DistroSeries.version == name_or_version),
596 DistroSeries.distribution == self).one()
597- if not distroseries:
598- raise NoSuchDistroSeries(name_or_version)
599- return distroseries
600+ if distroseries:
601+ return distroseries
602+ if follow_aliases:
603+ return self.resolveSeriesAlias(name_or_version)
604+ raise NoSuchDistroSeries(name_or_version)
605
606 def getDevelopmentSeries(self):
607 """See `IDistribution`."""
608@@ -957,7 +968,8 @@
609 search_text=search_text, owner=owner, sort=sort,
610 distribution=self).getResults()
611
612- def getDistroSeriesAndPocket(self, distroseries_name):
613+ def getDistroSeriesAndPocket(self, distroseries_name,
614+ follow_aliases=False):
615 """See `IDistribution`."""
616 # Get the list of suffixes.
617 suffixes = [suffix for suffix, ignored in suffixpocket.items()]
618@@ -966,13 +978,18 @@
619
620 for suffix in suffixes:
621 if distroseries_name.endswith(suffix):
622+ left_size = len(distroseries_name) - len(suffix)
623+ left = distroseries_name[:left_size]
624 try:
625- left_size = len(distroseries_name) - len(suffix)
626- return (self[distroseries_name[:left_size]],
627- suffixpocket[suffix])
628+ return self[left], suffixpocket[suffix]
629 except KeyError:
630+ if follow_aliases:
631+ try:
632+ resolved = self.resolveSeriesAlias(left)
633+ return resolved, suffixpocket[suffix]
634+ except NoSuchDistroSeries:
635+ pass
636 # Swallow KeyError to continue round the loop.
637- pass
638
639 raise NotFoundError(distroseries_name)
640
641
642=== modified file 'lib/lp/registry/model/distroseries.py'
643--- lib/lp/registry/model/distroseries.py 2013-08-19 06:43:04 +0000
644+++ lib/lp/registry/model/distroseries.py 2013-08-29 10:30:37 +0000
645@@ -55,6 +55,7 @@
646 from lp.bugs.model.structuralsubscription import (
647 StructuralSubscriptionTargetMixin,
648 )
649+from lp.registry.errors import NoSuchDistroSeries
650 from lp.registry.interfaces.distroseries import (
651 DerivationError,
652 IDistroSeries,
653@@ -1490,9 +1491,17 @@
654 DistroSeries.hide_all_translations == False,
655 DistroSeries.id == POTemplate.distroseriesID).config(distinct=True)
656
657- def queryByName(self, distribution, name):
658+ def queryByName(self, distribution, name, follow_aliases=False):
659 """See `IDistroSeriesSet`."""
660- return DistroSeries.selectOneBy(distribution=distribution, name=name)
661+ series = DistroSeries.selectOneBy(distribution=distribution, name=name)
662+ if series is not None:
663+ return series
664+ if follow_aliases:
665+ try:
666+ return distribution.resolveSeriesAlias(name)
667+ except NoSuchDistroSeries:
668+ pass
669+ return None
670
671 def queryByVersion(self, distribution, version):
672 """See `IDistroSeriesSet`."""
673
674=== modified file 'lib/lp/registry/stories/distribution/xx-distribution-overview.txt'
675--- lib/lp/registry/stories/distribution/xx-distribution-overview.txt 2012-07-30 12:11:31 +0000
676+++ lib/lp/registry/stories/distribution/xx-distribution-overview.txt 2013-08-29 10:30:37 +0000
677@@ -1,4 +1,6 @@
678-= Distributions =
679+=============
680+Distributions
681+=============
682
683 Launchpad can be used by both upstream projects and distributions - the
684 system understands the intrinsic differences between those kinds of
685@@ -6,16 +8,18 @@
686 system.
687
688
689-== Distribution listings ==
690+Distribution listings
691+=====================
692
693-There is a listing of all distributions at /distributions/:
694+There is a listing of all distributions at /distros/:
695
696 >>> anon_browser.open('http://launchpad.dev/distros/')
697 >>> print anon_browser.title
698 Distributions registered in Launchpad
699
700
701-== Distribution home pages ==
702+Distribution home pages
703+=======================
704
705 Each distribution has a home page in the system. The page will show a
706 consolidated view of active series, milestones and derivatives of that
707@@ -79,23 +83,23 @@
708 The 5 latest derivatives are displayed on the home page
709 along with a link to list all of them.
710
711- >>> anon_browser.open('http://launchpad.dev/ubuntu')
712- >>> print extract_text(find_tag_by_id(anon_browser.contents,
713- ... 'derivatives'))
714- Latest derivatives
715- 9.9.9
716- &#8220;Hoary Mock&#8221; series
717- (from Warty)
718- 8.06
719- &#8220;Krunch&#8221; series
720- (from Hoary)
721- 6.6.6
722- &#8220;Breezy Badger Autotest&#8221; series
723- (from Warty)
724- 2005
725- &#8220;Guada2005&#8221; series
726- (from Hoary)
727- All derivatives
728+ >>> anon_browser.open('http://launchpad.dev/ubuntu')
729+ >>> print extract_text(
730+ ... find_tag_by_id(anon_browser.contents, 'derivatives'))
731+ Latest derivatives
732+ 9.9.9
733+ &#8220;Hoary Mock&#8221; series
734+ (from Warty)
735+ 8.06
736+ &#8220;Krunch&#8221; series
737+ (from Hoary)
738+ 6.6.6
739+ &#8220;Breezy Badger Autotest&#8221; series
740+ (from Warty)
741+ 2005
742+ &#8220;Guada2005&#8221; series
743+ (from Hoary)
744+ All derivatives
745
746
747 The "All derivatives" link takes you to the derivatives page.
748@@ -106,16 +110,39 @@
749 If there are no derivatives, the link to the derivatives page is
750 not there.
751
752- >>> anon_browser.open('http://launchpad.dev/ubuntutest')
753- >>> print extract_text(find_tag_by_id(anon_browser.contents,
754- ... 'derivatives'))
755- Latest derivatives
756- No derivatives.
757-
758-
759- == Registration information ==
760-
761-The distroseries pages presents the registeration information.
762+ >>> anon_browser.open('http://launchpad.dev/ubuntutest')
763+ >>> print extract_text(
764+ ... find_tag_by_id(anon_browser.contents, 'derivatives'))
765+ Latest derivatives
766+ No derivatives.
767+
768+
769+If there is a development series alias, it becomes a redirect.
770+
771+ >>> from lp.registry.interfaces.distribution import IDistributionSet
772+ >>> from lp.testing import celebrity_logged_in
773+ >>> from zope.component import getUtility
774+
775+ >>> anon_browser.open("http://launchpad.dev/ubuntu/devel")
776+ Traceback (most recent call last):
777+ ...
778+ NotFound: Object: <Distribution ...>, name: u'devel'
779+
780+ >>> with celebrity_logged_in("admin"):
781+ ... ubuntu = getUtility(IDistributionSet).getByName(u"ubuntu")
782+ ... ubuntu.development_series_alias = "devel"
783+ >>> anon_browser.open("http://launchpad.dev/ubuntu/devel")
784+ >>> print anon_browser.url
785+ http://launchpad.dev/ubuntu/hoary
786+ >>> anon_browser.open("http://launchpad.dev/ubuntu/devel/+builds")
787+ >>> print anon_browser.url
788+ http://launchpad.dev/ubuntu/hoary/+builds
789+
790+
791+Registration information
792+========================
793+
794+The distroseries pages presents the registration information.
795
796 >>> anon_browser.open('http://launchpad.dev/ubuntu')
797
798@@ -129,7 +156,8 @@
799 http://launchpad.dev/~ubuntu-team
800
801
802-== Redirection for webservice URLs ==
803+Redirection for webservice URLs
804+===============================
805
806 The webservice exposes a URL for the archive associated with the distribution.
807 Displaying the page for that URL is nonsensical (it looks like the PPA
808
809=== modified file 'lib/lp/registry/stories/webservice/xx-distribution.txt'
810--- lib/lp/registry/stories/webservice/xx-distribution.txt 2012-10-25 11:47:43 +0000
811+++ lib/lp/registry/stories/webservice/xx-distribution.txt 2013-08-29 10:30:37 +0000
812@@ -32,6 +32,7 @@
813 date_created: u'2006-10-16T18:31:43.415195+00:00'
814 derivatives_collection_link: u'http://.../ubuntu/derivatives'
815 description: u'Ubuntu is a new approach...'
816+ development_series_alias: None
817 display_name: u'Ubuntu'
818 domain_name: u'ubuntulinux.org'
819 driver_link: None
820@@ -165,7 +166,8 @@
821 "getCountryMirror" returns the country DNS mirror for a given country;
822 returning None if there isn't one.
823
824- >>> # Prepare stuff.
825+Prepare stuff.
826+
827 >>> from zope.component import getUtility
828 >>> from lp.testing.pages import webservice_for_person
829 >>> from lp.services.webapp.interfaces import OAuthPermission
830@@ -187,7 +189,8 @@
831 ... permission=OAuthPermission.WRITE_PUBLIC)
832 >>> logout()
833
834- >>> # Mark new mirror as official and a country mirror.
835+Mark new mirror as official and a country mirror.
836+
837 >>> patch = {
838 ... u'status': 'Official',
839 ... u'country_dns_mirror': True
840
841=== modified file 'lib/lp/registry/tests/test_distribution.py'
842--- lib/lp/registry/tests/test_distribution.py 2012-09-18 19:41:02 +0000
843+++ lib/lp/registry/tests/test_distribution.py 2013-08-29 10:30:37 +0000
844@@ -1,4 +1,4 @@
845-# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
846+# Copyright 2009-2013 Canonical Ltd. This software is licensed under the
847 # GNU Affero General Public License version 3 (see the file LICENSE).
848
849 """Tests for Distribution."""
850@@ -404,8 +404,20 @@
851 name="dappere", version="42.6")
852 self.assertEquals(series, distro.getSeries("42.6"))
853
854-
855-class SeriesTests(TestCaseWithFactory):
856+ def test_development_series_alias(self):
857+ distro = self.factory.makeDistribution()
858+ with person_logged_in(distro.owner):
859+ distro.development_series_alias = "devel"
860+ self.assertRaises(
861+ NoSuchDistroSeries, distro.getSeries, "devel", follow_aliases=True)
862+ series = self.factory.makeDistroSeries(
863+ distribution=distro, status=SeriesStatus.DEVELOPMENT)
864+ self.assertRaises(NoSuchDistroSeries, distro.getSeries, "devel")
865+ self.assertEqual(
866+ series, distro.getSeries("devel", follow_aliases=True))
867+
868+
869+class DerivativesTests(TestCaseWithFactory):
870 """Test IDistribution.derivatives.
871 """
872
873@@ -416,10 +428,8 @@
874 distro2 = self.factory.makeDistribution()
875 previous_series = self.factory.makeDistroSeries(distribution=distro1)
876 series = self.factory.makeDistroSeries(
877- distribution=distro2,
878- previous_series=previous_series)
879- self.assertContentEqual(
880- [series], distro1.derivatives)
881+ distribution=distro2, previous_series=previous_series)
882+ self.assertContentEqual([series], distro1.derivatives)
883
884
885 class DistroSnapshotTestCase(TestCaseWithFactory):
886
887=== modified file 'lib/lp/soyuz/interfaces/archive.py'
888--- lib/lp/soyuz/interfaces/archive.py 2013-06-20 17:24:46 +0000
889+++ lib/lp/soyuz/interfaces/archive.py 2013-08-29 10:30:37 +0000
890@@ -1377,6 +1377,12 @@
891 immediately if the copy passes basic security checks and the copy
892 will happen sometime later with full checking.
893
894+ If the source or target distribution has a development series alias,
895+ then it may be used as the source or target distroseries name
896+ respectively; but note that this will always be resolved to the true
897+ development series of that distribution, which may not match the
898+ alias in the respective published archives.
899+
900 :param source_name: a string name of the package to copy.
901 :param version: the version of the package to copy.
902 :param from_archive: the source archive from which to copy.
903@@ -1456,6 +1462,12 @@
904 Partial changes of the destination archive can happen because each
905 source is copied in its own transaction.
906
907+ If the source or target distribution has a development series alias,
908+ then it may be used as the source or target distroseries name
909+ respectively; but note that this will always be resolved to the true
910+ development series of that distribution, which may not match the
911+ alias in the respective published archives.
912+
913 :param source_names: a list of string names of packages to copy.
914 :param from_archive: the source archive from which to copy.
915 :param to_pocket: the target pocket (as a string).
916@@ -1522,6 +1534,12 @@
917 copies cannot be performed, the whole operation will fail. There
918 will be no partial changes of the destination archive.
919
920+ If the source or target distribution has a development series alias,
921+ then it may be used as the source or target distroseries name
922+ respectively; but note that this will always be resolved to the true
923+ development series of that distribution, which may not match the
924+ alias in the respective published archives.
925+
926 :param source_names: a list of string names of packages to copy.
927 :param from_archive: the source archive from which to copy.
928 :param to_pocket: the target pocket (as a string).
929@@ -1564,6 +1582,12 @@
930 Copy a specific version of a named source to the destination
931 archive if necessary.
932
933+ If the source distribution has a development series alias, then it
934+ may be used as the source distroseries name; but note that this will
935+ always be resolved to the true development series of that
936+ distribution, which may not match the alias in the published source
937+ archive.
938+
939 :param source_name: a string name of the package to copy.
940 :param version: the version of the package to copy.
941 :param from_archive: the source archive from which to copy.
942
943=== modified file 'lib/lp/soyuz/model/archive.py'
944--- lib/lp/soyuz/model/archive.py 2013-07-22 09:38:22 +0000
945+++ lib/lp/soyuz/model/archive.py 2013-08-29 10:30:37 +0000
946@@ -628,9 +628,11 @@
947 SourcePackagePublishingHistory, *clauses).order_by(
948 SourcePackageName.name, Desc(SourcePackageRelease.version),
949 Desc(SourcePackagePublishingHistory.id))
950+
951 def eager_load(rows):
952 load_related(
953 SourcePackageRelease, rows, ['sourcepackagereleaseID'])
954+
955 return DecoratedResultSet(sources, pre_iter_hook=eager_load)
956
957 @property
958@@ -1749,7 +1751,7 @@
959 distribution = self.distribution
960 if to_series is not None:
961 result = getUtility(IDistroSeriesSet).queryByName(
962- distribution, to_series)
963+ distribution, to_series, follow_aliases=True)
964 if result is None:
965 raise NoSuchDistroSeries(to_series)
966 series = result
967
968=== modified file 'lib/lp/soyuz/scripts/publishdistro.py'
969--- lib/lp/soyuz/scripts/publishdistro.py 2013-05-01 18:39:38 +0000
970+++ lib/lp/soyuz/scripts/publishdistro.py 2013-08-29 10:30:37 +0000
971@@ -1,4 +1,4 @@
972-# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
973+# Copyright 2009-2013 Canonical Ltd. This software is licensed under the
974 # GNU Affero General Public License version 3 (see the file LICENSE).
975
976 """Publisher script class."""
977@@ -305,6 +305,8 @@
978 publisher.D_writeReleaseFiles(careful_indexing)
979 # The caller will commit this last step.
980
981+ publisher.createSeriesAliases()
982+
983 def main(self):
984 """See `LaunchpadScript`."""
985 self.validateOptions()
986
987=== modified file 'lib/lp/soyuz/scripts/tests/test_publishdistro.py'
988--- lib/lp/soyuz/scripts/tests/test_publishdistro.py 2013-05-02 00:14:02 +0000
989+++ lib/lp/soyuz/scripts/tests/test_publishdistro.py 2013-08-29 10:30:37 +0000
990@@ -1,4 +1,4 @@
991-# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
992+# Copyright 2009-2013 Canonical Ltd. This software is licensed under the
993 # GNU Affero General Public License version 3 (see the file LICENSE).
994
995 """Functional tests for publish-distro.py script."""
996@@ -30,7 +30,6 @@
997 from lp.soyuz.enums import (
998 ArchivePurpose,
999 ArchiveStatus,
1000- BinaryPackageFormat,
1001 PackagePublishingStatus,
1002 )
1003 from lp.soyuz.interfaces.archive import IArchiveSet
1004@@ -373,6 +372,7 @@
1005 self.C_doFTPArchive = FakeMethod()
1006 self.C_writeIndexes = FakeMethod()
1007 self.D_writeReleaseFiles = FakeMethod()
1008+ self.createSeriesAliases = FakeMethod()
1009
1010
1011 class TestPublishDistroMethods(TestCaseWithFactory):