Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Configure pre-commit and Ruff #16

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .docs/images/format.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .docs/images/pre-commit.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 0 additions & 6 deletions .flake8

This file was deleted.

3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ var/

.env
logs/*

# Virtual envs
.venv/
3 changes: 0 additions & 3 deletions .isort.cfg

This file was deleted.

27 changes: 27 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# See https://pre-commit.com/hooks.html for more hooks
repos:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can include the default_stages option for execute the linter only the push.
What do you think?

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.2.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files

- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.5.5
hooks:
# Run the linter.
- id: ruff
args: [ --fix ]
# Run the formatter.
- id: ruff-format

# Using mypy's hook isn't working properly. Keep using format.sh
- repo: local
hooks:
- id: mypy
name: mypy type checking
language: script
entry: ./scripts/exec.sh format
35 changes: 26 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,42 @@
- PostgreSQL database

## Project setup
You only need to install [Docker](https://docs.docker.com/engine/install/) and [Docker Compose](https://docs.docker.com/compose/install/).
To start the containers, just run `docker-compose up` (or `docker-compose up -d` if you want to run the containers in background); or `docker-compose create` and `docker-compose start` if you don't want to see the logs.
You only need to install [Docker](https://docs.docker.com/engine/install/) and [Docker Compose](https://docs.docker.com/compose/install/).
To start the containers, just run `docker-compose up` (or `docker-compose up -d` if you want to run the containers in background); or `docker-compose create` and `docker-compose start` if you don't want to see the logs.
Once the containers are running, you can go to `http://localhost:8000/docs` to see the automatic interactive API documentation.

For making code changes, installing `pre-commit` is necessary (see section [Code tools: pre-commit](#pre-commit))

## Migrations
We use Alembic as database migration tool. To run its commands you can open an interactive shell inside the backend container (you can use `./exec.sh bash` shortcut), or use the following shortcuts under the `/scripts` directory:
- `./exec.sh migrate` -> runs all the migrations
- `./exec.sh makemigrations` -> compares the actual status of the DB against the table metadata, and generates the migrations based on the comparison

## Code tools
Linters, formatters, etc.

- **Pycln**: Formatter for finding and removing unused import statements.
- **isort**: Tool to sort imports alphabetically and automatically separate into sections by type.
- **flake8**: Linting tool
### pre-commit
Install `pre-commit` via `pip`:

pip install pre-commit

Setup the `pre-commit` hooks, specified in `.pre-commit-config.yaml`:

pre-commit install

Ensure everything was set up correctly by running the hooks:

pre-commit run --all-files

![Screenshot](.docs/images/pre-commit.png)

#### Adding hooks

You can add new `pre-commit` hooks by editing `.pre-commit-config.yaml`. Whenever new hooks are added, you must run `pre-commit install` to ensure new hooks are run on commit.

### Linting, formatting and type checking

- **ruff**: Linter and formatter
- **mypy**: Static type checker
- **black**: PEP 8 compliant opinionated formatter

There is a shortcut under the `/scripts` directory that runs all this tools for you (`./exec.sh format`).

Expand All @@ -49,5 +68,3 @@ The template includes an admin interface via [SQLAdmin](https://github.com/amina
*One note: You should be careful when adding relationships to the list or detail pages (specially large many-to-many / one-to-many relationships), because it's not very optimal in terms of DB querys in those cases (all the related objects would be loaded in memory).*

![Screenshot](.docs/images/admin.png)


7 changes: 0 additions & 7 deletions mypy.ini

This file was deleted.

26 changes: 26 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[tool.ruff]
line-length = 130
force-exclude = true # Ensure exclusions are respected by the pre-commit hook
extend-exclude = [
"src/alembic/versions",
"__pycache__"
]

[tool.ruff.lint]
extend-select = [ # Defaults: [ "E4", "E7", "E9", "F" ] (https://docs.astral.sh/ruff/rules/#error-e)
"E501", # line-too-long
"I001", # unsorted-imports
"I002", # missing-required-import
]

[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401"]

[tool.mypy]
plugins = [
"pydantic.mypy",
"sqlalchemy.ext.mypy.plugin"
]
exclude = "src/alembic/"
disallow_untyped_defs = true
ignore_missing_imports = true
lucabenvenuto marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ pyxdg==0.28
PyYAML==6.0
requests==2.31.0
rsa==4.9
ruff==0.5.5
six==1.16.0
sniffio==1.3.0
sqladmin==0.10.3
Expand Down
14 changes: 5 additions & 9 deletions scripts/format.sh
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
#!/bin/bash

printf "Runing pycln...\n"
pycln src --exclude __init__.py --all
printf "\nRunning isort...\n"
isort src
printf "\nRunning flake8...\n"
flake8

printf "\nRunning mypy...\n"
mypy src

printf "\nRunning black...\n"
black src --exclude alembic
printf "\nRunning ruff check...\n"
ruff check --fix

printf "\nRunning ruff format...\n"
ruff format
4 changes: 1 addition & 3 deletions src/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,7 @@ async def logout(self, request: Request) -> bool:
return True

async def authenticate(self, request: Request) -> RedirectResponse | None:
failed_auth_response = RedirectResponse(
request.url_for("admin:login"), status_code=302
)
failed_auth_response = RedirectResponse(request.url_for("admin:login"), status_code=302)
manager = AuthManager()
token = request.session.get(AdminAuth.cookie_name)
if not token:
Expand Down
6 changes: 2 additions & 4 deletions src/alembic/env.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from logging.config import fileConfig

from alembic import context
from sqlalchemy import engine_from_config, pool

from alembic import context
from src import models # noqa F401
from src.core.config import settings
from src.core.database import SQLBase
Expand Down Expand Up @@ -59,9 +59,7 @@ def run_migrations_online():
)

with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata, compare_type=True
)
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)

with context.begin_transaction():
context.run_migrations()
Expand Down
2 changes: 1 addition & 1 deletion src/alembic/versions/2023-07-12-5d07fd610995_.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""empty message

Revision ID: 5d07fd610995
Revises:
Revises:
Create Date: 2023-07-12 18:37:52.159059

"""
Expand Down
4 changes: 1 addition & 3 deletions src/api/v1/routers/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@


@router.get("", response_model=Page[Item])
def get_items(
user: User = Depends(get_user), session: Session = Depends(db_session)
) -> Any:
def get_items(user: User = Depends(get_user), session: Session = Depends(db_session)) -> Any:
return paginate(session, user.get_items())


Expand Down
4 changes: 1 addition & 3 deletions src/controllers/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@

class ItemController:
@staticmethod
def create(
item_data: schemas.ItemCreate, owner_id: UUID, session: Session
) -> models.Item:
def create(item_data: schemas.ItemCreate, owner_id: UUID, session: Session) -> models.Item:
item_data = schemas.Item(owner_id=owner_id, **item_data.model_dump())
item = models.Item.objects(session).create(item_data.model_dump())
return item
4 changes: 1 addition & 3 deletions src/controllers/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ def create(user_data: UserCreate, session: Session) -> User:

@staticmethod
def login(user_data: UserCreate, session: Session) -> User:
login_exception = HTTPException(
status_code=401, detail="Invalid email or password"
)
login_exception = HTTPException(status_code=401, detail="Invalid email or password")
user = User.objects(session).get(User.email == user_data.email)
if not user:
raise login_exception
Expand Down
12 changes: 3 additions & 9 deletions src/core/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,7 @@ def get(self, *where_clause: Any) -> _Model | None:
def get_or_404(self, *where_clause: Any) -> _Model:
obj = self.get(*where_clause)
if obj is None:
raise HTTPException(
status_code=404, detail=f"{self.cls.__name__} not found"
)
raise HTTPException(status_code=404, detail=f"{self.cls.__name__} not found")
return obj

def get_all(self, *where_clause: Any) -> Sequence[_Model]:
Expand All @@ -96,14 +94,10 @@ def create(self, data: Dict[str, Any]) -> _Model:

@declarative_mixin
class TableIdMixin:
id: Mapped[uuid.UUID] = mapped_column(
primary_key=True, server_default=random_uuid()
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, server_default=random_uuid())


@declarative_mixin
class DatedTableMixin(TableIdMixin):
created_at: Mapped[datetime] = mapped_column(server_default=utcnow())
updated_at: Mapped[datetime] = mapped_column(
server_default=utcnow(), onupdate=datetime.utcnow()
)
updated_at: Mapped[datetime] = mapped_column(server_default=utcnow(), onupdate=datetime.utcnow())
16 changes: 4 additions & 12 deletions src/core/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,10 @@ class AuthManager:
accept_header = settings.accept_token

@classmethod
def create_access_token(
cls, user: User, expires_delta: timedelta | None = None
) -> Tuple[str, datetime]:
expires = datetime.utcnow() + (
expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
)
def create_access_token(cls, user: User, expires_delta: timedelta | None = None) -> Tuple[str, datetime]:
expires = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.access_token_expire_minutes))
claims = {"exp": expires, "user_id": str(user.id)}
token = jwt.encode(
claims=claims, key=settings.jwt_signing_key, algorithm=cls.algorithm
)
token = jwt.encode(claims=claims, key=settings.jwt_signing_key, algorithm=cls.algorithm)
return token, expires

@classmethod
Expand All @@ -65,9 +59,7 @@ def process_login(cls, user: User, response: Response) -> Token | None:

def get_user_from_token(self, token: str, session: Session) -> User:
try:
payload = jwt.decode(
token=token, key=settings.jwt_signing_key, algorithms=self.algorithm
)
payload = jwt.decode(token=token, key=settings.jwt_signing_key, algorithms=self.algorithm)
token_data = TokenPayload(**payload)
except (JWTError, ValidationError):
raise self.credentials_exception
Expand Down
12 changes: 3 additions & 9 deletions src/tests/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,7 @@ def check_me_response(self, response: Response) -> None:

@reset_database
def test_signup(self) -> None:
expected_expire = datetime.utcnow() + timedelta(
minutes=settings.access_token_expire_minutes
)
expected_expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
response = client.post(self.SIGNUP_URL, json=self.TEST_PAYLOAD)
assert response.status_code == 201
data = response.json()
Expand All @@ -74,9 +72,7 @@ def test_signup_dup_emails(self) -> None:
@reset_database
def test_login(self) -> None:
client.post(self.SIGNUP_URL, json=self.TEST_PAYLOAD)
expected_expire = datetime.utcnow() + timedelta(
minutes=settings.access_token_expire_minutes
)
expected_expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
response = client.post(self.LOGIN_URL, json=self.TEST_PAYLOAD)
assert response.status_code == 200
data = response.json()
Expand Down Expand Up @@ -126,9 +122,7 @@ def test_me_unauthenticated(self) -> None:
def test_me_bad_access_token(self) -> None:
client.post(self.SIGNUP_URL, json=self.TEST_PAYLOAD)
client.cookies.clear()
response = client.get(
self.ME_URL, headers={AuthManager.header_name: self.BAD_TOKEN}
)
response = client.get(self.ME_URL, headers={AuthManager.header_name: self.BAD_TOKEN})
assert response.status_code == 401

@reset_database
Expand Down