-
Notifications
You must be signed in to change notification settings - Fork 70
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Overhaul how date & time parsing works.
This commit breaks FHIRDate into four classes: - FHIRDate - FHIRDateTime - FHIRInstant - FHIRTime BREAKING CHANGES: - Obviously, some previously-FHIRDate fields will now parse as FHIRDateTime, FHIRInstant, or FHIRTime fields instead (as appropriate). - These new classes have different field names for the python object version of the JSON value. They use `.datetime` or `.time` instead of `.date`. BUG FIXES: - FHIR `time` fields are now correctly parsed. Previously, a time of "10:12:14" would result in a **date** of "1001-01-01" - Passing too much detail to a `date` field or too little detail to an `instant` field will now correctly throw a validation error. For example, a Patient.birthDate field with a time. Or an Observation.issued field with just a year. - Sub-seconds would be incorrectly chopped off of a `datetime`'s `.isostring` (which the FHIR spec allows us to do) and an `instant`'s `.isostring` (which the FHIR spec **does not** allow us to do). The `.date` Python representation and the `.as_json()` call would both work correctly and keep the sub-seconds. Only `.isostring` was affected. IMPROVEMENTS: - Leap seconds are now half-supported. The FHIR spec says clients "SHOULD accept and handle leap seconds gracefully", which we do... By dropping the leap second on the floor and rolling back to :59. But this is an improvement on previous behavior of a validation error. The `.as_json()` value will still preserve the leap second. - The Python object field is now always the appropriate type and name (FHIRDate.date is datetime.date, FHIRDateTime.datetime and FHIRInstant.datetime are datetime.datetime, and FHIRTime.time is datetime.time. Previously, a `datetime` field might result in a datetime.date if only given a date portion. (Which isn't entirely wrong, but consistently providing the same data type is useful.) - The dependency on isodate can now be dropped. It is lightly maintained and the stdlib can handle most of its job nowadays. - Much better class documentation for what sort of things are supported and which are not.
- Loading branch information
Showing
11 changed files
with
540 additions
and
83 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,123 @@ | ||
"""Private classes to help with date & time support.""" | ||
# 2014-2024, SMART Health IT. | ||
|
||
import datetime | ||
from typing import Union | ||
|
||
|
||
class _FHIRDateTimeMixin: | ||
""" | ||
Private mixin to provide helper methods for our date and time classes. | ||
Users of this mixin need to provide _REGEX and _FIELD properties and a from_string() method. | ||
""" | ||
|
||
def __init__(self, jsonval: Union[str, None] = None): | ||
super().__init__() | ||
|
||
setattr(self, self._FIELD, None) | ||
|
||
if jsonval is not None: | ||
if not isinstance(jsonval, str): | ||
raise TypeError("Expecting string when initializing {}, but got {}" | ||
.format(type(self), type(jsonval))) | ||
if not self._REGEX.fullmatch(jsonval): | ||
raise ValueError("does not match expected format") | ||
setattr(self, self._FIELD, self._from_string(jsonval)) | ||
|
||
self._orig_json: Union[str, None] = jsonval | ||
|
||
def __setattr__(self, prop, value): | ||
if self._FIELD == prop: | ||
self._orig_json = None | ||
object.__setattr__(self, prop, value) | ||
|
||
@property | ||
def isostring(self) -> Union[str, None]: | ||
""" | ||
Returns a standardized ISO 8601 version of the Python representation of the FHIR JSON. | ||
Note that this may not be a fully accurate version of the input JSON. | ||
In particular, it will convert partial dates like "2024" to full dates like "2024-01-01". | ||
It will also normalize the timezone, if present. | ||
""" | ||
py_value = getattr(self, self._FIELD) | ||
if py_value is None: | ||
return None | ||
return py_value.isoformat() | ||
|
||
def as_json(self) -> Union[str, None]: | ||
"""Returns the original JSON string used to create this instance.""" | ||
if self._orig_json is not None: | ||
return self._orig_json | ||
return self.isostring | ||
|
||
@classmethod | ||
def with_json(cls, jsonobj: Union[str, list]): | ||
""" Initialize a date from an ISO date string. | ||
""" | ||
if isinstance(jsonobj, str): | ||
return cls(jsonobj) | ||
|
||
if isinstance(jsonobj, list): | ||
return [cls(jsonval) for jsonval in jsonobj] | ||
|
||
raise TypeError("`cls.with_json()` only takes string or list of strings, but you provided {}" | ||
.format(type(jsonobj))) | ||
|
||
@classmethod | ||
def with_json_and_owner(cls, jsonobj: Union[str, list], owner): | ||
""" Added for compatibility reasons to FHIRElement; "owner" is | ||
discarded. | ||
""" | ||
return cls.with_json(jsonobj) | ||
|
||
@staticmethod | ||
def _strip_leap_seconds(value: str) -> str: | ||
""" | ||
Manually ignore leap seconds by clamping the seconds value to 59. | ||
Python native times don't support them (at the time of this writing, but also watch | ||
https://bugs.python.org/issue23574). For example, the stdlib's datetime.fromtimestamp() | ||
also clamps to 59 if the system gives it leap seconds. | ||
But FHIR allows leap seconds and says receiving code SHOULD accept them, | ||
so we should be graceful enough to at least not throw a ValueError, | ||
even though we can't natively represent the most-correct time. | ||
""" | ||
# We can get away with such relaxed replacement because we are already regex-certified | ||
# and ":60" can't show up anywhere but seconds. | ||
return value.replace(":60", ":59") | ||
|
||
@staticmethod | ||
def _parse_partial(value: str, date_cls): | ||
""" | ||
Handle partial dates like 1970 or 1980-12. | ||
FHIR allows them, but Python's datetime classes do not natively parse them. | ||
""" | ||
# Note that `value` has already been regex-certified by this point, | ||
# so we don't have to handle really wild strings. | ||
if len(value) < 10: | ||
pieces = value.split("-") | ||
if len(pieces) == 1: | ||
return date_cls(int(pieces[0]), 1, 1) | ||
else: | ||
return date_cls(int(pieces[0]), int(pieces[1]), 1) | ||
return date_cls.fromisoformat(value) | ||
|
||
@classmethod | ||
def _parse_date(cls, value: str) -> datetime.date: | ||
return cls._parse_partial(value, datetime.date) | ||
|
||
@classmethod | ||
def _parse_datetime(cls, value: str) -> datetime.datetime: | ||
# Until we depend on Python 3.11+, manually handle Z | ||
value = value.replace("Z", "+00:00") | ||
value = cls._strip_leap_seconds(value) | ||
return cls._parse_partial(value, datetime.datetime) | ||
|
||
@classmethod | ||
def _parse_time(cls, value: str) -> datetime.time: | ||
value = cls._strip_leap_seconds(value) | ||
return datetime.time.fromisoformat(value) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -157,5 +157,4 @@ def delete(self): | |
return None | ||
|
||
|
||
from . import fhirdate | ||
from . import fhirelementfactory |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,79 +1,48 @@ | ||
#!/usr/bin/env python | ||
# -*- coding: utf-8 -*- | ||
# | ||
# Facilitate working with dates. | ||
# 2014, SMART Health IT. | ||
"""Facilitate working with FHIR date fields.""" | ||
# 2024, SMART Health IT. | ||
|
||
import sys | ||
import logging | ||
import isodate | ||
import datetime | ||
import re | ||
from typing import Any, Union | ||
|
||
from ._dateutils import _FHIRDateTimeMixin | ||
|
||
class FHIRDate(object): | ||
""" Facilitate working with dates. | ||
- `date`: datetime object representing the receiver's date-time | ||
|
||
class FHIRDate(_FHIRDateTimeMixin): | ||
""" | ||
|
||
def __init__(self, jsonval=None): | ||
self.date = None | ||
if jsonval is not None: | ||
isstr = isinstance(jsonval, str) | ||
if not isstr and sys.version_info[0] < 3: # Python 2.x has 'str' and 'unicode' | ||
isstr = isinstance(jsonval, basestring) | ||
if not isstr: | ||
raise TypeError("Expecting string when initializing {}, but got {}" | ||
.format(type(self), type(jsonval))) | ||
try: | ||
if 'T' in jsonval: | ||
self.date = isodate.parse_datetime(jsonval) | ||
else: | ||
self.date = isodate.parse_date(jsonval) | ||
except Exception as e: | ||
logging.warning("Failed to initialize FHIRDate from \"{}\": {}" | ||
.format(jsonval, e)) | ||
|
||
self.origval = jsonval | ||
|
||
def __setattr__(self, prop, value): | ||
if 'date' == prop: | ||
self.origval = None | ||
object.__setattr__(self, prop, value) | ||
|
||
@property | ||
def isostring(self): | ||
if self.date is None: | ||
return None | ||
if isinstance(self.date, datetime.datetime): | ||
return isodate.datetime_isoformat(self.date) | ||
return isodate.date_isoformat(self.date) | ||
|
||
@classmethod | ||
def with_json(cls, jsonobj): | ||
""" Initialize a date from an ISO date string. | ||
""" | ||
isstr = isinstance(jsonobj, str) | ||
if not isstr and sys.version_info[0] < 3: # Python 2.x has 'str' and 'unicode' | ||
isstr = isinstance(jsonobj, basestring) | ||
if isstr: | ||
return cls(jsonobj) | ||
|
||
if isinstance(jsonobj, list): | ||
return [cls(jsonval) for jsonval in jsonobj] | ||
|
||
raise TypeError("`cls.with_json()` only takes string or list of strings, but you provided {}" | ||
.format(type(jsonobj))) | ||
|
||
A convenience class for working with FHIR dates in Python. | ||
http://hl7.org/fhir/R4/datatypes.html#date | ||
Converting to a Python representation does require some compromises: | ||
- This class will convert partial dates ("reduced precision dates") like "2024" into full | ||
dates using the earliest possible time (in this example, "2024-01-01") because Python's | ||
date class does not support partial dates. | ||
If such compromise is not useful for you, avoid using the `date` or `isostring` | ||
properties and just use the `as_json()` method in order to work with the original, | ||
exact string. | ||
Public properties: | ||
- `date`: datetime.date representing the JSON value | ||
- `isostring`: an ISO 8601 string version of the above Python object | ||
Public methods: | ||
- `as_json`: returns the original JSON used to construct the instance | ||
""" | ||
|
||
def __init__(self, jsonval: Union[str, None] = None): | ||
self.date: Union[datetime.date, None] = None | ||
super().__init__(jsonval) | ||
|
||
################################## | ||
# Private properties and methods # | ||
################################## | ||
|
||
# Pulled from spec for date | ||
_REGEX = re.compile(r"([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(0[1-9]|1[0-2])(-(0[1-9]|[1-2][0-9]|3[0-1]))?)?") | ||
_FIELD = "date" | ||
|
||
@classmethod | ||
def with_json_and_owner(cls, jsonobj, owner): | ||
""" Added for compatibility reasons to FHIRElement; "owner" is | ||
discarded. | ||
""" | ||
return cls.with_json(jsonobj) | ||
|
||
def as_json(self): | ||
if self.origval is not None: | ||
return self.origval | ||
return self.isostring | ||
|
||
def _from_string(cls, value: str) -> Any: | ||
return cls._parse_date(value) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
"""Facilitate working with FHIR datetime fields.""" | ||
# 2024, SMART Health IT. | ||
|
||
import datetime | ||
import re | ||
from typing import Any, Union | ||
|
||
from ._dateutils import _FHIRDateTimeMixin | ||
|
||
|
||
class FHIRDateTime(_FHIRDateTimeMixin): | ||
""" | ||
A convenience class for working with FHIR datetimes in Python. | ||
http://hl7.org/fhir/R4/datatypes.html#datetime | ||
Converting to a Python representation does require some compromises: | ||
- This class will convert partial dates ("reduced precision dates") like "2024" into full | ||
naive datetimes using the earliest possible time (in this example, "2024-01-01T00:00:00") | ||
because Python's datetime class does not support partial dates. | ||
- FHIR allows arbitrary sub-second precision, but Python only holds microseconds. | ||
- Leap seconds (:60) will be changed to the 59th second (:59) because Python's time classes | ||
do not support leap seconds. | ||
If such compromise is not useful for you, avoid using the `datetime` or `isostring` | ||
properties and just use the `as_json()` method in order to work with the original, | ||
exact string. | ||
Public properties: | ||
- `datetime`: datetime.datetime representing the JSON value (naive or aware) | ||
- `isostring`: an ISO 8601 string version of the above Python object | ||
Public methods: | ||
- `as_json`: returns the original JSON used to construct the instance | ||
""" | ||
|
||
def __init__(self, jsonval: Union[str, None] = None): | ||
self.datetime: Union[datetime.datetime, None] = None | ||
super().__init__(jsonval) | ||
|
||
################################## | ||
# Private properties and methods # | ||
################################## | ||
|
||
# Pulled from spec for datetime | ||
_REGEX = re.compile(r"([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(0[1-9]|1[0-2])(-(0[1-9]|[1-2][0-9]|3[0-1])(T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]+)?(Z|(\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)))?)?)?") | ||
_FIELD = "datetime" | ||
|
||
@classmethod | ||
def _from_string(cls, value: str) -> Any: | ||
return cls._parse_datetime(value) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
"""Facilitate working with FHIR instant fields.""" | ||
# 2024, SMART Health IT. | ||
|
||
import datetime | ||
import re | ||
from typing import Any, Union | ||
|
||
from ._dateutils import _FHIRDateTimeMixin | ||
|
||
|
||
class FHIRInstant(_FHIRDateTimeMixin): | ||
""" | ||
A convenience class for working with FHIR instants in Python. | ||
http://hl7.org/fhir/R4/datatypes.html#instant | ||
Converting to a Python representation does require some compromises: | ||
- FHIR allows arbitrary sub-second precision, but Python only holds microseconds. | ||
- Leap seconds (:60) will be changed to the 59th second (:59) because Python's time classes | ||
do not support leap seconds. | ||
If such compromise is not useful for you, avoid using the `datetime` or `isostring` | ||
properties and just use the `as_json()` method in order to work with the original, | ||
exact string. | ||
Public properties: | ||
- `datetime`: datetime.datetime representing the JSON value (aware only) | ||
- `isostring`: an ISO 8601 string version of the above Python object | ||
Public methods: | ||
- `as_json`: returns the original JSON used to construct the instance | ||
""" | ||
|
||
def __init__(self, jsonval: Union[str, None] = None): | ||
self.datetime: Union[datetime.datetime, None] = None | ||
super().__init__(jsonval) | ||
|
||
################################## | ||
# Private properties and methods # | ||
################################## | ||
|
||
# Pulled from spec for instant | ||
_REGEX = re.compile(r"([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]+)?(Z|(\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00))") | ||
_FIELD = "datetime" | ||
|
||
@classmethod | ||
def _from_string(cls, value: str) -> Any: | ||
return cls._parse_datetime(value) |
Oops, something went wrong.