From: Ben Darnell Date: Sat, 12 May 2018 22:27:28 +0000 (-0400) Subject: demos: Update blog demo X-Git-Tag: v5.1.0b1~17^2~4 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=b0194d852faa50a49fb2569a048707dfdd8e344b;p=thirdparty%2Ftornado.git demos: Update blog demo - Switch from MySQL to PostgreSQL/CockroachDB. - Use (and require) Python 3.5+. - Use aiopg instead of torndb. --- diff --git a/demos/blog/Dockerfile b/demos/blog/Dockerfile index 9ba708f38..6440449cc 100644 --- a/demos/blog/Dockerfile +++ b/demos/blog/Dockerfile @@ -1,17 +1,13 @@ -FROM python:2.7 +FROM python:3.6 EXPOSE 8888 -RUN apt-get update && apt-get install -y mysql-client - -# based on python:2.7-onbuild, but if we use that image directly -# the above apt-get line runs too late. RUN mkdir -p /usr/src/app WORKDIR /usr/src/app COPY requirements.txt /usr/src/app/ -RUN pip install -r requirements.txt +RUN pip install --no-cache-dir -r requirements.txt -COPY . /usr/src/app +COPY . . -CMD python blog.py --mysql_host=mysql +ENTRYPOINT ["python3", "blog.py"] diff --git a/demos/blog/README b/demos/blog/README index 72f0774f3..b4f7d2de8 100644 --- a/demos/blog/README +++ b/demos/blog/README @@ -1,63 +1,65 @@ 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. diff --git a/demos/blog/blog.py b/demos/blog/blog.py index d62995727..b3f61fa2f 100755 --- a/demos/blog/blog.py +++ b/demos/blog/blog.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # # Copyright 2009 Facebook # @@ -14,18 +14,16 @@ # 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 @@ -33,18 +31,32 @@ 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), @@ -66,44 +78,67 @@ class Application(tornado.web.Application): 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 @@ -111,66 +146,64 @@ class HomeHandler(BaseHandler): 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) @@ -179,40 +212,40 @@ class AuthCreateHandler(BaseHandler): 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", "/")) @@ -231,12 +264,26 @@ class EntryModule(tornado.web.UIModule): 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) diff --git a/demos/blog/docker-compose.yml b/demos/blog/docker-compose.yml index 247c94bea..95f8e84f4 100644 --- a/demos/blog/docker-compose.yml +++ b/demos/blog/docker-compose.yml @@ -1,15 +1,15 @@ -mysql: - image: mysql:5.6 +postgres: + image: postgres:10.3 environment: - MYSQL_ROOT_PASSWORD: its_a_secret_to_everybody - MYSQL_USER: blog - MYSQL_PASSWORD: blog - MYSQL_DATABASE: blog + POSTGRES_USER: blog + POSTGRES_PASSWORD: blog + POSTGRES_DB: blog ports: - "3306" blog: build: . links: - - mysql + - postgres ports: - "8888:8888" + command: --db_host=postgres diff --git a/demos/blog/requirements.txt b/demos/blog/requirements.txt index 8669e33bd..f4c727a02 100644 --- a/demos/blog/requirements.txt +++ b/demos/blog/requirements.txt @@ -1,6 +1,5 @@ +aiopg bcrypt -futures -MySQL-python markdown +psycopg2 tornado -torndb diff --git a/demos/blog/schema.sql b/demos/blog/schema.sql index a63e91fde..1820f1772 100644 --- a/demos/blog/schema.sql +++ b/demos/blog/schema.sql @@ -14,32 +14,30 @@ -- To create the database: -- CREATE DATABASE blog; --- GRANT ALL PRIVILEGES ON blog.* TO 'blog'@'localhost' IDENTIFIED BY 'blog'; +-- CREATE USER blog WITH PASSWORD 'blog'; +-- GRANT ALL ON DATABASE blog TO blog; -- -- To reload the tables: --- mysql --user=blog --password=blog --database=blog < schema.sql +-- psql -U blog -d blog < schema.sql -SET SESSION storage_engine = "InnoDB"; -SET SESSION time_zone = "+0:00"; -ALTER DATABASE CHARACTER SET "utf8"; +DROP TABLE IF EXISTS authors; +CREATE TABLE authors ( + id SERIAL PRIMARY KEY, + email VARCHAR(100) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + hashed_password VARCHAR(100) NOT NULL +); DROP TABLE IF EXISTS entries; CREATE TABLE entries ( - id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + id SERIAL PRIMARY KEY, author_id INT NOT NULL REFERENCES authors(id), slug VARCHAR(100) NOT NULL UNIQUE, title VARCHAR(512) NOT NULL, - markdown MEDIUMTEXT NOT NULL, - html MEDIUMTEXT NOT NULL, - published DATETIME NOT NULL, - updated TIMESTAMP NOT NULL, - KEY (published) + markdown TEXT NOT NULL, + html TEXT NOT NULL, + published TIMESTAMP NOT NULL, + updated TIMESTAMP NOT NULL ); -DROP TABLE IF EXISTS authors; -CREATE TABLE authors ( - id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, - email VARCHAR(100) NOT NULL UNIQUE, - name VARCHAR(100) NOT NULL, - hashed_password VARCHAR(100) NOT NULL -); +CREATE INDEX ON entries (published);