برنامه‌نویسی پایتون فهرست مطالب
فصل ۳۸

وب با Flask

یک وب‌سایت می‌نویسیم: آدرس‌ها، قالب‌های HTML، فرم‌ها، ورود کاربر، یک وب‌سرویس JSON، و یک پایگاه داده پشت همه این موارد. هر چه در این دوره خوانده‌اید اینجا یکجا برمی‌گردد: تابع، دیکشنری، کلاس، دکوراتور، SQLAlchemy و JSON.

در این فصل می‌خوانید
  1. وب چطور کار می‌کند
  2. اولین سایت
  3. آدرس متغیر، url_for و ۴۰۴
  4. قالب‌های Jinja2
  5. ارث‌بری قالب و فایل ایستا
  6. فرم‌ها و قاعده PRG
  7. نشست و کوکی
  8. رمز عبور و login_required
  9. وب‌سرویس JSON
  10. مصرف‌کننده API
  11. Flask-SQLAlchemy
  12. پروژه: دفترچه یادداشت

۱. وب چطور کار می‌کند

  1. مرورگر یک درخواست می‌فرستد: یک روش (GET، POST و مانند آن) و یک آدرس.
  2. برنامه شما تابع نمایی را انتخاب می‌کند که مسیرش با آن آدرس جور است.
  3. تابع یک پاسخ برمی‌گرداند: مقداری HTML یا JSON، به‌علاوه یک کد وضعیت مثل ۲۰۰ یا ۴۰۴ یا ۵۰۰.
  4. مرورگر آن را می‌کشد.
  5. و بعد همه چیز را فراموش می‌کند. HTTP حافظه ندارد، و نشست و کوکی دقیقاً برای همین وجود دارند.
python -m venv .venv
.venv\Scripts\activate            # Windows
pip install -r requirements.txt

۲. اولین سایت

from flask import Flask


# `__name__` tells Flask where this file is, so it can find `templates/`
# and `static/` next to it.
app = Flask(__name__)


@app.route('/')
def home() -> str:
    '''The home page. The name of the function is up to you.'''
    return '<h1>Hello!</h1><p>This is my first Flask program.</p>'


@app.route('/about')
def about() -> str:
    return ('<h1>Kadoos Institute</h1>'
            '<p>Python course, Rasht.</p>')


# One function can answer several addresses.
@app.route('/contact')
@app.route('/contact-us')
def contact() -> str:
    return '<h1>Contact</h1><p>Come and see us in Rasht.</p>'


if __name__ == '__main__':
    # host='127.0.0.1' = only this computer can open it.
    # host='0.0.0.0' would let the whole classroom network in.
    app.run(debug=True)

کوچک‌ترین سایت دنیا: نمونه 01_hello_flask/app.py

python app.py
# then open http://127.0.0.1:5000

شرح مثال

debug=True هرگز روی سرور واقعی

حالت اشکال‌زدایی دو چیز عالی به شما می‌دهد: سرور با هر بار ذخیره فایل خودش را دوباره راه می‌اندازد، و خطا به جای پیام خشک Internal Server Error با ردیابی کامل نشان داده می‌شود. ولی همان صفحه، به هر بازدیدکننده‌ای یک کنسول پایتون داخل برنامه شما می‌دهد. روی سرور واقعی، هرگز.

۳. آدرس متغیر، url_for و ۴۰۴

@app.route('/user/<username>')
def profile(username: str) -> str:
    '''A missing user is a 404, not a crash.'''
    if username not in USERS:
        abort(404)
    return f'<h1>{USERS[username]}</h1><p>username: {username}</p>'


@app.route('/post/<int:post_id>')
def post(post_id: int) -> str:
    '''`<int:...>`: post_id really is an int here.'''
    title = POSTS.get(post_id)
    if title is None:
        abort(404)
    # post_id is a real int, so arithmetic works without any conversion.
    return (f'<h1>{title}</h1>'
            f'<p>post {post_id}; the next one would be {post_id + 1}</p>')


@app.route('/old-home')
def old_home():
    '''A page that moved: send the browser to the new address.'''
    return redirect(url_for('home'))


