Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions BUGS.md

This file was deleted.

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ following advantages to starting your own from scratch :
without needing to send username or password again, and should be done
automatically by the Front-End.
- A clean layout to help structure your project.
- Uses the python logger for info/warning/error logging - tying transparently in
to the `uvicorn` logger.
- **A command-line admin tool**. This allows to configure the project metadata
very easily, add users (and make admin), and run a development server. This
can easily be modified to add your own functionality (for example bulk add
Expand Down
7 changes: 3 additions & 4 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,12 @@
- add pagination to the user list endpoint. Implement this in a way that is
generic and can be used for other custom endpoints too. The library
'fastapi-pagination' is really good and performant.
- remove all the `print` statements and replace with proper logging. Hook into
the `uvicorn` logger for this, and offer alternative if uvicorn is not being
used for some reason.
- use an alternative loggier if uvicorn is not being used for some reason.

## Bugs/Annoyances

See [BUGS.md](BUGS.md) for a list of known bugs and annoyances.
- If a user is deleted while logged in, the API returns a 500 (Internal Server
Error).

## Auth

Expand Down
17 changes: 9 additions & 8 deletions app/config/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import rtoml

from app.logs import logger


def get_project_root() -> Path:
"""Return the full path of the project root."""
Expand All @@ -29,12 +31,12 @@ def get_api_version() -> str:
config = rtoml.load(get_toml_path())
version: str = config["project"]["version"]

except KeyError as exc:
print(f"Cannot find the API version in the pyproject.toml file : {exc}")
except KeyError:
logger.error("Cannot find the API version in the pyproject.toml file")
sys.exit(2)

except OSError as exc:
print(f"Cannot read the pyproject.toml file : {exc}")
logger.error(f"Cannot read the pyproject.toml file : {exc}")
sys.exit(2)

else:
Expand All @@ -52,15 +54,14 @@ def get_api_details() -> tuple[str, str, list[dict[str, str]]]:
if not isinstance(authors, list):
authors = [authors]

except KeyError as exc:
print(
"Missing name/description or authors in the pyproject.toml file "
f": {exc}"
except KeyError:
logger.error(
"Missing name/description or authors in the pyproject.toml file"
)
sys.exit(2)

except OSError as exc:
print(f"Cannot read the pyproject.toml file : {exc}")
logger.error(f"Cannot read the pyproject.toml file : {exc}")
sys.exit(2)
else:
return (name, desc, authors)
Expand Down
7 changes: 4 additions & 3 deletions app/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@
from pydantic_settings import BaseSettings, SettingsConfigDict

from app.config.helpers import get_project_root
from app.logs import logger

try:
from .metadata import custom_metadata
except ModuleNotFoundError: # pragma: no cover
print(
"The metadata file could not be found, it may have been deleted.\n"
"Please run 'api-admin custom init' to regenerate defaults."
logger.error(
"The metadata file could not be found, it may have been deleted."
)
logger.error("Please run 'api-admin custom init' to regenerate defaults.")
sys.exit(1)


Expand Down
5 changes: 5 additions & 0 deletions app/logs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Get the 'uvicorn' logger so we can use it in our own logger."""

import logging

logger = logging.getLogger("uvicorn")
26 changes: 13 additions & 13 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from rich import print as rprint
from sqlalchemy.exc import SQLAlchemyError

from app.config.helpers import get_api_version, get_project_root
from app.config.settings import get_settings
from app.database.db import async_session
from app.logs import logger
from app.resources import config_error
from app.resources.routes import api_router

Expand All @@ -22,12 +22,15 @@
# gatekeeper to ensure the user has read the docs and noted the major changes
# since the last version.
if not get_settings().i_read_the_damn_docs:
print(
"\n[red]ERROR: [bold]You didn't read the docs and change the "
"settings in the .env file!\n"
"\nThe API has changed massively since version 0.4.0 and you need to "
"familiarize yourself with the new breaking changes.\n"
"\nSee https://api-template.seapagan.net/important/ for information.\n"
logger.error(
"You didn't read the docs and change the settings in the .env file!"
)
logger.error(
"The API has changed massively since version 0.4.0 and you need to "
"familiarize yourself with the new breaking changes."
)
logger.error(
"See https://api-template.seapagan.net/important/ for information."
)
sys.exit(BLIND_USER_ERROR)

Expand All @@ -43,13 +46,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, None]:
async with async_session() as session:
await session.connection()

rprint("[green]INFO: [/green][bold]Database configuration Tested.")
logger.info("Database configuration Tested.")
except SQLAlchemyError as exc:
rprint(f"[red]ERROR: [bold]Have you set up your .env file?? ({exc})")
rprint(
"[yellow]WARNING: [/yellow]Clearing routes and enabling "
"error message."
)
logger.error(f"Have you set up your .env file?? ({exc})")
logger.warning("Clearing routes and enabling error message.")
app.routes.clear()
app.include_router(config_error.router)

Expand Down
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ following advantages to starting your own from scratch :
automatically by the Front-End.
- Full test coverage using `Pytest`.
- A clean layout to help structure your project.
- Uses the python logger for info/warning/error logging - tying transparently in
to the `uvicorn` logger.
- **A command-line admin tool**. This allows to configure the project metadata
very easily, add users (and make admin), and run a development server. This
can easily be modified to add your own functionality (for example bulk add
Expand Down
4 changes: 2 additions & 2 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,15 @@ mdurl==0.1.2
mergedeep==1.3.4
mkdoc==0.1
mkdocs==1.6.1
mkdocs-autorefs==1.3.0
mkdocs-autorefs==1.3.1
mkdocs-get-deps==0.2.0
mkdocs-git-revision-date-localized-plugin==1.3.0
mkdocs-latest-git-tag-plugin==0.1.2
mkdocs-material==9.5.50
mkdocs-material-extensions==1.3.1
mkdocs-minify-plugin==0.8.0
mkdocs-swagger-ui-tag==0.6.11
mkdocstrings==0.27.0
mkdocstrings==0.28.1
mkdocstrings-python==1.13.0
mock==5.1.0
mypy==1.14.1
Expand Down
2 changes: 1 addition & 1 deletion tests/cli/test_gatekeeper.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,4 @@ def test_getkeeper(self, runner, monkeypatch) -> None:
)

assert result.returncode == BLIND_USER_ERROR
assert "You didn't read the docs" in result.stdout
assert "You didn't read the docs" in result.stderr
79 changes: 64 additions & 15 deletions tests/unit/test_config_helpers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Test config/helpers.py."""

import logging
from pathlib import Path

import pytest
Expand Down Expand Up @@ -44,35 +45,65 @@ def test_get_api_version(self, mocker) -> None:
)
assert get_api_version() == "1.2.3"

def test_get_api_version_missing_toml(self, mocker, capfd) -> None:
def test_get_api_version_missing_toml(self, mocker, caplog) -> None:
"""Test we exit when the toml file is missing."""
mocker.patch(self.mock_load_rtoml, side_effect=FileNotFoundError)

caplog.set_level(logging.ERROR)

with pytest.raises(SystemExit, match="2"):
get_api_version()
out, _ = capfd.readouterr()
assert "Cannot read the pyproject.toml file" in out
log_messages = [
(record.levelname, record.message) for record in caplog.records
]

def test_get_api_version_missing_version(self, mocker, capfd) -> None:
assert len(log_messages) == 1
assert any(
record.levelname == "ERROR"
and "Cannot read the pyproject.toml file" in record.message
for record in caplog.records
), "Expected error log not found"

def test_get_api_version_missing_version(self, mocker, caplog) -> None:
"""Test we exit when the version is missing."""
mocker.patch(
self.mock_load_rtoml,
return_value={"tool": {"poetry": {"version:": ""}}},
)
with pytest.raises(SystemExit, match="2"):
get_api_version()
out, _ = capfd.readouterr()
assert "Cannot find the API version in the pyproject.toml file" in out

def test_get_api_version_missing_key(self, mocker, capfd) -> None:
log_messages = [
(record.levelname, record.message) for record in caplog.records
]

assert len(log_messages) == 1
assert (
"ERROR",
"Cannot find the API version in the pyproject.toml file",
) in log_messages, "Expected error log not found"

def test_get_api_version_missing_key(self, mocker, caplog) -> None:
"""Test we exit when the key is missing."""
mocker.patch(
self.mock_load_rtoml,
return_value={"tool": {"poetry": {}}},
)

caplog.set_level(logging.ERROR)

with pytest.raises(SystemExit, match="2"):
get_api_version()
out, _ = capfd.readouterr()
assert "Cannot find the API version in the pyproject.toml file" in out

log_messages = [
(record.levelname, record.message) for record in caplog.records
]

assert len(log_messages) == 1
assert (
"ERROR",
"Cannot find the API version in the pyproject.toml file",
) in log_messages

def test_get_api_details(self, mocker, capfd) -> None:
"""Test we get the API details."""
Expand Down Expand Up @@ -164,7 +195,7 @@ def test_get_api_details_authors_is_list(self, mocker) -> None:
],
)
def test_get_api_details_missing_key(
self, mocker, capfd, missing_keys
self, mocker, caplog, missing_keys
) -> None:
"""We should return an Error if any details are missing."""
mocker.patch(
Expand All @@ -175,18 +206,36 @@ def test_get_api_details_missing_key(
}
},
)

