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.
Files changed (67) hide show
  1. {promium-3.9.1 → promium-3.9.2}/PKG-INFO +1 -1
  2. {promium-3.9.1 → promium-3.9.2}/Promium.egg-info/PKG-INFO +1 -1
  3. {promium-3.9.1 → promium-3.9.2}/Promium.egg-info/SOURCES.txt +1 -0
  4. promium-3.9.2/promium/_introspect.py +94 -0
  5. promium-3.9.2/promium/_soft_assert_rewrite.py +249 -0
  6. {promium-3.9.1 → promium-3.9.2}/promium/assertions.py +3 -2
  7. {promium-3.9.1 → promium-3.9.2}/promium/plugin.py +10 -0
  8. {promium-3.9.1 → promium-3.9.2}/pyproject.toml +2 -0
  9. {promium-3.9.1 → promium-3.9.2}/setup.py +1 -1
  10. promium-3.9.1/promium/_introspect.py +0 -206
  11. {promium-3.9.1 → promium-3.9.2}/LICENSE +0 -0
  12. {promium-3.9.1 → promium-3.9.2}/Promium.egg-info/dependency_links.txt +0 -0
  13. {promium-3.9.1 → promium-3.9.2}/Promium.egg-info/entry_points.txt +0 -0
  14. {promium-3.9.1 → promium-3.9.2}/Promium.egg-info/requires.txt +0 -0
  15. {promium-3.9.1 → promium-3.9.2}/Promium.egg-info/top_level.txt +0 -0
  16. {promium-3.9.1 → promium-3.9.2}/README.rst +0 -0
  17. {promium-3.9.1 → promium-3.9.2}/doc/__init__.py +0 -0
  18. {promium-3.9.1 → promium-3.9.2}/doc/conf.py +0 -0
  19. {promium-3.9.1 → promium-3.9.2}/promium/__init__.py +0 -0
  20. {promium-3.9.1 → promium-3.9.2}/promium/_diag.py +0 -0
  21. {promium-3.9.1 → promium-3.9.2}/promium/base.py +0 -0
  22. {promium-3.9.1 → promium-3.9.2}/promium/browsers.py +0 -0
  23. {promium-3.9.1 → promium-3.9.2}/promium/common.py +0 -0
  24. {promium-3.9.1 → promium-3.9.2}/promium/device_config.py +0 -0
  25. {promium-3.9.1 → promium-3.9.2}/promium/elements/__init__.py +0 -0
  26. {promium-3.9.1 → promium-3.9.2}/promium/elements/checkbox.py +0 -0
  27. {promium-3.9.1 → promium-3.9.2}/promium/elements/drop_downs.py +0 -0
  28. {promium-3.9.1 → promium-3.9.2}/promium/elements/frame.py +0 -0
  29. {promium-3.9.1 → promium-3.9.2}/promium/elements/input_field.py +0 -0
  30. {promium-3.9.1 → promium-3.9.2}/promium/elements/link.py +0 -0
  31. {promium-3.9.1 → promium-3.9.2}/promium/elements/multiselect_listbox.py +0 -0
  32. {promium-3.9.1 → promium-3.9.2}/promium/elements/radio_button.py +0 -0
  33. {promium-3.9.1 → promium-3.9.2}/promium/elements/textarea_field.py +0 -0
  34. {promium-3.9.1 → promium-3.9.2}/promium/elements/upload_button.py +0 -0
  35. {promium-3.9.1 → promium-3.9.2}/promium/exceptions.py +0 -0
  36. {promium-3.9.1 → promium-3.9.2}/promium/expected_conditions.py +0 -0
  37. {promium-3.9.1 → promium-3.9.2}/promium/helpers.py +0 -0
  38. {promium-3.9.1 → promium-3.9.2}/promium/logger.py +0 -0
  39. {promium-3.9.1 → promium-3.9.2}/promium/support/__init__.py +0 -0
  40. {promium-3.9.1 → promium-3.9.2}/promium/support/settings.py +0 -0
  41. {promium-3.9.1 → promium-3.9.2}/promium/test_case.py +0 -0
  42. {promium-3.9.1 → promium-3.9.2}/promium/waits.py +0 -0
  43. {promium-3.9.1 → promium-3.9.2}/setup.cfg +0 -0
  44. {promium-3.9.1 → promium-3.9.2}/tests/__init__.py +0 -0
  45. {promium-3.9.1 → promium-3.9.2}/tests/config.py +0 -0
  46. {promium-3.9.1 → promium-3.9.2}/tests/conftest.py +0 -0
  47. {promium-3.9.1 → promium-3.9.2}/tests/pages/__init__.py +0 -0
  48. {promium-3.9.1 → promium-3.9.2}/tests/pages/catalog_404_page.py +0 -0
  49. {promium-3.9.1 → promium-3.9.2}/tests/pages/catalog_gallery_page.py +0 -0
  50. {promium-3.9.1 → promium-3.9.2}/tests/settings/__init__.py +0 -0
  51. {promium-3.9.1 → promium-3.9.2}/tests/settings/test_base.py +0 -0
  52. {promium-3.9.1 → promium-3.9.2}/tests/settings/test_chrome.py +0 -0
  53. {promium-3.9.1 → promium-3.9.2}/tests/settings/test_edge.py +0 -0
  54. {promium-3.9.1 → promium-3.9.2}/tests/settings/test_firefox.py +0 -0
  55. {promium-3.9.1 → promium-3.9.2}/tests/settings/test_ie.py +0 -0
  56. {promium-3.9.1 → promium-3.9.2}/tests/settings/test_opera.py +0 -0
  57. {promium-3.9.1 → promium-3.9.2}/tests/test_catalog_404.py +0 -0
  58. {promium-3.9.1 → promium-3.9.2}/tests/test_catalog_gallery.py +0 -0
  59. {promium-3.9.1 → promium-3.9.2}/tests/test_enviroment.py +0 -0
  60. {promium-3.9.1 → promium-3.9.2}/tests/test_exceptions.py +0 -0
  61. {promium-3.9.1 → promium-3.9.2}/tests/test_expected_conditions.py +0 -0
  62. {promium-3.9.1 → promium-3.9.2}/tests/test_helpers.py +0 -0
  63. {promium-3.9.1 → promium-3.9.2}/tests/test_requests.py +0 -0
  64. {promium-3.9.1 → promium-3.9.2}/tests/test_soft_asserts.py +0 -0
  65. {promium-3.9.1 → promium-3.9.2}/tests/test_test_cases.py +0 -0
  66. {promium-3.9.1 → promium-3.9.2}/tests/test_waits.py +0 -0
  67. {promium-3.9.1 → promium-3.9.2}/tests/urls.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: Promium
