"""SplitIOClient provides methods to interact with the Split.io Admin API.""" import json import time from typing import Any, Dict, List from config import ( HARNESS_ACCOUNT_IDENTIFIER, HARNESS_BASE_URL, setup_logger, SPLITIO_BASE_URL, SPLITIO_HEADERS, SPLITIO_WORKSPACE_ID ) from exceptions import ( HTTPRequestError, InvalidContentTypeError, InvalidResponseError, JSONParsingError, RateLimitExceededError, UserNotFoundError ) import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry logger = setup_logger(__name__) class SplitIOClient: """ Client for interacting with the Split.io Admin API. Supports user/owner lookup, traffic type retrieval, and feature flag operations. """ def __init__( self, account_identifier: str | None = HARNESS_ACCOUNT_IDENTIFIER, workspace_id: str | None = SPLITIO_WORKSPACE_ID, headers: Dict[str, str] = SPLITIO_HEADERS ): """ Initialize the SplitIOClient. Args: workspace_id (str): The workspace ID for Split.io. headers (Dict[str, str]): Headers for authentication. """ if not account_identifier: raise ValueError('account_identifier must be set') if not workspace_id: raise ValueError('workspace_id must be set') self.account_identifier = account_identifier self.workspace_id = workspace_id self.session = self._init_session() self.session.headers.update(headers) self._cached_users: List[Dict[str, Any]] | None = None logger.debug(f'Initialized SplitIOClient with workspace_id={workspace_id}') def _init_session(self) -> requests.Session: """ Initialize a requests session with retry strategy. Returns: requests.Session: Configured session. """ retry_strategy = Retry( total=5, status_forcelist=[500, 502, 503, 504], allowed_methods=['GET', 'POST', 'PATCH'], backoff_factor=1 ) adapter = HTTPAdapter(max_retries=retry_strategy) session = requests.Session() session.mount('https://', adapter) logger.debug('Session initialized with retry strategy') return session def _request_with_custom_retry( self, method: str, url: str, retries: int = 5, min_retry_wait: int = 5, **kwargs ) -> Dict[str, Any] | List[Dict[str, Any]]: """ Make a request with custom retry handling for rate limiting. Args: method (str): HTTP method. url (str): Request URL. retries (int): Max retry attempts for 429 responses. min_retry_wait (int): Minimum wait time between retries. **kwargs: Additional request parameters. Returns: Dict: Parsed JSON response. Raises: Exception: For HTTP errors or unexpected content. """ logger.debug(f'Request [{method}] {url} with kwargs:{kwargs}') for attempt in range(retries + 1): resp = self.session.request(method, url, **kwargs) logger.debug(f'Response code: {resp.status_code}') if resp.status_code == 429: org = int(resp.headers.get('X-RateLimit-Reset-Seconds-Org', 1)) ip = int(resp.headers.get('X-RateLimit-Reset-Seconds-IP', 1)) wait_time = max(min_retry_wait, max(org, ip)) logger.warning(f'Rate limited. Retrying after {wait_time}s (attempt {attempt + 1}/{retries})') time.sleep(wait_time) continue if resp.status_code >= 400: logger.error(f'HTTP error: {resp.status_code} - {resp.text}') raise HTTPRequestError(f'HTTP {resp.status_code}: {resp.text}') if 'application/json' not in resp.headers.get('Content-Type', ''): raise InvalidContentTypeError(f"Unexpected content type: {resp.headers.get('Content-Type')}") try: data = resp.json() except ValueError: raise JSONParsingError('Failed to parse JSON response.') logger.debug(f'Response JSON: {str(data)[:500]}') if not isinstance(data, (dict, list)): raise InvalidResponseError('Unexpected JSON format: expected dict or list of dicts') return data raise RateLimitExceededError(f'Exceeded retry attempts for {method} {url}') def get_environments(self) -> List[Dict[str, Any]]: """ Retrieve environments for the workspace. Returns: List[Dict[str, Any]]: Environment data. """ url = f'{SPLITIO_BASE_URL}/environments/ws/{self.workspace_id}' logger.debug(f'Fetching environments from: {url}') data = self._request_with_custom_retry('GET', url) if not isinstance(data, list): raise InvalidResponseError('Expected a list of dicts for environments.') return data def get_traffic_types(self) -> List[Dict[str, Any]]: """ Retrieve all traffic types for the workspace. Returns: List[Dict[str, Any]]: Traffic type data. """ url = f'{SPLITIO_BASE_URL}/trafficTypes/ws/{self.workspace_id}' logger.debug(f'Fetching traffic types from: {url}') data = self._request_with_custom_retry('GET', url) if not isinstance(data, list): raise InvalidResponseError('Expected a list of dicts for traffic types.') return data def get_all_feature_flags(self) -> List[Dict[str, Any]]: """ Retrieve all feature flags for the workspace. Returns: List[Dict[str, Any]]: Feature flag data. """ logger.debug('Fetching all feature flags') feature_flags: List[Dict[str, Any]] = [] offset: int = 0 limit: int = 50 while True: url = f'{SPLITIO_BASE_URL}/splits/ws/{self.workspace_id}' paged_url = f'{url}?offset={offset}&limit={limit}' data = self._request_with_custom_retry('GET', paged_url) if not isinstance(data, dict): raise InvalidResponseError('Expected a dicts for feature flags.') batch = data.get('objects', []) feature_flags.extend(batch) if len(batch) < limit: break offset += limit logger.info(f'Found {len(feature_flags)} feature flags') return feature_flags def get_all_groups(self) -> List[Dict[str, Any]]: """ Retrieve all groups for the workspace. Returns: List[Dict[str, Any]]: Group data. """ logger.debug('Fetching all groups.') groups: List[Dict[str, Any]] = [] page: int = 0 total_pages: int | None = None while True: params: dict = { 'accountIdentifier': self.account_identifier, 'pageIndex': page } url = f'{HARNESS_BASE_URL}/user-groups' data = self._request_with_custom_retry('GET', url, params=params) if not isinstance(data, dict): raise InvalidResponseError('Expected a dicts for groups.') if data.get('status') != 'SUCCESS' or 'data' not in data: raise InvalidResponseError(f'Unexpected API response: {data}') data_obj = data['data'] content = data_obj.get('content', []) groups.extend(content) total_pages = data_obj.get('totalPages') if total_pages is None or page + 1 >= total_pages: break page += 1 logger.info(f'Found {len(groups)} groups.') return groups def get_all_users(self) -> List[Dict[str, Any]]: """ Fetch all users using marker-based pagination from Split.io. Returns: List[Dict]: List of users. """ if self._cached_users is not None: logger.debug('Using cached user list') return self._cached_users logger.debug('Fetching all users from API') users: List[Dict[str, Any]] = [] page: int = 0 total_pages: int | None = None while True: params: dict = { 'accountIdentifier': self.account_identifier, 'pageIndex': page } url: str = f'{HARNESS_BASE_URL}/user/aggregate' logger.debug(f'Fetching users page {page + 1} with params: {params}') result = self._request_with_custom_retry('POST', url, params=params) if not isinstance(result, dict): raise InvalidResponseError('Expected dict response from user fetch API') if result.get('status') != 'SUCCESS' or 'data' not in result: raise InvalidResponseError(f'Unexpected API response: {result}') data = result['data'] content = data.get('content', []) page_users = [ item['user'] for item in content if isinstance(item, dict) and 'user' in item ] users.extend(page_users) total_pages = data.get('totalPages') if total_pages is None or page + 1 >= total_pages: break page += 1 logger.info(f'Fetched {len(users)} users') self._cached_users = users return users def resolve_owner(self, username_or_email: str) -> Dict[str, str]: """ Resolve a username or email to a Split.io owner object. Args: username_or_email (str): Username or email. Returns: Dict: Owner dict containing 'id' and 'type'. Raises: ValueError: If user is not found. """ logger.debug(f'Resolving owner: {username_or_email}') all_users = self.get_all_users() for user in all_users: if user.get('email') == username_or_email or user.get('name') == username_or_email: logger.debug(f"Resolved owner: {username_or_email} -> {user['uuid']}") return {'id': user['uuid'], 'type': 'user'} logger.error('User not found: %s', username_or_email) raise UserNotFoundError(f"User '{username_or_email}' not found in Split.io") def create_feature_flag( self, flag_name: str, traffic_type_id: str, metadata: Dict[str, Any] ) -> Dict[str, Any]: """ Create a new feature flag in Split.io. Args: flag_name (str): Name of the flag. traffic_type_id (str): Traffic type ID. metadata (Dict): Includes description and owners. Returns: Dict[str, Any]: Parsed JSON response from Split.io. """ url = f'{SPLITIO_BASE_URL}/splits/ws/{self.workspace_id}/trafficTypes/{traffic_type_id}' logger.debug(f'Creating feature flag: {flag_name} at {url}') payload = { 'name': flag_name, 'description': metadata.get('description', ''), 'owners': metadata.get('owners', []) } logger.debug(f'Creating flag with payload: {json.dumps(payload, indent=2)}') data = self._request_with_custom_retry('POST', url, data=json.dumps(payload)) if not isinstance(data, dict): raise InvalidResponseError('Expected dict response from create feature flag API.') logger.debug(f'Feature flag created successfully: {data}') return data def patch_feature_flag( self, flag_name: str, patch_ops: List[Dict[str, Any]] ) -> Dict[str, Any]: """ Patch an existing feature flag using JSON Patch operations. Args: flag_name (str): Name of the flag to patch. patch_ops (List): List of patch operations. Returns: Dict[str, Any]: Parsed JSON response from Split.io. """ url = f'{SPLITIO_BASE_URL}/splits/ws/{self.workspace_id}/{flag_name}' logger.debug(f'Patching feature flag: {flag_name} at {url}') logger.debug(f'Patch operations: {json.dumps(patch_ops, indent=2)}') data = self._request_with_custom_retry('PATCH', url, data=json.dumps(patch_ops)) if not isinstance(data, dict): raise InvalidResponseError('Expected dict response from feature flag patch API.') logger.debug(f'Feature flag patched successfully: {data}') return data def create_flag_definition( self, flag_name: str, environment_id: str, definition_data: Dict[str, Any] ) -> Dict[str, Any]: """ Create a feature flag definition in a specific environment. Args: flag_name (str): Name of the feature flag. environment_id (str): Environment ID. definition_data (Dict): Definition data including treatments. Returns: Dict[str, Any]: Parsed JSON response from Split.io. """ url = f'{SPLITIO_BASE_URL}/splits/ws/{self.workspace_id}/{flag_name}/environments/{environment_id}' logger.debug(f'Posting definition to environment: {environment_id} for flag: {flag_name} at {url}') logger.debug(f'Definition data: {json.dumps(definition_data, indent=2)}') data = self._request_with_custom_retry('POST', url, data=json.dumps(definition_data)) if not isinstance(data, dict): raise InvalidResponseError('Expected dict response from post definition API.') logger.debug(f'Definition posted successfully: {data}') return data def associate_tags_to_split(self, flag_name: str, tags: List[str]) -> bool: """ Associate tags with a feature flag. Args: flag_name (str): Name of the feature flag. tags (List[str]): List of tags to associate. Returns: bool: True if tags were successfully associated, False if the flag was not found. """ url = f'{SPLITIO_BASE_URL}/tags/ws/{self.workspace_id}/object/{flag_name}/objecttype/Split' try: data = self._request_with_custom_retry('POST', url, data=json.dumps(tags)) if not isinstance(data, dict): raise InvalidResponseError('Expected dict response from post associate tags API.') return True except HTTPRequestError as e: if 'HTTP 404' in str(e): logger.warning(f'Flag {flag_name} not found while associating tags.') return False raise