Merge lp:~lifeless/lptools/upstream into lp:~dobey/lptools/trunk

Proposed by Robert Collins
Status: Merged
Approved by: dobey
Approved revision: not available
Merged at revision: not available
Proposed branch: lp:~lifeless/lptools/upstream
Merge into: lp:~dobey/lptools/trunk
Diff against target: 535 lines (+361/-59)
7 files modified
bin/lp-milestones (+189/-0)
bin/lp-review-list (+10/-31)
bin/lp-review-notifier (+8/-28)
lptools/__init__.py (+20/-0)
lptools/config.py (+83/-0)
lptools/launchpad.py (+38/-0)
setup.py (+13/-0)
To merge this branch: bzr merge lp:~lifeless/lptools/upstream
Reviewer Review Type Date Requested Status
dobey Approve
Elliot Murphy Pending
Review via email: mp+19764@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Robert Collins (lifeless) wrote :

This adds an lptools package and reduces some duplicate code; I also added a lp-milestones command with a 'create' command. I'll probably add a few more as I automate my release management process.

Oh, it uses bzrlib's command processing facilities, because they are awesome. You might say 'use commandant instead', but that isn't packaged yet.

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

Oh, and commandant depends on bzrlib anyhow :)

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

I've added a delete and rename command to this.

lp:~lifeless/lptools/upstream updated
14. By Robert Collins

Faster creation please.

15. By Robert Collins

Work around launchpadlib wanting absolute urls for.load.

16. By Robert Collins

Add milestone deletion.

17. By Robert Collins

Add milestone renaming.

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

I've also added 'release' as a subcommand, so one can totally script the release process.

lp:~lifeless/lptools/upstream updated
18. By Robert Collins

Add releasing.

19. By Robert Collins

Correctly handle failures in milestones delete.

