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.
@@ -0,0 +1,3 @@
1
+ from sectra_client.client import SectraClient
2
+
3
+ __all__ = ["SectraClient"]
@@ -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)