From 7c5ea4cffc6bef2ddeb3dff36c0fde4948f39b1e Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:41:56 +0000 Subject: [PATCH 1/7] SDK regeneration Unable to analyze changes with AI, incrementing PATCH version. --- .fern/metadata.json | 9 +- changelog.md | 4 + poetry.lock | 36 +- pyproject.toml | 7 +- reference.md | 663 ++++++++---------- src/phenoml/__init__.py | 67 +- src/phenoml/agent/client.py | 108 +-- src/phenoml/agent/prompts/client.py | 84 +-- src/phenoml/agent/raw_client.py | 10 +- .../agent/types/json_patch_operation.py | 12 +- src/phenoml/authtoken/__init__.py | 12 +- src/phenoml/authtoken/auth/client.py | 111 ++- src/phenoml/authtoken/auth/raw_client.py | 227 +++++- src/phenoml/authtoken/errors/__init__.py | 4 +- .../authtoken/errors/internal_server_error.py | 10 + src/phenoml/authtoken/types/__init__.py | 8 +- src/phenoml/authtoken/types/o_auth_error.py | 32 + .../authtoken/types/o_auth_error_error.py | 7 + src/phenoml/authtoken/types/token_response.py | 32 + src/phenoml/client.py | 275 ++++++-- src/phenoml/cohort/client.py | 12 +- src/phenoml/construe/client.py | 132 ++-- src/phenoml/core/__init__.py | 19 +- src/phenoml/core/client_wrapper.py | 43 +- src/phenoml/core/datetime_utils.py | 42 ++ src/phenoml/core/http_client.py | 489 +++++++++---- src/phenoml/core/http_response.py | 6 +- src/phenoml/core/jsonable_encoder.py | 8 + src/phenoml/core/logging.py | 107 +++ src/phenoml/core/oauth_token_provider.py | 73 ++ src/phenoml/core/pydantic_utilities.py | 337 ++++++++- src/phenoml/environment.py | 2 +- src/phenoml/fhir/client.py | 72 +- src/phenoml/fhir/types/fhir_bundle.py | 12 +- .../types/fhir_patch_request_body_item.py | 9 +- src/phenoml/fhir/types/fhir_resource.py | 12 +- src/phenoml/fhir/types/fhir_resource_meta.py | 8 +- src/phenoml/fhir_provider/client.py | 84 +-- .../types/service_account_key.py | 8 +- src/phenoml/lang2fhir/client.py | 72 +- .../types/create_multi_response_bundle.py | 4 +- ...create_multi_response_bundle_entry_item.py | 4 +- .../create_multi_response_resources_item.py | 24 +- .../lang2fhir/types/search_response.py | 26 +- src/phenoml/summary/client.py | 72 +- src/phenoml/summary/types/fhir_bundle.py | 4 +- src/phenoml/summary/types/fhir_resource.py | 7 +- src/phenoml/tools/client.py | 48 +- src/phenoml/tools/mcp_server/client.py | 48 +- src/phenoml/tools/mcp_server/tools/client.py | 48 +- src/phenoml/tools/types/cohort_response.py | 24 +- ...reate_multi_response_resource_info_item.py | 24 +- ...d_create_multi_response_response_bundle.py | 4 +- src/phenoml/workflows/client.py | 72 +- src/phenoml/wrapper_client.py | 123 ---- tests/utils/test_http_client.py | 249 ++++++- 56 files changed, 2646 insertions(+), 1400 deletions(-) create mode 100644 src/phenoml/authtoken/errors/internal_server_error.py create mode 100644 src/phenoml/authtoken/types/o_auth_error.py create mode 100644 src/phenoml/authtoken/types/o_auth_error_error.py create mode 100644 src/phenoml/authtoken/types/token_response.py create mode 100644 src/phenoml/core/logging.py create mode 100644 src/phenoml/core/oauth_token_provider.py delete mode 100644 src/phenoml/wrapper_client.py diff --git a/.fern/metadata.json b/.fern/metadata.json index 740b02c..7c5e43f 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -1,8 +1,9 @@ { - "cliVersion": "4.1.1", + "cliVersion": "4.3.3", "generatorName": "fernapi/fern-python-sdk", - "generatorVersion": "4.35.4", + "generatorVersion": "4.61.4", "generatorConfig": { - "client_class_name": "phenoml" - } + "client_class_name": "PhenomlClient" + }, + "sdkVersion": "7.3.1" } \ No newline at end of file diff --git a/changelog.md b/changelog.md index bd212d2..e6e9d35 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,7 @@ +## 7.3.1 - 2026-03-04 +* SDK regeneration +* Unable to analyze changes with AI, incrementing PATCH version. + ## 7.3.0 - 2026-03-03 * feat: add document multi-resource extraction endpoint * Add comprehensive support for extracting multiple FHIR resources from documents through a new API endpoint. This enhancement combines document text extraction with multi-resource detection capabilities. diff --git a/poetry.lock b/poetry.lock index 2744b38..83fdc8b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -75,6 +75,20 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "execnet" +version = "2.1.2" +description = "execnet: rapid multi-Python deployment" +optional = false +python-versions = ">=3.8" +files = [ + {file = "execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec"}, + {file = "execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd"}, +] + +[package.extras] +testing = ["hatch", "pre-commit", "pytest", "tox"] + [[package]] name = "h11" version = "0.16.0" @@ -418,6 +432,26 @@ pytest = ">=7.0.0,<9" docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] +[[package]] +name = "pytest-xdist" +version = "3.6.1" +description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"}, + {file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"}, +] + +[package.dependencies] +execnet = ">=2.1" +pytest = ">=7.0.0" + +[package.extras] +psutil = ["psutil (>=3.0)"] +setproctitle = ["setproctitle"] +testing = ["filelock"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -562,4 +596,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "8551b871abee465e23fb0966d51f2c155fd257b55bdcb0c02d095de19f92f358" +content-hash = "bcf31a142c86d9e556553c8c260a93b563ac64a043076dbd48b26111d422c26e" diff --git a/pyproject.toml b/pyproject.toml index 1758292..8722cb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,10 @@ [project] name = "phenoml" +dynamic = ["version"] [tool.poetry] name = "phenoml" -version = "7.3.0" +version = "7.3.1" description = "" readme = "README.md" authors = [] @@ -18,6 +19,9 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: MacOS", @@ -44,6 +48,7 @@ typing_extensions = ">= 4.0.0" mypy = "==1.13.0" pytest = "^7.4.0" pytest-asyncio = "^0.23.5" +pytest-xdist = "^3.6.1" python-dateutil = "^2.9.0" types-python-dateutil = "^2.9.0.20240316" ruff = "==0.11.5" diff --git a/reference.md b/reference.md index 7697eba..f714f31 100644 --- a/reference.md +++ b/reference.md @@ -1,6 +1,6 @@ # Reference ## Agent -
client.agent.create(...) +
client.agent.create(...) -> AsyncHttpResponse[AgentResponse]
@@ -27,11 +27,9 @@ Creates a new PhenoAgent with specified configuration
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.agent.create( name="name", prompts=["prompt_123", "prompt_456"], @@ -123,7 +121,7 @@ In shared/experiment environments, the default sandbox provider is used if a dif
-
client.agent.list(...) +
client.agent.list(...) -> AsyncHttpResponse[AgentListResponse]
@@ -150,11 +148,9 @@ Retrieves a list of PhenoAgents belonging to the authenticated user
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.agent.list( tags="tags", ) @@ -193,7 +189,7 @@ client.agent.list(
-
client.agent.get(...) +
client.agent.get(...) -> AsyncHttpResponse[AgentResponse]
@@ -220,11 +216,9 @@ Retrieves a specific agent by its ID
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.agent.get( id="id", ) @@ -263,7 +257,7 @@ client.agent.get(
-
client.agent.update(...) +
client.agent.update(...) -> AsyncHttpResponse[AgentResponse]
@@ -290,11 +284,9 @@ Updates an existing agent's configuration
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.agent.update( id="id", name="name", @@ -395,7 +387,7 @@ In shared/experiment environments, the default sandbox provider is used if a dif
-
client.agent.delete(...) +
client.agent.delete(...) -> AsyncHttpResponse[AgentDeleteResponse]
@@ -422,11 +414,9 @@ Deletes an existing agent
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.agent.delete( id="id", ) @@ -465,7 +455,7 @@ client.agent.delete(
-
client.agent.patch(...) +
client.agent.patch(...) -> AsyncHttpResponse[AgentResponse]
@@ -492,12 +482,10 @@ Patches an existing agent's configuration
```python -from phenoml import phenoml +from phenoml import PhenomlClient from phenoml.agent import JsonPatchOperation -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.agent.patch( id="id", request=[ @@ -560,7 +548,7 @@ client.agent.patch(
-
client.agent.chat(...) +
client.agent.chat(...) -> AsyncHttpResponse[AgentChatResponse]
@@ -587,11 +575,9 @@ Send a message to an agent and receive a JSON response.
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.agent.chat( phenoml_on_behalf_of="Patient/550e8400-e29b-41d4-a716-446655440000", phenoml_fhir_provider="550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", @@ -687,7 +673,9 @@ Multiple FHIR provider integrations can be provided as comma-separated values.
-
client.agent.stream_chat(...) +
client.agent.stream_chat(...) -> typing.AsyncIterator[ + AsyncHttpResponse[typing.AsyncIterator[AgentChatStreamEvent]] +]
@@ -716,11 +704,9 @@ tool_result, message_end, and error.
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() response = client.agent.stream_chat( phenoml_on_behalf_of="Patient/550e8400-e29b-41d4-a716-446655440000", phenoml_fhir_provider="550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", @@ -818,7 +804,7 @@ Multiple FHIR provider integrations can be provided as comma-separated values.
-
client.agent.get_chat_messages(...) +
client.agent.get_chat_messages(...) -> AsyncHttpResponse[AgentGetChatMessagesResponse]
@@ -845,11 +831,9 @@ Retrieves a list of chat messages for a given chat session
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.agent.get_chat_messages( chat_session_id="chat_session_id", num_messages=1, @@ -925,7 +909,7 @@ If not specified, messages with all roles are returned.
## Agent Prompts -
client.agent.prompts.create(...) +
client.agent.prompts.create(...) -> AsyncHttpResponse[AgentPromptsResponse]
@@ -952,11 +936,9 @@ Creates a new agent prompt
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.agent.prompts.create( name="Medical Assistant System Prompt", content="You are a helpful medical assistant specialized in FHIR data processing...", @@ -1028,7 +1010,7 @@ client.agent.prompts.create(
-
client.agent.prompts.list() +
client.agent.prompts.list() -> AsyncHttpResponse[PromptsListResponse]
@@ -1055,11 +1037,9 @@ Retrieves a list of agent prompts belonging to the authenticated user
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.agent.prompts.list() ``` @@ -1088,7 +1068,7 @@ client.agent.prompts.list()
-
client.agent.prompts.get(...) +
client.agent.prompts.get(...) -> AsyncHttpResponse[AgentPromptsResponse]
@@ -1115,11 +1095,9 @@ Retrieves a specific prompt by its ID
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.agent.prompts.get( id="id", ) @@ -1158,7 +1136,7 @@ client.agent.prompts.get(
-
client.agent.prompts.update(...) +
client.agent.prompts.update(...) -> AsyncHttpResponse[AgentPromptsResponse]
@@ -1185,11 +1163,9 @@ Updates an existing prompt
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.agent.prompts.update( id="id", ) @@ -1268,7 +1244,7 @@ client.agent.prompts.update(
-
client.agent.prompts.delete(...) +
client.agent.prompts.delete(...) -> AsyncHttpResponse[PromptsDeleteResponse]
@@ -1295,11 +1271,9 @@ Deletes a prompt
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.agent.prompts.delete( id="id", ) @@ -1338,7 +1312,7 @@ client.agent.prompts.delete(
-
client.agent.prompts.patch(...) +
client.agent.prompts.patch(...) -> AsyncHttpResponse[AgentPromptsResponse]
@@ -1365,12 +1339,10 @@ Patches an existing prompt
```python -from phenoml import phenoml +from phenoml import PhenomlClient from phenoml.agent import JsonPatchOperation -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.agent.prompts.patch( id="id", request=[ @@ -1433,7 +1405,7 @@ client.agent.prompts.patch(
-
client.agent.prompts.load_defaults() +
client.agent.prompts.load_defaults() -> AsyncHttpResponse[SuccessResponse]
@@ -1460,11 +1432,9 @@ Loads default agent prompts for the authenticated user
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.agent.prompts.load_defaults() ``` @@ -1494,7 +1464,7 @@ client.agent.prompts.load_defaults()
## Authtoken Auth -
client.authtoken.auth.generate_token(...) +
client.authtoken.auth.generate_token(...) -> AsyncHttpResponse[AuthGenerateTokenResponse]
@@ -1521,11 +1491,9 @@ Obtain an access token using client credentials
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.authtoken.auth.generate_token( username="username", password="password", @@ -1569,12 +1537,97 @@ client.authtoken.auth.generate_token(
+ + +
+ +
client.authtoken.auth.get_token(...) -> AsyncHttpResponse[TokenResponse] +
+
+ +#### ๐Ÿ“ Description + +
+
+ +
+
+ +OAuth 2.0 client credentials token endpoint (RFC 6749 ยง4.4). +Accepts client_id and client_secret in the request body (JSON or +form-encoded) or via Basic Auth header (RFC 6749 ยง2.3.1), and +returns an access token with expiration information. +
+
+
+
+ +#### ๐Ÿ”Œ Usage + +
+
+ +
+
+ +```python +from phenoml import PhenomlClient + +client = PhenomlClient() +client.authtoken.auth.get_token() + +``` +
+
+
+
+ +#### โš™๏ธ Parameters + +
+
+ +
+
+ +**grant_type:** `typing.Optional[str]` โ€” Must be "client_credentials" if provided + +
+
+ +
+
+ +**client_id:** `typing.Optional[str]` โ€” The client ID (credential username) + +
+
+ +
+
+ +**client_secret:** `typing.Optional[str]` โ€” The client secret (credential password) + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` โ€” Request-specific configuration. + +
+
+
+
+ +
## Cohort -
client.cohort.analyze(...) +
client.cohort.analyze(...) -> AsyncHttpResponse[CohortResponse]
@@ -1601,11 +1654,9 @@ Converts natural language text into structured FHIR search queries for patient c
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.cohort.analyze( text="female patients over 65 with diabetes but not hypertension", ) @@ -1645,7 +1696,7 @@ client.cohort.analyze(
## Construe -
client.construe.upload_code_system(...) +
client.construe.upload_code_system(...) -> AsyncHttpResponse[ConstrueUploadCodeSystemResponse]
@@ -1675,11 +1726,9 @@ transitions from "processing" to "ready" or "failed".
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.construe.upload_code_system( name="CUSTOM_CODES", version="1.0", @@ -1808,7 +1857,7 @@ When false (default), uploading a duplicate returns 409 Conflict.
-
client.construe.extract_codes(...) +
client.construe.extract_codes(...) -> AsyncHttpResponse[ExtractCodesResult]
@@ -1837,11 +1886,9 @@ Usage of CPT is subject to AMA requirements: see PhenoML Terms of Service.
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.construe.extract_codes( text="Patient is a 14-year-old female, previously healthy, who is here for evaluation of abnormal renal ultrasound with atrophic right kidney", ) @@ -1896,7 +1943,7 @@ client.construe.extract_codes(
-
client.construe.list_available_code_systems() +
client.construe.list_available_code_systems() -> AsyncHttpResponse[ListCodeSystemsResponse]
@@ -1923,11 +1970,9 @@ Returns the terminology server's catalog of available code systems, including bo
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.construe.list_available_code_systems() ``` @@ -1956,7 +2001,7 @@ client.construe.list_available_code_systems()
-
client.construe.get_code_system_detail(...) +
client.construe.get_code_system_detail(...) -> AsyncHttpResponse[GetCodeSystemDetailResponse]
@@ -1983,11 +2028,9 @@ Returns full metadata for a single code system, including timestamps and builtin
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.construe.get_code_system_detail( codesystem="ICD-10-CM", version="2025", @@ -2035,7 +2078,7 @@ client.construe.get_code_system_detail(
-
client.construe.delete_custom_code_system(...) +
client.construe.delete_custom_code_system(...) -> AsyncHttpResponse[DeleteCodeSystemResponse]
@@ -2063,11 +2106,9 @@ Only available on dedicated instances. Large systems may take up to a minute to
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.construe.delete_custom_code_system( codesystem="CUSTOM_CODES", version="version", @@ -2115,7 +2156,7 @@ client.construe.delete_custom_code_system(
-
client.construe.export_custom_code_system(...) +
client.construe.export_custom_code_system(...) -> AsyncHttpResponse[ExportCodeSystemResponse]
@@ -2144,11 +2185,9 @@ Only available on dedicated instances. Builtin systems cannot be exported.
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.construe.export_custom_code_system( codesystem="CUSTOM_CODES", version="version", @@ -2196,7 +2235,7 @@ client.construe.export_custom_code_system(
-
client.construe.list_codes_in_a_code_system(...) +
client.construe.list_codes_in_a_code_system(...) -> AsyncHttpResponse[ListCodesResponse]
@@ -2225,11 +2264,9 @@ Usage of CPT is subject to AMA requirements: see PhenoML Terms of Service.
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.construe.list_codes_in_a_code_system( codesystem="ICD-10-CM", version="2025", @@ -2295,7 +2332,7 @@ client.construe.list_codes_in_a_code_system(
-
client.construe.get_a_specific_code(...) +
client.construe.get_a_specific_code(...) -> AsyncHttpResponse[GetCodeResponse]
@@ -2324,11 +2361,9 @@ Usage of CPT is subject to AMA requirements: see PhenoML Terms of Service.
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.construe.get_a_specific_code( codesystem="ICD-10-CM", code_id="E11.65", @@ -2385,7 +2420,7 @@ client.construe.get_a_specific_code(
-
client.construe.semantic_search_embedding_based(...) +
client.construe.semantic_search_embedding_based(...) -> AsyncHttpResponse[SemanticSearchResponse]
@@ -2430,11 +2465,9 @@ Usage of CPT is subject to AMA requirements: see PhenoML Terms of Service.
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.construe.semantic_search_embedding_based( codesystem="ICD-10-CM", text="patient has trouble breathing at night and wakes up gasping", @@ -2500,7 +2533,7 @@ client.construe.semantic_search_embedding_based(
-
client.construe.submit_feedback_on_extraction_results(...) +
client.construe.submit_feedback_on_extraction_results(...) -> AsyncHttpResponse[FeedbackResponse]
@@ -2528,16 +2561,14 @@ Feedback includes the full extraction result received and the result the user ex
```python -from phenoml import phenoml +from phenoml import PhenomlClient from phenoml.construe import ( ExtractCodesResult, ExtractedCodeResult, ExtractRequestSystem, ) -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.construe.submit_feedback_on_extraction_results( text="Patient has type 2 diabetes with hyperglycemia", received_result=ExtractCodesResult( @@ -2620,7 +2651,7 @@ client.construe.submit_feedback_on_extraction_results(
-
client.construe.terminology_server_text_search(...) +
client.construe.terminology_server_text_search(...) -> AsyncHttpResponse[TextSearchResponse]
@@ -2670,11 +2701,9 @@ Usage of CPT is subject to AMA requirements: see PhenoML Terms of Service.
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.construe.terminology_server_text_search( codesystem="ICD-10-CM", q="E11.65", @@ -2741,7 +2770,7 @@ client.construe.terminology_server_text_search(
## Fhir -
client.fhir.search(...) +
client.fhir.search(...) -> AsyncHttpResponse[FhirSearchResponse]
@@ -2770,11 +2799,9 @@ The request is proxied to the configured FHIR server with appropriate authentica
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.fhir.search( fhir_provider_id="550e8400-e29b-41d4-a716-446655440000", fhir_path="Patient", @@ -2870,7 +2897,7 @@ Multiple FHIR provider integrations can be provided as comma-separated values.
-
client.fhir.create(...) +
client.fhir.create(...) -> AsyncHttpResponse[FhirResource]
@@ -2899,11 +2926,9 @@ The request is proxied to the configured FHIR server with appropriate authentica
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.fhir.create( fhir_provider_id="550e8400-e29b-41d4-a716-446655440000", fhir_path="Patient", @@ -3010,7 +3035,7 @@ Multiple FHIR provider integrations can be provided as comma-separated values.
-
client.fhir.upsert(...) +
client.fhir.upsert(...) -> AsyncHttpResponse[FhirResource]
@@ -3039,11 +3064,9 @@ The request is proxied to the configured FHIR server with appropriate authentica
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.fhir.upsert( fhir_provider_id="550e8400-e29b-41d4-a716-446655440000", fhir_path="Patient", @@ -3151,7 +3174,7 @@ Multiple FHIR provider integrations can be provided as comma-separated values.
-
client.fhir.delete(...) +
client.fhir.delete(...) -> AsyncHttpResponse[typing.Dict[str, typing.Any]]
@@ -3180,11 +3203,9 @@ The request is proxied to the configured FHIR server with appropriate authentica
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.fhir.delete( fhir_provider_id="550e8400-e29b-41d4-a716-446655440000", fhir_path="Patient", @@ -3266,7 +3287,7 @@ Multiple FHIR provider integrations can be provided as comma-separated values.
-
client.fhir.patch(...) +
client.fhir.patch(...) -> AsyncHttpResponse[FhirResource]
@@ -3300,12 +3321,10 @@ The request is proxied to the configured FHIR server with appropriate authentica
```python -from phenoml import phenoml +from phenoml import PhenomlClient from phenoml.fhir import FhirPatchRequestBodyItem -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.fhir.patch( fhir_provider_id="550e8400-e29b-41d4-a716-446655440000", fhir_path="Patient", @@ -3407,7 +3426,7 @@ Multiple FHIR provider integrations can be provided as comma-separated values.
-
client.fhir.execute_bundle(...) +
client.fhir.execute_bundle(...) -> AsyncHttpResponse[FhirBundle]
@@ -3438,12 +3457,10 @@ The request is proxied to the configured FHIR server with appropriate authentica
```python -from phenoml import phenoml +from phenoml import PhenomlClient from phenoml.fhir import FhirBundleEntryItem, FhirBundleEntryItemRequest -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.fhir.execute_bundle( fhir_provider_id="550e8400-e29b-41d4-a716-446655440000", phenoml_on_behalf_of="Patient/550e8400-e29b-41d4-a716-446655440000", @@ -3553,7 +3570,7 @@ Optional field as not all FHIR servers include it (e.g., Medplum).
## FhirProvider -
client.fhir_provider.create(...) +
client.fhir_provider.create(...) -> AsyncHttpResponse[FhirProviderResponse]
@@ -3582,12 +3599,10 @@ Note: The "sandbox" provider type cannot be created via this API - it is managed
```python -from phenoml import phenoml +from phenoml import PhenomlClient from phenoml.fhir_provider import FhirProviderCreateRequestAuth_Jwt -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.fhir_provider.create( name="Epic Sandbox", provider="athenahealth", @@ -3663,7 +3678,7 @@ client.fhir_provider.create(
-
client.fhir_provider.list() +
client.fhir_provider.list() -> AsyncHttpResponse[FhirProviderListResponse]
@@ -3693,11 +3708,9 @@ Sandbox providers return FhirProviderSandboxInfo.
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.fhir_provider.list() ``` @@ -3726,7 +3739,7 @@ client.fhir_provider.list()
-
client.fhir_provider.get(...) +
client.fhir_provider.get(...) -> AsyncHttpResponse[FhirProviderResponse]
@@ -3756,11 +3769,9 @@ On shared instances, only sandbox providers can be accessed.
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.fhir_provider.get( fhir_provider_id="fhir_provider_id", ) @@ -3799,7 +3810,7 @@ client.fhir_provider.get(
-
client.fhir_provider.delete(...) +
client.fhir_provider.delete(...) -> AsyncHttpResponse[FhirProviderDeleteResponse]
@@ -3828,11 +3839,9 @@ Note: Sandbox providers cannot be deleted.
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.fhir_provider.delete( fhir_provider_id="fhir_provider_id", ) @@ -3871,7 +3880,7 @@ client.fhir_provider.delete(
-
client.fhir_provider.add_auth_config(...) +
client.fhir_provider.add_auth_config(...) -> AsyncHttpResponse[FhirProviderResponse]
@@ -3901,12 +3910,10 @@ Note: Sandbox providers cannot be modified.
```python -from phenoml import phenoml +from phenoml import PhenomlClient from phenoml.fhir_provider import FhirProviderAddAuthConfigRequest_Jwt -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.fhir_provider.add_auth_config( fhir_provider_id="1716d214-de93-43a4-aa6b-a878d864e2ad", request=FhirProviderAddAuthConfigRequest_Jwt( @@ -3956,7 +3963,7 @@ client.fhir_provider.add_auth_config(
-
client.fhir_provider.set_active_auth_config(...) +
client.fhir_provider.set_active_auth_config(...) -> AsyncHttpResponse[FhirProviderResponse]
@@ -3989,11 +3996,9 @@ Note: Sandbox providers cannot be modified.
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.fhir_provider.set_active_auth_config( fhir_provider_id="1716d214-de93-43a4-aa6b-a878d864e2ad", auth_config_id="auth-config-123", @@ -4041,7 +4046,7 @@ client.fhir_provider.set_active_auth_config(
-
client.fhir_provider.remove_auth_config(...) +
client.fhir_provider.remove_auth_config(...) -> AsyncHttpResponse[FhirProviderRemoveAuthConfigResponse]
@@ -4071,11 +4076,9 @@ Note: Sandbox providers cannot be modified.
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.fhir_provider.remove_auth_config( fhir_provider_id="1716d214-de93-43a4-aa6b-a878d864e2ad", auth_config_id="auth-config-123", @@ -4124,7 +4127,7 @@ client.fhir_provider.remove_auth_config(
## Lang2Fhir -
client.lang2fhir.create(...) +
client.lang2fhir.create(...) -> AsyncHttpResponse[FhirResource]
@@ -4151,11 +4154,9 @@ Converts natural language text into a structured FHIR resource
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.lang2fhir.create( version="R4", resource="auto", @@ -4212,7 +4213,7 @@ client.lang2fhir.create(
-
client.lang2fhir.create_multi(...) +
client.lang2fhir.create_multi(...) -> AsyncHttpResponse[CreateMultiResponse]
@@ -4241,11 +4242,9 @@ Resources are linked with proper references (e.g., Conditions reference the Pati
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.lang2fhir.create_multi( text="John Smith, 45-year-old male, diagnosed with Type 2 Diabetes. Prescribed Metformin 500mg twice daily.", ) @@ -4300,7 +4299,7 @@ client.lang2fhir.create_multi(
-
client.lang2fhir.search(...) +
client.lang2fhir.search(...) -> AsyncHttpResponse[SearchResponse]
@@ -4334,11 +4333,9 @@ Schedule, ServiceRequest, Slot, and Specimen.
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.lang2fhir.search( text="Appointments between March 2-9, 2025", ) @@ -4387,7 +4384,7 @@ Examples:
-
client.lang2fhir.upload_profile(...) +
client.lang2fhir.upload_profile(...) -> AsyncHttpResponse[Lang2FhirUploadProfileResponse]
@@ -4423,11 +4420,9 @@ Uploads will be rejected if:
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.lang2fhir.upload_profile( profile="(base64 encoded FHIR StructureDefinition JSON)", ) @@ -4466,7 +4461,7 @@ client.lang2fhir.upload_profile(
-
client.lang2fhir.document(...) +
client.lang2fhir.document(...) -> AsyncHttpResponse[FhirResource]
@@ -4493,11 +4488,9 @@ Extracts text from a document (PDF or image) and converts it into a structured F
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.lang2fhir.document( version="R4", resource="questionnaire", @@ -4558,7 +4551,7 @@ File type is auto-detected from content magic bytes.
-
client.lang2fhir.extract_multiple_fhir_resources_from_a_document(...) +
client.lang2fhir.extract_multiple_fhir_resources_from_a_document(...) -> AsyncHttpResponse[CreateMultiResponse]
@@ -4588,11 +4581,9 @@ Resources are linked with proper references (e.g., Conditions reference the Pati
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.lang2fhir.extract_multiple_fhir_resources_from_a_document( version="R4", content="content", @@ -4653,7 +4644,7 @@ File type is auto-detected from content magic bytes.
## Summary -
client.summary.list_templates() +
client.summary.list_templates() -> AsyncHttpResponse[SummaryListTemplatesResponse]
@@ -4680,11 +4671,9 @@ Retrieves all summary templates for the authenticated user
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.summary.list_templates() ``` @@ -4713,7 +4702,7 @@ client.summary.list_templates()
-
client.summary.create_template(...) +
client.summary.create_template(...) -> AsyncHttpResponse[CreateSummaryTemplateResponse]
@@ -4740,11 +4729,9 @@ Creates a summary template from an example using LLM function calling
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.summary.create_template( name="name", example_summary="Patient John Doe, age 45, presents with hypertension diagnosed on 2024-01-15.", @@ -4826,7 +4813,7 @@ client.summary.create_template(
-
client.summary.get_template(...) +
client.summary.get_template(...) -> AsyncHttpResponse[SummaryGetTemplateResponse]
@@ -4853,11 +4840,9 @@ Retrieves a specific summary template
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.summary.get_template( id="id", ) @@ -4896,7 +4881,7 @@ client.summary.get_template(
-
client.summary.update_template(...) +
client.summary.update_template(...) -> AsyncHttpResponse[SummaryUpdateTemplateResponse]
@@ -4923,11 +4908,9 @@ Updates an existing summary template
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.summary.update_template( id="id", name="name", @@ -5010,7 +4993,7 @@ client.summary.update_template(
-
client.summary.delete_template(...) +
client.summary.delete_template(...) -> AsyncHttpResponse[SummaryDeleteTemplateResponse]
@@ -5037,11 +5020,9 @@ Deletes a summary template
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.summary.delete_template( id="id", ) @@ -5080,7 +5061,7 @@ client.summary.delete_template(
-
client.summary.create(...) +
client.summary.create(...) -> AsyncHttpResponse[CreateSummaryResponse]
@@ -5110,12 +5091,10 @@ Creates a summary from FHIR resources using one of three modes:
```python -from phenoml import phenoml +from phenoml import PhenomlClient from phenoml.summary import FhirResource -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.summary.create( fhir_resources=FhirResource( resource_type="resourceType", @@ -5184,7 +5163,7 @@ Summary generation mode:
## Tools -
client.tools.create_fhir_resource(...) +
client.tools.create_fhir_resource(...) -> AsyncHttpResponse[Lang2FhirAndCreateResponse]
@@ -5211,11 +5190,9 @@ Converts natural language to FHIR resource and optionally stores it in a FHIR se
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.tools.create_fhir_resource( phenoml_on_behalf_of="Patient/550e8400-e29b-41d4-a716-446655440000", phenoml_fhir_provider="550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", @@ -5295,7 +5272,7 @@ Multiple FHIR provider integrations can be provided as comma-separated values.
-
client.tools.create_fhir_resources_multi(...) +
client.tools.create_fhir_resources_multi(...) -> AsyncHttpResponse[Lang2FhirAndCreateMultiResponse]
@@ -5326,11 +5303,9 @@ resolve them via PUT requests after the initial bundle creation.
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.tools.create_fhir_resources_multi( phenoml_on_behalf_of="Patient/550e8400-e29b-41d4-a716-446655440000", phenoml_fhir_provider="550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", @@ -5410,7 +5385,7 @@ Multiple FHIR provider integrations can be provided as comma-separated values.
-
client.tools.search_fhir_resources(...) +
client.tools.search_fhir_resources(...) -> AsyncHttpResponse[Lang2FhirAndSearchResponse]
@@ -5437,11 +5412,9 @@ Converts natural language to FHIR search parameters and executes search in FHIR
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.tools.search_fhir_resources( phenoml_on_behalf_of="Patient/550e8400-e29b-41d4-a716-446655440000", phenoml_fhir_provider="550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", @@ -5536,7 +5509,7 @@ Multiple FHIR provider integrations can be provided as comma-separated values.
-
client.tools.analyze_cohort(...) +
client.tools.analyze_cohort(...) -> AsyncHttpResponse[CohortResponse]
@@ -5563,11 +5536,9 @@ Uses LLM to extract search concepts from natural language and builds patient coh
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.tools.analyze_cohort( phenoml_on_behalf_of="Patient/550e8400-e29b-41d4-a716-446655440000", phenoml_fhir_provider="550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", @@ -5640,7 +5611,7 @@ Multiple FHIR provider integrations can be provided as comma-separated values.
## Tools McpServer -
client.tools.mcp_server.create(...) +
client.tools.mcp_server.create(...) -> AsyncHttpResponse[McpServerResponse]
@@ -5667,11 +5638,9 @@ Creates a new MCP server
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.tools.mcp_server.create( name="My MCP Server", mcp_server_url="https://mcp.example.com", @@ -5719,7 +5688,7 @@ client.tools.mcp_server.create(
-
client.tools.mcp_server.list() +
client.tools.mcp_server.list() -> AsyncHttpResponse[McpServerResponse]
@@ -5746,11 +5715,9 @@ Lists all MCP servers for a specific user
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.tools.mcp_server.list() ``` @@ -5779,7 +5746,7 @@ client.tools.mcp_server.list()
-
client.tools.mcp_server.get(...) +
client.tools.mcp_server.get(...) -> AsyncHttpResponse[McpServerResponse]
@@ -5806,11 +5773,9 @@ Gets a MCP server by ID
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.tools.mcp_server.get( mcp_server_id="mcp_server_id", ) @@ -5849,7 +5814,7 @@ client.tools.mcp_server.get(
-
client.tools.mcp_server.delete(...) +
client.tools.mcp_server.delete(...) -> AsyncHttpResponse[McpServerResponse]
@@ -5876,11 +5841,9 @@ Deletes a MCP server by ID
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.tools.mcp_server.delete( mcp_server_id="mcp_server_id", ) @@ -5920,7 +5883,7 @@ client.tools.mcp_server.delete(
## Tools McpServer Tools -
client.tools.mcp_server.tools.list(...) +
client.tools.mcp_server.tools.list(...) -> AsyncHttpResponse[McpServerToolResponse]
@@ -5947,11 +5910,9 @@ Lists all MCP server tools for a specific MCP server
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.tools.mcp_server.tools.list( mcp_server_id="mcp_server_id", ) @@ -5990,7 +5951,7 @@ client.tools.mcp_server.tools.list(
-
client.tools.mcp_server.tools.get(...) +
client.tools.mcp_server.tools.get(...) -> AsyncHttpResponse[McpServerToolResponse]
@@ -6017,11 +5978,9 @@ Gets a MCP server tool by ID
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.tools.mcp_server.tools.get( mcp_server_tool_id="mcp_server_tool_id", ) @@ -6060,7 +6019,7 @@ client.tools.mcp_server.tools.get(
-
client.tools.mcp_server.tools.delete(...) +
client.tools.mcp_server.tools.delete(...) -> AsyncHttpResponse[McpServerToolResponse]
@@ -6087,11 +6046,9 @@ Deletes a MCP server tool by ID
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.tools.mcp_server.tools.delete( mcp_server_tool_id="mcp_server_tool_id", ) @@ -6130,7 +6087,7 @@ client.tools.mcp_server.tools.delete(
-
client.tools.mcp_server.tools.call(...) +
client.tools.mcp_server.tools.call(...) -> AsyncHttpResponse[McpServerToolCallResponse]
@@ -6157,11 +6114,9 @@ Calls a MCP server tool
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.tools.mcp_server.tools.call( mcp_server_tool_id="mcp_server_tool_id", arguments={"title": "PhenoML Agent API"}, @@ -6210,7 +6165,7 @@ client.tools.mcp_server.tools.call(
## Workflows -
client.workflows.list(...) +
client.workflows.list(...) -> AsyncHttpResponse[ListWorkflowsResponse]
@@ -6237,11 +6192,9 @@ Retrieves all workflow definitions for the authenticated user
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.workflows.list( verbose=True, ) @@ -6280,7 +6233,7 @@ client.workflows.list(
-
client.workflows.create(...) +
client.workflows.create(...) -> AsyncHttpResponse[CreateWorkflowResponse]
@@ -6307,11 +6260,9 @@ Creates a new workflow definition with graph generation from workflow instructio
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.workflows.create( verbose=True, name="Patient Data Mapping Workflow", @@ -6398,7 +6349,7 @@ client.workflows.create(
-
client.workflows.get(...) +
client.workflows.get(...) -> AsyncHttpResponse[WorkflowsGetResponse]
@@ -6425,11 +6376,9 @@ Retrieves a workflow definition by its ID
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.workflows.get( id="id", verbose=True, @@ -6477,7 +6426,7 @@ client.workflows.get(
-
client.workflows.update(...) +
client.workflows.update(...) -> AsyncHttpResponse[WorkflowsUpdateResponse]
@@ -6504,11 +6453,9 @@ Updates an existing workflow definition
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.workflows.update( id="id", verbose=True, @@ -6604,7 +6551,7 @@ client.workflows.update(
-
client.workflows.delete(...) +
client.workflows.delete(...) -> AsyncHttpResponse[WorkflowsDeleteResponse]
@@ -6631,11 +6578,9 @@ Deletes a workflow definition by its ID
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.workflows.delete( id="id", ) @@ -6674,7 +6619,7 @@ client.workflows.delete(
-
client.workflows.execute(...) +
client.workflows.execute(...) -> AsyncHttpResponse[ExecuteWorkflowResponse]
@@ -6701,11 +6646,9 @@ Executes a workflow with provided input data and returns results
```python -from phenoml import phenoml +from phenoml import PhenomlClient -client = phenoml( - token="YOUR_TOKEN", -) +client = PhenomlClient() client.workflows.execute( id="id", input_data={ diff --git a/src/phenoml/__init__.py b/src/phenoml/__init__.py index 4f5d3e4..3226938 100644 --- a/src/phenoml/__init__.py +++ b/src/phenoml/__init__.py @@ -2,29 +2,66 @@ # isort: skip_file -from . import agent, authtoken, cohort, construe, lang2fhir, tools -from .client import Asyncphenoml, phenoml -from .environment import phenomlEnvironment -from .version import __version__ -from .wrapper_client import PhenoMLClient, AsyncPhenoMLClient +import typing +from importlib import import_module + +if typing.TYPE_CHECKING: + from . import agent, authtoken, cohort, construe, fhir, fhir_provider, lang2fhir, summary, tools, workflows + from .client import AsyncPhenomlClient, PhenomlClient + from .environment import PhenomlClientEnvironment + from .version import __version__ +_dynamic_imports: typing.Dict[str, str] = { + "AsyncPhenomlClient": ".client", + "PhenomlClient": ".client", + "PhenomlClientEnvironment": ".environment", + "__version__": ".version", + "agent": ".agent", + "authtoken": ".authtoken", + "cohort": ".cohort", + "construe": ".construe", + "fhir": ".fhir", + "fhir_provider": ".fhir_provider", + "lang2fhir": ".lang2fhir", + "summary": ".summary", + "tools": ".tools", + "workflows": ".workflows", +} + + +def __getattr__(attr_name: str) -> typing.Any: + module_name = _dynamic_imports.get(attr_name) + if module_name is None: + raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}") + try: + module = import_module(module_name, __package__) + if module_name == f".{attr_name}": + return module + else: + return getattr(module, attr_name) + except ImportError as e: + raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e + except AttributeError as e: + raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e + + +def __dir__(): + lazy_attrs = list(_dynamic_imports.keys()) + return sorted(lazy_attrs) -# Primary client classes (recommended) -Client = PhenoMLClient -AsyncClient = AsyncPhenoMLClient __all__ = [ - "Asyncphenoml", - "AsyncPhenoMLClient", - "AsyncClient", # Primary async client - "PhenoMLClient", - "Client", # Primary sync client + "AsyncPhenomlClient", + "PhenomlClient", + "PhenomlClientEnvironment", "__version__", "agent", "authtoken", "cohort", "construe", + "fhir", + "fhir_provider", "lang2fhir", - "phenoml", # Base client (for advanced users) - "phenomlEnvironment", + "summary", "tools", + "workflows", ] diff --git a/src/phenoml/agent/client.py b/src/phenoml/agent/client.py index dc9e742..4055c31 100644 --- a/src/phenoml/agent/client.py +++ b/src/phenoml/agent/client.py @@ -90,11 +90,9 @@ def create( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.agent.create( name="name", prompts=["prompt_123", "prompt_456"], @@ -134,11 +132,9 @@ def list( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.agent.list( tags="tags", ) @@ -165,11 +161,9 @@ def get(self, id: str, *, request_options: typing.Optional[RequestOptions] = Non Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.agent.get( id="id", ) @@ -230,11 +224,9 @@ def update( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.agent.update( id="id", name="name", @@ -274,11 +266,9 @@ def delete(self, id: str, *, request_options: typing.Optional[RequestOptions] = Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.agent.delete( id="id", ) @@ -309,12 +299,10 @@ def patch( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient from phenoml.agent import JsonPatchOperation - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.agent.patch( id="id", request=[ @@ -388,11 +376,9 @@ def chat( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.agent.chat( phenoml_on_behalf_of="Patient/550e8400-e29b-41d4-a716-446655440000", phenoml_fhir_provider="550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", @@ -464,11 +450,9 @@ def stream_chat( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() response = client.agent.stream_chat( phenoml_on_behalf_of="Patient/550e8400-e29b-41d4-a716-446655440000", phenoml_fhir_provider="550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", @@ -533,11 +517,9 @@ def get_chat_messages( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.agent.get_chat_messages( chat_session_id="chat_session_id", num_messages=1, @@ -631,11 +613,9 @@ async def create( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -683,11 +663,9 @@ async def list( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -722,11 +700,9 @@ async def get(self, id: str, *, request_options: typing.Optional[RequestOptions] -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -795,11 +771,9 @@ async def update( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -847,11 +821,9 @@ async def delete(self, id: str, *, request_options: typing.Optional[RequestOptio -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -890,12 +862,10 @@ async def patch( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient from phenoml.agent import JsonPatchOperation - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -977,11 +947,9 @@ async def chat( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -1061,11 +1029,9 @@ async def stream_chat( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -1139,11 +1105,9 @@ async def get_chat_messages( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: diff --git a/src/phenoml/agent/prompts/client.py b/src/phenoml/agent/prompts/client.py index 19b9ac0..5035392 100644 --- a/src/phenoml/agent/prompts/client.py +++ b/src/phenoml/agent/prompts/client.py @@ -70,11 +70,9 @@ def create( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.agent.prompts.create( name="Medical Assistant System Prompt", content="You are a helpful medical assistant specialized in FHIR data processing...", @@ -106,11 +104,9 @@ def list(self, *, request_options: typing.Optional[RequestOptions] = None) -> Pr Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.agent.prompts.list() """ _response = self._raw_client.list(request_options=request_options) @@ -135,11 +131,9 @@ def get(self, id: str, *, request_options: typing.Optional[RequestOptions] = Non Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.agent.prompts.get( id="id", ) @@ -191,11 +185,9 @@ def update( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.agent.prompts.update( id="id", ) @@ -230,11 +222,9 @@ def delete(self, id: str, *, request_options: typing.Optional[RequestOptions] = Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.agent.prompts.delete( id="id", ) @@ -265,12 +255,10 @@ def patch( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient from phenoml.agent import JsonPatchOperation - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.agent.prompts.patch( id="id", request=[ @@ -310,11 +298,9 @@ def load_defaults(self, *, request_options: typing.Optional[RequestOptions] = No Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.agent.prompts.load_defaults() """ _response = self._raw_client.load_defaults(request_options=request_options) @@ -378,11 +364,9 @@ async def create( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -422,11 +406,9 @@ async def list(self, *, request_options: typing.Optional[RequestOptions] = None) -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -459,11 +441,9 @@ async def get(self, id: str, *, request_options: typing.Optional[RequestOptions] -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -523,11 +503,9 @@ async def update( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -572,11 +550,9 @@ async def delete( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -615,12 +591,10 @@ async def patch( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient from phenoml.agent import JsonPatchOperation - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -668,11 +642,9 @@ async def load_defaults(self, *, request_options: typing.Optional[RequestOptions -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: diff --git a/src/phenoml/agent/raw_client.py b/src/phenoml/agent/raw_client.py index 7036a52..c66fbb6 100644 --- a/src/phenoml/agent/raw_client.py +++ b/src/phenoml/agent/raw_client.py @@ -10,7 +10,7 @@ from ..core.http_response import AsyncHttpResponse, HttpResponse from ..core.http_sse._api import EventSource from ..core.jsonable_encoder import jsonable_encoder -from ..core.pydantic_utilities import parse_obj_as +from ..core.pydantic_utilities import parse_obj_as, parse_sse_obj from ..core.request_options import RequestOptions from ..core.serialization import convert_and_respect_annotation_metadata from .errors.bad_request_error import BadRequestError @@ -851,9 +851,9 @@ def _iter(): try: yield typing.cast( AgentChatStreamEvent, - parse_obj_as( + parse_sse_obj( + sse=_sse, type_=AgentChatStreamEvent, # type: ignore - object_=_sse.json(), ), ) except JSONDecodeError as e: @@ -1844,9 +1844,9 @@ async def _iter(): try: yield typing.cast( AgentChatStreamEvent, - parse_obj_as( + parse_sse_obj( + sse=_sse, type_=AgentChatStreamEvent, # type: ignore - object_=_sse.json(), ), ) except JSONDecodeError as e: diff --git a/src/phenoml/agent/types/json_patch_operation.py b/src/phenoml/agent/types/json_patch_operation.py index 1497d3b..f34f0a6 100644 --- a/src/phenoml/agent/types/json_patch_operation.py +++ b/src/phenoml/agent/types/json_patch_operation.py @@ -25,10 +25,14 @@ class JsonPatchOperation(UniversalBaseModel): The value to be used within the operations """ - from_: typing_extensions.Annotated[typing.Optional[str], FieldMetadata(alias="from")] = pydantic.Field(default=None) - """ - A JSON Pointer string specifying the location in the target document to move the value from (used with move and copy operations) - """ + from_: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="from"), + pydantic.Field( + alias="from", + description="A JSON Pointer string specifying the location in the target document to move the value from (used with move and copy operations)", + ), + ] = None if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/phenoml/authtoken/__init__.py b/src/phenoml/authtoken/__init__.py index e9529fc..53cf227 100644 --- a/src/phenoml/authtoken/__init__.py +++ b/src/phenoml/authtoken/__init__.py @@ -6,14 +6,18 @@ from importlib import import_module if typing.TYPE_CHECKING: - from .types import BadRequestErrorBody, UnauthorizedErrorBody - from .errors import BadRequestError, UnauthorizedError + from .types import BadRequestErrorBody, OAuthError, OAuthErrorError, TokenResponse, UnauthorizedErrorBody + from .errors import BadRequestError, InternalServerError, UnauthorizedError from . import auth from .auth import AuthGenerateTokenResponse _dynamic_imports: typing.Dict[str, str] = { "AuthGenerateTokenResponse": ".auth", "BadRequestError": ".errors", "BadRequestErrorBody": ".types", + "InternalServerError": ".errors", + "OAuthError": ".types", + "OAuthErrorError": ".types", + "TokenResponse": ".types", "UnauthorizedError": ".errors", "UnauthorizedErrorBody": ".types", "auth": ".auth", @@ -45,6 +49,10 @@ def __dir__(): "AuthGenerateTokenResponse", "BadRequestError", "BadRequestErrorBody", + "InternalServerError", + "OAuthError", + "OAuthErrorError", + "TokenResponse", "UnauthorizedError", "UnauthorizedErrorBody", "auth", diff --git a/src/phenoml/authtoken/auth/client.py b/src/phenoml/authtoken/auth/client.py index db194a4..4a0f014 100644 --- a/src/phenoml/authtoken/auth/client.py +++ b/src/phenoml/authtoken/auth/client.py @@ -4,6 +4,7 @@ from ...core.client_wrapper import AsyncClientWrapper, SyncClientWrapper from ...core.request_options import RequestOptions +from ..types.token_response import TokenResponse from .raw_client import AsyncRawAuthClient, RawAuthClient from .types.auth_generate_token_response import AuthGenerateTokenResponse @@ -50,11 +51,9 @@ def generate_token( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.authtoken.auth.generate_token( username="username", password="password", @@ -65,6 +64,51 @@ def generate_token( ) return _response.data + def get_token( + self, + *, + grant_type: typing.Optional[str] = OMIT, + client_id: typing.Optional[str] = OMIT, + client_secret: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> TokenResponse: + """ + OAuth 2.0 client credentials token endpoint (RFC 6749 ยง4.4). + Accepts client_id and client_secret in the request body (JSON or + form-encoded) or via Basic Auth header (RFC 6749 ยง2.3.1), and + returns an access token with expiration information. + + Parameters + ---------- + grant_type : typing.Optional[str] + Must be "client_credentials" if provided + + client_id : typing.Optional[str] + The client ID (credential username) + + client_secret : typing.Optional[str] + The client secret (credential password) + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TokenResponse + Successfully generated token + + Examples + -------- + from phenoml import PhenomlClient + + client = PhenomlClient() + client.authtoken.auth.get_token() + """ + _response = self._raw_client.get_token( + grant_type=grant_type, client_id=client_id, client_secret=client_secret, request_options=request_options + ) + return _response.data + class AsyncAuthClient: def __init__(self, *, client_wrapper: AsyncClientWrapper): @@ -107,11 +151,9 @@ async def generate_token( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -127,3 +169,56 @@ async def main() -> None: username=username, password=password, request_options=request_options ) return _response.data + + async def get_token( + self, + *, + grant_type: typing.Optional[str] = OMIT, + client_id: typing.Optional[str] = OMIT, + client_secret: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> TokenResponse: + """ + OAuth 2.0 client credentials token endpoint (RFC 6749 ยง4.4). + Accepts client_id and client_secret in the request body (JSON or + form-encoded) or via Basic Auth header (RFC 6749 ยง2.3.1), and + returns an access token with expiration information. + + Parameters + ---------- + grant_type : typing.Optional[str] + Must be "client_credentials" if provided + + client_id : typing.Optional[str] + The client ID (credential username) + + client_secret : typing.Optional[str] + The client secret (credential password) + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + TokenResponse + Successfully generated token + + Examples + -------- + import asyncio + + from phenoml import AsyncPhenomlClient + + client = AsyncPhenomlClient() + + + async def main() -> None: + await client.authtoken.auth.get_token() + + + asyncio.run(main()) + """ + _response = await self._raw_client.get_token( + grant_type=grant_type, client_id=client_id, client_secret=client_secret, request_options=request_options + ) + return _response.data diff --git a/src/phenoml/authtoken/auth/raw_client.py b/src/phenoml/authtoken/auth/raw_client.py index 7e4532d..b0f5e0f 100644 --- a/src/phenoml/authtoken/auth/raw_client.py +++ b/src/phenoml/authtoken/auth/raw_client.py @@ -1,6 +1,5 @@ # This file was auto-generated by Fern from our API Definition. -import base64 import typing from json.decoder import JSONDecodeError @@ -10,7 +9,9 @@ from ...core.pydantic_utilities import parse_obj_as from ...core.request_options import RequestOptions from ..errors.bad_request_error import BadRequestError +from ..errors.internal_server_error import InternalServerError from ..errors.unauthorized_error import UnauthorizedError +from ..types.token_response import TokenResponse from .types.auth_generate_token_response import AuthGenerateTokenResponse # this is used as the default value for optional parameters @@ -43,15 +44,14 @@ def generate_token( HttpResponse[AuthGenerateTokenResponse] Successfully generated token """ - # Create basic auth header - credentials = f"{username}:{password}" - encoded_credentials = base64.b64encode(credentials.encode()).decode() - _response = self._client_wrapper.httpx_client.request( "auth/token", method="POST", + json={ + "username": username, + "password": password, + }, headers={ - "Authorization": f"Basic {encoded_credentials}", "content-type": "application/json", }, request_options=request_options, @@ -71,9 +71,93 @@ def generate_token( raise BadRequestError( headers=dict(_response.headers), body=typing.cast( - typing.Optional[typing.Any], + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + def get_token( + self, + *, + grant_type: typing.Optional[str] = OMIT, + client_id: typing.Optional[str] = OMIT, + client_secret: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[TokenResponse]: + """ + OAuth 2.0 client credentials token endpoint (RFC 6749 ยง4.4). + Accepts client_id and client_secret in the request body (JSON or + form-encoded) or via Basic Auth header (RFC 6749 ยง2.3.1), and + returns an access token with expiration information. + + Parameters + ---------- + grant_type : typing.Optional[str] + Must be "client_credentials" if provided + + client_id : typing.Optional[str] + The client ID (credential username) + + client_secret : typing.Optional[str] + The client secret (credential password) + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[TokenResponse] + Successfully generated token + """ + _response = self._client_wrapper.httpx_client.request( + "v2/auth/token", + method="POST", + json={ + "grant_type": grant_type, + "client_id": client_id, + "client_secret": client_secret, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TokenResponse, + parse_obj_as( + type_=TokenResponse, # type: ignore + object_=_response.json(), + ), + ) + return HttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, parse_obj_as( - type_=typing.Optional[typing.Any], # type: ignore + type_=typing.Any, # type: ignore object_=_response.json(), ), ), @@ -82,9 +166,20 @@ def generate_token( raise UnauthorizedError( headers=dict(_response.headers), body=typing.cast( - typing.Optional[typing.Any], + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, parse_obj_as( - type_=typing.Optional[typing.Any], # type: ignore + type_=typing.Any, # type: ignore object_=_response.json(), ), ), @@ -121,15 +216,14 @@ async def generate_token( AsyncHttpResponse[AuthGenerateTokenResponse] Successfully generated token """ - # Create basic auth header - credentials = f"{username}:{password}" - encoded_credentials = base64.b64encode(credentials.encode()).decode() - _response = await self._client_wrapper.httpx_client.request( "auth/token", method="POST", + json={ + "username": username, + "password": password, + }, headers={ - "Authorization": f"Basic {encoded_credentials}", "content-type": "application/json", }, request_options=request_options, @@ -149,9 +243,93 @@ async def generate_token( raise BadRequestError( headers=dict(_response.headers), body=typing.cast( - typing.Optional[typing.Any], + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 401: + raise UnauthorizedError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + + async def get_token( + self, + *, + grant_type: typing.Optional[str] = OMIT, + client_id: typing.Optional[str] = OMIT, + client_secret: typing.Optional[str] = OMIT, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[TokenResponse]: + """ + OAuth 2.0 client credentials token endpoint (RFC 6749 ยง4.4). + Accepts client_id and client_secret in the request body (JSON or + form-encoded) or via Basic Auth header (RFC 6749 ยง2.3.1), and + returns an access token with expiration information. + + Parameters + ---------- + grant_type : typing.Optional[str] + Must be "client_credentials" if provided + + client_id : typing.Optional[str] + The client ID (credential username) + + client_secret : typing.Optional[str] + The client secret (credential password) + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[TokenResponse] + Successfully generated token + """ + _response = await self._client_wrapper.httpx_client.request( + "v2/auth/token", + method="POST", + json={ + "grant_type": grant_type, + "client_id": client_id, + "client_secret": client_secret, + }, + headers={ + "content-type": "application/json", + }, + request_options=request_options, + omit=OMIT, + ) + try: + if 200 <= _response.status_code < 300: + _data = typing.cast( + TokenResponse, + parse_obj_as( + type_=TokenResponse, # type: ignore + object_=_response.json(), + ), + ) + return AsyncHttpResponse(response=_response, data=_data) + if _response.status_code == 400: + raise BadRequestError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, parse_obj_as( - type_=typing.Optional[typing.Any], # type: ignore + type_=typing.Any, # type: ignore object_=_response.json(), ), ), @@ -160,9 +338,20 @@ async def generate_token( raise UnauthorizedError( headers=dict(_response.headers), body=typing.cast( - typing.Optional[typing.Any], + typing.Any, + parse_obj_as( + type_=typing.Any, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 500: + raise InternalServerError( + headers=dict(_response.headers), + body=typing.cast( + typing.Any, parse_obj_as( - type_=typing.Optional[typing.Any], # type: ignore + type_=typing.Any, # type: ignore object_=_response.json(), ), ), diff --git a/src/phenoml/authtoken/errors/__init__.py b/src/phenoml/authtoken/errors/__init__.py index 8de13d9..3d0326c 100644 --- a/src/phenoml/authtoken/errors/__init__.py +++ b/src/phenoml/authtoken/errors/__init__.py @@ -7,9 +7,11 @@ if typing.TYPE_CHECKING: from .bad_request_error import BadRequestError + from .internal_server_error import InternalServerError from .unauthorized_error import UnauthorizedError _dynamic_imports: typing.Dict[str, str] = { "BadRequestError": ".bad_request_error", + "InternalServerError": ".internal_server_error", "UnauthorizedError": ".unauthorized_error", } @@ -35,4 +37,4 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = ["BadRequestError", "UnauthorizedError"] +__all__ = ["BadRequestError", "InternalServerError", "UnauthorizedError"] diff --git a/src/phenoml/authtoken/errors/internal_server_error.py b/src/phenoml/authtoken/errors/internal_server_error.py new file mode 100644 index 0000000..6df0c1d --- /dev/null +++ b/src/phenoml/authtoken/errors/internal_server_error.py @@ -0,0 +1,10 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +from ...core.api_error import ApiError + + +class InternalServerError(ApiError): + def __init__(self, body: typing.Any, headers: typing.Optional[typing.Dict[str, str]] = None): + super().__init__(status_code=500, headers=headers, body=body) diff --git a/src/phenoml/authtoken/types/__init__.py b/src/phenoml/authtoken/types/__init__.py index 501b093..1856735 100644 --- a/src/phenoml/authtoken/types/__init__.py +++ b/src/phenoml/authtoken/types/__init__.py @@ -7,9 +7,15 @@ if typing.TYPE_CHECKING: from .bad_request_error_body import BadRequestErrorBody + from .o_auth_error import OAuthError + from .o_auth_error_error import OAuthErrorError + from .token_response import TokenResponse from .unauthorized_error_body import UnauthorizedErrorBody _dynamic_imports: typing.Dict[str, str] = { "BadRequestErrorBody": ".bad_request_error_body", + "OAuthError": ".o_auth_error", + "OAuthErrorError": ".o_auth_error_error", + "TokenResponse": ".token_response", "UnauthorizedErrorBody": ".unauthorized_error_body", } @@ -35,4 +41,4 @@ def __dir__(): return sorted(lazy_attrs) -__all__ = ["BadRequestErrorBody", "UnauthorizedErrorBody"] +__all__ = ["BadRequestErrorBody", "OAuthError", "OAuthErrorError", "TokenResponse", "UnauthorizedErrorBody"] diff --git a/src/phenoml/authtoken/types/o_auth_error.py b/src/phenoml/authtoken/types/o_auth_error.py new file mode 100644 index 0000000..1021574 --- /dev/null +++ b/src/phenoml/authtoken/types/o_auth_error.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel +from .o_auth_error_error import OAuthErrorError + + +class OAuthError(UniversalBaseModel): + """ + OAuth 2.0 error response (RFC 6749 ยง5.2) + """ + + error: OAuthErrorError = pydantic.Field() + """ + Error code + """ + + error_description: typing.Optional[str] = pydantic.Field(default=None) + """ + Human-readable error description + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/phenoml/authtoken/types/o_auth_error_error.py b/src/phenoml/authtoken/types/o_auth_error_error.py new file mode 100644 index 0000000..a6adaf5 --- /dev/null +++ b/src/phenoml/authtoken/types/o_auth_error_error.py @@ -0,0 +1,7 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +OAuthErrorError = typing.Union[ + typing.Literal["invalid_request", "invalid_client", "unsupported_grant_type", "server_error"], typing.Any +] diff --git a/src/phenoml/authtoken/types/token_response.py b/src/phenoml/authtoken/types/token_response.py new file mode 100644 index 0000000..cb237ea --- /dev/null +++ b/src/phenoml/authtoken/types/token_response.py @@ -0,0 +1,32 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ...core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class TokenResponse(UniversalBaseModel): + access_token: str = pydantic.Field() + """ + JWT access token for subsequent authenticated requests + """ + + token_type: str = pydantic.Field() + """ + Token type (always "Bearer") + """ + + expires_in: int = pydantic.Field() + """ + Token lifetime in seconds + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/phenoml/client.py b/src/phenoml/client.py index e77d55d..b117286 100644 --- a/src/phenoml/client.py +++ b/src/phenoml/client.py @@ -2,11 +2,15 @@ from __future__ import annotations +import os import typing import httpx +from .core.api_error import ApiError from .core.client_wrapper import AsyncClientWrapper, SyncClientWrapper -from .environment import phenomlEnvironment +from .core.logging import LogConfig, Logger +from .core.oauth_token_provider import AsyncOAuthTokenProvider, OAuthTokenProvider +from .environment import PhenomlClientEnvironment if typing.TYPE_CHECKING: from .agent.client import AgentClient, AsyncAgentClient @@ -21,27 +25,38 @@ from .workflows.client import AsyncWorkflowsClient, WorkflowsClient -class phenoml: +class PhenomlClient: """ Use this class to access the different functions within the SDK. You can instantiate any number of clients with different configuration that will propagate to these functions. Parameters ---------- + base_url : typing.Optional[str] The base url to use for requests from the client. - environment : phenomlEnvironment - The environment to use for requests from the client. from .environment import phenomlEnvironment + client_id : str + The client identifier used for authentication. + client_secret : str + The client secret used for authentication. + timeout : typing.Optional[float] + The timeout to be used, in seconds, for requests. By default the timeout is 60 seconds, unless a custom httpx client is used, in which case this default is not enforced. - Defaults to phenomlEnvironment.DEFAULT + follow_redirects : typing.Optional[bool] + Whether the default httpx client follows redirects or not, this is irrelevant if a custom httpx client is passed in. + httpx_client : typing.Optional[httpx.Client] + The httpx client to use for making requests, a preconfigured client is used by default, however this is useful should you want to pass in any custom httpx configuration. + # or ... - token : typing.Union[str, typing.Callable[[], str]] - headers : typing.Optional[typing.Dict[str, str]] - Additional headers to send with every request. + base_url : typing.Optional[str] + The base url to use for requests from the client. + + token : typing.Callable[[], str] + Authenticate by providing a callable that returns a pre-generated bearer token. In this mode, OAuth client credentials are not required. timeout : typing.Optional[float] The timeout to be used, in seconds, for requests. By default the timeout is 60 seconds, unless a custom httpx client is used, in which case this default is not enforced. @@ -54,38 +69,114 @@ class phenoml: Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient + + client = PhenomlClient() + + # or ... - client = phenoml( - token="YOUR_TOKEN", + from phenoml import PhenomlClient + + client = PhenomlClient( + base_url="https://yourhost.com/path/to/api", + token="YOUR_BEARER_TOKEN", ) """ + @typing.overload + def __init__( + self, + *, + base_url: typing.Optional[str] = None, + environment: PhenomlClientEnvironment = PhenomlClientEnvironment.DEFAULT, + instance_url: typing.Optional[str] = None, + headers: typing.Optional[typing.Dict[str, str]] = None, + timeout: typing.Optional[float] = None, + follow_redirects: typing.Optional[bool] = True, + httpx_client: typing.Optional[httpx.Client] = None, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, + client_id: typing.Optional[str] = os.getenv("PHENOML_CLIENT_ID"), + client_secret: typing.Optional[str] = os.getenv("PHENOML_CLIENT_SECRET"), + ): ... + @typing.overload def __init__( self, *, base_url: typing.Optional[str] = None, - environment: phenomlEnvironment = phenomlEnvironment.DEFAULT, - token: typing.Union[str, typing.Callable[[], str]], + environment: PhenomlClientEnvironment = PhenomlClientEnvironment.DEFAULT, + instance_url: typing.Optional[str] = None, headers: typing.Optional[typing.Dict[str, str]] = None, timeout: typing.Optional[float] = None, follow_redirects: typing.Optional[bool] = True, httpx_client: typing.Optional[httpx.Client] = None, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, + token: typing.Callable[[], str], + ): ... + def __init__( + self, + *, + base_url: typing.Optional[str] = None, + environment: PhenomlClientEnvironment = PhenomlClientEnvironment.DEFAULT, + instance_url: typing.Optional[str] = None, + headers: typing.Optional[typing.Dict[str, str]] = None, + client_id: typing.Optional[str] = os.getenv("PHENOML_CLIENT_ID"), + client_secret: typing.Optional[str] = os.getenv("PHENOML_CLIENT_SECRET"), + token: typing.Optional[typing.Callable[[], str]] = None, + _token_getter_override: typing.Optional[typing.Callable[[], str]] = None, + timeout: typing.Optional[float] = None, + follow_redirects: typing.Optional[bool] = True, + httpx_client: typing.Optional[httpx.Client] = None, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, ): _defaulted_timeout = ( timeout if timeout is not None else 60 if httpx_client is None else httpx_client.timeout.read ) - self._client_wrapper = SyncClientWrapper( - base_url=_get_base_url(base_url=base_url, environment=environment), - token=token, - headers=headers, - httpx_client=httpx_client - if httpx_client is not None - else httpx.Client(timeout=_defaulted_timeout, follow_redirects=follow_redirects) - if follow_redirects is not None - else httpx.Client(timeout=_defaulted_timeout), - timeout=_defaulted_timeout, - ) + if instance_url is not None: + _instance_url = instance_url if instance_url is not None else "experiment.app.pheno.ml" + base_url = "https://{instanceUrl}".format(instanceUrl=_instance_url) + if token is not None: + self._client_wrapper = SyncClientWrapper( + base_url=_get_base_url(base_url=base_url, environment=environment), + headers=headers, + httpx_client=httpx_client + if httpx_client is not None + else httpx.Client(timeout=_defaulted_timeout, follow_redirects=follow_redirects) + if follow_redirects is not None + else httpx.Client(timeout=_defaulted_timeout), + timeout=_defaulted_timeout, + logging=logging, + token=_token_getter_override if _token_getter_override is not None else token, + ) + elif client_id is not None and client_secret is not None: + oauth_token_provider = OAuthTokenProvider( + client_id=client_id, + client_secret=client_secret, + client_wrapper=SyncClientWrapper( + base_url=_get_base_url(base_url=base_url, environment=environment), + headers=headers, + httpx_client=httpx.Client(timeout=_defaulted_timeout, follow_redirects=follow_redirects) + if follow_redirects is not None + else httpx.Client(timeout=_defaulted_timeout), + timeout=_defaulted_timeout, + logging=logging, + ), + ) + self._client_wrapper = SyncClientWrapper( + base_url=_get_base_url(base_url=base_url, environment=environment), + headers=headers, + token=_token_getter_override if _token_getter_override is not None else oauth_token_provider.get_token, + httpx_client=httpx_client + if httpx_client is not None + else httpx.Client(timeout=_defaulted_timeout, follow_redirects=follow_redirects) + if follow_redirects is not None + else httpx.Client(timeout=_defaulted_timeout), + timeout=_defaulted_timeout, + logging=logging, + ) + else: + raise ApiError( + body="The client must be instantiated with either 'token' or both 'client_id' and 'client_secret'" + ) self._agent: typing.Optional[AgentClient] = None self._authtoken: typing.Optional[AuthtokenClient] = None self._cohort: typing.Optional[CohortClient] = None @@ -178,27 +269,38 @@ def workflows(self): return self._workflows -class Asyncphenoml: +class AsyncPhenomlClient: """ Use this class to access the different functions within the SDK. You can instantiate any number of clients with different configuration that will propagate to these functions. Parameters ---------- + base_url : typing.Optional[str] The base url to use for requests from the client. - environment : phenomlEnvironment - The environment to use for requests from the client. from .environment import phenomlEnvironment + client_id : str + The client identifier used for authentication. + client_secret : str + The client secret used for authentication. + timeout : typing.Optional[float] + The timeout to be used, in seconds, for requests. By default the timeout is 60 seconds, unless a custom httpx client is used, in which case this default is not enforced. - Defaults to phenomlEnvironment.DEFAULT + follow_redirects : typing.Optional[bool] + Whether the default httpx client follows redirects or not, this is irrelevant if a custom httpx client is passed in. + httpx_client : typing.Optional[httpx.AsyncClient] + The httpx client to use for making requests, a preconfigured client is used by default, however this is useful should you want to pass in any custom httpx configuration. + # or ... - token : typing.Union[str, typing.Callable[[], str]] - headers : typing.Optional[typing.Dict[str, str]] - Additional headers to send with every request. + base_url : typing.Optional[str] + The base url to use for requests from the client. + + token : typing.Callable[[], str] + Authenticate by providing a callable that returns a pre-generated bearer token. In this mode, OAuth client credentials are not required. timeout : typing.Optional[float] The timeout to be used, in seconds, for requests. By default the timeout is 60 seconds, unless a custom httpx client is used, in which case this default is not enforced. @@ -211,38 +313,115 @@ class Asyncphenoml: Examples -------- - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient + + client = AsyncPhenomlClient() + + # or ... - client = Asyncphenoml( - token="YOUR_TOKEN", + from phenoml import AsyncPhenomlClient + + client = AsyncPhenomlClient( + base_url="https://yourhost.com/path/to/api", + token="YOUR_BEARER_TOKEN", ) """ + @typing.overload + def __init__( + self, + *, + base_url: typing.Optional[str] = None, + environment: PhenomlClientEnvironment = PhenomlClientEnvironment.DEFAULT, + instance_url: typing.Optional[str] = None, + headers: typing.Optional[typing.Dict[str, str]] = None, + timeout: typing.Optional[float] = None, + follow_redirects: typing.Optional[bool] = True, + httpx_client: typing.Optional[httpx.AsyncClient] = None, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, + client_id: typing.Optional[str] = os.getenv("PHENOML_CLIENT_ID"), + client_secret: typing.Optional[str] = os.getenv("PHENOML_CLIENT_SECRET"), + ): ... + @typing.overload def __init__( self, *, base_url: typing.Optional[str] = None, - environment: phenomlEnvironment = phenomlEnvironment.DEFAULT, - token: typing.Union[str, typing.Callable[[], str]], + environment: PhenomlClientEnvironment = PhenomlClientEnvironment.DEFAULT, + instance_url: typing.Optional[str] = None, headers: typing.Optional[typing.Dict[str, str]] = None, timeout: typing.Optional[float] = None, follow_redirects: typing.Optional[bool] = True, httpx_client: typing.Optional[httpx.AsyncClient] = None, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, + token: typing.Callable[[], str], + ): ... + def __init__( + self, + *, + base_url: typing.Optional[str] = None, + environment: PhenomlClientEnvironment = PhenomlClientEnvironment.DEFAULT, + instance_url: typing.Optional[str] = None, + headers: typing.Optional[typing.Dict[str, str]] = None, + client_id: typing.Optional[str] = os.getenv("PHENOML_CLIENT_ID"), + client_secret: typing.Optional[str] = os.getenv("PHENOML_CLIENT_SECRET"), + token: typing.Optional[typing.Callable[[], str]] = None, + _token_getter_override: typing.Optional[typing.Callable[[], str]] = None, + timeout: typing.Optional[float] = None, + follow_redirects: typing.Optional[bool] = True, + httpx_client: typing.Optional[httpx.AsyncClient] = None, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, ): _defaulted_timeout = ( timeout if timeout is not None else 60 if httpx_client is None else httpx_client.timeout.read ) - self._client_wrapper = AsyncClientWrapper( - base_url=_get_base_url(base_url=base_url, environment=environment), - token=token, - headers=headers, - httpx_client=httpx_client - if httpx_client is not None - else httpx.AsyncClient(timeout=_defaulted_timeout, follow_redirects=follow_redirects) - if follow_redirects is not None - else httpx.AsyncClient(timeout=_defaulted_timeout), - timeout=_defaulted_timeout, - ) + if instance_url is not None: + _instance_url = instance_url if instance_url is not None else "experiment.app.pheno.ml" + base_url = "https://{instanceUrl}".format(instanceUrl=_instance_url) + if token is not None: + self._client_wrapper = AsyncClientWrapper( + base_url=_get_base_url(base_url=base_url, environment=environment), + headers=headers, + httpx_client=httpx_client + if httpx_client is not None + else httpx.AsyncClient(timeout=_defaulted_timeout, follow_redirects=follow_redirects) + if follow_redirects is not None + else httpx.AsyncClient(timeout=_defaulted_timeout), + timeout=_defaulted_timeout, + logging=logging, + token=_token_getter_override if _token_getter_override is not None else token, + ) + elif client_id is not None and client_secret is not None: + oauth_token_provider = AsyncOAuthTokenProvider( + client_id=client_id, + client_secret=client_secret, + client_wrapper=AsyncClientWrapper( + base_url=_get_base_url(base_url=base_url, environment=environment), + headers=headers, + httpx_client=httpx.AsyncClient(timeout=_defaulted_timeout, follow_redirects=follow_redirects) + if follow_redirects is not None + else httpx.AsyncClient(timeout=_defaulted_timeout), + timeout=_defaulted_timeout, + logging=logging, + ), + ) + self._client_wrapper = AsyncClientWrapper( + base_url=_get_base_url(base_url=base_url, environment=environment), + headers=headers, + token=_token_getter_override, + async_token=oauth_token_provider.get_token, + httpx_client=httpx_client + if httpx_client is not None + else httpx.AsyncClient(timeout=_defaulted_timeout, follow_redirects=follow_redirects) + if follow_redirects is not None + else httpx.AsyncClient(timeout=_defaulted_timeout), + timeout=_defaulted_timeout, + logging=logging, + ) + else: + raise ApiError( + body="The client must be instantiated with either 'token' or both 'client_id' and 'client_secret'" + ) self._agent: typing.Optional[AsyncAgentClient] = None self._authtoken: typing.Optional[AsyncAuthtokenClient] = None self._cohort: typing.Optional[AsyncCohortClient] = None @@ -335,7 +514,7 @@ def workflows(self): return self._workflows -def _get_base_url(*, base_url: typing.Optional[str] = None, environment: phenomlEnvironment) -> str: +def _get_base_url(*, base_url: typing.Optional[str] = None, environment: PhenomlClientEnvironment) -> str: if base_url is not None: return base_url elif environment is not None: diff --git a/src/phenoml/cohort/client.py b/src/phenoml/cohort/client.py index 48db3b4..af00fab 100644 --- a/src/phenoml/cohort/client.py +++ b/src/phenoml/cohort/client.py @@ -45,11 +45,9 @@ def analyze(self, *, text: str, request_options: typing.Optional[RequestOptions] Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.cohort.analyze( text="female patients over 65 with diabetes but not hypertension", ) @@ -94,11 +92,9 @@ async def analyze(self, *, text: str, request_options: typing.Optional[RequestOp -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: diff --git a/src/phenoml/construe/client.py b/src/phenoml/construe/client.py index ca76af8..cfcd910 100644 --- a/src/phenoml/construe/client.py +++ b/src/phenoml/construe/client.py @@ -111,11 +111,9 @@ def upload_code_system( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.construe.upload_code_system( name="CUSTOM_CODES", version="1.0", @@ -169,11 +167,9 @@ def extract_codes( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.construe.extract_codes( text="Patient is a 14-year-old female, previously healthy, who is here for evaluation of abnormal renal ultrasound with atrophic right kidney", ) @@ -201,11 +197,9 @@ def list_available_code_systems( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.construe.list_available_code_systems() """ _response = self._raw_client.list_available_code_systems(request_options=request_options) @@ -239,11 +233,9 @@ def get_code_system_detail( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.construe.get_code_system_detail( codesystem="ICD-10-CM", version="2025", @@ -283,11 +275,9 @@ def delete_custom_code_system( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.construe.delete_custom_code_system( codesystem="CUSTOM_CODES", version="version", @@ -328,11 +318,9 @@ def export_custom_code_system( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.construe.export_custom_code_system( codesystem="CUSTOM_CODES", version="version", @@ -381,11 +369,9 @@ def list_codes_in_a_code_system( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.construe.list_codes_in_a_code_system( codesystem="ICD-10-CM", version="2025", @@ -432,11 +418,9 @@ def get_a_specific_code( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.construe.get_a_specific_code( codesystem="ICD-10-CM", code_id="E11.65", @@ -502,11 +486,9 @@ def semantic_search_embedding_based( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.construe.semantic_search_embedding_based( codesystem="ICD-10-CM", text="patient has trouble breathing at night and wakes up gasping", @@ -554,16 +536,14 @@ def submit_feedback_on_extraction_results( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient from phenoml.construe import ( ExtractCodesResult, ExtractedCodeResult, ExtractRequestSystem, ) - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.construe.submit_feedback_on_extraction_results( text="Patient has type 2 diabetes with hyperglycemia", received_result=ExtractCodesResult( @@ -656,11 +636,9 @@ def terminology_server_text_search( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.construe.terminology_server_text_search( codesystem="ICD-10-CM", q="E11.65", @@ -762,11 +740,9 @@ async def upload_code_system( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -828,11 +804,9 @@ async def extract_codes( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -868,11 +842,9 @@ async def list_available_code_systems( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -914,11 +886,9 @@ async def get_code_system_detail( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -966,11 +936,9 @@ async def delete_custom_code_system( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -1019,11 +987,9 @@ async def export_custom_code_system( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -1080,11 +1046,9 @@ async def list_codes_in_a_code_system( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -1139,11 +1103,9 @@ async def get_a_specific_code( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -1217,11 +1179,9 @@ async def semantic_search_embedding_based( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -1277,16 +1237,14 @@ async def submit_feedback_on_extraction_results( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient from phenoml.construe import ( ExtractCodesResult, ExtractedCodeResult, ExtractRequestSystem, ) - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -1387,11 +1345,9 @@ async def terminology_server_text_search( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: diff --git a/src/phenoml/core/__init__.py b/src/phenoml/core/__init__.py index 9a33e23..956b67c 100644 --- a/src/phenoml/core/__init__.py +++ b/src/phenoml/core/__init__.py @@ -8,11 +8,12 @@ if typing.TYPE_CHECKING: from .api_error import ApiError from .client_wrapper import AsyncClientWrapper, BaseClientWrapper, SyncClientWrapper - from .datetime_utils import serialize_datetime + from .datetime_utils import Rfc2822DateTime, parse_rfc2822_datetime, serialize_datetime from .file import File, convert_file_dict_to_httpx_tuples, with_content_type from .http_client import AsyncHttpClient, HttpClient from .http_response import AsyncHttpResponse, HttpResponse from .jsonable_encoder import jsonable_encoder + from .logging import ConsoleLogger, ILogger, LogConfig, LogLevel, Logger, create_logger from .pydantic_utilities import ( IS_PYDANTIC_V2, UniversalBaseModel, @@ -32,20 +33,28 @@ "AsyncHttpClient": ".http_client", "AsyncHttpResponse": ".http_response", "BaseClientWrapper": ".client_wrapper", + "ConsoleLogger": ".logging", "FieldMetadata": ".serialization", "File": ".file", "HttpClient": ".http_client", "HttpResponse": ".http_response", + "ILogger": ".logging", "IS_PYDANTIC_V2": ".pydantic_utilities", + "LogConfig": ".logging", + "LogLevel": ".logging", + "Logger": ".logging", "RequestOptions": ".request_options", + "Rfc2822DateTime": ".datetime_utils", "SyncClientWrapper": ".client_wrapper", "UniversalBaseModel": ".pydantic_utilities", "UniversalRootModel": ".pydantic_utilities", "convert_and_respect_annotation_metadata": ".serialization", "convert_file_dict_to_httpx_tuples": ".file", + "create_logger": ".logging", "encode_query": ".query_encoder", "jsonable_encoder": ".jsonable_encoder", "parse_obj_as": ".pydantic_utilities", + "parse_rfc2822_datetime": ".datetime_utils", "remove_none_from_dict": ".remove_none_from_dict", "serialize_datetime": ".datetime_utils", "universal_field_validator": ".pydantic_utilities", @@ -82,20 +91,28 @@ def __dir__(): "AsyncHttpClient", "AsyncHttpResponse", "BaseClientWrapper", + "ConsoleLogger", "FieldMetadata", "File", "HttpClient", "HttpResponse", + "ILogger", "IS_PYDANTIC_V2", + "LogConfig", + "LogLevel", + "Logger", "RequestOptions", + "Rfc2822DateTime", "SyncClientWrapper", "UniversalBaseModel", "UniversalRootModel", "convert_and_respect_annotation_metadata", "convert_file_dict_to_httpx_tuples", + "create_logger", "encode_query", "jsonable_encoder", "parse_obj_as", + "parse_rfc2822_datetime", "remove_none_from_dict", "serialize_datetime", "universal_field_validator", diff --git a/src/phenoml/core/client_wrapper.py b/src/phenoml/core/client_wrapper.py index 5b19509..810a8df 100644 --- a/src/phenoml/core/client_wrapper.py +++ b/src/phenoml/core/client_wrapper.py @@ -4,35 +4,44 @@ import httpx from .http_client import AsyncHttpClient, HttpClient +from .logging import LogConfig, Logger class BaseClientWrapper: def __init__( self, *, - token: typing.Union[str, typing.Callable[[], str]], + token: typing.Optional[typing.Union[str, typing.Callable[[], str]]] = None, headers: typing.Optional[typing.Dict[str, str]] = None, base_url: str, timeout: typing.Optional[float] = None, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, ): self._token = token self._headers = headers self._base_url = base_url self._timeout = timeout + self._logging = logging def get_headers(self) -> typing.Dict[str, str]: + import platform + headers: typing.Dict[str, str] = { - "User-Agent": "phenoml/7.3.0", + "User-Agent": "phenoml/7.3.1", "X-Fern-Language": "Python", + "X-Fern-Runtime": f"python/{platform.python_version()}", + "X-Fern-Platform": f"{platform.system().lower()}/{platform.release()}", "X-Fern-SDK-Name": "phenoml", - "X-Fern-SDK-Version": "7.3.0", + "X-Fern-SDK-Version": "7.3.1", **(self.get_custom_headers() or {}), } - headers["Authorization"] = f"Bearer {self._get_token()}" + token = self._get_token() + if token is not None: + headers["Authorization"] = f"Bearer {token}" return headers - def _get_token(self) -> str: - if isinstance(self._token, str): + def _get_token(self) -> typing.Optional[str]: + if isinstance(self._token, str) or self._token is None: return self._token else: return self._token() @@ -51,18 +60,20 @@ class SyncClientWrapper(BaseClientWrapper): def __init__( self, *, - token: typing.Union[str, typing.Callable[[], str]], + token: typing.Optional[typing.Union[str, typing.Callable[[], str]]] = None, headers: typing.Optional[typing.Dict[str, str]] = None, base_url: str, timeout: typing.Optional[float] = None, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, httpx_client: httpx.Client, ): - super().__init__(token=token, headers=headers, base_url=base_url, timeout=timeout) + super().__init__(token=token, headers=headers, base_url=base_url, timeout=timeout, logging=logging) self.httpx_client = HttpClient( httpx_client=httpx_client, base_headers=self.get_headers, base_timeout=self.get_timeout, base_url=self.get_base_url, + logging_config=self._logging, ) @@ -70,16 +81,28 @@ class AsyncClientWrapper(BaseClientWrapper): def __init__( self, *, - token: typing.Union[str, typing.Callable[[], str]], + token: typing.Optional[typing.Union[str, typing.Callable[[], str]]] = None, headers: typing.Optional[typing.Dict[str, str]] = None, base_url: str, timeout: typing.Optional[float] = None, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, + async_token: typing.Optional[typing.Callable[[], typing.Awaitable[str]]] = None, httpx_client: httpx.AsyncClient, ): - super().__init__(token=token, headers=headers, base_url=base_url, timeout=timeout) + super().__init__(token=token, headers=headers, base_url=base_url, timeout=timeout, logging=logging) + self._async_token = async_token self.httpx_client = AsyncHttpClient( httpx_client=httpx_client, base_headers=self.get_headers, base_timeout=self.get_timeout, base_url=self.get_base_url, + async_base_headers=self.async_get_headers, + logging_config=self._logging, ) + + async def async_get_headers(self) -> typing.Dict[str, str]: + headers = self.get_headers() + if self._async_token is not None: + token = await self._async_token() + headers["Authorization"] = f"Bearer {token}" + return headers diff --git a/src/phenoml/core/datetime_utils.py b/src/phenoml/core/datetime_utils.py index 7c9864a..a12b2ad 100644 --- a/src/phenoml/core/datetime_utils.py +++ b/src/phenoml/core/datetime_utils.py @@ -1,6 +1,48 @@ # This file was auto-generated by Fern from our API Definition. import datetime as dt +from email.utils import parsedate_to_datetime +from typing import Any + +import pydantic + +IS_PYDANTIC_V2 = pydantic.VERSION.startswith("2.") + + +def parse_rfc2822_datetime(v: Any) -> dt.datetime: + """ + Parse an RFC 2822 datetime string (e.g., "Wed, 02 Oct 2002 13:00:00 GMT") + into a datetime object. If the value is already a datetime, return it as-is. + Falls back to ISO 8601 parsing if RFC 2822 parsing fails. + """ + if isinstance(v, dt.datetime): + return v + if isinstance(v, str): + try: + return parsedate_to_datetime(v) + except Exception: + pass + # Fallback to ISO 8601 parsing + return dt.datetime.fromisoformat(v.replace("Z", "+00:00")) + raise ValueError(f"Expected str or datetime, got {type(v)}") + + +class Rfc2822DateTime(dt.datetime): + """A datetime subclass that parses RFC 2822 date strings. + + On Pydantic V1, uses __get_validators__ for pre-validation. + On Pydantic V2, uses __get_pydantic_core_schema__ for BeforeValidator-style parsing. + """ + + @classmethod + def __get_validators__(cls): # type: ignore[no-untyped-def] + yield parse_rfc2822_datetime + + @classmethod + def __get_pydantic_core_schema__(cls, _source_type: Any, _handler: Any) -> Any: # type: ignore[override] + from pydantic_core import core_schema + + return core_schema.no_info_before_validator_function(parse_rfc2822_datetime, core_schema.datetime_schema()) def serialize_datetime(v: dt.datetime) -> str: diff --git a/src/phenoml/core/http_client.py b/src/phenoml/core/http_client.py index e4173f9..ee93758 100644 --- a/src/phenoml/core/http_client.py +++ b/src/phenoml/core/http_client.py @@ -5,7 +5,6 @@ import re import time import typing -import urllib.parse from contextlib import asynccontextmanager, contextmanager from random import random @@ -13,14 +12,15 @@ from .file import File, convert_file_dict_to_httpx_tuples from .force_multipart import FORCE_MULTIPART from .jsonable_encoder import jsonable_encoder +from .logging import LogConfig, Logger, create_logger from .query_encoder import encode_query -from .remove_none_from_dict import remove_none_from_dict +from .remove_none_from_dict import remove_none_from_dict as remove_none_from_dict from .request_options import RequestOptions from httpx._types import RequestFiles -INITIAL_RETRY_DELAY_SECONDS = 0.5 -MAX_RETRY_DELAY_SECONDS = 10 -MAX_RETRY_DELAY_SECONDS_FROM_HEADER = 30 +INITIAL_RETRY_DELAY_SECONDS = 1.0 +MAX_RETRY_DELAY_SECONDS = 60.0 +JITTER_FACTOR = 0.2 # 20% random jitter def _parse_retry_after(response_headers: httpx.Headers) -> typing.Optional[float]: @@ -64,6 +64,38 @@ def _parse_retry_after(response_headers: httpx.Headers) -> typing.Optional[float return seconds +def _add_positive_jitter(delay: float) -> float: + """Add positive jitter (0-20%) to prevent thundering herd.""" + jitter_multiplier = 1 + random() * JITTER_FACTOR + return delay * jitter_multiplier + + +def _add_symmetric_jitter(delay: float) -> float: + """Add symmetric jitter (ยฑ10%) for exponential backoff.""" + jitter_multiplier = 1 + (random() - 0.5) * JITTER_FACTOR + return delay * jitter_multiplier + + +def _parse_x_ratelimit_reset(response_headers: httpx.Headers) -> typing.Optional[float]: + """ + Parse the X-RateLimit-Reset header (Unix timestamp in seconds). + Returns seconds to wait, or None if header is missing/invalid. + """ + reset_time_str = response_headers.get("x-ratelimit-reset") + if reset_time_str is None: + return None + + try: + reset_time = int(reset_time_str) + delay = reset_time - time.time() + if delay > 0: + return delay + except (ValueError, TypeError): + pass + + return None + + def _retry_timeout(response: httpx.Response, retries: int) -> float: """ Determine the amount of time to wait before retrying a request. @@ -71,17 +103,19 @@ def _retry_timeout(response: httpx.Response, retries: int) -> float: with a jitter to determine the number of seconds to wait. """ - # If the API asks us to wait a certain amount of time (and it's a reasonable amount), just do what it says. + # 1. Check Retry-After header first retry_after = _parse_retry_after(response.headers) - if retry_after is not None and retry_after <= MAX_RETRY_DELAY_SECONDS_FROM_HEADER: - return retry_after + if retry_after is not None and retry_after > 0: + return min(retry_after, MAX_RETRY_DELAY_SECONDS) - # Apply exponential backoff, capped at MAX_RETRY_DELAY_SECONDS. - retry_delay = min(INITIAL_RETRY_DELAY_SECONDS * pow(2.0, retries), MAX_RETRY_DELAY_SECONDS) + # 2. Check X-RateLimit-Reset header (with positive jitter) + ratelimit_reset = _parse_x_ratelimit_reset(response.headers) + if ratelimit_reset is not None: + return _add_positive_jitter(min(ratelimit_reset, MAX_RETRY_DELAY_SECONDS)) - # Add a randomness / jitter to the retry delay to avoid overwhelming the server with retries. - timeout = retry_delay * (1 - 0.25 * random()) - return timeout if timeout >= 0 else 0 + # 3. Fall back to exponential backoff (with symmetric jitter) + backoff = min(INITIAL_RETRY_DELAY_SECONDS * pow(2.0, retries), MAX_RETRY_DELAY_SECONDS) + return _add_symmetric_jitter(backoff) def _should_retry(response: httpx.Response) -> bool: @@ -89,6 +123,71 @@ def _should_retry(response: httpx.Response) -> bool: return response.status_code >= 500 or response.status_code in retryable_400s +_SENSITIVE_HEADERS = frozenset( + { + "authorization", + "www-authenticate", + "x-api-key", + "api-key", + "apikey", + "x-api-token", + "x-auth-token", + "auth-token", + "cookie", + "set-cookie", + "proxy-authorization", + "proxy-authenticate", + "x-csrf-token", + "x-xsrf-token", + "x-session-token", + "x-access-token", + } +) + + +def _redact_headers(headers: typing.Dict[str, str]) -> typing.Dict[str, str]: + return {k: ("[REDACTED]" if k.lower() in _SENSITIVE_HEADERS else v) for k, v in headers.items()} + + +def _build_url(base_url: str, path: typing.Optional[str]) -> str: + """ + Build a full URL by joining a base URL with a path. + + This function correctly handles base URLs that contain path prefixes (e.g., tenant-based URLs) + by using string concatenation instead of urllib.parse.urljoin(), which would incorrectly + strip path components when the path starts with '/'. + + Example: + >>> _build_url("https://cloud.example.com/org/tenant/api", "/users") + 'https://cloud.example.com/org/tenant/api/users' + + Args: + base_url: The base URL, which may contain path prefixes. + path: The path to append. Can be None or empty string. + + Returns: + The full URL with base_url and path properly joined. + """ + if not path: + return base_url + return f"{base_url.rstrip('/')}/{path.lstrip('/')}" + + +def _maybe_filter_none_from_multipart_data( + data: typing.Optional[typing.Any], + request_files: typing.Optional[RequestFiles], + force_multipart: typing.Optional[bool], +) -> typing.Optional[typing.Any]: + """ + Filter None values from data body for multipart/form requests. + This prevents httpx from converting None to empty strings in multipart encoding. + Only applies when files are present or force_multipart is True. + """ + if data is not None and isinstance(data, typing.Mapping) and (request_files or force_multipart): + return remove_none_from_dict(data) + return data + + def remove_omit_from_dict( original: typing.Dict[str, typing.Optional[typing.Any]], omit: typing.Optional[typing.Any], @@ -143,8 +242,19 @@ def get_request_body( # If both data and json are None, we send json data in the event extra properties are specified json_body = maybe_filter_request_body(json, request_options, omit) - # If you have an empty JSON body, you should just send None - return (json_body if json_body != {} else None), data_body if data_body != {} else None + has_additional_body_parameters = bool( + request_options is not None and request_options.get("additional_body_parameters") + ) + + # Only collapse empty dict to None when the body was not explicitly provided + # and there are no additional body parameters. This preserves explicit empty + # bodies (e.g., when an endpoint has a request body type but all fields are optional). + if json_body == {} and json is None and not has_additional_body_parameters: + json_body = None + if data_body == {} and data is None and not has_additional_body_parameters: + data_body = None + + return json_body, data_body class HttpClient: @@ -155,11 +265,13 @@ def __init__( base_timeout: typing.Callable[[], typing.Optional[float]], base_headers: typing.Callable[[], typing.Dict[str, str]], base_url: typing.Optional[typing.Callable[[], str]] = None, + logging_config: typing.Optional[typing.Union[LogConfig, Logger]] = None, ): self.base_url = base_url self.base_timeout = base_timeout self.base_headers = base_headers self.httpx_client = httpx_client + self.logger = create_logger(logging_config) def get_base_url(self, maybe_base_url: typing.Optional[str]) -> str: base_url = maybe_base_url @@ -188,7 +300,7 @@ def request( ] = None, headers: typing.Optional[typing.Dict[str, typing.Any]] = None, request_options: typing.Optional[RequestOptions] = None, - retries: int = 2, + retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, ) -> httpx.Response: @@ -210,35 +322,53 @@ def request( if (request_files is None or len(request_files) == 0) and force_multipart: request_files = FORCE_MULTIPART - response = self.httpx_client.request( - method=method, - url=urllib.parse.urljoin(f"{base_url}/", path), - headers=jsonable_encoder( + data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) + + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( remove_none_from_dict( - { - **self.base_headers(), - **(headers if headers is not None else {}), - **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}), - } - ) - ), - params=encode_query( - jsonable_encoder( - remove_none_from_dict( - remove_omit_from_dict( - { - **(params if params is not None else {}), - **( - request_options.get("additional_query_parameters", {}) or {} - if request_options is not None - else {} - ), - }, - omit, - ) + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) or {} + if request_options is not None + else {} + ), + }, + omit, ) ) - ), + ) + ) + + _request_url = _build_url(base_url, path) + _request_headers = jsonable_encoder( + remove_none_from_dict( + { + **self.base_headers(), + **(headers if headers is not None else {}), + **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}), + } + ) + ) + + if self.logger.is_debug(): + self.logger.debug( + "Making HTTP request", + method=method, + url=_request_url, + headers=_redact_headers(_request_headers), + has_body=json_body is not None or data_body is not None, + ) + + response = self.httpx_client.request( + method=method, + url=_request_url, + headers=_request_headers, + params=_encoded_params if _encoded_params else None, json=json_body, data=data_body, content=content, @@ -246,9 +376,9 @@ def request( timeout=timeout, ) - max_retries: int = request_options.get("max_retries", 0) if request_options is not None else 0 + max_retries: int = request_options.get("max_retries", 2) if request_options is not None else 2 if _should_retry(response=response): - if max_retries > retries: + if retries < max_retries: time.sleep(_retry_timeout(response=response, retries=retries)) return self.request( path=path, @@ -264,6 +394,24 @@ def request( omit=omit, ) + if self.logger.is_debug(): + if 200 <= response.status_code < 400: + self.logger.debug( + "HTTP request succeeded", + method=method, + url=_request_url, + status_code=response.status_code, + ) + + if self.logger.is_error(): + if response.status_code >= 400: + self.logger.error( + "HTTP request failed with error status", + method=method, + url=_request_url, + status_code=response.status_code, + ) + return response @contextmanager @@ -285,7 +433,7 @@ def stream( ] = None, headers: typing.Optional[typing.Dict[str, typing.Any]] = None, request_options: typing.Optional[RequestOptions] = None, - retries: int = 2, + retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, ) -> typing.Iterator[httpx.Response]: @@ -307,35 +455,52 @@ def stream( json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit) - with self.httpx_client.stream( - method=method, - url=urllib.parse.urljoin(f"{base_url}/", path), - headers=jsonable_encoder( + data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) + + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( remove_none_from_dict( - { - **self.base_headers(), - **(headers if headers is not None else {}), - **(request_options.get("additional_headers", {}) if request_options is not None else {}), - } - ) - ), - params=encode_query( - jsonable_encoder( - remove_none_from_dict( - remove_omit_from_dict( - { - **(params if params is not None else {}), - **( - request_options.get("additional_query_parameters", {}) - if request_options is not None - else {} - ), - }, - omit, - ) + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) + if request_options is not None + else {} + ), + }, + omit, ) ) - ), + ) + ) + + _request_url = _build_url(base_url, path) + _request_headers = jsonable_encoder( + remove_none_from_dict( + { + **self.base_headers(), + **(headers if headers is not None else {}), + **(request_options.get("additional_headers", {}) if request_options is not None else {}), + } + ) + ) + + if self.logger.is_debug(): + self.logger.debug( + "Making streaming HTTP request", + method=method, + url=_request_url, + headers=_redact_headers(_request_headers), + ) + + with self.httpx_client.stream( + method=method, + url=_request_url, + headers=_request_headers, + params=_encoded_params if _encoded_params else None, json=json_body, data=data_body, content=content, @@ -353,11 +518,20 @@ def __init__( base_timeout: typing.Callable[[], typing.Optional[float]], base_headers: typing.Callable[[], typing.Dict[str, str]], base_url: typing.Optional[typing.Callable[[], str]] = None, + async_base_headers: typing.Optional[typing.Callable[[], typing.Awaitable[typing.Dict[str, str]]]] = None, + logging_config: typing.Optional[typing.Union[LogConfig, Logger]] = None, ): self.base_url = base_url self.base_timeout = base_timeout self.base_headers = base_headers + self.async_base_headers = async_base_headers self.httpx_client = httpx_client + self.logger = create_logger(logging_config) + + async def _get_headers(self) -> typing.Dict[str, str]: + if self.async_base_headers is not None: + return await self.async_base_headers() + return self.base_headers() def get_base_url(self, maybe_base_url: typing.Optional[str]) -> str: base_url = maybe_base_url @@ -386,7 +560,7 @@ async def request( ] = None, headers: typing.Optional[typing.Dict[str, typing.Any]] = None, request_options: typing.Optional[RequestOptions] = None, - retries: int = 2, + retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, ) -> httpx.Response: @@ -408,36 +582,56 @@ async def request( json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit) - # Add the input to each of these and do None-safety checks - response = await self.httpx_client.request( - method=method, - url=urllib.parse.urljoin(f"{base_url}/", path), - headers=jsonable_encoder( + data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) + + # Get headers (supports async token providers) + _headers = await self._get_headers() + + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( remove_none_from_dict( - { - **self.base_headers(), - **(headers if headers is not None else {}), - **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}), - } - ) - ), - params=encode_query( - jsonable_encoder( - remove_none_from_dict( - remove_omit_from_dict( - { - **(params if params is not None else {}), - **( - request_options.get("additional_query_parameters", {}) or {} - if request_options is not None - else {} - ), - }, - omit, - ) + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) or {} + if request_options is not None + else {} + ), + }, + omit, ) ) - ), + ) + ) + + _request_url = _build_url(base_url, path) + _request_headers = jsonable_encoder( + remove_none_from_dict( + { + **_headers, + **(headers if headers is not None else {}), + **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}), + } + ) + ) + + if self.logger.is_debug(): + self.logger.debug( + "Making HTTP request", + method=method, + url=_request_url, + headers=_redact_headers(_request_headers), + has_body=json_body is not None or data_body is not None, + ) + + response = await self.httpx_client.request( + method=method, + url=_request_url, + headers=_request_headers, + params=_encoded_params if _encoded_params else None, json=json_body, data=data_body, content=content, @@ -445,9 +639,9 @@ async def request( timeout=timeout, ) - max_retries: int = request_options.get("max_retries", 0) if request_options is not None else 0 + max_retries: int = request_options.get("max_retries", 2) if request_options is not None else 2 if _should_retry(response=response): - if max_retries > retries: + if retries < max_retries: await asyncio.sleep(_retry_timeout(response=response, retries=retries)) return await self.request( path=path, @@ -462,6 +656,25 @@ async def request( retries=retries + 1, omit=omit, ) + + if self.logger.is_debug(): + if 200 <= response.status_code < 400: + self.logger.debug( + "HTTP request succeeded", + method=method, + url=_request_url, + status_code=response.status_code, + ) + + if self.logger.is_error(): + if response.status_code >= 400: + self.logger.error( + "HTTP request failed with error status", + method=method, + url=_request_url, + status_code=response.status_code, + ) + return response @asynccontextmanager @@ -483,7 +696,7 @@ async def stream( ] = None, headers: typing.Optional[typing.Dict[str, typing.Any]] = None, request_options: typing.Optional[RequestOptions] = None, - retries: int = 2, + retries: int = 0, omit: typing.Optional[typing.Any] = None, force_multipart: typing.Optional[bool] = None, ) -> typing.AsyncIterator[httpx.Response]: @@ -505,35 +718,55 @@ async def stream( json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit) - async with self.httpx_client.stream( - method=method, - url=urllib.parse.urljoin(f"{base_url}/", path), - headers=jsonable_encoder( + data_body = _maybe_filter_none_from_multipart_data(data_body, request_files, force_multipart) + + # Get headers (supports async token providers) + _headers = await self._get_headers() + + # Compute encoded params separately to avoid passing empty list to httpx + # (httpx strips existing query params from URL when params=[] is passed) + _encoded_params = encode_query( + jsonable_encoder( remove_none_from_dict( - { - **self.base_headers(), - **(headers if headers is not None else {}), - **(request_options.get("additional_headers", {}) if request_options is not None else {}), - } - ) - ), - params=encode_query( - jsonable_encoder( - remove_none_from_dict( - remove_omit_from_dict( - { - **(params if params is not None else {}), - **( - request_options.get("additional_query_parameters", {}) - if request_options is not None - else {} - ), - }, - omit=omit, - ) + remove_omit_from_dict( + { + **(params if params is not None else {}), + **( + request_options.get("additional_query_parameters", {}) + if request_options is not None + else {} + ), + }, + omit=omit, ) ) - ), + ) + ) + + _request_url = _build_url(base_url, path) + _request_headers = jsonable_encoder( + remove_none_from_dict( + { + **_headers, + **(headers if headers is not None else {}), + **(request_options.get("additional_headers", {}) if request_options is not None else {}), + } + ) + ) + + if self.logger.is_debug(): + self.logger.debug( + "Making streaming HTTP request", + method=method, + url=_request_url, + headers=_redact_headers(_request_headers), + ) + + async with self.httpx_client.stream( + method=method, + url=_request_url, + headers=_request_headers, + params=_encoded_params if _encoded_params else None, json=json_body, data=data_body, content=content, diff --git a/src/phenoml/core/http_response.py b/src/phenoml/core/http_response.py index 2479747..00bb109 100644 --- a/src/phenoml/core/http_response.py +++ b/src/phenoml/core/http_response.py @@ -9,7 +9,7 @@ class BaseHttpResponse: - """Minimalist HTTP response wrapper that exposes response headers.""" + """Minimalist HTTP response wrapper that exposes response headers and status code.""" _response: httpx.Response @@ -20,6 +20,10 @@ def __init__(self, response: httpx.Response): def headers(self) -> Dict[str, str]: return dict(self._response.headers) + @property + def status_code(self) -> int: + return self._response.status_code + class HttpResponse(Generic[T], BaseHttpResponse): """HTTP response wrapper that exposes response headers and data.""" diff --git a/src/phenoml/core/jsonable_encoder.py b/src/phenoml/core/jsonable_encoder.py index afee366..f8beaea 100644 --- a/src/phenoml/core/jsonable_encoder.py +++ b/src/phenoml/core/jsonable_encoder.py @@ -30,6 +30,10 @@ def jsonable_encoder(obj: Any, custom_encoder: Optional[Dict[Any, Callable[[Any], Any]]] = None) -> Any: custom_encoder = custom_encoder or {} + # Generated SDKs use Ellipsis (`...`) as the sentinel value for "OMIT". + # OMIT values should be excluded from serialized payloads. + if obj is Ellipsis: + return None if custom_encoder: if type(obj) in custom_encoder: return custom_encoder[type(obj)](obj) @@ -70,6 +74,8 @@ def jsonable_encoder(obj: Any, custom_encoder: Optional[Dict[Any, Callable[[Any] allowed_keys = set(obj.keys()) for key, value in obj.items(): if key in allowed_keys: + if value is Ellipsis: + continue encoded_key = jsonable_encoder(key, custom_encoder=custom_encoder) encoded_value = jsonable_encoder(value, custom_encoder=custom_encoder) encoded_dict[encoded_key] = encoded_value @@ -77,6 +83,8 @@ def jsonable_encoder(obj: Any, custom_encoder: Optional[Dict[Any, Callable[[Any] if isinstance(obj, (list, set, frozenset, GeneratorType, tuple)): encoded_list = [] for item in obj: + if item is Ellipsis: + continue encoded_list.append(jsonable_encoder(item, custom_encoder=custom_encoder)) return encoded_list diff --git a/src/phenoml/core/logging.py b/src/phenoml/core/logging.py new file mode 100644 index 0000000..e5e5724 --- /dev/null +++ b/src/phenoml/core/logging.py @@ -0,0 +1,107 @@ +# This file was auto-generated by Fern from our API Definition. + +import logging +import typing + +LogLevel = typing.Literal["debug", "info", "warn", "error"] + +_LOG_LEVEL_MAP: typing.Dict[LogLevel, int] = { + "debug": 1, + "info": 2, + "warn": 3, + "error": 4, +} + + +class ILogger(typing.Protocol): + def debug(self, message: str, **kwargs: typing.Any) -> None: ... + def info(self, message: str, **kwargs: typing.Any) -> None: ... + def warn(self, message: str, **kwargs: typing.Any) -> None: ... + def error(self, message: str, **kwargs: typing.Any) -> None: ... + + +class ConsoleLogger: + _logger: logging.Logger + + def __init__(self) -> None: + self._logger = logging.getLogger("fern") + if not self._logger.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(levelname)s - %(message)s")) + self._logger.addHandler(handler) + self._logger.setLevel(logging.DEBUG) + + def debug(self, message: str, **kwargs: typing.Any) -> None: + self._logger.debug(message, extra=kwargs) + + def info(self, message: str, **kwargs: typing.Any) -> None: + self._logger.info(message, extra=kwargs) + + def warn(self, message: str, **kwargs: typing.Any) -> None: + self._logger.warning(message, extra=kwargs) + + def error(self, message: str, **kwargs: typing.Any) -> None: + self._logger.error(message, extra=kwargs) + + +class LogConfig(typing.TypedDict, total=False): + level: LogLevel + logger: ILogger + silent: bool + + +class Logger: + _level: int + _logger: ILogger + _silent: bool + + def __init__(self, *, level: LogLevel, logger: ILogger, silent: bool) -> None: + self._level = _LOG_LEVEL_MAP[level] + self._logger = logger + self._silent = silent + + def _should_log(self, level: LogLevel) -> bool: + return not self._silent and self._level <= _LOG_LEVEL_MAP[level] + + def is_debug(self) -> bool: + return self._should_log("debug") + + def is_info(self) -> bool: + return self._should_log("info") + + def is_warn(self) -> bool: + return self._should_log("warn") + + def is_error(self) -> bool: + return self._should_log("error") + + def debug(self, message: str, **kwargs: typing.Any) -> None: + if self.is_debug(): + self._logger.debug(message, **kwargs) + + def info(self, message: str, **kwargs: typing.Any) -> None: + if self.is_info(): + self._logger.info(message, **kwargs) + + def warn(self, message: str, **kwargs: typing.Any) -> None: + if self.is_warn(): + self._logger.warn(message, **kwargs) + + def error(self, message: str, **kwargs: typing.Any) -> None: + if self.is_error(): + self._logger.error(message, **kwargs) + + +_default_logger: Logger = Logger(level="info", logger=ConsoleLogger(), silent=True) + + +def create_logger(config: typing.Optional[typing.Union[LogConfig, Logger]] = None) -> Logger: + if config is None: + return _default_logger + if isinstance(config, Logger): + return config + return Logger( + level=config.get("level", "info"), + logger=config.get("logger", ConsoleLogger()), + silent=config.get("silent", True), + ) diff --git a/src/phenoml/core/oauth_token_provider.py b/src/phenoml/core/oauth_token_provider.py new file mode 100644 index 0000000..14ffe0f --- /dev/null +++ b/src/phenoml/core/oauth_token_provider.py @@ -0,0 +1,73 @@ +# This file was auto-generated by Fern from our API Definition. + +import asyncio +import datetime as dt +import threading +import typing +from asyncio import Lock as asyncio_Lock +from threading import Lock as threading_Lock + +from ..authtoken.auth.client import AsyncAuthClient, AuthClient +from .client_wrapper import AsyncClientWrapper, SyncClientWrapper + + +class OAuthTokenProvider: + BUFFER_IN_MINUTES = 2 + + def __init__(self, *, client_id: str, client_secret: str, client_wrapper: SyncClientWrapper): + self._client_id = client_id + self._client_secret = client_secret + self._access_token: typing.Optional[str] = None + self._expires_at: dt.datetime = dt.datetime.now() + self._auth_client = AuthClient(client_wrapper=client_wrapper) + self._lock: threading_Lock = threading.Lock() + + def get_token(self) -> str: + if self._access_token and self._expires_at > dt.datetime.now(): + return self._access_token + with self._lock: + if self._access_token and self._expires_at > dt.datetime.now(): + return self._access_token + return self._refresh() + + def _refresh(self) -> str: + token_response = self._auth_client.get_token(client_id=self._client_id, client_secret=self._client_secret) + self._access_token = token_response.access_token + self._expires_at = self._get_expires_at( + expires_in_seconds=token_response.expires_in, buffer_in_minutes=self.BUFFER_IN_MINUTES + ) + return self._access_token + + def _get_expires_at(self, *, expires_in_seconds: int, buffer_in_minutes: int): + return dt.datetime.now() + dt.timedelta(seconds=expires_in_seconds) - dt.timedelta(minutes=buffer_in_minutes) + + +class AsyncOAuthTokenProvider: + BUFFER_IN_MINUTES = 2 + + def __init__(self, *, client_id: str, client_secret: str, client_wrapper: AsyncClientWrapper): + self._client_id = client_id + self._client_secret = client_secret + self._access_token: typing.Optional[str] = None + self._expires_at: dt.datetime = dt.datetime.now() + self._auth_client = AsyncAuthClient(client_wrapper=client_wrapper) + self._lock: asyncio_Lock = asyncio.Lock() + + async def get_token(self) -> str: + if self._access_token and self._expires_at > dt.datetime.now(): + return self._access_token + async with self._lock: + if self._access_token and self._expires_at > dt.datetime.now(): + return self._access_token + return await self._refresh() + + async def _refresh(self) -> str: + token_response = await self._auth_client.get_token(client_id=self._client_id, client_secret=self._client_secret) + self._access_token = token_response.access_token + self._expires_at = self._get_expires_at( + expires_in_seconds=token_response.expires_in, buffer_in_minutes=self.BUFFER_IN_MINUTES + ) + return self._access_token + + def _get_expires_at(self, *, expires_in_seconds: int, buffer_in_minutes: int): + return dt.datetime.now() + dt.timedelta(seconds=expires_in_seconds) - dt.timedelta(minutes=buffer_in_minutes) diff --git a/src/phenoml/core/pydantic_utilities.py b/src/phenoml/core/pydantic_utilities.py index 185e5c4..831aadc 100644 --- a/src/phenoml/core/pydantic_utilities.py +++ b/src/phenoml/core/pydantic_utilities.py @@ -2,22 +2,64 @@ # nopycln: file import datetime as dt +import inspect +import json +import logging from collections import defaultdict -from typing import Any, Callable, ClassVar, Dict, List, Mapping, Optional, Set, Tuple, Type, TypeVar, Union, cast +from dataclasses import asdict +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + Dict, + List, + Mapping, + Optional, + Set, + Tuple, + Type, + TypeVar, + Union, + cast, +) import pydantic +import typing_extensions + +_logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from .http_sse._models import ServerSentEvent IS_PYDANTIC_V2 = pydantic.VERSION.startswith("2.") if IS_PYDANTIC_V2: - from pydantic.v1.datetime_parse import parse_date as parse_date - from pydantic.v1.datetime_parse import parse_datetime as parse_datetime - from pydantic.v1.fields import ModelField as ModelField - from pydantic.v1.json import ENCODERS_BY_TYPE as encoders_by_type # type: ignore[attr-defined] - from pydantic.v1.typing import get_args as get_args - from pydantic.v1.typing import get_origin as get_origin - from pydantic.v1.typing import is_literal_type as is_literal_type - from pydantic.v1.typing import is_union as is_union + import warnings + + _datetime_adapter = pydantic.TypeAdapter(dt.datetime) # type: ignore[attr-defined] + _date_adapter = pydantic.TypeAdapter(dt.date) # type: ignore[attr-defined] + + def parse_datetime(value: Any) -> dt.datetime: # type: ignore[misc] + if isinstance(value, dt.datetime): + return value + return _datetime_adapter.validate_python(value) + + def parse_date(value: Any) -> dt.date: # type: ignore[misc] + if isinstance(value, dt.datetime): + return value.date() + if isinstance(value, dt.date): + return value + return _date_adapter.validate_python(value) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + from pydantic.v1.fields import ModelField as ModelField + from pydantic.v1.json import ENCODERS_BY_TYPE as encoders_by_type # type: ignore[attr-defined] + from pydantic.v1.typing import get_args as get_args + from pydantic.v1.typing import get_origin as get_origin + from pydantic.v1.typing import is_literal_type as is_literal_type + from pydantic.v1.typing import is_union as is_union else: from pydantic.datetime_parse import parse_date as parse_date # type: ignore[no-redef] from pydantic.datetime_parse import parse_datetime as parse_datetime # type: ignore[no-redef] @@ -36,8 +78,212 @@ Model = TypeVar("Model", bound=pydantic.BaseModel) +def _get_discriminator_and_variants(type_: Type[Any]) -> Tuple[Optional[str], Optional[List[Type[Any]]]]: + """ + Extract the discriminator field name and union variants from a discriminated union type. + Supports Annotated[Union[...], Field(discriminator=...)] patterns. + Returns (discriminator, variants) or (None, None) if not a discriminated union. + """ + origin = typing_extensions.get_origin(type_) + + if origin is typing_extensions.Annotated: + args = typing_extensions.get_args(type_) + if len(args) >= 2: + inner_type = args[0] + # Check annotations for discriminator + discriminator = None + for annotation in args[1:]: + if hasattr(annotation, "discriminator"): + discriminator = getattr(annotation, "discriminator", None) + break + + if discriminator: + inner_origin = typing_extensions.get_origin(inner_type) + if inner_origin is Union: + variants = list(typing_extensions.get_args(inner_type)) + return discriminator, variants + return None, None + + +def _get_field_annotation(model: Type[Any], field_name: str) -> Optional[Type[Any]]: + """Get the type annotation of a field from a Pydantic model.""" + if IS_PYDANTIC_V2: + fields = getattr(model, "model_fields", {}) + field_info = fields.get(field_name) + if field_info: + return cast(Optional[Type[Any]], field_info.annotation) + else: + fields = getattr(model, "__fields__", {}) + field_info = fields.get(field_name) + if field_info: + return cast(Optional[Type[Any]], field_info.outer_type_) + return None + + +def _find_variant_by_discriminator( + variants: List[Type[Any]], + discriminator: str, + discriminator_value: Any, +) -> Optional[Type[Any]]: + """Find the union variant that matches the discriminator value.""" + for variant in variants: + if not (inspect.isclass(variant) and issubclass(variant, pydantic.BaseModel)): + continue + + disc_annotation = _get_field_annotation(variant, discriminator) + if disc_annotation and is_literal_type(disc_annotation): + literal_args = get_args(disc_annotation) + if literal_args and literal_args[0] == discriminator_value: + return variant + return None + + +def _is_string_type(type_: Type[Any]) -> bool: + """Check if a type is str or Optional[str].""" + if type_ is str: + return True + + origin = typing_extensions.get_origin(type_) + if origin is Union: + args = typing_extensions.get_args(type_) + # Optional[str] = Union[str, None] + non_none_args = [a for a in args if a is not type(None)] + if len(non_none_args) == 1 and non_none_args[0] is str: + return True + + return False + + +def parse_sse_obj(sse: "ServerSentEvent", type_: Type[T]) -> T: + """ + Parse a ServerSentEvent into the appropriate type. + + Handles two scenarios based on where the discriminator field is located: + + 1. Data-level discrimination: The discriminator (e.g., 'type') is inside the 'data' payload. + The union describes the data content, not the SSE envelope. + -> Returns: json.loads(data) parsed into the type + + Example: ChatStreamResponse with discriminator='type' + Input: ServerSentEvent(event="message", data='{"type": "content-delta", ...}', id="") + Output: ContentDeltaEvent (parsed from data, SSE envelope stripped) + + 2. Event-level discrimination: The discriminator (e.g., 'event') is at the SSE event level. + The union describes the full SSE event structure. + -> Returns: SSE envelope with 'data' field JSON-parsed only if the variant expects non-string + + Example: JobStreamResponse with discriminator='event' + Input: ServerSentEvent(event="ERROR", data='{"code": "FAILED", ...}', id="123") + Output: JobStreamResponse_Error with data as ErrorData object + + But for variants where data is str (like STATUS_UPDATE): + Input: ServerSentEvent(event="STATUS_UPDATE", data='{"status": "processing"}', id="1") + Output: JobStreamResponse_StatusUpdate with data as string (not parsed) + + Args: + sse: The ServerSentEvent object to parse + type_: The target discriminated union type + + Returns: + The parsed object of type T + + Note: + This function is only available in SDK contexts where http_sse module exists. + """ + sse_event = asdict(sse) + discriminator, variants = _get_discriminator_and_variants(type_) + + if discriminator is None or variants is None: + # Not a discriminated union - parse the data field as JSON + data_value = sse_event.get("data") + if isinstance(data_value, str) and data_value: + try: + parsed_data = json.loads(data_value) + return parse_obj_as(type_, parsed_data) + except json.JSONDecodeError as e: + _logger.warning( + "Failed to parse SSE data field as JSON: %s, data: %s", + e, + data_value[:100] if len(data_value) > 100 else data_value, + ) + return parse_obj_as(type_, sse_event) + + data_value = sse_event.get("data") + + # Check if discriminator is at the top level (event-level discrimination) + if discriminator in sse_event: + # Case 2: Event-level discrimination + # Find the matching variant to check if 'data' field needs JSON parsing + disc_value = sse_event.get(discriminator) + matching_variant = _find_variant_by_discriminator(variants, discriminator, disc_value) + + if matching_variant is not None: + # Check what type the variant expects for 'data' + data_type = _get_field_annotation(matching_variant, "data") + if data_type is not None and not _is_string_type(data_type): + # Variant expects non-string data - parse JSON + if isinstance(data_value, str) and data_value: + try: + parsed_data = json.loads(data_value) + new_object = dict(sse_event) + new_object["data"] = parsed_data + return parse_obj_as(type_, new_object) + except json.JSONDecodeError as e: + _logger.warning( + "Failed to parse SSE data field as JSON for event-level discrimination: %s, data: %s", + e, + data_value[:100] if len(data_value) > 100 else data_value, + ) + # Either no matching variant, data is string type, or JSON parse failed + return parse_obj_as(type_, sse_event) + + else: + # Case 1: Data-level discrimination + # The discriminator is inside the data payload - extract and parse data only + if isinstance(data_value, str) and data_value: + try: + parsed_data = json.loads(data_value) + return parse_obj_as(type_, parsed_data) + except json.JSONDecodeError as e: + _logger.warning( + "Failed to parse SSE data field as JSON for data-level discrimination: %s, data: %s", + e, + data_value[:100] if len(data_value) > 100 else data_value, + ) + return parse_obj_as(type_, sse_event) + + def parse_obj_as(type_: Type[T], object_: Any) -> T: - dealiased_object = convert_and_respect_annotation_metadata(object_=object_, annotation=type_, direction="read") + # convert_and_respect_annotation_metadata is required for TypedDict aliasing. + # + # For Pydantic models, whether we should pre-dealias depends on how the model encodes aliasing: + # - If the model uses real Pydantic aliases (pydantic.Field(alias=...)), then we must pass wire keys through + # unchanged so Pydantic can validate them. + # - If the model encodes aliasing only via FieldMetadata annotations, then we MUST pre-dealias because Pydantic + # will not recognize those aliases during validation. + if inspect.isclass(type_) and issubclass(type_, pydantic.BaseModel): + has_pydantic_aliases = False + if IS_PYDANTIC_V2: + for field_name, field_info in getattr(type_, "model_fields", {}).items(): # type: ignore[attr-defined] + alias = getattr(field_info, "alias", None) + if alias is not None and alias != field_name: + has_pydantic_aliases = True + break + else: + for field in getattr(type_, "__fields__", {}).values(): + alias = getattr(field, "alias", None) + name = getattr(field, "name", None) + if alias is not None and name is not None and alias != name: + has_pydantic_aliases = True + break + + dealiased_object = ( + object_ + if has_pydantic_aliases + else convert_and_respect_annotation_metadata(object_=object_, annotation=type_, direction="read") + ) + else: + dealiased_object = convert_and_respect_annotation_metadata(object_=object_, annotation=type_, direction="read") if IS_PYDANTIC_V2: adapter = pydantic.TypeAdapter(type_) # type: ignore[attr-defined] return adapter.validate_python(dealiased_object) @@ -59,6 +305,43 @@ class UniversalBaseModel(pydantic.BaseModel): protected_namespaces=(), ) + @pydantic.model_validator(mode="before") # type: ignore[attr-defined] + @classmethod + def _coerce_field_names_to_aliases(cls, data: Any) -> Any: + """ + Accept Python field names in input by rewriting them to their Pydantic aliases, + while avoiding silent collisions when a key could refer to multiple fields. + """ + if not isinstance(data, Mapping): + return data + + fields = getattr(cls, "model_fields", {}) # type: ignore[attr-defined] + name_to_alias: Dict[str, str] = {} + alias_to_name: Dict[str, str] = {} + + for name, field_info in fields.items(): + alias = getattr(field_info, "alias", None) or name + name_to_alias[name] = alias + if alias != name: + alias_to_name[alias] = name + + # Detect ambiguous keys: a key that is an alias for one field and a name for another. + ambiguous_keys = set(alias_to_name.keys()).intersection(set(name_to_alias.keys())) + for key in ambiguous_keys: + if key in data and name_to_alias[key] not in data: + raise ValueError( + f"Ambiguous input key '{key}': it is both a field name and an alias. " + "Provide the explicit alias key to disambiguate." + ) + + original_keys = set(data.keys()) + rewritten: Dict[str, Any] = dict(data) + for name, alias in name_to_alias.items(): + if alias != name and name in original_keys and alias not in rewritten: + rewritten[alias] = rewritten.pop(name) + + return rewritten + @pydantic.model_serializer(mode="plain", when_used="json") # type: ignore[attr-defined] def serialize_model(self) -> Any: # type: ignore[name-defined] serialized = self.dict() # type: ignore[attr-defined] @@ -71,6 +354,40 @@ class Config: smart_union = True json_encoders = {dt.datetime: serialize_datetime} + @pydantic.root_validator(pre=True) + def _coerce_field_names_to_aliases(cls, values: Any) -> Any: + """ + Pydantic v1 equivalent of _coerce_field_names_to_aliases. + """ + if not isinstance(values, Mapping): + return values + + fields = getattr(cls, "__fields__", {}) + name_to_alias: Dict[str, str] = {} + alias_to_name: Dict[str, str] = {} + + for name, field in fields.items(): + alias = getattr(field, "alias", None) or name + name_to_alias[name] = alias + if alias != name: + alias_to_name[alias] = name + + ambiguous_keys = set(alias_to_name.keys()).intersection(set(name_to_alias.keys())) + for key in ambiguous_keys: + if key in values and name_to_alias[key] not in values: + raise ValueError( + f"Ambiguous input key '{key}': it is both a field name and an alias. " + "Provide the explicit alias key to disambiguate." + ) + + original_keys = set(values.keys()) + rewritten: Dict[str, Any] = dict(values) + for name, alias in name_to_alias.items(): + if alias != name and name in original_keys and alias not in rewritten: + rewritten[alias] = rewritten.pop(name) + + return rewritten + @classmethod def model_construct(cls: Type["Model"], _fields_set: Optional[Set[str]] = None, **values: Any) -> "Model": dealiased_object = convert_and_respect_annotation_metadata(object_=values, annotation=cls, direction="read") diff --git a/src/phenoml/environment.py b/src/phenoml/environment.py index 3e737a0..c6c699c 100644 --- a/src/phenoml/environment.py +++ b/src/phenoml/environment.py @@ -3,5 +3,5 @@ import enum -class phenomlEnvironment(enum.Enum): +class PhenomlClientEnvironment(enum.Enum): DEFAULT = "https://experiment.app.pheno.ml" diff --git a/src/phenoml/fhir/client.py b/src/phenoml/fhir/client.py index 5afa9bc..7c38ef7 100644 --- a/src/phenoml/fhir/client.py +++ b/src/phenoml/fhir/client.py @@ -85,11 +85,9 @@ def search( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.fhir.search( fhir_provider_id="550e8400-e29b-41d4-a716-446655440000", fhir_path="Patient", @@ -165,11 +163,9 @@ def create( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.fhir.create( fhir_provider_id="550e8400-e29b-41d4-a716-446655440000", fhir_path="Patient", @@ -248,11 +244,9 @@ def upsert( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.fhir.upsert( fhir_provider_id="550e8400-e29b-41d4-a716-446655440000", fhir_path="Patient", @@ -320,11 +314,9 @@ def delete( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.fhir.delete( fhir_provider_id="550e8400-e29b-41d4-a716-446655440000", fhir_path="Patient", @@ -395,12 +387,10 @@ def patch( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient from phenoml.fhir import FhirPatchRequestBodyItem - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.fhir.patch( fhir_provider_id="550e8400-e29b-41d4-a716-446655440000", fhir_path="Patient", @@ -474,12 +464,10 @@ def execute_bundle( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient from phenoml.fhir import FhirBundleEntryItem, FhirBundleEntryItemRequest - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.fhir.execute_bundle( fhir_provider_id="550e8400-e29b-41d4-a716-446655440000", phenoml_on_behalf_of="Patient/550e8400-e29b-41d4-a716-446655440000", @@ -591,11 +579,9 @@ async def search( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -679,11 +665,9 @@ async def create( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -770,11 +754,9 @@ async def upsert( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -850,11 +832,9 @@ async def delete( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -933,12 +913,10 @@ async def patch( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient from phenoml.fhir import FhirPatchRequestBodyItem - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -1020,12 +998,10 @@ async def execute_bundle( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient from phenoml.fhir import FhirBundleEntryItem, FhirBundleEntryItemRequest - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: diff --git a/src/phenoml/fhir/types/fhir_bundle.py b/src/phenoml/fhir/types/fhir_bundle.py index b062870..90fe4d9 100644 --- a/src/phenoml/fhir/types/fhir_bundle.py +++ b/src/phenoml/fhir/types/fhir_bundle.py @@ -15,13 +15,11 @@ class FhirBundle(UniversalBaseModel): Based on the FHIRBundle struct from io/fhir.go. """ - resource_type: typing_extensions.Annotated[typing.Literal["Bundle"], FieldMetadata(alias="resourceType")] = ( - pydantic.Field(default="Bundle") - ) - """ - Always "Bundle" for bundle resources - """ - + resource_type: typing_extensions.Annotated[ + typing.Literal["Bundle"], + FieldMetadata(alias="resourceType"), + pydantic.Field(alias="resourceType", description='Always "Bundle" for bundle resources'), + ] = "Bundle" total: typing.Optional[int] = pydantic.Field(default=None) """ Total number of resources that match the search criteria. diff --git a/src/phenoml/fhir/types/fhir_patch_request_body_item.py b/src/phenoml/fhir/types/fhir_patch_request_body_item.py index ac8f037..67b2152 100644 --- a/src/phenoml/fhir/types/fhir_patch_request_body_item.py +++ b/src/phenoml/fhir/types/fhir_patch_request_body_item.py @@ -25,10 +25,11 @@ class FhirPatchRequestBodyItem(UniversalBaseModel): The value to use (required for add, replace, test operations) """ - from_: typing_extensions.Annotated[typing.Optional[str], FieldMetadata(alias="from")] = pydantic.Field(default=None) - """ - Source location for move and copy operations (JSON Pointer) - """ + from_: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="from"), + pydantic.Field(alias="from", description="Source location for move and copy operations (JSON Pointer)"), + ] = None if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/phenoml/fhir/types/fhir_resource.py b/src/phenoml/fhir/types/fhir_resource.py index 53a513b..9af4105 100644 --- a/src/phenoml/fhir/types/fhir_resource.py +++ b/src/phenoml/fhir/types/fhir_resource.py @@ -15,11 +15,13 @@ class FhirResource(UniversalBaseModel): but the specific structure depends on the resource type. """ - resource_type: typing_extensions.Annotated[str, FieldMetadata(alias="resourceType")] = pydantic.Field() - """ - The type of FHIR resource (e.g., Patient, Observation, etc.) - """ - + resource_type: typing_extensions.Annotated[ + str, + FieldMetadata(alias="resourceType"), + pydantic.Field( + alias="resourceType", description="The type of FHIR resource (e.g., Patient, Observation, etc.)" + ), + ] id: typing.Optional[str] = pydantic.Field(default=None) """ Logical ID of the resource diff --git a/src/phenoml/fhir/types/fhir_resource_meta.py b/src/phenoml/fhir/types/fhir_resource_meta.py index 849d7ac..8d84c74 100644 --- a/src/phenoml/fhir/types/fhir_resource_meta.py +++ b/src/phenoml/fhir/types/fhir_resource_meta.py @@ -14,8 +14,12 @@ class FhirResourceMeta(UniversalBaseModel): Metadata about the resource """ - version_id: typing_extensions.Annotated[typing.Optional[str], FieldMetadata(alias="versionId")] = None - last_updated: typing_extensions.Annotated[typing.Optional[dt.datetime], FieldMetadata(alias="lastUpdated")] = None + version_id: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="versionId"), pydantic.Field(alias="versionId") + ] = None + last_updated: typing_extensions.Annotated[ + typing.Optional[dt.datetime], FieldMetadata(alias="lastUpdated"), pydantic.Field(alias="lastUpdated") + ] = None profile: typing.Optional[typing.List[str]] = None if IS_PYDANTIC_V2: diff --git a/src/phenoml/fhir_provider/client.py b/src/phenoml/fhir_provider/client.py index 2285d24..94e624c 100644 --- a/src/phenoml/fhir_provider/client.py +++ b/src/phenoml/fhir_provider/client.py @@ -72,12 +72,10 @@ def create( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient from phenoml.fhir_provider import FhirProviderCreateRequestAuth_Jwt - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.fhir_provider.create( name="Epic Sandbox", provider="athenahealth", @@ -116,11 +114,9 @@ def list(self, *, request_options: typing.Optional[RequestOptions] = None) -> Fh Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.fhir_provider.list() """ _response = self._raw_client.list(request_options=request_options) @@ -150,11 +146,9 @@ def get( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.fhir_provider.get( fhir_provider_id="fhir_provider_id", ) @@ -185,11 +179,9 @@ def delete( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.fhir_provider.delete( fhir_provider_id="fhir_provider_id", ) @@ -227,12 +219,10 @@ def add_auth_config( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient from phenoml.fhir_provider import FhirProviderAddAuthConfigRequest_Jwt - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.fhir_provider.add_auth_config( fhir_provider_id="1716d214-de93-43a4-aa6b-a878d864e2ad", request=FhirProviderAddAuthConfigRequest_Jwt( @@ -274,11 +264,9 @@ def set_active_auth_config( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.fhir_provider.set_active_auth_config( fhir_provider_id="1716d214-de93-43a4-aa6b-a878d864e2ad", auth_config_id="auth-config-123", @@ -316,11 +304,9 @@ def remove_auth_config( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.fhir_provider.remove_auth_config( fhir_provider_id="1716d214-de93-43a4-aa6b-a878d864e2ad", auth_config_id="auth-config-123", @@ -389,12 +375,10 @@ async def create( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient from phenoml.fhir_provider import FhirProviderCreateRequestAuth_Jwt - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -441,11 +425,9 @@ async def list(self, *, request_options: typing.Optional[RequestOptions] = None) -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -483,11 +465,9 @@ async def get( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -526,11 +506,9 @@ async def delete( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -576,12 +554,10 @@ async def add_auth_config( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient from phenoml.fhir_provider import FhirProviderAddAuthConfigRequest_Jwt - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -633,11 +609,9 @@ async def set_active_auth_config( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -683,11 +657,9 @@ async def remove_auth_config( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: diff --git a/src/phenoml/fhir_provider/types/service_account_key.py b/src/phenoml/fhir_provider/types/service_account_key.py index c62c8ed..3b9bb71 100644 --- a/src/phenoml/fhir_provider/types/service_account_key.py +++ b/src/phenoml/fhir_provider/types/service_account_key.py @@ -21,8 +21,12 @@ class ServiceAccountKey(UniversalBaseModel): client_id: str auth_uri: str token_uri: str - auth_provider_x509cert_url: typing_extensions.Annotated[str, FieldMetadata(alias="auth_provider_x509_cert_url")] - client_x509cert_url: typing_extensions.Annotated[str, FieldMetadata(alias="client_x509_cert_url")] + auth_provider_x509cert_url: typing_extensions.Annotated[ + str, FieldMetadata(alias="auth_provider_x509_cert_url"), pydantic.Field(alias="auth_provider_x509_cert_url") + ] + client_x509cert_url: typing_extensions.Annotated[ + str, FieldMetadata(alias="client_x509_cert_url"), pydantic.Field(alias="client_x509_cert_url") + ] universe_domain: str if IS_PYDANTIC_V2: diff --git a/src/phenoml/lang2fhir/client.py b/src/phenoml/lang2fhir/client.py index 19e1ad0..599407f 100644 --- a/src/phenoml/lang2fhir/client.py +++ b/src/phenoml/lang2fhir/client.py @@ -62,11 +62,9 @@ def create( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.lang2fhir.create( version="R4", resource="auto", @@ -112,11 +110,9 @@ def create_multi( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.lang2fhir.create_multi( text="John Smith, 45-year-old male, diagnosed with Type 2 Diabetes. Prescribed Metformin 500mg twice daily.", ) @@ -160,11 +156,9 @@ def search(self, *, text: str, request_options: typing.Optional[RequestOptions] Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.lang2fhir.search( text="Appointments between March 2-9, 2025", ) @@ -202,11 +196,9 @@ def upload_profile( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.lang2fhir.upload_profile( profile="(base64 encoded FHIR StructureDefinition JSON)", ) @@ -243,11 +235,9 @@ def document( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.lang2fhir.document( version="R4", resource="questionnaire", @@ -296,11 +286,9 @@ def extract_multiple_fhir_resources_from_a_document( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.lang2fhir.extract_multiple_fhir_resources_from_a_document( version="R4", content="content", @@ -361,11 +349,9 @@ async def create( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -419,11 +405,9 @@ async def create_multi( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -475,11 +459,9 @@ async def search(self, *, text: str, request_options: typing.Optional[RequestOpt -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -525,11 +507,9 @@ async def upload_profile( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -574,11 +554,9 @@ async def document( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -635,11 +613,9 @@ async def extract_multiple_fhir_resources_from_a_document( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: diff --git a/src/phenoml/lang2fhir/types/create_multi_response_bundle.py b/src/phenoml/lang2fhir/types/create_multi_response_bundle.py index faa88a0..6fce3dc 100644 --- a/src/phenoml/lang2fhir/types/create_multi_response_bundle.py +++ b/src/phenoml/lang2fhir/types/create_multi_response_bundle.py @@ -14,7 +14,9 @@ class CreateMultiResponseBundle(UniversalBaseModel): FHIR transaction Bundle containing all extracted resources """ - resource_type: typing_extensions.Annotated[typing.Optional[str], FieldMetadata(alias="resourceType")] = None + resource_type: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="resourceType"), pydantic.Field(alias="resourceType") + ] = None type: typing.Optional[str] = None entry: typing.Optional[typing.List[CreateMultiResponseBundleEntryItem]] = None diff --git a/src/phenoml/lang2fhir/types/create_multi_response_bundle_entry_item.py b/src/phenoml/lang2fhir/types/create_multi_response_bundle_entry_item.py index c611d5c..b94f1df 100644 --- a/src/phenoml/lang2fhir/types/create_multi_response_bundle_entry_item.py +++ b/src/phenoml/lang2fhir/types/create_multi_response_bundle_entry_item.py @@ -10,7 +10,9 @@ class CreateMultiResponseBundleEntryItem(UniversalBaseModel): - full_url: typing_extensions.Annotated[typing.Optional[str], FieldMetadata(alias="fullUrl")] = None + full_url: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="fullUrl"), pydantic.Field(alias="fullUrl") + ] = None resource: typing.Optional[typing.Dict[str, typing.Any]] = None request: typing.Optional[CreateMultiResponseBundleEntryItemRequest] = None diff --git a/src/phenoml/lang2fhir/types/create_multi_response_resources_item.py b/src/phenoml/lang2fhir/types/create_multi_response_resources_item.py index e1ac1b4..8af9091 100644 --- a/src/phenoml/lang2fhir/types/create_multi_response_resources_item.py +++ b/src/phenoml/lang2fhir/types/create_multi_response_resources_item.py @@ -9,20 +9,16 @@ class CreateMultiResponseResourcesItem(UniversalBaseModel): - temp_id: typing_extensions.Annotated[typing.Optional[str], FieldMetadata(alias="tempId")] = pydantic.Field( - default=None - ) - """ - Temporary UUID for the resource - """ - - resource_type: typing_extensions.Annotated[typing.Optional[str], FieldMetadata(alias="resourceType")] = ( - pydantic.Field(default=None) - ) - """ - FHIR resource type - """ - + temp_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="tempId"), + pydantic.Field(alias="tempId", description="Temporary UUID for the resource"), + ] = None + resource_type: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="resourceType"), + pydantic.Field(alias="resourceType", description="FHIR resource type"), + ] = None description: typing.Optional[str] = pydantic.Field(default=None) """ Text excerpt this resource was extracted from diff --git a/src/phenoml/lang2fhir/types/search_response.py b/src/phenoml/lang2fhir/types/search_response.py index da01ac3..db0d19c 100644 --- a/src/phenoml/lang2fhir/types/search_response.py +++ b/src/phenoml/lang2fhir/types/search_response.py @@ -11,20 +11,18 @@ class SearchResponse(UniversalBaseModel): resource_type: typing_extensions.Annotated[ - typing.Optional[SearchResponseResourceType], FieldMetadata(alias="resourceType") - ] = pydantic.Field(default=None) - """ - The FHIR resource type identified for the search - """ - - search_params: typing_extensions.Annotated[typing.Optional[str], FieldMetadata(alias="searchParams")] = ( - pydantic.Field(default=None) - ) - """ - FHIR search parameters in standard query string format. - Parameters are formatted according to the FHIR specification with appropriate operators. - Code parameters are resolved to standard terminology codes (SNOMED CT, LOINC, RxNorm, ICD-10-CM). - """ + typing.Optional[SearchResponseResourceType], + FieldMetadata(alias="resourceType"), + pydantic.Field(alias="resourceType", description="The FHIR resource type identified for the search"), + ] = None + search_params: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="searchParams"), + pydantic.Field( + alias="searchParams", + description="FHIR search parameters in standard query string format.\nParameters are formatted according to the FHIR specification with appropriate operators.\nCode parameters are resolved to standard terminology codes (SNOMED CT, LOINC, RxNorm, ICD-10-CM).", + ), + ] = None if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/phenoml/summary/client.py b/src/phenoml/summary/client.py index 9310fe3..0701270 100644 --- a/src/phenoml/summary/client.py +++ b/src/phenoml/summary/client.py @@ -51,11 +51,9 @@ def list_templates( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.summary.list_templates() """ _response = self._raw_client.list_templates(request_options=request_options) @@ -105,11 +103,9 @@ def create_template( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.summary.create_template( name="name", example_summary="Patient John Doe, age 45, presents with hypertension diagnosed on 2024-01-15.", @@ -149,11 +145,9 @@ def get_template( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.summary.get_template( id="id", ) @@ -202,11 +196,9 @@ def update_template( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.summary.update_template( id="id", name="name", @@ -247,11 +239,9 @@ def delete_template( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.summary.delete_template( id="id", ) @@ -301,12 +291,10 @@ def create( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient from phenoml.summary import FhirResource - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.summary.create( fhir_resources=FhirResource( resource_type="resourceType", @@ -354,11 +342,9 @@ async def list_templates( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -416,11 +402,9 @@ async def create_template( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -468,11 +452,9 @@ async def get_template( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -529,11 +511,9 @@ async def update_template( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -582,11 +562,9 @@ async def delete_template( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -644,12 +622,10 @@ async def create( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient from phenoml.summary import FhirResource - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: diff --git a/src/phenoml/summary/types/fhir_bundle.py b/src/phenoml/summary/types/fhir_bundle.py index 6d7f279..546db32 100644 --- a/src/phenoml/summary/types/fhir_bundle.py +++ b/src/phenoml/summary/types/fhir_bundle.py @@ -10,7 +10,9 @@ class FhirBundle(UniversalBaseModel): - resource_type: typing_extensions.Annotated[typing.Literal["Bundle"], FieldMetadata(alias="resourceType")] = "Bundle" + resource_type: typing_extensions.Annotated[ + typing.Literal["Bundle"], FieldMetadata(alias="resourceType"), pydantic.Field(alias="resourceType") + ] = "Bundle" entry: typing.List[FhirBundleEntryItem] if IS_PYDANTIC_V2: diff --git a/src/phenoml/summary/types/fhir_resource.py b/src/phenoml/summary/types/fhir_resource.py index ee202af..3af9f49 100644 --- a/src/phenoml/summary/types/fhir_resource.py +++ b/src/phenoml/summary/types/fhir_resource.py @@ -9,10 +9,9 @@ class FhirResource(UniversalBaseModel): - resource_type: typing_extensions.Annotated[str, FieldMetadata(alias="resourceType")] = pydantic.Field() - """ - FHIR resource type - """ + resource_type: typing_extensions.Annotated[ + str, FieldMetadata(alias="resourceType"), pydantic.Field(alias="resourceType", description="FHIR resource type") + ] if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/phenoml/tools/client.py b/src/phenoml/tools/client.py index 9ff1cdd..7fd50fe 100644 --- a/src/phenoml/tools/client.py +++ b/src/phenoml/tools/client.py @@ -78,11 +78,9 @@ def create_fhir_resource( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.tools.create_fhir_resource( phenoml_on_behalf_of="Patient/550e8400-e29b-41d4-a716-446655440000", phenoml_fhir_provider="550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", @@ -146,11 +144,9 @@ def create_fhir_resources_multi( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.tools.create_fhir_resources_multi( phenoml_on_behalf_of="Patient/550e8400-e29b-41d4-a716-446655440000", phenoml_fhir_provider="550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", @@ -218,11 +214,9 @@ def search_fhir_resources( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.tools.search_fhir_resources( phenoml_on_behalf_of="Patient/550e8400-e29b-41d4-a716-446655440000", phenoml_fhir_provider="550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", @@ -279,11 +273,9 @@ def analyze_cohort( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.tools.analyze_cohort( phenoml_on_behalf_of="Patient/550e8400-e29b-41d4-a716-446655440000", phenoml_fhir_provider="550e8400-e29b-41d4-a716-446655440000:eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c...", @@ -370,11 +362,9 @@ async def create_fhir_resource( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -446,11 +436,9 @@ async def create_fhir_resources_multi( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -526,11 +514,9 @@ async def search_fhir_resources( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -595,11 +581,9 @@ async def analyze_cohort( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: diff --git a/src/phenoml/tools/mcp_server/client.py b/src/phenoml/tools/mcp_server/client.py index 624cc06..ef77869 100644 --- a/src/phenoml/tools/mcp_server/client.py +++ b/src/phenoml/tools/mcp_server/client.py @@ -56,11 +56,9 @@ def create( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.tools.mcp_server.create( name="My MCP Server", mcp_server_url="https://mcp.example.com", @@ -85,11 +83,9 @@ def list(self, *, request_options: typing.Optional[RequestOptions] = None) -> Mc Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.tools.mcp_server.list() """ _response = self._raw_client.list(request_options=request_options) @@ -114,11 +110,9 @@ def get(self, mcp_server_id: str, *, request_options: typing.Optional[RequestOpt Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.tools.mcp_server.get( mcp_server_id="mcp_server_id", ) @@ -147,11 +141,9 @@ def delete( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.tools.mcp_server.delete( mcp_server_id="mcp_server_id", ) @@ -211,11 +203,9 @@ async def create( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -250,11 +240,9 @@ async def list(self, *, request_options: typing.Optional[RequestOptions] = None) -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -289,11 +277,9 @@ async def get( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -330,11 +316,9 @@ async def delete( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: diff --git a/src/phenoml/tools/mcp_server/tools/client.py b/src/phenoml/tools/mcp_server/tools/client.py index f1d4509..dedda23 100644 --- a/src/phenoml/tools/mcp_server/tools/client.py +++ b/src/phenoml/tools/mcp_server/tools/client.py @@ -48,11 +48,9 @@ def list( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.tools.mcp_server.tools.list( mcp_server_id="mcp_server_id", ) @@ -81,11 +79,9 @@ def get( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.tools.mcp_server.tools.get( mcp_server_tool_id="mcp_server_tool_id", ) @@ -114,11 +110,9 @@ def delete( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.tools.mcp_server.tools.delete( mcp_server_tool_id="mcp_server_tool_id", ) @@ -154,11 +148,9 @@ def call( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.tools.mcp_server.tools.call( mcp_server_tool_id="mcp_server_tool_id", arguments={"title": "PhenoML Agent API"}, @@ -206,11 +198,9 @@ async def list( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -247,11 +237,9 @@ async def get( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -288,11 +276,9 @@ async def delete( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -336,11 +322,9 @@ async def call( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: diff --git a/src/phenoml/tools/types/cohort_response.py b/src/phenoml/tools/types/cohort_response.py index 98786b8..2b1f96b 100644 --- a/src/phenoml/tools/types/cohort_response.py +++ b/src/phenoml/tools/types/cohort_response.py @@ -20,20 +20,16 @@ class CohortResponse(UniversalBaseModel): Status message with details about the analysis """ - patient_ids: typing_extensions.Annotated[typing.Optional[typing.List[str]], FieldMetadata(alias="patientIds")] = ( - pydantic.Field(default=None) - ) - """ - Array of patient IDs that match the cohort criteria - """ - - patient_count: typing_extensions.Annotated[typing.Optional[int], FieldMetadata(alias="patientCount")] = ( - pydantic.Field(default=None) - ) - """ - Total number of patients in the cohort - """ - + patient_ids: typing_extensions.Annotated[ + typing.Optional[typing.List[str]], + FieldMetadata(alias="patientIds"), + pydantic.Field(alias="patientIds", description="Array of patient IDs that match the cohort criteria"), + ] = None + patient_count: typing_extensions.Annotated[ + typing.Optional[int], + FieldMetadata(alias="patientCount"), + pydantic.Field(alias="patientCount", description="Total number of patients in the cohort"), + ] = None queries: typing.Optional[typing.List[SearchConcept]] = pydantic.Field(default=None) """ Individual search concepts that were identified and executed diff --git a/src/phenoml/tools/types/lang2fhir_and_create_multi_response_resource_info_item.py b/src/phenoml/tools/types/lang2fhir_and_create_multi_response_resource_info_item.py index 4d31a65..c87ad3c 100644 --- a/src/phenoml/tools/types/lang2fhir_and_create_multi_response_resource_info_item.py +++ b/src/phenoml/tools/types/lang2fhir_and_create_multi_response_resource_info_item.py @@ -9,20 +9,16 @@ class Lang2FhirAndCreateMultiResponseResourceInfoItem(UniversalBaseModel): - temp_id: typing_extensions.Annotated[typing.Optional[str], FieldMetadata(alias="tempId")] = pydantic.Field( - default=None - ) - """ - Original temporary UUID - """ - - resource_type: typing_extensions.Annotated[typing.Optional[str], FieldMetadata(alias="resourceType")] = ( - pydantic.Field(default=None) - ) - """ - FHIR resource type - """ - + temp_id: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="tempId"), + pydantic.Field(alias="tempId", description="Original temporary UUID"), + ] = None + resource_type: typing_extensions.Annotated[ + typing.Optional[str], + FieldMetadata(alias="resourceType"), + pydantic.Field(alias="resourceType", description="FHIR resource type"), + ] = None description: typing.Optional[str] = pydantic.Field(default=None) """ Text excerpt this resource was extracted from diff --git a/src/phenoml/tools/types/lang2fhir_and_create_multi_response_response_bundle.py b/src/phenoml/tools/types/lang2fhir_and_create_multi_response_response_bundle.py index 052fdab..19d2b2d 100644 --- a/src/phenoml/tools/types/lang2fhir_and_create_multi_response_response_bundle.py +++ b/src/phenoml/tools/types/lang2fhir_and_create_multi_response_response_bundle.py @@ -13,7 +13,9 @@ class Lang2FhirAndCreateMultiResponseResponseBundle(UniversalBaseModel): FHIR transaction-response Bundle from the server with resolved resource IDs """ - resource_type: typing_extensions.Annotated[typing.Optional[str], FieldMetadata(alias="resourceType")] = None + resource_type: typing_extensions.Annotated[ + typing.Optional[str], FieldMetadata(alias="resourceType"), pydantic.Field(alias="resourceType") + ] = None type: typing.Optional[str] = None entry: typing.Optional[typing.List[typing.Dict[str, typing.Any]]] = None diff --git a/src/phenoml/workflows/client.py b/src/phenoml/workflows/client.py index 4c26a8c..b08af65 100644 --- a/src/phenoml/workflows/client.py +++ b/src/phenoml/workflows/client.py @@ -54,11 +54,9 @@ def list( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.workflows.list( verbose=True, ) @@ -110,11 +108,9 @@ def create( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.workflows.create( verbose=True, name="Patient Data Mapping Workflow", @@ -162,11 +158,9 @@ def get( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.workflows.get( id="id", verbose=True, @@ -223,11 +217,9 @@ def update( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.workflows.update( id="id", verbose=True, @@ -272,11 +264,9 @@ def delete(self, id: str, *, request_options: typing.Optional[RequestOptions] = Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.workflows.delete( id="id", ) @@ -312,11 +302,9 @@ def execute( Examples -------- - from phenoml import phenoml + from phenoml import PhenomlClient - client = phenoml( - token="YOUR_TOKEN", - ) + client = PhenomlClient() client.workflows.execute( id="id", input_data={ @@ -369,11 +357,9 @@ async def list( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -433,11 +419,9 @@ async def create( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -493,11 +477,9 @@ async def get( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -562,11 +544,9 @@ async def update( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -621,11 +601,9 @@ async def delete( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: @@ -669,11 +647,9 @@ async def execute( -------- import asyncio - from phenoml import Asyncphenoml + from phenoml import AsyncPhenomlClient - client = Asyncphenoml( - token="YOUR_TOKEN", - ) + client = AsyncPhenomlClient() async def main() -> None: diff --git a/src/phenoml/wrapper_client.py b/src/phenoml/wrapper_client.py deleted file mode 100644 index f533514..0000000 --- a/src/phenoml/wrapper_client.py +++ /dev/null @@ -1,123 +0,0 @@ -""" -Simple wrapper that extends the base client with automatic token generation. -""" - -import httpx -from typing import Optional, Union, Callable - -from .client import phenoml, Asyncphenoml -from .environment import phenomlEnvironment -from .authtoken.client import AuthtokenClient, AsyncAuthtokenClient -from .core.client_wrapper import SyncClientWrapper, AsyncClientWrapper - - -class PhenoMLClient(phenoml): - """ - Extends the base client with automatic token generation from username/password. - """ - - def __init__( - self, - *, - token: Optional[Union[str, Callable[[], str]]] = None, - username: Optional[str] = None, - password: Optional[str] = None, - **kwargs - ): - # Validate authentication - if token is None and (username is None or password is None): - raise ValueError("Must provide either 'token' or both 'username' and 'password'") - - if token is not None and (username is not None or password is not None): - raise ValueError("Cannot provide both 'token' and 'username'/'password'") - - # Generate token if needed - if token is None: - if username is None or password is None: - raise ValueError("Must provide both 'username' and 'password'") - base_url = kwargs.get('base_url') - if base_url is None: - raise ValueError("Must provide 'base_url' when using username/password") - token = self._generate_token(username, password, base_url) - - # Call parent constructor with the resolved token and all kwargs - super().__init__(token=token, **kwargs) - - def _generate_token(self, username: str, password: str, base_url: str) -> str: - """Generate token using the auth client.""" - # Create a simple client wrapper without authentication - client_wrapper = SyncClientWrapper( - token="", # No auth needed since we're using basic auth in the request - base_url=base_url, - httpx_client=httpx.Client() - ) - - # Create the auth client using the existing SDK - auth_client = AuthtokenClient(client_wrapper=client_wrapper) - - print(f"Generating token for {username} using auth client") - response = auth_client.auth.generate_token(username=username, password=password) - print(f"Token response: {response}") - return response.token - - -class AsyncPhenoMLClient(Asyncphenoml): - """ - Extends the async base client with automatic token generation from username/password. - """ - - def __init__( - self, - *, - token: Optional[Union[str, Callable[[], str]]] = None, - username: Optional[str] = None, - password: Optional[str] = None, - **kwargs - ): - # Validate authentication - if token is None and (username is None or password is None): - raise ValueError("Must provide either 'token' or both 'username' and 'password'") - - if token is not None and (username is not None or password is not None): - raise ValueError("Cannot provide both 'token' and 'username'/'password'") - - # Store for async token generation (needed for initialize) - self._username = username - self._password = password - self._base_url = kwargs.get('base_url') - if self._base_url is None: - raise ValueError("Must provide 'base_url' when using username/password") - - # Create with temporary token if needed - self._current_token = "" - super().__init__(token=token or (lambda: self._current_token), **kwargs) - - async def initialize(self) -> None: - """Generate token if username/password was provided.""" - if self._username and self._password: - token = await self._generate_token() - # Update the token on the existing instance instead of recreating - # This is a workaround since we can't easily recreate the instance - self._current_token = token - - async def _generate_token(self) -> str: - """Generate token using the auth client.""" - # Ensure base_url is a string - if self._base_url is None: - raise ValueError("Base URL must be provided") - - # Create a simple client wrapper without authentication - client_wrapper = AsyncClientWrapper( - token="", # No auth needed since we're using basic auth in the request - base_url=self._base_url, - httpx_client=httpx.AsyncClient() - ) - - # Create the auth client using the existing SDK - auth_client = AsyncAuthtokenClient(client_wrapper=client_wrapper) - - if self._username is None or self._password is None: - raise ValueError("Username and password must be provided") - - response = await auth_client.auth.generate_token(username=self._username, password=self._password) - return response.token \ No newline at end of file diff --git a/tests/utils/test_http_client.py b/tests/utils/test_http_client.py index d90ac63..2376709 100644 --- a/tests/utils/test_http_client.py +++ b/tests/utils/test_http_client.py @@ -1,13 +1,57 @@ # This file was auto-generated by Fern from our API Definition. -from phenoml.core.http_client import get_request_body +from typing import Any, Dict + +import pytest + +from phenoml.core.http_client import ( + AsyncHttpClient, + HttpClient, + _build_url, + get_request_body, + remove_none_from_dict, +) from phenoml.core.request_options import RequestOptions +# Stub clients for testing HttpClient and AsyncHttpClient +class _DummySyncClient: + """A minimal stub for httpx.Client that records request arguments.""" + + def __init__(self) -> None: + self.last_request_kwargs: Dict[str, Any] = {} + + def request(self, **kwargs: Any) -> "_DummyResponse": + self.last_request_kwargs = kwargs + return _DummyResponse() + + +class _DummyAsyncClient: + """A minimal stub for httpx.AsyncClient that records request arguments.""" + + def __init__(self) -> None: + self.last_request_kwargs: Dict[str, Any] = {} + + async def request(self, **kwargs: Any) -> "_DummyResponse": + self.last_request_kwargs = kwargs + return _DummyResponse() + + +class _DummyResponse: + """A minimal stub for httpx.Response.""" + + status_code = 200 + headers: Dict[str, str] = {} + + def get_request_options() -> RequestOptions: return {"additional_body_parameters": {"see you": "later"}} +def get_request_options_with_none() -> RequestOptions: + return {"additional_body_parameters": {"see you": "later", "optional": None}} + + def test_get_json_request_body() -> None: json_body, data_body = get_request_body(json={"hello": "world"}, data=None, request_options=None, omit=None) assert json_body == {"hello": "world"} @@ -48,14 +92,209 @@ def test_get_none_request_body() -> None: def test_get_empty_json_request_body() -> None: + """Test that implicit empty bodies (json=None) are collapsed to None.""" unrelated_request_options: RequestOptions = {"max_retries": 3} json_body, data_body = get_request_body(json=None, data=None, request_options=unrelated_request_options, omit=None) assert json_body is None assert data_body is None - json_body_extras, data_body_extras = get_request_body( - json={}, data=None, request_options=unrelated_request_options, omit=None + +def test_explicit_empty_json_body_is_preserved() -> None: + """Test that explicit empty bodies (json={}) are preserved and sent as {}. + + This is important for endpoints where the request body is required but all + fields are optional. The server expects valid JSON ({}) not an empty body. + """ + unrelated_request_options: RequestOptions = {"max_retries": 3} + + # Explicit json={} should be preserved + json_body, data_body = get_request_body(json={}, data=None, request_options=unrelated_request_options, omit=None) + assert json_body == {} + assert data_body is None + + # Explicit data={} should also be preserved + json_body2, data_body2 = get_request_body(json=None, data={}, request_options=unrelated_request_options, omit=None) + assert json_body2 is None + assert data_body2 == {} + + +def test_json_body_preserves_none_values() -> None: + """Test that JSON bodies preserve None values (they become JSON null).""" + json_body, data_body = get_request_body( + json={"hello": "world", "optional": None}, data=None, request_options=None, omit=None ) + # JSON bodies should preserve None values + assert json_body == {"hello": "world", "optional": None} + assert data_body is None - assert json_body_extras is None - assert data_body_extras is None + +def test_data_body_preserves_none_values_without_multipart() -> None: + """Test that data bodies preserve None values when not using multipart. + + The filtering of None values happens in HttpClient.request/stream methods, + not in get_request_body. This test verifies get_request_body doesn't filter None. + """ + json_body, data_body = get_request_body( + json=None, data={"hello": "world", "optional": None}, request_options=None, omit=None + ) + # get_request_body should preserve None values in data body + # The filtering happens later in HttpClient.request when multipart is detected + assert data_body == {"hello": "world", "optional": None} + assert json_body is None + + +def test_remove_none_from_dict_filters_none_values() -> None: + """Test that remove_none_from_dict correctly filters out None values.""" + original = {"hello": "world", "optional": None, "another": "value", "also_none": None} + filtered = remove_none_from_dict(original) + assert filtered == {"hello": "world", "another": "value"} + # Original should not be modified + assert original == {"hello": "world", "optional": None, "another": "value", "also_none": None} + + +def test_remove_none_from_dict_empty_dict() -> None: + """Test that remove_none_from_dict handles empty dict.""" + assert remove_none_from_dict({}) == {} + + +def test_remove_none_from_dict_all_none() -> None: + """Test that remove_none_from_dict handles dict with all None values.""" + assert remove_none_from_dict({"a": None, "b": None}) == {} + + +def test_http_client_does_not_pass_empty_params_list() -> None: + """Test that HttpClient passes params=None when params are empty. + + This prevents httpx from stripping existing query parameters from the URL, + which happens when params=[] or params={} is passed. + """ + dummy_client = _DummySyncClient() + http_client = HttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + ) + + # Use a path with query params (e.g., pagination cursor URL) + http_client.request( + path="resource?after=123", + method="GET", + params=None, + request_options=None, + ) + + # We care that httpx receives params=None, not [] or {} + assert "params" in dummy_client.last_request_kwargs + assert dummy_client.last_request_kwargs["params"] is None + + # Verify the query string in the URL is preserved + url = str(dummy_client.last_request_kwargs["url"]) + assert "after=123" in url, f"Expected query param 'after=123' in URL, got: {url}" + + +def test_http_client_passes_encoded_params_when_present() -> None: + """Test that HttpClient passes encoded params when params are provided.""" + dummy_client = _DummySyncClient() + http_client = HttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com/resource", + ) + + http_client.request( + path="", + method="GET", + params={"after": "456"}, + request_options=None, + ) + + params = dummy_client.last_request_kwargs["params"] + # For a simple dict, encode_query should give a single (key, value) tuple + assert params == [("after", "456")] + + +@pytest.mark.asyncio +async def test_async_http_client_does_not_pass_empty_params_list() -> None: + """Test that AsyncHttpClient passes params=None when params are empty. + + This prevents httpx from stripping existing query parameters from the URL, + which happens when params=[] or params={} is passed. + """ + dummy_client = _DummyAsyncClient() + http_client = AsyncHttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + async_base_headers=None, + ) + + # Use a path with query params (e.g., pagination cursor URL) + await http_client.request( + path="resource?after=123", + method="GET", + params=None, + request_options=None, + ) + + # We care that httpx receives params=None, not [] or {} + assert "params" in dummy_client.last_request_kwargs + assert dummy_client.last_request_kwargs["params"] is None + + # Verify the query string in the URL is preserved + url = str(dummy_client.last_request_kwargs["url"]) + assert "after=123" in url, f"Expected query param 'after=123' in URL, got: {url}" + + +@pytest.mark.asyncio +async def test_async_http_client_passes_encoded_params_when_present() -> None: + """Test that AsyncHttpClient passes encoded params when params are provided.""" + dummy_client = _DummyAsyncClient() + http_client = AsyncHttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: None, + base_headers=lambda: {}, + base_url=lambda: "https://example.com/resource", + async_base_headers=None, + ) + + await http_client.request( + path="", + method="GET", + params={"after": "456"}, + request_options=None, + ) + + params = dummy_client.last_request_kwargs["params"] + # For a simple dict, encode_query should give a single (key, value) tuple + assert params == [("after", "456")] + + +def test_basic_url_joining() -> None: + """Test basic URL joining with a simple base URL and path.""" + result = _build_url("https://api.example.com", "/users") + assert result == "https://api.example.com/users" + + +def test_basic_url_joining_trailing_slash() -> None: + """Test basic URL joining with a simple base URL and path.""" + result = _build_url("https://api.example.com/", "/users") + assert result == "https://api.example.com/users" + + +def test_preserves_base_url_path_prefix() -> None: + """Test that path prefixes in base URL are preserved. + + This is the critical bug fix - urllib.parse.urljoin() would strip + the path prefix when the path starts with '/'. + """ + result = _build_url("https://cloud.example.com/org/tenant/api", "/users") + assert result == "https://cloud.example.com/org/tenant/api/users" + + +def test_preserves_base_url_path_prefix_trailing_slash() -> None: + """Test that path prefixes in base URL are preserved.""" + result = _build_url("https://cloud.example.com/org/tenant/api/", "/users") + assert result == "https://cloud.example.com/org/tenant/api/users" From ba9c5ca2df1c0f74e6ca65671e0a612b886eda27 Mon Sep 17 00:00:00 2001 From: Gavin Sharp Date: Wed, 4 Mar 2026 15:49:16 -0500 Subject: [PATCH 2/7] Bump version to 8.0.0 and update changelog for major release Update version from 7.3.1 to 8.0.0 across all files (pyproject.toml, metadata.json, client_wrapper.py) and replace the placeholder changelog entry with detailed breaking changes documentation covering the OAuth 2.0 auth migration, client rename, and other significant changes. Co-Authored-By: Claude Opus 4.6 --- .fern/metadata.json | 2 +- changelog.md | 25 ++++++++++++++++++++++--- pyproject.toml | 2 +- src/phenoml/core/client_wrapper.py | 4 ++-- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/.fern/metadata.json b/.fern/metadata.json index 7c5e43f..a1c6702 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -5,5 +5,5 @@ "generatorConfig": { "client_class_name": "PhenomlClient" }, - "sdkVersion": "7.3.1" + "sdkVersion": "8.0.0" } \ No newline at end of file diff --git a/changelog.md b/changelog.md index e6e9d35..5936aa5 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,25 @@ -## 7.3.1 - 2026-03-04 -* SDK regeneration -* Unable to analyze changes with AI, incrementing PATCH version. +## 8.0.0 - 2026-03-04 + +### Breaking Changes + +- **Authentication**: Migrated from token-based auth to OAuth 2.0 client credentials flow. The client now accepts `client_id` and `client_secret` parameters (defaulting to `PHENOML_CLIENT_ID` and `PHENOML_CLIENT_SECRET` environment variables) instead of a `token` parameter. Tokens are automatically obtained and refreshed via the `/v2/auth/token` endpoint. A `token` callable is also supported for pre-generated tokens. +- **Client renamed**: The main client class is now `PhenomlClient` (was `PhenoMLClient` wrapper / `phenoml` base class). +- **Wrapper client removed**: The custom `wrapper_client.py` has been removed. Use `PhenomlClient` directly. + +### Added + +- New `/v2/auth/token` OAuth 2.0 client credentials endpoint with `TokenResponse`, `OAuthError`, and `OAuthErrorError` types. +- `OAuthTokenProvider` and `AsyncOAuthTokenProvider` for automatic token acquisition and refresh. +- Structured logging support via new `logging` module. +- `datetime_utils` module for date/time handling. +- Python 3.13, 3.14, and 3.15 support. + +### Internal + +- Upgraded Fern Python SDK generator to 4.61.4 and Fern CLI to 4.3.3. +- Major HTTP client rewrite with improved retry logic and logging. +- Expanded Pydantic utilities. +- Added `pytest-xdist` for parallel test execution. ## 7.3.0 - 2026-03-03 * feat: add document multi-resource extraction endpoint diff --git a/pyproject.toml b/pyproject.toml index 8722cb7..be0c282 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ dynamic = ["version"] [tool.poetry] name = "phenoml" -version = "7.3.1" +version = "8.0.0" description = "" readme = "README.md" authors = [] diff --git a/src/phenoml/core/client_wrapper.py b/src/phenoml/core/client_wrapper.py index 810a8df..5341ea9 100644 --- a/src/phenoml/core/client_wrapper.py +++ b/src/phenoml/core/client_wrapper.py @@ -27,12 +27,12 @@ def get_headers(self) -> typing.Dict[str, str]: import platform headers: typing.Dict[str, str] = { - "User-Agent": "phenoml/7.3.1", + "User-Agent": "phenoml/8.0.0", "X-Fern-Language": "Python", "X-Fern-Runtime": f"python/{platform.python_version()}", "X-Fern-Platform": f"{platform.system().lower()}/{platform.release()}", "X-Fern-SDK-Name": "phenoml", - "X-Fern-SDK-Version": "7.3.1", + "X-Fern-SDK-Version": "8.0.0", **(self.get_custom_headers() or {}), } token = self._get_token() From 85d1fa70aa5b5c09da452ffda40881d61666bd0c Mon Sep 17 00:00:00 2001 From: Gavin Sharp Date: Wed, 4 Mar 2026 15:53:30 -0500 Subject: [PATCH 3/7] Add migration guide to 8.0.0 changelog entry Include code examples showing how to migrate from token-based auth to OAuth 2.0 client credentials, and from PhenoMLClient to PhenomlClient. Co-Authored-By: Claude Opus 4.6 --- changelog.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/changelog.md b/changelog.md index 5936aa5..fe572d4 100644 --- a/changelog.md +++ b/changelog.md @@ -6,6 +6,40 @@ - **Client renamed**: The main client class is now `PhenomlClient` (was `PhenoMLClient` wrapper / `phenoml` base class). - **Wrapper client removed**: The custom `wrapper_client.py` has been removed. Use `PhenomlClient` directly. +### Migration Guide + +**Authentication** โ€” replace token-based auth with client credentials: + +```python +# Before +from phenoml import PhenoMLClient +client = PhenoMLClient(token="YOUR_TOKEN", base_url="https://api.phenoml.com") +# or +client = PhenoMLClient(username="user", password="pass", base_url="https://api.phenoml.com") + +# After (option 1: env vars PHENOML_CLIENT_ID and PHENOML_CLIENT_SECRET) +from phenoml import PhenomlClient +client = PhenomlClient() + +# After (option 2: explicit credentials) +from phenoml import PhenomlClient +client = PhenomlClient(client_id="YOUR_CLIENT_ID", client_secret="YOUR_CLIENT_SECRET") + +# After (option 3: pre-existing token) +from phenoml import PhenomlClient +client = PhenomlClient(token=lambda: "YOUR_TOKEN") +``` + +**Import updates:** + +```python +# Before +from phenoml import PhenoMLClient + +# After +from phenoml import PhenomlClient +``` + ### Added - New `/v2/auth/token` OAuth 2.0 client credentials endpoint with `TokenResponse`, `OAuthError`, and `OAuthErrorError` types. From 9a6cc2b15bbf04b4381b467168ae0c0f9e15974b Mon Sep 17 00:00:00 2001 From: Gavin Sharp Date: Wed, 4 Mar 2026 15:57:41 -0500 Subject: [PATCH 4/7] Update changelog auth descriptions to reference username/password instead of token-based auth Co-Authored-By: Claude Opus 4.6 --- changelog.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index fe572d4..c18782d 100644 --- a/changelog.md +++ b/changelog.md @@ -2,13 +2,13 @@ ### Breaking Changes -- **Authentication**: Migrated from token-based auth to OAuth 2.0 client credentials flow. The client now accepts `client_id` and `client_secret` parameters (defaulting to `PHENOML_CLIENT_ID` and `PHENOML_CLIENT_SECRET` environment variables) instead of a `token` parameter. Tokens are automatically obtained and refreshed via the `/v2/auth/token` endpoint. A `token` callable is also supported for pre-generated tokens. +- **Authentication**: Replaced username/password authentication with OAuth 2.0 client credentials. The client now accepts `client_id` and `client_secret` parameters (defaulting to `PHENOML_CLIENT_ID` and `PHENOML_CLIENT_SECRET` environment variables). Tokens are automatically obtained and refreshed via the `/v2/auth/token` endpoint. A `token` callable is also supported for pre-existing tokens. - **Client renamed**: The main client class is now `PhenomlClient` (was `PhenoMLClient` wrapper / `phenoml` base class). - **Wrapper client removed**: The custom `wrapper_client.py` has been removed. Use `PhenomlClient` directly. ### Migration Guide -**Authentication** โ€” replace token-based auth with client credentials: +**Authentication** โ€” replace username/password with client credentials: ```python # Before From b9903e7590e2784a6e1a56021e00f39207c966f2 Mon Sep 17 00:00:00 2001 From: Gavin Sharp Date: Wed, 4 Mar 2026 16:00:22 -0500 Subject: [PATCH 5/7] Remove Internal section from 8.0.0 changelog entry The Internal section (Fern generator/CLI upgrades, HTTP client rewrite, Pydantic utilities, pytest-xdist) is implementation detail that doesn't belong in the public changelog. Co-Authored-By: Claude Opus 4.6 --- changelog.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/changelog.md b/changelog.md index c18782d..86e2fd3 100644 --- a/changelog.md +++ b/changelog.md @@ -48,13 +48,6 @@ from phenoml import PhenomlClient - `datetime_utils` module for date/time handling. - Python 3.13, 3.14, and 3.15 support. -### Internal - -- Upgraded Fern Python SDK generator to 4.61.4 and Fern CLI to 4.3.3. -- Major HTTP client rewrite with improved retry logic and logging. -- Expanded Pydantic utilities. -- Added `pytest-xdist` for parallel test execution. - ## 7.3.0 - 2026-03-03 * feat: add document multi-resource extraction endpoint * Add comprehensive support for extracting multiple FHIR resources from documents through a new API endpoint. This enhancement combines document text extraction with multi-resource detection capabilities. From 080c573a82c163580216d3dd6b794d09f3649420 Mon Sep 17 00:00:00 2001 From: Gavin Sharp Date: Wed, 4 Mar 2026 16:01:27 -0500 Subject: [PATCH 6/7] Update API URL in changelog examples from https://api.phenoml.com to https://yourinstance.app.pheno.ml --- changelog.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index 86e2fd3..949eaa8 100644 --- a/changelog.md +++ b/changelog.md @@ -13,9 +13,9 @@ ```python # Before from phenoml import PhenoMLClient -client = PhenoMLClient(token="YOUR_TOKEN", base_url="https://api.phenoml.com") +client = PhenoMLClient(token="YOUR_TOKEN", base_url="https://yourinstance.app.pheno.ml") # or -client = PhenoMLClient(username="user", password="pass", base_url="https://api.phenoml.com") +client = PhenoMLClient(username="user", password="pass", base_url="https://yourinstance.app.pheno.ml") # After (option 1: env vars PHENOML_CLIENT_ID and PHENOML_CLIENT_SECRET) from phenoml import PhenomlClient From d3e37dfd6ac0266374158b10875900674cc9381e Mon Sep 17 00:00:00 2001 From: Gavin Sharp Date: Wed, 4 Mar 2026 16:09:12 -0500 Subject: [PATCH 7/7] Update migration guide code examples with base_url and multi-line formatting Co-Authored-By: Claude Opus 4.6 --- changelog.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/changelog.md b/changelog.md index 949eaa8..b0f791e 100644 --- a/changelog.md +++ b/changelog.md @@ -13,21 +13,30 @@ ```python # Before from phenoml import PhenoMLClient -client = PhenoMLClient(token="YOUR_TOKEN", base_url="https://yourinstance.app.pheno.ml") -# or -client = PhenoMLClient(username="user", password="pass", base_url="https://yourinstance.app.pheno.ml") +client = PhenoMLClient( + username="user", + password="pass", + base_url="https://yourinstance.app.pheno.ml", +) # After (option 1: env vars PHENOML_CLIENT_ID and PHENOML_CLIENT_SECRET) from phenoml import PhenomlClient -client = PhenomlClient() +client = PhenomlClient(base_url="https://yourinstance.app.pheno.ml") # After (option 2: explicit credentials) from phenoml import PhenomlClient -client = PhenomlClient(client_id="YOUR_CLIENT_ID", client_secret="YOUR_CLIENT_SECRET") +client = PhenomlClient( + client_id="YOUR_CLIENT_ID", + client_secret="YOUR_CLIENT_SECRET", + base_url="https://yourinstance.app.pheno.ml", +) # After (option 3: pre-existing token) from phenoml import PhenomlClient -client = PhenomlClient(token=lambda: "YOUR_TOKEN") +client = PhenomlClient( + token=lambda: "YOUR_TOKEN", + base_url="https://yourinstance.app.pheno.ml", +) ``` **Import updates:**