Skip to content

Commit c766d2f

Browse files
authored
Merge pull request #28 from rafsaf/update-readme-v4
Update readme for release v4
2 parents f0395e2 + e2149f9 commit c766d2f

File tree

6 files changed

+131
-65
lines changed

6 files changed

+131
-65
lines changed

README.md

Lines changed: 58 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
<a href="https://github.com/rafsaf/minimal-fastapi-postgres-template/blob/main/LICENSE" target="_blank">
55
<img src="https://img.shields.io/github/license/rafsaf/minimal-fastapi-postgres-template" alt="License">
66
</a>
7-
<a href="https://docs.python.org/3/whatsnew/3.10.html" target="_blank">
8-
<img src="https://img.shields.io/badge/python-3.10-blue" alt="Python">
7+
<a href="https://docs.python.org/3/whatsnew/3.11.html" target="_blank">
8+
<img src="https://img.shields.io/badge/python-3.11-blue" alt="Python">
99
</a>
1010
<a href="https://github.com/psf/black" target="_blank">
1111
<img src="https://img.shields.io/badge/code%20style-black-lightgrey" alt="Black">
@@ -23,7 +23,8 @@
2323
- [2. Install dependecies with poetry or without it](#2-install-dependecies-with-poetry-or-without-it)
2424
- [3. Setup databases](#3-setup-databases)
2525
- [4. Now you can run app](#4-now-you-can-run-app)
26-
- [Running tests](#running-tests)
26+
- [5. Activate pre-commit](#5-activate-pre-commit)
27+
- [6. Running tests](#6-running-tests)
2728
- [About](#about)
2829
- [Step by step example - POST and GET endpoints](#step-by-step-example---post-and-get-endpoints)
2930
- [1. Create SQLAlchemy model](#1-create-sqlalchemy-model)
@@ -36,19 +37,19 @@
3637

3738
## Features
3839

39-
- [x] SQLAlchemy 1.4 using new 2.0 API, async queries, and dataclasses in SQLAlchemy models for best possible autocompletion support
40+
- [x] **SQLAlchemy 2.0 only**, async queries, best possible autocompletion support (SQLAlchemy 2.0.0 was released January 26, 2023)
4041
- [x] Postgresql database under `asyncpg`
4142
- [x] [Alembic](https://alembic.sqlalchemy.org/en/latest/) migrations
4243
- [x] Very minimal project structure yet ready for quick start building new apps
4344
- [x] Refresh token endpoint (not only access like in official template)
4445
- [x] Two databases in docker-compose.yml (second one for tests) and ready to go Dockerfile with [Nginx Unit](https://unit.nginx.org/) webserver
45-
- [x] [Poetry](https://python-poetry.org/docs/) and Python 3.10 based
46-
- [x] `pre-push.sh` script with poetry export, autoflake, black, isort and flake8
46+
- [x] [Poetry](https://python-poetry.org/docs/) and Python 3.11 based
47+
- [x] `pre-commit` with poetry export, autoflake, black, isort and flake8
4748
- [x] Rich setup for pytest async tests with few included and extensible `conftest.py`
4849

4950
<br>
5051

51-
_Check out also online example: https://minimal-fastapi-postgres-template.rafsaf.pl, it's 100% code used in template with added domain and https only._
52+
_Check out also online example: https://minimal-fastapi-postgres-template.rafsaf.pl, it's 100% code used in template (docker image) with added domain and https only._
5253

5354
![template-fastapi-minimal-openapi-example](./docs/template-minimal-openapi-example.png)
5455

@@ -68,16 +69,16 @@ cookiecutter https://github.com/rafsaf/minimal-fastapi-postgres-template
6869

6970
```bash
7071
cd project_name
71-
### Poetry install (python3.10)
72+
### Poetry install (python3.11)
7273
poetry install
7374

74-
### Optionally there are also requirements
75-
python3.10 -m venv venv
75+
### Optionally there is also `requirements-dev.txt` file
76+
python3.11 -m venv venv
7677
source venv/bin/activate
7778
pip install -r requirements-dev.txt
7879
```
7980

80-
Note, be sure to use `python3.10` with this template with either poetry or standard venv & pip, if you need to stick to some earlier python version, you should adapt it yourself (remove python3.10+ specific syntax for example `str | int`)
81+
Note, be sure to use `python3.11` with this template with either poetry or standard venv & pip, if you need to stick to some earlier python version, you should adapt it yourself (remove new versions specific syntax for example `str | int` for python < 3.10 or `tomllib` for python < 3.11)
8182

8283
### 3. Setup databases
8384

@@ -99,7 +100,21 @@ uvicorn app.main:app --reload
99100

100101
You should then use `git init` to initialize git repository and access OpenAPI spec at http://localhost:8000/ by default. To customize docs url, cors and allowed hosts settings, read section about it.
101102

102-
### Running tests
103+
### 5. Activate pre-commit
104+
105+
[pre-commit](https://pre-commit.com/) is de facto standard now for pre push activities like isort or black.
106+
107+
Refer to `.pre-commit-config.yaml` file to see my opinionated choices.
108+
109+
```bash
110+
# Install pre-commit
111+
pre-commit install
112+
113+
# First initialization and run on all files
114+
pre-commit run --all-files
115+
```
116+
117+
### 6. Running tests
103118

104119
```bash
105120
# Note, it will use second database declared in docker-compose.yml, not default one
@@ -122,7 +137,7 @@ pytest
122137

123138
## About
124139

125-
This project is heavily based on the official template https://github.com/tiangolo/full-stack-fastapi-postgresql (and on my previous work: [link1](https://github.com/rafsaf/fastapi-plan), [link2](https://github.com/rafsaf/docker-fastapi-projects)), but as it now not too much up-to-date, it is much easier to create new one than change official. I didn't like some of conventions over there also (`crud` and `db` folders for example or `schemas` with bunch of files).
140+
This project is heavily based on the official template https://github.com/tiangolo/full-stack-fastapi-postgresql (and on my previous work: [link1](https://github.com/rafsaf/fastapi-plan), [link2](https://github.com/rafsaf/docker-fastapi-projects)), but as it now not too much up-to-date, it is much easier to create new one than change official. I didn't like some of conventions over there also (`crud` and `db` folders for example or `schemas` with bunch of files). This template aims to be as much up-to-date as possible, using only newest python versions and libraries versions.
126141

127142
`2.0` style SQLAlchemy API is good enough so there is no need to write everything in `crud` and waste our time... The `core` folder was also rewritten. There is great base for writting tests in `tests`, but I didn't want to write hundreds of them, I noticed that usually after changes in the structure of the project, auto tests are useless and you have to write them from scratch anyway (delete old ones...), hence less than more. Similarly with the `User` model, it is very modest, with just `id` (uuid), `email` and `password_hash`, because it will be adapted to the project anyway.
128143

@@ -145,51 +160,42 @@ We will add Pet model to `app/models.py`. To keep things clear, below is full re
145160
# app/models.py
146161

147162
import uuid
148-
from dataclasses import dataclass, field
149163

150-
from sqlalchemy import Column, ForeignKey, Integer, String
164+
from sqlalchemy import ForeignKey, Integer, String
151165
from sqlalchemy.dialects.postgresql import UUID
152-
from sqlalchemy.orm import registry, relationship
166+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
167+
153168

154-
Base = registry()
169+
class Base(DeclarativeBase):
170+
pass
155171

156172

157-
@Base.mapped
158-
@dataclass
159-
class User:
173+
class User(Base):
160174
__tablename__ = "user_model"
161-
__sa_dataclass_metadata_key__ = "sa"
162175

163-
id: uuid.UUID = field(
164-
init=False,
165-
default_factory=uuid.uuid4,
166-
metadata={"sa": Column(UUID(as_uuid=True), primary_key=True)},
176+
id: Mapped[str] = mapped_column(
177+
UUID(as_uuid=False), primary_key=True, default=lambda _: str(uuid.uuid4())
167178
)
168-
email: str = field(
169-
metadata={"sa": Column(String(254), nullable=False, unique=True, index=True)}
179+
email: Mapped[str] = mapped_column(
180+
String(254), nullable=False, unique=True, index=True
170181
)
171-
hashed_password: str = field(metadata={"sa": Column(String(128), nullable=False)})
182+
hashed_password: Mapped[str] = mapped_column(String(128), nullable=False)
172183

173184

174-
@Base.mapped
175-
@dataclass
176-
class Pet:
177-
__tablename__ = "pets"
178-
__sa_dataclass_metadata_key__ = "sa"
185+
class Pet(Base):
186+
__tablename__ = "pet"
179187

180-
id: int = field(init=False, metadata={"sa": Column(Integer, primary_key=True)})
181-
user_id: uuid.UUID = field(
182-
metadata={"sa": Column(ForeignKey("user_model.id", ondelete="CASCADE"))},
183-
)
184-
pet_name: str = field(
185-
metadata={"sa": Column(String(50), nullable=False)},
188+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
189+
user_id: Mapped[str] = mapped_column(
190+
ForeignKey("user_model.id", ondelete="CASCADE"),
186191
)
192+
pet_name: Mapped[str] = mapped_column(String(50), nullable=False)
187193

188194

189195

190196
```
191197

192-
Note, we are using super powerful SQLAlchemy feature here - you can read more about this fairy new syntax based on dataclasses [in this topic in the docs](https://docs.sqlalchemy.org/en/14/orm/declarative_styles.html#example-two-dataclasses-with-declarative-table).
198+
Note, we are using super powerful SQLAlchemy feature here - Mapped and mapped_column were first introduced in SQLAlchemy 2.0 on Feb 26, if this syntax is new for you, read carefully "what's new" part of documentation https://docs.sqlalchemy.org/en/20/changelog/whatsnew_20.html.
193199

194200
<br>
195201

@@ -221,9 +227,9 @@ PS. Note, alembic is configured in a way that it work with async setup and also
221227

222228
### 3. Create request and response schemas
223229

224-
Note, I personally lately (after seeing clear benefits at work) prefer less files than a lot of them for things like schemas.
230+
I personally lately (after seeing clear benefits at work in Samsung) prefer less files than a lot of them for things like schemas.
225231

226-
Thats why there are only 2 files: `requests.py` and `responses.py` in `schemas` folder and I would keep it that way even for few dozen of endpoints.
232+
Thats why there are only 2 files: `requests.py` and `responses.py` in `schemas` folder and I would keep it that way even for few dozen of endpoints. Not to mention this is opinionated.
227233

228234
```python
229235
# app/schemas/requests.py
@@ -245,7 +251,7 @@ class PetCreateRequest(BaseRequest):
245251
class PetResponse(BaseResponse):
246252
id: int
247253
pet_name: str
248-
user_id: uuid.UUID
254+
user_id: str
249255

250256
```
251257

@@ -290,13 +296,8 @@ async def get_all_my_pets(
290296
):
291297
"""Get list of pets for currently logged user."""
292298

293-
pets = await session.execute(
294-
select(Pet)
295-
.where(
296-
Pet.user_id == current_user.id,
297-
)
298-
.order_by(Pet.pet_name)
299-
)
299+
stmt = select(Pet).where(Pet.user_id == current_user.id).order_by(Pet.pet_name)
300+
pets = await session.execute(stmt)
300301
return pets.scalars().all()
301302

302303
```
@@ -341,16 +342,15 @@ async def test_create_new_pet(
341342
)
342343
assert response.status_code == 201
343344
result = response.json()
344-
assert result["user_id"] == str(default_user.id)
345+
assert result["user_id"] == default_user.id
345346
assert result["pet_name"] == "Tadeusz"
346347

347348

348349
async def test_get_all_my_pets(
349350
client: AsyncClient, default_user_headers, default_user: User, session: AsyncSession
350351
):
351-
352-
pet1 = Pet(default_user.id, "Pet_1")
353-
pet2 = Pet(default_user.id, "Pet_2")
352+
pet1 = Pet(user_id=default_user.id, pet_name="Pet_1")
353+
pet2 = Pet(user_id=default_user.id, pet_name="Pet_2")
354354
session.add(pet1)
355355
session.add(pet2)
356356
await session.commit()
@@ -363,12 +363,12 @@ async def test_get_all_my_pets(
363363

364364
assert response.json() == [
365365
{
366-
"user_id": str(pet1.user_id),
366+
"user_id": pet1.user_id,
367367
"pet_name": pet1.pet_name,
368368
"id": pet1.id,
369369
},
370370
{
371-
"user_id": str(pet2.user_id),
371+
"user_id": pet2.user_id,
372372
"pet_name": pet2.pet_name,
373373
"id": pet2.id,
374374
},
@@ -378,11 +378,9 @@ async def test_get_all_my_pets(
378378

379379
## Deployment strategies - via Docker image
380380

381-
This template has by default included `Dockerfile` with [Nginx Unit](https://unit.nginx.org/) webserver, that is my prefered choice, because of direct support for FastAPI and great ease of configuration. You should be able to run container(s) (over :80 port) and then need to setup the proxy, loadbalancer, with https enbaled, so the app stays behind it.
382-
383-
`nginx-unit-config.json` file included in main folder has some default configuration options, runs app in single process and thread. More info about config file here https://unit.nginx.org/configuration/#python and about also read howto for FastAPI: https://unit.nginx.org/howto/fastapi/.
381+
This template has by default included `Dockerfile` with [Uvicorn](https://www.uvicorn.org/) webserver, because it's simple and just for showcase purposes, with direct relation to FastAPI and great ease of configuration. You should be able to run container(s) (over :8000 port) and then need to setup the proxy, loadbalancer, with https enbaled, so the app stays behind it.
384382

385-
If you prefer other webservers for FastAPI, check out [Daphne](https://github.com/django/daphne), [Hypercorn](https://pgjones.gitlab.io/hypercorn/index.html) or [Uvicorn](https://www.uvicorn.org/).
383+
If you prefer other webservers for FastAPI, check out [Nginx Unit](https://unit.nginx.org/), [Daphne](https://github.com/django/daphne), [Hypercorn](https://pgjones.gitlab.io/hypercorn/index.html).
386384

387385
## Docs URL, CORS and Allowed Hosts
388386

{{cookiecutter.project_name}}/.pre-commit-config.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,10 @@ repos:
5454
hooks:
5555
- id: poetry-export
5656
args: ["-o", "requirements.txt", "--without-hashes"]
57+
58+
- repo: https://github.com/python-poetry/poetry
59+
rev: "1.3.0"
60+
hooks:
61+
- id: poetry-export
62+
args:
63+
["-o", "requirements-dev.txt", "--without-hashes", "--with", "dev"]

{{cookiecutter.project_name}}/app/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class User(Base):
2828
__tablename__ = "user_model"
2929

3030
id: Mapped[str] = mapped_column(
31-
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
31+
UUID(as_uuid=False), primary_key=True, default=lambda _: str(uuid.uuid4())
3232
)
3333
email: Mapped[str] = mapped_column(
3434
String(254), nullable=False, unique=True, index=True

{{cookiecutter.project_name}}/app/schemas/responses.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import uuid
2-
31
from pydantic import BaseModel, EmailStr
42

53

@@ -20,5 +18,5 @@ class AccessTokenResponse(BaseResponse):
2018

2119

2220
class UserResponse(BaseResponse):
23-
id: uuid.UUID
21+
id: str
2422
email: EmailStr

{{cookiecutter.project_name}}/app/tests/test_users.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ async def test_read_current_user(client: AsyncClient, default_user_headers):
1717
)
1818
assert response.status_code == 200
1919
assert response.json() == {
20-
"id": str(default_user_id),
20+
"id": default_user_id,
2121
"email": default_user_email,
2222
}
2323

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
alembic==1.9.2 ; python_version >= "3.11" and python_version < "4.0"
2+
anyio==3.6.2 ; python_version >= "3.11" and python_version < "4.0"
3+
asyncpg==0.27.0 ; python_version >= "3.11" and python_version < "4.0"
4+
attrs==22.2.0 ; python_version >= "3.11" and python_version < "4.0"
5+
autoflake==2.0.1 ; python_version >= "3.11" and python_version < "4.0"
6+
bcrypt==4.0.1 ; python_version >= "3.11" and python_version < "4.0"
7+
black==23.1.0 ; python_version >= "3.11" and python_version < "4.0"
8+
certifi==2022.12.7 ; python_version >= "3.11" and python_version < "4.0"
9+
cffi==1.15.1 ; python_version >= "3.11" and python_version < "4.0"
10+
cfgv==3.3.1 ; python_version >= "3.11" and python_version < "4.0"
11+
click==8.1.3 ; python_version >= "3.11" and python_version < "4.0"
12+
colorama==0.4.6 ; python_version >= "3.11" and python_version < "4.0" and sys_platform == "win32" or python_version >= "3.11" and python_version < "4.0" and platform_system == "Windows"
13+
coverage==7.1.0 ; python_version >= "3.11" and python_version < "4.0"
14+
cryptography==39.0.0 ; python_version >= "3.11" and python_version < "4.0"
15+
distlib==0.3.6 ; python_version >= "3.11" and python_version < "4.0"
16+
dnspython==2.3.0 ; python_version >= "3.11" and python_version < "4.0"
17+
email-validator==1.3.1 ; python_version >= "3.11" and python_version < "4.0"
18+
fastapi==0.89.1 ; python_version >= "3.11" and python_version < "4.0"
19+
filelock==3.9.0 ; python_version >= "3.11" and python_version < "4.0"
20+
flake8==6.0.0 ; python_version >= "3.11" and python_version < "4.0"
21+
greenlet==2.0.2 ; python_version >= "3.11" and python_version < "4.0" and platform_machine == "aarch64" or python_version >= "3.11" and python_version < "4.0" and platform_machine == "ppc64le" or python_version >= "3.11" and python_version < "4.0" and platform_machine == "x86_64" or python_version >= "3.11" and python_version < "4.0" and platform_machine == "amd64" or python_version >= "3.11" and python_version < "4.0" and platform_machine == "AMD64" or python_version >= "3.11" and python_version < "4.0" and platform_machine == "win32" or python_version >= "3.11" and python_version < "4.0" and platform_machine == "WIN32"
22+
h11==0.14.0 ; python_version >= "3.11" and python_version < "4.0"
23+
httpcore==0.16.3 ; python_version >= "3.11" and python_version < "4.0"
24+
httptools==0.5.0 ; python_version >= "3.11" and python_version < "4.0"
25+
httpx==0.23.3 ; python_version >= "3.11" and python_version < "4.0"
26+
identify==2.5.17 ; python_version >= "3.11" and python_version < "4.0"
27+
idna==3.4 ; python_version >= "3.11" and python_version < "4.0"
28+
iniconfig==2.0.0 ; python_version >= "3.11" and python_version < "4.0"
29+
isort==5.12.0 ; python_version >= "3.11" and python_version < "4.0"
30+
mako==1.2.4 ; python_version >= "3.11" and python_version < "4.0"
31+
markupsafe==2.1.2 ; python_version >= "3.11" and python_version < "4.0"
32+
mccabe==0.7.0 ; python_version >= "3.11" and python_version < "4.0"
33+
mypy-extensions==1.0.0 ; python_version >= "3.11" and python_version < "4.0"
34+
nodeenv==1.7.0 ; python_version >= "3.11" and python_version < "4.0"
35+
packaging==23.0 ; python_version >= "3.11" and python_version < "4.0"
36+
passlib[bcrypt]==1.7.4 ; python_version >= "3.11" and python_version < "4.0"
37+
pathspec==0.11.0 ; python_version >= "3.11" and python_version < "4.0"
38+
platformdirs==2.6.2 ; python_version >= "3.11" and python_version < "4.0"
39+
pluggy==1.0.0 ; python_version >= "3.11" and python_version < "4.0"
40+
pre-commit==3.0.4 ; python_version >= "3.11" and python_version < "4.0"
41+
pycodestyle==2.10.0 ; python_version >= "3.11" and python_version < "4.0"
42+
pycparser==2.21 ; python_version >= "3.11" and python_version < "4.0"
43+
pydantic==1.10.4 ; python_version >= "3.11" and python_version < "4.0"
44+
pydantic[dotenv,email]==1.10.4 ; python_version >= "3.11" and python_version < "4.0"
45+
pyflakes==3.0.1 ; python_version >= "3.11" and python_version < "4.0"
46+
pyjwt[crypto]==2.6.0 ; python_version >= "3.11" and python_version < "4.0"
47+
pytest-asyncio==0.20.3 ; python_version >= "3.11" and python_version < "4.0"
48+
pytest==7.2.1 ; python_version >= "3.11" and python_version < "4.0"
49+
python-dotenv==0.21.1 ; python_version >= "3.11" and python_version < "4.0"
50+
python-multipart==0.0.5 ; python_version >= "3.11" and python_version < "4.0"
51+
pyyaml==6.0 ; python_version >= "3.11" and python_version < "4.0"
52+
rfc3986[idna2008]==1.5.0 ; python_version >= "3.11" and python_version < "4.0"
53+
setuptools==67.1.0 ; python_version >= "3.11" and python_version < "4.0"
54+
six==1.16.0 ; python_version >= "3.11" and python_version < "4.0"
55+
sniffio==1.3.0 ; python_version >= "3.11" and python_version < "4.0"
56+
sqlalchemy==2.0.1 ; python_version >= "3.11" and python_version < "4.0"
57+
starlette==0.22.0 ; python_version >= "3.11" and python_version < "4.0"
58+
typing-extensions==4.4.0 ; python_version >= "3.11" and python_version < "4.0"
59+
uvicorn[standard]==0.20.0 ; python_version >= "3.11" and python_version < "4.0"
60+
uvloop==0.17.0 ; sys_platform != "win32" and sys_platform != "cygwin" and platform_python_implementation != "PyPy" and python_version >= "3.11" and python_version < "4.0"
61+
virtualenv==20.17.1 ; python_version >= "3.11" and python_version < "4.0"
62+
watchfiles==0.18.1 ; python_version >= "3.11" and python_version < "4.0"
63+
websockets==10.4 ; python_version >= "3.11" and python_version < "4.0"

0 commit comments

Comments
 (0)