Merge lp:~mpontillo/maas/remove-iscpy-1.8 into lp:maas/1.8

Proposed by Mike Pontillo
Status: Merged
Approved by: Mike Pontillo
Approved revision: no longer in the source branch.
Merged at revision: 4014
Proposed branch: lp:~mpontillo/maas/remove-iscpy-1.8
Merge into: lp:maas/1.8
Diff against target: 546 lines (+470/-10)
5 files modified
buildout.cfg (+0/-1)
src/maasserver/management/commands/edit_named_options.py (+8/-8)
src/maasserver/utils/isc.py (+283/-0)
src/maasserver/utils/tests/test_isc.py (+179/-0)
versions.cfg (+0/-1)
To merge this branch: bzr merge lp:~mpontillo/maas/remove-iscpy-1.8
Reviewer Review Type Date Requested Status
Mike Pontillo (community) Approve
Review via email: mp+263552@code.launchpad.net

Commit message

Remove dependency on iscpy. Add MAAS utility (based on iscpy) for parsing named.options. Add tests for iscpy-derived code.

To post a comment you must log in.
Revision history for this message
Mike Pontillo (mpontillo) wrote :

This is the same code that was approved for 1.7 and trunk, and landed on trunk. (Self-review might be appropriate in this circumstance?)

Revision history for this message
Mike Pontillo (mpontillo) wrote :

Self-approving. 1.7 and trunk have already landed. I don't want 1.8 to feel left out. =)

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (79.7 KiB)

The attempt to merge lp:~mpontillo/maas/remove-iscpy-1.8 into lp:maas/1.8 failed. Below is the output from the failed tests.

