From f44dbb22dbbb68cce18d8ddd9bd002a9a873845e Mon Sep 17 00:00:00 2001 From: Nano Taboada Date: Sun, 25 May 2025 18:49:33 -0300 Subject: [PATCH 1/4] chore: configure Black as the default Python code formatter --- .flake8 | 24 ++------------ .github/workflows/python-app.yml | 8 +++-- .vscode/extensions.json | 1 + .vscode/settings.json | 4 ++- CONTRIBUTING.md | 57 ++++++++++++++++++++------------ pyproject.toml | 16 +++++++++ requirements-lint.txt | 1 + 7 files changed, 66 insertions(+), 45 deletions(-) create mode 100644 pyproject.toml diff --git a/.flake8 b/.flake8 index 2fa7fdf..134992c 100644 --- a/.flake8 +++ b/.flake8 @@ -1,28 +1,10 @@ [flake8] -# Maximum line length allowed -max-line-length = 127 - -# Maximum allowed code complexity (10 is a reasonable threshold) +max-line-length = 88 max-complexity = 10 - -# Only check for specific error codes: -# - E9 -> Syntax errors -# - F63 -> Issues related to improper usage of `+=`, `-=`, etc. -# - F7 -> Issues related to improper `break`, `continue`, etc. -# - F82 -> Undefined names -select = E9,F63,F7,F82 - -# Exclude `.venv` from linting (prevents checking dependencies) +select = E,F,W +extend-ignore = E203, W503 exclude = .venv - -# Print the count of linting errors count = True - -# Show the exact source of the error show-source = True - -# Display statistics of errors at the end of the report statistics = True - -# Enable verbose mode for more detailed output verbose = True diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 4020e8c..9822764 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -31,7 +31,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} cache: 'pip' - - name: Install dependencies + - name: Install lint dependencies run: | python -m pip install --upgrade pip pip install -r requirements-lint.txt @@ -40,6 +40,10 @@ jobs: run: | flake8 . + - name: Check code formatting with Black + run: | + black --check . + test: needs: lint runs-on: ubuntu-latest @@ -53,7 +57,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} cache: 'pip' - - name: Install dependencies + - name: Install test dependencies run: | python -m pip install --upgrade pip pip install -r requirements-test.txt diff --git a/.vscode/extensions.json b/.vscode/extensions.json index b657926..1124ed5 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,6 +2,7 @@ "recommendations": [ "ms-python.python", "ms-python.vscode-pylance", + "ms-python.black-formatter", "github.vscode-pull-request-github", "github.vscode-github-actions", "ms-azuretools.vscode-containers", diff --git a/.vscode/settings.json b/.vscode/settings.json index 401becc..90a9247 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,8 +7,10 @@ "**/.git": true, "**/.DS_Store": true }, + "editor.wordWrapColumn": 88, + "editor.rulers": [88], "[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..e63ea16 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,43 +10,58 @@ We value **incremental, detail‑first contributions** over big rewrites or abst ## 2. Code & Commit Conventions - **Conventional Commits** - Follow : - - `feat: …` for new features - - `fix: …` for bug fixes - - `chore: …` for maintenance + Follow : + - `feat: ` for new features + - `fix: ` for bug fixes + - `chore: ` for maintenance or tooling - **Logical Commits** - Group changes by purpose. It’s okay to have multiple commits in a PR, but if they’re mere checkpoints, squash them into a single logical commit. - -- **Lint & Tests** - Run existing linters/formatters and ensure all tests pass. + Group changes by purpose. Multiple commits are fine, but avoid noise. Squash when appropriate. + +- **Python Formatting & Style** + - Use **[Black](https://black.readthedocs.io/)** for consistent code formatting. + - Black is opinionated: don't argue with it, just run it. + - Line length is set to **88**, matching the default. + - Use **[flake8](https://flake8.pycqa.org/en/latest/)** for static checks. + - Line length also set to 88. + - Some flake8 warnings are disabled (e.g. `E203`, `W503`) to avoid conflicts with Black. + - Run `black .` and `flake8` before submitting. + - Use Python **3.13.x** for local testing and formatting. + +- **Testing** + - Run `pytest` before pushing. + - Ensure coverage isn’t regressing. ## 3. Pull Request Workflow - **One logical change per PR.** -- **Rebase or squash** before opening to keep history concise. +- **Rebase or squash** before opening to keep history clean. - **Title & Description** - - Title uses Conventional Commits style. - - Description explains _what_ and _why_—keep context minimal. + - Use Conventional Commit format. + - Explain _what_ and _why_ concisely in the PR body. ## 4. Issue Reporting -- Search existing issues first. -- Provide a minimal reproducible example and clear steps. +- Search open issues before creating a new one. +- Include clear steps to reproduce and environment details. +- Prefer **focused** issues—don’t bundle multiple topics. ## 5. Automation & Checks -We enforce quality via CI on every push and PR: +All PRs and pushes go through CI: -- **Commitlint** for commit‑message style -- **Linters/Formatters** -- **Unit tests** +- **Commitlint** for commit style +- **Black** for formatting +- **flake8** for static checks +- **pytest** with coverage -Failures must be fixed before review. +PRs must pass all checks to be reviewed. ## 6. 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. +- See `CODE_OF_CONDUCT.md` for guidelines and reporting. +- For questions or planning, open an issue and use the `discussion` label, or mention a maintainer. + +--- -Thanks again for helping keep this project small, simple, and impactful! +Thanks again for helping keep this project small, sharp, and focused. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e1cb092 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[tool.black] +line-length = 88 +target-version = ['py313'] +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.venv + | build + | dist + | __pycache__ + | \.eggs + | \.mypy_cache + | \.tox +)/ +''' 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 From 0e84a751748b56111a319025e5bfeac92c5f23cb Mon Sep 17 00:00:00 2001 From: Nano Taboada Date: Sun, 25 May 2025 20:07:24 -0300 Subject: [PATCH 2/4] chore: reformat codebase with Black --- .flake8 | 1 + databases/player_database.py | 10 +- main.py | 13 ++- models/player_model.py | 3 + pyproject.toml | 14 ++- routes/health_route.py | 2 + routes/player_route.py | 35 +++--- schemas/player_schema.py | 11 +- services/player_service.py | 8 +- tests/player_stub.py | 4 +- tests/test_main.py | 204 +++++++++++++++++++++-------------- 11 files changed, 188 insertions(+), 117 deletions(-) diff --git a/.flake8 b/.flake8 index 134992c..dfaadb5 100644 --- a/.flake8 +++ b/.flake8 @@ -4,6 +4,7 @@ max-complexity = 10 select = E,F,W extend-ignore = E203, W503 exclude = .venv +per-file-ignores = tests/test_main.py: E501 count = True show-source = True statistics = True 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 index e1cb092..484ce82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,16 @@ include = '\.pyi?$' exclude = ''' /( \.git + | \.github + | \.pytest_cache | \.venv - | build - | dist + | \.vscode + | assets + | htmlcov + | postman_collections + | scripts + | storage | __pycache__ - | \.eggs - | \.mypy_cache - | \.tox + | tests/test_main\.py )/ ''' 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..c32946b 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_is_200( + client, +): """ - Given GET /health/ - when request - then response Status Code should be 200 OK + Given GET /health/ + when request + then response Status Code is 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_is_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 is 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_is_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 is 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_is_200( + 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 is 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_is_list_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 is list 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_is_404( + 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 is 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_is_200( + 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 is 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_is_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 is 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_is_404( + 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 is 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_is_200( + 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 is 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_is_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 is 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_is_422( + 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 is 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_is_409( + 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 is 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_is_201( + 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 is 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_is_422( + 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 is 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_is_404( + 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 is 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_is_204( + 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 is 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_is_404( + 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 is 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_is_204( + 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 is 204 (No Content) """ # Arrange player_id = 12 # nonexistent_player() previously created From 44269855f5b8792c3025ad2673ca67ccb4609b00 Mon Sep 17 00:00:00 2001 From: Nano Taboada Date: Sun, 25 May 2025 20:22:47 -0300 Subject: [PATCH 3/4] chore: add Black code style badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 7bd31b2..cdacc6d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ [![codecov](https://codecov.io/gh/nanotaboada/python-samples-fastapi-restful/branch/master/graph/badge.svg?token=A1WNZPRQEJ)](https://codecov.io/gh/nanotaboada/python-samples-fastapi-restful) [![CodeFactor](https://www.codefactor.io/repository/github/nanotaboada/python-samples-fastapi-restful/badge)](https://www.codefactor.io/repository/github/nanotaboada/python-samples-fastapi-restful) [![codebeat badge](https://codebeat.co/badges/4c4f7c08-3b35-4b57-a875-bf2043efe515)](https://codebeat.co/projects/github-com-nanotaboada-python-samples-fastapi-restful-master) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) ## About From cc61d5ad560236cc86d82fba23d086bd2094b11f Mon Sep 17 00:00:00 2001 From: Nano Taboada Date: Sun, 25 May 2025 21:17:28 -0300 Subject: [PATCH 4/4] chore: enable Flake8 on VS Code and align with Black rules --- .vscode/extensions.json | 2 +- .vscode/settings.json | 41 +++++++++++++++++++++++++++++++++++--- models/player_model.py | 9 ++++++--- routes/health_route.py | 3 ++- routes/player_route.py | 18 +++++++++++------ services/player_service.py | 6 ++++-- 6 files changed, 63 insertions(+), 16 deletions(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 1124ed5..b9d9500 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,7 @@ { "recommendations": [ "ms-python.python", - "ms-python.vscode-pylance", + "ms-python.flake8", "ms-python.black-formatter", "github.vscode-pull-request-github", "github.vscode-github-actions", diff --git a/.vscode/settings.json b/.vscode/settings.json index 90a9247..6110b19 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,4 @@ { - "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true, - "python.testing.pytestArgs": ["tests"], "files.exclude": { "**/__pycache__": true, "**/.git": true, @@ -13,6 +10,44 @@ "editor.defaultFormatter": "ms-python.black-formatter", "editor.formatOnSave": true }, + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": ["tests"], + "flake8.enabled": true, + "flake8.importStrategy": "fromEnvironment", + "flake8.path": ["${interpreter}", "-m", "flake8"], + // Point flake8 to use your existing config file automatically + "flake8.args": [ + "--max-line-length=88", + "--max-complexity=10", + "--select=E,F,W", + "--extend-ignore=E203,W503", + "--exclude=.venv", + "--per-file-ignores=tests/test_main.py:E501" + ], + // Exclude files/folders you don’t want to lint (matching Black’s exclude) + "flake8.ignorePatterns": [ + "**/.git/**", + "**/.github/**", + "**/.pytest_cache/**", + "**/.venv/**", + "**/.vscode/**", + "**/assets/**", + "**/htmlcov/**", + "**/postman_collections/**", + "**/scripts/**", + "**/storage/**", + "**/__pycache__/**", + "**/tests/test_main.py" + ], + "flake8.severity": { + "convention": "Information", + "error": "Error", + "fatal": "Error", + "refactor": "Hint", + "warning": "Warning", + "info": "Information" + }, "sonarlint.connectedMode.project": { "connectionId": "nanotaboada", "projectKey": "nanotaboada_python-samples-fastapi-restful" diff --git a/models/player_model.py b/models/player_model.py index 5a79373..0ecaf5a 100644 --- a/models/player_model.py +++ b/models/player_model.py @@ -23,7 +23,8 @@ class MainModel(BaseModel): model_config (ConfigDict): Configuration for Pydantic models, including: alias_generator (function): A function to generate field aliases. 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. + 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) @@ -41,10 +42,12 @@ class PlayerModel(MainModel): date_of_birth (Optional[str]): The date of birth of the Player, if provided. squad_number (int): The unique squad number assigned to the Player. position (str): The playing position of the Player. - abbr_position (Optional[str]): The abbreviated form of the Player's position, if any. + abbr_position (Optional[str]): The abbreviated form of the Player's position, + if any. team (Optional[str]): The team to which the Player belongs, if any. 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. + starting11 (Optional[bool]): Indicates if the Player is in the starting 11, + if provided. """ id: int diff --git a/routes/health_route.py b/routes/health_route.py index 1190af6..0db6333 100644 --- a/routes/health_route.py +++ b/routes/health_route.py @@ -13,6 +13,7 @@ @api_router.get("/health", tags=["Health"]) async def health_check(): """ - Simple health check endpoint. Returns a JSON response with a single key "status" and value "ok". + Simple health check endpoint. + Returns a JSON response with a single key "status" and value "ok". """ return {"status": "ok"} diff --git a/routes/player_route.py b/routes/player_route.py index b6efcad..ddeecc8 100644 --- a/routes/player_route.py +++ b/routes/player_route.py @@ -49,7 +49,8 @@ async def post_async( Endpoint to create a new player. Args: - player_model (PlayerModel): The Pydantic model representing the Player to create. + player_model (PlayerModel): The Pydantic model representing the Player to + create. async_session (AsyncSession): The async version of a SQLAlchemy ORM session. Raises: @@ -115,7 +116,8 @@ async def get_by_id_async( PlayerModel: The Pydantic model representing the matching Player. Raises: - HTTPException: Not found error if the Player with the specified ID does not exist. + HTTPException: Not found error if the Player with the specified ID does not + exist. """ player = await player_service.retrieve_by_id_async(async_session, player_id) if not player: @@ -145,7 +147,8 @@ async def get_by_squad_number_async( PlayerModel: The Pydantic model representing the matching Player. Raises: - HTTPException: HTTP 404 Not Found error if the Player with the specified Squad Number does not exist. + 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 @@ -174,11 +177,13 @@ async def put_async( Args: player_id (int): The ID of the Player to update. - player_model (PlayerModel): The Pydantic model representing the Player to update. + player_model (PlayerModel): The Pydantic model representing the Player to + update. async_session (AsyncSession): The async version of a SQLAlchemy ORM session. Raises: - HTTPException: HTTP 404 Not Found error if the Player with the specified ID does not exist. + HTTPException: HTTP 404 Not Found error if the Player with the specified ID + does not exist. """ player = await player_service.retrieve_by_id_async(async_session, player_id) if not player: @@ -208,7 +213,8 @@ async def delete_async( async_session (AsyncSession): The async version of a SQLAlchemy ORM session. Raises: - HTTPException: HTTP 404 Not Found error if the Player with the specified ID does not exist. + HTTPException: HTTP 404 Not Found error if the Player with the specified ID + does not exist. """ player = await player_service.retrieve_by_id_async(async_session, player_id) if not player: diff --git a/services/player_service.py b/services/player_service.py index 5ddf3ee..5c9e894 100644 --- a/services/player_service.py +++ b/services/player_service.py @@ -27,7 +27,8 @@ async def create_async(async_session: AsyncSession, player_model: PlayerModel): Args: async_session (AsyncSession): The async version of a SQLAlchemy ORM session. - player_model (PlayerModel): The Pydantic model representing the Player to create. + player_model (PlayerModel): The Pydantic model representing the Player to + create. Returns: True if the Player was created successfully, False otherwise. @@ -107,7 +108,8 @@ async def update_async(async_session: AsyncSession, player_model: PlayerModel): Args: async_session (AsyncSession): The async version of a SQLAlchemy ORM session. - player_model (PlayerModel): The Pydantic model representing the Player to update. + player_model (PlayerModel): The Pydantic model representing the Player to + update. Returns: True if the Player was updated successfully, False otherwise.