diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..287e696 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,6 @@ +repos: + - repo: https://github.com/psf/black + rev: 25.1.0 + hooks: + - id: black + language_version: python3.13.3 \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 401becc..d933425 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,7 +8,7 @@ "**/.DS_Store": true }, "[python]": { - "editor.defaultFormatter": "ms-python.autopep8", + "editor.defaultFormatter": "ms-python.black-formatter", "editor.formatOnSave": true }, "sonarlint.connectedMode.project": { diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e46e622..0bf7556 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,9 +44,17 @@ We enforce quality via CI on every push and PR: Failures must be fixed before review. -## 6. Code of Conduct & Support +## 6. Formatting + +We use [Black](https://black.readthedocs.io/) as the standard Python formatter in this project: + +- All Python code must be formatted with Black before committing or pushing +- You can install the pre-commit hook to automatically format your code before each commit + +## 7. Code of Conduct & Support - Please see `CODE_OF_CONDUCT.md` for behavioral expectations and reporting. - For quick questions or discussions, open an issue with the `discussion` label or mention a maintainer. Thanks again for helping keep this project small, simple, and impactful! + diff --git a/databases/player_database.py b/databases/player_database.py index a18607d..9a5fd5d 100644 --- a/databases/player_database.py +++ b/databases/player_database.py @@ -8,6 +8,7 @@ The `STORAGE_PATH` environment variable controls the SQLite file location. """ + import logging import os from typing import AsyncGenerator @@ -21,16 +22,11 @@ logging.getLogger("sqlalchemy.engine.Engine").handlers = logger.handlers async_engine = create_async_engine( - DATABASE_URL, - connect_args={"check_same_thread": False}, - echo=True + DATABASE_URL, connect_args={"check_same_thread": False}, echo=True ) async_sessionmaker = sessionmaker( - bind=async_engine, - class_=AsyncSession, - autocommit=False, - autoflush=False + bind=async_engine, class_=AsyncSession, autocommit=False, autoflush=False ) Base = declarative_base() diff --git a/main.py b/main.py index 14e912d..a1167f0 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ This serves as the entry point for running the API server. """ + from contextlib import asynccontextmanager import logging from typing import AsyncIterator @@ -17,6 +18,7 @@ UVICORN_LOGGER = "uvicorn.error" logger = logging.getLogger(UVICORN_LOGGER) + @asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: """ @@ -25,10 +27,13 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: logger.info("Lifespan event handler execution complete.") yield -app = FastAPI(lifespan=lifespan, - title="python-samples-fastapi-restful", - description="🧪 Proof of Concept for a RESTful API made with Python 3 and FastAPI", - version="1.0.0",) + +app = FastAPI( + lifespan=lifespan, + title="python-samples-fastapi-restful", + description="🧪 Proof of Concept for a RESTful API made with Python 3 and FastAPI", + version="1.0.0", +) app.include_router(player_route.api_router) app.include_router(health_route.api_router) diff --git a/models/player_model.py b/models/player_model.py index 7b3f419..5a79373 100644 --- a/models/player_model.py +++ b/models/player_model.py @@ -6,6 +6,7 @@ These models are used for data validation and serialization in the API. """ + from typing import Optional from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_camel @@ -24,6 +25,7 @@ class MainModel(BaseModel): Here, it uses `to_camel` to convert field names to camelCase. populate_by_name (bool): Allows population of fields by name when using Pydantic models. """ + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) @@ -44,6 +46,7 @@ class PlayerModel(MainModel): league (Optional[str]): The league where the team plays, if any. starting11 (Optional[bool]): Indicates if the Player is in the starting 11, if provided. """ + id: int first_name: str middle_name: Optional[str] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d9be0df --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.black] +line-length = 88 +target-version = ['py312'] \ No newline at end of file diff --git a/requirements-lint.txt b/requirements-lint.txt index 3062a4f..6c1f95d 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -1 +1,2 @@ flake8==7.2.0 +black==25.1.0 diff --git a/requirements-pre-commit b/requirements-pre-commit new file mode 100644 index 0000000..ff7a621 --- /dev/null +++ b/requirements-pre-commit @@ -0,0 +1 @@ +pre-commit==4.2.0 \ No newline at end of file diff --git a/routes/health_route.py b/routes/health_route.py index 838f454..1190af6 100644 --- a/routes/health_route.py +++ b/routes/health_route.py @@ -4,10 +4,12 @@ Defines a simple endpoint to verify that the service is up and running. Returns a JSON response with a "status" key set to "ok". """ + from fastapi import APIRouter api_router = APIRouter() + @api_router.get("/health", tags=["Health"]) async def health_check(): """ diff --git a/routes/player_route.py b/routes/player_route.py index 39e55bf..b6efcad 100644 --- a/routes/player_route.py +++ b/routes/player_route.py @@ -16,6 +16,7 @@ - PUT /players/{player_id} : Update an existing Player. - DELETE /players/{player_id} : Delete an existing Player. """ + from typing import List from fastapi import APIRouter, Body, Depends, HTTPException, status, Path, Response from sqlalchemy.ext.asyncio import AsyncSession @@ -29,7 +30,7 @@ simple_memory_cache = SimpleMemoryCache() CACHE_KEY = "players" -CACHE_TTL = 600 # 10 minutes +CACHE_TTL = 600 # 10 minutes # POST ------------------------------------------------------------------------- @@ -38,11 +39,11 @@ "/players/", status_code=status.HTTP_201_CREATED, summary="Creates a new Player", - tags=["Players"] + tags=["Players"], ) async def post_async( player_model: PlayerModel = Body(...), - async_session: AsyncSession = Depends(generate_async_session) + async_session: AsyncSession = Depends(generate_async_session), ): """ Endpoint to create a new player. @@ -60,6 +61,7 @@ async def post_async( await player_service.create_async(async_session, player_model) await simple_memory_cache.clear(CACHE_KEY) + # GET -------------------------------------------------------------------------- @@ -68,11 +70,10 @@ async def post_async( response_model=List[PlayerModel], status_code=status.HTTP_200_OK, summary="Retrieves a collection of Players", - tags=["Players"] + tags=["Players"], ) async def get_all_async( - response: Response, - async_session: AsyncSession = Depends(generate_async_session) + response: Response, async_session: AsyncSession = Depends(generate_async_session) ): """ Endpoint to retrieve all players. @@ -97,11 +98,11 @@ async def get_all_async( response_model=PlayerModel, status_code=status.HTTP_200_OK, summary="Retrieves a Player by its Id", - tags=["Players"] + tags=["Players"], ) async def get_by_id_async( player_id: int = Path(..., title="The ID of the Player"), - async_session: AsyncSession = Depends(generate_async_session) + async_session: AsyncSession = Depends(generate_async_session), ): """ Endpoint to retrieve a Player by its ID. @@ -127,11 +128,11 @@ async def get_by_id_async( response_model=PlayerModel, status_code=status.HTTP_200_OK, summary="Retrieves a Player by its Squad Number", - tags=["Players"] + tags=["Players"], ) async def get_by_squad_number_async( squad_number: int = Path(..., title="The Squad Number of the Player"), - async_session: AsyncSession = Depends(generate_async_session) + async_session: AsyncSession = Depends(generate_async_session), ): """ Endpoint to retrieve a Player by its Squad Number. @@ -146,11 +147,14 @@ async def get_by_squad_number_async( Raises: HTTPException: HTTP 404 Not Found error if the Player with the specified Squad Number does not exist. """ - player = await player_service.retrieve_by_squad_number_async(async_session, squad_number) + player = await player_service.retrieve_by_squad_number_async( + async_session, squad_number + ) if not player: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) return player + # PUT -------------------------------------------------------------------------- @@ -158,12 +162,12 @@ async def get_by_squad_number_async( "/players/{player_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Updates an existing Player", - tags=["Players"] + tags=["Players"], ) async def put_async( player_id: int = Path(..., title="The ID of the Player"), player_model: PlayerModel = Body(...), - async_session: AsyncSession = Depends(generate_async_session) + async_session: AsyncSession = Depends(generate_async_session), ): """ Endpoint to entirely update an existing Player. @@ -182,6 +186,7 @@ async def put_async( await player_service.update_async(async_session, player_model) await simple_memory_cache.clear(CACHE_KEY) + # DELETE ----------------------------------------------------------------------- @@ -189,11 +194,11 @@ async def put_async( "/players/{player_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Deletes an existing Player", - tags=["Players"] + tags=["Players"], ) async def delete_async( player_id: int = Path(..., title="The ID of the Player"), - async_session: AsyncSession = Depends(generate_async_session) + async_session: AsyncSession = Depends(generate_async_session), ): """ Endpoint to delete an existing Player. diff --git a/schemas/player_schema.py b/schemas/player_schema.py index f769809..a3524a3 100644 --- a/schemas/player_schema.py +++ b/schemas/player_schema.py @@ -5,6 +5,7 @@ Used for async database CRUD operations in the application. """ + from sqlalchemy import Column, String, Integer, Boolean from databases.player_database import Base @@ -30,13 +31,13 @@ class Player(Base): __tablename__ = "players" id = Column(Integer, primary_key=True) - first_name = Column(String, name='firstName', nullable=False) - middle_name = Column(String, name='middleName') - last_name = Column(String, name='lastName', nullable=False) + first_name = Column(String, name="firstName", nullable=False) + middle_name = Column(String, name="middleName") + last_name = Column(String, name="lastName", nullable=False) date_of_birth = Column(String, name="dateOfBirth") - squad_number = Column(Integer, name='squadNumber', unique=True, nullable=False) + squad_number = Column(Integer, name="squadNumber", unique=True, nullable=False) position = Column(String, nullable=False) - abbr_position = Column(String, name='abbrPosition') + abbr_position = Column(String, name="abbrPosition") team = Column(String) league = Column(String) starting11 = Column(Boolean) diff --git a/services/player_service.py b/services/player_service.py index 5464e03..5ddf3ee 100644 --- a/services/player_service.py +++ b/services/player_service.py @@ -11,6 +11,7 @@ Handles SQLAlchemy exceptions with transaction rollback and logs errors. """ + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.exc import SQLAlchemyError @@ -42,6 +43,7 @@ async def create_async(async_session: AsyncSession, player_model: PlayerModel): await async_session.rollback() return False + # Retrieve --------------------------------------------------------------------- @@ -77,7 +79,9 @@ async def retrieve_by_id_async(async_session: AsyncSession, player_id: int): return player -async def retrieve_by_squad_number_async(async_session: AsyncSession, squad_number: int): +async def retrieve_by_squad_number_async( + async_session: AsyncSession, squad_number: int +): """ Retrieves a Player by its Squad Number from the database. @@ -93,6 +97,7 @@ async def retrieve_by_squad_number_async(async_session: AsyncSession, squad_numb player = result.scalars().first() return player + # Update ----------------------------------------------------------------------- @@ -127,6 +132,7 @@ async def update_async(async_session: AsyncSession, player_model: PlayerModel): await async_session.rollback() return False + # Delete ----------------------------------------------------------------------- diff --git a/tests/player_stub.py b/tests/player_stub.py index 0c12bc0..34bd629 100644 --- a/tests/player_stub.py +++ b/tests/player_stub.py @@ -15,7 +15,7 @@ def __init__( abbr_position=None, team=None, league=None, - starting11=None + starting11=None, ): self.id = id self.first_name = first_name @@ -77,5 +77,5 @@ def unknown_player(): first_name="John", last_name="Doe", squad_number="999", - position="Lipsum" + position="Lipsum", ) diff --git a/tests/test_main.py b/tests/test_main.py index 9528352..bbc1222 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -15,6 +15,7 @@ - Handling of existing, nonexistent, and malformed requests - Conflict and edge case behaviors """ + import json from tests.player_stub import existing_player, nonexistent_player, unknown_player @@ -22,11 +23,14 @@ # GET /health/ ----------------------------------------------------------------- -def test_given_get_when_request_path_is_health_then_response_status_code_should_be_200_ok(client): + +def test_given_get_when_request_path_is_health_then_response_status_code_should_be_200_ok( + client, +): """ - Given GET /health/ - when request - then response Status Code should be 200 OK + Given GET /health/ + when request + then response Status Code should be 200 OK """ # Act response = client.get("/health/") @@ -34,14 +38,17 @@ def test_given_get_when_request_path_is_health_then_response_status_code_should_ assert response.status_code == 200 assert response.json() == {"status": "ok"} + # GET /players/ ---------------------------------------------------------------- -def test_given_get_when_request_is_initial_then_response_header_x_cache_should_be_miss(client): +def test_given_get_when_request_is_initial_then_response_header_x_cache_should_be_miss( + client, +): """ - Given GET /players/ - when request is initial - then response Header X-Cache value should be MISS + Given GET /players/ + when request is initial + then response Header X-Cache value should be MISS """ # Act response = client.get(PATH) @@ -50,25 +57,31 @@ def test_given_get_when_request_is_initial_then_response_header_x_cache_should_b assert "X-Cache" in response.headers assert response.headers.get("X-Cache") == "MISS" -def test_given_get_when_request_is_subsequent_then_response_header_x_cache_should_be_hit(client): + +def test_given_get_when_request_is_subsequent_then_response_header_x_cache_should_be_hit( + client, +): """ - Given GET /players/ - when request is subsequent - then response Header X-Cache should be HIT + Given GET /players/ + when request is subsequent + then response Header X-Cache should be HIT """ # Act - client.get(PATH) # initial - response = client.get(PATH) # subsequent (cached) + client.get(PATH) # initial + response = client.get(PATH) # subsequent (cached) # Assert assert "X-Cache" in response.headers assert response.headers.get("X-Cache") == "HIT" -def test_given_get_when_request_path_has_no_id_then_response_status_code_should_be_200_ok(client): + +def test_given_get_when_request_path_has_no_id_then_response_status_code_should_be_200_ok( + client, +): """ - Given GET /players/ - when request path has no ID - then response Status Code should be 200 OK + Given GET /players/ + when request path has no ID + then response Status Code should be 200 OK """ # Act response = client.get(PATH) @@ -76,11 +89,13 @@ def test_given_get_when_request_path_has_no_id_then_response_status_code_should_ assert response.status_code == 200 -def test_given_get_when_request_path_has_no_id_then_response_body_should_be_collection_of_players(client): +def test_given_get_when_request_path_has_no_id_then_response_body_should_be_collection_of_players( + client, +): """ - Given GET /players/ - when request path has no ID - then response Body should be collection of players + Given GET /players/ + when request path has no ID + then response Body should be collection of players """ # Act response = client.get(PATH) @@ -91,14 +106,17 @@ def test_given_get_when_request_path_has_no_id_then_response_body_should_be_coll player_id += 1 assert player["id"] == player_id + # GET /players/{player_id} ----------------------------------------------------- -def test_given_get_when_request_path_is_nonexistent_id_then_response_status_code_should_be_404_not_found(client): +def test_given_get_when_request_path_is_nonexistent_id_then_response_status_code_should_be_404_not_found( + client, +): """ - Given GET /players/{player_id} - when request path is nonexistent ID - then response Status Code should be 404 (Not Found) + Given GET /players/{player_id} + when request path is nonexistent ID + then response Status Code should be 404 (Not Found) """ # Arrange player_id = nonexistent_player().id @@ -108,11 +126,13 @@ def test_given_get_when_request_path_is_nonexistent_id_then_response_status_code assert response.status_code == 404 -def test_given_get_when_request_path_is_existing_id_then_response_status_code_should_be_200_ok(client): +def test_given_get_when_request_path_is_existing_id_then_response_status_code_should_be_200_ok( + client, +): """ - Given GET /players/{player_id} - when request path is existing ID - then response Status Code should be 200 (OK) + Given GET /players/{player_id} + when request path is existing ID + then response Status Code should be 200 (OK) """ # Arrange player_id = existing_player().id @@ -122,11 +142,13 @@ def test_given_get_when_request_path_is_existing_id_then_response_status_code_sh assert response.status_code == 200 -def test_given_get_when_request_path_is_existing_id_then_response_body_should_be_matching_player(client): +def test_given_get_when_request_path_is_existing_id_then_response_body_should_be_matching_player( + client, +): """ - Given GET /players/{player_id} - when request path is existing ID - then response body should be matching Player + Given GET /players/{player_id} + when request path is existing ID + then response body should be matching Player """ # Arrange player_id = existing_player().id @@ -136,14 +158,17 @@ def test_given_get_when_request_path_is_existing_id_then_response_body_should_be player = response.json() assert player["id"] == player_id + # GET /players/squadnumber/{squad_number} -------------------------------------- -def test_given_get_when_request_path_is_nonexistent_squad_number_then_response_status_code_should_be_404_not_found(client): +def test_given_get_when_request_path_is_nonexistent_squad_number_then_response_status_code_should_be_404_not_found( + client, +): """ - Given GET /players/squadnumber/{squad_number} - when request path is nonexistent Squad Number - then response Status Code should be 404 (Not Found) + Given GET /players/squadnumber/{squad_number} + when request path is nonexistent Squad Number + then response Status Code should be 404 (Not Found) """ # Arrange squad_number = nonexistent_player().squad_number @@ -153,11 +178,13 @@ def test_given_get_when_request_path_is_nonexistent_squad_number_then_response_s assert response.status_code == 404 -def test_given_get_when_request_path_is_existing_squad_number_then_response_status_code_should_be_200_ok(client): +def test_given_get_when_request_path_is_existing_squad_number_then_response_status_code_should_be_200_ok( + client, +): """ - Given GET /players/squadnumber/{squad_number} - when request path is existing Squad Number - then response Status Code should be 200 (OK) + Given GET /players/squadnumber/{squad_number} + when request path is existing Squad Number + then response Status Code should be 200 (OK) """ # Arrange squad_number = existing_player().squad_number @@ -167,11 +194,13 @@ def test_given_get_when_request_path_is_existing_squad_number_then_response_stat assert response.status_code == 200 -def test_given_get_when_request_path_is_existing_squad_number_then_response_body_should_be_matching_player(client): +def test_given_get_when_request_path_is_existing_squad_number_then_response_body_should_be_matching_player( + client, +): """ - Given GET /players/squadnumber/{squad_number} - when request path is existing Squad Number - then response body should be matching Player + Given GET /players/squadnumber/{squad_number} + when request path is existing Squad Number + then response body should be matching Player """ # Arrange squad_number = existing_player().squad_number @@ -181,14 +210,17 @@ def test_given_get_when_request_path_is_existing_squad_number_then_response_body player = response.json() assert player["squadNumber"] == squad_number + # POST /players/ --------------------------------------------------------------- -def test_given_post_when_request_body_is_empty_then_response_status_code_should_be_422_unprocessable_entity(client): +def test_given_post_when_request_body_is_empty_then_response_status_code_should_be_422_unprocessable_entity( + client, +): """ - Given POST /players/ - when request body is empty - then response Status Code should be 422 (Unprocessable Entity) + Given POST /players/ + when request body is empty + then response Status Code should be 422 (Unprocessable Entity) """ # Arrange body = {} @@ -198,11 +230,13 @@ def test_given_post_when_request_body_is_empty_then_response_status_code_should_ assert response.status_code == 422 -def test_given_post_when_request_body_is_existing_player_then_response_status_code_should_be_409_conflict(client): +def test_given_post_when_request_body_is_existing_player_then_response_status_code_should_be_409_conflict( + client, +): """ - Given POST /players/ - when request body is existing Player - then response Status Code should be 409 (Conflict) + Given POST /players/ + when request body is existing Player + then response Status Code should be 409 (Conflict) """ # Arrange player = existing_player() @@ -213,11 +247,13 @@ def test_given_post_when_request_body_is_existing_player_then_response_status_co assert response.status_code == 409 -def test_given_post_when_request_body_is_nonexistent_player_then_response_status_code_should_be_201_created(client): +def test_given_post_when_request_body_is_nonexistent_player_then_response_status_code_should_be_201_created( + client, +): """ - Given POST /players/ - when request body is nonexistent Player - then response Status Code should be 201 (Created) + Given POST /players/ + when request body is nonexistent Player + then response Status Code should be 201 (Created) """ # Arrange player = nonexistent_player() @@ -227,14 +263,17 @@ def test_given_post_when_request_body_is_nonexistent_player_then_response_status # Assert assert response.status_code == 201 + # PUT /players/{player_id} ----------------------------------------------------- -def test_given_put_when_request_body_is_empty_then_response_status_code_should_be_422_unprocessable_entity(client): +def test_given_put_when_request_body_is_empty_then_response_status_code_should_be_422_unprocessable_entity( + client, +): """ - Given PUT /players/{player_id} - when request body is empty - then response Status Code should be 422 (Unprocessable Entity) + Given PUT /players/{player_id} + when request body is empty + then response Status Code should be 422 (Unprocessable Entity) """ # Arrange player_id = existing_player().id @@ -245,11 +284,13 @@ def test_given_put_when_request_body_is_empty_then_response_status_code_should_b assert response.status_code == 422 -def test_given_put_when_request_path_is_unknown_id_then_response_status_code_should_be_404_not_found(client): +def test_given_put_when_request_path_is_unknown_id_then_response_status_code_should_be_404_not_found( + client, +): """ - Given PUT /players/{player_id} - when request path is unknown ID - then response Status Code should be 404 (Not Found) + Given PUT /players/{player_id} + when request path is unknown ID + then response Status Code should be 404 (Not Found) """ # Arrange player_id = unknown_player().id @@ -261,11 +302,13 @@ def test_given_put_when_request_path_is_unknown_id_then_response_status_code_sho assert response.status_code == 404 -def test_given_put_when_request_path_is_existing_id_then_response_status_code_should_be_204_no_content(client): +def test_given_put_when_request_path_is_existing_id_then_response_status_code_should_be_204_no_content( + client, +): """ - Given PUT /players/{player_id} - when request path is existing ID - then response Status Code should be 204 (No Content) + Given PUT /players/{player_id} + when request path is existing ID + then response Status Code should be 204 (No Content) """ # Arrange player_id = existing_player().id @@ -278,14 +321,17 @@ def test_given_put_when_request_path_is_existing_id_then_response_status_code_sh # Assert assert response.status_code == 204 + # DELETE /players/{player_id} -------------------------------------------------- -def test_given_delete_when_request_path_is_unknown_id_then_response_status_code_should_be_404_not_found(client): +def test_given_delete_when_request_path_is_unknown_id_then_response_status_code_should_be_404_not_found( + client, +): """ - Given DELETE /players/{player_id} - when request path is unknown ID - then response Status Code should be 404 (Not Found) + Given DELETE /players/{player_id} + when request path is unknown ID + then response Status Code should be 404 (Not Found) """ # Arrange player_id = unknown_player().id @@ -295,11 +341,13 @@ def test_given_delete_when_request_path_is_unknown_id_then_response_status_code_ assert response.status_code == 404 -def test_given_delete_when_request_path_is_existing_id_then_response_status_code_should_be__204_no_content(client): +def test_given_delete_when_request_path_is_existing_id_then_response_status_code_should_be__204_no_content( + client, +): """ - Given DELETE /players/{player_id} - when request path is existing ID - then response Status Code should be 204 (No Content) + Given DELETE /players/{player_id} + when request path is existing ID + then response Status Code should be 204 (No Content) """ # Arrange player_id = 12 # nonexistent_player() previously created