Merge lp:~dpb/latch-test/dont-be-so-verbose into lp:latch-test

Proposed by David Britton
Status: Merged
Approved by: David Britton
Approved revision: 14
Merged at revision: 10
Proposed branch: lp:~dpb/latch-test/dont-be-so-verbose
Merge into: lp:latch-test
Diff against target: 360 lines (+173/-45)
2 files modified
latch.py (+54/-24)
test_latch.py (+119/-21)
To merge this branch: bzr merge lp:~dpb/latch-test/dont-be-so-verbose
Reviewer Review Type Date Requested Status
Alberto Donato (community) Approve
Free Ekanayaka (community) Approve
🤖 Landscape Builder test results Approve
Review via email: mp+270211@code.launchpad.net

Commit message

- Don't treat failed LP reads the same as an empty set read
- Don't post such big latch messages, people can just click the results link that latch sticks in there.

Description of the change

- Refactored code to make the failed read from launchpad actionable
- Stop posting full text of failure to MP
- Added test cases for refactored code (mostly overlap with existing ones)

Please feel free to flame me for testing what could be done better, mocking, etc. I didn't want to spend too much time here, but wanted to make a good change at the same time.

To post a comment you must log in.
Revision history for this message
🤖 Landscape Builder (landscape-builder) wrote :

Command: make test
Result: Success
Revno: 13
Branch: lp:~davidpbritton/latch-test/dont-be-so-verbose
Jenkins: https://ci.lscape.net/job/latch-test/2272/

review: Approve (test results)
Revision history for this message
Free Ekanayaka (free.ekanayaka) wrote :

Thanks for pushing an MP for this David, looks like a good change to me.

Re testing, yes, my recommendation is to avoid mocks, but since these tests already make use of them, it seems out of scope to fix that here, so +1

Unrelated comment, but it seems to me that it's not possible (or it would be very awkward) to save artifacts generated by latch-test, because of this code in test_cli:

        with temporary_directory() as temp_dir:
            environment.initialize(temp_dir)
            result = environment.execute_test()

which essentially purge everything after tests have run. Would it be possible to add support for specifying an artifacts directory path inside the temporary directory that will moved or copied to the working directory before purging everything? This way we can attach more state information about failed tests using Jenkins artifacts (something that is now pressing because of some flaky integration tests).

review: Approve
Revision history for this message
Alberto Donato (ack) wrote :

Looks good, +1

A few minor comments inline.

review: Approve
Revision history for this message
David Britton (dpb) wrote :

Added bug for free's comment: https://launchpad.net/bugs/1493373

Addressed all of alberto's in this branch.

Revision history for this message
🤖 Landscape Builder (landscape-builder) wrote :

There are additional revisions which have not been approved in review. Please seek review and approval of these new revisions.

Revision history for this message
🤖 Landscape Builder (landscape-builder) wrote :

