Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Improve code coverage by adding more tests #9

Merged
merged 26 commits into from
Jun 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8510179
Add tests for `get_id()` and `get_ids()`
pkhalaj May 31, 2024
c0dee66
Fix the bug: order of message and status
pkhalaj Jun 1, 2024
2e8e025
Override the `caplog` fixture in pytest
pkhalaj Jun 1, 2024
ad4e143
Set `enqueue=False` in `conftest.py`
pkhalaj Jun 2, 2024
3aba9e6
Fix the bug: store the `database_config` once the client is initialized
pkhalaj Jun 2, 2024
79a67e6
Add `is_initialized()` method to the `MongoDB` class
pkhalaj Jun 2, 2024
7161505
Change the name of `make_test_app_config_as_dict()` and update docstring
pkhalaj Jun 2, 2024
742b84a
Add `@validate_call` to `MongoDB.initialize()`
pkhalaj Jun 2, 2024
9c4687d
Add new tests for the `MongoDB.initialize()` method
pkhalaj Jun 2, 2024
a206fba
Fix the bug: Set `MongoDB.__client=None` once it is closed
pkhalaj Jun 2, 2024
fc74639
Add a test for testing `MongoDB.close()` without initialization
pkhalaj Jun 2, 2024
7c93cda
Set `None` as default values for the args of `MongoDB.get_collection(…
pkhalaj Jun 2, 2024
f937d59
Add tests for `MongoDB.get_collection()` and `MongoDB().get_database()`
pkhalaj Jun 2, 2024
e8014ba
Remove unused `MongoDocument` model from `config.py`
pkhalaj Jun 3, 2024
650f792
Add a specific error message to `config.id_must_be_valid()` when rais…
pkhalaj Jun 4, 2024
a6f136f
Add tests for `config.id_must_be_valid()`
pkhalaj Jun 4, 2024
29f19a7
Extract the part which creates the FastAPI app into a separate module.
pkhalaj Jun 9, 2024
8453356
Switch to `httpx.AsyncClient` for testing the API calls
pkhalaj Jun 9, 2024
2f4e9fa
Change "/databases/" to "/databases"
pkhalaj Jun 9, 2024
ca5d3c3
Add a test for the `api.run_server()`
pkhalaj Jun 10, 2024
27733f7
Add a test for `get_distinct_items_in_collection()`
pkhalaj Jun 10, 2024
aa8302c
Add more tests for `databases.document_by_id()`
pkhalaj Jun 10, 2024
7f50198
Make `check_log()` a fixture
pkhalaj Jun 10, 2024
583284d
Add a test for unknown messages sent to the recoder
pkhalaj Jun 10, 2024
e678756
Change `_aux()` to `check_log_message_at_level()`
pkhalaj Jun 13, 2024
62974a4
Add a note to explain why we change error type in `id_must_be_valid()`
pkhalaj Jun 13, 2024
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
107 changes: 13 additions & 94 deletions trolldb/api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,57 +15,27 @@

import asyncio
import sys
import time
from contextlib import contextmanager
from multiprocessing import Process
from typing import Any, Generator, NoReturn
from typing import NoReturn

import uvicorn
from fastapi import FastAPI, status
from fastapi.responses import PlainTextResponse
from loguru import logger
from pydantic import ValidationError

from trolldb.api.routes import api_router
from trolldb.config.config import AppConfig, Timeout
from trolldb.api.fastapi_app import fastapi_app
from trolldb.config.config import AppConfig
from trolldb.database.mongodb import mongodb_context
from trolldb.errors.errors import ResponseError

API_INFO = dict(
title="pytroll-db",
summary="The database API of Pytroll",
description=
"The API allows you to perform CRUD operations as well as querying the database"
"At the moment only MongoDB is supported. It is based on the following Python packages"
"\n * **PyMongo** (https://github.com/mongodb/mongo-python-driver)"
"\n * **motor** (https://github.com/mongodb/motor)",
license_info=dict(
name="The GNU General Public License v3.0",
url="https://www.gnu.org/licenses/gpl-3.0.en.html"
)
)
"""These will appear in the auto-generated documentation and are passed to the ``FastAPI`` class as keyword args."""


@logger.catch(onerror=lambda _: sys.exit(1))
def run_server(config: AppConfig, **kwargs) -> None:
"""Runs the API server with all the routes and connection to the database.
def run_server(app_config: AppConfig) -> None:
"""Runs the API server with connection to the database.

It first creates a FastAPI application and runs it using `uvicorn <https://www.uvicorn.org/>`_ which is
ASGI (Asynchronous Server Gateway Interface) compliant. This function runs the event loop using
It runs the imported ``fastapi_app`` using `uvicorn <https://www.uvicorn.org/>`_ which is ASGI
(Asynchronous Server Gateway Interface) compliant. This function runs the event loop using
`asyncio <https://docs.python.org/3/library/asyncio.html>`_ and does not yield!

