Merge lp:~benji/launchpad/bug-781600 into lp:launchpad

Proposed by Benji York
Status: Merged
Approved by: Benji York
Approved revision: no longer in the source branch.
Merged at revision: 13439
Proposed branch: lp:~benji/launchpad/bug-781600
Merge into: lp:launchpad
Diff against target: 219 lines (+147/-5)
3 files modified
lib/lp/registry/interfaces/distribution.py (+24/-0)
lib/lp/registry/model/distribution.py (+33/-2)
lib/lp/registry/tests/test_distro_webservice.py (+90/-3)
To merge this branch: bzr merge lp:~benji/launchpad/bug-781600
Reviewer Review Type Date Requested Status
Francis J. Lacoste (community) Approve
Review via email: mp+67704@code.launchpad.net

Commit message

[r=flacoste][bug=781600] Provide a web service API to retrieve the branch tip revisions (optionally since a given date) and official series information for a given distribution.

Description of the change

Bug 781600 is about providing a web service API to retrieve the branch
tip revisions (optionally since a given date) and official series
information for a given distribution. This branch adds a getBranchTips
named operation to distributions to provide that functionality.

Preimplemenation discussion was had with Francis.

The implementation is a relatively straight-forward SQL query who's
results are lightly post-processed to provide a slightly nicer (and
requested) structure for the return value.

Tests were added to lib/lp/registry/tests/test_distro_webservice.py.
Specifically the TestGetBranchTips class.

Lint: some pre-existing lint fixed, now lint-free.

To post a comment you must log in.
Revision history for this message
Francis J. Lacoste (flacoste) wrote :
Download full text (5.9 KiB)

> === modified file 'lib/lp/registry/interfaces/distribution.py'

> @operation_parameters(
> + since=Datetime(
> + title=_("Time of last change"),
> + description=_(
> + "Return branches that have new tips since this timestamp."),
> + required=False))
> + @export_operation_as(name="getBranchTips")
> + @export_read_operation()
> + @operation_for_version('devel')
> + def getBranchTips(since):
> + """Return a collection of branches which have new tips since a date.
> + """

I'd suggest improving the docstring. Something along the lines of:

    Return a list of branches information which have new tips since a date.

    Each branch information is a tuple of (branch_unique_name, tip_revision,
    (official_series*)).

    So for each branch in the distribution, you'll get the branch unique name,
    the revision id of tip, and if the branch is official for some series, the
    list of series name.

    :param since: If specified, only returns branch modified since that date
        time.

You probably need to make since=None since it's not a required parameter.

> === modified file 'lib/lp/registry/model/distribution.py'

> """See `IBugTarget`."""
> return get_bug_tags("BugTask.distribution = %s" % sqlvalues(self))
>
> + def getBranchTips(self, since=None):
> + """See `IDistribution`."""
> + query = """
> + SELECT unique_name, last_scanned_id,
> + SeriesSourcePackageBranch.distroseries FROM Branch
> + JOIN DistroSeries ON Branch.distroseries = DistroSeries.id
> + JOIN Distribution ON DistroSeries.distribution = Distribution.id
> + JOIN SeriesSourcePackageBranch ON
> + Branch.id = SeriesSourcePackageBranch.branch
> + WHERE Distribution.name = %s""" % sqlvalues(self.name)

Not all branches will be official, so you want to use a LEFT OUTER JOIN on
both SeriesSourcePackageBranch and DistroSeries.

Distro series id is pretty meangingless, we should return their name.
DistroSeries.name instead of SeriesSourcePackageBranch.distroseries

> +
> + if since is not None:
> + query += (
> + ' AND branch.last_scanned > %s' % sqlvalues(since))
> +
> + query += ' ORDER BY unique_name, last_scanned_id;'
> +
> + data = list(Store.of(self).execute(query))
> +
> + # Group on location (unique_name) and revision (last_scanned_id).
> + results = []
> + for key, group in itertools.groupby(data, itemgetter(0, 1)):
> + results.append(list(key))
> + # Pull out all the official series IDs and append them as a list
> + # to the end of the current record.
> + results[-1].append(map(itemgetter(-1), group))
> + return results
> +

That last grouping will need to be modified to handle the NULL case.

It's kind of a shame that we have to materialize the whole results set here,
since for huge size, it will be batched anyway by the web service code. Would
it make sense to use an generator here? Maybe not, since we'd still retrieve
all results for later batche...

