Skip to content

Commit ba3f558

Browse files
authored
Merge pull request #188 from openradx:adit-client-into-adit
Add adit-client source to adit repo
2 parents 8d8e0d0 + f494d12 commit ba3f558

File tree

17 files changed

+746
-7
lines changed

17 files changed

+746
-7
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
- name: Setup Python
2121
uses: actions/setup-python@v5
2222
with:
23-
python-version-file: ".python-version"
23+
python-version-file: "pyproject.toml"
2424
- name: Install dependencies
2525
run: uv sync
2626
- name: Configure environment
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: Build and Publish Client to PyPI
2+
on:
3+
release:
4+
types: [published]
5+
jobs:
6+
build-and-publish-client:
7+
runs-on: ubuntu-latest
8+
environment:
9+
name: pypi
10+
url: https://pypi.org/p/adit-client
11+
permissions:
12+
id-token: write
13+
defaults:
14+
run:
15+
working-directory: adit-client
16+
steps:
17+
- name: Checkout repository
18+
uses: actions/checkout@v4
19+
- name: Install uv
20+
uses: astral-sh/setup-uv@v5
21+
with:
22+
version: "0.6.0"
23+
- name: Setup Python
24+
uses: actions/setup-python@v5
25+
with:
26+
python-version-file: "pyproject.toml"
27+
- name: Build wheel and sdist
28+
run: uv build
29+
- name: Publish package distributions to PyPI
30+
uses: pypa/gh-action-pypi-publish@release/v1
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Build and Publish Docker Image to GHCR
1+
name: Build and Push Docker Image to GHCR
22
on:
33
release:
44
types: [published]

Dockerfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ FROM builder-base AS development
3535
RUN --mount=type=cache,target=/root/.cache/uv \
3636
--mount=type=bind,source=uv.lock,target=uv.lock \
3737
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
38-
uv sync --frozen --no-install-project
38+
uv sync --frozen --no-install-project --no-group client
3939

4040
RUN playwright install --with-deps chromium
4141

@@ -51,9 +51,9 @@ FROM builder-base AS production
5151
RUN --mount=type=cache,target=/root/.cache/uv \
5252
--mount=type=bind,source=uv.lock,target=uv.lock \
5353
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
54-
uv sync --frozen --no-install-project --no-dev
54+
uv sync --frozen --no-install-project --no-dev --no-group client
5555

5656
ADD . /app
5757

5858
RUN --mount=type=cache,target=/root/.cache/uv \
59-
uv sync --frozen --no-dev
59+
uv sync --frozen --no-dev --no-group client

