Promium 3.9.1__tar.gz → 3.9.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {promium-3.9.1 → promium-3.9.2}/PKG-INFO +1 -1
- {promium-3.9.1 → promium-3.9.2}/Promium.egg-info/PKG-INFO +1 -1
- {promium-3.9.1 → promium-3.9.2}/Promium.egg-info/SOURCES.txt +1 -0
- promium-3.9.2/promium/_introspect.py +94 -0
- promium-3.9.2/promium/_soft_assert_rewrite.py +249 -0
- {promium-3.9.1 → promium-3.9.2}/promium/assertions.py +3 -2
- {promium-3.9.1 → promium-3.9.2}/promium/plugin.py +10 -0
- {promium-3.9.1 → promium-3.9.2}/pyproject.toml +2 -0
- {promium-3.9.1 → promium-3.9.2}/setup.py +1 -1
- promium-3.9.1/promium/_introspect.py +0 -206
- {promium-3.9.1 → promium-3.9.2}/LICENSE +0 -0
- {promium-3.9.1 → promium-3.9.2}/Promium.egg-info/dependency_links.txt +0 -0
- {promium-3.9.1 → promium-3.9.2}/Promium.egg-info/entry_points.txt +0 -0
- {promium-3.9.1 → promium-3.9.2}/Promium.egg-info/requires.txt +0 -0
- {promium-3.9.1 → promium-3.9.2}/Promium.egg-info/top_level.txt +0 -0
- {promium-3.9.1 → promium-3.9.2}/README.rst +0 -0
- {promium-3.9.1 → promium-3.9.2}/doc/__init__.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/doc/conf.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/__init__.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/_diag.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/base.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/browsers.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/common.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/device_config.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/elements/__init__.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/elements/checkbox.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/elements/drop_downs.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/elements/frame.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/elements/input_field.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/elements/link.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/elements/multiselect_listbox.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/elements/radio_button.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/elements/textarea_field.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/elements/upload_button.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/exceptions.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/expected_conditions.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/helpers.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/logger.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/support/__init__.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/support/settings.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/test_case.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/promium/waits.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/setup.cfg +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/__init__.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/config.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/conftest.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/pages/__init__.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/pages/catalog_404_page.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/pages/catalog_gallery_page.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/settings/__init__.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/settings/test_base.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/settings/test_chrome.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/settings/test_edge.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/settings/test_firefox.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/settings/test_ie.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/settings/test_opera.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/test_catalog_404.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/test_catalog_gallery.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/test_enviroment.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/test_exceptions.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/test_expected_conditions.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/test_helpers.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/test_requests.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/test_soft_asserts.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/test_test_cases.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/test_waits.py +0 -0
- {promium-3.9.1 → promium-3.9.2}/tests/urls.py +0 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Formatting helpers for soft_assert error messages."""
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import os
|
|
5
|
+
import pprint
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_NO_COLOR = bool(
|
|
9
|
+
os.environ.get("NO_COLOR")
|
|
10
|
+
or os.environ.get("PROMIUM_ASSERTION_PANEL_MONO_COLOR")
|
|
11
|
+
)
|
|
12
|
+
_RED = "" if _NO_COLOR else "\033[31m"
|
|
13
|
+
_GREEN = "" if _NO_COLOR else "\033[32m"
|
|
14
|
+
_YELLOW = "" if _NO_COLOR else "\033[1;33m"
|
|
15
|
+
_BOLD = "" if _NO_COLOR else "\033[1m"
|
|
16
|
+
_WHITE = "" if _NO_COLOR else "\033[1;37m"
|
|
17
|
+
_RESET = "" if _NO_COLOR else "\033[0m"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _escape_control(s: str) -> str:
|
|
21
|
+
"""Replace control characters with \\xNN escapes, keep Unicode as-is."""
|
|
22
|
+
return "".join(
|
|
23
|
+
f"\\x{ord(c):02x}" if ord(c) < 32 or ord(c) == 127 else c for c in s
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _colorize_ndiff(left_lines: list[str], right_lines: list[str]) -> str:
|
|
28
|
+
"""Return colorized ndiff output for two sequences of lines."""
|
|
29
|
+
colored = []
|
|
30
|
+
for line in difflib.ndiff(left_lines, right_lines):
|
|
31
|
+
stripped = line.rstrip("\n")
|
|
32
|
+
if line.startswith("- "):
|
|
33
|
+
colored.append(f"{_RED}{stripped}{_RESET}\n")
|
|
34
|
+
elif line.startswith("+ "):
|
|
35
|
+
colored.append(f"{_GREEN}{stripped}{_RESET}\n")
|
|
36
|
+
elif line.startswith("? "):
|
|
37
|
+
colored.append(f"{_YELLOW}{stripped}{_RESET}\n")
|
|
38
|
+
else:
|
|
39
|
+
colored.append(line)
|
|
40
|
+
return "".join(colored)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _repr_value(val: object) -> str:
|
|
44
|
+
"""Return repr of a value, truncated to 200 chars."""
|
|
45
|
+
r = repr(val)
|
|
46
|
+
return r if len(r) <= 200 else r[:200] + "..."
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _to_lines(obj: object) -> list[str]:
|
|
50
|
+
"""Pretty-format *obj* into a list of lines ending with ``\\n``."""
|
|
51
|
+
fmt = pprint.PrettyPrinter(width=1, sort_dicts=False).pformat
|
|
52
|
+
lines = fmt(obj).splitlines(keepends=True)
|
|
53
|
+
if lines and not lines[-1].endswith("\n"):
|
|
54
|
+
lines[-1] += "\n"
|
|
55
|
+
return lines
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def format_sa_data(sa_data: tuple) -> str:
|
|
59
|
+
"""Format captured ``_sa_data`` into a colorized error string."""
|
|
60
|
+
tag, _source, *rest = sa_data
|
|
61
|
+
|
|
62
|
+
if tag == "cmp":
|
|
63
|
+
op, left, right = rest
|
|
64
|
+
result = (
|
|
65
|
+
f"{_RED}{_repr_value(left)}{_RESET} "
|
|
66
|
+
f"{_BOLD}{op}{_RESET} "
|
|
67
|
+
f"{_GREEN}{_repr_value(right)}{_RESET}"
|
|
68
|
+
)
|
|
69
|
+
if (
|
|
70
|
+
op in {"==", "!="}
|
|
71
|
+
and isinstance(left, str)
|
|
72
|
+
and isinstance(right, str)
|
|
73
|
+
):
|
|
74
|
+
esc = _escape_control
|
|
75
|
+
result += "\n" + _colorize_ndiff(
|
|
76
|
+
[esc(left) + "\n"],
|
|
77
|
+
[esc(right) + "\n"],
|
|
78
|
+
)
|
|
79
|
+
elif (
|
|
80
|
+
op in {"==", "!="}
|
|
81
|
+
and isinstance(left, (dict, list, tuple, set, frozenset))
|
|
82
|
+
and isinstance(right, (dict, list, tuple, set, frozenset))
|
|
83
|
+
):
|
|
84
|
+
result += "\n" + _colorize_ndiff(
|
|
85
|
+
_to_lines(left),
|
|
86
|
+
_to_lines(right),
|
|
87
|
+
)
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
if tag == "val":
|
|
91
|
+
(value,) = rest
|
|
92
|
+
return f"{_RED}{_repr_value(value)}{_RESET}"
|
|
93
|
+
|
|
94
|
+
return str(sa_data)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""AST rewriter for ``soft_assert`` calls.
|
|
2
|
+
|
|
3
|
+
Transforms ``self.soft_assert(expr, ...)`` at **import time** so that
|
|
4
|
+
sub-expressions are evaluated into temporary variables *before* the
|
|
5
|
+
comparison. The captured values are forwarded to ``soft_assert`` via
|
|
6
|
+
the hidden ``_sa_data`` keyword argument, eliminating the need for
|
|
7
|
+
runtime ``eval()`` and preventing double-evaluation of side-effecting
|
|
8
|
+
expressions like ``list.pop()``.
|
|
9
|
+
|
|
10
|
+
The rewriter is installed as an import hook via :func:`install` (called
|
|
11
|
+
from ``promium.plugin.pytest_configure``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
import ast
|
|
16
|
+
import contextlib
|
|
17
|
+
import importlib.abc
|
|
18
|
+
import importlib.machinery
|
|
19
|
+
import importlib.util
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
import types
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_COMPARE_OPS: dict[type, str] = {
|
|
30
|
+
ast.Eq: "==",
|
|
31
|
+
ast.NotEq: "!=",
|
|
32
|
+
ast.Lt: "<",
|
|
33
|
+
ast.LtE: "<=",
|
|
34
|
+
ast.Gt: ">",
|
|
35
|
+
ast.GtE: ">=",
|
|
36
|
+
ast.Is: "is",
|
|
37
|
+
ast.IsNot: "is not",
|
|
38
|
+
ast.In: "in",
|
|
39
|
+
ast.NotIn: "not in",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SoftAssertTransformer(ast.NodeTransformer):
|
|
44
|
+
"""Rewrite ``self.soft_assert(expr, ...)`` into temp-var assignments
|
|
45
|
+
followed by a call that carries ``_sa_data`` with pre-computed values.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, source: str) -> None:
|
|
49
|
+
self._source = source
|
|
50
|
+
self._counter = 0
|
|
51
|
+
self.changed = False
|
|
52
|
+
|
|
53
|
+
def _tmp(self) -> str:
|
|
54
|
+
name = f"@_sa_{self._counter}"
|
|
55
|
+
self._counter += 1
|
|
56
|
+
return name
|
|
57
|
+
|
|
58
|
+
def _assign(self, name: str, value: ast.expr, donor: ast.AST) -> ast.Assign:
|
|
59
|
+
stmt = ast.Assign(
|
|
60
|
+
targets=[ast.Name(id=name, ctx=ast.Store())],
|
|
61
|
+
value=value,
|
|
62
|
+
)
|
|
63
|
+
ast.copy_location(stmt, donor)
|
|
64
|
+
ast.fix_missing_locations(stmt)
|
|
65
|
+
return stmt
|
|
66
|
+
|
|
67
|
+
def _finish(
|
|
68
|
+
self,
|
|
69
|
+
stmts: list[ast.stmt],
|
|
70
|
+
call: ast.Call,
|
|
71
|
+
new_first_arg: ast.expr,
|
|
72
|
+
sa_data: ast.Tuple,
|
|
73
|
+
donor: ast.AST,
|
|
74
|
+
) -> list[ast.stmt]:
|
|
75
|
+
new_call = ast.Call(
|
|
76
|
+
func=call.func,
|
|
77
|
+
args=[new_first_arg, *call.args[1:]],
|
|
78
|
+
keywords=[
|
|
79
|
+
*call.keywords,
|
|
80
|
+
ast.keyword(arg="_sa_data", value=sa_data),
|
|
81
|
+
],
|
|
82
|
+
)
|
|
83
|
+
stmt = ast.Expr(value=new_call)
|
|
84
|
+
ast.copy_location(stmt, donor)
|
|
85
|
+
ast.fix_missing_locations(stmt)
|
|
86
|
+
stmts.append(stmt)
|
|
87
|
+
return stmts
|
|
88
|
+
|
|
89
|
+
def visit_Expr(self, node: ast.Expr) -> ast.AST:
|
|
90
|
+
self.generic_visit(node)
|
|
91
|
+
call = node.value
|
|
92
|
+
if not (
|
|
93
|
+
isinstance(call, ast.Call)
|
|
94
|
+
and isinstance(call.func, ast.Attribute)
|
|
95
|
+
and call.func.attr == "soft_assert"
|
|
96
|
+
and call.args
|
|
97
|
+
and not any(kw.arg == "_sa_data" for kw in call.keywords)
|
|
98
|
+
):
|
|
99
|
+
return node
|
|
100
|
+
|
|
101
|
+
expr_arg = call.args[0]
|
|
102
|
+
source = ast.get_source_segment(self._source, expr_arg) or ""
|
|
103
|
+
n = ast.Name
|
|
104
|
+
|
|
105
|
+
self.changed = True
|
|
106
|
+
|
|
107
|
+
if isinstance(expr_arg, ast.Compare) and len(expr_arg.ops) == 1:
|
|
108
|
+
ln, rn = self._tmp(), self._tmp()
|
|
109
|
+
stmts = [
|
|
110
|
+
self._assign(ln, expr_arg.left, node),
|
|
111
|
+
self._assign(rn, expr_arg.comparators[0], node),
|
|
112
|
+
]
|
|
113
|
+
new_expr = ast.Compare(
|
|
114
|
+
left=n(id=ln, ctx=ast.Load()),
|
|
115
|
+
ops=list(expr_arg.ops),
|
|
116
|
+
comparators=[n(id=rn, ctx=ast.Load())],
|
|
117
|
+
)
|
|
118
|
+
op_str = _COMPARE_OPS.get(type(expr_arg.ops[0]), "??")
|
|
119
|
+
sa_data = ast.Tuple(
|
|
120
|
+
elts=[
|
|
121
|
+
ast.Constant(value="cmp"),
|
|
122
|
+
ast.Constant(value=source),
|
|
123
|
+
ast.Constant(value=op_str),
|
|
124
|
+
n(id=ln, ctx=ast.Load()),
|
|
125
|
+
n(id=rn, ctx=ast.Load()),
|
|
126
|
+
],
|
|
127
|
+
ctx=ast.Load(),
|
|
128
|
+
)
|
|
129
|
+
else:
|
|
130
|
+
vn = self._tmp()
|
|
131
|
+
stmts = [self._assign(vn, expr_arg, node)]
|
|
132
|
+
new_expr = n(id=vn, ctx=ast.Load())
|
|
133
|
+
sa_data = ast.Tuple(
|
|
134
|
+
elts=[
|
|
135
|
+
ast.Constant(value="val"),
|
|
136
|
+
ast.Constant(value=source),
|
|
137
|
+
n(id=vn, ctx=ast.Load()),
|
|
138
|
+
],
|
|
139
|
+
ctx=ast.Load(),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return self._finish(stmts, call, new_expr, sa_data, node)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class _SoftAssertLoader(importlib.abc.Loader):
|
|
146
|
+
"""Loader that compiles a module from AST-rewritten source."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, origin: str) -> None:
|
|
149
|
+
self._origin = origin
|
|
150
|
+
|
|
151
|
+
def create_module( # noqa: PLR6301
|
|
152
|
+
self,
|
|
153
|
+
spec: importlib.machinery.ModuleSpec,
|
|
154
|
+
) -> None:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
def exec_module(self, module: types.ModuleType) -> None:
|
|
158
|
+
module.__file__ = self._origin
|
|
159
|
+
source = Path(self._origin).read_text("utf-8")
|
|
160
|
+
|
|
161
|
+
tree = ast.parse(source, filename=self._origin)
|
|
162
|
+
transformer = SoftAssertTransformer(source)
|
|
163
|
+
tree = transformer.visit(tree)
|
|
164
|
+
|
|
165
|
+
if transformer.changed:
|
|
166
|
+
ast.fix_missing_locations(tree)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
from _pytest.assertion.rewrite import rewrite_asserts
|
|
170
|
+
|
|
171
|
+
rewrite_asserts(tree, source.encode("utf-8"))
|
|
172
|
+
except Exception: # noqa: BLE001
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
code = compile(tree, self._origin, "exec")
|
|
176
|
+
exec(code, module.__dict__) # noqa: S102
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class SoftAssertImportHook(importlib.abc.MetaPathFinder):
|
|
180
|
+
"""Import hook that rewrites ``soft_assert`` calls in test modules."""
|
|
181
|
+
|
|
182
|
+
def __init__(self) -> None:
|
|
183
|
+
self._finding: set[str] = set()
|
|
184
|
+
|
|
185
|
+
def find_spec(
|
|
186
|
+
self,
|
|
187
|
+
name: str,
|
|
188
|
+
path: object = None,
|
|
189
|
+
target: object = None,
|
|
190
|
+
) -> importlib.machinery.ModuleSpec | None:
|
|
191
|
+
if name in self._finding:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
parts = name.split(".")
|
|
195
|
+
last = parts[-1]
|
|
196
|
+
if not (last.startswith("test_") or last == "conftest"):
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
self._finding.add(name)
|
|
200
|
+
try:
|
|
201
|
+
spec = importlib.util.find_spec(name)
|
|
202
|
+
if not (
|
|
203
|
+
spec
|
|
204
|
+
and spec.origin
|
|
205
|
+
and spec.origin.endswith(".py")
|
|
206
|
+
and Path(spec.origin).exists()
|
|
207
|
+
):
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
source = Path(spec.origin).read_text("utf-8")
|
|
211
|
+
if "soft_assert" not in source:
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
new_spec = importlib.machinery.ModuleSpec(
|
|
215
|
+
name,
|
|
216
|
+
_SoftAssertLoader(spec.origin),
|
|
217
|
+
origin=spec.origin,
|
|
218
|
+
is_package=spec.submodule_search_locations is not None,
|
|
219
|
+
)
|
|
220
|
+
new_spec.has_location = True
|
|
221
|
+
if spec.submodule_search_locations is not None:
|
|
222
|
+
new_spec.submodule_search_locations = list(
|
|
223
|
+
spec.submodule_search_locations,
|
|
224
|
+
)
|
|
225
|
+
return new_spec
|
|
226
|
+
except Exception: # noqa: BLE001
|
|
227
|
+
return None
|
|
228
|
+
finally:
|
|
229
|
+
self._finding.discard(name)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
_hook_instance: SoftAssertImportHook | None = None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def install() -> None:
|
|
236
|
+
"""Install the import hook at the front of ``sys.meta_path``."""
|
|
237
|
+
global _hook_instance # noqa: PLW0603
|
|
238
|
+
if _hook_instance is None:
|
|
239
|
+
_hook_instance = SoftAssertImportHook()
|
|
240
|
+
sys.meta_path.insert(0, _hook_instance)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def uninstall() -> None:
|
|
244
|
+
"""Remove the import hook from ``sys.meta_path``."""
|
|
245
|
+
global _hook_instance # noqa: PLW0603
|
|
246
|
+
if _hook_instance is not None:
|
|
247
|
+
with contextlib.suppress(ValueError):
|
|
248
|
+
sys.meta_path.remove(_hook_instance)
|
|
249
|
+
_hook_instance = None
|
|
@@ -11,7 +11,7 @@ import urllib3
|
|
|
11
11
|
from json_checker import Checker, CheckerError
|
|
12
12
|
from selenium.webdriver.remote.webdriver import WebDriver as SeleniumWebDriver
|
|
13
13
|
|
|
14
|
-
from promium._introspect import _RESET, _WHITE,
|
|
14
|
+
from promium._introspect import _RESET, _WHITE, format_sa_data
|
|
15
15
|
from promium.common import upload_screenshot
|
|
16
16
|
|
|
17
17
|
|
|
@@ -161,10 +161,11 @@ class BaseSoftAssertion:
|
|
|
161
161
|
msg: str | None = None,
|
|
162
162
|
with_screen: bool = False,
|
|
163
163
|
hard_assert: bool | None = None,
|
|
164
|
+
_sa_data: tuple | None = None,
|
|
164
165
|
) -> None:
|
|
165
166
|
"""Check that the expression is true."""
|
|
166
167
|
if not expr:
|
|
167
|
-
source_expr =
|
|
168
|
+
source_expr = format_sa_data(_sa_data) if _sa_data else None
|
|
168
169
|
parts = []
|
|
169
170
|
if msg:
|
|
170
171
|
parts.append(f"{_WHITE}{msg}{_RESET}")
|
|
@@ -473,6 +473,10 @@ def pytest_runtest_makereport(item: pytest.Item, call: CallInfo) -> None:
|
|
|
473
473
|
|
|
474
474
|
|
|
475
475
|
def pytest_configure(config: Config) -> None:
|
|
476
|
+
from promium._soft_assert_rewrite import install as _install_sa_hook
|
|
477
|
+
|
|
478
|
+
_install_sa_hook()
|
|
479
|
+
|
|
476
480
|
os.environ["PYTHONWARNINGS"] = (
|
|
477
481
|
"ignore:An HTTPS request has been made, "
|
|
478
482
|
"ignore:A true SSLContext object is not available, "
|
|
@@ -489,3 +493,9 @@ def pytest_configure(config: Config) -> None:
|
|
|
489
493
|
os.environ["CHECK_CONSOLE"] = "Enabled"
|
|
490
494
|
if not getattr(config, "lastfailed_file", None):
|
|
491
495
|
config.lastfailed_file = "lastfailed"
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def pytest_unconfigure(config: Config) -> None:
|
|
499
|
+
from promium._soft_assert_rewrite import uninstall as _uninstall_sa_hook
|
|
500
|
+
|
|
501
|
+
_uninstall_sa_hook()
|
|
@@ -104,6 +104,8 @@ ignore = [
|
|
|
104
104
|
[tool.ruff.lint.per-file-ignores]
|
|
105
105
|
"tests/*" = ["ANN", "PT011", "PLC0415", "I001"]
|
|
106
106
|
"promium/test_case.py" = ["I001"] # conflict between isort and Python standard for from __future__ imports
|
|
107
|
+
"promium/plugin.py" = ["PLC0415"]
|
|
108
|
+
"promium/_soft_assert_rewrite.py" = ["I001", "PLC0415", "PLC2701", "N802"]
|
|
107
109
|
|
|
108
110
|
[tool.ruff.lint.isort.sections]
|
|
109
111
|
STDLIB = ["STDLIB"]
|
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
"""AST-based expression introspection for soft_assert.
|
|
2
|
-
|
|
3
|
-
Extracts the source expression from a ``soft_assert(...)`` call,
|
|
4
|
-
substitutes runtime values, and returns a colorized string with
|
|
5
|
-
a diff (for strings and containers) similar to pytest assertion output.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import ast
|
|
9
|
-
import difflib
|
|
10
|
-
import inspect
|
|
11
|
-
import os
|
|
12
|
-
import pprint
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
from typing import Any
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
_SENTINEL = object()
|
|
18
|
-
|
|
19
|
-
_COMPARE_OPS = {
|
|
20
|
-
ast.Eq: "==",
|
|
21
|
-
ast.NotEq: "!=",
|
|
22
|
-
ast.Lt: "<",
|
|
23
|
-
ast.LtE: "<=",
|
|
24
|
-
ast.Gt: ">",
|
|
25
|
-
ast.GtE: ">=",
|
|
26
|
-
ast.Is: "is",
|
|
27
|
-
ast.IsNot: "is not",
|
|
28
|
-
ast.In: "in",
|
|
29
|
-
ast.NotIn: "not in",
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
_NO_COLOR = bool(
|
|
33
|
-
os.environ.get("NO_COLOR")
|
|
34
|
-
or os.environ.get("PROMIUM_ASSERTION_PANEL_MONO_COLOR")
|
|
35
|
-
)
|
|
36
|
-
_RED = "" if _NO_COLOR else "\033[31m"
|
|
37
|
-
_GREEN = "" if _NO_COLOR else "\033[32m"
|
|
38
|
-
_YELLOW = "" if _NO_COLOR else "\033[1;33m"
|
|
39
|
-
_BOLD = "" if _NO_COLOR else "\033[1m"
|
|
40
|
-
_WHITE = "" if _NO_COLOR else "\033[1;37m"
|
|
41
|
-
_RESET = "" if _NO_COLOR else "\033[0m"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def _escape_control(s: str) -> str:
|
|
45
|
-
"""Replace control characters with \\xNN escapes, keep Unicode as-is."""
|
|
46
|
-
return "".join(
|
|
47
|
-
f"\\x{ord(c):02x}" if ord(c) < 32 or ord(c) == 127 else c for c in s
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _colorize_ndiff(left_lines: list[str], right_lines: list[str]) -> str:
|
|
52
|
-
"""Return colorized ndiff output for two sequences of lines."""
|
|
53
|
-
colored = []
|
|
54
|
-
for line in difflib.ndiff(left_lines, right_lines):
|
|
55
|
-
stripped = line.rstrip("\n")
|
|
56
|
-
if line.startswith("- "):
|
|
57
|
-
colored.append(f"{_RED}{stripped}{_RESET}\n")
|
|
58
|
-
elif line.startswith("+ "):
|
|
59
|
-
colored.append(f"{_GREEN}{stripped}{_RESET}\n")
|
|
60
|
-
elif line.startswith("? "):
|
|
61
|
-
colored.append(f"{_YELLOW}{stripped}{_RESET}\n")
|
|
62
|
-
else:
|
|
63
|
-
colored.append(line)
|
|
64
|
-
return "".join(colored)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def extract_call_expression(method_name: str) -> str | None:
|
|
68
|
-
"""Extract the first argument source from a call to *method_name*
|
|
69
|
-
and rebuild it with runtime values substituted.
|
|
70
|
-
|
|
71
|
-
Example: ``self.soft_assert(code in var)``
|
|
72
|
-
where code='23', var=[1,2,3] → ``"'23' in [1, 2, 3]"``
|
|
73
|
-
"""
|
|
74
|
-
try:
|
|
75
|
-
caller = inspect.currentframe().f_back.f_back
|
|
76
|
-
source = Path(caller.f_code.co_filename).read_text(encoding="utf-8")
|
|
77
|
-
|
|
78
|
-
for node in ast.walk(ast.parse(source)):
|
|
79
|
-
if not (
|
|
80
|
-
isinstance(node, ast.Call)
|
|
81
|
-
and isinstance(node.func, ast.Attribute)
|
|
82
|
-
and node.func.attr == method_name
|
|
83
|
-
and node.args
|
|
84
|
-
and node.lineno
|
|
85
|
-
<= caller.f_lineno
|
|
86
|
-
<= (node.end_lineno or node.lineno)
|
|
87
|
-
):
|
|
88
|
-
continue
|
|
89
|
-
|
|
90
|
-
expr_node = node.args[0]
|
|
91
|
-
expr_source = ast.get_source_segment(source, expr_node)
|
|
92
|
-
if not expr_source:
|
|
93
|
-
return None
|
|
94
|
-
|
|
95
|
-
def _may_have_side_effect(node: ast.expr) -> bool:
|
|
96
|
-
if isinstance(node, ast.Call):
|
|
97
|
-
return True
|
|
98
|
-
if isinstance(node, ast.Attribute):
|
|
99
|
-
if _may_have_side_effect(node.value):
|
|
100
|
-
return True
|
|
101
|
-
val_src = ast.get_source_segment(source, node.value)
|
|
102
|
-
if val_src:
|
|
103
|
-
try:
|
|
104
|
-
obj = eval(
|
|
105
|
-
val_src, caller.f_globals, caller.f_locals
|
|
106
|
-
) # noqa: S307
|
|
107
|
-
if isinstance(
|
|
108
|
-
getattr(type(obj), node.attr, None),
|
|
109
|
-
property,
|
|
110
|
-
):
|
|
111
|
-
return True
|
|
112
|
-
except Exception:
|
|
113
|
-
pass
|
|
114
|
-
return False
|
|
115
|
-
return any(
|
|
116
|
-
_may_have_side_effect(child)
|
|
117
|
-
for child in ast.iter_child_nodes(node)
|
|
118
|
-
if isinstance(child, ast.expr)
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
def _try_eval(operand: ast.expr) -> Any:
|
|
122
|
-
"""Return the runtime value or _SENTINEL if eval fails."""
|
|
123
|
-
if _may_have_side_effect(operand):
|
|
124
|
-
return _SENTINEL
|
|
125
|
-
src = ast.get_source_segment(source, operand)
|
|
126
|
-
if not src:
|
|
127
|
-
return _SENTINEL
|
|
128
|
-
try:
|
|
129
|
-
return eval(src, caller.f_globals, caller.f_locals) # noqa: S307
|
|
130
|
-
except Exception:
|
|
131
|
-
return _SENTINEL
|
|
132
|
-
|
|
133
|
-
def _eval_operand(operand: ast.expr) -> str:
|
|
134
|
-
"""Return repr(value) if eval succeeds, otherwise source text."""
|
|
135
|
-
val = _try_eval(operand)
|
|
136
|
-
if val is _SENTINEL:
|
|
137
|
-
return ast.get_source_segment(source, operand) or "?"
|
|
138
|
-
r = repr(val)
|
|
139
|
-
return r if len(r) <= 200 else r[:200] + "..."
|
|
140
|
-
|
|
141
|
-
if isinstance(expr_node, ast.Compare):
|
|
142
|
-
left_repr = _eval_operand(expr_node.left)
|
|
143
|
-
parts = [f"{_RED}{left_repr}{_RESET}"]
|
|
144
|
-
for cmp_op, comp in zip(expr_node.ops, expr_node.comparators):
|
|
145
|
-
op_str = _COMPARE_OPS.get(type(cmp_op), "??")
|
|
146
|
-
parts.append(f"{_BOLD}{op_str}{_RESET}")
|
|
147
|
-
parts.append(f"{_GREEN}{_eval_operand(comp)}{_RESET}")
|
|
148
|
-
result = " ".join(parts)
|
|
149
|
-
|
|
150
|
-
if len(expr_node.ops) == 1 and isinstance(
|
|
151
|
-
expr_node.ops[0], (ast.Eq, ast.NotEq)
|
|
152
|
-
):
|
|
153
|
-
left_val = _try_eval(expr_node.left)
|
|
154
|
-
right_val = _try_eval(expr_node.comparators[0])
|
|
155
|
-
if isinstance(left_val, str) and isinstance(right_val, str):
|
|
156
|
-
esc = _escape_control
|
|
157
|
-
result += "\n" + _colorize_ndiff(
|
|
158
|
-
[esc(left_val) + "\n"],
|
|
159
|
-
[esc(right_val) + "\n"],
|
|
160
|
-
)
|
|
161
|
-
elif (
|
|
162
|
-
left_val is not _SENTINEL
|
|
163
|
-
and right_val is not _SENTINEL
|
|
164
|
-
and isinstance(
|
|
165
|
-
left_val,
|
|
166
|
-
(dict, list, tuple, set, frozenset),
|
|
167
|
-
)
|
|
168
|
-
and isinstance(
|
|
169
|
-
right_val,
|
|
170
|
-
(dict, list, tuple, set, frozenset),
|
|
171
|
-
)
|
|
172
|
-
):
|
|
173
|
-
fmt = pprint.PrettyPrinter(
|
|
174
|
-
width=1, sort_dicts=False
|
|
175
|
-
).pformat
|
|
176
|
-
left_lines = fmt(left_val).splitlines(keepends=True)
|
|
177
|
-
right_lines = fmt(right_val).splitlines(keepends=True)
|
|
178
|
-
if left_lines and not left_lines[-1].endswith("\n"):
|
|
179
|
-
left_lines[-1] += "\n"
|
|
180
|
-
if right_lines and not right_lines[-1].endswith("\n"):
|
|
181
|
-
right_lines[-1] += "\n"
|
|
182
|
-
result += "\n" + _colorize_ndiff(left_lines, right_lines)
|
|
183
|
-
return result
|
|
184
|
-
|
|
185
|
-
if isinstance(expr_node, ast.BoolOp):
|
|
186
|
-
op_str = " and " if isinstance(expr_node.op, ast.And) else " or "
|
|
187
|
-
colored_parts = []
|
|
188
|
-
for v in expr_node.values:
|
|
189
|
-
val = _try_eval(v)
|
|
190
|
-
operand_str = _eval_operand(v)
|
|
191
|
-
if val is _SENTINEL:
|
|
192
|
-
colored_parts.append(operand_str)
|
|
193
|
-
elif val:
|
|
194
|
-
colored_parts.append(f"{_GREEN}{operand_str}{_RESET}")
|
|
195
|
-
else:
|
|
196
|
-
colored_parts.append(f"{_RED}{operand_str}{_RESET}")
|
|
197
|
-
return (f"{_BOLD}{op_str}{_RESET}").join(colored_parts)
|
|
198
|
-
|
|
199
|
-
val = _try_eval(expr_node)
|
|
200
|
-
operand_str = _eval_operand(expr_node)
|
|
201
|
-
if val is not _SENTINEL and not val:
|
|
202
|
-
return f"{_RED}{operand_str}{_RESET}"
|
|
203
|
-
return operand_str
|
|
204
|
-
except Exception:
|
|
205
|
-
pass
|
|
206
|
-
return None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|