Read more...

review: Needs Fixing
Revision history for this message
Benji York (benji) wrote :
Download full text (7.7 KiB)

On Tue, Jul 12, 2011 at 3:34 PM, Francis J. Lacoste
<email address hidden> wrote:
> Review: Needs Fixing

Thanks very much for the review. I'm certainly not knowledgeable of
this area of LP yet and can use the help.

>> === modified file 'lib/lp/registry/interfaces/distribution.py'
>
>>      @operation_parameters(
>> +        since=Datetime(
>> +            title=_("Time of last change"),
>> +            description=_(
>> +                "Return branches that have new tips since this timestamp."),
>> +            required=False))
>> +    @export_operation_as(name="getBranchTips")
>> +    @export_read_operation()
>> +    @operation_for_version('devel')
>> +    def getBranchTips(since):
>> +        """Return a collection of branches which have new tips since a date.
>> +        """
>
> I'd suggest improving the docstring. Something along the lines of:

Looks good. Done.

> You probably need to make since=None since it's not a required parameter.

It seems odd to me to put the default in the interface in addition to in
the implementation. It has no effect and I worry that the DRY violation
could result in a loss of synchronization with the real default and
result in confusion. Also, the "required=False" bit is what indicates
that "since" is optional, the interface reader shouldn't care what
internal value we use to signify that it wasn't provided.

That being said, I'm only -0 so I'll add it given a bit of a push.

>> === modified file 'lib/lp/registry/model/distribution.py'
>
>>          """See `IBugTarget`."""
>>          return get_bug_tags("BugTask.distribution = %s" % sqlvalues(self))
>>
>> +    def getBranchTips(self, since=None):
>> +        """See `IDistribution`."""
>> +        query = """
>> +            SELECT unique_name, last_scanned_id,
>> +                SeriesSourcePackageBranch.distroseries FROM Branch
>> +            JOIN DistroSeries ON Branch.distroseries = DistroSeries.id
>> +            JOIN Distribution ON DistroSeries.distribution = Distribution.id
>> +            JOIN SeriesSourcePackageBranch ON
>> +                Branch.id = SeriesSourcePackageBranch.branch
>> +            WHERE Distribution.name = %s""" % sqlvalues(self.name)
>
> Not all branches will be official, so you want to use a LEFT OUTER JOIN on
> both SeriesSourcePackageBranch and DistroSeries.

I fixed that and added a test as mentioned below.

> Distro series id is pretty meangingless, we should return their name.
> DistroSeries.name instead of SeriesSourcePackageBranch.distroseries

Done. I had to tweak the query more than just substituting
DistroSeries.name but not too much.

>> +
>> +        if since is not None:
>> +            query += (
>> +                ' AND branch.last_scanned > %s' % sqlvalues(since))
>> +
>> +        query += ' ORDER BY unique_name, last_scanned_id;'
>> +
>> +        data = list(Store.of(self).execute(query))
>> +
>> +        # Group on location (unique_name) and revision (last_scanned_id).
>> +        results = []
>> +        for key, group in itertools.groupby(data, itemgetter(0, 1)):
>> +            results.append(list(key))
>> +            # Pull out all the official series IDs and append them as a list
>> +  ...

Read more...

Revision history for this message
Robert Collins (lifeless) wrote :

On Thu, Jul 14, 2011 at 8:48 AM, Benji York <email address hidden> wrote:
>
>> It's kind of a shame that we have to materialize the whole results set here,
>> since for huge size, it will be batched anyway by the web service code. Would
>> it make sense to use an generator here? Maybe not, since we'd still retrieve
>> all results for later batches.
>
> Given that we use ORDER BY, I suspect the only savings would be not
> transmitting the non-batched results from the DB server to the app
> server.  However, making the method a generator might still be a win
> because even though they will likely come back to get the rest, that
> request may well hit a different app server.  (I don't think we do any
> sort of request affinity but would like to know if we do.)
>
> Later... Nope, that didn't work.  lazr.restful exploded because it
> couldn't adapt the generator to IFiniteSequence.  I would have expected
> returning a generator to work, but I can't find any other instances in
> the code base.  I've left it as a non-generator for the time being but
> would appreciate any knowledge that can be shared on the topic.

So, order by does not on its own mean that the whole result set is
materialised; in fact, if there is an index that can satisfy the
ordering, and its limited, pg seems to prioritise it very highly.

