diff --git a/docs/installation.rst b/docs/installation.rst index 49fa157..2159057 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -28,6 +28,15 @@ Pre-build package available at PyPi: pip install dicomweb-client +Additional dependencies required for extensions compatible with +`Google Cloud Platform (GCP)`_ may be installed as: + +.. _Google Cloud Platform (GCP): https://cloud.google.com + +.. code-block:: none + + pip install dicomweb-client[gcp] + Source code available at Github: .. code-block:: none diff --git a/docs/package.rst b/docs/package.rst index 2ccb856..e34644a 100644 --- a/docs/package.rst +++ b/docs/package.rst @@ -28,14 +28,6 @@ dicomweb\_client.cli module .. autoprogram:: dicomweb_client.cli:_get_parser() :prog: dicomweb_client -dicomweb\_client.error module -+++++++++++++++++++++++++++++ - -.. automodule:: dicomweb_client.error - :members: - :undoc-members: - :show-inheritance: - dicomweb\_client.log module +++++++++++++++++++++++++++ @@ -53,9 +45,25 @@ dicomweb\_client.session_utils module :show-inheritance: dicomweb\_client.uri module -+++++++++++++++++++++++++++++++++++++ ++++++++++++++++++++++++++++ .. automodule:: dicomweb_client.uri :members: :undoc-members: :show-inheritance: + +dicomweb\_client.ext.gcp.session_utils module ++++++++++++++++++++++++++++++++++++++++++++++ + +.. automodule:: dicomweb_client.ext.gcp.session_utils + :members: + :undoc-members: + :show-inheritance: + +dicomweb\_client.ext.gcp.uri module ++++++++++++++++++++++++++++++++++++ + +.. automodule:: dicomweb_client.ext.gcp.uri + :members: + :undoc-members: + :show-inheritance: diff --git a/setup.py b/setup.py index e055280..42fe3c9 100644 --- a/setup.py +++ b/setup.py @@ -42,6 +42,7 @@ package_dir={'': 'src'}, extras_require={ 'gcp': [ + 'dataclasses>=0.8; python_version=="3.6"', 'google-auth>=1.6', 'google-oauth>=1.0', ], diff --git a/src/dicomweb_client/ext/__init__.py b/src/dicomweb_client/ext/__init__.py new file mode 100644 index 0000000..2e98b57 --- /dev/null +++ b/src/dicomweb_client/ext/__init__.py @@ -0,0 +1 @@ +"""Vendor-specific extensions of the `dicomweb_client` package.""" diff --git a/src/dicomweb_client/ext/gcp/__init__.py b/src/dicomweb_client/ext/gcp/__init__.py new file mode 100644 index 0000000..294364f --- /dev/null +++ b/src/dicomweb_client/ext/gcp/__init__.py @@ -0,0 +1,10 @@ +"""Google Cloud Platform (GCP) compatible extensions of `dicomweb_client`. + +Modules under this package may require additional dependencies. Instructions for +installation are available in the Installation Guide here: +https://dicomweb-client.readthedocs.io/en/latest/installation.html#installation-guide + +For further details about GCP, visit: https://cloud.google.com +""" + +from dicomweb_client.ext.gcp.uri import GoogleCloudHealthcareURL # noqa diff --git a/src/dicomweb_client/ext/gcp/session_utils.py b/src/dicomweb_client/ext/gcp/session_utils.py new file mode 100644 index 0000000..a5271e0 --- /dev/null +++ b/src/dicomweb_client/ext/gcp/session_utils.py @@ -0,0 +1,37 @@ +"""Session management utilities for Google Cloud Platform (GCP).""" +from typing import Optional, Any + +try: + import google.auth + from google.auth.transport import requests as google_requests +except ImportError: + raise ImportError( + 'The `dicomweb-client` package needs to be installed with the ' + '"gcp" extra requirements to use this module, as follows: ' + '`pip install dicomweb-client[gcp]`') +import requests + + +def create_session_from_gcp_credentials( + google_credentials: Optional[Any] = None + ) -> requests.Session: + """Creates an authorized session for Google Cloud Platform. + + Parameters + ---------- + google_credentials: Any + Google Cloud credentials. + (see https://cloud.google.com/docs/authentication/production + for more information on Google Cloud authentication). + If not set, will be initialized to ``google.auth.default()``. + + Returns + ------- + requests.Session + Google Cloud authorized session. + """ + if google_credentials is None: + google_credentials, _ = google.auth.default( + scopes=['https://www.googleapis.com/auth/cloud-platform'] + ) + return google_requests.AuthorizedSession(google_credentials) diff --git a/src/dicomweb_client/ext/gcp/uri.py b/src/dicomweb_client/ext/gcp/uri.py new file mode 100644 index 0000000..55c9fdd --- /dev/null +++ b/src/dicomweb_client/ext/gcp/uri.py @@ -0,0 +1,113 @@ +"""Utilities for Google Cloud Healthcare DICOMweb API URI manipulation. + +For details, visit: https://cloud.google.com/healthcare +""" +import dataclasses +import re + + +# Used for Project ID and Location validation in `GoogleCloudHealthcareURL`. +_REGEX_ID_1 = re.compile(r'[\w-]+') +# Used for Dataset ID and DICOM Store ID validation in +# `GoogleCloudHealthcareURL`. +_REGEX_ID_2 = re.compile(r'[\w.-]+') +# Regex for the DICOM Store suffix for the Google Cloud Healthcare API endpoint. +_STORE_REGEX = re.compile( + (r'projects/(%s)/locations/(%s)/datasets/(%s)/' + r'dicomStores/(%s)/dicomWeb$') % (_REGEX_ID_1.pattern, + _REGEX_ID_1.pattern, + _REGEX_ID_2.pattern, + _REGEX_ID_2.pattern)) +# The URL for the Google Cloud Healthcare API endpoint. +_CHC_API_URL = 'https://healthcare.googleapis.com/v1' +# GCP resource name validation error. +_GCP_RESOURCE_ERROR_TMPL = ('`{attribute}` must match regex {regex}. Actual ' + 'value: {value!r}') + + +@dataclasses.dataclass(eq=True, frozen=True) +class GoogleCloudHealthcareURL: + """Base URL container for DICOM Stores under the `Google Cloud Healthcare API`_. + + This class facilitates the parsing and creation of :py:attr:`URI.base_url` + corresponding to DICOMweb API Service URLs under the v1_ API. The URLs are + of the form: + ``https://healthcare.googleapis.com/v1/projects/{project_id}/locations/{location}/datasets/{dataset_id}/dicomStores/{dicom_store_id}/dicomWeb`` + + .. _Google Cloud Healthcare API: https://cloud.google.com/healthcare + .. _v1: https://cloud.google.com/healthcare/docs/how-tos/transition-guide + + Attributes: + project_id: str + The ID of the `GCP Project + `_ + that contains the DICOM Store. + location: str + The `Region name + `_ of the + geographic location configured for the Dataset that contains the + DICOM Store. + dataset_id: str + The ID of the `Dataset + `_ + that contains the DICOM Store. + dicom_store_id: str + The ID of the `DICOM Store + `_. + """ + project_id: str + location: str + dataset_id: str + dicom_store_id: str + + def __post_init__(self) -> None: + """Performs input sanity checks.""" + for regex, attribute, value in ( + (_REGEX_ID_1, 'project_id', self.project_id), + (_REGEX_ID_1, 'location', self.location), + (_REGEX_ID_2, 'dataset_id', self.dataset_id), + (_REGEX_ID_2, 'dicom_store_id', self.dicom_store_id)): + if regex.fullmatch(value) is None: + raise ValueError(_GCP_RESOURCE_ERROR_TMPL.format( + attribute=attribute, regex=regex, value=value)) + + def __str__(self) -> str: + """Returns a string URL for use as :py:attr:`URI.base_url`. + + See class docstring for the returned URL format. + """ + return (f'{_CHC_API_URL}/' + f'projects/{self.project_id}/' + f'locations/{self.location}/' + f'datasets/{self.dataset_id}/' + f'dicomStores/{self.dicom_store_id}/dicomWeb') + + @classmethod + def from_string(cls, base_url: str) -> 'GoogleCloudHealthcareURL': + """Creates an instance from ``base_url``. + + Parameters + ---------- + base_url: str + The URL for the DICOMweb API Service endpoint corresponding to a + `CHC API DICOM Store + `_. + See class docstring for supported formats. + + Raises + ------ + ValueError + If ``base_url`` does not match the specifications in the class + docstring. + """ + if not base_url.startswith(f'{_CHC_API_URL}/'): + raise ValueError('Invalid CHC API v1 URL: {base_url!r}') + resource_suffix = base_url[len(_CHC_API_URL) + 1:] + + store_match = _STORE_REGEX.match(resource_suffix) + if store_match is None: + raise ValueError( + 'Invalid CHC API v1 DICOM Store name: {resource_suffix!r}') + + return cls(store_match.group(1), store_match.group(2), + store_match.group(3), store_match.group(4)) diff --git a/src/dicomweb_client/session_utils.py b/src/dicomweb_client/session_utils.py index 6dfd583..a6f004a 100644 --- a/src/dicomweb_client/session_utils.py +++ b/src/dicomweb_client/session_utils.py @@ -1,6 +1,7 @@ import logging import os from typing import Optional, Any +import warnings import requests @@ -128,20 +129,12 @@ def create_session_from_gcp_credentials( ------- requests.Session Google cloud authorized session - """ - try: - from google.auth.transport import requests as google_requests - if google_credentials is None: - import google.auth - google_credentials, _ = google.auth.default( - scopes=['https://www.googleapis.com/auth/cloud-platform'] - ) - except ImportError: - raise ImportError( - 'The dicomweb-client package needs to be installed with the ' - '"gcp" extra requirements to support interaction with the ' - 'Google Cloud Healthcare API: pip install dicomweb-client[gcp]' - ) - logger.debug('initialize, authenticate and authorize HTTP session') - return google_requests.AuthorizedSession(google_credentials) + warnings.warn( + 'This method shall be deprecated in a future release. Prefer using the ' + 'underlying implementation directly, now moved to ' + '`dicomweb_client.ext.gcp.session_utils`.', + DeprecationWarning) + import dicomweb_client.ext.gcp.session_utils as gcp_session_utils + return gcp_session_utils.create_session_from_gcp_credentials( + google_credentials) diff --git a/tests/test_ext_gcp_uri.py b/tests/test_ext_gcp_uri.py new file mode 100644 index 0000000..2d74838 --- /dev/null +++ b/tests/test_ext_gcp_uri.py @@ -0,0 +1,78 @@ +"""Unit tests for `dicomweb_client.ext.gcp.uri` module.""" +from dicomweb_client.ext.gcp.uri import GoogleCloudHealthcareURL + +import pytest + +_PROJECT_ID = 'my-project44' +_LOCATION = 'us-central1' +_DATASET_ID = 'my-44.dataset' +_DICOM_STORE_ID = 'my.d1com_store' +_CHC_API_URL = 'https://healthcare.googleapis.com/v1' +_CHC_BASE_URL = ( + f'{_CHC_API_URL}/' + f'projects/{_PROJECT_ID}/locations/{_LOCATION}/' + f'datasets/{_DATASET_ID}/dicomStores/{_DICOM_STORE_ID}/dicomWeb') + + +def test_chc_dicom_store_str(): + """Locks down `GoogleCloudHealthcareURL.__str__()`.""" + assert str( + GoogleCloudHealthcareURL( + _PROJECT_ID, + _LOCATION, + _DATASET_ID, + _DICOM_STORE_ID)) == _CHC_BASE_URL + + +@pytest.mark.parametrize('name', ['hmmm.1', '#95', '43/']) +def test_chc_invalid_project_or_location(name): + """Tests for bad `project_id`, `location`.""" + with pytest.raises(ValueError, match='project_id'): + GoogleCloudHealthcareURL(name, _LOCATION, _DATASET_ID, _DICOM_STORE_ID) + with pytest.raises(ValueError, match='location'): + GoogleCloudHealthcareURL( + _PROJECT_ID, name, _DATASET_ID, _DICOM_STORE_ID) + + +@pytest.mark.parametrize('name', ['hmmm.!', '#95', '43/']) +def test_chc_invalid_dataset_or_store(name): + """Tests for bad `dataset_id`, `dicom_store_id`.""" + with pytest.raises(ValueError, match='dataset_id'): + GoogleCloudHealthcareURL(_PROJECT_ID, _LOCATION, name, _DICOM_STORE_ID) + with pytest.raises(ValueError, match='dicom_store_id'): + GoogleCloudHealthcareURL( + _PROJECT_ID, _LOCATION, _DATASET_ID, name) + + +@pytest.mark.parametrize('url', [f'{_CHC_API_URL}beta', 'https://some.url']) +def test_chc_from_string_invalid_api(url): + """Tests for bad API URL error`GoogleCloudHealthcareURL.from_string()`.""" + with pytest.raises(ValueError, match='v1 URL'): + GoogleCloudHealthcareURL.from_string(url) + + +@pytest.mark.parametrize('url', [ + f'{_CHC_BASE_URL}/', # Trailing slash disallowed. + f'{_CHC_API_URL}/project/p/locations/l/datasets/d/dicomStores/ds/dicomWeb', + f'{_CHC_API_URL}/projects/p/location/l/datasets/d/dicomStores/ds/dicomWeb', + f'{_CHC_API_URL}/projects/p/locations/l/dataset/d/dicomStores/ds/dicomWeb', + f'{_CHC_API_URL}/projects/p/locations/l/datasets/d/dicomStore/ds/dicomWeb', + f'{_CHC_API_URL}/locations/l/datasets/d/dicomStores/ds/dicomWeb', + f'{_CHC_API_URL}/projects/p/datasets/d/dicomStores/ds/dicomWeb', + f'{_CHC_API_URL}/projects/p/locations/l/dicomStores/ds/dicomWeb', + f'{_CHC_API_URL}/projects/p/locations/l/datasets/d/dicomWeb', + f'{_CHC_API_URL}/projects/p/locations/l//datasets/d/dicomStores/ds/dicomWeb' +]) +def test_chc_from_string_invalid_store_name(url): + """Tests for bad Store name `GoogleCloudHealthcareURL.from_string()`.""" + with pytest.raises(ValueError, match='v1 DICOM'): + GoogleCloudHealthcareURL.from_string(url) + + +def test_chc_from_string_success(): + """Locks down `GoogleCloudHealthcareURL.from_string()`.""" + store = GoogleCloudHealthcareURL.from_string(_CHC_BASE_URL) + assert store.project_id == _PROJECT_ID + assert store.location == _LOCATION + assert store.dataset_id == _DATASET_ID + assert store.dicom_store_id == _DICOM_STORE_ID