Args:
config:
The configuration of the application which includes both the server and database configurations.

**kwargs:
The keyword arguments are the same as those accepted by the
`FastAPI class <https://fastapi.tiangolo.com/reference/fastapi/#fastapi.FastAPI>`_ and are directly passed
to it. These keyword arguments will be first concatenated with the configurations of the API server which
are read from the ``config`` argument. The keyword arguments which are passed explicitly to the function
take precedence over ``config``. Finally, :obj:`API_INFO`, which are hard-coded information for the API
server, will be concatenated and takes precedence over all.
app_config:
The configuration of the API server and the database.

Example:
.. code-block:: python
Expand All @@ -78,68 +48,17 @@
"""
logger.info("Attempt to run the API server ...")

# Concatenate the keyword arguments for the API server in the order of precedence (lower to higher).
app = FastAPI(**(config.api_server._asdict() | kwargs | API_INFO))

app.include_router(api_router)

@app.exception_handler(ResponseError)
async def auto_handler_response_errors(_, exc: ResponseError) -> PlainTextResponse:
"""Catches all the exceptions raised as a ResponseError, e.g. accessing non-existing databases/collections."""
status_code, message = exc.get_error_details()
info = dict(
status_code=status_code if status_code else status.HTTP_500_INTERNAL_SERVER_ERROR,
content=message if message else "Generic Error [This is not okay, check why we have the generic error!]",
)
logger.error(f"Response error caught by the API auto exception handler: {info}")
return PlainTextResponse(**info)

@app.exception_handler(ValidationError)
async def auto_handler_pydantic_validation_errors(_, exc: ValidationError) -> PlainTextResponse:
"""Catches all the exceptions raised as a Pydantic ValidationError."""
logger.error(f"Response error caught by the API auto exception handler: {exc}")
return PlainTextResponse(str(exc), status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)

async def _serve() -> NoReturn:
"""An auxiliary coroutine to be used in the asynchronous execution of the FastAPI application."""
async with mongodb_context(config.database):
async with mongodb_context(app_config.database):

Check warning on line 53 in trolldb/api/api.py

View check run for this annotation

Codecov / codecov/patch

trolldb/api/api.py#L53

Added line #L53 was not covered by tests
logger.info("Attempt to start the uvicorn server ...")
await uvicorn.Server(
config=uvicorn.Config(
host=config.api_server.url.host,
port=config.api_server.url.port,
app=app
host=app_config.api_server.url.host,
port=app_config.api_server.url.port,
app=fastapi_app
)
).serve()

logger.info("Attempt to run the asyncio loop for the API server ...")
asyncio.run(_serve())


@contextmanager
def api_server_process_context(config: AppConfig, startup_time: Timeout = 2) -> Generator[Process, Any, None]:
"""A synchronous context manager to run the API server in a separate process (non-blocking).

It uses the `multiprocessing <https://docs.python.org/3/library/multiprocessing.html>`_ package. The main use case
is envisaged to be in `TESTING` environments.

Args:
config:
Same as ``config`` argument for :func:`run_server`.

