+++++++++++++ Blog Tutorial +++++++++++++ :author: Ian Bicking :revision: $Rev$ :date: $LastChangedDate$ .. contents:: .. note:: This tutorial is not yet finished. What you see is what you get, and yeah that's not a whole lot. Introduction ============ This tutorial will go through the process of creating a blog using `Python Paste `_, SQLObject_, and `Zope Page Templates`_. This blog will rely heavily on static publishing -- that is, when at all possible flat HTML pages will be written to disk. For some parts (e.g., posting a new item) this will of course be infeasible, but for most of the site this should work fine. .. _SQLObject: http://sqlobject.org .. _Zope Page Templates: http://www.zope.org/DevHome/Wikis/DevSite/Projects/ZPT/FrontPage As much as possible, this code will be accompanied by unit tests, and test-driven methodologies. Doing test-driven documenting of the incremental process of creating test-driven software may get a little hairy, but wish me luck! This tutorial presupposes you are somewhat comfortable with the basic stack -- the `To-Do Tutorial`_ is a better place to start for a beginner. .. _To-Do Tutorial: TodoTutorial.html Setting Up The App ================== .. note:: We're doing all this in the Python interpreter, even though you'd normally do some of this in the shell. This way the authors of this tutorial can use something called doctest_, which allows this tutorial to be tested Python in an automated way. .. _doctest: http://python.org/doc/current/lib/module-doctest.html .. comment: >>> from paste.tests.doctest_webapp import * >>> BASE = '/var/www/example-builds/wwblog' >>> import sys >>> clear_dir(BASE) >>> run("paster create --template=webkit_zpt %s" % BASE) >>> os.chdir(BASE) :: $ export PYTHONPATH=/path/to/Paste:$PYTHONPATH $ BASE=/var/www/example-builds/wwblog $ paster create --template=webkit_zpt $BASE $ cd $BASE The Model ========= Since we're using SQLObject, we'll be doing the complete model in that. The predecessor of this blog used flat files, custom-written indexes, and simple rfc822_ based files for structure. It did not scale well at all. .. _rfc822: http://python.org/doc/current/lib/module-rfc822.html Here's the model: .. run: create_file('db.py', 'v1', r""" from sqlobject import * class Article(SQLObject): url = StringCol(notNull=True) title = StringCol() content = StringCol(notNull=True) content_mime_type = StringCol(notNull=True) author = ForeignKey('User') parent = ForeignKey('Article', default=None) created = DateTimeCol(notNull=True, default=DateTimeCol.now) last_updated = DateTimeCol(default=None) atom_id = StringCol() hidden = BoolCol(notNull=True, default=False) article_type = StringCol(notNull=True, default='article') categories = RelatedJoin('Category') class Category(SQLObject): name = StringCol(alternateID=True) articles = RelatedJoin('Article') class User(SQLObject): class sqlmeta: table = 'user_info' username = StringCol(alternateID=True) email = StringCol() name = StringCol() homepage = StringCol() password_encoded = StringCol() role = StringCol(notNull=True, default='user') """) .. raw:: html :file: resources/blog-tutorial/db.py.v1.gen.html A few things to note: * All the columns allow ``NULL`` by default, unless we say ``notNull=True``. * ``ForeignKey('User')`` is a join to another table (the ``User`` table, of course). We have to use strings to refer to other class, because in this case the ``User`` class hasn't even been created. Generally all references between classes are by name. * ``created`` has a default. You can give a fixed default (like ``True`` or ``3``), or you can pass in a function that is called. In this case, if you don't indicate ``Article(..., created=something)`` then ``created`` will be the current date and time. Unless a default is explicitly given, it is an error to leave a column out of the constructor. ``NULL`` (which is ``None`` in Python) is *not* considered a default. * Some column types don't relate directly to database types. For instance, though PostgreSQL has a ``BOOLEAN`` type, most databases don't, so ``BoolCol`` translates to some kind of ``INT`` column on those database. * ``RelatedJoin('Category')`` creates a mapping table (``article_category``) and is a many-to-many join between articles and categories. * ``user`` isn't a valid table name in many databases, so while the class is named ``User``, the table actually is ``user_info``. This kind of extra information about a class is typically passed in through the ``sqlmeta`` inner class. These classes have lots of other *behavior*, but this should be a good list of actual information. We'll add more behavior later. Now we'll create the database. First we configure it, adding these lines to ``server.conf``:: import os database = 'sqlite:%s/data.db' % os.path.dirname(__file__) You could also use:: database = 'mysql://user:passwd@localhost/dbname' database = 'postgresql://user:password@localhost/dbname' .. comment (change server.conf) >>> change_file('server.conf', [('insert', 2, r"""import os ... database = 'sqlite:%s/data.db' % os.path.dirname(__file__) ... ... """)]) Now we'll use ``sqlobject-admin`` to set up the tables: .. comment (do it) >>> run_command('sqlobject-admin create -f server.conf ' ... '-m wwblog.db', 'create', and_print=True) .. raw:: html :file: resources/blog-tutorial/shell-command.create.gen.html Fixture Data ------------ To test things later we'll need a bit of data to make the tests interesting. It's best if we write code to clear any data and put known data in -- that way we can restore the database at any time to a known state, and can write our tests against that data. We'll add some code to the end of ``db.py``: .. comment (change) >>> append_to_file('db.py', 'append-fixture', r""" ... ... from paste import CONFIG ... def reset_data(): ... sqlhub.processConnection = connectionForURI(CONFIG['database']) ... for soClass in (User, Category, Article): ... soClass.clearTable() ... auth = User(username='author', email='author@example.com', ... name='Author Person', password_encoded=None, ... role='author', homepage=None) ... user = User(username='commentor', email='comment@example.com', ... name='Comment Person', password_encoded=None, ... role='user', homepage='http://yahoo.com') ... programming = Category(name='Programming') ... family = Category(name='family') ... a1 = Article(url='/2004/05/01/article1.html', ... title='First article', ... content='This is an article', ... content_mime_type='text/html', ... author=auth, parent=None, ... last_updated=None, atom_id=None) ... a2 = Article(url='/2004/05/10/article2.html', ... title='Second article', ... content='Another\narticle', ... content_mime_type='text/plain', ... author=auth, parent=None, ... last_updated=None, atom_id=None) ... c1 = Article(url='/2004/05/01/article1-comment1.html', ... title=None, content='Nice article!', ... content_mime_type='text/x-untrusted-html', ... author=user, parent=a1, last_updated=None, ... atom_id=None, article_type='comment') ... a1.addCategory(programming) ... a1.addCategory(family) ... """) .. raw:: html :file: resources/blog-tutorial/db.py.append-fixture.gen.html Test Fixture ------------ See `Testing Applications With Paste `_ for more on the details of how we set up testing. We'll be using `py.test `_ for the testing framework. First, lets create our own test fixture. We'll create a directory ``tests/`` and add a file ``fixture.py``. .. comment (create files): >>> create_file('tests/__init__.py', 'v1', '#\n') >>> create_file('tests/fixture.py', 'v1', r""" ... from paste.tests.fixture import setup_module as paste_setup ... from wwblog import db ... ... def setup_module(module): ... paste_setup(module) ... db.reset_data() ... """) .. raw:: html :file: resources/blog-tutorial/tests/fixture.py.v1.gen.html Now in each test we'll do:: from wwblog.tests.fixture import setup_module And that will give us a consistent state for the module (note that data isn't reset between each test in the module, just once for the module, so we'll have to be aware of that). Let's write a first test: .. comment (create test): >>> create_file('tests/test_db.py', 'v1', r""" ... from fixture import setup_module ... from wwblog.db import * ... ... def test_data(): ... # make sure we have the two users we set up ... assert len(list(User.select())) == 2 ... # and get the first article for testing ... a1 = list(Article.selectBy(title='First article'))[0] ... # make sure it has categories, then make sure the ... # categories contain this article as well ... assert len(list(a1.categories)) == 2 ... for cat in a1.categories: ... assert a1 in cat.articles ... """) .. raw:: html :file: resources/blog-tutorial/tests/test_db.py.v1.gen.html For the most part, this stuff is already tested by SQLObject, but this is a basic sanity check, and a test that we have set up the classes properly. One problem, though, is that we have to make sure that ``sys.path`` is set up properly. We could set ``$PYTHONPATH``, but that can be a bit annoying; we'll put it in a special file ``conftest.py`` that py.test loads up: .. comment (create): >>> create_file('conftest.py', 'v1', r""" ... import sys, os ... sys.path.append('/path/to/Paste') ... sys.path.append(os.path.dirname(os.path.dirname(__file__))) ... from paste.util.thirdparty import add_package ... add_package('sqlobject') ... """) .. raw:: html :file: resources/blog-tutorial/conftest.py.v1.gen.html Now we should be able to run py.test: .. comment (do): >>> run_command('py.test', 't1', and_print=True) inserting into sys.path: ... =...= test process starts =...= testing-mode: inprocess executable: .../python (...) using py lib: .../py .../test_db.py[1] . =...= tests finished: 1 passed in ... seconds =...= .. raw:: html :file: resources/blog-tutorial/shell-command.t1.gen.html Very good! Alright then, on to making an application... Static Publishing ----------------- Remember I said something about static publishing? So... what does that mean? Well, it means that when possible we should write files out to disk. These files might be in their final form, though in some environments it might be nice to write out files that are interpreted by `server side includes `_ or PHP. By generating what is effectively code, the "static" files can contain dynamic portions, e.g., a running list of recently-updated external blogs. But more importantly, many changes can be made without generating the entire site; changes to the look of the site, of course, but also smaller things, like up-to-date archive links on the sides of pages and other little bits of code. This tutorial will work with server-side includes, because they are dumb enough that we won't be tempted to push too much functionality into them (and we might be able to extract their functionality into fully-formed pages); but they'll also save us a lot of work early on. If you haven't used server-side includes you can read the document I linked too, but you can also probably pick it up easily enough from the examples. URL Layout ---------- The blog "application" will be available through some URL (which we can figure out at runtime). But everything else gets written onto disk with some URL equivalent, so that path and URL will have to be configurable. The Front Page -------------- First we'll set up a simple front page. We'll write a new ``index.py``: .. comment (do so): >>> create_file('web/index.py', 'v1', r""" ... """) .. raw:: html :file: resources/blog-tutorial/web/index.py.v1.gen.html