Python Flask框架(进阶1)
项目布局
创建一个目录。
简单的flask只有一个文件。
然而,随着项目变得越来越大,将所有代码保存在一个文件中就变得非常困难。Python项目使用包将代码组织成多个模块,这些模块可以在需要时导入。
项目目录将包含:
- flaskr/, 一个包含应用程序代码和文件的python包。
- tests/, 一个包含测试模块的目录。
- venv/,一个python虚拟环境,其中安装了flask和其他以来项目。
- 告诉Python如何安装项目的安装文件。
- 版本控制配置,如git。您应该养成为所有项目使用某种类型的版本控制的习惯,无论大小如何。
- 您将来可能添加的任何其他项目文件。
最后,你的项目看起来就像这样:
/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
如果使用版本控制,则应忽略在运行项目时生成的下列文件。根据您使用的编辑器,可能还有其他文件。一般来说,忽略您没有编写的文件。例如,使用git:
venv/
*.pyc
__pycache__/
instance/
.pytest_cache/
.coverage
htmlcov/
dist/
build/
*.egg-info/
安装应用
Flask应用程序是Flask类的一个实例。关于应用程序的所有内容,如配置和url,都将用这个类注册。
创建Flask应用程序最直接的方法是在代码的顶部直接创建一个全局Flask实例,比如“Hello, World!”例子在前一页做过。虽然这在某些情况下是简单和有用的,但随着项目的增长,它可能会导致一些棘手的问题。
您将在函数中创建一个Flaks实例,而不是全局创建一个Flask实例。这个函数称为应用程序工厂。应用程序需要的任何配置、注册和其他设置都将在函数中进行,然后返回应用程序。
应用工厂
创建flaskr目录并添加_init_ .py文件。py有双重功能:它将包含应用程序工厂,并告诉Python应该将flaskr目录视为一个包。
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 是应用工厂函数。
- app = Flask(__name__, instance_relative_config=True) creates the Flask instance.
- __name__是当前Python模块的名称。应用程序需要知道它位于何处来设置一些路径,而用__name__来告诉它这一点很方便。
- instance_relative_config=True告诉应用程序配置文件相对于实例文件夹。实例文件夹位于flaskr包之外,可以保存不应该提交到版本控制的本地数据,比如配置机密和数据库文件。
- from_mapping()设置应用程序将使用的一些默认配置:
- flask和扩展程序使用SECRET_KEY来保证数据安全。它被设置为’dev’,以便在开发期间提供一个方便的值,但是在部署时应该用一个随机值覆盖它。
- DATABASE是存储SQLite数据库文件的路径。它在app.instance_path下,这是flask为实例文件夹选择的路径。
- from_pyfile()用实例文件夹中的config.py文件(如果存在的话)中的值覆盖默认配置。例如,在部署时,可以使用它设置一个真正的SECRET_KEY。
- test_config也可以传递给工厂,并将代替实例配置使用。这样,您将在后面编写的测试就可以独立于您所配置的任何开发值进行配置。
- makedirs()确保app.instance_path存在。flask不会自动创建实例文件夹,但是需要创建它,因为您的项目将在那里创建SQLite数据库文件。
- @app.route()创建了一个简单的路由,因此在学习本教程的其余部分之前,您可以看到应用程序正在工作。它在URL /hello和一个返回响应的函数(字符串“hello, World!“在这种情况下。
配置环境变量/运行
$env:FLASK_APP = "flaskr"
$env:FLASK_ENV = "development"
flask run
定义使用数据库
应用程序将使用SQLite数据库存储用户和推送。Python在sqlite3模块中提供了对SQLite的内置支持。
SQLite很方便,因为它不需要设置单独的数据库服务器,而且是Python内置的。但是,如果并发请求试图同时写入数据库,则每次写入都会按顺序进行,从而降低速度。小型应用程序不会注意到这一点。一旦你变大了,你可能想要切换到一个不同的数据库。
本教程没有详细介绍SQL。如果您不熟悉它,SQLite文档描述了这种语言。
链接到数据库
当使用SQLite数据库(以及大多数其他Python数据库库)时,要做的第一件事是创建到它的连接。使用连接执行任何查询和操作,连接在工作完成后关闭。
在web应用程序中,这种连接通常与请求绑定。它是在处理请求时创建的,并在发送响应之前关闭。
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()
g是一个特殊的对象,对于每个请求都是惟一的。它用于存储请求期间多个函数可能访问的数据。如果在同一个请求中第二次调用get_db,则存储和重用连接,而不是创建新的连接。
current_app是另一个特殊的对象,**它指向处理请求的Flask应用程序。**由于使用了应用程序工厂,所以在编写其余代码时没有应用程序对象。get_db将在创建应用程序并处理请求时调用,因此可以使用current_app。
sqlite3.connect()建立到数据库配置键指向的文件的连接。这个文件还不需要存在,并且在稍后初始化数据库之前不会存在。
sqlite3.Row告诉连接返回的行,行为像字典一样,这允许按名称访问列。
close_db通过检查g.db是否设置好来检查是否创建了连接。如果连接存在,则关闭连接。接下来,您将告诉应用程序关于应用程序工厂中的close_db函数的信息,以便在每次请求之后调用它。
创建表格
在SQLite中,数据存储在表和列中。在存储和检索数据之前,需要先创建这些属性。Flaskr将用户存储在用户表中,post存储在post表中。用创建空表所需的SQL命令创建一个文件:
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)
);
使用db.py去运行上面的SQL命令。
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.')
**open_resource()**打开一个相对于flaskr包的文件,这很有用,因为在稍后部署应用程序时,您不一定知道该位置在哪里。get_db返回一个数据库连接,用于执行从文件中读取的命令。
**command()**定义一个名为init-db的命令行命令,该命令调用init_db函数并向用户显示一条成功消息。您可以阅读命令行界面来了解有关编写命令的更多信息。
注册我们的应用
close_db和init_db_command函数需要在应用程序实例中注册,否则应用程序不会使用它们。但是,由于使用的是工厂函数,所以在编写函数时不能使用该实例。相反,编写一个接受应用程序并进行注册的函数。
flaskr/db.py
def init_app(app):
app.teardown_appcontext(close_db)
app.cli.add_command(init_db_command)
teardown_appcontext()告诉Flask在返回响应后进行清理时调用该函数。
add_command()添加了一个可以用flask命令调用的新命令。
从工厂导入并调用这个函数。在返回应用程序之前,将新代码放在工厂函数的末尾。
flaskr/init.py
def create_app():
app = ...
# existing code omitted
from . import db
db.init_app(app)
return app
初始化数据库文件
既然init-db已经在应用程序中注册,就可以使用flask命令调用它,类似于前一页中的run命令。
注意
如果您仍然从上一页运行服务器,则可以停止服务器,或者在新终端中运行此命令。如果使用新终端,请记住切换到项目目录并**env,如activate the environment中所述。您还需要设置FLASK_APP和FLASK_ENV,如前一页所示。
运行init-db命令:
flask init-db
Initialized the database.
现在将会有一个flaskr。项目实例文件夹中的sqlite文件。
蓝图和视图
视图函数是用来响应应用程序请求的代码。Flask使用模式将传入的请求URL匹配到应该处理它的视图。视图返回Flask转换为输出响应的数据。Flask还可以采取另一种方法,根据视图的名称和参数生成视图的URL。
创建一个蓝图
蓝图是组织一组相关视图和其他代码的方法。视图和其他代码不是直接注册到应用程序中,而是注册到蓝图中。然后,当蓝图在工厂函数中可用时,将其注册到应用程序中。
Flaskr将有两个蓝图,一个用于身份验证功能,另一个用于博客文章功能。每个蓝图的代码将放在一个单独的模块中。由于博客需要了解身份验证,您将首先编写身份验证。
flaskr/auth.py:
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')
这将创建一个名为“auth”的蓝图。与应用程序对象一样,蓝图需要知道它在何处定义,因此将作为第二个参数传递给_name__。url_prefix将前缀到与蓝图关联的所有url。
使用app.register_blueprint()从工厂导入并注册蓝图。在返回应用程序之前,将新代码放在工厂函数的末尾。
flaskr/init.py:
def create_app():
app = ...
# existing code omitted
from . import auth
app.register_blueprint(auth.bp)
return app
身份验证蓝图将具有注册新用户、登录和注销的视图。
第一个视图:注册器
当用户访问/auth/register URL时,register视图将返回HTML和一个表单,供用户填写。当他们提交表单时,它将验证他们的输入,或者再次显示带有错误消息的表单,或者创建新用户并转到登录页面。
现在只需要编写视图代码。在后面,您将编写模板来生成HTML表单。
flaskr/auth.py:
@bp.route('/register', methods=('GET', 'POST'))
def register():
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:
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将URL /register与register视图函数关联起来。当Flask接收到/auth/register的请求时,它将调用register视图并使用返回值作为响应。
- 如果用户提交了表单,则请求。方法为“POST”。在本例中,开始验证输入。
- request.form是提交的表单键和值的一种特殊类型的dict映射。用户将输入他们的用户名和密码。
- 验证用户名和密码是否为空。
- 通过查询数据库并检查是否返回结果来验证用户名尚未注册。db。执行需要一个SQL查询**?任何用户输入的占位符**,以及用于替换占位符的值元组。数据库库将负责转义这些值,这样您就不会受到SQL注入攻击的攻击。
fetchone()从查询中返回一行。如果查询没有返回任何结果,则返回None。稍后,使用fetchall(),它返回所有结果的列表。 - 如果验证成功,则将新用户数据插入数据库。为了安全起见,密码不应该直接存储在数据库中。**相反,generate_password_hash()用于安全地对密码进行散列,并存储该散列。**由于此查询修改数据,因此需要在稍后调用db.commit()来保存更改。
- 在存储用户之后,它们被重定向到登录页面。url_for()根据login视图的名称为其生成URL。这比直接编写URL更好,因为它允许您稍后更改URL,而无需更改所有链接到URL的代码。redirect()生成对生成URL的重定向响应。
- 如果验证失败,将向用户显示错误。flash()存储可以在呈现模板时检索的消息。??
- 当用户最初导航到auth/register时,或者出现验证错误时,应该显示带有注册表单的HTML页面。render_template()将呈现一个包含HTML的模板,您将在本教程的下一步中编写该模板。
登陆
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')
与注册表的观点有一些不同之处:
- 首先查询用户,并将其存储在变量中供以后使用。
- check_password_hash()以与存储的散列相同的方式散列提交的密码,并对它们进行安全比较。如果匹配,则密码有效。
- session是一个dict,**它跨请求存储数据。**当验证成功时,用户的id将存储在一个新会话中。数据存储在发送到浏览器的cookie中,然后浏览器将其与后续请求一起发回。Flask对数据进行了安全的签名,这样就不会被篡改了。
既然用户的id存储在会话中,**它将在后续请求中可用。**在每个请求开始时,如果用户已登录,则应加载其信息并将其提供给其他视图。
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()
before_app_request()注册了一个在视图函数之前运行的函数,无论请求的URL是什么。
load_logged_in_user检查会话中是否存储了用户id,并从数据库中获取该用户的数据,并将其存储在g上。用户,它持续到请求的长度。如果没有用户id,或者该id不存在,则g。用户将为None。
注销
要注销,您需要从会话中删除用户id。然后load_logged_in_user不会在后续请求上加载用户。
flaskr/auth.py:
@bp.route('/logout')
def logout():
session.clear()
return redirect(url_for('index'))
要求在其他视图中进行身份验证
创建、编辑和删除博客文章需要用户登录。可以使用装饰器对它应用到的每个视图进行检查。
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
这个装饰器返回一个新的视图函数,该函数包装应用于其上的原始视图。新函数检查是否加载了用户,否则将重定向到登录页面。如果加载了用户,则调用原始视图并正常继续。在编写博客视图时,您将使用这个装饰器。
端点和url
url_for() 函数的作用是:根据视图的名称和参数生成视图的URL。与视图关联的名称也称为端点,默认情况下它与视图函数的名称相同。
例如,本教程前面添加到应用程序工厂的hello()视图的名称为“hello”,可以用url_for(“hello”)链接到它。如果它接受一个参数(稍后您将看到),那么它将被链接到使用url_for(‘hello’, who=‘World’)。
当使用blueprint时,blueprint的名称被前缀为函数的名称,因此上面所写的登录函数的端点是’auth '。因为您将它添加到“auth”蓝图中。
模板
您已经为您的应用程序编写了身份验证视图,但是如果您正在运行服务器并尝试访问任何url,您将看到TemplateNotFound错误。这是因为视图调用render_template(),但是您还没有编写模板。模板文件将存储在flaskr包中的templates目录中。
模板是包含静态数据和动态数据占位符的文件。使用特定数据呈现模板以生成最终文档。Flask使用Jinja模板库来呈现模板。
在您的应用程序中,您将使用模板来呈现将在用户浏览器中显示的HTML。在Flask中,Jinja被配置为自动转义HTML模板中呈现的任何数据。这意味着呈现用户输入是安全的;他们输入的任何字符,如<和>,都将转义为安全值,这些值在浏览器中看起来相同,但不会造成不必要的影响。
Jinja的外观和行为都很像Python。使用特殊的分隔符将Jinja语法与模板中的静态数据区分开来。{{和}}之间的任何内容都是将输出到最终文档的表达式。{%和%}表示类似if和for的控制流语句。 与Python不同,块由开始和结束标记表示,而不是缩进,因为块内的静态文本可以更改缩进。
基本布局
应用程序中的每个页面在不同的主体周围都具有相同的基本布局。每个模板将扩展一个基本模板并覆盖特定的部分,而不是在每个模板中编写整个HTML结构。
flaskr/templates/base.html:
<!doctype html>
<title>{% block title %}{% endblock %} - Flaskr</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<nav>
<h1>Flaskr</h1>
<ul>
{% if g.user %}
<li><span>{{ g.user['username'] }}</span>
<li><a href="{{ url_for('auth.logout') }}">Log Out</a>
{% else %}
<li><a href="{{ url_for('auth.register') }}">Register</a>
<li><a href="{{ url_for('auth.login') }}">Log In</a>
{% endif %}
</ul>
</nav>
<section class="content">
<header>
{% block header %}{% endblock %}
</header>
{% for message in get_flashed_messages() %}
<div class="flash">{{ message }}</div>
{% endfor %}
{% block content %}{% endblock %}
</section>
g在模板中自动可用。基于if g.user 已经设置(来自load_logged_in_user),将显示用户名和注销链接,否则将显示注册和登录链接。url_for()也是自动可用的,用于生成视图的url,而不是手动将它们写出来。
在页面标题之后和内容之前,模板循环遍历get_flashed_messages()返回的每个消息。您在视图中使用flash()来显示错误消息,下面的代码将显示它们。
这里定义了三个块,将在其他模板中被覆盖:
- {% block title %}将更改浏览器选项卡和窗口标题中显示的标题。
- {% block header %}与title类似,但将更改页面上显示的标题。
- {% block content %}是每个页面的内容所在,例如登录表单或博客文章。
基本模板直接位于templates目录中。为了保持其他模板的组织,蓝图的模板将被放置在与蓝图同名的目录中。
注册
flaskr/templates/auth/register.html
{% extends 'base.html' %}
{% block header %}
<h1>{% block title %}Register{% endblock %}</h1>
{% endblock %}
{% block content %}
<form method="post">
<label for="username">Username</label>
<input name="username" id="username" required>
<label for="password">Password</label>
<input type="password" name="password" id="password" required>
<input type="submit" value="Register">
</form>
{% endblock %}
{% extends ‘base.html’ %} **告诉Jinja这个模板应该替换基模板中的块。所有呈现的内容都必须出现在{% block %}标记中,**这些标记覆盖基模板中的块。
这里使用的一个有用模式是将**{% block title %}放在{% block header %}**中。这将设置标题块,然后将其值输出到标题块中,这样窗口和页面就可以共享相同的标题,而不需要编写两次标题。
这里input标记使用了required属性。这告诉浏览器**在填写这些字段之前不要提交表单。**如果用户使用的是不支持该属性的旧浏览器,或者他们使用的是浏览器之外的东西来发出请求,那么您仍然希望验证Flask视图中的数据。始终在服务器上完全验证数据是很重要的,即使客户机也执行一些验证。
登陆
这与注册模板相同,除了标题和提交按钮。
flaskr/templates/auth/login.html
{% extends 'base.html' %}
{% block header %}
<h1>{% block title %}Log In{% endblock %}</h1>
{% endblock %}
{% block content %}
<form method="post">
<label for="username">Username</label>
<input name="username" id="username" required>
<label for="password">Password</label>
<input type="password" name="password" id="password" required>
<input type="submit" value="Log In">
</form>
{% endblock %}
注册一个用户
现在已经编写了身份验证模板,可以注册用户了。确保服务器仍在运行(如果没有运行烧瓶,则运行烧瓶),然后转到http://127.0.0.1:5000/auth/register。
尝试在不填写表单的情况下单击“Register”按钮,可以看到浏览器显示一条错误消息。尝试从Register .html模板中删除所需的属性,然后再次单击“Register”。与浏览器显示错误不同,页面将重新加载,视图中的flash()将显示错误。
填写用户名和密码,您将被重定向到登录页面。尝试输入不正确的用户名,或正确的用户名和密码。如果你登录,你会得到一个错误,因为没有索引视图重定向到。
静态文件
身份验证视图和模板可以工作,但是它们现在看起来非常简单。可以添加一些CSS来为构建的HTML布局添加样式。样式不会改变,所以它是一个静态文件而不是模板。
Flask自动添加一个静态视图,该视图采用相对于flaskr/static目录的路径并提供服务。html模板已经有一个指向style.css文件的链接:
{{ url_for(‘static’, filename=‘style.css’) }}
除了CSS之外,其他类型的静态文件可能是带有JavaScript函数的文件或徽标图像。它们都放在flaskr/static目录下,并用url_for(‘static’, filename=’…’)引用。
本教程不关注如何编写CSS,所以您可以将以下内容复制到flaskr/static/style.css文件中:
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; }
博客的蓝图
您将使用在编写身份验证蓝图时学到的相同技术来编写博客蓝图。博客应该列出所有的文章,允许登录的用户创建文章,并允许文章的作者编辑或删除它。
在实现每个视图时,保持开发服务器运行。保存更改时,请尝试到浏览器中的URL并进行测试。
蓝图
定义蓝图并将其注册到应用程序工厂。
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__)
使用app.register_blueprint()从工厂导入并注册蓝图。在返回应用程序之前,将新代码放在工厂函数的末尾。
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
与auth blueprint不同,blog blueprint没有url_prefix。index视图在/,create视图在/create,依此类推。博客是Flaskr的主要特性,所以博客索引将是主要索引是有意义的。
但是,下面定义的index视图的端点将是blog.index。一些身份验证视图引用普通索引端点。add_url_rule()将端点名称’index’与/ url关联起来,这样url_for(‘index’)或url_for(‘blog.index’)都可以工作,以任何一种方式生成相同的/ url。
在另一个应用程序中,您可以给blog blueprint一个url_prefix,并在应用程序工厂中定义一个单独的索引视图,类似于hello视图。然后是索引和博客。索引端点和url将不同。
索引
该索引将显示所有的帖子,最近的第一个。使用联接,以便在结果中可以使用用户表中的作者信息。
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)
flaskr/templates/blog/index.html
{% extends 'base.html' %}
{% block header %}
<h1>{% block title %}Posts{% endblock %}</h1>
{% if g.user %}
<a class="action" href="{{ url_for('blog.create') }}">New</a>
{% endif %}
{% endblock %}
{% block content %}
{% for post in posts %}
<article class="post">
<header>
<div>
<h1>{{ post['title'] }}</h1>
<div class="about">by {{ post['username'] }} on {{ post['created'].strftime('%Y-%m-%d') }}</div>
</div>
{% if g.user['id'] == post['author_id'] %}
<a class="action" href="{{ url_for('blog.update', id=post['id']) }}">Edit</a>
{% endif %}
</header>
<p class="body">{{ post['body'] }}</p>
</article>
{% if not loop.last %}
<hr>
{% endif %}
{% endfor %}
{% endblock %}
当用户登录时,头信息块向create视图添加一个链接。当用户是一篇文章的作者时,他们将看到该文章的update视图的“Edit”链接。循环。最后是Jinja for循环中可用的一个特殊变量。它用于在每篇文章之后(最后一篇除外)显示一行,以便在视觉上分隔它们。
创建
create视图的工作原理与auth寄存器视图相同。要么显示表单,要么验证已发布的数据并将其添加到数据库,要么显示错误。
前面编写的login_required装饰器用于博客视图。用户必须登录才能访问这些视图,否则将重定向到登录页面。
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')
flaskr/templates/blog/create.html
{% extends 'base.html' %}
{% block header %}
<h1>{% block title %}New Post{% endblock %}</h1>
{% endblock %}
{% block content %}
<form method="post">
<label for="title">Title</label>
<input name="title" id="title" value="{{ request.form['title'] }}" required>
<label for="body">Body</label>
<textarea name="body" id="body">{{ request.form['body'] }}</textarea>
<input type="submit" value="Save">
</form>
{% endblock %}
更新
update和delete视图都需要根据id获取一个post,并检查作者是否匹配已登录的用户。为了避免重复代码,您可以编写一个函数来获取post并从每个视图调用它。
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
abort()将引发一个返回HTTP状态代码的特殊异常。它使用一个可选的消息来显示错误,否则将使用默认消息。404表示“未找到”,403表示“禁止”。(401表示“未经授权”,但您将重定向到登录页面,而不是返回该状态。)
定义了check_author参数,这样函数就可以在不检查作者的情况下获取文章。如果您编写了一个视图来显示页面上的单个帖子,而用户并不在意,因为他们没有修改帖子,那么这将非常有用。
flaskr/blog.py
@bp.route('/<int:id>/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)
flaskr/templates/blog/update.html
{% extends 'base.html' %}
{% block header %}
<h1>{% block title %}Edit "{{ post['title'] }}"{% endblock %}</h1>
{% endblock %}
{% block content %}
<form method="post">
<label for="title">Title</label>
<input name="title" id="title"
value="{{ request.form['title'] or post['title'] }}" required>
<label for="body">Body</label>
<textarea name="body" id="body">{{ request.form['body'] or post['body'] }}</textarea>
<input type="submit" value="Save">
</form>
<hr>
<form action="{{ url_for('blog.delete', id=post['id']) }}" method="post">
<input class="danger" type="submit" value="Delete" onclick="return confirm('Are you sure?');">
</form>
{% endblock %}
这个模板有两个表单。第一个将编辑后的数据发布到当前页面(//update)。
另一个表单只包含一个按钮,并指定一个action属性,该属性将发布到delete视图。该按钮使用一些JavaScript在提交之前显示一个确认对话框。
模式{{request.form[‘title’]或post[‘title’]}}用于选择表单中出现的数据。当表单尚未提交时,将显示原始的post数据,但如果发布了无效的表单数据,则希望显示该数据,以便用户可以修复错误,因此请求。而是使用form.request是模板中自动可用的另一个变量。
删除
delete视图没有自己的模板,delete按钮是update.html的一部分,并发送到//delete URL。由于没有模板,它只处理POST方法,然后重定向到index视图。
flaskr/blog.py
@bp.route('/<int:id>/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'))
使你的项目可安装
使您的项目可安装意味着您可以构建一个发行版文件并将其安装到另一个环境中,就像您在您的项目环境中安装了Flask一样。这使得部署项目与安装任何其他库一样,所以您使用所有标准的Python工具来管理一切。
安装还带来了其他一些好处,这些好处可能在本教程或作为一个新的Python用户中并不明显,包括:
- 目前,Python和Flask只知道如何使用flaskr包,因为您是从您的项目目录运行- 的。安装意味着无论您从何处运行,都可以导入它。
- 您可以像其他包一样管理项目的依赖项,因此pip安装您的项目。whl安装它们。
- 测试工具可以将测试环境与开发环境隔离开来。
请注意
这是在本教程后面介绍的,但是在您未来的项目中,您应该总是从这个开始。
描述项目
steup.py文件描述了您的项目及其所属的文件。
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告诉Python要包含哪些包目录(以及它们包含的Python文件)。****find_packages()会自动找到这些目录,所以您不必将它们键入。要包含其他文件,比如静态目录和模板目录,include_package_data被设置。Python需要另一个名为MANIFEST.in的文件。告诉我们其他的数据是什么。
include flaskr/schema.sql
graft flaskr/static
graft flaskr/templates
global-exclude *.pyc
这告诉Python复制static和templates目录以及schema.sql文件,但要排除所有字节码文件。
有关使用的文件和选项的另一种解释,请参阅官方打包指南。
安装项目
使用pip在虚拟环境中安装项目。
pip install -e .
这告诉pip在当前目录中找到setup.py,并以可编辑或开发模式安装它。可编辑模式意味着,当您对本地代码进行更改时,只需要在更改有关项目的元数据(如依赖项)时重新安装。
您可以观察到该项目现在已经安装了pip list。
列举出项目已经安装 的包。
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
到目前为止,您运行项目的方式没有任何变化。FLASK_APP仍然设置为flaskr, flask run仍然运行应用程序。
测试覆盖
为您的应用程序编写单元测试允许您检查您所编写的代码是否按照您所期望的方式工作。Flask提供了一个测试客户机,它模拟对应用程序的请求并返回响应数据。
您应该测试尽可能多的代码。函数中的代码只在调用函数时运行,分支中的代码(例如if块)只在满足条件时运行。您希望确保使用覆盖每个分支的数据测试每个函数。
您越接近100%的覆盖率,您就会越放心地认为进行更改不会意外地更改其他行为。然而,100%的覆盖率并不能保证您的应用程序没有bug。特别是,它没有测试用户如何在浏览器中与应用程序交互。尽管如此,测试覆盖仍然是开发过程中使用的一个重要工具。
请注意
这将在本教程的后面介绍,但是在您未来的项目中,您应该在开发时进行测试。
您将使用pytest和coverage来测试和度量代码。安装它们:
pip install pytest coverage
安装和设置
测试代码位于tests目录中。这个目录位于flaskr包的旁边,而不是它的内部。tests/conftest.py 文件包含每个测试将使用的名为fixture的设置函数。测试在以**test_开头的Python模块中,这些模块中的每个测试函数也都以test_**开头。
每个测试将创建一个新的临时数据库文件,并填充一些将在测试中使用的数据。编写一个SQL文件来插入该数据。
测试数据:
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')
app固定将调用工厂函数并通过test_config来配置用于测试的应用程序和数据库,而不是使用本地开发配置。
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()
**tempfile.mkstemp()**创建并打开一个临时文件,返回文件对象及其路径。
DATABASE路径被覆盖,因此它指向这个临时路径而不是实例文件夹。设置路径之后,创建数据库表并插入测试数据。测试结束后,关闭并删除临时文件。
TESTING 告诉Flask该应用程序处于测试模式。Flask改变了一些内部行为,因此更容易测试,其他扩展也可以使用这个标志使测试更容易。
clinet fixture使用app fixture创建的应用程序对象调用app.test_client()。测试将使用客户机在不运行服务器的情况下向应用程序发出请求。
runner配置类似于client. app.test_cli_runner() 创建一个运行器,它可以调用应用程序注册的单击命令。
Pytest通过将它们的函数名与测试函数中的参数名匹配来使用fixture。例如,接下来要编写的test_hello函数接受一个客户机参数。Pytest将其与客户机fixture函数匹配,调用它,并将返回的值传递给测试函数。
工厂
关于工厂本身没有太多需要测试的地方。大多数代码已经为每个测试执行了,所以如果某些东西失败了,其他测试将会注意到。
唯一可以更改的行为是通过测试配置。如果没有传递配置,应该有一些默认配置,否则应该重写配置。
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!'
在本教程开头编写工厂时,您添加了hello路由作为示例。它返回“Hello, World!”,因此测试检查响应数据是否匹配。
数据库
在应用程序上下文中,每次调用get_db都应该返回相同的连接。在上下文之后,连接应该关闭。
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)
init-db命令应该调用init_db函数并输出一条消息。
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
这个测试使用Pytest的monkeypatch fixture将init_db函数替换为一个记录它被调用的函数。上面编写的runner fixture用于按名称调用init-db命令。
授权
对于大多数视图,用户都需要登录。在测试中,最简单的方法是向login视图发出POST请求。您可以编写一个带方法的类来实现这一点,而不是每次都把它写出来,并使用一个fixture将它传递给每个测试的客户端。
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)
使用auth fixture,您可以在测试中调用auth.login()作为测试用户登录,这是作为测试数据的一部分插入到app fixture中的。
register视图应该在GET上成功呈现。在提交有效表单数据时,它应该重定向到登录URL,用户的数据应该在数据库中。无效数据应显示错误消息。
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
client.get() 发出GET请求并返回Flask返回的响应对象。类似地,client.post()发出POST请求,将数据dict转换为表单数据。
要测试页面是否成功呈现,需要发出一个简单的请求,并检查一个200 OK status_code。如果呈现失败,Flask将返回500 内部服务器错误代码。
当register视图重定向到login视图时,headers 将具有带有登录URL的位置头文件。
data以字节的形式包含响应体。如果希望在页面上呈现某个值,请检查它是否
data中。字节必须与字节进行比较。如果想比较Unicode文本,可以使用get_data(as_text=True)。
pytest.mark.parametrize 告诉Pytest用不同的参数运行相同的测试函数。您可以在这里使用它来测试不同的无效输入和错误消息,而不需要编写相同的代码三次。
login视图的测试与register视图的测试非常相似。session应该在登录后设置user_id,而不是测试数据库中的数据。
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
在with块中使用client允许在返回响应后访问上下文变量,比如session。通常,在请求之外访问会话会引发错误。
测试注销与登录是相反的。退出后会话不应该包含user_id。
tests/test_auth.py
def test_logout(client, auth):
auth.login()
with client:
auth.logout()
assert 'user_id' not in session
博客
所有博客视图都使用前面编写的auth fixture。调用auth.login(),来自客户机的后续请求将作为test用户登录。
index视图应该显示与测试数据一起添加的帖子的信息。当以作者身份登录时,应该有一个编辑文章的链接。
您还可以在测试index视图时测试更多的身份验证行为。未登录时,每个页面显示登录或注册的链接。当登录时,有一个链接可以注销。
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
用户必须登录才能访问创建、更新和删除视图。登录用户必须是文章的作者才能访问更新和删除,否则将返回403禁止状态。如果不存在具有给定id的帖子,update和delete应该返回404 Not Found。
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
create和update视图应该呈现GET请求并返回一个200 OK状态。当在POST请求中发送有效数据时,create应该将新的POST数据插入数据库,update应该修改现有数据。两个页面都应该显示关于无效数据的错误消息。
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
delete视图应该重定向到索引URL,并且post应该不再存在于数据库中。
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
运行测试
可以将一些额外的配置添加到项目的setup.cfg文件中,这不是必需的,但是可以使运行测试的覆盖率更少。
setup.cfg
[tool:pytest]
testpaths = tests
[coverage:run]
branch = True
source =
flaskr
要运行测试,使用pytest命令。它将找到并运行您编写的所有测试函数。
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 =======================
如果任何测试失败,pytest将显示所引发的错误。您可以运行pytest -v来获得每个测试函数的列表,而不是点。
要度量测试的代码覆盖率,可以使用覆盖率命令运行pytest,而不是直接运行它。
coverage run -m pytest
你可以在终端机内浏览简单的覆盖报告:
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%
HTML报告允许您查看每个文件中覆盖了哪些行:
coverage html
这将生成htmlcov目录中的文件。在浏览器中打开htmlcov/index.html查看报告。