Ign http://security.ubuntu.com trusty-security InRelease
Get:1 http://security.ubuntu.com trusty-security Release.gpg [933 B]
Get:2 http://security.ubuntu.com trusty-security Release [63.5 kB]
Ign http://nova.clouds.archive.ubuntu.com trusty InRelease
Ign http://nova.clouds.archive.ubuntu.com trusty-updates InRelease
Hit http://nova.clouds.archive.ubuntu.com trusty Release.gpg
Get:3 http://nova.clouds.archive.ubuntu.com trusty-updates Release.gpg [933 B]
Hit http://nova.clouds.archive.ubuntu.com trusty Release
Get:4 http://nova.clouds.archive.ubuntu.com trusty-updates Release [63.5 kB]
Get:5 http://security.ubuntu.com trusty-security/main Sources [87.4 kB]
Get:6 http://security.ubuntu.com trusty-security/universe Sources [28.1 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/main Sources
Get:7 http://security.ubuntu.com trusty-security/main amd64 Packages [304 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Sources
Hit http://nova.clouds.archive.ubuntu.com trusty/main amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/universe amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en
Get:8 http://nova.clouds.archive.ubuntu.com trusty-updates/main Sources [212 kB]
Get:9 http://security.ubuntu.com trusty-security/universe amd64 Packages [111 kB]
Hit http://security.ubuntu.com trusty-security/main Translation-en
Hit http://security.ubuntu.com trusty-security/universe Translation-en
Get:10 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Sources [123 kB]
Get:11 http://nova.clouds.archive.ubuntu.com trusty-updates/main amd64 Packages [562 kB]
Get:12 http://nova.clouds.archive.ubuntu.com trusty-updates/universe amd64 Packages [292 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/universe Translation-en
Ign http://nova.clouds.archive.ubuntu.com trusty/main Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en_US
Fetched 1,848 kB in 3s (600 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
     --no-install-recommends install apache2 authbind bind9 bind9utils build-essential bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libpq-dev make nodejs-legacy npm pep8 phantomjs postgresql pyflakes python-apt python-bson python-bzrlib python-convoy python-coverage python-crochet python-cssselect python-curtin python-dev python-distro-info python-django python-django-piston python-django-south python-djorm-ext-pgarray python-docutils python-extras python-fixtures python-flake8 python-formencode python-hivex python-httplib2 python-iscpy python-jinja2 python-jsonschema python-lockfile python-lxml python-mock python-netaddr python-netifaces p...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'buildout.cfg'
2--- buildout.cfg 2015-05-29 12:30:52 +0000
3+++ buildout.cfg 2015-07-04 09:40:25 +0000
4@@ -71,7 +71,6 @@
5 djorm-ext-pgarray
6 docutils
7 crochet
8- iscpy
9 entry-points =
10 maas-region-admin=maasserver:execute_from_command_line
11 twistd.region=twisted.scripts.twistd:run
12
13=== modified file 'src/maasserver/management/commands/edit_named_options.py'
14--- src/maasserver/management/commands/edit_named_options.py 2015-05-07 18:14:38 +0000
15+++ src/maasserver/management/commands/edit_named_options.py 2015-07-04 09:40:25 +0000
16@@ -28,9 +28,9 @@
17 BaseCommand,
18 CommandError,
19 )
20-from iscpy import (
21- MakeISC,
22- ParseISCString,
23+from maasserver.utils.isc import (
24+ make_isc_string,
25+ parse_isc_string,
26 )
27 from provisioningserver.dns.config import MAAS_NAMED_CONF_OPTIONS_INSIDE_NAME
28
29@@ -61,12 +61,12 @@
30 return options_file
31
32 def parse_file(self, config_path, options_file):
33- """Read the named.conf.options file and parse it with iscpy.
34+ """Read the named.conf.options file and parse it.
35
36- We also use iscpy to insert the include statement that we need.
37+ Then insert the include statement that we need.
38 """
39 try:
40- config_dict = ParseISCString(options_file)
41+ config_dict = parse_isc_string(options_file)
42 except Exception as e:
43 # Yes, it throws bare exceptions :(
44 raise CommandError("Failed to parse %s: %s" % (
45@@ -81,7 +81,7 @@
46 return config_dict
47
48 def set_up_include_statement(self, options_block, config_path):
49- """Insert the 'include' directive into the iscpy-parsed options."""
50+ """Insert the 'include' directive into the parsed options."""
51 dir = os.path.join(os.path.dirname(config_path), "maas")
52 options_block['include'] = '"%s%s%s"' % (
53 dir, os.path.sep, MAAS_NAMED_CONF_OPTIONS_INSIDE_NAME)
54@@ -128,7 +128,7 @@
55 self.set_up_include_statement(options_block, config_path)
56 self.remove_forwarders(options_block)
57 self.remove_dnssec_validation(options_block)
58- new_content = MakeISC(config_dict)
59+ new_content = make_isc_string(config_dict)
60
61 # Back up and write new file.
62 self.back_up_existing_file(config_path)
63
64=== added file 'src/maasserver/utils/isc.py'
65--- src/maasserver/utils/isc.py 1970-01-01 00:00:00 +0000
66+++ src/maasserver/utils/isc.py 2015-07-04 09:40:25 +0000
67@@ -0,0 +1,283 @@
68+# Copyright (c) 2009, Purdue University
69+# Copyright (c) 2015, Canonical Ltd.
70+# All rights reserved.
71+#
72+# Redistribution and use in source and binary forms, with or without
73+# modification, are permitted provided that the following conditions are met:
74+#
75+# Redistributions of source code must retain the above copyright notice, this
76+# list of conditions and the following disclaimer.
77+#
78+# Redistributions in binary form must reproduce the above copyright notice,
79+# this list of conditions and the following disclaimer in the documentation
80+# and/or other materials provided with the distribution.
81+#
82+# Neither the name of the Purdue University nor the names of its contributors
83+# may be used to endorse or promote products derived from this software without
84+# specific prior written permission.
85+#
86+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
87+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
88+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
89+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
90+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
91+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
92+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
93+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
94+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
95+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
96+# POSSIBILITY OF SUCH DAMAGE.
97+
98+from __future__ import (
99+ absolute_import,
100+ print_function,
101+ unicode_literals,
102+)
103+
104+str = None
105+
106+__metaclass__ = type
107+__all__ = [
108+ 'make_isc_string',
109+ 'parse_isc_string',
110+]
111+
112+import copy
113+
114+
115+def _clip(char_list):
116+ """Clips char_list to individual stanza.
117+
118+ Inputs:
119+ char_list: partial of char_list from _parse_tokens
120+
121+ Outputs:
122+ tuple: (int: skip to char list index, list: shortened char_list)
123+ """
124+ assert char_list[0] == '{'
125+ char_list.pop(0)
126+ skip = 0
127+ for index, item in enumerate(char_list):
128+ if item == '{':
129+ skip += 1
130+ elif item == '}' and skip == 0:
131+ return index, char_list[:index]
132+ elif item == '}':
133+ skip -= 1
134+ raise Exception("Invalid brackets.")
135+
136+
137+def _parse_tokens(char_list):
138+ """Parses exploded isc named.conf portions.
139+
140+ Inputs:
141+ char_list: List of isc file parts
142+
143+ Outputs:
144+ dict: fragment or full isc file dict
145+ Recursive dictionary of isc file, dict values can be of 3 types,
146+ dict, string and bool. Boolean values are always true. Booleans are false
147+ if key is absent. Booleans represent situations in isc files such as:
148+ acl "registered" { 10.1.0/32; 10.1.1:/32;}}
149+
150+ Example:
151+
152+ {'stanza1 "new"': 'test_info', 'stanza1 "embedded"': {'acl "registered"':
153+ {'10.1.0/32': True, '10.1.1/32': True}}}
154+ """
155+ index = 0
156+ dictionary_fragment = {}
157+ new_char_list = copy.deepcopy(char_list)
158+ if type(new_char_list) == str:
159+ return new_char_list
160+ if type(new_char_list) == dict:
161+ return new_char_list
162+ last_open = None
163+ continuous_line = False
164+ temp_list = []
165+
166+ # Prevent "may be referenced before assignment" error
167+ key = None
168+
169+ while index < len(new_char_list):
170+ if new_char_list[index] == '{':
171+ last_open = index
172+ if new_char_list[index] == ';' and continuous_line:
173+ dictionary_fragment = temp_list
174+ temp_list = []
175+ continuous_line = False
176+ if new_char_list[index] == ';':
177+ continuous_line = False
178+ if (len(new_char_list) > index + 1 and
179+ new_char_list[index] == '}' and
180+ new_char_list[index + 1] != ';'):
181+ skip, value = _clip(new_char_list[last_open:])
182+ temp_list.append({key: copy.deepcopy(_parse_tokens(value))})
183+ continuous_line = True
184+ if len(new_char_list) > index + 1 and new_char_list[index + 1] == '{':
185+ # assert key is not None
186+ key = new_char_list.pop(index)
187+ skip, dict_value = _clip(new_char_list[index:])
188+ if continuous_line:
189+ temp_list.append(
190+ {key: copy.deepcopy(_parse_tokens(dict_value))})
191+ else:
192+ dictionary_fragment[key] = copy.deepcopy(
193+ _parse_tokens(dict_value))
194+ index += skip
195+ else:
196+ if len(new_char_list[
197+ index].split()) == 1 and '{' not in new_char_list:
198+ for item in new_char_list:
199+ if item in [';']:
200+ continue
201+ dictionary_fragment[item] = True
202+
203+ # If there are more than 1 'keywords' at new_char_list[index]
204+ # ex - "recursion no;"
205+ elif len(new_char_list[index].split()) >= 2:
206+ dictionary_fragment[
207+ new_char_list[index].split()[0]] = (
208+ ' '.join(new_char_list[index].split()[1:]))
209+ index += 1
210+
211+ # If there is just 1 'keyword' at new_char_list[index]
212+ # ex "recursion;" (not a valid option, but for example's sake it's
213+ # fine)
214+ elif new_char_list[index] not in ['{', ';', '}']:
215+ key = new_char_list[index]
216+ dictionary_fragment[key] = ''
217+ index += 1
218+ index += 1
219+
220+ return dictionary_fragment
221+
222+
223+def _scrub_comments(isc_string):
224+ """Clears comments from an isc file
225+
226+ Inputs:
227+ isc_string: string of isc file
228+ Outputs:
229+ string: string of scrubbed isc file
230+ """
231+ isc_list = []
232+ if isc_string is None:
233+ return ''
234+ expanded_comment = False
235+ for line in isc_string.split('\n'):
236+ no_comment_line = ""
237+ # Vet out any inline comments
238+ if '/*' in line.strip():
239+ try:
240+ striped_line = line.strip()
241+ chars = enumerate(striped_line)
242+ while True:
243+ i, c = chars.next()
244+ try:
245+ if c == '/' and striped_line[i + 1] == '*':
246+ expanded_comment = True
247+ chars.next() # Skip '*'
248+ continue
249+ elif c == '*' and striped_line[i + 1] == '/':
250+ expanded_comment = False
251+ chars.next() # Skip '/'
252+ continue
253+ except IndexError:
254+ continue # We are at the end of the line
255+ if expanded_comment:
256+ continue
257+ else:
258+ no_comment_line += c
259+ except StopIteration:
260+ if no_comment_line:
261+ isc_list.append(no_comment_line)
262+ continue
263+
264+ if expanded_comment:
265+ if '*/' in line.strip():
266+ expanded_comment = False
267+ isc_list.append(line.split('*/')[-1])
268+ continue
269+ else:
270+ continue
271+ if line.strip().startswith(('#', '//')):
272+ continue
273+ else:
274+ isc_list.append(line.split('#')[0].split('//')[0].strip())
275+ return '\n'.join(isc_list)
276+
277+
278+def _explode(isc_string):
279+ """Explodes isc file into relevant tokens.
280+
281+ Inputs:
282+ isc_string: String of isc file
283+
284+ Outputs:
285+ list: list of isc file tokens delimited by brackets and semicolons
286+ ['stanza1 "new"', '{', 'test_info', ';', '}']
287+ """
288+ str_array = []
289+ temp_string = []
290+ for char in isc_string:
291+ if char in ['\n']:
292+ continue
293+ if char in ['{', '}', ';']:
294+ if ''.join(temp_string).strip() == '':
295+ str_array.append(char)
296+ else:
297+ str_array.append(''.join(temp_string).strip())
298+ str_array.append(char)
299+ temp_string = []
300+ else:
301+ temp_string.append(char)
302+ return str_array
303+
304+
305+def parse_isc_string(isc_string):
306+ """Makes a dictionary from an ISC file string
307+
308+ Inputs:
309+ isc_string: string of isc file
310+
311+ Outputs:
312+ dict: dictionary of ISC file representation
313+ """
314+ return _parse_tokens(_explode(_scrub_comments(isc_string)))
315+
316+
317+def make_isc_string(isc_dict, terminate=True):
318+ """Outputs an isc formatted file string from a dict
319+
320+ Inputs:
321+ isc_dict: a recursive dictionary to be turned into an isc file
322+ (from ParseTokens)
323+
324+ Outputs:
325+ str: string of isc file without indentation
326+ """
327+ if terminate:
328+ terminator = ';'
329+ else:
330+ terminator = ''
331+ if type(isc_dict) == str:
332+ return isc_dict
333+ isc_list = []
334+ for option in isc_dict:
335+ if type(isc_dict[option]) == bool:
336+ isc_list.append('%s%s' % (option, terminator))
337+ elif (type(isc_dict[option]) == str or
338+ type(isc_dict[option]) == unicode):
339+ isc_list.append('%s %s%s' % (option, isc_dict[option], terminator))
340+ elif type(isc_dict[option]) == list:
341+ new_list = []
342+ for item in isc_dict[option]:
343+ new_list.append(make_isc_string(item, terminate=False))
344+ new_list[-1] = '%s%s' % (new_list[-1], terminator)
345+ isc_list.append(
346+ '%s { %s }%s' % (option, ' '.join(new_list), terminator))
347+ elif type(isc_dict[option]) == dict:
348+ isc_list.append('%s { %s }%s' % (
349+ option, make_isc_string(isc_dict[option]), terminator))
350+ return '\n'.join(isc_list)
351
352=== added file 'src/maasserver/utils/tests/test_isc.py'
353--- src/maasserver/utils/tests/test_isc.py 1970-01-01 00:00:00 +0000
354+++ src/maasserver/utils/tests/test_isc.py 2015-07-04 09:40:25 +0000
355@@ -0,0 +1,179 @@
356+# Copyright 2015 Canonical Ltd. This software is licensed under the
357+# GNU Affero General Public License version 3 (see the file LICENSE).
358+
359+"""Test ISC configuration file parser/generator."""
360+
361+from __future__ import (
362+ absolute_import,
363+ print_function,
364+ unicode_literals,
365+)
366+
367+str = None
368+
369+__metaclass__ = type
370+__all__ = []
371+
372+
373+from textwrap import dedent
374+
375+from maasserver.utils.isc import (
376+ make_isc_string,
377+ parse_isc_string,
378+)
379+from maastesting.testcase import MAASTestCase
380+
381+
382+class TestParseISCString(MAASTestCase):
383+
384+ def test_parses_simple_bind_options(self):
385+ testdata = dedent("""\
386+ options {
387+ directory "/var/cache/bind";
388+
389+ dnssec-validation auto;
390+
391+ auth-nxdomain no; # conform to RFC1035
392+ listen-on-v6 { any; };
393+ };
394+ """)
395+ options = parse_isc_string(testdata)
396+ self.assertEqual(
397+ {u'options': {u'auth-nxdomain': u'no',
398+ u'directory': u'"/var/cache/bind"',
399+ u'dnssec-validation': u'auto',
400+ u'listen-on-v6': {u'any': True}}}, options)
401+
402+ def test_parses_bind_acl(self):
403+ testdata = dedent("""\
404+ acl goodclients {
405+ 192.0.2.0/24;
406+ localhost;
407+ localnets;
408+ };
409+ """)
410+ acl = parse_isc_string(testdata)
411+ self.assertEqual(
412+ {u'acl goodclients': {u'192.0.2.0/24': True,
413+ u'localhost': True,
414+ u'localnets': True}}, acl)
415+
416+ def test_parses_multiple_forwarders(self):
417+ testdata = dedent("""\
418+ forwarders {
419+ 91.189.94.2;
420+ 91.189.94.3;
421+ 91.189.94.4;
422+ 91.189.94.5;
423+ 91.189.94.6;
424+ };
425+ """)
426+ forwarders = parse_isc_string(testdata)
427+ self.assertEqual(
428+ {u'forwarders': {u'91.189.94.2': True,
429+ u'91.189.94.3': True,
430+ u'91.189.94.4': True,
431+ u'91.189.94.5': True,
432+ u'91.189.94.6': True}}, forwarders)
433+
434+ def test_parses_bug_1413388_config(self):
435+ testdata = dedent("""\
436+ acl canonical-int-ns { 91.189.90.151; 91.189.89.192; };
437+
438+ options {
439+ directory "/var/cache/bind";
440+
441+ forwarders {
442+ 91.189.94.2;
443+ 91.189.94.2;
444+ };
445+
446+ dnssec-validation auto;
447+
448+ auth-nxdomain no; # conform to RFC1035
449+ listen-on-v6 { any; };
450+
451+ allow-query { any; };
452+ allow-transfer { 10.222.64.1; canonical-int-ns; };
453+
454+ notify explicit;
455+ also-notify { 91.189.90.151; 91.189.89.192; };
456+
457+ allow-query-cache { 10.222.64.0/18; };
458+ recursion yes;
459+ };
460+
461+ zone "." { type master; file "/etc/bind/db.special"; };
462+ """)
463+ config = parse_isc_string(testdata)
464+ self.assertEqual(
465+ {u'acl canonical-int-ns':
466+ {u'91.189.89.192': True, u'91.189.90.151': True},
467+ u'options': {u'allow-query': {u'any': True},
468+ u'allow-query-cache': {u'10.222.64.0/18': True},
469+ u'allow-transfer': {u'10.222.64.1': True,
470+ u'canonical-int-ns': True},
471+ u'also-notify': {u'91.189.89.192': True,
472+ u'91.189.90.151': True},
473+ u'auth-nxdomain': u'no',
474+ u'directory': u'"/var/cache/bind"',
475+ u'dnssec-validation': u'auto',
476+ u'forwarders': {u'91.189.94.2': True},
477+ u'listen-on-v6': {u'any': True},
478+ u'notify': u'explicit',
479+ u'recursion': u'yes'},
480+ u'zone "."':
481+ {u'file': u'"/etc/bind/db.special"', u'type': u'master'}},
482+ config)
483+
484+ def test_parse_then_make_then_parse_generates_identical_config(self):
485+ testdata = dedent("""\
486+ acl canonical-int-ns { 91.189.90.151; 91.189.89.192; };
487+
488+ options {
489+ directory "/var/cache/bind";
490+
491+ forwarders {
492+ 91.189.94.2;
493+ 91.189.94.2;
494+ };
495+
496+ dnssec-validation auto;
497+
498+ auth-nxdomain no; # conform to RFC1035
499+ listen-on-v6 { any; };
500+
501+ allow-query { any; };
502+ allow-transfer { 10.222.64.1; canonical-int-ns; };
503+
504+ notify explicit;
505+ also-notify { 91.189.90.151; 91.189.89.192; };
506+
507+ allow-query-cache { 10.222.64.0/18; };
508+ recursion yes;
509+ };
510+
511+ zone "." { type master; file "/etc/bind/db.special"; };
512+ """)
513+ config = parse_isc_string(testdata)
514+ config_string = make_isc_string(config)
515+ config = parse_isc_string(config_string)
516+ self.assertEqual(
517+ {u'acl canonical-int-ns':
518+ {u'91.189.89.192': True, u'91.189.90.151': True},
519+ u'options': {u'allow-query': {u'any': True},
520+ u'allow-query-cache': {u'10.222.64.0/18': True},
521+ u'allow-transfer': {u'10.222.64.1': True,
522+ u'canonical-int-ns': True},
523+ u'also-notify': {u'91.189.89.192': True,
524+ u'91.189.90.151': True},
525+ u'auth-nxdomain': u'no',
526+ u'directory': u'"/var/cache/bind"',
527+ u'dnssec-validation': u'auto',
528+ u'forwarders': {u'91.189.94.2': True},
529+ u'listen-on-v6': {u'any': True},
530+ u'notify': u'explicit',
531+ u'recursion': u'yes'},
532+ u'zone "."':
533+ {u'file': u'"/etc/bind/db.special"', u'type': u'master'}},
534+ config)
535
536=== modified file 'versions.cfg'
537--- versions.cfg 2015-05-06 09:51:19 +0000
538+++ versions.cfg 2015-07-04 09:40:25 +0000
539@@ -35,7 +35,6 @@
540 extras = 0.0.3
541 fixtures = 0.3.14
542 httplib2 = 0.8
543-iscpy = 1.05
544 iso8601 = 0.1.4
545 junitxml = 0.6
546 nose = 1.3.1

Subscribers

People subscribed via source and target branches

to all changes: