вторник, 24 апреля 2007 г.

Интерфейс Python для Flickr API.

Некоторое время назад нужно было использовать Flickr API. Конечно, есть и готовые Python API для Flickr, но тут сработал синдром NIH и захотелось чего-то своего, понятного и простого. Публикую как иллюстрацию развития идеи от простого к усложнению, но не слишком сложному :) Опять же, как у меня часто получается, тут скорее не про Flickr, а про Python.

API у Flickr довольно жирный, но мне поначалу нужно было из него всего пару вызовов. При этом не хотелось себя сразу ограничивать этими вызовами — лучше иметь гибкий инструмент, который подходил бы в 99% случаев без изменения.

Напишем простую функцию для доступа к Flickr API:

import urllib, socket
API_ENDPOINT_URL = "http://api.flickr.com/services/rest/"
SOCKET_TIMEOUT = 5 # 5 seconds
def flickr_api_get(api_key, method, format="json", **kwargs):
if format == "json":
if kwargs.get("nojsoncallback", None) != False:
kwargs["nojsoncallback"] = "1"
else:
del(kwargs["nojsoncallback"])

kwargs.update(dict(api_key=api_key, method=method, format=format))
url = "%s?%s" % (API_ENDPOINT_URL, urllib.urlencode(kwargs))
result = None
try:
try:
old_timeout = socket.getdefaulttimeout()
socket.setdefaulttimeout(SOCKET_TIMEOUT)
result = urllib.urlopen(url)
finally:
socket.setdefaulttimeout(old_timeout)
except:
raise IOError("Could not get any results from the Flickr website")

if result:
return result.read()


Если передать функции аргумент format (из тех значений, которые поддерживаются Flickr API), то метод вернет результат в виде строки в запрошенном формате. Flickr API может возвращать результат в нескольких форматах, среди которых несколько базируются на XML и один — JSON. Я предпочитаю JSON, и установил его в качестве формата по умолчанию.

Параметр nojsoncallback — специфический для Flickr API, используется только в том случае если формат ответа — JSON. Отвечает за то, возвращать ли результат в формате JSON, обернутым в вызов JavaScript-функции. Если nojsoncallback не определен, то результат вернется в таком виде, что необходимо, если хочется использовать API из JavaScript. Если определен, то вернется "чистый" JSON, что нам и нужно в большинстве случаев (и такое поведение сделано по умолчанию, если все же хотите его переопределить, передайте аргумент nojsoncallback со значением False).

Вызывать ее просто.

API_KEY = 'your_flickr_api_key'
res = flickr_api_get(API_KEY, "flickr.people.findByUserName", username="my_flickr_username")
print res


Всё? Нет, я на этом не успокоился.

Кроме результата в виде строки, хочется иметь возможность получать результат в виде разобранной структуры данных Python. Сделаем так, что если в аргументах формат не передан, то запрос идет в формате по умолчанию — "json", а затем результат переводится в структуры данных python при помощи пакета simplejson (также доступен из дистрибуции Django). Класс FlickrResponseJSONDecoder как раз и занимается этим переводом. Сделано это так для того, чтобы можно было поменять формат по умолчанию и класс-транслятор позже, если понадобится.

try:
from django.utils import simplejson
except ImportError:
import simplejson

class FlickrResponseJSONDecoder(object):
def __init__(self, data):
self.data = data

def decode(self):
datadict = {}
try:
datadict = simplejson.loads(self.data)
except:
raise

return datadict

def flickr_api_get(api_key, method, format=None, _decoder=FlickrResponseJSONDecoder, _default_format="json", **kwargs):
decodeResponse = False
if not format:
format = _default_format
decodeResponse = True

if format == "json":
if kwargs.get("nojsoncallback", None) != False or decodeResponse:
kwargs["nojsoncallback"] = "1"
elif kwargs.has_key("nojsoncallback"):
del(kwargs["nojsoncallback"])

kwargs.update(dict(api_key=api_key, method=method, format=format))
url = "%s?%s" % (API_ENDPOINT_URL, urllib.urlencode(kwargs))
result = None
try:
try:
old_timeout = socket.getdefaulttimeout()
socket.setdefaulttimeout(SOCKET_TIMEOUT)
result = urllib.urlopen(url)
finally:
socket.setdefaulttimeout(old_timeout)
except:
raise IOError("Could not get any results from the Flickr website")

