├── .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 |
--------------------------------------------------------------------------------