Revision history for this message
dobey (dobey) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'bin/lp-milestones'
2--- bin/lp-milestones 1970-01-01 00:00:00 +0000
3+++ bin/lp-milestones 2010-02-20 06:50:29 +0000
4@@ -0,0 +1,189 @@
5+#!/usr/bin/python
6+#
7+# Author: Robert Collins <robert.collins@canonical.com>
8+#
9+# Copyright 2010 Canonical Ltd.
10+#
11+# This program is free software: you can redistribute it and/or modify it
12+# under the terms of the GNU General Public License version 3, as published
13+# by the Free Software Foundation.
14+#
15+# This program is distributed in the hope that it will be useful, but
16+# WITHOUT ANY WARRANTY; without even the implied warranties of
17+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
18+# PURPOSE. See the GNU General Public License for more details.
19+#
20+# You should have received a copy of the GNU General Public License along
21+# with this program. If not, see <http://www.gnu.org/licenses/>.
22+
23+from __future__ import with_statement
24+import time
25+import optparse
26+import os
27+import sys
28+
29+# Might want to make errors import lazy.
30+from bzrlib import commands, errors, ui, version_info as bzr_version_info
31+from launchpadlib.errors import HTTPError
32+
33+from lptools import config
34+
35+def list_commands(command_names):
36+ mod = sys.modules[__name__]
37+ command_names.update(commands._scan_module_for_commands(mod))
38+ return command_names
39+
40+
41+def get_command(cmd_or_None, cmd_name):
42+ if cmd_name is None:
43+ return cmd_help()
44+ try:
45+ return globals()['cmd_' + cmd_name]()
46+ except KeyError:
47+ return cmd_or_None
48+
49+
50+class LaunchpadCommand(commands.Command):
51+ """Base class for commands working with launchpad."""
52+
53+ def run_argv_aliases(self, argv, alias_argv=None):
54+ # This might not be unique-enough for a cachedir; can do
55+ # lp-milestones/cmdname if needed.
56+ self.launchpad = config.get_launchpad('lp-milestones')
57+ return commands.Command.run_argv_aliases(self, argv, alias_argv)
58+
59+
60+class cmd_create(LaunchpadCommand):
61+ """Create a milestone.
62+
63+ lp-milestone create projectname/seriesname/milestonename
64+ """
65+
66+ takes_args = ['milestone']
67+
68+ def run(self, milestone):
69+ components = milestone.split('/')
70+ if len(components) != 3:
71+ raise errors.BzrCommandError("milestone (%s) too short or too long."
72+ % milestone)
73+ projectname, seriesname, milestonename = components
74+ # Direct access takes 50% of the time of doing traversal.
75+ #proj = self.launchpad.projects[projectname]
76+ #series = proj.getSeries(name=seriesname)
77+ series = self.launchpad.load(projectname + '/' + seriesname)
78+ milestone = series.newMilestone(name=milestonename)
79+
80+
81+class cmd_delete(LaunchpadCommand):
82+ """Delete a milestone.
83+
84+ lp-milestone delete projectname/milestonename
85+
86+ Note that this cannot delete a milestone with releases on it (yet). The
87+ server will throw a 500 error, and you may see
88+ File "/srv/////lib/lp/registry/model/milestone.py", line 209, in destroySelf
89+ "You cannot delete a milestone which has a product release "
90+ AssertionError: You cannot delete a milestone which has a product release associated with it.
91+ In the trace if you have appropriate access.
92+ """
93+
94+ takes_args = ['milestone']
95+
96+ def run(self, milestone):
97+ components = milestone.split('/')
98+ if len(components) != 2:
99+ raise errors.BzrCommandError("milestone (%s) too short or too long."
100+ % milestone)
101+ m = self.launchpad.load('%s/+milestone/%s' % tuple(components))
102+ try:
103+ m.delete()
104+ except HTTPError, e:
105+ if e.response.status == 404:
106+ pass
107+ elif e.response.status == 500:
108+ self.outf.write("Perhaps the milestone has been released?\n")
109+ self.outf.write("If so you can undo this in the web UI.\n")
110+ raise
111+ else:
112+ raise
113+
114+
115+class cmd_help(commands.Command):
116+ """Show help on a command or other topic."""
117+
118+ # Can't use the stock bzrlib help, because the help indices aren't quite
119+ # generic enough.
120+ takes_args = ['topic?']
121+ def run(self, topic=None):
122+ if topic is None:
123+ self.outf.write(
124+"""lp-milestones -- An lptools command to work with milestones in launchpad.
125+https://launchpad.net/lptools/
126+
127+lp-milestones help commands -- list commands
128+""")
129+ else:
130+ import bzrlib.help
131+ bzrlib.help.help(topic)
132+
133+
134+class cmd_release(LaunchpadCommand):
135+ """Create a release from a milestone.
136+
137+ lp-milestone release projectname/milestonename
138+ """
139+
140+ takes_args = ['milestone']
141+
142+ def run(self, milestone):
143+ components = milestone.split('/')
144+ if len(components) != 2:
145+ raise errors.BzrCommandError("milestone (%s) too short or too long."
146+ % milestone)
147+ m = self.launchpad.load('%s/+milestone/%s' % tuple(components))
148+ now = time.strftime('%Y-%m-%d-%X', time.gmtime())
149+ # note: there is a bug with how the releases are created, don't be surprised
150+ # if they are created 'X hours ago' where 'X' is the hour in UTC.
151+ m.createProductRelease(date_released=now)
152+
153+
154+class cmd_rename(LaunchpadCommand):
155+ """Rename a milestone.
156+
157+ lp-milestone rename projectname/milestonename newname
158+ """
159+
160+ takes_args = ['milestone', 'newname']
161+
162+ def run(self, milestone, newname):
163+ components = milestone.split('/')
164+ if len(components) != 2:
165+ raise errors.BzrCommandError("milestone (%s) too short or too long."
166+ % milestone)
167+ if '/' in newname:
168+ raise errors.BzrCommandError(
169+ "milestones can only be renamed within a project.")
170+ m = self.launchpad.load('%s/+milestone/%s' % tuple(components))
171+ m.name = newname
172+ m.lp_save()
173+
174+
175+def do_run_bzr(argv):
176+ if bzr_version_info > (2, 2, 0):
177+ # in bzr 2.2 we can disable bzr plugins so bzr commands don't show
178+ # up.
179+ return commands.run_bzr(argv, lambda:None, lambda:None)
180+ else:
181+ return commands.run_bzr(argv)
182+
183+
184+def main():
185+ commands.Command.hooks.install_named_hook('list_commands', list_commands,
186+ "list")
187+ commands.Command.hooks.install_named_hook('get_command', get_command,
188+ "get")
189+ ui.ui_factory = ui.make_ui_for_terminal(sys.stdin, sys.stdout, sys.stderr)
190+ sys.exit(commands.exception_to_return_code(do_run_bzr, sys.argv[1:]))
191+
192+if __name__ == "__main__":
193+ main()
194
195=== modified file 'bin/lp-review-list'
196--- bin/lp-review-list 2010-02-01 05:30:54 +0000
197+++ bin/lp-review-list 2010-02-20 06:50:30 +0000
198@@ -17,25 +17,21 @@
199 # with this program. If not, see <http://www.gnu.org/licenses/>.
200
201 from __future__ import with_statement
202+from ConfigParser import ConfigParser
203+import os
204+import re
205+import subprocess
206+import sys
207+from threading import Thread
208
209 import pygtk
210 pygtk.require('2.0')
211 import gobject
212 import gtk
213 import pango
214-
215-from ConfigParser import ConfigParser
216-import os
217-import re
218-import subprocess
219-import sys
220-
221 from xdg.BaseDirectory import xdg_cache_home, xdg_config_home
222
223-from threading import Thread
224-
225-from launchpadlib.launchpad import Launchpad, EDGE_SERVICE_ROOT
226-from launchpadlib.credentials import Credentials
227+from lptools import config
228
229 VOTES = { "Approve" : "#00ff00",
230 "Needs Fixing" : "#993300",
231@@ -65,6 +61,7 @@
232 else:
233 self.projects = []
234
235+ # XXX: Not currently honoured - not sure if it ever worked.
236 if self.config.has_option("lptools", "server"):
237 self.api_server = self.config.get("lptools", "server")
238 else:
239@@ -167,7 +164,7 @@
240 self.set_title("Pending Reviews")
241 self.set_default_size(320, 400)
242 self.connect("destroy", lambda w: gtk.main_quit())
243- self.connect("delete_event", lambda w: gtk.main_quit())
244+ self.connect("delete_event", lambda w,x: gtk.main_quit())
245
246 vbox = gtk.VBox()
247 self.add(vbox)
248@@ -203,10 +200,6 @@
249 col = gtk.TreeViewColumn("Branch", cell, markup=0)
250 view.append_column(col)
251
252- self.cachedir = os.path.join(xdg_cache_home, "review-list")
253- if not os.path.isdir(self.cachedir):
254- os.makedirs(self.cachedir)
255-
256 self.launchpad = None
257 self.me = None
258
259@@ -220,21 +213,7 @@
260 Thread(target=self.__lp_login).start()
261
262 def __lp_login(self):
263- credsfile = os.path.join(self.cachedir, "credentials")
264-
265- if os.path.exists(credsfile):
266- creds = Credentials()
267-
268- with file(credsfile) as f:
269- creds.load(f)
270- self.launchpad = Launchpad(creds, EDGE_SERVICE_ROOT)
271- else:
272- self.launchpad = Launchpad.get_token_and_login(
273- 'review-list',
274- EDGE_SERVICE_ROOT,
275- self.cachedir)
276- with file(credsfile, "w") as f:
277- self.launchpad.credentials.save(f)
278+ self.launchpad = config.get_launchpad("review-list")
279
280 self.me = self.launchpad.me
281
282
283=== modified file 'bin/lp-review-notifier'
284--- bin/lp-review-notifier 2010-02-01 05:30:54 +0000
285+++ bin/lp-review-notifier 2010-02-20 06:50:29 +0000
286@@ -17,21 +17,18 @@
287 # with this program. If not, see <http://www.gnu.org/licenses/>.
288
289 from __future__ import with_statement
290+import os
291+import sys
292
293-import pygtk
294-pygtk.require('2.0')
295 import gobject
296 import gtk
297-
298+import pygtk
299 import pynotify
300-
301-import os
302-import sys
303-
304 from xdg.BaseDirectory import xdg_cache_home
305
306-from launchpadlib.launchpad import Launchpad, EDGE_SERVICE_ROOT
307-from launchpadlib.credentials import Credentials
308+from lptools import config
309+
310+pygtk.require('2.0')
311
312 ICON_NAME = "bzr-icon-64"
313
314@@ -40,31 +37,14 @@
315 def __init__(self):
316 self.id = 0
317 self.cached_candidates = {}
318-
319- self.cachedir = os.path.join(xdg_cache_home, "review-notifier")
320- credsfile = os.path.join(self.cachedir, "credentials")
321-
322- if not os.path.isdir(self.cachedir):
323- os.makedirs(self.cachedir)
324-
325- if os.path.exists(credsfile):
326- creds = Credentials()
327- with file(credsfile) as f:
328- creds.load(f)
329- self.launchpad = Launchpad(creds, EDGE_SERVICE_ROOT)
330- else:
331- self.launchpad = Launchpad.get_token_and_login('review-notifier',
332- EDGE_SERVICE_ROOT,
333- self.cachedir)
334- with file(credsfile, "w") as f:
335- self.launchpad.credentials.save(f)
336-
337+ self.launchpad = config.get_launchpad("review-notifier")
338 self.me = self.launchpad.me
339
340 print "Allo, %s" % self.me.name
341
342 self.projects = []
343
344+ # TODO: get the config from lptools.conf
345 for arg in sys.argv:
346 if not arg.endswith("review-notifier"):
347 self.projects.append(arg)
348
349=== added directory 'lptools'
350=== added file 'lptools/__init__.py'
351--- lptools/__init__.py 1970-01-01 00:00:00 +0000
352+++ lptools/__init__.py 2010-02-20 06:50:30 +0000
353@@ -0,0 +1,20 @@
354+# Author: Robert Collins <robert.collins@canonical.com>
355+#
356+# Copyright 2010 Canonical Ltd.
357+#
358+# This program is free software: you can redistribute it and/or modify it
359+# under the terms of the GNU General Public License version 3, as published
360+# by the Free Software Foundation.
361+#
362+# This program is distributed in the hope that it will be useful, but
363+# WITHOUT ANY WARRANTY; without even the implied warranties of
364+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
365+# PURPOSE. See the GNU General Public License for more details.
366+#
367+# You should have received a copy of the GNU General Public License along
368+# with this program. If not, see <http://www.gnu.org/licenses/>.
369+
370+"""LP-tools is largely command line tools. The package contains various helpers.
371+
372+See lptools.config for configuration support logic.
373+"""
374
375=== added file 'lptools/config.py'
376--- lptools/config.py 1970-01-01 00:00:00 +0000
377+++ lptools/config.py 2010-02-20 06:50:30 +0000
378@@ -0,0 +1,83 @@
379+# Author: Robert Collins <robert.collins@canonical.com>
380+#
381+# Copyright 2010 Canonical Ltd.
382+#
383+# This program is free software: you can redistribute it and/or modify it
384+# under the terms of the GNU General Public License version 3, as published
385+# by the Free Software Foundation.
386+#
387+# This program is distributed in the hope that it will be useful, but
388+# WITHOUT ANY WARRANTY; without even the implied warranties of
389+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
390+# PURPOSE. See the GNU General Public License for more details.
391+#
392+# You should have received a copy of the GNU General Public License along
393+# with this program. If not, see <http://www.gnu.org/licenses/>.
394+
395+from __future__ import with_statement
396+
397+"""Configuration glue for lptools."""
398+
399+__all__ = [
400+ "ensure_dir",
401+ "get_launchpad",
402+ "lptools_cachedir",
403+ "lptools_credentials_path",
404+ ]
405+
406+import os.path
407+
408+from launchpadlib.credentials import Credentials
409+from launchpadlib.launchpad import EDGE_SERVICE_ROOT
410+from xdg.BaseDirectory import xdg_cache_home
411+
412+from lptools import launchpad
413+
414+
415+def ensure_dir(dir):
416+ """Ensure that dir exists."""
417+ if not os.path.isdir(dir):
418+ os.makedirs(dir)
419+
420+
421+def get_launchpad(appname):
422+ """Get a login to launchpad for lptools caching in cachedir.
423+
424+ Note that caching is not multiple-process safe in launchpadlib, and the
425+ appname parameter is used to create per-app cachedirs.
426+
427+ :param appname: The name of the app used to create per-app cachedirs.
428+ """
429+ cachedir = os.path.join(xdg_cache_home, appname)
430+ ensure_dir(cachedir)
431+ credspath = lptools_credentials_path()
432+ if os.path.exists(credspath):
433+ creds = Credentials()
434+ with file(credspath) as f:
435+ creds.load(f)
436+ return launchpad.Launchpad(creds, EDGE_SERVICE_ROOT, cachedir)
437+ else:
438+ result = launchpad.Launchpad.get_token_and_login('lptools',
439+ EDGE_SERVICE_ROOT, cachedir)
440+ with file(credspath, "w") as f:
441+ result.credentials.save(f)
442+ return result
443+
444+
445+def lptools_cachedir():
446+ """Return the cachedir for common lptools things.
447+
448+ This is xdg_cache_home/lptools.
449+ """
450+ return os.path.join(xdg_cache_home, "lptools")
451+
452+
453+def lptools_credentials_path():
454+ """Return the path to the lptools credentials file.
455+
456+ This also ensures the path is usable by checking it's containing directory
457+ exists.
458+ """
459+ cachedir = lptools_cachedir()
460+ ensure_dir(cachedir)
461+ return os.path.join(lptools_cachedir(), 'credentials')
462
463=== added file 'lptools/launchpad.py'
464--- lptools/launchpad.py 1970-01-01 00:00:00 +0000
465+++ lptools/launchpad.py 2010-02-20 06:50:30 +0000
466@@ -0,0 +1,38 @@
467+# Author: Robert Collins <robert.collins@canonical.com>
468+#
469+# Copyright 2010 Canonical Ltd.
470+#
471+# This program is free software: you can redistribute it and/or modify it
472+# under the terms of the GNU General Public License version 3, as published
473+# by the Free Software Foundation.
474+#
475+# This program is distributed in the hope that it will be useful, but
476+# WITHOUT ANY WARRANTY; without even the implied warranties of
477+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
478+# PURPOSE. See the GNU General Public License for more details.
479+#
480+# You should have received a copy of the GNU General Public License along
481+# with this program. If not, see <http://www.gnu.org/licenses/>.
482+
483+__all__ = [
484+ 'Launchpad',
485+ ]
486+
487+"""Wrapper class for launchpadlib with tweaks."""
488+
489+from launchpadlib.launchpad import Launchpad as UpstreamLaunchpad
490+
491+class Launchpad(UpstreamLaunchpad):
492+ """Launchpad object with bugfixes."""
493+
494+ def load(self, url_string):
495+ """Load an object.
496+
497+ Extended to support url_string being a relative url.
498+
499+ Needed until bug 524775 is fixed.
500+ """
501+ if not url_string.startswith('https:'):
502+ return UpstreamLaunchpad.load(self, str(self._root_uri) + url_string)
503+ else:
504+ return UpstreamLaunchpad.load(self, url_string)
505
506=== modified file 'setup.py'
507--- setup.py 2010-02-01 05:30:54 +0000
508+++ setup.py 2010-02-20 06:50:29 +0000
509@@ -2,6 +2,9 @@
510
511 from glob import glob
512 from distutils.core import setup
513+import os.path
514+
515+description = file(os.path.join(os.path.dirname(__file__), 'README'), 'rb').read()
516
517 setup(
518 name='lptools',
519@@ -11,7 +14,17 @@
520 author_email='rodney.dawes@canonical.com',
521 license='GPLv3',
522 description='A collection of tools for developers who use launchpad',
523+ long_description=description,
524 py_modules=[],
525+ packages=['lptools'],
526 scripts=glob('bin/*'),
527+ classifiers = [
528+ 'Development Status :: 4 - Beta',
529+ 'Intended Audience :: Developers',
530+ 'License :: OSI Approved :: GNU General Public License v3 (GPL3)'
531+ 'Operating System :: OS Independent',
532+ 'Programming Language :: Python',
533+ 'Topic :: Software Development',
534+ ],
535 )
536

Subscribers

People subscribed via source and target branches

to all changes: