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..ff449fd --- /dev/null +++ b/docs/explanation/database.md @@ -0,0 +1,152 @@ +# 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) + 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. + +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/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/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 diff --git a/docs/usage.md b/docs/usage.md index 7e7df2a..b0b8a95 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -2,18 +2,24 @@ ## Installation -Clone the repository from [here][repo]{:target="_blank"} and install the -dependencies. This project uses [Poetry][poetry]{:target="_blank"} for +Clone [this repository][repo]{:target="_blank"} and install the +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 @@ -24,11 +30,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"}. @@ -80,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/ 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 diff --git a/requirements-dev.txt b/requirements-dev.txt index 8f0a57a..5e159bc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -31,7 +31,7 @@ github-changelog-md==0.9.5 greenlet==3.1.1 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 1b23f9b..db639dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ fastapi==0.115.12 fastapi-cli==0.0.7 greenlet==3.1.1 h11==0.16.0 -httpcore==1.0.7 +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]]