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,35 @@
1
+ """selenium-expect — Playwright-style expect() for Selenium Python."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from selenium_expect._config import (
6
+ ExpectConfig,
7
+ get_config,
8
+ set_debug_mode,
9
+ set_default_polling_interval,
10
+ set_default_polling_intervals,
11
+ set_default_timeout,
12
+ set_screenshot_on_failure,
13
+ )
14
+ from selenium_expect._expect import expect
15
+ from selenium_expect._matcher import extend, merge_expects
16
+ from selenium_expect._poll import poll
17
+ from selenium_expect._soft import SoftAssertionCollector, assert_all
18
+
19
+ __version__ = "1.0.0"
20
+
21
+ __all__: list[str] = [
22
+ "ExpectConfig",
23
+ "SoftAssertionCollector",
24
+ "assert_all",
25
+ "expect",
26
+ "extend",
27
+ "get_config",
28
+ "merge_expects",
29
+ "poll",
30
+ "set_debug_mode",
31
+ "set_default_polling_interval",
32
+ "set_default_polling_intervals",
33
+ "set_default_timeout",
34
+ "set_screenshot_on_failure",
35
+ ]
@@ -0,0 +1,117 @@
1
+ """Composition assertions — satisfy_all, satisfy_any, satisfy_none.
2
+
3
+ Each condition is a callable that receives the target and executes an
4
+ assertion (e.g. a lambda calling ``expect(el).to_be_visible()``).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from typing import Any
11
+
12
+ from selenium_expect._errors import AssertionFormatter
13
+
14
+
15
+ def satisfy_all(
16
+ target: Any,
17
+ *conditions: Callable[[Any], None],
18
+ message: str | None = None,
19
+ ) -> None:
20
+ """Assert all conditions pass (AND logic).
21
+
22
+ Each condition is a callable that receives *target* and may raise
23
+ ``AssertionError`` on failure. All must pass without raising.
24
+
25
+ Each condition is responsible for its own retry/timeout via ``expect()``.
26
+ """
27
+ if not conditions:
28
+ raise ValueError("satisfy_all requires at least one condition")
29
+ failures: list[str] = []
30
+ for i, cond in enumerate(conditions):
31
+ try:
32
+ cond(target)
33
+ except AssertionError as exc:
34
+ failures.append(f"condition {i}: {exc}")
35
+ if failures:
36
+ combined = "\n".join(failures)
37
+ error_msg = AssertionFormatter.format_error(
38
+ entity="composition",
39
+ condition="to_satisfy_all",
40
+ expected="all pass",
41
+ actual=combined,
42
+ elapsed_ms=0,
43
+ poll_count=len(conditions),
44
+ polling_interval=0.0,
45
+ message=message,
46
+ )
47
+ raise AssertionError(error_msg)
48
+
49
+
50
+ def satisfy_any(
51
+ target: Any,
52
+ *conditions: Callable[[Any], None],
53
+ message: str | None = None,
54
+ ) -> None:
55
+ """Assert at least one condition passes (OR logic).
56
+
57
+ Each condition is a callable that receives *target* and may raise
58
+ ``AssertionError`` on failure. At least one must pass without raising.
59
+
60
+ Each condition is responsible for its own retry/timeout via ``expect()``.
61
+ """
62
+ if not conditions:
63
+ raise ValueError("satisfy_any requires at least one condition")
64
+ failures: list[str] = []
65
+ for i, cond in enumerate(conditions):
66
+ try:
67
+ cond(target)
68
+ return # at least one passed
69
+ except AssertionError as exc:
70
+ failures.append(f"condition {i}: {exc}")
71
+ combined = "\n".join(failures)
72
+ error_msg = AssertionFormatter.format_error(
73
+ entity="composition",
74
+ condition="to_satisfy_any",
75
+ expected="at least one pass",
76
+ actual=combined,
77
+ elapsed_ms=0,
78
+ poll_count=len(conditions),
79
+ polling_interval=0.0,
80
+ message=message,
81
+ )
82
+ raise AssertionError(error_msg)
83
+
84
+
85
+ def satisfy_none(
86
+ target: Any,
87
+ *conditions: Callable[[Any], None],
88
+ message: str | None = None,
89
+ ) -> None:
90
+ """Assert no condition passes (NOT logic).
91
+
92
+ Each condition is a callable that receives *target* and may raise
93
+ ``AssertionError``. All must raise (i.e. none pass).
94
+
95
+ Each condition is responsible for its own retry/timeout via ``expect()``.
96
+ """
97
+ if not conditions:
98
+ raise ValueError("satisfy_none requires at least one condition")
99
+ passed: list[int] = []
100
+ for i, cond in enumerate(conditions):
101
+ try:
102
+ cond(target)
103
+ passed.append(i)
104
+ except AssertionError:
105
+ pass # expected to fail
106
+ if passed:
107
+ error_msg = AssertionFormatter.format_error(
108
+ entity="composition",
109
+ condition="to_satisfy_none",
110
+ expected="none pass",
111
+ actual=f"conditions {passed} passed",
112
+ elapsed_ms=0,
113
+ poll_count=len(conditions),
114
+ polling_interval=0.0,
115
+ message=message,
116
+ )
117
+ raise AssertionError(error_msg)
@@ -0,0 +1,96 @@
1
+ """Configuration for selenium-expect assertions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from dataclasses import replace as _replace
7
+ from typing import Any
8
+
9
+
10
+ def normalize_timeout(timeout: float) -> float:
11
+ """Convert timeout to seconds.
12
+
13
+ If *timeout* is an int >= 1000, it is interpreted as milliseconds.
14
+ Floats and ints < 1000 are treated as seconds.
15
+ """
16
+ if isinstance(timeout, int) and timeout >= 1000:
17
+ return timeout / 1000.0
18
+ return float(timeout)
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class ExpectConfig:
23
+ """Immutable configuration for expect assertions.
24
+
25
+ Use ``replace()`` to create a new instance with overridden fields.
26
+ The global config singleton is mutated via the module-level setters.
27
+ """
28
+
29
+ timeout: float = 5.0
30
+ polling_interval: float = 0.5
31
+ polling_intervals: list[float] | None = None
32
+ screenshot_on_failure: bool = False
33
+ screenshot_path: str | None = None
34
+ debug_mode: bool = False
35
+ soft_mode: bool = False
36
+
37
+ def __post_init__(self) -> None:
38
+ if self.timeout < 0:
39
+ raise ValueError(f"timeout must be >= 0, got {self.timeout}")
40
+ if self.polling_interval < 0:
41
+ raise ValueError(f"polling_interval must be >= 0, got {self.polling_interval}")
42
+ if self.polling_intervals is not None:
43
+ if len(self.polling_intervals) == 0:
44
+ raise ValueError("polling_intervals must not be empty; use None for fixed interval")
45
+ for i, interval in enumerate(self.polling_intervals):
46
+ if interval < 0:
47
+ raise ValueError(f"polling_intervals[{i}] must be >= 0, got {interval}")
48
+
49
+ def replace(self, **kwargs: Any) -> ExpectConfig:
50
+ """Return a new instance with overridden fields."""
51
+ return _replace(self, **kwargs)
52
+
53
+
54
+ _global_config: ExpectConfig = ExpectConfig()
55
+
56
+
57
+ def set_default_timeout(seconds: float) -> None:
58
+ """Set the default timeout for all expect assertions.
59
+
60
+ If *seconds* is an int >= 1000, it is interpreted as milliseconds
61
+ (consistent with ``expect(timeout=...)``).
62
+ """
63
+ global _global_config
64
+ _global_config = _global_config.replace(timeout=normalize_timeout(seconds))
65
+
66
+
67
+ def set_default_polling_interval(seconds: float) -> None:
68
+ """Set the default polling interval for all expect assertions."""
69
+ global _global_config
70
+ _global_config = _global_config.replace(polling_interval=seconds)
71
+
72
+
73
+ def set_default_polling_intervals(intervals: list[float]) -> None:
74
+ """Set a backoff schedule for polling intervals."""
75
+ global _global_config
76
+ _global_config = _global_config.replace(polling_intervals=intervals)
77
+
78
+
79
+ def set_screenshot_on_failure(enabled: bool, path: str | None = None) -> None:
80
+ """Enable or disable screenshot capture on assertion failure."""
81
+ global _global_config
82
+ _global_config = _global_config.replace(
83
+ screenshot_on_failure=enabled,
84
+ screenshot_path=path if path is not None else "./screenshots/",
85
+ )
86
+
87
+
88
+ def set_debug_mode(enabled: bool) -> None:
89
+ """Enable or disable debug logging for retry loops."""
90
+ global _global_config
91
+ _global_config = _global_config.replace(debug_mode=enabled)
92
+
93
+
94
+ def get_config() -> ExpectConfig:
95
+ """Return the current global config."""
96
+ return _global_config
@@ -0,0 +1,57 @@
1
+ """Assertion error message formatting for selenium-expect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class AssertionFormatter:
9
+ """Builds descriptive multi-line error messages."""
10
+
11
+ @staticmethod
12
+ def format_error(
13
+ entity: str,
14
+ condition: str,
15
+ expected: Any,
16
+ actual: Any,
17
+ elapsed_ms: int,
18
+ poll_count: int,
19
+ polling_interval: float,
20
+ message: str | None = None,
21
+ element_html: str | None = None,
22
+ timeline: list[dict[str, Any]] | None = None,
23
+ ) -> str:
24
+ """Format a descriptive AssertionError message.
25
+
26
+ Output format:
27
+ Expected {entity} {condition}, but got {actual}
28
+ Expected: {expected}
29
+ Actual: {actual}
30
+ Element: {truncated_html} # if element_html provided
31
+ Waited: {elapsed_ms}ms ({poll_count} polls at {polling_interval}s interval)
32
+ Message: {custom_message} # if message provided
33
+ Timeline: [poll N: actual, ...] # last 5 polls if timeline provided
34
+ """
35
+ lines: list[str] = [
36
+ f"Expected {entity} {condition}, but got {actual}",
37
+ f" Expected: {expected}",
38
+ f" Actual: {actual}",
39
+ ]
40
+
41
+ if element_html is not None:
42
+ truncated = element_html if len(element_html) <= 200 else element_html[:200] + "..."
43
+ lines.append(f" Element: {truncated}")
44
+
45
+ lines.append(
46
+ f" Waited: {elapsed_ms}ms ({poll_count} polls at {polling_interval}s interval)"
47
+ )
48
+
49
+ if message is not None:
50
+ lines.append(f" Message: {message}")
51
+
52
+ if timeline:
53
+ recent = timeline[-5:]
54
+ entries = ", ".join(f"poll {e['poll']}: {e['actual']}" for e in recent)
55
+ lines.append(f" Timeline: [{entries}]")
56
+
57
+ return "\n".join(lines)
@@ -0,0 +1,181 @@
1
+ """expect() entry point — dispatches to the correct assertion class."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any, cast
7
+
8
+ from selenium.webdriver.common.alert import Alert
9
+ from selenium.webdriver.remote.shadowroot import ShadowRoot
10
+ from selenium.webdriver.remote.webdriver import WebDriver
11
+ from selenium.webdriver.remote.webelement import WebElement
12
+ from selenium.webdriver.support.ui import Select
13
+
14
+ from selenium_expect._config import ExpectConfig, get_config, normalize_timeout
15
+ from selenium_expect._poll import PollAssertion
16
+ from selenium_expect.assertions import ASSERTION_REGISTRY
17
+ from selenium_expect.assertions._base import AssertionMixin
18
+
19
+
20
+ def _resolve_target_type(target: Any) -> str:
21
+ """Determine the registry type name for *target*.
22
+
23
+ Select is checked before WebElement because Select wraps a WebElement.
24
+ """
25
+ if isinstance(target, Select):
26
+ return "Select"
27
+ if isinstance(target, WebElement):
28
+ return "WebElement"
29
+ if isinstance(target, list):
30
+ return "list"
31
+ if isinstance(target, WebDriver):
32
+ return "WebDriver"
33
+ if isinstance(target, Alert):
34
+ return "Alert"
35
+ if isinstance(target, ShadowRoot):
36
+ return "ShadowRoot"
37
+ cls_name = type(target).__name__
38
+ if cls_name == "WebDriver":
39
+ return "WebDriver"
40
+ if cls_name == "Alert":
41
+ return "Alert"
42
+ return cls_name
43
+
44
+
45
+ class Expect:
46
+ """Callable expect dispatcher with attached utilities.
47
+
48
+ Use ``expect(target)`` to create assertions, ``expect.poll(fn)`` for
49
+ retry-based polling, and ``expect.configure(...)`` for pre-configured
50
+ variants.
51
+ """
52
+
53
+ def __call__(
54
+ self,
55
+ target: Any,
56
+ /,
57
+ *,
58
+ message: str | None = None,
59
+ timeout: float | None = None,
60
+ polling: float | list[float] | None = None,
61
+ soft: bool | None = None,
62
+ config: ExpectConfig | None = None,
63
+ by: str | None = None,
64
+ value: str | None = None,
65
+ locator: tuple[str, str] | None = None,
66
+ ) -> AssertionMixin:
67
+ """Create an expect assertion for the given target.
68
+
69
+ Dispatches to the appropriate assertion class via ``ASSERTION_REGISTRY``
70
+ based on the target's type.
71
+
72
+ If ``by`` and ``value`` are provided, a ``LocatorExpect`` is created
73
+ that re-finds the element on each poll cycle.
74
+
75
+ Alternatively, ``locator=(By.ID, 'foo')`` can be used as a tuple
76
+ shorthand for ``by=..., value=...``.
77
+ """
78
+ if locator is not None:
79
+ if by is not None or value is not None:
80
+ raise ValueError("Cannot use both 'locator' and 'by/value' arguments")
81
+ by, value = locator
82
+
83
+ if (by is not None) != (value is not None):
84
+ raise ValueError("Must provide both 'by' and 'value', or neither")
85
+
86
+ if by is not None and value is not None:
87
+ from selenium_expect._locator import LocatorExpect
88
+
89
+ if not isinstance(target, WebDriver):
90
+ raise TypeError("expect() with by/value requires a WebDriver target")
91
+
92
+ effective_config = config if config is not None else get_config()
93
+ if timeout is not None or polling is not None or soft is not None:
94
+ overrides: dict[str, Any] = {}
95
+ if timeout is not None:
96
+ overrides["timeout"] = normalize_timeout(timeout)
97
+ if polling is not None:
98
+ if isinstance(polling, list):
99
+ overrides["polling_intervals"] = polling
100
+ else:
101
+ overrides["polling_interval"] = polling
102
+ if soft is not None:
103
+ overrides["soft_mode"] = soft
104
+ effective_config = effective_config.replace(**overrides)
105
+
106
+ return LocatorExpect(
107
+ driver=target,
108
+ by=by,
109
+ value=value,
110
+ config=effective_config,
111
+ message=message,
112
+ )
113
+
114
+ if target is None:
115
+ raise TypeError("expect() does not support None as target")
116
+
117
+ type_name = _resolve_target_type(target)
118
+ cls = ASSERTION_REGISTRY.get(type_name)
119
+ if cls is None:
120
+ raise TypeError(f"expect() does not support target type '{type_name}'")
121
+
122
+ assertion_cls = cast(type[AssertionMixin], cls)
123
+
124
+ effective_config = config if config is not None else get_config()
125
+
126
+ if timeout is not None or polling is not None or soft is not None:
127
+ cfg_overrides: dict[str, Any] = {}
128
+ if timeout is not None:
129
+ cfg_overrides["timeout"] = normalize_timeout(timeout)
130
+ if polling is not None:
131
+ if isinstance(polling, list):
132
+ cfg_overrides["polling_intervals"] = polling
133
+ else:
134
+ cfg_overrides["polling_interval"] = polling
135
+ if soft is not None:
136
+ cfg_overrides["soft_mode"] = soft
137
+ effective_config = effective_config.replace(**cfg_overrides)
138
+
139
+ return assertion_cls(target=target, config=effective_config, message=message)
140
+
141
+ def poll(
142
+ self,
143
+ fn: Callable[[], Any],
144
+ *,
145
+ timeout: float | None = None,
146
+ polling: float | list[float] | None = None,
147
+ config: ExpectConfig | None = None,
148
+ ) -> PollAssertion:
149
+ """Create a ``PollAssertion`` for retry-based assertions on *fn*.
150
+
151
+ Usage::
152
+
153
+ expect.poll(lambda: driver.execute_script("return document.readyState"))
154
+ .to_equal("complete")
155
+ """
156
+ return PollAssertion(fn, timeout=timeout, polling=polling, config=config)
157
+
158
+ def configure(self, **defaults: Any) -> Callable[..., AssertionMixin]:
159
+ """Create a pre-configured expect variant.
160
+
161
+ Returns a callable that behaves like ``expect()`` with *defaults*
162
+ pre-applied. Explicit kwargs from the caller override the defaults.
163
+
164
+ Usage::
165
+
166
+ fast_expect = expect.configure(timeout=1.0, polling=0.1)
167
+ fast_expect(el).to_be_visible()
168
+ """
169
+
170
+ def _configured_expect(
171
+ target: Any,
172
+ /,
173
+ **overrides: Any,
174
+ ) -> AssertionMixin:
175
+ merged: dict[str, Any] = {**defaults, **overrides}
176
+ return self(target, **merged)
177
+
178
+ return _configured_expect
179
+
180
+
181
+ expect = Expect()
@@ -0,0 +1,157 @@
1
+ """LocatorExpect — re-finds element on each poll cycle.
2
+
3
+ When ``expect(driver, by=By.ID, value="foo")`` is used, a ``LocatorExpect``
4
+ is returned instead of ``ExpectElement``. On every poll cycle it calls
5
+ ``driver.find_element(by, value)`` to get a fresh element, then delegates
6
+ the condition check to the corresponding ``ExpectElement`` method.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from selenium.common.exceptions import NoSuchElementException, StaleElementReferenceException
14
+ from selenium.webdriver.remote.webdriver import WebDriver
15
+
16
+ from selenium_expect._config import ExpectConfig
17
+ from selenium_expect.assertions._base import AssertionMixin
18
+
19
+
20
+ class LocatorExpect(AssertionMixin):
21
+ """Locator-based expect that re-finds the element on each poll.
22
+
23
+ Delegates all ``ExpectElement`` assertion methods via ``__getattr__``.
24
+ Each assertion method is executed with a fresh element obtained from
25
+ ``driver.find_element(by, value)`` on every poll cycle.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ driver: WebDriver,
31
+ by: str,
32
+ value: str,
33
+ config: ExpectConfig | None = None,
34
+ message: str | None = None,
35
+ negate: bool = False,
36
+ ) -> None:
37
+ super().__init__(target=driver, config=config, message=message, negate=negate)
38
+ self._driver = driver
39
+ self._by = by
40
+ self._value = value
41
+
42
+ def _find_element(self) -> Any:
43
+ """Find the element fresh. Returns None if not found."""
44
+ try:
45
+ return self._driver.find_element(self._by, self._value)
46
+ except NoSuchElementException:
47
+ return None
48
+
49
+ @property
50
+ def not_(self) -> LocatorExpect:
51
+ """Return a negated copy."""
52
+ return LocatorExpect(
53
+ driver=self._driver,
54
+ by=self._by,
55
+ value=self._value,
56
+ config=self._config,
57
+ message=self._message,
58
+ negate=not self._negate,
59
+ )
60
+
61
+ def _entity_description(self) -> str:
62
+ return f"locator({self._by}={self._value!r})"
63
+
64
+ def _get_element_html(self) -> str | None:
65
+ el = self._find_element()
66
+ if el is None:
67
+ return None
68
+ try:
69
+ html: str | None = el.get_attribute("outerHTML")
70
+ return html
71
+ except StaleElementReferenceException:
72
+ return None
73
+
74
+ def __getattr__(self, name: str) -> Any:
75
+ """Delegate to ExpectElement methods or custom matchers with re-find on each poll.
76
+
77
+ For each assertion method call, we wrap the condition so that
78
+ ``find_element`` is called fresh on every retry poll.
79
+ """
80
+ # Avoid recursion for private/dunder attributes
81
+ if name.startswith("_"):
82
+ raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}")
83
+
84
+ from selenium_expect._matcher import CustomMatcherRegistry
85
+ from selenium_expect.assertions.element import ExpectElement
86
+
87
+ # Check for custom matcher first
88
+ matcher_fn = CustomMatcherRegistry.get(name)
89
+
90
+ # Get the actual method from ExpectElement
91
+ element_method = getattr(ExpectElement, name, None)
92
+
93
+ if matcher_fn is None and (element_method is None or not callable(element_method)):
94
+ raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}")
95
+
96
+ if matcher_fn is not None:
97
+
98
+ def _invoke_matcher(*args: Any, **kwargs: Any) -> None:
99
+ timeout = kwargs.pop("timeout", None)
100
+ polling = kwargs.pop("polling", None)
101
+
102
+ def condition() -> tuple[bool, Any]:
103
+ try:
104
+ el = self._driver.find_element(self._by, self._value)
105
+ except NoSuchElementException:
106
+ return (False, "element not found")
107
+ try:
108
+ return matcher_fn(el, *args, **kwargs)
109
+ except StaleElementReferenceException:
110
+ return (False, "stale element")
111
+
112
+ self._run_assertion(
113
+ condition=condition,
114
+ condition_name=name.replace("_", " "),
115
+ expected=None,
116
+ entity=self._entity_description(),
117
+ timeout=timeout,
118
+ polling=polling,
119
+ )
120
+
121
+ return _invoke_matcher
122
+
123
+ def _invoke(*args: Any, **kwargs: Any) -> None:
124
+ timeout = kwargs.pop("timeout", None)
125
+ polling = kwargs.pop("polling", None)
126
+
127
+ def condition() -> tuple[bool, Any]:
128
+ try:
129
+ el = self._driver.find_element(self._by, self._value)
130
+ except NoSuchElementException:
131
+ return (False, "element not found")
132
+ inner_config = self._config.replace(soft_mode=False, screenshot_on_failure=False)
133
+ temp = ExpectElement(
134
+ target=el,
135
+ config=inner_config,
136
+ message=self._message,
137
+ negate=False,
138
+ )
139
+ method = getattr(temp, name)
140
+ try:
141
+ method(*args, timeout=0.001, **kwargs)
142
+ return (True, "passed")
143
+ except AssertionError:
144
+ return (False, "failed")
145
+ except StaleElementReferenceException:
146
+ return (False, "stale element")
147
+
148
+ self._run_assertion(
149
+ condition=condition,
150
+ condition_name=name.replace("_", " "),
151
+ expected=None,
152
+ entity=self._entity_description(),
153
+ timeout=timeout,
154
+ polling=polling,
155
+ )
156
+
157
+ return _invoke