startup_time:
The overall time in seconds that is expected for the server and the database connections to be established
before actual requests can be sent to the server. For testing purposes ensure that this is sufficiently
large so that the tests will not time out.
"""
logger.info("Attempt to run the API server process in a context manager ...")
process = Process(target=run_server, args=(config,))
try:
process.start()
time.sleep(startup_time)
yield process
finally:
logger.info("Attempt to terminate the API server process in the context manager ...")
process.terminate()
process.join()
logger.info("The API server process has terminated successfully.")
51 changes: 51 additions & 0 deletions trolldb/api/fastapi_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""The module which creates the main FastAPI app which will be used when running the API server."""

from fastapi import FastAPI, status
from fastapi.responses import PlainTextResponse
from loguru import logger
from pydantic import ValidationError

from trolldb.api.routes import api_router
from trolldb.errors.errors import ResponseError

API_INFO = dict(
title="pytroll-db",
summary="The database API of Pytroll",
description=
"The API allows you to perform CRUD operations as well as querying the database"
"At the moment only MongoDB is supported. It is based on the following Python packages"
"\n * **PyMongo** (https://github.com/mongodb/mongo-python-driver)"
"\n * **motor** (https://github.com/mongodb/motor)",
license_info=dict(
name="The GNU General Public License v3.0",
url="https://www.gnu.org/licenses/gpl-3.0.en.html"
)
)
"""These will appear in the auto-generated documentation and are passed to the ``FastAPI`` class as keyword args."""

logger.info("Attempt to create the FastAPI app ...")
fastapi_app = FastAPI()

fastapi_app.include_router(api_router)


@fastapi_app.exception_handler(ResponseError)
async def auto_handler_response_errors(_, exc: ResponseError) -> PlainTextResponse:
"""Catches all the exceptions raised as a ResponseError, e.g. accessing non-existing databases/collections."""
status_code, message = exc.get_error_details()
info = dict(
status_code=status_code if status_code else status.HTTP_500_INTERNAL_SERVER_ERROR,
content=message if message else "Generic Error [This is not okay, check why we have the generic error!]",
)
logger.error(f"Response error caught by the API auto exception handler: {info}")
return PlainTextResponse(**info)


@fastapi_app.exception_handler(ValidationError)
async def auto_handler_pydantic_validation_errors(_, exc: ValidationError) -> PlainTextResponse:
"""Catches all the exceptions raised as a Pydantic ValidationError."""
logger.error(f"Response error caught by the API auto exception handler: {exc}")
return PlainTextResponse(str(exc), status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)

Check warning on line 48 in trolldb/api/fastapi_app.py

View check run for this annotation

Codecov / codecov/patch

trolldb/api/fastapi_app.py#L47-L48

Added lines #L47 - L48 were not covered by tests


logger.info("FastAPI app created successfully.")
2 changes: 1 addition & 1 deletion trolldb/api/routes/databases.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
router = APIRouter()


