From 9aac3403783288da2a840e36a75c8d5a69d7e897 Mon Sep 17 00:00:00 2001 From: Kale Kundert Date: Sun, 2 Feb 2020 02:02:38 -0500 Subject: [PATCH 1/4] Prototype the regex debugger. --- src/_pytest/assertion/rewrite.py | 17 +++++++- src/_pytest/python_api.py | 68 +++++++++++++++++++++++++++++++- src/pytest/__init__.py | 3 ++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index ab5e63a1e0c..354a3009788 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -435,6 +435,13 @@ def _call_reprcompare(ops, results, expls, each_obj): return expl +def _call_reprcall(callable, args, kwargs, result, default): + if hasattr(result, '_pytest_raw_repr'): + return saferepr(result).replace('\n', '\n~') + else: + return default + + def _call_assertion_pass(lineno, orig, expl): # type: (int, str, str) -> None if util._assertion_pass is not None: @@ -966,7 +973,15 @@ def visit_Call(self, call): res = self.assign(new_call) res_expl = self.explanation_param(self.display(res)) outer_expl = "{}\n{{{} = {}\n}}".format(res_expl, res_expl, expl) - return res, outer_expl + expl_call = self.helper( + "_call_reprcall", + new_func, + ast.List(new_args, ast.Load()), + ast.List(new_kwargs, ast.Load()), + res, + ast.Str(outer_expl), + ) + return res, self.explanation_param(expl_call) def visit_Starred(self, starred): # From Python 3.5, a Starred node can appear in a function call diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 24145016ce6..94e05703dd3 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -715,7 +715,6 @@ def raises( # noqa: F811 ) fail(message) - raises.Exception = fail.Exception # type: ignore @@ -755,3 +754,70 @@ def __exit__( if self.match_expr is not None: self.excinfo.match(self.match_expr) return True + + +# builtin regular expression helpers + + +def match(pattern, string, flags=0): + import regex as re + return RegexDebugger(re.match, pattern, string, flags) + +def search(pattern, string, flags=0): + import regex as re + return RegexDebugger(re.search, pattern, string, flags) + +def fullmatch(pattern, string, flags=0): + import regex as re + return RegexDebugger(re.fullmatch, pattern, string, flags) + +class RegexDebugger: + _pytest_raw_repr = True + + def __init__(self, matcher, pattern, string, flags): + self.caller = inspect.stack()[1] + self.matcher = matcher + self.pattern = pattern + self.string = string + self.flags = flags + + def __bool__(self): + return self.matcher(self.pattern, self.string, self.flags) is not None + + def __repr__(self): + import regex as re + from textwrap import shorten + + def mark_errors(errors): + markers = [' '] * (max(errors) + 1) + for i in errors: + markers[i] = '^' + return ''.join(markers) + + fuzzy_pattern = '(?:%s){e}' % self.pattern + fuzzy_match = self.matcher( + fuzzy_pattern, + self.string, + self.flags | re.ENHANCEMATCH, + ) + errors = fuzzy_match.fuzzy_changes + error_names = ( + f'insertions: ', + f'deletions: ', + f'substitutions:', + ) + + + s = lambda x: shorten(repr(x), width=20, placeholder='...') + lines = [ + f'pytest.{self.caller.function}({s(self.pattern)}, {s(self.string)}, flags={self.flags})' + f'', + f'', + f'pattern: {self.pattern}', + f'string: {self.string}', + ] + for error, name in zip(errors, error_names): + if error: + lines += [f'{name} {mark_errors(error)}'] + + return '\n'.join(lines) diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index 7b79603afc6..e9bac07ba7e 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -34,6 +34,9 @@ from _pytest.python import Package from _pytest.python_api import approx from _pytest.python_api import raises +from _pytest.python_api import match +from _pytest.python_api import search +from _pytest.python_api import fullmatch from _pytest.recwarn import deprecated_call from _pytest.recwarn import warns from _pytest.warning_types import PytestAssertRewriteWarning From 74bb58187f1c912f970c28c8781a90de6c2d3c03 Mon Sep 17 00:00:00 2001 From: Kale Kundert Date: Sun, 2 Feb 2020 11:31:49 -0500 Subject: [PATCH 2/4] Automatically detect re.match() and friends. --- src/_pytest/assertion/rewrite.py | 23 ++++++++--- src/_pytest/assertion/util.py | 43 ++++++++++++++++++++ src/_pytest/python_api.py | 68 +------------------------------- 3 files changed, 61 insertions(+), 73 deletions(-) diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index 354a3009788..aca8cf5014e 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -435,11 +435,22 @@ def _call_reprcompare(ops, results, expls, each_obj): return expl -def _call_reprcall(callable, args, kwargs, result, default): - if hasattr(result, '_pytest_raw_repr'): - return saferepr(result).replace('\n', '\n~') +def _call_reprcall(callable, args, kwargs, result, expl_callable, expl_result): + import re, regex + regex_callables = { + re.match, regex.match, + re.search, regex.search, + re.fullmatch, regex.fullmatch, + } + if callable in regex_callables: + # I'll have to figure out how to deal with kwargs/optional flags. + pattern, string = args + return util._call_regex(callable, pattern, string, 0, result).replace('\n', '\n~') + else: - return default + return "{}\n{{{} = {}\n}}".format(expl_result, expl_result, expl_callable) + + def _call_assertion_pass(lineno, orig, expl): @@ -972,14 +983,14 @@ def visit_Call(self, call): new_call = ast.Call(new_func, new_args, new_kwargs) res = self.assign(new_call) res_expl = self.explanation_param(self.display(res)) - outer_expl = "{}\n{{{} = {}\n}}".format(res_expl, res_expl, expl) expl_call = self.helper( "_call_reprcall", new_func, ast.List(new_args, ast.Load()), ast.List(new_kwargs, ast.Load()), res, - ast.Str(outer_expl), + ast.Str(expl), + ast.Str(res_expl), ) return res, self.explanation_param(expl_call) diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index 67f8d46185e..ed8659bedb2 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -438,6 +438,49 @@ def _compare_eq_cls( return explanation +def _call_regex(callable, pattern, string, flags, result): + import re, regex + + def mark_errors(errors): + markers = [' '] * (max(errors) + 1) + for i in errors: + markers[i] = '^' + return ''.join(markers) + + fuzzy_matchers = { + re.match: regex.match, + re.search: regex.search, + re.fullmatch: regex.fullmatch, + } + fuzzy_matcher = fuzzy_matchers.get(callable, callable) + fuzzy_pattern = '(?:%s){e}' % pattern + fuzzy_match = fuzzy_matcher( + fuzzy_pattern, + string, + flags | regex.ENHANCEMATCH, + ) + errors = fuzzy_match.fuzzy_changes + error_names = ( + f'insertions: ', + f'deletions: ', + f'substitutions:', + ) + + trunc = lambda x: x[:17] + '...' if len(x) > 20 else x + lines = [ + f'{callable.__module__}.{callable.__qualname__}({trunc(pattern)}, {trunc(string)}, flags={flags})' + f'', + f'', + f'pattern: {pattern}', + f'string: {string}', + ] + for error, name in zip(errors, error_names): + if error: + lines += [f'{name} {mark_errors(error)}'] + + return '\n'.join(lines) + + def _notin_text(term: str, text: str, verbose: int = 0) -> List[str]: index = text.find(term) head = text[:index] diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 94e05703dd3..24145016ce6 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -715,6 +715,7 @@ def raises( # noqa: F811 ) fail(message) + raises.Exception = fail.Exception # type: ignore @@ -754,70 +755,3 @@ def __exit__( if self.match_expr is not None: self.excinfo.match(self.match_expr) return True - - -# builtin regular expression helpers - - -def match(pattern, string, flags=0): - import regex as re - return RegexDebugger(re.match, pattern, string, flags) - -def search(pattern, string, flags=0): - import regex as re - return RegexDebugger(re.search, pattern, string, flags) - -def fullmatch(pattern, string, flags=0): - import regex as re - return RegexDebugger(re.fullmatch, pattern, string, flags) - -class RegexDebugger: - _pytest_raw_repr = True - - def __init__(self, matcher, pattern, string, flags): - self.caller = inspect.stack()[1] - self.matcher = matcher - self.pattern = pattern - self.string = string - self.flags = flags - - def __bool__(self): - return self.matcher(self.pattern, self.string, self.flags) is not None - - def __repr__(self): - import regex as re - from textwrap import shorten - - def mark_errors(errors): - markers = [' '] * (max(errors) + 1) - for i in errors: - markers[i] = '^' - return ''.join(markers) - - fuzzy_pattern = '(?:%s){e}' % self.pattern - fuzzy_match = self.matcher( - fuzzy_pattern, - self.string, - self.flags | re.ENHANCEMATCH, - ) - errors = fuzzy_match.fuzzy_changes - error_names = ( - f'insertions: ', - f'deletions: ', - f'substitutions:', - ) - - - s = lambda x: shorten(repr(x), width=20, placeholder='...') - lines = [ - f'pytest.{self.caller.function}({s(self.pattern)}, {s(self.string)}, flags={self.flags})' - f'', - f'', - f'pattern: {self.pattern}', - f'string: {self.string}', - ] - for error, name in zip(errors, error_names): - if error: - lines += [f'{name} {mark_errors(error)}'] - - return '\n'.join(lines) From f984f30ab505edf1f1c6ffcf04607fc0ca586470 Mon Sep 17 00:00:00 2001 From: Kale Kundert Date: Sun, 2 Feb 2020 11:34:51 -0500 Subject: [PATCH 3/4] Remove the API functions I added previously. --- src/pytest/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/pytest/__init__.py b/src/pytest/__init__.py index e9bac07ba7e..7b79603afc6 100644 --- a/src/pytest/__init__.py +++ b/src/pytest/__init__.py @@ -34,9 +34,6 @@ from _pytest.python import Package from _pytest.python_api import approx from _pytest.python_api import raises -from _pytest.python_api import match -from _pytest.python_api import search -from _pytest.python_api import fullmatch from _pytest.recwarn import deprecated_call from _pytest.recwarn import warns from _pytest.warning_types import PytestAssertRewriteWarning From fd2d9332ff8f4263ca6443480669964652ab7b56 Mon Sep 17 00:00:00 2001 From: Kale Kundert Date: Sun, 2 Feb 2020 11:43:23 -0500 Subject: [PATCH 4/4] Fix how the argument reprs are truncated. --- src/_pytest/assertion/util.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index ed8659bedb2..ad7bd2c82ff 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -466,7 +466,8 @@ def mark_errors(errors): f'substitutions:', ) - trunc = lambda x: x[:17] + '...' if len(x) > 20 else x + n = 8 + trunc = lambda x: f"'{x[:n-1]}…'" if len(x) > n else repr(x) lines = [ f'{callable.__module__}.{callable.__qualname__}({trunc(pattern)}, {trunc(string)}, flags={flags})' f'',