3
- Version: 3.9.1
3
+ Version: 3.9.2
4
4
  Summary: Selenium wrapper for testing Web UI
5
5
  Author: Denis Korytkin, Nataliia Guieva, Roman Zaporozhets, Vladimir Kritov, Oleh Dykusha
6
6
  Project-URL: Home page, https://none
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: Promium
3
- Version: 3.9.1
3
+ Version: 3.9.2
4
4
  Summary: Selenium wrapper for testing Web UI
5
5
  Author: Denis Korytkin, Nataliia Guieva, Roman Zaporozhets, Vladimir Kritov, Oleh Dykusha
6
6
  Project-URL: Home page, https://none
@@ -13,6 +13,7 @@ doc/conf.py
13
13
  promium/__init__.py
14
14
  promium/_diag.py
15
15
  promium/_introspect.py
16
+ promium/_soft_assert_rewrite.py
16
17
  promium/assertions.py
17
18
  promium/base.py
18
19
  promium/browsers.py
@@ -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, extract_call_expression
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 = extract_call_expression("soft_assert")
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"]
@@ -19,7 +19,7 @@ LONG_DESCRIPTION = Path("README.rst").read_text(encoding="utf-8")
19
19
 
20
20
  setup(
21
21
  name="Promium",
22
- version="3.9.1",
22
+ version="3.9.2",
23
23
  install_requires=REQUIREMENTS,
24
24
  author=(
25
25
  "Denis Korytkin, Nataliia Guieva, "
@@ -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