selenium-expect 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,111 @@
1
+ """Custom matcher registry for selenium-expect.
2
+
3
+ Allows users to register custom assertion methods that integrate with
4
+ the retry loop and negation via ``__getattr__`` on ``AssertionMixin``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from types import ModuleType
11
+ from typing import Any, ClassVar
12
+
13
+
14
+ class CustomMatcherRegistry:
15
+ """Registry of user-defined custom matchers."""
16
+
17
+ _matchers: ClassVar[dict[str, Callable[..., tuple[bool, Any]]]] = {}
18
+
19
+ @classmethod
20
+ def register(cls, name: str, matcher_fn: Callable[..., tuple[bool, Any]]) -> None:
21
+ """Register a custom matcher function under *name*."""
22
+ cls._matchers[name] = matcher_fn
23
+
24
+ @classmethod
25
+ def get(cls, name: str) -> Callable[..., tuple[bool, Any]] | None:
26
+ """Return the matcher registered under *name*, or ``None``."""
27
+ return cls._matchers.get(name)
28
+
29
+ @classmethod
30
+ def names(cls) -> list[str]:
31
+ """Return all registered matcher names."""
32
+ return list(cls._matchers)
33
+
34
+ @classmethod
35
+ def reset(cls) -> None:
36
+ """Clear all registered matchers (for testing)."""
37
+ cls._matchers.clear()
38
+
39
+ @classmethod
40
+ def merge_from(cls, *modules: ModuleType) -> list[str]:
41
+ """Merge matchers from the given modules into this registry.
42
+
43
+ Each module is expected to have used ``@extend`` to register
44
+ matchers at import time. This method imports (if passed as a
45
+ string) or processes each module and returns the names of
46
+ newly registered matchers.
47
+ """
48
+ added: list[str] = []
49
+ for mod in modules:
50
+ for name in dir(mod):
51
+ fn = getattr(mod, name, None)
52
+ if callable(fn) and hasattr(fn, "_selenium_expect_matcher"):
53
+ matcher_name: str = fn._selenium_expect_matcher
54
+ if matcher_name not in cls._matchers:
55
+ cls._matchers[matcher_name] = fn
56
+ added.append(matcher_name)
57
+ return added
58
+
59
+
60
+ _MatcherFn = Callable[..., tuple[bool, Any]]
61
+
62
+
63
+ def extend(name: str) -> Callable[[_MatcherFn], _MatcherFn]:
64
+ """Decorator to register a custom matcher under *name*.
65
+
66
+ Usage::
67
+
68
+ @extend("to_be_in_viewport")
69
+ def check_in_viewport(element: Any) -> tuple[bool, Any]:
70
+ ...
71
+ return (passed, actual_value)
72
+
73
+ The matcher function receives the assertion's ``_target`` as its
74
+ first argument and must return a ``(bool, Any)`` tuple where the
75
+ bool indicates pass/fail and the Any is the actual value for
76
+ error reporting.
77
+ """
78
+
79
+ def decorator(fn: Callable[..., tuple[bool, Any]]) -> Callable[..., tuple[bool, Any]]:
80
+ CustomMatcherRegistry.register(name, fn)
81
+ fn._selenium_expect_matcher = name # type: ignore[attr-defined]
82
+ return fn
83
+
84
+ return decorator
85
+
86
+
87
+ def merge_expects(*modules: ModuleType | str) -> list[str]:
88
+ """Combine custom matchers from multiple modules into the registry.
89
+
90
+ Each module should have used ``@extend`` to register matchers.
91
+ Pass modules as objects or importable strings.
92
+
93
+ Usage::
94
+
95
+ import my_matchers
96
+ merge_expects(my_matchers)
97
+
98
+ # or by import path:
99
+ merge_expects("my_project.matchers")
100
+
101
+ Returns the list of newly registered matcher names.
102
+ """
103
+ import importlib
104
+
105
+ resolved: list[ModuleType] = []
106
+ for mod in modules:
107
+ if isinstance(mod, str):
108
+ resolved.append(importlib.import_module(mod))
109
+ else:
110
+ resolved.append(mod)
111
+ return CustomMatcherRegistry.merge_from(*resolved)
@@ -0,0 +1,221 @@
1
+ """PollAssertion — retry-based assertions over arbitrary functions.
2
+
3
+ ``poll(fn)`` wraps any zero-arg callable and provides Playwright-style
4
+ assertion methods that retry until the function's return value satisfies
5
+ the condition or the timeout expires.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ from selenium_expect._config import (
15
+ ExpectConfig,
16
+ get_config,
17
+ )
18
+ from selenium_expect._config import (
19
+ normalize_timeout as _normalize_timeout,
20
+ )
21
+ from selenium_expect._errors import AssertionFormatter
22
+ from selenium_expect._retry import retry_until
23
+
24
+
25
+ class PollAssertion:
26
+ """Assertion over an arbitrary function with retry loop."""
27
+
28
+ def __init__(
29
+ self,
30
+ fn: Callable[[], Any],
31
+ timeout: float | None = None,
32
+ polling: float | list[float] | None = None,
33
+ config: ExpectConfig | None = None,
34
+ ) -> None:
35
+ self._fn = fn
36
+ self._config = config if config is not None else get_config()
37
+ self._timeout = _normalize_timeout(timeout) if timeout is not None else self._config.timeout
38
+ if self._timeout < 0:
39
+ raise ValueError(f"timeout must be >= 0, got {self._timeout}")
40
+ if polling is None:
41
+ self._polling_interval = self._config.polling_interval
42
+ self._polling_intervals = self._config.polling_intervals
43
+ elif isinstance(polling, list):
44
+ if len(polling) == 0:
45
+ raise ValueError("polling list must not be empty; use a float for fixed interval")
46
+ self._polling_interval = 0.5
47
+ self._polling_intervals = polling
48
+ else:
49
+ self._polling_interval = polling
50
+ self._polling_intervals = None
51
+ if self._polling_interval < 0:
52
+ raise ValueError(f"polling interval must be >= 0, got {self._polling_interval}")
53
+ if self._polling_intervals is not None:
54
+ for i, interval in enumerate(self._polling_intervals):
55
+ if interval < 0:
56
+ raise ValueError(f"polling_intervals[{i}] must be >= 0, got {interval}")
57
+
58
+ def _run(
59
+ self,
60
+ condition: Callable[[], tuple[bool, Any]],
61
+ condition_name: str,
62
+ expected: Any = None,
63
+ ) -> None:
64
+ """Execute the retry loop and raise on failure."""
65
+ result = retry_until(
66
+ condition=condition,
67
+ timeout=self._timeout,
68
+ polling_interval=self._polling_interval,
69
+ polling_intervals=self._polling_intervals,
70
+ debug=self._config.debug_mode,
71
+ )
72
+ if result.passed:
73
+ return
74
+ error_msg = AssertionFormatter.format_error(
75
+ entity="poll()",
76
+ condition=condition_name,
77
+ expected=expected,
78
+ actual=result.actual_value,
79
+ elapsed_ms=result.elapsed_ms,
80
+ poll_count=result.poll_count,
81
+ polling_interval=self._polling_interval,
82
+ timeline=result.timeline,
83
+ )
84
+ if self._config.soft_mode:
85
+ from selenium_expect._soft import SoftAssertionCollector
86
+
87
+ SoftAssertionCollector.add_failure(error_msg)
88
+ else:
89
+ raise AssertionError(error_msg)
90
+
91
+ def to_equal(self, expected: Any) -> None:
92
+ """Assert fn() == expected."""
93
+ fn = self._fn
94
+
95
+ def condition() -> tuple[bool, Any]:
96
+ actual = fn()
97
+ return (actual == expected, actual)
98
+
99
+ self._run(condition, f"to equal {expected!r}", expected)
100
+
101
+ def to_be_truthy(self) -> None:
102
+ """Assert bool(fn()) is True."""
103
+ fn = self._fn
104
+
105
+ def condition() -> tuple[bool, Any]:
106
+ actual = fn()
107
+ return (bool(actual), actual)
108
+
109
+ self._run(condition, "to be truthy", True)
110
+
111
+ def to_be_falsy(self) -> None:
112
+ """Assert bool(fn()) is False."""
113
+ fn = self._fn
114
+
115
+ def condition() -> tuple[bool, Any]:
116
+ actual = fn()
117
+ return (not bool(actual), actual)
118
+
119
+ self._run(condition, "to be falsy", False)
120
+
121
+ def to_be_none(self) -> None:
122
+ """Assert fn() is None."""
123
+ fn = self._fn
124
+
125
+ def condition() -> tuple[bool, Any]:
126
+ actual = fn()
127
+ return (actual is None, actual)
128
+
129
+ self._run(condition, "to be None", None)
130
+
131
+ def to_contain(self, expected: Any) -> None:
132
+ """Assert expected in fn()."""
133
+ fn = self._fn
134
+
135
+ def condition() -> tuple[bool, Any]:
136
+ actual = fn()
137
+ if actual is None:
138
+ return (False, actual)
139
+ try:
140
+ return (expected in actual, actual)
141
+ except TypeError:
142
+ return (False, f"not iterable: {actual!r}")
143
+
144
+ self._run(condition, f"to contain {expected!r}", expected)
145
+
146
+ def to_match(self, pattern: str) -> None:
147
+ """Assert re.search(pattern, str(fn()))."""
148
+ fn = self._fn
149
+
150
+ def condition() -> tuple[bool, Any]:
151
+ actual = fn()
152
+ return (re.search(pattern, str(actual)) is not None, actual)
153
+
154
+ self._run(condition, f"to match {pattern!r}", pattern)
155
+
156
+ def to_be_greater_than(self, expected: Any) -> None:
157
+ """Assert fn() > expected."""
158
+ fn = self._fn
159
+
160
+ def condition() -> tuple[bool, Any]:
161
+ actual = fn()
162
+ try:
163
+ return (actual > expected, actual)
164
+ except TypeError:
165
+ return (False, f"not comparable: {actual!r} > {expected!r}")
166
+
167
+ self._run(condition, f"to be greater than {expected}", expected)
168
+
169
+ def to_be_less_than(self, expected: Any) -> None:
170
+ """Assert fn() < expected."""
171
+ fn = self._fn
172
+
173
+ def condition() -> tuple[bool, Any]:
174
+ actual = fn()
175
+ try:
176
+ return (actual < expected, actual)
177
+ except TypeError:
178
+ return (False, f"not comparable: {actual!r} < {expected!r}")
179
+
180
+ self._run(condition, f"to be less than {expected}", expected)
181
+
182
+ def to_be_in_list(self, expected: list[Any]) -> None:
183
+ """Assert fn() in expected."""
184
+ fn = self._fn
185
+
186
+ def condition() -> tuple[bool, Any]:
187
+ actual = fn()
188
+ return (actual in expected, actual)
189
+
190
+ self._run(condition, f"to be in {expected!r}", expected)
191
+
192
+ def to_have_length(self, expected: int) -> None:
193
+ """Assert len(fn()) == expected."""
194
+ fn = self._fn
195
+
196
+ def condition() -> tuple[bool, Any]:
197
+ actual = fn()
198
+ try:
199
+ actual_len = len(actual)
200
+ except TypeError:
201
+ return (False, f"no len() for {actual!r}")
202
+ return (actual_len == expected, actual_len)
203
+
204
+ self._run(condition, f"to have length {expected}", expected)
205
+
206
+
207
+ def poll(
208
+ fn: Callable[[], Any],
209
+ *,
210
+ timeout: float | None = None,
211
+ polling: float | list[float] | None = None,
212
+ config: ExpectConfig | None = None,
213
+ ) -> PollAssertion:
214
+ """Create a ``PollAssertion`` for retry-based assertions on *fn*.
215
+
216
+ Usage::
217
+
218
+ poll(lambda: driver.execute_script("return document.readyState"))
219
+ .to_equal("complete")
220
+ """
221
+ return PollAssertion(fn, timeout=timeout, polling=polling, config=config)
@@ -0,0 +1,145 @@
1
+ """Retry engine for selenium-expect assertions.
2
+
3
+ The retry loop is agnostic — it knows nothing about Selenium. It calls
4
+ a condition callable repeatedly until it returns ``(True, value)`` or
5
+ the timeout expires. Retryable Selenium exceptions are caught and
6
+ treated as a failed poll.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import time
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ from selenium.common.exceptions import (
18
+ ElementNotInteractableException,
19
+ NoAlertPresentException,
20
+ NoSuchElementException,
21
+ NoSuchFrameException,
22
+ NoSuchShadowRootException,
23
+ NoSuchWindowException,
24
+ StaleElementReferenceException,
25
+ )
26
+
27
+ logger = logging.getLogger("selenium_expect")
28
+
29
+ RETRYABLE_EXCEPTIONS: tuple[type[Exception], ...] = (
30
+ StaleElementReferenceException,
31
+ NoSuchElementException,
32
+ NoAlertPresentException,
33
+ NoSuchWindowException,
34
+ NoSuchFrameException,
35
+ ElementNotInteractableException,
36
+ NoSuchShadowRootException,
37
+ )
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class RetryResult:
42
+ """Result of a retry loop execution."""
43
+
44
+ passed: bool
45
+ actual_value: Any
46
+ elapsed_ms: int
47
+ poll_count: int
48
+ timeline: list[dict[str, Any]] = field(default_factory=list)
49
+
50
+
51
+ def retry_until(
52
+ condition: Callable[[], tuple[bool, Any]],
53
+ timeout: float,
54
+ polling_interval: float = 0.5,
55
+ polling_intervals: list[float] | None = None,
56
+ debug: bool = False,
57
+ ) -> RetryResult:
58
+ """Execute ``condition()`` repeatedly until it returns ``(True, value)`` or timeout.
59
+
60
+ Args:
61
+ condition: Callable returning ``(passed: bool, actual_value: Any)``.
62
+ timeout: Maximum seconds to wait.
63
+ polling_interval: Fixed seconds between polls.
64
+ polling_intervals: Backoff schedule (overrides ``polling_interval``).
65
+ When exhausted, the last interval is repeated.
66
+ debug: If ``True``, log each poll via the ``logging`` module.
67
+
68
+ Returns:
69
+ ``RetryResult`` with pass/fail, actual value, elapsed time, and poll timeline.
70
+ """
71
+ start = time.monotonic()
72
+ poll_count = 0
73
+ timeline: list[dict[str, Any]] = []
74
+ actual_value: Any = None
75
+
76
+ def _get_interval(idx: int) -> float:
77
+ if polling_intervals is None or len(polling_intervals) == 0:
78
+ return polling_interval
79
+ if idx < len(polling_intervals):
80
+ return polling_intervals[idx]
81
+ return polling_intervals[-1]
82
+
83
+ while True:
84
+ poll_count += 1
85
+ try:
86
+ passed, actual_value = condition()
87
+ except RETRYABLE_EXCEPTIONS as exc:
88
+ passed = False
89
+ actual_value = f"{type(exc).__name__}: {exc}"
90
+ if debug:
91
+ logger.debug(
92
+ "poll %d: retryable exception %s — %.1fms elapsed",
93
+ poll_count,
94
+ type(exc).__name__,
95
+ (time.monotonic() - start) * 1000,
96
+ )
97
+ timeline.append({"poll": poll_count, "passed": False, "actual": str(actual_value)})
98
+ elapsed = time.monotonic() - start
99
+ if elapsed >= timeout:
100
+ break
101
+ _sleep_and_check_timeout(start, timeout, _get_interval(poll_count - 1))
102
+ continue
103
+
104
+ if debug:
105
+ logger.debug(
106
+ "poll %d: passed=%s actual=%r — %.1fms elapsed",
107
+ poll_count,
108
+ passed,
109
+ actual_value,
110
+ (time.monotonic() - start) * 1000,
111
+ )
112
+
113
+ timeline.append({"poll": poll_count, "passed": passed, "actual": actual_value})
114
+
115
+ if passed:
116
+ return RetryResult(
117
+ passed=True,
118
+ actual_value=actual_value,
119
+ elapsed_ms=int((time.monotonic() - start) * 1000),
120
+ poll_count=poll_count,
121
+ timeline=timeline,
122
+ )
123
+
124
+ elapsed = time.monotonic() - start
125
+ if elapsed >= timeout:
126
+ break
127
+
128
+ _sleep_and_check_timeout(start, timeout, _get_interval(poll_count - 1))
129
+
130
+ return RetryResult(
131
+ passed=False,
132
+ actual_value=actual_value,
133
+ elapsed_ms=int((time.monotonic() - start) * 1000),
134
+ poll_count=poll_count,
135
+ timeline=timeline,
136
+ )
137
+
138
+
139
+ def _sleep_and_check_timeout(start: float, timeout: float, interval: float) -> None:
140
+ """Sleep for *interval* seconds, but not past the timeout deadline."""
141
+ elapsed = time.monotonic() - start
142
+ remaining = timeout - elapsed
143
+ if remaining <= 0:
144
+ return
145
+ time.sleep(min(interval, remaining))
@@ -0,0 +1,49 @@
1
+ """Soft assertion collector for selenium-expect.
2
+
3
+ Accumulates assertion failures when ``soft_mode=True`` instead of raising
4
+ immediately. ``assert_all()`` raises a combined ``AssertionError`` if any
5
+ failures were collected.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import ClassVar
11
+
12
+
13
+ class SoftAssertionCollector:
14
+ """Collects soft assertion failures for deferred raising."""
15
+
16
+ _failures: ClassVar[list[str]] = []
17
+
18
+ @classmethod
19
+ def add_failure(cls, message: str) -> None:
20
+ """Record a soft assertion failure."""
21
+ cls._failures.append(message)
22
+
23
+ @classmethod
24
+ def get_failures(cls) -> list[str]:
25
+ """Return all collected failures."""
26
+ return list(cls._failures)
27
+
28
+ @classmethod
29
+ def reset(cls) -> None:
30
+ """Clear all collected failures."""
31
+ cls._failures.clear()
32
+
33
+ @classmethod
34
+ def assert_all(cls) -> None:
35
+ """Raise ``AssertionError`` if any failures were collected, then reset."""
36
+ if not cls._failures:
37
+ return
38
+ messages = list(cls._failures)
39
+ cls.reset()
40
+ combined = "\n---\n".join(messages)
41
+ raise AssertionError(f"Soft assertion failures ({len(messages)}):\n{combined}")
42
+
43
+
44
+ def assert_all() -> None:
45
+ """Raise ``AssertionError`` if any soft failures were collected, then reset.
46
+
47
+ Convenience wrapper around ``SoftAssertionCollector.assert_all()``.
48
+ """
49
+ SoftAssertionCollector.assert_all()
@@ -0,0 +1,30 @@
1
+ """Assertion class registry for selenium-expect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from typing import Any
7
+
8
+ ASSERTION_REGISTRY: dict[str, type[Any]] = {}
9
+
10
+
11
+ def register(type_name: str, cls: type[Any]) -> None:
12
+ """Register an assertion class for a target type name."""
13
+ ASSERTION_REGISTRY[type_name] = cls
14
+
15
+
16
+ # Import all assertion modules to trigger their register() calls.
17
+ # Done via importlib to avoid issues with `list` being a Python builtin.
18
+ for _module_name in (
19
+ "alert",
20
+ "cookie",
21
+ "driver",
22
+ "element",
23
+ "iframe",
24
+ "js",
25
+ "list",
26
+ "select",
27
+ "shadow",
28
+ "window",
29
+ ):
30
+ importlib.import_module(f"selenium_expect.assertions.{_module_name}")