Skip to content

Commit

Permalink
Add UvicornTestServer and local coverage script
Browse files Browse the repository at this point in the history
  • Loading branch information
jennydaman committed Jul 27, 2024
1 parent cf3f3a3 commit 7622826
Show file tree
Hide file tree
Showing 11 changed files with 113 additions and 53 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
- name: Test
id: test
continue-on-error: true # we want to upload coverage, even on failure
run: docker compose run -T --use-aliases test
run: docker compose run -T --use-aliases test pytest --color=yes --cov=serie --cov-report=xml --run-e2e
- name: Copy coverage.xml from container
run: docker cp "$(docker compose ps -a test -q | tail -n 1):/app/coverage.xml" coverage.xml
- name: Upload coverage to Codecov
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
__pycache__
/src/serie/version.txt
/.test_data
coverage.xml

5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,9 @@ Run unit tests on-the-metal:
rye run pytest
```

End-to-end tests require _SERIE_ to run in the same docker network as _CUBE_ and Hasura,
so we need to run them with Docker Compose:
End-to-end tests require _SERIE_ to run in the same docker network as _CUBE_ and Hasura.
First, run [miniChRIS-docker](https://github.com/FNNDSC/miniChRIS-docker) to get _CUBE_
and Hasura up, then run pytest and _SERIE_ using Docker Compose:

```shell
docker compose run test
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ services:
context: .
args:
REQUIREMENTS_FILE: requirements-dev.lock
command: pytest --color=yes --run-e2e --cov=serie --cov-report=xml
command: pytest --color=yes --code-highlight=yes --run-e2e
working_dir: /app
volumes:
# /app is a volume and the source code files are binded as subdirectories
Expand Down
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ dependencies = [
readme = "README.md"
requires-python = ">= 3.12"

[tool.rye.scripts]
cover = """
sh -c '
docker compose run --remove-orphans test pytest --cov=serie --cov-report=xml --run-e2e
docker cp "$(docker compose ps -a test -q | head -n 1):/app/coverage.xml" coverage.xml
sed -i -e "s#/app/serie#$PWD/src/serie#" coverage.xml
'
"""

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Expand Down
42 changes: 4 additions & 38 deletions src/main.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,6 @@
from typing import Optional, Annotated
from serie import router, __version__
from fastapi import FastAPI

from aiochris.types import FeedUrl
from fastapi import FastAPI, Response, status, Header

from serie import (
DicomSeriesPayload,
get_settings,
BadAuthorizationError,
ClientActions,
__version__,
)
from serie.models import OxidicomCustomMetadata

app = FastAPI(
title="Specific Endpoints for Research Integration Events",
Expand All @@ -19,30 +9,6 @@
"url": "https://chrisproject.org",
"email": "[email protected]",
},
version=__version__,
version=__version__
)


@app.post("/dicom_series/")
async def dicom_series(
payload: DicomSeriesPayload,
authorization: Annotated[str, Header()],
response: Response,
) -> Optional[FeedUrl]:
"""
Create *ChRIS* plugin instances and/or workflows on DICOM series data when an entire DICOM series is received.
"""
if (oxm_file := OxidicomCustomMetadata.from_pacsfile(payload.data)) is None:
response.status_code = status.HTTP_204_NO_CONTENT
return None

settings = get_settings()
actions = ClientActions(auth=authorization, url=settings.chris_url)
try:
feed = await actions.create_analysis(
oxm_file, payload.jobs, payload.feed_name_template
)
except BadAuthorizationError as e:
response.status_code = status.HTTP_401_UNAUTHORIZED
return None
return feed.url
app.include_router(router)
10 changes: 2 additions & 8 deletions src/serie/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
from serie.actions import ClientActions
from serie.clients import BadAuthorizationError
from serie.models import DicomSeriesPayload
from serie.settings import get_settings
from serie.router import router
from serie.__version__ import __version__

__all__ = [
"ClientActions",
"DicomSeriesPayload",
"get_settings",
"BadAuthorizationError",
"router",
"__version__",
]
36 changes: 36 additions & 0 deletions src/serie/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from typing import Optional, Annotated

from aiochris.types import FeedUrl
from fastapi import Response, status, Header, APIRouter

from serie.clients import BadAuthorizationError
from serie.models import DicomSeriesPayload, OxidicomCustomMetadata
from serie.settings import get_settings
from serie.actions import ClientActions

router = APIRouter()


@router.post("/dicom_series/")
async def dicom_series(
payload: DicomSeriesPayload,
authorization: Annotated[str, Header()],
response: Response,
) -> Optional[FeedUrl]:
"""
Create *ChRIS* plugin instances and/or workflows on DICOM series data when an entire DICOM series is received.
"""
if (oxm_file := OxidicomCustomMetadata.from_pacsfile(payload.data)) is None:
response.status_code = status.HTTP_204_NO_CONTENT
return None

settings = get_settings()
actions = ClientActions(auth=authorization, url=settings.chris_url)
try:
feed = await actions.create_analysis(
oxm_file, payload.jobs, payload.feed_name_template
)
except BadAuthorizationError as e:
response.status_code = status.HTTP_401_UNAUTHORIZED
return None
return feed.url
2 changes: 2 additions & 0 deletions tests/e2e_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@
CUBE_CONTAINER_ID = "chris"
CHRIS_USERNAME = "serie"
CHRIS_PASSWORD = "serie1234"
SERIE_PORT = 8000
SERIE_HOST = "test.serie"
30 changes: 29 additions & 1 deletion tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,48 @@
import pytest
import pytest_asyncio
import requests
import uvicorn
from aiochris import ChrisClient
from aiochris.errors import IncorrectLoginError
from asyncer import asyncify
from fastapi import FastAPI

import tests.e2e_config as config
from serie import router
from serie.settings import get_settings
from tests.uvicorn_test_server import UvicornTestServer


@pytest.mark.e2e
@pytest.mark.asyncio
async def test_e2e(chris: ChrisClient):
async def test_e2e(server: UvicornTestServer, chris: ChrisClient):
await _start_test(chris)


@pytest_asyncio.fixture
async def server() -> UvicornTestServer:
app = FastAPI(title="SERIE TEST")
app.include_router(router)
uvicorn_config = uvicorn.Config(
app=app,
host="0.0.0.0",
port=config.SERIE_PORT,
# N.B. uvicorn wants to create its own async event loop.
# The default option causes some conflict with the outer loop.
# A workaround is to set loop="asyncio". This will surely
# break in the future!
loop="asyncio"
)
server = UvicornTestServer(uvicorn_config)
await server.start()
try:
yield server
except Exception:
raise
finally:
await server.stop()


@pytest_asyncio.fixture
async def chris() -> ChrisClient:
settings = get_settings()
Expand Down
26 changes: 24 additions & 2 deletions tests/uvicorn_test_server.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,30 @@
import asyncio

import uvicorn
import threading


class UvicornTestServer:
"""
An uvicorn server which runs in a different thread, and can be shut down programmatically.
See https://github.com/encode/uvicorn/discussions/1103
"""

def __init__(self):
...
def __init__(self, config: uvicorn.Config):
self._server = uvicorn.Server(config)
self._thread = threading.Thread(daemon=True, target=self._server.run)

async def start(self):
self._thread.start()
await self._wait_for_started()

async def _wait_for_started(self):
while not self._server.started:
await asyncio.sleep(0.1)

async def stop(self):
if self._thread.is_alive():
self._server.should_exit = True
while self._thread.is_alive():
await asyncio.sleep(0.1)

0 comments on commit 7622826

Please sign in to comment.