From 75fdf507b939b6ae9c5ba31f76e651d799f9ead4 Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Thu, 18 Apr 2024 09:38:19 +0100 Subject: [PATCH 01/25] ehr and llm layer wip --- src/ehr/__init__.py | 0 src/ehr/ehr.py | 43 +++++++++++++++++++ src/ehr/workflows/__init__.py | 0 src/ehr/workflows/cds_hooks/__init__.py | 0 src/ehr/workflows/cds_hooks/order-select.py | 0 src/ehr/workflows/cds_hooks/order-sign.py | 0 src/ehr/workflows/cds_hooks/patient-view.py | 5 +++ src/ehr/workflows/notereader/__init__.py | 0 .../notereader/sign-note-inpatient.py | 0 .../notereader/sign-note-outpatient.py | 0 src/models/integrations/__init__.py | 0 src/models/integrations/langchain.py | 0 src/models/integrations/medcat.py | 0 src/models/llm.py | 7 +++ src/models/nlp.py | 0 src/service/__init__.py | 0 src/service/servers/rest.py | 0 src/service/servers/soap.py | 0 src/service/service-builder.py | 3 ++ .../specifications/epic-notereader-service.py | 0 src/service/specifications/hl7-cds-service.py | 0 21 files changed, 58 insertions(+) create mode 100644 src/ehr/__init__.py create mode 100644 src/ehr/ehr.py create mode 100644 src/ehr/workflows/__init__.py create mode 100644 src/ehr/workflows/cds_hooks/__init__.py create mode 100644 src/ehr/workflows/cds_hooks/order-select.py create mode 100644 src/ehr/workflows/cds_hooks/order-sign.py create mode 100644 src/ehr/workflows/cds_hooks/patient-view.py create mode 100644 src/ehr/workflows/notereader/__init__.py create mode 100644 src/ehr/workflows/notereader/sign-note-inpatient.py create mode 100644 src/ehr/workflows/notereader/sign-note-outpatient.py create mode 100644 src/models/integrations/__init__.py create mode 100644 src/models/integrations/langchain.py create mode 100644 src/models/integrations/medcat.py create mode 100644 src/models/llm.py create mode 100644 src/models/nlp.py create mode 100644 src/service/__init__.py create mode 100644 src/service/servers/rest.py create mode 100644 src/service/servers/soap.py create mode 100644 src/service/service-builder.py create mode 100644 src/service/specifications/epic-notereader-service.py create mode 100644 src/service/specifications/hl7-cds-service.py diff --git a/src/ehr/__init__.py b/src/ehr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ehr/ehr.py b/src/ehr/ehr.py new file mode 100644 index 0000000..8b478bc --- /dev/null +++ b/src/ehr/ehr.py @@ -0,0 +1,43 @@ +from enum import Enum +from pydantic import List, Optional + +# a workflow is a specific event that may occur in an EHR that triggers a request to server +class Workflow(Enum): + pass + +class UseCase(Enum): + clinical_decision_support = "cds" + clinical_documentation = "notereader" + +class Vendor(Enum): + Epic = "epic" + + + +class EHR: + """ + EHR mocker which simulates the behaviour of an EHR sending API requests to a third-party server + Currently support: + - Clinical Decision Support (HL7 CDS Hooks) + - Clinical Documentation (Epic NoteReader) + """ + def __init__(self, data, use_case: UseCase, vendor: Optional[Vendor] = "epic") -> None: + self.data = data # DoppelData object + self.vendor = vendor + self.use_case = use_case + self.events: List[Workflow] # the sequence of events is determined by the use case + + def _init_ehr_events() -> None: + pass + + def _construct_request() -> None: + """ + Constructs the API request using DoppelData object + """ + pass + + def send_request(num=1) -> None: + """ + Sends the API request to the NLP service + """ + pass diff --git a/src/ehr/workflows/__init__.py b/src/ehr/workflows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ehr/workflows/cds_hooks/__init__.py b/src/ehr/workflows/cds_hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ehr/workflows/cds_hooks/order-select.py b/src/ehr/workflows/cds_hooks/order-select.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ehr/workflows/cds_hooks/order-sign.py b/src/ehr/workflows/cds_hooks/order-sign.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ehr/workflows/cds_hooks/patient-view.py b/src/ehr/workflows/cds_hooks/patient-view.py new file mode 100644 index 0000000..d47ec41 --- /dev/null +++ b/src/ehr/workflows/cds_hooks/patient-view.py @@ -0,0 +1,5 @@ +""" +The user has just opened a patient's record; +typically called only once at the beginning of a user's interaction with a specific patient's record. +https://cds-hooks.org/hooks/patient-view/ +""" \ No newline at end of file diff --git a/src/ehr/workflows/notereader/__init__.py b/src/ehr/workflows/notereader/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ehr/workflows/notereader/sign-note-inpatient.py b/src/ehr/workflows/notereader/sign-note-inpatient.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ehr/workflows/notereader/sign-note-outpatient.py b/src/ehr/workflows/notereader/sign-note-outpatient.py new file mode 100644 index 0000000..e69de29 diff --git a/src/models/integrations/__init__.py b/src/models/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/models/integrations/langchain.py b/src/models/integrations/langchain.py new file mode 100644 index 0000000..e69de29 diff --git a/src/models/integrations/medcat.py b/src/models/integrations/medcat.py new file mode 100644 index 0000000..e69de29 diff --git a/src/models/llm.py b/src/models/llm.py new file mode 100644 index 0000000..c4e2cdb --- /dev/null +++ b/src/models/llm.py @@ -0,0 +1,7 @@ +""" +A wrapper for an LLM model +""" + +class LLM: + def __init__(self) -> None: + self.model \ No newline at end of file diff --git a/src/models/nlp.py b/src/models/nlp.py new file mode 100644 index 0000000..e69de29 diff --git a/src/service/__init__.py b/src/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/service/servers/rest.py b/src/service/servers/rest.py new file mode 100644 index 0000000..e69de29 diff --git a/src/service/servers/soap.py b/src/service/servers/soap.py new file mode 100644 index 0000000..e69de29 diff --git a/src/service/service-builder.py b/src/service/service-builder.py new file mode 100644 index 0000000..810310b --- /dev/null +++ b/src/service/service-builder.py @@ -0,0 +1,3 @@ +""" +The model needs to be wrapped in a REST API server spcified by CDS / Epic etc. +""" \ No newline at end of file diff --git a/src/service/specifications/epic-notereader-service.py b/src/service/specifications/epic-notereader-service.py new file mode 100644 index 0000000..e69de29 diff --git a/src/service/specifications/hl7-cds-service.py b/src/service/specifications/hl7-cds-service.py new file mode 100644 index 0000000..e69de29 From 24cff90ff984bf7ad3e391f8a4e48cfd9c5c1d3c Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Fri, 19 Apr 2024 09:57:04 +0100 Subject: [PATCH 02/25] Set up structure for EHR client class --- src/ehr/base.py | 43 ++++++++++++++ src/ehr/ehr.py | 64 ++++++++++++++------- src/ehr/use_cases/__init__.py | 0 src/ehr/use_cases/cds.py | 20 +++++++ src/ehr/use_cases/clindoc.py | 20 +++++++ src/ehr/workflows/cds_hooks/order-select.py | 4 ++ 6 files changed, 129 insertions(+), 22 deletions(-) create mode 100644 src/ehr/base.py create mode 100644 src/ehr/use_cases/__init__.py create mode 100644 src/ehr/use_cases/cds.py create mode 100644 src/ehr/use_cases/clindoc.py diff --git a/src/ehr/base.py b/src/ehr/base.py new file mode 100644 index 0000000..9edd78f --- /dev/null +++ b/src/ehr/base.py @@ -0,0 +1,43 @@ +from abc import ABC, abstractmethod +from enum import Enum +from pydantic import Dict + + +# a workflow is a specific event that may occur in an EHR that triggers a request to server +class Workflow(Enum): + patient_view = "patient-view" + order_select = "order-select" + order_sign = "order-sign" + + +class UseCase(Enum): + clinical_decision_support = "cds" + clinical_documentation = "notereader" + + +class BaseClient(ABC): + """Base client class + A client can be an EHR or CPOE etc. + The basic operation is that it sends data in a specified standard. + """ + + @abstractmethod + def send_request(self) -> None: + """ + Sends a request to AI service + """ + +class BaseUseCase(ABC): + """ + Abstract class for a specific use case of an EHR object + Use cases will differ by: + - the data it accepts (FHIR or CDA) + - the format of the request it constructs (CDS Hook or NoteReader workflows) + """ + @abstractmethod + def _validate_data(self, data) -> bool: + pass + + @abstractmethod + def construct_request(self, data, workflow: Workflow) -> Dict: + pass \ No newline at end of file diff --git a/src/ehr/ehr.py b/src/ehr/ehr.py index 8b478bc..3316c19 100644 --- a/src/ehr/ehr.py +++ b/src/ehr/ehr.py @@ -1,43 +1,63 @@ +import logging +import requests + +from typing import Dict from enum import Enum from pydantic import List, Optional -# a workflow is a specific event that may occur in an EHR that triggers a request to server -class Workflow(Enum): - pass +from .base import BaseClient, UseCase, Workflow + +log = logging.getLogger(__name__) -class UseCase(Enum): - clinical_decision_support = "cds" - clinical_documentation = "notereader" +# wrap vendor specific logic in decorators? may be too complex, use config? class Vendor(Enum): Epic = "epic" - -class EHR: +class EHR(BaseClient): """ EHR mocker which simulates the behaviour of an EHR sending API requests to a third-party server Currently support: - Clinical Decision Support (HL7 CDS Hooks) - Clinical Documentation (Epic NoteReader) """ - def __init__(self, data, use_case: UseCase, vendor: Optional[Vendor] = "epic") -> None: - self.data = data # DoppelData object - self.vendor = vendor - self.use_case = use_case - self.events: List[Workflow] # the sequence of events is determined by the use case - - def _init_ehr_events() -> None: - pass + def __init__(self, use_case: UseCase) -> None: + self._use_case = use_case + self.data = None # DoppelData object; + self.fhir_server_endpoint = None # this is just for reference, simulating return of data from fhir server is all the same - def _construct_request() -> None: + @property + def UseCase(self) -> UseCase: + return self._use_case + + @UseCase.setter + def UseCase(self, use_case: UseCase) -> None: + self._use_case = use_case + + # to implement in DoppelData + @property + def DataSchema(self) -> Dict: + return self.data._schema + + def add_database(self, data) -> None: """ - Constructs the API request using DoppelData object + This will take in a DoppelData object and validate it for the appropriate workflow """ - pass + self.data = data - def send_request(num=1) -> None: + def send_request(self, url: str, workflow: Workflow) -> None: """ - Sends the API request to the NLP service + Sends the API request to an AI service """ - pass + + request = self._use_case.construct_request(self.data, workflow) + + try: + response = requests.post(url=url, body=request) + except Exception as e: + raise RuntimeError(f"Error sending request: {e}") + + + return response + diff --git a/src/ehr/use_cases/__init__.py b/src/ehr/use_cases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ehr/use_cases/cds.py b/src/ehr/use_cases/cds.py new file mode 100644 index 0000000..0a9ff82 --- /dev/null +++ b/src/ehr/use_cases/cds.py @@ -0,0 +1,20 @@ +from pydantic import Dict +from ..base import BaseUseCase, Workflow + + +class ClinicalDecisionSupport(BaseUseCase): + """ + Simulates the behaviour of EHR backend for Clinical Decision Support (CDS) + """ + def _validate_data(self, data, workflow: Workflow) -> bool: + # do something to valida fhir data and the worklow it's for + pass + + def construct_request(self, data, workflow: Workflow) -> Dict: + if self._validate_data(data, workflow): + # do something to construct a cds rest API post request depending on the workflow + request = {} + else: + raise ValueError(f"Error validating data for workflow {Workflow}") + + return request \ No newline at end of file diff --git a/src/ehr/use_cases/clindoc.py b/src/ehr/use_cases/clindoc.py new file mode 100644 index 0000000..b92f731 --- /dev/null +++ b/src/ehr/use_cases/clindoc.py @@ -0,0 +1,20 @@ +from pydantic import Dict +from ..base import BaseUseCase, Workflow + + +class ClinicalDocumentation(BaseUseCase): + """ + Simulates the behaviour of EHR backend for clinical documentation (NoteReader) + """ + def _validate_data(self, data, workflow: Workflow) -> bool: + # do something to validate cda data and the workflow it's for + pass + + def construct_request(self, data, workflow: Workflow) -> Dict: + if self._validate_data(data, workflow): + # do something to construct a notereader soap request + request = {} + else: + raise ValueError(f"Error validating data for workflow {Workflow}") + + return request \ No newline at end of file diff --git a/src/ehr/workflows/cds_hooks/order-select.py b/src/ehr/workflows/cds_hooks/order-select.py index e69de29..046e2f9 100644 --- a/src/ehr/workflows/cds_hooks/order-select.py +++ b/src/ehr/workflows/cds_hooks/order-select.py @@ -0,0 +1,4 @@ +from pydantic import BaseModel + +class OrderSelect(BaseModel): + pass \ No newline at end of file From f41ca6e4e9cde0be8d6d038e28245a8b4d45dda1 Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Fri, 19 Apr 2024 14:36:13 +0100 Subject: [PATCH 03/25] added workflow validation in strategy and renamed ehr -> simulator --- src/ehr/ehr.py | 63 ------------- src/{ehr => simulator}/__init__.py | 0 src/{ehr => simulator}/base.py | 33 ++++++- src/simulator/ehr.py | 94 +++++++++++++++++++ src/{ehr => simulator}/use_cases/__init__.py | 0 src/{ehr => simulator}/use_cases/cds.py | 11 ++- src/{ehr => simulator}/use_cases/clindoc.py | 11 ++- src/{ehr => simulator}/workflows/__init__.py | 0 .../workflows/cds_hooks/__init__.py | 0 .../workflows/cds_hooks/order-select.py | 0 .../workflows/cds_hooks/order-sign.py | 0 .../workflows/cds_hooks/patient-view.py | 0 .../workflows/notereader/__init__.py | 0 .../notereader/sign-note-inpatient.py | 0 .../notereader/sign-note-outpatient.py | 0 15 files changed, 137 insertions(+), 75 deletions(-) delete mode 100644 src/ehr/ehr.py rename src/{ehr => simulator}/__init__.py (100%) rename src/{ehr => simulator}/base.py (50%) create mode 100644 src/simulator/ehr.py rename src/{ehr => simulator}/use_cases/__init__.py (100%) rename src/{ehr => simulator}/use_cases/cds.py (73%) rename src/{ehr => simulator}/use_cases/clindoc.py (71%) rename src/{ehr => simulator}/workflows/__init__.py (100%) rename src/{ehr => simulator}/workflows/cds_hooks/__init__.py (100%) rename src/{ehr => simulator}/workflows/cds_hooks/order-select.py (100%) rename src/{ehr => simulator}/workflows/cds_hooks/order-sign.py (100%) rename src/{ehr => simulator}/workflows/cds_hooks/patient-view.py (100%) rename src/{ehr => simulator}/workflows/notereader/__init__.py (100%) rename src/{ehr => simulator}/workflows/notereader/sign-note-inpatient.py (100%) rename src/{ehr => simulator}/workflows/notereader/sign-note-outpatient.py (100%) diff --git a/src/ehr/ehr.py b/src/ehr/ehr.py deleted file mode 100644 index 3316c19..0000000 --- a/src/ehr/ehr.py +++ /dev/null @@ -1,63 +0,0 @@ -import logging -import requests - -from typing import Dict -from enum import Enum -from pydantic import List, Optional - -from .base import BaseClient, UseCase, Workflow - -log = logging.getLogger(__name__) - - -# wrap vendor specific logic in decorators? may be too complex, use config? -class Vendor(Enum): - Epic = "epic" - - -class EHR(BaseClient): - """ - EHR mocker which simulates the behaviour of an EHR sending API requests to a third-party server - Currently support: - - Clinical Decision Support (HL7 CDS Hooks) - - Clinical Documentation (Epic NoteReader) - """ - def __init__(self, use_case: UseCase) -> None: - self._use_case = use_case - self.data = None # DoppelData object; - self.fhir_server_endpoint = None # this is just for reference, simulating return of data from fhir server is all the same - - @property - def UseCase(self) -> UseCase: - return self._use_case - - @UseCase.setter - def UseCase(self, use_case: UseCase) -> None: - self._use_case = use_case - - # to implement in DoppelData - @property - def DataSchema(self) -> Dict: - return self.data._schema - - def add_database(self, data) -> None: - """ - This will take in a DoppelData object and validate it for the appropriate workflow - """ - self.data = data - - def send_request(self, url: str, workflow: Workflow) -> None: - """ - Sends the API request to an AI service - """ - - request = self._use_case.construct_request(self.data, workflow) - - try: - response = requests.post(url=url, body=request) - except Exception as e: - raise RuntimeError(f"Error sending request: {e}") - - - return response - diff --git a/src/ehr/__init__.py b/src/simulator/__init__.py similarity index 100% rename from src/ehr/__init__.py rename to src/simulator/__init__.py diff --git a/src/ehr/base.py b/src/simulator/base.py similarity index 50% rename from src/ehr/base.py rename to src/simulator/base.py index 9edd78f..3828efb 100644 --- a/src/ehr/base.py +++ b/src/simulator/base.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod from enum import Enum -from pydantic import Dict +from typing import Dict # a workflow is a specific event that may occur in an EHR that triggers a request to server @@ -8,11 +8,35 @@ class Workflow(Enum): patient_view = "patient-view" order_select = "order-select" order_sign = "order-sign" + encounter_discharge = "encounter-discharge" + notereader_sign_inpatient = "notereader-sign-inpatient" + notereader_sign_outpatient = "notereader-sign-outpatient" -class UseCase(Enum): - clinical_decision_support = "cds" - clinical_documentation = "notereader" +class UseCaseType(Enum): + ClinicalDecisionSupport = ( + "patient-view", "order-select", "order-sign", "encounter-discharge" + ) + ClinicalDocumentation = ( + "notereader-sign-inpatient", "notereader-sign-outpatient" + ) + + def __init__(self, *workflows): + self.allowed_workflows = workflows + + +def is_valid_workflow(use_case: UseCaseType, workflow: Workflow) -> bool: + return workflow.value in use_case.allowed_workflows + + +def validate_workflow(use_case): + def decorator(func): + def wrapper(*args, **kwargs): + if not is_valid_workflow(use_case, args[2]): + raise ValueError(f"Invalid workflow {args[2]} for UseCase {use_case}") + return func(*args, **kwargs) + return wrapper + return decorator class BaseClient(ABC): @@ -27,6 +51,7 @@ def send_request(self) -> None: Sends a request to AI service """ + class BaseUseCase(ABC): """ Abstract class for a specific use case of an EHR object diff --git a/src/simulator/ehr.py b/src/simulator/ehr.py new file mode 100644 index 0000000..d1ff2a6 --- /dev/null +++ b/src/simulator/ehr.py @@ -0,0 +1,94 @@ +import logging +import requests + +from pathlib import Path +from typing import Dict +from enum import Enum + +from .use_cases.cds import ClinicalDecisionSupport +from .use_cases.clindoc import ClinicalDocumentation +from .base import BaseClient, BaseUseCase, UseCaseType, Workflow + +log = logging.getLogger(__name__) + + +# wrap vendor specific logic in decorators? may be too complex, use config? +class Vendor(Enum): + Epic = "epic" + + +class EHR(BaseClient): + """ + EHR mocker which simulates the behaviour of an EHR sending API requests to a third-party server + Currently support: + - Clinical Decision Support (HL7 CDS Hooks) + - Clinical Documentation (Epic NoteReader) + """ + def __init__(self, use_case: BaseUseCase) -> None: + self._use_case = use_case + self.data = None # DoppelData object; + self.fhir_server_endpoint = None # this is just for reference, simulating return of data from fhir server is all the same + + @property + def UseCase(self) -> UseCaseType: + return self._use_case + + @UseCase.setter + def UseCase(self, use_case: UseCase) -> None: + self._use_case = use_case + + # to implement in DoppelData + @property + def DataSchema(self) -> Dict: + return self.data._schema + + @classmethod + def from_doppeldata(cls, data, use_case: UseCase) -> None: + """ + Constructs EHR from DoppelData object + """ + return cls(data, use_case) + + @classmethod + def from_path(cls, path: Path, use_case: UseCase) -> None: + """ + Constructs EHR from a local folder containing requests + """ + data = path + return cls(data, use_case) + + # @staticmethod + # def choose_strategy(strategy_type: str): + # """Selects the strategy based on the strategy_type argument.""" + # strategies = { + # "cds": ClinicalDecisionSupport(), + # "clinical_documentation": ClinicalDocumentation() + # } + # return strategies.get(strategy_type, None) + + def add_database(self, data) -> None: + """ + This will take in a DoppelData object + """ + self.data = data + + def send_request(self, url: str, workflow: Workflow) -> None: + """ + Sends the API request to an AI service + """ + response = {} + request = self._use_case.construct_request(self.data, workflow) + + try: + response = requests.post(url=url, data=request) + except Exception as e: + log.error(f"Error sending request: {e}") + + + return response + + async def send_request_bulk(self, num: int, url: str, workflow: Workflow) -> None: + """ + Sends bulk API requests to an AI service + """ + diff --git a/src/ehr/use_cases/__init__.py b/src/simulator/use_cases/__init__.py similarity index 100% rename from src/ehr/use_cases/__init__.py rename to src/simulator/use_cases/__init__.py diff --git a/src/ehr/use_cases/cds.py b/src/simulator/use_cases/cds.py similarity index 73% rename from src/ehr/use_cases/cds.py rename to src/simulator/use_cases/cds.py index 0a9ff82..3dd66c2 100644 --- a/src/ehr/use_cases/cds.py +++ b/src/simulator/use_cases/cds.py @@ -1,5 +1,6 @@ -from pydantic import Dict -from ..base import BaseUseCase, Workflow +from typing import Dict + +from ..base import BaseUseCase, UseCaseType, Workflow, validate_workflow class ClinicalDecisionSupport(BaseUseCase): @@ -8,11 +9,13 @@ class ClinicalDecisionSupport(BaseUseCase): """ def _validate_data(self, data, workflow: Workflow) -> bool: # do something to valida fhir data and the worklow it's for - pass - + return True + + @validate_workflow(UseCaseType.ClinicalDecisionSupport) def construct_request(self, data, workflow: Workflow) -> Dict: if self._validate_data(data, workflow): # do something to construct a cds rest API post request depending on the workflow + print("Construction CDS request") request = {} else: raise ValueError(f"Error validating data for workflow {Workflow}") diff --git a/src/ehr/use_cases/clindoc.py b/src/simulator/use_cases/clindoc.py similarity index 71% rename from src/ehr/use_cases/clindoc.py rename to src/simulator/use_cases/clindoc.py index b92f731..19a4052 100644 --- a/src/ehr/use_cases/clindoc.py +++ b/src/simulator/use_cases/clindoc.py @@ -1,5 +1,6 @@ -from pydantic import Dict -from ..base import BaseUseCase, Workflow +from typing import Dict + +from ..base import BaseUseCase, UseCaseType, Workflow, validate_workflow class ClinicalDocumentation(BaseUseCase): @@ -8,11 +9,13 @@ class ClinicalDocumentation(BaseUseCase): """ def _validate_data(self, data, workflow: Workflow) -> bool: # do something to validate cda data and the workflow it's for - pass - + return True + + @validate_workflow(UseCaseType.ClinicalDocumentation) def construct_request(self, data, workflow: Workflow) -> Dict: if self._validate_data(data, workflow): # do something to construct a notereader soap request + print("Constructing Clinical Documentation request") request = {} else: raise ValueError(f"Error validating data for workflow {Workflow}") diff --git a/src/ehr/workflows/__init__.py b/src/simulator/workflows/__init__.py similarity index 100% rename from src/ehr/workflows/__init__.py rename to src/simulator/workflows/__init__.py diff --git a/src/ehr/workflows/cds_hooks/__init__.py b/src/simulator/workflows/cds_hooks/__init__.py similarity index 100% rename from src/ehr/workflows/cds_hooks/__init__.py rename to src/simulator/workflows/cds_hooks/__init__.py diff --git a/src/ehr/workflows/cds_hooks/order-select.py b/src/simulator/workflows/cds_hooks/order-select.py similarity index 100% rename from src/ehr/workflows/cds_hooks/order-select.py rename to src/simulator/workflows/cds_hooks/order-select.py diff --git a/src/ehr/workflows/cds_hooks/order-sign.py b/src/simulator/workflows/cds_hooks/order-sign.py similarity index 100% rename from src/ehr/workflows/cds_hooks/order-sign.py rename to src/simulator/workflows/cds_hooks/order-sign.py diff --git a/src/ehr/workflows/cds_hooks/patient-view.py b/src/simulator/workflows/cds_hooks/patient-view.py similarity index 100% rename from src/ehr/workflows/cds_hooks/patient-view.py rename to src/simulator/workflows/cds_hooks/patient-view.py diff --git a/src/ehr/workflows/notereader/__init__.py b/src/simulator/workflows/notereader/__init__.py similarity index 100% rename from src/ehr/workflows/notereader/__init__.py rename to src/simulator/workflows/notereader/__init__.py diff --git a/src/ehr/workflows/notereader/sign-note-inpatient.py b/src/simulator/workflows/notereader/sign-note-inpatient.py similarity index 100% rename from src/ehr/workflows/notereader/sign-note-inpatient.py rename to src/simulator/workflows/notereader/sign-note-inpatient.py diff --git a/src/ehr/workflows/notereader/sign-note-outpatient.py b/src/simulator/workflows/notereader/sign-note-outpatient.py similarity index 100% rename from src/ehr/workflows/notereader/sign-note-outpatient.py rename to src/simulator/workflows/notereader/sign-note-outpatient.py From ec271b693374ea6bc441d86b1b2a08cee270a0d5 Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Mon, 22 Apr 2024 11:04:22 +0100 Subject: [PATCH 04/25] add description to use cases --- src/simulator/ehr.py | 24 ++++++++++-------------- src/simulator/use_cases/cds.py | 3 +++ src/simulator/use_cases/clindoc.py | 5 ++++- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/simulator/ehr.py b/src/simulator/ehr.py index d1ff2a6..66152c5 100644 --- a/src/simulator/ehr.py +++ b/src/simulator/ehr.py @@ -5,8 +5,7 @@ from typing import Dict from enum import Enum -from .use_cases.cds import ClinicalDecisionSupport -from .use_cases.clindoc import ClinicalDocumentation +from .use_cases.test import TestUseCase from .base import BaseClient, BaseUseCase, UseCaseType, Workflow log = logging.getLogger(__name__) @@ -24,14 +23,14 @@ class EHR(BaseClient): - Clinical Decision Support (HL7 CDS Hooks) - Clinical Documentation (Epic NoteReader) """ - def __init__(self, use_case: BaseUseCase) -> None: + def __init__(self, use_case: BaseUseCase = None) -> None: self._use_case = use_case self.data = None # DoppelData object; self.fhir_server_endpoint = None # this is just for reference, simulating return of data from fhir server is all the same @property def UseCase(self) -> UseCaseType: - return self._use_case + return self._use_case.description() @UseCase.setter def UseCase(self, use_case: UseCase) -> None: @@ -40,7 +39,10 @@ def UseCase(self, use_case: UseCase) -> None: # to implement in DoppelData @property def DataSchema(self) -> Dict: - return self.data._schema + if self.data is not None: + return self.data._schema + else: + return None @classmethod def from_doppeldata(cls, data, use_case: UseCase) -> None: @@ -56,15 +58,6 @@ def from_path(cls, path: Path, use_case: UseCase) -> None: """ data = path return cls(data, use_case) - - # @staticmethod - # def choose_strategy(strategy_type: str): - # """Selects the strategy based on the strategy_type argument.""" - # strategies = { - # "cds": ClinicalDecisionSupport(), - # "clinical_documentation": ClinicalDocumentation() - # } - # return strategies.get(strategy_type, None) def add_database(self, data) -> None: """ @@ -76,6 +69,9 @@ def send_request(self, url: str, workflow: Workflow) -> None: """ Sends the API request to an AI service """ + if self._use_case is None: + raise RuntimeError("No EHR use case configured! Set using .UseCase") + response = {} request = self._use_case.construct_request(self.data, workflow) diff --git a/src/simulator/use_cases/cds.py b/src/simulator/use_cases/cds.py index 3dd66c2..8f697cc 100644 --- a/src/simulator/use_cases/cds.py +++ b/src/simulator/use_cases/cds.py @@ -7,6 +7,9 @@ class ClinicalDecisionSupport(BaseUseCase): """ Simulates the behaviour of EHR backend for Clinical Decision Support (CDS) """ + def description(self) -> str: + return "Clinical decision support (HL7 CDS specification)" + def _validate_data(self, data, workflow: Workflow) -> bool: # do something to valida fhir data and the worklow it's for return True diff --git a/src/simulator/use_cases/clindoc.py b/src/simulator/use_cases/clindoc.py index 19a4052..a747abe 100644 --- a/src/simulator/use_cases/clindoc.py +++ b/src/simulator/use_cases/clindoc.py @@ -7,6 +7,9 @@ class ClinicalDocumentation(BaseUseCase): """ Simulates the behaviour of EHR backend for clinical documentation (NoteReader) """ + def description(self) -> str: + return "Clinical documentation (NoteReader)" + def _validate_data(self, data, workflow: Workflow) -> bool: # do something to validate cda data and the workflow it's for return True @@ -15,7 +18,7 @@ def _validate_data(self, data, workflow: Workflow) -> bool: def construct_request(self, data, workflow: Workflow) -> Dict: if self._validate_data(data, workflow): # do something to construct a notereader soap request - print("Constructing Clinical Documentation request") + print("Constructing Clinical Documentation request...") request = {} else: raise ValueError(f"Error validating data for workflow {Workflow}") From 7a22a994f66e752630a96f067f5f7371432ab57c Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Wed, 24 Apr 2024 12:26:47 +0100 Subject: [PATCH 05/25] clean up imports --- src/simulator/ehr.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/simulator/ehr.py b/src/simulator/ehr.py index 66152c5..db56703 100644 --- a/src/simulator/ehr.py +++ b/src/simulator/ehr.py @@ -5,7 +5,6 @@ from typing import Dict from enum import Enum -from .use_cases.test import TestUseCase from .base import BaseClient, BaseUseCase, UseCaseType, Workflow log = logging.getLogger(__name__) From 802165a966adde010750b09cb83de19c210f7501 Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Wed, 24 Apr 2024 18:01:55 +0100 Subject: [PATCH 06/25] restructure project to decorator-based sdk --- example_use.py | 38 +++++++++++++++++++ src/{simulator => }/base.py | 0 src/decorators.py | 32 ++++++++++++++++ src/{models => }/integrations/__init__.py | 0 src/{models => }/integrations/langchain.py | 0 src/{models => }/integrations/medcat.py | 0 src/methods.py | 20 ++++++++++ .../use_cases => models}/__init__.py | 0 src/models/llm.py | 7 ---- .../cds_hooks => models}/order-select.py | 0 .../cds_hooks => models}/order-sign.py | 0 .../cds_hooks => models}/patient-view.py | 0 .../sign-note-inpatient.py | 0 .../sign-note-outpatient.py | 0 src/{models/nlp.py => service/client.py} | 0 src/service/{service-builder.py => server.py} | 0 src/service/servers/rest.py | 0 src/service/{servers => }/soap.py | 0 .../specifications}/__init__.py | 0 src/simulator/ehr.py | 2 +- .../workflows/notereader/__init__.py | 0 .../cds_hooks => use_cases}/__init__.py | 0 src/{simulator => }/use_cases/cds.py | 4 +- src/{simulator => }/use_cases/clindoc.py | 0 24 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 example_use.py rename src/{simulator => }/base.py (100%) create mode 100644 src/decorators.py rename src/{models => }/integrations/__init__.py (100%) rename src/{models => }/integrations/langchain.py (100%) rename src/{models => }/integrations/medcat.py (100%) create mode 100644 src/methods.py rename src/{simulator/use_cases => models}/__init__.py (100%) delete mode 100644 src/models/llm.py rename src/{simulator/workflows/cds_hooks => models}/order-select.py (100%) rename src/{simulator/workflows/cds_hooks => models}/order-sign.py (100%) rename src/{simulator/workflows/cds_hooks => models}/patient-view.py (100%) rename src/{simulator/workflows/notereader => models}/sign-note-inpatient.py (100%) rename src/{simulator/workflows/notereader => models}/sign-note-outpatient.py (100%) rename src/{models/nlp.py => service/client.py} (100%) rename src/service/{service-builder.py => server.py} (100%) delete mode 100644 src/service/servers/rest.py rename src/service/{servers => }/soap.py (100%) rename src/{simulator/workflows => service/specifications}/__init__.py (100%) delete mode 100644 src/simulator/workflows/notereader/__init__.py rename src/{simulator/workflows/cds_hooks => use_cases}/__init__.py (100%) rename src/{simulator => }/use_cases/cds.py (88%) rename src/{simulator => }/use_cases/clindoc.py (100%) diff --git a/example_use.py b/example_use.py new file mode 100644 index 0000000..86f7831 --- /dev/null +++ b/example_use.py @@ -0,0 +1,38 @@ +from src.simulator.ehr import EHR, UseCaseType, Workflow +from src.use_cases.cds import ClinicalDecisionSupport +from src.decorators import ehr + +def Run(): + # ehr = EHR() + # ehr = EHR.from_doppeldata(data) + # ehr = EHR.from_path(path) + + # ehr.UseCase = ClinicalDecisionSupport() + # print(ehr.UseCase) + + # ehr.add_database(data) + + # ehr.send_request("http://0.0.0.0:8000", Workflow("patient-view")) + # ehr.send_request("http://0.0.0.0:8000", Workflow("notereader-sign-inpatient")) + + class myCDS: + def __init__(self) -> None: + self.data_generator = None + + # decorator sets up an instance of ehr configured with use case CDS + @ehr(use_case=ClinicalDecisionSupport(), workflow='patient-view') + def trigger(self, data_spec): + data = "hello, " + data_spec + return data + + # @service(langserve=True) + # def LLM(self): + # chain = llm | output_parser + # return chain + + request = myCDS().trigger("lady whiskerson") + print(request) + + +if __name__ == "__main__": + Run() \ No newline at end of file diff --git a/src/simulator/base.py b/src/base.py similarity index 100% rename from src/simulator/base.py rename to src/base.py diff --git a/src/decorators.py b/src/decorators.py new file mode 100644 index 0000000..4d7c143 --- /dev/null +++ b/src/decorators.py @@ -0,0 +1,32 @@ +import logging + +from functools import wraps + +from .base import Workflow +from .methods import EHRClientMethod + +log = logging.getLogger(__name__) + + +def ehr(func=None, *, workflow=None, use_case=None): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + # TODO: map workflow strings to enum + try: + workflow_mapped = Workflow(workflow) + except ValueError as e: + raise ValueError(f"{e}: please select from {[x.value for x in Workflow]}") + + if use_case.__class__.__name__ in ["ClinicalDocumentation", "ClinicalDecisionSupport"]: + method = EHRClientMethod(func, workflow=workflow_mapped, use_case=use_case) + else: + raise NotImplementedError + return method(*args, **kwargs) + return wrapper + + if func is None: + return decorator + else: + return decorator(func) + diff --git a/src/models/integrations/__init__.py b/src/integrations/__init__.py similarity index 100% rename from src/models/integrations/__init__.py rename to src/integrations/__init__.py diff --git a/src/models/integrations/langchain.py b/src/integrations/langchain.py similarity index 100% rename from src/models/integrations/langchain.py rename to src/integrations/langchain.py diff --git a/src/models/integrations/medcat.py b/src/integrations/medcat.py similarity index 100% rename from src/models/integrations/medcat.py rename to src/integrations/medcat.py diff --git a/src/methods.py b/src/methods.py new file mode 100644 index 0000000..73109f4 --- /dev/null +++ b/src/methods.py @@ -0,0 +1,20 @@ +import logging + + +class EHRClientMethod: + def __init__(self, func, workflow=None, use_case=None): + self.func = func + self.workflow = workflow + self.use_case = use_case + + def __call__(self, *args, **kwargs): + # Call the function to process data + data = self.func(*args, **kwargs) + + # Use the strategy (use case) to construct the request with the output from func + if self.use_case: + request_data = self.use_case.construct_request(data, self.workflow) + return request_data + else: + return data + diff --git a/src/simulator/use_cases/__init__.py b/src/models/__init__.py similarity index 100% rename from src/simulator/use_cases/__init__.py rename to src/models/__init__.py diff --git a/src/models/llm.py b/src/models/llm.py deleted file mode 100644 index c4e2cdb..0000000 --- a/src/models/llm.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -A wrapper for an LLM model -""" - -class LLM: - def __init__(self) -> None: - self.model \ No newline at end of file diff --git a/src/simulator/workflows/cds_hooks/order-select.py b/src/models/order-select.py similarity index 100% rename from src/simulator/workflows/cds_hooks/order-select.py rename to src/models/order-select.py diff --git a/src/simulator/workflows/cds_hooks/order-sign.py b/src/models/order-sign.py similarity index 100% rename from src/simulator/workflows/cds_hooks/order-sign.py rename to src/models/order-sign.py diff --git a/src/simulator/workflows/cds_hooks/patient-view.py b/src/models/patient-view.py similarity index 100% rename from src/simulator/workflows/cds_hooks/patient-view.py rename to src/models/patient-view.py diff --git a/src/simulator/workflows/notereader/sign-note-inpatient.py b/src/models/sign-note-inpatient.py similarity index 100% rename from src/simulator/workflows/notereader/sign-note-inpatient.py rename to src/models/sign-note-inpatient.py diff --git a/src/simulator/workflows/notereader/sign-note-outpatient.py b/src/models/sign-note-outpatient.py similarity index 100% rename from src/simulator/workflows/notereader/sign-note-outpatient.py rename to src/models/sign-note-outpatient.py diff --git a/src/models/nlp.py b/src/service/client.py similarity index 100% rename from src/models/nlp.py rename to src/service/client.py diff --git a/src/service/service-builder.py b/src/service/server.py similarity index 100% rename from src/service/service-builder.py rename to src/service/server.py diff --git a/src/service/servers/rest.py b/src/service/servers/rest.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/service/servers/soap.py b/src/service/soap.py similarity index 100% rename from src/service/servers/soap.py rename to src/service/soap.py diff --git a/src/simulator/workflows/__init__.py b/src/service/specifications/__init__.py similarity index 100% rename from src/simulator/workflows/__init__.py rename to src/service/specifications/__init__.py diff --git a/src/simulator/ehr.py b/src/simulator/ehr.py index db56703..b771c86 100644 --- a/src/simulator/ehr.py +++ b/src/simulator/ehr.py @@ -5,7 +5,7 @@ from typing import Dict from enum import Enum -from .base import BaseClient, BaseUseCase, UseCaseType, Workflow +from ..base import BaseClient, BaseUseCase, UseCaseType, Workflow log = logging.getLogger(__name__) diff --git a/src/simulator/workflows/notereader/__init__.py b/src/simulator/workflows/notereader/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/simulator/workflows/cds_hooks/__init__.py b/src/use_cases/__init__.py similarity index 100% rename from src/simulator/workflows/cds_hooks/__init__.py rename to src/use_cases/__init__.py diff --git a/src/simulator/use_cases/cds.py b/src/use_cases/cds.py similarity index 88% rename from src/simulator/use_cases/cds.py rename to src/use_cases/cds.py index 8f697cc..d4a19f3 100644 --- a/src/simulator/use_cases/cds.py +++ b/src/use_cases/cds.py @@ -18,8 +18,8 @@ def _validate_data(self, data, workflow: Workflow) -> bool: def construct_request(self, data, workflow: Workflow) -> Dict: if self._validate_data(data, workflow): # do something to construct a cds rest API post request depending on the workflow - print("Construction CDS request") - request = {} + print(f"Constructing CDS request for {workflow.value}...") + request = {"request": data} else: raise ValueError(f"Error validating data for workflow {Workflow}") diff --git a/src/simulator/use_cases/clindoc.py b/src/use_cases/clindoc.py similarity index 100% rename from src/simulator/use_cases/clindoc.py rename to src/use_cases/clindoc.py From ef1b80613aaf32f4e512137818c9a6d683c2a365 Mon Sep 17 00:00:00 2001 From: Adam Kells Date: Mon, 22 Apr 2024 16:02:19 +0100 Subject: [PATCH 07/25] adding poetry lock file --- poetry.lock | 428 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 poetry.lock diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..49ff557 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,428 @@ +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +description = "Copy your docs directly to the gh-pages branch." +optional = false +python-versions = "*" +files = [ + {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, + {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, +] + +[package.dependencies] +python-dateutil = ">=2.8.1" + +[package.extras] +dev = ["flake8", "markdown", "twine", "wheel"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "jinja2" +version = "3.1.3" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, + {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markdown" +version = "3.6" +description = "Python implementation of John Gruber's Markdown." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Markdown-3.6-py3-none-any.whl", hash = "sha256:48f276f4d8cfb8ce6527c8f79e2ee29708508bf4d40aa410fbc3b4ee832c850f"}, + {file = "Markdown-3.6.tar.gz", hash = "sha256:ed4f41f6daecbeeb96e576ce414c41d2d876daa9a16cb35fa8ed8c2ddfad0224"}, +] + +[package.extras] +docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] +testing = ["coverage", "pyyaml"] + +[[package]] +name = "markupsafe" +version = "2.1.5" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +description = "A deep merge function for 🐍." +optional = false +python-versions = ">=3.6" +files = [ + {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, + {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, +] + +[[package]] +name = "mkdocs" +version = "1.6.0" +description = "Project documentation with Markdown." +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs-1.6.0-py3-none-any.whl", hash = "sha256:1eb5cb7676b7d89323e62b56235010216319217d4af5ddc543a91beb8d125ea7"}, + {file = "mkdocs-1.6.0.tar.gz", hash = "sha256:a73f735824ef83a4f3bcb7a231dcab23f5a838f88b7efc54a0eef5fbdbc3c512"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} +ghp-import = ">=1.0" +jinja2 = ">=2.11.1" +markdown = ">=3.3.6" +markupsafe = ">=2.0.1" +mergedeep = ">=1.3.4" +mkdocs-get-deps = ">=0.2.0" +packaging = ">=20.5" +pathspec = ">=0.11.1" +pyyaml = ">=5.1" +pyyaml-env-tag = ">=0.1" +watchdog = ">=2.0" + +[package.extras] +i18n = ["babel (>=2.9.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.4)", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"}, + {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"}, +] + +[package.dependencies] +mergedeep = ">=1.3.4" +platformdirs = ">=2.2.0" +pyyaml = ">=5.1" + +[[package]] +name = "packaging" +version = "24.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.2.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, + {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "8.1.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, + {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.4,<2.0" + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "pyyaml-env-tag" +version = "0.1" +description = "A custom YAML tag for referencing environment variables in YAML files. " +optional = false +python-versions = ">=3.6" +files = [ + {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, + {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, +] + +[package.dependencies] +pyyaml = "*" + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "watchdog" +version = "4.0.0" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.8" +files = [ + {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:39cb34b1f1afbf23e9562501673e7146777efe95da24fab5707b88f7fb11649b"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c522392acc5e962bcac3b22b9592493ffd06d1fc5d755954e6be9f4990de932b"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6c47bdd680009b11c9ac382163e05ca43baf4127954c5f6d0250e7d772d2b80c"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8350d4055505412a426b6ad8c521bc7d367d1637a762c70fdd93a3a0d595990b"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c17d98799f32e3f55f181f19dd2021d762eb38fdd381b4a748b9f5a36738e935"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4986db5e8880b0e6b7cd52ba36255d4793bf5cdc95bd6264806c233173b1ec0b"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:11e12fafb13372e18ca1bbf12d50f593e7280646687463dd47730fd4f4d5d257"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5369136a6474678e02426bd984466343924d1df8e2fd94a9b443cb7e3aa20d19"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76ad8484379695f3fe46228962017a7e1337e9acadafed67eb20aabb175df98b"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:45cc09cc4c3b43fb10b59ef4d07318d9a3ecdbff03abd2e36e77b6dd9f9a5c85"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eed82cdf79cd7f0232e2fdc1ad05b06a5e102a43e331f7d041e5f0e0a34a51c4"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba30a896166f0fee83183cec913298151b73164160d965af2e93a20bbd2ab605"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d18d7f18a47de6863cd480734613502904611730f8def45fc52a5d97503e5101"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2895bf0518361a9728773083908801a376743bcc37dfa252b801af8fd281b1ca"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87e9df830022488e235dd601478c15ad73a0389628588ba0b028cb74eb72fed8"}, + {file = "watchdog-4.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6e949a8a94186bced05b6508faa61b7adacc911115664ccb1923b9ad1f1ccf7b"}, + {file = "watchdog-4.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6a4db54edea37d1058b08947c789a2354ee02972ed5d1e0dca9b0b820f4c7f92"}, + {file = "watchdog-4.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d31481ccf4694a8416b681544c23bd271f5a123162ab603c7d7d2dd7dd901a07"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8fec441f5adcf81dd240a5fe78e3d83767999771630b5ddfc5867827a34fa3d3"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:6a9c71a0b02985b4b0b6d14b875a6c86ddea2fdbebd0c9a720a806a8bbffc69f"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:557ba04c816d23ce98a06e70af6abaa0485f6d94994ec78a42b05d1c03dcbd50"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:d0f9bd1fd919134d459d8abf954f63886745f4660ef66480b9d753a7c9d40927"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:f9b2fdca47dc855516b2d66eef3c39f2672cbf7e7a42e7e67ad2cbfcd6ba107d"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:73c7a935e62033bd5e8f0da33a4dcb763da2361921a69a5a95aaf6c93aa03a87"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6a80d5cae8c265842c7419c560b9961561556c4361b297b4c431903f8c33b269"}, + {file = "watchdog-4.0.0-py3-none-win32.whl", hash = "sha256:8f9a542c979df62098ae9c58b19e03ad3df1c9d8c6895d96c0d51da17b243b1c"}, + {file = "watchdog-4.0.0-py3-none-win_amd64.whl", hash = "sha256:f970663fa4f7e80401a7b0cbeec00fa801bf0287d93d48368fc3e6fa32716245"}, + {file = "watchdog-4.0.0-py3-none-win_ia64.whl", hash = "sha256:9a03e16e55465177d416699331b0f3564138f1807ecc5f2de9d55d8f188d08c7"}, + {file = "watchdog-4.0.0.tar.gz", hash = "sha256:e3e7065cbdabe6183ab82199d7a4f6b3ba0a438c5a512a68559846ccb76a78ec"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "35d94ccf245203340acb12038e5dec577e25ffbc71da5e36c9d7026225130cc5" From 5e05641b8f08f24c80bad235c1a6fa48cb6f2ca0 Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Sun, 28 Apr 2024 14:47:08 +0100 Subject: [PATCH 08/25] Added pydantic models for cds requests and responses --- .../{order-sign.py => hooks/__init__.py} | 0 src/models/{ => hooks}/order-select.py | 3 +- .../order-sign.py} | 0 src/models/{ => hooks}/patient-view.py | 4 +- .../sign-note-inpatient.py} | 0 src/models/hooks/sign-note-outpatient.py | 0 src/models/payload.py | 0 src/models/requests/__init__.py | 0 src/models/requests/cdsrequest.py | 28 +++ src/models/responses/__init__.py | 0 src/models/responses/cdsdiscovery.py | 23 +++ src/models/responses/cdsfeedback.py | 3 + src/models/responses/cdsservice.py | 192 ++++++++++++++++++ 13 files changed, 250 insertions(+), 3 deletions(-) rename src/models/{order-sign.py => hooks/__init__.py} (100%) rename src/models/{ => hooks}/order-select.py (86%) rename src/models/{sign-note-inpatient.py => hooks/order-sign.py} (100%) rename src/models/{ => hooks}/patient-view.py (75%) rename src/models/{sign-note-outpatient.py => hooks/sign-note-inpatient.py} (100%) create mode 100644 src/models/hooks/sign-note-outpatient.py create mode 100644 src/models/payload.py create mode 100644 src/models/requests/__init__.py create mode 100644 src/models/requests/cdsrequest.py create mode 100644 src/models/responses/__init__.py create mode 100644 src/models/responses/cdsdiscovery.py create mode 100644 src/models/responses/cdsfeedback.py create mode 100644 src/models/responses/cdsservice.py diff --git a/src/models/order-sign.py b/src/models/hooks/__init__.py similarity index 100% rename from src/models/order-sign.py rename to src/models/hooks/__init__.py diff --git a/src/models/order-select.py b/src/models/hooks/order-select.py similarity index 86% rename from src/models/order-select.py rename to src/models/hooks/order-select.py index 046e2f9..59fdc9d 100644 --- a/src/models/order-select.py +++ b/src/models/hooks/order-select.py @@ -1,4 +1,5 @@ from pydantic import BaseModel + class OrderSelect(BaseModel): - pass \ No newline at end of file + pass diff --git a/src/models/sign-note-inpatient.py b/src/models/hooks/order-sign.py similarity index 100% rename from src/models/sign-note-inpatient.py rename to src/models/hooks/order-sign.py diff --git a/src/models/patient-view.py b/src/models/hooks/patient-view.py similarity index 75% rename from src/models/patient-view.py rename to src/models/hooks/patient-view.py index d47ec41..58bfa7d 100644 --- a/src/models/patient-view.py +++ b/src/models/hooks/patient-view.py @@ -1,5 +1,5 @@ """ -The user has just opened a patient's record; +The user has just opened a patient's record; typically called only once at the beginning of a user's interaction with a specific patient's record. https://cds-hooks.org/hooks/patient-view/ -""" \ No newline at end of file +""" diff --git a/src/models/sign-note-outpatient.py b/src/models/hooks/sign-note-inpatient.py similarity index 100% rename from src/models/sign-note-outpatient.py rename to src/models/hooks/sign-note-inpatient.py diff --git a/src/models/hooks/sign-note-outpatient.py b/src/models/hooks/sign-note-outpatient.py new file mode 100644 index 0000000..e69de29 diff --git a/src/models/payload.py b/src/models/payload.py new file mode 100644 index 0000000..e69de29 diff --git a/src/models/requests/__init__.py b/src/models/requests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/models/requests/cdsrequest.py b/src/models/requests/cdsrequest.py new file mode 100644 index 0000000..27c8955 --- /dev/null +++ b/src/models/requests/cdsrequest.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel, HttpUrl +from typing import Optional, List, Dict, Any + + +class FHIRAuthorization(BaseModel): + access_token: str # OAuth2 access token + token_type: str = "Bearer" + expires_in: int + scope: str + subject: str + + +class CDSRequest(BaseModel): + """ + https://cds-hooks.org/specification/current/#http-request_1 + """ + + hook: str + hookInstance: str + context: Dict[str, Any] + fhirServer: Optional[HttpUrl] = None + fhirAuthorization: Optional[FHIRAuthorization] = ( + None # note this is required if fhirserver is given + ) + prefetch: Optional[Dict[str, Any]] = ( + None # fhir resource is passed either thru prefetched template of fhir server + ) + extension: Optional[List[Dict[str, Any]]] = None diff --git a/src/models/responses/__init__.py b/src/models/responses/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/models/responses/cdsdiscovery.py b/src/models/responses/cdsdiscovery.py new file mode 100644 index 0000000..ff19c3a --- /dev/null +++ b/src/models/responses/cdsdiscovery.py @@ -0,0 +1,23 @@ +""" +https://cds-hooks.org/specification/current/#discovery +""" + +from pydantic import BaseModel +from typing import Optional, List, Dict, Any + + +class CDSService(BaseModel): + """ + https://cds-hooks.org/specification/current/#response + """ + + hook: str + description: str + id: str + title: Optional[str] + prefetch: Optional[Dict[str, Any]] = None + usageRequirements: Optional[str] = None + + +class CDSServiceDiscoveryResponse(BaseModel): + services: List[CDSService] diff --git a/src/models/responses/cdsfeedback.py b/src/models/responses/cdsfeedback.py new file mode 100644 index 0000000..9eff1ee --- /dev/null +++ b/src/models/responses/cdsfeedback.py @@ -0,0 +1,3 @@ +""" +https://cds-hooks.org/specification/current/#feedback +""" diff --git a/src/models/responses/cdsservice.py b/src/models/responses/cdsservice.py new file mode 100644 index 0000000..c347cd9 --- /dev/null +++ b/src/models/responses/cdsservice.py @@ -0,0 +1,192 @@ +from enum import Enum + +from pydantic import BaseModel, Field, HttpUrl, field_validator, model_validator +from typing import Optional, List, Dict, Any +from typing_extensions import Self + + +class IndicatorEnum(str, Enum): + """ + Urgency/importance of what Card conveys. + Allowed values, in order of increasing urgency, are: info, warning, critical. + The CDS Client MAY use this field to help make UI display decisions such as sort order or coloring. + """ + + info = "info" + warning = "warning" + critical = "critical" + + +class SelectionBehaviorEnum(str, Enum): + """ + Describes the intended selection behavior of the suggestions in the card. + Allowed values are: at-most-one, indicating that the user may choose none or + at most one of the suggestions; any, indicating that the end user may choose + any number of suggestions including none of them and all of them. + CDS Clients that do not understand the value MUST treat the card as an error. + """ + + at_most_one = "at-most-one" + any = "any" + + +class ActionTypeEnum(str, Enum): + """ + The type of action being performed + """ + + create = "create" + update = "update" + delete = "delete" + + +class LinkTypeEnum(str, Enum): + """ + The type of the given URL. There are two possible values for this field. + A type of absolute indicates that the URL is absolute and should be treated as-is. + A type of smart indicates that the URL is a SMART app launch URL and the CDS Client + should ensure the SMART app launch URL is populated with the appropriate SMART + launch parameters. + """ + + absolute = "absolute" + smart = "smart" + + +class Link(BaseModel): + """ + * CDS Client support for appContext requires additional coordination with the authorization + server that is not described or specified in CDS Hooks nor SMART. + + * Autolaunchable is experimental + + https://cds-hooks.org/specification/current/#link + """ + + label: str + url: HttpUrl + type: LinkTypeEnum + appContext: Optional[str] = None + autoLaunchable: Optional[bool] + + @model_validator(mode="after") + def validate_link(self) -> Self: + if self.appContext: + assert ( + self.type == LinkTypeEnum.smart + ), f"'type' must be 'smart' for appContext to be valued." + + return self + + +class Coding(BaseModel): + """ + The Coding data type captures the concept of a code. This coding type is a standalone data type + in CDS Hooks modeled after a trimmed down version of the FHIR Coding data type. + """ + + code: str + system: str + display: Optional[str] = None + + +class Action(BaseModel): + """ + Within a suggestion, all actions are logically AND'd together, such that a user selecting a + suggestion selects all of the actions within it. When a suggestion contains multiple actions, + the actions SHOULD be processed as per FHIR's rules for processing transactions with the CDS + Client's fhirServer as the base url for the inferred full URL of the transaction bundle entries. + + https://cds-hooks.org/specification/current/#action + """ + + type: ActionTypeEnum + description: str + resource: Optional[Dict] = None + resourceId: Optional[str] = None + + @model_validator(mode="after") + def validate_action_type(self) -> Self: + if self.type in [ActionTypeEnum.create, ActionTypeEnum.update]: + assert ( + self.resource + ), f"'resource' must be provided when type is '{self.type.value}'" + else: + assert ( + self.resourceId + ), f"'resourceId' must be provided when type is '{self.type.value}'" + + return self + + +class Suggestion(BaseModel): + """ + Allows a service to suggest a set of changes in the context of the current activity + (e.g. changing the dose of a medication currently being prescribed, for the order-sign activity). + If suggestions are present, selectionBehavior MUST also be provided. + + https://cds-hooks.org/specification/current/#suggestion + """ + + label: str + uuid: Optional[str] = None + isRecommended: Optional[bool] + actions: Optional[List[Action]] = [] + + +class Source(BaseModel): + """ + Grouping structure for the Source of the information displayed on this card. + The source should be the primary source of guidance for the decision support Card represents. + + https://cds-hooks.org/specification/current/#source + """ + + label: str + url: Optional[HttpUrl] = None + icon: Optional[HttpUrl] = None + topic: Optional[Coding] = None + + +class Card(BaseModel): + """ + Cards can provide a combination of information (for reading), suggested actions + (to be applied if a user selects them), and links (to launch an app if the user selects them). + The CDS Client decides how to display cards, but this specification recommends displaying suggestions + using buttons, and links using underlined text. + + https://cds-hooks.org/specification/current/#card-attributes + """ + + summary: str = Field(..., max_length=140) + indicator: IndicatorEnum + source: Source + uuid: Optional[str] = None + detail: Optional[str] = None + suggestions: Optional[List[Suggestion]] = None + selectionBehavior: Optional[SelectionBehaviorEnum] = None + overrideReasons: Optional[List[Coding]] = None + links: Optional[List[Link]] = None + + @model_validator(mode="after") + def validate_suggestions(self) -> Self: + if self.suggestions is not None: + assert self.selectionBehavior, f"'selectionBehavior' must be given if 'suggestions' is present! Choose from {[v for v in SelectionBehaviorEnum.value]}" + return self + + @field_validator("detail") + @classmethod + def validate_markdown(cls, v: str) -> str: + if v is not None: + if not (v.startswith("#") or v.startswith("*")): + raise ValueError("Detail must be in GitHub Flavored Markdown format.") + return v + + +class CDSResponse(BaseModel): + """ + Http response + """ + + cards: List[Card] = [] + systemActions: Optional[Action] = None From 33750504497fc79932a325f0a6266140687f815f Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Sun, 28 Apr 2024 14:48:18 +0100 Subject: [PATCH 09/25] Run linter --- example_use.py | 11 ++++++----- src/base.py | 20 ++++++++++++-------- src/decorators.py | 20 +++++++++++++------- src/methods.py | 3 +-- src/service/server.py | 2 +- src/simulator/ehr.py | 19 +++++++++---------- src/use_cases/cds.py | 7 ++++--- src/use_cases/clindoc.py | 7 ++++--- 8 files changed, 50 insertions(+), 39 deletions(-) diff --git a/example_use.py b/example_use.py index 86f7831..7e5548b 100644 --- a/example_use.py +++ b/example_use.py @@ -2,6 +2,7 @@ from src.use_cases.cds import ClinicalDecisionSupport from src.decorators import ehr + def Run(): # ehr = EHR() # ehr = EHR.from_doppeldata(data) @@ -18,13 +19,13 @@ def Run(): class myCDS: def __init__(self) -> None: self.data_generator = None - - # decorator sets up an instance of ehr configured with use case CDS - @ehr(use_case=ClinicalDecisionSupport(), workflow='patient-view') + + # decorator sets up an instance of ehr configured with use case CDS + @ehr(use_case=ClinicalDecisionSupport(), workflow="patient-view") def trigger(self, data_spec): data = "hello, " + data_spec return data - + # @service(langserve=True) # def LLM(self): # chain = llm | output_parser @@ -35,4 +36,4 @@ def trigger(self, data_spec): if __name__ == "__main__": - Run() \ No newline at end of file + Run() diff --git a/src/base.py b/src/base.py index 3828efb..6e07eca 100644 --- a/src/base.py +++ b/src/base.py @@ -15,12 +15,13 @@ class Workflow(Enum): class UseCaseType(Enum): ClinicalDecisionSupport = ( - "patient-view", "order-select", "order-sign", "encounter-discharge" - ) - ClinicalDocumentation = ( - "notereader-sign-inpatient", "notereader-sign-outpatient" - ) - + "patient-view", + "order-select", + "order-sign", + "encounter-discharge", + ) + ClinicalDocumentation = ("notereader-sign-inpatient", "notereader-sign-outpatient") + def __init__(self, *workflows): self.allowed_workflows = workflows @@ -35,7 +36,9 @@ def wrapper(*args, **kwargs): if not is_valid_workflow(use_case, args[2]): raise ValueError(f"Invalid workflow {args[2]} for UseCase {use_case}") return func(*args, **kwargs) + return wrapper + return decorator @@ -44,7 +47,7 @@ class BaseClient(ABC): A client can be an EHR or CPOE etc. The basic operation is that it sends data in a specified standard. """ - + @abstractmethod def send_request(self) -> None: """ @@ -59,10 +62,11 @@ class BaseUseCase(ABC): - the data it accepts (FHIR or CDA) - the format of the request it constructs (CDS Hook or NoteReader workflows) """ + @abstractmethod def _validate_data(self, data) -> bool: pass @abstractmethod def construct_request(self, data, workflow: Workflow) -> Dict: - pass \ No newline at end of file + pass diff --git a/src/decorators.py b/src/decorators.py index 4d7c143..5dbfbf3 100644 --- a/src/decorators.py +++ b/src/decorators.py @@ -12,21 +12,27 @@ def ehr(func=None, *, workflow=None, use_case=None): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): - # TODO: map workflow strings to enum try: - workflow_mapped = Workflow(workflow) + workflow_enum = Workflow(workflow) except ValueError as e: - raise ValueError(f"{e}: please select from {[x.value for x in Workflow]}") - - if use_case.__class__.__name__ in ["ClinicalDocumentation", "ClinicalDecisionSupport"]: - method = EHRClientMethod(func, workflow=workflow_mapped, use_case=use_case) + raise ValueError( + f"{e}: please select from {[x.value for x in Workflow]}" + ) + + if use_case.__class__.__name__ in [ + "ClinicalDocumentation", + "ClinicalDecisionSupport", + ]: + method = EHRClientMethod( + func, workflow=workflow_enum, use_case=use_case + ) else: raise NotImplementedError return method(*args, **kwargs) + return wrapper if func is None: return decorator else: return decorator(func) - diff --git a/src/methods.py b/src/methods.py index 73109f4..c774a20 100644 --- a/src/methods.py +++ b/src/methods.py @@ -14,7 +14,6 @@ def __call__(self, *args, **kwargs): # Use the strategy (use case) to construct the request with the output from func if self.use_case: request_data = self.use_case.construct_request(data, self.workflow) - return request_data + return request_data else: return data - diff --git a/src/service/server.py b/src/service/server.py index 810310b..83656f3 100644 --- a/src/service/server.py +++ b/src/service/server.py @@ -1,3 +1,3 @@ """ The model needs to be wrapped in a REST API server spcified by CDS / Epic etc. -""" \ No newline at end of file +""" diff --git a/src/simulator/ehr.py b/src/simulator/ehr.py index b771c86..5075e79 100644 --- a/src/simulator/ehr.py +++ b/src/simulator/ehr.py @@ -22,15 +22,16 @@ class EHR(BaseClient): - Clinical Decision Support (HL7 CDS Hooks) - Clinical Documentation (Epic NoteReader) """ + def __init__(self, use_case: BaseUseCase = None) -> None: self._use_case = use_case - self.data = None # DoppelData object; + self.data = None # DoppelData object; self.fhir_server_endpoint = None # this is just for reference, simulating return of data from fhir server is all the same - + @property def UseCase(self) -> UseCaseType: return self._use_case.description() - + @UseCase.setter def UseCase(self, use_case: UseCase) -> None: self._use_case = use_case @@ -42,7 +43,7 @@ def DataSchema(self) -> Dict: return self.data._schema else: return None - + @classmethod def from_doppeldata(cls, data, use_case: UseCase) -> None: """ @@ -57,7 +58,7 @@ def from_path(cls, path: Path, use_case: UseCase) -> None: """ data = path return cls(data, use_case) - + def add_database(self, data) -> None: """ This will take in a DoppelData object @@ -73,17 +74,15 @@ def send_request(self, url: str, workflow: Workflow) -> None: response = {} request = self._use_case.construct_request(self.data, workflow) - - try: + + try: response = requests.post(url=url, data=request) except Exception as e: log.error(f"Error sending request: {e}") - return response - + async def send_request_bulk(self, num: int, url: str, workflow: Workflow) -> None: """ Sends bulk API requests to an AI service """ - diff --git a/src/use_cases/cds.py b/src/use_cases/cds.py index d4a19f3..689721c 100644 --- a/src/use_cases/cds.py +++ b/src/use_cases/cds.py @@ -7,13 +7,14 @@ class ClinicalDecisionSupport(BaseUseCase): """ Simulates the behaviour of EHR backend for Clinical Decision Support (CDS) """ + def description(self) -> str: return "Clinical decision support (HL7 CDS specification)" - + def _validate_data(self, data, workflow: Workflow) -> bool: # do something to valida fhir data and the worklow it's for return True - + @validate_workflow(UseCaseType.ClinicalDecisionSupport) def construct_request(self, data, workflow: Workflow) -> Dict: if self._validate_data(data, workflow): @@ -23,4 +24,4 @@ def construct_request(self, data, workflow: Workflow) -> Dict: else: raise ValueError(f"Error validating data for workflow {Workflow}") - return request \ No newline at end of file + return request diff --git a/src/use_cases/clindoc.py b/src/use_cases/clindoc.py index a747abe..6f6d14d 100644 --- a/src/use_cases/clindoc.py +++ b/src/use_cases/clindoc.py @@ -7,13 +7,14 @@ class ClinicalDocumentation(BaseUseCase): """ Simulates the behaviour of EHR backend for clinical documentation (NoteReader) """ + def description(self) -> str: return "Clinical documentation (NoteReader)" - + def _validate_data(self, data, workflow: Workflow) -> bool: # do something to validate cda data and the workflow it's for return True - + @validate_workflow(UseCaseType.ClinicalDocumentation) def construct_request(self, data, workflow: Workflow) -> Dict: if self._validate_data(data, workflow): @@ -23,4 +24,4 @@ def construct_request(self, data, workflow: Workflow) -> Dict: else: raise ValueError(f"Error validating data for workflow {Workflow}") - return request \ No newline at end of file + return request From 8bddd574ca33f647bef065f0e502dbd6f2b23a50 Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Mon, 29 Apr 2024 10:28:03 +0100 Subject: [PATCH 10/25] Added cds feedback models --- src/models/responses/cdsfeedback.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/models/responses/cdsfeedback.py b/src/models/responses/cdsfeedback.py index 9eff1ee..2ff21ac 100644 --- a/src/models/responses/cdsfeedback.py +++ b/src/models/responses/cdsfeedback.py @@ -1,3 +1,32 @@ """ +This is not compulsary + https://cds-hooks.org/specification/current/#feedback """ + +from pydantic import BaseModel +from typing import Optional, Dict, Any +from enum import Enum + +from .cdsservice import Coding + + +class OutcomeEnum(str, Enum): + accepted = "accepted" + overridden = "overridden" + + +class OverrideReason(BaseModel): + reason: Coding + userComment: Optional[str] = None + + +class CDSFeedback(BaseModel): + """ + https://cds-hooks.org/specification/current/#feedback + """ + card: str + outcome: OutcomeEnum + outcomeTimestamp: str + acceptedSuggestion: Optional[Dict[str, Any]] = None + overriddeReason: Optional[OverrideReason] = None \ No newline at end of file From e73b5cae25bbc1dc711c037a977742bfc99dd8bd Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Mon, 29 Apr 2024 14:13:01 +0100 Subject: [PATCH 11/25] Added pydantic models for cds hook context --- src/models/hooks/basehookcontext.py | 7 +++ src/models/hooks/encounter-discharge.py | 32 ++++++++++++++ src/models/hooks/order-select.py | 59 +++++++++++++++++++++++-- src/models/hooks/order-sign.py | 44 ++++++++++++++++++ src/models/hooks/patient-view.py | 37 +++++++++++++--- 5 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 src/models/hooks/basehookcontext.py create mode 100644 src/models/hooks/encounter-discharge.py diff --git a/src/models/hooks/basehookcontext.py b/src/models/hooks/basehookcontext.py new file mode 100644 index 0000000..72995a4 --- /dev/null +++ b/src/models/hooks/basehookcontext.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel +from abc import ABC + + +class BaseHookContext(BaseModel, ABC): + userId: str + patientId: str diff --git a/src/models/hooks/encounter-discharge.py b/src/models/hooks/encounter-discharge.py new file mode 100644 index 0000000..acda56f --- /dev/null +++ b/src/models/hooks/encounter-discharge.py @@ -0,0 +1,32 @@ +from pydantic import Field + +from .basehookcontext import BaseHookContext + + +class EncounterDischargeContext(BaseHookContext): + """ + Workflow: + This hook is triggered during the discharge process for typically inpatient encounters. It can be invoked + at any point from the start to the end of the discharge process. The purpose is to allow hook services to + intervene in various aspects of the discharge decision. This includes verifying discharge medications, + ensuring continuity of care planning, and verifying necessary documentation for discharge processing. + + Attributes: + userId (str): REQUIRED. The ID of the current user, expected to be a Practitioner or PractitionerRole. + For example, 'Practitioner/123'. + patientId (str): REQUIRED. The FHIR Patient.id of the patient being discharged. + encounterId (str): REQUIRED. The FHIR Encounter.id of the encounter being ended. + + Documentation: https://cds-hooks.org/hooks/encounter-discharge/ + """ + + userId: str = Field( + ..., + description="The ID of the current user, expected to be in the format 'Practitioner/123'.", + ) + patientId: str = Field( + ..., description="The FHIR Patient.id of the patient being discharged." + ) + encounterId: str = Field( + ..., description="The FHIR Encounter.id of the encounter being ended." + ) diff --git a/src/models/hooks/order-select.py b/src/models/hooks/order-select.py index 59fdc9d..5257907 100644 --- a/src/models/hooks/order-select.py +++ b/src/models/hooks/order-select.py @@ -1,5 +1,58 @@ -from pydantic import BaseModel +from pydantic import Field, model_validator +from typing import List, Dict, Optional, Any, Self +from .basehookcontext import BaseHookContext -class OrderSelect(BaseModel): - pass + +class OrderSelectContext(BaseHookContext): + """ + Workflow: The order-select hook occurs after the clinician selects the order and before signing. + This hook occurs when a clinician initially selects one or more new orders from a list of + potential orders for a specific patient (including orders for medications, procedures, labs + and other orders). The newly selected order defines that medication, procedure, lab, etc, + but may or may not define the additional details necessary to finalize the order. + + Attributes: + userId (str): REQUIRED. An identifier of the current user, in the format [ResourceType]/[id], + where ResourceType is either 'Practitioner' or 'PractitionerRole'. Examples: 'PractitionerRole/123', + 'Practitioner/abc'. + patientId (str): REQUIRED. The FHIR Patient.id representing the current patient in context. + encounterId (Optional[str]): OPTIONAL. The FHIR Encounter.id representing the current encounter in context, + if applicable. + selections ([str]): REQUIRED. A list of the FHIR id(s) of the newly selected orders, referencing resources + in the draftOrders Bundle. Example: 'MedicationRequest/103'. + draftOrders (object): REQUIRED. A Bundle of FHIR request resources with a draft status, representing all unsigned + orders from the current session, including newly selected orders. + + Documentation: https://cds-hooks.org/hooks/order-select/ + """ + + # TODO: validate selection and FHIR Bundle resource + + userId: str = Field( + pattern=r"^(Practitioner|PractitionerRole)/[^\s]+$", + description="An identifier of the current user in the format [ResourceType]/[id].", + ) + patientId: str = Field( + ..., + description="The FHIR Patient.id representing the current patient in context.", + ) + encounterId: Optional[str] = Field( + None, + description="The FHIR Encounter.id of the current encounter, if applicable.", + ) + selections: List[str] = Field( + ..., description="A list of the FHIR ids of the newly selected orders." + ) + draftOrders: Dict[str, Any] = Field( + ..., description="A Bundle of FHIR request resources with a draft status." + ) + + @model_validator(mode="after") + def validate_selections(self) -> Self: + for selection in self.selections: + if "/" not in selection: + raise ValueError( + "Each selection must be a valid FHIR resource identifier in the format 'ResourceType/ResourceID'." + ) + return self diff --git a/src/models/hooks/order-sign.py b/src/models/hooks/order-sign.py index e69de29..aae6f78 100644 --- a/src/models/hooks/order-sign.py +++ b/src/models/hooks/order-sign.py @@ -0,0 +1,44 @@ +from pydantic import Field +from typing import Optional, Dict, Any + +from .basehookcontext import BaseHookContext + + +class OrderSignContext(BaseHookContext): + """ + Workflow: + The order-sign hook is triggered when a clinician is ready to sign one or more orders for a patient. + This includes orders for medications, procedures, labs, and other orders. It is one of the last workflow + events before an order is promoted from a draft status. The context includes all order details such as + dose, quantity, route, etc., even though the order is still in a draft status. This hook is also applicable + for re-signing revised orders, which may have a status other than 'draft'. The hook replaces the + medication-prescribe and order-review hooks. + + Attributes: + userId (str): REQUIRED. The ID of the current user, expected to be of type 'Practitioner' or 'PractitionerRole'. + Examples include 'PractitionerRole/123' or 'Practitioner/abc'. + patientId (str): REQUIRED. The FHIR Patient.id representing the current patient in context. + encounterId (Optional[str]): OPTIONAL. The FHIR Encounter.id of the current encounter in context. + draftOrders (dict): REQUIRED. A Bundle of FHIR request resources with a draft status, representing orders that + aren't yet signed from the current ordering session. + + Documentation: https://cds-hooks.org/hooks/order-sign/ + """ + + # TODO: validate draftOrders + + userId: str = Field( + pattern=r"^(Practitioner|PractitionerRole)/[^\s]+$", + description="The ID of the current user in the format [ResourceType]/[id].", + ) + patientId: str = Field( + ..., + description="The FHIR Patient.id representing the current patient in context.", + ) + encounterId: Optional[str] = Field( + None, + description="The FHIR Encounter.id of the current encounter, if applicable.", + ) + draftOrders: Dict[str, Any] = Field( + ..., description="A Bundle of FHIR request resources with a draft status." + ) diff --git a/src/models/hooks/patient-view.py b/src/models/hooks/patient-view.py index 58bfa7d..5456769 100644 --- a/src/models/hooks/patient-view.py +++ b/src/models/hooks/patient-view.py @@ -1,5 +1,32 @@ -""" -The user has just opened a patient's record; -typically called only once at the beginning of a user's interaction with a specific patient's record. -https://cds-hooks.org/hooks/patient-view/ -""" +from pydantic import Field +from typing import Optional + +from .basehookcontext import BaseHookContext + + +class PatientViewContext(BaseHookContext): + """ + Workflow: The user has just opened a patient's record; typically called only once at the beginning of a user's + interaction with a specific patient's record. + + Attributes: + userId (str): An identifier of the current user, in the format [ResourceType]/[id], + where ResourceType is one of 'Practitioner', 'PractitionerRole', 'Patient', + or 'RelatedPerson'. Examples: 'Practitioner/abc', 'Patient/123'. + patientId (str): The FHIR Patient.id representing the current patient in context. + encounterId (Optional[str]): The FHIR Encounter.id representing the current encounter in context, + if applicable. This field is optional. + + Documentation: https://cds-hooks.org/hooks/patient-view/ + """ + + # TODO: more comprehensive validator? for now regex should suffice + + userId: str = Field( + pattern=r"^(Practitioner|PractitionerRole|Patient|RelatedPerson)/[^\s]+$", + description="The ID of the current user, expected to be in the format 'Practitioner/123'.", + ) + patientId: str = Field(..., description="The FHIR Patient.id of the patient.") + encounterId: Optional[str] = Field( + None, description="The FHIR Encounter.id of the encounter, if applicable." + ) From f480f6f473225793b77be7a3286249e1e4f205ce Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Mon, 29 Apr 2024 14:14:41 +0100 Subject: [PATCH 12/25] Add BaseHookContext to cdsrequest --- src/models/requests/cdsrequest.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/models/requests/cdsrequest.py b/src/models/requests/cdsrequest.py index 27c8955..dd80ef2 100644 --- a/src/models/requests/cdsrequest.py +++ b/src/models/requests/cdsrequest.py @@ -1,6 +1,10 @@ from pydantic import BaseModel, HttpUrl from typing import Optional, List, Dict, Any +from ..hooks.basehookcontext import BaseHookContext + +# TODO: add docstrings + class FHIRAuthorization(BaseModel): access_token: str # OAuth2 access token @@ -17,7 +21,7 @@ class CDSRequest(BaseModel): hook: str hookInstance: str - context: Dict[str, Any] + context: BaseHookContext fhirServer: Optional[HttpUrl] = None fhirAuthorization: Optional[FHIRAuthorization] = ( None # note this is required if fhirserver is given From 5bce1612f3196957cfcf800b935fbb72cc943232 Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Mon, 29 Apr 2024 14:15:12 +0100 Subject: [PATCH 13/25] :rotating_light: --- src/models/payload.py | 0 src/models/responses/cdsfeedback.py | 3 ++- src/models/responses/cdsservice.py | 6 ++++-- 3 files changed, 6 insertions(+), 3 deletions(-) delete mode 100644 src/models/payload.py diff --git a/src/models/payload.py b/src/models/payload.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/models/responses/cdsfeedback.py b/src/models/responses/cdsfeedback.py index 2ff21ac..ed0227d 100644 --- a/src/models/responses/cdsfeedback.py +++ b/src/models/responses/cdsfeedback.py @@ -25,8 +25,9 @@ class CDSFeedback(BaseModel): """ https://cds-hooks.org/specification/current/#feedback """ + card: str outcome: OutcomeEnum outcomeTimestamp: str acceptedSuggestion: Optional[Dict[str, Any]] = None - overriddeReason: Optional[OverrideReason] = None \ No newline at end of file + overriddeReason: Optional[OverrideReason] = None diff --git a/src/models/responses/cdsservice.py b/src/models/responses/cdsservice.py index c347cd9..003ee39 100644 --- a/src/models/responses/cdsservice.py +++ b/src/models/responses/cdsservice.py @@ -1,9 +1,11 @@ from enum import Enum from pydantic import BaseModel, Field, HttpUrl, field_validator, model_validator -from typing import Optional, List, Dict, Any +from typing import Optional, List, Dict from typing_extensions import Self +# TODO: add docstrings + class IndicatorEnum(str, Enum): """ @@ -74,7 +76,7 @@ def validate_link(self) -> Self: if self.appContext: assert ( self.type == LinkTypeEnum.smart - ), f"'type' must be 'smart' for appContext to be valued." + ), "'type' must be 'smart' for appContext to be valued." return self From e89a691fe557c9f4f9d6e3f105e4b8a9af8bbc6e Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Tue, 30 Apr 2024 01:08:35 +0100 Subject: [PATCH 14/25] Rename hook models --- .../hooks/{encounter-discharge.py => encounterdischarge.py} | 0 src/models/hooks/{order-select.py => orderselect.py} | 3 ++- src/models/hooks/{order-sign.py => ordersign.py} | 0 src/models/hooks/{patient-view.py => patientview.py} | 0 4 files changed, 2 insertions(+), 1 deletion(-) rename src/models/hooks/{encounter-discharge.py => encounterdischarge.py} (100%) rename src/models/hooks/{order-select.py => orderselect.py} (97%) rename src/models/hooks/{order-sign.py => ordersign.py} (100%) rename src/models/hooks/{patient-view.py => patientview.py} (100%) diff --git a/src/models/hooks/encounter-discharge.py b/src/models/hooks/encounterdischarge.py similarity index 100% rename from src/models/hooks/encounter-discharge.py rename to src/models/hooks/encounterdischarge.py diff --git a/src/models/hooks/order-select.py b/src/models/hooks/orderselect.py similarity index 97% rename from src/models/hooks/order-select.py rename to src/models/hooks/orderselect.py index 5257907..06e8a58 100644 --- a/src/models/hooks/order-select.py +++ b/src/models/hooks/orderselect.py @@ -1,5 +1,6 @@ from pydantic import Field, model_validator -from typing import List, Dict, Optional, Any, Self +from typing import List, Dict, Optional, Any +from typing_extensions import Self from .basehookcontext import BaseHookContext diff --git a/src/models/hooks/order-sign.py b/src/models/hooks/ordersign.py similarity index 100% rename from src/models/hooks/order-sign.py rename to src/models/hooks/ordersign.py diff --git a/src/models/hooks/patient-view.py b/src/models/hooks/patientview.py similarity index 100% rename from src/models/hooks/patient-view.py rename to src/models/hooks/patientview.py From 05656c47fb8451eb0b58f360eca57a72df4ce17b Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Tue, 30 Apr 2024 01:13:55 +0100 Subject: [PATCH 15/25] draft ehr generate and send request methods --- src/decorators.py | 13 ++++++++++--- src/methods.py | 31 +++++++++++++++++++++---------- src/service/client.py | 6 ++++++ src/use_cases/cds.py | 29 ++++++++++++++++++++++++++--- 4 files changed, 63 insertions(+), 16 deletions(-) diff --git a/src/decorators.py b/src/decorators.py index 5dbfbf3..7f77249 100644 --- a/src/decorators.py +++ b/src/decorators.py @@ -8,10 +8,15 @@ log = logging.getLogger(__name__) -def ehr(func=None, *, workflow=None, use_case=None): +# TODO: add validator and error handling +def ehr(func=None, *, workflow=None, num=1): def decorator(func): @wraps(func) - def wrapper(*args, **kwargs): + def wrapper(self, *args, **kwargs): + use_case = getattr(self, "use_case", None) + if use_case is None: + raise ValueError("Use case not configured!") + try: workflow_enum = Workflow(workflow) except ValueError as e: @@ -26,9 +31,11 @@ def wrapper(*args, **kwargs): method = EHRClientMethod( func, workflow=workflow_enum, use_case=use_case ) + for _ in range(num): + method.generate_request(self, *args, **kwargs) else: raise NotImplementedError - return method(*args, **kwargs) + return method # (self, *args, **kwargs) return wrapper diff --git a/src/methods.py b/src/methods.py index c774a20..b055db9 100644 --- a/src/methods.py +++ b/src/methods.py @@ -1,19 +1,30 @@ import logging +import requests + +log = logging.getLogger(__name__) class EHRClientMethod: def __init__(self, func, workflow=None, use_case=None): - self.func = func + self.data_generator_func = func self.workflow = workflow self.use_case = use_case + self.request_data = [] + + def generate_request(self, *args, **kwargs) -> None: + data = self.data_generator_func(*args, **kwargs) + self.request_data.append(self.use_case.construct_request(data, self.workflow)) - def __call__(self, *args, **kwargs): - # Call the function to process data - data = self.func(*args, **kwargs) + def send_request(self, url: str) -> None: + json_responses = [] + for request in self.request_data: + try: + response = requests.post( + url=url, data=request.model_dump_json(exclude_none=True) + ) + json_responses.append(response.json()) + except Exception as e: + log.error(f"Error sending request: {e}") + json_responses.append({}) - # Use the strategy (use case) to construct the request with the output from func - if self.use_case: - request_data = self.use_case.construct_request(data, self.workflow) - return request_data - else: - return data + return json_responses diff --git a/src/service/client.py b/src/service/client.py index e69de29..37e534b 100644 --- a/src/service/client.py +++ b/src/service/client.py @@ -0,0 +1,6 @@ +from ..base import BaseClient + + +class EHRClient(BaseClient): + def __init__(self) -> None: + pass diff --git a/src/use_cases/cds.py b/src/use_cases/cds.py index 689721c..a01bfc3 100644 --- a/src/use_cases/cds.py +++ b/src/use_cases/cds.py @@ -1,6 +1,15 @@ +import logging + from typing import Dict from ..base import BaseUseCase, UseCaseType, Workflow, validate_workflow +from ..models.requests.cdsrequest import CDSRequest +from ..models.hooks.orderselect import OrderSelectContext +from ..models.hooks.ordersign import OrderSignContext +from ..models.hooks.patientview import PatientViewContext +from ..models.hooks.encounterdischarge import EncounterDischargeContext + +log = logging.getLogger(__name__) class ClinicalDecisionSupport(BaseUseCase): @@ -8,6 +17,15 @@ class ClinicalDecisionSupport(BaseUseCase): Simulates the behaviour of EHR backend for Clinical Decision Support (CDS) """ + def __init__(self) -> None: + super().__init__() + self.context_mapping = { + Workflow.order_select: OrderSelectContext, + Workflow.order_sign: OrderSignContext, + Workflow.patient_view: PatientViewContext, + Workflow.encounter_discharge: EncounterDischargeContext, + } + def description(self) -> str: return "Clinical decision support (HL7 CDS specification)" @@ -17,10 +35,15 @@ def _validate_data(self, data, workflow: Workflow) -> bool: @validate_workflow(UseCaseType.ClinicalDecisionSupport) def construct_request(self, data, workflow: Workflow) -> Dict: + # TODO: sub data for actual DoppelData format if self._validate_data(data, workflow): - # do something to construct a cds rest API post request depending on the workflow - print(f"Constructing CDS request for {workflow.value}...") - request = {"request": data} + log.debug(f"Constructing CDS request for {workflow.value} from {data}") + + context_model = self.context_mapping.get(workflow, None) + context = context_model(**data.context) + request = CDSRequest( + hook=workflow.value, hookInstance=data.uuid, context=context + ) else: raise ValueError(f"Error validating data for workflow {Workflow}") From 9c60b194bc3cc7a2cc4b1a8ea2f108124bb271e9 Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Wed, 1 May 2024 17:40:22 +0100 Subject: [PATCH 16/25] Rename src -> healthchain, update poetry --- README.md | 4 +- healthchain/base.py | 72 +++ healthchain/decorators.py | 45 ++ healthchain/integrations/__init__.py | 0 healthchain/integrations/langchain.py | 0 healthchain/integrations/medcat.py | 0 healthchain/methods.py | 30 ++ healthchain/models/__init__.py | 0 healthchain/models/hooks/__init__.py | 0 healthchain/models/hooks/basehookcontext.py | 7 + .../models/hooks/encounterdischarge.py | 32 ++ healthchain/models/hooks/orderselect.py | 59 +++ healthchain/models/hooks/ordersign.py | 44 ++ healthchain/models/hooks/patientview.py | 32 ++ .../models/hooks/sign-note-inpatient.py | 0 .../models/hooks/sign-note-outpatient.py | 0 healthchain/models/requests/__init__.py | 0 healthchain/models/requests/cdsrequest.py | 32 ++ healthchain/models/responses/__init__.py | 0 healthchain/models/responses/cdsdiscovery.py | 23 + healthchain/models/responses/cdsfeedback.py | 33 ++ healthchain/models/responses/cdsservice.py | 194 ++++++++ healthchain/service/__init__.py | 0 healthchain/service/client.py | 6 + healthchain/service/server.py | 3 + healthchain/service/soap.py | 0 .../service/specifications/__init__.py | 0 .../specifications/epic-notereader-service.py | 0 .../service/specifications/hl7-cds-service.py | 0 healthchain/simulator/__init__.py | 0 healthchain/simulator/ehr.py | 88 ++++ healthchain/use_cases/__init__.py | 0 healthchain/use_cases/cds.py | 50 ++ healthchain/use_cases/clindoc.py | 27 ++ poetry.lock | 450 +++++++++++++++++- pyproject.toml | 12 +- 36 files changed, 1231 insertions(+), 12 deletions(-) create mode 100644 healthchain/base.py create mode 100644 healthchain/decorators.py create mode 100644 healthchain/integrations/__init__.py create mode 100644 healthchain/integrations/langchain.py create mode 100644 healthchain/integrations/medcat.py create mode 100644 healthchain/methods.py create mode 100644 healthchain/models/__init__.py create mode 100644 healthchain/models/hooks/__init__.py create mode 100644 healthchain/models/hooks/basehookcontext.py create mode 100644 healthchain/models/hooks/encounterdischarge.py create mode 100644 healthchain/models/hooks/orderselect.py create mode 100644 healthchain/models/hooks/ordersign.py create mode 100644 healthchain/models/hooks/patientview.py create mode 100644 healthchain/models/hooks/sign-note-inpatient.py create mode 100644 healthchain/models/hooks/sign-note-outpatient.py create mode 100644 healthchain/models/requests/__init__.py create mode 100644 healthchain/models/requests/cdsrequest.py create mode 100644 healthchain/models/responses/__init__.py create mode 100644 healthchain/models/responses/cdsdiscovery.py create mode 100644 healthchain/models/responses/cdsfeedback.py create mode 100644 healthchain/models/responses/cdsservice.py create mode 100644 healthchain/service/__init__.py create mode 100644 healthchain/service/client.py create mode 100644 healthchain/service/server.py create mode 100644 healthchain/service/soap.py create mode 100644 healthchain/service/specifications/__init__.py create mode 100644 healthchain/service/specifications/epic-notereader-service.py create mode 100644 healthchain/service/specifications/hl7-cds-service.py create mode 100644 healthchain/simulator/__init__.py create mode 100644 healthchain/simulator/ehr.py create mode 100644 healthchain/use_cases/__init__.py create mode 100644 healthchain/use_cases/cds.py create mode 100644 healthchain/use_cases/clindoc.py diff --git a/README.md b/README.md index b1ee71d..6a888b8 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ ![GitHub License](https://img.shields.io/github/license/:user/:repo) -# DoppelData +# HealthChain -A package to help Healthtech developers generate synthetic data in the Fhir data standard. +Simplify prototyping and testing LLM applications in healthcare context. ## Quickstart diff --git a/healthchain/base.py b/healthchain/base.py new file mode 100644 index 0000000..6e07eca --- /dev/null +++ b/healthchain/base.py @@ -0,0 +1,72 @@ +from abc import ABC, abstractmethod +from enum import Enum +from typing import Dict + + +# a workflow is a specific event that may occur in an EHR that triggers a request to server +class Workflow(Enum): + patient_view = "patient-view" + order_select = "order-select" + order_sign = "order-sign" + encounter_discharge = "encounter-discharge" + notereader_sign_inpatient = "notereader-sign-inpatient" + notereader_sign_outpatient = "notereader-sign-outpatient" + + +class UseCaseType(Enum): + ClinicalDecisionSupport = ( + "patient-view", + "order-select", + "order-sign", + "encounter-discharge", + ) + ClinicalDocumentation = ("notereader-sign-inpatient", "notereader-sign-outpatient") + + def __init__(self, *workflows): + self.allowed_workflows = workflows + + +def is_valid_workflow(use_case: UseCaseType, workflow: Workflow) -> bool: + return workflow.value in use_case.allowed_workflows + + +def validate_workflow(use_case): + def decorator(func): + def wrapper(*args, **kwargs): + if not is_valid_workflow(use_case, args[2]): + raise ValueError(f"Invalid workflow {args[2]} for UseCase {use_case}") + return func(*args, **kwargs) + + return wrapper + + return decorator + + +class BaseClient(ABC): + """Base client class + A client can be an EHR or CPOE etc. + The basic operation is that it sends data in a specified standard. + """ + + @abstractmethod + def send_request(self) -> None: + """ + Sends a request to AI service + """ + + +class BaseUseCase(ABC): + """ + Abstract class for a specific use case of an EHR object + Use cases will differ by: + - the data it accepts (FHIR or CDA) + - the format of the request it constructs (CDS Hook or NoteReader workflows) + """ + + @abstractmethod + def _validate_data(self, data) -> bool: + pass + + @abstractmethod + def construct_request(self, data, workflow: Workflow) -> Dict: + pass diff --git a/healthchain/decorators.py b/healthchain/decorators.py new file mode 100644 index 0000000..7f77249 --- /dev/null +++ b/healthchain/decorators.py @@ -0,0 +1,45 @@ +import logging + +from functools import wraps + +from .base import Workflow +from .methods import EHRClientMethod + +log = logging.getLogger(__name__) + + +# TODO: add validator and error handling +def ehr(func=None, *, workflow=None, num=1): + def decorator(func): + @wraps(func) + def wrapper(self, *args, **kwargs): + use_case = getattr(self, "use_case", None) + if use_case is None: + raise ValueError("Use case not configured!") + + try: + workflow_enum = Workflow(workflow) + except ValueError as e: + raise ValueError( + f"{e}: please select from {[x.value for x in Workflow]}" + ) + + if use_case.__class__.__name__ in [ + "ClinicalDocumentation", + "ClinicalDecisionSupport", + ]: + method = EHRClientMethod( + func, workflow=workflow_enum, use_case=use_case + ) + for _ in range(num): + method.generate_request(self, *args, **kwargs) + else: + raise NotImplementedError + return method # (self, *args, **kwargs) + + return wrapper + + if func is None: + return decorator + else: + return decorator(func) diff --git a/healthchain/integrations/__init__.py b/healthchain/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/healthchain/integrations/langchain.py b/healthchain/integrations/langchain.py new file mode 100644 index 0000000..e69de29 diff --git a/healthchain/integrations/medcat.py b/healthchain/integrations/medcat.py new file mode 100644 index 0000000..e69de29 diff --git a/healthchain/methods.py b/healthchain/methods.py new file mode 100644 index 0000000..b055db9 --- /dev/null +++ b/healthchain/methods.py @@ -0,0 +1,30 @@ +import logging +import requests + +log = logging.getLogger(__name__) + + +class EHRClientMethod: + def __init__(self, func, workflow=None, use_case=None): + self.data_generator_func = func + self.workflow = workflow + self.use_case = use_case + self.request_data = [] + + def generate_request(self, *args, **kwargs) -> None: + data = self.data_generator_func(*args, **kwargs) + self.request_data.append(self.use_case.construct_request(data, self.workflow)) + + def send_request(self, url: str) -> None: + json_responses = [] + for request in self.request_data: + try: + response = requests.post( + url=url, data=request.model_dump_json(exclude_none=True) + ) + json_responses.append(response.json()) + except Exception as e: + log.error(f"Error sending request: {e}") + json_responses.append({}) + + return json_responses diff --git a/healthchain/models/__init__.py b/healthchain/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/healthchain/models/hooks/__init__.py b/healthchain/models/hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/healthchain/models/hooks/basehookcontext.py b/healthchain/models/hooks/basehookcontext.py new file mode 100644 index 0000000..72995a4 --- /dev/null +++ b/healthchain/models/hooks/basehookcontext.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel +from abc import ABC + + +class BaseHookContext(BaseModel, ABC): + userId: str + patientId: str diff --git a/healthchain/models/hooks/encounterdischarge.py b/healthchain/models/hooks/encounterdischarge.py new file mode 100644 index 0000000..acda56f --- /dev/null +++ b/healthchain/models/hooks/encounterdischarge.py @@ -0,0 +1,32 @@ +from pydantic import Field + +from .basehookcontext import BaseHookContext + + +class EncounterDischargeContext(BaseHookContext): + """ + Workflow: + This hook is triggered during the discharge process for typically inpatient encounters. It can be invoked + at any point from the start to the end of the discharge process. The purpose is to allow hook services to + intervene in various aspects of the discharge decision. This includes verifying discharge medications, + ensuring continuity of care planning, and verifying necessary documentation for discharge processing. + + Attributes: + userId (str): REQUIRED. The ID of the current user, expected to be a Practitioner or PractitionerRole. + For example, 'Practitioner/123'. + patientId (str): REQUIRED. The FHIR Patient.id of the patient being discharged. + encounterId (str): REQUIRED. The FHIR Encounter.id of the encounter being ended. + + Documentation: https://cds-hooks.org/hooks/encounter-discharge/ + """ + + userId: str = Field( + ..., + description="The ID of the current user, expected to be in the format 'Practitioner/123'.", + ) + patientId: str = Field( + ..., description="The FHIR Patient.id of the patient being discharged." + ) + encounterId: str = Field( + ..., description="The FHIR Encounter.id of the encounter being ended." + ) diff --git a/healthchain/models/hooks/orderselect.py b/healthchain/models/hooks/orderselect.py new file mode 100644 index 0000000..06e8a58 --- /dev/null +++ b/healthchain/models/hooks/orderselect.py @@ -0,0 +1,59 @@ +from pydantic import Field, model_validator +from typing import List, Dict, Optional, Any +from typing_extensions import Self + +from .basehookcontext import BaseHookContext + + +class OrderSelectContext(BaseHookContext): + """ + Workflow: The order-select hook occurs after the clinician selects the order and before signing. + This hook occurs when a clinician initially selects one or more new orders from a list of + potential orders for a specific patient (including orders for medications, procedures, labs + and other orders). The newly selected order defines that medication, procedure, lab, etc, + but may or may not define the additional details necessary to finalize the order. + + Attributes: + userId (str): REQUIRED. An identifier of the current user, in the format [ResourceType]/[id], + where ResourceType is either 'Practitioner' or 'PractitionerRole'. Examples: 'PractitionerRole/123', + 'Practitioner/abc'. + patientId (str): REQUIRED. The FHIR Patient.id representing the current patient in context. + encounterId (Optional[str]): OPTIONAL. The FHIR Encounter.id representing the current encounter in context, + if applicable. + selections ([str]): REQUIRED. A list of the FHIR id(s) of the newly selected orders, referencing resources + in the draftOrders Bundle. Example: 'MedicationRequest/103'. + draftOrders (object): REQUIRED. A Bundle of FHIR request resources with a draft status, representing all unsigned + orders from the current session, including newly selected orders. + + Documentation: https://cds-hooks.org/hooks/order-select/ + """ + + # TODO: validate selection and FHIR Bundle resource + + userId: str = Field( + pattern=r"^(Practitioner|PractitionerRole)/[^\s]+$", + description="An identifier of the current user in the format [ResourceType]/[id].", + ) + patientId: str = Field( + ..., + description="The FHIR Patient.id representing the current patient in context.", + ) + encounterId: Optional[str] = Field( + None, + description="The FHIR Encounter.id of the current encounter, if applicable.", + ) + selections: List[str] = Field( + ..., description="A list of the FHIR ids of the newly selected orders." + ) + draftOrders: Dict[str, Any] = Field( + ..., description="A Bundle of FHIR request resources with a draft status." + ) + + @model_validator(mode="after") + def validate_selections(self) -> Self: + for selection in self.selections: + if "/" not in selection: + raise ValueError( + "Each selection must be a valid FHIR resource identifier in the format 'ResourceType/ResourceID'." + ) + return self diff --git a/healthchain/models/hooks/ordersign.py b/healthchain/models/hooks/ordersign.py new file mode 100644 index 0000000..aae6f78 --- /dev/null +++ b/healthchain/models/hooks/ordersign.py @@ -0,0 +1,44 @@ +from pydantic import Field +from typing import Optional, Dict, Any + +from .basehookcontext import BaseHookContext + + +class OrderSignContext(BaseHookContext): + """ + Workflow: + The order-sign hook is triggered when a clinician is ready to sign one or more orders for a patient. + This includes orders for medications, procedures, labs, and other orders. It is one of the last workflow + events before an order is promoted from a draft status. The context includes all order details such as + dose, quantity, route, etc., even though the order is still in a draft status. This hook is also applicable + for re-signing revised orders, which may have a status other than 'draft'. The hook replaces the + medication-prescribe and order-review hooks. + + Attributes: + userId (str): REQUIRED. The ID of the current user, expected to be of type 'Practitioner' or 'PractitionerRole'. + Examples include 'PractitionerRole/123' or 'Practitioner/abc'. + patientId (str): REQUIRED. The FHIR Patient.id representing the current patient in context. + encounterId (Optional[str]): OPTIONAL. The FHIR Encounter.id of the current encounter in context. + draftOrders (dict): REQUIRED. A Bundle of FHIR request resources with a draft status, representing orders that + aren't yet signed from the current ordering session. + + Documentation: https://cds-hooks.org/hooks/order-sign/ + """ + + # TODO: validate draftOrders + + userId: str = Field( + pattern=r"^(Practitioner|PractitionerRole)/[^\s]+$", + description="The ID of the current user in the format [ResourceType]/[id].", + ) + patientId: str = Field( + ..., + description="The FHIR Patient.id representing the current patient in context.", + ) + encounterId: Optional[str] = Field( + None, + description="The FHIR Encounter.id of the current encounter, if applicable.", + ) + draftOrders: Dict[str, Any] = Field( + ..., description="A Bundle of FHIR request resources with a draft status." + ) diff --git a/healthchain/models/hooks/patientview.py b/healthchain/models/hooks/patientview.py new file mode 100644 index 0000000..5456769 --- /dev/null +++ b/healthchain/models/hooks/patientview.py @@ -0,0 +1,32 @@ +from pydantic import Field +from typing import Optional + +from .basehookcontext import BaseHookContext + + +class PatientViewContext(BaseHookContext): + """ + Workflow: The user has just opened a patient's record; typically called only once at the beginning of a user's + interaction with a specific patient's record. + + Attributes: + userId (str): An identifier of the current user, in the format [ResourceType]/[id], + where ResourceType is one of 'Practitioner', 'PractitionerRole', 'Patient', + or 'RelatedPerson'. Examples: 'Practitioner/abc', 'Patient/123'. + patientId (str): The FHIR Patient.id representing the current patient in context. + encounterId (Optional[str]): The FHIR Encounter.id representing the current encounter in context, + if applicable. This field is optional. + + Documentation: https://cds-hooks.org/hooks/patient-view/ + """ + + # TODO: more comprehensive validator? for now regex should suffice + + userId: str = Field( + pattern=r"^(Practitioner|PractitionerRole|Patient|RelatedPerson)/[^\s]+$", + description="The ID of the current user, expected to be in the format 'Practitioner/123'.", + ) + patientId: str = Field(..., description="The FHIR Patient.id of the patient.") + encounterId: Optional[str] = Field( + None, description="The FHIR Encounter.id of the encounter, if applicable." + ) diff --git a/healthchain/models/hooks/sign-note-inpatient.py b/healthchain/models/hooks/sign-note-inpatient.py new file mode 100644 index 0000000..e69de29 diff --git a/healthchain/models/hooks/sign-note-outpatient.py b/healthchain/models/hooks/sign-note-outpatient.py new file mode 100644 index 0000000..e69de29 diff --git a/healthchain/models/requests/__init__.py b/healthchain/models/requests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/healthchain/models/requests/cdsrequest.py b/healthchain/models/requests/cdsrequest.py new file mode 100644 index 0000000..dd80ef2 --- /dev/null +++ b/healthchain/models/requests/cdsrequest.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel, HttpUrl +from typing import Optional, List, Dict, Any + +from ..hooks.basehookcontext import BaseHookContext + +# TODO: add docstrings + + +class FHIRAuthorization(BaseModel): + access_token: str # OAuth2 access token + token_type: str = "Bearer" + expires_in: int + scope: str + subject: str + + +class CDSRequest(BaseModel): + """ + https://cds-hooks.org/specification/current/#http-request_1 + """ + + hook: str + hookInstance: str + context: BaseHookContext + fhirServer: Optional[HttpUrl] = None + fhirAuthorization: Optional[FHIRAuthorization] = ( + None # note this is required if fhirserver is given + ) + prefetch: Optional[Dict[str, Any]] = ( + None # fhir resource is passed either thru prefetched template of fhir server + ) + extension: Optional[List[Dict[str, Any]]] = None diff --git a/healthchain/models/responses/__init__.py b/healthchain/models/responses/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/healthchain/models/responses/cdsdiscovery.py b/healthchain/models/responses/cdsdiscovery.py new file mode 100644 index 0000000..ff19c3a --- /dev/null +++ b/healthchain/models/responses/cdsdiscovery.py @@ -0,0 +1,23 @@ +""" +https://cds-hooks.org/specification/current/#discovery +""" + +from pydantic import BaseModel +from typing import Optional, List, Dict, Any + + +class CDSService(BaseModel): + """ + https://cds-hooks.org/specification/current/#response + """ + + hook: str + description: str + id: str + title: Optional[str] + prefetch: Optional[Dict[str, Any]] = None + usageRequirements: Optional[str] = None + + +class CDSServiceDiscoveryResponse(BaseModel): + services: List[CDSService] diff --git a/healthchain/models/responses/cdsfeedback.py b/healthchain/models/responses/cdsfeedback.py new file mode 100644 index 0000000..ed0227d --- /dev/null +++ b/healthchain/models/responses/cdsfeedback.py @@ -0,0 +1,33 @@ +""" +This is not compulsary + +https://cds-hooks.org/specification/current/#feedback +""" + +from pydantic import BaseModel +from typing import Optional, Dict, Any +from enum import Enum + +from .cdsservice import Coding + + +class OutcomeEnum(str, Enum): + accepted = "accepted" + overridden = "overridden" + + +class OverrideReason(BaseModel): + reason: Coding + userComment: Optional[str] = None + + +class CDSFeedback(BaseModel): + """ + https://cds-hooks.org/specification/current/#feedback + """ + + card: str + outcome: OutcomeEnum + outcomeTimestamp: str + acceptedSuggestion: Optional[Dict[str, Any]] = None + overriddeReason: Optional[OverrideReason] = None diff --git a/healthchain/models/responses/cdsservice.py b/healthchain/models/responses/cdsservice.py new file mode 100644 index 0000000..003ee39 --- /dev/null +++ b/healthchain/models/responses/cdsservice.py @@ -0,0 +1,194 @@ +from enum import Enum + +from pydantic import BaseModel, Field, HttpUrl, field_validator, model_validator +from typing import Optional, List, Dict +from typing_extensions import Self + +# TODO: add docstrings + + +class IndicatorEnum(str, Enum): + """ + Urgency/importance of what Card conveys. + Allowed values, in order of increasing urgency, are: info, warning, critical. + The CDS Client MAY use this field to help make UI display decisions such as sort order or coloring. + """ + + info = "info" + warning = "warning" + critical = "critical" + + +class SelectionBehaviorEnum(str, Enum): + """ + Describes the intended selection behavior of the suggestions in the card. + Allowed values are: at-most-one, indicating that the user may choose none or + at most one of the suggestions; any, indicating that the end user may choose + any number of suggestions including none of them and all of them. + CDS Clients that do not understand the value MUST treat the card as an error. + """ + + at_most_one = "at-most-one" + any = "any" + + +class ActionTypeEnum(str, Enum): + """ + The type of action being performed + """ + + create = "create" + update = "update" + delete = "delete" + + +class LinkTypeEnum(str, Enum): + """ + The type of the given URL. There are two possible values for this field. + A type of absolute indicates that the URL is absolute and should be treated as-is. + A type of smart indicates that the URL is a SMART app launch URL and the CDS Client + should ensure the SMART app launch URL is populated with the appropriate SMART + launch parameters. + """ + + absolute = "absolute" + smart = "smart" + + +class Link(BaseModel): + """ + * CDS Client support for appContext requires additional coordination with the authorization + server that is not described or specified in CDS Hooks nor SMART. + + * Autolaunchable is experimental + + https://cds-hooks.org/specification/current/#link + """ + + label: str + url: HttpUrl + type: LinkTypeEnum + appContext: Optional[str] = None + autoLaunchable: Optional[bool] + + @model_validator(mode="after") + def validate_link(self) -> Self: + if self.appContext: + assert ( + self.type == LinkTypeEnum.smart + ), "'type' must be 'smart' for appContext to be valued." + + return self + + +class Coding(BaseModel): + """ + The Coding data type captures the concept of a code. This coding type is a standalone data type + in CDS Hooks modeled after a trimmed down version of the FHIR Coding data type. + """ + + code: str + system: str + display: Optional[str] = None + + +class Action(BaseModel): + """ + Within a suggestion, all actions are logically AND'd together, such that a user selecting a + suggestion selects all of the actions within it. When a suggestion contains multiple actions, + the actions SHOULD be processed as per FHIR's rules for processing transactions with the CDS + Client's fhirServer as the base url for the inferred full URL of the transaction bundle entries. + + https://cds-hooks.org/specification/current/#action + """ + + type: ActionTypeEnum + description: str + resource: Optional[Dict] = None + resourceId: Optional[str] = None + + @model_validator(mode="after") + def validate_action_type(self) -> Self: + if self.type in [ActionTypeEnum.create, ActionTypeEnum.update]: + assert ( + self.resource + ), f"'resource' must be provided when type is '{self.type.value}'" + else: + assert ( + self.resourceId + ), f"'resourceId' must be provided when type is '{self.type.value}'" + + return self + + +class Suggestion(BaseModel): + """ + Allows a service to suggest a set of changes in the context of the current activity + (e.g. changing the dose of a medication currently being prescribed, for the order-sign activity). + If suggestions are present, selectionBehavior MUST also be provided. + + https://cds-hooks.org/specification/current/#suggestion + """ + + label: str + uuid: Optional[str] = None + isRecommended: Optional[bool] + actions: Optional[List[Action]] = [] + + +class Source(BaseModel): + """ + Grouping structure for the Source of the information displayed on this card. + The source should be the primary source of guidance for the decision support Card represents. + + https://cds-hooks.org/specification/current/#source + """ + + label: str + url: Optional[HttpUrl] = None + icon: Optional[HttpUrl] = None + topic: Optional[Coding] = None + + +class Card(BaseModel): + """ + Cards can provide a combination of information (for reading), suggested actions + (to be applied if a user selects them), and links (to launch an app if the user selects them). + The CDS Client decides how to display cards, but this specification recommends displaying suggestions + using buttons, and links using underlined text. + + https://cds-hooks.org/specification/current/#card-attributes + """ + + summary: str = Field(..., max_length=140) + indicator: IndicatorEnum + source: Source + uuid: Optional[str] = None + detail: Optional[str] = None + suggestions: Optional[List[Suggestion]] = None + selectionBehavior: Optional[SelectionBehaviorEnum] = None + overrideReasons: Optional[List[Coding]] = None + links: Optional[List[Link]] = None + + @model_validator(mode="after") + def validate_suggestions(self) -> Self: + if self.suggestions is not None: + assert self.selectionBehavior, f"'selectionBehavior' must be given if 'suggestions' is present! Choose from {[v for v in SelectionBehaviorEnum.value]}" + return self + + @field_validator("detail") + @classmethod + def validate_markdown(cls, v: str) -> str: + if v is not None: + if not (v.startswith("#") or v.startswith("*")): + raise ValueError("Detail must be in GitHub Flavored Markdown format.") + return v + + +class CDSResponse(BaseModel): + """ + Http response + """ + + cards: List[Card] = [] + systemActions: Optional[Action] = None diff --git a/healthchain/service/__init__.py b/healthchain/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/healthchain/service/client.py b/healthchain/service/client.py new file mode 100644 index 0000000..37e534b --- /dev/null +++ b/healthchain/service/client.py @@ -0,0 +1,6 @@ +from ..base import BaseClient + + +class EHRClient(BaseClient): + def __init__(self) -> None: + pass diff --git a/healthchain/service/server.py b/healthchain/service/server.py new file mode 100644 index 0000000..83656f3 --- /dev/null +++ b/healthchain/service/server.py @@ -0,0 +1,3 @@ +""" +The model needs to be wrapped in a REST API server spcified by CDS / Epic etc. +""" diff --git a/healthchain/service/soap.py b/healthchain/service/soap.py new file mode 100644 index 0000000..e69de29 diff --git a/healthchain/service/specifications/__init__.py b/healthchain/service/specifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/healthchain/service/specifications/epic-notereader-service.py b/healthchain/service/specifications/epic-notereader-service.py new file mode 100644 index 0000000..e69de29 diff --git a/healthchain/service/specifications/hl7-cds-service.py b/healthchain/service/specifications/hl7-cds-service.py new file mode 100644 index 0000000..e69de29 diff --git a/healthchain/simulator/__init__.py b/healthchain/simulator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/healthchain/simulator/ehr.py b/healthchain/simulator/ehr.py new file mode 100644 index 0000000..5075e79 --- /dev/null +++ b/healthchain/simulator/ehr.py @@ -0,0 +1,88 @@ +import logging +import requests + +from pathlib import Path +from typing import Dict +from enum import Enum + +from ..base import BaseClient, BaseUseCase, UseCaseType, Workflow + +log = logging.getLogger(__name__) + + +# wrap vendor specific logic in decorators? may be too complex, use config? +class Vendor(Enum): + Epic = "epic" + + +class EHR(BaseClient): + """ + EHR mocker which simulates the behaviour of an EHR sending API requests to a third-party server + Currently support: + - Clinical Decision Support (HL7 CDS Hooks) + - Clinical Documentation (Epic NoteReader) + """ + + def __init__(self, use_case: BaseUseCase = None) -> None: + self._use_case = use_case + self.data = None # DoppelData object; + self.fhir_server_endpoint = None # this is just for reference, simulating return of data from fhir server is all the same + + @property + def UseCase(self) -> UseCaseType: + return self._use_case.description() + + @UseCase.setter + def UseCase(self, use_case: UseCase) -> None: + self._use_case = use_case + + # to implement in DoppelData + @property + def DataSchema(self) -> Dict: + if self.data is not None: + return self.data._schema + else: + return None + + @classmethod + def from_doppeldata(cls, data, use_case: UseCase) -> None: + """ + Constructs EHR from DoppelData object + """ + return cls(data, use_case) + + @classmethod + def from_path(cls, path: Path, use_case: UseCase) -> None: + """ + Constructs EHR from a local folder containing requests + """ + data = path + return cls(data, use_case) + + def add_database(self, data) -> None: + """ + This will take in a DoppelData object + """ + self.data = data + + def send_request(self, url: str, workflow: Workflow) -> None: + """ + Sends the API request to an AI service + """ + if self._use_case is None: + raise RuntimeError("No EHR use case configured! Set using .UseCase") + + response = {} + request = self._use_case.construct_request(self.data, workflow) + + try: + response = requests.post(url=url, data=request) + except Exception as e: + log.error(f"Error sending request: {e}") + + return response + + async def send_request_bulk(self, num: int, url: str, workflow: Workflow) -> None: + """ + Sends bulk API requests to an AI service + """ diff --git a/healthchain/use_cases/__init__.py b/healthchain/use_cases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/healthchain/use_cases/cds.py b/healthchain/use_cases/cds.py new file mode 100644 index 0000000..a01bfc3 --- /dev/null +++ b/healthchain/use_cases/cds.py @@ -0,0 +1,50 @@ +import logging + +from typing import Dict + +from ..base import BaseUseCase, UseCaseType, Workflow, validate_workflow +from ..models.requests.cdsrequest import CDSRequest +from ..models.hooks.orderselect import OrderSelectContext +from ..models.hooks.ordersign import OrderSignContext +from ..models.hooks.patientview import PatientViewContext +from ..models.hooks.encounterdischarge import EncounterDischargeContext + +log = logging.getLogger(__name__) + + +class ClinicalDecisionSupport(BaseUseCase): + """ + Simulates the behaviour of EHR backend for Clinical Decision Support (CDS) + """ + + def __init__(self) -> None: + super().__init__() + self.context_mapping = { + Workflow.order_select: OrderSelectContext, + Workflow.order_sign: OrderSignContext, + Workflow.patient_view: PatientViewContext, + Workflow.encounter_discharge: EncounterDischargeContext, + } + + def description(self) -> str: + return "Clinical decision support (HL7 CDS specification)" + + def _validate_data(self, data, workflow: Workflow) -> bool: + # do something to valida fhir data and the worklow it's for + return True + + @validate_workflow(UseCaseType.ClinicalDecisionSupport) + def construct_request(self, data, workflow: Workflow) -> Dict: + # TODO: sub data for actual DoppelData format + if self._validate_data(data, workflow): + log.debug(f"Constructing CDS request for {workflow.value} from {data}") + + context_model = self.context_mapping.get(workflow, None) + context = context_model(**data.context) + request = CDSRequest( + hook=workflow.value, hookInstance=data.uuid, context=context + ) + else: + raise ValueError(f"Error validating data for workflow {Workflow}") + + return request diff --git a/healthchain/use_cases/clindoc.py b/healthchain/use_cases/clindoc.py new file mode 100644 index 0000000..6f6d14d --- /dev/null +++ b/healthchain/use_cases/clindoc.py @@ -0,0 +1,27 @@ +from typing import Dict + +from ..base import BaseUseCase, UseCaseType, Workflow, validate_workflow + + +class ClinicalDocumentation(BaseUseCase): + """ + Simulates the behaviour of EHR backend for clinical documentation (NoteReader) + """ + + def description(self) -> str: + return "Clinical documentation (NoteReader)" + + def _validate_data(self, data, workflow: Workflow) -> bool: + # do something to validate cda data and the workflow it's for + return True + + @validate_workflow(UseCaseType.ClinicalDocumentation) + def construct_request(self, data, workflow: Workflow) -> Dict: + if self._validate_data(data, workflow): + # do something to construct a notereader soap request + print("Constructing Clinical Documentation request...") + request = {} + else: + raise ValueError(f"Error validating data for workflow {Workflow}") + + return request diff --git a/poetry.lock b/poetry.lock index 49ff557..dcda8ee 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,137 @@ # This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +[[package]] +name = "annotated-types" +version = "0.6.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.6.0-py3-none-any.whl", hash = "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43"}, + {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, +] + +[[package]] +name = "certifi" +version = "2024.2.2" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, + {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + [[package]] name = "click" version = "8.1.7" @@ -25,6 +157,33 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "distlib" +version = "0.3.8" +description = "Distribution utilities" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, +] + +[[package]] +name = "filelock" +version = "3.14.0" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.14.0-py3-none-any.whl", hash = "sha256:43339835842f110ca7ae60f1e1c160714c5a6afd15a2873419ab185334975c0f"}, + {file = "filelock-3.14.0.tar.gz", hash = "sha256:6ea72da3be9b8c82afd3edcf99f2fffbb5076335a5ae4d03248bb5b6c3eae78a"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8)"] + [[package]] name = "ghp-import" version = "2.1.0" @@ -42,6 +201,31 @@ python-dateutil = ">=2.8.1" [package.extras] dev = ["flake8", "markdown", "twine", "wheel"] +[[package]] +name = "identify" +version = "2.5.36" +description = "File identification library for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "identify-2.5.36-py2.py3-none-any.whl", hash = "sha256:37d93f380f4de590500d9dba7db359d0d3da95ffe7f9de1753faa159e71e7dfa"}, + {file = "identify-2.5.36.tar.gz", hash = "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "idna" +version = "3.7" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, +] + [[package]] name = "iniconfig" version = "2.0.0" @@ -211,6 +395,20 @@ mergedeep = ">=1.3.4" platformdirs = ">=2.2.0" pyyaml = ">=5.1" +[[package]] +name = "nodeenv" +version = "1.8.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +files = [ + {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, + {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, +] + +[package.dependencies] +setuptools = "*" + [[package]] name = "packaging" version = "24.0" @@ -263,25 +461,153 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pre-commit" +version = "3.7.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pre_commit-3.7.0-py2.py3-none-any.whl", hash = "sha256:5eae9e10c2b5ac51577c3452ec0a490455c45a0533f7960f993a0d01e59decab"}, + {file = "pre_commit-3.7.0.tar.gz", hash = "sha256:e209d61b8acdcf742404408531f0c37d49d2c734fd7cff2d6076083d191cb060"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pydantic" +version = "2.7.1" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.7.1-py3-none-any.whl", hash = "sha256:e029badca45266732a9a79898a15ae2e8b14840b1eabbb25844be28f0b33f3d5"}, + {file = "pydantic-2.7.1.tar.gz", hash = "sha256:e9dbb5eada8abe4d9ae5f46b9939aead650cd2b68f249bb3a8139dbe125803cc"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.18.2" +typing-extensions = ">=4.6.1" + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.18.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.18.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:9e08e867b306f525802df7cd16c44ff5ebbe747ff0ca6cf3fde7f36c05a59a81"}, + {file = "pydantic_core-2.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f0a21cbaa69900cbe1a2e7cad2aa74ac3cf21b10c3efb0fa0b80305274c0e8a2"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0680b1f1f11fda801397de52c36ce38ef1c1dc841a0927a94f226dea29c3ae3d"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:95b9d5e72481d3780ba3442eac863eae92ae43a5f3adb5b4d0a1de89d42bb250"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fcf5cd9c4b655ad666ca332b9a081112cd7a58a8b5a6ca7a3104bc950f2038"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b5155ff768083cb1d62f3e143b49a8a3432e6789a3abee8acd005c3c7af1c74"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553ef617b6836fc7e4df130bb851e32fe357ce36336d897fd6646d6058d980af"}, + {file = "pydantic_core-2.18.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b89ed9eb7d616ef5714e5590e6cf7f23b02d0d539767d33561e3675d6f9e3857"}, + {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:75f7e9488238e920ab6204399ded280dc4c307d034f3924cd7f90a38b1829563"}, + {file = "pydantic_core-2.18.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ef26c9e94a8c04a1b2924149a9cb081836913818e55681722d7f29af88fe7b38"}, + {file = "pydantic_core-2.18.2-cp310-none-win32.whl", hash = "sha256:182245ff6b0039e82b6bb585ed55a64d7c81c560715d1bad0cbad6dfa07b4027"}, + {file = "pydantic_core-2.18.2-cp310-none-win_amd64.whl", hash = "sha256:e23ec367a948b6d812301afc1b13f8094ab7b2c280af66ef450efc357d2ae543"}, + {file = "pydantic_core-2.18.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:219da3f096d50a157f33645a1cf31c0ad1fe829a92181dd1311022f986e5fbe3"}, + {file = "pydantic_core-2.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc1cfd88a64e012b74e94cd00bbe0f9c6df57049c97f02bb07d39e9c852e19a4"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05b7133a6e6aeb8df37d6f413f7705a37ab4031597f64ab56384c94d98fa0e90"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:224c421235f6102e8737032483f43c1a8cfb1d2f45740c44166219599358c2cd"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b14d82cdb934e99dda6d9d60dc84a24379820176cc4a0d123f88df319ae9c150"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2728b01246a3bba6de144f9e3115b532ee44bd6cf39795194fb75491824a1413"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:470b94480bb5ee929f5acba6995251ada5e059a5ef3e0dfc63cca287283ebfa6"}, + {file = "pydantic_core-2.18.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:997abc4df705d1295a42f95b4eec4950a37ad8ae46d913caeee117b6b198811c"}, + {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:75250dbc5290e3f1a0f4618db35e51a165186f9034eff158f3d490b3fed9f8a0"}, + {file = "pydantic_core-2.18.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4456f2dca97c425231d7315737d45239b2b51a50dc2b6f0c2bb181fce6207664"}, + {file = "pydantic_core-2.18.2-cp311-none-win32.whl", hash = "sha256:269322dcc3d8bdb69f054681edff86276b2ff972447863cf34c8b860f5188e2e"}, + {file = "pydantic_core-2.18.2-cp311-none-win_amd64.whl", hash = "sha256:800d60565aec896f25bc3cfa56d2277d52d5182af08162f7954f938c06dc4ee3"}, + {file = "pydantic_core-2.18.2-cp311-none-win_arm64.whl", hash = "sha256:1404c69d6a676245199767ba4f633cce5f4ad4181f9d0ccb0577e1f66cf4c46d"}, + {file = "pydantic_core-2.18.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:fb2bd7be70c0fe4dfd32c951bc813d9fe6ebcbfdd15a07527796c8204bd36242"}, + {file = "pydantic_core-2.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6132dd3bd52838acddca05a72aafb6eab6536aa145e923bb50f45e78b7251043"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7d904828195733c183d20a54230c0df0eb46ec746ea1a666730787353e87182"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c9bd70772c720142be1020eac55f8143a34ec9f82d75a8e7a07852023e46617f"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b8ed04b3582771764538f7ee7001b02e1170223cf9b75dff0bc698fadb00cf3"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6dac87ddb34aaec85f873d737e9d06a3555a1cc1a8e0c44b7f8d5daeb89d86f"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ca4ae5a27ad7a4ee5170aebce1574b375de390bc01284f87b18d43a3984df72"}, + {file = "pydantic_core-2.18.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:886eec03591b7cf058467a70a87733b35f44707bd86cf64a615584fd72488b7c"}, + {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ca7b0c1f1c983e064caa85f3792dd2fe3526b3505378874afa84baf662e12241"}, + {file = "pydantic_core-2.18.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b4356d3538c3649337df4074e81b85f0616b79731fe22dd11b99499b2ebbdf3"}, + {file = "pydantic_core-2.18.2-cp312-none-win32.whl", hash = "sha256:8b172601454f2d7701121bbec3425dd71efcb787a027edf49724c9cefc14c038"}, + {file = "pydantic_core-2.18.2-cp312-none-win_amd64.whl", hash = "sha256:b1bd7e47b1558ea872bd16c8502c414f9e90dcf12f1395129d7bb42a09a95438"}, + {file = "pydantic_core-2.18.2-cp312-none-win_arm64.whl", hash = "sha256:98758d627ff397e752bc339272c14c98199c613f922d4a384ddc07526c86a2ec"}, + {file = "pydantic_core-2.18.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:9fdad8e35f278b2c3eb77cbdc5c0a49dada440657bf738d6905ce106dc1de439"}, + {file = "pydantic_core-2.18.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1d90c3265ae107f91a4f279f4d6f6f1d4907ac76c6868b27dc7fb33688cfb347"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390193c770399861d8df9670fb0d1874f330c79caaca4642332df7c682bf6b91"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:82d5d4d78e4448683cb467897fe24e2b74bb7b973a541ea1dcfec1d3cbce39fb"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4774f3184d2ef3e14e8693194f661dea5a4d6ca4e3dc8e39786d33a94865cefd"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4d938ec0adf5167cb335acb25a4ee69a8107e4984f8fbd2e897021d9e4ca21b"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0e8b1be28239fc64a88a8189d1df7fad8be8c1ae47fcc33e43d4be15f99cc70"}, + {file = "pydantic_core-2.18.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:868649da93e5a3d5eacc2b5b3b9235c98ccdbfd443832f31e075f54419e1b96b"}, + {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:78363590ef93d5d226ba21a90a03ea89a20738ee5b7da83d771d283fd8a56761"}, + {file = "pydantic_core-2.18.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:852e966fbd035a6468fc0a3496589b45e2208ec7ca95c26470a54daed82a0788"}, + {file = "pydantic_core-2.18.2-cp38-none-win32.whl", hash = "sha256:6a46e22a707e7ad4484ac9ee9f290f9d501df45954184e23fc29408dfad61350"}, + {file = "pydantic_core-2.18.2-cp38-none-win_amd64.whl", hash = "sha256:d91cb5ea8b11607cc757675051f61b3d93f15eca3cefb3e6c704a5d6e8440f4e"}, + {file = "pydantic_core-2.18.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ae0a8a797a5e56c053610fa7be147993fe50960fa43609ff2a9552b0e07013e8"}, + {file = "pydantic_core-2.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:042473b6280246b1dbf530559246f6842b56119c2926d1e52b631bdc46075f2a"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a388a77e629b9ec814c1b1e6b3b595fe521d2cdc625fcca26fbc2d44c816804"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25add29b8f3b233ae90ccef2d902d0ae0432eb0d45370fe315d1a5cf231004b"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f459a5ce8434614dfd39bbebf1041952ae01da6bed9855008cb33b875cb024c0"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eff2de745698eb46eeb51193a9f41d67d834d50e424aef27df2fcdee1b153845"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8309f67285bdfe65c372ea3722b7a5642680f3dba538566340a9d36e920b5f0"}, + {file = "pydantic_core-2.18.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f93a8a2e3938ff656a7c1bc57193b1319960ac015b6e87d76c76bf14fe0244b4"}, + {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:22057013c8c1e272eb8d0eebc796701167d8377441ec894a8fed1af64a0bf399"}, + {file = "pydantic_core-2.18.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cfeecd1ac6cc1fb2692c3d5110781c965aabd4ec5d32799773ca7b1456ac636b"}, + {file = "pydantic_core-2.18.2-cp39-none-win32.whl", hash = "sha256:0d69b4c2f6bb3e130dba60d34c0845ba31b69babdd3f78f7c0c8fae5021a253e"}, + {file = "pydantic_core-2.18.2-cp39-none-win_amd64.whl", hash = "sha256:d9319e499827271b09b4e411905b24a426b8fb69464dfa1696258f53a3334641"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a1874c6dd4113308bd0eb568418e6114b252afe44319ead2b4081e9b9521fe75"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:ccdd111c03bfd3666bd2472b674c6899550e09e9f298954cfc896ab92b5b0e6d"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e18609ceaa6eed63753037fc06ebb16041d17d28199ae5aba0052c51449650a9"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e5c584d357c4e2baf0ff7baf44f4994be121e16a2c88918a5817331fc7599d7"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43f0f463cf89ace478de71a318b1b4f05ebc456a9b9300d027b4b57c1a2064fb"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e1b395e58b10b73b07b7cf740d728dd4ff9365ac46c18751bf8b3d8cca8f625a"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0098300eebb1c837271d3d1a2cd2911e7c11b396eac9661655ee524a7f10587b"}, + {file = "pydantic_core-2.18.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:36789b70d613fbac0a25bb07ab3d9dba4d2e38af609c020cf4d888d165ee0bf3"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3f9a801e7c8f1ef8718da265bba008fa121243dfe37c1cea17840b0944dfd72c"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3a6515ebc6e69d85502b4951d89131ca4e036078ea35533bb76327f8424531ce"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20aca1e2298c56ececfd8ed159ae4dde2df0781988c97ef77d5c16ff4bd5b400"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:223ee893d77a310a0391dca6df00f70bbc2f36a71a895cecd9a0e762dc37b349"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2334ce8c673ee93a1d6a65bd90327588387ba073c17e61bf19b4fd97d688d63c"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:cbca948f2d14b09d20268cda7b0367723d79063f26c4ffc523af9042cad95592"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b3ef08e20ec49e02d5c6717a91bb5af9b20f1805583cb0adfe9ba2c6b505b5ae"}, + {file = "pydantic_core-2.18.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c6fdc8627910eed0c01aed6a390a252fe3ea6d472ee70fdde56273f198938374"}, + {file = "pydantic_core-2.18.2.tar.gz", hash = "sha256:2e29d20810dfc3043ee13ac7d9e25105799817683348823f305ab3f349b9386e"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + [[package]] name = "pytest" -version = "8.1.1" +version = "8.2.0" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"}, - {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"}, + {file = "pytest-8.2.0-py3-none-any.whl", hash = "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233"}, + {file = "pytest-8.2.0.tar.gz", hash = "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f"}, ] [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} iniconfig = "*" packaging = "*" -pluggy = ">=1.4,<2.0" +pluggy = ">=1.5,<2.0" [package.extras] -testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] [[package]] name = "python-dateutil" @@ -322,6 +648,7 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -370,6 +697,69 @@ files = [ [package.dependencies] pyyaml = "*" +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.7" +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "ruff" +version = "0.4.2" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.4.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:8d14dc8953f8af7e003a485ef560bbefa5f8cc1ad994eebb5b12136049bbccc5"}, + {file = "ruff-0.4.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:24016ed18db3dc9786af103ff49c03bdf408ea253f3cb9e3638f39ac9cf2d483"}, + {file = "ruff-0.4.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2e06459042ac841ed510196c350ba35a9b24a643e23db60d79b2db92af0c2b"}, + {file = "ruff-0.4.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3afabaf7ba8e9c485a14ad8f4122feff6b2b93cc53cd4dad2fd24ae35112d5c5"}, + {file = "ruff-0.4.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:799eb468ea6bc54b95527143a4ceaf970d5aa3613050c6cff54c85fda3fde480"}, + {file = "ruff-0.4.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ec4ba9436a51527fb6931a8839af4c36a5481f8c19e8f5e42c2f7ad3a49f5069"}, + {file = "ruff-0.4.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6a2243f8f434e487c2a010c7252150b1fdf019035130f41b77626f5655c9ca22"}, + {file = "ruff-0.4.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8772130a063f3eebdf7095da00c0b9898bd1774c43b336272c3e98667d4fb8fa"}, + {file = "ruff-0.4.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ab165ef5d72392b4ebb85a8b0fbd321f69832a632e07a74794c0e598e7a8376"}, + {file = "ruff-0.4.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1f32cadf44c2020e75e0c56c3408ed1d32c024766bd41aedef92aa3ca28eef68"}, + {file = "ruff-0.4.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:22e306bf15e09af45ca812bc42fa59b628646fa7c26072555f278994890bc7ac"}, + {file = "ruff-0.4.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:82986bb77ad83a1719c90b9528a9dd663c9206f7c0ab69282af8223566a0c34e"}, + {file = "ruff-0.4.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:652e4ba553e421a6dc2a6d4868bc3b3881311702633eb3672f9f244ded8908cd"}, + {file = "ruff-0.4.2-py3-none-win32.whl", hash = "sha256:7891ee376770ac094da3ad40c116258a381b86c7352552788377c6eb16d784fe"}, + {file = "ruff-0.4.2-py3-none-win_amd64.whl", hash = "sha256:5ec481661fb2fd88a5d6cf1f83403d388ec90f9daaa36e40e2c003de66751798"}, + {file = "ruff-0.4.2-py3-none-win_arm64.whl", hash = "sha256:cbd1e87c71bca14792948c4ccb51ee61c3296e164019d2d484f3eaa2d360dfaf"}, + {file = "ruff-0.4.2.tar.gz", hash = "sha256:33bcc160aee2520664bc0859cfeaebc84bb7323becff3f303b8f1f2d81cb4edc"}, +] + +[[package]] +name = "setuptools" +version = "69.5.1" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "setuptools-69.5.1-py3-none-any.whl", hash = "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"}, + {file = "setuptools-69.5.1.tar.gz", hash = "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + [[package]] name = "six" version = "1.16.0" @@ -381,6 +771,54 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "typing-extensions" +version = "4.11.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, + {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, +] + +[[package]] +name = "urllib3" +version = "2.2.1" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, + {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "virtualenv" +version = "20.26.1" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.26.1-py3-none-any.whl", hash = "sha256:7aa9982a728ae5892558bff6a2839c00b9ed145523ece2274fad6f414690ae75"}, + {file = "virtualenv-20.26.1.tar.gz", hash = "sha256:604bfdceaeece392802e6ae48e69cec49168b9c5f4a44e483963f9242eb0e78b"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + [[package]] name = "watchdog" version = "4.0.0" @@ -425,4 +863,4 @@ watchmedo = ["PyYAML (>=3.10)"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "35d94ccf245203340acb12038e5dec577e25ffbc71da5e36c9d7026225130cc5" +content-hash = "5c8fa6d31794cba4cef5463f7acd004269976fca9da4f3cf28aba2ff5d52193d" diff --git a/pyproject.toml b/pyproject.toml index b52b114..fde1562 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,21 @@ [tool.poetry] -name = "doppeldata" +name = "HealthChain" version = "0.1.0" -description = "" -authors = ["Adam Kells "] +description = "Remarkably simple LLM prototyping and testing in healthcare context." +authors = ["Adam Kells ", "Jennifer Jiang-Kells "] license = "MIT" readme = "README.md" [tool.poetry.dependencies] python = "^3.11" mkdocs = "^1.5.3" +pydantic = "^2.7.1" +requests = "^2.31.0" [tool.poetry.group.dev.dependencies] -pytest = "^8.1.1" +ruff = "^0.4.2" +pytest = "^8.2.0" +pre-commit = "^3.7.0" [build-system] requires = ["poetry-core"] From 125effbf7646be51440c636825e029874d80912a Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Wed, 1 May 2024 18:40:52 +0100 Subject: [PATCH 17/25] Add pre-commit hooks --- .pre-commit-config.yaml | 19 ++ docs/index.md | 2 +- example_use.py | 35 +++- mkdocs.yml | 2 +- src/base.py | 72 ------- src/decorators.py | 45 ---- src/integrations/__init__.py | 0 src/integrations/langchain.py | 0 src/integrations/medcat.py | 0 src/methods.py | 30 --- src/models/__init__.py | 0 src/models/hooks/__init__.py | 0 src/models/hooks/basehookcontext.py | 7 - src/models/hooks/encounterdischarge.py | 32 --- src/models/hooks/orderselect.py | 59 ------ src/models/hooks/ordersign.py | 44 ---- src/models/hooks/patientview.py | 32 --- src/models/hooks/sign-note-inpatient.py | 0 src/models/hooks/sign-note-outpatient.py | 0 src/models/requests/__init__.py | 0 src/models/requests/cdsrequest.py | 32 --- src/models/responses/__init__.py | 0 src/models/responses/cdsdiscovery.py | 23 --- src/models/responses/cdsfeedback.py | 33 --- src/models/responses/cdsservice.py | 194 ------------------ src/service/__init__.py | 0 src/service/client.py | 6 - src/service/server.py | 3 - src/service/soap.py | 0 src/service/specifications/__init__.py | 0 .../specifications/epic-notereader-service.py | 0 src/service/specifications/hl7-cds-service.py | 0 src/simulator/__init__.py | 0 src/simulator/ehr.py | 88 -------- src/use_cases/__init__.py | 0 src/use_cases/cds.py | 50 ----- src/use_cases/clindoc.py | 27 --- 37 files changed, 47 insertions(+), 788 deletions(-) create mode 100644 .pre-commit-config.yaml delete mode 100644 src/base.py delete mode 100644 src/decorators.py delete mode 100644 src/integrations/__init__.py delete mode 100644 src/integrations/langchain.py delete mode 100644 src/integrations/medcat.py delete mode 100644 src/methods.py delete mode 100644 src/models/__init__.py delete mode 100644 src/models/hooks/__init__.py delete mode 100644 src/models/hooks/basehookcontext.py delete mode 100644 src/models/hooks/encounterdischarge.py delete mode 100644 src/models/hooks/orderselect.py delete mode 100644 src/models/hooks/ordersign.py delete mode 100644 src/models/hooks/patientview.py delete mode 100644 src/models/hooks/sign-note-inpatient.py delete mode 100644 src/models/hooks/sign-note-outpatient.py delete mode 100644 src/models/requests/__init__.py delete mode 100644 src/models/requests/cdsrequest.py delete mode 100644 src/models/responses/__init__.py delete mode 100644 src/models/responses/cdsdiscovery.py delete mode 100644 src/models/responses/cdsfeedback.py delete mode 100644 src/models/responses/cdsservice.py delete mode 100644 src/service/__init__.py delete mode 100644 src/service/client.py delete mode 100644 src/service/server.py delete mode 100644 src/service/soap.py delete mode 100644 src/service/specifications/__init__.py delete mode 100644 src/service/specifications/epic-notereader-service.py delete mode 100644 src/service/specifications/hl7-cds-service.py delete mode 100644 src/simulator/__init__.py delete mode 100644 src/simulator/ehr.py delete mode 100644 src/use_cases/__init__.py delete mode 100644 src/use_cases/cds.py delete mode 100644 src/use_cases/clindoc.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a0a5a7e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.4.2 + hooks: + # Run the linter. + - id: ruff + args: [ --fix ] + # Run the formatter. + - id: ruff-format diff --git a/docs/index.md b/docs/index.md index f8fc1fb..9802c01 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1 +1 @@ -# DoppelData \ No newline at end of file +# DoppelData diff --git a/example_use.py b/example_use.py index 7e5548b..648bf82 100644 --- a/example_use.py +++ b/example_use.py @@ -1,6 +1,7 @@ -from src.simulator.ehr import EHR, UseCaseType, Workflow -from src.use_cases.cds import ClinicalDecisionSupport -from src.decorators import ehr +from healthchain.use_cases.cds import ClinicalDecisionSupport +from healthchain.decorators import ehr +import dataclasses +import uuid def Run(): @@ -16,23 +17,39 @@ def Run(): # ehr.send_request("http://0.0.0.0:8000", Workflow("patient-view")) # ehr.send_request("http://0.0.0.0:8000", Workflow("notereader-sign-inpatient")) + @dataclasses.dataclass + class synth_data: + context: dict + uuid: str + prefetch: dict + + # @sandbox(use_case=ClinicalDecisionSupport()) class myCDS: def __init__(self) -> None: self.data_generator = None + self.use_case = ClinicalDecisionSupport() # decorator sets up an instance of ehr configured with use case CDS - @ehr(use_case=ClinicalDecisionSupport(), workflow="patient-view") - def trigger(self, data_spec): - data = "hello, " + data_spec + @ehr(workflow="patient-view", num=5) + def load_data(self, data_spec): + # data = "hello, " + data_spec + data = synth_data( + context={"userId": "Practitioner/123", "patientId": data_spec}, + uuid=str(uuid.uuid4()), + prefetch={}, + ) return data # @service(langserve=True) - # def LLM(self): + # def llm(self): # chain = llm | output_parser # return chain - request = myCDS().trigger("lady whiskerson") - print(request) + cds = myCDS() + ehr_client = cds.load_data("123") + request = ehr_client.request_data + for i in range(len(request)): + print(request[i].model_dump_json(exclude_none=True)) if __name__ == "__main__": diff --git a/mkdocs.yml b/mkdocs.yml index 211afff..14fde43 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,4 +15,4 @@ theme: tabs: true palette: primary: white - accent: blue \ No newline at end of file + accent: blue diff --git a/src/base.py b/src/base.py deleted file mode 100644 index 6e07eca..0000000 --- a/src/base.py +++ /dev/null @@ -1,72 +0,0 @@ -from abc import ABC, abstractmethod -from enum import Enum -from typing import Dict - - -# a workflow is a specific event that may occur in an EHR that triggers a request to server -class Workflow(Enum): - patient_view = "patient-view" - order_select = "order-select" - order_sign = "order-sign" - encounter_discharge = "encounter-discharge" - notereader_sign_inpatient = "notereader-sign-inpatient" - notereader_sign_outpatient = "notereader-sign-outpatient" - - -class UseCaseType(Enum): - ClinicalDecisionSupport = ( - "patient-view", - "order-select", - "order-sign", - "encounter-discharge", - ) - ClinicalDocumentation = ("notereader-sign-inpatient", "notereader-sign-outpatient") - - def __init__(self, *workflows): - self.allowed_workflows = workflows - - -def is_valid_workflow(use_case: UseCaseType, workflow: Workflow) -> bool: - return workflow.value in use_case.allowed_workflows - - -def validate_workflow(use_case): - def decorator(func): - def wrapper(*args, **kwargs): - if not is_valid_workflow(use_case, args[2]): - raise ValueError(f"Invalid workflow {args[2]} for UseCase {use_case}") - return func(*args, **kwargs) - - return wrapper - - return decorator - - -class BaseClient(ABC): - """Base client class - A client can be an EHR or CPOE etc. - The basic operation is that it sends data in a specified standard. - """ - - @abstractmethod - def send_request(self) -> None: - """ - Sends a request to AI service - """ - - -class BaseUseCase(ABC): - """ - Abstract class for a specific use case of an EHR object - Use cases will differ by: - - the data it accepts (FHIR or CDA) - - the format of the request it constructs (CDS Hook or NoteReader workflows) - """ - - @abstractmethod - def _validate_data(self, data) -> bool: - pass - - @abstractmethod - def construct_request(self, data, workflow: Workflow) -> Dict: - pass diff --git a/src/decorators.py b/src/decorators.py deleted file mode 100644 index 7f77249..0000000 --- a/src/decorators.py +++ /dev/null @@ -1,45 +0,0 @@ -import logging - -from functools import wraps - -from .base import Workflow -from .methods import EHRClientMethod - -log = logging.getLogger(__name__) - - -# TODO: add validator and error handling -def ehr(func=None, *, workflow=None, num=1): - def decorator(func): - @wraps(func) - def wrapper(self, *args, **kwargs): - use_case = getattr(self, "use_case", None) - if use_case is None: - raise ValueError("Use case not configured!") - - try: - workflow_enum = Workflow(workflow) - except ValueError as e: - raise ValueError( - f"{e}: please select from {[x.value for x in Workflow]}" - ) - - if use_case.__class__.__name__ in [ - "ClinicalDocumentation", - "ClinicalDecisionSupport", - ]: - method = EHRClientMethod( - func, workflow=workflow_enum, use_case=use_case - ) - for _ in range(num): - method.generate_request(self, *args, **kwargs) - else: - raise NotImplementedError - return method # (self, *args, **kwargs) - - return wrapper - - if func is None: - return decorator - else: - return decorator(func) diff --git a/src/integrations/__init__.py b/src/integrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/integrations/langchain.py b/src/integrations/langchain.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/integrations/medcat.py b/src/integrations/medcat.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/methods.py b/src/methods.py deleted file mode 100644 index b055db9..0000000 --- a/src/methods.py +++ /dev/null @@ -1,30 +0,0 @@ -import logging -import requests - -log = logging.getLogger(__name__) - - -class EHRClientMethod: - def __init__(self, func, workflow=None, use_case=None): - self.data_generator_func = func - self.workflow = workflow - self.use_case = use_case - self.request_data = [] - - def generate_request(self, *args, **kwargs) -> None: - data = self.data_generator_func(*args, **kwargs) - self.request_data.append(self.use_case.construct_request(data, self.workflow)) - - def send_request(self, url: str) -> None: - json_responses = [] - for request in self.request_data: - try: - response = requests.post( - url=url, data=request.model_dump_json(exclude_none=True) - ) - json_responses.append(response.json()) - except Exception as e: - log.error(f"Error sending request: {e}") - json_responses.append({}) - - return json_responses diff --git a/src/models/__init__.py b/src/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/models/hooks/__init__.py b/src/models/hooks/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/models/hooks/basehookcontext.py b/src/models/hooks/basehookcontext.py deleted file mode 100644 index 72995a4..0000000 --- a/src/models/hooks/basehookcontext.py +++ /dev/null @@ -1,7 +0,0 @@ -from pydantic import BaseModel -from abc import ABC - - -class BaseHookContext(BaseModel, ABC): - userId: str - patientId: str diff --git a/src/models/hooks/encounterdischarge.py b/src/models/hooks/encounterdischarge.py deleted file mode 100644 index acda56f..0000000 --- a/src/models/hooks/encounterdischarge.py +++ /dev/null @@ -1,32 +0,0 @@ -from pydantic import Field - -from .basehookcontext import BaseHookContext - - -class EncounterDischargeContext(BaseHookContext): - """ - Workflow: - This hook is triggered during the discharge process for typically inpatient encounters. It can be invoked - at any point from the start to the end of the discharge process. The purpose is to allow hook services to - intervene in various aspects of the discharge decision. This includes verifying discharge medications, - ensuring continuity of care planning, and verifying necessary documentation for discharge processing. - - Attributes: - userId (str): REQUIRED. The ID of the current user, expected to be a Practitioner or PractitionerRole. - For example, 'Practitioner/123'. - patientId (str): REQUIRED. The FHIR Patient.id of the patient being discharged. - encounterId (str): REQUIRED. The FHIR Encounter.id of the encounter being ended. - - Documentation: https://cds-hooks.org/hooks/encounter-discharge/ - """ - - userId: str = Field( - ..., - description="The ID of the current user, expected to be in the format 'Practitioner/123'.", - ) - patientId: str = Field( - ..., description="The FHIR Patient.id of the patient being discharged." - ) - encounterId: str = Field( - ..., description="The FHIR Encounter.id of the encounter being ended." - ) diff --git a/src/models/hooks/orderselect.py b/src/models/hooks/orderselect.py deleted file mode 100644 index 06e8a58..0000000 --- a/src/models/hooks/orderselect.py +++ /dev/null @@ -1,59 +0,0 @@ -from pydantic import Field, model_validator -from typing import List, Dict, Optional, Any -from typing_extensions import Self - -from .basehookcontext import BaseHookContext - - -class OrderSelectContext(BaseHookContext): - """ - Workflow: The order-select hook occurs after the clinician selects the order and before signing. - This hook occurs when a clinician initially selects one or more new orders from a list of - potential orders for a specific patient (including orders for medications, procedures, labs - and other orders). The newly selected order defines that medication, procedure, lab, etc, - but may or may not define the additional details necessary to finalize the order. - - Attributes: - userId (str): REQUIRED. An identifier of the current user, in the format [ResourceType]/[id], - where ResourceType is either 'Practitioner' or 'PractitionerRole'. Examples: 'PractitionerRole/123', - 'Practitioner/abc'. - patientId (str): REQUIRED. The FHIR Patient.id representing the current patient in context. - encounterId (Optional[str]): OPTIONAL. The FHIR Encounter.id representing the current encounter in context, - if applicable. - selections ([str]): REQUIRED. A list of the FHIR id(s) of the newly selected orders, referencing resources - in the draftOrders Bundle. Example: 'MedicationRequest/103'. - draftOrders (object): REQUIRED. A Bundle of FHIR request resources with a draft status, representing all unsigned - orders from the current session, including newly selected orders. - - Documentation: https://cds-hooks.org/hooks/order-select/ - """ - - # TODO: validate selection and FHIR Bundle resource - - userId: str = Field( - pattern=r"^(Practitioner|PractitionerRole)/[^\s]+$", - description="An identifier of the current user in the format [ResourceType]/[id].", - ) - patientId: str = Field( - ..., - description="The FHIR Patient.id representing the current patient in context.", - ) - encounterId: Optional[str] = Field( - None, - description="The FHIR Encounter.id of the current encounter, if applicable.", - ) - selections: List[str] = Field( - ..., description="A list of the FHIR ids of the newly selected orders." - ) - draftOrders: Dict[str, Any] = Field( - ..., description="A Bundle of FHIR request resources with a draft status." - ) - - @model_validator(mode="after") - def validate_selections(self) -> Self: - for selection in self.selections: - if "/" not in selection: - raise ValueError( - "Each selection must be a valid FHIR resource identifier in the format 'ResourceType/ResourceID'." - ) - return self diff --git a/src/models/hooks/ordersign.py b/src/models/hooks/ordersign.py deleted file mode 100644 index aae6f78..0000000 --- a/src/models/hooks/ordersign.py +++ /dev/null @@ -1,44 +0,0 @@ -from pydantic import Field -from typing import Optional, Dict, Any - -from .basehookcontext import BaseHookContext - - -class OrderSignContext(BaseHookContext): - """ - Workflow: - The order-sign hook is triggered when a clinician is ready to sign one or more orders for a patient. - This includes orders for medications, procedures, labs, and other orders. It is one of the last workflow - events before an order is promoted from a draft status. The context includes all order details such as - dose, quantity, route, etc., even though the order is still in a draft status. This hook is also applicable - for re-signing revised orders, which may have a status other than 'draft'. The hook replaces the - medication-prescribe and order-review hooks. - - Attributes: - userId (str): REQUIRED. The ID of the current user, expected to be of type 'Practitioner' or 'PractitionerRole'. - Examples include 'PractitionerRole/123' or 'Practitioner/abc'. - patientId (str): REQUIRED. The FHIR Patient.id representing the current patient in context. - encounterId (Optional[str]): OPTIONAL. The FHIR Encounter.id of the current encounter in context. - draftOrders (dict): REQUIRED. A Bundle of FHIR request resources with a draft status, representing orders that - aren't yet signed from the current ordering session. - - Documentation: https://cds-hooks.org/hooks/order-sign/ - """ - - # TODO: validate draftOrders - - userId: str = Field( - pattern=r"^(Practitioner|PractitionerRole)/[^\s]+$", - description="The ID of the current user in the format [ResourceType]/[id].", - ) - patientId: str = Field( - ..., - description="The FHIR Patient.id representing the current patient in context.", - ) - encounterId: Optional[str] = Field( - None, - description="The FHIR Encounter.id of the current encounter, if applicable.", - ) - draftOrders: Dict[str, Any] = Field( - ..., description="A Bundle of FHIR request resources with a draft status." - ) diff --git a/src/models/hooks/patientview.py b/src/models/hooks/patientview.py deleted file mode 100644 index 5456769..0000000 --- a/src/models/hooks/patientview.py +++ /dev/null @@ -1,32 +0,0 @@ -from pydantic import Field -from typing import Optional - -from .basehookcontext import BaseHookContext - - -class PatientViewContext(BaseHookContext): - """ - Workflow: The user has just opened a patient's record; typically called only once at the beginning of a user's - interaction with a specific patient's record. - - Attributes: - userId (str): An identifier of the current user, in the format [ResourceType]/[id], - where ResourceType is one of 'Practitioner', 'PractitionerRole', 'Patient', - or 'RelatedPerson'. Examples: 'Practitioner/abc', 'Patient/123'. - patientId (str): The FHIR Patient.id representing the current patient in context. - encounterId (Optional[str]): The FHIR Encounter.id representing the current encounter in context, - if applicable. This field is optional. - - Documentation: https://cds-hooks.org/hooks/patient-view/ - """ - - # TODO: more comprehensive validator? for now regex should suffice - - userId: str = Field( - pattern=r"^(Practitioner|PractitionerRole|Patient|RelatedPerson)/[^\s]+$", - description="The ID of the current user, expected to be in the format 'Practitioner/123'.", - ) - patientId: str = Field(..., description="The FHIR Patient.id of the patient.") - encounterId: Optional[str] = Field( - None, description="The FHIR Encounter.id of the encounter, if applicable." - ) diff --git a/src/models/hooks/sign-note-inpatient.py b/src/models/hooks/sign-note-inpatient.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/models/hooks/sign-note-outpatient.py b/src/models/hooks/sign-note-outpatient.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/models/requests/__init__.py b/src/models/requests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/models/requests/cdsrequest.py b/src/models/requests/cdsrequest.py deleted file mode 100644 index dd80ef2..0000000 --- a/src/models/requests/cdsrequest.py +++ /dev/null @@ -1,32 +0,0 @@ -from pydantic import BaseModel, HttpUrl -from typing import Optional, List, Dict, Any - -from ..hooks.basehookcontext import BaseHookContext - -# TODO: add docstrings - - -class FHIRAuthorization(BaseModel): - access_token: str # OAuth2 access token - token_type: str = "Bearer" - expires_in: int - scope: str - subject: str - - -class CDSRequest(BaseModel): - """ - https://cds-hooks.org/specification/current/#http-request_1 - """ - - hook: str - hookInstance: str - context: BaseHookContext - fhirServer: Optional[HttpUrl] = None - fhirAuthorization: Optional[FHIRAuthorization] = ( - None # note this is required if fhirserver is given - ) - prefetch: Optional[Dict[str, Any]] = ( - None # fhir resource is passed either thru prefetched template of fhir server - ) - extension: Optional[List[Dict[str, Any]]] = None diff --git a/src/models/responses/__init__.py b/src/models/responses/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/models/responses/cdsdiscovery.py b/src/models/responses/cdsdiscovery.py deleted file mode 100644 index ff19c3a..0000000 --- a/src/models/responses/cdsdiscovery.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -https://cds-hooks.org/specification/current/#discovery -""" - -from pydantic import BaseModel -from typing import Optional, List, Dict, Any - - -class CDSService(BaseModel): - """ - https://cds-hooks.org/specification/current/#response - """ - - hook: str - description: str - id: str - title: Optional[str] - prefetch: Optional[Dict[str, Any]] = None - usageRequirements: Optional[str] = None - - -class CDSServiceDiscoveryResponse(BaseModel): - services: List[CDSService] diff --git a/src/models/responses/cdsfeedback.py b/src/models/responses/cdsfeedback.py deleted file mode 100644 index ed0227d..0000000 --- a/src/models/responses/cdsfeedback.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -This is not compulsary - -https://cds-hooks.org/specification/current/#feedback -""" - -from pydantic import BaseModel -from typing import Optional, Dict, Any -from enum import Enum - -from .cdsservice import Coding - - -class OutcomeEnum(str, Enum): - accepted = "accepted" - overridden = "overridden" - - -class OverrideReason(BaseModel): - reason: Coding - userComment: Optional[str] = None - - -class CDSFeedback(BaseModel): - """ - https://cds-hooks.org/specification/current/#feedback - """ - - card: str - outcome: OutcomeEnum - outcomeTimestamp: str - acceptedSuggestion: Optional[Dict[str, Any]] = None - overriddeReason: Optional[OverrideReason] = None diff --git a/src/models/responses/cdsservice.py b/src/models/responses/cdsservice.py deleted file mode 100644 index 003ee39..0000000 --- a/src/models/responses/cdsservice.py +++ /dev/null @@ -1,194 +0,0 @@ -from enum import Enum - -from pydantic import BaseModel, Field, HttpUrl, field_validator, model_validator -from typing import Optional, List, Dict -from typing_extensions import Self - -# TODO: add docstrings - - -class IndicatorEnum(str, Enum): - """ - Urgency/importance of what Card conveys. - Allowed values, in order of increasing urgency, are: info, warning, critical. - The CDS Client MAY use this field to help make UI display decisions such as sort order or coloring. - """ - - info = "info" - warning = "warning" - critical = "critical" - - -class SelectionBehaviorEnum(str, Enum): - """ - Describes the intended selection behavior of the suggestions in the card. - Allowed values are: at-most-one, indicating that the user may choose none or - at most one of the suggestions; any, indicating that the end user may choose - any number of suggestions including none of them and all of them. - CDS Clients that do not understand the value MUST treat the card as an error. - """ - - at_most_one = "at-most-one" - any = "any" - - -class ActionTypeEnum(str, Enum): - """ - The type of action being performed - """ - - create = "create" - update = "update" - delete = "delete" - - -class LinkTypeEnum(str, Enum): - """ - The type of the given URL. There are two possible values for this field. - A type of absolute indicates that the URL is absolute and should be treated as-is. - A type of smart indicates that the URL is a SMART app launch URL and the CDS Client - should ensure the SMART app launch URL is populated with the appropriate SMART - launch parameters. - """ - - absolute = "absolute" - smart = "smart" - - -class Link(BaseModel): - """ - * CDS Client support for appContext requires additional coordination with the authorization - server that is not described or specified in CDS Hooks nor SMART. - - * Autolaunchable is experimental - - https://cds-hooks.org/specification/current/#link - """ - - label: str - url: HttpUrl - type: LinkTypeEnum - appContext: Optional[str] = None - autoLaunchable: Optional[bool] - - @model_validator(mode="after") - def validate_link(self) -> Self: - if self.appContext: - assert ( - self.type == LinkTypeEnum.smart - ), "'type' must be 'smart' for appContext to be valued." - - return self - - -class Coding(BaseModel): - """ - The Coding data type captures the concept of a code. This coding type is a standalone data type - in CDS Hooks modeled after a trimmed down version of the FHIR Coding data type. - """ - - code: str - system: str - display: Optional[str] = None - - -class Action(BaseModel): - """ - Within a suggestion, all actions are logically AND'd together, such that a user selecting a - suggestion selects all of the actions within it. When a suggestion contains multiple actions, - the actions SHOULD be processed as per FHIR's rules for processing transactions with the CDS - Client's fhirServer as the base url for the inferred full URL of the transaction bundle entries. - - https://cds-hooks.org/specification/current/#action - """ - - type: ActionTypeEnum - description: str - resource: Optional[Dict] = None - resourceId: Optional[str] = None - - @model_validator(mode="after") - def validate_action_type(self) -> Self: - if self.type in [ActionTypeEnum.create, ActionTypeEnum.update]: - assert ( - self.resource - ), f"'resource' must be provided when type is '{self.type.value}'" - else: - assert ( - self.resourceId - ), f"'resourceId' must be provided when type is '{self.type.value}'" - - return self - - -class Suggestion(BaseModel): - """ - Allows a service to suggest a set of changes in the context of the current activity - (e.g. changing the dose of a medication currently being prescribed, for the order-sign activity). - If suggestions are present, selectionBehavior MUST also be provided. - - https://cds-hooks.org/specification/current/#suggestion - """ - - label: str - uuid: Optional[str] = None - isRecommended: Optional[bool] - actions: Optional[List[Action]] = [] - - -class Source(BaseModel): - """ - Grouping structure for the Source of the information displayed on this card. - The source should be the primary source of guidance for the decision support Card represents. - - https://cds-hooks.org/specification/current/#source - """ - - label: str - url: Optional[HttpUrl] = None - icon: Optional[HttpUrl] = None - topic: Optional[Coding] = None - - -class Card(BaseModel): - """ - Cards can provide a combination of information (for reading), suggested actions - (to be applied if a user selects them), and links (to launch an app if the user selects them). - The CDS Client decides how to display cards, but this specification recommends displaying suggestions - using buttons, and links using underlined text. - - https://cds-hooks.org/specification/current/#card-attributes - """ - - summary: str = Field(..., max_length=140) - indicator: IndicatorEnum - source: Source - uuid: Optional[str] = None - detail: Optional[str] = None - suggestions: Optional[List[Suggestion]] = None - selectionBehavior: Optional[SelectionBehaviorEnum] = None - overrideReasons: Optional[List[Coding]] = None - links: Optional[List[Link]] = None - - @model_validator(mode="after") - def validate_suggestions(self) -> Self: - if self.suggestions is not None: - assert self.selectionBehavior, f"'selectionBehavior' must be given if 'suggestions' is present! Choose from {[v for v in SelectionBehaviorEnum.value]}" - return self - - @field_validator("detail") - @classmethod - def validate_markdown(cls, v: str) -> str: - if v is not None: - if not (v.startswith("#") or v.startswith("*")): - raise ValueError("Detail must be in GitHub Flavored Markdown format.") - return v - - -class CDSResponse(BaseModel): - """ - Http response - """ - - cards: List[Card] = [] - systemActions: Optional[Action] = None diff --git a/src/service/__init__.py b/src/service/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/service/client.py b/src/service/client.py deleted file mode 100644 index 37e534b..0000000 --- a/src/service/client.py +++ /dev/null @@ -1,6 +0,0 @@ -from ..base import BaseClient - - -class EHRClient(BaseClient): - def __init__(self) -> None: - pass diff --git a/src/service/server.py b/src/service/server.py deleted file mode 100644 index 83656f3..0000000 --- a/src/service/server.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -The model needs to be wrapped in a REST API server spcified by CDS / Epic etc. -""" diff --git a/src/service/soap.py b/src/service/soap.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/service/specifications/__init__.py b/src/service/specifications/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/service/specifications/epic-notereader-service.py b/src/service/specifications/epic-notereader-service.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/service/specifications/hl7-cds-service.py b/src/service/specifications/hl7-cds-service.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/simulator/__init__.py b/src/simulator/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/simulator/ehr.py b/src/simulator/ehr.py deleted file mode 100644 index 5075e79..0000000 --- a/src/simulator/ehr.py +++ /dev/null @@ -1,88 +0,0 @@ -import logging -import requests - -from pathlib import Path -from typing import Dict -from enum import Enum - -from ..base import BaseClient, BaseUseCase, UseCaseType, Workflow - -log = logging.getLogger(__name__) - - -# wrap vendor specific logic in decorators? may be too complex, use config? -class Vendor(Enum): - Epic = "epic" - - -class EHR(BaseClient): - """ - EHR mocker which simulates the behaviour of an EHR sending API requests to a third-party server - Currently support: - - Clinical Decision Support (HL7 CDS Hooks) - - Clinical Documentation (Epic NoteReader) - """ - - def __init__(self, use_case: BaseUseCase = None) -> None: - self._use_case = use_case - self.data = None # DoppelData object; - self.fhir_server_endpoint = None # this is just for reference, simulating return of data from fhir server is all the same - - @property - def UseCase(self) -> UseCaseType: - return self._use_case.description() - - @UseCase.setter - def UseCase(self, use_case: UseCase) -> None: - self._use_case = use_case - - # to implement in DoppelData - @property - def DataSchema(self) -> Dict: - if self.data is not None: - return self.data._schema - else: - return None - - @classmethod - def from_doppeldata(cls, data, use_case: UseCase) -> None: - """ - Constructs EHR from DoppelData object - """ - return cls(data, use_case) - - @classmethod - def from_path(cls, path: Path, use_case: UseCase) -> None: - """ - Constructs EHR from a local folder containing requests - """ - data = path - return cls(data, use_case) - - def add_database(self, data) -> None: - """ - This will take in a DoppelData object - """ - self.data = data - - def send_request(self, url: str, workflow: Workflow) -> None: - """ - Sends the API request to an AI service - """ - if self._use_case is None: - raise RuntimeError("No EHR use case configured! Set using .UseCase") - - response = {} - request = self._use_case.construct_request(self.data, workflow) - - try: - response = requests.post(url=url, data=request) - except Exception as e: - log.error(f"Error sending request: {e}") - - return response - - async def send_request_bulk(self, num: int, url: str, workflow: Workflow) -> None: - """ - Sends bulk API requests to an AI service - """ diff --git a/src/use_cases/__init__.py b/src/use_cases/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/use_cases/cds.py b/src/use_cases/cds.py deleted file mode 100644 index a01bfc3..0000000 --- a/src/use_cases/cds.py +++ /dev/null @@ -1,50 +0,0 @@ -import logging - -from typing import Dict - -from ..base import BaseUseCase, UseCaseType, Workflow, validate_workflow -from ..models.requests.cdsrequest import CDSRequest -from ..models.hooks.orderselect import OrderSelectContext -from ..models.hooks.ordersign import OrderSignContext -from ..models.hooks.patientview import PatientViewContext -from ..models.hooks.encounterdischarge import EncounterDischargeContext - -log = logging.getLogger(__name__) - - -class ClinicalDecisionSupport(BaseUseCase): - """ - Simulates the behaviour of EHR backend for Clinical Decision Support (CDS) - """ - - def __init__(self) -> None: - super().__init__() - self.context_mapping = { - Workflow.order_select: OrderSelectContext, - Workflow.order_sign: OrderSignContext, - Workflow.patient_view: PatientViewContext, - Workflow.encounter_discharge: EncounterDischargeContext, - } - - def description(self) -> str: - return "Clinical decision support (HL7 CDS specification)" - - def _validate_data(self, data, workflow: Workflow) -> bool: - # do something to valida fhir data and the worklow it's for - return True - - @validate_workflow(UseCaseType.ClinicalDecisionSupport) - def construct_request(self, data, workflow: Workflow) -> Dict: - # TODO: sub data for actual DoppelData format - if self._validate_data(data, workflow): - log.debug(f"Constructing CDS request for {workflow.value} from {data}") - - context_model = self.context_mapping.get(workflow, None) - context = context_model(**data.context) - request = CDSRequest( - hook=workflow.value, hookInstance=data.uuid, context=context - ) - else: - raise ValueError(f"Error validating data for workflow {Workflow}") - - return request diff --git a/src/use_cases/clindoc.py b/src/use_cases/clindoc.py deleted file mode 100644 index 6f6d14d..0000000 --- a/src/use_cases/clindoc.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Dict - -from ..base import BaseUseCase, UseCaseType, Workflow, validate_workflow - - -class ClinicalDocumentation(BaseUseCase): - """ - Simulates the behaviour of EHR backend for clinical documentation (NoteReader) - """ - - def description(self) -> str: - return "Clinical documentation (NoteReader)" - - def _validate_data(self, data, workflow: Workflow) -> bool: - # do something to validate cda data and the workflow it's for - return True - - @validate_workflow(UseCaseType.ClinicalDocumentation) - def construct_request(self, data, workflow: Workflow) -> Dict: - if self._validate_data(data, workflow): - # do something to construct a notereader soap request - print("Constructing Clinical Documentation request...") - request = {} - else: - raise ValueError(f"Error validating data for workflow {Workflow}") - - return request From 49713ee74e4b51ccf8f222497ff8387fc74e9148 Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Thu, 2 May 2024 15:09:14 +0100 Subject: [PATCH 18/25] Renamed EHRClientMethod -> EHRClient --- healthchain/{methods.py => clients.py} | 2 +- healthchain/decorators.py | 6 ++---- healthchain/service/client.py | 6 ------ 3 files changed, 3 insertions(+), 11 deletions(-) rename healthchain/{methods.py => clients.py} (97%) delete mode 100644 healthchain/service/client.py diff --git a/healthchain/methods.py b/healthchain/clients.py similarity index 97% rename from healthchain/methods.py rename to healthchain/clients.py index b055db9..9a83eca 100644 --- a/healthchain/methods.py +++ b/healthchain/clients.py @@ -4,7 +4,7 @@ log = logging.getLogger(__name__) -class EHRClientMethod: +class EHRClient: def __init__(self, func, workflow=None, use_case=None): self.data_generator_func = func self.workflow = workflow diff --git a/healthchain/decorators.py b/healthchain/decorators.py index 7f77249..74dadcc 100644 --- a/healthchain/decorators.py +++ b/healthchain/decorators.py @@ -3,7 +3,7 @@ from functools import wraps from .base import Workflow -from .methods import EHRClientMethod +from .clients import EHRClient log = logging.getLogger(__name__) @@ -28,9 +28,7 @@ def wrapper(self, *args, **kwargs): "ClinicalDocumentation", "ClinicalDecisionSupport", ]: - method = EHRClientMethod( - func, workflow=workflow_enum, use_case=use_case - ) + method = EHRClient(func, workflow=workflow_enum, use_case=use_case) for _ in range(num): method.generate_request(self, *args, **kwargs) else: diff --git a/healthchain/service/client.py b/healthchain/service/client.py deleted file mode 100644 index 37e534b..0000000 --- a/healthchain/service/client.py +++ /dev/null @@ -1,6 +0,0 @@ -from ..base import BaseClient - - -class EHRClient(BaseClient): - def __init__(self) -> None: - pass From 8e5f84ad8628f09a12d0f871c9828ab664bc4339 Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Thu, 2 May 2024 15:09:54 +0100 Subject: [PATCH 19/25] Added tests --- tests/test_clients.py | 68 ++++++++++++++++++++++++++++++++++ tests/test_decorators.py | 62 +++++++++++++++++++++++++++++++ tests/test_usecases.py | 79 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 tests/test_clients.py create mode 100644 tests/test_decorators.py create mode 100644 tests/test_usecases.py diff --git a/tests/test_clients.py b/tests/test_clients.py new file mode 100644 index 0000000..34e02ac --- /dev/null +++ b/tests/test_clients.py @@ -0,0 +1,68 @@ +import pytest +from unittest.mock import Mock, patch +from healthchain.clients import EHRClient + + +@pytest.fixture +def mock_function(): + return Mock() + + +@pytest.fixture +def mock_workflow(): + return Mock() + + +@pytest.fixture +def mock_use_case(): + mock = Mock() + mock.construct_request = Mock( + return_value=Mock(model_dump_json=Mock(return_value="{}")) + ) + return mock + + +@pytest.fixture +def ehr_client(mock_function, mock_workflow, mock_use_case): + return EHRClient(mock_function, mock_workflow, mock_use_case) + + +def test_init(ehr_client, mock_function, mock_workflow, mock_use_case): + assert ehr_client.data_generator_func == mock_function + assert ehr_client.workflow == mock_workflow + assert ehr_client.use_case == mock_use_case + assert ehr_client.request_data == [] + + +def test_generate_request(ehr_client, mock_use_case): + ehr_client.generate_request(1, 2, test="data") + mock_use_case.construct_request.assert_called_once() + assert len(ehr_client.request_data) == 1 + + +@patch("requests.post") +def test_send_request(mock_post, ehr_client): + # Configure the mock to return a successful response + mock_post.return_value.json = Mock(return_value={"status": "success"}) + mock_post.return_value.status_code = 200 + ehr_client.request_data = [ + Mock(model_dump_json=Mock(return_value="{}")) for _ in range(2) + ] + + responses = ehr_client.send_request("http://fakeurl.com") + + assert mock_post.call_count == 2 + assert all(response["status"] == "success" for response in responses) + + # Test error handling + mock_post.side_effect = Exception("Failed to connect") + responses = ehr_client.send_request("http://fakeurl.com") + assert {} in responses # Check if empty dict was appended due to the error + + +def test_logging_on_send_request_error(caplog, ehr_client): + with patch("requests.post") as mock_post: + mock_post.side_effect = Exception("Failed to connect") + ehr_client.request_data = [Mock(model_dump_json=Mock(return_value="{}"))] + ehr_client.send_request("http://fakeurl.com") + assert "Error sending request: Failed to connect" in caplog.text diff --git a/tests/test_decorators.py b/tests/test_decorators.py new file mode 100644 index 0000000..c023900 --- /dev/null +++ b/tests/test_decorators.py @@ -0,0 +1,62 @@ +import pytest + +from unittest.mock import Mock +from healthchain.decorators import ehr + + +class MockUseCase: + pass + + +class ClinicalDocumentation: + construct_request = Mock(return_value=Mock(model_dump_json=Mock(return_value="{}"))) + + +class ClinicalDecisionSupport: + construct_request = Mock(return_value=Mock(model_dump_json=Mock(return_value="{}"))) + + +@pytest.fixture +def function(): + def func(self): + pass + + return func + + +class TestEHRDecorator: + def test_use_case_not_configured(self, function): + instance = MockUseCase() + decorated = ehr(workflow="any_workflow")(function) + with pytest.raises(ValueError) as excinfo: + decorated(instance) + assert "Use case not configured" in str(excinfo.value) + + def test_invalid_workflow(self, function): + instance = ClinicalDocumentation() + instance.use_case = ClinicalDocumentation() + with pytest.raises(ValueError) as excinfo: + decorated = ehr(workflow="invalid_workflow")(function) + decorated(instance) + assert "please select from" in str(excinfo.value) + + def test_unsupported_use_case(self, function): + instance = MockUseCase() + instance.use_case = MockUseCase() # This use case should not be supported + decorated = ehr(workflow="patient-view")(function) + with pytest.raises(NotImplementedError): + decorated(instance) + + def test_correct_behavior(self, function): + instance = ClinicalDocumentation() + instance.use_case = ClinicalDocumentation() + decorated = ehr(workflow="order-sign")(function) + result = decorated(instance) + assert len(result.request_data) == 1 + + def test_multiple_calls(self, function): + instance = ClinicalDecisionSupport() + instance.use_case = ClinicalDecisionSupport() + decorated = ehr(workflow="order-select", num=3)(function) + result = decorated(instance) + assert len(result.request_data) == 3 diff --git a/tests/test_usecases.py b/tests/test_usecases.py new file mode 100644 index 0000000..7188f9d --- /dev/null +++ b/tests/test_usecases.py @@ -0,0 +1,79 @@ +import pytest +import dataclasses + +from unittest.mock import patch, MagicMock +from healthchain.base import Workflow +from healthchain.models.requests.cdsrequest import CDSRequest +from healthchain.use_cases.cds import ClinicalDecisionSupport +from healthchain.models.hooks.patientview import PatientViewContext + + +@dataclasses.dataclass +class synth_data: + context: dict + uuid: str + prefetch: dict + + +@pytest.fixture +def cds(): + return ClinicalDecisionSupport() + + +@pytest.fixture +def valid_data(): + return synth_data( + context={"userId": "Practitioner/123", "patientId": "123"}, + uuid="1234-5678", + prefetch={}, + ) + + +@pytest.fixture +def invalid_data(): + return synth_data( + context={"invalidId": "Practitioner/123", "patientId": "123"}, + uuid="1234-5678", + prefetch={}, + ) + + +def test_valid_data_request_construction(cds, valid_data): + with patch.object(CDSRequest, "__init__", return_value=None) as mock_init: + cds.construct_request(valid_data, Workflow.patient_view) + mock_init.assert_called_once_with( + hook=Workflow.patient_view.value, + hookInstance="1234-5678", + context=PatientViewContext(userId="Practitioner/123", patientId="123"), + ) + + +# def test_invalid_data_raises_error(cds, invalid_data): +# with pytest.raises(ValueError): +# cds.construct_request(invalid_data, Workflow.patient_view) + + +def test_context_mapping(cds, valid_data): + with patch.dict( + cds.context_mapping, + { + Workflow.patient_view: MagicMock( + spec=PatientViewContext, + return_value=PatientViewContext( + userId="Practitioner/123", patientId="123" + ), + ) + }, + ): + cds.construct_request(valid_data, Workflow.patient_view) + cds.context_mapping[Workflow.patient_view].assert_called_once_with( + **valid_data.context + ) + + +def test_workflow_validation_decorator(cds, valid_data): + with pytest.raises(ValueError) as excinfo: + cds.construct_request(valid_data, Workflow.notereader_sign_inpatient) + assert "Invalid workflow" in str(excinfo.value) + + assert cds.construct_request(valid_data, Workflow.patient_view) From 1b20ca0c89d2ca74acad67fdc295d3babf5d4a27 Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Thu, 2 May 2024 17:12:35 +0100 Subject: [PATCH 20/25] Add logging --- healthchain/__init__.py | 6 +++ healthchain/simulator/__init__.py | 0 healthchain/simulator/ehr.py | 88 ------------------------------- healthchain/utils/logger.py | 35 ++++++++++++ poetry.lock | 2 +- pyproject.toml | 1 + 6 files changed, 43 insertions(+), 89 deletions(-) create mode 100644 healthchain/__init__.py delete mode 100644 healthchain/simulator/__init__.py delete mode 100644 healthchain/simulator/ehr.py create mode 100644 healthchain/utils/logger.py diff --git a/healthchain/__init__.py b/healthchain/__init__.py new file mode 100644 index 0000000..5826d2b --- /dev/null +++ b/healthchain/__init__.py @@ -0,0 +1,6 @@ +import logging +from .utils.logger import ColorLogger + + +logging.setLoggerClass(ColorLogger) +logger = logging.getLogger(__name__) diff --git a/healthchain/simulator/__init__.py b/healthchain/simulator/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/healthchain/simulator/ehr.py b/healthchain/simulator/ehr.py deleted file mode 100644 index 5075e79..0000000 --- a/healthchain/simulator/ehr.py +++ /dev/null @@ -1,88 +0,0 @@ -import logging -import requests - -from pathlib import Path -from typing import Dict -from enum import Enum - -from ..base import BaseClient, BaseUseCase, UseCaseType, Workflow - -log = logging.getLogger(__name__) - - -# wrap vendor specific logic in decorators? may be too complex, use config? -class Vendor(Enum): - Epic = "epic" - - -class EHR(BaseClient): - """ - EHR mocker which simulates the behaviour of an EHR sending API requests to a third-party server - Currently support: - - Clinical Decision Support (HL7 CDS Hooks) - - Clinical Documentation (Epic NoteReader) - """ - - def __init__(self, use_case: BaseUseCase = None) -> None: - self._use_case = use_case - self.data = None # DoppelData object; - self.fhir_server_endpoint = None # this is just for reference, simulating return of data from fhir server is all the same - - @property - def UseCase(self) -> UseCaseType: - return self._use_case.description() - - @UseCase.setter - def UseCase(self, use_case: UseCase) -> None: - self._use_case = use_case - - # to implement in DoppelData - @property - def DataSchema(self) -> Dict: - if self.data is not None: - return self.data._schema - else: - return None - - @classmethod - def from_doppeldata(cls, data, use_case: UseCase) -> None: - """ - Constructs EHR from DoppelData object - """ - return cls(data, use_case) - - @classmethod - def from_path(cls, path: Path, use_case: UseCase) -> None: - """ - Constructs EHR from a local folder containing requests - """ - data = path - return cls(data, use_case) - - def add_database(self, data) -> None: - """ - This will take in a DoppelData object - """ - self.data = data - - def send_request(self, url: str, workflow: Workflow) -> None: - """ - Sends the API request to an AI service - """ - if self._use_case is None: - raise RuntimeError("No EHR use case configured! Set using .UseCase") - - response = {} - request = self._use_case.construct_request(self.data, workflow) - - try: - response = requests.post(url=url, data=request) - except Exception as e: - log.error(f"Error sending request: {e}") - - return response - - async def send_request_bulk(self, num: int, url: str, workflow: Workflow) -> None: - """ - Sends bulk API requests to an AI service - """ diff --git a/healthchain/utils/logger.py b/healthchain/utils/logger.py new file mode 100644 index 0000000..edebc23 --- /dev/null +++ b/healthchain/utils/logger.py @@ -0,0 +1,35 @@ +import logging +from colorama import init, Fore, Back + +init(autoreset=True) + + +class ColorFormatter(logging.Formatter): + # Change this dictionary to suit your coloring needs! + COLORS = { + "WARNING": Fore.YELLOW, + "ERROR": Fore.RED, + "DEBUG": Fore.BLUE, + "INFO": Fore.GREEN, + "CRITICAL": Fore.RED + Back.WHITE, + } + + def format(self, record): + color = self.COLORS.get(record.levelname, "") + if color: + record.asctime = color + self.formatTime(record, self.datefmt) + record.name = color + record.name + record.levelname = color + record.levelname + record.msg = color + record.msg + return logging.Formatter.format(self, record) + + +class ColorLogger(logging.Logger): + def __init__(self, name): + logging.Logger.__init__(self, name, logging.DEBUG) + color_formatter = ColorFormatter( + "%(asctime)-10s %(levelname)s [%(module)s]: %(message)s" + ) + console = logging.StreamHandler() + console.setFormatter(color_formatter) + self.addHandler(console) diff --git a/poetry.lock b/poetry.lock index dcda8ee..594f5f8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -863,4 +863,4 @@ watchmedo = ["PyYAML (>=3.10)"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "5c8fa6d31794cba4cef5463f7acd004269976fca9da4f3cf28aba2ff5d52193d" +content-hash = "0b425e65b14697aadd7680ae01bef0d2dbc1c3bf307681b1f8ef41f34d84d817" diff --git a/pyproject.toml b/pyproject.toml index fde1562..f80a963 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ python = "^3.11" mkdocs = "^1.5.3" pydantic = "^2.7.1" requests = "^2.31.0" +colorama = "^0.4.6" [tool.poetry.group.dev.dependencies] ruff = "^0.4.2" From 232a9d614c3f7cea47b954f5f2a1f19bf785f7eb Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Thu, 2 May 2024 18:13:58 +0100 Subject: [PATCH 21/25] Added correct typing --- healthchain/base.py | 2 +- healthchain/clients.py | 22 ++++++++++++------- healthchain/decorators.py | 19 +++++++++++----- .../integrations/{medcat.py => spacy.py} | 0 healthchain/use_cases/cds.py | 8 ++++++- healthchain/use_cases/clindoc.py | 8 ++++++- healthchain/utils/__init__.py | 0 7 files changed, 42 insertions(+), 17 deletions(-) rename healthchain/integrations/{medcat.py => spacy.py} (100%) create mode 100644 healthchain/utils/__init__.py diff --git a/healthchain/base.py b/healthchain/base.py index 6e07eca..47c7bb3 100644 --- a/healthchain/base.py +++ b/healthchain/base.py @@ -30,7 +30,7 @@ def is_valid_workflow(use_case: UseCaseType, workflow: Workflow) -> bool: return workflow.value in use_case.allowed_workflows -def validate_workflow(use_case): +def validate_workflow(use_case: UseCaseType): def decorator(func): def wrapper(*args, **kwargs): if not is_valid_workflow(use_case, args[2]): diff --git a/healthchain/clients.py b/healthchain/clients.py index 9a83eca..abc8b2c 100644 --- a/healthchain/clients.py +++ b/healthchain/clients.py @@ -1,22 +1,28 @@ import logging import requests +from typing import Any, Callable, List, Dict + +from .base import BaseUseCase, Workflow + log = logging.getLogger(__name__) class EHRClient: - def __init__(self, func, workflow=None, use_case=None): - self.data_generator_func = func - self.workflow = workflow - self.use_case = use_case - self.request_data = [] + def __init__( + self, func: Callable[..., Any], workflow: Workflow, use_case: BaseUseCase + ): + self.data_generator_func: Callable[..., Any] = func + self.workflow: Workflow = workflow + self.use_case: BaseUseCase = use_case + self.request_data: List[Dict] = [] - def generate_request(self, *args, **kwargs) -> None: + def generate_request(self, *args: Any, **kwargs: Any) -> None: data = self.data_generator_func(*args, **kwargs) self.request_data.append(self.use_case.construct_request(data, self.workflow)) - def send_request(self, url: str) -> None: - json_responses = [] + def send_request(self, url: str) -> List[Dict]: + json_responses: List[Dict] = [] for request in self.request_data: try: response = requests.post( diff --git a/healthchain/decorators.py b/healthchain/decorators.py index 74dadcc..a2ede9d 100644 --- a/healthchain/decorators.py +++ b/healthchain/decorators.py @@ -1,21 +1,28 @@ import logging from functools import wraps +from typing import Any, TypeVar, Optional, Callable, Union -from .base import Workflow +from .base import Workflow, BaseUseCase from .clients import EHRClient log = logging.getLogger(__name__) +F = TypeVar("F", bound=Callable) + # TODO: add validator and error handling -def ehr(func=None, *, workflow=None, num=1): - def decorator(func): +def ehr( + func: Optional[F] = None, *, workflow: Workflow, num: int = 1 +) -> Union[Callable[..., Any], Callable[[F], F]]: + def decorator(func: F) -> F: @wraps(func) - def wrapper(self, *args, **kwargs): + def wrapper(self: BaseUseCase, *args: Any, **kwargs: Any) -> EHRClient: use_case = getattr(self, "use_case", None) if use_case is None: - raise ValueError("Use case not configured!") + raise ValueError( + f"Use case not configured! Check {type(self)} is a valid strategy." + ) try: workflow_enum = Workflow(workflow) @@ -33,7 +40,7 @@ def wrapper(self, *args, **kwargs): method.generate_request(self, *args, **kwargs) else: raise NotImplementedError - return method # (self, *args, **kwargs) + return method return wrapper diff --git a/healthchain/integrations/medcat.py b/healthchain/integrations/spacy.py similarity index 100% rename from healthchain/integrations/medcat.py rename to healthchain/integrations/spacy.py diff --git a/healthchain/use_cases/cds.py b/healthchain/use_cases/cds.py index a01bfc3..2543f4a 100644 --- a/healthchain/use_cases/cds.py +++ b/healthchain/use_cases/cds.py @@ -26,6 +26,7 @@ def __init__(self) -> None: Workflow.encounter_discharge: EncounterDischargeContext, } + @property def description(self) -> str: return "Clinical decision support (HL7 CDS specification)" @@ -35,11 +36,16 @@ def _validate_data(self, data, workflow: Workflow) -> bool: @validate_workflow(UseCaseType.ClinicalDecisionSupport) def construct_request(self, data, workflow: Workflow) -> Dict: - # TODO: sub data for actual DoppelData format + # TODO: sub data for actual DoppelData format!! if self._validate_data(data, workflow): log.debug(f"Constructing CDS request for {workflow.value} from {data}") context_model = self.context_mapping.get(workflow, None) + if context_model is None: + raise ValueError( + f"Invalid workflow {workflow.value} or workflow model not implemented." + ) + context = context_model(**data.context) request = CDSRequest( hook=workflow.value, hookInstance=data.uuid, context=context diff --git a/healthchain/use_cases/clindoc.py b/healthchain/use_cases/clindoc.py index 6f6d14d..ac73485 100644 --- a/healthchain/use_cases/clindoc.py +++ b/healthchain/use_cases/clindoc.py @@ -1,13 +1,19 @@ +import logging + from typing import Dict from ..base import BaseUseCase, UseCaseType, Workflow, validate_workflow +log = logging.getLogger(__name__) + +# TODO: TO IMPLEMENT class ClinicalDocumentation(BaseUseCase): """ Simulates the behaviour of EHR backend for clinical documentation (NoteReader) """ + @property def description(self) -> str: return "Clinical documentation (NoteReader)" @@ -19,7 +25,7 @@ def _validate_data(self, data, workflow: Workflow) -> bool: def construct_request(self, data, workflow: Workflow) -> Dict: if self._validate_data(data, workflow): # do something to construct a notereader soap request - print("Constructing Clinical Documentation request...") + log.debug("Constructing Clinical Documentation request...") request = {} else: raise ValueError(f"Error validating data for workflow {Workflow}") diff --git a/healthchain/utils/__init__.py b/healthchain/utils/__init__.py new file mode 100644 index 0000000..e69de29 From 274cecb1919f0736aa139577abf50bcc6d58fa34 Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Thu, 2 May 2024 18:51:23 +0100 Subject: [PATCH 22/25] Added docstrings --- healthchain/clients.py | 34 ++++++++++++++++++-- healthchain/decorators.py | 24 ++++++++++++++ healthchain/models/requests/cdsrequest.py | 15 +++++++-- healthchain/models/responses/cdsdiscovery.py | 18 ++++++++++- healthchain/models/responses/cdsfeedback.py | 12 ++++++- healthchain/use_cases/cds.py | 15 ++++++++- healthchain/use_cases/clindoc.py | 2 +- 7 files changed, 112 insertions(+), 8 deletions(-) diff --git a/healthchain/clients.py b/healthchain/clients.py index abc8b2c..a9daaa1 100644 --- a/healthchain/clients.py +++ b/healthchain/clients.py @@ -3,25 +3,55 @@ from typing import Any, Callable, List, Dict -from .base import BaseUseCase, Workflow +from .base import BaseUseCase, BaseClient, Workflow log = logging.getLogger(__name__) -class EHRClient: +class EHRClient(BaseClient): def __init__( self, func: Callable[..., Any], workflow: Workflow, use_case: BaseUseCase ): + """ + Initializes the EHRClient with a data generator function and optional workflow and use case. + + Parameters: + func (Callable[..., Any]): A function to generate data for requests. + workflow ([Workflow]): The workflow context to apply to the data generator. + use_case ([BaseUseCase]): The strategy object to construct requests based on the generated data. + Should be a subclass of BaseUseCase. Example - ClinicalDecisionSupport() + """ self.data_generator_func: Callable[..., Any] = func self.workflow: Workflow = workflow self.use_case: BaseUseCase = use_case self.request_data: List[Dict] = [] def generate_request(self, *args: Any, **kwargs: Any) -> None: + """ + Generates a request using the data produced by the data generator function, + and appends it to the internal request queue. + + Parameters: + *args (Any): Positional arguments passed to the data generator function. + **kwargs (Any): Keyword arguments passed to the data generator function. + + Raises: + ValueError: If the use case is not configured. + """ data = self.data_generator_func(*args, **kwargs) self.request_data.append(self.use_case.construct_request(data, self.workflow)) def send_request(self, url: str) -> List[Dict]: + """ + Sends all queued requests to the specified URL and collects the responses. + + Parameters: + url (str): The URL to which the requests will be sent. + Returns: + List[dict]: A list of JSON responses from the server. + Notes: + This method logs errors rather than raising them, to avoid interrupting the batch processing of requests. + """ json_responses: List[Dict] = [] for request in self.request_data: try: diff --git a/healthchain/decorators.py b/healthchain/decorators.py index a2ede9d..c6d4774 100644 --- a/healthchain/decorators.py +++ b/healthchain/decorators.py @@ -15,6 +15,30 @@ def ehr( func: Optional[F] = None, *, workflow: Workflow, num: int = 1 ) -> Union[Callable[..., Any], Callable[[F], F]]: + """ + A decorator that wraps around a data generator function and returns an EHRClient + + Parameters: + func (Optional[Callable]): The function to be decorated. If None, this allows the decorator to + be used with arguments. + workflow ([str]): The workflow identifier which should match an item in the Workflow enum. + This specifies the context in which the EHR function will operate. + num (int): The number of requests to generate in the queue; defaults to 1. + + Returns: + Callable: A decorated callable that incorporates EHR functionality or the decorator itself + if 'func' is None, allowing it to be used as a parameterized decorator. + + Raises: + ValueError: If the workflow does not correspond to any defined enum or if use case is not configured. + NotImplementedError: If the use case class is not one of the supported types. + + Example: + @ehr(workflow='patient-view', num=2) + def generate_data(self, config): + # Function implementation + """ + def decorator(func: F) -> F: @wraps(func) def wrapper(self: BaseUseCase, *args: Any, **kwargs: Any) -> EHRClient: diff --git a/healthchain/models/requests/cdsrequest.py b/healthchain/models/requests/cdsrequest.py index dd80ef2..4e52687 100644 --- a/healthchain/models/requests/cdsrequest.py +++ b/healthchain/models/requests/cdsrequest.py @@ -16,7 +16,18 @@ class FHIRAuthorization(BaseModel): class CDSRequest(BaseModel): """ - https://cds-hooks.org/specification/current/#http-request_1 + A model representing the data structure for a CDS service call, triggered by specific hooks + within a healthcare application. + + Attributes: + hook (str): The hook that triggered this CDS Service call. For example, 'patient-view'. + hookInstance (UUID): A universally unique identifier for this particular hook call. + fhirServer (HttpUrl): The base URL of the CDS Client's FHIR server. This field is required if `fhirAuthorization` is provided. + fhirAuthorization (Optional[FhirAuthorization]): Optional authorization details providing a bearer access token for FHIR resources. + context (Dict[str, Any]): Hook-specific contextual data required by the CDS service. + prefetch (Optional[Dict[str, Any]]): Optional FHIR data that was prefetched by the CDS Client. + + Documentation: https://cds-hooks.org/specification/current/#http-request_1 """ hook: str @@ -24,7 +35,7 @@ class CDSRequest(BaseModel): context: BaseHookContext fhirServer: Optional[HttpUrl] = None fhirAuthorization: Optional[FHIRAuthorization] = ( - None # note this is required if fhirserver is given + None # TODO: note this is required if fhirserver is given ) prefetch: Optional[Dict[str, Any]] = ( None # fhir resource is passed either thru prefetched template of fhir server diff --git a/healthchain/models/responses/cdsdiscovery.py b/healthchain/models/responses/cdsdiscovery.py index ff19c3a..890c4fd 100644 --- a/healthchain/models/responses/cdsdiscovery.py +++ b/healthchain/models/responses/cdsdiscovery.py @@ -8,7 +8,18 @@ class CDSService(BaseModel): """ - https://cds-hooks.org/specification/current/#response + A model representing a CDS service configuration. + + Attributes: + hook (str): The hook this service should be invoked on. This should correspond to one of the predefined hooks. + title (Optional[str]): The human-friendly name of this service. It is recommended to provide this for better usability. + description (str): A detailed description of what this service does and its purpose within the CDS framework. + id (str): The unique identifier of this service. It forms part of the URL as {baseUrl}/cds-services/{id}. + prefetch (Optional[Dict[str, str]]): Optional FHIR queries that the service requests the CDS Client to perform + and provide on each service call. Keys describe the type of data and values are the actual FHIR query strings. + usageRequirements (Optional[str]): Human-friendly description of any preconditions for the use of this CDS service. + + Documentation: https://cds-hooks.org/specification/current/#response """ hook: str @@ -20,4 +31,9 @@ class CDSService(BaseModel): class CDSServiceDiscoveryResponse(BaseModel): + """ + A CDS Service is discoverable via a stable endpoint by CDS Clients. The Discovery endpoint includes information such as a + description of the CDS Service, when it should be invoked, and any data that is requested to be prefetched. + """ + services: List[CDSService] diff --git a/healthchain/models/responses/cdsfeedback.py b/healthchain/models/responses/cdsfeedback.py index ed0227d..39d940f 100644 --- a/healthchain/models/responses/cdsfeedback.py +++ b/healthchain/models/responses/cdsfeedback.py @@ -23,7 +23,17 @@ class OverrideReason(BaseModel): class CDSFeedback(BaseModel): """ - https://cds-hooks.org/specification/current/#feedback + A feedback endpoint enables suggestion tracking & analytics. + A CDS Service MAY support a feedback endpoint; a CDS Client SHOULD be capable of sending feedback. + + Attributes: + card (str): The card.uuid from the CDS Hooks response. Uniquely identifies the card. + outcome (str): The outcome of the action, either 'accepted' or 'overridden'. + acceptedSuggestions (List[AcceptedSuggestion]): An array of accepted suggestions, required if the outcome is 'accepted'. + overrideReason (Optional[OverrideReason]): The reason for overriding, including any coding and comments. + outcomeTimestamp (datetime): The ISO8601 timestamp of when the action was taken on the card. + + Documentation: https://cds-hooks.org/specification/current/#feedback """ card: str diff --git a/healthchain/use_cases/cds.py b/healthchain/use_cases/cds.py index 2543f4a..aca34d0 100644 --- a/healthchain/use_cases/cds.py +++ b/healthchain/use_cases/cds.py @@ -14,7 +14,7 @@ class ClinicalDecisionSupport(BaseUseCase): """ - Simulates the behaviour of EHR backend for Clinical Decision Support (CDS) + Implements EHR backend strategy for Clinical Decision Support (CDS) """ def __init__(self) -> None: @@ -36,6 +36,19 @@ def _validate_data(self, data, workflow: Workflow) -> bool: @validate_workflow(UseCaseType.ClinicalDecisionSupport) def construct_request(self, data, workflow: Workflow) -> Dict: + """ + Constructs a HL7-compliant CDS request based on workflow. + + Parameters: + data: FHIR data to be injected in request. + workflow (Workflow): The CDS hook name, e.g. patient-view. + + Returns: + Dict: A json-compatible CDS request. + + Raises: + ValueError: If the workflow is invalid or the data does not validate properly. + """ # TODO: sub data for actual DoppelData format!! if self._validate_data(data, workflow): log.debug(f"Constructing CDS request for {workflow.value} from {data}") diff --git a/healthchain/use_cases/clindoc.py b/healthchain/use_cases/clindoc.py index ac73485..5c5f91a 100644 --- a/healthchain/use_cases/clindoc.py +++ b/healthchain/use_cases/clindoc.py @@ -10,7 +10,7 @@ # TODO: TO IMPLEMENT class ClinicalDocumentation(BaseUseCase): """ - Simulates the behaviour of EHR backend for clinical documentation (NoteReader) + Implements EHR backend strategy for clinical documentation (NoteReader) """ @property From 2fdcba25d0f1133f0c84231505f94e43f0c64764 Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Thu, 2 May 2024 19:25:03 +0100 Subject: [PATCH 23/25] Fix ci --- .github/workflows/ci.yml | 3 +++ tests/__init__.py | 0 2 files changed, 3 insertions(+) create mode 100644 tests/__init__.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34e0762..9029476 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,9 @@ jobs: with: python-version: ${{matrix.python-version}} poetry-version: ${{matrix.poetry-version}} + - name: Install package + run: | + poetry install --no-interaction - name: Lint with Ruff run: | poetry run ruff format diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 From 31a2784993e2daf0e34ec4e962d4cc1f57ea9732 Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Thu, 9 May 2024 15:33:57 +0100 Subject: [PATCH 24/25] Changes from comments --- healthchain/base.py | 19 +++++++++++++++---- healthchain/decorators.py | 7 ++----- healthchain/use_cases/cds.py | 4 ++-- healthchain/use_cases/clindoc.py | 4 ++-- tests/test_usecases.py | 10 ++++++++-- 5 files changed, 29 insertions(+), 15 deletions(-) diff --git a/healthchain/base.py b/healthchain/base.py index 47c7bb3..e8c1594 100644 --- a/healthchain/base.py +++ b/healthchain/base.py @@ -14,6 +14,11 @@ class Workflow(Enum): class UseCaseType(Enum): + cds = "ClinicalDecisionSupport" + clindoc = "ClinicalDocumentation" + + +class UseCaseMapping(Enum): ClinicalDecisionSupport = ( "patient-view", "order-select", @@ -26,15 +31,21 @@ def __init__(self, *workflows): self.allowed_workflows = workflows -def is_valid_workflow(use_case: UseCaseType, workflow: Workflow) -> bool: +def is_valid_workflow(use_case: UseCaseMapping, workflow: Workflow) -> bool: return workflow.value in use_case.allowed_workflows -def validate_workflow(use_case: UseCaseType): +def validate_workflow(use_case: UseCaseMapping): def decorator(func): def wrapper(*args, **kwargs): - if not is_valid_workflow(use_case, args[2]): - raise ValueError(f"Invalid workflow {args[2]} for UseCase {use_case}") + if len(kwargs) > 0: + workflow = kwargs.get("workflow") + else: + for arg in args: + if type(arg) == Workflow: + workflow = arg + if not is_valid_workflow(use_case, workflow): + raise ValueError(f"Invalid workflow {workflow} for UseCase {use_case}") return func(*args, **kwargs) return wrapper diff --git a/healthchain/decorators.py b/healthchain/decorators.py index c6d4774..46765cb 100644 --- a/healthchain/decorators.py +++ b/healthchain/decorators.py @@ -3,7 +3,7 @@ from functools import wraps from typing import Any, TypeVar, Optional, Callable, Union -from .base import Workflow, BaseUseCase +from .base import Workflow, BaseUseCase, UseCaseType from .clients import EHRClient log = logging.getLogger(__name__) @@ -55,10 +55,7 @@ def wrapper(self: BaseUseCase, *args: Any, **kwargs: Any) -> EHRClient: f"{e}: please select from {[x.value for x in Workflow]}" ) - if use_case.__class__.__name__ in [ - "ClinicalDocumentation", - "ClinicalDecisionSupport", - ]: + if use_case.__class__.__name__ in [e.value for e in UseCaseType]: method = EHRClient(func, workflow=workflow_enum, use_case=use_case) for _ in range(num): method.generate_request(self, *args, **kwargs) diff --git a/healthchain/use_cases/cds.py b/healthchain/use_cases/cds.py index aca34d0..5c87cea 100644 --- a/healthchain/use_cases/cds.py +++ b/healthchain/use_cases/cds.py @@ -2,7 +2,7 @@ from typing import Dict -from ..base import BaseUseCase, UseCaseType, Workflow, validate_workflow +from ..base import BaseUseCase, UseCaseMapping, Workflow, validate_workflow from ..models.requests.cdsrequest import CDSRequest from ..models.hooks.orderselect import OrderSelectContext from ..models.hooks.ordersign import OrderSignContext @@ -34,7 +34,7 @@ def _validate_data(self, data, workflow: Workflow) -> bool: # do something to valida fhir data and the worklow it's for return True - @validate_workflow(UseCaseType.ClinicalDecisionSupport) + @validate_workflow(UseCaseMapping.ClinicalDecisionSupport) def construct_request(self, data, workflow: Workflow) -> Dict: """ Constructs a HL7-compliant CDS request based on workflow. diff --git a/healthchain/use_cases/clindoc.py b/healthchain/use_cases/clindoc.py index 5c5f91a..7b2038b 100644 --- a/healthchain/use_cases/clindoc.py +++ b/healthchain/use_cases/clindoc.py @@ -2,7 +2,7 @@ from typing import Dict -from ..base import BaseUseCase, UseCaseType, Workflow, validate_workflow +from ..base import BaseUseCase, UseCaseMapping, Workflow, validate_workflow log = logging.getLogger(__name__) @@ -21,7 +21,7 @@ def _validate_data(self, data, workflow: Workflow) -> bool: # do something to validate cda data and the workflow it's for return True - @validate_workflow(UseCaseType.ClinicalDocumentation) + @validate_workflow(UseCaseMapping.ClinicalDocumentation) def construct_request(self, data, workflow: Workflow) -> Dict: if self._validate_data(data, workflow): # do something to construct a notereader soap request diff --git a/tests/test_usecases.py b/tests/test_usecases.py index 7188f9d..03fb4b9 100644 --- a/tests/test_usecases.py +++ b/tests/test_usecases.py @@ -65,7 +65,7 @@ def test_context_mapping(cds, valid_data): ) }, ): - cds.construct_request(valid_data, Workflow.patient_view) + cds.construct_request(data=valid_data, workflow=Workflow.patient_view) cds.context_mapping[Workflow.patient_view].assert_called_once_with( **valid_data.context ) @@ -73,7 +73,13 @@ def test_context_mapping(cds, valid_data): def test_workflow_validation_decorator(cds, valid_data): with pytest.raises(ValueError) as excinfo: - cds.construct_request(valid_data, Workflow.notereader_sign_inpatient) + cds.construct_request(Workflow.notereader_sign_inpatient, valid_data) + assert "Invalid workflow" in str(excinfo.value) + + with pytest.raises(ValueError) as excinfo: + cds.construct_request( + data=valid_data, workflow=Workflow.notereader_sign_inpatient + ) assert "Invalid workflow" in str(excinfo.value) assert cds.construct_request(valid_data, Workflow.patient_view) From af397ca8ce913f189fe6d2f99cc5318ff23f8417 Mon Sep 17 00:00:00 2001 From: jenniferajiang Date: Thu, 9 May 2024 15:40:33 +0100 Subject: [PATCH 25/25] Renamed CDS pydantic models --- healthchain/models/responses/cdsdiscovery.py | 2 +- healthchain/models/responses/cdsfeedback.py | 2 +- healthchain/models/responses/{cdsservice.py => cdsresponse.py} | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) rename healthchain/models/responses/{cdsservice.py => cdsresponse.py} (99%) diff --git a/healthchain/models/responses/cdsdiscovery.py b/healthchain/models/responses/cdsdiscovery.py index 890c4fd..590b19c 100644 --- a/healthchain/models/responses/cdsdiscovery.py +++ b/healthchain/models/responses/cdsdiscovery.py @@ -30,7 +30,7 @@ class CDSService(BaseModel): usageRequirements: Optional[str] = None -class CDSServiceDiscoveryResponse(BaseModel): +class CDSServiceInformation(BaseModel): """ A CDS Service is discoverable via a stable endpoint by CDS Clients. The Discovery endpoint includes information such as a description of the CDS Service, when it should be invoked, and any data that is requested to be prefetched. diff --git a/healthchain/models/responses/cdsfeedback.py b/healthchain/models/responses/cdsfeedback.py index 39d940f..29f748d 100644 --- a/healthchain/models/responses/cdsfeedback.py +++ b/healthchain/models/responses/cdsfeedback.py @@ -8,7 +8,7 @@ from typing import Optional, Dict, Any from enum import Enum -from .cdsservice import Coding +from .cdsresponse import Coding class OutcomeEnum(str, Enum): diff --git a/healthchain/models/responses/cdsservice.py b/healthchain/models/responses/cdsresponse.py similarity index 99% rename from healthchain/models/responses/cdsservice.py rename to healthchain/models/responses/cdsresponse.py index 003ee39..f3e2ab5 100644 --- a/healthchain/models/responses/cdsservice.py +++ b/healthchain/models/responses/cdsresponse.py @@ -4,8 +4,6 @@ from typing import Optional, List, Dict from typing_extensions import Self -# TODO: add docstrings - class IndicatorEnum(str, Enum): """