diff --git a/ids7client/python/.bandit.yml b/ids7client/python/.bandit.yml new file mode 100644 index 0000000..44ff451 --- /dev/null +++ b/ids7client/python/.bandit.yml @@ -0,0 +1,3 @@ +skips: + - B311 + - B113 diff --git a/ids7client/python/.gitignore b/ids7client/python/.gitignore new file mode 100644 index 0000000..4b32776 --- /dev/null +++ b/ids7client/python/.gitignore @@ -0,0 +1,52 @@ +# These are some examples of commonly ignored file patterns. +# You should customize this list as applicable to your project. +# Learn more about .gitignore: +# https://www.atlassian.com/git/tutorials/saving-changes/gitignore + +# Node artifact files +node_modules/ +dist/ + +# Compiled Java class files +*.class + +# Compiled Python bytecode +*.py[cod] + +# Log files +*.log + +# Package files +*.jar + +# Maven +target/ +dist/ + +# JetBrains IDE +.idea/ + +# Unit test reports +TEST*.xml + +# Generated by MacOS +.DS_Store + +# Generated by Windows +Thumbs.db + +# Applications +*.app +*.exe +*.war + +# Large media files +*.mp4 +*.tiff +*.avi +*.flv +*.mov +*.wmv + +**/*.egg-info +**/*build diff --git a/ids7client/python/Makefile b/ids7client/python/Makefile new file mode 100644 index 0000000..9acd0cb --- /dev/null +++ b/ids7client/python/Makefile @@ -0,0 +1,14 @@ +SHELL=bash + +.PHONY: format +format: + isort .; + black . + +.PHONY: lint +lint: + flake8 ids7client/ + bandit -c .bandit.yml -r ids7client/ + isort --check ids7client/ + black ids7client/ --check --diff + mypy ids7client/ diff --git a/ids7client/python/README.md b/ids7client/python/README.md new file mode 100644 index 0000000..24696c7 --- /dev/null +++ b/ids7client/python/README.md @@ -0,0 +1,174 @@ +# Python Client for Sectra IDS7 PACS + +## Introduction + +This python package aims to facilite the development of AI applications integrated in Sectra IDS7 PACS. + +Please note that for now, not every feature is implemented but the package can easily be enriched: + +* some endpoints are missing (e.g., slide downloading) +* QIDO client is pretty basic + +### What it implements + +* A client for the IDS7 AI API (`IDS7AIClient`) +* A client for the IDS7 QIDO API (`IDS7QidoClient`) +* A set of pydantic models to encapsulate and validate data sent and received from IDS7 + + +### What it does not implement, but might, at some point + +* A server to receive analysis requests from IDS7 + +## Installation + +To install ids7client (until it is published in pypi): + +```pip install .``` + +To install ids7client with development dependencies (linting and formating, see below): + +```pip install ./[dev]``` + +## Usage + +Before using the client, make sure you have access to a valid authentication token, sent in the analysis requests. + +### Example 1: Retrieve image information + +```python +from ids7client.ai import IDS7AIClient + +# Callback info, sent by IDS7 in the request +callback_url = "https://sectrapacs.com" +callback_token = "abc" + +# Slide id, sent by IDS7 in the request +slide_id = "blabla" + +client = IDS7AIClient( + url=callback_url, + token=callback_token +) + +# Returns the image info with extended and personal health information data +image_info = client.get_image_info(slide_id, extended=True, phi=True) +``` + +### Example 2: Returning basic results + +The following code creates a result payload that can be sent as response to display a frame on the whole slide and a label telling the user that the analysis is pending. + +```python +from ids7client.ai import ( + ResultContent, + ResultType, + PrimitiveItem, + Style, + Polygon, + Point, + Label, + Result +) + +# Slide id, sent by IDS7 in the request +slide_id = "blabla" + +data = ResultContent( + type=ResultType.PRIMITIVES, + content=[ + PrimitiveItem( + style=Style( + strokeStyle="#000000", + ), + polygons=[ + Polygon( + points=[ + Point(x=0.0, y=0.0), + Point(x=1.0, y=0.0), + Point(x=1.0, y=1.0), + Point(x=0.0, y=1.0), + ] + ) + ], + labels=[ + Label( + location=Point(x=0.00, y=0.0), + label="Analysis running", + ) + ], + ) + ], +) + +result = Result( + slideId=slide_id, + displayResult="Analysis running", + applicationVersion="1.0.0", + data=data +) +``` + +### Example 3: Retrieving patient, request and exam id + +```python +from IDS7Client.ai import IDS7AIClient +from IDS7Client.qido import IDS7QidoClient, DicomCodes + +# Callback info, sent by IDS7 in the request +callback_url = "https://sectrapacs.com" +callback_token = "abc" + +# Slide id, sent by IDS7 in the request +slide_id = "blabla" + +ai_client = IDS7AIClient( + url=callback_url, + token=callback_token +) + +# Returns the image info with extended and personal health information data +image_info = client.get_image_info(slide_id, extended=True, phi=True) + +# Instanciates QIDO client with provided url, username and password +qido_client = IDS7QidoClient(qido_url, qido_username, qido_password) + +# Retrieve study from QIDO API +study = qido_client.find_one_study(studyInstanceUid=data.study_id) + +# Retrieve patient, request and exam id from the response +patient_id = study.get_value_as_string(DicomCodes.PATIENT_ID) +request_id = study.get_value_as_string(DicomCodes.REQUEST_ID) +exam_id = study.get_value_as_string(DicomCodes.EXAM_ID) +``` + +### Error handling and retries + +Any request to the IDS7 server is retried 5 times with exponential delays if there is a connection error. Any other error is not handled by the clients. + +Clients raise `IDS7RequestError` if the IDS7 server returns an error status code (e.g., 400, 404, 500, etc.). The error includes the returned status code, text and the requested path. + +## Code quality + +Code quality is ensured with the following tools: + +* flake8 for formating +* mypy for static typing analysis +* bandit for security +* black for formating +* isort for imports order + +One can format the code using the following command: + +```make format``` + +One can lint the code using the following command: + +```make lint``` + + +## What's next? + +* HL7 API client +* More detailed data validation (e.g., min and max lengths of arrays) +* Missing IDS7 endpoints \ No newline at end of file diff --git a/ids7client/python/extra_requirements.txt b/ids7client/python/extra_requirements.txt new file mode 100644 index 0000000..d6f16cf --- /dev/null +++ b/ids7client/python/extra_requirements.txt @@ -0,0 +1,7 @@ +bandit +black +flake8 +isort +mypy +types-requests +types-setuptools diff --git a/ids7client/python/ids7client/__init__.py b/ids7client/python/ids7client/__init__.py new file mode 100644 index 0000000..22753e6 --- /dev/null +++ b/ids7client/python/ids7client/__init__.py @@ -0,0 +1 @@ +from .errors import IDS7RequestError diff --git a/ids7client/python/ids7client/ai/__init__.py b/ids7client/python/ids7client/ai/__init__.py new file mode 100644 index 0000000..b40c3c8 --- /dev/null +++ b/ids7client/python/ids7client/ai/__init__.py @@ -0,0 +1,41 @@ +from .client import IDS7AIClient +from .schemas import ( + Action, + ApplicationInfo, + Attachment, + AttachmentState, + CallbackInfo, + Context, + CreateInput, + DisplayedName, + DisplayProperties, + FocalPlane, + ImageInfo, + ImageNotification, + InputTemplate, + InputType, + Invocation, + Label, + MultiAreaContent, + OpticalPath, + Patch, + PatchContent, + Point, + Polygon, + Polyline, + PrimitiveItem, + Registration, + Result, + ResultContent, + ResultData, + ResultResponse, + ResultType, + Size, + SlideFormat, + Specimen, + Status, + Style, + TaggedPolygonContent, + TaggedPolygonInputContent, + TileFormat, +) diff --git a/ids7client/python/ids7client/ai/client.py b/ids7client/python/ids7client/ai/client.py new file mode 100644 index 0000000..b26506c --- /dev/null +++ b/ids7client/python/ids7client/ai/client.py @@ -0,0 +1,163 @@ +import logging +from typing import Dict, Optional, cast + +import requests + +from ids7client.errors import IDS7RequestError +from ids7client.helpers import JSONPayload, connection_retry + +from .schemas import ApplicationInfo, ImageInfo, Result, ResultResponse + +logger = logging.getLogger(__name__) + + +class IDS7AIClient: + """Class managing connection and requests to IDS7 server AI API. + + Args: + url (str): URL of the IDS7 server + token (str): Callback token + app_id (str): Registered application id + + Attributes: + ids7_version (ApplicationInfo): Versions of the IDS7 server + """ + + __slots__ = ("_url", "_token", "ids7_version", "_headers", "_app_id") + + def __init__(self, url: str, token: str, app_id: str) -> None: + self._url = url + self._token = token + self._app_id = app_id + self._headers = {"Authorization": f"Bearer {token}"} + self.ids7_version = self._retrieve_ids7_versions() + + def _retrieve_ids7_versions(self) -> ApplicationInfo: + """Retrieves the versions of IDS7 from the server.""" + + versions = ApplicationInfo(**cast(dict, self._get("/info"))) + self._headers.update( + { + "X-Sectra-ApiVersion": versions.apiVersion, + "X-Sectra-SoftwareVersion": versions.softwareVersion, + } + ) + return versions + + @connection_retry() + def _get(self, path: str, **kwargs) -> JSONPayload: + """Runs a GET request to IDS7. Named args are query parameters.""" + + url = f"{self._url}{path}" + resp = requests.get(url, params=kwargs, headers=self._headers) + if resp.status_code != 200: + raise IDS7RequestError(resp.status_code, resp.text, path) + return resp.json() + + @connection_retry() + def _post( + self, path: str, payload: JSONPayload, parse_response: bool = True + ) -> Optional[JSONPayload]: + """Runs a POST request to IDS7. + + Args: + path (str): Request path + payload (JSONPayload): Body + parse_response (bool): Whether the response should be parsed as JSON or not. + Defaults to True. + + Returns: + Optional[JSONPayload]: Response, if parse_response is True. + """ + url = f"{self._url}{path}" + resp = requests.post(url, json=payload, headers=self._headers) + if resp.status_code != 201: + raise IDS7RequestError(resp.status_code, resp.text, path) + if parse_response: + return resp.json() + return None + + @connection_retry() + def _put( + self, path: str, values: JSONPayload, parse_response: bool = True + ) -> Optional[JSONPayload]: + """Runs a PUT request to IDS7. + + Args: + path (str): Request path + payload (JSONPayload): Body + parse_response (bool): Whether the response should be parsed as JSON or not. + Defaults to True. + + Returns: + Optional[JSONPayload]: Response, if parse_response is True. + """ + url = f"{self._url}{path}" + resp = requests.put(url, json=values, headers=self._headers) + if resp.status_code != 200: + raise IDS7RequestError(resp.status_code, resp.text, path) + if parse_response: + return resp.json() + return None + + def get_image_info( + self, slide_id: str, extended: bool = False, phi: bool = False + ) -> ImageInfo: + """Retrieves a slide info from its id. + + Args: + slide_id (str): Id of the slide to retrieve info from + extended (bool): Whether extended info should be included or not. + Defaults to False. + phi (bool): Whether Protected Health Information should be included or not. + Defaults to False. + + Returns: + ImageInfo: Requested slide info + """ + path = f"/slides/{slide_id}/info" + params: Dict[str, str] = {} + if extended: + params["scope"] = "extended" + if phi: + params["includePHI"] = "true" + return ImageInfo(**cast(dict, self._get(path, **params))) + + def create_results(self, results: Result) -> ResultResponse: + """Creates a result in IDS7. + + Args: + results (Result): Results payload + + Returns: + ResultResponse: Parsed IDS7 response. + """ + path = f"/applications/{self._app_id}/results" + resp = self._post(path, results.model_dump()) + return ResultResponse(**cast(dict, resp)) + + def get_results(self, id: str) -> ResultResponse: + """Retrieves results. + + Args: + id (str): Results id + + Returns: + ResultResponse: Retrieved results. + """ + path = f"/application/{self._app_id}/results/{id}" + return ResultResponse(**self._get(path)) # type: ignore + + def update_results(self, id: str, results: Result) -> ResultResponse: + """Update existing results. + + Args: + id (str): Results id + results (Result): Updated values + + Returns: + ResultResponse: Updated results + """ + path = f"/application/{self._app_id}/results/{id}" + resp = self._put(path, results.model_dump()) + return ResultResponse(**cast(dict, resp)) diff --git a/ids7client/python/ids7client/ai/schemas/__init__.py b/ids7client/python/ids7client/ai/schemas/__init__.py new file mode 100644 index 0000000..8332340 --- /dev/null +++ b/ids7client/python/ids7client/ai/schemas/__init__.py @@ -0,0 +1,37 @@ +from .common import ( + CallbackInfo, + Context, + DisplayedName, + InputType, + Point, + Polygon, + Size, +) +from .image import FocalPlane, ImageInfo, OpticalPath, SlideFormat, Specimen, TileFormat +from .info import ApplicationInfo +from .invocation import ( + Action, + CreateInput, + ImageNotification, + Invocation, + MultiAreaContent, + TaggedPolygonContent, +) +from .registration import InputTemplate, Registration, TaggedPolygonInputContent +from .results import ( + Attachment, + AttachmentState, + DisplayProperties, + Label, + Patch, + PatchContent, + Polyline, + PrimitiveItem, + Result, + ResultContent, + ResultData, + ResultResponse, + ResultType, + Status, + Style, +) diff --git a/ids7client/python/ids7client/ai/schemas/common.py b/ids7client/python/ids7client/ai/schemas/common.py new file mode 100644 index 0000000..ee506bf --- /dev/null +++ b/ids7client/python/ids7client/ai/schemas/common.py @@ -0,0 +1,53 @@ +from enum import Enum, unique +from typing import List, Optional + +from pydantic import BaseModel + + +class CallbackInfo(BaseModel): + """Model for callbacks info.""" + + url: str + token: str + + +class Context(BaseModel): + """Model for analysis context.""" + + useGPU: Optional[str] = None + seedValue: int = 666 + + +@unique +class InputType(str, Enum): + """Enum for input template types.""" + + MULTI_AREA = "multiArea" + TAGGED_POLYGON = "taggedPolygon" + WHOLE_SLIDE = "wholeSlide" + + +class Point(BaseModel): + """Model for points.""" + + x: float + y: float + + +class Polygon(BaseModel): + """Model for polygons.""" + + points: List[Point] + + +class Size(BaseModel): + """Model for size.""" + + width: int + height: int + + +class DisplayedName(BaseModel): + """Model for displayed names.""" + + displayName: str diff --git a/ids7client/python/ids7client/ai/schemas/image.py b/ids7client/python/ids7client/ai/schemas/image.py new file mode 100644 index 0000000..8edcf9e --- /dev/null +++ b/ids7client/python/ids7client/ai/schemas/image.py @@ -0,0 +1,70 @@ +from typing import Dict, List, Optional + +from pydantic import BaseModel + +from .common import DisplayedName, Size + + +class FocalPlane(BaseModel): + """Model for focal planes.""" + + id: str + offsetUm: float + + +class OpticalPath(BaseModel): + """Model for optical paths.""" + + id: str + description: str + + +class TileFormat(BaseModel): + """Model for tiles format.""" + + mimeType: str + extension: str + + +class SlideFormat(BaseModel): + """Model for slide format.""" + + mimeType: str + + +class Specimen(BaseModel): + """Model for specimen fields.""" + + anatomy: Optional[str] = None + description: Optional[str] = None + + +class ImageInfo(BaseModel): + """Model for images information.""" + + id: str + isStreamable: bool + imageSize: Size + tileSize: Size + micronsPerPixel: float + focalPlanes: List[FocalPlane] + opticalPaths: List[OpticalPath] + storedTileFormat: TileFormat + availableTileFormats: List[TileFormat] + fileFormat: SlideFormat + staining: DisplayedName + block: DisplayedName + specimen: Optional[Specimen] = None + bodyPart: Optional[str] = None + examCode: Optional[str] = None + examDescription: Optional[str] = None + stationName: str + priority: Optional[int] = None + seriesInstanceUid: Optional[str] = None + lisSlideId: Optional[str] = None + accessionNumberIssuer: Optional[str] = None + accessionNumber: Optional[str] = None + studyInstanceUid: Optional[str] = None + examId: Optional[str] = None + examDateTime: Optional[str] = None + examFreeFields: Optional[List[Dict[str, str]]] = None diff --git a/ids7client/python/ids7client/ai/schemas/info.py b/ids7client/python/ids7client/ai/schemas/info.py new file mode 100644 index 0000000..d8b05fa --- /dev/null +++ b/ids7client/python/ids7client/ai/schemas/info.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel + + +class ApplicationInfo(BaseModel): + """Model for applications info.""" + + apiVersion: str + softwareVersion: str diff --git a/ids7client/python/ids7client/ai/schemas/invocation.py b/ids7client/python/ids7client/ai/schemas/invocation.py new file mode 100644 index 0000000..1c4ddf4 --- /dev/null +++ b/ids7client/python/ids7client/ai/schemas/invocation.py @@ -0,0 +1,79 @@ +from enum import Enum, unique +from typing import List, Optional, Union + +from pydantic import BaseModel, model_validator + +from .common import CallbackInfo, Context, InputType, Polygon +from .image import ImageInfo +from .results import ResultResponse + + +@unique +class Action(str, Enum): + """Enum of invocation actions""" + + CREATE = "create" + MODIFY = "modify" + CANCEL = "cancel" + + +class TaggedPolygonContent(BaseModel): + """Model for tagged polygon input content.""" + + polygon: Polygon + tags: Optional[List[str]] = None + tagIndex: Optional[int] = None + + @model_validator(mode="after") + def validate_tags(self) -> "TaggedPolygonContent": + if self.tags is not None and self.tagIndex is None: + raise ValueError("tagIndex must be defined when tags is not None") + return self + + +class MultiAreaContent(BaseModel): + """Model for multi area input content.""" + + polygons: List[Polygon] + + +class CreateInput(BaseModel): + """Model for create invocation input.""" + + type: InputType + content: Optional[Union[TaggedPolygonContent, MultiAreaContent]] = None + + @model_validator(mode="after") + def validate_content(self) -> "CreateInput": + if self.type != InputType.WHOLE_SLIDE and self.content is None: + raise ValueError("content must be defined when type is not wholeSlide") + return self + + +class InvocationBase(BaseModel): + """Base model for both hook launch and image notification.""" + + applicationId: str + slideId: str + callbackInfo: CallbackInfo + context: Optional[Context] = None + cancellationToken: Optional[str] = None + + +class Invocation(InvocationBase): + """Model for invocations from IDS7.""" + + action: Action + input: Optional[Union[CreateInput, ResultResponse]] = None + + @model_validator(mode="after") + def validate_input(self) -> "Invocation": + if self.action != Action.CANCEL and self.input is None: + raise ValueError("input must not be None when action is not cancel") + return self + + +class ImageNotification(InvocationBase): + """Model for new image notification from IDS7.""" + + imageInfo: ImageInfo diff --git a/ids7client/python/ids7client/ai/schemas/registration.py b/ids7client/python/ids7client/ai/schemas/registration.py new file mode 100644 index 0000000..57ed26f --- /dev/null +++ b/ids7client/python/ids7client/ai/schemas/registration.py @@ -0,0 +1,29 @@ +from typing import List, Optional + +from pydantic import BaseModel + +from .common import Context, InputType + + +class TaggedPolygonInputContent(BaseModel): + """Model for tagged polygon input template content.""" + + tags: List[str] + + +class InputTemplate(BaseModel): + """Model for input templates.""" + + type: InputType + content: Optional[TaggedPolygonInputContent] = None + + +class Registration(BaseModel): + """Model for Sectra registration.""" + + applicationId: str + displayName: str + manufacturer: str + url: str + inputTemplate: InputTemplate + context: Context diff --git a/ids7client/python/ids7client/ai/schemas/results.py b/ids7client/python/ids7client/ai/schemas/results.py new file mode 100644 index 0000000..7a6d6ba --- /dev/null +++ b/ids7client/python/ids7client/ai/schemas/results.py @@ -0,0 +1,122 @@ +from enum import Enum, unique +from typing import Any, Dict, List, Optional, Union + +from pydantic import BaseModel + +from .common import Point, Polygon + + +class Style(BaseModel): + """Model for style.""" + + strokeStyle: Optional[str] = None + fillStyle: Optional[str] = None + size: Optional[int] = None + + +class Polyline(BaseModel): + """Model for polylines.""" + + points: List[Point] + + +class Label(BaseModel): + """Model for label.""" + + location: Point + label: str + + +class PrimitiveItem(BaseModel): + """Model for polygon primitive content item.""" + + style: Optional[Style] = None + polygons: Optional[List[Polygon]] = None + polylines: Optional[List[Polyline]] = None + labels: Optional[List[Label]] = None + + +class Patch(BaseModel): + """Model for patches.""" + + tag: int + position: Point + sortKeyValue: float + + +class Status(BaseModel): + """Model for statuses.""" + + value: Optional[bool] = True + message: Optional[str] = None + + +class PatchContent(BaseModel): + """Model for sectra patch content.""" + + description: str + polygons: List[Polygon] + patches: List[Patch] + tags: List[str] + patchSize: int + magnification: float + statuses: Dict[str, Status] = {"allowVerify": Status()} + + +@unique +class ResultType(str, Enum): + """Enum for result types.""" + + PATCHES = "patchCollection" + PRIMITIVES = "primitive" + + +class ResultContent(BaseModel): + """Schema for results content.""" + + type: ResultType + content: Union[PatchContent, List[PrimitiveItem]] + + +DisplayProperties = Dict[str, Union[str, int, float]] + + +class ResultData(BaseModel): + """Schema for data field in results.""" + + context: Dict[str, Any] = {} + result: ResultContent + + +@unique +class AttachmentState(str, Enum): + """Enum for attachment states.""" + + NEW = "new" + UPLOAD_IN_PROGRESS = "upload-in-progress" + STORED = "stored" + + +class Attachment(BaseModel): + """Model for result attachments.""" + + name: str + state: AttachmentState + + +class Result(BaseModel): + """Schema for result posting in IDS7 server.""" + + slideId: str + displayResult: str + displayProperties: DisplayProperties = {} + applicationVersion: str + attachments: Optional[List[Attachment]] = None + data: Union[ResultData, Dict[str, Any]] = {} + + +class ResultResponse(Result): + """Schema for result retrieval from IDS7 server.""" + + id: int + versionId: str diff --git a/ids7client/python/ids7client/errors.py b/ids7client/python/ids7client/errors.py new file mode 100644 index 0000000..3c88297 --- /dev/null +++ b/ids7client/python/ids7client/errors.py @@ -0,0 +1,12 @@ +class IDS7RequestError(Exception): + """Exception raised when a request to IDS7 has failed.""" + + __slots__ = ("status_code", "text", "path") + + def __init__(self, status_code: int, text: str, path: str) -> None: + self.status_code = status_code + self.text = text + self.path = path + super().__init__( + f"Request {path} has failed with status code {status_code}: {text}" + ) diff --git a/ids7client/python/ids7client/helpers.py b/ids7client/python/ids7client/helpers.py new file mode 100644 index 0000000..d1fdd3b --- /dev/null +++ b/ids7client/python/ids7client/helpers.py @@ -0,0 +1,36 @@ +import logging +import time +from functools import wraps +from typing import Any, Dict, List, Union + +logger = logging.getLogger(__name__) + + +def connection_retry(): + """Decorator used to retry requests if connection cannot be established.""" + + def decorator(fn): + @wraps(fn) + def _request(*args, **kwargs): + trial = 1 + while trial < 5: + try: + return fn(*args, **kwargs) + except ConnectionError: + delay = 2**trial + logger.warning( + "IDS7 connection error trial %s/5, retrying in %ss", + trial, + delay, + ) + trial += 1 + time.sleep(delay) + logger.error("Request failed after 5 trials") + raise ConnectionError() + + return _request + + return decorator + + +JSONPayload = Union[List[Dict[str, Any]], Dict[str, Any]] diff --git a/ids7client/python/ids7client/qido/__init__.py b/ids7client/python/ids7client/qido/__init__.py new file mode 100644 index 0000000..a1e7954 --- /dev/null +++ b/ids7client/python/ids7client/qido/__init__.py @@ -0,0 +1,2 @@ +from .client import IDS7QidoClient +from .schemas import NAMES_TO_DICOM_CODES, DicomCodes, DicomObject, DicomValue diff --git a/ids7client/python/ids7client/qido/client.py b/ids7client/python/ids7client/qido/client.py new file mode 100644 index 0000000..65e836e --- /dev/null +++ b/ids7client/python/ids7client/qido/client.py @@ -0,0 +1,74 @@ +import logging +from typing import Any, Dict, List, cast + +import requests +from requests.auth import HTTPBasicAuth + +from ids7client.errors import IDS7RequestError +from ids7client.helpers import JSONPayload, connection_retry + +from .schemas import NAMES_TO_DICOM_CODES, DicomObject + +logger = logging.getLogger(__name__) + + +def _make_query_params(**kwargs) -> Dict[str, Any]: + """Builds a query params with dicom codes from names.""" + + params: Dict[str, Any] = {} + for k, v in kwargs.items(): + code = NAMES_TO_DICOM_CODES.get(k) + if code is None: + logger.warning( + "No dicom code found for key %s, skipping it in query params", k + ) + continue + params[code] = v + return params + + +class IDS7QidoClient: + """Class managing connection and requests to IDS7 server. + + Args: + url (str): URL of the IDS7 Qido API + username (str): API username + password (str): API password + """ + + __slots__ = ( + "_url", + "_auth", + ) + + def __init__(self, url: str, username: str, password: str) -> None: + self._url = url + self._auth = HTTPBasicAuth(username, password) + + @connection_retry() + def _get(self, path: str, **kwargs) -> JSONPayload: + """Runs a GET request to IDS7. Named args are query parameters.""" + + resp = requests.get(path, params=kwargs, auth=self._auth) + if resp.status_code != 200: + raise IDS7RequestError(resp.status_code, resp.text, path) + return resp.json() + + def find_all_studies(self, **kwargs) -> List[DicomObject]: + """Finds all studies matchings query.""" + + params = _make_query_params(**kwargs) + path = f"{self._url}/studies" + res = self._get(path, **params) + return [DicomObject(obj) for obj in cast(list, res)] + + def find_one_study(self, **kwargs) -> DicomObject: + """Finds one study matching query. + + Raises: + IDS7RequestError: If no study was found. + """ + studies = self.find_all_studies(**kwargs) + if not studies: + raise IDS7RequestError(404, "No study found", "") + return studies[0] diff --git a/ids7client/python/ids7client/qido/schemas.py b/ids7client/python/ids7client/qido/schemas.py new file mode 100644 index 0000000..579a9d6 --- /dev/null +++ b/ids7client/python/ids7client/qido/schemas.py @@ -0,0 +1,93 @@ +from enum import Enum, unique +from typing import Any, Dict, List, Union, cast + +from pydantic import BaseModel + + +@unique +class DicomCodes(str, Enum): + """Enum for IDS7 dicom codes.""" + + PATIENT_ID = "00100020" + EXAM_ID = "00200010" + REQUEST_ID = "00080050" + STUDY_ID = "0020000D" + + +NAMES_TO_DICOM_CODES: Dict[str, DicomCodes] = {"studyInstanceUid": DicomCodes.STUDY_ID} + + +class DicomValue(BaseModel): + """Model for a single Dicom field value.""" + + vr: str + Value: List[Union[str, Dict[str, "DicomValue"], Dict[str, str]]] + + def is_string(self) -> bool: + """Checks if the value is a string. If the list of values is empty, + returns True.""" + + if len(self.Value) == 0: + return True + return isinstance(self.Value[0], str) + + def is_values_dict(self) -> bool: + """Checks if the value is a dict of dicom values. + If Value is empty, returns True. + If all dicts are empty, returns True.""" + + if len(self.Value) == 0: + return True + if not isinstance(self.Value[0], dict): + return False + for item in self.Value: + if len(item) > 0: + return isinstance(next(iter(item.values())), "DicomValue") # type: ignore + # No dict in values with an entry + return True + + def is_str_dict(self) -> bool: + """Checks if the value is a dict of strings. + If Value is empty, returns True. + If all dicts are empty, returns True.""" + if len(self.Value) == 0: + return True + if not isinstance(self.Value[0], dict): + return False + for item in self.Value: + if len(item) > 0: + return isinstance(next(iter(item.values())), str) # type: ignore + # No dict in values with an entry + return True + + def first_as_string(self) -> str: + """Returns the first value as a string. + + Raises: + IndexError: if the Value is empty + """ + return cast(str, self.Value[0]) + + +class DicomObject: + """Class for handling dicom objects.""" + + __slots__ = ("fields",) + + def __init__(self, fields: Dict[str, Any]) -> None: + self.fields = {k: DicomValue(**v) for k, v in fields.items()} + + def get_value_as_string(self, key: str) -> str: + """Returns a field value as string. + + Args: + key (str): Field key + + Returns: + str: Field first value as string. + + Raises: + KeyError: If the field doesn't exist + IndexError: If the field value is empty + """ + return self.fields[key].first_as_string() diff --git a/ids7client/python/requirements.txt b/ids7client/python/requirements.txt new file mode 100644 index 0000000..ea510cf --- /dev/null +++ b/ids7client/python/requirements.txt @@ -0,0 +1,3 @@ +hl7 +pydantic +requests \ No newline at end of file diff --git a/ids7client/python/setup.cfg b/ids7client/python/setup.cfg new file mode 100644 index 0000000..9ae4b0c --- /dev/null +++ b/ids7client/python/setup.cfg @@ -0,0 +1,50 @@ +[isort] +profile = black +known_first_party = ids7client + +[darglint] +strictness = long + +[flake8] +max-complexity = 8 +statistics = True +max-line-length = 150 +ignore = DAR,W503,E203 +per-file-ignores = + __init__.py: F401 + +[mypy] +ignore_missing_imports = True +follow_imports = skip +strict_optional = True + +[pylint.master] +jobs=0 + +[pylint.messages] +disable= + too-few-public-methods, + missing-module-docstring, + bad-continuation, + fixme, + raise-missing-from, + too-many-lines, + duplicate-code, + invalid-name, + line-too-long + + +[pylint.similarities] +ignore-imports=yes + +[pylint.design] +max-attributes=10 + +[pylint.reports] +reports=no +files-output=pylint.txt +msg-template="{path}:{line}:\ [{msg_id}({symbol}), {obj}] {msg}" + +[pylint.typecheck] +ignored-classes=Base,Session,BaseEnum +ignored-modules=pydantic diff --git a/ids7client/python/setup.py b/ids7client/python/setup.py new file mode 100644 index 0000000..dea1f42 --- /dev/null +++ b/ids7client/python/setup.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python +import os + +from setuptools import find_packages, setup + +__version__ = os.environ.get("VERSION", "0.1.0") + + +def _load_requirements(req_file: str): + print("Requirements", req_file) + try: + with open(req_file, "r") as require_file: + return require_file.read() + except OSError: + print(f"[WARNING] {req_file} not found, no requirements in setup.") + return [] + + +setup( + name="ids7client", + version=__version__, + author="Primaa", + description="Client for Sectra IDS7 server", + install_requires=_load_requirements("requirements.txt"), + extras_require={"dev": _load_requirements("extra_requirements.txt")}, + packages=find_packages(), + python_requires=">=3.8", +)