@app.errorhandler(404)
def not_found(error):
    '''Our own 404 page. The second value is the HTTP STATUS CODE.'''
    return ('<h1>404 - not found</h1>'
            f'<p><a href="{url_for("home")}">back home</a></p>'), 404

مسیرهای متغیر: نمونه 02_routes_and_variables/app.py

مبدلمی‌پذیرد
string (پیش‌فرض)هر متنی بدون اسلش
intعدد صحیح، و واقعاً int تحویل می‌دهد
floatعدد اعشاری
pathمتن همراه با اسلش
uuidشناسه یکتای جهانی

شرح مثال

۴. قالب‌های Jinja2

برگرداندن HTML از داخل رشته پایتون، بعد از سه خط از دست می‌رود. Flask از Jinja2 استفاده می‌کند: یک فایل HTML که در آن سوراخ‌هایی هست. قالب‌ها در پوشه‌ای به نام templates/ کنار app.py می‌نشینند و این نام ثابت است.

@app.route('/students')
def students():
    return render_template('students.html', students=STUDENTS,
                           passing_mark=10)


@app.route('/empty')
def empty():
    '''The same template with an empty list: see the {% else %} of the loop.'''
    return render_template('students.html', students=[], passing_mark=10)

تابع نما: نمونه 03_templates_jinja/app.py

