# HG changeset patch # User k0s # Date 1252786017 14400 # Node ID e3823be6a423ba885635ec824604f5bedc043988 initial commit of bitsyblog, from https://svn.openplans.org/svn/standalone/bitsyblog/trunk/ diff -r 000000000000 -r e3823be6a423 FAQ.txt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/FAQ.txt Sat Sep 12 16:06:57 2009 -0400 @@ -0,0 +1,54 @@ +Meet bitsyblog: bitsyblog doesn't do much, but it could do less + +Why yet another blogging app? Because I wanted to blog from +the command line and emacs and stubbornly decided it was easier +to write a new blog than to write a front-end script for +wordpress + +No, really! In my cursory survey of blogging software, there +wasn't anything out there that seemed RESTful and minimalist. +I don't want a blog that does much; I want to throw content (a +little or much) on the web quickly and in the way I want to do +it. I also dislike that most blogging software seems monolithic. +From the "less is better" school of thought, I tend to believe +that a piece of software should do one thing and one thing well + +What does bitsyblog do? +* blogging. Only blogging +* (...and setting user preferences) +* (...and auth snuck in their too) + +What doesn't bitsyblog do? +* commenting +* tagging +* uploading/managing files +* turn all your text emoticons to animated gifs +* all of these could be done with WSGI middleware + +Isn't WSGI middleware just another way of building a monolith? Not if its +done right. If your app is dependent on middleware, or vice +versa, then yes. Commenting on a blog post is the same as commmenting +on a wiki page. + +But what if the user wants to delete their blog post? +Should the comments get deleted as well? This points to a +possible coupling between the app and the client. The solution +of course is to not allow users to delete blog posts. +(Incidentally, using HTTP's DELETE could be used to solve this. Too bad this +isn't doable in today's browsers) + +So how does bitsyblog work? +* webob and paste are used to process and serve HTTP +* restructured text is used for blog formatting +* auth is built on paste.auth + +What is next for bitsyblog? Feeds and some cleanup and little details. +Then middleware (probably commenting first). + +What is up with the mice? I caught them eating cheese in the cheeseshop, +so I put them to work in my logo. + +How do I get bitsyblog? Go to http://bitsyblog.biz/ +Also, its on the cheeseshop + +Your blog probably doesn't do much ... but could it do less? diff -r 000000000000 -r e3823be6a423 README.txt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/README.txt Sat Sep 12 16:06:57 2009 -0400 @@ -0,0 +1,124 @@ +bitsyblog +========= + +*bitsyblog doesn't do much, but it could do less* + + +Why another blog? +----------------- + +My ideal blog would invoke my favorite editor, take a bunch of text, and +throw it on the web. Sometimes I like to write long elaborate posts. +Othertimes I just want to make a quick note. + +Meet `bitsyblog `_, +a tiny tiny `python `_ +`weblog `_. +Posting is done with a POST request, so while you can use +a web form to do this, its just as easy to use curl, urllib, or anything else +to post. + + +How does it work? +----------------- + +A user URLs is like + +http://bitsyblog.biz/k0s + +k0s is my user name here . If you are 1337h4x0r, this will be +http://bitsyblog.biz/1337h4x0r . +Posting to this will take the body of the POST request and add a date stamp +Blog entries are thrown in files and are displayed with markup +available with `restructured text +`_. + +You can also get a more specific range of posts +by specifying up to the year, month +and day in the URL: + +http://bitsyblog.biz/k0s/2008/2/1 + +Not all of this needs to be specified. + +Permalinks are also available in the form of the date stamp: + +http://bitsyblog.biz/k0s/20080201141502 + +You can find the permalink by clicking on the subject of the blog post. + +If you really want to post through the web, support is at + +http://bitsyblog.biz/k0s/post + +If you're more friendly with python scripts, +`blogme.py `_ +is available: +https://svn.openplans.org/svn/standalone/bitsyblog/trunk/blogme.py + + +Get me a blog! +-------------- + +Create an account at http://bitsyblog.biz/join . All you need is a +username and password (and I threw a +`CAPTCHA `_ in there at some +point). +Then...you're ready to blog! The auth is a minimal thing +I threw together out of `paste.auth `_. + +Once you're signed in, you'll notice the navigation links at the top +of the page have changed. You can now post and change your preferences. + +In your preferences, you can change the date format and set the +subject format of your blog posts. You can also upload +verifiable CSS to theme your blog. For the date format, I have patched +`dateutil.parser `_ +to return the format string that the date was originally in and hope +that my changes can make it back to the source sometime. + +When posting, you have the option to make your post 'public' (everyone +can see it), 'secret' (only your friends can see it), 'private' (only +you can see it. Friends are settable in your preferences. + + +What bitsyblog doesn't do +------------------------- + +* Commenting: this should done with +`WSGI middlware +`_. +There's nothing specific about commenting on a blog post that is any +different from commenting on a paragraph in (for instance) a wiki article. + +* Tagging: again, this should be done with middleware + +* Hosting files: Its a blog, not a file repo! Any markup doable with +`restructured text `_ +is doable with bitsyblog, but images, videos, whatever must +be held off-site. + + +What is next for bitsyblog? +--------------------------- + +Other than that, its a pretty small project. No templates and +currently about 700 lines of code. (I'll get it back to 500, I +swear). bitsyblog is designed as a personal blog that should be strong +in both workflow as well as "niceness" of code. + +I'm guessing your blog doesn't do much...but could it do less? + + +bitsyblog is built on top of `paste `_ +and `webob `_. You'll need the +trunk version of paste for a change made to +`paste.auth.auth_tkt +`_ +in order to have cookies work correctly (r7261). + +Thanks to `The Open Planning Project `_ +and my friends there for making this possible. + +Please email jhammel at openplans dot org with any questions. + diff -r 000000000000 -r e3823be6a423 TODO.txt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/TODO.txt Sat Sep 12 16:06:57 2009 -0400 @@ -0,0 +1,45 @@ +TODO +---- + +* DELETE posts (since browsers don't delete, maybe a POST request to + /k0s/20080302221048 might have to be used) + +* DELETE css files + +* User preference: number of links to display in nav by default + +* How many posts to display by default at '/%(user)' ? + +* it would be nice if the viewed user's blog was added to the site nav + +* years, months in a user's nav + +* method for getting blog post title + +* smarter permalinks; should not use host_url + +* blog entries now record the user so methods should not need the user + to be passed in with the blog entry (code cleanup) + +* single user bitsyblog -> BitsierBlog + +* PUT to blog should edit (/k0s/20080302221048/edit should front end + this, which renders the post form but with the text already inside + and a datestamp specified) + +* markup.form should have different layouts + +* markup.form should (optionally?) use the Settings for validation, etc. + +* better atom feeds? + +* simple CAPTCHA auth for ephemeral users + +* code cleanup (the eternal chore) + +* middleware: +** CSS zen garden (samadhi) +** commenting +** tagging + +>>> this is 2.0 diff -r 000000000000 -r e3823be6a423 bitsyblog.dia Binary file bitsyblog.dia has changed diff -r 000000000000 -r e3823be6a423 bitsyblog/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/bitsyblog/__init__.py Sat Sep 12 16:06:57 2009 -0400 @@ -0,0 +1,1 @@ +# diff -r 000000000000 -r e3823be6a423 bitsyblog/bitsyauth.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/bitsyblog/bitsyauth.py Sat Sep 12 16:06:57 2009 -0400 @@ -0,0 +1,368 @@ +import markup +import random +import re +import sys + +from cStringIO import StringIO +from markup.form import Form +from paste.auth import basic, cookie, digest, form, multi, auth_tkt +from webob import Request, Response, exc + +try: + from skimpyGimpy import skimpyAPI + CAPTCHA = True +except ImportError: + CAPTCHA = False + +dictionary_file = '/usr/share/dict/american-english' + +def random_word(): + """generate a random word for CAPTCHA auth""" + min_length = 5 # minimum word length + if not globals().has_key('dictionary'): + # read the dictionary -- this may be platform dependent + # XXX could use a backup dictionary + _dictionary = file(dictionary_file).readlines() + _dictionary = [ i.strip() for i in _dictionary ] + _dictionary = [ i.lower() for i in _dictionary + if i.isalpha() and i > min_length ] + globals()['dictionary'] = _dictionary + return random.Random().choice(dictionary) + +class BitsyAuthInnerWare(object): + """inner auth; does login checking""" + + def __init__(self, app, passwords, newuser=None, site=None, realm=None): + """a simple reimplementation of auth + * app: the WSGI app to be wrapped + * passwords: callable that return a dictionary of {'user': 'password'} + * newuser: callable to make a new user, taking name + pw + * site: name of the site + * realm: realm for HTTP digest authentication + """ + + self.app = app + self.passwords = passwords + self.site = site or '' + self.realm = realm or self.site + self.captcha = True + self.redirect_to = '/' # redirect to site root + self.urls = { 'login': '/login', 'join': '/join', } + self.keys = {} # keys, words for CAPTCHA request + + self.content_type = { 'image_captcha': 'image/png', + 'wav_captcha': 'audio/wav' } + + if newuser: + self.newuser = newuser + else: + self.urls.pop('join') # don't do joining + + # WSGI app securely wrapped + self.wrapped_app = self.security_wrapper() + + if not CAPTCHA: + self.captcha = False + + ### WSGI/HTTP layer + + def __call__(self, environ, start_response): + + self.request = Request(environ) + self.request.path_info = self.request.path_info.rstrip('/') + + # URLs intrinsic to BitsyAuth + if self.request.path_info == '/logout': + response = self.redirect() + return response(self.request.environ, start_response) + + if self.request.path_info in self.url_lookup(): + response = self.make_response() + return response(self.request.environ, start_response) + + # digest auth + if self.request.headers.has_key('Authorization'): + return self.wrapped_app(self.request.environ, start_response) + + response = self.request.get_response(self.app) + # respond to 401s + if response.status_int == 401: # Unauthorized + if self.request.environ.get('REMOTE_USER'): + return exc.HTTPForbidden() + else: + response = self.request.get_response(self.wrapped_app) + + user = self.request.environ.get('REMOTE_USER') + if user: + self.request.environ['paste.auth_tkt.set_user'](user) + + return response(self.request.environ, start_response) + + ### authentication function + + def digest_authfunc(self, environ, realm, user): + return self.passwords()[user] # passwords stored in m5 digest + + def authfunc(self, environ, user, password): + return self.hash(user, password) == self.passwords()[user] + + def hash(self, user, password): + # use md5 digest for now + return digest.digest_password(self.realm, user, password) + + def security_wrapper(self): + """return the app securely wrapped""" + + multi_auth = multi.MultiHandler(self.app) + + # digest authentication + multi_auth.add_method('digest', digest.middleware, + self.realm, self.digest_authfunc) + multi_auth.set_query_argument('digest', key='auth') + + # form authentication + template = self.login(wrap=True, action='%s') + multi_auth.add_method('form', form.middleware, self.authfunc, + template=template) + multi_auth.set_default('form') + + return multi_auth + + # might have to wrap cookie.middleware(BitsyAuth(multi(app))) ::shrug:: + return cookie.middleware(multi_auth) + + ### methods dealing with intrinsic URLs + + def url_lookup(self): + retval = dict([ (value, key) for key, value + in self.urls.items() ]) + if self.captcha: + retval.update(dict([(('/join/%s.png' % key), 'image_captcha') + for key in self.keys])) + return retval + + def get_response(self, text, content_type='text/html'): + res = Response(content_type=content_type, body=text) + res.content_length = len(res.body) + return res + + def make_response(self): + url_lookup = self.url_lookup() + path = self.request.path_info + assert path in url_lookup + + # login and join shouldn't be accessible when logged in + if self.request.environ.get('REMOTE_USER'): + return self.redirect("You are already logged in") + + handler = url_lookup[path] + function = getattr(self, handler) + + if self.request.method == 'GET': + # XXX could/should do this with decorators + return self.get_response(function(wrap=True), + content_type=self.content_type.get(handler,'text/html')) + if self.request.method == 'POST': + post_func = getattr(self, handler + "_post") + errors = post_func() + if errors: + return self.get_response(function(errors=errors, wrap=True)) + else: + return self.redirect("Welcome!") + + def redirect(self, message=''): + """redirect from instrinsic urls""" + return exc.HTTPSeeOther(message, location=self.redirect_to) + + def image_captcha(self, wrap=True): + """return data for the image""" + key = self.request.path_info.split('/join/')[-1] + key = int(key.split('.png')[0]) + return skimpyAPI.Png(self.keys[key], scale=3.0).data() + + ### forms and their display methods + + ### login + + def login_form(self, referer=None, action=None): + if action is None: + action = self.urls['login'] + form = Form(action=action, submit='Login') + form.add_element('textfield', 'Name', input_name='username') + form.add_element('password', 'Password', input_name='password') + if referer is not None: + form.add_element('hidden', 'referer', value=referer) + return form + + def login(self, errors=None, wrap=False, action=None): + """login div""" + form = self.login_form(action=action) + join = self.urls.get('join') + retval = form(errors) + if join: + retval += '
\n' + markup.a('join', href="%s" % join) + retval = markup.div(retval) + if wrap: + title = 'login' + if self.site: + pagetitle = '%s - %s' % (title, self.site) + retval = markup.wrap(markup.h1(title.title()) + retval, + pagetitle=pagetitle) + + return retval + + def login_post(self): + """handle a login POST request""" + user = self.request.POST.get('username') + password = self.request.POST.get('password') + passwords = self.passwords() + error = False + if user not in passwords: + error = True + else: + error = not self.authfunc(self.request.environ, user, password) + if error: + return { 'Name': 'Wrong username or password' } + self.request.environ['REMOTE_USER'] = user + self.request.environ['paste.auth_tkt.set_user'](user) + + ### join + + def captcha_pre(self, word, key): + """CAPTCHA with pre-formatted text""" + return skimpyAPI.Pre(word, scale=1.2).data() + + def captcha_png(self, word, key): + """CAPTCHA with a PNG image""" + return markup.image('/join/%s.png' % key) + + def join_form(self): + captcha = '' + if self.captcha: + # data for CAPTCHA + key = random.Random().randint(0, sys.maxint) + word = random_word() + + self.keys[key] = word + + captcha = StringIO() + + captcha_text = "Please type the word below so I know you're not a computer:" + captcha_help = "(please %s if the page is unreadable)" % markup.link('/join?captcha=image', 'go here') + + print >> captcha, markup.p('%s
%s' % (captcha_text, + markup.i(captcha_help))) + + # determine type of CAPTCHA + captchas = ' '.join(self.request.GET.getall('captcha')) + if not captchas: + captchas = 'pre' + + captcha_funcs=dict(pre=self.captcha_pre, + image=self.captcha_png,) + captchas = [ captcha_funcs[i](word, key) for i in captchas.split() + if i in captcha_funcs ] + captchas = '\n'.join([markup.p(i) for i in captchas]) + print >> captcha, captchas + + print >> captcha, markup.p(markup.input(None, **dict(name='captcha', type='text'))) + + captcha = captcha.getvalue() + + form = Form(action=self.urls['join'], submit='Join', post_html=captcha) + form.add_element('textfield', 'Name') + form.add_password_confirmation() + form.add_element('hidden', 'key', value=str(key)) + return form + + def join(self, errors=None, wrap=False): + """join div or page if wrap""" + form = self.join_form() + retval = markup.div(form(errors)) + if wrap: + pagetitle = title = 'join' + if self.site: + pagetitle = '%s - %s' % (title, self.site) + if self.captcha: + errors = errors or {} + captcha_err = errors.get('CAPTCHA', '') + if captcha_err: + captcha_err = markup.p(markup.em(captcha_err), + **{'class': 'error'}) + retval = markup.wrap(markup.h1(title.title()) + captcha_err + retval, + pagetitle=pagetitle) + return retval + + def join_post(self): + """handle a join POST request""" + form = self.join_form() + errors = form.validate(self.request.POST) + + # validate captcha + if CAPTCHA: + key = self.request.POST.get('key') + try: + key = int(key) + except ValueError: + key = None + if not key: + errors['CAPTCHA'] = 'Please type the funky looking word' + word = self.keys.pop(key, None) + if not word: + errors['CAPTCHA'] = 'Please type the funky looking word' + if word != self.request.POST.get('captcha','').lower(): + errors['CAPTCHA'] = 'Sorry, you typed the wrong word' + + name = self.request.POST.get('Name', '') + if not name: + if not errors.has_key('Name'): + errors['Name'] = [] + errors['Name'].append('Please enter a user name') + if name in self.passwords(): + if not errors.has_key('Name'): + errors['Name'] = [] + errors['Name'].append('The name %s is already taken' % name) + + if not errors: # create a new user + self.newuser(name, + self.hash(name, self.request.POST['Password'])) + self.request.environ['REMOTE_USER'] = name # login the new user + self.request.environ['paste.auth_tkt.set_user'](name) + + return errors + +class BitsyAuth(object): + """outer middleware for auth; does the cookie handling and wrapping""" + + def __init__(self, app, global_conf, passwords, newuser, site='', secret='secret'): + self.app = app + self.path = '/logout' + self.cookie = '__ac' + auth = BitsyAuthInnerWare(app, passwords=passwords, newuser=newuser, site=site) + self.hash = auth.hash + # paste.auth.cookie + # self.cookie_handler = cookie.middleware(auth, cookie_name=self.cookie, timeout=90) # minutes + + # paste.auth.auth_tkt + self.cookie_handler = auth_tkt.make_auth_tkt_middleware(auth, global_conf, secret, cookie_name=self.cookie, logout_path='/logout') + + def __call__(self, environ, start_response): + if environ['PATH_INFO'] == '/logout': + pass + return self.cookie_handler(environ, start_response) + + def logout(self, environ): + req = Request(environ) + keys = [ 'REMOTE_USER' ] + # keys = [ 'REMOTE_USER', 'AUTH_TYPE', 'paste.auth.cookie', 'paste.cookies', 'HTTP_COOKIE' ] # XXX zealous kill + for key in keys: + req.environ.pop(key, None) + + body = 'logoutlogout' + res = Response(content_type='text/html', body=body) + res.content_length = len(res.body) + req.cookies.pop(self.cookie, None) + res.delete_cookie(self.cookie) + res.unset_cookie(self.cookie) + return res(environ, start_response) + diff -r 000000000000 -r e3823be6a423 bitsyblog/bitsyblog.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/bitsyblog/bitsyblog.py Sat Sep 12 16:06:57 2009 -0400 @@ -0,0 +1,863 @@ +""" +a tiny tiny blog. +this is the view class and is more bitsyblog than anything +else can claim to be +""" + +### global variables + +# who can view which blog posts +roles = { 'public': ( 'public', ), + 'friend': ( 'public', 'secret' ), + 'author': ( 'public', 'secret', 'private' ), } + +### imports + +import dateutil.parser # XXX separate, for now +import parser # bitsyblog dateutil parser + +import cgi +import datetime +import docutils +import docutils.core +import inspect +import markup +import os +import PyRSS2Gen +import re +import utils + +from blog import FileBlog +from docutils.utils import SystemMessage +from lxml import etree +from user import FilespaceUsers +from markup.form import Form +from cStringIO import StringIO +from webob import Request, Response, exc + +### exceptions + +class BlogPathException(Exception): + """exception when trying to retrieve the blog""" + +### the main course + +class BitsyBlog(object): + """a very tiny blog""" + + ### class level variables + defaults = { 'date_format': '%H:%M %F', + 'file_dir': os.path.dirname(__file__), + 'subject': '[ %(date)s ]:', + 'n_links': 5, # number of links for navigation + 'site_name': 'bitsyblog', + 'help_file': None + } + + def __init__(self, **kw): + for key in self.defaults: + setattr(self, key, kw.get(key, self.defaults[key])) + self.n_links = int(self.n_links) # could be a string from the .ini + self.response_functions = { 'GET': self.get, + 'POST': self.post, + 'PUT': self.put + } + + # abstract attributes + self.users = FilespaceUsers(self.file_dir) + self.blog = FileBlog(self.file_dir) + self.cooker = self.restructuredText + + if hasattr(self, 'help_file') and os.path.exists(self.help_file): + help = file(self.help_file).read() + self.help = docutils.core.publish_string(help, + writer_name='html', + settings_overrides={'report_level': 5}) + + + # for BitsyAuth + self.newuser = self.users.new + + ### methods dealing with HTTP + + def __call__(self, environ, start_response): + self.request = Request(environ) + res = self.make_response(self.request.method) + return res(environ, start_response) + + def make_response(self, method): + return self.response_functions.get(method, self.error)() + + def get_response(self, text, content_type='text/html'): + res = Response(content_type=content_type, body=text) + res.content_length = len(res.body) + return res + + def get_index(self): + """returns material pertaining to the root of the site""" + path = self.request.path_info.strip('/') + + n_links = self.number_of_links() + + ### the front page + if not path: + return self.get_response(self.index(n_links)) + + ### feeds + + n_posts = self.number_of_posts() + + # site rss + if path == 'rss': + if n_posts is None: + n_posts = 10 + return self.get_response(self.site_rss(n_posts), content_type='text/xml') + + # site atom + if path == 'atom': + if n_posts is None: + n_posts = 10 + return self.get_response(self.atom(self.blog.latest(self.users.users(), n_posts)), content_type='text/xml') + + ### help + if path == 'help' and hasattr(self, 'help'): + return self.get_response(self.help) + + ### static files + + # site.css + if path == 'css/site.css': + css_file = os.path.join(self.file_dir, 'site.css') + return self.get_response(file(css_file).read(), content_type='text/css') + + # logo + if path == 'bitsyblog.png': + logo = os.path.join(self.file_dir, 'bitsyblog.png') + return self.get_response(file(logo, 'rb').read(), content_type='image/png') + + def get_user_space(self, user, path): + self.request.user = self.users[user] # user whose blog is viewed + check = self.check_user(user) # is this the authenticated user? + + feed = None # not an rss/atom feed by default (flag) + n_posts = self.number_of_posts(user) + + # special paths + if path == [ 'post' ]: + if check is not None: + return check + return self.get_response(self.form_post(user)) + + if path == [ 'preferences' ]: + if check is not None: + return check + return self.get_response(self.preferences(user)) + + if path == [ 'rss' ]: + feed = 'rss' + path = [] + if n_posts is None: + n_posts = 10 # TODO: allow to be configurable + + if path == [ 'atom' ]: + feed = 'atom' + path = [] + if n_posts is None: + n_posts = 10 # TODO: allow to be configurable + + if len(path) == 2: + if path[0] == 'css': + for i in self.request.user.settings['CSS']: + # find the right CSS file + if i['filename'] == path[1]: + return self.get_response(i['css'], content_type='text/css') + else: + return exc.HTTPNotFound('CSS "%s" not found' % path[1]) + + role = self.role(user) + + # get the blog + try: + blog = self.get_blog(user, path, role, n_items=n_posts) + except BlogPathException, e: + return exc.HTTPNotFound(str(e)) + except exc.HTTPException, e: + return e.wsgi_response + + if feed == 'rss': + content = self.rss(user, blog) # XXX different signatures + return self.get_response(content, content_type='text/xml') + + if feed == 'atom': + content = self.atom(blog, user) # XXX different signatures + return self.get_response(content, content_type='text/xml') + + # reverse the blog if necessary + if self.request.GET.get('sort') == 'forward': + blog = list(reversed(blog)) + + n_links = self.number_of_links(user) + # don't display navigation for short blogs + if len(blog) < 2: + n_links = 0 + + # write the blog + content = self.write_blog(user, blog, self.request.path_info, n_links) + + # return the content + return self.get_response(content) + + def get(self): + """ + display the blog or respond to a get request + """ + # front matter of the site + index = self.get_index() + if index is not None: + return index + + ### user space + user, path = self.userpath() + if user not in self.users: + return exc.HTTPNotFound("No blog found for %s" % user) + return self.get_user_space(user, path) + + def post(self): + """ + write a blog entry and other POST requests + """ + # TODO: posting to '/' ? + + # find user + path + user, path = self.userpath() + + if user not in self.users: + return exc.HTTPNotFound("No blog found for %s" % user) + self.request.user = self.users[user] + + check = self.check_user(user) + if check is not None: + return check + + if len(path): + if path == [ 'preferences' ]: + + # make the data look like we want + settings = {} + settings['Date format'] = self.request.POST.get('Date format') + settings['Subject'] = '%(date)s'.join((self.request.POST['Subject-0'], self.request.POST['Subject-2'])) + settings['Stylesheet'] = self.request.POST['Stylesheet'] + settings['CSS file'] = self.request.POST.get('CSS file') + settings['Friends'] = ', '.join(self.request.POST.getall('Friends')) + + errors = self.users.write_settings(user, **settings) + if errors: # re-display form with errors + return self.get_response(self.preferences(user, errors)) + + return self.get_response(self.preferences(user, message='Changes saved')) + elif len(path) == 1 and self.isentry(path[0]): + entry = self.blog.entry(user, path[0], roles['author']) + if entry is None: + return exc.HTTPNotFound("Blog entry %s not found %s" % path[0]) + privacy = self.request.POST.get('privacy') + datestamp = entry.datestamp() + if privacy: + self.blog.delete(user, datestamp) + self.blog.post(user, datestamp, entry.body, privacy) + return exc.HTTPSeeOther("Settings updated", location='/%s/%s' % (user, datestamp)) + else: + return exc.HTTPMethodNotAllowed("Not sure what you're trying to do") + + # get the body of the post + body = self.request.body + body = self.request.POST.get('form-post', body) + body = body.strip() + if not body: + return exc.HTTPSeeOther("Your post has no content! No blog for you", + location='/%s' % self.user_url(user, 'post')) + + # determine if the post is secret or private + privacy = self.request.GET.get('privacy') or self.request.POST.get('privacy') or 'public' + + # write the file + now = utils.datestamp(datetime.datetime.now()) + location = "/%s" % self.user_url(user, now) + self.blog.post(user, now, body, privacy) + + # point the user at the post + return exc.HTTPSeeOther("Post blogged by bitsy", location=location) + + def put(self): + """ + PUT several blog entries from a file + """ + + # find user + path + user, path = self.user() + + if user not in self.users.users(): + return exc.HTTPNotFound("No blog found for %s" % user) + + if len(path): + return exc.HTTPMethodNotAllowed("Not sure what you're trying to do") + + # find the dates + entries in the file + regex = '\[.*\]:' + entries = re.split(regex, self.request.body)[1:] + dates = [ date.strip().strip(':').strip('[]').strip() + for date in re.findall(regex, self.request.body) ] + dates = [ dateutil.parser.parse(date) for date in dates ] + + # write to the blog + for i in range(len(entries)): + datestamp = utils.datestamp(dates[i]) + self.blog.post(user, datestamp, entries[i], 'public') + + return exc.HTTPOk("%s posts blogged by bitsy" % len(entries)) + + + def error(self): + """deal with non-supported methods""" + methods = ', '.join(self.response_functions.keys()[:1]) + methods += ' and %s' % self.response_functions.keys()[-1] + return exc.HTTPMethodNotAllowed("Only %s operations are allowed" % methods) + + ### auth functions + + def passwords(self): + return self.users.passwords() + + def authenticated(self): + """return authenticated user""" + return self.request.environ.get('REMOTE_USER') + + def check_user(self, user): + """ + determine authenticated user + returns None on success + """ + authenticated = self.authenticated() + if authenticated is None: + return exc.HTTPUnauthorized('Unauthorized') + elif user != authenticated: + return exc.HTTPForbidden("Forbidden") + + def role(self, user): + """ + determine what role the authenticated member has + with respect to the user + """ + auth = self.authenticated() + if not auth: + return 'public' + if auth == user: + return 'author' + else: + if auth in self.request.user.settings['Friends']: + return 'friend' + else: + return 'public' + + ### user methods + + def userpath(self): + """user who's blog one is viewing""" + path = self.request.path_info.strip('/').split('/') + name = path[0] + path = path[1:] + if not name: + name = None + return name, path + + ### date methods + + def isentry(self, string): # TODO -> blog.py + return (len(string) == len(''.join(utils.timeformat))) and string.isdigit() + + def user_url(self, user, *args, **kw): + permalink = kw.get('permalink') + if permalink: + _args = [ self.request.host_url, user ] + else: + _args = [ user ] + _args.extend(args) + return '/'.join([str(arg) for arg in _args]) + + def permalink(self, blogentry): + return self.user_url(blogentry.user, blogentry.datestamp(), permalink=True) + + def entry_subject(self, blogentry): + if hasattr(self.request, 'user') and self.request.user.name == blogentry.user: + prefs = self.request.user.settings + else: + prefs = self.users[blogentry.user].settings + subject = prefs.get('Subject', self.subject) + date_format = prefs.get('Date format', self.date_format) + return subject % { 'date': blogentry.date.strftime(date_format) } + + def mangledurl(self, blogentry): + return self.user_url(blogentry.user, 'x%x' % (int(blogentry.datestamp()) * self.users.secret(blogentry.user)), permalink=True) + + def unmangleurl(self, url, user): + url = url.strip('x') + try: + value = int(url, 16) + except ValueError: + return None + + # XXX once one has a mangled url, one can obtain the secret + value /= self.users.secret(user) + + entry = str(value) + if self.isentry(entry): + return self.blog.entry(user, entry, ['public', 'secret', 'private']) + + ### blog retrival methods + + def number_of_posts(self, user=None): + """return the number of blog posts to display""" + # determine number of posts to display (None -> all) + n_posts = self.request.GET.get('posts', None) + if n_posts is not None: + try: + n_posts = int(n_posts) + if n_links > 0 and n_links > n_posts: + n_links = n_posts # don't display more links than posts + except (TypeError, ValueError): + n_posts = None + return n_posts + + def number_of_links(self, user=None): + """return the number of links to display in the navigation""" + # determine number of navigation links to display + n_links = self.request.GET.get('n') + if n_links == 'all': + return -1 + try: + n_links = int(n_links) + except (TypeError, ValueError): + n_links = self.n_links + return n_links + + def get_blog(self, user, path, role='public', n_items=None): + """retrieve the blog entry based on the path""" + + notfound = "blog entry not found" + + # get permissions + allowed = roles[role] + + # entire blog + if not path: + return self.blog(user, allowed, n_items) + + # mangled urls + if len(path) == 1 and path[0].startswith('x'): + entry = self.unmangleurl(path[0], user) + if entry: + return [ entry ] + else: + raise BlogPathException(notfound) + + # individual blog entry + if (len(path) == 1) and self.isentry(path[0]): + blog = self.blog.entry(user, path[0], allowed) + if not blog: + raise BlogPathException(notfound) + return [ blog ] + + # parse the path into a date path + n_date_vals = 3 # year, month, date + if len(path) > n_date_vals: + raise BlogPathException(notfound) + + # ensure the path conforms to expected values (ints): + try: + [ int(i) for i in path] + except ValueError: + raise BlogPathException(notfound) + + # get the blog + return self.blog.entries(user, allowed, *path) + + ### methods that write HTML + + def render(self, body, title=None, feedtitle=None): + """layout the page in a unified way""" + stylesheets = () + user = getattr(self.request, 'user', None) + _title = [ self.site_name ] + if user: + stylesheets = self.request.user['CSS'] + stylesheets = [ (("/%s" % self.user_url(user.name, 'css', css['filename'])), + css['name']) for css in stylesheets ] + _title.insert(0, self.request.user.name) + else: + stylesheets = (("/css/site.css", "Default"),) + + if title: + _title.insert(0, title) + + title = ' - '.join(_title) + head_markup = () + if feedtitle: + head_markup = ( '' % feedtitle, + '' % feedtitle,) + return markup.wrap(self.site_nav()+body, title, stylesheets, head_markup=head_markup) + + def site_nav(self): + """returns HTML for site navigation""" + links = [('/',), ] + user = self.authenticated() + if user: + links.extend([('/%s' % user, user), + ('/%s/post' % user, 'post'), + ('/%s/preferences' % user, 'preferences'), + ('/logout', 'logout')]) + else: + links.extend([('/login', 'login'), ('/join', 'join')]) + + if hasattr(self, 'help'): + links.append(('/help', 'help')) + + links = [ markup.link(*i) for i in links ] + return markup.listify(links, ordered=False, **{ 'class': 'site-nav'}) + + def index(self, n_links): + retval = StringIO() + print >> retval, '