It looks like the grouping could be done in the DB ?
If so then returning the result set may make sense.

Another alternative is to use the new batchnavigator 1.2.5 facilities
to do batch memos rather than slicing. However that may require some
glue into the lazr stuff.. OTOH you're pretty well placed for that
sort of surgery :)

-Rob

Revision history for this message
Francis J. Lacoste (flacoste) wrote :
Download full text (6.2 KiB)

On 11-07-13 04:48 PM, Benji York wrote:
> On Tue, Jul 12, 2011 at 3:34 PM, Francis J. Lacoste

>
>> You probably need to make since=None since it's not a required parameter.
>
> It seems odd to me to put the default in the interface in addition to in
> the implementation. It has no effect and I worry that the DRY violation
> could result in a loss of synchronization with the real default and
> result in confusion. Also, the "required=False" bit is what indicates
> that "since" is optional, the interface reader shouldn't care what
> internal value we use to signify that it wasn't provided.
>
> That being said, I'm only -0 so I'll add it given a bit of a push.

Well, interfaces and implementation are usually not on the side of DRY
:-) To me, if the interface doesn't have a default parameter, it usually
means that client should expect to have to provide a parameter. Again,
the fact that this is mainly for webservice consumption makes this a
little less important. Anyway, Do as you will!

>
>>> === modified file 'lib/lp/registry/model/distribution.py'
>>
>>> """See `IBugTarget`."""
>>> return get_bug_tags("BugTask.distribution = %s" % sqlvalues(self))
>>>
>>> + def getBranchTips(self, since=None):
>>> + """See `IDistribution`."""
>>> + query = """
>>> + SELECT unique_name, last_scanned_id,
>>> + SeriesSourcePackageBranch.distroseries FROM Branch
>>> + JOIN DistroSeries ON Branch.distroseries = DistroSeries.id
>>> + JOIN Distribution ON DistroSeries.distribution = Distribution.id
>>> + JOIN SeriesSourcePackageBranch ON
>>> + Branch.id = SeriesSourcePackageBranch.branch
>>> + WHERE Distribution.name = %s""" % sqlvalues(self.name)
>>
>> Not all branches will be official, so you want to use a LEFT OUTER JOIN on
>> both SeriesSourcePackageBranch and DistroSeries.
>
> I fixed that and added a test as mentioned below.
>
>> Distro series id is pretty meangingless, we should return their name.
>> DistroSeries.name instead of SeriesSourcePackageBranch.distroseries
>
> Done. I had to tweak the query more than just substituting
> DistroSeries.name but not too much.

Some comment on your new query:

> + query = """
> + SELECT unique_name, last_scanned_id, SPBDS.name FROM Branch
> + LEFT OUTER JOIN DistroSeries
> + ON Branch.distroseries = DistroSeries.id

You probably want a normal join here as you don't expect any branch here
without a distroseries. (And this join is just to get at the
distribution right?)

> + LEFT OUTER JOIN SeriesSourcePackageBranch
> + ON Branch.id = SeriesSourcePackageBranch.branch
> + JOIN Distribution
> + ON DistroSeries.distribution = Distribution.id
> + LEFT OUTER JOIN DistroSeries SPBDS -- (SourcePackageBranchDistroSeries)
> + ON SeriesSourcePackageBranch.distroseries = SPBDS.id
> + WHERE Distribution.name = %s""" % sqlvalues(self.name)

You don't have to join the distribution here.

You could use

DistroSeries.distribution = self.id

The SPBDDS left outer join seems right....

Read more...

review: Approve
Revision history for this message
Benji York (benji) wrote :
Download full text (4.5 KiB)

On Wed, Jul 13, 2011 at 6:13 PM, Francis J. Lacoste
<email address hidden> wrote:
> Review: Approve
> On 11-07-13 04:48 PM, Benji York wrote:
>> On Tue, Jul 12, 2011 at 3:34 PM, Francis J. Lacoste