<table border="1" cellpadding="6">
    <tr><th>#</th><th>Name</th><th>City</th><th>Score</th><th></th></tr>

    {# `loop.index` counts from 1; `students` is the list we passed in #}
    {% for student in students %}
        <tr>
            <td>{{ loop.index }}</td>
            <td>{{ student.name | title }}</td>
            <td>{{ student.city }}</td>
            <td>{{ student.score }}</td>
            <td>
                {% if student.score >= passing_mark %}
                    passed
                {% else %}
                    failed
                {% endif %}
            </td>
        </tr>
    {% else %}
        {# runs when the list is EMPTY -- no `if` needed around the loop #}
        <tr><td colspan="5">Nobody has registered yet.</td></tr>
    {% endfor %}
</table>

قالب: templates/students.html

نحو Jinjaوظیفه آن
{{ value }}چاپ یک مقدار
{% if %} ... {% endif %}شرط
{% for x in items %}حلقه، با {% else %} برای حالت خالی
{{ value | upper }}فیلتر
{# ... #}توضیح Jinja، که به HTML نمی‌رسد
loop.indexشماره تکرار، از یک

فیلترهایی که زود به کارتان می‌آیند: length، upper، title، round، default('-')، join(', ') و tojson.

گریز خودکار، و حفره XSS

Jinja به طور خودکار محتوای متغیرها را بی‌خطر می‌کند: یک script که کاربر تایپ کرده، به متن ساده تبدیل می‌شود و اجرا نمی‌شود. اگر این محافظ را با فیلتر safe روی داده‌ای که کاربر فرستاده خاموش کنید، در همین مرحله یک حفره XSS ساخته‌اید. قاعده ساده است: هرگز safe را روی داده‌ای که از کاربر آمده نگذارید.

۵. ارث‌بری قالب و فایل ایستا

نوشتن سرصفحه و منو و پاصفحه در ده فایل یعنی ده جا برای تغییر دادن. راه درست، یک قالب پایه است که بقیه از آن ارث می‌برند.

<head>
    <meta charset="UTF-8">
    {# A block with a DEFAULT value: a child page may override the title. #}
    <title>{% block title %}{{ site_name }}{% endblock %}</title>

    {# url_for('static', ...) -- never write the path by hand. #}
    <link rel="stylesheet"
          href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
    <header>
        <h1>{{ site_name }}</h1>
        <nav>
            <a href="{{ url_for('home') }}">Home</a>
            <a href="{{ url_for('courses') }}">Courses</a>
        </nav>
    </header>

    <main>
        {# The hole every child page fills. #}
        {% block content %}{% endblock %}
    </main>
</body>

قالب پایه: نمونه 04_inheritance_and_static/templates/base.html

شرح مثال

۶. فرم‌ها و قاعده PRG

def validate(form) -> tuple[dict, list[str]]:
    '''Clean the fields and collect every problem, not just the first one.'''
    data = {
        'name': form.get('name', '').strip(),
        'email': form.get('email', '').strip(),
        'course': form.get('course', ''),
        'sessions': form.get('sessions', '').strip(),
        'newsletter': 'newsletter' in form,      # a checkbox is present or not
    }
    errors = []

    if len(data['name']) < 3:
        errors.append('The name must be at least 3 characters long.')
    if not EMAIL_PATTERN.match(data['email']):
        errors.append('That does not look like an e-mail address.')
    # Never trust a <select>: the browser is not the only thing that can POST.
    if data['course'] not in COURSES:
        errors.append('Please choose a course from the list.')
    if not data['sessions'].isdigit() or not 1 <= int(data['sessions']) <= 20:
        errors.append('The number of sessions must be between 1 and 20.')

    return data, errors


@app.route('/', methods=['GET', 'POST'])
def register():
    '''One address, two methods: show the form, then receive it.'''
    if request.method == 'POST':
        data, errors = validate(request.form)

        if errors:
            for message in errors:
                flash(message, 'error')
            # Give the form back WITH what the visitor already typed.
            return render_template('register.html', courses=COURSES,
                                   data=data)

        data['sessions'] = int(data['sessions'])
        REGISTRATIONS.append(data)
        flash(f'Thank you {data["name"]}, you are registered.', 'success')
        # POST / Redirect / GET: F5 now reloads a harmless page.
        return redirect(url_for('registrations'))

    # GET: an empty form.
    return render_template('register.html', courses=COURSES, data={})

فرم: نمونه 05_forms/app.py

روشمعنایشکجا
GETچیزی به من بدهجست‌وجو و نمایش. مقدارها در آدرس‌اند
POSTاین را بگیر و نگه دارهر چیزی که داده را تغییر می‌دهد

شرح مثال

قاعده POST / Redirect / GET

بعد از یک POST موفق، هرگز صفحه را رندر نکنید؛ هدایت کنید. وگرنه مرورگر هنوز همان POST را در دست دارد و زدن F5 فرم را دوباره می‌فرستد: ثبت‌نام دوم، پرداخت دوم. هدایت کردن، آخرین کار را به یک GET بی‌خطر تبدیل می‌کند. پیام flash() هم برای همین است: پیام را نگه می‌دارد تا صفحه بعدی نشانش دهد.

۷. نشست و کوکی

HTTP حافظه ندارد. هر درخواست تنها می‌آید و سرور نمی‌داند کسی که /profile را می‌خواهد همان کسی است که یک ثانیه پیش فرم ورود را پر کرد. پاسخ، کوکی است.

session['username'] = 'sara'      # write
session.get('username')           # read
session.pop('username', None)     # forget
session.clear()                   # forget everything

نشست، یک دیکشنری درون کوکی

آنچه باید درباره نشست بدانید

۸. رمز عبور و login_required

def login_required(view):
    '''A decorator that sends anonymous visitors to the login page.'''
    @functools.wraps(view)          # keep the name of the wrapped function
    def wrapper(*args, **kwargs):
        if 'username' not in session:
            flash('Please log in first.', 'error')
            # `next=` remembers where the visitor wanted to go.
            return redirect(url_for('login', next=request.path))
        return view(*args, **kwargs)
    return wrapper


@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form.get('username', '').strip().lower()
        password = request.form.get('password', '')

        user = USERS.get(username)
        # ONE message for both cases: saying "no such user" would tell an
        # attacker which names exist.
        if user is None or not check_password_hash(user['password_hash'],
                                                   password):
            flash('Wrong user name or password.', 'error')
            return render_template('login.html', username=username)

        # A new session id at login: it closes the "session fixation" hole.
        session.clear()
        session['username'] = username

        # Never redirect to any address the visitor sends us: an absolute URL
        # here would be an "open redirect" straight to a phishing page.
        target = request.args.get('next', '')
        if not target.startswith('/') or target.startswith('//'):
            target = url_for('profile')
        return redirect(target)

    return render_template('login.html', username='')


@app.route('/profile')
@login_required
def profile():
    ...

ورود کاربر: نمونه 06_sessions_and_login/app.py

هرگز رمز عبور را ذخیره نکنید

درهم رمز را ذخیره کنید: generate_password_hash('1234') چیزی مثل scrypt:32768:8:1$xYz... می‌سازد، و check_password_hash(hash, typed) درست یا نادرست می‌دهد. درهم را نمی‌شود به رمز برگرداند، پس پایگاه داده دزدیده‌شده، رمز همه کاربران را به مهاجم نمی‌دهد. کتابخانه werkzeug همراه Flask می‌آید، پس در همین مرحله داریدش.

سه نکته امنیتی در همین چند خط

۹. وب‌سرویس JSON

در فصل بیست و هشتم یک REST API را صدا زدیم. حالا خودمان یکی می‌نویسیم. تفاوتش با صفحه‌های بالا این است که به جای HTML، JSON برمی‌گردانیم و کد وضعیت معنا را حمل می‌کند.

کدمعنایش
۲۰۰بفرمایید، این هم جوابتان
۲۰۱چیزی که فرستادی را ساختم
۲۰۴انجام شد و چیزی برای گفتن نیست
۴۰۰پیام تو غلط بود
۴۰۱ و ۴۰۳وارد نشده‌ای / اجازه نداری
۴۰۴چنین چیزی وجود ندارد
۴۰۵روش اشتباه برای این آدرس
۵۰۰من اشتباه کردم
def read_payload() -> tuple[dict | None, tuple | None]:
    '''Return (data, error_response). Exactly one of the two is None.'''
    # silent=True -> None instead of an exception when the body is not JSON.
    payload = request.get_json(silent=True)
    if not isinstance(payload, dict):
        return None, (jsonify(error='the body must be a JSON object'), 400)
    return payload, None


@app.route('/api/books/<int:book_id>', methods=['GET'])
def get_book(book_id: int):
    if book_id not in BOOKS:
        return jsonify(error=f'no book with id {book_id}'), 404
    return jsonify(with_id(book_id))


@app.route('/api/books', methods=['POST'])
def create_book():
    global next_id

    payload, error = read_payload()
    if error:
        return error

    title = str(payload.get('title', '')).strip()
    if not title:
        return jsonify(error='"title" is required'), 400

    book_id = next_id
    next_id += 1
    BOOKS[book_id] = {'title': title, ...}
    # 201 Created, and the address of the new thing in the Location header.
    return jsonify(with_id(book_id)), 201, {
        'Location': f'/api/books/{book_id}'}


@app.errorhandler(404)
def api_not_found(error):
    '''An API must answer JSON even when it fails -- never an HTML page.'''
    return jsonify(error='not found'), 404

وب‌سرویس: نمونه 07_json_api/app.py

آدرسروشوظیفه آن
/api/booksGETفهرست
/api/booksPOSTساختن یکی
/api/books/3GETگرفتن یکی
/api/books/3PUTجایگزینی کامل
/api/books/3PATCHتغییر بخشی از آن
/api/books/3DELETEحذف

شرح مثال

۱۰. مصرف‌کننده API

BASE_URL = 'http://127.0.0.1:5000'
TIMEOUT = 5


def main() -> None:
    session = requests.Session()        # reuses the connection

    try:
        show('GET the list', session.get(f'{BASE_URL}/api/books',
                                         timeout=TIMEOUT))

        response = session.post(f'{BASE_URL}/api/books',
                                json={'title': 'Automate the Boring Stuff',
                                      'author': 'Al Sweigart',
                                      'year': 2019},
                                timeout=TIMEOUT)
        show('POST a new book', response)
        book_id = response.json()['id']
        print('    Location header:', response.headers.get('Location'))

مشتری: نمونه 08_api_client/client.py

سه کاری که این مشتری می‌کند و نسخه اصلی نمی‌کرد: روی هر درخواست timeout می‌گذارد (بدون آن یک درخواست می‌تواند تا ابد معلق بماند)؛ کورکورانه raise_for_status() صدا نمی‌زند بلکه به کد نگاه می‌کند تا بتواند «کتاب وجود ندارد» را از «سرور خراب است» تشخیص دهد؛ و وقتی سرور اصلاً بالا نیست، صریح به کاربر می‌گوید.

۱۱. Flask-SQLAlchemy

همان SQLAlchemy فصل سی و دوم، این بار داخل یک سایت. Flask-SQLAlchemy لایه نازکی روی آن است که db.session و db.Model را از قبل به برنامه گره می‌زند.

class Base(DeclarativeBase):
    '''The base class every model inherits from (SQLAlchemy 2 style).'''


db = SQLAlchemy(model_class=Base)

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///library.db'
db.init_app(app)


class Author(db.Model):
    __tablename__ = 'author'

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(100), nullable=False)

    # One author has many books. `back_populates` connects the two sides;
    # `cascade` deletes the books of an author we delete.
    books: Mapped[list['Book']] = relationship(
        back_populates='author', cascade='all, delete-orphan')


# -- commands, not routes --------------------------------------------------
@app.cli.command('init-db')
def init_db_command() -> None:
    '''Create the tables and a little sample data.

    Run it with:  flask --app app init-db
    '''
    db.create_all()

مدل‌ها و فرمان: نمونه 09_flask_sqlalchemy/app.py

دستوروظیفه آن
db.session.scalars(db.select(Book)).all()همه سطرها
db.select(Book).where(...)با شرط
db.session.get(Book, 3)با کلید اصلی
db.get_or_404(Book, 3)یا شیء، یا یک ۴۰۴ تمیز
db.session.add(x); db.session.commit()ذخیره
db.session.delete(x)حذف
ساخت جدول‌ها با فرمان، نه با مسیر

نسخه اصلی این درس یک مسیر GET /api/init-db داشت که جدول‌ها را می‌ساخت. یعنی هر کسی که آن آدرس را پیدا کند می‌تواند به پایگاه داده شما دست بزند؛ و اصولاً یک GET نباید هیچ چیزی را عوض کند. راه درست یک فرمان است: flask --app app init-db.

۱۲. پروژه: دفترچه یادداشت

پروژه پایانی این فصل، یک برنامه یادداشت با دسته‌بندی، جست‌وجو، چهار عمل اصلی و یک API است. مهم‌ترین درسش دو الگوست که هر پروژه Flask بعد از سی مسیر به آنها نیاز پیدا می‌کند: کارخانه برنامه و بلوپرینت.

def create_app(database_uri: str | None = None) -> Flask:
    '''Build and return a ready application.'''
    app = Flask(__name__)

    app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY',
                                              'dev-key-not-for-production')
    app.config['SQLALCHEMY_DATABASE_URI'] = (database_uri
                                             or 'sqlite:///notes.db')

    db.init_app(app)
    app.register_blueprint(notes_bp)

    register_error_handlers(app)
    register_commands(app)

    return app

کارخانه برنامه: نمونه 10_notes_project/app.py

چرا این تابع اضافه ارزشش را دارد

ساختار پروژه سه فایل پایتون دارد که هر کدام یک کار می‌کنند: models.py فقط جدول‌ها، routes.py فقط مسیرها در قالب یک بلوپرینت، و app.py که همه را به هم وصل می‌کند. همان لایه‌بندی فصل سی و پنجم، این بار در وب.

class Note(db.Model):
    __tablename__ = 'note'

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(120), nullable=False)
    body: Mapped[str] = mapped_column(Text, nullable=False, default='')

    # A note without a category is allowed, so the column may be NULL.
    category_id: Mapped[int | None] = mapped_column(
        ForeignKey('category.id'), nullable=True)
    category: Mapped[Category | None] = relationship(back_populates='notes')

    # server_default=func.now(): the DATABASE fills it in, so the value is
    # right even for a row inserted by hand in another program.
    created_at: Mapped[datetime] = mapped_column(
        DateTime, server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(
        DateTime, server_default=func.now(), onupdate=func.now())

    def to_dict(self) -> dict:
        '''The shape the JSON API sends out.'''
        return {'id': self.id,
                'title': self.title,
                'body': self.body,
                'category': self.category.name if self.category else None,
                'created_at': self.created_at.isoformat()}

مدل یادداشت: نمونه 10_notes_project/models.py

فایل‌های تمرینی این فصل

پوشه S38-flask-web-development شامل ده پوشه است. در هر پوشه python app.py بزنید و http://127.0.0.1:5000 را باز کنید؛ با Ctrl-C متوقفش کنید. برای نمونه هشتم، اول سرور نمونه هفتم را در ترمینال دوم راه بیندازید. برای نمونه‌های نهم و دهم، اول flask --app app init-db بزنید.

خلاصه فصل

ابزاروظیفه آن
Flask(__name__)ساختن برنامه
@app.route('/x')وصل کردن آدرس به تابع
<int:post_id>بخش متغیر آدرس، با تبدیل خودکار
url_for('name', ...)ساخت لینک. هرگز دستی ننویسید
abort(404)پاسخ صادقانه به چیزی که وجود ندارد
@app.errorhandler(404)صفحه خطای خودتان
redirect(...)فرستادن مرورگر به آدرس دیگر
render_template(...)رندر یک قالب Jinja2
گریز خودکارمحافظ XSS. safe روی داده کاربر ممنوع
extends و blockارث‌بری قالب
url_for('static', filename=...)فایل‌های ایستا
request.form و request.argsداده فرم و داده آدرس
POST / Redirect / GETتا F5 فرم را دوباره نفرستد
flash()پیام برای صفحه بعدی
session و secret_keyکوکی امضاشده. خواندنی است، پس چیز خصوصی در آن نگذارید
generate_password_hashهرگز رمز را خام ذخیره نکنید
@login_requiredدکوراتور محافظ صفحه
jsonify(...), 201پاسخ JSON با کد وضعیت درست
get_json(silent=True)بدنه غیر JSON یعنی ۴۰۰، نه ۵۰۰
db.get_or_404شیء یا ۴۰۴ تمیز
@app.cli.commandفرمان، به جای مسیری که پایگاه داده را دست می‌زند
کارخانه برنامه و بلوپرینتتا پروژه بعد از سی مسیر خوانا بماند

تمرین‌های فصل سی و هشت

  1. سایتی با سه صفحه بسازید: خانه، درباره ما و تماس، با یک منوی مشترک در قالب پایه.
  2. مسیری بسازید که عددی بگیرد و جدول ضرب آن عدد را در یک جدول HTML نشان دهد.
  3. صفحه ۴۰۴ و ۵۰۰ اختصاصی برای سایتتان بنویسید.
  4. فهرست دانشجویان را از یک فایل JSON بخوانید و با for و if در قالب نمایش دهید.
  5. یک فرم نظرسنجی بسازید که همه فیلدها را اعتبارسنجی کند، خطاها را با flash نشان دهد، و بعد از ثبت موفق هدایت کند.
  6. جست‌وجوی ?q= روی همان فهرست پیاده کنید.
  7. ورود کاربر با رمز درهم‌شده و دکوراتور @login_required بسازید و صفحه پروفایل را محافظت کنید.
  8. به سایتتان صفحه خروج اضافه کنید و مطمئن شوید نشست کاملاً پاک می‌شود.
  9. یک REST API برای «کارهای امروز» بنویسید با هر شش عمل و کدهای وضعیت درست.
  10. برای همان API یک مشتری با requests بنویسید که timeout دارد و ۴۰۴ را از ۵۰۰ تشخیص می‌دهد.
  11. پروژه یادداشت را با یک مدل تازه گسترش دهید: برچسب‌ها، با رابطه چندبه‌چند.
  12. به پروژه یادداشت، صفحه‌بندی اضافه کنید: ده یادداشت در هر صفحه.
  13. توضیح دهید چرا debug=True روی سرور واقعی خطرناک است و دقیقاً چه چیزی را در اختیار مهاجم می‌گذارد.
  14. توضیح دهید چرا فیلتر safe روی داده‌ای که کاربر نوشته، یک حفره امنیتی است. یک نمونه ورودی بنویسید که حمله را نشان دهد.
فصل قبل: همروندی، نخ‌ها و asyncio فصل بعد: پروژه پایانی، Git و GitHub