From 8cae8eef22ebb69499619beb0560b1c289104a62 Mon Sep 17 00:00:00 2001 From: Michael Terry Date: Mon, 22 Jul 2024 12:14:20 -0400 Subject: [PATCH] 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. --- .github/workflows/ci.yaml | 3 +- Default/mappings.py | 9 +- Default/settings.py | 6 +- Sample/_dateutils.py | 123 ++++++++++++++++++++ Sample/fhirabstractresource.py | 1 - Sample/fhirdate.py | 115 +++++++------------ Sample/fhirdatetime.py | 51 +++++++++ Sample/fhirinstant.py | 48 ++++++++ Sample/fhirtime.py | 48 ++++++++ Sample/template-unittest.py | 15 ++- tests/fhirdate_test.py | 204 +++++++++++++++++++++++++++++++++ 11 files changed, 540 insertions(+), 83 deletions(-) create mode 100644 Sample/_dateutils.py create mode 100644 Sample/fhirdatetime.py create mode 100644 Sample/fhirinstant.py create mode 100644 Sample/fhirtime.py create mode 100644 tests/fhirdate_test.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ebb33012..99ed35db 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,8 +31,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - # isodate is used by our sample templates and pytest is our runner of choice - pip install isodate pytest + pip install pytest - name: Cache R5 download uses: actions/cache@v4 diff --git a/Default/mappings.py b/Default/mappings.py index 7cc91582..e8b81b15 100644 --- a/Default/mappings.py +++ b/Default/mappings.py @@ -12,9 +12,9 @@ 'positiveInt': 'int', 'unsignedInt': 'int', 'date': 'FHIRDate', - 'dateTime': 'FHIRDate', - 'instant': 'FHIRDate', - 'time': 'FHIRDate', + 'dateTime': 'FHIRDateTime', + 'instant': 'FHIRInstant', + 'time': 'FHIRTime', 'decimal': 'float', 'string': 'str', @@ -46,6 +46,9 @@ 'float': 'float', 'FHIRDate': 'str', + 'FHIRDateTime': 'str', + 'FHIRInstant': 'str', + 'FHIRTime': 'str', } jsonmap_default = 'dict' diff --git a/Default/settings.py b/Default/settings.py index 2c1fcda6..b11992a2 100644 --- a/Default/settings.py +++ b/Default/settings.py @@ -65,5 +65,9 @@ ]), ('Sample/fhirabstractresource.py', 'fhirabstractresource', ['FHIRAbstractResource']), ('Sample/fhirreference.py', 'fhirreference', ['FHIRReference']), - ('Sample/fhirdate.py', 'fhirdate', ['date', 'dateTime', 'instant', 'time']), + ('Sample/fhirdate.py', 'fhirdate', ['date']), + ('Sample/fhirdatetime.py', 'fhirdatetime', ['dateTime']), + ('Sample/fhirinstant.py', 'fhirinstant', ['instant']), + ('Sample/fhirtime.py', 'fhirtime', ['time']), + ('Sample/_dateutils.py', '_dateutils', []), ] diff --git a/Sample/_dateutils.py b/Sample/_dateutils.py new file mode 100644 index 00000000..e9dbcf1e --- /dev/null +++ b/Sample/_dateutils.py @@ -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) diff --git a/Sample/fhirabstractresource.py b/Sample/fhirabstractresource.py index 4db9f564..209e8280 100644 --- a/Sample/fhirabstractresource.py +++ b/Sample/fhirabstractresource.py @@ -157,5 +157,4 @@ def delete(self): return None -from . import fhirdate from . import fhirelementfactory diff --git a/Sample/fhirdate.py b/Sample/fhirdate.py index f1586966..3c5861cc 100644 --- a/Sample/fhirdate.py +++ b/Sample/fhirdate.py @@ -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) diff --git a/Sample/fhirdatetime.py b/Sample/fhirdatetime.py new file mode 100644 index 00000000..8954e6d6 --- /dev/null +++ b/Sample/fhirdatetime.py @@ -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) diff --git a/Sample/fhirinstant.py b/Sample/fhirinstant.py new file mode 100644 index 00000000..08ac1903 --- /dev/null +++ b/Sample/fhirinstant.py @@ -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) diff --git a/Sample/fhirtime.py b/Sample/fhirtime.py new file mode 100644 index 00000000..b04ef877 --- /dev/null +++ b/Sample/fhirtime.py @@ -0,0 +1,48 @@ +"""Facilitate working with FHIR time fields.""" +# 2024, SMART Health IT. + +import datetime +import re +from typing import Any, Union + +from ._dateutils import _FHIRDateTimeMixin + + +class FHIRTime(_FHIRDateTimeMixin): + """ + A convenience class for working with FHIR times in Python. + + http://hl7.org/fhir/R4/datatypes.html#time + + 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 `time` or `isostring` + properties and just use the `as_json()` method in order to work with the original, + exact string. + + Public properties: + - `time`: datetime.time 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.time: Union[datetime.time, None] = None + super().__init__(jsonval) + + ################################## + # Private properties and methods # + ################################## + + # Pulled from spec for time + _REGEX = re.compile(r"([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]+)?") + _FIELD = "time" + + @classmethod + def _from_string(cls, value: str) -> Any: + return cls._parse_time(value) diff --git a/Sample/template-unittest.py b/Sample/template-unittest.py index cc21a689..0699b57e 100644 --- a/Sample/template-unittest.py +++ b/Sample/template-unittest.py @@ -14,6 +14,9 @@ import json from . import {{ class.module }} from .fhirdate import FHIRDate +from .fhirdatetime import FHIRDateTime +from .fhirinstant import FHIRInstant +from .fhirtime import FHIRTime class {{ class.name }}Tests(unittest.TestCase): @@ -48,12 +51,18 @@ def impl{{ class.name }}{{ loop.index }}(self, inst): {%- else %} self.assertFalse(inst.{{ onetest.path }}) {%- endif %} - {%- else %}{% if "FHIRDate" == onetest.klass.name %} - self.assertEqual(inst.{{ onetest.path }}.date, FHIRDate("{{ onetest.value }}").date) + {%- else %}{% if onetest.klass.name == "FHIRDate" %} + self.assertEqual(inst.{{ onetest.path }}.date, {{ onetest.klass.name }}("{{ onetest.value }}").date) + self.assertEqual(inst.{{ onetest.path }}.as_json(), "{{ onetest.value }}") + {%- else %}{% if onetest.klass.name in ["FHIRDateTime", "FHIRInstant"] %} + self.assertEqual(inst.{{ onetest.path }}.datetime, {{ onetest.klass.name }}("{{ onetest.value }}").datetime) + self.assertEqual(inst.{{ onetest.path }}.as_json(), "{{ onetest.value }}") + {%- else %}{% if onetest.klass.name == "FHIRTime" %} + self.assertEqual(inst.{{ onetest.path }}.time, {{ onetest.klass.name }}("{{ onetest.value }}").time) self.assertEqual(inst.{{ onetest.path }}.as_json(), "{{ onetest.value }}") {%- else %} # Don't know how to create unit test for "{{ onetest.path }}", which is a {{ onetest.klass.name }} - {%- endif %}{% endif %}{% endif %}{% endif %} + {%- endif %}{% endif %}{% endif %}{% endif %}{% endif %}{% endif %} {%- endfor %} {%- endfor %} diff --git a/tests/fhirdate_test.py b/tests/fhirdate_test.py new file mode 100644 index 00000000..c6f5d55d --- /dev/null +++ b/tests/fhirdate_test.py @@ -0,0 +1,204 @@ +import datetime +import unittest + +from models.fhirabstractbase import FHIRValidationError +from models.fhirdate import FHIRDate +from models.fhirdatetime import FHIRDateTime +from models.fhirinstant import FHIRInstant +from models.fhirtime import FHIRTime +from models.patient import Patient +from models.observation import Observation +from models.timing import Timing + + +class TestFHIRDate(unittest.TestCase): + + def test_empty(self): + date = FHIRDate() + self.assertIsNone(date.date) + self.assertIsNone(date.isostring) + self.assertIsNone(date.as_json()) + + def test_object_validation(self): + """Confirm that when constructing an invalid JSON class, we complain""" + with self.assertRaisesRegex(FHIRValidationError, "Expecting string when initializing"): + Timing({"event": [1923, "1924"]}) + with self.assertRaisesRegex(FHIRValidationError, "does not match expected format"): + Patient({"birthDate": "1923-10-11T12:34:56Z"}) + + def test_with_json(self): + """Confirm we can make objects correctly""" + self.assertEqual(FHIRDate.with_json_and_owner("2024", None).isostring, "2024-01-01") + self.assertEqual(FHIRTime.with_json("10:12:14").isostring, "10:12:14") + self.assertEqual( + [x.isostring for x in FHIRTime.with_json(["10:12:14", "01:01:01"])], + ["10:12:14", "01:01:01"] + ) + with self.assertRaisesRegex(TypeError, "only takes string or list"): + FHIRDateTime.with_json(2024) + + def test_date(self): + """ + Verify we parse valid date values. + + From http://hl7.org/fhir/datatypes.html#date: + - The format is YYYY, YYYY-MM, or YYYY-MM-DD, e.g. 2018, 1973-06, or 1905-08-23. + - There SHALL be no timezone offset + """ + # Various happy path strings + self.assertEqual(FHIRDate("0001").isostring, "0001-01-01") + self.assertEqual(FHIRDate("2018").isostring, "2018-01-01") + self.assertEqual(FHIRDate("1973-06").isostring, "1973-06-01") + self.assertEqual(FHIRDate("1905-08-23").isostring, "1905-08-23") + + # Check that we also correctly provide the date property + date = FHIRDate("1982").date # datetime.date + self.assertIsInstance(date, datetime.date) + self.assertEqual(date.isoformat(), "1982-01-01") + + # Check that we give back the original input when converting back to as_json() + self.assertEqual(FHIRDate("1982").as_json(), "1982") + + # Confirm we're used in actual objects + p = Patient({"birthDate": "1923-10-11"}) + self.assertIsInstance(p.birthDate, FHIRDate) + self.assertEqual(p.birthDate.isostring, "1923-10-11") + + # Now test a bunch of invalid strings + self.assertRaises(ValueError, FHIRDate, "82") + self.assertRaises(ValueError, FHIRDate, "82/07/23") + self.assertRaises(ValueError, FHIRDate, "07-23-1982") + self.assertRaises(ValueError, FHIRDate, "13:28:17") + self.assertRaises(ValueError, FHIRDate, "2015-02-07T13:28:17-05:00") + + def test_datetime(self): + """ + Verify we parse valid datetime values. + + From http://hl7.org/fhir/datatypes.html#datetime: + - The format is YYYY, YYYY-MM, YYYY-MM-DD or YYYY-MM-DDThh:mm:ss+zz:zz + - e.g. 2018, 1973-06, 1905-08-23, 2015-02-07T13:28:17-05:00 or 2017-01-01T00:00:00.000Z + - If hours and minutes are specified, a timezone offset SHALL be populated + - Seconds must be provided due to schema type constraints + but may be zero-filled and may be ignored at receiver discretion. + - Milliseconds are optionally allowed (the spec's regex actually allows arbitrary + sub-second precision) + """ + # Various happy path strings + self.assertEqual(FHIRDateTime("2018").isostring, "2018-01-01T00:00:00") + self.assertEqual(FHIRDateTime("1973-06").isostring, "1973-06-01T00:00:00") + self.assertEqual(FHIRDateTime("1905-08-23").isostring, "1905-08-23T00:00:00") + self.assertEqual( + FHIRDateTime("2015-02-07T13:28:17-05:00").isostring, "2015-02-07T13:28:17-05:00" + ) + self.assertEqual( + FHIRDateTime("2017-01-01T00:00:00.123456Z").isostring, + "2017-01-01T00:00:00.123456+00:00" + ) + self.assertEqual( # leap second + FHIRDateTime("2015-02-07T13:28:60Z").isostring, "2015-02-07T13:28:59+00:00" + ) + + # Check that we also correctly provide the datetime property + self.assertIsInstance(FHIRDateTime("2015").datetime, datetime.datetime) + self.assertIsInstance(FHIRDateTime("2015-02-07").datetime, datetime.datetime) + self.assertIsInstance(FHIRDateTime("2015-02-07T13:28:17Z").datetime, datetime.datetime) + + # Check that we give back the original input when converting back to as_json() + self.assertEqual(FHIRDateTime("1982").as_json(), "1982") + + # Confirm we're used in actual objects + p = Patient({"deceasedDateTime": "1923-10-11"}) + self.assertIsInstance(p.deceasedDateTime, FHIRDateTime) + self.assertEqual(p.deceasedDateTime.isostring, "1923-10-11T00:00:00") + + # Now test a bunch of invalid strings + self.assertRaises(ValueError, FHIRDateTime, "82") + self.assertRaises(ValueError, FHIRDateTime, "82/07/23") + self.assertRaises(ValueError, FHIRDateTime, "07-23-1982") + self.assertRaises(ValueError, FHIRDateTime, "13:28:17") + self.assertRaises(ValueError, FHIRDateTime, "2015-02-07T13:28") # no seconds + self.assertRaises(ValueError, FHIRDateTime, "2015-02-07T13:28:17") # no timezone + + def test_instant(self): + """ + Verify we parse valid instant values. + + From http://hl7.org/fhir/datatypes.html#instant: + - The format is YYYY-MM-DDThh:mm:ss.sss+zz:zz + - e.g. 2015-02-07T13:28:17.239+02:00 or 2017-01-01T00:00:00Z + - The time SHALL be specified at least to the second and SHALL include a time zone. + """ + # Various happy path strings + self.assertEqual( + FHIRInstant("2015-02-07T13:28:17-05:00").isostring, "2015-02-07T13:28:17-05:00" + ) + self.assertEqual( + FHIRInstant("2017-01-01T00:00:00.123456Z").isostring, + "2017-01-01T00:00:00.123456+00:00" + ) + self.assertEqual( # leap second + FHIRInstant("2017-01-01T00:00:60Z").isostring, "2017-01-01T00:00:59+00:00" + ) + + # Check that we also correctly provide the datetime property + self.assertIsInstance(FHIRInstant("2015-02-07T13:28:17Z").datetime, datetime.datetime) + + # Check that we give back the original input when converting back to as_json() + self.assertEqual( + FHIRInstant("2017-01-01T00:00:00Z").as_json(), + "2017-01-01T00:00:00Z" # Z instead of +00.00 + ) + + # Confirm we're used in actual objects + obs = Observation({ + "issued": "2017-01-01T00:00:00.123Z", + "status": "X", + "code": {"text": "X"}}, + ) + self.assertIsInstance(obs.issued, FHIRInstant) + self.assertEqual(obs.issued.isostring, "2017-01-01T00:00:00.123000+00:00") + + # Now test a bunch of invalid strings + self.assertRaises(ValueError, FHIRInstant, "82") + self.assertRaises(ValueError, FHIRInstant, "82/07/23") + self.assertRaises(ValueError, FHIRInstant, "07-23-1982") + self.assertRaises(ValueError, FHIRInstant, "13:28:17") + self.assertRaises(ValueError, FHIRInstant, "2015") + self.assertRaises(ValueError, FHIRInstant, "2015-02-07") + self.assertRaises(ValueError, FHIRInstant, "2015-02-07T13:28") # no seconds + self.assertRaises(ValueError, FHIRInstant, "2015-02-07T13:28:17") # no timezone + + def test_time(self): + """ + Verify we parse valid time values. + + From http://hl7.org/fhir/datatypes.html#time: + - The format is hh:mm:ss + - A timezone offset SHALL NOT be present + - Uses 24-hour time + - Sub-seconds allowed (to arbitrary precision, per the regex...) + """ + # Various happy path strings + self.assertEqual(FHIRTime("13:28:17").isostring, "13:28:17") + self.assertEqual(FHIRTime("13:28:17.123456").isostring, "13:28:17.123456") + self.assertEqual(FHIRTime("00:00:60").isostring, "00:00:59") # leap second + + # Check that we also correctly provide the time property + self.assertIsInstance(FHIRTime("13:28:17").time, datetime.time) + + # Check that we give back the original input when converting back to as_json() + self.assertEqual(FHIRTime("00:00:00").as_json(), "00:00:00") + + # Confirm we're used in actual objects + obs = Observation({"valueTime": "14:49:32", "status": "X", "code": {"text": "X"}}) + self.assertIsInstance(obs.valueTime, FHIRTime) + self.assertEqual(obs.valueTime.isostring, "14:49:32") + + # Now test a bunch of invalid strings + self.assertRaises(ValueError, FHIRTime, "82") + self.assertRaises(ValueError, FHIRTime, "82/07/23") + self.assertRaises(ValueError, FHIRTime, "07-23-1982") + self.assertRaises(ValueError, FHIRTime, "2015") + self.assertRaises(ValueError, FHIRTime, "2015-02-07T13:28:17Z") + self.assertRaises(ValueError, FHIRTime, "10:12")