├── requirements.txt ├── .gitignore ├── Sample ├── template-dependencies.json ├── template-codesystems.py ├── template-elementfactory.py ├── fhirtime.py ├── fhirdate.py ├── fhirinstant.py ├── fhirdatetime.py ├── template-resource.py ├── template-unittest.py ├── fhirreference.py ├── _dateutils.py ├── fhirabstractresource.py └── fhirabstractbase.py ├── LICENSE.txt ├── logger.py ├── generate.py ├── Default ├── mappings.py └── settings.py ├── .github └── workflows │ └── ci.yaml ├── fhirloader.py ├── README.md ├── fhirunittest.py ├── tests └── fhirdate_test.py ├── fhirrenderer.py ├── fhirclass.py └── fhirspec.py /requirements.txt: -------------------------------------------------------------------------------- 1 | colorlog>=2.10.0 2 | jinja2>=3.0 3 | requests>=2.13.0 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__ 3 | env 4 | /.idea/ 5 | 6 | # ignore mappings and settings files 7 | /mappings.py 8 | /settings.py 9 | 10 | # ignore downloads 11 | /downloads 12 | -------------------------------------------------------------------------------- /Sample/template-dependencies.json: -------------------------------------------------------------------------------- 1 | { 2 | {%- for resource in resources %} 3 | "{{ resource.profile.targetname }}": { 4 | "dependencies": [ 5 | {%- for klass in resource.imports %}"{{ klass.module }}"{% if not loop.last %},{% endif %}{% endfor -%} 6 | ]{% if resource.references %}, 7 | "references": [ 8 | {%- for reference in resource.references %}"{{ reference }}"{% if not loop.last %},{% endif %}{% endfor -%} 9 | ]{% endif %} 10 | }{% if not loop.last %},{% endif %} 11 | {%- endfor %} 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2015 Boston Children's Hospital 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /Sample/template-codesystems.py: -------------------------------------------------------------------------------- 1 | # 2 | # CodeSystems.py 3 | # client-py 4 | # 5 | # Generated from FHIR {{ info.version }} on {{ info.date }}. 6 | # {{ info.year }}, SMART Health IT. 7 | # 8 | # THIS HAS BEEN ADAPTED FROM Swift Enums WITHOUT EVER BEING IMPLEMENTED IN 9 | # Python, FOR DEMONSTRATION PURPOSES ONLY. 10 | # 11 | {% if system.generate_enum %} 12 | 13 | class {{ system.name }}(object) { 14 | """ {{ system.definition.description|wordwrap(width=120, wrapstring="\n") }} 15 | 16 | URL: {{ system.url }} 17 | {%- if system.definition.valueSet %} 18 | ValueSet: {{ system.definition.valueSet }} 19 | {%- endif %} 20 | """ 21 | {%- for code in system.codes %} 22 | 23 | {{ code.name }} = "{{ code.code }}" 24 | """ {{ code.definition|wordwrap(width=112, wrapstring="\n /// ") }} 25 | """ 26 | {%- endfor %} 27 | } 28 | {% endif %} 29 | -------------------------------------------------------------------------------- /logger.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | 6 | # desired log level 7 | log_level = logging.DEBUG 8 | 9 | # we try to setup a colored logger, used throughout fhir-generator 10 | # use "logger.logger.log()" 11 | logging.root.setLevel(log_level) 12 | try: 13 | from colorlog import ColoredFormatter 14 | logfmt = " %(log_color)s%(levelname)-8s%(reset)s | %(log_color)s%(message)s%(reset)s" 15 | formatter = ColoredFormatter(logfmt) 16 | 17 | stream = logging.StreamHandler() 18 | stream.setLevel(log_level) 19 | stream.setFormatter(formatter) 20 | 21 | logger = logging.getLogger('fhirparser') 22 | logger.setLevel(log_level) 23 | logger.addHandler(stream) 24 | except Exception as e: 25 | logging.info('Install "colorlog" to enable colored log messages') 26 | 27 | logger = logging.getLogger('fhirparser') 28 | -------------------------------------------------------------------------------- /generate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Download and parse FHIR resource definitions 5 | # Supply "-f" to force a redownload of the spec 6 | # Supply "-c" to force using the cached spec (incompatible with "-f") 7 | # Supply "-d" to load and parse but not write resources 8 | # Supply "-l" to only download the spec 9 | 10 | import sys 11 | 12 | import settings 13 | import fhirloader 14 | import fhirspec 15 | 16 | 17 | if '__main__' == __name__: 18 | force_download = len(sys.argv) > 1 and '-f' in sys.argv 19 | dry = len(sys.argv) > 1 and ('-d' in sys.argv or '--dry-run' in sys.argv) 20 | load_only = len(sys.argv) > 1 and ('-l' in sys.argv or '--load-only' in sys.argv) 21 | force_cache = len(sys.argv) > 1 and ('-c' in sys.argv or '--cache-only' in sys.argv) 22 | 23 | # assure we have all files 24 | loader = fhirloader.FHIRLoader(settings) 25 | spec_source = loader.load(force_download=force_download, force_cache=force_cache) 26 | 27 | # parse 28 | if not load_only: 29 | spec = fhirspec.FHIRSpec(spec_source, settings) 30 | if not dry: 31 | spec.write() 32 | -------------------------------------------------------------------------------- /Sample/template-elementfactory.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Generated from FHIR {{ info.version }} on {{ info.date }}. 5 | # {{ info.year }}, SMART Health IT. 6 | # 7 | # THIS TEMPLATE IS FOR ILLUSTRATIVE PURPOSES ONLY, YOU NEED TO CREATE YOUR OWN 8 | # WHEN USING fhir-parser. 9 | 10 | 11 | class FHIRElementFactory(object): 12 | """ Factory class to instantiate resources by resource name. 13 | """ 14 | 15 | @classmethod 16 | def instantiate(cls, resource_type, jsondict): 17 | """ Instantiate a resource of the type correlating to "resource_type". 18 | 19 | :param str resource_type: The name/type of the resource to instantiate 20 | :param dict jsondict: The JSON dictionary to use for data 21 | :returns: A resource of the respective type or `Element` 22 | """ 23 | {%- for klass in classes %}{% if klass.resource_type %} 24 | if "{{ klass.resource_type }}" == resource_type: 25 | from . import {{ klass.module }} 26 | return {{ klass.module }}.{{ klass.name }}(jsondict) 27 | {%- endif %}{% endfor %} 28 | from . import element 29 | return element.Element(jsondict) 30 | 31 | -------------------------------------------------------------------------------- /Sample/fhirtime.py: -------------------------------------------------------------------------------- 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 ._dateutils import _FHIRDateTimeMixin 9 | 10 | 11 | class FHIRTime(_FHIRDateTimeMixin): 12 | """ 13 | A convenience class for working with FHIR times in Python. 14 | 15 | http://hl7.org/fhir/R4/datatypes.html#time 16 | 17 | Converting to a Python representation does require some compromises: 18 | - FHIR allows arbitrary sub-second precision, but Python only holds microseconds. 19 | - Leap seconds (:60) will be changed to the 59th second (:59) because Python's time classes 20 | do not support leap seconds. 21 | 22 | If such compromise is not useful for you, avoid using the `time` or `isostring` 23 | properties and just use the `as_json()` method in order to work with the original, 24 | exact string. 25 | 26 | Public properties: 27 | - `time`: datetime.time representing the JSON value 28 | - `isostring`: an ISO 8601 string version of the above Python object 29 | 30 | Public methods: 31 | - `as_json`: returns the original JSON used to construct the instance 32 | """ 33 | 34 | def __init__(self, jsonval: Union[str, None] = None): 35 | self.time: Union[datetime.time, None] = None 36 | super().__init__(jsonval) 37 | 38 | ################################## 39 | # Private properties and methods # 40 | ################################## 41 | 42 | # Pulled from spec for time 43 | _REGEX = re.compile(r"([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]+)?") 44 | _FIELD = "time" 45 | 46 | @classmethod 47 | def _from_string(cls, value: str) -> Any: 48 | return cls._parse_time(value) 49 | -------------------------------------------------------------------------------- /Sample/fhirdate.py: -------------------------------------------------------------------------------- 1 | """Facilitate working with FHIR date fields.""" 2 | # 2024, SMART Health IT. 3 | 4 | import datetime 5 | import re 6 | from typing import Any, Union 7 | 8 | from ._dateutils import _FHIRDateTimeMixin 9 | 10 | 11 | class FHIRDate(_FHIRDateTimeMixin): 12 | """ 13 | A convenience class for working with FHIR dates in Python. 14 | 15 | http://hl7.org/fhir/R4/datatypes.html#date 16 | 17 | Converting to a Python representation does require some compromises: 18 | - This class will convert partial dates ("reduced precision dates") like "2024" into full 19 | dates using the earliest possible time (in this example, "2024-01-01") because Python's 20 | date class does not support partial dates. 21 | 22 | If such compromise is not useful for you, avoid using the `date` or `isostring` 23 | properties and just use the `as_json()` method in order to work with the original, 24 | exact string. 25 | 26 | Public properties: 27 | - `date`: datetime.date representing the JSON value 28 | - `isostring`: an ISO 8601 string version of the above Python object 29 | 30 | Public methods: 31 | - `as_json`: returns the original JSON used to construct the instance 32 | """ 33 | 34 | def __init__(self, jsonval: Union[str, None] = None): 35 | self.date: Union[datetime.date, None] = None 36 | super().__init__(jsonval) 37 | 38 | ################################## 39 | # Private properties and methods # 40 | ################################## 41 | 42 | # Pulled from spec for date 43 | _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]))?)?") 44 | _FIELD = "date" 45 | 46 | @classmethod 47 | def _from_string(cls, value: str) -> Any: 48 | return cls._parse_date(value) 49 | -------------------------------------------------------------------------------- /Sample/fhirinstant.py: -------------------------------------------------------------------------------- 1 | """Facilitate working with FHIR instant fields.""" 2 | # 2024, SMART Health IT. 3 | 4 | import datetime 5 | import re 6 | from typing import Any, Union 7 | 8 | from ._dateutils import _FHIRDateTimeMixin 9 | 10 | 11 | class FHIRInstant(_FHIRDateTimeMixin): 12 | """ 13 | A convenience class for working with FHIR instants in Python. 14 | 15 | http://hl7.org/fhir/R4/datatypes.html#instant 16 | 17 | Converting to a Python representation does require some compromises: 18 | - FHIR allows arbitrary sub-second precision, but Python only holds microseconds. 19 | - Leap seconds (:60) will be changed to the 59th second (:59) because Python's time classes 20 | do not support leap seconds. 21 | 22 | If such compromise is not useful for you, avoid using the `datetime` or `isostring` 23 | properties and just use the `as_json()` method in order to work with the original, 24 | exact string. 25 | 26 | Public properties: 27 | - `datetime`: datetime.datetime representing the JSON value (aware only) 28 | - `isostring`: an ISO 8601 string version of the above Python object 29 | 30 | Public methods: 31 | - `as_json`: returns the original JSON used to construct the instance 32 | """ 33 | 34 | def __init__(self, jsonval: Union[str, None] = None): 35 | self.datetime: Union[datetime.datetime, None] = None 36 | super().__init__(jsonval) 37 | 38 | ################################## 39 | # Private properties and methods # 40 | ################################## 41 | 42 | # Pulled from spec for instant 43 | _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))") 44 | _FIELD = "datetime" 45 | 46 | @classmethod 47 | def _from_string(cls, value: str) -> Any: 48 | return cls._parse_datetime(value) 49 | -------------------------------------------------------------------------------- /Sample/fhirdatetime.py: -------------------------------------------------------------------------------- 1 | """Facilitate working with FHIR datetime fields.""" 2 | # 2024, SMART Health IT. 3 | 4 | import datetime 5 | import re 6 | from typing import Any, Union 7 | 8 | from ._dateutils import _FHIRDateTimeMixin 9 | 10 | 11 | class FHIRDateTime(_FHIRDateTimeMixin): 12 | """ 13 | A convenience class for working with FHIR datetimes in Python. 14 | 15 | http://hl7.org/fhir/R4/datatypes.html#datetime 16 | 17 | Converting to a Python representation does require some compromises: 18 | - This class will convert partial dates ("reduced precision dates") like "2024" into full 19 | naive datetimes using the earliest possible time (in this example, "2024-01-01T00:00:00") 20 | because Python's datetime class does not support partial dates. 21 | - FHIR allows arbitrary sub-second precision, but Python only holds microseconds. 22 | - Leap seconds (:60) will be changed to the 59th second (:59) because Python's time classes 23 | do not support leap seconds. 24 | 25 | If such compromise is not useful for you, avoid using the `datetime` or `isostring` 26 | properties and just use the `as_json()` method in order to work with the original, 27 | exact string. 28 | 29 | Public properties: 30 | - `datetime`: datetime.datetime representing the JSON value (naive or aware) 31 | - `isostring`: an ISO 8601 string version of the above Python object 32 | 33 | Public methods: 34 | - `as_json`: returns the original JSON used to construct the instance 35 | """ 36 | 37 | def __init__(self, jsonval: Union[str, None] = None): 38 | self.datetime: Union[datetime.datetime, None] = None 39 | super().__init__(jsonval) 40 | 41 | ################################## 42 | # Private properties and methods # 43 | ################################## 44 | 45 | # Pulled from spec for datetime 46 | _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)))?)?)?") 47 | _FIELD = "datetime" 48 | 49 | @classmethod 50 | def _from_string(cls, value: str) -> Any: 51 | return cls._parse_datetime(value) 52 | -------------------------------------------------------------------------------- /Default/mappings.py: -------------------------------------------------------------------------------- 1 | # Mappings for the FHIR class generator. 2 | # 3 | # This should be useable as-is for Python classes. 4 | 5 | # Which class names to map to resources and elements 6 | classmap = { 7 | 'Any': 'Resource', 8 | 'Practitioner.role': 'PractRole', # to avoid Practitioner.role and PractitionerRole generating the same class 9 | 10 | 'boolean': 'bool', 11 | 'integer': 'int', 12 | 'positiveInt': 'int', 13 | 'unsignedInt': 'int', 14 | 'date': 'FHIRDate', 15 | 'dateTime': 'FHIRDateTime', 16 | 'instant': 'FHIRInstant', 17 | 'time': 'FHIRTime', 18 | 'decimal': 'float', 19 | 20 | 'string': 'str', 21 | 'markdown': 'str', 22 | 'id': 'str', 23 | 'code': 'str', # for now we're not generating enums for these 24 | 'uri': 'str', 25 | 'url': 'str', 26 | 'canonical': 'str', 27 | 'oid': 'str', 28 | 'uuid': 'str', 29 | 'xhtml': 'str', 30 | 'base64Binary': 'str', 31 | } 32 | 33 | # Classes to be replaced with different ones at resource rendering time 34 | replacemap = { 35 | 'Reference': 'FHIRReference', # `FHIRReference` adds dereferencing capabilities 36 | } 37 | 38 | # Which class names are native to the language (or can be treated this way) 39 | natives = ['bool', 'int', 'float', 'str', 'dict'] 40 | 41 | # Which classes are to be expected from JSON decoding 42 | jsonmap = { 43 | 'str': 'str', 44 | 'int': 'int', 45 | 'bool': 'bool', 46 | 'float': 'float', 47 | 48 | 'FHIRDate': 'str', 49 | 'FHIRDateTime': 'str', 50 | 'FHIRInstant': 'str', 51 | 'FHIRTime': 'str', 52 | } 53 | jsonmap_default = 'dict' 54 | 55 | # Properties that need to be renamed because of language keyword conflicts 56 | reservedmap = { 57 | 'for': 'for_fhir', 58 | 'from': 'from_fhir', 59 | 'class': 'class_fhir', 60 | 'import': 'import_fhir', 61 | 'global': 'global_fhir', 62 | 'assert': 'assert_fhir', 63 | 'except': 'except_fhir', 64 | } 65 | 66 | # For enum codes where a computer just cannot generate reasonable names 67 | enum_map = { 68 | '=': 'eq', 69 | '!=': 'ne', 70 | '<': 'lt', 71 | '<=': 'lte', 72 | '>': 'gt', 73 | '>=': 'gte', 74 | '*': 'max', 75 | '+': 'pos', 76 | '-': 'neg', 77 | } 78 | 79 | # If you want to give specific names to enums based on their URI 80 | enum_namemap = { 81 | # 'http://hl7.org/fhir/coverage-exception': 'CoverageExceptionCodes', 82 | } 83 | 84 | # If certain CodeSystems don't need to generate an enum 85 | enum_ignore = { 86 | # 'http://hl7.org/fhir/resource-type-link', 87 | } 88 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | # The goal here is to cancel older workflows when a PR is updated (because it's pointless work) 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | generate: 15 | name: generate 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | cache: pip 29 | 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install -r requirements.txt 34 | pip install pytest 35 | 36 | - name: Cache R5 download 37 | uses: actions/cache@v4 38 | with: 39 | path: downloads-r5 40 | key: downloads-r5 41 | 42 | - name: Generate R5 43 | run: | 44 | cp ./Default/settings.py . 45 | sed -i "s|'../models|'models|g" settings.py 46 | sed -i "s|'downloads'|'downloads-r5'|g" settings.py 47 | sed -i "s|\(^specification_url = \)'.*'|\1'http://hl7.org/fhir/R5'|g" settings.py 48 | rm -rf models 49 | ./generate.py 50 | grep 'Generated from FHIR 5.0.0' models/account.py # sanity check 51 | 52 | # FIXME: The R5 tests fail to pass currently (due to some wrong types and missing properties) 53 | # See https://github.com/smart-on-fhir/fhir-parser/issues/51 54 | # - name: Test R5 55 | # run: | 56 | # FHIR_UNITTEST_DATADIR=downloads-r5 pytest 57 | 58 | - name: Cache R4 download 59 | uses: actions/cache@v4 60 | with: 61 | path: downloads-r4 62 | key: downloads-r4 63 | 64 | - name: Generate R4 65 | run: | 66 | cp ./Default/settings.py . 67 | sed -i "s|'../models|'models|g" settings.py 68 | sed -i "s|'downloads'|'downloads-r4'|g" settings.py 69 | sed -i "s|\(^specification_url = \)'.*'|\1'http://hl7.org/fhir/R4'|g" settings.py 70 | rm -rf models 71 | ./generate.py 72 | grep 'Generated from FHIR 4.0.1' models/account.py # sanity check 73 | 74 | - name: Test R4 75 | run: | 76 | FHIR_UNITTEST_DATADIR=downloads-r4 pytest 77 | -------------------------------------------------------------------------------- /Sample/template-resource.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Generated from FHIR {{ info.version }} ({{ profile.url }}) on {{ info.date }}. 5 | # {{ info.year }}, SMART Health IT. 6 | # 7 | # THIS TEMPLATE IS FOR ILLUSTRATIVE PURPOSES ONLY, YOU NEED TO CREATE YOUR OWN 8 | # WHEN USING fhir-parser. 9 | 10 | {%- set imported = {} %} 11 | {%- for klass in classes %} 12 | 13 | 14 | {% if klass.superclass in imports and klass.superclass.module not in imported -%} 15 | from . import {{ klass.superclass.module }} 16 | {% set _ = imported.update({klass.superclass.module: True}) %} 17 | {% endif -%} 18 | 19 | class {{ klass.name }}({% if klass.superclass in imports %}{{ klass.superclass.module }}.{% endif -%} 20 | {{ klass.superclass.name|default('object')}}): 21 | """ {{ klass.short|wordwrap(width=75, wrapstring="\n ") }}. 22 | {%- if klass.formal %} 23 | 24 | {{ klass.formal|wordwrap(width=75, wrapstring="\n ") }} 25 | {%- endif %} 26 | """ 27 | {%- if klass.resource_type %} 28 | 29 | resource_type = "{{ klass.resource_type }}" 30 | {%- endif %} 31 | 32 | def __init__(self, jsondict=None, strict=True): 33 | """ Initialize all valid properties. 34 | 35 | :raises: FHIRValidationError on validation errors, unless strict is False 36 | :param dict jsondict: A JSON dictionary to use for initialization 37 | :param bool strict: If True (the default), invalid variables will raise a TypeError 38 | """ 39 | {%- for prop in klass.properties %} 40 | 41 | self.{{ prop.name }} = None 42 | """ {{ prop.short|wordwrap(67, wrapstring="\n ") }}. 43 | {% if prop.is_array %}List of{% else %}Type{% endif %} `{{ prop.class_name }}`{% if prop.is_array %} items{% endif %} 44 | {%- if prop.reference_to_names|length > 0 %} referencing `{{ prop.reference_to_names|join(', ') }}`{% endif %} 45 | {%- if prop.json_class != prop.class_name %} (represented as `{{ prop.json_class }}` in JSON){% endif %}. """ 46 | {%- endfor %} 47 | 48 | super({{ klass.name }}, self).__init__(jsondict=jsondict, strict=strict) 49 | 50 | {%- if klass.properties %} 51 | 52 | def elementProperties(self): 53 | js = super({{ klass.name }}, self).elementProperties() 54 | {%- if 'element' == klass.module and 'Element' == klass.name %} 55 | {%- for imp in imports %}{% if imp.module not in imported %} 56 | from . import {{ imp.module }} 57 | {%- set _ = imported.update({imp.module: True}) %} 58 | {%- endif %}{% endfor %} 59 | {%- endif %} 60 | js.extend([ 61 | {%- for prop in klass.properties %} 62 | ("{{ prop.name }}", "{{ prop.orig_name }}", 63 | {%- if prop.module_name %} {{ prop.module_name }}.{% else %} {% endif %}{{ prop.class_name }}, {# #} 64 | {{- prop.is_array }}, 65 | {%- if prop.one_of_many %} "{{ prop.one_of_many }}"{% else %} None{% endif %}, {# #} 66 | {{- prop.nonoptional }}), 67 | {%- endfor %} 68 | ]) 69 | return js 70 | 71 | {%- endif %} 72 | {%- endfor %} 73 | 74 | {% for imp in imports %}{% if imp.module not in imported %} 75 | from . import {{ imp.module }} 76 | {%- endif %}{% endfor %} 77 | 78 | -------------------------------------------------------------------------------- /Sample/template-unittest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Generated from FHIR {{ info.version }} on {{ info.date }}. 5 | # {{ info.year }}, SMART Health IT. 6 | # 7 | # THIS TEMPLATE IS FOR ILLUSTRATIVE PURPOSES ONLY, YOU NEED TO CREATE YOUR OWN 8 | # WHEN USING fhir-parser. 9 | 10 | 11 | import os 12 | import io 13 | import unittest 14 | import json 15 | from . import {{ class.module }} 16 | from .fhirdate import FHIRDate 17 | from .fhirdatetime import FHIRDateTime 18 | from .fhirinstant import FHIRInstant 19 | from .fhirtime import FHIRTime 20 | 21 | 22 | class {{ class.name }}Tests(unittest.TestCase): 23 | def instantiate_from(self, filename): 24 | datadir = os.environ.get('FHIR_UNITTEST_DATADIR') or '' 25 | with io.open(os.path.join(datadir, filename), 'r', encoding='utf-8') as handle: 26 | js = json.load(handle) 27 | self.assertEqual("{{ class.name }}", js["resourceType"]) 28 | return {{ class.module }}.{{ class.name }}(js) 29 | 30 | {%- for tcase in tests %} 31 | 32 | def test{{ class.name }}{{ loop.index }}(self): 33 | inst = self.instantiate_from("{{ tcase.filename }}") 34 | self.assertIsNotNone(inst, "Must have instantiated a {{ class.name }} instance") 35 | self.impl{{ class.name }}{{ loop.index }}(inst) 36 | 37 | js = inst.as_json() 38 | self.assertEqual("{{ class.name }}", js["resourceType"]) 39 | inst2 = {{ class.module }}.{{ class.name }}(js) 40 | self.impl{{ class.name }}{{ loop.index }}(inst2) 41 | 42 | def impl{{ class.name }}{{ loop.index }}(self, inst): 43 | {%- for onetest in tcase.tests %} 44 | {%- if "str" == onetest.klass.name %} 45 | self.assertEqual(inst.{{ onetest.path }}, "{{ onetest.value|replace('\\', '\\\\')|replace('"', '\\"') }}") 46 | {%- else %}{% if "int" == onetest.klass.name or "float" == onetest.klass.name or "NSDecimalNumber" == onetest.klass.name %} 47 | self.assertEqual(inst.{{ onetest.path }}, {{ onetest.value }}) 48 | {%- else %}{% if "bool" == onetest.klass.name %} 49 | {%- if onetest.value %} 50 | self.assertTrue(inst.{{ onetest.path }}) 51 | {%- else %} 52 | self.assertFalse(inst.{{ onetest.path }}) 53 | {%- endif %} 54 | {%- else %}{% if onetest.klass.name == "FHIRDate" %} 55 | self.assertEqual(inst.{{ onetest.path }}.date, {{ onetest.klass.name }}("{{ onetest.value }}").date) 56 | self.assertEqual(inst.{{ onetest.path }}.as_json(), "{{ onetest.value }}") 57 | {%- else %}{% if onetest.klass.name in ["FHIRDateTime", "FHIRInstant"] %} 58 | self.assertEqual(inst.{{ onetest.path }}.datetime, {{ onetest.klass.name }}("{{ onetest.value }}").datetime) 59 | self.assertEqual(inst.{{ onetest.path }}.as_json(), "{{ onetest.value }}") 60 | {%- else %}{% if onetest.klass.name == "FHIRTime" %} 61 | self.assertEqual(inst.{{ onetest.path }}.time, {{ onetest.klass.name }}("{{ onetest.value }}").time) 62 | self.assertEqual(inst.{{ onetest.path }}.as_json(), "{{ onetest.value }}") 63 | {%- else %} 64 | # Don't know how to create unit test for "{{ onetest.path }}", which is a {{ onetest.klass.name }} 65 | {%- endif %}{% endif %}{% endif %}{% endif %}{% endif %}{% endif %} 66 | {%- endfor %} 67 | {%- endfor %} 68 | 69 | 70 | -------------------------------------------------------------------------------- /fhirloader.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import io 5 | import os.path 6 | from logger import logger 7 | 8 | 9 | class FHIRLoader(object): 10 | """ Class to download the files needed for the generator. 11 | 12 | The `needs` dictionary contains as key the local file needed and how to 13 | get it from the specification URL. 14 | """ 15 | needs = { 16 | 'version.info': 'version.info', 17 | 'profiles-resources.json': 'examples-json.zip', 18 | } 19 | 20 | def __init__(self, settings): 21 | self.settings = settings 22 | self.base_url = settings.specification_url 23 | self.cache = os.path.join(*settings.download_directory.split('/')) 24 | 25 | def load(self, force_download=False, force_cache=False): 26 | """ Makes sure all the files needed have been downloaded. 27 | 28 | :returns: The path to the directory with all our files. 29 | """ 30 | if force_download: assert not force_cache 31 | 32 | if os.path.isdir(self.cache) and force_download: 33 | import shutil 34 | shutil.rmtree(self.cache) 35 | 36 | if not os.path.isdir(self.cache): 37 | os.mkdir(self.cache) 38 | 39 | # check all files and download if missing 40 | uses_cache = False 41 | for local, remote in self.__class__.needs.items(): 42 | path = os.path.join(self.cache, local) 43 | 44 | if not os.path.exists(path): 45 | if force_cache: 46 | raise Exception('Resource missing from cache: {}'.format(local)) 47 | logger.info('Downloading {}'.format(remote)) 48 | filename = self.download(remote) 49 | 50 | # unzip 51 | if '.zip' == filename[-4:]: 52 | logger.info('Extracting {}'.format(filename)) 53 | self.expand(filename) 54 | else: 55 | uses_cache = True 56 | 57 | if uses_cache: 58 | logger.info('Using cached resources, supply "-f" to re-download') 59 | 60 | return self.cache 61 | 62 | def download(self, filename): 63 | """ Download the given file located on the server. 64 | 65 | :returns: The local file name in our cache directory the file was 66 | downloaded to 67 | """ 68 | import requests # import here as we can bypass its use with a manual download 69 | 70 | url = self.base_url+'/'+filename 71 | path = os.path.join(self.cache, filename) 72 | 73 | ret = requests.get(url) 74 | if not ret.ok: 75 | raise Exception("Failed to download {}".format(url)) 76 | with io.open(path, 'wb') as handle: 77 | for chunk in ret.iter_content(): 78 | handle.write(chunk) 79 | 80 | return filename 81 | 82 | def expand(self, local): 83 | """ Expand the ZIP file at the given path to the cache directory. 84 | """ 85 | path = os.path.join(self.cache, local) 86 | assert os.path.exists(path) 87 | import zipfile # import here as we can bypass its use with a manual unzip 88 | 89 | with zipfile.ZipFile(path) as z: 90 | z.extractall(self.cache) 91 | 92 | -------------------------------------------------------------------------------- /Sample/fhirreference.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Subclassing FHIR's reference to add resolving capabilities 5 | 6 | import logging 7 | from . import reference 8 | 9 | 10 | class FHIRReference(reference.Reference): 11 | """ Subclassing FHIR's `Reference` resource to add resolving capabilities. 12 | """ 13 | 14 | def resolved(self, klass): 15 | """ Resolves the reference and caches the result, returning instance(s) 16 | of the referenced classes. 17 | 18 | :param klass: The expected class of the resource 19 | :returns: An instance (or list thereof) of the resolved reference if 20 | dereferencing was successful, `None` otherwise 21 | """ 22 | owning_resource = self.owningResource() 23 | if owning_resource is None: 24 | raise Exception("Cannot resolve reference without having an owner (which must be a `DomainResource`)") 25 | if klass is None: 26 | raise Exception("Cannot resolve reference without knowing the class") 27 | 28 | refid = self.processedReferenceIdentifier() 29 | if not refid: 30 | logging.warning("No `reference` set, cannot resolve") 31 | return None 32 | 33 | # already resolved and cached? -> must be implemented in abstract base/resource 34 | 35 | # not yet resolved, see if it's a contained resource 36 | if owning_resource.contained is not None: 37 | for contained in owning_resource.contained: 38 | if contained.id == refid: 39 | owning_resource.didResolveReference(refid, contained) 40 | if isinstance(contained, klass): 41 | return contained 42 | logging.warning("Contained resource {} is not a {} but a {}".format(refid, klass, contained.__class__)) 43 | return None 44 | 45 | # are we in a bundle? 46 | ref_is_relative = '://' not in self.reference and 'urn:' != self.reference[:4] 47 | if (sys.version_info < (3, 0)): 48 | from . import bundle 49 | bundle = self.owningBundle() 50 | while bundle is not None: 51 | if bundle.entry is not None: 52 | fullUrl = self.reference 53 | if ref_is_relative: 54 | base = bundle.server.base_uri if bundle.server else '' 55 | fullUrl = base + self.reference 56 | 57 | for entry in bundle.entry: 58 | if entry.fullUrl == fullUrl: 59 | found = entry.resource 60 | if isinstance(found, klass): 61 | return found 62 | logging.warning("Bundled resource {} is not a {} but a {}".format(refid, klass, found.__class__)) 63 | return None 64 | bundle = bundle.owningBundle() 65 | 66 | # relative references, use the same server 67 | server = None 68 | if ref_is_relative: 69 | server = owning_resource.server if owning_resource else None 70 | 71 | # TODO: instantiate server for absolute resource 72 | if server is None: 73 | logging.warning("Not implemented: resolving absolute reference to resource {}" 74 | .format(self.reference)) 75 | return None 76 | 77 | # fetch remote resource; unable to verify klass since we use klass.read_from() 78 | relative = klass.read_from(self.reference, server) 79 | owning_resource.didResolveReference(refid, relative) 80 | return relative 81 | 82 | def processedReferenceIdentifier(self): 83 | """ Normalizes the reference-id. 84 | """ 85 | if self.reference and '#' == self.reference[0]: 86 | return self.reference[1:] 87 | return self.reference 88 | 89 | 90 | import sys 91 | if (sys.version_info > (3, 0)): # Python 2 imports are POS 92 | from . import bundle 93 | -------------------------------------------------------------------------------- /Default/settings.py: -------------------------------------------------------------------------------- 1 | # These are settings for the FHIR class generator. 2 | # All paths are relative to the `fhir-parser` directory. You can use '/' to 3 | # indicate directories: the parser will split them on '/' and use os.path to 4 | # make them platform independent. 5 | 6 | from Default.mappings import * 7 | 8 | 9 | # Base URL for where to load specification data from 10 | specification_url = 'http://hl7.org/fhir/R4' 11 | #specification_url = 'http://build.fhir.org' 12 | 13 | # To which directory to download to 14 | download_directory = 'downloads' 15 | 16 | # In which directory to find the templates. See below for settings that start with `tpl_`: these are the template names. 17 | tpl_base = 'Sample' 18 | 19 | # Whether and where to put the generated class models 20 | write_resources = True 21 | tpl_resource_source = 'template-resource.py' # the template to use as source when writing resource implementations for profiles 22 | tpl_resource_target = '../models' # target directory to write the generated class files to 23 | tpl_resource_target_ptrn = '{}.py' # target class file name pattern, with one placeholder (`{}`) for the class name 24 | tpl_codesystems_source = 'template-codesystems.py' # the template to use as source when writing enums for CodeSystems; can be `None` 25 | tpl_codesystems_target_ptrn = 'codesystem_{}.py' # the filename pattern to use for generated code systems and value sets, with one placeholder (`{}`) for the class name 26 | 27 | # Whether and where to put the factory methods and the dependency graph 28 | write_factory = True 29 | tpl_factory_source = 'template-elementfactory.py' # the template to use for factory generation 30 | tpl_factory_target = '../models/fhirelementfactory.py' # where to write the generated factory to 31 | write_dependencies = False 32 | tpl_dependencies_source = 'template-dependencies.json' # template used to render the JSON dependency graph 33 | tpl_dependencies_target = './dependencies.json' # write dependency JSON to project root 34 | 35 | # Whether and where to write unit tests 36 | write_unittests = True 37 | tpl_unittest_source = 'template-unittest.py' # the template to use for unit test generation 38 | tpl_unittest_target = '../models' # target directory to write the generated unit test files to 39 | tpl_unittest_target_ptrn = '{}_test.py' # target file name pattern for unit tests; the one placeholder (`{}`) will be the class name 40 | unittest_copyfiles = [] # array of file names to copy to the test directory `tpl_unittest_target` (e.g. unit test base classes) 41 | 42 | unittest_format_path_prepare = '{}' # used to format `path` before appending another path element - one placeholder for `path` 43 | unittest_format_path_key = '{}.{}' # used to create property paths by appending `key` to the existing `path` - two placeholders 44 | unittest_format_path_index = '{}[{}]' # used for array properties - two placeholders, `path` and the array index 45 | 46 | # Settings for classes and resources 47 | default_base = { 48 | 'complex-type': 'FHIRAbstractBase', # the class to use for "Element" types 49 | 'resource': 'FHIRAbstractResource', # the class to use for "Resource" types 50 | } 51 | resource_modules_lowercase = True # whether all resource paths (i.e. modules) should be lowercase 52 | camelcase_classes = True # whether class name generation should use CamelCase 53 | camelcase_enums = True # whether names for enums should be camelCased 54 | backbone_class_adds_parent = True # if True, backbone class names prepend their parent's class name 55 | 56 | # All these files should be copied to `tpl_resource_target`: tuples of (path/to/file, module, array-of-class-names) 57 | # If the path is None, no file will be copied but the class names will still be recognized and it is assumed the class is present. 58 | manual_profiles = [ 59 | ('Sample/fhirabstractbase.py', 'fhirabstractbase', [ 60 | 'boolean', 61 | 'string', 'base64Binary', 'code', 'id', 62 | 'decimal', 'integer', 'unsignedInt', 'positiveInt', 63 | 'uri', 'oid', 'uuid', 64 | 'FHIRAbstractBase', 65 | ]), 66 | ('Sample/fhirabstractresource.py', 'fhirabstractresource', ['FHIRAbstractResource']), 67 | ('Sample/fhirreference.py', 'fhirreference', ['FHIRReference']), 68 | ('Sample/fhirdate.py', 'fhirdate', ['date']), 69 | ('Sample/fhirdatetime.py', 'fhirdatetime', ['dateTime']), 70 | ('Sample/fhirinstant.py', 'fhirinstant', ['instant']), 71 | ('Sample/fhirtime.py', 'fhirtime', ['time']), 72 | ('Sample/_dateutils.py', '_dateutils', []), 73 | ] 74 | -------------------------------------------------------------------------------- /Sample/_dateutils.py: -------------------------------------------------------------------------------- 1 | """Private classes to help with date & time support.""" 2 | # 2014-2024, SMART Health IT. 3 | 4 | import datetime 5 | from typing import Union 6 | 7 | 8 | class _FHIRDateTimeMixin: 9 | """ 10 | Private mixin to provide helper methods for our date and time classes. 11 | 12 | Users of this mixin need to provide _REGEX and _FIELD properties and a from_string() method. 13 | """ 14 | 15 | def __init__(self, jsonval: Union[str, None] = None): 16 | super().__init__() 17 | 18 | setattr(self, self._FIELD, None) 19 | 20 | if jsonval is not None: 21 | if not isinstance(jsonval, str): 22 | raise TypeError("Expecting string when initializing {}, but got {}" 23 | .format(type(self), type(jsonval))) 24 | if not self._REGEX.fullmatch(jsonval): 25 | raise ValueError("does not match expected format") 26 | setattr(self, self._FIELD, self._from_string(jsonval)) 27 | 28 | self._orig_json: Union[str, None] = jsonval 29 | 30 | def __setattr__(self, prop, value): 31 | if self._FIELD == prop: 32 | self._orig_json = None 33 | object.__setattr__(self, prop, value) 34 | 35 | @property 36 | def isostring(self) -> Union[str, None]: 37 | """ 38 | Returns a standardized ISO 8601 version of the Python representation of the FHIR JSON. 39 | 40 | Note that this may not be a fully accurate version of the input JSON. 41 | In particular, it will convert partial dates like "2024" to full dates like "2024-01-01". 42 | It will also normalize the timezone, if present. 43 | """ 44 | py_value = getattr(self, self._FIELD) 45 | if py_value is None: 46 | return None 47 | return py_value.isoformat() 48 | 49 | def as_json(self) -> Union[str, None]: 50 | """Returns the original JSON string used to create this instance.""" 51 | if self._orig_json is not None: 52 | return self._orig_json 53 | return self.isostring 54 | 55 | @classmethod 56 | def with_json(cls, jsonobj: Union[str, list]): 57 | """ Initialize a date from an ISO date string. 58 | """ 59 | if isinstance(jsonobj, str): 60 | return cls(jsonobj) 61 | 62 | if isinstance(jsonobj, list): 63 | return [cls(jsonval) for jsonval in jsonobj] 64 | 65 | raise TypeError("`cls.with_json()` only takes string or list of strings, but you provided {}" 66 | .format(type(jsonobj))) 67 | 68 | @classmethod 69 | def with_json_and_owner(cls, jsonobj: Union[str, list], owner): 70 | """ Added for compatibility reasons to FHIRElement; "owner" is 71 | discarded. 72 | """ 73 | return cls.with_json(jsonobj) 74 | 75 | @staticmethod 76 | def _strip_leap_seconds(value: str) -> str: 77 | """ 78 | Manually ignore leap seconds by clamping the seconds value to 59. 79 | 80 | Python native times don't support them (at the time of this writing, but also watch 81 | https://bugs.python.org/issue23574). For example, the stdlib's datetime.fromtimestamp() 82 | also clamps to 59 if the system gives it leap seconds. 83 | 84 | But FHIR allows leap seconds and says receiving code SHOULD accept them, 85 | so we should be graceful enough to at least not throw a ValueError, 86 | even though we can't natively represent the most-correct time. 87 | """ 88 | # We can get away with such relaxed replacement because we are already regex-certified 89 | # and ":60" can't show up anywhere but seconds. 90 | return value.replace(":60", ":59") 91 | 92 | @staticmethod 93 | def _parse_partial(value: str, date_cls): 94 | """ 95 | Handle partial dates like 1970 or 1980-12. 96 | 97 | FHIR allows them, but Python's datetime classes do not natively parse them. 98 | """ 99 | # Note that `value` has already been regex-certified by this point, 100 | # so we don't have to handle really wild strings. 101 | if len(value) < 10: 102 | pieces = value.split("-") 103 | if len(pieces) == 1: 104 | return date_cls(int(pieces[0]), 1, 1) 105 | else: 106 | return date_cls(int(pieces[0]), int(pieces[1]), 1) 107 | return date_cls.fromisoformat(value) 108 | 109 | @classmethod 110 | def _parse_date(cls, value: str) -> datetime.date: 111 | return cls._parse_partial(value, datetime.date) 112 | 113 | @classmethod 114 | def _parse_datetime(cls, value: str) -> datetime.datetime: 115 | # Until we depend on Python 3.11+, manually handle Z 116 | value = value.replace("Z", "+00:00") 117 | value = cls._strip_leap_seconds(value) 118 | return cls._parse_partial(value, datetime.datetime) 119 | 120 | @classmethod 121 | def _parse_time(cls, value: str) -> datetime.time: 122 | value = cls._strip_leap_seconds(value) 123 | return datetime.time.fromisoformat(value) 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Python FHIR Parser 2 | ================== 3 | A Python FHIR specification parser for model class generation. 4 | If you've come here because you want _Swift_ or _Python_ classes for FHIR data models, look at our client libraries instead: 5 | 6 | - [Swift-FHIR][] and [Swift-SMART][] 7 | - Python [client-py][] 8 | 9 | The `main` branch is currently capable of parsing _R4_ 10 | and has preliminary support for _R5_. 11 | 12 | This work is licensed under the [APACHE license][license]. 13 | FHIR® is the registered trademark of [HL7][] and is used with the permission of HL7. 14 | 15 | 16 | Tech 17 | ---- 18 | 19 | The _generate.py_ script downloads [FHIR specification][fhir] files, parses the profiles (using _fhirspec.py_) and represents them as `FHIRClass` instances with `FHIRClassProperty` properties (found in _fhirclass.py_). 20 | Additionally, `FHIRUnitTest` (in _fhirunittest.py_) instances get created that can generate unit tests from provided FHIR examples. 21 | These representations are then used by [Jinja][] templates to create classes in certain programming languages, mentioned below. 22 | 23 | This script does its job for the most part, but it doesn't yet handle all FHIR peculiarities and there's no guarantee the output is correct or complete. 24 | This repository **does not include the templates and base classes** needed for class generation, you must do this yourself in your project. 25 | You will typically add this repo as a submodule to your framework project, create a directory that contains the necessary base classes and templates, create _settings_ and _mappings_ files and run the script. 26 | Examples on what you would need to do for Python classes can be found in _Default/settings.py_, _Default/mappings.py_ and _Sample/templates*_. 27 | 28 | 29 | Use 30 | --- 31 | 32 | 1. Add `fhir-parser` as a submodule/subdirectory to the project that will use it 33 | 2. Create the file `mappings.py` in your project, to be copied to fhir-parser root. 34 | First, import the default mappings using `from Default.mappings import *` (unless you will define all variables yourself anyway). 35 | Then adjust your `mappings.py` to your liking by overriding the mappings you wish to change. 36 | 3. Similarly, create the file `settings.py` in your project. 37 | First, import the default settings using `from Default.settings import *` and override any settings you want to change. 38 | Then, import the mappings you have just created with `from mappings import *`. 39 | The default settings import the default mappings, so you may need to overwrite more keys from _mappings_ than you'd first think. 40 | You most likely want to change the topmost settings found in the default file, which are determining where the templates can be found and generated classes will be copied to. 41 | 4. Install the generator's requirements by running `pip3` (or `pip`): 42 | ```bash 43 | pip3 install -r requirements.txt 44 | ``` 45 | 46 | 5. Create a script that copies your `mappings.py` and `settings.py` file to the root of `fhir-parser`, _cd_s into `fhir-parser` and then runs `generate.py`. 47 | The _generate_ script by default wants to use Python _3_, issue `python generate.py` if you don't have Python 3 yet. 48 | * Supply the `-f` flag to force a re-download of the spec. 49 | * Supply the `--cache-only` (`-c`) flag to deny the re-download of the spec and only use cached resources (incompatible with `-f`). 50 | 51 | > NOTE that the script currently overwrites existing files without asking and without regret. 52 | 53 | 54 | Languages 55 | ========= 56 | 57 | This repo used to contain templates for Python and Swift classes, but these have been moved to the respective framework repositories. 58 | A very basic Python sample implementation is included in the `Sample` directory, complementing the default _mapping_ and _settings_ files in `Default`. 59 | 60 | To get a sense of how to use _fhir-parser_, take a look at these libraries: 61 | 62 | - [**Swift-FHIR**][swift-fhir] 63 | - [**fhirclient**][client-py] 64 | 65 | 66 | Tech Details 67 | ============ 68 | 69 | This parser still applies some tricks, stemming from the evolving nature of FHIR's profile definitions. 70 | Some tricks may have become obsolete and should be cleaned up. 71 | 72 | ### How are property names determined? 73 | 74 | Every “property” of a class, meaning every `element` in a profile snapshot, is represented as a `FHIRStructureDefinitionElement` instance. 75 | If an element itself defines a class, e.g. `Patient.animal`, calling the instance's `as_properties()` method returns a list of `FHIRClassProperty` instances – usually only one – that indicates a class was found in the profile. 76 | The class of this property is derived from `element.type`, which is expected to only contain one entry, in this matter: 77 | 78 | - If _type_ is `BackboneElement`, a class name is constructed from the parent element (in this case _Patient_) and the property name (in this case _animal_), camel-cased (in this case _PatientAnimal_). 79 | - Otherwise, the type is taken as-is (e.g. _CodeableConcept_) and mapped according to mappings' `classmap`, which is expected to be a valid FHIR class. 80 | 81 | > TODO: should `http://hl7.org/fhir/StructureDefinition/structuredefinition-explicit-type-name` be respected? 82 | 83 | 84 | [license]: ./LICENSE.txt 85 | [hl7]: http://hl7.org/ 86 | [fhir]: http://www.hl7.org/implement/standards/fhir/ 87 | [jinja]: http://jinja.pocoo.org/ 88 | [swift]: https://developer.apple.com/swift/ 89 | [swift-fhir]: https://github.com/smart-on-fhir/Swift-FHIR 90 | [swift-smart]: https://github.com/smart-on-fhir/Swift-SMART 91 | [client-py]: https://github.com/smart-on-fhir/client-py 92 | -------------------------------------------------------------------------------- /Sample/fhirabstractresource.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Base class for FHIR resources. 5 | # 2014, SMART Health IT. 6 | 7 | from . import fhirabstractbase 8 | 9 | 10 | class FHIRAbstractResource(fhirabstractbase.FHIRAbstractBase): 11 | """ Extends the FHIRAbstractBase with server talking capabilities. 12 | """ 13 | resource_type = 'FHIRAbstractResource' 14 | 15 | def __init__(self, jsondict=None, strict=True): 16 | self._server = None 17 | """ The server the instance was read from. """ 18 | 19 | # raise if "resourceType" does not match 20 | if jsondict is not None and 'resourceType' in jsondict \ 21 | and jsondict['resourceType'] != self.resource_type: 22 | raise Exception("Attempting to instantiate {} with resource data that defines a resourceType of \"{}\"" 23 | .format(self.__class__, jsondict['resourceType'])) 24 | 25 | super(FHIRAbstractResource, self).__init__(jsondict=jsondict, strict=strict) 26 | 27 | @classmethod 28 | def _with_json_dict(cls, jsondict): 29 | """ Overridden to use a factory if called when "resourceType" is 30 | defined in the JSON but does not match the receiver's resource_type. 31 | """ 32 | if not isinstance(jsondict, dict): 33 | raise Exception("Cannot use this method with anything but a JSON dictionary, got {}" 34 | .format(jsondict)) 35 | 36 | res_type = jsondict.get('resourceType') 37 | if res_type and res_type != cls.resource_type: 38 | return fhirelementfactory.FHIRElementFactory.instantiate(res_type, jsondict) 39 | return super(FHIRAbstractResource, cls)._with_json_dict(jsondict) 40 | 41 | def as_json(self): 42 | js = super(FHIRAbstractResource, self).as_json() 43 | js['resourceType'] = self.resource_type 44 | return js 45 | 46 | 47 | # MARK: Handling Paths 48 | 49 | def relativeBase(self): 50 | return self.__class__.resource_type 51 | 52 | def relativePath(self): 53 | if self.id is None: 54 | return self.relativeBase() 55 | return "{}/{}".format(self.relativeBase(), self.id) 56 | 57 | 58 | # MARK: - Server Connection 59 | 60 | @property 61 | def server(self): 62 | """ Walks the owner hierarchy until it finds an owner with a server. 63 | """ 64 | if self._server is None: 65 | owningRes = self.owningResource() 66 | self._server = owningRes.server if owningRes is not None else None 67 | return self._server 68 | 69 | @classmethod 70 | def read(cls, rem_id, server): 71 | """ Read the resource with the given id from the given server. The 72 | passed-in server instance must support a `request_json()` method call, 73 | taking a relative path as first (and only mandatory) argument. 74 | 75 | :param str rem_id: The id of the resource on the remote server 76 | :param FHIRServer server: An instance of a FHIR server or compatible class 77 | :returns: An instance of the receiving class 78 | """ 79 | if not rem_id: 80 | raise Exception("Cannot read resource without remote id") 81 | 82 | path = '{}/{}'.format(cls.resource_type, rem_id) 83 | instance = cls.read_from(path, server) 84 | instance._local_id = rem_id 85 | 86 | return instance 87 | 88 | @classmethod 89 | def read_from(cls, path, server): 90 | """ Requests data from the given REST path on the server and creates 91 | an instance of the receiving class. 92 | 93 | :param str path: The REST path to read from 94 | :param FHIRServer server: An instance of a FHIR server or compatible class 95 | :returns: An instance of the receiving class 96 | """ 97 | if not path: 98 | raise Exception("Cannot read resource without REST path") 99 | if server is None: 100 | raise Exception("Cannot read resource without server instance") 101 | 102 | ret = server.request_json(path) 103 | instance = cls(jsondict=ret) 104 | instance._server = server 105 | return instance 106 | 107 | def create(self, server): 108 | """ Attempt to create the receiver on the given server, using a POST 109 | command. 110 | 111 | :param FHIRServer server: The server to create the receiver on 112 | :returns: None or the response JSON on success 113 | """ 114 | srv = server or self.server 115 | if srv is None: 116 | raise Exception("Cannot create a resource without a server") 117 | if self.id: 118 | raise Exception("This resource already has an id, cannot create") 119 | 120 | ret = srv.post_json(self.relativeBase(), self.as_json()) 121 | if len(ret.text) > 0: 122 | return ret.json() 123 | return None 124 | 125 | def update(self, server=None): 126 | """ Update the receiver's representation on the given server, issuing 127 | a PUT command. 128 | 129 | :param FHIRServer server: The server to update the receiver on; 130 | optional, will use the instance's `server` if needed. 131 | :returns: None or the response JSON on success 132 | """ 133 | srv = server or self.server 134 | if srv is None: 135 | raise Exception("Cannot update a resource that does not have a server") 136 | if not self.id: 137 | raise Exception("Cannot update a resource that does not have an id") 138 | 139 | ret = srv.put_json(self.relativePath(), self.as_json()) 140 | if len(ret.text) > 0: 141 | return ret.json() 142 | return None 143 | 144 | def delete(self): 145 | """ Delete the receiver from the given server with a DELETE command. 146 | 147 | :returns: None or the response JSON on success 148 | """ 149 | if self.server is None: 150 | raise Exception("Cannot delete a resource that does not have a server") 151 | if not self.id: 152 | raise Exception("Cannot delete a resource that does not have an id") 153 | 154 | ret = self.server.delete_json(self.relativePath()) 155 | if len(ret.text) > 0: 156 | return ret.json() 157 | return None 158 | 159 | 160 | from . import fhirelementfactory 161 | -------------------------------------------------------------------------------- /fhirunittest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import io 5 | import re 6 | import sys 7 | import glob 8 | import json 9 | import os.path 10 | 11 | from logger import logger 12 | import fhirclass 13 | 14 | 15 | class FHIRUnitTestController(object): 16 | """ Can create unit tests from example files. 17 | """ 18 | 19 | def __init__(self, spec, settings=None): 20 | self.spec = spec 21 | self.settings = settings or spec.settings 22 | self.files = None 23 | self.collections = None 24 | 25 | def find_and_parse_tests(self, directory): 26 | self.files = FHIRResourceFile.find_all(directory) 27 | 28 | # create tests 29 | tests = [] 30 | for resource in self.files: 31 | test = self.unittest_for_resource(resource) 32 | if test is not None: 33 | tests.append(test) 34 | 35 | # collect per class 36 | collections = {} 37 | for test in tests: 38 | coll = collections.get(test.klass.name) 39 | if coll is None: 40 | coll = FHIRUnitTestCollection(test.klass) 41 | collections[test.klass.name] = coll 42 | coll.add_test(test) 43 | 44 | self.collections = [v for k,v in collections.items()] 45 | 46 | 47 | # MARK: Utilities 48 | 49 | def unittest_for_resource(self, resource): 50 | """ Returns a FHIRUnitTest instance or None for the given resource, 51 | depending on if the class to be tested is known. 52 | """ 53 | classname = resource.content.get('resourceType') 54 | assert classname 55 | if classname in self.settings.classmap: 56 | classname = self.settings.classmap[classname] 57 | klass = fhirclass.FHIRClass.with_name(classname) 58 | if klass is None: 59 | logger.error('There is no class for "{}", cannot create unit tests' 60 | .format(classname)) 61 | return None 62 | 63 | return FHIRUnitTest(self, resource.filepath, resource.content, klass) 64 | 65 | def make_path(self, prefix, key): 66 | """ Takes care of combining prefix and key into a path. 67 | """ 68 | path = key 69 | if prefix: 70 | path = self.settings.unittest_format_path_key.format(prefix, path) 71 | return path 72 | 73 | 74 | class FHIRUnitTestCollection(object): 75 | """ Represents a FHIR unit test collection, meaning unit tests pertaining 76 | to one data model class, to be run against local sample files. 77 | """ 78 | def __init__(self, klass): 79 | self.klass = klass 80 | self.tests = [] 81 | 82 | def add_test(self, test): 83 | if test is not None: 84 | if len(self.tests) < 10: 85 | self.tests.append(test) # let's assume we don't need 100s of unit tests 86 | 87 | 88 | class FHIRUnitTest(object): 89 | """ Holds on to unit tests (FHIRUnitTestItem in `tests`) that are to be run 90 | against one data model class (`klass`). 91 | """ 92 | def __init__(self, controller, filepath, content, klass, prefix=None): 93 | assert content and klass 94 | self.controller = controller 95 | self.filepath = filepath 96 | self.filename = os.path.basename(filepath) 97 | self.content = content 98 | self.klass = klass 99 | self.prefix = prefix 100 | 101 | self.tests = None 102 | self.expand() 103 | 104 | def expand(self): 105 | """ Expand into a list of FHIRUnitTestItem_name instances. 106 | """ 107 | tests = [] 108 | for key, val in self.content.items(): 109 | if 'resourceType' == key or 'fhir_comments' == key or '_' == key[:1]: 110 | continue 111 | 112 | prop = self.klass.property_for(key) 113 | if prop is None: 114 | path = "{}.{}".format(self.prefix, key) if self.prefix else key 115 | logger.warning('Unknown property "{}" in unit test on {} in {}' 116 | .format(path, self.klass.name, self.filepath)) 117 | else: 118 | propclass = fhirclass.FHIRClass.with_name(prop.class_name) 119 | if propclass is None: 120 | path = "{}.{}".format(self.prefix, prop.name) if self.prefix else prop.name 121 | logger.error('There is no class "{}" for property "{}" in {}' 122 | .format(prop.class_name, path, self.filepath)) 123 | else: 124 | path = self.controller.make_path(self.prefix, prop.name) 125 | 126 | if list == type(val): 127 | i = 0 128 | for ival in val: 129 | idxpath = self.controller.settings.unittest_format_path_index.format(path, i) 130 | item = FHIRUnitTestItem(self.filepath, idxpath, ival, propclass, True, prop.enum) 131 | tests.extend(item.create_tests(self.controller)) 132 | i += 1 133 | if i >= 10: # let's assume we don't need 100s of unit tests 134 | break 135 | else: 136 | item = FHIRUnitTestItem(self.filepath, path, val, propclass, False, prop.enum) 137 | tests.extend(item.create_tests(self.controller)) 138 | 139 | self.tests = sorted(tests, key=lambda t: t.path) 140 | 141 | 142 | class FHIRUnitTestItem(object): 143 | """ Represents unit tests to be performed against a single data model 144 | property. If the property itself is an element, will be expanded into 145 | more FHIRUnitTestItem that cover its own properties. 146 | """ 147 | 148 | def __init__(self, filepath, path, value, klass, array_item, enum_item): 149 | assert path 150 | assert value is not None, (filepath, path) 151 | assert klass 152 | self.filepath = filepath # needed for debug logging 153 | self.path = path 154 | self.value = value 155 | self.klass = klass 156 | self.array_item = array_item 157 | self.enum = enum_item.name if enum_item is not None else None 158 | 159 | def create_tests(self, controller): 160 | """ Creates as many FHIRUnitTestItem instances as the item defines, or 161 | just returns a list containing itself if this property is not an 162 | element. 163 | 164 | :returns: A list of FHIRUnitTestItem items, never None 165 | """ 166 | tests = [] 167 | 168 | # property is another element, recurse 169 | if dict == type(self.value): 170 | prefix = self.path 171 | if not self.array_item: 172 | prefix = controller.settings.unittest_format_path_prepare.format(prefix) 173 | 174 | test = FHIRUnitTest(controller, self.filepath, self.value, self.klass, prefix) 175 | tests.extend(test.tests) 176 | 177 | # regular test case; skip string tests that are longer than 200 chars 178 | else: 179 | isstr = isinstance(self.value, str) 180 | if not isstr and sys.version_info[0] < 3: # Python 2.x has 'str' and 'unicode' 181 | isstr = isinstance(self.value, basestring) 182 | 183 | value = self.value 184 | if isstr: 185 | if len(value) > 200: 186 | return tests 187 | elif not hasattr(value, 'isprintable'): # Python 2.x doesn't have it 188 | try: 189 | value.decode('utf-8') 190 | except Exception: 191 | return tests 192 | elif not value.isprintable(): 193 | return tests 194 | 195 | value = self.value.replace("\n", "\\n") 196 | tests.append(self) 197 | 198 | return tests 199 | 200 | def __repr__(self): 201 | return 'Unit Test Case "{}": "{}"'.format(self.path, self.value) 202 | 203 | 204 | class FHIRResourceFile(object): 205 | """ A FHIR example resource file. 206 | """ 207 | @classmethod 208 | def find_all(cls, directory): 209 | """ Finds all example JSON files in the given directory. 210 | """ 211 | assert os.path.isdir(directory) 212 | all_tests = [] 213 | for utest in glob.glob(os.path.join(directory, '*-example*.json')): 214 | if 'canonical.json' not in utest: 215 | all_tests.append(cls(filepath=utest)) 216 | 217 | return all_tests 218 | 219 | def __init__(self, filepath): 220 | self.filepath = filepath 221 | self._content = None 222 | 223 | @property 224 | def content(self): 225 | """ Process the unit test file, determining class structure 226 | from the given classes dict. 227 | 228 | :returns: A tuple with (top-class-name, [test-dictionaries]) 229 | """ 230 | if self._content is None: 231 | logger.info('Parsing unit test {}'.format(os.path.basename(self.filepath))) 232 | utest = None 233 | assert os.path.exists(self.filepath) 234 | with io.open(self.filepath, 'r', encoding='utf-8') as handle: 235 | utest = json.load(handle) 236 | assert utest 237 | self._content = utest 238 | 239 | return self._content 240 | 241 | -------------------------------------------------------------------------------- /tests/fhirdate_test.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import unittest 3 | 4 | from models.fhirabstractbase import FHIRValidationError 5 | from models.fhirdate import FHIRDate 6 | from models.fhirdatetime import FHIRDateTime 7 | from models.fhirinstant import FHIRInstant 8 | from models.fhirtime import FHIRTime 9 | from models.patient import Patient 10 | from models.observation import Observation 11 | from models.timing import Timing 12 | 13 | 14 | class TestFHIRDate(unittest.TestCase): 15 | 16 | def test_empty(self): 17 | date = FHIRDate() 18 | self.assertIsNone(date.date) 19 | self.assertIsNone(date.isostring) 20 | self.assertIsNone(date.as_json()) 21 | 22 | def test_object_validation(self): 23 | """Confirm that when constructing an invalid JSON class, we complain""" 24 | with self.assertRaisesRegex(FHIRValidationError, "Expecting string when initializing"): 25 | Timing({"event": [1923, "1924"]}) 26 | with self.assertRaisesRegex(FHIRValidationError, "does not match expected format"): 27 | Patient({"birthDate": "1923-10-11T12:34:56Z"}) 28 | 29 | def test_with_json(self): 30 | """Confirm we can make objects correctly""" 31 | self.assertEqual(FHIRDate.with_json_and_owner("2024", None).isostring, "2024-01-01") 32 | self.assertEqual(FHIRTime.with_json("10:12:14").isostring, "10:12:14") 33 | self.assertEqual( 34 | [x.isostring for x in FHIRTime.with_json(["10:12:14", "01:01:01"])], 35 | ["10:12:14", "01:01:01"] 36 | ) 37 | with self.assertRaisesRegex(TypeError, "only takes string or list"): 38 | FHIRDateTime.with_json(2024) 39 | 40 | def test_date(self): 41 | """ 42 | Verify we parse valid date values. 43 | 44 | From http://hl7.org/fhir/datatypes.html#date: 45 | - The format is YYYY, YYYY-MM, or YYYY-MM-DD, e.g. 2018, 1973-06, or 1905-08-23. 46 | - There SHALL be no timezone offset 47 | """ 48 | # Various happy path strings 49 | self.assertEqual(FHIRDate("0001").isostring, "0001-01-01") 50 | self.assertEqual(FHIRDate("2018").isostring, "2018-01-01") 51 | self.assertEqual(FHIRDate("1973-06").isostring, "1973-06-01") 52 | self.assertEqual(FHIRDate("1905-08-23").isostring, "1905-08-23") 53 | 54 | # Check that we also correctly provide the date property 55 | date = FHIRDate("1982").date # datetime.date 56 | self.assertIsInstance(date, datetime.date) 57 | self.assertEqual(date.isoformat(), "1982-01-01") 58 | 59 | # Check that we give back the original input when converting back to as_json() 60 | self.assertEqual(FHIRDate("1982").as_json(), "1982") 61 | 62 | # Confirm we're used in actual objects 63 | p = Patient({"birthDate": "1923-10-11"}) 64 | self.assertIsInstance(p.birthDate, FHIRDate) 65 | self.assertEqual(p.birthDate.isostring, "1923-10-11") 66 | 67 | # Now test a bunch of invalid strings 68 | self.assertRaises(ValueError, FHIRDate, "82") 69 | self.assertRaises(ValueError, FHIRDate, "82/07/23") 70 | self.assertRaises(ValueError, FHIRDate, "07-23-1982") 71 | self.assertRaises(ValueError, FHIRDate, "13:28:17") 72 | self.assertRaises(ValueError, FHIRDate, "2015-02-07T13:28:17-05:00") 73 | 74 | def test_datetime(self): 75 | """ 76 | Verify we parse valid datetime values. 77 | 78 | From http://hl7.org/fhir/datatypes.html#datetime: 79 | - The format is YYYY, YYYY-MM, YYYY-MM-DD or YYYY-MM-DDThh:mm:ss+zz:zz 80 | - e.g. 2018, 1973-06, 1905-08-23, 2015-02-07T13:28:17-05:00 or 2017-01-01T00:00:00.000Z 81 | - If hours and minutes are specified, a timezone offset SHALL be populated 82 | - Seconds must be provided due to schema type constraints 83 | but may be zero-filled and may be ignored at receiver discretion. 84 | - Milliseconds are optionally allowed (the spec's regex actually allows arbitrary 85 | sub-second precision) 86 | """ 87 | # Various happy path strings 88 | self.assertEqual(FHIRDateTime("2018").isostring, "2018-01-01T00:00:00") 89 | self.assertEqual(FHIRDateTime("1973-06").isostring, "1973-06-01T00:00:00") 90 | self.assertEqual(FHIRDateTime("1905-08-23").isostring, "1905-08-23T00:00:00") 91 | self.assertEqual( 92 | FHIRDateTime("2015-02-07T13:28:17-05:00").isostring, "2015-02-07T13:28:17-05:00" 93 | ) 94 | self.assertEqual( 95 | FHIRDateTime("2017-01-01T00:00:00.123456Z").isostring, 96 | "2017-01-01T00:00:00.123456+00:00" 97 | ) 98 | self.assertEqual( # leap second 99 | FHIRDateTime("2015-02-07T13:28:60Z").isostring, "2015-02-07T13:28:59+00:00" 100 | ) 101 | 102 | # Check that we also correctly provide the datetime property 103 | self.assertIsInstance(FHIRDateTime("2015").datetime, datetime.datetime) 104 | self.assertIsInstance(FHIRDateTime("2015-02-07").datetime, datetime.datetime) 105 | self.assertIsInstance(FHIRDateTime("2015-02-07T13:28:17Z").datetime, datetime.datetime) 106 | 107 | # Check that we give back the original input when converting back to as_json() 108 | self.assertEqual(FHIRDateTime("1982").as_json(), "1982") 109 | 110 | # Confirm we're used in actual objects 111 | p = Patient({"deceasedDateTime": "1923-10-11"}) 112 | self.assertIsInstance(p.deceasedDateTime, FHIRDateTime) 113 | self.assertEqual(p.deceasedDateTime.isostring, "1923-10-11T00:00:00") 114 | 115 | # Now test a bunch of invalid strings 116 | self.assertRaises(ValueError, FHIRDateTime, "82") 117 | self.assertRaises(ValueError, FHIRDateTime, "82/07/23") 118 | self.assertRaises(ValueError, FHIRDateTime, "07-23-1982") 119 | self.assertRaises(ValueError, FHIRDateTime, "13:28:17") 120 | self.assertRaises(ValueError, FHIRDateTime, "2015-02-07T13:28") # no seconds 121 | self.assertRaises(ValueError, FHIRDateTime, "2015-02-07T13:28:17") # no timezone 122 | 123 | def test_instant(self): 124 | """ 125 | Verify we parse valid instant values. 126 | 127 | From http://hl7.org/fhir/datatypes.html#instant: 128 | - The format is YYYY-MM-DDThh:mm:ss.sss+zz:zz 129 | - e.g. 2015-02-07T13:28:17.239+02:00 or 2017-01-01T00:00:00Z 130 | - The time SHALL be specified at least to the second and SHALL include a time zone. 131 | """ 132 | # Various happy path strings 133 | self.assertEqual( 134 | FHIRInstant("2015-02-07T13:28:17-05:00").isostring, "2015-02-07T13:28:17-05:00" 135 | ) 136 | self.assertEqual( 137 | FHIRInstant("2017-01-01T00:00:00.123456Z").isostring, 138 | "2017-01-01T00:00:00.123456+00:00" 139 | ) 140 | self.assertEqual( # leap second 141 | FHIRInstant("2017-01-01T00:00:60Z").isostring, "2017-01-01T00:00:59+00:00" 142 | ) 143 | 144 | # Check that we also correctly provide the datetime property 145 | self.assertIsInstance(FHIRInstant("2015-02-07T13:28:17Z").datetime, datetime.datetime) 146 | 147 | # Check that we give back the original input when converting back to as_json() 148 | self.assertEqual( 149 | FHIRInstant("2017-01-01T00:00:00Z").as_json(), 150 | "2017-01-01T00:00:00Z" # Z instead of +00.00 151 | ) 152 | 153 | # Confirm we're used in actual objects 154 | obs = Observation({ 155 | "issued": "2017-01-01T00:00:00.123Z", 156 | "status": "X", 157 | "code": {"text": "X"}}, 158 | ) 159 | self.assertIsInstance(obs.issued, FHIRInstant) 160 | self.assertEqual(obs.issued.isostring, "2017-01-01T00:00:00.123000+00:00") 161 | 162 | # Now test a bunch of invalid strings 163 | self.assertRaises(ValueError, FHIRInstant, "82") 164 | self.assertRaises(ValueError, FHIRInstant, "82/07/23") 165 | self.assertRaises(ValueError, FHIRInstant, "07-23-1982") 166 | self.assertRaises(ValueError, FHIRInstant, "13:28:17") 167 | self.assertRaises(ValueError, FHIRInstant, "2015") 168 | self.assertRaises(ValueError, FHIRInstant, "2015-02-07") 169 | self.assertRaises(ValueError, FHIRInstant, "2015-02-07T13:28") # no seconds 170 | self.assertRaises(ValueError, FHIRInstant, "2015-02-07T13:28:17") # no timezone 171 | 172 | def test_time(self): 173 | """ 174 | Verify we parse valid time values. 175 | 176 | From http://hl7.org/fhir/datatypes.html#time: 177 | - The format is hh:mm:ss 178 | - A timezone offset SHALL NOT be present 179 | - Uses 24-hour time 180 | - Sub-seconds allowed (to arbitrary precision, per the regex...) 181 | """ 182 | # Various happy path strings 183 | self.assertEqual(FHIRTime("13:28:17").isostring, "13:28:17") 184 | self.assertEqual(FHIRTime("13:28:17.123456").isostring, "13:28:17.123456") 185 | self.assertEqual(FHIRTime("00:00:60").isostring, "00:00:59") # leap second 186 | 187 | # Check that we also correctly provide the time property 188 | self.assertIsInstance(FHIRTime("13:28:17").time, datetime.time) 189 | 190 | # Check that we give back the original input when converting back to as_json() 191 | self.assertEqual(FHIRTime("00:00:00").as_json(), "00:00:00") 192 | 193 | # Confirm we're used in actual objects 194 | obs = Observation({"valueTime": "14:49:32", "status": "X", "code": {"text": "X"}}) 195 | self.assertIsInstance(obs.valueTime, FHIRTime) 196 | self.assertEqual(obs.valueTime.isostring, "14:49:32") 197 | 198 | # Now test a bunch of invalid strings 199 | self.assertRaises(ValueError, FHIRTime, "82") 200 | self.assertRaises(ValueError, FHIRTime, "82/07/23") 201 | self.assertRaises(ValueError, FHIRTime, "07-23-1982") 202 | self.assertRaises(ValueError, FHIRTime, "2015") 203 | self.assertRaises(ValueError, FHIRTime, "2015-02-07T13:28:17Z") 204 | self.assertRaises(ValueError, FHIRTime, "10:12") 205 | -------------------------------------------------------------------------------- /fhirrenderer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import io 5 | import os 6 | import re 7 | import shutil 8 | import textwrap 9 | 10 | from jinja2 import Environment, PackageLoader, TemplateNotFound 11 | from jinja2.filters import pass_environment 12 | from logger import logger 13 | 14 | 15 | class FHIRRenderer(object): 16 | """ Superclass for all renderer implementations. 17 | """ 18 | 19 | def __init__(self, spec, settings): 20 | self.spec = spec 21 | self.settings = self.__class__.cleaned_settings(settings) 22 | self.jinjaenv = Environment(loader=PackageLoader('generate', self.settings.tpl_base)) 23 | self.jinjaenv.filters['wordwrap'] = do_wordwrap 24 | 25 | @classmethod 26 | def cleaned_settings(cls, settings): 27 | """ Splits paths at '/' and re-joins them using os.path.join(). 28 | """ 29 | settings.tpl_base = os.path.join(*settings.tpl_base.split('/')) 30 | settings.tpl_resource_target = os.path.join(*settings.tpl_resource_target.split('/')) 31 | settings.tpl_factory_target = os.path.join(*settings.tpl_factory_target.split('/')) 32 | settings.tpl_unittest_target = os.path.join(*settings.tpl_unittest_target.split('/')) 33 | settings.tpl_resource_target = os.path.join(*settings.tpl_resource_target.split('/')) 34 | return settings 35 | 36 | def render(self): 37 | """ The main rendering start point, for subclasses to override. 38 | """ 39 | raise Exception("Cannot use abstract superclass' `render` method") 40 | 41 | def do_render(self, data, template_name, target_path): 42 | """ Render the given data using a Jinja2 template, writing to the file 43 | at the target path. 44 | 45 | :param template_name: The Jinja2 template to render, located in settings.tpl_base 46 | :param target_path: Output path 47 | """ 48 | try: 49 | template = self.jinjaenv.get_template(template_name) 50 | except TemplateNotFound as e: 51 | logger.error("Template \"{}\" not found in «{}», cannot render" 52 | .format(template_name, self.settings.tpl_base)) 53 | return 54 | 55 | if not target_path: 56 | raise Exception("No target filepath provided") 57 | dirpath = os.path.dirname(target_path) 58 | if not os.path.isdir(dirpath): 59 | os.makedirs(dirpath) 60 | 61 | with io.open(target_path, 'w', encoding='utf-8') as handle: 62 | logger.info('Writing {}'.format(target_path)) 63 | rendered = template.render(data) 64 | handle.write(rendered) 65 | # handle.write(rendered.encode('utf-8')) 66 | 67 | 68 | class FHIRStructureDefinitionRenderer(FHIRRenderer): 69 | """ Write classes for a profile/structure-definition. 70 | """ 71 | def copy_files(self, target_dir): 72 | """ Copy base resources to the target location, according to settings. 73 | """ 74 | for origpath, module, contains in self.settings.manual_profiles: 75 | if not origpath: 76 | continue 77 | filepath = os.path.join(*origpath.split('/')) 78 | if os.path.exists(filepath): 79 | tgt = os.path.join(target_dir, os.path.basename(filepath)) 80 | logger.info("Copying manual profiles in {} to {}".format(os.path.basename(filepath), tgt)) 81 | shutil.copyfile(filepath, tgt) 82 | 83 | def render(self): 84 | for profile in self.spec.writable_profiles(): 85 | classes = sorted(profile.writable_classes(), key=lambda x: x.name) 86 | if 0 == len(classes): 87 | if profile.url is not None: # manual profiles have no url and usually write no classes 88 | logger.info('Profile "{}" returns zero writable classes, skipping'.format(profile.url)) 89 | continue 90 | 91 | imports = profile.needed_external_classes() 92 | data = { 93 | 'profile': profile, 94 | 'info': self.spec.info, 95 | 'imports': imports, 96 | 'classes': classes 97 | } 98 | 99 | ptrn = profile.targetname.lower() if self.settings.resource_modules_lowercase else profile.targetname 100 | source_path = self.settings.tpl_resource_source 101 | target_name = self.settings.tpl_resource_target_ptrn.format(ptrn) 102 | target_path = os.path.join(self.settings.tpl_resource_target, target_name) 103 | 104 | self.do_render(data, source_path, target_path) 105 | self.copy_files(os.path.dirname(target_path)) 106 | 107 | 108 | class FHIRFactoryRenderer(FHIRRenderer): 109 | """ Write factories for FHIR classes. 110 | """ 111 | def render(self): 112 | classes = [] 113 | for profile in self.spec.writable_profiles(): 114 | classes.extend(profile.writable_classes()) 115 | 116 | data = { 117 | 'info': self.spec.info, 118 | 'classes': sorted(classes, key=lambda x: x.name), 119 | } 120 | self.do_render(data, self.settings.tpl_factory_source, self.settings.tpl_factory_target) 121 | 122 | 123 | class FHIRDependencyRenderer(FHIRRenderer): 124 | """ Puts down dependencies for each of the FHIR resources. Per resource 125 | class will grab all class/resource names that are needed for its 126 | properties and add them to the "imports" key. Will also check 127 | classes/resources may appear in references and list those in the 128 | "references" key. 129 | """ 130 | def render(self): 131 | data = {'info': self.spec.info} 132 | resources = [] 133 | for profile in self.spec.writable_profiles(): 134 | resources.append({ 135 | 'name': profile.targetname, 136 | 'imports': profile.needed_external_classes(), 137 | 'references': profile.referenced_classes(), 138 | }) 139 | data['resources'] = sorted(resources, key=lambda x: x['name']) 140 | self.do_render(data, self.settings.tpl_dependencies_source, self.settings.tpl_dependencies_target) 141 | 142 | 143 | class FHIRValueSetRenderer(FHIRRenderer): 144 | """ Write ValueSet and CodeSystem contained in the FHIR spec. 145 | """ 146 | def render(self): 147 | if not self.settings.tpl_codesystems_source: 148 | logger.info("Not rendering value sets and code systems since `tpl_codesystems_source` is not set") 149 | return 150 | 151 | systems = [v for k,v in self.spec.codesystems.items()] 152 | for system in sorted(systems, key=lambda x: x.name): 153 | if not system.generate_enum: 154 | continue 155 | 156 | data = { 157 | 'info': self.spec.info, 158 | 'system': system, 159 | } 160 | target_name = self.settings.tpl_codesystems_target_ptrn.format(system.name) 161 | target_path = os.path.join(self.settings.tpl_resource_target, target_name) 162 | self.do_render(data, self.settings.tpl_codesystems_source, target_path) 163 | 164 | 165 | class FHIRUnitTestRenderer(FHIRRenderer): 166 | """ Write unit tests. 167 | """ 168 | def render(self): 169 | if self.spec.unit_tests is None: 170 | return 171 | 172 | # render all unit test collections 173 | for coll in self.spec.unit_tests: 174 | data = { 175 | 'info': self.spec.info, 176 | 'class': coll.klass, 177 | 'tests': coll.tests, 178 | } 179 | 180 | file_pattern = coll.klass.name 181 | if self.settings.resource_modules_lowercase: 182 | file_pattern = file_pattern.lower() 183 | file_name = self.settings.tpl_unittest_target_ptrn.format(file_pattern) 184 | file_path = os.path.join(self.settings.tpl_unittest_target, file_name) 185 | 186 | self.do_render(data, self.settings.tpl_unittest_source, file_path) 187 | 188 | # copy unit test files, if any 189 | if self.settings.unittest_copyfiles is not None: 190 | for origfile in self.settings.unittest_copyfiles: 191 | utfile = os.path.join(*origfile.split('/')) 192 | if os.path.exists(utfile): 193 | target = os.path.join(self.settings.tpl_unittest_target, os.path.basename(utfile)) 194 | logger.info('Copying unittest file {} to {}'.format(os.path.basename(utfile), target)) 195 | shutil.copyfile(utfile, target) 196 | else: 197 | logger.warn("Unit test file \"{}\" configured in `unittest_copyfiles` does not exist" 198 | .format(utfile)) 199 | 200 | 201 | # There is a bug in Jinja's wordwrap (inherited from `textwrap`) in that it 202 | # ignores existing linebreaks when applying the wrap: 203 | # https://github.com/mitsuhiko/jinja2/issues/175 204 | # Here's the workaround: 205 | @pass_environment 206 | def do_wordwrap(environment, s, width=79, break_long_words=True, wrapstring=None): 207 | """ 208 | Return a copy of the string passed to the filter wrapped after 209 | ``79`` characters. You can override this default using the first 210 | parameter. If you set the second parameter to `false` Jinja will not 211 | split words apart if they are longer than `width`. 212 | """ 213 | if not s: 214 | return s 215 | 216 | if not wrapstring: 217 | wrapstring = environment.newline_sequence 218 | 219 | accumulator = [] 220 | # Workaround: pre-split the string on \r, \r\n and \n 221 | for component in re.split(r"\r\n|\n|\r", s): 222 | # textwrap will eat empty strings for breakfirst. Therefore we route them around it. 223 | if len(component) == 0: 224 | accumulator.append(component) 225 | continue 226 | accumulator.extend( 227 | textwrap.wrap(component, width=width, expand_tabs=False, 228 | replace_whitespace=False, 229 | break_long_words=break_long_words) 230 | ) 231 | return wrapstring.join(accumulator) 232 | 233 | -------------------------------------------------------------------------------- /fhirclass.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import re 5 | 6 | from logger import logger 7 | 8 | 9 | class FHIRClass(object): 10 | """ An element/resource that should become its own class. 11 | """ 12 | 13 | known = {} 14 | 15 | @classmethod 16 | def for_element(cls, element): 17 | """ Returns an existing class or creates one for the given element. 18 | Returns a tuple with the class and a bool indicating creation. 19 | """ 20 | assert element.represents_class 21 | class_name = element.name_if_class 22 | if class_name in cls.known: 23 | return cls.known[class_name], False 24 | 25 | klass = cls(element, class_name) 26 | cls.known[class_name] = klass 27 | return klass, True 28 | 29 | @classmethod 30 | def with_name(cls, class_name): 31 | return cls.known.get(class_name) 32 | 33 | def __init__(self, element, class_name): 34 | assert element.represents_class 35 | self.from_element = element 36 | self.path = element.path 37 | self.name = class_name 38 | self.module = None 39 | self.resource_type = element.name_of_resource() 40 | self.superclass = None 41 | self.interfaces = None 42 | self.short = element.definition.short 43 | self.formal = element.definition.formal 44 | self.properties = [] 45 | self.expanded_nonoptionals = {} 46 | 47 | def add_property(self, prop): 48 | """ Add a property to the receiver. 49 | 50 | :param FHIRClassProperty prop: A FHIRClassProperty instance 51 | """ 52 | assert isinstance(prop, FHIRClassProperty) 53 | 54 | # do we already have a property with this name? 55 | # if we do and it's a specific reference, make it a reference to a 56 | # generic resource 57 | for existing in self.properties: 58 | if existing.name == prop.name: 59 | if 0 == len(existing.reference_to_names): 60 | logger.warning('Already have property "{}" on "{}", which is only allowed for references'.format(prop.name, self.name)) 61 | else: 62 | existing.reference_to_names.extend(prop.reference_to_names) 63 | return 64 | 65 | self.properties.append(prop) 66 | 67 | if prop.nonoptional: 68 | if prop.one_of_many is not None: 69 | existing = self.expanded_nonoptionals[prop.one_of_many] if prop.one_of_many in self.expanded_nonoptionals else [] 70 | existing.append(prop) 71 | self.expanded_nonoptionals[prop.one_of_many] = sorted(existing, key=lambda x: re.sub(r'\W', '', x.name)) 72 | else: 73 | self.expanded_nonoptionals[prop.name] = [prop] 74 | 75 | @property 76 | def nonexpanded_properties(self): 77 | nonexpanded = [] 78 | included = {} 79 | for prop in self.properties: 80 | if prop.nonexpanded_name not in included: 81 | included[prop.nonexpanded_name] = prop 82 | nonexpanded.append(prop) 83 | prop.expansions = [prop] 84 | else: 85 | included[prop.nonexpanded_name].expansions.append(prop) 86 | return nonexpanded 87 | 88 | @property 89 | def nonexpanded_properties_all(self): 90 | nonexpanded = self.nonexpanded_properties.copy() 91 | if self.superclass is not None: 92 | included = set([p.nonexpanded_name for p in nonexpanded]) 93 | for prop in self.superclass.nonexpanded_properties_all: 94 | if prop.nonexpanded_name in included: 95 | continue 96 | included.add(prop.nonexpanded_name) 97 | nonexpanded.append(prop) 98 | return nonexpanded 99 | 100 | @property 101 | def nonexpanded_nonoptionals(self): 102 | nonexpanded = [] 103 | included = set() 104 | for prop in self.properties: 105 | if not prop.nonoptional: 106 | continue 107 | if prop.nonexpanded_name in included: 108 | continue 109 | included.add(prop.nonexpanded_name) 110 | nonexpanded.append(prop) 111 | return nonexpanded 112 | 113 | @property 114 | def nonexpanded_nonoptionals_all(self): 115 | nonexpanded = self.nonexpanded_nonoptionals.copy() 116 | if self.superclass is not None: 117 | included = set([p.nonexpanded_name for p in nonexpanded]) 118 | for prop in self.superclass.nonexpanded_nonoptionals_all: 119 | if prop.nonexpanded_name in included: 120 | continue 121 | included.add(prop.nonexpanded_name) 122 | nonexpanded.append(prop) 123 | return nonexpanded 124 | 125 | def property_for(self, prop_name): 126 | for prop in self.properties: 127 | if prop.orig_name == prop_name: 128 | return prop 129 | if self.superclass: 130 | return self.superclass.property_for(prop_name) 131 | return None 132 | 133 | def should_write(self): 134 | if self.superclass is not None: 135 | return True 136 | return True if len(self.properties) > 0 else False 137 | 138 | @property 139 | def has_nonoptional(self): 140 | for prop in self.properties: 141 | if prop.nonoptional: 142 | return True 143 | return False 144 | 145 | @property 146 | def has_one_of_many(self): 147 | for prop in self.properties: 148 | if prop.one_of_many is not None: 149 | return True 150 | return False 151 | 152 | @property 153 | def sorted_properties(self): 154 | return sorted(self.properties, key=lambda x: re.sub(r'\W', '', x.name)) 155 | 156 | @property 157 | def sorted_properties_all(self): 158 | properties = self.properties.copy() 159 | if self.superclass is not None: 160 | properties.extend(self.superclass.sorted_properties_all) 161 | return sorted(properties, key=lambda x: re.sub(r'\W', '', x.name)) 162 | 163 | @property 164 | def sorted_nonexpanded_properties(self): 165 | return sorted(self.nonexpanded_properties, key=lambda x: re.sub(r'\W', '', x.name)) 166 | 167 | @property 168 | def sorted_nonexpanded_properties_all(self): 169 | return sorted(self.nonexpanded_properties_all, key=lambda x: re.sub(r'\W', '', x.name)) 170 | 171 | @property 172 | def sorted_nonoptionals(self): 173 | return sorted(self.expanded_nonoptionals.items()) 174 | 175 | @property 176 | def sorted_nonexpanded_nonoptionals(self): 177 | return sorted(self.nonexpanded_nonoptionals, key=lambda x: re.sub(r'\W', '', x.name)) 178 | 179 | @property 180 | def sorted_nonexpanded_nonoptionals_all(self): 181 | return sorted(self.nonexpanded_nonoptionals_all, key=lambda x: re.sub(r'\W', '', x.name)) 182 | 183 | @property 184 | def has_expanded_nonoptionals(self): 185 | return len([p for p in self.properties if p.one_of_many and p.nonoptional]) > 0 186 | 187 | @property 188 | def has_only_expandable_properties(self): 189 | return len([p for p in self.properties if not p.one_of_many]) < 1 190 | 191 | @property 192 | def resource_type_enum(self): 193 | return self.resource_type[:1].lower() + self.resource_type[1:] 194 | 195 | 196 | def __repr__(self): 197 | return f"<{self.__class__.__name__}> path: {self.path}, name: {self.name}, resourceType: {self.resource_type}" 198 | 199 | 200 | class FHIRClassProperty(object): 201 | """ An element describing an instance property. 202 | """ 203 | 204 | def __init__(self, element, type_obj, type_name=None): 205 | assert element and type_obj # and must be instances of FHIRStructureDefinitionElement and FHIRElementType 206 | spec = element.profile.spec 207 | 208 | self.path = element.path 209 | self.one_of_many = None # assign if this property has been expanded from "property[x]" 210 | if not type_name: 211 | type_name = type_obj.code 212 | self.type_name = type_name 213 | 214 | name = element.definition.prop_name 215 | if '[x]' in name: 216 | self.one_of_many = spec.safe_property_name(name.replace('[x]', '')) 217 | name = name.replace('[x]', '{}{}'.format(type_name[:1].upper(), type_name[1:])) 218 | 219 | self.orig_name = name 220 | self.name = spec.safe_property_name(name) 221 | self.parent_name = element.parent_name 222 | self.class_name = spec.class_name_for_type_if_property(type_name) 223 | self.enum = element.enum if 'code' == type_name else None 224 | self.module_name = None # should only be set if it's an external module (think Python) 225 | self.expansions = None # will be populated in the class' `nonexpanded` property lists 226 | self.json_class = spec.json_class_for_class_name(self.class_name) 227 | self.is_native = False if self.enum else spec.class_name_is_native(self.class_name) 228 | self.is_array = True if '*' == element.n_max else False 229 | self.is_summary = element.is_summary 230 | self.is_summary_n_min_conflict = element.summary_n_min_conflict 231 | self.nonoptional = True if element.n_min is not None and 0 != int(element.n_min) else False 232 | self.reference_to_names = [spec.class_name_for_profile(type_obj.profile)] if type_obj.profile is not None else [] 233 | self.short = element.definition.short 234 | self.formal = element.definition.formal 235 | self.representation = element.definition.representation 236 | 237 | @property 238 | def documentation(self): 239 | doc = "" 240 | if self.enum is not None: 241 | doc = self.formal 242 | if self.enum.restricted_to is not None: 243 | add = f"\nRestricted to: {self.enum.restricted_to}" 244 | doc = doc + add if doc is not None and len(doc) > 0 else add 245 | else: 246 | doc = self.short 247 | 248 | if self.one_of_many is not None: 249 | add = f"\nOne of `{self.one_of_many}[x]`" 250 | doc = doc + add if doc is not None and len(doc) > 0 else add 251 | 252 | return doc 253 | 254 | @property 255 | def desired_classname(self): 256 | return self.enum.name if self.enum is not None else self.class_name 257 | 258 | @property 259 | def nonexpanded_name(self): 260 | return self.one_of_many if self.one_of_many is not None else self.name 261 | 262 | @property 263 | def nonexpanded_classname(self): 264 | if self.one_of_many is not None: # We leave it up to the template to supply a class name in this case 265 | return None 266 | return self.desired_classname 267 | 268 | -------------------------------------------------------------------------------- /Sample/fhirabstractbase.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Base class for all FHIR elements. 5 | 6 | import sys 7 | import logging 8 | 9 | 10 | class FHIRValidationError(Exception): 11 | """ Exception raised when one or more errors occurred during model 12 | validation. 13 | """ 14 | 15 | def __init__(self, errors, path=None): 16 | """ Initializer. 17 | 18 | :param errors: List of Exception instances. Also accepts a string, 19 | which is converted to a TypeError. 20 | :param str path: The property path on the object where errors occurred 21 | """ 22 | if not isinstance(errors, list): 23 | errors = [TypeError(errors)] 24 | msgs = "\n ".join([str(e).replace("\n", "\n ") for e in errors]) 25 | message = "{}:\n {}".format(path or "{root}", msgs) 26 | 27 | super(FHIRValidationError, self).__init__(message) 28 | 29 | self.errors = errors 30 | """ A list of validation errors encountered. Typically contains 31 | TypeError, KeyError, possibly AttributeError and others. """ 32 | 33 | self.path = path 34 | """ The path on the object where the errors occurred. """ 35 | 36 | def prefixed(self, path_prefix): 37 | """ Creates a new instance of the receiver, with the given path prefix 38 | applied. """ 39 | path = '{}.{}'.format(path_prefix, self.path) if self.path is not None else path_prefix 40 | return self.__class__(self.errors, path) 41 | 42 | 43 | class FHIRAbstractBase(object): 44 | """ Abstract base class for all FHIR elements. 45 | """ 46 | 47 | def __init__(self, jsondict=None, strict=True): 48 | """ Initializer. If strict is true, raises on errors, otherwise uses 49 | `logging.warning()`. 50 | 51 | :raises: FHIRValidationError on validation errors, unless strict is False 52 | :param dict jsondict: A JSON dictionary to use for initialization 53 | :param bool strict: If True (the default), invalid variables will raise a TypeError 54 | """ 55 | 56 | self._owner = None 57 | """ Points to the parent resource, if there is one. """ 58 | 59 | if jsondict is not None: 60 | if strict: 61 | self.update_with_json(jsondict) 62 | else: 63 | try: 64 | self.update_with_json(jsondict) 65 | except FHIRValidationError as e: 66 | for err in e.errors: 67 | logging.warning(err) 68 | 69 | 70 | # MARK: Instantiation from JSON 71 | 72 | @classmethod 73 | def with_json(cls, jsonobj): 74 | """ Initialize an element from a JSON dictionary or array. 75 | 76 | If the JSON dictionary has a "resourceType" entry and the specified 77 | resource type is not the receiving classes type, uses 78 | `FHIRElementFactory` to return a correct class instance. 79 | 80 | :raises: TypeError on anything but dict or list of dicts 81 | :raises: FHIRValidationError if instantiation fails 82 | :param jsonobj: A dict or list of dicts to instantiate from 83 | :returns: An instance or a list of instances created from JSON data 84 | """ 85 | if isinstance(jsonobj, dict): 86 | return cls._with_json_dict(jsonobj) 87 | 88 | if isinstance(jsonobj, list): 89 | arr = [] 90 | for jsondict in jsonobj: 91 | try: 92 | arr.append(cls._with_json_dict(jsondict)) 93 | except FHIRValidationError as e: 94 | raise e.prefixed(str(len(arr))) 95 | return arr 96 | 97 | raise TypeError("`with_json()` on {} only takes dict or list of dict, but you provided {}" 98 | .format(cls, type(jsonobj))) 99 | 100 | @classmethod 101 | def _with_json_dict(cls, jsondict): 102 | """ Internal method to instantiate from JSON dictionary. 103 | 104 | :raises: TypeError on anything but dict 105 | :raises: FHIRValidationError if instantiation fails 106 | :returns: An instance created from dictionary data 107 | """ 108 | if not isinstance(jsondict, dict): 109 | raise TypeError("Can only use `_with_json_dict()` on {} with a dictionary, got {}" 110 | .format(type(self), type(jsondict))) 111 | return cls(jsondict) 112 | 113 | @classmethod 114 | def with_json_and_owner(cls, jsonobj, owner): 115 | """ Instantiates by forwarding to `with_json()`, then remembers the 116 | "owner" of the instantiated elements. The "owner" is the resource 117 | containing the receiver and is used to resolve contained resources. 118 | 119 | :raises: TypeError on anything but dict or list of dicts 120 | :raises: FHIRValidationError if instantiation fails 121 | :param dict jsonobj: Decoded JSON dictionary (or list thereof) 122 | :param FHIRElement owner: The owning parent 123 | :returns: An instance or a list of instances created from JSON data 124 | """ 125 | instance = cls.with_json(jsonobj) 126 | if isinstance(instance, list): 127 | for inst in instance: 128 | inst._owner = owner 129 | else: 130 | instance._owner = owner 131 | 132 | return instance 133 | 134 | 135 | # MARK: (De)Serialization 136 | 137 | def elementProperties(self): 138 | """ Returns a list of tuples, one tuple for each property that should 139 | be serialized, as: ("name", "json_name", type, is_list, "of_many", not_optional) 140 | """ 141 | return [] 142 | 143 | def update_with_json(self, jsondict): 144 | """ Update the receiver with data in a JSON dictionary. 145 | 146 | :raises: FHIRValidationError on validation errors 147 | :param dict jsondict: The JSON dictionary to use to update the receiver 148 | :returns: None on success, a list of errors if there were errors 149 | """ 150 | if jsondict is None: 151 | return 152 | 153 | if not isinstance(jsondict, dict): 154 | raise FHIRValidationError("Non-dict type {} fed to `update_with_json` on {}" 155 | .format(type(jsondict), type(self))) 156 | 157 | # loop all registered properties and instantiate 158 | errs = [] 159 | valid = set(['resourceType']) 160 | found = set() 161 | nonoptionals = set() 162 | for name, jsname, typ, is_list, of_many, not_optional in self.elementProperties(): 163 | valid.add(jsname) 164 | if of_many is not None: 165 | valid.add(of_many) 166 | 167 | # bring the value in shape 168 | err = None 169 | value = jsondict.get(jsname) 170 | if value is not None and hasattr(typ, 'with_json_and_owner'): 171 | try: 172 | value = typ.with_json_and_owner(value, self) 173 | except Exception as e: 174 | value = None 175 | err = e 176 | 177 | # got a value, test if it is of required type and assign 178 | if value is not None: 179 | testval = value 180 | if is_list: 181 | if not isinstance(value, list): 182 | err = TypeError("Wrong type {} for list property \"{}\" on {}, expecting a list of {}" 183 | .format(type(value), name, type(self), typ)) 184 | testval = None 185 | else: 186 | testval = value[0] if value and len(value) > 0 else None 187 | 188 | if testval is not None and not self._matches_type(testval, typ): 189 | err = TypeError("Wrong type {} for property \"{}\" on {}, expecting {}" 190 | .format(type(testval), name, type(self), typ)) 191 | else: 192 | setattr(self, name, value) 193 | 194 | found.add(jsname) 195 | if of_many is not None: 196 | found.add(of_many) 197 | 198 | # not optional and missing, report (we clean `of_many` later on) 199 | elif not_optional: 200 | nonoptionals.add(of_many or jsname) 201 | 202 | # TODO: look at `_name` only if this is a primitive! 203 | _jsname = '_'+jsname 204 | _value = jsondict.get(_jsname) 205 | if _value is not None: 206 | valid.add(_jsname) 207 | found.add(_jsname) 208 | 209 | # report errors 210 | if err is not None: 211 | errs.append(err.prefixed(name) if isinstance(err, FHIRValidationError) else FHIRValidationError([err], name)) 212 | 213 | # were there missing non-optional entries? 214 | if len(nonoptionals) > 0: 215 | for miss in nonoptionals - found: 216 | errs.append(KeyError("Non-optional property \"{}\" on {} is missing" 217 | .format(miss, self))) 218 | 219 | # were there superfluous dictionary keys? 220 | if len(set(jsondict.keys()) - valid) > 0: 221 | for supflu in set(jsondict.keys()) - valid: 222 | errs.append(AttributeError("Superfluous entry \"{}\" in data for {}" 223 | .format(supflu, self))) 224 | 225 | if len(errs) > 0: 226 | raise FHIRValidationError(errs) 227 | 228 | def as_json(self): 229 | """ Serializes to JSON by inspecting `elementProperties()` and creating 230 | a JSON dictionary of all registered properties. Checks: 231 | 232 | - whether required properties are not None (and lists not empty) 233 | - whether not-None properties are of the correct type 234 | 235 | :raises: FHIRValidationError if properties have the wrong type or if 236 | required properties are empty 237 | :returns: A validated dict object that can be JSON serialized 238 | """ 239 | js = {} 240 | errs = [] 241 | 242 | # JSONify all registered properties 243 | found = set() 244 | nonoptionals = set() 245 | for name, jsname, typ, is_list, of_many, not_optional in self.elementProperties(): 246 | if not_optional: 247 | nonoptionals.add(of_many or jsname) 248 | 249 | err = None 250 | value = getattr(self, name) 251 | if value is None: 252 | continue 253 | 254 | if is_list: 255 | if not isinstance(value, list): 256 | err = TypeError("Expecting property \"{}\" on {} to be list, but is {}" 257 | .format(name, type(self), type(value))) 258 | elif len(value) > 0: 259 | if not self._matches_type(value[0], typ): 260 | err = TypeError("Expecting property \"{}\" on {} to be {}, but is {}" 261 | .format(name, type(self), typ, type(value[0]))) 262 | else: 263 | lst = [] 264 | for v in value: 265 | try: 266 | lst.append(v.as_json() if hasattr(v, 'as_json') else v) 267 | except FHIRValidationError as e: 268 | err = e.prefixed(str(len(lst))).prefixed(name) 269 | found.add(of_many or jsname) 270 | js[jsname] = lst 271 | else: 272 | if not self._matches_type(value, typ): 273 | err = TypeError("Expecting property \"{}\" on {} to be {}, but is {}" 274 | .format(name, type(self), typ, type(value))) 275 | else: 276 | try: 277 | found.add(of_many or jsname) 278 | js[jsname] = value.as_json() if hasattr(value, 'as_json') else value 279 | except FHIRValidationError as e: 280 | err = e.prefixed(name) 281 | 282 | if err is not None: 283 | errs.append(err if isinstance(err, FHIRValidationError) else FHIRValidationError([err], name)) 284 | 285 | # any missing non-optionals? 286 | if len(nonoptionals - found) > 0: 287 | for nonop in nonoptionals - found: 288 | errs.append(KeyError("Property \"{}\" on {} is not optional, you must provide a value for it" 289 | .format(nonop, self))) 290 | 291 | if len(errs) > 0: 292 | raise FHIRValidationError(errs) 293 | return js 294 | 295 | def _matches_type(self, value, typ): 296 | if value is None: 297 | return True 298 | if isinstance(value, typ): 299 | return True 300 | if int == typ or float == typ: 301 | return (isinstance(value, int) or isinstance(value, float)) 302 | if (sys.version_info < (3, 0)) and (str == typ or unicode == typ): 303 | return (isinstance(value, str) or isinstance(value, unicode)) 304 | return False 305 | 306 | 307 | # MARK: Owner 308 | 309 | def owningResource(self): 310 | """ Walks the owner hierarchy and returns the next parent that is a 311 | `DomainResource` instance. 312 | """ 313 | owner = self._owner 314 | while owner is not None and not hasattr(owner, "contained"): 315 | owner = owner._owner 316 | return owner 317 | 318 | def owningBundle(self): 319 | """ Walks the owner hierarchy and returns the next parent that is a 320 | `Bundle` instance. 321 | """ 322 | owner = self._owner 323 | while owner is not None and not 'Bundle' == owner.resource_type: 324 | owner = owner._owner 325 | return owner 326 | 327 | -------------------------------------------------------------------------------- /fhirspec.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import io 5 | import os 6 | import re 7 | import sys 8 | import glob 9 | import json 10 | import datetime 11 | import pathlib 12 | 13 | from logger import logger 14 | import fhirclass 15 | import fhirunittest 16 | import fhirrenderer 17 | 18 | # allow to skip some profiles by matching against their url (used while WiP) 19 | skip_because_unsupported = [ 20 | r'SimpleQuantity', 21 | ] 22 | 23 | 24 | class FHIRSpec(object): 25 | """ The FHIR specification. 26 | """ 27 | 28 | def __init__(self, directory, settings): 29 | assert os.path.isdir(directory) 30 | assert settings is not None 31 | self.directory = directory 32 | self.settings = settings 33 | self.info = FHIRVersionInfo(self, directory) 34 | self.valuesets = {} # system-url: FHIRValueSet() 35 | self.codesystems = {} # system-url: FHIRCodeSystem() 36 | self.profiles = {} # profile-name: FHIRStructureDefinition() 37 | self.unit_tests = None # FHIRUnitTestCollection() 38 | 39 | self.prepare() 40 | self.read_profiles() 41 | self.finalize() 42 | 43 | def prepare(self): 44 | """ Run actions before starting to parse profiles. 45 | """ 46 | self.read_valuesets() 47 | self.handle_manual_profiles() 48 | 49 | def read_bundle_resources(self, filename): 50 | """ Return an array of the Bundle's entry's "resource" elements. 51 | """ 52 | logger.info("Reading {}".format(filename)) 53 | filepath = os.path.join(self.directory, filename) 54 | with io.open(filepath, encoding='utf-8') as handle: 55 | parsed = json.load(handle) 56 | if 'resourceType' not in parsed: 57 | raise Exception("Expecting \"resourceType\" to be present, but is not in {}" 58 | .format(filepath)) 59 | if 'Bundle' != parsed['resourceType']: 60 | raise Exception("Can only process \"Bundle\" resources") 61 | if 'entry' not in parsed: 62 | raise Exception("There are no entries in the Bundle at {}" 63 | .format(filepath)) 64 | 65 | return [e['resource'] for e in parsed['entry']] 66 | 67 | 68 | # MARK: Managing ValueSets and CodeSystems 69 | 70 | def read_valuesets(self): 71 | resources = self.read_bundle_resources('valuesets.json') 72 | for resource in resources: 73 | if 'ValueSet' == resource['resourceType']: 74 | assert 'url' in resource 75 | valueset = FHIRValueSet(self, resource) 76 | self.valuesets[valueset.url] = valueset 77 | if valueset.dstu2_inlined_codesystem: 78 | codesystem = FHIRCodeSystem(self, valueset.dstu2_inlined_codesystem) 79 | codesystem.valueset_url = valueset.url 80 | self.found_codesystem(codesystem) 81 | elif 'CodeSystem' == resource['resourceType']: 82 | assert 'url' in resource 83 | if 'content' in resource and 'concept' in resource: 84 | codesystem = FHIRCodeSystem(self, resource) 85 | self.found_codesystem(codesystem) 86 | else: 87 | logger.warn("CodeSystem with no concepts: {}".format(resource['url'])) 88 | logger.info("Found {} ValueSets and {} CodeSystems".format(len(self.valuesets), len(self.codesystems))) 89 | 90 | def found_codesystem(self, codesystem): 91 | if codesystem.url not in self.settings.enum_ignore: 92 | self.codesystems[codesystem.url] = codesystem 93 | 94 | def valueset_with_uri(self, uri): 95 | assert uri 96 | return self.valuesets.get(uri) 97 | 98 | def codesystem_with_uri(self, uri): 99 | assert uri 100 | return self.codesystems.get(uri) 101 | 102 | 103 | # MARK: Handling Profiles 104 | 105 | def read_profiles(self): 106 | """ Find all (JSON) profiles and instantiate into FHIRStructureDefinition. 107 | """ 108 | resources = [] 109 | for filename in ['profiles-types.json', 'profiles-resources.json']: #, 'profiles-others.json']: 110 | bundle_res = self.read_bundle_resources(filename) 111 | for resource in bundle_res: 112 | if 'StructureDefinition' == resource['resourceType']: 113 | resources.append(resource) 114 | else: 115 | logger.debug('Not handling resource of type {}' 116 | .format(resource['resourceType'])) 117 | 118 | # create profile instances 119 | for resource in resources: 120 | profile = FHIRStructureDefinition(self, resource) 121 | for pattern in skip_because_unsupported: 122 | if re.search(pattern, profile.url) is not None: 123 | logger.info('Skipping "{}"'.format(resource['url'])) 124 | profile = None 125 | break 126 | 127 | if profile is not None and self.found_profile(profile): 128 | profile.process_profile() 129 | 130 | def found_profile(self, profile): 131 | if not profile or not profile.name: 132 | raise Exception("No name for profile {}".format(profile)) 133 | if profile.name.lower() in self.profiles: 134 | logger.debug('Already have profile "{}", discarding'.format(profile.name)) 135 | return False 136 | 137 | self.profiles[profile.name.lower()] = profile 138 | return True 139 | 140 | def profile_named(self, profile_name): 141 | return self.profiles.get(profile_name.lower()) 142 | 143 | def handle_manual_profiles(self): 144 | """ Creates in-memory representations for all our manually defined 145 | profiles. 146 | """ 147 | for filepath, module, contains in self.settings.manual_profiles: 148 | for contained in contains: 149 | profile = FHIRStructureDefinition(self, None) 150 | profile.manual_module = module 151 | 152 | prof_dict = { 153 | 'name': contained, 154 | 'differential': { 155 | 'element': [{'path': contained}] 156 | } 157 | } 158 | 159 | profile.structure = FHIRStructureDefinitionStructure(profile, prof_dict) 160 | if self.found_profile(profile): 161 | profile.process_profile() 162 | 163 | def finalize(self): 164 | """ Should be called after all profiles have been parsed and allows 165 | to perform additional actions, like looking up class implementations 166 | from different profiles. 167 | """ 168 | for key, prof in self.profiles.items(): 169 | prof.finalize() 170 | 171 | 172 | # MARK: Naming Utilities 173 | 174 | def as_module_name(self, name): 175 | return name.lower() if name and self.settings.resource_modules_lowercase else name 176 | 177 | def as_class_name(self, classname, parent_name=None): 178 | """ This method formulates a class name from the given arguments, 179 | applying formatting according to settings. 180 | """ 181 | if not classname or 0 == len(classname): 182 | return None 183 | 184 | # if we have a parent, do we have a mapped class? 185 | pathname = '{}.{}'.format(parent_name, classname) if parent_name is not None else None 186 | if pathname is not None and pathname in self.settings.classmap: 187 | return self.settings.classmap[pathname] 188 | 189 | # is our plain class mapped? 190 | if classname in self.settings.classmap: 191 | return self.settings.classmap[classname] 192 | 193 | # CamelCase or just plain 194 | if self.settings.camelcase_classes: 195 | return classname[:1].upper() + classname[1:] 196 | return classname 197 | 198 | def class_name_for_type(self, type_name, parent_name=None): 199 | return self.as_class_name(type_name, parent_name) 200 | 201 | def class_name_for_type_if_property(self, type_name): 202 | classname = self.class_name_for_type(type_name) 203 | if not classname: 204 | return None 205 | return self.settings.replacemap.get(classname, classname) 206 | 207 | def class_name_for_profile(self, profile_name): 208 | if not profile_name: 209 | return None 210 | # TODO need to figure out what to do with this later. Annotation author supports multiples types that caused this to fail 211 | if isinstance(profile_name, (list,)) and len(profile_name) > 0: 212 | classnames = [] 213 | for name_part in profile_name: 214 | classnames.append(self.as_class_name(name_part.split('/')[-1])) # may be the full Profile URI, like http://hl7.org/fhir/Profile/MyProfile 215 | return classnames 216 | type_name = profile_name.split('/')[-1] # may be the full Profile URI, like http://hl7.org/fhir/Profile/MyProfile 217 | return self.as_class_name(type_name) 218 | 219 | def class_name_is_native(self, class_name): 220 | return class_name in self.settings.natives 221 | 222 | def safe_property_name(self, prop_name): 223 | return self.settings.reservedmap.get(prop_name, prop_name) 224 | 225 | def safe_enum_name(self, enum_name, ucfirst=False): 226 | assert enum_name, "Must have a name" 227 | name = self.settings.enum_map.get(enum_name, enum_name) 228 | parts = re.split(r'\W+', name) 229 | if self.settings.camelcase_enums: 230 | name = ''.join([n[:1].upper() + n[1:] for n in parts]) 231 | if not ucfirst and name.upper() != name: 232 | name = name[:1].lower() + name[1:] 233 | else: 234 | name = '_'.join(parts) 235 | 236 | if re.match(r'^\d', name): 237 | name = f'_{name}' 238 | 239 | return self.settings.reservedmap.get(name, name) 240 | 241 | def json_class_for_class_name(self, class_name): 242 | return self.settings.jsonmap.get(class_name, self.settings.jsonmap_default) 243 | 244 | 245 | # MARK: Unit Tests 246 | 247 | def parse_unit_tests(self): 248 | controller = fhirunittest.FHIRUnitTestController(self) 249 | controller.find_and_parse_tests(self.directory) 250 | self.unit_tests = controller.collections 251 | 252 | 253 | # MARK: Writing Data 254 | 255 | def writable_profiles(self): 256 | """ Returns a list of `FHIRStructureDefinition` instances. 257 | """ 258 | profiles = [] 259 | for key, profile in self.profiles.items(): 260 | if profile.manual_module is None and not profile.is_interface: 261 | profiles.append(profile) 262 | return profiles 263 | 264 | def write(self): 265 | if self.settings.write_resources: 266 | renderer = fhirrenderer.FHIRStructureDefinitionRenderer(self, self.settings) 267 | renderer.render() 268 | 269 | vsrenderer = fhirrenderer.FHIRValueSetRenderer(self, self.settings) 270 | vsrenderer.render() 271 | 272 | # Create init file so that our relative imports work out of the box 273 | pathlib.Path(self.settings.tpl_resource_target, "__init__.py").touch() 274 | 275 | if self.settings.write_factory: 276 | renderer = fhirrenderer.FHIRFactoryRenderer(self, self.settings) 277 | renderer.render() 278 | 279 | if self.settings.write_dependencies: 280 | renderer = fhirrenderer.FHIRDependencyRenderer(self, self.settings) 281 | renderer.render() 282 | 283 | if self.settings.write_unittests: 284 | self.parse_unit_tests() 285 | renderer = fhirrenderer.FHIRUnitTestRenderer(self, self.settings) 286 | renderer.render() 287 | 288 | 289 | class FHIRVersionInfo(object): 290 | """ The version of a FHIR specification. 291 | """ 292 | 293 | def __init__(self, spec, directory): 294 | self.spec = spec 295 | 296 | now = datetime.date.today() 297 | self.date = now.isoformat() 298 | self.year = now.year 299 | 300 | self.version = None 301 | infofile = os.path.join(directory, 'version.info') 302 | self.read_version(infofile) 303 | 304 | def read_version(self, filepath): 305 | assert os.path.isfile(filepath) 306 | with io.open(filepath, 'r', encoding='utf-8') as handle: 307 | text = handle.read() 308 | for line in text.split("\n"): 309 | if '=' in line: 310 | (n, v) = line.strip().split('=', 2) 311 | if 'FhirVersion' == n: 312 | self.version = v 313 | 314 | 315 | class FHIRValueSetEnum(object): 316 | """ Holds on to parsed `FHIRValueSet` properties. 317 | """ 318 | 319 | def __init__(self, name, restricted_to, value_set): 320 | self.name = name 321 | self.restricted_to = restricted_to if len(restricted_to) > 0 else None 322 | self.value_set = value_set 323 | self.represents_class = True # required for FHIRClass compatibily 324 | self.module = name # required for FHIRClass compatibily 325 | self.name_if_class = name # required for FHIRClass compatibily 326 | self.path = None # required for FHIRClass compatibily 327 | 328 | @property 329 | def definition(self): 330 | return self.value_set 331 | 332 | def name_of_resource(self): # required for FHIRClass compatibily 333 | return None 334 | 335 | 336 | class FHIRValueSet(object): 337 | """ Holds on to ValueSets bundled with the spec. 338 | """ 339 | 340 | def __init__(self, spec, set_dict): 341 | self.spec = spec 342 | self.definition = set_dict 343 | self.url = set_dict.get('url') 344 | self.dstu2_inlined_codesystem = self.definition.get('codeSystem') 345 | if self.dstu2_inlined_codesystem is not None: 346 | self.dstu2_inlined_codesystem['url'] = self.dstu2_inlined_codesystem['system'] 347 | self.dstu2_inlined_codesystem['content'] = "complete" 348 | self.dstu2_inlined_codesystem['name'] = self.definition.get('name') 349 | self.dstu2_inlined_codesystem['description'] = self.definition.get('description') 350 | 351 | self._enum = None 352 | 353 | @property 354 | def short(self): 355 | return self.definition.get('title') 356 | 357 | @property 358 | def formal(self): 359 | return self.definition.get('description') 360 | 361 | @property 362 | def enum(self): 363 | """ Returns FHIRValueSetEnum if this valueset can be represented by one. 364 | """ 365 | if self._enum is not None: 366 | return self._enum 367 | 368 | include = None 369 | 370 | if self.dstu2_inlined_codesystem is not None: 371 | include = [self.dstu2_inlined_codesystem] 372 | else: 373 | compose = self.definition.get('compose') 374 | if compose is None: 375 | raise Exception(f"Currently only composed ValueSets are supported. {self.definition}") 376 | if 'exclude' in compose: 377 | raise Exception("Not currently supporting 'exclude' on ValueSet") 378 | include = compose.get('include') or compose.get('import') # "import" is for DSTU-2 compatibility 379 | 380 | if 1 != len(include): 381 | logger.warn("Ignoring ValueSet with more than 1 includes ({}: {})".format(len(include), include)) 382 | return None 383 | 384 | system = include[0].get('system') 385 | if system is None: 386 | return None 387 | 388 | # alright, this is a ValueSet with 1 include and a system, is there a CodeSystem? 389 | cs = self.spec.codesystem_with_uri(system) 390 | if cs is None or not cs.generate_enum: 391 | return None 392 | 393 | # do we only allow specific concepts? 394 | restricted_to = [] 395 | concepts = include[0].get('concept') 396 | if concepts is not None: 397 | for concept in concepts: 398 | assert 'code' in concept 399 | restricted_to.append(concept['code']) 400 | 401 | self._enum = FHIRValueSetEnum(name=cs.name, restricted_to=restricted_to, value_set=self) 402 | return self._enum 403 | 404 | 405 | class FHIRCodeSystem(object): 406 | """ Holds on to CodeSystems bundled with the spec. 407 | """ 408 | 409 | def __init__(self, spec, resource): 410 | assert 'content' in resource 411 | self.spec = spec 412 | self.definition = resource 413 | self.url = resource.get('url') 414 | if self.url in self.spec.settings.enum_namemap: 415 | self.name = self.spec.settings.enum_namemap[self.url] 416 | else: 417 | self.name = self.spec.safe_enum_name(resource.get('name'), ucfirst=True) 418 | if len(self.name) < 1: 419 | raise Exception(f"Unable to create a name for enum of system {self.url}. You may need to specify a name explicitly in mappings.enum_namemap. Code system content: {resource}") 420 | self.description = resource.get('description') 421 | self.valueset_url = resource.get('valueSet') 422 | self.codes = None 423 | self.generate_enum = False 424 | concepts = resource.get('concept', []) 425 | 426 | if resource.get('experimental'): 427 | return 428 | self.generate_enum = 'complete' == resource['content'] 429 | if not self.generate_enum: 430 | logger.debug("Will not generate enum for CodeSystem \"{}\" whose content is {}" 431 | .format(self.url, resource['content'])) 432 | return 433 | 434 | assert concepts, "Expecting at least one code for \"complete\" CodeSystem" 435 | if len(concepts) > 200: 436 | self.generate_enum = False 437 | logger.info("Will not generate enum for CodeSystem \"{}\" because it has > 200 ({}) concepts" 438 | .format(self.url, len(concepts))) 439 | return 440 | 441 | self.codes = self.parsed_codes(concepts) 442 | 443 | def parsed_codes(self, codes, prefix=None): 444 | found = [] 445 | for c in codes: 446 | if re.match(r'\d', c['code'][:1]): 447 | self.generate_enum = False 448 | logger.info("Will not generate enum for CodeSystem \"{}\" because at least one concept code starts with a number" 449 | .format(self.url)) 450 | return None 451 | 452 | cd = c['code'] 453 | name = '{}-{}'.format(prefix, cd) if prefix and not cd.startswith(prefix) else cd 454 | code_name = self.spec.safe_enum_name(cd) 455 | if len(code_name) < 1: 456 | raise Exception(f"Unable to create a member name for enum '{cd}' in {self.url}. You may need to add '{cd}' to mappings.enum_map") 457 | c['name'] = code_name 458 | c['definition'] = c.get('definition') or c['name'] 459 | found.append(c) 460 | 461 | # nested concepts? 462 | if 'concept' in c: 463 | fnd = self.parsed_codes(c['concept']) 464 | if fnd is None: 465 | return None 466 | found.extend(fnd) 467 | return found 468 | 469 | 470 | class FHIRStructureDefinition(object): 471 | """ One FHIR structure definition. 472 | """ 473 | 474 | def __init__(self, spec, profile): 475 | self.manual_module = None 476 | self.spec = spec 477 | self.url = None 478 | self.targetname = None 479 | self.structure = None 480 | self.elements = None 481 | self.main_element = None 482 | self.is_interface = False 483 | self._class_map = {} 484 | self.classes = [] 485 | self._did_finalize = False 486 | 487 | if profile is not None: 488 | self.parse_profile(profile) 489 | 490 | def __repr__(self): 491 | return f'<{self.__class__.__name__}> name: {self.name}, url: {self.url}' 492 | 493 | @property 494 | def name(self): 495 | return self.structure.name if self.structure is not None else None 496 | 497 | def read_profile(self, filepath): 498 | """ Read the JSON definition of a profile from disk and parse. 499 | 500 | Not currently used. 501 | """ 502 | profile = None 503 | with io.open(filepath, 'r', encoding='utf-8') as handle: 504 | profile = json.load(handle) 505 | self.parse_profile(profile) 506 | 507 | def parse_profile(self, profile): 508 | """ Parse a JSON profile into a structure. 509 | """ 510 | assert profile 511 | assert 'StructureDefinition' == profile['resourceType'] 512 | 513 | # parse structure 514 | self.url = profile.get('url') 515 | logger.info('Parsing profile "{}"'.format(profile.get('name'))) 516 | self.structure = FHIRStructureDefinitionStructure(self, profile) 517 | 518 | def process_profile(self): 519 | """ Extract all elements and create classes. 520 | """ 521 | 522 | # Is this an interface and not a resource? This is new in 4.2 523 | is_interface_ext = FHIRExtension.extensionForURL('http://hl7.org/fhir/StructureDefinition/structuredefinition-interface', self.structure.extensions) 524 | if is_interface_ext is not None and is_interface_ext.valueBoolean: 525 | self.is_interface = True 526 | 527 | # Parse the differential to find classes we need to build 528 | struct = self.structure.differential# or self.structure.snapshot 529 | if struct is not None: 530 | mapped = {} 531 | self.elements = [] 532 | for elem_dict in struct: 533 | element = FHIRStructureDefinitionElement(self, elem_dict, self.main_element is None) 534 | self.elements.append(element) 535 | mapped[element.path] = element 536 | 537 | # establish hierarchy (may move to extra loop in case elements are no longer in order) 538 | if element.is_main_profile_element: 539 | self.main_element = element 540 | parent = mapped.get(element.parent_name) 541 | if parent: 542 | parent.add_child(element) 543 | 544 | # resolve element dependencies 545 | for element in self.elements: 546 | element.resolve_dependencies() 547 | 548 | # run check: if n_min > 0 and parent is in summary, must also be in summary 549 | for element in self.elements: 550 | if element.n_min is not None and element.n_min > 0: 551 | if element.parent is not None and element.parent.is_summary and not element.is_summary: 552 | logger.error("n_min > 0 but not summary: `{}`".format(element.path)) 553 | element.summary_n_min_conflict = True 554 | 555 | # create classes and class properties 556 | if self.main_element is not None: 557 | snap_class, subs = self.main_element.create_class() 558 | if snap_class is None: 559 | raise Exception('The main element for "{}" did not create a class' 560 | .format(self.url)) 561 | 562 | self.found_class(snap_class) 563 | for sub in subs: 564 | self.found_class(sub) 565 | self.targetname = snap_class.name 566 | 567 | def element_with_id(self, ident): 568 | """ Returns a FHIRStructureDefinitionElementDefinition with the given 569 | id, if found. Used to retrieve elements defined via `contentReference`. 570 | """ 571 | if self.elements is not None: 572 | for element in self.elements: 573 | if element.definition.id == ident: 574 | return element 575 | return None 576 | 577 | def dstu2_element_with_name(self, name): 578 | """ Returns a FHIRStructureDefinitionElementDefinition with the given 579 | name, if found. Used to retrieve elements defined via `nameReference` 580 | used in DSTU-2. 581 | """ 582 | if self.elements is not None: 583 | for element in self.elements: 584 | if element.definition.name == name: 585 | return element 586 | return None 587 | 588 | # MARK: Class Handling 589 | 590 | def found_class(self, klass): 591 | self.classes.append(klass) 592 | 593 | def needed_external_classes(self): 594 | """ Returns a unique list of class items that are needed for any of the 595 | receiver's classes' properties and are not defined in this profile. 596 | 597 | :raises: Will raise if called before `finalize` has been called. 598 | """ 599 | if not self._did_finalize: 600 | raise Exception('Cannot use `needed_external_classes` before finalizing') 601 | 602 | internal = set([c.name for c in self.classes]) 603 | needed = set() 604 | needs = [] 605 | 606 | for klass in self.classes: 607 | # are there superclasses that we need to import? 608 | sup_cls = klass.superclass 609 | if sup_cls is not None and sup_cls.name not in internal and sup_cls.name not in needed: 610 | needed.add(sup_cls.name) 611 | needs.append(sup_cls) 612 | 613 | # look at all properties' classes and assign their modules 614 | for prop in klass.properties: 615 | prop_cls_name = prop.class_name 616 | if prop.enum is not None and not self.spec.class_name_is_native(prop_cls_name): 617 | enum_cls, did_create = fhirclass.FHIRClass.for_element(prop.enum) 618 | enum_cls.module = prop.enum.name 619 | prop.module_name = enum_cls.module 620 | if enum_cls.name not in needed: 621 | needed.add(enum_cls.name) 622 | needs.append(enum_cls) 623 | 624 | elif prop_cls_name not in internal and not self.spec.class_name_is_native(prop_cls_name): 625 | prop_cls = fhirclass.FHIRClass.with_name(prop_cls_name) 626 | if prop_cls is None: 627 | raise Exception('There is no class "{}" for property "{}" on "{}" in {}'.format(prop_cls_name, prop.name, klass.name, self.name)) 628 | else: 629 | prop.module_name = prop_cls.module 630 | if prop_cls_name not in needed: 631 | needed.add(prop_cls_name) 632 | needs.append(prop_cls) 633 | 634 | return sorted(needs, key=lambda n: n.module or n.name) 635 | 636 | def referenced_classes(self): 637 | """ Returns a unique list of **external** class names that are 638 | referenced from at least one of the receiver's `Reference`-type 639 | properties. 640 | 641 | :raises: Will raise if called before `finalize` has been called. 642 | """ 643 | if not self._did_finalize: 644 | raise Exception('Cannot use `referenced_classes` before finalizing') 645 | 646 | references = set() 647 | for klass in self.classes: 648 | for prop in klass.properties: 649 | if len(prop.reference_to_names) > 0: 650 | references.update(prop.reference_to_names) 651 | 652 | # no need to list references to our own classes, remove them 653 | for klass in self.classes: 654 | references.discard(klass.name) 655 | 656 | return sorted(references) 657 | 658 | def writable_classes(self): 659 | classes = [] 660 | for klass in self.classes: 661 | if klass.should_write(): 662 | classes.append(klass) 663 | return classes 664 | 665 | 666 | # MARK: Finalizing 667 | 668 | def finalize(self): 669 | """ Our spec object calls this when all profiles have been parsed. 670 | """ 671 | 672 | # assign all super-classes as objects 673 | for cls in self.classes: 674 | if cls.superclass is None: 675 | superclass_name = cls.from_element.superclass_name 676 | super_cls = fhirclass.FHIRClass.with_name(superclass_name) 677 | if super_cls is None and superclass_name is not None: 678 | raise Exception('There is no class implementation for class named "{}" in profile "{}"' 679 | .format(superclass_name, self.url)) 680 | else: 681 | cls.superclass = super_cls 682 | cls.interfaces = cls.from_element.interfaces_if_main_element 683 | 684 | self._did_finalize = True 685 | 686 | 687 | class FHIRStructureDefinitionStructure(object): 688 | """ The actual structure of a complete profile. 689 | """ 690 | 691 | def __init__(self, profile, profile_dict): 692 | self.profile = profile 693 | self.name = None 694 | self.base = None 695 | self.kind = None 696 | self.subclass_of = None 697 | self.snapshot = None 698 | self.differential = None 699 | self.extensions = None 700 | 701 | self.parse_from(profile_dict) 702 | 703 | def parse_from(self, json_dict): 704 | name = json_dict.get('name') 705 | if not name: 706 | raise Exception("Must find 'name' in profile dictionary but found nothing") 707 | self.name = self.profile.spec.class_name_for_profile(name) 708 | self.base = json_dict.get('baseDefinition') 709 | self.kind = json_dict.get('kind') 710 | if self.base: 711 | self.subclass_of = self.profile.spec.class_name_for_profile(self.base) 712 | self.extensions = json_dict.get('extension') 713 | 714 | # find element definitions 715 | if 'snapshot' in json_dict: 716 | self.snapshot = json_dict['snapshot'].get('element', []) 717 | if 'differential' in json_dict: 718 | self.differential = json_dict['differential'].get('element', []) 719 | 720 | 721 | class FHIRStructureDefinitionElement(object): 722 | """ An element in a profile's structure. 723 | """ 724 | 725 | def __init__(self, profile, element_dict, is_main_profile_element=False): 726 | assert isinstance(profile, FHIRStructureDefinition) 727 | self.profile = profile 728 | self.path = None 729 | self.parent = None 730 | self.children = None 731 | self.parent_name = None 732 | self.definition = None 733 | self.n_min = None 734 | self.n_max = None 735 | self.is_summary = False 736 | self.summary_n_min_conflict = False # to mark conflicts, see #13215 (http://gforge.hl7.org/gf/project/fhir/tracker/?action=TrackerItemEdit&tracker_item_id=13125) 737 | self.valueset = None 738 | self.enum = None # assigned if the element has a binding to a ValueSet that is a CodeSystem generating an enum 739 | 740 | self.is_main_profile_element = is_main_profile_element 741 | self.represents_class = False 742 | 743 | self._superclass_name = None 744 | self._name_if_class = None 745 | self._did_resolve_dependencies = False 746 | 747 | if element_dict is not None: 748 | self.parse_from(element_dict) 749 | else: 750 | self.definition = FHIRStructureDefinitionElementDefinition(self, None) 751 | 752 | def parse_from(self, element_dict): 753 | self.path = element_dict['path'] 754 | parts = self.path.split('.') 755 | self.parent_name = '.'.join(parts[:-1]) if len(parts) > 0 else None 756 | prop_name = parts[-1] 757 | if '-' in prop_name: 758 | prop_name = ''.join([n[:1].upper() + n[1:] for n in prop_name.split('-')]) 759 | 760 | self.definition = FHIRStructureDefinitionElementDefinition(self, element_dict) 761 | self.definition.prop_name = prop_name 762 | 763 | self.n_min = element_dict.get('min') 764 | self.n_max = element_dict.get('max') 765 | self.is_summary = element_dict.get('isSummary') 766 | 767 | def resolve_dependencies(self): 768 | if self.is_main_profile_element: 769 | self.represents_class = True 770 | if not self.represents_class and self.children is not None and len(self.children) > 0: 771 | self.represents_class = True 772 | if self.definition is not None: 773 | self.definition.resolve_dependencies() 774 | 775 | self._did_resolve_dependencies = True 776 | 777 | 778 | # MARK: Hierarchy 779 | 780 | @property 781 | def non_interface_superclass_if_main_element(self): 782 | if not self.is_main_profile_element: 783 | return None 784 | 785 | next_up = self.profile.structure 786 | while next_up.subclass_of is not None: 787 | profile_up = next_up.profile.spec.profile_named(next_up.subclass_of) 788 | if profile_up is None: 789 | raise Exception('StructureDefinitionElement {} defines a base of \"{}\", which I know nothing about' 790 | .format(self.path, next_up.subclass_of)) 791 | 792 | if profile_up.is_interface: 793 | next_up = profile_up.structure 794 | else: 795 | # This is a 4.2 workaround for `Resource` inheriting from `Base`, which we can't support right now 796 | # because the `resourceType` property is undefined 797 | if self.profile.structure.kind != profile_up.structure.kind: 798 | return None 799 | return profile_up.structure.name 800 | 801 | return None 802 | 803 | @property 804 | def interfaces_if_main_element(self): 805 | if not self.is_main_profile_element: 806 | return None 807 | 808 | interfaces = [] 809 | next_up = self.profile.structure 810 | while next_up.subclass_of is not None: 811 | profile = next_up.profile.spec.profile_named(next_up.subclass_of) 812 | if profile is None: 813 | raise Exception('StructureDefinitionElement {} defines a base of \"{}\", which I know nothing about' 814 | .format(self.path, next_up.subclass_of)) 815 | 816 | if profile.is_interface: 817 | interfaces.append(profile) 818 | next_up = profile.structure 819 | 820 | return interfaces if len(interfaces) > 0 else None 821 | 822 | def add_child(self, element): 823 | assert isinstance(element, FHIRStructureDefinitionElement) 824 | element.parent = self 825 | if self.children is None: 826 | self.children = [element] 827 | else: 828 | self.children.append(element) 829 | 830 | def create_class(self, module=None): 831 | """ Creates a FHIRClass instance from the receiver, returning the 832 | created class as the first and all inline defined subclasses as the 833 | second item in the tuple. 834 | """ 835 | assert self._did_resolve_dependencies 836 | if not self.represents_class: 837 | return None, None 838 | 839 | subs = [] 840 | cls, did_create = fhirclass.FHIRClass.for_element(self) 841 | if did_create: # manual_profiles 842 | if module is None: 843 | if self.profile.manual_module is not None: 844 | module = self.profile.manual_module 845 | elif self.is_main_profile_element: 846 | module = self.profile.spec.as_module_name(cls.name) 847 | cls.module = module 848 | logger.debug('Created class "{}", module {}'.format(cls.name, module)) 849 | 850 | # child classes 851 | if self.children is not None: 852 | for child in self.children: 853 | properties = child.as_properties() 854 | if properties is not None: 855 | 856 | # collect subclasses 857 | sub, subsubs = child.create_class(module) 858 | if sub is not None: 859 | subs.append(sub) 860 | if subsubs is not None: 861 | subs.extend(subsubs) 862 | 863 | # add properties to class 864 | if did_create: 865 | for prop in properties: 866 | cls.add_property(prop) 867 | 868 | return cls, subs 869 | 870 | def as_properties(self): 871 | """ If the element describes a *class property*, returns a list of 872 | FHIRClassProperty instances, None otherwise. 873 | """ 874 | assert self._did_resolve_dependencies 875 | if self.is_main_profile_element or self.definition is None: 876 | return None 877 | 878 | # TODO: handle slicing information (not sure why these properties were 879 | # omitted previously) 880 | #if self.definition.slicing: 881 | # logger.debug('Omitting property "{}" for slicing'.format(self.definition.prop_name)) 882 | # return None 883 | 884 | # this must be a property 885 | if self.parent is None: 886 | raise Exception('Element reports as property but has no parent: "{}"' 887 | .format(self.path)) 888 | 889 | # create a list of FHIRClassProperty instances (usually with only 1 item) 890 | if len(self.definition.types) > 0: 891 | props = [] 892 | for type_obj in self.definition.types: 893 | 894 | # an inline class 895 | if 'BackboneElement' == type_obj.code or 'Element' == type_obj.code: # data types don't use "BackboneElement" 896 | props.append(fhirclass.FHIRClassProperty(self, type_obj, self.name_if_class)) 897 | # TODO: look at http://hl7.org/fhir/StructureDefinition/structuredefinition-explicit-type-name ? 898 | else: 899 | props.append(fhirclass.FHIRClassProperty(self, type_obj)) 900 | return props 901 | 902 | # no `type` definition in the element: it's a property with an inline class definition 903 | type_obj = FHIRElementType() 904 | return [fhirclass.FHIRClassProperty(self, type_obj, self.name_if_class)] 905 | 906 | 907 | # MARK: Name Utils 908 | 909 | def name_of_resource(self): 910 | assert self._did_resolve_dependencies 911 | if not self.is_main_profile_element or self.profile.structure.kind is None or self.profile.structure.kind != 'resource': 912 | return None 913 | return self.profile.name 914 | 915 | @property 916 | def name_if_class(self): 917 | if self._name_if_class is None: 918 | self._name_if_class = self.definition.name_if_class() 919 | return self._name_if_class 920 | 921 | @property 922 | def superclass_name(self): 923 | """ Determine the superclass for the element (used for class elements). 924 | """ 925 | if self._superclass_name is None: 926 | tps = self.definition.types 927 | if len(tps) > 1: 928 | raise Exception('Have more than one type to determine superclass in "{}": "{}"' 929 | .format(self.path, tps)) 930 | type_code = None 931 | 932 | main_superclass_type_code = self.non_interface_superclass_if_main_element 933 | if self.is_main_profile_element and main_superclass_type_code is not None: 934 | type_code = main_superclass_type_code 935 | elif len(tps) > 0: 936 | type_code = tps[0].code 937 | elif self.profile.structure.kind: 938 | type_code = self.profile.spec.settings.default_base.get(self.profile.structure.kind) 939 | self._superclass_name = self.profile.spec.class_name_for_type(type_code) 940 | 941 | return self._superclass_name 942 | 943 | def __repr__(self): 944 | return f"<{self.__class__.__name__}> path: {self.path}" 945 | 946 | 947 | class FHIRStructureDefinitionElementDefinition(object): 948 | """ The definition of a FHIR element. 949 | """ 950 | 951 | def __init__(self, element, definition_dict): 952 | self.id = None 953 | self.element = element 954 | self.types = [] 955 | self.name = None 956 | self.prop_name = None 957 | self.content_reference = None 958 | self._content_referenced = None 959 | self.short = None 960 | self.formal = None 961 | self.comment = None 962 | self.binding = None 963 | self.constraint = None 964 | self.mapping = None 965 | self.slicing = None 966 | self.representation = None 967 | # TODO: extract "defaultValue[x]", "fixed[x]", "pattern[x]" 968 | # TODO: handle "slicing" 969 | 970 | if definition_dict is not None: 971 | self.parse_from(definition_dict) 972 | 973 | def parse_from(self, definition_dict): 974 | self.id = definition_dict.get('id') 975 | self.types = [] 976 | for type_dict in definition_dict.get('type', []): 977 | self.types.append(FHIRElementType(type_dict)) 978 | 979 | self.name = definition_dict.get('name') 980 | self.content_reference = definition_dict.get('contentReference') 981 | self.dstu2_name_reference = definition_dict.get('nameReference') 982 | 983 | self.short = definition_dict.get('short') 984 | self.formal = definition_dict.get('definition') 985 | if self.formal and self.short == self.formal[:-1]: # formal adds a trailing period 986 | self.formal = None 987 | self.comment = definition_dict.get('comments') 988 | 989 | if 'binding' in definition_dict: 990 | self.binding = FHIRElementBinding(definition_dict['binding']) 991 | if 'constraint' in definition_dict: 992 | self.constraint = FHIRElementConstraint(definition_dict['constraint']) 993 | if 'mapping' in definition_dict: 994 | self.mapping = FHIRElementMapping(definition_dict['mapping']) 995 | if 'slicing' in definition_dict: 996 | self.slicing = definition_dict['slicing'] 997 | self.representation = definition_dict.get('representation') 998 | 999 | def resolve_dependencies(self): 1000 | # update the definition from a reference, if there is one 1001 | if self.content_reference is not None: 1002 | if '#' != self.content_reference[:1]: 1003 | raise Exception("Only relative 'contentReference' element definitions are supported right now") 1004 | elem = self.element.profile.element_with_id(self.content_reference[1:]) 1005 | if elem is None: 1006 | raise Exception(f'There is no element definiton with id "{self.content_reference}", as referenced by {self.path} in {self.profile.url}') 1007 | self._content_referenced = elem.definition 1008 | elif self.dstu2_name_reference is not None: # DSTU-2 backwards-compatibility 1009 | elem = self.element.profile.dstu2_element_with_name(self.dstu2_name_reference) 1010 | if elem is None: 1011 | raise Exception(f'There is no element definiton with name "{self.dstu2_name_reference}", as referenced by {self.path} in {self.profile.url}') 1012 | self._content_referenced = elem.definition 1013 | 1014 | # resolve bindings 1015 | if self.binding is not None and self.binding.is_required and self.binding.has_valueset: 1016 | uri = self.binding.valueset_uri 1017 | if 'http://hl7.org/fhir' != uri[:19]: 1018 | logger.debug("Ignoring foreign ValueSet \"{}\"".format(uri)) 1019 | return 1020 | # remove version from canonical URI, if present, e.g. "http://hl7.org/fhir/ValueSet/name-use|4.0.0" 1021 | if '|' in uri: 1022 | uri = uri.split('|')[0] 1023 | 1024 | valueset = self.element.profile.spec.valueset_with_uri(uri) 1025 | if valueset is None: 1026 | logger.error("There is no ValueSet for required binding \"{}\" on {} in {}" 1027 | .format(uri, self.name or self.prop_name, self.element.profile.name)) 1028 | else: 1029 | self.element.valueset = valueset 1030 | self.element.enum = valueset.enum 1031 | 1032 | def name_if_class(self): 1033 | """ Determines the class-name that the element would have if it was 1034 | defining a class. This means it uses "name", if present, and the last 1035 | "path" component otherwise. It also detects if the definition is a 1036 | reference and will re-use the class name defined by the referenced 1037 | element (such as `ValueSet.codeSystem.concept.concept`). 1038 | """ 1039 | 1040 | # This Element is a reference, pick up the original name 1041 | if self._content_referenced is not None: 1042 | return self._content_referenced.name_if_class() 1043 | 1044 | with_name = self.name or self.prop_name 1045 | parent_name = self.element.parent.name_if_class if self.element.parent is not None else None 1046 | classname = self.element.profile.spec.class_name_for_type(with_name, parent_name) 1047 | if parent_name is not None and self.element.profile.spec.settings.backbone_class_adds_parent: 1048 | classname = parent_name + classname 1049 | return classname 1050 | 1051 | 1052 | class FHIRElementType(object): 1053 | """ Representing a type of an element. 1054 | """ 1055 | 1056 | def __init__(self, type_dict=None): 1057 | self.code = None 1058 | self.profile = None 1059 | 1060 | if type_dict is not None: 1061 | self.parse_from(type_dict) 1062 | 1063 | def parse_from(self, type_dict): 1064 | self.code = type_dict.get('code') 1065 | 1066 | # Look for the "structuredefinition-fhir-type" extension, introduced after R4 1067 | extensions = type_dict.get('extension') 1068 | if extensions is not None: 1069 | fhir_ext = FHIRExtension.extensionForURL('http://hl7.org/fhir/StructureDefinition/structuredefinition-fhir-type', extensions) 1070 | if fhir_ext is not None: # Supported in R4 and newer 1071 | self.code = fhir_ext.valueUri or fhir_ext.valueUrl # 'valueUrl' for R4 support 1072 | 1073 | # This may hit on R4 or earlier 1074 | ext_code = type_dict.get('_code') 1075 | if self.code is None and ext_code is not None: 1076 | json_ext = [e for e in ext_code.get('extension', []) if e.get('url') == 'http://hl7.org/fhir/StructureDefinition/structuredefinition-json-type'] 1077 | if len(json_ext) < 1: 1078 | raise Exception(f'Expecting either "code" or "_code" and a JSON type extension, found neither in {type_dict}') 1079 | if len(json_ext) > 1: 1080 | raise Exception(f'Found more than one structure definition JSON type in {type_dict}') 1081 | self.code = json_ext[0].get('valueString') 1082 | 1083 | if self.code is None: 1084 | raise Exception(f'No element type code found in {type_dict}') 1085 | if not _is_string(self.code): 1086 | raise Exception("Expecting a string for 'code' definition of an element type, got {} as {}" 1087 | .format(self.code, type(self.code))) 1088 | if not isinstance(type_dict.get('targetProfile'), (list,)): 1089 | self.profile = type_dict.get('targetProfile') 1090 | if self.profile is not None and not _is_string(self.profile) and not isinstance(type_dict.get('targetProfile'), (list,)): #Added a check to make sure the targetProfile wasn't a list 1091 | raise Exception("Expecting a string for 'targetProfile' definition of an element type, got {} as {}" 1092 | .format(self.profile, type(self.profile))) 1093 | 1094 | 1095 | class FHIRElementBinding(object): 1096 | """ The "binding" element in an element definition 1097 | """ 1098 | def __init__(self, binding_obj): 1099 | self.strength = binding_obj.get('strength') 1100 | self.description = binding_obj.get('description') 1101 | self.valueset = binding_obj.get('valueSet') 1102 | self.legacy_uri = binding_obj.get('valueSetUri') 1103 | self.legacy_canonical = binding_obj.get('valueSetCanonical') 1104 | self.dstu2_reference = binding_obj.get('valueSetReference', {}).get('reference') 1105 | self.is_required = 'required' == self.strength 1106 | 1107 | @property 1108 | def has_valueset(self): 1109 | return self.valueset_uri is not None 1110 | 1111 | @property 1112 | def valueset_uri(self): 1113 | return self.valueset or self.legacy_uri or self.legacy_canonical or self.dstu2_reference 1114 | 1115 | 1116 | class FHIRElementConstraint(object): 1117 | """ Constraint on an element. 1118 | """ 1119 | def __init__(self, constraint_arr): 1120 | pass 1121 | 1122 | 1123 | class FHIRElementMapping(object): 1124 | """ Mapping FHIR to other standards. 1125 | """ 1126 | def __init__(self, mapping_arr): 1127 | pass 1128 | 1129 | 1130 | class FHIRExtension(object): 1131 | """ A FHIR Extension. 1132 | """ 1133 | def __init__(self, extension): 1134 | self.url = extension.get('url') 1135 | self.extension = extension 1136 | 1137 | @classmethod 1138 | def extensionForURL(cls, url, extension_dicts): 1139 | """ Provided with an array of extension dictionaries (!), returns an 1140 | instance of FHIRExtension if the url matches. 1141 | """ 1142 | if extension_dicts is None: 1143 | return None 1144 | for extension_dict in extension_dicts: 1145 | if extension_dict.get('url') == url: 1146 | return cls(extension_dict) 1147 | return None 1148 | 1149 | def __getattr__(self, name): 1150 | if name not in self.extension: 1151 | return None # we don't want to raise AttributeError 1152 | return self.extension[name] 1153 | 1154 | 1155 | def _is_string(element): 1156 | isstr = isinstance(element, str) 1157 | if not isstr and sys.version_info[0] < 3: # Python 2.x has 'str' and 'unicode' 1158 | isstr = isinstance(element, basestring) 1159 | return isstr 1160 | 1161 | --------------------------------------------------------------------------------