From c3dd7b8e4c9e322c413f4a49e5d15aceafd17c2d Mon Sep 17 00:00:00 2001 From: David Lord Date: Fri, 9 Feb 2018 14:39:05 -0800 Subject: [PATCH] rewrite tutorial docs and example --- CONTRIBUTING.rst | 2 +- docs/_static/flaskr.png | Bin 66253 -> 0 bytes docs/conf.py | 39 +- docs/installation.rst | 2 + docs/patterns/jquery.rst | 6 +- docs/patterns/packages.rst | 16 +- docs/testing.rst | 5 +- docs/tutorial/blog.rst | 336 +++++++++++ docs/tutorial/css.rst | 31 - docs/tutorial/database.rst | 213 +++++++ docs/tutorial/dbcon.rst | 78 --- docs/tutorial/dbinit.rst | 80 --- docs/tutorial/deploy.rst | 121 ++++ docs/tutorial/factory.rst | 177 ++++++ docs/tutorial/flaskr_edit.png | Bin 0 -> 13259 bytes docs/tutorial/flaskr_index.png | Bin 0 -> 11675 bytes docs/tutorial/flaskr_login.png | Bin 0 -> 7455 bytes docs/tutorial/folders.rst | 31 - docs/tutorial/index.rst | 85 ++- docs/tutorial/install.rst | 113 ++++ docs/tutorial/introduction.rst | 39 -- docs/tutorial/layout.rst | 110 ++++ docs/tutorial/next.rst | 38 ++ docs/tutorial/packaging.rst | 108 ---- docs/tutorial/schema.rst | 25 - docs/tutorial/setup.rst | 101 ---- docs/tutorial/static.rst | 72 +++ docs/tutorial/templates.rst | 264 ++++++--- docs/tutorial/testing.rst | 96 --- docs/tutorial/tests.rst | 561 ++++++++++++++++++ docs/tutorial/views.rst | 373 +++++++++--- examples/blueprintexample/blueprintexample.py | 19 - .../blueprintexample/simple_page/__init__.py | 0 .../simple_page/simple_page.py | 13 - .../simple_page/templates/pages/hello.html | 5 - .../simple_page/templates/pages/index.html | 5 - .../simple_page/templates/pages/layout.html | 20 - .../simple_page/templates/pages/world.html | 4 - .../blueprintexample/test_blueprintexample.py | 35 -- examples/flaskr/.gitignore | 2 - examples/flaskr/README | 40 -- examples/flaskr/flaskr/__init__.py | 0 examples/flaskr/flaskr/blueprints/__init__.py | 0 examples/flaskr/flaskr/blueprints/flaskr.py | 85 --- examples/flaskr/flaskr/factory.py | 64 -- examples/flaskr/flaskr/schema.sql | 6 - examples/flaskr/flaskr/static/style.css | 18 - examples/flaskr/flaskr/templates/layout.html | 17 - examples/flaskr/flaskr/templates/login.html | 14 - .../flaskr/flaskr/templates/show_entries.html | 21 - examples/flaskr/setup.cfg | 2 - examples/flaskr/setup.py | 27 - examples/flaskr/tests/test_flaskr.py | 83 --- examples/jqueryexample/jqueryexample.py | 29 - examples/jqueryexample/templates/index.html | 33 -- examples/jqueryexample/templates/layout.html | 8 - examples/minitwit/.gitignore | 2 - examples/minitwit/MANIFEST.in | 3 - examples/minitwit/README | 39 -- examples/minitwit/minitwit/__init__.py | 1 - examples/minitwit/minitwit/minitwit.py | 256 -------- examples/minitwit/minitwit/schema.sql | 21 - examples/minitwit/minitwit/static/style.css | 178 ------ .../minitwit/minitwit/templates/layout.html | 32 - .../minitwit/minitwit/templates/login.html | 16 - .../minitwit/minitwit/templates/register.html | 19 - .../minitwit/minitwit/templates/timeline.html | 49 -- examples/minitwit/setup.cfg | 2 - examples/minitwit/setup.py | 16 - examples/minitwit/tests/test_minitwit.py | 150 ----- examples/patterns/largerapp/setup.py | 10 - .../largerapp/tests/test_largerapp.py | 21 - .../largerapp/yourapplication/__init__.py | 13 - .../yourapplication/static/style.css | 0 .../yourapplication/templates/index.html | 0 .../yourapplication/templates/layout.html | 0 .../yourapplication/templates/login.html | 0 .../largerapp/yourapplication/views.py | 14 - examples/tutorial/.gitignore | 14 + examples/tutorial/LICENSE | 31 + examples/{flaskr => tutorial}/MANIFEST.in | 7 +- examples/tutorial/README.rst | 76 +++ examples/tutorial/flaskr/__init__.py | 48 ++ examples/tutorial/flaskr/auth.py | 108 ++++ examples/tutorial/flaskr/blog.py | 119 ++++ examples/tutorial/flaskr/db.py | 54 ++ examples/tutorial/flaskr/schema.sql | 20 + examples/tutorial/flaskr/static/style.css | 134 +++++ .../tutorial/flaskr/templates/auth/login.html | 15 + .../flaskr/templates/auth/register.html | 15 + examples/tutorial/flaskr/templates/base.html | 24 + .../flaskr/templates/blog/create.html | 15 + .../tutorial/flaskr/templates/blog/index.html | 28 + .../flaskr/templates/blog/update.html | 19 + examples/tutorial/setup.cfg | 13 + examples/tutorial/setup.py | 23 + examples/tutorial/tests/conftest.py | 64 ++ examples/tutorial/tests/data.sql | 8 + examples/tutorial/tests/test_auth.py | 66 +++ examples/tutorial/tests/test_blog.py | 92 +++ examples/tutorial/tests/test_db.py | 28 + examples/tutorial/tests/test_factory.py | 12 + tox.ini | 4 +- 103 files changed, 3327 insertions(+), 2224 deletions(-) delete mode 100644 docs/_static/flaskr.png create mode 100644 docs/tutorial/blog.rst delete mode 100644 docs/tutorial/css.rst create mode 100644 docs/tutorial/database.rst delete mode 100644 docs/tutorial/dbcon.rst delete mode 100644 docs/tutorial/dbinit.rst create mode 100644 docs/tutorial/deploy.rst create mode 100644 docs/tutorial/factory.rst create mode 100644 docs/tutorial/flaskr_edit.png create mode 100644 docs/tutorial/flaskr_index.png create mode 100644 docs/tutorial/flaskr_login.png delete mode 100644 docs/tutorial/folders.rst create mode 100644 docs/tutorial/install.rst delete mode 100644 docs/tutorial/introduction.rst create mode 100644 docs/tutorial/layout.rst create mode 100644 docs/tutorial/next.rst delete mode 100644 docs/tutorial/packaging.rst delete mode 100644 docs/tutorial/schema.rst delete mode 100644 docs/tutorial/setup.rst create mode 100644 docs/tutorial/static.rst delete mode 100644 docs/tutorial/testing.rst create mode 100644 docs/tutorial/tests.rst delete mode 100644 examples/blueprintexample/blueprintexample.py delete mode 100644 examples/blueprintexample/simple_page/__init__.py delete mode 100644 examples/blueprintexample/simple_page/simple_page.py delete mode 100644 examples/blueprintexample/simple_page/templates/pages/hello.html delete mode 100644 examples/blueprintexample/simple_page/templates/pages/index.html delete mode 100644 examples/blueprintexample/simple_page/templates/pages/layout.html delete mode 100644 examples/blueprintexample/simple_page/templates/pages/world.html delete mode 100644 examples/blueprintexample/test_blueprintexample.py delete mode 100644 examples/flaskr/.gitignore delete mode 100644 examples/flaskr/README delete mode 100644 examples/flaskr/flaskr/__init__.py delete mode 100644 examples/flaskr/flaskr/blueprints/__init__.py delete mode 100644 examples/flaskr/flaskr/blueprints/flaskr.py delete mode 100644 examples/flaskr/flaskr/factory.py delete mode 100644 examples/flaskr/flaskr/schema.sql delete mode 100644 examples/flaskr/flaskr/static/style.css delete mode 100644 examples/flaskr/flaskr/templates/layout.html delete mode 100644 examples/flaskr/flaskr/templates/login.html delete mode 100644 examples/flaskr/flaskr/templates/show_entries.html delete mode 100644 examples/flaskr/setup.cfg delete mode 100644 examples/flaskr/setup.py delete mode 100644 examples/flaskr/tests/test_flaskr.py delete mode 100644 examples/jqueryexample/jqueryexample.py delete mode 100644 examples/jqueryexample/templates/index.html delete mode 100644 examples/jqueryexample/templates/layout.html delete mode 100644 examples/minitwit/.gitignore delete mode 100644 examples/minitwit/MANIFEST.in delete mode 100644 examples/minitwit/README delete mode 100644 examples/minitwit/minitwit/__init__.py delete mode 100644 examples/minitwit/minitwit/minitwit.py delete mode 100644 examples/minitwit/minitwit/schema.sql delete mode 100644 examples/minitwit/minitwit/static/style.css delete mode 100644 examples/minitwit/minitwit/templates/layout.html delete mode 100644 examples/minitwit/minitwit/templates/login.html delete mode 100644 examples/minitwit/minitwit/templates/register.html delete mode 100644 examples/minitwit/minitwit/templates/timeline.html delete mode 100644 examples/minitwit/setup.cfg delete mode 100644 examples/minitwit/setup.py delete mode 100644 examples/minitwit/tests/test_minitwit.py delete mode 100644 examples/patterns/largerapp/setup.py delete mode 100644 examples/patterns/largerapp/tests/test_largerapp.py delete mode 100644 examples/patterns/largerapp/yourapplication/__init__.py delete mode 100644 examples/patterns/largerapp/yourapplication/static/style.css delete mode 100644 examples/patterns/largerapp/yourapplication/templates/index.html delete mode 100644 examples/patterns/largerapp/yourapplication/templates/layout.html delete mode 100644 examples/patterns/largerapp/yourapplication/templates/login.html delete mode 100644 examples/patterns/largerapp/yourapplication/views.py create mode 100644 examples/tutorial/.gitignore create mode 100644 examples/tutorial/LICENSE rename examples/{flaskr => tutorial}/MANIFEST.in (58%) create mode 100644 examples/tutorial/README.rst create mode 100644 examples/tutorial/flaskr/__init__.py create mode 100644 examples/tutorial/flaskr/auth.py create mode 100644 examples/tutorial/flaskr/blog.py create mode 100644 examples/tutorial/flaskr/db.py create mode 100644 examples/tutorial/flaskr/schema.sql create mode 100644 examples/tutorial/flaskr/static/style.css create mode 100644 examples/tutorial/flaskr/templates/auth/login.html create mode 100644 examples/tutorial/flaskr/templates/auth/register.html create mode 100644 examples/tutorial/flaskr/templates/base.html create mode 100644 examples/tutorial/flaskr/templates/blog/create.html create mode 100644 examples/tutorial/flaskr/templates/blog/index.html create mode 100644 examples/tutorial/flaskr/templates/blog/update.html create mode 100644 examples/tutorial/setup.cfg create mode 100644 examples/tutorial/setup.py create mode 100644 examples/tutorial/tests/conftest.py create mode 100644 examples/tutorial/tests/data.sql create mode 100644 examples/tutorial/tests/test_auth.py create mode 100644 examples/tutorial/tests/test_blog.py create mode 100644 examples/tutorial/tests/test_db.py create mode 100644 examples/tutorial/tests/test_factory.py diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index a9bcace6..36b7df3a 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -75,7 +75,7 @@ First time setup .. _latest version of git: https://git-scm.com/downloads .. _username: https://help.github.com/articles/setting-your-username-in-git/ .. _email: https://help.github.com/articles/setting-your-email-in-git/ -.. _Fork: https://github.com/pallets/flask/pull/2305#fork-destination-box +.. _Fork: https://github.com/pallets/flask/fork .. _Clone: https://help.github.com/articles/fork-a-repo/#step-2-create-a-local-clone-of-your-fork Start coding diff --git a/docs/_static/flaskr.png b/docs/_static/flaskr.png deleted file mode 100644 index 838f760472249e1bd480a42c405b815e9cbae633..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 66253 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 AU;qFB diff --git a/docs/conf.py b/docs/conf.py index 682391f3..a037476e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,13 +11,13 @@ # All configuration values have a default; values that are commented out # serve to show the default. from __future__ import print_function + +import datetime import os import sys -import pkg_resources import time -import datetime -from sphinx.application import Sphinx +import pkg_resources BUILD_DATE = datetime.datetime.utcfromtimestamp(int(os.environ.get('SOURCE_DATE_EPOCH', time.time()))) @@ -300,7 +300,7 @@ unwrap_decorators() del unwrap_decorators -def setup(app: Sphinx): +def setup(app): def cut_module_meta(app, what, name, obj, options, lines): """Remove metadata from autodoc output.""" if what != 'module': @@ -312,3 +312,34 @@ def setup(app: Sphinx): ] app.connect('autodoc-process-docstring', cut_module_meta) + + def github_link( + name, rawtext, text, lineno, inliner, + options=None, content=None + ): + app = inliner.document.settings.env.app + release = app.config.release + base_url = 'https://github.com/pallets/flask/tree/' + + if text.endswith('>'): + words, text = text[:-1].rsplit('<', 1) + words = words.strip() + else: + words = None + + if release.endswith('dev'): + url = '{0}master/{1}'.format(base_url, text) + else: + url = '{0}{1}/{2}'.format(base_url, release, text) + + if words is None: + words = url + + from docutils.nodes import reference + from docutils.parsers.rst.roles import set_classes + options = options or {} + set_classes(options) + node = reference(rawtext, words, refuri=url, **options) + return [node], [] + + app.add_role('gh', github_link) diff --git a/docs/installation.rst b/docs/installation.rst index 88b9af09..e32ec6c7 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -104,6 +104,8 @@ On Windows: \Python27\Scripts\virtualenv.exe venv +.. _install-activate-env: + Activate the environment ~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/patterns/jquery.rst b/docs/patterns/jquery.rst index d136d5b4..24569a53 100644 --- a/docs/patterns/jquery.rst +++ b/docs/patterns/jquery.rst @@ -162,7 +162,5 @@ explanation of the little bit of code above: argument. Note that we can use the `$SCRIPT_ROOT` variable here that we set earlier. -If you don't get the whole picture, download the `sourcecode -for this example -`_ -from GitHub. +If you don't get the whole picture, download the :gh:`sourcecode +for this example `. diff --git a/docs/patterns/packages.rst b/docs/patterns/packages.rst index 6b0ee7ad..f6b51614 100644 --- a/docs/patterns/packages.rst +++ b/docs/patterns/packages.rst @@ -17,9 +17,8 @@ this:: login.html ... -If you find yourself stuck on something, feel free -to take a look at the source code for this example. -You'll find `the full src for this example here`_. +The :ref:`tutorial ` is structured this way, see the +:gh:`example code `. Simple Packages --------------- @@ -59,21 +58,21 @@ a big problem, just add a new file called :file:`setup.py` next to the inner ], ) -In order to run the application you need to export an environment variable -that tells Flask where to find the application instance:: +In order to run the application you need to export an environment variable +that tells Flask where to find the application instance:: export FLASK_APP=yourapplication -If you are outside of the project directory make sure to provide the exact +If you are outside of the project directory make sure to provide the exact path to your application directory. Similarly you can turn on the development features like this:: export FLASK_ENV=development -In order to install and run the application you need to issue the following +In order to install and run the application you need to issue the following commands:: - pip install -e . + pip install -e . flask run What did we gain from this? Now we can restructure the application a bit @@ -134,7 +133,6 @@ You should then end up with something like that:: .. _working-with-modules: -.. _the full src for this example here: https://github.com/pallets/flask/tree/master/examples/patterns/largerapp Working with Blueprints ----------------------- diff --git a/docs/testing.rst b/docs/testing.rst index 4a272df6..bfbc1d91 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -28,10 +28,7 @@ The Application First, we need an application to test; we will use the application from the :ref:`tutorial`. If you don't have that application yet, get the -source code from `the examples`_. - -.. _the examples: - https://github.com/pallets/flask/tree/master/examples/flaskr/ +source code from :gh:`the examples `. The Testing Skeleton -------------------- diff --git a/docs/tutorial/blog.rst b/docs/tutorial/blog.rst new file mode 100644 index 00000000..4511d61b --- /dev/null +++ b/docs/tutorial/blog.rst @@ -0,0 +1,336 @@ +.. currentmodule:: flask + +Blog Blueprint +============== + +You'll use the same techniques you learned about when writing the +authentication blueprint to write the blog blueprint. The blog should +list all posts, allow logged in users to create posts, and allow the +author of a post to edit or delete it. + +As you implement each view, keep the development server running. As you +save your changes, try going to the URL in your browser and testing them +out. + +The Blueprint +------------- + +Define the blueprint and register it in the application factory. + +.. code-block:: python + :caption: ``flaskr/blog.py`` + + from flask import ( + Blueprint, flash, g, redirect, render_template, request, url_for + ) + from werkzeug.exceptions import abort + + from flaskr.auth import login_required + from flaskr.db import get_db + + bp = Blueprint('blog', __name__) + +Import and register the blueprint from the factory using +:meth:`app.register_blueprint() `. Place the +new code at the end of the factory function before returning the app. + +.. code-block:: python + :caption: ``flaskr/__init__.py`` + + def create_app(): + app = ... + # existing code omitted + + from . import blog + app.register_blueprint(blog.bp) + app.add_url_rule('/', endpoint='index') + + return app + + +Unlike the auth blueprint, the blog blueprint does not have a +``url_prefix``. So the ``index`` view will be at ``/``, the ``create`` +view at ``/create``, and so on. The blog is the main feature of Flaskr, +so it makes sense that the blog index will be the main index. + +However, the endpoint for the ``index`` view defined below will be +``blog.index``. Some of the authentication views referred to a plain +``index`` endpoint. :meth:`app.add_url_rule() ` +associates the endpoint name ``'index'`` with the ``/`` url so that +``url_for('index')`` or ``url_for('blog.index')`` will both work, +generating the same ``/`` URL either way. + +In another application you might give the blog blueprint a +``url_prefix`` and define a separate ``index`` view in the application +factory, similar to the ``hello`` view. Then the ``index`` and +``blog.index`` endpoints and URLs would be different. + + +Index +----- + +The index will show all of the posts, most recent first. A ``JOIN`` is +used so that the author information from the ``user`` table is +available in the result. + +.. code-block:: python + :caption: ``flaskr/blog.py`` + + @bp.route('/') + def index(): + db = get_db() + posts = db.execute( + 'SELECT p.id, title, body, created, author_id, username' + ' FROM post p JOIN user u ON p.author_id = u.id' + ' ORDER BY created DESC' + ).fetchall() + return render_template('blog/index.html', posts=posts) + +.. code-block:: html+jinja + :caption: ``flaskr/templates/blog/index.html`` + + {% extends 'base.html' %} + + {% block header %} +

{% block title %}Posts{% endblock %}

+ {% if g.user %} + New + {% endif %} + {% endblock %} + + {% block content %} + {% for post in posts %} +
+
+
+

{{ post['title'] }}

+
by {{ post['username'] }} on {{ post['created'].strftime('%Y-%m-%d') }}
+
+ {% if g.user['id'] == post['author_id'] %} + Edit + {% endif %} +
+

{{ post['body'] }}

+
+ {% if not loop.last %} +
+ {% endif %} + {% endfor %} + {% endblock %} + +When a user is logged in, the ``header`` block adds a link to the +``create`` view. When the user is the author of a post, they'll see an +"Edit" link to the ``update`` view for that post. ``loop.last`` is a +special variable available inside `Jinja for loops`_. It's used to +display a line after each post except the last one, to visually separate +them. + +.. _Jinja for loops: http://jinja.pocoo.org/docs/templates/#for + + +Create +------ + +The ``create`` view works the same as the auth ``register`` view. Either +the form is displayed, or the posted data is validated and the post is +added to the database or an error is shown. + +The ``login_required`` decorator you wrote earlier is used on the blog +views. A user must be logged in to visit these views, otherwise they +will be redirected to the login page. + +.. code-block:: python + :caption: ``flaskr/blog.py`` + + @bp.route('/create', methods=('GET', 'POST')) + @login_required + def create(): + if request.method == 'POST': + title = request.form['title'] + body = request.form['body'] + error = None + + if not title: + error = 'Title is required.' + + if error is not None: + flash(error) + else: + db = get_db() + db.execute( + 'INSERT INTO post (title, body, author_id)' + ' VALUES (?, ?, ?)', + (title, body, g.user['id']) + ) + db.commit() + return redirect(url_for('blog.index')) + + return render_template('blog/create.html') + +.. code-block:: html+jinja + :caption: ``flaskr/templates/blog/create.html`` + + {% extends 'base.html' %} + + {% block header %} +

{% block title %}New Post{% endblock %}

