diff --git a/src/coverup/segment.py b/src/coverup/segment.py index 66ca205..595e4ba 100644 --- a/src/coverup/segment.py +++ b/src/coverup/segment.py @@ -83,12 +83,6 @@ def find_enclosing(root, line): if begin <= line <= node.end_lineno: return (node, begin, node.end_lineno+1) # +1 for range() style - def line_is_docstring(line, node): - return node.body and line == node.body[0].lineno and \ - isinstance(node.body[0], ast.Expr) and \ - isinstance(node.body[0].value, ast.Constant) and \ - isinstance(node.body[0].value.value, str) - for fname, fcov in coverage['files'].items(): with open(fname, "r") as src: tree = ast.parse(src.read(), fname) @@ -102,6 +96,11 @@ def line_is_docstring(line, node): lines_of_interest = missing_lines.union(set(sum(missing_branches,[]))) lines_of_interest.discard(0) # may result from N->0 branches + # TODO remove from missing lines with load-time statements? + # - directly under ModuleDef, ClassDef + # note that docstring don't generate code, so can't be missing within functions + # But then: how to capture missing coverage if a module was never loaded? + lines_in_segments = set() for line in sorted(lines_of_interest): # sorted() simplifies tests @@ -109,32 +108,22 @@ def line_is_docstring(line, node): # already in a segment continue + # FIXME add segments for top-level elements (under ModuleDef) if element := find_enclosing(tree, line): node, begin, end = element context = [] - # if a class and above line limit, look for enclosing element + # If a class is above the line limit, look for enclosing element # that might allow us to obey the limit while isinstance(node, ast.ClassDef) and end - begin > line_limit and \ (element := find_enclosing(node, line)): context.append((begin, node.lineno+1)) # +1 for range() style node, begin, end = element - if line == node.lineno or line_is_docstring(line, node): - # 'class' and 'def' are processed at import time, not really interesting - # TODO load modules to remove such (and any others) at coverage collection time? - lines_of_interest.remove(line) - continue - - # if 'line' is about a statement within a class and all that follows it - # are function/class definitions, we can trim the segment, reducing its 'end' + # Don't create a segment for a class that's too large... if we did, we + # might create a segment for a class after creating segments for its contents. if isinstance(node, ast.ClassDef) and end - begin > line_limit: - if all(child.lineno <= line or \ - isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) \ - for child in ast.iter_child_nodes(node)): - end = min(find_first_line(child) for child in ast.iter_child_nodes(node) \ - if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and \ - child.lineno > line) + continue assert line < end assert (begin, end) not in line_ranges diff --git a/tests/test_segment.py b/tests/test_segment.py index 9b40dbe..07160da 100644 --- a/tests/test_segment.py +++ b/tests/test_segment.py @@ -1,5 +1,6 @@ from pathlib import Path -from coverup.segment import * +import coverup.segment as segment +import textwrap import json @@ -24,307 +25,240 @@ def __exit__(self, *args): self.mock.__exit__(*args) -somecode_py = (Path("tests") / "somecode.py").read_text() -somecode_json = """\ -{ - "files": { - "tests/somecode.py": { - "executed_lines": [ - 3, 4, 6, 9, 20, 21, 25, 27, 29, 38, 39, 40 - ], - "missing_lines": [ - 7, 10, 12, 13, 15, 16, 18, 23, 32, 34, 36 - ], - "executed_branches": [ - [38, 39] - ], - "missing_branches": [ - [12, 13], [12, 15], [38, 0] - ] - } - } -} -""" +somecode_py = textwrap.dedent("""\ + # Sample Python code used to create some tests. + import sys + class Foo: + '''docstring...''' -def test_basic(): - coverage = json.loads(somecode_json) + @staticmethod + def foo(): + pass - with mockfs({"tests/somecode.py": somecode_py}): - segs = get_missing_coverage(coverage, line_limit=2) + def __init__(self): + '''initializes...''' + self._foo = 0 - assert all([Path(seg.filename).name == 'somecode.py' for seg in segs]) - seg_names = [seg.name for seg in segs] - assert ['__init__', 'foo', 'bar', 'globalDef2'] == seg_names + x = 0 + if x != 0: + y = 2 - bar = segs[seg_names.index('bar')] - assert bar.begin == 20 # decorator line - assert '@staticmethod' in bar.get_excerpt(), "Decorator missing" + class Bar: + z = 10 - for seg in segs: - for l in seg.missing_lines: - assert seg.begin <= l <= seg.end + def bar(): + '''docstring''' + class Baz: + assert False + def baz(): + pass -def test_coarse(): - coverage = json.loads(somecode_json) + GLOBAL = 42 - with mockfs({"tests/somecode.py": somecode_py}): - segs = get_missing_coverage(coverage, line_limit=100) - - assert all([Path(seg.filename).name == 'somecode.py' for seg in segs]) - seg_names = [seg.name for seg in segs] - assert ['SomeCode', 'globalDef2'] == seg_names - - assert segs[seg_names.index('SomeCode')].begin == 3 # entire class? - assert segs[seg_names.index('SomeCode')].end == 24 # entire class? - - for seg in segs: - for l in seg.missing_lines: - assert seg.begin <= l <= seg.end + def func(x): + '''docstring''' + if x > 0: + return 42**42 + if __name__ == "__main__": + ... +""") -def test_no_branch_coverage(): - somecode_json_no_branch = """\ +somecode_json = """\ { "files": { "tests/somecode.py": { "executed_lines": [ - 3, 4, 6, 9, 20, 21, 25, 27, 29, 38, 39, 40 + 2, 4, 5, 7, 8, 11, 15, 16, 19, + 20, 22, 30, 32, 37, 38 ], "missing_lines": [ - 7, 10, 12, 13, 15, 16, 18, 23, 32, 34, 36 - ] - } - } -} -""" - coverage = json.loads(somecode_json_no_branch) - - with mockfs({"tests/somecode.py": somecode_py}): - segs = get_missing_coverage(coverage, line_limit=2) - - assert all([Path(seg.filename).name == 'somecode.py' for seg in segs]) - assert ['__init__', 'foo', 'bar', 'globalDef2'] == [seg.name for seg in segs] - - -def test_all_missing(): - somecode_json = """\ -{ - "files": { - "tests/somecode.py": { - "executed_lines": [ + 9, 13, 17, 24, 25, 27, 28, 34, 35 ], - "missing_lines": [ - 3, 4, 6, 9, 20, 21, 25, 27, 29, 38, 39, 40, - 7, 10, 12, 13, 15, 16, 18, 23, 32, 34, 36 + "executed_branches": [ + [ 16, 19 ], [ 37, 38 ] + ], + "missing_branches": [ + [ 16, 17 ], + [ 34, 0 ], + [ 34, 35 ], + [ 37, 0 ] ] } } } """ + +def test_large_limit_whole_class(): coverage = json.loads(somecode_json) - with mockfs({"tests/somecode.py": somecode_py}): - segs = get_missing_coverage(coverage, line_limit=3) - print("\n".join(f"{s} {s.lines_of_interest=}" for s in segs)) + with mockfs({"tests/somecode.py": somecode_py}): + segs = segment.get_missing_coverage(coverage, line_limit=100) + assert ['Foo', 'func'] == [seg.name for seg in segs] assert all([Path(seg.filename).name == 'somecode.py' for seg in segs]) - assert ['__init__', 'foo', 'bar', 'globalDef', 'globalDef2'] == [seg.name for seg in segs] - - for i in range(1, len(segs)): - assert segs[i-1].end <= segs[i].begin # no overlaps - - # FIXME global statements missing... how to best capture them? + assert all(seg.begin < seg.end for seg in segs) + assert textwrap.dedent(segs[0].get_excerpt(tag_lines=False)) == textwrap.dedent("""\ + class Foo: + '''docstring...''' -def test_class_excludes_decorator_of_function_if_at_limit(): - code_py = """\ -class Foo: - x = 0 + @staticmethod + def foo(): + pass - @staticmethod - def foo(): - pass -""" + def __init__(self): + '''initializes...''' + self._foo = 0 - code_json = """\ -{ - "files": { - "code.py": { - "executed_lines": [ - ], - "missing_lines": [ - 1, 2, 4, 5, 6 - ] - } - } -} -""" - coverage = json.loads(code_json) - with mockfs({"code.py": code_py}): - segs = get_missing_coverage(coverage, line_limit=4) + x = 0 + if x != 0: + y = 2 - print("\n".join(f"{s} {s.lines_of_interest=}" for s in segs)) + class Bar: + z = 10 - assert ['Foo', 'foo'] == [seg.name for seg in segs] - assert segs[0].begin == 1 - assert segs[0].end <= 4 # shouldn't include "@staticmethod" - assert segs[0].missing_lines == {1,2} + def bar(): + '''docstring''' + class Baz: + assert False + def baz(): + pass + """) -def test_class_statements_after_methods(): - code_py = """\ -class Foo: - @staticmethod - def foo(): - pass + assert textwrap.dedent(segs[1].get_excerpt(tag_lines=False)) == textwrap.dedent("""\ + def func(x): + '''docstring''' + if x > 0: + return 42**42 + """) - x = 0 - y = 1 + # FIXME check executed_lines, missing_lines, ..., interesting_lines - def bar(): - pass -""" - code_json = """\ -{ - "files": { - "code.py": { - "executed_lines": [ - ], - "missing_lines": [ - 1, 2, 3, 4, 6, 7, 9, 10 - ] - } - } -} -""" - coverage = json.loads(code_json) - with mockfs({"code.py": code_py}): - segs = get_missing_coverage(coverage, line_limit=4) - - print("\n".join(str(s) for s in segs)) - - for seg in segs: - for l in seg.missing_lines: - assert seg.begin <= l <= seg.end +def test_small_limit(): + coverage = json.loads(somecode_json) + with mockfs({"tests/somecode.py": somecode_py}): + segs = segment.get_missing_coverage(coverage, line_limit=3) -def test_class_within_class(): - code_py = """\ -class Foo: - foo_ = 0 - class Bar: - bar_ = 0 - def __init__(self): - self.x = 0 - self.y = 0 + # "Bar" omitted because executed + assert ['foo', '__init__', 'bar', 'func'] == [seg.name for seg in segs] + assert all([Path(seg.filename).name == 'somecode.py' for seg in segs]) + assert all(seg.begin < seg.end for seg in segs) + + + assert textwrap.dedent(segs[0].get_excerpt(tag_lines=False)) == textwrap.dedent("""\ + class Foo: + @staticmethod + def foo(): + pass + """) + + assert textwrap.dedent(segs[2].get_excerpt(tag_lines=False)) == textwrap.dedent("""\ + class Foo: + def bar(): + '''docstring''' + class Baz: + assert False + + def baz(): + pass + """) + + # FIXME check executed_lines, missing_lines, ..., interesting_lines + + +def test_all_missing_not_loaded(): + neverloaded_json = """\ + { + "files": { + "tests/somecode.py": { + "executed_lines": [], + "missing_lines": [ + 2, 4, 5, 7, 8, 9, 11, 13, 15, 16, 17, + 19, 20, 22, 24, 25, 27, 28, 30, 32, 34, + 35, 37, 38 ], + "executed_branches": [], + "missing_branches": [ + [ 16, 17 ], [ 16, 19 ], [ 34, 0 ], [ 34, 35 ], + [ 37, 0 ], [ 37, 38 ] + ] + } + } + }""" -""" + coverage = json.loads(neverloaded_json) - code_json = """\ -{ - "files": { - "code.py": { - "executed_lines": [ - ], - "missing_lines": [ - 1, 2, 3, 4, 5, 6, 7 - ] - } - } -} -""" - coverage = json.loads(code_json) - with mockfs({"code.py": code_py}): - segs = get_missing_coverage(coverage, line_limit=2) + with mockfs({"tests/somecode.py": somecode_py}): + segs = segment.get_missing_coverage(coverage, line_limit=2) - print("\n".join(str(s) for s in segs)) + assert ['foo', '__init__', 'Bar', 'bar', 'func'] == [seg.name for seg in segs] + assert all([Path(seg.filename).name == 'somecode.py' for seg in segs]) + assert all(seg.begin < seg.end for seg in segs) - for seg in segs: - for l in seg.missing_lines: - assert seg.begin <= l <= seg.end + assert textwrap.dedent(segs[3].get_excerpt(tag_lines=False)) == textwrap.dedent("""\ + class Foo: + def bar(): + '''docstring''' + class Baz: + assert False - seg_names = [seg.name for seg in segs] - assert seg_names == ['Foo', 'Bar', '__init__'] + def baz(): + pass + """) - init = segs[seg_names.index('__init__')] - assert init.context == [(1,2), (3,4)] + # FIXME check executed_lines, missing_lines, ..., interesting_lines -def test_only_coverage_missing(): - code_py = """\ -class Foo: - class Bar: - def __init__(self, x): - self.x = None - if x: - self.x = x - self.y = 0 +def test_only_branch_missing(): + code_py = textwrap.dedent("""\ + class Foo: + class Bar: + def __init__(self, x): + self.x = None + if x: + self.x = x + self.y = 0 -""" + """) code_json = """\ -{ - "files": { - "code.py": { - "executed_lines": [ - 1, 2, 3, 4, 5, 6, 7 - ], - "missing_lines": [ - ], - "executed_branches": [ - [5, 6] - ], - "missing_branches": [ - [5, 7] - ] - } - } -} -""" - coverage = json.loads(code_json) - with mockfs({"code.py": code_py}): - segs = get_missing_coverage(coverage, line_limit=4) - - print("\n".join(f"{s} {s.lines_of_interest=}" for s in segs)) - - for seg in segs: - for l in seg.missing_lines: - assert seg.begin <= l <= seg.end - - seg_names = [seg.name for seg in segs] - assert seg_names == ['__init__'] - assert segs[0].context == [(1,2), (2,3)] - assert segs[0].missing_lines == set() - assert segs[0].missing_branches == {(5,7)} - - -def test_class_long_init(): - code_py = """\ -class SomeClass(object): - - def __init__(self, module): - ... - ... - ... - ... - ... -""" - - code_cov = { - 'files': { - 'code.py': { - 'executed_lines': [], - 'missing_lines': [1, *range(3, 9)] + { + "files": { + "code.py": { + "executed_lines": [ + 1, 2, 3, 4, 5, 6, 7 + ], + "missing_lines": [ + ], + "executed_branches": [ + [5, 6] + ], + "missing_branches": [ + [5, 7] + ] + } } - } - } + }""" + coverage = json.loads(code_json) with mockfs({"code.py": code_py}): - segs = get_missing_coverage(code_cov, line_limit=5) - - print("\n".join(f"{s} {s.lines_of_interest=}" for s in segs)) - assert segs[0].begin == 3 + segs = segment.get_missing_coverage(coverage, line_limit=4) + + assert len(segs) == 1 + assert "__init__" == segs[0].name + + assert textwrap.dedent(segs[0].get_excerpt()) == textwrap.dedent("""\ + class Foo: + class Bar: + def __init__(self, x): + self.x = None + 5: if x: + self.x = x + 7: self.y = 0 + """)