~launchpad-pqm/launchpad/devel

9404.1.2 by Jonathan Lange
start hacking on autoland.
1
"""Land an approved merge proposal."""
2
10430.2.1 by Brad Crittenden
Fixed comparison between launchpad._root_uri and *_SERVICE_ROOT to account for the fact the former has a version tacked onto it now.
3
from launchpadlib.launchpad import Launchpad
4
from launchpadlib.uris import (
5
    DEV_SERVICE_ROOT, EDGE_SERVICE_ROOT, LPNET_SERVICE_ROOT,
6
    STAGING_SERVICE_ROOT)
9404.1.2 by Jonathan Lange
start hacking on autoland.
7
from lazr.uri import URI
9644.4.1 by Brad Crittenden
Make ec2 land figure out the merge proposal based on the current branch.
8
from bzrlib.errors import BzrCommandError
9
9404.1.2 by Jonathan Lange
start hacking on autoland.
10
9404.1.29 by Jonathan Lange
Handle the case when there are no reviewers.
11
class MissingReviewError(Exception):
12
    """Raised when we try to get a review message without enough reviewers."""
9404.1.2 by Jonathan Lange
start hacking on autoland.
13
14
11079.1.1 by Ursula Junque (Ursinha)
Added the no_qa parameter and how to deal with it in the get_commit_message method. Added an exception class to describe this error: MissingBugsError.
15
class MissingBugsError(Exception):
11079.1.15 by Ursula Junque (Ursinha)
changed all variables and options from incr to incremental, more self explainable; raising MissingBugs exceptions without a message
16
    """Merge proposal has no linked bugs and no [no-qa] tag."""
17
18
19
class MissingBugsIncrementalError(Exception):
20
    """Merge proposal has the [incr] tag but no linked bugs."""
11079.1.6 by Ursula Junque (Ursinha)
Adding MissingBugsIncrError exception and incr commit tag to the check_qa_clauses method, now changed to check both tags, no-qa and incr.
21
22
9404.1.2 by Jonathan Lange
start hacking on autoland.
23
class LaunchpadBranchLander:
24
25
    name = 'launchpad-branch-lander'
26
27
    def __init__(self, launchpad):
28
        self._launchpad = launchpad
29
30
    @classmethod
11677.3.3 by Robert Collins
More edge removal.
31
    def load(cls, service_root='production'):
9404.1.25 by Jonathan Lange
Compliant XXXs
32
        # XXX: JonathanLange 2009-09-24: No unit tests.
33
        # XXX: JonathanLange 2009-09-24 bug=435813: If cached data invalid,
34
        # there's no easy way to delete it and try again.
12143.3.2 by Leonard Richardson
Updated to support the new Launchpad constructor and the return of login_with.
35
        launchpad = Launchpad.login_with(cls.name, service_root)
9404.1.2 by Jonathan Lange
start hacking on autoland.
36
        return cls(launchpad)
37
38
    def load_merge_proposal(self, mp_url):
39
        """Get the merge proposal object for the 'mp_url'."""
9404.1.25 by Jonathan Lange
Compliant XXXs
40
        # XXX: JonathanLange 2009-09-24: No unit tests.
9404.1.2 by Jonathan Lange
start hacking on autoland.
41
        web_mp_uri = URI(mp_url)
42
        api_mp_uri = self._launchpad._root_uri.append(
43
            web_mp_uri.path.lstrip('/'))
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
44
        return MergeProposal(self._launchpad.load(str(api_mp_uri)))
45
9644.4.4 by Brad Crittenden
Changes from code review. Fixed get_reviewer_clause to account for multiple review types and mentored reviews.
46
    def get_lp_branch(self, branch):
47
        """Get the launchpadlib branch based on a bzr branch."""
48
        # First try the public branch.
49
        branch_url = branch.get_public_branch()
50
        if branch_url:
51
            lp_branch = self._launchpad.branches.getByUrl(
52
                url=branch_url)
53
            if lp_branch is not None:
54
                return lp_branch
55
        # If that didn't work try the push location.
56
        branch_url = branch.get_push_location()
57
        if branch_url:
58
            lp_branch = self._launchpad.branches.getByUrl(
59
                url=branch_url)
