diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 3ede0d2..1e76065 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -21,4 +21,4 @@ jobs: pip install -r requirements.txt - name: Analysing the code with pylint run: | - pylint -d C0200,C0301,C0114,R0903,C0115,W0246,R0914,C0209,E1121,C0103,C2801,R0801,E1101,E0401,E0611,R0911,C0116,W0212,W0719,W0601,W1203,W0123,W0511 $(git ls-files '*.py') + pylint -d C0200,C0301,C0114,R0903,C0115,W0246,R0914,C0209,E1121,C0103,C2801,R0801,E1101,E0401,E0611,R0911,C0116,W0212,W0719,W0601,W1203,W0123,W0511,W0621 $(git ls-files '*.py') diff --git a/Makefile b/Makefile index 0308d77..f8f94f6 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,23 @@ -black: - black ./lib +format: black flake8 pylint ruff -lint: flake8 pylint +black: + black ./lib || true + black ./tests || true flake8: - flake8 --ignore E501,E402,F401,W503 ./lib + flake8 --ignore E501,E402,F401,W503,C0414 ./lib || true + flake8 --ignore E501,E402,F401,W503,C0414 ./tests || true pylint: - pylint --extension-pkg-whitelist='pydantic' ./lib/* + pylint --extension-pkg-whitelist='pydantic' ./lib || true + pylint --extension-pkg-whitelist='pydantic' --disable=E0401,W0621 ./tests || true + +ruff: + ruff check --fix ./lib || true + ruff check --fix ./tests || true + +test: + python3 -m pytest . dev: python3 -m uvicorn lib:app --reload --port 3000 @@ -19,3 +29,5 @@ clean: build: docker build -t infinity-api . --no-cache + +.PHONY: black flake8 pylint test dev clean build ruff format diff --git a/README.md b/README.md index afd731b..bab77cc 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ - Install dependencies `python3 -m pip install -r requirements.txt` ## Development -- black ./lib -- pylint --extension-pkg-whitelist='pydantic' ./lib/* -- flake8 --ignore E501,E402,F401,W503 ./lib +- make format +- make test +- make clean +- make build ## Starting the server - Setup MONGODB_CONNECTION_STRING: diff --git a/lib/__init__.py b/lib/__init__.py index 171b18e..4eecb2e 100644 --- a/lib/__init__.py +++ b/lib/__init__.py @@ -25,4 +25,6 @@ def parse_error(error): return f"{exc_type}: {exc_obj}" -from lib.api import app # pylint: disable=wrong-import-position,cyclic-import +from lib.api import ( # pylint: disable=wrong-import-position,cyclic-import,useless-import-alias + app as app, +) diff --git a/lib/controllers/environment.py b/lib/controllers/environment.py index fb1c91e..20f4874 100644 --- a/lib/controllers/environment.py +++ b/lib/controllers/environment.py @@ -27,18 +27,8 @@ class EnvController: - CRUD operations over models.Env on the database """ - def __init__(self, env: Env): - self._env = env - - @property - def env(self) -> Env: - return self._env - - @env.setter - def env(self, env: Env): - self._env = env - - async def create_env(self) -> Union[EnvCreated, HTTPException]: + @staticmethod + async def create_env(env: Env) -> Union[EnvCreated, HTTPException]: """ Create a env in the database. @@ -46,7 +36,7 @@ async def create_env(self) -> Union[EnvCreated, HTTPException]: views.EnvCreated """ try: - async with EnvRepository(self.env) as env_repo: + async with EnvRepository(env) as env_repo: await env_repo.create_env() except PyMongoError as e: logger.error( @@ -157,8 +147,9 @@ async def get_rocketpy_env_binary( f"Call to controllers.environment.get_rocketpy_env_binary completed for Env {env_id}" ) + @staticmethod async def update_env_by_id( - self, env_id: str + env_id: str, env: Env ) -> Union[EnvUpdated, HTTPException]: """ Update a models.Env in the database. @@ -173,7 +164,7 @@ async def update_env_by_id( HTTP 404 Not Found: If the env is not found in the database. """ try: - async with EnvRepository(self.env) as env_repo: + async with EnvRepository(env) as env_repo: await env_repo.update_env_by_id(env_id) except PyMongoError as e: logger.error( diff --git a/lib/routes/environment.py b/lib/routes/environment.py index 1eb84a6..6834ed3 100644 --- a/lib/routes/environment.py +++ b/lib/routes/environment.py @@ -36,7 +36,7 @@ async def create_env(env: Env) -> EnvCreated: ``` models.Env JSON ``` """ with tracer.start_as_current_span("create_env"): - return await EnvController(env).create_env() + return await EnvController.create_env(env) @router.get("/{env_id}") @@ -63,11 +63,11 @@ async def update_env(env_id: str, env: Env) -> EnvUpdated: ``` """ with tracer.start_as_current_span("update_env"): - return await EnvController(env).update_env_by_id(env_id) + return await EnvController.update_env_by_id(env_id, env) @router.get( - "/rocketpy/{env_id}", + "/{env_id}/rocketpy", responses={ 203: { "description": "Binary file download", @@ -118,4 +118,4 @@ async def delete_env(env_id: str) -> EnvDeleted: ``` env_id: str ``` """ with tracer.start_as_current_span("delete_env"): - return await EnvController(env_id).delete_env_by_id(env_id) + return await EnvController.delete_env_by_id(env_id) diff --git a/pyproject.toml b/pyproject.toml index c3dbcae..570738d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,7 @@ -[build-system] -requires = ["setuptools", "setuptools_scm"] -build-backend = "setuptools.build_meta" - -[tool.setuptools.dynamic] -dependencies = {file = ["requirements.txt"]} - [project] name = "Infinity-API" -version = "2.2.0" +version = "2.3.0" description = "RESTFULL open API for rocketpy" -dynamic = ["dependencies"] requires-python = ">=3.12" authors = [ {name = "Gabriel Barberini", email = "gabrielbarberinirc@gmail.com"} @@ -21,7 +13,7 @@ maintainers = [ readme = "README.md" keywords = ["rocketpy", "API", "simulation", "rocket", "flight"] classifiers = [ - "Development Status :: Alpha", + "Development Status :: Production", "Programming Language :: Python" ] @@ -52,3 +44,14 @@ disable = """ raise-missing-from, too-many-instance-attributes, """ + +[tool.ruff] +line-length = 79 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "N", "Q"] +ignore = ["N815", "E501", "Q000", "E402"] +fixable = [ + "F401", +] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..00564b5 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +flake8 +pylint +ruff diff --git a/tests/test_routes/test_environment_route.py b/tests/test_routes/test_environment_route.py new file mode 100644 index 0000000..e0e3bb6 --- /dev/null +++ b/tests/test_routes/test_environment_route.py @@ -0,0 +1,291 @@ +from unittest.mock import patch +import json +import pytest +from fastapi.testclient import TestClient +from fastapi import HTTPException +from lib.models.environment import Env +from lib.controllers.environment import EnvController +from lib.views.environment import ( + EnvCreated, + EnvUpdated, + EnvDeleted, + EnvSummary, +) +from lib import app + +client = TestClient(app) + + +@pytest.fixture +def stub_env(): + env = Env(latitude=0, longitude=0) + env_json = env.model_dump_json() + return json.loads(env_json) + + +@pytest.fixture +def stub_env_summary(): + env_summary = EnvSummary() + env_summary_json = env_summary.model_dump_json() + return json.loads(env_summary_json) + + +def test_create_env(stub_env): + with patch.object( + EnvController, "create_env", return_value=EnvCreated(env_id="123") + ) as mock_create_env: + response = client.post("/environments/", json=stub_env) + assert response.status_code == 200 + assert response.json() == { + "env_id": "123", + "message": "Environment successfully created", + } + mock_create_env.assert_called_once_with(Env(**stub_env)) + + +def test_create_env_optional_params(): + test_object = { + "latitude": 0, + "longitude": 0, + "elevation": 1, + "atmospheric_model_type": "STANDARD_ATMOSPHERE", + "atmospheric_model_file": None, + "date": "2021-01-01T00:00:00", + } + with patch.object( + EnvController, "create_env", return_value=EnvCreated(env_id="123") + ) as mock_create_env: + response = client.post("/environments/", json=test_object) + assert response.status_code == 200 + assert response.json() == { + "env_id": "123", + "message": "Environment successfully created", + } + mock_create_env.assert_called_once_with(Env(**test_object)) + + +def test_create_env_invalid_input(): + response = client.post( + "/environments/", json={"latitude": "foo", "longitude": "bar"} + ) + assert response.status_code == 422 + + +def test_create_env_server_error(stub_env): + with patch.object( + EnvController, "create_env", side_effect=Exception("error") + ): + with pytest.raises(Exception): + response = client.post("/environments/", json=stub_env) + assert response.status_code == 500 + assert response.json() == { + "detail": "Failed to create environment: error" + } + + +def test_read_env(stub_env): + with patch.object( + EnvController, "get_env_by_id", return_value=Env(**stub_env) + ) as mock_read_env: + response = client.get("/environments/123") + assert response.status_code == 200 + assert response.json() == stub_env + mock_read_env.assert_called_once_with("123") + + +def test_read_env_not_found(): + with patch.object( + EnvController, + "get_env_by_id", + side_effect=HTTPException( + status_code=404, detail="Environment not found" + ), + ) as mock_read_env: + response = client.get("/environments/123") + assert response.status_code == 404 + assert response.json() == {"detail": "Environment not found"} + mock_read_env.assert_called_once_with("123") + + +def test_read_env_server_error(): + with patch.object( + EnvController, "get_env_by_id", side_effect=Exception("error") + ): + with pytest.raises(Exception): + response = client.get("/environments/123") + assert response.status_code == 500 + assert response.json() == { + "detail": "Failed to read environment: error" + } + + +def test_update_env(stub_env): + with patch.object( + EnvController, + "update_env_by_id", + return_value=EnvUpdated(env_id="123"), + ) as mock_update_env: + response = client.put("/environments/123", json=stub_env) + assert response.status_code == 200 + assert response.json() == { + "env_id": "123", + "message": "Environment successfully updated", + } + mock_update_env.assert_called_once_with("123", Env(**stub_env)) + + +def test_update_env_invalid_input(): + response = client.put( + "/environments/123", json={"latitude": "foo", "longitude": "bar"} + ) + assert response.status_code == 422 + + +def test_update_env_not_found(stub_env): + with patch.object( + EnvController, + "update_env_by_id", + side_effect=HTTPException( + status_code=404, detail="Environment not found" + ), + ): + response = client.put("/environments/123", json=stub_env) + assert response.status_code == 404 + assert response.json() == {"detail": "Environment not found"} + + +def test_update_env_server_error(stub_env): + with patch.object( + EnvController, + "update_env_by_id", + side_effect=Exception("error"), + ): + with pytest.raises(Exception): + response = client.put("/environments/123", json=stub_env) + assert response.status_code == 500 + assert response.json() == { + "detail": "Failed to update environment: error" + } + + +def test_delete_env(): + with patch.object( + EnvController, + "delete_env_by_id", + return_value=EnvDeleted(env_id="123"), + ) as mock_delete_env: + response = client.delete("/environments/123") + assert response.status_code == 200 + assert response.json() == { + "env_id": "123", + "message": "Environment successfully deleted", + } + mock_delete_env.assert_called_once_with("123") + + +def test_delete_env_not_found(): + with patch.object( + EnvController, + "delete_env_by_id", + return_value=EnvDeleted(env_id="123"), + ) as mock_delete_env: + response = client.delete("/environments/123") + assert response.status_code == 200 + assert response.json() == { + "env_id": "123", + "message": "Environment successfully deleted", + } + mock_delete_env.assert_called_once_with("123") + + +def test_delete_env_server_error(): + with patch.object( + EnvController, + "delete_env_by_id", + side_effect=Exception("error"), + ): + with pytest.raises(Exception): + response = client.delete("/environments/123") + assert response.status_code == 500 + assert response.json() == { + "detail": "Failed to delete environment: error" + } + + +def test_simulate_env(stub_env_summary): + with patch.object( + EnvController, + "simulate_env", + return_value=EnvSummary(**stub_env_summary), + ) as mock_simulate_env: + response = client.get("/environments/123/summary") + assert response.status_code == 200 + assert response.json() == stub_env_summary + mock_simulate_env.assert_called_once_with("123") + + +def test_simulate_env_not_found(): + with patch.object( + EnvController, + "simulate_env", + side_effect=HTTPException( + status_code=404, detail="Environment not found" + ), + ) as mock_simulate_env: + response = client.get("/environments/123/summary") + assert response.status_code == 404 + assert response.json() == {"detail": "Environment not found"} + mock_simulate_env.assert_called_once_with("123") + + +def test_simulate_env_server_error(): + with patch.object( + EnvController, + "simulate_env", + side_effect=Exception("error"), + ): + with pytest.raises(Exception): + response = client.get("/environments/123/summary") + assert response.status_code == 500 + assert response.json() == { + "detail": "Failed to simulate environment: error" + } + + +def test_read_rocketpy_env(): + with patch.object( + EnvController, "get_rocketpy_env_binary", return_value=b'rocketpy' + ) as mock_read_rocketpy_env: + response = client.get("/environments/123/rocketpy") + assert response.status_code == 203 + assert response.content == b'rocketpy' + assert response.headers["content-type"] == "application/octet-stream" + mock_read_rocketpy_env.assert_called_once_with("123") + + +def test_read_rocketpy_env_not_found(): + with patch.object( + EnvController, + "get_rocketpy_env_binary", + side_effect=HTTPException( + status_code=404, detail="Environment not found" + ), + ) as mock_read_rocketpy_env: + response = client.get("/environments/123/rocketpy") + assert response.status_code == 404 + assert response.json() == {"detail": "Environment not found"} + mock_read_rocketpy_env.assert_called_once_with("123") + + +def test_read_rocketpy_env_server_error(): + with patch.object( + EnvController, + "get_rocketpy_env_binary", + side_effect=Exception("error"), + ): + with pytest.raises(Exception): + response = client.get("/environments/123/rocketpy") + assert response.status_code == 500 + assert response.json() == { + "detail": "Failed to read rocketpy environment: error" + }