Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
python-version-file: "pyproject.toml"
- name: Install dependencies
run: uv sync
- name: Configure environment
Expand Down
30 changes: 30 additions & 0 deletions .github/workflows/publish-client.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Build and Publish Client to PyPI
on:
release:
types: [published]
jobs:
build-and-publish-client:
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/adit-client
permissions:
id-token: write
defaults:
run:
working-directory: adit-client
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
version: "0.6.0"
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
- name: Build wheel and sdist
run: uv build
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Build and Publish Docker Image to GHCR
name: Build and Push Docker Image to GHCR
on:
release:
types: [published]
Expand Down
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ FROM builder-base AS development
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-project
uv sync --frozen --no-install-project --no-group client

RUN playwright install --with-deps chromium

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

ADD . /app

RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
uv sync --frozen --no-dev --no-group client
32 changes: 32 additions & 0 deletions adit-client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# ADIT Client

## About

ADIT Client is the official Python client of [ADIT (Automated DICOM Transfer)](https://github.com/openradx/adit).

## Usage

### Prerequisites

- Generate an API token in your ADIT profile.
- Make sure you have the permissions to access the ADIT API.
- Also make sure you have access to the DICOM nodes you want query.

### Code

```python
server_url = "https://adit" # The host URL of the ADIT server
auth_token = "my_token" # The authentication token generated in your profile
client = AditClient(server_url=server_url, auth_token=auth_token)

# Search for studies. The first parameter is the AE title of the DICOM server
# you want to query.
studies = client.search_for_studies("ORTHANC1", {"PatientName": "Doe, John"})

# The client returns pydicom datasets.
study_descriptions = [study.StudyDescription for study in studies]
```

## License

- AGPL 3.0 or later
4 changes: 4 additions & 0 deletions adit-client/adit_client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .client import AditClient

__all__ = ["AditClient"]
__version__ = "0.0.0"
208 changes: 208 additions & 0 deletions adit-client/adit_client/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import importlib.metadata
from typing import Iterator

from dicognito.anonymizer import Anonymizer
from dicognito.value_keeper import ValueKeeper
from dicomweb_client import DICOMwebClient, session_utils
from pydicom import Dataset

DEFAULT_SKIP_ELEMENTS_ANONYMIZATION = [
"AcquisitionDate",
"AcquisitionDateTime",
"AcquisitionTime",
"ContentDate",
"ContentTime",
"SeriesDate",
"SeriesTime",
"StudyDate",
"StudyTime",
]


class AditClient:
def __init__(
self,
server_url: str,
auth_token: str,
verify: str | bool = True,
trial_protocol_id: str | None = None,
trial_protocol_name: str | None = None,
skip_elements_anonymization: list[str] | None = None,
) -> None:
self.server_url = server_url
self.auth_token = auth_token
self.verify = verify
self.trial_protocol_id = trial_protocol_id
self.trial_protocol_name = trial_protocol_name
self.__version__ = importlib.metadata.version("adit-client")

if skip_elements_anonymization is None:
self.skip_elements_anonymization = DEFAULT_SKIP_ELEMENTS_ANONYMIZATION
else:
self.skip_elements_anonymization = skip_elements_anonymization

def search_for_studies(
self, ae_title: str, query: dict[str, str] | None = None
) -> list[Dataset]:
"""Search for studies."""
results = self._create_dicom_web_client(ae_title).search_for_studies(search_filters=query)
return [Dataset.from_json(result) for result in results]

def search_for_series(
self, ae_title: str, study_uid: str, query: dict[str, str] | None = None
) -> list[Dataset]:
"""Search for series."""
results = self._create_dicom_web_client(ae_title).search_for_series(
study_uid, search_filters=query
)
return [Dataset.from_json(result) for result in results]

def search_for_images(
self,
ae_title: str,
study_uid: str,
series_uid: str,
query: dict[str, str] | None = None,
) -> list[Dataset]:
"""Search for images."""
results = self._create_dicom_web_client(ae_title).search_for_instances(
study_uid, series_uid, search_filters=query
)
return [Dataset.from_json(result) for result in results]

def retrieve_study(
self, ae_title: str, study_uid: str, pseudonym: str | None = None
) -> list[Dataset]:
"""Retrieve all instances of a study."""
images = self._create_dicom_web_client(ae_title).retrieve_study(study_uid)

anonymizer: Anonymizer | None = None
if pseudonym is not None:
anonymizer = self._setup_anonymizer()

return [self._handle_dataset(image, anonymizer, pseudonym) for image in images]

def iter_study(
self, ae_title: str, study_uid: str, pseudonym: str | None = None
) -> Iterator[Dataset]:
"""Iterate over all instances of a study."""
images = self._create_dicom_web_client(ae_title).iter_study(study_uid)

anonymizer: Anonymizer | None = None
if pseudonym is not None:
anonymizer = self._setup_anonymizer()

for image in images:
yield self._handle_dataset(image, anonymizer, pseudonym)

def retrieve_series(
self,
ae_title: str,
study_uid: str,
series_uid: str,
pseudonym: str | None = None,
) -> list[Dataset]:
"""Retrieve all instances of a series."""
images = self._create_dicom_web_client(ae_title).retrieve_series(
study_uid, series_instance_uid=series_uid
)

anonymizer: Anonymizer | None = None
if pseudonym is not None:
anonymizer = self._setup_anonymizer()

return [self._handle_dataset(image, anonymizer, pseudonym) for image in images]

def iter_series(
self,
ae_title: str,
study_uid: str,
series_uid: str,
pseudonym: str | None = None,
) -> Iterator[Dataset]:
"""Iterate over all instances of a series."""
images = self._create_dicom_web_client(ae_title).iter_series(
study_uid, series_instance_uid=series_uid
)

anonymizer: Anonymizer | None = None
if pseudonym is not None:
anonymizer = self._setup_anonymizer()

for image in images:
yield self._handle_dataset(image, anonymizer, pseudonym)

def retrieve_image(
self,
ae_title: str,
study_uid: str,
series_uid: str,
image_uid: str,
pseudonym: str | None = None,
) -> Dataset:
"""Retrieve an image."""
image = self._create_dicom_web_client(ae_title).retrieve_instance(
study_uid, series_uid, image_uid
)

anonymizer: Anonymizer | None = None
if pseudonym is not None:
anonymizer = self._setup_anonymizer()

return self._handle_dataset(image, anonymizer, pseudonym)

def store_images(self, ae_title: str, images: list[Dataset]) -> Dataset:
"""Store images."""
return self._create_dicom_web_client(ae_title).store_instances(images)

def _create_dicom_web_client(self, ae_title: str) -> DICOMwebClient:
session = session_utils.create_session()

if isinstance(self.verify, bool):
session.verify = self.verify
else:
session = session_utils.add_certs_to_session(session=session, ca_bundle=self.verify)

return DICOMwebClient(
session=session,
url=f"{self.server_url}/api/dicom-web/{ae_title}",
qido_url_prefix="qidors",
wado_url_prefix="wadors",
stow_url_prefix="stowrs",
headers={
"Authorization": f"Token {self.auth_token}",
"User-Agent": f"python-adit_client/{self.__version__}",
},
)

def _setup_anonymizer(self) -> Anonymizer:
anonymizer = Anonymizer()
for element in self.skip_elements_anonymization:
anonymizer.add_element_handler(ValueKeeper(element))
return anonymizer

def _handle_dataset(
self, ds: Dataset, anonymizer: Anonymizer | None, pseudonym: str | None
) -> Dataset:
# Similar to what ADIT does in core/processors.py

if self.trial_protocol_id is not None:
ds.ClinicalTrialProtocolID = self.trial_protocol_id

if self.trial_protocol_name is not None:
ds.ClinicalTrialProtocolName = self.trial_protocol_name

if pseudonym is not None:
assert anonymizer is not None
anonymizer.anonymize(ds)
ds.PatientID = pseudonym
ds.PatientName = pseudonym

if pseudonym and self.trial_protocol_id:
session_id = f"{ds.StudyDate}-{ds.StudyTime}"
ds.PatientComments = (
f"Project:{self.trial_protocol_id} Subject:{pseudonym} "
f"Session:{pseudonym}_{session_id}"
)

return ds
Empty file.
12 changes: 12 additions & 0 deletions adit-client/adit_client/utils/testing_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from adit_radis_shared.accounts.models import User
from adit_radis_shared.common.utils.testing_helpers import add_user_to_group
from adit_radis_shared.token_authentication.models import Token
from django.contrib.auth.models import Group


def create_admin_with_group_and_token():
user: User = User.objects.create_superuser("admin")
group = Group.objects.create(name="Staff")
add_user_to_group(user, group)
_, token = Token.objects.create_token(user, "", None)
return user, group, token
48 changes: 48 additions & 0 deletions adit-client/notebooks/adit_client prod.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import os\n",
"\n",
"from dotenv import load_dotenv\n",
"\n",
"from adit_client import AditClient\n",
"\n",
"load_dotenv(\"../../.env.prod\", override=True)\n",
"\n",
"server_url = f\"http://localhost:{os.environ['WEB_DEV_PORT']}\"\n",
"auth_token = os.environ[\"SUPERUSER_AUTH_TOKEN\"]\n",
"\n",
"client = AditClient(server_url, auth_token, verify=\"xxx\")\n",
"\n",
"client.search_for_studies(\"ORTHANC1\", {\"PatientID\": \"1001\"})"
]
}
],
"metadata": {
"kernelspec": {
"display_name": ".venv",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.3"
},
"orig_nbformat": 4
},
"nbformat": 4,
"nbformat_minor": 2
}
Loading
Loading