> Some comment on your new query:
>
>> +        query = """
>> +            SELECT unique_name, last_scanned_id, SPBDS.name FROM Branch
>> +            LEFT OUTER JOIN DistroSeries
>> +                ON Branch.distroseries = DistroSeries.id
>
> You probably want a normal join here as you don't expect any branch here
> without a distroseries. (And this join is just to get at the
> distribution right?)

Good catch. Fixed.

>> +            LEFT OUTER JOIN SeriesSourcePackageBranch
>> +                ON Branch.id = SeriesSourcePackageBranch.branch
>> +            JOIN Distribution
>> +                ON DistroSeries.distribution = Distribution.id
>> +            LEFT OUTER JOIN DistroSeries SPBDS -- (SourcePackageBranchDistroSeries)
>> +                ON SeriesSourcePackageBranch.distroseries = SPBDS.id
>> +            WHERE Distribution.name = %s""" % sqlvalues(self.name)
>
> You don't have to join the distribution here.
>
> You could use
>
> DistroSeries.distribution = self.id

Changed.

>>> It's kind of a shame that we have to materialize the whole results set here,
>>> since for huge size, it will be batched anyway by the web service code. Would
>>> it make sense to use an generator here? Maybe not, since we'd still retrieve
>>> all results for later batches.
>>
>> Given that we use ORDER BY, I suspect the only savings would be not
>> transmitting the non-batched results from the DB server to the app
>> server.  However, making the method a generator might still be a win
>> because even though they will likely come back to get the rest, that
>> request may well hit a different app server.  (I don't think we do any
>> sort of request affinity but would like to know if we do.)
>
> We don't and wouldn't really matter since each API request results in a
> different DB transaction.

Indeed. My history with ZODB is showing here. Where's the object cache
when you need it. ;)

>> Later... Nope, that didn't work.  lazr.restful exploded because it
>> couldn't adapt the generator to IFiniteSequence.  I would have expected
>> returning a generator to work, but I can't find any other instances in
>> the code base.  I've left it as a non-generator for the time being but
>> would appreciate any knowledge that can be shared on the topic.
>
> Yeah, that's fine. The direction Robert suggested might work, but not
> sure how easy/hard it would be.

Given the number of WIP items I have at the moment I really need to move
on so I'm not going to investigate further.

