Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Code coverage report seems wrong on 3.8 #409

Closed
P403n1x87 opened this issue Jun 2, 2020 · 8 comments · Fixed by #433
Closed

Code coverage report seems wrong on 3.8 #409

P403n1x87 opened this issue Jun 2, 2020 · 8 comments · Fixed by #433

Comments

@P403n1x87
Copy link

Summary

Running the same tests with nox against Python 3.6, 3.7 and 3.8 shows wrong report being generated for Python 3.8. This seems to be caused by the fact that the number of reported lines for some sources is wrong.

Expected vs actual result

The report generated with Python 3.6 seems fine:

----------- coverage: platform linux, python 3.6.9-final-0 -----------
Name                          Stmts   Miss  Cover   Missing
-----------------------------------------------------------
austin/__init__.py               60     15    75%   86-87, 92-99, 114, 122, 134, 155, 165, 182
austin/aio.py                    32      2    94%   84, 107
austin/cli.py                    63      2    97%   160, 171
austin/format/__init__.py         0      0   100%
austin/format/speedscope.py      75     17    77%   112, 119, 160-197, 201
austin/simple.py                 29      2    93%   72, 93
austin/stats.py                 122      3    98%   223, 255, 273
austin/threads.py                17      2    88%   81, 93
-----------------------------------------------------------
TOTAL                           398     43    89%

This is what the same tests produce with Python 3.8

----------- coverage: platform linux, python 3.8.3-final-0 -----------
Name                          Stmts   Miss  Cover   Missing
-----------------------------------------------------------
austin/__init__.py               74     42    43%   86-87, 92-122, 134, 155-1447
austin/aio.py                    44     35    20%   329-1657
austin/cli.py                   126     93    26%   160-171, 306-2957
austin/format/__init__.py         0      0   100%
austin/format/speedscope.py     113     71    37%   160-1477
austin/simple.py                 34     25    26%   319-866
austin/stats.py                 132      9    93%   223, 255, 273, 329-691
austin/threads.py                17      2    88%   81, 93
-----------------------------------------------------------
TOTAL                           540    277    49%

Note how some sources in the 3.8 case are reported to be ridiculously larger than in the 3.6 case.

Reproducer

It seems to happen with every test run.

Versions

pytest (5.4.2)
pytest-cov (2.9.0)
nox (2020.5.24)

Config

Please ask me in a comment if you need anything in particular.

Code

Currently from a private repository

@ionelmc
Copy link
Member

ionelmc commented Jun 4, 2020

A reproducer is a a code sample that produces the problem, not a statement that the problem does produce every time. How would a mere statement help me figure it out :)

@P403n1x87
Copy link
Author

Sorry I don't have a minimal example that I can share with you a the moment. But by the looks of it, any piece of code is likely to reproduce the problem

@ionelmc
Copy link
Member

ionelmc commented Jun 4, 2020

Well at least characterize your project. Does it use subprocesses? Multiprocessing? Anything out of the ordinary.

@P403n1x87
Copy link
Author

Sure. This is part of the pyproject.toml file that could be of interest

[tool.poetry.dependencies]
python = ">=3.6"
dataclasses = "*"
psutil = ">=5.7.0"

[tool.poetry.dev-dependencies]
coverage = {extras = ["toml"], version = "*"}
pytest = ">=5.4.2"
pytest-cov = ">=2.8.1"
sphinx = "^3.0.4"
sphinx-autodoc-typehints = "^1.10.3"
nox = "^2020.5.24"
mypy = "^0.770"
codecov = "^2.1.3"

[tool.poetry.urls]
issues = "https://github.com/P403n1x87/austin-python/issues"

[tool.coverage.run]
branch = true
source = ["austin"]

[tool.coverage.report]
show_missing = true

The project uses subprocesses together with asyncio and threading. The content of austin/cli.py is pretty standalone