bitsyblog

' + + # get the blogs + blogs = {} + for user in self.users: + blog = self.blog(user, ('public',), n_links) + if blog: + blogs[user] = blog + users = blogs.keys() + + # display latest active user first + users.sort(key=lambda user: blogs[user][0].date, reverse=True) + + # display users' blogs + for user in users: + print >> retval, '
' % user + print >> retval, '%s' % (user, user) + blog = blogs[user] + print >> retval, self.navigation(user, blog, '/%s' % user, n_links) + print >> retval, '
' + + return self.render(retval.getvalue(), feedtitle=self.site_name) + + def navigation(self, user, blog, path, n_links, n_char=80): + prefs = self.users[user].settings + + if n_links == 0 or not len(blog): + return '' + retval = StringIO() + print >> retval, '' + return retval.getvalue() + + def blog_entry(self, user, entry): + """given the content string, return a marked-up blog entry""" + # XXX no need to pass user + + # user preferences + prefs = self.request.user.settings + format = prefs.get('Date format', self.date_format) + subject = prefs.get('Subject', self.subject) + + role = self.role(user) + + subject = subject % { 'date' : entry.date.strftime(format) } + subject = cgi.escape(subject) + html = StringIO() + id = entry.datestamp() + print >> html, '
' % id + print >> html, '' % id + print >> html, '
' + print >> html, '%s' % (self.user_url(user, id), subject) + if (entry.privacy == 'secret') and (role == 'friend'): + print >> html, 'secret' + print >> html, '
' + print >> html, self.cooker(entry.body) + + if role == 'author': + print >> html, '
' % self.user_url(entry.user, id) + print >> html, self.privacy_settings(entry.privacy) + print >> html, '' + print >> html, '
' + if entry.privacy != 'public': + title = "You can give this URL so people may see this %s post without logging in" % entry.privacy + print >> html, '
' + print >> html, 'Mangled URL:' % title + print >> html, markup.link(self.mangledurl(entry)) + print >> html, '
' + + print >> html, '
' + return html.getvalue() + + def write_blog(self, user, blog, path, n_links): + """return the user's blog in HTML""" + # XXX no need to pass path or user! + retval = StringIO() + print >> retval, self.navigation(user, blog, path, n_links, 0) + for entry in blog: + print >> retval, self.blog_entry(user, entry) + feedtitle=None + if self.request.path_info.strip('/') == user: + feedtitle = "%s's blog" % user + title = None + if len(blog) == 1: + format = self.request.user.settings.get('Date format', self.date_format) + title = blog[0].date.strftime(format) + return self.render(retval.getvalue(), title=title, feedtitle=feedtitle) + + def restructuredText(self, string): + origstring = string + settings = { 'report_level': 5 } + string = string.strip() + try: + + parts = docutils.core.publish_parts(string.strip(), + writer_name='html', + settings_overrides=settings) + body = parts['body'] + except SystemMessage, e: + lines = [ cgi.escape(i.strip()) for i in string.split('\n') ] + body = '
\n'.join(lines) + + + retval = '
%s
' % body + return retval + + # this should be reenabled if 'system-message's again appear in the markup + try: + foo = etree.fromstring(retval) + except etree.XMLSyntaxError: + return retval + # should cleanup the
+ for i in foo.getiterator(): + if dict(i.items()).get('class') == 'system-message': + i.clear() + + return etree.tostring(foo) + + ### feeds + + def site_rss(self, n_items=10): + blog = self.blog.latest(list(self.users.users()), n_items) + title = self.site_name + ' - rss' + link = self.request.host_url # XXX should be smarter + description = "latest scribblings on %s" % self.site_name + lastBuildDate = datetime.datetime.now() + items = [ self.rss_item(entry.user, entry) for entry in blog ] + rss = PyRSS2Gen.RSS2(title=title, + link=link, + description=description, + lastBuildDate=lastBuildDate, + items=items) + return rss.to_xml() + + def rss(self, user, blog): + """ + rss feed for a user's blog + done with PyRSS2Gen: + http://www.dalkescientific.com/Python/PyRSS2Gen.html + """ + title = "%s's blog" % user + link = os.path.split(self.request.url)[0] + description = "latest blog entries for %s on %s" % (user, self.site_name) + lastBuildDate = datetime.datetime.now() # not sure what this means + + items = [ self.rss_item(user, entry) for entry in blog ] + rss = PyRSS2Gen.RSS2(title=title, + link=link, + description=description, + lastBuildDate=lastBuildDate, + items=items) + return rss.to_xml() + + def rss_item(self, user, entry): + if hasattr(self.request, 'user') and self.request.user.name == user: + prefs = self.request.user.settings + else: + prefs = self.users[user].settings + subject = prefs.get('Subject', self.subject) + date_format = prefs.get('Date format', self.date_format) + title = entry.title() + return PyRSS2Gen.RSSItem(title=title, #subject % { 'date': entry.date.strftime(date_format) }, + link=self.permalink(entry), + description=unicode(entry.body, errors='replace'), + author=user, + guid=PyRSS2Gen.Guid(self.permalink(entry)), + pubDate=entry.date) + + + def atom(self, blog, author=None): + retval = StringIO() + print >> retval, """ + +""" + if author: + title = "%s's blog" % author + link = self.request.host_url + '/' + author + else: + title = self.site_name + ' - atom' + link = self.request.host_url + + date = blog[0].date.isoformat() + + print >> retval, '%s' % title + print >> retval, '' % link + print >> retval, '%s' % date + if author: + print >> retval, """ + + %s + """ % author + + for entry in blog: + print >> retval, '' + print >> retval, '%s' % cgi.escape(entry.title()) + print >> retval, '' % self.permalink(entry) + print >> retval, '%s' % entry.date.isoformat() + print >> retval, '%s' % cgi.escape(entry.body) + + print >> retval, '' + + print >> retval, '' + return retval.getvalue() + + ### forms and accompanying display + + def form_post(self, user): + retval = StringIO() + print >> retval, '
' % self.user_url(user) + print >> retval, '
' + print >> retval, self.privacy_settings() + print >> retval, '' + print >> retval, '
' + return self.render(retval.getvalue()) + + def preferences_form(self, user): + prefs = self.request.user.settings + form = Form() + + # date format + format = prefs.get('Date format', self.date_format) + value = datetime.datetime.now().strftime(format) + form.add_element('textfield', 'Date format', value=value, + help='how to display dates in your blog post subject') + + # subject + subject = prefs.get('Subject', self.subject) + subject = subject.split('%(date)s', 1) + func = lambda name: value + form.add_elements('Subject', + ['textfield', func, 'textfield' ], + [ (), (), ()], # this is horrible! + [ dict(value=subject[0]), + {}, + dict(value=subject[1]) ], + help='how to display the subject line of your blog post' + ) + + # CSS files + css_files = [ i['name'] for i in prefs['CSS'] ] + form.add_element('menu', 'Stylesheet', css_files, + help='which CSS file should be the default') + + # or upload a CSS file + form.add_element('file_upload', 'CSS file', + help='upload a CSS file to theme your webpage') + + # Friends -- can see secret posts + users = [ i for i in list(self.users.users()) + if i != user ] + if users: + users.sort(key=str.lower) + form.add_element('checkboxes', 'Friends', + users, prefs.get('Friends', set()), + help='friends can see your secret posts') + + return form + + def preferences(self, user, errors=None, message=None): + """user preferences form""" + body = self.preferences_form(user)(errors) + if message: + body = '%s\n%s' % ( markup.p(markup.strong(message)), body ) + return self.render(body, title='preferences') + + def privacy_settings(self, default='public'): + """HTML snippet for privacy settings""" + settings = (('public', 'viewable to everyone'), + ('secret', 'viewable only to your friends'), + ('private', 'viewable only to you')) + form = Form() + return form.radiobuttons('privacy', settings, checked=default, joiner=' ') + +class BitsierBlog(BitsyBlog): + """single user version of bitsyblog""" + + def get(self): + ### user space + user, path = self.userpath() + if user not in self.users: + return exc.HTTPNotFound("No blog found for %s" % user) + + return self.get_user_space(user, path) + + def userpath(self): + path = self.request.path_info.strip('/').split('/') + if path == ['']: + path = [] + return self.user, path + + def user_url(self, user, *args, **kw): + permalink = kw.get('permalink') + if permalink: + _args = [ self.request.host_url ] + else: + _args = [ ] + _args.extend(args) + return '/'.join([str(arg) for arg in _args]) + + def passwords(self): + return { self.user: self.users.password(self.user) } + + def site_nav(self): + """returns HTML for site navigation""" + links = [('/', self.user), ] + user = self.authenticated() + if user: + links.extend([ + ('/post', 'post'), + ('/preferences', 'preferences'), + ('/logout', 'logout')]) + else: + links.append(('/login', 'login')) + + if hasattr(self, 'help'): + links.append(('/help', 'help')) + + links = [ markup.link(*i) for i in links ] + return markup.listify(links, ordered=False, **{ 'class': 'site-nav'}) diff -r 000000000000 -r e3823be6a423 bitsyblog/blog.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/bitsyblog/blog.py Sat Sep 12 16:06:57 2009 -0400 @@ -0,0 +1,155 @@ +"""blog interfaces to data for bitsy""" + +import os +import utils + +from cStringIO import StringIO +from glob import glob + +class BlogEntry(object): + """interface class for a blog entry""" + def __init__(self, date, body, privacy, user): + self.date = date + self.body = body + self.privacy = privacy + if user is not None: + self.user = user + + def title(self, characters=80): + if '\n' in self.body: + lines = [i.strip() for i in self.body[:characters].split('\n')] + if len(lines[0]) > characters: + return self.snippet(charachters) + if len(lines) > 1 and not lines[1]: + return lines[0] + return self.snippet(characters) + + def snippet(self, characters=80): + if characters: + if len(self.body) > characters: + + + text = ' '.join(self.body[:characters].split()[:-1]) + if '\n' in text: + lines = [ i.strip() for i in text.split('\n') ] + if '' in lines: + return '\n'.join(lines[:lines.index('')]) + + if text: + return '%s ...' % text + else: + return self.body + return '' + + def datestamp(self): + return utils.datestamp(self.date) + +class Blog(object): + """abstract class for a users' blog""" + + def __call__(self, user, permissions=('public',), number=None): + return self.blog(user, permissions, number=number) + + def latest(self, users, number): + """return the lastest entries""" + blog = [] + for user in users: + blog.extend(self.blog(user, ('public',), number)) + blog.sort(key=lambda entry: entry.date, reverse=True) + return blog[:number] + + # interfaces for subclasses + + def blog(self, user, permissions, number=None): + """ + return the user's blog sorted in reverse date order + if number is None, the entire blog is returned + """ + + def entry(self, user, datestamp, permissions): + """ + return a single blog entry with the given datestamp: + 'YYYYMMDDHHMMSS' + """ + + def entries(self, user, permissions, year=None, month=None, day=None): + """return entries by date""" + + + def post(self, user, date, text, privacy): + """post a new blog entry""" + + + def delete(self, user, datestamp): + """remove a blog entry""" + +class FileBlog(Blog): + """a blog that lives on the filesystem""" + + def __init__(self, directory): + self.directory = directory + + def location(self, user, permission, *path): + """returns which directory files are in based on permission""" + return os.path.join(self.directory, user, 'entries', permission, *path) + + def body(self, user, datestamp, permission): + return file(self.location(user, permission, datestamp)).read() + + def get_entry(self, user, datestamp, permission): + return BlogEntry(utils.date(datestamp), + self.body(user, datestamp, permission), + permission, user) + + ### interfaces from Blog + + def blog(self, user, permissions, number=None): + entries = [] + for permission in permissions: + entries.extend([ (entry, permission) + for entry in os.listdir(self.location(user, permission)) ]) + entries.sort(key=lambda x: x[0], reverse=True) + + if number is not None: + entries = entries[:number] + + return [ self.get_entry(user, x[0], x[1]) for x in entries ] + + def entry(self, user, datestamp, permissions): + for permission in permissions: + filename = self.location(user, permission, datestamp) + if os.path.exists(filename): + return self.get_entry(user, datestamp, permission) + + def entries(self, user, permissions, year=None, month=None, day=None): + + # build a file glob expression + dateargs = [ year, month, day, None ] + glob_expr = '' + for index in range(len(dateargs)): + value = dateargs[index] + if value is None: + break + length = len(utils.timeformat[index]) + glob_expr += '%0*d' % (length, int(value)) + while index < len(utils.timeformat): + glob_expr += '[0-9]' * len(utils.timeformat[index]) + index += 1 + + # get the blog entries + entries = [] + for permission in permissions: + entries.extend([ (os.path.split(entry)[-1], permission) + for entry in glob(os.path.join(self.location(user, permission), glob_expr)) ]) + entries.sort(key=lambda x: x[0], reverse=True) + return [ self.get_entry(user, x[0], x[1]) for x in entries ] + + def post(self, user, datestamp, body, privacy): + blog = file(self.location(user, privacy, datestamp), 'w') + print >> blog, body + + def delete(self, user, datestamp): + for permission in 'public', 'secret', 'private': + path = self.location(user, permission, datestamp) + if os.path.exists(path): + os.remove(path) diff -r 000000000000 -r e3823be6a423 bitsyblog/factory.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/bitsyblog/factory.py Sat Sep 12 16:06:57 2009 -0400 @@ -0,0 +1,36 @@ +from bitsyauth import BitsyAuth +from bitsyblog import BitsyBlog, BitsierBlog +from getpass import getpass +from paste.httpexceptions import HTTPExceptionHandler + +def factory(global_conf, **app_conf): + """make bitsyauth app and wrap it in middleware""" + + config = [ 'file_dir', 'date_format', 'subject', 'n_links', 'help_file' ] + key_str = 'bitsyblog.%s' + args = dict([ (key, app_conf[ key_str % key]) for key in config + if app_conf.has_key(key_str % key) ]) + + app = BitsyBlog(**args) + secret = app_conf.get('secret', 'secret') + return BitsyAuth(HTTPExceptionHandler(app), global_conf, app.passwords, app.newuser, 'bitsyblog', secret) + +# TODO: use wsgifilter.proxyapp.DebugHeaders to debug the headers apache +# doesn't like + +def bitsierfactory(global_conf, **app_conf): + """make single-user bitsyblog""" + config = [ 'file_dir', 'date_format', 'subject', 'n_links', 'help_file' ] + key_str = 'bitsyblog.%s' + args = dict([ (key, app_conf[ key_str % key]) for key in config + if app_conf.has_key(key_str % key) ]) + + user = app_conf['bitsyblog.user'] + app = BitsierBlog(**args) + app.user = user + secret = app_conf.get('secret', 'secret') + auth = BitsyAuth(HTTPExceptionHandler(app), global_conf, app.passwords, newuser=None, site='bitsyblog', secret=secret) + if not user in app.users: + pw = getpass('Enter password for %s: ' % user) + app.newuser(user, auth.hash(app.user, pw)) + return auth diff -r 000000000000 -r e3823be6a423 bitsyblog/parser.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/bitsyblog/parser.py Sat Sep 12 16:06:57 2009 -0400 @@ -0,0 +1,1001 @@ +# -*- coding:iso-8859-1 -*- +""" +Copyright (c) 2003-2005 Gustavo Niemeyer + +This module offers extensions to the standard python 2.3+ +datetime module. +""" +__author__ = "Gustavo Niemeyer " +__license__ = "PSF License" + +import os.path +import string +import sys +import time +import math + +import datetime +import dateutil.relativedelta as relativedelta +import dateutil.tz as tz + +__all__ = ["parse", "parserinfo"] + +# Some pointers: +# +# http://www.cl.cam.ac.uk/~mgk25/iso-time.html +# http://www.iso.ch/iso/en/prods-services/popstds/datesandtime.html +# http://www.w3.org/TR/NOTE-datetime +# http://ringmaster.arc.nasa.gov/tools/time_formats.html +# http://search.cpan.org/author/MUIR/Time-modules-2003.0211/lib/Time/ParseDate.pm +# http://stein.cshl.org/jade/distrib/docs/java.text.SimpleDateFormat.html + +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO + +class _timelex: + def __init__(self, instream): + if isinstance(instream, basestring): + instream = StringIO(instream) + self.instream = instream + self.wordchars = ('abcdfeghijklmnopqrstuvwxyz' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ_' + 'ßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ' + 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ') + self.numchars = '0123456789' + self.whitespace = ' \t\r\n' + self.charstack = [] + self.tokenstack = [] + self.eof = False + + def get_token(self): + if self.tokenstack: + return self.tokenstack.pop(0) + seenletters = False + token = None + state = None + wordchars = self.wordchars + numchars = self.numchars + whitespace = self.whitespace + while not self.eof: + if self.charstack: + nextchar = self.charstack.pop(0) + else: + nextchar = self.instream.read(1) + while nextchar == '\x00': + nextchar = self.instream.read(1) + if not nextchar: + self.eof = True + break + elif not state: + token = nextchar + if nextchar in wordchars: + state = 'a' + elif nextchar in numchars: + state = '0' + elif nextchar in whitespace: + token = ' ' + break # emit token + else: + break # emit token + elif state == 'a': + seenletters = True + if nextchar in wordchars: + token += nextchar + elif nextchar == '.': + token += nextchar + state = 'a.' + else: + self.charstack.append(nextchar) + break # emit token + elif state == '0': + if nextchar in numchars: + token += nextchar + elif nextchar == '.': + token += nextchar + state = '0.' + else: + self.charstack.append(nextchar) + break # emit token + elif state == 'a.': + seenletters = True + if nextchar == '.' or nextchar in wordchars: + token += nextchar + elif nextchar in numchars and token[-1] == '.': + token += nextchar + state = '0.' + else: + self.charstack.append(nextchar) + break # emit token + elif state == '0.': + if nextchar == '.' or nextchar in numchars: + token += nextchar + elif nextchar in wordchars and token[-1] == '.': + token += nextchar + state = 'a.' + else: + self.charstack.append(nextchar) + break # emit token + if (state in ('a.', '0.') and + (seenletters or token.count('.') > 1 or token[-1] == '.')): + l = token.split('.') + token = l[0] + for tok in l[1:]: + self.tokenstack.append('.') + if tok: + self.tokenstack.append(tok) + return token + + def __iter__(self): + return self + + def next(self): + token = self.get_token() + if token is None: + raise StopIteration + return token + + def split(cls, s): + return list(cls(s)) + split = classmethod(split) + +class _resultbase(object): + + def __init__(self): + for attr in self.__slots__: + setattr(self, attr, None) + + def _repr(self, classname): + l = [] + for attr in self.__slots__: + value = getattr(self, attr) + if value is not None: + l.append("%s=%s" % (attr, `value`)) + return "%s(%s)" % (classname, ", ".join(l)) + + def __repr__(self): + return self._repr(self.__class__.__name__) + +class parserinfo: + + # m from a.m/p.m, t from ISO T separator + JUMP = [" ", ".", ",", ";", "-", "/", "'", + "at", "on", "and", "ad", "m", "t", "of", + "st", "nd", "rd", "th"] + + WEEKDAYS = [("Mon", "Monday"), + ("Tue", "Tuesday"), + ("Wed", "Wednesday"), + ("Thu", "Thursday"), + ("Fri", "Friday"), + ("Sat", "Saturday"), + ("Sun", "Sunday")] + MONTHS = [("Jan", "January"), + ("Feb", "February"), + ("Mar", "March"), + ("Apr", "April"), + ("May", "May"), + ("Jun", "June"), + ("Jul", "July"), + ("Aug", "August"), + ("Sep", "September"), + ("Oct", "October"), + ("Nov", "November"), + ("Dec", "December")] + HMS = [("h", "hour", "hours"), + ("m", "minute", "minutes"), + ("s", "second", "seconds")] + AMPM = [("am", "a"), + ("pm", "p")] + UTCZONE = ["UTC", "GMT", "Z"] + PERTAIN = ["of"] + TZOFFSET = {} + + def __init__(self, dayfirst=False, yearfirst=False): + self._jump = self._convert(self.JUMP) + self._weekdays = self._convert(self.WEEKDAYS) + self._months = self._convert(self.MONTHS) + self._hms = self._convert(self.HMS) + self._ampm = self._convert(self.AMPM) + self._utczone = self._convert(self.UTCZONE) + self._pertain = self._convert(self.PERTAIN) + + self.dayfirst = dayfirst + self.yearfirst = yearfirst + + self._year = time.localtime().tm_year + self._century = self._year/100*100 + + def _convert(self, lst): + dct = {} + for i in range(len(lst)): + v = lst[i] + if isinstance(v, tuple): + for v in v: + dct[v.lower()] = i + else: + dct[v.lower()] = i + return dct + + def jump(self, name): + return name.lower() in self._jump + + def weekday(self, name): + if len(name) >= 3: + try: + return self._weekdays[name.lower()] + except KeyError: + pass + return None + + def month(self, name): + if len(name) >= 3: + try: + return self._months[name.lower()]+1 + except KeyError: + pass + return None + + def hms(self, name): + try: + return self._hms[name.lower()] + except KeyError: + return None + + def ampm(self, name): + try: + return self._ampm[name.lower()] + except KeyError: + return None + + def pertain(self, name): + return name.lower() in self._pertain + + def utczone(self, name): + return name.lower() in self._utczone + + def tzoffset(self, name): + if name in self._utczone: + return 0 + return self.TZOFFSET.get(name) + + def convertyear(self, year): + if year < 100: + year += self._century + if abs(year-self._year) >= 50: + if year < self._year: + year += 100 + else: + year -= 100 + return year + + def validate(self, res): + # move to info + if res.year is not None: + res.year = self.convertyear(res.year) + if res.tzoffset == 0 and not res.tzname or res.tzname == 'Z': + res.tzname = "UTC" + res.tzoffset = 0 + elif res.tzoffset != 0 and res.tzname and self.utczone(res.tzname): + res.tzoffset = 0 + return True + + +class parser: + + def __init__(self, info=parserinfo): + if issubclass(info, parserinfo): + self.info = parserinfo() + elif isinstance(info, parserinfo): + self.info = info + else: + raise TypeError, "Unsupported parserinfo type" + + def parse(self, timestr, default=None, + ignoretz=False, tzinfos=None, + **kwargs): + if not default: + default = datetime.datetime.now().replace(hour=0, minute=0, + second=0, microsecond=0) + res = self._parse(timestr, **kwargs) + if res is None: + raise ValueError, "unknown string format" + repl = {} + for attr in ["year", "month", "day", "hour", + "minute", "second", "microsecond"]: + value = getattr(res, attr) + if value is not None: + repl[attr] = value + ret = default.replace(**repl) + if res.weekday is not None and not res.day: + ret = ret+relativedelta.relativedelta(weekday=res.weekday) + if not ignoretz: + if callable(tzinfos) or tzinfos and res.tzname in tzinfos: + if callable(tzinfos): + tzdata = tzinfos(res.tzname, res.tzoffset) + else: + tzdata = tzinfos.get(res.tzname) + if isinstance(tzdata, datetime.tzinfo): + tzinfo = tzdata + elif isinstance(tzdata, basestring): + tzinfo = tz.tzstr(tzdata) + elif isinstance(tzdata, int): + tzinfo = tz.tzoffset(res.tzname, tzdata) + else: + raise ValueError, "offset must be tzinfo subclass, " \ + "tz string, or int offset" + ret = ret.replace(tzinfo=tzinfo) + elif res.tzname and res.tzname in time.tzname: + ret = ret.replace(tzinfo=tz.tzlocal()) + elif res.tzoffset == 0: + ret = ret.replace(tzinfo=tz.tzutc()) + elif res.tzoffset: + ret = ret.replace(tzinfo=tz.tzoffset(res.tzname, res.tzoffset)) + return ret + + class _result(_resultbase): + __slots__ = ["year", "month", "day", "weekday", + "hour", "minute", "second", "microsecond", + "tzname", "tzoffset"] + + def _parse(self, timestr, dayfirst=None, yearfirst=None, fuzzy=False): + info = self.info + if dayfirst is None: + dayfirst = info.dayfirst + if yearfirst is None: + yearfirst = info.yearfirst + res = self._result() + + res.format = [] + ymd_index = [ None, None, None ] + + l = _timelex.split(timestr) + try: + + # year/month/day list + ymd = [] + + # Index of the month string in ymd + mstridx = -1 + + len_l = len(l) + i = 0 + while i < len_l: + # Check if it's a number + try: + value = float(l[i]) + except ValueError: + value = None + if value is not None: + # Token is a number + len_li = len(l[i]) + i += 1 + if (len(ymd) == 3 and len_li in (2, 4) + and (i >= len_l or (l[i] != ':' and + info.hms(l[i]) is None))): + # 19990101T23[59] + s = l[i-1] + res.hour = int(s[:2]) + res.format.append('%H') + if len_li == 4: + res.minute = int(s[2:]) + res.format.append('%M') + elif len_li == 6 or (len_li > 6 and l[i-1].find('.') == 6): + # YYMMDD or HHMMSS[.ss] + s = l[i-1] + if not ymd and l[i-1].find('.') == -1: + ymd.append(info.convertyear(int(s[:2]))) + ymd.append(int(s[2:4])) + ymd.append(int(s[4:])) + res.format.append('%y%m%d') # XXX ? + else: + # 19990101T235959[.59] + res.hour = int(s[:2]) + res.minute = int(s[2:4]) + value = float(s[4:]) + res.second = int(value) + res.format.append('%H%M%S') + if value%1: + res.microsecond = int(1000000*(value%1)) + elif len_li == 8: + # YYYYMMDD + s = l[i-1] + ymd.append(int(s[:4])) + ymd.append(int(s[4:6])) + ymd.append(int(s[6:])) + res.format.append('%Y%m%d') # XXX ? + elif len_li in (12, 14): + # YYYYMMDDhhmm[ss] + s = l[i-1] + ymd.append(int(s[:4])) + ymd.append(int(s[4:6])) + ymd.append(int(s[6:8])) + res.hour = int(s[8:10]) + res.minute = int(s[10:12]) + res.format.append('%Y%m%d%H%M') # XXX ? + if len_li == 14: + res.second = int(s[12:]) + res.format.append('%S') + elif ((i < len_l and info.hms(l[i]) is not None) or + (i+1 < len_l and l[i] == ' ' and + info.hms(l[i+1]) is not None)): + # HH[ ]h or MM[ ]m or SS[.ss][ ]s + space = '' + if l[i] == ' ': + i += 1 + space = ' ' + idx = info.hms(l[i]) + while True: + if idx == 0: + res.hour = int(value) + res.format.append('%H') + if value%1: + res.minute = int(60*(value%1)) + res.format.append('.%M') + elif idx == 1: + res.minute = int(value) + res.format.append('%M') + if value%1: + res.second = int(60*(value%1)) + res.format.append('.%S') + elif idx == 2: + res.second = int(value) + res.format.append('%S') + if value%1: + res.microsecond = int(1000000*(value%1)) + + # not sure if this is right + res.format.append('%s%s' % (space, l[i])) + space = '' + + i += 1 + if i >= len_l or idx == 2: + break + # 12h00 + try: + value = float(l[i]) + except ValueError: + break + else: + i += 1 + idx += 1 + if i < len_l: + newidx = info.hms(l[i]) + if newidx is not None: + idx = newidx + elif i+1 < len_l and l[i] == ':': + # HH:MM[:SS[.ss]] + res.hour = int(value) + i += 1 + value = float(l[i]) + res.minute = int(value) + res.format.extend(('%H', ':%M')) + if value%1: + res.second = int(60*(value%1)) + res.format.append('.%S') + i += 1 + if i < len_l and l[i] == ':': + value = float(l[i+1]) + res.second = int(value) + res.format.append(':%S') + if value%1: + res.microsecond = int(1000000*(value%1)) + # XXX no date format equivalent (nanoseconds?) + i += 2 + elif i < len_l and l[i] in ('-', '/', '.'): + sep = l[i] + ymd.append(int(value)) + res.format.append('%d' + sep) # XXX ? + i += 1 + if i < len_l and not info.jump(l[i]): + try: + # 01-01[-01] + ymd.append(int(l[i])) + res.format.append('%m') + except ValueError: + # 01-Jan[-01] + value = info.month(l[i]) + if value is not None: + ymd.append(value) + if len(l[i]) > 3: + res.format.append('%B') + else: + res.format.append('%b') + assert mstridx == -1 + mstridx = len(ymd)-1 + else: + return None + i += 1 + if i < len_l and l[i] == sep: + res.format.append(sep) + # We have three members + i += 1 + value = info.month(l[i]) + if value is not None: + ymd.append(value) + if len(l[i]) > 3: + res.format.append('%B') + else: + res.format.append('%b') + mstridx = len(ymd)-1 + assert mstridx == -1 + else: + ymd.append(int(l[i])) + if len(l[i]) == 2: + res.format.append('%y') + else: + res.format.append('%Y') + i += 1 + elif i >= len_l or info.jump(l[i]): + if i+1 < len_l and info.ampm(l[i+1]) is not None: + # 12 am + res.hour = int(value) + if res.hour < 12 and info.ampm(l[i+1]) == 1: + res.hour += 12 + elif res.hour == 12 and info.ampm(l[i+1]) == 0: + res.hour = 0 + res.format.append('%-I %P') + i += 1 + else: + # Year, month or day + ymd.append(int(value)) + ymd_index[len(ymd)-1] = dict(index=len(res.format), value=int(value)) + res.format.append('') + if i < len_l and info.jump(l[i]): + res.format.append(l[i]) + i += 1 + elif info.ampm(l[i]) is not None: + # 12am + res.hour = int(value) + if res.hour < 12 and info.ampm(l[i]) == 1: + res.hour += 12 + elif res.hour == 12 and info.ampm(l[i]) == 0: + res.hour = 0 + i += 1 + res.format.append('%-I%P') + elif not fuzzy: + return None + else: + i += 1 + continue + + # Check weekday + value = info.weekday(l[i]) + if value is not None: + res.weekday = value + if len(l[i]) > 3: + res.format.append('%A') + else: + res.format.append('%a') + i += 1 + continue + + # Check month name + value = info.month(l[i]) + if value is not None: + ymd.append(value) + if len(l[i]) > 3: + res.format.append('%B') + else: + res.format.append('%b') + + assert mstridx == -1 + mstridx = len(ymd)-1 + i += 1 + if i < len_l: + if l[i] in ('-', '/'): + # Jan-01[-99] + sep = l[i] + res.format.append(sep) + i += 1 + ymd.append(int(l[i])) # XXX ? + i += 1 + if i < len_l and l[i] == sep: + # Jan-01-99 + res.format.append(sep) + i += 1 + ymd.append(int(l[i])) # XXX ? + i += 1 + elif (i+3 < len_l and l[i] == l[i+2] == ' ' + and info.pertain(l[i+1])): + # Jan of 01 + # In this case, 01 is clearly year + try: + value = int(l[i+3]) + except ValueError: + # Wrong guess + pass + else: + # Convert it here to become unambiguous + ymd.append(info.convertyear(value)) # XXX ? + i += 4 + continue + + # Check am/pm + value = info.ampm(l[i]) + if value is not None: + if value == 1 and res.hour < 12: + res.hour += 12 + elif value == 0 and res.hour == 12: + res.hour = 0 + if l[i].isupper(): + res.format.append('%p') + else: + res.format.append('%P') + index = res.format.index('%H') + if index >= 0: + res.format[index] = '%-I' + i += 1 + continue + + # Check for a timezone name + if (res.hour is not None and len(l[i]) <= 5 and + res.tzname is None and res.tzoffset is None and + not [x for x in l[i] if x not in string.ascii_uppercase]): + res.tzname = l[i] + res.tzoffset = info.tzoffset(res.tzname) + i += 1 + + # Check for something like GMT+3, or BRST+3. Notice + # that it doesn't mean "I am 3 hours after GMT", but + # "my time +3 is GMT". If found, we reverse the + # logic so that timezone parsing code will get it + # right. + if i < len_l and l[i] in ('+', '-'): + l[i] = ('+', '-')[l[i] == '+'] + res.tzoffset = None + if info.utczone(res.tzname): + # With something like GMT+3, the timezone + # is *not* GMT. + res.tzname = None + + continue + + # Check for a numbered timezone + if res.hour is not None and l[i] in ('+', '-'): + signal = (-1,1)[l[i] == '+'] + i += 1 + len_li = len(l[i]) + if len_li == 4: + # -0300 + res.tzoffset = int(l[i][:2])*3600+int(l[i][2:])*60 + elif i+1 < len_l and l[i+1] == ':': + # -03:00 + res.tzoffset = int(l[i])*3600+int(l[i+2])*60 + i += 2 + elif len_li <= 2: + # -[0]3 + res.tzoffset = int(l[i][:2])*3600 + else: + return None + i += 1 + res.tzoffset *= signal + + # Look for a timezone name between parenthesis + if (i+3 < len_l and + info.jump(l[i]) and l[i+1] == '(' and l[i+3] == ')' and + 3 <= len(l[i+2]) <= 5 and + not [x for x in l[i+2] + if x not in string.ascii_uppercase]): + # -0300 (BRST) + res.tzname = l[i+2] + i += 4 + continue + + # Check jumps + if not (info.jump(l[i]) or fuzzy): + return None + else: + res.format.append(l[i]) + + i += 1 + + # Process year/month/day + len_ymd = len(ymd) + if len_ymd > 3: + # More than three members!? + return None + elif len_ymd == 1 or (mstridx != -1 and len_ymd == 2): + # One member, or two members with a month string + if mstridx != -1: + res.month = ymd[mstridx] + if ymd_index[mstridx] is not None: + res.format[ymd_index[mstridx]['index']] = '%m' + del ymd[mstridx] + del ymd_index[mstridx] + if len_ymd > 1 or mstridx == -1: + if ymd[0] > 31: + res.year = ymd[0] + if ymd_index[0] is not None: + if int(math.log10(ymd_index[0]['value']) < 2): + format = '%y' + else: + format = '%Y' + res.format[ymd_index[0]['index']] = format + else: + res.day = ymd[0] + if ymd_index[0] is not None: + res.format[ymd_index[0]['index']] = '%-d' + elif len_ymd == 2: + # Two members with numbers + if ymd[0] > 31: + # 99-01 + res.year, res.month = ymd + if ymd_index[0] is not None: + if int(math.log10(ymd_index[0]['value']) < 2): + format = '%y' + else: + format = '%Y' + res.format[ymd_index[0]['index']] = format + if ymd_index[1] is not None: + res.format[ymd_index[1]['index']] = '%m' + elif ymd[1] > 31: + # 01-99 + res.month, res.year = ymd + if ymd_index[0] is not None: + res.format[ymd_index[0]['index']] = '%-m' + if ymd_index[1] is not None: + if int(math.log10(ymd_index[1]['value']) < 2): + format = '%y' + else: + format = '%Y' + res.format[ymd_index[1]['index']] = format + elif dayfirst and ymd[1] <= 12: + # 13-01 + res.day, res.month = ymd + if ymd_index[0] is not None: + res.format[ymd_index[0]['index']] = '%-d' + if ymd_index[1] is not None: + res.format[ymd_index[1]['index']] = '%-m' + else: + # 01-13 + res.month, res.day = ymd + if ymd_index[0] is not None: + res.format[ymd_index[0]['index']] = '%-m' + if ymd_index[1] is not None: + res.format[ymd_index[1]['index']] = '%-d' + if len_ymd == 3: + # Three members + if mstridx == 0: + res.month, res.day, res.year = ymd + if ymd_index[0] is not None: + res.format[ymd_index[0]['index']] = '%-m' + if ymd_index[1] is not None: + res.format[ymd_index[1]['index']] = '%-d' + if ymd_index[2] is not None: + if int(math.log10(ymd_index[2]['value']) < 2): + format = '%y' + else: + format = '%Y' + res.format[ymd_index[2]['index']] = format + + elif mstridx == 1: + if ymd[0] > 31 or (yearfirst and ymd[2] <= 31): + # 99-Jan-01 + res.year, res.month, res.day = ymd + else: + # 01-Jan-01 + # Give precendence to day-first, since + # two-digit years is usually hand-written. + res.day, res.month, res.year = ymd + elif mstridx == 2: + # WTF!? + if ymd[1] > 31: + # 01-99-Jan + res.day, res.year, res.month = ymd + else: + # 99-01-Jan + res.year, res.day, res.month = ymd + else: + if ymd[0] > 31 or \ + (yearfirst and ymd[1] <= 12 and ymd[2] <= 31): + # 99-01-01 + res.year, res.month, res.day = ymd + if ymd_index[0] is not None: + if int(math.log10(ymd_index[0]['value']) < 2): + format = '%y' + else: + format = '%Y' + res.format[ymd_index[0]['index']] = format + if ymd_index[1] is not None: + res.format[ymd_index[1]['index']] = '%-m' + if ymd_index[2] is not None: + res.format[ymd_index[2]['index']] = '%-d' + + elif ymd[0] > 12 or (dayfirst and ymd[1] <= 12): + # 13-01-01 + res.day, res.month, res.year = ymd + else: + # 01-13-01 + res.month, res.day, res.year = ymd + + except (IndexError, ValueError, AssertionError): + return None + + if not info.validate(res): + return None + res.format = ''.join(res.format) + return res + +DEFAULTPARSER = parser() +def parse(timestr, parserinfo=None, **kwargs): + if parserinfo: + return parser(parserinfo).parse(timestr, **kwargs) + else: + return DEFAULTPARSER.parse(timestr, **kwargs) + +class _tzparser: + + class _result(_resultbase): + + __slots__ = ["stdabbr", "stdoffset", "dstabbr", "dstoffset", + "start", "end"] + + class _attr(_resultbase): + __slots__ = ["month", "week", "weekday", + "yday", "jyday", "day", "time"] + + def __repr__(self): + return self._repr("") + + def __init__(self): + _resultbase.__init__(self) + self.start = self._attr() + self.end = self._attr() + + def parse(self, tzstr): + res = self._result() + l = _timelex.split(tzstr) + try: + + len_l = len(l) + + i = 0 + while i < len_l: + # BRST+3[BRDT[+2]] + j = i + while j < len_l and not [x for x in l[j] + if x in "0123456789:,-+"]: + j += 1 + if j != i: + if not res.stdabbr: + offattr = "stdoffset" + res.stdabbr = "".join(l[i:j]) + else: + offattr = "dstoffset" + res.dstabbr = "".join(l[i:j]) + i = j + if (i < len_l and + (l[i] in ('+', '-') or l[i][0] in "0123456789")): + if l[i] in ('+', '-'): + signal = (1,-1)[l[i] == '+'] + i += 1 + else: + signal = -1 + len_li = len(l[i]) + if len_li == 4: + # -0300 + setattr(res, offattr, + (int(l[i][:2])*3600+int(l[i][2:])*60)*signal) + elif i+1 < len_l and l[i+1] == ':': + # -03:00 + setattr(res, offattr, + (int(l[i])*3600+int(l[i+2])*60)*signal) + i += 2 + elif len_li <= 2: + # -[0]3 + setattr(res, offattr, + int(l[i][:2])*3600*signal) + else: + return None + i += 1 + if res.dstabbr: + break + else: + break + + if i < len_l: + for j in range(i, len_l): + if l[j] == ';': l[j] = ',' + + assert l[i] == ',' + + i += 1 + + if i >= len_l: + pass + elif (8 <= l.count(',') <= 9 and + not [y for x in l[i:] if x != ',' + for y in x if y not in "0123456789"]): + # GMT0BST,3,0,30,3600,10,0,26,7200[,3600] + for x in (res.start, res.end): + x.month = int(l[i]) + i += 2 + if l[i] == '-': + value = int(l[i+1])*-1 + i += 1 + else: + value = int(l[i]) + i += 2 + if value: + x.week = value + x.weekday = (int(l[i])-1)%7 + else: + x.day = int(l[i]) + i += 2 + x.time = int(l[i]) + i += 2 + if i < len_l: + if l[i] in ('-','+'): + signal = (-1,1)[l[i] == "+"] + i += 1 + else: + signal = 1 + res.dstoffset = (res.stdoffset+int(l[i]))*signal + elif (l.count(',') == 2 and l[i:].count('/') <= 2 and + not [y for x in l[i:] if x not in (',','/','J','M', + '.','-',':') + for y in x if y not in "0123456789"]): + for x in (res.start, res.end): + if l[i] == 'J': + # non-leap year day (1 based) + i += 1 + x.jyday = int(l[i]) + elif l[i] == 'M': + # month[-.]week[-.]weekday + i += 1 + x.month = int(l[i]) + i += 1 + assert l[i] in ('-', '.') + i += 1 + x.week = int(l[i]) + if x.week == 5: + x.week = -1 + i += 1 + assert l[i] in ('-', '.') + i += 1 + x.weekday = (int(l[i])-1)%7 + else: + # year day (zero based) + x.yday = int(l[i])+1 + + i += 1 + + if i < len_l and l[i] == '/': + i += 1 + # start time + len_li = len(l[i]) + if len_li == 4: + # -0300 + x.time = (int(l[i][:2])*3600+int(l[i][2:])*60) + elif i+1 < len_l and l[i+1] == ':': + # -03:00 + x.time = int(l[i])*3600+int(l[i+2])*60 + i += 2 + if i+1 < len_l and l[i+1] == ':': + i += 2 + x.time += int(l[i]) + elif len_li <= 2: + # -[0]3 + x.time = (int(l[i][:2])*3600) + else: + return None + i += 1 + + assert i == len_l or l[i] == ',' + + i += 1 + + assert i >= len_l + + except (IndexError, ValueError, AssertionError): + return None + + return res + +DEFAULTTZPARSER = _tzparser() +def _parsetz(tzstr): + return DEFAULTTZPARSER.parse(tzstr) + +# vim:ts=4:sw=4:et diff -r 000000000000 -r e3823be6a423 bitsyblog/settings.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/bitsyblog/settings.py Sat Sep 12 16:06:57 2009 -0400 @@ -0,0 +1,62 @@ +"""user settings / preferences""" + +import parser # dateutils.parser with mods to retain strftime format +import urllib2 +import utils + +class InvalidSettingError(Exception): + """error when trying to validate a setting""" + +class Setting(object): + def __init__(self, name, value=None): + self.name = name + self.value = value + self.error_message = 'Invalid value for %s' % name + + def validator(self, value): + return True + + def set(self, value): + if not self.validator(value): + raise InvalidSettingError(self.error_message) + self.value = value + +class DateFormat(Setting): + def __init__(self): + Setting.__init__(self, 'Date format') + + def set(self, format): + value = parser.parser()._parse(format) + if value: + self.value = value.format + else: + raise InvalidSettingError('unrecognized date format: %s' % format) + + +class CSSFile(Setting): + def __init__(self): + Setting.__init__(self, 'CSS file') + + def set(self, value): + if not hasattr(value, 'file'): + return True # blank set: don't do anything + css = value.file.read() + try: + validcss = utils.validate_css(css) + except urllib2.URLError: + raise InvalidSettingError('Could not validate CSS (sorry!)') + if not validcss: + raise InvalidSettingError('%s is not valid css' % filename) + filename = value.filename + if not filename.endswith('.css'): + filename = '%s.css' % filename + self.value = dict(filename=filename, css=css) + +user = [ DateFormat(), + Setting('Subject'), + Setting('Friends'), + Setting('Stylesheet'), + ] + +form = user[:] +form += [ CSSFile() ] diff -r 000000000000 -r e3823be6a423 bitsyblog/user.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/bitsyblog/user.py Sat Sep 12 16:06:57 2009 -0400 @@ -0,0 +1,212 @@ +import os +import random +import settings +import shutil +from bitsyblog import roles +from webob import exc + +class BitsyUser(object): + """interface class for a bitsyblog user""" + settings = {} + def __init__(self, name, password=''): + self.name = name + self.password = password + + # user behaves like a dictionary of settings + + def __getitem__(self, key): + return self.settings[key] + + def get(self, key, default=None): + return self.settings.get(key, default) + +class BitsyUsers(object): + """abstract class for bitsyblog user management""" + + def __iter__(self): return self.users() + + def __contains__(self, user): + return user in self.users() + + def __getitem__(self, user): + """return a user""" + if user not in self.users(): + raise KeyError + user = BitsyUser(user, self.password(user)) + user.settings = self.settings(user.name) + return user + + def passwords(self): + """returns a dictionary of { user: password }""" + passwords = {} + for user in self.users(): + passwords[user] = self.password(user) + return passwords + + ### interface methods to be specified by the child class + + def new(self, name, password): + """create a new user""" + + def users(self): + """returns the ids of all users (generator)""" + + def password(self, user): + """return the password for the user""" + + def settings(self, user): + """get user settings""" + + def write_settings(self, user, **kw): + """set attributes on a user""" + + +class FilespaceUsers(BitsyUsers): + """users that live on the filesystem""" + + def __init__(self, directory): + BitsyUsers.__init__(self) + self.directory = directory # directory to store user information in + + def home(self, user, *path): + return os.path.join(self.directory, user, *path) + + def pw_file(self, user): + return self.home(user, '.password') + + def secret(self, user): + secretfile = self.home(user, '.secret') + if os.path.exists(secretfile): + secret = int(file(secretfile).read().strip()) + else: + secret = random.randint(1024, 1024**4) + secretfile = file(secretfile, 'w') + print >> secretfile, secret + return secret + + def preferences_file(self, user): + return self.home(user, 'preferences.txt') + + def css(self, user, default): + css_dir = self.home(user, 'css') + css_files = [ i for i in os.listdir(css_dir) if i.endswith('.css') ] + if default: + default = '%s.css' % default + try: + index = css_files.index(default) + css_files.insert(0, css_files.pop(index)) + except ValueError: + pass + return [ dict(filename=i, name=i.rsplit('.css',1)[0], + css=file(os.path.join(css_dir, i)).read()) + for i in css_files ] + + ### interfaces for BitsyUsers + + def new(self, name, password): + """create a new user account""" + # XXX this shouldn't use HTTP exceptions + + if name in self.users(): + raise exc.HTTPForbidden("The name %s is already taken" % name).exception + + # characters forbidden in user name + forbidden = ' |<>./?,' + urls = [ 'join', 'login', 'logout', 'css', 'rss', 'atom', 'help' ] + if [ i for i in forbidden if i in name ]: + raise exc.HTTPForbidden("The name '%s' contains forbidden characters [%s]" % (user, forbidden)).exception + if name in urls: + raise exc.HTTPForbidden("The name '%s' is already used for a url" % user).exception + + home = self.home(name) + os.mkdir(home) + pw_file = file(self.pw_file(name), 'w') + print >> pw_file, password + + # setup entries structure for blog + entries = os.path.join(home, 'entries') + os.mkdir(entries) + for setting in roles['author']: + os.mkdir(os.path.join(entries, setting)) + + # setup user CSS + css_dir = os.path.join(home, 'css') + os.mkdir(css_dir) + shutil.copyfile(os.path.join(self.directory, 'site.css'), + os.path.join(css_dir, 'default.css')) + + + def users(self): + ignores = set(['.svn']) + for user in os.listdir(self.directory): + # ensure integrity of user folder + if user in ignores: + continue + if os.path.isdir(os.path.join(self.directory, user)): + yield user + + def password(self, user): + pw_file = '.password' # name of the password file on the filesystem + password = self.home(user, pw_file) + if os.path.exists(password): + return file(password).read().strip() + return '' # unspecified password + + def settings(self, name): + """returns a dictionary of user preferences from a file""" + filename = self.home(name, 'preferences.txt') + prefs = {} + if os.path.exists(filename): + prefs = file(filename).read().split('\n') + prefs = [ i for i in prefs if i.strip() ] + prefs = [ [ j.strip() for j in i.split(':', 1) ] + for i in prefs if ':' in i] + prefs = dict(prefs) + + # assemble friends from a list + friends = prefs.get('Friends') # can see secret blog posts + if friends: + prefs['Friends'] = friends.split(', ') + else: + prefs['Friends'] = [] + + # CSS files + prefs['CSS'] = self.css(name, prefs.get('Stylesheet')) + + return prefs + + def write_settings(self, name, **kw): + """write user settings to disk""" + + # generic stuff; could factor out + + newsettings = {} + errors = {} + + for setting in settings.form: + if kw.has_key(setting.name): + try: + setting.set(kw[setting.name]) + newsettings[setting.name] = setting.value + except settings.InvalidSettingError, e: + errors[setting.name] = str(e) + + if errors: + return errors + + # this makes the function depend on implemention + # i don't like this + new_css = newsettings.pop('CSS file') + if new_css: + filename = new_css['filename'] + css_file = file(self.home(name, 'css', filename), 'w') + print >> css_file, new_css['css'] + newsettings['CSS'] = filename.rsplit('.css', 1)[0] + + prefs = self.settings(name) + prefs.update(newsettings) + + # write the preferences to a file + preferences = file(self.preferences_file(name), 'w') + for key, value in prefs.items(): + print >> preferences, '%s: %s' % ( key, value ) diff -r 000000000000 -r e3823be6a423 bitsyblog/utils.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/bitsyblog/utils.py Sat Sep 12 16:06:57 2009 -0400 @@ -0,0 +1,29 @@ +"""utlity functions for bitsyblog""" + +import datetime +import os +import urllib +import urllib2 + +# format to uniquely label blog posts +timeformat = ( 'YYYY', 'MM', 'DD', 'HH', 'MM', 'SS' ) +timestamp = '%Y%m%d%H%M%S' # strftime representation + +def validate_css(css): + """use a webservice to determine if the argument is valid css""" + url = 'http://jigsaw.w3.org/css-validator/validator?text=%s' + url = url % urllib.quote_plus(css) + foo = urllib2.urlopen(url) + text = foo.read() + return not 'We found the following errors' in text + +def date(datestamp): + datestamp = os.path.split(datestamp)[-1] + retval = [] + for i in timeformat: + retval.append(int(datestamp[:len(i)])) + datestamp = datestamp[len(i):] + return datetime.datetime(*retval) + +def datestamp(date): + return date.strftime(timestamp) diff -r 000000000000 -r e3823be6a423 blog/bitsyblog.png Binary file blog/bitsyblog.png has changed diff -r 000000000000 -r e3823be6a423 blog/site.css --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/blog/site.css Sat Sep 12 16:06:57 2009 -0400 @@ -0,0 +1,23 @@ +div { +border: thin solid; +background-color: #edd; +margin: 1em; + } + +.site-nav { +clear: both; +list-style-type: none; +display: inline; +margin-top: 0em; +padding-top: 0em; + } + +.site-nav a { +float: left; +margin-left: 0.3em; +margin-right: 0.3em; + } + +.error { +color: red; +} \ No newline at end of file diff -r 000000000000 -r e3823be6a423 blogme.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/blogme.py Sat Sep 12 16:06:57 2009 -0400 @@ -0,0 +1,102 @@ +#!/usr/bin/env python + +import optparse +import os +import subprocess +import sys +import tempfile +import urllib2 + +# global variables + +EDITOR='emacs -nw' +SERVER='http://bitsyblog.biz' +dotfile='.blogme' + +parser = optparse.OptionParser() +parser.add_option('-s', '--server', default=SERVER) +parser.add_option('-u', '--user') +parser.add_option('-p', '--password') +parser.add_option('--private', action='store_true', default=False) +parser.add_option('--secret', action='store_true', default=False) + +options, args = parser.parse_args() + +if options.private and options.secret: + print "post can't be secret and private!" + sys.exit(1) + +# parse dotfile + +home = os.environ.get('HOME') +if home: + dotfile = os.path.join(home, dotfile) + if os.path.exists(dotfile): + prefs = file(dotfile).read().split('\n') + prefs = [ i for i in prefs if i.strip() ] + prefs = [ [ j.strip() for j in i.split(':', 1) ] for i in prefs + if ':' in i] # probably not necessary + prefs = dict(prefs) + else: + prefs = {} + +# determine user name and password +fields = [ 'user', 'password' ] +for field in fields: + globals()[field] = prefs.get(field) + + optval = getattr(options, field) + if optval: + password = None # needed to ensure prompting for pw from command line + globals()[field] = optval + + if globals()[field] is None: + globals()[field] = raw_input('%s: ' % field) +assert user is not None +assert password is not None + +# write the dotfile if it doesn't exist +if not os.path.exists(dotfile): + preffile = file(dotfile, 'w') + print >> preffile, 'user: %s' % user + print >> preffile, 'password: %s' % password + preffile.close() + os.chmod(dotfile, 0600) + +def tmpbuffer(editor=EDITOR): + """open an editor and retreive the resulting editted buffer""" + tmpfile = tempfile.mktemp(suffix='.txt') + cmdline = editor.split() + cmdline.append(tmpfile) + edit = subprocess.call(cmdline) + buffer = file(tmpfile).read().strip() + os.remove(tmpfile) + return buffer + +# get the blog + +if args: + msg = ' '.join(args) +else: + msg = tmpbuffer() + +# open the url + +url = '/'.join((options.server, user)) +url += '?auth=digest' # specify authentication method + +if options.private: + url += '&privacy=private' +if options.secret: + url += '&privacy=secret' + +authhandler = urllib2.HTTPDigestAuthHandler() +authhandler.add_password('bitsyblog', url, user, password) +opener = urllib2.build_opener(authhandler) +urllib2.install_opener(opener) + +try: + url = urllib2.urlopen(url, data=msg) + print url.url # print the blog post's url +except urllib2.HTTPError: + pass diff -r 000000000000 -r e3823be6a423 multiuser.ini --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/multiuser.ini Sat Sep 12 16:06:57 2009 -0400 @@ -0,0 +1,22 @@ +[DEFAULT] +debug = true +email_to = jhammel@openplans.org +smtp_server = localhost +error_email_from = paste@localhost + +[server:main] +use = egg:Paste#http +host = 0.0.0.0 +port = 8001 + +[composite:main] +use = egg:Paste#urlmap +/ = bitsyblog + +set debug = false + +[app:bitsyblog] +paste.app_factory = bitsyblog.factory:factory +bitsyblog.file_dir = %(here)s/blog +bitsyblog.date_format = %H:%M %A, %B %-d, %Y +bitsyblog.help_file = %(here)s/README.txt \ No newline at end of file diff -r 000000000000 -r e3823be6a423 roadmap.txt --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/roadmap.txt Sat Sep 12 16:06:57 2009 -0400 @@ -0,0 +1,208 @@ +bitsyblog, middleware, and the roadmap to the future + +the nice thing with working like software like bitsyblog is that the +intent of the software is clear. bitsyblog aims to provide a solution +for users to keep a log of their activities on the web. When thinking of +software in terms of the narrow intent of a component, it becomes much +easier to divine what sort of framework would look like around it and +how this framework might be applicable to other types of software. + +Firstly, I noticed that the bitsyblog component app is not as small as +it could be. bitsyblog provides a multi-user solution to (personal) +blogging. This level of solution should exist, but thinking in terms +of components it is easy to see that users may want a personal blog +that is not multi-user. lammy wants a blog on his desktop computer. +When at his computer, he probably wants to be logged in all the time +(always accept connections from 127.0.0.1). He doesn't want other +people to be able to make a blog there. When not at his desktop, he +wants to be able to login and post through the web and maybe used the +blogme.py script too. In bitsyblog terms, '/lammy' +becomes '/' and all urls ('preferences', 'post', 'css/') become +relative to this. Doing this involves separating out the individual +blog components from the multi-user component and probably having +different .ini files from which to run paste. + +I've also realized that the way I handle CSS is a solution to a +problem that has nothing to do with bitsyblog. The other items in +preferences ('date', 'subject', but not 'friends' which has to do with +roles) are about formatting the blog posts. The CSS is about themeing +pages which has nothing to do with blog posts. So what is needed is a +CSS component, which is possibly linked to from the preferences page. +Lets put this at '/lammy/css'. This pages may have the fields for +preferences, but also provides an interface for managing the CSS. A +user may want to delete CSS or edit them, render the current page with +a selected stylesheet or what's in the editor, or render an arbitrary web page +This is like CSS zen garden, but more interactive, and +(optionally) allowing the user to save the CSS and apply it to their +pages. I can also imagine, in a 2.0 version, an implementation that +allows permits transforming of CSS to suitable to theming a page with +a stylesheet intended for another page. One has a stylesheet with a +bunch of rules. Using javascript, one mouses over the DOM of the +other page. The rules are modified in order to applied to the +appropriate container on the page. This sounds much like what +deliverance is supposed to do, and maybe it could be utilized for this +intent, though perhaps the intents are different and its better not to +use it (to be investigated). + +Both of the above components fall into a general pattern: there is a +component which is the multi-user blog manager. Lets call this +bitsyblog. There are microcomponents, the single-user blog (lets call +this microblog) and the CSS zen garden component which lets you play +with stylesheets (I dub thee samadhi). microblog and samadhi can live +on their own, in which case they live at '/', or they might be managed +by an external component -- microblog by bitsyblog, or samadhi +by...not really sure, either a microblog instance or bitsyblog. In +the latter case, they need to know where they live. I'm not sure what +provides the rewrite rule telling them where they live. Ian? Ethan? +There are a few configuration type details -- like auth, whether +samadhi needs to know about where to put these CSS files or how +bitsyusers can fetch them, but essentially if you give these the +appropriate path, they should be able to work relatively +independently. + +In a different class of components are comments and tagging. While it +is easy to imagine bitsyblog managing comments and tags, it is also +fairly easy to see that these services really don't need to know what +is being commented or tagged on. For commenting, I imagine an app +similar to smokesignals, but with a different persistence model. Lets +call it commentator. Commentator is a javascript app with no +intrinsic URLs. Given a set of rules (for instance, "allow commenting +on
s with an id attribute) a block of text is shown as the last child +of each
(or conditionally on mouseover). This will initially +say something like "No comments | Add a comment" where each of these +do what you think ('N comments' displays the comments, 'Add a comment' +gives a textarea where you can say your mind). When a comment is +added, it is put in a database (presumedly SQL, though really on a +super-simple db is necessary) with the URL and/or id +as its key. When the page is loaded, the text of the page is examined +on response and the appropriate comments are displayed. It is also +necessary to have some sort of mapping optionally between URL and id +to another URI, as blog posts both are displayed as part of the user's +entire blog and also have a unique URL. Notice that nothing here is +particularly unique to blogs. Commenting on
s or

s in a wiki +page is essentially the same (though in this case, it might be better +to comment on the entire page, as these may not have ids and the page +can be editted all willy-nilly like that. Commenting on individual +

s and

s is, however, a really nice thing to have, so in a 2.0 +type incarnation of commentator, this should be added regardless of +the difficulty). If bitsyblog was part of a +site with a wiki, then commentator could deal with them both nicely. +Optionally, one might specify the rule that if an item had comments, +it either could not be deleted or that an alert would pop up before +deletion with [Confirm] and [Cancel] buttons. This is easy to do if a +browser could do a DELETE request and things behaved all RESTful. +Otherwise, this would also be a non-trivial problem. + +Tagging is very similar to commentator, though it might have a few +intrinsic URLs too. You specify a similar set of rules concerning +what can be tagged ("all
s with ids in /%(user)s/ and +/%(user)s/200*") and a method pointing the ids to URLs. Then you have +a database, which again is pretty simple and again in its simplest +form could be a dictionary. Though here, the tags are the keys and +the URLs are the values. You could also record number of tags per +item, specify whether non-authenticated members can tag, etc. As the +first or last child of each (say)
, you have what it has been +tagged with, maybe weighted by how many times it has been tagged. You +have a URL for searching by tags, maybe on a per-path basis ("I want +to see all the tags under /k0s that have been tagged with 'cats' and +'cute'"). You could also have a URL for the tag cloud. Again, what +is being tagged is pretty divorced from the tagging component itself +(lets call it tagit, unless that name is taken). + +I have presented samadhi as being managed by bitsyblog or microblog +and commentator and tagit living outside of bitsyblog in the +middleware stack. I've done this because this "feels right" to me +right now, though I reseve my right to change my mind about it. The +thing I want emphasize is the difference in the two approaches: + +* When a WSGI app is "managed" by another component app, the manager + has to know about the managed app and dispatch the appropriate + information (request, etc) to it, though in doing so it has the + opportunity to have more control over the URL structure and what the + component app does. It is also easier to return HTML this way. + +* When a WSGI "app" is used as middleware and needs to markup the + returned page in HTML, this is conducive to using javascript in + order to insert nodes into the DOM dynamically, presumedly based on + some rule set. Alternatively, the middleware may decompose the page + with lxml and insert HTML where it needs to go that way. In this + approach, the middleware need not have a URL (this may be + preferable). + +It is not necessarily clear to me at this time which approach is +better for different apps. I have put commenting and tagging in the +latter category because commenting and tagging on web pages seems +divorced (save by a rule set) from what is being commented on or +tagged. I have put microblog in the first category because it is +clearly in the same layer of software as bitsyblog, and some +management tasks will be necessary even for microblog. samadhi I put +in the first category as well (though I hesistate right now) because +otherwise the rewriting of URLs makes me nervous. Perhaps there is +need of a WSGI dispatcher that fetches apps based on some sort of rule +concerning the URL? Also, it seems +necessary to put the CSS on user data. This could also be +accomplished the other way: 1. abstract out members and roles +(teamroler?) and store the data (CSS, etc) on them here; 2. have +samadhi write a defined set of urls (how defined? not sure) including +the stylesheets that it knows about, presumedly putting the 'foo' +stylesheet at '/lammy/css/foo.css'. This still involves samadhi +knowing something about the url structure, and now it is dependent on +the user/role thing, though this could be a configuration option. + +This is the middleware story. Other things could also be done +internally to bitsyblog and its supporting software to make the +experience more seamless. + +I am bothered by the fact that the logic supporting the form and user +settings is somewhat divorced. Ideally, rendering the form should do +all the validation and return the settings in a sensible way to use. +I don't think this is hard -- the settings.py should just be +incorporated into markup.form. This goes some ways to reinventing +things like formencode and zope.schema, so if we can get these to do +what we want, maybe we shouldn't write our own. But at cursory +glance, formencode won't and zope.schema isn't easily usable outside +of zope. So maybe rolling our own isn't bad. + +There's more middleware, but its hidden and is a bad coupling (that +is, the app will depend on the middleware unless its done cleverly). +The site nav does not appear on '/login' or '/join' (or any url not +specified internally to bitsyblog, for that matter). It would be +conceivable to have a SiteNav middleware that (either through +javascript or lxml decomposition) writes the site nav at the top of +the screen. bistyblog and bitsyauth would have to know about this +though, so that they could add the relevent links into environ. Maybe +this isn't horrible, but it seems so. Likewise, the site css doesn't +show up here. Should it? Is this another piece of middleware or part +of samadhi? + +An important piece of middleware should involve members and roles. I +really don't want to write this one, but I would like to consume it. +It should live just inside of bitsyauth and make the users.py file +unnecessary. It might be better to sit on this one until last. + +So should be bitsyblog 2.0. +There are a few other streamlining things (see the TODO.txt), but if +all of this was implemented, bitsyblog would be a rich web experience +that was highly componentized with each of the components doing one +thing well. Moreso, since coupling is avoided, save through +configuration, auth, and other loose links, each of these components +could be used independently of bitsyblog or turned off without +undesirable consequences. + +At least as much as being a good blogging app, bitsyblog is about +elegant design and component architecture. This roadmap shows what is +possible in terms of having a fully functional blogging application +with middleware that can be used outside of just bitsyblog. To take +the other end of the spectrum, wordpress says "Hey, write plugins that +will make me work better." As a programmer, I really hate this +approach. If I write a commenting plugin for wordpress, then I have +just that. If I want to use it to comment on a wiki page, I'm out of +luck. If I write commenting middleware, then it can be used for any +WSGI app with minimal configuration. As a programmer, I generally +want the freedom to assemble a webapp however I want. With this +freedom comes some work, but for me its a worthy sacrifice. As a web +consumer, I understand that site administrators want out of the box +solutions. bitsyblog will offer this too. The default deployment of +bitsyblog will pull down commentator, samadhi, and tagit and have an +.ini file pointing to the appropriate factory to create the chain of +middleware to have all of these pieces in place. There is no contradiction. diff -r 000000000000 -r e3823be6a423 setup.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/setup.py Sat Sep 12 16:06:57 2009 -0400 @@ -0,0 +1,50 @@ +from setuptools import setup, find_packages +import sys, os + +version = '1.1.1' + +try: + description = file('README.txt').read() +except IOError: + description = """ +Meet bitsyblog. Posting is done with a POST request, so while you can use +a web form to do this, its just as easy to use curl, urllib, or anything else +to post. +""" + +setup(name='bitsyblog', + version=version, + description="a tiny tiny blog", + long_description=description, + classifiers=[], # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + keywords='blog', + author='Jeff Hammel', + author_email='jhammel@openplans.org', + url='http://bitsyblog.biz', + license='GPL', + packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), + include_package_data=True, + zip_safe=False, + install_requires=[ + # -*- Extra requirements: -*- + 'WebOb', + 'Paste', + 'PasteScript', + 'dateutil', + 'markup', + 'skimpygimpy', + 'lxml', + 'PyRSS2Gen' + ], + dependency_links=[ + 'https://svn.openplans.org/svn/standalone/markup#egg=markup', + 'http://svn.pythonpaste.org/Paste/trunk#egg=Paste', + 'http://downloads.sourceforge.net/skimpygimpy/skimpyGimpy_1_3.zip#egg=skimpygimpy', + 'http://www.dalkescientific.com/Python/PyRSS2Gen-1.0.0.tar.gz#egg=PyRSS2Gen' + ], + entry_points=""" + # -*- Entry points: -*- + [paste.app_factory] + main = bitsyblog.factory:factory + """, + ) diff -r 000000000000 -r e3823be6a423 singleuser.ini --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/singleuser.ini Sat Sep 12 16:06:57 2009 -0400 @@ -0,0 +1,23 @@ +[DEFAULT] +debug = true +email_to = jhammel@openplans.org +smtp_server = localhost +error_email_from = paste@localhost + +[server:main] +use = egg:Paste#http +host = 0.0.0.0 +port = 8003 + +[composite:main] +use = egg:Paste#urlmap +/ = bitsyblog + +set debug = false + +[app:bitsyblog] +paste.app_factory = bitsyblog.factory:bitsierfactory +bitsyblog.file_dir = %(here)s/blog +bitsyblog.date_format = %H:%M %A, %B %-d, %Y +bitsyblog.help_file = %(here)s/README.txt +bitsyblog.user = openplans \ No newline at end of file