Merge lp:~allenap/maas/test-bling into lp:maas/trunk

Proposed by Gavin Panella on 2012-01-18
Status: Merged
Merged at revision: 22
Proposed branch: lp:~allenap/maas/test-bling
Merge into: lp:maas/trunk
Diff against target: 1189 lines (+898/-55)
22 files modified
.ctags (+4/-0)
Makefile (+2/-1)
buildout.cfg (+3/-0)
setup.py (+15/-10)
src/maas/development.py (+8/-8)
src/maas/settings.py (+3/-2)
src/maas/testing/__init__.py (+37/-0)
src/maas/testing/runner.py (+11/-4)
src/maas/urls.py (+2/-1)
src/maasserver/macaddress.py (+2/-2)
src/maasserver/models.py (+1/-2)
src/maasserver/tests/__init__.py (+8/-10)
src/maasserver/tests/test_macaddressfield.py (+10/-5)
src/maasserver/tests/test_models.py (+15/-6)
src/maasserver/urls.py (+4/-1)
src/maasserver/views.py (+4/-3)
templates/README (+3/-0)
templates/module.py (+9/-0)
templates/test.py (+19/-0)
utilities/format-imports (+406/-0)
utilities/format-new-and-modified-imports (+13/-0)
utilities/python_standard_libs.py (+319/-0)
To merge this branch: bzr merge lp:~allenap/maas/test-bling
Reviewer Review Type Date Requested Status
Raphaël Badin (community) 2012-01-18 Needs Information on 2012-01-18
Review via email: mp+89078@code.launchpad.net

Description of the Change

This brings testresources and testtools into maas, creating a base class from these. It also adds some templates to use when creating a new module or test.

To post a comment you must log in.
lp:~allenap/maas/test-bling updated on 2012-01-18
28. By Gavin Panella on 2012-01-18

Merge trunk, resolving 1 conflict.

29. By Gavin Panella on 2012-01-18

Use templates/test.py and maas.testing.TestCase in test_macaddressfield.

30. By Gavin Panella on 2012-01-18

Fix lint.

Raphaël Badin (rvb) wrote :

Looks good. I confess I'm no test expert so I'm happy if the testsuite runs when I launch /bin/test. But I'm not sure about [2].

[1]

62 # Use our custom test runner, which makes sure that a local database
63 # cluster is running in the branch.
64 -TEST_RUNNER='maas.testing.runner.CustomTestRunner'
65 +TEST_RUNNER='maas.testing.runner.TestRunner'

Why the rename? It is indeed a custom runner isn't it?

[2]

339 +from maas.testing import TestCase
340 +
341 +
342 +class TestSomething(TestCase):

This is in the template, but you have not updated the existing tests. Why is that?

Also, I sense this is a problem because this will require maasserver's tests to import from maas.testing. This is not a no-go but maas is a simple app that should be independent of the containing project.

What do you think?

[3]

Maybe we should add the copyright headers in a separate branch on all the files at once.

[4]

222 -from django.test import TestCase
223 -
224 -from maasserver.models import MACAddress, Node
225 +from maas.testing import TestCase
226 from maasserver.macaddress import validate_mac
227 +from maasserver.models import (
228 + MACAddress,
229 + Node,
230 + )

How about stealing the lp code to do that if the script is not huge? I really don't want to do that manually each time add an import.

review: Needs Information
Gavin Panella (allenap) wrote :

Thanks for the great review :)

> [1]
>
> 62 # Use our custom test runner, which makes sure that a local database
> 63 # cluster is running in the branch.
> 64 -TEST_RUNNER='maas.testing.runner.CustomTestRunner'
> 65 +TEST_RUNNER='maas.testing.runner.TestRunner'
>
> Why the rename? It is indeed a custom runner isn't it?

Yes, but so is anything that's a subclass. I first changed it to
MaaSTestRunner then realised that it was already in the maas namespace
so dropped the prefix. Perhaps I should have just left it alone right
from the start :)

> [2]
>
> 339 +from maas.testing import TestCase
> 340 +
> 341 +
> 342 +class TestSomething(TestCase):
>
> This is in the template, but you have not updated the existing
> tests. Why is that?

I think I have...?

> Also, I sense this is a problem because this will require
> maasserver's tests to import from maas.testing. This is not a no-go
> but maas is a simple app that should be independent of the
> containing project.

You have a good point. I thought about this a bit...

 * Move preexisting stuff in maas to maas.web
 * Move maasserver to maas.api
 * Leaves room for maas.metadata, etc.
 * maas.testing remains where it is, available for all.

(In other words, .api and .metadata are applications used by .web.)

> [3]
>
> Maybe we should add the copyright headers in a separate branch on
> all the files at once.

Ah, perhaps, but we can get there one bit at a time too :)

> [4]
>
> 222 -from django.test import TestCase
> 223 -
> 224 -from maasserver.models import MACAddress, Node
> 225 +from maas.testing import TestCase
> 226 from maasserver.macaddress import validate_mac
> 227 +from maasserver.models import (
> 228 + MACAddress,
> 229 + Node,
> 230 + )
>
> How about stealing the lp code to do that if the script is not huge?
> I really don't want to do that manually each time add an import.

I have been thinking about packaging that up for a while. I will steal
it for now, but we really ought to get it out there. Fwiw, it can be
run from the LP tree without fuss; it operates on the working
directory, not on the tree in which it resides.

lp:~allenap/maas/test-bling updated on 2012-01-19
31. By Gavin Panella on 2012-01-18

Borrow format-imports from Launchpad.

32. By Gavin Panella on 2012-01-18

Update python_standard_libs for 2.7.

33. By Gavin Panella on 2012-01-18

Also run pep8.

