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 ADDED
@@ -0,0 +1,92 @@
1
+ """
2
+ Waitless - Zero-wait UI automation stabilization library.
3
+
4
+ Eliminate explicit waits and sleeps in UI automation by automatically
5
+ waiting for true UI stability instead of time-based conditions.
6
+
7
+ Basic Usage:
8
+ from waitless import stabilize
9
+
10
+ driver = webdriver.Chrome()
11
+ driver = stabilize(driver) # That's it!
12
+
13
+ # All interactions now auto-wait for stability
14
+ driver.find_element(By.ID, "button").click()
15
+
16
+ Configuration:
17
+ from waitless import stabilize, StabilizationConfig
18
+
19
+ config = StabilizationConfig(
20
+ timeout=5, # Max wait time
21
+ strictness='strict', # All signals must be stable
22
+ debug_mode=True # Enable logging
23
+ )
24
+
25
+ driver = stabilize(driver, config=config)
26
+
27
+ Manual Stabilization:
28
+ from waitless import wait_for_stability
29
+
30
+ wait_for_stability(driver) # Explicit wait
31
+ driver.find_element(...).click()
32
+
33
+ Disable:
34
+ from waitless import unstabilize
35
+
36
+ driver = unstabilize(driver) # Back to original behavior
37
+ """
38
+
39
+ __version__ = '0.1.0'
40
+ __author__ = 'Dhiraj Das'
41
+
42
+ # Public API
43
+ from .config import StabilizationConfig, DEFAULT_CONFIG
44
+ from .selenium_integration import (
45
+ stabilize,
46
+ unstabilize,
47
+ wait_for_stability,
48
+ get_diagnostics,
49
+ StabilizedWebDriver,
50
+ StabilizedWebElement,
51
+ )
52
+ from .exceptions import (
53
+ WaitlessError,
54
+ StabilizationTimeout,
55
+ InstrumentationError,
56
+ ConfigurationError,
57
+ NotStabilizedError,
58
+ )
59
+ from .engine import StabilizationEngine
60
+ from .diagnostics import DiagnosticReport, generate_report, print_report
61
+
62
+ __all__ = [
63
+ # Version
64
+ '__version__',
65
+
66
+ # Main API
67
+ 'stabilize',
68
+ 'unstabilize',
69
+ 'wait_for_stability',
70
+ 'get_diagnostics',
71
+
72
+ # Configuration
73
+ 'StabilizationConfig',
74
+ 'DEFAULT_CONFIG',
75
+
76
+ # Types
77
+ 'StabilizedWebDriver',
78
+ 'StabilizedWebElement',
79
+ 'StabilizationEngine',
80
+
81
+ # Exceptions
82
+ 'WaitlessError',
83
+ 'StabilizationTimeout',
84
+ 'InstrumentationError',
85
+ 'ConfigurationError',
86
+ 'NotStabilizedError',
87
+
88
+ # Diagnostics
89
+ 'DiagnosticReport',
90
+ 'generate_report',
91
+ 'print_report',
92
+ ]
waitless/__main__.py ADDED
@@ -0,0 +1,116 @@
1
+ """
2
+ CLI entry point for waitless.
3
+
4
+ Provides the 'waitless doctor' command for diagnostics.
5
+ """
6
+
7
+ import argparse
8
+ import sys
9
+ import json
10
+ from datetime import datetime
11
+
12
+
13
+ def main():
14
+ """Main CLI entry point."""
15
+ parser = argparse.ArgumentParser(
16
+ prog='waitless',
17
+ description='Waitless - Zero-wait UI automation stabilization'
18
+ )
19
+
20
+ subparsers = parser.add_subparsers(dest='command', help='Available commands')
21
+
22
+ # Doctor command
23
+ doctor_parser = subparsers.add_parser(
24
+ 'doctor',
25
+ help='Analyze and diagnose stability issues'
26
+ )
27
+ doctor_parser.add_argument(
28
+ '--json',
29
+ action='store_true',
30
+ help='Output in JSON format for CI integration'
31
+ )
32
+ doctor_parser.add_argument(
33
+ '--file',
34
+ type=str,
35
+ help='Load diagnostics from a JSON file'
36
+ )
37
+
38
+ # Version command
39
+ subparsers.add_parser('version', help='Show version')
40
+
41
+ args = parser.parse_args()
42
+
43
+ if args.command == 'version':
44
+ from . import __version__
45
+ print(f"waitless version {__version__}")
46
+ return 0
47
+
48
+ elif args.command == 'doctor':
49
+ return run_doctor(args)
50
+
51
+ else:
52
+ parser.print_help()
53
+ return 0
54
+
55
+
56
+ def run_doctor(args):
57
+ """Run the doctor diagnostic command."""
58
+ from .diagnostics import DiagnosticReport
59
+
60
+ if args.file:
61
+ # Load from file
62
+ try:
63
+ with open(args.file, 'r') as f:
64
+ data = json.load(f)
65
+ diagnostics = data.get('diagnostics', data)
66
+ except FileNotFoundError:
67
+ print(f"Error: File not found: {args.file}", file=sys.stderr)
68
+ return 1
69
+ except json.JSONDecodeError as e:
70
+ print(f"Error: Invalid JSON: {e}", file=sys.stderr)
71
+ return 1
72
+ else:
73
+ # Show usage instructions
74
+ print("╔" + "═" * 66 + "╗")
75
+ print("║" + "WAITLESS DOCTOR".center(66) + "║")
76
+ print("╠" + "═" * 66 + "╣")
77
+ print("║".ljust(67) + "║")
78
+ print("║ The doctor command analyzes stability diagnostics.".ljust(67) + "║")
79
+ print("║".ljust(67) + "║")
80
+ print("║ USAGE OPTIONS:".ljust(67) + "║")
81
+ print("║".ljust(67) + "║")
82
+ print("║ 1. From a diagnostic file:".ljust(67) + "║")
83
+ print("║ waitless doctor --file diagnostics.json".ljust(67) + "║")
84
+ print("║".ljust(67) + "║")
85
+ print("║ 2. In your test code, capture diagnostics on failure:".ljust(67) + "║")
86
+ print("║".ljust(67) + "║")
87
+ print("║ from waitless import get_diagnostics".ljust(67) + "║")
88
+ print("║ from waitless.diagnostics import print_report".ljust(67) + "║")
89
+ print("║".ljust(67) + "║")
90
+ print("║ try:".ljust(67) + "║")
91
+ print("║ driver.find_element(...).click()".ljust(67) + "║")
92
+ print("║ except StabilizationTimeout as e:".ljust(67) + "║")
93
+ print("║ print_report(engine) # Print diagnostic report".ljust(67) + "║")
94
+ print("║".ljust(67) + "║")
95
+ print("║ 3. Export diagnostics for CI:".ljust(67) + "║")
96
+ print("║".ljust(67) + "║")
97
+ print("║ diagnostics = get_diagnostics(driver)".ljust(67) + "║")
98
+ print("║ with open('diag.json', 'w') as f:".ljust(67) + "║")
99
+ print("║ json.dump(diagnostics, f)".ljust(67) + "║")
100
+ print("║".ljust(67) + "║")
101
+ print("╚" + "═" * 66 + "╝")
102
+ return 0
103
+
104
+ # Generate report
105
+ report = DiagnosticReport(diagnostics)
106
+
107
+ if args.json:
108
+ print(report.to_json())
109
+ else:
110
+ print(report.generate_text_report())
111
+
112
+ return 0
113
+
114
+
115
+ if __name__ == '__main__':
116
+ sys.exit(main())
waitless/config.py ADDED
@@ -0,0 +1,162 @@
1
+ """
2
+ Waitless configuration management.
3
+
4
+ Provides sensible defaults with full customization options.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Literal, Optional
9
+ from .exceptions import ConfigurationError
10
+
11
+
12
+ StrictnessLevel = Literal['strict', 'normal', 'relaxed']
13
+
14
+
15
+ @dataclass
16
+ class StabilizationConfig:
17
+ """
18
+ Configuration for UI stabilization behavior.
19
+
20
+ Attributes:
21
+ timeout: Maximum time (seconds) to wait for stability. Default 10s.
22
+ Consider lowering to 5s for faster feedback loops.
23
+
24
+ dom_settle_time: Time (seconds) DOM must be quiet to be considered stable.
25
+ Default 0.1s (100ms).
26
+
27
+ network_idle_threshold: Maximum pending requests allowed for stability.
28
+ Default 0 (all requests must complete).
29
+
30
+ ⚠️ WARNING: Many apps have background traffic:
31
+ - Analytics calls
32
+ - Long polling
33
+ - Feature flags
34
+ - WebSocket heartbeats
35
+
36
+ If your tests timeout frequently, try setting this to 1-2.
37
+
38
+ animation_detection: Whether to wait for CSS animations/transitions.
39
+ Default True. Disable for apps with infinite animations.
40
+
41
+ layout_stability: Whether to wait for element positions to stabilize.
42
+ Default True in 'strict' mode.
43
+
44
+ strictness: Overall strictness level.
45
+ - 'strict': All signals must be stable (recommended)
46
+ - 'normal': DOM + Network only (faster)
47
+ - 'relaxed': DOM only (fastest, least reliable)
48
+
49
+ debug_mode: Enable verbose logging for troubleshooting.
50
+ Default False.
51
+
52
+ poll_interval: How often to check stability (seconds).
53
+ Default 0.05s (50ms). Lower = more responsive but more CPU.
54
+
55
+ reinject_on_navigation: Auto-reinject instrumentation after navigation.
56
+ Default True.
57
+ """
58
+
59
+ timeout: float = 10.0
60
+ dom_settle_time: float = 0.1
61
+ network_idle_threshold: int = 0
62
+ animation_detection: bool = True
63
+ layout_stability: bool = True
64
+ strictness: StrictnessLevel = 'normal'
65
+ debug_mode: bool = False
66
+ poll_interval: float = 0.05
67
+ reinject_on_navigation: bool = True
68
+
69
+ def __post_init__(self):
70
+ """Validate configuration values."""
71
+ self._validate()
72
+
73
+ def _validate(self) -> None:
74
+ """Validate all configuration values."""
75
+ if self.timeout <= 0:
76
+ raise ConfigurationError(f"timeout must be positive, got {self.timeout}")
77
+
78
+ if self.timeout > 60:
79
+ import warnings
80
+ warnings.warn(
81
+ f"timeout of {self.timeout}s is very high. "
82
+ "Consider investigating root cause of slow stability.",
83
+ UserWarning
84
+ )
85
+
86
+ if self.dom_settle_time < 0:
87
+ raise ConfigurationError(
88
+ f"dom_settle_time must be non-negative, got {self.dom_settle_time}"
89
+ )
90
+
91
+ if self.network_idle_threshold < 0:
92
+ raise ConfigurationError(
93
+ f"network_idle_threshold must be non-negative, got {self.network_idle_threshold}"
94
+ )
95
+
96
+ if self.poll_interval <= 0:
97
+ raise ConfigurationError(
98
+ f"poll_interval must be positive, got {self.poll_interval}"
99
+ )
100
+
101
+ if self.poll_interval > self.timeout:
102
+ raise ConfigurationError(
103
+ f"poll_interval ({self.poll_interval}) cannot exceed timeout ({self.timeout})"
104
+ )
105
+
106
+ if self.strictness not in ('strict', 'normal', 'relaxed'):
107
+ raise ConfigurationError(
108
+ f"strictness must be 'strict', 'normal', or 'relaxed', got '{self.strictness}'"
109
+ )
110
+
111
+ def with_overrides(self, **kwargs) -> 'StabilizationConfig':
112
+ """
113
+ Create a new config with some values overridden.
114
+
115
+ Example:
116
+ new_config = config.with_overrides(timeout=5, debug_mode=True)
117
+ """
118
+ current = {
119
+ 'timeout': self.timeout,
120
+ 'dom_settle_time': self.dom_settle_time,
121
+ 'network_idle_threshold': self.network_idle_threshold,
122
+ 'animation_detection': self.animation_detection,
123
+ 'layout_stability': self.layout_stability,
124
+ 'strictness': self.strictness,
125
+ 'debug_mode': self.debug_mode,
126
+ 'poll_interval': self.poll_interval,
127
+ 'reinject_on_navigation': self.reinject_on_navigation,
128
+ }
129
+ current.update(kwargs)
130
+ return StabilizationConfig(**current)
131
+
132
+ @classmethod
133
+ def strict(cls) -> 'StabilizationConfig':
134
+ """Factory for strict configuration (all signals, lower timeout)."""
135
+ return cls(
136
+ strictness='strict',
137
+ timeout=5.0,
138
+ animation_detection=True,
139
+ layout_stability=True,
140
+ )
141
+
142
+ @classmethod
143
+ def relaxed(cls) -> 'StabilizationConfig':
144
+ """Factory for relaxed configuration (DOM only, higher threshold)."""
145
+ return cls(
146
+ strictness='relaxed',
147
+ network_idle_threshold=2,
148
+ animation_detection=False,
149
+ layout_stability=False,
150
+ )
151
+
152
+ @classmethod
153
+ def ci(cls) -> 'StabilizationConfig':
154
+ """Factory for CI environments (longer timeout, verbose logging)."""
155
+ return cls(
156
+ timeout=15.0,
157
+ debug_mode=True,
158
+ strictness='normal',
159
+ )
160
+
161
+
162
+ DEFAULT_CONFIG = StabilizationConfig()
@@ -0,0 +1,171 @@
1
+ """
2
+ Diagnostics and doctor feature.
3
+
4
+ Provides detailed analysis of stability issues with actionable suggestions.
5
+ """
6
+
7
+ import json
8
+ from datetime import datetime
9
+ from typing import Dict, Any, List, Optional, TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from .engine import StabilizationEngine
13
+
14
+
15
+ class DiagnosticReport:
16
+ """
17
+ Generates human-readable diagnostic reports for stability issues.
18
+ """
19
+
20
+ def __init__(self, diagnostics: Dict[str, Any]):
21
+ self.diagnostics = diagnostics
22
+ self.timestamp = datetime.now()
23
+
24
+ def generate_text_report(self) -> str:
25
+ """Generate a text-based diagnostic report."""
26
+ lines = []
27
+ lines.append("╔" + "═" * 66 + "╗")
28
+ lines.append("║" + "WAITLESS STABILITY REPORT".center(66) + "║")
29
+ lines.append("╠" + "═" * 66 + "╣")
30
+ lines.append(f"║ Report generated at: {self.timestamp.strftime('%Y-%m-%d %H:%M:%S'):<43} ║")
31
+
32
+ config = self.diagnostics.get('config', {})
33
+ lines.append("╠" + "═" * 66 + "╣")
34
+ lines.append("║ CONFIGURATION:".ljust(67) + "║")
35
+ lines.append(f"║ Timeout: {config.get('timeout', 'N/A')}s".ljust(67) + "║")
36
+ lines.append(f"║ Strictness: {config.get('strictness', 'N/A')}".ljust(67) + "║")
37
+ lines.append(f"║ Network threshold: {config.get('network_idle_threshold', 'N/A')} pending requests".ljust(67) + "║")
38
+ lines.append(f"║ Animation detection: {config.get('animation_detection', 'N/A')}".ljust(67) + "║")
39
+
40
+ blocking = self.diagnostics.get('blocking_factors', {})
41
+ if blocking:
42
+ lines.append("╠" + "═" * 66 + "╣")
43
+ lines.append("║ BLOCKING FACTORS:".ljust(67) + "║")
44
+ lines.append("║".ljust(67) + "║")
45
+
46
+ pending = blocking.get('pending_requests', 0)
47
+ if pending > 0:
48
+ lines.append("║ ⚠ NETWORK: {} request(s) still pending".format(pending).ljust(67) + "║")
49
+
50
+ details = blocking.get('pending_request_details', [])
51
+ for req in details[:5]: # Show max 5
52
+ url = req.get('url', 'unknown')[:50]
53
+ started = req.get('startTime', 0)
54
+ lines.append(f"║ → {req.get('type', 'unknown').upper()} {url}".ljust(67) + "║")
55
+
56
+ if len(details) > 5:
57
+ lines.append(f"║ ... and {len(details) - 5} more".ljust(67) + "║")
58
+ lines.append("║".ljust(67) + "║")
59
+
60
+ animations = blocking.get('active_animations', 0)
61
+ if animations > 0:
62
+ lines.append(f"║ ⚠ ANIMATIONS: {animations} active animation(s)".ljust(67) + "║")
63
+ lines.append("║".ljust(67) + "║")
64
+
65
+ if blocking.get('layout_shifting'):
66
+ lines.append("║ ⚠ LAYOUT: Elements are still moving".ljust(67) + "║")
67
+ lines.append("║".ljust(67) + "║")
68
+
69
+ status = self.diagnostics.get('last_status')
70
+ if status:
71
+ lines.append("╠" + "═" * 66 + "╣")
72
+ lines.append("║ SIGNAL STATUS:".ljust(67) + "║")
73
+
74
+ for signal in status.get('signals', []):
75
+ state = "✓" if signal['state'] == 'STABLE' else "✗"
76
+ mandatory = "[M]" if signal['mandatory'] else "[O]"
77
+ line = f"║ {state} {mandatory} {signal['type']}: {signal.get('details', 'N/A')}"
78
+ lines.append(line[:66].ljust(67) + "║")
79
+
80
+ timeline = self.diagnostics.get('timeline', [])
81
+ if timeline:
82
+ lines.append("╠" + "═" * 66 + "╣")
83
+ lines.append("║ RECENT EVENTS (last 10):".ljust(67) + "║")
84
+
85
+ for entry in timeline[-10:]:
86
+ time_str = str(entry.get('time', ''))[-6:]
87
+ msg = entry.get('message', '')[:50]
88
+ lines.append(f"║ [{time_str}] {msg}".ljust(67) + "║")
89
+
90
+ lines.extend(self._generate_suggestions())
91
+ lines.append("╚" + "═" * 66 + "╝")
92
+
93
+ return "\n".join(lines)
94
+
95
+ def _generate_suggestions(self) -> List[str]:
96
+ """Generate actionable suggestions based on diagnostics."""
97
+ lines = []
98
+ suggestions = []
99
+
100
+ blocking = self.diagnostics.get('blocking_factors', {})
101
+ config = self.diagnostics.get('config', {})
102
+ pending = blocking.get('pending_requests', 0)
103
+ if pending > 0:
104
+ threshold = config.get('network_idle_threshold', 0)
105
+ if threshold == 0:
106
+ suggestions.append(
107
+ "Network requests are blocking stability. If your app has "
108
+ "background traffic (analytics, polling), consider:\n"
109
+ " config = StabilizationConfig(network_idle_threshold=2)"
110
+ )
111
+
112
+ details = blocking.get('pending_request_details', [])
113
+ slow_apis = [r for r in details if '/api/' in r.get('url', '')]
114
+ if slow_apis:
115
+ suggestions.append(
116
+ "Slow API endpoints detected. Consider:\n"
117
+ " - Mocking slow endpoints in tests\n"
118
+ " - Increasing timeout if APIs are legitimately slow"
119
+ )
120
+ animations = blocking.get('active_animations', 0)
121
+ if animations > 0:
122
+ suggestions.append(
123
+ "CSS animations are blocking stability. If you have infinite "
124
+ "animations (spinners), consider:\n"
125
+ " config = StabilizationConfig(animation_detection=False)\n"
126
+ " OR use strictness='relaxed'"
127
+ )
128
+ if blocking.get('layout_shifting'):
129
+ suggestions.append(
130
+ "Layout is unstable (elements moving). This often indicates:\n"
131
+ " - Images loading without dimensions\n"
132
+ " - Font loading causing reflow\n"
133
+ " - Dynamic content insertion"
134
+ )
135
+ timeout = config.get('timeout', 10)
136
+ if timeout >= 10:
137
+ suggestions.append(
138
+ f"Timeout is {timeout}s (default). For faster feedback, consider:\n"
139
+ " config = StabilizationConfig(timeout=5)"
140
+ )
141
+
142
+ if suggestions:
143
+ lines.append("╠" + "═" * 66 + "╣")
144
+ lines.append("║ SUGGESTIONS:".ljust(67) + "║")
145
+ lines.append("║".ljust(67) + "║")
146
+
147
+ for i, suggestion in enumerate(suggestions, 1):
148
+ for line in f"{i}. {suggestion}".split('\n'):
149
+ lines.append(f"║ {line}".ljust(67) + "║")
150
+ lines.append("║".ljust(67) + "║")
151
+
152
+ return lines
153
+
154
+ def to_json(self) -> str:
155
+ """Export diagnostics as JSON for CI integration."""
156
+ return json.dumps({
157
+ 'timestamp': self.timestamp.isoformat(),
158
+ 'diagnostics': self.diagnostics,
159
+ }, indent=2, default=str)
160
+
161
+
162
+ def generate_report(engine: 'StabilizationEngine') -> DiagnosticReport:
163
+ """Generate a diagnostic report from an engine."""
164
+ diagnostics = engine.get_diagnostics()
165
+ return DiagnosticReport(diagnostics)
166
+
167
+
168
+ def print_report(engine: 'StabilizationEngine') -> None:
169
+ """Print a diagnostic report to stdout."""
170
+ report = generate_report(engine)
171
+ print(report.generate_text_report())