Skip to content

Commit 757a9ae

Browse files
authored
Create core, refactor ABCs (#30)
* Create core, refactor ABCs * Comment around geolocation extension URL
1 parent ae3d9e7 commit 757a9ae

File tree

10 files changed

+158
-158
lines changed

10 files changed

+158
-158
lines changed

phdi/fhir/geospatial/__init__.py

Lines changed: 3 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,4 @@
1-
from abc import ABC, abstractmethod
2-
from typing import List
1+
from phdi.fhir.geospatial.core import BaseFhirGeocodeClient
2+
from phdi.fhir.geospatial.smarty import SmartyFhirGeocodeClient
33

4-
5-
class FhirGeocodeClient(ABC):
6-
"""
7-
A basic abstract class representing a vendor-agnostic geocoder client
8-
designed as a wrapper to process FHIR-based data (including resources
9-
and bundles). Requires implementing classes to define methods to
10-
geocode from both bundles and resources. Callers should use the
11-
provided interface functions (e.g. geocode_resource) to interact with
12-
the underlying vendor-specific client property.
13-
"""
14-
15-
@abstractmethod
16-
def geocode_resource(self, resource: dict) -> dict:
17-
"""
18-
Function that uses the implementing client to perform geocoding
19-
on the provided resource, which is passed in as a dictionary.
20-
"""
21-
pass
22-
23-
@abstractmethod
24-
def geocode_bundle(self, bundle: List[dict]):
25-
"""
26-
Function that uses the implementing client to perform geocoding
27-
on all supported resources in the provided FHIR bundle, which
28-
is passed in as a list of FHIR-formatted dictionaries.
29-
"""
30-
pass
31-
32-
@staticmethod
33-
def _store_lat_long_extension(address: dict, lat: float, long: float) -> None:
34-
"""
35-
Given a FHIR-formatted dictionary holding address fields, add
36-
appropriate extension data for latitude and longitude, if the fields
37-
aren't already present.
38-
39-
:param address: A FHIR formatted dictionary holding address fields
40-
:param lat: The latitude to add to the FHIR data as an extension
41-
:param long: The longitude to add to the FHIR data as an extension
42-
:return: None, but leaves the given dictionary with an "extension"
43-
property if one is not already present, in which lat and long
44-
occur as FHIR-classified geolocation elements
45-
"""
46-
if "extension" not in address:
47-
address["extension"] = []
48-
address["extension"].append(
49-
{
50-
"url": "http://hl7.org/fhir/StructureDefinition/geolocation",
51-
"extension": [
52-
{
53-
"url": "latitude",
54-
"valueDecimal": lat,
55-
},
56-
{
57-
"url": "longitude",
58-
"valueDecimal": long,
59-
},
60-
],
61-
}
62-
)
4+
__all__ = ("BaseFhirGeocodeClient", "SmartyFhirGeocodeClient")

phdi/fhir/geospatial/core.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from abc import ABC, abstractmethod
2+
from typing import List
3+
4+
5+
class BaseFhirGeocodeClient(ABC):
6+
"""
7+
A basic abstract class representing a vendor-agnostic geocoder client
8+
designed as a wrapper to process FHIR-based data (including resources
9+
and bundles). Requires implementing classes to define methods to
10+
geocode from both bundles and resources. Callers should use the
11+
provided interface functions (e.g. geocode_resource) to interact with
12+
the underlying vendor-specific client property.
13+
"""
14+
15+
@abstractmethod
16+
def geocode_resource(self, resource: dict) -> dict:
17+
"""
18+
Function that uses the implementing client to perform geocoding
19+
on the provided resource, which is passed in as a dictionary.
20+
"""
21+
pass
22+
23+
@abstractmethod
24+
def geocode_bundle(self, bundle: List[dict]):
25+
"""
26+
Function that uses the implementing client to perform geocoding
27+
on all supported resources in the provided FHIR bundle, which
28+
is passed in as a list of FHIR-formatted dictionaries.
29+
"""
30+
pass
31+
32+
@staticmethod
33+
def _store_lat_long_extension(address: dict, lat: float, long: float) -> None:
34+
"""
35+
Given a FHIR-formatted dictionary holding address fields, add
36+
appropriate extension data for latitude and longitude, if the fields
37+
aren't already present. The extension data is added directly to the
38+
input dictionary, leaving lat and long as FHIR-identified
39+
geolocation elements.
40+
41+
:param address: A FHIR formatted dictionary holding address fields
42+
:param lat: The latitude to add to the FHIR data as an extension
43+
:param long: The longitude to add to the FHIR data as an extension
44+
"""
45+
if "extension" not in address:
46+
address["extension"] = []
47+
48+
# Append with a properly resolving URL for FHIR's canonical geospatial
49+
# structure definition, as all extensions are required to have this
50+
# attribute; see https://www.hl7.org/fhir/extensibility.html
51+
address["extension"].append(
52+
{
53+
"url": "http://hl7.org/fhir/StructureDefinition/geolocation",
54+
"extension": [
55+
{
56+
"url": "latitude",
57+
"valueDecimal": lat,
58+
},
59+
{
60+
"url": "longitude",
61+
"valueDecimal": long,
62+
},
63+
],
64+
}
65+
)

phdi/fhir/geospatial/smarty.py

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
from phdi.geospatial.smarty import SmartyGeocodeClient
2-
from phdi.fhir.geospatial import FhirGeocodeClient
3-
4-
from smartystreets_python_sdk import us_street
5-
61
from typing import List
72
from copy import copy
3+
from smartystreets_python_sdk import us_street
84

9-
from ...utils import get_one_line_address
5+
from phdi.geospatial.smarty import SmartyGeocodeClient
6+
from phdi.fhir.geospatial.core import BaseFhirGeocodeClient
7+
from phdi.fhir.utils import get_one_line_address
108

119

12-
class SmartyFhirGeocodeClient(FhirGeocodeClient):
10+
class SmartyFhirGeocodeClient(BaseFhirGeocodeClient):
1311
"""
1412
Implementation of a geocoding client designed to handle FHIR-
1513
formatted data using the SmartyStreets API.
@@ -38,16 +36,15 @@ def geocode_client(self) -> us_street.Client:
3836
def geocode_resource(self, resource: dict, overwrite=True) -> dict:
3937
"""
4038
Performs geocoding on one or more addresses in a given FHIR
41-
resource. Currently supported resource types are:
39+
resource and returns either the result or a copy thereof.
40+
Currently supported resource types are:
4241
4342
- Patient
4443
4544
:param resource: The resource whose addresses should be geocoded
4645
:param overwrite: Whether to save the geocoding information over
4746
the raw data, or to create a copy of the given data and write
4847
over that instead. Defaults to True (write over given data).
49-
:return: The resource (or a copy thereof) with geocoded
50-
information added
5148
"""
5249
if not overwrite:
5350
resource = copy.deepcopy(resource)
@@ -89,8 +86,6 @@ def geocode_bundle(self, bundle: List[dict], overwrite=True) -> List[dict]:
8986
:param overwrite: Whether to overwrite the address data in the given
9087
bundle's resources (True), or whether to create a copy of the bundle
9188
and overwrite that instead (False). Defaults to True.
92-
:return: The given FHIR bundle (or a copy thereof) where all
93-
resources have updated geocoded information
9489
"""
9590
if not overwrite:
9691
bundle = copy.deepcopy(bundle)

phdi/geospatial/__init__.py

Lines changed: 3 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,4 @@
1-
from typing import List, Optional, Union
2-
from dataclasses import dataclass
3-
from abc import ABC, abstractmethod
1+
from phdi.geospatial.core import GeocodeResult, BaseGeocodeClient
2+
from phdi.geospatial.smarty import SmartyGeocodeClient
43

5-
6-
@dataclass
7-
class GeocodeResult:
8-
"""
9-
A basic dataclass representing a successful geocoding response.
10-
Based on the field nomenclature of a FHIR address, specified at
11-
https://www.hl7.org/fhir/datatypes.html#Address.
12-
"""
13-
14-
line: List[str]
15-
city: str
16-
state: str
17-
postal_code: str
18-
county_fips: str
19-
lat: float
20-
lng: float
21-
district: Optional[str] = None
22-
country: Optional[str] = None
23-
county_name: Optional[str] = None
24-
precision: Optional[str] = None
25-
26-
27-
class GeocodeClient(ABC):
28-
"""
29-
A basic abstract class representing a vendor-agnostic geocoder client.
30-
Requires implementing classes to define methods to geocode from both
31-
strings and dictionaries. Callers should use the provided interface
32-
functions (e.g. geocode_from_str) to interact with the underlying
33-
vendor-specific client property.
34-
"""
35-
36-
@abstractmethod
37-
def geocode_from_str(self, address: str) -> Union[GeocodeResult, None]:
38-
"""
39-
Function that uses the implementing client to perform geocoding
40-
on the provided address, which is formatted as a string.
41-
"""
42-
pass
43-
44-
@abstractmethod
45-
def geocode_from_dict(self, address: dict) -> Union[GeocodeResult, None]:
46-
"""
47-
Function that uses the implementing client to perform geocoding
48-
on the provided address, which is given as a dictionary. The given
49-
dictionary should conform to standard nomenclature around address
50-
fields, including:
51-
52-
street: the number and street address
53-
street2: additional street level information (if needed)
54-
apartment: apartment or suite number (if needed)
55-
city: city to geocode
56-
state: state to geocode
57-
postal_code: the postal code to use
58-
urbanization: urbanization code for area, sector, or regional
59-
development (only used for Puerto Rican addresses)
60-
61-
There is no minimum number of fields that must be specified to use this
62-
function; however, a minimum of street, city, and state are suggested
63-
for the best matches.
64-
"""
65-
pass
4+
__all__ = ("GeocodeResult", "BaseGeocodeClient", "SmartyGeocodeClient")

phdi/geospatial/core.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from typing import List, Optional, Union
2+
from dataclasses import dataclass
3+
from abc import ABC, abstractmethod
4+
5+
6+
@dataclass
7+
class GeocodeResult:
8+
"""
9+
A basic dataclass representing a successful geocoding response.
10+
Based on the field nomenclature of a FHIR address, specified at
11+
https://www.hl7.org/fhir/datatypes.html#Address.
12+
"""
13+
14+
line: List[str]
15+
city: str
16+
state: str
17+
postal_code: str
18+
county_fips: str
19+
lat: float
20+
lng: float
21+
district: Optional[str] = None
22+
country: Optional[str] = None
23+
county_name: Optional[str] = None
24+
precision: Optional[str] = None
25+
26+
27+
class BaseGeocodeClient(ABC):
28+
"""
29+
A basic abstract class representing a vendor-agnostic geocoder client.
30+
Requires implementing classes to define methods to geocode from both
31+
strings and dictionaries. Callers should use the provided interface
32+
functions (e.g. geocode_from_str) to interact with the underlying
33+
vendor-specific client property.
34+
"""
35+
36+
@abstractmethod
37+
def geocode_from_str(self, address: str) -> Union[GeocodeResult, None]:
38+
"""
39+
Function that uses the implementing client to perform geocoding
40+
on the provided address, which is formatted as a string.
41+
"""
42+
pass
43+
44+
@abstractmethod
45+
def geocode_from_dict(self, address: dict) -> Union[GeocodeResult, None]:
46+
"""
47+
Function that uses the implementing client to perform geocoding
48+
on the provided address, which is given as a dictionary. The given
49+
dictionary should conform to standard nomenclature around address
50+
fields, including:
51+
52+
street: the number and street address
53+
street2: additional street level information (if needed)
54+
apartment: apartment or suite number (if needed)
55+
city: city to geocode
56+
state: state to geocode
57+
postal_code: the postal code to use
58+
urbanization: urbanization code for area, sector, or regional
59+
development (only used for Puerto Rican addresses)
60+
61+
There is no minimum number of fields that must be specified to use this
62+
function; however, a minimum of street, city, and state are suggested
63+
for the best matches.
64+
"""
65+
pass

phdi/geospatial/smarty.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
from phdi.geospatial import GeocodeClient, GeocodeResult
2-
1+
from typing import List, Union
32
from smartystreets_python_sdk import StaticCredentials, ClientBuilder
43
from smartystreets_python_sdk import us_street
54
from smartystreets_python_sdk.us_street.lookup import Lookup
65

7-
from typing import List, Union
6+
from phdi.geospatial.core import BaseGeocodeClient, GeocodeResult
87

98

10-
class SmartyGeocodeClient(GeocodeClient):
9+
class SmartyGeocodeClient(BaseGeocodeClient):
1110
"""
1211
Implementation of a geocoding client using the SmartyStreets API.
1312
Requires an authorization ID as well as an authentication token
@@ -45,8 +44,6 @@ def geocode_from_str(self, address: str) -> Union[GeocodeResult, None]:
4544
to precisely geocode the address, so no result is returned.
4645
4746
:param address: The address to geocode, given as a string
48-
:return: A GeocodeResult containing address information, if lookup was
49-
successful. Otherwise, None.
5047
"""
5148
lookup = Lookup(street=address)
5249
self.__client.send_lookup(lookup)
@@ -59,8 +56,6 @@ def geocode_from_dict(self, address: dict) -> Union[GeocodeResult, None]:
5956
returned, otherwise the function returns None.
6057
6158
:param address: a dictionary with fields outlined above
62-
:return: A GeocodeResult containing address information, if lookup was
63-
successful. Otherwise, None.
6459
"""
6560

6661
# Configure the lookup with whatever provided address values
@@ -83,12 +78,11 @@ def geocode_from_dict(self, address: dict) -> Union[GeocodeResult, None]:
8378
def _parse_smarty_result(lookup):
8479
"""
8580
Private helper function to parse a returned Smarty geocoding result into
86-
our standardized GeocodeResult class.
81+
our standardized GeocodeResult class. If the Smarty lookup is null or
82+
doesn't include latitude and longitude information, returns None
83+
instead.
8784
8885
:param lookup: The us_street.lookup client instantiated for geocoding
89-
:return: A GeocodeResult containing address information, if the given
90-
lookup contains a non-null result that includes latitude and
91-
longitude information. Otherwise, None.
9286
"""
9387
# Valid responses have results with lat/long
9488
if lookup.result and lookup.result[0].metadata.latitude:

secret.out

Whitespace-only changes.

tests/fhir/geospatial/test_fhir_geospatial.py renamed to tests/fhir/geospatial/test_fhir_core.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
from phdi.fhir.geospatial import FhirGeocodeClient
2-
31
import json
42
import pathlib
53

4+
from phdi.fhir.geospatial.core import BaseFhirGeocodeClient
5+
66

77
def test_store_lat_long():
88
bundle = json.load(
@@ -14,7 +14,7 @@ def test_store_lat_long():
1414
)
1515
patient = bundle["entry"][1]["resource"]
1616
address = patient.get("address", {})[0]
17-
FhirGeocodeClient._store_lat_long_extension(address, 40.032, -64.987)
17+
BaseFhirGeocodeClient._store_lat_long_extension(address, 40.032, -64.987)
1818
assert address["extension"] is not None
1919

2020
stored_both = False

0 commit comments

Comments
 (0)