34. By Gavin Panella on 2012-01-18

Fix lint.

35. By Gavin Panella on 2012-01-18

Merge trunk.

36. By Gavin Panella on 2012-01-19

Merge trunk, resolving several conflicts.

37. By Gavin Panella on 2012-01-19

Add empty __init__.py to maasserver.testing, and up-call in NodeTest.setUp().

38. By Gavin Panella on 2012-01-19

Options for tags/TAGS generation.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file '.ctags'
2--- .ctags 1970-01-01 00:00:00 +0000
3+++ .ctags 2012-01-19 10:35:31 +0000
4@@ -0,0 +1,4 @@
5+--python-kinds=-iv
6+--exclude=*-min.js
7+--exclude=*-debug.js
8+--extra=+f
9
10=== modified file 'Makefile'
11--- Makefile 2012-01-18 17:05:52 +0000
12+++ Makefile 2012-01-19 10:35:31 +0000
13@@ -14,8 +14,9 @@
14 test: bin/test
15 bin/test
16
17+lint: sources = setup.py src templates utilities
18 lint:
19- pyflakes src
20+ @(pyflakes $(sources); pep8 --repeat $(sources)) | sort -g
21
22 check: clean test
23
24
25=== modified file 'buildout.cfg'
26--- buildout.cfg 2012-01-17 16:03:30 +0000
27+++ buildout.cfg 2012-01-19 10:35:31 +0000
28@@ -24,9 +24,12 @@
29 django
30 django-debug-toolbar
31 django-piston
32+ fixtures
33 psycopg2
34 rabbitfixture
35 South
36+ testresources
37+ testtools
38 project = maas
39 projectegg = maas
40 test = maasserver
41
42=== modified file 'setup.py'
43--- setup.py 2012-01-18 17:41:01 +0000
44+++ setup.py 2012-01-19 10:35:31 +0000
45@@ -4,11 +4,16 @@
46
47 """Distutils installer for maas."""
48
49+import os
50+
51 import distribute_setup
52 distribute_setup.use_setuptools()
53-import os
54-
55-from setuptools import setup, find_packages
56+
57+from setuptools import (
58+ find_packages,
59+ setup,
60+ )
61+
62
63 def read(fname):
64 return open(os.path.join(os.path.dirname(__file__), fname)).read().strip()
65@@ -21,17 +26,17 @@
66 url="https://launchpad.net/maas",
67 license="GPL",
68 description="Metal as as Service",
69- long_description = read('README.txt'),
70+ long_description=read('README.txt'),
71
72 author="MaaS Developers",
73 author_email="juju@lists.ubuntu.com",
74
75- packages = find_packages('src'),
76- package_dir = {'': 'src'},
77-
78- install_requires = ['setuptools'],
79-
80- classifiers = [
81+ packages=find_packages('src'),
82+ package_dir={'': 'src'},
83+
84+ install_requires=['setuptools'],
85+
86+ classifiers=[
87 'Development Status :: 4 - Beta',
88 'Framework :: Django',
89 'Intended Audience :: Developers',
90
91=== modified file 'src/maas/development.py'
92--- src/maas/development.py 2012-01-17 16:45:13 +0000
93+++ src/maas/development.py 2012-01-19 10:35:31 +0000
94@@ -1,13 +1,13 @@
95
96 # Django development settings for maas project.
97
98+import os
99+
100 from maas.settings import *
101
102-import os
103-
104 # Use our custom test runner, which makes sure that a local database
105 # cluster is running in the branch.
106-TEST_RUNNER='maas.testing.runner.CustomTestRunner'
107+TEST_RUNNER = 'maas.testing.runner.TestRunner'
108
109 DEBUG = True
110 TEMPLATE_DEBUG = DEBUG
111@@ -117,8 +117,8 @@
112 ROOT_URLCONF = 'maas.urls'
113
114 TEMPLATE_DIRS = (
115- # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
116- # Always use forward slashes, even on Windows.
117+ # Put strings here, like "/home/html/django_templates" or
118+ # "C:/www/django/templates". Always use forward slashes, even on Windows.
119 # Don't forget to use absolute paths, not relative paths.
120 os.path.join(os.path.dirname(__file__), "templates"),
121 )
122@@ -150,9 +150,9 @@
123 'version': 1,
124 'disable_existing_loggers': False,
125 'handlers': {
126- 'console':{
127- 'level':'DEBUG',
128- 'class':'logging.StreamHandler',
129+ 'console': {
130+ 'level': 'DEBUG',
131+ 'class': 'logging.StreamHandler',
132 }
133 },
134 'loggers': {
135
136=== modified file 'src/maas/settings.py'
137--- src/maas/settings.py 2012-01-16 17:03:28 +0000
138+++ src/maas/settings.py 2012-01-19 10:35:31 +0000
139@@ -3,6 +3,7 @@
140
141 import os
142
143+
144 ADMINS = (
145 # ('Your Name', 'your_email@example.com'),
146 )
147@@ -123,8 +124,8 @@
148 ROOT_URLCONF = 'maas.urls'
149
150 TEMPLATE_DIRS = (
151- # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
152- # Always use forward slashes, even on Windows.
153+ # Put strings here, like "/home/html/django_templates" or
154+ # "C:/www/django/templates". Always use forward slashes, even on Windows.
155 # Don't forget to use absolute paths, not relative paths.
156 os.path.join(os.path.dirname(__file__), "templates"),
157 )
158
159=== modified file 'src/maas/testing/__init__.py'
160--- src/maas/testing/__init__.py 2012-01-17 16:45:13 +0000
161+++ src/maas/testing/__init__.py 2012-01-19 10:35:31 +0000
162@@ -0,0 +1,37 @@
163+__all__ = []
164+__metaclass__ = type
165+
166+import django.test
167+import testresources
168+import testtools
169+
170+
171+class TestCase(testtools.TestCase, django.test.TestCase):
172+ """`TestCase` for Metal as a Service.
173+
174+ Supports test resources and fixtures.
175+ """
176+
177+ # testresources.ResourcedTestCase does something similar to this class
178+ # (with respect to setUpResources and tearDownResources) but it explicitly
179+ # up-calls to unittest.TestCase instead of using super() even though it is
180+ # not guaranteed that the next class in the inheritance chain is
181+ # unittest.TestCase.
182+
183+ resources = ()
184+
185+ def setUp(self):
186+ super(TestCase, self).setUp()
187+ self.setUpResources()
188+
189+ def setUpResources(self):
190+ testresources.setUpResources(
191+ self, self.resources, testresources._get_result())
192+
193+ def tearDown(self):
194+ self.tearDownResources()
195+ super(TestCase, self).tearDown()
196+
197+ def tearDownResources(self):
198+ testresources.tearDownResources(
199+ self, self.resources, testresources._get_result())
200
201=== modified file 'src/maas/testing/runner.py'
202--- src/maas/testing/runner.py 2012-01-18 14:39:59 +0000
203+++ src/maas/testing/runner.py 2012-01-19 10:35:31 +0000
204@@ -1,11 +1,18 @@
205 from subprocess import check_call
206+
207 from django.test.simple import DjangoTestSuiteRunner
208-
209-
210-class CustomTestRunner(DjangoTestSuiteRunner):
211+from testresources import OptimisingTestSuite
212+
213+
214+class TestRunner(DjangoTestSuiteRunner):
215 """Custom test runner; ensures that the test database cluster is up."""
216
217+ def build_suite(self, test_labels, extra_tests=None, **kwargs):
218+ suite = super(TestRunner, self).build_suite(
219+ test_labels, extra_tests, **kwargs)
220+ return OptimisingTestSuite(suite)
221+
222 def setup_databases(self, *args, **kwargs):
223 """Fire up the db cluster, then punt to original implementation."""
224 check_call(['bin/maasdb', 'start', './db/', 'disposable'])
225- return super(CustomTestRunner, self).setup_databases(*args, **kwargs)
226+ return super(TestRunner, self).setup_databases(*args, **kwargs)
227
228=== modified file 'src/maas/urls.py'
229--- src/maas/urls.py 2012-01-16 16:15:44 +0000
230+++ src/maas/urls.py 2012-01-19 10:35:31 +0000
231@@ -1,6 +1,7 @@
232
233+from django.conf import settings
234 from django.conf.urls.defaults import *
235-from django.conf import settings
236+
237
238 urlpatterns = patterns('',
239 url(r'^', include('maasserver.urls')),
240
241=== modified file 'src/maasserver/macaddress.py'
242--- src/maasserver/macaddress.py 2012-01-18 14:27:42 +0000
243+++ src/maasserver/macaddress.py 2012-01-19 10:35:31 +0000
244@@ -8,8 +8,8 @@
245 mac_re = re.compile(r'^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$')
246
247 validate_mac = RegexValidator(
248- regex = mac_re,
249- message = u"Enter a valid MAC address (e.g. AA:BB:CC:DD:EE:FF).")
250+ regex=mac_re,
251+ message=u"Enter a valid MAC address (e.g. AA:BB:CC:DD:EE:FF).")
252
253
254 class MACAddressField(Field):
255
256=== modified file 'src/maasserver/models.py'
257--- src/maasserver/models.py 2012-01-18 19:16:57 +0000
258+++ src/maasserver/models.py 2012-01-19 10:35:31 +0000
259@@ -2,9 +2,8 @@
260 import re
261 from uuid import uuid1
262
263+from django.contrib import admin
264 from django.db import models
265-from django.contrib import admin
266-
267 from maasserver.macaddress import MACAddressField
268
269
270
271=== added file 'src/maasserver/testing/__init__.py'
272=== modified file 'src/maasserver/tests/__init__.py'
273--- src/maasserver/tests/__init__.py 2012-01-17 15:35:41 +0000
274+++ src/maasserver/tests/__init__.py 2012-01-19 10:35:31 +0000
275@@ -1,12 +1,10 @@
276-import os
277-import unittest
278-
279-_test_suite = unittest.TestSuite()
280-for module in os.listdir(os.path.dirname(__file__)):
281- if module == '__init__.py' or module[-3:] != '.py':
282- continue
283- mod = __import__(module[:-3], locals(), globals())
284- _test_suite.addTests(unittest.TestLoader().loadTestsFromModule(mod))
285+# Copyright 2012 Canonical Ltd. This software is licensed under the
286+# GNU Affero General Public License version 3 (see the file LICENSE).
287+
288+from os.path import dirname
289+
290+from django.utils.unittest import defaultTestLoader
291+
292
293 def suite():
294- return _test_suite
295+ return defaultTestLoader.discover(dirname(__file__))
296
297=== modified file 'src/maasserver/tests/test_macaddressfield.py'
298--- src/maasserver/tests/test_macaddressfield.py 2012-01-18 19:16:57 +0000
299+++ src/maasserver/tests/test_macaddressfield.py 2012-01-19 10:35:31 +0000
300@@ -1,10 +1,15 @@
301-"""
302-Test MACAddressField.
303-"""
304+# Copyright 2012 Canonical Ltd. This software is licensed under the
305+# GNU Affero General Public License version 3 (see the file LICENSE).
306+
307+"""Test MACAddressField."""
308+
309+from __future__ import print_function
310+
311+__metaclass__ = type
312+__all__ = []
313
314 from django.core.exceptions import ValidationError
315-from django.test import TestCase
316-
317+from maas.testing import TestCase
318 from maasserver.models import MACAddress
319 from maasserver.macaddress import validate_mac
320 from maasserver.testing.factory import factory
321
322=== modified file 'src/maasserver/tests/test_models.py'
323--- src/maasserver/tests/test_models.py 2012-01-18 19:16:57 +0000
324+++ src/maasserver/tests/test_models.py 2012-01-19 10:35:31 +0000
325@@ -1,16 +1,25 @@
326-"""
327-Test maasserver models.
328-"""
329+# Copyright 2012 Canonical Ltd. This software is licensed under the
330+# GNU Affero General Public License version 3 (see the file LICENSE).
331+
332+"""Test maasserver models."""
333+
334+from __future__ import print_function
335+
336+__metaclass__ = type
337+__all__ = []
338
339 from django.core.exceptions import ValidationError
340-from django.test import TestCase
341-
342-from maasserver.models import Node, MACAddress
343+from maas.testing import TestCase
344+from maasserver.models import (
345+ MACAddress,
346+ Node,
347+ )
348
349
350 class NodeTest(TestCase):
351
352 def setUp(self):
353+ super(NodeTest, self).setUp()
354 self.node = Node()
355 self.node.save()
356
357
358=== modified file 'src/maasserver/urls.py'
359--- src/maasserver/urls.py 2012-01-18 17:10:34 +0000
360+++ src/maasserver/urls.py 2012-01-19 10:35:31 +0000
361@@ -3,7 +3,10 @@
362 from django.views.generic import ListView
363 from piston.resource import Resource
364 from maasserver.models import Node
365-from maasserver.views import NodeView, NodesCreateView
366+from maasserver.views import (
367+ NodesCreateView,
368+ NodeView,
369+ )
370 from maasserver.api import NodeHandler, NodeMacsHandler
371
372
373
374=== modified file 'src/maasserver/views.py'
375--- src/maasserver/views.py 2012-01-16 08:33:18 +0000
376+++ src/maasserver/views.py 2012-01-19 10:35:31 +0000
377@@ -1,7 +1,8 @@
378-from django.http import HttpResponse
379-from django.views.generic import ListView, CreateView
380 from django.shortcuts import get_object_or_404
381-
382+from django.views.generic import (
383+ CreateView,
384+ ListView,
385+ )
386 from maasserver.models import Node
387
388
389
390=== added directory 'templates'
391=== added file 'templates/README'
392--- templates/README 1970-01-01 00:00:00 +0000
393+++ templates/README 2012-01-19 10:35:31 +0000
394@@ -0,0 +1,3 @@
395+This directory contains templates for files that may need to be added
396+to this project, like HTML pages, Python modules, Python tests, and so
397+on.
398
399=== added file 'templates/module.py'
400--- templates/module.py 1970-01-01 00:00:00 +0000
401+++ templates/module.py 2012-01-19 10:35:31 +0000
402@@ -0,0 +1,9 @@
403+# Copyright 2012 Canonical Ltd. This software is licensed under the
404+# GNU Affero General Public License version 3 (see the file LICENSE).
405+
406+"""..."""
407+
408+from __future__ import print_function
409+
410+__metaclass__ = type
411+__all__ = []
412
413=== added file 'templates/test.py'
414--- templates/test.py 1970-01-01 00:00:00 +0000
415+++ templates/test.py 2012-01-19 10:35:31 +0000
416@@ -0,0 +1,19 @@
417+# Copyright 2012 Canonical Ltd. This software is licensed under the
418+# GNU Affero General Public License version 3 (see the file LICENSE).
419+
420+"""..."""
421+
422+from __future__ import print_function
423+
424+__metaclass__ = type
425+__all__ = []
426+
427+from maas.testing import TestCase
428+
429+
430+class TestSomething(TestCase):
431+
432+ #resources = [...]
433+
434+ def test_something(self):
435+ self.assertTrue(1)
436
437=== added directory 'utilities'
438=== added file 'utilities/format-imports'
439--- utilities/format-imports 1970-01-01 00:00:00 +0000
440+++ utilities/format-imports 2012-01-19 10:35:31 +0000
441@@ -0,0 +1,406 @@
442+#!/usr/bin/python
443+#
444+# Copyright 2010 Canonical Ltd. This software is licensed under the
445+# GNU Affero General Public License version 3 (see the file LICENSE).
446+
447+""" Format import sections in python files
448+
449+= Usage =
450+
451+format-imports <file or directory> ...
452+
453+= Operation =
454+
455+The script will process each filename on the command line. If the file is a
456+directory it recurses into it an process all *.py files found in the tree.
457+It will output the paths of all the files that have been changed.
458+
459+For Launchpad it was applied to the "lib/canonical/launchpad" and the "lib/lp"
460+subtrees. Running it with those parameters on a freshly branched LP tree
461+should not produce any output, meaning that all the files in the tree should
462+be formatted correctly.
463+
464+The script identifies the import section of each file as a block of lines
465+that start with "import" or "from" or are indented with at least one space or
466+are blank lines. Comment lines are also included if they are followed by an
467+import statement. An inital __future__ import and a module docstring are
468+explicitly skipped.
469+
470+The import section is rewritten as three subsections, each separated by a
471+blank line. Any of the sections may be empty.
472+ 1. Standard python library modules
473+ 2. Import statements explicitly ordered to the top (see below)
474+ 3. Third-party modules, meaning anything not fitting one of the other
475+ subsection criteria
476+ 4. Local modules that begin with "canonical" or "lp".
477+
478+Each section is sorted alphabetically by module name. Each module is put
479+on its own line, i.e.
480+{{{
481+ import os, sys
482+}}}
483+becomes
484+{{{
485+ import os
486+ import sys
487+}}}
488+Multiple import statements for the same module are conflated into one
489+statement, or two if the module was imported alongside an object inside it,
490+i.e.
491+{{{
492+ import sys
493+ from sys import stdin
494+}}}
495+
496+Statements that import more than one objects are put on multiple lines in
497+list style, i.e.
498+{{{
499+ from sys import (
500+ stdin,
501+ stdout,
502+ )
503+}}}
504+Objects are sorted alphabetically and case-insensitively. One-object imports
505+are only formatted in this manner if the statement exceeds 78 characters in
506+length.
507+
508+Comments stick with the import statement that followed them. Comments at the
509+end of one-line statements are moved to be be in front of it, .i.e.
510+{{{
511+ from sys import exit # Have a way out
512+}}}
513+becomes
514+{{{
515+ # Have a way out
516+ from sys import exit
517+}}}
518+
519+= Format control =
520+
521+Two special comments allow to control the operation of the formatter.
522+
523+When an import statement is immediately preceded by a comment that starts
524+with the word "FIRST", it is placed into the second subsection (see above).
525+
526+When the first import statement is directly preceded by a comment that starts
527+with the word "SKIP", the entire file is exempt from formatting.
528+
529+= Known bugs =
530+
531+Make sure to always check the result of the re-formatting to see if you have
532+been bitten by one of these.
533+
534+Comments inside multi-line import statements break the formatter. A statement
535+like this will be ignored:
536+{{{
537+ from lp.app.interfaces import (
538+ # Don't do this.
539+ IMyInterface,
540+ IMyOtherInterface, # Don't do this either
541+ )
542+}}}
543+Actually, this will make the statement and all following to be ignored:
544+{{{
545+ from lp.app.interfaces import (
546+ # Breaks indentation rules anyway.
547+ IMyInterface,
548+ IMyOtherInterface,
549+ )
550+}}}
551+
552+If a single-line statement has both a comment in front of it and at the end
553+of the line, only the end-line comment will survive. This could probably
554+easily be fixed to concatenate the too.
555+{{{
556+ # I am a gonner.
557+ from lp.app.interfaces import IMyInterface # I will survive!
558+}}}
559+
560+Line continuation characters are recognized and resolved but
561+not re-introduced. This may leave the re-formatted text with a line that
562+is over the length limit.
563+{{{
564+ from lp.app.verylongnames.orverlydeep.modulestructure.leavenoroom \
565+ import object
566+}}}
567+"""
568+
569+__metaclass__ = type
570+
571+# SKIP this file when reformatting.
572+import os
573+import re
574+import sys
575+from textwrap import dedent
576+
577+sys.path[0:0] = [os.path.dirname(__file__)]
578+from python_standard_libs import python_standard_libs
579+
580+# python_standard_libs is only used for membership tests.
581+python_standard_libs = frozenset(python_standard_libs)
582+
583+# To search for escaped newline chars.
584+escaped_nl_regex = re.compile("\\\\\n", re.M)
585+import_regex = re.compile("^import +(?P<module>.+)$", re.M)
586+from_import_single_regex = re.compile(
587+ "^from (?P<module>.+) +import +"
588+ "(?P<objects>[*]|[a-zA-Z0-9_, ]+)"
589+ "(?P<comment>#.*)?$", re.M)
590+from_import_multi_regex = re.compile(
591+ "^from +(?P<module>.+) +import *[(](?P<objects>[a-zA-Z0-9_, \n]+)[)]$",
592+ re.M)
593+comment_regex = re.compile(
594+ "(?P<comment>(^#.+\n)+)(^import|^from) +(?P<module>[a-zA-Z0-9_.]+)", re.M)
595+split_regex = re.compile(",\s*")
596+module_base_regex = re.compile("([^. ]+)")
597+
598+# Module docstrings are multiline (""") strings that are not indented and are
599+# followed at some point by an import .
600+module_docstring_regex = re.compile(
601+ '(?P<docstring>^["]{3}[^"]+["]{3}\n).*^(import |from .+ import)',
602+ re.M | re.S)
603+# The imports section starts with an import state that is not a __future__
604+# import and consists of import lines, indented lines, empty lines and
605+# comments which are followed by an import line. Sometimes we even find
606+# lines that contain a single ")"... :-(
607+imports_section_regex = re.compile(
608+ "(^#.+\n)*^(import|(from ((?!__future__)\S+) import)).*\n"
609+ "(^import .+\n|^from .+\n|^[\t ]+.+\n|(^#.+\n)+((^import|^from) "
610+ ".+\n)|^\n|^[)]\n)*",
611+ re.M)
612+
613+
614+def format_import_lines(module, objects):
615+ """Generate correct from...import strings."""
616+ if len(objects) == 1:
617+ statement = "from %s import %s" % (module, objects[0])
618+ if len(statement) < 79:
619+ return statement
620+ return "from %s import (\n %s,\n )" % (
621+ module, ",\n ".join(objects))
622+
623+
624+def find_imports_section(content):
625+ """Return that part of the file that contains the import statements."""
626+ # Skip module docstring.
627+ match = module_docstring_regex.search(content)
628+ if match is None:
629+ startpos = 0
630+ else:
631+ startpos = match.end('docstring')
632+
633+ match = imports_section_regex.search(content, startpos)
634+ if match is None:
635+ return (None, None)
636+ startpos = match.start()
637+ endpos = match.end()
638+ if content[startpos:endpos].startswith('# SKIP'):
639+ # Skip files explicitely.
640+ return(None, None)
641+ return (startpos, endpos)
642+
643+
644+class ImportStatement:
645+ """Holds information about an import statement."""
646+
647+ def __init__(self, objects=None, comment=None):
648+ self.import_module = objects is None
649+ if objects is None:
650+ self.objects = None
651+ else:
652+ self.objects = sorted(objects, key=str.lower)
653+ self.comment = comment
654+
655+ def addObjects(self, new_objects):
656+ """More objects in this statement; eliminate duplicates."""
657+ if self.objects is None:
658+ # No objects so far.
659+ self.objects = new_objects
660+ else:
661+ # Use set to eliminate double objects.
662+ more_objects = set(self.objects + new_objects)
663+ self.objects = sorted(list(more_objects), key=str.lower)
664+
665+ def setComment(self, comment):
666+ """Add a comment to the statement."""
667+ self.comment = comment
668+
669+
670+def parse_import_statements(import_section):
671+ """Split the import section into statements.
672+
673+ Returns a dictionary with the module as the key and the objects being
674+ imported as a sorted list of strings."""
675+ imports = {}
676+ # Search for escaped newlines and remove them.
677+ searchpos = 0
678+ while True:
679+ match = escaped_nl_regex.search(import_section, searchpos)
680+ if match is None:
681+ break
682+ start = match.start()
683+ end = match.end()
684+ import_section = import_section[:start] + import_section[end:]
685+ searchpos = start
686+ # Search for simple one-line import statements.
687+ searchpos = 0
688+ while True:
689+ match = import_regex.search(import_section, searchpos)
690+ if match is None:
691+ break
692+ # These imports are marked by a "None" value.
693+ # Multiple modules in one statement are split up.
694+ for module in split_regex.split(match.group('module').strip()):
695+ imports[module] = ImportStatement()
696+ searchpos = match.end()
697+ # Search for "from ... import" statements.
698+ for pattern in (from_import_single_regex, from_import_multi_regex):
699+ searchpos = 0
700+ while True:
701+ match = pattern.search(import_section, searchpos)
702+ if match is None:
703+ break
704+ import_objects = split_regex.split(
705+ match.group('objects').strip(" \n,"))
706+ module = match.group('module').strip()
707+ # Only one pattern has a 'comment' group.
708+ comment = match.groupdict().get('comment', None)
709+ if module in imports:
710+ # Catch double import lines.
711+ imports[module].addObjects(import_objects)
712+ else:
713+ imports[module] = ImportStatement(import_objects)
714+ if comment is not None:
715+ imports[module].setComment(comment)
716+ searchpos = match.end()
717+ # Search for comments in import section.
718+ searchpos = 0
719+ while True:
720+ match = comment_regex.search(import_section, searchpos)
721+ if match is None:
722+ break
723+ module = match.group('module').strip()
724+ comment = match.group('comment').strip()
725+ imports[module].setComment(comment)
726+ searchpos = match.end()
727+
728+ return imports
729+
730+LOCAL_PACKAGES = (
731+ 'canonical', 'lp', 'launchpad_loggerhead', 'devscripts',
732+ # database/* have some implicit relative imports.
733+ 'fti', 'replication', 'preflight', 'security', 'upgrade',
734+ )
735+
736+def format_imports(imports):
737+ """Group and order imports, return the new import statements."""
738+ early_section = {}
739+ standard_section = {}
740+ first_section = {}
741+ thirdparty_section = {}
742+ local_section = {}
743+ # Group modules into sections.
744+ for module, statement in imports.iteritems():
745+ module_base = module_base_regex.findall(module)[0]
746+ comment = statement.comment
747+ if module_base == '_pythonpath':
748+ early_section[module] = statement
749+ elif comment is not None and comment.startswith("# FIRST"):
750+ first_section[module] = statement
751+ elif module_base in LOCAL_PACKAGES:
752+ local_section[module] = statement
753+ elif module_base in python_standard_libs:
754+ standard_section[module] = statement
755+ else:
756+ thirdparty_section[module] = statement
757+
758+ all_import_lines = []
759+ # Sort within each section and generate statement strings.
760+ sections = (
761+ early_section,
762+ standard_section,
763+ first_section,
764+ thirdparty_section,
765+ local_section,
766+ )
767+ for section in sections:
768+ import_lines = []
769+ for module in sorted(section.keys(), key=str.lower):
770+ if section[module].comment is not None:
771+ import_lines.append(section[module].comment)
772+ if section[module].import_module:
773+ import_lines.append("import %s" % module)
774+ if section[module].objects is not None:
775+ import_lines.append(
776+ format_import_lines(module, section[module].objects))
777+ if len(import_lines) > 0:
778+ all_import_lines.append('\n'.join(import_lines))
779+ # Sections are separated by two blank lines.
780+ return '\n\n'.join(all_import_lines)
781+
782+
783+def reformat_importsection(filename):
784+ """Replace the given file with a reformatted version of it."""
785+ pyfile = file(filename).read()
786+ import_start, import_end = find_imports_section(pyfile)
787+ if import_start is None:
788+ # Skip files with no import section.
789+ return False
790+ imports_section = pyfile[import_start:import_end]
791+ imports = parse_import_statements(imports_section)
792+
793+ next_char = pyfile[import_end:import_end + 1]
794+
795+ if next_char == '':
796+ number_of_newlines = 1
797+ elif next_char != '#':
798+ # Two newlines before anything but comments.
799+ number_of_newlines = 3
800+ else:
801+ number_of_newlines = 2
802+
803+ new_imports = format_imports(imports) + ("\n" * number_of_newlines)
804+ if new_imports == imports_section:
805+ # No change, no need to write a new file.
806+ return False
807+
808+ new_file = open(filename, "w")
809+ new_file.write(pyfile[:import_start])
810+ new_file.write(new_imports)
811+ new_file.write(pyfile[import_end:])
812+
813+ return True
814+
815+
816+def process_file(fpath):
817+ """Process the file with the given path."""
818+ changed = reformat_importsection(fpath)
819+ if changed:
820+ print fpath
821+
822+
823+def process_tree(dpath):
824+ """Walk a directory tree and process all *.py files."""
825+ for dirpath, dirnames, filenames in os.walk(dpath):
826+ for filename in filenames:
827+ if filename.endswith('.py'):
828+ process_file(os.path.join(dirpath, filename))
829+
830+
831+if __name__ == "__main__":
832+ if len(sys.argv) == 1 or sys.argv[1] in ("-h", "-?", "--help"):
833+ sys.stderr.write(dedent("""\
834+ usage: format-imports <file or directory> ...
835+
836+ Type "format-imports --docstring | less" to see the documentation.
837+ """))
838+ sys.exit(1)
839+ if sys.argv[1] == "--docstring":
840+ sys.stdout.write(__doc__)
841+ sys.exit(2)
842+ for filename in sys.argv[1:]:
843+ if os.path.isdir(filename):
844+ process_tree(filename)
845+ else:
846+ process_file(filename)
847+ sys.exit(0)
848
849=== added file 'utilities/format-new-and-modified-imports'
850--- utilities/format-new-and-modified-imports 1970-01-01 00:00:00 +0000
851+++ utilities/format-new-and-modified-imports 2012-01-19 10:35:31 +0000
852@@ -0,0 +1,13 @@
853+#!/usr/bin/env bash
854+#
855+# Reformat imports in new and modified files. Call without arguments
856+# to operate on uncommitted changes. Arguments will be passed to bzr
857+# status, so to operate on all new and modified files relative to the
858+# submit branch, use:
859+#
860+# format-new-and-modified-imports -r submit:
861+#
862+
863+bzr status --short "$@" | \
864+ awk '/^.[MN] .*[.]py$/ { print $NF }' | \
865+ xargs -r "$(dirname "$0")/format-imports"
866
867=== added file 'utilities/python_standard_libs.py'
868--- utilities/python_standard_libs.py 1970-01-01 00:00:00 +0000
869+++ utilities/python_standard_libs.py 2012-01-19 10:35:31 +0000
870@@ -0,0 +1,319 @@
871+# Copyright 2010 Canonical Ltd. This software is licensed under the
872+# GNU Affero General Public License version 3 (see the file LICENSE).
873+
874+""" A list of top-level standard python library names.
875+
876+This list is used by format-imports to determine if a module is in this group
877+or not.
878+The list is taken from http://docs.python.org/release/2.5.4/lib/modindex.html
879+but modules specific to other OSs have been taken out. It may need to be
880+updated from time to time.
881+"""
882+
883+
884+# Run this to generate a new module list.
885+if __name__ == '__main__':
886+ from lxml import html
887+ from sys import version_info, stdout
888+ modindex_url = (
889+ "http://docs.python.org/release/"
890+ "{0}.{1}.{2}/modindex.html").format(*version_info)
891+ root = html.parse(modindex_url).getroot()
892+ modules = set(
893+ node.text.split(".", 1)[0] # The "base" module name.
894+ for node in root.cssselect("table tt"))
895+ stdout.write("python_standard_libs = [\n")
896+ for module in sorted(modules, key=str.lower):
897+ stdout.write(" %r,\n" % module)
898+ stdout.write(" ]\n")
899+
900+
901+python_standard_libs = [
902+ '__builtin__',
903+ '__future__',
904+ '__main__',
905+ '_winreg',
906+ 'abc',
907+ 'aepack',
908+ 'aetools',
909+ 'aetypes',
910+ 'aifc',
911+ 'al',
912+ 'AL',
913+ 'anydbm',
914+ 'applesingle',
915+ 'argparse',
916+ 'array',
917+ 'ast',
918+ 'asynchat',
919+ 'asyncore',
920+ 'atexit',
921+ 'audioop',
922+ 'autoGIL',
923+ 'base64',
924+ 'BaseHTTPServer',
925+ 'Bastion',
926+ 'bdb',
927+ 'binascii',
928+ 'binhex',
929+ 'bisect',
930+ 'bsddb',
931+ 'buildtools',
932+ 'bz2',
933+ 'calendar',
934+ 'Carbon',
935+ 'cd',
936+ 'cfmfile',
937+ 'cgi',
938+ 'CGIHTTPServer',
939+ 'cgitb',
940+ 'chunk',
941+ 'cmath',
942+ 'cmd',
943+ 'code',
944+ 'codecs',
945+ 'codeop',
946+ 'collections',
947+ 'ColorPicker',
948+ 'colorsys',
949+ 'commands',
950+ 'compileall',
951+ 'compiler',
952+ 'ConfigParser',
953+ 'contextlib',
954+ 'Cookie',
955+ 'cookielib',
956+ 'copy',
957+ 'copy_reg',
958+ 'cPickle',
959+ 'cProfile',
960+ 'crypt',
961+ 'cStringIO',
962+ 'csv',
963+ 'ctypes',
964+ 'curses',
965+ 'datetime',
966+ 'dbhash',
967+ 'dbm',
968+ 'decimal',
969+ 'DEVICE',
970+ 'difflib',
971+ 'dircache',
972+ 'dis',
973+ 'distutils',
974+ 'dl',
975+ 'doctest',
976+ 'DocXMLRPCServer',
977+ 'dumbdbm',
978+ 'dummy_thread',
979+ 'dummy_threading',
980+ 'EasyDialogs',
981+ 'email',
982+ 'encodings',
983+ 'errno',
984+ 'exceptions',
985+ 'fcntl',
986+ 'filecmp',
987+ 'fileinput',
988+ 'findertools',
989+ 'FL',
990+ 'fl',
991+ 'flp',
992+ 'fm',
993+ 'fnmatch',
994+ 'formatter',
995+ 'fpectl',
996+ 'fpformat',
997+ 'fractions',
998+ 'FrameWork',
999+ 'ftplib',
1000+ 'functools',
1001+ 'future_builtins',
1002+ 'gc',
1003+ 'gdbm',
1004+ 'gensuitemodule',
1005+ 'getopt',
1006+ 'getpass',
1007+ 'gettext',
1008+ 'gl',
1009+ 'GL',
1010+ 'glob',
1011+ 'grp',
1012+ 'gzip',
1013+ 'hashlib',
1014+ 'heapq',
1015+ 'hmac',
1016+ 'hotshot',
1017+ 'htmlentitydefs',
1018+ 'htmllib',
1019+ 'HTMLParser',
1020+ 'httplib',
1021+ 'ic',
1022+ 'icopen',
1023+ 'imageop',
1024+ 'imaplib',
1025+ 'imgfile',
1026+ 'imghdr',
1027+ 'imp',
1028+ 'importlib',
1029+ 'imputil',
1030+ 'inspect',
1031+ 'io',
1032+ 'itertools',
1033+ 'jpeg',
1034+ 'json',
1035+ 'keyword',
1036+ 'lib2to3',
1037+ 'linecache',
1038+ 'locale',
1039+ 'logging',
1040+ 'macerrors',
1041+ 'MacOS',
1042+ 'macostools',
1043+ 'macpath',
1044+ 'macresource',
1045+ 'mailbox',
1046+ 'mailcap',
1047+ 'marshal',
1048+ 'math',
1049+ 'md5',
1050+ 'mhlib',
1051+ 'mimetools',
1052+ 'mimetypes',
1053+ 'MimeWriter',
1054+ 'mimify',
1055+ 'MiniAEFrame',
1056+ 'mmap',
1057+ 'modulefinder',
1058+ 'msilib',
1059+ 'msvcrt',
1060+ 'multifile',
1061+ 'multiprocessing',
1062+ 'mutex',
1063+ 'Nav',
1064+ 'netrc',
1065+ 'new',
1066+ 'nis',
1067+ 'nntplib',
1068+ 'numbers',
1069+ 'operator',
1070+ 'optparse',
1071+ 'os',
1072+ 'ossaudiodev',
1073+ 'parser',
1074+ 'pdb',
1075+ 'pickle',
1076+ 'pickletools',
1077+ 'pipes',
1078+ 'PixMapWrapper',
1079+ 'pkgutil',
1080+ 'platform',
1081+ 'plistlib',
1082+ 'popen2',
1083+ 'poplib',
1084+ 'posix',
1085+ 'posixfile',
1086+ 'pprint',
1087+ 'profile',
1088+ 'pstats',
1089+ 'pty',
1090+ 'pwd',
1091+ 'py_compile',
1092+ 'pyclbr',
1093+ 'pydoc',
1094+ 'Queue',
1095+ 'quopri',
1096+ 'random',
1097+ 're',
1098+ 'readline',
1099+ 'repr',
1100+ 'resource',
1101+ 'rexec',
1102+ 'rfc822',
1103+ 'rlcompleter',
1104+ 'robotparser',
1105+ 'runpy',
1106+ 'sched',
1107+ 'ScrolledText',
1108+ 'select',
1109+ 'sets',
1110+ 'sgmllib',
1111+ 'sha',
1112+ 'shelve',
1113+ 'shlex',
1114+ 'shutil',
1115+ 'signal',
1116+ 'SimpleHTTPServer',
1117+ 'SimpleXMLRPCServer',
1118+ 'site',
1119+ 'smtpd',
1120+ 'smtplib',
1121+ 'sndhdr',
1122+ 'socket',
1123+ 'SocketServer',
1124+ 'spwd',
1125+ 'sqlite3',
1126+ 'ssl',
1127+ 'stat',
1128+ 'statvfs',
1129+ 'string',
1130+ 'StringIO',
1131+ 'stringprep',
1132+ 'struct',
1133+ 'subprocess',
1134+ 'sunau',
1135+ 'sunaudiodev',
1136+ 'SUNAUDIODEV',
1137+ 'symbol',
1138+ 'symtable',
1139+ 'sys',
1140+ 'sysconfig',
1141+ 'syslog',
1142+ 'tabnanny',
1143+ 'tarfile',
1144+ 'telnetlib',
1145+ 'tempfile',
1146+ 'termios',
1147+ 'test',
1148+ 'textwrap',
1149+ 'thread',
1150+ 'threading',
1151+ 'time',
1152+ 'timeit',
1153+ 'Tix',
1154+ 'Tkinter',
1155+ 'token',
1156+ 'tokenize',
1157+ 'trace',
1158+ 'traceback',
1159+ 'ttk',
1160+ 'tty',
1161+ 'turtle',
1162+ 'types',
1163+ 'unicodedata',
1164+ 'unittest',
1165+ 'urllib',
1166+ 'urllib2',
1167+ 'urlparse',
1168+ 'user',
1169+ 'UserDict',
1170+ 'UserList',
1171+ 'UserString',
1172+ 'uu',
1173+ 'uuid',
1174+ 'videoreader',
1175+ 'W',
1176+ 'warnings',
1177+ 'wave',
1178+ 'weakref',
1179+ 'webbrowser',
1180+ 'whichdb',
1181+ 'winsound',
1182+ 'wsgiref',
1183+ 'xdrlib',
1184+ 'xml',
1185+ 'xmlrpclib',
1186+ 'zipfile',
1187+ 'zipimport',
1188+ 'zlib',
1189+ ]