ferrero-opentext/Python-Version/venv/lib/python3.12/site-packages/boxsdk/session/session.py

587 lines
23 KiB
Python

import random
import math
from functools import partial
from logging import getLogger
from numbers import Number
from typing import TYPE_CHECKING, Optional, Any, Type, Callable, Set
from requests.exceptions import RequestException
from boxsdk.exception import BoxException
from .box_request import BoxRequest as _BoxRequest
from .box_response import BoxResponse as _BoxResponse
from ..config import API, Client, Proxy
from ..exception import BoxAPIException
from ..network.default_network import DefaultNetwork
from ..util.json import is_json_response
from ..util.multipart_stream import MultipartStream
from ..util.shared_link import get_shared_link_header
from ..util.translator import Translator
if TYPE_CHECKING:
from boxsdk.network.network_interface import Network
from boxsdk.object.user import User
from boxsdk import NetworkResponse, OAuth2
class Session:
_retry_randomization_factor = 0.5
_retry_base_interval = 1
_JWT_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
_CCG_GRANT_TYPE = 'client_credentials'
"""
Box API session. Provides automatic retry of failed requests.
"""
def __init__(
self,
network_layer: 'Network' = None,
default_headers: Optional['dict'] = None,
translator: Translator = None,
default_network_request_kwargs: Optional['dict'] = None,
api_config: API = None,
client_config: Client = None,
proxy_config: Optional[Proxy] = None,
):
"""
:param network_layer:
Network implementation used by the session to make requests.
:param default_headers:
A dictionary containing default values to be used as headers when this session makes an API request.
:param translator:
(optional) The translator to use for translating Box API JSON
responses into :class:`BaseAPIJSONObject` smart objects.
Defaults to a new :class:`Translator` that inherits the
registrations of the default translator.
:param default_network_request_kwargs:
A dictionary containing default values to be passed to the network layer
when this session makes an API request.
:param api_config:
Object containing URLs for the Box API.
:param client_config:
Object containing client information, including user agent string.
:param proxy_config:
Object containing proxy information.
"""
if translator is None:
translator = Translator(extend_default_translator=True, new_child=True)
self._api_config = api_config or API()
self._client_config = client_config or Client()
self._proxy_config = proxy_config or Proxy()
super().__init__()
self._network_layer = network_layer or DefaultNetwork()
self._default_headers = {
'User-Agent': self._client_config.USER_AGENT_STRING,
'X-Box-UA': self._client_config.BOX_UA_STRING,
}
self._translator = translator
self._default_network_request_kwargs = {}
if default_headers:
self._default_headers.update(default_headers)
if default_network_request_kwargs:
self._default_network_request_kwargs.update(default_network_request_kwargs)
self._logger = getLogger(__name__)
def get(self, url: str, **kwargs: Any) -> '_BoxResponse':
"""Make a GET request to the Box API.
:param url:
The URL for the request.
"""
return self.request('GET', url, **kwargs)
def post(self, url: str, **kwargs: Any) -> '_BoxResponse':
"""Make a POST request to the Box API.
:param url:
The URL for the request.
"""
return self.request('POST', url, **kwargs)
def put(self, url: str, **kwargs: Any) -> '_BoxResponse':
"""Make a PUT request to the Box API.
:param url:
The URL for the request.
"""
return self.request('PUT', url, **kwargs)
def delete(self, url: str, **kwargs: Any) -> '_BoxResponse':
"""Make a DELETE request to the Box API.
:param url:
The URL for the request.
"""
if 'expect_json_response' not in kwargs:
kwargs['expect_json_response'] = False
if 'skip_retry_codes' not in kwargs:
kwargs['skip_retry_codes'] = {202}
return self.request('DELETE', url, **kwargs)
def options(self, url: str, **kwargs: Any) -> '_BoxResponse':
"""Make an OPTIONS request to the Box API.
:param url:
The URL for the request.
"""
return self.request('OPTIONS', url, **kwargs)
def request(self, method: str, url: str, **kwargs: Any) -> '_BoxResponse':
"""Make a request to the Box API.
:param method:
The HTTP verb for the request.
:param url:
The URL for the request.
"""
response = self._prepare_and_send_request(method, url, **kwargs)
return self.box_response_constructor(response)
@property
def box_request_constructor(self) -> Type[_BoxRequest]:
"""Get the constructor for the container class representing an API request"""
return _BoxRequest
@property
def box_response_constructor(self) -> Type[_BoxResponse]:
"""Get the constructor for the container class representing an API response"""
return _BoxResponse
@property
def translator(self) -> Translator:
"""
The translator used for translating Box API JSON responses into `BaseAPIJSONObject` smart objects.
"""
return self._translator
@property
def api_config(self) -> API:
return self._api_config
@property
def client_config(self) -> Client:
return self._client_config
@property
def proxy_config(self) -> Proxy:
return self._proxy_config
def get_url(self, endpoint: str, *args: Any) -> str:
"""
Return the URL for the given Box API endpoint.
:param endpoint:
The name of the endpoint.
:param args:
Additional parts of the endpoint URL.
"""
# pylint:disable=no-self-use
url = [f'{self._api_config.BASE_API_URL}/{endpoint}']
url.extend([f'/{x}' for x in args])
return ''.join(url)
def get_constructor_kwargs(self) -> dict:
return dict(
network_layer=self._network_layer,
translator=self._translator,
default_network_request_kwargs=self._default_network_request_kwargs.copy(),
api_config=self._api_config,
client_config=self._client_config,
proxy_config=self._proxy_config,
default_headers=self._default_headers.copy(),
)
def as_user(self, user: 'User') -> 'Session':
"""
Returns a new session object with default headers set up to make requests as the specified user.
:param user:
The user to impersonate when making API requests.
"""
kwargs = self.get_constructor_kwargs()
kwargs['default_headers']['As-User'] = user.object_id
return self.__class__(**kwargs)
def with_shared_link(self, shared_link: str, shared_link_password: str = None) -> 'Session':
"""
Returns a new session object with default headers set up to make requests using the shared link for auth.
:param shared_link:
The shared link.
:param shared_link_password:
The password for the shared link.
"""
kwargs = self.get_constructor_kwargs()
kwargs['default_headers'].update(get_shared_link_header(shared_link, shared_link_password))
return self.__class__(**kwargs)
def with_default_network_request_kwargs(self, extra_network_parameters: dict) -> 'Session':
kwargs = self.get_constructor_kwargs()
kwargs['default_network_request_kwargs'].update(extra_network_parameters)
return self.__class__(**kwargs)
# We updated our retry strategy to use exponential backoff instead of the header returned from the API response.
# This is something we can remove in latter major bumps.
# pylint: disable=unused-argument
def get_retry_after_time(self, attempt_number: int, retry_after_header: Optional[str]) -> Number:
"""
Get the amount of time to wait before retrying the API request, using the attempt number that failed to
calculate the retry time for the next retry attempt.
If the Retry-After header is supplied, use it; otherwise, use exponential backoff
For 202 Accepted (thumbnail or file not ready) and 429 (too many requests), retry later, after a delay
specified by the Retry-After header.
For 5xx Server Error, retry later, after a delay; use exponential backoff to determine the delay.
:param attempt_number: How many attempts at this request have already been tried.
:param retry_after_header: Value of the 'Retry-After` response header.
:return: Number of seconds to wait before retrying.
"""
if retry_after_header is not None:
try:
return int(retry_after_header)
except (ValueError, TypeError):
pass
min_randomization = 1 - self._retry_randomization_factor
max_randomization = 1 + self._retry_randomization_factor
randomization = (random.uniform(0, 1) * (max_randomization - min_randomization)) + min_randomization
exponential = math.pow(2, attempt_number)
return exponential * self._retry_base_interval * randomization
@staticmethod
def _raise_on_unsuccessful_request(network_response: 'NetworkResponse', request: '_BoxRequest', raised_exception: Exception) -> None:
"""
Raise an exception if the request was unsuccessful.
:param network_response:
The network response which is being tested for success.
:param request:
The API request that could be unsuccessful.
"""
if network_response is None:
raise raised_exception
if not network_response.ok:
response_json = {}
try:
response_json = network_response.json()
except ValueError:
pass
raise BoxAPIException(
status=network_response.status_code,
headers=network_response.headers,
code=response_json.get('code', None) or response_json.get('error', None),
message=response_json.get('message', None) or response_json.get('error_description', None),
request_id=response_json.get('request_id', None),
url=request.url,
method=request.method,
context_info=response_json.get('context_info', None),
network_response=network_response
)
if not Session._is_json_response_if_expected(network_response, request):
raise BoxAPIException(
status=network_response.status_code,
headers=network_response.headers,
message='Non-json response received, while expecting json response.',
url=request.url,
method=request.method,
network_response=network_response,
)
@staticmethod
def _is_json_response_if_expected(network_response: 'NetworkResponse', request: '_BoxRequest') -> bool:
"""
Validate that the response is json if the request expects json response.
:param network_response:
The network response which is being tested for success.
:param request:
The API request that could be unsuccessful.
"""
return not request.expect_json_response or is_json_response(network_response)
def _prepare_and_send_request(
self,
method: str,
url: str,
headers: dict = None,
auto_session_renewal: bool = True,
expect_json_response: bool = True,
**kwargs: Any
) -> 'NetworkResponse':
"""
Prepare a request to be sent to the Box API.
:param method:
The HTTP verb to use to make the request.
:param url:
The request URL.
:param headers:
Headers to include with the request.
:param auto_session_renewal:
Whether to automatically renew the session if the request fails due to an expired access token.
:param expect_json_response:
Whether the response content should be json.
"""
files = kwargs.get('files')
kwargs['file_stream_positions'] = None
if files:
kwargs['file_stream_positions'] = {name: file_tuple[1].tell() for name, file_tuple in files.items()}
attempt_number = 0
request_headers = self._get_request_headers()
request_headers.update(headers or {})
request = self.box_request_constructor(
url=url,
method=method,
headers=request_headers,
auto_session_renewal=auto_session_renewal,
expect_json_response=expect_json_response,
)
skip_retry_codes = kwargs.pop('skip_retry_codes', set())
raised_exception = None
try:
network_response = self._send_request(request, **kwargs)
reauthentication_needed = network_response.status_code == 401
except RequestException as request_exc:
raised_exception = request_exc
network_response = None
if 'EOF occurred in violation of protocol' in str(request_exc):
reauthentication_needed = True
elif any(text in str(request_exc) for text in [
'Connection aborted', 'Connection broken', 'Connection reset'
]):
reauthentication_needed = False
else:
raise
while True:
retry = self._get_retry_request_callable(
network_response, attempt_number, request, skip_retry_codes, reauthentication_needed, **kwargs)
if retry is None or attempt_number >= API.MAX_RETRY_ATTEMPTS:
if network_response is None:
raise raised_exception
break
attempt_number += 1
self._logger.debug('Retrying request')
network_response = retry(request, **kwargs)
self._raise_on_unsuccessful_request(network_response, request, raised_exception)
return network_response
def _get_retry_request_callable(
self,
network_response: Optional['NetworkResponse'],
attempt_number: int,
request: '_BoxRequest',
skip_retry_codes: Set[int],
session_renewal_needed: bool = False,
**kwargs: Any
) -> Optional[Callable]:
"""
Get a callable that retries a request for certain types of failure.
For 202 Accepted (thumbnail or file not ready) and 429 (too many requests), retry later, after a delay
specified by the Retry-After header.
For 5xx Server Error, retry later, after a delay; use exponential backoff to determine the delay.
Otherwise, return None.
:param network_response:
The response from the Box API.
:param attempt_number:
How many attempts at this request have already been tried. Used for exponential backoff calculations.
:param request:
The API request that could require retrying.
:return:
Callable that, when called, will retry the request. Takes the same parameters as :meth:`_send_request`.
"""
# pylint:disable=unused-argument
# pylint:disable=line-too-long
if network_response is None or (network_response.ok and request.method == 'GET' and not self._is_json_response_if_expected(network_response, request)):
return partial(
self._network_layer.retry_after,
self.get_retry_after_time(attempt_number, None),
self._send_request,
)
code = network_response.status_code
if (code in (202, 429) or code >= 500) and code not in skip_retry_codes and not self._is_server_auth_type(kwargs):
return partial(
self._network_layer.retry_after,
self.get_retry_after_time(attempt_number, network_response.headers.get('Retry-After', None)),
self._send_request,
)
return None
def _is_server_auth_type(self, kwargs: dict) -> bool:
data = kwargs.get('data', {})
grant_type = None
try:
if 'grant_type' in data:
grant_type = data['grant_type']
except TypeError:
pass
return grant_type in (self._JWT_GRANT_TYPE, self._CCG_GRANT_TYPE)
def _get_request_headers(self) -> dict:
return self._default_headers.copy()
def _prepare_proxy(self) -> Optional[dict]:
"""
Prepares basic authenticated and unauthenticated proxies for requests.
:return:
A prepared proxy dict to send along with the request. None if incorrect parameters were passed.
"""
proxy = {}
if self._proxy_config.URL is None:
return None
if self._proxy_config.AUTH and {'user', 'password'} <= set(self._proxy_config.AUTH):
host = self._proxy_config.URL
address = host.split('//')[1]
proxy_string = f'http://{self._proxy_config.AUTH.get("user", None)}:' \
f'{self._proxy_config.AUTH.get("password", None)}@{address}'
elif self._proxy_config.AUTH is None:
proxy_string = self._proxy_config.URL
else:
raise BoxException("The proxy auth dict you provided does not match pattern "
"{'user': 'example_user', 'password': 'example_password'}")
proxy['http'] = proxy_string
proxy['https'] = proxy['http']
return proxy
def _send_request(self, request: '_BoxRequest', **kwargs: Any) -> 'NetworkResponse':
"""
Make a request to the Box API.
:param request:
The API request to send.
"""
# Reset stream positions to what they were when the request was made so the same data is sent even if this
# is a retried attempt.
files, file_stream_positions, stream_file_content = (
kwargs.get('files'), kwargs.pop('file_stream_positions'), kwargs.pop('stream_file_content', True))
request_kwargs = self._default_network_request_kwargs.copy()
request_kwargs.update(kwargs)
proxy_dict = self._prepare_proxy()
if proxy_dict is not None:
request_kwargs.update({'proxies': proxy_dict})
if files and file_stream_positions:
for name, position in file_stream_positions.items():
files[name][1].seek(position)
if stream_file_content:
data = request_kwargs.pop('data', {})
multipart_stream = MultipartStream(data, files)
request_kwargs['data'] = multipart_stream
del request_kwargs['files']
request.headers['Content-Type'] = multipart_stream.content_type
request.access_token = request_kwargs.pop('access_token', None)
# send the request
network_response = self._network_layer.request(
request.method,
request.url,
access_token=request.access_token,
headers=request.headers,
log_response_content=request.expect_json_response,
**request_kwargs
)
return network_response
class AuthorizedSession(Session):
"""
Box API authorized session. Provides auth, automatic retry of failed requests, and session renewal.
"""
def __init__(self, oauth: 'OAuth2', **kwargs: Any):
"""
:param oauth:
OAuth2 object used by the session to authorize requests.
:param session:
The Box API session to wrap for authorization.
"""
super().__init__(**kwargs)
self._oauth = oauth
def get_constructor_kwargs(self) -> dict:
kwargs = super().get_constructor_kwargs()
kwargs['oauth'] = self._oauth
return kwargs
def _renew_session(self, access_token_used: Optional[str]) -> str:
"""
Renews the session by refreshing the access token.
:param access_token_used:
The access token that's currently being used by the session, that needs to be refreshed.
"""
new_access_token, _ = self._oauth.refresh(access_token_used)
return new_access_token
def _get_retry_request_callable(
self,
network_response: Optional['NetworkResponse'],
attempt_number: int,
request: '_BoxRequest',
skip_retry_codes: Set[int],
session_renewal_needed: bool = False,
**kwargs: Any
) -> Callable:
"""
Get a callable that retries a request for certain types of failure.
For 401 Unauthorized responses, renew the session by refreshing the access token; then retry.
Otherwise, defer to baseclass implementation.
:param network_response:
The response from the Box API.
:param attempt_number:
How many attempts at this request have already been tried. Used for exponential backoff calculations.
:param request:
The API request that could require retrying.
:return:
Callable that, when called, will retry the request. Takes the same parameters as :meth:`_send_request`.
"""
if request.auto_session_renewal and session_renewal_needed:
self._renew_session(request.access_token)
request.auto_session_renewal = False
return self._send_request
return super()._get_retry_request_callable(
network_response,
attempt_number,
request,
skip_retry_codes,
session_renewal_needed,
**kwargs
)
def _send_request(self, request: '_BoxRequest', **kwargs: Any) -> 'NetworkResponse':
"""
Make a request to the Box API.
:param request:
The API request to send.
"""
# Since there can be session renewal happening in the middle of preparing the request, it's important to be
# consistent with the access_token being used in the request.
access_token = self._oauth.access_token
if request.auto_session_renewal and access_token is None:
access_token = self._renew_session(None)
request.auto_session_renewal = False
authorization_header = {'Authorization': f'Bearer {access_token}'}
request.headers.update(authorization_header)
kwargs['access_token'] = access_token
return super()._send_request(request, **kwargs)