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
=== modified file 'buildout.cfg'
--- buildout.cfg 2015-05-29 12:30:52 +0000
+++ buildout.cfg 2015-07-04 09:40:25 +0000
@@ -71,7 +71,6 @@
71 djorm-ext-pgarray71 djorm-ext-pgarray
72 docutils72 docutils
73 crochet73 crochet
74 iscpy
75entry-points =74entry-points =
76 maas-region-admin=maasserver:execute_from_command_line75 maas-region-admin=maasserver:execute_from_command_line
77 twistd.region=twisted.scripts.twistd:run76 twistd.region=twisted.scripts.twistd:run
7877
=== modified file 'src/maasserver/management/commands/edit_named_options.py'
--- src/maasserver/management/commands/edit_named_options.py 2015-05-07 18:14:38 +0000
+++ src/maasserver/management/commands/edit_named_options.py 2015-07-04 09:40:25 +0000
@@ -28,9 +28,9 @@
28 BaseCommand,28 BaseCommand,
29 CommandError,29 CommandError,
30)30)
31from iscpy import (31from maasserver.utils.isc import (
32 MakeISC,32 make_isc_string,
33 ParseISCString,33 parse_isc_string,
34)34)
35from provisioningserver.dns.config import MAAS_NAMED_CONF_OPTIONS_INSIDE_NAME35from provisioningserver.dns.config import MAAS_NAMED_CONF_OPTIONS_INSIDE_NAME
3636
@@ -61,12 +61,12 @@
61 return options_file61 return options_file
6262
63 def parse_file(self, config_path, options_file):63 def parse_file(self, config_path, options_file):
64 """Read the named.conf.options file and parse it with iscpy.64 """Read the named.conf.options file and parse it.
6565
66 We also use iscpy to insert the include statement that we need.66 Then insert the include statement that we need.
67 """67 """
68 try:68 try:
69 config_dict = ParseISCString(options_file)69 config_dict = parse_isc_string(options_file)
70 except Exception as e:70 except Exception as e:
71 # Yes, it throws bare exceptions :(71 # Yes, it throws bare exceptions :(
72 raise CommandError("Failed to parse %s: %s" % (72 raise CommandError("Failed to parse %s: %s" % (
@@ -81,7 +81,7 @@
81 return config_dict81 return config_dict
8282
83 def set_up_include_statement(self, options_block, config_path):83 def set_up_include_statement(self, options_block, config_path):
84 """Insert the 'include' directive into the iscpy-parsed options."""84 """Insert the 'include' directive into the parsed options."""
85 dir = os.path.join(os.path.dirname(config_path), "maas")85 dir = os.path.join(os.path.dirname(config_path), "maas")
86 options_block['include'] = '"%s%s%s"' % (86 options_block['include'] = '"%s%s%s"' % (
87 dir, os.path.sep, MAAS_NAMED_CONF_OPTIONS_INSIDE_NAME)87 dir, os.path.sep, MAAS_NAMED_CONF_OPTIONS_INSIDE_NAME)
@@ -128,7 +128,7 @@
128 self.set_up_include_statement(options_block, config_path)128 self.set_up_include_statement(options_block, config_path)
129 self.remove_forwarders(options_block)129 self.remove_forwarders(options_block)
130 self.remove_dnssec_validation(options_block)130 self.remove_dnssec_validation(options_block)
131 new_content = MakeISC(config_dict)131 new_content = make_isc_string(config_dict)
132132
133 # Back up and write new file.133 # Back up and write new file.
134 self.back_up_existing_file(config_path)134 self.back_up_existing_file(config_path)
135135
=== added file 'src/maasserver/utils/isc.py'
--- src/maasserver/utils/isc.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/utils/isc.py 2015-07-04 09:40:25 +0000
@@ -0,0 +1,283 @@
1# Copyright (c) 2009, Purdue University
2# Copyright (c) 2015, Canonical Ltd.
3# All rights reserved.
4#
5# Redistribution and use in source and binary forms, with or without
6# modification, are permitted provided that the following conditions are met:
7#
8# Redistributions of source code must retain the above copyright notice, this
9# list of conditions and the following disclaimer.
10#
11# Redistributions in binary form must reproduce the above copyright notice,
12# this list of conditions and the following disclaimer in the documentation
13# and/or other materials provided with the distribution.
14#
15# Neither the name of the Purdue University nor the names of its contributors
16# may be used to endorse or promote products derived from this software without
17# specific prior written permission.
18#
19# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
22# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
23# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
24# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
25# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
26# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
27# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
28# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29# POSSIBILITY OF SUCH DAMAGE.
30
31from __future__ import (
32 absolute_import,
33 print_function,
34 unicode_literals,
35)
36
37str = None
38
39__metaclass__ = type
40__all__ = [
41 'make_isc_string',
42 'parse_isc_string',
43]
44
45import copy
46
47
48def _clip(char_list):
49 """Clips char_list to individual stanza.
50
51 Inputs:
52 char_list: partial of char_list from _parse_tokens
53
54 Outputs:
55 tuple: (int: skip to char list index, list: shortened char_list)
56 """
57 assert char_list[0] == '{'
58 char_list.pop(0)
59 skip = 0
60 for index, item in enumerate(char_list):
61 if item == '{':
62 skip += 1
63 elif item == '}' and skip == 0:
64 return index, char_list[:index]
65 elif item == '}':
66 skip -= 1
67 raise Exception("Invalid brackets.")
68
69
70def _parse_tokens(char_list):
71 """Parses exploded isc named.conf portions.
72
73 Inputs:
74 char_list: List of isc file parts
75
76 Outputs:
77 dict: fragment or full isc file dict
78 Recursive dictionary of isc file, dict values can be of 3 types,
79 dict, string and bool. Boolean values are always true. Booleans are false
80 if key is absent. Booleans represent situations in isc files such as:
81 acl "registered" { 10.1.0/32; 10.1.1:/32;}}
82
83 Example:
84
85 {'stanza1 "new"': 'test_info', 'stanza1 "embedded"': {'acl "registered"':
86 {'10.1.0/32': True, '10.1.1/32': True}}}
87 """
88 index = 0
89 dictionary_fragment = {}
90 new_char_list = copy.deepcopy(char_list)
91 if type(new_char_list) == str:
92 return new_char_list
93 if type(new_char_list) == dict:
94 return new_char_list
95 last_open = None
96 continuous_line = False
97 temp_list = []
98
99 # Prevent "may be referenced before assignment" error
100 key = None
101
102 while index < len(new_char_list):
103 if new_char_list[index] == '{':
104 last_open = index
105 if new_char_list[index] == ';' and continuous_line:
106 dictionary_fragment = temp_list
107 temp_list = []
108 continuous_line = False
109 if new_char_list[index] == ';':
110 continuous_line = False
111 if (len(new_char_list) > index + 1 and
112 new_char_list[index] == '}' and
113 new_char_list[index + 1] != ';'):
114 skip, value = _clip(new_char_list[last_open:])
115 temp_list.append({key: copy.deepcopy(_parse_tokens(value))})
116 continuous_line = True
117 if len(new_char_list) > index + 1 and new_char_list[index + 1] == '{':
118 # assert key is not None
119 key = new_char_list.pop(index)
120 skip, dict_value = _clip(new_char_list[index:])
121 if continuous_line:
122 temp_list.append(
123 {key: copy.deepcopy(_parse_tokens(dict_value))})
124 else:
125 dictionary_fragment[key] = copy.deepcopy(
126 _parse_tokens(dict_value))
127 index += skip
128 else:
129 if len(new_char_list[
130 index].split()) == 1 and '{' not in new_char_list:
131 for item in new_char_list:
132 if item in [';']:
133 continue
134 dictionary_fragment[item] = True
135
136 # If there are more than 1 'keywords' at new_char_list[index]
137 # ex - "recursion no;"
138 elif len(new_char_list[index].split()) >= 2:
139 dictionary_fragment[
140 new_char_list[index].split()[0]] = (
141 ' '.join(new_char_list[index].split()[1:]))
142 index += 1
143
144 # If there is just 1 'keyword' at new_char_list[index]
145 # ex "recursion;" (not a valid option, but for example's sake it's
146 # fine)
147 elif new_char_list[index] not in ['{', ';', '}']:
148 key = new_char_list[index]
149 dictionary_fragment[key] = ''
150 index += 1
151 index += 1
152
153 return dictionary_fragment
154
155
156def _scrub_comments(isc_string):
157 """Clears comments from an isc file
158
159 Inputs:
160 isc_string: string of isc file
161 Outputs:
162 string: string of scrubbed isc file
163 """
164 isc_list = []
165 if isc_string is None:
166 return ''
167 expanded_comment = False
168 for line in isc_string.split('\n'):
169 no_comment_line = ""
170 # Vet out any inline comments
171 if '/*' in line.strip():
172 try:
173 striped_line = line.strip()
174 chars = enumerate(striped_line)
175 while True:
176 i, c = chars.next()
177 try:
178 if c == '/' and striped_line[i + 1] == '*':
179 expanded_comment = True
180 chars.next() # Skip '*'
181 continue
182 elif c == '*' and striped_line[i + 1] == '/':
183 expanded_comment = False
184 chars.next() # Skip '/'
185 continue
186 except IndexError:
187 continue # We are at the end of the line
188 if expanded_comment:
189 continue
190 else:
191 no_comment_line += c
192 except StopIteration:
193 if no_comment_line:
194 isc_list.append(no_comment_line)
195 continue
196
197 if expanded_comment:
198 if '*/' in line.strip():
199 expanded_comment = False
200 isc_list.append(line.split('*/')[-1])
201 continue
202 else:
203 continue
204 if line.strip().startswith(('#', '//')):
205 continue
206 else:
207 isc_list.append(line.split('#')[0].split('//')[0].strip())
208 return '\n'.join(isc_list)
209
210
211def _explode(isc_string):
212 """Explodes isc file into relevant tokens.
213
214 Inputs:
215 isc_string: String of isc file
216
217 Outputs:
218 list: list of isc file tokens delimited by brackets and semicolons
219 ['stanza1 "new"', '{', 'test_info', ';', '}']
220 """
221 str_array = []
222 temp_string = []
223 for char in isc_string:
224 if char in ['\n']:
225 continue
226 if char in ['{', '}', ';']:
227 if ''.join(temp_string).strip() == '':
228 str_array.append(char)
229 else:
230 str_array.append(''.join(temp_string).strip())
231 str_array.append(char)
232 temp_string = []
233 else:
234 temp_string.append(char)
235 return str_array
236
237
238def parse_isc_string(isc_string):
239 """Makes a dictionary from an ISC file string
240
241 Inputs:
242 isc_string: string of isc file
243
244 Outputs:
245 dict: dictionary of ISC file representation
246 """
247 return _parse_tokens(_explode(_scrub_comments(isc_string)))
248
249
250def make_isc_string(isc_dict, terminate=True):
251 """Outputs an isc formatted file string from a dict
252
253 Inputs:
254 isc_dict: a recursive dictionary to be turned into an isc file
255 (from ParseTokens)
256
257 Outputs:
258 str: string of isc file without indentation
259 """
260 if terminate:
261 terminator = ';'
262 else:
263 terminator = ''
264 if type(isc_dict) == str:
265 return isc_dict
266 isc_list = []
267 for option in isc_dict:
268 if type(isc_dict[option]) == bool:
269 isc_list.append('%s%s' % (option, terminator))
270 elif (type(isc_dict[option]) == str or
271 type(isc_dict[option]) == unicode):
272 isc_list.append('%s %s%s' % (option, isc_dict[option], terminator))
273 elif type(isc_dict[option]) == list:
274 new_list = []
275 for item in isc_dict[option]:
276 new_list.append(make_isc_string(item, terminate=False))
277 new_list[-1] = '%s%s' % (new_list[-1], terminator)
278 isc_list.append(
279 '%s { %s }%s' % (option, ' '.join(new_list), terminator))
280 elif type(isc_dict[option]) == dict:
281 isc_list.append('%s { %s }%s' % (
282 option, make_isc_string(isc_dict[option]), terminator))
283 return '\n'.join(isc_list)
0284
=== added file 'src/maasserver/utils/tests/test_isc.py'
--- src/maasserver/utils/tests/test_isc.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/utils/tests/test_isc.py 2015-07-04 09:40:25 +0000
@@ -0,0 +1,179 @@
1# Copyright 2015 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Test ISC configuration file parser/generator."""
5
6from __future__ import (
7 absolute_import,
8 print_function,
9 unicode_literals,
10)
11
12str = None
13
14__metaclass__ = type
15__all__ = []
16
17
18from textwrap import dedent
19
20from maasserver.utils.isc import (
21 make_isc_string,
22 parse_isc_string,
23)
24from maastesting.testcase import MAASTestCase
25
26
27class TestParseISCString(MAASTestCase):
28
29 def test_parses_simple_bind_options(self):
30 testdata = dedent("""\
31 options {
32 directory "/var/cache/bind";
33
34 dnssec-validation auto;
35
36 auth-nxdomain no; # conform to RFC1035
37 listen-on-v6 { any; };
38 };
39 """)
40 options = parse_isc_string(testdata)
41 self.assertEqual(
42 {u'options': {u'auth-nxdomain': u'no',
43 u'directory': u'"/var/cache/bind"',
44 u'dnssec-validation': u'auto',
45 u'listen-on-v6': {u'any': True}}}, options)
46
47 def test_parses_bind_acl(self):
48 testdata = dedent("""\
49 acl goodclients {
50 192.0.2.0/24;
51 localhost;
52 localnets;
53 };
54 """)
55 acl = parse_isc_string(testdata)
56 self.assertEqual(
57 {u'acl goodclients': {u'192.0.2.0/24': True,
58 u'localhost': True,
59 u'localnets': True}}, acl)
60
61 def test_parses_multiple_forwarders(self):
62 testdata = dedent("""\
63 forwarders {
64 91.189.94.2;
65 91.189.94.3;
66 91.189.94.4;
67 91.189.94.5;
68 91.189.94.6;
69 };
70 """)
71 forwarders = parse_isc_string(testdata)
72 self.assertEqual(
73 {u'forwarders': {u'91.189.94.2': True,
74 u'91.189.94.3': True,
75 u'91.189.94.4': True,
76 u'91.189.94.5': True,
77 u'91.189.94.6': True}}, forwarders)
78
79 def test_parses_bug_1413388_config(self):
80 testdata = dedent("""\
81 acl canonical-int-ns { 91.189.90.151; 91.189.89.192; };
82
83 options {
84 directory "/var/cache/bind";
85
86 forwarders {
87 91.189.94.2;
88 91.189.94.2;
89 };
90
91 dnssec-validation auto;
92
93 auth-nxdomain no; # conform to RFC1035
94 listen-on-v6 { any; };
95
96 allow-query { any; };
97 allow-transfer { 10.222.64.1; canonical-int-ns; };
98
99 notify explicit;
100 also-notify { 91.189.90.151; 91.189.89.192; };
101
102 allow-query-cache { 10.222.64.0/18; };
103 recursion yes;
104 };
105
106 zone "." { type master; file "/etc/bind/db.special"; };
107 """)
108 config = parse_isc_string(testdata)
109 self.assertEqual(
110 {u'acl canonical-int-ns':
111 {u'91.189.89.192': True, u'91.189.90.151': True},
112 u'options': {u'allow-query': {u'any': True},
113 u'allow-query-cache': {u'10.222.64.0/18': True},
114 u'allow-transfer': {u'10.222.64.1': True,
115 u'canonical-int-ns': True},
116 u'also-notify': {u'91.189.89.192': True,
117 u'91.189.90.151': True},
118 u'auth-nxdomain': u'no',
119 u'directory': u'"/var/cache/bind"',
120 u'dnssec-validation': u'auto',
121 u'forwarders': {u'91.189.94.2': True},
122 u'listen-on-v6': {u'any': True},
123 u'notify': u'explicit',
124 u'recursion': u'yes'},
125 u'zone "."':
126 {u'file': u'"/etc/bind/db.special"', u'type': u'master'}},
127 config)
128
129 def test_parse_then_make_then_parse_generates_identical_config(self):
130 testdata = dedent("""\
131 acl canonical-int-ns { 91.189.90.151; 91.189.89.192; };
132
133 options {
134 directory "/var/cache/bind";
135
136 forwarders {
137 91.189.94.2;
138 91.189.94.2;
139 };
140
141 dnssec-validation auto;
142
143 auth-nxdomain no; # conform to RFC1035
144 listen-on-v6 { any; };
145
146 allow-query { any; };
147 allow-transfer { 10.222.64.1; canonical-int-ns; };
148
149 notify explicit;
150 also-notify { 91.189.90.151; 91.189.89.192; };
151
152 allow-query-cache { 10.222.64.0/18; };
153 recursion yes;
154 };
155
156 zone "." { type master; file "/etc/bind/db.special"; };
157 """)
158 config = parse_isc_string(testdata)
159 config_string = make_isc_string(config)
160 config = parse_isc_string(config_string)
161 self.assertEqual(
162 {u'acl canonical-int-ns':
163 {u'91.189.89.192': True, u'91.189.90.151': True},
164 u'options': {u'allow-query': {u'any': True},
165 u'allow-query-cache': {u'10.222.64.0/18': True},
166 u'allow-transfer': {u'10.222.64.1': True,
167 u'canonical-int-ns': True},
168 u'also-notify': {u'91.189.89.192': True,
169 u'91.189.90.151': True},
170 u'auth-nxdomain': u'no',
171 u'directory': u'"/var/cache/bind"',
172 u'dnssec-validation': u'auto',
173 u'forwarders': {u'91.189.94.2': True},
174 u'listen-on-v6': {u'any': True},
175 u'notify': u'explicit',
176 u'recursion': u'yes'},
177 u'zone "."':
178 {u'file': u'"/etc/bind/db.special"', u'type': u'master'}},
179 config)
0180
=== modified file 'versions.cfg'
--- versions.cfg 2015-05-06 09:51:19 +0000
+++ versions.cfg 2015-07-04 09:40:25 +0000
@@ -35,7 +35,6 @@
35extras = 0.0.335extras = 0.0.3
36fixtures = 0.3.1436fixtures = 0.3.14
37httplib2 = 0.837httplib2 = 0.8
38iscpy = 1.05
39iso8601 = 0.1.438iso8601 = 0.1.4
40junitxml = 0.639junitxml = 0.6
41nose = 1.3.140nose = 1.3.1

Subscribers

People subscribed via source and target branches

to all changes: