├── .gitignore ├── .github ├── release.yml └── workflows │ └── ci.yaml ├── .pre-commit-config.yaml ├── pyproject.toml ├── LICENSE ├── test_parse_log.py ├── README.md ├── action.yaml └── parse_logs.py /.gitignore: -------------------------------------------------------------------------------- 1 | /.hypothesis/ 2 | __pycache__/ 3 | 4 | /.prettier_cache/ 5 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot[bot] 5 | - pre-commit-ci[bot] 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | 8 | jobs: 9 | ci: 10 | name: tests 11 | runs-on: [ubuntu-latest] 12 | strategy: 13 | matrix: 14 | python-version: ["3.10", "3.11", "3.12", "3.13"] 15 | 16 | steps: 17 | - name: clone the repository 18 | uses: actions/checkout@v3 19 | - name: setup python 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: upgrade pip 24 | run: | 25 | python -m pip install --upgrade pip 26 | - name: install dependencies 27 | run: | 28 | python -m pip install pytest hypothesis more-itertools 29 | - name: run tests 30 | run: | 31 | python -m pytest -rf 32 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v5.0.0 7 | hooks: 8 | - id: trailing-whitespace 9 | - id: end-of-file-fixer 10 | 11 | - repo: https://github.com/psf/black-pre-commit-mirror 12 | rev: 25.1.0 13 | hooks: 14 | - id: black 15 | 16 | - repo: https://github.com/astral-sh/ruff-pre-commit 17 | rev: v0.12.2 18 | hooks: 19 | - id: ruff 20 | args: ["--fix", "--show-fixes"] 21 | 22 | - repo: https://github.com/rbubley/mirrors-prettier 23 | rev: v3.6.2 24 | hooks: 25 | - id: prettier 26 | args: ["--cache-location=.prettier_cache/cache"] 27 | 28 | - repo: https://github.com/ComPWA/taplo-pre-commit 29 | rev: v0.9.3 30 | hooks: 31 | - id: taplo-format 32 | args: ["--option", "array_auto_collapse=false"] 33 | - id: taplo-lint 34 | args: ["--no-schema"] 35 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | target-version = "py310" 3 | builtins = ["ellipsis"] 4 | exclude = [ 5 | ".git", 6 | ".eggs", 7 | "build", 8 | "dist", 9 | "__pycache__", 10 | ] 11 | line-length = 100 12 | 13 | [tool.ruff.lint] 14 | # E402: module level import not at top of file 15 | # E501: line too long - let black worry about that 16 | # E731: do not assign a lambda expression, use a def 17 | ignore = [ 18 | "E402", 19 | "E501", 20 | "E731", 21 | ] 22 | select = [ 23 | "F", # Pyflakes 24 | "E", # Pycodestyle 25 | "I", # isort 26 | "UP", # Pyupgrade 27 | "TID", # flake8-tidy-imports 28 | "W", 29 | ] 30 | extend-safe-fixes = [ 31 | "TID252", # absolute imports 32 | ] 33 | fixable = ["I", "TID252"] 34 | 35 | [tool.ruff.lint.flake8-tidy-imports] 36 | # Disallow all relative imports. 37 | ban-relative-imports = "all" 38 | 39 | [tool.coverage.run] 40 | branch = true 41 | 42 | [tool.coverage.report] 43 | show_missing = true 44 | exclude_lines = ["pragma: no cover", "if TYPE_CHECKING"] 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025-, Scientific Python Developers 4 | Copyright (c) 2022-2025, xarray developers 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /test_parse_log.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | 4 | import hypothesis.strategies as st 5 | from hypothesis import given, note 6 | 7 | import parse_logs 8 | 9 | directory_re = r"(\w|-)+" 10 | path_re = re.compile(rf"/?({directory_re}(/{directory_re})*/)?test_[A-Za-z0-9_]+\.py") 11 | filepaths = st.from_regex(path_re, fullmatch=True) 12 | 13 | group_re = r"Test[A-Za-z0-9_]+" 14 | name_re = re.compile(rf"({group_re}::)*test_[A-Za-z0-9_]+") 15 | names = st.from_regex(name_re, fullmatch=True) 16 | 17 | variants = st.from_regex(re.compile(r"(\w+-)*\w+"), fullmatch=True) 18 | 19 | messages = st.text() 20 | 21 | 22 | def ansi_csi_escapes(): 23 | parameter_bytes = st.lists(st.characters(min_codepoint=0x30, max_codepoint=0x3F)) 24 | intermediate_bytes = st.lists(st.characters(min_codepoint=0x20, max_codepoint=0x2F)) 25 | final_bytes = st.characters(min_codepoint=0x40, max_codepoint=0x7E) 26 | 27 | return st.builds( 28 | lambda *args: "".join(["\x1b[", *args]), 29 | parameter_bytes.map("".join), 30 | intermediate_bytes.map("".join), 31 | final_bytes, 32 | ) 33 | 34 | 35 | def ansi_c1_escapes(): 36 | byte_ = st.characters( 37 | codec="ascii", min_codepoint=0x40, max_codepoint=0x5F, exclude_characters=["["] 38 | ) 39 | return st.builds(lambda b: f"\x1b{b}", byte_) 40 | 41 | 42 | def ansi_fe_escapes(): 43 | return ansi_csi_escapes() | ansi_c1_escapes() 44 | 45 | 46 | def preformatted_reports(): 47 | return st.tuples(filepaths, names, variants | st.none(), messages).map( 48 | lambda x: parse_logs.PreformattedReport(*x) 49 | ) 50 | 51 | 52 | @given(filepaths, names, variants) 53 | def test_parse_nodeid(path, name, variant): 54 | if variant is not None: 55 | nodeid = f"{path}::{name}[{variant}]" 56 | else: 57 | nodeid = f"{path}::{name}" 58 | 59 | note(f"nodeid: {nodeid}") 60 | 61 | expected = {"filepath": path, "name": name, "variant": variant} 62 | actual = parse_logs.parse_nodeid(nodeid) 63 | 64 | assert actual == expected 65 | 66 | 67 | @given(st.lists(preformatted_reports()), st.integers(min_value=0)) 68 | def test_truncate(reports, max_chars): 69 | py_version = ".".join(str(part) for part in sys.version_info[:3]) 70 | 71 | formatted = parse_logs.truncate(reports, max_chars=max_chars, py_version=py_version) 72 | 73 | assert formatted is None or len(formatted) <= max_chars 74 | 75 | 76 | @given(st.lists(ansi_fe_escapes()).map("".join)) 77 | def test_strip_ansi_multiple(escapes): 78 | assert parse_logs.strip_ansi(escapes) == "" 79 | 80 | 81 | @given(ansi_fe_escapes()) 82 | def test_strip_ansi(escape): 83 | message = f"some {escape}text" 84 | 85 | assert parse_logs.strip_ansi(message) == "some text" 86 | 87 | 88 | @given(ansi_fe_escapes()) 89 | def test_preformatted_report_ansi(escape): 90 | actual = parse_logs.PreformattedReport( 91 | filepath="a", name="b", variant=None, message=f"{escape}text" 92 | ) 93 | assert actual.message == "text" 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # issue-from-pytest-log 2 | 3 | Create an issue for failed tests from a [pytest-reportlog](https://github.com/pytest-dev/pytest-reportlog) file or update an existing one if it already exists. 4 | 5 | How this works: 6 | 7 | 1. `pytest-reportlog` writes a complete and machine-readable log of failed tests. 8 | 2. The action extracts the failed tests and creates a report while making sure that it fits into the character limits of github issue forms. 9 | 3. The action looks for existing open issues with the configured title and label 10 | a. if one exists: replace the old description with the report 11 | b. if there is none: open a new issue and insert the report 12 | 13 | ## Usage 14 | 15 | To use the `issue-from-pytest-log` action in workflows, simply add a new step: 16 | 17 | > [!WARNING] 18 | > The action won't run properly unless the `issues: write` permission is requested as shown below. 19 | 20 | ```yaml 21 | jobs: 22 | my-job: 23 | ... 24 | strategy: 25 | fail-fast: false 26 | ... 27 | 28 | permissions: 29 | issues: write 30 | 31 | ... 32 | 33 | - uses: actions/setup-python@v4 34 | with: 35 | python-version: "3.12" 36 | cache: pip 37 | 38 | ... 39 | 40 | - run: | 41 | pip install --upgrade pytest-reportlog 42 | 43 | ... 44 | 45 | - run: | 46 | pytest --report-log pytest-log.jsonl 47 | 48 | ... 49 | 50 | - uses: scientific-python/issue-from-pytest-log-action@f94477e45ef40e4403d7585ba639a9a3bcc53d43 # v1.3.0 51 | if: | 52 | failure() 53 | && ... 54 | with: 55 | log-path: pytest-log.jsonl 56 | ``` 57 | 58 | See [this repository](https://github.com/keewis/reportlog-test/issues) for example issues. For more realistic examples, see 59 | 60 | - `xarray` ([workflow](https://github.com/pydata/xarray/blob/main/.github/workflows/upstream-dev-ci.yaml), [example issue](https://github.com/pydata/xarray/issues/6197)) 61 | - `dask` ([workflow](https://github.com/dask/dask/blob/main/.github/workflows/upstream.yml), [example issue](https://github.com/dask/dask/issues/10089)) 62 | 63 | ## Options 64 | 65 | ### log path 66 | 67 | required. 68 | 69 | Use `log-path` to specify where the output of `pytest-reportlog` is. 70 | 71 | ### issue title 72 | 73 | optional. Default: `⚠️ Nightly upstream-dev CI failed ⚠️` 74 | 75 | In case you don't like the default title for new issues, this setting can be used to set a different one: 76 | 77 | ```yaml 78 | - uses: scientific-python/issue-from-pytest-log-action@f94477e45ef40e4403d7585ba639a9a3bcc53d43 # v1.3.0 79 | with: 80 | log-path: pytest-log.jsonl 81 | issue-title: "Nightly CI failed" 82 | ``` 83 | 84 | The title can also be parametrized, in which case a separate issue will be opened for each variation of the title. 85 | 86 | ### issue label 87 | 88 | optional. Default: `CI` 89 | 90 | The label to set on the new issue. 91 | 92 | ```yaml 93 | - uses: scientific-python/issue-from-pytest-log-action@f94477e45ef40e4403d7585ba639a9a3bcc53d43 # v1.3.0 94 | with: 95 | log-path: pytest-log.jsonl 96 | issue-label: "CI" 97 | ``` 98 | 99 | ### assignees 100 | 101 | optional 102 | 103 | Any assignees to set on the new issue: 104 | 105 | ```yaml 106 | - uses: scientific-python/issue-from-pytest-log-action@f94477e45ef40e4403d7585ba639a9a3bcc53d43 # v1.3.0 107 | with: 108 | log-path: pytest-log.jsonl 109 | assignees: ["user1", "user2"] 110 | ``` 111 | 112 | Note that assignees must have the commit bit on the repository. 113 | -------------------------------------------------------------------------------- /action.yaml: -------------------------------------------------------------------------------- 1 | name: Create Issue From pytest log 2 | description: >- 3 | Create an issue for failed tests from a pytest-reportlog file. 4 | inputs: 5 | log-path: 6 | description: >- 7 | The path to the log file 8 | required: true 9 | issue-title: 10 | description: >- 11 | Title of issue being created or updated. Can be a parametrized string, in which case 12 | a new issue will be opened for all variations. 13 | required: false 14 | default: "⚠️ Nightly upstream-dev CI failed ⚠️" 15 | issue-label: 16 | description: >- 17 | Labels to apply to issue 18 | required: false 19 | default: "CI" 20 | assignees: 21 | description: >- 22 | Comma-separated users to assign to the issue (no spaces). All assigned users have to 23 | have commit rights. 24 | required: false 25 | default: "" 26 | outputs: {} 27 | branding: 28 | color: "red" 29 | icon: "alert-triangle" 30 | 31 | runs: 32 | using: composite 33 | # TODO: learn enough javascript / node.js to write the reportlog parsing 34 | steps: 35 | - name: print environment information 36 | shell: bash -l {0} 37 | run: | 38 | python --version 39 | python -m pip list 40 | - name: install dependencies 41 | shell: bash -l {0} 42 | run: | 43 | python -m pip install pytest more-itertools 44 | - name: produce the issue body 45 | shell: bash -l {0} 46 | run: | 47 | python $GITHUB_ACTION_PATH/parse_logs.py ${{ inputs.log-path }} 48 | - name: create the issue 49 | uses: actions/github-script@v7 50 | with: 51 | github-token: ${{ github.token }} 52 | script: | 53 | const fs = require('fs'); 54 | const pytest_logs = fs.readFileSync('pytest-logs.txt', 'utf8'); 55 | const assignees = "${{inputs.assignees}}".split(","); 56 | const workflow_url = `https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; 57 | const issue_body = `[Workflow Run URL](${workflow_url})\n${pytest_logs}`; 58 | 59 | const variables = { 60 | owner: context.repo.owner, 61 | name: context.repo.repo, 62 | label: "${{ inputs.issue-label }}", 63 | creator: "app/github-actions", 64 | title: "${{ inputs.issue-title }}" 65 | }; 66 | const query_string = `repo:${variables.owner}/${variables.name} author:${variables.creator} label:${variables.label} is:open in:title ${variables.title}`; 67 | 68 | // Run GraphQL query against GitHub API to find the most recent open issue used for reporting failures 69 | const query = `query { 70 | search(query: "${query_string}", type:ISSUE, first: 1) { 71 | edges { 72 | node { 73 | ... on Issue { 74 | body 75 | id 76 | number 77 | } 78 | } 79 | } 80 | } 81 | }`; 82 | 83 | const result = await github.graphql(query); 84 | 85 | // If no issue is open, create a new issue, 86 | // else update the body of the existing issue. 87 | if (result.search.edges.length === 0) { 88 | github.rest.issues.create({ 89 | owner: variables.owner, 90 | repo: variables.name, 91 | body: issue_body, 92 | title: variables.title, 93 | labels: [variables.label], 94 | assignees: assignees 95 | }); 96 | } else { 97 | github.rest.issues.update({ 98 | owner: variables.owner, 99 | repo: variables.name, 100 | issue_number: result.search.edges[0].node.number, 101 | body: issue_body 102 | }); 103 | } 104 | -------------------------------------------------------------------------------- /parse_logs.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | import argparse 3 | import functools 4 | import json 5 | import pathlib 6 | import re 7 | import sys 8 | import textwrap 9 | from dataclasses import dataclass 10 | 11 | import more_itertools 12 | from pytest import CollectReport, TestReport 13 | 14 | test_collection_stage = "test collection session" 15 | fe_bytes = "[\x40-\x5f]" 16 | parameter_bytes = "[\x30-\x3f]" 17 | intermediate_bytes = "[\x20-\x2f]" 18 | final_bytes = "[\x40-\x7e]" 19 | ansi_fe_escape_re = re.compile( 20 | rf""" 21 | \x1B # ESC 22 | (?: 23 | \[ # CSI 24 | {parameter_bytes}* 25 | {intermediate_bytes}* 26 | {final_bytes} 27 | | {fe_bytes} # single-byte Fe 28 | ) 29 | """, 30 | re.VERBOSE, 31 | ) 32 | 33 | 34 | def strip_ansi(msg): 35 | """strip all ansi escape sequences""" 36 | return ansi_fe_escape_re.sub("", msg) 37 | 38 | 39 | @dataclass 40 | class SessionStart: 41 | pytest_version: str 42 | outcome: str = "status" 43 | 44 | @classmethod 45 | def _from_json(cls, json): 46 | json_ = json.copy() 47 | json_.pop("$report_type") 48 | return cls(**json_) 49 | 50 | 51 | @dataclass 52 | class SessionFinish: 53 | exitstatus: str 54 | outcome: str = "status" 55 | 56 | @classmethod 57 | def _from_json(cls, json): 58 | json_ = json.copy() 59 | json_.pop("$report_type") 60 | return cls(**json_) 61 | 62 | 63 | @dataclass 64 | class PreformattedReport: 65 | filepath: str 66 | name: str 67 | variant: str | None 68 | message: str 69 | 70 | def __post_init__(self): 71 | self.message = strip_ansi(self.message) 72 | 73 | 74 | @dataclass 75 | class CollectionError: 76 | name: str 77 | repr_: str 78 | 79 | 80 | def parse_record(record): 81 | report_types = { 82 | "TestReport": TestReport, 83 | "CollectReport": CollectReport, 84 | "SessionStart": SessionStart, 85 | "SessionFinish": SessionFinish, 86 | } 87 | cls = report_types.get(record["$report_type"]) 88 | if cls is None: 89 | raise ValueError(f"unknown report type: {record['$report_type']}") 90 | 91 | return cls._from_json(record) 92 | 93 | 94 | nodeid_re = re.compile(r"(?P.+?)::(?P.+?)(?:\[(?P.+)\])?") 95 | 96 | 97 | def parse_nodeid(nodeid): 98 | match = nodeid_re.fullmatch(nodeid) 99 | if match is None: 100 | raise ValueError(f"unknown test id: {nodeid}") 101 | 102 | return match.groupdict() 103 | 104 | 105 | @functools.singledispatch 106 | def preformat_report(report): 107 | parsed = parse_nodeid(report.nodeid) 108 | return PreformattedReport(message=str(report), **parsed) 109 | 110 | 111 | @preformat_report.register 112 | def _(report: TestReport): 113 | parsed = parse_nodeid(report.nodeid) 114 | if isinstance(report.longrepr, str): 115 | message = report.longrepr 116 | else: 117 | message = report.longrepr.reprcrash.message 118 | return PreformattedReport(message=message, **parsed) 119 | 120 | 121 | @preformat_report.register 122 | def _(report: CollectReport): 123 | if report.nodeid == "": 124 | return CollectionError(name=test_collection_stage, repr_=str(report.longrepr)) 125 | 126 | if "::" not in report.nodeid: 127 | parsed = { 128 | "filepath": report.nodeid, 129 | "name": None, 130 | "variant": None, 131 | } 132 | else: 133 | parsed = parse_nodeid(report.nodeid) 134 | 135 | if isinstance(report.longrepr, str): 136 | message = report.longrepr.split("\n")[-1].removeprefix("E").lstrip() 137 | else: 138 | message = report.longrepr.reprcrash.message 139 | return PreformattedReport(message=message, **parsed) 140 | 141 | 142 | def format_summary(report): 143 | if report.variant is not None: 144 | return f"{report.filepath}::{report.name}[{report.variant}]: {report.message}" 145 | elif report.name is not None: 146 | return f"{report.filepath}::{report.name}: {report.message}" 147 | else: 148 | return f"{report.filepath}: {report.message}" 149 | 150 | 151 | def format_report(summaries, py_version): 152 | template = textwrap.dedent( 153 | """\ 154 |
Python {py_version} Test Summary 155 | 156 | ``` 157 | {summaries} 158 | ``` 159 | 160 |
161 | """ 162 | ) 163 | # can't use f-strings because that would format *before* the dedenting 164 | message = template.format(summaries="\n".join(summaries), py_version=py_version) 165 | return message 166 | 167 | 168 | def merge_variants(reports, max_chars, **formatter_kwargs): 169 | def format_variant_group(name, group): 170 | filepath, test_name, message = name 171 | 172 | n_variants = len(group) 173 | if n_variants != 1: 174 | return f"{filepath}::{test_name}[{n_variants} failing variants]: {message}" 175 | elif n_variants == 1 and group[0].variant is not None: 176 | report = more_itertools.one(group) 177 | return f"{filepath}::{test_name}[{report.variant}]: {message}" 178 | else: 179 | return f"{filepath}::{test_name}: {message}" 180 | 181 | bucket = more_itertools.bucket(reports, lambda r: (r.filepath, r.name, r.message)) 182 | 183 | summaries = [format_variant_group(name, list(bucket[name])) for name in bucket] 184 | formatted = format_report(summaries, **formatter_kwargs) 185 | 186 | return formatted 187 | 188 | 189 | def truncate(reports, max_chars, **formatter_kwargs): 190 | fractions = [0.95, 0.75, 0.5, 0.25, 0.1, 0.01] 191 | 192 | n_reports = len(reports) 193 | for fraction in fractions: 194 | n_selected = int(n_reports * fraction) 195 | selected_reports = reports[: int(n_reports * fraction)] 196 | report_messages = [format_summary(report) for report in selected_reports] 197 | summary = report_messages + [f"+ {n_reports - n_selected} failing tests"] 198 | formatted = format_report(summary, **formatter_kwargs) 199 | if len(formatted) <= max_chars: 200 | return formatted 201 | 202 | return None 203 | 204 | 205 | def summarize(reports, **formatter_kwargs): 206 | summary = [f"{len(reports)} failing tests"] 207 | return format_report(summary, **formatter_kwargs) 208 | 209 | 210 | def compressed_report(reports, max_chars, **formatter_kwargs): 211 | strategies = [ 212 | merge_variants, 213 | # merge_test_files, 214 | # merge_tests, 215 | truncate, 216 | ] 217 | summaries = [format_summary(report) for report in reports] 218 | formatted = format_report(summaries, **formatter_kwargs) 219 | if len(formatted) <= max_chars: 220 | return formatted 221 | 222 | for strategy in strategies: 223 | formatted = strategy(reports, max_chars=max_chars, **formatter_kwargs) 224 | if formatted is not None and len(formatted) <= max_chars: 225 | return formatted 226 | 227 | return summarize(reports, **formatter_kwargs) 228 | 229 | 230 | def format_collection_error(error, **formatter_kwargs): 231 | return textwrap.dedent( 232 | """\ 233 |
Python {py_version} Test Summary 234 | 235 | {name} failed: 236 | ``` 237 | {traceback} 238 | ``` 239 | 240 |
241 | """ 242 | ).format(py_version=py_version, name=error.name, traceback=error.repr_) 243 | 244 | 245 | if __name__ == "__main__": 246 | parser = argparse.ArgumentParser() 247 | parser.add_argument("filepath", type=pathlib.Path) 248 | args = parser.parse_args() 249 | 250 | py_version = ".".join(str(_) for _ in sys.version_info[:2]) 251 | 252 | print("Parsing logs ...") 253 | 254 | lines = args.filepath.read_text().splitlines() 255 | parsed_lines = [json.loads(line) for line in lines] 256 | reports = [ 257 | parse_record(data) 258 | for data in parsed_lines 259 | if data["$report_type"] != "WarningMessage" 260 | ] 261 | 262 | failed = [report for report in reports if report.outcome == "failed"] 263 | preformatted = [preformat_report(report) for report in failed] 264 | if len(preformatted) == 1 and isinstance(preformatted[0], CollectionError): 265 | message = format_collection_error(preformatted[0], py_version=py_version) 266 | else: 267 | message = compressed_report( 268 | preformatted, max_chars=65535, py_version=py_version 269 | ) 270 | 271 | output_file = pathlib.Path("pytest-logs.txt") 272 | print(f"Writing output file to: {output_file.absolute()}") 273 | output_file.write_text(message) 274 | --------------------------------------------------------------------------------