templates/python/facebook_business/api.py (582 lines of code) (raw):

# Copyright 2014 Facebook, Inc. # You are hereby granted a non-exclusive, worldwide, royalty-free license to # use, copy, modify, and distribute this software in source code or binary # form for use in connection with the web services and APIs provided by # Facebook. # As with any software that integrates with the Facebook platform, your use # of this software is subject to the Facebook Developer Principles and # Policies [http://developers.facebook.com/policy/]. This copyright notice # shall be included in all copies or substantial portions of the software. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER # DEALINGS IN THE SOFTWARE. from facebook_business.session import FacebookSession from facebook_business import apiconfig from facebook_business.exceptions import ( FacebookRequestError, FacebookBadObjectError, FacebookUnavailablePropertyException, FacebookBadParameterError, ) from facebook_business.utils import api_utils from facebook_business.utils import urls from contextlib import contextmanager import copy from six.moves import http_client import os import json import six import re try: # Since python 3 from six.moves import collections_abc except ImportError: # Won't work after python 3.8 import collections as collections_abc from facebook_business.adobjects.objectparser import ObjectParser from facebook_business.typechecker import TypeChecker """ api module contains classes that make http requests to Facebook's graph API. """ class FacebookResponse(object): """Encapsulates an http response from Facebook's Graph API.""" def __init__(self, body=None, http_status=None, headers=None, call=None): """Initializes the object's internal data. Args: body (optional): The response body as text. http_status (optional): The http status code. headers (optional): The http headers. call (optional): The original call that was made. """ self._body = body self._http_status = http_status self._headers = headers or {} self._call = call def body(self): """Returns the response body.""" return self._body def json(self): """Returns the response body -- in json if possible.""" try: return json.loads(self._body) except (TypeError, ValueError): return self._body def headers(self): """Return the response headers.""" return self._headers def etag(self): """Returns the ETag header value if it exists.""" return self._headers.get('ETag') def status(self): """Returns the http status code of the response.""" return self._http_status def is_success(self): """Returns boolean indicating if the call was successful.""" json_body = self.json() if isinstance(json_body, collections_abc.Mapping) and 'error' in json_body: # Is a dictionary, has error in it return False elif bool(json_body): # Has body and no error if 'success' in json_body: return json_body['success'] # API can retuen a success 200 when service unavailable occurs return 'Service Unavailable' not in json_body elif self._http_status == http_client.NOT_MODIFIED: # ETAG Hit return True elif self._http_status == http_client.OK: # HTTP Okay return True else: # Something else return False def is_failure(self): """Returns boolean indicating if the call failed.""" return not self.is_success() def error(self): """ Returns a FacebookRequestError (located in the exceptions module) with an appropriate debug message. """ if self.is_failure(): return FacebookRequestError( "Call was not successful", self._call, self.status(), self.headers(), self.body(), ) else: return None class FacebookAdsApi(object): """Encapsulates session attributes and methods to make API calls. Attributes: SDK_VERSION (class): indicating sdk version. HTTP_METHOD_GET (class): HTTP GET method name. HTTP_METHOD_POST (class): HTTP POST method name HTTP_METHOD_DELETE (class): HTTP DELETE method name HTTP_DEFAULT_HEADERS (class): Default HTTP headers for requests made by this sdk. """ SDK_VERSION = apiconfig.ads_api_config['SDK_VERSION'] API_VERSION = apiconfig.ads_api_config['API_VERSION'] HTTP_METHOD_GET = 'GET' HTTP_METHOD_POST = 'POST' HTTP_METHOD_DELETE = 'DELETE' HTTP_DEFAULT_HEADERS = { 'User-Agent': "fbbizsdk-python-%s" % SDK_VERSION, } _default_api = None _default_account_id = None def __init__(self, session, api_version=None, enable_debug_logger=False): """Initializes the api instance. Args: session: FacebookSession object that contains a requests interface and attribute GRAPH (the Facebook GRAPH API URL). api_version: API version """ self._session = session self._num_requests_succeeded = 0 self._num_requests_attempted = 0 self._api_version = api_version or self.API_VERSION self._enable_debug_logger = enable_debug_logger def get_num_requests_attempted(self): """Returns the number of calls attempted.""" return self._num_requests_attempted def get_num_requests_succeeded(self): """Returns the number of calls that succeeded.""" return self._num_requests_succeeded @classmethod def init( cls, app_id=None, app_secret=None, access_token=None, account_id=None, api_version=None, proxies=None, timeout=None, debug=False, crash_log=True, ): session = FacebookSession(app_id, app_secret, access_token, proxies, timeout) api = cls(session, api_version, enable_debug_logger=debug) cls.set_default_api(api) if account_id: cls.set_default_account_id(account_id) if crash_log: from facebook_business.crashreporter import CrashReporter if debug: CrashReporter.enableLogging() CrashReporter.enable() return api @classmethod def set_default_api(cls, api_instance): """Sets the default api instance. When making calls to the api, objects will revert to using the default api if one is not specified when initializing the objects. Args: api_instance: The instance which to set as default. """ cls._default_api = api_instance @classmethod def get_default_api(cls): """Returns the default api instance.""" return cls._default_api @classmethod def set_default_account_id(cls, account_id): account_id = str(account_id) if account_id.find('act_') == -1: raise ValueError( "Account ID provided in FacebookAdsApi.set_default_account_id " "expects a string that begins with 'act_'", ) cls._default_account_id = account_id @classmethod def get_default_account_id(cls): return cls._default_account_id def call( self, method, path, params=None, headers=None, files=None, url_override=None, api_version=None, ): """Makes an API call. Args: method: The HTTP method name (e.g. 'GET'). path: A tuple of path tokens or a full URL string. A tuple will be translated to a url as follows: graph_url/tuple[0]/tuple[1]... It will be assumed that if the path is not a string, it will be iterable. params (optional): A mapping of request parameters where a key is the parameter name and its value is a string or an object which can be JSON-encoded. headers (optional): A mapping of request headers where a key is the header name and its value is the header value. files (optional): An optional mapping of file names to binary open file objects. These files will be attached to the request. Returns: A FacebookResponse object containing the response body, headers, http status, and summary of the call that was made. Raises: FacebookResponse.error() if the request failed. """ if not params: params = {} if not headers: headers = {} if not files: files = {} api_version = api_version or self._api_version if api_version and not re.search('v[0-9]+\.[0-9]+', api_version): raise FacebookBadObjectError( 'Please provide the API version in the following format: %s' % self.API_VERSION, ) self._num_requests_attempted += 1 if not isinstance(path, six.string_types): # Path is not a full path path = "/".join(( url_override or self._session.GRAPH, api_version, '/'.join(map(str, path)), )) # Include api headers in http request headers = headers.copy() headers.update(FacebookAdsApi.HTTP_DEFAULT_HEADERS) if params: params = _top_level_param_json_encode(params) # Get request response and encapsulate it in a FacebookResponse if method in ('GET', 'DELETE'): response = self._session.requests.request( method, path, params=params, headers=headers, files=files, timeout=self._session.timeout ) else: response = self._session.requests.request( method, path, data=params, headers=headers, files=files, timeout=self._session.timeout ) if self._enable_debug_logger: import curlify print(curlify.to_curl(response.request)) fb_response = FacebookResponse( body=response.text, headers=response.headers, http_status=response.status_code, call={ 'method': method, 'path': path, 'params': params, 'headers': headers, 'files': files, }, ) if fb_response.is_failure(): raise fb_response.error() self._num_requests_succeeded += 1 return fb_response def new_batch(self): """ Returns a new FacebookAdsApiBatch, which when executed will go through this api. """ return FacebookAdsApiBatch(api=self) class FacebookAdsApiBatch(object): """ Exposes methods to build a sequence of calls which can be executed with a single http request. Note: Individual exceptions won't be thrown for each call that fails. The success and failure callback functions corresponding to a call should handle its success or failure. """ def __init__(self, api, success=None, failure=None): self._api = api self._files = [] self._batch = [] self._success_callbacks = [] self._failure_callbacks = [] if success is not None: self._success_callbacks.append(success) if failure is not None: self._failure_callbacks.append(failure) self._requests = [] def __len__(self): return len(self._batch) def add( self, method, relative_path, params=None, headers=None, files=None, success=None, failure=None, request=None, ): """Adds a call to the batch. Args: method: The HTTP method name (e.g. 'GET'). relative_path: A tuple of path tokens or a relative URL string. A tuple will be translated to a url as follows: <graph url>/<tuple[0]>/<tuple[1]>... It will be assumed that if the path is not a string, it will be iterable. params (optional): A mapping of request parameters where a key is the parameter name and its value is a string or an object which can be JSON-encoded. headers (optional): A mapping of request headers where a key is the header name and its value is the header value. files (optional): An optional mapping of file names to binary open file objects. These files will be attached to the request. success (optional): A callback function which will be called with the FacebookResponse of this call if the call succeeded. failure (optional): A callback function which will be called with the FacebookResponse of this call if the call failed. request (optional): The APIRequest object Returns: A dictionary describing the call. """ if not isinstance(relative_path, six.string_types): relative_url = '/'.join(relative_path) else: relative_url = relative_path call = { 'method': method, 'relative_url': relative_url, } if params: params = _top_level_param_json_encode(params) keyvals = ['%s=%s' % (key, urls.quote_with_encoding(value)) for key, value in params.items()] if method == 'GET': call['relative_url'] += '?' + '&'.join(keyvals) else: call['body'] = '&'.join(keyvals) if files: call['attached_files'] = ','.join(files.keys()) if headers: call['headers'] = [] for header in headers: batch_formatted_header = {} batch_formatted_header['name'] = header batch_formatted_header['value'] = headers[header] call['headers'].append(batch_formatted_header) self._batch.append(call) self._files.append(files) self._success_callbacks.append(success) self._failure_callbacks.append(failure) self._requests.append(request) return call def add_request( self, request, success=None, failure=None, ): """Interface to add a APIRequest to the batch. Args: request: The APIRequest object to add success (optional): A callback function which will be called with the FacebookResponse of this call if the call succeeded. failure (optional): A callback function which will be called with the FacebookResponse of this call if the call failed. Returns: A dictionary describing the call. """ updated_params = copy.deepcopy(request._params) if request._fields: updated_params['fields'] = ','.join(request._fields) return self.add( method=request._method, relative_path=request._path, params=updated_params, files=request._file_params, success=success, failure=failure, request=request, ) def execute(self): """Makes a batch call to the api associated with this object. For each individual call response, calls the success or failure callback function if they were specified. Note: Does not explicitly raise exceptions. Individual exceptions won't be thrown for each call that fails. The success and failure callback functions corresponding to a call should handle its success or failure. Returns: If some of the calls have failed, returns a new FacebookAdsApiBatch object with those calls. Otherwise, returns None. """ if not self._batch: return None method = 'POST' path = tuple() params = {'batch': self._batch} files = {} for call_files in self._files: if call_files: files.update(call_files) fb_response = self._api.call( method, path, params=params, files=files, ) responses = fb_response.json() retry_indices = [] for index, response in enumerate(responses): if response: body = response.get('body') code = response.get('code') headers = response.get('headers') inner_fb_response = FacebookResponse( body=body, headers=headers, http_status=code, call=self._batch[index], ) if inner_fb_response.is_success(): if self._success_callbacks[index]: self._success_callbacks[index](inner_fb_response) elif self._failure_callbacks[index]: self._failure_callbacks[index](inner_fb_response) else: retry_indices.append(index) if retry_indices: new_batch = self.__class__(self._api) new_batch._files = [self._files[index] for index in retry_indices] new_batch._batch = [self._batch[index] for index in retry_indices] new_batch._success_callbacks = [self._success_callbacks[index] for index in retry_indices] new_batch._failure_callbacks = [self._failure_callbacks[index] for index in retry_indices] return new_batch else: return None class FacebookRequest: """ Represents an API request """ def __init__( self, node_id, method, endpoint, api=None, param_checker=TypeChecker({}, {}), target_class=None, api_type=None, allow_file_upload=False, response_parser=None, include_summary=True, api_version=None, ): """ Args: node_id: The node id to perform the api call. method: The HTTP method of the call. endpoint: The edge of the api call. api (optional): The FacebookAdsApi object. param_checker (optional): Parameter checker. target_class (optional): The return class of the api call. api_type (optional): NODE or EDGE type of the call. allow_file_upload (optional): Whether the call allows upload. response_parser (optional): An ObjectParser to parse response. include_summary (optional): Include "summary". api_version (optional): API version. """ self._api = api or FacebookAdsApi.get_default_api() self._node_id = node_id self._method = method self._endpoint = endpoint.replace('/', '') self._path = (node_id, endpoint.replace('/', '')) self._param_checker = param_checker self._target_class = target_class self._api_type = api_type self._allow_file_upload = allow_file_upload self._response_parser = response_parser self._include_summary = include_summary self._api_version = api_version self._params = {} self._fields = [] self._file_params = {} self._file_counter = 0 self._accepted_fields = [] if target_class is not None: self._accepted_fields = target_class.Field.__dict__.values() def add_file(self, file_path): if not self._allow_file_upload: api_utils.warning('Endpoint ' + self._endpoint + ' cannot upload files') file_key = 'source' + str(self._file_counter) if os.path.isfile(file_path): self._file_params[file_key] = file_path self._file_counter += 1 else: raise FacebookBadParameterError( 'Cannot find file ' + file_path + '!', ) return self def add_files(self, files): if files is None: return self for file_path in files: self.add_file(file_path) return self def add_field(self, field): if field not in self._fields: self._fields.append(field) if field not in self._accepted_fields: api_utils.warning(self._endpoint + ' does not allow field ' + field) return self def add_fields(self, fields): if fields is None: return self for field in fields: self.add_field(field) return self def add_param(self, key, value): if not self._param_checker.is_valid_pair(key, value): api_utils.warning('value of ' + key + ' might not be compatible. ' + ' Expect ' + self._param_checker.get_type(key) + '; ' + ' got ' + str(type(value))) if self._param_checker.is_file_param(key): self._file_params[key] = value else: self._params[key] = self._extract_value(value) return self def add_params(self, params): if params is None: return self for key in params.keys(): self.add_param(key, params[key]) return self def get_fields(self): return list(self._fields) def get_params(self): return copy.deepcopy(self._params) def execute(self): params = copy.deepcopy(self._params) if self._api_type == "EDGE" and self._method == "GET": cursor = Cursor( target_objects_class=self._target_class, params=params, fields=self._fields, include_summary=self._include_summary, api=self._api, node_id=self._node_id, endpoint=self._endpoint, ) cursor.load_next_page() return cursor if self._fields: params['fields'] = ','.join(self._fields) with open_files(self._file_params) as files: response = self._api.call( method=self._method, path=(self._path), params=params, files=files, api_version=self._api_version, ) if response.error(): raise response.error() if self._response_parser: return self._response_parser.parse_single(response.json()) else: return response def add_to_batch(self, batch, success=None, failure=None): batch.add_request(self, success, failure) def _extract_value(self, value): if hasattr(value, 'export_all_data'): return value.export_all_data() elif isinstance(value, list): return [self._extract_value(item) for item in value] elif isinstance(value, dict): return dict((self._extract_value(k), self._extract_value(v)) for (k, v) in value.items()) else: return value class Cursor(object): """Cursor is an cursor over an object's connections. Previously called EdgeIterator. Examples: >>> me = AdAccountUser('me') >>> my_accounts = [act for act in Cursor(me, AdAccount)] >>> my_accounts [<AdAccount act_abc>, <AdAccount act_xyz>] """ def __init__( self, source_object=None, target_objects_class=None, fields=None, params=None, include_summary=True, api=None, node_id=None, endpoint=None, object_parser=None ): """ Initializes an cursor over the objects to which there is an edge from source_object. To initialize, you'll need to provide either (source_object and target_objects_class) or (api, node_id, endpoint, and object_parser) Args: source_object: An AbstractObject instance from which to inspect an edge. This object should have an id. target_objects_class: Objects traverersed over will be initialized with this AbstractObject class. fields (optional): A list of fields of target_objects_class to automatically read in. params (optional): A mapping of request parameters where a key is the parameter name and its value is a string or an object which can be JSON-encoded. include_summary (optional): Include summary. api (optional): FacebookAdsApi object. node_id (optional): The ID of calling node. endpoint (optional): The edge name. object_parser (optional): The ObjectParser to parse response. """ self.params = dict(params or {}) target_objects_class._assign_fields_to_params(fields, self.params) self._source_object = source_object self._target_objects_class = target_objects_class self._node_id = node_id or source_object.get_id_assured() self._endpoint = endpoint or target_objects_class.get_endpoint() self._api = api or source_object.get_api() self._path = ( self._node_id, self._endpoint, ) self._queue = [] self._headers = [] self._finished_iteration = False self._total_count = None self._summary = None self._include_summary = include_summary or 'default_summary' in self.params self._object_parser = object_parser or ObjectParser( api=self._api, target_class=self._target_objects_class, ) def __repr__(self): return str(self._queue) def __len__(self): return len(self._queue) def __iter__(self): return self def __next__(self): # Load next page at end. # If load_next_page returns False, raise StopIteration exception if not self._queue and not self.load_next_page(): raise StopIteration() return self._queue.pop(0) # Python 2 compatibility. next = __next__ def __getitem__(self, index): return self._queue[index] def headers(self): return self._headers def total(self): if self._total_count is None: raise FacebookUnavailablePropertyException( "Couldn't retrieve the object total count for that type " "of request.", ) return self._total_count def summary(self): if self._summary is None or not isinstance(self._summary, dict): raise FacebookUnavailablePropertyException( "Couldn't retrieve the object summary for that type " "of request.", ) return "<Summary> %s" % ( json.dumps( self._summary, sort_keys=True, indent=4, separators=(',', ': '), ), ) def load_next_page(self): """Queries server for more nodes and loads them into the internal queue. Returns: True if successful, else False. """ if self._finished_iteration: return False if ( self._include_summary and 'default_summary' not in self.params and 'summary' not in self.params ): self.params['summary'] = True response_obj = self._api.call( 'GET', self._path, params=self.params, ) response = response_obj.json() self._headers = response_obj.headers() if 'paging' in response and 'next' in response['paging']: self._path = response['paging']['next'] self.params = {} else: # Indicate if this was the last page self._finished_iteration = True if ( self._include_summary and 'summary' in response and 'total_count' in response['summary'] ): self._total_count = response['summary']['total_count'] if self._include_summary and 'summary' in response: self._summary = response['summary'] self._queue = self.build_objects_from_response(response) return len(self._queue) > 0 def get_one(self): for obj in self: return obj return None def build_objects_from_response(self, response): return self._object_parser.parse_multiple(response) @contextmanager def open_files(files): opened_files = {} for key, path in files.items(): opened_files.update({key: open(path, 'rb')}) yield opened_files for file in opened_files.values(): file.close() def _top_level_param_json_encode(params): params = params.copy() for param, value in params.items(): if ( isinstance(value, (collections_abc.Mapping, collections_abc.Sequence, bool)) and not isinstance(value, six.string_types) ): params[param] = json.dumps( value, sort_keys=True, separators=(',', ':'), ) else: params[param] = value return params