omnimancer-cli 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.
Files changed (126) hide show
  1. omnimancer/__init__.py +42 -0
  2. omnimancer/__main__.py +9 -0
  3. omnimancer/cli/__init__.py +8 -0
  4. omnimancer/cli/agent_manager.py +826 -0
  5. omnimancer/cli/agent_wizard.py +944 -0
  6. omnimancer/cli/approval_formatter.py +734 -0
  7. omnimancer/cli/approval_integration.py +734 -0
  8. omnimancer/cli/approval_prompt.py +1286 -0
  9. omnimancer/cli/batch_approval_display.py +617 -0
  10. omnimancer/cli/batch_approval_filters.py +729 -0
  11. omnimancer/cli/batch_operation_monitor.py +775 -0
  12. omnimancer/cli/commands/performance_cmd.py +624 -0
  13. omnimancer/cli/commands.py +757 -0
  14. omnimancer/cli/completion_handler.py +281 -0
  15. omnimancer/cli/config_setup_wizard.py +783 -0
  16. omnimancer/cli/dynamic_commands.py +371 -0
  17. omnimancer/cli/handlers/__init__.py +18 -0
  18. omnimancer/cli/handlers/agent_handler.py +865 -0
  19. omnimancer/cli/handlers/agent_persona_handler.py +1048 -0
  20. omnimancer/cli/handlers/custom_agent_handler.py +352 -0
  21. omnimancer/cli/handlers/permissions_handler.py +1609 -0
  22. omnimancer/cli/interface.py +6695 -0
  23. omnimancer/cli/performance_dashboard.py +612 -0
  24. omnimancer/core/__init__.py +34 -0
  25. omnimancer/core/agent/__init__.py +256 -0
  26. omnimancer/core/agent/agent_config.py +715 -0
  27. omnimancer/core/agent/agent_switcher.py +668 -0
  28. omnimancer/core/agent/approval_context.py +460 -0
  29. omnimancer/core/agent/approval_dialog.py +902 -0
  30. omnimancer/core/agent/approval_interface.py +628 -0
  31. omnimancer/core/agent/approval_manager.py +760 -0
  32. omnimancer/core/agent/cli_integration.py +470 -0
  33. omnimancer/core/agent/config.py +845 -0
  34. omnimancer/core/agent/custom_agent_integration.py +654 -0
  35. omnimancer/core/agent/diff_renderer.py +706 -0
  36. omnimancer/core/agent/file_content_display.py +503 -0
  37. omnimancer/core/agent/file_modification_workflow.py +658 -0
  38. omnimancer/core/agent/file_system_manager.py +1790 -0
  39. omnimancer/core/agent/input_handler.py +614 -0
  40. omnimancer/core/agent/metrics_collector.py +771 -0
  41. omnimancer/core/agent/optimization_engine.py +729 -0
  42. omnimancer/core/agent/performance_monitor.py +717 -0
  43. omnimancer/core/agent/persona.py +870 -0
  44. omnimancer/core/agent/persona_validator.py +672 -0
  45. omnimancer/core/agent/program_executor.py +748 -0
  46. omnimancer/core/agent/proposed_changes_integration.py +663 -0
  47. omnimancer/core/agent/read_before_write_errors.py +492 -0
  48. omnimancer/core/agent/read_before_write_ui.py +647 -0
  49. omnimancer/core/agent/rich_renderer.py +763 -0
  50. omnimancer/core/agent/status_config.py +238 -0
  51. omnimancer/core/agent/status_core.py +446 -0
  52. omnimancer/core/agent/status_integrated_engine.py +540 -0
  53. omnimancer/core/agent/status_manager.py +878 -0
  54. omnimancer/core/agent/status_ui_components.py +535 -0
  55. omnimancer/core/agent/template_applicator.py +548 -0
  56. omnimancer/core/agent/terminal_status_listener.py +394 -0
  57. omnimancer/core/agent/token_tracker.py +679 -0
  58. omnimancer/core/agent/types.py +60 -0
  59. omnimancer/core/agent/web_client.py +745 -0
  60. omnimancer/core/agent/workflow_orchestrator.py +826 -0
  61. omnimancer/core/agent_engine.py +1625 -0
  62. omnimancer/core/agent_mode_manager.py +706 -0
  63. omnimancer/core/agent_progress_ui.py +545 -0
  64. omnimancer/core/chat_manager.py +141 -0
  65. omnimancer/core/config_manager.py +2199 -0
  66. omnimancer/core/config_migration.py +772 -0
  67. omnimancer/core/config_provider.py +1974 -0
  68. omnimancer/core/config_security_validator.py +393 -0
  69. omnimancer/core/config_validator.py +768 -0
  70. omnimancer/core/conversation_manager.py +352 -0
  71. omnimancer/core/engine.py +787 -0
  72. omnimancer/core/enhanced_input.py +523 -0
  73. omnimancer/core/env_loader.py +83 -0
  74. omnimancer/core/fallback_manager.py +568 -0
  75. omnimancer/core/health_monitor.py +462 -0
  76. omnimancer/core/history_manager.py +462 -0
  77. omnimancer/core/mcp_integration_layer.py +878 -0
  78. omnimancer/core/models.py +1889 -0
  79. omnimancer/core/provider_capabilities.py +403 -0
  80. omnimancer/core/provider_initializer.py +450 -0
  81. omnimancer/core/provider_registry.py +432 -0
  82. omnimancer/core/security/__init__.py +16 -0
  83. omnimancer/core/security/approval_workflow.py +406 -0
  84. omnimancer/core/security/audit_logger.py +511 -0
  85. omnimancer/core/security/permission_controller.py +511 -0
  86. omnimancer/core/security/sandbox_manager.py +379 -0
  87. omnimancer/core/security/security_manager.py +530 -0
  88. omnimancer/core/setup_wizard.py +345 -0
  89. omnimancer/core/setup_wizard_core.py +449 -0
  90. omnimancer/core/setup_wizard_guide_generator.py +376 -0
  91. omnimancer/core/setup_wizard_provider_setup.py +454 -0
  92. omnimancer/core/setup_wizard_ui.py +317 -0
  93. omnimancer/core/setup_wizard_validation.py +371 -0
  94. omnimancer/core/signal_handler.py +174 -0
  95. omnimancer/mcp/__init__.py +11 -0
  96. omnimancer/mcp/client.py +725 -0
  97. omnimancer/mcp/manager.py +649 -0
  98. omnimancer/providers/__init__.py +24 -0
  99. omnimancer/providers/azure.py +608 -0
  100. omnimancer/providers/base.py +262 -0
  101. omnimancer/providers/bedrock.py +711 -0
  102. omnimancer/providers/claude.py +426 -0
  103. omnimancer/providers/claude_code.py +406 -0
  104. omnimancer/providers/cohere.py +412 -0
  105. omnimancer/providers/factory.py +988 -0
  106. omnimancer/providers/gemini.py +708 -0
  107. omnimancer/providers/mistral.py +554 -0
  108. omnimancer/providers/ollama.py +510 -0
  109. omnimancer/providers/openai.py +362 -0
  110. omnimancer/providers/openrouter.py +709 -0
  111. omnimancer/providers/perplexity.py +476 -0
  112. omnimancer/providers/vertex.py +617 -0
  113. omnimancer/providers/xai.py +484 -0
  114. omnimancer/ui/__init__.py +6 -0
  115. omnimancer/ui/cancellation_handler.py +235 -0
  116. omnimancer/ui/progress_indicator.py +318 -0
  117. omnimancer/utils/__init__.py +22 -0
  118. omnimancer/utils/error_handler.py +589 -0
  119. omnimancer/utils/errors.py +667 -0
  120. omnimancer/utils/retry.py +238 -0
  121. omnimancer_cli-0.1.0.dist-info/METADATA +413 -0
  122. omnimancer_cli-0.1.0.dist-info/RECORD +126 -0
  123. omnimancer_cli-0.1.0.dist-info/WHEEL +5 -0
  124. omnimancer_cli-0.1.0.dist-info/entry_points.txt +4 -0
  125. omnimancer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  126. omnimancer_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,745 @@
1
+ """Web client for HTTP requests, API interactions, and web scraping with security features."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ import pickle
7
+ import re
8
+ import time
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime, timedelta
11
+ from enum import Enum
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional, Set, Union
14
+ from urllib.parse import urljoin, urlparse
15
+
16
+ import aiohttp
17
+ import html2text
18
+ from bs4 import BeautifulSoup
19
+ from readability import Document
20
+
21
+ from ..security import SecurityManager
22
+ from ..security.permission_controller import PermissionOperation
23
+
24
+
25
+ class RequestMethod(Enum):
26
+ """HTTP request methods."""
27
+
28
+ GET = "GET"
29
+ POST = "POST"
30
+ PUT = "PUT"
31
+ DELETE = "DELETE"
32
+ HEAD = "HEAD"
33
+ OPTIONS = "OPTIONS"
34
+ PATCH = "PATCH"
35
+
36
+
37
+ @dataclass
38
+ class WebResponse:
39
+ """Response from web request."""
40
+
41
+ url: str
42
+ status: int
43
+ headers: Dict[str, str]
44
+ content: Union[str, bytes]
45
+ content_type: str
46
+ encoding: str
47
+ cookies: Dict[str, str] = field(default_factory=dict)
48
+ history: List[str] = field(default_factory=list)
49
+ elapsed: float = 0.0
50
+ from_cache: bool = False
51
+
52
+ @property
53
+ def is_success(self) -> bool:
54
+ """Check if response was successful."""
55
+ return 200 <= self.status < 300
56
+
57
+ @property
58
+ def is_text(self) -> bool:
59
+ """Check if response is text content."""
60
+ return isinstance(self.content, str)
61
+
62
+ @property
63
+ def text(self) -> str:
64
+ """Get text content."""
65
+ if isinstance(self.content, str):
66
+ return self.content
67
+
68
+ # Handle binary encoding specially
69
+ if self.encoding == "binary":
70
+ return self.content.decode("utf-8", errors="ignore")
71
+
72
+ return self.content.decode(self.encoding or "utf-8", errors="ignore")
73
+
74
+ @property
75
+ def json(self) -> Any:
76
+ """Parse JSON content."""
77
+ return json.loads(self.text)
78
+
79
+
80
+ @dataclass
81
+ class CacheEntry:
82
+ """Cache entry for responses."""
83
+
84
+ response: WebResponse
85
+ timestamp: datetime
86
+ expires: datetime
87
+ etag: Optional[str] = None
88
+ last_modified: Optional[str] = None
89
+
90
+
91
+ class RateLimiter:
92
+ """Rate limiter for HTTP requests."""
93
+
94
+ def __init__(
95
+ self,
96
+ requests_per_second: float = 10.0,
97
+ requests_per_minute: float = 100.0,
98
+ requests_per_hour: float = 1000.0,
99
+ ):
100
+ self.requests_per_second = requests_per_second
101
+ self.requests_per_minute = requests_per_minute
102
+ self.requests_per_hour = requests_per_hour
103
+
104
+ self.request_times: Dict[str, List[float]] = {}
105
+ self.lock = asyncio.Lock()
106
+
107
+ async def wait_if_needed(self, domain: str) -> None:
108
+ """Wait if rate limit would be exceeded."""
109
+ async with self.lock:
110
+ now = time.time()
111
+
112
+ if domain not in self.request_times:
113
+ self.request_times[domain] = []
114
+
115
+ times = self.request_times[domain]
116
+
117
+ # Clean old entries
118
+ times[:] = [t for t in times if now - t < 3600] # Keep last hour
119
+
120
+ # Check limits
121
+ recent_second = [t for t in times if now - t < 1.0]
122
+ recent_minute = [t for t in times if now - t < 60.0]
123
+ recent_hour = [t for t in times if now - t < 3600.0]
124
+
125
+ wait_time = 0.0
126
+
127
+ # Check per-second limit
128
+ if len(recent_second) >= self.requests_per_second:
129
+ wait_time = max(wait_time, 1.0 - (now - recent_second[0]))
130
+
131
+ # Check per-minute limit
132
+ if len(recent_minute) >= self.requests_per_minute:
133
+ wait_time = max(wait_time, 60.0 - (now - recent_minute[0]))
134
+
135
+ # Check per-hour limit
136
+ if len(recent_hour) >= self.requests_per_hour:
137
+ wait_time = max(wait_time, 3600.0 - (now - recent_hour[0]))
138
+
139
+ if wait_time > 0:
140
+ await asyncio.sleep(wait_time)
141
+
142
+ # Record this request
143
+ times.append(time.time())
144
+
145
+
146
+ class ResponseCache:
147
+ """Cache for HTTP responses."""
148
+
149
+ def __init__(
150
+ self,
151
+ cache_dir: Optional[Path] = None,
152
+ max_size_mb: int = 100,
153
+ default_ttl: int = 3600,
154
+ ):
155
+
156
+ self.cache_dir = cache_dir or Path.home() / ".omnimancer" / "web_cache"
157
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
158
+
159
+ self.max_size_mb = max_size_mb
160
+ self.default_ttl = default_ttl
161
+ self.memory_cache: Dict[str, CacheEntry] = {}
162
+
163
+ def _get_cache_key(self, url: str, method: str, headers: Dict[str, str]) -> str:
164
+ """Generate cache key for request."""
165
+ key_data = f"{method}:{url}:{json.dumps(sorted(headers.items()))}"
166
+ return hashlib.md5(key_data.encode()).hexdigest()
167
+
168
+ def _get_cache_path(self, cache_key: str) -> Path:
169
+ """Get file path for cache entry."""
170
+ return self.cache_dir / f"{cache_key}.cache"
171
+
172
+ async def get(
173
+ self,
174
+ url: str,
175
+ method: str = "GET",
176
+ headers: Optional[Dict[str, str]] = None,
177
+ ) -> Optional[WebResponse]:
178
+ """Get cached response if available and valid."""
179
+
180
+ headers = headers or {}
181
+ cache_key = self._get_cache_key(url, method, headers)
182
+
183
+ # Check memory cache first
184
+ if cache_key in self.memory_cache:
185
+ entry = self.memory_cache[cache_key]
186
+ if datetime.now() < entry.expires:
187
+ entry.response.from_cache = True
188
+ return entry.response
189
+ else:
190
+ del self.memory_cache[cache_key]
191
+
192
+ # Check disk cache
193
+ cache_path = self._get_cache_path(cache_key)
194
+ if cache_path.exists():
195
+ try:
196
+ with open(cache_path, "rb") as f:
197
+ entry = pickle.load(f)
198
+
199
+ if datetime.now() < entry.expires:
200
+ # Load into memory cache
201
+ self.memory_cache[cache_key] = entry
202
+ entry.response.from_cache = True
203
+ return entry.response
204
+ else:
205
+ # Remove expired cache
206
+ cache_path.unlink()
207
+
208
+ except (pickle.PickleError, EOFError, FileNotFoundError):
209
+ # Remove corrupted cache
210
+ if cache_path.exists():
211
+ cache_path.unlink()
212
+
213
+ return None
214
+
215
+ async def set(
216
+ self,
217
+ url: str,
218
+ method: str,
219
+ headers: Dict[str, str],
220
+ response: WebResponse,
221
+ ttl: Optional[int] = None,
222
+ ) -> None:
223
+ """Cache response."""
224
+
225
+ ttl = ttl if ttl is not None else self.default_ttl
226
+ cache_key = self._get_cache_key(url, method, headers)
227
+
228
+ expires = datetime.now() + timedelta(seconds=ttl)
229
+ entry = CacheEntry(
230
+ response=response,
231
+ timestamp=datetime.now(),
232
+ expires=expires,
233
+ etag=response.headers.get("ETag"),
234
+ last_modified=response.headers.get("Last-Modified"),
235
+ )
236
+
237
+ # Store in memory
238
+ self.memory_cache[cache_key] = entry
239
+
240
+ # Store on disk
241
+ cache_path = self._get_cache_path(cache_key)
242
+ try:
243
+ with open(cache_path, "wb") as f:
244
+ pickle.dump(entry, f)
245
+ except (pickle.PickleError, OSError):
246
+ pass # Continue without disk cache
247
+
248
+ self._cleanup_cache()
249
+
250
+ def _cleanup_cache(self) -> None:
251
+ """Clean up cache to stay within size limits."""
252
+ # Remove expired entries from memory
253
+ now = datetime.now()
254
+ expired_keys = [k for k, v in self.memory_cache.items() if now >= v.expires]
255
+ for key in expired_keys:
256
+ del self.memory_cache[key]
257
+
258
+ # Check disk cache size
259
+ try:
260
+ total_size = sum(f.stat().st_size for f in self.cache_dir.glob("*.cache"))
261
+ max_size_bytes = self.max_size_mb * 1024 * 1024
262
+
263
+ if total_size > max_size_bytes:
264
+ # Remove oldest files
265
+ cache_files = sorted(
266
+ self.cache_dir.glob("*.cache"),
267
+ key=lambda f: f.stat().st_mtime,
268
+ )
269
+
270
+ for cache_file in cache_files:
271
+ cache_file.unlink()
272
+ total_size -= cache_file.stat().st_size
273
+ if total_size <= max_size_bytes * 0.8: # Keep 20% buffer
274
+ break
275
+ except OSError:
276
+ pass
277
+
278
+
279
+ class WebClient:
280
+ """Advanced web client with rate limiting, caching, and content extraction."""
281
+
282
+ def __init__(
283
+ self,
284
+ security_manager: Optional[SecurityManager] = None,
285
+ user_agent: str = "Omnimancer-Agent/1.0",
286
+ timeout: int = 30,
287
+ max_redirects: int = 10,
288
+ enable_cache: bool = True,
289
+ enable_rate_limiting: bool = True,
290
+ ):
291
+
292
+ self.security = security_manager or SecurityManager()
293
+ self.user_agent = user_agent
294
+ self.timeout = timeout
295
+ self.max_redirects = max_redirects
296
+
297
+ # Initialize components
298
+ self.rate_limiter = RateLimiter() if enable_rate_limiting else None
299
+ self.cache = ResponseCache() if enable_cache else None
300
+
301
+ # Domain blacklist (security)
302
+ self.blacklisted_domains = self._get_default_blacklist()
303
+ self.whitelisted_domains: Set[str] = set()
304
+
305
+ # Content extraction
306
+ self.html_converter = html2text.HTML2Text()
307
+ self.html_converter.ignore_links = False
308
+ self.html_converter.ignore_images = True
309
+
310
+ # Session will be created when needed
311
+ self._session: Optional[aiohttp.ClientSession] = None
312
+
313
+ # Request statistics
314
+ self.stats = {
315
+ "requests_made": 0,
316
+ "cache_hits": 0,
317
+ "cache_misses": 0,
318
+ "rate_limited": 0,
319
+ "blocked_requests": 0,
320
+ "failed_requests": 0,
321
+ }
322
+
323
+ def _get_default_blacklist(self) -> Set[str]:
324
+ """Get default domain blacklist."""
325
+ return {
326
+ # Localhost and private networks
327
+ "localhost",
328
+ "127.0.0.1",
329
+ "0.0.0.0",
330
+ "::1",
331
+ # Private IP ranges (partial)
332
+ "192.168.",
333
+ "10.",
334
+ "172.16.",
335
+ "172.17.",
336
+ "172.18.",
337
+ "172.19.",
338
+ "172.20.",
339
+ "172.21.",
340
+ "172.22.",
341
+ "172.23.",
342
+ "172.24.",
343
+ "172.25.",
344
+ "172.26.",
345
+ "172.27.",
346
+ "172.28.",
347
+ "172.29.",
348
+ "172.30.",
349
+ "172.31.",
350
+ # Potentially harmful
351
+ "malware-test.com",
352
+ "eicar.org",
353
+ }
354
+
355
+ async def _get_session(self) -> aiohttp.ClientSession:
356
+ """Get or create aiohttp session."""
357
+ if self._session is None or self._session.closed:
358
+ timeout = aiohttp.ClientTimeout(total=self.timeout)
359
+ headers = {"User-Agent": self.user_agent}
360
+
361
+ self._session = aiohttp.ClientSession(
362
+ timeout=timeout,
363
+ headers=headers,
364
+ connector=aiohttp.TCPConnector(
365
+ limit=100, limit_per_host=20, enable_cleanup_closed=True
366
+ ),
367
+ )
368
+
369
+ return self._session
370
+
371
+ def _is_url_allowed(self, url: str) -> bool:
372
+ """Check if URL is allowed by security policy."""
373
+ try:
374
+ parsed = urlparse(url)
375
+ domain = parsed.hostname
376
+
377
+ if not domain:
378
+ return False
379
+
380
+ # Check whitelist first
381
+ if self.whitelisted_domains and domain in self.whitelisted_domains:
382
+ return True
383
+
384
+ # Check blacklist
385
+ if domain in self.blacklisted_domains:
386
+ return False
387
+
388
+ # Check for private IP patterns
389
+ for blocked_pattern in self.blacklisted_domains:
390
+ if blocked_pattern.endswith(".") and domain.startswith(blocked_pattern):
391
+ return False
392
+
393
+ # Additional security checks
394
+ if parsed.scheme not in ["http", "https"]:
395
+ return False
396
+
397
+ return True
398
+
399
+ except Exception:
400
+ return False
401
+
402
+ async def request(
403
+ self,
404
+ url: str,
405
+ method: RequestMethod = RequestMethod.GET,
406
+ headers: Optional[Dict[str, str]] = None,
407
+ params: Optional[Dict[str, Any]] = None,
408
+ data: Optional[Union[str, bytes, Dict[str, Any]]] = None,
409
+ json_data: Optional[Dict[str, Any]] = None,
410
+ cookies: Optional[Dict[str, str]] = None,
411
+ allow_redirects: bool = True,
412
+ timeout: Optional[int] = None,
413
+ cache_ttl: Optional[int] = None,
414
+ ) -> WebResponse:
415
+ """Make HTTP request with security, rate limiting, and caching."""
416
+
417
+ url = url.strip()
418
+ headers = headers or {}
419
+ method_str = (
420
+ method.value if isinstance(method, RequestMethod) else str(method).upper()
421
+ )
422
+
423
+ # Security validation
424
+ if not self._is_url_allowed(url):
425
+ self.stats["blocked_requests"] += 1
426
+ raise ValueError(f"URL blocked by security policy: {url}")
427
+
428
+ # Validate with security manager
429
+ operation = PermissionOperation(
430
+ operation_type="network_request",
431
+ url=url,
432
+ method=method_str,
433
+ headers=headers,
434
+ )
435
+
436
+ validation = await self.security.validate_operation(operation)
437
+ if not validation["allowed"]:
438
+ self.stats["blocked_requests"] += 1
439
+ raise ValueError(f"Request blocked: {', '.join(validation['reasons'])}")
440
+
441
+ # Check cache for GET requests
442
+ cached_response = None
443
+ if method == RequestMethod.GET and self.cache:
444
+ cached_response = await self.cache.get(url, method_str, headers)
445
+ if cached_response:
446
+ self.stats["cache_hits"] += 1
447
+ return cached_response
448
+ self.stats["cache_misses"] += 1
449
+
450
+ # Rate limiting
451
+ if self.rate_limiter:
452
+ domain = urlparse(url).hostname
453
+ if domain:
454
+ start_time = time.time()
455
+ await self.rate_limiter.wait_if_needed(domain)
456
+ if time.time() - start_time > 0.1: # If we waited more than 100ms
457
+ self.stats["rate_limited"] += 1
458
+
459
+ # Make request with retries
460
+ response = await self._make_request_with_retry(
461
+ url,
462
+ method_str,
463
+ headers,
464
+ params,
465
+ data,
466
+ json_data,
467
+ cookies,
468
+ allow_redirects,
469
+ timeout or self.timeout,
470
+ )
471
+
472
+ # Cache successful GET responses
473
+ if (
474
+ response.is_success
475
+ and method == RequestMethod.GET
476
+ and self.cache
477
+ and response.status != 204
478
+ ):
479
+ await self.cache.set(url, method_str, headers, response, cache_ttl)
480
+
481
+ self.stats["requests_made"] += 1
482
+ return response
483
+
484
+ async def _make_request_with_retry(
485
+ self,
486
+ url: str,
487
+ method: str,
488
+ headers: Dict[str, str],
489
+ params: Optional[Dict[str, Any]],
490
+ data: Optional[Union[str, bytes, Dict[str, Any]]],
491
+ json_data: Optional[Dict[str, Any]],
492
+ cookies: Optional[Dict[str, str]],
493
+ allow_redirects: bool,
494
+ timeout: int,
495
+ max_retries: int = 3,
496
+ ) -> WebResponse:
497
+ """Make request with exponential backoff retry."""
498
+
499
+ session = await self._get_session()
500
+ last_exception = None
501
+
502
+ for attempt in range(max_retries + 1):
503
+ try:
504
+ start_time = time.time()
505
+
506
+ # Prepare request kwargs
507
+ kwargs = {}
508
+ if params:
509
+ kwargs["params"] = params
510
+ if data:
511
+ kwargs["data"] = data
512
+ if json_data:
513
+ kwargs["json"] = json_data
514
+ if cookies:
515
+ kwargs["cookies"] = cookies
516
+ if not allow_redirects:
517
+ kwargs["allow_redirects"] = False
518
+
519
+ async with session.request(
520
+ method, url, headers=headers, **kwargs
521
+ ) as resp:
522
+ elapsed = time.time() - start_time
523
+
524
+ # Read content
525
+ content_bytes = await resp.read()
526
+
527
+ # Determine if content is text
528
+ content_type = resp.headers.get("content-type", "").lower()
529
+ is_text = any(
530
+ t in content_type
531
+ for t in [
532
+ "text/",
533
+ "application/json",
534
+ "application/xml",
535
+ ]
536
+ )
537
+
538
+ # Convert to text if appropriate
539
+ if is_text:
540
+ encoding = resp.charset or "utf-8"
541
+ content = content_bytes.decode(encoding, errors="ignore")
542
+ else:
543
+ content = content_bytes
544
+ encoding = "binary"
545
+
546
+ # Build response
547
+ response = WebResponse(
548
+ url=str(resp.url),
549
+ status=resp.status,
550
+ headers=dict(resp.headers),
551
+ content=content,
552
+ content_type=content_type,
553
+ encoding=encoding,
554
+ cookies=dict(resp.cookies),
555
+ history=[str(h.url) for h in resp.history],
556
+ elapsed=elapsed,
557
+ )
558
+
559
+ return response
560
+
561
+ except (aiohttp.ClientError, asyncio.TimeoutError) as e:
562
+ last_exception = e
563
+
564
+ # Don't retry on client errors (4xx) except specific cases
565
+ if hasattr(e, "status") and 400 <= e.status < 500:
566
+ if e.status not in [429, 408]: # Rate limit, timeout
567
+ break
568
+
569
+ if attempt < max_retries:
570
+ # Exponential backoff
571
+ wait_time = (2**attempt) + (0.1 * attempt)
572
+ await asyncio.sleep(wait_time)
573
+
574
+ # All retries failed
575
+ self.stats["failed_requests"] += 1
576
+ raise Exception(
577
+ f"Request failed after {max_retries + 1} attempts: {last_exception}"
578
+ )
579
+
580
+ async def get(self, url: str, **kwargs) -> WebResponse:
581
+ """Make GET request."""
582
+ return await self.request(url, RequestMethod.GET, **kwargs)
583
+
584
+ async def post(self, url: str, **kwargs) -> WebResponse:
585
+ """Make POST request."""
586
+ return await self.request(url, RequestMethod.POST, **kwargs)
587
+
588
+ async def put(self, url: str, **kwargs) -> WebResponse:
589
+ """Make PUT request."""
590
+ return await self.request(url, RequestMethod.PUT, **kwargs)
591
+
592
+ async def delete(self, url: str, **kwargs) -> WebResponse:
593
+ """Make DELETE request."""
594
+ return await self.request(url, RequestMethod.DELETE, **kwargs)
595
+
596
+ async def head(self, url: str, **kwargs) -> WebResponse:
597
+ """Make HEAD request."""
598
+ return await self.request(url, RequestMethod.HEAD, **kwargs)
599
+
600
+ async def scrape_content(
601
+ self,
602
+ url: str,
603
+ extract_main_content: bool = True,
604
+ convert_to_markdown: bool = True,
605
+ remove_scripts: bool = True,
606
+ ) -> Dict[str, Any]:
607
+ """Scrape and extract content from web page."""
608
+
609
+ response = await self.get(url)
610
+
611
+ if not response.is_success:
612
+ raise ValueError(f"Failed to fetch content: HTTP {response.status}")
613
+
614
+ if not response.is_text:
615
+ raise ValueError("Response is not text content")
616
+
617
+ html_content = response.text
618
+ soup = BeautifulSoup(html_content, "html.parser")
619
+
620
+ # Remove unwanted elements
621
+ if remove_scripts:
622
+ for element in soup(
623
+ ["script", "style", "nav", "header", "footer", "aside"]
624
+ ):
625
+ element.decompose()
626
+
627
+ # Extract metadata
628
+ title = soup.find("title")
629
+ title_text = title.get_text().strip() if title else ""
630
+
631
+ meta_description = soup.find("meta", attrs={"name": "description"})
632
+ description = (
633
+ meta_description.get("content", "").strip() if meta_description else ""
634
+ )
635
+
636
+ # Extract main content
637
+ if extract_main_content:
638
+ # Try readability first (use cleaned HTML)
639
+ try:
640
+ cleaned_html = str(soup)
641
+ doc = Document(cleaned_html)
642
+ main_content = doc.summary()
643
+ main_soup = BeautifulSoup(main_content, "html.parser")
644
+ except:
645
+ # Fallback to common content selectors
646
+ main_soup = (
647
+ soup.find("main")
648
+ or soup.find("article")
649
+ or soup.find("div", class_=re.compile(r"content|main|article"))
650
+ )
651
+ if not main_soup:
652
+ main_soup = soup
653
+ else:
654
+ main_soup = soup
655
+
656
+ # Convert to text
657
+ if convert_to_markdown:
658
+ # Convert to markdown
659
+ main_text = self.html_converter.handle(str(main_soup))
660
+ else:
661
+ # Plain text
662
+ main_text = main_soup.get_text(separator="\n", strip=True)
663
+
664
+ # Extract links
665
+ links = []
666
+ for link in soup.find_all("a", href=True):
667
+ href = link["href"]
668
+ text = link.get_text().strip()
669
+ if href and text:
670
+ absolute_url = urljoin(url, href)
671
+ links.append({"url": absolute_url, "text": text})
672
+
673
+ # Extract images
674
+ images = []
675
+ for img in soup.find_all("img", src=True):
676
+ src = img["src"]
677
+ alt = img.get("alt", "").strip()
678
+ absolute_url = urljoin(url, src)
679
+ images.append({"url": absolute_url, "alt": alt})
680
+
681
+ return {
682
+ "url": response.url,
683
+ "title": title_text,
684
+ "description": description,
685
+ "content": main_text,
686
+ "links": links[:50], # Limit links
687
+ "images": images[:20], # Limit images
688
+ "status": response.status,
689
+ "content_type": response.content_type,
690
+ "word_count": len(main_text.split()),
691
+ "char_count": len(main_text),
692
+ }
693
+
694
+ def add_to_blacklist(self, domain: str) -> None:
695
+ """Add domain to blacklist."""
696
+ self.blacklisted_domains.add(domain)
697
+
698
+ def add_to_whitelist(self, domain: str) -> None:
699
+ """Add domain to whitelist."""
700
+ self.whitelisted_domains.add(domain)
701
+
702
+ def remove_from_blacklist(self, domain: str) -> None:
703
+ """Remove domain from blacklist."""
704
+ self.blacklisted_domains.discard(domain)
705
+
706
+ def remove_from_whitelist(self, domain: str) -> None:
707
+ """Remove domain from whitelist."""
708
+ self.whitelisted_domains.discard(domain)
709
+
710
+ def get_stats(self) -> Dict[str, Any]:
711
+ """Get client statistics."""
712
+ return {
713
+ **self.stats,
714
+ "cache_size": len(self.cache.memory_cache) if self.cache else 0,
715
+ "blacklisted_domains": len(self.blacklisted_domains),
716
+ "whitelisted_domains": len(self.whitelisted_domains),
717
+ }
718
+
719
+ def clear_cache(self) -> None:
720
+ """Clear response cache."""
721
+ if self.cache:
722
+ self.cache.memory_cache.clear()
723
+ # Clear disk cache
724
+ for cache_file in self.cache.cache_dir.glob("*.cache"):
725
+ try:
726
+ cache_file.unlink()
727
+ except OSError:
728
+ pass
729
+
730
+ async def close(self) -> None:
731
+ """Close the web client and cleanup resources."""
732
+ if self._session and not self._session.closed:
733
+ await self._session.close()
734
+
735
+ # Clear cache on close
736
+ if self.cache:
737
+ self.cache.memory_cache.clear()
738
+
739
+ async def __aenter__(self):
740
+ """Async context manager entry."""
741
+ return self
742
+
743
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
744
+ """Async context manager exit."""
745
+ await self.close()