adit-client/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# ADIT Client
2+
3+
## About
4+
5+
ADIT Client is the official Python client of [ADIT (Automated DICOM Transfer)](https://github.com/openradx/adit).
6+
7+
## Usage
8+
9+
### Prerequisites
10+
11+
- Generate an API token in your ADIT profile.
12+
- Make sure you have the permissions to access the ADIT API.
13+
- Also make sure you have access to the DICOM nodes you want query.
14+
15+
### Code
16+
17+
```python
18+
server_url = "https://adit" # The host URL of the ADIT server
19+
auth_token = "my_token" # The authentication token generated in your profile
20+
client = AditClient(server_url=server_url, auth_token=auth_token)
21+
22+
# Search for studies. The first parameter is the AE title of the DICOM server
23+
# you want to query.
24+
studies = client.search_for_studies("ORTHANC1", {"PatientName": "Doe, John"})
25+
26+
# The client returns pydicom datasets.
27+
study_descriptions = [study.StudyDescription for study in studies]
28+
```
29+
30+
## License
31+
32+
- AGPL 3.0 or later
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .client import AditClient
2+
3+
__all__ = ["AditClient"]
4+
__version__ = "0.0.0"

adit-client/adit_client/client.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import importlib.metadata
2+
from typing import Iterator
3+
4+
from dicognito.anonymizer import Anonymizer
5+
from dicognito.value_keeper import ValueKeeper
6+
from dicomweb_client import DICOMwebClient, session_utils
7+
from pydicom import Dataset
8+
9+
DEFAULT_SKIP_ELEMENTS_ANONYMIZATION = [
10+
"AcquisitionDate",
11+
"AcquisitionDateTime",
12+
"AcquisitionTime",
13+
"ContentDate",
14+
"ContentTime",
15+
"SeriesDate",
16+
"SeriesTime",
17+
"StudyDate",
18+
"StudyTime",
19+
]
20+
21+
22+
class AditClient:
23+
def __init__(
24+
self,
25+
server_url: str,
26+
auth_token: str,
27+
verify: str | bool = True,
28+
trial_protocol_id: str | None = None,
29+
trial_protocol_name: str | None = None,
30+
skip_elements_anonymization: list[str] | None = None,
31+
) -> None:
32+
self.server_url = server_url
33+
self.auth_token = auth_token
34+
self.verify = verify
35+
self.trial_protocol_id = trial_protocol_id
36+
self.trial_protocol_name = trial_protocol_name
37+
self.__version__ = importlib.metadata.version("adit-client")
38+
39+
if skip_elements_anonymization is None:
40+
self.skip_elements_anonymization = DEFAULT_SKIP_ELEMENTS_ANONYMIZATION
41+
else:
42+
self.skip_elements_anonymization = skip_elements_anonymization
43+
44+
def search_for_studies(
45+
self, ae_title: str, query: dict[str, str] | None = None
46+
) -> list[Dataset]:
47+
"""Search for studies."""
48+
results = self._create_dicom_web_client(ae_title).search_for_studies(search_filters=query)
49+
return [Dataset.from_json(result) for result in results]
50+
51+
def search_for_series(
52+
self, ae_title: str, study_uid: str, query: dict[str, str] | None = None
53+
) -> list[Dataset]:
54+
"""Search for series."""
55+
results = self._create_dicom_web_client(ae_title).search_for_series(
56+
study_uid, search_filters=query
57+
)
58+
return [Dataset.from_json(result) for result in results]
59+
60+
def search_for_images(
61+
self,
62+
ae_title: str,
63+
study_uid: str,
64+
series_uid: str,
65+
query: dict[str, str] | None = None,
66+
) -> list[Dataset]:
67+
"""Search for images."""
68+
results = self._create_dicom_web_client(ae_title).search_for_instances(
69+
study_uid, series_uid, search_filters=query
70+
)
71+
return [Dataset.from_json(result) for result in results]
72+
73+
def retrieve_study(
74+
self, ae_title: str, study_uid: str, pseudonym: str | None = None
75+
) -> list[Dataset]:
76+
"""Retrieve all instances of a study."""
77+
images = self._create_dicom_web_client(ae_title).retrieve_study(study_uid)
78+
79+
anonymizer: Anonymizer | None = None
80+
if pseudonym is not None:
81+
anonymizer = self._setup_anonymizer()
82+
83+
return [self._handle_dataset(image, anonymizer, pseudonym) for image in images]
84+
85+
def iter_study(
86+
self, ae_title: str, study_uid: str, pseudonym: str | None = None
87+
) -> Iterator[Dataset]:
88+
"""Iterate over all instances of a study."""
89+
images = self._create_dicom_web_client(ae_title).iter_study(study_uid)
90+
91+
anonymizer: Anonymizer | None = None
92+
if pseudonym is not None:
93+
anonymizer = self._setup_anonymizer()
94+
95+
for image in images:
96+
yield self._handle_dataset(image, anonymizer, pseudonym)
97+
98+
def retrieve_series(
99+
self,
100+
ae_title: str,
101+
study_uid: str,
102+
series_uid: str,
103+
pseudonym: str | None = None,
104+
) -> list[Dataset]:
105+
"""Retrieve all instances of a series."""
106+
images = self._create_dicom_web_client(ae_title).retrieve_series(
107+
study_uid, series_instance_uid=series_uid
108+
)
109+
110+
anonymizer: Anonymizer | None = None
111+
if pseudonym is not None:
112+
anonymizer = self._setup_anonymizer()
113+
114+
return [self._handle_dataset(image, anonymizer, pseudonym) for image in images]
115+
116+
def iter_series(
117+
self,
118+
ae_title: str,
119+
study_uid: str,
120+
series_uid: str,
121+
pseudonym: str | None = None,
122+
) -> Iterator[Dataset]:
123+
"""Iterate over all instances of a series."""
124+
images = self._create_dicom_web_client(ae_title).iter_series(
125+
study_uid, series_instance_uid=series_uid
126+
)
127+
128+
anonymizer: Anonymizer | None = None
129+
if pseudonym is not None:
130+
anonymizer = self._setup_anonymizer()
131+
132+
for image in images:
133+
yield self._handle_dataset(image, anonymizer, pseudonym)
134+
135+
def retrieve_image(
136+
self,
137+
ae_title: str,
138+
study_uid: str,
139+
series_uid: str,
140+
image_uid: str,
141+
pseudonym: str | None = None,
142+
) -> Dataset:
143+
"""Retrieve an image."""
144+
image = self._create_dicom_web_client(ae_title).retrieve_instance(
145+
study_uid, series_uid, image_uid
146+
)
147+
148+
anonymizer: Anonymizer | None = None
149+
if pseudonym is not None:
150+
anonymizer = self._setup_anonymizer()
151+
152+
return self._handle_dataset(image, anonymizer, pseudonym)
153+
154+
def store_images(self, ae_title: str, images: list[Dataset]) -> Dataset:
155+
"""Store images."""
156+
return self._create_dicom_web_client(ae_title).store_instances(images)
157+
158+
def _create_dicom_web_client(self, ae_title: str) -> DICOMwebClient:
159+
session = session_utils.create_session()
160+
161+
if isinstance(self.verify, bool):
162+
session.verify = self.verify
163+
else:
164+
session = session_utils.add_certs_to_session(session=session, ca_bundle=self.verify)
165+
166+
return DICOMwebClient(
167+
session=session,
168+
url=f"{self.server_url}/api/dicom-web/{ae_title}",
169+
qido_url_prefix="qidors",
170+
wado_url_prefix="wadors",
171+
stow_url_prefix="stowrs",
172+
headers={
173+
"Authorization": f"Token {self.auth_token}",
174+
"User-Agent": f"python-adit_client/{self.__version__}",
175+
},
176+
)
177+
178+
def _setup_anonymizer(self) -> Anonymizer:
179+
anonymizer = Anonymizer()
180+
for element in self.skip_elements_anonymization:
181+
anonymizer.add_element_handler(ValueKeeper(element))
182+
return anonymizer
183+
184+
def _handle_dataset(
185+
self, ds: Dataset, anonymizer: Anonymizer | None, pseudonym: str | None
186+
) -> Dataset:
187+
# Similar to what ADIT does in core/processors.py
188+
189+
if self.trial_protocol_id is not None:
190+
ds.ClinicalTrialProtocolID = self.trial_protocol_id
191+
192+
if self.trial_protocol_name is not None:
193+
ds.ClinicalTrialProtocolName = self.trial_protocol_name
194+
195+
if pseudonym is not None:
196+
assert anonymizer is not None
197+
anonymizer.anonymize(ds)
198+
ds.PatientID = pseudonym
199+
ds.PatientName = pseudonym
200+
201+
if pseudonym and self.trial_protocol_id:
202+
session_id = f"{ds.StudyDate}-{ds.StudyTime}"
203+
ds.PatientComments = (
204+
f"Project:{self.trial_protocol_id} Subject:{pseudonym} "
205+
f"Session:{pseudonym}_{session_id}"
206+
)
207+
208+
return ds

adit-client/adit_client/utils/__init__.py

Whitespace-only changes.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from adit_radis_shared.accounts.models import User
2+
from adit_radis_shared.common.utils.testing_helpers import add_user_to_group
3+
from adit_radis_shared.token_authentication.models import Token
4+
from django.contrib.auth.models import Group
5+
6+
7+
def create_admin_with_group_and_token():
8+
user: User = User.objects.create_superuser("admin")
9+
group = Group.objects.create(name="Staff")
10+
add_user_to_group(user, group)
11+
_, token = Token.objects.create_token(user, "", None)
12+
return user, group, token
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "code",
5+
"execution_count": null,
6+
"metadata": {},
7+
"outputs": [],
8+
"source": [
9+
"import os\n",
10+
"\n",
11+
"from dotenv import load_dotenv\n",
12+
"\n",
13+
"from adit_client import AditClient\n",
14+
"\n",
15+
"load_dotenv(\"../../.env.prod\", override=True)\n",
16+
"\n",
17+
"server_url = f\"http://localhost:{os.environ['WEB_DEV_PORT']}\"\n",
18+
"auth_token = os.environ[\"SUPERUSER_AUTH_TOKEN\"]\n",
19+
"\n",
20+
"client = AditClient(server_url, auth_token, verify=\"xxx\")\n",
21+
"\n",
22+
"client.search_for_studies(\"ORTHANC1\", {\"PatientID\": \"1001\"})"
23+
]
24+
}
25+
],
26+
"metadata": {
27+
"kernelspec": {
28+
"display_name": ".venv",
29+
"language": "python",
30+
"name": "python3"
31+
},
32+
"language_info": {
33+
"codemirror_mode": {
34+
"name": "ipython",
35+
"version": 3
36+
},
37+
"file_extension": ".py",
38+
"mimetype": "text/x-python",
39+
"name": "python",
40+
"nbconvert_exporter": "python",
41+
"pygments_lexer": "ipython3",
42+
"version": "3.12.3"
43+
},
44+
"orig_nbformat": 4
45+
},
46+
"nbformat": 4,
47+
"nbformat_minor": 2
48+
}

0 commit comments

Comments
 (0)