Merge lp:~ericsnowcurrently/fake-juju/python-lib-classes into lp:~landscape/fake-juju/trunk-old
- python-lib-classes
- Merge into trunk-old
Proposed by
Eric Snow
Status: | Merged |
---|---|
Approved by: | Eric Snow |
Approved revision: | 53 |
Merged at revision: | 39 |
Proposed branch: | lp:~ericsnowcurrently/fake-juju/python-lib-classes |
Merge into: | lp:~landscape/fake-juju/trunk-old |
Prerequisite: | lp:~ericsnowcurrently/fake-juju/python-lib-helpers |
Diff against target: |
479 lines (+427/-1) 5 files modified
python/fakejuju/__init__.py (+8/-0) python/fakejuju/failures.py (+65/-0) python/fakejuju/fakejuju.py (+100/-0) python/fakejuju/tests/test_failures.py (+101/-0) python/fakejuju/tests/test_fakejuju.py (+153/-1) |
To merge this branch: | bzr merge lp:~ericsnowcurrently/fake-juju/python-lib-classes |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
🤖 Landscape Builder | test results | Approve | |
Free Ekanayaka (community) | Approve | ||
Review via email: mp+307895@code.launchpad.net |
This proposal supersedes a proposal from 2016-10-06.
Commit message
Add the FakeJuju and Failures classes.
Description of the change
Add the FakeJuju and Failures classes.
Testing instructions:
Run the unit tests.
To post a comment you must log in.
Revision history for this message
🤖 Landscape Builder (landscape-builder) : | # |
review:
Abstain
(executing tests)
Revision history for this message
🤖 Landscape Builder (landscape-builder) wrote : | # |
review:
Approve
(test results)
Revision history for this message
Free Ekanayaka (free.ekanayaka) wrote : | # |
Looks mostly good to me, marking as N/I just to see what you think of the comments, but I'll be also happy to approve this MP as it is, in case.
review:
Needs Information
Revision history for this message
Eric Snow (ericsnowcurrently) : | # |
Revision history for this message
Free Ekanayaka (free.ekanayaka) wrote : | # |
+1 with a comment about YAGNI-ing the logsdir parameter.
review:
Approve
Revision history for this message
🤖 Landscape Builder (landscape-builder) : | # |
review:
Abstain
(executing tests)
Revision history for this message
🤖 Landscape Builder (landscape-builder) wrote : | # |
Command: make ci-test
Result: Success
Revno: 49
Branch: lp:~ericsnowcurrently/fake-juju/python-lib-classes
Jenkins: https:/
review:
Approve
(test results)
Revision history for this message
🤖 Landscape Builder (landscape-builder) : | # |
review:
Abstain
(executing tests)
Revision history for this message
🤖 Landscape Builder (landscape-builder) wrote : | # |
Command: make ci-test
Result: Success
Revno: 53
Branch: lp:~ericsnowcurrently/fake-juju/python-lib-classes
Jenkins: https:/
review:
Approve
(test results)
Revision history for this message
Eric Snow (ericsnowcurrently) : | # |
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'python/fakejuju/__init__.py' |
2 | --- python/fakejuju/__init__.py 2016-10-13 18:20:43 +0000 |
3 | +++ python/fakejuju/__init__.py 2016-10-17 15:55:46 +0000 |
4 | @@ -45,6 +45,14 @@ |
5 | |
6 | """ |
7 | |
8 | +from .fakejuju import get_filename, set_envvars, FakeJuju |
9 | + |
10 | + |
11 | +__all__ = [ |
12 | + "__version__", |
13 | + "get_bootstrap_spec", "get_filename", "set_envvars", |
14 | + "FakeJuju", |
15 | + ] |
16 | |
17 | __version__ = "0.9.0b1" |
18 | |
19 | |
20 | === added file 'python/fakejuju/failures.py' |
21 | --- python/fakejuju/failures.py 1970-01-01 00:00:00 +0000 |
22 | +++ python/fakejuju/failures.py 2016-10-17 15:55:46 +0000 |
23 | @@ -0,0 +1,65 @@ |
24 | +# Copyright 2016 Canonical Limited. All rights reserved. |
25 | + |
26 | +import errno |
27 | +import os |
28 | +import os.path |
29 | + |
30 | + |
31 | +class Failures(object): |
32 | + """The collection of injected failures to use with a fake-juju. |
33 | + |
34 | + The failures are tracked here as well as injected into any |
35 | + fake-juju using the initial config dir (aka "juju home"). |
36 | + |
37 | + Note that fake-juju provides only limited capability for |
38 | + failure injection. |
39 | + """ |
40 | + |
41 | + def __init__(self, cfgdir, entities=None): |
42 | + """ |
43 | + @param cfgdir: The "juju home" directory into which the |
44 | + failures will be registered for injection. |
45 | + @param entities: The entity names to start with, if any. |
46 | + """ |
47 | + filename = os.path.join(cfgdir, "juju-failures") |
48 | + entities = set(entities or ()) |
49 | + |
50 | + self._filename = filename |
51 | + self._entities = entities |
52 | + |
53 | + @property |
54 | + def filename(self): |
55 | + """The path to the failures file the fake-juju reads.""" |
56 | + return self._filename |
57 | + |
58 | + @property |
59 | + def entities(self): |
60 | + """The IDs of the failing entities.""" |
61 | + return set(self._entities) |
62 | + |
63 | + def _flush(self): |
64 | + """Write the failures to disk.""" |
65 | + data = "\n".join(self._entities) + "\n" |
66 | + try: |
67 | + file = open(self._filename, "w") |
68 | + except IOError: |
69 | + dirname = os.path.dirname(self._filename) |
70 | + if not os.path.exists(dirname): |
71 | + os.makedirs(dirname) |
72 | + file = open(self._filename, "w") |
73 | + with file: |
74 | + file.write(data) |
75 | + |
76 | + def fail_entity(self, tag): |
77 | + """Inject a global failure for the identified Juju entity.""" |
78 | + self._entities.add(tag) |
79 | + self._flush() |
80 | + |
81 | + def clear(self): |
82 | + """Remove all injected failures.""" |
83 | + try: |
84 | + os.remove(self._filename) |
85 | + except OSError as e: |
86 | + if e.errno != errno.ENOENT: |
87 | + raise |
88 | + self._entities.clear() |
89 | |
90 | === modified file 'python/fakejuju/fakejuju.py' |
91 | --- python/fakejuju/fakejuju.py 2016-10-13 18:20:43 +0000 |
92 | +++ python/fakejuju/fakejuju.py 2016-10-17 15:55:46 +0000 |
93 | @@ -2,6 +2,10 @@ |
94 | |
95 | import os.path |
96 | |
97 | +import txjuju.cli |
98 | + |
99 | +from .failures import Failures |
100 | + |
101 | |
102 | def get_filename(version, bindir=None): |
103 | """Return the full path to the fake-juju binary for the given version. |
104 | @@ -30,3 +34,99 @@ |
105 | """ |
106 | envvars["FAKE_JUJU_FAILURES"] = failures_filename or "" |
107 | envvars["FAKE_JUJU_LOGS_DIR"] = logsdir or "" |
108 | + |
109 | + |
110 | +class FakeJuju(object): |
111 | + """The fundamental details for fake-juju.""" |
112 | + |
113 | + @classmethod |
114 | + def from_version(cls, version, cfgdir, |
115 | + logsdir=None, failuresdir=None, bindir=None): |
116 | + """Return a new instance given the provided information. |
117 | + |
118 | + @param version: The Juju version to fake. |
119 | + @param cfgdir: The "juju home" directory to use. |
120 | + @param logsdir: The directory where logs will be written. |
121 | + This defaults to cfgdir. |
122 | + @params failuresdir: The directory where failure injection |
123 | + is managed. |
124 | + @param bindir: The directory containing the fake-juju binary. |
125 | + This defaults to /usr/bin. |
126 | + """ |
127 | + if logsdir is None: |
128 | + logsdir = cfgdir |
129 | + if failuresdir is None: |
130 | + failuresdir = cfgdir |
131 | + filename = get_filename(version, bindir=bindir) |
132 | + failures = Failures(failuresdir) |
133 | + return cls(filename, version, cfgdir, logsdir, failures) |
134 | + |
135 | + def __init__(self, filename, version, cfgdir, logsdir=None, failures=None): |
136 | + """ |
137 | + @param filename: The path to the fake-juju binary. |
138 | + @param version: The Juju version to fake. |
139 | + @param cfgdir: The "juju home" directory to use. |
140 | + @param logsdir: The directory where logs will be written. |
141 | + This defaults to cfgdir. |
142 | + @param failures: The set of fake-juju failures to use. |
143 | + """ |
144 | + logsdir = logsdir if logsdir is not None else cfgdir |
145 | + if failures is None and cfgdir: |
146 | + failures = Failures(cfgdir) |
147 | + |
148 | + if not filename: |
149 | + raise ValueError("missing filename") |
150 | + if not version: |
151 | + raise ValueError("missing version") |
152 | + if not cfgdir: |
153 | + raise ValueError("missing cfgdir") |
154 | + if not logsdir: |
155 | + raise ValueError("missing logsdir") |
156 | + if failures is None: |
157 | + raise ValueError("missing failures") |
158 | + |
159 | + self.filename = filename |
160 | + self.version = version |
161 | + self.cfgdir = cfgdir |
162 | + self.logsdir = logsdir |
163 | + self.failures = failures |
164 | + |
165 | + @property |
166 | + def logfile(self): |
167 | + """The path to fake-juju's log file.""" |
168 | + return os.path.join(self.logsdir, "fake-juju.log") |
169 | + |
170 | + @property |
171 | + def infofile(self): |
172 | + """The path to fake-juju's data cache.""" |
173 | + return os.path.join(self.cfgdir, "fakejuju") |
174 | + |
175 | + @property |
176 | + def fifo(self): |
177 | + """The path to the fifo file that triggers shutdown.""" |
178 | + return os.path.join(self.cfgdir, "fifo") |
179 | + |
180 | + @property |
181 | + def cacertfile(self): |
182 | + """The path to the API server's certificate.""" |
183 | + return os.path.join(self.cfgdir, "cert.ca") |
184 | + |
185 | + def cli(self, envvars=None): |
186 | + """Return the txjuju.cli.CLI for this fake-juju. |
187 | + |
188 | + Currently fake-juju supports only the following juju subcommands: |
189 | + |
190 | + * bootstrap |
191 | + Not that this only supports the dummy provider and the local |
192 | + system is only minimally impacted. |
193 | + * api-info |
194 | + Note that passwords are always omited, even if requested. |
195 | + * api-endpoints |
196 | + * destroy-environment |
197 | + """ |
198 | + if envvars is None: |
199 | + envvars = os.environ |
200 | + envvars = dict(envvars) |
201 | + set_envvars(envvars, self.failures._filename, self.logsdir) |
202 | + return txjuju.cli.CLI.from_version( |
203 | + self.filename, self.version, self.cfgdir, envvars) |
204 | |
205 | === added file 'python/fakejuju/tests/test_failures.py' |
206 | --- python/fakejuju/tests/test_failures.py 1970-01-01 00:00:00 +0000 |
207 | +++ python/fakejuju/tests/test_failures.py 2016-10-17 15:55:46 +0000 |
208 | @@ -0,0 +1,101 @@ |
209 | +# Copyright 2016 Canonical Limited. All rights reserved. |
210 | + |
211 | +import os |
212 | +import os.path |
213 | +import shutil |
214 | +import tempfile |
215 | +import unittest |
216 | + |
217 | +from fakejuju.failures import Failures |
218 | + |
219 | + |
220 | +class FailuresTests(unittest.TestCase): |
221 | + |
222 | + def setUp(self): |
223 | + super(FailuresTests, self).setUp() |
224 | + self.dirname = tempfile.mkdtemp(prefix="fakejuju-test-") |
225 | + |
226 | + def tearDown(self): |
227 | + shutil.rmtree(self.dirname) |
228 | + super(FailuresTests, self).tearDown() |
229 | + |
230 | + def test_full(self): |
231 | + """Failures() works correctly when given all args.""" |
232 | + entities = [u"x", u"y", u"z"] |
233 | + failures = Failures(u"/some/dir", entities) |
234 | + |
235 | + self.assertEqual(failures.filename, u"/some/dir/juju-failures") |
236 | + self.assertEqual(failures.entities, set(entities)) |
237 | + |
238 | + def test_minimal(self): |
239 | + """Failures() works correctly when given minimal args.""" |
240 | + failures = Failures(u"/some/dir") |
241 | + |
242 | + self.assertEqual(failures.filename, u"/some/dir/juju-failures") |
243 | + self.assertEqual(failures.entities, set()) |
244 | + |
245 | + def test_conversion(self): |
246 | + """Failures() doesn't convert any values.""" |
247 | + failures_str = Failures("/some/dir", ["x", "y", "z"]) |
248 | + failures_unicode = Failures(u"/some/dir", [u"x", u"y", u"z"]) |
249 | + |
250 | + self.assertIsInstance(failures_str.filename, str) |
251 | + self.assertIsInstance(failures_unicode.filename, unicode) |
252 | + for id in failures_str.entities: |
253 | + self.assertIsInstance(id, str) |
254 | + for id in failures_unicode.entities: |
255 | + self.assertIsInstance(id, unicode) |
256 | + |
257 | + def test_file_not_created_initially(self): |
258 | + """Failures() doesn't create a missing cfgdir until necessary.""" |
259 | + failures = Failures(self.dirname) |
260 | + |
261 | + self.assertFalse(os.path.exists(failures.filename)) |
262 | + |
263 | + def test_cfgdir_created(self): |
264 | + """Failures() creates a missing cfgdir as soon as it's needed.""" |
265 | + dirname = os.path.join(self.dirname, "subdir") |
266 | + self.assertFalse(os.path.exists(dirname)) |
267 | + failures = Failures(dirname) |
268 | + failures.fail_entity("unit-xyz") |
269 | + |
270 | + self.assertTrue(os.path.exists(dirname)) |
271 | + |
272 | + def test_fail_entity_one(self): |
273 | + """Failures,fail_entity() writes an initial entry to disk.""" |
274 | + failures = Failures(self.dirname) |
275 | + failures.fail_entity("unit-abc") |
276 | + with open(failures.filename) as file: |
277 | + data = file.read() |
278 | + |
279 | + self.assertEqual(data, "unit-abc\n") |
280 | + |
281 | + def test_fail_entity_multiple(self): |
282 | + """Failures.fail_entity() correctly writes multiple entries to disk.""" |
283 | + failures = Failures(self.dirname) |
284 | + failures.fail_entity("unit-abc") |
285 | + failures.fail_entity("unit-xyz") |
286 | + |
287 | + with open(failures.filename) as file: |
288 | + data = file.read() |
289 | + entities = set(tag for tag in data.splitlines() if tag) |
290 | + self.assertEqual(entities, failures.entities) |
291 | + self.assertTrue(data.endswith("\n")) |
292 | + |
293 | + def test_clear_exists(self): |
294 | + """Failures.clear() deletes the failures file if it exists.""" |
295 | + failures = Failures(self.dirname) |
296 | + failures.fail_entity("unit-abc") |
297 | + self.assertTrue(os.path.exists(failures.filename)) |
298 | + failures.clear() |
299 | + |
300 | + self.assertFalse(os.path.exists(failures.filename)) |
301 | + self.assertEqual(failures.entities, set()) |
302 | + |
303 | + def test_clear_not_exists(self): |
304 | + """Failures.clear() does nothing if the failures file is missing.""" |
305 | + failures = Failures(self.dirname) |
306 | + self.assertFalse(os.path.exists(failures.filename)) |
307 | + failures.clear() |
308 | + |
309 | + self.assertFalse(os.path.exists(failures.filename)) |
310 | |
311 | === modified file 'python/fakejuju/tests/test_fakejuju.py' |
312 | --- python/fakejuju/tests/test_fakejuju.py 2016-10-13 18:25:28 +0000 |
313 | +++ python/fakejuju/tests/test_fakejuju.py 2016-10-17 15:55:46 +0000 |
314 | @@ -1,8 +1,13 @@ |
315 | # Copyright 2016 Canonical Limited. All rights reserved. |
316 | |
317 | +import os |
318 | import unittest |
319 | |
320 | -from fakejuju.fakejuju import get_filename, set_envvars |
321 | +from txjuju import _juju1, _juju2 |
322 | +from txjuju._utils import Executable |
323 | + |
324 | +from fakejuju.failures import Failures |
325 | +from fakejuju.fakejuju import get_filename, set_envvars, FakeJuju |
326 | |
327 | |
328 | class GetFilenameTests(unittest.TestCase): |
329 | @@ -115,3 +120,150 @@ |
330 | "FAKE_JUJU_FAILURES": "", |
331 | "FAKE_JUJU_LOGS_DIR": "", |
332 | }) |
333 | + |
334 | + |
335 | +class FakeJujuTests(unittest.TestCase): |
336 | + |
337 | + def test_from_version_full(self): |
338 | + """FakeJuju.from_version() works correctly when given all args.""" |
339 | + juju = FakeJuju.from_version( |
340 | + "1.25.6", "/a/juju/home", "/logs/dir", "/failures/dir", "/bin/dir") |
341 | + |
342 | + self.assertEqual(juju.filename, "/bin/dir/fake-juju-1.25.6") |
343 | + self.assertEqual(juju.version, "1.25.6") |
344 | + self.assertEqual(juju.cfgdir, "/a/juju/home") |
345 | + self.assertEqual(juju.logsdir, "/logs/dir") |
346 | + self.assertEqual(juju.failures.filename, "/failures/dir/juju-failures") |
347 | + |
348 | + def test_from_version_minimal(self): |
349 | + """FakeJuju.from_version() works correctly when given minimal args.""" |
350 | + juju = FakeJuju.from_version("1.25.6", "/my/juju/home") |
351 | + |
352 | + self.assertEqual(juju.filename, "/usr/bin/fake-juju-1.25.6") |
353 | + self.assertEqual(juju.version, "1.25.6") |
354 | + self.assertEqual(juju.cfgdir, "/my/juju/home") |
355 | + self.assertEqual(juju.logsdir, "/my/juju/home") |
356 | + self.assertEqual(juju.failures.filename, "/my/juju/home/juju-failures") |
357 | + |
358 | + def test_full(self): |
359 | + """FakeJuju() works correctly when given all args.""" |
360 | + cfgdir = "/my/juju/home" |
361 | + failures = Failures(cfgdir) |
362 | + juju = FakeJuju("/fake-juju", "1.25.6", cfgdir, "/some/logs", failures) |
363 | + |
364 | + self.assertEqual(juju.filename, "/fake-juju") |
365 | + self.assertEqual(juju.version, "1.25.6") |
366 | + self.assertEqual(juju.cfgdir, cfgdir) |
367 | + self.assertEqual(juju.logsdir, "/some/logs") |
368 | + self.assertIs(juju.failures, failures) |
369 | + |
370 | + def test_minimal(self): |
371 | + """FakeJuju() works correctly when given minimal args.""" |
372 | + juju = FakeJuju("/fake-juju", "1.25.6", "/my/juju/home") |
373 | + |
374 | + self.assertEqual(juju.filename, "/fake-juju") |
375 | + self.assertEqual(juju.version, "1.25.6") |
376 | + self.assertEqual(juju.cfgdir, "/my/juju/home") |
377 | + self.assertEqual(juju.logsdir, "/my/juju/home") |
378 | + self.assertEqual(juju.failures.filename, "/my/juju/home/juju-failures") |
379 | + |
380 | + def test_conversions(self): |
381 | + """FakeJuju() doesn't convert the type of any value.""" |
382 | + juju_str = FakeJuju( |
383 | + "/fake-juju", "1.25.6", "/x", "/y", Failures("/...")) |
384 | + juju_unicode = FakeJuju( |
385 | + u"/fake-juju", u"1.25.6", u"/x", u"/y", Failures(u"/...")) |
386 | + |
387 | + for name in ('filename version cfgdir logsdir'.split()): |
388 | + self.assertIsInstance(getattr(juju_str, name), str) |
389 | + self.assertIsInstance(getattr(juju_unicode, name), unicode) |
390 | + |
391 | + def test_missing_filename(self): |
392 | + """FakeJuju() fails if filename is None or empty.""" |
393 | + with self.assertRaises(ValueError): |
394 | + FakeJuju(None, "1.25.6", "/my/juju/home") |
395 | + with self.assertRaises(ValueError): |
396 | + FakeJuju("", "1.25.6", "/my/juju/home") |
397 | + |
398 | + def test_missing_version(self): |
399 | + """FakeJuju() fails if version is None or empty.""" |
400 | + with self.assertRaises(ValueError): |
401 | + FakeJuju("/fake-juju", None, "/my/juju/home") |
402 | + with self.assertRaises(ValueError): |
403 | + FakeJuju("/fake-juju", "", "/my/juju/home") |
404 | + |
405 | + def test_missing_cfgdir(self): |
406 | + """FakeJuju() fails if cfgdir is None or empty.""" |
407 | + with self.assertRaises(ValueError): |
408 | + FakeJuju("/fake-juju", "1.25.6", None) |
409 | + with self.assertRaises(ValueError): |
410 | + FakeJuju("/fake-juju", "1.25.6", "") |
411 | + |
412 | + def test_logfile(self): |
413 | + """FakeJuju.logfile returns the path to the fake-juju log file.""" |
414 | + juju = FakeJuju("/fake-juju", "1.25.6", "/x", "/some/logs") |
415 | + |
416 | + self.assertEqual(juju.logfile, "/some/logs/fake-juju.log") |
417 | + |
418 | + def test_infofile(self): |
419 | + """FakeJuju.logfile returns the path to the fake-juju info file.""" |
420 | + juju = FakeJuju("/fake-juju", "1.25.6", "/x") |
421 | + |
422 | + self.assertEqual(juju.infofile, "/x/fakejuju") |
423 | + |
424 | + def test_fifo(self): |
425 | + """FakeJuju.logfile returns the path to the fake-juju fifo.""" |
426 | + juju = FakeJuju("/fake-juju", "1.25.6", "/x") |
427 | + |
428 | + self.assertEqual(juju.fifo, "/x/fifo") |
429 | + |
430 | + def test_cacertfile(self): |
431 | + """FakeJuju.cacertfile returns the path to the Juju API cert.""" |
432 | + juju = FakeJuju("/fake-juju", "1.25.6", "/x") |
433 | + |
434 | + self.assertEqual(juju.cacertfile, "/x/cert.ca") |
435 | + |
436 | + def test_cli_full(self): |
437 | + """FakeJuju.cli() works correctly when given all args.""" |
438 | + juju = FakeJuju("/fake-juju", "1.25.6", "/x") |
439 | + cli = juju.cli({"SPAM": "eggs"}) |
440 | + |
441 | + self.assertEqual( |
442 | + cli._exe, |
443 | + Executable("/fake-juju", { |
444 | + "SPAM": "eggs", |
445 | + "FAKE_JUJU_FAILURES": "/x/juju-failures", |
446 | + "FAKE_JUJU_LOGS_DIR": "/x", |
447 | + "JUJU_HOME": "/x", |
448 | + }), |
449 | + ) |
450 | + |
451 | + def test_cli_minimal(self): |
452 | + """FakeJuju.cli() works correctly when given minimal args.""" |
453 | + juju = FakeJuju("/fake-juju", "1.25.6", "/x") |
454 | + cli = juju.cli() |
455 | + |
456 | + self.assertEqual( |
457 | + cli._exe, |
458 | + Executable("/fake-juju", dict(os.environ, **{ |
459 | + "FAKE_JUJU_FAILURES": "/x/juju-failures", |
460 | + "FAKE_JUJU_LOGS_DIR": "/x", |
461 | + "JUJU_HOME": "/x", |
462 | + })), |
463 | + ) |
464 | + |
465 | + def test_cli_juju1(self): |
466 | + """FakeJuju.cli() works correctly for Juju 1.x.""" |
467 | + juju = FakeJuju.from_version("1.25.6", "/x") |
468 | + cli = juju.cli() |
469 | + |
470 | + self.assertEqual(cli._exe.envvars["JUJU_HOME"], "/x") |
471 | + self.assertIsInstance(cli._juju, _juju1.CLIHooks) |
472 | + |
473 | + def test_cli_juju2(self): |
474 | + """FakeJuju.cli() works correctly for Juju 2.x.""" |
475 | + juju = FakeJuju.from_version("2.0.0", "/x") |
476 | + cli = juju.cli() |
477 | + |
478 | + self.assertEqual(cli._exe.envvars["JUJU_DATA"], "/x") |
479 | + self.assertIsInstance(cli._juju, _juju2.CLIHooks) |
Command: make ci-test /ci.lscape. net/job/ latch-test- xenial/ 17/
Result: Success
Revno: 48
Branch: lp:~ericsnowcurrently/fake-juju/python-lib-classes
Jenkins: https:/