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

Log the actual exception that caused a ParseError #322

Merged
merged 6 commits into from Jul 6, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 5 additions & 2 deletions python/publish/github_action.py
Expand Up @@ -62,8 +62,11 @@ def warning(self, message: str, file: Optional[str] = None, line: Optional[int]
params.update(col=column)
self._command(self._file, 'warning', message, params)

def error(self, message: str, file: Optional[str] = None, line: Optional[int] = None, column: Optional[int] = None):
logger.error(message)
def error(self,
message: str,
file: Optional[str] = None, line: Optional[int] = None, column: Optional[int] = None,
exception: Optional[BaseException] = None):
logger.error(message, exc_info=exception)

params = {}
if file is not None:
Expand Down
61 changes: 36 additions & 25 deletions python/publish/junit.py
Expand Up @@ -120,50 +120,61 @@ def close(self) -> Element:


JUnitTree = etree.ElementTree
JUnitTreeOrException = Union[JUnitTree, BaseException]
ParsedJUnitFile = Tuple[str, JUnitTreeOrException]
JUnitTreeOrParseError = Union[JUnitTree, ParseError]
JUnitXmlOrParseError = Union[JUnitXml, ParseError]
ParsedJUnitFile = Tuple[str, JUnitTreeOrParseError]


def safe_parse_xml_file(path: str, parse: Callable[[str], JUnitTree]) -> JUnitTreeOrParseError:
"""Parses an xml file and returns either a JUnitTree or a ParseError."""
if not os.path.exists(path):
return ParseError.from_exception(path, FileNotFoundError(f'File does not exist.'))
if os.stat(path).st_size == 0:
return ParseError.from_exception(path, Exception(f'File is empty.'))

try:
return parse(path)
except BaseException as e:
return ParseError.from_exception(path, e)


def progress_safe_parse_xml_file(files: Iterable[str],
parse: Callable[[str], JUnitTree],
progress: Callable[[ParsedJUnitFile], ParsedJUnitFile]) -> Iterable[ParsedJUnitFile]:
return [progress((file, safe_parse_xml_file(file, parse))) for file in files]


def parse_junit_xml_files(files: Iterable[str],
drop_testcases: bool = False,
progress: Callable[[ParsedJUnitFile], ParsedJUnitFile] = lambda x: x) -> Iterable[ParsedJUnitFile]:
"""Parses junit xml files."""
def parse(path: str) -> JUnitTreeOrException:
"""Parses a junit xml file and returns either a JUnitTree or an Exception."""
if not os.path.exists(path):
return FileNotFoundError(f'File does not exist.')
if os.stat(path).st_size == 0:
return Exception(f'File is empty.')

try:
if drop_testcases:
builder = DropTestCaseBuilder()
parser = etree.XMLParser(target=builder, encoding='utf-8', huge_tree=True)
return etree.parse(path, parser=parser)
return etree.parse(path)
except BaseException as e:
return e
def parse(path: str) -> JUnitTree:
if drop_testcases:
builder = DropTestCaseBuilder()
parser = etree.XMLParser(target=builder, encoding='utf-8', huge_tree=True)
return etree.parse(path, parser=parser)
return etree.parse(path)

return [progress((result_file, parse(result_file))) for result_file in files]
return progress_safe_parse_xml_file(files, parse, progress)


def process_junit_xml_elems(trees: Iterable[ParsedJUnitFile], time_factor: float = 1.0) -> ParsedUnitTestResults:
def create_junitxml(filepath: str, tree: JUnitTree) -> Union[JUnitXml, JUnitXmlError]:
def create_junitxml(filepath: str, tree: JUnitTree) -> JUnitXmlOrParseError:
try:
instance = JUnitXml.fromroot(tree.getroot())
instance.filepath = filepath
return instance
except JUnitXmlError as e:
return e
return ParseError.from_exception(filepath, e)

processed = [(result_file, create_junitxml(result_file, tree) if not isinstance(tree, BaseException) else tree)
processed = [(result_file, create_junitxml(result_file, tree) if not isinstance(tree, ParseError) else tree)
for result_file, tree in trees]
junits = [(result_file, junit)
for result_file, junit in processed
if not isinstance(junit, BaseException)]
errors = [ParseError.from_exception(result_file, exception)
for result_file, exception in processed
if isinstance(exception, BaseException)]
if not isinstance(junit, ParseError)]
errors = [error
for _, error in processed
if isinstance(error, ParseError)]

suites = [(result_file, suite)
for result_file, junit in junits
Expand Down
22 changes: 6 additions & 16 deletions python/publish/nunit.py
@@ -1,29 +1,19 @@
import os
import pathlib
from typing import Iterable, Callable

from lxml import etree

from publish.junit import JUnitTreeOrException, ParsedJUnitFile
from publish.junit import JUnitTree, ParsedJUnitFile, progress_safe_parse_xml_file

with (pathlib.Path(__file__).parent / 'xslt' / 'nunit3-to-junit.xslt').open('r', encoding='utf-8') as r:
with (pathlib.Path(__file__).resolve().parent / 'xslt' / 'nunit3-to-junit.xslt').open('r', encoding='utf-8') as r:
transform_nunit_to_junit = etree.XSLT(etree.parse(r))


def parse_nunit_files(files: Iterable[str],
progress: Callable[[ParsedJUnitFile], ParsedJUnitFile] = lambda x: x) -> Iterable[ParsedJUnitFile]:
"""Parses nunit files."""
def parse(path: str) -> JUnitTreeOrException:
"""Parses an nunit file and returns either a JUnitTree or an Exception."""
if not os.path.exists(path):
return FileNotFoundError(f'File does not exist.')
if os.stat(path).st_size == 0:
return Exception(f'File is empty.')
def parse(path: str) -> JUnitTree:
nunit = etree.parse(path)
return transform_nunit_to_junit(nunit)

try:
nunit = etree.parse(path)
return transform_nunit_to_junit(nunit)
except BaseException as e:
return e

return [progress((result_file, parse(result_file))) for result_file in files]
return progress_safe_parse_xml_file(files, parse, progress)
8 changes: 7 additions & 1 deletion python/publish/publisher.py
Expand Up @@ -98,8 +98,14 @@ def _formatted_stats_and_delta(cls,
return d

def _as_dict(self) -> Dict[str, Any]:
self_without_exceptions = dataclasses.replace(
self,
stats=self.stats.without_exceptions(),
stats_with_delta=self.stats_with_delta.without_exceptions() if self.stats_with_delta else None
)
# the dict_factory removes None values
return dataclasses.asdict(self, dict_factory=lambda x: {k: v for (k, v) in x if v is not None})
return dataclasses.asdict(self_without_exceptions,
dict_factory=lambda x: {k: v for (k, v) in x if v is not None})

def to_dict(self, thousands_separator: str) -> Mapping[str, Any]:
d = self._as_dict()
Expand Down
22 changes: 6 additions & 16 deletions python/publish/trx.py
@@ -1,29 +1,19 @@
import os
import pathlib
from typing import Iterable, Callable

from lxml import etree

from publish.junit import JUnitTreeOrException, ParsedJUnitFile
from publish.junit import JUnitTree, ParsedJUnitFile, progress_safe_parse_xml_file

with (pathlib.Path(__file__).parent / 'xslt' / 'trx-to-junit.xslt').open('r', encoding='utf-8') as r:
with (pathlib.Path(__file__).resolve().parent / 'xslt' / 'trx-to-junit.xslt').open('r', encoding='utf-8') as r:
transform_trx_to_junit = etree.XSLT(etree.parse(r))


def parse_trx_files(files: Iterable[str],
progress: Callable[[ParsedJUnitFile], ParsedJUnitFile] = lambda x: x) -> Iterable[ParsedJUnitFile]:
"""Parses trx files."""
def parse(path: str) -> JUnitTreeOrException:
"""Parses a trx file and returns either a JUnitTree or an Exception."""
if not os.path.exists(path):
return FileNotFoundError(f'File does not exist.')
if os.stat(path).st_size == 0:
return Exception(f'File is empty.')
def parse(path: str) -> JUnitTree:
trx = etree.parse(path)
return transform_trx_to_junit(trx)

try:
trx = etree.parse(path)
return transform_trx_to_junit(trx)
except BaseException as e:
return e

return [progress((result_file, parse(result_file))) for result_file in files]
return progress_safe_parse_xml_file(files, parse, progress)
67 changes: 61 additions & 6 deletions python/publish/unittestresults.py
@@ -1,5 +1,5 @@
from collections import defaultdict
import dataclasses
from collections import defaultdict
from dataclasses import dataclass
from typing import Optional, List, Mapping, Any, Union, Dict, Callable
from xml.etree.ElementTree import ParseError as XmlParseError
Expand Down Expand Up @@ -31,6 +31,7 @@ class ParseError:
message: str
line: Optional[int] = None
column: Optional[int] = None
exception: Optional[BaseException] = None

@staticmethod
def from_exception(file: str, exception: BaseException):
Expand All @@ -44,8 +45,12 @@ def from_exception(file: str, exception: BaseException):
msg = f'File is not a valid XML file:\n{msg}'
elif msg.startswith('Invalid format.'):
msg = f'File is not a valid JUnit file:\n{msg}'
return ParseError(file=file, message=msg, line=line, column=column)
return ParseError(file=file, message=str(exception))
return ParseError(file=file, message=msg, line=line, column=column, exception=exception)
return ParseError(file=file, message=str(exception), exception=exception)

# exceptions can be arbitrary types and might not be serializable
def without_exception(self) -> 'ParseError':
return dataclasses.replace(self, exception=None)


@dataclass(frozen=True)
Expand Down Expand Up @@ -208,7 +213,7 @@ def is_different_in_failures(self, other: 'UnitTestRunResultsOrDeltaResults'):
def is_different_in_errors(self, other: 'UnitTestRunResultsOrDeltaResults'):
return self.is_different(other, self._error_fields)

def with_errors(self, errors: List[ParseError]):
def with_errors(self, errors: List[ParseError]) -> 'UnitTestRunResults':
return UnitTestRunResults(
files=self.files,
errors=errors,
Expand All @@ -230,8 +235,32 @@ def with_errors(self, errors: List[ParseError]):
commit=self.commit
)

# exceptions can be arbitrary types and might not be serializable
def without_exceptions(self) -> 'UnitTestRunResults':
return UnitTestRunResults(
files=self.files,
errors=[error.without_exception() for error in self.errors],
suites=self.suites,
duration=self.duration,

tests=self.tests,
tests_succ=self.tests_succ,
tests_skip=self.tests_skip,
tests_fail=self.tests_fail,
tests_error=self.tests_error,

runs=self.runs,
runs_succ=self.runs_succ,
runs_skip=self.runs_skip,
runs_fail=self.runs_fail,
runs_error=self.runs_error,

commit=self.commit
)

def to_dict(self) -> Dict[str, Any]:
return dataclasses.asdict(self)
# dict is usually used to serialize, but exceptions are likely not serializable, so we exclude them
return dataclasses.asdict(self.without_exceptions())

@staticmethod
def from_dict(values: Mapping[str, Any]) -> 'UnitTestRunResults':
Expand Down Expand Up @@ -315,7 +344,8 @@ def has_errors(self):
return len(self.errors) > 0 or self.tests_error.get('number') > 0 or self.runs_error.get('number') > 0

def to_dict(self) -> Dict[str, Any]:
return dataclasses.asdict(self)
# dict is usually used to serialize, but exceptions are likely not serializable, so we exclude them
return dataclasses.asdict(self.without_exceptions())

def without_delta(self) -> UnitTestRunResults:
def v(value: Numeric) -> int:
Expand All @@ -329,6 +359,31 @@ def d(value: Numeric) -> int:
runs=v(self.runs), runs_succ=v(self.runs_succ), runs_skip=v(self.runs_skip), runs_fail=v(self.runs_fail), runs_error=v(self.runs_error),
commit=self.commit)

def without_exceptions(self) -> 'UnitTestRunDeltaResults':
return UnitTestRunDeltaResults(
files=self.files,
errors=[error.without_exception() for error in self.errors],
suites=self.suites,
duration=self.duration,

tests=self.tests,
tests_succ=self.tests_succ,
tests_skip=self.tests_skip,
tests_fail=self.tests_fail,
tests_error=self.tests_error,

runs=self.runs,
runs_succ=self.runs_succ,
runs_skip=self.runs_skip,
runs_fail=self.runs_fail,
runs_error=self.runs_error,

commit=self.commit,

reference_type=self.reference_type,
reference_commit=self.reference_commit
)


UnitTestRunResultsOrDeltaResults = Union[UnitTestRunResults, UnitTestRunDeltaResults]

Expand Down
22 changes: 6 additions & 16 deletions python/publish/xunit.py
@@ -1,29 +1,19 @@
import os
import pathlib
from typing import Iterable, Callable

from lxml import etree

from publish.junit import JUnitTreeOrException, ParsedJUnitFile
from publish.junit import JUnitTree, ParsedJUnitFile, progress_safe_parse_xml_file

with (pathlib.Path(__file__).parent / 'xslt' / 'xunit-to-junit.xslt').open('r', encoding='utf-8') as r:
with (pathlib.Path(__file__).resolve().parent / 'xslt' / 'xunit-to-junit.xslt').open('r', encoding='utf-8') as r:
transform_xunit_to_junit = etree.XSLT(etree.parse(r))


def parse_xunit_files(files: Iterable[str],
progress: Callable[[ParsedJUnitFile], ParsedJUnitFile] = lambda x: x) -> Iterable[ParsedJUnitFile]:
"""Parses xunit files."""
def parse(path: str) -> JUnitTreeOrException:
"""Parses an xunit file and returns either a JUnitTree or an Exception."""
if not os.path.exists(path):
return FileNotFoundError(f'File does not exist.')
if os.stat(path).st_size == 0:
return Exception(f'File is empty.')
def parse(path: str) -> JUnitTree:
xunit = etree.parse(path)
return transform_xunit_to_junit(xunit)

try:
xunit = etree.parse(path)
return transform_xunit_to_junit(xunit)
except BaseException as e:
return e

return [progress((result_file, parse(result_file))) for result_file in files]
return progress_safe_parse_xml_file(files, parse, progress)
2 changes: 1 addition & 1 deletion python/publish_unit_test_results.py
Expand Up @@ -144,7 +144,7 @@ def main(settings: Settings, gha: GithubAction) -> None:

# get the unit test results
parsed = parse_files(settings, gha)
[gha.error(message=f'Error processing result file: {error.message}', file=error.file, line=error.line, column=error.column)
[gha.error(message=f'Error processing result file: {error.message}', file=error.file, line=error.line, column=error.column, exception=error.exception)
for error in parsed.errors]

# process the parsed results
Expand Down
2 changes: 1 addition & 1 deletion python/test/files/junit-xml/empty.exception
@@ -1 +1 @@
Exception: 'File is empty.'
ParseError: file='files/junit-xml/empty.xml', message='File is empty.', line=None, column=None, exception=Exception('File is empty.')
3 changes: 2 additions & 1 deletion python/test/files/junit-xml/non-junit.results
Expand Up @@ -3,7 +3,8 @@ publish.unittestresults.ParsedUnitTestResults(
errors=[
publish.unittestresults.ParseError(
file='non-junit.xml',
message='Invalid format.'
message='Invalid format.',
exception=junitparser.junitparser.JUnitXmlError('Invalid format.')
)
],
suites=0,
Expand Down
2 changes: 1 addition & 1 deletion python/test/files/junit-xml/non-xml.exception
@@ -1 +1 @@
XMLSyntaxError: "Start tag expected, '<' not found, line 1, column 1"
ParseError: file='files/junit-xml/non-xml.xml', message="Start tag expected, '<' not found, line 1, column 1 (non-xml.xml, line 1)", line=None, column=None, exception=XMLSyntaxError("Start tag expected, '<' not found, line 1, column 1")
2 changes: 1 addition & 1 deletion python/test/files/junit-xml/pytest/corrupt-xml.exception
@@ -1 +1 @@
XMLSyntaxError: 'Premature end of data in tag skipped line 9, line 11, column 22'
ParseError: file='files/junit-xml/pytest/corrupt-xml.xml', message='Premature end of data in tag skipped line 9, line 11, column 22 (corrupt-xml.xml, line 11)', line=None, column=None, exception=XMLSyntaxError('Premature end of data in tag skipped line 9, line 11, column 22')
@@ -1 +1 @@
XMLSyntaxError: 'Char 0x0 out of allowed range, line 33, column 16'
ParseError: file='files/nunit/nunit3/jenkins/NUnit-issue17521.xml', message='Char 0x0 out of allowed range, line 33, column 16 (NUnit-issue17521.xml, line 33)', line=None, column=None, exception=XMLSyntaxError('Char 0x0 out of allowed range, line 33, column 16')
@@ -1 +1 @@
XMLSyntaxError: 'attributes construct error, line 5, column 109'
ParseError: file='files/nunit/nunit3/jenkins/NUnit-issue47367.xml', message='attributes construct error, line 5, column 109 (NUnit-issue47367.xml, line 5)', line=None, column=None, exception=XMLSyntaxError('attributes construct error, line 5, column 109')