No approved revision specified.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'latch.py'
2--- latch.py 2015-04-23 18:43:19 +0000
3+++ latch.py 2015-09-08 14:29:55 +0000
4@@ -12,11 +12,20 @@
5 import sys
6 import tempfile
7
8+
9 log = logging.getLogger(__name__)
10
11
12+class LaunchpadFetchError(Exception):
13+ """Error fetching collection from Launchpad."""
14+
15+
16 def fetch_collection(lp, collection):
17- """Fetch a launchpad collection with proper batch awareness."""
18+ """Fetch a launchpad collection with proper batch awareness.
19+
20+ @param collection: Collection to read.
21+ raises LaunchpadFetchError if any errors come from lp.
22+ """
23 try:
24 if collection.total_size > 0:
25 for entry in collection.entries:
26@@ -25,16 +34,16 @@
27 collection = collection.next_collection
28 for entry in collection.entries:
29 yield lp.load(entry["self_link"])
30- except Exception:
31+ except Exception as e:
32 log.exception("Error retrieving collection")
33-
34-
35-def format_test_result(result, always_output=False):
36+ raise LaunchpadFetchError(str(e))
37+
38+
39+def format_test_result(result, show_output=False):
40 """
41 Format test results for printing/appending to an MP.
42
43- @param always_output True=always append, False=append
44- only on error
45+ @param show_output True=show, False=hide (default)
46 """
47 string = u"Command: %(command)s\n" \
48 u"Result: %(status)s\n" \
49@@ -42,7 +51,7 @@
50 u"Branch: %(branch)s" % result
51 if 'BUILD_URL' in os.environ:
52 string += u"\nJenkins: %s" % os.environ["BUILD_URL"]
53- if always_output or result["status"] == u"Fail":
54+ if show_output:
55 string += u"\n-------------------------------------------\n"
56 string += u"%s" % result["output"]
57 return string
58@@ -239,32 +248,53 @@
59 log.debug("-> Would be adding result: %s to %s" % (
60 result['status'], self.lp_candidate.web_link))
61
62+ def _get_revision_from_comment(self, comment):
63+ """Given a comment on an MP, return the revision it tested.
64+ 0 will be returned if the comment does not apply."""
65+ revision_tested = 0
66+ if self.lp.me != comment.author:
67+ return 0
68+ m = re.search("Revno: (.*)", comment.message_body[:1024])
69+ if m is None:
70+ log.warning("comment (%s) from %s missing revno, skipping" % (
71+ comment.self_link, self.lp.me))
72+ return 0
73+ stored_revision = m.group(1).strip()
74+ try:
75+ revision_tested = int(stored_revision)
76+ except ValueError:
77+ log.warning("comment (%s) revno parse error, skipping" % (
78+ comment.self_link))
79+ return 0
80+ return revision_tested
81+
82 def are_test_results_stale(self):
83 """
84 Evaluate candidate to determine if latest results are stale. Return
85 boolean evaluation. Each result is checked for the stored revision
86 number and compared to the source_branch revno.
87+ Error conditions are counted as not stale so as not to trigger
88+ retests over and over.
89 """
90 revision_tested = 0
91 log.debug(
92 "-> current revno: r%s" % (
93 self.lp_candidate.source_branch.revision_count))
94- for comment in fetch_collection(self.lp,
95- self.lp_candidate.all_comments):
96- if self.lp.me != comment.author:
97- continue
98- m = re.search("Revno: (.*)", comment.message_body[:1024])
99- if m is None:
100- log.warning("comment (%s) from %s missing revno, skipping" % (
101- comment.self_link, self.lp.me))
102- continue
103- stored_revision = m.group(1).strip()
104- try:
105- revision_tested = max(revision_tested, int(stored_revision))
106- except ValueError:
107- log.warning("comment (%s) revno parse error, skipping" % (
108- comment.self_link))
109- continue
110+
111+ revision_tested = 0
112+ try:
113+ # Since comments are always in chronological order, we can
114+ # stop after we hit a match going backward
115+ for comment in reversed(fetch_collection(
116+ self.lp, self.lp_candidate.all_comments)):
117+ revision_tested = self._get_revision_from_comment(comment)
118+ if revision_tested > 0:
119+ break
120+ except LaunchpadFetchError:
121+ log.exception("Error fetching comments from Launchpad on %s" % (
122+ self.lp_candidate.web_link))
123+ log.warning("Skipping candidate: %s")
124+ return False
125
126 if revision_tested == 0:
127 log.debug("-> results are stale, tested_revision == 0")
128
129=== modified file 'test_latch.py'
130--- test_latch.py 2015-04-23 16:01:38 +0000
131+++ test_latch.py 2015-09-08 14:29:55 +0000
132@@ -3,19 +3,45 @@
133 import latch
134 import unittest
135 from mock import MagicMock, patch
136+import logging
137+from logging.handlers import BufferingHandler
138 import os
139-import random
140 from subprocess import PIPE, STDOUT, check_call, CalledProcessError
141
142 OUTPUT = u"output ✈"
143 MULTILINE_OUTPUT = u"multiline\noutput ✈"
144
145+FAKE_ME = MagicMock()
146+FAKE_ME.name = "fake-user"
147+
148
149 def fetch_collection(self, collection):
150 """Return collection directly."""
151 return collection
152
153
154+def get_fake_comment(body=None, revno=1, author_name=FAKE_ME.name):
155+ """Return a fake comment.
156+ @param body: If set, put this in the body, otherwise, formulate the body
157+ as a latch comment with a Revno:
158+ @param revno: revision number
159+ @param author_name: author name
160+ """
161+ fake_comment = MagicMock()
162+ if author_name == FAKE_ME.name:
163+ fake_author = FAKE_ME
164+ else:
165+ fake_author = MagicMock()
166+ fake_author.name = author_name
167+ fake_comment.author = fake_author
168+
169+ if body is None:
170+ fake_comment.message_body = "Revno: %s" % revno
171+ else:
172+ fake_comment.message_body = body
173+ return fake_comment
174+
175+
176 def add_results(candidate, revision, author):
177 """
178 Add fake test results to a candidate, one for each revision in
179@@ -23,7 +49,6 @@
180 """
181 candidate.all_comments = []
182 revisions = range(1, revision + 1)
183- random.shuffle(revisions)
184 for revno in revisions:
185 comment = MagicMock()
186 comment.message_body = "Revno: %s" % revno
187@@ -89,17 +114,61 @@
188 return (source, target)
189
190
191+def raises_launchpad_fetch_error(*args):
192+ raise latch.LaunchpadFetchError("This is an Error.")
193+
194+
195 # For all tests, bypass fetch_collection and just return raw data
196 latch.fetch_collection = fetch_collection
197
198
199+class TestLogHandler(BufferingHandler):
200+ """LogHandler so unit tests can inspect logs written."""
201+
202+ def __init__(self):
203+ super(TestLogHandler, self).__init__(0)
204+ self.buffer = ()
205+
206+ def shouldFlush(self):
207+ return False
208+
209+ def emit(self, record):
210+ self.buffer += (record.levelname, record.msg),
211+ self.buffer += ("ALL", record.msg),
212+
213+
214+class LoggedTest(unittest.TestCase):
215+ """Testing class that can track emitted logs"""
216+
217+ def setUp(self):
218+ super(LoggedTest, self).setUp()
219+ self.handler = TestLogHandler()
220+ self.logger = logging.getLogger(latch.__name__)
221+ self.logger.addHandler(self.handler)
222+
223+ def tearDown(self):
224+ self.logger.removeHandler(self.handler)
225+ self.handler.close()
226+
227+ def assertInLogs(self, message, level="ALL"):
228+ """Assert that a message is logged. Substrings are OK.
229+
230+ Optionally restict search to a log level of INFO, WARNING and ERROR.
231+ """
232+ for log_level, log_message in self.handler.buffer:
233+ if level == log_level:
234+ if message in log_message:
235+ return
236+ raise AssertionError("String '%s' not found in %s" % (
237+ message, self.handler.buffer))
238+
239+
240 class LatchTest(unittest.TestCase):
241
242 def setUp(self):
243 self.latch = latch.Latch()
244 self.latch.lp = MagicMock()
245- self.latch.lp.me = MagicMock()
246- self.latch.lp.me.name = "fake-user"
247+ self.latch.lp.me = FAKE_ME
248
249 def test_scan_branch_nothing(self):
250 """Scan list of candidates, nothing should match."""
251@@ -125,7 +194,7 @@
252 def setUp(self):
253 self.latch = latch.Latch()
254 self.latch.lp = MagicMock()
255- self.latch.lp.me.name = "fake-user"
256+ self.latch.lp.me = FAKE_ME
257
258 def test__lookup_config(self):
259 """section and key/value exist in config."""
260@@ -157,14 +226,14 @@
261 self.latch._lookup_config(config, candidate, "missing_key")
262
263
264-class CandidateTest(unittest.TestCase):
265+class CandidateTest(LoggedTest):
266
267 def setUp(self):
268+ super(CandidateTest, self).setUp()
269 # Create a candidate object for testing, individual tests
270 # will need to fill out the lp_candidate appropriately
271 self.lp = MagicMock()
272- self.lp.me = MagicMock()
273- self.lp.me.name = "fake-user"
274+ self.lp.me = FAKE_ME
275 self.candidate = latch.Candidate(self.lp, MagicMock())
276
277 def test_add_reviewer_readonly(self):
278@@ -240,6 +309,43 @@
279 "This is a test.")
280 self.assertTrue(self.candidate.are_test_results_stale())
281
282+ def test_are_test_results_stale_lp_error(self):
283+ """Test results revno == source branch."""
284+ self.candidate.lp_candidate.source_branch.revision_count = 1
285+ add_results(self.candidate.lp_candidate, 1, self.lp.me)
286+ old_fetch_collection = latch.fetch_collection
287+ try:
288+ latch.fetch_collection = raises_launchpad_fetch_error
289+ self.assertFalse(
290+ self.candidate.are_test_results_stale())
291+ self.assertInLogs("Error fetching comments from Launchpad")
292+ self.assertInLogs("Skipping candidate")
293+ finally:
294+ latch.fetch_collection = old_fetch_collection
295+
296+ def test_get_revision_from_comment(self):
297+ """Test comment happy path, it's one of ours, and it has a revno."""
298+ comment = get_fake_comment(revno=100)
299+ self.assertEqual(
300+ self.candidate._get_revision_from_comment(comment), 100)
301+
302+ def test_get_revision_from_comment_different_author(self):
303+ """Test comment filter: comment not from self.lp.me returns 0."""
304+ comment = get_fake_comment(author_name="someone-else")
305+ self.assertEqual(self.candidate._get_revision_from_comment(comment), 0)
306+
307+ def test_get_revision_from_comment_no_revno(self):
308+ """Test comment filter: revno missing from comment."""
309+ comment = get_fake_comment(body="foobar")
310+ self.assertEqual(self.candidate._get_revision_from_comment(comment), 0)
311+ self.assertInLogs("missing revno, skipping")
312+
313+ def test_get_revision_from_comment_revno_parse_error(self):
314+ """Test comment filter: revno not an int."""
315+ comment = get_fake_comment(revno="foobar")
316+ self.assertEqual(self.candidate._get_revision_from_comment(comment), 0)
317+ self.assertInLogs("revno parse error, skipping")
318+
319 def test_add_result_readonly(self):
320 """if readonly, should NOT try to create a comment."""
321 result = {
322@@ -293,25 +399,17 @@
323
324 def test_format_test_result_force_include_output(self):
325 """Passing result, output requested to appear."""
326- string = latch.format_test_result(self.result, always_output=True)
327+ string = latch.format_test_result(self.result, show_output=True)
328 self.assertIn(u"Result: Success", string)
329 self.assertIn(MULTILINE_OUTPUT, string)
330 self.assertNotIn(u"Jenkins:", string)
331
332- def test_format_test_result_include_output_with_failure(self):
333- """Failing result, output should appear."""
334+ def test_format_test_result_with_failure_no_output(self):
335+ """Failing result, output should not appear since not requested."""
336 self.result["status"] = "Fail"
337 string = latch.format_test_result(self.result)
338 self.assertIn(u"Revno: 1234", string)
339- self.assertIn(MULTILINE_OUTPUT, string)
340- self.assertNotIn(u"Jenkins:", string)
341-
342- def test_format_test_result_include_output_with_failure_and_request(self):
343- """Failing result, output should appear, even if requested."""
344- self.result["status"] = "Fail"
345- string = latch.format_test_result(self.result, always_output=True)
346- self.assertIn(u"Revno: 1234", string)
347- self.assertIn(MULTILINE_OUTPUT, string)
348+ self.assertNotIn(MULTILINE_OUTPUT, string)
349 self.assertNotIn(u"Jenkins:", string)
350
351 def test_format_test_result_jenkins_build_url(self):
352@@ -333,7 +431,7 @@
353 class TestEnvironmentTest(unittest.TestCase):
354 def setUp(self):
355 self.lp = MagicMock()
356- self.lp.me.name = "fake-user"
357+ self.lp.me = FAKE_ME
358 self.environment = latch.TestEnvironment(
359 latch.Candidate(self.lp, get_candidate(self.lp.me.name)),
360 self.lp,

Subscribers

People subscribed via source and target branches

to all changes: