diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44a52b5e..764a782f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: - published env: - MINIMUM_PYTHON_VERSION: '3.8' + MINIMUM_PYTHON_VERSION: '3.9' concurrency: group: ${{ github.head_ref || github.run_id }} @@ -19,10 +19,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Install Python ${{ env.MINIMUM_PYTHON_VERSION }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ env.MINIMUM_PYTHON_VERSION }} - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install pre-commit run: | python -m pip install --upgrade pip @@ -40,11 +40,11 @@ jobs: strategy: fail-fast: false # Try to work around ECS errors matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -52,7 +52,7 @@ jobs: python -m pip install --upgrade pip python -m pip install tox-gh-actions poetry - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v2 + uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ secrets.AWS_ROLE_ARN }} aws-region: us-east-1 @@ -66,8 +66,6 @@ jobs: retry -t 5 -- docker pull public.ecr.aws/diag-nijmegen/grand-challenge/http:latest - name: Add gc.localhost to /etc/hosts run: sudo echo "127.0.0.1 gc.localhost\n127.0.0.1 minio.localhost" | sudo tee -a /etc/hosts - - name: Find the docker compose version (should be at least 2.1.1 for --wait, everything works locally with 2.5.1, 2.4.1+azure-1 does not work) - run: docker compose version - name: Run tox run: tox @@ -77,10 +75,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Install Python ${{ env.MINIMUM_PYTHON_VERSION }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ env.MINIMUM_PYTHON_VERSION }} - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 38dd9ab6..da5a2055 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.6.0 hooks: - id: check-docstring-first - id: debug-statements @@ -8,22 +8,22 @@ repos: - id: mixed-line-ending - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 + rev: v3.17.0 hooks: - id: pyupgrade language: python - args: [--py38-plus, --keep-runtime-typing] + args: [--py39-plus, --keep-runtime-typing] - repo: https://github.com/pycqa/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort - repo: https://github.com/ambv/black - rev: 23.1.0 + rev: 24.4.2 hooks: - id: black language: python - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 7.1.0 hooks: - id: flake8 language: python @@ -35,7 +35,7 @@ repos: - mccabe - yesqa - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.0.0' + rev: 'v1.11.1' hooks: - id: mypy additional_dependencies: diff --git a/HISTORY.md b/HISTORY.md index 16781d77..ba606ef1 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,9 +2,10 @@ ## 0.12.0 (UNRELEASED) - - Removed support for Python 3.6 and 3.7 - - Added support for Python 3.11 + - Removed support for Python 3.6, 3.7 and 3.8 + - Added support for Python 3.11 and 3.12 - Removed the retina endpoints + - Migrated to use Pydantic models for request and response validation ## 0.11.0 (2022-12-14) diff --git a/README.md b/README.md index 7c39862e..3a41982c 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ Challenge](https://grand-challenge.org/documentation/grand-challenge-api/). This client is tested using the `tox` framework. This enables testing the client in various python-version environments. -For example, running a specific `your_test` for only the python 3.8 +For example, running a specific `your_test` for only the python 3.9 environment can be done as follows: ```bash -tox -e py38 -- -k your_test +tox -e py39 -- -k your_test ``` diff --git a/gcapi/apibase.py b/gcapi/apibase.py index 051269fe..20af5d58 100644 --- a/gcapi/apibase.py +++ b/gcapi/apibase.py @@ -1,16 +1,6 @@ import collections -from typing import ( - Any, - Dict, - Generator, - Generic, - Iterator, - List, - Sequence, - Type, - TypeVar, - overload, -) +from collections.abc import Generator, Iterator, Sequence +from typing import Any, Generic, TypeVar, overload from urllib.parse import urljoin from httpx import URL, HTTPStatusError @@ -28,15 +18,12 @@ class ClientInterface: @property - def base_url(self) -> URL: - ... + def base_url(self) -> URL: ... @base_url.setter - def base_url(self, v: URLTypes): - ... + def base_url(self, v: URLTypes): ... - def validate_url(self, url): - ... + def validate_url(self, url): ... def __call__( self, @@ -59,7 +46,7 @@ def __init__( offset: int, limit: int, total_count: int, - results: List[T], + results: list[T], **kwargs, ) -> None: super().__init__(**kwargs) @@ -69,12 +56,10 @@ def __init__( self._results = results @overload - def __getitem__(self, key: int) -> T: - ... + def __getitem__(self, key: int) -> T: ... @overload - def __getitem__(self, key: slice) -> Sequence[T]: - ... + def __getitem__(self, key: slice) -> Sequence[T]: ... def __getitem__(self, key): return self._results[key] @@ -96,7 +81,7 @@ def total_count(self) -> int: class Common(Generic[T]): - model: Type[T] + model: type[T] _client: ClientInterface base_path: str @@ -104,7 +89,7 @@ class Common(Generic[T]): class APIBase(Generic[T], Common[T]): - sub_apis: Dict[str, Type["APIBase"]] = {} + sub_apis: dict[str, type["APIBase"]] = {} def __init__(self, client) -> None: if isinstance(self, ModifiableMixin): @@ -123,7 +108,7 @@ def list(self, params=None): def page( self, offset=0, limit=100, params=None - ) -> Generator[T, Dict[Any, Any], PageResult[T]]: + ) -> Generator[T, dict[Any, Any], PageResult[T]]: if params is None: params = {} @@ -158,7 +143,7 @@ def iterate_all(self, params=None) -> Iterator[T]: yield from current_list offset += req_count - def detail(self, pk=None, **params) -> Generator[T, Dict[Any, Any], T]: + def detail(self, pk=None, **params) -> Generator[T, dict[Any, Any], T]: if all((pk, params)): raise ValueError("Only one of pk or params must be specified") diff --git a/gcapi/client.py b/gcapi/client.py index cd7fe0b5..14500af3 100644 --- a/gcapi/client.py +++ b/gcapi/client.py @@ -2,20 +2,12 @@ import os import re import uuid +from collections.abc import Generator from io import BytesIO from pathlib import Path from random import randint from time import sleep -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Dict, - Generator, - List, - Optional, - Union, -) +from typing import TYPE_CHECKING, Any, Callable, Optional, Union from urllib.parse import urljoin import httpx @@ -351,7 +343,7 @@ class WorkstationConfigsAPI(APIBase[gcapi.models.WorkstationConfig]): model = gcapi.models.WorkstationConfig -def _generate_auth_header(token: str = "") -> Dict: +def _generate_auth_header(token: str = "") -> dict: if not token: try: token = str(os.environ["GRAND_CHALLENGE_AUTHORIZATION"]) @@ -524,7 +516,7 @@ def _upload_file(self, value): def upload_cases( # noqa: C901 self, *, - files: List[str], + files: list[str], archive: Optional[str] = None, answer: Optional[str] = None, archive_item: Optional[str] = None, @@ -622,7 +614,7 @@ def upload_cases( # noqa: C901 return raw_image_upload_session - def run_external_job(self, *, algorithm: str, inputs: Dict[str, Any]): + def run_external_job(self, *, algorithm: str, inputs: dict[str, Any]): """ Starts an algorithm job with the provided inputs. You will need to provide the slug of the algorithm. You can find this in the @@ -702,7 +694,7 @@ def run_external_job(self, *, algorithm: str, inputs: Dict[str, Any]): return (yield from self.__org_api_meta.algorithm_jobs.create(**job)) def update_archive_item( - self, *, archive_item_pk: str, values: Dict[str, Any] + self, *, archive_item_pk: str, values: dict[str, Any] ): """ This function updates an existing archive item with the provided values @@ -739,7 +731,7 @@ def update_archive_item( item = yield from self.__org_api_meta.archive_items.detail( pk=archive_item_pk ) - civs: Dict[str, list] = {"values": []} + civs: dict[str, list] = {"values": []} for civ_slug, value in values.items(): try: @@ -831,7 +823,7 @@ def _validate_display_set_values(self, values, interfaces): return interfaces def add_cases_to_reader_study( - self, *, reader_study: str, display_sets: List[Dict[str, Any]] + self, *, reader_study: str, display_sets: list[dict[str, Any]] ): """ This function takes a reader study slug and a list of diplay sets @@ -862,7 +854,7 @@ def add_cases_to_reader_study( The pks of the newly created display sets. """ res = [] - interfaces: Dict[str, Dict] = {} + interfaces: dict[str, dict] = {} for display_set in display_sets: new_interfaces = yield from self._validate_display_set_values( display_set.items(), interfaces diff --git a/gcapi/gcapi.py b/gcapi/gcapi.py index 16afb95d..4c51b1a9 100644 --- a/gcapi/gcapi.py +++ b/gcapi/gcapi.py @@ -1,15 +1,8 @@ import inspect import logging +from collections.abc import AsyncGenerator, Generator from functools import wraps -from typing import ( - Any, - AsyncGenerator, - Callable, - Dict, - Generator, - NamedTuple, - Union, -) +from typing import Any, Callable, NamedTuple, Union import httpx @@ -72,7 +65,7 @@ def wrap(*args, **kwargs): def _wrap_client_base_interfaces(self): def wrap_api(api: APIBase): - attrs: Dict[str, Any] = {"__init__": lambda *_, **__: None} + attrs: dict[str, Any] = {"__init__": lambda *_, **__: None} for name in dir(api): if name.startswith("__"): diff --git a/gcapi/models.py b/gcapi/models.py index 9b1e4a66..83cb2ebc 100644 --- a/gcapi/models.py +++ b/gcapi/models.py @@ -27,7 +27,7 @@ class AlgorithmImage(BaseModel): @dataclass class Answer(BaseModel): - answer: Optional[Dict[str, Any]] + answer: Optional[dict[str, Any]] api_url: str created: str creator: str @@ -43,7 +43,7 @@ class Answer(BaseModel): @dataclass class AnswerRequest(BaseModel): - answer: Optional[Dict[str, Any]] + answer: Optional[dict[str, Any]] display_set: Optional[str] question: str last_edit_duration: Optional[str] @@ -54,7 +54,7 @@ class Archive(BaseModel): pk: str name: str title: str - algorithms: List[str] + algorithms: list[str] logo: str description: Optional[str] api_url: str @@ -115,7 +115,7 @@ class ColorSpaceEnum(Enum): @dataclass class ComponentInterfaceValuePost(BaseModel): interface: str - value: Optional[Dict[str, Any]] + value: Optional[dict[str, Any]] file: Optional[str] image: Optional[str] pk: int @@ -124,7 +124,7 @@ class ComponentInterfaceValuePost(BaseModel): @dataclass class ComponentInterfaceValuePostRequest(BaseModel): interface: str - value: Optional[Dict[str, Any]] + value: Optional[dict[str, Any]] file: Optional[bytes] image: Optional[str] upload_session: Optional[str] @@ -134,7 +134,7 @@ class ComponentInterfaceValuePostRequest(BaseModel): @dataclass class DisplaySetPostRequest(BaseModel): reader_study: Optional[str] - values: Optional[List[ComponentInterfaceValuePostRequest]] + values: Optional[list[ComponentInterfaceValuePostRequest]] order: Optional[int] @@ -144,8 +144,8 @@ class ETDRSGridAnnotation(BaseModel): grader: Optional[int] created: Optional[str] image: str - fovea: List[float] - optic_disk: Optional[List[float]] + fovea: list[float] + optic_disk: Optional[list[float]] @dataclass @@ -153,8 +153,8 @@ class ETDRSGridAnnotationRequest(BaseModel): grader: Optional[int] created: Optional[str] image: str - fovea: List[float] - optic_disk: Optional[List[float]] + fovea: list[float] + optic_disk: Optional[list[float]] class EyeChoiceEnum(Enum): @@ -169,7 +169,7 @@ class Feedback(BaseModel): session: str screenshot: Optional[str] user_comment: str - context: Optional[Dict[str, Any]] + context: Optional[dict[str, Any]] @dataclass @@ -177,7 +177,7 @@ class FeedbackRequest(BaseModel): session: str screenshot: Optional[bytes] user_comment: str - context: Optional[Dict[str, Any]] + context: Optional[dict[str, Any]] class FieldOfViewEnum(Enum): @@ -204,12 +204,12 @@ class FollowRequest(BaseModel): @dataclass class HangingProtocol(BaseModel): - json_: Dict[str, Any] + json_: dict[str, Any] @dataclass class HangingProtocolRequest(BaseModel): - json_: Dict[str, Any] + json_: dict[str, Any] @dataclass @@ -252,14 +252,14 @@ class ImagingModality(BaseModel): @dataclass class JobPost(BaseModel): pk: str - inputs: List[ComponentInterfaceValuePost] + inputs: list[ComponentInterfaceValuePost] status: str @dataclass class JobPostRequest(BaseModel): algorithm: str - inputs: List[ComponentInterfaceValuePostRequest] + inputs: list[ComponentInterfaceValuePostRequest] class LocationEnum(Enum): @@ -575,7 +575,7 @@ class PaginatedAlgorithmImageList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[AlgorithmImage]] + results: Optional[list[AlgorithmImage]] @dataclass @@ -583,7 +583,7 @@ class PaginatedAnswerList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[Answer]] + results: Optional[list[Answer]] @dataclass @@ -591,7 +591,7 @@ class PaginatedArchiveList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[Archive]] + results: Optional[list[Archive]] @dataclass @@ -599,7 +599,7 @@ class PaginatedFeedbackList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[Feedback]] + results: Optional[list[Feedback]] @dataclass @@ -607,7 +607,7 @@ class PaginatedFollowList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[Follow]] + results: Optional[list[Follow]] @dataclass @@ -615,7 +615,7 @@ class PaginatedNotificationList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[Notification]] + results: Optional[list[Notification]] @dataclass @@ -636,7 +636,7 @@ class PartRequest(BaseModel): @dataclass class PatchedAnswerRequest(BaseModel): - answer: Optional[Dict[str, Any]] + answer: Optional[dict[str, Any]] display_set: Optional[str] question: Optional[str] last_edit_duration: Optional[str] @@ -645,7 +645,7 @@ class PatchedAnswerRequest(BaseModel): @dataclass class PatchedArchiveItemPostRequest(BaseModel): archive: Optional[str] - values: Optional[List[ComponentInterfaceValuePostRequest]] + values: Optional[list[ComponentInterfaceValuePostRequest]] @dataclass @@ -660,7 +660,7 @@ class PatchedBooleanClassificationAnnotationRequest(BaseModel): @dataclass class PatchedDisplaySetPostRequest(BaseModel): reader_study: Optional[str] - values: Optional[List[ComponentInterfaceValuePostRequest]] + values: Optional[list[ComponentInterfaceValuePostRequest]] order: Optional[int] @@ -669,8 +669,8 @@ class PatchedETDRSGridAnnotationRequest(BaseModel): grader: Optional[int] created: Optional[str] image: Optional[str] - fovea: Optional[List[float]] - optic_disk: Optional[List[float]] + fovea: Optional[list[float]] + optic_disk: Optional[list[float]] @dataclass @@ -716,7 +716,7 @@ class PatchedRetinaImagePathologyAnnotationRequest(BaseModel): @dataclass class PatchedSinglePolygonAnnotationRequest(BaseModel): - value: Optional[List[List[float]]] + value: Optional[list[list[float]]] annotation_set: Optional[str] z: Optional[float] interpolated: Optional[bool] @@ -724,12 +724,12 @@ class PatchedSinglePolygonAnnotationRequest(BaseModel): @dataclass class PatchedUserUploadCompleteRequest(BaseModel): - parts: Optional[List[PartRequest]] + parts: Optional[list[PartRequest]] @dataclass class PatchedUserUploadPresignedURLsRequest(BaseModel): - part_numbers: Optional[List[int]] + part_numbers: Optional[list[int]] class PathologyEnum(Enum): @@ -770,23 +770,23 @@ class RawImageUploadSession(BaseModel): creator: Optional[int] status: str error_message: Optional[str] - image_set: List[str] + image_set: list[str] api_url: str - user_uploads: Optional[List[str]] - uploads: List[str] + user_uploads: Optional[list[str]] + uploads: list[str] @dataclass class RawImageUploadSessionRequest(BaseModel): creator: Optional[int] error_message: Optional[str] - user_uploads: Optional[List[str]] + user_uploads: Optional[list[str]] archive: Optional[str] answer: Optional[str] interface: Optional[str] archive_item: Optional[str] display_set: Optional[str] - uploads: List[str] + uploads: list[str] @dataclass @@ -828,19 +828,19 @@ class SimpleImage(BaseModel): class SingleLandmarkAnnotationSerializerNoParent(BaseModel): id: str image: str - landmarks: List[List[float]] + landmarks: list[list[float]] @dataclass class SingleLandmarkAnnotationSerializerNoParentRequest(BaseModel): image: str - landmarks: List[List[float]] + landmarks: list[list[float]] @dataclass class SinglePolygonAnnotation(BaseModel): id: str - value: List[List[float]] + value: list[list[float]] annotation_set: str created: str z: Optional[float] @@ -849,7 +849,7 @@ class SinglePolygonAnnotation(BaseModel): @dataclass class SinglePolygonAnnotationRequest(BaseModel): - value: List[List[float]] + value: list[list[float]] annotation_set: str z: Optional[float] interpolated: Optional[bool] @@ -858,7 +858,7 @@ class SinglePolygonAnnotationRequest(BaseModel): @dataclass class SinglePolygonAnnotationSerializerNoParent(BaseModel): id: Optional[str] - value: List[List[float]] + value: list[list[float]] z: Optional[float] interpolated: Optional[bool] @@ -866,7 +866,7 @@ class SinglePolygonAnnotationSerializerNoParent(BaseModel): @dataclass class SinglePolygonAnnotationSerializerNoParentRequest(BaseModel): id: Optional[str] - value: List[List[float]] + value: list[list[float]] z: Optional[float] interpolated: Optional[bool] @@ -1545,7 +1545,7 @@ class UserUploadParts(BaseModel): s3_upload_id: str status: str api_url: str - parts: List[Part] + parts: list[Part] @dataclass @@ -1557,7 +1557,7 @@ class UserUploadPresignedURLs(BaseModel): s3_upload_id: str status: str api_url: str - presigned_urls: Dict[str, str] + presigned_urls: dict[str, str] @dataclass @@ -1590,18 +1590,18 @@ class WorkstationConfig(BaseModel): modified: str creator: str image_context: str - window_presets: List[WindowPreset] + window_presets: list[WindowPreset] default_window_preset: WindowPreset default_slab_thickness_mm: float default_slab_render_method: str default_orientation: str default_overlay_alpha: float - overlay_luts: List[LookUpTable] + overlay_luts: list[LookUpTable] default_overlay_lut: LookUpTable default_overlay_interpolation: str default_image_interpolation: str - overlay_segments: Optional[Dict[str, Any]] - key_bindings: Optional[Dict[str, Any]] + overlay_segments: Optional[dict[str, Any]] + key_bindings: Optional[dict[str, Any]] default_zoom_scale: float default_brush_size: Optional[Decimal] default_annotation_color: Optional[str] @@ -1619,7 +1619,7 @@ class WorkstationConfig(BaseModel): show_overlay_selection_tool: Optional[bool] show_lut_selection_tool: Optional[bool] show_annotation_counter_tool: Optional[bool] - enabled_preprocessors: List[str] + enabled_preprocessors: list[str] auto_jump_center_of_gravity: Optional[bool] link_images: Optional[bool] link_panning: Optional[bool] @@ -1634,15 +1634,15 @@ class WorkstationConfig(BaseModel): @dataclass class ArchiveItemPost(BaseModel): pk: str - values: List[ComponentInterfaceValuePost] + values: list[ComponentInterfaceValuePost] hanging_protocol: Optional[HangingProtocol] - view_content: Dict[str, Any] + view_content: dict[str, Any] @dataclass class ArchiveItemPostRequest(BaseModel): archive: str - values: List[ComponentInterfaceValuePostRequest] + values: list[ComponentInterfaceValuePostRequest] @dataclass @@ -1652,17 +1652,17 @@ class ComponentInterface(BaseModel): slug: str kind: str pk: int - default_value: Optional[Dict[str, Any]] + default_value: Optional[dict[str, Any]] super_kind: str relative_path: str - overlay_segments: Optional[Dict[str, Any]] + overlay_segments: Optional[dict[str, Any]] look_up_table: Optional[LookUpTable] @dataclass class ComponentInterfaceValue(BaseModel): interface: ComponentInterface - value: Optional[Dict[str, Any]] + value: Optional[dict[str, Any]] file: Optional[str] image: Optional[SimpleImage] pk: int @@ -1672,11 +1672,11 @@ class ComponentInterfaceValue(BaseModel): class DisplaySetPost(BaseModel): pk: str reader_study: Optional[str] - values: Optional[List[ComponentInterfaceValuePost]] + values: Optional[list[ComponentInterfaceValuePost]] order: Optional[int] api_url: str hanging_protocol: Optional[HangingProtocol] - view_content: Dict[str, Any] + view_content: dict[str, Any] description: str index: Optional[int] @@ -1684,7 +1684,7 @@ class DisplaySetPost(BaseModel): @dataclass class HyperlinkedComponentInterfaceValue(BaseModel): interface: ComponentInterface - value: Optional[Dict[str, Any]] + value: Optional[dict[str, Any]] file: Optional[str] image: Optional[str] pk: int @@ -1695,14 +1695,14 @@ class HyperlinkedJob(BaseModel): pk: str api_url: str algorithm_image: str - inputs: List[HyperlinkedComponentInterfaceValue] - outputs: List[HyperlinkedComponentInterfaceValue] + inputs: list[HyperlinkedComponentInterfaceValue] + outputs: list[HyperlinkedComponentInterfaceValue] status: str rendered_result_text: str started_at: Optional[str] completed_at: Optional[str] hanging_protocol: Optional[HangingProtocol] - view_content: Dict[str, Any] + view_content: dict[str, Any] @dataclass @@ -1754,7 +1754,7 @@ class LandmarkAnnotationSet(BaseModel): id: str grader: Optional[int] created: Optional[str] - singlelandmarkannotation_set: List[ + singlelandmarkannotation_set: list[ SingleLandmarkAnnotationSerializerNoParent ] @@ -1763,7 +1763,7 @@ class LandmarkAnnotationSet(BaseModel): class LandmarkAnnotationSetRequest(BaseModel): grader: Optional[int] created: Optional[str] - singlelandmarkannotation_set: List[ + singlelandmarkannotation_set: list[ SingleLandmarkAnnotationSerializerNoParentRequest ] @@ -1775,7 +1775,7 @@ class NestedPolygonAnnotationSet(BaseModel): grader: Optional[int] created: Optional[str] name: str - singlepolygonannotation_set: List[ + singlepolygonannotation_set: list[ SinglePolygonAnnotationSerializerNoParent ] @@ -1786,7 +1786,7 @@ class NestedPolygonAnnotationSetRequest(BaseModel): grader: Optional[int] created: Optional[str] name: str - singlepolygonannotation_set: List[ + singlepolygonannotation_set: list[ SinglePolygonAnnotationSerializerNoParentRequest ] @@ -1796,7 +1796,7 @@ class PaginatedComponentInterfaceList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[ComponentInterface]] + results: Optional[list[ComponentInterface]] @dataclass @@ -1804,7 +1804,7 @@ class PaginatedHyperlinkedJobList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[HyperlinkedJob]] + results: Optional[list[HyperlinkedJob]] @dataclass @@ -1812,7 +1812,7 @@ class PaginatedRawImageUploadSessionList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[RawImageUploadSession]] + results: Optional[list[RawImageUploadSession]] @dataclass @@ -1820,7 +1820,7 @@ class PaginatedSessionList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[Session]] + results: Optional[list[Session]] @dataclass @@ -1828,7 +1828,7 @@ class PaginatedUserUploadList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[UserUpload]] + results: Optional[list[UserUpload]] @dataclass @@ -1836,7 +1836,7 @@ class PaginatedWorkstationConfigList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[WorkstationConfig]] + results: Optional[list[WorkstationConfig]] @dataclass @@ -1844,7 +1844,7 @@ class PaginatedWorkstationList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[Workstation]] + results: Optional[list[Workstation]] @dataclass @@ -1869,7 +1869,7 @@ class PatchedLandmarkAnnotationSetRequest(BaseModel): grader: Optional[int] created: Optional[str] singlelandmarkannotation_set: Optional[ - List[SingleLandmarkAnnotationSerializerNoParentRequest] + list[SingleLandmarkAnnotationSerializerNoParentRequest] ] @@ -1880,7 +1880,7 @@ class PatchedNestedPolygonAnnotationSetRequest(BaseModel): created: Optional[str] name: Optional[str] singlepolygonannotation_set: Optional[ - List[SinglePolygonAnnotationSerializerNoParentRequest] + list[SinglePolygonAnnotationSerializerNoParentRequest] ] @@ -1895,9 +1895,9 @@ class Question(BaseModel): question_text: str reader_study: str required: Optional[bool] - options: List[CategoricalOption] + options: list[CategoricalOption] interface: Optional[ComponentInterface] - overlay_segments: Optional[Dict[str, Any]] + overlay_segments: Optional[dict[str, Any]] look_up_table: Optional[LookUpTable] widget: str answer_min_value: Optional[int] @@ -1914,7 +1914,7 @@ class ReaderStudy(BaseModel): description: Optional[str] help_text: str pk: str - questions: List[Question] + questions: list[Question] title: str is_educational: Optional[bool] has_ground_truth: bool @@ -1928,7 +1928,7 @@ class ReaderStudy(BaseModel): class RetinaImage(BaseModel): pk: str name: str - files: List[ImageFile] + files: list[ImageFile] width: int height: int depth: Optional[int] @@ -1939,8 +1939,8 @@ class RetinaImage(BaseModel): Union[StereoscopicChoiceEnum, BlankEnum, NullEnum] ] field_of_view: Optional[Union[FieldOfViewEnum, BlankEnum, NullEnum]] - shape_without_color: List[int] - shape: List[int] + shape_without_color: list[int] + shape: list[int] voxel_width_mm: Optional[float] voxel_height_mm: Optional[float] voxel_depth_mm: Optional[float] @@ -1957,7 +1957,7 @@ class RetinaImage(BaseModel): series_description: Optional[str] window_center: Optional[float] window_width: Optional[float] - landmark_annotations: List[str] + landmark_annotations: list[str] @dataclass @@ -1988,28 +1988,28 @@ class Algorithm(BaseModel): logo: str slug: str average_duration: Optional[float] - inputs: List[ComponentInterface] - outputs: List[ComponentInterface] + inputs: list[ComponentInterface] + outputs: list[ComponentInterface] @dataclass class ArchiveItem(BaseModel): pk: str archive: str - values: List[HyperlinkedComponentInterfaceValue] + values: list[HyperlinkedComponentInterfaceValue] hanging_protocol: Optional[HangingProtocol] - view_content: Dict[str, Any] + view_content: dict[str, Any] @dataclass class DisplaySet(BaseModel): pk: str reader_study: str - values: List[HyperlinkedComponentInterfaceValue] + values: list[HyperlinkedComponentInterfaceValue] order: Optional[int] api_url: str hanging_protocol: Optional[HangingProtocol] - view_content: Dict[str, Any] + view_content: dict[str, Any] description: str index: Optional[int] @@ -2021,10 +2021,10 @@ class Evaluation(BaseModel): submission: Submission created: str published: Optional[bool] - outputs: List[ComponentInterfaceValue] + outputs: list[ComponentInterfaceValue] rank: Optional[int] rank_score: Optional[float] - rank_per_metric: Optional[Dict[str, Any]] + rank_per_metric: Optional[dict[str, Any]] status: str title: str @@ -2033,7 +2033,7 @@ class Evaluation(BaseModel): class HyperlinkedImage(BaseModel): pk: str name: str - files: List[ImageFile] + files: list[ImageFile] width: int height: int depth: Optional[int] @@ -2044,8 +2044,8 @@ class HyperlinkedImage(BaseModel): Union[StereoscopicChoiceEnum, BlankEnum, NullEnum] ] field_of_view: Optional[Union[FieldOfViewEnum, BlankEnum, NullEnum]] - shape_without_color: List[int] - shape: List[int] + shape_without_color: list[int] + shape: list[int] voxel_width_mm: Optional[float] voxel_height_mm: Optional[float] voxel_depth_mm: Optional[float] @@ -2069,7 +2069,7 @@ class PaginatedAlgorithmList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[Algorithm]] + results: Optional[list[Algorithm]] @dataclass @@ -2077,7 +2077,7 @@ class PaginatedArchiveItemList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[ArchiveItem]] + results: Optional[list[ArchiveItem]] @dataclass @@ -2085,7 +2085,7 @@ class PaginatedDisplaySetList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[DisplaySet]] + results: Optional[list[DisplaySet]] @dataclass @@ -2093,7 +2093,7 @@ class PaginatedEvaluationList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[Evaluation]] + results: Optional[list[Evaluation]] @dataclass @@ -2101,7 +2101,7 @@ class PaginatedHyperlinkedImageList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[HyperlinkedImage]] + results: Optional[list[HyperlinkedImage]] @dataclass @@ -2109,7 +2109,7 @@ class PaginatedQuestionList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[Question]] + results: Optional[list[Question]] @dataclass @@ -2117,7 +2117,7 @@ class PaginatedReaderStudyList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[ReaderStudy]] + results: Optional[list[ReaderStudy]] @dataclass @@ -2125,4 +2125,4 @@ class PaginatedRetinaImageList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[RetinaImage]] + results: Optional[list[RetinaImage]] diff --git a/gcapi/retries.py b/gcapi/retries.py index 65bdd677..32c7ffce 100644 --- a/gcapi/retries.py +++ b/gcapi/retries.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional +from typing import Optional import httpx from httpx import codes @@ -33,7 +33,7 @@ class SelectiveBackoffStrategy(BaseRetryStrategy): def __init__(self, backoff_factor, maximum_number_of_retries): self.backoff_factor: float = backoff_factor self.maximum_number_of_retries: int = maximum_number_of_retries - self.earlier_number_of_retries: Dict[int, int] = dict() + self.earlier_number_of_retries: dict[int, int] = dict() def __call__(self) -> BaseRetryStrategy: return self.__class__( diff --git a/gcapi/sync_async_hybrid_support.py b/gcapi/sync_async_hybrid_support.py index 46fafeee..bd3ee25d 100644 --- a/gcapi/sync_async_hybrid_support.py +++ b/gcapi/sync_async_hybrid_support.py @@ -160,7 +160,7 @@ async def rest_query(self, pk): """ from dataclasses import dataclass -from typing import Callable, Dict, Tuple, TypeVar, Union +from typing import Callable, TypeVar, Union @dataclass @@ -174,8 +174,8 @@ class CapturedCall: """ func: Union[object, Callable] - args: Tuple - kwargs: Dict + args: tuple + kwargs: dict SLOT = object() diff --git a/gcapi/transports.py b/gcapi/transports.py index bcf19944..694476ed 100644 --- a/gcapi/transports.py +++ b/gcapi/transports.py @@ -1,7 +1,7 @@ import asyncio import logging from time import sleep -from typing import Callable, Optional, Tuple +from typing import Callable, Optional import httpx @@ -34,7 +34,7 @@ def _get_retry_delay( retry_strategy, response, request, - ) -> Tuple[BaseRetryStrategy, Optional[Seconds]]: + ) -> tuple[BaseRetryStrategy, Optional[Seconds]]: if retry_strategy is None: retry_strategy = self.retry_strategy() # type: ignore diff --git a/pyproject.toml b/pyproject.toml index 893d98c9..14123876 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ gcapi = "gcapi.cli:main" [tool.poetry.dependencies] -python = ">=3.8,<4.0" +python = ">=3.9,<4.0" httpx = "~0.23.0" Click = ">=6.0" pydantic = "*" @@ -25,7 +25,6 @@ pytest = "*" pytest-randomly = "*" pytest-cov = "*" pyyaml = "*" -docker-compose-wait = "*" datamodel-code-generator = "^0.17.1" mypy = "^1.1.1" @@ -40,7 +39,7 @@ line_length = 79 [tool.black] line-length = 79 -target-version = ['py38'] +target-version = ['py39'] [tool.pytest.ini_options] minversion = "6.0" @@ -55,14 +54,14 @@ xfail_strict = true legacy_tox_ini = """ [tox] isolated_build = True -envlist = py38, py39, py310, py311 +envlist = py39, py310, py311, py312 [gh-actions] python = - 3.8: py38 3.9: py39 3.10: py310 3.11: py311 + 3.12: py312 [testenv] allowlist_externals = diff --git a/setup.cfg b/setup.cfg index 8613ac51..314d899b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [mypy] -python_version = 3.8 +python_version = 3.9 plugins = pydantic.mypy [flake8] @@ -32,3 +32,5 @@ ignore = # B905 `zip()` without an explicit `strict=` parameter # Introduced in py310, still need to support py39 B905 + B907 + E704 diff --git a/tests/async_integration_tests.py b/tests/async_integration_tests.py index 3bffe0b7..ad2fe3b4 100644 --- a/tests/async_integration_tests.py +++ b/tests/async_integration_tests.py @@ -332,7 +332,8 @@ async def get_download(): "generic-medical-image", ["image10x10x101.mha"], ), - ("test-algorithm-evaluation-file-1", "json-file", ["test.json"]), + # TODO this algorithm was removed from the test fixtures + # ("test-algorithm-evaluation-file-1", "json-file", ["test.json"]), ), ) @pytest.mark.anyio @@ -760,7 +761,7 @@ async def check_file(interface_value, expected_name): added_display_sets, display_sets ): ds = await c.reader_studies.display_sets.detail(pk=display_set_pk) - # make take a while for the images to be added + # may take a while for the images to be added while len(ds["values"]) != len(display_set): ds = await c.reader_studies.display_sets.detail( pk=display_set_pk @@ -827,7 +828,7 @@ async def test_add_cases_to_reader_study_invalid_path( ) assert str(e.value) == ( - "Invalid file paths: " # noqa: B907 + "Invalid file paths: " f"{{'generic-medical-image': ['{file_path}']}}" ) diff --git a/tests/conftest.py b/tests/conftest.py index e2b5a73f..b02881bd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,11 @@ import os import shutil +from collections.abc import Generator from os import makedirs from pathlib import Path -from subprocess import check_call +from subprocess import STDOUT, check_output from tempfile import TemporaryDirectory from time import sleep -from typing import Generator import httpx import pytest @@ -41,62 +41,77 @@ def local_grand_challenge() -> Generator[str, None, None]: with TemporaryDirectory() as tmp_path: for f in [ "docker-compose.yml", - "dockerfiles/db/postgres.test.conf", - "Makefile", - "scripts/development_fixtures.py", - "scripts/component_interface_value_fixtures.py", - "scripts/image10x10x10.mha", "scripts/minio.py", - "app/tests/resources/gc_demo_algorithm/copy_io.py", - "app/tests/resources/gc_demo_algorithm/Dockerfile", ]: get_grand_challenge_file(Path(f), Path(tmp_path)) - for local_path, container_path in ( - ( - "/testdata/algorithm_io.tar.gz", - "scripts/algorithm_io.tar.gz", - ), - ( - "/fixtures/algorithm_evaluation_fixtures.py", - "scripts/algorithm_evaluation_fixtures.py", - ), - ): + for file in (Path(__file__).parent / "scripts").glob("*"): shutil.copy( - os.path.abspath(os.path.dirname(__file__)) + local_path, - Path(tmp_path) / container_path, + file, + Path(tmp_path) / "scripts" / file.name, ) + + docker_gid = int( + os.environ.get( + "DOCKER_GID", + check_output( + "getent group docker | cut -d: -f3", + shell=True, + text=True, + ), + ).strip() + ) + try: - check_call( + check_output( [ "bash", "-c", - "echo DOCKER_GID=`getent group docker | cut -d: -f3` > .env", # noqa: B950 + f"echo DOCKER_GID={docker_gid} > .env", ], cwd=tmp_path, + stderr=STDOUT, ) - check_call( - ["make", "development_fixtures"], + check_output( + ["docker", "compose", "pull"], cwd=tmp_path, + stderr=STDOUT, ) - check_call( - ["make", "algorithm_evaluation_fixtures"], + check_output( + [ + "docker", + "compose", + "run", + "-v", + f"{(Path(tmp_path) / 'scripts').absolute()}:/app/scripts:ro", + "--rm", + "celery_worker_evaluation", + "bash", + "-c", + ( + "python manage.py migrate " + "&& python manage.py runscript " + "minio create_test_fixtures" + ), + ], cwd=tmp_path, + stderr=STDOUT, ) - check_call( + check_output( [ - "docker-compose", + "docker", + "compose", "up", + "--wait", + "--wait-timeout", + "300", "-d", "http", "celery_worker", "celery_worker_evaluation", ], cwd=tmp_path, - ) - check_call( - ["docker-compose-wait", "-w", "-t", "5m"], - cwd=tmp_path, + stderr=STDOUT, ) # Give the system some time to import the algorithm image @@ -105,7 +120,11 @@ def local_grand_challenge() -> Generator[str, None, None]: yield local_api_url finally: - check_call(["docker-compose", "down"], cwd=tmp_path) + check_output( + ["docker", "compose", "down"], + cwd=tmp_path, + stderr=STDOUT, + ) def get_grand_challenge_file(repo_path: Path, output_directory: Path) -> None: @@ -116,11 +135,10 @@ def get_grand_challenge_file(repo_path: Path, output_directory: Path) -> None: ), follow_redirects=True, ) + r.raise_for_status() if str(repo_path) == "docker-compose.yml": content = rewrite_docker_compose(r.content) - elif str(repo_path) == "Makefile": - content = rewrite_makefile(r.content) else: content = r.content @@ -135,9 +153,14 @@ def rewrite_docker_compose(content: bytes) -> bytes: spec = yaml.safe_load(content) for s in spec["services"]: - # Remove the non-postgres volume mounts, these are not needed for testing - if s != "postgres" and "volumes" in spec["services"][s]: - del spec["services"][s]["volumes"] + # Remove the non-docker socket volume mounts, + # these are not needed for these tests + if "volumes" in spec["services"][s]: + spec["services"][s]["volumes"] = [ + volume + for volume in spec["services"][s]["volumes"] + if volume["target"] == "/var/run/docker.sock" + ] # Replace test with production containers if ( @@ -162,22 +185,3 @@ def rewrite_docker_compose(content: bytes) -> bytes: spec["services"][service]["command"] = command return yaml.safe_dump(spec).encode("utf-8") - - -def rewrite_makefile(content: bytes) -> bytes: - # Using `docker compose` with version 2.4.1+azure-1 does not seem to work - # It works locally with version `2.5.1`, so for now go back to docker-compose - # If this is fixed docker-compose-wait can be removed and the `--wait` - # option added to the "up" action above - makefile = content.decode("utf-8") - makefile = makefile.replace("docker compose", "docker-compose") - # Faker is required by development_fixtures.py but not available on the production - # container. So we add it manually here. - makefile = makefile.replace( - "python manage.py migrate && " - "python manage.py runscript minio development_fixtures", - "python -m pip install faker && " - "python manage.py migrate && " - "python manage.py runscript minio development_fixtures", - ) - return makefile.encode("utf-8") diff --git a/tests/fixtures/algorithm_evaluation_fixtures.py b/tests/fixtures/algorithm_evaluation_fixtures.py deleted file mode 100644 index 067af668..00000000 --- a/tests/fixtures/algorithm_evaluation_fixtures.py +++ /dev/null @@ -1,184 +0,0 @@ -import os -from contextlib import contextmanager -from pathlib import Path - -from django.conf import settings -from django.contrib.auth import get_user_model -from django.core.files.base import ContentFile -from grandchallenge.algorithms.models import Algorithm, AlgorithmImage -from grandchallenge.archives.models import Archive, ArchiveItem -from grandchallenge.cases.models import Image, ImageFile -from grandchallenge.challenges.models import Challenge -from grandchallenge.components.models import ( - ComponentInterface, - ComponentInterfaceValue, -) -from grandchallenge.core.fixtures import create_uploaded_image -from grandchallenge.evaluation.models import Method, Phase -from grandchallenge.evaluation.utils import SubmissionKindChoices -from grandchallenge.invoices.models import Invoice -from grandchallenge.workstations.models import Workstation - - -def run(): - print("👷 Creating Algorithm Evaluation Fixtures") - - users = _get_users() - inputs = _get_inputs() - outputs = _get_outputs() - challenge_count = Challenge.objects.count() - archive = _create_archive( - creator=users["demo"], interfaces=inputs, suffix=challenge_count - ) - _create_challenge( - creator=users["demo"], - participant=users["demop"], - archive=archive, - suffix=challenge_count, - inputs=inputs, - outputs=outputs, - ) - _create_algorithm( - creator=users["demop"], - inputs=inputs, - outputs=outputs, - suffix=f"Image {challenge_count}", - ) - _create_algorithm( - creator=users["demop"], - inputs=_get_json_file_inputs(), - outputs=outputs, - suffix=f"File {challenge_count}", - ) - - -def _get_users(): - users = get_user_model().objects.filter(username__in=["demo", "demop"]) - return {u.username: u for u in users} - - -def _get_inputs(): - return ComponentInterface.objects.filter( - slug__in=["generic-medical-image"] - ) - - -def _get_outputs(): - return ComponentInterface.objects.filter( - slug__in=["generic-medical-image", "results-json-file"] - ) - - -def _get_json_file_inputs(): - return [ - ComponentInterface.objects.get_or_create( - title="JSON File", - relative_path="json-file", - kind=ComponentInterface.Kind.ANY, - store_in_database=False, - )[0] - ] - - -def _create_archive(*, creator, interfaces, suffix, items=5): - a = Archive.objects.create( - title=f"Algorithm Evaluation {suffix} Test Set", - logo=create_uploaded_image(), - workstation=Workstation.objects.get( - slug=settings.DEFAULT_WORKSTATION_SLUG - ), - ) - a.add_editor(creator) - - for n in range(items): - ai = ArchiveItem.objects.create(archive=a) - for interface in interfaces: - v = ComponentInterfaceValue.objects.create(interface=interface) - - im = Image.objects.create( - name=f"Test Image {n}", width=10, height=10 - ) - im_file = ImageFile.objects.create(image=im) - - with _uploaded_image_file() as f: - im_file.file.save(f"test_image_{n}.mha", f) - im_file.save() - - v.image = im - v.save() - - ai.values.add(v) - - return a - - -def _create_challenge( - *, creator, participant, archive, suffix, inputs, outputs -): - c = Challenge.objects.create( - short_name=f"algorithm-evaluation-{suffix}", - creator=creator, - hidden=False, - logo=create_uploaded_image(), - ) - c.add_participant(participant) - - Invoice.objects.create( - challenge=c, - support_costs_euros=0, - compute_costs_euros=10, - storage_costs_euros=0, - payment_status=Invoice.PaymentStatusChoices.PAID, - ) - - p = Phase.objects.create( - challenge=c, title="Phase 1", algorithm_time_limit=300 - ) - - p.algorithm_inputs.set(inputs) - p.algorithm_outputs.set(outputs) - - p.title = "Algorithm Evaluation" - p.submission_kind = SubmissionKindChoices.ALGORITHM - p.archive = archive - p.score_jsonpath = "score" - p.submissions_limit_per_user_per_period = 10 - p.save() - - m = Method(creator=creator, phase=p) - - with _gc_demo_algorithm() as container: - m.image.save("algorithm_io.tar", container) - - -def _create_algorithm(*, creator, inputs, outputs, suffix): - algorithm = Algorithm.objects.create( - title=f"Test Algorithm Evaluation {suffix}", - logo=create_uploaded_image(), - ) - algorithm.inputs.set(inputs) - algorithm.outputs.set(outputs) - algorithm.add_editor(creator) - - algorithm_image = AlgorithmImage(creator=creator, algorithm=algorithm) - - with _gc_demo_algorithm() as container: - algorithm_image.image.save("algorithm_io.tar", container) - - -@contextmanager -def _gc_demo_algorithm(): - path = Path(__file__).parent / "algorithm_io.tar.gz" - yield from _uploaded_file(path=path) - - -@contextmanager -def _uploaded_image_file(): - path = Path(__file__).parent / "image10x10x10.mha" - yield from _uploaded_file(path=path) - - -def _uploaded_file(*, path): - with open(os.path.join(settings.SITE_ROOT, path), "rb") as f: - with ContentFile(f.read()) as content: - yield content diff --git a/tests/integration_tests.py b/tests/integration_tests.py index cf7340ed..0ceb4d46 100644 --- a/tests/integration_tests.py +++ b/tests/integration_tests.py @@ -295,11 +295,12 @@ def get_download(): "generic-medical-image", ["image10x10x101.mha"], ), - ( - "test-algorithm-evaluation-file-1", - "json-file", - ["test.json"], - ), + # TODO this algorithm was removed from the test fixtures + # ( + # "test-algorithm-evaluation-file-1", + # "json-file", + # ["test.json"], + # ), ), ) def test_create_job_with_upload( @@ -683,7 +684,7 @@ def check_file(interface_value, expected_name): for display_set_pk, display_set in zip(added_display_sets, display_sets): ds = c.reader_studies.display_sets.detail(pk=display_set_pk) - # make take a while for the images to be added + # may take a while for the images to be added while len(ds["values"]) != len(display_set): ds = c.reader_studies.display_sets.detail(pk=display_set_pk) @@ -746,8 +747,7 @@ def test_add_cases_to_reader_study_invalid_path(local_grand_challenge): ) assert str(e.value) == ( - "Invalid file paths: " # noqa: B907 - f"{{'generic-medical-image': ['{file_path}']}}" + "Invalid file paths: " f"{{'generic-medical-image': ['{file_path}']}}" ) diff --git a/tests/scripts/Dockerfile b/tests/scripts/Dockerfile new file mode 100644 index 00000000..a09b18d6 --- /dev/null +++ b/tests/scripts/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +RUN useradd -ms /bin/bash myuser +RUN groupadd -r mygroup +RUN usermod -a -G mygroup myuser + +WORKDIR /home/myuser + +USER myuser + +ADD copy_io.py . + +ENTRYPOINT ["python", "copy_io.py"] diff --git a/tests/scripts/copy_io.py b/tests/scripts/copy_io.py new file mode 100644 index 00000000..7f57062a --- /dev/null +++ b/tests/scripts/copy_io.py @@ -0,0 +1,32 @@ +import json +from pathlib import Path +from shutil import copy +from warnings import warn + + +def create_output(): + res = {"score": 1} # dummy metric for ranking on leaderboard + files = {x for x in Path("/input").rglob("*") if x.is_file()} + + for file in files: + try: + with open(file) as f: + val = json.loads(f.read()) + except Exception as e: + warn(f"Could not load {file} as json, {e}") + val = "file" + + res[str(file.absolute())] = val + + # Copy all the input files to output + new_file = Path("/output/") / file.relative_to("/input/") + new_file.parent.mkdir(parents=True, exist_ok=True) + copy(file, new_file) + + for output_filename in ["results", "metrics"]: + with open(f"/output/{output_filename}.json", "w") as f: + f.write(json.dumps(res)) + + +if __name__ == "__main__": + create_output() diff --git a/tests/scripts/create_test_fixtures.py b/tests/scripts/create_test_fixtures.py new file mode 100644 index 00000000..41c4830b --- /dev/null +++ b/tests/scripts/create_test_fixtures.py @@ -0,0 +1,468 @@ +import base64 +import gzip +import logging +import os +import shutil +from contextlib import contextmanager +from pathlib import Path +from tempfile import TemporaryDirectory + +from allauth.account.models import EmailAddress +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group, Permission +from django.core.exceptions import ObjectDoesNotExist +from django.core.files.base import ContentFile +from django.db import IntegrityError +from grandchallenge.algorithms.models import Algorithm, AlgorithmImage +from grandchallenge.archives.models import Archive, ArchiveItem +from grandchallenge.cases.models import Image, ImageFile +from grandchallenge.challenges.models import Challenge +from grandchallenge.components.backends import docker_client +from grandchallenge.components.models import ( + ComponentInterface, + ComponentInterfaceValue, +) +from grandchallenge.core.fixtures import create_uploaded_image +from grandchallenge.evaluation.models import ( + Evaluation, + Method, + Phase, + Submission, +) +from grandchallenge.evaluation.utils import SubmissionKindChoices +from grandchallenge.invoices.models import Invoice +from grandchallenge.reader_studies.models import ( + Answer, + DisplaySet, + Question, + QuestionWidgetKindChoices, + ReaderStudy, +) +from grandchallenge.verifications.models import Verification +from grandchallenge.workstations.models import Workstation +from knox import crypto +from knox.models import AuthToken +from knox.settings import CONSTANTS + +logger = logging.getLogger(__name__) + +DEFAULT_USERS = [ + "demo", + "demop", + "admin", + "readerstudy", + "archive", +] + + +def run(): + """Creates the main project, demo user and demo challenge.""" + print("🔨 Creating development fixtures 🔨") + + if not settings.DEBUG: + raise RuntimeError( + "Skipping this command, server is not in DEBUG mode." + ) + + try: + users = _create_users(usernames=DEFAULT_USERS) + except IntegrityError as e: + raise RuntimeError("Fixtures already initialized") from e + + _set_user_permissions(users) + _create_demo_challenge(users=users) + _create_reader_studies(users) + _create_archive(users) + _create_user_tokens(users) + + inputs = _get_inputs() + outputs = _get_outputs() + challenge_count = Challenge.objects.count() + archive = _create_phase_archive( + creator=users["demo"], interfaces=inputs, suffix=challenge_count + ) + _create_challenge( + creator=users["demo"], + participant=users["demop"], + archive=archive, + suffix=challenge_count, + inputs=inputs, + outputs=outputs, + ) + _create_algorithm( + creator=users["demop"], + inputs=inputs, + outputs=outputs, + suffix=f"Image {challenge_count}", + ) + + print("✨ Test fixtures successfully created ✨") + + +def _create_users(usernames): + users = {} + + for username in usernames: + user = get_user_model().objects.create( + username=username, + email=f"{username}@example.com", + is_active=True, + first_name=username, + last_name=username, + ) + user.set_password(username) + user.save() + + EmailAddress.objects.create( + user=user, + email=user.email, + verified=True, + primary=True, + ) + + Verification.objects.create( + user=user, + email=user.email, + is_verified=True, + ) + + user.user_profile.institution = f"University of {username}" + user.user_profile.department = f"Department of {username}s" + user.user_profile.country = "NL" + user.user_profile.receive_newsletter = True + user.user_profile.save() + users[username] = user + + return users + + +def _set_user_permissions(users): + users["admin"].is_staff = True + users["admin"].save() + + rs_group = Group.objects.get( + name=settings.READER_STUDY_CREATORS_GROUP_NAME + ) + users["readerstudy"].groups.add(rs_group) + + add_archive_perm = Permission.objects.get(codename="add_archive") + users["archive"].user_permissions.add(add_archive_perm) + users["demo"].user_permissions.add(add_archive_perm) + + +def _create_demo_challenge(users): + demo = Challenge.objects.create( + short_name="demo", + description="Demo Challenge", + creator=users["demo"], + hidden=False, + display_forum_link=True, + ) + demo.add_participant(users["demop"]) + + phase = Phase.objects.create(challenge=demo, title="Phase 1") + + phase.score_title = "Accuracy ± std" + phase.score_jsonpath = "acc.mean" + phase.score_error_jsonpath = "acc.std" + phase.extra_results_columns = [ + { + "title": "Dice ± std", + "path": "dice.mean", + "error_path": "dice.std", + "order": "desc", + } + ] + + phase.submission_kind = SubmissionKindChoices.ALGORITHM + phase.save() + + method = Method(phase=phase, creator=users["demo"]) + + with _gc_demo_algorithm() as container: + method.image.save("algorithm_io.tar", container) + + submission = Submission(phase=phase, creator=users["demop"]) + content = ContentFile(base64.b64decode(b"")) + submission.predictions_file.save("test.csv", content) + submission.save() + + e = Evaluation.objects.create( + submission=submission, method=method, status=Evaluation.SUCCESS + ) + + def create_result(evaluation, result: dict): + interface = ComponentInterface.objects.get(slug="metrics-json-file") + + try: + output_civ = evaluation.outputs.get(interface=interface) + output_civ.value = result + output_civ.save() + except ObjectDoesNotExist: + output_civ = ComponentInterfaceValue.objects.create( + interface=interface, value=result + ) + evaluation.outputs.add(output_civ) + + create_result( + e, + { + "acc": {"mean": 0, "std": 0.1}, + "dice": {"mean": 0.71, "std": 0.05}, + }, + ) + + +def _create_reader_studies(users): + reader_study = ReaderStudy.objects.create( + title="Reader Study", + workstation=Workstation.objects.get( + slug=settings.DEFAULT_WORKSTATION_SLUG + ), + logo=create_uploaded_image(), + description="Test reader study", + view_content={"main": ["generic-medical-image"]}, + ) + reader_study.editors_group.user_set.add(users["readerstudy"]) + reader_study.readers_group.user_set.add(users["demo"]) + + question = Question.objects.create( + reader_study=reader_study, + question_text="foo", + answer_type=Question.AnswerType.TEXT, + widget=QuestionWidgetKindChoices.TEXT_INPUT, + ) + + display_set = DisplaySet.objects.create( + reader_study=reader_study, + ) + image = _create_image( + name="test_image2.mha", + width=128, + height=128, + color_space="RGB", + ) + + annotation_interface = ComponentInterface( + store_in_database=True, + relative_path="annotation.json", + slug="annotation", + title="Annotation", + kind=ComponentInterface.Kind.TWO_D_BOUNDING_BOX, + ) + annotation_interface.save() + civ = ComponentInterfaceValue.objects.create( + interface=ComponentInterface.objects.get(slug="generic-medical-image"), + image=image, + ) + display_set.values.set([civ]) + + answer = Answer.objects.create( + creator=users["readerstudy"], + question=question, + answer="foo", + display_set=display_set, + ) + answer.save() + + +def _create_archive(users): + archive = Archive.objects.create( + title="Archive", + workstation=Workstation.objects.get( + slug=settings.DEFAULT_WORKSTATION_SLUG + ), + logo=create_uploaded_image(), + description="Test archive", + ) + archive.editors_group.user_set.add(users["archive"]) + archive.uploaders_group.user_set.add(users["demo"]) + + item = ArchiveItem.objects.create(archive=archive) + civ = ComponentInterfaceValue.objects.create( + interface=ComponentInterface.objects.get(slug="generic-medical-image"), + image=_create_image( + name="test_image2.mha", + width=128, + height=128, + color_space="RGB", + ), + ) + + item.values.add(civ) + + +def _create_user_tokens(users): + # Hard code tokens used in gcapi integration tests + user_tokens = { + "admin": "1b9436200001f2eaf57cd77db075cbb60a49a00a", + "readerstudy": "01614a77b1c0b4ecd402be50a8ff96188d5b011d", + "demop": "00aa710f4dc5621a0cb64b0795fbba02e39d7700", + "archive": "0d284528953157759d26c469297afcf6fd367f71", + } + + out = f"{'*' * 80}\n" + for user, token in user_tokens.items(): + digest = crypto.hash_token(token) + + AuthToken( + token_key=token[: CONSTANTS.TOKEN_KEY_LENGTH], + digest=digest, + user=users[user], + expiry=None, + ).save() + + out += f"\t{user} token is: {token}\n" + out += f"{'*' * 80}\n" + logger.debug(out) + + +image_counter = 0 + + +def _create_image(**kwargs): + global image_counter + + im = Image.objects.create(**kwargs) + im_file = ImageFile.objects.create(image=im) + + with _uploaded_image_file() as f: + im_file.file.save(f"test_image_{image_counter}.mha", f) + image_counter += 1 + im_file.save() + + return im + + +def _get_inputs(): + return ComponentInterface.objects.filter( + slug__in=["generic-medical-image"] + ) + + +def _get_outputs(): + return ComponentInterface.objects.filter( + slug__in=["generic-medical-image", "results-json-file"] + ) + + +def _create_phase_archive(*, creator, interfaces, suffix, items=5): + a = Archive.objects.create( + title=f"Algorithm Evaluation {suffix} Test Set", + logo=create_uploaded_image(), + workstation=Workstation.objects.get( + slug=settings.DEFAULT_WORKSTATION_SLUG + ), + ) + a.add_editor(creator) + + for n in range(items): + ai = ArchiveItem.objects.create(archive=a) + for interface in interfaces: + v = ComponentInterfaceValue.objects.create(interface=interface) + + im = Image.objects.create( + name=f"Test Image {n}", width=10, height=10 + ) + im_file = ImageFile.objects.create(image=im) + + with _uploaded_image_file() as f: + im_file.file.save(f"test_image_{n}.mha", f) + im_file.save() + + v.image = im + v.save() + + ai.values.add(v) + + return a + + +def _create_challenge( + *, creator, participant, archive, suffix, inputs, outputs +): + c = Challenge.objects.create( + short_name=f"algorithm-evaluation-{suffix}", + creator=creator, + hidden=False, + logo=create_uploaded_image(), + ) + c.add_participant(participant) + + Invoice.objects.create( + challenge=c, + support_costs_euros=0, + compute_costs_euros=10, + storage_costs_euros=0, + payment_status=Invoice.PaymentStatusChoices.PAID, + ) + + p = Phase.objects.create( + challenge=c, title="Phase 1", algorithm_time_limit=300 + ) + + p.algorithm_inputs.set(inputs) + p.algorithm_outputs.set(outputs) + + p.title = "Algorithm Evaluation" + p.submission_kind = SubmissionKindChoices.ALGORITHM + p.archive = archive + p.score_jsonpath = "score" + p.submissions_limit_per_user_per_period = 10 + p.save() + + m = Method(creator=creator, phase=p) + + with _gc_demo_algorithm() as container: + m.image.save("algorithm_io.tar", container) + + +def _create_algorithm(*, creator, inputs, outputs, suffix): + algorithm = Algorithm.objects.create( + title=f"Test Algorithm Evaluation {suffix}", + logo=create_uploaded_image(), + ) + algorithm.inputs.set(inputs) + algorithm.outputs.set(outputs) + algorithm.add_editor(creator) + + algorithm_image = AlgorithmImage(creator=creator, algorithm=algorithm) + + with _gc_demo_algorithm() as container: + algorithm_image.image.save("algorithm_io.tar", container) + + +@contextmanager +def _gc_demo_algorithm(): + with TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + + repo_tag = "fixtures-algorithm-io:latest" + + docker_client.build_image( + path=str(Path(__file__).parent.absolute()), repo_tag=repo_tag + ) + + outfile = tmp_path / f"{repo_tag}.tar" + output_gz = f"{outfile}.gz" + + docker_client.save_image(repo_tag=repo_tag, output=outfile) + + with open(outfile, "rb") as f_in: + with gzip.open(output_gz, "wb") as f_out: + shutil.copyfileobj(f_in, f_out) + + yield from _uploaded_file(path=output_gz) + + +@contextmanager +def _uploaded_image_file(): + path = Path(__file__).parent / "image10x10x10.mha" + yield from _uploaded_file(path=path) + + +def _uploaded_file(*, path): + with open(os.path.join(settings.SITE_ROOT, path), "rb") as f: + with ContentFile(f.read()) as content: + yield content diff --git a/tests/scripts/image10x10x10.mha b/tests/scripts/image10x10x10.mha new file mode 100644 index 00000000..8a265e1c Binary files /dev/null and b/tests/scripts/image10x10x10.mha differ diff --git a/tests/testdata/algorithm_io.tar.gz b/tests/testdata/algorithm_io.tar.gz deleted file mode 100644 index 8ea9ea35..00000000 Binary files a/tests/testdata/algorithm_io.tar.gz and /dev/null differ