├── .editorconfig ├── .github └── workflows │ ├── lock.yaml │ ├── pre-commit.yaml │ ├── publish.yaml │ └── tests.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CHANGES.md ├── LICENSE.txt ├── README.md ├── docs ├── _static │ └── theme.css ├── alembic.md ├── api.md ├── changes.md ├── conf.py ├── engine.md ├── index.md ├── license.md ├── session.md ├── start.md └── testing.md ├── examples ├── blog │ ├── .flaskenv │ ├── .gitignore │ ├── LICENSE.txt │ ├── README.md │ ├── pyproject.toml │ ├── requirements │ │ ├── base.txt │ │ ├── dev.in │ │ └── dev.txt │ ├── src │ │ └── flaskr │ │ │ ├── __init__.py │ │ │ ├── auth.py │ │ │ ├── blog.py │ │ │ ├── migrations │ │ │ ├── 1731180753_init.py │ │ │ └── script.py.mako │ │ │ ├── py.typed │ │ │ ├── static │ │ │ └── style.css │ │ │ └── templates │ │ │ ├── auth │ │ │ ├── login.html │ │ │ └── register.html │ │ │ ├── base.html │ │ │ └── blog │ │ │ ├── create.html │ │ │ ├── index.html │ │ │ └── update.html │ └── tests │ │ ├── conftest.py │ │ ├── test_auth.py │ │ └── test_blog.py └── todo │ ├── .flaskenv │ ├── README.md │ ├── app.py │ └── templates │ ├── layout.html │ ├── new.html │ └── show_all.html ├── pyproject.toml ├── src └── flask_sqlalchemy_lite │ ├── __init__.py │ ├── _cli.py │ ├── _extension.py │ ├── _make.py │ └── py.typed ├── tests ├── conftest.py ├── test_cli.py ├── test_engine.py └── test_session.py └── uv.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | end_of_line = lf 9 | charset = utf-8 10 | max_line_length = 88 11 | 12 | [*.{css,html,js,json,jsx,scss,ts,tsx,yaml,yml}] 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.github/workflows/lock.yaml: -------------------------------------------------------------------------------- 1 | name: Lock inactive closed issues 2 | # Lock closed issues that have not received any further activity for two weeks. 3 | # This does not close open issues, only humans may do that. It is easier to 4 | # respond to new issues with fresh examples rather than continuing discussions 5 | # on old issues. 6 | 7 | on: 8 | schedule: 9 | - cron: '0 0 28 * *' 10 | permissions: 11 | issues: write 12 | pull-requests: write 13 | discussions: write 14 | concurrency: 15 | group: lock 16 | jobs: 17 | lock: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1 21 | with: 22 | issue-inactive-days: 14 23 | pr-inactive-days: 14 24 | discussion-inactive-days: 14 25 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yaml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | on: 3 | pull_request: 4 | push: 5 | branches: [main, stable] 6 | jobs: 7 | main: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 11 | - uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6.1.0 12 | with: 13 | enable-cache: true 14 | prune-cache: false 15 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 16 | id: setup-python 17 | with: 18 | python-version-file: pyproject.toml 19 | - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 20 | with: 21 | path: ~/.cache/pre-commit 22 | key: pre-commit|${{ hashFiles('pyproject.toml', '.pre-commit-config.yaml') }} 23 | - run: uv run --locked --group pre-commit pre-commit run --show-diff-on-failure --color=always --all-files 24 | - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 25 | if: ${{ !cancelled() }} 26 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | tags: ['*'] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | outputs: 9 | hash: ${{ steps.hash.outputs.hash }} 10 | steps: 11 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 12 | - uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6.1.0 13 | with: 14 | enable-cache: true 15 | prune-cache: false 16 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 17 | with: 18 | python-version-file: pyproject.toml 19 | - run: echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> $GITHUB_ENV 20 | - run: uv build 21 | - name: generate hash 22 | id: hash 23 | run: cd dist && echo "hash=$(sha256sum * | base64 -w0)" >> $GITHUB_OUTPUT 24 | - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 25 | with: 26 | path: ./dist 27 | provenance: 28 | needs: [build] 29 | permissions: 30 | actions: read 31 | id-token: write 32 | contents: write 33 | # Can't pin with hash due to how this workflow works. 34 | uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 35 | with: 36 | base64-subjects: ${{ needs.build.outputs.hash }} 37 | create-release: 38 | needs: [provenance] 39 | runs-on: ubuntu-latest 40 | permissions: 41 | contents: write 42 | steps: 43 | - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 44 | - name: create release 45 | run: > 46 | gh release create --draft --repo ${{ github.repository }} 47 | ${{ github.ref_name }} 48 | *.intoto.jsonl/* artifact/* 49 | env: 50 | GH_TOKEN: ${{ github.token }} 51 | publish-pypi: 52 | needs: [provenance] 53 | environment: 54 | name: publish 55 | url: https://pypi.org/project/Flask-SQLAlchemy-Lite/${{ github.ref_name }} 56 | runs-on: ubuntu-latest 57 | permissions: 58 | id-token: write 59 | steps: 60 | - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 61 | - uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 62 | with: 63 | packages-dir: artifact/ 64 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | pull_request: 4 | paths-ignore: ['docs/**', 'README.md'] 5 | push: 6 | branches: [main, stable] 7 | paths-ignore: ['docs/**', 'README.md'] 8 | jobs: 9 | tests: 10 | name: ${{ matrix.name || matrix.python }} 11 | runs-on: ${{ matrix.os || 'ubuntu-latest' }} 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - {python: '3.13'} 17 | - {python: '3.12'} 18 | - {python: '3.11'} 19 | - {python: '3.10'} 20 | - {python: '3.9'} 21 | steps: 22 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 23 | - uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6.1.0 24 | with: 25 | enable-cache: true 26 | prune-cache: false 27 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 28 | with: 29 | python-version: ${{ matrix.python }} 30 | - run: uv run --locked tox run -e ${{ matrix.tox || format('py{0}', matrix.python) }} 31 | typing: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 35 | - uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6.1.0 36 | with: 37 | enable-cache: true 38 | prune-cache: false 39 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 40 | with: 41 | python-version-file: pyproject.toml 42 | - name: cache mypy 43 | uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 44 | with: 45 | path: ./.mypy_cache 46 | key: mypy|${{ hashFiles('pyproject.toml') }} 47 | - run: uv run --locked tox run -e typing 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | __pycache__/ 4 | dist/ 5 | .coverage* 6 | htmlcov/ 7 | .tox/ 8 | docs/_build/ 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: d19233b89771be2d89273f163f5edc5a39bbc34a # frozen: v0.11.12 4 | hooks: 5 | - id: ruff 6 | - id: ruff-format 7 | - repo: https://github.com/astral-sh/uv-pre-commit 8 | rev: 5763f5f580fa24d9d32c85bc3bae8cca4a8f8945 # frozen: 0.7.9 9 | hooks: 10 | - id: uv-lock 11 | - repo: https://github.com/pre-commit/pre-commit-hooks 12 | rev: cef0300fd0fc4d2a87a85fa2093c6b283ea36f4b # frozen: v5.0.0 13 | hooks: 14 | - id: check-merge-conflict 15 | - id: debug-statements 16 | - id: fix-byte-order-marker 17 | - id: trailing-whitespace 18 | - id: end-of-file-fixer 19 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-24.04 4 | tools: 5 | python: '3.13' 6 | commands: 7 | - asdf plugin add uv 8 | - asdf install uv latest 9 | - asdf global uv latest 10 | - uv run --group docs sphinx-build -W -b dirhtml docs $READTHEDOCS_OUTPUT/html 11 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ## Version 0.1.0 2 | 3 | Released 2024-06-07 4 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Pallets 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flask-SQLAlchemy-Lite 2 | 3 | Integrate [SQLAlchemy] with [Flask]. Use Flask's config to define SQLAlchemy 4 | database engines. Create SQLAlchemy ORM sessions that are cleaned up 5 | automatically after requests. 6 | 7 | Intended to be a replacement for [Flask-SQLAlchemy]. Unlike the prior extension, 8 | this one does not attempt to manage the model base class, tables, metadata, or 9 | multiple binds for sessions. This makes the extension much simpler, letting the 10 | developer use standard SQLAlchemy instead. 11 | 12 | [SQLAlchemy]: https://sqlalchemy.org 13 | [Flask]: https://flask.palletsprojects.com 14 | [Flask-SQLAlchemy]: https://flask-sqlalchemy.readthedocs.io 15 | 16 | ## Pallets Community Ecosystem 17 | 18 | > [!IMPORTANT]\ 19 | > This project is part of the Pallets Community Ecosystem. Pallets is the open 20 | > source organization that maintains Flask; Pallets-Eco enables community 21 | > maintenance of Flask extensions. If you are interested in helping maintain 22 | > this project, please reach out on [the Pallets Discord server][discord]. 23 | > 24 | > [discord]: https://discord.gg/pallets 25 | 26 | ## A Simple Example 27 | 28 | ```python 29 | from flask import Flask 30 | from flask_sqlalchemy_lite import SQLAlchemy 31 | from sqlalchemy import select 32 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 33 | 34 | 35 | class Base(DeclarativeBase): 36 | pass 37 | 38 | 39 | class User(Base): 40 | __tablename__ = "user" 41 | id: Mapped[int] = mapped_column(primary_key=True) 42 | username: Mapped[str] = mapped_column(unique=True) 43 | 44 | 45 | app = Flask(__name__) 46 | app.config["SQLALCHEMY_ENGINES"] = {"default": "sqlite:///default.sqlite"} 47 | db = SQLAlchemy(app) 48 | 49 | with app.app_context(): 50 | Base.metadata.create_all(db.engine) 51 | 52 | db.session.add(User(username="example")) 53 | db.session.commit() 54 | 55 | users = db.session.scalars(select(User)) 56 | ``` 57 | -------------------------------------------------------------------------------- /docs/_static/theme.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:ital,wght@0,400;0,700;1,400;1,700&family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap'); 2 | -------------------------------------------------------------------------------- /docs/alembic.md: -------------------------------------------------------------------------------- 1 | # Migrations with Alembic 2 | 3 | Typically, you'll want to use [Alembic] to generate and run migrations as you 4 | create and modify your tables. The [Flask-Alembic] extension provides 5 | integration between Flask, Flask-SQLAlchemy(-Lite), and Alembic. You can also 6 | use Alembic directly, but it will require a little more setup. 7 | 8 | [Alembic]: https://alembic.sqlalchemy.org 9 | [Flask-Alembic]: https://flask-alembic.readthedocs.io 10 | 11 | 12 | ## Flask-Alembic 13 | 14 | [Flask-Alembic] will use the engines configured by Flask-SQLAlchemy-Lite. You 15 | need to tell it about the metadata object for your base model. Multiple 16 | databases are also supported, see the Flask-Alembic docs for details. 17 | 18 | ```python 19 | from flask import Flask 20 | from flask_alembic import Alembic 21 | from flask_sqlalchemy_lite import SQLAlchemy 22 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column 23 | 24 | class Model(DeclarativeBase): 25 | pass 26 | 27 | class User(Model): 28 | __tablename__ = "user" 29 | id: Mapped[int] = mapped_column(primary_key=True) 30 | name: Mapped[str] 31 | 32 | db = SQLAlchemy() 33 | alembic = Alembic(metadatas=Model.metadata) 34 | 35 | def create_app(): 36 | app = Flask(__name__) 37 | app.config["SQLALCHEMY_ENGINES"] = {"default": "sqlite:///default.sqlite"} 38 | app.config.from_prefixed_env() 39 | db.init_app(app) 40 | alembic.init_app(app) 41 | return app 42 | ``` 43 | 44 | ``` 45 | $ flask db revision 'init' 46 | $ flask db upgrade 47 | ``` 48 | 49 | 50 | ## Plain Alembic 51 | 52 | You'll need to modify the `migrations/env.py` script that [Alembic] generates to 53 | tell it about your Flask application and the engine and metadata. 54 | 55 | ``` 56 | $ alembic init migrations 57 | ``` 58 | 59 | Modify parts of `migrations/env.py`, the `...` are omitted parts of the file. 60 | 61 | ```python 62 | from project import create_app, Model, db 63 | 64 | flask_app = create_app() 65 | 66 | ... 67 | 68 | target_metadata = Model.metadata 69 | 70 | ... 71 | 72 | def run_migrations_online() -> None: 73 | with flask_app.app_context(): 74 | connectable = db.engine 75 | 76 | ... 77 | 78 | ... 79 | ``` 80 | 81 | ``` 82 | $ alembic revision --autogenerate -m 'init' 83 | $ alembic upgrade head 84 | ``` 85 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | Anything documented here is part of the public API that Flask-SQLAlchemy 4 | provides, unless otherwise indicated. Anything not documented here is considered 5 | internal or private and may change at any time. 6 | 7 | ```{eval-rst} 8 | .. currentmodule:: flask_sqlalchemy_lite 9 | 10 | .. autoclass:: SQLAlchemy 11 | :members: 12 | ``` 13 | -------------------------------------------------------------------------------- /docs/changes.md: -------------------------------------------------------------------------------- 1 | # Changes 2 | 3 | ```{include} ../CHANGES.md 4 | ``` 5 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | 3 | # Project -------------------------------------------------------------- 4 | 5 | project = "Flask-SQLAlchemy-Lite" 6 | version = release = importlib.metadata.version("flask-sqlalchemy-lite").partition( 7 | ".dev" 8 | )[0] 9 | 10 | # General -------------------------------------------------------------- 11 | 12 | default_role = "code" 13 | extensions = [ 14 | "sphinx.ext.autodoc", 15 | "sphinx.ext.extlinks", 16 | "sphinx.ext.intersphinx", 17 | "myst_parser", 18 | ] 19 | autodoc_member_order = "bysource" 20 | autodoc_typehints = "description" 21 | autodoc_preserve_defaults = True 22 | extlinks = { 23 | "issue": ("https://github.com/pallets-eco/flask-sqlalchemy-lite/issues/%s", "#%s"), 24 | "pr": ("https://github.com/pallets-eco/flask-sqlalchemy-lite/pull/%s", "#%s"), 25 | } 26 | intersphinx_mapping = { 27 | "python": ("https://docs.python.org/3/", None), 28 | "flask": ("https://flask.palletsprojects.com", None), 29 | "sqlalchemy": ("https://docs.sqlalchemy.org", None), 30 | } 31 | myst_enable_extensions = [ 32 | "fieldlist", 33 | ] 34 | myst_heading_anchors = 2 35 | 36 | # HTML ----------------------------------------------------------------- 37 | 38 | html_theme = "furo" 39 | html_static_path = ["_static"] 40 | html_css_files = ["theme.css"] 41 | html_copy_source = False 42 | html_theme_options = { 43 | "source_repository": "https://github.com/pallets-eco/flask-sqlalchemy-lite/", 44 | "source_branch": "main", 45 | "source_directory": "docs/", 46 | "light_css_variables": { 47 | "font-stack": "'Atkinson Hyperlegible', sans-serif", 48 | "font-stack--monospace": "'Source Code Pro', monospace", 49 | }, 50 | } 51 | pygments_style = "default" 52 | pygments_style_dark = "github-dark" 53 | html_show_copyright = False 54 | html_use_index = False 55 | html_domain_indices = False 56 | -------------------------------------------------------------------------------- /docs/engine.md: -------------------------------------------------------------------------------- 1 | # Engines 2 | 3 | One or more SQLAlchemy {class}`engines ` can be 4 | configured through Flask's {attr}`app.config `. The engines 5 | are created when {meth}`.SQLAlchemy.init_app` is called, changing config after 6 | that will have no effect. Both sync and async engines can be configured. 7 | 8 | 9 | ## Flask Config 10 | 11 | ```{currentmodule} flask_sqlalchemy_lite 12 | ``` 13 | 14 | ```{data} SQLALCHEMY_ENGINES 15 | A dictionary defining sync engine configurations. Each key is a name for an 16 | engine, used to refer to them later. Each value is the engine configuration. 17 | 18 | If the value is a dict, it consists of keyword arguments to be passed to 19 | {func}`sqlalchemy.create_engine`. The `'url'` key is required; it can be a 20 | connection string (`dialect://user:pass@host:port/name?args`), a 21 | {class}`sqlalchemy.engine.URL` instance, or a dict representing keyword 22 | arguments to pass to {meth}`sqlalchemy.engine.URL.create`. 23 | 24 | As a shortcut, if you only need to specify the URL and no other arguments, the 25 | value can be a connection string or `URL` instance. 26 | ``` 27 | 28 | ```{data} SQLALCHEMY_ASYNC_ENGINES 29 | The same as {data}`SQLALCHEMY_ENGINES`, but for async engine configurations. 30 | ``` 31 | 32 | ### URL Examples 33 | 34 | The following configurations are all equivalent. 35 | 36 | ```python 37 | SQLALCHEMY_ENGINES = { 38 | "default": "sqlite:///default.sqlite" 39 | } 40 | ``` 41 | 42 | ```python 43 | from sqlalchemy import URL 44 | SQLALCHEMY_ENGINES = { 45 | "default": URL.create("sqlite", database="default.sqlite") 46 | } 47 | ``` 48 | 49 | ```python 50 | SQLALCHEMY_ENGINES = { 51 | "default": {"url": "sqlite:///default.sqlite"} 52 | } 53 | ``` 54 | 55 | ```python 56 | from sqlalchemy import URL 57 | SQLALCHEMY_ENGINES = { 58 | "default": {"url": URL.create("sqlite", database="default.sqlite")} 59 | } 60 | ``` 61 | 62 | ```python 63 | SQLALCHEMY_ENGINES = { 64 | "default": {"url": {"drivername": "sqlite", "database": "default.sqlite"}} 65 | } 66 | ``` 67 | 68 | 69 | ## Default Options 70 | 71 | Default engine options can be passed as the `engine_options` parameter when 72 | creating the {class}`.SQLAlchemy` instance. The config for each engine will be 73 | merged with these default options, overriding any shared keys. This applies to 74 | both sync and async engines. You can use specific config if you need different 75 | options for each. 76 | 77 | 78 | ### SQLite Defaults 79 | 80 | A relative database path will be relative to the app's 81 | {attr}`~flask.Flask.instance_path` instead of the current directory. The 82 | instance folder will be created if it does not exist. 83 | 84 | When using a memory database (no path, or `:memory:`), a static pool will be 85 | used, and `check_same_thread=False` will be passed. This allows multiple workers 86 | to share the database. 87 | 88 | 89 | ### MySQL Defaults 90 | 91 | When using a queue pool (default), `pool_recycle` is set to 7200 seconds 92 | (2 hours), forcing SQLAlchemy to reconnect before MySQL would discard the idle 93 | connection. 94 | 95 | The connection charset is set to `utf8mb4`. 96 | 97 | 98 | ## The Default Engine and Bind 99 | 100 | The `"default"` key is special, and will be used for {attr}`~.SQLAlchemy.engine` 101 | and as the default bind for {attr}`~.SQLAlchemy.sessionmaker`. By default, it is 102 | an error not to configure it for one of sync or async engines. 103 | 104 | 105 | ## Custom Engines 106 | 107 | You can ignore the Flask config altogether and create engines yourself. In that 108 | case, you pass `require_default_engine=False` when creating the extension to 109 | ignore the check for default config. Adding custom engines to the 110 | {attr}`~.SQLAlchemy.engines` map will make them accessible through the extension, 111 | but that's not required either. You will want to call 112 | `db.sessionmaker.configure(bind=..., binds=...)` to set up these custom engines 113 | if you plan to use the provided session management though. 114 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Flask-SQLAlchemy-Lite 2 | 3 | This [Flask]/[Quart] extension manages [SQLAlchemy] engines and sessions as part 4 | of your web application. Engines can be configured through Flask config, and 5 | sessions are manages and cleaned up as part of the app/request context. 6 | SQLAlchemy's async capabilities are supported as well, and both sync and async 7 | can be configured and used at the same time. 8 | 9 | [Flask]: https://flask.palletsprojects.com 10 | [Quart]: https://quart.palletsprojects.com 11 | [SQLAlchemy]: https://www.sqlalchemy.org 12 | 13 | Install it from [PyPI] using an installer such as pip: 14 | 15 | ``` 16 | $ pip install Flask-SQLAlchemy-Lite 17 | ``` 18 | 19 | [PyPI]: https://pypi.org/project/Flask-SQLAlchemy-Lite 20 | 21 | This is intended to be a replacement for the [Flask-SQLAlchemy] extension. It 22 | provides the same `db.engine` and `db.session` interface. However, this 23 | extension avoids pretty much every other thing the former extension managed. It 24 | does not create the base model, table class, or metadata itself. It does not 25 | implement a custom bind system. It does not provide automatic table naming for 26 | models. It does not provide query recording, pagination, query methods, etc. 27 | 28 | [Flask-SQLAlchemy]: https://flask-sqlalchemy.palletsprojects.com 29 | 30 | This extension tries to do as little as possible and as close to plain 31 | SQLAlchemy as possible. You define your base model using whatever SQLAlchemy 32 | pattern you want, old or modern. You use SQLAlchemy's `session.binds` API for 33 | mapping different models to different engines. You import all names from 34 | SQLAlchemy directly, rather than using `db.Mapped`, `db.select`, etc. Sessions 35 | are tied directly to request lifetime, but can also be created and managed 36 | directly, and do not use the `scoped_session` interface. 37 | 38 | These docs cover how the extension works, _not_ how to use SQLAlchemy. Read the 39 | [SQLAlchemy docs], which include a comprehensive tutorial, to learn how to use 40 | SQLAlchemy. 41 | 42 | [SQLAlchemy docs]: https://docs.sqlalchemy.org 43 | 44 | ```{toctree} 45 | :hidden: 46 | 47 | start 48 | alembic 49 | engine 50 | session 51 | testing 52 | api 53 | changes 54 | license 55 | ``` 56 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | ```{literalinclude} ../LICENSE.txt 4 | :language: text 5 | ``` 6 | -------------------------------------------------------------------------------- /docs/session.md: -------------------------------------------------------------------------------- 1 | # Sessions 2 | 3 | A SQLAlchemy {class}`~sqlalchemy.orm.sessionmaker` is created when 4 | {meth}`.SQLAlchemy.init_app` is called. Both sync and async sessionmakers 5 | are created regardless of if any sync or async engines are defined. 6 | 7 | 8 | ## Default Options 9 | 10 | Default session options can be passed as the `session_options` parameter when 11 | creating the {class}`.SQLAlchemy` instance. This applies to both sync and async 12 | sessions. You can call each sessionmaker's `configure` method if you need 13 | different options for each. 14 | 15 | 16 | ## Session Management 17 | 18 | Most use cases will use one session, and tie it to the lifetime of each request. 19 | Use {attr}`db.session <.SQLAlchemy.session>` for this. It will return the same 20 | session throughout a request, then close it when the request ends. SQLAlchemy 21 | will rollback any uncomitted state in the session when it is closed. 22 | 23 | You can also create other sessions besides the default. Calling 24 | {meth}`db.get_session(name) <.SQLAlchemy.get_session>` will create separate 25 | sessions that are also closed at the end of the request. 26 | 27 | The sessions are closed when the application context is torn down. This happens 28 | for each request, but also at the end of CLI commands, and for manual 29 | `with app.app_context()` blocks. 30 | 31 | 32 | ### Manual Sessions 33 | 34 | You can also use {attr}`db.sessionmaker <.SQLAlchemy.sessionmaker>` directly to 35 | create sessions. These will not be closed automatically at the end of requests, 36 | so you'll need to manage them manually. An easy way to do that is using a `with` 37 | block. 38 | 39 | ```python 40 | with db.sessionmaker() as session: 41 | ... 42 | ``` 43 | 44 | 45 | ### Async 46 | 47 | SQLAlchemy warns that the async sessions it provides are _not_ safe to be used 48 | across concurrent tasks. For example, the same session should not be passed to 49 | multiple tasks when using `asyncio.gather`. Either use 50 | {meth}`db.get_async_session(name) <.SQLAlchemy.get_async_session>` with a unique 51 | name for each task, or use 52 | {attr}`db.async_sessionmaker <.SQLAlchemy.async_sessionmaker>` to manage session 53 | lifetimes manually. The latter is what SQLAlchemy recommends. 54 | 55 | 56 | ## Multiple Binds 57 | 58 | If the `"default"` engine key is defined when initializing the extension, it 59 | will be set as the default bind for sessions. This is optional, but if you don't 60 | configure it up front, you'll want to call `db.sessionmaker.configure(bind=...)` 61 | later to set the default bind, or otherwise specify a bind for each query. 62 | 63 | SQLAlchemy supports using different engines when querying different tables or 64 | models. This requires specifying a mapping from a model, base class, or table to 65 | an engine object. When using the extension, you can set this up generically 66 | in `session_options` by mapping to names instead of engine objects. During 67 | initialization, the extension will substitute each name for the configured 68 | engine. You can also call `db.sessionmaker.configure(binds=...)` after the fact 69 | and pass the engines using {meth}`~.SQLAlchemy.get_engine` yourself. 70 | 71 | ```python 72 | db = SQLAlchemy(session_options={"binds": { 73 | User: "auth", 74 | Role: "auth", 75 | ExternalBase: "external", 76 | }}) 77 | ``` 78 | -------------------------------------------------------------------------------- /docs/start.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This page walks through the common use of the extension. See the rest of the 4 | documentation for more details about other features. 5 | 6 | These docs cover how the extension works, _not_ how to use SQLAlchemy. Read the 7 | [SQLAlchemy docs], which include a comprehensive tutorial, to learn how to use 8 | SQLAlchemy. 9 | 10 | [SQLAlchemy docs]: https://docs.sqlalchemy.org 11 | 12 | 13 | ## Setup 14 | 15 | Create an instance of {class}`.SQLAlchemy`. Define the 16 | {data}`.SQLALCHEMY_ENGINES` config, a dict, with at least the `"default"` key 17 | with a [connection string] value. When setting up the Flask app, call the 18 | extension's {meth}`~.SQLAlchemy.init_app` method. 19 | 20 | [connection string]: https://docs.sqlalchemy.org/core/engines.html#database-urls 21 | 22 | ```python 23 | from flask import Flask 24 | from flask_sqlalchemy_lite import SQLAlchemy 25 | 26 | db = SQLAlchemy() 27 | 28 | def create_app(): 29 | app = Flask(__name__) 30 | app.config |= { 31 | "SQLALCHEMY_ENGINES": { 32 | "default": "sqlite:///default.sqlite", 33 | }, 34 | } 35 | app.config.from_prefixed_env() 36 | db.init_app(app) 37 | return app 38 | ``` 39 | 40 | When not using the app factory pattern, you can pass the app directly when 41 | creating the instance, and it will call `init_app` automatically. 42 | 43 | ```python 44 | app = Flask(__name__) 45 | app.config |= { 46 | "SQLALCHEMY_ENGINES": { 47 | "default": "sqlite:///default.sqlite", 48 | }, 49 | } 50 | app.config.from_prefixed_env() 51 | db = SQLAlchemy(app) 52 | ``` 53 | 54 | 55 | ## Models 56 | 57 | The modern (SQLAlchemy 2) way to define models uses type annotations. Create a 58 | base class first. Each model subclasses the base and defines at least a 59 | `__tablename__` and a primary key column. 60 | 61 | ```python 62 | from __future__ import annotations 63 | from datetime import datetime 64 | from datetime import UTC 65 | from sqlalchemy import ForeignKey 66 | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship 67 | 68 | class Model(DeclarativeBase): 69 | pass 70 | 71 | class User(Model): 72 | __tablename__ = "user" 73 | id: Mapped[int] = mapped_column(primary_key=True) 74 | name: Mapped[str] 75 | posts: Mapped[list[Post]] = relationship(back_populates="author") 76 | 77 | class Post(Model): 78 | __tablename__ = "post" 79 | id: Mapped[int] = mapped_column(primary_key=True) 80 | title: Mapped[str] 81 | body: Mapped[str] 82 | author_id: Mapped[int] = mapped_column(ForeignKey(User.id)) 83 | author: Mapped[User] = relationship(back_populates="posts") 84 | created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC)) 85 | ``` 86 | 87 | There are other ways to define models, such as integrating with 88 | {mod}`dataclasses`, the legacy metaclass base, or setting up mappings manually. 89 | This extension can be used with any method. 90 | 91 | 92 | ### Creating Tables 93 | 94 | Typically, you'll want to use [Alembic] to generate and run migrations as you 95 | create and modify your tables. The [Flask-Alembic] extension provides 96 | integration between Flask, Flask-SQLAlchemy(-Lite), and Alembic. You can also 97 | use Alembic directly, but it will require a little more setup. 98 | 99 | [Alembic]: https://alembic.sqlalchemy.org 100 | [Flask-Alembic]: https://flask-alembic.readthedocs.io 101 | 102 | See {doc}`alembic` for instructions. 103 | 104 | --- 105 | 106 | For basic uses, you can use the `metadata.create_all()` method. You can call 107 | this for multiple metadatas with different engines. This will create any tables 108 | that do not exist. It will _not_ update existing tables, such as adding new 109 | columns. For that you need Alembic migrations. 110 | 111 | Engines and session can only be accessed inside a Flask application context. 112 | When not inside a request or CLI command, such as during setup, push a context 113 | using a `with` block. 114 | 115 | ```python 116 | with app.app_context(): 117 | Model.metadata.create_all(db.engine) 118 | OtherModel.metadata.create_all(db.get_engine("other")) 119 | ``` 120 | 121 | 122 | ### Populating the Flask Shell 123 | 124 | When using the `flask shell` command to start an interactive interpreter, 125 | any model classes that have been registered with any SQLAlchemy base class will 126 | be made available. The {class}`.SQLAlchemy` instance will be made available as 127 | `db`. And the `sqlalchemy` namespace will be imported as `sa`. 128 | 129 | These three things make it easy to work with the database from the shell without 130 | needing any manual imports. 131 | 132 | ```pycon 133 | >>> for user in db.session.scalars(sa.select(User)): 134 | ... user.active = False 135 | ... 136 | >>> db.session.commit() 137 | ``` 138 | 139 | 140 | ## Executing Queries 141 | 142 | Queries are constructed and executed using standard SQLAlchemy. To add a model 143 | instance to the session, use `db.session.add(obj)`. To modify a row, modify the 144 | model's attributes. Then call `db.session.commit()` to save the changes to the 145 | database. 146 | 147 | To query data from the database, use SQLAlchemy's `select()` constructor and 148 | pass it to `db.session.scalars()` when selecting a model, or `.execute()` when 149 | selecting a compound set of rows. There are also constructors for other 150 | operations for less common use cases such as bulk inserts or updates. 151 | 152 | ```python 153 | from flask import request, abort, render_template 154 | from sqlalchemy import select 155 | 156 | @app.route("/users") 157 | def user_list(): 158 | users = db.session.scalars(select(User).order_by(User.name)).all() 159 | return render_template("users/list.html", users=users) 160 | 161 | @app.route("/users/create") 162 | def user_create(): 163 | name = request.form["name"] 164 | 165 | if db.session.scalar(select(User).where(User.name == name)) is not None: 166 | abort(400) 167 | 168 | db.session.add(User(name=name)) 169 | db.session.commit() 170 | return app.redirect(app.url_for("user_list")) 171 | ``` 172 | 173 | 174 | ### Application Context 175 | 176 | Engines and sessions can only be accessed inside a Flask application context. 177 | A context is active during each request, and during a CLI command. Therefore, 178 | you can usually access `db.session` without any extra work. 179 | 180 | When not inside a request or CLI command, such as during setup or certain test 181 | cases, push a context using a `with` block. 182 | 183 | ```python 184 | with app.app_context(): 185 | # db.session and db.engine are accessible 186 | ... 187 | ``` 188 | 189 | 190 | ## Async 191 | 192 | The extension also provides SQLAlchemy's async engines and sessions. Prefix any 193 | engine or session access with `async_` to get the equivalent async objects. For 194 | example, {attr}`db.async_session <.SQLAlchemy.async_session>`. You'll want to 195 | review [SQLAlchemy's async docs][async docs], as there are some more things to 196 | be aware of than with sync usage. 197 | 198 | [async docs]: https://docs.sqlalchemy.org/orm/extensions/asyncio.html 199 | 200 | In particular, SQLAlchemy warns that the async sessions it provides are _not_ 201 | safe to be used across concurrent tasks. For example, the same session should 202 | not be passed to multiple tasks when using `asyncio.gather`. Either use 203 | {meth}`db.get_async_session(name) <.SQLAlchemy.get_async_session>` with a unique 204 | name for each task, or use 205 | {attr}`db.async_sessionmaker <.SQLAlchemy.async_sessionmaker>` to manage session 206 | lifetimes manually. The latter is what SQLAlchemy recommends. 207 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Writing Tests 2 | 3 | Writing tests that use the database can be a bit tricky. Mainly you need to 4 | isolate the database changes to each test so that one test doesn't accidentally 5 | affect another. 6 | 7 | This goes over some patterns for writing tests using [pytest]. Other test 8 | frameworks should work similarly. 9 | 10 | [pytest]: https://docs.pytest.org 11 | 12 | 13 | ## The App Factory Pattern 14 | 15 | You'll want to use the app factory pattern. It's possible to test without it, 16 | but that becomes a lot harder to reconfigure for testing. A factory paired with 17 | a test fixture ensures that each test is isolated to a separate app instance. 18 | 19 | Here's a general pattern for the Flask app factory. When running the server, it 20 | will be called without arguments. The test fixture will call it and pass 21 | `test_config` to set a different engine URL and any other overrides. 22 | 23 | ```python 24 | from flask import Flask 25 | from flask_sqlalchemy_lite import SQLAlchemy 26 | 27 | db = SQLAlchemy() 28 | 29 | def create_app(test_config=None): 30 | app = Flask(__name__) 31 | app.config |= { 32 | "SQLALCHEMY_ENGINES": {"default": "sqlite:///default.sqlite"} 33 | } 34 | 35 | if test_config is None: 36 | app.config.from_prefixed_env() 37 | else: 38 | app.testing = True 39 | app.config |= test_config 40 | 41 | db.init_app(app) 42 | return app 43 | ``` 44 | 45 | Then write an `app` test fixture to create an app for each test. Note that a 46 | different URL is passed to the factory. 47 | 48 | ```python 49 | import pytest 50 | from project import create_app 51 | 52 | @pytest.fixture 53 | def app(): 54 | app = create_app({ 55 | "SQLALCHEMY_ENGINES": {"default": "sqlite://"} 56 | }) 57 | yield app 58 | ``` 59 | 60 | When writing the factory, we also defined `db = SQLAlchemy()` outside the 61 | factory. You import this throughout your app to make queries, and you will 62 | import it in your tests as well. 63 | 64 | 65 | ## Use a Test Database 66 | 67 | Always configure your engines to point to temporary test databases. You 68 | definitely don't want to point to your production database, but you probably 69 | don't want to point to your local development database either. This way, any 70 | data your tests use do not affect the data you're working with. 71 | 72 | Let's say your default engine is configured as `postgresql:///project`. During 73 | testing, change the config to use something like `postgresql:///project-test` 74 | instead. 75 | 76 | [SQLAlchemy-Utils] provides functions to issue `create database` and 77 | `drop database`. You can use these to set up the database at the beginning of 78 | the test session and clean it up at the end. Then you can create the tables 79 | for each model, and they will be available during all the tests. 80 | 81 | [SQLAlchemy-Utils]: https://sqlalchemy-utils.readthedocs.io/en/latest/database_helpers.html 82 | 83 | 84 | ```python 85 | import pytest 86 | from sqlalchemy_utils import create_database, drop_database 87 | from project import create_app, db, Model 88 | 89 | @pytest.fixture(scope="session", autouse=True) 90 | def _manage_test_database(): 91 | app = create_app({ 92 | "SQLALCHEMY_ENGINES": {"default": "postgresql:///project-test"} 93 | }) 94 | 95 | with app.app_context(): 96 | engines = db.engines 97 | 98 | for engine in engines.values(): 99 | create_database(engine.url) 100 | 101 | Model.metadata.create_all(engines["default"]) 102 | 103 | yield 104 | 105 | for engine in engines.values(): 106 | drop_database(engine.url) 107 | ``` 108 | 109 | If you had multiple bases, you would call `metadata.create_all()` for each one 110 | with the appropriate engine. 111 | 112 | Since this fixture is session scoped, you create an app locally rather than 113 | using the function scoped `app` fixture. The app context should only be pushed 114 | to get the engine, it _must not_ be active during the entire session otherwise 115 | requests and cleanup will not work correctly. 116 | 117 | 118 | ### SQLite 119 | 120 | When using SQLite, it's much easier to isolate each test by using an in memory 121 | database instead of a database file. This is fast enough that you can skip the 122 | session scoped fixture above and instead make it part of the `app` fixture: 123 | 124 | ```python 125 | import pytest 126 | from project import create_app, db, Model 127 | 128 | @pytest.fixture 129 | def app(): 130 | app = create_app({ 131 | "SQLALCHEMY_ENGINES": {"default": "sqlite://"} 132 | }) 133 | 134 | with app.app_context(): 135 | engine = db.engine 136 | 137 | Model.metadata.create_all(engine) 138 | yield app 139 | ``` 140 | 141 | 142 | ## Avoid Writing Data 143 | 144 | If code in a test writes data to the database, and another test reads data from 145 | the database, one test running before another might affect what the other test 146 | sees. This isn't good, each test should be isolated and have no lasting effects. 147 | 148 | Each engine in `db.engines` can be patched to represent a connection with a 149 | transaction instead of a pool. Then all operations will occur inside the 150 | transaction and be discarded at the end, without writing anything permanently. 151 | 152 | Modify the `app` fixture to do this patching. 153 | 154 | ```python 155 | import pytest 156 | from project import create_app, db 157 | 158 | @pytest.fixture 159 | def app(): 160 | app = create_app({ 161 | "SQLALCHEMY_ENGINES": {"default": "postgresql:///project-test"} 162 | }) 163 | 164 | with app.app_context(): 165 | engines = db.engines 166 | 167 | cleanup = [] 168 | 169 | for key, engine in engines.items(): 170 | connection = engine.connect() 171 | transaction = connection.begin() 172 | engines[key] = connection 173 | cleanup.append((key, engine, connection, transaction)) 174 | 175 | yield app 176 | 177 | for key, engine, connection, transaction in cleanup: 178 | transaction.rollback() 179 | connection.close() 180 | engines[key] = engine 181 | ``` 182 | 183 | This is not needed when using a SQLite in memory database as discussed above, as 184 | each test will already be using a separate app with a separate in memory 185 | database. 186 | 187 | 188 | ## Async 189 | 190 | You'll need to use a pytest plugin such as [pytest-asyncio] to enable 191 | `async def` fixtures and tests, but otherwise all the concepts here should still 192 | apply. 193 | 194 | [pytest-asyncio]: https://pytest-asyncio.readthedocs.io 195 | 196 | 197 | ## Testing Data Around Requests 198 | 199 | While your Flask app will expose endpoints to modify your database, it can be 200 | inconvenient to create and inspect all your data for a test through requests. It 201 | might be easier to directly insert a model in exactly the form you need before a 202 | request, or directly query and examine the model after a request. 203 | 204 | Accessing `db.session` or `db.engine` requires an app context, so you can push 205 | one temporarily. *Do not make requests inside an active context, they 206 | will behave unexpectedly.* 207 | 208 | ```python 209 | from project import db, User 210 | 211 | def test_update_user(app): 212 | # Insert a user to be updated. 213 | with app.app_context(): 214 | user = User(username="example", name="Example User") 215 | db.session.add(user) 216 | user_id = user.id 217 | 218 | # Make a request to the update endpoint. Outside the app context! 219 | client = app.test_client() 220 | client.post(f"/user/update/{user_id}", data={"name": "Real Name"}) 221 | 222 | # Query the user and verify the update. 223 | with app.app_context(): 224 | user = db.session.get(User, user_id) 225 | assert user.name == "Real Name" 226 | ``` 227 | 228 | ## Testing Data Without Requests 229 | 230 | You might also want to test your database models, or functions that work with 231 | them, directly rather than within a request. In that case, using a with block 232 | and extra indentation to push a context seems unnecessary. 233 | 234 | You can define a fixture that pushes an app context for the duration of the 235 | test. However, as warned above: *Do not make requests inside an active context, 236 | they will behave unexpectedly.* Only use this fixture for tests where you won't 237 | make requests. 238 | 239 | ```python 240 | import pytest 241 | 242 | @pytest.fixture 243 | def app_ctx(app): 244 | with app.app_context() as ctx: 245 | yield ctx 246 | ``` 247 | 248 | Since you probably won't need to access the `ctx` value, you can depend on the 249 | fixture using a mark instead of an argument. 250 | 251 | ```python 252 | from datetime import datetime, timedelta, UTC 253 | import pytest 254 | from project import db, User 255 | 256 | @pytest.mark.usefixtures("app_ctx") 257 | def test_deactivate_old_users(): 258 | db.session.add(User(active=True, last_seen=datetime.now(UTC) - timedelta(days=32))) 259 | db.session.commit() 260 | # before running the deactivate job, there is one active user 261 | assert len(db.session.scalars(User).where(User.active).all()) == 1 262 | User.deactivate_old_users() # a method you wrote 263 | # there are no longer any active users 264 | assert len(db.session.scalars(User).where(User.active).all()) == 0 265 | ``` 266 | -------------------------------------------------------------------------------- /examples/blog/.flaskenv: -------------------------------------------------------------------------------- 1 | FLASK_APP=flaskr 2 | -------------------------------------------------------------------------------- /examples/blog/.gitignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | __pycache__/ 3 | .coverage 4 | htmlcov/ 5 | instance/ 6 | -------------------------------------------------------------------------------- /examples/blog/LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Pallets 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/blog/README.md: -------------------------------------------------------------------------------- 1 | # Flask Tutorial App 2 | 3 | The basic blog app built in the Flask [tutorial]. Modified to use 4 | Flask-SQLAlchemy-Lite and Flask-Alembic. 5 | 6 | [tutorial]: https://flask.palletsprojects.com/tutorial/ 7 | 8 | 9 | ## Install 10 | 11 | Clone the repository and move into the project folder: 12 | 13 | ``` 14 | $ git clone https://github.com/pallets-eco/flask-sqlalchemy-lite 15 | $ cd flask-sqlalchemy-lite/examples/blog 16 | ``` 17 | 18 | Create a virtualenv and activate it: 19 | 20 | ``` 21 | $ python3 -m venv .venv 22 | $ . .venv/bin/activate 23 | ``` 24 | 25 | Or on Windows: 26 | 27 | ``` 28 | $ py -m venv .venv 29 | $ .venv\Scripts\activate 30 | ``` 31 | 32 | Install the project and its dev dependencies: 33 | 34 | ``` 35 | $ pip install -r requirements/dev.txt && pip install -e . 36 | ``` 37 | 38 | ## Run 39 | 40 | ``` 41 | $ flask db upgrade 42 | $ flask run --debug 43 | ``` 44 | 45 | Open in a browser. 46 | 47 | 48 | ## Test 49 | 50 | ``` 51 | $ coverage run -m pytest 52 | $ coverage report 53 | $ coverage html # open htmlcov/index.html in a browser 54 | ``` 55 | -------------------------------------------------------------------------------- /examples/blog/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "flaskr" 3 | version = "1.0.0" 4 | description = "The basic blog app built in the Flask tutorial." 5 | readme = "README.md" 6 | license = {file = "LICENSE.txt"} 7 | maintainers = [{name = "Pallets", email = "contact@palletsprojects.com"}] 8 | classifiers = ["Private :: Do Not Upload"] 9 | dependencies = [ 10 | "flask", 11 | "flask-alembic", 12 | "flask-sqlalchemy-lite", 13 | ] 14 | 15 | [build-system] 16 | requires = ["flit_core<4"] 17 | build-backend = "flit_core.buildapi" 18 | 19 | [tool.pytest.ini_options] 20 | testpaths = ["tests"] 21 | filterwarnings = ["error"] 22 | 23 | [tool.coverage.run] 24 | source = ["flaskr", "tests"] 25 | branch = true 26 | 27 | [tool.mypy] 28 | files = ["src/flaskr", "tests"] 29 | show_error_codes = true 30 | pretty = true 31 | strict = true 32 | 33 | [tool.pyright] 34 | include = ["src/flaskr", "tests"] 35 | typeCheckingMode = "basic" 36 | -------------------------------------------------------------------------------- /examples/blog/requirements/base.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile ../pyproject.toml -o base.txt 3 | alembic==1.14.0 4 | # via flask-alembic 5 | asgiref==3.8.1 6 | # via flask 7 | blinker==1.9.0 8 | # via flask 9 | click==8.1.7 10 | # via flask 11 | flask==3.0.3 12 | # via 13 | # flaskr (../pyproject.toml) 14 | # flask-alembic 15 | # flask-sqlalchemy-lite 16 | flask-alembic==3.1.1 17 | # via flaskr (../pyproject.toml) 18 | flask-sqlalchemy-lite==0.1.0 19 | # via flaskr (../pyproject.toml) 20 | greenlet==3.1.1 21 | # via sqlalchemy 22 | itsdangerous==2.2.0 23 | # via flask 24 | jinja2==3.1.4 25 | # via flask 26 | mako==1.3.6 27 | # via alembic 28 | markupsafe==3.0.2 29 | # via 30 | # jinja2 31 | # mako 32 | # werkzeug 33 | sqlalchemy==2.0.36 34 | # via 35 | # alembic 36 | # flask-alembic 37 | # flask-sqlalchemy-lite 38 | typing-extensions==4.12.2 39 | # via 40 | # alembic 41 | # sqlalchemy 42 | werkzeug==3.1.3 43 | # via flask 44 | -------------------------------------------------------------------------------- /examples/blog/requirements/dev.in: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | coverage 3 | mypy 4 | pytest 5 | python-dotenv 6 | sqlalchemy-utils 7 | watchdog 8 | -------------------------------------------------------------------------------- /examples/blog/requirements/dev.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile dev.in -o dev.txt 3 | alembic==1.14.0 4 | # via 5 | # -r base.txt 6 | # flask-alembic 7 | asgiref==3.8.1 8 | # via 9 | # -r base.txt 10 | # flask 11 | blinker==1.9.0 12 | # via 13 | # -r base.txt 14 | # flask 15 | click==8.1.7 16 | # via 17 | # -r base.txt 18 | # flask 19 | coverage==7.6.4 20 | # via -r dev.in 21 | flask==3.0.3 22 | # via 23 | # -r base.txt 24 | # flask-alembic 25 | # flask-sqlalchemy-lite 26 | flask-alembic==3.1.1 27 | # via -r base.txt 28 | flask-sqlalchemy-lite==0.1.0 29 | # via -r base.txt 30 | greenlet==3.1.1 31 | # via 32 | # -r base.txt 33 | # sqlalchemy 34 | iniconfig==2.0.0 35 | # via pytest 36 | itsdangerous==2.2.0 37 | # via 38 | # -r base.txt 39 | # flask 40 | jinja2==3.1.4 41 | # via 42 | # -r base.txt 43 | # flask 44 | mako==1.3.6 45 | # via 46 | # -r base.txt 47 | # alembic 48 | markupsafe==3.0.2 49 | # via 50 | # -r base.txt 51 | # jinja2 52 | # mako 53 | # werkzeug 54 | mypy==1.13.0 55 | # via -r dev.in 56 | mypy-extensions==1.0.0 57 | # via mypy 58 | packaging==24.2 59 | # via pytest 60 | pluggy==1.5.0 61 | # via pytest 62 | pytest==8.3.3 63 | # via -r dev.in 64 | python-dotenv==1.0.1 65 | # via -r dev.in 66 | sqlalchemy==2.0.36 67 | # via 68 | # -r base.txt 69 | # alembic 70 | # flask-alembic 71 | # flask-sqlalchemy-lite 72 | typing-extensions==4.12.2 73 | # via 74 | # -r base.txt 75 | # alembic 76 | # mypy 77 | # sqlalchemy 78 | watchdog==6.0.0 79 | # via -r dev.in 80 | werkzeug==3.1.3 81 | # via 82 | # -r base.txt 83 | # flask 84 | -------------------------------------------------------------------------------- /examples/blog/src/flaskr/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import sqlalchemy as sa 6 | from flask import Flask 7 | from flask_alembic import Alembic 8 | from sqlalchemy import orm 9 | 10 | from flask_sqlalchemy_lite import SQLAlchemy 11 | 12 | 13 | class Model(orm.DeclarativeBase): 14 | metadata: t.ClassVar[sa.MetaData] = sa.MetaData( 15 | naming_convention={ 16 | "ix": "ix_%(column_0_label)s", 17 | "uq": "uq_%(table_name)s_%(column_0_name)s", 18 | "ck": "ck_%(table_name)s_%(constraint_name)s", 19 | "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", 20 | "pk": "pk_%(table_name)s", 21 | } 22 | ) 23 | 24 | 25 | db: SQLAlchemy = SQLAlchemy() 26 | alembic: Alembic = Alembic(metadatas=Model.metadata) 27 | 28 | 29 | def create_app(test_config: dict[str, t.Any] | None = None) -> Flask: 30 | """Create and configure an instance of the Flask application.""" 31 | app = Flask(__name__) 32 | app.config |= { 33 | # a default secret that should be overridden by instance config 34 | "SECRET_KEY": "dev", 35 | # store the database in the instance folder 36 | "SQLALCHEMY_ENGINES": {"default": "sqlite:///blog.sqlite"}, 37 | } 38 | 39 | if test_config is None: # pragma: no cover 40 | # load config from env vars when not testing 41 | app.config.from_prefixed_env() 42 | else: 43 | # load the test config if passed in 44 | app.testing = True 45 | app.config |= test_config 46 | 47 | # apply the extensions to the app 48 | db.init_app(app) 49 | alembic.init_app(app) 50 | 51 | # apply the blueprints to the app 52 | from flaskr import auth 53 | from flaskr import blog 54 | 55 | app.register_blueprint(auth.bp) 56 | app.register_blueprint(blog.bp) 57 | 58 | # make url_for('index') == url_for('blog.index') 59 | # in another app, you might define a separate main index here with 60 | # app.route, while giving the blog blueprint a url_prefix, but for 61 | # the tutorial the blog will be the main index 62 | app.add_url_rule("/", endpoint="index") 63 | 64 | return app 65 | -------------------------------------------------------------------------------- /examples/blog/src/flaskr/auth.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections.abc as c 4 | import functools 5 | import typing as t 6 | 7 | import sqlalchemy as sa 8 | from flask import Blueprint 9 | from flask import flash 10 | from flask import g 11 | from flask import redirect 12 | from flask import render_template 13 | from flask import request 14 | from flask import session 15 | from flask import url_for 16 | from sqlalchemy import orm 17 | from werkzeug import Response 18 | from werkzeug.security import check_password_hash 19 | from werkzeug.security import generate_password_hash 20 | 21 | from flaskr import db 22 | from flaskr import Model 23 | 24 | if t.TYPE_CHECKING: # pragma: no cover 25 | from flaskr.blog import Post 26 | 27 | F = t.TypeVar("F", bound=c.Callable[..., t.Any]) 28 | 29 | bp: Blueprint = Blueprint("auth", __name__, url_prefix="/auth") 30 | 31 | 32 | class User(Model): 33 | __tablename__ = "user" 34 | id: orm.Mapped[int] = orm.mapped_column(primary_key=True) 35 | username: orm.Mapped[str] = orm.mapped_column(unique=True) 36 | password_hash: orm.Mapped[str] 37 | posts: orm.Mapped[list[Post]] = orm.relationship(back_populates="author") 38 | 39 | def set_password(self, value: str) -> None: 40 | """Store the password as a hash for security.""" 41 | self.password_hash = generate_password_hash(value) 42 | 43 | def check_password(self, value: str) -> bool: 44 | return check_password_hash(self.password_hash, value) 45 | 46 | 47 | def login_required(view: F) -> F: 48 | """View decorator that redirects anonymous users to the login page.""" 49 | 50 | @functools.wraps(view) 51 | def wrapped_view(**kwargs: t.Any) -> t.Any: 52 | if g.user is None: 53 | return redirect(url_for("auth.login")) 54 | 55 | return view(**kwargs) 56 | 57 | return wrapped_view # type: ignore[return-value] 58 | 59 | 60 | @bp.before_app_request 61 | def load_logged_in_user() -> None: 62 | """If a user id is stored in the session, load the user object from 63 | the database into ``g.user``.""" 64 | user_id = session.get("user_id") 65 | 66 | if user_id is None: 67 | g.user = None 68 | else: 69 | g.user = db.session.get(User, user_id) 70 | 71 | 72 | @bp.get("/register") 73 | def register() -> str: 74 | """Show the form to register a new user.""" 75 | return render_template("auth/register.html") 76 | 77 | 78 | @bp.post("/register") 79 | def register_submit() -> Response | str: 80 | """Register a new user. 81 | 82 | Validates that the username is not already taken. Hashes the 83 | password for security. 84 | """ 85 | username = request.form["username"] 86 | password = request.form["password"] 87 | error = None 88 | 89 | if not username: 90 | error = "Username is required." 91 | elif not password: 92 | error = "Password is required." 93 | elif db.session.scalar(sa.select(User).where(User.username == username)): 94 | error = f"User {username} is already registered." 95 | 96 | if error is not None: 97 | flash(error) 98 | return render_template("auth/register.html") 99 | 100 | user = User(username=username) 101 | user.set_password(password) 102 | db.session.add(user) 103 | db.session.commit() 104 | return redirect(url_for("auth.login")) 105 | 106 | 107 | @bp.get("/login") 108 | def login() -> str: 109 | """Show the form to log in a user.""" 110 | return render_template("auth/login.html") 111 | 112 | 113 | @bp.post("/login") 114 | def login_submit() -> Response | str: 115 | """Log in a registered user by adding the user id to the session.""" 116 | username = request.form["username"] 117 | password = request.form["password"] 118 | error = None 119 | user = db.session.scalar(sa.select(User).where(User.username == username)) 120 | 121 | if user is None: 122 | error = "Incorrect username." 123 | elif not user.check_password(password): 124 | error = "Incorrect password." 125 | 126 | if error is not None: 127 | flash(error) 128 | return render_template("auth/login.html") 129 | 130 | # store the user id in a new session and return to the index 131 | session.clear() 132 | session["user_id"] = user.id # type: ignore[union-attr] 133 | return redirect(url_for("index")) 134 | 135 | 136 | @bp.get("/logout") 137 | def logout() -> Response: 138 | """Clear the current session, including the stored user id.""" 139 | session.clear() 140 | return redirect(url_for("index")) 141 | -------------------------------------------------------------------------------- /examples/blog/src/flaskr/blog.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | from datetime import UTC 5 | 6 | import sqlalchemy as sa 7 | from flask import Blueprint 8 | from flask import flash 9 | from flask import g 10 | from flask import redirect 11 | from flask import render_template 12 | from flask import request 13 | from flask import url_for 14 | from sqlalchemy import orm 15 | from werkzeug import Response 16 | from werkzeug.exceptions import abort 17 | 18 | from flaskr import db 19 | from flaskr import Model 20 | from flaskr.auth import login_required 21 | from flaskr.auth import User 22 | 23 | bp: Blueprint = Blueprint("blog", __name__) 24 | 25 | 26 | class Post(Model): 27 | __tablename__ = "post" 28 | id: orm.Mapped[int] = orm.mapped_column(primary_key=True) 29 | author_id: orm.Mapped[int] = orm.mapped_column(sa.ForeignKey("user.id")) 30 | # lazy="joined" means the user is returned with the post in one query 31 | author: orm.Mapped[User] = orm.relationship(lazy="joined", back_populates="posts") 32 | created: orm.Mapped[datetime] = orm.mapped_column(default=lambda: datetime.now(UTC)) 33 | title: orm.Mapped[str] 34 | body: orm.Mapped[str] 35 | 36 | @property 37 | def update_url(self) -> str: 38 | return url_for("blog.update", id=self.id) 39 | 40 | @property 41 | def delete_url(self) -> str: 42 | return url_for("blog.delete", id=self.id) 43 | 44 | 45 | @bp.get("/") 46 | def index() -> str: 47 | """Show all the posts, most recent first.""" 48 | posts = db.session.scalars(sa.select(Post).order_by(Post.created.desc())) 49 | return render_template("blog/index.html", posts=posts) 50 | 51 | 52 | def get_post(id: int, check_author: bool = True) -> Post: 53 | """Get a post and its author by id. 54 | 55 | Checks that the id exists and optionally that the current user is 56 | the author. 57 | 58 | :param id: id of post to get 59 | :param check_author: require the current user to be the author 60 | :raise 404: if a post with the given id doesn't exist 61 | :raise 403: if the current user isn't the author 62 | """ 63 | post = db.session.get(Post, id) 64 | 65 | if post is None: 66 | abort(404, f"Post id {id} doesn't exist.") 67 | 68 | if check_author and post.author != g.user: 69 | abort(403) 70 | 71 | return post 72 | 73 | 74 | @bp.get("/create") 75 | @login_required 76 | def create() -> str: 77 | """Show the create post form.""" 78 | return render_template("blog/create.html") 79 | 80 | 81 | @bp.post("/create") 82 | @login_required 83 | def create_submit() -> Response | str: 84 | """Create a new post for the current user.""" 85 | title = request.form["title"] 86 | body = request.form["body"] 87 | error = None 88 | 89 | if not title: 90 | error = "Title is required." 91 | 92 | if error is not None: 93 | flash(error) 94 | return render_template("blog/create.html") 95 | 96 | db.session.add(Post(title=title, body=body, author=g.user)) 97 | db.session.commit() 98 | return redirect(url_for("blog.index")) 99 | 100 | 101 | @bp.get("//update") 102 | @login_required 103 | def update(id: int) -> str: 104 | """Show the update post form.""" 105 | post = get_post(id) 106 | return render_template("blog/update.html", post=post) 107 | 108 | 109 | @bp.post("//update") 110 | @login_required 111 | def update_submit(id: int) -> Response | str: 112 | """Update a post if the current user is the author.""" 113 | post = get_post(id) 114 | title = request.form["title"] 115 | body = request.form["body"] 116 | error = None 117 | 118 | if not title: 119 | error = "Title is required." 120 | 121 | if error is not None: 122 | flash(error) 123 | return render_template("blog/update.html", post=post) 124 | 125 | post.title = title 126 | post.body = body 127 | db.session.commit() 128 | return redirect(url_for("blog.index")) 129 | 130 | 131 | @bp.post("//delete") 132 | @login_required 133 | def delete(id: int) -> Response: 134 | """Delete a post. 135 | 136 | Ensures that the post exists and that the logged-in user is the 137 | author of the post. 138 | """ 139 | db.session.delete(get_post(id)) 140 | db.session.commit() 141 | return redirect(url_for("blog.index")) 142 | -------------------------------------------------------------------------------- /examples/blog/src/flaskr/migrations/1731180753_init.py: -------------------------------------------------------------------------------- 1 | """init 2 | 3 | Revision ID: 1731180753 4 | Revises: 5 | Create Date: 2024-11-09 11:32:33.542384 6 | """ 7 | 8 | import sqlalchemy as sa 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision: str = "1731180753" 13 | down_revision: str | None = None 14 | branch_labels: str | tuple[str, ...] | None = ("default",) 15 | depends_on: str | tuple[str, ...] | None = None 16 | 17 | 18 | def upgrade() -> None: 19 | # ### commands auto generated by Alembic - please adjust! ### 20 | op.create_table( 21 | "user", 22 | sa.Column("id", sa.Integer(), nullable=False), 23 | sa.Column("username", sa.String(), nullable=False), 24 | sa.Column("password_hash", sa.String(), nullable=False), 25 | sa.PrimaryKeyConstraint("id", name=op.f("pk_user")), 26 | sa.UniqueConstraint("username", name=op.f("uq_user_username")), 27 | ) 28 | op.create_table( 29 | "post", 30 | sa.Column("id", sa.Integer(), nullable=False), 31 | sa.Column("author_id", sa.Integer(), nullable=False), 32 | sa.Column("created", sa.DateTime(), nullable=False), 33 | sa.Column("title", sa.String(), nullable=False), 34 | sa.Column("body", sa.String(), nullable=False), 35 | sa.ForeignKeyConstraint( 36 | ["author_id"], ["user.id"], name=op.f("fk_post_author_id_user") 37 | ), 38 | sa.PrimaryKeyConstraint("id", name=op.f("pk_post")), 39 | ) 40 | # ### end Alembic commands ### 41 | 42 | 43 | def downgrade() -> None: 44 | # ### commands auto generated by Alembic - please adjust! ### 45 | op.drop_table("post") 46 | op.drop_table("user") 47 | # ### end Alembic commands ### 48 | -------------------------------------------------------------------------------- /examples/blog/src/flaskr/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | """ 7 | import sqlalchemy as sa 8 | from alembic import op 9 | ${imports if imports else ""} 10 | # revision identifiers, used by Alembic. 11 | revision: str = ${repr(up_revision)} 12 | down_revision: str | tuple[str, ...] | None = ${repr(down_revision)} 13 | branch_labels: str | tuple[str, ...] | None = ${repr(branch_labels)} 14 | depends_on: str | tuple[str, ...] | None = ${repr(depends_on)} 15 | 16 | 17 | def upgrade() -> None: 18 | ${upgrades if upgrades else "pass"} 19 | 20 | 21 | def downgrade() -> None: 22 | ${downgrades if downgrades else "pass"} 23 | -------------------------------------------------------------------------------- /examples/blog/src/flaskr/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pallets-eco/flask-sqlalchemy-lite/4dc26ca6575a83a647acb690d7fc9b0571a34262/examples/blog/src/flaskr/py.typed -------------------------------------------------------------------------------- /examples/blog/src/flaskr/static/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: sans-serif; 3 | background: #eee; 4 | padding: 1rem; 5 | } 6 | 7 | body { 8 | max-width: 960px; 9 | margin: 0 auto; 10 | background: white; 11 | } 12 | 13 | h1, h2, h3, h4, h5, h6 { 14 | font-family: serif; 15 | color: #377ba8; 16 | margin: 1rem 0; 17 | } 18 | 19 | a { 20 | color: #377ba8; 21 | } 22 | 23 | hr { 24 | border: none; 25 | border-top: 1px solid lightgray; 26 | } 27 | 28 | nav { 29 | background: lightgray; 30 | display: flex; 31 | align-items: center; 32 | padding: 0 0.5rem; 33 | } 34 | 35 | nav h1 { 36 | flex: auto; 37 | margin: 0; 38 | } 39 | 40 | nav h1 a { 41 | text-decoration: none; 42 | padding: 0.25rem 0.5rem; 43 | } 44 | 45 | nav ul { 46 | display: flex; 47 | list-style: none; 48 | margin: 0; 49 | padding: 0; 50 | } 51 | 52 | nav ul li a, nav ul li span, header .action { 53 | display: block; 54 | padding: 0.5rem; 55 | } 56 | 57 | .content { 58 | padding: 0 1rem 1rem; 59 | } 60 | 61 | .content > header { 62 | border-bottom: 1px solid lightgray; 63 | display: flex; 64 | align-items: flex-end; 65 | } 66 | 67 | .content > header h1 { 68 | flex: auto; 69 | margin: 1rem 0 0.25rem 0; 70 | } 71 | 72 | .flash { 73 | margin: 1em 0; 74 | padding: 1em; 75 | background: #cae6f6; 76 | border: 1px solid #377ba8; 77 | } 78 | 79 | .post > header { 80 | display: flex; 81 | align-items: flex-end; 82 | font-size: 0.85em; 83 | } 84 | 85 | .post > header > div:first-of-type { 86 | flex: auto; 87 | } 88 | 89 | .post > header h1 { 90 | font-size: 1.5em; 91 | margin-bottom: 0; 92 | } 93 | 94 | .post .about { 95 | color: slategray; 96 | font-style: italic; 97 | } 98 | 99 | .post .body { 100 | white-space: pre-line; 101 | } 102 | 103 | .content:last-child { 104 | margin-bottom: 0; 105 | } 106 | 107 | .content form { 108 | margin: 1em 0; 109 | display: flex; 110 | flex-direction: column; 111 | } 112 | 113 | .content label { 114 | font-weight: bold; 115 | margin-bottom: 0.5em; 116 | } 117 | 118 | .content input, .content textarea { 119 | margin-bottom: 1em; 120 | } 121 | 122 | .content textarea { 123 | min-height: 12em; 124 | resize: vertical; 125 | } 126 | 127 | input.danger { 128 | color: #cc2f2e; 129 | } 130 | 131 | input[type=submit] { 132 | align-self: start; 133 | min-width: 10em; 134 | } 135 | -------------------------------------------------------------------------------- /examples/blog/src/flaskr/templates/auth/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block header %} 4 |

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

