├── .gitignore ├── MANIFEST.in ├── README.md ├── REQUIREMENTS ├── REQUIREMENTS.django ├── UNLICENSE ├── distribute_setup.py ├── setup.py ├── src └── facegraph │ ├── __init__.py │ ├── api.py │ ├── fql.py │ ├── graph.py │ └── url_operations.py └── tests ├── __init__.py └── test_url_operations.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | *.pyo 4 | *.sqlite3 5 | .DS_Store 6 | build 7 | dist 8 | local_settings.py 9 | MANIFEST 10 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include distribute_setup.py 2 | include REQUIREMENTS 3 | include REQUIREMENTS.* 4 | include README.md 5 | include UNLICENSE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyFaceGraph 2 | 3 | pyFaceGraph is a Python client library for the [Facebook Graph API][api]. It is 4 | being developed and maintained by [iPlatform][]. 5 | 6 | [api]: http://developers.facebook.com/docs/api 7 | [iplatform]: http://theiplatform.com/ 8 | 9 | 10 | ## Installation 11 | 12 | Via `pip` or `easy_install`: 13 | 14 | pip install pyfacegraph 15 | easy_install pyfacegraph 16 | 17 | You can install an 'edge' version via git: 18 | 19 | git clone 'git://github.com/iplatform/pyFaceGraph.git' 20 | cd pyFaceGraph 21 | python setup.py install 22 | 23 | 24 | ## Graph API Tutorial 25 | 26 | To begin using the API, create a new `Graph` with an access token: 27 | 28 | >>> from facegraph import Graph 29 | >>> g = Graph(ACCESS_TOKEN) # Access token is optional. 30 | >>> g 31 | 32 | 33 | 34 | ### Addressing Nodes 35 | 36 | Each `Graph` contains an access token and a URL. The graph you just created 37 | will have a URL of 'https://graph.facebook.com/' by default (this is defined as 38 | the class attribute `Graph.API_ROOT`). 39 | 40 | >>> print g.url 41 | https://graph.facebook.com/ 42 | >>> unicode(g.url) 43 | u'https://graph.facebook.com/' 44 | 45 | To address child nodes, `Graph` supports dynamic attribute and item lookups: 46 | 47 | >>> g.me 48 | 49 | >>> g.me.home 50 | 51 | >>> g['me']['home'] 52 | 53 | >>> g[123456789] 54 | 55 | 56 | Note that a `Graph` instance is rarely modified; these methods all return copies 57 | of the original object. In addition, the API is lazy: HTTP requests will 58 | never be made unless you explicitly make them. 59 | 60 | 61 | ### Retrieving Nodes 62 | 63 | You can fetch data by calling a `Graph` instance: 64 | 65 | >>> about_me = g.me() 66 | >>> about_me 67 | Node({'about': '...', 'id': '1503223370'}) 68 | 69 | This returns a `Node` object, which contains the retrieved data. `Node` is 70 | a subclass of `bunch.Bunch` [[pypi](http://pypi.python.org/pypi/bunch)], so you 71 | can access keys using either attribute or item syntax: 72 | 73 | >>> about_me.id 74 | '1503223370' 75 | >>> about_me.first_name 76 | 'Zachary' 77 | >>> about_me.hometown.name 78 | 'London, United Kingdom' 79 | >>> about_me['hometown']['name'] 80 | 'London, United Kingdom' 81 | 82 | Accessing non-existent attributes or items will return a `Graph` instance 83 | corresponding to a child node. This `Graph` can then be called normally, to 84 | retrieve the child node it represents: 85 | 86 | >>> 'home' in about_me # Not present in the data itself 87 | False 88 | >>> about_me.home 89 | 90 | >>> about_me.home() 91 | Node({'data': [...]}) 92 | 93 | 94 | ### Creating, Updating and Deleting Nodes 95 | 96 | With the Graph API, node manipulation is done via HTTP POST requests. 97 | `Graph.post()` will POST to the current URL, with varying semantics for each 98 | endpoint: 99 | 100 | >>> post = g.me.feed.post(message="Test.") # Status update 101 | >>> post 102 | Node({'id': '...'}) 103 | >>> g[post.id].comments.post(message="A comment.") # Comment on the post 104 | Node({'id': '...'}) 105 | >>> g[post.id].likes.post() # Like the post 106 | True 107 | 108 | >>> event = g[121481007877204]() 109 | >>> event.name 110 | 'Facebook Developer Garage London May 2010' 111 | >>> event.rsvp_status is None 112 | True 113 | >>> event.attending.post() # Attend the given event 114 | True 115 | 116 | Any keyword arguments passed to `post()` will be added as form data. Consult the 117 | [Facebook API docs][api-ref] for a complete reference on URLs and options. 118 | 119 | [api-ref]: http://developers.facebook.com/docs/reference/api/ 120 | 121 | Nodes can be deleted by adding `?method=delete` to the URL; the `delete()` 122 | method is a helpful shortcut: 123 | 124 | >>> g[post.id].delete() 125 | True 126 | 127 | 128 | ## Canvas Applications 129 | 130 | Facebook is rolling out support for OAuth 2.0 in canvas applications with the 131 | new `signed_request` parameter; you can read about it [here][signed_request]. 132 | 133 | [signed_request]: http://developers.facebook.com/docs/authentication/canvas 134 | 135 | pyFaceGraph comes with a simple function for verifying and decoding this 136 | parameter: 137 | 138 | >>> from facegraph import decode_signed_request, InvalidSignature 139 | 140 | >>> decode_signed_request(APP_SECRET, GET['signed_request']) 141 | {'0': 'payload', 'algorithm': 'HMAC-SHA256'} 142 | 143 | >>> decode_signed_request('wrong-secret', GET['signed_request']) 144 | Traceback (most recent call last): 145 | ... 146 | InvalidSignature 147 | 148 | 149 | 150 | ### OAuth 151 | 152 | pyFaceGraph defines two abstract class-based views (using [django-clsview][]) 153 | and a mixin; these help your app obtain OAuth 2.0 access tokens to access the 154 | Graph API on behalf of a Facebook user. 155 | 156 | [django-clsview]: http://github.com/zacharyvoase/django-clsview 157 | 158 | * `FacebookOAuthView`: Defines some common methods useful to all 159 | Facebook-related CBVs. Should be used as an inherited ‘mixin’, i.e.: 160 | 161 | class Callback(CallbackView, FacebookOAuthView): 162 | pass 163 | 164 | Examples of methods implemented here are `redirect_uri()` (which must be the 165 | same for both the authorize and callback views), `client_id()` and 166 | `client_secret()` and `fetch_url()` 167 | 168 | * `AuthorizeView`: Acts solely to redirect users to Facebook for 169 | authorization. You can override `authorize_url()` to change the URL which 170 | the user is redirected to. `scope()` and `display()` are two shortcuts for 171 | the Facebook-specific parameters, which you might need to alter for your 172 | requirements. 173 | 174 | * `CallbackView`: The view the user is redirected back to from Facebook, upon 175 | successful authorization. It’s up to you to write the body of this (by 176 | defining `__call__()`), but you can call `get_access_token()` to fetch the 177 | access token. Suggestions include saving the access token to the database, 178 | or storing it in `request.session`. 179 | 180 | See the source of [facegraph.django.views][fdv] for the ultimate reference guide 181 | to these views. 182 | 183 | [fdv]: http://github.com/iplatform/pyFaceGraph/blob/master/src/facegraph/django/views.py 184 | 185 | 186 | #### Example 187 | 188 | Take a look at the example project in `test/graphdevtools`, which shows how to 189 | construct an app that interfaces with Facebook (and provides a dashboard for 190 | generating access tokens with varying permissions). You’ll need to create 191 | `local_settings.py` in that directory and define `SECRET_KEY`, 192 | `FACEBOOK_CLIENT_ID` and `FACEBOOK_CLIENT_SECRET` (for the latter two you need 193 | to register a Facebook application). 194 | 195 | 196 | ### Middleware 197 | 198 | The provided `FacebookGraphMiddleware` will attach a `facegraph.Graph` instance 199 | to each request, accessible as `request.graph`. You will need to subclass this 200 | middleware to define your own method of fetching the access token. For example: 201 | 202 | ## myapp/middleware.py: 203 | 204 | from facegraph.django.middleware import FacebookGraphMiddleware 205 | 206 | class GraphMiddleware(FacebookGraphMiddleware): 207 | def access_token(self, request): 208 | return request.session.get('access_token') 209 | 210 | ## settings.py: 211 | 212 | MIDDLEWARE_CLASSES = ( 213 | # ... 214 | 'myapp.middleware.GraphMiddleware', 215 | # ... 216 | ) 217 | 218 | Note that this will still attach a `Graph` even if the access token is `None`. 219 | To check for authentication, just use `if request.graph.access_token:` in your 220 | view code. 221 | 222 | There is also a piece of middleware for canvas applications: 223 | `FacebookCanvasMiddleware` will verify and decode Facebook’s new 224 | `signed_request` parameter for each request, and attach the value as 225 | `request.fbrequest`. By default, `settings.FACEBOOK_APP_SECRET` will be used as 226 | the application secret for signature verification, but you can subclass the 227 | middleware and override the `app_secret()` method to modify this behavior: 228 | 229 | ## myapp/middleware.py: 230 | 231 | from django.conf import settings 232 | from facegraph.django.middleware import FacebookCanvasMiddleware 233 | 234 | class CanvasMiddleware(FacebookCanvasMiddleware): 235 | def app_secret(self, request): 236 | return settings.MY_FACEBOOK_APP_SECRET 237 | 238 | ## settings.py: 239 | 240 | MIDDLEWARE_CLASSES = ( 241 | # ... 242 | 'myapp.middleware.CanvasMiddleware', 243 | # ... 244 | ) 245 | 246 | Finally, `FacebookCanvasGraphMiddleware` is a subclass of 247 | `FacebookGraphMiddleware` which will use the signed request as the source of the 248 | access token; no subclassing is necessary, just place it *after* 249 | `CanvasMiddleware` in `MIDDLEWARE_CLASSES`: 250 | 251 | MIDDLEWARE_CLASSES = ( 252 | # ... 253 | 'myapp.middleware.CanvasMiddleware', 254 | 'facegraph.django.middleware.FacebookCanvasGraphMiddleware', 255 | # ... 256 | ) 257 | 258 | 259 | ## (Un)license 260 | 261 | This is free and unencumbered software released into the public domain. 262 | 263 | Anyone is free to copy, modify, publish, use, compile, sell, or distribute this 264 | software, either in source code form or as a compiled binary, for any purpose, 265 | commercial or non-commercial, and by any means. 266 | 267 | In jurisdictions that recognize copyright laws, the author or authors of this 268 | software dedicate any and all copyright interest in the software to the public 269 | domain. We make this dedication for the benefit of the public at large and to 270 | the detriment of our heirs and successors. We intend this dedication to be an 271 | overt act of relinquishment in perpetuity of all present and future rights to 272 | this software under copyright law. 273 | 274 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 275 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 276 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE 277 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 278 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 279 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 280 | 281 | For more information, please refer to 282 | -------------------------------------------------------------------------------- /REQUIREMENTS: -------------------------------------------------------------------------------- 1 | bunch>=1.0.1 2 | ujson>=1.19 3 | -------------------------------------------------------------------------------- /REQUIREMENTS.django: -------------------------------------------------------------------------------- 1 | django-clsview>=0.0.1 -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /distribute_setup.py: -------------------------------------------------------------------------------- 1 | #!python 2 | """Bootstrap distribute installation 3 | 4 | If you want to use setuptools in your package's setup.py, just include this 5 | file in the same directory with it, and add this to the top of your setup.py:: 6 | 7 | from distribute_setup import use_setuptools 8 | use_setuptools() 9 | 10 | If you want to require a specific version of setuptools, set a download 11 | mirror, or use an alternate download directory, you can do so by supplying 12 | the appropriate options to ``use_setuptools()``. 13 | 14 | This file can also be run as a script to install or upgrade setuptools. 15 | """ 16 | import os 17 | import sys 18 | import time 19 | import fnmatch 20 | import tempfile 21 | import tarfile 22 | from distutils import log 23 | 24 | try: 25 | from site import USER_SITE 26 | except ImportError: 27 | USER_SITE = None 28 | 29 | try: 30 | import subprocess 31 | 32 | def _python_cmd(*args): 33 | args = (sys.executable,) + args 34 | return subprocess.call(args) == 0 35 | 36 | except ImportError: 37 | # will be used for python 2.3 38 | def _python_cmd(*args): 39 | args = (sys.executable,) + args 40 | # quoting arguments if windows 41 | if sys.platform == 'win32': 42 | def quote(arg): 43 | if ' ' in arg: 44 | return '"%s"' % arg 45 | return arg 46 | args = [quote(arg) for arg in args] 47 | return os.spawnl(os.P_WAIT, sys.executable, *args) == 0 48 | 49 | DEFAULT_VERSION = "0.6.10" 50 | DEFAULT_URL = "http://pypi.python.org/packages/source/d/distribute/" 51 | SETUPTOOLS_FAKED_VERSION = "0.6c11" 52 | 53 | SETUPTOOLS_PKG_INFO = """\ 54 | Metadata-Version: 1.0 55 | Name: setuptools 56 | Version: %s 57 | Summary: xxxx 58 | Home-page: xxx 59 | Author: xxx 60 | Author-email: xxx 61 | License: xxx 62 | Description: xxx 63 | """ % SETUPTOOLS_FAKED_VERSION 64 | 65 | 66 | def _install(tarball): 67 | # extracting the tarball 68 | tmpdir = tempfile.mkdtemp() 69 | log.warn('Extracting in %s', tmpdir) 70 | old_wd = os.getcwd() 71 | try: 72 | os.chdir(tmpdir) 73 | tar = tarfile.open(tarball) 74 | _extractall(tar) 75 | tar.close() 76 | 77 | # going in the directory 78 | subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) 79 | os.chdir(subdir) 80 | log.warn('Now working in %s', subdir) 81 | 82 | # installing 83 | log.warn('Installing Distribute') 84 | if not _python_cmd('setup.py', 'install'): 85 | log.warn('Something went wrong during the installation.') 86 | log.warn('See the error message above.') 87 | finally: 88 | os.chdir(old_wd) 89 | 90 | 91 | def _build_egg(egg, tarball, to_dir): 92 | # extracting the tarball 93 | tmpdir = tempfile.mkdtemp() 94 | log.warn('Extracting in %s', tmpdir) 95 | old_wd = os.getcwd() 96 | try: 97 | os.chdir(tmpdir) 98 | tar = tarfile.open(tarball) 99 | _extractall(tar) 100 | tar.close() 101 | 102 | # going in the directory 103 | subdir = os.path.join(tmpdir, os.listdir(tmpdir)[0]) 104 | os.chdir(subdir) 105 | log.warn('Now working in %s', subdir) 106 | 107 | # building an egg 108 | log.warn('Building a Distribute egg in %s', to_dir) 109 | _python_cmd('setup.py', '-q', 'bdist_egg', '--dist-dir', to_dir) 110 | 111 | finally: 112 | os.chdir(old_wd) 113 | # returning the result 114 | log.warn(egg) 115 | if not os.path.exists(egg): 116 | raise IOError('Could not build the egg.') 117 | 118 | 119 | def _do_download(version, download_base, to_dir, download_delay): 120 | egg = os.path.join(to_dir, 'distribute-%s-py%d.%d.egg' 121 | % (version, sys.version_info[0], sys.version_info[1])) 122 | if not os.path.exists(egg): 123 | tarball = download_setuptools(version, download_base, 124 | to_dir, download_delay) 125 | _build_egg(egg, tarball, to_dir) 126 | sys.path.insert(0, egg) 127 | import setuptools 128 | setuptools.bootstrap_install_from = egg 129 | 130 | 131 | def use_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, 132 | to_dir=os.curdir, download_delay=15, no_fake=True): 133 | # making sure we use the absolute path 134 | to_dir = os.path.abspath(to_dir) 135 | was_imported = 'pkg_resources' in sys.modules or \ 136 | 'setuptools' in sys.modules 137 | try: 138 | try: 139 | import pkg_resources 140 | if not hasattr(pkg_resources, '_distribute'): 141 | if not no_fake: 142 | _fake_setuptools() 143 | raise ImportError 144 | except ImportError: 145 | return _do_download(version, download_base, to_dir, download_delay) 146 | try: 147 | pkg_resources.require("distribute>="+version) 148 | return 149 | except pkg_resources.VersionConflict: 150 | e = sys.exc_info()[1] 151 | if was_imported: 152 | sys.stderr.write( 153 | "The required version of distribute (>=%s) is not available,\n" 154 | "and can't be installed while this script is running. Please\n" 155 | "install a more recent version first, using\n" 156 | "'easy_install -U distribute'." 157 | "\n\n(Currently using %r)\n" % (version, e.args[0])) 158 | sys.exit(2) 159 | else: 160 | del pkg_resources, sys.modules['pkg_resources'] # reload ok 161 | return _do_download(version, download_base, to_dir, 162 | download_delay) 163 | except pkg_resources.DistributionNotFound: 164 | return _do_download(version, download_base, to_dir, 165 | download_delay) 166 | finally: 167 | if not no_fake: 168 | _create_fake_setuptools_pkg_info(to_dir) 169 | 170 | def download_setuptools(version=DEFAULT_VERSION, download_base=DEFAULT_URL, 171 | to_dir=os.curdir, delay=15): 172 | """Download distribute from a specified location and return its filename 173 | 174 | `version` should be a valid distribute version number that is available 175 | as an egg for download under the `download_base` URL (which should end 176 | with a '/'). `to_dir` is the directory where the egg will be downloaded. 177 | `delay` is the number of seconds to pause before an actual download 178 | attempt. 179 | """ 180 | # making sure we use the absolute path 181 | to_dir = os.path.abspath(to_dir) 182 | try: 183 | from urllib.request import urlopen 184 | except ImportError: 185 | from urllib2 import urlopen 186 | tgz_name = "distribute-%s.tar.gz" % version 187 | url = download_base + tgz_name 188 | saveto = os.path.join(to_dir, tgz_name) 189 | src = dst = None 190 | if not os.path.exists(saveto): # Avoid repeated downloads 191 | try: 192 | log.warn("Downloading %s", url) 193 | src = urlopen(url) 194 | # Read/write all in one block, so we don't create a corrupt file 195 | # if the download is interrupted. 196 | data = src.read() 197 | dst = open(saveto, "wb") 198 | dst.write(data) 199 | finally: 200 | if src: 201 | src.close() 202 | if dst: 203 | dst.close() 204 | return os.path.realpath(saveto) 205 | 206 | 207 | def _patch_file(path, content): 208 | """Will backup the file then patch it""" 209 | existing_content = open(path).read() 210 | if existing_content == content: 211 | # already patched 212 | log.warn('Already patched.') 213 | return False 214 | log.warn('Patching...') 215 | _rename_path(path) 216 | f = open(path, 'w') 217 | try: 218 | f.write(content) 219 | finally: 220 | f.close() 221 | return True 222 | 223 | 224 | def _same_content(path, content): 225 | return open(path).read() == content 226 | 227 | def _no_sandbox(function): 228 | def __no_sandbox(*args, **kw): 229 | try: 230 | from setuptools.sandbox import DirectorySandbox 231 | def violation(*args): 232 | pass 233 | DirectorySandbox._old = DirectorySandbox._violation 234 | DirectorySandbox._violation = violation 235 | patched = True 236 | except ImportError: 237 | patched = False 238 | 239 | try: 240 | return function(*args, **kw) 241 | finally: 242 | if patched: 243 | DirectorySandbox._violation = DirectorySandbox._old 244 | del DirectorySandbox._old 245 | 246 | return __no_sandbox 247 | 248 | @_no_sandbox 249 | def _rename_path(path): 250 | new_name = path + '.OLD.%s' % time.time() 251 | log.warn('Renaming %s into %s', path, new_name) 252 | os.rename(path, new_name) 253 | return new_name 254 | 255 | def _remove_flat_installation(placeholder): 256 | if not os.path.isdir(placeholder): 257 | log.warn('Unkown installation at %s', placeholder) 258 | return False 259 | found = False 260 | for file in os.listdir(placeholder): 261 | if fnmatch.fnmatch(file, 'setuptools*.egg-info'): 262 | found = True 263 | break 264 | if not found: 265 | log.warn('Could not locate setuptools*.egg-info') 266 | return 267 | 268 | log.warn('Removing elements out of the way...') 269 | pkg_info = os.path.join(placeholder, file) 270 | if os.path.isdir(pkg_info): 271 | patched = _patch_egg_dir(pkg_info) 272 | else: 273 | patched = _patch_file(pkg_info, SETUPTOOLS_PKG_INFO) 274 | 275 | if not patched: 276 | log.warn('%s already patched.', pkg_info) 277 | return False 278 | # now let's move the files out of the way 279 | for element in ('setuptools', 'pkg_resources.py', 'site.py'): 280 | element = os.path.join(placeholder, element) 281 | if os.path.exists(element): 282 | _rename_path(element) 283 | else: 284 | log.warn('Could not find the %s element of the ' 285 | 'Setuptools distribution', element) 286 | return True 287 | 288 | 289 | def _after_install(dist): 290 | log.warn('After install bootstrap.') 291 | placeholder = dist.get_command_obj('install').install_purelib 292 | _create_fake_setuptools_pkg_info(placeholder) 293 | 294 | @_no_sandbox 295 | def _create_fake_setuptools_pkg_info(placeholder): 296 | if not placeholder or not os.path.exists(placeholder): 297 | log.warn('Could not find the install location') 298 | return 299 | pyver = '%s.%s' % (sys.version_info[0], sys.version_info[1]) 300 | setuptools_file = 'setuptools-%s-py%s.egg-info' % \ 301 | (SETUPTOOLS_FAKED_VERSION, pyver) 302 | pkg_info = os.path.join(placeholder, setuptools_file) 303 | if os.path.exists(pkg_info): 304 | log.warn('%s already exists', pkg_info) 305 | return 306 | 307 | log.warn('Creating %s', pkg_info) 308 | f = open(pkg_info, 'w') 309 | try: 310 | f.write(SETUPTOOLS_PKG_INFO) 311 | finally: 312 | f.close() 313 | 314 | pth_file = os.path.join(placeholder, 'setuptools.pth') 315 | log.warn('Creating %s', pth_file) 316 | f = open(pth_file, 'w') 317 | try: 318 | f.write(os.path.join(os.curdir, setuptools_file)) 319 | finally: 320 | f.close() 321 | 322 | def _patch_egg_dir(path): 323 | # let's check if it's already patched 324 | pkg_info = os.path.join(path, 'EGG-INFO', 'PKG-INFO') 325 | if os.path.exists(pkg_info): 326 | if _same_content(pkg_info, SETUPTOOLS_PKG_INFO): 327 | log.warn('%s already patched.', pkg_info) 328 | return False 329 | _rename_path(path) 330 | os.mkdir(path) 331 | os.mkdir(os.path.join(path, 'EGG-INFO')) 332 | pkg_info = os.path.join(path, 'EGG-INFO', 'PKG-INFO') 333 | f = open(pkg_info, 'w') 334 | try: 335 | f.write(SETUPTOOLS_PKG_INFO) 336 | finally: 337 | f.close() 338 | return True 339 | 340 | 341 | def _before_install(): 342 | log.warn('Before install bootstrap.') 343 | _fake_setuptools() 344 | 345 | 346 | def _under_prefix(location): 347 | if 'install' not in sys.argv: 348 | return True 349 | args = sys.argv[sys.argv.index('install')+1:] 350 | for index, arg in enumerate(args): 351 | for option in ('--root', '--prefix'): 352 | if arg.startswith('%s=' % option): 353 | top_dir = arg.split('root=')[-1] 354 | return location.startswith(top_dir) 355 | elif arg == option: 356 | if len(args) > index: 357 | top_dir = args[index+1] 358 | return location.startswith(top_dir) 359 | elif option == '--user' and USER_SITE is not None: 360 | return location.startswith(USER_SITE) 361 | return True 362 | 363 | 364 | def _fake_setuptools(): 365 | log.warn('Scanning installed packages') 366 | try: 367 | import pkg_resources 368 | except ImportError: 369 | # we're cool 370 | log.warn('Setuptools or Distribute does not seem to be installed.') 371 | return 372 | ws = pkg_resources.working_set 373 | try: 374 | setuptools_dist = ws.find(pkg_resources.Requirement.parse('setuptools', 375 | replacement=False)) 376 | except TypeError: 377 | # old distribute API 378 | setuptools_dist = ws.find(pkg_resources.Requirement.parse('setuptools')) 379 | 380 | if setuptools_dist is None: 381 | log.warn('No setuptools distribution found') 382 | return 383 | # detecting if it was already faked 384 | setuptools_location = setuptools_dist.location 385 | log.warn('Setuptools installation detected at %s', setuptools_location) 386 | 387 | # if --root or --preix was provided, and if 388 | # setuptools is not located in them, we don't patch it 389 | if not _under_prefix(setuptools_location): 390 | log.warn('Not patching, --root or --prefix is installing Distribute' 391 | ' in another location') 392 | return 393 | 394 | # let's see if its an egg 395 | if not setuptools_location.endswith('.egg'): 396 | log.warn('Non-egg installation') 397 | res = _remove_flat_installation(setuptools_location) 398 | if not res: 399 | return 400 | else: 401 | log.warn('Egg installation') 402 | pkg_info = os.path.join(setuptools_location, 'EGG-INFO', 'PKG-INFO') 403 | if (os.path.exists(pkg_info) and 404 | _same_content(pkg_info, SETUPTOOLS_PKG_INFO)): 405 | log.warn('Already patched.') 406 | return 407 | log.warn('Patching...') 408 | # let's create a fake egg replacing setuptools one 409 | res = _patch_egg_dir(setuptools_location) 410 | if not res: 411 | return 412 | log.warn('Patched done.') 413 | _relaunch() 414 | 415 | 416 | def _relaunch(): 417 | log.warn('Relaunching...') 418 | # we have to relaunch the process 419 | args = [sys.executable] + sys.argv 420 | sys.exit(subprocess.call(args)) 421 | 422 | 423 | def _extractall(self, path=".", members=None): 424 | """Extract all members from the archive to the current working 425 | directory and set owner, modification time and permissions on 426 | directories afterwards. `path' specifies a different directory 427 | to extract to. `members' is optional and must be a subset of the 428 | list returned by getmembers(). 429 | """ 430 | import copy 431 | import operator 432 | from tarfile import ExtractError 433 | directories = [] 434 | 435 | if members is None: 436 | members = self 437 | 438 | for tarinfo in members: 439 | if tarinfo.isdir(): 440 | # Extract directories with a safe mode. 441 | directories.append(tarinfo) 442 | tarinfo = copy.copy(tarinfo) 443 | tarinfo.mode = 448 # decimal for oct 0700 444 | self.extract(tarinfo, path) 445 | 446 | # Reverse sort directories. 447 | if sys.version_info < (2, 4): 448 | def sorter(dir1, dir2): 449 | return cmp(dir1.name, dir2.name) 450 | directories.sort(sorter) 451 | directories.reverse() 452 | else: 453 | directories.sort(key=operator.attrgetter('name'), reverse=True) 454 | 455 | # Set correct owner, mtime and filemode on directories. 456 | for tarinfo in directories: 457 | dirpath = os.path.join(path, tarinfo.name) 458 | try: 459 | self.chown(tarinfo, dirpath) 460 | self.utime(tarinfo, dirpath) 461 | self.chmod(tarinfo, dirpath) 462 | except ExtractError: 463 | e = sys.exc_info()[1] 464 | if self.errorlevel > 1: 465 | raise 466 | else: 467 | self._dbg(1, "tarfile: %s" % e) 468 | 469 | 470 | def main(argv, version=DEFAULT_VERSION): 471 | """Install or upgrade setuptools and EasyInstall""" 472 | tarball = download_setuptools() 473 | _install(tarball) 474 | 475 | 476 | if __name__ == '__main__': 477 | main(sys.argv[1:]) 478 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import glob 5 | import os 6 | import re 7 | 8 | from distribute_setup import use_setuptools; use_setuptools() 9 | from setuptools import setup, find_packages 10 | 11 | rel_file = lambda *args: os.path.join(os.path.dirname(os.path.abspath(__file__)), *args) 12 | cleanup = lambda lines: filter(None, map(lambda s: s.strip(), lines)) 13 | 14 | def read_from(filename): 15 | fp = open(filename) 16 | try: 17 | return fp.read() 18 | finally: 19 | fp.close() 20 | 21 | def get_version(): 22 | data = read_from(rel_file('src', 'facegraph', '__init__.py')) 23 | return re.search(r"__version__ = '([^']+)'", data).group(1) 24 | 25 | def get_requirements(): 26 | return cleanup(read_from(rel_file('REQUIREMENTS')).splitlines()) 27 | 28 | def get_extra_requirements(): 29 | extras_require = {} 30 | for req_filename in glob.glob(rel_file('REQUIREMENTS.*')): 31 | group = os.path.basename(req_filename).split('.', 1)[1] 32 | extras_require[group] = cleanup(read_from(req_filename).splitlines()) 33 | return extras_require 34 | 35 | setup( 36 | name = 'pyfacegraph', 37 | version = get_version(), 38 | author = "iPlatform Ltd", 39 | author_email = "opensource@theiplatform.com", 40 | url = 'http://github.com/iplatform/pyFaceGraph', 41 | description = "A client library for the Facebook Graph API.", 42 | packages = find_packages(where='src'), 43 | package_dir = {'': 'src'}, 44 | install_requires = get_requirements(), 45 | extras_require = get_extra_requirements(), 46 | ) 47 | -------------------------------------------------------------------------------- /src/facegraph/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __version__ = '0.0.36' 4 | 5 | from facegraph.api import Api 6 | from facegraph.api import ApiException 7 | from facegraph.fql import FQL 8 | from facegraph.graph import Graph 9 | from facegraph.graph import GraphException 10 | -------------------------------------------------------------------------------- /src/facegraph/api.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import ujson as json 3 | from urllib import urlencode, unquote 4 | 5 | FB_READ_TIMEOUT = 180 6 | 7 | class Api: 8 | 9 | def __init__(self, access_token=None, request=None, cookie=None, app_id=None, stack=None, 10 | err_handler=None, timeout=FB_READ_TIMEOUT, urllib2=None, httplib=None, 11 | retries=5): 12 | 13 | self.uid = None 14 | self.access_token = access_token 15 | self.stack = stack if stack else [] 16 | self.cookie = cookie 17 | self.err_handler = err_handler 18 | self.retries = retries 19 | 20 | if urllib2 is None: 21 | import urllib2 22 | self.urllib2 = urllib2 23 | if httplib is None: 24 | import httplib 25 | self.httplib = httplib 26 | self.timeout = timeout 27 | 28 | socket.setdefaulttimeout(self.timeout) 29 | 30 | if self.cookie: 31 | self.load_cookie() 32 | elif request: 33 | self.check_cookie(request, app_id) 34 | 35 | def __sentry__(self): 36 | return u'FB(method: %s, access_token: %s)' % (self.__method(), self.access_token) 37 | 38 | def __repr__(self): 39 | return '' % (self.__method(), id(self)) 40 | 41 | def __method(self): 42 | return u".".join(self.stack) 43 | 44 | def __getitem__(self, name): 45 | """ 46 | This method returns a new FB and allows us to chain attributes, e.g. fb.stream.publish 47 | A stack of attributes is maintained so that we can call the correct method later 48 | """ 49 | s = [] 50 | s.extend(self.stack) 51 | s.append(name) 52 | return self.__class__(stack=s, access_token=self.access_token, cookie=self.cookie, err_handler=self.err_handler, 53 | timeout=self.timeout, retries=self.retries, urllib2=self.urllib2, httplib=self.httplib) 54 | 55 | def __getattr__(self, name): 56 | """ 57 | We trigger __getitem__ here so that both self.method.name and self['method']['name'] work 58 | """ 59 | return self[name] 60 | 61 | def __call__(self, _retries=None, *args, **kwargs): 62 | """ 63 | Executes an old REST api method using the stored method stack 64 | """ 65 | _retries = _retries or self.retries 66 | 67 | if len(self.stack)>0: 68 | kwargs.update({"format": "JSON"}) 69 | method = self.__method() 70 | # Custom overrides 71 | if method == "photos.upload": 72 | return self.__photo_upload(**kwargs) 73 | 74 | # UTF8 75 | utf8_kwargs = {} 76 | for (k,v) in kwargs.iteritems(): 77 | try: 78 | v = v.encode('UTF-8') 79 | except AttributeError: pass 80 | utf8_kwargs[k] = v 81 | 82 | url = "https://api.facebook.com/method/%s?" % method 83 | if self.access_token: 84 | url += 'access_token=%s&' % self.access_token 85 | url += urlencode(utf8_kwargs) 86 | 87 | attempt = 0 88 | while True: 89 | try: 90 | response = self.urllib2.urlopen(url, timeout=self.timeout).read() 91 | break 92 | except self.urllib2.HTTPError, e: 93 | response = e.fp.read() 94 | break 95 | except (self.httplib.BadStatusLine, IOError): 96 | if attempt < _retries: 97 | attempt += 1 98 | else: 99 | raise 100 | 101 | return self.__process_response(response, params=kwargs) 102 | 103 | def __process_response(self, response, params=None): 104 | try: 105 | data = json.loads(response) 106 | except ValueError: 107 | data = response 108 | try: 109 | if 'error_code' in data: 110 | e = ApiException(code=int(data.get('error_code')), 111 | message=data.get('error_msg'), 112 | method=self.__method(), 113 | params=params, 114 | api=self) 115 | if self.err_handler: 116 | return self.err_handler(e=e) 117 | else: 118 | raise e 119 | 120 | except TypeError: 121 | pass 122 | return data 123 | 124 | def __photo_upload(self, _retries=None, **kwargs): 125 | _retries = _retries or self.retries 126 | 127 | body = [] 128 | crlf = '\r\n' 129 | boundary = "conversocialBoundary" 130 | 131 | # UTF8 132 | utf8_kwargs = {} 133 | for (k,v) in kwargs.iteritems(): 134 | try: 135 | v = v.encode('UTF-8') 136 | except AttributeError: pass 137 | utf8_kwargs[k] = v 138 | 139 | # Add args 140 | utf8_kwargs.update({'access_token': self.access_token}) 141 | for (k,v) in utf8_kwargs.iteritems(): 142 | if k=='photo': continue 143 | body.append("--"+boundary) 144 | body.append('Content-Disposition: form-data; name="%s"' % k) 145 | body.append('') 146 | body.append(str(v)) 147 | 148 | # Add raw image data 149 | photo = utf8_kwargs.get('photo') 150 | photo.open() 151 | data = photo.read() 152 | photo.close() 153 | 154 | body.append("--"+boundary) 155 | body.append('Content-Disposition: form-data; filename="myfilewhichisgood.png"') 156 | body.append('Content-Type: image/png') 157 | body.append('') 158 | body.append(data) 159 | 160 | body.append("--"+boundary+"--") 161 | body.append('') 162 | 163 | body = crlf.join(body) 164 | 165 | # Post to server 166 | r = self.httplib.HTTPSConnection('api.facebook.com', timeout=self.timeout) 167 | headers = {'Content-Type': 'multipart/form-data; boundary=%s' % boundary, 168 | 'Content-Length': str(len(body)), 169 | 'MIME-Version': '1.0'} 170 | 171 | r.request('POST', '/method/photos.upload', body, headers) 172 | 173 | attempt = 0 174 | while True: 175 | try: 176 | response = r.getresponse().read() 177 | return self.__process_response(response, params=kwargs) 178 | except (self.httplib.BadStatusLine, IOError): 179 | if attempt < _retries: 180 | attempt += 1 181 | else: 182 | raise 183 | finally: 184 | r.close() 185 | 186 | def check_cookie(self, request, app_id): 187 | """" 188 | Parses the fb cookie if present 189 | """ 190 | cookie = request.COOKIES.get("fbs_%s" % app_id) 191 | if cookie: 192 | self.cookie = dict([(v.split("=")[0], unquote(v.split("=")[1])) for v in cookie.split('&')]) 193 | self.load_cookie() 194 | 195 | def load_cookie(self): 196 | """ 197 | Checks for user FB cookie and sets as instance attributes. 198 | Contains: 199 | access_token OAuth 2.0 access token used by FB for authentication 200 | uid Users's Facebook UID 201 | expires Expiry date of cookie, will be 0 for constant auth 202 | secret Application secret 203 | sig Sig parameter 204 | session_key Old-style session key, replaced by access_token, deprecated 205 | """ 206 | if self.cookie: 207 | for k in self.cookie: 208 | setattr(self, k, self.cookie.get(k)) 209 | 210 | def __fetch(self, url): 211 | try: 212 | response = self.urllib2.urlopen(url, timeout=self.timeout) 213 | except self.urllib2.HTTPError, e: 214 | response = e.fp 215 | return json.load(response) 216 | 217 | def verify_token(self, tries=1): 218 | url = "https://graph.facebook.com/me?access_token=%s" % self.access_token 219 | for n in range(tries): 220 | data = self.__fetch(url) 221 | if 'error' in data: 222 | pass 223 | else: 224 | return True 225 | 226 | def exists(self, object_id): 227 | url = "https://graph.facebook.com/%s?access_token=%s" % (object_id, self.access_token) 228 | data = self.__fetch(url) 229 | if data: 230 | return True 231 | else: 232 | return False 233 | 234 | class ApiException(Exception): 235 | def __init__(self, code, message, args=None, params=None, api=None, method=None): 236 | Exception.__init__(self) 237 | if args is not None: 238 | self.args = args 239 | self.message = message 240 | self.code = code 241 | self.params = params 242 | self.api = api 243 | self.method = method 244 | 245 | def __repr__(self): 246 | return str(self) 247 | 248 | def __str__(self): 249 | str = "%s, Method: %s" % (self.message, self.method) 250 | if self.params: 251 | str = "%s, Params: %s" % (str, self.params) 252 | if self.code: 253 | str = "(#%s) %s" % (self.code, str) 254 | return str 255 | -------------------------------------------------------------------------------- /src/facegraph/fql.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import urllib2 4 | 5 | import bunch 6 | import ujson as json 7 | from graph import GraphException 8 | from url_operations import add_path, update_query_params 9 | 10 | class FQL(object): 11 | 12 | """ 13 | A maker of single and multiple FQL queries. 14 | 15 | Usage 16 | ===== 17 | 18 | Single queries: 19 | 20 | >>> q = FQL('access_token') 21 | >>> result = q("SELECT post_id FROM stream WHERE source_id = ...") 22 | >>> result 23 | [Bunch(post_id='XXXYYYZZZ'), ...] 24 | 25 | >>> result[0] 26 | Bunch(post_id='XXXYYYZZZ') 27 | 28 | >>> result[0].post_id 29 | 'XXXYYYZZZ' 30 | 31 | Multiple queries: 32 | 33 | >>> q = FQL('access_token') 34 | >>> result = q.multi(dict(query1="SELECT...", query2="SELECT...")) 35 | 36 | >>> result[0].name 37 | 'query1' 38 | >>> result[0].fql_result_set 39 | [...] 40 | 41 | >>> result[1].name 42 | 'query2' 43 | >>> result[1].fql_result_set 44 | [...] 45 | 46 | """ 47 | 48 | ENDPOINT = 'https://api.facebook.com/method/' 49 | 50 | def __init__(self, access_token=None, err_handler=None): 51 | self.access_token = access_token 52 | self.err_handler = err_handler 53 | 54 | def __call__(self, query, **params): 55 | 56 | """ 57 | Execute a single FQL query (using `fql.query`). 58 | 59 | Example: 60 | 61 | >>> q = FQL('access_token') 62 | >>> result = q("SELECT post_id FROM stream WHERE source_id = ...") 63 | >>> result 64 | [Bunch(post_id='XXXYYYZZZ'), ...] 65 | 66 | >>> result[0] 67 | Bunch(post_id='XXXYYYZZZ') 68 | 69 | >>> result[0].post_id 70 | 'XXXYYYZZZ' 71 | 72 | """ 73 | 74 | url = add_path(self.ENDPOINT, 'fql.query') 75 | params.update(query=query, access_token=self.access_token, 76 | format='json') 77 | url = update_query_params(url, params) 78 | 79 | return self.fetch_json(url) 80 | 81 | def multi(self, queries, **params): 82 | 83 | """ 84 | Execute multiple FQL queries (using `fql.multiquery`). 85 | 86 | Example: 87 | 88 | >>> q = FQL('access_token') 89 | >>> result = q.multi(dict(query1="SELECT...", query2="SELECT...")) 90 | 91 | >>> result[0].name 92 | 'query1' 93 | >>> result[0].fql_result_set 94 | [...] 95 | 96 | >>> result[1].name 97 | 'query2' 98 | >>> result[1].fql_result_set 99 | [...] 100 | 101 | """ 102 | 103 | url = add_path(self.ENDPOINT, 'fql.multiquery') 104 | params.update(queries=json.dumps(queries), 105 | access_token=self.access_token, format='json') 106 | url = update_query_params(url, params) 107 | 108 | return self.fetch_json(url) 109 | 110 | @classmethod 111 | def fetch_json(cls, url, data=None): 112 | response = json.loads(cls.fetch(url, data=data)) 113 | if isinstance(response, dict): 114 | if response.get("error_msg"): 115 | code = response.get("error_code") 116 | msg = response.get("error_msg") 117 | args = response.get("request_args") 118 | raise GraphException(code, msg, args=args) 119 | return bunch.bunchify(response) 120 | 121 | @staticmethod 122 | def fetch(url, data=None): 123 | conn = urllib2.urlopen(url, data=data) 124 | try: 125 | return conn.read() 126 | finally: 127 | conn.close() 128 | -------------------------------------------------------------------------------- /src/facegraph/graph.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | import re 4 | import urllib 5 | import urllib2 as default_urllib2 6 | import httplib as default_httplib 7 | import traceback 8 | 9 | from facegraph.url_operations import (add_path, get_host, 10 | add_query_params, update_query_params, get_path) 11 | 12 | import bunch 13 | import ujson as json 14 | from functools import partial 15 | 16 | p = "^\(#(\d+)\)" 17 | code_re = re.compile(p) 18 | 19 | __all__ = ['Graph'] 20 | 21 | log = logging.getLogger('pyfacegraph') 22 | 23 | class Graph(object): 24 | 25 | """ 26 | Proxy for accessing the Facebook Graph API. 27 | 28 | This class uses dynamic attribute handling to provide a flexible and 29 | future-proof interface to the Graph API. 30 | 31 | Tutorial 32 | ======== 33 | 34 | To get started using the API, create a new `Graph` instance with an access 35 | token: 36 | 37 | >>> g = Graph(access_token) # Access token is optional. 38 | >>> g 39 | 40 | 41 | Addressing Nodes 42 | ---------------- 43 | 44 | Each `Graph` contains an access token and a URL. The graph you just created 45 | will have its URL set to 'https://graph.facebook.com/' by default (this is 46 | defined as the class attribute `Graph.API_ROOT`). 47 | 48 | >>> print g.url 49 | https://graph.facebook.com/ 50 | 51 | To address child nodes within the Graph API, `Graph` supports dynamic 52 | attribute and item lookups: 53 | 54 | >>> g.me 55 | 56 | >>> g.me.home 57 | 58 | >>> g['me']['home'] 59 | 60 | >>> g[123456789] 61 | 62 | 63 | Note that a `Graph` instance is rarely modified; these methods return copies 64 | of the original object. In addition, the API is lazy: HTTP requests will 65 | never be made unless you explicitly make them. 66 | 67 | Retrieving Nodes 68 | ---------------- 69 | 70 | You can fetch data by calling a `Graph` instance: 71 | 72 | >>> about_me = g.me() 73 | >>> about_me 74 | Node({'about': '...', 'id': '1503223370'}) 75 | 76 | This returns a `Node` object, which contains the retrieved data. `Node` is 77 | a subclass of `bunch.Bunch`, so you can access keys using attribute syntax: 78 | 79 | >>> about_me.id 80 | '1503223370' 81 | >>> about_me.first_name 82 | 'Zachary' 83 | >>> about_me.hometown.name 84 | 'London, United Kingdom' 85 | 86 | Accessing non-existent attributes or items will return a `Graph` instance 87 | corresponding to a child node. This `Graph` can then be called normally, to 88 | retrieve the child node it represents: 89 | 90 | >>> about_me.home 91 | 92 | >>> about_me.home() 93 | Node({'data': [...]}) 94 | 95 | See `Node`’s documentation for further examples. 96 | 97 | Creating, Updating and Deleting Nodes 98 | ------------------------------------- 99 | 100 | With the Graph API, node manipulation is done via HTTP POST requests. The 101 | `post()` method on `Graph` instances will POST to the current URL, with 102 | varying semantics for each endpoint: 103 | 104 | >>> post = g.me.feed.post(message="Test.") # Status update 105 | >>> post 106 | Node({'id': '...'}) 107 | >>> g[post.id].comments.post(message="A comment.") # Comment on the post 108 | Node({'id': '...'}) 109 | >>> g[post.id].likes.post() # Like the post 110 | True 111 | 112 | >>> event = g[121481007877204]() 113 | >>> event.name 114 | 'Facebook Developer Garage London May 2010' 115 | >>> event.rsvp_status is None 116 | True 117 | >>> event.attending.post() # Attend the given event 118 | True 119 | 120 | Deletes are just POST requests with `?method=delete`; the `delete()` method 121 | is a helpful shortcut: 122 | 123 | >>> g[post.id].delete() 124 | True 125 | 126 | """ 127 | 128 | API_ROOT = 'https://graph.facebook.com/' 129 | DEFAULT_TIMEOUT = 0 # No timeout as default 130 | 131 | def __init__(self, access_token=None, err_handler=None, timeout=DEFAULT_TIMEOUT, retries=5, urllib2=None, httplib=None, **state): 132 | self.access_token = access_token 133 | self.err_handler = err_handler 134 | self.url = self.API_ROOT 135 | self.timeout = timeout 136 | self.retries = retries 137 | self.__dict__.update(state) 138 | if urllib2 is None: 139 | import urllib2 140 | self.urllib2 = urllib2 141 | if httplib is None: 142 | import httplib 143 | self.httplib = httplib 144 | 145 | def __repr__(self): 146 | return '' % (self.url, id(self)) 147 | 148 | def copy(self, **update): 149 | """Copy this Graph, optionally overriding some attributes.""" 150 | return type(self)(access_token=self.access_token, 151 | err_handler=self.err_handler, 152 | timeout=self.timeout, 153 | retries=self.retries, 154 | urllib2=self.urllib2, 155 | httplib=self.httplib, 156 | **update) 157 | 158 | def __getitem__(self, item): 159 | if isinstance(item, slice): 160 | log.debug('Deprecated magic slice!') 161 | log.debug( traceback.format_stack()) 162 | return self._range(item.start, item.stop) 163 | return self.copy(url=add_path(self.url, unicode(item))) 164 | 165 | def __getattr__(self, attr): 166 | return self[attr] 167 | 168 | def _range(self, start, stop): 169 | params = {'offset': start, 170 | 'limit': stop - start} 171 | return self.copy(url=add_query_params(self.url, params)) 172 | 173 | def with_url_params(self, param, val): 174 | """ 175 | this used to overload the bitwise OR op 176 | """ 177 | return self.copy(url=update_query_params(self.url, (param, val))) 178 | 179 | def __call__(self, **params): 180 | log.debug('Deprecated magic call!') 181 | log.debug( traceback.format_stack()) 182 | return self.call_fb(**params) 183 | 184 | def call_fb(self, **params): 185 | """Read the current URL, and JSON-decode the results.""" 186 | 187 | if self.access_token: 188 | params['access_token'] = self.access_token 189 | url = update_query_params(self.url, params) 190 | data = json.loads(self.fetch(url, 191 | timeout=self.timeout, 192 | retries=self.retries, 193 | urllib2=self.urllib2, 194 | httplib=self.httplib)) 195 | return self.process_response(data, params) 196 | 197 | def __iter__(self): 198 | raise TypeError('%r object is not iterable' % self.__class__.__name__) 199 | 200 | def __sentry__(self): 201 | return 'Graph(url: %s, params: %s)' % (self.url, repr(self.__dict__)) 202 | 203 | def fields(self, *fields): 204 | """Shortcut for `?fields=x,y,z`.""" 205 | return self | ('fields', ','.join(fields)) 206 | 207 | def ids(self, *ids): 208 | """Shortcut for `?ids=1,2,3`.""" 209 | 210 | return self | ('ids', ','.join(map(str, ids))) 211 | 212 | def process_response(self, data, params, method=None): 213 | if isinstance(data, dict): 214 | if data.get("error"): 215 | code = data["error"].get("code") 216 | if code is None: 217 | code = data["error"].get("error_code") 218 | msg = data["error"].get("message") 219 | if msg is None: 220 | msg = data["error"].get("error_msg") 221 | if code is None: 222 | code_match = code_re.match(msg) 223 | if code_match is not None: 224 | code = int(code_match.group(1)) 225 | e = GraphException(code, msg, graph=self, params=params, method=method) 226 | if self.err_handler: 227 | return self.err_handler(e=e) 228 | else: 229 | raise e 230 | return bunch.bunchify(data) 231 | return data 232 | 233 | def post(self, **params): 234 | """ 235 | POST to this URL (with parameters); return the JSON-decoded result. 236 | 237 | Example: 238 | 239 | >>> Graph('ACCESS TOKEN').me.feed.post(message="Test.") 240 | Node({'id': '...'}) 241 | 242 | Some methods allow file attachments so uses MIME request to send those through. 243 | Must pass in a file object as 'file' 244 | """ 245 | 246 | if self.access_token: 247 | params['access_token'] = self.access_token 248 | 249 | if get_path(self.url).split('/')[-1] in ['photos']: 250 | params['timeout'] = self.timeout 251 | params['httplib'] = self.httplib 252 | fetch = partial(self.post_mime, 253 | self.url, 254 | httplib=self.httplib, 255 | retries=self.retries, 256 | **params) 257 | else: 258 | params = dict([(k, v.encode('UTF-8')) for (k,v) in params.iteritems() if v is not None]) 259 | fetch = partial(self.fetch, 260 | self.url, 261 | urllib2=self.urllib2, 262 | httplib=self.httplib, 263 | timeout=self.timeout, 264 | retries=self.retries, 265 | data=urllib.urlencode(params)) 266 | 267 | data = json.loads(fetch()) 268 | return self.process_response(data, params, "post") 269 | 270 | def post_file(self, file, **params): 271 | if self.access_token: 272 | params['access_token'] = self.access_token 273 | params['file'] = file 274 | params['timeout'] = self.timeout 275 | params['httplib'] = self.httplib 276 | data = json.loads(self.post_mime(self.url, **params)) 277 | 278 | return self.process_response(data, params, "post_file") 279 | 280 | @staticmethod 281 | def post_mime(url, httplib=default_httplib, timeout=DEFAULT_TIMEOUT, retries=5, **kwargs): 282 | body = [] 283 | crlf = '\r\n' 284 | boundary = "graphBoundary" 285 | 286 | # UTF8 params 287 | utf8_kwargs = dict([(k, v.encode('UTF-8')) for (k,v) in kwargs.iteritems() if k != 'file' and v is not None]) 288 | 289 | # Add args 290 | for (k,v) in utf8_kwargs.iteritems(): 291 | body.append("--"+boundary) 292 | body.append('Content-Disposition: form-data; name="%s"' % k) 293 | body.append('') 294 | body.append(str(v)) 295 | 296 | # Add raw data 297 | file = kwargs.get('file') 298 | if file: 299 | file.open() 300 | data = file.read() 301 | file.close() 302 | 303 | body.append("--"+boundary) 304 | body.append('Content-Disposition: form-data; filename="facegraphfile.png"') 305 | body.append('') 306 | body.append(data) 307 | 308 | body.append("--"+boundary+"--") 309 | body.append('') 310 | 311 | body = crlf.join(body) 312 | 313 | # Post to server 314 | kwargs = {} 315 | if timeout: 316 | kwargs = {'timeout': timeout} 317 | r = httplib.HTTPSConnection(get_host(url), **kwargs) 318 | headers = {'Content-Type': 'multipart/form-data; boundary=%s' % boundary, 319 | 'Content-Length': str(len(body)), 320 | 'MIME-Version': '1.0'} 321 | 322 | r.request('POST', get_path(url).encode(), body, headers) 323 | attempt = 0 324 | while True: 325 | try: 326 | return r.getresponse().read() 327 | except (httplib.BadStatusLine, IOError): 328 | if attempt < retries: 329 | attempt += 1 330 | else: 331 | raise 332 | finally: 333 | r.close() 334 | 335 | def delete(self): 336 | """ 337 | Delete this resource. Sends a POST with `?method=delete` 338 | """ 339 | return self.post(method='delete') 340 | 341 | @staticmethod 342 | def fetch(url, data=None, urllib2=default_urllib2, httplib=default_httplib, timeout=DEFAULT_TIMEOUT, retries=None): 343 | """ 344 | Fetch the specified URL, with optional form data; return a string. 345 | 346 | This method exists mainly for dependency injection purposes. By default 347 | it uses urllib2; you may override it and use an alternative library. 348 | """ 349 | conn = None 350 | attempt = 0 351 | while True: 352 | try: 353 | kwargs = {} 354 | if timeout: 355 | kwargs = {'timeout': timeout} 356 | conn = urllib2.urlopen(url, data=data, **kwargs) 357 | return conn.read() 358 | except urllib2.HTTPError, e: 359 | return e.fp.read() 360 | except (httplib.BadStatusLine, IOError): 361 | if attempt < retries: 362 | attempt += 1 363 | else: 364 | raise 365 | finally: 366 | conn and conn.close() 367 | 368 | def __sentry__(self): 369 | """ 370 | Transform the graph object into something that sentry can 371 | understand 372 | """ 373 | return "Graph(%s, %s)" % (self.url, str(self.__dict__)) 374 | 375 | 376 | class GraphException(Exception): 377 | def __init__(self, code, message, args=None, params=None, graph=None, method=None): 378 | Exception.__init__(self) 379 | if args is not None: 380 | self.args = args 381 | self.message = message 382 | self.code = code 383 | self.params = params 384 | self.graph = graph 385 | self.method = method 386 | 387 | def __repr__(self): 388 | return str(self) 389 | 390 | def __str__(self): 391 | s = self.message 392 | if self.graph: 393 | s += "Node: %s" % self.graph.url 394 | if self.params: 395 | s += ", Params: %s" % self.params 396 | if self.code: 397 | s += ", (%s)" % self.code 398 | return s 399 | -------------------------------------------------------------------------------- /src/facegraph/url_operations.py: -------------------------------------------------------------------------------- 1 | import urllib 2 | import urlparse 3 | 4 | def get_path(url): 5 | scheme, host, path, query, fragment = urlparse.urlsplit(url) 6 | return path 7 | 8 | def get_host(url): 9 | scheme, host, path, query, fragment = urlparse.urlsplit(url) 10 | return host 11 | 12 | def add_path(url, new_path): 13 | """Given a url and path, return a new url that combines 14 | the two. 15 | """ 16 | scheme, host, path, query, fragment = urlparse.urlsplit(url) 17 | new_path = new_path.lstrip('/') 18 | if path.endswith('/'): 19 | path += new_path 20 | else: 21 | path += '/' + new_path 22 | return urlparse.urlunsplit([scheme, host, path, query, fragment]) 23 | 24 | def _query_param(key, value): 25 | """ensure that a query parameter's value is a string 26 | of bytes in UTF-8 encoding. 27 | """ 28 | if isinstance(value, unicode): 29 | pass 30 | elif isinstance(value, str): 31 | value = value.decode('utf-8') 32 | else: 33 | value = unicode(value) 34 | return key, value.encode('utf-8') 35 | 36 | def _make_query_tuples(params): 37 | if hasattr(params, 'items'): 38 | return [_query_param(*param) for param in params.items()] 39 | else: 40 | return [_query_param(*params)] 41 | 42 | def add_query_params(url, params): 43 | """use the _update_query_params function to set a new query 44 | string for the url based on params. 45 | """ 46 | return update_query_params(url, params, update=False) 47 | 48 | def update_query_params(url, params, update=True): 49 | """Given a url and a tuple or dict of parameters, return 50 | a url that includes the parameters as a properly formatted 51 | query string. 52 | 53 | If update is True, change any existing values to new values 54 | given in params. 55 | """ 56 | scheme, host, path, query, fragment = urlparse.urlsplit(url) 57 | 58 | # urlparse.parse_qsl gives back url-decoded byte strings. Leave these as 59 | # they are: they will be re-urlencoded below 60 | query_bits = [(k, v) for k, v in urlparse.parse_qsl(query)] 61 | if update: 62 | query_bits = dict(query_bits) 63 | query_bits.update(_make_query_tuples(params)) 64 | else: 65 | query_bits.extend(_make_query_tuples(params)) 66 | 67 | query = urllib.urlencode(query_bits) 68 | return urlparse.urlunsplit([scheme, host, path, query, fragment]) 69 | 70 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/colinhowe/pyFaceGraph/c335ac4e460c4521621eaefa98adc7826ba1f932/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_url_operations.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from facegraph import graph 3 | from facegraph import url_operations as ops 4 | from facegraph.fql import FQL 5 | from mock import patch 6 | 7 | class UrlOperationsTests(TestCase): 8 | def test_get_path(self): 9 | self.assertEquals('', ops.get_path(u'http://a.com')) 10 | self.assertEquals('/', ops.get_path(u'http://a.com/')) 11 | self.assertEquals('/a', ops.get_path(u'http://a.com/a')) 12 | self.assertEquals('/a/', ops.get_path(u'http://a.com/a/')) 13 | self.assertEquals('/a/b', ops.get_path(u'http://a.com/a/b')) 14 | 15 | def test_get_host(self): 16 | self.assertEquals('a.com', ops.get_host('http://a.com')) 17 | self.assertEquals('a.com', ops.get_host('http://a.com/a/b')) 18 | self.assertEquals('a.com', ops.get_host('http://a.com/a?a=b')) 19 | 20 | def test_add_path(self): 21 | url = u'http://a.com' 22 | self.assertEquals('http://a.com/', ops.add_path(url, '')) 23 | self.assertEquals('http://a.com/path', ops.add_path(url, 'path')) 24 | self.assertEquals('http://a.com/path', ops.add_path(url, '/path')) 25 | self.assertEquals('http://a.com/path/', ops.add_path(url, 'path/')) 26 | self.assertEquals('http://a.com/path/', ops.add_path(url, '/path/')) 27 | 28 | def test_add_path_trailing_slash(self): 29 | url = u'http://a.com/' 30 | self.assertEquals('http://a.com/path', ops.add_path(url, 'path')) 31 | self.assertEquals('http://a.com/path', ops.add_path(url, '/path')) 32 | self.assertEquals('http://a.com/path/', ops.add_path(url, 'path/')) 33 | self.assertEquals('http://a.com/path/', ops.add_path(url, '/path/')) 34 | 35 | def test_add_path_existing_path(self): 36 | url = u'http://a.com/path1' 37 | self.assertEquals('http://a.com/path1/path2', ops.add_path(url, 'path2')) 38 | self.assertEquals('http://a.com/path1/path2', ops.add_path(url, '/path2')) 39 | self.assertEquals('http://a.com/path1/path2/', ops.add_path(url, 'path2/')) 40 | self.assertEquals('http://a.com/path1/path2/', ops.add_path(url, '/path2/')) 41 | 42 | def test_add_path_trailing_slash_and_existing_path(self): 43 | url = u'http://a.com/path1/' 44 | self.assertEquals('http://a.com/path1/path2', ops.add_path(url, 'path2')) 45 | self.assertEquals('http://a.com/path1/path2', ops.add_path(url, '/path2')) 46 | self.assertEquals('http://a.com/path1/path2/', ops.add_path(url, 'path2/')) 47 | self.assertEquals('http://a.com/path1/path2/', ops.add_path(url, '/path2/')) 48 | 49 | def test_add_path_fragment(self): 50 | url = u'http://a.com/path1/#anchor' 51 | self.assertEquals('http://a.com/path1/path2#anchor', ops.add_path(url, 'path2')) 52 | self.assertEquals('http://a.com/path1/path2/#anchor', ops.add_path(url, 'path2/')) 53 | 54 | def test_add_path_query_string(self): 55 | url = u'http://a.com/path1/?a=b' 56 | self.assertEquals('http://a.com/path1/path2?a=b', ops.add_path(url, 'path2')) 57 | self.assertEquals('http://a.com/path1/path2/?a=b', ops.add_path(url, 'path2/')) 58 | 59 | def test_query_param(self): 60 | self.assertEquals(('a', 'b'), ops._query_param('a', 'b')) 61 | 62 | def test_query_param_unicode(self): 63 | # unicode objects should be encoded as utf-8 bytes 64 | self.assertEquals(('a', 'b'), ops._query_param('a', u'b')) 65 | self.assertEquals(('a', '\xc3\xa9'), ops._query_param('a', u'\xe9')) 66 | 67 | # bytes should be remain as bytes 68 | self.assertEquals(('a', '\xc3\xa9'), ops._query_param('a', '\xc3\xa9')) 69 | 70 | def test_add_query_params(self): 71 | url = u'http://a.com' 72 | self.assertEquals('http://a.com?a=b', ops.add_query_params(url, ('a', 'b'))) 73 | self.assertEquals('http://a.com?a=b', ops.add_query_params(url, {'a': 'b'})) 74 | self.assertEquals('http://a.com?a=%C3%A9', ops.add_query_params(url, {'a': '\xc3\xa9'})) 75 | 76 | url = u'http://a.com/path' 77 | self.assertEquals('http://a.com/path?a=b', ops.add_query_params(url, {'a': 'b'})) 78 | 79 | url = u'http://a.com?a=b' 80 | self.assertEquals('http://a.com?a=b&a=c', ops.add_query_params(url, ('a', 'c'))) 81 | self.assertEquals('http://a.com?a=b&c=d', ops.add_query_params(url, ('c', 'd'))) 82 | 83 | def test_update_query_params(self): 84 | url = u'http://a.com?a=b' 85 | self.assertEquals('http://a.com?a=b', ops.update_query_params(url, {})) 86 | self.assertEquals('http://a.com?a=c', ops.update_query_params(url, ('a', 'c'))) 87 | self.assertEquals('http://a.com?a=b&c=d', ops.update_query_params(url, {'c': 'd'})) 88 | self.assertEquals('http://a.com?a=%C4%A9', ops.update_query_params(url, {'a': '\xc4\xa9'})) 89 | 90 | url = u'http://a.com/path?a=b' 91 | self.assertEquals('http://a.com/path?a=c', ops.update_query_params(url, {'a': 'c'})) 92 | 93 | def test_escaping(self): 94 | url = u'http://a.com' 95 | self.assertEquals('http://a.com?my+key=c', ops.add_query_params(url, ('my key', 'c'))) 96 | self.assertEquals('http://a.com?c=my+val', ops.add_query_params(url, ('c', 'my val'))) 97 | 98 | def test_no_double_escaping_existing_params(self): 99 | url = 'http://a.com?a=%C4%A9' 100 | self.assertEquals('http://a.com?a=%C4%A9&c=d', ops.update_query_params(url, {'c': 'd'})) 101 | 102 | url = 'http://a.com?a=my+val' 103 | self.assertEquals('http://a.com?a=my+val&c=d', ops.update_query_params(url, {'c': 'd'})) 104 | 105 | class GraphUrlTests(TestCase): 106 | def setUp(self): 107 | self.graph = graph.Graph() 108 | 109 | def test_initial_state(self): 110 | self.assertEquals(graph.Graph.API_ROOT, self.graph.url) 111 | 112 | def test_getitem(self): 113 | expected = 'https://graph.facebook.com/path' 114 | self.assertEquals(expected, self.graph.path.url) 115 | 116 | expected = 'https://graph.facebook.com/path/path2' 117 | self.assertEquals(expected, self.graph.path.path2.url) 118 | 119 | def test_getitem_slice(self): 120 | url = self.graph[0:20].url 121 | self.assertTrue(url.startswith('https://graph.facebook.com/?')) 122 | self.assertTrue('offset=0' in url) 123 | self.assertTrue('limit=20' in url) 124 | 125 | def test_getattr(self): 126 | expected = 'https://graph.facebook.com/path' 127 | self.assertEquals(expected, self.graph['path'].url) 128 | 129 | expected = 'https://graph.facebook.com/path/path2' 130 | self.assertEquals(expected, self.graph['path']['path2'].url) 131 | 132 | def test_update_params(self): 133 | expected = 'https://graph.facebook.com/?a=b' 134 | self.graph = self.graph & {'a': 'b'} 135 | self.assertEquals(expected, self.graph.url) 136 | expected += '&c=d' 137 | self.assertEquals(expected, (self.graph & {'c': 'd'}).url) 138 | 139 | def test_set_params(self): 140 | expected = 'https://graph.facebook.com/?a=b' 141 | self.graph = self.graph | {'a': 'b'} 142 | self.assertEquals(expected, self.graph.url) 143 | 144 | expected = 'https://graph.facebook.com/?a=c' 145 | self.assertEquals(expected, (self.graph | {'a': 'c'}).url) 146 | 147 | expected = 'https://graph.facebook.com/?a=b&c=d' 148 | self.assertEquals(expected, (self.graph | {'c': 'd'}).url) 149 | 150 | def test_fields(self): 151 | expected = 'https://graph.facebook.com/?fields=a%2Cb' 152 | self.graph = self.graph.fields('a', 'b') 153 | self.assertEquals(expected, self.graph.url) 154 | 155 | def test_ids(self): 156 | expected = 'https://graph.facebook.com/?ids=a%2Cb' 157 | self.graph = self.graph.ids('a', 'b') 158 | self.assertEquals(expected, self.graph.url) 159 | 160 | 161 | class FQLTests(TestCase): 162 | def setUp(self): 163 | self.fql = FQL(access_token='abc123') 164 | 165 | @patch('facegraph.fql.FQL.fetch_json') 166 | def test_call(self, mock_fetch): 167 | self.fql('my_query') 168 | url = mock_fetch.call_args[0][0] 169 | self.assertTrue(url.startswith('https://api.facebook.com/method/fql.query?')) 170 | self.assertTrue('query=my_query' in url) 171 | self.assertTrue('access_token=abc123' in url) 172 | 173 | @patch('facegraph.fql.FQL.fetch_json') 174 | def test_call_with_arbitrary_params(self, mock_fetch): 175 | self.fql('my_query', key='value') 176 | url = mock_fetch.call_args[0][0] 177 | self.assertTrue(url.startswith('https://api.facebook.com/method/fql.query?')) 178 | self.assertTrue('query=my_query' in url) 179 | self.assertTrue('access_token=abc123' in url) 180 | self.assertTrue('key=value' in url) 181 | 182 | @patch('facegraph.fql.FQL.fetch_json') 183 | def test_multi(self, mock_fetch): 184 | self.fql.multi(['my_query1', 'my_query2']) 185 | url = mock_fetch.call_args[0][0] 186 | self.assertTrue(url.startswith('https://api.facebook.com/method/fql.multiquery?')) 187 | self.assertTrue("&queries=%5B%22my_query1%22%2C+%22my_query2%22%5D" in url) 188 | --------------------------------------------------------------------------------