sectra-image-analysis-api 1.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sectra_client/__init__.py +3 -0
- sectra_client/client.py +310 -0
- sectra_client/mock_server.py +468 -0
- sectra_client/schemas/__init__.py +96 -0
- sectra_client/schemas/common.py +46 -0
- sectra_client/schemas/image.py +88 -0
- sectra_client/schemas/info.py +8 -0
- sectra_client/schemas/invocation.py +92 -0
- sectra_client/schemas/quality_control.py +26 -0
- sectra_client/schemas/registration.py +29 -0
- sectra_client/schemas/results.py +111 -0
- sectra_client/utils/__init__.py +0 -0
- sectra_client/utils/decode_images.py +17 -0
- sectra_client/utils/errors.py +10 -0
- sectra_client/utils/helpers.py +32 -0
- sectra_image_analysis_api-1.5.1.dist-info/METADATA +143 -0
- sectra_image_analysis_api-1.5.1.dist-info/RECORD +19 -0
- sectra_image_analysis_api-1.5.1.dist-info/WHEEL +4 -0
- sectra_image_analysis_api-1.5.1.dist-info/licenses/LICENSE +21 -0
sectra_client/client.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import pathlib
|
|
3
|
+
import re
|
|
4
|
+
from typing import List, Optional, cast
|
|
5
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from requests_toolbelt.multipart import decoder
|
|
9
|
+
|
|
10
|
+
from sectra_client.schemas import (
|
|
11
|
+
AdaptedResult,
|
|
12
|
+
ApplicationInfo,
|
|
13
|
+
CaseImageInfo,
|
|
14
|
+
ImageMetadata,
|
|
15
|
+
QualityControl,
|
|
16
|
+
Result,
|
|
17
|
+
ResultResponse,
|
|
18
|
+
)
|
|
19
|
+
from sectra_client.schemas.image import LabelImage
|
|
20
|
+
from sectra_client.utils.errors import SectraRequestError
|
|
21
|
+
from sectra_client.utils.helpers import JSONPayload, connection_retry
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
class SectraClient:
|
|
26
|
+
__slots__ = ("_url", "_token", "version_info", "_headers", "_session")
|
|
27
|
+
|
|
28
|
+
def __init__(self, url: str, token: str, _allow_http: bool = False) -> None:
|
|
29
|
+
# Normalize base url
|
|
30
|
+
parts = urlsplit(url.strip())
|
|
31
|
+
|
|
32
|
+
scheme = parts.scheme
|
|
33
|
+
if (scheme == "http" and not _allow_http) or scheme == "":
|
|
34
|
+
scheme = "https"
|
|
35
|
+
|
|
36
|
+
base = urlunsplit((scheme, parts.netloc, parts.path.rstrip("/"), parts.query, parts.fragment))
|
|
37
|
+
self._url = base
|
|
38
|
+
|
|
39
|
+
self._token = token
|
|
40
|
+
self._headers = {"Authorization": f"Bearer {token}"}
|
|
41
|
+
|
|
42
|
+
self._session = requests.Session()
|
|
43
|
+
self._session.headers.update(self._headers)
|
|
44
|
+
|
|
45
|
+
self.version_info = self._retrieve_version_info()
|
|
46
|
+
|
|
47
|
+
def __enter__(self) -> "SectraClient":
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
51
|
+
# Make sure to close the session
|
|
52
|
+
self._session.close()
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def response_headers(self) -> dict[str, str]:
|
|
56
|
+
"""Returns the headers that should be included in responses to Sectra callbacks."""
|
|
57
|
+
return {
|
|
58
|
+
"X-Sectra-ApiVersion": self.version_info.apiVersion,
|
|
59
|
+
"X-Sectra-SoftwareVersion": self.version_info.softwareVersion,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@connection_retry()
|
|
63
|
+
def _get(self, path: str, **kwargs) -> JSONPayload:
|
|
64
|
+
url = f"{self._url}{path}"
|
|
65
|
+
resp = self._session.get(url, params=kwargs) # headers already on session
|
|
66
|
+
if resp.status_code != 200:
|
|
67
|
+
raise SectraRequestError(resp.status_code, resp.text, path)
|
|
68
|
+
return resp.json()
|
|
69
|
+
|
|
70
|
+
@connection_retry()
|
|
71
|
+
def _get_raw(self, path: str, **kwargs) -> requests.Response:
|
|
72
|
+
url = f"{self._url}{path}"
|
|
73
|
+
resp = self._session.get(url, params=kwargs)
|
|
74
|
+
if resp.status_code != 200:
|
|
75
|
+
raise SectraRequestError(resp.status_code, resp.text, path)
|
|
76
|
+
return resp
|
|
77
|
+
|
|
78
|
+
@connection_retry()
|
|
79
|
+
def _post(self, path: str, payload: JSONPayload, parse_response: bool = True) -> Optional[JSONPayload]:
|
|
80
|
+
url = f"{self._url}{path}"
|
|
81
|
+
resp = self._session.post(url, json=payload)
|
|
82
|
+
if resp.status_code != 201:
|
|
83
|
+
raise SectraRequestError(resp.status_code, resp.text, path)
|
|
84
|
+
if parse_response:
|
|
85
|
+
return resp.json()
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
@connection_retry()
|
|
89
|
+
def _put(self, path: str, values: JSONPayload, parse_response: bool = True) -> Optional[JSONPayload]:
|
|
90
|
+
url = f"{self._url}{path}"
|
|
91
|
+
resp = self._session.put(url, json=values)
|
|
92
|
+
if resp.status_code != 200:
|
|
93
|
+
raise SectraRequestError(resp.status_code, resp.text, path)
|
|
94
|
+
if parse_response:
|
|
95
|
+
return resp.json()
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
def _retrieve_version_info(self) -> ApplicationInfo:
|
|
99
|
+
"""Retrieves the versions of DPAT from the server."""
|
|
100
|
+
|
|
101
|
+
versions = ApplicationInfo(**cast(dict, self._get("/info")))
|
|
102
|
+
self._headers.update(
|
|
103
|
+
{"X-Sectra-ApiVersion": versions.apiVersion, "X-Sectra-SoftwareVersion": versions.softwareVersion}
|
|
104
|
+
)
|
|
105
|
+
self._session.headers.update(
|
|
106
|
+
{"X-Sectra-ApiVersion": versions.apiVersion, "X-Sectra-SoftwareVersion": versions.softwareVersion}
|
|
107
|
+
)
|
|
108
|
+
return versions
|
|
109
|
+
|
|
110
|
+
def close(self) -> None:
|
|
111
|
+
"""Closes the SectraClient session."""
|
|
112
|
+
self._session.close()
|
|
113
|
+
|
|
114
|
+
def get_image_infos_in_case(
|
|
115
|
+
self, accession_number: str, phi: bool = False, accession_number_issuer_id: Optional[str] = None
|
|
116
|
+
) -> list[CaseImageInfo]:
|
|
117
|
+
"""Retrieves all slides in a case. Available from IA-API 1.9 (Sectra 4.1).
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
accession_number (str): Accession number of the case
|
|
121
|
+
phi (bool): Whether Protected Health Information should be included or not.
|
|
122
|
+
Defaults to False.
|
|
123
|
+
accession_number_issuer_id (Optional[str]): Issuer ID of the accession number.
|
|
124
|
+
Defaults to None.
|
|
125
|
+
Returns:
|
|
126
|
+
list[CaseImageInfo]: List of slides in the case
|
|
127
|
+
|
|
128
|
+
"""
|
|
129
|
+
path = f"/requests/{accession_number}/images/info"
|
|
130
|
+
params: dict[str, str] = {}
|
|
131
|
+
if phi:
|
|
132
|
+
params["includePHI"] = "true"
|
|
133
|
+
if accession_number_issuer_id:
|
|
134
|
+
params["accessionNumberIssuerId"] = accession_number_issuer_id
|
|
135
|
+
return [CaseImageInfo(**img) for img in cast(list[dict], self._get(path, **params))]
|
|
136
|
+
|
|
137
|
+
def get_image_infos_in_case_by_slide_id(
|
|
138
|
+
self, slide_id: str, phi: bool = False, accession_number_issuer_id: Optional[str] = None
|
|
139
|
+
) -> list[CaseImageInfo]:
|
|
140
|
+
"""Retrieves all slides in the case a slide belongs to. Available from IA-API 1.9 (Sectra 4.1).
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
slide_id (str): Id of the slide
|
|
144
|
+
phi (bool): Whether Protected Health Information should be included or not.
|
|
145
|
+
Defaults to False.
|
|
146
|
+
accession_number_issuer_id (Optional[str]): Issuer ID of the accession number.
|
|
147
|
+
Defaults to None.
|
|
148
|
+
Returns:
|
|
149
|
+
list[CaseImageInfo]: List of slides in the case
|
|
150
|
+
|
|
151
|
+
"""
|
|
152
|
+
path = f"/slides/{slide_id}/request/images/info"
|
|
153
|
+
params: dict[str, str] = {}
|
|
154
|
+
if phi:
|
|
155
|
+
params["includePHI"] = "true"
|
|
156
|
+
if accession_number_issuer_id:
|
|
157
|
+
params["accessionNumberIssuerId"] = accession_number_issuer_id
|
|
158
|
+
return [CaseImageInfo(**img) for img in cast(list[dict], self._get(path, **params))]
|
|
159
|
+
|
|
160
|
+
def get_image_metadata(self, slide_id: str, extended: bool = False, phi: bool = False) -> ImageMetadata:
|
|
161
|
+
"""Retrieves a slide metadata from its slide_id.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
slide_id (str): Id of the slide to retrieve info from
|
|
165
|
+
extended (bool): Whether extended info should be included or not.
|
|
166
|
+
Defaults to False.
|
|
167
|
+
phi (bool): Whether Protected Health Information should be included or not.
|
|
168
|
+
Defaults to False.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
ImageMetadata: Requested slide info
|
|
172
|
+
"""
|
|
173
|
+
path = f"/slides/{slide_id}/info"
|
|
174
|
+
params: dict[str, str] = {}
|
|
175
|
+
if extended:
|
|
176
|
+
params["scope"] = "extended"
|
|
177
|
+
if phi:
|
|
178
|
+
params["includePHI"] = "true"
|
|
179
|
+
return ImageMetadata(**cast(dict, self._get(path, **params)))
|
|
180
|
+
|
|
181
|
+
def get_label_image(self, slide_id: str) -> LabelImage:
|
|
182
|
+
"""Retrieves the label image for a slide.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
slide_id (str): Id of the slide to retrieve the label image for
|
|
186
|
+
Returns:
|
|
187
|
+
LabelImage: Label image data
|
|
188
|
+
"""
|
|
189
|
+
path = f"/slides/{slide_id}/label"
|
|
190
|
+
resp = self._get_raw(path)
|
|
191
|
+
return LabelImage(image=resp.content)
|
|
192
|
+
|
|
193
|
+
def download_slide_files(self, slide_id: str, output_dir: pathlib.Path | str) -> list[pathlib.Path]:
|
|
194
|
+
"""Download and store all files associated with a slide.
|
|
195
|
+
|
|
196
|
+
This calls `/slides/{slide_id}/files`, decodes the multipart response,
|
|
197
|
+
and writes each part to disk. Filenames are taken from the
|
|
198
|
+
Content-Disposition header when available; otherwise a fallback name
|
|
199
|
+
is generated.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
slide_id: Id of the slide to download files for.
|
|
203
|
+
output_dir: Directory where files will be stored. Created if needed.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
List of paths to the written files.
|
|
207
|
+
"""
|
|
208
|
+
path = f"/slides/{slide_id}/files"
|
|
209
|
+
resp = self._get_raw(path)
|
|
210
|
+
|
|
211
|
+
multipart_data = decoder.MultipartDecoder.from_response(resp)
|
|
212
|
+
|
|
213
|
+
output_path = pathlib.Path(output_dir)
|
|
214
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
|
|
216
|
+
written_files: list[pathlib.Path] = []
|
|
217
|
+
|
|
218
|
+
api_version = resp.headers.get("X-Sectra-ApiVersion", "unknown")
|
|
219
|
+
|
|
220
|
+
for i, part in enumerate(multipart_data.parts):
|
|
221
|
+
# Decode headers to text and normalise keys
|
|
222
|
+
headers = {k.decode().lower(): v.decode() for k, v in part.headers.items()}
|
|
223
|
+
disp = headers.get("content-disposition", "")
|
|
224
|
+
|
|
225
|
+
# Try to extract filename from Content-Disposition
|
|
226
|
+
m = re.search(r'filename="([^"]+)"', disp)
|
|
227
|
+
if m:
|
|
228
|
+
filename = m.group(1)
|
|
229
|
+
else:
|
|
230
|
+
raise AttributeError(
|
|
231
|
+
"Missing filename in Content-Disposition header for slide",
|
|
232
|
+
f"{slide_id} part {i} (API version: {api_version})",
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
file_path = output_path / filename
|
|
236
|
+
|
|
237
|
+
with file_path.open("wb") as f:
|
|
238
|
+
f.write(part.content)
|
|
239
|
+
|
|
240
|
+
logger.info("Wrote slide file: %s", file_path)
|
|
241
|
+
written_files.append(file_path)
|
|
242
|
+
|
|
243
|
+
return written_files
|
|
244
|
+
|
|
245
|
+
def create_results(self, app_id: str, results: Result) -> ResultResponse:
|
|
246
|
+
"""Creates a result in Sectra.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
results (Result): Results payload
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
ResultResponse: Parsed Sectra response.
|
|
253
|
+
"""
|
|
254
|
+
path = f"/applications/{app_id}/results"
|
|
255
|
+
resp = self._post(path, results.model_dump())
|
|
256
|
+
return ResultResponse(**cast(dict, resp))
|
|
257
|
+
|
|
258
|
+
def get_result_by_result_id(self, app_id: str, result_id: int) -> ResultResponse:
|
|
259
|
+
"""Retrieves a result by its result id.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
app_id (str): Application id
|
|
263
|
+
result_id (str): Result id
|
|
264
|
+
Returns:
|
|
265
|
+
ResultResponse: Retrieved result.
|
|
266
|
+
"""
|
|
267
|
+
path = f"/applications/{app_id}/results/{result_id}"
|
|
268
|
+
resp = self._get(path)
|
|
269
|
+
return ResultResponse(**cast(dict, resp))
|
|
270
|
+
|
|
271
|
+
def get_all_results(self, wsi_id: str, app_id: str) -> List[ResultResponse]:
|
|
272
|
+
"""Retrieves results.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
wsi_id (str): Results id
|
|
276
|
+
app_id (str): Application id
|
|
277
|
+
Returns:
|
|
278
|
+
ResultResponse: Retrieved results.
|
|
279
|
+
"""
|
|
280
|
+
path = f"/applications/{app_id}/results/slide/{wsi_id}"
|
|
281
|
+
resp = self._get(path)
|
|
282
|
+
return [ResultResponse(**cast(dict, r)) for r in cast(list[dict], resp)]
|
|
283
|
+
|
|
284
|
+
def update_results(self, app_id: str, result_id: int, result: AdaptedResult) -> ResultResponse:
|
|
285
|
+
"""Update existing results.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
app_id (str): Application id
|
|
289
|
+
result_id (int): Result id
|
|
290
|
+
result (AdaptedResult): Updated result data
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
ResultResponse: Updated results
|
|
294
|
+
"""
|
|
295
|
+
path = f"/applications/{app_id}/results/{result_id}"
|
|
296
|
+
resp = self._put(path, result.model_dump())
|
|
297
|
+
return ResultResponse(**cast(dict, resp))
|
|
298
|
+
|
|
299
|
+
def set_quality_control(self, slide_id: str, quality_control: QualityControl) -> None:
|
|
300
|
+
"""Sets quality control for a slide. Available from IA-API 1.10 (Sectra 4.2).
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
slide_id (str): Id of the slide to set quality control for
|
|
304
|
+
quality_control (QualityControl): Quality control data to set
|
|
305
|
+
|
|
306
|
+
Raises:
|
|
307
|
+
DPATRequestError: If the request fails
|
|
308
|
+
"""
|
|
309
|
+
path = f"/slides/{slide_id}/qualityControl"
|
|
310
|
+
self._put(path, quality_control.model_dump(), parse_response=False)
|