+ {% endblock %} + + {% block content %} +
+ + + + + +
+ {% endblock %} + + +Update +------ + +Both the ``update`` and ``delete`` views will need to fetch a ``post`` +by ``id`` and check if the author matches the logged in user. To avoid +duplicating code, you can write a function to get the ``post`` and call +it from each view. + +.. code-block:: python + :caption: ``flaskr/blog.py`` + + def get_post(id, check_author=True): + post = get_db().execute( + 'SELECT p.id, title, body, created, author_id, username' + ' FROM post p JOIN user u ON p.author_id = u.id' + ' WHERE p.id = ?', + (id,) + ).fetchone() + + if post is None: + abort(404, "Post id {0} doesn't exist.".format(id)) + + if check_author and post['author_id'] != g.user['id']: + abort(403) + + return post + +:func:`abort` will raise a special exception that returns an HTTP status +code. It takes an optional message to show with the error, otherwise a +default message is used. ``404`` means "Not Found", and ``403`` means +"Forbidden". (``401`` means "Unauthorized", but you redirect to the +login page instead of returning that status.) + +The ``check_author`` argument is defined so that the function can be +used to get a ``post`` without checking the author. This would be useful +if you wrote a view to show an individual post on a page, where the user +doesn't matter because they're not modifying the post. + +.. code-block:: python + :caption: ``flaskr/blog.py`` + + @bp.route('//update', methods=('GET', 'POST')) + @login_required + def update(id): + post = get_post(id) + + if request.method == 'POST': + title = request.form['title'] + body = request.form['body'] + error = None + + if not title: + error = 'Title is required.' + + if error is not None: + flash(error) + else: + db = get_db() + db.execute( + 'UPDATE post SET title = ?, body = ?' + ' WHERE id = ?', + (title, body, id) + ) + db.commit() + return redirect(url_for('blog.index')) + + return render_template('blog/update.html', post=post) + +Unlike the views you've written so far, the ``update`` function takes +an argument, ``id``. That corresponds to the ```` in the route. +A real URL will look like ``/1/update``. Flask will capture the ``1``, +ensure it's an :class:`int`, and pass it as the ``id`` argument. If you +don't specify ``int:`` and instead do ````, it will be a string. +To generate a URL to the update page, :func:`url_for` needs to be passed +the ``id`` so it knows what to fill in: +``url_for('blog.update', id=post['id'])``. This is also in the +``index.html`` file above. + +The ``create`` and ``update`` views look very similar. The main +difference is that the ``update`` view uses a ``post`` object and an +``UPDATE`` query instead of an ``INSERT``. With some clever refactoring, +you could use one view and template for both actions, but for the +tutorial it's clearer to keep them separate. + +.. code-block:: html+jinja + :caption: ``flaskr/templates/blog/update.html`` + + {% extends 'base.html' %} + + {% block header %} +

{% block title %}Edit "{{ post['title'] }}"{% endblock %}

