Running the Tornado Blog example app
====================================
-This demo is a simple blogging engine that uses MySQL to store posts and
-Google Accounts for author authentication. Since it depends on MySQL, you
-need to set up MySQL and the database schema for the demo to run.
+
+This demo is a simple blogging engine that uses a database to store posts.
+You must have PostgreSQL or CockroachDB installed to run this demo.
If you have `docker` and `docker-compose` installed, the demo and all
its prerequisites can be installed with `docker-compose up`.
-1. Install prerequisites and build tornado
+1. Install a database if needed
+
+ Consult the documentation at either https://www.postgresql.org or
+ https://www.cockroachlabs.com to install one of these databases for
+ your platform.
+
+2. Install Python prerequisites
- See http://www.tornadoweb.org/ for installation instructions. If you can
- run the "helloworld" example application, your environment is set up
- correctly.
+ This demo requires Python 3.5 or newer, and the packages listed in
+ requirements.txt. Install them with `pip -r requirements.txt`
-2. Install MySQL if needed
+3. Create a database and user for the blog.
- Consult the documentation for your platform. Under Ubuntu Linux you
- can run "apt-get install mysql". Under OS X you can download the
- MySQL PKG file from http://dev.mysql.com/downloads/mysql/
+ Connect to the database with `psql -Upostgres` (for PostgreSQL) or
+ `cockroach sql` (for CockroachDB).
-3. Install Python prerequisites
+ Create a database and user, and grant permissions:
- Install the packages MySQL-python, torndb, and markdown (e.g. using pip or
- easy_install). Note that these packages currently only work on
- Python 2. Tornado supports Python 3, but this blog demo does not.
+ CREATE DATABASE blog;
+ CREATE USER blog WITH PASSWORD 'blog';
+ GRANT ALL ON DATABASE blog TO blog;
-3. Connect to MySQL and create a database and user for the blog.
+ (If using CockroachDB in insecure mode, omit the `WITH PASSWORD 'blog'`)
- Connect to MySQL as a user that can create databases and users:
- mysql -u root
+4. Create the tables in your new database (optional):
- Create a database named "blog":
- mysql> CREATE DATABASE blog;
+ The blog application will create its tables automatically when starting up.
+ It's also possible to create them separately.
- Allow the "blog" user to connect with the password "blog":
- mysql> GRANT ALL PRIVILEGES ON blog.* TO 'blog'@'localhost' IDENTIFIED BY 'blog';
+ You can use the provided schema.sql file by running this command for PostgreSQL:
-4. Create the tables in your new database.
+ psql -U blog -d blog < schema.sql
- You can use the provided schema.sql file by running this command:
- mysql --user=blog --password=blog --database=blog < schema.sql
+ Or this one for CockcroachDB:
+
+ cockroach sql -u blog -d blog < schema.sql
You can run the above command again later if you want to delete the
contents of the blog and start over after testing.
5. Run the blog example
- With the default user, password, and database you can just run:
+ For PostgreSQL, you can just run
./blog.py
- If you've changed anything, you can alter the default MySQL settings
- with arguments on the command line, e.g.:
- ./blog.py --mysql_user=casey --mysql_password=happiness --mysql_database=foodblog
+ For CockroachDB, run
+ ./blog.py --db_port=26257
+
+ If you've changed anything from the defaults, use the other `--db_*` flags.
6. Visit your new blog
- Open http://localhost:8888/ in your web browser. You will be redirected to
- a Google account sign-in page because the blog uses Google accounts for
- authentication.
+ Open http://localhost:8888/ in your web browser.
Currently the first user to connect will automatically be given the
ability to create and edit posts.
-#!/usr/bin/env python
+#!/usr/bin/env python3
#
# Copyright 2009 Facebook
#
# License for the specific language governing permissions and limitations
# under the License.
+import aiopg
import bcrypt
-import concurrent.futures
-import MySQLdb
import markdown
import os.path
+import psycopg2
import re
-import subprocess
-import torndb
import tornado.escape
-from tornado import gen
import tornado.httpserver
import tornado.ioloop
+import tornado.locks
import tornado.options
import tornado.web
import unicodedata
from tornado.options import define, options
define("port", default=8888, help="run on the given port", type=int)
-define("mysql_host", default="127.0.0.1:3306", help="blog database host")
-define("mysql_database", default="blog", help="blog database name")
-define("mysql_user", default="blog", help="blog database user")
-define("mysql_password", default="blog", help="blog database password")
+define("db_host", default="127.0.0.1", help="blog database host")
+define("db_port", default=5432, help="blog database port")
+define("db_database", default="blog", help="blog database name")
+define("db_user", default="blog", help="blog database user")
+define("db_password", default="blog", help="blog database password")
-# A thread pool to be used for password hashing with bcrypt.
-executor = concurrent.futures.ThreadPoolExecutor(2)
+class NoResultError(Exception):
+ pass
+
+
+async def maybe_create_tables(db):
+ try:
+ with (await db.cursor()) as cur:
+ await cur.execute("SELECT COUNT(*) FROM entries LIMIT 1")
+ await cur.fetchone()
+ except psycopg2.ProgrammingError:
+ with open('schema.sql') as f:
+ schema = f.read()
+ with (await db.cursor()) as cur:
+ await cur.execute(schema)
class Application(tornado.web.Application):
- def __init__(self):
+ def __init__(self, db):
+ self.db = db
handlers = [
(r"/", HomeHandler),
(r"/archive", ArchiveHandler),
debug=True,
)
super(Application, self).__init__(handlers, **settings)
- # Have one global connection to the blog DB across all handlers
- self.db = torndb.Connection(
- host=options.mysql_host, database=options.mysql_database,
- user=options.mysql_user, password=options.mysql_password)
-
- self.maybe_create_tables()
-
- def maybe_create_tables(self):
- try:
- self.db.get("SELECT COUNT(*) from entries;")
- except MySQLdb.ProgrammingError:
- subprocess.check_call(['mysql',
- '--host=' + options.mysql_host,
- '--database=' + options.mysql_database,
- '--user=' + options.mysql_user,
- '--password=' + options.mysql_password],
- stdin=open('schema.sql'))
class BaseHandler(tornado.web.RequestHandler):
- @property
- def db(self):
- return self.application.db
-
- def get_current_user(self):
+ def row_to_obj(self, row, cur):
+ """Convert a SQL row to an object supporting dict and attribute access."""
+ obj = tornado.util.ObjectDict()
+ for val, desc in zip(row, cur.description):
+ obj[desc.name] = val
+ return obj
+
+ async def execute(self, stmt, *args):
+ """Execute a SQL statement.
+
+ Must be called with ``await self.execute(...)``
+ """
+ with (await self.application.db.cursor()) as cur:
+ await cur.execute(stmt, args)
+
+ async def query(self, stmt, *args):
+ """Query for a list of results.
+
+ Typical usage::
+
+ results = await self.query(...)
+
+ Or::
+
+ for row in await self.query(...)
+ """
+ with (await self.application.db.cursor()) as cur:
+ await cur.execute(stmt, args)
+ return [self.row_to_obj(row, cur)
+ for row in await cur.fetchall()]
+
+ async def queryone(self, stmt, *args):
+ """Query for exactly one result.
+
+ Raises NoResultError if there are no results, or ValueError if
+ there are more than one.
+ """
+ results = await self.query(stmt, *args)
+ if len(results) == 0:
+ raise NoResultError()
+ elif len(results) > 1:
+ raise ValueError("Expected 1 result, got %d" % len(results))
+ return results[0]
+
+ async def prepare(self):
+ # get_current_user cannot be a coroutine, so set
+ # self.current_user in prepare instead.
user_id = self.get_secure_cookie("blogdemo_user")
- if not user_id:
- return None
- return self.db.get("SELECT * FROM authors WHERE id = %s", int(user_id))
+ if user_id:
+ self.current_user = await self.queryone("SELECT * FROM authors WHERE id = %s", int(user_id))
- def any_author_exists(self):
- return bool(self.db.get("SELECT * FROM authors LIMIT 1"))
+ async def any_author_exists(self):
+ return bool(await self.query("SELECT * FROM authors LIMIT 1"))
class HomeHandler(BaseHandler):
- def get(self):
- entries = self.db.query("SELECT * FROM entries ORDER BY published "
- "DESC LIMIT 5")
+ async def get(self):
+ entries = await self.query("SELECT * FROM entries ORDER BY published DESC LIMIT 5")
if not entries:
self.redirect("/compose")
return
class EntryHandler(BaseHandler):
- def get(self, slug):
- entry = self.db.get("SELECT * FROM entries WHERE slug = %s", slug)
+ async def get(self, slug):
+ entry = await self.queryone("SELECT * FROM entries WHERE slug = %s", slug)
if not entry:
raise tornado.web.HTTPError(404)
self.render("entry.html", entry=entry)
class ArchiveHandler(BaseHandler):
- def get(self):
- entries = self.db.query("SELECT * FROM entries ORDER BY published "
- "DESC")
+ async def get(self):
+ entries = await self.query("SELECT * FROM entries ORDER BY published DESC")
self.render("archive.html", entries=entries)
class FeedHandler(BaseHandler):
- def get(self):
- entries = self.db.query("SELECT * FROM entries ORDER BY published "
- "DESC LIMIT 10")
+ async def get(self):
+ entries = await self.query("SELECT * FROM entries ORDER BY published DESC LIMIT 10")
self.set_header("Content-Type", "application/atom+xml")
self.render("feed.xml", entries=entries)
class ComposeHandler(BaseHandler):
@tornado.web.authenticated
- def get(self):
+ async def get(self):
id = self.get_argument("id", None)
entry = None
if id:
- entry = self.db.get("SELECT * FROM entries WHERE id = %s", int(id))
+ entry = await self.query("SELECT * FROM entries WHERE id = %s", int(id))
self.render("compose.html", entry=entry)
@tornado.web.authenticated
- def post(self):
+ async def post(self):
id = self.get_argument("id", None)
title = self.get_argument("title")
text = self.get_argument("markdown")
html = markdown.markdown(text)
if id:
- entry = self.db.get("SELECT * FROM entries WHERE id = %s", int(id))
+ entry = await self.query("SELECT * FROM entries WHERE id = %s", int(id))
if not entry:
raise tornado.web.HTTPError(404)
slug = entry.slug
- self.db.execute(
+ await self.execute(
"UPDATE entries SET title = %s, markdown = %s, html = %s "
"WHERE id = %s", title, text, html, int(id))
else:
- slug = unicodedata.normalize("NFKD", title).encode(
- "ascii", "ignore")
+ slug = unicodedata.normalize("NFKD", title)
slug = re.sub(r"[^\w]+", " ", slug)
slug = "-".join(slug.lower().strip().split())
+ slug = slug.encode("ascii", "ignore").decode("ascii")
if not slug:
slug = "entry"
while True:
- e = self.db.get("SELECT * FROM entries WHERE slug = %s", slug)
+ e = await self.query("SELECT * FROM entries WHERE slug = %s", slug)
if not e:
break
slug += "-2"
- self.db.execute(
- "INSERT INTO entries (author_id,title,slug,markdown,html,"
- "published) VALUES (%s,%s,%s,%s,%s,UTC_TIMESTAMP())",
+ await self.execute(
+ "INSERT INTO entries (author_id,title,slug,markdown,html,published,updated)"
+ "VALUES (%s,%s,%s,%s,%s,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)",
self.current_user.id, title, slug, text, html)
self.redirect("/entry/" + slug)
def get(self):
self.render("create_author.html")
- @gen.coroutine
- def post(self):
- if self.any_author_exists():
+ async def post(self):
+ if await self.any_author_exists():
raise tornado.web.HTTPError(400, "author already created")
- hashed_password = yield executor.submit(
- bcrypt.hashpw, tornado.escape.utf8(self.get_argument("password")),
+ hashed_password = await tornado.ioloop.IOLoop.current().run_in_executor(
+ None, bcrypt.hashpw, tornado.escape.utf8(self.get_argument("password")),
bcrypt.gensalt())
- author_id = self.db.execute(
+ author = await self.queryone(
"INSERT INTO authors (email, name, hashed_password) "
- "VALUES (%s, %s, %s)",
+ "VALUES (%s, %s, %s) RETURNING id",
self.get_argument("email"), self.get_argument("name"),
- hashed_password)
- self.set_secure_cookie("blogdemo_user", str(author_id))
+ tornado.escape.to_unicode(hashed_password))
+ self.set_secure_cookie("blogdemo_user", str(author.id))
self.redirect(self.get_argument("next", "/"))
class AuthLoginHandler(BaseHandler):
- def get(self):
+ async def get(self):
# If there are no authors, redirect to the account creation page.
- if not self.any_author_exists():
+ if not await self.any_author_exists():
self.redirect("/auth/create")
else:
self.render("login.html", error=None)
- @gen.coroutine
- def post(self):
- author = self.db.get("SELECT * FROM authors WHERE email = %s",
- self.get_argument("email"))
- if not author:
+ async def post(self):
+ try:
+ author = await self.queryone("SELECT * FROM authors WHERE email = %s",
+ self.get_argument("email"))
+ except NoResultError:
self.render("login.html", error="email not found")
return
- hashed_password = yield executor.submit(
- bcrypt.hashpw, tornado.escape.utf8(self.get_argument("password")),
+ hashed_password = await tornado.ioloop.IOLoop.current().run_in_executor(
+ None, bcrypt.hashpw, tornado.escape.utf8(self.get_argument("password")),
tornado.escape.utf8(author.hashed_password))
+ hashed_password = tornado.escape.to_unicode(hashed_password)
if hashed_password == author.hashed_password:
self.set_secure_cookie("blogdemo_user", str(author.id))
self.redirect(self.get_argument("next", "/"))
return self.render_string("modules/entry.html", entry=entry)
-def main():
+async def main():
tornado.options.parse_command_line()
- http_server = tornado.httpserver.HTTPServer(Application())
- http_server.listen(options.port)
- tornado.ioloop.IOLoop.current().start()
+
+ # Create the global connection pool.
+ async with aiopg.create_pool(
+ host=options.db_host,
+ port=options.db_port,
+ user=options.db_user,
+ password=options.db_password,
+ dbname=options.db_database) as db:
+ await maybe_create_tables(db)
+ app = Application(db)
+ app.listen(options.port)
+
+ # In this demo the server will simply run until interrupted
+ # with Ctrl-C, but if you want to shut down more gracefully,
+ # call shutdown_event.set().
+ shutdown_event = tornado.locks.Event()
+ await shutdown_event.wait()
if __name__ == "__main__":
- main()
+ tornado.ioloop.IOLoop.current().run_sync(main)