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/__init__.py +92 -0
- waitless/__main__.py +116 -0
- waitless/config.py +162 -0
- waitless/diagnostics.py +171 -0
- waitless/engine.py +275 -0
- waitless/exceptions.py +108 -0
- waitless/instrumentation.py +328 -0
- waitless/selenium_integration.py +296 -0
- waitless/signals.py +215 -0
- waitless-0.1.0.dist-info/METADATA +243 -0
- waitless-0.1.0.dist-info/RECORD +14 -0
- waitless-0.1.0.dist-info/WHEEL +5 -0
- waitless-0.1.0.dist-info/entry_points.txt +2 -0
- waitless-0.1.0.dist-info/top_level.txt +1 -0
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
|