diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44a52b5..764a782 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 efe1bed..da5a205 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,17 +8,17 @@ 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 @@ -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 941868e..ba606ef 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -2,8 +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 7c39862..3a41982 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 051269f..20af5d5 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 973c901..14500af 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 @@ -206,40 +198,6 @@ class ComponentInterfacesAPI(APIBase[gcapi.models.ComponentInterface]): model = gcapi.models.ComponentInterface -class RetinaLandmarkAnnotationSetsAPI( - ModifiableMixin, APIBase[gcapi.models.LandmarkAnnotationSet] -): - base_path = "retina/landmark-annotation/" - model = gcapi.models.LandmarkAnnotationSet - - def for_image(self, pk): - result = yield self.yield_request( - method="GET", path=self.base_path, params={"image_id": pk} - ) - return result - - -class RetinaPolygonAnnotationSetsAPI( - ModifiableMixin, APIBase[gcapi.models.NestedPolygonAnnotationSet] -): - base_path = "retina/polygon-annotation-set/" - model = gcapi.models.NestedPolygonAnnotationSet - - -class RetinaSinglePolygonAnnotationsAPI( - ModifiableMixin, APIBase[gcapi.models.SinglePolygonAnnotation] -): - base_path = "retina/single-polygon-annotation/" - model = gcapi.models.SinglePolygonAnnotation - - -class RetinaETDRSGridAnnotationsAPI( - ModifiableMixin, APIBase[gcapi.models.ETDRSGridAnnotation] -): - base_path = "retina/etdrs-grid-annotation/" - model = gcapi.models.ETDRSGridAnnotation - - class UploadsAPI(APIBase[gcapi.models.UserUpload]): base_path = "uploads/" model = gcapi.models.UserUpload @@ -385,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"]) @@ -410,10 +368,6 @@ class ApiDefinitions: algorithm_jobs: AlgorithmJobsAPI archives: ArchivesAPI workstation_configs: WorkstationConfigsAPI - retina_landmark_annotations: RetinaLandmarkAnnotationSetsAPI - retina_polygon_annotation_sets: RetinaPolygonAnnotationSetsAPI - retina_single_polygon_annotations: RetinaSinglePolygonAnnotationsAPI - retina_etdrs_grid_annotations: RetinaETDRSGridAnnotationsAPI raw_image_upload_sessions: UploadSessionsAPI archive_items: ArchiveItemsAPI interfaces: ComponentInterfacesAPI @@ -562,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, @@ -660,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 @@ -740,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 @@ -764,17 +718,6 @@ def update_archive_item( If you provide a value or file for an existing interface of the archive item, the old value will be overwritten by the new one, hence allowing you to update existing archive item values. - For images that are already associated with an archive item, you can - also change the interface type (e.g. from generic medical image to - generic overlay) by providing the link to the existing image together - with the new interface slug you would like to use: - client.update_archive_item( - archive_item_pk=items[0]['id'], - values={ - "generic-overlay": - "https://grand-challenge.org/api/v1/cases/images/.../", - } - ) Parameters ---------- @@ -788,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: @@ -880,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 @@ -911,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 16afb95..4c51b1a 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 f9bdff3..83cb2eb 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,15 +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 - algorithm_title: str started_at: Optional[str] completed_at: Optional[str] hanging_protocol: Optional[HangingProtocol] - view_content: Dict[str, Any] + view_content: dict[str, Any] @dataclass @@ -1755,7 +1754,7 @@ class LandmarkAnnotationSet(BaseModel): id: str grader: Optional[int] created: Optional[str] - singlelandmarkannotation_set: List[ + singlelandmarkannotation_set: list[ SingleLandmarkAnnotationSerializerNoParent ] @@ -1764,7 +1763,7 @@ class LandmarkAnnotationSet(BaseModel): class LandmarkAnnotationSetRequest(BaseModel): grader: Optional[int] created: Optional[str] - singlelandmarkannotation_set: List[ + singlelandmarkannotation_set: list[ SingleLandmarkAnnotationSerializerNoParentRequest ] @@ -1776,7 +1775,7 @@ class NestedPolygonAnnotationSet(BaseModel): grader: Optional[int] created: Optional[str] name: str - singlepolygonannotation_set: List[ + singlepolygonannotation_set: list[ SinglePolygonAnnotationSerializerNoParent ] @@ -1787,7 +1786,7 @@ class NestedPolygonAnnotationSetRequest(BaseModel): grader: Optional[int] created: Optional[str] name: str - singlepolygonannotation_set: List[ + singlepolygonannotation_set: list[ SinglePolygonAnnotationSerializerNoParentRequest ] @@ -1797,7 +1796,7 @@ class PaginatedComponentInterfaceList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[ComponentInterface]] + results: Optional[list[ComponentInterface]] @dataclass @@ -1805,7 +1804,7 @@ class PaginatedHyperlinkedJobList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[HyperlinkedJob]] + results: Optional[list[HyperlinkedJob]] @dataclass @@ -1813,7 +1812,7 @@ class PaginatedRawImageUploadSessionList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[RawImageUploadSession]] + results: Optional[list[RawImageUploadSession]] @dataclass @@ -1821,7 +1820,7 @@ class PaginatedSessionList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[Session]] + results: Optional[list[Session]] @dataclass @@ -1829,7 +1828,7 @@ class PaginatedUserUploadList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[UserUpload]] + results: Optional[list[UserUpload]] @dataclass @@ -1837,7 +1836,7 @@ class PaginatedWorkstationConfigList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[WorkstationConfig]] + results: Optional[list[WorkstationConfig]] @dataclass @@ -1845,7 +1844,7 @@ class PaginatedWorkstationList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[Workstation]] + results: Optional[list[Workstation]] @dataclass @@ -1870,7 +1869,7 @@ class PatchedLandmarkAnnotationSetRequest(BaseModel): grader: Optional[int] created: Optional[str] singlelandmarkannotation_set: Optional[ - List[SingleLandmarkAnnotationSerializerNoParentRequest] + list[SingleLandmarkAnnotationSerializerNoParentRequest] ] @@ -1881,7 +1880,7 @@ class PatchedNestedPolygonAnnotationSetRequest(BaseModel): created: Optional[str] name: Optional[str] singlepolygonannotation_set: Optional[ - List[SinglePolygonAnnotationSerializerNoParentRequest] + list[SinglePolygonAnnotationSerializerNoParentRequest] ] @@ -1896,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] @@ -1915,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 @@ -1929,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] @@ -1940,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] @@ -1958,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 @@ -1989,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] @@ -2022,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 @@ -2034,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] @@ -2045,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] @@ -2070,7 +2069,7 @@ class PaginatedAlgorithmList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[Algorithm]] + results: Optional[list[Algorithm]] @dataclass @@ -2078,7 +2077,7 @@ class PaginatedArchiveItemList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[ArchiveItem]] + results: Optional[list[ArchiveItem]] @dataclass @@ -2086,7 +2085,7 @@ class PaginatedDisplaySetList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[DisplaySet]] + results: Optional[list[DisplaySet]] @dataclass @@ -2094,7 +2093,7 @@ class PaginatedEvaluationList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[Evaluation]] + results: Optional[list[Evaluation]] @dataclass @@ -2102,7 +2101,7 @@ class PaginatedHyperlinkedImageList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[HyperlinkedImage]] + results: Optional[list[HyperlinkedImage]] @dataclass @@ -2110,7 +2109,7 @@ class PaginatedQuestionList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[Question]] + results: Optional[list[Question]] @dataclass @@ -2118,7 +2117,7 @@ class PaginatedReaderStudyList(BaseModel): count: Optional[int] next: Optional[str] previous: Optional[str] - results: Optional[List[ReaderStudy]] + results: Optional[list[ReaderStudy]] @dataclass @@ -2126,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 65bdd67..32c7ffc 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 46fafee..bd3ee25 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 bcf1994..694476e 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 893d98c..1412387 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 8613ac5..314d899 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 751faca..01a015a 100644 --- a/tests/async_integration_tests.py +++ b/tests/async_integration_tests.py @@ -11,7 +11,6 @@ ARCHIVE_TOKEN, DEMO_PARTICIPANT_TOKEN, READERSTUDY_TOKEN, - RETINA_TOKEN, async_recurse_call, ) @@ -38,106 +37,10 @@ async def get_archive_items(client, archive_pk, min_size): return il -@pytest.mark.parametrize( - "annotation", - [ - "retina_landmark_annotations", - "retina_polygon_annotation_sets", - "retina_single_polygon_annotations", - ], -) -@pytest.mark.anyio -async def test_list_annotations(local_grand_challenge, annotation): - async with AsyncClient( - base_url=local_grand_challenge, verify=False, token=RETINA_TOKEN - ) as c: - response = await getattr(c, annotation).list() - assert len(response) == 0 - - -@pytest.mark.anyio -async def test_create_landmark_annotation(local_grand_challenge): - async with AsyncClient( - base_url=local_grand_challenge, verify=False, token=RETINA_TOKEN - ) as c: - nil_uuid = "00000000-0000-4000-9000-000000000000" - create_data = { - "grader": 0, - "singlelandmarkannotation_set": [ - {"image": nil_uuid, "landmarks": [[0, 0], [1, 1], [2, 2]]}, - {"image": nil_uuid, "landmarks": [[0, 0], [1, 1], [2, 2]]}, - ], - } - with pytest.raises(HTTPStatusError) as e: - await c.retina_landmark_annotations.create(**create_data) - response = e.value.response - assert response.status_code == 400 - response = response.json() - assert ( - response["grader"][0] == 'Invalid pk "0" - object does not exist.' - ) - for sla_error in response["singlelandmarkannotation_set"]: - assert ( - sla_error["image"][0] - == f'Invalid pk "{nil_uuid}" - object does not exist.' # noqa: B907 - ) - - -@pytest.mark.anyio -async def test_create_polygon_annotation_set(local_grand_challenge): - async with AsyncClient( - base_url=local_grand_challenge, verify=False, token=RETINA_TOKEN - ) as c: - nil_uuid = "00000000-0000-4000-9000-000000000000" - create_data = { - "grader": 0, - "image": nil_uuid, - "singlepolygonannotation_set": [ - {"z": 0, "value": [[0, 0], [1, 1], [2, 2]]}, - {"z": 1, "value": [[0, 0], [1, 1], [2, 2]]}, - ], - } - with pytest.raises(HTTPStatusError) as e: - await c.retina_polygon_annotation_sets.create(**create_data) - response = e.value.response - assert response.status_code == 400 - response = response.json() - assert ( - response["grader"][0] == 'Invalid pk "0" - object does not exist.' - ) - assert ( - response["image"][0] - == f'Invalid pk "{nil_uuid}" - object does not exist.' # noqa: B907 - ) - assert response["name"][0] == "This field is required." - - -@pytest.mark.anyio -async def test_create_single_polygon_annotations(local_grand_challenge): - async with AsyncClient( - base_url=local_grand_challenge, verify=False, token=RETINA_TOKEN - ) as c: - create_data = { - "z": 0, - "value": [[0, 0], [1, 1], [2, 2]], - "annotation_set": 0, - } - - with pytest.raises(HTTPStatusError) as e: - await c.retina_single_polygon_annotations.create(**create_data) - response = e.value.response - assert response.status_code == 400 - response = response.json() - assert ( - response["annotation_set"][0] - == 'Invalid pk "0" - object does not exist.' - ) - - @pytest.mark.anyio async def test_raw_image_and_upload_session(local_grand_challenge): async with AsyncClient( - base_url=local_grand_challenge, verify=False, token=RETINA_TOKEN + base_url=local_grand_challenge, verify=False, token=ADMIN_TOKEN ) as c: assert len(await c.raw_image_upload_sessions.page()) == 0 @@ -145,7 +48,7 @@ async def test_raw_image_and_upload_session(local_grand_challenge): @pytest.mark.anyio async def test_local_response(local_grand_challenge): async with AsyncClient( - base_url=local_grand_challenge, verify=False, token=RETINA_TOKEN + base_url=local_grand_challenge, verify=False, token=ADMIN_TOKEN ) as c: # Empty response, but it didn't error out so the server is responding assert len(await c.algorithms.page()) == 0 @@ -170,20 +73,20 @@ async def test_chunked_uploads(local_grand_challenge): "count" ] == 1 + existing_chunks_admin - # retina + # archive async with AsyncClient( - token=RETINA_TOKEN, base_url=local_grand_challenge, verify=False - ) as c_retina: - existing_chunks_retina = (await c_retina(path="uploads/"))["count"] + token=ARCHIVE_TOKEN, base_url=local_grand_challenge, verify=False + ) as c_archive: + existing_chunks_archive = (await c_archive(path="uploads/"))["count"] with open(file_to_upload, "rb") as f: - await c_retina.uploads.upload_fileobj( + await c_archive.uploads.upload_fileobj( fileobj=f, filename=file_to_upload.name ) - assert (await c_retina(path="uploads/"))[ + assert (await c_archive(path="uploads/"))[ "count" - ] == 1 + existing_chunks_retina + ] == 1 + existing_chunks_archive async with AsyncClient(token="whatever") as c: with pytest.raises(HTTPStatusError): @@ -429,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 @@ -459,7 +363,7 @@ async def run_job(): assert job["status"] == "Queued" assert len(job["inputs"]) == 1 job = await c.algorithm_jobs.detail(job["pk"]) - assert job["status"] == "Queued" + assert job["status"] in {"Queued", "Started"} @pytest.mark.parametrize( @@ -702,64 +606,6 @@ async def get_updated_archive_detail(): assert new_json_civ["value"] == {"foo": 0.8} -@pytest.mark.anyio -async def test_update_interface_kind_of_archive_item_image_civ( - local_grand_challenge, -): - async with AsyncClient( - base_url=local_grand_challenge, verify=False, token=ARCHIVE_TOKEN - ) as c: - # check number of archive items - archive = await c.archives.iterate_all( - params={"slug": "archive"} - ).__anext__() - items = c.archive_items.iterate_all(params={"archive": archive["pk"]}) - old_items_list = [item async for item in items] - - # create new archive item - _ = await c.upload_cases( - archive="archive", - files=[Path(__file__).parent / "testdata" / "image10x10x101.mha"], - ) - - # retrieve existing archive item pk - items_list = await get_archive_items( - c, archive["pk"], len(old_items_list) - ) - - old_civ_count = len(items_list[-1]["values"]) - - assert ( - items_list[-1]["values"][0]["interface"]["slug"] - == "generic-medical-image" - ) - im = items_list[-1]["values"][0]["image"] - image = await get_file(c, im) - - # change interface slug from generic-medical-image to generic-overlay - _ = await c.update_archive_item( - archive_item_pk=items_list[-1]["pk"], - values={"generic-overlay": image["api_url"]}, - ) - - @async_recurse_call - async def get_updated_archive_detail(): - item = await c.archive_items.detail(items_list[-1]["pk"]) - if item["values"][-1]["interface"]["slug"] != "generic-overlay": - # interface type has not yet been replaced - raise ValueError - return item - - item_updated = await get_updated_archive_detail() - - # still the same amount of civs - assert len(item_updated["values"]) == old_civ_count - assert "generic-medical-image" not in [ - value["interface"]["slug"] for value in item_updated["values"] - ] - assert item_updated["values"][-1]["image"] == im - - @pytest.mark.anyio async def test_update_archive_item_with_non_existing_interface( local_grand_challenge, @@ -915,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 @@ -982,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 bf3fab0..41c847e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +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 @@ -39,48 +41,78 @@ 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/algorithm_evaluation_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 file in (Path(__file__).parent / "scripts").glob("*"): + if file.is_file(): + shutil.copy( + 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 @@ -89,22 +121,25 @@ 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: r = httpx.get( ( - f"https://raw.githubusercontent.com/comic/grand-challenge.org/" + "https://raw.githubusercontent.com/comic/grand-challenge.org/" f"main/{repo_path}" ), 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 @@ -119,9 +154,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 ( @@ -146,13 +186,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") - return makefile.encode("utf-8") diff --git a/tests/integration_tests.py b/tests/integration_tests.py index 0653761..bc824b7 100644 --- a/tests/integration_tests.py +++ b/tests/integration_tests.py @@ -11,7 +11,6 @@ ARCHIVE_TOKEN, DEMO_PARTICIPANT_TOKEN, READERSTUDY_TOKEN, - RETINA_TOKEN, recurse_call, ) @@ -39,94 +38,6 @@ def get_archive_items(client, archive_pk, min_size): return items -@pytest.mark.parametrize( - "annotation", - [ - "retina_landmark_annotations", - "retina_polygon_annotation_sets", - "retina_single_polygon_annotations", - ], -) -def test_list_annotations(local_grand_challenge, annotation): - c = Client( - base_url=local_grand_challenge, verify=False, token=RETINA_TOKEN - ) - response = getattr(c, annotation).list() - assert len(response) == 0 - - -def test_create_landmark_annotation(local_grand_challenge): - c = Client( - base_url=local_grand_challenge, verify=False, token=RETINA_TOKEN - ) - nil_uuid = "00000000-0000-4000-9000-000000000000" - create_data = { - "grader": 0, - "singlelandmarkannotation_set": [ - {"image": nil_uuid, "landmarks": [[0, 0], [1, 1], [2, 2]]}, - {"image": nil_uuid, "landmarks": [[0, 0], [1, 1], [2, 2]]}, - ], - } - with pytest.raises(HTTPStatusError) as e: - c.retina_landmark_annotations.create(**create_data) - response = e.value.response - assert response.status_code == 400 - response = response.json() - assert response["grader"][0] == 'Invalid pk "0" - object does not exist.' - for sla_error in response["singlelandmarkannotation_set"]: - assert ( - sla_error["image"][0] - == f'Invalid pk "{nil_uuid}" - object does not exist.' # noqa: B907 - ) - - -def test_create_polygon_annotation_set(local_grand_challenge): - c = Client( - base_url=local_grand_challenge, verify=False, token=RETINA_TOKEN - ) - nil_uuid = "00000000-0000-4000-9000-000000000000" - create_data = { - "grader": 0, - "image": nil_uuid, - "singlepolygonannotation_set": [ - {"z": 0, "value": [[0, 0], [1, 1], [2, 2]]}, - {"z": 1, "value": [[0, 0], [1, 1], [2, 2]]}, - ], - } - with pytest.raises(HTTPStatusError) as e: - c.retina_polygon_annotation_sets.create(**create_data) - response = e.value.response - assert response.status_code == 400 - response = response.json() - assert response["grader"][0] == 'Invalid pk "0" - object does not exist.' - assert ( - response["image"][0] - == f'Invalid pk "{nil_uuid}" - object does not exist.' # noqa: B907 - ) - assert response["name"][0] == "This field is required." - - -def test_create_single_polygon_annotations(local_grand_challenge): - c = Client( - base_url=local_grand_challenge, verify=False, token=RETINA_TOKEN - ) - create_data = { - "z": 0, - "value": [[0, 0], [1, 1], [2, 2]], - "annotation_set": 0, - } - - with pytest.raises(HTTPStatusError) as e: - c.retina_single_polygon_annotations.create(**create_data) - response = e.value.response - assert response.status_code == 400 - response = response.json() - assert ( - response["annotation_set"][0] - == 'Invalid pk "0" - object does not exist.' - ) - - @pytest.mark.parametrize( "files", ( @@ -173,18 +84,18 @@ def test_chunked_uploads(local_grand_challenge): assert c_admin(path="uploads/")["count"] == 1 + existing_chunks_admin - # retina - c_retina = Client( - token=RETINA_TOKEN, base_url=local_grand_challenge, verify=False + # archive + c_archive = Client( + token=ARCHIVE_TOKEN, base_url=local_grand_challenge, verify=False ) - existing_chunks_retina = c_retina(path="uploads/")["count"] + existing_chunks_archive = c_archive(path="uploads/")["count"] with open(file_to_upload, "rb") as f: - c_retina.uploads.upload_fileobj( + c_archive.uploads.upload_fileobj( fileobj=f, filename=file_to_upload.name ) - assert c_retina(path="uploads/")["count"] == 1 + existing_chunks_retina + assert c_archive(path="uploads/")["count"] == 1 + existing_chunks_archive c = Client(token="whatever") with pytest.raises(HTTPStatusError): @@ -384,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( @@ -417,7 +329,7 @@ def run_job(): assert job["status"] == "Queued" assert len(job["inputs"]) == 1 job = c.algorithm_jobs.detail(job["pk"]) - assert job["status"] == "Queued" + assert job["status"] in {"Queued", "Started"} def test_get_algorithm_by_slug(local_grand_challenge): @@ -620,58 +532,6 @@ def get_updated_archive_item_detail(): assert new_json_civ["value"] == {"foo": 0.8} -def test_update_interface_kind_of_archive_item_image_civ( - local_grand_challenge, -): - c = Client( - base_url=local_grand_challenge, verify=False, token=ARCHIVE_TOKEN - ) - # check number of archive items - archive = next(c.archives.iterate_all(params={"slug": "archive"})) - old_items_list = list( - c.archive_items.iterate_all(params={"archive": archive["pk"]}) - ) - - # create new archive item - _ = c.upload_cases( - archive="archive", - files=[Path(__file__).parent / "testdata" / "image10x10x101.mha"], - ) - - # retrieve existing archive item pk - items = get_archive_items(c, archive["pk"], len(old_items_list)) - old_civ_count = len(items[-1]["values"]) - - assert ( - items[-1]["values"][0]["interface"]["slug"] == "generic-medical-image" - ) - im = items[-1]["values"][0]["image"] - image = get_file(c, im) - - # change interface slug from generic-medical-image to generic-overlay - _ = c.update_archive_item( - archive_item_pk=items[-1]["pk"], - values={"generic-overlay": image["api_url"]}, - ) - - @recurse_call - def get_updated_archive_items(): - i = c.archive_items.detail(items[-1]["pk"]) - if i["values"][-1]["interface"]["slug"] != "generic-overlay": - # item has not been added yet - raise ValueError - return i - - item_updated = get_updated_archive_items() - - # still the same amount of civs - assert len(item_updated["values"]) == old_civ_count - assert "generic-medical-image" not in [ - value["interface"]["slug"] for value in item_updated["values"] - ] - assert item_updated["values"][-1]["image"] == im - - def test_update_archive_item_with_non_existing_interface( local_grand_challenge, ): @@ -824,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) @@ -887,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 0000000..a09b18d --- /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/constants.py b/tests/scripts/constants.py new file mode 100644 index 0000000..1bfe165 --- /dev/null +++ b/tests/scripts/constants.py @@ -0,0 +1,6 @@ +USER_TOKENS = { + "admin": "1b9436200001f2eaf57cd77db075cbb60a49a00a", + "readerstudy": "01614a77b1c0b4ecd402be50a8ff96188d5b011d", + "demop": "00aa710f4dc5621a0cb64b0795fbba02e39d7700", + "archive": "0d284528953157759d26c469297afcf6fd367f71", +} diff --git a/tests/scripts/copy_io.py b/tests/scripts/copy_io.py new file mode 100644 index 0000000..7f57062 --- /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 0000000..f61f9f9 --- /dev/null +++ b/tests/scripts/create_test_fixtures.py @@ -0,0 +1,462 @@ +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 + +from .constants import USER_TOKENS + +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): + 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 0000000..8a265e1 Binary files /dev/null and b/tests/scripts/image10x10x10.mha differ diff --git a/tests/utils.py b/tests/utils.py index ab06c2f..cfc8ad9 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -3,11 +3,12 @@ from httpx import AsyncHTTPTransport, HTTPStatusError, HTTPTransport -RETINA_TOKEN = "f1f98a1733c05b12118785ffd995c250fe4d90da" -ADMIN_TOKEN = "1b9436200001f2eaf57cd77db075cbb60a49a00a" -READERSTUDY_TOKEN = "01614a77b1c0b4ecd402be50a8ff96188d5b011d" -DEMO_PARTICIPANT_TOKEN = "00aa710f4dc5621a0cb64b0795fbba02e39d7700" -ARCHIVE_TOKEN = "0d284528953157759d26c469297afcf6fd367f71" +from tests.scripts.constants import USER_TOKENS + +ADMIN_TOKEN = USER_TOKENS["admin"] +READERSTUDY_TOKEN = USER_TOKENS["readerstudy"] +DEMO_PARTICIPANT_TOKEN = USER_TOKENS["demop"] +ARCHIVE_TOKEN = USER_TOKENS["archive"] def recurse_call(func):