+ {% endblock %} + + {% block content %} +
+ + + + + +
+
+
+ +
+ {% endblock %} + +This template has two forms. The first posts the edited data to the +current page (``//update``). The other form contains only a button +and specifies an ``action`` attribute that posts to the delete view +instead. The button uses some JavaScript to show a confirmation dialog +before submitting. + +The pattern ``{{ request.form['title'] or post['title'] }}`` is used to +choose what data appears in the form. When the form hasn't been +submitted, the original ``post`` data appears, but if invalid form data +was posted you want to display that so the user can fix the error, so +``request.form`` is used instead. :data:`request` is another variable +that's automatically available in templates. + + +Delete +------ + +The delete view doesn't have its own template, the delete button is part +of ``update.html`` and posts to the ``//delete`` URL. Since there +is no template, it will only handle the ``POST`` method then redirect +to the ``index`` view. + +.. code-block:: python + :caption: ``flaskr/blog.py`` + + @bp.route('//delete', methods=('POST',)) + @login_required + def delete(id): + get_post(id) + db = get_db() + db.execute('DELETE FROM post WHERE id = ?', (id,)) + db.commit() + return redirect(url_for('blog.index')) + +Congratulations, you've now finished writing your application! Take some +time to try out everything in the browser. However, there's still more +to do before the project is complete. + +Continue to :doc:`install`. diff --git a/docs/tutorial/css.rst b/docs/tutorial/css.rst deleted file mode 100644 index 56414657..00000000 --- a/docs/tutorial/css.rst +++ /dev/null @@ -1,31 +0,0 @@ -.. _tutorial-css: - -Step 8: Adding Style -==================== - -Now that everything else works, it's time to add some style to the -application. Just create a stylesheet called :file:`style.css` in the -:file:`static` folder: - -.. sourcecode:: css - - body { font-family: sans-serif; background: #eee; } - a, h1, h2 { color: #377ba8; } - h1, h2 { font-family: 'Georgia', serif; margin: 0; } - h1 { border-bottom: 2px solid #eee; } - h2 { font-size: 1.2em; } - - .page { margin: 2em auto; width: 35em; border: 5px solid #ccc; - padding: 0.8em; background: white; } - .entries { list-style: none; margin: 0; padding: 0; } - .entries li { margin: 0.8em 1.2em; } - .entries li h2 { margin-left: -1em; } - .add-entry { font-size: 0.9em; border-bottom: 1px solid #ccc; } - .add-entry dl { font-weight: bold; } - .metanav { text-align: right; font-size: 0.8em; padding: 0.3em; - margin-bottom: 1em; background: #fafafa; } - .flash { background: #cee5F5; padding: 0.5em; - border: 1px solid #aacbe2; } - .error { background: #f0d6d6; padding: 0.5em; } - -Continue with :ref:`tutorial-testing`. diff --git a/docs/tutorial/database.rst b/docs/tutorial/database.rst new file mode 100644 index 00000000..51f20b61 --- /dev/null +++ b/docs/tutorial/database.rst @@ -0,0 +1,213 @@ +.. currentmodule:: flask + +Define and Access the Database +============================== + +The application will use a `SQLite`_ database to store users and posts. +Python comes with built-in support for SQLite in the :mod:`sqlite3` +module. + +SQLite is convenient because it doesn't require setting up a separate +database server and is built-in to Python. However, if concurrent +requests try to write to the database at the same time, they will slow +down as each write happens sequentially. Small applications won't notice +this. Once you become big, you may want to switch to a different +database. + +The tutorial doesn't go into detail about SQL. If you are not familiar +with it, the SQLite docs describe the `language`_. + +.. _SQLite: https://sqlite.org/about.html +.. _language: https://sqlite.org/lang.html + + +Connect to the Database +----------------------- + +The first thing to do when working with a SQLite database (and most +other Python database libraries) is to create a connection to it. Any +queries and operations are performed using the connection, which is +closed after the work is finished. + +In web applications this connection is typically tied to the request. It +is created at some point when handling a request, and closed before the +response is sent. + +.. code-block:: python + :caption: ``flaskr/db.py`` + + import sqlite3 + + import click + from flask import current_app, g + from flask.cli import with_appcontext + + + def get_db(): + if 'db' not in g: + g.db = sqlite3.connect( + current_app.config['DATABASE'], + detect_types=sqlite3.PARSE_DECLTYPES + ) + g.db.row_factory = sqlite3.Row + + return g.db + + + def close_db(e=None): + db = g.pop('db', None) + + if db is not None: + db.close() + +:data:`g` is a special object that is unique for each request. It is +used to store data that might be accessed by multiple functions during +the request. The connection is stored and reused instead of creating a +new connection if ``get_db`` is called a second time in the same +request. + +:data:`current_app` is another special object that points to the Flask +application handling the request. Since you used an application factory, +there is no application object when writing the rest of your code. +``get_db`` will be called when the application has been created and is +handling a request, so :data:`current_app` can be used. + +:func:`sqlite3.connect` establishes a connection to the file pointed at +by the ``DATABASE`` configuration key. This file doesn't have to exist +yet, and won't until you initialize the database later. + +:class:`sqlite3.Row` tells the connection to return rows that behave +like dicts. This allows accessing the columns by name. + +``close_db`` checks if a connection was created by checking if ``g.db`` +was set. If the connection exists, it is closed. Further down you will +tell your application about the ``close_db`` function in the application +factory so that it is called after each request. + + +Create the Tables +----------------- + +In SQLite, data is stored in *tables* and *columns*. These need to be +created before you can store and retrieve data. Flaskr will store users +in the ``user`` table, and posts in the ``post`` table. Create a file +with the SQL commands needed to create empty tables: + +.. code-block:: sql + :caption: ``flaskr/schema.sql`` + + DROP TABLE IF EXISTS user; + DROP TABLE IF EXISTS post; + + CREATE TABLE user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL + ); + + CREATE TABLE post ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + author_id INTEGER NOT NULL, + created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + title TEXT NOT NULL, + body TEXT NOT NULL, + FOREIGN KEY (author_id) REFERENCES user (id) + ); + +Add the Python functions that will run these SQL commands to the +``db.py`` file: + +.. code-block:: python + :caption: ``flaskr/db.py`` + + def init_db(): + db = get_db() + + with current_app.open_resource('schema.sql') as f: + db.executescript(f.read().decode('utf8')) + + + @click.command('init-db') + @with_appcontext + def init_db_command(): + """Clear the existing data and create new tables.""" + init_db() + click.echo('Initialized the database.') + +:meth:`open_resource() ` opens a file relative to +the ``flaskr`` package, which is useful since you won't necessarily know +where that location is when deploying the application later. ``get_db`` +returns a database connection, which is used to execute the commands +read from the file. + +:func:`click.command` defines a command line command called ``init-db`` +that calls the ``init_db`` function and shows a success message to the +user. You can read :ref:`cli` to learn more about writing commands. + + +Register with the Application +----------------------------- + +The ``close_db`` and ``init_db_command`` functions need to be registered +with the application instance, otherwise they won't be used by the +application. However, since you're using a factory function, that +instance isn't available when writing the functions. Instead, write a +function that takes an application and does the registration. + +.. code-block:: python + :caption: ``flaskr/db.py`` + + def init_app(app): + app.teardown_appcontext(close_db) + app.cli.add_command(init_db_command) + +:meth:`app.teardown_appcontext() ` tells +Flask to call that function when cleaning up after returning the +response. + +:meth:`app.cli.add_command() ` adds a new +command that can be called with the ``flask`` command. + +Import and call this function from the factory. Place the new code at +the end of the factory function before returning the app. + +.. code-block:: python + :caption: ``flaskr/__init__.py`` + + def create_app(): + app = ... + # existing code omitted + + from . import db + db.init_app(app) + + return app + + +Initialize the Database File +---------------------------- + +Now that ``init-db`` has been registered with the app, it can be called +using the ``flask`` command, similar to the ``run`` command from the +previous page. + +.. note:: + + If you're still running the server from the previous page, you can + either stop the server, or run this command in a new terminal. If + you use a new terminal, remember to change to your project directory + and activate the env as described in :ref:`install-activate-env`. + You'll also need to set ``FLASK_APP`` and ``FLASK_ENV`` as shown on + the previous page. + +Run the ``init-db`` command: + +.. code-block:: none + + flask init-db + Initialized the database. + +There will now be a ``flaskr.sqlite`` file in the ``instance`` folder in +your project. + +Continue to :doc:`views`. diff --git a/docs/tutorial/dbcon.rst b/docs/tutorial/dbcon.rst deleted file mode 100644 index 179c962b..00000000 --- a/docs/tutorial/dbcon.rst +++ /dev/null @@ -1,78 +0,0 @@ -.. _tutorial-dbcon: - -Step 4: Database Connections ----------------------------- - -Let's continue building our code in the ``flaskr.py`` file. -(Scroll to the end of the page for more about project layout.) - -You currently have a function for establishing a database connection with -`connect_db`, but by itself, it is not particularly useful. Creating and -closing database connections all the time is very inefficient, so you will -need to keep it around for longer. Because database connections -encapsulate a transaction, you will need to make sure that only one -request at a time uses the connection. An elegant way to do this is by -utilizing the *application context*. - -Flask provides two contexts: the *application context* and the -*request context*. For the time being, all you have to know is that there -are special variables that use these. For instance, the -:data:`~flask.request` variable is the request object associated with -the current request, whereas :data:`~flask.g` is a general purpose -variable associated with the current application context. The tutorial -will cover some more details of this later on. - -For the time being, all you have to know is that you can store information -safely on the :data:`~flask.g` object. - -So when do you put it on there? To do that you can make a helper -function. The first time the function is called, it will create a database -connection for the current context, and successive calls will return the -already established connection:: - - def get_db(): - """Opens a new database connection if there is none yet for the - current application context. - """ - if not hasattr(g, 'sqlite_db'): - g.sqlite_db = connect_db() - return g.sqlite_db - -Now you know how to connect, but how can you properly disconnect? For -that, Flask provides us with the :meth:`~flask.Flask.teardown_appcontext` -decorator. It's executed every time the application context tears down:: - - @app.teardown_appcontext - def close_db(error): - """Closes the database again at the end of the request.""" - if hasattr(g, 'sqlite_db'): - g.sqlite_db.close() - -Functions marked with :meth:`~flask.Flask.teardown_appcontext` are called -every time the app context tears down. What does this mean? -Essentially, the app context is created before the request comes in and is -destroyed (torn down) whenever the request finishes. A teardown can -happen because of two reasons: either everything went well (the error -parameter will be ``None``) or an exception happened, in which case the error -is passed to the teardown function. - -Curious about what these contexts mean? Have a look at the -:ref:`app-context` documentation to learn more. - -Continue to :ref:`tutorial-dbinit`. - -.. hint:: Where do I put this code? - - If you've been following along in this tutorial, you might be wondering - where to put the code from this step and the next. A logical place is to - group these module-level functions together, and put your new - ``get_db`` and ``close_db`` functions below your existing - ``connect_db`` function (following the tutorial line-by-line). - - If you need a moment to find your bearings, take a look at how the `example - source`_ is organized. In Flask, you can put all of your application code - into a single Python module. You don't have to, and if your app :ref:`grows - larger `, it's a good idea not to. - -.. _example source: - https://github.com/pallets/flask/tree/master/examples/flaskr/ diff --git a/docs/tutorial/dbinit.rst b/docs/tutorial/dbinit.rst deleted file mode 100644 index 484354ba..00000000 --- a/docs/tutorial/dbinit.rst +++ /dev/null @@ -1,80 +0,0 @@ -.. _tutorial-dbinit: - -Step 5: Creating The Database -============================= - -As outlined earlier, Flaskr is a database powered application, and more -precisely, it is an application powered by a relational database system. Such -systems need a schema that tells them how to store that information. -Before starting the server for the first time, it's important to create -that schema. - -Such a schema could be created by piping the ``schema.sql`` file into the -``sqlite3`` command as follows:: - - sqlite3 /tmp/flaskr.db < schema.sql - -However, the downside of this is that it requires the ``sqlite3`` command -to be installed, which is not necessarily the case on every system. This -also requires that you provide the path to the database, which can introduce -errors. - -Instead of the ``sqlite3`` command above, it's a good idea to add a function -to our application that initializes the database for you. To do this, you -can create a function and hook it into a :command:`flask` command that -initializes the database. - -Take a look at the code segment below. A good place to add this function, -and command, is just below the ``connect_db`` function in :file:`flaskr.py`:: - - def init_db(): - db = get_db() - - with app.open_resource('schema.sql', mode='r') as f: - db.cursor().executescript(f.read()) - - db.commit() - - - @app.cli.command('initdb') - def initdb_command(): - """Initializes the database.""" - - init_db() - print('Initialized the database.') - -The ``app.cli.command()`` decorator registers a new command with the -:command:`flask` script. When the command executes, Flask will automatically -create an application context which is bound to the right application. -Within the function, you can then access :attr:`flask.g` and other things as -you might expect. When the script ends, the application context tears down -and the database connection is released. - -You will want to keep an actual function around that initializes the database, -though, so that we can easily create databases in unit tests later on. (For -more information see :ref:`testing`.) - -The :func:`~flask.Flask.open_resource` method of the application object -is a convenient helper function that will open a resource that the -application provides. This function opens a file from the resource -location (the :file:`flaskr/flaskr` folder) and allows you to read from it. -It is used in this example to execute a script on the database connection. - -The connection object provided by SQLite can give you a cursor object. -On that cursor, there is a method to execute a complete script. Finally, you -only have to commit the changes. SQLite3 and other transactional -databases will not commit unless you explicitly tell it to. - -Now, in a terminal, from the application root directory :file:`flaskr/` it is -possible to create a database with the :command:`flask` script:: - - flask initdb - Initialized the database. - -.. admonition:: Troubleshooting - - If you get an exception later on stating that a table cannot be found, check - that you did execute the ``initdb`` command and that your table names are - correct (singular vs. plural, for example). - -Continue with :ref:`tutorial-views` diff --git a/docs/tutorial/deploy.rst b/docs/tutorial/deploy.rst new file mode 100644 index 00000000..a0c052ea --- /dev/null +++ b/docs/tutorial/deploy.rst @@ -0,0 +1,121 @@ +Deploy to Production +==================== + +This part of the tutorial assumes you have a server that you want to +deploy your application to. It gives an overview of how to create the +distribution file and install it, but won't go into specifics about +what server or software to use. You can set up a new environment on your +development computer to try out the instructions below, but probably +shouldn't use it for hosting a real public application. See +:doc:`/deploying/index` for a list of many different ways to host your +application. + + +Build and Install +----------------- + +When you want to deploy your application elsewhere, you build a +distribution file. The current standard for Python distribution is the +*wheel* format, with the ``.whl`` extension. Make sure the wheel library +is installed first: + +.. code-block:: none + + pip install wheel + +Running ``setup.py`` with Python gives you a command line tool to issue +build-related commands. The ``bdist_wheel`` command will build a wheel +distribution file. + +.. code-block:: none + + python setup.py bdist_wheel + +You can find the file in ``dist/flaskr-1.0.0-py3-none-any.whl``. The +file name is the name of the project, the version, and some tags about +the file can install. + +Copy this file to another machine, +:ref:`set up a new virtualenv `, then install the +file with ``pip``. + +.. code-block:: none + + pip install flaskr-1.0.0-py3-none-any.whl + +Pip will install your project along with its dependencies. + +Since this is a different machine, you need to run ``init-db`` again to +create the database in the instance folder. + +.. code-block:: none + + export FLASK_APP=flaskr + flask init-db + +When Flask detects that it's installed (not in editable mode), it uses +a different directory for the instance folder. You can find it at +``venv/var/flaskr-instance`` instead. + + +Configure the Secret Key +------------------------ + +In the beginning of the tutorial that you gave a default value for +:data:`SECRET_KEY`. This should be changed to some random bytes in +production. Otherwise, attackers could use the public ``'dev'`` key to +modify the session cookie, or anything else that uses the secret key. + +You can use the following command to output a random secret key: + +.. code-block:: none + + python -c 'import os; print(os.urandom(16))' + + b'_5#y2L"F4Q8z\n\xec]/' + +Create the ``config.py`` file in the instance folder, which the factory +will read from if it exists. Copy the generated value into it. + +.. code-block:: python + :caption: ``venv/var/flaskr-instance/config.py`` + + SECRET_KEY = b'_5#y2L"F4Q8z\n\xec]/' + +You can also set any other necessary configuration here, although +``SECRET_KEY`` is the only one needed for Flaskr. + + +Run with a Production Server +---------------------------- + +When running publicly rather than in development, you should not use the +built-in development server (``flask run``). The development server is +provided by Werkzeug for convenience, but is not designed to be +particularly efficient, stable, or secure. + +Instead, use a production WSGI server. For example, to use `Waitress`_, +first install it in the virtual environment: + +.. code-block:: none + + pip install waitress + +You need to tell Waitress about your application, but it doesn't use +``FLASK_APP`` like ``flask run`` does. You need to tell it to import and +call the application factory to get an application object. + +.. code-block:: none + + waitress-serve --call 'flaskr:create_app' + + Serving on http://0.0.0.0:8080 + +See :doc:`/deploying/index` for a list of many different ways to host +your application. Waitress is just an example, chosen for the tutorial +because it supports both Windows and Linux. There are many more WSGI +servers and deployment options that you may choose for your project. + +.. _Waitress: https://docs.pylonsproject.org/projects/waitress/ + +Continue to :doc:`next`. diff --git a/docs/tutorial/factory.rst b/docs/tutorial/factory.rst new file mode 100644 index 00000000..62462e1c --- /dev/null +++ b/docs/tutorial/factory.rst @@ -0,0 +1,177 @@ +.. currentmodule:: flask + +Application Setup +================= + +A Flask application is an instance of the :class:`Flask` class. +Everything about the application, such as configuration and URLs, will +be registered with this class. + +The most straightforward way to create a Flask application is to create +a global :class:`Flask` instance directly at the top of your code, like +how the "Hello, World!" example did on the previous page. While this is +simple and useful in some cases, it can cause some tricky issues as the +project grows. + +Instead of creating a :class:`Flask` instance globally, you will create +it inside a function. This function is known as the *application +factory*. Any configuration, registration, and other setup the +application needs will happen inside the function, then the application +will be returned. + + +The Application Factory +----------------------- + +It's time to start coding! Create the ``flaskr`` directory and add the +``__init__.py`` file. The ``__init__.py`` serves double duty: it will +contain the application factory, and it tells Python that the ``flaskr`` +directory should be treated as a package. + +.. code-block:: none + + mkdir flaskr + +.. code-block:: python + :caption: ``flaskr/__init__.py`` + + import os + + from flask import Flask + + + def create_app(test_config=None): + # create and configure the app + app = Flask(__name__, instance_relative_config=True) + app.config.from_mapping( + SECRET_KEY='dev', + DATABASE=os.path.join(app.instance_path, 'flaskr.sqlite'), + ) + + if test_config is None: + # load the instance config, if it exists, when not testing + app.config.from_pyfile('config.py', silent=True) + else: + # load the test config if passed in + app.config.from_mapping(test_config) + + # ensure the instance folder exists + try: + os.makedirs(app.instance_path) + except OSError: + pass + + # a simple page that says hello + @app.route('/hello') + def hello(): + return 'Hello, World!' + + return app + +``create_app`` is the application factory function. You'll add to it +later in the tutorial, but it already does a lot. + +#. ``app = Flask(__name__, instance_relative_config=True)`` creates the + :class:`Flask` instance. + + * ``__name__`` is the name of the current Python module. The app + needs to know where it's located to set up some paths, and + ``__name__`` is a convenient way to tell it that. + + * ``instance_relative_config=True`` tells the app that + configuration files are relative to the + :ref:`instance folder `. The instance folder + is located outside the ``flaskr`` package and can hold local + data that shouldn't be committed to version control, such as + configuration secrets and the database file. + +#. :meth:`app.config.from_mapping() ` sets + some default configuration that the app will use: + + * :data:`SECRET_KEY` is used by Flask and extensions to keep data + safe. It's set to ``'dev'`` to provide a convenient value + during development, but it should be overridden with a random + value when deploying. + + * ``DATABASE`` is the path where the SQLite database file will be + saved. It's under + :attr:`app.instance_path `, which is the + path that Flask has chosen for the instance folder. You'll learn + more about the database in the next section. + +#. :meth:`app.config.from_pyfile() ` overrides + the default configuration with values taken from the ``config.py`` + file in the instance folder if it exists. For example, when + deploying, this can be used to set a real ``SECRET_KEY``. + + * ``test_config`` can also be passed to the factory, and will be + used instead of the instance configuration. This is so the tests + you'll write later in the tutorial can be configured + independently of any development values you have configured. + +#. :func:`os.makedirs` ensures that + :attr:`app.instance_path ` exists. Flask + doesn't create the instance folder automatically, but it needs to be + created because your project will create the SQLite database file + there. + +#. :meth:`@app.route() ` creates a simple route so you can + see the application working before getting into the rest of the + tutorial. It creates a connection between the URL ``/hello`` and a + function that returns a response, the string ``'Hello, World!'`` in + this case. + + +Run The Application +------------------- + +Now you can run your application using the ``flask`` command. From the +terminal, tell Flask where to find your application, then run it in +development mode. + +Development mode shows an interactive debugger whenever a page raises an +exception, and restarts the server whenever you make changes to the +code. You can leave it running and just reload the browser page as you +follow the tutorial. + +For Linux and Mac: + +.. code-block:: none + + export FLASK_APP=flaskr + export FLASK_ENV=development + flask run + +For Windows cmd, use ``set`` instead of ``export``: + +.. code-block:: none + + set FLASK_APP=flaskr + set FLASK_ENV=development + flask run + +For Windows PowerShell, use ``$env:`` instead of ``export``: + +.. code-block:: none + + $env:FLASK_APP = "flaskr" + $env:FLASK_ENV = "development" + flask run + +You'll see output similar to this: + +.. code-block:: none + + * Serving Flask app "flaskr" + * Environment: development + * Debug mode: on + * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) + * Restarting with stat + * Debugger is active! + * Debugger PIN: 855-212-761 + +Visit http://127.0.0.1:5000/hello in a browser and you should see the +"Hello, World!" message. Congratulations, you're now running your Flask +web application! + +Continue to :doc:`database`. diff --git a/docs/tutorial/flaskr_edit.png b/docs/tutorial/flaskr_edit.png new file mode 100644 index 0000000000000000000000000000000000000000..6cd6e3980fbf1b5b619d2e013723368d137fafdc GIT binary patch literal 13259 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 literal 0 HcmV?d00001 diff --git a/docs/tutorial/flaskr_index.png b/docs/tutorial/flaskr_index.png new file mode 100644 index 0000000000000000000000000000000000000000..aa2b50f552ee094df735372fcf534a5c14d0c325 GIT binary patch literal 11675 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 literal 0 HcmV?d00001 diff --git a/docs/tutorial/folders.rst b/docs/tutorial/folders.rst deleted file mode 100644 index 23fefaec..00000000 --- a/docs/tutorial/folders.rst +++ /dev/null @@ -1,31 +0,0 @@ -.. _tutorial-folders: - -Step 0: Creating The Folders -============================ - -It is recommended to install your Flask application within a virtualenv. Please -read the :ref:`installation` section to set up your environment. - -Now that you have installed Flask, you will need to create the folders required -for this tutorial. Your directory structure will look like this:: - - /flaskr - /flaskr - /static - /templates - -The application will be installed and run as Python package. This is the -recommended way to install and run Flask applications. You will see exactly -how to run ``flaskr`` later on in this tutorial. - -For now go ahead and create the applications directory structure. In the next -few steps you will be creating the database schema as well as the main module. - -As a quick side note, the files inside of the :file:`static` folder are -available to users of the application via HTTP. This is the place where CSS and -JavaScript files go. Inside the :file:`templates` folder, Flask will look for -`Jinja2`_ templates. You will see examples of this later on. - -For now you should continue with :ref:`tutorial-schema`. - -.. _Jinja2: http://jinja.pocoo.org/ diff --git a/docs/tutorial/index.rst b/docs/tutorial/index.rst index 7eee5fa0..9b43c510 100644 --- a/docs/tutorial/index.rst +++ b/docs/tutorial/index.rst @@ -3,31 +3,64 @@ Tutorial ======== -Learn by example to develop an application with Python and Flask. - -In this tutorial, we will create a simple blogging application. It only -supports one user, only allows text entries, and has no feeds or comments. - -While very simple, this example still features everything you need to get -started. In addition to Flask, we will use SQLite for the database, which is -built-in to Python, so there is nothing else you need. - -If you want the full source code in advance or for comparison, check out -the `example source`_. - -.. _example source: https://github.com/pallets/flask/tree/master/examples/flaskr/ - .. toctree:: - :maxdepth: 2 + :caption: Contents: + :maxdepth: 1 - introduction - folders - schema - setup - packaging - dbcon - dbinit - views - templates - css - testing + layout + factory + database + views + templates + static + blog + install + tests + deploy + next + +This tutorial will walk you through creating a basic blog application +called Flaskr. Users will be able to register, log in, create posts, +and edit or delete their own posts. You will be able to package and +install the application on other computers. + +.. image:: flaskr_index.png + :align: center + :class: screenshot + :alt: screenshot of index page + +It's assumed that you're already familiar with Python. The `official +tutorial`_ in the Python docs is a great way to learn or review first. + +.. _official tutorial: https://docs.python.org/3/tutorial/ + +While it's designed to give a good starting point, the tutorial doesn't +cover all of Flask's features. Check out the :ref:`quickstart` for an +overview of what Flask can do, then dive into the docs to find out more. +The tutorial only uses what's provided by Flask and Python. In another +project, you might decide to use :ref:`extensions` or other libraries to +make some tasks simpler. + +.. image:: flaskr_login.png + :align: center + :class: screenshot + :alt: screenshot of login page + +Flask is flexible. It doesn't require you to use any particular project +or code layout. However, when first starting, it's helpful to use a more +structured approach. This means that the tutorial will require a bit of +boilerplate up front, but it's done to avoid many common pitfalls that +new developers encounter, and it creates a project that's easy to expand +on. Once you become more comfortable with Flask, you can step out of +this structure and take full advantage of Flask's flexibility. + +.. image:: flaskr_edit.png + :align: center + :class: screenshot + :alt: screenshot of login page + +:gh:`The tutorial project is available as an example in the Flask +repository `, if you want to compare your project +with the final product as you follow the tutorial. + +Continue to :doc:`layout`. diff --git a/docs/tutorial/install.rst b/docs/tutorial/install.rst new file mode 100644 index 00000000..fff0b52c --- /dev/null +++ b/docs/tutorial/install.rst @@ -0,0 +1,113 @@ +Make the Project Installable +============================ + +Making your project installable means that you can build a +*distribution* file and install that in another environment, just like +you installed Flask in your project's environment. This makes deploying +your project the same as installing any other library, so you're using +all the standard Python tools to manage everything. + +Installing also comes with other benefits that might not be obvious from +the tutorial or as a new Python user, including: + +* Currently, Python and Flask understand how to use the ``flaskr`` + package only because you're running from your project's directory. + Installing means you can import it no matter where you run from. + +* You can manage your project's dependencies just like other packages + do, so ``pip install yourproject.whl`` installs them. + +* Test tools can isolate your test environment from your development + environment. + +.. note:: + This is being introduced late in the tutorial, but in your future + projects you should always start with this. + + +Describe the Project +-------------------- + +The ``setup.py`` file describes your project and the files that belong +to it. + +.. code-block:: python + :caption: ``setup.py`` + + from setuptools import find_packages, setup + + setup( + name='flaskr', + version='1.0.0', + packages=find_packages(), + include_package_data=True, + zip_safe=False, + install_requires=[ + 'flask', + ], + ) + + +``packages`` tells Python what package directories (and the Python files +they contain) to include. ``find_packages()`` finds these directories +automatically so you don't have to type them out. To include other +files, such as the static and templates directories, +``include_package_data`` is set. Python needs another file named +``MANIFEST.in`` to tell what this other data is. + +.. code-block:: none + :caption: ``MANIFEST.in`` + + include flaskr/schema.sql + graft flaskr/static + graft flaskr/templates + global-exclude *.pyc + +This tells Python to copy everything in the ``static`` and ``templates`` +directories, and the ``schema.sql`` file, but to exclude all bytecode +files. + +See the `official packaging guide`_ for another explanation of the files +and options used. + +.. _official packaging guide: https://packaging.python.org/tutorials/distributing-packages/ + + +Install the Project +------------------- + +Use ``pip`` to install your project in the virtual environment. + +.. code-block:: none + + pip install -e . + +This tells pip to find ``setup.py`` in the current directory and install +it in *editable* or *development* mode. Editable mode means that as you +make changes to your local code, you'll only need to re-install if you +change the metadata about the project, such as its dependencies. + +You can observe that the project is now installed with ``pip list``. + +.. code-block:: none + + pip list + + Package Version Location + -------------- --------- ---------------------------------- + click 6.7 + Flask 1.0 + flaskr 1.0.0 /home/user/Projects/flask-tutorial + itsdangerous 0.24 + Jinja2 2.10 + MarkupSafe 1.0 + pip 9.0.3 + setuptools 39.0.1 + Werkzeug 0.14.1 + wheel 0.30.0 + +Nothing changes from how you've been running your project so far. +``FLASK_APP`` is still set to ``flaskr`` and ``flask run`` still runs +the application. + +Continue to :doc:`tests`. diff --git a/docs/tutorial/introduction.rst b/docs/tutorial/introduction.rst deleted file mode 100644 index ed984715..00000000 --- a/docs/tutorial/introduction.rst +++ /dev/null @@ -1,39 +0,0 @@ -.. _tutorial-introduction: - -Introducing Flaskr -================== - -This tutorial will demonstrate a blogging application named Flaskr, but feel -free to choose your own less Web-2.0-ish name ;) Essentially, it will do the -following things: - -1. Let the user sign in and out with credentials specified in the - configuration. Only one user is supported. -2. When the user is logged in, they can add new entries to the page - consisting of a text-only title and some HTML for the text. This HTML - is not sanitized because we trust the user here. -3. The index page shows all entries so far in reverse chronological order - (newest on top) and the user can add new ones from there if logged in. - -SQLite3 will be used directly for this application because it's good enough -for an application of this size. For larger applications, however, -it makes a lot of sense to use `SQLAlchemy`_, as it handles database -connections in a more intelligent way, allowing you to target different -relational databases at once and more. You might also want to consider -one of the popular NoSQL databases if your data is more suited for those. - -.. warning:: - If you're following the tutorial from a specific version of the docs, be - sure to check out the same tag in the repository, otherwise the tutorial - may be different than the example. - -Here is a screenshot of the final application: - -.. image:: ../_static/flaskr.png - :align: center - :class: screenshot - :alt: screenshot of the final application - -Continue with :ref:`tutorial-folders`. - -.. _SQLAlchemy: https://www.sqlalchemy.org/ diff --git a/docs/tutorial/layout.rst b/docs/tutorial/layout.rst new file mode 100644 index 00000000..2d7ddebe --- /dev/null +++ b/docs/tutorial/layout.rst @@ -0,0 +1,110 @@ +Project Layout +============== + +Create a project directory and enter it: + +.. code-block:: none + + mkdir flask-tutorial + cd flask-tutorial + +Then follow the :doc:`installation instructions ` to set +up a Python virtual environment and install Flask for your project. + +The tutorial will assume you're working from the ``flask-tutorial`` +directory from now on. The file names at the top of each code block are +relative to this directory. + +---- + +A Flask application can be as simple as a single file. + +.. code-block:: python + :caption: ``hello.py`` + + from flask import Flask + + app = Flask(__name__) + + + @app.route('/') + def hello(): + return 'Hello, World!' + +However, as a project get bigger, it becomes overwhelming to keep all +the code in one file. Python projects use *packages* to organize code +into multiple modules that can be imported where needed, and the +tutorial will do this as well. + +The project directory will contain: + +* ``flaskr/``, a Python package containing your application code and + files. +* ``tests/``, a directory containing test modules. +* ``venv/``, a Python virtual environment where Flask and other + dependencies are installed. +* Installation files telling Python how to install your project. +* Version control config, such as `git`_. You should make a habit of + using some type of version control for all your projects, no matter + the size. +* Any other project files you might add in the future. + +.. _git: https://git-scm.com/ + +By the end, your project layout will look like this: + +.. code-block:: none + + /home/user/Projects/flask-tutorial + ├── flaskr/ + │   ├── __init__.py + │   ├── db.py + │   ├── schema.sql + │   ├── auth.py + │   ├── blog.py + │   ├── templates/ + │   │ ├── base.html + │   │ ├── auth/ + │   │ │   ├── login.html + │   │ │   └── register.html + │   │ └── blog/ + │   │ ├── create.html + │   │ ├── index.html + │   │ └── update.html + │   └── static/ + │      └── style.css + ├── tests/ + │   ├── conftest.py + │   ├── data.sql + │   ├── test_factory.py + │   ├── test_db.py + │  ├── test_auth.py + │  └── test_blog.py + ├── venv/ + ├── setup.py + └── MANIFEST.in + +If you're using version control, the following files that are generated +while running your project should be ignored. There may be other files +based on the editor you use. In general, ignore files that you didn't +write. For example, with git: + +.. code-block:: none + :caption: ``.gitignore`` + + venv/ + + *.pyc + __pycache__/ + + instance/ + + .pytest_cache/ + .coverage + htmlcov/ + + dist/ + build/ + *.egg-info/ + +Continue to :doc:`factory`. diff --git a/docs/tutorial/next.rst b/docs/tutorial/next.rst new file mode 100644 index 00000000..07bbc048 --- /dev/null +++ b/docs/tutorial/next.rst @@ -0,0 +1,38 @@ +Keep Developing! +================ + +You've learned about quite a few Flask and Python concepts throughout +the tutorial. Go back and review the tutorial and compare your code with +the steps you took to get there. Compare your project to the +:gh:`example project `, which might look a bit +different due to the step-by-step nature of the tutorial. + +There's a lot more to Flask than what you've seen so far. Even so, +you're now equipped to start developing your own web applications. Check +out the :ref:`quickstart` for an overview of what Flask can do, then +dive into the docs to keep learning. Flask uses `Jinja`_, `Click`_, +`Werkzeug`_, and `ItsDangerous`_ behind the scenes, and they all have +their own documentation too. You'll also be interested in +:ref:`extensions` which make tasks like working with the database or +validating form data easier and more powerful. + +If you want to keep developing your Flaskr project, here are some ideas +for what to try next: + +* A detail view to show a single post. Click a post's title to go to + its page. +* Like / unlike a post. +* Comments. +* Tags. Clicking a tag shows all the posts with that tag. +* A search box that filters the index page by name. +* Paged display. Only show 5 posts per page. +* Upload an image to go along with a post. +* Format posts using Markdown. +* An RSS feed of new posts. + +Have fun and make awesome applications! + +.. _Jinja: https://palletsprojects.com/p/jinja/ +.. _Click: https://palletsprojects.com/p/click/ +.. _Werkzeug: https://palletsprojects.com/p/werkzeug/ +.. _ItsDangerous: https://palletsprojects.com/p/itsdangerous/ diff --git a/docs/tutorial/packaging.rst b/docs/tutorial/packaging.rst deleted file mode 100644 index e08f26fa..00000000 --- a/docs/tutorial/packaging.rst +++ /dev/null @@ -1,108 +0,0 @@ -.. _tutorial-packaging: - -Step 3: Installing flaskr as a Package -====================================== - -Flask is now shipped with built-in support for `Click`_. Click provides -Flask with enhanced and extensible command line utilities. Later in this -tutorial you will see exactly how to extend the ``flask`` command line -interface (CLI). - -A useful pattern to manage a Flask application is to install your app -following the `Python Packaging Guide`_. Presently this involves -creating two new files; :file:`setup.py` and :file:`MANIFEST.in` in the -projects root directory. You also need to add an :file:`__init__.py` -file to make the :file:`flaskr/flaskr` directory a package. After these -changes, your code structure should be:: - - /flaskr - /flaskr - __init__.py - /static - /templates - flaskr.py - schema.sql - setup.py - MANIFEST.in - -Create the ``setup.py`` file for ``flaskr`` with the following content:: - - from setuptools import setup - - setup( - name='flaskr', - packages=['flaskr'], - include_package_data=True, - install_requires=[ - 'flask', - ], - ) - -When using setuptools, it is also necessary to specify any special files -that should be included in your package (in the :file:`MANIFEST.in`). -In this case, the static and templates directories need to be included, -as well as the schema. - -Create the :file:`MANIFEST.in` and add the following lines:: - - graft flaskr/templates - graft flaskr/static - include flaskr/schema.sql - -Next, to simplify locating the application, create the file, -:file:`flaskr/__init__.py` containing only the following import statement:: - - from .flaskr import app - -This import statement brings the application instance into the top-level -of the application package. When it is time to run the application, the -Flask development server needs the location of the app instance. This -import statement simplifies the location process. Without the above -import statement, the export statement a few steps below would need to be -``export FLASK_APP=flaskr.flaskr``. - -At this point you should be able to install the application. As usual, it -is recommended to install your Flask application within a `virtualenv`_. -With that said, from the ``flaskr/`` directory, go ahead and install the -application with:: - - pip install --editable . - -The above installation command assumes that it is run within the projects -root directory, ``flaskr/``. The ``editable`` flag allows editing -source code without having to reinstall the Flask app each time you make -changes. The flaskr app is now installed in your virtualenv (see output -of ``pip freeze``). - -With that out of the way, you should be able to start up the application. -Do this on Mac or Linux with the following commands in ``flaskr/``:: - - export FLASK_APP=flaskr - export FLASK_ENV=development - flask run - -(In case you are on Windows you need to use ``set`` instead of ``export``). -Exporting ``FLASK_ENV=development`` turns on all development features -such as enabling the interactive debugger. - -*Never leave debug mode activated in a production system*, because it will -allow users to execute code on the server! - -You will see a message telling you that server has started along with -the address at which you can access it in a browser. - -When you head over to the server in your browser, you will get a 404 error -because we don't have any views yet. That will be addressed a little later, -but first, you should get the database working. - -.. admonition:: Externally Visible Server - - Want your server to be publicly available? Check out the - :ref:`externally visible server ` section for more - information. - -Continue with :ref:`tutorial-dbcon`. - -.. _Click: http://click.pocoo.org -.. _Python Packaging Guide: https://packaging.python.org -.. _virtualenv: https://virtualenv.pypa.io diff --git a/docs/tutorial/schema.rst b/docs/tutorial/schema.rst deleted file mode 100644 index 00f56f09..00000000 --- a/docs/tutorial/schema.rst +++ /dev/null @@ -1,25 +0,0 @@ -.. _tutorial-schema: - -Step 1: Database Schema -======================= - -In this step, you will create the database schema. Only a single table is -needed for this application and it will only support SQLite. All you need to do -is put the following contents into a file named :file:`schema.sql` in the -:file:`flaskr/flaskr` folder: - -.. sourcecode:: sql - - drop table if exists entries; - create table entries ( - id integer primary key autoincrement, - title text not null, - 'text' text not null - ); - -This schema consists of a single table called ``entries``. Each row in -this table has an ``id``, a ``title``, and a ``text``. The ``id`` is an -automatically incrementing integer and a primary key, the other two are -strings that must not be null. - -Continue with :ref:`tutorial-setup`. diff --git a/docs/tutorial/setup.rst b/docs/tutorial/setup.rst deleted file mode 100644 index 5c69ecca..00000000 --- a/docs/tutorial/setup.rst +++ /dev/null @@ -1,101 +0,0 @@ -.. _tutorial-setup: - -Step 2: Application Setup Code -============================== - -Next, we will create the application module, :file:`flaskr.py`. Just like the -:file:`schema.sql` file you created in the previous step, this file should be -placed inside of the :file:`flaskr/flaskr` folder. - -For this tutorial, all the Python code we use will be put into this file -(except for one line in ``__init__.py``, and any testing or optional files you -decide to create). - -The first several lines of code in the application module are the needed import -statements. After that there will be a few lines of configuration code. - -For small applications like ``flaskr``, it is possible to drop the configuration -directly into the module. However, a cleaner solution is to create a separate -``.py`` file, load that, and import the values from there. - -Here are the import statements (in :file:`flaskr.py`):: - - import os - import sqlite3 - - from flask import (Flask, request, session, g, redirect, url_for, abort, - render_template, flash) - -The next couple lines will create the actual application instance and -initialize it with the config from the same file in :file:`flaskr.py`:: - - app = Flask(__name__) # create the application instance :) - app.config.from_object(__name__) # load config from this file , flaskr.py - - # Load default config and override config from an environment variable - app.config.update( - DATABASE=os.path.join(app.root_path, 'flaskr.db'), - SECRET_KEY=b'_5#y2L"F4Q8z\n\xec]/', - USERNAME='admin', - PASSWORD='default' - ) - app.config.from_envvar('FLASKR_SETTINGS', silent=True) - -In the above code, the :class:`~flask.Config` object works similarly to a -dictionary, so it can be updated with new values. - -.. admonition:: Database Path - - Operating systems know the concept of a current working directory for - each process. Unfortunately, you cannot depend on this in web - applications because you might have more than one application in the - same process. - - For this reason the ``app.root_path`` attribute can be used to - get the path to the application. Together with the ``os.path`` module, - files can then easily be found. In this example, we place the - database right next to it. - - For a real-world application, it's recommended to use - :ref:`instance-folders` instead. - -Usually, it is a good idea to load a separate, environment-specific -configuration file. Flask allows you to import multiple configurations and it -will use the setting defined in the last import. This enables robust -configuration setups. :meth:`~flask.Config.from_envvar` can help achieve -this. :: - - app.config.from_envvar('FLASKR_SETTINGS', silent=True) - -If you want to do this (not required for this tutorial) simply define the -environment variable :envvar:`FLASKR_SETTINGS` that points to a config file -to be loaded. The silent switch just tells Flask to not complain if no such -environment key is set. - -In addition to that, you can use the :meth:`~flask.Config.from_object` -method on the config object and provide it with an import name of a -module. Flask will then initialize the variable from that module. Note -that in all cases, only variable names that are uppercase are considered. - -The :data:`SECRET_KEY` is needed to keep the client-side sessions secure. -Choose that key wisely and as hard to guess and complex as possible. - -Lastly, add a method that allows for easy connections to the specified -database. :: - - def connect_db(): - """Connects to the specific database.""" - - rv = sqlite3.connect(app.config['DATABASE']) - rv.row_factory = sqlite3.Row - return rv - -This can be used to open a connection on request and also from the -interactive Python shell or a script. This will come in handy later. -You can create a simple database connection through SQLite and then tell -it to use the :class:`sqlite3.Row` object to represent rows. This allows -the rows to be treated as if they were dictionaries instead of tuples. - -In the next section you will see how to run the application. - -Continue with :ref:`tutorial-packaging`. diff --git a/docs/tutorial/static.rst b/docs/tutorial/static.rst new file mode 100644 index 00000000..29548e04 --- /dev/null +++ b/docs/tutorial/static.rst @@ -0,0 +1,72 @@ +Static Files +============ + +The authentication views and templates work, but they look very plain +right now. Some `CSS`_ can be added to add style to the HTML layout you +constructed. The style won't change, so it's a *static* file rather than +a template. + +Flask automatically adds a ``static`` view that takes a path relative +to the ``flaskr/static`` directory and serves it. The ``base.html`` +template already has a link to the ``style.css`` file: + +.. code-block:: html+jinja + + {{ url_for('static', filename='style.css') }} + +Besides CSS, other types of static files might be files with JavaScript +functions, or a logo image. They are all placed under the +``flaskr/static`` directory and referenced with +``url_for('static', filename='...')``. + +This tutorial isn't focused on how to write CSS, so you can just copy +the following into the ``flaskr/static/style.css`` file: + +.. code-block:: css + :caption: ``flaskr/static/style.css`` + + html { font-family: sans-serif; background: #eee; padding: 1rem; } + body { max-width: 960px; margin: 0 auto; background: white; } + h1 { font-family: serif; color: #377ba8; margin: 1rem 0; } + a { color: #377ba8; } + hr { border: none; border-top: 1px solid lightgray; } + nav { background: lightgray; display: flex; align-items: center; padding: 0 0.5rem; } + nav h1 { flex: auto; margin: 0; } + nav h1 a { text-decoration: none; padding: 0.25rem 0.5rem; } + nav ul { display: flex; list-style: none; margin: 0; padding: 0; } + nav ul li a, nav ul li span, header .action { display: block; padding: 0.5rem; } + .content { padding: 0 1rem 1rem; } + .content > header { border-bottom: 1px solid lightgray; display: flex; align-items: flex-end; } + .content > header h1 { flex: auto; margin: 1rem 0 0.25rem 0; } + .flash { margin: 1em 0; padding: 1em; background: #cae6f6; border: 1px solid #377ba8; } + .post > header { display: flex; align-items: flex-end; font-size: 0.85em; } + .post > header > div:first-of-type { flex: auto; } + .post > header h1 { font-size: 1.5em; margin-bottom: 0; } + .post .about { color: slategray; font-style: italic; } + .post .body { white-space: pre-line; } + .content:last-child { margin-bottom: 0; } + .content form { margin: 1em 0; display: flex; flex-direction: column; } + .content label { font-weight: bold; margin-bottom: 0.5em; } + .content input, .content textarea { margin-bottom: 1em; } + .content textarea { min-height: 12em; resize: vertical; } + input.danger { color: #cc2f2e; } + input[type=submit] { align-self: start; min-width: 10em; } + +You can find a less compact version of ``style.css`` in the +:gh:`example code `. + +Go to http://127.0.0.1/auth/login and the page should look like the +screenshot below. + +.. image:: flaskr_login.png + :align: center + :class: screenshot + :alt: screenshot of login page + +You can read more about CSS from `Mozilla's documentation `_. If +you change a static file, refresh the browser page. If the change +doesn't show up, try clearing your browser's cache. + +.. _CSS: https://developer.mozilla.org/docs/Web/CSS + +Continue to :doc:`blog`. diff --git a/docs/tutorial/templates.rst b/docs/tutorial/templates.rst index 12a555e7..226081c9 100644 --- a/docs/tutorial/templates.rst +++ b/docs/tutorial/templates.rst @@ -1,113 +1,187 @@ -.. _tutorial-templates: +.. currentmodule:: flask -Step 7: The Templates -===================== +Templates +========= -Now it is time to start working on the templates. As you may have -noticed, if you make requests with the app running, you will get -an exception that Flask cannot find the templates. The templates -are using `Jinja2`_ syntax and have autoescaping enabled by -default. This means that unless you mark a value in the code with -:class:`~flask.Markup` or with the ``|safe`` filter in the template, -Jinja2 will ensure that special characters such as ``<`` or ``>`` are -escaped with their XML equivalents. +You've written the authentication views for your application, but if +you're running the server and try to go to any of the URLs, you'll see a +``TemplateNotFound`` error. That's because the views are calling +:func:`render_template`, but you haven't written the templates yet. +The template files will be stored in the ``templates`` directory inside +the ``flaskr`` package. -We are also using template inheritance which makes it possible to reuse -the layout of the website in all pages. +Templates are files that contain static data as well as placeholders +for dynamic data. A template is rendered with specific data to produce a +final document. Flask uses the `Jinja`_ template library to render +templates. -Create the follwing three HTML files and place them in the -:file:`templates` folder: +In your application, you will use templates to render `HTML`_ which +will display in the user's browser. In Flask, Jinja is configured to +*autoescape* any data that is rendered in HTML templates. This means +that it's safe to render user input; any characters they've entered that +could mess with the HTML, such as ``<`` and ``>`` will be *escaped* with +*safe* values that look the same in the browser but don't cause unwanted +effects. -.. _Jinja2: http://jinja.pocoo.org/docs/templates +Jinja looks and behaves mostly like Python. Special delimiters are used +to distinguish Jinja syntax from the static data in the template. +Anything between ``{{`` and ``}}`` is an expression that will be output +to the final document. ``{%`` and ``%}`` denotes a control flow +statement like ``if`` and ``for``. Unlike Python, blocks are denoted +by start and end tags rather than indentation since static text within +a block could change indentation. -layout.html ------------ +.. _Jinja: http://jinja.pocoo.org/docs/templates/ +.. _HTML: https://developer.mozilla.org/docs/Web/HTML -This template contains the HTML skeleton, the header and a link to log in -(or log out if the user was already logged in). It also displays the -flashed messages if there are any. The ``{% block body %}`` block can be -replaced by a block of the same name (``body``) in a child template. -The :class:`~flask.session` dict is available in the template as well and -you can use that to check if the user is logged in or not. Note that in -Jinja you can access missing attributes and items of objects / dicts which -makes the following code work, even if there is no ``'logged_in'`` key in -the session: +The Base Layout +--------------- -.. sourcecode:: html+jinja +Each page in the application will have the same basic layout around a +different body. Instead of writing the entire HTML structure in each +template, each template will *extend* a base template and override +specific sections. + +.. code-block:: html+jinja + :caption: ``flaskr/templates/base.html`` - Flaskr - -
+ {% block title %}{% endblock %} - Flaskr + +
- -show_entries.html ------------------ - -This template extends the :file:`layout.html` template from above to display the -messages. Note that the ``for`` loop iterates over the messages we passed -in with the :func:`~flask.render_template` function. Notice that the form is -configured to submit to the `add_entry` view function and use ``POST`` as -HTTP method: - -.. sourcecode:: html+jinja - - {% extends "layout.html" %} - {% block body %} - {% if session.logged_in %} -
-
-
Title: -
-
Text: -
-
-
-
- {% endif %} -
    - {% for entry in entries %} -
  • {{ entry.title }}

    {{ entry.text|safe }}
  • - {% else %} -
  • Unbelievable. No entries here so far
  • - {% endfor %} + + +
    +
    + {% block header %}{% endblock %} +
    + {% for message in get_flashed_messages() %} +
    {{ message }}
    + {% endfor %} + {% block content %}{% endblock %} +
    + +:data:`g` is automatically available in templates. Based on if +``g.user`` is set (from ``load_logged_in_user``), either the username +and a log out link are displayed, otherwise links to register and log in +are displayed. :func:`url_for` is also automatically available, and is +used to generate URLs to views instead of writing them out manually. + +After the page title, and before the content, the template loops over +each message returned by :func:`get_flashed_messages`. You used +:func:`flash` in the views to show error messages, and this is the code +that will display them. + +There are three blocks defined here that will be overridden in the other +templates: + +#. ``{% block title %}`` will change the title displayed in the + browser's tab and window title. + +#. ``{% block header %}`` is similar to ``title`` but will change the + title displayed on the page. + +#. ``{% block content %}`` is where the content of each page goes, such + as the login form or a blog post. + +The base template is directly in the ``templates`` directory. To keep +the others organized, the templates for a blueprint will be placed in a +directory with the same name as the blueprint. + + +Register +-------- + +.. code-block:: html+jinja + :caption: ``flaskr/templates/auth/register.html`` + + {% extends 'base.html' %} + + {% block header %} +

    {% block title %}Register{% endblock %}

    {% endblock %} -login.html ----------- - -This is the login template, which basically just displays a form to allow -the user to login: - -.. sourcecode:: html+jinja - - {% extends "layout.html" %} - {% block body %} -

    Login

    - {% if error %}

    Error: {{ error }}{% endif %} -

    -
    -
    Username: -
    -
    Password: -
    -
    -
    + {% block content %} + + + + + +
    {% endblock %} -Continue with :ref:`tutorial-css`. +``{% extends 'base.html' %}`` tells Jinja that this template should +replace the blocks from the base template. All the rendered content must +appear inside ``{% block %}`` tags that override blocks from the base +template. + +A useful pattern used here is to place ``{% block title %}`` inside +``{% block header %}``. This will set the title block and then output +the value of it into the header block, so that both the window and page +share the same title without writing it twice. + +The ``input`` tags are using the ``required`` attribute here. This tells +the browser not to submit the form until those fields are filled in. If +the user is using an older browser that doesn't support that attribute, +or if they are using something besides a browser to make requests, you +still want to validate the data in the Flask view. It's important to +always fully validate the data on the server, even if the client does +some validation as well. + + +Log In +------ + +This is identical to the register template except for the title and +submit button. + +.. code-block:: html+jinja + :caption: ``flaskr/templates/auth/login.html`` + + {% extends 'base.html' %} + + {% block header %} +

    {% block title %}Log In{% endblock %}

    + {% endblock %} + + {% block content %} +
    + + + + + +
    + {% endblock %} + + +Register A User +--------------- + +Now that the authentication templates are written, you can register a +user. Make sure the server is still running (``flask run`` if it's not), +then go to http://127.0.0.1:5000/auth/register. + +Try clicking the "Register" button without filling out the form and see +that the browser shows an error message. Try removing the ``required`` +attributes from the ``register.html`` template and click "Register" +again. Instead of the browser showing an error, the page will reload and +the error from :func:`flash` in the view will be shown. + +Fill out a username and password and you'll be redirected to the login +page. Try entering an incorrect username, or the correct username and +incorrect password. If you log in you'll get an error because there's +no ``index`` view to redirect to yet. + +Continue to :doc:`static`. diff --git a/docs/tutorial/testing.rst b/docs/tutorial/testing.rst deleted file mode 100644 index 26099375..00000000 --- a/docs/tutorial/testing.rst +++ /dev/null @@ -1,96 +0,0 @@ -.. _tutorial-testing: - -Bonus: Testing the Application -============================== - -Now that you have finished the application and everything works as -expected, it's probably not a bad idea to add automated tests to simplify -modifications in the future. The application above is used as a basic -example of how to perform unit testing in the :ref:`testing` section of the -documentation. Go there to see how easy it is to test Flask applications. - -Adding tests to flaskr ----------------------- - -Assuming you have seen the :ref:`testing` section and have either written -your own tests for ``flaskr`` or have followed along with the examples -provided, you might be wondering about ways to organize the project. - -One possible and recommended project structure is:: - - flaskr/ - flaskr/ - __init__.py - static/ - templates/ - tests/ - test_flaskr.py - setup.py - MANIFEST.in - -For now go ahead a create the :file:`tests/` directory as well as the -:file:`test_flaskr.py` file. - -Running the tests ------------------ - -At this point you can run the tests. Here ``pytest`` will be used. - -.. note:: Make sure that ``pytest`` is installed in the same virtualenv - as flaskr. Otherwise ``pytest`` test will not be able to import the - required components to test the application:: - - pip install -e . - pip install pytest - -Run and watch the tests pass, within the top-level :file:`flaskr/` -directory as:: - - pytest - -Testing + setuptools --------------------- - -One way to handle testing is to integrate it with ``setuptools``. Here -that requires adding a couple of lines to the :file:`setup.py` file and -creating a new file :file:`setup.cfg`. One benefit of running the tests -this way is that you do not have to install ``pytest``. Go ahead and -update the :file:`setup.py` file to contain:: - - from setuptools import setup - - setup( - name='flaskr', - packages=['flaskr'], - include_package_data=True, - install_requires=[ - 'flask', - ], - setup_requires=[ - 'pytest-runner', - ], - tests_require=[ - 'pytest', - ], - ) - -Now create :file:`setup.cfg` in the project root (alongside -:file:`setup.py`):: - - [aliases] - test=pytest - -Now you can run:: - - python setup.py test - -This calls on the alias created in :file:`setup.cfg` which in turn runs -``pytest`` via ``pytest-runner``, as the :file:`setup.py` script has -been called. (Recall the `setup_requires` argument in :file:`setup.py`) -Following the standard rules of test-discovery your tests will be -found, run, and hopefully pass. - -This is one possible way to run and manage testing. Here ``pytest`` is -used, but there are other options such as ``nose``. Integrating testing -with ``setuptools`` is convenient because it is not necessary to actually -download ``pytest`` or any other testing framework one might use. diff --git a/docs/tutorial/tests.rst b/docs/tutorial/tests.rst new file mode 100644 index 00000000..565450f9 --- /dev/null +++ b/docs/tutorial/tests.rst @@ -0,0 +1,561 @@ +.. currentmodule:: flask + +Test Coverage +============= + +Writing unit tests for your application lets you check that the code +you wrote works the way you expect. Flask provides a test client that +simulates requests to the application and returns the response data. + +You should test as much of your code as possible. Code in functions only +runs when the function is called, and code in branches, such as ``if`` +blocks, only runs when the condition is met. You want to make sure that +each function is tested with data that covers each branch. + +The closer you get to 100% coverage, the more comfortable you can be +that making a change won't unexpectedly change other behavior. However, +100% coverage doesn't guarantee that your application doesn't have bugs. +In particular, it doesn't test how the user interacts with the +application in the browser. Despite this, test coverage is an important +tool to use during development. + +.. note:: + This is being introduced late in the tutorial, but in your future + projects you should test as you develop. + +You'll use `pytest`_ and `coverage`_ to test and measure your code. +Install them both: + +.. code-block:: none + + pip install pytest coverage + +.. _pytest: https://pytest.readthedocs.io/ +.. _coverage: https://coverage.readthedocs.io/ + + +Setup and Fixtures +------------------ + +The test code is located in the ``tests`` directory. This directory is +*next to* the ``flaskr`` package, not inside it. The +``tests/conftest.py`` file contains setup functions called *fixtures* +that each test will use. Tests are in Python modules that start with +``test_``, and each test function in those modules also starts with +``test_``. + +Each test will create a new temporary database file and populate some +data that will be used in the tests. Write a SQL file to insert that +data. + +.. code-block:: sql + :caption: ``tests/data.sql`` + + INSERT INTO user (username, password) + VALUES + ('test', 'pbkdf2:sha256:50000$TCI4GzcX$0de171a4f4dac32e3364c7ddc7c14f3e2fa61f2d17574483f7ffbb431b4acb2f'), + ('other', 'pbkdf2:sha256:50000$kJPKsz6N$d2d4784f1b030a9761f5ccaeeaca413f27f2ecb76d6168407af962ddce849f79'); + + INSERT INTO post (title, body, author_id, created) + VALUES + ('test title', 'test' || x'0a' || 'body', 1, '2018-01-01 00:00:00'); + +The ``app`` fixture will call the factory and pass ``test_config`` to +configure the application and database for testing instead of using your +local development configuration. + +.. code-block:: python + :caption: ``tests/conftest.py`` + + import os + import tempfile + + import pytest + from flaskr import create_app + from flaskr.db import get_db, init_db + + with open(os.path.join(os.path.dirname(__file__), 'data.sql'), 'rb') as f: + _data_sql = f.read().decode('utf8') + + + @pytest.fixture + def app(): + db_fd, db_path = tempfile.mkstemp() + + app = create_app({ + 'TESTING': True, + 'DATABASE': db_path, + }) + + with app.app_context(): + init_db() + get_db().executescript(_data_sql) + + yield app + + os.close(db_fd) + os.unlink(db_path) + + + @pytest.fixture + def client(app): + return app.test_client() + + + @pytest.fixture + def runner(app): + return app.test_cli_runner() + +:func:`tempfile.mkstemp` creates and opens a temporary file, returning +the file object and the path to it. The ``DATABASE`` path is +overridden so it points to this temporary path instead of the instance +folder. After setting the path, the database tables are created and the +test data is inserted. After the test is over, the temporary file is +closed and removed. + +:data:`TESTING` tells Flask that the app is in test mode. Flask changes +some internal behavior so it's easier to test, and other extensions can +also use the flag to make testing them easier. + +The ``client`` fixture calls +:meth:`app.test_client() ` with the application +object created by the ``app`` fixture. Tests will use the client to make +requests to the application without running the server. + +The ``runner`` fixture is similar to ``client``. +:meth:`app.test_cli_runner() ` creates a runner +that can call the Click commands registered with the application. + +Pytest uses fixtures by matching their function names with the names +of arguments in the test functions. For example, the ``test_hello`` +function you'll write next takes a ``client`` argument. Pytest matches +that with the ``client`` fixture function, calls it, and passes the +returned value to the test function. + + +Factory +------- + +There's not much to test about the factory itself. Most of the code will +be executed for each test already, so if something fails the other tests +will notice. + +The only behavior that can change is passing test config. If config is +not passed, there should be some default configuration, otherwise the +configuration should be overridden. + +.. code-block:: python + :caption: ``tests/test_factory.py`` + + from flaskr import create_app + + + def test_config(): + assert not create_app().testing + assert create_app({'TESTING': True}).testing + + + def test_hello(client): + response = client.get('/hello') + assert response.data == b'Hello, World!' + +You added the ``hello`` route as an example when writing the factory at +the beginning of the tutorial. It returns "Hello, World!", so the test +checks that the response data matches. + + +Database +-------- + +Within an application context, ``get_db`` should return the same +connection each time it's called. After the context, the connection +should be closed. + +.. code-block:: python + :caption: ``tests/test_db.py`` + + import sqlite3 + + import pytest + from flaskr.db import get_db + + + def test_get_close_db(app): + with app.app_context(): + db = get_db() + assert db is get_db() + + with pytest.raises(sqlite3.ProgrammingError) as e: + db.execute('SELECT 1') + + assert 'closed' in str(e) + +The ``init-db`` command should call the ``init_db`` function and output +a message. + +.. code-block:: python + :caption: ``tests/test_db.py`` + + def test_init_db_command(runner, monkeypatch): + class Recorder(object): + called = False + + def fake_init_db(): + Recorder.called = True + + monkeypatch.setattr('flaskr.db.init_db', fake_init_db) + result = runner.invoke(args=['init-db']) + assert 'Initialized' in result.output + assert Recorder.called + +This test uses Pytest's ``monkeypatch`` fixture to replace the +``init_db`` function with one that records that it's been called. The +``runner`` fixture you wrote above is used to call the ``init-db`` +command by name. + + +Authentication +-------------- + +For most of the views, a user needs to be logged in. The easiest way to +do this in tests is to make a ``POST`` request to the ``login`` view +with the client. Rather than writing that out every time, you can write +a class with methods to do that, and use a fixture to pass it the client +for each test. + +.. code-block:: python + :caption: ``tests/conftest.py`` + + class AuthActions(object): + def __init__(self, client): + self._client = client + + def login(self, username='test', password='test'): + return self._client.post( + '/auth/login', + data={'username': username, 'password': password} + ) + + def logout(self): + return self._client.get('/auth/logout') + + + @pytest.fixture + def auth(client): + return AuthActions(client) + +With the ``auth`` fixture, you can call ``auth.login()`` in a test to +log in as the ``test`` user, which was inserted as part of the test +data in the ``app`` fixture. + +The ``register`` view should render successfully on ``GET``. On ``POST`` +with valid form data, it should redirect to the login URL and the user's +data should be in the database. Invalid data should display error +messages. + +.. code-block:: python + :caption: ``tests/test_auth.py`` + + import pytest + from flask import g, session + from flaskr.db import get_db + + + def test_register(client, app): + assert client.get('/auth/register').status_code == 200 + response = client.post( + '/auth/register', data={'username': 'a', 'password': 'a'} + ) + assert 'http://localhost/auth/login' == response.headers['Location'] + + with app.app_context(): + assert get_db().execute( + "select * from user where username = 'a'", + ).fetchone() is not None + + + @pytest.mark.parametrize(('username', 'password', 'message'), ( + ('', '', b'Username is required.'), + ('a', '', b'Password is required.'), + ('test', 'test', b'already registered'), + )) + def test_register_validate_input(client, username, password, message): + response = client.post( + '/auth/register', + data={'username': username, 'password': password} + ) + assert message in response.data + +:meth:`client.get() ` makes a ``GET`` request +and returns the :class:`Response` object returned by Flask. Similarly, +:meth:`client.post() ` makes a ``POST`` +request, converting the ``data`` dict into form data. + +To test that the page renders successfully, a simple request is made and +checked for a ``200 OK`` :attr:`~Response.status_code`. If +rendering failed, Flask would return a ``500 Internal Server Error`` +code. + +:attr:`~Response.headers` will have a ``Location`` header with the login +URL when the register view redirects to the login view. + +:attr:`~Response.data` contains the body of the response as bytes. If +you expect a certain value to render on the page, check that it's in +``data``. Bytes must be compared to bytes. If you want to compare +Unicode text, use :meth:`get_data(as_text=True) ` +instead. + +``pytest.mark.parametrize`` tells Pytest to run the same test function +with different arguments. You use it here to test different invalid +input and error messages without writing the same code three times. + +The tests for the ``login`` view are very similar to those for +``register``. Rather than testing the data in the database, +:data:`session` should have ``user_id`` set after logging in. + +.. code-block:: python + :caption: ``tests/test_auth.py`` + + def test_login(client, auth): + assert client.get('/auth/login').status_code == 200 + response = auth.login() + assert response.headers['Location'] == 'http://localhost/' + + with client: + client.get('/') + assert session['user_id'] == 1 + assert g.user['username'] == 'test' + + + @pytest.mark.parametrize(('username', 'password', 'message'), ( + ('a', 'test', b'Incorrect username.'), + ('test', 'a', b'Incorrect password.'), + )) + def test_login_validate_input(auth, username, password, message): + response = auth.login(username, password) + assert message in response.data + +Using ``client`` in a ``with`` block allows accessing context variables +such as :data:`session` after the response is returned. Normally, +accessing ``session`` outside of a request would raise an error. + +Testing ``logout`` is the opposite of ``login``. :data:`session` should +not contain ``user_id`` after logging out. + +.. code-block:: python + :caption: ``tests/test_auth.py`` + + def test_logout(client, auth): + auth.login() + + with client: + auth.logout() + assert 'user_id' not in session + + +Blog +---- + +All the blog views use the ``auth`` fixture you wrote earlier. Call +``auth.login()`` and subsequent requests from the client will be logged +in as the ``test`` user. + +The ``index`` view should display information about the post that was +added with the test data. When logged in as the author, there should be +a link to edit the post. + +You can also test some more authentication behavior while testing the +``index`` view. When not logged in, each page shows links to log in or +register. When logged in, there's a link to log out. + +.. code-block:: python + :caption: ``tests/test_blog.py`` + + import pytest + from flaskr.db import get_db + + + def test_index(client, auth): + response = client.get('/') + assert b"Log In" in response.data + assert b"Register" in response.data + + auth.login() + response = client.get('/') + assert b'Log Out' in response.data + assert b'test title' in response.data + assert b'by test on 2018-01-01' in response.data + assert b'test\nbody' in response.data + assert b'href="/1/update"' in response.data + +A user must be logged in to access the ``create``, ``update``, and +``delete`` views. The logged in user must be the author of the post to +access ``update`` and ``delete``, otherwise a ``403 Forbidden`` status +is returned. If a ``post`` with the given ``id`` doesn't exist, +``update`` and ``delete`` should return ``404 Not Found``. + +.. code-block:: python + :caption: ``tests/test_blog.py`` + + @pytest.mark.parametrize('path', ( + '/create', + '/1/update', + '/1/delete', + )) + def test_login_required(client, path): + response = client.post(path) + assert response.headers['Location'] == 'http://localhost/auth/login' + + + def test_author_required(app, client, auth): + # change the post author to another user + with app.app_context(): + db = get_db() + db.execute('UPDATE post SET author_id = 2 WHERE id = 1') + db.commit() + + auth.login() + # current user can't modify other user's post + assert client.post('/1/update').status_code == 403 + assert client.post('/1/delete').status_code == 403 + # current user doesn't see edit link + assert b'href="/1/update"' not in client.get('/').data + + + @pytest.mark.parametrize('path', ( + '/2/update', + '/2/delete', + )) + def test_exists_required(client, auth, path): + auth.login() + assert client.post(path).status_code == 404 + +The ``create`` and ``update`` views should render and return a +``200 OK`` status for a ``GET`` request. When valid data is sent in a +``POST`` request, ``create`` should insert the new post data into the +database, and ``update`` should modify the existing data. Both pages +should show an error message on invalid data. + +.. code-block:: python + :caption: ``tests/test_blog.py`` + + def test_create(client, auth, app): + auth.login() + assert client.get('/create').status_code == 200 + client.post('/create', data={'title': 'created', 'body': ''}) + + with app.app_context(): + db = get_db() + count = db.execute('SELECT COUNT(id) FROM post').fetchone()[0] + assert count == 2 + + + def test_update(client, auth, app): + auth.login() + assert client.get('/1/update').status_code == 200 + client.post('/1/update', data={'title': 'updated', 'body': ''}) + + with app.app_context(): + db = get_db() + post = db.execute('SELECT * FROM post WHERE id = 1').fetchone() + assert post['title'] == 'updated' + + + @pytest.mark.parametrize('path', ( + '/create', + '/1/update', + )) + def test_create_update_validate(client, auth, path): + auth.login() + response = client.post(path, data={'title': '', 'body': ''}) + assert b'Title is required.' in response.data + +The ``delete`` view should redirect to the index URL and the post should +no longer exist in the database. + +.. code-block:: python + :caption: ``tests/test_blog.py`` + + def test_delete(client, auth, app): + auth.login() + response = client.post('/1/delete') + assert response.headers['Location'] == 'http://localhost/' + + with app.app_context(): + db = get_db() + post = db.execute('SELECT * FROM post WHERE id = 1').fetchone() + assert post is None + + +Running the Tests +----------------- + +Some extra configuration, which is not required but makes running +tests with coverage less verbose, can be added to the project's +``setup.cfg`` file. + +.. code-block:: none + :caption: ``setup.cfg`` + + [tool:pytest] + testpaths = tests + + [coverage:run] + branch = True + source = + flaskr + +To run the tests, use the ``pytest`` command. It will find and run all +the test functions you've written. + +.. code-block:: none + + pytest + + ========================= test session starts ========================== + platform linux -- Python 3.6.4, pytest-3.5.0, py-1.5.3, pluggy-0.6.0 + rootdir: /home/user/Projects/flask-tutorial, inifile: setup.cfg + collected 23 items + + tests/test_auth.py ........ [ 34%] + tests/test_blog.py ............ [ 86%] + tests/test_db.py .. [ 95%] + tests/test_factory.py .. [100%] + + ====================== 24 passed in 0.64 seconds ======================= + +If any tests fail, pytest will show the error that was raised. You can +run ``pytest -v`` to get a list of each test function rather than dots. + +To measure the code coverage of your tests, use the ``coverage`` command +to run pytest instead of running it directly. + +.. code-block:: none + + coverage run -m pytest + +You can either view a simple coverage report in the terminal: + +.. code-block:: none + + coverage report + + Name Stmts Miss Branch BrPart Cover + ------------------------------------------------------ + flaskr/__init__.py 21 0 2 0 100% + flaskr/auth.py 54 0 22 0 100% + flaskr/blog.py 54 0 16 0 100% + flaskr/db.py 24 0 4 0 100% + ------------------------------------------------------ + TOTAL 153 0 44 0 100% + +An HTML report allows you to see which lines were covered in each file: + +.. code-block:: none + + coverage html + +This generates files in the ``htmlcov`` directory. Open +``htmlcov/index.html`` in your browser to see the report. + +Continue to :doc:`deploy`. diff --git a/docs/tutorial/views.rst b/docs/tutorial/views.rst index 1b09fcb8..c9c6a7ca 100644 --- a/docs/tutorial/views.rst +++ b/docs/tutorial/views.rst @@ -1,118 +1,301 @@ -.. _tutorial-views: +.. currentmodule:: flask -Step 6: The View Functions -========================== +Blueprints and Views +==================== -Now that the database connections are working, you can start writing the -view functions. You will need four of them; Show Entries, Add New Entry, -Login and Logout. Add the following code snipets to :file:`flaskr.py`. +A view function is the code you write to respond to requests to your +application. Flask uses patterns to match the incoming request URL to +the view that should handle it. The view returns data that Flask turns +into an outgoing response. Flask can also go the other direction and +generate a URL to a view based on its name and arguments. -Show Entries ------------- -This view shows all the entries stored in the database. It listens on the -root of the application and will select title and text from the database. -The one with the highest id (the newest entry) will be on top. The rows -returned from the cursor look a bit like dictionaries because we are using -the :class:`sqlite3.Row` row factory. +Create a Blueprint +------------------ -The view function will pass the entries to the :file:`show_entries.html` -template and return the rendered one:: +A :class:`Blueprint` is a way to organize a group of related views and +other code. Rather than registering views and other code directly with +an application, they are registered with a blueprint. Then the blueprint +is registered with the application when it is available in the factory +function. - @app.route('/') - def show_entries(): - db = get_db() - cur = db.execute('select title, text from entries order by id desc') - entries = cur.fetchall() - return render_template('show_entries.html', entries=entries) +Flaskr will have two blueprints, one for authentication functions and +one for the blog posts functions. The code for each blueprint will go +in a separate module. Since the blog needs to know about authentication, +you'll write the authentication one first. -Add New Entry -------------- +.. code-block:: python + :caption: ``flaskr/auth.py`` -This view lets the user add new entries if they are logged in. This only -responds to ``POST`` requests; the actual form is shown on the -`show_entries` page. If everything worked out well, it will -:func:`~flask.flash` an information message to the next request and -redirect back to the `show_entries` page:: + import functools - @app.route('/add', methods=['POST']) - def add_entry(): - if not session.get('logged_in'): - abort(401) - db = get_db() - db.execute('insert into entries (title, text) values (?, ?)', - [request.form['title'], request.form['text']]) - db.commit() - flash('New entry was successfully posted') - return redirect(url_for('show_entries')) + from flask import ( + Blueprint, flash, g, redirect, render_template, request, session, url_for + ) + from werkzeug.security import check_password_hash, generate_password_hash -Note that this view checks that the user is logged in (that is, if the -`logged_in` key is present in the session and ``True``). + from flaskr.db import get_db -.. admonition:: Security Note + bp = Blueprint('auth', __name__, url_prefix='/auth') - Be sure to use question marks when building SQL statements, as done in the - example above. Otherwise, your app will be vulnerable to SQL injection when - you use string formatting to build SQL statements. - See :ref:`sqlite3` for more. +This creates a :class:`Blueprint` named ``'auth'``. Like the application +object, the blueprint needs to know where it's defined, so ``__name__`` +is passed as the second argument. The ``url_prefix`` will be prepended +to all the URLs associated with the blueprint. -Login and Logout ----------------- +Import and register the blueprint from the factory using +:meth:`app.register_blueprint() `. Place the +new code at the end of the factory function before returning the app. -These functions are used to sign the user in and out. Login checks the -username and password against the ones from the configuration and sets the -`logged_in` key for the session. If the user logged in successfully, that -key is set to ``True``, and the user is redirected back to the `show_entries` -page. In addition, a message is flashed that informs the user that he or -she was logged in successfully. If an error occurred, the template is -notified about that, and the user is asked again:: +.. code-block:: python + :caption: ``flaskr/__init__.py`` - @app.route('/login', methods=['GET', 'POST']) - def login(): - error = None + def create_app(): + app = ... + # existing code omitted + + from . import auth + app.register_blueprint(auth.bp) + + return app + +The authentication blueprint will have views to register new users and +to log in and log out. + + +The First View: Register +------------------------ + +When the user visits the ``/auth/register`` URL, the ``register`` view +will return `HTML`_ with a form for them to fill out. When they submit +the form, it will validate their input and either show the form again +with an error message or create the new user and go to the login page. + +.. _HTML: https://developer.mozilla.org/docs/Web/HTML + +For now you will just write the view code. On the next page, you'll +write templates to generate the HTML form. + +.. code-block:: python + :caption: ``flaskr/auth.py`` + + @bp.route('/register', methods=('GET', 'POST')) + def register(): if request.method == 'POST': - if request.form['username'] != app.config['USERNAME']: - error = 'Invalid username' - elif request.form['password'] != app.config['PASSWORD']: - error = 'Invalid password' - else: - session['logged_in'] = True - flash('You were logged in') - return redirect(url_for('show_entries')) - return render_template('login.html', error=error) + username = request.form['username'] + password = request.form['password'] + db = get_db() + error = None -The `logout` function, on the other hand, removes that key from the session -again. There is a neat trick here: if you use the :meth:`~dict.pop` method -of the dict and pass a second parameter to it (the default), the method -will delete the key from the dictionary if present or do nothing when that -key is not in there. This is helpful because now it is not necessary to -check if the user was logged in. + if not username: + error = 'Username is required.' + elif not password: + error = 'Password is required.' + elif db.execute( + 'SELECT id FROM user WHERE username = ?', (username,) + ).fetchone() is not None: + error = 'User {} is already registered.'.format(username) -:: + if error is None: + db.execute( + 'INSERT INTO user (username, password) VALUES (?, ?)', + (username, generate_password_hash(password)) + ) + db.commit() + return redirect(url_for('auth.login')) - @app.route('/logout') + flash(error) + + return render_template('auth/register.html') + +Here's what the ``register`` view function is doing: + +#. :meth:`@bp.route ` associates the URL ``/register`` + with the ``register`` view function. When Flask receives a request + to ``/auth/register``, it will call the ``register`` view and use + the return value as the response. + +#. If the user submitted the form, + :attr:`request.method ` will be ``'POST'``. In this + case, start validating the input. + +#. :attr:`request.form ` is a special type of + :class:`dict` mapping submitted form keys and values. The user will + input their ``username`` and ``password``. + +#. Validate that ``username`` and ``password`` are not empty. + +#. Validate that ``username`` is not already registered by querying the + database and checking if a result is returned. + :meth:`db.execute ` takes a SQL query + with ``?`` placeholders for any user input, and a tuple of values + to replace the placeholders with. The database library will take + care of escaping the values so you are not vulnerable to a + *SQL injection attack*. + + :meth:`~sqlite3.Cursor.fetchone` returns one row from the query. + If the query returned no results, it returns ``None``. Later, + :meth:`~sqlite3.Cursor.fetchall` is used, which returns a list of + all results. + +#. If validation succeeds, insert the new user data into the database. + For security, passwords should never be stored in the database + directly. Instead, + :func:`~werkzeug.security.generate_password_hash` is used to + securely hash the password, and that hash is stored. Since this + query modifies data, :meth:`db.commit() ` + needs to be called afterwards to save the changes. + +#. After storing the user, they are redirected to the login page. + :func:`url_for` generates the URL for the login view based on its + name. This is preferable to writing the URL directly as it allows + you to change the URL later without changing all code that links to + it. :func:`redirect` generates a redirect response to the generated + URL. + +#. If validation fails, the error is shown to the user. :func:`flash` + stores messages that can be retrieved when rendering the template. + +#. When the user initially navigates to ``auth/register``, or + there was an validation error, an HTML page with the registration + form should be shown. :func:`render_template` will render a template + containing the HTML, which you'll write in the next step of the + tutorial. + + +Login +----- + +This view follows the same pattern as the ``register`` view above. + +.. code-block:: python + :caption: ``flaskr/auth.py`` + + @bp.route('/login', methods=('GET', 'POST')) + def login(): + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + db = get_db() + error = None + user = db.execute( + 'SELECT * FROM user WHERE username = ?', (username,) + ).fetchone() + + if user is None: + error = 'Incorrect username.' + elif not check_password_hash(user['password'], password): + error = 'Incorrect password.' + + if error is None: + session.clear() + session['user_id'] = user['id'] + return redirect(url_for('index')) + + flash(error) + + return render_template('auth/login.html') + +There are a few differences from the ``register`` view: + +#. The user is queried first and stored in a variable for later use. + +#. :func:`~werkzeug.security.check_password_hash` hashes the submitted + password in the same way as the stored hash and securely compares + them. If they match, the password is valid. + +#. :data:`session` is a :class:`dict` that stores data across requests. + When validation succeeds, the user's ``id`` is stored in a new + session. The data is stored in a *cookie* that is sent to the + browser, and the browser then sends it back with subsequent requests. + Flask securely *signs* the data so that it can't be tampered with. + +Now that the user's ``id`` is stored in the :data:`session`, it will be +available on subsequent requests. At the beginning of each request, if +a user is logged in their information should be loaded and made +available to other views. + +.. code-block:: python + :caption: ``flaskr/auth.py`` + + @bp.before_app_request + def load_logged_in_user(): + user_id = session.get('user_id') + + if user_id is None: + g.user = None + else: + g.user = get_db().execute( + 'SELECT * FROM user WHERE id = ?', (user_id,) + ).fetchone() + +:meth:`bp.before_app_request() ` registers +a function that runs before the view function, no matter what URL is +requested. ``load_logged_in_user`` checks if a user id is stored in the +:data:`session` and gets that user's data from the database, storing it +on :data:`g.user `, which lasts for the length of the request. If +there is no user id, or if the id doesn't exist, ``g.user`` will be +``None``. + + +Logout +------ + +To log out, you need to remove the user id from the :data:`session`. +Then ``load_logged_in_user`` won't load a user on subsequent requests. + +.. code-block:: python + :caption: ``flaskr/auth.py`` + + @bp.route('/logout') def logout(): - session.pop('logged_in', None) - flash('You were logged out') - return redirect(url_for('show_entries')) - -.. admonition:: Security Note - - Passwords should never be stored in plain text in a production - system. This tutorial uses plain text passwords for simplicity. If you - plan to release a project based off this tutorial out into the world, - passwords should be both `hashed and salted`_ before being stored in a - database or file. - - Fortunately, there are Flask extensions for the purpose of - hashing passwords and verifying passwords against hashes, so adding - this functionality is fairly straight forward. There are also - many general python libraries that can be used for hashing. - - You can find a list of recommended Flask extensions - `here `_ + session.clear() + return redirect(url_for('index')) -Continue with :ref:`tutorial-templates`. +Require Authentication in Other Views +------------------------------------- -.. _hashed and salted: https://blog.codinghorror.com/youre-probably-storing-passwords-incorrectly/ +Creating, editing, and deleting blog posts will require a user to be +logged in. A *decorator* can be used to check this for each view it's +applied to. + +.. code-block:: python + :caption: ``flaskr/auth.py`` + + def login_required(view): + @functools.wraps(view) + def wrapped_view(**kwargs): + if g.user is None: + return redirect(url_for('auth.login')) + + return view(**kwargs) + + return wrapped_view + +This decorator returns a new view function that wraps the original view +it's applied to. The new function checks if a user is loaded and +redirects to the login page otherwise. If a user is loaded the original +view is called and continues normally. You'll use this decorator when +writing the blog views. + +Endpoints and URLs +------------------ + +The :func:`url_for` function generates the URL to a view based on a name +and arguments. The name associated with a view is also called the +*endpoint*, and by default it's the same as the name of the view +function. + +For example, the ``hello()`` view that was added to the app +factory earlier in the tutorial has the name ``'hello'`` and can be +linked to with ``url_for('hello')``. If it took an argument, which +you'll see later, it would be linked to using +``url_for('hello', who='World')``. + +When using a blueprint, the name of the blueprint is prepended to the +name of the function, so the endpoint for the ``login`` function you +wrote above is ``'auth.login'`` because you added it to the ``'auth'`` +blueprint. + +Continue to :doc:`templates`. diff --git a/examples/blueprintexample/blueprintexample.py b/examples/blueprintexample/blueprintexample.py deleted file mode 100644 index 6ca0dd13..00000000 --- a/examples/blueprintexample/blueprintexample.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Blueprint Example -~~~~~~~~~~~~~~~~~ - -:copyright: © 2010 by the Pallets team. -:license: BSD, see LICENSE for more details. -""" - -from flask import Flask -from simple_page.simple_page import simple_page - -app = Flask(__name__) -app.register_blueprint(simple_page) -# Blueprint can be registered many times -app.register_blueprint(simple_page, url_prefix='/pages') - -if __name__=='__main__': - app.run() diff --git a/examples/blueprintexample/simple_page/__init__.py b/examples/blueprintexample/simple_page/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/examples/blueprintexample/simple_page/simple_page.py b/examples/blueprintexample/simple_page/simple_page.py deleted file mode 100644 index cb82cc37..00000000 --- a/examples/blueprintexample/simple_page/simple_page.py +++ /dev/null @@ -1,13 +0,0 @@ -from flask import Blueprint, render_template, abort -from jinja2 import TemplateNotFound - -simple_page = Blueprint('simple_page', __name__, - template_folder='templates') - -@simple_page.route('/', defaults={'page': 'index'}) -@simple_page.route('/') -def show(page): - try: - return render_template('pages/%s.html' % page) - except TemplateNotFound: - abort(404) diff --git a/examples/blueprintexample/simple_page/templates/pages/hello.html b/examples/blueprintexample/simple_page/templates/pages/hello.html deleted file mode 100644 index 7e4a624d..00000000 --- a/examples/blueprintexample/simple_page/templates/pages/hello.html +++ /dev/null @@ -1,5 +0,0 @@ -{% extends "pages/layout.html" %} - -{% block body %} - Hello -{% endblock %} diff --git a/examples/blueprintexample/simple_page/templates/pages/index.html b/examples/blueprintexample/simple_page/templates/pages/index.html deleted file mode 100644 index b8d92da4..00000000 --- a/examples/blueprintexample/simple_page/templates/pages/index.html +++ /dev/null @@ -1,5 +0,0 @@ -{% extends "pages/layout.html" %} - -{% block body %} - Blueprint example page -{% endblock %} diff --git a/examples/blueprintexample/simple_page/templates/pages/layout.html b/examples/blueprintexample/simple_page/templates/pages/layout.html deleted file mode 100644 index f312d44b..00000000 --- a/examples/blueprintexample/simple_page/templates/pages/layout.html +++ /dev/null @@ -1,20 +0,0 @@ - -Simple Page Blueprint -
    -

    This is blueprint example

    -

    - A simple page blueprint is registered under / and /pages - you can access it using this URLs: -

    -

    - Also you can register the same blueprint under another path -

    - - {% block body %}{% endblock %} -
    diff --git a/examples/blueprintexample/simple_page/templates/pages/world.html b/examples/blueprintexample/simple_page/templates/pages/world.html deleted file mode 100644 index 9fa2880a..00000000 --- a/examples/blueprintexample/simple_page/templates/pages/world.html +++ /dev/null @@ -1,4 +0,0 @@ -{% extends "pages/layout.html" %} -{% block body %} - World -{% endblock %} diff --git a/examples/blueprintexample/test_blueprintexample.py b/examples/blueprintexample/test_blueprintexample.py deleted file mode 100644 index 44df7762..00000000 --- a/examples/blueprintexample/test_blueprintexample.py +++ /dev/null @@ -1,35 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Blueprint Example Tests -~~~~~~~~~~~~~~~~~~~~~~~ - -:copyright: © 2010 by the Pallets team. -:license: BSD, see LICENSE for more details. -""" - -import pytest - -import blueprintexample - - -@pytest.fixture -def client(): - return blueprintexample.app.test_client() - - -def test_urls(client): - r = client.get('/') - assert r.status_code == 200 - - r = client.get('/hello') - assert r.status_code == 200 - - r = client.get('/world') - assert r.status_code == 200 - - # second blueprint instance - r = client.get('/pages/hello') - assert r.status_code == 200 - - r = client.get('/pages/world') - assert r.status_code == 200 diff --git a/examples/flaskr/.gitignore b/examples/flaskr/.gitignore deleted file mode 100644 index 8d567f84..00000000 --- a/examples/flaskr/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -flaskr.db -.eggs/ diff --git a/examples/flaskr/README b/examples/flaskr/README deleted file mode 100644 index ab668d67..00000000 --- a/examples/flaskr/README +++ /dev/null @@ -1,40 +0,0 @@ - / Flaskr / - - a minimal blog application - - - ~ What is Flaskr? - - A sqlite powered thumble blog application - - ~ How do I use it? - - 1. edit the configuration in the factory.py file or - export a FLASKR_SETTINGS environment variable - pointing to a configuration file or pass in a - dictionary with config values using the create_app - function. - - 2. install the app from the root of the project directory - - pip install --editable . - - 3. instruct flask to use the right application - - export FLASK_APP="flaskr.factory:create_app()" - - 4. initialize the database with this command: - - flask initdb - - 5. now you can run flaskr: - - flask run - - the application will greet you on - http://localhost:5000/ - - ~ Is it tested? - - You betcha. Run `python setup.py test` to see - the tests pass. diff --git a/examples/flaskr/flaskr/__init__.py b/examples/flaskr/flaskr/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/examples/flaskr/flaskr/blueprints/__init__.py b/examples/flaskr/flaskr/blueprints/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/examples/flaskr/flaskr/blueprints/flaskr.py b/examples/flaskr/flaskr/blueprints/flaskr.py deleted file mode 100644 index e42bee62..00000000 --- a/examples/flaskr/flaskr/blueprints/flaskr.py +++ /dev/null @@ -1,85 +0,0 @@ -# -*- coding: utf-8 -*- -""" - Flaskr - ~~~~~~ - - A microblog example application written as Flask tutorial with - Flask and sqlite3. - - :copyright: © 2010 by the Pallets team. - :license: BSD, see LICENSE for more details. -""" - -from sqlite3 import dbapi2 as sqlite3 -from flask import Blueprint, request, session, g, redirect, url_for, abort, \ - render_template, flash, current_app - - -# create our blueprint :) -bp = Blueprint('flaskr', __name__) - - -def connect_db(): - """Connects to the specific database.""" - rv = sqlite3.connect(current_app.config['DATABASE']) - rv.row_factory = sqlite3.Row - return rv - - -def init_db(): - """Initializes the database.""" - db = get_db() - with current_app.open_resource('schema.sql', mode='r') as f: - db.cursor().executescript(f.read()) - db.commit() - - -def get_db(): - """Opens a new database connection if there is none yet for the - current application context. - """ - if not hasattr(g, 'sqlite_db'): - g.sqlite_db = connect_db() - return g.sqlite_db - - -@bp.route('/') -def show_entries(): - db = get_db() - cur = db.execute('select title, text from entries order by id desc') - entries = cur.fetchall() - return render_template('show_entries.html', entries=entries) - - -@bp.route('/add', methods=['POST']) -def add_entry(): - if not session.get('logged_in'): - abort(401) - db = get_db() - db.execute('insert into entries (title, text) values (?, ?)', - [request.form['title'], request.form['text']]) - db.commit() - flash('New entry was successfully posted') - return redirect(url_for('flaskr.show_entries')) - - -@bp.route('/login', methods=['GET', 'POST']) -def login(): - error = None - if request.method == 'POST': - if request.form['username'] != current_app.config['USERNAME']: - error = 'Invalid username' - elif request.form['password'] != current_app.config['PASSWORD']: - error = 'Invalid password' - else: - session['logged_in'] = True - flash('You were logged in') - return redirect(url_for('flaskr.show_entries')) - return render_template('login.html', error=error) - - -@bp.route('/logout') -def logout(): - session.pop('logged_in', None) - flash('You were logged out') - return redirect(url_for('flaskr.show_entries')) diff --git a/examples/flaskr/flaskr/factory.py b/examples/flaskr/flaskr/factory.py deleted file mode 100644 index b504f64a..00000000 --- a/examples/flaskr/flaskr/factory.py +++ /dev/null @@ -1,64 +0,0 @@ -# -*- coding: utf-8 -*- -""" - Flaskr - ~~~~~~ - - A microblog example application written as Flask tutorial with - Flask and sqlite3. - - :copyright: © 2010 by the Pallets team. - :license: BSD, see LICENSE for more details. -""" - -import os -from flask import Flask, g -from werkzeug.utils import find_modules, import_string -from flaskr.blueprints.flaskr import init_db - - -def create_app(config=None): - app = Flask('flaskr') - - app.config.update(dict( - DATABASE=os.path.join(app.root_path, 'flaskr.db'), - DEBUG=True, - SECRET_KEY=b'_5#y2L"F4Q8z\n\xec]/', - USERNAME='admin', - PASSWORD='default' - )) - app.config.update(config or {}) - app.config.from_envvar('FLASKR_SETTINGS', silent=True) - - register_blueprints(app) - register_cli(app) - register_teardowns(app) - - return app - - -def register_blueprints(app): - """Register all blueprint modules - - Reference: Armin Ronacher, "Flask for Fun and for Profit" PyBay 2016. - """ - for name in find_modules('flaskr.blueprints'): - mod = import_string(name) - if hasattr(mod, 'bp'): - app.register_blueprint(mod.bp) - return None - - -def register_cli(app): - @app.cli.command('initdb') - def initdb_command(): - """Creates the database tables.""" - init_db() - print('Initialized the database.') - - -def register_teardowns(app): - @app.teardown_appcontext - def close_db(error): - """Closes the database again at the end of the request.""" - if hasattr(g, 'sqlite_db'): - g.sqlite_db.close() diff --git a/examples/flaskr/flaskr/schema.sql b/examples/flaskr/flaskr/schema.sql deleted file mode 100644 index 25b2cadd..00000000 --- a/examples/flaskr/flaskr/schema.sql +++ /dev/null @@ -1,6 +0,0 @@ -drop table if exists entries; -create table entries ( - id integer primary key autoincrement, - title text not null, - 'text' text not null -); diff --git a/examples/flaskr/flaskr/static/style.css b/examples/flaskr/flaskr/static/style.css deleted file mode 100644 index 4f3b71d8..00000000 --- a/examples/flaskr/flaskr/static/style.css +++ /dev/null @@ -1,18 +0,0 @@ -body { font-family: sans-serif; background: #eee; } -a, h1, h2 { color: #377BA8; } -h1, h2 { font-family: 'Georgia', serif; margin: 0; } -h1 { border-bottom: 2px solid #eee; } -h2 { font-size: 1.2em; } - -.page { margin: 2em auto; width: 35em; border: 5px solid #ccc; - padding: 0.8em; background: white; } -.entries { list-style: none; margin: 0; padding: 0; } -.entries li { margin: 0.8em 1.2em; } -.entries li h2 { margin-left: -1em; } -.add-entry { font-size: 0.9em; border-bottom: 1px solid #ccc; } -.add-entry dl { font-weight: bold; } -.metanav { text-align: right; font-size: 0.8em; padding: 0.3em; - margin-bottom: 1em; background: #fafafa; } -.flash { background: #CEE5F5; padding: 0.5em; - border: 1px solid #AACBE2; } -.error { background: #F0D6D6; padding: 0.5em; } diff --git a/examples/flaskr/flaskr/templates/layout.html b/examples/flaskr/flaskr/templates/layout.html deleted file mode 100644 index 862a9f4a..00000000 --- a/examples/flaskr/flaskr/templates/layout.html +++ /dev/null @@ -1,17 +0,0 @@ - -Flaskr - -
    -

    Flaskr

    -
    - {% if not session.logged_in %} - log in - {% else %} - log out - {% endif %} -
    - {% for message in get_flashed_messages() %} -
    {{ message }}
    - {% endfor %} - {% block body %}{% endblock %} -
    diff --git a/examples/flaskr/flaskr/templates/login.html b/examples/flaskr/flaskr/templates/login.html deleted file mode 100644 index 505d2f66..00000000 --- a/examples/flaskr/flaskr/templates/login.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "layout.html" %} -{% block body %} -

    Login

    - {% if error %}

    Error: {{ error }}{% endif %} -

    -
    -
    Username: -
    -
    Password: -
    -
    -
    -
    -{% endblock %} diff --git a/examples/flaskr/flaskr/templates/show_entries.html b/examples/flaskr/flaskr/templates/show_entries.html deleted file mode 100644 index cf8fbb86..00000000 --- a/examples/flaskr/flaskr/templates/show_entries.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends "layout.html" %} -{% block body %} - {% if session.logged_in %} -
    -
    -
    Title: -
    -
    Text: -
    -
    -
    -
    - {% endif %} -
      - {% for entry in entries %} -
    • {{ entry.title }}

      {{ entry.text|safe }}
    • - {% else %} -
    • Unbelievable. No entries here so far
    • - {% endfor %} -
    -{% endblock %} diff --git a/examples/flaskr/setup.cfg b/examples/flaskr/setup.cfg deleted file mode 100644 index 9af7e6f1..00000000 --- a/examples/flaskr/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[aliases] -test=pytest \ No newline at end of file diff --git a/examples/flaskr/setup.py b/examples/flaskr/setup.py deleted file mode 100644 index f8995a07..00000000 --- a/examples/flaskr/setup.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -""" - Flaskr Tests - ~~~~~~~~~~~~ - - Tests the Flaskr application. - - :copyright: © 2010 by the Pallets team. - :license: BSD, see LICENSE for more details. -""" - -from setuptools import setup, find_packages - -setup( - name='flaskr', - packages=find_packages(), - include_package_data=True, - install_requires=[ - 'flask', - ], - setup_requires=[ - 'pytest-runner', - ], - tests_require=[ - 'pytest', - ], -) diff --git a/examples/flaskr/tests/test_flaskr.py b/examples/flaskr/tests/test_flaskr.py deleted file mode 100644 index 6e7618d5..00000000 --- a/examples/flaskr/tests/test_flaskr.py +++ /dev/null @@ -1,83 +0,0 @@ -# -*- coding: utf-8 -*- -""" - Flaskr Tests - ~~~~~~~~~~~~ - - Tests the Flaskr application. - - :copyright: © 2010 by the Pallets team. - :license: BSD, see LICENSE for more details. -""" - -import os -import tempfile -import pytest -from flaskr.factory import create_app -from flaskr.blueprints.flaskr import init_db - - -@pytest.fixture -def app(): - db_fd, db_path = tempfile.mkstemp() - config = { - 'DATABASE': db_path, - 'TESTING': True, - } - app = create_app(config=config) - - with app.app_context(): - init_db() - yield app - - os.close(db_fd) - os.unlink(db_path) - - -@pytest.fixture -def client(app): - return app.test_client() - - -def login(client, username, password): - return client.post('/login', data=dict( - username=username, - password=password - ), follow_redirects=True) - - -def logout(client): - return client.get('/logout', follow_redirects=True) - - -def test_empty_db(client): - """Start with a blank database.""" - rv = client.get('/') - assert b'No entries here so far' in rv.data - - -def test_login_logout(client, app): - """Make sure login and logout works""" - rv = login(client, app.config['USERNAME'], - app.config['PASSWORD']) - assert b'You were logged in' in rv.data - rv = logout(client) - assert b'You were logged out' in rv.data - rv = login(client,app.config['USERNAME'] + 'x', - app.config['PASSWORD']) - assert b'Invalid username' in rv.data - rv = login(client, app.config['USERNAME'], - app.config['PASSWORD'] + 'x') - assert b'Invalid password' in rv.data - - -def test_messages(client, app): - """Test that messages work""" - login(client, app.config['USERNAME'], - app.config['PASSWORD']) - rv = client.post('/add', data=dict( - title='', - text='HTML allowed here' - ), follow_redirects=True) - assert b'No entries here so far' not in rv.data - assert b'<Hello>' in rv.data - assert b'HTML allowed here' in rv.data diff --git a/examples/jqueryexample/jqueryexample.py b/examples/jqueryexample/jqueryexample.py deleted file mode 100644 index 561e5375..00000000 --- a/examples/jqueryexample/jqueryexample.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- coding: utf-8 -*- -""" - jQuery Example - ~~~~~~~~~~~~~~ - - A simple application that shows how Flask and jQuery get along. - - :copyright: © 2010 by the Pallets team. - :license: BSD, see LICENSE for more details. -""" - -from flask import Flask, jsonify, render_template, request -app = Flask(__name__) - - -@app.route('/_add_numbers') -def add_numbers(): - """Add two numbers server side, ridiculous but well...""" - a = request.args.get('a', 0, type=int) - b = request.args.get('b', 0, type=int) - return jsonify(result=a + b) - - -@app.route('/') -def index(): - return render_template('index.html') - -if __name__ == '__main__': - app.run() diff --git a/examples/jqueryexample/templates/index.html b/examples/jqueryexample/templates/index.html deleted file mode 100644 index b6118cf4..00000000 --- a/examples/jqueryexample/templates/index.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "layout.html" %} -{% block body %} - -

    jQuery Example

    -

    - + - = - ? -

    calculate server side -{% endblock %} diff --git a/examples/jqueryexample/templates/layout.html b/examples/jqueryexample/templates/layout.html deleted file mode 100644 index 8be7606e..00000000 --- a/examples/jqueryexample/templates/layout.html +++ /dev/null @@ -1,8 +0,0 @@ - -jQuery Example - - -{% block body %}{% endblock %} diff --git a/examples/minitwit/.gitignore b/examples/minitwit/.gitignore deleted file mode 100644 index c3accd82..00000000 --- a/examples/minitwit/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -minitwit.db -.eggs/ diff --git a/examples/minitwit/MANIFEST.in b/examples/minitwit/MANIFEST.in deleted file mode 100644 index 973d6586..00000000 --- a/examples/minitwit/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -graft minitwit/templates -graft minitwit/static -include minitwit/schema.sql \ No newline at end of file diff --git a/examples/minitwit/README b/examples/minitwit/README deleted file mode 100644 index b9bc5ea2..00000000 --- a/examples/minitwit/README +++ /dev/null @@ -1,39 +0,0 @@ - - / MiniTwit / - - because writing todo lists is not fun - - - ~ What is MiniTwit? - - A SQLite and Flask powered twitter clone - - ~ How do I use it? - - 1. edit the configuration in the minitwit.py file or - export an MINITWIT_SETTINGS environment variable - pointing to a configuration file. - - 2. install the app from the root of the project directory - - pip install --editable . - - 3. tell flask about the right application: - - export FLASK_APP=minitwit - - 4. fire up a shell and run this: - - flask initdb - - 5. now you can run minitwit: - - flask run - - the application will greet you on - http://localhost:5000/ - - ~ Is it tested? - - You betcha. Run the `python setup.py test` file to - see the tests pass. diff --git a/examples/minitwit/minitwit/__init__.py b/examples/minitwit/minitwit/__init__.py deleted file mode 100644 index 96c81aec..00000000 --- a/examples/minitwit/minitwit/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .minitwit import app diff --git a/examples/minitwit/minitwit/minitwit.py b/examples/minitwit/minitwit/minitwit.py deleted file mode 100644 index 2fe002e2..00000000 --- a/examples/minitwit/minitwit/minitwit.py +++ /dev/null @@ -1,256 +0,0 @@ -# -*- coding: utf-8 -*- -""" - MiniTwit - ~~~~~~~~ - - A microblogging application written with Flask and sqlite3. - - :copyright: © 2010 by the Pallets team. - :license: BSD, see LICENSE for more details. -""" - -import time -from sqlite3 import dbapi2 as sqlite3 -from hashlib import md5 -from datetime import datetime -from flask import Flask, request, session, url_for, redirect, \ - render_template, abort, g, flash, _app_ctx_stack -from werkzeug import check_password_hash, generate_password_hash - - -# configuration -DATABASE = '/tmp/minitwit.db' -PER_PAGE = 30 -DEBUG = True -SECRET_KEY = b'_5#y2L"F4Q8z\n\xec]/' - -# create our little application :) -app = Flask('minitwit') -app.config.from_object(__name__) -app.config.from_envvar('MINITWIT_SETTINGS', silent=True) - - -def get_db(): - """Opens a new database connection if there is none yet for the - current application context. - """ - top = _app_ctx_stack.top - if not hasattr(top, 'sqlite_db'): - top.sqlite_db = sqlite3.connect(app.config['DATABASE']) - top.sqlite_db.row_factory = sqlite3.Row - return top.sqlite_db - - -@app.teardown_appcontext -def close_database(exception): - """Closes the database again at the end of the request.""" - top = _app_ctx_stack.top - if hasattr(top, 'sqlite_db'): - top.sqlite_db.close() - - -def init_db(): - """Initializes the database.""" - db = get_db() - with app.open_resource('schema.sql', mode='r') as f: - db.cursor().executescript(f.read()) - db.commit() - - -@app.cli.command('initdb') -def initdb_command(): - """Creates the database tables.""" - init_db() - print('Initialized the database.') - - -def query_db(query, args=(), one=False): - """Queries the database and returns a list of dictionaries.""" - cur = get_db().execute(query, args) - rv = cur.fetchall() - return (rv[0] if rv else None) if one else rv - - -def get_user_id(username): - """Convenience method to look up the id for a username.""" - rv = query_db('select user_id from user where username = ?', - [username], one=True) - return rv[0] if rv else None - - -def format_datetime(timestamp): - """Format a timestamp for display.""" - return datetime.utcfromtimestamp(timestamp).strftime('%Y-%m-%d @ %H:%M') - - -def gravatar_url(email, size=80): - """Return the gravatar image for the given email address.""" - return 'https://www.gravatar.com/avatar/%s?d=identicon&s=%d' % \ - (md5(email.strip().lower().encode('utf-8')).hexdigest(), size) - - -@app.before_request -def before_request(): - g.user = None - if 'user_id' in session: - g.user = query_db('select * from user where user_id = ?', - [session['user_id']], one=True) - - -@app.route('/') -def timeline(): - """Shows a users timeline or if no user is logged in it will - redirect to the public timeline. This timeline shows the user's - messages as well as all the messages of followed users. - """ - if not g.user: - return redirect(url_for('public_timeline')) - return render_template('timeline.html', messages=query_db(''' - select message.*, user.* from message, user - where message.author_id = user.user_id and ( - user.user_id = ? or - user.user_id in (select whom_id from follower - where who_id = ?)) - order by message.pub_date desc limit ?''', - [session['user_id'], session['user_id'], PER_PAGE])) - - -@app.route('/public') -def public_timeline(): - """Displays the latest messages of all users.""" - return render_template('timeline.html', messages=query_db(''' - select message.*, user.* from message, user - where message.author_id = user.user_id - order by message.pub_date desc limit ?''', [PER_PAGE])) - - -@app.route('/') -def user_timeline(username): - """Display's a users tweets.""" - profile_user = query_db('select * from user where username = ?', - [username], one=True) - if profile_user is None: - abort(404) - followed = False - if g.user: - followed = query_db('''select 1 from follower where - follower.who_id = ? and follower.whom_id = ?''', - [session['user_id'], profile_user['user_id']], - one=True) is not None - return render_template('timeline.html', messages=query_db(''' - select message.*, user.* from message, user where - user.user_id = message.author_id and user.user_id = ? - order by message.pub_date desc limit ?''', - [profile_user['user_id'], PER_PAGE]), followed=followed, - profile_user=profile_user) - - -@app.route('//follow') -def follow_user(username): - """Adds the current user as follower of the given user.""" - if not g.user: - abort(401) - whom_id = get_user_id(username) - if whom_id is None: - abort(404) - db = get_db() - db.execute('insert into follower (who_id, whom_id) values (?, ?)', - [session['user_id'], whom_id]) - db.commit() - flash('You are now following "%s"' % username) - return redirect(url_for('user_timeline', username=username)) - - -@app.route('//unfollow') -def unfollow_user(username): - """Removes the current user as follower of the given user.""" - if not g.user: - abort(401) - whom_id = get_user_id(username) - if whom_id is None: - abort(404) - db = get_db() - db.execute('delete from follower where who_id=? and whom_id=?', - [session['user_id'], whom_id]) - db.commit() - flash('You are no longer following "%s"' % username) - return redirect(url_for('user_timeline', username=username)) - - -@app.route('/add_message', methods=['POST']) -def add_message(): - """Registers a new message for the user.""" - if 'user_id' not in session: - abort(401) - if request.form['text']: - db = get_db() - db.execute('''insert into message (author_id, text, pub_date) - values (?, ?, ?)''', (session['user_id'], request.form['text'], - int(time.time()))) - db.commit() - flash('Your message was recorded') - return redirect(url_for('timeline')) - - -@app.route('/login', methods=['GET', 'POST']) -def login(): - """Logs the user in.""" - if g.user: - return redirect(url_for('timeline')) - error = None - if request.method == 'POST': - user = query_db('''select * from user where - username = ?''', [request.form['username']], one=True) - if user is None: - error = 'Invalid username' - elif not check_password_hash(user['pw_hash'], - request.form['password']): - error = 'Invalid password' - else: - flash('You were logged in') - session['user_id'] = user['user_id'] - return redirect(url_for('timeline')) - return render_template('login.html', error=error) - - -@app.route('/register', methods=['GET', 'POST']) -def register(): - """Registers the user.""" - if g.user: - return redirect(url_for('timeline')) - error = None - if request.method == 'POST': - if not request.form['username']: - error = 'You have to enter a username' - elif not request.form['email'] or \ - '@' not in request.form['email']: - error = 'You have to enter a valid email address' - elif not request.form['password']: - error = 'You have to enter a password' - elif request.form['password'] != request.form['password2']: - error = 'The two passwords do not match' - elif get_user_id(request.form['username']) is not None: - error = 'The username is already taken' - else: - db = get_db() - db.execute('''insert into user ( - username, email, pw_hash) values (?, ?, ?)''', - [request.form['username'], request.form['email'], - generate_password_hash(request.form['password'])]) - db.commit() - flash('You were successfully registered and can login now') - return redirect(url_for('login')) - return render_template('register.html', error=error) - - -@app.route('/logout') -def logout(): - """Logs the user out.""" - flash('You were logged out') - session.pop('user_id', None) - return redirect(url_for('public_timeline')) - - -# add some filters to jinja -app.jinja_env.filters['datetimeformat'] = format_datetime -app.jinja_env.filters['gravatar'] = gravatar_url diff --git a/examples/minitwit/minitwit/schema.sql b/examples/minitwit/minitwit/schema.sql deleted file mode 100644 index b272adc8..00000000 --- a/examples/minitwit/minitwit/schema.sql +++ /dev/null @@ -1,21 +0,0 @@ -drop table if exists user; -create table user ( - user_id integer primary key autoincrement, - username text not null, - email text not null, - pw_hash text not null -); - -drop table if exists follower; -create table follower ( - who_id integer, - whom_id integer -); - -drop table if exists message; -create table message ( - message_id integer primary key autoincrement, - author_id integer not null, - text text not null, - pub_date integer -); diff --git a/examples/minitwit/minitwit/static/style.css b/examples/minitwit/minitwit/static/style.css deleted file mode 100644 index ebbed8c9..00000000 --- a/examples/minitwit/minitwit/static/style.css +++ /dev/null @@ -1,178 +0,0 @@ -body { - background: #CAECE9; - font-family: 'Trebuchet MS', sans-serif; - font-size: 14px; -} - -a { - color: #26776F; -} - -a:hover { - color: #333; -} - -input[type="text"], -input[type="password"] { - background: white; - border: 1px solid #BFE6E2; - padding: 2px; - font-family: 'Trebuchet MS', sans-serif; - font-size: 14px; - -moz-border-radius: 2px; - -webkit-border-radius: 2px; - color: #105751; -} - -input[type="submit"] { - background: #105751; - border: 1px solid #073B36; - padding: 1px 3px; - font-family: 'Trebuchet MS', sans-serif; - font-size: 14px; - font-weight: bold; - -moz-border-radius: 2px; - -webkit-border-radius: 2px; - color: white; -} - -div.page { - background: white; - border: 1px solid #6ECCC4; - width: 700px; - margin: 30px auto; -} - -div.page h1 { - background: #6ECCC4; - margin: 0; - padding: 10px 14px; - color: white; - letter-spacing: 1px; - text-shadow: 0 0 3px #24776F; - font-weight: normal; -} - -div.page div.navigation { - background: #DEE9E8; - padding: 4px 10px; - border-top: 1px solid #ccc; - border-bottom: 1px solid #eee; - color: #888; - font-size: 12px; - letter-spacing: 0.5px; -} - -div.page div.navigation a { - color: #444; - font-weight: bold; -} - -div.page h2 { - margin: 0 0 15px 0; - color: #105751; - text-shadow: 0 1px 2px #ccc; -} - -div.page div.body { - padding: 10px; -} - -div.page div.footer { - background: #eee; - color: #888; - padding: 5px 10px; - font-size: 12px; -} - -div.page div.followstatus { - border: 1px solid #ccc; - background: #E3EBEA; - -moz-border-radius: 2px; - -webkit-border-radius: 2px; - padding: 3px; - font-size: 13px; -} - -div.page ul.messages { - list-style: none; - margin: 0; - padding: 0; -} - -div.page ul.messages li { - margin: 10px 0; - padding: 5px; - background: #F0FAF9; - border: 1px solid #DBF3F1; - -moz-border-radius: 5px; - -webkit-border-radius: 5px; - min-height: 48px; -} - -div.page ul.messages p { - margin: 0; -} - -div.page ul.messages li img { - float: left; - padding: 0 10px 0 0; -} - -div.page ul.messages li small { - font-size: 0.9em; - color: #888; -} - -div.page div.twitbox { - margin: 10px 0; - padding: 5px; - background: #F0FAF9; - border: 1px solid #94E2DA; - -moz-border-radius: 5px; - -webkit-border-radius: 5px; -} - -div.page div.twitbox h3 { - margin: 0; - font-size: 1em; - color: #2C7E76; -} - -div.page div.twitbox p { - margin: 0; -} - -div.page div.twitbox input[type="text"] { - width: 585px; -} - -div.page div.twitbox input[type="submit"] { - width: 70px; - margin-left: 5px; -} - -ul.flashes { - list-style: none; - margin: 10px 10px 0 10px; - padding: 0; -} - -ul.flashes li { - background: #B9F3ED; - border: 1px solid #81CEC6; - -moz-border-radius: 2px; - -webkit-border-radius: 2px; - padding: 4px; - font-size: 13px; -} - -div.error { - margin: 10px 0; - background: #FAE4E4; - border: 1px solid #DD6F6F; - -moz-border-radius: 2px; - -webkit-border-radius: 2px; - padding: 4px; - font-size: 13px; -} diff --git a/examples/minitwit/minitwit/templates/layout.html b/examples/minitwit/minitwit/templates/layout.html deleted file mode 100644 index 5a43df61..00000000 --- a/examples/minitwit/minitwit/templates/layout.html +++ /dev/null @@ -1,32 +0,0 @@ - -{% block title %}Welcome{% endblock %} | MiniTwit - -

    -

    MiniTwit

    - - {% with flashes = get_flashed_messages() %} - {% if flashes %} -
      - {% for message in flashes %} -
    • {{ message }} - {% endfor %} -
    - {% endif %} - {% endwith %} -
    - {% block body %}{% endblock %} -
    - -
    diff --git a/examples/minitwit/minitwit/templates/login.html b/examples/minitwit/minitwit/templates/login.html deleted file mode 100644 index f15bf109..00000000 --- a/examples/minitwit/minitwit/templates/login.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "layout.html" %} -{% block title %}Sign In{% endblock %} -{% block body %} -

    Sign In

    - {% if error %}
    Error: {{ error }}
    {% endif %} -
    -
    -
    Username: -
    -
    Password: -
    -
    -
    -
    -{% endblock %} - diff --git a/examples/minitwit/minitwit/templates/register.html b/examples/minitwit/minitwit/templates/register.html deleted file mode 100644 index f28cd9f0..00000000 --- a/examples/minitwit/minitwit/templates/register.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "layout.html" %} -{% block title %}Sign Up{% endblock %} -{% block body %} -

    Sign Up

    - {% if error %}
    Error: {{ error }}
    {% endif %} -
    -
    -
    Username: -
    -
    E-Mail: -
    -
    Password: -
    -
    Password (repeat): -
    -
    -
    -
    -{% endblock %} diff --git a/examples/minitwit/minitwit/templates/timeline.html b/examples/minitwit/minitwit/templates/timeline.html deleted file mode 100644 index bf655634..00000000 --- a/examples/minitwit/minitwit/templates/timeline.html +++ /dev/null @@ -1,49 +0,0 @@ -{% extends "layout.html" %} -{% block title %} - {% if request.endpoint == 'public_timeline' %} - Public Timeline - {% elif request.endpoint == 'user_timeline' %} - {{ profile_user.username }}'s Timeline - {% else %} - My Timeline - {% endif %} -{% endblock %} -{% block body %} -

    {{ self.title() }}

    - {% if g.user %} - {% if request.endpoint == 'user_timeline' %} -
    - {% if g.user.user_id == profile_user.user_id %} - This is you! - {% elif followed %} - You are currently following this user. - Unfollow user. - {% else %} - You are not yet following this user. - . - {% endif %} -
    - {% elif request.endpoint == 'timeline' %} -
    -

    What's on your mind {{ g.user.username }}?

    -
    -

    -

    -
    - {% endif %} - {% endif %} -
      - {% for message in messages %} -
    • - {{ message.username }} - {{ message.text }} - — {{ message.pub_date|datetimeformat }} - {% else %} -

    • There's no message so far. - {% endfor %} -
    -{% endblock %} diff --git a/examples/minitwit/setup.cfg b/examples/minitwit/setup.cfg deleted file mode 100644 index b7e47898..00000000 --- a/examples/minitwit/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[aliases] -test=pytest diff --git a/examples/minitwit/setup.py b/examples/minitwit/setup.py deleted file mode 100644 index 1e580216..00000000 --- a/examples/minitwit/setup.py +++ /dev/null @@ -1,16 +0,0 @@ -from setuptools import setup - -setup( - name='minitwit', - packages=['minitwit'], - include_package_data=True, - install_requires=[ - 'flask', - ], - setup_requires=[ - 'pytest-runner', - ], - tests_require=[ - 'pytest', - ], -) \ No newline at end of file diff --git a/examples/minitwit/tests/test_minitwit.py b/examples/minitwit/tests/test_minitwit.py deleted file mode 100644 index 3decc6da..00000000 --- a/examples/minitwit/tests/test_minitwit.py +++ /dev/null @@ -1,150 +0,0 @@ -# -*- coding: utf-8 -*- -""" - MiniTwit Tests - ~~~~~~~~~~~~~~ - - Tests the MiniTwit application. - - :copyright: © 2010 by the Pallets team. - :license: BSD, see LICENSE for more details. -""" - -import os -import tempfile -import pytest -from minitwit import minitwit - - -@pytest.fixture -def client(): - db_fd, minitwit.app.config['DATABASE'] = tempfile.mkstemp() - client = minitwit.app.test_client() - with minitwit.app.app_context(): - minitwit.init_db() - - yield client - - os.close(db_fd) - os.unlink(minitwit.app.config['DATABASE']) - - -def register(client, username, password, password2=None, email=None): - """Helper function to register a user""" - if password2 is None: - password2 = password - if email is None: - email = username + '@example.com' - return client.post('/register', data={ - 'username': username, - 'password': password, - 'password2': password2, - 'email': email, - }, follow_redirects=True) - - -def login(client, username, password): - """Helper function to login""" - return client.post('/login', data={ - 'username': username, - 'password': password - }, follow_redirects=True) - - -def register_and_login(client, username, password): - """Registers and logs in in one go""" - register(client, username, password) - return login(client, username, password) - - -def logout(client): - """Helper function to logout""" - return client.get('/logout', follow_redirects=True) - - -def add_message(client, text): - """Records a message""" - rv = client.post('/add_message', data={'text': text}, - follow_redirects=True) - if text: - assert b'Your message was recorded' in rv.data - return rv - - -def test_register(client): - """Make sure registering works""" - rv = register(client, 'user1', 'default') - assert b'You were successfully registered ' \ - b'and can login now' in rv.data - rv = register(client, 'user1', 'default') - assert b'The username is already taken' in rv.data - rv = register(client, '', 'default') - assert b'You have to enter a username' in rv.data - rv = register(client, 'meh', '') - assert b'You have to enter a password' in rv.data - rv = register(client, 'meh', 'x', 'y') - assert b'The two passwords do not match' in rv.data - rv = register(client, 'meh', 'foo', email='broken') - assert b'You have to enter a valid email address' in rv.data - - -def test_login_logout(client): - """Make sure logging in and logging out works""" - rv = register_and_login(client, 'user1', 'default') - assert b'You were logged in' in rv.data - rv = logout(client) - assert b'You were logged out' in rv.data - rv = login(client, 'user1', 'wrongpassword') - assert b'Invalid password' in rv.data - rv = login(client, 'user2', 'wrongpassword') - assert b'Invalid username' in rv.data - - -def test_message_recording(client): - """Check if adding messages works""" - register_and_login(client, 'foo', 'default') - add_message(client, 'test message 1') - add_message(client, '') - rv = client.get('/') - assert b'test message 1' in rv.data - assert b'<test message 2>' in rv.data - - -def test_timelines(client): - """Make sure that timelines work""" - register_and_login(client, 'foo', 'default') - add_message(client, 'the message by foo') - logout(client) - register_and_login(client, 'bar', 'default') - add_message(client, 'the message by bar') - rv = client.get('/public') - assert b'the message by foo' in rv.data - assert b'the message by bar' in rv.data - - # bar's timeline should just show bar's message - rv = client.get('/') - assert b'the message by foo' not in rv.data - assert b'the message by bar' in rv.data - - # now let's follow foo - rv = client.get('/foo/follow', follow_redirects=True) - assert b'You are now following "foo"' in rv.data - - # we should now see foo's message - rv = client.get('/') - assert b'the message by foo' in rv.data - assert b'the message by bar' in rv.data - - # but on the user's page we only want the user's message - rv = client.get('/bar') - assert b'the message by foo' not in rv.data - assert b'the message by bar' in rv.data - rv = client.get('/foo') - assert b'the message by foo' in rv.data - assert b'the message by bar' not in rv.data - - # now unfollow and check if that worked - rv = client.get('/foo/unfollow', follow_redirects=True) - assert b'You are no longer following "foo"' in rv.data - rv = client.get('/') - assert b'the message by foo' not in rv.data - assert b'the message by bar' in rv.data diff --git a/examples/patterns/largerapp/setup.py b/examples/patterns/largerapp/setup.py deleted file mode 100644 index eaf00f07..00000000 --- a/examples/patterns/largerapp/setup.py +++ /dev/null @@ -1,10 +0,0 @@ -from setuptools import setup - -setup( - name='yourapplication', - packages=['yourapplication'], - include_package_data=True, - install_requires=[ - 'flask', - ], -) diff --git a/examples/patterns/largerapp/tests/test_largerapp.py b/examples/patterns/largerapp/tests/test_largerapp.py deleted file mode 100644 index 32553d7c..00000000 --- a/examples/patterns/largerapp/tests/test_largerapp.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Larger App Tests -~~~~~~~~~~~~~~~~ - -:copyright: © 2010 by the Pallets team. -:license: BSD, see LICENSE for more details. -""" - -from yourapplication import app -import pytest - -@pytest.fixture -def client(): - app.config['TESTING'] = True - client = app.test_client() - return client - -def test_index(client): - rv = client.get('/') - assert b"Hello World!" in rv.data diff --git a/examples/patterns/largerapp/yourapplication/__init__.py b/examples/patterns/largerapp/yourapplication/__init__.py deleted file mode 100644 index c2e05dda..00000000 --- a/examples/patterns/largerapp/yourapplication/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# -*- coding: utf-8 -*- -""" -yourapplication -~~~~~~~~~~~~~~~ - -:copyright: © 2010 by the Pallets team. -:license: BSD, see LICENSE for more details. -""" - -from flask import Flask -app = Flask('yourapplication') - -import yourapplication.views diff --git a/examples/patterns/largerapp/yourapplication/static/style.css b/examples/patterns/largerapp/yourapplication/static/style.css deleted file mode 100644 index e69de29b..00000000 diff --git a/examples/patterns/largerapp/yourapplication/templates/index.html b/examples/patterns/largerapp/yourapplication/templates/index.html deleted file mode 100644 index e69de29b..00000000 diff --git a/examples/patterns/largerapp/yourapplication/templates/layout.html b/examples/patterns/largerapp/yourapplication/templates/layout.html deleted file mode 100644 index e69de29b..00000000 diff --git a/examples/patterns/largerapp/yourapplication/templates/login.html b/examples/patterns/largerapp/yourapplication/templates/login.html deleted file mode 100644 index e69de29b..00000000 diff --git a/examples/patterns/largerapp/yourapplication/views.py b/examples/patterns/largerapp/yourapplication/views.py deleted file mode 100644 index 5337eab7..00000000 --- a/examples/patterns/largerapp/yourapplication/views.py +++ /dev/null @@ -1,14 +0,0 @@ -# -*- coding: utf-8 -*- -""" -yourapplication.views -~~~~~~~~~~~~~~~~~~~~~ - -:copyright: © 2010 by the Pallets team. -:license: BSD, see LICENSE for more details. -""" - -from yourapplication import app - -@app.route('/') -def index(): - return 'Hello World!' diff --git a/examples/tutorial/.gitignore b/examples/tutorial/.gitignore new file mode 100644 index 00000000..85a35845 --- /dev/null +++ b/examples/tutorial/.gitignore @@ -0,0 +1,14 @@ +venv/ +*.pyc +__pycache__/ +instance/ +.cache/ +.pytest_cache/ +.coverage +htmlcov/ +dist/ +build/ +*.egg-info/ +.idea/ +*.swp +*~ diff --git a/examples/tutorial/LICENSE b/examples/tutorial/LICENSE new file mode 100644 index 00000000..8f9252f4 --- /dev/null +++ b/examples/tutorial/LICENSE @@ -0,0 +1,31 @@ +Copyright © 2010 by the Pallets team. + +Some rights reserved. + +Redistribution and use in source and binary forms of the software as +well as documentation, with or without modification, are permitted +provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE AND DOCUMENTATION IS PROVIDED BY THE COPYRIGHT HOLDERS AND +CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF +USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +THIS SOFTWARE AND DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/examples/flaskr/MANIFEST.in b/examples/tutorial/MANIFEST.in similarity index 58% rename from examples/flaskr/MANIFEST.in rename to examples/tutorial/MANIFEST.in index efbd93df..a73511ed 100644 --- a/examples/flaskr/MANIFEST.in +++ b/examples/tutorial/MANIFEST.in @@ -1,3 +1,6 @@ -graft flaskr/templates -graft flaskr/static +include LICENSE include flaskr/schema.sql +graft flaskr/static +graft flaskr/templates +graft tests +global-exclude *.pyc diff --git a/examples/tutorial/README.rst b/examples/tutorial/README.rst new file mode 100644 index 00000000..e0c44b61 --- /dev/null +++ b/examples/tutorial/README.rst @@ -0,0 +1,76 @@ +Flaskr +====== + +The basic blog app built in the Flask `tutorial`_. + +.. _tutorial: http://flask.pocoo.org/docs/tutorial/ + + +Install +------- + +**Be sure to use the same version of the code as the version of the docs +you're reading.** You probably want the latest tagged version, but the +default Git version is the master branch. :: + + # clone the repository + git clone https://github.com/pallets/flask + cd flask + # checkout the correct version + git tag # shows the tagged versions + git checkout latest-tag-found-above + cd examples/tutorial + +Create a virtualenv and activate it:: + + python3 -m venv venv + . venv/bin/activate + +Or on Windows cmd:: + + py -3 -m venv venv + venv\Scripts\activate.bat + +Install Flaskr:: + + pip install -e . + +Or if you are using the master branch, install Flask from source before +installing Flaskr:: + + pip install -e ../.. + pip install -e . + + +Run +--- + +:: + + export FLASK_APP=flaskr + export FLASK_ENV=development + flask run + +Or on Windows cmd:: + + set FLASK_APP=flaskr + set FLASK_ENV=development + flask run + +Open http://127.0.0.1:5000 in a browser. + + +Test +---- + +:: + + pip install pytest + pytest + +Run with coverage report:: + + pip install pytest coverage + coverage run -m pytest + coverage report + coverage html # open htmlcov/index.html in a browser diff --git a/examples/tutorial/flaskr/__init__.py b/examples/tutorial/flaskr/__init__.py new file mode 100644 index 00000000..05316607 --- /dev/null +++ b/examples/tutorial/flaskr/__init__.py @@ -0,0 +1,48 @@ +import os + +from flask import Flask + + +def create_app(test_config=None): + """Create and configure an instance of the Flask application.""" + app = Flask(__name__, instance_relative_config=True) + app.config.from_mapping( + # a default secret that should be overridden by instance config + SECRET_KEY='dev', + # store the database in the instance folder + DATABASE=os.path.join(app.instance_path, 'flaskr.sqlite'), + ) + + if test_config is None: + # load the instance config, if it exists, when not testing + app.config.from_pyfile('config.py', silent=True) + else: + # load the test config if passed in + app.config.update(test_config) + + # ensure the instance folder exists + try: + os.makedirs(app.instance_path) + except OSError: + pass + + @app.route('/hello') + def hello(): + return 'Hello, World!' + + # register the database commands + from flaskr import db + db.init_app(app) + + # apply the blueprints to the app + from flaskr import auth, blog + app.register_blueprint(auth.bp) + app.register_blueprint(blog.bp) + + # make url_for('index') == url_for('blog.index') + # in another app, you might define a separate main index here with + # app.route, while giving the blog blueprint a url_prefix, but for + # the tutorial the blog will be the main index + app.add_url_rule('/', endpoint='index') + + return app diff --git a/examples/tutorial/flaskr/auth.py b/examples/tutorial/flaskr/auth.py new file mode 100644 index 00000000..d86095bf --- /dev/null +++ b/examples/tutorial/flaskr/auth.py @@ -0,0 +1,108 @@ +import functools + +from flask import ( + Blueprint, flash, g, redirect, render_template, request, session, url_for +) +from werkzeug.security import check_password_hash, generate_password_hash + +from flaskr.db import get_db + +bp = Blueprint('auth', __name__, url_prefix='/auth') + + +def login_required(view): + """View decorator that redirects anonymous users to the login page.""" + @functools.wraps(view) + def wrapped_view(**kwargs): + if g.user is None: + return redirect(url_for('auth.login')) + + return view(**kwargs) + + return wrapped_view + + +@bp.before_app_request +def load_logged_in_user(): + """If a user id is stored in the session, load the user object from + the database into ``g.user``.""" + user_id = session.get('user_id') + + if user_id is None: + g.user = None + else: + g.user = get_db().execute( + 'SELECT * FROM user WHERE id = ?', (user_id,) + ).fetchone() + + +@bp.route('/register', methods=('GET', 'POST')) +def register(): + """Register a new user. + + Validates that the username is not already taken. Hashes the + password for security. + """ + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + db = get_db() + error = None + + if not username: + error = 'Username is required.' + elif not password: + error = 'Password is required.' + elif db.execute( + 'SELECT id FROM user WHERE username = ?', (username,) + ).fetchone() is not None: + error = 'User {} is already registered.'.format(username) + + if error is None: + # the name is available, store it in the database and go to + # the login page + db.execute( + 'INSERT INTO user (username, password) VALUES (?, ?)', + (username, generate_password_hash(password)) + ) + db.commit() + return redirect(url_for('auth.login')) + + flash(error) + + return render_template('auth/register.html') + + +@bp.route('/login', methods=('GET', 'POST')) +def login(): + """Log in a registered user by adding the user id to the session.""" + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + db = get_db() + error = None + user = db.execute( + 'SELECT * FROM user WHERE username = ?', (username,) + ).fetchone() + + if user is None: + error = 'Incorrect username.' + elif not check_password_hash(user['password'], password): + error = 'Incorrect password.' + + if error is None: + # store the user id in a new session and return to the index + session.clear() + session['user_id'] = user['id'] + return redirect(url_for('index')) + + flash(error) + + return render_template('auth/login.html') + + +@bp.route('/logout') +def logout(): + """Clear the current session, including the stored user id.""" + session.clear() + return redirect(url_for('index')) diff --git a/examples/tutorial/flaskr/blog.py b/examples/tutorial/flaskr/blog.py new file mode 100644 index 00000000..784b1d8c --- /dev/null +++ b/examples/tutorial/flaskr/blog.py @@ -0,0 +1,119 @@ +from flask import ( + Blueprint, flash, g, redirect, render_template, request, url_for +) +from werkzeug.exceptions import abort + +from flaskr.auth import login_required +from flaskr.db import get_db + +bp = Blueprint('blog', __name__) + + +@bp.route('/') +def index(): + """Show all the posts, most recent first.""" + db = get_db() + posts = db.execute( + 'SELECT p.id, title, body, created, author_id, username' + ' FROM post p JOIN user u ON p.author_id = u.id' + ' ORDER BY created DESC' + ).fetchall() + return render_template('blog/index.html', posts=posts) + + +def get_post(id, check_author=True): + """Get a post and its author by id. + + Checks that the id exists and optionally that the current user is + the author. + + :param id: id of post to get + :param check_author: require the current user to be the author + :return: the post with author information + :raise 404: if a post with the given id doesn't exist + :raise 403: if the current user isn't the author + """ + post = get_db().execute( + 'SELECT p.id, title, body, created, author_id, username' + ' FROM post p JOIN user u ON p.author_id = u.id' + ' WHERE p.id = ?', + (id,) + ).fetchone() + + if post is None: + abort(404, "Post id {0} doesn't exist.".format(id)) + + if check_author and post['author_id'] != g.user['id']: + abort(403) + + return post + + +@bp.route('/create', methods=('GET', 'POST')) +@login_required +def create(): + """Create a new post for the current user.""" + if request.method == 'POST': + title = request.form['title'] + body = request.form['body'] + error = None + + if not title: + error = 'Title is required.' + + if error is not None: + flash(error) + else: + db = get_db() + db.execute( + 'INSERT INTO post (title, body, author_id)' + ' VALUES (?, ?, ?)', + (title, body, g.user['id']) + ) + db.commit() + return redirect(url_for('blog.index')) + + return render_template('blog/create.html') + + +@bp.route('//update', methods=('GET', 'POST')) +@login_required +def update(id): + """Update a post if the current user is the author.""" + post = get_post(id) + + if request.method == 'POST': + title = request.form['title'] + body = request.form['body'] + error = None + + if not title: + error = 'Title is required.' + + if error is not None: + flash(error) + else: + db = get_db() + db.execute( + 'UPDATE post SET title = ?, body = ? WHERE id = ?', + (title, body, id) + ) + db.commit() + return redirect(url_for('blog.index')) + + return render_template('blog/update.html', post=post) + + +@bp.route('//delete', methods=('POST',)) +@login_required +def delete(id): + """Delete a post. + + Ensures that the post exists and that the logged in user is the + author of the post. + """ + get_post(id) + db = get_db() + db.execute('DELETE FROM post WHERE id = ?', (id,)) + db.commit() + return redirect(url_for('blog.index')) diff --git a/examples/tutorial/flaskr/db.py b/examples/tutorial/flaskr/db.py new file mode 100644 index 00000000..03bd3b3c --- /dev/null +++ b/examples/tutorial/flaskr/db.py @@ -0,0 +1,54 @@ +import sqlite3 + +import click +from flask import current_app, g +from flask.cli import with_appcontext + + +def get_db(): + """Connect to the application's configured database. The connection + is unique for each request and will be reused if this is called + again. + """ + if 'db' not in g: + g.db = sqlite3.connect( + current_app.config['DATABASE'], + detect_types=sqlite3.PARSE_DECLTYPES + ) + g.db.row_factory = sqlite3.Row + + return g.db + + +def close_db(e=None): + """If this request connected to the database, close the + connection. + """ + db = g.pop('db', None) + + if db is not None: + db.close() + + +def init_db(): + """Clear existing data and create new tables.""" + db = get_db() + + with current_app.open_resource('schema.sql') as f: + db.executescript(f.read().decode('utf8')) + + +@click.command('init-db') +@with_appcontext +def init_db_command(): + """Clear existing data and create new tables.""" + init_db() + click.echo('Initialized the database.') + + +def init_app(app): + """Register database functions with the Flask app. This is called by + the application factory. + """ + app.teardown_appcontext(close_db) + app.cli.add_command(init_db_command) diff --git a/examples/tutorial/flaskr/schema.sql b/examples/tutorial/flaskr/schema.sql new file mode 100644 index 00000000..dd4c8660 --- /dev/null +++ b/examples/tutorial/flaskr/schema.sql @@ -0,0 +1,20 @@ +-- Initialize the database. +-- Drop any existing data and create empty tables. + +DROP TABLE IF EXISTS user; +DROP TABLE IF EXISTS post; + +CREATE TABLE user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL +); + +CREATE TABLE post ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + author_id INTEGER NOT NULL, + created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + title TEXT NOT NULL, + body TEXT NOT NULL, + FOREIGN KEY (author_id) REFERENCES user (id) +); diff --git a/examples/tutorial/flaskr/static/style.css b/examples/tutorial/flaskr/static/style.css new file mode 100644 index 00000000..2f1f4d0c --- /dev/null +++ b/examples/tutorial/flaskr/static/style.css @@ -0,0 +1,134 @@ +html { + font-family: sans-serif; + background: #eee; + padding: 1rem; +} + +body { + max-width: 960px; + margin: 0 auto; + background: white; +} + +h1, h2, h3, h4, h5, h6 { + font-family: serif; + color: #377ba8; + margin: 1rem 0; +} + +a { + color: #377ba8; +} + +hr { + border: none; + border-top: 1px solid lightgray; +} + +nav { + background: lightgray; + display: flex; + align-items: center; + padding: 0 0.5rem; +} + +nav h1 { + flex: auto; + margin: 0; +} + +nav h1 a { + text-decoration: none; + padding: 0.25rem 0.5rem; +} + +nav ul { + display: flex; + list-style: none; + margin: 0; + padding: 0; +} + +nav ul li a, nav ul li span, header .action { + display: block; + padding: 0.5rem; +} + +.content { + padding: 0 1rem 1rem; +} + +.content > header { + border-bottom: 1px solid lightgray; + display: flex; + align-items: flex-end; +} + +.content > header h1 { + flex: auto; + margin: 1rem 0 0.25rem 0; +} + +.flash { + margin: 1em 0; + padding: 1em; + background: #cae6f6; + border: 1px solid #377ba8; +} + +.post > header { + display: flex; + align-items: flex-end; + font-size: 0.85em; +} + +.post > header > div:first-of-type { + flex: auto; +} + +.post > header h1 { + font-size: 1.5em; + margin-bottom: 0; +} + +.post .about { + color: slategray; + font-style: italic; +} + +.post .body { + white-space: pre-line; +} + +.content:last-child { + margin-bottom: 0; +} + +.content form { + margin: 1em 0; + display: flex; + flex-direction: column; +} + +.content label { + font-weight: bold; + margin-bottom: 0.5em; +} + +.content input, .content textarea { + margin-bottom: 1em; +} + +.content textarea { + min-height: 12em; + resize: vertical; +} + +input.danger { + color: #cc2f2e; +} + +input[type=submit] { + align-self: start; + min-width: 10em; +} diff --git a/examples/tutorial/flaskr/templates/auth/login.html b/examples/tutorial/flaskr/templates/auth/login.html new file mode 100644 index 00000000..b326b5a6 --- /dev/null +++ b/examples/tutorial/flaskr/templates/auth/login.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + +{% block header %} +

    {% block title %}Log In{% endblock %}

    +{% endblock %} + +{% block content %} +
    + + + + + +
    +{% endblock %} diff --git a/examples/tutorial/flaskr/templates/auth/register.html b/examples/tutorial/flaskr/templates/auth/register.html new file mode 100644 index 00000000..4320e17e --- /dev/null +++ b/examples/tutorial/flaskr/templates/auth/register.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + +{% block header %} +

    {% block title %}Register{% endblock %}

    +{% endblock %} + +{% block content %} +
    + + + + + +
    +{% endblock %} diff --git a/examples/tutorial/flaskr/templates/base.html b/examples/tutorial/flaskr/templates/base.html new file mode 100644 index 00000000..f09e9268 --- /dev/null +++ b/examples/tutorial/flaskr/templates/base.html @@ -0,0 +1,24 @@ + +{% block title %}{% endblock %} - Flaskr + + +
    +
    + {% block header %}{% endblock %} +
    + {% for message in get_flashed_messages() %} +
    {{ message }}
    + {% endfor %} + {% block content %}{% endblock %} +
    diff --git a/examples/tutorial/flaskr/templates/blog/create.html b/examples/tutorial/flaskr/templates/blog/create.html new file mode 100644 index 00000000..88e31e44 --- /dev/null +++ b/examples/tutorial/flaskr/templates/blog/create.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + +{% block header %} +

    {% block title %}New Post{% endblock %}

    +{% endblock %} + +{% block content %} +
    + + + + + +
    +{% endblock %} diff --git a/examples/tutorial/flaskr/templates/blog/index.html b/examples/tutorial/flaskr/templates/blog/index.html new file mode 100644 index 00000000..3481b8e1 --- /dev/null +++ b/examples/tutorial/flaskr/templates/blog/index.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %} + +{% block header %} +

    {% block title %}Posts{% endblock %}

    + {% if g.user %} + New + {% endif %} +{% endblock %} + +{% block content %} + {% for post in posts %} +
    +
    +
    +

    {{ post['title'] }}

    +
    by {{ post['username'] }} on {{ post['created'].strftime('%Y-%m-%d') }}
    +
    + {% if g.user['id'] == post['author_id'] %} + Edit + {% endif %} +
    +

    {{ post['body'] }}

    +
    + {% if not loop.last %} +
    + {% endif %} + {% endfor %} +{% endblock %} diff --git a/examples/tutorial/flaskr/templates/blog/update.html b/examples/tutorial/flaskr/templates/blog/update.html new file mode 100644 index 00000000..2c405e63 --- /dev/null +++ b/examples/tutorial/flaskr/templates/blog/update.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} + +{% block header %} +

    {% block title %}Edit "{{ post['title'] }}"{% endblock %}

    +{% endblock %} + +{% block content %} +
    + + + + + +
    +
    +
    + +
    +{% endblock %} diff --git a/examples/tutorial/setup.cfg b/examples/tutorial/setup.cfg new file mode 100644 index 00000000..b0cc972d --- /dev/null +++ b/examples/tutorial/setup.cfg @@ -0,0 +1,13 @@ +[metadata] +license_file = LICENSE + +[bdist_wheel] +universal = False + +[tool:pytest] +testpaths = tests + +[coverage:run] +branch = True +source = + flaskr diff --git a/examples/tutorial/setup.py b/examples/tutorial/setup.py new file mode 100644 index 00000000..52a282a2 --- /dev/null +++ b/examples/tutorial/setup.py @@ -0,0 +1,23 @@ +import io + +from setuptools import find_packages, setup + +with io.open('README.rst', 'rt', encoding='utf8') as f: + readme = f.read() + +setup( + name='flaskr', + version='1.0.0', + url='http://flask.pocoo.org/docs/tutorial/', + license='BSD', + maintainer='Pallets team', + maintainer_email='contact@palletsprojects.com', + description='The basic blog app built in the Flask tutorial.', + long_description=readme, + packages=find_packages(), + include_package_data=True, + zip_safe=False, + install_requires=[ + 'flask', + ], +) diff --git a/examples/tutorial/tests/conftest.py b/examples/tutorial/tests/conftest.py new file mode 100644 index 00000000..143d6924 --- /dev/null +++ b/examples/tutorial/tests/conftest.py @@ -0,0 +1,64 @@ +import os +import tempfile + +import pytest +from flaskr import create_app +from flaskr.db import get_db, init_db + +# read in SQL for populating test data +with open(os.path.join(os.path.dirname(__file__), 'data.sql'), 'rb') as f: + _data_sql = f.read().decode('utf8') + + +@pytest.fixture +def app(): + """Create and configure a new app instance for each test.""" + # create a temporary file to isolate the database for each test + db_fd, db_path = tempfile.mkstemp() + # create the app with common test config + app = create_app({ + 'TESTING': True, + 'DATABASE': db_path, + }) + + # create the database and load test data + with app.app_context(): + init_db() + get_db().executescript(_data_sql) + + yield app + + # close and remove the temporary database + os.close(db_fd) + os.unlink(db_path) + + +@pytest.fixture +def client(app): + """A test client for the app.""" + return app.test_client() + + +@pytest.fixture +def runner(app): + """A test runner for the app's Click commands.""" + return app.test_cli_runner() + + +class AuthActions(object): + def __init__(self, client): + self._client = client + + def login(self, username='test', password='test'): + return self._client.post( + '/auth/login', + data={'username': username, 'password': password} + ) + + def logout(self): + return self._client.get('/auth/logout') + + +@pytest.fixture +def auth(client): + return AuthActions(client) diff --git a/examples/tutorial/tests/data.sql b/examples/tutorial/tests/data.sql new file mode 100644 index 00000000..9b680065 --- /dev/null +++ b/examples/tutorial/tests/data.sql @@ -0,0 +1,8 @@ +INSERT INTO user (username, password) +VALUES + ('test', 'pbkdf2:sha256:50000$TCI4GzcX$0de171a4f4dac32e3364c7ddc7c14f3e2fa61f2d17574483f7ffbb431b4acb2f'), + ('other', 'pbkdf2:sha256:50000$kJPKsz6N$d2d4784f1b030a9761f5ccaeeaca413f27f2ecb76d6168407af962ddce849f79'); + +INSERT INTO post (title, body, author_id, created) +VALUES + ('test title', 'test' || x'0a' || 'body', 1, '2018-01-01 00:00:00'); diff --git a/examples/tutorial/tests/test_auth.py b/examples/tutorial/tests/test_auth.py new file mode 100644 index 00000000..884bdf49 --- /dev/null +++ b/examples/tutorial/tests/test_auth.py @@ -0,0 +1,66 @@ +import pytest +from flask import g, session +from flaskr.db import get_db + + +def test_register(client, app): + # test that viewing the page renders without template errors + assert client.get('/auth/register').status_code == 200 + + # test that successful registration redirects to the login page + response = client.post( + '/auth/register', data={'username': 'a', 'password': 'a'} + ) + assert 'http://localhost/auth/login' == response.headers['Location'] + + # test that the user was inserted into the database + with app.app_context(): + assert get_db().execute( + "select * from user where username = 'a'", + ).fetchone() is not None + + +@pytest.mark.parametrize(('username', 'password', 'message'), ( + ('', '', b'Username is required.'), + ('a', '', b'Password is required.'), + ('test', 'test', b'already registered'), +)) +def test_register_validate_input(client, username, password, message): + response = client.post( + '/auth/register', + data={'username': username, 'password': password} + ) + assert message in response.data + + +def test_login(client, auth): + # test that viewing the page renders without template errors + assert client.get('/auth/login').status_code == 200 + + # test that successful login redirects to the index page + response = auth.login() + assert response.headers['Location'] == 'http://localhost/' + + # login request set the user_id in the session + # check that the user is loaded from the session + with client: + client.get('/') + assert session['user_id'] == 1 + assert g.user['username'] == 'test' + + +@pytest.mark.parametrize(('username', 'password', 'message'), ( + ('a', 'test', b'Incorrect username.'), + ('test', 'a', b'Incorrect password.'), +)) +def test_login_validate_input(auth, username, password, message): + response = auth.login(username, password) + assert message in response.data + + +def test_logout(client, auth): + auth.login() + + with client: + auth.logout() + assert 'user_id' not in session diff --git a/examples/tutorial/tests/test_blog.py b/examples/tutorial/tests/test_blog.py new file mode 100644 index 00000000..11700458 --- /dev/null +++ b/examples/tutorial/tests/test_blog.py @@ -0,0 +1,92 @@ +import pytest +from flaskr.db import get_db + + +def test_index(client, auth): + response = client.get('/') + assert b"Log In" in response.data + assert b"Register" in response.data + + auth.login() + response = client.get('/') + assert b'test title' in response.data + assert b'by test on 2018-01-01' in response.data + assert b'test\nbody' in response.data + assert b'href="/1/update"' in response.data + + +@pytest.mark.parametrize('path', ( + '/create', + '/1/update', + '/1/delete', +)) +def test_login_required(client, path): + response = client.post(path) + assert response.headers['Location'] == 'http://localhost/auth/login' + + +def test_author_required(app, client, auth): + # change the post author to another user + with app.app_context(): + db = get_db() + db.execute('UPDATE post SET author_id = 2 WHERE id = 1') + db.commit() + + auth.login() + # current user can't modify other user's post + assert client.post('/1/update').status_code == 403 + assert client.post('/1/delete').status_code == 403 + # current user doesn't see edit link + assert b'href="/1/update"' not in client.get('/').data + + +@pytest.mark.parametrize('path', ( + '/2/update', + '/2/delete', +)) +def test_exists_required(client, auth, path): + auth.login() + assert client.post(path).status_code == 404 + + +def test_create(client, auth, app): + auth.login() + assert client.get('/create').status_code == 200 + client.post('/create', data={'title': 'created', 'body': ''}) + + with app.app_context(): + db = get_db() + count = db.execute('SELECT COUNT(id) FROM post').fetchone()[0] + assert count == 2 + + +def test_update(client, auth, app): + auth.login() + assert client.get('/1/update').status_code == 200 + client.post('/1/update', data={'title': 'updated', 'body': ''}) + + with app.app_context(): + db = get_db() + post = db.execute('SELECT * FROM post WHERE id = 1').fetchone() + assert post['title'] == 'updated' + + +@pytest.mark.parametrize('path', ( + '/create', + '/1/update', +)) +def test_create_update_validate(client, auth, path): + auth.login() + response = client.post(path, data={'title': '', 'body': ''}) + assert b'Title is required.' in response.data + + +def test_delete(client, auth, app): + auth.login() + response = client.post('/1/delete') + assert response.headers['Location'] == 'http://localhost/' + + with app.app_context(): + db = get_db() + post = db.execute('SELECT * FROM post WHERE id = 1').fetchone() + assert post is None diff --git a/examples/tutorial/tests/test_db.py b/examples/tutorial/tests/test_db.py new file mode 100644 index 00000000..99c46d04 --- /dev/null +++ b/examples/tutorial/tests/test_db.py @@ -0,0 +1,28 @@ +import sqlite3 + +import pytest +from flaskr.db import get_db + + +def test_get_close_db(app): + with app.app_context(): + db = get_db() + assert db is get_db() + + with pytest.raises(sqlite3.ProgrammingError) as e: + db.execute('SELECT 1') + + assert 'closed' in str(e) + + +def test_init_db_command(runner, monkeypatch): + class Recorder(object): + called = False + + def fake_init_db(): + Recorder.called = True + + monkeypatch.setattr('flaskr.db.init_db', fake_init_db) + result = runner.invoke(args=['init-db']) + assert 'Initialized' in result.output + assert Recorder.called diff --git a/examples/tutorial/tests/test_factory.py b/examples/tutorial/tests/test_factory.py new file mode 100644 index 00000000..b7afeae7 --- /dev/null +++ b/examples/tutorial/tests/test_factory.py @@ -0,0 +1,12 @@ +from flaskr import create_app + + +def test_config(): + """Test create_app without passing test config.""" + assert not create_app().testing + assert create_app({'TESTING': True}).testing + + +def test_hello(client): + response = client.get('/hello') + assert response.data == b'Hello, World!' diff --git a/tox.ini b/tox.ini index d74c8758..cbc72ade 100644 --- a/tox.ini +++ b/tox.ini @@ -31,9 +31,7 @@ deps = commands = # the examples need to be installed to test successfully - pip install -e examples/flaskr -q - pip install -e examples/minitwit -q - pip install -e examples/patterns/largerapp -q + pip install -e examples/tutorial -q # pytest-cov doesn't seem to play nice with -p coverage run -p -m pytest tests examples