| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 1441b96 commit ee271ee
5 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -30,13 +30,14 @@ Unreleased | |||
| 30 | 30 | ||
| 31 | 31 | - A number of performance improvements thanks to Paul Kehrer, in pull requests | |
| 32 | 32 | `2213 <pull 2213_>`_, `2214 <pull 2214_>`_, `2215 <pull 2215_>`_, `2216 | |
| 33 | - <pull 2216_>`_, and `2218 <pull 2218_>`_. | ||
| 33 | + <pull 2216_>`_, `2218 <pull 2218_>`_, and `2220 <pull 2220_>`_. | ||
| 34 | 34 | ||
| 35 | 35 | .. _pull 2213: https://github.com/coveragepy/coveragepy/pull/2213 | |
| 36 | 36 | .. _pull 2214: https://github.com/coveragepy/coveragepy/pull/2214 | |
| 37 | 37 | .. _pull 2215: https://github.com/coveragepy/coveragepy/pull/2215 | |
| 38 | 38 | .. _pull 2216: https://github.com/coveragepy/coveragepy/pull/2216 | |
| 39 | 39 | .. _pull 2218: https://github.com/coveragepy/coveragepy/pull/2218 | |
| 40 | + .. _pull 2220: https://github.com/coveragepy/coveragepy/pull/2220 | ||
| 40 | 41 | .. _pull 2224: https://github.com/coveragepy/coveragepy/pull/2224 | |
| 41 | 42 | ||
| 42 | 43 | .. start-releases | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -26,6 +26,45 @@ | |||
| 26 | 26 | os = isolate_module(os) | |
| 27 | 27 | ||
| 28 | 28 | ||
| 29 | + def multiline_map_from_tokens(tokens: Iterable[tokenize.TokenInfo]) -> dict[TLineNo, TLineNo]: | ||
| 30 | + """Compute the multiline map from a stream of tokens. | ||
| 31 | + | ||
| 32 | + The result maps line numbers in multi-line statements to the first line | ||
| 33 | + number of their statement. This is the only place the map is computed: | ||
| 34 | + `PythonParser._raw_parse` uses it for parsing and reporting, and the | ||
| 35 | + sys.monitoring core uses `multiline_map_from_text` to get the map without | ||
| 36 | + paying for a full parse during measurement. | ||
| 37 | + | ||
| 38 | + """ | ||
| 39 | + multiline_map: dict[TLineNo, TLineNo] = {} | ||
| 40 | + # The line number of the first line in a multi-line statement. | ||
| 41 | + first_line = 0 | ||
| 42 | + for toktype, ttext, (slineno, _), (elineno, _), _ in tokens: | ||
| 43 | + if toktype == token.NEWLINE: | ||
| 44 | + if first_line and elineno != first_line: | ||
| 45 | + # We're at the end of a line, and we've ended on a | ||
| 46 | + # different line than the first line of the statement, | ||
| 47 | + # so record a multi-line range. | ||
| 48 | + for l in range(first_line, elineno + 1): | ||
| 49 | + multiline_map[l] = first_line | ||
| 50 | + first_line = 0 | ||
| 51 | + if ttext.strip() and toktype != tokenize.COMMENT: | ||
| 52 | + # A non-white-space token, the first in a statement. | ||
| 53 | + if not first_line: | ||
| 54 | + first_line = slineno | ||
| 55 | + return multiline_map | ||
| 56 | + | ||
| 57 | + | ||
| 58 | + def multiline_map_from_text(text: str) -> dict[TLineNo, TLineNo]: | ||
| 59 | + """Compute just the multiline map for `text`, without a full parse. | ||
| 60 | + | ||
| 61 | + Can raise tokenize.TokenError, IndentationError, or SyntaxError if the | ||
| 62 | + text isn't parsable as Python. | ||
| 63 | + | ||
| 64 | + """ | ||
| 65 | + return multiline_map_from_tokens(generate_tokens(text)) | ||
| 66 | + | ||
| 67 | + | ||
| 29 | 68 | class PythonParser: | |
| 30 | 69 | """Parse code to find executable lines, excluded lines, etc. | |
| 31 | 70 | ||
@@ -148,8 +187,9 @@ def _raw_parse(self) -> None: | |||
| 148 | 187 | nesting: int = 0 | |
| 149 | 188 | ||
| 150 | 189 | assert self.text is not None | |
| 151 | - tokgen = generate_tokens(self.text) | ||
| 152 | - for toktype, ttext, (slineno, _), (elineno, _), ltext in tokgen: | ||
| 190 | + tokens = list(generate_tokens(self.text)) | ||
| 191 | + self.multiline_map = multiline_map_from_tokens(tokens) | ||
| 192 | + for toktype, ttext, (slineno, _), (elineno, _), ltext in tokens: | ||
| 153 | 193 | if self.show_tokens: # pragma: debugging | |
| 154 | 194 | print( | |
| 155 | 195 | "%10s %5s %-20r %r" | |
@@ -179,12 +219,9 @@ def _raw_parse(self) -> None: | |||
| 179 | 219 | elif ttext in ")]}": | |
| 180 | 220 | nesting -= 1 | |
| 181 | 221 | elif toktype == token.NEWLINE: | |
| 182 | - if first_line and elineno != first_line: | ||
| 183 | - # We're at the end of a line, and we've ended on a | ||
| 184 | - # different line than the first line of the statement, | ||
| 185 | - # so record a multi-line range. | ||
| 186 | - for l in range(first_line, elineno + 1): | ||
| 187 | - self.multiline_map[l] = first_line | ||
| 222 | + # multiline_map_from_tokens() has already recorded this | ||
| 223 | + # statement's lines; we only track first_line here for the | ||
| 224 | + # exclusion logic. | ||
| 188 | 225 | first_line = 0 | |
| 189 | 226 | ||
| 190 | 227 | if ttext.strip() and toktype != tokenize.COMMENT: | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -12,6 +12,7 @@ | |||
| 12 | 12 | import os.path | |
| 13 | 13 | import sys | |
| 14 | 14 | import threading | |
| 15 | + import tokenize | ||
| 15 | 16 | import traceback | |
| 16 | 17 | from collections.abc import Callable | |
| 17 | 18 | from dataclasses import dataclass | |
@@ -21,9 +22,10 @@ | |||
| 21 | 22 | from coverage import env | |
| 22 | 23 | from coverage.bytecode import TBranchTrails, always_jumps, branch_trails, bytes_to_lines | |
| 23 | 24 | from coverage.debug import short_filename, short_stack | |
| 24 | - from coverage.exceptions import NoSource, NotPython | ||
| 25 | + from coverage.exceptions import NoSource | ||
| 25 | 26 | from coverage.misc import isolate_module | |
| 26 | - from coverage.parser import PythonParser | ||
| 27 | + from coverage.parser import multiline_map_from_text | ||
| 28 | + from coverage.python import get_python_source | ||
| 27 | 29 | from coverage.types import ( | |
| 28 | 30 | AnyCallable, | |
| 29 | 31 | TFileDisposition, | |
@@ -223,6 +225,9 @@ def __init__(self) -> None: | |||
| 223 | 225 | # Map filename:__name__ -> set(id(code_object)) | |
| 224 | 226 | self.filename_code_ids: dict[str, set[int]] = collections.defaultdict(set) | |
| 225 | 227 | ||
| 228 | + # Map filename -> multiline map, so each file is parsed at most once. | ||
| 229 | + self.multiline_maps: dict[str, dict[TLineNo, TLineNo]] = {} | ||
| 230 | + | ||
| 226 | 231 | self.sysmon_on = False | |
| 227 | 232 | self.lock = threading.Lock() | |
| 228 | 233 | ||
@@ -458,7 +463,7 @@ def sysmon_branch_either( | |||
| 458 | 463 | if not code_info.branch_trails: | |
| 459 | 464 | if self.stats is not None: | |
| 460 | 465 | self.stats["branch_trails"] += 1 | |
| 461 | - multiline_map = get_multiline_map(code.co_filename) | ||
| 466 | + multiline_map = self.get_multiline_map(code.co_filename) | ||
| 462 | 467 | code_info.branch_trails = branch_trails(code, multiline_map=multiline_map) | |
| 463 | 468 | code_info.always_jumps = always_jumps(code) | |
| 464 | 469 | # log(f"branch_trails for {code}:\n{ppformat(code_info.branch_trails)}") | |
@@ -495,20 +500,26 @@ def sysmon_branch_either( | |||
| 495 | 500 | ||
| 496 | 501 | return DISABLE | |
| 497 | 502 | ||
| 503 | + def get_multiline_map(self, filename: str) -> dict[TLineNo, TLineNo]: | ||
| 504 | + """Get the multiline map for `filename`, computing it at most once.""" | ||
| 505 | + multiline_map = self.multiline_maps.get(filename) | ||
| 506 | + if multiline_map is None: | ||
| 507 | + multiline_map = self.multiline_maps[filename] = compute_multiline_map(filename) | ||
| 508 | + return multiline_map | ||
| 509 | + | ||
| 498 | 510 | ||
| 499 | - @functools.lru_cache(maxsize=20) | ||
| 500 | - def get_multiline_map(filename: str) -> dict[TLineNo, TLineNo]: | ||
| 501 | - """Get a PythonParser for the given filename, cached.""" | ||
| 511 | + def compute_multiline_map(filename: str) -> dict[TLineNo, TLineNo]: | ||
| 512 | + """Tokenize `filename` and return its multiline map.""" | ||
| 502 | 513 | try: | |
| 503 | - parser = PythonParser(filename=filename) | ||
| 504 | - parser.parse_source() | ||
| 505 | - except NotPython: | ||
| 514 | + text = get_python_source(filename) | ||
| 515 | + except (OSError, NoSource): | ||
| 516 | + # This can happen if open() in python.py fails. | ||
| 517 | + return {} | ||
| 518 | + try: | ||
| 519 | + return multiline_map_from_text(text) | ||
| 520 | + except (tokenize.TokenError, IndentationError, SyntaxError): | ||
| 506 | 521 | # The file was not Python. This can happen when the code object refers | |
| 507 | 522 | # to an original non-Python source file, like a Jinja template. | |
| 508 | 523 | # In that case, just return an empty map, which might lead to slightly | |
| 509 | 524 | # wrong branch coverage, but we don't have any better option. | |
| 510 | 525 | return {} | |
| 511 | - except NoSource: | ||
| 512 | - # This can happen if open() in python.py fails. | ||
| 513 | - return {} | ||
| 514 | - return parser.multiline_map | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -8,13 +8,15 @@ | |||
| 8 | 8 | import ast | |
| 9 | 9 | import re | |
| 10 | 10 | import textwrap | |
| 11 | + import tokenize | ||
| 11 | 12 | from unittest import mock | |
| 12 | 13 | ||
| 13 | 14 | import pytest | |
| 14 | 15 | ||
| 15 | 16 | from coverage import env | |
| 16 | 17 | from coverage.exceptions import NoSource, NotPython | |
| 17 | - from coverage.parser import PythonParser, is_constant_test_expr | ||
| 18 | + from coverage.parser import PythonParser, is_constant_test_expr, multiline_map_from_text | ||
| 19 | + from coverage.types import TLineNo | ||
| 18 | 20 | ||
| 19 | 21 | from tests.coveragetest import CoverageTest | |
| 20 | 22 | from tests.helpers import arcz_to_arcs | |
@@ -1320,3 +1322,170 @@ def test_is_constant_test_expr(expr: str, ret: tuple[bool, bool]) -> None: | |||
| 1320 | 1322 | node = ast.parse(expr, mode="eval").body | |
| 1321 | 1323 | print(ast.dump(node, indent=4)) | |
| 1322 | 1324 | assert is_constant_test_expr(node) == ret | |
| 1325 | + | ||
| 1326 | + | ||
| 1327 | + class MultilineMapTest(CoverageTest): | ||
| 1328 | + """Tests of multiline_map_from_text.""" | ||
| 1329 | + | ||
| 1330 | + run_in_temp_dir = False | ||
| 1331 | + | ||
| 1332 | + def multiline_map(self, text: str) -> dict[TLineNo, TLineNo]: | ||
| 1333 | + """Compute the multiline map of dedented `text`.""" | ||
| 1334 | + return multiline_map_from_text(textwrap.dedent(text)) | ||
| 1335 | + | ||
| 1336 | + def test_single_line_statements_have_no_entries(self) -> None: | ||
| 1337 | + assert ( | ||
| 1338 | + self.multiline_map("""\ | ||
| 1339 | + a = 1 | ||
| 1340 | + b = 2; c = 3 | ||
| 1341 | + # a comment | ||
| 1342 | + | ||
| 1343 | + def f(x): | ||
| 1344 | + return x | ||
| 1345 | + """) | ||
| 1346 | + == {} | ||
| 1347 | + ) | ||
| 1348 | + | ||
| 1349 | + def test_parenthesized_expression(self) -> None: | ||
| 1350 | + assert self.multiline_map("""\ | ||
| 1351 | + x = ( | ||
| 1352 | + 1 + | ||
| 1353 | + 2 | ||
| 1354 | + ) | ||
| 1355 | + y = 5 | ||
| 1356 | + """) == {1: 1, 2: 1, 3: 1, 4: 1} | ||
| 1357 | + | ||
| 1358 | + def test_backslash_continuation(self) -> None: | ||
| 1359 | + assert self.multiline_map("""\ | ||
| 1360 | + total = 1 + \\ | ||
| 1361 | + 2 | ||
| 1362 | + """) == {1: 1, 2: 1} | ||
| 1363 | + | ||
| 1364 | + def test_triple_quoted_string(self) -> None: | ||
| 1365 | + assert self.multiline_map("""\ | ||
| 1366 | + s = '''one | ||
| 1367 | + two | ||
| 1368 | + three''' | ||
| 1369 | + """) == {1: 1, 2: 1, 3: 1} | ||
| 1370 | + | ||
| 1371 | + def test_multiline_signature(self) -> None: | ||
| 1372 | + assert self.multiline_map("""\ | ||
| 1373 | + def f( | ||
| 1374 | + a, | ||
| 1375 | + b, | ||
| 1376 | + ): | ||
| 1377 | + return a + b | ||
| 1378 | + """) == {1: 1, 2: 1, 3: 1, 4: 1} | ||
| 1379 | + | ||
| 1380 | + def test_multiline_if_header(self) -> None: | ||
| 1381 | + assert self.multiline_map("""\ | ||
| 1382 | + if (a and | ||
| 1383 | + b): | ||
| 1384 | + c = 1 | ||
| 1385 | + """) == {1: 1, 2: 1} | ||
| 1386 | + | ||
| 1387 | + def test_blank_and_comment_lines_inside_statement(self) -> None: | ||
| 1388 | + # Lines inside a multi-line statement belong to it, even blank lines | ||
| 1389 | + # and comment lines. | ||
| 1390 | + assert self.multiline_map("""\ | ||
| 1391 | + x = [ | ||
| 1392 | + 1, | ||
| 1393 | + | ||
| 1394 | + # two comes next | ||
| 1395 | + 2, | ||
| 1396 | + ] | ||
| 1397 | + """) == {lineno: 1 for lineno in range(1, 7)} | ||
| 1398 | + | ||
| 1399 | + def test_statements_map_to_their_own_starts(self) -> None: | ||
| 1400 | + assert self.multiline_map("""\ | ||
| 1401 | + a = (1 + | ||
| 1402 | + 2) | ||
| 1403 | + b = 3 | ||
| 1404 | + c = (4 + | ||
| 1405 | + 5) | ||
| 1406 | + """) == {1: 1, 2: 1, 4: 4, 5: 4} | ||
| 1407 | + | ||
| 1408 | + def test_unparsable_text_raises(self) -> None: | ||
| 1409 | + with pytest.raises(tokenize.TokenError): | ||
| 1410 | + multiline_map_from_text("x = (\n") | ||
| 1411 | + | ||
| 1412 | + @pytest.mark.parametrize( | ||
| 1413 | + "text", | ||
| 1414 | + [ | ||
| 1415 | + """\ | ||
| 1416 | + a = 1 | ||
| 1417 | + b = 2 | ||
| 1418 | + """, | ||
| 1419 | + """\ | ||
| 1420 | + x = ( | ||
| 1421 | + 1 + | ||
| 1422 | + 2 | ||
| 1423 | + ) | ||
| 1424 | + y = 5 | ||
| 1425 | + """, | ||
| 1426 | + """\ | ||
| 1427 | + def f( | ||
| 1428 | + a, | ||
| 1429 | + b=[1, | ||
| 1430 | + 2], | ||
| 1431 | + ): | ||
| 1432 | + return (a + | ||
| 1433 | + b) | ||
| 1434 | + """, | ||
| 1435 | + """\ | ||
| 1436 | + @decorator( | ||
| 1437 | + arg, | ||
| 1438 | + ) | ||
| 1439 | + def f(): | ||
| 1440 | + pass | ||
| 1441 | + """, | ||
| 1442 | + """\ | ||
| 1443 | + if (a and | ||
| 1444 | + b): | ||
| 1445 | + c = (1, | ||
| 1446 | + 2) | ||
| 1447 | + """, | ||
| 1448 | + """\ | ||
| 1449 | + with (open('a') as fa, | ||
| 1450 | + open('b') as fb): | ||
| 1451 | + pass | ||
| 1452 | + """, | ||
| 1453 | + """\ | ||
| 1454 | + s = f'''one {x | ||
| 1455 | + + 1} two | ||
| 1456 | + three''' | ||
| 1457 | + t = 4 | ||
| 1458 | + """, | ||
| 1459 | + """\ | ||
| 1460 | + class C( | ||
| 1461 | + Base, | ||
| 1462 | + ): | ||
| 1463 | + attr = 1 | ||
| 1464 | + """, | ||
| 1465 | + """\ | ||
| 1466 | + result = [x | ||
| 1467 | + for x in items | ||
| 1468 | + if x > 0 | ||
| 1469 | + ] | ||
| 1470 | + """, | ||
| 1471 | + """\ | ||
| 1472 | + match (command, | ||
| 1473 | + arg): | ||
| 1474 | + case (1, 2): | ||
| 1475 | + pass | ||
| 1476 | + """, | ||
| 1477 | + """\ | ||
| 1478 | + total = 1 + \\ | ||
| 1479 | + 2 + \\ | ||
| 1480 | + 3 | ||
| 1481 | + """, | ||
| 1482 | + ], | ||
| 1483 | + ) | ||
| 1484 | + def test_agrees_with_python_parser(self, text: str) -> None: | ||
| 1485 | + # PythonParser._raw_parse gets its multiline map from the same code, | ||
| 1486 | + # but through a different path (tokenizing to a list first). The two | ||
| 1487 | + # must never drift apart: the sys.monitoring core uses this map to | ||
| 1488 | + # attribute branch arcs to the lines that reports are keyed by. | ||
| 1489 | + parser = PythonParser(text=textwrap.dedent(text)) | ||
| 1490 | + parser.parse_source() | ||
| 1491 | + assert multiline_map_from_text(text) == parser.multiline_map | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments