"""
Checks for got-vs-want statements
A "got-string" is data produced by a doctest that we want to check matches some
expected value.
A "want-string" is a representation of the output we expect, if the
"got-string" is different than the "want-string" the doctest will fail with a
:class:`GotWantException`. A want string should come directly after a doctest
and should not be prefixed by the three cheverons (``>>> ``).
There are two types of data that a doctest could "get" as a "got-string",
either the contents of standard out the value of an expression itself.
A doctest that uses stdout might look like this
.. code:: python
>>> print('We expect this exact string')
We expect this exact string
A doctest that uses a raw expression might look like this
.. code:: python
>>> def foo():
>>> return 3
>>> foo()
3
In most cases it is best to use stdout to write your got-want tests because it
is easier to control strings sent to stdout than it is to control the
representation of expression-based "got-strings".
"""
from __future__ import annotations
import difflib
import doctest
import math
import re
import typing
from collections.abc import Mapping
from typing import Dict, Set
from typing import OrderedDict as OrderedDictType
from xdoctest import constants, directive, utils
unicode_literal_re = re.compile(r'(\W|^)[uU]([rR]?[\'\"])', re.UNICODE)
bytes_literal_re = re.compile(r'(\W|^)[bB]([rR]?[\'\"])', re.UNICODE)
BLANKLINE_MARKER = '<BLANKLINE>'
ELLIPSIS_MARKER = '...'
TRAILING_WS = re.compile(r'[ \t]*$', re.UNICODE | re.MULTILINE)
_FLOAT_CMP_NUMBER_RE = re.compile(
r'(?<![^\s=,:;*+\-/%<>&|^~\(\[\{])'
r'(?P<number>[+-]?(?:'
r'(?:\d+(?:\.\d*)?|\.\d+)'
r'(?:[eE][+-]?\d+)?'
r'|inf(?:inity)?|nan'
r'))'
r'(?![^\s=,:;*+\-/%<>&|^~\)\]\}jJ])',
re.IGNORECASE,
)
_FLOAT_CMP_PLACEHOLDER = '\x00'
_EXCEPTION_RE = re.compile(
r"""
# Grab the traceback header. Different versions of Python have
# said different things on the first traceback line.
^(?P<hdr> Traceback\ \(
(?: most\ recent\ call\ last
| innermost\ last
) \) :
)
\s* $ # toss trailing whitespace on the header.
(?P<stack> .*?) # don't blink: absorb stuff until...
^ (?P<msg> \w+ .*) # a line *starts* with alphanum.
""",
re.VERBOSE | re.MULTILINE | re.DOTALL,
)
[docs]
def check_got_vs_want(
want: str,
got_stdout: str,
got_eval: typing.Any = constants.NOT_EVALED,
runstate: directive.RuntimeState | None = None,
) -> bool:
"""
Determines to check against either got_stdout or got_eval, and then does
the comparison.
If both stdout and eval "got" outputs are specified, then the "want"
target may match either value.
Args:
want (str): target to match against
got_stdout (str): output from stdout
got_eval (str): output from an eval statement.
runstate (xdoctest.directive.RuntimeState | None): current state
Raises:
GotWantException - If the "got" differs from this parts want.
"""
# If we did not want anything than ignore eval and stdout
if got_eval is constants.NOT_EVALED:
# if there was no eval, check stdout
got = got_stdout
flag = check_output(got, want, runstate)
else:
if not got_stdout:
# If there was no stdout then use eval value.
try:
got = repr(got_eval)
except Exception as ex:
raise ExtractGotReprException(
'Error calling repr for {}. Caused by: {!r}'.format(
type(got_eval), ex
),
ex,
)
flag = check_output(got, want, runstate)
else:
# If there was eval and stdout, defer to stdout
# but allow fallback on the eval.
got = got_stdout
flag = check_output(got, want, runstate)
if not flag:
# allow eval to fallback and save us, but if it fails, do a
# diff with stdout
got = repr(got_eval)
flag = check_output(got, want, runstate)
if not flag:
got = got_stdout
if not flag:
msg = 'got differs with doctest want'
exp = GotWantException(msg, got, want)
raise exp
return flag
[docs]
def _strip_exception_details(msg: str) -> str:
"""
Args:
msg (str):
Returns:
str:
"""
# Support for IGNORE_EXCEPTION_DETAIL.
# Get rid of everything except the exception name; in particular, drop
# the possibly dotted module path (if any) and the exception message (if
# any). We assume that a colon is never part of a dotted name, or of an
# exception name.
# E.g., given
# "foo.bar.MyError: la di da"
# return "MyError"
# Or for "abc.def" or "abc.def:\n" return "def".
start, end = 0, len(msg)
# The exception name must appear on the first line.
i = msg.find('\n')
if i >= 0:
end = i
# retain up to the first colon (if any)
i = msg.find(':', 0, end)
if i >= 0:
end = i
# retain just the exception name
i = msg.rfind('.', 0, end)
if i >= 0:
start = i + 1
return msg[start:end]
[docs]
def check_exception(
exc_got: str,
want: str,
runstate: directive.RuntimeState | None = None,
) -> bool:
"""
Checks want against an exception
Args:
exc_got (str): the exception message
want (str): target to match against
runstate (xdoctest.directive.RuntimeState | None): current state
Raises:
GotWantException - If the "got" differs from this parts want.
Returns:
bool: True if got matches want
"""
exc_want = extract_exc_want(want)
if exc_want is None:
# Reraise the error if the want message is formatted like an exception
raise
flag = check_output(exc_got, exc_want, runstate)
# print('exc_want = {!r}'.format(exc_want))
# print('exc_got = {!r}'.format(exc_got))
# print('flag = {!r}'.format(flag))
if runstate is None:
runstate = directive.RuntimeState()
if not flag and runstate['IGNORE_EXCEPTION_DETAIL']:
exc_got1 = _strip_exception_details(exc_got)
exc_want1 = _strip_exception_details(exc_want)
flag = check_output(exc_got1, exc_want1, runstate)
if flag:
exc_got = exc_got1
exc_want = exc_want1
if not flag:
msg = 'exception message is different'
ex = GotWantException(msg, exc_got, exc_want)
raise ex
return flag
[docs]
def _xdoctest_check_output(
got: str,
want: str,
runstate: directive.RuntimeState | None = None,
) -> bool:
"""
Does the actual comparison between `got` and `want` as long as the check is
enabled.
Args:
got (str): text produced by the test
want (str): target to match against
runstate (xdoctest.directive.RuntimeState | None): current state
Returns:
bool: True if got matches want or if the check is disabled
"""
if not want: # nocover
return True
if want:
if runstate is None:
runstate = directive.RuntimeState()
if runstate['IGNORE_OUTPUT']:
return True
# Try default
if got == want:
return True
got, want = normalize(got, want, runstate)
return _check_match(got, want, runstate)
return False
[docs]
def _coerce_runstate(
runstate: directive.RuntimeState | Mapping[str, object] | None,
) -> directive.RuntimeState:
"""Normalize compatibility mappings without taxing native state.
Real :class:`RuntimeState` objects are returned unchanged. Sparse mappings
take the pre-fast-path compatibility route through stdlib option bits,
yielding a complete runtime state before native matching accesses flags.
Example:
>>> state = _coerce_runstate({'_output_checker': 'xdoctest'})
>>> isinstance(state, directive.RuntimeState)
True
>>> state.get_output_checker()
'xdoctest'
"""
if isinstance(runstate, directive.RuntimeState):
return runstate
if runstate is None:
return directive.RuntimeState()
from xdoctest import checker_facade
optionflags = checker_facade.runtime_state_to_optionflags(runstate)
normalized = checker_facade.optionflags_to_runtime_state(optionflags)
normalized.set_output_checker(
str(runstate.get('_output_checker', 'xdoctest'))
)
return normalized
[docs]
def check_output(
got: str,
want: str,
runstate: directive.RuntimeState | Mapping[str, object] | None = None,
) -> bool:
"""
Check output using the currently configured output checker backend.
The default ``'xdoctest'`` checker consumes the structured runtime state
directly. Only when a foreign (registered) checker is selected are the
runtime state and checker-only bits packed into a stdlib-shaped
optionflags ``int`` — conversion happens at the boundary, not on the
native path.
Args:
got (str): text produced by the test
want (str): target to match against
runstate (xdoctest.directive.RuntimeState | None): current state
Returns:
bool: True if got matches want or if the check is disabled
"""
if not want: # nocover
return True
runstate = _coerce_runstate(runstate)
checker_name = runstate.get_output_checker()
if checker_name == 'xdoctest':
return _xdoctest_check_output(got, want, runstate)
from xdoctest import checker_facade
optionflags = checker_facade.runtime_state_to_optionflags(runstate)
output_checker = checker_facade.resolve_checker(checker_name, runstate)
return bool(output_checker.check_output(want, got, optionflags))
[docs]
def _check_match(
got: str, want: str, runstate: directive.RuntimeState | dict
) -> bool:
"""
Does the actual comparison between `got` and `want`
Args:
got (str): normalized text produced by the test
want (str): normalized target to match against
runstate (xdoctest.directive.RuntimeState): current state
Returns:
bool: True if got matches want
"""
if got == want:
return True
if runstate['FLOAT_CMP']:
if _float_cmp_match(got, want, runstate):
return True
if runstate['ELLIPSIS']:
if _ellipsis_match(got, want):
return True
return False
[docs]
def _ellipsis_match(got: typing.Any, want: typing.Any) -> bool:
r"""
The ellipsis matching algorithm taken directly from standard doctest.
Worst-case linear-time ellipsis matching.
Args:
got (str):
want (str):
Returns:
bool: True if the text matches according to the ellipsis rule
CommandLine:
python -m xdoctest.checker _ellipsis_match
Example:
>>> _ellipsis_match('aaa', 'aa...aa')
False
>>> _ellipsis_match('anything', '...')
True
>>> _ellipsis_match('prefix-anything', 'prefix-...')
True
>>> _ellipsis_match('suffix-anything', 'prefix-...')
False
>>> _ellipsis_match('foo', '... foo')
True
>>> _ellipsis_match('took=3.4s', 'took=...s')
True
>>> _ellipsis_match('best=3.4s ave=4.5s', 'best=...s ave=...s')
True
>>> _ellipsis_match('took: 1.16e-05 s\nbest=9.63e-07 s ave=1.002e-06 ± 3e-08 s\n',
>>> 'took: ...s\nbest=...s ave=...s\n')
True
"""
if ELLIPSIS_MARKER not in want:
return want == got
# Find "the real" strings.
# ws = want.split(ELLIPSIS_MARKER)
# MODIFICATION: the ellipsis consumes all whitespace around it
# for compatibility with whitespace normalization.
ws = re.split(
r'\s*{}\s*'.format(re.escape(ELLIPSIS_MARKER)), want, flags=re.MULTILINE
)
assert len(ws) >= 2
# Deal with exact matches possibly needed at one or both ends.
startpos, endpos = 0, len(got)
w = ws[0]
if w: # starts with exact match
if got.startswith(w):
startpos = len(w)
del ws[0]
else:
return False
w = ws[-1]
if w: # ends with exact match
if got.endswith(w):
endpos -= len(w)
del ws[-1]
else:
return False
if startpos > endpos:
# Exact end matches required more characters than we have, as in
# _ellipsis_match('aa...aa', 'aaa')
return False
# For the rest, we only need to find the leftmost non-overlapping
# match for each piece. If there's no overall match that way alone,
# there's no overall match period.
for w in ws:
# w may be '' at times, if there are consecutive ellipses, or
# due to an ellipsis at the start or end of `want`. That's OK.
# Search for an empty string succeeds, and doesn't change startpos.
startpos = got.find(w, startpos, endpos)
if startpos < 0:
return False
startpos += len(w)
return True
[docs]
def _float_cmp_match(
got: str, want: str, runstate: directive.RuntimeState | dict
) -> bool:
"""Compare numeric substrings approximately while preserving text rules.
Numeric fields are replaced with a neutral placeholder before ordinary
text or ellipsis matching. Numeric values are then compared separately,
with ellipses allowed to consume zero or more numeric fields as well as
arbitrary text.
"""
allow_ellipsis = bool(runstate['ELLIPSIS'])
want_chunks = (
want.split(ELLIPSIS_MARKER) if allow_ellipsis else [want]
)
got_structure = _replace_float_cmp_numbers(got)
want_structure = ELLIPSIS_MARKER.join(
_replace_float_cmp_numbers(chunk) for chunk in want_chunks
)
if allow_ellipsis:
if not _ellipsis_match(got_structure, want_structure):
return False
elif got_structure != want_structure:
return False
got_numbers = _find_float_cmp_numbers(got)
want_number_chunks = [
_find_float_cmp_numbers(chunk) for chunk in want_chunks
]
return _float_cmp_number_chunks_match(got_numbers, want_number_chunks)
[docs]
def _find_float_cmp_numbers(text: str) -> list[str]:
"""Return independently formatted numeric fields from ``text``."""
return [
match.group('number') for match in _FLOAT_CMP_NUMBER_RE.finditer(text)
]
[docs]
def _replace_float_cmp_numbers(text: str) -> str:
"""Replace recognized numeric fields without disturbing surrounding text."""
return _FLOAT_CMP_NUMBER_RE.sub(_FLOAT_CMP_PLACEHOLDER, text)
[docs]
def _float_cmp_number_lists_equal(got: list[str], want: list[str]) -> bool:
return len(got) == len(want) and all(
_float_cmp_numeric_equal(got_value, want_value)
for got_value, want_value in zip(got, want)
)
[docs]
def _float_cmp_number_chunks_match(
got_numbers: list[str], want_chunks: list[list[str]]
) -> bool:
"""Match numeric chunks separated by expected ellipses."""
if len(want_chunks) == 1:
return _float_cmp_number_lists_equal(got_numbers, want_chunks[0])
first = want_chunks[0]
if not _float_cmp_number_lists_equal(got_numbers[: len(first)], first):
return False
start = len(first)
last = want_chunks[-1]
stop = len(got_numbers) - len(last)
if stop < start or not _float_cmp_number_lists_equal(
got_numbers[stop:], last
):
return False
for chunk in want_chunks[1:-1]:
if not chunk:
continue
width = len(chunk)
found = next(
(
candidate
for candidate in range(start, stop - width + 1)
if _float_cmp_number_lists_equal(
got_numbers[candidate : candidate + width], chunk
)
),
None,
)
if found is None:
return False
start = found + width
return start <= stop
[docs]
def _float_cmp_numeric_equal(got_text: str, want_text: str) -> bool:
got_value = _parse_float_cmp_number(got_text)
want_value = _parse_float_cmp_number(want_text)
if math.isnan(got_value) or math.isnan(want_value):
return math.isnan(got_value) and math.isnan(want_value)
if math.isinf(got_value) or math.isinf(want_value):
return got_value == want_value
return math.isclose(got_value, want_value, rel_tol=1e-5, abs_tol=1e-8)
[docs]
def _parse_float_cmp_number(text: str) -> float:
lowered = text.lower()
if lowered in {'nan', '+nan', '-nan'}:
return float('nan')
if lowered in {'inf', '+inf', 'infinity', '+infinity'}:
return float('inf')
if lowered in {'-inf', '-infinity'}:
return float('-inf')
return float(text)
[docs]
def normalize(
got: str,
want: str,
runstate: directive.RuntimeState
| Dict[str, bool | Set[str]]
| OrderedDictType[str, bool | Set[str]]
| None = None,
) -> tuple[str, str]:
r"""
Normalizes the got and want string based on the runtime state.
Adapted from doctest_nose_plugin.py from the nltk project:
https://github.com/nltk/nltk
Further extended to also support byte literals.
Args:
got (str): unnormalized got str.
want (str): unnormalized want str.
runstate (xdoctest.directive.RuntimeState | None): current state
Returns:
Tuple[str, str]:
The normalized got and want str
Example:
>>> from xdoctest.checker import * # NOQA
>>> want = "...\n(0, 2, {'weight': 1})\n(0, 3, {'weight': 2})"
>>> got = "(0, 2, {'weight': 1})\n(0, 3, {'weight': 2})"
>>> normalize(got, want)
("(0, 2, {'weight': 1}) (0, 3, {'weight': 2})",
"... (0, 2, {'weight': 1}) (0, 3, {'weight': 2})")
"""
if runstate is None:
runstate = directive.RuntimeState()
def remove_prefixes(regex: str | re.Pattern[str], text: str) -> str:
return re.sub(regex, r'\1\2', text)
def visible_text(lines: list[str]) -> list[str]:
# TODO: backspaces
# Any lines that end with only a carriage return are erased
return [line for line in lines if not line.endswith('\r')]
# Remove terminal colors
if True:
got = utils.strip_ansi(got)
want = utils.strip_ansi(want)
if True:
# normalize python 2/3 byte/unicode prefixes
got = remove_prefixes(unicode_literal_re, got)
want = remove_prefixes(unicode_literal_re, want)
# Note: normalizing away prefixes can cause weird "got"
# results to print when there is a got-want mismatch.
# For instance, if you get {'b': 22} but you want {'b': 2}
# this will cause xdoctest to report that you wanted {'': 2}
# because it reports the normalized version of the want message
got = remove_prefixes(bytes_literal_re, got)
want = remove_prefixes(bytes_literal_re, want)
# Replace <BLANKLINE>s if it is being used.
if not runstate['DONT_ACCEPT_BLANKLINE']:
want = remove_blankline_marker(want)
# always remove trailing whitespace
got = re.sub(TRAILING_WS, '', got)
want = re.sub(TRAILING_WS, '', want)
# normalize endling newlines
want = want.rstrip()
got = got.rstrip()
# Always remove invisible text
got_lines = got.splitlines(True)
want_lines = want.splitlines(True)
got_lines = visible_text(got_lines)
want_lines = visible_text(want_lines)
want = ''.join(want_lines)
got = ''.join(got_lines)
if runstate['NORMALIZE_WHITESPACE'] or runstate['IGNORE_WHITESPACE']:
# all whitespace normalization
# treat newlines and all whitespace as a single space
got = ' '.join(got.split())
want = ' '.join(want.split())
if runstate['IGNORE_WHITESPACE']:
# Completely remove whitespace
got = re.sub(r'\s', '', got, flags=re.MULTILINE)
want = re.sub(r'\s', '', want, flags=re.MULTILINE)
if runstate['NORMALIZE_REPR']:
def norm_repr(a: str, b: str) -> str:
# If removing quotes would allow for a match, remove them.
if not _check_match(a, b, runstate):
for q in ['"', "'"]:
if a.startswith(q) and a.endswith(q):
if _check_match(a[1:-1], b, runstate):
return a[1:-1]
return a
got = norm_repr(got, want)
want = norm_repr(want, got)
return got, want
[docs]
class GotWantException(AssertionError):
got: str
want: str
"""
Exception used when the "got" output of a doctest differs from the expected
"want" output.
"""
def __init__(self, msg: str, got: str, want: str) -> None:
"""
Args:
msg (str): The exception message
got (str): The unnormalized got str
want (str): The unnormalized want str
"""
super(GotWantException, self).__init__(msg)
self.got = got
self.want = want
[docs]
def _do_a_fancy_diff(
self, runstate: directive.RuntimeState | None = None
) -> bool:
# Not unless they asked for a fancy diff.
got = self.got
want = self.want
if runstate is None:
runstate = directive.RuntimeState()
# ndiff does intraline difference marking, so can be useful even
# for 1-line differences.
if runstate['REPORT_NDIFF']:
return True
# The other diff types need at least a few lines to be helpful.
if runstate['REPORT_UDIFF'] or runstate['REPORT_CDIFF']:
return want.count('\n') > 2 and got.count('\n') > 2
return False
[docs]
def _output_difference_xdoctest(
self,
runstate: directive.RuntimeState | None = None,
colored: bool = True,
) -> str:
"""
Return a string describing the differences between the expected output
for a given example (`example`) and the actual output (`got`).
The `runstate` contains option flags used to compare `want` and `got`.
Args:
runstate (xdoctest.directive.RuntimeState | None): current state
colored (bool): if the text should be colored
Returns:
str: formatted difference text
Note:
This does not check if got matches want, it only outputs the raw
differences. Got/Want normalization may make the differences appear
more exaggerated than they are.
"""
got = self.got
want = self.want
if runstate is None:
runstate = directive.RuntimeState()
# Don't normalize because it usually removes the newlines
runstate_ = runstate.to_dict()
# Don't normalize whitespaces in report for better visibility
runstate_['NORMALIZE_WHITESPACE'] = False
runstate_['IGNORE_WHITESPACE'] = False
got, want = normalize(got, want, runstate_)
# If <BLANKLINE>s are being used, then replace blank lines
# with <BLANKLINE> in the actual output string.
# if not runstate['DONT_ACCEPT_BLANKLINE']:
# got = re.sub('(?m)^[ ]*(?=\n)', BLANKLINE_MARKER, got)
got = utils.ensure_unicode(got)
# Check if we should use diff.
if self._do_a_fancy_diff(runstate):
# Split want & got into lines.
want_lines = want.splitlines(True)
got_lines = got.splitlines(True)
# Use difflib to find their differences.
if runstate['REPORT_UDIFF']:
diff = list(difflib.unified_diff(want_lines, got_lines, n=2))[
2:
] # strip the diff header
kind = 'unified diff with -expected +actual'
elif runstate['REPORT_CDIFF']:
diff = list(difflib.context_diff(want_lines, got_lines, n=2))[
2:
] # strip the diff header
kind = 'context diff with expected followed by actual'
elif runstate['REPORT_NDIFF']:
# TODO: Is there a way to make Differ ignore whitespace if that
# runtime directive is specified?
engine = difflib.Differ(charjunk=difflib.IS_CHARACTER_JUNK)
diff = list(engine.compare(want_lines, got_lines))
kind = 'ndiff with -expected +actual'
else:
raise ValueError('Invalid difflib option')
# Remove trailing whitespace on diff output.
diff = [line.rstrip() + '\n' for line in diff]
diff_text = ''.join(diff)
if colored:
diff_text = utils.highlight_code(diff_text, lexer_name='diff')
text = 'Differences (%s):\n' % kind + utils.indent(diff_text)
else:
# If we're not using diff, then simply list the expected
# output followed by the actual output.
if want and got:
if colored:
got = utils.color_text(got, 'red')
want = utils.color_text(want, 'red')
text = 'Expected:\n{}\nGot:\n{}'.format(
utils.indent(self.want), utils.indent(self.got)
)
elif want:
if colored:
got = utils.color_text(got, 'red')
want = utils.color_text(want, 'red')
text = 'Expected:\n{}\nGot nothing\n'.format(utils.indent(want))
elif got:
if colored:
got = utils.color_text(got, 'red')
text = 'Expected nothing\nGot:\n{}'.format(utils.indent(got))
else: # nocover
raise AssertionError('impossible state')
text = 'Expected nothing\nGot nothing\n'
return text
[docs]
def output_difference(
self,
runstate: directive.RuntimeState | Mapping[str, object] | None = None,
colored: bool = True,
) -> str:
runstate = _coerce_runstate(runstate)
checker_name = runstate.get_output_checker()
if checker_name != 'xdoctest':
# A foreign checker may provide its own difference rendering
# (e.g. to display fixed-up wants); fall back to the native
# renderer when it inherits the facade default.
from xdoctest import checker_facade
output_checker = checker_facade.resolve_checker(
checker_name, runstate
)
if (
output_checker.__class__.output_difference
is not checker_facade.OutputChecker.output_difference
):
example = doctest.Example(source='', want=self.want)
optionflags = checker_facade.runtime_state_to_optionflags(
runstate
)
return output_checker.output_difference(
example, self.got, optionflags
)
return self._output_difference_xdoctest(
runstate=runstate,
colored=colored,
)
[docs]
def output_repr_difference(
self, runstate: directive.RuntimeState | None = None
) -> str:
"""
Constructs a repr difference with minimal normalization.
Args:
runstate (xdoctest.directive.RuntimeState | None): current state
Returns:
str: formatted repr difference text
"""
minimal_got = self.got.rstrip()
minimal_want = self.want.rstrip()
if runstate is None:
runstate = directive.RuntimeState()
# Don't normalize because it usually removes the newlines
runstate_ = runstate.to_dict()
if not runstate_['DONT_ACCEPT_BLANKLINE']:
minimal_want = remove_blankline_marker(minimal_want)
lines = [
('Repr Difference:'),
# TODO: get a semi-normalized output before showing repr?
(' got = {!r}'.format(minimal_got)),
(' want = {!r}'.format(minimal_want)),
]
return '\n'.join(lines)
[docs]
def remove_blankline_marker(text: str) -> str:
r"""
Args:
text (str): input text
Returns:
str: output text
Example:
>>> text1 = 'foo\n{}\nbar'.format(BLANKLINE_MARKER)
>>> text2 = '{}\nbar'.format(BLANKLINE_MARKER)
>>> text4 = 'foo\n{}'.format(BLANKLINE_MARKER)
>>> text3 = '{}'.format(BLANKLINE_MARKER)
>>> text5 = text1 + text1 + text1
>>> assert BLANKLINE_MARKER not in remove_blankline_marker(text1)
>>> assert BLANKLINE_MARKER not in remove_blankline_marker(text2)
>>> assert BLANKLINE_MARKER not in remove_blankline_marker(text3)
>>> assert BLANKLINE_MARKER not in remove_blankline_marker(text4)
>>> assert BLANKLINE_MARKER not in remove_blankline_marker(text5)
"""
pos_lb = '(?<=\n)' # positive lookbehind
blankline_pattern = '|'.join(
['{pos_lb}{marker}\n', '{marker}\n', '\n{marker}', '{marker}']
).format(marker=BLANKLINE_MARKER, pos_lb=pos_lb)
# blankline_pattern = r'(?<=\n)[ ]*{}\n?'.format(re.escape(BLANKLINE_MARKER))
new_text = re.sub(blankline_pattern, '\n', text, flags=re.MULTILINE)
return new_text
if __name__ == '__main__':
"""
CommandLine:
python -m xdoctest.checker all
"""
import xdoctest as xdoc
xdoc.doctest_module()