5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 | 10 | 11 | 12 | 13 | 14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /examples/blog/src/flaskr/templates/auth/register.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block header %} 4 |

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

5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 | 10 | 11 | 12 | 13 | 14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /examples/blog/src/flaskr/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | {% block title %}{% endblock %} - Flaskr 3 | 4 | 16 |
17 |
18 | {% block header %}{% endblock %} 19 |
20 | {% for message in get_flashed_messages() %} 21 |
{{ message }}
22 | {% endfor %} 23 | {% block content %}{% endblock %} 24 |
25 | -------------------------------------------------------------------------------- /examples/blog/src/flaskr/templates/blog/create.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block header %} 4 |

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

5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 | 10 | 11 | 12 | 13 | 14 |
15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /examples/blog/src/flaskr/templates/blog/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block header %} 4 |

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

5 | {% if g.user %} 6 | New 7 | {% endif %} 8 | {% endblock %} 9 | 10 | {% block content %} 11 | {% for post in posts %} 12 |
13 |
14 |
15 |

{{ post.title }}

16 |
by {{ post.author.username }} on {{ post.created.strftime("%Y-%m-%d") }}
17 |
18 | {% if g.user == post.author %} 19 | Edit 20 | {% endif %} 21 |
22 |

{{ post.body }}

23 |
24 | {% if not loop.last %} 25 |
26 | {% endif %} 27 | {% endfor %} 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /examples/blog/src/flaskr/templates/blog/update.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block header %} 4 |