60
            if lp_branch is not None:
61
                return lp_branch
62
        raise BzrCommandError(
63
            "No public branch could be found.  Please re-run and specify "
64
            "the URL for the merge proposal.")
65
9644.4.1 by Brad Crittenden
Make ec2 land figure out the merge proposal based on the current branch.
66
    def get_merge_proposal_from_branch(self, branch):
67
        """Get the merge proposal from the branch."""
9644.4.4 by Brad Crittenden
Changes from code review. Fixed get_reviewer_clause to account for multiple review types and mentored reviews.
68
69
        lp_branch = self.get_lp_branch(branch)
12708.2.6 by Jonathan Lange
Only consider needs-review or approved merge proposals.
70
        proposals = [
71
            mp for mp in lp_branch.landing_targets
72
            if mp.queue_status in ('Needs review', 'Approved')]
9644.4.2 by Brad Crittenden
Fixed error handling for the case when there are no merge proposals.
73
        if len(proposals) == 0:
74
            raise BzrCommandError(
12708.2.6 by Jonathan Lange
Only consider needs-review or approved merge proposals.
75
                "The public branch has no open source merge proposals.  "
9644.4.2 by Brad Crittenden
Fixed error handling for the case when there are no merge proposals.
76
                "You must have a merge proposal before attempting to "
77
                "land the branch.")
78
        elif len(proposals) > 1:
9644.4.1 by Brad Crittenden
Make ec2 land figure out the merge proposal based on the current branch.
79
            raise BzrCommandError(
12708.2.6 by Jonathan Lange
Only consider needs-review or approved merge proposals.
80
                "The public branch has multiple open source merge "
81
                "proposals.  You must provide the URL to the one you wish "
82
                "to use.")
9644.4.1 by Brad Crittenden
Make ec2 land figure out the merge proposal based on the current branch.
83
        return MergeProposal(proposals[0])
84
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
85
86
class MergeProposal:
9404.1.15 by Jonathan Lange
Docstrings.
87
    """Wrapper around launchpadlib `IBranchMergeProposal` for landing."""
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
88
89
    def __init__(self, mp):
9404.1.15 by Jonathan Lange
Docstrings.
90
        """Construct a merge proposal.
91
92
        :param mp: A launchpadlib `IBranchMergeProposal`.
93
        """
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
94
        self._mp = mp
95
        self._launchpad = mp._root
96
97
    @property
98
    def source_branch(self):
9404.1.15 by Jonathan Lange
Docstrings.
99
        """The push URL of the source branch."""
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
100
        return str(self._get_push_url(self._mp.source_branch))
101
102
    @property
103
    def target_branch(self):
9404.1.15 by Jonathan Lange
Docstrings.
104
        """The push URL of the target branch."""
9404.1.35 by Jonathan Lange
Make target_branch better.
105
        return str(self._get_push_url(self._mp.target_branch))
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
106
9404.1.27 by Jonathan Lange
Use EDGE, fix up commit message API.
107
    @property
108
    def commit_message(self):
109
        """The commit message specified on the merge proposal."""
110
        return self._mp.commit_message
111
9404.1.28 by Jonathan Lange
Check that the merge proposal is approved before landing it.
112
    @property
113
    def is_approved(self):
114
        """Is this merge proposal approved for landing."""
115
        return self._mp.queue_status == 'Approved'
116
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
117
    def get_stakeholder_emails(self):
118
        """Return a collection of people who should know about branch landing.
119
120
        Used to determine who to email with the ec2 test results.
121
122
        :return: A set of `IPerson`s.
123
        """
9404.1.25 by Jonathan Lange
Compliant XXXs
124
        # XXX: JonathanLange 2009-09-24: No unit tests.
9404.1.30 by Jonathan Lange
Don't specify duplicate email addresses.
125
        return set(
9644.4.5 by Brad Crittenden
Assume no review type is a code review.
126
            map(get_email,
127
                [self._mp.source_branch.owner, self._launchpad.me]))
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
128
129
    def get_reviews(self):
130
        """Return a dictionary of all Approved reviews.
131
132
        Used to determine who has actually approved a branch for landing. The
133
        key of the dictionary is the type of review, and the value is the list
134
        of people who have voted Approve with that type.
135
136
        Common types include 'code', 'db', 'ui' and of course `None`.
137
        """
138
        reviews = {}
139
        for vote in self._mp.votes:
140
            comment = vote.comment
141
            if comment is None or comment.vote != "Approve":
142
                continue
143
            reviewers = reviews.setdefault(vote.review_type, [])
144
            reviewers.append(vote.reviewer)
12708.2.7 by Jonathan Lange
I think that this fixes bug 607434
145
        if self.is_approved and not reviews:
146
            reviews[None] = [self._mp.reviewer]
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
147
        return reviews
148
149
    def get_bugs(self):
9404.1.15 by Jonathan Lange
Docstrings.
150
        """Return a collection of bugs linked to the source branch."""
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
151
        return self._mp.source_branch.linked_bugs
152
153
    def _get_push_url(self, branch):
9404.1.9 by Jonathan Lange
Make get_push_url work and use it a little.
154
        """Return the push URL for 'branch'.
155
156
        This function is a work-around for Launchpad's lack of exposing the
157
        branch's push URL.
158
159
        :param branch: A launchpadlib `IBranch`.
160
        """
9404.1.25 by Jonathan Lange
Compliant XXXs
161
        # XXX: JonathanLange 2009-09-24: No unit tests.
9404.1.9 by Jonathan Lange
Make get_push_url work and use it a little.
162
        host = get_bazaar_host(str(self._launchpad._root_uri))
9404.1.24 by Jonathan Lange
Substantial amount of comment cleanup.
163
        # XXX: JonathanLange 2009-09-24 bug=435790: lazr.uri allows a path
164
        # without a leading '/' and then doesn't insert a '/' in the final
165
        # URL. Do it ourselves.
9404.1.9 by Jonathan Lange
Make get_push_url work and use it a little.
166
        return URI(scheme='bzr+ssh', host=host, path='/' + branch.unique_name)
9404.1.2 by Jonathan Lange
start hacking on autoland.
167
11869.5.2 by Diogo Matsubara
change get_commit_message to build_commit_message since that's what the method is actually doing.
168
    def build_commit_message(self, commit_text, testfix=False, no_qa=False,
11490.1.2 by Diogo Matsubara
implement the rollback option and add more tests for the no-qa and incr options
169
                           incremental=False, rollback=None):
9404.1.14 by Jonathan Lange
Extract a merge proposal wrapper class out of the functions.
170
        """Get the Launchpad-style commit message for a merge proposal."""
171
        reviews = self.get_reviews()
172
        bugs = self.get_bugs()
11079.1.12 by Ursula Junque (Ursinha)
applied changes suggested by jtv: strip the string handling and trying to use tags, inside get_commit_message now I just join the clauses and create the commit msg.
173
11869.5.3 by Diogo Matsubara
add code to remove duplicate tags from commit message
174
        tags = [
11079.1.19 by Ursula Junque (Ursinha)
changes suggested by jtv: how to generate clauses string and some elifs
175
            get_testfix_clause(testfix),
176
            get_reviewer_clause(reviews),
177
            get_bugs_clause(bugs),
178
            get_qa_clause(bugs, no_qa,
11490.1.2 by Diogo Matsubara
implement the rollback option and add more tests for the no-qa and incr options
179
                incremental, rollback=rollback),
11869.5.3 by Diogo Matsubara
add code to remove duplicate tags from commit message
180
            ]
181
182
        # Make sure we don't add duplicated tags to commit_text.
183
        commit_tags = tags[:]
184
        for tag in tags:
185
            if tag in commit_text:
186
                commit_tags.remove(tag)
187
188
        if commit_tags:
189
            return '%s %s' % (''.join(commit_tags), commit_text)
190
        else:
191
            return commit_text
11079.1.12 by Ursula Junque (Ursinha)
applied changes suggested by jtv: strip the string handling and trying to use tags, inside get_commit_message now I just join the clauses and create the commit msg.
192
11869.5.1 by Diogo Matsubara
set the commit message in the merge proposal from the built commit message.
193
    def set_commit_message(self, commit_message):
194
        """Set the Launchpad-style commit message for a merge proposal."""