>>>> === modified file 'lib/lp/registry/tests/test_distro_webservice.py'
>>>
>
>>>
>>> You can use makeRelatedBranches(reference_branch=self.branch) to make it an
>>> official source package. Or use
>>> makeRelatedBranchesForSourcePackage(source_package) to create the branch and
>>> make it official in one call.
>>
>> I tried to use makeRelatedBranchesForSourcePackage and
>> makeRelatedBranches but the need to have two different distro series
>> releases (series_1 and series_2 in the original cod...

Read more...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/lp/registry/interfaces/distribution.py'
--- lib/lp/registry/interfaces/distribution.py 2011-06-16 21:17:37 +0000
+++ lib/lp/registry/interfaces/distribution.py 2011-07-14 14:11:23 +0000
@@ -28,6 +28,7 @@
28 export_operation_as,28 export_operation_as,
29 export_read_operation,29 export_read_operation,
30 exported,30 exported,
31 operation_for_version,
31 operation_parameters,32 operation_parameters,
32 operation_returns_collection_of,33 operation_returns_collection_of,
33 operation_returns_entry,34 operation_returns_entry,
@@ -392,6 +393,29 @@
392 """393 """
393394
394 @operation_parameters(395 @operation_parameters(
396 since=Datetime(
397 title=_("Time of last change"),
398 description=_(
399 "Return branches that have new tips since this timestamp."),
400 required=False))
401 @export_operation_as(name="getBranchTips")
402 @export_read_operation()
403 @operation_for_version('devel')
404 def getBranchTips(since):
405 """Return a list of branches which have new tips since a date.
406
407 Each branch information is a tuple of (branch_unique_name,
408 tip_revision, (official_series*)).
409
410 So for each branch in the distribution, you'll get the branch unique
411 name, the revision id of tip, and if the branch is official for some
412 series, the list of series name.
413
414 :param since: If specified, limits results to branches modified since
415 that date and time.
416 """
417
418 @operation_parameters(
395 name=TextLine(title=_("Name"), required=True))419 name=TextLine(title=_("Name"), required=True))
396 @operation_returns_entry(IDistributionMirror)420 @operation_returns_entry(IDistributionMirror)
397 @export_read_operation()421 @export_read_operation()
398422
=== modified file 'lib/lp/registry/model/distribution.py'
--- lib/lp/registry/model/distribution.py 2011-06-24 05:26:37 +0000
+++ lib/lp/registry/model/distribution.py 2011-07-14 14:11:23 +0000
@@ -10,7 +10,8 @@
10 'DistributionSet',10 'DistributionSet',
11 ]11 ]
1212
13from operator import attrgetter13from operator import attrgetter, itemgetter
14import itertools
1415
15from sqlobject import (16from sqlobject import (
16 BoolCol,17 BoolCol,
@@ -654,6 +655,36 @@
654 """See `IBugTarget`."""655 """See `IBugTarget`."""
655 return get_bug_tags("BugTask.distribution = %s" % sqlvalues(self))656 return get_bug_tags("BugTask.distribution = %s" % sqlvalues(self))
656657
658 def getBranchTips(self, since=None):
659 """See `IDistribution`."""
660 query = """
661 SELECT unique_name, last_scanned_id, SPBDS.name FROM Branch
662 JOIN DistroSeries
663 ON Branch.distroseries = DistroSeries.id
664 LEFT OUTER JOIN SeriesSourcePackageBranch
665 ON Branch.id = SeriesSourcePackageBranch.branch
666 LEFT OUTER JOIN DistroSeries SPBDS
667 -- (SPDBS stands for Source Package Branch Distro Series)
668 ON SeriesSourcePackageBranch.distroseries = SPBDS.id
669 WHERE DistroSeries.distribution = %s""" % sqlvalues(self.id)
670
671 if since is not None:
672 query += (
673 ' AND branch.last_scanned > %s' % sqlvalues(since))
674
675 query += ' ORDER BY unique_name, last_scanned_id;'
676
677 data = Store.of(self).execute(query)
678
679 result = []
680 # Group on location (unique_name) and revision (last_scanned_id).
681 for key, group in itertools.groupby(data, itemgetter(0, 1)):
682 result.append(list(key))
683 # Pull out all the official series names and append them as a list
684 # to the end of the current record, removing Nones from the list.
685 result[-1].append(filter(None, map(itemgetter(-1), group)))
686 return result
687
657 def getMirrorByName(self, name):688 def getMirrorByName(self, name):
658 """See `IDistribution`."""689 """See `IDistribution`."""
659 return Store.of(self).find(690 return Store.of(self).find(
@@ -1422,7 +1453,7 @@
1422 # the sourcepackagename from that.1453 # the sourcepackagename from that.
1423 bpph = IStore(BinaryPackagePublishingHistory).find(1454 bpph = IStore(BinaryPackagePublishingHistory).find(
1424 BinaryPackagePublishingHistory,1455 BinaryPackagePublishingHistory,
1425 # See comment above for rationale for using an extra query 1456 # See comment above for rationale for using an extra query
1426 # instead of an inner join. (Bottom line, it would time out1457 # instead of an inner join. (Bottom line, it would time out
1427 # otherwise.)1458 # otherwise.)
1428 BinaryPackagePublishingHistory.archiveID.is_in(1459 BinaryPackagePublishingHistory.archiveID.is_in(
14291460
=== modified file 'lib/lp/registry/tests/test_distro_webservice.py'
--- lib/lp/registry/tests/test_distro_webservice.py 2011-03-23 16:28:51 +0000
+++ lib/lp/registry/tests/test_distro_webservice.py 2011-07-14 14:11:23 +0000
@@ -3,12 +3,21 @@
33
4__metaclass__ = type4__metaclass__ = type
55
6from datetime import datetime
7
8import pytz
6from launchpadlib.errors import Unauthorized9from launchpadlib.errors import Unauthorized
710
8from zope.security.management import endInteraction11from zope.security.management import (
9from zope.security.proxy import removeSecurityProxy12 endInteraction,
13 newInteraction,
14 )
1015
11from canonical.testing.layers import DatabaseFunctionalLayer16from canonical.testing.layers import DatabaseFunctionalLayer
17from lp.code.model.seriessourcepackagebranch import (
18 SeriesSourcePackageBranchSet,
19 )
20from lp.registry.interfaces.pocket import PackagePublishingPocket
12from lp.testing import (21from lp.testing import (
13 api_url,22 api_url,
14 launchpadlib_for,23 launchpadlib_for,
@@ -21,10 +30,88 @@
2130
22 layer = DatabaseFunctionalLayer31 layer = DatabaseFunctionalLayer
2332
24 def test_attempt_to_write_data_without_permission_gives_Unauthorized(self):33 def test_write_without_permission_gives_Unauthorized(self):
25 distro = self.factory.makeDistribution()34 distro = self.factory.makeDistribution()
26 endInteraction()35 endInteraction()
27 lp = launchpadlib_for("anonymous-access")36 lp = launchpadlib_for("anonymous-access")
28 lp_distro = lp.load(api_url(distro))37 lp_distro = lp.load(api_url(distro))
29 lp_distro.active = False38 lp_distro.active = False
30 self.assertRaises(Unauthorized, lp_distro.lp_save)39 self.assertRaises(Unauthorized, lp_distro.lp_save)
40
41
42class TestGetBranchTips(TestCaseWithFactory):
43 """Test the getBranchTips method and it's exposure to the web service."""
44
45 layer = DatabaseFunctionalLayer
46
47 def setUp(self):
48 super(TestGetBranchTips, self).setUp()
49 self.distro = self.factory.makeDistribution()
50 series_1 = self.series_1 = self.factory.makeDistroRelease(self.distro)
51 series_2 = self.series_2 = self.factory.makeDistroRelease(self.distro)
52 source_package = self.factory.makeSourcePackage(distroseries=series_1)
53 branch = self.factory.makeBranch(sourcepackage=source_package)
54 unofficial_branch = self.factory.makeBranch(sourcepackage=source_package)
55 registrant = self.factory.makePerson()
56 now = datetime.now(pytz.UTC)
57 sourcepackagename = self.factory.makeSourcePackageName()
58 SeriesSourcePackageBranchSet.new(
59 series_1, PackagePublishingPocket.RELEASE, sourcepackagename,
60 branch, registrant, now)
61 SeriesSourcePackageBranchSet.new(
62 series_2, PackagePublishingPocket.RELEASE, sourcepackagename,
63 branch, registrant, now)
64 self.factory.makeRevisionsForBranch(branch)
65 self.branch_name = branch.unique_name
66 self.unofficial_branch_name = unofficial_branch.unique_name
67 self.branch_last_scanned_id = branch.last_scanned_id
68 endInteraction()
69 self.lp = launchpadlib_for("anonymous-access")
70 self.lp_distro = self.lp.distributions[self.distro.name]
71
72 def test_structure(self):
73 """The structure of the results is what we expect."""
74 # The results should be structured as a list of
75 # (location, tip revision ID, [official series, official series, ...])
76 item = self.lp_distro.getBranchTips()[0]
77 self.assertEqual(item[0], self.branch_name)
78 self.assertTrue(item[1], self.branch_last_scanned_id)
79 self.assertEqual(
80 sorted(item[2]),
81 [self.series_1.name, self.series_2.name])
82
83 def test_same_results(self):
84 """Calling getBranchTips directly matches calling it via the API."""
85 # The web service transmutes tuples into lists, so we have to do the
86 # same to the results of directly calling getBranchTips.
87 listified = [list(x) for x in self.distro.getBranchTips()]
88 self.assertEqual(listified, self.lp_distro.getBranchTips())
89
90 def test_revisions(self):
91 """If a branch has revisions then the most recent one is returned."""
92 revision = self.lp_distro.getBranchTips()[0][1]
93 self.assertNotEqual(None, revision)
94
95 def test_since(self):
96 """If "since" is given, return branches with new tips since then."""
97 # There is at least one branch with a tip since the year 2000.
98 self.assertNotEqual(0, len(self.lp_distro.getBranchTips(
99 since=datetime(2000, 1, 1))))
100 # There are no branches with a tip since the year 3000.
101 self.assertEqual(0, len(self.lp_distro.getBranchTips(
102 since=datetime(3000, 1, 1))))
103
104 def test_series(self):
105 """The official series are included in the data."""
106 actual_series_names = sorted([self.series_1.name, self.series_2.name])
107 returned_series_names = sorted(self.lp_distro.getBranchTips()[0][-1])
108 self.assertEqual(actual_series_names, returned_series_names)
109
110 def test_unofficial_branch(self):
111 """Not all branches are official."""
112 # If a branch isn't official, the last skanned ID will be None and the
113 # official distro series list will be empty.
114 tips = self.lp_distro.getBranchTips()[1]
115 self.assertEqual(tips[0], self.unofficial_branch_name)
116 self.assertEqual(tips[1], None)
117 self.assertEqual(tips[2], [])