diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml new file mode 100644 index 0000000..2b11178 --- /dev/null +++ b/.github/workflows/pre-commit.yaml @@ -0,0 +1,14 @@ +name: pre-commit + +on: + pull_request: + push: + branches: [main] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v3 + - uses: pre-commit/action@v3.0.1 diff --git a/.gitignore b/.gitignore index cc260fc..ea9a8a8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ ### VisualStudioCode ### .vscode/* -__pycache__ \ No newline at end of file +__pycache__ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..79598bb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.1 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + - repo: https://github.com/pre-commit/mirrors-mypy + rev: "v1.9.0" + hooks: + - id: mypy + args: [--strict] + additional_dependencies: + - "pydantic" + - "fastapi" + - "sqlalchemy" + - "pytest" diff --git a/README.md b/README.md new file mode 100644 index 0000000..4aad4fa --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# Expenses Server in FastAPI + +# Development + +1. Start the db: +```sh +sudo docker compose -f docker-compose.yml -p expenses_server up +``` + +2. Start the server on port 8090: +```sh +poetry run uvicorn expenses_server.main:app --reload --port=8090 --use-colors +``` + +3. Running tests + +```sh +poetry run pytest # runs all tests +poetry run pytest -s --pdb # helpful for debugging tests +``` + +4. Kill the containers and remove volumes + +```sh +sudo docker compose -f docker-compose.yml -p expenses_server down --remove-orphans --volumes +``` diff --git a/docker-compose.yml b/docker-compose.yml index 7ac58b5..b1604a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ volumes: services: expenses_db: - image: postgres:latest + image: docker.io/postgres:latest container_name: expenses_db environment: POSTGRES_USER: expenses diff --git a/expenses_server_fastapi/__init__.py b/expenses_server/__init__.py similarity index 100% rename from expenses_server_fastapi/__init__.py rename to expenses_server/__init__.py diff --git a/expenses_server/db.py b/expenses_server/db.py new file mode 100644 index 0000000..637b974 --- /dev/null +++ b/expenses_server/db.py @@ -0,0 +1,21 @@ +from typing import Generator +from pydantic_core import MultiHostUrl +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session +from pydantic import PostgresDsn + +DB_URL: PostgresDsn = MultiHostUrl( + "postgresql://expenses:expenses@localhost:15000/expenses" +) + +engine = create_engine(DB_URL.unicode_string(), echo=True) + +SessionLocal = sessionmaker(autoflush=False, autocommit=False, bind=engine) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/expenses_server_fastapi/db_models/__init__.py b/expenses_server/db_models/__init__.py similarity index 100% rename from expenses_server_fastapi/db_models/__init__.py rename to expenses_server/db_models/__init__.py diff --git a/expenses_server/db_models/account.py b/expenses_server/db_models/account.py new file mode 100644 index 0000000..8edab61 --- /dev/null +++ b/expenses_server/db_models/account.py @@ -0,0 +1,17 @@ +from typing import TYPE_CHECKING +from uuid import uuid4 +from sqlalchemy import Integer, String +from sqlalchemy.orm import MappedColumn as Column, Mapped +from .core import Base +from sqlalchemy.orm import relationship + + +if TYPE_CHECKING: + from .expense import Expense + + +class Account(Base): + __tablename__ = "accounts" + name: Mapped[str] = Column(String, unique=True) + expenses: Mapped["Expense"] = relationship("Expense", back_populates="account") + id: Mapped[int] = Column(Integer, primary_key=True, default=uuid4) diff --git a/expenses_server/db_models/category.py b/expenses_server/db_models/category.py new file mode 100644 index 0000000..a348acd --- /dev/null +++ b/expenses_server/db_models/category.py @@ -0,0 +1,10 @@ +from sqlalchemy.orm import mapped_column, Mapped +from .core import Base +from sqlalchemy.orm import relationship + + +class Category(Base): + __tablename__ = "categories" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(unique=True) + expenses = relationship("Expense", back_populates="category") diff --git a/expenses_server/db_models/core.py b/expenses_server/db_models/core.py new file mode 100644 index 0000000..57dcd91 --- /dev/null +++ b/expenses_server/db_models/core.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass + + +class Base(MappedAsDataclass, DeclarativeBase): + pass diff --git a/expenses_server/db_models/expense.py b/expenses_server/db_models/expense.py new file mode 100644 index 0000000..120e441 --- /dev/null +++ b/expenses_server/db_models/expense.py @@ -0,0 +1,39 @@ +from uuid import uuid4, UUID +from sqlalchemy import TIMESTAMP, ForeignKey, Text, func, types +from sqlalchemy.orm import relationship, Mapped, mapped_column +from .core import Base +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from .category import Category + from .account import Account + + +class Expense(Base): + __tablename__ = "expenses" + + # TODO: Restrict type of currency + currency: Mapped[str] + amount: Mapped[float] = mapped_column() + description: Mapped[str | None] = mapped_column(Text) + category_id: Mapped[int] = mapped_column(ForeignKey("categories.id")) + category: Mapped["Category"] = relationship( + "Category", back_populates="expenses", init=False + ) + + # TODO: Check if back_populates and corresponding value in account is needed + account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id")) + account: Mapped["Account"] = relationship( + "Account", back_populates="expenses", init=False + ) + + external_id: Mapped[str | None] = mapped_column(unique=True, default=None) + id: Mapped[UUID] = mapped_column( + types.UUID, + default_factory=uuid4, + primary_key=True, + ) + timestamp: Mapped[str] = mapped_column( + TIMESTAMP, nullable=False, default=func.now() + ) diff --git a/expenses_server_fastapi/dtos/__init__.py b/expenses_server/dtos/__init__.py similarity index 100% rename from expenses_server_fastapi/dtos/__init__.py rename to expenses_server/dtos/__init__.py diff --git a/expenses_server/dtos/accounts.py b/expenses_server/dtos/accounts.py new file mode 100644 index 0000000..f2a3c10 --- /dev/null +++ b/expenses_server/dtos/accounts.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class Account(BaseModel): + id: int + name: str + + class Config: + from_attributes = True diff --git a/expenses_server/dtos/categories.py b/expenses_server/dtos/categories.py new file mode 100644 index 0000000..ed1e506 --- /dev/null +++ b/expenses_server/dtos/categories.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class Category(BaseModel): + id: int + name: str + + class Config: + from_attributes = True diff --git a/expenses_server/dtos/currencies.py b/expenses_server/dtos/currencies.py new file mode 100644 index 0000000..a0d4aba --- /dev/null +++ b/expenses_server/dtos/currencies.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class CurrencyEnum(Enum): + CHF = "CHF" + EUR = "EUR" diff --git a/expenses_server/dtos/expenses.py b/expenses_server/dtos/expenses.py new file mode 100644 index 0000000..f273092 --- /dev/null +++ b/expenses_server/dtos/expenses.py @@ -0,0 +1,43 @@ +from datetime import datetime +from pydantic import UUID4, BaseModel, field_serializer +from .categories import Category +from .accounts import Account +from .currencies import CurrencyEnum + + +class ExpenseBase(BaseModel): + amount: float + currency: CurrencyEnum # TODO: In OpenApI this does not show option "EUR" + description: str | None + category_name: str = "other" + account_id: int + + +class ExpenseCreate(ExpenseBase): + class Config: + from_attributes = True + + +class ExpenseUpdate(BaseModel): + amount: float | None = None + currency: CurrencyEnum | None = None + description: str | None = None + category_name: str | None = None + account_id: int | None = None + + +class ExpenseDTO(BaseModel): + id: UUID4 + timestamp: datetime + account: Account + amount: float + currency: CurrencyEnum + description: str | None + category: Category + + @field_serializer("category") + def serialize_category(self, category: Category) -> str: + return category.name + + class Config: + from_attributes = True diff --git a/expenses_server/main.py b/expenses_server/main.py new file mode 100644 index 0000000..880ab8f --- /dev/null +++ b/expenses_server/main.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, FastAPI +from .db import engine +from .db_models.core import Base +from .routes.accounts import router as account_router +from .routes.expenses import router as expense_router +from sqlalchemy import event +from .db_models.account import Account +from .db_models.category import Category +from .seed_db import populate_accounts, populate_categories + +app = FastAPI() +base_router = APIRouter(prefix="/api") + + +event.listen(Category.__table__, "after_create", populate_categories) +event.listen(Account.__table__, "after_create", populate_accounts) + +Base.metadata.create_all(bind=engine) + + +base_router.get("/") + + +async def root() -> dict[str, str]: + return {"message": "Hello World"} + + +base_router.include_router(account_router) +base_router.include_router(expense_router) + +app.include_router(base_router) diff --git a/expenses_server_fastapi/routes/__init__.py b/expenses_server/routes/__init__.py similarity index 100% rename from expenses_server_fastapi/routes/__init__.py rename to expenses_server/routes/__init__.py diff --git a/expenses_server/routes/accounts.py b/expenses_server/routes/accounts.py new file mode 100644 index 0000000..88aaa83 --- /dev/null +++ b/expenses_server/routes/accounts.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from ..db import get_db +from ..db_models.account import Account as DBAccount +from ..dtos.accounts import Account + + +router = APIRouter(prefix="/accounts") + + +@router.get("", response_model=list[Account]) +def get_all_accounts(db: Session = Depends(get_db)) -> list[DBAccount]: + # TODO: Only accounts for user should be sent + return db.query(DBAccount).all() diff --git a/expenses_server/routes/expenses.py b/expenses_server/routes/expenses.py new file mode 100644 index 0000000..3f824cf --- /dev/null +++ b/expenses_server/routes/expenses.py @@ -0,0 +1,102 @@ +from http import HTTPStatus +from typing import cast +from fastapi import APIRouter, Depends, HTTPException +from pydantic import UUID4 + +from expenses_server.dtos.currencies import CurrencyEnum + +from ..db import get_db +from ..db_models.category import Category +from ..dtos.expenses import ExpenseDTO, ExpenseCreate, ExpenseUpdate +from ..db_models.expense import Expense as DBExpense +from sqlalchemy.orm import Session + + +router = APIRouter(prefix="/expenses") + + +@router.post("", response_model=ExpenseDTO) +async def create_expense( + expense: ExpenseCreate, session: Session = Depends(get_db) +) -> DBExpense: + db_category = ( + session.query(Category).filter(Category.name == expense.category_name).first() + ) + db_expense = DBExpense( + amount=expense.amount, + description=expense.description, + currency=expense.currency.value, + # TODO Default category should be handled better + # TODO Should the default not be set for category? + category_id=db_category.id if db_category is not None else 13, + account_id=expense.account_id, + ) + session.add(db_expense) + session.commit() + session.refresh(db_expense) + return db_expense + + +@router.get("", response_model=list[ExpenseDTO]) +async def get_all_expenses(session: Session = Depends(get_db)) -> list[ExpenseDTO]: + db_expenses = session.query(DBExpense).order_by(DBExpense.timestamp.desc()).all() + expenses = [ExpenseDTO.model_validate(expense) for expense in db_expenses] + return expenses + + +@router.get("/{expense_id}", response_model=ExpenseDTO) +async def get_expense( + expense_id: UUID4, session: Session = Depends(get_db) +) -> ExpenseDTO: + db_expense = session.get(DBExpense, expense_id) + if db_expense is None: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Expense with id {expense_id} not found", + ) + return ExpenseDTO.model_validate(db_expense) + + +@router.patch("/{expense_id}", response_model=ExpenseDTO) +async def update_expense( + expense_id: UUID4, expense_update: ExpenseUpdate, session: Session = Depends(get_db) +) -> ExpenseDTO: + # TODO: Ask David - How to best refactor this + payload = expense_update.model_dump(exclude_none=True) + + # Replace category_name with category_id + if payload.get("category_name") is not None: + db_category = ( + session.query(Category) + .filter(Category.name == payload.get("category_name", "other")) + .first() + ) + del payload["category_name"] + payload["category_id"] = db_category.id if db_category is not None else 13 + # Replace currency name with currency + if payload.get("currency"): + payload["currency"] = cast(CurrencyEnum, payload["currency"]).value + + session.query(DBExpense).filter(DBExpense.id == expense_id).update( + values=payload # type: ignore + ) + session.commit() + updated_expense = session.get(DBExpense, expense_id) + return ExpenseDTO.model_validate(updated_expense) + + +@router.delete("/{expense_id}", response_model=ExpenseDTO) +async def delete_expenses( + expense_id: UUID4, db: Session = Depends(get_db) +) -> ExpenseDTO: + db_expense = db.get(DBExpense, expense_id) + if db_expense is None: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Expense with id {expense_id} not found", + ) + + expense = ExpenseDTO.model_validate(db_expense) + db.delete(db_expense) + db.commit() + return expense diff --git a/expenses_server/seed_db.py b/expenses_server/seed_db.py new file mode 100644 index 0000000..3ad3374 --- /dev/null +++ b/expenses_server/seed_db.py @@ -0,0 +1,39 @@ +from typing import Any +from sqlalchemy import Table +from sqlalchemy.engine.base import Connection + + +categories_data = [ + {"id": 1, "name": "groceries"}, + {"id": 2, "name": "plants"}, + {"id": 3, "name": "eating-out"}, + {"id": 4, "name": "rent"}, + {"id": 5, "name": "travel"}, + {"id": 6, "name": "car"}, + {"id": 7, "name": "pet"}, + {"id": 8, "name": "family"}, + {"id": 9, "name": "gadgets"}, + {"id": 10, "name": "medical"}, + {"id": 11, "name": "hobbies"}, + {"id": 12, "name": "atm"}, + {"id": 13, "name": "other"}, + {"id": 14, "name": "entertainment"}, +] + + +def populate_categories(target: Table, connection: Connection, **kw: Any) -> None: + connection.execute(target.insert(), categories_data) + + +# TODO: Ask if id should be int (to be compatible with current frontend) or uuid4? +accounts_data = [ + {"id": 0, "name": "Joint Revolut"}, + {"id": 1, "name": "Wise David"}, + {"id": 2, "name": "Wise Dini"}, + {"id": 3, "name": "Revolut David"}, + {"id": 4, "name": "Revolut Dini"}, +] + + +def populate_accounts(target: Table, connection: Connection, **ke: Any) -> None: + connection.execute(target.insert(), accounts_data) diff --git a/expenses_server/tests/__init__.py b/expenses_server/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/expenses_server/tests/conftest.py b/expenses_server/tests/conftest.py new file mode 100644 index 0000000..6325a2f --- /dev/null +++ b/expenses_server/tests/conftest.py @@ -0,0 +1,28 @@ +from http import HTTPStatus +from typing import Any, Generator +from fastapi.testclient import TestClient +import pytest +from expenses_server.main import app +from expenses_server.tests.utils import create_mock_expense + + +@pytest.fixture() +def test_client() -> Generator[TestClient, None, None]: + client = TestClient(app=app, base_url="http://localhost:8090") + yield client + + +@pytest.fixture() +def test_expenses( + test_client: TestClient, +) -> Generator[tuple[TestClient, list[dict[str, Any]]], None, None]: + expense1 = create_mock_expense(test_client) + print("EXPENSE1", expense1) + expense2 = create_mock_expense(test_client, extra={"account_id": 2}) + print("EXPENSE2", expense2) + + yield test_client, [expense1, expense2] + + for expense in [expense1, expense2]: + response = test_client.delete(f'/expenses/{expense["id"]}') + assert response.status_code in [HTTPStatus.NOT_FOUND, HTTPStatus.OK] diff --git a/expenses_server/tests/test_create_expense.py b/expenses_server/tests/test_create_expense.py new file mode 100644 index 0000000..d2e13db --- /dev/null +++ b/expenses_server/tests/test_create_expense.py @@ -0,0 +1,21 @@ +from fastapi.testclient import TestClient + + +def test_virtual_lab_created(test_client: TestClient) -> None: + payload = { + "amount": 10, + "currency": "CHF", + "description": "Migros", + "category_name": "groceries", + "account_id": 1, + } + response = test_client.post("/api/expenses", json=payload) + assert response.status_code == 200 + data = response.json() + assert data["id"] is not None + assert data["amount"] == 10.0 + assert data["currency"] == "CHF" + assert data["description"] == "Migros" + assert data["category"] == "groceries" + assert data["account"]["id"] == 1 + assert data["account"]["name"] == "Wise David" diff --git a/expenses_server/tests/test_delete_expense.py b/expenses_server/tests/test_delete_expense.py new file mode 100644 index 0000000..5c0378a --- /dev/null +++ b/expenses_server/tests/test_delete_expense.py @@ -0,0 +1,16 @@ +from http import HTTPStatus +from typing import Any +from fastapi.testclient import TestClient + + +def test_delete_expenses( + test_expenses: tuple[TestClient, list[dict[str, Any]]], +) -> None: + client, mock_expenses = test_expenses + expense_id = mock_expenses[0]["id"] + response = client.delete(f"/api/expenses/{expense_id}") + assert response.status_code == HTTPStatus.OK + assert response.json() == mock_expenses[0] + + get_deleted_expense = client.get(f"/api/expenses/{expense_id}") + assert get_deleted_expense.status_code == HTTPStatus.NOT_FOUND diff --git a/expenses_server/tests/test_get_all_expenses.py b/expenses_server/tests/test_get_all_expenses.py new file mode 100644 index 0000000..85be462 --- /dev/null +++ b/expenses_server/tests/test_get_all_expenses.py @@ -0,0 +1,14 @@ +from typing import cast +from fastapi.testclient import TestClient + +from expenses_server.dtos.expenses import ExpenseDTO + + +def test_get_all_expenses(test_expenses: tuple[TestClient, list[ExpenseDTO]]) -> None: + client, mock_expenses = test_expenses + response = client.get("/api/expenses") + assert response.status_code == 200 + expenses = cast(list[ExpenseDTO], response.json()) + assert len(expenses) >= len(mock_expenses) + assert mock_expenses[0] in expenses + assert mock_expenses[1] in expenses diff --git a/expenses_server/tests/test_get_expense.py b/expenses_server/tests/test_get_expense.py new file mode 100644 index 0000000..ad5620d --- /dev/null +++ b/expenses_server/tests/test_get_expense.py @@ -0,0 +1,10 @@ +from typing import Any +from fastapi.testclient import TestClient + + +def test_get_expense(test_expenses: tuple[TestClient, list[dict[str, Any]]]) -> None: + client, mock_expenses = test_expenses + expense_id = mock_expenses[0]["id"] + response = client.get(f"/api/expenses/{expense_id}") + assert response.status_code == 200 + assert response.json() == mock_expenses[0] diff --git a/expenses_server/tests/test_update_expense.py b/expenses_server/tests/test_update_expense.py new file mode 100644 index 0000000..21f51df --- /dev/null +++ b/expenses_server/tests/test_update_expense.py @@ -0,0 +1,21 @@ +from typing import Any +from fastapi.testclient import TestClient +import copy + + +def test_update_expense(test_expenses: tuple[TestClient, list[dict[str, Any]]]) -> None: + client, mock_expenses = test_expenses + expense_before_update = mock_expenses[0] + + payload = { + "amount": 10.89, + } + expected_response = copy.deepcopy(expense_before_update) + expected_response.update(payload) + + response = client.patch( + f"/api/expenses/{expense_before_update["id"]}", json=payload + ) + assert response.status_code == 200 + actual_response = response.json() + assert actual_response == expected_response diff --git a/expenses_server/tests/utils.py b/expenses_server/tests/utils.py new file mode 100644 index 0000000..e39d5fc --- /dev/null +++ b/expenses_server/tests/utils.py @@ -0,0 +1,28 @@ +from typing import Any, cast +from fastapi.testclient import TestClient + + +def create_mock_expense( + client: TestClient, extra: dict[str, Any] | None = None +) -> dict[str, Any]: + payload = ( + { + "amount": 10, + "currency": "CHF", + "description": "Migros", + "category_name": "groceries", + "account_id": 1, + } + if extra is None + else { + "amount": 10, + "currency": "CHF", + "description": "Migros", + "category_name": "groceries", + "account_id": 1, + **extra, + } + ) + response = client.post("/api/expenses", json=payload) + assert response.status_code == 200 + return cast(dict[str, Any], response.json()) diff --git a/expenses_server_fastapi/db.py b/expenses_server_fastapi/db.py deleted file mode 100644 index 6a8dd97..0000000 --- a/expenses_server_fastapi/db.py +++ /dev/null @@ -1,20 +0,0 @@ -from pydantic_core import MultiHostUrl -from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker -from pydantic import PostgresDsn - -DB_URL: PostgresDsn = MultiHostUrl("postgresql://expenses:expenses@localhost:15000/expenses") - -engine = create_engine(DB_URL.unicode_string()) - -SessionLocal = sessionmaker(autoflush=False, autocommit = False, bind=engine) - -Base = declarative_base() - -def get_db(): - db = SessionLocal() - try: - yield db - finally: - db.close() \ No newline at end of file diff --git a/expenses_server_fastapi/db_models/accounts.py b/expenses_server_fastapi/db_models/accounts.py deleted file mode 100644 index 1c06022..0000000 --- a/expenses_server_fastapi/db_models/accounts.py +++ /dev/null @@ -1,9 +0,0 @@ -from uuid import uuid4 -from sqlalchemy import UUID, Column, String -from expenses_server_fastapi.db import Base - - -class Account(Base): - __tablename__ = 'accounts' - id = Column(UUID, primary_key=True, default=uuid4) - name = Column(String, unique=True) \ No newline at end of file diff --git a/expenses_server_fastapi/dtos/accounts.py b/expenses_server_fastapi/dtos/accounts.py deleted file mode 100644 index c6fd6fe..0000000 --- a/expenses_server_fastapi/dtos/accounts.py +++ /dev/null @@ -1,8 +0,0 @@ -from pydantic import UUID4, BaseModel - -class Account(BaseModel): - id: UUID4 - name: str - - class Config: - from_attributes = True \ No newline at end of file diff --git a/expenses_server_fastapi/main.py b/expenses_server_fastapi/main.py deleted file mode 100644 index 0584ee2..0000000 --- a/expenses_server_fastapi/main.py +++ /dev/null @@ -1,13 +0,0 @@ -from fastapi import FastAPI -from expenses_server_fastapi.db import Base, engine -from .routes.accounts import router as account_router - -app = FastAPI() - -Base.metadata.create_all(bind=engine) - -@app.get("/") -async def root(): - return {"message": "Hello World"} - -app.include_router(account_router) \ No newline at end of file diff --git a/expenses_server_fastapi/routes/accounts.py b/expenses_server_fastapi/routes/accounts.py deleted file mode 100644 index a68872f..0000000 --- a/expenses_server_fastapi/routes/accounts.py +++ /dev/null @@ -1,13 +0,0 @@ -from fastapi import APIRouter, Depends -from sqlalchemy.orm import Session -from expenses_server_fastapi.db import get_db -from expenses_server_fastapi.db_models.accounts import Account as DBAccount -from expenses_server_fastapi.dtos.accounts import Account - - -router = APIRouter(prefix="/accounts") - -@router.get("", response_model=list[Account]) -def get_all_accounts(db: Session = Depends(get_db)): - # TODO: Only accounts for user should be sent - return db.query(DBAccount).all() \ No newline at end of file diff --git a/functional-tests/helpers.bash b/functional-tests/helpers.bash new file mode 100644 index 0000000..163f5d5 --- /dev/null +++ b/functional-tests/helpers.bash @@ -0,0 +1,85 @@ +#!/usr/bin/env bats +BASE_URL=http://127.0.0.1:8080 + +do_post() { + local path="${1?}" + local data="${2?}" + curl \ + -X POST \ + --silent \ + "${BASE_URL}/${path}" \ + -H 'Content-Type: application/json' \ + -d "$data" +} + +do_put() { + local path="${1?}" + local data="${2?}" + curl \ + -X PUT \ + --silent \ + "${BASE_URL}/${path}" \ + -H 'Content-Type: application/json' \ + -d "$data" +} + +do_get() { + local path="${1?}" + curl \ + -X GET \ + --silent \ + "${BASE_URL}/${path}" \ + -H 'Content-Type: application/json' +} + +do_delete() { + local path="${1?}" + curl \ + -X DELETE \ + --silent \ + "${BASE_URL}/${path}" \ + -H 'Content-Type: application/json' +} + +is_equal() { + local left="${1?}" + local right="${2?}" + diff <( printf '%s' "$left" ) <( printf "%s" "$right" ) \ + && return 0 + echo -e "is_equal failed\nleft: $left\nright: $right" >&2 + return 1 +} + +match_regex() { + local regex="${1?}" + local what="${2?}" + [[ "$what" =~ $regex ]] && return 0 + echo -e "match_regex failed\nregex: '$regex'\nwhat: $what" >&2 + return 1 +} + +json_has_equal() { + local key="${1?}" + local value="${2?}" + local data="${3?}" + local cur_value=$(echo "$data" | jq -r ".$key") \ + && is_equal "$cur_value" "$value" \ + && return 0 + echo -e "json_has_equal: key '$key' with value '$value' not found in \n$data" >&2 + return 1 +} + +json_has_match() { + local key="${1?}" + local match="${2?}" + local data="${3?}" + local cur_value=$(echo "$data" | jq -r ".$key") + match_regex "$match" "$cur_value" && return 0 + echo -e "json_has_match: key '$key' value '$cur_value' does not match '$match'" >&2 + return 1 +} + +json_get() { + local key="${1?}" + jq -r ".$key" +} diff --git a/functional-tests/test_expenses.bats b/functional-tests/test_expenses.bats new file mode 100644 index 0000000..11374f5 --- /dev/null +++ b/functional-tests/test_expenses.bats @@ -0,0 +1,114 @@ +#!/usr/bin/env bats + +# per whole file +setup_file() { + curdir="${BATS_TEST_FILENAME%/*}" + db_host="127.0.0.1" + test_db="expenses" + test_db_pass="dummypass" + test_db_user="expenses" + mysql="mysql \ + --host=$db_host \ + --port=3306 \ + --protocol=tcp \ + --user=$test_db_user \ + --password=$test_db_pass" + + $mysql -e "drop database if exists $test_db" + $mysql -e "create database $test_db" + $mysql "$test_db" < $curdir/../db/schema.sql + $mysql -e "insert into accounts values (0, 'Test account 0'),(1, 'Test account 1')" "$test_db" + $mysql -e "select * from expenses;" "$test_db" +} + + +# per test +setup() { + load helpers +} + +@test "I can get the expenses (empty database)" { + run do_get "api/expenses" + + [[ "$status" == "0" ]] + is_equal \ + '[]' \ + "$output" +} + +@test "I can create a new expense" { + new_expense='{ + "currency": "EUR", + "amount": "42.0", + "category": "eating-out", + "description": "Some fake expense number 1", + "accountId": 1, + "timestamp": "'"$(date +%Y-%m-%dT%H:%M:%SZ)"'" + }' + run do_post "api/expenses" "$new_expense" + + [[ "$status" == "0" ]] + json_has_match 'amount' '42' "$output" + json_has_match 'category' 'eating-out' "$output" + json_has_match 'description' 'Some fake expense number 1' "$output" + json_has_match 'account.id' '1' "$output" +} + +@test "I can update an expense" { + new_expense='{ + "currency": "EUR", + "amount": "42.0", + "category": "eating-out", + "description": "Some fake expense number 1", + "accountId": 1, + "timestamp": "'"$(date +%Y-%m-%dT%H:%M:%SZ)"'" + }' + run do_post "api/expenses" "$new_expense" + + [[ "$status" == "0" ]] + json_has_match 'amount' '42' "$output" + + new_expense_id="$(echo "$output" | json_get "id")" + modified_expense='{ + "currency": "EUR", + "amount": "84.0", + "category": "eating-out", + "description": "Some fake expense number 1", + "accountId": 1, + "timestamp": "'"$(date +%Y-%m-%dT%H:%M:%SZ)"'" + }' + run do_put "api/expenses/$new_expense_id" "$modified_expense" + + [[ "$status" == "0" ]] + json_has_match "amount" '84' "$output" + + run do_get "api/expenses" + + json_has_match "[] | select( .id == $new_expense_id) | .amount" '84' "$output" +} + +@test "I can delete an expense" { + new_expense='{ + "currency": "EUR", + "amount": "42.0", + "category": "eating-out", + "description": "Some fake expense number 1", + "accountId": 1, + "timestamp": "'"$(date +%Y-%m-%dT%H:%M:%SZ)"'" + }' + run do_post "api/expenses" "$new_expense" + + [[ "$status" == "0" ]] + json_has_match 'amount' '42' "$output" + + new_expense_id="$(echo "$output" | json_get "id")" + + run do_delete "api/expenses/$new_expense_id" + + [[ "$status" == "0" ]] + [[ "$output" == "$new_expense_id" ]] + + run do_get "api/expenses" + + json_has_match "[] | select( .id == $new_expense_id)" '' "$output" +} diff --git a/poetry.lock b/poetry.lock index 584e5bc..7b5d0c3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -31,6 +31,17 @@ doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphin test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] trio = ["trio (>=0.23)"] +[[package]] +name = "bats-core-pkg" +version = "0.1.10.post2" +description = "Python wrapper on tops of bats-core" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "bats_core_pkg-0.1.10.post2-py3-none-any.whl", hash = "sha256:fe9c4deb12bdaea64bd06d18ac0680003f47d18242d1bcf500034813a88e7be6"}, + {file = "bats_core_pkg-0.1.10.post2.tar.gz", hash = "sha256:db687c13b8f0fafba4bed2996f4ae3d7bbad9f8aba6cd37dd72f9abe9960950d"}, +] + [[package]] name = "certifi" version = "2024.2.2" @@ -42,6 +53,17 @@ files = [ {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, ] +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + [[package]] name = "click" version = "8.1.7" @@ -67,6 +89,17 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "distlib" +version = "0.3.8" +description = "Distribution utilities" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, +] + [[package]] name = "dnspython" version = "2.6.1" @@ -132,6 +165,22 @@ uvicorn = {version = ">=0.12.0", extras = ["standard"], optional = true, markers [package.extras] all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +[[package]] +name = "filelock" +version = "3.13.1" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, + {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8)"] + [[package]] name = "greenlet" version = "3.0.3" @@ -307,6 +356,20 @@ cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] +[[package]] +name = "identify" +version = "2.5.35" +description = "File identification library for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "identify-2.5.35-py2.py3-none-any.whl", hash = "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e"}, + {file = "identify-2.5.35.tar.gz", hash = "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791"}, +] + +[package.extras] +license = ["ukkonen"] + [[package]] name = "idna" version = "3.6" @@ -318,6 +381,17 @@ files = [ {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, ] +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + [[package]] name = "itsdangerous" version = "2.1.2" @@ -415,6 +489,77 @@ files = [ {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, ] +[[package]] +name = "mypy" +version = "1.9.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f"}, + {file = "mypy-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed"}, + {file = "mypy-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150"}, + {file = "mypy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374"}, + {file = "mypy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03"}, + {file = "mypy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3"}, + {file = "mypy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc"}, + {file = "mypy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129"}, + {file = "mypy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612"}, + {file = "mypy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3"}, + {file = "mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd"}, + {file = "mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6"}, + {file = "mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185"}, + {file = "mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913"}, + {file = "mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"}, + {file = "mypy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b"}, + {file = "mypy-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2"}, + {file = "mypy-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e"}, + {file = "mypy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04"}, + {file = "mypy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89"}, + {file = "mypy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02"}, + {file = "mypy-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4"}, + {file = "mypy-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d"}, + {file = "mypy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf"}, + {file = "mypy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9"}, + {file = "mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e"}, + {file = "mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nodeenv" +version = "1.8.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +files = [ + {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, + {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, +] + +[package.dependencies] +setuptools = "*" + [[package]] name = "orjson" version = "3.9.15" @@ -474,6 +619,65 @@ files = [ {file = "orjson-3.9.15.tar.gz", hash = "sha256:95cae920959d772f30ab36d3b25f83bb0f3be671e986c72ce22f8fa700dae061"}, ] +[[package]] +name = "packaging" +version = "24.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, +] + +[[package]] +name = "platformdirs" +version = "4.2.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, + {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] + +[[package]] +name = "pluggy" +version = "1.4.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, + {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "3.6.2" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pre_commit-3.6.2-py2.py3-none-any.whl", hash = "sha256:ba637c2d7a670c10daedc059f5c49b5bd0aadbccfcd7ec15592cf9665117532c"}, + {file = "pre_commit-3.6.2.tar.gz", hash = "sha256:c3ef34f463045c88658c5b99f38c1e297abdcc0ff13f98d3370055fbbfabc67e"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + [[package]] name = "psycopg2-binary" version = "2.9.9" @@ -701,6 +905,44 @@ python-dotenv = ">=0.21.0" toml = ["tomli (>=2.0.1)"] yaml = ["pyyaml (>=6.0.1)"] +[[package]] +name = "pyright" +version = "1.1.354" +description = "Command line wrapper for pyright" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyright-1.1.354-py3-none-any.whl", hash = "sha256:f28d61ae8ae035fc52ded1070e8d9e786051a26a4127bbd7a4ba0399b81b37b5"}, + {file = "pyright-1.1.354.tar.gz", hash = "sha256:b1070dc774ff2e79eb0523fe87f4ba9a90550de7e4b030a2bc9e031864029a1f"}, +] + +[package.dependencies] +nodeenv = ">=1.6.0" + +[package.extras] +all = ["twine (>=3.4.1)"] +dev = ["twine (>=3.4.1)"] + +[[package]] +name = "pytest" +version = "8.1.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, + {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.4,<2.0" + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + [[package]] name = "python-dotenv" version = "1.0.1" @@ -789,6 +1031,48 @@ files = [ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, ] +[[package]] +name = "ruff" +version = "0.3.3" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.3.3-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:973a0e388b7bc2e9148c7f9be8b8c6ae7471b9be37e1cc732f8f44a6f6d7720d"}, + {file = "ruff-0.3.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfa60d23269d6e2031129b053fdb4e5a7b0637fc6c9c0586737b962b2f834493"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1eca7ff7a47043cf6ce5c7f45f603b09121a7cc047447744b029d1b719278eb5"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7d3f6762217c1da954de24b4a1a70515630d29f71e268ec5000afe81377642d"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b24c19e8598916d9c6f5a5437671f55ee93c212a2c4c569605dc3842b6820386"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5a6cbf216b69c7090f0fe4669501a27326c34e119068c1494f35aaf4cc683778"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:352e95ead6964974b234e16ba8a66dad102ec7bf8ac064a23f95371d8b198aab"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d6ab88c81c4040a817aa432484e838aaddf8bfd7ca70e4e615482757acb64f8"}, + {file = "ruff-0.3.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79bca3a03a759cc773fca69e0bdeac8abd1c13c31b798d5bb3c9da4a03144a9f"}, + {file = "ruff-0.3.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2700a804d5336bcffe063fd789ca2c7b02b552d2e323a336700abb8ae9e6a3f8"}, + {file = "ruff-0.3.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fd66469f1a18fdb9d32e22b79f486223052ddf057dc56dea0caaf1a47bdfaf4e"}, + {file = "ruff-0.3.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:45817af234605525cdf6317005923bf532514e1ea3d9270acf61ca2440691376"}, + {file = "ruff-0.3.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0da458989ce0159555ef224d5b7c24d3d2e4bf4c300b85467b08c3261c6bc6a8"}, + {file = "ruff-0.3.3-py3-none-win32.whl", hash = "sha256:f2831ec6a580a97f1ea82ea1eda0401c3cdf512cf2045fa3c85e8ef109e87de0"}, + {file = "ruff-0.3.3-py3-none-win_amd64.whl", hash = "sha256:be90bcae57c24d9f9d023b12d627e958eb55f595428bafcb7fec0791ad25ddfc"}, + {file = "ruff-0.3.3-py3-none-win_arm64.whl", hash = "sha256:0171aab5fecdc54383993389710a3d1227f2da124d76a2784a7098e818f92d61"}, + {file = "ruff-0.3.3.tar.gz", hash = "sha256:38671be06f57a2f8aba957d9f701ea889aa5736be806f18c0cd03d6ff0cbca8d"}, +] + +[[package]] +name = "setuptools" +version = "69.2.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-69.2.0-py3-none-any.whl", hash = "sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c"}, + {file = "setuptools-69.2.0.tar.gz", hash = "sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + [[package]] name = "sniffio" version = "1.3.1" @@ -1068,6 +1352,26 @@ files = [ docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0)", "aiohttp (>=3.8.1)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] +[[package]] +name = "virtualenv" +version = "20.25.1" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.25.1-py3-none-any.whl", hash = "sha256:961c026ac520bac5f69acb8ea063e8a4f071bcc9457b9c1f28f6b085c511583a"}, + {file = "virtualenv-20.25.1.tar.gz", hash = "sha256:e08e13ecdca7a0bd53798f356d5831434afa5b07b93f0abdf0797b7a06ffe197"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + [[package]] name = "watchfiles" version = "0.21.0" @@ -1239,4 +1543,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "b4b7cb75177b9f7defd18f502f42d20c3ded854673bd682f20df64a8815dbbfa" +content-hash = "b02c22fdef5b404306c93b6cc3b1848f4f53a67943916fd0bcfaf92eb6cc2a0c" diff --git a/pyproject.toml b/pyproject.toml index b7ed96c..fa7defa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "Python server for expenses using fastapi" authors = ["Dinika Saxena "] readme = "README.md" -packages = [{include = "expenses_server_fastapi"}] +packages = [{include = "expenses_server"}] [tool.poetry.dependencies] python = "^3.12" @@ -14,6 +14,16 @@ pydantic = "^2.6.3" uuid = "^1.30" psycopg2-binary = "^2.9.9" +[tool.mypy] + +[tool.poetry.group.dev.dependencies] +pre-commit = "^3.6.2" +ruff = "^0.3.3" +uvicorn = "^0.28.0" +pyright = "^1.1.354" +mypy = "^1.9.0" +pytest = "^8.1.1" +bats-core-pkg = "^0.1.10.post2" [build-system] requires = ["poetry-core"] diff --git a/test.py b/test.py new file mode 100644 index 0000000..c88a013 --- /dev/null +++ b/test.py @@ -0,0 +1,30 @@ +from sqlalchemy.orm import ( + Mapped, + DeclarativeBase, + MappedAsDataclass, + relationship, + mapped_column, +) + + +class Base(MappedAsDataclass, DeclarativeBase): + pass + + +class Role(Base): + __tablename__ = "roles" + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + name: Mapped[str] + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True, init=False) + name: Mapped[str] + role: Mapped[Role] = relationship("Role", back_populates="roles", init=False) + + +my_role = Role(name="somerole") +my_user = User(name="User1")