Skip to content

Commit

Permalink
Add Flask webapp support
Browse files Browse the repository at this point in the history
Signed-off-by: Aurélien Bompard <[email protected]>
  • Loading branch information
abompard committed Dec 20, 2024
1 parent 048c8aa commit b366746
Show file tree
Hide file tree
Showing 22 changed files with 484 additions and 6 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Here are the libraries and services that are integrated:
- Optionaly, CLI with [Click](click.palletsprojects.com/)
- Optionaly, database support with [SQLAlchemy](https://www.sqlalchemy.org/)
& [Alembic](https://alembic.sqlalchemy.org)
- Optionaly, Flask security with [Flask-Talisman](https://pypi.org/project/flask-talisman/)


## Requirements
Expand Down
4 changes: 3 additions & 1 deletion cookiecutter.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
"with_sqlalchemy": false,
"with_cli": false,
"with_i18n": false,
"with_flask": false,
"__prompts__": {
"with_sqlalchemy": "Add database support with SQLAlchemy?",
"with_cli": "Add a command line interface?",
"with_i18n": "Add support for translations (i18n)?"
"with_i18n": "Add support for translations (i18n)?",
"with_flask": "Is it a Flask application?"
},
"_extensions": ["cookiecutter.extensions.RandomStringExtension"],
"_copy_without_render": [
Expand Down
16 changes: 16 additions & 0 deletions hooks/post_gen_project.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,24 @@ rm -rf "{{ cookiecutter.pkg_name }}/cli.py"
{% if not cookiecutter.with_i18n %}
rm -f "babel.cfg"
rm -rf "{{ cookiecutter.pkg_name }}/translations"
rm -f "{{ cookiecutter.pkg_name }}/l10n.py"
{% endif %}
{% if not cookiecutter.with_flask %}
rm -rf "deploy"
rm -f "{{ cookiecutter.slug }}.cfg.default"
rm -f "{{ cookiecutter.pkg_name }}/app.py"
rm -f "{{ cookiecutter.pkg_name }}/defaults.py"
rm -f "{{ cookiecutter.pkg_name }}/l10n.py"
rm -rf "{{ cookiecutter.pkg_name }}/templates/"
rm -rf "{{ cookiecutter.pkg_name }}/utils/"
rm -rf "{{ cookiecutter.pkg_name }}/views/"
rm -f "tests/app_config.py"
rm -f "tests/test_healthz.py"
rm -f "tests/test_root.py"
{% endif %}

black .
ruff check --fix .
git init .
git commit --allow-empty -s -m "Initial empty commit"
git add .
Expand Down
8 changes: 7 additions & 1 deletion tests/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,18 @@ def run(self, *args, **kwargs):
@pytest.mark.parametrize("with_sqlalchemy", [False, True])
@pytest.mark.parametrize("with_cli", [False, True])
@pytest.mark.parametrize("with_i18n", [False, True])
def test_basic_creation(tmpdir, with_sqlalchemy, with_cli, with_i18n):
@pytest.mark.parametrize("with_flask", [False, True])
def test_basic_creation(tmpdir, with_sqlalchemy, with_cli, with_i18n, with_flask):
print("*"*40, with_sqlalchemy, with_cli, with_i18n, with_flask)
if with_flask and not with_sqlalchemy:
# Not supported
pytest.skip("Unsupported: flask without sqlalchemy")
templated = Templated.create(
tmpdir=tmpdir,
with_sqlalchemy=with_sqlalchemy,
with_cli=with_cli,
with_i18n=with_i18n,
with_flask=with_flask,
)
# We can't have 100% coverage with a templated project
templated.run(["sed", "-i", "-e", "/^fail_under = 100/d", "pyproject.toml"])
Expand Down
8 changes: 8 additions & 0 deletions {{ cookiecutter.slug }}/deploy/{{ cookiecutter.slug }}.wsgi
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# SPDX-FileCopyrightText: Contributors to the Fedora Project
#
# SPDX-License-Identifier: GPL-3.0-or-later

from {{ cookiecutter.pkg_name }}.app import create_app


application = create_app()
18 changes: 17 additions & 1 deletion {{ cookiecutter.slug }}/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ classifiers = [
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
{% if cookiecutter.with_flask -%}
"Environment :: Web Environment",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
{% endif %}
]

[tool.poetry.dependencies]
Expand All @@ -47,6 +51,17 @@ click = "^8.1.3"
{% if cookiecutter.with_i18n -%}
Babel = "^2.10.0"
{%- endif %}
{% if cookiecutter.with_flask -%}
flask = "^3.0.0"
flask-wtf = ">=0.14"
flask-healthz = "^1.0.0"
flask-talisman = ">=0.8.0"
whitenoise = ">=5.3"
gunicorn = ">=20.0.0"
{% if cookiecutter.with_i18n -%}
flask-babel = "^4.0.0"
{%- endif %}
{%- endif %}

[tool.poetry.group.dev.dependencies]
pytest = ">=7.0.0"
Expand All @@ -56,6 +71,7 @@ ruff = ">=0.1.1"
coverage = {extras = ["toml"], version = ">=7.0.0"}
diff-cover = ">=8.0.0"
liccheck = ">=0.6"
towncrier = ">=21.3.0"
pre-commit = ">=2.13"

[tool.poetry.group.docs.dependencies]
Expand Down Expand Up @@ -120,7 +136,7 @@ omit = [


[tool.towncrier]
package = "{{ cookiecutter.slug }}"
package = "{{ cookiecutter.pkg_name }}"
filename = "docs/release_notes.md"
directory = "changelog.d"
start_string = "<!-- towncrier release notes start -->\n"
Expand Down
7 changes: 7 additions & 0 deletions {{ cookiecutter.slug }}/tests/app_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# SPDX-FileCopyrightText: Contributors to the Fedora Project
#
# SPDX-License-Identifier: GPL-3.0-or-later

TESTING = True
DEBUG = True
WTF_CSRF_ENABLED = False
31 changes: 28 additions & 3 deletions {{ cookiecutter.slug }}/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,33 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later

import pytest # noqa: F401
{%- if cookiecutter.with_sqlalchemy %}
{% if cookiecutter.with_flask -%}
import os
{%- endif %}
import pytest {%- if not cookiecutter.with_flask %} # noqa: F401 {%- endif %}

{% if cookiecutter.with_sqlalchemy -%}
from {{ cookiecutter.pkg_name }}.database import db {%- if not cookiecutter.with_flask %} # noqa: F401 {%- endif %}
{%- endif %}
{% if cookiecutter.with_flask -%}
from {{ cookiecutter.pkg_name }}.app import create_app


@pytest.fixture
def app(tmpdir):
app = create_app()
app.config.from_object("tests.app_config")
# Setup the DB
db_path = os.path.join(tmpdir, "database.sqlite")
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}"
with app.app_context():
db.manager.create()
return app


from {{ cookiecutter.pkg_name }}.database import db # noqa: F401
@pytest.fixture
def client(app):
with app.test_client() as client:
with app.app_context():
yield client
{%- endif %}
61 changes: 61 additions & 0 deletions {{ cookiecutter.slug }}/tests/test_healthz.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# SPDX-FileCopyrightText: Contributors to the Fedora Project
#
# SPDX-License-Identifier: GPL-3.0-or-later

from flask import current_app
from sqlalchemy_helpers.manager import DatabaseStatus

from {{ cookiecutter.pkg_name }}.database import db


def test_healthz_liveness(client):
"""Test the /healthz/live check endpoint"""
response = client.get("/healthz/live")
assert response.status_code == 200
assert response.json == {"status": 200, "title": "OK"}


def test_healthz_readiness_ok(client):
"""Test the /healthz/ready check endpoint"""
response = client.get("/healthz/ready")
print(response.data)
assert response.status_code == 200
assert response.json == {"status": 200, "title": "OK"}


def test_healthz_readiness_unavailable(client, mocker, tmpdir):
"""Test the /healthz/ready check endpoint when the DB is not ready"""
db.manager.drop()
mocker.patch.dict(
current_app.config, {"SQLALCHEMY_DATABASE_URI": f"sqlite:///{tmpdir}/new.db"}
)
response = client.get("/healthz/ready")
assert response.status_code == 503
assert response.json == {"status": 503, "title": "Can't connect to the database"}


def test_healthz_readiness_needs_upgrade(client, mocker):
"""Test the /healthz/ready check endpoint when the DB schema is old"""
mocker.patch.object(
db.manager, "get_status", return_value=DatabaseStatus.UPGRADE_AVAILABLE
)
response = client.get("/healthz/ready")
assert response.status_code == 503
assert response.json == {
"status": 503,
"title": "The database schema needs to be updated",
}


def test_healthz_readiness_exception(client, mocker):
"""Test the /healthz/ready check endpoint when the DB is wrong"""
mocker.patch.dict(
current_app.config, {"SQLALCHEMY_DATABASE_URI": "sqlite:////does/not/exist"}
)
response = client.get("/healthz/ready")
assert response.status_code == 503
assert response.json["status"] == 503
assert response.json["title"].startswith(
"Can't get the database status: (sqlite3.OperationalError) "
"unable to open database file"
)
35 changes: 35 additions & 0 deletions {{ cookiecutter.slug }}/tests/test_root.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# SPDX-FileCopyrightText: Contributors to the Fedora Project
#
# SPDX-License-Identifier: GPL-3.0-or-later

from bs4 import BeautifulSoup

from {{ cookiecutter.pkg_name }}.database import db
from {{ cookiecutter.pkg_name }}.models import User


def test_root(client):
"""Test the root page"""
response = client.get("/")
assert response.status_code == 200
page = BeautifulSoup(response.data, "html.parser")
assert page.title
assert page.title.string is not None
assert "{{ cookiecutter.name }}" in page.title.string


def test_profile(client):
"""Test the profile page"""
user = User(name="testuser", full_name="Test User", timezone="Europe/Paris")
db.session.add(user)
db.session.flush()
response = client.get(f"/user/{user.id}")
assert response.status_code == 200
page = BeautifulSoup(response.data, "html.parser")
assert page.title
assert page.title.string is not None
assert "{{ cookiecutter.name }}" in page.title.string
content = page.select_one("#profile")
assert content is not None
assert "Full Name: Test User" in list(content.stripped_strings)
assert "Timezone: Europe/Paris" in list(content.stripped_strings)
90 changes: 90 additions & 0 deletions {{ cookiecutter.slug }}/{{ cookiecutter.pkg_name }}/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# SPDX-FileCopyrightText: Contributors to the Fedora Project
#
# SPDX-License-Identifier: GPL-3.0-or-later

import os
from logging.config import dictConfig

import flask_talisman
from flask import Flask
from flask_healthz import healthz
from flask_wtf.csrf import CSRFProtect
from whitenoise import WhiteNoise

{% if cookiecutter.with_i18n -%}
from {{ cookiecutter.pkg_name }} import l10n
{%- endif %}
from {{ cookiecutter.pkg_name }}.database import db
from {{ cookiecutter.pkg_name }}.utils import import_all
from {{ cookiecutter.pkg_name }}.views import blueprint


# Forms
csrf = CSRFProtect()

# Security headers
talisman = flask_talisman.Talisman()


def create_app(config=None):
"""See https://flask.palletsprojects.com/en/1.1.x/patterns/appfactories/"""

app = Flask(__name__)

# Load default configuration
app.config.from_object("{{ cookiecutter.pkg_name }}.defaults")

# Load the optional configuration file
if "FLASK_CONFIG" in os.environ:
app.config.from_envvar("FLASK_CONFIG")

# Load the config passed as argument
app.config.update(config or {})

if app.config.get("TEMPLATES_AUTO_RELOAD"):
app.jinja_env.auto_reload = True

# Logging
if app.config.get("LOGGING"):
dictConfig(app.config["LOGGING"])

# Extensions
{%- if cookiecutter.with_i18n %}
l10n.babel.init_app(app, locale_selector=l10n.pick_locale, timezone_selector=l10n.get_timezone)
app.before_request(l10n.store_locale)
app.jinja_env.add_extension("jinja2.ext.i18n")
{%- endif %}
csrf.init_app(app)

# Database
db.init_app(app)

# Security
talisman.init_app(
app,
force_https=app.config.get("SESSION_COOKIE_SECURE", True),
session_cookie_secure=app.config.get("SESSION_COOKIE_SECURE", True),
frame_options=flask_talisman.DENY,
referrer_policy="same-origin",
content_security_policy={
"default-src": ["'self'", "apps.fedoraproject.org"],
"script-src": [
# https://csp.withgoogle.com/docs/strict-csp.html#example
"'strict-dynamic'",
],
# "img-src": ["'self'", "seccdn.libravatar.org"],
},
content_security_policy_nonce_in=["script-src"],
)

# Register views
import_all("{{ cookiecutter.pkg_name }}.views")
app.register_blueprint(blueprint)
app.register_blueprint(healthz, url_prefix="/healthz")

# Static files
app.wsgi_app = WhiteNoise(
app.wsgi_app, root=f"{app.root_path}/static/", prefix="static/"
)

return app
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
get_or_create,
is_sqlite,
)
{% if cookiecutter.with_flask -%}
from sqlalchemy_helpers.flask_ext import get_or_404 # noqa: F401
{%- endif %}

from {{ cookiecutter.pkg_name }}.config import get_config

Expand Down
17 changes: 17 additions & 0 deletions {{ cookiecutter.slug }}/{{ cookiecutter.pkg_name }}/defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# SPDX-FileCopyrightText: Contributors to the Fedora Project
#
# SPDX-License-Identifier: GPL-3.0-or-later

# This file contains the default configuration values

TEMPLATES_AUTO_RELOAD = False
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = True

SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_DATABASE_URI = "sqlite:///../{{ cookiecutter.slug }}.db"

HEALTHZ = {
"live": "{{ cookiecutter.pkg_name }}.utils.healthz.liveness",
"ready": "{{ cookiecutter.pkg_name }}.utils.healthz.readiness",
}
Loading

0 comments on commit b366746

Please sign in to comment.