forked from exercism/python-test-runner
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdata.py
More file actions
183 lines (140 loc) · 4.98 KB
/
data.py
File metadata and controls
183 lines (140 loc) · 4.98 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
"""
Datatypes to support the Python test runner.
"""
from dataclasses import dataclass, field, asdict
from enum import Enum, auto
from json import JSONEncoder, dumps
from typing import Any, List, NewType, Optional
from pathlib import Path
from re import compile, match, sub
# an exercise slug, ie two-fer
Slug = NewType("Slug", str)
# a directory
Directory = NewType("Directory", Path)
# a Pytest-style hierarchy, ./file.py::Class::test_function
Hierarchy = NewType("Hierarchy", str)
class Status(Enum):
"""
The status of a given test or test session.
"""
PASS = auto()
FAIL = auto()
ERROR = auto()
# a (optional) message for inclusion in results.json
Message = Optional[str]
Output = Optional[str]
@dataclass
class TestInfo:
lineno: int
end_lineno: int
variants: int
@dataclass
class Test:
"""
An individual test's results.
"""
name: str
status: Status = Status.PASS
message: Message = None
test_code: str = ""
task_id: int = 0
# for an explanation of why both of these are necessary see
# https://florimond.dev/blog/articles/2018/10/reconciling-dataclasses-and-properties-in-python/
output: Output = None
_output: Output = field(default=None, init=False, repr=False)
def _update(self, status: Status, message: Message = None) -> None:
self.status = status
if message:
self.message = message
@property
def output(self) -> Output:
return self._output
@output.setter
def output(self, captured: Output) -> None:
# this test is necessary due to a curious artifact of @dataclass when
# combined with @property; if no value is passed to the Test constructor
# then the generated __init__ will attempt to call the property.setter
# with the property itself; by ignoring that we let the private field's
# default value (in this case None) remain in place
if isinstance(captured, property):
return
captured = captured.strip()
truncate_msg = " [Output was truncated. Please limit to 500 chars]"
if len(captured) > 500:
captured = captured[: 500 - len(truncate_msg)] + truncate_msg
self._output = captured
def fail(self, message: Message = None) -> None:
"""
Indicate this test failed.
"""
self._update(Status.FAIL, message)
def error(self, message: Message = None) -> None:
"""
Indicate this test encountered an error.
"""
self._update(Status.ERROR, message)
def is_passing(self):
"""
Check if the test is currently passing.
"""
return self.status is Status.PASS
@dataclass
class Results:
"""
Overall results of a test run.
"""
version: int = 3
status: Status = Status.PASS
message: Message = None
tests: List[Test] = field(default_factory=list)
def add(self, test: Test) -> None:
"""
Add a Test to the list of tests.
"""
if test.status is Status.FAIL:
self.fail()
self.tests.append(test)
def fail(self) -> None:
"""
Indicate the test run had at least one failure.
"""
self.status = Status.FAIL
def error(self, message: Message = None) -> None:
"""
Indicate the test run fatally errored.
"""
self.status = Status.ERROR
self.message = message
@staticmethod
def _factory(items):
result = {}
for key, value in items:
if key == "_output" or key in {"message", "output", "subtest"} and value in (None, "", " "):
continue
if isinstance(value, Status):
value = value.name.lower()
result[key] = value
return result
def as_json(self):
"""
- Trim off the TestClass name and test_ prefix from each test_name.
- Replace underscores with spaces for more human-readable strings.
- Add a sort order signifier (~) to parent tests with subtests.
(~) ensures parent tests sort to last position.
- If it is a concept exercise, sort the current tests array by
task_id and variation# then Dump all results to formatted JSON.
"""
trim_name = compile(r'^(.+)(Test\.test_)')
results = asdict(self, dict_factory=self._factory)
concept_exercise = False
for item in results["tests"]:
if "[variation" not in item["name"] and item["task_id"] > 0:
item["name"] = sub(trim_name, '\\1 > ', item["name"]).replace('_', ' ') + "~"
concept_exercise = True
else:
item["name"] = sub(trim_name, '\\1 > ', item["name"]).replace('_', ' ')
if concept_exercise:
results["tests"] = sorted(results["tests"], key= lambda item: (item["task_id"], item["name"]))
else:
results["tests"] = sorted(results["tests"], key=lambda item: (item["task_id"]))
return dumps(results, indent=2)