From aaafb5a7cfa03664985185d796154e5e1f5bfcdb Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Mon, 18 Feb 2013 14:31:45 -0600 Subject: [PATCH 01/38] intermediate changes, switching computers --- downloader.py | 2 -- facebook.py | 62 +++++++++++++++++++-------------------------------- pg.py | 9 ++++---- repeater.py | 9 +++++--- 4 files changed, 33 insertions(+), 49 deletions(-) diff --git a/downloader.py b/downloader.py index 0783552..54acfda 100755 --- a/downloader.py +++ b/downloader.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # Copyright (C) 2012 Ourbunny # # This program is free software: you can redistribute it and/or modify diff --git a/facebook.py b/facebook.py index b2687eb..4b060cb 100755 --- a/facebook.py +++ b/facebook.py @@ -1,6 +1,5 @@ -#!/usr/bin/env python -# # Copyright 2010 Facebook +# Copyright 2013 Ourbunny # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain @@ -14,7 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. # -# Modified for photograbber by Tommy Murphy, ourbunny.com +# Modified for PhotoGrabber by Tommy Murphy, ourbunny.com """Python client library for the Facebook Platform. @@ -25,25 +24,11 @@ JavaScript SDK at http://github.com/facebook/connect-js/. """ -import cgi -import hashlib import time import urllib import logging -from repeater import repeat - -# Find a JSON parser -try: - import json - _parse_json = lambda s: json.loads(s) -except ImportError: - try: - import simplejson - _parse_json = lambda s: simplejson.loads(s) - except ImportError: - # For Google AppEngine - from django.utils import simplejson - _parse_json = lambda s: simplejson.loads(s) +import repeater +import json class GraphAPI(object): """A client for the Facebook Graph API. @@ -93,17 +78,17 @@ def get_object(self, id, limit=500): """ data = [] - has_more = True args = {} args["limit"] = limit # first request self.logger.info('retieving: %s' % id) - response = self._request(id, args) - if response == False: - # OAuthException + try: + response = self._request(id, args) + except repeater.DoNotRepeatError as e: + logger.error(e) return False if response.has_key('data'): @@ -128,7 +113,7 @@ def get_object(self, id, limit=500): return data - @repeat + @repeater.repeat def _follow(self, path): """Follow a graph API path.""" @@ -138,7 +123,7 @@ def _follow(self, path): self.rtt = self.rtt+1 try: - response = _parse_json(file.read()) + response = json.loads(file.read()) self.logger.debug(json.dumps(response, indent=4)) finally: file.close() @@ -147,7 +132,7 @@ def _follow(self, path): response["error"]["message"]) return response - @repeat + @repeater.repeat def _request(self, path, args=None): """Fetches the given path in the Graph API.""" @@ -166,7 +151,7 @@ def _request(self, path, args=None): self.rtt = self.rtt+1 try: - response = _parse_json(file.read()) + response = json.loads(file.read()) self.logger.debug(json.dumps(response, indent=4)) finally: file.close() @@ -180,7 +165,7 @@ def _request(self, path, args=None): response["error"]["message"]) return response - @repeat + @repeater.repeat def fql(self, query): """Execute an FQL query.""" @@ -200,7 +185,7 @@ def fql(self, query): self.rtt = self.rtt+1 try: - response = _parse_json(file.read()) + response = json.loads(file.read()) self.logger.debug(json.dumps(response, indent=4)) if type(response) is dict and "error_code" in response: raise GraphAPIError(response["error_code"], @@ -219,24 +204,23 @@ def reset_stats(self): """Reset the number of HTTP requests performed by GraphAPI.""" self.rtt = 0 + class GraphAPIError(Exception): def __init__(self, code, message): Exception.__init__(self, message) self.code = code -### photograbber specific ### - -import webbrowser - -CLIENT_ID = "139730900025" -RETURN_URL = "http://faceauth.appspot.com/" -SCOPE = ''.join(['user_photos,', - 'friends_photos,', - 'user_likes']) - def request_token(): """Prompt the user to login to facebook and obtain an OAuth token.""" + import webbrowser + + CLIENT_ID = "139730900025" + RETURN_URL = "http://faceauth.appspot.com/" + SCOPE = ''.join(['user_photos,', + 'friends_photos,', + 'user_likes']) + url = ''.join(['https://graph.facebook.com/oauth/authorize?', 'client_id=%(cid)s&', 'redirect_uri=%(rurl)s&', diff --git a/pg.py b/pg.py index cd2e9bd..aa1e0e4 100755 --- a/pg.py +++ b/pg.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # -# Copyright (C) 2012 Ourbunny +# Copyright 2013 Ourbunny # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -24,7 +24,6 @@ import time import logging import os -import multiprocessing # help strings helps = {} @@ -107,7 +106,7 @@ def main(): # check if token works my_info = helper.get_me() - if my_info == False: + if not my_info: logger.error('Provided Token Failed: %s' % args.token) print 'Provided Token Failed: OAuthException' exit() @@ -171,8 +170,8 @@ def main(): args.target.append(raw_input("Target: ")) # get options - if args.c is False and args.a is False: - if args.u is False and args.t is False: + if not args.c and not args.a: + if not args.u and not args.t: print '' print 'Options' print '-------' diff --git a/repeater.py b/repeater.py index c417d2d..44b98c6 100755 --- a/repeater.py +++ b/repeater.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python -# # Copyright (C) 2012 Ourbunny # # This program is free software: you can redistribute it and/or modify @@ -18,6 +16,9 @@ import logging import time +# raise DoNotRepeatError in a function to force repeat() to exit prematurely +class DoNotRepeatError(Exception): pass + # function repeater decorator def repeat(func, n=10, standoff=1.5): """Execute a function repeatedly until success. @@ -45,7 +46,9 @@ def wrapped(*args, **kwargs): while True: try: return func(*args, **kwargs) - except Exception, e: + except DoNotRepeate: + raise + except Exception as e: logger.exception('failed function: %s' % e) if retries < n: retries += 1 From 56eacef0daec20db8dcd3f78108a0d0705dc45b3 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Sun, 10 Mar 2013 21:58:21 -0500 Subject: [PATCH 02/38] repeater cleanup and comments Updated copyright message, DoNotRepeatError, replaced pages with likes --- downloader.py | 2 +- facebook.py | 94 +++++++++++++++++++++++++++++++-------------------- helpers.py | 14 ++++---- pg.py | 15 ++++---- repeater.py | 64 ++++++++++++++++++++++++++--------- 5 files changed, 119 insertions(+), 70 deletions(-) diff --git a/downloader.py b/downloader.py index 54acfda..6e50695 100755 --- a/downloader.py +++ b/downloader.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012 Ourbunny +# Copyright (C) 2013 Ourbunny # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/facebook.py b/facebook.py index 4b060cb..71f95e5 100755 --- a/facebook.py +++ b/facebook.py @@ -1,5 +1,5 @@ # Copyright 2010 Facebook -# Copyright 2013 Ourbunny +# Copyright 2013 Ourbunny (modified for PhotoGrabber) # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain @@ -12,8 +12,6 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. -# -# Modified for PhotoGrabber by Tommy Murphy, ourbunny.com """Python client library for the Facebook Platform. @@ -61,22 +59,34 @@ def __init__(self, access_token=None): self.rtt = 0 # round trip total def get_object(self, id, limit=500): - """Get an entine object from the Graph API by paging over the requested - object until the entire object is retrieved. + """Get an entire object from the Graph API. + + Retreives an entine object by following the pages in a response. + + Args: + id (str): The path of the object to retreive. + + Kwards: + limit (int): The number of object to request per page (default 500) + + Returns: + list|dict. Context dependent - graph = facebook.GraphAPI(access_token) - user = graph.get_object('me') - print user['id'] - photos = graph.get_object('me/photos') - for photo in photos: - print photo['id'] + Raises: + GraphAPIError - id: path to the object to retrieve - limit: number of objects to retrieve in each page [max = 5000] + >>>graph = facebook.GraphAPI(access_token) + >>>user = graph.get_object('me') + >>>print user['id'] + >>>photos = graph.get_object('me/photos') + >>>for photo in photos: + >>> print photo['id'] - Returns [list|dictionary] or False on OAuthException. """ + # API defines max limit as 5K + if limit > 5000: limit = 5000 + data = [] args = {} @@ -85,11 +95,7 @@ def get_object(self, id, limit=500): # first request self.logger.info('retieving: %s' % id) - try: - response = self._request(id, args) - except repeater.DoNotRepeatError as e: - logger.error(e) - return False + response = self._request(id, args) # GraphAPIError if response.has_key('data'): # response is a list @@ -99,7 +105,7 @@ def get_object(self, id, limit=500): # iterate over pages while response['paging'].has_key('next'): page_next = response['paging']['next'] - response = self._follow(page_next) + response = self._follow(page_next) #GraphAPIError if len(response['data']) > 0: data.extend(response['data']) else: @@ -117,19 +123,30 @@ def get_object(self, id, limit=500): def _follow(self, path): """Follow a graph API path.""" + # no need to build URL since it was given to us self.logger.debug('GET: %s' % path) - file = urllib.urlopen(path) + file = urllib.urlopen(path) #IOError self.rtt = self.rtt+1 try: - response = json.loads(file.read()) + response = json.loads(file.read()) #ValueError, IOError self.logger.debug(json.dumps(response, indent=4)) finally: file.close() + if response.get("error"): - raise GraphAPIError(response["error"]["code"], - response["error"]["message"]) + try: + raise GraphAPIError(response["error"]["code"], + response["error"]["message"]) + except GraphAPIError as e: + if e.code == 190 or e.code == 2500: + # do not bother repeating if OAuthException + raise repeater.DoNotRepeatError(e) + else: + # raise original GraphAPIError (and try again) + raise + return response @repeater.repeat @@ -146,23 +163,28 @@ def _request(self, path, args=None): urllib.urlencode(args)]) self.logger.debug('GET: %s' % path) - file = urllib.urlopen(path) + file = urllib.urlopen(path) #IOError self.rtt = self.rtt+1 try: - response = json.loads(file.read()) + response = json.loads(file.read()) #ValueError, IOError self.logger.debug(json.dumps(response, indent=4)) finally: file.close() + if response.get("error"): - code = response["error"]["code"] - if code == 190 or code == 2500: - # abort on OAuthException - self.logger.error(response["error"]["message"]) - return False - raise GraphAPIError(response["error"]["code"], - response["error"]["message"]) + try: + raise GraphAPIError(response["error"]["code"], + response["error"]["message"]) + except GraphAPIError as e: + if e.code == 190 or e.code == 2500: + # do not bother repeating if OAuthException + raise repeater.DoNotRepeatError(e) + else: + # raise original GraphAPIError (and try again) + raise + return response @repeater.repeat @@ -186,12 +208,11 @@ def fql(self, query): try: response = json.loads(file.read()) - self.logger.debug(json.dumps(response, indent=4)) + self.logger.debug(json.dumps(response, indent=4)) #ValueError, IOError if type(response) is dict and "error_code" in response: + # add do not repeate error raise GraphAPIError(response["error_code"], response["error_msg"]) - except Exception, e: - raise e finally: file.close() return response @@ -230,4 +251,3 @@ def request_token(): args = { "cid" : CLIENT_ID, "rurl" : RETURN_URL, "scope" : SCOPE, } webbrowser.open(url % args) - diff --git a/helpers.py b/helpers.py index 535fde7..4b8000a 100755 --- a/helpers.py +++ b/helpers.py @@ -1,6 +1,4 @@ -#!/usr/bin/env python -# -# Copyright (C) 2012 Ourbunny +# Copyright (C) 2013 Ourbunny # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -28,7 +26,7 @@ class Helper(object): helper = helpers.Helper(graph) helper.get_friends(id) helper.get_subscriptions(id) - helper.get_pages(id) + helper.get_likes(id) helper.get_albums(id) helper.get_tagged(id) helper.get_tagged_albums(id) @@ -61,7 +59,7 @@ def find_album_ids(self, picture_ids): new_ids = self.graph.fql(q % pids) try: new_ids = [x['object_id'] for x in new_ids] - except Exception,e: + except Exception as e: self.logger.error('no album access') self.logger.error('%s' % e) bad_query = q % pids @@ -89,7 +87,7 @@ def get_subscriptions(self, id): data = self.graph.get_object('%s/subscribedto' % id, 5000) return sorted(data, key=lambda k:k['name'].lower()) - def get_pages(self, id): + def get_likes(self, id): data = self.graph.get_object('%s/likes' % id, 5000) return sorted(data, key=lambda k:k['name'].lower()) @@ -113,6 +111,8 @@ def _fill_album(self, album, comments): """Takes an already loaded album and fills out the photos and comments""" + # album must be dictionary, with 'photos' + # get comments if comments and 'comments' in album: if len(album['comments']) >= 25: @@ -157,8 +157,6 @@ def get_album(self, id, comments=False): return album album = self.graph.get_object('%s' % id) - if type(album) is not dict: - import pdb;pdb.set_trace() return self._fill_album(album, comments) def get_albums(self, id, comments=False): diff --git a/pg.py b/pg.py index aa1e0e4..b2158c7 100755 --- a/pg.py +++ b/pg.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # -# Copyright 2013 Ourbunny +# Copyright (C) 2013 Ourbunny # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -35,7 +35,7 @@ helps['token'] = 'Specify the OAuth token used to authenticate with Facebook.' helps['list-targets'] ='Display names and object_id\'s of potential targets' helps['list-albums'] = 'List the albums uploaded by a target. Separate the object_id\'s of targets with spaces.' -helps['target'] = 'Download targets. Separate the object_id\'s of people or pages with spaces.' +helps['target'] = 'Download targets. Separate the object_id\'s of people or likes with spaces.' helps['album'] = 'Download full albums. Separate the object_id\'s of the albums with spaces.' helps['dir'] = 'Specify the directory to store the downloaded information. (Use with --target or --album)' helps['debug'] = 'Log extra debug information to pg.log' @@ -44,12 +44,11 @@ def print_func(text): print text def main(): - # parse arguements parser = argparse.ArgumentParser(description="Download Facebook photos.") parser.add_argument('--gui', action='store_true', help=helps['gui']) parser.add_argument('--token', help=helps['token']) - parser.add_argument('--list-targets', choices=('me','friends','pages','following','all'), help=helps['list-targets']) + parser.add_argument('--list-targets', choices=('me','friends','likes','following','all'), help=helps['list-targets']) parser.add_argument('--list-albums', nargs='+', help=helps['list-albums']) parser.add_argument('--target', nargs='+', help=helps['target']) parser.add_argument('-u', action='store_true', help=helps['u']) @@ -111,20 +110,20 @@ def main(): print 'Provided Token Failed: OAuthException' exit() - # --list-targets {'me','friends','pages','following','all'} + # --list-targets {'me','friends','likes','following','all'} target_list = [] if args.list_targets == 'me': target_list.append(my_info) elif args.list_targets == 'friends': target_list.extend(helper.get_friends('me')) - elif args.list_targets == 'pages': - target_list.extend(helper.get_pages('me')) + elif args.list_targets == 'likes': + target_list.extend(helper.get_likes('me')) elif args.list_targets == 'following': target_list.extend(helper.get_subscriptions('me')) elif args.list_targets == 'all': target_list.append(my_info) target_list.extend(helper.get_friends('me')) - target_list.extend(helper.get_pages('me')) + target_list.extend(helper.get_likes('me')) target_list.extend(helper.get_subscriptions('me')) if args.list_targets is not None: diff --git a/repeater.py b/repeater.py index 44b98c6..e9c1545 100755 --- a/repeater.py +++ b/repeater.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012 Ourbunny +# Copyright (C) 2013 Ourbunny # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -17,27 +17,58 @@ import time # raise DoNotRepeatError in a function to force repeat() to exit prematurely -class DoNotRepeatError(Exception): pass +class DoNotRepeatError(Exception): + def __init__(self, error): + Exception.__init__(self, error.message) + self.error = error # function repeater decorator def repeat(func, n=10, standoff=1.5): """Execute a function repeatedly until success. - @repeat - def fail(): - print 'try fail...' - throw new Exception() + Args: + func (function): The function to repeat - @repeat - def pass(): - print 'pass' + Kwargs: + n (int): The number of times to repeate the function before raising an error + standoff (float): Multiplier increment to wait between retrying the function - fail() - pass() + >>>import repeater.repeat + + >>>@repeater.repeat + >>>def fail(): + >>> print 'A' + >>> raise Exception() + >>> print 'B' + + >>>@repeater.repeat + >>> def pass(): + >>> print 'B' + + >>>@repeater.repeat + >>>def failpass(): + >>> print 'C' + >>> raise repeater.DoNotRepeatError(Exception()) + >>> print 'D' + + >>>fail() # prints 'A' 10 times, failing each time + A + A + A + A + A + A + A + A + A + A + + >>>pass() # prints 'B' once, succeeding on first try + B + + >>>failpass() # prints 'C' once, then fails + C - func: pointer to function - n: retry the call times before raising an error - standoff: multiplier increment for each standoff """ def wrapped(*args, **kwargs): @@ -46,8 +77,9 @@ def wrapped(*args, **kwargs): while True: try: return func(*args, **kwargs) - except DoNotRepeate: - raise + except DoNotRepeate as e: + # raise the exception that caused funciton failure + raise e.error except Exception as e: logger.exception('failed function: %s' % e) if retries < n: From ca566f7895bc82257c81cbbcf55d7edc00f18351 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Sun, 10 Mar 2013 22:15:37 -0500 Subject: [PATCH 03/38] Better readme description --- README.md | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 9a4e053..15dc1fb 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ # PhotoGrabber -A cross platform desktop application to backup images from facebook. This repo -holds an experimental build that makes use of the Graph API, provides a command -line interface, and uses the wxPython GUI toolkit. +A cross platform desktop application to backup images from Facebook. ## License -Copyright (R) 2012 Ourbunny +Copyright (C) 2013 Ourbunny This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -19,17 +17,14 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . +## Dependencies + +* facebook-sdk (Apache 2.0) +* [PySide](http://qt-project.org/wiki/Category:LanguageBindings::PySide) (LGPL v2.1) +* [Qt](http://qt-project.org) (LGPL v2.1) + ## Contributors The following individuals have provided code patches that have been included in a PhotoGrabber release. -Bryce Boe - bryceboe.com - -## TODO - * eliminate app login from wx GUI code - * prevent use from accidentally pressing a button twice - * make code/comments uniform - * split up downloader function and utilize @repeat decorator - * cleanup path names to work with unicode on multiple filesystems - * add packaging details - +Bryce Boe - [bryceboe.com](http://bryceboe.com) \ No newline at end of file From d235794deb23aca9a419db543e4ffea3d2300532 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Sun, 10 Mar 2013 22:44:25 -0500 Subject: [PATCH 04/38] added utf-8 encoding to src files added utf-8 encoding to src files and also removed wxpythong gui files --- downloader.py | 2 + facebook.py | 2 + gui.wxg | 254 ----------------------------------------- gui/__init__.py | 0 gui/wxFrameChooser.py | 61 ---------- gui/wxFrameDownload.py | 62 ---------- gui/wxFrameLogin.py | 47 -------- gui/wxFrameOptions.py | 59 ---------- gui/wxFrameToken.py | 51 --------- helpers.py | 2 + pgui.py | 193 ------------------------------- repeater.py | 2 + 12 files changed, 8 insertions(+), 727 deletions(-) delete mode 100644 gui.wxg delete mode 100644 gui/__init__.py delete mode 100644 gui/wxFrameChooser.py delete mode 100644 gui/wxFrameDownload.py delete mode 100644 gui/wxFrameLogin.py delete mode 100644 gui/wxFrameOptions.py delete mode 100644 gui/wxFrameToken.py delete mode 100755 pgui.py diff --git a/downloader.py b/downloader.py index 6e50695..a6cc35b 100755 --- a/downloader.py +++ b/downloader.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright (C) 2013 Ourbunny # # This program is free software: you can redistribute it and/or modify diff --git a/facebook.py b/facebook.py index 71f95e5..6af423a 100755 --- a/facebook.py +++ b/facebook.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright 2010 Facebook # Copyright 2013 Ourbunny (modified for PhotoGrabber) # diff --git a/gui.wxg b/gui.wxg deleted file mode 100644 index 9e287e1..0000000 --- a/gui.wxg +++ /dev/null @@ -1,254 +0,0 @@ - - - - - - - PhotoGrabber - 1 - 1 - 400, 200 - - wxVERTICAL - - wxEXPAND - 0 - - - - 400, 200 - - wxVERTICAL - - wxEXPAND - 0 - - - - Step 1) Click the button below to authenticate PhotoGrabber with Facebook. - - - - wxEXPAND - 0 - - - - - - - - - - - - - PhotoGrabber - 1 - 1 - 400, 200 - - wxHORIZONTAL - - wxEXPAND - 0 - - - - 400, 200 - - wxVERTICAL - - wxALL|wxEXPAND - 0 - - - - Step 2) Paste your login token in the field below: - - - - wxEXPAND - 0 - - - - - - - wxEXPAND - 0 - - - - - - - - - - - - - PhotoGrabber - 1 - 1 - 400, 200 - - wxHORIZONTAL - - wxEXPAND - 0 - - - - 400, 200 - - wxVERTICAL - - wxEXPAND - 0 - - - - Step 3) Select the people or pages that you would like to download. - - - - wxEXPAND - 0 - - - - 0 - Press ctrl to select multiple users or pages - - Myself - Jackie Murphy - Monika Henn - Mat Stolarik - Melissa Patterson - - - - - wxEXPAND - 0 - - - - - - - - - - - - - PhotoGrabber - 1 - 1 - 400, 200 - - wxHORIZONTAL - - wxEXPAND - 0 - - - - 400, 200 - - wxVERTICAL - - wxEXPAND - 0 - - - - Step 4) Select your download options. - - - - 0 - - - - - - - 0 - - - - - - - 0 - - - - - - - 0 - - - - - - - wxEXPAND - 0 - - - - - - - - - - - - - PhotoGrabber - 1 - 1 - 400, 200 - - wxHORIZONTAL - - wxEXPAND - 0 - - - - 400, 200 - - wxVERTICAL - - wxALIGN_CENTER_HORIZONTAL - 0 - - - - 1 - - - - - wxEXPAND - 0 - - - - - - - - - - - diff --git a/gui/__init__.py b/gui/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/gui/wxFrameChooser.py b/gui/wxFrameChooser.py deleted file mode 100644 index c24d487..0000000 --- a/gui/wxFrameChooser.py +++ /dev/null @@ -1,61 +0,0 @@ -# -*- coding: utf-8 -*- -# generated by wxGlade 0.6.4 on Sat Oct 27 14:06:05 2012 - -import wx - -class wxFrameChooser(wx.Frame): - def __init__(self, *args, **kwds): - # begin wxGlade: wxFrameChooser.__init__ - kwds["style"] = wx.DEFAULT_FRAME_STYLE - wx.Frame.__init__(self, *args, **kwds) - self.panel_3 = wx.Panel(self, -1) - self.text_ctrl_3 = wx.TextCtrl(self.panel_3, -1, "Step 3) Select the people or pages that you would like to download.", style=wx.TE_MULTILINE | wx.TE_READONLY) - self.list_box_1 = wx.ListBox(self.panel_3, -1, choices=["Myself", "Jackie Murphy", "Monika Henn", "Mat Stolarik", "Melissa Patterson"], style=wx.LB_MULTIPLE | wx.LB_ALWAYS_SB) - self.button_chooser = wx.Button(self.panel_3, -1, "Select Options") - - self.__set_properties() - self.__do_layout() - # end wxGlade - - def __set_properties(self): - # begin wxGlade: wxFrameChooser.__set_properties - self.SetTitle("PhotoGrabber") - self.SetSize((400, 200)) - self.list_box_1.SetToolTipString("Press ctrl to select multiple users or pages") - self.list_box_1.SetSelection(0) - self.panel_3.SetMinSize((400, 200)) - # end wxGlade - - def __do_layout(self): - # begin wxGlade: wxFrameChooser.__do_layout - sizer_3 = wx.BoxSizer(wx.HORIZONTAL) - sizer_3_1 = wx.BoxSizer(wx.VERTICAL) - sizer_3_1.Add(self.text_ctrl_3, 1, wx.EXPAND, 0) - sizer_3_1.Add(self.list_box_1, 3, wx.EXPAND, 0) - sizer_3_1.Add(self.button_chooser, 1, wx.EXPAND, 0) - self.panel_3.SetSizer(sizer_3_1) - sizer_3.Add(self.panel_3, 1, wx.EXPAND, 0) - self.SetSizer(sizer_3) - sizer_3.SetSizeHints(self) - self.Layout() - self.Centre() - # end wxGlade - - # PhotoGrabber glue - - def Setup(self, state): - self.state = state - # populate listbox - # import pdb;pdb.set_trace() - self.list_box_1.Set([x['name'] for x in self.state.target_list]) - self.button_chooser.Bind(wx.EVT_BUTTON, self.Submit) - - def Submit(self, event): - # tell self.state which items were selected - # self.state.selected_list - # import pdb;pdb.set_trace() - self.state.targets = [] - for id in self.list_box_1.GetSelections(): - self.state.targets.append(self.state.target_list[id]['id']) - - self.state.toOptions() diff --git a/gui/wxFrameDownload.py b/gui/wxFrameDownload.py deleted file mode 100644 index c809542..0000000 --- a/gui/wxFrameDownload.py +++ /dev/null @@ -1,62 +0,0 @@ -# -*- coding: utf-8 -*- -# generated by wxGlade 0.6.4 on Sat Oct 27 14:06:05 2012 - -import wx - -class wxFrameDownload(wx.Frame): - def __init__(self, *args, **kwds): - # begin wxGlade: wxFrameDownload.__init__ - kwds["style"] = wx.DEFAULT_FRAME_STYLE - wx.Frame.__init__(self, *args, **kwds) - self.panel_5 = wx.Panel(self, -1) - self.label_status = wx.StaticText(self.panel_5, -1, "\n\nStatus", style=wx.ALIGN_CENTRE) - self.button_stop = wx.Button(self.panel_5, -1, "Quit") - - self.__set_properties() - self.__do_layout() - # end wxGlade - - def __set_properties(self): - # begin wxGlade: wxFrameDownload.__set_properties - self.SetTitle("PhotoGrabber") - self.SetSize((400, 200)) - self.panel_5.SetMinSize((400, 200)) - # end wxGlade - - def __do_layout(self): - # begin wxGlade: wxFrameDownload.__do_layout - sizer_5 = wx.BoxSizer(wx.HORIZONTAL) - sizer_5_1 = wx.BoxSizer(wx.VERTICAL) - sizer_5_1.Add(self.label_status, 1, wx.ALIGN_CENTER_HORIZONTAL, 0) - sizer_5_1.Add(self.button_stop, 1, wx.EXPAND, 0) - self.panel_5.SetSizer(sizer_5_1) - sizer_5.Add(self.panel_5, 1, wx.EXPAND, 0) - self.SetSizer(sizer_5) - sizer_5.SetSizeHints(self) - self.Layout() - self.Centre() - # end wxGlade - - # PhotoGrabber glue - - def Setup(self, state): - self.state = state - self.button_stop.Bind(wx.EVT_BUTTON, self.Quit) - self.Bind(wx.EVT_CLOSE, self.OnClose) - - def Begin(self): - # tell the main program to begin downloading - # provide pointer to Update() function to notify UI of download status - self.state.beginDownload(self.Update) - - def Update(self, event): - self.label_status.SetLabel(event.GetValue()) - self.Layout() - - def Quit(self, event): - self.Close() - - def OnClose(self, event): - # notify app to hard close - import os - os._exit(1) diff --git a/gui/wxFrameLogin.py b/gui/wxFrameLogin.py deleted file mode 100644 index 9a23660..0000000 --- a/gui/wxFrameLogin.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- -# generated by wxGlade 0.6.4 on Sat Oct 27 14:06:05 2012 - -import wx - -class wxFrameLogin(wx.Frame): - def __init__(self, *args, **kwds): - # begin wxGlade: wxFrameLogin.__init__ - kwds["style"] = wx.DEFAULT_FRAME_STYLE - wx.Frame.__init__(self, *args, **kwds) - self.panel_1 = wx.Panel(self, -1) - self.text_ctrl_1 = wx.TextCtrl(self.panel_1, -1, "Step 1) Click the button below to authenticate PhotoGrabber with Facebook.", style=wx.TE_MULTILINE | wx.TE_READONLY) - self.button_login = wx.Button(self.panel_1, -1, "Login to Facebook") - - self.__set_properties() - self.__do_layout() - # end wxGlade - - def __set_properties(self): - # begin wxGlade: wxFrameLogin.__set_properties - self.SetTitle("PhotoGrabber") - self.SetSize((400, 200)) - self.panel_1.SetMinSize((400, 200)) - # end wxGlade - - def __do_layout(self): - # begin wxGlade: wxFrameLogin.__do_layout - sizer_1 = wx.BoxSizer(wx.VERTICAL) - sizer_1_1 = wx.BoxSizer(wx.VERTICAL) - sizer_1_1.Add(self.text_ctrl_1, 1, wx.EXPAND, 0) - sizer_1_1.Add(self.button_login, 1, wx.EXPAND, 0) - self.panel_1.SetSizer(sizer_1_1) - sizer_1.Add(self.panel_1, 1, wx.EXPAND, 0) - self.SetSizer(sizer_1) - sizer_1.SetSizeHints(self) - self.Layout() - self.Centre() - # end wxGlade - - # PhotoGrabber glue - - def Setup(self, state): - self.state = state - self.button_login.Bind(wx.EVT_BUTTON, self.Submit) - - def Submit(self, event): - self.state.toToken() diff --git a/gui/wxFrameOptions.py b/gui/wxFrameOptions.py deleted file mode 100644 index c0792c9..0000000 --- a/gui/wxFrameOptions.py +++ /dev/null @@ -1,59 +0,0 @@ -# -*- coding: utf-8 -*- -# generated by wxGlade 0.6.4 on Sat Oct 27 14:06:05 2012 - -import wx - -class wxFrameOptions(wx.Frame): - def __init__(self, *args, **kwds): - # begin wxGlade: wxFrameOptions.__init__ - kwds["style"] = wx.DEFAULT_FRAME_STYLE - wx.Frame.__init__(self, *args, **kwds) - self.panel_4 = wx.Panel(self, -1) - self.text_ctrl_4 = wx.TextCtrl(self.panel_4, -1, "Step 4) Select your download options.", style=wx.TE_MULTILINE | wx.TE_READONLY) - self.checkbox_1 = wx.CheckBox(self.panel_4, -1, "Tagged photos") - self.checkbox_2 = wx.CheckBox(self.panel_4, -1, "Full Albums of tagged photos") - self.checkbox_3 = wx.CheckBox(self.panel_4, -1, "Albums uploaded by the user") - self.checkbox_4 = wx.CheckBox(self.panel_4, -1, "Comments and Tagging Information") - self.button_options = wx.Button(self.panel_4, -1, "Begin Download!") - - self.__set_properties() - self.__do_layout() - # end wxGlade - - def __set_properties(self): - # begin wxGlade: wxFrameOptions.__set_properties - self.SetTitle("PhotoGrabber") - self.SetSize((400, 200)) - self.panel_4.SetMinSize((400, 200)) - # end wxGlade - - def __do_layout(self): - # begin wxGlade: wxFrameOptions.__do_layout - sizer_4 = wx.BoxSizer(wx.HORIZONTAL) - sizer_4_1 = wx.BoxSizer(wx.VERTICAL) - sizer_4_1.Add(self.text_ctrl_4, 1, wx.EXPAND, 0) - sizer_4_1.Add(self.checkbox_1, 0, 0, 0) - sizer_4_1.Add(self.checkbox_2, 0, 0, 0) - sizer_4_1.Add(self.checkbox_3, 0, 0, 0) - sizer_4_1.Add(self.checkbox_4, 0, 0, 0) - sizer_4_1.Add(self.button_options, 1, wx.EXPAND, 0) - self.panel_4.SetSizer(sizer_4_1) - sizer_4.Add(self.panel_4, 1, wx.EXPAND, 0) - self.SetSizer(sizer_4) - sizer_4.SetSizeHints(self) - self.Layout() - self.Centre() - # end wxGlade - - # PhotoGrabber glue - - def Setup(self, state): - self.state = state - self.button_options.Bind(wx.EVT_BUTTON, self.Submit) - - def Submit(self, event): - self.state.t = self.checkbox_1.GetValue() - self.state.a = self.checkbox_2.GetValue() - self.state.u = self.checkbox_3.GetValue() - self.state.c = self.checkbox_4.GetValue() - self.state.toFolder() diff --git a/gui/wxFrameToken.py b/gui/wxFrameToken.py deleted file mode 100644 index c8b7546..0000000 --- a/gui/wxFrameToken.py +++ /dev/null @@ -1,51 +0,0 @@ -# -*- coding: utf-8 -*- -# generated by wxGlade 0.6.4 on Sat Oct 27 14:06:05 2012 - -import wx - -class wxFrameToken(wx.Frame): - def __init__(self, *args, **kwds): - # begin wxGlade: wxFrameToken.__init__ - kwds["style"] = wx.DEFAULT_FRAME_STYLE - wx.Frame.__init__(self, *args, **kwds) - self.panel_2 = wx.Panel(self, -1) - self.text_ctrl_2 = wx.TextCtrl(self.panel_2, -1, "Step 2) Paste your login token in the field below:", style=wx.TE_MULTILINE | wx.TE_READONLY) - self.text_ctrl_token = wx.TextCtrl(self.panel_2, -1, "", style=wx.TE_MULTILINE) - self.button_token = wx.Button(self.panel_2, -1, "Process Token") - - self.__set_properties() - self.__do_layout() - # end wxGlade - - def __set_properties(self): - # begin wxGlade: wxFrameToken.__set_properties - self.SetTitle("PhotoGrabber") - self.SetSize((400, 200)) - self.panel_2.SetMinSize((400, 200)) - # end wxGlade - - def __do_layout(self): - # begin wxGlade: wxFrameToken.__do_layout - sizer_2 = wx.BoxSizer(wx.HORIZONTAL) - sizer_2_1 = wx.BoxSizer(wx.VERTICAL) - sizer_2_1.Add(self.text_ctrl_2, 1, wx.ALL | wx.EXPAND, 0) - sizer_2_1.Add(self.text_ctrl_token, 1, wx.EXPAND, 0) - sizer_2_1.Add(self.button_token, 1, wx.EXPAND, 0) - self.panel_2.SetSizer(sizer_2_1) - sizer_2.Add(self.panel_2, 1, wx.EXPAND, 0) - self.SetSizer(sizer_2) - sizer_2.SetSizeHints(self) - self.Layout() - self.Centre() - # end wxGlade - - # PhotoGrabber glue - - def Setup(self, state): - self.state = state - self.button_token.Bind(wx.EVT_BUTTON, self.Submit) - - def Submit(self, event): - # TODO SANATIZE INPUT? - self.state.token = self.text_ctrl_token.GetValue() - self.state.toChooser() diff --git a/helpers.py b/helpers.py index 4b8000a..d371d8f 100755 --- a/helpers.py +++ b/helpers.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright (C) 2013 Ourbunny # # This program is free software: you can redistribute it and/or modify diff --git a/pgui.py b/pgui.py deleted file mode 100755 index 10ac9e5..0000000 --- a/pgui.py +++ /dev/null @@ -1,193 +0,0 @@ -import wx -from gui.wxFrameLogin import wxFrameLogin -from gui.wxFrameToken import wxFrameToken -from gui.wxFrameChooser import wxFrameChooser -from gui.wxFrameOptions import wxFrameOptions -from gui.wxFrameDownload import wxFrameDownload - -import facebook -import helpers -import downloader - -import logging -import threading - - -myEVT_UPDATE_STATUS = wx.NewEventType() -EVT_UPDATE_STATUS = wx.PyEventBinder(myEVT_UPDATE_STATUS, 1) - -class UpdateStatusEvent(wx.PyCommandEvent): - """Event to signal a status update is ready""" - def __init__(self, etype, eid, value=None): - """Creates the event object""" - wx.PyCommandEvent.__init__(self, etype, eid) - self._value = value - - def GetValue(self): - """Returns the value form the event. - - @return: the value of this event - """ - return self._value - -class ProcessThread(threading.Thread): - def __init__(self, parent, config, helper): - """ - @param parent: The gui object that should recieve updates - @param config: dictionary of download config - @param helper: facebook connnection - """ - threading.Thread.__init__(self) - self._parent = parent - self._config = config - self._helper = helper - - def run(self): - """Overrides Thread.run. Called by Thread.start().""" - self._helper.process(self._config, self.update) - - def update(self, text): - evt = UpdateStatusEvent(myEVT_UPDATE_STATUS, -1, text) - wx.PostEvent(self._parent, evt) - -class PhotoGrabberGUI(wx.App): - """Control and Data Structure for GUI. - - helper - Instance of the facebook object. Performs Graph API queries. - - target_list - People/pages to download. - - directory - Location to save files. - - current_frame - Current GUI frame (wxFrame). The PhotoGrabberGUI object is - passed to the frame to pass data and issue control follow - events. - - Each frame must implement a Setup() function and call the - appropriate PhotoGrabberGui.to* function to advance to next - frame. - """ - - logger = logging.getLogger('PhotoGrabberGUI') - helper = None - current_frame = None - target_list = [] # read by GUI to display usernames - - # TODO: document and make more descriptive - token = None # authentication token to use - targets = [] # what to actually download - u = False - t = False - c = False - a = False - directory = None # directory to store downloads - - def OnInit(self): - wx.InitAllImageHandlers() - self.current_frame = wxFrameLogin(None, -1, "") - self.current_frame.Setup(self) - self.SetTopWindow(self.current_frame) - self.current_frame.Show() - return 1 - - def __nextFrame(self, frame): - """Destroy current frame then create and setup the next frame.""" - self.current_frame.Destroy() - self.current_frame = frame - self.current_frame.Setup(self) - self.SetTopWindow(self.current_frame) - self.current_frame.Show() - - def __errorDialog(self, message): - msg_dialog = wx.MessageDialog(parent=self.current_frame, - message=message, - caption='Error', - style=wx.OK | wx.ICON_ERROR | wx.STAY_ON_TOP - ) - msg_dialog.ShowModal() - msg_dialog.Destroy() - - - # workflow functions (called by frames) - # login window - # token window - # chooser window - # options window - # folder dialog - # download status - - def toToken(self): - facebook.request_token() - self.__nextFrame(wxFrameToken(None, -1, "")) - - def toChooser(self): - self.helper = helpers.Helper(facebook.GraphAPI(self.token)) - - # CODE BELOW BLOCKS, CREATE WORKER THREAD - my_info = self.helper.get_me() - if my_info == False: - self.logger.error('Provided Token Failed: %s' % self.token) - self.__errorDialog('Invalid Token. Please re-authenticate with Facebook and try again.') - return - - self.target_list.append(my_info) - self.target_list.extend(self.helper.get_friends('me')) - self.target_list.extend(self.helper.get_pages('me')) - self.target_list.extend(self.helper.get_subscriptions('me')) - # CODE ABOVE BLOCKS, CREAT WORKER THREAD - - # it is possible that there could be multiple 'Tommy Murphy' - # make sure to download all different versions that get selected - - self.__nextFrame(wxFrameChooser(None, -1, "")) - - def toOptions(self): - if self.targets is None or len(self.targets) == 0: - self.__errorDialog('You must select a target.') - else: - self.__nextFrame(wxFrameOptions(None, -1, "")) - - def toFolder(self): - dir_dialog = wx.DirDialog(parent=self.current_frame, - message="Choose a directory:", - style=wx.DD_DEFAULT_STYLE - ) - - if dir_dialog.ShowModal() == wx.ID_OK: - self.directory = dir_dialog.GetPath() - self.logger.info("Download Directory: %s" % self.directory) - dir_dialog.Destroy() - self.toDownload() - else: - self.logger.error("Download Directory: None") - dir_dialog.Destroy() - # let user know they have to select a directory - self.__errorDialog('You must choose a directory.') - - def toDownload(self): - self.__nextFrame(wxFrameDownload(None, -1, "")) - self.current_frame.Begin() - - def beginDownload(self, update): - # TODO: problem - GUI blocked on this - # process each target - - config = {} - config['dir'] = self.directory - config['targets'] = self.targets - config['u'] = self.u - config['t'] = self.t - config['c'] = self.c - config['a'] = self.a - - self.Bind(EVT_UPDATE_STATUS, update) - - worker = ProcessThread(self, config, self.helper) - worker.start() - -# end of class PhotoGrabberGUI - -def start(): - #PhotoGrabber = PhotoGrabberGUI(redirect=True, filename=None) # replace with 0 to use pdb - PhotoGrabber = PhotoGrabberGUI(0) - PhotoGrabber.MainLoop() diff --git a/repeater.py b/repeater.py index e9c1545..4941756 100755 --- a/repeater.py +++ b/repeater.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# # Copyright (C) 2013 Ourbunny # # This program is free software: you can redistribute it and/or modify From 7530434822816eb6141a431300bba458c17c883d Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Mon, 11 Mar 2013 19:50:51 -0500 Subject: [PATCH 05/38] corrected error handler misspelled DoNotRepeatError --- repeater.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/repeater.py b/repeater.py index 4941756..b77baf4 100755 --- a/repeater.py +++ b/repeater.py @@ -79,7 +79,7 @@ def wrapped(*args, **kwargs): while True: try: return func(*args, **kwargs) - except DoNotRepeate as e: + except DoNotRepeatError as e: # raise the exception that caused funciton failure raise e.error except Exception as e: From e0587843d858dbf75a26094fb0794aea29bea60c Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Tue, 12 Mar 2013 20:37:44 -0500 Subject: [PATCH 06/38] clarified options --- pg.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pg.py b/pg.py index b2158c7..180d3aa 100755 --- a/pg.py +++ b/pg.py @@ -27,10 +27,10 @@ # help strings helps = {} -helps['u'] = 'Download all albums uploaded by the targets. (Use with --target)' -helps['t'] = 'Download all photos with the target tagged. (Use with --target)' +helps['u'] = 'Download all albums uploaded by the target. (Use with --target)' +helps['t'] = 'Download all photos where the target is tagged. (Use with --target)' helps['c'] = 'Download full comment data. (Use with --target)' -helps['a'] = 'Download full album, even if just 1 photo has the tagged target. (Use with --target)' +helps['a'] = 'Download full albums, even if just 1 photo has the tagged target. (Use with --target)' helps['gui'] = 'Use wx based GUI' helps['token'] = 'Specify the OAuth token used to authenticate with Facebook.' helps['list-targets'] ='Display names and object_id\'s of potential targets' From dc255da0484ff46ccab0073077b118afc7697e5d Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Tue, 12 Mar 2013 20:38:34 -0500 Subject: [PATCH 07/38] log additional error information Log the URL requested when a GraphAPIError is raised --- facebook.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/facebook.py b/facebook.py index 6af423a..31ad2c9 100755 --- a/facebook.py +++ b/facebook.py @@ -147,6 +147,7 @@ def _follow(self, path): raise repeater.DoNotRepeatError(e) else: # raise original GraphAPIError (and try again) + self.logger.error('GET: %s failed' % path) raise return response @@ -185,6 +186,7 @@ def _request(self, path, args=None): raise repeater.DoNotRepeatError(e) else: # raise original GraphAPIError (and try again) + self.logger.error('GET: %s failed' % path) raise return response @@ -213,6 +215,7 @@ def fql(self, query): self.logger.debug(json.dumps(response, indent=4)) #ValueError, IOError if type(response) is dict and "error_code" in response: # add do not repeate error + self.logger.error('GET: %s failed' % path) raise GraphAPIError(response["error_code"], response["error_msg"]) finally: From 53e2bc2ba6082b3143b038844326ac26cb323ee1 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Tue, 12 Mar 2013 21:21:31 -0500 Subject: [PATCH 08/38] prepare for gui Added extra calls to 'update' around long running functions to better notify GUI of status and improve UI responsiveness. Also switched default execution to GUI instead of CMD. --- helpers.py | 27 ++++++++++++++++++++++----- pg.py | 8 ++++---- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/helpers.py b/helpers.py index d371d8f..ca3e70d 100755 --- a/helpers.py +++ b/helpers.py @@ -39,6 +39,9 @@ class Helper(object): def __init__(self, graph=None): self.graph = graph self.logger = logging.getLogger('helper') + + def update(self, text): + pass def find_album_ids(self, picture_ids): """Find the albums that contains pictures. @@ -57,6 +60,7 @@ def find_album_ids(self, picture_ids): # split query into 25 pictures at a time for i in range(len(picture_ids) / 25 + 1): + self.update(None) pids = ','.join(picture_ids[i * 25:(i+1) * 25]) new_ids = self.graph.fql(q % pids) try: @@ -82,20 +86,24 @@ def get_info(self, id): return self.graph.get_object('%s' % id) def get_friends(self, id): + self.update(None) data = self.graph.get_object('%s/friends' % id, 5000) return sorted(data, key=lambda k:k['name'].lower()) def get_subscriptions(self, id): + self.update(None) data = self.graph.get_object('%s/subscribedto' % id, 5000) return sorted(data, key=lambda k:k['name'].lower()) def get_likes(self, id): + self.update(None) data = self.graph.get_object('%s/likes' % id, 5000) return sorted(data, key=lambda k:k['name'].lower()) # return the list of album information that id has uploaded def get_album_list(self, id): + self.update(None) return self.graph.get_object('%s/albums' % id) # The following methods return a list of albums & photos @@ -119,15 +127,18 @@ def _fill_album(self, album, comments): if comments and 'comments' in album: if len(album['comments']) >= 25: album['comments'] = self.graph.get_object('%s/comments' % album['id']) + self.update(None) # get album photos album['photos'] = self.graph.get_object('%s/photos' % album['id']) + self.update(None) if len(album['photos']) == 0: self.logger.error('album had zero photos: %s' % album['id']) return None for photo in album['photos']: + self.update(None) # get picture comments if comments and 'comments' in photo: n_before = len(photo['comments']['data']) @@ -159,6 +170,7 @@ def get_album(self, id, comments=False): return album album = self.graph.get_object('%s' % id) + self.update(None) return self._fill_album(album, comments) def get_albums(self, id, comments=False): @@ -171,6 +183,7 @@ def get_albums(self, id, comments=False): self.logger.info('albums: %d' % len(data)) for album in data: + self.update(None) album = self._fill_album(album, comments) # remove empty albums @@ -234,6 +247,8 @@ def process(self, config, update): t = config['t'] c = config['c'] a = config['a'] + + self.update = update self.logger.info("%s" % config) @@ -249,13 +264,13 @@ def process(self, config, update): # get user uploaded photos if u: - update('Retrieving %s\'s album data...' % target_info['name']) + self.update('Retrieving %s\'s album data...' % target_info['name']) u_data = self.get_albums(target, comments=c) t_data = [] # get tagged if t: - update('Retrieving %s\'s tagged photo data...' % target_info['name']) + self.update('Retrieving %s\'s tagged photo data...' % target_info['name']) t_data = self.get_tagged(target, comments=c, full=a) if u and t: @@ -274,7 +289,7 @@ def process(self, config, update): for album in data: pics = pics + len(album['photos']) - update('Downloading %d photos...' % pics) + self.update('Downloading %d photos...' % pics) for album in data: # TODO: Error where 2 albums with same name exist @@ -287,11 +302,13 @@ def process(self, config, update): self.logger.info('Waiting for childeren to finish') while multiprocessing.active_children(): - time.sleep(1) + time.sleep(0.1) + self.update(None) + pool.join() self.logger.info('Child processes completed') self.logger.info('albums: %s' % len(data)) self.logger.info('pics: %s' % pics) - update('%d photos downloaded!' % pics) + self.update('%d photos downloaded!' % pics) diff --git a/pg.py b/pg.py index b2158c7..f165b0c 100755 --- a/pg.py +++ b/pg.py @@ -31,7 +31,7 @@ helps['t'] = 'Download all photos with the target tagged. (Use with --target)' helps['c'] = 'Download full comment data. (Use with --target)' helps['a'] = 'Download full album, even if just 1 photo has the tagged target. (Use with --target)' -helps['gui'] = 'Use wx based GUI' +helps['cmd'] = 'Use command line instead of Qt GUI' helps['token'] = 'Specify the OAuth token used to authenticate with Facebook.' helps['list-targets'] ='Display names and object_id\'s of potential targets' helps['list-albums'] = 'List the albums uploaded by a target. Separate the object_id\'s of targets with spaces.' @@ -41,12 +41,12 @@ helps['debug'] = 'Log extra debug information to pg.log' def print_func(text): - print text + if text: print text def main(): # parse arguements parser = argparse.ArgumentParser(description="Download Facebook photos.") - parser.add_argument('--gui', action='store_true', help=helps['gui']) + parser.add_argument('--cmd', action='store_true', help=helps['cmd']) parser.add_argument('--token', help=helps['token']) parser.add_argument('--list-targets', choices=('me','friends','likes','following','all'), help=helps['list-targets']) parser.add_argument('--list-albums', nargs='+', help=helps['list-albums']) @@ -80,7 +80,7 @@ def main(): logger.info('Arguments parsed, logger configured.') # GUI - if args.gui: + if not args.cmd: logger.info('Starting GUI.') import pgui pgui.start() From 1bf86ba3ffe521c4666a83909f3c5f340f3029ae Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Tue, 12 Mar 2013 21:22:59 -0500 Subject: [PATCH 09/38] gui code PySide gui for photograbber --- pgui.py | 207 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ wizard.py | 137 ++++++++++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 pgui.py create mode 100644 wizard.py diff --git a/pgui.py b/pgui.py new file mode 100644 index 0000000..9265d2a --- /dev/null +++ b/pgui.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2013 Ourbunny +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# Req for building on Win7 +# Python 2.7.3 - win32 +# PySide 1.1.2 win32 py2.7 +# pywin32-218.win32-py2.7 +# PyInstaller + +import sys +from PySide import QtCore, QtGui +from wizard import Ui_Wizard + +import facebook +import helpers +import downloader + +import logging +import threading + +class ControlMainWindow(QtGui.QWizard): + def __init__(self, parent=None): + super(ControlMainWindow, self).__init__(parent) + self.ui = Ui_Wizard() + self.ui.setupUi(self) + + # data + self.logger = logging.getLogger('PhotoGrabberGUI') + self.helper = None + self.token = '' + self.config = {} + self.config['sleep_time'] = 0.1 + + # connect signals and validate pages + self.ui.loginPushButton.clicked.connect(self.loginPressed) + self.ui.browseToolButton.clicked.connect(self.openFolder) + self.ui.wizardPageLogin.registerField("token*", self.ui.enterTokenLineEdit) + self.ui.wizardPageLogin.validatePage = self.validateLogin + self.ui.wizardPageTarget.validatePage = self.validateTarget + self.ui.wizardPageLocation.validatePage = self.beginDownload + + def loginPressed(self): + facebook.request_token() + + def openFolder(self): + dialog = QtGui.QFileDialog() + dialog.setFileMode(QtGui.QFileDialog.Directory) + dialog.setOption(QtGui.QFileDialog.ShowDirsOnly) + if dialog.exec_(): + self.config['dir'] = dialog.selectedFiles()[0] + self.ui.pathLineEdit.setText(self.config['dir']) + + def validateLogin(self): + # present progress modal + progress = QtGui.QProgressDialog("Logging in...", "Abort", 0, 5, parent=self) + #QtGui.qApp.processEvents() is unnecessary when dialog is Modal + progress.setWindowModality(QtCore.Qt.WindowModal) + progress.show() + + # attempt to login + self.token = self.ui.enterTokenLineEdit.text() + try: + if not self.token.isalnum(): raise Exception("Please insert a valid token") + self.helper = helpers.Helper(facebook.GraphAPI(self.token)) + my_info = self.helper.get_me() + except Exception as e: + progress.close() + QtGui.QMessageBox.warning(self, "PhotoGrabber", unicode(e)) + return False + + progress.setValue(1) + if progress.wasCanceled(): return False + + # clear list + self.ui.targetTreeWidget.topLevelItem(0).takeChildren() + self.ui.targetTreeWidget.topLevelItem(1).takeChildren() + self.ui.targetTreeWidget.topLevelItem(2).takeChildren() + + # populate list + try: + friends = self.helper.get_friends('me') + except Exception as e: + progress.close() + QtGui.QMessageBox.warning(self, "PhotoGrabber", unicode(e)) + return False + + progress.setValue(2) + if progress.wasCanceled(): return False + + try: + likes = self.helper.get_likes('me') + except Exception as e: + progress.close() + QtGui.QMessageBox.warning(self, "PhotoGrabber", unicode(e)) + return False + + progress.setValue(3) + if progress.wasCanceled(): return False + + try: + subscriptions = self.helper.get_subscriptions('me') + except Exception as e: + progress.close() + QtGui.QMessageBox.warning(self, "PhotoGrabber", unicode(e)) + return False + + progress.setValue(4) + if progress.wasCanceled(): return False + + item = QtGui.QTreeWidgetItem() + item.setText(0, my_info['name']) + item.setData(1, 0, my_info) + self.ui.targetTreeWidget.topLevelItem(0).addChild(item) + + for p in friends: + item = QtGui.QTreeWidgetItem() + item.setText(0, p['name']) + item.setData(1, 0, p) + self.ui.targetTreeWidget.topLevelItem(0).addChild(item) + + for p in likes: + item = QtGui.QTreeWidgetItem() + item.setText(0, p['name']) + item.setData(1, 0, p) + self.ui.targetTreeWidget.topLevelItem(1).addChild(item) + + for p in subscriptions: + item = QtGui.QTreeWidgetItem() + item.setText(0, p['name']) + item.setData(1, 0, p) + self.ui.targetTreeWidget.topLevelItem(2).addChild(item) + + progress.setValue(5) + progress.close() + return True + + def validateTarget(self): + # setup next page to current directory + self.config['dir'] = QtGui.QFileDialog().directory().absolutePath() + self.ui.pathLineEdit.setText(self.config['dir']) + + self.config['u'] = self.ui.allAlbumsCheckBox.isChecked() + self.config['t'] = self.ui.allPhotosCheckBox.isChecked() + self.config['c'] = self.ui.commentsCheckBox.isChecked() + self.config['a'] = self.ui.fullAlbumsCheckBox.isChecked() + + # ensure check boxes will work + if not self.config['t'] and not self.config['u']: + QtGui.QMessageBox.warning(self, "PhotoGrabber", "Invalid option combination, please choose to download tagged photos or uploaded albums.") + return False + + # make sure a real item is selected + self.config['targets'] = [] + for i in self.ui.targetTreeWidget.selectedItems(): + if i.data(1,0) is not None: self.config['targets'].append(i.data(1,0)['id']) + + if len(self.config['targets']) > 0: return True + + QtGui.QMessageBox.warning(self, "PhotoGrabber", "Please select a valid target") + return False + + def beginDownload(self): + # present progress modal + total = len(self.config['targets']) + self.progress = QtGui.QProgressDialog("Downloading...", "Abort", 0, total, parent=self) + self.progress.setWindowModality(QtCore.Qt.WindowModal) + self.progress.show() + + # processing heavy function + self.helper.process(self.config, self.updateProgress) + + self.progress.setValue(total) + self.progress.close() + return True + + def updateProgress(self, text): + QtGui.qApp.processEvents() + if self.progress.wasCanceled(): + # hard quit + sys.exit() + + if text: + if text.endswith('downloaded!'): + self.progress.setValue(self.progress.value() + 1) + self.progress.setLabelText(text) + +def start(): + app = QtGui.QApplication(sys.argv) + mySW = ControlMainWindow() + mySW.show() + sys.exit(app.exec_()) + + diff --git a/wizard.py b/wizard.py new file mode 100644 index 0000000..775720d --- /dev/null +++ b/wizard.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'wizard.ui' +# +# Created: Mon Mar 11 17:55:52 2013 +# by: pyside-uic 0.2.13 running on PySide 1.1.1 +# +# WARNING! All changes made in this file will be lost! + +from PySide import QtCore, QtGui + +class Ui_Wizard(object): + def setupUi(self, Wizard): + Wizard.setObjectName("Wizard") + Wizard.resize(500, 360) + sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(Wizard.sizePolicy().hasHeightForWidth()) + Wizard.setSizePolicy(sizePolicy) + Wizard.setMaximumSize(QtCore.QSize(524287, 524287)) + Wizard.setWizardStyle(QtGui.QWizard.ModernStyle) + Wizard.setOptions(QtGui.QWizard.NoBackButtonOnStartPage|QtGui.QWizard.NoCancelButton|QtGui.QWizard.NoDefaultButton) + self.wizardPageLogin = QtGui.QWizardPage() + self.wizardPageLogin.setSubTitle("") + self.wizardPageLogin.setObjectName("wizardPageLogin") + self.gridLayout_1 = QtGui.QGridLayout(self.wizardPageLogin) + self.gridLayout_1.setSizeConstraint(QtGui.QLayout.SetDefaultConstraint) + self.gridLayout_1.setObjectName("gridLayout_1") + self.loginPushButton = QtGui.QPushButton(self.wizardPageLogin) + self.loginPushButton.setDefault(True) + self.loginPushButton.setObjectName("loginPushButton") + self.gridLayout_1.addWidget(self.loginPushButton, 1, 0, 1, 1) + self.formLayout = QtGui.QFormLayout() + self.formLayout.setFieldGrowthPolicy(QtGui.QFormLayout.ExpandingFieldsGrow) + self.formLayout.setObjectName("formLayout") + self.enterTokenLabel = QtGui.QLabel(self.wizardPageLogin) + self.enterTokenLabel.setObjectName("enterTokenLabel") + self.formLayout.setWidget(0, QtGui.QFormLayout.LabelRole, self.enterTokenLabel) + self.enterTokenLineEdit = QtGui.QLineEdit(self.wizardPageLogin) + self.enterTokenLineEdit.setObjectName("enterTokenLineEdit") + self.formLayout.setWidget(0, QtGui.QFormLayout.FieldRole, self.enterTokenLineEdit) + self.gridLayout_1.addLayout(self.formLayout, 2, 0, 1, 1) + spacerItem = QtGui.QSpacerItem(20, 1, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) + self.gridLayout_1.addItem(spacerItem, 0, 0, 1, 1) + spacerItem1 = QtGui.QSpacerItem(20, 1, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) + self.gridLayout_1.addItem(spacerItem1, 3, 0, 1, 1) + self.aboutPushButton = QtGui.QPushButton(self.wizardPageLogin) + sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Fixed, QtGui.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.aboutPushButton.sizePolicy().hasHeightForWidth()) + self.aboutPushButton.setSizePolicy(sizePolicy) + self.aboutPushButton.setAutoDefault(True) + self.aboutPushButton.setDefault(False) + self.aboutPushButton.setFlat(False) + self.aboutPushButton.setObjectName("aboutPushButton") + self.gridLayout_1.addWidget(self.aboutPushButton, 5, 0, 1, 1) + Wizard.addPage(self.wizardPageLogin) + self.wizardPageTarget = QtGui.QWizardPage() + self.wizardPageTarget.setObjectName("wizardPageTarget") + self.gridLayout = QtGui.QGridLayout(self.wizardPageTarget) + self.gridLayout.setObjectName("gridLayout") + self.targetTreeWidget = QtGui.QTreeWidget(self.wizardPageTarget) + self.targetTreeWidget.setSelectionMode(QtGui.QAbstractItemView.ExtendedSelection) + self.targetTreeWidget.setSelectionBehavior(QtGui.QAbstractItemView.SelectItems) + self.targetTreeWidget.setAutoExpandDelay(-1) + self.targetTreeWidget.setObjectName("targetTreeWidget") + item_0 = QtGui.QTreeWidgetItem(self.targetTreeWidget) + item_0 = QtGui.QTreeWidgetItem(self.targetTreeWidget) + item_0 = QtGui.QTreeWidgetItem(self.targetTreeWidget) + self.targetTreeWidget.header().setVisible(False) + self.targetTreeWidget.header().setHighlightSections(False) + self.gridLayout.addWidget(self.targetTreeWidget, 1, 0, 1, 1) + self.allPhotosCheckBox = QtGui.QCheckBox(self.wizardPageTarget) + self.allPhotosCheckBox.setTristate(False) + self.allPhotosCheckBox.setObjectName("allPhotosCheckBox") + self.gridLayout.addWidget(self.allPhotosCheckBox, 2, 0, 1, 1) + self.fullAlbumsCheckBox = QtGui.QCheckBox(self.wizardPageTarget) + self.fullAlbumsCheckBox.setObjectName("fullAlbumsCheckBox") + self.gridLayout.addWidget(self.fullAlbumsCheckBox, 4, 0, 1, 1) + self.commentsCheckBox = QtGui.QCheckBox(self.wizardPageTarget) + self.commentsCheckBox.setObjectName("commentsCheckBox") + self.gridLayout.addWidget(self.commentsCheckBox, 6, 0, 1, 1) + self.allAlbumsCheckBox = QtGui.QCheckBox(self.wizardPageTarget) + self.allAlbumsCheckBox.setObjectName("allAlbumsCheckBox") + self.gridLayout.addWidget(self.allAlbumsCheckBox, 3, 0, 1, 1) + Wizard.addPage(self.wizardPageTarget) + self.wizardPageLocation = QtGui.QWizardPage() + self.wizardPageLocation.setObjectName("wizardPageLocation") + self.gridLayout_4 = QtGui.QGridLayout(self.wizardPageLocation) + self.gridLayout_4.setObjectName("gridLayout_4") + self.pathLineEdit = QtGui.QLineEdit(self.wizardPageLocation) + self.pathLineEdit.setObjectName("pathLineEdit") + self.pathLineEdit.setReadOnly(True) + self.gridLayout_4.addWidget(self.pathLineEdit, 1, 0, 1, 1) + spacerItem2 = QtGui.QSpacerItem(20, 1, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) + self.gridLayout_4.addItem(spacerItem2, 0, 0, 1, 2) + spacerItem3 = QtGui.QSpacerItem(20, 1, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) + self.gridLayout_4.addItem(spacerItem3, 2, 0, 1, 2) + self.browseToolButton = QtGui.QToolButton(self.wizardPageLocation) + self.browseToolButton.setObjectName("browseToolButton") + self.gridLayout_4.addWidget(self.browseToolButton, 1, 1, 1, 1) + Wizard.addPage(self.wizardPageLocation) + + self.retranslateUi(Wizard) + QtCore.QMetaObject.connectSlotsByName(Wizard) + Wizard.setTabOrder(self.loginPushButton, self.enterTokenLineEdit) + Wizard.setTabOrder(self.enterTokenLineEdit, self.targetTreeWidget) + Wizard.setTabOrder(self.targetTreeWidget, self.allPhotosCheckBox) + Wizard.setTabOrder(self.allPhotosCheckBox, self.allAlbumsCheckBox) + Wizard.setTabOrder(self.allAlbumsCheckBox, self.fullAlbumsCheckBox) + Wizard.setTabOrder(self.fullAlbumsCheckBox, self.commentsCheckBox) + Wizard.setTabOrder(self.commentsCheckBox, self.pathLineEdit) + Wizard.setTabOrder(self.pathLineEdit, self.browseToolButton) + + def retranslateUi(self, Wizard): + Wizard.setWindowTitle(QtGui.QApplication.translate("Wizard", "PhotoGrabber", None, QtGui.QApplication.UnicodeUTF8)) + self.wizardPageLogin.setTitle(QtGui.QApplication.translate("Wizard", "Login to Facebook", None, QtGui.QApplication.UnicodeUTF8)) + self.loginPushButton.setText(QtGui.QApplication.translate("Wizard", "Login", None, QtGui.QApplication.UnicodeUTF8)) + self.enterTokenLabel.setText(QtGui.QApplication.translate("Wizard", "Enter Token", None, QtGui.QApplication.UnicodeUTF8)) + self.aboutPushButton.setText(QtGui.QApplication.translate("Wizard", "About", None, QtGui.QApplication.UnicodeUTF8)) + self.wizardPageTarget.setTitle(QtGui.QApplication.translate("Wizard", "Select Target(s)", None, QtGui.QApplication.UnicodeUTF8)) + self.targetTreeWidget.headerItem().setText(0, QtGui.QApplication.translate("Wizard", "Target", None, QtGui.QApplication.UnicodeUTF8)) + __sortingEnabled = self.targetTreeWidget.isSortingEnabled() + self.targetTreeWidget.setSortingEnabled(False) + self.targetTreeWidget.topLevelItem(0).setText(0, QtGui.QApplication.translate("Wizard", "Friends", None, QtGui.QApplication.UnicodeUTF8)) + self.targetTreeWidget.topLevelItem(1).setText(0, QtGui.QApplication.translate("Wizard", "Likes", None, QtGui.QApplication.UnicodeUTF8)) + self.targetTreeWidget.topLevelItem(2).setText(0, QtGui.QApplication.translate("Wizard", "Following", None, QtGui.QApplication.UnicodeUTF8)) + self.targetTreeWidget.setSortingEnabled(__sortingEnabled) + self.allPhotosCheckBox.setText(QtGui.QApplication.translate("Wizard", "All tagged photos", None, QtGui.QApplication.UnicodeUTF8)) + self.fullAlbumsCheckBox.setText(QtGui.QApplication.translate("Wizard", "Full albums of tagged photos", None, QtGui.QApplication.UnicodeUTF8)) + self.commentsCheckBox.setText(QtGui.QApplication.translate("Wizard", "Complete comment/tag data", None, QtGui.QApplication.UnicodeUTF8)) + self.allAlbumsCheckBox.setText(QtGui.QApplication.translate("Wizard", "Uploaded albums", None, QtGui.QApplication.UnicodeUTF8)) + self.wizardPageLocation.setTitle(QtGui.QApplication.translate("Wizard", "Select Download Location", None, QtGui.QApplication.UnicodeUTF8)) + self.browseToolButton.setText(QtGui.QApplication.translate("Wizard", "Browse ...", None, QtGui.QApplication.UnicodeUTF8)) + From 21bf5d2dda0e629d1760677baadb2afc82ce4740 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Sat, 16 Mar 2013 09:13:53 -0500 Subject: [PATCH 10/38] corrected cmd arguements --- pg.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pg.py b/pg.py index 5929994..046e951 100755 --- a/pg.py +++ b/pg.py @@ -30,8 +30,8 @@ helps['u'] = 'Download all albums uploaded by the target. (Use with --target)' helps['t'] = 'Download all photos where the target is tagged. (Use with --target)' helps['c'] = 'Download full comment data. (Use with --target)' -helps['a'] = 'Download full albums, even if just 1 photo has the tagged target. (Use with --target)' -helps['gui'] = 'Use wx based GUI' +helps['a'] = 'Download the full album, even if tagged in a single photo. (Use with --target and -t)' +helps['cmd'] = 'Use command line instead of Qt GUI' helps['token'] = 'Specify the OAuth token used to authenticate with Facebook.' helps['list-targets'] ='Display names and object_id\'s of potential targets' helps['list-albums'] = 'List the albums uploaded by a target. Separate the object_id\'s of targets with spaces.' From d4147717adf15f4f524b86bcc975dd44b6c20822 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Sat, 16 Mar 2013 13:50:23 -0500 Subject: [PATCH 11/38] replace urllib with requests Adds HTTPS certificate verification to prevent MITM attacks. --- downloader.py | 7 +++--- facebook.py | 70 ++++++++++++++++++++++++--------------------------- 2 files changed, 36 insertions(+), 41 deletions(-) diff --git a/downloader.py b/downloader.py index a6cc35b..b80a25e 100755 --- a/downloader.py +++ b/downloader.py @@ -19,7 +19,7 @@ import os import json import time -import urllib2 +import requests import shutil import re @@ -72,17 +72,16 @@ def save_album(album, path, comments=False): # if os.path.isfile(filename): picout = open(pic_path, 'wb') - handler = urllib2.Request(photo['src_big']) retry = True while retry: try: logger.info('downloading:%s' % photo['src_big']) - data = urllib2.urlopen(handler) + r = requests.get(photo['src_big']) retry = False # save file - picout.write(data.read()) + picout.write(r.content) picout.close() created_time = time.strptime(photo['created_time'], '%Y-%m-%dT%H:%M:%S+0000') photo['created_time_int'] = int(time.mktime(created_time)) diff --git a/facebook.py b/facebook.py index 31ad2c9..568e054 100755 --- a/facebook.py +++ b/facebook.py @@ -25,7 +25,7 @@ """ import time -import urllib +import requests import logging import repeater import json @@ -75,7 +75,7 @@ def get_object(self, id, limit=500): list|dict. Context dependent Raises: - GraphAPIError + GraphAPIError, ConnectionError, HTTPError, Timeout, TooManyRedirects >>>graph = facebook.GraphAPI(access_token) >>>user = graph.get_object('me') @@ -126,16 +126,17 @@ def _follow(self, path): """Follow a graph API path.""" # no need to build URL since it was given to us - self.logger.debug('GET: %s' % path) - file = urllib.urlopen(path) #IOError self.rtt = self.rtt+1 try: - response = json.loads(file.read()) #ValueError, IOError - self.logger.debug(json.dumps(response, indent=4)) - finally: - file.close() + r = requests.get(path) + except requests.exceptions.SSLError as e: + raise repeater.DoNotRepeatError(e) + self.logger.debug('GET: %s' % r.url) + + response = r.json() + self.logger.debug(json.dumps(response, indent=4)) if response.get("error"): try: @@ -161,20 +162,18 @@ def _request(self, path, args=None): args["access_token"] = self.access_token path = ''.join(["https://graph.facebook.com/", - path, - "?", - urllib.urlencode(args)]) - - self.logger.debug('GET: %s' % path) - file = urllib.urlopen(path) #IOError + path]) self.rtt = self.rtt+1 try: - response = json.loads(file.read()) #ValueError, IOError - self.logger.debug(json.dumps(response, indent=4)) - finally: - file.close() + r = requests.get(path, params=args) + except requests.exceptions.SSLError as e: + raise repeater.DoNotRepeatError(e) + self.logger.debug('GET: %s' % r.url) + + response = r.json() + self.logger.debug(json.dumps(response, indent=4)) if response.get("error"): try: @@ -197,29 +196,26 @@ def fql(self, query): # see FQL documention link - query = urllib.quote(query) - path = ''.join(['https://api.facebook.com/method/fql.query?', - 'format=json&', - 'query=%(q)s&', - 'access_token=%(at)s']) - args = { "q" : query, "at" : self.access_token, } - path = path % args - - self.logger.debug('GET: %s' % path) - file = urllib.urlopen(path) + path = 'https://api.facebook.com/method/fql.query?' + args = { "format":"json", "query" : query, "access_token" : self.access_token, } self.rtt = self.rtt+1 try: - response = json.loads(file.read()) - self.logger.debug(json.dumps(response, indent=4)) #ValueError, IOError - if type(response) is dict and "error_code" in response: - # add do not repeate error - self.logger.error('GET: %s failed' % path) - raise GraphAPIError(response["error_code"], - response["error_msg"]) - finally: - file.close() + r = requests.get(path, params=args) + except requests.exceptions.SSLError as e: + raise repeater.DoNotRepeatError(e) + self.logger.debug('GET: %s' % r.url) + + response = r.json() + self.logger.debug(json.dumps(response, indent=4)) + + if type(response) is dict and "error_code" in response: + # add do not repeate error + self.logger.error('GET: %s failed' % path) + raise GraphAPIError(response["error_code"], + response["error_msg"]) + return response def get_stats(self): From 0a61937262e70923310596aa39951dbcfde089e3 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Sat, 16 Mar 2013 13:51:26 -0500 Subject: [PATCH 12/38] handle duplicate album names in event of duplicat album names, the album id is now appended to the folder. this prevents metadata loss. --- downloader.py | 2 +- helpers.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/downloader.py b/downloader.py index b80a25e..c6bce9a 100755 --- a/downloader.py +++ b/downloader.py @@ -45,7 +45,7 @@ def save_album(album, path, comments=False): # # '\*|"|:|<|>|\?|\\|/|,|' REPLACE_RE = re.compile(r'\*|"|:|<|>|\?|\\|/|,') - folder = unicode(album['name']) + folder = unicode(album['folder_name']) folder = REPLACE_RE.sub('_', folder) path = os.path.join(path, folder) if not os.path.isdir(path): diff --git a/helpers.py b/helpers.py index ca3e70d..fa9bdf7 100755 --- a/helpers.py +++ b/helpers.py @@ -16,6 +16,7 @@ # along with this program. If not, see . import logging +import collections class Helper(object): """Helper functions for retrieving Facebook data. @@ -281,6 +282,15 @@ def process(self, config, update): data.extend(u_data) data.extend(t_data) + + # find duplicates + names = [album['name'] for album in data] + duplicate_names = [name for name, count in collections.Counter(names).items() if count > 1] + for album in data: + if album['name'] in duplicate_names: + album['folder_name'] = '%s - %s' % (album['name'], album['id']) + else: + album['folder_name'] = album['name'] # download data pool = multiprocessing.Pool(processes=5) From b9ae9a0363371244573a83953dd171d2f85fba9b Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Sat, 16 Mar 2013 14:21:59 -0500 Subject: [PATCH 13/38] validate cmd input include additional checks to protect user from him/herself --- pg.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pg.py b/pg.py index 046e951..2cda1c4 100755 --- a/pg.py +++ b/pg.py @@ -91,11 +91,13 @@ def main(): if args.token is None: logger.info('No token provided.') browser = raw_input("Open Browser [y/n]: ") + if not browser.isalnum() raise ValueError('Input must be alphanumeric.') if browser == 'y': logger.info('Opening default browser.') facebook.request_token() time.sleep(1) args.token = raw_input("Enter Token: ") + if not args.token.isalnum() raise ValueError('Input must be alphanumeric.') logger.info('Provided token: %s' % args.token) @@ -149,6 +151,7 @@ def main(): args.dir = current_dir else: args.dir = unicode(args.dir) + if not os.path.exists(args.dir) raise ValueError('Download Location must exist.') logger.info('Download Location: %s' % args.dir) @@ -157,6 +160,7 @@ def main(): logger.info('Downloading albums.') for album in args.album: # note, doesnt manually ask for caut options for album + if not album.isdigit() raise ValueError('Input must be numeric.') print 'Retrieving album data: %s...' % album data = helper.get_album(album, comments=args.c) print 'Downloading photos' @@ -167,6 +171,7 @@ def main(): if args.target is None: args.target = [] args.target.append(raw_input("Target: ")) + if not args.target.isalnum() raise ValueError('Input must be alphanumeric') # get options if not args.c and not args.a: @@ -179,6 +184,7 @@ def main(): print 'c: %s' % helps['c'] print 'a: %s' % helps['a'] opt_str = raw_input("Input Options (e.g. 'cau' or 'caut'):") + if not opt_str.isalnum() raise ValueError('Input must be alphanumeric') if 'u' in opt_str: args.u = True if 't' in opt_str: From f746d29454d48b92599a42329d4d726bb87df079 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Sat, 16 Mar 2013 14:30:26 -0500 Subject: [PATCH 14/38] unnecessary comment remove comment that should have been removed in 0a61937262e70923310596aa39951dbcfde089e3 --- helpers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/helpers.py b/helpers.py index fa9bdf7..5c264bf 100755 --- a/helpers.py +++ b/helpers.py @@ -302,7 +302,6 @@ def process(self, config, update): self.update('Downloading %d photos...' % pics) for album in data: - # TODO: Error where 2 albums with same name exist path = os.path.join(savedir,unicode(target_info['name'])) pool.apply_async(downloader.save_album, (album,path,c) From 8d5514be4c767123c09d853196037b9b452cb3ac Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Sat, 16 Mar 2013 14:33:04 -0500 Subject: [PATCH 15/38] unnecessary import time module not used in facebook --- facebook.py | 1 - 1 file changed, 1 deletion(-) diff --git a/facebook.py b/facebook.py index 568e054..67c4376 100755 --- a/facebook.py +++ b/facebook.py @@ -24,7 +24,6 @@ JavaScript SDK at http://github.com/facebook/connect-js/. """ -import time import requests import logging import repeater From ddc1cba3ac6599e08c7b9c2b3ad3168362eb167b Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Sat, 16 Mar 2013 16:12:19 -0500 Subject: [PATCH 16/38] corrected error handling typo, should have tested before commit --- pg.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pg.py b/pg.py index 2cda1c4..a091218 100755 --- a/pg.py +++ b/pg.py @@ -91,13 +91,13 @@ def main(): if args.token is None: logger.info('No token provided.') browser = raw_input("Open Browser [y/n]: ") - if not browser.isalnum() raise ValueError('Input must be alphanumeric.') + if not browser.isalnum(): raise ValueError('Input must be alphanumeric.') if browser == 'y': logger.info('Opening default browser.') facebook.request_token() time.sleep(1) args.token = raw_input("Enter Token: ") - if not args.token.isalnum() raise ValueError('Input must be alphanumeric.') + if not args.token.isalnum(): raise ValueError('Input must be alphanumeric.') logger.info('Provided token: %s' % args.token) @@ -151,7 +151,7 @@ def main(): args.dir = current_dir else: args.dir = unicode(args.dir) - if not os.path.exists(args.dir) raise ValueError('Download Location must exist.') + if not os.path.exists(args.dir): raise ValueError('Download Location must exist.') logger.info('Download Location: %s' % args.dir) @@ -160,7 +160,7 @@ def main(): logger.info('Downloading albums.') for album in args.album: # note, doesnt manually ask for caut options for album - if not album.isdigit() raise ValueError('Input must be numeric.') + if not album.isdigit(): raise ValueError('Input must be numeric.') print 'Retrieving album data: %s...' % album data = helper.get_album(album, comments=args.c) print 'Downloading photos' @@ -171,7 +171,7 @@ def main(): if args.target is None: args.target = [] args.target.append(raw_input("Target: ")) - if not args.target.isalnum() raise ValueError('Input must be alphanumeric') + if not args.target.isalnum(): raise ValueError('Input must be alphanumeric') # get options if not args.c and not args.a: @@ -184,7 +184,7 @@ def main(): print 'c: %s' % helps['c'] print 'a: %s' % helps['a'] opt_str = raw_input("Input Options (e.g. 'cau' or 'caut'):") - if not opt_str.isalnum() raise ValueError('Input must be alphanumeric') + if not opt_str.isalnum(): raise ValueError('Input must be alphanumeric') if 'u' in opt_str: args.u = True if 't' in opt_str: From b10e02bba659ba00f3cebe059e76508c83211f92 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Sat, 16 Mar 2013 16:13:33 -0500 Subject: [PATCH 17/38] additional gui options added completion message, about dialog, and specify non-friend target --- pgui.py | 42 ++++++++++++++++++++++++++++++++---------- wizard.py | 20 +++++++++++++++----- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/pgui.py b/pgui.py index 9265d2a..2e6e49d 100644 --- a/pgui.py +++ b/pgui.py @@ -44,26 +44,31 @@ def __init__(self, parent=None): self.token = '' self.config = {} self.config['sleep_time'] = 0.1 + self.advancedTarget = "" # connect signals and validate pages + self.ui.aboutPushButton.clicked.connect(self.aboutPressed) self.ui.loginPushButton.clicked.connect(self.loginPressed) + self.ui.advancedPushButton.clicked.connect(self.advancedPressed) self.ui.browseToolButton.clicked.connect(self.openFolder) self.ui.wizardPageLogin.registerField("token*", self.ui.enterTokenLineEdit) self.ui.wizardPageLogin.validatePage = self.validateLogin self.ui.wizardPageTarget.validatePage = self.validateTarget self.ui.wizardPageLocation.validatePage = self.beginDownload + def aboutPressed(self): + QtGui.QMessageBox.about(self, "About", "PhotoGrabber v100\n(C) 2013 Ourbunny\nGPLv3\n\nphotograbber.com\nFor full licensing information view the LICENSE.txt file.") + def loginPressed(self): facebook.request_token() - - def openFolder(self): - dialog = QtGui.QFileDialog() - dialog.setFileMode(QtGui.QFileDialog.Directory) - dialog.setOption(QtGui.QFileDialog.ShowDirsOnly) - if dialog.exec_(): - self.config['dir'] = dialog.selectedFiles()[0] - self.ui.pathLineEdit.setText(self.config['dir']) - + + def advancedPressed(self): + self.advancedTarget, ok = QtGui.QInputDialog.getText(self, "Specify Target", "ID/username of target", text=self.advancedTarget) + if ok: + self.ui.targetTreeWidget.setEnabled(False) + else: + self.ui.targetTreeWidget.setEnabled(True) + def validateLogin(self): # present progress modal progress = QtGui.QProgressDialog("Logging in...", "Abort", 0, 5, parent=self) @@ -165,6 +170,11 @@ def validateTarget(self): # make sure a real item is selected self.config['targets'] = [] + if not self.ui.targetTreeWidget.isEnabled(): + self.config['targets'].append(self.advancedTarget) + #get info on target? + return True + for i in self.ui.targetTreeWidget.selectedItems(): if i.data(1,0) is not None: self.config['targets'].append(i.data(1,0)['id']) @@ -173,6 +183,14 @@ def validateTarget(self): QtGui.QMessageBox.warning(self, "PhotoGrabber", "Please select a valid target") return False + def openFolder(self): + dialog = QtGui.QFileDialog() + dialog.setFileMode(QtGui.QFileDialog.Directory) + dialog.setOption(QtGui.QFileDialog.ShowDirsOnly) + if dialog.exec_(): + self.config['dir'] = dialog.selectedFiles()[0] + self.ui.pathLineEdit.setText(self.config['dir']) + def beginDownload(self): # present progress modal total = len(self.config['targets']) @@ -181,9 +199,13 @@ def beginDownload(self): self.progress.show() # processing heavy function - self.helper.process(self.config, self.updateProgress) + try: + self.helper.process(self.config, self.updateProgress) + except Exception as e: + QtGui.QMessageBox.critical(self, "Error", '%s - more info in pg.log' % e) self.progress.setValue(total) + QtGui.QMessageBox.information(self, "Done", "Download is complete") self.progress.close() return True diff --git a/wizard.py b/wizard.py index 775720d..c183331 100644 --- a/wizard.py +++ b/wizard.py @@ -76,15 +76,22 @@ def setupUi(self, Wizard): self.allPhotosCheckBox.setTristate(False) self.allPhotosCheckBox.setObjectName("allPhotosCheckBox") self.gridLayout.addWidget(self.allPhotosCheckBox, 2, 0, 1, 1) + self.allAlbumsCheckBox = QtGui.QCheckBox(self.wizardPageTarget) + self.allAlbumsCheckBox.setObjectName("allAlbumsCheckBox") + self.gridLayout.addWidget(self.allAlbumsCheckBox, 3, 0, 1, 1) self.fullAlbumsCheckBox = QtGui.QCheckBox(self.wizardPageTarget) self.fullAlbumsCheckBox.setObjectName("fullAlbumsCheckBox") self.gridLayout.addWidget(self.fullAlbumsCheckBox, 4, 0, 1, 1) self.commentsCheckBox = QtGui.QCheckBox(self.wizardPageTarget) self.commentsCheckBox.setObjectName("commentsCheckBox") - self.gridLayout.addWidget(self.commentsCheckBox, 6, 0, 1, 1) - self.allAlbumsCheckBox = QtGui.QCheckBox(self.wizardPageTarget) - self.allAlbumsCheckBox.setObjectName("allAlbumsCheckBox") - self.gridLayout.addWidget(self.allAlbumsCheckBox, 3, 0, 1, 1) + self.gridLayout.addWidget(self.commentsCheckBox, 5, 0, 1, 1) + self.advancedPushButton = QtGui.QPushButton(self.wizardPageTarget) + self.advancedPushButton.setSizePolicy(sizePolicy) + self.advancedPushButton.setAutoDefault(True) + self.advancedPushButton.setDefault(False) + self.advancedPushButton.setFlat(False) + self.advancedPushButton.setObjectName("advancedPushButton") + self.gridLayout.addWidget(self.advancedPushButton, 6, 0, 1, 1) Wizard.addPage(self.wizardPageTarget) self.wizardPageLocation = QtGui.QWizardPage() self.wizardPageLocation.setObjectName("wizardPageLocation") @@ -111,7 +118,8 @@ def setupUi(self, Wizard): Wizard.setTabOrder(self.allPhotosCheckBox, self.allAlbumsCheckBox) Wizard.setTabOrder(self.allAlbumsCheckBox, self.fullAlbumsCheckBox) Wizard.setTabOrder(self.fullAlbumsCheckBox, self.commentsCheckBox) - Wizard.setTabOrder(self.commentsCheckBox, self.pathLineEdit) + Wizard.setTabOrder(self.commentsCheckBox, self.advancedPushButton) + Wizard.setTabOrder(self.advancedPushButton, self.pathLineEdit) Wizard.setTabOrder(self.pathLineEdit, self.browseToolButton) def retranslateUi(self, Wizard): @@ -132,6 +140,8 @@ def retranslateUi(self, Wizard): self.fullAlbumsCheckBox.setText(QtGui.QApplication.translate("Wizard", "Full albums of tagged photos", None, QtGui.QApplication.UnicodeUTF8)) self.commentsCheckBox.setText(QtGui.QApplication.translate("Wizard", "Complete comment/tag data", None, QtGui.QApplication.UnicodeUTF8)) self.allAlbumsCheckBox.setText(QtGui.QApplication.translate("Wizard", "Uploaded albums", None, QtGui.QApplication.UnicodeUTF8)) + self.advancedPushButton.setText(QtGui.QApplication.translate("Wizard", "Advanced", None, QtGui.QApplication.UnicodeUTF8)) + self.wizardPageLocation.setTitle(QtGui.QApplication.translate("Wizard", "Select Download Location", None, QtGui.QApplication.UnicodeUTF8)) self.browseToolButton.setText(QtGui.QApplication.translate("Wizard", "Browse ...", None, QtGui.QApplication.UnicodeUTF8)) From 14b1cc55d76798049ef720b6230cc4ce468eb3c4 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Sat, 16 Mar 2013 16:44:18 -0500 Subject: [PATCH 18/38] attribute requests add attribution to requests in readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 15dc1fb..919c7e0 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ along with this program. If not, see . ## Dependencies * facebook-sdk (Apache 2.0) +* [Requests](http://python-requests.org) (Apache 2.0) * [PySide](http://qt-project.org/wiki/Category:LanguageBindings::PySide) (LGPL v2.1) * [Qt](http://qt-project.org) (LGPL v2.1) From b2288e1bb6097ae05278a5f2c5e8139aba62ae8b Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Sun, 17 Mar 2013 14:35:22 -0500 Subject: [PATCH 19/38] prepare for multithreading --- helpers.py | 24 ++------ pgui.py | 161 ++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 120 insertions(+), 65 deletions(-) diff --git a/helpers.py b/helpers.py index 5c264bf..f64592e 100755 --- a/helpers.py +++ b/helpers.py @@ -40,9 +40,6 @@ class Helper(object): def __init__(self, graph=None): self.graph = graph self.logger = logging.getLogger('helper') - - def update(self, text): - pass def find_album_ids(self, picture_ids): """Find the albums that contains pictures. @@ -61,7 +58,6 @@ def find_album_ids(self, picture_ids): # split query into 25 pictures at a time for i in range(len(picture_ids) / 25 + 1): - self.update(None) pids = ','.join(picture_ids[i * 25:(i+1) * 25]) new_ids = self.graph.fql(q % pids) try: @@ -87,24 +83,20 @@ def get_info(self, id): return self.graph.get_object('%s' % id) def get_friends(self, id): - self.update(None) data = self.graph.get_object('%s/friends' % id, 5000) return sorted(data, key=lambda k:k['name'].lower()) def get_subscriptions(self, id): - self.update(None) data = self.graph.get_object('%s/subscribedto' % id, 5000) return sorted(data, key=lambda k:k['name'].lower()) def get_likes(self, id): - self.update(None) data = self.graph.get_object('%s/likes' % id, 5000) return sorted(data, key=lambda k:k['name'].lower()) # return the list of album information that id has uploaded def get_album_list(self, id): - self.update(None) return self.graph.get_object('%s/albums' % id) # The following methods return a list of albums & photos @@ -128,18 +120,15 @@ def _fill_album(self, album, comments): if comments and 'comments' in album: if len(album['comments']) >= 25: album['comments'] = self.graph.get_object('%s/comments' % album['id']) - self.update(None) # get album photos album['photos'] = self.graph.get_object('%s/photos' % album['id']) - self.update(None) if len(album['photos']) == 0: self.logger.error('album had zero photos: %s' % album['id']) return None for photo in album['photos']: - self.update(None) # get picture comments if comments and 'comments' in photo: n_before = len(photo['comments']['data']) @@ -171,7 +160,6 @@ def get_album(self, id, comments=False): return album album = self.graph.get_object('%s' % id) - self.update(None) return self._fill_album(album, comments) def get_albums(self, id, comments=False): @@ -184,7 +172,6 @@ def get_albums(self, id, comments=False): self.logger.info('albums: %d' % len(data)) for album in data: - self.update(None) album = self._fill_album(album, comments) # remove empty albums @@ -248,8 +235,6 @@ def process(self, config, update): t = config['t'] c = config['c'] a = config['a'] - - self.update = update self.logger.info("%s" % config) @@ -265,13 +250,13 @@ def process(self, config, update): # get user uploaded photos if u: - self.update('Retrieving %s\'s album data...' % target_info['name']) + update('Retrieving %s\'s album data...' % target_info['name'] u_data = self.get_albums(target, comments=c) t_data = [] # get tagged if t: - self.update('Retrieving %s\'s tagged photo data...' % target_info['name']) + update('Retrieving %s\'s tagged photo data...' % target_info['name']) t_data = self.get_tagged(target, comments=c, full=a) if u and t: @@ -299,7 +284,7 @@ def process(self, config, update): for album in data: pics = pics + len(album['photos']) - self.update('Downloading %d photos...' % pics) + update('Downloading %d photos...' % pics) for album in data: path = os.path.join(savedir,unicode(target_info['name'])) @@ -312,7 +297,6 @@ def process(self, config, update): while multiprocessing.active_children(): time.sleep(0.1) - self.update(None) pool.join() @@ -320,4 +304,4 @@ def process(self, config, update): self.logger.info('albums: %s' % len(data)) self.logger.info('pics: %s' % pics) - self.update('%d photos downloaded!' % pics) + update('%d photos downloaded!' % pics) diff --git a/pgui.py b/pgui.py index 2e6e49d..af84631 100644 --- a/pgui.py +++ b/pgui.py @@ -32,6 +32,86 @@ import logging import threading +class LoginSignal(QtCore.QObject): + sig = QtCore.Signal(int) + err = QtCore.Signal(str) + msg = QtCore.Signal(str) + +class LoginThread(QtCore.QThread): + def __init__(self, helper, data, parent=None): + QtCore.QThread.__init__(self, parent) + + self.mutex = QtCore.QMutex() + self.abort = False #accessed by both threads + self.data_ready = False + + self.helper = helper + self.data = data + self.status = LoginSignal() + + def run(self): + try: + self.mutex.lock() + if self.abort: + self.mutex.unlock() + return + self.mutex.unlock() + + self.data['my_info'] = self.helper.get_me() + + self.mutex.lock() + if self.abort: + self.mutex.unlock() + return + self.status.sig.emit(1) + self.mutex.unlock() + + self.data['friends'] = self.helper.get_friends('me') + + self.mutex.lock() + if self.abort: + self.mutex.unlock() + return + self.status.sig.emit(2) + self.mutex.unlock() + + self.data['likes'] = self.helper.get_likes('me') + + self.mutex.lock() + if self.abort: + self.mutex.unlock() + return + self.status.sig.emit(3) + self.mutex.unlock() + + self.data['subscriptions'] = self.helper.get_subscriptions('me') + self.status.sig.emit(4) + + self.data_ready = True + except Exception as e: + print "error: %s" % e + self.status.err.emit('%s' % e) + + def stop(self): + self.mutex.lock() + self.abort = True + self.mutex.unlock() + +class DownloadThread(QtCore.QThread): + def __init__(self, helper, config, parent=None): + QtCore.QThread.__init__(self, parent) + + self.helper = helper + self.config = config + self.status = LoginSignal() + + def run(self): + try: + self.helper.process(self.config, self.status.msg.emit) + except Exception as e: + print "error: %s" % e + self.status.err.emit('%s' % e) + class ControlMainWindow(QtGui.QWizard): def __init__(self, parent=None): super(ControlMainWindow, self).__init__(parent) @@ -69,9 +149,12 @@ def advancedPressed(self): else: self.ui.targetTreeWidget.setEnabled(True) + def errorMessage(self, error): + QtGui.QMessageBox.critical(self, "Error", '%s - more info in pg.log' % error) + def validateLogin(self): # present progress modal - progress = QtGui.QProgressDialog("Logging in...", "Abort", 0, 5, parent=self) + progress = QtGui.QProgressDialog("Logging in...", "Abort", 0, 4, parent=self) #QtGui.qApp.processEvents() is unnecessary when dialog is Modal progress.setWindowModality(QtCore.Qt.WindowModal) progress.show() @@ -81,14 +164,26 @@ def validateLogin(self): try: if not self.token.isalnum(): raise Exception("Please insert a valid token") self.helper = helpers.Helper(facebook.GraphAPI(self.token)) - my_info = self.helper.get_me() except Exception as e: progress.close() - QtGui.QMessageBox.warning(self, "PhotoGrabber", unicode(e)) + self.errorMessage(e) return False - progress.setValue(1) - if progress.wasCanceled(): return False + data = {} + thread = LoginThread(self.helper, data) + thread.status.sig.connect(progress.setValue) + thread.status.err.connect(self.errorMessage) + thread.status.err.connect(progress.cancel) + thread.start() + + while thread.isRunning(): + QtGui.qApp.processEvents() + if progress.wasCanceled(): + thread.stop() + thread.wait() + return False + + if progress.wasCanceled() or not thread.data_ready: return False # clear list self.ui.targetTreeWidget.topLevelItem(0).takeChildren() @@ -96,60 +191,29 @@ def validateLogin(self): self.ui.targetTreeWidget.topLevelItem(2).takeChildren() # populate list - try: - friends = self.helper.get_friends('me') - except Exception as e: - progress.close() - QtGui.QMessageBox.warning(self, "PhotoGrabber", unicode(e)) - return False - - progress.setValue(2) - if progress.wasCanceled(): return False - - try: - likes = self.helper.get_likes('me') - except Exception as e: - progress.close() - QtGui.QMessageBox.warning(self, "PhotoGrabber", unicode(e)) - return False - - progress.setValue(3) - if progress.wasCanceled(): return False - - try: - subscriptions = self.helper.get_subscriptions('me') - except Exception as e: - progress.close() - QtGui.QMessageBox.warning(self, "PhotoGrabber", unicode(e)) - return False - - progress.setValue(4) - if progress.wasCanceled(): return False - item = QtGui.QTreeWidgetItem() - item.setText(0, my_info['name']) - item.setData(1, 0, my_info) + item.setText(0, data['my_info']['name']) + item.setData(1, 0, data['my_info']) self.ui.targetTreeWidget.topLevelItem(0).addChild(item) - for p in friends: + for p in data['friends']: item = QtGui.QTreeWidgetItem() item.setText(0, p['name']) item.setData(1, 0, p) self.ui.targetTreeWidget.topLevelItem(0).addChild(item) - for p in likes: + for p in data['likes']: item = QtGui.QTreeWidgetItem() item.setText(0, p['name']) item.setData(1, 0, p) self.ui.targetTreeWidget.topLevelItem(1).addChild(item) - for p in subscriptions: + for p in data['subscriptions']: item = QtGui.QTreeWidgetItem() item.setText(0, p['name']) item.setData(1, 0, p) self.ui.targetTreeWidget.topLevelItem(2).addChild(item) - progress.setValue(5) progress.close() return True @@ -199,10 +263,17 @@ def beginDownload(self): self.progress.show() # processing heavy function - try: - self.helper.process(self.config, self.updateProgress) - except Exception as e: - QtGui.QMessageBox.critical(self, "Error", '%s - more info in pg.log' % e) + thread = DownloadThread(self.helper, self.config) + thread.status.msg.connect(self.updateProgress) + thread.status.err.connect(self.errorMessage) + thread.status.err.connect(self.progress.cancel) + thread.start() + + while thread.isRunning(): + QtGui.qApp.processEvents() + if self.progress.wasCanceled(): + thread.stop() + sys.exit() self.progress.setValue(total) QtGui.QMessageBox.information(self, "Done", "Download is complete") From 7d20613d5983c0ead9c3c14a3d56a108db6f80f4 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Tue, 19 Mar 2013 20:20:35 -0500 Subject: [PATCH 20/38] new error mode Detect when Facebook returns ill formatted error (not correct JSON error) --- facebook.py | 57 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/facebook.py b/facebook.py index 67c4376..2a5d403 100755 --- a/facebook.py +++ b/facebook.py @@ -96,25 +96,28 @@ def get_object(self, id, limit=500): # first request self.logger.info('retieving: %s' % id) - response = self._request(id, args) # GraphAPIError - - if response.has_key('data'): - # response is a list - data.extend(response['data']) - - if response.has_key('paging'): - # iterate over pages - while response['paging'].has_key('next'): - page_next = response['paging']['next'] - response = self._follow(page_next) #GraphAPIError - if len(response['data']) > 0: - data.extend(response['data']) - else: - break - else: - # response is a dict - self.logger.debug('no response key "data"') - data = response + try: + response = self._request(id, args) # GraphAPIError + if response.has_key('data'): + # response is a list + data.extend(response['data']) + + if response.has_key('paging'): + # iterate over pages + while response['paging'].has_key('next'): + page_next = response['paging']['next'] + response = self._follow(page_next) #GraphAPIError + if len(response['data']) > 0: + data.extend(response['data']) + else: + break + else: + # response is a dict + self.logger.debug('no response key "data"') + data = response + except Exception as e: + self.logger.error(e) + data = [] self.logger.info('data size: %d' % len(data)) @@ -137,6 +140,12 @@ def _follow(self, path): response = r.json() self.logger.debug(json.dumps(response, indent=4)) + if type(response) is dict and "error_code" in response: + # add do not repeate error + self.logger.error('GET: %s failed' % r.url) + raise GraphAPIError(response["error_code"], + response["error_msg"]) + if response.get("error"): try: raise GraphAPIError(response["error"]["code"], @@ -147,7 +156,7 @@ def _follow(self, path): raise repeater.DoNotRepeatError(e) else: # raise original GraphAPIError (and try again) - self.logger.error('GET: %s failed' % path) + self.logger.error('GET: %s failed' % r.url) raise return response @@ -174,6 +183,12 @@ def _request(self, path, args=None): response = r.json() self.logger.debug(json.dumps(response, indent=4)) + if type(response) is dict and "error_code" in response: + # add do not repeate error + self.logger.error('GET: %s failed' % r.url) + raise GraphAPIError(response["error_code"], + response["error_msg"]) + if response.get("error"): try: raise GraphAPIError(response["error"]["code"], @@ -184,7 +199,7 @@ def _request(self, path, args=None): raise repeater.DoNotRepeatError(e) else: # raise original GraphAPIError (and try again) - self.logger.error('GET: %s failed' % path) + self.logger.error('GET: %s failed' % r.url) raise return response From 1562411cf280bb86c6232def85d0a3a92ea18aae Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Tue, 19 Mar 2013 20:22:01 -0500 Subject: [PATCH 21/38] download thread pool translate process function into a thread utilizing a pool of threads to download files. --- helpers.py | 193 ++++++++++++++++++++++++++++++++++++++++++----------- pg.py | 6 +- pgui.py | 52 +++------------ 3 files changed, 168 insertions(+), 83 deletions(-) diff --git a/helpers.py b/helpers.py index f64592e..cf93c63 100755 --- a/helpers.py +++ b/helpers.py @@ -122,7 +122,7 @@ def _fill_album(self, album, comments): album['comments'] = self.graph.get_object('%s/comments' % album['id']) # get album photos - album['photos'] = self.graph.get_object('%s/photos' % album['id']) + album['photos'] = self.graph.get_object('%s/photos' % album['id'], limit=50) if len(album['photos']) == 0: self.logger.error('album had zero photos: %s' % album['id']) @@ -168,14 +168,14 @@ def get_albums(self, id, comments=False): self.logger.info('get_albums: %s' % id) data = self.graph.get_object('%s/albums' % id) - self.logger.info('albums: %d' % len(data)) for album in data: album = self._fill_album(album, comments) - + # remove empty albums data = [album for album in data if album is not None] + data = [album for album in data if len(album['photos']) > 0] return data def get_tagged(self, id, comments=False, full=True): @@ -189,6 +189,8 @@ def get_tagged(self, id, comments=False, full=True): self.logger.info('get_tagged: %s' % id) unsorted = self.graph.get_object('%s/photos' % id) + self.logger.info('tagged in %d photos' % len(unsorted)) + unsorted_ids = [x['id'] for x in unsorted] album_ids = self.find_album_ids(unsorted_ids) @@ -226,38 +228,152 @@ def get_tagged(self, id, comments=False, full=True): return data - def process(self, config, update): - """Collect all necessary information and download all files""" +import threading +import repeater +import requests +import Queue +import os +import time +import re +import shutil +import json + +class DownloaderThread(threading.Thread): + def __init__(self, q): + """make many of these threads. + + pulls tuples (photo, album_path) from queue to download""" + + threading.Thread.__init__(self) + self.daemon=True + self.q = q + self.logger = logging.getLogger('DownloadThread') + + @repeater.repeat + def _download(self, web_path): + r = requests.get(web_path) + return r.content + + def run(self): + while True: + photo, path = self.q.get() - savedir = config['dir'] - targets = config['targets'] - u = config['u'] - t = config['t'] - c = config['c'] - a = config['a'] + try: + save_path = os.path.join(path, photo['path']) + web_path = photo['src_big'] + + picout = open(save_path, 'wb') + self.logger.info('downloading:%s' % web_path) + try: + picout.write(self._download(web_path)) + finally: + picout.close() + + # correct file time + created_time = time.strptime(photo['created_time'], '%Y-%m-%dT%H:%M:%S+0000') + time_int = int(time.mktime(created_time)) + os.utime(save_path, (time_int,) * 2) + except Exception as e: + self.logger.error(e) + + self.q.task_done() + +class ProcessThread(threading.Thread): + def __init__(self, helper, config): + threading.Thread.__init__(self) + self.helper = helper + self.config = config + self.logger = logging.getLogger('ProcessThread') + self.q = Queue.Queue() + self.msg = "Downloading..." + self.pics = 0 + self.total = 0 + + def _processAlbum(self, album, path, comments): + # recursively make path + # http://serverfault.com/questions/242110/which-common-charecters-are-illegal-in-unix-and-windows-filesystems + # + # NULL and / are not valid on EXT3 + # < > : " / \ | ? * are not valid Windows + # prohibited characters in order: + # * " : < > ? \ / , NULL + # + # '\*|"|:|<|>|\?|\\|/|,|' + # add . for ... case + REPLACE_RE = re.compile(r'\*|"|:|<|>|\?|\\|/|,|\.') + folder = unicode(album['folder_name']) + folder = REPLACE_RE.sub('_', folder) + path = os.path.join(path, folder) + if not os.path.isdir(path): + os.makedirs(path) # recursive makedir + + # save files + for photo in album['photos']: + # set 'src_big' to largest photo size + width = -1 + for image in photo['images']: + if image['width'] > width: + photo['src_big'] = image['source'] + width = image['width'] + + # filename of photo + photo['path'] = '%s' % photo['src_big'].split('/')[-1] + + # TODO: add photo to queue + self.q.put( (photo,path) ) + + # exit funcion if no need to save metadata + if not comments: + return + + # save JSON file + ts = time.strftime("%y-%m-%d_%H-%M-%S") + + filename = os.path.join(path, 'pg_%s.json' % ts) + alfilename = os.path.join(path, 'album.json') + htmlfilename = os.path.join(path, 'viewer.html') + try: + db_file = open(filename, "w") + db_file.write("var al = "); + json.dump(album, db_file) + db_file.write(";\n") + db_file.close() + shutil.copy(filename, alfilename) + shutil.copy(os.path.join('dep', 'viewer.html'), htmlfilename) + except Exception as e: + self.logger.error(e) + + def run(self): + """Collect all necessary information and download all files""" + + for i in range(50): + t=DownloaderThread(self.q) + t.start() - self.logger.info("%s" % config) + savedir = self.config['dir'] + targets = self.config['targets'] + u = self.config['u'] + t = self.config['t'] + c = self.config['c'] + a = self.config['a'] - import downloader - import multiprocessing - import os - import time + self.logger.info("%s" % self.config) for target in targets: - target_info = self.get_info(target) + target_info = self.helper.get_info(target) data = [] u_data = [] # get user uploaded photos if u: - update('Retrieving %s\'s album data...' % target_info['name'] - u_data = self.get_albums(target, comments=c) + self.msg = 'Retrieving %s\'s album data...' % target_info['name'] + u_data = self.helper.get_albums(target, comments=c) t_data = [] # get tagged if t: - update('Retrieving %s\'s tagged photo data...' % target_info['name']) - t_data = self.get_tagged(target, comments=c, full=a) + self.msg = 'Retrieving %s\'s tagged photo data...' % target_info['name'] + t_data = self.helper.get_tagged(target, comments=c, full=a) if u and t: # list of user ids @@ -268,7 +384,7 @@ def process(self, config, update): data.extend(u_data) data.extend(t_data) - # find duplicates + # find duplicate album names names = [album['name'] for album in data] duplicate_names = [name for name, count in collections.Counter(names).items() if count > 1] for album in data: @@ -277,31 +393,28 @@ def process(self, config, update): else: album['folder_name'] = album['name'] - # download data - pool = multiprocessing.Pool(processes=5) - - pics = 0 + self.total = 0 for album in data: - pics = pics + len(album['photos']) + self.total = self.total + len(album['photos']) - update('Downloading %d photos...' % pics) + self.msg = 'Downloading %d photos...' % self.total for album in data: path = os.path.join(savedir,unicode(target_info['name'])) - pool.apply_async(downloader.save_album, - (album,path,c) - ) #callback= - pool.close() + self._processAlbum(album, path, c) self.logger.info('Waiting for childeren to finish') - - while multiprocessing.active_children(): - time.sleep(0.1) - - pool.join() + + self.q.join() self.logger.info('Child processes completed') self.logger.info('albums: %s' % len(data)) - self.logger.info('pics: %s' % pics) - - update('%d photos downloaded!' % pics) + self.logger.info('pics: %s' % self.total) + + self.msg = '%d photos downloaded!' % self.total + + def status(self): + return self.msg + + def progress(self): + return (self.total - self.q.qsize(), self.total) \ No newline at end of file diff --git a/pg.py b/pg.py index a091218..c9b79c9 100755 --- a/pg.py +++ b/pg.py @@ -195,7 +195,6 @@ def main(): args.a = True # TODO: logger print caut options, logger duplicate print info's - config = {} config['dir'] = args.dir config['targets'] = args.target @@ -204,7 +203,10 @@ def main(): config['c'] = args.c config['a'] = args.a - helper.process(config, print_func) + # processing heavy function + thread = helpers.ProcessThread(self.helper, self.config) + thread.start() + thread.join() if __name__ == "__main__": main() diff --git a/pgui.py b/pgui.py index af84631..00a4e82 100644 --- a/pgui.py +++ b/pgui.py @@ -27,15 +27,12 @@ import facebook import helpers -import downloader import logging -import threading class LoginSignal(QtCore.QObject): sig = QtCore.Signal(int) err = QtCore.Signal(str) - msg = QtCore.Signal(str) class LoginThread(QtCore.QThread): def __init__(self, helper, data, parent=None): @@ -96,21 +93,6 @@ def stop(self): self.mutex.lock() self.abort = True self.mutex.unlock() - -class DownloadThread(QtCore.QThread): - def __init__(self, helper, config, parent=None): - QtCore.QThread.__init__(self, parent) - - self.helper = helper - self.config = config - self.status = LoginSignal() - - def run(self): - try: - self.helper.process(self.config, self.status.msg.emit) - except Exception as e: - print "error: %s" % e - self.status.err.emit('%s' % e) class ControlMainWindow(QtGui.QWizard): def __init__(self, parent=None): @@ -258,38 +240,26 @@ def openFolder(self): def beginDownload(self): # present progress modal total = len(self.config['targets']) - self.progress = QtGui.QProgressDialog("Downloading...", "Abort", 0, total, parent=self) - self.progress.setWindowModality(QtCore.Qt.WindowModal) - self.progress.show() + progress = QtGui.QProgressDialog("Downloading...", "Abort", 0, 0, parent=self) + progress.setWindowModality(QtCore.Qt.WindowModal) + progress.show() # processing heavy function - thread = DownloadThread(self.helper, self.config) - thread.status.msg.connect(self.updateProgress) - thread.status.err.connect(self.errorMessage) - thread.status.err.connect(self.progress.cancel) + thread = helpers.ProcessThread(self.helper, self.config) thread.start() - while thread.isRunning(): + while thread.isAlive(): QtGui.qApp.processEvents() - if self.progress.wasCanceled(): - thread.stop() + progress.setLabelText(thread.status()) + if progress.wasCanceled(): + #thread.stop() sys.exit() - self.progress.setValue(total) + progress.setValue(total) + progress.setLabelText(thread.status()) QtGui.QMessageBox.information(self, "Done", "Download is complete") - self.progress.close() + progress.close() return True - - def updateProgress(self, text): - QtGui.qApp.processEvents() - if self.progress.wasCanceled(): - # hard quit - sys.exit() - - if text: - if text.endswith('downloaded!'): - self.progress.setValue(self.progress.value() + 1) - self.progress.setLabelText(text) def start(): app = QtGui.QApplication(sys.argv) From 6534cb9086a10bdb861f6be897f19401853da38d Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Sun, 24 Mar 2013 11:06:44 -0500 Subject: [PATCH 22/38] cleanup comments remove unnecessary downloader.py, better comments, facebook.py raises exception when an error is encountered. --- downloader.py | 123 -------------------------------------------------- facebook.py | 44 +++++++++++++----- helpers.py | 58 +++++++++++------------- pg.py | 1 - pgui.py | 3 +- repeater.py | 14 +++--- wizard.py | 19 +++++++- 7 files changed, 85 insertions(+), 177 deletions(-) delete mode 100755 downloader.py diff --git a/downloader.py b/downloader.py deleted file mode 100755 index c6bce9a..0000000 --- a/downloader.py +++ /dev/null @@ -1,123 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright (C) 2013 Ourbunny -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import logging -import os -import json -import time -import requests -import shutil -import re - -# download album methods for multiprocessing - -def save_album(album, path, comments=False): - """Process a full album. Save data as JSON and download photos. - - album: full album data - path: directory to save to albums - comments: write JSON and viewer.html if True - """ - - logger = logging.getLogger('save_album') - - # recursively make path - # http://serverfault.com/questions/242110/which-common-charecters-are-illegal-in-unix-and-windows-filesystems - # - # NULL and / are not valid on EXT3 - # < > : " / \ | ? * are not valid Windows - # prohibited characters in order: - # * " : < > ? \ / , NULL - # - # '\*|"|:|<|>|\?|\\|/|,|' - REPLACE_RE = re.compile(r'\*|"|:|<|>|\?|\\|/|,') - folder = unicode(album['folder_name']) - folder = REPLACE_RE.sub('_', folder) - path = os.path.join(path, folder) - if not os.path.isdir(path): - os.makedirs(path) # recursive makedir - - # save files - for photo in album['photos']: - # set 'src_big' to largest photo size - width = -1 - for image in photo['images']: - if image['width'] > width: - photo['src_big'] = image['source'] - width = image['width'] - - # filename of photo - photo['path'] = '%s' % photo['src_big'].split('/')[-1] - - # save photos - max_retries = 10 - retries = 0 - - pic_path = os.path.join(path, photo['path']) - - # if os.path.isfile(filename): - - picout = open(pic_path, 'wb') - retry = True - - while retry: - try: - logger.info('downloading:%s' % photo['src_big']) - r = requests.get(photo['src_big']) - retry = False - - # save file - picout.write(r.content) - picout.close() - created_time = time.strptime(photo['created_time'], '%Y-%m-%dT%H:%M:%S+0000') - photo['created_time_int'] = int(time.mktime(created_time)) - os.utime(pic_path, (photo['created_time_int'],) * 2) - except Exception, e: - if retries < max_retries: - retries += 1 - logger.info('retrying download %s' % photo['src_big']) - # sleep longer and longer between retries - time.sleep(retries * 2) - else: - # skip on 404 error - logger.info('Could not download %s' % photo['src_big']) - picout.close() - os.remove(pic_path) - retry = False - - # exit funcion if no need to save metadata - if not comments: - return - - # save JSON file - ts = time.strftime("%y-%m-%d_%H-%M-%S") - - filename = os.path.join(path, 'pg_%s.json' % ts) - alfilename = os.path.join(path, 'album.json') - htmlfilename = os.path.join(path, 'viewer.html') - try: - db_file = open(filename, "w") - db_file.write("var al = "); - json.dump(album, db_file) - db_file.write(";\n") - db_file.close() - shutil.copy(filename, alfilename) - shutil.copy(os.path.join('dep', 'viewer.html'), htmlfilename) - except Exception, e: - logger.info('Saving JSON Failed: %s', filename) - - return diff --git a/facebook.py b/facebook.py index 2a5d403..9f9bf36 100755 --- a/facebook.py +++ b/facebook.py @@ -42,9 +42,9 @@ class GraphAPI(object): token, this will fetch the profile of the active user and the list of the user's friends: - graph = facebook.GraphAPI(access_token) - user = graph.get_object("me") - friends = graph.get_connections(user["id"], "friends") + >>>graph = facebook.GraphAPI(access_token) + >>>user = graph.get_object("me") + >>>friends = graph.get_object("me/friends") You can see a list of all of the objects and connections supported by the API at http://developers.facebook.com/docs/reference/api/. @@ -52,6 +52,23 @@ class GraphAPI(object): You can obtain an access token via OAuth or by using the Facebook JavaScript SDK. See http://developers.facebook.com/docs/authentication/ for details. + + Modifications include: + + * Switched to Pythonic HTTP `requests` api for server cert validation + * Added decorator to retry requests that fail. + * Automatic paging over objects. + + >>>graph.get_object("me/photos") + + * A helper function to perform fql queries. + + >>>graph.fql('SELECT uid FROM user WHERE username IS georgehtakei') + + * Tracks the number of HTTP requests performed by GraphAPI. + + >>>graph.get_stats() + """ def __init__(self, access_token=None): @@ -62,19 +79,20 @@ def __init__(self, access_token=None): def get_object(self, id, limit=500): """Get an entire object from the Graph API. - Retreives an entine object by following the pages in a response. + Retreives an entine object paging responses. Args: id (str): The path of the object to retreive. Kwards: - limit (int): The number of object to request per page (default 500) + limit (int): The number of items requested per page (default 500) Returns: - list|dict. Context dependent + list|dict. Context dependent. Raises: - GraphAPIError, ConnectionError, HTTPError, Timeout, TooManyRedirects + GraphAPIError, ConnectionError, HTTPError, Timeout, + TooManyRedirects >>>graph = facebook.GraphAPI(access_token) >>>user = graph.get_object('me') @@ -117,7 +135,7 @@ def get_object(self, id, limit=500): data = response except Exception as e: self.logger.error(e) - data = [] + raise self.logger.info('data size: %d' % len(data)) @@ -141,7 +159,7 @@ def _follow(self, path): self.logger.debug(json.dumps(response, indent=4)) if type(response) is dict and "error_code" in response: - # add do not repeate error + # add do not repeate error, returned fql-style error... self.logger.error('GET: %s failed' % r.url) raise GraphAPIError(response["error_code"], response["error_msg"]) @@ -211,7 +229,8 @@ def fql(self, query): # see FQL documention link path = 'https://api.facebook.com/method/fql.query?' - args = { "format":"json", "query" : query, "access_token" : self.access_token, } + args = { "format":"json", "query" : query, + "access_token" : self.access_token, } self.rtt = self.rtt+1 @@ -242,12 +261,15 @@ def reset_stats(self): class GraphAPIError(Exception): + """Error raised through Facebook API.""" + def __init__(self, code, message): Exception.__init__(self, message) self.code = code def request_token(): - """Prompt the user to login to facebook and obtain an OAuth token.""" + """Prompt the user to login to facebook using their default web browser to + obtain an OAuth token.""" import webbrowser diff --git a/helpers.py b/helpers.py index cf93c63..2621983 100755 --- a/helpers.py +++ b/helpers.py @@ -17,24 +17,32 @@ import logging import collections +import threading +import repeater +import requests +import Queue +import os +import time +import re +import shutil +import json class Helper(object): """Helper functions for retrieving Facebook data. - Example usage: - import facebook - import helpers - - graph = facebook.GraphAPI(access_token) - helper = helpers.Helper(graph) - helper.get_friends(id) - helper.get_subscriptions(id) - helper.get_likes(id) - helper.get_albums(id) - helper.get_tagged(id) - helper.get_tagged_albums(id) + >>>graph = facebook.GraphAPI(access_token) + >>>helper = helpers.Helper(graph) + >>>helper.get_friends(id) + >>>helper.get_subscriptions(id) + >>>helper.get_likes(id) + >>>helper.get_albums(id) + >>>helper.get_tagged(id) + >>>helper.get_tagged_albums(id) The id field in all cases is the id of the target for backup. + + Args: + graph (obj): The path of the object to retreive. """ def __init__(self, graph=None): @@ -47,8 +55,8 @@ def find_album_ids(self, picture_ids): The picture_id arguement must be a list of photo object_id's. Returns a list of album object_id's. If permissions for the album do - not allow for album information to be retrieved then it is omitted from - the list. + not allow for album information to be retrieved then it is omitted + from the list. """ q = ''.join(['SELECT object_id, aid FROM album WHERE aid ', @@ -196,7 +204,8 @@ def get_tagged(self, id, comments=False, full=True): data = [] - self.logger.info('%d photos in %d albums' % (len(unsorted_ids), len(album_ids))) + self.logger.info('%d photos in %d albums' % + (len(unsorted_ids), len(album_ids))) # TODO: this could be done in parallel for album_id in album_ids: @@ -228,16 +237,7 @@ def get_tagged(self, id, comments=False, full=True): return data -import threading -import repeater -import requests -import Queue -import os -import time -import re -import shutil -import json - + class DownloaderThread(threading.Thread): def __init__(self, q): """make many of these threads. @@ -298,8 +298,8 @@ def _processAlbum(self, album, path, comments): # prohibited characters in order: # * " : < > ? \ / , NULL # - # '\*|"|:|<|>|\?|\\|/|,|' - # add . for ... case + # '\*|"|:|<|>|\?|\\|/|,|' + # add . for ... case, windows does not like ellipsis REPLACE_RE = re.compile(r'\*|"|:|<|>|\?|\\|/|,|\.') folder = unicode(album['folder_name']) folder = REPLACE_RE.sub('_', folder) @@ -319,7 +319,6 @@ def _processAlbum(self, album, path, comments): # filename of photo photo['path'] = '%s' % photo['src_big'].split('/')[-1] - # TODO: add photo to queue self.q.put( (photo,path) ) # exit funcion if no need to save metadata @@ -415,6 +414,3 @@ def run(self): def status(self): return self.msg - - def progress(self): - return (self.total - self.q.qsize(), self.total) \ No newline at end of file diff --git a/pg.py b/pg.py index c9b79c9..9ff5d91 100755 --- a/pg.py +++ b/pg.py @@ -18,7 +18,6 @@ import facebook import helpers -import downloader import argparse import time diff --git a/pgui.py b/pgui.py index 00a4e82..058f39c 100644 --- a/pgui.py +++ b/pgui.py @@ -86,7 +86,6 @@ def run(self): self.data_ready = True except Exception as e: - print "error: %s" % e self.status.err.emit('%s' % e) def stop(self): @@ -132,7 +131,7 @@ def advancedPressed(self): self.ui.targetTreeWidget.setEnabled(True) def errorMessage(self, error): - QtGui.QMessageBox.critical(self, "Error", '%s - more info in pg.log' % error) + QtGui.QMessageBox.critical(self, "Error", '%s' % error) def validateLogin(self): # present progress modal diff --git a/repeater.py b/repeater.py index b77baf4..6a109ee 100755 --- a/repeater.py +++ b/repeater.py @@ -18,22 +18,22 @@ import logging import time -# raise DoNotRepeatError in a function to force repeat() to exit prematurely class DoNotRepeatError(Exception): + """Raise DoNotRepeatError in a function to force repeat() to exit.""" + def __init__(self, error): Exception.__init__(self, error.message) self.error = error -# function repeater decorator def repeat(func, n=10, standoff=1.5): - """Execute a function repeatedly until success. + """Execute a function repeatedly until success (no exceptions raised). Args: func (function): The function to repeat Kwargs: - n (int): The number of times to repeate the function before raising an error - standoff (float): Multiplier increment to wait between retrying the function + n (int): The number of times to repeate `func` before raising an error + standoff (float): Multiplier increment to wait between retrying `func` >>>import repeater.repeat @@ -75,7 +75,7 @@ def repeat(func, n=10, standoff=1.5): def wrapped(*args, **kwargs): retries = 0 - logger = logging.getLogger('repeat decorator') + logger = logging.getLogger('repeater') while True: try: return func(*args, **kwargs) @@ -83,7 +83,7 @@ def wrapped(*args, **kwargs): # raise the exception that caused funciton failure raise e.error except Exception as e: - logger.exception('failed function: %s' % e) + logger.exception('Function failed: %s' % e) if retries < n: retries += 1 time.sleep(retries * standoff) diff --git a/wizard.py b/wizard.py index c183331..b13f6b9 100644 --- a/wizard.py +++ b/wizard.py @@ -1,11 +1,26 @@ # -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- +# +# Copyright (C) 2013 Ourbunny +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# # Form implementation generated from reading ui file 'wizard.ui' # # Created: Mon Mar 11 17:55:52 2013 # by: pyside-uic 0.2.13 running on PySide 1.1.1 -# -# WARNING! All changes made in this file will be lost! from PySide import QtCore, QtGui From bd27ed2c1f8b738692120e127efa0f92ff6d1621 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Sat, 20 Apr 2013 15:32:38 -0500 Subject: [PATCH 23/38] trust me Reworked Facebook API to be queue based, overhauled helpers API, and corrected gui use of threads among other things... --- .gitignore | 1 + README.md | 1 - dep/viewer.html | 49 ++-- facebook.py | 501 +++++++++++++++++++++------------------ helpers.py | 619 +++++++++++++++++++++++++++++++++--------------- pg.py | 108 +++++---- pgui.py | 140 +++++------ repeater.py | 22 +- 8 files changed, 869 insertions(+), 572 deletions(-) diff --git a/.gitignore b/.gitignore index f24cd99..0d73ec6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ var sdist develop-eggs .installed.cfg +requests # Installer logs pip-log.txt diff --git a/README.md b/README.md index 919c7e0..fe0443e 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,6 @@ along with this program. If not, see . ## Dependencies -* facebook-sdk (Apache 2.0) * [Requests](http://python-requests.org) (Apache 2.0) * [PySide](http://qt-project.org/wiki/Category:LanguageBindings::PySide) (LGPL v2.1) * [Qt](http://qt-project.org) (LGPL v2.1) diff --git a/dep/viewer.html b/dep/viewer.html index de0e162..22bed98 100644 --- a/dep/viewer.html +++ b/dep/viewer.html @@ -77,7 +77,7 @@ #tags, #comments, #albumcomments{ margin-top:10px; } -#tags a{ +#tags a, #likes a{ margin:10px; } @@ -107,9 +107,9 @@ // heading var title = document.createElement("h1"); - if (typeof al['photos'][pid]['caption'] != 'undefined') { - // fix: find how to figure out the caption - title.innerHTML = "

