Skip to content

Commit 15f755b

Browse files
authored
Merge pull request #164 from smart-on-fhir/mikix/dates
Overhaul how date & time parsing works.
2 parents efcaade + 9cbc5ae commit 15f755b

File tree

344 files changed

+2826
-1633
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

344 files changed

+2826
-1633
lines changed

MAINTAINERS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,7 @@ Using flit (*Note*: Alternatively, you can use [twine](https://twine.readthedocs
4040

4141
pip install -U flit
4242
flit publish
43+
44+
### Announce the release
45+
46+
Make a post in the [Zulip channel](https://chat.fhir.org/#narrow/stream/179218-python) for python.

fhir-parser-resources/fhirabstractresource.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,5 @@ def where(cls, struct):
211211
return fhirsearch.FHIRSearch(cls, struct)
212212

213213

214-
from . import fhirdate
215214
from . import fhirsearch
216215
from . import fhirelementfactory

fhir-parser-resources/fhirdate.py

Lines changed: 128 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,157 @@
1-
#!/usr/bin/env python
2-
# -*- coding: utf-8 -*-
3-
#
4-
# Facilitate working with dates.
5-
# 2014, SMART Health IT.
6-
7-
import sys
8-
import logging
9-
import isodate
1+
"""Facilitate working with FHIR dates and times."""
2+
# 2014-2024, SMART Health IT.
3+
104
import datetime
5+
import re
6+
from typing import Any, Union
7+
8+
9+
class FHIRDate:
10+
"""
11+
A convenience class for working with FHIR dates in Python.
12+
13+
http://hl7.org/fhir/R4/datatypes.html#date
1114
12-
logger = logging.getLogger(__name__)
15+
Converting to a Python representation does require some compromises:
16+
- This class will convert partial dates ("reduced precision dates") like "2024" into full
17+
dates using the earliest possible time (in this example, "2024-01-01") because Python's
18+
date class does not support partial dates.
1319
20+
If such compromise is not useful for you, avoid using the `date` or `isostring`
21+
properties and just use the `as_json()` method in order to work with the original,
22+
exact string.
1423
15-
class FHIRDate(object):
16-
""" Facilitate working with dates.
17-
18-
- `date`: datetime object representing the receiver's date-time
24+
For backwards-compatibility reasons, this class is the parent class of FHIRDateTime,
25+
FHIRInstant, and FHIRTime. But they are all separate concepts and in a future major release,
26+
they should be split into entirely separate classes.
27+
28+
Public properties:
29+
- `date`: datetime.date representing the JSON value
30+
- `isostring`: an ISO 8601 string version of the above Python object
31+
32+
Public methods:
33+
- `as_json`: returns the original JSON used to construct the instance
1934
"""
20-
21-
def __init__(self, jsonval=None):
22-
self.date = None
35+
36+
def __init__(self, jsonval: Union[str, None] = None):
37+
self.date: Union[datetime.date, datetime.datetime, datetime.time, None] = None
38+
2339
if jsonval is not None:
24-
isstr = isinstance(jsonval, str)
25-
if not isstr and sys.version_info[0] < 3: # Python 2.x has 'str' and 'unicode'
26-
isstr = isinstance(jsonval, basestring)
27-
if not isstr:
40+
if not isinstance(jsonval, str):
2841
raise TypeError("Expecting string when initializing {}, but got {}"
2942
.format(type(self), type(jsonval)))
30-
try:
31-
if 'T' in jsonval:
32-
self.date = isodate.parse_datetime(jsonval)
33-
else:
34-
self.date = isodate.parse_date(jsonval)
35-
except Exception as e:
36-
logger.warning("Failed to initialize FHIRDate from \"{}\": {}"
37-
.format(jsonval, e))
38-
39-
self.origval = jsonval
40-
43+
if not self._REGEX.fullmatch(jsonval):
44+
raise ValueError("does not match expected format")
45+
self.date = self._from_string(jsonval)
46+
47+
self.origval: Union[str, None] = jsonval
48+
4149
def __setattr__(self, prop, value):
42-
if 'date' == prop:
50+
if prop in {'date', self._FIELD}:
4351
self.origval = None
44-
object.__setattr__(self, prop, value)
45-
52+
# Keep these two fields in sync
53+
object.__setattr__(self, self._FIELD, value)
54+
object.__setattr__(self, "date", value)
55+
else:
56+
object.__setattr__(self, prop, value)
57+
4658
@property
47-
def isostring(self):
59+
def isostring(self) -> Union[str, None]:
60+
"""
61+
Returns a standardized ISO 8601 version of the Python representation of the FHIR JSON.
62+
63+
Note that this may not be a fully accurate version of the input JSON.
64+
In particular, it will convert partial dates like "2024" to full dates like "2024-01-01".
65+
It will also normalize the timezone, if present.
66+
"""
4867
if self.date is None:
4968
return None
50-
if isinstance(self.date, datetime.datetime):
51-
return isodate.datetime_isoformat(self.date)
52-
return isodate.date_isoformat(self.date)
53-
69+
return self.date.isoformat()
70+
5471
@classmethod
55-
def with_json(cls, jsonobj):
72+
def with_json(cls, jsonobj: Union[str, list]):
5673
""" Initialize a date from an ISO date string.
5774
"""
58-
isstr = isinstance(jsonobj, str)
59-
if not isstr and sys.version_info[0] < 3: # Python 2.x has 'str' and 'unicode'
60-
isstr = isinstance(jsonobj, basestring)
61-
if isstr:
75+
if isinstance(jsonobj, str):
6276
return cls(jsonobj)
63-
77+
6478
if isinstance(jsonobj, list):
6579
return [cls(jsonval) for jsonval in jsonobj]
66-
80+
6781
raise TypeError("`cls.with_json()` only takes string or list of strings, but you provided {}"
6882
.format(type(jsonobj)))
69-
83+
7084
@classmethod
71-
def with_json_and_owner(cls, jsonobj, owner):
85+
def with_json_and_owner(cls, jsonobj: Union[str, list], owner):
7286
""" Added for compatibility reasons to FHIRElement; "owner" is
7387
discarded.
7488
"""
7589
return cls.with_json(jsonobj)
76-
77-
def as_json(self):
90+
91+
def as_json(self) -> Union[str, None]:
92+
"""Returns the original JSON string used to create this instance."""
7893
if self.origval is not None:
7994
return self.origval
8095
return self.isostring
81-
96+
97+
##################################
98+
# Private properties and methods #
99+
##################################
100+
101+
# Pulled from spec for date
102+
_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]))?)?")
103+
_FIELD = "date"
104+
105+
@staticmethod
106+
def _parse_partial(value: str, cls):
107+
"""
108+
Handle partial dates like 1970 or 1980-12.
109+
110+
FHIR allows them, but Python's datetime classes do not natively parse them.
111+
"""
112+
# Note that `value` has already been regex-certified by this point,
113+
# so we don't have to handle really wild strings.
114+
if len(value) < 10:
115+
pieces = value.split("-")
116+
if len(pieces) == 1:
117+
return cls(int(pieces[0]), 1, 1)
118+
else:
119+
return cls(int(pieces[0]), int(pieces[1]), 1)
120+
return cls.fromisoformat(value)
121+
122+
@staticmethod
123+
def _parse_date(value: str) -> datetime.date:
124+
return FHIRDate._parse_partial(value, datetime.date)
125+
126+
@staticmethod
127+
def _parse_datetime(value: str) -> datetime.datetime:
128+
# Until we depend on Python 3.11+, manually handle Z
129+
value = value.replace("Z", "+00:00")
130+
value = FHIRDate._strip_leap_seconds(value)
131+
return FHIRDate._parse_partial(value, datetime.datetime)
132+
133+
@staticmethod
134+
def _parse_time(value: str) -> datetime.time:
135+
value = FHIRDate._strip_leap_seconds(value)
136+
return datetime.time.fromisoformat(value)
137+
138+
@staticmethod
139+
def _strip_leap_seconds(value: str) -> str:
140+
"""
141+
Manually ignore leap seconds by clamping the seconds value to 59.
142+
143+
Python native times don't support them (at the time of this writing, but also watch
144+
https://bugs.python.org/issue23574). For example, the stdlib's datetime.fromtimestamp()
145+
also clamps to 59 if the system gives it leap seconds.
146+
147+
But FHIR allows leap seconds and says receiving code SHOULD accept them,
148+
so we should be graceful enough to at least not throw a ValueError,
149+
even though we can't natively represent the most-correct time.
150+
"""
151+
# We can get away with such relaxed replacement because we are already regex-certified
152+
# and ":60" can't show up anywhere but seconds.
153+
return value.replace(":60", ":59")
154+
155+
@staticmethod
156+
def _from_string(value: str) -> Any:
157+
return FHIRDate._parse_date(value)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Facilitate working with FHIR time fields."""
2+
# 2024, SMART Health IT.
3+
4+
import datetime
5+
import re
6+
from typing import Any, Union
7+
8+
from .fhirdate import FHIRDate
9+
10+
11+
# This inherits from FHIRDate as a matter of backwards compatibility.
12+
# (in case anyone was doing isinstance(obj, FHIRDate))
13+
# Next time we bump the major version, we can stop that and also drop the
14+
# backwards-compatible 'date' alias. R4-QUIRK
15+
16+
class FHIRDateTime(FHIRDate):
17+
"""
18+
A convenience class for working with FHIR datetimes in Python.
19+
20+
http://hl7.org/fhir/R4/datatypes.html#datetime
21+
22+
Converting to a Python representation does require some compromises:
23+
- This class will convert partial dates ("reduced precision dates") like "2024" into full
24+
naive datetimes using the earliest possible time (in this example, "2024-01-01T00:00:00")
25+
because Python's datetime class does not support partial dates.
26+
- FHIR allows arbitrary sub-second precision, but Python only holds microseconds.
27+
- Leap seconds (:60) will be changed to the 59th second (:59) because Python's time classes
28+
do not support leap seconds.
29+
30+
If such compromise is not useful for you, avoid using the `date`, `datetime`, or `isostring`
31+
properties and just use the `as_json()` method in order to work with the original,
32+
exact string.
33+
34+
Public properties:
35+
- `datetime`: datetime.datetime representing the JSON value (naive or aware)
36+
- `date`: backwards-compatibility alias for `datetime`
37+
- `isostring`: an ISO 8601 string version of the above Python object
38+
39+
Public methods:
40+
- `as_json`: returns the original JSON used to construct the instance
41+
"""
42+
43+
def __init__(self, jsonval: Union[str, None] = None):
44+
self.datetime: Union[datetime.datetime, None] = None
45+
super().__init__(jsonval)
46+
47+
##################################
48+
# Private properties and methods #
49+
##################################
50+
51+
# Pulled from spec for datetime
52+
_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)))?)?)?")
53+
_FIELD = "datetime"
54+
55+
@staticmethod
56+
def _from_string(value: str) -> Any:
57+
return FHIRDate._parse_datetime(value)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Facilitate working with FHIR time fields."""
2+
# 2024, SMART Health IT.
3+
4+
import datetime
5+
import re
6+
from typing import Any, Union
7+
8+
from .fhirdate import FHIRDate
9+
10+
11+
# This inherits from FHIRDate as a matter of backwards compatibility.
12+
# (in case anyone was doing isinstance(obj, FHIRDate))
13+
# Next time we bump the major version, we can stop that and also drop the
14+
# backwards-compatible 'date' alias. R4-QUIRK
15+
16+
class FHIRInstant(FHIRDate):
17+
"""
18+
A convenience class for working with FHIR instants in Python.
19+
20+
http://hl7.org/fhir/R4/datatypes.html#instant
21+
22+
Converting to a Python representation does require some compromises:
23+
- FHIR allows arbitrary sub-second precision, but Python only holds microseconds.
24+
- Leap seconds (:60) will be changed to the 59th second (:59) because Python's time classes
25+
do not support leap seconds.
26+
27+
If such compromise is not useful for you, avoid using the `date`, `datetime`, or `isostring`
28+
properties and just use the `as_json()` method in order to work with the original,
29+
exact string.
30+
31+
Public properties:
32+
- `datetime`: datetime.datetime representing the JSON value (aware only)
33+
- `date`: backwards-compatibility alias for `datetime`
34+
- `isostring`: an ISO 8601 string version of the above Python object
35+
36+
Public methods:
37+
- `as_json`: returns the original JSON used to construct the instance
38+
"""
39+
40+
def __init__(self, jsonval: Union[str, None] = None):
41+
self.datetime: Union[datetime.datetime, None] = None
42+
super().__init__(jsonval)
43+
44+
##################################
45+
# Private properties and methods #
46+
##################################
47+
48+
# Pulled from spec for instant
49+
_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))")
50+
_FIELD = "datetime"
51+
52+
@staticmethod
53+
def _from_string(value: str) -> Any:
54+
return FHIRDate._parse_datetime(value)

fhir-parser-resources/fhirtime.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Facilitate working with FHIR time fields."""
2+
# 2024, SMART Health IT.
3+
4+
import datetime
5+
import re
6+
from typing import Any, Union
7+
8+
from .fhirdate import FHIRDate
9+
10+
11+
# This inherits from FHIRDate as a matter of backwards compatibility.
12+
# (in case anyone was doing isinstance(obj, FHIRDate))
13+
# Next time we bump the major version, we can stop that and also drop the
14+
# backwards-compatible 'date' alias. R4-QUIRK
15+
16+
class FHIRTime(FHIRDate):
17+
"""
18+
A convenience class for working with FHIR times in Python.
19+
20+
http://hl7.org/fhir/R4/datatypes.html#time
21+
22+
Converting to a Python representation does require some compromises:
23+
- FHIR allows arbitrary sub-second precision, but Python only holds microseconds.
24+
- Leap seconds (:60) will be changed to the 59th second (:59) because Python's time classes
25+
do not support leap seconds.
26+
27+
If such compromise is not useful for you, avoid using the `date`, `time`, or `isostring`
28+
properties and just use the `as_json()` method in order to work with the original,
29+
exact string.
30+
31+
Public properties:
32+
- `time`: datetime.time representing the JSON value
33+
- `date`: backwards-compatibility alias for `time`
34+
- `isostring`: an ISO 8601 string version of the above Python object
35+
36+
Public methods:
37+
- `as_json`: returns the original JSON used to construct the instance
38+
"""
39+
40+
def __init__(self, jsonval: Union[str, None] = None):
41+
self.time: Union[datetime.time, None] = None
42+
super().__init__(jsonval)
43+
44+
##################################
45+
# Private properties and methods #
46+
##################################
47+
48+
# Pulled from spec for time
49+
_REGEX = re.compile(r"([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]+)?")
50+
_FIELD = "time"
51+
52+
@staticmethod
53+
def _from_string(value: str) -> Any:
54+
return FHIRDate._parse_time(value)

0 commit comments

Comments
 (0)