ctp-client 1.0.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,45 @@
1
+ Metadata-Version: 2.4
2
+ Name: ctp-client
3
+ Version: 1.0.1
4
+ Summary: ctp-client is a Python client for [Clinical Trials Processor](https://mircwiki.rsna.org/index.php?title=MIRC_CTP).
5
+ Project-URL: CI/CD, https://gitlab.com/radiology/infrastructure/data-curation-tools/ctp-client/-/pipelines
6
+ Project-URL: Changelog, https://ctp-client.readthedocs.io/en/latest/changelog.html
7
+ Project-URL: Documentation, https://ctp-client.readthedocs.io
8
+ Project-URL: Download, https://gitlab.com/radiology/infrastructure/data-curation-tools/ctp-client/-/archive/master/ctp-client-master.zip
9
+ Project-URL: Homepage, https://gitlab.com/radiology/infrastructure/data-curation-tools/ctp-client.git
10
+ Project-URL: Issues, https://gitlab.com/radiology/infrastructure/data-curation-tools/ctp-client/-/issues
11
+ Author-email: BIGR Imaging Infrastructure Group <imaging.infrastructure@erasmusmc.nl>
12
+ License: Apache 2.0
13
+ License-File: LICENSE
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Intended Audience :: Education
17
+ Classifier: Intended Audience :: Healthcare Industry
18
+ Classifier: Intended Audience :: Information Technology
19
+ Classifier: Intended Audience :: Science/Research
20
+ Classifier: License :: OSI Approved :: Apache Software License
21
+ Classifier: Natural Language :: English
22
+ Classifier: Operating System :: OS Independent
23
+ Classifier: Programming Language :: Python :: 3 :: Only
24
+ Classifier: Programming Language :: Python :: 3.9
25
+ Classifier: Programming Language :: Python :: 3.10
26
+ Classifier: Programming Language :: Python :: 3.11
27
+ Classifier: Programming Language :: Python :: 3.12
28
+ Classifier: Programming Language :: Python :: 3.13
29
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
30
+ Classifier: Topic :: System :: Logging
31
+ Classifier: Topic :: Utilities
32
+ Requires-Python: >=3.9
33
+ Requires-Dist: beautifulsoup4==4.12.3
34
+ Requires-Dist: click-log==0.4.0
35
+ Requires-Dist: click-loglevel==0.5.1
36
+ Requires-Dist: click==8.1.3
37
+ Requires-Dist: pydantic==2.10.3
38
+ Requires-Dist: pyyaml==6.0.2
39
+ Requires-Dist: requests>=2.22.0
40
+ Requires-Dist: tabulate==0.9.0
41
+ Description-Content-Type: text/markdown
42
+
43
+ # CTPy
44
+
45
+ CTPy is a Python client for [Clinical Trials Processor](https://mircwiki.rsna.org/index.php?title=MIRC_CTP).
@@ -0,0 +1,18 @@
1
+ ctpy/__init__.py,sha256=EP3GZi8JGn1trSB9AoYk0mEaeKPepvt6y41TOKl3mCs,1446
2
+ ctpy/client.py,sha256=TystTq56n5U-i405s7aKg0saT4EauNDpiveM1mnyIVs,9964
3
+ ctpy/exceptions.py,sha256=MfOIIsysalSGq0Q5zjoFygEQT1Fxhl9_clad-k_TDAw,1144
4
+ ctpy/helpers.py,sha256=eU6Bapl7PZJSPmwE6bU3Bw1bGBbFsMWtEreb0d62R_Y,7390
5
+ ctpy/version.py,sha256=Zk7F83QsYfUU3p4V3lMkE5OwMPMEZAUNWBZQoj7REW0,455
6
+ ctpy/cli/__init__.py,sha256=F5erEag1v1NfNPQl2UFpuL49vmT2W4hXaTqkTCi-WsA,710
7
+ ctpy/cli/cli.py,sha256=RyCcXVZMwgMv2dxVgO5GCAwzfXHuZIYD-FlIWsHfGPU,7307
8
+ ctpy/core/__init__.py,sha256=ENrDRrf2Ur_jU1nEHhx_3P9Od4Sk6qRMjxUyxrmpyyk,782
9
+ ctpy/core/pipeline.py,sha256=CVWYMa6Wz6ps6Huj6d8ffxfD1qd8DbIZuoJ26YpykNg,1279
10
+ ctpy/core/server.py,sha256=GEpjcn25A2wGveJVQ7KsjvSysMeNbUFfu4LeVFKzFMk,1949
11
+ ctpy/core/stages.py,sha256=oDd3NOMBuuyrBiabJPN70m_Vp4mnz-lrPDz0xY5Quss,5865
12
+ ctpy/tests/mock.py,sha256=jFU2d9mM9IEtgjqtIpWouAypo3UsCM9c7hEdhKAb3DY,1189
13
+ ctpy/tests/test_init.py,sha256=iAc-l17rVRyhVlUxByHENGVRnZbkc_EXVDaVG5WE_I4,649
14
+ ctp_client-1.0.1.dist-info/METADATA,sha256=FQtuc_mv023nefnuDN1Qdc5I67YkAsCJo1tA4Q-JUxY,2228
15
+ ctp_client-1.0.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
+ ctp_client-1.0.1.dist-info/entry_points.txt,sha256=wKTrCeAZoSgYSpds8OJnxIjHki5cGIupxs6TJmmlt9s,38
17
+ ctp_client-1.0.1.dist-info/licenses/LICENSE,sha256=ohsbe-1180tqV0K-E81i4S5MMEHucR1lLirw3LvIpXw,665
18
+ ctp_client-1.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ctpy = ctpy.cli:cli
@@ -0,0 +1,14 @@
1
+ Copyright 2017 Biomedical Imaging Group Rotterdam, Departments of
2
+ Medical Informatics and Radiology, Erasmus MC, Rotterdam, The Netherlands
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
ctpy/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ # Copyright 2017 Biomedical Imaging Group Rotterdam, Departments of
2
+ # Medical Informatics and Radiology, Erasmus MC, Rotterdam, The Netherlands
3
+
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+
17
+ from typing import Optional
18
+
19
+ from .import version
20
+ from .client import CTPYclient
21
+
22
+ __version__ = version.version
23
+
24
+
25
+ def connect(host: str,
26
+ port: int,
27
+ username: str,
28
+ password: str,
29
+ rate_limit: Optional[int] = None) -> CTPYclient:
30
+ """
31
+ Createa a connection to a CTP server
32
+
33
+ :param host: host to connect to
34
+ :param port: port to connect to
35
+ :param username: username to login as
36
+ :param password: password for user
37
+ :param rate_limit: optionally limit the time between requests, this is specified
38
+ as the maximum requests per second
39
+
40
+ :return: CTP client object
41
+ """
42
+ ctpy_client = CTPYclient(host, port, username, password, rate_limit)
43
+ return ctpy_client
ctpy/cli/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ # Copyright 2017 Biomedical Imaging Group Rotterdam, Departments of
2
+ # Medical Informatics and Radiology, Erasmus MC, Rotterdam, The Netherlands
3
+
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+
17
+ from .cli import cli
ctpy/cli/cli.py ADDED
@@ -0,0 +1,157 @@
1
+ # Copyright 2017 Biomedical Imaging Group Rotterdam, Departments of
2
+ # Medical Informatics and Radiology, Erasmus MC, Rotterdam, The Netherlands
3
+
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+
17
+ import socket
18
+ import logging
19
+ from pathlib import Path
20
+
21
+ import click
22
+ import tabulate
23
+ from click_loglevel import LogLevel
24
+ import yaml
25
+ from yaml.loader import SafeLoader
26
+
27
+ from ctpy import CTPYclient
28
+ from ctpy import exceptions
29
+ from ctpy.helpers import poll_object_tracker_for_studyuid, summarize_object_tracker_content
30
+
31
+ @click.group()
32
+ @click.version_option()
33
+ @click.pass_context
34
+ def cli(ctx):
35
+ ctx.ensure_object(dict)
36
+
37
+ @cli.command()
38
+ @click.option('--lookup-table-filename', type=Path, required=True)
39
+ @click.option('--ctp-host', required=True)
40
+ @click.option('--ctp-port', type=int, default=1180, required=True)
41
+ @click.option('--dicom-anonymizer-id', required=True)
42
+ @click.option('--username', required=True)
43
+ @click.option('--password', required=True, prompt=True, hide_input=True)
44
+ @click.option('--log-level', type=LogLevel(), default=logging.INFO)
45
+ @click.pass_context
46
+ def post_lookup_table(_, lookup_table_filename, ctp_host, ctp_port,
47
+ dicom_anonymizer_id, username, password, log_level):
48
+ logging.basicConfig(
49
+ format=f"%(asctime)s - {socket.gethostname()} - [%(levelname)-8s] %(message)s",
50
+ level=log_level,
51
+ )
52
+ ctpy_client = CTPYclient(ctp_host, ctp_port, username, password, rate_limit=0.2)
53
+ with open(lookup_table_filename, encoding='utf-8') as lookup_table_file:
54
+ lut = yaml.load(lookup_table_file, Loader=SafeLoader)
55
+ for key, value in lut.items():
56
+ try:
57
+ ctpy_client.add_to_lookup(dicom_anonymizer_id, key, value)
58
+ except exceptions.CTPYFailedAddingLUT:
59
+ pass
60
+ break
61
+
62
+ @cli.command()
63
+ @click.option('--ctp-host', required=True)
64
+ @click.option('--ctp-port', type=int, default=1180, required=True)
65
+ @click.option('--pipeline-id', type=int, default=0, required=True)
66
+ @click.option('--object-tracker-id', type=int, default=1, required=True)
67
+ @click.option('--username', required=True, default="king")
68
+ @click.option('--password', required=True, default="password", prompt=True, hide_input=True)
69
+ @click.option('--patient-id', required=True)
70
+ @click.option('--study-uid', 'study_uid_filter')
71
+ @click.option('--delay', type=int, default=5)
72
+ @click.option('--timeout', type=int, default=900)
73
+ @click.option('--log-level', type=LogLevel(), default=logging.INFO)
74
+ @click.pass_context
75
+ def check_object_tracker(_, ctp_host, ctp_port, pipeline_id, object_tracker_id,
76
+ username, password, patient_id, study_uid_filter, delay,
77
+ timeout, log_level):
78
+ logging.basicConfig(
79
+ format=f"%(asctime)s - {socket.gethostname()} - [%(levelname)-8s] %(message)s",
80
+ level=log_level,
81
+ )
82
+ ctpy_client = CTPYclient(ctp_host, ctp_port, username, password, rate_limit=0.2)
83
+ object_tracker_data = poll_object_tracker_for_studyuid(ctpy_client,
84
+ pipeline_id,
85
+ object_tracker_id,
86
+ patient_id,
87
+ study_uid_filter,
88
+ delay,
89
+ timeout)
90
+ logging.debug('patient_data: %s', object_tracker_data)
91
+ if object_tracker_data and patient_id in object_tracker_data:
92
+ for study_uid, series_info in object_tracker_data[patient_id].items():
93
+ for series_uid, instances in series_info.items():
94
+ logging.info('%s|%s|%s', study_uid, series_uid, instances)
95
+
96
+ @cli.command()
97
+ @click.option('--ctp-host', required=True)
98
+ @click.option('--ctp-port', type=int, default=1180, required=True)
99
+ @click.option('--pipeline-id', type=int, default=0, required=True)
100
+ @click.option('--idmap-id', type=int, default=1, required=True)
101
+ @click.option('--username', required=True, default="king")
102
+ @click.option('--password', required=True, default="password", prompt=True, hide_input=True)
103
+ @click.option('--keytype', required=True)
104
+ @click.option('--key', required=True)
105
+ @click.option('--log-level', type=LogLevel(), default=logging.INFO)
106
+ @click.pass_context
107
+ def query_idmap(_, ctp_host, ctp_port, pipeline_id, idmap_id, keytype, key,
108
+ username, password, log_level):
109
+ logging.basicConfig(
110
+ format=f"%(asctime)s - {socket.gethostname()} - [%(levelname)-8s] %(message)s",
111
+ level=log_level,
112
+ )
113
+ ctpy_client = CTPYclient(ctp_host, ctp_port, username, password, rate_limit=0.2)
114
+ study_uid = ctpy_client.idmap_search(pipeline_id, idmap_id, key, keytype)
115
+ if study_uid:
116
+ logging.info(study_uid)
117
+
118
+
119
+ @cli.command()
120
+ @click.option('--ctp-host', required=True)
121
+ @click.option('--ctp-port', type=int, default=1180, required=True)
122
+ @click.option('--username', required=False)
123
+ @click.option('--password', required=False)
124
+ def get_summary(ctp_host, ctp_port, username, password):
125
+ logging.basicConfig(
126
+ format=f"%(asctime)s - {socket.gethostname()} - [%(levelname)-8s] %(message)s",
127
+ level='INFO',
128
+ )
129
+ ctpy_client = CTPYclient(ctp_host, ctp_port, username, password, rate_limit=0.2)
130
+ summary = ctpy_client.get_ctp_summary()
131
+ logging.info(f"\n{tabulate.tabulate([s.to_table_row() for s in summary], headers=['pipeline', 'import', 'export', 'quarantines'])}")
132
+
133
+
134
+ @cli.command()
135
+ @click.option('--ctp-host', required=True)
136
+ @click.option('--ctp-port', type=int, default=1180, required=True)
137
+ @click.option('--username', required=True)
138
+ @click.option('--password', required=True)
139
+ @click.option('--pipeline-name', required=True)
140
+ def get_tracker_summary(ctp_host, ctp_port, username, password, pipeline_name):
141
+ logging.basicConfig(
142
+ format=f"%(asctime)s - {socket.gethostname()} - [%(levelname)-8s] %(message)s",
143
+ level='INFO',
144
+ )
145
+
146
+ ctpy_client = CTPYclient(ctp_host, ctp_port, username, password, rate_limit=0.2)
147
+ pipeline = ctpy_client.server.pipelines.get(pipeline_name)
148
+ if pipeline is None:
149
+ raise exceptions.CTPYPipelineNotFound(f"Pipeline {pipeline_name} not found!")
150
+
151
+ trackers_summary = list()
152
+ object_trackers = ctpy_client.find_object_tracker_stages(pipeline)
153
+ for tracker_id, object_tracker in object_trackers:
154
+ object_tracker_contents = ctpy_client.get_object_tracker_patient_info(pipeline_id=pipeline.pipeline_id, stage_id=tracker_id)
155
+
156
+ trackers_summary.append([object_tracker.name, *summarize_object_tracker_content(object_tracker_contents)])
157
+ logging.info(f"\n{tabulate.tabulate(trackers_summary, headers=['name', 'subjects', 'experiments', 'scans', 'instances'])}")
ctpy/client.py ADDED
@@ -0,0 +1,265 @@
1
+ # Copyright 2022 Biomedical Imaging Group Rotterdam, Department of
2
+ # Radiology, Erasmus MC, Rotterdam, The Netherlands
3
+
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+
17
+ import logging
18
+ import re
19
+ from time import time, sleep
20
+ from typing import Dict, List, Optional
21
+
22
+ import requests
23
+ from requests.exceptions import ConnectionError as RequestsConnectionError
24
+
25
+ from ctpy import exceptions
26
+ from ctpy.core import CTPServer
27
+ from ctpy.core.stages import ObjectTracker
28
+ from ctpy.helpers import parse_idmap_results, parse_object_tracker_results, parse_summary, parse_step, CtpSummary
29
+
30
+
31
+ class CTPYclient():
32
+
33
+ def __init__(self, host: str, port: int, username: str, password: str, rate_limit: Optional[int] = None):
34
+ self.host: str = host
35
+ self.port: int = port
36
+ self.headers: Dict[str, str] = {}
37
+
38
+ if username is None:
39
+ logging.debug(f'No username information')
40
+ self.session: requests.Session = requests.Session()
41
+ else:
42
+ logging.debug(f'Log in as user: {username}')
43
+ self.session: requests.Session = self._login(username, password)
44
+
45
+ self.rate_limit: int = rate_limit
46
+ self.server: CTPServer = CTPServer(self)
47
+
48
+ def _login(self, username: str, password: str) -> requests.Session:
49
+ cookies = {
50
+ 'CTP': 'session',
51
+ }
52
+
53
+ headers = {
54
+ 'Connection': 'keep-alive',
55
+ 'Referer': f'{self.host}:{self.port}/',
56
+ }
57
+
58
+ timestamp = int(time())
59
+ query = f'username={username}&password={password}&url=/&timestamp={timestamp}'
60
+ login_uri = f'{self.host}:{self.port}/login?{query}'
61
+ logging.debug(f'Logging in using url: {login_uri}')
62
+ try:
63
+ response = requests.get(login_uri, cookies=cookies,
64
+ headers=headers, allow_redirects=False)
65
+ logging.debug(f'got response: {response}')
66
+ except RequestsConnectionError as esc:
67
+ raise exceptions.CTPYLoginException(esc)
68
+
69
+ if response.status_code == 302 and 'RSNASESSION' in response.cookies:
70
+ logging.debug('Succesfully connected to CTP')
71
+ session = requests.Session()
72
+ session.cookies.update({
73
+ 'RSNASESSION': response.cookies['RSNASESSION']
74
+ })
75
+ else:
76
+ raise exceptions.CTPYLoginException
77
+ return session
78
+
79
+ def _rate_limit(self):
80
+ if self.rate_limit:
81
+ sleep(1 / self.rate_limit)
82
+
83
+ def format_uri(self, path: str, query: Optional[str] = None) -> str:
84
+ if path and path[0] != '/':
85
+ path = '/' + path
86
+ if query and query[0] != '?':
87
+ query = '?' + query
88
+
89
+ if not query:
90
+ query = ''
91
+ if not path:
92
+ path = ''
93
+
94
+ return f'{self.host}:{self.port}{path}{query}'
95
+
96
+ def get(self,
97
+ path: str,
98
+ query: Optional[str] = None,
99
+ timeout: Optional[float] = None,
100
+ headers: Dict[str, str] = None) -> requests.Response:
101
+ """
102
+ Process a REST GET to the server
103
+
104
+ :param path: path for the request
105
+ :param query: query string to use for the request
106
+ :param timeout: timeout
107
+ :param headers: request headers
108
+ :return: requests response
109
+ """
110
+ uri = self.format_uri(path, query)
111
+ logging.debug('GET: %s', uri)
112
+ self._rate_limit()
113
+ try:
114
+ response = self.session.get(uri, timeout=timeout, headers=headers)
115
+ except RequestsConnectionError as esc:
116
+ raise exceptions.CTPYConnectionAborted(esc)
117
+ return response
118
+
119
+ def put(self,
120
+ path: str,
121
+ query: Optional[str] = None,
122
+ timeout: Optional[float] = None,
123
+ headers: Dict[str, str] = None) -> requests.Response:
124
+ uri = self.format_uri(path, query)
125
+ logging.debug('PUT: %s', uri)
126
+ self._rate_limit()
127
+ try:
128
+ response = self.session.put(uri, timeout=timeout, headers=headers)
129
+ except RequestsConnectionError as esc:
130
+ raise exceptions.CTPYConnectionAborted(esc)
131
+ return response
132
+
133
+ def post(self,
134
+ path: str,
135
+ data,
136
+ query: Optional[str] = None,
137
+ timeout: Optional[float] = None,
138
+ headers: Dict[str, str] = None) -> requests.Response:
139
+ uri = self.format_uri(path, query)
140
+ logging.debug('POST: %s', uri)
141
+ self._rate_limit()
142
+ try:
143
+ response = self.session.post(uri, data=data, timeout=timeout, headers=headers)
144
+ except RequestsConnectionError as esc:
145
+ raise exceptions.CTPYConnectionAborted(esc)
146
+ return response
147
+
148
+ def get_ctp_summary(self) -> List[CtpSummary]:
149
+ response = self.get('/summary?suppress')
150
+ if response.status_code == 200:
151
+ logging.debug('Succesfully got html')
152
+ elif response.status_code == 403:
153
+ logging.error('Forbidden')
154
+ raise exceptions.CTPYAccessException
155
+ else:
156
+ raise exceptions.CTPYGeneralError
157
+
158
+ results = parse_summary(response.content)
159
+ return results
160
+
161
+ def get_ctp_step(self, pipeline: int, step: int) -> Dict[str, Dict[str, str]]:
162
+ response = self.get(f'/summary?p={pipeline}&s={step}&suppress')
163
+ print(f"{response.status_code}=")
164
+
165
+ if response.status_code == 200:
166
+ logging.debug('Succesfully got html')
167
+ elif response.status_code == 403:
168
+ logging.error('Forbidden')
169
+ raise exceptions.CTPYAccessException
170
+ else:
171
+ raise exceptions.CTPYGeneralError
172
+
173
+ if "<title>ERROR</title>" in response.text:
174
+ match = re.search("java.lang.IndexOutOfBoundsException: Index (\d+) out of bounds for length (\d+)", response.text)
175
+
176
+ if match:
177
+ index = match.group(1)
178
+ length = match.group(2)
179
+
180
+ raise IndexError(f"Index out of range {index=} {length=}")
181
+ else:
182
+ raise exceptions.CTPYGeneralError
183
+
184
+ results = parse_step(response.content)
185
+ return results
186
+
187
+ def add_to_lookup(self, stage_id, key, value):
188
+ query = f'id={stage_id}&key={key}&value={value}'
189
+ response = self.put('/lookup', query)
190
+ if response.status_code == 200:
191
+ logging.info('Succesfully added Key/Value')
192
+ elif response.status_code == 304:
193
+ logging.warning('Did not modified key/value')
194
+ raise exceptions.CTPYFailedAddingLUT
195
+ elif response.status_code == 403:
196
+ logging.error('Forbidden')
197
+ raise exceptions.CTPYAccessException
198
+ else:
199
+ raise exceptions.CTPYGeneralError
200
+ # logging.debug(response.content)
201
+
202
+ def get_object_tracker_patient_info(self, pipeline_id, stage_id, key='', patient_id=None, study_uid_filter=None):
203
+ # headers = self.headers.copy()
204
+ headers = {}
205
+ headers['Referer'] = f'{self.host}:{self.port}/objecttracker'
206
+ data = {
207
+ 'p': str(pipeline_id),
208
+ 's': str(stage_id),
209
+ 'suppress': '',
210
+ 'keytype': 'patient',
211
+ 'keys': key,
212
+ 'format': 'csv'
213
+ }
214
+ response = self.post('objecttracker',
215
+ headers=headers,
216
+ data=data)
217
+ if response.status_code != 200:
218
+ logging.debug(response.text)
219
+ return None
220
+ logging.debug(f'Results: {response.text}')
221
+
222
+ object_tracker_dict = parse_object_tracker_results(response.text, patient_id, study_uid_filter)
223
+ return object_tracker_dict
224
+
225
+ def idmap_search(self, pipeline_id, stage_id, key, keytype):
226
+ headers = self.headers.copy()
227
+ headers['Referer'] = f'{self.host}:{self.port}/idmap'
228
+ data ={
229
+ "p": str(pipeline_id),
230
+ "s": str(stage_id),
231
+ "suppress": "",
232
+ "keytype": keytype,
233
+ "keys": str(key),
234
+ "format": "csv"
235
+ }
236
+ logging.debug(f'Querying ID-map at {pipeline_id};{stage_id}')
237
+ response = self.session.post(f'{self.host}:{self.port}/idmap',
238
+ headers=headers,
239
+ data=data)
240
+ if response.status_code != 200:
241
+ logging.debug(f'Got {response.status_code}')
242
+ logging.debug(response.text)
243
+ return None
244
+
245
+ new_id = parse_idmap_results(response.text)
246
+ return new_id
247
+
248
+ def idmap_search_uid(self, pipeline_id, stage_id, key):
249
+ return self.idmap_search(pipeline_id, stage_id, key, keytype="originalUID")
250
+
251
+ def idmap_reverse_search_uid(self, pipeline_id, stage_id, key):
252
+ return self.idmap_search(pipeline_id, stage_id, key, keytype="trialUID")
253
+
254
+ def idmap_search_patientid(self, pipeline_id, stage_id, key):
255
+ return self.idmap_search(pipeline_id, stage_id, key, keytype="originalPtID")
256
+
257
+ def idmap_reverse_search_patientid(self, pipeline_id, stage_id, key):
258
+ return self.idmap_search(pipeline_id, stage_id, key, keytype="trialPtID")
259
+
260
+ def find_object_tracker_stages(self, pipeline):
261
+ object_trackers = [stage for stage in pipeline.stages.items() if isinstance(stage[1], ObjectTracker)]
262
+ if object_trackers:
263
+ return object_trackers
264
+ else:
265
+ raise exceptions.CTPYNoObjectTrackerFound(f"No ObjectTracker stages found in pipeline {pipeline.pipeline_id}!")
ctpy/core/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ # Copyright 2017 Biomedical Imaging Group Rotterdam, Departments of
2
+ # Medical Informatics and Radiology, Erasmus MC, Rotterdam, The Netherlands
3
+
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ from .pipeline import Pipeline
17
+ from .server import CTPServer
18
+ from .stages import StageFactory
ctpy/core/pipeline.py ADDED
@@ -0,0 +1,33 @@
1
+ # Copyright 2017 Biomedical Imaging Group Rotterdam, Departments of
2
+ # Medical Informatics and Radiology, Erasmus MC, Rotterdam, The Netherlands
3
+
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+
17
+ import logging
18
+
19
+ from ctpy.core.stages import StageFactory
20
+
21
+
22
+ class Pipeline():
23
+ def __init__(self, server, pipeline_id, pipeline):
24
+ self.server = server
25
+ self.pipeline_id = pipeline_id
26
+ self.stages = {}
27
+ for stage_id, stage in enumerate(pipeline):
28
+ logging.debug('Create stage:<%s> <%s>', stage_id, stage)
29
+ ctp_stage = StageFactory.create_stage(self, stage_id, stage.attrib)
30
+ if id in stage.attrib:
31
+ self.stages[stage.attrib['id']] = ctp_stage
32
+ else:
33
+ self.stages[stage_id] = ctp_stage
ctpy/core/server.py ADDED
@@ -0,0 +1,50 @@
1
+ # Copyright 2017 Biomedical Imaging Group Rotterdam, Departments of
2
+ # Medical Informatics and Radiology, Erasmus MC, Rotterdam, The Netherlands
3
+
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+
17
+ import xml.etree.ElementTree as ET
18
+ from xml.etree.ElementTree import ParseError
19
+
20
+ from ctpy import exceptions
21
+ from ctpy.core import Pipeline
22
+
23
+
24
+ class CTPServer():
25
+ def __init__(self, ctpy_session):
26
+ self.ctpy_session = ctpy_session
27
+ response = self.ctpy_session.get('server')
28
+ if response.status_code != 200:
29
+ raise exceptions.CTPYServerXMLParseError()
30
+ xml_string = response.text
31
+ try:
32
+ server = ET.fromstring(xml_string)
33
+ except ParseError as exc:
34
+ raise exceptions.CTPYServerXMLParseError(exc)
35
+
36
+ # {'build': '2021.04.01 at 11:04:40 CDT', 'ip': '10.42.0.83', 'java': '1.7', 'port': '1080'}
37
+ self.build = server.attrib['build']
38
+ self.ip = server.attrib['ip']
39
+ self.java = server.attrib['java']
40
+ self.port = server.attrib['port']
41
+ self.pipelines = {}
42
+
43
+ for configuration in server:
44
+ if configuration.tag != 'Configuration':
45
+ raise exceptions.CTPYServerXMLParseError
46
+ pipeline_id = 0
47
+ for child in configuration:
48
+ if child.tag == 'Pipeline':
49
+ self.pipelines[child.attrib['name']] = Pipeline(self, pipeline_id, child)
50
+ pipeline_id += 1
ctpy/core/stages.py ADDED
@@ -0,0 +1,153 @@
1
+ # Copyright 2017 Biomedical Imaging Group Rotterdam, Departments of
2
+ # Medical Informatics and Radiology, Erasmus MC, Rotterdam, The Netherlands
3
+
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+
17
+ import logging
18
+
19
+ from ctpy import exceptions
20
+ from ctpy.helpers import parse_idmap_results, parse_object_tracker_results
21
+
22
+
23
+ class StageFactory:
24
+ @classmethod
25
+ def create_stage(cls, pipeline, stage_id, stage_config):
26
+ stage_class = stage_config['class'].split('.')[-1]
27
+
28
+ sanitized_config = cls._sanitize_config(stage_config)
29
+ logging.debug('Creating stage: %s', stage_class)
30
+ logging.debug('Creating stage: %s', sanitized_config)
31
+
32
+ if stage_class == 'IDMap':
33
+ return IDMap(pipeline, stage_id, **sanitized_config)
34
+ if stage_class == 'DicomAnonymizer':
35
+ return DicomAnonymizer(pipeline, stage_id, **sanitized_config)
36
+ if stage_class == 'DicomFilter':
37
+ return DicomFilter(pipeline, stage_id, **sanitized_config)
38
+ if stage_class == 'DicomPixelAnonymizer':
39
+ return DicomPixelAnonymizer(pipeline, stage_id, **sanitized_config)
40
+ if stage_class == 'ObjectTracker':
41
+ return ObjectTracker(pipeline, stage_id, **sanitized_config)
42
+
43
+ return BaseStage(pipeline, stage_id, **sanitized_config)
44
+
45
+ @classmethod
46
+ def _sanitize_config(cls, stage_config):
47
+ sanitized_config = {}
48
+ if 'class' not in stage_config or 'root' not in stage_config:
49
+ raise exceptions.CTPYStageParseError
50
+ sanitized_config['stage_class'] = stage_config['class']
51
+ sanitized_config['root'] = stage_config['root']
52
+
53
+ accepted_keys = ['id', 'root', 'quarantine', 'lookup_table', 'script', 'name', 'script']
54
+
55
+ for key in accepted_keys:
56
+ if key in stage_config:
57
+ sanitized_config[key] = stage_config[key]
58
+ return sanitized_config
59
+
60
+
61
+ class BaseStage():
62
+ def __init__(self, pipeline, stage_id, stage_class, root, id=None, quarantine=None, name=None):
63
+ self.id = id
64
+ self.stage_id = stage_id
65
+ self.pipeline = pipeline
66
+ self.stage_class = stage_class
67
+ self.root = root
68
+ self.quarantine = quarantine
69
+ self.name = name
70
+
71
+ def __str__(self):
72
+ return f'This Stage is of type: {type(self).__name__}'
73
+
74
+
75
+ class ObjectTracker(BaseStage):
76
+ def query_patientids(self, key, study_uid_filter=None):
77
+ headers = {}
78
+ headers['Referer'] = self.pipeline.server.ctpy_session.format_uri('/objecttracker')
79
+ data = {
80
+ 'p': str(self.pipeline.pipeline_id),
81
+ 's': str(self.id),
82
+ 'suppress': '',
83
+ 'keytype': 'patient',
84
+ 'keys': key,
85
+ 'format': 'csv'
86
+ }
87
+ response = self.pipeline.server.ctpy_session.post('objecttracker',
88
+ headers=headers,
89
+ data=data)
90
+ if response.status_code != 200:
91
+ logging.debug(response.text)
92
+ return None
93
+ object_tracker_dict = parse_object_tracker_results(response.text, study_uid_filter)
94
+ return object_tracker_dict
95
+
96
+
97
+ class IDMap(BaseStage):
98
+ def search(self, key, keytype):
99
+ headers = {}
100
+ headers['Referer'] = self.pipeline.server.ctpy_session.format_uri('/idmap')
101
+ data ={
102
+ 'p': str(self.pipeline.pipeline_id),
103
+ 's': str(self.id),
104
+ "suppress": "",
105
+ "keytype": keytype,
106
+ "keys": str(key),
107
+ "format": "csv"
108
+ }
109
+
110
+ response = self.pipeline.server.ctpy_session.post('idmap',
111
+ headers=headers,
112
+ data=data)
113
+ if response.status_code != 200:
114
+ logging.debug(response.text)
115
+ return None
116
+
117
+ new_id = parse_idmap_results(response.text)
118
+ return new_id
119
+
120
+
121
+ class DicomFilter(BaseStage):
122
+ def __init__(self, pipeline, stage_id, stage_class, root,
123
+ id=None, name=None, quarantine=None, script=None):
124
+ super().__init__(pipeline, stage_class, root, name, quarantine=quarantine, name=name, id=id)
125
+ self.script = script
126
+
127
+
128
+ class DicomAnonymizer(BaseStage):
129
+ def __init__(self, pipeline, stage_id, stage_class, root, id,
130
+ name=None, quarantine=None, script=None, lookup_table=None):
131
+ super().__init__(pipeline, stage_id, stage_class, root, quarantine=quarantine, name=name, id=id)
132
+ self.lookup_table = lookup_table
133
+ self.script = script
134
+
135
+ def add_key(self, key, value):
136
+ query = f'id={self.id}&key={key}&value={value}'
137
+ response = self.pipeline.server.ctpy_session.put('/lookup', query)
138
+ if response.status_code == 200:
139
+ logging.info('Succesfully added Key/Value')
140
+ elif response.status_code == 304:
141
+ logging.warning('Did not modified key/value')
142
+ raise exceptions.CTPYFailedAddingLUT
143
+ elif response.status_code == 403:
144
+ logging.error('Forbidden')
145
+ raise exceptions.CTPYAccessException
146
+ else:
147
+ raise exceptions.CTPYGeneralError
148
+
149
+ class DicomPixelAnonymizer(BaseStage):
150
+ def __init__(self, pipeline, stage_id, stage_class,
151
+ name=None, root=None, quarantine=None, script=None):
152
+ super().__init__(pipeline, stage_class, root, name)
153
+ self.script = script
ctpy/exceptions.py ADDED
@@ -0,0 +1,45 @@
1
+ # Copyright 2022 Biomedical Imaging Group Rotterdam, Department of
2
+ # Radiology, Erasmus MC, Rotterdam, The Netherlands
3
+
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+
17
+ class CTPYFailedAddingLUT(Exception):
18
+ pass
19
+
20
+ class CTPYNoObjectTrackerFound(Exception):
21
+ pass
22
+
23
+ class CTPYPipelineNotFound(Exception):
24
+ pass
25
+
26
+ class CTPYLoginException(Exception):
27
+ pass
28
+
29
+ class CTPYSSLError(Exception):
30
+ pass
31
+
32
+ class CTPYAccessException(Exception):
33
+ pass
34
+
35
+ class CTPYServerXMLParseError(Exception):
36
+ pass
37
+
38
+ class CTPYConnectionAborted(Exception):
39
+ pass
40
+
41
+ class CTPYStageParseError(Exception):
42
+ pass
43
+
44
+ class CTPYGeneralError(Exception):
45
+ pass
ctpy/helpers.py ADDED
@@ -0,0 +1,188 @@
1
+ # Copyright 2017 Biomedical Imaging Group Rotterdam, Departments of
2
+ # Medical Informatics and Radiology, Erasmus MC, Rotterdam, The Netherlands
3
+
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ from time import sleep, time
17
+ import logging
18
+ import datetime
19
+ from typing import Dict, List, Optional, Union
20
+
21
+ import bs4
22
+ from pydantic import BaseModel, Field, AliasChoices, field_validator
23
+
24
+
25
+ def parse_object_tracker_results(object_tracker_string: str,
26
+ patient_id_filter: Optional[str] = None,
27
+ study_uid_filter: Optional[str] = None) -> Union[None, Dict[str, Dict[str, Dict[str, str]]]]:
28
+ try:
29
+ data = object_tracker_string.split()[1:]
30
+ except IndexError:
31
+ return None
32
+ object_tracker_dict = {}
33
+ for row in data:
34
+ row = row.split(',')
35
+ patient_id = row[0]
36
+ study_uid = row[1]
37
+ series_uid = row[2]
38
+ instances = int(row[3])
39
+ if patient_id_filter and not patient_id == patient_id_filter:
40
+ logging.debug('Filtering Patient: %s not equal: %s', patient_id, patient_id_filter)
41
+ continue
42
+ if study_uid_filter and not study_uid == study_uid_filter:
43
+ logging.debug('Filtering StudyUID: %s', study_uid, study_uid_filter)
44
+ continue
45
+ if patient_id in object_tracker_dict:
46
+ if study_uid in object_tracker_dict[patient_id]:
47
+ if series_uid in object_tracker_dict[patient_id][study_uid]:
48
+ object_tracker_dict[patient_id][study_uid][series_uid] = instances
49
+ logging.debug('Update %s to object_tracker_dict', instances)
50
+ else:
51
+ object_tracker_dict[patient_id][study_uid][series_uid] = instances
52
+ logging.debug('Add %s to object_tracker_dict', instances)
53
+ else:
54
+ object_tracker_dict[patient_id][study_uid] = {series_uid: instances}
55
+ logging.debug('Add %s|%s;%s to object_tracker_dict',
56
+ study_uid, series_uid, instances)
57
+ else:
58
+ logging.debug('Add %s|%s|%s;%s to object_tracker_dict',
59
+ patient_id, study_uid, series_uid, instances)
60
+ object_tracker_dict[patient_id] = {study_uid: {series_uid: instances}}
61
+ return object_tracker_dict
62
+
63
+
64
+ def parse_idmap_results(idmap_string: str) -> Union[None, str]:
65
+ logging.debug(f'idmap: {idmap_string}')
66
+ lines = idmap_string.split('\n')
67
+ _, new = lines[1].split(',')
68
+
69
+ if new.startswith('=("'):
70
+ trial_uid = new[3:-2]
71
+ return trial_uid
72
+ return None
73
+
74
+
75
+ def poll_object_tracker_for_studyuid(ctpy_session, pipeline_id,
76
+ object_tracker_id, patient_id,
77
+ study_uid, delay, timeout):
78
+ old_data = object_tracker_data = {}
79
+ last_update = int(time())
80
+ logging.debug(f'Querying for patient:{patient_id}, study_uid: {study_uid}')
81
+ while old_data == object_tracker_data:
82
+ object_tracker_data = ctpy_session.get_object_tracker_patient_info(pipeline_id,
83
+ object_tracker_id,
84
+ '',
85
+ patient_id,
86
+ study_uid)
87
+ logging.debug('Checking for new data...')
88
+ if old_data == object_tracker_data:
89
+ logging.debug('No incoming data check timeout')
90
+ if int(time() - last_update) > timeout:
91
+ logging.debug('Timeout reached')
92
+ break
93
+ else:
94
+ last_update = int(time())
95
+ logging.debug('Incoming data...')
96
+ logging.debug(f'Found: {object_tracker_data}')
97
+
98
+ old_data = object_tracker_data
99
+ sleep(delay)
100
+
101
+ return object_tracker_data
102
+
103
+
104
+ class CtpSummary(BaseModel):
105
+ pipeline: str = Field('', validation_alias=AliasChoices('Pipeline'))
106
+ import_queues: int = Field(0, validation_alias=AliasChoices('ImportQueues'))
107
+ export_queues: int = Field(0, validation_alias=AliasChoices('ExportQueues'))
108
+ quarantines: int = Field(0, validation_alias=AliasChoices('Quarantines'))
109
+ time_stamp: datetime.datetime = Field(
110
+ default_factory=lambda: datetime.datetime.now()) # pylint:disable=unnecessary-lambda
111
+
112
+ def __init__(self, row):
113
+ super().__init__()
114
+ values = row.find_all('td')
115
+ self.pipeline = values[0].text
116
+ self.import_queues = int(values[1].text)
117
+ self.export_queues = int(values[2].text)
118
+ self.quarantines = int(values[3].text)
119
+
120
+ @field_validator('import_queues', 'export_queues', 'quarantines', mode='before')
121
+ @classmethod
122
+ def remove_separators(cls, v: str) -> int:
123
+ if isinstance(v, int):
124
+ return v
125
+ return int(v.replace(',', ''))
126
+
127
+ def to_table_row(self) -> List:
128
+ return [self.pipeline, self.import_queues, self.export_queues, self.quarantines]
129
+
130
+
131
+ def parse_summary(html: Union[str, bytes]) -> Union[None, List[CtpSummary]]:
132
+ soup = bs4.BeautifulSoup(html, 'html.parser')
133
+ tables = soup.find_all('table', attrs={'class': 'summary'})
134
+ if len(tables) != 1:
135
+ return None
136
+
137
+ rows = tables[0].find_all('tr')
138
+ results = [CtpSummary(row) for row in rows[1:]]
139
+
140
+ return results
141
+
142
+
143
+ def parse_step(html: Union[str, bytes]) -> Dict[str, Dict[str, str]]:
144
+ soup = bs4.BeautifulSoup(html, 'html.parser')
145
+
146
+ status_table = soup.find('h2', string='Status').find_next_sibling('table')
147
+ config_table = soup.find('h2', string='Configuration').find_next_sibling('table')
148
+
149
+ return {
150
+ 'status': parse_vertical_table(status_table),
151
+ 'config': parse_vertical_table(config_table),
152
+ }
153
+
154
+
155
+ def parse_vertical_table(table_element: bs4.Tag) -> Dict[str, str]:
156
+ """
157
+ Parse the vertical tables that just have 2 columns with a key-value structure
158
+
159
+ :param table_element: table DOM element
160
+ :return: table parsed into a dict
161
+ """
162
+ rows = table_element.find_all('tr')
163
+
164
+ result = {}
165
+ for row in rows:
166
+ elements = row.find_all('td')
167
+ if len(elements) != 2:
168
+ raise ValueError('Expected table to have 2 columns only!')
169
+
170
+ result[elements[0].text] = elements[1].text
171
+
172
+ return result
173
+
174
+
175
+ def summarize_object_tracker_content(object_tracker_content):
176
+ subjects = 0
177
+ experiments = 0
178
+ scans = 0
179
+ instances = 0
180
+ for _, subject_info in object_tracker_content.items():
181
+ subjects += 1
182
+ for _, experiment_info in subject_info.items():
183
+ experiments += 1
184
+ for _, scan_instances in experiment_info.items():
185
+ scans += 1
186
+ instances += scan_instances
187
+
188
+ return subjects, experiments, scans, instances
ctpy/tests/mock.py ADDED
@@ -0,0 +1,32 @@
1
+ # Copyright 2024 Biomedical Imaging Group Rotterdam, Departments of
2
+ # Medical Informatics and Radiology, Erasmus MC, Rotterdam, The Netherlands
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ from typing import Any, Pattern, Union
17
+
18
+ from requests import Response
19
+ from requests_mock import Mocker
20
+
21
+
22
+ class CTPRequestsMocker(Mocker):
23
+ uri: str = "http://ctp.example.com"
24
+ port: int = 1080
25
+
26
+ def request(self,
27
+ method: str,
28
+ url: Union[str, Pattern[str]],
29
+ **kwargs: Any) -> Response:
30
+ url = f"{self.uri}:{self.port}/{url.lstrip('/')}"
31
+ print(f'Mocking request for {url}')
32
+ return super().request(method, url, **kwargs)
@@ -0,0 +1,18 @@
1
+ import ctpy
2
+ from ctpy import CTPYclient
3
+
4
+
5
+ def test_connect(ctpy_mock):
6
+ ctpy_mock.get('/login', cookies={'RSNASESSION': '123'}, status_code=302)
7
+
8
+ build = "2021.04.01 at 11:04:40 CDT"
9
+ ip = "10.42.0.83"
10
+ java = "1.7"
11
+ ctpy_mock.get('/server', text=f"""<?xml version="1.0" encoding="UTF-8" ?>\n<server build="{build}" ip="{ip}" java="{java}" port="1080" />""")
12
+
13
+ connection = ctpy.connect(ctpy_mock.uri, port=ctpy_mock.port, username="test", password="secret")
14
+
15
+ assert isinstance(connection, CTPYclient)
16
+ assert connection.server.build == build
17
+ assert connection.server.ip == ip
18
+ assert connection.server.java == java
ctpy/version.py ADDED
@@ -0,0 +1,12 @@
1
+ # This file is automatically generated by versioningit during build stage. Do not edit it manually.
2
+ # Configuration is stored in the pyproject.toml file.
3
+
4
+ version = '1.0.1'
5
+ version_tuple = (1, 0, 1)
6
+ base_version = '1.0.1' # Latest released version known at build time
7
+ git_revision = '89d6adf'
8
+ git_revision_full = '89d6adf5e2ce060ebd334f67655bb8d278cfff22'
9
+ git_branch = 'None'
10
+ git_revision_date = '2025-01-16T12:32:55'
11
+ build_date = '2025-01-16T12:36:14'
12
+