├── .gitignore ├── Makefile ├── README.md ├── base.cfg ├── buildout.cfg ├── flaskext ├── __init__.py └── fbapi │ ├── __init__.py │ ├── api.py │ ├── decorators.py │ ├── omnivore.py │ ├── storage │ ├── __init__.py │ └── redis.py │ ├── templates │ └── oauth_redir.html │ └── views.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | bootstrap.py 2 | /bin 3 | /develop-eggs 4 | /eggs 5 | /*.egg-info 6 | /parts 7 | /bootstrap.py 8 | /.installed.cfg 9 | /.project 10 | /.pydevproject 11 | *~ 12 | *.pyc 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL = development 2 | .PHONY = development clean 3 | ARCHFLAGS = "-arch i386 -arch x86_64" 4 | 5 | 6 | bootstrap.py : 7 | wget http://svn.zope.org/*checkout*/zc.buildout/trunk/bootstrap/bootstrap.py 8 | 9 | bin/buildout : bootstrap.py 10 | python bootstrap.py -d 11 | 12 | development : bin/buildout 13 | env ARCHFLAGS=${ARCHFLAGS} bin/buildout 14 | 15 | clean : 16 | rm -rf bin develop-eggs eggs parts .installed.cfg downloads bootstrap.py 17 | find . -name "*~" -exec rm {} \; 18 | find . -name "DEADJOE" -exec rm {} \; 19 | find . -name "*.pyc" -exec rm {} \; 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Flask Facebook API 2 | ================== 3 | 4 | Provides 5 | 6 | - Server side authentication & permissions request for Facebook canvas apps 7 | - Helper methods for FQL and graph api `flaskext.fbapi.api` `fbapi_get_*` 8 | - Simple query profiling decorator `duration_dump` 9 | - Function retry decorator (useful for FB API calls) `retry_on_exception` 10 | - Application access token retrieval `fbapi_get_application_access_token` 11 | 12 | Configuration 13 | ---- 14 | Configuration is taken from current_app.config. 15 | 16 | FBAPI_SCOPE - list/tuple of access privileges 17 | FBAPI_APP_URI - URI for our canvas app through Facebook 18 | FBAPI_APP_ID 19 | FBAPI_APP_SECRET 20 | FBAPI_ACCESS_TOKEN_STORAGE - defaults to redis store 21 | FBAPI_REDIS_DB - defaults to 1 22 | FBAPI_REDIS_HOST - defaults to localhost 23 | FBAPI_REDIS_PORT - defaults to 6379 24 | 25 | Usage 26 | ----- 27 | Initialization 28 | 29 | from flaskext.fbapi import FbApi 30 | 31 | def main(): 32 | app.config.from_object(config.DevelopmentConfig) 33 | FbApi(app) 34 | app.run(host='0.0.0.0', port=8000) 35 | 36 | Usage. All view methods that use access_token, initiate/terminate oauth shall be wrapped with `fbapi_authentication_required` decorator. 37 | 38 | @app.route('/',methods=['GET','POST']) 39 | @fbapi_authentication_required 40 | def select_match(): 41 | return 'Select Match' 42 | 43 | Access_token storage 44 | ------ 45 | Some backend is required to store access_tokens. At this point one is implemented: `flaskext.fbapi.storage.redis`. In case of different redis schema or a different backend please define a new class and configure FbApi. 46 | 47 | -------------------------------------------------------------------------------- /base.cfg: -------------------------------------------------------------------------------- 1 | [buildout] 2 | extensions = buildout.dumppickedversions 3 | develop = . 4 | parts = 5 | scripts 6 | omelette 7 | test 8 | versions = versions 9 | 10 | eggs = ipython 11 | 12 | [omelette] 13 | recipe = collective.recipe.omelette 14 | eggs = ${buildout:eggs} 15 | #location = omlette 16 | ignore-develop = True 17 | 18 | [python] 19 | executable = /usr/local/bin/python 20 | 21 | [fabric] 22 | recipe = zc.recipe.egg 23 | 24 | [scripts] 25 | recipe = zc.recipe.egg 26 | eggs = 27 | ${buildout:eggs} 28 | interpreter = python 29 | dependent-scripts = true 30 | 31 | [test] 32 | recipe = pbp.recipe.noserunner 33 | eggs = ${buildout:eggs} 34 | defaults = -v 35 | -------------------------------------------------------------------------------- /buildout.cfg: -------------------------------------------------------------------------------- 1 | [buildout] 2 | extends = base.cfg 3 | eggs += flask-fbapi 4 | newest = false 5 | 6 | [versions] 7 | 8 | -------------------------------------------------------------------------------- /flaskext/__init__.py: -------------------------------------------------------------------------------- 1 | __import__('pkg_resources').declare_namespace(__name__) 2 | -------------------------------------------------------------------------------- /flaskext/fbapi/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | required configuration in app.config 3 | 4 | # FBAPI_SCOPE - list/tuple of access privelages 5 | # FBAPI_APP_URI - URI for our canvas app through facebook 6 | # FBAPI_APP_ID 7 | # FBAPI_APP_SECRET 8 | # FBAPI_ACCESS_TOKEN_STORAGE - defaults to redis store 9 | # FBAPI_REDIS_DB - defaults to 1 10 | # FBAPI_REDIS_HOST - defaults to localhost 11 | # FBAPI_REDIS_PORT - defaults to 6379 12 | """ 13 | 14 | from flaskext.fbapi.omnivore import FbApi 15 | from flaskext.fbapi.decorators import fbapi_authentication_required, retry_on_exception, duration_dump 16 | -------------------------------------------------------------------------------- /flaskext/fbapi/api.py: -------------------------------------------------------------------------------- 1 | import base64, hashlib, hmac 2 | import simplejson as json 3 | import urllib, urllib2 4 | from pprint import pformat 5 | from flask import current_app, request 6 | from datetime import datetime 7 | 8 | 9 | class FacebookApiException(Exception): 10 | def __init__(self, api_response): 11 | self.api_response = api_response 12 | 13 | def __str__(self): 14 | return "%s: %s" % (self.__class__.__name__, json.dumps(self.api_response)) 15 | 16 | def oauth_login_url(preserve_path=True, next_url=None): 17 | """ 18 | returns oauth dialog url 19 | if next_url is defined than it overrides preserve_path 20 | if preserve_path is set to True (default) than FBAPI_APP_URI/request.path is used 21 | """ 22 | FBAPI_SCOPE = current_app.config['FBAPI_SCOPE'] 23 | FBAPI_APP_URI = current_app.config['FBAPI_APP_URI'] 24 | FBAPI_APP_ID = current_app.config['FBAPI_APP_ID'] 25 | 26 | if next_url: 27 | redirect_uri = next_url 28 | else: 29 | if preserve_path: 30 | #as the user is redirected through _top we need an url within facebook domain 31 | redirect_uri = FBAPI_APP_URI + request.path[1:] 32 | else: 33 | redirect_uri = FBAPI_APP_URI 34 | 35 | fb_login_uri = "https://www.facebook.com/dialog/oauth?client_id=%s&redirect_uri=%s" % (FBAPI_APP_ID, redirect_uri) 36 | if FBAPI_SCOPE: 37 | fb_login_uri += "&scope=%s" % ",".join(FBAPI_SCOPE) 38 | return fb_login_uri 39 | 40 | 41 | def base64_url_decode(data): 42 | data = data.encode(u'ascii') 43 | data += '=' * (4 - (len(data) % 4)) 44 | return base64.urlsafe_b64decode(data) 45 | 46 | 47 | def base64_url_encode(data): 48 | return base64.urlsafe_b64encode(data).rstrip('=') 49 | 50 | 51 | def simple_dict_serialisation(params): 52 | return "&".join(map(lambda k: "%s=%s" % (k, params[k]), params.keys())) 53 | 54 | 55 | def parse_signed_request(signed_request): 56 | FBAPI_APP_SECRET = current_app.config['FBAPI_APP_SECRET'] 57 | encoded_sig, payload = signed_request.split('.', 1) 58 | 59 | sig = base64_url_decode(encoded_sig) 60 | data = json.loads(base64_url_decode(payload)) 61 | 62 | if data.get('algorithm').upper() != 'HMAC-SHA256': 63 | current_app.logger.error('Unknown algorithm for signed request') 64 | return None 65 | else: 66 | expected_sig = hmac.new(FBAPI_APP_SECRET, msg=payload, digestmod=hashlib.sha256).digest() 67 | 68 | if sig == expected_sig: 69 | current_app.logger.debug("signed_request: %s" % pformat(data)) 70 | return data 71 | else: 72 | current_app.logger.error('Invalid signed request received!') 73 | return None 74 | 75 | 76 | def is_valid_signed_request(signed_request): 77 | try: 78 | return signed_request['user_id'] and signed_request['expires'] and signed_request['oauth_token'] 79 | except: 80 | return False 81 | 82 | def is_deauthorize_signed_request(signed_request): 83 | try: 84 | return signed_request['user_id'] and not signed_request.has_key('oauth_token') 85 | except: 86 | return False 87 | 88 | 89 | def fbapi_get_string(path, domain=u'graph', params=None, access_token=None, encode_func=urllib.urlencode): 90 | """Make an API call""" 91 | if not params: 92 | params = {} 93 | params[u'method'] = u'GET' 94 | if access_token: 95 | params[u'access_token'] = access_token 96 | 97 | for k, v in params.iteritems(): 98 | if hasattr(v, 'encode'): 99 | params[k] = v.encode('utf-8') 100 | 101 | url = u'https://' + domain + u'.facebook.com' + path 102 | params_encoded = encode_func(params) 103 | url = url + params_encoded 104 | current_app.logger.debug("FBAPI request: %s" % url) 105 | result = urllib2.urlopen(url).read() 106 | current_app.logger.debug("FBAPI response: %s" % result) 107 | 108 | return result 109 | 110 | 111 | def fbapi_get_json(path, domain=u'graph', params=None, access_token=None, encode_func=urllib.urlencode): 112 | """Make an API call (json)""" 113 | if not params: 114 | params = {} 115 | if format: 116 | params[u'format'] = u'json' 117 | 118 | unparsed_result = fbapi_get_string(path, domain=domain, params=params, access_token=access_token, encode_func=encode_func) 119 | try: 120 | result = json.loads(unparsed_result) 121 | current_app.logger.debug("FBAPI parsed response: \n%s" % pformat(result)) 122 | except JSONDecodeError: 123 | #add log request 124 | current_app.logger.error("FBAPI not a json response") 125 | raise FacebookApiException(result) 126 | finally: 127 | if u'error' in result or u'error_code' in result: 128 | raise FacebookApiException(result) 129 | 130 | return result 131 | 132 | 133 | def fbapi_get_fql(path, params=None, access_token=None, encode_func=urllib.urlencode, format=u"json"): 134 | """Make an FQL API call""" 135 | result = fbapi_get_json(path=u"/method/fql.query?", domain=u"api", params=params, access_token=access_token, encode_func=encode_func) 136 | 137 | return result 138 | 139 | 140 | def fbapi_get_fql_multiquery(params=None, access_token=None, encode_func=urllib.urlencode, format="json"): 141 | """Make a multiquery FQL API call""" 142 | result = fbapi_get_json(path=u"/method/fql.multiquery?", domain=u"api", params=params, access_token=access_token, encode_func=encode_func) 143 | 144 | return result 145 | 146 | 147 | def fbapi_auth(code): 148 | """ 149 | returns (access_token, expires) 150 | """ 151 | FBAPI_APP_URI = current_app.config['FBAPI_APP_URI'] 152 | FBAPI_APP_ID = current_app.config['FBAPI_APP_ID'] 153 | FBAPI_APP_SECRET = current_app.config['FBAPI_APP_SECRET'] 154 | 155 | params = {'client_id':FBAPI_APP_ID, 156 | 'redirect_uri':FBAPI_APP_URI, 157 | 'client_secret':FBAPI_APP_SECRET, 158 | 'code':code} 159 | 160 | result = fbapi_get_string(path=u"/oauth/access_token?", params=params, encode_func=simple_dict_serialisation) 161 | pairs = result.split("&", 1) 162 | result_dict = {} 163 | for pair in pairs: 164 | (key, value) = pair.split("=") 165 | result_dict[key] = value 166 | 167 | return (result_dict["access_token"], result_dict["expires"]) 168 | 169 | 170 | def fbapi_get_application_access_token(id): 171 | FB_APP_SECRET = current_app.config['FB_APP_SECRET'] 172 | token = fbapi_get_string(path=u"/oauth/access_token", params=dict(grant_type=u'client_credentials', client_id=id, client_secret=FB_APP_SECRET), domain=u'graph') 173 | token = token.split('=')[-1] 174 | if not str(id) in token: 175 | current_app.logger.error('Token mismatch: %s not in %s', id, token) 176 | return token 177 | -------------------------------------------------------------------------------- /flaskext/fbapi/decorators.py: -------------------------------------------------------------------------------- 1 | from flaskext.fbapi.api import parse_signed_request, is_valid_signed_request, is_deauthorize_signed_request, fbapi_auth, oauth_login_url 2 | from functools import wraps 3 | from flask import current_app, request, session, g, render_template 4 | from datetime import datetime 5 | from flask import _request_ctx_stack 6 | 7 | 8 | 9 | def _handle_signed_request(): 10 | """ 11 | parses signed_request 12 | """ 13 | ctx = _request_ctx_stack.top 14 | token_storage = ctx.token_storage 15 | 16 | signed_request = request.form.get('signed_request', None) 17 | if signed_request: #attached to request 18 | current_app.logger.debug("signed_request data: '%s'" % signed_request) 19 | fb_signed_request = parse_signed_request(signed_request) 20 | 21 | if is_valid_signed_request(fb_signed_request): #let's use only perfect signed_request 22 | g.fb_signed_request = fb_signed_request 23 | g.fb_user_id = fb_signed_request['user_id'] 24 | session['fb_user_id'] = fb_signed_request['user_id'] 25 | 26 | expires = fb_signed_request['expires'] #TODO: change date to delta seconds 27 | access_token = fb_signed_request['oauth_token'] 28 | token_storage.save(g.fb_user_id, access_token, expires) 29 | g.fb_access_token = access_token 30 | 31 | session.modified = True 32 | 33 | elif is_deauthorize_signed_request(fb_signed_request): 34 | fb_user_id = fb_signed_request['user_id'] 35 | current_app.logger.info("signed_request deauthorized user: %s" % fb_user_id) 36 | token_storage.deauthorize(fb_user_id) 37 | return oauth_redir_render() #output is ignored by facebook so sending oauth request is just a formality 38 | 39 | else: 40 | current_app.logger.error("signed_request invalid (can be HACKING attempt)") 41 | return oauth_redir_render() 42 | #TODO: deauthorized case 43 | 44 | 45 | def _handle_session(): 46 | """ 47 | makes sure that g.fb_user_id is defined 48 | """ 49 | if not hasattr(g, "fb_user_id") or not g.fb_user_id: 50 | g.fb_user_id = session.get("fb_user_id", None) 51 | if not g.fb_user_id: 52 | return oauth_redir_render() 53 | current_app.logger.debug("fb_user_id: %s" % g.fb_user_id) 54 | 55 | 56 | def _handle_oauth_response(): 57 | """ 58 | requres g.fb_user_id 59 | we may already have access_token from signed_request but as we may get a new one during oauth than let's use the new one 60 | """ 61 | error = request.args.get("error", None) 62 | code = request.args.get("code", None) 63 | if error: #potential error response from oauth flow 64 | error_reason = request.args.get("error_reason", None) 65 | error_description = request.args.get("error_description", None) 66 | current_app.logger.error("oauth response error\n error: '%s'\n error_reason: '%s'\nerror_desctiption: '%s'" % (error, error_reason, error_description)) 67 | #TODO: add signal oauth_failed 68 | return oauth_redir_render() 69 | 70 | if code: #it's time to authenticate the app 71 | current_app.logger.debug("oauth response code: '%s'" % code) 72 | try: 73 | (access_token, expires) = fbapi_auth(code) 74 | current_app.logger.debug("fbapi_auth response (%s,%s)" % (access_token, expires)) 75 | token_storage.save(g.fb_user_id, access_token, expires) 76 | g.fb_access_token = access_token 77 | 78 | except Exception as e: 79 | current_app.logger.debug("fbapi_auth exception on app authentication: %s" % e) 80 | return oauth_redir_render() 81 | 82 | 83 | def _handle_storage_fallback(): 84 | """ 85 | makes sure that we have access_token (I.e. using session used_id get one from Storage) 86 | """ 87 | if not hasattr(g, "fb_access_token") or not g.fb_access_token: 88 | token_storage.load(g.fb_user_id) 89 | return 90 | 91 | if not g.fb_access_token: 92 | current_app.logger.debug("redis fallback: no active access_token in storage") 93 | return oauth_redir_render() 94 | 95 | 96 | def fbapi_authentication_required(func): 97 | @wraps(func) 98 | def inner(*args, **kwargs): 99 | result = _handle_signed_request() 100 | if result is not None: 101 | return result 102 | 103 | result = _handle_session() 104 | if result is not None: 105 | return result 106 | 107 | result = _handle_oauth_response() 108 | if result is not None: 109 | return result 110 | 111 | result = _handle_storage_fallback() 112 | if result is not None: 113 | return result 114 | 115 | return func(*args, **kwargs) 116 | return inner 117 | 118 | 119 | def oauth_redir_render(): 120 | """ 121 | renders the page that requests facebook auth 122 | """ 123 | return render_template(u'oauth_redir.html', fb_oauth_uri=oauth_login_url()) 124 | 125 | 126 | def retry_on_exception(func, retries): 127 | """ 128 | decorator to retry existing function on any exception 129 | number of retries can be passed as an argument retries 130 | usage: api_retry(api_string,params={},retries=1) 131 | """ 132 | @wraps(func) 133 | def retry(*args, **kwargs): 134 | done = False 135 | while retries >= 0 and not done: 136 | try: 137 | result = func(*args, **kwargs) 138 | return result 139 | except Exception as e: 140 | retries = retries - 1 141 | current_app.logger.info("Exception: %s" % e) 142 | raise e 143 | return retry 144 | 145 | 146 | def duration_dump(func): 147 | """ 148 | decorator to measure function duration 149 | """ 150 | @wraps(func) 151 | def duration(*args, **kwargs): 152 | ts = datetime.now() 153 | result = func(*args, **kwargs) 154 | te = datetime.now() 155 | print 'timestamp end' 156 | print '%r (%r, %r) %s sec' % (func.__name__, args, kwargs, te - ts) 157 | return result 158 | 159 | return duration 160 | -------------------------------------------------------------------------------- /flaskext/fbapi/omnivore.py: -------------------------------------------------------------------------------- 1 | from flask import _request_ctx_stack 2 | from flaskext.fbapi.storage import redis 3 | from flask import _request_ctx_stack 4 | 5 | class FbApi(object): 6 | """ 7 | Extension to Flask app to handle storage open/close 8 | 9 | All views that require access_token, initiate/terminate oauth shall be decorated using 10 | 11 | @app.route(your_url,methods=['GET','POST']) 12 | @fbapi_authentication_required 13 | """ 14 | 15 | def __init__(self, app=None): 16 | if app is not None: 17 | self.app = app 18 | self.init_app(self.app) 19 | else: 20 | self.app = None 21 | 22 | 23 | def init_app(self, app): 24 | app.config.setdefault('FBAPI_ACCESS_TOKEN_STORAGE', redis.AccessTokenStore) 25 | assert app.config.has_key('FBAPI_SCOPE') 26 | assert app.config.has_key('FBAPI_APP_URI') 27 | assert app.config.has_key('FBAPI_APP_ID') 28 | assert app.config.has_key('FBAPI_APP_SECRET') 29 | 30 | self.initialize_access_token_store(app) 31 | 32 | app.teardown_request(self.teardown_request) 33 | app.before_request(self.before_request) 34 | 35 | 36 | def initialize_access_token_store(self, app): 37 | self.token_storage = app.config['FBAPI_ACCESS_TOKEN_STORAGE'](self.app) 38 | 39 | 40 | def before_request(self): 41 | ctx = _request_ctx_stack.top 42 | ctx.token_storage = self.token_storage 43 | self.token_storage.open() 44 | 45 | 46 | def teardown_request(self, exception): 47 | self.token_storage.close() 48 | ctx = _request_ctx_stack.top 49 | ctx.token_storage = None 50 | -------------------------------------------------------------------------------- /flaskext/fbapi/storage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/munhitsu/flask-fbapi/916c382a3ff80610c6058d1856f025c1a6bcb27c/flaskext/fbapi/storage/__init__.py -------------------------------------------------------------------------------- /flaskext/fbapi/storage/redis.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from flask import _request_ctx_stack 3 | import redis 4 | 5 | 6 | class AccessTokenStore(object): 7 | """ 8 | Decomposed layer to simplify access_token storage selection. 9 | Uses ctx.redis_fb as a storage 10 | 11 | object is one per Application 12 | context is keept in request context 13 | 14 | """ 15 | 16 | def __init__(self, app): 17 | # let's use app only to get cofig and not store it 18 | self.config_redis_host = app.config.get("FBAPI_REDIS_HOST", 'localhost') 19 | self.config_redis_port = app.config.get("FBAPI_REDIS_PORT", 6379) 20 | self.config_redis_db = app.config.get("FBAPI_REDIS_DB", 1) 21 | 22 | def open(self): 23 | ctx = _request_ctx_stack.top 24 | ctx.redis_fb = redis.Redis(host=self.config_redis_host, port=self.config_redis_port, db=self.config_redis_db) 25 | 26 | def close(self): 27 | ctx = _request_ctx_stack.top 28 | ctx.redis_fb = None 29 | 30 | def _get_db(self): 31 | ctx = _request_ctx_stack.top 32 | if ctx is not None: 33 | return ctx.redis_fb 34 | 35 | def save(self, user_id, access_token, expires): 36 | redis_fb = self._get_db() 37 | redis_fb.setex(user_id, access_token, expires) #set's key with expiry 38 | redis_fb.sadd('authorized_users', user_id) #extends users set, WARNING potential bottleneck 39 | 40 | def load(self, user_id): 41 | redis_fb = self._get_db() 42 | redis_fb.get(user_id) 43 | 44 | def deauthorize(self, user_id): 45 | redis_fb = self._get_db() 46 | redis_fb.sdel('authorized_users', user_id) 47 | redis_fb.delete(user_id) 48 | redis_fb.sadd('deauthorized_users', user_id) 49 | -------------------------------------------------------------------------------- /flaskext/fbapi/templates/oauth_redir.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /flaskext/fbapi/views.py: -------------------------------------------------------------------------------- 1 | from flaskext.fbapi.api import oauth_login_url 2 | from flaskext.fbapi.decorators import fbapi_authentication_required 3 | 4 | #TODO: not crucial but nice to have landing view 5 | #@app.route('/oauth/',methods=['GET','POST']) 6 | def oauth_redirect_view(): 7 | return render_template(u'oauth_redir.html',fb_oauth_uri=oauth_login_url()) 8 | 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | 5 | setup( 6 | name='Flask-FBAPI', 7 | version='0.1.7-alpha', 8 | url='http://github.com/munhitsu/flask-fbapi', 9 | license='BSD', 10 | author='Mateusz Lapsa-Malawski', 11 | author_email='mateusz@munhitsu.com', 12 | description='Facebook API for Flask (http://flask.pocoo.org) based applications', 13 | long_description=__doc__, 14 | packages=['flaskext'], 15 | namespace_packages=['flaskext'], 16 | zip_safe=False, 17 | platforms='any', 18 | test_suite="nose.collector", 19 | # package_dir={'': 'src'}, 20 | install_requires=[ 21 | 'flask', 22 | 'redis', 23 | 'simplejson', 24 | ], 25 | classifiers=[ 26 | 'Environment :: Web Environment', 27 | 'Intended Audience :: Developers', 28 | 'License :: OSI Approved :: BSD License', 29 | 'Operating System :: OS Independent', 30 | 'Programming Language :: Python', 31 | 'Programming Language :: Python :: 2', 32 | 'Programming Language :: Python :: 2.5', 33 | 'Programming Language :: Python :: 2.6', 34 | 'Programming Language :: Python :: 2.7', 35 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 36 | 'Topic :: Software Development :: Libraries :: Python Modules', 37 | ], 38 | ) 39 | --------------------------------------------------------------------------------