Skip to content

Annotate warnings #68

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

Merged
merged 3 commits into from
Oct 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ env:
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version:
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,9 @@ If your test is running in a Docker container, you have to install this plugin a

If your tests are run from a subdirectory of the git repository, you have to set the `PYTEST_RUN_PATH` environment variable to the path of that directory relative to the repository root in order for GitHub to identify the files with errors correctly.

### Warning annotations

This plugin also supports warning annotations when used with Pytest 6.0+. To disable warning annotations, pass `--exclude-warning-annotations` to pytest.

## Screenshot
[![Image from Gyazo](https://i.gyazo.com/b578304465dd1b755ceb0e04692a57d9.png)](https://gyazo.com/b578304465dd1b755ceb0e04692a57d9)
120 changes: 98 additions & 22 deletions plugin_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,17 @@
import pytest
from packaging import version

PYTEST_VERSION = version.parse(pytest.__version__)
pytest_plugins = "pytester"


# result.stderr.no_fnmatch_line() is added to testdir on pytest 5.3.0
# result.stderr.no_fnmatch_line() was added to testdir on pytest 5.3.0
# https://docs.pytest.org/en/stable/changelog.html#pytest-5-3-0-2019-11-19
def no_fnmatch_line(result, pattern):
if version.parse(pytest.__version__) >= version.parse("5.3.0"):
result.stderr.no_fnmatch_line(pattern + "*",)
else:
assert pattern not in result.stderr.str()
def no_fnmatch_line(result: pytest.RunResult, pattern: str):
result.stderr.no_fnmatch_line(pattern + "*")


def test_annotation_succeed_no_output(testdir):
def test_annotation_succeed_no_output(testdir: pytest.Testdir):
testdir.makepyfile(
"""
import pytest
Expand All @@ -33,7 +31,7 @@ def test_success():
no_fnmatch_line(result, "::error file=test_annotation_succeed_no_output.py")


def test_annotation_pytest_error(testdir):
def test_annotation_pytest_error(testdir: pytest.Testdir):
testdir.makepyfile(
"""
import pytest
Expand All @@ -55,7 +53,7 @@ def test_error():
)


def test_annotation_fail(testdir):
def test_annotation_fail(testdir: pytest.Testdir):
testdir.makepyfile(
"""
import pytest
Expand All @@ -68,11 +66,13 @@ def test_fail():
testdir.monkeypatch.setenv("GITHUB_ACTIONS", "true")
result = testdir.runpytest_subprocess()
result.stderr.fnmatch_lines(
["::error file=test_annotation_fail.py,line=5::test_fail*assert 0*",]
[
"::error file=test_annotation_fail.py,line=5::test_fail*assert 0*",
]
)


def test_annotation_exception(testdir):
def test_annotation_exception(testdir: pytest.Testdir):
testdir.makepyfile(
"""
import pytest
Expand All @@ -86,11 +86,51 @@ def test_fail():
testdir.monkeypatch.setenv("GITHUB_ACTIONS", "true")
result = testdir.runpytest_subprocess()
result.stderr.fnmatch_lines(
["::error file=test_annotation_exception.py,line=5::test_fail*oops*",]
[
"::error file=test_annotation_exception.py,line=5::test_fail*oops*",
]
)


def test_annotation_warning(testdir: pytest.Testdir):
testdir.makepyfile(
"""
import warnings
import pytest
pytest_plugins = 'pytest_github_actions_annotate_failures'
def test_warning():
warnings.warn('beware', Warning)
assert 1
"""
)
testdir.monkeypatch.setenv("GITHUB_ACTIONS", "true")
result = testdir.runpytest_subprocess()
result.stderr.fnmatch_lines(
[
"::warning file=test_annotation_warning.py,line=6::beware",
]
)


def test_annotation_exclude_warnings(testdir: pytest.Testdir):
testdir.makepyfile(
"""
import warnings
import pytest
pytest_plugins = 'pytest_github_actions_annotate_failures'
def test_warning():
warnings.warn('beware', Warning)
assert 1
"""
)
testdir.monkeypatch.setenv("GITHUB_ACTIONS", "true")
result = testdir.runpytest_subprocess("--exclude-warning-annotations")
assert not result.stderr.lines


def test_annotation_third_party_exception(testdir):
def test_annotation_third_party_exception(testdir: pytest.Testdir):
testdir.makepyfile(
my_module="""
def fn():
Expand All @@ -111,11 +151,43 @@ def test_fail():
testdir.monkeypatch.setenv("GITHUB_ACTIONS", "true")
result = testdir.runpytest_subprocess()
result.stderr.fnmatch_lines(
["::error file=test_annotation_third_party_exception.py,line=6::test_fail*oops*",]
[
"::error file=test_annotation_third_party_exception.py,line=6::test_fail*oops*",
]
)


def test_annotation_third_party_warning(testdir: pytest.Testdir):
testdir.makepyfile(
my_module="""
import warnings
def fn():
warnings.warn('beware', Warning)
"""
)

testdir.makepyfile(
"""
import pytest
from my_module import fn
pytest_plugins = 'pytest_github_actions_annotate_failures'
def test_warning():
fn()
"""
)
testdir.monkeypatch.setenv("GITHUB_ACTIONS", "true")
result = testdir.runpytest_subprocess()
result.stderr.fnmatch_lines(
# ["::warning file=test_annotation_third_party_warning.py,line=6::beware",]
[
"::warning file=my_module.py,line=4::beware",
]
)


def test_annotation_fail_disabled_outside_workflow(testdir):
def test_annotation_fail_disabled_outside_workflow(testdir: pytest.Testdir):
testdir.makepyfile(
"""
import pytest
Expand All @@ -132,7 +204,7 @@ def test_fail():
)


def test_annotation_fail_cwd(testdir):
def test_annotation_fail_cwd(testdir: pytest.Testdir):
testdir.makepyfile(
"""
import pytest
Expand All @@ -148,11 +220,13 @@ def test_fail():
testdir.makefile(".ini", pytest="[pytest]\ntestpaths=..")
result = testdir.runpytest_subprocess("--rootdir=foo")
result.stderr.fnmatch_lines(
["::error file=test_annotation_fail_cwd.py,line=5::test_fail*assert 0*",]
[
"::error file=test_annotation_fail_cwd.py,line=5::test_fail*assert 0*",
]
)


def test_annotation_fail_runpath(testdir):
def test_annotation_fail_runpath(testdir: pytest.Testdir):
testdir.makepyfile(
"""
import pytest
Expand All @@ -166,11 +240,13 @@ def test_fail():
testdir.monkeypatch.setenv("PYTEST_RUN_PATH", "some_path")
result = testdir.runpytest_subprocess()
result.stderr.fnmatch_lines(
["::error file=some_path/test_annotation_fail_runpath.py,line=5::test_fail*assert 0*",]
[
"::error file=some_path/test_annotation_fail_runpath.py,line=5::test_fail*assert 0*",
]
)


def test_annotation_long(testdir):
def test_annotation_long(testdir: pytest.Testdir):
testdir.makepyfile(
"""
import pytest
Expand Down Expand Up @@ -202,7 +278,7 @@ def test_fail():
no_fnmatch_line(result, "::*assert x += 1*")


def test_class_method(testdir):
def test_class_method(testdir: pytest.Testdir):
testdir.makepyfile(
"""
import pytest
Expand All @@ -224,7 +300,7 @@ def test_method(self):
no_fnmatch_line(result, "::*x = 1*")


def test_annotation_param(testdir):
def test_annotation_param(testdir: pytest.Testdir):
testdir.makepyfile(
"""
import pytest
Expand Down
100 changes: 85 additions & 15 deletions pytest_github_actions_annotate_failures/plugin.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@

from __future__ import annotations

import contextlib
import os
import sys
from collections import OrderedDict
from typing import TYPE_CHECKING

import pytest
from _pytest._code.code import ExceptionRepr
from packaging import version

if TYPE_CHECKING:
from _pytest.nodes import Item
Expand All @@ -23,6 +24,9 @@
# https://github.com/pytest-dev/pytest/blob/master/src/_pytest/terminal.py


PYTEST_VERSION = version.parse(pytest.__version__)


@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item: Item, call): # noqa: ARG001
# execute all other hooks to obtain the report object
Expand Down Expand Up @@ -79,25 +83,91 @@ def pytest_runtest_makereport(item: Item, call): # noqa: ARG001
elif isinstance(report.longrepr, str):
longrepr += "\n\n" + report.longrepr

print(
_error_workflow_command(filesystempath, lineno, longrepr), file=sys.stderr
workflow_command = _build_workflow_command(
"error",
filesystempath,
lineno,
message=longrepr,
)
print(workflow_command, file=sys.stderr)


def _error_workflow_command(filesystempath, lineno, longrepr):
# Build collection of arguments. Ordering is strict for easy testing
details_dict = OrderedDict()
details_dict["file"] = filesystempath
if lineno is not None:
details_dict["line"] = lineno

details = ",".join(f"{k}={v}" for k, v in details_dict.items())
class _AnnotateWarnings:
def pytest_warning_recorded(self, warning_message, when, nodeid, location): # noqa: ARG002
# enable only in a workflow of GitHub Actions
# ref: https://help.github.com/en/actions/configuring-and-managing-workflows/using-environment-variables#default-environment-variables
if os.environ.get("GITHUB_ACTIONS") != "true":
return

if longrepr is None:
return f"\n::error {details}"
filesystempath = warning_message.filename
workspace = os.environ.get("GITHUB_WORKFLOW")

longrepr = _escape(longrepr)
return f"\n::error {details}::{longrepr}"
if workspace:
try:
rel_path = os.path.relpath(filesystempath, workspace)
except ValueError:
# os.path.relpath() will raise ValueError on Windows
# when full_path and workspace have different mount points.
rel_path = filesystempath
if not rel_path.startswith(".."):
filesystempath = rel_path
else:
with contextlib.suppress(ValueError):
filesystempath = os.path.relpath(filesystempath)
Comment on lines +115 to +116
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@henryiii this lets Windows tests pass now


workflow_command = _build_workflow_command(
"warning",
filesystempath,
warning_message.lineno,
message=warning_message.message.args[0],
)
print(workflow_command, file=sys.stderr)


def pytest_addoption(parser):
group = parser.getgroup("pytest_github_actions_annotate_failures")
group.addoption(
"--exclude-warning-annotations",
action="store_true",
default=False,
help="Annotate failures in GitHub Actions.",
)

def pytest_configure(config):
if not config.option.exclude_warning_annotations:
config.pluginmanager.register(_AnnotateWarnings(), "annotate_warnings")


def _build_workflow_command(
command_name,
file,
line,
end_line=None,
column=None,
end_column=None,
title=None,
message=None,
):
"""Build a command to annotate a workflow."""
result = f"::{command_name} "

entries = [
("file", file),
("line", line),
("endLine", end_line),
("col", column),
("endColumn", end_column),
("title", title),
]

result = result + ",".join(
f"{k}={v}" for k, v in entries if v is not None
)

if message is not None:
result = result + "::" + _escape(message)

return result


def _escape(s):
Expand Down