# streaming_providers/providers/scripts/provider.py # -*- coding: utf-8 -*- """ Scripts Provider Implementation Executes Python scripts that implement streaming provider logic. Each script must support actions: channels, manifest, cdm Environment Variable: SCRIPTS_PROVIDERS_PATH: Directory containing provider scripts (auto-discovers *.py files) """ import json import os import re import subprocess import sys import time import datetime from typing import ClassVar, Dict, List, Optional, Any from ...base.models import DRMConfig, DRMSystem, LicenseConfig, StreamingChannel, Event from ...base.models.proxy_models import ProxyConfig from ...base.provider import StreamingProvider from ...base.utils.logger import logger from ...base.utils.vfs import get_vfs from .constants import ( ACTION_CHANNELS, ACTION_MANIFEST, ACTION_CDM, AUTO_REFRESH_ON_EXPIRE, CHANNELS_CACHE_TTL, CDM_CACHE_TTL, CHANNEL_FIELD_MAPPING, DEFAULT_CHANNEL_CONFIG, DEFAULT_COUNTRY, DEFAULT_ENCODING, DEFAULT_DRM_SYSTEM, DEFAULT_LANGUAGE, DEFAULT_SCRIPT_TIMEOUT, ENV_SCRIPTS_PROVIDERS_PATH, ERROR_SCRIPT_NOT_FOUND, ERROR_SCRIPT_TIMEOUT, ERROR_INVALID_JSON, ERROR_CHANNELS_MISSING_FIELD, ERROR_MANIFEST_MISSING_FIELD, ERROR_CDM_INVALID_FORMAT, EXCLUDED_FILENAMES, MAX_OUTPUT_SIZE, PROXY_ARG_FORMAT, REQUIRED_CHANNEL_FIELDS, REQUIRED_MANIFEST_FIELDS, SCRIPTS_LOGO, SKIP_INVALID_CHANNELS, SUPPORTED_EXTENSIONS, # Import the new exception classes ScriptNotFoundError, ScriptTimeoutError, ScriptExecutionError, InvalidScriptOutputError, ) class ScriptsProvider(StreamingProvider): """ Scripts Provider Executes Python scripts that provide streaming functionality. Each script in the configured directory becomes a provider instance. """ # ============================================================================ # STATIC METADATA # ============================================================================ PROVIDER_LABEL: ClassVar[str] = "Scripts Provider" SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ["anonymous"] # Auth handled internally PROVIDER_LOGO: ClassVar[str] = SCRIPTS_LOGO SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["*"] # All countries def __init__( self, country: str = DEFAULT_COUNTRY, config_dir: Optional[str] = None, script_filename: Optional[str] = None, proxy_config: Optional[ProxyConfig] = None, proxy_url: Optional[str] = None, script_timeout: int = DEFAULT_SCRIPT_TIMEOUT, channels_cache_ttl: int = CHANNELS_CACHE_TTL, cdm_cache_ttl: int = CDM_CACHE_TTL, auto_refresh: bool = AUTO_REFRESH_ON_EXPIRE, encoding: str = DEFAULT_ENCODING, script_args: Optional[Dict[str, str]] = None, ): """ Initialize Scripts provider Args: country: Country code (default: XX for international) config_dir: Configuration directory path script_filename: Specific script filename (if None, auto-discovers all *.py files) proxy_config: Proxy configuration object proxy_url: Proxy URL string script_timeout: Timeout in seconds for script execution channels_cache_ttl: Cache TTL for channels list in seconds cdm_cache_ttl: Cache TTL for DRM keys in seconds auto_refresh: Auto-refresh cache when expired encoding: File encoding for reading scripts script_args: Additional static arguments to pass to scripts Environment Variables: SCRIPTS_PROVIDERS_PATH: Directory containing script files (overrides config_dir) """ super().__init__(country=country) # Store script filename - this determines provider identity self.script_filename = script_filename self.script_timeout = script_timeout self.channels_cache_ttl = channels_cache_ttl self.cdm_cache_ttl = cdm_cache_ttl self.auto_refresh = auto_refresh self.encoding = encoding self.script_args = script_args or {} # Determine scripts directory from environment or config_dir scripts_path = os.environ.get(ENV_SCRIPTS_PROVIDERS_PATH) if scripts_path: logger.info(f"Scripts: Using {ENV_SCRIPTS_PROVIDERS_PATH} from environment: {scripts_path}") self.scripts_dir = scripts_path elif config_dir: self.scripts_dir = config_dir else: # Use current directory as fallback self.scripts_dir = "." logger.warning( "Scripts: No SCRIPTS_PROVIDERS_PATH or config_dir specified, using current directory" ) # Initialize VFS for file operations self.vfs = get_vfs(config_dir=self.scripts_dir) # Setup HTTP manager (minimal, scripts handle their own networking) self.http_manager = self._setup_http_manager( provider_name=self._get_base_provider_name(), proxy_config=proxy_config, proxy_url=proxy_url, config_dir=config_dir, ) # Internal state and caching self._channels_cache: List[StreamingChannel] = [] self._channels_cache_timestamp: float = 0 self._cdm_cache: Dict[str, DRMConfig] = {} # channel_id -> DRMConfig self._cdm_cache_timestamps: Dict[str, float] = {} # Statistics self._stats = { "script_executions": 0, "script_failures": 0, "channels_parsed": 0, "channels_skipped": 0, "drm_configs_loaded": 0, "cache_hits": 0, "cache_misses": 0, } # Validate script exists if specific filename provided if self.script_filename: self._validate_script() # ============================================================================ # STATIC METHODS FOR DISCOVERY # ============================================================================ @staticmethod def _get_base_provider_name() -> str: """Get base provider name (always 'scripts') for internal use""" return "scripts" @property def provider_name(self) -> str: """ Provider name is the script filename without extension Examples: - "discoveryplus.py" -> "discoveryplus" - "dplay.py" -> "dplay" - "peacock_us.py" -> "peacock_us" """ if self.script_filename: # Remove extension name = os.path.splitext(self.script_filename)[0] # Clean name for provider identifier clean_name = re.sub(r'[^a-z0-9_]+', '', name.lower()) return clean_name if clean_name else "script" return "scripts" @property def provider_label(self) -> str: """ Provider label is the script filename (human readable) Examples: - "discoveryplus.py" -> "Discoveryplus" - "dplay.py" -> "Dplay" - "peacock_us.py" -> "Peacock Us" """ if self.script_filename: # Convert filename to nice label name = os.path.splitext(self.script_filename)[0] # Replace underscores with spaces and title case label = name.replace('_', ' ').title() return label return self.PROVIDER_LABEL @property def provider_logo(self) -> str: return self.PROVIDER_LOGO @property def supported_auth_types(self) -> List[str]: return self.SUPPORTED_AUTH_TYPES @property def uses_dynamic_manifests(self) -> bool: return True # Scripts always generate manifests on demand @property def implements_epg(self) -> bool: return False # Scripts don't implement EPG # ============================================================================ # SCRIPT DISCOVERY # ============================================================================ def _validate_script(self) -> bool: """Validate that the configured script exists and is readable""" if not self.script_filename: return False if not self.vfs.exists(self.script_filename): logger.error(f"Scripts: Script not found: {self.script_filename}") return False return True @classmethod def discover_scripts(cls, config_dir: Optional[str] = None) -> List[str]: """ Discover all Python scripts in the scripts directory This is used for dynamic provider registration - each script becomes a provider. Args: config_dir: Configuration directory path Returns: List of script filenames (without directory) that can be providers """ try: # Determine scripts directory scripts_path = os.environ.get(ENV_SCRIPTS_PROVIDERS_PATH) if scripts_path: scripts_dir = scripts_path elif config_dir: scripts_dir = config_dir else: scripts_dir = "." # Initialize VFS vfs = get_vfs(config_dir=scripts_dir) # Discover all Python files discovered = [] for ext in SUPPORTED_EXTENSIONS: files = vfs.list_files("", pattern=f"*{ext}") discovered.extend(files) # Remove duplicates and sort discovered = sorted(set(discovered)) # Filter out excluded filenames discovered = [ f for f in discovered if os.path.basename(f) not in EXCLUDED_FILENAMES and not os.path.basename(f).startswith('_') ] if not discovered: logger.debug(f"Scripts: No provider scripts found in {scripts_dir}") else: logger.info(f"Scripts: Discovered {len(discovered)} provider scripts: {', '.join(discovered)}") return discovered except Exception as e: logger.error(f"Scripts: Error during script discovery: {e}") return [] # ============================================================================ # SCRIPT EXECUTION # ============================================================================ def _build_script_command( self, action: str, proxy_url: Optional[str] = None, timeout: Optional[int] = None, **kwargs, ) -> List[str]: """ Build command line for script execution Args: action: Action to perform ('channels', 'manifest', 'cdm') proxy_url: Optional proxy URL string timeout: Optional custom timeout (uses self.script_timeout if None) **kwargs: Additional parameters to pass to script Returns: List of command arguments """ if not self.script_filename: raise ValueError("No script filename configured") # Get full path to script script_path = self.vfs.join_path(self.script_filename) # Base command cmd = [sys.executable, script_path, "--action", action] # Add proxy if provided if proxy_url: cmd.append(PROXY_ARG_FORMAT.format(proxy_url)) # Add all kwargs as --key=value arguments for key, value in kwargs.items(): if value is not None: # Convert value to string and escape if needed cmd.append(f"--{key}={value}") # Add static script args (these come last and shouldn't override user kwargs) for key, value in self.script_args.items(): if key not in kwargs: # Only add if not already provided cmd.append(f"--{key}={value}") return cmd def _execute_script( self, action: str, proxy_url: Optional[str] = None, timeout: Optional[int] = None, **kwargs, ) -> Optional[Dict[str, Any]]: """ Execute a script with the given action and parameters Args: action: Action to perform proxy_url: Optional proxy URL timeout: Optional custom timeout (uses self.script_timeout if None) **kwargs: Additional parameters Returns: Parsed JSON output as dict, or None if execution failed """ if not self._validate_script(): logger.error(ERROR_SCRIPT_NOT_FOUND.format(filename=self.script_filename)) return None cmd = self._build_script_command(action, proxy_url, timeout, **kwargs) execution_timeout = timeout if timeout is not None else self.script_timeout logger.debug(f"Scripts: Executing {self.provider_label}: {' '.join(cmd)}") try: # Execute script with timeout result = subprocess.run( cmd, capture_output=True, text=True, timeout=execution_timeout, check=False, encoding='utf-8', # Explicitly set encoding errors='replace', # Replace invalid characters instead of failing ) self._stats["script_executions"] += 1 # Check exit code if result.returncode != 0: self._stats["script_failures"] += 1 stderr = result.stderr.strip() if result.stderr else "No error output" logger.error( f"Scripts: {self.provider_label} - " f"Script failed (exit code {result.returncode}): {stderr}" ) return None # Check stdout size if len(result.stdout) > MAX_OUTPUT_SIZE: logger.error( f"Scripts: {self.provider_label} - " f"Output exceeds maximum size ({len(result.stdout)} > {MAX_OUTPUT_SIZE})" ) return None # Parse JSON output try: output = json.loads(result.stdout) return output except json.JSONDecodeError as e: self._stats["script_failures"] += 1 logger.error(ERROR_INVALID_JSON.format(error=e)) logger.debug(f"Scripts: Raw output: {result.stdout[:500]}") return None except subprocess.TimeoutExpired: self._stats["script_failures"] += 1 logger.error(ERROR_SCRIPT_TIMEOUT.format( timeout=execution_timeout, filename=self.script_filename )) return None except Exception as e: self._stats["script_failures"] += 1 logger.error(f"Scripts: {self.provider_label} - Unexpected error: {e}") return None # ============================================================================ # CHANNEL PARSING # ============================================================================ def _parse_channels_output(self, output: Dict[str, Any]) -> List[StreamingChannel]: """ Parse script output for action=channels into StreamingChannel objects Expected output format: { "Channels": [ { "Name": "Channel Name", "Id": "channel_id", # optional "Mode": "live", "SessionManifest": True, "ManifestScript": "chanid=123", "CdmType": "widevine", "UseCdm": True, "Cdm": "chanid=123", ... } ] } Args: output: Parsed JSON output from script Returns: List of StreamingChannel objects """ channels = [] # Extract Channels array channels_data = output.get("Channels", []) if not isinstance(channels_data, list): logger.warning(f"Scripts: {self.provider_label} - 'Channels' is not a list") return [] for idx, channel_data in enumerate(channels_data): try: # Skip invalid channels if not isinstance(channel_data, dict): logger.warning(f"Scripts: {self.provider_label} - Channel {idx} is not a dict") continue # Check required fields missing_fields = [] for field in REQUIRED_CHANNEL_FIELDS: if field not in channel_data: missing_fields.append(field) if missing_fields: if SKIP_INVALID_CHANNELS: logger.warning(ERROR_CHANNELS_MISSING_FIELD.format( field=", ".join(missing_fields), name=channel_data.get("Name", f"channel_{idx}") )) self._stats["channels_skipped"] += 1 continue # Map fields to StreamingChannel format mapped_data = { "provider": self.provider_name, "country": self.country, "language": DEFAULT_LANGUAGE, } # Apply field mapping for script_field, channel_field in CHANNEL_FIELD_MAPPING.items(): if script_field in channel_data: mapped_data[channel_field] = channel_data[script_field] # Generate channel_id if not present if "channel_id" not in mapped_data: mapped_data["channel_id"] = self._generate_channel_id( mapped_data.get("name", "unknown"), idx + 1, self.script_filename or "script" ) # Apply default config for key, value in DEFAULT_CHANNEL_CONFIG.items(): if key not in mapped_data: mapped_data[key] = value # Create channel object channel = StreamingChannel(**mapped_data) channels.append(channel) self._stats["channels_parsed"] += 1 except Exception as e: logger.error(f"Scripts: {self.provider_label} - Error parsing channel {idx}: {e}") self._stats["channels_skipped"] += 1 return channels @staticmethod def _generate_channel_id(name: str, index: int, script_name: str) -> str: """Generate a unique channel ID from name and script""" # Clean name: lowercase, remove special chars, replace spaces with underscores clean_name = re.sub(r"[^a-z0-9\s]", "", name.lower()) clean_name = re.sub(r"\s+", "_", clean_name.strip()) # Clean script name clean_script = re.sub(r"[^a-z0-9]", "_", script_name.lower()) clean_script = clean_script.replace(".py", "") return f"{clean_script}_{clean_name}_{index}" # ============================================================================ # DRM PARSING # ============================================================================ def _parse_cdm_output( self, output: Dict[str, Any], channel_id: str, ) -> Optional[DRMConfig]: """ Parse script output for action=cdm into DRMConfig Expected output format: { "keyids": { "0123456789abcdef0123456789abcdef": "fedcba9876543210fedcba9876543210", ... }, "system": "widevine" # optional, defaults to widevine } Or list of hex pairs: [ "0123456789abcdef0123456789abcdef:fedcba9876543210fedcba9876543210", ... ] Args: output: Parsed JSON output from script channel_id: Channel ID for logging Returns: DRMConfig object or None """ try: keyids = {} drm_system = DEFAULT_DRM_SYSTEM # Case 1: Dictionary format with keyids if isinstance(output, dict): # Extract system if present if "system" in output: drm_system = output["system"].lower() # Extract keyids if "keyids" in output and isinstance(output["keyids"], dict): for kid, key in output["keyids"].items(): # Clean hex strings (remove dashes and spaces) kid_clean = kid.lower().replace("-", "").replace(" ", "") key_clean = key.lower().replace("-", "").replace(" ", "") # Validate 128-bit hex (32 chars) if len(kid_clean) == 32 and all(c in "0123456789abcdef" for c in kid_clean): if len(key_clean) == 32 and all(c in "0123456789abcdef" for c in key_clean): keyids[kid_clean] = key_clean else: logger.warning(f"Scripts: {self.provider_label} - Invalid key hex: {key}") else: logger.warning(f"Scripts: {self.provider_label} - Invalid KID hex: {kid}") # Case 2: List format with "KID:KEY" pairs elif isinstance(output, list): for item in output: if isinstance(item, str) and ":" in item: kid, key = item.split(":", 1) kid = kid.lower().replace("-", "").replace(" ", "") key = key.lower().replace("-", "").replace(" ", "") if len(kid) == 32 and all(c in "0123456789abcdef" for c in kid): if len(key) == 32 and all(c in "0123456789abcdef" for c in key): keyids[kid] = key else: logger.warning(ERROR_CDM_INVALID_FORMAT.format(line=item)) else: logger.warning(ERROR_CDM_INVALID_FORMAT.format(line=item)) else: logger.warning(ERROR_CDM_INVALID_FORMAT.format(line=str(item))) if not keyids: logger.debug(f"Scripts: {self.provider_label} - No valid keys found for channel {channel_id}") return None # Resolve DRM system drm_system_enum = DRMSystem.from_alias(drm_system) or DRMSystem.WIDEVINE # Create DRM config license_config = LicenseConfig( keyids=keyids, ) drm_config = DRMConfig( system=drm_system_enum, license=license_config, ) self._stats["drm_configs_loaded"] += 1 logger.debug(f"Scripts: {self.provider_label} - Loaded {len(keyids)} keys for {channel_id}") return drm_config except Exception as e: logger.error(f"Scripts: {self.provider_label} - Error parsing CDM output: {e}") return None # ============================================================================ # CHANNEL MANAGEMENT # ============================================================================ def _should_refresh_channels_cache(self) -> bool: """Check if channels cache should be refreshed""" if not self._channels_cache: return True if self.auto_refresh: age = time.time() - self._channels_cache_timestamp return age >= self.channels_cache_ttl return False def _should_refresh_cdm_cache(self, channel_id: str) -> bool: """Check if CDM cache for a channel should be refreshed""" if channel_id not in self._cdm_cache: return True if self.auto_refresh: age = time.time() - self._cdm_cache_timestamps.get(channel_id, 0) return age >= self.cdm_cache_ttl return False def get_channels( self, fetch_manifests: bool = False, populate_streaming_data: bool = True, force_refresh: bool = False, proxy_url: Optional[str] = None, **kwargs, ) -> List[StreamingChannel]: """ Get channels by executing script with action=channels Args: fetch_manifests: Not used (manifests are generated on demand) populate_streaming_data: Not used (data comes from script) force_refresh: Force re-execution of script proxy_url: Optional proxy URL to pass to script **kwargs: Additional arguments passed to script Returns: List of StreamingChannel objects """ # Check cache if not force_refresh and not self._should_refresh_channels_cache(): if self._channels_cache: self._stats["cache_hits"] += 1 logger.debug(f"Scripts ({self.provider_label}): Returning channels from cache") return self._channels_cache.copy() self._stats["cache_misses"] += 1 logger.info(f"Scripts ({self.provider_label}): Executing script to fetch channels...") # Execute script output = self._execute_script( action=ACTION_CHANNELS, proxy_url=proxy_url, **kwargs, ) if not output: # Return cached data if available, otherwise empty list if self._channels_cache: logger.warning(f"Scripts ({self.provider_label}): Script failed, using stale cache") return self._channels_cache.copy() return [] # Parse channels channels = self._parse_channels_output(output) # Update cache self._channels_cache = channels self._channels_cache_timestamp = time.time() logger.info( f"Scripts ({self.provider_label}): Loaded {len(channels)} channels " f"(parsed: {self._stats['channels_parsed']}, skipped: {self._stats['channels_skipped']})" ) return channels def get_events( self, start_time: Optional[datetime] = None, end_time: Optional[datetime] = None, **kwargs, ) -> List[Event]: return [] def get_manifest( self, content_id: str, proxy_url: Optional[str] = None, **kwargs, ) -> Optional[str]: """ Get manifest URL for a specific channel by executing script with action=manifest Args: channel_id: Channel identifier proxy_url: Optional proxy URL to pass to script **kwargs: Additional arguments passed to script (merged with channel manifest script) Returns: Manifest URL string or None """ # Find channel to get manifest script parameters channel = None channels = self.get_channels() for ch in channels: if ch.channel_id == content_id: channel = ch break if not channel: logger.warning(f"Scripts ({self.provider_label}): Channel not found: {content_id}") return None # Parse manifest script parameters manifest_params = {} if channel.manifest_script: # Parse key=value pairs for param in channel.manifest_script.split("&"): if "=" in param: key, value = param.split("=", 1) manifest_params[key] = value # Merge with provided kwargs (kwargs take precedence) script_kwargs = {**manifest_params, **kwargs} # Execute script output = self._execute_script( action=ACTION_MANIFEST, proxy_url=proxy_url, **script_kwargs, ) if not output: return None # Validate required fields for field in REQUIRED_MANIFEST_FIELDS: if field not in output: logger.error(ERROR_MANIFEST_MISSING_FIELD.format(field=field)) return None # Extract manifest URL manifest_url = output.get("ManifestUrl") if not manifest_url: logger.error("Scripts: ManifestUrl is empty") return None # Note: Headers and Heartbeat are stored in the channel/drm config # This is handled by the player, not the provider return manifest_url def get_drm( self, content_id: str, proxy_url: Optional[str] = None, force_refresh: bool = False, **kwargs, ) -> List[DRMConfig]: """ Get DRM configuration for a specific channel Args: channel_id: Channel identifier proxy_url: Optional proxy URL to pass to script force_refresh: Force re-fetching of DRM keys **kwargs: Additional arguments passed to script Returns: List of DRMConfig objects (empty if no DRM) """ # Find channel to get CDM parameters channel = None channels = self.get_channels() for ch in channels: if ch.channel_id == content_id: channel = ch break if not channel: logger.warning(f"Scripts ({self.provider_label}): Channel not found: {content_id}") return [] # Check cache if not force_refresh and not self._should_refresh_cdm_cache(content_id): cached_config = self._cdm_cache.get(content_id) if cached_config: self._stats["cache_hits"] += 1 logger.debug(f"Scripts ({self.provider_label}): Returning DRM config from cache for {content_id}") return [cached_config] self._stats["cache_misses"] += 1 # Parse CDM parameters from channel cdm_params = {} if channel.cdm: # Parse key=value pairs for param in channel.cdm.split("&"): if "=" in param: key, value = param.split("=", 1) cdm_params[key] = value # Merge with provided kwargs script_kwargs = {**cdm_params, **kwargs} # Execute script output = self._execute_script( action=ACTION_CDM, proxy_url=proxy_url, **script_kwargs, ) if not output: return [] # Parse DRM config drm_config = self._parse_cdm_output(output, content_id) if drm_config: # Update cache self._cdm_cache[content_id] = drm_config self._cdm_cache_timestamps[content_id] = time.time() return [drm_config] return [] def enrich_channel_data( self, channel: StreamingChannel, **kwargs, ) -> Optional[StreamingChannel]: """ Scripts provider channels are already fully populated Returns: Same channel object """ return channel def get_dynamic_manifest_params( self, channel: StreamingChannel, **kwargs, ) -> Optional[str]: """ Get manifest script parameters for a channel Returns: Manifest script string or None """ return channel.manifest_script if channel.session_manifest else None # ============================================================================ # UTILITY METHODS # ============================================================================ def get_statistics(self) -> Dict[str, Any]: """Get execution statistics""" return { **self._stats, "script_filename": self.script_filename, "scripts_dir": self.scripts_dir, "channels_cache_age": time.time() - self._channels_cache_timestamp if self._channels_cache_timestamp else 0, "cached_channels": len(self._channels_cache), "cached_drm_configs": len(self._cdm_cache), } def test_script(self) -> Dict[str, Any]: """ Test if script is executable and returns valid channels Returns: Test result dictionary """ result: Dict[str, Any] = { "provider": self.provider_name, "label": self.provider_label, "script": self.script_filename, "scripts_dir": self.scripts_dir, "success": False, "script_exists": False, "script_executable": False, "channels_found": 0, "error": None, } try: # Check script exists if not self.script_filename: result["error"] = "No script filename configured" return result result["script_exists"] = self.vfs.exists(self.script_filename) if not result["script_exists"]: result["error"] = f"Script not found: {self.script_filename}" return result # Try to execute script with channels action output = self._execute_script(ACTION_CHANNELS, timeout=10) if output: result["script_executable"] = True # Parse channels channels = self._parse_channels_output(output) result["channels_found"] = len(channels) # Check if we got valid channels if channels: result["success"] = True result["sample_channels"] = [ {"name": c.name, "id": c.channel_id} for c in channels[:5] # First 5 channels as sample ] else: result["error"] = "Script execution failed or returned no output" except Exception as e: result["error"] = str(e) result["statistics"] = self.get_statistics() return result def clear_caches(self) -> None: """Clear all caches""" self._channels_cache = [] self._channels_cache_timestamp = 0 self._cdm_cache.clear() self._cdm_cache_timestamps.clear() logger.info(f"Scripts ({self.provider_label}): Caches cleared") def close(self) -> None: """Clean up resources""" if hasattr(self, "http_manager") and self.http_manager: self.http_manager.close() def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.close() return False