195
        self._mp.commit_message = commit_message
196
        self._mp.lp_save()
197
11079.1.12 by Ursula Junque (Ursinha)
applied changes suggested by jtv: strip the string handling and trying to use tags, inside get_commit_message now I just join the clauses and create the commit msg.
198
199
def get_testfix_clause(testfix=False):
200
    """Get the testfix clause."""
11079.1.19 by Ursula Junque (Ursinha)
changes suggested by jtv: how to generate clauses string and some elifs
201
    if testfix:
202
        testfix_clause = '[testfix]'
203
    else:
204
        testfix_clause = ''
11079.1.12 by Ursula Junque (Ursinha)
applied changes suggested by jtv: strip the string handling and trying to use tags, inside get_commit_message now I just join the clauses and create the commit msg.
205
    return testfix_clause
206
207
11490.1.2 by Diogo Matsubara
implement the rollback option and add more tests for the no-qa and incr options
208
def get_qa_clause(bugs, no_qa=False, incremental=False, rollback=None):
11079.1.15 by Ursula Junque (Ursinha)
changed all variables and options from incr to incremental, more self explainable; raising MissingBugs exceptions without a message
209
    """Check the no-qa and incremental options, getting the qa clause.
11079.1.12 by Ursula Junque (Ursinha)
applied changes suggested by jtv: strip the string handling and trying to use tags, inside get_commit_message now I just join the clauses and create the commit msg.
210
11490.1.1 by Diogo Matsubara
implement the no-qa and incr use case in the autoland.py script
211
    The qa clause will always be or no-qa, or incremental, or no-qa and
11490.1.2 by Diogo Matsubara
implement the rollback option and add more tests for the no-qa and incr options
212
    incremental, or a revno for the rollback clause, or no tags.
213
214
    See https://dev.launchpad.net/QAProcessContinuousRollouts for detailed
215
    explanation of each clause.
11079.1.12 by Ursula Junque (Ursinha)
applied changes suggested by jtv: strip the string handling and trying to use tags, inside get_commit_message now I just join the clauses and create the commit msg.
216
    """
217
    qa_clause = ""
11079.1.6 by Ursula Junque (Ursinha)
Adding MissingBugsIncrError exception and incr commit tag to the check_qa_clauses method, now changed to check both tags, no-qa and incr.
218
11490.1.2 by Diogo Matsubara
implement the rollback option and add more tests for the no-qa and incr options
219
    if not bugs and not no_qa and not incremental and not rollback:
11079.1.15 by Ursula Junque (Ursinha)
changed all variables and options from incr to incremental, more self explainable; raising MissingBugs exceptions without a message
220
        raise MissingBugsError
221
222
    if incremental and not bugs:
223
        raise MissingBugsIncrementalError
224
11490.1.1 by Diogo Matsubara
implement the no-qa and incr use case in the autoland.py script
225
    if no_qa and incremental:
226
        qa_clause = '[no-qa][incr]'
227
    elif incremental:
11079.1.19 by Ursula Junque (Ursinha)
changes suggested by jtv: how to generate clauses string and some elifs
228
        qa_clause = '[incr]'
229
    elif no_qa:
230
        qa_clause = '[no-qa]'
11490.1.2 by Diogo Matsubara
implement the rollback option and add more tests for the no-qa and incr options
231
    elif rollback:
232
        qa_clause = '[rollback=%d]' % rollback
11079.1.19 by Ursula Junque (Ursinha)
changes suggested by jtv: how to generate clauses string and some elifs
233
    else:
234
        qa_clause = ''
11079.1.6 by Ursula Junque (Ursinha)
Adding MissingBugsIncrError exception and incr commit tag to the check_qa_clauses method, now changed to check both tags, no-qa and incr.
235
11079.1.12 by Ursula Junque (Ursinha)
applied changes suggested by jtv: strip the string handling and trying to use tags, inside get_commit_message now I just join the clauses and create the commit msg.
236
    return qa_clause
11079.1.3 by Ursula Junque (Ursinha)
created a separated method to take care of the no-qa tag, check_qa_clause
237
238
9404.1.11 by Jonathan Lange
Tie everything together to make the default command line.
239
def get_email(person):
9404.1.17 by Jonathan Lange
More docstrings.
240
    """Get the preferred email address for 'person'."""