@router.get("/",
@router.get("",
response_model=list[str],
summary="Gets the list of all database names")
async def database_names(
Expand Down
17 changes: 11 additions & 6 deletions trolldb/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,22 +42,27 @@ def id_must_be_valid(id_like_string: str) -> ObjectId:
ValueError:
If the given string cannot be converted to a valid ObjectId. This will ultimately turn into a pydantic
validation error.

Note:
The reason that we change the type of the raised error is the following. As per the requirements of Pydantic,
one can either raise a ``ValueError`` or ``AssertionError`` in a custom validator. Here we have defined a custom
validator for a MongoDB object ID. When it fails it raises ``InvalidId`` which is not a valid exception to
signify validation failure in Pydantic, hence the need to catch the error and raise a different one.

Reference:
https://docs.pydantic.dev/latest/concepts/validators/#handling-errors-in-validators

"""
try:
return ObjectId(id_like_string)
except InvalidId as e:
raise ValueError from e
raise ValueError(f"{id_like_string} is not a valid value for an ID.") from e
Copy link
Member

Choose a reason for hiding this comment

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

Why change the type of error?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The reason is that as per the requirements of Pydantic, one can either raise a ValueError or AssertionError in a custom validator. Here I have defined a custom validator for a MongoDB object ID. When it fails it raises InvalidId which is not a valid exception to signify validation failure in Pydantic, hence the need to catch the error and raise a different one.

Please let me know if my understanding of this is correct.

Reference: https://docs.pydantic.dev/latest/concepts/validators/#handling-errors-in-validators

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I have added a note on this to the validator's docstring.

Copy link
Member

Choose a reason for hiding this comment

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

ok, all good then.



MongoObjectId = Annotated[str, AfterValidator(id_must_be_valid)]
"""The type hint validator for object IDs."""


class MongoDocument(BaseModel):
"""Pydantic model for a MongoDB document."""
_id: MongoObjectId


class APIServerConfig(NamedTuple):
"""A named tuple to hold all the configurations of the API server (excluding the database).

Expand Down
24 changes: 19 additions & 5 deletions trolldb/database/mongodb.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ class MongoDB:
"""MongoDB creates these databases by default for self usage."""

@classmethod
@validate_call
async def initialize(cls, database_config: DatabaseConfig):
"""Initializes the motor client. Note that this method has to be awaited!

Expand All @@ -121,6 +122,9 @@ async def initialize(cls, database_config: DatabaseConfig):
On success ``None``.

Raises:
ValidationError:
If the method is not called with arguments of valid type.

SystemExit(errno.EIO):
If connection is not established, i.e. ``ConnectionFailure``.
SystemExit(errno.EIO):
Expand Down Expand Up @@ -178,15 +182,22 @@ async def initialize(cls, database_config: DatabaseConfig):
logger.info("The main collection name exists.")

cls.__main_collection = cls.__main_database.get_collection(database_config.main_collection_name)
cls.__database_config = database_config
logger.info("MongoDB is successfully initialized.")

@classmethod
def is_initialized(cls) -> bool:
"""Checks if the motor client is initialized."""
return cls.__client is not None

@classmethod
def close(cls) -> None:
"""Closes the motor client."""
logger.info("Attempt to close the MongoDB client ...")
if cls.__client:
cls.__database_config = None
cls.__client.close()
cls.__client = None
cls.__database_config = None
logger.info("The MongoDB client is closed successfully.")
return
Client.CloseNotAllowedError.sys_exit_log(errno.EIO)
Expand Down Expand Up @@ -221,8 +232,8 @@ def main_database(cls) -> AsyncIOMotorDatabase:
@validate_call
async def get_collection(
cls,
database_name: str | None,
collection_name: str | None) -> AsyncIOMotorCollection:
database_name: str | None = None,
collection_name: str | None = None) -> AsyncIOMotorCollection:
"""Gets the collection object given its name and the database name in which it resides.

Args:
Expand Down Expand Up @@ -263,7 +274,7 @@ async def get_collection(

@classmethod
@validate_call
async def get_database(cls, database_name: str | None) -> AsyncIOMotorDatabase:
async def get_database(cls, database_name: str | None = None) -> AsyncIOMotorDatabase:
"""Gets the database object given its name.

Args:
Expand Down Expand Up @@ -308,5 +319,8 @@ async def mongodb_context(database_config: DatabaseConfig) -> AsyncGenerator:
await MongoDB.initialize(database_config)
yield
finally:
MongoDB.close()
if MongoDB.is_initialized():
MongoDB.close()
else:
logger.info("The MongoDB client was not initialized!")
logger.info("The MongoDB context manager is successfully closed.")
4 changes: 2 additions & 2 deletions trolldb/errors/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ def log_as_warning(
extra_information: dict | None = None,
status_code: int | None = None) -> None:
"""Same as :func:`~ResponseError.get_error_details` but logs the error as a warning and returns ``None``."""
msg, _ = self.get_error_details(extra_information, status_code)
_, msg = self.get_error_details(extra_information, status_code)
logger.warning(msg)

def sys_exit_log(
Expand All @@ -224,7 +224,7 @@ def sys_exit_log(
Returns:
Does not return anything, but logs the error and exits the program.
"""
msg, _ = self.get_error_details(extra_information, status_code)
_, msg = self.get_error_details(extra_information, status_code)
logger.error(msg)
exit(exit_code)

Expand Down
Loading