from argparse import ArgumentParser, Namespace, REMAINDER
from typing import Any, List, NoReturn

from austin import AustinError


class AustinCommandLineError(AustinError):
    """Invalid Austin command line."""

    pass


class AustinArgumentParser(ArgumentParser):
    """Austin Command Line parser.
    This command line parser is based on :class:`argparse.ArgumentParser` and
    provides a minimal implementation for parsing the standard Austin command
    line. The boolean arguments of the constructor are used to specify whether
    the corresponding Austin option should be parsed or not. For example, if
    your application doesn't need the possiblity of switching to the
    alternative format, you can exclude this option with ``alt_format=False``.
    Note that al least one between ``pid`` and ``command`` is required, but
    they cannot be used together when invoking Austin.
    """

    def __init__(
        self,
        name: str = "austin",
        alt_format: bool = True,
        children: bool = True,
        exclude_empty: bool = True,
        full: bool = True,
        interval: bool = True,
        memory: bool = True,
        pid: bool = True,
        sleepless: bool = True,
        timeout: bool = True,
        command: bool = True,
        **kwargs: Any,
    ) -> None:
        super().__init__(prog=name, **kwargs)

        if not (pid and command):
            raise AustinCommandLineError(
                "Austin command line parser must have at least one between pid "
                "and command."
            )

        if alt_format:
            self.add_argument(
                "-a",
                "--alt-format",
                help="Alternative collapsed stack sample format.",
                action="store_true",
            )

        if children:
            self.add_argument(
                "-C",
                "--children",
                help="Attach to child processes.",
                action="store_true",
            )

        if exclude_empty:
            self.add_argument(
                "-e",
                "--exclude-empty",
                help="Do not output samples of threads with no frame stacks.",
                action="store_true",
            )

        if full:
            self.add_argument(
                "-f",
                "--full",
                help="Produce the full set of metrics (time +mem -mem).",
                action="store_true",
            )

        if interval:
            self.add_argument(
                "-i",
                "--interval",
                help="Sampling interval (default is 100us).",
                type=int,
            )

        if memory:
            self.add_argument(
                "-m", "--memory", help="Profile memory usage.", action="store_true"
            )

        if pid:
            self.add_argument(
                "-p",
                "--pid",
                help="The the ID of the process to which Austin should attach.",
                type=int,
            )

        if sleepless:
            self.add_argument(
                "-s", "--sleepless", help="Suppress idle samples.", action="store_true"
            )

        if timeout:
            self.add_argument(
                "-t",
                "--timeout",
                help="Approximate start up wait time. Increase on slow machines "
                "(default is 100ms).",
                type=int,
            )

        if command:
            self.add_argument(
                "command",
                type=str,
                nargs=REMAINDER,
                help="The command to execute if no PID is provided, followed by "
                "its arguments.",
            )

    def parse_args(
        self, args: List[str] = None, namespace: Namespace = None
    ) -> Namespace:
        """Parse the list of arguments.
        Return a :class:`argparse.Namespace` with the parsed result. If no PID
        nor a command are passed, an instance of the
        :class:`AustinCommandLineError` exception is thrown.
        """
        parsed_austin_args, unparsed = super().parse_known_args(args, namespace)
        if unparsed:
            raise AustinCommandLineError(
                f"Some arguments were left unparsed: {unparsed}"
            )

        if not parsed_austin_args.pid and not parsed_austin_args.command:
            raise AustinCommandLineError("No PID or command given.")

        return parsed_austin_args

    def exit(self, status: int = 0, message: str = None) -> NoReturn:
        """Raise exception on error."""
        raise AustinCommandLineError(message, status)

    @staticmethod
    def to_list(args: Namespace) -> List[str]:
        """Convert a :class:`argparse.Namespace` to a list of arguments.
        This is the opposite of the parsing of the command line. This static
        method is intended to filter and reconstruct the command line arguments
        that need to be passed to lower level APIs to start the actual Austin
        process.
        """
        arg_list = []
        if getattr(args, "alt_format", None):
            arg_list.append("-a")
        if getattr(args, "children", None):
            arg_list.append("-C")
        if getattr(args, "exclude_empty", None):
            arg_list.append("-e")
        if getattr(args, "full", None):
            arg_list.append("-f")
        if getattr(args, "interval", None):
            arg_list += ["-i", str(args.interval)]
        if getattr(args, "memory", None):
            arg_list.append("-m")
        if getattr(args, "pid", None):
            arg_list += ["-p", str(args.pid)]
        if getattr(args, "sleepless", None):
            arg_list.append("-s")
        if getattr(args, "timeout", None):
            arg_list += ["-t", str(args.timeout)]
        if getattr(args, "command", None):
            arg_list.append(args.command)

        return arg_list

As you can see, this is about 200 lines of code, but the report shows

----------- coverage: platform linux, python 3.8.3-final-0 -----------
Name                          Stmts   Miss  Cover   Missing
-----------------------------------------------------------
austin/cli.py                   126     93    26%   160-171, 306-2957

This is the test that exercises the code above

from austin.cli import AustinArgumentParser, AustinCommandLineError
from pytest import raises


class Bunch:
    def __getattr__(self, name):
        return self.__dict__.get(name)


def test_missing_command_and_pid():
    with raises(AustinCommandLineError):
        AustinArgumentParser().parse_args([])

    with raises(AustinCommandLineError):
        AustinArgumentParser(pid=False, command=False)


def test_command_with_options():
    args = AustinArgumentParser().parse_args(
        ["-i", "1000", "python3", "-c", 'print("Test")']
    )

    assert args.command == ["python3", "-c", 'print("Test")']


def test_command_with_options_and_arguments():
    args = AustinArgumentParser().parse_args(
        ["-i", "1000", "python3", "my_app.py", "-c", 'print("Test")']
    )

    assert args.command == ["python3", "my_app.py", "-c", 'print("Test")']


def test_command_with_austin_args():
    args = AustinArgumentParser().parse_args(
        ["-i", "1000", "python3", "my_app.py", "-i", "100"]
    )

    assert args.interval == 1000
    assert args.command == ["python3", "my_app.py", "-i", "100"]


def test_pid_only():
    args = AustinArgumentParser().parse_args(["-i", "1000", "-p", "1086"])

    assert args.pid == 1086


def test_args_list():

    args = Bunch()
    args.alt_format = True
    args.children = True
    args.exclude_empty = True
    args.full = True
    args.interval = 1000
    args.memory = True
    args.pid = 42
    args.sleepless = True
    args.timeout = 50
    args.command = "python3"

    args.foo = "bar"

    assert AustinArgumentParser.to_list(args) == [
        "-a",
        "-C",
        "-e",
        "-f",
        "-i",
        "1000",
        "-m",
        "-p",
        "42",
        "-s",
        "-t",
        "50",
        "python3",
    ]

@ionelmc
Copy link
Member

ionelmc commented Jun 4, 2020

Well if the CLI is enough to reproduce the problem put it in a public repo?

@matthieugouel
Copy link

matthieugouel commented Jul 26, 2020

Same problem here, with a project that uses subprocess, and asyncio in python 3.8. Unfortunately It's on a private repo for now but at least it seems not to be an isolated issue.
EDIT: I used pytest==6.0.0rc1 and pytest-cov==2.10.0, not nox.

@frsann
Copy link

frsann commented Aug 6, 2020

I had similar problems and updating coverage to ==5.2.1 helped.

@MatTerra
Copy link
Contributor

MatTerra commented Sep 10, 2020

Had the same issue with a public project NovaAPI with coverage version 4.4. Updating to 5.2.1 like @frsann suggested solved the issue. The coverage report was including 4000 lines in a file with only around 200 lines.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants