From b53d51e0184911eaaecca264e03ffb0d95b22d29 Mon Sep 17 00:00:00 2001 From: Haadiyah-Zafar Date: Tue, 18 Aug 2026 08:59:42 +0500 Subject: [PATCH] Add option to truncate GitHub annotations --- README.md | 2 + plugin_test.py | 56 ++++++++++++++++++- .../plugin.py | 47 ++++++++++++++-- 3 files changed, 100 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9d03932..37d860f 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ 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. +To keep GitHub Actions annotations concise, pass `--github-annotation-max-length=N` to truncate annotation messages longer than `N` characters. The default value is `0`, which disables truncation. + ### Warning annotations This plugin also supports warning annotations when used with Pytest 6.0+. To disable warning annotations, pass `--exclude-warning-annotations` to pytest. diff --git a/plugin_test.py b/plugin_test.py index 089091f..e14f0d2 100644 --- a/plugin_test.py +++ b/plugin_test.py @@ -4,13 +4,18 @@ import sys import warnings from collections import Counter +from pathlib import Path import pytest from packaging import version -from pytest_github_actions_annotate_failures.plugin import _AnnotateWarnings +from pytest_github_actions_annotate_failures.plugin import ( + _AnnotateWarnings, + _truncate_message, +) PYTEST_VERSION = version.parse(pytest.__version__) +REPO_ROOT = Path(__file__).parent pytest_plugins = "pytester" @@ -393,6 +398,55 @@ def test_fail(): result.stderr.no_fnmatch_line("::*assert x += 1*") +def test_truncate_message(): + assert _truncate_message("abc", 0) == "abc" + assert _truncate_message("abc", 3) == "abc" + assert _truncate_message("abcdef", 5) == "ab..." + assert _truncate_message("abcdef", 2) == ".." + + +def test_annotation_fail_with_max_length( + pytester: pytest.Pytester, + monkeypatch: pytest.MonkeyPatch, +): + pytester.makepyfile( + """ + import pytest + + def test_fail(): + assert False, "x" * 1000 + """ + ) + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("PYTHONPATH", str(REPO_ROOT)) + result = pytester.runpytest_subprocess( + "-p", + "pytest_github_actions_annotate_failures.plugin", + "--github-annotation-max-length=80", + ) + lines = [line for line in result.errlines if line.startswith("::error ")] + + assert len(lines) == 1 + annotation_message = lines[0].split("::", 2)[2].replace("%0A", "\n") + assert len(annotation_message) == 80 + assert annotation_message.endswith("...") + assert "x" * 100 not in annotation_message + + +def test_annotation_max_length_rejects_negative( + pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setenv("PYTHONPATH", str(REPO_ROOT)) + result = pytester.runpytest_subprocess( + "-p", + "pytest_github_actions_annotate_failures.plugin", + "--github-annotation-max-length=-1", + ) + result.stderr.fnmatch_lines( + ["ERROR: --github-annotation-max-length must be greater than or equal to 0"] + ) + + def test_class_method(pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch): pytester.makepyfile( """ diff --git a/pytest_github_actions_annotate_failures/plugin.py b/pytest_github_actions_annotate_failures/plugin.py index 977d830..72248ff 100644 --- a/pytest_github_actions_annotate_failures/plugin.py +++ b/pytest_github_actions_annotate_failures/plugin.py @@ -13,6 +13,8 @@ from _pytest.reports import TestReport +_ELLIPSIS = "..." + # Reference: # https://docs.pytest.org/en/latest/writing_plugins.html#hookwrapper-executing-around-other-hooks @@ -24,6 +26,9 @@ class _AnnotateErrors: + def __init__(self, max_annotation_length: int = 0) -> None: + self.max_annotation_length = max_annotation_length + @pytest.hookimpl(tryfirst=True) def pytest_runtest_logreport(self, report: TestReport): """Handle test reporting for all pytest versions.""" @@ -68,7 +73,7 @@ def pytest_runtest_logreport(self, report: TestReport): "error", compute_path(filesystempath), lineno, - message=longrepr, + message=_truncate_message(longrepr, self.max_annotation_length), ) print(workflow_command, file=sys.stderr) @@ -96,6 +101,9 @@ def compute_path(filesystempath: str) -> str: class _AnnotateWarnings: + def __init__(self, max_annotation_length: int = 0) -> None: + self.max_annotation_length = max_annotation_length + def pytest_warning_recorded( self, warning_message: WarningMessage, @@ -116,7 +124,9 @@ def pytest_warning_recorded( "warning", compute_path(filesystempath), warning_message.lineno, - message=str(warning_message.message), + message=_truncate_message( + str(warning_message.message), self.max_annotation_length + ), ) print(workflow_command, file=sys.stderr) @@ -129,6 +139,16 @@ def pytest_addoption(parser): default=False, help="Exclude annotating warnings in GitHub Actions.", ) + group.addoption( + "--github-annotation-max-length", + action="store", + default=0, + type=int, + help=( + "Maximum length of GitHub Actions annotation messages. " + "Use 0 to disable truncation." + ), + ) def pytest_configure(config): @@ -138,10 +158,19 @@ def pytest_configure(config): if config.pluginmanager.hasplugin("xdist") and hasattr(config, "workerinput"): return + max_annotation_length = config.option.github_annotation_max_length + if max_annotation_length < 0: + msg = "--github-annotation-max-length must be greater than or equal to 0" + raise pytest.UsageError(msg) + if not config.option.exclude_warning_annotations: - config.pluginmanager.register(_AnnotateWarnings(), "annotate_warnings") + config.pluginmanager.register( + _AnnotateWarnings(max_annotation_length), "annotate_warnings" + ) - config.pluginmanager.register(_AnnotateErrors(), "annotate_errors") + config.pluginmanager.register( + _AnnotateErrors(max_annotation_length), "annotate_errors" + ) def _build_workflow_command( @@ -176,3 +205,13 @@ def _build_workflow_command( def _escape(s: str) -> str: return s.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + + +def _truncate_message(message: str, max_length: int) -> str: + if max_length <= 0 or len(message) <= max_length: + return message + + if max_length <= len(_ELLIPSIS): + return _ELLIPSIS[:max_length] + + return message[: max_length - len(_ELLIPSIS)] + _ELLIPSIS