├── .gitignore ├── LICENSE ├── README.md ├── posterous-shell ├── posterous ├── __init__.py ├── api.py ├── bind.py ├── error.py ├── models.py ├── parsers.py └── utils.py ├── scripts └── backup-posterous.py ├── setup.py └── tests ├── posts.xml ├── sites.xml └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | build/ 4 | dist/ 5 | *.egg-info 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Benjamin Reitzammer 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # What is posterous-python? 2 | It's a simple to use Python library for version 1 of the [Posterous API](http://posterous.com/api). 3 | It covers the entire API and it's really easy to extend when new API methods are added! 4 | Support for API v2 will be added soon. 5 | 6 | ## Getting started 7 | * Check out the posterous-python source code using git: 8 | git clone git://github.com/nureineide/posterous-python.git 9 | * Run setuptools to install 10 | sudo python setup.py install 11 | 12 | That's it! Now run the posterous-shell to start playing with the library. 13 | 14 | ##Sample usage 15 | import posterous 16 | 17 | api = posterous.API('username', 'password') 18 | 19 | # Get the user's sites 20 | sites = api.get_sites() 21 | 22 | for site in sites: 23 | print site.name 24 | 25 | # Get all of the posts from the first site 26 | for post in api.read_posts(id=sites[0].id): 27 | print '%s (%s)' % (post.title, post.url) 28 | print ' - written by %s' % post.author 29 | 30 | if post.commentsenabled: 31 | print ' - has %s comment(s)' % post.commentscount 32 | 33 | if post.commentscount > 0: 34 | print ' - comments:' 35 | for comment in post.comments: 36 | print ' - "%s" by %s' % (comment.body, comment.author) 37 | 38 | if hasattr(post, 'media'): 39 | print ' - media:' 40 | for media in post.media: 41 | print ' - %s' % media.url 42 | 43 | print '\n' 44 | 45 | 46 | # Create a new post with an image 47 | image = open("jellyfish.png", "rb").read() 48 | post = api.new_post(title="I love Posterous", body="Do you love it too?", media=image) 49 | 50 | # Add a comment 51 | post.new_comment("This is a really interesting post.") 52 | 53 | Until there is full documentation coverage, you can take a look at api.py for the available methods and their arguments. The model objects also have methods that allow you to quickly perform actions (i.e. post.new_comment() instead of api.read_posts()[0].new_comment()), so look at models.py for those. 54 | 55 | ##In the future... 56 | Expect to see these new features: 57 | 58 | * Easy pagination for iterating over large result sets 59 | * Response caching 60 | * Full documentation 61 | * A cool script for backing up a Posterous site 62 | 63 | ##Last words 64 | The design of this library was very much inspired by [Tweepy](http://github.com/joshthecoder/tweepy). Tweepy is an excellent Python wrapper for Twitter's API, so give it a look if you're working with Twitter. 65 | 66 | ###License 67 | [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0) - See LICENSE for more details. 68 | 69 | Copyright (c) 2010 Benjamin Reitzammer <[nureineide](http://github.com/nureineide)> 70 | 71 | ###Credits 72 | Michael Campagnaro <[mikecampo](http://github.com/mikecampo)> 73 | -------------------------------------------------------------------------------- /posterous-shell: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from getpass import getpass 4 | from optparse import OptionParser 5 | import posterous 6 | 7 | """Launch an interactive shell ready for Posterous usage 8 | 9 | This script is handy for debugging posterous during development 10 | or to just play around with the library. 11 | It imports posterous and creates an authenticated API instance (api) 12 | using the credentials provided. 13 | """ 14 | 15 | opt = OptionParser(usage='posterous-shell ') 16 | options, args = opt.parse_args() 17 | 18 | if len(args) == 1: 19 | username, password = args[0], getpass() 20 | elif len(args) == 2: 21 | username, password = args[0], args[1] 22 | else: 23 | username, password = None, None 24 | 25 | local_ns = {'posterous': posterous, 'api': posterous.API(username, password)} 26 | shellbanner = '' 27 | 28 | try: 29 | import IPython 30 | ipshell = IPython.Shell.IPShell([''], user_ns = local_ns) 31 | ipshell.mainloop(sys_exit=1, banner = shellbanner) 32 | except ImportError: 33 | import code 34 | code.interact(shellbanner, local = local_ns) 35 | 36 | -------------------------------------------------------------------------------- /posterous/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright: 2 | # Copyright (c) 2010, Benjamin Reitzammer , 3 | # All rights reserved. 4 | # 5 | # License: 6 | # This program is free software. You can distribute/modify this program under 7 | # the terms of the Apache License Version 2.0 available at 8 | # http://www.apache.org/licenses/LICENSE-2.0.txt 9 | 10 | """ 11 | Simple wrapper-lib for accessing the Posterous API via python. 12 | See http://posterous.com/api 13 | """ 14 | 15 | __version__ = "1.0" 16 | __author__ = "Benjamin Reitzammer " 17 | __email__ = "benjamin@squeakyvessel.com" 18 | __credits__ = ['Michael Campagnaro '] 19 | 20 | from posterous.api import API 21 | 22 | # unauthenticated instance 23 | api = API() 24 | -------------------------------------------------------------------------------- /posterous/api.py: -------------------------------------------------------------------------------- 1 | # Copyright: 2 | # Copyright (c) 2010, Benjamin Reitzammer , 3 | # All rights reserved. 4 | # 5 | # License: 6 | # This program is free software. You can distribute/modify this program under 7 | # the terms of the Apache License Version 2.0 available at 8 | # http://www.apache.org/licenses/LICENSE-2.0.txt 9 | 10 | from datetime import datetime 11 | 12 | from posterous.parsers import ModelParser 13 | from posterous.bind import bind_method 14 | from posterous.utils import * 15 | 16 | 17 | class API(object): 18 | def __init__(self, username=None, password=None, 19 | host='https://posterous.com', api_root='/api', parser=None): 20 | self.username = username 21 | self.password = password 22 | self.host = host 23 | self.api_root = api_root 24 | self.parser = parser or ModelParser() 25 | 26 | ## API methods 27 | """ 28 | Required arguments: 29 | 'path' - The API method's URL path. 30 | 31 | The optional arguments available are: 32 | 'method' - The HTTP request method to use: "GET", "POST", 33 | "DELETE" ... Defaults to "GET" if argument 34 | is not provided. 35 | 'payload_type' - The name of the Model class that will retain and 36 | parse the response data. 37 | 'payload_list' - If True, a list of 'payload_type' objects is returned. 38 | 'response_type' - Determines which parser to use. Set to 'json' if the 39 | response is in JSON format. Defaults to 'xml' if not 40 | specified. 41 | 'allowed_param' - A list of params that the API method accepts. Must be 42 | formatted as a list of tuples, with the param name 43 | being paired with the expected value type. If more 44 | than one type is allowed, place the types in a tuple. 45 | 'require_auth' - True if the API method requires authentication. 46 | """ 47 | 48 | ## Reading 49 | """ 50 | Returns a list of all sites owned and authored by the 51 | authenticated user. 52 | """ 53 | get_sites = bind_method( 54 | path = 'getsites', 55 | payload_type = 'site', 56 | payload_list = True, 57 | allowed_param = [], 58 | require_auth = True 59 | ) 60 | 61 | """ 62 | Returns a list of posts. Authentication is optional. 63 | If it's not authenticated, either the site_id or hostname 64 | is required and only public posts will be returned. 65 | """ 66 | read_posts = bind_method( 67 | path = 'readposts', 68 | payload_type = 'post', 69 | payload_list = True, 70 | allowed_param = [ 71 | ('site_id', int), 72 | ('hostname', basestring), 73 | ('num_posts', int), 74 | ('page', int), 75 | ('tag', basestring)], 76 | require_auth = False 77 | ) 78 | 79 | """ 80 | Returns a post by interacting with the Post.ly API. 81 | The id param must be in Post.ly shortcode. 82 | (Example: 123abc in http://post.ly/123abc) 83 | Authentication is required if the post is private. 84 | """ 85 | get_post = bind_method( 86 | path = 'getpost', 87 | payload_type = 'post', 88 | allowed_param = [('id', basestring)], 89 | require_auth = False 90 | ) 91 | 92 | """ 93 | Returns a list of all post tags. Authentication is 94 | optional. If it's not authenticated, either the site_id or 95 | hostname is required and only tags in public posts/sites 96 | will be returned. 97 | """ 98 | get_tags = bind_method( 99 | path = 'gettags', 100 | payload_type = 'tag', 101 | payload_list = True, 102 | allowed_param = [ 103 | ('site_id', int), 104 | ('hostname', basestring)], 105 | require_auth = False 106 | ) 107 | 108 | ## Posting 109 | """ 110 | Creates a new post and returns a post object. 111 | The media param must be set to file data. If posting 112 | multiple files, provide a list of file data. 113 | """ 114 | new_post = bind_method( 115 | path = 'newpost', 116 | method = 'POST', 117 | payload_type = 'post', 118 | allowed_param = [ 119 | ('site_id', int), 120 | ('title', basestring), 121 | ('body', basestring), 122 | ('media', (basestring, list)), 123 | ('autopost', bool), 124 | ('private', bool), 125 | ('date', datetime), 126 | ('tags', basestring), 127 | ('source', basestring), 128 | ('sourceLink', basestring)], 129 | require_auth = True 130 | ) 131 | 132 | """ 133 | Returns an updated post. 134 | The media param must be set to file data. If posting 135 | multiple files, provide a list of file data. 136 | """ 137 | update_post = bind_method( 138 | path = 'updatepost', 139 | method = 'POST', 140 | payload_type = 'post', 141 | allowed_param = [ 142 | ('post_id', int), 143 | ('title', basestring), 144 | ('body', basestring), 145 | ('media', (basestring, list))], 146 | require_auth = True 147 | ) 148 | 149 | """ 150 | Returns a comment with its accompanying post. 151 | If a name is not provided, the authenticated user will 152 | own the comment. Optionally, a name and email may be 153 | provided to create an anonymous comment; only the site 154 | owner can do this. 155 | """ 156 | new_comment = bind_method( 157 | path = 'newcomment', 158 | method = 'POST', 159 | payload_type = 'comment', 160 | allowed_param = [ 161 | ('post_id', int), 162 | ('comment', basestring), 163 | ('name', basestring), 164 | ('email', basestring), 165 | ('date', datetime)], 166 | require_auth = True 167 | ) 168 | 169 | ## Twitter 170 | """ 171 | Allows the posting of media to Posterous using Twitter 172 | credentials. Username and password are required params. 173 | If the Twitter user is registered on Posterous, it will 174 | post to their default site. If not registered, Posterous 175 | will create a new site for them. 176 | 177 | The media param must be set to file data. If posting 178 | multiple files, provide a list of file data. 179 | 180 | Returns a JSON object with the post id and post url. 181 | """ 182 | twitter_upload = bind_method( 183 | path = 'upload', 184 | method = 'POST', 185 | payload_type = 'json', 186 | response_type = 'json', 187 | allowed_params = [ 188 | ('username', basestring), 189 | ('password', basestring), 190 | ('media', (basestring, list)), 191 | ('message', basestring), 192 | ('body', basestring), 193 | ('source', basestring), 194 | ('sourceLink', basestring)] 195 | ) 196 | 197 | """ 198 | Has the same functionality of 'twitter_upload', while 199 | also tweeting the message with a link. 200 | """ 201 | twitter_upload_and_post = bind_method( 202 | path = 'uploadAndPost', 203 | method = 'POST', 204 | payload_type = 'json', 205 | response_type = 'json', 206 | allowed_params = [ 207 | ('username', basestring), 208 | ('password', basestring), 209 | ('media', (basestring, list)), 210 | ('message', basestring), 211 | ('body', basestring), 212 | ('source', basestring), 213 | ('sourceLink', basestring)] 214 | ) 215 | 216 | -------------------------------------------------------------------------------- /posterous/bind.py: -------------------------------------------------------------------------------- 1 | # Copyright: 2 | # Copyright (c) 2010, Benjamin Reitzammer , 3 | # All rights reserved. 4 | # 5 | # License: 6 | # This program is free software. You can distribute/modify this program under 7 | # the terms of the Apache License Version 2.0 available at 8 | # http://www.apache.org/licenses/LICENSE-2.0.txt 9 | 10 | import urllib 11 | import urllib2 12 | from datetime import datetime 13 | from base64 import b64encode 14 | 15 | from posterous.utils import enc_utf8_str 16 | 17 | 18 | def bind_method(**options): 19 | 20 | class APIMethod(object): 21 | # Get the options for the api method 22 | path = options['path'] 23 | payload_type = options.get('payload_type', None) 24 | payload_list = options.get('payload_list', False) 25 | response_type = options.get('response_type', 'xml') 26 | allowed_param = options.get('allowed_param', []) 27 | method = options.get('method', 'GET') 28 | require_auth = options.get('require_auth', False) 29 | 30 | def __init__(self, api, args, kwargs): 31 | # If the method requires authentication and no credentials 32 | # are provided, throw an error 33 | if self.require_auth and not (api.username and api.password): 34 | raise Exception('Authentication is required!') 35 | 36 | self.api = api 37 | self.headers = kwargs.pop('headers', {}) 38 | self.api_url = api.host + api.api_root 39 | self._build_parameters(args, kwargs) 40 | 41 | def _build_parameters(self, args, kwargs): 42 | self.parameters = [] 43 | 44 | args = list(args) 45 | args.reverse() 46 | 47 | for name, p_type in self.allowed_param: 48 | value = None 49 | if args: 50 | value = args.pop() 51 | 52 | if name in kwargs: 53 | if not value: 54 | value = kwargs.pop(name) 55 | else: 56 | raise TypeError('Multiple values for parameter %s supplied!' % name) 57 | if not value: 58 | continue 59 | 60 | if not isinstance(p_type, tuple): 61 | p_type = (p_type,) 62 | 63 | self._check_type(value, p_type, name) 64 | self._set_param(name, value) 65 | 66 | def _check_type(self, value, p_type, name): 67 | """ 68 | Throws a TypeError exception if the value type is not in the p_type tuple. 69 | """ 70 | if not isinstance(value, p_type): 71 | raise TypeError('The value passed for parameter %s is not valid! It must be one of these: %s' % (name, p_type)) 72 | 73 | if isinstance(value, list): 74 | for val in value: 75 | if isinstance(val, list) or not isinstance(val, p_type): 76 | raise TypeError('A value passed for parameter %s is not valid. It must be one of these: %s' % (name, p_type)) 77 | 78 | def _set_param(self, name, value): 79 | """Do appropriate type casts and utf-8 encode the parameter values""" 80 | if isinstance(value, bool): 81 | value = int(value) 82 | 83 | elif isinstance(value, datetime): 84 | value = '%s +0000' % value.strftime('%a, %d %b %Y %H:%M:%S').split('.')[0] 85 | 86 | elif isinstance(value, list): 87 | for val in value: 88 | self.parameters.append(('%s[]' % name, enc_utf8_str(val))) 89 | return 90 | 91 | self.parameters.append((name, enc_utf8_str(value))) 92 | 93 | def execute(self): 94 | # Build request URL 95 | url = self.api_url + '/' + self.path 96 | 97 | # Apply authentication if required 98 | if self.api.username and self.api.password: 99 | auth = b64encode('%s:%s' % (self.api.username, self.api.password)) 100 | self.headers['Authorization'] = 'Basic %s' % auth 101 | 102 | # Encode the parameters 103 | post_data = None 104 | if self.method == 'POST': 105 | post_data = urllib.urlencode(self.parameters) 106 | elif self.method == 'GET' and self.parameters: 107 | url = '%s?%s' % (url, urllib.urlencode(self.parameters)) 108 | 109 | # Make the request 110 | try: 111 | request = urllib2.Request(url, post_data, self.headers) 112 | resp = urllib2.urlopen(request) 113 | except Exception, e: 114 | # TODO: do better parsing of errors 115 | raise Exception('Failed to send request: %s' % e) 116 | 117 | return self.api.parser.parse(self, resp.read()) 118 | 119 | 120 | def _call(api, *args, **kwargs): 121 | method = APIMethod(api, args, kwargs) 122 | return method.execute() 123 | 124 | return _call 125 | 126 | -------------------------------------------------------------------------------- /posterous/error.py: -------------------------------------------------------------------------------- 1 | # Copyright: 2 | # Copyright (c) 2010, Benjamin Reitzammer , 3 | # All rights reserved. 4 | # 5 | # License: 6 | # This program is free software. You can distribute/modify this program under 7 | # the terms of the Apache License Version 2.0 available at 8 | # http://www.apache.org/licenses/LICENSE-2.0.txt 9 | 10 | class PosterousError(Exception): 11 | """Posterous exception""" 12 | def __init__(self, error, code=None): 13 | self.message = error 14 | self.error_code = code 15 | 16 | def __str__(self): 17 | return '(%s) %s' % (self.error_code, self.message) 18 | 19 | -------------------------------------------------------------------------------- /posterous/models.py: -------------------------------------------------------------------------------- 1 | # Copyright: 2 | # Copyright (c) 2010, Benjamin Reitzammer , 3 | # All rights reserved. 4 | # 5 | # License: 6 | # This program is free software. You can distribute/modify this program under 7 | # the terms of the Apache License Version 2.0 available at 8 | # http://www.apache.org/licenses/LICENSE-2.0.txt 9 | 10 | from posterous.utils import parse_datetime 11 | 12 | 13 | class Model(object): 14 | """ Base class """ 15 | def __init__(self, api=None): 16 | self._api = api 17 | 18 | @classmethod 19 | def parse(self, api, json): 20 | if isinstance(json, list): 21 | return self.parse_list(api, json) 22 | else: 23 | return self.parse_obj(api, json) 24 | 25 | @classmethod 26 | def parse_list(self, api, json_list): 27 | results = list() 28 | for obj in json_list: 29 | results.append(self.parse_obj(api, obj)) 30 | return results 31 | 32 | 33 | class Post(Model): 34 | @classmethod 35 | def parse_obj(self, api, json): 36 | post = self(api) 37 | for k, v in json.iteritems(): 38 | if k == 'media': 39 | setattr(post, k, Media.parse(api, v)) 40 | elif k == 'comments': 41 | setattr(post, k, Comment.parse(api, v)) 42 | else: 43 | setattr(post, k, v) 44 | return post 45 | 46 | def update(self, *args, **kwargs): 47 | return self._api.update_post(self.id, *args, **kwargs) 48 | 49 | def new_comment(self, *args, **kwargs): 50 | return self._api.new_comment(self.id, *args, **kwargs) 51 | 52 | 53 | class Site(Model): 54 | @classmethod 55 | def parse_obj(self, api, json): 56 | site = self(api) 57 | for k, v in json.iteritems(): 58 | setattr(site, k, v) 59 | return site 60 | 61 | def read_posts(self, **kwargs): 62 | return self._api.read_posts(self.id, **kwargs) 63 | 64 | def new_post(self, *args, **kwargs): 65 | return self._api.new_post(self.id, *args, **kwargs) 66 | 67 | def tags(self): 68 | return self._api.get_tags(self.id) 69 | 70 | 71 | class Comment(Model): 72 | @classmethod 73 | def parse_obj(self, api, json): 74 | comment = self(api) 75 | for k, v in json.iteritems(): 76 | setattr(comment, k, v) 77 | return comment 78 | 79 | 80 | class Tag(Model): 81 | @classmethod 82 | def parse_obj(self, api, json): 83 | tag = self(api) 84 | for k, v in json.iteritems(): 85 | setattr(tag, k, v) 86 | return tag 87 | 88 | def __str__(self): 89 | try: 90 | return self.tag_string 91 | except AttributeError: 92 | return '' 93 | 94 | 95 | class Media(Model): 96 | @classmethod 97 | def parse_obj(self, api, json, obj=None): 98 | # attributes from the medium tag are set on original Media object. 99 | media = obj or self(api) 100 | for k, v in json.iteritems(): 101 | if k == 'medium': 102 | Media.parse_obj(api, v, media) 103 | elif k == 'thumb': 104 | setattr(media, k, Media.parse_obj(api, v)) 105 | else: 106 | setattr(media, k, v) 107 | return media 108 | 109 | def download(self): 110 | # TODO: download file 111 | pass 112 | 113 | 114 | class JSONModel(Model): 115 | @classmethod 116 | def parse_obj(self, api, json): 117 | return json 118 | 119 | 120 | class ModelFactory(object): 121 | """ 122 | Used by parsers for creating instances of models. 123 | """ 124 | post = Post 125 | site = Site 126 | comment = Comment 127 | tag = Tag 128 | media = Media 129 | json = JSONModel 130 | 131 | """Used to cast response tags to the correct type""" 132 | attribute_map = { 133 | ('id', 'views', 'count', 'filesize', 'height', 'width', 'commentscount', 134 | 'num_posts'): int, 135 | ('private', 'commentsenabled', 'primary'): lambda v: v.lower() == 'true', 136 | ('date'): lambda v: parse_datetime(v) 137 | } 138 | 139 | -------------------------------------------------------------------------------- /posterous/parsers.py: -------------------------------------------------------------------------------- 1 | # Copyright: 2 | # Copyright (c) 2010, Benjamin Reitzammer , 3 | # All rights reserved. 4 | # 5 | # License: 6 | # This program is free software. You can distribute/modify this program under 7 | # the terms of the Apache License Version 2.0 available at 8 | # http://www.apache.org/licenses/LICENSE-2.0.txt 9 | 10 | import xml.etree.cElementTree as ET 11 | 12 | from posterous.models import ModelFactory, attribute_map 13 | from posterous.utils import import_simplejson 14 | from posterous.error import PosterousError 15 | 16 | 17 | def set_type(name, value): 18 | """Sets the value to the appropriate type.""" 19 | for names in attribute_map: 20 | if name in names: 21 | return attribute_map.get(names)(value) 22 | # most likely a string 23 | return value 24 | 25 | 26 | class XMLDict(dict): 27 | """ 28 | Traverses the XML tree recursively and builds an object 29 | representation of each element. Element attributes are not 30 | read since they don't appear in any current Posterous API 31 | response. Returns a dictionary of objects. 32 | 33 | Modified from: http://code.activestate.com/recipes/410469/ 34 | """ 35 | def __init__(self, parent_element): 36 | childrenNames = list((child.tag for child in parent_element)) 37 | 38 | for element in parent_element: 39 | tag = element.tag.lower() 40 | if element: 41 | if len(element) == 1 or element[0].tag != element[1].tag: 42 | # we assume that if the first two tags in a series are 43 | # different, then they are all different. 44 | aDict = XMLDict(element) 45 | else: 46 | # treat like list 47 | aDict = {element[0].tag.lower(): XMLList(element)} 48 | 49 | if childrenNames.count(tag) > 1: 50 | # there are multiple siblings with this tag, so they 51 | # must be grouped together 52 | try: 53 | # move this element's dict under the first sibling 54 | self[tag].append(aDict) 55 | except KeyError: 56 | # the first for this tag 57 | self.update({tag: [aDict]}) 58 | else: 59 | self.update({tag: aDict}) 60 | else: 61 | # finally, if there are no child tags, extract the text 62 | value = set_type(tag, element.text.strip()) 63 | if childrenNames.count(tag) > 1: 64 | # there are multiple instances of this tag, so they 65 | # must be grouped together 66 | try: 67 | # append this tags text to the tag's matching list 68 | self[tag].append(value) 69 | except KeyError: 70 | # the first for this tag 71 | self.update({tag: [value]}) 72 | else: 73 | self.update({tag: value}) 74 | 75 | 76 | class XMLList(list): 77 | """ 78 | Similar to the XMLDict class; traverses a list of element 79 | siblings and creates a list of their values. 80 | 81 | Modified from: http://code.activestate.com/recipes/410469/ 82 | """ 83 | def __init__(self, aList): 84 | for element in aList: 85 | if element: 86 | if len(element) == 1 or element[0].tag != element[1].tag: 87 | self.append(XMLDict(element)) 88 | else: 89 | self.append(XMLList(element)) 90 | elif element.text: 91 | text = set_type(element.tag.lower(), element.text.strip()) 92 | if text: 93 | self.append(text) 94 | 95 | 96 | class XMLParser(object): 97 | def __init__(self): 98 | pass 99 | 100 | def parse(self, method, payload): 101 | """Parses the XML payload and returns a dict of objects""" 102 | root = ET.XML(payload) 103 | 104 | if root.tag != 'rsp': 105 | raise PosterousError('XML response is missing the status tag! ' \ 106 | 'The response may be malformed.') 107 | 108 | # Verify that the response was successful before parsing 109 | if root.get('stat') == 'fail': 110 | error = root[0] 111 | self.parse_error(error) 112 | else: 113 | # There are nesting inconsistencies in the response XML 114 | # with some tags appearing below the payload model element. 115 | # This is a problem when the payload_type is _not_ a list. 116 | # If the root has multiple children, all siblings of the first 117 | # child will be moved under said child. 118 | if not method.payload_list and len(root) > 1: 119 | for node in root[1:]: 120 | root[0].append(node) 121 | root.remove(node) 122 | 123 | if method.payload_list: 124 | # A list of results is expected 125 | result = [] 126 | for node in root: 127 | result.append(XMLDict(node)) 128 | else: 129 | # Move to the first child before parsing the tree 130 | result = XMLDict(root[0]) 131 | 132 | # Make sure the values are formatted properly 133 | return self.cleanup(result) 134 | 135 | def parse_error(self, error): 136 | raise PosterousError(error.get('msg'), error.get('code')) 137 | 138 | def cleanup(self, output): 139 | def clean(obj): 140 | if 'comment' in obj: 141 | comments = obj['comment'] 142 | del obj['comment'] 143 | # make it a list 144 | if not isinstance(comments, list): 145 | comments = [comments] 146 | obj['comments'] = comments 147 | 148 | if 'media' in obj: 149 | # make it a list 150 | if not isinstance(obj['media'], list): 151 | obj['media'] = [obj['media']] 152 | return obj 153 | 154 | if isinstance(output, list): 155 | output = list((clean(obj) for obj in output)) 156 | else: 157 | output = clean(output) 158 | 159 | return output 160 | 161 | 162 | class ModelParser(object): 163 | """Used for parsing a method response into a model object.""" 164 | 165 | def __init__(self, model_factory=None): 166 | self.model_factory = model_factory or ModelFactory 167 | 168 | def parse(self, method, payload): 169 | # Get the appropriate model for this payload 170 | try: 171 | if method.payload_type is None: 172 | return 173 | model = getattr(self.model_factory, method.payload_type) 174 | except AttributeError: 175 | raise Exception('No model for this payload type: %s' % 176 | method.payload_type) 177 | 178 | # The payload XML must be parsed into a dict of objects before 179 | # being used in the model. 180 | if method.response_type == 'xml': 181 | xml_parser = XMLParser() 182 | data = xml_parser.parse(method, payload) 183 | else: 184 | raise NotImplementedError 185 | 186 | return model.parse(method.api, data) 187 | 188 | -------------------------------------------------------------------------------- /posterous/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright: 2 | # Copyright (c) 2010, Benjamin Reitzammer , 3 | # All rights reserved. 4 | # 5 | # License: 6 | # This program is free software. You can distribute/modify this program under 7 | # the terms of the Apache License Version 2.0 available at 8 | # http://www.apache.org/licenses/LICENSE-2.0.txt 9 | 10 | from datetime import datetime, timedelta 11 | import locale 12 | import time 13 | 14 | 15 | def parse_datetime(time_string): 16 | # Set locale for date parsing 17 | utc_offset_str = time_string[-6:].strip() 18 | sign = 1 19 | 20 | if utc_offset_str[0] == '-': 21 | sign = -1 22 | utc_offset_str = utc_offset_str[1:5] 23 | 24 | utcoffset = sign * timedelta(hours=int(utc_offset_str[0:2]), 25 | minutes=int(utc_offset_str[2:4])) 26 | 27 | return datetime.strptime(time_string[:-6], '%a, %d %b %Y %H:%M:%S') - utcoffset 28 | 29 | def strip_dict(d): 30 | """Returns a new dictionary with keys that had a value""" 31 | ret = {} 32 | for k, v in d.items(): 33 | if v: ret[k] = v 34 | return ret 35 | 36 | def enc_utf8_str(arg): 37 | """ Convenience func for encoding a value into a utf8 string """ 38 | # written by Michael Norton (http://docondev.blogspot.com/) 39 | if isinstance(arg, unicode): 40 | arg = arg.encode('utf-8') 41 | elif not isinstance(arg, str): 42 | arg = str(arg) 43 | return arg 44 | 45 | def import_simplejson(): 46 | try: 47 | import simplejson as json 48 | except ImportError: 49 | try: 50 | # they may have django 51 | from django.utils import simplejson as json 52 | except ImportError: 53 | raise ImportError, "Can't load a json library" 54 | return json 55 | -------------------------------------------------------------------------------- /scripts/backup-posterous.py: -------------------------------------------------------------------------------- 1 | """ 2 | needs python 2.6 3 | """ 4 | 5 | from __future__ import with_statement 6 | from optparse import OptionParser 7 | import logging 8 | import datetime 9 | import os, os.path 10 | import re 11 | import sys 12 | import urlparse, urllib 13 | import simplejson 14 | 15 | from posterous.api import Posterous 16 | 17 | 18 | class JsonDateEncoder(simplejson.JSONEncoder): 19 | def default(self, o): 20 | try: 21 | if o and type(o) == datetime.datetime: 22 | return str(o).encode('utf8') 23 | except TypeError: 24 | pass 25 | return simplejson.JSONEncoder.default(self, o) 26 | 27 | 28 | if __name__ == '__main__': 29 | """ 30 | Create a folder structure where 31 | /{options.folder} 32 | /{site.hostname} 33 | site-{site.hostname}.json 34 | {post-slug}.json <-- contains body & comments & everything else 35 | {post-slug}_media{num} 36 | """ 37 | 38 | batch_sz = 50 # default (and current api max) 39 | opt_parser = OptionParser() 40 | 41 | opt_parser.add_option("-u", "--username", dest="username", 42 | help="Email address associated with posterous account") 43 | 44 | opt_parser.add_option("-p", "--password", dest="password", 45 | help="Password associated with posterous account") 46 | 47 | opt_parser.add_option("-f", "--folder", dest="folder", default="backup", 48 | help="Folder to store backup data in (Beware, if it exists, " \ 49 | "data may be overwritten). Defaults to backup/") 50 | 51 | opt_parser.add_option("-s", "--site-id", type="int", dest="site_id", 52 | help="Only query site with this id") 53 | 54 | opt_parser.add_option("-b", "--batch-size", type="int", dest="batch_size", 55 | default=batch_sz, help="The number of posts to get per API call. " \ 56 | "Default is %d" % batch_sz) 57 | 58 | opt_parser.add_option("-d", "--debug", dest="debug", action="store_true", 59 | default=False, help="Debug output") 60 | 61 | opt_parser.add_option("-v", "--verbose", dest="verbose", 62 | action="store_true", default=False, help="Verbose output (overrides -d)") 63 | 64 | opt_parser.add_option("-q", "--quiet", dest="quiet", action="store_true", 65 | default=True, help="Quiet output (overrides -v and -d)") 66 | 67 | # get the command line args 68 | (options, args) = opt_parser.parse_args() 69 | 70 | if options.debug: 71 | logging.basicConfig(level=logging.DEBUG) 72 | elif options.verbose: 73 | logging.basicConfig(level=logging.INFO) 74 | elif options.quiet: 75 | logging.basicConfig(level=logging.WARNING) 76 | 77 | if not options.username or not options.password: 78 | print "You must provide a username and password.\n" 79 | opt_parser.print_help() 80 | sys.exit() 81 | 82 | # Make the API calls and parse the data 83 | posterous = Posterous(options.username, options.password) 84 | 85 | for site in posterous.get_sites(): 86 | if options.site_id and options.site_id != site.id: 87 | continue 88 | 89 | site_folder = os.path.join(options.folder, site.hostname) 90 | logging.info("Creating folder '%s' for site '%s'" % (site_folder, site.id)) 91 | if not os.path.exists(site_folder): 92 | os.makedirs(site_folder) 93 | 94 | # create a private folder in case they have private posts 95 | private_folder = os.path.join(site_folder, "private") 96 | if not os.path.exists(private_folder): 97 | os.mkdir(private_folder) 98 | 99 | site_file = os.path.join(site_folder, 'site-%s.json' % site.hostname) 100 | logging.debug(u"Opening file '%s' for site '%s' (%s)" % 101 | (site_file, site.hostname, site.id)) 102 | 103 | with open(site_file, 'w+') as sf: 104 | simplejson.dump(site, sf, cls=JsonDateEncoder) 105 | 106 | rem = 2 if site.num_posts % options.batch_size > 0 else 1 107 | page_numbers = range(1, int(site.num_posts/options.batch_size) + rem) 108 | 109 | for page in page_numbers: 110 | logging.info("Retrieving page %s of %s with %s posts per page" % 111 | (page, len(page_numbers), options.batch_size)) 112 | 113 | for p in posterous.get_posts(site_id=site.id, page_num=page, 114 | num_posts=options.batch_size): 115 | post_slug = re.sub(r'^/', '', urlparse.urlparse(p.link).path) 116 | post_file = os.path.join(site_folder, '%s.json' % post_slug) 117 | 118 | logging.debug(u"Opening file '%s' for post '%s'" % 119 | (post_file, p.title)) 120 | 121 | with open(post_file, 'w+') as f: 122 | simplejson.dump(p, f, cls=JsonDateEncoder) 123 | 124 | # save the media from each post 125 | for i, m in enumerate(p.media): 126 | u = m.medium_url if hasattr(m, 'medium_url') else m.url 127 | media_type = re.search(r'\.(\w+)$', u).group(1) 128 | media_file = os.path.join(site_folder, '%s_%s.%s' % 129 | (post_slug, i, media_type)) 130 | logging.debug("Getting media for post '%s' from url '%s'" % 131 | (p.title, u)) 132 | 133 | urllib.urlretrieve(u, media_file) 134 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | #from distutils.core import setup 3 | from setuptools import setup, find_packages 4 | 5 | setup( 6 | name="posterous-python", 7 | version="1.0", 8 | description="Posterous API library for python", 9 | author="Benjamin Reitzammer, Michael Campagnaro", 10 | author_email="benjamin@squeakyvessel.com, mikecampo@gmail.com", 11 | url="http://github.com/nureineide/posterous-python", 12 | license="http://www.apache.org/licenses/LICENSE-2.0", 13 | packages = find_packages(), 14 | keywords= "posterous api library") 15 | -------------------------------------------------------------------------------- /tests/posts.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | http://post.ly/abc123 5 | http://sachin.posterous.com/brunch-in-san-francisco 6 | Brunch in San Francisco 7 | 55 8 | What a great brunch! 9 | Sun, 03 May 2009 19:58:58 -0800 10 | 0 11 | false 12 | sachin agarwal 13 | http://debug2.posterous.com/user_profile_pics/16071/Picture_1_thumb.png 14 | true 15 | 16 | image 17 | 18 | http://posterous.com/getfile/files.posterous.com/sachin/DIptatiCkiv/IMG_0477.scaled500.jpg 19 | 47 20 | 333 21 | 500 22 | 23 | 24 | http://posterous.com/getfile/files.posterous.com/sachin/DIptatiCkiv/IMG_0477.thumb.jpg 25 | 5 26 | 36 27 | 36 28 | 29 | 30 | 31 | audio 32 | http://posterous.com/getfile/files.posterous.com/sachin/DIptatiCkiv/sheila.mp3 33 | 10116 34 | Smashing Pumpkins 35 | Adore 36 | To Sheila 37 | 38 | 39 | video 40 | http://posterous.com/getfile/files.posterous.com/sachin/DIptatiCkiv/movie.avi 41 | 6537 42 | http://posterous.com/getfile/files.posterous.com/sachin/DIptatiCkiv/movie.png 43 | http://posterous.com/getfile/files.posterous.com/sachin/DIptatiCkiv/movie.flv 44 | http://posterous.com/getfile/files.posterous.com/sachin/DIptatiCkiv/movie.mp4 45 | 46 | 1 47 | 48 | This is a comment 49 | Thu, 04 Jun 2009 01:33:43 -0800 50 | sachin 51 | http://debug2.posterous.com/user_profile_pics/16071/Picture_1_thumb.png 52 | 53 | 54 | 55 | http://post.ly/xxxx 56 | http://nureineide.posterous.com/touchtable 57 | Touchtable 58 | 10529618 59 | http://nuigroup.com/log/the_unituio_project/#When:11:45:00Z
Wird Zeit einen Tisch zu haben :)]]> 60 | Mon, 25 Jan 2010 00:00:20 -0800 61 | 66 62 | false 63 | true 64 | 2 65 | 66 | 67 | Mon, 25 Jan 2010 01:07:33 -0800 68 | Benjamin 69 | http://files.posterous.com/user_profile_pics/242405/head9_thumb.jpg 70 | 71 | 72 | 73 | Mon, 25 Jan 2010 01:48:52 -0800 74 | test 75 | 76 |
77 | 78 | http://post.ly/KR6f 79 | http://nureineide.posterous.com/original-art-mauricio-anzeri-collections-from 80 | Original Art: Mauricio Anzeri - collections from the last place \t 81 | 10537108 82 | \r\r

Stunning!

]]> 83 | Mon, 25 Jan 2010 03:23:32 -0800 84 | 62 85 | false 86 | Benjamin 87 | http://files.posterous.com/user_profile_pics/242405/head9_thumb.jpg 88 | true 89 | 0 90 |
91 | 92 | http://post.ly/NHro 93 | http://nureineide.posterous.com/hardgraft-did-you-know-the-3fold-has-been-fea 94 | @hardgraft Did you know the 3Fold has been featured in the latest FastCompany issue? (correct link this time) 95 | 11502888 96 |

via tweetie
]]> 97 | Thu, 11 Feb 2010 00:52:22 -0800 98 | 51 99 | false 100 | Benjamin 101 | http://files.posterous.com/user_profile_pics/242405/head9_thumb.jpg 102 | true 103 | 104 | image 105 | 106 | http://posterous.com/getfile/files.posterous.com/nureineide/kxviCrDsuwFxzzHqkdEAnFJjcJHGoekJEaiBaumxtegClIAlkznCzcbnzhat/image.jpg.scaled500.jpg 107 | 69 108 | 667 109 | 500 110 | 111 | 112 | http://posterous.com/getfile/files.posterous.com/nureineide/kxviCrDsuwFxzzHqkdEAnFJjcJHGoekJEaiBaumxtegClIAlkznCzcbnzhat/image.jpg.thumb.jpg 113 | 1 114 | 36 115 | 36 116 | 117 | 118 | 0 119 |
120 |
121 | -------------------------------------------------------------------------------- /tests/sites.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1 4 | Sachin Agarwal's Posterous 5 | http://sachin.posterous.com 6 | sachin 7 | false 8 | true 9 | true 10 | 50 11 | 12 | 13 | 2 14 | Agarwal's Posterous 15 | http://agarwal.posterous.com 16 | agarwal 17 | true 18 | false 19 | true 20 | 40 21 | 22 | -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | 2 | import sys 3 | sys.path.append("..") 4 | 5 | from datetime import datetime 6 | import os.path 7 | from posterous.api import * 8 | 9 | 10 | def get_file_name(n): 11 | return os.path.join(os.path.dirname( os.path.realpath( __file__ ) ), n) 12 | 13 | 14 | def test_sites_xml_parser(): 15 | with open(get_file_name('sites.xml')) as f: 16 | sites = posterous.parse_sites_xml(f.read()) 17 | 18 | assert len(sites) == 2 19 | assert sites[0].name == "Sachin Agarwal's Posterous" 20 | assert sites[0].hostname == 'sachin' 21 | assert sites[0].url == 'http://sachin.posterous.com' 22 | assert sites[0].id == 1 23 | assert sites[0].private == False 24 | assert sites[0].primary == True 25 | assert sites[0].commentsenabled == True 26 | assert sites[0].num_posts == 50 27 | 28 | 29 | def test_post_xml_parser(): 30 | with open(get_file_name('posts.xml')) as f: 31 | posts = posterous.parse_posts_xml(f.read()) 32 | 33 | assert len(posts) == 4, "List length is %s" % len(posts) 34 | p = posts[0] 35 | assert p.url == 'http://post.ly/abc123' 36 | assert p.title == 'Brunch in San Francisco' 37 | assert p.id == 55 38 | assert p.views == 0 39 | assert p.body == "What a great brunch!" 40 | assert p.private == False 41 | assert p.commentsenabled == True 42 | assert p.link == 'http://sachin.posterous.com/brunch-in-san-francisco' 43 | assert p.authorpic == 'http://debug2.posterous.com/user_profile_pics/16071/Picture_1_thumb.png' 44 | assert p.date == posterous.parse_date('Sun, 03 May 2009 19:58:58 -0800') 45 | 46 | assert len(p.comments) == 1 47 | assert p.comments[0].body == 'This is a comment' 48 | assert p.comments[0].author == 'sachin' 49 | assert p.comments[0].date == posterous.parse_date('Thu, 04 Jun 2009 01:33:43 -0800') 50 | 51 | assert len(p.media) == 3 52 | img = p.media[0] 53 | aud = p.media[1] 54 | vid = p.media[2] 55 | 56 | assert img.medium_filesize == 47 57 | assert img.medium_url == 'http://posterous.com/getfile/files.posterous.com/sachin/DIptatiCkiv/IMG_0477.scaled500.jpg' 58 | assert img.medium_width == 500 59 | assert img.medium_height == 333 60 | assert img.thumb_url == 'http://posterous.com/getfile/files.posterous.com/sachin/DIptatiCkiv/IMG_0477.thumb.jpg' 61 | assert img.thumb_filesize == 5 62 | assert img.thumb_height == 36 63 | assert img.thumb_width == 36 64 | 65 | assert aud.url == "http://posterous.com/getfile/files.posterous.com/sachin/DIptatiCkiv/sheila.mp3" 66 | assert aud.filesize == 10116 67 | assert aud.artist == 'Smashing Pumpkins' 68 | assert aud.album == 'Adore' 69 | assert aud.song == "To Sheila" 70 | 71 | assert vid.url == "http://posterous.com/getfile/files.posterous.com/sachin/DIptatiCkiv/movie.avi" 72 | assert vid.filesize == 6537 73 | assert vid.thumb == "http://posterous.com/getfile/files.posterous.com/sachin/DIptatiCkiv/movie.png" 74 | assert vid.flv == "http://posterous.com/getfile/files.posterous.com/sachin/DIptatiCkiv/movie.flv" 75 | assert vid.mp4 == "http://posterous.com/getfile/files.posterous.com/sachin/DIptatiCkiv/movie.mp4" 76 | 77 | 78 | --------------------------------------------------------------------------------