OLD | NEW |
---|---|
1 #!/usr/bin/python | 1 #!/usr/bin/python |
2 # Copyright (c) 2011 The Chromium Authors. All rights reserved. | 2 # Copyright (c) 2011 The Chromium Authors. All rights reserved. |
3 # Use of this source code is governed by a BSD-style license that can be | 3 # Use of this source code is governed by a BSD-style license that can be |
4 # found in the LICENSE file. | 4 # found in the LICENSE file. |
5 | 5 |
6 """A module for TestExpectionsManager. | 6 """A Module for the Webkit layout test test expectation related class.""" |
7 | 7 |
8 TestExpectaionManager manages data for Webkit layout tests. | 8 import re |
9 """ | 9 import urllib2 |
10 | 10 |
11 import csv | 11 # Default Webkit SVN location for chromium test expectation file. |
12 import re | 12 # TODO(imasaki@) : support multiple test expectations files. |
Ami GONE FROM CHROMIUM
2011/08/18 19:56:58
No '@' on TODO.
imasaki1
2011/08/18 22:51:56
Done.
| |
13 import time | 13 DEFAULT_TEST_EXPECTATION_LOCATION = ( |
14 import urllib | 14 'http://svn.webkit.org/repository/webkit/trunk/' |
15 import pysvn | 15 'LayoutTests/platform/chromium/test_expectations.txt') |
16 | 16 |
17 from csv_utils import CsvUtils | 17 # The following is from test expectation syntax. The detail can be found in |
18 from layout_test_test_case import LayoutTestCaseManager | 18 # http://www.chromium.org/developers/testing/ |
19 from test_case_patterns import TestCasePatterns | 19 # webkit-layout-tests#TOC-Test-Expectations |
20 # <decision> ::== [SKIP] [WONTFIX] [SLOW] | |
21 DECISION_NAMES = ['SKIP', 'WONTFIX', 'SLOW'] | |
22 # <platform> ::== [GPU] [CPU] [WIN] [LINUX] [MAC] | |
23 PLATFORM_NAMES = ['GPU', 'CPU', 'WIN', 'LINUX', 'MAC'] | |
24 # <config> ::== RELEASE | DEBUG | |
25 CONFIG_NAMES = ['RELEASE', 'DEBUG'] | |
26 # <EXPECTATION_NAMES> ::== \ | |
27 # [FAIL] [PASS] [CRASH] [TIMEOUT] [IMAGE] [TEXT] [IMAGE+TEXT] | |
28 EXPECTATION_NAMES = ['FAIL', 'PASS', 'CRASH', | |
29 'TIMEOUT', 'IMAGE', 'TEXT', | |
30 'IMAGE+TEXT'] | |
20 | 31 |
21 | 32 |
22 class TestExpectationsManager(object): | 33 class TestExpectations(object): |
23 """This class manages test expectations data for Webkit layout tests. | 34 """A class to model the content of test expectation file for analysis. |
24 | 35 |
25 The detail of the test expectation file can be found in | 36 The raw test expectation file can be found in |
26 http://trac.webkit.org/wiki/TestExpectations. | 37 http://svn.webkit.org/repository/webkit/trunk/LayoutTests/platform/ |
Ami GONE FROM CHROMIUM
2011/08/18 19:56:58
Can reference DEFAULT above
imasaki1
2011/08/18 22:51:56
Done.
| |
38 chromium/test_expectations.txt. | |
39 It is necessary to parse this file and store meaningful information for | |
40 the analysis (joining with exisinting layout tests using a test name). | |
Ami GONE FROM CHROMIUM
2011/08/18 19:56:58
typo: existinting
imasaki1
2011/08/18 22:51:56
Done.
| |
41 Instance variable |all_test_expectation_info| is ussed. | |
Ami GONE FROM CHROMIUM
2011/08/18 19:56:58
ussed
imasaki1
2011/08/18 22:51:56
Done.
| |
42 A test name such as 'media/video-source-type.html' is used for the key | |
43 to store information. However, a test name can appear multiple times in | |
44 the test expectation file. So, the map should keep all the occurrences | |
45 information. For example, the current test expectation file has the following | |
46 two entries: | |
47 BUGWK58587 LINUX DEBUG GPU : media/video-zoom.html = IMAGE | |
48 BUGCR86714 MAC GPU : media/video-zoom.html = CRASH IMAGE | |
49 In this case, all_test_expectation_info['media/video-zoom.html'] will have | |
Ami GONE FROM CHROMIUM
2011/08/18 19:56:58
s/have/have a/
imasaki1
2011/08/18 22:51:56
Done.
| |
50 list with two element, each of which is the map of the entries' | |
Ami GONE FROM CHROMIUM
2011/08/18 19:56:58
s/element/elements/
Ami GONE FROM CHROMIUM
2011/08/18 19:56:58
entries' what?
imasaki1
2011/08/18 22:51:56
Done.
| |
51 """ | |
27 | 52 |
28 This class does the following: | 53 @staticmethod |
Ami GONE FROM CHROMIUM
2011/08/18 19:56:58
Stopped reviewing at this point b/c I realized thi
imasaki1
2011/08/18 22:51:56
This class is broken since test_expectations.TestE
Ami GONE FROM CHROMIUM
2011/08/19 15:43:00
I think you meant http://trac.webkit.org/changeset
| |
29 (1) get test expectation file from WebKit subversion source | 54 def GetAllDataNames(): |
30 repository using pysvn. | 55 """get all names relating to test expectation data. |
31 (2) parse the file and generate CSV entries. | 56 |
32 (3) create hyperlinks where appropriate (e.g., test case name). | 57 Returns: |
58 a list of all names (keywords) used in the test expectation files. | |
33 """ | 59 """ |
60 return DECISION_NAMES + PLATFORM_NAMES + CONFIG_NAMES + EXPECTATION_NAMES | |
34 | 61 |
35 DEFAULT_TEST_EXPECTATION_DIR = ( | 62 def __init__(self, url=DEFAULT_TEST_EXPECTATION_LOCATION): |
36 'http://svn.webkit.org/repository/webkit/trunk/' | 63 """read the test expectation file from the specified URL and parse it. |
37 'LayoutTests/platform/chromium/') | |
38 | 64 |
39 DEFAULT_TEST_EXPECTATION_LOCATION = ( | 65 All the parsed information is stored in the instance variable |
40 'http://svn.webkit.org/repository/webkit/trunk/' | 66 |all_test_expectation_info|, which is the map where its key is test name |
41 'LayoutTests/platform/chromium/test_expectations.txt') | 67 and its value is a list of the test expectation entries information map |
68 such as 'SKIP' or 'Comments' or 'Bugs'. | |
42 | 69 |
43 CHROME_BUG_URL = 'http://code.google.com/p/chromium/issues/detail?id=' | 70 Args: |
71 url: A URL string for the test expectation file. | |
72 """ | |
73 self.all_test_expectation_info = {} | |
74 resp = urllib2.urlopen(url) | |
75 if resp.code != 200: | |
76 raise NameError('Test expectation file does not exist in %s' % source) | |
77 # Start parsing each line. | |
78 lines = resp.read().split('\n') | |
79 comments = '' | |
80 for line in lines: | |
81 if line.startswith('//'): | |
82 # Comments can be Multiple lines. | |
83 comments += line.replace('//', '') | |
84 elif not line: | |
85 comments = '' | |
86 else: | |
87 test_expectation_info = self.ParseLine(line, comments) | |
88 testname = TestExpectations.ExtractTestName(line) | |
89 if not testname in self.all_test_expectation_info: | |
90 self.all_test_expectation_info[testname] = [] | |
91 # This is a list for multiple entries. | |
92 self.all_test_expectation_info[testname].append(test_expectation_info) | |
44 | 93 |
45 WEBKIT_BUG_URL = 'https://bugs.webkit.org/show_bug.cgi?id=' | 94 @staticmethod |
95 def ExtractTestName(line): | |
96 """extract either a test name or a directory name from each line. | |
46 | 97 |
47 FLAKINESS_DASHBOARD_LINK = ( | 98 Args: |
48 'http://test-results.appspot.com/dashboards/' | 99 line: each line in the test expectation file. |
49 'flakiness_dashboard.html#tests=') | |
50 | 100 |
51 TEST_CASE_PATTERNS = TestCasePatterns() | 101 Returns: |
102 a test name or directory name string. Returns '' if no matching. | |
103 """ | |
104 # First try to find test case where ending with .html. | |
105 matches = re.search(r':\s+(\S+.html)', line) | |
106 # Next try to find directory | |
107 if matches: | |
108 return matches.group(1) | |
109 matches = re.search(r':\s+(\S+)', line) | |
110 if matches: | |
111 return matches.group(1) | |
112 else: | |
113 return '' | |
52 | 114 |
53 OTHER_FIELD_NAMES = ['TestCase', 'Media', 'Flaky', 'WebKitD', 'Bug', | 115 @staticmethod |
54 'Test'] | 116 def ParseLine(line, comments): |
117 """Parse each line in test expectation and update test expectation info. | |
55 | 118 |
56 # The following is from test expectation syntax. | 119 This looks for each name in |GetAllDataNames()| in line and store in the |
57 # BUG[0-9]+ [SKIP] [WONTFIX] [SLOW] | 120 map and returns it. Also, store comments and bugs in the map. |
58 DECISION_COLUMN_NAMES = ['SKIP', 'SLOW'] | |
59 # <platform> ::== [GPU] [CPU] [WIN] [LINUX] [MAC] | |
60 PLATFORM_COLUMN_NAMES = ['GPU', 'CPU', 'WIN', 'LINUX', 'MAC'] | |
61 #<config> ::== RELEASE | DEBUG | |
62 CONFIG_COLUMN_NAMES = ['RELEASE', 'DEBUG'] | |
63 # <EXPECTATION_COLUMN_NAMES> ::== \ | |
64 # [FAIL] [PASS] [CRASH] [TIMEOUT] [IMAGE] [TEXT] [IMAGE+TEXT] | |
65 EXPECTATION_COLUMN_NAMES = ['FAIL', 'PASS', 'CRASH', | |
66 'TIMEOUT', 'IMAGE', 'TEXT', | |
67 'IMAGE+TEXT'] | |
68 | 121 |
69 # These are coming from metadata in comments in the test expectation file. | 122 Args: |
70 COMMENT_COLUMN_NAMES = ['UNIMPLEMENTED', 'KNOWNISSUE', 'TESTISSUE', | 123 line: each line in the test expectation. For example, |
71 'WONTFIX'] | 124 "BUGCR86714 MAC GPU : media/video-zoom.html = CRASH IMAGE" |
125 comments: comments in the test expectation. Usually, it exist just before | |
126 the entry. | |
72 | 127 |
73 RAW_COMMENT_COLUMN_NAME = 'COMMENTS' | 128 Returns: |
74 | 129 a map containing the test expectation entries an and comments and bugs. |
75 def __init__(self): | 130 """ |
76 """Initialize the test cases.""" | 131 test_expectation_info = {} |
77 self.testcases = [] | 132 for name in TestExpectations.GetAllDataNames(): |
78 | 133 if not line: |
79 def get_column_indexes(self, column_names): | 134 # Clear the comments if there is space (this is typical the case). |
80 """Get column indexes for given column names. | 135 comments = '' |
81 | 136 if name in line: |
82 Args: | 137 test_expectation_info[name] = True |
83 column_names: a list of column names. | 138 # Store comments. |
84 | 139 test_expectation_info['Comments'] = comments |
85 Returns: | 140 # Store bugs. |
86 a list of indexes for the given column names. | 141 bugs = re.findall(r'BUG\w+', line) |
87 """ | 142 if len(bugs) > 0: |
88 all_field_names = self.get_all_column_names(True, True) | 143 test_expectation_info['Bugs'] = bugs |
89 return [all_field_names.index( | 144 return test_expectation_info |
90 field_name) for field_name in column_names] | |
91 | |
92 def get_all_column_names(self, include_other_fields, include_comments): | |
93 """Get all column names that are used in CSV file. | |
94 | |
95 Args: | |
96 include_other_fields: a boolean that indicates the result should | |
97 include other column names (defined in OTHER_COLUMNS_NAMES). | |
98 include_comments: a boolean that indicates the result should | |
99 include comment-related names. | |
100 | |
101 Returns: | |
102 a list that contains column names. | |
103 """ | |
104 return_list = ( | |
105 self.DECISION_COLUMN_NAMES + self.PLATFORM_COLUMN_NAMES + | |
106 self.CONFIG_COLUMN_NAMES + self.EXPECTATION_COLUMN_NAMES) | |
107 if include_other_fields: | |
108 return_list = self.OTHER_FIELD_NAMES + return_list | |
109 if include_comments: | |
110 return_list.extend(self.COMMENT_COLUMN_NAMES) | |
111 return_list.append(self.RAW_COMMENT_COLUMN_NAME) | |
112 return return_list | |
113 | |
114 def get_test_case_element(self, test_case, column_names): | |
115 """Get test case elements. | |
116 | |
117 A test case is a collection of test case elements. | |
118 | |
119 Args: | |
120 test_case: test case data that contains all test case elements. | |
121 column_names: column names for specifying test case elements. | |
122 | |
123 Returns: | |
124 A list of test case elements for the given column names. | |
125 """ | |
126 field_indexes = self.get_column_indexes(column_names) | |
127 test_case = self.get_test_case_by_name(test_case) | |
128 if test_case is None: | |
129 return [] | |
130 return [test_case[fid] for fid in field_indexes] | |
131 | |
132 def get_test_case_by_name(self, target): | |
133 """Get test case object by test case name. | |
134 | |
135 Args: | |
136 target: test case name. | |
137 | |
138 Returns: | |
139 A test case with the test case name or None if the test case | |
140 cannot be found. | |
141 """ | |
142 for testcase in self.testcases: | |
143 # Test case name is stored in the first column. | |
144 if testcase[0] == target: | |
145 return testcase | |
146 return None | |
147 | |
148 def get_all_test_case_names(self): | |
149 """Get all test case names. | |
150 | |
151 Returns: | |
152 A list of test case names. | |
153 """ | |
154 return [testcase[0] for testcase in self.testcases] | |
155 | |
156 def generate_link_for_bug(self, bug): | |
157 """Generate link for a bug. | |
158 | |
159 Parse the bug description accordingly. The bug description can be like | |
160 the following: BUGWK1234, BUGCR1234, BUGFOO. | |
161 | |
162 Args: | |
163 bug: A string bug description | |
164 | |
165 Returns: | |
166 A string that represents a bug link. Returns an empty | |
167 string if match is not found. | |
168 """ | |
169 pattern_for_webkit_bug = 'BUGWK(\d+)' | |
170 match = re.search(pattern_for_webkit_bug, bug) | |
171 if match is not None: | |
172 return self.WEBKIT_BUG_URL + match.group(1) | |
173 pattern_for_chrome_bug = 'BUGCR(\d+)' | |
174 match = re.search(pattern_for_chrome_bug, bug) | |
175 if match is not None: | |
176 return self.CHROME_BUG_URL + match.group(1) | |
177 pattern_for_other_bug = 'BUG(\S+)' | |
178 match = re.search(pattern_for_other_bug, bug) | |
179 if match is not None: | |
180 return 'mailto:%s@chromium.org' % match.group(1).lower() | |
181 return '' | |
182 | |
183 def generate_link_for_dashboard(self, testcase_name, webkit): | |
184 """Generate link for flakiness dashboard. | |
185 | |
186 Args: | |
187 testcase_name: a string test case name. | |
188 webkit: a boolean indicating whether to use the webkit dashboard. | |
189 | |
190 Returns: | |
191 A string link to the flakiness dashboard. | |
192 """ | |
193 url = self.FLAKINESS_DASHBOARD_LINK + urllib.quote(testcase_name) | |
194 if webkit: | |
195 url += '&master=webkit.org' | |
196 return url | |
197 | |
198 def parse_line(self, line, previous_comment_info_list, test_case_patterns, | |
199 media_test_cases_only, writer): | |
200 """Parse each line in test_expectations.txt. | |
201 | |
202 The format of each line is as follows: | |
203 BUG[0-9]+ [SKIP] [WONTFIX] [SLOW] [<platform>] [<config>] | |
204 : <url> = <EXPCTATION> | |
205 The example is: | |
206 BUG123 BUG345 MAC : media/hoge.html | |
207 WONTFIX SKIP BUG19635 : media/restore-from-page-cache.html | |
208 = TIMEOUT | |
209 | |
210 Args: | |
211 line: a text for each line in the test expectation file. | |
212 previous_comment_info_list: a list of comments in previous lines. | |
213 test_case_patterns: a string for test case pattern. | |
214 media_test_cases_only: a boolean for media test case only. | |
215 writer: writer for intermediate results. | |
216 """ | |
217 bugs = re.findall(r'BUG\w+', line) | |
218 testcases = re.search(r':\s+(\S+.[html|xml|svg|js])', line) | |
219 if testcases is not None: | |
220 testcase = testcases.group(1) | |
221 data = [] | |
222 data.append(testcase) | |
223 | |
224 if LayoutTestCaseManager.check_test_case_matches_pattern( | |
225 testcase, test_case_patterns): | |
226 data.append('Y') | |
227 media_test_case = True | |
228 else: | |
229 data.append('N') | |
230 media_test_case = False | |
231 | |
232 dlink = self.generate_link_for_dashboard(testcase, False) | |
233 # Generate dashboard link. | |
234 data.append( | |
235 CsvUtils.generate_hyperlink_in_csv(dlink, 'Y', '')) | |
236 dlink = self.generate_link_for_dashboard(testcase, True) | |
237 # Generate dashboard link. | |
238 data.append( | |
239 CsvUtils.generate_hyperlink_in_csv(dlink, 'Y', '')) | |
240 for bug in bugs: | |
241 blink = self.generate_link_for_bug(bug) | |
242 data.append( | |
243 CsvUtils.generate_hyperlink_in_csv(blink, bug, '')) | |
244 # Fill the gap for test case with less bug/test. | |
245 for i in range(2 - len(bugs)): | |
246 data.append('') | |
247 | |
248 # Fill all data with 'N' (default). | |
249 for field_name in self.get_all_column_names(False, False): | |
250 if field_name in line: | |
251 data.append('Y') | |
252 else: | |
253 data.append('N') | |
254 # Comments will be accumulated in previous comments. | |
255 # This is for multiple-line comment. | |
256 for previous_comment_info in previous_comment_info_list: | |
257 data.append(previous_comment_info) | |
258 # Write only media related test case if specified. | |
259 if (media_test_case and media_test_cases_only | |
260 or (not media_test_cases_only)): | |
261 writer.writerow(data) | |
262 self.testcases.append(data) | |
263 | |
264 def process_comments(self, comments): | |
265 """Process comments in the test expectations file. | |
266 | |
267 Comments may contain special keywords such as UNIMPLEMENTED, | |
268 KNOWNISSUE, TESTISSUE (in TestExpectationsManager.COMMENT_COLUMN_NAMES) | |
269 and may be multiple lines. | |
270 | |
271 Args: | |
272 comments: A raw comment from the test expectation file. | |
273 It is above test case name. | |
274 | |
275 Returns: | |
276 A list of 'Y' or 'N' whether the comments contain | |
277 each column name in TestExpectationsManager.COMMENT_COLUMN_NAMES | |
278 in the comments. | |
279 """ | |
280 return_list = [] | |
281 for ccn in TestExpectationsManager.COMMENT_COLUMN_NAMES: | |
282 if ccn in comments: | |
283 return_list.append('Y') | |
284 else: | |
285 return_list.append('N') | |
286 return_list.append(comments) | |
287 return return_list | |
288 | |
289 def get_and_save_content(self, location, output): | |
290 """Simply get test expectation from the specified location and save it. | |
291 | |
292 Args: | |
293 location: SVN location of the test expectation file. | |
294 output: an output file path including file name. | |
295 """ | |
296 client = pysvn.Client() | |
297 file_object = open(output, 'w') | |
298 file_object.write(client.cat(location)) | |
299 file_object.close() | |
300 | |
301 def get_and_save_content_media_only(self, location, output): | |
302 """Simply get test expectation from the specified location. | |
303 | |
304 It also saves it (media only). | |
305 | |
306 Args: | |
307 location: SVN location of the test expectation file. | |
308 output: an output file path including file name. | |
309 """ | |
310 file_object = file(location, 'r') | |
311 text_list = list(file_object) | |
312 output_text = '' | |
313 for txt in text_list: | |
314 if txt.startswith('//'): | |
315 output_text += txt | |
316 else: | |
317 for pattern in ( | |
318 self.TEST_CASE_PATTERNS.get_test_case_pattern( | |
319 'media')): | |
320 if re.search(pattern, txt) is not None: | |
321 output_text += txt | |
322 break | |
323 file_object.close() | |
324 # Save output. | |
325 file_object = open(output, 'w') | |
326 file_object.write(output_text) | |
327 file_object.close() | |
328 | |
329 def get_and_parse_content(self, location, output, media_test_case_only): | |
330 """Get test_expectations.txt and parse the content. | |
331 | |
332 The comments are parsed as well since it contains keywords. | |
333 | |
334 Args: | |
335 location: SVN location of the test expectation file. | |
336 output: an output file path including file name. | |
337 media_test_case_only: A boolean indicating whether media | |
338 test cases should only be processed. | |
339 """ | |
340 if location.startswith('http'): | |
341 # Get from SVN. | |
342 client = pysvn.Client() | |
343 # Check out the current version of the pysvn project. | |
344 txt = client.cat(location) | |
345 else: | |
346 if location.endswith('.csv'): | |
347 # No parsing in the case of CSV file. | |
348 # Direct reading. | |
349 file_object = file(location, 'r') | |
350 self.testcases = list(csv.reader(file_object)) | |
351 file_object.close() | |
352 return | |
353 file_object = open(location, 'r') | |
354 txt = file_object.read() | |
355 file_object.close() | |
356 | |
357 file_object = file(output, 'wb') | |
358 writer = csv.writer(file_object) | |
359 writer.writerow(self.get_all_column_names(True, True)) | |
360 lines = txt.split('\n') | |
361 process = False | |
362 previous_comment = '' | |
363 previous_comment_list = [] | |
364 for line in lines: | |
365 if line.isspace() or str is '': | |
366 continue | |
367 line.strip() | |
368 if line.startswith('//'): | |
369 # There are comments. | |
370 line = line.replace('//', '') | |
371 if process is True: | |
372 previous_comment = line | |
373 else: | |
374 previous_comment = previous_comment + '\n' + line | |
375 previous_comment_list = self.process_comments(previous_comment) | |
376 process = False | |
377 else: | |
378 self.parse_line( | |
379 line, previous_comment_list, | |
380 self.TEST_CASE_PATTERNS.get_test_case_pattern('media'), | |
381 media_test_case_only, writer) | |
382 process = True | |
383 file_object.close() | |
384 | |
385 def get_te_diff_between_times(self, te_location, start, end, patterns, | |
386 change, checkchange): | |
387 """Get test expectation diff output for a given time period. | |
388 | |
389 Args: | |
390 te_location: SVN location of the test expectation file. | |
391 start: a date object for the start of the time period. | |
392 end: a date object for the end of the time period. | |
393 patterns: test case name patterns of the test cases that | |
394 we are interested in. This could be test case name | |
395 as it is in the case of exact matching. | |
396 change: the number of change in occurrence of test case in | |
397 test expectation. | |
398 checkchange: a boolean to indicate if we want to check the | |
399 change in occurrence as specified in |change| argument. | |
400 | |
401 Returns: | |
402 a list of tuples (old_revision, new_revision, | |
403 diff_line, author, date, commit_message) that matches | |
404 the condition. | |
405 """ | |
406 client = pysvn.Client() | |
407 client.checkout(te_location, 'tmp', recurse=False) | |
408 logs = client.log('tmp/test_expectations.txt', | |
409 revision_start=pysvn.Revision( | |
410 pysvn.opt_revision_kind.date, start), | |
411 revision_end=pysvn.Revision( | |
412 pysvn.opt_revision_kind.date, end)) | |
413 result_list = [] | |
414 for i in xrange(len(logs) - 1): | |
415 # PySVN.log returns logs in reverse chronological order. | |
416 new_rev = logs[i].revision.number | |
417 old_rev = logs[i + 1].revision.number | |
418 # Getting information about new revision. | |
419 author = logs[i].author | |
420 date = logs[i].date | |
421 message = logs[i].message | |
422 text = client.diff('/tmp', 'tmp/test_expectations.txt', | |
423 revision1=pysvn.Revision( | |
424 pysvn.opt_revision_kind.number, old_rev), | |
425 revision2=pysvn.Revision( | |
426 pysvn.opt_revision_kind.number, new_rev)) | |
427 lines = text.split('\n') | |
428 for line in lines: | |
429 for pattern in patterns: | |
430 matches = re.findall(pattern, line) | |
431 if matches: | |
432 if checkchange: | |
433 if ((line[0] == '+' and change > 0) or | |
434 (line[0] == '-' and change < 0)): | |
435 result_list.append((old_rev, new_rev, line, | |
436 author, date, message)) | |
437 else: | |
438 if line[0] == '+' or line[0] == '-': | |
439 result_list.append((old_rev, new_rev, line, | |
440 author, date, message)) | |
441 return result_list | |
OLD | NEW |