" + al['photos'][pid]['caption'] + "

"; + if (typeof al['photos'][pid]['name'] != 'undefined') { + // fix: find how to figure out the name + title.innerHTML = "

" + al['photos'][pid]['name'] + "

"; } else { title.innerHTML = "

Untitled

"; } @@ -125,6 +125,8 @@ oimg.id = "thepic"; // fix: size of the image oimg.setAttribute('src', al['photos'][pid]['path']); + oimg.setAttribute('width', 'auto'); + oimg.setAttribute('height', '500px'); imgp.appendChild(oimg); mydiv.appendChild(imgp); @@ -135,9 +137,9 @@ tagdiv.innerHTML = "Tagged: "; mydiv.appendChild(tagdiv); - for (tag in al['photos'][pid]['tags']['data']) { + for (tag in al['photos'][pid]['tags']) { var thetag = document.createElement('a'); - thetag.innerHTML = al['photos'][pid]['tags']['data'][tag]['name']; + thetag.innerHTML = al['photos'][pid]['tags'][tag]['name']; thetag.href="#"; var elem = {}; elem.tag = tag; @@ -147,16 +149,31 @@ tagdiv.appendChild(thetag); } } + + // fix: find how tags work + if (al['photos'][pid]['likes']) { + var likediv = document.createElement("div"); + likediv.id = "likes"; + likediv.innerHTML = "Likes: "; + mydiv.appendChild(likediv); + + for (like in al['photos'][pid]['likes']) { + var thetag = document.createElement('a'); + thetag.innerHTML = al['photos'][pid]['likes'][like]['name']; + thetag.href="#"; + likediv.appendChild(thetag); + } + } if (al['photos'][pid]['comments']) { var cdiv = document.createElement("div"); cdiv.id = "comments"; mydiv.appendChild(cdiv); - for (comment in al['photos'][pid]['comments']['data']) { + for (comment in al['photos'][pid]['comments']) { var thecomment = document.createElement('p'); - var name = al['photos'][pid]['comments']['data'][comment]['from']['name']; - var text = al['photos'][pid]['comments']['data'][comment]['message']; - var time = al['photos'][pid]['comments']['data'][comment]['created_time']; + var name = al['photos'][pid]['comments'][comment]['from']['name']; + var text = al['photos'][pid]['comments'][comment]['message']; + var time = al['photos'][pid]['comments'][comment]['created_time']; thecomment.innerHTML = "" + name + ": " + text + "
" + time + ""; cdiv.appendChild(thecomment); } @@ -170,8 +187,8 @@ var tb = document.getElementById('box'); tb.position = 'absolute'; // fix - tb.style.left = '' + (tp.offsetLeft - 70 + (al['photos'][pid]['tags']['data'][tag]['x']/100) * tp.offsetWidth) + 'px'; - tb.style.top = '' + (tp.offsetTop - 70 + (al['photos'][pid]['tags']['data'][tag]['y']/100) * tp.offsetHeight) + 'px'; + tb.style.left = '' + (tp.offsetLeft - 70 + (al['photos'][pid]['tags'][tag]['x']/100) * tp.offsetWidth) + 'px'; + tb.style.top = '' + (tp.offsetTop - 70 + (al['photos'][pid]['tags'][tag]['y']/100) * tp.offsetHeight) + 'px'; tb.style.display = "block"; } @@ -233,11 +250,11 @@ if (al['comments']) { var cdiv = document.getElementById('albumcomments'); - for (comment in al['comments']['data']) { + for (comment in al['comments']) { var thecomment = document.createElement('p'); - var name = al['comments']['data'][comment]['from']['name']; - var text = al['comments']['data'][comment]['message']; - var time = al['comments']['data'][comment]['created_time']; + var name = al['comments'][comment]['from']['name']; + var text = al['comments'][comment]['message']; + var time = al['comments'][comment]['created_time']; thecomment.innerHTML = "" + name + ": " + text + "
" + time + ""; cdiv.appendChild(thecomment); } diff --git a/facebook.py b/facebook.py index 9f9bf36..c69a74e 100755 --- a/facebook.py +++ b/facebook.py @@ -1,272 +1,312 @@ # -*- coding: utf-8 -*- # -# Copyright 2010 Facebook -# Copyright 2013 Ourbunny (modified for PhotoGrabber) +# Copyright (C) 2013 Ourbunny # -# Licensed under the Apache License, Version 2.0 (the "License"); you may -# not use this file except in compliance with the License. You may obtain -# a copy of the License at +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. # -# http://www.apache.org/licenses/LICENSE-2.0 +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. # -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . -"""Python client library for the Facebook Platform. - -This client library is designed to support the Graph API and the official -Facebook JavaScript SDK, which is the canonical way to implement -Facebook authentication. Read more about the Graph API at -http://developers.facebook.com/docs/api. You can download the Facebook -JavaScript SDK at http://github.com/facebook/connect-js/. -""" - -import requests +import json import logging +import Queue import repeater -import json - -class GraphAPI(object): - """A client for the Facebook Graph API. - - See http://developers.facebook.com/docs/api for complete documentation - for the API. - - The Graph API is made up of the objects in Facebook (e.g., people, pages, - events, photos) and the connections between them (e.g., friends, - photo tags, and event RSVPs). This client provides access to those - primitive types in a generic way. For example, given an OAuth access - token, this will fetch the profile of the active user and the list - of the user's friends: - - >>>graph = facebook.GraphAPI(access_token) - >>>user = graph.get_object("me") - >>>friends = graph.get_object("me/friends") - - You can see a list of all of the objects and connections supported - by the API at http://developers.facebook.com/docs/reference/api/. - - You can obtain an access token via OAuth or by using the Facebook - JavaScript SDK. See http://developers.facebook.com/docs/authentication/ - for details. - - Modifications include: - - * Switched to Pythonic HTTP `requests` api for server cert validation - * Added decorator to retry requests that fail. - * Automatic paging over objects. - - >>>graph.get_object("me/photos") - - * A helper function to perform fql queries. - - >>>graph.fql('SELECT uid FROM user WHERE username IS georgehtakei') - - * Tracks the number of HTTP requests performed by GraphAPI. - - >>>graph.get_stats() +import requests +import threading - """ +log = logging.getLogger('pg.%s' % __name__) +class GraphBuilder(object): def __init__(self, access_token=None): self.access_token = access_token - self.logger = logging.getLogger('facebook') - self.rtt = 0 # round trip total - - def get_object(self, id, limit=500): - """Get an entire object from the Graph API. - - Retreives an entine object paging responses. - - Args: - id (str): The path of the object to retreive. - - Kwards: - limit (int): The number of items requested per page (default 500) - - Returns: - list|dict. Context dependent. - - Raises: - GraphAPIError, ConnectionError, HTTPError, Timeout, - TooManyRedirects - - >>>graph = facebook.GraphAPI(access_token) - >>>user = graph.get_object('me') - >>>print user['id'] - >>>photos = graph.get_object('me/photos') - >>>for photo in photos: - >>> print photo['id'] - """ + def set_token(self, access_token): + self.access_token = access_token + def get_object(self, path, limit=100): # API defines max limit as 5K if limit > 5000: limit = 5000 - data = [] - args = {} - args["limit"] = limit - - # first request - self.logger.info('retieving: %s' % id) - - try: - response = self._request(id, args) # GraphAPIError - if response.has_key('data'): - # response is a list - data.extend(response['data']) - - if response.has_key('paging'): - # iterate over pages - while response['paging'].has_key('next'): - page_next = response['paging']['next'] - response = self._follow(page_next) #GraphAPIError - if len(response['data']) > 0: - data.extend(response['data']) - else: - break - else: - # response is a dict - self.logger.debug('no response key "data"') - data = response - except Exception as e: - self.logger.error(e) - raise - - self.logger.info('data size: %d' % len(data)) - - return data - - @repeater.repeat - def _follow(self, path): - """Follow a graph API path.""" - - # no need to build URL since it was given to us - - self.rtt = self.rtt+1 - - try: - r = requests.get(path) - except requests.exceptions.SSLError as e: - raise repeater.DoNotRepeatError(e) - self.logger.debug('GET: %s' % r.url) - - response = r.json() - self.logger.debug(json.dumps(response, indent=4)) - - if type(response) is dict and "error_code" in response: - # add do not repeate error, returned fql-style error... - self.logger.error('GET: %s failed' % r.url) - raise GraphAPIError(response["error_code"], - response["error_msg"]) - - if response.get("error"): - try: - raise GraphAPIError(response["error"]["code"], - response["error"]["message"]) - except GraphAPIError as e: - if e.code == 190 or e.code == 2500: - # do not bother repeating if OAuthException - raise repeater.DoNotRepeatError(e) - else: - # raise original GraphAPIError (and try again) - self.logger.error('GET: %s failed' % r.url) - raise - - return response - - @repeater.repeat - def _request(self, path, args=None): - """Fetches the given path in the Graph API.""" - - if not args: args = {} - if self.access_token: - args["access_token"] = self.access_token + #if limit > 0: + # args["limit"] = limit + args["access_token"] = self.access_token path = ''.join(["https://graph.facebook.com/", path]) - self.rtt = self.rtt+1 - - try: - r = requests.get(path, params=args) - except requests.exceptions.SSLError as e: - raise repeater.DoNotRepeatError(e) - self.logger.debug('GET: %s' % r.url) - - response = r.json() - self.logger.debug(json.dumps(response, indent=4)) - - if type(response) is dict and "error_code" in response: - # add do not repeate error - self.logger.error('GET: %s failed' % r.url) - raise GraphAPIError(response["error_code"], - response["error_msg"]) - - if response.get("error"): - try: - raise GraphAPIError(response["error"]["code"], - response["error"]["message"]) - except GraphAPIError as e: - if e.code == 190 or e.code == 2500: - # do not bother repeating if OAuthException - raise repeater.DoNotRepeatError(e) - else: - # raise original GraphAPIError (and try again) - self.logger.error('GET: %s failed' % r.url) - raise - - return response + return path, args - @repeater.repeat def fql(self, query): - """Execute an FQL query.""" - # see FQL documention link path = 'https://api.facebook.com/method/fql.query?' args = { "format":"json", "query" : query, "access_token" : self.access_token, } + + return path, args - self.rtt = self.rtt+1 - - try: - r = requests.get(path, params=args) - except requests.exceptions.SSLError as e: - raise repeater.DoNotRepeatError(e) - self.logger.debug('GET: %s' % r.url) - - response = r.json() - self.logger.debug(json.dumps(response, indent=4)) - + def parse(self, response, url): if type(response) is dict and "error_code" in response: - # add do not repeate error - self.logger.error('GET: %s failed' % path) + log.error('GET: %s failed' % url) raise GraphAPIError(response["error_code"], - response["error_msg"]) + response["error_msg"], + url) - return response - - def get_stats(self): - """Returns the number of HTTP requests performed by GraphAPI.""" - return self.rtt + if type(response) is dict and "error" in response: + log.error('GET: %s failed' % url) + raise GraphAPIError(response["error"]["code"], + response["error"]["message"], + url) - def reset_stats(self): - """Reset the number of HTTP requests performed by GraphAPI.""" - self.rtt = 0 + return response class GraphAPIError(Exception): """Error raised through Facebook API.""" - def __init__(self, code, message): + def __init__(self, code, message, url): Exception.__init__(self, message) self.code = code + self.url = url + + +class GraphRequestHandler(threading.Thread): + def __init__(self, request_queue, response_queue, graph_builder): + threading.Thread.__init__(self) + self.daemon = True + + self.request_queue = request_queue + self.response_queue = response_queue + self.graph_builder = graph_builder + + @repeater.repeat + def _get(self, request): + if 'path' in request: + path, args = self.graph_builder.get_object(request['path']) + elif 'query' in request: + path, args = self.graph_builder.fql(request['query']) + elif 'url' in request: + path, args = request['url'], [] + else: + raise repeater.DoNotRepeatError(TypeError('Malformed request')) + + try: + r = requests.get(path, params=args) + except requests.exceptions.SSLError as e: + raise repeater.DoNotRepeatError(e) + + log.debug('GET: %s' % r.url) + response = r.json() + #log.debug(json.dumps(response, indent=4)) + try: + retVal = self.graph_builder.parse(response, r.url) + except GraphAPIError as e: + # https://developers.facebook.com/docs/reference/api/errors/ + # do not try on OAuth errors + if e.code is 190: + raise repeater.DoNotRepeatError(e) + # API Too Many Calls (server side throttling) + # API User Too Many Calls + if e.code is 4 or e.code is 17: + raise repeater.PauseRepeatError(e, 60) + raise e + return retVal + + def run(self): + while True: + request = self.request_queue.get() + + #process request + more = True + while more: + try: + response = self._get(request) + except Exception as e: + request['error'] = e # notify of error! + request['more'] = False + self.response_queue.put(request) + break + + if 'data' in response: + request['response'] = response['data'] + else: + request['response'] = response + + # is there an option to page over responses + more = False + if 'paging' in response: + if len(response['data']) > 0: + if 'next' in response['paging']: + next_request = request.copy() + next_request.pop('response', None) + next_request.pop('path', None) + next_request.pop('query', None) + next_request['url'] = response['paging']['next'] + more = True + + request['more'] = more + self.response_queue.put(request) + if more: + request = next_request + + self.request_queue.task_done() + + +class GraphAPI(threading.Thread): + def __init__(self, access_token): + threading.Thread.__init__(self) + self.daemon = True + + self.graph_builder = GraphBuilder() + self.set_token(access_token) + + self.id = 0 + self.active = [] + self.activeLock = threading.Lock() + + self.data = {} + self.errors ={} + self.dataLock = threading.Lock() + + self.request_queue = Queue.Queue() + self.response_queue = Queue.Queue() + self.threads = [] + + def run(self): + # create my worker threads + for n in range(10): + t = GraphRequestHandler(self.request_queue, self.response_queue, + self.graph_builder) + self.threads.append(t) + t.start() + + # process responses + while True: + response = self.response_queue.get() + + # save response in data dictionary + self.dataLock.acquire() + if 'response' in response: + data = response['response'] + if response['id'] in self.data: + try: + self.data[response['id']].extend(data) + except Exception as e: + import pdb; pdb.set_trace() + else: + self.data[response['id']] = data + elif 'error' in response: + self.errors[response['id']] = response['error'] + self.dataLock.release() + + # processing on 'id' is complete + if not response['more']: + self.activeLock.acquire() + self.active.remove(response['id']) + self.activeLock.release() + + self.response_queue.task_done() + + def set_token(self, access_token): + # clear all logs of any tokens, for security + if access_token is not None: + format = logging.root.handlers[0].formatter._fmt + formatter = FacebookFormatter(format, access_token) + logging.root.handlers[0].setFormatter(formatter) + + self.graph_builder.set_token(access_token) + + def make_request(self, request): + """ request should only have one of path, query, url + request = { + 'id': , + 'path': , + 'query': , + 'url': , + --- after served --- + 'response': , + 'more': , internal use, says whether response is last one or if there are 'more' + 'error': , + } + """ + self.activeLock.acquire() + self.id += 1 + request['id'] = self.id + self.active.append(request['id']) + self.activeLock.release() + self.request_queue.put(request) + return request['id'] + + def make_requests(self, requests): + self.activeLock.acquire() + rids = [] + for request in requests: + self.id += 1 + request['id'] = self.id + self.active.append(request['id']) + self.request_queue.put(request) + rids.append(request['id']) + self.activeLock.release() + return rids + + def request_active(self, id): + # answers the question: is the request done? + self.activeLock.acquire() + retVal = id in self.active + self.activeLock.release() + return retVal + + def requests_active(self, ids): + self.activeLock.acquire() + retVal = False + if len(list(set(ids) & set(self.active))) > 0: + retVal = True + self.activeLock.release() + return retVal + + def has_data(self, id): + self.dataLock.acquire() + # data can mean data or an error + retVal = id in self.data or id in self.errors + self.dataLock.release() + return retVal + + def get_data(self, id): + # returns available data, does not block + # will return all data before raising an error + retVal = retErr = None + + self.dataLock.acquire() + if id in self.data: + retVal = self.data.pop(id, None) + elif id in self.errors: + retErr = self.errors.pop(id, None) + self.dataLock.release() + + if retErr is not None: + raise retErr + else: + return retVal + +class FacebookFormatter(logging.Formatter): + def __init__(self, format, token): + logging.Formatter.__init__(self, format) + self.token = token + + def format(self, record): + msg = logging.Formatter.format(self, record) + return msg.replace(self.token, "") + def request_token(): """Prompt the user to login to facebook using their default web browser to obtain an OAuth token.""" @@ -277,7 +317,8 @@ def request_token(): RETURN_URL = "http://faceauth.appspot.com/" SCOPE = ''.join(['user_photos,', 'friends_photos,', - 'user_likes']) + 'user_likes,', + 'user_subscriptions',]) url = ''.join(['https://graph.facebook.com/oauth/authorize?', 'client_id=%(cid)s&', @@ -286,5 +327,7 @@ def request_token(): 'type=user_agent']) args = { "cid" : CLIENT_ID, "rurl" : RETURN_URL, "scope" : SCOPE, } + + log.info(url % args) - webbrowser.open(url % args) + webbrowser.open(url % args) \ No newline at end of file diff --git a/helpers.py b/helpers.py index 2621983..d2dcaed 100755 --- a/helpers.py +++ b/helpers.py @@ -25,166 +25,329 @@ import time import re import shutil -import json +import json -class Helper(object): - """Helper functions for retrieving Facebook data. +log = logging.getLogger('pg.%s' % __name__) - >>>graph = facebook.GraphAPI(access_token) - >>>helper = helpers.Helper(graph) - >>>helper.get_friends(id) - >>>helper.get_subscriptions(id) - >>>helper.get_likes(id) - >>>helper.get_albums(id) - >>>helper.get_tagged(id) - >>>helper.get_tagged_albums(id) - - The id field in all cases is the id of the target for backup. - - Args: - graph (obj): The path of the object to retreive. - """ - - def __init__(self, graph=None): +class PeopleGrabber(object): + def __init__(self, graph): self.graph = graph - self.logger = logging.getLogger('helper') - - def find_album_ids(self, picture_ids): - """Find the albums that contains pictures. - - The picture_id arguement must be a list of photo object_id's. - - Returns a list of album object_id's. If permissions for the album do - not allow for album information to be retrieved then it is omitted - from the list. - """ - - q = ''.join(['SELECT object_id, aid FROM album WHERE aid ', - 'IN (SELECT aid FROM photo WHERE object_id IN (%s))']) - - ids = [] - - # split query into 25 pictures at a time - for i in range(len(picture_ids) / 25 + 1): - pids = ','.join(picture_ids[i * 25:(i+1) * 25]) - new_ids = self.graph.fql(q % pids) - try: - new_ids = [x['object_id'] for x in new_ids] - except Exception as e: - self.logger.error('no album access') - self.logger.error('%s' % e) - bad_query = q % pids - self.logger.error('query: %s' % bad_query) - new_ids = [] - - ids = list(set(ids+new_ids)) - - return ids - - # The following methods return a list of object id <> friend - # [ {'id':, 'name':}, ... ] - - def get_me(self): - return self.graph.get_object('me') def get_info(self, id): - return self.graph.get_object('%s' % id) - + request = {'path':'%s' % id} + rid = self.graph.make_request(request) + while self.graph.request_active(rid): + time.sleep(1) + return self.graph.get_data(rid) + def get_friends(self, id): - data = self.graph.get_object('%s/friends' % id, 5000) - return sorted(data, key=lambda k:k['name'].lower()) + request = {'path':'%s/friends' % id} + rid = self.graph.make_request(request) + while self.graph.request_active(rid): + time.sleep(1) + a = self.graph.get_data(rid) + return a def get_subscriptions(self, id): - data = self.graph.get_object('%s/subscribedto' % id, 5000) - return sorted(data, key=lambda k:k['name'].lower()) - + request = {'path':'%s/subscribedto' % id} + rid = self.graph.make_request(request) + while self.graph.request_active(rid): + time.sleep(1) + return self.graph.get_data(rid) + def get_likes(self, id): - data = self.graph.get_object('%s/likes' % id, 5000) - return sorted(data, key=lambda k:k['name'].lower()) + request = {'path':'%s/likes' % id} + rid = self.graph.make_request(request) + while self.graph.request_active(rid): + time.sleep(1) + return self.graph.get_data(rid) + + +class AlbumGrabber(object): + def __init__(self, graph): + self.graph = graph - # return the list of album information that id has uploaded + def get_info(self, id): + request = {'path':'%s' % id} + rid = self.graph.make_request(request) + while self.graph.request_active(rid): + time.sleep(1) + return self.graph.get_data(rid) + + def list_albums(self, id): + request = {'path':'%s/albums' % id} + rid = self.graph.make_request(request) + while self.graph.request_active(rid): + time.sleep(1) + return self.graph.get_data(rid) + + def _get_node_comments(self, node, comments): + if not comments: + # correct tags + try: + node['tags'] = node['tags']['data'] + except Exception as e: + pass + + # correct likes + try: + node['likes'] = node['likes']['data'] + except Exception as e: + pass - def get_album_list(self, id): - return self.graph.get_object('%s/albums' % id) + # correct comments + try: + node['comments'] = node['comments']['data'] + except Exception as e: + pass + return + + # get node tags + try: + url = node['tags']['paging']['next'] + r = {'url':url} + node['tags_rid'] = self.graph.make_request(r) + except Exception as e: + pass + + # get node likes + try: + url = node['likes']['paging']['next'] + r = {'url':url} + node['likes_rid'] = self.graph.make_request(r) + except Exception as e: + pass - # The following methods return a list of albums & photos - # returns [{album_1}, ..., {album_n} ] - # where album_n = {'id':, 'comments':[<>], 'photos':[<>], ... } + # get node comments + try: + url = node['comments']['paging']['next'] + r = {'url':url} + node['comments_rid'] = self.graph.make_request(r) + except Exception as e: + pass - # note: there is a potential for optimization of comments - # only download comments if - # 1) we want comments - # 2) if comments already exists - # 3) follow comment paging, instead of re-getting all + # correct tags + try: + node['tags'] = node['tags']['data'] + except Exception as e: + pass + + # correct likes + try: + node['likes'] = node['likes']['data'] + except Exception as e: + pass + # correct comments + try: + node['comments'] = node['comments']['data'] + except Exception as e: + pass + + def _fulfill_album_requests(self, album): + """Does not fulfil 'photos_rid' since that may require additional + requests.""" + + wait = 0 + # fulfill all remaining data requests + if 'likes_rid' in album: + rid = album['likes_rid'] + if self.graph.request_active(rid): + wait += 1 + else: + try: + album['likes'].extend(self.graph.get_data(rid)) + except Exception as e: + log.exception(e) + finally: + album.pop('likes_rid', None) - def _fill_album(self, album, comments): - """Takes an already loaded album and fills out the photos and - comments""" + if 'comments_rid' in album: + rid = album['comments_rid'] + if self.graph.request_active(rid): + wait += 1 + else: + try: + album['comments'].extend(self.graph.get_data(rid)) + except Exception as e: + log.exception(e) + finally: + album.pop('comments_rid', None) - # album must be dictionary, with 'photos' + for photo in album['photos']: + if 'tags_rid' in photo: + rid = photo['tags_rid'] + if self.graph.request_active(rid): + wait += 1 + else: + try: + photo['tags'].extend(self.graph.get_data(rid)) + except Exception as e: + log.exception(e) + finally: + photo.pop('tags_rid', None) + + if 'likes_rid' in photo: + rid = photo['likes_rid'] + if self.graph.request_active(rid): + wait += 1 + else: + try: + photo['likes'].extend(self.graph.get_data(rid)) + except Exception as e: + log.exception(e) + finally: + photo.pop('likes_rid', None) + + if 'comments_rid' in photo: + rid = photo['comments_rid'] + if self.graph.request_active(rid): + wait += 1 + else: + try: + photo['comments'].extend(self.graph.get_data(rid)) + except Exception as e: + log.exception(e) + finally: + photo.pop('comments_rid', None) + + return wait + + def _finish_albums(self, albums, comments, focus=None): + # append photos to album & request photo info + oldwait = 0 + while True: + photos_done = True + wait = 0 + for album in albums: + # skip if photos already done + if 'photos_rid' not in album: + continue + + # check if request is done + rid = album['photos_rid'] + if self.graph.request_active(rid): + photos_done = False + wait += 1 + else: + try: + album['photos'] = self.graph.get_data(rid) + except Exception as e: + log.error('Photos request unsuccessful for album: %s' % album['id']) + log.exception(e) + album['photos'] = [] + finally: + album.pop('photos_rid', None) + + if focus is not None: + # remove photos that we don't care about + album['photos'] = [photo for photo in album['photos'] if photo['id'] in focus] + + for photo in album['photos']: + self._get_node_comments(photo, comments) + + if photos_done: break + if wait != oldwait: + log.info('Waiting on %d photos requests.' % wait) + oldwait = wait + time.sleep(1) + + log.info('All photos found. Waiting on remaining data requests.') - # get comments - if comments and 'comments' in album: - if len(album['comments']) >= 25: - album['comments'] = self.graph.get_object('%s/comments' % album['id']) + # fulfill all remaining data requests + oldwait = 0 + while True: + wait = 0 + for album in albums: + wait += self._fulfill_album_requests(album) - # get album photos - album['photos'] = self.graph.get_object('%s/photos' % album['id'], limit=50) + if wait is 0: break + if wait != oldwait: + log.info('Waiting on %d requests.' % wait) + oldwait = wait + time.sleep(1) - if len(album['photos']) == 0: - self.logger.error('album had zero photos: %s' % album['id']) - return None + return albums - for photo in album['photos']: - # get picture comments - if comments and 'comments' in photo: - n_before = len(photo['comments']['data']) - # using examples from: georgehtakei/photos - # the default number of comments to inculde in a photo from - # /photos or //photos is 25 - # this applies to likes also - if n_before >= 25: - photo['comments'] = self.graph.get_object('%s/comments' % photo['id']) - n_after = len(photo['comments']) - if n_before != n_after: - self.logger.info('found more comments:' + str(n_before) + ' to ' + str(n_after)) - return album - - def get_album(self, id, comments=False): - """Get a single album""" - - self.logger.info('begin get_album: %s' % id) - - # handle special case: - # create empty album if there are not permissions to view album info - # but can see photo from tagged - if id == '0': - album= {} - album['id'] = '0' - album['name'] = 'Unknown' - album['comments'] = [] - album['photos'] = [] - return album - - album = self.graph.get_object('%s' % id) - return self._fill_album(album, comments) - - def get_albums(self, id, comments=False): - """Get all albums uploaded by id""" - - self.logger.info('get_albums: %s' % id) - - data = self.graph.get_object('%s/albums' % id) - self.logger.info('albums: %d' % len(data)) + def get_target_albums(self, id, comments=True): + albums = [] + + # request list of albums + request = {'path':'%s/albums' % id} + rid = self.graph.make_request(request) + + # iterate over albums, requesting photos & comments + while True: + # request photos from albums + temp = self.graph.get_data(rid) # raises exception + if temp is None: + temp = [] + + for album in temp: + aid = album['id'] + albums.append(album) + + # get album photos + r = {'path':'%s/photos' % aid} + album['photos_rid'] = self.graph.make_request(r) + + # queue album metadata + self._get_node_comments(album, comments) + + # break loop when no more request data + active = self.graph.request_active(rid) + more_data = self.graph.has_data(rid) + if not active and not more_data: break + time.sleep(1) + + return self._finish_albums(albums, comments) + + def get_albums_by_id(self, albums, comments=True, focus=None): + # albums = [ {'id':, 'photos':[, ]} , ... ] + + # put in a request for each album + for album in albums: + request = {'path':'%s' % album['id']} + rid = self.graph.make_request(request) + album['album_rid'] = rid + + request = {'path':'%s/photos' % album['id']} + rid = self.graph.make_request(request) + album['photos_rid'] = rid - for album in data: - album = self._fill_album(album, comments) - - # remove empty albums - data = [album for album in data if album is not None] - data = [album for album in data if len(album['photos']) > 0] - return data + # fulfill album requests + oldwait = 0 + while True: + wait = 0 + for album in albums: + if 'album_rid' in album: + rid = album['album_rid'] + if self.graph.request_active(rid): + wait += 1 + else: + try: + temp = self.graph.get_data(rid) + except Exception as e: + log.exception(e) + temp = album + temp.pop('album_rid', None) + + try: + album.update(temp) + album.pop('album_rid', None) + except Exception as e: + log.exception(e) + import pdb; pdb.set_trace() + self._get_node_comments(album, comments) + + if wait is 0: break + if wait != oldwait: + log.info('Waiting on %d album requests.' % wait) + oldwait = wait + time.sleep(1) + + # get node comments on album + return self._finish_albums(albums, comments, focus) def get_tagged(self, id, comments=False, full=True): """Get all photos where argument id is tagged. @@ -194,49 +357,106 @@ def get_tagged(self, id, comments=False, full=True): full: get all photos from all album the user is tagged in """ - self.logger.info('get_tagged: %s' % id) + log.info('get_tagged: %s' % id) + + # request list of albums + request = {'path':'%s/photos' % id} + rid = self.graph.make_request(request) + + while self.graph.request_active(rid): + time.sleep(1) + + unsorted = self.graph.get_data(rid) # raises exception - unsorted = self.graph.get_object('%s/photos' % id) - self.logger.info('tagged in %d photos' % len(unsorted)) + log.info('tagged in %d photos' % len(unsorted)) unsorted_ids = [x['id'] for x in unsorted] album_ids = self.find_album_ids(unsorted_ids) data = [] - self.logger.info('%d photos in %d albums' % + log.info('%d photos in %d albums' % (len(unsorted_ids), len(album_ids))) - # TODO: this could be done in parallel for album_id in album_ids: - album = self.get_album(album_id, comments) - photo_ids = [x['id'] for x in album['photos']] - if not full: - # limit album to only those in unsorted, even though we now - # have information on them all... graph API sucks - photos = [x for x in unsorted if x['id'] in photo_ids] - album['photos'] = photos - # remove id's from unsorted that are in the album - unsorted = [x for x in unsorted if x['id'] not in photo_ids] + album = {} + album['id'] = album_id data.append(album) + + if full: + data = self.get_albums_by_id(data, comments) + else: + data = self.get_albums_by_id(data, comments, focus=unsorted_ids) + # clear out album photos + for album in data: + photo_ids = [pic['id'] for pic in album['photos']] + # remove id's from unsorted that are in the album + unsorted = [pic for pic in unsorted if pic['id'] not in photo_ids] + # anything not claimed under album_ids will fall into fake album if len(unsorted) > 0: - empty_album = self.get_album('0', comments) + empty_album = {} + empty_album['id'] = '0' + empty_album['name'] = 'Unknown' empty_album['photos'] = unsorted for photo in empty_album['photos']: - # get picture comments - if comments and 'comments' in photo: - photo['comments'] = self.graph.get_object('%s/comments' % photo['id']) + self._get_node_comments(photo, comments) - data.append(empty_album) + wait = 0 + while True: + wait = self._fulfill_album_requests(empty_album) + if wait is 0: break + time.sleep(1) - # remove empty albums - data = [album for album in data if album is not None] + data.append(empty_album) return data + def find_album_ids(self, picture_ids): + """Find the albums that contains pictures. + + The picture_id arguement must be a list of photo object_id's. + + Returns a list of album object_id's. If permissions for the album do + not allow for album information to be retrieved then it is omitted + from the list. + """ + + q = ''.join(['SELECT object_id, aid FROM album WHERE aid ', + 'IN (SELECT aid FROM photo WHERE object_id IN (%s))']) + + ids = [] + rids = [] + + # split query into 25 pictures at a time + for i in range(len(picture_ids) / 25 + 1): + pids = ','.join(picture_ids[i * 25:(i+1) * 25]) + request = {'query':q % pids} + rid = self.graph.make_request(request) + rids.append(rid) + + while True: + wait = 0 + for rid in rids: + if self.graph.request_active(rid): + wait += 1 + else: + try: + new_ids = self.graph.get_data(rid) + new_ids = [x['object_id'] for x in new_ids] + except Exception as e: + bad_query = q % pids + log.error('query: %s' % bad_query) + log.exception(e) + new_ids = [] + + ids = list(set(ids+new_ids)) + if wait is 0: break + time.sleep(1) + + return ids class DownloaderThread(threading.Thread): def __init__(self, q): @@ -247,7 +467,6 @@ def __init__(self, q): threading.Thread.__init__(self) self.daemon=True self.q = q - self.logger = logging.getLogger('DownloadThread') @repeater.repeat def _download(self, web_path): @@ -263,7 +482,7 @@ def run(self): web_path = photo['src_big'] picout = open(save_path, 'wb') - self.logger.info('downloading:%s' % web_path) + log.info('downloading:%s' % web_path) try: picout.write(self._download(web_path)) finally: @@ -274,22 +493,22 @@ def run(self): time_int = int(time.mktime(created_time)) os.utime(save_path, (time_int,) * 2) except Exception as e: - self.logger.error(e) + log.exception(e) self.q.task_done() -class ProcessThread(threading.Thread): - def __init__(self, helper, config): - threading.Thread.__init__(self) - self.helper = helper - self.config = config - self.logger = logging.getLogger('ProcessThread') +class DownloadPool(object): + def __init__(self): self.q = Queue.Queue() - self.msg = "Downloading..." - self.pics = 0 - self.total = 0 - def _processAlbum(self, album, path, comments): + def add_thread(self): + t=DownloaderThread(self.q) + t.start() + + def get_queue(self): + return self.q + + def save_album(self, album, path): # recursively make path # http://serverfault.com/questions/242110/which-common-charecters-are-illegal-in-unix-and-windows-filesystems # @@ -318,13 +537,10 @@ def _processAlbum(self, album, path, comments): # filename of photo photo['path'] = '%s' % photo['src_big'].split('/')[-1] + photo['path'] = '%s' % photo['path'].split('?')[0] # remove any extra arguement nonsense from end of url self.q.put( (photo,path) ) - # exit funcion if no need to save metadata - if not comments: - return - # save JSON file ts = time.strftime("%y-%m-%d_%H-%M-%S") @@ -340,14 +556,22 @@ def _processAlbum(self, album, path, comments): shutil.copy(filename, alfilename) shutil.copy(os.path.join('dep', 'viewer.html'), htmlfilename) except Exception as e: - self.logger.error(e) + log.error(e) + +class ProcessThread(threading.Thread): + def __init__(self, albumgrab, config, pool): + threading.Thread.__init__(self) + self.daemon=True + + self.albumgrab = albumgrab + self.config = config + self.pool = pool # downloadpool, must have threads already running + self.msg = "Downloading..." + self.pics = 0 + self.total = 0 def run(self): """Collect all necessary information and download all files""" - - for i in range(50): - t=DownloaderThread(self.q) - t.start() savedir = self.config['dir'] targets = self.config['targets'] @@ -356,23 +580,23 @@ def run(self): c = self.config['c'] a = self.config['a'] - self.logger.info("%s" % self.config) + log.info("%s" % self.config) for target in targets: - target_info = self.helper.get_info(target) + target_info = self.albumgrab.get_info(target) data = [] u_data = [] # get user uploaded photos if u: self.msg = 'Retrieving %s\'s album data...' % target_info['name'] - u_data = self.helper.get_albums(target, comments=c) + u_data = self.albumgrab.get_target_albums(target, comments=c) t_data = [] # get tagged if t: self.msg = 'Retrieving %s\'s tagged photo data...' % target_info['name'] - t_data = self.helper.get_tagged(target, comments=c, full=a) + t_data = self.albumgrab.get_tagged(target, comments=c, full=a) if u and t: # list of user ids @@ -384,11 +608,20 @@ def run(self): data.extend(t_data) # find duplicate album names + for album in data: + if 'name' not in album or 'from' not in album: + log.error('Name not in album: %s' % album) + + # idea, folder name = album (if from target) otherwise + # also, have 'process data, &args' options for get_target_albums, etc so we can download + # before waiting on all data + + data = [album for album in data if len(album['photos']) > 0] names = [album['name'] for album in data] duplicate_names = [name for name, count in collections.Counter(names).items() if count > 1] for album in data: if album['name'] in duplicate_names: - album['folder_name'] = '%s - %s' % (album['name'], album['id']) + album['folder_name'] = '%s - %s' % (album['name'], album['from']['name']) else: album['folder_name'] = album['name'] @@ -400,15 +633,15 @@ def run(self): for album in data: path = os.path.join(savedir,unicode(target_info['name'])) - self._processAlbum(album, path, c) + self.pool.save_album(album, path) - self.logger.info('Waiting for childeren to finish') + log.info('Waiting for childeren to finish.') - self.q.join() + self.pool.get_queue().join() - self.logger.info('Child processes completed') - self.logger.info('albums: %s' % len(data)) - self.logger.info('pics: %s' % self.total) + log.info('DownloaderThreads completed.') + log.info('Albums: %s' % len(data)) + log.info('Pics: %s' % self.total) self.msg = '%d photos downloaded!' % self.total diff --git a/pg.py b/pg.py index 9ff5d91..371b855 100755 --- a/pg.py +++ b/pg.py @@ -39,11 +39,13 @@ helps['dir'] = 'Specify the directory to store the downloaded information. (Use with --target or --album)' helps['debug'] = 'Log extra debug information to pg.log' +log = logging.getLogger('pg') + def print_func(text): if text: print text def main(): - # parse arguements + # parse arguments parser = argparse.ArgumentParser(description="Download Facebook photos.") parser.add_argument('--cmd', action='store_true', help=helps['cmd']) parser.add_argument('--token', help=helps['token']) @@ -59,55 +61,55 @@ def main(): parser.add_argument('--debug', choices=('info','debug'), help=helps['debug']) args = parser.parse_args() - + # setup logging + format = "%(asctime)s:%(levelname)s:%(name)s:%(lineno)d:%(message)s" + + logging.basicConfig(filename='pg.log', + filemode='w', + format=format, + level=logging.ERROR) + if args.debug == 'info': - logging.basicConfig(filename='pg.log', - filemode='w', - level=logging.INFO) + logging.getLogger("pg").setLevel(logging.INFO) elif args.debug == 'debug': - logging.basicConfig(filename='pg.log', - filemode='w', - level=logging.DEBUG) - else: - logging.basicConfig(filename='pg.log', - filemode='w', - level=logging.ERROR) - - logger = logging.getLogger('photograbber') + logging.getLogger("pg").setLevel(logging.DEBUG) - logger.info('Arguments parsed, logger configured.') + log.info('Arguments parsed, log configured.') # GUI if not args.cmd: - logger.info('Starting GUI.') + log.info('Starting GUI.') import pgui pgui.start() - logger.info('GUI completed, exiting.') + log.info('GUI completed, exiting.') exit() # Login if args.token is None: - logger.info('No token provided.') + log.info('No token provided.') browser = raw_input("Open Browser [y/n]: ") if not browser.isalnum(): raise ValueError('Input must be alphanumeric.') if browser == 'y': - logger.info('Opening default browser.') + log.info('Opening default browser.') facebook.request_token() time.sleep(1) args.token = raw_input("Enter Token: ") if not args.token.isalnum(): raise ValueError('Input must be alphanumeric.') - logger.info('Provided token: %s' % args.token) - - # TODO: check if token works, if not then quit + # setup facebook API objects graph = facebook.GraphAPI(args.token) - helper = helpers.Helper(graph) + graph.start() + peoplegrab = helpers.PeopleGrabber(graph) + albumgrab = helpers.AlbumGrabber(graph) + + # ensure token is removed from logs... + log.info('Provided token: %s' % self.token) # check if token works - my_info = helper.get_me() + my_info = peoplegrab.get_info('me') if not my_info: - logger.error('Provided Token Failed: %s' % args.token) + log.error('Provided Token Failed: %s' % args.token) print 'Provided Token Failed: OAuthException' exit() @@ -116,28 +118,28 @@ def main(): if args.list_targets == 'me': target_list.append(my_info) elif args.list_targets == 'friends': - target_list.extend(helper.get_friends('me')) + target_list.extend(peoplegrab.get_friends('me')) elif args.list_targets == 'likes': - target_list.extend(helper.get_likes('me')) + target_list.extend(peoplegrab.get_likes('me')) elif args.list_targets == 'following': - target_list.extend(helper.get_subscriptions('me')) + target_list.extend(peoplegrab.get_subscriptions('me')) elif args.list_targets == 'all': target_list.append(my_info) - target_list.extend(helper.get_friends('me')) - target_list.extend(helper.get_likes('me')) - target_list.extend(helper.get_subscriptions('me')) + target_list.extend(peoplegrab.get_friends('me')) + target_list.extend(peoplegrab.get_likes('me')) + target_list.extend(peoplegrab.get_subscriptions('me')) if args.list_targets is not None: - logger.info('Listing available targets.') + log.info('Listing available targets.') for target in target_list: print ('%(id)s:"%(name)s"' % target).encode('utf-8') return # --list_albums ... if args.list_albums is not None: - logger.info('Listing available albums.') + log.info('Listing available albums.') for target in args.list_albums: - album_list = helper.get_album_list(target) + album_list = albumgrab.list_albums(target) for album in album_list: print ('%(id)s:"%(name)s"' % album).encode('utf-8') return @@ -152,18 +154,37 @@ def main(): args.dir = unicode(args.dir) if not os.path.exists(args.dir): raise ValueError('Download Location must exist.') - logger.info('Download Location: %s' % args.dir) + log.info('Download Location: %s' % args.dir) # --album ... if args.album is not None: - logger.info('Downloading albums.') + log.info('Downloading albums.') + albums = [] for album in args.album: # note, doesnt manually ask for caut options for album if not album.isdigit(): raise ValueError('Input must be numeric.') print 'Retrieving album data: %s...' % album - data = helper.get_album(album, comments=args.c) - print 'Downloading photos' - downloader.save_album(data, args.dir) + albums.append({'id':album}) + + data = albumgrab.get_albums_by_id(albums, comments=args.c) + + # todo: filter photos_ids from albums before downloading... + + print 'Downloading photos' + pool = helpers.DownloadPool() + for a in range(5): pool.add_thread() + + # find duplicate album names + names = [album['name'] for album in data] + duplicate_names = [name for name, count in collections.Counter(names).items() if count > 1] + for album in data: + if album['name'] in duplicate_names: + album['folder_name'] = '%s - %s' % (album['name'], album['id']) + else: + album['folder_name'] = album['name'] + pool.save_album(album, args.dir, args.c) + + pool.get_queue().join() return # --target ... @@ -193,7 +214,6 @@ def main(): if 'a' in opt_str: args.a = True - # TODO: logger print caut options, logger duplicate print info's config = {} config['dir'] = args.dir config['targets'] = args.target @@ -202,8 +222,12 @@ def main(): config['c'] = args.c config['a'] = args.a - # processing heavy function - thread = helpers.ProcessThread(self.helper, self.config) + # download pool + pool = helpers.DownloadPool() + for a in range(5): pool.add_thread() + + # process thread + thread = helpers.ProcessThread(albumgrab, config, pool) thread.start() thread.join() diff --git a/pgui.py b/pgui.py index 058f39c..28b0989 100644 --- a/pgui.py +++ b/pgui.py @@ -24,75 +24,15 @@ import sys from PySide import QtCore, QtGui from wizard import Ui_Wizard +from operator import itemgetter import facebook import helpers import logging -class LoginSignal(QtCore.QObject): - sig = QtCore.Signal(int) - err = QtCore.Signal(str) +log = logging.getLogger('pg.%s' % __name__) -class LoginThread(QtCore.QThread): - def __init__(self, helper, data, parent=None): - QtCore.QThread.__init__(self, parent) - - self.mutex = QtCore.QMutex() - self.abort = False #accessed by both threads - self.data_ready = False - - self.helper = helper - self.data = data - self.status = LoginSignal() - - def run(self): - try: - self.mutex.lock() - if self.abort: - self.mutex.unlock() - return - self.mutex.unlock() - - self.data['my_info'] = self.helper.get_me() - - self.mutex.lock() - if self.abort: - self.mutex.unlock() - return - self.status.sig.emit(1) - self.mutex.unlock() - - self.data['friends'] = self.helper.get_friends('me') - - self.mutex.lock() - if self.abort: - self.mutex.unlock() - return - self.status.sig.emit(2) - self.mutex.unlock() - - self.data['likes'] = self.helper.get_likes('me') - - self.mutex.lock() - if self.abort: - self.mutex.unlock() - return - self.status.sig.emit(3) - self.mutex.unlock() - - self.data['subscriptions'] = self.helper.get_subscriptions('me') - self.status.sig.emit(4) - - self.data_ready = True - except Exception as e: - self.status.err.emit('%s' % e) - - def stop(self): - self.mutex.lock() - self.abort = True - self.mutex.unlock() - class ControlMainWindow(QtGui.QWizard): def __init__(self, parent=None): super(ControlMainWindow, self).__init__(parent) @@ -100,12 +40,15 @@ def __init__(self, parent=None): self.ui.setupUi(self) # data - self.logger = logging.getLogger('PhotoGrabberGUI') - self.helper = None + self.graph = facebook.GraphAPI('') + self.graph.start() + self.peoplegrab = None + self.albumgrab = None + self.pool = helpers.DownloadPool() + for i in range(15): self.pool.add_thread() self.token = '' self.config = {} - self.config['sleep_time'] = 0.1 - self.advancedTarget = "" + self.adv_target = "" # connect signals and validate pages self.ui.aboutPushButton.clicked.connect(self.aboutPressed) @@ -118,53 +61,79 @@ def __init__(self, parent=None): self.ui.wizardPageLocation.validatePage = self.beginDownload def aboutPressed(self): - QtGui.QMessageBox.about(self, "About", "PhotoGrabber v100\n(C) 2013 Ourbunny\nGPLv3\n\nphotograbber.com\nFor full licensing information view the LICENSE.txt file.") + QtGui.QMessageBox.about(self, "About", "PhotoGrabber v100\n(C) 2013 Ourbunny\nGPLv3\n\nphotograbber.com\nView the LICENSE.txt file for full licensing information.") def loginPressed(self): facebook.request_token() def advancedPressed(self): - self.advancedTarget, ok = QtGui.QInputDialog.getText(self, "Specify Target", "ID/username of target", text=self.advancedTarget) + self.adv_target, ok = QtGui.QInputDialog.getText(self, "Specify Target", "ID/username of target", text=self.adv_target) if ok: self.ui.targetTreeWidget.setEnabled(False) else: self.ui.targetTreeWidget.setEnabled(True) def errorMessage(self, error): + log.exception(error) QtGui.QMessageBox.critical(self, "Error", '%s' % error) def validateLogin(self): # present progress modal - progress = QtGui.QProgressDialog("Logging in...", "Abort", 0, 4, parent=self) + progress = QtGui.QProgressDialog("Logging in...", "Abort", 0, 0, parent=self) #QtGui.qApp.processEvents() is unnecessary when dialog is Modal progress.setWindowModality(QtCore.Qt.WindowModal) progress.show() - # attempt to login self.token = self.ui.enterTokenLineEdit.text() + + # allow user to specify debug mode + if self.token.endswith(":debug"): + logging.getLogger("pg").setLevel(logging.DEBUG) + log.info('DEBUG mode enabled.') + self.token = self.token.split(":debug")[0] + if self.token.endswith(":info"): + logging.getLogger("pg").setLevel(logging.INFO) + log.info('INFO mode enabled.') + self.token = self.token.split(":info")[0] + try: - if not self.token.isalnum(): raise Exception("Please insert a valid token") - self.helper = helpers.Helper(facebook.GraphAPI(self.token)) + if not self.token.isalnum(): raise Exception("Please insert a valid token.") + self.graph.set_token(self.token) + + # ensure token is removed from logs... + log.info('Provided token: %s' % self.token) + + self.peoplegrab = helpers.PeopleGrabber(self.graph) + self.albumgrab = helpers.AlbumGrabber(self.graph) except Exception as e: progress.close() self.errorMessage(e) return False data = {} - thread = LoginThread(self.helper, data) - thread.status.sig.connect(progress.setValue) - thread.status.err.connect(self.errorMessage) - thread.status.err.connect(progress.cancel) - thread.start() - while thread.isRunning(): + requests = [] + requests.append({'path':'me'}) + requests.append({'path':'me/friends'}) + requests.append({'path':'me/likes'}) + requests.append({'path':'me/subscribedto'}) + + rids = self.graph.make_requests(requests) + while self.graph.requests_active(rids): QtGui.qApp.processEvents() if progress.wasCanceled(): - thread.stop() - thread.wait() + progress.close() return False - if progress.wasCanceled() or not thread.data_ready: return False + try: + data['my_info'] = self.graph.get_data(rids[0]) + data['friends'] = sorted(self.graph.get_data(rids[1]), key=itemgetter('name')) + data['likes'] = sorted(self.graph.get_data(rids[2]), key=itemgetter('name')) + data['subscriptions'] = sorted(self.graph.get_data(rids[3]), key=itemgetter('name')) + except Exception as e: + progress.close() + self.errorMessage(e) + return False # clear list self.ui.targetTreeWidget.topLevelItem(0).takeChildren() @@ -216,7 +185,7 @@ def validateTarget(self): # make sure a real item is selected self.config['targets'] = [] if not self.ui.targetTreeWidget.isEnabled(): - self.config['targets'].append(self.advancedTarget) + self.config['targets'].append(self.adv_target) #get info on target? return True @@ -244,19 +213,18 @@ def beginDownload(self): progress.show() # processing heavy function - thread = helpers.ProcessThread(self.helper, self.config) + thread = helpers.ProcessThread(self.albumgrab, self.config, self.pool) thread.start() while thread.isAlive(): QtGui.qApp.processEvents() progress.setLabelText(thread.status()) if progress.wasCanceled(): - #thread.stop() sys.exit() progress.setValue(total) progress.setLabelText(thread.status()) - QtGui.QMessageBox.information(self, "Done", "Download is complete") + QtGui.QMessageBox.information(self, "Done", "Download is complete.") progress.close() return True @@ -265,5 +233,3 @@ def start(): mySW = ControlMainWindow() mySW.show() sys.exit(app.exec_()) - - diff --git a/repeater.py b/repeater.py index 6a109ee..da436cc 100755 --- a/repeater.py +++ b/repeater.py @@ -18,6 +18,8 @@ import logging import time +log = logging.getLogger('pg.%s' % __name__) + class DoNotRepeatError(Exception): """Raise DoNotRepeatError in a function to force repeat() to exit.""" @@ -25,7 +27,16 @@ def __init__(self, error): Exception.__init__(self, error.message) self.error = error -def repeat(func, n=10, standoff=1.5): +class PauseRepeatError(Exception): + """Raise PauseRepeatError in a function to delay repeating for a set number + of seconds.""" + + def __init__(self, error, delay): + Exception.__init__(self, error.message) + self.error = error + self.delay = delay + +def repeat(func, n=5, standoff=1.5): """Execute a function repeatedly until success (no exceptions raised). Args: @@ -44,7 +55,7 @@ def repeat(func, n=10, standoff=1.5): >>> print 'B' >>>@repeater.repeat - >>> def pass(): + >>>def pass(): >>> print 'B' >>>@repeater.repeat @@ -75,15 +86,18 @@ def repeat(func, n=10, standoff=1.5): def wrapped(*args, **kwargs): retries = 0 - logger = logging.getLogger('repeater') while True: try: return func(*args, **kwargs) except DoNotRepeatError as e: # raise the exception that caused funciton failure raise e.error + except PauseRepeatError as e: + log.exception(e) + time.sleep(e.delay) + retries += 1 except Exception as e: - logger.exception('Function failed: %s' % e) + log.exception(e) if retries < n: retries += 1 time.sleep(retries * standoff) From 33d9a07d9c520e2e057bd54bb757dd52e00472bf Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Sun, 21 Apr 2013 13:25:42 -0500 Subject: [PATCH 24/38] corrected cmd line option Fixed 2 copy and paste errors, changed album downloading path to include album author --- dep/viewer.html | 2 +- pg.py | 24 +++++++++++++----------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/dep/viewer.html b/dep/viewer.html index 22bed98..8810ab2 100644 --- a/dep/viewer.html +++ b/dep/viewer.html @@ -294,7 +294,7 @@

This page is best viewed in a modern web browser...

-

generated by photograbber

+

generated by photograbber

diff --git a/pg.py b/pg.py index 371b855..a6b362e 100755 --- a/pg.py +++ b/pg.py @@ -46,7 +46,7 @@ def print_func(text): def main(): # parse arguments - parser = argparse.ArgumentParser(description="Download Facebook photos.") + parser = argparse.ArgumentParser(description="Download photos from Facebook.") parser.add_argument('--cmd', action='store_true', help=helps['cmd']) parser.add_argument('--token', help=helps['token']) parser.add_argument('--list-targets', choices=('me','friends','likes','following','all'), help=helps['list-targets']) @@ -104,7 +104,7 @@ def main(): albumgrab = helpers.AlbumGrabber(graph) # ensure token is removed from logs... - log.info('Provided token: %s' % self.token) + log.info('Provided token: %s' % args.token) # check if token works my_info = peoplegrab.get_info('me') @@ -174,15 +174,12 @@ def main(): pool = helpers.DownloadPool() for a in range(5): pool.add_thread() - # find duplicate album names - names = [album['name'] for album in data] - duplicate_names = [name for name, count in collections.Counter(names).items() if count > 1] + # set path to include the name of who uploaded the album + data = [album for album in data if len(album['photos']) > 0] for album in data: - if album['name'] in duplicate_names: - album['folder_name'] = '%s - %s' % (album['name'], album['id']) - else: - album['folder_name'] = album['name'] - pool.save_album(album, args.dir, args.c) + album['folder_name'] = album['name'] + path = os.path.join(args.dir, unicode(album['from']['name'])) + pool.save_album(album, path) pool.get_queue().join() return @@ -191,7 +188,9 @@ def main(): if args.target is None: args.target = [] args.target.append(raw_input("Target: ")) - if not args.target.isalnum(): raise ValueError('Input must be alphanumeric') + + for target in args.target: + if not target.isalnum(): raise ValueError('Input must be alphanumeric') # get options if not args.c and not args.a: @@ -229,6 +228,9 @@ def main(): # process thread thread = helpers.ProcessThread(albumgrab, config, pool) thread.start() + + print 'Please wait while I download your photos...' + thread.join() if __name__ == "__main__": From 8630720a8f0384d5446e1c6298e59c13a9f4d50e Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Tue, 23 Apr 2013 18:50:13 -0500 Subject: [PATCH 25/38] version clarification next release will be version 2.100, previous one was r99. switching to major version 2, considering all changes '1 revision'. --- facebook.py | 2 +- pgui.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/facebook.py b/facebook.py index c69a74e..d0f3fcf 100755 --- a/facebook.py +++ b/facebook.py @@ -314,7 +314,7 @@ def request_token(): import webbrowser CLIENT_ID = "139730900025" - RETURN_URL = "http://faceauth.appspot.com/" + RETURN_URL = "http://faceauth.appspot.com/?version=2100" SCOPE = ''.join(['user_photos,', 'friends_photos,', 'user_likes,', diff --git a/pgui.py b/pgui.py index 28b0989..ebd7457 100644 --- a/pgui.py +++ b/pgui.py @@ -61,7 +61,7 @@ def __init__(self, parent=None): self.ui.wizardPageLocation.validatePage = self.beginDownload def aboutPressed(self): - QtGui.QMessageBox.about(self, "About", "PhotoGrabber v100\n(C) 2013 Ourbunny\nGPLv3\n\nphotograbber.com\nView the LICENSE.txt file for full licensing information.") + QtGui.QMessageBox.about(self, "About", "PhotoGrabber v2.100\n(C) 2013 Ourbunny\nGPLv3\n\nphotograbber.org\nView the LICENSE.txt file for full licensing information.") def loginPressed(self): facebook.request_token() From 29fba732d7cf04161384cd34ef53de514e01b5e8 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Tue, 23 Apr 2013 18:50:56 -0500 Subject: [PATCH 26/38] ignore logs ensure logs are not accidentally included in a commit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 0d73ec6..73322c5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,10 @@ var sdist develop-eggs .installed.cfg + +# pg specific requests +*.log # Installer logs pip-log.txt From 5d1ff86a48028be74005dd671c41f220fb9c7f49 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Tue, 23 Apr 2013 18:52:17 -0500 Subject: [PATCH 27/38] resource wrapper include a resource wrapper for pyinstaller --one-file --- helpers.py | 6 ++++-- res.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 res.py diff --git a/helpers.py b/helpers.py index d2dcaed..09164f5 100755 --- a/helpers.py +++ b/helpers.py @@ -25,7 +25,8 @@ import time import re import shutil -import json +import json +import res log = logging.getLogger('pg.%s' % __name__) @@ -554,7 +555,8 @@ def save_album(self, album, path): db_file.write(";\n") db_file.close() shutil.copy(filename, alfilename) - shutil.copy(os.path.join('dep', 'viewer.html'), htmlfilename) + template_path = res.getpath('dep/viewer.html') + shutil.copy(template_path, htmlfilename) except Exception as e: log.error(e) diff --git a/res.py b/res.py new file mode 100644 index 0000000..1b99f2a --- /dev/null +++ b/res.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2013 Ourbunny +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +# necessary because of how onefile and pyinstaller works +# http://www.pyinstaller.org/export/v2.0/project/doc/Manual.html#accessing-data-files + +import os +import sys + +def getpath(name): + if getattr(sys, 'frozen', None): + basedir = sys._MEIPASS + else: + basedir = os.path.dirname(__file__) + + return os.path.join(basedir, name) \ No newline at end of file From 83e70e0af0b62745eb59c7d01d61a2078df4888f Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Tue, 23 Apr 2013 18:53:50 -0500 Subject: [PATCH 28/38] force usage of correct cacert file requests can mistakenly not include the cacert file when bundling on windows, force resource usage --- pg.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pg.py b/pg.py index a6b362e..b0e2303 100755 --- a/pg.py +++ b/pg.py @@ -24,6 +24,12 @@ import logging import os +import res + +# error when packaging +# https://github.com/kennethreitz/requests/issues/557 +os.environ['REQUESTS_CA_BUNDLE'] = res.getpath('requests/cacert.pem') + # help strings helps = {} helps['u'] = 'Download all albums uploaded by the target. (Use with --target)' @@ -76,7 +82,7 @@ def main(): logging.getLogger("pg").setLevel(logging.DEBUG) log.info('Arguments parsed, log configured.') - + # GUI if not args.cmd: log.info('Starting GUI.') From cdee36fa2c2b7aaf8a611c2238802e639f573905 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Tue, 23 Apr 2013 18:54:14 -0500 Subject: [PATCH 29/38] app icon force Qt windows to display the photograbber icon --- dep/pg.png | Bin 0 -> 71729 bytes wizard.py | 2 ++ 2 files changed, 2 insertions(+) create mode 100644 dep/pg.png diff --git a/dep/pg.png b/dep/pg.png new file mode 100644 index 0000000000000000000000000000000000000000..c73d669682eff979425d17bb436b590ea597e73c GIT binary patch literal 71729 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 Date: Tue, 23 Apr 2013 19:04:40 -0500 Subject: [PATCH 30/38] binary spec file pyinstaller spec file --- PhotoGrabber.spec | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 PhotoGrabber.spec diff --git a/PhotoGrabber.spec b/PhotoGrabber.spec new file mode 100644 index 0000000..19e51af --- /dev/null +++ b/PhotoGrabber.spec @@ -0,0 +1,23 @@ +# -*- mode: python -*- +a = Analysis(['pg.py'], + hiddenimports=[], + hookspath=None) + +a.datas += [ + ('dep/pg.png', 'dep/pg.png', 'DATA'), + ('dep/viewer.html', 'dep/viewer.html', 'DATA'), + ('requests/cacert.pem', 'requests/cacert.pem', 'DATA'), + ] +pyz = PYZ(a.pure) +exe = EXE(pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + name=os.path.join('dist', 'PhotoGrabber.exe'), + debug=False, + strip=None, + upx=True, + console=False , icon='dep\\pg.ico') +app = BUNDLE(exe, + name=os.path.join('dist', 'PhotoGrabber.exe.app')) From eef945f07f66c7f9cc7c6b44ce607177310404ac Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Tue, 23 Apr 2013 19:48:17 -0500 Subject: [PATCH 31/38] reduce logged data reduce logged data. this _should_ only happen on an unknown album without correct permissions to get album data --- helpers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/helpers.py b/helpers.py index 09164f5..2792d48 100755 --- a/helpers.py +++ b/helpers.py @@ -612,7 +612,8 @@ def run(self): # find duplicate album names for album in data: if 'name' not in album or 'from' not in album: - log.error('Name not in album: %s' % album) + log.debug('Name not in album: %s' % album) + log.error('name or from not in album') # idea, folder name = album (if from target) otherwise # also, have 'process data, &args' options for get_target_albums, etc so we can download From d2e3bcccce75b5c05624362fc50c6c0cf98d3f01 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Tue, 23 Apr 2013 21:20:30 -0500 Subject: [PATCH 32/38] rm duplicate line --- wizard.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/wizard.py b/wizard.py index 4210a16..daf5fa0 100644 --- a/wizard.py +++ b/wizard.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - # -*- coding: utf-8 -*- # # Copyright (C) 2013 Ourbunny From 9540bd2edacefabc6c881efd1a564d8c373b96c0 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Tue, 23 Apr 2013 21:25:56 -0500 Subject: [PATCH 33/38] OSX app packing -py2app packaging script -modification to file to determine location on OSX -TODO: try on windows and linux (packed and src) --- res.py | 10 +++++++--- setup-osx.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 setup-osx.py diff --git a/res.py b/res.py index 1b99f2a..0f29300 100644 --- a/res.py +++ b/res.py @@ -22,11 +22,15 @@ import os import sys +import logging +log = logging.getLogger(__name__) def getpath(name): - if getattr(sys, 'frozen', None): + if getattr(sys, '_MEIPASS', None): basedir = sys._MEIPASS else: - basedir = os.path.dirname(__file__) - + #basedir = os.path.dirname(__file__) + basedir = os.getcwd() + + log.error('basedir: %s' % basedir) return os.path.join(basedir, name) \ No newline at end of file diff --git a/setup-osx.py b/setup-osx.py new file mode 100644 index 0000000..425bd33 --- /dev/null +++ b/setup-osx.py @@ -0,0 +1,18 @@ +""" +This is a setup.py script generated by py2applet + +Usage: + python setup.py py2app +""" +from setuptools import setup + +DATA_FILES = [('dep',['dep/viewer.html','dep/pg.png']), + ('requests',['requests/cacert.pem'])] + +setup( + name="PhotoGrabber", + data_files=DATA_FILES, + setup_requires=['py2app'], + app=['pg.py'], + options=dict(py2app=dict(argv_emulation=True,iconfile='dep/pg.icns')), +) From 7b7407bf37ac58d7bb2b7c1499ea3ca8c901ef98 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Wed, 24 Apr 2013 19:58:49 -0500 Subject: [PATCH 34/38] security MITM could inject JS to steal token. Always use HTTPS. --- facebook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/facebook.py b/facebook.py index d0f3fcf..c40c3e7 100755 --- a/facebook.py +++ b/facebook.py @@ -314,7 +314,7 @@ def request_token(): import webbrowser CLIENT_ID = "139730900025" - RETURN_URL = "http://faceauth.appspot.com/?version=2100" + RETURN_URL = "https://faceauth.appspot.com/?version=2100" SCOPE = ''.join(['user_photos,', 'friends_photos,', 'user_likes,', From d109d422ea2fe50b71ca266a294611fbcca9b0e1 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Wed, 24 Apr 2013 20:00:21 -0500 Subject: [PATCH 35/38] additional license information including licenses for python, requests, pyside, and qt --- LICENSE-PySide-Qt.txt | 502 +++++++++++++++++++++++++++ LICENSE-Python27.txt | 773 ++++++++++++++++++++++++++++++++++++++++++ LICENSE-requests.txt | 13 + README.md | 14 +- 4 files changed, 1298 insertions(+), 4 deletions(-) create mode 100644 LICENSE-PySide-Qt.txt create mode 100644 LICENSE-Python27.txt create mode 100644 LICENSE-requests.txt diff --git a/LICENSE-PySide-Qt.txt b/LICENSE-PySide-Qt.txt new file mode 100644 index 0000000..f166cc5 --- /dev/null +++ b/LICENSE-PySide-Qt.txt @@ -0,0 +1,502 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! \ No newline at end of file diff --git a/LICENSE-Python27.txt b/LICENSE-Python27.txt new file mode 100644 index 0000000..d4dbdeb --- /dev/null +++ b/LICENSE-Python27.txt @@ -0,0 +1,773 @@ +A. HISTORY OF THE SOFTWARE +========================== + +Python was created in the early 1990s by Guido van Rossum at Stichting +Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands +as a successor of a language called ABC. Guido remains Python's +principal author, although it includes many contributions from others. + +In 1995, Guido continued his work on Python at the Corporation for +National Research Initiatives (CNRI, see http://www.cnri.reston.va.us) +in Reston, Virginia where he released several versions of the +software. + +In May 2000, Guido and the Python core development team moved to +BeOpen.com to form the BeOpen PythonLabs team. In October of the same +year, the PythonLabs team moved to Digital Creations (now Zope +Corporation, see http://www.zope.com). In 2001, the Python Software +Foundation (PSF, see http://www.python.org/psf/) was formed, a +non-profit organization created specifically to own Python-related +Intellectual Property. Zope Corporation is a sponsoring member of +the PSF. + +All Python releases are Open Source (see http://www.opensource.org for +the Open Source Definition). Historically, most, but not all, Python +releases have also been GPL-compatible; the table below summarizes +the various releases. + + Release Derived Year Owner GPL- + from compatible? (1) + + 0.9.0 thru 1.2 1991-1995 CWI yes + 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes + 1.6 1.5.2 2000 CNRI no + 2.0 1.6 2000 BeOpen.com no + 1.6.1 1.6 2001 CNRI yes (2) + 2.1 2.0+1.6.1 2001 PSF no + 2.0.1 2.0+1.6.1 2001 PSF yes + 2.1.1 2.1+2.0.1 2001 PSF yes + 2.2 2.1.1 2001 PSF yes + 2.1.2 2.1.1 2002 PSF yes + 2.1.3 2.1.2 2002 PSF yes + 2.2.1 2.2 2002 PSF yes + 2.2.2 2.2.1 2002 PSF yes + 2.2.3 2.2.2 2003 PSF yes + 2.3 2.2.2 2002-2003 PSF yes + 2.3.1 2.3 2002-2003 PSF yes + 2.3.2 2.3.1 2002-2003 PSF yes + 2.3.3 2.3.2 2002-2003 PSF yes + 2.3.4 2.3.3 2004 PSF yes + 2.3.5 2.3.4 2005 PSF yes + 2.4 2.3 2004 PSF yes + 2.4.1 2.4 2005 PSF yes + 2.4.2 2.4.1 2005 PSF yes + 2.4.3 2.4.2 2006 PSF yes + 2.4.4 2.4.3 2006 PSF yes + 2.5 2.4 2006 PSF yes + 2.5.1 2.5 2007 PSF yes + 2.5.2 2.5.1 2008 PSF yes + 2.5.3 2.5.2 2008 PSF yes + 2.6 2.5 2008 PSF yes + 2.6.1 2.6 2008 PSF yes + 2.6.2 2.6.1 2009 PSF yes + 2.6.3 2.6.2 2009 PSF yes + 2.6.4 2.6.3 2009 PSF yes + 2.6.5 2.6.4 2010 PSF yes + 2.7 2.6 2010 PSF yes + +Footnotes: + +(1) GPL-compatible doesn't mean that we're distributing Python under + the GPL. All Python licenses, unlike the GPL, let you distribute + a modified version without making your changes open source. The + GPL-compatible licenses make it possible to combine Python with + other software that is released under the GPL; the others don't. + +(2) According to Richard Stallman, 1.6.1 is not GPL-compatible, + because its license has a choice of law clause. According to + CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 + is "not incompatible" with the GPL. + +Thanks to the many outside volunteers who have worked under Guido's +direction to make these releases possible. + + +B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON +=============================================================== + +PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +-------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, +2011, 2012 Python Software Foundation; All Rights Reserved" are retained in Python +alone or in any derivative version prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 +------------------------------------------- + +BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 + +1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an +office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the +Individual or Organization ("Licensee") accessing and otherwise using +this software in source or binary form and its associated +documentation ("the Software"). + +2. Subject to the terms and conditions of this BeOpen Python License +Agreement, BeOpen hereby grants Licensee a non-exclusive, +royalty-free, world-wide license to reproduce, analyze, test, perform +and/or display publicly, prepare derivative works, distribute, and +otherwise use the Software alone or in any derivative version, +provided, however, that the BeOpen Python License is retained in the +Software, alone or in any derivative version prepared by Licensee. + +3. BeOpen is making the Software available to Licensee on an "AS IS" +basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE +SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS +AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY +DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +5. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +6. This License Agreement shall be governed by and interpreted in all +respects by the law of the State of California, excluding conflict of +law provisions. Nothing in this License Agreement shall be deemed to +create any relationship of agency, partnership, or joint venture +between BeOpen and Licensee. This License Agreement does not grant +permission to use BeOpen trademarks or trade names in a trademark +sense to endorse or promote products or services of Licensee, or any +third party. As an exception, the "BeOpen Python" logos available at +http://www.pythonlabs.com/logos.html may be used according to the +permissions granted on that web page. + +7. By copying, installing or otherwise using the software, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 +--------------------------------------- + +1. This LICENSE AGREEMENT is between the Corporation for National +Research Initiatives, having an office at 1895 Preston White Drive, +Reston, VA 20191 ("CNRI"), and the Individual or Organization +("Licensee") accessing and otherwise using Python 1.6.1 software in +source or binary form and its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, CNRI +hereby grants Licensee a nonexclusive, royalty-free, world-wide +license to reproduce, analyze, test, perform and/or display publicly, +prepare derivative works, distribute, and otherwise use Python 1.6.1 +alone or in any derivative version, provided, however, that CNRI's +License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) +1995-2001 Corporation for National Research Initiatives; All Rights +Reserved" are retained in Python 1.6.1 alone or in any derivative +version prepared by Licensee. Alternately, in lieu of CNRI's License +Agreement, Licensee may substitute the following text (omitting the +quotes): "Python 1.6.1 is made available subject to the terms and +conditions in CNRI's License Agreement. This Agreement together with +Python 1.6.1 may be located on the Internet using the following +unique, persistent identifier (known as a handle): 1895.22/1013. This +Agreement may also be obtained from a proxy server on the Internet +using the following URL: http://hdl.handle.net/1895.22/1013". + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python 1.6.1 or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python 1.6.1. + +4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" +basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. This License Agreement shall be governed by the federal +intellectual property law of the United States, including without +limitation the federal copyright law, and, to the extent such +U.S. federal law does not apply, by the law of the Commonwealth of +Virginia, excluding Virginia's conflict of law provisions. +Notwithstanding the foregoing, with regard to derivative works based +on Python 1.6.1 that incorporate non-separable material that was +previously distributed under the GNU General Public License (GPL), the +law of the Commonwealth of Virginia shall govern this License +Agreement only as to issues arising under or with respect to +Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this +License Agreement shall be deemed to create any relationship of +agency, partnership, or joint venture between CNRI and Licensee. This +License Agreement does not grant permission to use CNRI trademarks or +trade name in a trademark sense to endorse or promote products or +services of Licensee, or any third party. + +8. By clicking on the "ACCEPT" button where indicated, or by copying, +installing or otherwise using Python 1.6.1, Licensee agrees to be +bound by the terms and conditions of this License Agreement. + + ACCEPT + + +CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 +-------------------------------------------------- + +Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, +The Netherlands. All rights reserved. + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of Stichting Mathematisch +Centrum or CWI not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior +permission. + +STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO +THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE +FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +Additional Conditions for this Windows binary build +--------------------------------------------------- + +This program is linked with and uses Microsoft Distributable Code, +copyrighted by Microsoft Corporation. The Microsoft Distributable Code +includes the following files: + +msvcr90.dll +msvcp90.dll +msvcm90.dll + +If you further distribute programs that include the Microsoft +Distributable Code, you must comply with the restrictions on +distribution specified by Microsoft. In particular, you must require +distributors and external end users to agree to terms that protect the +Microsoft Distributable Code at least as much as Microsoft's own +requirements for the Distributable Code. See Microsoft's documentation +(included in its developer tools and on its website at microsoft.com) +for specific details. + +Redistribution of the Windows binary build of the Python interpreter +complies with this agreement, provided that you do not: + +- alter any copyright, trademark or patent notice in Microsoft's +Distributable Code; + +- use Microsoft's trademarks in your programs' names or in a way that +suggests your programs come from or are endorsed by Microsoft; + +- distribute Microsoft's Distributable Code to run on a platform other +than Microsoft operating systems, run-time technologies or application +platforms; or + +- include Microsoft Distributable Code in malicious, deceptive or +unlawful programs. + +These restrictions apply only to the Microsoft Distributable Code as +defined above, not to Python itself or any programs running on the +Python interpreter. The redistribution of the Python interpreter and +libraries is governed by the Python Software License included with this +file, or by other licenses as marked. + + +This copy of Python includes a copy of bzip2, which is licensed under the following terms: + + +-------------------------------------------------------------------------- + +This program, "bzip2", the associated library "libbzip2", and all +documentation, are copyright (C) 1996-2007 Julian R Seward. All +rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. The origin of this software must not be misrepresented; you must + not claim that you wrote the original software. If you use this + software in a product, an acknowledgment in the product + documentation would be appreciated but is not required. + +3. Altered source versions must be plainly marked as such, and must + not be misrepresented as being the original software. + +4. The name of the author may not be used to endorse or promote + products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Julian Seward, jseward@bzip.org +bzip2/libbzip2 version 1.0.5 of 10 December 2007 + +-------------------------------------------------------------------------- + +This copy of Python includes a copy of Berkeley DB, which is licensed under the following terms: + +/*- + * $Id: LICENSE,v 12.9 2008/02/07 17:12:17 mark Exp $ + */ + +The following is the license that applies to this copy of the Berkeley DB +software. For a license to use the Berkeley DB software under conditions +other than those described here, or to purchase support for this software, +please contact Oracle at berkeleydb-info_us@oracle.com. + +=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= +/* + * Copyright (c) 1990,2008 Oracle. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Redistributions in any form must be accompanied by information on + * how to obtain complete source code for the DB software and any + * accompanying software that uses the DB software. The source code + * must either be included in the distribution or be available for no + * more than the cost of distribution plus a nominal fee, and must be + * freely redistributable under reasonable conditions. For an + * executable file, complete source code means the source code for all + * modules it contains. It does not include source code for modules or + * files that typically accompany the major components of the operating + * system on which the executable file runs. + * + * THIS SOFTWARE IS PROVIDED BY ORACLE ``AS IS'' AND ANY EXPRESS OR + * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR + * NON-INFRINGEMENT, ARE DISCLAIMED. IN NO EVENT SHALL ORACLE BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR + * BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN + * IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +/* + * Copyright (c) 1990, 1993, 1994, 1995 + * The Regents of the University of California. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the University nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + */ +/* + * Copyright (c) 1995, 1996 + * The President and Fellows of Harvard University. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the University nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY HARVARD AND ITS CONTRIBUTORS ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL HARVARD OR ITS CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + */ +=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= +/*** + * ASM: a very small and fast Java bytecode manipulation framework + * Copyright (c) 2000-2005 INRIA, France Telecom + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the copyright holders nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + * THE POSSIBILITY OF SUCH DAMAGE. + */ + +This copy of Python includes a copy of openssl, which is licensed under the following terms: + + + LICENSE ISSUES + ============== + + The OpenSSL toolkit stays under a dual license, i.e. both the conditions of + the OpenSSL License and the original SSLeay license apply to the toolkit. + See below for the actual license texts. Actually both licenses are BSD-style + Open Source licenses. In case of any license issues related to OpenSSL + please contact openssl-core@openssl.org. + + OpenSSL License + --------------- + +/* ==================================================================== + * Copyright (c) 1998-2008 The OpenSSL Project. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * 3. All advertising materials mentioning features or use of this + * software must display the following acknowledgment: + * "This product includes software developed by the OpenSSL Project + * for use in the OpenSSL Toolkit. (http://www.openssl.org/)" + * + * 4. The names "OpenSSL Toolkit" and "OpenSSL Project" must not be used to + * endorse or promote products derived from this software without + * prior written permission. For written permission, please contact + * openssl-core@openssl.org. + * + * 5. Products derived from this software may not be called "OpenSSL" + * nor may "OpenSSL" appear in their names without prior written + * permission of the OpenSSL Project. + * + * 6. Redistributions of any form whatsoever must retain the following + * acknowledgment: + * "This product includes software developed by the OpenSSL Project + * for use in the OpenSSL Toolkit (http://www.openssl.org/)" + * + * THIS SOFTWARE IS PROVIDED BY THE OpenSSL PROJECT ``AS IS'' AND ANY + * EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE OpenSSL PROJECT OR + * ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + * OF THE POSSIBILITY OF SUCH DAMAGE. + * ==================================================================== + * + * This product includes cryptographic software written by Eric Young + * (eay@cryptsoft.com). This product includes software written by Tim + * Hudson (tjh@cryptsoft.com). + * + */ + + Original SSLeay License + ----------------------- + +/* Copyright (C) 1995-1998 Eric Young (eay@cryptsoft.com) + * All rights reserved. + * + * This package is an SSL implementation written + * by Eric Young (eay@cryptsoft.com). + * The implementation was written so as to conform with Netscapes SSL. + * + * This library is free for commercial and non-commercial use as long as + * the following conditions are aheared to. The following conditions + * apply to all code found in this distribution, be it the RC4, RSA, + * lhash, DES, etc., code; not just the SSL code. The SSL documentation + * included with this distribution is covered by the same copyright terms + * except that the holder is Tim Hudson (tjh@cryptsoft.com). + * + * Copyright remains Eric Young's, and as such any Copyright notices in + * the code are not to be removed. + * If this package is used in a product, Eric Young should be given attribution + * as the author of the parts of the library used. + * This can be in the form of a textual message at program startup or + * in documentation (online or textual) provided with the package. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. All advertising materials mentioning features or use of this software + * must display the following acknowledgement: + * "This product includes cryptographic software written by + * Eric Young (eay@cryptsoft.com)" + * The word 'cryptographic' can be left out if the rouines from the library + * being used are not cryptographic related :-). + * 4. If you include any Windows specific code (or a derivative thereof) from + * the apps directory (application code) you must include an acknowledgement: + * "This product includes software written by Tim Hudson (tjh@cryptsoft.com)" + * + * THIS SOFTWARE IS PROVIDED BY ERIC YOUNG ``AS IS'' AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. + * + * The licence and distribution terms for any publically available version or + * derivative of this code cannot be changed. i.e. this code cannot simply be + * copied and put under another distribution licence + * [including the GNU Public Licence.] + */ + + +This copy of Python includes a copy of Tcl, which is licensed under the following terms: + +This software is copyrighted by the Regents of the University of +California, Sun Microsystems, Inc., Scriptics Corporation, ActiveState +Corporation and other parties. The following terms apply to all files +associated with the software unless explicitly disclaimed in +individual files. + +The authors hereby grant permission to use, copy, modify, distribute, +and license this software and its documentation for any purpose, provided +that existing copyright notices are retained in all copies and that this +notice is included verbatim in any distributions. No written agreement, +license, or royalty fee is required for any of the authorized uses. +Modifications to this software may be copyrighted by their authors +and need not follow the licensing terms described here, provided that +the new terms are clearly indicated on the first page of each file where +they apply. + +IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY +FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY +DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE +IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE +NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR +MODIFICATIONS. + +GOVERNMENT USE: If you are acquiring this software on behalf of the +U.S. government, the Government shall have only "Restricted Rights" +in the software and related documentation as defined in the Federal +Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you +are acquiring the software on behalf of the Department of Defense, the +software shall be classified as "Commercial Computer Software" and the +Government shall have only "Restricted Rights" as defined in Clause +252.227-7013 (c) (1) of DFARs. Notwithstanding the foregoing, the +authors grant the U.S. Government and others acting in its behalf +permission to use and distribute the software in accordance with the +terms specified in this license. + +This copy of Python includes a copy of Tk, which is licensed under the following terms: + +This software is copyrighted by the Regents of the University of +California, Sun Microsystems, Inc., and other parties. The following +terms apply to all files associated with the software unless explicitly +disclaimed in individual files. + +The authors hereby grant permission to use, copy, modify, distribute, +and license this software and its documentation for any purpose, provided +that existing copyright notices are retained in all copies and that this +notice is included verbatim in any distributions. No written agreement, +license, or royalty fee is required for any of the authorized uses. +Modifications to this software may be copyrighted by their authors +and need not follow the licensing terms described here, provided that +the new terms are clearly indicated on the first page of each file where +they apply. + +IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY +FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY +DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE +IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE +NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR +MODIFICATIONS. + +GOVERNMENT USE: If you are acquiring this software on behalf of the +U.S. government, the Government shall have only "Restricted Rights" +in the software and related documentation as defined in the Federal +Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you +are acquiring the software on behalf of the Department of Defense, the +software shall be classified as "Commercial Computer Software" and the +Government shall have only "Restricted Rights" as defined in Clause +252.227-7013 (c) (1) of DFARs. Notwithstanding the foregoing, the +authors grant the U.S. Government and others acting in its behalf +permission to use and distribute the software in accordance with the +terms specified in this license. + +This copy of Python includes a copy of Tix, which is licensed under the following terms: + +Copyright (c) 1993-1999 Ioi Kim Lam. +Copyright (c) 2000-2001 Tix Project Group. +Copyright (c) 2004 ActiveState + +This software is copyrighted by the above entities +and other parties. The following terms apply to all files associated +with the software unless explicitly disclaimed in individual files. + +The authors hereby grant permission to use, copy, modify, distribute, +and license this software and its documentation for any purpose, provided +that existing copyright notices are retained in all copies and that this +notice is included verbatim in any distributions. No written agreement, +license, or royalty fee is required for any of the authorized uses. +Modifications to this software may be copyrighted by their authors +and need not follow the licensing terms described here, provided that +the new terms are clearly indicated on the first page of each file where +they apply. + +IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY +FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY +DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE +IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE +NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR +MODIFICATIONS. + +GOVERNMENT USE: If you are acquiring this software on behalf of the +U.S. government, the Government shall have only "Restricted Rights" +in the software and related documentation as defined in the Federal +Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you +are acquiring the software on behalf of the Department of Defense, the +software shall be classified as "Commercial Computer Software" and the +Government shall have only "Restricted Rights" as defined in Clause +252.227-7013 (c) (1) of DFARs. Notwithstanding the foregoing, the +authors grant the U.S. Government and others acting in its behalf +permission to use and distribute the software in accordance with the +terms specified in this license. + +---------------------------------------------------------------------- + +Parts of this software are based on the Tcl/Tk software copyrighted by +the Regents of the University of California, Sun Microsystems, Inc., +and other parties. The original license terms of the Tcl/Tk software +distribution is included in the file docs/license.tcltk. + +Parts of this software are based on the HTML Library software +copyrighted by Sun Microsystems, Inc. The original license terms of +the HTML Library software distribution is included in the file +docs/license.html_lib. diff --git a/LICENSE-requests.txt b/LICENSE-requests.txt new file mode 100644 index 0000000..fb45b2c --- /dev/null +++ b/LICENSE-requests.txt @@ -0,0 +1,13 @@ +Copyright 2013 Kenneth Reitz + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md index fe0443e..d5565d8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # PhotoGrabber -A cross platform desktop application to backup images from Facebook. +A cross platform desktop application to download images from Facebook. ## License Copyright (C) 2013 Ourbunny @@ -19,9 +19,15 @@ along with this program. If not, see . ## Dependencies -* [Requests](http://python-requests.org) (Apache 2.0) -* [PySide](http://qt-project.org/wiki/Category:LanguageBindings::PySide) (LGPL v2.1) -* [Qt](http://qt-project.org) (LGPL v2.1) +* [Python](http://docs.python.org/2/license.html) - v2.7.3 (PSF) +* [Requests](http://python-requests.org) - v1.2.0, 20 Apr 13 (Apache 2.0) +* [PySide](http://qt-project.org/wiki/Category:LanguageBindings::PySide) - v1.1.2 (v1.1.1 on OSX) (LGPL v2.1) +* [Qt](http://qt-project.org) - v4.8.0 (LGPL v2.1) + +## Built Using + +* PyInstaller - v2.0 +* py2app - v0.7.3 ## Contributors The following individuals have provided code patches that have been included in From f487a54ae181fb9771e6bc8f65454e2ef3a25529 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Wed, 24 Apr 2013 21:28:53 -0500 Subject: [PATCH 36/38] limit status updates try to avoid weird progress dialog artifacts on OSX by just reporting total on complete download --- helpers.py | 4 ++-- pgui.py | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/helpers.py b/helpers.py index 2792d48..8ecf1e5 100755 --- a/helpers.py +++ b/helpers.py @@ -628,7 +628,7 @@ def run(self): else: album['folder_name'] = album['name'] - self.total = 0 + #self.total = 0 for album in data: self.total = self.total + len(album['photos']) @@ -646,7 +646,7 @@ def run(self): log.info('Albums: %s' % len(data)) log.info('Pics: %s' % self.total) - self.msg = '%d photos downloaded!' % self.total + self.msg = '%d photos downloaded!' % self.total def status(self): return self.msg diff --git a/pgui.py b/pgui.py index ebd7457..d4e9cb9 100644 --- a/pgui.py +++ b/pgui.py @@ -218,13 +218,14 @@ def beginDownload(self): while thread.isAlive(): QtGui.qApp.processEvents() - progress.setLabelText(thread.status()) + #progress.setLabelText(thread.status()) if progress.wasCanceled(): sys.exit() - progress.setValue(total) - progress.setLabelText(thread.status()) - QtGui.QMessageBox.information(self, "Done", "Download is complete.") + #progress.setValue(total) + #progress.setLabelText(thread.status()) + #QtGui.QMessageBox.information(self, "Done", "Download is complete.") + QtGui.QMessageBox.information(self, "Done", thread.status()) progress.close() return True From 94ddaca58f569526a1fc0cef855b40103cf78170 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Wed, 24 Apr 2013 21:29:20 -0500 Subject: [PATCH 37/38] limit logging of basedir only do it once after parsing arguments --- pg.py | 2 ++ res.py | 3 --- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pg.py b/pg.py index b0e2303..c669d5a 100755 --- a/pg.py +++ b/pg.py @@ -83,6 +83,8 @@ def main(): log.info('Arguments parsed, log configured.') + log.error('basedir: %s' % res.getpath() ) + # GUI if not args.cmd: log.info('Starting GUI.') diff --git a/res.py b/res.py index 0f29300..aeed664 100644 --- a/res.py +++ b/res.py @@ -22,8 +22,6 @@ import os import sys -import logging -log = logging.getLogger(__name__) def getpath(name): if getattr(sys, '_MEIPASS', None): @@ -32,5 +30,4 @@ def getpath(name): #basedir = os.path.dirname(__file__) basedir = os.getcwd() - log.error('basedir: %s' % basedir) return os.path.join(basedir, name) \ No newline at end of file From 8af1d403a6e1e78c21afd10bd6bc62bd3cd126a0 Mon Sep 17 00:00:00 2001 From: Tommy Murphy Date: Wed, 24 Apr 2013 21:34:56 -0500 Subject: [PATCH 38/38] limit basedir log only print basedir once from main pg execution instead of each getpath instance --- res.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/res.py b/res.py index aeed664..30a9356 100644 --- a/res.py +++ b/res.py @@ -23,11 +23,14 @@ import os import sys -def getpath(name): +def getpath(name=None): if getattr(sys, '_MEIPASS', None): basedir = sys._MEIPASS else: #basedir = os.path.dirname(__file__) basedir = os.getcwd() + + if name is None: + return basedir return os.path.join(basedir, name) \ No newline at end of file