diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..331b8f3b --- /dev/null +++ b/.coveragerc @@ -0,0 +1,3 @@ +[run] +source = instagram +branch = False diff --git a/.gitignore b/.gitignore index 90f7f583..bf7cb040 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,8 @@ .DS_Store *.swp test_settings.py - +dist +build +*.egg-info +.coverage +coverage.xml diff --git a/.travis.yml b/.travis.yml index 5cadb27c..4fedae27 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,20 @@ language: python python: - - "2.6" - "2.7" - - "3.3" - "3.4" + - "3.5" + - "3.6" + - "3.6-dev" + - "3.7-dev" + - "nightly" install: - "pip install ." -script: "python tests.py" - + - "pip install -r build_requirements.txt" +script: + - "coverage run tests.py" + - "coverage xml -o coverage.xml" +after_success: + - "coveralls" + - "if [[ $TRAVIS_PYTHON_VERSION != 2.6 ]]; then + python-codacy-coverage -r coverage.xml; + fi" diff --git a/README.md b/README.md index dd916e09..f5a7f339 100644 --- a/README.md +++ b/README.md @@ -4,41 +4,51 @@ - - - -[![Build Status](https://api.travis-ci.org/Instagram/python-instagram.svg)](https://travis-ci.org/Instagram/python-instagram) +[![Build Status](https://api.travis-ci.org/wkoot/python-instagram.svg)](https://travis-ci.org/wkoot/python-instagram) +[![Coveralls Coverage](https://coveralls.io/repos/github/wkoot/python-instagram/badge.svg)](https://coveralls.io/github/wkoot/python-instagram) +[![Codacy Badge](https://api.codacy.com/project/badge/Grade/6dd0431b8752421d95521ea59fc6ecc1)](https://www.codacy.com/app/wkoot/python-instagram) +[![Code Climate](https://img.shields.io/codeclimate/github/wkoot/python-instagram.svg)](https://codeclimate.com/github/wkoot/python-instagram) + python-instagram -====== +================ A Python 2/3 client for the Instagram REST and Search APIs + Installation ------ +------------ ``` -pip install python-instagram +pip install instagram ``` -Requires ------ + + +Requirements +------------ * httplib2 * simplejson * six + * pytz Instagram REST and Search APIs ------------------------------ -Our [developer site](http://instagram.com/developer) documents all the Instagram REST and Search APIs. +The [Instagram developer page](http://instagram.com/developer) supposedly documents all the Instagram REST and Search APIs. -Blog ----------------------------- -The [Developer Blog] features news and important announcements about the Instagram Platform. You will also find tutorials and best practices to help you build great platform integrations. Make sure to subscribe to the RSS feed not to miss out on new posts: [http://developers.instagram.com](http://developers.instagram.com). +Instagram for developers blog +----------------------------- +The [Instagram for developers blog](http://developers.instagram.com/) features news and important announcements about the Instagram Platform. +You will also find tutorials and best practices to help you build great platform integrations. -Community ----------------------- -The [Stack Overflow community](http://stackoverflow.com/questions/tagged/instagram/) is a great place to ask API related questions or if you need help with your code. Make sure to tag your questions with the Instagram tag to get fast answers from other fellow developers and members of the Instagram team. +Stack Overflow community +------------------------ +The Stack Overflow [instagram tag](http://stackoverflow.com/questions/tagged/instagram/) is a great place to ask API related questions or if you need help with your code. +Make sure to tag your questions with the Instagram tag to get fast answers from other fellow developers and members of the Instagram team. Authentication ------ +-------------- Instagram API uses the OAuth2 protocol for authentication, but not all functionality requires authentication. See the docs for more information: http://instagram.com/developer/authentication/ @@ -66,7 +76,7 @@ recent_media, next_ = api.user_recent_media(user_id="userid", count=10) for media in recent_media: print media.caption.text ``` - + ### Making unauthenticated requests For methods that don't require authentication, you can just pass your client ID and optionally client secret into the InstagramAPI @@ -79,8 +89,9 @@ for media in popular_media: print media.images['standard_resolution'].url ``` -Real-time Subscriptions: ------ + +Real-time Subscriptions +----------------------- See the docs for more on real-time subscriptions: http://instagr.am/developer/realtime/ @@ -107,7 +118,7 @@ Along with that, you would typically register subscription "reactors" for proces reactor = subscriptions.SubscriptionsReactor() reactor.register_callback(subscriptions.SubscriptionType.USER, process_user_update) ``` - + See the provided sample app for an example of making a subscription, reacting to it, an processing the updates. You can also use the API to list and delete subscriptions: @@ -116,10 +127,10 @@ You can also use the API to list and delete subscriptions: api.list_subscriptions() api.delete_subscriptions(id=342342) ``` - -Data Retrieval: ------ + +Data Retrieval +-------------- See the endpoints docs for more on these methods: http://instagr.am/developer/endpoints/ @@ -142,7 +153,7 @@ while next_: ``` Users: http://instagr.am/developer/endpoints/users/ - + ``` python api.user(user_id) api.user_media_feed()* @@ -150,7 +161,7 @@ api.user_liked_media()* api.user_recent_media(user_id, count, max_id)* api.user_search(q, count, lat, lng, min_timestamp, max_timestamp) ``` - + Relationships: http://instagr.am/developer/endpoints/relationships/ ``` python @@ -173,7 +184,7 @@ api.media(media_id) api.media_popular(count, max_id) api.media_search(q, count, lat, lng, min_timestamp, max_timestamp) ``` - + Comments: http://instagr.am/developer/endpoints/comments/ ``` python @@ -181,7 +192,7 @@ api.media_comments(media_id) api.create_media_comment(media_id, text) api.delete_comment(media_id, comment_id) ``` - + Likes: http://instagr.am/developer/endpoints/likes/ ``` python @@ -189,7 +200,7 @@ api.media_likes(media_id) api.like_media(media_id) api.unlike_media(media_id) ``` - + Tags: http://instagr.am/developer/endpoints/tags/ ``` python @@ -197,7 +208,7 @@ api.tag(tag_name) api.tag_recent_media(count, max_tag_id, tag_name)* api.tag_search(q, count)* ``` - + Locations: http://instagr.am/developer/endpoints/locations/ ``` python @@ -205,15 +216,17 @@ api.location(location_id) api.location_recent_media(count, max_id, location_id)* api.location_search(q, count, lat, lng, foursquare_id, foursquare_v2_id) ``` - + Geographies: http://instagr.am/developer/endpoints/geographies/ ``` python api.geography_recent_media(count, max_id, geography_id)* ``` + Error handling ------- +-------------- + Importing the bind module allows handling of specific error status codes. An example is provided below: ``` python from instagram.bind import InstagramAPIError @@ -225,26 +238,35 @@ except InstagramAPIError as e: print "\nUser is set to private." ``` + +Setting Timeouts +---------------- + +By default there is no timeout for requests to the Instagram API. You can specify a timeout in one of two ways: +``` python +from instagram.client import InstagramAPI + +# set a 30-second timeout for this particular InstagramAPI instance +api = InstagramAPI(access_token=access_token, client_secret=client_secret, timeout=30) +``` +or +``` python +import socket + +# Set the global default timeout, which applies to all sockets in your +# program where a timeout is not otherwise specified. +socket.setdefaulttimeout(30) +``` + + Trouble Shooting ------- +---------------- If you get an error of a module not being defined during the Instagram import call, this might update a necessary package. ``` sudo pip install --upgrade six ``` -Sample app ------- -This repository includes a one-file sample app that uses the bottle framework and demonstrates -authentication, subscriptions, and update processing. To try it out: - - * Download bottle if you don't already have it: pip install bottle - * Download bottle-session if you don't already have it: pip install bottle-session - * Download and run a redis instance on port 6379 if you don't already have it. Check http://redis.io for instructions. - * Set your redirect URI to 'http://localhost:8515/oauth_callback' in your dev profile - * Open up sample\_app.py, update it with your client\_id and secret, and set redirect URI to 'http://localhost:8515/oauth_callback' - * Run the file; it will host a local server on port 8515. - * Try visiting http://localhost:8515 in your browser Contributing ------------ @@ -289,8 +311,9 @@ Submitting a Pull Request 8. Submit a pull request. 9. If you haven't already, complete the Contributor License Agreement ("CLA"). + Contributor License Agreement ("CLA") -_____________________________________ +------------------------------------- In order to accept your pull request, we need you to submit a CLA. You only need to do this once to work on any of Instagram's or Facebook's open source projects. diff --git a/build_requirements.txt b/build_requirements.txt new file mode 100644 index 00000000..ff4cee22 --- /dev/null +++ b/build_requirements.txt @@ -0,0 +1,4 @@ +twine==1.8.1 +coverage==4.4.1 +coveralls==1.2.0 +codacy-coverage==1.3.3 diff --git a/dist/python-instagram-1.0.0.tar.gz b/dist/python-instagram-1.0.0.tar.gz deleted file mode 100644 index 10c8c6b4..00000000 Binary files a/dist/python-instagram-1.0.0.tar.gz and /dev/null differ diff --git a/dist/python-instagram-1.0.1.tar.gz b/dist/python-instagram-1.0.1.tar.gz deleted file mode 100644 index 8d63ba34..00000000 Binary files a/dist/python-instagram-1.0.1.tar.gz and /dev/null differ diff --git a/dist/python-instagram-1.1.0.tar.gz b/dist/python-instagram-1.1.0.tar.gz deleted file mode 100644 index fc1e38b1..00000000 Binary files a/dist/python-instagram-1.1.0.tar.gz and /dev/null differ diff --git a/dist/python-instagram-1.1.1.tar.gz b/dist/python-instagram-1.1.1.tar.gz deleted file mode 100644 index 18b4be3e..00000000 Binary files a/dist/python-instagram-1.1.1.tar.gz and /dev/null differ diff --git a/dist/python-instagram-1.1.2.tar.gz b/dist/python-instagram-1.1.2.tar.gz deleted file mode 100644 index 49548290..00000000 Binary files a/dist/python-instagram-1.1.2.tar.gz and /dev/null differ diff --git a/fixtures/media_search.json b/fixtures/media_search.json index b03cf157..38c1f171 100644 --- a/fixtures/media_search.json +++ b/fixtures/media_search.json @@ -594,6 +594,114 @@ "profile_picture": "http://distillery.s3.amazonaws.com/profiles/profile_113603_75sq_1287035206.jpg", "id": "113603" } + }, + { + "type": "image", + "tags": [], + "location": { + "latitude": 37.775180799999987, + "id": "68841", + "longitude": -122.2270716, + "name": "El Novillo Taco Truck" + }, + "comments": { + "count": 1 + }, + "caption": { + "created_time": "1287585453", + "text": "Image with broken comment data ", + "from": { + "username": "darodriguez", + "first_name": "David", + "last_name": "Rodriguez", + "type": "user", + "id": "113603" + }, + "id": "495311" + }, + "link": "http://localhost:8000/p/C5Wr/", + "likes": { + "count": 0 + }, + "created_time": "1287585407", + "images": { + "low_resolution": { + "url": "http://distillery-dev.s3.amazonaws.com/media/2010/10/20/1fde15405b6349ef949c9a8f5498868e_6.jpg", + "width": 480, + "height": 480 + }, + "thumbnail": { + "url": "http://distillery-dev.s3.amazonaws.com/media/2010/10/20/1fde15405b6349ef949c9a8f5498868e_5.jpg", + "width": 150, + "height": 150 + }, + "standard_resolution": { + "url": "http://distillery-dev.s3.amazonaws.com/media/2010/10/20/1fde15405b6349ef949c9a8f5498868e_7.jpg", + "width": 612, + "height": 612 + } + }, + "user_has_liked": false, + "id": "759211", + "user": { + "username": "darodriguez", + "profile_picture": "http://distillery.s3.amazonaws.com/profiles/profile_113603_75sq_1287035206.jpg", + "id": "113603" + } + }, + { + "type": "video", + "tags": [], + "location": { + "latitude": 37.775180799999987, + "id": "68841", + "longitude": -122.2270716, + "name": "El Novillo Taco Truck" + }, + "comments": { + "count": 0 + }, + "caption": { + "created_time": "1287585453", + "text": "Type video without having videos in data", + "from": { + "username": "darodriguez", + "first_name": "David", + "last_name": "Rodriguez", + "type": "user", + "id": "113603" + }, + "id": "495311" + }, + "link": "http://localhost:8000/p/C5Wr/", + "likes": { + "count": 0 + }, + "created_time": "1287585407", + "images": { + "low_resolution": { + "url": "http://distillery-dev.s3.amazonaws.com/media/2010/10/20/1fde15405b6349ef949c9a8f5498868e_6.jpg", + "width": 480, + "height": 480 + }, + "thumbnail": { + "url": "http://distillery-dev.s3.amazonaws.com/media/2010/10/20/1fde15405b6349ef949c9a8f5498868e_5.jpg", + "width": 150, + "height": 150 + }, + "standard_resolution": { + "url": "http://distillery-dev.s3.amazonaws.com/media/2010/10/20/1fde15405b6349ef949c9a8f5498868e_7.jpg", + "width": 612, + "height": 612 + } + }, + "user_has_liked": false, + "id": "759211", + "user": { + "username": "darodriguez", + "profile_picture": "http://distillery.s3.amazonaws.com/profiles/profile_113603_75sq_1287035206.jpg", + "id": "113603" + } } ] } \ No newline at end of file diff --git a/fixtures/tag_recent_media.json b/fixtures/tag_recent_media.json index 56c5a3a5..a574406a 100644 --- a/fixtures/tag_recent_media.json +++ b/fixtures/tag_recent_media.json @@ -189,6 +189,135 @@ "id": "5273", "last_name": "" } + }, + { + "id": "1614083460929239315_25025320", + "user": { + "id": "25025320", + "full_name": "Instagram", + "profile_picture": "https://scontent.cdninstagram.com/t51.2885-19/s150x150/14719833_310540259320655_1605122788543168512_a.jpg", + "username": "instagram" + }, + "images": { + "thumbnail": { + "width": 150, + "height": 150, + "url": "https://scontent.cdninstagram.com/t51.2885-15/s150x150/e15/21980918_1513931928693772_9033863567060762624_n.jpg" + }, + "low_resolution": { + "width": 320, + "height": 320, + "url": "https://scontent.cdninstagram.com/t51.2885-15/s320x320/e15/21980918_1513931928693772_9033863567060762624_n.jpg" + }, + "standard_resolution": { + "width": 640, + "height": 640, + "url": "https://scontent.cdninstagram.com/t51.2885-15/s640x640/e15/21980918_1513931928693772_9033863567060762624_n.jpg" + } + }, + "created_time": "1506633764", + "caption": { + "id": "17900429008029332", + "text": "Hello, world! It\u2019s time for another dose of #WeeklyFluff. Meet Kuzuki (@sou_ham), a happy hamster from Japan who has been described as a \u201csmall vacuum cleaner.\u201d Follow @sou_ham to make sure you never miss one of Kuzuki\u2019s \u2014 or Uzuki's! (the other hamster in the family) \u2014 adventures. \ud83d\udc39\ud83d\udc39", + "created_time": "1506633764", + "from": { + "id": "25025320", + "full_name": "Instagram", + "profile_picture": "https://scontent.cdninstagram.com/t51.2885-19/s150x150/14719833_310540259320655_1605122788543168512_a.jpg", + "username": "instagram" + } + }, + "user_has_liked": false, + "likes": { + "count": 821506 + }, + "tags": ["weeklyfluff"], + "filter": "Normal", + "comments": { + "count": 15562 + }, + "type": "video", + "link": "http://localhost:8000/p/BZmYFsehTkT/", + "location": null, + "attribution": null, + "users_in_photo": [], + "videos": { + "standard_resolution": { + "width": 640, + "height": 640, + "url": "https://scontent.cdninstagram.com/t50.2886-16/22137988_1621796317884357_179940820256817152_n.mp4", + "id": "0" + }, + "low_bandwidth": { + "width": 480, + "height": 480, + "url": "https://scontent.cdninstagram.com/t50.2886-16/22112778_357761027970441_6775862658118713344_n.mp4", + "id": "0" + }, + "low_resolution": { + "width": 480, + "height": 480, + "url": "https://scontent.cdninstagram.com/t50.2886-16/22112778_357761027970441_6775862658118713344_n.mp4", + "id": "0" + } + } + }, + { + "id": "1613267810091631142_25025320", + "user": { + "id": "25025320", + "full_name": "Instagram", + "profile_picture": "https://scontent.cdninstagram.com/t51.2885-19/s150x150/14719833_310540259320655_1605122788543168512_a.jpg", + "username": "instagram" + }, + "images": { + "thumbnail": { + "width": 150, + "height": 150, + "url": "https://scontent.cdninstagram.com/t51.2885-15/s150x150/e35/21985313_400032113744601_677307280198205440_n.jpg" + }, + "low_resolution": { + "width": 320, + "height": 320, + "url": "https://scontent.cdninstagram.com/t51.2885-15/s320x320/e35/21985313_400032113744601_677307280198205440_n.jpg" + }, + "standard_resolution": { + "width": 640, + "height": 640, + "url": "https://scontent.cdninstagram.com/t51.2885-15/s640x640/sh0.08/e35/21985313_400032113744601_677307280198205440_n.jpg" + } + }, + "created_time": "1506536531", + "caption": { + "id": "17874787510182141", + "text": "Photo by @naomijon\nBlue hair. Blue eyes. \u201cThese two things are kind of my trademark,\u201d says German beauty blogger Naomi Jon (@naomijon). \u201cIf I had to choose between black and any color, I would always go for the color, because it\u2019s way more exciting, and it always attracts attention.\u201d\nWatch Naomi apply a colorful eye shadow combination on our Instagram story right now. \ud83d\udc99", + "created_time": "1506536531", + "from": { + "id": "25025320", + "full_name": "Instagram", + "profile_picture": "https://scontent.cdninstagram.com/t51.2885-19/s150x150/14719833_310540259320655_1605122788543168512_a.jpg", + "username": "instagram" + } + }, + "user_has_liked": false, + "likes": { + "count": 590414 + }, + "tags": [], + "filter": "Normal", + "comments": { + "count": 6413 + }, + "type": "image", + "link": "https://www.instagram.com/p/BZjeoacFYIm/", + "location": { + "latitude": 52.3667, + "longitude": 9.71667, + "name": "Hanover, Germany", + "id": 213305671 + }, + "attribution": null, + "users_in_photo": [] } ] } \ No newline at end of file diff --git a/instagram/__init__.py b/instagram/__init__.py index 837cc3ef..56156d3b 100644 --- a/instagram/__init__.py +++ b/instagram/__init__.py @@ -1,2 +1,5 @@ from .bind import InstagramAPIError, InstagramClientError from .client import InstagramAPI +from . import models + +__all__ = ['InstagramAPI', 'InstagramAPIError', 'InstagramClientError', 'models'] diff --git a/instagram/bind.py b/instagram/bind.py index cce758bf..2288e9e1 100644 --- a/instagram/bind.py +++ b/instagram/bind.py @@ -1,4 +1,3 @@ -import urllib from .oauth2 import OAuth2Request import re from .json_import import simplejson @@ -6,14 +5,12 @@ from hashlib import sha256 import six from six.moves.urllib.parse import quote -import sys re_path_template = re.compile('{\w+}') def encode_string(value): - return value.encode('utf-8') \ - if isinstance(value, six.text_type) else str(value) + return value.encode('utf-8') if isinstance(value, six.text_type) else str(value) class InstagramClientError(Exception): @@ -29,7 +26,6 @@ def __str__(self): class InstagramAPIError(Exception): - def __init__(self, status_code, error_type, error_message, *args, **kwargs): self.status_code = status_code self.error_type = error_type @@ -40,7 +36,6 @@ def __str__(self): def bind_method(**config): - class InstagramAPIMethod(object): path = config['path'] @@ -62,6 +57,7 @@ def __init__(self, api, *args, **kwargs): self.pagination_format = 'next_url' else: self.pagination_format = kwargs.pop('pagination_format', 'next_url') + self.return_json = kwargs.pop("return_json", False) self.max_pages = kwargs.pop("max_pages", 3) self.with_next_url = kwargs.pop("with_next_url", None) @@ -85,9 +81,10 @@ def _build_parameters(self, args, kwargs): continue if key in self.parameters: raise InstagramClientError("Parameter %s already supplied" % key) + self.parameters[key] = encode_string(value) - if 'user_id' in self.accepts_parameters and not 'user_id' in self.parameters \ - and not self.requires_target_user: + + if 'user_id' in self.accepts_parameters and 'user_id' not in self.parameters and not self.requires_target_user: self.parameters['user_id'] = 'self' def _build_path(self): @@ -103,7 +100,7 @@ def _build_path(self): self.path = self.path.replace(variable, value) if self.api.format and not self.exclude_format: - self.path = self.path + '.%s' % self.api.format + self.path += '.%s' % self.api.format def _build_pagination_info(self, content_obj): """Extract pagination information in the desired format.""" @@ -112,11 +109,12 @@ def _build_pagination_info(self, content_obj): return pagination.get('next_url') if self.pagination_format == 'dict': return pagination + raise Exception('Invalid value for pagination_format: %s' % self.pagination_format) - + def _do_api_request(self, url, method="GET", body=None, headers=None): headers = headers or {} - if self.signature and self.api.client_ips != None and self.api.client_secret != None: + if self.signature and self.api.client_ips is not None and self.api.client_secret is not None: secret = self.api.client_secret ips = self.api.client_ips signature = hmac.new(secret, ips, sha256).hexdigest() @@ -125,20 +123,26 @@ def _do_api_request(self, url, method="GET", body=None, headers=None): response, content = OAuth2Request(self.api).make_request(url, method=method, body=body, headers=headers) if response['status'] == '503' or response['status'] == '429': raise InstagramAPIError(response['status'], "Rate limited", "Your client is making too many request per second") + if hasattr(content, "decode"): + content = content.decode('utf-8') + try: content_obj = simplejson.loads(content) except ValueError: raise InstagramClientError('Unable to parse response, not valid JSON.', status_code=response['status']) + # Handle OAuthRateLimitExceeded from Instagram's Nginx which uses different format to documented api responses if 'meta' not in content_obj: if content_obj.get('code') == 420 or content_obj.get('code') == 429: error_message = content_obj.get('error_message') or "Your client is making too many request per second" raise InstagramAPIError(content_obj.get('code'), "Rate limited", error_message) + raise InstagramAPIError(content_obj.get('code'), content_obj.get('error_type'), content_obj.get('error_message')) + api_responses = [] status_code = content_obj['meta']['code'] - self.api.x_ratelimit_remaining = response.get("x-ratelimit-remaining",None) - self.api.x_ratelimit = response.get("x-ratelimit-limit",None) + self.api.x_ratelimit_remaining = response.get("x-ratelimit-remaining", None) + self.api.x_ratelimit = response.get("x-ratelimit-limit", None) if status_code == 200: if not self.objectify_response: return content_obj, None @@ -150,14 +154,17 @@ def _do_api_request(self, url, method="GET", body=None, headers=None): else: obj = self.root_class.object_from_dictionary(entry) api_responses.append(obj) + elif self.response_type == 'entry': data = content_obj['data'] if self.return_json: api_responses = data else: api_responses = self.root_class.object_from_dictionary(data) + elif self.response_type == 'empty': pass + return api_responses, self._build_pagination_info(content_obj) else: raise InstagramAPIError(status_code, content_obj['meta']['error_type'], content_obj['meta']['error_message']) @@ -165,30 +172,33 @@ def _do_api_request(self, url, method="GET", body=None, headers=None): def _paginator_with_url(self, url, method="GET", body=None, headers=None): headers = headers or {} pages_read = 0 + while url and (self.max_pages is None or pages_read < self.max_pages): api_responses, url = self._do_api_request(url, method, body, headers) pages_read += 1 yield api_responses, url + return def _get_with_next_url(self, url, method="GET", body=None, headers=None): headers = headers or {} - content, next = self._do_api_request(url, method, body, headers) - return content, next + content, next_url = self._do_api_request(url, method, body, headers) + + return content, next_url def execute(self): - url, method, body, headers = OAuth2Request(self.api).prepare_request(self.method, - self.path, - self.parameters, - include_secret=self.include_secret) + url, method, body, headers = OAuth2Request(self.api).prepare_request(self.method, self.path, self.parameters, include_secret=self.include_secret) + if self.with_next_url: return self._get_with_next_url(self.with_next_url, method, body, headers) + if self.as_generator: return self._paginator_with_url(url, method, body, headers) else: - content, next = self._do_api_request(url, method, body, headers) + content, next_url = self._do_api_request(url, method, body, headers) + if self.paginates: - return content, next + return content, next_url else: return content diff --git a/instagram/client.py b/instagram/client.py index 624bc0ec..df283eea 100644 --- a/instagram/client.py +++ b/instagram/client.py @@ -8,8 +8,41 @@ SUPPORTED_FORMATS = ['json'] -class InstagramAPI(oauth2.OAuth2API): +def _make_relationship_shortcut(action): + def _inner(api, *args, **kwargs): + return api.change_user_relationship(user_id=kwargs.get("user_id"), action=action) + + return _inner + + +def _make_subscription_action(method, include=None, exclude=None): + accepts_parameters = ["object", + "aspect", + "object_id", # Optional if subscribing to all users + "callback_url", + "lat", # Geography + "lng", # Geography + "radius", # Geography + "verify_token"] + + if include: + accepts_parameters.extend(include) + if exclude: + accepts_parameters = [x for x in accepts_parameters if x not in exclude] + + signature = False if method == 'GET' else True + return bind_method( + path="/subscriptions", + method=method, + accepts_parameters=accepts_parameters, + include_secret=True, + objectify_response=False, + signature=signature, + ) + + +class InstagramAPI(oauth2.OAuth2API): host = "api.instagram.com" base_path = "/v1" access_token_field = "access_token" @@ -17,7 +50,7 @@ class InstagramAPI(oauth2.OAuth2API): access_token_url = "https://api.instagram.com/oauth/access_token" protocol = "https" api_name = "Instagram" - x_ratelimit_remaining = None + x_ratelimit_remaining = None x_ratelimit = None def __init__(self, *args, **kwargs): @@ -29,180 +62,173 @@ def __init__(self, *args, **kwargs): super(InstagramAPI, self).__init__(**kwargs) media_popular = bind_method( - path="/media/popular", - accepts_parameters=MEDIA_ACCEPT_PARAMETERS, - root_class=Media) + path="/media/popular", + accepts_parameters=MEDIA_ACCEPT_PARAMETERS, + root_class=Media) media_search = bind_method( - path="/media/search", - accepts_parameters=SEARCH_ACCEPT_PARAMETERS + ['lat', 'lng', 'min_timestamp', 'max_timestamp', 'distance'], - root_class=Media) + path="/media/search", + accepts_parameters=SEARCH_ACCEPT_PARAMETERS + ['lat', 'lng', 'min_timestamp', 'max_timestamp', 'distance'], + root_class=Media) media_shortcode = bind_method( - path="/media/shortcode/{shortcode}", - accepts_parameters=['shortcode'], - response_type="entry", - root_class=MediaShortcode, - exclude_format=True) - + path="/media/shortcode/{shortcode}", + accepts_parameters=['shortcode'], + response_type="entry", + root_class=MediaShortcode, + exclude_format=True) media_likes = bind_method( - path="/media/{media_id}/likes", - accepts_parameters=['media_id'], - root_class=User) + path="/media/{media_id}/likes", + accepts_parameters=['media_id'], + root_class=User) like_media = bind_method( - path="/media/{media_id}/likes", - method="POST", - signature=True, - accepts_parameters=['media_id'], - response_type="empty") + path="/media/{media_id}/likes", + method="POST", + signature=True, + accepts_parameters=['media_id'], + response_type="empty") unlike_media = bind_method( - path="/media/{media_id}/likes", - method="DELETE", - signature=True, - accepts_parameters=['media_id'], - response_type="empty") + path="/media/{media_id}/likes", + method="DELETE", + signature=True, + accepts_parameters=['media_id'], + response_type="empty") create_media_comment = bind_method( - path="/media/{media_id}/comments", - method="POST", - signature=True, - accepts_parameters=['media_id', 'text'], - response_type="empty", - root_class=Comment) + path="/media/{media_id}/comments", + method="POST", + signature=True, + accepts_parameters=['media_id', 'text'], + response_type="empty", + root_class=Comment) delete_comment = bind_method( - path="/media/{media_id}/comments/{comment_id}", - method="DELETE", - signature=True, - accepts_parameters=['media_id', 'comment_id'], - response_type="empty") + path="/media/{media_id}/comments/{comment_id}", + method="DELETE", + signature=True, + accepts_parameters=['media_id', 'comment_id'], + response_type="empty") media_comments = bind_method( - path="/media/{media_id}/comments", - method="GET", - accepts_parameters=['media_id'], - response_type="list", - root_class=Comment) + path="/media/{media_id}/comments", + method="GET", + accepts_parameters=['media_id'], + response_type="list", + root_class=Comment) media = bind_method( - path="/media/{media_id}", - accepts_parameters=['media_id'], - response_type="entry", - root_class=Media) + path="/media/{media_id}", + accepts_parameters=['media_id'], + response_type="entry", + root_class=Media) user_media_feed = bind_method( - path="/users/self/feed", - accepts_parameters=MEDIA_ACCEPT_PARAMETERS, - root_class=Media, - paginates=True) + path="/users/self/media/recent", + accepts_parameters=MEDIA_ACCEPT_PARAMETERS, + root_class=Media, + paginates=True) user_liked_media = bind_method( - path="/users/self/media/liked", - accepts_parameters=MEDIA_ACCEPT_PARAMETERS, - root_class=Media, - paginates=True) + path="/users/self/media/liked", + accepts_parameters=MEDIA_ACCEPT_PARAMETERS, + root_class=Media, + paginates=True) user_recent_media = bind_method( - path="/users/{user_id}/media/recent", - accepts_parameters=MEDIA_ACCEPT_PARAMETERS + ['user_id', 'min_id', 'max_timestamp', 'min_timestamp'], - root_class=Media, - paginates=True) + path="/users/{user_id}/media/recent", + accepts_parameters=MEDIA_ACCEPT_PARAMETERS + ['user_id', 'min_id', 'max_timestamp', 'min_timestamp'], + root_class=Media, + paginates=True) user_search = bind_method( - path="/users/search", - accepts_parameters=SEARCH_ACCEPT_PARAMETERS, - root_class=User) + path="/users/search", + accepts_parameters=SEARCH_ACCEPT_PARAMETERS, + root_class=User) user_follows = bind_method( - path="/users/{user_id}/follows", - accepts_parameters=["user_id"], - paginates=True, - root_class=User) + path="/users/{user_id}/follows", + accepts_parameters=["user_id"], + paginates=True, + root_class=User) user_followed_by = bind_method( - path="/users/{user_id}/followed-by", - accepts_parameters=["user_id"], - paginates=True, - root_class=User) + path="/users/{user_id}/followed-by", + accepts_parameters=["user_id"], + paginates=True, + root_class=User) user = bind_method( - path="/users/{user_id}", - accepts_parameters=["user_id"], - root_class=User, - response_type="entry") + path="/users/{user_id}", + accepts_parameters=["user_id"], + root_class=User, + response_type="entry") location_recent_media = bind_method( - path="/locations/{location_id}/media/recent", - accepts_parameters=MEDIA_ACCEPT_PARAMETERS + ['location_id'], - root_class=Media, - paginates=True) + path="/locations/{location_id}/media/recent", + accepts_parameters=MEDIA_ACCEPT_PARAMETERS + ['location_id'], + root_class=Media, + paginates=True) location_search = bind_method( - path="/locations/search", - accepts_parameters=SEARCH_ACCEPT_PARAMETERS + ['lat', 'lng', 'foursquare_id', 'foursquare_v2_id'], - root_class=Location) + path="/locations/search", + accepts_parameters=SEARCH_ACCEPT_PARAMETERS + ['lat', 'lng', 'foursquare_id', 'foursquare_v2_id'], + root_class=Location) location = bind_method( - path="/locations/{location_id}", - accepts_parameters=["location_id"], - root_class=Location, - response_type="entry") + path="/locations/{location_id}", + accepts_parameters=["location_id"], + root_class=Location, + response_type="entry") geography_recent_media = bind_method( - path="/geographies/{geography_id}/media/recent", - accepts_parameters=MEDIA_ACCEPT_PARAMETERS + ["geography_id"], - root_class=Media, - paginates=True) + path="/geographies/{geography_id}/media/recent", + accepts_parameters=MEDIA_ACCEPT_PARAMETERS + ["geography_id"], + root_class=Media, + paginates=True) tag_recent_media = bind_method( - path="/tags/{tag_name}/media/recent", - accepts_parameters=['count', 'max_tag_id', 'tag_name'], - root_class=Media, - paginates=True) + path="/tags/{tag_name}/media/recent", + accepts_parameters=['count', 'max_tag_id', 'tag_name'], + root_class=Media, + paginates=True) tag_search = bind_method( - path="/tags/search", - accepts_parameters=SEARCH_ACCEPT_PARAMETERS, - root_class=Tag, - paginates=True) + path="/tags/search", + accepts_parameters=SEARCH_ACCEPT_PARAMETERS, + root_class=Tag, + paginates=True) tag = bind_method( - path="/tags/{tag_name}", - accepts_parameters=["tag_name"], - root_class=Tag, - response_type="entry") + path="/tags/{tag_name}", + accepts_parameters=["tag_name"], + root_class=Tag, + response_type="entry") user_incoming_requests = bind_method( - path="/users/self/requested-by", - root_class=User) + path="/users/self/requested-by", + root_class=User) change_user_relationship = bind_method( - method="POST", - path="/users/{user_id}/relationship", - signature=True, - root_class=Relationship, - accepts_parameters=["user_id", "action"], - paginates=True, - requires_target_user=True, - response_type="entry") + method="POST", + path="/users/{user_id}/relationship", + signature=True, + root_class=Relationship, + accepts_parameters=["user_id", "action"], + paginates=True, + requires_target_user=True, + response_type="entry") user_relationship = bind_method( - method="GET", - path="/users/{user_id}/relationship", - root_class=Relationship, - accepts_parameters=["user_id"], - paginates=False, - requires_target_user=True, - response_type="entry") - - def _make_relationship_shortcut(action): - def _inner(self, *args, **kwargs): - return self.change_user_relationship(user_id=kwargs.get("user_id"), - action=action) - return _inner + method="GET", + path="/users/{user_id}/relationship", + root_class=Relationship, + accepts_parameters=["user_id"], + paginates=False, + requires_target_user=True, + response_type="entry") follow_user = _make_relationship_shortcut('follow') unfollow_user = _make_relationship_shortcut('unfollow') @@ -211,30 +237,6 @@ def _inner(self, *args, **kwargs): approve_user_request = _make_relationship_shortcut('approve') ignore_user_request = _make_relationship_shortcut('ignore') - def _make_subscription_action(method, include=None, exclude=None): - accepts_parameters = ["object", - "aspect", - "object_id", # Optional if subscribing to all users - "callback_url", - "lat", # Geography - "lng", # Geography - "radius", # Geography - "verify_token"] - - if include: - accepts_parameters.extend(include) - if exclude: - accepts_parameters = [x for x in accepts_parameters if x not in exclude] - signature = False if method == 'GET' else True - return bind_method( - path="/subscriptions", - method=method, - accepts_parameters=accepts_parameters, - include_secret=True, - objectify_response=False, - signature=signature, - ) - create_subscription = _make_subscription_action('POST') list_subscriptions = _make_subscription_action('GET') - delete_subscriptions = _make_subscription_action('DELETE', exclude=['object_id'], include=['id']) + delete_subscriptions = _make_subscription_action('DELETE', include=['id'], exclude=['object_id']) diff --git a/instagram/helper.py b/instagram/helper.py index 62bcf5b5..ef96d53a 100644 --- a/instagram/helper.py +++ b/instagram/helper.py @@ -1,9 +1,11 @@ import calendar from datetime import datetime +import pytz def timestamp_to_datetime(ts): - return datetime.utcfromtimestamp(float(ts)) + naive = datetime.utcfromtimestamp(float(ts)) + return naive.replace(tzinfo=pytz.UTC) def datetime_to_timestamp(dt): diff --git a/instagram/models.py b/instagram/models.py index d2517ca2..576088a0 100644 --- a/instagram/models.py +++ b/instagram/models.py @@ -3,21 +3,17 @@ class ApiModel(object): - @classmethod def object_from_dictionary(cls, entry): # make dict keys all strings if entry is None: return "" + entry_str_dict = dict([(str(key), value) for key, value in entry.items()]) return cls(**entry_str_dict) def __repr__(self): return str(self) - # if six.PY2: - # return six.text_type(self).encode('utf8') - # else: - # return self.encode('utf8') def __str__(self): if six.PY3: @@ -27,24 +23,24 @@ def __str__(self): class Image(ApiModel): - - def __init__(self, url, width, height): + def __init__(self, url, width, height, id=None): self.url = url self.height = height self.width = width + if id: # don't store ids None and 0, just provide support for what seems to be an upcoming API change + self.id = id + def __unicode__(self): return "Image: %s" % self.url class Video(Image): - def __unicode__(self): return "Video: %s" % self.url class Media(ApiModel): - def __init__(self, id=None, **kwargs): self.id = id for key, value in six.iteritems(kwargs): @@ -62,11 +58,9 @@ def get_low_resolution_url(self): else: return self.videos['low_resolution'].url - def get_thumbnail_url(self): return self.images['thumbnail'].url - def __unicode__(self): return "Media: %s" % self.id @@ -74,59 +68,55 @@ def __unicode__(self): def object_from_dictionary(cls, entry): new_media = Media(id=entry['id']) new_media.type = entry['type'] - new_media.user = User.object_from_dictionary(entry['user']) new_media.images = {} - for version, version_info in six.iteritems(entry['images']): + for version, version_info in six.iteritems(entry.get('images', {})): new_media.images[version] = Image.object_from_dictionary(version_info) if new_media.type == 'video': new_media.videos = {} - for version, version_info in six.iteritems(entry['videos']): + for version, version_info in six.iteritems(entry.get('videos', {})): new_media.videos[version] = Video.object_from_dictionary(version_info) - if 'user_has_liked' in entry: - new_media.user_has_liked = entry['user_has_liked'] - new_media.like_count = entry['likes']['count'] + new_media.user_has_liked = entry.get('user_has_liked', False) + + new_media.like_count = entry.get('likes', {}).get('count', 0) new_media.likes = [] - if 'data' in entry['likes']: - for like in entry['likes']['data']: + if new_media.like_count: + for like in entry.get('likes', {}).get('data', []): new_media.likes.append(User.object_from_dictionary(like)) - new_media.comment_count = entry['comments']['count'] + new_media.comment_count = entry.get('comments', {}).get('count', 0) new_media.comments = [] - for comment in entry['comments']['data']: - new_media.comments.append(Comment.object_from_dictionary(comment)) + if new_media.comment_count: + for comment in entry.get('comments', {}).get('data', []): + new_media.comments.append(Comment.object_from_dictionary(comment)) new_media.users_in_photo = [] - if entry.get('users_in_photo'): - for user_in_photo in entry['users_in_photo']: - new_media.users_in_photo.append(UserInPhoto.object_from_dictionary(user_in_photo)) + for user_in_photo in entry.get('users_in_photo') or []: + new_media.users_in_photo.append(UserInPhoto.object_from_dictionary(user_in_photo)) new_media.created_time = timestamp_to_datetime(entry['created_time']) - if entry['location'] and 'id' in entry: + if entry.get('location') and entry.get('id'): new_media.location = Location.object_from_dictionary(entry['location']) new_media.caption = None - if entry['caption']: + if entry.get('caption'): new_media.caption = Comment.object_from_dictionary(entry['caption']) - + new_media.tags = [] - if entry['tags']: - for tag in entry['tags']: - new_media.tags.append(Tag.object_from_dictionary({'name': tag})) + for tag in entry.get('tags', []): + new_media.tags.append(Tag.object_from_dictionary({'name': tag})) new_media.link = entry['link'] - new_media.filter = entry.get('filter') return new_media class MediaShortcode(Media): - def __init__(self, shortcode=None, **kwargs): self.shortcode = shortcode for key, value in six.iteritems(kwargs): @@ -179,11 +169,9 @@ def __init__(self, id, *args, **kwargs): def object_from_dictionary(cls, entry): point = None if 'latitude' in entry: - point = Point(entry.get('latitude'), - entry.get('longitude')) - location = Location(entry.get('id', 0), - point=point, - name=entry.get('name', '')) + point = Point(entry.get('latitude'), entry.get('longitude')) + + location = Location(entry.get('id', 0), point=point, name=entry.get('name', '')) return location def __unicode__(self): @@ -191,7 +179,6 @@ def __unicode__(self): class User(ApiModel): - def __init__(self, id, *args, **kwargs): self.id = id for key, value in six.iteritems(kwargs): @@ -202,7 +189,6 @@ def __unicode__(self): class Relationship(ApiModel): - def __init__(self, incoming_status="none", outgoing_status="none", target_user_is_private=False): self.incoming_status = incoming_status self.outgoing_status = outgoing_status diff --git a/instagram/oauth2.py b/instagram/oauth2.py index 053b1be8..7d6ec6ba 100644 --- a/instagram/oauth2.py +++ b/instagram/oauth2.py @@ -27,12 +27,13 @@ class OAuth2API(object): # override with 'Instagram', etc api_name = "Generic API" - def __init__(self, client_id=None, client_secret=None, client_ips=None, access_token=None, redirect_uri=None): + def __init__(self, client_id=None, client_secret=None, client_ips=None, access_token=None, redirect_uri=None, timeout=None): self.client_id = client_id self.client_secret = client_secret self.client_ips = client_ips self.access_token = access_token self.redirect_uri = redirect_uri + self.timeout = timeout def get_authorize_url(self, scope=None): req = OAuth2AuthExchangeRequest(self) @@ -54,8 +55,7 @@ def exchange_user_id_for_access_token(self, user_id): def exchange_xauth_login_for_access_token(self, username, password, scope=None): """ scope should be a tuple or list of requested scope access levels """ req = OAuth2AuthExchangeRequest(self) - return req.exchange_for_access_token(username=username, password=password, - scope=scope) + return req.exchange_for_access_token(username=username, password=password, scope=scope) class OAuth2AuthExchangeRequest(object): @@ -68,10 +68,12 @@ def _url_for_authorize(self, scope=None): "response_type": "code", "redirect_uri": self.api.redirect_uri } + if scope: client_params.update(scope=' '.join(scope)) + url_params = urlencode(client_params) - return "%s?%s" % (self.api.authorize_url, url_params) + return "{url}?{params}".format(url=self.api.authorize_url, params=url_params) def _data_for_exchange(self, code=None, username=None, password=None, scope=None, user_id=None): client_params = { @@ -80,6 +82,7 @@ def _data_for_exchange(self, code=None, username=None, password=None, scope=None "redirect_uri": self.api.redirect_uri, "grant_type": "authorization_code" } + if code: client_params.update(code=code) elif username and password: @@ -88,31 +91,38 @@ def _data_for_exchange(self, code=None, username=None, password=None, scope=None grant_type="password") if scope: client_params.update(scope=' '.join(scope)) + elif user_id: client_params.update(user_id=user_id) + return urlencode(client_params) def get_authorize_url(self, scope=None): return self._url_for_authorize(scope=scope) def get_authorize_login_url(self, scope=None): - http_object = Http(disable_ssl_certificate_validation=True) + http_object = Http(timeout=self.api.timeout, disable_ssl_certificate_validation=True) url = self._url_for_authorize(scope=scope) response, content = http_object.request(url) if response['status'] != '200': - raise OAuth2AuthExchangeError("The server returned a non-200 response for URL %s" % url) - redirected_to = response['content-location'] + raise OAuth2AuthExchangeError("The server returned a non-200 response for URL {url}".format(url)) + + redirected_to = response['Content-Location'] return redirected_to def exchange_for_access_token(self, code=None, username=None, password=None, scope=None, user_id=None): data = self._data_for_exchange(code, username, password, scope=scope, user_id=user_id) - http_object = Http(disable_ssl_certificate_validation=True) + http_object = Http(timeout=self.api.timeout, disable_ssl_certificate_validation=True) url = self.api.access_token_url - response, content = http_object.request(url, method="POST", body=data) + + headers = {"Content-Type": "application/x-www-form-urlencoded"} + response, content = http_object.request(url, method="POST", body=data, headers=headers) parsed_content = simplejson.loads(content.decode()) + if int(response['status']) != 200: raise OAuth2AuthExchangeError(parsed_content.get("error_message", "")) + return parsed_content['access_token'], parsed_content['user'] @@ -121,9 +131,12 @@ def __init__(self, api): self.api = api def _generate_sig(self, endpoint, params, secret): - sig = endpoint - for key in sorted(params.keys()): - sig += '|%s=%s' % (key, params[key]) + # handle unicode when signing, urlencode can't handle otherwise. + def enc_if_str(p): + return p.encode('utf-8') if isinstance(p, six.text_type) else p + + path = ''.join('|{key}={val}'.format(key=key, val=enc_if_str(params[key])) for key in sorted(params.keys())) + sig = '{endpoint}{path}'.format(endpoint=endpoint, path=path) return hmac.new(secret.encode(), sig.encode(), sha256).hexdigest() def url_for_get(self, path, parameters): @@ -135,30 +148,30 @@ def get_request(self, path, **kwargs): def post_request(self, path, **kwargs): return self.make_request(self.prepare_request("POST", path, kwargs)) + # TODO - make use of six.moves.urllib.parse.urlparse for all this string munging def _full_url(self, path, include_secret=False, include_signed_request=True): - return "%s://%s%s%s%s%s" % (self.api.protocol, - self.api.host, - self.api.base_path, - path, - self._auth_query(include_secret), - self._signed_request(path, {}, include_signed_request, include_secret)) + signed_request = self._signed_request(path, {}, include_signed_request, include_secret) + return "{protocol}://{host}{basepath}{path}{query}{signed}".format( + protocol=self.api.protocol, host=self.api.host, basepath=self.api.base_path, path=path, + query=self._auth_query(include_secret), signed=signed_request) def _full_url_with_params(self, path, params, include_secret=False, include_signed_request=True): - return (self._full_url(path, include_secret) + - self._full_query_with_params(params) + - self._signed_request(path, params, include_signed_request, include_secret)) + signed_request = self._signed_request(path, params, include_signed_request, include_secret) + return "{url}{query}{signed}".format( + url=self._full_url(path, include_secret), query=self._full_query_with_params(params), signed=signed_request) def _full_query_with_params(self, params): - params = ("&" + urlencode(params)) if params else "" - return params + if not params: + return "" + return "&{params}".format(params=urlencode(params)) def _auth_query(self, include_secret=False): if self.api.access_token: - return ("?%s=%s" % (self.api.access_token_field, self.api.access_token)) + return "?{field}={token}".format(field=self.api.access_token_field, token=self.api.access_token) elif self.api.client_id: - base = ("?client_id=%s" % (self.api.client_id)) + base = "?client_id={client_id}".format(client_id=self.api.client_id) if include_secret: - base += "&client_secret=%s" % (self.api.client_secret) + base += "&client_secret={client_secret}".format(client_secret=self.api.client_secret) return base def _signed_request(self, path, params, include_signed_request, include_secret): @@ -167,9 +180,11 @@ def _signed_request(self, path, params, include_signed_request, include_secret): params['access_token'] = self.api.access_token elif self.api.client_id: params['client_id'] = self.api.client_id + if include_secret and self.api.client_secret: params['client_secret'] = self.api.client_secret - return "&sig=%s" % self._generate_sig(path, params, self.api.client_secret) + + return "&sig={signed}".format(signed=self._generate_sig(path, params, self.api.client_secret)) else: return '' @@ -183,15 +198,16 @@ def get_content_type(file_name): return mimetypes.guess_type(file_name)[0] or "application/octet-stream" def encode_field(field_name): - return ("--" + boundary, - 'Content-Disposition: form-data; name="%s"' % (field_name), + return ("--{boundary}".format(boundary=boundary), + 'Content-Disposition: form-data; name="{field_name}"'.format(field_name=field_name), "", str(params[field_name])) def encode_file(field_name): file_name, file_handle = files[field_name] - return ("--" + boundary, - 'Content-Disposition: form-data; name="%s"; filename="%s"' % (field_name, file_name), - "Content-Type: " + get_content_type(file_name), + return ("--{boundary}".format(boundary=boundary), + 'Content-Disposition: form-data; name="{field_name}"; filename="{file_name}"'.format( + field_name=field_name, file_name=file_name), + "Content-Type: {content_type}".format(content_type=get_content_type(file_name)), "", file_handle.read()) lines = [] @@ -199,10 +215,11 @@ def encode_file(field_name): lines.extend(encode_field(field)) for field in files: lines.extend(encode_file(field)) - lines.extend(("--%s--" % (boundary), "")) + + lines.extend(("--{boundary}--".format(boundary=boundary), "")) body = "\r\n".join(lines) - headers = {"Content-Type": "multipart/form-data; boundary=" + boundary, + headers = {"Content-Type": "multipart/form-data; boundary={boundary}".format(boundary=boundary), "Content-Length": str(len(body))} return body, headers @@ -218,10 +235,9 @@ def prepare_request(self, method, path, params, include_secret=False): if not params.get('files'): if method == "POST": body = self._post_body(params) - headers = {'Content-type': 'application/x-www-form-urlencoded'} - url = self._full_url(path, include_secret) - else: - url = self._full_url_with_params(path, params, include_secret) + headers = {'Content-Type': 'application/x-www-form-urlencoded'} + + url = self._full_url_with_params(path, params, include_secret) else: body, headers = self._encode_multipart(params, params['files']) url = self._full_url(path) @@ -230,9 +246,14 @@ def prepare_request(self, method, path, params, include_secret=False): def make_request(self, url, method="GET", body=None, headers=None): headers = headers or {} - if not 'User-Agent' in headers: - headers.update({"User-Agent": "%s Python Client" % self.api.api_name}) + if 'User-Agent' not in headers: + headers.update({"User-Agent": "{api_name} Python Client".format(api_name=self.api.api_name)}) + # https://github.com/jcgregorio/httplib2/issues/173 # bug in httplib2 w/ Python 3 and disable_ssl_certificate_validation=True - http_obj = Http() if six.PY3 else Http(disable_ssl_certificate_validation=True) + if six.PY3: + http_obj = Http(timeout=self.api.timeout) + else: + http_obj = Http(timeout=self.api.timeout, disable_ssl_certificate_validation=True) + return http_obj.request(url, method, body=body, headers=headers) diff --git a/instagram/subscriptions.py b/instagram/subscriptions.py index d84d1fe9..22ba5e77 100644 --- a/instagram/subscriptions.py +++ b/instagram/subscriptions.py @@ -2,6 +2,7 @@ import hashlib from .json_import import simplejson + class SubscriptionType: TAG = 'tag' USER = 'user' diff --git a/python_instagram.egg-info/PKG-INFO b/python_instagram.egg-info/PKG-INFO deleted file mode 100644 index 13b71c3b..00000000 --- a/python_instagram.egg-info/PKG-INFO +++ /dev/null @@ -1,11 +0,0 @@ -Metadata-Version: 1.0 -Name: python-instagram -Version: 1.1.3 -Summary: Instagram API client -Home-page: http://github.com/Instagram/python-instagram -Author: Instagram, Inc -Author-email: apidevelopers@instagram.com -License: MIT -Description: UNKNOWN -Keywords: instagram -Platform: UNKNOWN diff --git a/python_instagram.egg-info/SOURCES.txt b/python_instagram.egg-info/SOURCES.txt deleted file mode 100644 index 02db67d7..00000000 --- a/python_instagram.egg-info/SOURCES.txt +++ /dev/null @@ -1,15 +0,0 @@ -setup.py -instagram/__init__.py -instagram/bind.py -instagram/client.py -instagram/helper.py -instagram/json_import.py -instagram/models.py -instagram/oauth2.py -instagram/subscriptions.py -python_instagram.egg-info/PKG-INFO -python_instagram.egg-info/SOURCES.txt -python_instagram.egg-info/dependency_links.txt -python_instagram.egg-info/requires.txt -python_instagram.egg-info/top_level.txt -python_instagram.egg-info/zip-safe \ No newline at end of file diff --git a/python_instagram.egg-info/dependency_links.txt b/python_instagram.egg-info/dependency_links.txt deleted file mode 100644 index 8b137891..00000000 --- a/python_instagram.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/python_instagram.egg-info/requires.txt b/python_instagram.egg-info/requires.txt deleted file mode 100644 index b85397f3..00000000 --- a/python_instagram.egg-info/requires.txt +++ /dev/null @@ -1,2 +0,0 @@ -simplejson -httplib2 \ No newline at end of file diff --git a/python_instagram.egg-info/top_level.txt b/python_instagram.egg-info/top_level.txt deleted file mode 100644 index aebfbd98..00000000 --- a/python_instagram.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -instagram diff --git a/python_instagram.egg-info/zip-safe b/python_instagram.egg-info/zip-safe deleted file mode 100644 index 8b137891..00000000 --- a/python_instagram.egg-info/zip-safe +++ /dev/null @@ -1 +0,0 @@ - diff --git a/requirements.txt b/requirements.txt index c0eed9ba..9420d78d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,4 @@ -bottle==0.12.7 httplib2==0.9 -python-instagram==1.1.3 -redis==2.10.3 simplejson==3.6.3 -beaker==1.6.4 six==1.8.0 +pytz==2015.4 diff --git a/sample_app.py b/sample_app.py deleted file mode 100644 index 82cc13a9..00000000 --- a/sample_app.py +++ /dev/null @@ -1,272 +0,0 @@ -import bottle -import beaker.middleware -from bottle import route, redirect, post, run, request, hook -from instagram import client, subscriptions - -bottle.debug(True) - -session_opts = { - 'session.type': 'file', - 'session.data_dir': './session/', - 'session.auto': True, -} - -app = beaker.middleware.SessionMiddleware(bottle.app(), session_opts) - -CONFIG = { - 'client_id': '', - 'client_secret': '', - 'redirect_uri': 'http://localhost:8515/oauth_callback' -} - -unauthenticated_api = client.InstagramAPI(**CONFIG) - -@hook('before_request') -def setup_request(): - request.session = request.environ['beaker.session'] - -def process_tag_update(update): - print(update) - -reactor = subscriptions.SubscriptionsReactor() -reactor.register_callback(subscriptions.SubscriptionType.TAG, process_tag_update) - -@route('/') -def home(): - try: - url = unauthenticated_api.get_authorize_url(scope=["likes","comments"]) - return 'Connect with Instagram' % url - except Exception as e: - print(e) - -def get_nav(): - nav_menu = ("

Python Instagram

" - "") - return nav_menu - -@route('/oauth_callback') -def on_callback(): - code = request.GET.get("code") - if not code: - return 'Missing code' - try: - access_token, user_info = unauthenticated_api.exchange_code_for_access_token(code) - if not access_token: - return 'Could not get access token' - api = client.InstagramAPI(access_token=access_token, client_secret=CONFIG['client_secret']) - request.session['access_token'] = access_token - except Exception as e: - print(e) - return get_nav() - -@route('/recent') -def on_recent(): - content = "

User Recent Media

" - access_token = request.session['access_token'] - if not access_token: - return 'Missing Access Token' - try: - api = client.InstagramAPI(access_token=access_token, client_secret=CONFIG['client_secret']) - recent_media, next = api.user_recent_media() - photos = [] - for media in recent_media: - photos.append('
') - if(media.type == 'video'): - photos.append('' % (media.get_standard_resolution_url())) - else: - photos.append('' % (media.get_low_resolution_url())) - photos.append("
Like Un-Like LikesCount=%s
" % (media.id,media.id,media.like_count)) - content += ''.join(photos) - except Exception as e: - print(e) - return "%s %s
Remaining API Calls = %s/%s" % (get_nav(),content,api.x_ratelimit_remaining,api.x_ratelimit) - -@route('/media_like/') -def media_like(id): - access_token = request.session['access_token'] - api = client.InstagramAPI(access_token=access_token, client_secret=CONFIG['client_secret']) - api.like_media(media_id=id) - redirect("/recent") - -@route('/media_unlike/') -def media_unlike(id): - access_token = request.session['access_token'] - api = client.InstagramAPI(access_token=access_token, client_secret=CONFIG['client_secret']) - api.unlike_media(media_id=id) - redirect("/recent") - -@route('/user_media_feed') -def on_user_media_feed(): - access_token = request.session['access_token'] - content = "

User Media Feed

" - if not access_token: - return 'Missing Access Token' - try: - api = client.InstagramAPI(access_token=access_token, client_secret=CONFIG['client_secret']) - media_feed, next = api.user_media_feed() - photos = [] - for media in media_feed: - photos.append('' % media.get_standard_resolution_url()) - counter = 1 - while next and counter < 3: - media_feed, next = api.user_media_feed(with_next_url=next) - for media in media_feed: - photos.append('' % media.get_standard_resolution_url()) - counter += 1 - content += ''.join(photos) - except Exception as e: - print(e) - return "%s %s
Remaining API Calls = %s/%s" % (get_nav(),content,api.x_ratelimit_remaining,api.x_ratelimit) - -@route('/location_recent_media') -def location_recent_media(): - access_token = request.session['access_token'] - content = "

Location Recent Media

" - if not access_token: - return 'Missing Access Token' - try: - api = client.InstagramAPI(access_token=access_token, client_secret=CONFIG['client_secret']) - recent_media, next = api.location_recent_media(location_id=514276) - photos = [] - for media in recent_media: - photos.append('' % media.get_standard_resolution_url()) - content += ''.join(photos) - except Exception as e: - print(e) - return "%s %s
Remaining API Calls = %s/%s" % (get_nav(),content,api.x_ratelimit_remaining,api.x_ratelimit) - -@route('/media_search') -def media_search(): - access_token = request.session['access_token'] - content = "

Media Search

" - if not access_token: - return 'Missing Access Token' - try: - api = client.InstagramAPI(access_token=access_token, client_secret=CONFIG['client_secret']) - media_search = api.media_search(lat="37.7808851",lng="-122.3948632",distance=1000) - photos = [] - for media in media_search: - photos.append('' % media.get_standard_resolution_url()) - content += ''.join(photos) - except Exception as e: - print(e) - return "%s %s
Remaining API Calls = %s/%s" % (get_nav(),content,api.x_ratelimit_remaining,api.x_ratelimit) - -@route('/media_popular') -def media_popular(): - access_token = request.session['access_token'] - content = "

Popular Media

" - if not access_token: - return 'Missing Access Token' - try: - api = client.InstagramAPI(access_token=access_token, client_secret=CONFIG['client_secret']) - media_search = api.media_popular() - photos = [] - for media in media_search: - photos.append('' % media.get_standard_resolution_url()) - content += ''.join(photos) - except Exception as e: - print(e) - return "%s %s
Remaining API Calls = %s/%s" % (get_nav(),content,api.x_ratelimit_remaining,api.x_ratelimit) - -@route('/user_search') -def user_search(): - access_token = request.session['access_token'] - content = "

User Search

" - if not access_token: - return 'Missing Access Token' - try: - api = client.InstagramAPI(access_token=access_token, client_secret=CONFIG['client_secret']) - user_search = api.user_search(q="Instagram") - users = [] - for user in user_search: - users.append('
  • %s
  • ' % (user.profile_picture,user.username)) - content += ''.join(users) - except Exception as e: - print(e) - return "%s %s
    Remaining API Calls = %s/%s" % (get_nav(),content,api.x_ratelimit_remaining,api.x_ratelimit) - -@route('/user_follows') -def user_follows(): - access_token = request.session['access_token'] - content = "

    User Follows

    " - if not access_token: - return 'Missing Access Token' - try: - api = client.InstagramAPI(access_token=access_token, client_secret=CONFIG['client_secret']) - # 25025320 is http://instagram.com/instagram - user_follows, next = api.user_follows('25025320') - users = [] - for user in user_follows: - users.append('
  • %s
  • ' % (user.profile_picture,user.username)) - while next: - user_follows, next = api.user_follows(with_next_url=next) - for user in user_follows: - users.append('
  • %s
  • ' % (user.profile_picture,user.username)) - content += ''.join(users) - except Exception as e: - print(e) - return "%s %s
    Remaining API Calls = %s/%s" % (get_nav(),content,api.x_ratelimit_remaining,api.x_ratelimit) - -@route('/location_search') -def location_search(): - access_token = request.session['access_token'] - content = "

    Location Search

    " - if not access_token: - return 'Missing Access Token' - try: - api = client.InstagramAPI(access_token=access_token, client_secret=CONFIG['client_secret']) - location_search = api.location_search(lat="37.7808851",lng="-122.3948632",distance=1000) - locations = [] - for location in location_search: - locations.append('
  • %s Map
  • ' % (location.name,location.point.latitude,location.point.longitude)) - content += ''.join(locations) - except Exception as e: - print(e) - return "%s %s
    Remaining API Calls = %s/%s" % (get_nav(),content,api.x_ratelimit_remaining,api.x_ratelimit) - -@route('/tag_search') -def tag_search(): - access_token = request.session['access_token'] - content = "

    Tag Search

    " - if not access_token: - return 'Missing Access Token' - try: - api = client.InstagramAPI(access_token=access_token, client_secret=CONFIG['client_secret']) - tag_search, next_tag = api.tag_search(q="backclimateaction") - tag_recent_media, next = api.tag_recent_media(tag_name=tag_search[0].name) - photos = [] - for tag_media in tag_recent_media: - photos.append('' % tag_media.get_standard_resolution_url()) - content += ''.join(photos) - except Exception as e: - print(e) - return "%s %s
    Remaining API Calls = %s/%s" % (get_nav(),content,api.x_ratelimit_remaining,api.x_ratelimit) - -@route('/realtime_callback') -@post('/realtime_callback') -def on_realtime_callback(): - mode = request.GET.get("hub.mode") - challenge = request.GET.get("hub.challenge") - verify_token = request.GET.get("hub.verify_token") - if challenge: - return challenge - else: - x_hub_signature = request.header.get('X-Hub-Signature') - raw_response = request.body.read() - try: - reactor.process(CONFIG['client_secret'], raw_response, x_hub_signature) - except subscriptions.SubscriptionVerifyError: - print("Signature mismatch") - -bottle.run(app=app, host='localhost', port=8515, reloader=True) diff --git a/setup.py b/setup.py index b0187529..f5de51d7 100644 --- a/setup.py +++ b/setup.py @@ -1,14 +1,21 @@ #!/usr/bin/env python from setuptools import setup, find_packages -setup(name="python-instagram", - version="1.3.2", - description="Instagram API client", - license="MIT", - install_requires=["simplejson","httplib2","six"], - author="Instagram, Inc", - author_email="apidevelopers@instagram.com", - url="http://github.com/Instagram/python-instagram", - packages = find_packages(), - keywords= "instagram", - zip_safe = True) +setup( + name="instagram", + version="1.3.4", + description="Instagram API client", + license="MIT", + install_requires=[ + "simplejson", + "httplib2", + "six", + "pytz", + ], + author="instagram, wkoot", + author_email="pypi@rondarchief.nl", + url="http://github.com/wkoot/python-instagram", + packages=find_packages(), + keywords="instagram", + zip_safe=True +) diff --git a/tests.py b/tests.py index fcf705a7..85f5ffe0 100755 --- a/tests.py +++ b/tests.py @@ -1,7 +1,8 @@ #!/usr/bin/env python - import types import six +import pytz + try: import simplejson as json except ImportError: @@ -10,6 +11,7 @@ import unittest from six.moves.urllib.parse import urlparse, parse_qs from instagram import client, oauth2, InstagramAPIError +from instagram.helper import timestamp_to_datetime TEST_AUTH = False client_id = "DEBUG" @@ -17,15 +19,13 @@ access_token = "DEBUG" redirect_uri = "http://example.com" -class MockHttp(object): +class MockHttp(object): def __init__(self, *args, **kwargs): pass def request(self, url, method="GET", body=None, headers={}): - fail_state = { - 'status':'400' - }, "{}" + fail_state = {'status': '400'}, "{}" parsed = urlparse(url) options = parse_qs(parsed.query) @@ -33,27 +33,28 @@ def request(self, url, method="GET", body=None, headers={}): fn_name = str(active_call) if fn_name == 'get_authorize_login_url': return { - 'status': '200', - 'content-location':'http://example.com/redirect/login' - }, None + 'status': '200', + 'Content-Location': 'http://example.com/redirect/login' + }, None - if not 'access_token' in options and not 'client_id' in options: + if 'access_token' not in options and 'client_id' not in options: fn_name += '_unauthorized' - if 'self' in url and not 'access_token' in options: + if 'self' in url and 'access_token' not in options: fn_name += '_no_auth_user' fl = open('fixtures/%s.json' % fn_name) content = fl.read() fl.close() + json_content = json.loads(content) status = json_content['meta']['code'] - return { - 'status': status - }, content + return {'status': status}, content -oauth2.Http = MockHttp +oauth2.Http = MockHttp active_call = None + + class TestInstagramAPI(client.InstagramAPI): def __getattribute__(self, attr): global active_call @@ -62,13 +63,18 @@ def __getattribute__(self, attr): active_call = attr return actual_val + class InstagramAuthTests(unittest.TestCase): def setUp(self): + if not TEST_AUTH: + raise unittest.SkipTest() + self.unauthenticated_api = TestInstagramAPI(client_id=client_id, redirect_uri=redirect_uri, client_secret=client_secret) def test_authorize_login_url(self): redirect_uri = self.unauthenticated_api.get_authorize_login_url() assert redirect_uri + print("Please visit and authorize at:\n%s" % redirect_uri) code = raw_input("Paste received code (blank to skip): ").strip() if not code: @@ -83,12 +89,23 @@ def test_xauth_exchange(self): username = raw_input("Enter username for XAuth (blank to skip): ").strip() if not username: return - password = getpass.getpass("Enter password for XAuth (blank to skip): ").strip() + + password = getpass.getpass("Enter password for XAuth (blank to skip): ").strip() access_token = self.unauthenticated_api.exchange_xauth_login_for_access_token(username, password) assert access_token -class InstagramAPITests(unittest.TestCase): +class OAuth2RequestTests(unittest.TestCase): + def setUp(self): + super(OAuth2RequestTests, self).setUp() + self.api = TestInstagramAPI(access_token=access_token) + self.request = oauth2.OAuth2Request(self.api) + + def test_generate_sig(self): + self.request._generate_sig(endpoint='/', params=dict(count=1), secret=client_secret) + + +class InstagramAPITests(unittest.TestCase): def setUp(self): super(InstagramAPITests, self).setUp() self.client_only_api = TestInstagramAPI(client_id=client_id) @@ -98,8 +115,8 @@ def test_media_popular(self): self.api.media_popular(count=10) def test_media_search(self): - self.client_only_api.media_search(lat=37.7,lng=-122.22) - self.api.media_search(lat=37.7,lng=-122.22) + self.client_only_api.media_search(lat=37.7, lng=-122.22) + self.api.media_search(lat=37.7, lng=-122.22) def test_media_shortcode(self): self.client_only_api.media_shortcode('os1NQjxtvF') @@ -144,49 +161,45 @@ def test_user_liked_media(self): def test_user_recent_media(self): media, url = self.api.user_recent_media(count=10) - self.assertTrue( all( [hasattr(obj, 'type') for obj in media] ) ) + self.assertTrue(all([hasattr(obj, 'type') for obj in media])) image = media[0] self.assertEqual( - image.get_standard_resolution_url(), - "http://distillery-dev.s3.amazonaws.com/media/2011/02/02/1ce5f3f490a640ca9068e6000c91adc5_7.jpg") + image.get_standard_resolution_url(), + "http://distillery-dev.s3.amazonaws.com/media/2011/02/02/1ce5f3f490a640ca9068e6000c91adc5_7.jpg") self.assertEqual( - image.get_low_resolution_url(), - "http://distillery-dev.s3.amazonaws.com/media/2011/02/02/1ce5f3f490a640ca9068e6000c91adc5_6.jpg") + image.get_low_resolution_url(), + "http://distillery-dev.s3.amazonaws.com/media/2011/02/02/1ce5f3f490a640ca9068e6000c91adc5_6.jpg") self.assertEqual( - image.get_thumbnail_url(), - "http://distillery-dev.s3.amazonaws.com/media/2011/02/02/1ce5f3f490a640ca9068e6000c91adc5_5.jpg") + image.get_thumbnail_url(), + "http://distillery-dev.s3.amazonaws.com/media/2011/02/02/1ce5f3f490a640ca9068e6000c91adc5_5.jpg") - self.assertEqual( False, hasattr(image, 'videos') ) + self.assertEqual(False, hasattr(image, 'videos')) video = media[1] self.assertEqual( - video.get_standard_resolution_url(), - video.videos['standard_resolution'].url) + video.get_standard_resolution_url(), + video.videos['standard_resolution'].url) self.assertEqual( - video.get_standard_resolution_url(), - "http://distilleryvesper9-13.ak.instagram.com/090d06dad9cd11e2aa0912313817975d_101.mp4") + video.get_standard_resolution_url(), + "http://distilleryvesper9-13.ak.instagram.com/090d06dad9cd11e2aa0912313817975d_101.mp4") self.assertEqual( - video.get_low_resolution_url(), - "http://distilleryvesper9-13.ak.instagram.com/090d06dad9cd11e2aa0912313817975d_102.mp4") + video.get_low_resolution_url(), + "http://distilleryvesper9-13.ak.instagram.com/090d06dad9cd11e2aa0912313817975d_102.mp4") self.assertEqual( - video.get_thumbnail_url(), - "http://distilleryimage2.ak.instagram.com/11f75f1cd9cc11e2a0fd22000aa8039a_5.jpg") - - - - + video.get_thumbnail_url(), + "http://distilleryimage2.ak.instagram.com/11f75f1cd9cc11e2a0fd22000aa8039a_5.jpg") def test_user_search(self): self.api.user_search('mikeyk', 10) def test_user_follows(self): - for page in self.api.user_followed_by(as_generator=True): + for page in self.api.user_follows(as_generator=True): str(page) def test_user_followed_by(self): @@ -204,7 +217,7 @@ def test_location_recent_media(self): self.api.location_recent_media(location_id=1) def test_location_search(self): - self.api.location_search(lat=37.7,lng=-122.22, distance=2500) + self.api.location_search(lat=37.7, lng=-122.22, distance=2500) def test_location(self): self.api.location(1) @@ -222,18 +235,6 @@ def test_tag_search(self): def test_tag(self): self.api.tag("coffee") - def test_user_follows(self): - self.api.user_follows() - - def test_user_followed_by(self): - self.api.user_followed_by() - - def test_user_followed_by(self): - self.api.user_followed_by() - - def test_user_requested_by(self): - self.api.user_followed_by() - def test_user_incoming_requests(self): self.api.user_incoming_requests() @@ -246,6 +247,17 @@ def test_change_relationship(self): def test_geography_recent_media(self): self.api.geography_recent_media(geography_id=1) + +class InstagramHelperTests(unittest.TestCase): + def setUp(self): + self.timestamp = 1439822186 + + def test_timestamp_to_datetime(self): + date_time = timestamp_to_datetime(float(self.timestamp)) + self.assertTrue(date_time.tzinfo is not None) + self.assertEqual(date_time.tzinfo, pytz.UTC) + + if __name__ == '__main__': if not TEST_AUTH: del InstagramAuthTests