From db0a977547ab84d7fbc3addce8cbace4e6d18ad0 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 29 Jan 2024 16:40:09 +0000 Subject: [PATCH 1/5] docs: add intro and database pages Signed-off-by: Grant Ramsay --- docs/explanation.md | 4 - docs/explanation/database.md | 148 +++++++++++++++++++++++++++++++++++ docs/explanation/intro.md | 31 ++++++++ docs/explanation/main.md | 4 + docs/explanation/models.md | 4 + docs/usage.md | 21 +++-- mkdocs.yml | 13 ++- pyproject.toml | 4 + 8 files changed, 217 insertions(+), 12 deletions(-) delete mode 100644 docs/explanation.md create mode 100644 docs/explanation/database.md create mode 100644 docs/explanation/intro.md create mode 100644 docs/explanation/main.md create mode 100644 docs/explanation/models.md diff --git a/docs/explanation.md b/docs/explanation.md deleted file mode 100644 index c7b1d2a..0000000 --- a/docs/explanation.md +++ /dev/null @@ -1,4 +0,0 @@ -# Code Explanation - -!!! note "Under Construction" - This section is still to be written. diff --git a/docs/explanation/database.md b/docs/explanation/database.md new file mode 100644 index 0000000..e016756 --- /dev/null +++ b/docs/explanation/database.md @@ -0,0 +1,148 @@ +# The Database file (db.py) + +The database setup is fairly straightforward, we will go through it line by +line. + +## Imports + +```python linenums="1" +"""Set up the database connection and session.""" "" +from collections.abc import AsyncGenerator +from typing import Any + +from sqlalchemy import MetaData +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase +``` + +Lines 1 to 11 are the imports. The only thing to note here is that we are using +the `AsyncGenerator` type hint for the `get_db` function. This is because we are +using the `yield` keyword in the function, which makes it a generator. The +`AsyncGenerator` type hint is a special type hint that is used for asynchronous +generators. + +## Database Connection String + +```python linenums="13" +DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres" +# DATABASE_URL = "sqlite+aiosqlite:///./test.db" +``` + +We set a variable to be used later which contains the database URL. We are using +PostgreSQL, but you can use any database that SQLAlchemy supports. The commented +out line is for SQLite, which is a good choice for testing. You can comment out +the PostgreSQL line (**13**)and uncomment the SQLite line (**14**)to use SQLite +instead. + +This is a basic connection string, in reality you would want to use environment +variables to store the user/password and database name. + +## The Base Class + +```python linenums="20" +class Base(DeclarativeBase): + """Base class for SQLAlchemy models. + + All other models should inherit from this class. + """ + + metadata = MetaData( + naming_convention={ + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", + } + ) +``` + +This takes the `DeclarativeBase` class from SQLAlchemy and adds a `metadata` +attribute to it. This is used to define the naming convention for the database +tables. **This is not required**, but it is a good idea to set this up for +consistency. + +We will use this class as the base class for all of our future models. + +## The database engine and session + +```python linenums="37" +async_engine = create_async_engine(DATABASE_URL, echo=False) +``` + +Here on line 37 we create the database engine. The `create_async_engine` +function takes the database URL and returns an engine, the connection to the +database. The `echo` parameter is set to `False` to prevent SQLAlchemy from +outputting all of the SQL commands it is running. Note that it uses the +`DATABASE_URL` variable we set earlier. + +```python linenums="38" +async_session = async_sessionmaker(async_engine, expire_on_commit=False) +``` + +Next, we create the session. The `async_sessionmaker` function takes the engine +and returns a session. The `expire_on_commit` parameter is set to `False` to +prevent SQLAlchemy from expiring objects on commit. This is required for +`asyncpg` to work properly. + +We will NOT use this session directly, instead we will use the `get_db` function +below to get and release a session. + +## The `get_db()` function + +```python linenums="41" +async def get_db() -> AsyncGenerator[AsyncSession, Any]: + """Get a database session. + + To be used for dependency injection. + """ + async with async_session() as session, session.begin(): + yield session +``` + +This function is used to get a database session as a generator function. This +function is used for dependency injection, which is a fancy way of saying that +we will use it to pass the database session to other functions. Since we have +used the `with` statement, the session will be automatically closed (and data +comitted) when the function returns, usually after the related route is +complete. + +!!! note + Note that in line **46** we are using a combined `with` statement. This + is a shortcut for using two nested `with` statements, one for the + `async_session` and one for the `session.begin()`. + +## The `init_models()` function + +This function is used to create the database tables. It is called by the +`lifespan()` function at startup. + +!!! note + This function is only used in our demo, in a real application you would + use a migration tool like + [Alembic](https://alembic.sqlalchemy.org/en/latest/){:target="_blank"} + instead. + +```python linenums="50" +async def init_models() -> None: + """Create tables if they don't already exist. + + In a real-life example we would use Alembic to manage migrations. + """ + async with async_engine.begin() as conn: + # await conn.run_sync(Base.metadata.drop_all) # noqa: ERA001 + await conn.run_sync(Base.metadata.create_all) +``` + +This function shows how to run a `syncronous` function in an `async` context +using the `async_engine` object directly instead of the `async_session` object. +On line **57** we use the `run_sync` method to run the `create_all` method of +the `Base.metadata` object (a syncronous function). This will create all of the +tables defined in the models. + +Next, we will look at the models themselves and the Schemas used to validate +them within FastAPI. diff --git a/docs/explanation/intro.md b/docs/explanation/intro.md new file mode 100644 index 0000000..7b3ee50 --- /dev/null +++ b/docs/explanation/intro.md @@ -0,0 +1,31 @@ +# Introduction + +This section will attempt to explain the code in this repository. It is not +meant to be a tutorial on how to use FastAPI or SQLAlchemy, but rather an +explanation of how to get the two to work together **Asynchronously**. + +## Caveats + +This is a very simple example of a REST API. It is not meant to be used in +production, it is meant to be a simple example of how to use FastAPI and +SQLAlchemy together **Asynchronously**. As such there are some things that you +would +not do in a production environment, such as: + +- Using SQLite as the database +- Manual database migrations, instead of using a tool like Alembic +- No validation of the data being sent to the API +- No check for duplicate email addresses +- The code layout is not optimal. The relevant files are all in the root + directory, instead of being in a `src` directory or similar, and the routes + would be better in a separate file. + +The above is not an exhaustive list! + +## Relevant Files + +- `main.py` - The main file that runs the program and contains the routes +- `db.py` - This file contains the database connection and functions +- `models.py` - This file contains the database models +- `schema.py` - This defines the Pydantic schemas for the models, used for + validation and serialization. diff --git a/docs/explanation/main.md b/docs/explanation/main.md new file mode 100644 index 0000000..07689ed --- /dev/null +++ b/docs/explanation/main.md @@ -0,0 +1,4 @@ +# The Main Application and Routes + +!!! danger "To be Added" + This section is not yet written. diff --git a/docs/explanation/models.md b/docs/explanation/models.md new file mode 100644 index 0000000..c961e3d --- /dev/null +++ b/docs/explanation/models.md @@ -0,0 +1,4 @@ +# Models and Schemas + +!!! danger "To be Added" + This section is not yet written. diff --git a/docs/usage.md b/docs/usage.md index 7e7df2a..ddde733 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -2,10 +2,12 @@ ## Installation -Clone the repository from [here][repo]{:target="_blank"} and install the +Clone [this repository][repo]{:target="_blank"} and install the dependencies. This project uses [Poetry][poetry]{:target="_blank"} for dependency management which should be installed on your system first. +Install the dependencies: + ```console poetry install ``` @@ -24,11 +26,18 @@ Run the server using `Uvicorn`: uvicorn main:app --reload ``` -> You can also run the server by just executing the `main.py` file: -> -> ```console -> python main.py -> ``` +!!! note + You can also run the server by just executing the `main.py` file: + + ```console + python main.py + ``` + + or using the included `POE` alias: + + ```console + poe serve + ``` Then open your browser at [http://localhost:8000](http://localhost:8000){:target="_blank"}. diff --git a/mkdocs.yml b/mkdocs.yml index 3bb8318..89ebdb4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -10,6 +10,9 @@ theme: features: - navigation.footer - navigation.expand + - navigation.prune + - content.code.copy + - content.code.annotate extra: social: @@ -34,13 +37,19 @@ plugins: markdown_extensions: - admonition - pymdownx.snippets + - pymdownx.superfences + - md_in_html - pymdownx.highlight: - linenums: false + linenums: true auto_title: false - attr_list nav: - Home: index.md - Usage: usage.md - - Code Description: explanation.md + - Code Description: + - Introduction: explanation/intro.md + - Database Setup: explanation/database.md + - Models and Schemas: explanation/models.md + - Application and Routes: explanation/main.md - License: license.md diff --git a/pyproject.toml b/pyproject.toml index 3012468..96bd6f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,10 @@ markdown.help = "Run markdown checks" "docs:serve:all".cmd = "mkdocs serve" "docs:serve:all".help = "Serve documentation locally on all interfaces" +# generate the CHANGELOG.md file +changelog.cmd = "github-changelog-md" +changelog.help = "Generate the CHANGELOG.md file" + [tool.pymarkdown] plugins.md014.enabled = false plugins.md046.enabled = false From 63245b68bf62fb0d301852a1287f45d1c321924e Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 29 Jan 2024 17:04:26 +0000 Subject: [PATCH 2/5] update major library versions Signed-off-by: Grant Ramsay --- requirements-dev.txt | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 8f0a57a..247091b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -29,7 +29,7 @@ filelock==3.18.0 ghp-import==2.1.0 github-changelog-md==0.9.5 greenlet==3.1.1 -h11==0.16.0 +h11==0.14.0 htmlmin2==0.1.13 httpcore==1.0.7 httptools==0.6.4 diff --git a/requirements.txt b/requirements.txt index 1b23f9b..0d42c44 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ exceptiongroup==1.2.2 ; python_full_version < '3.11' fastapi==0.115.12 fastapi-cli==0.0.7 greenlet==3.1.1 -h11==0.16.0 +h11==0.14.0 httpcore==1.0.7 httptools==0.6.4 httpx==0.28.1 From 1bacca9019b1cff4063ebf1e9a97a28491c41930 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Mon, 29 Jan 2024 17:04:51 +0000 Subject: [PATCH 3/5] docs: some tweaks to existing wording Signed-off-by: Grant Ramsay --- docs/explanation/database.md | 10 +++++--- docs/index.md | 45 ++++++++++++++++++++++++++---------- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/docs/explanation/database.md b/docs/explanation/database.md index e016756..ff449fd 100644 --- a/docs/explanation/database.md +++ b/docs/explanation/database.md @@ -35,8 +35,8 @@ DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres" We set a variable to be used later which contains the database URL. We are using PostgreSQL, but you can use any database that SQLAlchemy supports. The commented out line is for SQLite, which is a good choice for testing. You can comment out -the PostgreSQL line (**13**)and uncomment the SQLite line (**14**)to use SQLite -instead. +the PostgreSQL line (**13**) and uncomment the SQLite line (**14**) to use +SQLite instead. This is a basic connection string, in reality you would want to use environment variables to store the user/password and database name. @@ -134,7 +134,7 @@ async def init_models() -> None: In a real-life example we would use Alembic to manage migrations. """ async with async_engine.begin() as conn: - # await conn.run_sync(Base.metadata.drop_all) # noqa: ERA001 + # await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.create_all) ``` @@ -144,5 +144,9 @@ On line **57** we use the `run_sync` method to run the `create_all` method of the `Base.metadata` object (a syncronous function). This will create all of the tables defined in the models. +If you want to drop the tables and recreate them every time the server restarts, +you can uncomment line **56**. This is obviously not much good for production +use, but it can be useful for testing. + Next, we will look at the models themselves and the Schemas used to validate them within FastAPI. diff --git a/docs/index.md b/docs/index.md index e216487..746996c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,21 +2,42 @@ ## Introduction -I've been using [FastAPI][fastapi]{:target="_blank"} and +This repository contains a very simple example how to use FastAPI with Async +SQLAlchemy 2.0, in `ORM` mode. I'll probably add an example for `Core` mode +also. No effort has been made to make this a production ready application, it's +just a simple demo since at the time of writing there were few clear examples of +how to do this. + +Last update 29th January 2024, and tested to work with the following versions: + +- Python 3.9+ +- FastAPI 0.109.0 +- SQLAlchemy 2.0.25 + +## Why use Raw SQLAlchemy? + +I was using [FastAPI][fastapi]{:target="_blank"} and [SQLAlchemy][sqla]{:target="_blank"} combined with -[encode/databases][databases]{:target="_blank"} for a while now. +[encode/databases][databases]{:target="_blank"} for a while. This worked fine +originally but I felt I needed a bit more control over the database session +management. -The `databases` package is a great wrapper around `SQLAlchemy` that allows you -to use async/await with SQLAlchemy. +!!! info + The [databases][databases]{:target="_blank"} package is a great wrapper + around `SQLAlchemy` that allows you to use async/await for database + operations. It also has a nice way of managing the database session, which + is why I used it originally. -However, this does not seem be be actively maintained anymore. So I decided to -give the new [Async SQLAlchemy][async-sqla]{:target="_blank"} a try instead. +However, this did not seem be be actively maintained at the time, so I decided +to give the newer [Async SQLAlchemy][async-sqla]{:target="_blank"} a try +instead. -This repository contains a very simple example how to use FastAPI with Async -SQLAlchemy 2.0, in `ORM` mode. I'll probably add an example for `Core` mode -also. +This repository is the result of my exprimentation while converting my +[FastAPI-template][fastapi-template]{:target="_blank"} project to use `Async +SQLAlchemy` instead of `databases`. -[fastapi]:https://fastapi.tiangolo.com/ +[fastapi]: https://fastapi.tiangolo.com/ [sqla]: https://www.sqlalchemy.org/ -[databases]:https://www.encode.io/databases/ -[async-sqla]:https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html +[databases]: https://www.encode.io/databases/ +[async-sqla]: https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html +[fastapi-template]: https://github.com/seapagan/fastapi-template From 5648939df9845653d98fdae023fb73190ad7f312 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Sat, 26 Apr 2025 07:40:15 +0100 Subject: [PATCH 4/5] update docs for 'uv' Signed-off-by: Grant Ramsay --- docs/usage.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index ddde733..b0b8a95 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -3,19 +3,23 @@ ## Installation Clone [this repository][repo]{:target="_blank"} and install the -dependencies. This project uses [Poetry][poetry]{:target="_blank"} for +dependencies. This project uses [uv][uv]{:target="_blank"} for dependency management which should be installed on your system first. Install the dependencies: ```console -poetry install +uv sync ``` Then switch to the virtual environment: ```console -poetry shell +# On Linux: +source .venv/bin/activate + +# On Windows: +.venv\Scripts\activate ``` ## Usage @@ -89,7 +93,7 @@ SQLite database. DATABASE_URL = "sqlite+aiosqlite:///./test.db" ``` -[poetry]: https://python-poetry.org/ +[uv]: https://docs.astral.sh/uv/ [postgres]:https://www.postgresql.org/ [docker]:https://www.docker.com/ [sqlite]:https://www.sqlite.org/ From c102118e33b43a80656f480fbe21035f3402b159 Mon Sep 17 00:00:00 2001 From: Grant Ramsay Date: Sat, 26 Apr 2025 07:42:07 +0100 Subject: [PATCH 5/5] Bump h11 and httpcore versions to 0.16.0 and 1.0.9 respectively Signed-off-by: Grant Ramsay --- requirements-dev.txt | 4 ++-- requirements.txt | 4 ++-- uv.lock | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 247091b..5e159bc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -29,9 +29,9 @@ filelock==3.18.0 ghp-import==2.1.0 github-changelog-md==0.9.5 greenlet==3.1.1 -h11==0.14.0 +h11==0.16.0 htmlmin2==0.1.13 -httpcore==1.0.7 +httpcore==1.0.9 httptools==0.6.4 httpx==0.28.1 identify==2.6.9 diff --git a/requirements.txt b/requirements.txt index 0d42c44..db639dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,8 +14,8 @@ exceptiongroup==1.2.2 ; python_full_version < '3.11' fastapi==0.115.12 fastapi-cli==0.0.7 greenlet==3.1.1 -h11==0.14.0 -httpcore==1.0.7 +h11==0.16.0 +httpcore==1.0.9 httptools==0.6.4 httpx==0.28.1 idna==3.10 diff --git a/uv.lock b/uv.lock index 6e03755..2c71ce6 100644 --- a/uv.lock +++ b/uv.lock @@ -641,11 +641,11 @@ wheels = [ [[package]] name = "h11" -version = "0.14.0" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, ] [[package]] @@ -658,15 +658,15 @@ wheels = [ [[package]] name = "httpcore" -version = "1.0.7" +version = "1.0.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, ] [[package]]