Skip to main content

project-scaffold

Use when the user asks to "create a new Flask project", "scaffold a Flask app", "start a Flask project", "Flask project structure", "Flask project layout", "init Flask project", "Flask with SQLAlchemy and migrations from scratch", or needs the initial layout, application factory, config, .env and .gitignore for a new Flask 3 application.

Source facts

Repository
Agents-Store/opencode-plugins
Last source activity
October 3, 2026 at 23:16
Detected SKILL.md language
English
Stars
0
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
project-scaffold
description
Use when the user asks to "create a new Flask project", "scaffold a Flask app", "start a Flask project", "Flask project structure", "Flask project layout", "init Flask project", "Flask with SQLAlchemy and migrations from scratch", or needs the initial layout, application factory, config, .env and .gitignore for a new Flask 3 application.
# Flask Project Scaffold ## Overview A new Flask 3 project that starts, logs a user in and migrates its database on the first run: the application factory, config classes chosen by an `APP_ENV` variable, extensions in `extensions.py` (Flask-SQLAlchemy, Flask-Migrate, Flask-Login, `CSRFProtect`), a `User` model, a dashboard blueprint, a base template, and the `.flaskenv` / `.env` / `.gitignore` files. The files are complete: create them as written, in order, and `flask db upgrade` builds the `users` table from a migration revision. The login, registration and logout routes and their templates are in `auth-flask-login`; the factory below already registers that blueprint (step 7). Layout (flat, application name `myapp`): ``` myapp/ ├── app.py # create_app() factory ├── config.py # Development / Production / Testing config classes ├── extensions.py # Base, db, migrate, login_manager, csrf (no app imported) ├── models.py # SQLAlchemy models ├── routes/ │ ├── __init__.py │ ├── auth.py # login, register, logout (auth-flask-login) │ └── dashboard.py # landing page ├── templates/ # base.html, dashboard.html, login.html, register.html ├── static/ │ ├── css/base.css │ └── js/auth.js # password show/hide (auth-flask-login) ├── tests/ # conftest.py and tests (app-patterns, Testing) ├── migrations/ # created by flask db init, committed ├── instance/ # SQLite database in development (gitignored) ├── pytest.ini ├── requirements.txt ├── .flaskenv # FLASK_APP, APP_ENV, FLASK_DEBUG (public, committed) ├── .env # SECRET_KEY, DATABASE_URL (private, gitignored) └── .gitignore ``` ## 1. Python and install Python 3.10 or newer. Flask-SQLAlchemy 3.1.1 (the latest release) declares `sqlalchemy>=2.0.16` with no upper bound, so a plain install pulls SQLAlchemy 2.1. A `MappedAsDataclass` base fails on SQLAlchemy 2.1 ([pallets-eco/flask-sqlalchemy#1420](https://github.com/pallets-eco/flask-sqlalchemy/issues/1420); plain `db.Model` works), so pin `SQLAlchemy<2.1` to be safe until Flask-SQLAlchemy supports 2.1, in `requirements.txt` as well as on the command line: ``` # requirements.txt Flask>=3.1 Flask-SQLAlchemy>=3.1.1 SQLAlchemy>=2.0.16,<2.1 Flask-Migrate>=4.1 Flask-Login>=0.6.3 Flask-WTF>=1.2 python-dotenv>=1.0 ``` ```bash # from the project root python3 -m venv .venv && . .venv/bin/activate pip install -r requirements.txt ``` ## 2. Environment files ``` # .flaskenv FLASK_APP=app APP_ENV=development FLASK_DEBUG=1 ``` ``` # .gitignore .env instance/ .venv/ __pycache__/ *.pyc .claude/settings.local.json ``` `.env` is private. Generate the secret instead of typing one: ```bash printf 'SECRET_KEY=%s\n' "$(python3 -c 'import secrets; print(secrets.token_hex(32))')" > .env ``` Production adds `DATABASE_URL` (the development config falls back to `sqlite:///dev.db`, stored in `instance/`). The `flask` command loads `.flaskenv` and `.env` itself (python-dotenv), and real environment variables win over both files. gunicorn and other WSGI servers do not read them: a production deployment sets `APP_ENV=production`, `SECRET_KEY` and `DATABASE_URL` in the real environment, and does not ship the development `.flaskenv` values. ## 3. Config classes ```python # config.py import os class Config: SECRET_KEY = os.environ.get('SECRET_KEY') # create_app() refuses to start without it SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') class DevelopmentConfig(Config): SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:///dev.db') class ProductionConfig(Config): SESSION_COOKIE_SECURE = True SESSION_COOKIE_SAMESITE = 'Lax' class TestingConfig(Config): TESTING = True SECRET_KEY = 'testing' SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' WTF_CSRF_ENABLED = False # TESTING alone does not switch CSRF off CONFIGS = { 'development': DevelopmentConfig, 'production': ProductionConfig, 'testing': TestingConfig, } ``` There is no `DEBUG` here: debug mode comes from `--debug` / `FLASK_DEBUG`. More settings (Flask 3.1 production keys, `SECRET_KEY_FALLBACKS`): `app-patterns`. ## 4. Extensions `Base` carries a naming convention, so constraints and indexes get stable names that Alembic autogenerate and SQLite batch migrations rely on (details in the `sqlalchemy-dev` plugin, `model-patterns`). ```python # extensions.py from flask_login import LoginManager from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy from flask_wtf.csrf import CSRFProtect from sqlalchemy import MetaData from sqlalchemy.orm import DeclarativeBase NAMING_CONVENTION = { "ix": "ix_%(column_0_label)s", "uq": "uq_%(table_name)s_%(column_0_name)s", "ck": "ck_%(table_name)s_%(constraint_name)s", "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", "pk": "pk_%(table_name)s", } class Base(DeclarativeBase): metadata = MetaData(naming_convention=NAMING_CONVENTION) db = SQLAlchemy(model_class=Base) migrate = Migrate() login_manager = LoginManager() login_manager.login_view = 'auth.login' login_manager.login_message = 'Please sign in first' csrf = CSRFProtect() ``` ## 5. Models ```python # models.py import datetime as dt from flask_login import UserMixin from sqlalchemy import DateTime, String from sqlalchemy.orm import Mapped, mapped_column from extensions import db def utcnow() -> dt.datetime: return dt.datetime.now(dt.timezone.utc) class User(UserMixin, db.Model): __tablename__ = 'users' id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(100)) email: Mapped[str] = mapped_column(String(120), unique=True) password_hash: Mapped[str] = mapped_column(String(256)) created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=utcnow) ``` `Mapped[str]` is `NOT NULL`; `Mapped[str | None]` is nullable. The `sqlalchemy-dev` plugin (`model-patterns`, with `references/flask-login-user.md`) covers the `User` model in depth and every other model. ## 6. Application factory ```python # app.py import os from flask import Flask from config import CONFIGS from extensions import csrf, db, login_manager, migrate def create_app(config_class=None): app = Flask(__name__) # Explicit class (tests) wins; otherwise our own APP_ENV picks it. # FLASK_ENV is ignored: removed in Flask 2.3. Use our own APP_ENV. app.config.from_object(config_class or CONFIGS[os.environ.get('APP_ENV', 'production')]) if not app.config.get('SECRET_KEY'): raise RuntimeError('SECRET_KEY is not set') # Bind extensions (created in extensions.py) db.init_app(app) migrate.init_app(app, db) login_manager.init_app(app) csrf.init_app(app) from models import User @login_manager.user_loader def load_user(user_id): return db.session.get(User, int(user_id)) # Register blueprints from routes.auth import auth_bp from routes.dashboard import dashboard_bp app.register_blueprint(auth_bp) app.register_blueprint(dashboard_bp) return app ``` `from models import User` inside the factory puts every model on `db.metadata` before Flask-Migrate reads it, and avoids a circular import. Import each new model module there (or in `models.py`) too: a model that nothing imports is invisible to `flask db migrate`. ## 7. Blueprints ```python # routes/__init__.py ``` ```python # routes/dashboard.py from flask import Blueprint, render_template from flask_login import login_required dashboard_bp = Blueprint('dashboard', __name__) @dashboard_bp.route('/') @dashboard_bp.route('/dashboard') @login_required def dashboard(): return render_template('dashboard.html') ``` Create `routes/auth.py`, `templates/login.html`, `templates/register.html` and `static/js/auth.js` from `auth-flask-login`. Add each new feature as one more blueprint file registered in the factory. ## 8. Templates and static files The logout control is a `POST` form with a CSRF token, not a link: a link would let any other site sign users out. ```jinja2 {# templates/base.html #} <!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{% block title %}myapp{% endblock %}</title> <link rel="stylesheet" href="{{ url_for('static', filename='css/base.css') }}"> {% block extra_css %}{% endblock %} </head> <body> <nav> {% if current_user.is_authenticated %} <a href="{{ url_for('dashboard.dashboard') }}">Dashboard</a> <form method="post" action="{{ url_for('auth.logout') }}" class="inline"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <button type="submit">Log out ({{ current_user.name }})</button> </form> {% else %} <a href="{{ url_for('auth.login') }}">Log in</a> <a href="{{ url_for('auth.register') }}">Register</a> {% endif %} </nav> {% with messages = get_flashed_messages(with_categories=true) %} {% for category, message in messages %} <div class="flash flash-{{ category }}">{{ message }}</div> {% endfor %} {% endwith %} <main>{% block content %}{% endblock %}</main> {% block scripts %}{% endblock %} </body> </html> ``` ```jinja2 {# templates/dashboard.html #} {% extends "base.html" %} {% block title %}Dashboard{% endblock %} {% block content %} <h1>Dashboard</h1> <p>Signed in as {{ current_user.email }}.</p> {% endblock %} ``` ```css /* static/css/base.css */ form.inline { display: inline; } .flash-error { color: #b00020; } .flash-success { color: #1b5e20; } ``` ## 9. First migration and run ```bash # run from the project root, after the files above exist and .env holds SECRET_KEY flask db init flask db migrate -m "Initial schema" grep -n "create_table" migrations/versions/*.py # must show 'users': read the revision before applying it flask db upgrade flask routes flask run ``` Open `http://127.0.0.1:5000/register`, create an account, sign out, sign in again. Where port 5000 is taken (macOS AirPlay Receiver), run `flask run --port 5001`. After `flask db migrate`, a revision with no `create_table('users'` means that the model was not imported (step 6) or that the tables already exist in the database (a `create_all()` call somewhere). Fix that before `flask db upgrade`. `flask db init` writes a `migrations/env.py` whose `get_engine()` helper calls a Flask-SQLAlchemy API that 3.1 deprecates; it surfaces only as a `DeprecationWarning` when warnings are errors, and the `sqlalchemy-dev` plugin's `cli-recipes` has the two-line fix. ## 10. Tests `pytest.ini` puts the project root on the import path (so `tests/conftest.py` can `from app import create_app`) and turns SQLAlchemy's deprecation warnings, `LegacyAPIWarning` included, into errors, so a legacy call fails a test instead of passing silently: ```ini # pytest.ini [pytest] pythonpath = . testpaths = tests filterwarnings = error::sqlalchemy.exc.SADeprecationWarning ``` ```bash pip install pytest pytest ``` `tests/conftest.py` (the `app`, `client` and `runner` fixtures) and the first tests are in `app-patterns`, Testing; the login-flow tests are in `auth-flask-login`. ## Rules the scaffold follows - No `db.create_all()` in the factory; the schema comes from `flask db upgrade` (`create_all()` only in test fixtures). - `SECRET_KEY` has no fallback value: the factory raises when it is missing. - Debug mode comes from `--debug` / `FLASK_DEBUG`, never from a config class. - Queries use `db.session.execute(db.select(...))`, not `Model.query`.
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub