diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index c197700..8b3e4cb 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -4,8 +4,6 @@ name: Python application on: - push: - branches: [ "main" ] pull_request: branches: [ "main" ] @@ -26,7 +24,8 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + pip install -r requirements.txt + pip install pytest pytest-mock - name: Test with pytest run: | pytest -v --junitxml=pytest.xml -o junit_logging=all . diff --git a/application/app.py b/application/app.py index 9d09a58..46503a3 100644 --- a/application/app.py +++ b/application/app.py @@ -27,19 +27,16 @@ async def predict(self, prediction_request: PredictionRequest): job_id = str(uuid4()) self.jobs[job_id] = {"status": "processing", "result": None} asyncio.create_task(self.process_job(job_id, prediction_request)) - # await asyncio.sleep(random.random() * 3) # Simulate a long-running task - # response = await prediction_job return {"job_id": job_id} async def process_job(self, job_id: str, member_features: PredictionRequest): try: - await asyncio.sleep(random.random() * 3) # Simulate a long-running task + result = await get_predictions(member_features) self.jobs[job_id]["status"] = "completed" self.jobs[job_id]["result"] = result - return result except Exception as e: self.jobs[job_id]["status"] = "failed" self.jobs[job_id]["result"] = str(e) diff --git a/francis.md b/francis.md deleted file mode 100644 index c6ebba0..0000000 --- a/francis.md +++ /dev/null @@ -1,18 +0,0 @@ -# Docker Web API Execution - -docker pull python:3.11-slim - -docker run -it -p 8000:8000 -w /var/plus -v $(pwd):/var/plus python:3.11-slim bash -> pip install -r requirements.txt -> fastapi dev main.py --host 0.0.0.0 - -# Code changes -- fix requirements for fastapi : fastapi[standard] -- Refactor POST - /predict to not wait for a response and return te job_id - - Remove return typing - - Change code to not make the job function call back predict function (infinite loop) - - Move the "await asyncio.sleep(random.random() * 3)" to the job function. - - -# Remarks -- Should an empty member_id considered as missing? \ No newline at end of file diff --git a/machine_learning/predict.py b/machine_learning/predict.py index ca74a4a..b254c02 100644 --- a/machine_learning/predict.py +++ b/machine_learning/predict.py @@ -2,6 +2,7 @@ from typing import Dict from models.prediction_request import PredictionRequest import random +import asyncio @staticmethod @@ -28,6 +29,8 @@ async def get_predictions( else: probability_to_transact = 0.0 + await asyncio.sleep(random.random() * 3) # Simulate a long-running task + return { "average_transaction_size": avg_transaction_size, "probability_to_transact": probability_to_transact diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..0d065a0 --- /dev/null +++ b/readme.md @@ -0,0 +1,42 @@ +# Docker test execution + +docker pull python:3.11-slim + + docker run -it -w /var/plus -v $(pwd):/var/plus python:3.11-slim bash -c "pip install -r requirements.txt && pip install pytest pytest-mock && pytest -v" + +# Local execution + +Setup: + pip install -r requirements.txt + pip install pytest pytest-mock + +Execution: + pytest + +# Docker Web API Execution + +To simply start an instance of the Web API + + docker pull python:3.11-slim + + docker run -it -p 8000:8000 -w /var/plus -v $(pwd):/var/plus python:3.11-slim bash -c "pip install -r requirements.txt && fastapi dev main.py --host 0.0.0.0" + +# Code changes from original application code +- Fix requirements for fastapi : fastapi[standard] +- Refactor POST - /predict to not wait for a response and return the job_id : + - Remove return typing + - Change code to not make the job function call back predict function (infinite loop) + - Move the "await asyncio.sleep(random.random() * 3)" to the predict function. + +# Work done + +## Tests +- Basic tests for each endpoint - 200 +- Check for basic invalid parameters (Json payload non good, bad or missing path ID) +- Check prediction result status based on the execution state +- Use mocking to prevent call to the prediction system, to prevent dependencies on unstable system and to remove waiting for the execution to complete. +- Also use mocking to prevent the execution of prediction system to control if a job is completed or pending. + +## GitHub +- Setup a Git Hub action to execute test when a PR is created. +- Also add basic reporting of the test in the Git Hub Action job summary diff --git a/requirements.txt b/requirements.txt index 5eaf089..5dacd17 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,4 +12,3 @@ typing-inspection==0.4.0 typing_extensions==4.13.2 uuid==1.30 uvicorn==0.34.2 -pytest==8.4.1 diff --git a/test/test_result.py b/test/test_result.py index 2e793c8..df891d7 100644 --- a/test/test_result.py +++ b/test/test_result.py @@ -2,7 +2,8 @@ from httpx import ASGITransport, AsyncClient from main import app import pytest -import anyio +from unittest.mock import AsyncMock + client = TestClient(app) @@ -12,6 +13,37 @@ def anyio_backend(): return 'asyncio' +@pytest.fixture() +def mock_get_prediction(mocker): + async_mock = AsyncMock() + mocker.patch('application.app.get_predictions', side_effect=async_mock) + + async_mock.return_value = { + "average_transaction_size": 50.0, + "probability_to_transact": 0.75 + } + + return async_mock + + +@pytest.fixture() +def mock_get_prediction_in_error(mocker): + async_mock = AsyncMock() + mocker.patch('application.app.get_predictions', side_effect=async_mock) + + async_mock.side_effect = Exception("Something happened") + + return async_mock + + +@pytest.fixture() +def mock_process_jop_do_nothing(mocker): + async_mock = AsyncMock() + mocker.patch('application.app.Application.process_job', side_effect=async_mock) + + return async_mock + + @pytest.fixture() async def async_client(): async with AsyncClient( @@ -37,9 +69,8 @@ async def job_id(async_client): @pytest.mark.anyio -async def test_result_200_success(async_client, job_id): +async def test_result_200_success(mock_get_prediction, async_client, job_id): - await anyio.sleep(3) response = await async_client.get(f"/result/{job_id}") assert response.status_code == 200 @@ -47,27 +78,37 @@ async def test_result_200_success(async_client, job_id): assert response.json() == { "job_id": job_id, "result": { - "average_transaction_size": 505.0, - "probability_to_transact": 0.4602739726027397 + "average_transaction_size": 50.0, + "probability_to_transact": 0.75 } } @pytest.mark.anyio -async def test_status_400_processing(async_client, job_id): +async def test_status_400_processing(mock_process_jop_do_nothing, async_client, job_id): response = await async_client.get(f"/result/{job_id}") assert response.status_code == 400 - assert response.json() == {'detail': 'Result not ready'} + assert response.json() == {"detail": "Result not ready"} @pytest.mark.anyio -async def test_result_404(async_client): +async def test_result_404(mock_process_jop_do_nothing, async_client): response = await async_client.get("/result/bad_job_id") assert response.status_code == 404 assert response.json() == {"detail": "Job ID not found"} + + +@pytest.mark.anyio +async def test_status_500_fail(mock_get_prediction_in_error, async_client, job_id): + + response = await async_client.get(f"/result/{job_id}") + + assert response.status_code == 500 + + assert response.json() == {"detail": "Unknown error occurred during prediction"} diff --git a/test/test_status.py b/test/test_status.py index 9472514..b5fd127 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -2,7 +2,7 @@ from httpx import ASGITransport, AsyncClient from main import app import pytest -import anyio +from unittest.mock import AsyncMock client = TestClient(app) @@ -12,6 +12,27 @@ def anyio_backend(): return 'asyncio' +@pytest.fixture() +def mock_get_prediction(mocker): + async_mock = AsyncMock() + mocker.patch('application.app.get_predictions', side_effect=async_mock) + + async_mock.return_value = { + "average_transaction_size": 50.0, + "probability_to_transact": 0.75 + } + + return async_mock + + +@pytest.fixture() +def mock_process_jop_do_nothing(mocker): + async_mock = AsyncMock() + mocker.patch('application.app.Application.process_job', side_effect=async_mock) + + return async_mock + + @pytest.fixture() async def async_client(): async with AsyncClient( @@ -37,9 +58,8 @@ async def job_id(async_client): @pytest.mark.anyio -async def test_status_200_success(async_client, job_id): +async def test_status_200_success(mock_get_prediction, async_client, job_id): - await anyio.sleep(3) response = await async_client.get(f"/status/{job_id}") assert response.status_code == 200 @@ -51,7 +71,7 @@ async def test_status_200_success(async_client, job_id): @pytest.mark.anyio -async def test_status_200_processing(async_client, job_id): +async def test_status_200_processing(mock_process_jop_do_nothing, async_client, job_id): response = await async_client.get(f"/status/{job_id}") @@ -64,10 +84,20 @@ async def test_status_200_processing(async_client, job_id): @pytest.mark.anyio -async def test_status_404(async_client): +async def test_status_404_bad_id(async_client): response = await async_client.get("/status/bad_job_id") assert response.status_code == 404 assert response.json() == {"detail": "Job ID not found"} + + +@pytest.mark.anyio +async def test_status_404_missing(async_client): + + response = await async_client.get("/status/") + + assert response.status_code == 404 + + assert response.json() == {"detail": "Not Found"}