{% block title %}Edit "{{ post.title }}"{% endblock %}

5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 | 18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /examples/blog/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections.abc as c 4 | from datetime import datetime 5 | from datetime import UTC 6 | 7 | import pytest 8 | from flask import Flask 9 | from flask.testing import FlaskClient 10 | from flaskr import create_app 11 | from flaskr import db 12 | from flaskr import Model 13 | from flaskr.auth import User 14 | from flaskr.blog import Post 15 | from werkzeug.test import TestResponse 16 | 17 | 18 | @pytest.fixture 19 | def app() -> c.Iterator[Flask]: 20 | # create the app with test config 21 | app = create_app({"SQLALCHEMY_ENGINES": {"default": "sqlite://"}}) 22 | 23 | # create the database and load test data 24 | with app.app_context(): 25 | Model.metadata.create_all(db.engine) 26 | user1 = User(username="test") 27 | user1.set_password("test") 28 | db.session.add(user1) 29 | user2 = User(username="other") 30 | user2.set_password("other") 31 | db.session.add(user2) 32 | db.session.add( 33 | Post( 34 | title="test title", 35 | body="test\nbody", 36 | author=user1, 37 | created=datetime(2018, 1, 1, tzinfo=UTC), 38 | ) 39 | ) 40 | db.session.commit() 41 | 42 | yield app 43 | 44 | with app.app_context(): 45 | db.engine.dispose() 46 | 47 | 48 | @pytest.fixture 49 | def client(app: Flask) -> FlaskClient: 50 | """A test client for the app.""" 51 | return app.test_client() 52 | 53 | 54 | class AuthActions: 55 | def __init__(self, client: FlaskClient) -> None: 56 | self._client = client 57 | 58 | def login(self, username: str = "test", password: str = "test") -> TestResponse: 59 | return self._client.post( 60 | "/auth/login", data={"username": username, "password": password} 61 | ) 62 | 63 | def logout(self) -> TestResponse: 64 | return self._client.get("/auth/logout") 65 | 66 | 67 | @pytest.fixture 68 | def auth(client: FlaskClient) -> AuthActions: 69 | return AuthActions(client) 70 | -------------------------------------------------------------------------------- /examples/blog/tests/test_auth.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import pytest 6 | import sqlalchemy as sa 7 | from flask import Flask 8 | from flask import g 9 | from flask import session 10 | from flask.testing import FlaskClient 11 | from flaskr import db 12 | from flaskr.auth import User 13 | 14 | if t.TYPE_CHECKING: # pragma: no cover 15 | from conftest import AuthActions 16 | 17 | 18 | def test_register(app: Flask, client: FlaskClient) -> None: 19 | # test that viewing the page renders without template errors 20 | assert client.get("/auth/register").status_code == 200 21 | 22 | # test that successful registration redirects to the login page 23 | response = client.post("/auth/register", data={"username": "a", "password": "a"}) 24 | assert response.headers["Location"] == "/auth/login" 25 | 26 | # test that the user was inserted into the database 27 | with app.app_context(): 28 | user = db.session.scalar(sa.select(User).where(User.username == "a")) 29 | assert user is not None 30 | 31 | 32 | @pytest.mark.parametrize( 33 | ("username", "password", "message"), 34 | [ 35 | ("", "", "Username is required."), 36 | ("a", "", "Password is required."), 37 | ("test", "test", "already registered"), 38 | ], 39 | ) 40 | def test_register_validate_input( 41 | client: FlaskClient, username: str, password: str, message: str 42 | ) -> None: 43 | response = client.post( 44 | "/auth/register", data={"username": username, "password": password} 45 | ) 46 | assert message in response.text 47 | 48 | 49 | def test_login(client: FlaskClient, auth: AuthActions) -> None: 50 | # test that viewing the page renders without template errors 51 | assert client.get("/auth/login").status_code == 200 52 | 53 | # test that successful login redirects to the index page 54 | response = auth.login() 55 | assert response.headers["Location"] == "/" 56 | 57 | # login request set the user_id in the session 58 | # check that the user is loaded from the session 59 | with client: 60 | client.get("/") 61 | assert session["user_id"] == 1 62 | assert g.user.username == "test" 63 | 64 | 65 | @pytest.mark.parametrize( 66 | ("username", "password", "message"), 67 | [ 68 | ("a", "test", "Incorrect username."), 69 | ("test", "a", "Incorrect password."), 70 | ], 71 | ) 72 | def test_login_validate_input( 73 | auth: AuthActions, username: str, password: str, message: str 74 | ) -> None: 75 | response = auth.login(username, password) 76 | assert message in response.text 77 | 78 | 79 | def test_logout(client: FlaskClient, auth: AuthActions) -> None: 80 | auth.login() 81 | 82 | with client: 83 | auth.logout() 84 | assert "user_id" not in session 85 | -------------------------------------------------------------------------------- /examples/blog/tests/test_blog.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import pytest 6 | import sqlalchemy as sa 7 | from flask import Flask 8 | from flask.testing import FlaskClient 9 | from flaskr import db 10 | from flaskr.blog import Post 11 | 12 | if t.TYPE_CHECKING: # pragma: no cover 13 | from conftest import AuthActions 14 | 15 | 16 | def test_index(client: FlaskClient, auth: AuthActions) -> None: 17 | response = client.get("/") 18 | assert "Log In" in response.text 19 | assert "Register" in response.text 20 | 21 | auth.login() 22 | response = client.get("/") 23 | assert "test title" in response.text 24 | assert "by test on 2018-01-01" in response.text 25 | assert "test\nbody" in response.text 26 | assert 'href="/1/update"' in response.text 27 | 28 | 29 | @pytest.mark.parametrize("path", ["/create", "/1/update", "/1/delete"]) 30 | def test_login_required(client: FlaskClient, path: str) -> None: 31 | response = client.post(path) 32 | assert response.headers["Location"] == "/auth/login" 33 | 34 | 35 | def test_author_required(app: Flask, client: FlaskClient, auth: AuthActions) -> None: 36 | # change the post author to another user 37 | with app.app_context(): 38 | post = db.session.get_one(Post, 1) 39 | post.author_id = 2 40 | db.session.commit() 41 | 42 | auth.login() 43 | # current user can't modify other user's post 44 | assert client.post("/1/update").status_code == 403 45 | assert client.post("/1/delete").status_code == 403 46 | # current user doesn't see edit link 47 | assert 'href="/1/update"' not in client.get("/").text 48 | 49 | 50 | @pytest.mark.parametrize("path", ["/2/update", "/2/delete"]) 51 | def test_exists_required(client: FlaskClient, auth: AuthActions, path: str) -> None: 52 | auth.login() 53 | assert client.post(path).status_code == 404 54 | 55 | 56 | def test_create(app: Flask, client: FlaskClient, auth: AuthActions) -> None: 57 | auth.login() 58 | assert client.get("/create").status_code == 200 59 | client.post("/create", data={"title": "created", "body": ""}) 60 | 61 | with app.app_context(): 62 | count = db.session.scalar(sa.select(sa.func.count(Post.id))) 63 | assert count == 2 64 | 65 | 66 | def test_update(app: Flask, client: FlaskClient, auth: AuthActions) -> None: 67 | auth.login() 68 | assert client.get("/1/update").status_code == 200 69 | client.post("/1/update", data={"title": "updated", "body": ""}) 70 | 71 | with app.app_context(): 72 | post = db.session.get_one(Post, 1) 73 | assert post.title == "updated" 74 | 75 | 76 | @pytest.mark.parametrize("path", ["/create", "/1/update"]) 77 | def test_create_update_validate( 78 | client: FlaskClient, auth: AuthActions, path: str 79 | ) -> None: 80 | auth.login() 81 | response = client.post(path, data={"title": "", "body": ""}) 82 | assert "Title is required." in response.text 83 | 84 | 85 | def test_delete(app: Flask, client: FlaskClient, auth: AuthActions) -> None: 86 | auth.login() 87 | response = client.post("/1/delete") 88 | assert response.headers["Location"] == "/" 89 | 90 | with app.app_context(): 91 | post = db.session.get(Post, 1) 92 | assert post is None 93 | -------------------------------------------------------------------------------- /examples/todo/.flaskenv: -------------------------------------------------------------------------------- 1 | FLASK_APP=app 2 | -------------------------------------------------------------------------------- /examples/todo/README.md: -------------------------------------------------------------------------------- 1 | # Basic Todo List App 2 | 3 | A very basic todo list app, with only the minimal structure needed for a Flask 4 | and Flask-SQLAlchemy-Lite app. 5 | 6 | ## Install 7 | 8 | Clone the repository and move into the project folder: 9 | 10 | ``` 11 | $ git clone https://github.com/pallets-eco/flask-sqlalchemy-lite 12 | $ cd flask-sqlalchemy-lite/examples/todo 13 | ``` 14 | 15 | Create a virtualenv and activate it: 16 | 17 | ``` 18 | $ python3 -m venv .venv 19 | $ . .venv/bin/activate 20 | ``` 21 | 22 | Or on Windows: 23 | 24 | ``` 25 | $ py -m venv .venv 26 | $ .venv\Scripts\activate 27 | ``` 28 | 29 | Install the extension: 30 | 31 | ``` 32 | $ pip install flask-sqlalchemy-lite 33 | ``` 34 | 35 | ## Run 36 | 37 | ``` 38 | $ flask -A app run --debug 39 | ``` 40 | 41 | Open in a browser. 42 | -------------------------------------------------------------------------------- /examples/todo/app.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from datetime import UTC 3 | 4 | import sqlalchemy as sa 5 | from flask import flash 6 | from flask import Flask 7 | from flask import redirect 8 | from flask import render_template 9 | from flask import request 10 | from flask import url_for 11 | from sqlalchemy import orm 12 | 13 | from flask_sqlalchemy_lite import SQLAlchemy 14 | 15 | app = Flask(__name__) 16 | app.secret_key = "dev" 17 | app.config["SQLALCHEMY_ENGINES"] = {"default": "sqlite:///todo.sqlite"} 18 | db = SQLAlchemy(app) 19 | 20 | 21 | class Model(orm.DeclarativeBase): 22 | pass 23 | 24 | 25 | def now() -> datetime: 26 | return datetime.now(UTC) 27 | 28 | 29 | class Todo(Model): 30 | __tablename__ = "todo" 31 | id: orm.Mapped[int] = orm.mapped_column(primary_key=True) 32 | title: orm.Mapped[str] 33 | text: orm.Mapped[str] 34 | done: orm.Mapped[bool] = orm.mapped_column(default=False) 35 | pub_date: orm.Mapped[datetime] = orm.mapped_column(default=now) 36 | 37 | 38 | with app.app_context(): 39 | Model.metadata.create_all(db.engine) 40 | 41 | 42 | @app.get("/") 43 | def show_all(): 44 | todos = db.session.scalars(sa.select(Todo).order_by(Todo.pub_date.desc())) 45 | return render_template("show_all.html", todos=todos) 46 | 47 | 48 | @app.get("/new") 49 | def new(): 50 | return render_template("new.html") 51 | 52 | 53 | @app.post("/new") 54 | def submit_new(): 55 | if not request.form["title"]: 56 | flash("Title is required", "error") 57 | elif not request.form["text"]: 58 | flash("Text is required", "error") 59 | else: 60 | todo = Todo(title=request.form["title"], text=request.form["text"]) 61 | db.session.add(todo) 62 | db.session.commit() 63 | flash("Todo item was successfully created") 64 | return redirect(url_for("show_all")) 65 | 66 | return render_template("new.html") 67 | 68 | 69 | @app.post("/update") 70 | def update_done(): 71 | for todo in db.session.execute(sa.select(Todo)).scalars(): 72 | todo.done = f"done.{todo.id}" in request.form 73 | 74 | flash("Updated status") 75 | db.session.commit() 76 | return redirect(url_for("show_all")) 77 | -------------------------------------------------------------------------------- /examples/todo/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | Todo 3 |

