##// END OF EJS Templates
todos: all todos needs to be resolved for merge to happen....
marcink -
r1342:44fc3039 default
parent child Browse files
Show More
@@ -1,612 +1,617 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2011-2017 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 comments model for RhodeCode
23 23 """
24 24
25 25 import logging
26 26 import traceback
27 27 import collections
28 28
29 29 from datetime import datetime
30 30
31 31 from pylons.i18n.translation import _
32 32 from pyramid.threadlocal import get_current_registry
33 33 from sqlalchemy.sql.expression import null
34 34 from sqlalchemy.sql.functions import coalesce
35 35
36 36 from rhodecode.lib import helpers as h, diffs
37 37 from rhodecode.lib.channelstream import channelstream_request
38 38 from rhodecode.lib.utils import action_logger
39 39 from rhodecode.lib.utils2 import extract_mentioned_users
40 40 from rhodecode.model import BaseModel
41 41 from rhodecode.model.db import (
42 42 ChangesetComment, User, Notification, PullRequest, AttributeDict)
43 43 from rhodecode.model.notification import NotificationModel
44 44 from rhodecode.model.meta import Session
45 45 from rhodecode.model.settings import VcsSettingsModel
46 46 from rhodecode.model.notification import EmailNotificationModel
47 47 from rhodecode.model.validation_schema.schemas import comment_schema
48 48
49 49
50 50 log = logging.getLogger(__name__)
51 51
52 52
53 53 class CommentsModel(BaseModel):
54 54
55 55 cls = ChangesetComment
56 56
57 57 DIFF_CONTEXT_BEFORE = 3
58 58 DIFF_CONTEXT_AFTER = 3
59 59
60 60 def __get_commit_comment(self, changeset_comment):
61 61 return self._get_instance(ChangesetComment, changeset_comment)
62 62
63 63 def __get_pull_request(self, pull_request):
64 64 return self._get_instance(PullRequest, pull_request)
65 65
66 66 def _extract_mentions(self, s):
67 67 user_objects = []
68 68 for username in extract_mentioned_users(s):
69 69 user_obj = User.get_by_username(username, case_insensitive=True)
70 70 if user_obj:
71 71 user_objects.append(user_obj)
72 72 return user_objects
73 73
74 74 def _get_renderer(self, global_renderer='rst'):
75 75 try:
76 76 # try reading from visual context
77 77 from pylons import tmpl_context
78 78 global_renderer = tmpl_context.visual.default_renderer
79 79 except AttributeError:
80 80 log.debug("Renderer not set, falling back "
81 81 "to default renderer '%s'", global_renderer)
82 82 except Exception:
83 83 log.error(traceback.format_exc())
84 84 return global_renderer
85 85
86 86 def aggregate_comments(self, comments, versions, show_version, inline=False):
87 87 # group by versions, and count until, and display objects
88 88
89 89 comment_groups = collections.defaultdict(list)
90 90 [comment_groups[
91 91 _co.pull_request_version_id].append(_co) for _co in comments]
92 92
93 93 def yield_comments(pos):
94 94 for co in comment_groups[pos]:
95 95 yield co
96 96
97 97 comment_versions = collections.defaultdict(
98 98 lambda: collections.defaultdict(list))
99 99 prev_prvid = -1
100 100 # fake last entry with None, to aggregate on "latest" version which
101 101 # doesn't have an pull_request_version_id
102 102 for ver in versions + [AttributeDict({'pull_request_version_id': None})]:
103 103 prvid = ver.pull_request_version_id
104 104 if prev_prvid == -1:
105 105 prev_prvid = prvid
106 106
107 107 for co in yield_comments(prvid):
108 108 comment_versions[prvid]['at'].append(co)
109 109
110 110 # save until
111 111 current = comment_versions[prvid]['at']
112 112 prev_until = comment_versions[prev_prvid]['until']
113 113 cur_until = prev_until + current
114 114 comment_versions[prvid]['until'].extend(cur_until)
115 115
116 116 # save outdated
117 117 if inline:
118 118 outdated = [x for x in cur_until
119 119 if x.outdated_at_version(show_version)]
120 120 else:
121 121 outdated = [x for x in cur_until
122 122 if x.older_than_version(show_version)]
123 123 display = [x for x in cur_until if x not in outdated]
124 124
125 125 comment_versions[prvid]['outdated'] = outdated
126 126 comment_versions[prvid]['display'] = display
127 127
128 128 prev_prvid = prvid
129 129
130 130 return comment_versions
131 131
132 def get_unresolved_todos(self, pull_request):
132 def get_unresolved_todos(self, pull_request, show_outdated=True):
133 133
134 134 todos = Session().query(ChangesetComment) \
135 135 .filter(ChangesetComment.pull_request == pull_request) \
136 136 .filter(ChangesetComment.resolved_by == None) \
137 137 .filter(ChangesetComment.comment_type
138 == ChangesetComment.COMMENT_TYPE_TODO) \
139 .filter(coalesce(ChangesetComment.display_state, '') !=
140 ChangesetComment.COMMENT_OUTDATED).all()
138 == ChangesetComment.COMMENT_TYPE_TODO)
139
140 if not show_outdated:
141 todos = todos.filter(
142 coalesce(ChangesetComment.display_state, '') !=
143 ChangesetComment.COMMENT_OUTDATED)
144
145 todos = todos.all()
141 146
142 147 return todos
143 148
144 149 def create(self, text, repo, user, commit_id=None, pull_request=None,
145 150 f_path=None, line_no=None, status_change=None,
146 151 status_change_type=None, comment_type=None,
147 152 resolves_comment_id=None, closing_pr=False, send_email=True,
148 153 renderer=None):
149 154 """
150 155 Creates new comment for commit or pull request.
151 156 IF status_change is not none this comment is associated with a
152 157 status change of commit or commit associated with pull request
153 158
154 159 :param text:
155 160 :param repo:
156 161 :param user:
157 162 :param commit_id:
158 163 :param pull_request:
159 164 :param f_path:
160 165 :param line_no:
161 166 :param status_change: Label for status change
162 167 :param comment_type: Type of comment
163 168 :param status_change_type: type of status change
164 169 :param closing_pr:
165 170 :param send_email:
166 171 :param renderer: pick renderer for this comment
167 172 """
168 173 if not text:
169 174 log.warning('Missing text for comment, skipping...')
170 175 return
171 176
172 177 if not renderer:
173 178 renderer = self._get_renderer()
174 179
175 180 repo = self._get_repo(repo)
176 181 user = self._get_user(user)
177 182
178 183 schema = comment_schema.CommentSchema()
179 184 validated_kwargs = schema.deserialize(dict(
180 185 comment_body=text,
181 186 comment_type=comment_type,
182 187 comment_file=f_path,
183 188 comment_line=line_no,
184 189 renderer_type=renderer,
185 190 status_change=status_change_type,
186 191 resolves_comment_id=resolves_comment_id,
187 192 repo=repo.repo_id,
188 193 user=user.user_id,
189 194 ))
190 195
191 196 comment = ChangesetComment()
192 197 comment.renderer = validated_kwargs['renderer_type']
193 198 comment.text = validated_kwargs['comment_body']
194 199 comment.f_path = validated_kwargs['comment_file']
195 200 comment.line_no = validated_kwargs['comment_line']
196 201 comment.comment_type = validated_kwargs['comment_type']
197 202
198 203 comment.repo = repo
199 204 comment.author = user
200 205 comment.resolved_comment = self.__get_commit_comment(
201 206 validated_kwargs['resolves_comment_id'])
202 207
203 208 pull_request_id = pull_request
204 209
205 210 commit_obj = None
206 211 pull_request_obj = None
207 212
208 213 if commit_id:
209 214 notification_type = EmailNotificationModel.TYPE_COMMIT_COMMENT
210 215 # do a lookup, so we don't pass something bad here
211 216 commit_obj = repo.scm_instance().get_commit(commit_id=commit_id)
212 217 comment.revision = commit_obj.raw_id
213 218
214 219 elif pull_request_id:
215 220 notification_type = EmailNotificationModel.TYPE_PULL_REQUEST_COMMENT
216 221 pull_request_obj = self.__get_pull_request(pull_request_id)
217 222 comment.pull_request = pull_request_obj
218 223 else:
219 224 raise Exception('Please specify commit or pull_request_id')
220 225
221 226 Session().add(comment)
222 227 Session().flush()
223 228 kwargs = {
224 229 'user': user,
225 230 'renderer_type': renderer,
226 231 'repo_name': repo.repo_name,
227 232 'status_change': status_change,
228 233 'status_change_type': status_change_type,
229 234 'comment_body': text,
230 235 'comment_file': f_path,
231 236 'comment_line': line_no,
232 237 }
233 238
234 239 if commit_obj:
235 240 recipients = ChangesetComment.get_users(
236 241 revision=commit_obj.raw_id)
237 242 # add commit author if it's in RhodeCode system
238 243 cs_author = User.get_from_cs_author(commit_obj.author)
239 244 if not cs_author:
240 245 # use repo owner if we cannot extract the author correctly
241 246 cs_author = repo.user
242 247 recipients += [cs_author]
243 248
244 249 commit_comment_url = self.get_url(comment)
245 250
246 251 target_repo_url = h.link_to(
247 252 repo.repo_name,
248 253 h.url('summary_home',
249 254 repo_name=repo.repo_name, qualified=True))
250 255
251 256 # commit specifics
252 257 kwargs.update({
253 258 'commit': commit_obj,
254 259 'commit_message': commit_obj.message,
255 260 'commit_target_repo': target_repo_url,
256 261 'commit_comment_url': commit_comment_url,
257 262 })
258 263
259 264 elif pull_request_obj:
260 265 # get the current participants of this pull request
261 266 recipients = ChangesetComment.get_users(
262 267 pull_request_id=pull_request_obj.pull_request_id)
263 268 # add pull request author
264 269 recipients += [pull_request_obj.author]
265 270
266 271 # add the reviewers to notification
267 272 recipients += [x.user for x in pull_request_obj.reviewers]
268 273
269 274 pr_target_repo = pull_request_obj.target_repo
270 275 pr_source_repo = pull_request_obj.source_repo
271 276
272 277 pr_comment_url = h.url(
273 278 'pullrequest_show',
274 279 repo_name=pr_target_repo.repo_name,
275 280 pull_request_id=pull_request_obj.pull_request_id,
276 281 anchor='comment-%s' % comment.comment_id,
277 282 qualified=True,)
278 283
279 284 # set some variables for email notification
280 285 pr_target_repo_url = h.url(
281 286 'summary_home', repo_name=pr_target_repo.repo_name,
282 287 qualified=True)
283 288
284 289 pr_source_repo_url = h.url(
285 290 'summary_home', repo_name=pr_source_repo.repo_name,
286 291 qualified=True)
287 292
288 293 # pull request specifics
289 294 kwargs.update({
290 295 'pull_request': pull_request_obj,
291 296 'pr_id': pull_request_obj.pull_request_id,
292 297 'pr_target_repo': pr_target_repo,
293 298 'pr_target_repo_url': pr_target_repo_url,
294 299 'pr_source_repo': pr_source_repo,
295 300 'pr_source_repo_url': pr_source_repo_url,
296 301 'pr_comment_url': pr_comment_url,
297 302 'pr_closing': closing_pr,
298 303 })
299 304 if send_email:
300 305 # pre-generate the subject for notification itself
301 306 (subject,
302 307 _h, _e, # we don't care about those
303 308 body_plaintext) = EmailNotificationModel().render_email(
304 309 notification_type, **kwargs)
305 310
306 311 mention_recipients = set(
307 312 self._extract_mentions(text)).difference(recipients)
308 313
309 314 # create notification objects, and emails
310 315 NotificationModel().create(
311 316 created_by=user,
312 317 notification_subject=subject,
313 318 notification_body=body_plaintext,
314 319 notification_type=notification_type,
315 320 recipients=recipients,
316 321 mention_recipients=mention_recipients,
317 322 email_kwargs=kwargs,
318 323 )
319 324
320 325 action = (
321 326 'user_commented_pull_request:{}'.format(
322 327 comment.pull_request.pull_request_id)
323 328 if comment.pull_request
324 329 else 'user_commented_revision:{}'.format(comment.revision)
325 330 )
326 331 action_logger(user, action, comment.repo)
327 332
328 333 registry = get_current_registry()
329 334 rhodecode_plugins = getattr(registry, 'rhodecode_plugins', {})
330 335 channelstream_config = rhodecode_plugins.get('channelstream', {})
331 336 msg_url = ''
332 337 if commit_obj:
333 338 msg_url = commit_comment_url
334 339 repo_name = repo.repo_name
335 340 elif pull_request_obj:
336 341 msg_url = pr_comment_url
337 342 repo_name = pr_target_repo.repo_name
338 343
339 344 if channelstream_config.get('enabled'):
340 345 message = '<strong>{}</strong> {} - ' \
341 346 '<a onclick="window.location=\'{}\';' \
342 347 'window.location.reload()">' \
343 348 '<strong>{}</strong></a>'
344 349 message = message.format(
345 350 user.username, _('made a comment'), msg_url,
346 351 _('Show it now'))
347 352 channel = '/repo${}$/pr/{}'.format(
348 353 repo_name,
349 354 pull_request_id
350 355 )
351 356 payload = {
352 357 'type': 'message',
353 358 'timestamp': datetime.utcnow(),
354 359 'user': 'system',
355 360 'exclude_users': [user.username],
356 361 'channel': channel,
357 362 'message': {
358 363 'message': message,
359 364 'level': 'info',
360 365 'topic': '/notifications'
361 366 }
362 367 }
363 368 channelstream_request(channelstream_config, [payload],
364 369 '/message', raise_exc=False)
365 370
366 371 return comment
367 372
368 373 def delete(self, comment):
369 374 """
370 375 Deletes given comment
371 376
372 377 :param comment_id:
373 378 """
374 379 comment = self.__get_commit_comment(comment)
375 380 Session().delete(comment)
376 381
377 382 return comment
378 383
379 384 def get_all_comments(self, repo_id, revision=None, pull_request=None):
380 385 q = ChangesetComment.query()\
381 386 .filter(ChangesetComment.repo_id == repo_id)
382 387 if revision:
383 388 q = q.filter(ChangesetComment.revision == revision)
384 389 elif pull_request:
385 390 pull_request = self.__get_pull_request(pull_request)
386 391 q = q.filter(ChangesetComment.pull_request == pull_request)
387 392 else:
388 393 raise Exception('Please specify commit or pull_request')
389 394 q = q.order_by(ChangesetComment.created_on)
390 395 return q.all()
391 396
392 397 def get_url(self, comment):
393 398 comment = self.__get_commit_comment(comment)
394 399 if comment.pull_request:
395 400 return h.url(
396 401 'pullrequest_show',
397 402 repo_name=comment.pull_request.target_repo.repo_name,
398 403 pull_request_id=comment.pull_request.pull_request_id,
399 404 anchor='comment-%s' % comment.comment_id,
400 405 qualified=True,)
401 406 else:
402 407 return h.url(
403 408 'changeset_home',
404 409 repo_name=comment.repo.repo_name,
405 410 revision=comment.revision,
406 411 anchor='comment-%s' % comment.comment_id,
407 412 qualified=True,)
408 413
409 414 def get_comments(self, repo_id, revision=None, pull_request=None):
410 415 """
411 416 Gets main comments based on revision or pull_request_id
412 417
413 418 :param repo_id:
414 419 :param revision:
415 420 :param pull_request:
416 421 """
417 422
418 423 q = ChangesetComment.query()\
419 424 .filter(ChangesetComment.repo_id == repo_id)\
420 425 .filter(ChangesetComment.line_no == None)\
421 426 .filter(ChangesetComment.f_path == None)
422 427 if revision:
423 428 q = q.filter(ChangesetComment.revision == revision)
424 429 elif pull_request:
425 430 pull_request = self.__get_pull_request(pull_request)
426 431 q = q.filter(ChangesetComment.pull_request == pull_request)
427 432 else:
428 433 raise Exception('Please specify commit or pull_request')
429 434 q = q.order_by(ChangesetComment.created_on)
430 435 return q.all()
431 436
432 437 def get_inline_comments(self, repo_id, revision=None, pull_request=None):
433 438 q = self._get_inline_comments_query(repo_id, revision, pull_request)
434 439 return self._group_comments_by_path_and_line_number(q)
435 440
436 441 def get_inline_comments_count(self, inline_comments, skip_outdated=True,
437 442 version=None):
438 443 inline_cnt = 0
439 444 for fname, per_line_comments in inline_comments.iteritems():
440 445 for lno, comments in per_line_comments.iteritems():
441 446 for comm in comments:
442 447 if not comm.outdated_at_version(version) and skip_outdated:
443 448 inline_cnt += 1
444 449
445 450 return inline_cnt
446 451
447 452 def get_outdated_comments(self, repo_id, pull_request):
448 453 # TODO: johbo: Remove `repo_id`, it is not needed to find the comments
449 454 # of a pull request.
450 455 q = self._all_inline_comments_of_pull_request(pull_request)
451 456 q = q.filter(
452 457 ChangesetComment.display_state ==
453 458 ChangesetComment.COMMENT_OUTDATED
454 459 ).order_by(ChangesetComment.comment_id.asc())
455 460
456 461 return self._group_comments_by_path_and_line_number(q)
457 462
458 463 def _get_inline_comments_query(self, repo_id, revision, pull_request):
459 464 # TODO: johbo: Split this into two methods: One for PR and one for
460 465 # commit.
461 466 if revision:
462 467 q = Session().query(ChangesetComment).filter(
463 468 ChangesetComment.repo_id == repo_id,
464 469 ChangesetComment.line_no != null(),
465 470 ChangesetComment.f_path != null(),
466 471 ChangesetComment.revision == revision)
467 472
468 473 elif pull_request:
469 474 pull_request = self.__get_pull_request(pull_request)
470 475 if not CommentsModel.use_outdated_comments(pull_request):
471 476 q = self._visible_inline_comments_of_pull_request(pull_request)
472 477 else:
473 478 q = self._all_inline_comments_of_pull_request(pull_request)
474 479
475 480 else:
476 481 raise Exception('Please specify commit or pull_request_id')
477 482 q = q.order_by(ChangesetComment.comment_id.asc())
478 483 return q
479 484
480 485 def _group_comments_by_path_and_line_number(self, q):
481 486 comments = q.all()
482 487 paths = collections.defaultdict(lambda: collections.defaultdict(list))
483 488 for co in comments:
484 489 paths[co.f_path][co.line_no].append(co)
485 490 return paths
486 491
487 492 @classmethod
488 493 def needed_extra_diff_context(cls):
489 494 return max(cls.DIFF_CONTEXT_BEFORE, cls.DIFF_CONTEXT_AFTER)
490 495
491 496 def outdate_comments(self, pull_request, old_diff_data, new_diff_data):
492 497 if not CommentsModel.use_outdated_comments(pull_request):
493 498 return
494 499
495 500 comments = self._visible_inline_comments_of_pull_request(pull_request)
496 501 comments_to_outdate = comments.all()
497 502
498 503 for comment in comments_to_outdate:
499 504 self._outdate_one_comment(comment, old_diff_data, new_diff_data)
500 505
501 506 def _outdate_one_comment(self, comment, old_diff_proc, new_diff_proc):
502 507 diff_line = _parse_comment_line_number(comment.line_no)
503 508
504 509 try:
505 510 old_context = old_diff_proc.get_context_of_line(
506 511 path=comment.f_path, diff_line=diff_line)
507 512 new_context = new_diff_proc.get_context_of_line(
508 513 path=comment.f_path, diff_line=diff_line)
509 514 except (diffs.LineNotInDiffException,
510 515 diffs.FileNotInDiffException):
511 516 comment.display_state = ChangesetComment.COMMENT_OUTDATED
512 517 return
513 518
514 519 if old_context == new_context:
515 520 return
516 521
517 522 if self._should_relocate_diff_line(diff_line):
518 523 new_diff_lines = new_diff_proc.find_context(
519 524 path=comment.f_path, context=old_context,
520 525 offset=self.DIFF_CONTEXT_BEFORE)
521 526 if not new_diff_lines:
522 527 comment.display_state = ChangesetComment.COMMENT_OUTDATED
523 528 else:
524 529 new_diff_line = self._choose_closest_diff_line(
525 530 diff_line, new_diff_lines)
526 531 comment.line_no = _diff_to_comment_line_number(new_diff_line)
527 532 else:
528 533 comment.display_state = ChangesetComment.COMMENT_OUTDATED
529 534
530 535 def _should_relocate_diff_line(self, diff_line):
531 536 """
532 537 Checks if relocation shall be tried for the given `diff_line`.
533 538
534 539 If a comment points into the first lines, then we can have a situation
535 540 that after an update another line has been added on top. In this case
536 541 we would find the context still and move the comment around. This
537 542 would be wrong.
538 543 """
539 544 should_relocate = (
540 545 (diff_line.new and diff_line.new > self.DIFF_CONTEXT_BEFORE) or
541 546 (diff_line.old and diff_line.old > self.DIFF_CONTEXT_BEFORE))
542 547 return should_relocate
543 548
544 549 def _choose_closest_diff_line(self, diff_line, new_diff_lines):
545 550 candidate = new_diff_lines[0]
546 551 best_delta = _diff_line_delta(diff_line, candidate)
547 552 for new_diff_line in new_diff_lines[1:]:
548 553 delta = _diff_line_delta(diff_line, new_diff_line)
549 554 if delta < best_delta:
550 555 candidate = new_diff_line
551 556 best_delta = delta
552 557 return candidate
553 558
554 559 def _visible_inline_comments_of_pull_request(self, pull_request):
555 560 comments = self._all_inline_comments_of_pull_request(pull_request)
556 561 comments = comments.filter(
557 562 coalesce(ChangesetComment.display_state, '') !=
558 563 ChangesetComment.COMMENT_OUTDATED)
559 564 return comments
560 565
561 566 def _all_inline_comments_of_pull_request(self, pull_request):
562 567 comments = Session().query(ChangesetComment)\
563 568 .filter(ChangesetComment.line_no != None)\
564 569 .filter(ChangesetComment.f_path != None)\
565 570 .filter(ChangesetComment.pull_request == pull_request)
566 571 return comments
567 572
568 573 def _all_general_comments_of_pull_request(self, pull_request):
569 574 comments = Session().query(ChangesetComment)\
570 575 .filter(ChangesetComment.line_no == None)\
571 576 .filter(ChangesetComment.f_path == None)\
572 577 .filter(ChangesetComment.pull_request == pull_request)
573 578 return comments
574 579
575 580 @staticmethod
576 581 def use_outdated_comments(pull_request):
577 582 settings_model = VcsSettingsModel(repo=pull_request.target_repo)
578 583 settings = settings_model.get_general_settings()
579 584 return settings.get('rhodecode_use_outdated_comments', False)
580 585
581 586
582 587 def _parse_comment_line_number(line_no):
583 588 """
584 589 Parses line numbers of the form "(o|n)\d+" and returns them in a tuple.
585 590 """
586 591 old_line = None
587 592 new_line = None
588 593 if line_no.startswith('o'):
589 594 old_line = int(line_no[1:])
590 595 elif line_no.startswith('n'):
591 596 new_line = int(line_no[1:])
592 597 else:
593 598 raise ValueError("Comment lines have to start with either 'o' or 'n'.")
594 599 return diffs.DiffLineNumber(old_line, new_line)
595 600
596 601
597 602 def _diff_to_comment_line_number(diff_line):
598 603 if diff_line.new is not None:
599 604 return u'n{}'.format(diff_line.new)
600 605 elif diff_line.old is not None:
601 606 return u'o{}'.format(diff_line.old)
602 607 return u''
603 608
604 609
605 610 def _diff_line_delta(a, b):
606 611 if None not in (a.new, b.new):
607 612 return abs(a.new - b.new)
608 613 elif None not in (a.old, b.old):
609 614 return abs(a.old - b.old)
610 615 else:
611 616 raise ValueError(
612 617 "Cannot compute delta between {} and {}".format(a, b))
General Comments 0
You need to be logged in to leave comments. Login now