if result:
resp = result.read()
if decodeResponse:
resp = _decoder(resp).decode()
return resp


После вызова этой функции без аргумента format функция вернет уже не строку в формате JSON, а словарь python, что удобно если мы собираемся обрабатывать эти данные дальше не в JavaScript, а в нашей программе.

Едем дальше. Формат вызовов Flickr API очень похож на формат вызовов атрибутов объектов в python, например flickr.people.findByUsername. Почему же не использовать эту приятную особенность?

Для этого можно использовать "магические" методы __getattr__ и __call__. Первый будем использовать для определения имени метода API и конфигурации соответствующего объекта, второй — для вызова метода (фактически — вызова самого экземпляра класса).
В результате получился вот такой код (привожу почти полностью, пояснения ниже):

class Flickr(object):
_decoder = FlickrResponseJSONDecoder
_default_format = "json"
def __init__(self, api_key=None, method="flickr", **kwargs):
# some sanity checks
if not api_key and not kwargs.get("api_key", None):
raise ValueError("the api_key should not be empty")
if not method and not kwargs.get("method", None):
raise ValueError("the method should not be empty")

self._args = dict(method=method, api_key=api_key)
self._args.update(kwargs)

def __getattr__(self, attr):
args = self._args.copy()
args["method"] = "%s.%s" % (args["method"], attr)
return self.__class__(**args)

def __call__(self, **kwargs):
args = self._args.copy()
args.update(kwargs)

# should we decode response to get python objects, or return raw response?
decodeResponse = False

if not args.get("format", None):
args["format"] = self._default_format
decodeResponse = True

if args["format"] == "json":
if args.get("nojsoncallback", None) != False or decodeResponse:
args["nojsoncallback"] = "1"
elif args.has_key("nojsoncallback"):
del(args["nojsoncallback"])

url = self._makeUrl(**args)

result = None
try:
try:
old_timeout = socket.getdefaulttimeout()
socket.setdefaulttimeout(SOCKET_TIMEOUT)
result = urllib.urlopen(url)
finally:
socket.setdefaulttimeout(old_timeout)
except:
raise IOError("Could not get any results from the Flickr website")

if result:
resp = result.read()
if decodeResponse:
resp = self._decoder(resp).decode()
return resp

def _makeUrl(self, **kwargs):
url = "%s?%s" % (API_ENDPOINT_URL, urllib.urlencode(kwargs))
return url


Теперь вызовы API можно делать уже так:

API_KEY = 'your_flickr_api_key'
flickr = Flickr(api_key=API_KEY)
res = flickr.people.findByUserName(username='flickr_username')
user_id = res['user']['id']
res = flickr.people.getPublicPhotos(user_id=user_id)
photo = res["photos"]["photo"][0]


Как видите, класс не определяет весь API, зато позволяет вызвать любой метод с соответствующими параметрами. При отсутствие нужного метода в объекте, в __getattr__ создается новый объект того же класса, в его конструктор передаются те же аргументы, что были переданы в "вызывающий" объект, при этом изменяется имя метода Flickr API. В результате при попытке доступа к flickr.people.findByUserName мы получаем объект класса Flickr, содержащий тот же API Key, что был передан при инициализации объекта в самом начале, и название метода "flickr.people.findByUserName". При вызове этого объекта на выполнение (сработает метод __call__, который практически повторяет логику функции flickr_api_get), в вызываемую API URL попадут API Key, текущее имя метода, а также все параметры, которые были переданы при вызове.

Наверное, возникает закономерный вопрос — чего мы добились в итоге? Чем наш API, обернутый в объектный интерфейс лучше первоначального варианта с функцией, кроме как то, что он позволяет вызывать методы API более "красиво"?

Как вы уже наверняка поняли, класс Flickr представляет собой довольно низкоуровневый интерфейс. Он не занимается обработкой данных, которые проходят в ответах вызовов API, как успешных, так и содержащих информацию об ошибке. Он вообще не вдается в детали и структуру возвращаемых данных. Вся эта обработка возлагается на код приложения, которое использует этот интерфейс.

Тут и появляется основное, на мой взгляд, преимущество такой объектной обертки. Она позволяет нам расширять API, используя всю мощь Python. Класс Flickr можно расширить своей логикой, определив специальную обработку для некоторых вызовов.
Для начала определим еще один метод в классе Flickr, при помощи которого можно будет конфигурировать экземпляры этого класса, выступающие в качестве пространств имен Flickr API:

    def __get__(self, instance, owner):
if instance and isinstance(instance, Flickr):
parent_args = instance._args.copy()
parent_args.pop("method")
self._args.update(parent_args)

return self


Чтобы понять, зачем так, сразу же покажу пример.

class FlickrPeople(Flickr):
def findByUserName(self, username, **kwargs):
res = self.__call__(method="%s.findByUserName" % self._args["method"], username=username, **kwargs)
if res and isinstance(res, dict):
if res['stat'] != 'ok':
return None
return res['user']['id']
else:
return res

Flickr.people = FlickrPeople(api_key="nonexistent", method="flickr.people")


Таким образом, мы переопределили обработку пространства имен flickr.people Flickr API — назначили для этого атрибут people класса Flickr, который, в свою очередь, является экземпляром подкласса Flickr. При вызове несуществующего атрибута, как мы помним, у нас вызыватеся __getattr__. Для вызова существующего, в данном случае, people, будет вызван __get__ в соответствующем объекте-атрибуте. Тут пригодились дескрипторы.

Объект класса FlickrPeople может получать начальную конфигурацию как при вызове __init__, как Flickr, так и при вызове метода __get__, который будет произведен при доступе к атрибуту people экземпляра класса Flickr. При инициализации атрибута people API key пока что неизвестен, поэтому я устанавливаю его в заведомо неверный. Но при доступе к атрибуту правильный ключ будет получен от родительского объекта.

flickr = Flickr(api_key=API_KEY)
user_id = flickr.people.findByUserName(username='flickr_username')
res = flickr.people.getPublicPhotos(user_id=user_id)
photo = res["photos"]["photo"][0]


Вызов API findByUserName определен как метод класса FlickrPeople. Он уже знает о возможной структуре данных, которые вернет вызов API, принимает соответствующие решения и обрабатывает эти данные, возвращая в нашем случае удобное нам значение user_id вместо структуры данных из результата вызова API, либо None, если произошла ошибка. Все остальные вызовы в пространстве имен flickr.people, как и все вызовы за пределами этого пространства, происходят обычным образом, без дополнительной обработки, возвращая структуры данных результата вызова API.

Класс Flickr пока не содержит одного важного метода, при помощи которого можно получить URL картинки. Информация о изображении возвращается методом API не в виде URL, а в виде нескольких параметров. Подробнее об этом - на соответствующей странице. Напишем этот метод.

    @classmethod
def getImageUrl(cls, farm, server="", id="", secret="", size="", originalformat="", originalsecret="", **kwargs):
'''http://farm{farm-id}.static.flickr.com/{server-id}/{id}_{secret}.jpg
http://farm{farm-id}.static.flickr.com/{server-id}/{id}_{secret}_[mstb].jpg
http://farm{farm-id}.static.flickr.com/{server-id}/{id}_{o-secret}_o.(jpg|gif|png)
'''

if isinstance(farm, dict): # photo dict passed instead of an integer in the farm argument
kw = dict(server=server, id=id, secret=secret, size=size,
originalformat=originalformat, originalsecret=originalsecret)
for key, val in kw.items():
kw[key] = val or farm.get(key, "")
kw['farm'] = farm['farm']
return cls.getImageUrl(**kw)

if size:
size = "_"+size
else:
size = ""
format = originalformat or "jpg"
secret = originalsecret or secret

return "http://farm%s.static.flickr.com/%s/%s_%s%s.%s" % (farm, server, id, secret, size, format)


Продолжим пример использования, показанный выше.

photo = res["photos"]["photo"][0]
print flickr.getImageUrl(photo, size="s")


Методу можно передать все аргументы по отдельности, либо в первом аргументе передать словарь с аргументами (такой словарь как раз получается при выборе данных об изображении из результата вызова метода API, например flickr.people.getPublicPhotos или других подобных).

1 комментарий:

Постоянные читатели