|
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 | + |
10 | 4 | 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 |
11 | 14 |
|
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. |
13 | 19 |
|
| 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. |
14 | 23 |
|
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 |
19 | 34 | """ |
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 | + |
23 | 39 | 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): |
28 | 41 | raise TypeError("Expecting string when initializing {}, but got {}" |
29 | 42 | .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 | + |
41 | 49 | def __setattr__(self, prop, value): |
42 | | - if 'date' == prop: |
| 50 | + if prop in {'date', self._FIELD}: |
43 | 51 | 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 | + |
46 | 58 | @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 | + """ |
48 | 67 | if self.date is None: |
49 | 68 | 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 | + |
54 | 71 | @classmethod |
55 | | - def with_json(cls, jsonobj): |
| 72 | + def with_json(cls, jsonobj: Union[str, list]): |
56 | 73 | """ Initialize a date from an ISO date string. |
57 | 74 | """ |
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): |
62 | 76 | return cls(jsonobj) |
63 | | - |
| 77 | + |
64 | 78 | if isinstance(jsonobj, list): |
65 | 79 | return [cls(jsonval) for jsonval in jsonobj] |
66 | | - |
| 80 | + |
67 | 81 | raise TypeError("`cls.with_json()` only takes string or list of strings, but you provided {}" |
68 | 82 | .format(type(jsonobj))) |
69 | | - |
| 83 | + |
70 | 84 | @classmethod |
71 | | - def with_json_and_owner(cls, jsonobj, owner): |
| 85 | + def with_json_and_owner(cls, jsonobj: Union[str, list], owner): |
72 | 86 | """ Added for compatibility reasons to FHIRElement; "owner" is |
73 | 87 | discarded. |
74 | 88 | """ |
75 | 89 | 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.""" |
78 | 93 | if self.origval is not None: |
79 | 94 | return self.origval |
80 | 95 | 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) |
0 commit comments