waitless 0.1.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.
waitless/engine.py ADDED
@@ -0,0 +1,275 @@
1
+ """
2
+ Core stabilization engine.
3
+
4
+ This is the heart of waitless - it manages JavaScript injection,
5
+ polls for stability status, and makes the final stability decision.
6
+ """
7
+
8
+ import time
9
+ import threading
10
+ import logging
11
+ from typing import Optional, Dict, Any, TYPE_CHECKING
12
+
13
+ from .config import StabilizationConfig, DEFAULT_CONFIG
14
+ from .signals import SignalEvaluator, StabilityStatus
15
+ from .instrumentation import (
16
+ INSTRUMENTATION_SCRIPT,
17
+ CHECK_ALIVE_SCRIPT,
18
+ GET_STATUS_SCRIPT,
19
+ )
20
+ from .exceptions import StabilizationTimeout, InstrumentationError
21
+
22
+
23
+ if TYPE_CHECKING:
24
+ from selenium.webdriver.remote.webdriver import WebDriver
25
+
26
+
27
+ logger = logging.getLogger('waitless')
28
+
29
+
30
+ class StabilizationEngine:
31
+ """
32
+ Core engine that manages stability detection for a WebDriver instance.
33
+
34
+ Thread-safe: Uses locks to prevent concurrent stabilization calls.
35
+
36
+ Usage:
37
+ engine = StabilizationEngine(driver, config)
38
+ engine.wait_for_stability()
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ driver: 'WebDriver',
44
+ config: Optional[StabilizationConfig] = None
45
+ ):
46
+ self.driver = driver
47
+ self.config = config or DEFAULT_CONFIG
48
+ self.evaluator = SignalEvaluator(self.config)
49
+
50
+ self._lock = threading.Lock()
51
+ self._instrumented = False
52
+ self._last_url: Optional[str] = None
53
+
54
+
55
+ self._last_status: Optional[StabilityStatus] = None
56
+ self._last_blocking_factors: Dict[str, Any] = {}
57
+ self._timeline: list = []
58
+
59
+ if self.config.debug_mode:
60
+ logging.basicConfig(level=logging.DEBUG)
61
+ logger.setLevel(logging.DEBUG)
62
+
63
+ def ensure_instrumented(self) -> None:
64
+ """
65
+ Ensure JavaScript instrumentation is active in the browser.
66
+
67
+ Re-injects if:
68
+ - Never injected before
69
+ - Page navigated (URL changed)
70
+ - Instrumentation is not responding
71
+ """
72
+ current_url = self._get_current_url()
73
+
74
+ needs_injection = (
75
+ not self._instrumented or
76
+ current_url != self._last_url or
77
+ not self._is_instrumentation_alive()
78
+ )
79
+
80
+ if needs_injection:
81
+ self._inject_instrumentation()
82
+ self._last_url = current_url
83
+
84
+ def _get_current_url(self) -> str:
85
+ """Get current page URL safely."""
86
+ try:
87
+ return self.driver.current_url
88
+ except Exception:
89
+ return ""
90
+
91
+ def _is_instrumentation_alive(self) -> bool:
92
+ """
93
+ Check if the __waitless__ object is still alive and wired.
94
+
95
+ This is the re-validation check mentioned in architecture:
96
+ Before every stabilization call, verify instrumentation is active.
97
+ """
98
+ try:
99
+ result = self.driver.execute_script(CHECK_ALIVE_SCRIPT)
100
+ return result is True
101
+ except Exception:
102
+ return False
103
+
104
+ def _inject_instrumentation(self) -> None:
105
+ """Inject JavaScript instrumentation into the page."""
106
+ try:
107
+ self.driver.execute_script(INSTRUMENTATION_SCRIPT)
108
+ self._instrumented = True
109
+ self._debug("Instrumentation injected successfully")
110
+ except Exception as e:
111
+ self._instrumented = False
112
+ raise InstrumentationError(
113
+ f"Failed to inject instrumentation: {e}",
114
+ original_error=e
115
+ )
116
+
117
+ def _get_browser_status(self) -> Optional[Dict[str, Any]]:
118
+ """Get current stability status from browser."""
119
+ try:
120
+ return self.driver.execute_script(GET_STATUS_SCRIPT)
121
+ except Exception as e:
122
+ self._debug(f"Failed to get browser status: {e}")
123
+ return None
124
+
125
+ def wait_for_stability(self, timeout: Optional[float] = None) -> StabilityStatus:
126
+ """
127
+ Wait for UI to become stable.
128
+
129
+ This is the main entry point for stability waiting.
130
+ Thread-safe.
131
+
132
+ Args:
133
+ timeout: Override default timeout (seconds)
134
+
135
+ Returns:
136
+ StabilityStatus when stable
137
+
138
+ Raises:
139
+ StabilizationTimeout: If UI doesn't stabilize in time
140
+ InstrumentationError: If JavaScript injection fails
141
+ """
142
+ with self._lock:
143
+ return self._wait_for_stability_impl(timeout)
144
+
145
+ def _wait_for_stability_impl(self, timeout: Optional[float] = None) -> StabilityStatus:
146
+ """Internal implementation of stability waiting."""
147
+ effective_timeout = timeout or self.config.timeout
148
+ start_time = time.time()
149
+
150
+ self.ensure_instrumented()
151
+
152
+ last_status: Optional[StabilityStatus] = None
153
+
154
+ while True:
155
+ elapsed = time.time() - start_time
156
+
157
+ if elapsed >= effective_timeout:
158
+ # Timeout - collect diagnostic info and raise
159
+ self._handle_timeout(effective_timeout, last_status)
160
+
161
+ browser_state = self._get_browser_status()
162
+
163
+ if browser_state is None:
164
+ if self.config.reinject_on_navigation:
165
+ self._debug("Browser status unavailable, attempting reinject")
166
+ self._inject_instrumentation()
167
+ time.sleep(self.config.poll_interval)
168
+ continue
169
+ else:
170
+ raise InstrumentationError(
171
+ "Lost connection to browser instrumentation"
172
+ )
173
+
174
+ current_time = time.time()
175
+ status = self.evaluator.evaluate(browser_state, current_time)
176
+ last_status = status
177
+ self._last_status = status
178
+
179
+ if status.is_stable:
180
+ self._debug(f"UI stable after {elapsed:.2f}s")
181
+ return status
182
+
183
+ self._update_diagnostics(browser_state, status)
184
+ time.sleep(self.config.poll_interval)
185
+
186
+ def _handle_timeout(
187
+ self,
188
+ timeout: float,
189
+ last_status: Optional[StabilityStatus]
190
+ ) -> None:
191
+ """Handle stabilization timeout with detailed diagnostics."""
192
+ blocking_factors = {}
193
+
194
+ if last_status:
195
+ for signal in last_status.blocking_signals:
196
+ if signal.signal_type.name == 'NETWORK_REQUESTS':
197
+ blocking_factors['pending_requests'] = signal.value
198
+ elif signal.signal_type.name == 'DOM_MUTATIONS':
199
+ blocking_factors['recent_mutations'] = True
200
+ elif signal.signal_type.name == 'CSS_ANIMATIONS':
201
+ blocking_factors['active_animations'] = signal.value
202
+ elif signal.signal_type.name == 'LAYOUT_SHIFT':
203
+ blocking_factors['layout_shifting'] = signal.value
204
+
205
+ message = (
206
+ f"UI did not stabilize within {timeout}s. "
207
+ "Run 'waitless doctor' for detailed analysis."
208
+ )
209
+
210
+ if blocking_factors:
211
+ message += f" Blocking: {list(blocking_factors.keys())}"
212
+
213
+ logger.warning(f"\n{'='*60}")
214
+ logger.warning("WAITLESS TIMEOUT")
215
+ logger.warning(f"{'='*60}")
216
+ logger.warning(message)
217
+ logger.warning(f"{'='*60}\n")
218
+
219
+ raise StabilizationTimeout(
220
+ message=message,
221
+ timeout=timeout,
222
+ blocking_factors=blocking_factors,
223
+ timeline=self._timeline[-50:],
224
+ )
225
+
226
+ def _update_diagnostics(
227
+ self,
228
+ browser_state: Dict[str, Any],
229
+ status: StabilityStatus
230
+ ) -> None:
231
+ """Update diagnostic information for the doctor command."""
232
+ self._last_blocking_factors = {
233
+ 'pending_requests': browser_state.get('pending_requests', 0),
234
+ 'pending_request_details': browser_state.get('pending_request_details', []),
235
+ 'active_animations': browser_state.get('active_animations', 0),
236
+ 'layout_shifting': browser_state.get('layout_shifting', False),
237
+ 'last_mutation_time': browser_state.get('last_mutation_time', 0),
238
+ }
239
+
240
+ timeline = browser_state.get('timeline', [])
241
+ self._timeline.extend(timeline)
242
+ self._timeline = self._timeline[-200:]
243
+
244
+ def _debug(self, message: str) -> None:
245
+ """Log debug message if debug mode is enabled."""
246
+ if self.config.debug_mode:
247
+ logger.debug(f"[waitless] {message}")
248
+
249
+ def get_diagnostics(self) -> Dict[str, Any]:
250
+ """
251
+ Get diagnostic information for the doctor command.
252
+
253
+ Returns:
254
+ Dictionary with diagnostic data
255
+ """
256
+ return {
257
+ 'config': {
258
+ 'timeout': self.config.timeout,
259
+ 'strictness': self.config.strictness,
260
+ 'network_idle_threshold': self.config.network_idle_threshold,
261
+ 'animation_detection': self.config.animation_detection,
262
+ },
263
+ 'last_status': self._last_status.to_dict() if self._last_status else None,
264
+ 'blocking_factors': self._last_blocking_factors,
265
+ 'timeline': self._timeline[-50:],
266
+ 'instrumented': self._instrumented,
267
+ }
268
+
269
+ def reset(self) -> None:
270
+ """Reset engine state (useful between tests)."""
271
+ self._instrumented = False
272
+ self._last_url = None
273
+ self._last_status = None
274
+ self._last_blocking_factors = {}
275
+ self._timeline = []
waitless/exceptions.py ADDED
@@ -0,0 +1,108 @@
1
+ """
2
+ Waitless custom exceptions.
3
+
4
+ Provides clear, actionable error types for stability-related failures.
5
+ """
6
+
7
+ from typing import Optional, Dict, Any
8
+
9
+
10
+ class WaitlessError(Exception):
11
+ """Base exception for all waitless errors."""
12
+ pass
13
+
14
+
15
+ class StabilizationTimeout(WaitlessError):
16
+ """
17
+ Raised when UI doesn't stabilize within the configured timeout.
18
+
19
+ Contains diagnostic information about what was blocking stability.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ message: str,
25
+ timeout: float,
26
+ blocking_factors: Optional[Dict[str, Any]] = None,
27
+ timeline: Optional[list] = None
28
+ ):
29
+ super().__init__(message)
30
+ self.timeout = timeout
31
+ self.blocking_factors = blocking_factors or {}
32
+ self.timeline = timeline or []
33
+
34
+ def get_diagnostic_summary(self) -> str:
35
+ """Return a human-readable summary of what blocked stability."""
36
+ lines = [
37
+ f"\n{'='*60}",
38
+ "STABILIZATION TIMEOUT - UI did not become stable",
39
+ f"{'='*60}",
40
+ f"Timeout: {self.timeout}s",
41
+ "",
42
+ "BLOCKING FACTORS:",
43
+ ]
44
+
45
+ if not self.blocking_factors:
46
+ lines.append(" (No specific blocking factors captured)")
47
+ else:
48
+ if self.blocking_factors.get('pending_requests', 0) > 0:
49
+ count = self.blocking_factors['pending_requests']
50
+ lines.append(f" ⚠ NETWORK: {count} request(s) still pending")
51
+
52
+ if self.blocking_factors.get('recent_mutations', 0) > 0:
53
+ count = self.blocking_factors['recent_mutations']
54
+ lines.append(f" ⚠ DOM: {count} mutation(s) in last interval")
55
+
56
+ if self.blocking_factors.get('active_animations', 0) > 0:
57
+ count = self.blocking_factors['active_animations']
58
+ lines.append(f" ⚠ ANIMATIONS: {count} active animation(s)")
59
+
60
+ if self.blocking_factors.get('layout_shifting'):
61
+ lines.append(" ⚠ LAYOUT: Elements still moving")
62
+
63
+ lines.extend([
64
+ "",
65
+ "SUGGESTIONS:",
66
+ " 1. Increase timeout if slow network is expected",
67
+ " 2. Set network_idle_threshold > 0 if background requests exist",
68
+ " 3. Use strictness='relaxed' to ignore animations",
69
+ " 4. Run 'waitless doctor' for detailed diagnostics",
70
+ f"{'='*60}",
71
+ ])
72
+
73
+ return "\n".join(lines)
74
+
75
+ def __str__(self) -> str:
76
+ base = super().__str__()
77
+ return f"{base}\n{self.get_diagnostic_summary()}"
78
+
79
+
80
+ class InstrumentationError(WaitlessError):
81
+ """
82
+ Raised when JavaScript instrumentation fails to inject or execute.
83
+
84
+ This usually indicates:
85
+ - Page navigation occurred and destroyed the instrumentation
86
+ - JavaScript is disabled
87
+ - CSP policy blocking script execution
88
+ """
89
+
90
+ def __init__(self, message: str, original_error: Optional[Exception] = None):
91
+ super().__init__(message)
92
+ self.original_error = original_error
93
+
94
+
95
+ class ConfigurationError(WaitlessError):
96
+ """
97
+ Raised when invalid configuration is provided.
98
+ """
99
+ pass
100
+
101
+
102
+ class NotStabilizedError(WaitlessError):
103
+ """
104
+ Raised when an interaction is attempted without stabilization.
105
+
106
+ This is only raised in strict mode when auto-stabilization is disabled.
107
+ """
108
+ pass