]> git.ipfire.org Git - thirdparty/tornado.git/commitdiff
demos: Update blog demo
authorBen Darnell <ben@bendarnell.com>
Sat, 12 May 2018 22:27:28 +0000 (18:27 -0400)
committerBen Darnell <ben@bendarnell.com>
Sun, 13 May 2018 19:07:15 +0000 (15:07 -0400)
- Switch from MySQL to PostgreSQL/CockroachDB.
- Use (and require) Python 3.5+.
- Use aiopg instead of torndb.

demos/blog/Dockerfile
demos/blog/README
demos/blog/blog.py
demos/blog/docker-compose.yml
demos/blog/requirements.txt
demos/blog/schema.sql

index 9ba708f382fb23824e1c830466aa703e69879469..6440449cc0a97c503e5bc4550cb10c4cba65d991 100644 (file)
@@ -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"]
index 72f0774f39994c7a844ab49054f91a01ee13199e..b4f7d2de8ffbf41239f62ceb602cbee7aa9eaa95 100644 (file)
@@ -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.
index d629957270c302511d51f9b3d455d6c616470265..b3f61fa2f9fbf03848db04de34f1d11c430d8f09 100755 (executable)
@@ -1,4 +1,4 @@
-#!/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
@@ -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)
index 247c94beafa4392419c7939539fc1c95917c565b..95f8e84f4b0f7fd4383df2153508c1de7b9090a8 100644 (file)
@@ -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
index 8669e33bd5c1b951010a243021418eb26303d652..f4c727a021e4fd5de869bc13d8e11be37db496d3 100644 (file)
@@ -1,6 +1,5 @@
+aiopg
 bcrypt
-futures
-MySQL-python
 markdown
+psycopg2
 tornado
-torndb
index a63e91fdefaa876d5d3bbcacf9bc38b8f8e0a766..1820f177202e4dff9162a0951ff59281199c6fe7 100644 (file)
 
 -- 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);