Merge lp:~gocept/landscape-client/py3-twisted-util into lp:~landscape/landscape-client/trunk
- py3-twisted-util
- Merge into trunk
Status: | Superseded |
---|---|
Proposed branch: | lp:~gocept/landscape-client/py3-twisted-util |
Merge into: | lp:~landscape/landscape-client/trunk |
Prerequisite: | lp:~gocept/landscape-client/python3 |
Diff against target: |
422 lines (+101/-62) 8 files modified
landscape/broker/tests/test_store.py (+2/-2) landscape/lib/fs.py (+37/-19) landscape/lib/juju.py (+1/-1) landscape/lib/tests/test_fs.py (+27/-5) landscape/lib/tests/test_process.py (+5/-3) landscape/lib/tests/test_twisted_util.py (+16/-20) landscape/lib/twisted_util.py (+9/-9) landscape/lib/vm_info.py (+4/-3) |
To merge this branch: | bzr merge lp:~gocept/landscape-client/py3-twisted-util |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
🤖 Landscape Builder | test results | Approve | |
Steffen Allner (community) | Needs Fixing | ||
Daniel Havlik (community) | Approve | ||
Данило Шеган (community) | Approve | ||
Adam Collard (community) | Abstain | ||
Landscape | Pending | ||
Review via email: mp+319100@code.launchpad.net |
This proposal has been superseded by a proposal from 2017-03-13.
Commit message
Description of the change
We are on the journey to get the Bytes / String story solved for Python 2/3 compatibility.
This MP considers the part of code which receives data from the stdout and stderr and therefore handles with Bytes and not with Stings in Python 3. To reduce the amount of compat code, the code has been changed to use the same `io.BytesIO()` in Python 2 and 3.
🤖 Landscape Builder (landscape-builder) : | # |
🤖 Landscape Builder (landscape-builder) wrote : | # |
Adam Collard (adam-collard) : | # |
David Britton (dpb) wrote : | # |
Hi Daniel -- Please add 'landscape' on these in the future. Otherwise only Adam will be able to review. :)
I've taken care of this one.
Thanks!
Данило Шеган (danilo) wrote : | # |
I've just noticed that the dependency branch is still missing the second Landscape dev reviewer: I've added myself there and will be reviewing that first. We also require Adam to approve the changes there too.
I'll get back to this after I am done with that branch.
Данило Шеган (danilo) wrote : | # |
FWIW, you should fix the test failure as well: http://
(test builder is still running on Ubuntu 12.04 [precise], so maybe that's to blame, but I haven't looked deeper: if it's easy, it's nice to get a fix for that too, but if not, we can probably move to supporting only 14.04 and later since 12.04 is EOLin this April)
Steffen Allner (sallner) wrote : | # |
I am currently developing on an Ubuntu 16.04.1 and cannot reproduce this test failure. But I agree, we should at least test it on 14.04.
Steffen Allner (sallner) wrote : | # |
I am actually surprised that there should be only one test failure. I tried to test it on an Ubuntu 12.04. and 14.04. and failed both times. For 12.04 I already failed to install bzr as python-bzrlib depends on python-configobj, which in turn is a virtual-package now and did not install anything.
On 14.04. executing the tests led to some ImportErrors out of `twisted.
🤖 Landscape Builder (landscape-builder) : | # |
Данило Шеган (danilo) wrote : | # |
Yeah, we are definitely missing some development setup bits for landscape-client: I've done an "apt-get build-dep landscape-client", and as you noted, bzr was still missing, but also python-mock and xvfb. xvfb is probably not really needed anymore with our GUI component retired, but I'll need to double check that. However, the problem with python-mock on 12.04 is that it seems to be very old (0.7.2) and the tests already use features from a newer version (1.0.1, eg. by adding "deb http://
I'll now look if 14.04 as-is has all the deps needed at sufficiently new versions and update our infrastructure to run the tests on that instead.
🤖 Landscape Builder (landscape-builder) wrote : | # |
Command: TRIAL_ARGS=-j4 make check
Result: Success
Revno: 959
Branch: lp:~gocept/landscape-client/py3-twisted-util
Jenkins: https:/
Steffen Allner (sallner) wrote : | # |
At least that one worked. Thanks, Danilo! Apart from that, I recall that we agreed on compatibility with 16.04. for the new version of landscape client. So if those problems persist or occur more frequently, we have to find a different mode.
Данило Шеган (danilo) wrote : | # |
Righto: please do focus on what the agreement specifies, we can deal with compatibility in that case (sorry if I overstepped).
🤖 Landscape Builder (landscape-builder) : | # |
🤖 Landscape Builder (landscape-builder) wrote : | # |
Command: TRIAL_ARGS=-j4 make check
Result: Success
Revno: 959
Branch: lp:~gocept/landscape-client/py3-twisted-util
Jenkins: https:/
🤖 Landscape Builder (landscape-builder) : | # |
🤖 Landscape Builder (landscape-builder) wrote : | # |
Command: TRIAL_ARGS=-j4 make check
Result: Success
Revno: 959
Branch: lp:~gocept/landscape-client/py3-twisted-util
Jenkins: https:/
Данило Шеган (danilo) wrote : | # |
Looks good, +1 (we do need to wait for the prereq to land, of course)
Steffen Allner (sallner) wrote : | # |
If we merge the py3-fs branch first, we will get some failing tests. So I will merge py3-fs into this branch and update the proposal to have it as prerequisite.
- 960. By Steffen Allner
-
Backmerge from trunk.
- 961. By Steffen Allner
-
Merge landscape.lib.fs with optional encoding.
- 962. By Steffen Allner
-
Use bytes here as we do not have non-ascii characters in the scripts.
🤖 Landscape Builder (landscape-builder) : | # |
🤖 Landscape Builder (landscape-builder) wrote : | # |
Command: TRIAL_ARGS=-j4 make check
Result: Success
Revno: 962
Branch: lp:~gocept/landscape-client/py3-twisted-util
Jenkins: https:/
- 963. By Steffen Allner
-
Merge in current l.l.fs and use create_text_file().
Unmerged revisions
Preview Diff
1 | === modified file 'landscape/broker/tests/test_store.py' |
2 | --- landscape/broker/tests/test_store.py 2016-08-17 20:31:48 +0000 |
3 | +++ landscape/broker/tests/test_store.py 2017-03-13 10:45:51 +0000 |
4 | @@ -280,7 +280,7 @@ |
5 | self.store.add({"type": "data", "data": 1}) |
6 | # We simulate it by creating a fake file which raises halfway through |
7 | # writing a file. |
8 | - with mock.patch("__builtin__.open") as mock_open: |
9 | + with mock.patch("landscape.lib.fs.open") as mock_open: |
10 | mocked_file = mock_open.return_value |
11 | mocked_file.write.side_effect = IOError("Sorry, pal!") |
12 | # This kind of ensures that raising an exception is somewhat |
13 | @@ -288,7 +288,7 @@ |
14 | # on special exception-handling in the file-writing code. |
15 | self.assertRaises( |
16 | IOError, self.store.add, {"type": "data", "data": 2}) |
17 | - mock_open.assert_called_with(mock.ANY, "w") |
18 | + mock_open.assert_called_with(mock.ANY, "wb") |
19 | mocked_file.write.assert_called_once_with(mock.ANY) |
20 | self.assertEqual(self.store.get_pending_messages(), |
21 | [{"type": "data", "data": 1, "api": "3.2"}]) |
22 | |
23 | === modified file 'landscape/lib/fs.py' |
24 | --- landscape/lib/fs.py 2013-05-28 08:48:31 +0000 |
25 | +++ landscape/lib/fs.py 2017-03-13 10:45:51 +0000 |
26 | @@ -1,18 +1,31 @@ |
27 | """File-system utils""" |
28 | |
29 | +import codecs |
30 | import os |
31 | import time |
32 | |
33 | |
34 | -def create_file(path, content): |
35 | +from twisted.python.compat import long |
36 | + |
37 | + |
38 | +def create_file(path, content, encoding=None): |
39 | """Create a file with the given content. |
40 | |
41 | @param path: The path to the file. |
42 | @param content: The content to be written in the file. |
43 | + @param encoding: An optional encoding. If set, the content will be encoded |
44 | + with C{encoding} before writing to the file. |
45 | """ |
46 | - fd = open(path, "w") |
47 | - fd.write(content) |
48 | - fd.close() |
49 | + if encoding: |
50 | + content = codecs.encode(content, encoding) |
51 | + # XXX: Due to a very specific mock of `open()` in landscape.broker.tests.\ |
52 | + # test_store.MessageStoreTest.test_atomic_message_writing it is hard to |
53 | + # write this file opening as context manager. |
54 | + fd = open(path, "wb") |
55 | + try: |
56 | + fd.write(content) |
57 | + finally: |
58 | + fd.close() |
59 | |
60 | |
61 | def append_file(path, content): |
62 | @@ -23,28 +36,33 @@ |
63 | @param path: The path to the file. |
64 | @param content: The content to be written in the file at the end. |
65 | """ |
66 | - fd = open(path, "a") |
67 | - fd.write(content) |
68 | - fd.close() |
69 | - |
70 | - |
71 | -def read_file(path, limit=None): |
72 | + with open(path, "a") as fd: |
73 | + fd.write(content) |
74 | + |
75 | + |
76 | +def read_file(path, limit=None, encoding=None): |
77 | """Return the content of the given file. |
78 | |
79 | @param path: The path to the file. |
80 | @param limit: An optional read limit. If positive, read up to that number |
81 | of bytes from the beginning of the file. If negative, read up to that |
82 | number of bytes from the end of the file. |
83 | - @return content: The content of the file, possibly trimmed to C{limit}. |
84 | + @param encoding: An optional encoding. If set, the content will be returned |
85 | + decoded with C{encoding}. |
86 | + @return content: The content of the file as bytes or unicode string |
87 | + (depending on C{encoding}), possibly trimmed to C{limit}. |
88 | """ |
89 | - fd = open(path, "r") |
90 | - if limit and os.path.getsize(path) > abs(limit): |
91 | - whence = 0 |
92 | - if limit < 0: |
93 | - whence = 2 |
94 | - fd.seek(limit, whence) |
95 | - content = fd.read() |
96 | - fd.close() |
97 | + # Use binary mode since opening a file in text mode in Python 3 does not |
98 | + # allow non-zero offset seek from the end of the file. |
99 | + with open(path, "rb") as fd: |
100 | + if limit and os.path.getsize(path) > abs(limit): |
101 | + whence = 0 |
102 | + if limit < 0: |
103 | + whence = 2 |
104 | + fd.seek(limit, whence) |
105 | + content = fd.read() |
106 | + if encoding: |
107 | + content = codecs.decode(content, encoding) |
108 | return content |
109 | |
110 | |
111 | |
112 | === modified file 'landscape/lib/juju.py' |
113 | --- landscape/lib/juju.py 2014-09-24 08:09:55 +0000 |
114 | +++ landscape/lib/juju.py 2017-03-13 10:45:51 +0000 |
115 | @@ -13,7 +13,7 @@ |
116 | if not os.path.exists(config.juju_filename): |
117 | return |
118 | |
119 | - json_contents = read_file(config.juju_filename) |
120 | + json_contents = read_file(config.juju_filename, encoding="utf-8") |
121 | try: |
122 | juju_info = json.loads(json_contents) |
123 | # Catch any error the json lib could throw, because we don't know or |
124 | |
125 | === modified file 'landscape/lib/tests/test_fs.py' |
126 | --- landscape/lib/tests/test_fs.py 2016-06-15 16:42:17 +0000 |
127 | +++ landscape/lib/tests/test_fs.py 2017-03-13 10:45:51 +0000 |
128 | @@ -1,7 +1,11 @@ |
129 | +# -*- coding: utf-8 -*- |
130 | +import codecs |
131 | import os |
132 | from mock import patch |
133 | import time |
134 | |
135 | +from twisted.python.compat import long |
136 | + |
137 | from landscape.tests.helpers import LandscapeTest |
138 | |
139 | from landscape.lib.fs import append_file, read_file, touch_file |
140 | @@ -14,7 +18,16 @@ |
141 | With no options L{read_file} reads the whole file passed as argument. |
142 | """ |
143 | path = self.makeFile("foo") |
144 | - self.assertEqual(read_file(path), "foo") |
145 | + self.assertEqual(read_file(path), b"foo") |
146 | + |
147 | + def test_read_file_encoding(self): |
148 | + """ |
149 | + With encoding L{read_file} reads the whole file passed as argument and |
150 | + decodes it. |
151 | + """ |
152 | + utf8_content = codecs.encode(u"foo \N{SNOWMAN}", "utf-8") |
153 | + path = self.makeFile(utf8_content, mode="wb") |
154 | + self.assertEqual(read_file(path, encoding="utf-8"), u"foo ☃") |
155 | |
156 | def test_read_file_with_limit(self): |
157 | """ |
158 | @@ -22,14 +35,23 @@ |
159 | given limit. |
160 | """ |
161 | path = self.makeFile("foo bar") |
162 | - self.assertEqual(read_file(path, limit=3), " bar") |
163 | + self.assertEqual(read_file(path, limit=3), b" bar") |
164 | + |
165 | + def test_read_file_with_limit_encoding(self): |
166 | + """ |
167 | + With a positive limit L{read_file} reads only the bytes after the |
168 | + given limit. |
169 | + """ |
170 | + utf8_content = codecs.encode(u"foo \N{SNOWMAN}", "utf-8") |
171 | + path = self.makeFile(utf8_content, mode="wb") |
172 | + self.assertEqual(read_file(path, limit=3, encoding="utf-8"), u" ☃") |
173 | |
174 | def test_read_file_with_negative_limit(self): |
175 | """ |
176 | With a negative limit L{read_file} reads only the tail of the file. |
177 | """ |
178 | path = self.makeFile("foo bar from end") |
179 | - self.assertEqual(read_file(path, limit=-3), "end") |
180 | + self.assertEqual(read_file(path, limit=-3), b"end") |
181 | |
182 | def test_read_file_with_limit_bigger_than_file(self): |
183 | """ |
184 | @@ -37,8 +59,8 @@ |
185 | file. |
186 | """ |
187 | path = self.makeFile("foo bar from end") |
188 | - self.assertEqual(read_file(path, limit=100), "foo bar from end") |
189 | - self.assertEqual(read_file(path, limit=-100), "foo bar from end") |
190 | + self.assertEqual(read_file(path, limit=100), b"foo bar from end") |
191 | + self.assertEqual(read_file(path, limit=-100), b"foo bar from end") |
192 | |
193 | |
194 | class TouchFileTest(LandscapeTest): |
195 | |
196 | === modified file 'landscape/lib/tests/test_process.py' |
197 | --- landscape/lib/tests/test_process.py 2017-03-09 10:13:30 +0000 |
198 | +++ landscape/lib/tests/test_process.py 2017-03-13 10:45:51 +0000 |
199 | @@ -25,7 +25,8 @@ |
200 | os.mkdir(process_dir) |
201 | |
202 | cmd_line = "/usr/bin/foo" |
203 | - create_file(os.path.join(process_dir, "cmdline"), cmd_line) |
204 | + create_file( |
205 | + os.path.join(process_dir, "cmdline"), cmd_line, encoding="utf-8") |
206 | |
207 | status = "\n".join([ |
208 | "Name: foo", |
209 | @@ -34,11 +35,12 @@ |
210 | "Gid: 2000", |
211 | "VmSize: 3000", |
212 | "Ignored: value"]) |
213 | - create_file(os.path.join(process_dir, "status"), status) |
214 | + create_file( |
215 | + os.path.join(process_dir, "status"), status, encoding="utf-8") |
216 | |
217 | stat_array = [str(index) for index in range(44)] |
218 | stat = " ".join(stat_array) |
219 | - create_file(os.path.join(process_dir, "stat"), stat) |
220 | + create_file(os.path.join(process_dir, "stat"), stat, encoding="utf-8") |
221 | |
222 | @mock.patch("landscape.lib.process.detect_jiffies", return_value=1) |
223 | @mock.patch("os.listdir") |
224 | |
225 | === modified file 'landscape/lib/tests/test_twisted_util.py' |
226 | --- landscape/lib/tests/test_twisted_util.py 2017-01-23 12:14:33 +0000 |
227 | +++ landscape/lib/tests/test_twisted_util.py 2017-03-13 10:45:51 +0000 |
228 | @@ -1,8 +1,5 @@ |
229 | import os |
230 | |
231 | -from twisted.python.compat import _PY3 |
232 | -from unittest import skipIf |
233 | - |
234 | from landscape.lib.fs import create_file |
235 | from landscape.lib.twisted_util import spawn_process |
236 | from landscape.tests.helpers import LandscapeTest |
237 | @@ -19,12 +16,12 @@ |
238 | """ |
239 | The process is executed and returns the expected exit code. |
240 | """ |
241 | - create_file(self.command, "#!/bin/sh\nexit 2") |
242 | + create_file(self.command, b"#!/bin/sh\nexit 2") |
243 | |
244 | def callback(args): |
245 | out, err, code = args |
246 | - self.assertEqual(out, "") |
247 | - self.assertEqual(err, "") |
248 | + self.assertEqual(out, b"") |
249 | + self.assertEqual(err, b"") |
250 | self.assertEqual(code, 2) |
251 | |
252 | result = spawn_process(self.command) |
253 | @@ -37,8 +34,8 @@ |
254 | """ |
255 | def callback(args): |
256 | out, err, code = args |
257 | - self.assertEqual(out, "a b") |
258 | - self.assertEqual(err, "") |
259 | + self.assertEqual(out, b"a b") |
260 | + self.assertEqual(err, b"") |
261 | self.assertEqual(code, 0) |
262 | |
263 | result = spawn_process(self.command, args=("a", "b")) |
264 | @@ -49,12 +46,12 @@ |
265 | """ |
266 | The process returns the expected standard error. |
267 | """ |
268 | - create_file(self.command, "#!/bin/sh\necho -n $@ >&2") |
269 | + create_file(self.command, b"#!/bin/sh\necho -n $@ >&2") |
270 | |
271 | def callback(args): |
272 | out, err, code = args |
273 | - self.assertEqual(out, "") |
274 | - self.assertEqual(err, "a b") |
275 | + self.assertEqual(out, b"") |
276 | + self.assertEqual(err, b"a b") |
277 | self.assertEqual(code, 0) |
278 | |
279 | result = spawn_process(self.command, args=("a", "b")) |
280 | @@ -66,9 +63,9 @@ |
281 | If a callback for process output is provieded, it is called for every |
282 | line of output. |
283 | """ |
284 | - create_file(self.command, "#!/bin/sh\n/bin/echo -ne $@") |
285 | + create_file(self.command, b"#!/bin/sh\n/bin/echo -ne $@") |
286 | param = r"some text\nanother line\nok, last one\n" |
287 | - expected = ["some text", "another line", "ok, last one"] |
288 | + expected = [b"some text", b"another line", b"ok, last one"] |
289 | lines = [] |
290 | |
291 | def line_received(line): |
292 | @@ -87,9 +84,9 @@ |
293 | """ |
294 | If output ends with more than one newline, empty lines are preserved. |
295 | """ |
296 | - create_file(self.command, "#!/bin/sh\n/bin/echo -ne $@") |
297 | + create_file(self.command, b"#!/bin/sh\n/bin/echo -ne $@") |
298 | param = r"some text\nanother line\n\n\n" |
299 | - expected = ["some text", "another line", "", ""] |
300 | + expected = [b"some text", b"another line", b"", b""] |
301 | lines = [] |
302 | |
303 | def line_received(line): |
304 | @@ -109,9 +106,9 @@ |
305 | If output ends without a newline, the line is still passed to the |
306 | callback. |
307 | """ |
308 | - create_file(self.command, "#!/bin/sh\n/bin/echo -ne $@") |
309 | + create_file(self.command, b"#!/bin/sh\n/bin/echo -ne $@") |
310 | param = r"some text\nanother line\nok, last one" |
311 | - expected = ["some text", "another line", "ok, last one"] |
312 | + expected = [b"some text", b"another line", b"ok, last one"] |
313 | lines = [] |
314 | |
315 | def line_received(line): |
316 | @@ -126,16 +123,15 @@ |
317 | result.addCallback(callback) |
318 | return result |
319 | |
320 | - @skipIf(_PY3, 'Takes long with Python3, probably unclean Reactor') |
321 | def test_spawn_process_with_stdin(self): |
322 | """ |
323 | Optionally C{spawn_process} accepts a C{stdin} argument. |
324 | """ |
325 | - create_file(self.command, "#!/bin/sh\n/bin/cat") |
326 | + create_file(self.command, b"#!/bin/sh\n/bin/cat") |
327 | |
328 | def callback(args): |
329 | out, err, code = args |
330 | - self.assertEqual("hello", out) |
331 | + self.assertEqual(b"hello", out) |
332 | |
333 | result = spawn_process(self.command, stdin="hello") |
334 | result.addCallback(callback) |
335 | |
336 | === modified file 'landscape/lib/twisted_util.py' |
337 | --- landscape/lib/twisted_util.py 2017-01-10 16:12:53 +0000 |
338 | +++ landscape/lib/twisted_util.py 2017-03-13 10:45:51 +0000 |
339 | @@ -1,10 +1,10 @@ |
340 | +import io |
341 | + |
342 | from twisted.internet.defer import DeferredList, Deferred |
343 | from twisted.internet.protocol import ProcessProtocol |
344 | from twisted.internet.process import Process, ProcessReader |
345 | from twisted.internet import reactor |
346 | -from twisted.python.compat import itervalues |
347 | - |
348 | -from landscape.compat import StringIO |
349 | +from twisted.python.compat import itervalues, networkString |
350 | |
351 | |
352 | def gather_results(deferreds, consume_errors=False): |
353 | @@ -20,16 +20,16 @@ |
354 | |
355 | def __init__(self, deferred, stdin=None, line_received=None): |
356 | self.deferred = deferred |
357 | - self.outBuf = StringIO() |
358 | - self.errBuf = StringIO() |
359 | + self.outBuf = io.BytesIO() |
360 | + self.errBuf = io.BytesIO() |
361 | self.errReceived = self.errBuf.write |
362 | self.stdin = stdin |
363 | self.line_received = line_received |
364 | - self._partial_line = "" |
365 | + self._partial_line = b"" |
366 | |
367 | def connectionMade(self): |
368 | if self.stdin is not None: |
369 | - self.transport.write(self.stdin) |
370 | + self.transport.write(networkString(self.stdin)) |
371 | self.transport.closeStdin() |
372 | |
373 | def outReceived(self, data): |
374 | @@ -41,7 +41,7 @@ |
375 | # data may contain more than one line, so we split the output and save |
376 | # the last line. If it's an empty string nothing happens, otherwise it |
377 | # will be returned once complete |
378 | - lines = data.split("\n") |
379 | + lines = data.split(b"\n") |
380 | lines[0] = self._partial_line + lines[0] |
381 | self._partial_line = lines.pop() |
382 | |
383 | @@ -51,7 +51,7 @@ |
384 | def processEnded(self, reason): |
385 | if self._partial_line: |
386 | self.line_received(self._partial_line) |
387 | - self._partial_line = "" |
388 | + self._partial_line = b"" |
389 | out = self.outBuf.getvalue() |
390 | err = self.errBuf.getvalue() |
391 | e = reason.value |
392 | |
393 | === modified file 'landscape/lib/vm_info.py' |
394 | --- landscape/lib/vm_info.py 2016-09-23 15:43:36 +0000 |
395 | +++ landscape/lib/vm_info.py 2017-03-13 10:45:51 +0000 |
396 | @@ -34,7 +34,7 @@ |
397 | for filename in ("container_type", "systemd/container"): |
398 | path = os.path.join(run_path, filename) |
399 | if os.path.exists(path): |
400 | - return read_file(path).strip() |
401 | + return read_file(path, encoding="utf-8").strip() |
402 | return "" |
403 | |
404 | |
405 | @@ -52,7 +52,7 @@ |
406 | |
407 | def _get_vm_by_vendor(sys_vendor_path): |
408 | """Return the VM type string (possibly empty) based on the vendor.""" |
409 | - vendor = read_file(sys_vendor_path).lower() |
410 | + vendor = read_file(sys_vendor_path, encoding="utf-8").lower() |
411 | # Use lower-key string for vendors, since we do case-insentive match. |
412 | content_vendors_map = ( |
413 | ("bochs", "kvm"), |
414 | @@ -72,7 +72,8 @@ |
415 | def _get_vm_legacy(root_path): |
416 | """Check if the host is virtualized looking at /proc/cpuinfo content.""" |
417 | try: |
418 | - cpuinfo = read_file(os.path.join(root_path, "proc/cpuinfo")) |
419 | + cpuinfo = read_file( |
420 | + os.path.join(root_path, "proc/cpuinfo"), encoding="utf-8") |
421 | except (IOError, OSError): |
422 | return "" |
423 |
Command: TRIAL_ARGS=-j4 make check /ci.lscape. net/job/ latch-test- precise/ 887/
Result: Fail
Revno: 959
Branch: lp:~gocept/landscape-client/py3-twisted-util
Jenkins: https:/