9404.1.11 by Jonathan Lange
Tie everything together to make the default command line.
241
    email_object = person.preferred_email_address
9404.1.24 by Jonathan Lange
Substantial amount of comment cleanup.
242
    # XXX: JonathanLange 2009-09-24 bug=319432: This raises a very obscure
243
    # error when the email address isn't set. e.g. with name12 in the sample
244
    # data. e.g. "httplib2.RelativeURIError: Only absolute URIs are allowed.
245
    # uri = tag:launchpad.net:2008:redacted".
9404.1.11 by Jonathan Lange
Tie everything together to make the default command line.
246
    return email_object.email
247
9404.1.2 by Jonathan Lange
start hacking on autoland.
248
249
def get_bugs_clause(bugs):
9404.1.4 by Jonathan Lange
More tests.
250
    """Return the bugs clause of a commit message.
251
252
    :param bugs: A collection of `IBug` objects.
253
    :return: A string of the form "[bug=A,B,C]".
254
    """
9404.1.2 by Jonathan Lange
start hacking on autoland.
255
    if not bugs:
256
        return ''
12708.2.5 by Jonathan Lange
Don't include bugs that have been fixed in Launchpad.
257
    bug_ids = []
258
    for bug in bugs:
259
        for task in bug.bug_tasks:
260
            if (task.bug_target_name == 'launchpad'
261
                and task.status not in ['Fix Committed', 'Fix Released']):
262
                bug_ids.append(str(bug.id))
263
                break
264
    if not bug_ids:
265
        return ''
266
    return '[bug=%s]' % ','.join(bug_ids)
9404.1.2 by Jonathan Lange
start hacking on autoland.
267
268
9404.1.4 by Jonathan Lange
More tests.
269
def get_reviewer_handle(reviewer):
270
    """Get the handle for 'reviewer'.
271
272
    The handles of reviewers are included in the commit message for Launchpad
273
    changes. Historically, these handles have been the IRC nicks. Thus, if
274
    'reviewer' has an IRC nickname for Freenode, we use that. Otherwise we use
275
    their Launchpad username.
276
277
    :param reviewer: A launchpadlib `IPerson` object.
9404.1.5 by Jonathan Lange
Get the reviewer string.
278
    :return: unicode text.
9404.1.4 by Jonathan Lange
More tests.
279
    """
280
    irc_handles = reviewer.irc_nicknames
281
    for handle in irc_handles:
282
        if handle.network == 'irc.freenode.net':
283
            return handle.nickname
284
    return reviewer.name
285
286
9404.1.26 by Jonathan Lange
Release-critical support added.
287
def _comma_separated_names(things):
9644.4.7 by Brad Crittenden
Fixed test failures and added a test for mentored reviews.
288
    """Return a string of comma-separated names of 'things'.
289
290
    The list is sorted before being joined.
291
    """
292
    return ','.join(sorted(thing.name for thing in things))
9404.1.26 by Jonathan Lange
Release-critical support added.
293
294
9404.1.5 by Jonathan Lange
Get the reviewer string.
295
def get_reviewer_clause(reviewers):
296
    """Get the reviewer section of a commit message, given the reviewers.
297
298
    :param reviewers: A dict mapping review types to lists of reviewers, as
299
        returned by 'get_reviews'.
300
    :return: A string like u'[r=foo,bar][ui=plop]'.
301
    """
9862.1.2 by Aaron Bentley
Handle the case where the reviewer field is ''.
302
    # If no review type is specified it is assumed to be a code review.
9644.4.5 by Brad Crittenden
Assume no review type is a code review.
303
    code_reviewers = reviewers.get(None, [])
9644.4.4 by Brad Crittenden
Changes from code review. Fixed get_reviewer_clause to account for multiple review types and mentored reviews.
304
    ui_reviewers = []
305
    rc_reviewers = []
306
    for review_type, reviewer in reviewers.items():
9644.4.7 by Brad Crittenden
Fixed test failures and added a test for mentored reviews.
307
        if review_type is None:
308
            continue
9862.1.2 by Aaron Bentley
Handle the case where the reviewer field is ''.
309
        if review_type == '':
310
            code_reviewers.extend(reviewer)
9644.4.7 by Brad Crittenden
Fixed test failures and added a test for mentored reviews.
311
        if 'code' in review_type or 'db' in review_type:
9644.4.4 by Brad Crittenden
Changes from code review. Fixed get_reviewer_clause to account for multiple review types and mentored reviews.
312
            code_reviewers.extend(reviewer)
313
        if 'ui' in review_type:
314
            ui_reviewers.extend(reviewer)
315
        if 'release-critical' in review_type:
316
            rc_reviewers.extend(reviewer)
9404.1.29 by Jonathan Lange
Handle the case when there are no reviewers.
317
    if not code_reviewers:
318
        raise MissingReviewError("Need approved votes in order to land.")
9404.1.5 by Jonathan Lange
Get the reviewer string.
319
    if ui_reviewers:
12289.3.1 by William Grant
Don't add [ui=none] if there are no UI reviewers. The PQM regexp no longer requires it.
320
        ui_clause = '[ui=%s]' % _comma_separated_names(ui_reviewers)
9404.1.5 by Jonathan Lange
Get the reviewer string.
321
    else:
12289.3.1 by William Grant
Don't add [ui=none] if there are no UI reviewers. The PQM regexp no longer requires it.
322
        ui_clause = ''
9404.1.26 by Jonathan Lange
Release-critical support added.
323
    if rc_reviewers:
324
        rc_clause = (
325
            '[release-critical=%s]' % _comma_separated_names(rc_reviewers))
326
    else:
327
        rc_clause = ''
12289.3.1 by William Grant
Don't add [ui=none] if there are no UI reviewers. The PQM regexp no longer requires it.
328
    return '%s[r=%s]%s' % (
9404.1.26 by Jonathan Lange
Release-critical support added.
329
        rc_clause, _comma_separated_names(code_reviewers), ui_clause)
9404.1.5 by Jonathan Lange
Get the reviewer string.
330
331
9404.1.7 by Jonathan Lange
Add a function that will return the bazaar host given an API root.
332
def get_bazaar_host(api_root):
333
    """Get the Bazaar service for the given API root."""
9404.1.24 by Jonathan Lange
Substantial amount of comment cleanup.
334
    # XXX: JonathanLange 2009-09-24 bug=435803: This is only needed because
335
    # Launchpad doesn't expose the push URL for branches.
10430.2.1 by Brad Crittenden
Fixed comparison between launchpad._root_uri and *_SERVICE_ROOT to account for the fact the former has a version tacked onto it now.
336
    if api_root.startswith(EDGE_SERVICE_ROOT):
9404.1.7 by Jonathan Lange
Add a function that will return the bazaar host given an API root.
337
        return 'bazaar.launchpad.net'
10430.2.1 by Brad Crittenden
Fixed comparison between launchpad._root_uri and *_SERVICE_ROOT to account for the fact the former has a version tacked onto it now.
338
    elif api_root.startswith(DEV_SERVICE_ROOT):
9404.1.7 by Jonathan Lange
Add a function that will return the bazaar host given an API root.
339
        return 'bazaar.launchpad.dev'
10430.2.1 by Brad Crittenden
Fixed comparison between launchpad._root_uri and *_SERVICE_ROOT to account for the fact the former has a version tacked onto it now.
340
    elif api_root.startswith(STAGING_SERVICE_ROOT):
9404.1.7 by Jonathan Lange
Add a function that will return the bazaar host given an API root.
341
        return 'bazaar.staging.launchpad.net'
10430.2.1 by Brad Crittenden
Fixed comparison between launchpad._root_uri and *_SERVICE_ROOT to account for the fact the former has a version tacked onto it now.
342
    elif api_root.startswith(LPNET_SERVICE_ROOT):
9404.1.7 by Jonathan Lange
Add a function that will return the bazaar host given an API root.
343
        return 'bazaar.launchpad.net'
344
    else:
345
        raise ValueError(
346
            'Cannot determine Bazaar host. "%s" not a recognized Launchpad '
347
            'API root.' % (api_root,))