Permalink
Browse files

Merge pull request #6 from dreikanter/master

Token authorization + test coverage
  • Loading branch information...
2 parents 0f1226b + 27d10ad commit 2538fccb9b970f3ebc4255b22653f3d27c5b7d0f @mgan59 committed Sep 7, 2012
Showing with 162 additions and 28 deletions.
  1. +11 −0 .editorconfig
  2. +13 −0 .gitignore
  3. +54 −28 pinboard.py
  4. +6 −0 tests/sample_conf.py
  5. +78 −0 tests/test.py
View
@@ -0,0 +1,11 @@
+; top-most EditorConfig file
+root = true
+
+; Unix-style newlines
+[*]
+end_of_line = CRLF
+
+; 4 space indentation
+[*.py]
+indent_style = space
+indent_size = 4
View
@@ -0,0 +1,13 @@
+*.py[co]
+
+# Packages
+*.egg
+*.egg-info
+dist
+build
+bin
+sdist
+MANIFEST
+
+# Do not commit user credentials!
+tests/conf.py
View
@@ -6,7 +6,7 @@
This library was built on top of Paul Mucur's original work on the python-delicious
which was supported for python 2.3. Morgan became a contributor and ported this library
-to pinboard.in when it was announced in December 2010 that delicious servers may be
+to pinboard.in when it was announced in December 2010 that delicious servers may be
shutting down.
The port to pinboard resulted in the inclusion of gzip support
@@ -74,13 +74,33 @@
AUTH_HANDLER_REALM = 'API'
AUTH_HANDLER_URI = "https://api.pinboard.in/"
-def open(username, password):
- """Open a connection to a pinboard.in account"""
- return PinboardAccount(username, password)
-def connect(username, password):
- """Open a connection to a pinboard.in account"""
- return open(username, password)
+def open(username=None, password=None, token=None):
+ """Open a connection to a pinboard.in account
+
+ Arguments:
+ username -- pinboard.in user name; for canonical authentication
+ both user and password should be specified
+
+ password -- pinboard.in password
+
+ token -- API token; username and password will be ignored
+ if the token is defined
+
+ Usage:
+ >>> open('johnd', 'secret$777')
+ >>> open(username='johnd', password='secret$777')
+ >>> open(token='johnd:258329B14EB83FD1E449')
+
+ Returns:
+ New pinboard.PinboardAccount instance."""
+ return PinboardAccount(username, password, token)
+
+
+def connect(username=None, password=None, token=None):
+ """Open a connection to a pinboard.in account
+ (alias for pinboard.open())."""
+ return open(username, password, token)
# Custom exceptions
@@ -121,6 +141,7 @@ class DateParamsError(PinboardError):
'''Date params error'''
pass
+
class PinboardAccount(UserDict):
"""A pinboard.in account"""
@@ -131,24 +152,31 @@ class PinboardAccount(UserDict):
# Time of last request so that the one second limit can be enforced.
__lastrequest = None
+ # Pinboard API token
+ # (see http://blog.pinboard.in/2012/07/api_authentication_tokens/)
+ __token = None
+
# Special methods
- def __init__(self, username, password):
+ def __init__(self, username=None, password=None, token=None):
UserDict.__init__(self)
# Authenticate the URL opener so that it can access Pinboard
if _debug:
sys.stderr.write("Initialising Pinboard Account object.\n")
- auth_handler = urllib2.HTTPBasicAuthHandler()
- auth_handler.add_password("API", "https://api.pinboard.in/", \
- username, password)
- opener = urllib2.build_opener(auth_handler)
+
+ if token:
+ self.__token = urllib.quote_plus(token)
+ opener = urllib2.build_opener()
+ else:
+ auth_handler = urllib2.HTTPBasicAuthHandler()
+ auth_handler.add_password("API", "https://api.pinboard.in/", \
+ username, password)
+ opener = urllib2.build_opener(auth_handler)
+
opener.addheaders = [("User-agent", USER_AGENT), ('Accept-encoding', 'gzip')]
urllib2.install_opener(opener)
if _debug:
sys.stderr.write("URL opener with HTTP authenticiation installed globally.\n")
-
-
- if _debug:
sys.stderr.write("Time of last update loaded into class dictionary.\n")
def __getitem__(self, key):
@@ -171,9 +199,7 @@ def __setitem__(self, key, value):
self.__postschanged = 1
return UserDict.__setitem__(self, key, value)
-
def __request(self, url):
-
# Make sure that it has been at least 1 second since the last
# request was made. If not, halt execution for approximately one
# seconds.
@@ -186,7 +212,11 @@ def __request(self, url):
self.__lastrequest = time.time()
if _debug:
sys.stderr.write("Opening %s.\n" % url)
-
+
+ if self.__token:
+ sep = '&' if '?' in url else '?'
+ url = "%s%sauth_token=%s" % (url, sep, self.__token)
+
try:
## for pinboard a gzip request is made
raw_xml = urllib2.urlopen(url)
@@ -195,10 +225,10 @@ def __request(self, url):
compressedstream = StringIO.StringIO(compresseddata)
gzipper = gzip.GzipFile(fileobj=compressedstream)
xml = gzipper.read()
-
+
except urllib2.URLError, e:
- raise e
-
+ raise e
+
self["headers"] = {}
for header in raw_xml.headers.headers:
(name, value) = header.split(": ")
@@ -209,10 +239,7 @@ def __request(self, url):
if _debug:
sys.stderr.write("%s opened successfully.\n" % url)
return minidom.parseString(xml)
-
-
-
-
+
def posts(self, tag="", date="", todt="", fromdt="", count=0):
"""Return pinboard.in bookmarks as a list of dictionaries.
@@ -319,7 +346,7 @@ def suggest(self, url):
popular = [t.firstChild.data for t in tags.getElementsByTagName('popular')]
recommended = [t.firstChild.data for t in tags.getElementsByTagName('recommended')]
-
+
return {'popular': popular, 'recommended': recommended}
def tags(self):
@@ -397,14 +424,13 @@ def dates(self, tag=""):
self["dates"] = dates
return dates
-
# Methods to modify pinboard.in content
def add(self, url, description, extended="", tags=(), date="", toread="no", replace="no", shared="yes"):
"""Add a new post to pinboard.in"""
query = {}
query["url"] = url
- query ["description"] = description
+ query["description"] = description
query["toread"] = toread
query["replace"] = replace
query["shared"] = shared
View
@@ -0,0 +1,6 @@
+# Copy this file to conf.py and replace values
+# with the real data to enable unit tests.
+
+username = 'johnd'
+password = 'secret$71717'
+token = 'johnd:01234567890123456789'
View
@@ -0,0 +1,78 @@
+#!/usr/bin/env python
+
+"""Python-Pinboard unit tests.
+
+Before running the tests:
+
+1. Update user credentials at conf.py (see sample_conf.py for example).
+2. Consider to backup your data before running this test on real account
+or use dedicated sandbox account (the second approach is recommended)."""
+
+import conf
+import unittest
+import sys
+import time
+
+sys.path.insert(0, '..')
+
+import pinboard
+
+
+def get_tag_names(tags):
+ for tag in tags:
+ yield tag['name']
+
+
+class TestPinboardAccount(unittest.TestCase):
+
+ def test_token(self):
+ p = pinboard.open(token=conf.token)
+ self.common_case(p)
+
+ def test_canonical(self):
+ p = pinboard.open(conf.username, conf.password)
+ self.common_case(p)
+
+ def common_case(self, p):
+ """Add some test bookmark records and than delete them"""
+ test_url = 'http://github.com'
+ test_tag = '__testing__'
+
+ # Adding a test bookmark
+ p.add(url=test_url,
+ description='GitHub',
+ extended='It\'s a GitHub!',
+ tags=(test_tag))
+
+ posts = p.posts(tag=test_tag)
+
+ # Bookmark was added
+ self.assertIs(type(posts), list)
+ self.assertTrue(posts)
+
+ # Looks like there is no immediate consistency between API inputs
+ # and outputs, that's why additional delays added here and below.
+ time.sleep(3)
+
+ # Tags contains new tag
+ tags = p.tags()
+ self.assertTrue(type(tags), dict)
+ self.assertIn(test_tag, get_tag_names(tags))
+
+ # Deleting test bookmark(s)
+ for post in posts:
+ p.delete(post['href'])
+
+ time.sleep(3)
+
+ # There are no posts with test tag
+ posts = p.posts(tag=test_tag)
+ self.assertFalse(posts)
+
+ # And no test tag any more
+ tags = p.tags()
+ self.assertNotIn(test_tag, get_tag_names(tags))
+
+
+if __name__ == '__main__':
+ unittest.main()

0 comments on commit 2538fcc

Please sign in to comment.