Todo

4 | {%- for category, message in get_flashed_messages(with_categories=true) %} 5 |

6 | {{ "Error: " if category == 'error' }}{{ message }} 7 | {%- endfor %} 8 | {% block body %}{% endblock %} 9 | -------------------------------------------------------------------------------- /examples/todo/templates/new.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 |

New Item

4 |
5 |

6 | 9 |

10 | 13 |

14 |

15 | Back to list 16 |

17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /examples/todo/templates/show_all.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 |

All Items

4 |
5 | 6 | 7 | 8 | 13 | {%- for todo in todos %} 14 | 15 | 20 | 21 | {%- endfor %} 22 | 23 | 24 |
# 9 | Title 10 | Date 11 | Done? 12 |
{{ todo.id }} 16 | {{ todo.title }} 17 | {{ todo.pub_date.strftime("%Y-%m-%d %H:%M") }} 18 | 19 |
{{ todo.text }}
New 25 | 26 |
27 |
28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "Flask-SQLAlchemy-Lite" 3 | version = "0.1.0" 4 | description = "Integrate SQLAlchemy with Flask." 5 | readme = "README.md" 6 | license = "MIT" 7 | license-files = ["LICENSE.txt"] 8 | maintainers = [{ name = "Pallets", email = "contact@palletsprojects.com" }] 9 | classifiers = [ 10 | "Development Status :: 4 - Beta", 11 | "Framework :: Flask", 12 | "Programming Language :: Python", 13 | "Typing :: Typed", 14 | ] 15 | requires-python = ">=3.9" 16 | dependencies = [ 17 | "flask[async]", 18 | "sqlalchemy[asyncio]", 19 | ] 20 | 21 | [project.urls] 22 | Donate = "https://palletsprojects.com/donate" 23 | Documentation = "https://flask-sqlalchemy-lite.readthedocs.io" 24 | Changes = "https://flask-sqlalchemy-lite.readthedocs.io/page/changes/" 25 | Source = "https://github.com/pallets-eco/flask-sqlalchemy-lite/" 26 | Chat = "https://discord.gg/pallets" 27 | 28 | [dependency-groups] 29 | dev = [ 30 | "ruff", 31 | "tox", 32 | "tox-uv", 33 | ] 34 | docs = [ 35 | "furo", 36 | "myst-parser", 37 | "sphinx", 38 | "sphinxcontrib-log-cabinet", 39 | ] 40 | docs-auto = [ 41 | "sphinx-autobuild", 42 | ] 43 | gha-update = [ 44 | "gha-update ; python_full_version >= '3.12'", 45 | ] 46 | pre-commit = [ 47 | "pre-commit", 48 | "pre-commit-uv", 49 | ] 50 | tests = [ 51 | "aiosqlite", 52 | "coverage", 53 | "pytest", 54 | ] 55 | typing = [ 56 | "mypy", 57 | "pyright", 58 | ] 59 | 60 | [build-system] 61 | requires = ["flit-core<4"] 62 | build-backend = "flit_core.buildapi" 63 | 64 | [tool.flit.module] 65 | name = "flask_sqlalchemy_lite" 66 | 67 | [tool.uv] 68 | default-groups = ["dev", "pre-commit", "tests", "typing"] 69 | 70 | [tool.pytest.ini_options] 71 | testpaths = ["tests"] 72 | filterwarnings = ["error"] 73 | 74 | [tool.coverage.run] 75 | branch = true 76 | source = ["flask_sqlalchemy_lite", "tests"] 77 | 78 | [tool.coverage.paths] 79 | source = ["src", "*/site-packages"] 80 | 81 | [tool.coverage.report] 82 | exclude_also = [ 83 | "if t.TYPE_CHECKING", 84 | "raise NotImplementedError", 85 | ": \\.{3}", 86 | ] 87 | 88 | [tool.mypy] 89 | python_version = "3.9" 90 | files = ["src", "tests"] 91 | show_error_codes = true 92 | pretty = true 93 | strict = true 94 | 95 | [tool.pyright] 96 | pythonVersion = "3.9" 97 | include = ["src", "tests"] 98 | typeCheckingMode = "standard" 99 | 100 | [tool.ruff] 101 | src = ["src"] 102 | fix = true 103 | show-fixes = true 104 | output-format = "full" 105 | 106 | [tool.ruff.lint] 107 | select = [ 108 | "B", # flake8-bugbear 109 | "E", # pycodestyle error 110 | "F", # pyflakes 111 | "I", # isort 112 | "UP", # pyupgrade 113 | "W", # pycodestyle warning 114 | ] 115 | ignore = [ 116 | "UP038", # keep isinstance tuple 117 | ] 118 | 119 | [tool.ruff.lint.isort] 120 | force-single-line = true 121 | order-by-type = false 122 | 123 | [tool.gha-update] 124 | tag-only = [ 125 | "slsa-framework/slsa-github-generator", 126 | ] 127 | 128 | [tool.tox] 129 | env_list = [ 130 | "py3.13", "py3.12", "py3.11", "py3.10", "py3.9", 131 | "style", 132 | "typing", 133 | "docs", 134 | ] 135 | 136 | [tool.tox.env_run_base] 137 | description = "pytest on latest dependency versions" 138 | runner = "uv-venv-lock-runner" 139 | package = "wheel" 140 | wheel_build_env = ".pkg" 141 | constrain_package_deps = true 142 | use_frozen_constraints = true 143 | dependency_groups = ["tests"] 144 | commands = [[ 145 | "pytest", "-v", "--tb=short", "--basetemp={env_tmp_dir}", 146 | { replace = "posargs", default = [], extend = true }, 147 | ]] 148 | 149 | [tool.tox.env.style] 150 | description = "run all pre-commit hooks on all files" 151 | dependency_groups = ["pre-commit"] 152 | skip_install = true 153 | commands = [["pre-commit", "run", "--all-files"]] 154 | 155 | [tool.tox.env.typing] 156 | description = "run static type checkers" 157 | dependency_groups = ["tests", "typing"] 158 | commands = [ 159 | ["mypy"], 160 | ["pyright"], 161 | ["pyright", "--verifytypes", "flask_sqlalchemy_lite", "--ignoreexternal"], 162 | ] 163 | 164 | [tool.tox.env.docs] 165 | description = "build docs" 166 | dependency_groups = ["docs"] 167 | commands = [["sphinx-build", "-E", "-W", "-b", "dirhtml", "docs", "docs/_build/dirhtml"]] 168 | 169 | [tool.tox.env.docs-auto] 170 | description = "continuously rebuild docs and start a local server" 171 | dependency_groups = ["docs", "docs-auto"] 172 | commands = [["sphinx-autobuild", "-W", "-b", "dirhtml", "--watch", "src", "docs", "docs/_build/dirhtml"]] 173 | 174 | [tool.tox.env.update-actions] 175 | description = "update GitHub Actions pins" 176 | labels = ["update"] 177 | dependency_groups = ["gha-update"] 178 | skip_install = true 179 | commands = [["gha-update"]] 180 | 181 | [tool.tox.env.update-pre_commit] 182 | description = "update pre-commit pins" 183 | labels = ["update"] 184 | dependency_groups = ["pre-commit"] 185 | skip_install = true 186 | commands = [["pre-commit", "autoupdate", "--freeze", "-j4"]] 187 | 188 | [tool.tox.env.update-requirements] 189 | description = "update uv lock" 190 | labels = ["update"] 191 | dependency_groups = [] 192 | no_default_groups = true 193 | skip_install = true 194 | commands = [["uv", "lock", { replace = "posargs", default = ["-U"], extend = true }]] 195 | -------------------------------------------------------------------------------- /src/flask_sqlalchemy_lite/__init__.py: -------------------------------------------------------------------------------- 1 | from ._extension import SQLAlchemy 2 | 3 | __all__ = [ 4 | "SQLAlchemy", 5 | ] 6 | -------------------------------------------------------------------------------- /src/flask_sqlalchemy_lite/_cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import sqlalchemy as sa 6 | from flask import current_app 7 | from sqlalchemy.orm.mapper import _all_registries 8 | 9 | 10 | def add_models_to_shell() -> dict[str, t.Any]: 11 | """Adds the ``db`` instance and all model classes to ``flask shell``. Adds 12 | the ``sqlalchemy`` namespace as ``sa``. 13 | """ 14 | out: dict[str, t.Any] = { 15 | m.class_.__name__: m.class_ for r in _all_registries() for m in r.mappers 16 | } 17 | out["db"] = current_app.extensions["sqlalchemy"] 18 | out["sa"] = sa 19 | return out 20 | -------------------------------------------------------------------------------- /src/flask_sqlalchemy_lite/_extension.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | from dataclasses import dataclass 5 | from weakref import WeakKeyDictionary 6 | 7 | import sqlalchemy as sa 8 | import sqlalchemy.ext.asyncio as sa_async 9 | import sqlalchemy.orm as orm 10 | from flask import current_app 11 | from flask import g 12 | from flask.sansio.app import App 13 | 14 | from ._cli import add_models_to_shell 15 | from ._make import _make_engines 16 | from ._make import _make_sessionmaker 17 | 18 | 19 | class SQLAlchemy: 20 | """Manage SQLAlchemy engines and sessions for Flask applications. 21 | 22 | :param app: Call :meth:`init_app` on this Flask application. 23 | :param require_default_engine: Whether to raise an error if a `"default"` 24 | engine is not configured. 25 | :param engine_options: Default arguments passed to 26 | :func:`sqlalchemy.create_engine` for each configured engine. 27 | :param session_options: Arguments to configure :attr:`sessionmaker` with. 28 | """ 29 | 30 | def __init__( 31 | self, 32 | app: App | None = None, 33 | *, 34 | require_default_engine: bool = True, 35 | engine_options: dict[str, t.Any] | None = None, 36 | session_options: dict[str, t.Any] | None = None, 37 | ) -> None: 38 | if engine_options is None: 39 | engine_options = {} 40 | 41 | if session_options is None: 42 | session_options = {} 43 | 44 | self._require_default_engine: bool = require_default_engine 45 | self._engine_options: dict[str, t.Any] = engine_options 46 | self._session_options: dict[str, t.Any] = session_options 47 | self._app_state: WeakKeyDictionary[App, _State] = WeakKeyDictionary() 48 | 49 | if app is not None: 50 | self.init_app(app) 51 | 52 | def init_app(self, app: App) -> None: 53 | """Register the extension on an application, creating engines from its 54 | :attr:`~.Flask.config`. 55 | 56 | :param app: The application to register. 57 | """ 58 | if "sqlalchemy" in app.extensions: 59 | raise RuntimeError( 60 | "A 'SQLAlchemy' extension is already initialized on this app." 61 | " Import and use that instead." 62 | ) 63 | 64 | engines = _make_engines(app, self._engine_options, False) 65 | async_engines = _make_engines(app, self._engine_options, True) 66 | 67 | if self._require_default_engine and not (engines or async_engines): 68 | raise RuntimeError( 69 | "Either 'SQLALCHEMY_ENGINES[\"default\"]' or" 70 | " 'SQLALCHEMY_ASYNC_ENGINES[\"default\"]' must be defined." 71 | ) 72 | 73 | self._app_state[app] = _State( 74 | engines=engines, 75 | sessionmaker=_make_sessionmaker(self._session_options, engines, False), 76 | async_engines=async_engines, 77 | async_sessionmaker=_make_sessionmaker( 78 | self._session_options, async_engines, True 79 | ), 80 | ) 81 | app.extensions["sqlalchemy"] = self 82 | app.teardown_appcontext(_close_sessions) 83 | app.teardown_appcontext(_close_async_sessions) 84 | app.shell_context_processor(add_models_to_shell) 85 | 86 | def _get_state(self) -> _State: 87 | app = current_app._get_current_object() # type: ignore[attr-defined] 88 | 89 | if app not in self._app_state: 90 | raise RuntimeError( 91 | "The current Flask app is not registered with this 'SQLAlchemy'" 92 | " instance. Did you forget to call 'init_app', or did you" 93 | " create multiple 'SQLAlchemy' instances?" 94 | ) 95 | 96 | return self._app_state[app] 97 | 98 | @property 99 | def engines(self) -> dict[str, sa.Engine]: 100 | """The engines associated with the current application.""" 101 | return self._get_state().engines 102 | 103 | def get_engine(self, name: str = "default") -> sa.Engine: 104 | """Get a specific engine associated with the current application. 105 | 106 | The :attr:`engine` attribute is a shortcut for calling this without an 107 | argument to get the default engine. 108 | 109 | :param name: The name associated with the engine. 110 | """ 111 | try: 112 | return self.engines[name] 113 | except KeyError as e: 114 | raise KeyError(f"'SQLALCHEMY_ENGINES[\"{name}\"]' was not defined.") from e 115 | 116 | @property 117 | def engine(self) -> sa.Engine: 118 | """The default engine associated with the current application.""" 119 | return self.get_engine() 120 | 121 | @property 122 | def sessionmaker(self) -> orm.sessionmaker[orm.Session]: 123 | """The session factory configured for the current application. This can 124 | be used to create sessions directly, but they will not be closed 125 | automatically at the end of the application context. Use :attr:`session` 126 | and :meth:`get_session` for that. 127 | 128 | This can also be used to update the session options after 129 | :meth:`init_app`, by calling its 130 | :meth:`~sqlalchemy.orm.sessionmaker.configure` method. 131 | """ 132 | return self._get_state().sessionmaker 133 | 134 | def get_session(self, name: str = "default") -> orm.Session: 135 | """Create a :class:`sqlalchemy.orm.Session` that will be closed at the 136 | end of the application context. Repeated calls with the same name within 137 | the same application context will return the same session. 138 | 139 | The :attr:`session` attribute is a shortcut for calling this without an 140 | argument to get the default session. 141 | 142 | :param name: A unique name for caching the session. 143 | """ 144 | sessions: dict[str, orm.Session] = g.setdefault("_sqlalchemy_sessions", {}) 145 | 146 | if name not in sessions: 147 | sessions[name] = self.sessionmaker() 148 | 149 | return sessions[name] 150 | 151 | @property 152 | def session(self) -> orm.Session: 153 | """The default session for the current application context. It will be 154 | closed when the context ends. 155 | """ 156 | return self.get_session() 157 | 158 | @property 159 | def async_engines(self) -> dict[str, sa_async.AsyncEngine]: 160 | """The async engines associated with the current application.""" 161 | return self._get_state().async_engines 162 | 163 | def get_async_engine(self, name: str = "default") -> sa_async.AsyncEngine: 164 | """Get a specific async engine associated with the current application. 165 | 166 | The :attr:`async_engine` attribute is a shortcut for calling this without 167 | an argument to get the default engine. 168 | 169 | :param name: The name associated with the engine. 170 | """ 171 | try: 172 | return self.async_engines[name] 173 | except KeyError as e: 174 | raise KeyError( 175 | f"'SQLALCHEMY_ASYNC_ENGINES[\"{name}\"]' was not defined." 176 | ) from e 177 | 178 | @property 179 | def async_engine(self) -> sa_async.AsyncEngine: 180 | """The default async engine associated with the current application.""" 181 | return self.get_async_engine() 182 | 183 | @property 184 | def async_sessionmaker(self) -> sa_async.async_sessionmaker[sa_async.AsyncSession]: 185 | """The async session factory configured for the current application. 186 | This can be used to create sessions directly, but they will not be 187 | closed automatically at the end of the application context. This is the 188 | preferred way to use async sessions, by directly creating and managing 189 | them. 190 | 191 | :attr:`async_session` and :meth:`get_async_session` are available to 192 | manage async sessions for the entire application context, but managing 193 | them directly with this is preferred. 194 | 195 | This can also be used to update the session options after 196 | :meth:`init_app`, by calling its 197 | :meth:`~sqlalchemy.ext.asyncio.async_sessionmaker.configure` method. 198 | """ 199 | return self._get_state().async_sessionmaker 200 | 201 | def get_async_session(self, name: str = "default") -> sa_async.AsyncSession: 202 | """Create a :class:`sqlalchemy.ext.asyncio.AsyncSession` that will be 203 | closed at the end of the application context. Repeated calls with the 204 | same name within the same application context will return the same 205 | session. 206 | 207 | The :attr:`async_session` attribute is a shortcut for calling this 208 | without an argument to get the default async session. 209 | 210 | Async sessions are not safe to use across concurrent tasks, as they 211 | represent unguarded mutable state. Use a separate named session for each 212 | task within a single application context, or use 213 | :attr:`async_sessionmaker` to directly control async session lifetime. 214 | 215 | :param name: A unique name for caching the session. 216 | """ 217 | sessions: dict[str, sa_async.AsyncSession] = g.setdefault( 218 | "_sqlalchemy_async_sessions", {} 219 | ) 220 | 221 | if name not in sessions: 222 | sessions[name] = self.async_sessionmaker() 223 | 224 | return sessions[name] 225 | 226 | @property 227 | def async_session(self) -> sa_async.AsyncSession: 228 | """The default async session for the current application context. It 229 | will be closed when the context ends. 230 | """ 231 | return self.get_async_session() 232 | 233 | 234 | @dataclass 235 | class _State: 236 | """The objects associated with one application.""" 237 | 238 | engines: dict[str, t.Any] 239 | sessionmaker: orm.sessionmaker[orm.Session] 240 | async_engines: dict[str, t.Any] 241 | async_sessionmaker: sa_async.async_sessionmaker[sa_async.AsyncSession] 242 | 243 | 244 | def _close_sessions(e: BaseException | None) -> None: 245 | """Close any tracked sessions when the application context ends.""" 246 | sessions: dict[str, orm.Session] = g.pop("_sqlalchemy_sessions", None) 247 | 248 | if sessions is None: 249 | return 250 | 251 | for session in sessions.values(): 252 | session.close() 253 | 254 | 255 | async def _close_async_sessions(e: BaseException | None) -> None: 256 | """Close any tracked async sessions when the application context ends.""" 257 | sessions: dict[str, sa_async.AsyncSession] = g.pop( 258 | "_sqlalchemy_async_sessions", None 259 | ) 260 | 261 | if sessions is None: 262 | return 263 | 264 | for session in sessions.values(): 265 | await session.close() 266 | -------------------------------------------------------------------------------- /src/flask_sqlalchemy_lite/_make.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import typing as t 5 | 6 | import sqlalchemy as sa 7 | from flask.sansio.app import App 8 | from sqlalchemy import orm as orm 9 | from sqlalchemy.ext import asyncio as sa_async 10 | 11 | 12 | @t.overload 13 | def _make_engines( # pragma: no cover 14 | app: App, base: dict[str, t.Any], is_async: t.Literal[False] 15 | ) -> dict[str, sa.Engine]: ... 16 | 17 | 18 | @t.overload 19 | def _make_engines( # pragma: no cover 20 | app: App, base: dict[str, t.Any], is_async: t.Literal[True] 21 | ) -> dict[str, sa_async.AsyncEngine]: ... 22 | 23 | 24 | def _make_engines(app: App, base: dict[str, t.Any], is_async: bool) -> dict[str, t.Any]: 25 | """Create the collection of sync or async engines from app config. 26 | 27 | :param app: The Flask application being registered. 28 | :param base: The default options passed to the extension. 29 | :param is_async: Whether to create sync or async engines. 30 | """ 31 | if not is_async: 32 | config_key = "SQLALCHEMY_ENGINES" 33 | make: t.Callable[..., t.Any] = sa.engine_from_config 34 | else: 35 | config_key = "SQLALCHEMY_ASYNC_ENGINES" 36 | make = sa_async.async_engine_from_config 37 | 38 | engine_configs: dict[str, dict[str, t.Any]] = app.config.get(config_key, {}) 39 | 40 | if not engine_configs: 41 | return {} 42 | 43 | return { 44 | name: make( 45 | _prepare_engine_options(app, f'{config_key}["{name}"]', base, config), 46 | prefix="", 47 | ) 48 | for name, config in engine_configs.items() 49 | } 50 | 51 | 52 | def _prepare_engine_options( 53 | app: App, 54 | config_name: str, 55 | base: dict[str, t.Any], 56 | engine_config: str | sa.URL | dict[str, t.Any], 57 | ) -> dict[str, t.Any]: 58 | """Prepare the arguments to be passed to ``create_engine``. Combine default 59 | and config values, apply backend-specific options, etc. 60 | 61 | :param app: The Flask application being registered. 62 | :param config_name: The name of the engine in the app config. 63 | :param base: The default options passed to the extension. 64 | :param engine_config: The app config for this named engine. 65 | """ 66 | if isinstance(engine_config, (str, sa.URL)): 67 | options = base.copy() 68 | options["url"] = engine_config 69 | elif "url" not in engine_config: 70 | raise RuntimeError(f"'{config_name}[\"url\"]' must be defined.") 71 | else: 72 | options = base | engine_config 73 | 74 | url_value: str | sa.URL | dict[str, t.Any] = options["url"] 75 | 76 | if isinstance(url_value, dict): 77 | url = sa.URL.create(**url_value) 78 | else: 79 | url = sa.make_url(url_value) 80 | 81 | backend = url.get_backend_name() 82 | driver = url.get_driver_name() 83 | 84 | # For certain backends, apply better defaults for a web app. 85 | if backend == "sqlite": 86 | if url.database is None or url.database in {"", ":memory:"}: 87 | # Use a static pool so each connection is to the same in-memory database. 88 | options["poolclass"] = sa.pool.StaticPool 89 | 90 | if driver == "pysqlite": 91 | # Allow sharing the connection across threads for the 92 | # built-in sqlite3 module. 93 | connect_args = options.setdefault("connect_args", {}) 94 | connect_args["check_same_thread"] = False 95 | else: 96 | # The path could be sqlite:///path or sqlite:///file:path?uri=true. 97 | is_uri = url.query.get("uri", False) 98 | 99 | if is_uri: 100 | db_str = url.database[5:] 101 | else: 102 | db_str = url.database 103 | 104 | if not os.path.isabs(db_str): 105 | # Relative paths are relative to the app's instance path. Create 106 | # it if it doesn't exist. 107 | os.makedirs(app.instance_path, exist_ok=True) 108 | db_str = os.path.join(app.instance_path, db_str) 109 | 110 | if is_uri: 111 | db_str = f"file:{db_str}" 112 | 113 | url = url.set(database=db_str) 114 | elif backend == "mysql": # pragma: no branch 115 | # Set queue defaults only when using a queue pool. 116 | # issubclass is used to handle AsyncAdaptedQueuePool as well. 117 | if "poolclass" not in options or issubclass( 118 | options["poolclass"], sa.pool.QueuePool 119 | ): 120 | options.setdefault("pool_recycle", 7200) 121 | 122 | if "charset" not in url.query: 123 | url = url.update_query_dict({"charset": "utf8mb4"}) 124 | 125 | options["url"] = url 126 | return options 127 | 128 | 129 | @t.overload 130 | def _make_sessionmaker( # pragma: no cover 131 | base: dict[str, t.Any], engines: dict[str, sa.Engine], is_async: t.Literal[False] 132 | ) -> orm.sessionmaker[orm.Session]: ... 133 | 134 | 135 | @t.overload 136 | def _make_sessionmaker( # pragma: no cover 137 | base: dict[str, t.Any], 138 | engines: dict[str, sa_async.AsyncEngine], 139 | is_async: t.Literal[True], 140 | ) -> sa_async.async_sessionmaker[sa_async.AsyncSession]: ... 141 | 142 | 143 | def _make_sessionmaker( 144 | base: dict[str, t.Any], engines: dict[str, t.Any], is_async: bool 145 | ) -> t.Any: 146 | """Create the sync or async sessionmaker for the extension. Apply engines 147 | to the ``bind`` and ``binds`` parameters. 148 | 149 | :param base: The default options passed to the extension. 150 | :param engines: The collection of sync or async engines. 151 | :param is_async: Whether to create a sync or async sessionmaker. 152 | """ 153 | if not is_async: 154 | config_key = "SQLALCHEMY_ENGINES" 155 | make: t.Callable[..., t.Any] = orm.sessionmaker 156 | else: 157 | config_key = "SQLALCHEMY_ASYNC_ENGINES" 158 | make = sa_async.async_sessionmaker 159 | 160 | options = base.copy() 161 | 162 | if "default" in engines: 163 | options["bind"] = engines["default"] 164 | 165 | if "binds" in options: 166 | for base, bind in options["binds"].items(): 167 | if isinstance(bind, str): 168 | if bind not in engines: 169 | raise RuntimeError( 170 | f"'{config_key}[\"{bind}\"]' is not defined, but is" 171 | " used in 'session_options[\"binds\"]'." 172 | ) 173 | 174 | options["binds"][base] = engines[bind] 175 | 176 | return make(**options) 177 | -------------------------------------------------------------------------------- /src/flask_sqlalchemy_lite/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pallets-eco/flask-sqlalchemy-lite/4dc26ca6575a83a647acb690d7fc9b0571a34262/src/flask_sqlalchemy_lite/py.typed -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections.abc as cabc 4 | import os 5 | import typing as t 6 | from pathlib import Path 7 | 8 | import pytest 9 | from flask import Flask 10 | from flask.ctx import AppContext 11 | 12 | from flask_sqlalchemy_lite import SQLAlchemy 13 | 14 | 15 | @pytest.fixture 16 | def app(request: pytest.FixtureRequest, tmp_path: Path) -> cabc.Iterator[Flask]: 17 | app = Flask(request.module.__name__, instance_path=os.fspath(tmp_path / "instance")) 18 | app.config |= { 19 | "TESTING": True, 20 | "SQLALCHEMY_ENGINES": {"default": "sqlite://"}, 21 | "SQLALCHEMY_ASYNC_ENGINES": {"default": "sqlite+aiosqlite://"}, 22 | } 23 | yield app 24 | 25 | # If a SQLAlchemy extension was registered, dispose of all its engines to 26 | # avoid ResourceWarning: unclosed sqlite3.Connection. 27 | try: 28 | db: SQLAlchemy = app.extensions["sqlalchemy"] 29 | except KeyError: 30 | pass 31 | else: 32 | with app.app_context(): 33 | engines = db.engines.values() 34 | 35 | for engine in engines: 36 | engine.dispose() 37 | 38 | 39 | @pytest.fixture 40 | def app_ctx(app: Flask) -> t.Iterator[AppContext]: 41 | with app.app_context() as ctx: 42 | yield ctx 43 | 44 | 45 | @pytest.fixture 46 | def db(app: Flask) -> SQLAlchemy: 47 | return SQLAlchemy(app) 48 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | import sqlalchemy.orm as orm 5 | 6 | from flask_sqlalchemy_lite._cli import add_models_to_shell 7 | 8 | 9 | class BaseFirst(orm.DeclarativeBase): 10 | pass 11 | 12 | 13 | class UserFirst(BaseFirst): 14 | __tablename__ = "user" 15 | id: orm.Mapped[int] = orm.mapped_column(primary_key=True) 16 | 17 | 18 | class BaseSecond(orm.DeclarativeBase): 19 | pass 20 | 21 | 22 | class UserSecond(BaseSecond): 23 | __tablename__ = "user" 24 | id: orm.Mapped[int] = orm.mapped_column(primary_key=True) 25 | 26 | 27 | @pytest.mark.usefixtures("app_ctx", "db") 28 | def test_shell_context() -> None: 29 | context = add_models_to_shell() 30 | assert "UserFirst" in context 31 | assert "UserSecond" in context 32 | assert "db" in context 33 | assert "sa" in context 34 | -------------------------------------------------------------------------------- /tests/test_engine.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os.path 4 | import typing as t 5 | from pathlib import Path 6 | from unittest import mock 7 | 8 | import pytest 9 | import sqlalchemy as sa 10 | import sqlalchemy.orm as orm 11 | from flask import Flask 12 | 13 | from flask_sqlalchemy_lite import SQLAlchemy 14 | 15 | 16 | class Base(orm.DeclarativeBase): 17 | pass 18 | 19 | 20 | class Todo(Base): 21 | __tablename__ = "todo" 22 | id: orm.Mapped[int] = orm.mapped_column(primary_key=True) 23 | 24 | 25 | @pytest.mark.usefixtures("app_ctx") 26 | def test_default_engine(db: SQLAlchemy) -> None: 27 | """The default engine is accessible in a few ways.""" 28 | engine = db.engines["default"] 29 | assert db.get_engine() is engine 30 | assert db.engine is engine 31 | 32 | async_engine = db.async_engines["default"] 33 | assert db.get_async_engine() is async_engine 34 | assert db.async_engine is async_engine 35 | 36 | 37 | @pytest.mark.usefixtures("app_ctx") 38 | def test_default_required(app: Flask) -> None: 39 | """An error is raised if no default engine is defined.""" 40 | del app.config["SQLALCHEMY_ENGINES"] 41 | del app.config["SQLALCHEMY_ASYNC_ENGINES"] 42 | 43 | with pytest.raises(RuntimeError, match="must be defined"): 44 | SQLAlchemy(app) 45 | 46 | 47 | @pytest.mark.usefixtures("app_ctx") 48 | def test_only_one_default_required(app: Flask) -> None: 49 | """If a sync default engine is defined, an async one is not required. It 50 | will still raise an error on access. 51 | """ 52 | del app.config["SQLALCHEMY_ASYNC_ENGINES"] 53 | db = SQLAlchemy(app) 54 | 55 | with pytest.raises(KeyError, match="was not defined"): 56 | assert db.async_engine 57 | 58 | with pytest.raises(KeyError, match="was not defined"): 59 | assert db.get_async_engine() 60 | 61 | with pytest.raises(KeyError, match="was not defined"): 62 | assert db.get_async_engine("a") 63 | 64 | 65 | @pytest.mark.usefixtures("app_ctx") 66 | def test_disable_default_required(app: Flask) -> None: 67 | """The requirement of a default engine can be disabled.""" 68 | del app.config["SQLALCHEMY_ENGINES"] 69 | del app.config["SQLALCHEMY_ASYNC_ENGINES"] 70 | SQLAlchemy(app, require_default_engine=False) 71 | 72 | 73 | def test_engines_require_app_context(db: SQLAlchemy) -> None: 74 | """Accessing engines outside an app context raises an error.""" 75 | with pytest.raises(RuntimeError, match="Working outside"): 76 | assert db.engines 77 | 78 | with pytest.raises(RuntimeError, match="Working outside"): 79 | assert db.async_engines 80 | 81 | 82 | @pytest.mark.usefixtures("app_ctx") 83 | def test_engines_require_init() -> None: 84 | """Accessing engines when the current app isn't registered fails.""" 85 | db = SQLAlchemy() 86 | 87 | with pytest.raises(RuntimeError, match="not registered"): 88 | assert db.engines 89 | 90 | with pytest.raises(RuntimeError, match="not registered"): 91 | assert db.async_engines 92 | 93 | 94 | @pytest.mark.usefixtures("db") 95 | def test_init_twice_fails(app: Flask) -> None: 96 | """Registering the same app twice fails.""" 97 | with pytest.raises(RuntimeError, match="already initialized"): 98 | SQLAlchemy(app) 99 | 100 | 101 | @pytest.mark.usefixtures("app_ctx") 102 | def test_multiple_engines(app: Flask) -> None: 103 | """Multiple engines can be configured.""" 104 | app.config["SQLALCHEMY_ENGINES"]["a"] = "sqlite://" 105 | db = SQLAlchemy(app) 106 | assert len(db.engines) == 2 107 | assert str(db.engines["a"].url) == "sqlite://" 108 | 109 | 110 | @pytest.mark.usefixtures("app_ctx") 111 | def test_undefined_engine(db: SQLAlchemy) -> None: 112 | with pytest.raises(KeyError, match="was not defined"): 113 | assert db.get_engine("a") 114 | 115 | 116 | @pytest.mark.usefixtures("app_ctx") 117 | def test_config_engine_options(app: Flask) -> None: 118 | """Engine config can apply options.""" 119 | app.config["SQLALCHEMY_ENGINES"]["default"] = {"url": "sqlite://", "echo": True} 120 | db = SQLAlchemy(app) 121 | assert db.engine.echo 122 | 123 | 124 | @pytest.mark.usefixtures("app_ctx") 125 | def test_init_engine_options(app: Flask) -> None: 126 | """Default engine options can be passed to the extension. Config overrides 127 | default options. 128 | """ 129 | app.config["SQLALCHEMY_ENGINES"] = { 130 | "default": {"url": "sqlite://", "echo": False}, 131 | "a": "sqlite://", 132 | } 133 | db = SQLAlchemy(app, engine_options={"echo": True}) 134 | # init is default 135 | assert db.engines["a"].echo 136 | # config overrides init 137 | assert not db.engine.echo 138 | 139 | 140 | @pytest.mark.usefixtures("app_ctx") 141 | @pytest.mark.parametrize( 142 | "value", 143 | [ 144 | "sqlite://", 145 | sa.engine.URL.create("sqlite"), 146 | {"url": "sqlite://"}, 147 | {"url": sa.engine.URL.create("sqlite")}, 148 | {"url": {"drivername": "sqlite"}}, 149 | ], 150 | ) 151 | def test_url_type(app: Flask, value: str | sa.engine.URL | dict[str, t.Any]) -> None: 152 | """Engine config can be a URL or a dict of options with a 'url' key. A URL can 153 | be a string or URL. Can also be a dict for the 'url' key. 154 | """ 155 | app.config["SQLALCHEMY_ENGINES"]["a"] = value 156 | db = SQLAlchemy(app) 157 | assert str(db.engines["a"].url) == "sqlite://" 158 | 159 | 160 | def test_no_url(app: Flask) -> None: 161 | """Engine config must have 'url' key.""" 162 | app.config["SQLALCHEMY_ENGINES"]["default"] = {} 163 | 164 | with pytest.raises(RuntimeError, match="must be defined"): 165 | SQLAlchemy(app) 166 | 167 | 168 | @pytest.mark.usefixtures("app_ctx") 169 | def test_sqlite_relative_path(app: Flask) -> None: 170 | """SQLite database path is relative to the instance path, and creates the 171 | instance folder. 172 | """ 173 | app.config["SQLALCHEMY_ENGINES"]["default"] = "sqlite:///test.db" 174 | db = SQLAlchemy(app) 175 | Base.metadata.create_all(db.engine) 176 | assert not isinstance(db.engine.pool, sa.pool.StaticPool) 177 | db_path = db.engine.url.database 178 | assert db_path is not None 179 | assert db_path.startswith(app.instance_path) 180 | assert os.path.exists(db_path) 181 | 182 | 183 | @pytest.mark.usefixtures("app_ctx") 184 | def test_sqlite_absolute_path(app: Flask, tmp_path: Path) -> None: 185 | """An absolute SQLite database path is not changed, and does not create the 186 | instance folder. 187 | """ 188 | db_path = os.fspath(tmp_path / "test.db") 189 | app.config["SQLALCHEMY_ENGINES"]["default"] = f"sqlite:///{db_path}" 190 | db = SQLAlchemy(app) 191 | Base.metadata.create_all(db.engine) 192 | assert db.engine.url.database == db_path 193 | assert os.path.exists(db_path) 194 | assert not os.path.exists(app.instance_path) 195 | 196 | 197 | @pytest.mark.usefixtures("app_ctx") 198 | def test_sqlite_driver_level_uri(app: Flask) -> None: 199 | """SQLite database path can use a special syntax, and is relative to the 200 | instance path. 201 | """ 202 | app.config["SQLALCHEMY_ENGINES"]["default"] = "sqlite:///file:test.db?uri=true" 203 | db = SQLAlchemy(app) 204 | Base.metadata.create_all(db.engine) 205 | db_path = db.engine.url.database 206 | assert db_path is not None 207 | assert db_path.startswith(f"file:{app.instance_path}") 208 | assert os.path.exists(db_path[5:]) 209 | 210 | 211 | @mock.patch("sqlalchemy.engine.create.create_engine", autospec=True) 212 | def test_sqlite_memory_defaults(create_engine: mock.Mock, app: Flask) -> None: 213 | """Defaults are applied for the SQLite driver for an in-memory database.""" 214 | SQLAlchemy(app) 215 | assert create_engine.call_args.kwargs["poolclass"] is sa.pool.StaticPool 216 | assert create_engine.call_args.kwargs["connect_args"]["check_same_thread"] is False 217 | 218 | 219 | @mock.patch("sqlalchemy.engine.create.create_engine", autospec=True) 220 | def test_mysql_defaults(create_engine: mock.Mock, app: Flask) -> None: 221 | """Defaults are applied for the MySQL driver.""" 222 | app.config["SQLALCHEMY_ENGINES"]["default"] = "mysql:///test" 223 | SQLAlchemy(app) 224 | assert create_engine.call_args.kwargs["pool_recycle"] == 7200 225 | assert create_engine.call_args.args[0].query["charset"] == "utf8mb4" 226 | 227 | 228 | @mock.patch("sqlalchemy.engine.create.create_engine", autospec=True) 229 | def test_mysql_skip_defaults(create_engine: mock.Mock, app: Flask) -> None: 230 | """Defaults are not applied if they are already set.""" 231 | app.config["SQLALCHEMY_ENGINES"]["default"] = { 232 | "url": "mysql:///test?charset=latin1", 233 | "poolclass": sa.pool.StaticPool, 234 | } 235 | SQLAlchemy(app) 236 | assert "pool_recycle" not in create_engine.call_args.kwargs 237 | assert create_engine.call_args.args[0].query["charset"] == "latin1" 238 | -------------------------------------------------------------------------------- /tests/test_session.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | import sqlalchemy as sa 5 | import sqlalchemy.orm as orm 6 | from flask import Flask 7 | from flask import g 8 | 9 | from flask_sqlalchemy_lite import SQLAlchemy 10 | 11 | 12 | class Base(orm.DeclarativeBase): 13 | pass 14 | 15 | 16 | class Todo(Base): 17 | __tablename__ = "todo" 18 | id: orm.Mapped[int] = orm.mapped_column(primary_key=True) 19 | 20 | 21 | class Base2(orm.DeclarativeBase): 22 | pass 23 | 24 | 25 | class Post(Base2): 26 | __tablename__ = "post" 27 | id: orm.Mapped[int] = orm.mapped_column(primary_key=True) 28 | 29 | 30 | class Like(Base2): 31 | __tablename__ = "like" 32 | id: orm.Mapped[int] = orm.mapped_column(primary_key=True) 33 | 34 | 35 | @pytest.mark.usefixtures("app_ctx") 36 | def test_sessionmaker(db: SQLAlchemy) -> None: 37 | with db.sessionmaker() as session: 38 | assert session.scalar(sa.text("select 1")) == 1 39 | 40 | 41 | def test_sessionmaker_require_app_context(db: SQLAlchemy) -> None: 42 | """Accessing sessionmaker outside an app context raises an error.""" 43 | with pytest.raises(RuntimeError, match="Working outside"): 44 | assert db.sessionmaker 45 | 46 | with pytest.raises(RuntimeError, match="Working outside"): 47 | assert db.async_sessionmaker 48 | 49 | 50 | @pytest.mark.usefixtures("app_ctx") 51 | def test_sessionmaker_require_init(app: Flask) -> None: 52 | """Accessing sessionmaker when the current app isn't registered fails.""" 53 | db = SQLAlchemy() 54 | 55 | with pytest.raises(RuntimeError, match="not registered"): 56 | assert db.sessionmaker 57 | 58 | with pytest.raises(RuntimeError, match="not registered"): 59 | assert db.async_sessionmaker 60 | 61 | 62 | @pytest.mark.usefixtures("app_ctx") 63 | def test_default_session(db: SQLAlchemy) -> None: 64 | """The default session is accessible in a few ways.""" 65 | session = db.get_session() 66 | assert db.get_session() is session 67 | assert db.session is session 68 | 69 | async_session = db.get_async_session() 70 | assert db.get_async_session() is async_session 71 | assert db.async_session is async_session 72 | 73 | 74 | @pytest.mark.usefixtures("app_ctx") 75 | def test_multiple_sessions(db: SQLAlchemy) -> None: 76 | """Multiple sessions can be tracked.""" 77 | other_session = db.get_session("a") 78 | assert db.get_session() is not other_session 79 | assert db.get_session("a") is other_session 80 | 81 | 82 | def test_cleanup_sessions(app: Flask, db: SQLAlchemy) -> None: 83 | """Sessions are tracked and cleaned up with the app context.""" 84 | with app.app_context() as ctx: 85 | default = db.get_session() 86 | other = db.get_session("a") 87 | assert g._sqlalchemy_sessions == {"default": default, "a": other} 88 | async_default = db.get_async_session() 89 | async_other = db.get_async_session("a") 90 | assert g._sqlalchemy_async_sessions == { 91 | "default": async_default, 92 | "a": async_other, 93 | } 94 | 95 | assert "_sqlalchemy_sessions" not in ctx.g 96 | assert "_sqlalchemy_async_sessions" not in ctx.g 97 | 98 | 99 | def test_sessionmaker_configure(app: Flask, db: SQLAlchemy) -> None: 100 | """The sessionmaker can be reconfigured and persists across app contexts.""" 101 | with app.app_context(): 102 | assert db.session.expire_on_commit 103 | 104 | with app.app_context(): 105 | db.sessionmaker.configure(expire_on_commit=False) 106 | assert not db.session.expire_on_commit 107 | 108 | with app.app_context(): 109 | assert not db.session.expire_on_commit 110 | 111 | 112 | @pytest.mark.usefixtures("app_ctx") 113 | def test_session_options(app: Flask) -> None: 114 | """Session options can be passed to the extension.""" 115 | db = SQLAlchemy(app, session_options={"expire_on_commit": False}) 116 | assert not db.session.expire_on_commit 117 | 118 | 119 | @pytest.mark.usefixtures("app_ctx") 120 | def test_session_bind(db: SQLAlchemy) -> None: 121 | """The default bind is the default engine.""" 122 | assert db.session.get_bind() is db.engine 123 | 124 | 125 | @pytest.mark.usefixtures("app_ctx") 126 | def test_session_binds(app: Flask) -> None: 127 | """The binds session option converts strings to engines.""" 128 | app.config["SQLALCHEMY_ENGINES"]["a"] = "sqlite://" 129 | external = sa.create_engine("sqlite://") 130 | db = SQLAlchemy(app, session_options={"binds": {Base2: "a", Like: external}}) 131 | # The default bind is used for no match. 132 | assert db.session.get_bind(Todo) is db.engine 133 | # A string is turned into an engine. 134 | assert db.session.get_bind(Post) is db.get_engine("a") 135 | # An engine is passed through directly. 136 | assert db.session.get_bind(Like) is external 137 | 138 | 139 | def test_session_binds_invalid_engine(app: Flask) -> None: 140 | with pytest.raises(RuntimeError, match="not defined"): 141 | SQLAlchemy(app, session_options={"binds": {Post: "a"}}) 142 | --------------------------------------------------------------------------------