caplog.set_level(logging.ERROR)

with pytest.raises(SystemExit, match="2"):
get_api_details()
out, _ = capfd.readouterr()
assert "Missing name/description or authors" in out

def test_get_api_details_missing_toml(self, mocker, capfd) -> None:
logger_messages = [
(record.levelname, record.message) for record in caplog.records
]
assert len(logger_messages) == 1
assert (
"ERROR",
"Missing name/description or authors in the pyproject.toml file",
) in logger_messages

def test_get_api_details_missing_toml(self, mocker, caplog) -> None:
"""Test we exit when the toml file is missing."""
mocker.patch(self.mock_load_rtoml, side_effect=FileNotFoundError)

caplog.set_level(logging.ERROR)

with pytest.raises(SystemExit, match="2"):
get_api_details()
out, _ = capfd.readouterr()
assert "Cannot read the pyproject.toml file" in out

assert len(caplog.records) == 1
assert any(
record.levelname == "ERROR"
and "Cannot read the pyproject.toml file" in record.message
for record in caplog.records
)

def test_licences_structure(self) -> None:
"""Test the licences structure."""
Expand Down
37 changes: 30 additions & 7 deletions tests/unit/test_lifespan.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for the 'lifespan' function in the main module."""

import logging

import pytest
from fastapi import FastAPI
from fastapi.routing import APIRoute
Expand Down Expand Up @@ -29,7 +31,7 @@ async def test_lifespan_runs_without_errors(self, mocker) -> None:
mock_session.return_value.__aenter__.return_value.connection.assert_called_once()

async def test_lifespan_prints_informational_message(
self, capsys, mocker
self, caplog, mocker
) -> None:
"""Ensure the lifespan function prints an informational message."""
app = FastAPI()
Expand All @@ -38,10 +40,17 @@ async def test_lifespan_prints_informational_message(
mock_session.return_value.__aenter__.return_value.connection
)
mock_connection.return_value = None

caplog.set_level(logging.INFO)

async with lifespan(app):
pass # NOSONAR
captured = capsys.readouterr()
assert "Database configuration Tested." in captured.out

log_messages = [
(record.levelname, record.message) for record in caplog.records
]

assert ("INFO", "Database configuration Tested.") in log_messages

async def test_lifespan_yields_control(self, mocker) -> None:
"""Ensure the lifespan function yields control to the caller."""
Expand All @@ -55,17 +64,31 @@ async def test_lifespan_yields_control(self, mocker) -> None:
assert result is None

async def test_lifespan_raises_sqlachemy_error(
self, capsys, mocker
self, caplog, mocker
) -> None:
"""Ensure the lifespan function prints an error if fails."""
app = FastAPI()
mock_session = mocker.patch(self.mock_session)
mock_session.return_value.__aenter__.side_effect = SQLAlchemyError

caplog.set_level(logging.WARNING)

async with lifespan(app):
pass # NOSONAR
captured = capsys.readouterr()
assert "Have you set up your .env file??" in captured.out
assert "Clearing routes and enabling error message." in captured.out

log_messages = [
(record.levelname, record.message) for record in caplog.records
]

assert any(
record.levelname == "ERROR"
and "Have you set up your .env file??" in record.message
for record in caplog.records
), "Expected error log not found"
assert (
"WARNING",
"Clearing routes and enabling error message.",
) in log_messages, "Expected warning log not found"

async def test_lifespan_clears_routes_and_enables_error_message(
self, mocker
Expand Down
Loading
Loading