diff --git a/__init__.py b/__init__.py index dbb59a2..92f9065 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1 @@ -from .re2oapi.client import Re2oAPIClient -from .re2oapi import exceptions - -__all__ = ['Re2oAPIClient', 'exceptions'] +from .re2oapi import * diff --git a/re2oapi/__init__.py b/re2oapi/__init__.py index e69de29..5939662 100644 --- a/re2oapi/__init__.py +++ b/re2oapi/__init__.py @@ -0,0 +1,4 @@ +from .client import Re2oAPIClient +from . import exceptions + +__all__ = ['Re2oAPIClient', 'exceptions'] diff --git a/re2oapi/client.py b/re2oapi/client.py index 9de89f9..09b913e 100644 --- a/re2oapi/client.py +++ b/re2oapi/client.py @@ -1,3 +1,4 @@ +import logging import datetime import iso8601 from pathlib import Path @@ -23,7 +24,8 @@ class Re2oAPIClient: process for you. """ - def __init__(self, hostname, username, password, token_file=None, use_tls=True): + def __init__(self, hostname, username, password, token_file=None, + use_tls=True, log_level=logging.CRITICAL+10): """Creates an API client. Args: @@ -36,7 +38,9 @@ class Re2oAPIClient: default value is `None`, which indicated to use `$HOME/{DEFAULT_TOKEN_FILENAME}`. use_tls: A boolean to indicate whether the client should us TLS - (recommended for production). The Default is `True`. + (recommended for production). The default is `True`. + log_level: Control the logging level to use. The default is + `logging.CRITICAL+10`. So nothing is logged. Raises: requests.exceptions.ConnectionError: Unable to resolve the @@ -47,6 +51,24 @@ class Re2oAPIClient: are not valid according to the Re2o server. """.format(DEFAULT_TOKEN_FILENAME=DEFAULT_TOKEN_FILENAME) + # Enable logging + self.log = logging.getLogger(__name__) + if not self.log.hasHandlers(): + # Avoid multiplicating handlers + handler = logging.StreamHandler() + formatter = logging.Formatter( + "%(asctime)s %(levelname)s %(name)s %(message)s" + ) + handler.setFormatter(formatter) + self.log.addHandler(handler) + self.log.setLevel(log_level) + + self.log.info("Starting new Re2o API client.") + self.log.debug("hostname = " + str(hostname)) + self.log.debug("username = " + str(username)) + self.log.debug("token_file = " + str(token_file)) + self.log.debug("use_tls = " + str(use_tls)) + self.use_tls = use_tls self.token_file = token_file or Path.home() / DEFAULT_TOKEN_FILENAME self.hostname = hostname @@ -73,29 +95,47 @@ class Re2oAPIClient: datetime.timedelta(seconds=TIME_FOR_RENEW) def _get_token_from_file(self): + self.log.debug("Retrieving token from token file '{}'." + .format(self.token_file)) + # Check the token file exists if not self.token_file.is_file(): - raise exceptions.TokenFileNotFound(self.token_file) + e = exceptions.TokenFileNotFound(self.token_file) + self.log.error(e) + raise e # Read the data in the file - with self.token_file.open() as f: - data = json.load(f) + try: + with self.token_file.open() as f: + data = json.load(f) + except Exception: + e = exceptions.TokenFileNotReadable(self.token_file) + self.log.error(e) + raise e try: # Retrieve data for this hostname and this username in the file token_data = data[self.hostname][self._username] - return { + ret = { 'token': token_data['token'], 'expiration': iso8601.parse_date(token_data['expiration']) } except KeyError: - raise exceptions.TokenNotInTokenFile( + e = exceptions.TokenNotInTokenFile( self._username, self.hostname, self.token_file ) + self.log.error(e) + raise e + else: + self.log.debug("Token successfully retrieved from token " + "file '{}'.".format(self.token_file)) + return ret def _save_token_to_file(self): + self.log.debug("Saving token to token file '{}'." + .format(self.token_file)) try: # Read previous data to not erase other tokens @@ -103,6 +143,8 @@ class Re2oAPIClient: data = json.load(f) except Exception: # Default value if file not readbale or data not valid JSON + self.log.warning("Token file '{}' is not a valid JSON readable " + "file. Considered empty.".format(self.token_file)) data = {} # Insert the new token in the data (replace old token if it exists) @@ -120,25 +162,38 @@ class Re2oAPIClient: json.dump(data, f) self.token_file.chmod(stat.S_IWRITE | stat.S_IREAD) except Exception: - pass + self.log.error("Token file '{}' could not be written. Passing." + .format(self.token_file)) + else: + self.log.debug("Token sucessfully writen in token file '{}'." + .format(self.token_file)) def _get_token_from_server(self): + self.log.debug("Requesting a new token form server '{}' for user " + "'{}'.".format(self.hostname, self._username)) # Perform the authentication request response = requests.post( self.get_url_for('token'), data={'username': self._username, 'password': self._password} ) + self.log.debug("Response code: "+str(response.status_code)) + if response.status_code == requests.codes.bad_request: - raise exceptions.InvalidCredentials(self._username, self.hostname) + e = exceptions.InvalidCredentials(self._username, self.hostname) + self.log.error(e) + raise e response.raise_for_status() # Return the token and expiration time response = response.json() - return { + ret = { 'token': response['token'], 'expiration': iso8601.parse_date(response['expiration']) } + self.log.debug("Token successfully retrieved from server '{}'." + .format(self.hostname)) + return ret def _force_renew_token(self): self.token = self._get_token_from_server() @@ -161,35 +216,51 @@ class Re2oAPIClient: return self.token['token'] def _request(self, method, url, headers={}, params={}, *args, **kwargs): + self.log.info("Building the request {} {}.".format(method.upper(), url)) # Update headers to force the 'Authorization' field with the right token + self.log.debug("Forcing authentication token.") headers.update({ 'Authorization': 'Token {}'.format(self.get_token()) }) # Use a json format unless the user already specified something if not 'format' in params.keys(): + self.log.debug("Forcing JSON format response.") params.update({'format': 'json'}) # Perform the request + self.log.info("Performing request {} {}".format(method.upper(), url)) response = getattr(requests, method)( url, headers=headers, params=params, *args, **kwargs ) + self.log.debug("Response code: "+str(response.status_code)) + if response.status_code == requests.codes.unauthorized: # Force re-login to the server (case of a wrong token but valid # credentials) and then retry the request without catching errors. + self.log.warning("Token refused. Trying to refresh the token.") self._force_renew_token() + headers.update({ 'Authorization': 'Token {}'.format(self.get_token()) }) + self.log.info("Re-performing the request {} {}" + .format(method.upper(), url)) response = getattr(requests, method)( url, headers=headers, params=params, *args, **kwargs ) + self.log.debug("Response code: "+str(response.status_code)) + if response.status_code == requests.codes.forbidden: - raise exceptions.PermissionDenied(method, url, self._username) + e = exceptions.PermissionDenied(method, url, self._username) + self.log.debug(e) + raise e response.raise_for_status() - return response.json() + ret = response.json() + self.log.debug("Request {} {} successful.".format(method, url)) + return ret def delete(self, *args, **kwargs): """Performs a DELETE request. @@ -370,7 +441,7 @@ class Re2oAPIClient: return '{proto}://{host}{endpoint}'.format( proto=('https' if self.use_tls else 'http'), host=self.hostname, - endpoint=endpoints.get_endpoint_for(name, **kwargs) + endpoint=endpoints.get_endpoint_for(name, self.log, **kwargs) ) def _list_for(self, obj_name, max_results=None, params={}, **kwargs): @@ -391,9 +462,13 @@ class Re2oAPIClient: exceptions.PermissionDenied: The user does not have the right to perform this request. """ + self.log.info("Starting listing {} objects".format(obj_name)) + self.log.debug("max_results = "+str(max_results)) + # For optimization, list all results in one page unless the user # is forcing the use of a different `page_size`. if not 'page_size' in params.keys(): + self.log.debug("Forcing 'page_size' parameter to 'all'.") params['page_size'] = 'all' # Performs the request for the first page @@ -410,7 +485,9 @@ class Re2oAPIClient: results += response['results'] # Returns the exact number of results if applicable - return results[:max_results] if max_results else results + ret = results[:max_results] if max_results else results + self.log.debug("Listing {} objects successful".format(obj_name)) + return ret def _count_for(self, obj_name, params={}, **kwargs): """Count all '{obj_name}' objects on the server: @@ -430,18 +507,24 @@ class Re2oAPIClient: exceptions.PermissionDenied: The user does not have the right to perform this request. """ + self.log.info("Starting counting {} objects".format(obj_name)) + # For optimization, ask fo only 1 result (so the server will take # less time to process the request) unless the user is forcing the # use of a different `page_size`. if not 'page_size' in params.keys(): + self.log.debug("Forcing 'page_size' parameter to '1'.") params['page_size'] = 1 # Performs the request and return the `count` value in the response. - return self.get( + ret = self.get( self.get_url_for('%s-list' % obj_name, **kwargs), params=params )['count'] + self.log.debug("Counting {} objects successful".format(obj_name)) + return ret + def _view_for(self, obj_name, params={}, **kwargs): """Retrieved the details of a '{obj_name}' object from the server. @@ -460,11 +543,15 @@ class Re2oAPIClient: exceptions.PermissionDenied: The user does not have the right to perform this request. """ - return self.get( + self.log.info("Starting viewing a {} object".format(obj_name)) + ret = self.get( self.get_url_for('%s-detail' % obj_name, **kwargs), params=params ) + self.log.debug("Viewing {} object successful".format(obj_name)) + return ret + def __getattr__(self, item): if item.startswith('list_'): diff --git a/re2oapi/endpoints.py b/re2oapi/endpoints.py index 212dac2..f27c72d 100644 --- a/re2oapi/endpoints.py +++ b/re2oapi/endpoints.py @@ -113,13 +113,19 @@ def get_names(): return urls.keys() -def get_endpoint_for(name, **kwargs): +def get_endpoint_for(name, logger=None, **kwargs): try: url=urls[name] - except KeyError as e: - raise exceptions.URLNameDoesNotExists(name) + except KeyError: + e = exceptions.URLNameDoesNotExists(name) + if logger is not None: + logger.warning(e) + raise e else: try: return url.format_map(kwargs) except KeyError as e: - raise exceptions.URLParameterMissing(e) + e = exceptions.URLParameterMissing(name, e) + if logger is not None: + logger.warning(e) + raise e diff --git a/re2oapi/exceptions.py b/re2oapi/exceptions.py index 54d0850..08d7913 100644 --- a/re2oapi/exceptions.py +++ b/re2oapi/exceptions.py @@ -12,7 +12,7 @@ class URLNameDoesNotExists(APIClientGenericError): class URLParameterMissing(APIClientGenericError): - template = "The url named '{}' require the parameter '{}' to be built." + template = "The url named '{}' requires the parameter {} to be built." class InvalidCredentials(APIClientGenericError): @@ -22,9 +22,14 @@ class InvalidCredentials(APIClientGenericError): class PermissionDenied(APIClientGenericError): template = "The {} request to '{}' was denied for {}." + class TokenFileNotFound(APIClientGenericError): template = "Token file at {} not found." +class TokenFileNotReadable(APIClientGenericError): + template = "Token file at {} is not a JSON readable file." + + class TokenNotInTokenFile(APIClientGenericError): template = "Token for {}@{} not found in token file ({})."