diff --git a/CHANGELOG.md b/CHANGELOG.md index 250b143..7486da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,22 @@ All notable changes to the Sonetel Python Module are tracked in this file. -## [0.2.1] - 24-08-2024 +## [0.3.0] - 28-03-2025 + ### Added +- HTTP Session Management with connection pooling for improved performance +- Automatic retry mechanism with exponential backoff for failed requests +- Configurable timeouts, retry settings, and connection pool parameters +- Proper resource cleanup on program exit +- Enhanced error handling and logging capabilities +- New `configure()` function to customize SDK behavior - `delete()` method added to the VoiceApp class. Can be used to delete an existing voice app in the account. +### Changed +- Optimized API request handling for better performance +- Improved error reporting with more detailed messages +- Updated docstrings to follow Google style format + ### Fixed - `get()` method in the Recording class didn't apply the optional parameters correctly. Fixed now. diff --git a/README.md b/README.md index cd6de3f..5376f6c 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ ![GitHub](https://img.shields.io/github/license/sonetel/sonetel-python)   ![PyPI](https://img.shields.io/pypi/v/sonetel)   ![GitHub issues](https://img.shields.io/github/issues/sonetel/sonetel-python)   [![Documentation Status](https://readthedocs.org/projects/sonetel-python/badge/?version=latest)](https://sonetel-python.readthedocs.io/en/latest/?badge=latest) ## 1. Introduction + The Sonetel API is a REST based web-service that enables you to manage your Sonetel account from your own platform or service. You can manage your account, your phone numbers and make callback calls etc. This Python package provides an easy-to-use interface to integrate Sonetel's APIs with your service. For more information about the API, please see the [documentation](https://docs.sonetel.com/). @@ -48,6 +49,7 @@ To use the package, add the following line to the top of your Python program. `import sonetel` Here's a description of the various modules and the methods available with each. + #### 2.2.1 Auth The Auth module is used to generate and manage access tokens. @@ -73,7 +75,7 @@ s = sntl.Auth(username=user, password=pswd) print(s.get_access_token()) ``` -##### Refresh access token +##### Refresh access token When your access token has expired, you can use the `create_token()` method to get a new `access_token` & `refresh_token`. @@ -124,7 +126,7 @@ It supports the following methods: 3. `get_balance()` - Returns the prepaid balance. Pass the parameter `currency` = `True` to include the currency with the returned value. 4. `get_accountid()` - Fetch the account ID. -##### Print your Sonetel account ID and the current prepaid balance. +##### Print your Sonetel account ID and the current prepaid balance ```python import os @@ -198,6 +200,37 @@ print(ph.get()) ``` +## SDK Configuration + +The Sonetel SDK now supports customizable configuration options for HTTP connections, retries, and logging. You can configure these settings using the `configure()` function: + +```python +import sonetel + +# Configure the SDK with custom settings +sonetel.configure( + timeout=(5, 60), # (connect_timeout, read_timeout) in seconds + max_retries=5, # Maximum number of retries for failed requests + backoff_factor=0.5, # Exponential backoff factor + pool_connections=20, # Number of connection pools + pool_maxsize=20, # Maximum connections per pool + log_level="DEBUG" # Logging level (DEBUG, INFO, WARNING, ERROR) +) + +# Then use the SDK as normal +user = os.environ.get('sonetelUsername') +pswd = os.environ.get('sonetelPassword') +auth = sonetel.Auth(username=user, password=pswd) +``` + +### Performance Improvements + +The SDK now uses connection pooling and automatic retries to improve performance and reliability: + +- **Connection Pooling**: Reuses HTTP connections to reduce latency and overhead +- **Automatic Retries**: Automatically retries failed requests with exponential backoff +- **Proper Resource Cleanup**: Automatically closes connections when your program exits + ## Storing your credentials Please keep your Sonetel login credentials safe to avoid any misuse of your account. Do not hard code them into scripts or save them in files that are saved in any form of version control. @@ -220,6 +253,6 @@ print(s.get_access_token()) ## Help -For help with the Sonetel API, have a look at the API documentation. +For help with the Sonetel API, have a look at the [API documentation](https://docs.sonetel.com). If you have an issue with the module, please [report an issue](https://github.com/Sonetel/sonetel-python/issues/issues) on GitHub. diff --git a/docs/index.md b/docs/index.md index c25b174..6713836 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,3 +8,32 @@ It allows you to quickly integrate Sonetel's communication services such as inte - If you don't have an account, sign up from [sonetel.com](https://app.sonetel.com/register?tag=api-developer&simple=true) - Install the `sonetel` package using pip. + +## Performance Features + +The Sonetel SDK includes several performance optimizations: + +- **HTTP Session Management**: Uses connection pooling to reduce latency and overhead +- **Automatic Retries**: Retries failed requests with exponential backoff +- **Configurable Settings**: Customize timeouts, retry behavior, and connection pooling +- **Resource Management**: Automatically cleans up resources when your program exits + +## Configuration + +You can configure the SDK's behavior using the `configure()` function: + +```python +import sonetel + +# Configure the SDK with custom settings +sonetel.configure( + timeout=(5, 60), # (connect_timeout, read_timeout) in seconds + max_retries=5, # Maximum number of retries for failed requests + backoff_factor=0.5, # Exponential backoff factor + pool_connections=20, # Number of connection pools + pool_maxsize=20, # Maximum connections per pool + log_level="DEBUG" # Logging level (DEBUG, INFO, WARNING, ERROR) +) +``` + +All parameters are optional and have sensible defaults. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md new file mode 100644 index 0000000..9033933 --- /dev/null +++ b/docs/reference/configuration.md @@ -0,0 +1,98 @@ +# Configuration + +The Sonetel SDK provides configuration options to customize its behavior, including HTTP connection settings, retry behavior, and logging. + +## Configure Function + +The `configure()` function allows you to customize the SDK's behavior: + +```python +import sonetel + +sonetel.configure( + timeout=(3.05, 60), + max_retries=3, + backoff_factor=0.3, + pool_connections=10, + pool_maxsize=10, + log_level="INFO" +) +``` + +### Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `timeout` | `float` or `tuple(float, float)` | `(3.05, 60)` | Request timeout in seconds. Can be a single float or a tuple of (connect_timeout, read_timeout) | +| `max_retries` | `int` | `3` | Maximum number of retries for failed requests | +| `backoff_factor` | `float` | `0.3` | Backoff factor for retries (exponential backoff) | +| `pool_connections` | `int` | `10` | Number of connection pools to cache | +| `pool_maxsize` | `int` | `10` | Maximum number of connections to save in the pool | +| `log_level` | `str` | `None` | Logging level (DEBUG, INFO, WARNING, ERROR) | + +## Session Management + +The SDK uses connection pooling to improve performance by reusing HTTP connections. This reduces the overhead of establishing new connections for each request. + +### Benefits + +- **Reduced Latency**: Reusing connections eliminates the need for TCP handshakes and TLS negotiations for each request +- **Improved Throughput**: Connection pooling allows for more efficient use of resources +- **Automatic Retries**: Failed requests are automatically retried with exponential backoff +- **Resource Cleanup**: Connections are properly closed when your program exits + +### Retry Behavior + +The SDK automatically retries failed requests with the following characteristics: + +- Retries are performed for server errors (HTTP 500, 502, 503, 504) +- Exponential backoff is used to avoid overwhelming the server +- The backoff formula is: `{backoff factor} * (2 ** ({number of total retries} - 1))` +- With the default backoff factor of 0.3, the retry delays would be: + - 1st retry: 0.3s + - 2nd retry: 0.6s + - 3rd retry: 1.2s + +## Logging + +The SDK uses Python's standard logging module. You can configure the log level using the `log_level` parameter in the `configure()` function. + +```python +import sonetel + +# Enable debug logging +sonetel.configure(log_level="DEBUG") +``` + +You can also configure logging manually: + +```python +import logging + +# Configure the root logger +logging.basicConfig(level=logging.INFO) + +# Or configure just the sonetel logger +logging.getLogger('sonetel').setLevel(logging.DEBUG) +``` + +## Example Usage + +```python +import sonetel +import logging + +# Configure the SDK with custom settings +sonetel.configure( + timeout=(5, 60), + max_retries=5, + backoff_factor=0.5, + pool_connections=20, + pool_maxsize=20, + log_level="DEBUG" +) + +# Then use the SDK as normal +user = os.environ.get('sonetelUsername') +pswd = os.environ.get('sonetelPassword') +auth = sonetel.Auth(username=user, password=pswd) diff --git a/mkdocs.yml b/mkdocs.yml index 69976b7..7620574 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -20,3 +20,4 @@ nav: - Account: reference/account.md - Auth: reference/auth.md - Calls: reference/calls.md + - Configuration: reference/configuration.md diff --git a/setup.py b/setup.py index 5e9a26b..78bc9b2 100644 --- a/setup.py +++ b/setup.py @@ -1,12 +1,29 @@ from setuptools import setup setup( - name='sonetel', - version='0.2.1', - packages=['sonetel'], - url='https://github.com/Sonetel/sonetel-python', - license='MIT', - author='aashish', - author_email='dev.support@sonetel.com', - description='A simple python wrapper for using Sonetel\'s REST APIs' + name="sonetel", + version="0.3.0", + packages=["sonetel"], + url="https://github.com/Sonetel/sonetel-python", + license="MIT", + author="aashish", + author_email="dev.support@sonetel.com", + description="A simple python wrapper for using Sonetel's REST APIs", + install_requires=[ + "requests>=2.25.0", + "PyJWT>=2.0.0", + ], + python_requires=">=3.6", + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Topic :: Software Development :: Libraries :: Python Modules", + ], ) diff --git a/sonetel/__init__.py b/sonetel/__init__.py index 49b5005..40fc264 100644 --- a/sonetel/__init__.py +++ b/sonetel/__init__.py @@ -1,11 +1,71 @@ """ -Sonetel +Sonetel Python SDK + +A Python package for using Sonetel's REST API endpoints. """ +import atexit +import logging +from typing import Optional, Tuple, Union + from .account import Account from .auth import Auth from .calls import Call from .phonenumber import PhoneNumber from .recording import Recording from .users import User +from .utilities import get_session from .voiceapps import VoiceApp + +# Configure package-level logger +logger = logging.getLogger(__name__) + +__version__ = "0.3.0" + + +def configure( + timeout: Union[float, Tuple[float, float]] = (3.05, 60), + max_retries: int = 3, + backoff_factor: float = 0.3, + pool_connections: int = 10, + pool_maxsize: int = 10, + log_level: Optional[str] = None, +): + """ + Configure the Sonetel SDK with custom parameters. + + Args: + timeout: Request timeout in seconds. Can be a single float or a tuple of (connect_timeout, read_timeout) + max_retries: Maximum number of retries for failed requests + backoff_factor: Backoff factor for retries (exponential backoff) + pool_connections: Number of connection pools to cache + pool_maxsize: Maximum number of connections to save in the pool + log_level: Logging level (DEBUG, INFO, WARNING, ERROR) + """ + # Configure logging + if log_level: + logging.basicConfig(level=getattr(logging, log_level)) + logger.setLevel(getattr(logging, log_level)) + + # Get session with custom parameters + get_session( + timeout=timeout, + max_retries=max_retries, + backoff_factor=backoff_factor, + pool_connections=pool_connections, + pool_maxsize=pool_maxsize, + ) + + +# Register cleanup function for program exit +def _cleanup(): + """Close the session and release resources on program exit.""" + try: + session = get_session() + session.close() + logger.debug("Sonetel SDK session closed") + except Exception as e: + logger.debug("Error during cleanup: %s", e) + + +atexit.register(_cleanup) diff --git a/sonetel/_constants.py b/sonetel/_constants.py index 3be94c1..8ebfd4d 100644 --- a/sonetel/_constants.py +++ b/sonetel/_constants.py @@ -3,34 +3,34 @@ """ # General -API_URI_BASE = 'https://public-api.sonetel.com' -API_URI_AUTH = 'https://api.sonetel.com/SonetelAuth/beta/oauth/token' -API_AUDIENCE = 'api.sonetel.com' -PKG_VERSION = '0.2.1' +API_URI_BASE = "https://public-api.sonetel.com" +API_URI_AUTH = "https://api.sonetel.com/SonetelAuth/beta/oauth/token" +API_AUDIENCE = "api.sonetel.com" +PKG_VERSION = "0.3.0" # Headers -CONTENT_TYPE_GENERAL = 'application/json;charset=UTF-8' -CONTENT_TYPE_AUTH = 'application/x-www-form-urlencoded' +CONTENT_TYPE_GENERAL = "application/json;charset=UTF-8" +CONTENT_TYPE_AUTH = "application/x-www-form-urlencoded" # Authentication -CONST_JWT_USER = 'sonetel-api' -CONST_JWT_PASS = 'sonetel-api' -CONST_TYPES_GRANT = ['password', 'refresh_token'] -CONST_TYPES_REFRESH = ['yes', 'no'] +CONST_JWT_USER = "sonetel-api" +CONST_JWT_PASS = "sonetel-api" +CONST_TYPES_GRANT = ["password", "refresh_token"] +CONST_TYPES_REFRESH = ["yes", "no"] # API Resources -API_ENDPOINT_ACCOUNT = '/account/' -API_ENDPOINT_CALLBACK = '/make-calls/call/call-back' -API_ENDPOINT_NUMBERSUBSCRIPTION = '/phonenumbersubscription/' -API_ENDPOINT_VOICEAPP = '/voiceapp/' -API_ENDPOINT_USER = '/user/' -API_ENDPOINT_CALL_RECORDING = '/call-recording' +API_ENDPOINT_ACCOUNT = "/account/" +API_ENDPOINT_CALLBACK = "/make-calls/call/call-back" +API_ENDPOINT_NUMBERSUBSCRIPTION = "/phonenumbersubscription/" +API_ENDPOINT_VOICEAPP = "/voiceapp/" +API_ENDPOINT_USER = "/user/" +API_ENDPOINT_CALL_RECORDING = "/call-recording" # Users -CONST_TYPES_USER = ['regular', 'admin'] +CONST_TYPES_USER = ["regular", "admin"] # Phone Numbers -CONST_CONNECT_TO_TYPES = ['user', 'phnum', 'sip', 'app'] +CONST_CONNECT_TO_TYPES = ["user", "phnum", "sip", "app"] # Error codes ERR_NUM_NOT_E164 = 1000 @@ -39,4 +39,3 @@ ERR_USED_ID_EMPTY = 2001 ERR_ACCOUNT_UPDATE_BODY_EMPTY = 3000 ERR_CALLBACK_NUM_EMPTY = 4000 - diff --git a/sonetel/auth.py b/sonetel/auth.py index 3270763..6b3f3ba 100644 --- a/sonetel/auth.py +++ b/sonetel/auth.py @@ -11,16 +11,22 @@ * `get_refresh_token()` - Get the refresh token. """ + +import requests + # Import Packages. from jwt import decode -import requests + from . import _constants as const from . import exceptions as e +from . import utilities as util + class Auth: """ Authentication class. Create, refresh and fetch tokens. """ + def __init__(self, username: str, password: str): self.__username = username @@ -28,19 +34,20 @@ def __init__(self, username: str, password: str): # Get access token from API token = self.create_token() - self._access_token = token['access_token'] - self._refresh_token = token['refresh_token'] + self._access_token = token["access_token"] + self._refresh_token = token["refresh_token"] self._decoded_token = decode( self._access_token, - audience='api.sonetel.com', - options={"verify_signature": False} + audience="api.sonetel.com", + options={"verify_signature": False}, ) - def create_token(self, - refresh_token: str = '', - grant_type: str = 'password', - refresh: str = 'yes', - ): + def create_token( + self, + refresh_token: str = "", + grant_type: str = "password", + refresh: str = "yes", + ): """ Create an API access token from the user's Sonetel email address and password. Optionally, generate a refresh token. Set the ``grant_type`` to ``refresh_token`` to refresh an @@ -48,68 +55,60 @@ def create_token(self, **Documentation**: https://docs.sonetel.com/docs/sonetel-documentation/YXBpOjExMzI3NDM3-authentication - :param refresh: Optional. Flag to return refresh token in the response. Accepted values 'yes' and 'no'. Defaults to 'yes' - :param grant_type: Optional. The OAuth2 grant type - `password` and `refresh_token` accepted. Defaults to 'password' - :param refresh_token: Optional. Pass the `refresh_token` generated from a previous request in this field to generate a new access_token. + Args: + refresh: Optional. Flag to return refresh token in the response. Accepted values 'yes' and 'no'. Defaults to 'yes' + grant_type: Optional. The OAuth2 grant type - `password` and `refresh_token` accepted. Defaults to 'password' + refresh_token: Optional. Pass the `refresh_token` generated from a previous request in this field to generate a new access_token. - :return: dict. The access token and refresh token if the request was processed successfully. If the request failed, the error message is returned. + Returns: + dict: The access token and refresh token if the request was processed successfully. If the request failed, the error message is returned. """ # Checks if grant_type.strip().lower() not in const.CONST_TYPES_GRANT: - raise e.AuthException(f'invalid grant: {grant_type}') + raise e.AuthException(f"invalid grant: {grant_type}") if refresh.strip().lower() not in const.CONST_TYPES_REFRESH: - refresh = 'yes' + refresh = "yes" - if grant_type.strip().lower() == 'refresh_token' and not refresh_token: + if grant_type.strip().lower() == "refresh_token" and not refresh_token: refresh_token = self._refresh_token # Prepare the request body. body = f"grant_type={grant_type}&refresh={refresh}" # Add the refresh token to the request body if passed to the function - if grant_type == 'refresh_token': + if grant_type == "refresh_token": body += f"&refresh_token={refresh_token}" else: body += f"&username={self.__username}&password={self.__password}" # Prepare the request auth = (const.CONST_JWT_USER, const.CONST_JWT_PASS) - headers = {'Content-Type': const.CONTENT_TYPE_AUTH} - - # Send the request - try: - req = requests.post( - url=const.API_URI_AUTH, - data=body, - headers=headers, - auth=auth, - timeout=60 - ) - req.raise_for_status() - except requests.exceptions.ConnectionError as err: - return {'status': 'failed', 'error': 'ConnectionError', 'message': err} - except requests.exceptions.Timeout: - return {'status': 'failed', 'error': 'Timeout', 'message': 'Operation timed out. Please try again.'} - except requests.exceptions.HTTPError as err: - return {'status': 'failed', 'error': 'Timeout', 'message': err} - - # Check the response and handle accordingly. - if req.status_code == requests.codes.ok: # pylint: disable=no-member - response_json = req.json() - - if refresh_token and grant_type == 'refresh_token': - self._access_token = response_json["access_token"] - self._refresh_token = response_json["refresh_token"] + + # Use session manager for the request + session = util.get_session() + response = session.request( + method="post", + url=const.API_URI_AUTH, + body=body, + content_type=const.CONTENT_TYPE_AUTH, + auth=auth, + ) + + # Check for success and handle token updates + if response.get("status") != "failed" and "access_token" in response: + if refresh_token and grant_type == "refresh_token": + self._access_token = response["access_token"] + self._refresh_token = response["refresh_token"] self._decoded_token = decode( self._access_token, - audience='api.sonetel.com', - options={"verify_signature": False} + audience="api.sonetel.com", + options={"verify_signature": False}, ) + return response - return response_json - return {'status': 'failed', 'error': 'Unknown error', 'message': req.text} + return response # This will contain error details if it failed def get_access_token(self): """ @@ -127,7 +126,7 @@ def get_access_token(self): Returns: access_token (str): The access token that can be used to access other account resources. """ - return self._access_token if hasattr(self, '_access_token') else False + return self._access_token if hasattr(self, "_access_token") else False def get_refresh_token(self): """ @@ -145,7 +144,7 @@ def get_refresh_token(self): Returns: str: The refresh token. """ - return self._refresh_token if hasattr(self, '_refresh_token') else False + return self._refresh_token if hasattr(self, "_refresh_token") else False def get_decoded_token(self): """ @@ -165,4 +164,4 @@ def get_decoded_token(self): Returns: dict: The decoded token. """ - return self._decoded_token if hasattr(self, '_decoded_token') else False + return self._decoded_token if hasattr(self, "_decoded_token") else False diff --git a/sonetel/recording.py b/sonetel/recording.py index 7189c05..d485c86 100644 --- a/sonetel/recording.py +++ b/sonetel/recording.py @@ -1,26 +1,29 @@ """ Manage call recordings """ + # Import Packages. -from . import utilities as util from . import _constants as const +from . import utilities as util + class Recording(util.Resource): """ Class representing the call recording resource. """ - def __init__(self, access_token: str = None): + def __init__(self, access_token: str = ""): super().__init__(access_token) - self._url = f'{const.API_URI_BASE}{const.API_ENDPOINT_CALL_RECORDING}' - - def get(self, - start_time: str = None, - end_time: str = None, - file_access_details: bool = False, - voice_call_details: bool = False, - rec_id: str = None - ): + self._url = f"{const.API_URI_BASE}{const.API_ENDPOINT_CALL_RECORDING}" + + def get( + self, + start_time: str = "", + end_time: str = "", + file_access_details: bool = False, + voice_call_details: bool = False, + rec_id: str = "", + ): """ Get a list of all the call recordings or a single recording. Add the start_time and end_time to filter the recordings based on the created date. @@ -36,31 +39,35 @@ def get(self, """ url = self._url - field_prefix = '&' + field_prefix = "&" # Prepare the request URL based on the params passed to the method if rec_id: # Get a single recording - url += f'/{rec_id}' - field_prefix = '?' + url += f"/{rec_id}" + field_prefix = "?" else: # Search for and return multiple recordings - url += f'?account_id={self._accountid}' + url += f"?account_id={self._accountid}" - if util.is_valid_date(start_time) and util.is_valid_date(end_time) and util.date_diff(start_time, end_time): - url += f'&created_date_max={end_time}&created_date_min={start_time}' + if ( + util.is_valid_date(start_time) + and util.is_valid_date(end_time) + and util.date_diff(start_time, end_time) + ): + url += f"&created_date_max={end_time}&created_date_min={start_time}" fields = [] if file_access_details: - fields.append('file_access_details') + fields.append("file_access_details") if voice_call_details: - fields.append('voice_call_details') + fields.append("voice_call_details") if len(fields) > 0: - url += f'{field_prefix}fields=' + ','.join(fields) + url += f"{field_prefix}fields=" + ",".join(fields) - return util.send_api_request(token=self._token, uri=url, method='get') + return util.send_api_request(token=self._token, uri=url, method="get") def delete(self, rec_id: str) -> dict: """ @@ -69,5 +76,5 @@ def delete(self, rec_id: str) -> dict: :param rec_id: The ID of the recording that should be deleted :returns: A representation of the deleted recording. """ - url = f'{self._url}/{rec_id}' - return util.send_api_request(token=self._token, uri=url, method='delete') + url = f"{self._url}/{rec_id}" + return util.send_api_request(token=self._token, uri=url, method="delete") diff --git a/sonetel/session.py b/sonetel/session.py new file mode 100644 index 0000000..49db362 --- /dev/null +++ b/sonetel/session.py @@ -0,0 +1,160 @@ +""" +Session management for Sonetel API requests. +""" + +import logging +import time +from typing import Any, Dict, Optional, Tuple, Union + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from . import _constants as const +from . import exceptions as e + + +class SessionManager: + """ + Manages HTTP sessions and requests for the Sonetel API. + Provides connection pooling, retry logic, and consistent error handling. + """ + + def __init__( + self, + timeout: Union[float, Tuple[float, float]] = (3.05, 60), + max_retries: int = 3, + backoff_factor: float = 0.3, + status_forcelist: Tuple = (500, 502, 503, 504), + pool_connections: int = 10, + pool_maxsize: int = 10, + ): + """ + Initialize the session manager with configurable parameters. + + Args: + timeout: Request timeout (connect_timeout, read_timeout) in seconds + max_retries: Maximum number of retries for failed requests + backoff_factor: Backoff factor for retries (exponential backoff) + status_forcelist: HTTP status codes that should trigger a retry + pool_connections: Number of connection pools to cache + pool_maxsize: Maximum number of connections to save in the pool + """ + self.session = requests.Session() + self.timeout = timeout + + # Configure retry strategy + retry_strategy = Retry( + total=max_retries, + backoff_factor=backoff_factor, + status_forcelist=status_forcelist, + allowed_methods=["GET", "PUT", "DELETE", "POST", "PATCH"], + ) + + # Mount the retry adapter to the session + adapter = HTTPAdapter( + max_retries=retry_strategy, + pool_connections=pool_connections, + pool_maxsize=pool_maxsize, + ) + self.session.mount("https://", adapter) + self.session.mount("http://", adapter) + + # Add default headers + self.session.headers.update( + {"User-Agent": f"Sonetel Python Package - v{const.PKG_VERSION}"} + ) + + self.logger = logging.getLogger(__name__) + + def request( + self, + method: str, + url: str, + token: Optional[str] = None, + body: Optional[str] = None, + content_type: str = const.CONTENT_TYPE_GENERAL, + auth: Optional[Tuple[str, str]] = None, + ) -> Dict[str, Any]: + """ + Send an HTTP request to the Sonetel API with proper error handling and logging. + + Args: + method: HTTP method (GET, POST, PUT, DELETE) + url: API endpoint URL + token: OAuth access token (for authenticated requests) + body: Request body (JSON string) + content_type: Content type header + auth: Basic auth tuple (username, password) + + Returns: + API response as a dict + + Raises: + SonetelException: For API errors and connection issues + """ + headers = {"Content-Type": content_type} + + # Add authorization if token is provided + if token: + headers["Authorization"] = f"Bearer {token}" + + start_time = time.time() + + try: + self.logger.debug("Sending %s request to %s", method, url) + + response = self.session.request( + method=method, + url=url, + headers=headers, + data=body, + auth=auth, + timeout=self.timeout, + ) + + # Raise HTTPError for bad response codes + response.raise_for_status() + + # Log request duration + duration = time.time() - start_time + self.logger.debug("Request completed in %ss", format(duration, ".2f")) + + # Return JSON response if available + if response.status_code == requests.codes.ok: + return response.json() + + # Should not get here due to raise_for_status + return {"status": "unknown", "response": response.text} + + except requests.exceptions.HTTPError as err: + self.logger.error("HTTP Error: %s", err) + return { + "status": "failed", + "error": "HTTPError", + "message": err.response.text, + } + + except requests.exceptions.ConnectionError as err: + self.logger.error("Connection Error: %s", err) + return {"status": "failed", "error": "ConnectionError", "message": str(err)} + + except requests.exceptions.Timeout as err: + self.logger.error("Timeout Error: %s", err) + return { + "status": "failed", + "error": "Timeout", + "message": "Request timed out", + } + + except requests.exceptions.RequestException as err: + self.logger.error("Request Exception: %s", err) + return { + "status": "failed", + "error": "RequestException", + "message": str(err), + } + + def close(self): + """Close the session and release resources.""" + self.session.close() diff --git a/sonetel/utilities.py b/sonetel/utilities.py index 8cbf303..dda7f8d 100644 --- a/sonetel/utilities.py +++ b/sonetel/utilities.py @@ -1,37 +1,69 @@ """ Utilities for internal use """ -from time import time + import datetime +import logging +from time import time + import jwt import requests + from . import _constants as const from . import exceptions as e +from .session import SessionManager + +# Configure logging +logger = logging.getLogger(__name__) + +# Global session manager instance +_session_manager = None + + +def get_session(**kwargs) -> SessionManager: + """ + Get or create the global session manager instance. + + Args: + **kwargs: Optional configuration parameters for SessionManager + + Returns: + The global SessionManager instance + """ + global _session_manager + if _session_manager is None or kwargs: + _session_manager = SessionManager(**kwargs) + return _session_manager + class Resource: """ Base recource class for Sonetel API """ + def __init__(self, access_token: str): if not access_token: raise e.AuthException("access_token is a required parameter.") self._token: str = access_token self._decoded_token = decode_token(self._token) - self._accountid: str = self._decoded_token['acc_id'] - self._userid: str = self._decoded_token['user_id'] + self._accountid: str = self._decoded_token["acc_id"] + self._userid: str = self._decoded_token["user_id"] if not is_valid_token(self._decoded_token): raise e.AuthException("Token has expired") + # Static methods + def is_valid_token(decoded_token: dict) -> bool: """ Return True if token hasn't expired. Accepts a decoded token. """ # TODO: If token has expired, try to refresh it - return decoded_token['exp'] - int(time()) > 60 + return decoded_token["exp"] - int(time()) > 60 + def is_valid_date(date_text): """ @@ -39,44 +71,49 @@ def is_valid_date(date_text): """ # Based on https://stackoverflow.com/a/16870699/18276605 try: - datetime.datetime.strptime(date_text, '%Y%m%dT%H:%M:%SZ') + datetime.datetime.strptime(date_text, "%Y%m%dT%H:%M:%SZ") return True except Exception: return False + def date_diff(start, end): """ Check if the end date is greater than start date. Returns a boolean. """ - start_date = datetime.datetime.strptime(start, '%Y%m%dT%H:%M:%SZ').strftime('%s') - end_date = datetime.datetime.strptime(end, '%Y%m%dT%H:%M:%SZ').strftime('%s') + start_date = datetime.datetime.strptime(start, "%Y%m%dT%H:%M:%SZ").strftime("%s") + end_date = datetime.datetime.strptime(end, "%Y%m%dT%H:%M:%SZ").strftime("%s") return int(end_date) - int(start_date) > 0 + def decode_token(token) -> dict: """ Decode the JWT token """ return jwt.decode( - token, - audience='api.sonetel.com', - options={"verify_signature": False} + token, audience="api.sonetel.com", options={"verify_signature": False} ) -def send_api_request(token: str, - uri: str, - method: str = 'GET', - body: str = None, - body_type: str = const.CONTENT_TYPE_GENERAL) -> dict: +def send_api_request( + token: str, + uri: str, + method: str = "GET", + body: str = "", + body_type: str = const.CONTENT_TYPE_GENERAL, +) -> dict: """ - Send an API request to Sonetel. + Send an API request to Sonetel using the session manager. + + Args: + token: Required. String. The access token. + uri: Required. String. The API endpoint to send the request to. + method: Optional. String. The HTTP method to use. Defaults to GET. + body: Optional. String. The body of the request. Defaults to an empty string. + body_type: Optional. String. The content type of the body. Defaults to application/json. - :param token: Required. String. The access token. - :param uri: Required. String. The API endpoint to send the request to. - :param method: Optional. String. The HTTP method to use. Defaults to GET. - :param body: Optional. String. The body of the request. Defaults to None. - :param body_type: Optional. String. The content type of the body. Defaults to application/json. - :return: A dictionary containing the response. + Returns: + A dictionary containing the response. """ # Checks @@ -85,42 +122,15 @@ def send_api_request(token: str, if not uri: raise e.SonetelException('"uri" is a required parameter') - # Prepare the request Header - request_header = { - "Authorization": "Bearer " + token, - "Content-Type": body_type, - "User-Agent": f'Sonetel Python Package - v{const.PKG_VERSION}' - } + # Get the session manager and send the request + session = get_session() + return session.request( + method=method, url=uri, token=token, body=body, content_type=body_type + ) - # Send the request - try: - r = requests.request( - method=method, - url=uri, - headers=request_header, - data=body, - timeout=60 - ) - r.raise_for_status() - except requests.exceptions.HTTPError as err: - return {'status': 'failed', 'error': 'HTTPError', 'message': err.response.text} - except requests.exceptions.ConnectionError as err: - return {'status': 'failed', 'error': 'ConnectionError', 'message': err} - except requests.exceptions.RequestException as err: - return {'status': 'failed', 'error': 'RequestException', 'message': err} - - # pylint: disable=no-member - if r.status_code == requests.codes.ok: - return r.json() - - r.raise_for_status() def prepare_error(code: int, message: str) -> dict: """ Prepare a dict with the error response. """ - return { - 'status': 'failed', - 'code': code, - 'message': message - } + return {"status": "failed", "code": code, "message": message} diff --git a/sonetel/voiceapps.py b/sonetel/voiceapps.py index 4ea179e..84e4b22 100644 --- a/sonetel/voiceapps.py +++ b/sonetel/voiceapps.py @@ -1,6 +1,7 @@ """ Voice apps """ + from . import _constants as const from . import exceptions as e from . import utilities as util @@ -18,7 +19,7 @@ def __init__(self, access_token): super().__init__(access_token=access_token) self._url = f"{const.API_URI_BASE}{const.API_ENDPOINT_ACCOUNT}{self._accountid}{const.API_ENDPOINT_VOICEAPP}" - def get(self, app_id: str = None): + def get(self, app_id: str = ""): """ Get voice apps. Specify the app_id to get a specific voice app or leave it blank to get all voice apps in the Sonetel account. diff --git a/tests/README.md b/tests/README.md index 2a471a3..3819a6d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -24,4 +24,4 @@ Set the following environment variables. These are used in unit tests. - `SonetelUsername` - The email address of a Sonetel account - `SonetelPassword` - Passsword for the above Sonetel account - `SonetelSelfUserId` - Unique ID of your own user account in Sonetel. -- `SonetelTestUserId` - Unique ID of another user in your Sonetel account. \ No newline at end of file +- `SonetelTestUserId` - Unique ID of another user in your Sonetel account diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..3288854 --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,105 @@ +""" +Tests for the session management functionality. +""" + +import unittest +from unittest.mock import MagicMock, patch + +import requests + +from sonetel import configure +from sonetel.session import SessionManager +from sonetel.utilities import get_session + + +class TestSessionManager(unittest.TestCase): + """Test the SessionManager class.""" + + def test_session_creation(self): + """Test that a SessionManager can be created.""" + session_manager = SessionManager() + self.assertIsNotNone(session_manager) + self.assertIsInstance(session_manager.session, requests.Session) + + @patch("requests.Session.request") + def test_request_success(self, mock_request): + """Test that a request can be made successfully.""" + # Setup mock + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"status": "success", "data": "test"} + mock_request.return_value = mock_response + + # Create session manager and make request + session_manager = SessionManager() + response = session_manager.request( + method="GET", + url="https://example.com", + token="test_token", + ) + + # Verify request was made with correct parameters + mock_request.assert_called_once() + args, kwargs = mock_request.call_args + self.assertEqual(kwargs["method"], "GET") + self.assertEqual(kwargs["url"], "https://example.com") + self.assertEqual(kwargs["headers"]["Authorization"], "Bearer test_token") + self.assertEqual( + kwargs["headers"]["Content-Type"], "application/json;charset=UTF-8" + ) + + # Verify response was processed correctly + self.assertEqual(response, {"status": "success", "data": "test"}) + + @patch("requests.Session.request") + def test_request_error(self, mock_request): + """Test that errors are handled correctly.""" + # Setup mock to raise an exception + mock_request.side_effect = requests.exceptions.ConnectionError( + "Connection error" + ) + + # Create session manager and make request + session_manager = SessionManager() + response = session_manager.request( + method="GET", + url="https://example.com", + ) + + # Verify error response + self.assertEqual(response["status"], "failed") + self.assertEqual(response["error"], "ConnectionError") + self.assertIn("Connection error", response["message"]) + + def test_get_session(self): + """Test that get_session returns a SessionManager instance.""" + session1 = get_session() + session2 = get_session() + + # Verify that the same instance is returned + self.assertIs(session1, session2) + + # Verify that it's a SessionManager + self.assertIsInstance(session1, SessionManager) + + @patch("sonetel.utilities._session_manager") + def test_configure(self, mock_session_manager): + """Test that configure creates a new session with the specified parameters.""" + # Call configure with custom parameters + configure( + timeout=10, + max_retries=5, + backoff_factor=0.5, + pool_connections=20, + pool_maxsize=20, + log_level="DEBUG", + ) + + # Verify that get_session was called with the correct parameters + # Note: This is a simplified test that doesn't actually verify the parameters + # were passed correctly, since that would require more complex mocking + self.assertIsNotNone(get_session()) + + +if __name__ == "__main__": + unittest.main()