Skip to content

Commit a760820

Browse files
authored
DICOMweb URL manipulation for Google Cloud Healthcare API (#48)
* Remove obsolete reference to `dicomweb_client.error` module from `package.rst`. * Add `create_chc_uri()` to `uri.py`. * Repackage `create_chc_uri()` under new class `CloudHealthcareDICOMStore`. * Update documentation for `CloudHealthcareDICOMStore`. * Add `CloudHealthcareDICOMStore.from_url()`. * Rename `CloudHealthcareDICOMStore` to `GoogleCloudHealthcare`. * Add tests for `GoogleCloudHealthcare` validators. * Cosmetic internal change in `GoogleCloudHealthcare`. * Rename `from_url()` to `from_string()` in `GoogleCloudHealthcare`. * Rename `GoogleCloudHealthcare` to `GoogleCloudHealthcareURL`. * Change base class of `GoogleCloudHealthcareURL` from `attr` to `dataclass`. * Fix `flake8` errors in `uri.py`. * Add `dataclasses` backport for Python 3.6. * Move `uri.GoogleCloudHealthcareURL` to new module `ext.gcp.uri`. * Remove dead code from `tests/test_uri.py`. * Disable lint check for unused import. * Copy `session_utils.create_session_from_gcp_credentials()` to `ext.gcp.session_utils` and add deprecation warning to the original. * Move dependency check in `session_utils.create_session_from_gcp_credentials()` to `ext.gcp.session_utils`.
1 parent e568079 commit a760820

File tree

9 files changed

+275
-25
lines changed

9 files changed

+275
-25
lines changed

docs/installation.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ Pre-build package available at PyPi:
2828
2929
pip install dicomweb-client
3030
31+
Additional dependencies required for extensions compatible with
32+
`Google Cloud Platform (GCP)`_ may be installed as:
33+
34+
.. _Google Cloud Platform (GCP): https://cloud.google.com
35+
36+
.. code-block:: none
37+
38+
pip install dicomweb-client[gcp]
39+
3140
Source code available at Github:
3241

3342
.. code-block:: none

docs/package.rst

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,6 @@ dicomweb\_client.cli module
2828
.. autoprogram:: dicomweb_client.cli:_get_parser()
2929
:prog: dicomweb_client
3030

31-
dicomweb\_client.error module
32-
+++++++++++++++++++++++++++++
33-
34-
.. automodule:: dicomweb_client.error
35-
:members:
36-
:undoc-members:
37-
:show-inheritance:
38-
3931
dicomweb\_client.log module
4032
+++++++++++++++++++++++++++
4133

@@ -53,9 +45,25 @@ dicomweb\_client.session_utils module
5345
:show-inheritance:
5446

5547
dicomweb\_client.uri module
56-
+++++++++++++++++++++++++++++++++++++
48+
+++++++++++++++++++++++++++
5749

5850
.. automodule:: dicomweb_client.uri
5951
:members:
6052
:undoc-members:
6153
:show-inheritance:
54+
55+
dicomweb\_client.ext.gcp.session_utils module
56+
+++++++++++++++++++++++++++++++++++++++++++++
57+
58+
.. automodule:: dicomweb_client.ext.gcp.session_utils
59+
:members:
60+
:undoc-members:
61+
:show-inheritance:
62+
63+
dicomweb\_client.ext.gcp.uri module
64+
+++++++++++++++++++++++++++++++++++
65+
66+
.. automodule:: dicomweb_client.ext.gcp.uri
67+
:members:
68+
:undoc-members:
69+
:show-inheritance:

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
package_dir={'': 'src'},
4343
extras_require={
4444
'gcp': [
45+
'dataclasses>=0.8; python_version=="3.6"',
4546
'google-auth>=1.6',
4647
'google-oauth>=1.0',
4748
],
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Vendor-specific extensions of the `dicomweb_client` package."""
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Google Cloud Platform (GCP) compatible extensions of `dicomweb_client`.
2+
3+
Modules under this package may require additional dependencies. Instructions for
4+
installation are available in the Installation Guide here:
5+
https://dicomweb-client.readthedocs.io/en/latest/installation.html#installation-guide
6+
7+
For further details about GCP, visit: https://cloud.google.com
8+
"""
9+
10+
from dicomweb_client.ext.gcp.uri import GoogleCloudHealthcareURL # noqa
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Session management utilities for Google Cloud Platform (GCP)."""
2+
from typing import Optional, Any
3+
4+
try:
5+
import google.auth
6+
from google.auth.transport import requests as google_requests
7+
except ImportError:
8+
raise ImportError(
9+
'The `dicomweb-client` package needs to be installed with the '
10+
'"gcp" extra requirements to use this module, as follows: '
11+
'`pip install dicomweb-client[gcp]`')
12+
import requests
13+
14+
15+
def create_session_from_gcp_credentials(
16+
google_credentials: Optional[Any] = None
17+
) -> requests.Session:
18+
"""Creates an authorized session for Google Cloud Platform.
19+
20+
Parameters
21+
----------
22+
google_credentials: Any
23+
Google Cloud credentials.
24+
(see https://cloud.google.com/docs/authentication/production
25+
for more information on Google Cloud authentication).
26+
If not set, will be initialized to ``google.auth.default()``.
27+
28+
Returns
29+
-------
30+
requests.Session
31+
Google Cloud authorized session.
32+
"""
33+
if google_credentials is None:
34+
google_credentials, _ = google.auth.default(
35+
scopes=['https://www.googleapis.com/auth/cloud-platform']
36+
)
37+
return google_requests.AuthorizedSession(google_credentials)

src/dicomweb_client/ext/gcp/uri.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Utilities for Google Cloud Healthcare DICOMweb API URI manipulation.
2+
3+
For details, visit: https://cloud.google.com/healthcare
4+
"""
5+
import dataclasses
6+
import re
7+
8+
9+
# Used for Project ID and Location validation in `GoogleCloudHealthcareURL`.
10+
_REGEX_ID_1 = re.compile(r'[\w-]+')
11+
# Used for Dataset ID and DICOM Store ID validation in
12+
# `GoogleCloudHealthcareURL`.
13+
_REGEX_ID_2 = re.compile(r'[\w.-]+')
14+
# Regex for the DICOM Store suffix for the Google Cloud Healthcare API endpoint.
15+
_STORE_REGEX = re.compile(
16+
(r'projects/(%s)/locations/(%s)/datasets/(%s)/'
17+
r'dicomStores/(%s)/dicomWeb$') % (_REGEX_ID_1.pattern,
18+
_REGEX_ID_1.pattern,
19+
_REGEX_ID_2.pattern,
20+
_REGEX_ID_2.pattern))
21+
# The URL for the Google Cloud Healthcare API endpoint.
22+
_CHC_API_URL = 'https://healthcare.googleapis.com/v1'
23+
# GCP resource name validation error.
24+
_GCP_RESOURCE_ERROR_TMPL = ('`{attribute}` must match regex {regex}. Actual '
25+
'value: {value!r}')
26+
27+
28+
@dataclasses.dataclass(eq=True, frozen=True)
29+
class GoogleCloudHealthcareURL:
30+
"""Base URL container for DICOM Stores under the `Google Cloud Healthcare API`_.
31+
32+
This class facilitates the parsing and creation of :py:attr:`URI.base_url`
33+
corresponding to DICOMweb API Service URLs under the v1_ API. The URLs are
34+
of the form:
35+
``https://healthcare.googleapis.com/v1/projects/{project_id}/locations/{location}/datasets/{dataset_id}/dicomStores/{dicom_store_id}/dicomWeb``
36+
37+
.. _Google Cloud Healthcare API: https://cloud.google.com/healthcare
38+
.. _v1: https://cloud.google.com/healthcare/docs/how-tos/transition-guide
39+
40+
Attributes:
41+
project_id: str
42+
The ID of the `GCP Project
43+
<https://cloud.google.com/healthcare/docs/concepts/projects-datasets-data-stores#projects>`_
44+
that contains the DICOM Store.
45+
location: str
46+
The `Region name
47+
<https://cloud.google.com/healthcare/docs/concepts/regions>`_ of the
48+
geographic location configured for the Dataset that contains the
49+
DICOM Store.
50+
dataset_id: str
51+
The ID of the `Dataset
52+
<https://cloud.google.com/healthcare/docs/concepts/projects-datasets-data-stores#datasets_and_data_stores>`_
53+
that contains the DICOM Store.
54+
dicom_store_id: str
55+
The ID of the `DICOM Store
56+
<https://cloud.google.com/healthcare/docs/concepts/dicom#dicom_stores>`_.
57+
"""
58+
project_id: str
59+
location: str
60+
dataset_id: str
61+
dicom_store_id: str
62+
63+
def __post_init__(self) -> None:
64+
"""Performs input sanity checks."""
65+
for regex, attribute, value in (
66+
(_REGEX_ID_1, 'project_id', self.project_id),
67+
(_REGEX_ID_1, 'location', self.location),
68+
(_REGEX_ID_2, 'dataset_id', self.dataset_id),
69+
(_REGEX_ID_2, 'dicom_store_id', self.dicom_store_id)):
70+
if regex.fullmatch(value) is None:
71+
raise ValueError(_GCP_RESOURCE_ERROR_TMPL.format(
72+
attribute=attribute, regex=regex, value=value))
73+
74+
def __str__(self) -> str:
75+
"""Returns a string URL for use as :py:attr:`URI.base_url`.
76+
77+
See class docstring for the returned URL format.
78+
"""
79+
return (f'{_CHC_API_URL}/'
80+
f'projects/{self.project_id}/'
81+
f'locations/{self.location}/'
82+
f'datasets/{self.dataset_id}/'
83+
f'dicomStores/{self.dicom_store_id}/dicomWeb')
84+
85+
@classmethod
86+
def from_string(cls, base_url: str) -> 'GoogleCloudHealthcareURL':
87+
"""Creates an instance from ``base_url``.
88+
89+
Parameters
90+
----------
91+
base_url: str
92+
The URL for the DICOMweb API Service endpoint corresponding to a
93+
`CHC API DICOM Store
94+
<https://cloud.google.com/healthcare/docs/concepts/dicom#dicom_stores>`_.
95+
See class docstring for supported formats.
96+
97+
Raises
98+
------
99+
ValueError
100+
If ``base_url`` does not match the specifications in the class
101+
docstring.
102+
"""
103+
if not base_url.startswith(f'{_CHC_API_URL}/'):
104+
raise ValueError('Invalid CHC API v1 URL: {base_url!r}')
105+
resource_suffix = base_url[len(_CHC_API_URL) + 1:]
106+
107+
store_match = _STORE_REGEX.match(resource_suffix)
108+
if store_match is None:
109+
raise ValueError(
110+
'Invalid CHC API v1 DICOM Store name: {resource_suffix!r}')
111+
112+
return cls(store_match.group(1), store_match.group(2),
113+
store_match.group(3), store_match.group(4))

src/dicomweb_client/session_utils.py

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22
import os
33
from typing import Optional, Any
4+
import warnings
45

56
import requests
67

@@ -128,20 +129,12 @@ def create_session_from_gcp_credentials(
128129
-------
129130
requests.Session
130131
Google cloud authorized session
131-
132132
"""
133-
try:
134-
from google.auth.transport import requests as google_requests
135-
if google_credentials is None:
136-
import google.auth
137-
google_credentials, _ = google.auth.default(
138-
scopes=['https://www.googleapis.com/auth/cloud-platform']
139-
)
140-
except ImportError:
141-
raise ImportError(
142-
'The dicomweb-client package needs to be installed with the '
143-
'"gcp" extra requirements to support interaction with the '
144-
'Google Cloud Healthcare API: pip install dicomweb-client[gcp]'
145-
)
146-
logger.debug('initialize, authenticate and authorize HTTP session')
147-
return google_requests.AuthorizedSession(google_credentials)
133+
warnings.warn(
134+
'This method shall be deprecated in a future release. Prefer using the '
135+
'underlying implementation directly, now moved to '
136+
'`dicomweb_client.ext.gcp.session_utils`.',
137+
DeprecationWarning)
138+
import dicomweb_client.ext.gcp.session_utils as gcp_session_utils
139+
return gcp_session_utils.create_session_from_gcp_credentials(
140+
google_credentials)

tests/test_ext_gcp_uri.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Unit tests for `dicomweb_client.ext.gcp.uri` module."""
2+
from dicomweb_client.ext.gcp.uri import GoogleCloudHealthcareURL
3+
4+
import pytest
5+
6+
_PROJECT_ID = 'my-project44'
7+
_LOCATION = 'us-central1'
8+
_DATASET_ID = 'my-44.dataset'
9+
_DICOM_STORE_ID = 'my.d1com_store'
10+
_CHC_API_URL = 'https://healthcare.googleapis.com/v1'
11+
_CHC_BASE_URL = (
12+
f'{_CHC_API_URL}/'
13+
f'projects/{_PROJECT_ID}/locations/{_LOCATION}/'
14+
f'datasets/{_DATASET_ID}/dicomStores/{_DICOM_STORE_ID}/dicomWeb')
15+
16+
17+
def test_chc_dicom_store_str():
18+
"""Locks down `GoogleCloudHealthcareURL.__str__()`."""
19+
assert str(
20+
GoogleCloudHealthcareURL(
21+
_PROJECT_ID,
22+
_LOCATION,
23+
_DATASET_ID,
24+
_DICOM_STORE_ID)) == _CHC_BASE_URL
25+
26+
27+
@pytest.mark.parametrize('name', ['hmmm.1', '#95', '43/'])
28+
def test_chc_invalid_project_or_location(name):
29+
"""Tests for bad `project_id`, `location`."""
30+
with pytest.raises(ValueError, match='project_id'):
31+
GoogleCloudHealthcareURL(name, _LOCATION, _DATASET_ID, _DICOM_STORE_ID)
32+
with pytest.raises(ValueError, match='location'):
33+
GoogleCloudHealthcareURL(
34+
_PROJECT_ID, name, _DATASET_ID, _DICOM_STORE_ID)
35+
36+
37+
@pytest.mark.parametrize('name', ['hmmm.!', '#95', '43/'])
38+
def test_chc_invalid_dataset_or_store(name):
39+
"""Tests for bad `dataset_id`, `dicom_store_id`."""
40+
with pytest.raises(ValueError, match='dataset_id'):
41+
GoogleCloudHealthcareURL(_PROJECT_ID, _LOCATION, name, _DICOM_STORE_ID)
42+
with pytest.raises(ValueError, match='dicom_store_id'):
43+
GoogleCloudHealthcareURL(
44+
_PROJECT_ID, _LOCATION, _DATASET_ID, name)
45+
46+
47+
@pytest.mark.parametrize('url', [f'{_CHC_API_URL}beta', 'https://some.url'])
48+
def test_chc_from_string_invalid_api(url):
49+
"""Tests for bad API URL error`GoogleCloudHealthcareURL.from_string()`."""
50+
with pytest.raises(ValueError, match='v1 URL'):
51+
GoogleCloudHealthcareURL.from_string(url)
52+
53+
54+
@pytest.mark.parametrize('url', [
55+
f'{_CHC_BASE_URL}/', # Trailing slash disallowed.
56+
f'{_CHC_API_URL}/project/p/locations/l/datasets/d/dicomStores/ds/dicomWeb',
57+
f'{_CHC_API_URL}/projects/p/location/l/datasets/d/dicomStores/ds/dicomWeb',
58+
f'{_CHC_API_URL}/projects/p/locations/l/dataset/d/dicomStores/ds/dicomWeb',
59+
f'{_CHC_API_URL}/projects/p/locations/l/datasets/d/dicomStore/ds/dicomWeb',
60+
f'{_CHC_API_URL}/locations/l/datasets/d/dicomStores/ds/dicomWeb',
61+
f'{_CHC_API_URL}/projects/p/datasets/d/dicomStores/ds/dicomWeb',
62+
f'{_CHC_API_URL}/projects/p/locations/l/dicomStores/ds/dicomWeb',
63+
f'{_CHC_API_URL}/projects/p/locations/l/datasets/d/dicomWeb',
64+
f'{_CHC_API_URL}/projects/p/locations/l//datasets/d/dicomStores/ds/dicomWeb'
65+
])
66+
def test_chc_from_string_invalid_store_name(url):
67+
"""Tests for bad Store name `GoogleCloudHealthcareURL.from_string()`."""
68+
with pytest.raises(ValueError, match='v1 DICOM'):
69+
GoogleCloudHealthcareURL.from_string(url)
70+
71+
72+
def test_chc_from_string_success():
73+
"""Locks down `GoogleCloudHealthcareURL.from_string()`."""
74+
store = GoogleCloudHealthcareURL.from_string(_CHC_BASE_URL)
75+
assert store.project_id == _PROJECT_ID
76+
assert store.location == _LOCATION
77+
assert store.dataset_id == _DATASET_ID
78+
assert store.dicom_store_id == _DICOM_STORE_ID

0 commit comments

Comments
 (0)