From 849914fae9ed0e44934573084b8c043e5e40b35b Mon Sep 17 00:00:00 2001 From: Charles Graham SWT Date: Thu, 13 Feb 2025 16:40:00 -0600 Subject: [PATCH 01/25] Initial setup of location group function defs --- cwms/location/group.py | 212 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 cwms/location/group.py diff --git a/cwms/location/group.py b/cwms/location/group.py new file mode 100644 index 00000000..7546fc3f --- /dev/null +++ b/cwms/location/group.py @@ -0,0 +1,212 @@ +# Copyright (c) 2024 +# United States Army Corps of Engineers - Hydrologic Engineering Center (USACE/HEC) +# All Rights Reserved. USACE PROPRIETARY/CONFIDENTIAL. +# Source may not be released without written approval from HEC +from typing import Optional + +import cwms.api as api +from cwms.cwms_types import JSON, Data, DeleteMethod + + +def store_location_group(data: JSON, fail_if_exists: Optional[bool]) -> None: + """ + Parameters + ---------- + data : dict + A dictionary representing the JSON data to be stored. + If the `data` value is None, a `ValueError` will be raised. + fail_if_exists : str, optional + A boolean value indicating whether to fail if the location group already exists. + Default is True. + + Returns + ------- + None + + Raises + ------ + ValueError + If any of data is None. + ClientError + If a 400 range error code response is returned from the server. + NoDataFoundError + If a 404 range error code response is returned from the server. + ServerError + If a 500 range error code response is returned from the server. + """ + + if data is None: + raise ValueError("Cannot store a location group without a JSON data dictionary") + + endpoint = "location/group" + api.post(endpoint, data, api_version=1) + + +def get_location_group(group_id: str, office_id: str, category_id: str) -> Data: + """ + Parameters + ---------- + group_id : str + Specifies the location_group whose data is to be included in the response + office_id : str + Specifies the owning office of the location group whose data is to be included in the response. + category_id : str + Specifies the category containing the location group whose data is to be included in the response. + + Returns + ------- + response : dict + the JSON response from CWMS Data API. + + Raises + ------ + ValueError + If any of group_id or office_id is None. + ClientError + If a 400 range error code response is returned from the server. + NoDataFoundError + If a 404 range error code response is returned from the server. + ServerError + If a 500 range error code response is returned from the server. + """ + + if group_id is None: + raise ValueError("Retrieve location group requires a group id") + if office_id is None: + raise ValueError("Retrieve location group requires an office") + if category_id is None: + raise ValueError("Retrieve location group requires a category id") + + endpoint = f"location/group/{group_id}" + + params = {"office": office_id, "category-id": category_id} + response = api.get(endpoint, params) + + return Data(response) + + +def get_location_groups( + office_id: Optional[str], + include_assigned: Optional[bool], + location_category_like: Optional[str], +) -> Data: + """ + Parameters + ---------- + office_id : str + Specifies the owning office of the location group(s) whose data is to be included in the response. If this field is not specified, matching location groups information from all offices shall be returned. + include_assigned : str + Include the assigned locations in the returned location groups. (default: false) + location_category_like : str + Posix regular expression matching against the location category id + + References + ---------- + https://cwms.usace.army.mil/cwms-data-api/endpoint/location/group + + Returns + ------- + response : dict + the JSON response from CWMS Data API. + + Raises + ------ + ValueError + If any of office_id or category_id is None. + ClientError + If a 400 range error code response is returned from the server. + NoDataFoundError + If a 404 range error code response is returned from the server. + ServerError + If a 500 range error code response is returned from the server. + """ + + endpoint = "location/group" + params = { + "office": office_id, + "include_assigned": include_assigned, + "location_category_like": location_category_like, + } + + response = api.get(endpoint, params, api_version=1) + + return Data(response) + + +def delete_location_group( + group_id: str, office_id: str, delete_method: DeleteMethod +) -> None: + """ + Parameters + ---------- + group_id : str + The ID of the location group. + office_id : str + The ID of the office. + delete_method: DeleteMethod + The method to use to delete the location group. + + Returns + ------- + None + + Raises + ------ + ValueError + If any of group_id, office_id, or delete_method is None. + ClientError + If a 400 range error code response is returned from the server. + If a 404 range error code response is returned from the server. + ServerError + If a 500 range error code response is returned from the server. + """ + + if group_id is None: + raise ValueError("Delete location group requires a group id") + if office_id is None: + raise ValueError("Delete location group requires an office") + if delete_method is None: + raise ValueError("Delete location group requires a delete method") + + endpoint = f"location/group/{group_id}" + params = {"office": office_id, "method": delete_method.name} + api.delete(endpoint, params) + + +def update_location_group( + data: JSON, office_id: str, group_id: str, replace_assigned_locs: Optional[bool] +) -> None: + """ + Parameters + ---------- + office_id : str + Specifies the office of the user making the request. This is the office that the location, group, and category belong to. If the group and/or category belong to the CWMS office, this only identifies the location. + replace_assigned_locs : str + Specifies whether to unassign all existing locations before assigning new locations specified in the content body Default: false + group_id : str + The new name of the location group. + + Returns + ------- + None + + Raises + ------ + ValueError + If any of old_location_group_name, new_location_group_name, or office_id is None. + ClientError + If a 400 range error code response is returned from the server. + NoDataFoundError + If a 404 range error code response is returned from the server. + ServerError + If a 500 range error code response is returned from the server. + """ + + if group_id is None: + raise ValueError("Rename location group requires a location group name") + if office_id is None: + raise ValueError("Rename location group requires an office") + + endpoint = f"location/group/{group_id}" + params = {"office": office_id, "replace-assigned-locs": replace_assigned_locs} + api.patch(data=data, endpoint=endpoint, params=params) From e2f1d0186ba0fbb4a77d76c7dc798fa9c369af20 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Tue, 11 Mar 2025 21:58:15 -0500 Subject: [PATCH 02/25] Bump version, fix typo in project toml description --- .gitignore | 1 + pyproject.toml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 776a7183..8bc2be71 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ **/target tmp **/\.~lock* +scripts # Byte-compiled / optimized / DLL files **/__pycache__/ diff --git a/pyproject.toml b/pyproject.toml index d46a7558..ec7c72a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [tool.poetry] name = "cwms-python" repository = "https://github.com/HydrologicEngineeringCenter/cwms-python" -version = "0.6.0" +version = "0.6.1" packages = [ { include = "cwms" }, ] -description = "Corps water managerment systems (CWMS) REST API for Data Retrieval of USACE water data" +description = "Corps water management systems (CWMS) REST API for Data Retrieval of USACE water data" readme = "README.md" license = "LICENSE" keywords = ["USACE", "water data", "CWMS"] From 4328f084581be0a63861a6d80a525917cf6f9b29 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Tue, 11 Mar 2025 22:07:38 -0500 Subject: [PATCH 03/25] Change response close method to context manager (with) to ensure closure of HTTP response. This will prevent resource (connection) leaks in the event the request fails. --- cwms/api.py | 76 +++++++++++++++++++++++------------------------------ 1 file changed, 33 insertions(+), 43 deletions(-) diff --git a/cwms/api.py b/cwms/api.py index 31afe02a..9213bd09 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -190,18 +190,16 @@ def get_xml( """ headers = {"Accept": api_version_text(api_version)} - response = SESSION.get(endpoint, params=params, headers=headers) - response.close() + with SESSION.get(endpoint, params=params, headers=headers) as response: + if response.status_code < 200 or response.status_code >= 300: + logging.error(f"CDA Error: response={response}") + raise ApiError(response) - if response.status_code < 200 or response.status_code >= 300: - logging.error(f"CDA Error: response={response}") - raise ApiError(response) - - try: - return response.content.decode("utf-8") - except JSONDecodeError as error: - logging.error(f"Error decoding CDA response as xml: {error}") - return {} + try: + return response.content.decode("utf-8") + except JSONDecodeError as error: + logging.error(f"Error decoding CDA response as xml: {error}") + return {} def get( @@ -228,17 +226,15 @@ def get( """ headers = {"Accept": api_version_text(api_version)} - response = SESSION.get(endpoint, params=params, headers=headers) - response.close() - if response.status_code < 200 or response.status_code >= 300: - logging.error(f"CDA Error: response={response}") - raise ApiError(response) - - try: - return cast(JSON, response.json()) - except JSONDecodeError as error: - logging.error(f"Error decoding CDA response as json: {error}") - return {} + with SESSION.get(endpoint, params=params, headers=headers) as response: + if response.status_code < 200 or response.status_code >= 300: + logging.error(f"CDA Error: response={response}") + raise ApiError(response) + try: + return cast(JSON, response.json()) + except JSONDecodeError as error: + logging.error(f"Error decoding CDA response as json: {error}") + return {} def get_with_paging( @@ -312,12 +308,10 @@ def post( if isinstance(data, dict) or isinstance(data, list): data = json.dumps(data) - response = SESSION.post(endpoint, params=params, headers=headers, data=data) - response.close() - - if response.status_code < 200 or response.status_code >= 300: - logging.error(f"CDA Error: response={response}") - raise ApiError(response) + with SESSION.post(endpoint, params=params, headers=headers, data=data) as response: + if response.status_code < 200 or response.status_code >= 300: + logging.error(f"CDA Error: response={response}") + raise ApiError(response) def patch( @@ -346,16 +340,13 @@ def patch( """ headers = {"accept": "*/*", "Content-Type": api_version_text(api_version)} - if data is None: - response = SESSION.patch(endpoint, params=params, headers=headers) - else: - if isinstance(data, dict) or isinstance(data, list): - data = json.dumps(data) - response = SESSION.patch(endpoint, params=params, headers=headers, data=data) - response.close() - if response.status_code < 200 or response.status_code >= 300: - logging.error(f"CDA Error: response={response}") - raise ApiError(response) + + if data and isinstance(data, dict) or isinstance(data, list): + data = json.dumps(data) + with SESSION.patch(endpoint, params=params, headers=headers, data=data) as response: + if response.status_code < 200 or response.status_code >= 300: + logging.error(f"CDA Error: response={response}") + raise ApiError(response) def delete( @@ -379,8 +370,7 @@ def delete( """ headers = {"Accept": api_version_text(api_version)} - response = SESSION.delete(endpoint, params=params, headers=headers) - response.close() - if response.status_code < 200 or response.status_code >= 300: - logging.error(f"CDA Error: response={response}") - raise ApiError(response) + with SESSION.delete(endpoint, params=params, headers=headers) as response: + if response.status_code < 200 or response.status_code >= 300: + logging.error(f"CDA Error: response={response}") + raise ApiError(response) From b9319c85349490c51ec7578554ccaa9514440f96 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Tue, 11 Mar 2025 22:11:34 -0500 Subject: [PATCH 04/25] Switch to using response.ok as 300 are usually handled by requests library and redirects still return valid data. Simplify by accepting anything under 400 with response.ok --- cwms/api.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cwms/api.py b/cwms/api.py index 9213bd09..c382b464 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -191,7 +191,7 @@ def get_xml( headers = {"Accept": api_version_text(api_version)} with SESSION.get(endpoint, params=params, headers=headers) as response: - if response.status_code < 200 or response.status_code >= 300: + if not response.ok: logging.error(f"CDA Error: response={response}") raise ApiError(response) @@ -227,7 +227,7 @@ def get( headers = {"Accept": api_version_text(api_version)} with SESSION.get(endpoint, params=params, headers=headers) as response: - if response.status_code < 200 or response.status_code >= 300: + if not response.ok: logging.error(f"CDA Error: response={response}") raise ApiError(response) try: @@ -309,7 +309,7 @@ def post( data = json.dumps(data) with SESSION.post(endpoint, params=params, headers=headers, data=data) as response: - if response.status_code < 200 or response.status_code >= 300: + if not response.ok: logging.error(f"CDA Error: response={response}") raise ApiError(response) @@ -344,7 +344,7 @@ def patch( if data and isinstance(data, dict) or isinstance(data, list): data = json.dumps(data) with SESSION.patch(endpoint, params=params, headers=headers, data=data) as response: - if response.status_code < 200 or response.status_code >= 300: + if not response.ok: logging.error(f"CDA Error: response={response}") raise ApiError(response) @@ -371,6 +371,6 @@ def delete( headers = {"Accept": api_version_text(api_version)} with SESSION.delete(endpoint, params=params, headers=headers) as response: - if response.status_code < 200 or response.status_code >= 300: + if not response.ok: logging.error(f"CDA Error: response={response}") raise ApiError(response) From a9555b2a11f55ba557aaba1699f378674fc2805c Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Tue, 11 Mar 2025 22:37:25 -0500 Subject: [PATCH 05/25] Update blob return type, make get requests dynamic on response content-type --- cwms/api.py | 31 ++++++++++++++++--------------- cwms/catalog/blobs.py | 6 +++--- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/cwms/api.py b/cwms/api.py index c382b464..8ea44880 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -188,18 +188,8 @@ def get_xml( Raises: ApiError: If an error response is return by the API. """ - - headers = {"Accept": api_version_text(api_version)} - with SESSION.get(endpoint, params=params, headers=headers) as response: - if not response.ok: - logging.error(f"CDA Error: response={response}") - raise ApiError(response) - - try: - return response.content.decode("utf-8") - except JSONDecodeError as error: - logging.error(f"Error decoding CDA response as xml: {error}") - return {} + # Wrap the primary get for backwards compatibility + return get(endpoint=endpoint, params=params, api_version=api_version) def get( @@ -231,10 +221,21 @@ def get( logging.error(f"CDA Error: response={response}") raise ApiError(response) try: - return cast(JSON, response.json()) + # Avoid case sensitivity issues with the content type header + content_type = response.headers.get("Content-Type", "").lower() + # Most CDA content is JSON + if "application/json" in content_type: + return cast(JSON, response.json()) + # Use automatic charset detection with .text + if "text/plain" in content_type or "text/" in content_type: + return response.text + # Fallback for remaining content types + return response.content.decode("utf-8") except JSONDecodeError as error: - logging.error(f"Error decoding CDA response as json: {error}") - return {} + logging.error( + f"Error decoding CDA response as JSON: {error} on line {error.lineno}\n\tFalling back to text" + ) + return response.text def get_with_paging( diff --git a/cwms/catalog/blobs.py b/cwms/catalog/blobs.py index 3e622697..788257c0 100644 --- a/cwms/catalog/blobs.py +++ b/cwms/catalog/blobs.py @@ -4,7 +4,7 @@ from cwms.cwms_types import JSON, Data -def get_blob(blob_id: str, office_id: str) -> Data: +def get_blob(blob_id: str, office_id: str) -> str: """Get a single clob. Parameters @@ -17,13 +17,13 @@ def get_blob(blob_id: str, office_id: str) -> Data: Returns ------- - cwms data type. data.json will return the JSON output and data.df will return a dataframe + str: the value returned based on the content-type it was stored with as a string """ endpoint = f"blobs/{blob_id}" params = {"office": office_id} response = api.get(endpoint, params, api_version=1) - return Data(response) + return response def get_blobs( From 3fc330c5125ea7d6f2b05333a4f31ced3b6e9594 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Tue, 11 Mar 2025 22:42:13 -0500 Subject: [PATCH 06/25] Fix clob to BLOB in get, add extra notes for storing/gets to pydocs --- cwms/catalog/blobs.py | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/cwms/catalog/blobs.py b/cwms/catalog/blobs.py index 788257c0..39171ca6 100644 --- a/cwms/catalog/blobs.py +++ b/cwms/catalog/blobs.py @@ -5,12 +5,12 @@ def get_blob(blob_id: str, office_id: str) -> str: - """Get a single clob. + """Get a single BLOB (Binary Large Object). Parameters ---------- blob_id: string - Specifies the id of the blob + Specifies the id of the blob. ALL blob ids are UPPERCASE. office_id: string Specifies the office of the blob. @@ -58,22 +58,25 @@ def store_blobs(data: JSON, fail_if_exists: Optional[bool] = True) -> None: """Create New Blob Parameters - ---------- - Data: JSON dictionary - JSON containing information of Blob to be updated - { - "office-id": "string", - "id": "string", - "description": "string", - "media-type-id": "string", - "value": "string" - } - fail_if_exists: Boolean - Create will fail if provided ID already exists. Default: true - - Returns - ------- - None + ---------- + **Note**: The "id" field must be uppercase, or it will be automatically cast to uppercase. + + Data: JSON dictionary + JSON containing information of Blob to be updated. + + { + "office-id": "string", + "id": "STRING", + "description": "string", + "media-type-id": "string", + "value": "string" + } + fail_if_exists: Boolean + Create will fail if the provided ID already exists. Default: True + + Returns + ------- + None """ if not isinstance(data, dict): From 5b89757e9a68a5fff3cb1ab58956057f52428e11 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Tue, 11 Mar 2025 22:53:44 -0500 Subject: [PATCH 07/25] Create base64 check utility for blobs --- cwms/utils/__init__.py | 0 cwms/utils/checks.py | 10 ++++++++++ 2 files changed, 10 insertions(+) create mode 100644 cwms/utils/__init__.py create mode 100644 cwms/utils/checks.py diff --git a/cwms/utils/__init__.py b/cwms/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cwms/utils/checks.py b/cwms/utils/checks.py new file mode 100644 index 00000000..e16e7235 --- /dev/null +++ b/cwms/utils/checks.py @@ -0,0 +1,10 @@ +import base64 + + +def is_base64(s: str) -> bool: + """Check if a string is Base64 encoded.""" + try: + decoded = base64.b64decode(s, validate=True) + return base64.b64encode(decoded).decode("utf-8") == s + except (ValueError, TypeError): + return False From a2000887a39188a41d57c0ad74989b491cd24a24 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Tue, 11 Mar 2025 22:54:46 -0500 Subject: [PATCH 08/25] Add blurb to users on how to format the BLOB storage, correct note, ensure VALUE stored to CDA is a base64 encoded string. --- cwms/catalog/blobs.py | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/cwms/catalog/blobs.py b/cwms/catalog/blobs.py index 39171ca6..8066dbe0 100644 --- a/cwms/catalog/blobs.py +++ b/cwms/catalog/blobs.py @@ -1,7 +1,18 @@ +import base64 from typing import Optional import cwms.api as api from cwms.cwms_types import JSON, Data +from cwms.utils.checks import is_base64 + +STORE_DICT = """data = { + "office-id": "SWT", + "id": "MYFILE_OR_BLOB_ID.TXT", + "description": "Your description here", + "media-type-id": "application/octet-stream", + "value": "STRING of content or BASE64_ENCODED_STRING" +} +""" def get_blob(blob_id: str, office_id: str) -> str: @@ -55,22 +66,16 @@ def get_blobs( def store_blobs(data: JSON, fail_if_exists: Optional[bool] = True) -> None: - """Create New Blob + f"""Create New Blob Parameters ---------- - **Note**: The "id" field must be uppercase, or it will be automatically cast to uppercase. + **Note**: The "id" field is automatically cast to uppercase. Data: JSON dictionary JSON containing information of Blob to be updated. - { - "office-id": "string", - "id": "STRING", - "description": "string", - "media-type-id": "string", - "value": "string" - } + {STORE_DICT} fail_if_exists: Boolean Create will fail if the provided ID already exists. Default: True @@ -80,9 +85,15 @@ def store_blobs(data: JSON, fail_if_exists: Optional[bool] = True) -> None: """ if not isinstance(data, dict): - raise ValueError("Cannot store a Blob without a JSON data dictionary") + raise ValueError( + f"Cannot store a Blob without a JSON data dictionary:\n{STORE_DICT}" + ) + + # Encode value if it's not already Base64-encoded + if "value" in data and not is_base64(data["value"]): + # Encode to bytes, then Base64, then decode to string for storing + data["value"] = base64.b64encode(data["value"].encode("utf-8")).decode("utf-8") endpoint = "blobs" params = {"fail-if-exists": fail_if_exists} - return api.post(endpoint, data, params, api_version=1) From 7c186d89d6241eb49fbe298c0a16963a79cb4ee9 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Tue, 11 Mar 2025 23:45:36 -0500 Subject: [PATCH 09/25] Not sure how to set the headers in the mock requests response, for now if no content type is set fall back to json --- cwms/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cwms/api.py b/cwms/api.py index 8ea44880..5a1338eb 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -224,7 +224,7 @@ def get( # Avoid case sensitivity issues with the content type header content_type = response.headers.get("Content-Type", "").lower() # Most CDA content is JSON - if "application/json" in content_type: + if "application/json" in content_type or not content_type: return cast(JSON, response.json()) # Use automatic charset detection with .text if "text/plain" in content_type or "text/" in content_type: From 2f358076c108469a2d4c852aa3cfa47ef6d65814 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Tue, 11 Mar 2025 23:49:56 -0500 Subject: [PATCH 10/25] Ensure string or JSON can return from blob --- cwms/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cwms/api.py b/cwms/api.py index 5a1338eb..462b4e08 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -29,7 +29,7 @@ import json import logging from json import JSONDecodeError -from typing import Any, Optional, cast +from typing import Any, Optional, cast, Union from requests import Response, adapters from requests_toolbelt import sessions # type: ignore @@ -197,7 +197,7 @@ def get( params: Optional[RequestParams] = None, *, api_version: int = API_VERSION, -) -> JSON: +) -> Union[JSON, str]: """Make a GET request to the CWMS Data API. Args: From e504397247262465e3eddc738d58b2eb9122e697 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Tue, 11 Mar 2025 23:54:12 -0500 Subject: [PATCH 11/25] Forgot to install precommit on this box, fix sort order of typing for iosort --- cwms/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cwms/api.py b/cwms/api.py index 462b4e08..0c3279dd 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -29,7 +29,7 @@ import json import logging from json import JSONDecodeError -from typing import Any, Optional, cast, Union +from typing import Any, Optional, Union, cast from requests import Response, adapters from requests_toolbelt import sessions # type: ignore From ae96f0d23ab2bfa54cbbdd30c31278a452639449 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Tue, 11 Mar 2025 23:59:07 -0500 Subject: [PATCH 12/25] Attempt casting get_blob to string and reverting type union for get --- cwms/api.py | 4 ++-- cwms/catalog/blobs.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cwms/api.py b/cwms/api.py index 0c3279dd..5a1338eb 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -29,7 +29,7 @@ import json import logging from json import JSONDecodeError -from typing import Any, Optional, Union, cast +from typing import Any, Optional, cast from requests import Response, adapters from requests_toolbelt import sessions # type: ignore @@ -197,7 +197,7 @@ def get( params: Optional[RequestParams] = None, *, api_version: int = API_VERSION, -) -> Union[JSON, str]: +) -> JSON: """Make a GET request to the CWMS Data API. Args: diff --git a/cwms/catalog/blobs.py b/cwms/catalog/blobs.py index 8066dbe0..1a5b6435 100644 --- a/cwms/catalog/blobs.py +++ b/cwms/catalog/blobs.py @@ -34,7 +34,7 @@ def get_blob(blob_id: str, office_id: str) -> str: endpoint = f"blobs/{blob_id}" params = {"office": office_id} response = api.get(endpoint, params, api_version=1) - return response + return str(response) def get_blobs( From fada38ab9fd4a78d612fb725935a18cdfd19dacc Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Wed, 12 Mar 2025 00:07:04 -0500 Subject: [PATCH 13/25] Wrap all the responses for get that are not json in a dictionary so the JSON response is happy for mypy --- cwms/api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cwms/api.py b/cwms/api.py index 5a1338eb..cfa0fc1e 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -228,14 +228,14 @@ def get( return cast(JSON, response.json()) # Use automatic charset detection with .text if "text/plain" in content_type or "text/" in content_type: - return response.text + return {"value": response.text} # Fallback for remaining content types - return response.content.decode("utf-8") + return {"value": response.content.decode("utf-8")} except JSONDecodeError as error: logging.error( f"Error decoding CDA response as JSON: {error} on line {error.lineno}\n\tFalling back to text" ) - return response.text + return {"error": response.text} def get_with_paging( From 179212038b764c2adea3065d0a3fa7e965a34bf0 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Wed, 12 Mar 2025 21:39:46 -0500 Subject: [PATCH 14/25] Remove duplicate group methods --- cwms/location/group.py | 212 ----------------------------------------- 1 file changed, 212 deletions(-) delete mode 100644 cwms/location/group.py diff --git a/cwms/location/group.py b/cwms/location/group.py deleted file mode 100644 index 7546fc3f..00000000 --- a/cwms/location/group.py +++ /dev/null @@ -1,212 +0,0 @@ -# Copyright (c) 2024 -# United States Army Corps of Engineers - Hydrologic Engineering Center (USACE/HEC) -# All Rights Reserved. USACE PROPRIETARY/CONFIDENTIAL. -# Source may not be released without written approval from HEC -from typing import Optional - -import cwms.api as api -from cwms.cwms_types import JSON, Data, DeleteMethod - - -def store_location_group(data: JSON, fail_if_exists: Optional[bool]) -> None: - """ - Parameters - ---------- - data : dict - A dictionary representing the JSON data to be stored. - If the `data` value is None, a `ValueError` will be raised. - fail_if_exists : str, optional - A boolean value indicating whether to fail if the location group already exists. - Default is True. - - Returns - ------- - None - - Raises - ------ - ValueError - If any of data is None. - ClientError - If a 400 range error code response is returned from the server. - NoDataFoundError - If a 404 range error code response is returned from the server. - ServerError - If a 500 range error code response is returned from the server. - """ - - if data is None: - raise ValueError("Cannot store a location group without a JSON data dictionary") - - endpoint = "location/group" - api.post(endpoint, data, api_version=1) - - -def get_location_group(group_id: str, office_id: str, category_id: str) -> Data: - """ - Parameters - ---------- - group_id : str - Specifies the location_group whose data is to be included in the response - office_id : str - Specifies the owning office of the location group whose data is to be included in the response. - category_id : str - Specifies the category containing the location group whose data is to be included in the response. - - Returns - ------- - response : dict - the JSON response from CWMS Data API. - - Raises - ------ - ValueError - If any of group_id or office_id is None. - ClientError - If a 400 range error code response is returned from the server. - NoDataFoundError - If a 404 range error code response is returned from the server. - ServerError - If a 500 range error code response is returned from the server. - """ - - if group_id is None: - raise ValueError("Retrieve location group requires a group id") - if office_id is None: - raise ValueError("Retrieve location group requires an office") - if category_id is None: - raise ValueError("Retrieve location group requires a category id") - - endpoint = f"location/group/{group_id}" - - params = {"office": office_id, "category-id": category_id} - response = api.get(endpoint, params) - - return Data(response) - - -def get_location_groups( - office_id: Optional[str], - include_assigned: Optional[bool], - location_category_like: Optional[str], -) -> Data: - """ - Parameters - ---------- - office_id : str - Specifies the owning office of the location group(s) whose data is to be included in the response. If this field is not specified, matching location groups information from all offices shall be returned. - include_assigned : str - Include the assigned locations in the returned location groups. (default: false) - location_category_like : str - Posix regular expression matching against the location category id - - References - ---------- - https://cwms.usace.army.mil/cwms-data-api/endpoint/location/group - - Returns - ------- - response : dict - the JSON response from CWMS Data API. - - Raises - ------ - ValueError - If any of office_id or category_id is None. - ClientError - If a 400 range error code response is returned from the server. - NoDataFoundError - If a 404 range error code response is returned from the server. - ServerError - If a 500 range error code response is returned from the server. - """ - - endpoint = "location/group" - params = { - "office": office_id, - "include_assigned": include_assigned, - "location_category_like": location_category_like, - } - - response = api.get(endpoint, params, api_version=1) - - return Data(response) - - -def delete_location_group( - group_id: str, office_id: str, delete_method: DeleteMethod -) -> None: - """ - Parameters - ---------- - group_id : str - The ID of the location group. - office_id : str - The ID of the office. - delete_method: DeleteMethod - The method to use to delete the location group. - - Returns - ------- - None - - Raises - ------ - ValueError - If any of group_id, office_id, or delete_method is None. - ClientError - If a 400 range error code response is returned from the server. - If a 404 range error code response is returned from the server. - ServerError - If a 500 range error code response is returned from the server. - """ - - if group_id is None: - raise ValueError("Delete location group requires a group id") - if office_id is None: - raise ValueError("Delete location group requires an office") - if delete_method is None: - raise ValueError("Delete location group requires a delete method") - - endpoint = f"location/group/{group_id}" - params = {"office": office_id, "method": delete_method.name} - api.delete(endpoint, params) - - -def update_location_group( - data: JSON, office_id: str, group_id: str, replace_assigned_locs: Optional[bool] -) -> None: - """ - Parameters - ---------- - office_id : str - Specifies the office of the user making the request. This is the office that the location, group, and category belong to. If the group and/or category belong to the CWMS office, this only identifies the location. - replace_assigned_locs : str - Specifies whether to unassign all existing locations before assigning new locations specified in the content body Default: false - group_id : str - The new name of the location group. - - Returns - ------- - None - - Raises - ------ - ValueError - If any of old_location_group_name, new_location_group_name, or office_id is None. - ClientError - If a 400 range error code response is returned from the server. - NoDataFoundError - If a 404 range error code response is returned from the server. - ServerError - If a 500 range error code response is returned from the server. - """ - - if group_id is None: - raise ValueError("Rename location group requires a location group name") - if office_id is None: - raise ValueError("Rename location group requires an office") - - endpoint = f"location/group/{group_id}" - params = {"office": office_id, "replace-assigned-locs": replace_assigned_locs} - api.patch(data=data, endpoint=endpoint, params=params) From 2fe3602878900a7ddccc8f72bbe97283535dc78b Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Wed, 12 Mar 2025 21:42:29 -0500 Subject: [PATCH 15/25] Ensure accept mimetype is set for proper file type response; Update blob to correct version; Change types to Any that can return various mimetypes (not just json); Change from 102 to xml/v2 --- cwms/api.py | 101 ++++++++++++++++-------------------- cwms/catalog/blobs.py | 2 +- cwms/ratings/ratings.py | 111 ++++++---------------------------------- 3 files changed, 63 insertions(+), 151 deletions(-) diff --git a/cwms/api.py b/cwms/api.py index cfa0fc1e..fa6c61c3 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -28,6 +28,7 @@ import json import logging +import mimetypes from json import JSONDecodeError from typing import Any, Optional, cast @@ -138,58 +139,31 @@ def return_base_url() -> str: return str(SESSION.base_url) -def api_version_text(api_version: int) -> str: - """Initialize CDA request headers. - - The CDA supports multiple versions. To request a specific version, the version number +def api_version_text(api_version: int = 0, format: str = "") -> str: + """CDA supports multiple versions per endpoint. To request a specific version, the version number must be included in the request headers. Args: api_version: The CDA version to use for the request. - - Returns: - A dict containing the request headers. - - Raises: - InvalidVersion: If an unsupported API version is specified. - """ - - if api_version == 1: - version = "application/json" - elif api_version == 2: - version = "application/json;version=2" - elif api_version == 102: - version = "application/xml;version=2" - else: - raise InvalidVersion(f"API version {api_version} is not supported.") - - return version - - -def get_xml( - endpoint: str, - params: Optional[RequestParams] = None, - *, - api_version: int = API_VERSION, -) -> Any: - """Make a GET request to the CWMS Data API. - - Args: - endpoint: The CDA endpoint for the record(s). - params (optional): Query parameters for the request. - - Keyword Args: - api_version (optional): The CDA version to use for the request. If not specified, - the default API_VERSION will be used. - + format: The format of the response data. + Options include: + - json (default) + - xml + - csv + - tab + Example: + api_version_text(2, "json") returns "application/json;version=2" Returns: - The deserialized JSON response data. - - Raises: - ApiError: If an error response is return by the API. + A accept header string with the specified version and/or format. """ - # Wrap the primary get for backwards compatibility - return get(endpoint=endpoint, params=params, api_version=api_version) + # Support future versions by dynamically pulling the format from the mimetypes module + mimetype = mimetypes.types_map.get(f".{format}", "application/json") + # Handle edge cases the stdlib does not return as expected + if format == "xml": + # XML returns from stdlib as text/xml, but CDA expects application/xml + mimetype = "application/xml" + # For present and future versions, append the version number if one is provided. Zero will not set a version. + return mimetype + f";version={api_version}" if api_version else mimetype def get( @@ -197,7 +171,8 @@ def get( params: Optional[RequestParams] = None, *, api_version: int = API_VERSION, -) -> JSON: + format: str = "json", +) -> Any: """Make a GET request to the CWMS Data API. Args: @@ -214,9 +189,9 @@ def get( Raises: ApiError: If an error response is return by the API. """ - - headers = {"Accept": api_version_text(api_version)} + headers = {"Accept": api_version_text(api_version, format=format)} with SESSION.get(endpoint, params=params, headers=headers) as response: + print(response.url, response.headers) if not response.ok: logging.error(f"CDA Error: response={response}") raise ApiError(response) @@ -228,9 +203,9 @@ def get( return cast(JSON, response.json()) # Use automatic charset detection with .text if "text/plain" in content_type or "text/" in content_type: - return {"value": response.text} + return response.text # Fallback for remaining content types - return {"value": response.content.decode("utf-8")} + return response.content.decode("utf-8") except JSONDecodeError as error: logging.error( f"Error decoding CDA response as JSON: {error} on line {error.lineno}\n\tFalling back to text" @@ -244,7 +219,8 @@ def get_with_paging( params: RequestParams, *, api_version: int = API_VERSION, -) -> JSON: + format: str = "json", +) -> Any: """Make a GET request to the CWMS Data API with paging. Args: @@ -255,6 +231,7 @@ def get_with_paging( Keyword Args: api_version (optional): The CDA version to use for the request. If not specified, the default API_VERSION will be used. + format (optional): The format of the body data. Returns: The deserialized JSON response data. @@ -265,7 +242,7 @@ def get_with_paging( first_pass = True while (params["page"] is not None) or first_pass: - temp = get(endpoint, params, api_version=api_version) + temp = get(endpoint, params, api_version=api_version, format=format) if first_pass: response = temp else: @@ -284,6 +261,7 @@ def post( params: Optional[RequestParams] = None, *, api_version: int = API_VERSION, + format: str = "json", ) -> None: """Make a POST request to the CWMS Data API. @@ -295,6 +273,7 @@ def post( Keyword Args: api_version (optional): The CDA version to use for the request. If not specified, the default API_VERSION will be used. + format(optional): The format of the body data. Returns: The deserialized JSON response data. @@ -304,7 +283,10 @@ def post( """ # post requires different headers than get for - headers = {"accept": "*/*", "Content-Type": api_version_text(api_version)} + headers = { + "accept": "*/*", + "Content-Type": api_version_text(api_version, format=format), + } if isinstance(data, dict) or isinstance(data, list): data = json.dumps(data) @@ -321,6 +303,7 @@ def patch( params: Optional[RequestParams] = None, *, api_version: int = API_VERSION, + format: str = "json", ) -> None: """Make a PATCH request to the CWMS Data API. @@ -332,6 +315,7 @@ def patch( Keyword Args: api_version (optional): The CDA version to use for the request. If not specified, the default API_VERSION will be used. + format (optional): The format of the body data. Returns: The deserialized JSON response data. @@ -340,7 +324,10 @@ def patch( ApiError: If an error response is return by the API. """ - headers = {"accept": "*/*", "Content-Type": api_version_text(api_version)} + headers = { + "accept": "*/*", + "Content-Type": api_version_text(api_version, format=format), + } if data and isinstance(data, dict) or isinstance(data, list): data = json.dumps(data) @@ -355,12 +342,14 @@ def delete( params: Optional[RequestParams] = None, *, api_version: int = API_VERSION, + format: str = "json", ) -> None: """Make a DELETE request to the CWMS Data API. Args: endpoint: The CDA endpoint for the record. params (optional): Query parameters for the request. + format (optional): The format of the body data. Keyword Args: api_version (optional): The CDA version to use for the request. If not specified, @@ -370,7 +359,7 @@ def delete( ApiError: If an error response is return by the API. """ - headers = {"Accept": api_version_text(api_version)} + headers = {"Accept": api_version_text(api_version, format=format)} with SESSION.delete(endpoint, params=params, headers=headers) as response: if not response.ok: logging.error(f"CDA Error: response={response}") diff --git a/cwms/catalog/blobs.py b/cwms/catalog/blobs.py index 1a5b6435..d94758c2 100644 --- a/cwms/catalog/blobs.py +++ b/cwms/catalog/blobs.py @@ -61,7 +61,7 @@ def get_blobs( endpoint = "blobs" params = {"office": office_id, "page-size": page_size, "like": blob_id_like} - response = api.get(endpoint, params, api_version=1) + response = api.get(endpoint, params, api_version=2) return Data(response, selector="blobs") diff --git a/cwms/ratings/ratings.py b/cwms/ratings/ratings.py index f5b36650..a854ee05 100644 --- a/cwms/ratings/ratings.py +++ b/cwms/ratings/ratings.py @@ -34,7 +34,7 @@ def rating_current_effective_date(rating_id: str, office_id: str) -> Any: def get_current_rating( rating_id: str, office_id: str, -) -> Data: +) -> Any: """Retrives the rating table for the current active rating. i.e. the rating table with the latest effective date for the rating specification @@ -69,91 +69,6 @@ def get_current_rating( return rating -def get_current_rating_xml( - rating_id: str, - office_id: str, -) -> Any: - """Retrives the rating table for the current active rating. i.e. the rating table with the latest - effective date for the rating specification - - Parameters - ---------- - rating_id: string - The rating-id of the effective dates to be retrieved - office_id: string - The owning office of the rating specifications. If no office is provided information from all offices will - be returned - Returns - ------- - rating : str - xml data as a string - """ - - max_effective = rating_current_effective_date( - rating_id=rating_id, office_id=office_id - ) - - rating = get_ratings_xml( - rating_id=rating_id, - office_id=office_id, - begin=max_effective, - end=max_effective, - method="EAGER", - ) - - return rating - - -def get_ratings_xml( - rating_id: str, - office_id: str, - begin: Optional[datetime] = None, - end: Optional[datetime] = None, - timezone: Optional[str] = None, - method: Optional[str] = "EAGER", -) -> Any: - """Retrives ratings for a specific rating-id - - Parameters - ---------- - rating_id: string - The rating-id of the effective dates to be retrieved - office_id: string - The owning office of the rating specifications. If no office is provided information from all offices will - be returned - begin: datetime, optional - the start of the time window for data to be included in the response. This is based on the effective date of the ratings - end: datetime, optional - the end of the time window for data to be included int he reponse. This is based on the effective date of the ratings - timezone: - the time zone of the values in the being and end fields if not specified UTC is used - method: - the retrival method used - EAGER: retireves all ratings data include the individual dependenant and independant values - LAZY: retrieved all rating data excluding the individual dependance and independant values - REFERENCE: only retrievies reference data about the rating spec. - Returns - ------- - xml_data : str - xml data as a string - """ - methods = ["EAGER", "LAZY", "REFERENCE"] - if method not in methods: - raise ValueError("method needs to be one of EAGER, LAZY, or REFERENCE") - - endpoint = f"ratings/{rating_id}" - params = { - "office": office_id, - "begin": begin.isoformat() if begin else None, - "end": end.isoformat() if end else None, - "timezone": timezone, - "method": method, - } - - xml_data = api.get_xml(endpoint, params, api_version=102) - return xml_data - - def get_ratings( rating_id: str, office_id: str, @@ -161,8 +76,9 @@ def get_ratings( end: Optional[datetime] = None, timezone: Optional[str] = None, method: Optional[str] = "EAGER", + format: Optional[str] = None, single_rating_df: Optional[bool] = False, -) -> Data: +) -> Any: """Retrives ratings for a specific rating-id Parameters @@ -183,6 +99,12 @@ def get_ratings( EAGER: retireves all ratings data include the individual dependenant and independant values LAZY: retrieved all rating data excluding the individual dependance and independant values REFERENCE: only retrievies reference data about the rating spec. + format: + Specifies the encoding format of the response. Valid values for the format field for this URI are: + - tab + - csv + - xml + - json (default) single_rating_df: bool, optional = False Set to True when using eager and a single rating is returned. Will place the single rating into the .df function used with the get_current_rating or when a only a single rating curve is to be returned. @@ -202,9 +124,11 @@ def get_ratings( "end": end.isoformat() if end else None, "timezone": timezone, "method": method, + "format": format, } - response = api.get(endpoint, params) + response = api.get(endpoint, params, api_version=2) + if (method == "EAGER") and single_rating_df: data = Data(response, selector="simple-rating.rating-points") elif method == "REFERENCE": @@ -223,7 +147,7 @@ def rating_simple_df_to_json( transition_start_date: Optional[datetime] = None, description: Optional[str] = None, active: Optional[bool] = True, -) -> JSON: +) -> Any: """This function converts a dataframe to a json dictionary in the correct format to be posted using the store_ratings function. Can only be used for simple ratings with a indenpendant and 1 dependant variable. @@ -327,12 +251,11 @@ def update_ratings( raise ValueError( "Cannot store a rating without a JSON data dictionary or in XML" ) - + format = "json" + api_version = 2 if xml_heading in data: - api_version = 102 - else: - api_version = 2 - return api.patch(endpoint, data, params, api_version=api_version) + format = "xml" + return api.patch(endpoint, data, params, api_version=api_version, format=format) def delete_ratings( From 9c0befd44d7456d71502bede97dee1cd6a81d36a Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Wed, 12 Mar 2025 21:43:47 -0500 Subject: [PATCH 16/25] Change all references to 102 (xml, v2) to format="xml" and version 2 --- cwms/ratings/ratings.py | 8 ++++---- cwms/ratings/ratings_spec.py | 2 +- cwms/ratings/ratings_template.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cwms/ratings/ratings.py b/cwms/ratings/ratings.py index a854ee05..40ef4676 100644 --- a/cwms/ratings/ratings.py +++ b/cwms/ratings/ratings.py @@ -329,8 +329,8 @@ def store_rating(data: Any, store_template: Optional[bool] = True) -> None: "Cannot store a timeseries without a JSON data dictionaryor in XML" ) + api_version = 2 + format = "json" if xml_heading in data: - api_version = 102 - else: - api_version = 2 - return api.post(endpoint, data, params, api_version=api_version) + format = "xml" + return api.post(endpoint, data, params, api_version=api_version, format=format) diff --git a/cwms/ratings/ratings_spec.py b/cwms/ratings/ratings_spec.py index 684bbdb6..6bd9fddd 100644 --- a/cwms/ratings/ratings_spec.py +++ b/cwms/ratings/ratings_spec.py @@ -201,4 +201,4 @@ def store_rating_spec(data: str, fail_if_exists: Optional[bool] = True) -> None: endpoint = "ratings/spec/" params = {"fail-if-exists": fail_if_exists} - return api.post(endpoint, data, params, api_version=102) + return api.post(endpoint, data, params, format="xml", api_version=2) diff --git a/cwms/ratings/ratings_template.py b/cwms/ratings/ratings_template.py index 93d2d2ea..10f93a72 100644 --- a/cwms/ratings/ratings_template.py +++ b/cwms/ratings/ratings_template.py @@ -145,4 +145,4 @@ def store_rating_template(data: str, fail_if_exists: Optional[bool] = True) -> N endpoint = "ratings/template/" params = {"fail-if-exists": fail_if_exists} - return api.post(endpoint, data, params, api_version=102) + return api.post(endpoint, data, params, api_version=2, format="xml") From 32719b9baa30dc8f1405987cc3173c61a8a352a9 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Wed, 12 Mar 2025 21:49:51 -0500 Subject: [PATCH 17/25] Remove print statements --- cwms/api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cwms/api.py b/cwms/api.py index fa6c61c3..a1c11779 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -191,7 +191,6 @@ def get( """ headers = {"Accept": api_version_text(api_version, format=format)} with SESSION.get(endpoint, params=params, headers=headers) as response: - print(response.url, response.headers) if not response.ok: logging.error(f"CDA Error: response={response}") raise ApiError(response) From 841e0c2c9b9f8128c95d48da1ef3136b6ef49554 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Wed, 12 Mar 2025 22:04:50 -0500 Subject: [PATCH 18/25] Run spell checker through all files --- cwms/ratings/ratings.py | 28 ++++++++++++++-------------- cwms/ratings/ratings_spec.py | 14 +++++++------- cwms/timeseries/timeseries.py | 12 ++++++------ 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/cwms/ratings/ratings.py b/cwms/ratings/ratings.py index 40ef4676..d50bb8ad 100644 --- a/cwms/ratings/ratings.py +++ b/cwms/ratings/ratings.py @@ -15,7 +15,7 @@ def rating_current_effective_date(rating_id: str, office_id: str) -> Any: """Retrieve the most recent effective date for a specific rating id. Returns - datatime + Any the datetime of the most recent effective date for a rating id. If max effective date is not present for rating_id then None will be returned @@ -35,7 +35,7 @@ def get_current_rating( rating_id: str, office_id: str, ) -> Any: - """Retrives the rating table for the current active rating. i.e. the rating table with the latest + """Retrieves the rating table for the current active rating. i.e. the rating table with the latest effective date for the rating specification Parameters @@ -46,7 +46,7 @@ def get_current_rating( The owning office of the rating specifications. If no office is provided information from all offices will be returned rating_table_in_df: Bool, Optional Default = True - define if the independant and dependant variables should be stored as a dataframe + define if the independent and dependant variables should be stored as a dataframe Returns ------- Data : Data @@ -79,7 +79,7 @@ def get_ratings( format: Optional[str] = None, single_rating_df: Optional[bool] = False, ) -> Any: - """Retrives ratings for a specific rating-id + """Retrieves ratings for a specific rating-id Parameters ---------- @@ -91,14 +91,14 @@ def get_ratings( begin: datetime, optional the start of the time window for data to be included in the response. This is based on the effective date of the ratings end: datetime, optional - the end of the time window for data to be included int he reponse. This is based on the effective date of the ratings + the end of the time window for data to be included int he response. This is based on the effective date of the ratings timezone: the time zone of the values in the being and end fields if not specified UTC is used method: - the retrival method used - EAGER: retireves all ratings data include the individual dependenant and independant values - LAZY: retrieved all rating data excluding the individual dependance and independant values - REFERENCE: only retrievies reference data about the rating spec. + the retrieval method used + EAGER: retrieves all ratings data include the individual dependant and independent values + LAZY: retrieved all rating data excluding the individual dependence and independent values + REFERENCE: only retrieves reference data about the rating spec. format: Specifies the encoding format of the response. Valid values for the format field for this URI are: - tab @@ -149,13 +149,13 @@ def rating_simple_df_to_json( active: Optional[bool] = True, ) -> Any: """This function converts a dataframe to a json dictionary in the correct format to be posted using the store_ratings function. Can - only be used for simple ratings with a indenpendant and 1 dependant variable. + only be used for simple ratings with a independent and 1 dependant variable. Parameters ---------- data: pd.Dataframe Rating Table to be stored to an exiting rating specification and template. Can only have 2 columns ind and dep. ind - contained the indenpendant variable and dep contains the dependent variable. + contained the independent variable and dep contains the dependent variable. ind dep 0 9.62 0.01 1 9.63 0.01 @@ -173,7 +173,7 @@ def rating_simple_df_to_json( office_id: str the owning office of the rating units: str - units for both the independant and dependent variable seperated by ; i.e. ft;cfs or ft;ft. + units for both the independent and dependent variable separated by ; i.e. ft;cfs or ft;ft. effective_date: datetime, The effective date of the rating curve to be stored. transition_start_date: datetime Optional = None @@ -307,7 +307,7 @@ def delete_ratings( def store_rating(data: Any, store_template: Optional[bool] = True) -> None: - """Will create a new ratingset including template/spec and rating + """Will create a new rating-set including template/spec and rating Parameters ---------- @@ -326,7 +326,7 @@ def store_rating(data: Any, store_template: Optional[bool] = True) -> None: if not isinstance(data, dict) and xml_heading not in data: raise ValueError( - "Cannot store a timeseries without a JSON data dictionaryor in XML" + "Cannot store a timeseries without a JSON data dictionary or in XML" ) api_version = 2 diff --git a/cwms/ratings/ratings_spec.py b/cwms/ratings/ratings_spec.py index 6bd9fddd..f24602e1 100644 --- a/cwms/ratings/ratings_spec.py +++ b/cwms/ratings/ratings_spec.py @@ -8,7 +8,7 @@ def get_rating_spec(rating_id: str, office_id: str) -> Data: - """Retrives a single rating spec + """Retrieves a single rating spec Parameters ---------- @@ -37,7 +37,7 @@ def get_rating_specs( rating_id_mask: Optional[str] = None, page_size: int = 500000, ) -> Data: - """Retrives a list of rating specification + """Retrieves a list of rating specification Parameters ---------- @@ -45,7 +45,7 @@ def get_rating_specs( The owning office of the rating specifications. If no office is provided information from all offices will be returned rating-id-mask: string, optional - Posix regular expression that specifies the rating ids to be included in the reponce. If not specified all + Posix regular expression that specifies the rating ids to be included in the response. If not specified all rating specs shall be returned. page-size: int, optional, default is 5000000: Specifies the number of records to obtain in a single call. @@ -111,7 +111,7 @@ def rating_spec_df_to_xml(data: pd.DataFrame) -> str: Parameters ---------- data : pd_dataframe - pandas dataframe that contrains rating specification paramters + pandas dataframe that contains rating specification parameters should follow same formate the is returned from get_rating_spec function Returns ------- @@ -134,10 +134,10 @@ def rating_spec_df_to_xml(data: pd.DataFrame) -> str: {str(data.loc[0,'auto-migrate-extension']).lower()} """ - ind_rouding = data.loc[0, "independent-rounding-specs"] - if isinstance(ind_rouding, list): + ind_rounding = data.loc[0, "independent-rounding-specs"] + if isinstance(ind_rounding, list): i = 1 - for rounding in ind_rouding: + for rounding in ind_rounding: spec_xml = ( spec_xml + f"""\n {rounding['value']}""" diff --git a/cwms/timeseries/timeseries.py b/cwms/timeseries/timeseries.py index 4eef62a6..1e72e5f3 100644 --- a/cwms/timeseries/timeseries.py +++ b/cwms/timeseries/timeseries.py @@ -21,8 +21,8 @@ def get_multi_timeseries_df( Parameters ---------- - ts_ids: linst - a list of timeseries to get. If the timeseries is a verioned timeseries then serpeate the ts_id from the + ts_ids: list + a list of timeseries to get. If the timeseries is a versioned timeseries then separate the ts_id from the version_date using a :. Example "OMA.Stage.Inst.6Hours.0.Fcst-MRBWM-GRFT:2024-04-22 07:00:00-05:00". Make sure that the version date include the timezone offset if not in UTC. office_id: string @@ -163,7 +163,7 @@ def get_timeseries( not specified, any required time window ends at the current time. Any timezone information should be passed within the datetime object. If no timezone information is given, default will be UTC. - page_size: int, optional, default is 5000000: Sepcifies the number of records to obtain in + page_size: int, optional, default is 5000000: Specifies the number of records to obtain in a single call. version_date: datetime, optional, default is None Version date of time series values being requested. If this field is not specified and @@ -208,7 +208,7 @@ def timeseries_df_to_json( office_id: str, version_date: Optional[datetime] = None, ) -> JSON: - """This function converts a dataframe to a json dictionary in the correct format to be posted using the store_timeseries fucntion. + """This function converts a dataframe to a json dictionary in the correct format to be posted using the store_timeseries function. Parameters ---------- @@ -223,7 +223,7 @@ def timeseries_df_to_json( 2 2023-12-20T15:15:00.000-05:00 98.5 0 3 2023-12-20T15:30:00.000-05:00 98.5 0 ts_id: str - timeseried id:specified name of the timeseries to be posted to + timeseries id:specified name of the timeseries to be posted to office_id: str the owning office of the time series units: str @@ -280,7 +280,7 @@ def store_timeseries( ---------- data: JSON dictionary Time Series data to be stored. - create_as_ltrs: bool, optional, defualt is False + create_as_ltrs: bool, optional, default is False Flag indicating if timeseries should be created as Local Regular Time Series. store_rule: str, optional, default is None: The business rule to use when merging the incoming with existing data. Available values : From d40200297a23748bafc0989b51d1978100467d11 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Wed, 12 Mar 2025 22:13:09 -0500 Subject: [PATCH 19/25] Correct api_version_text to not set a version for 0 and 1; remove test for versions higher than two, allow CDA to say version is not supported. --- cwms/api.py | 2 +- tests/api_test.py | 7 ------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/cwms/api.py b/cwms/api.py index a1c11779..1511ffd5 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -163,7 +163,7 @@ def api_version_text(api_version: int = 0, format: str = "") -> str: # XML returns from stdlib as text/xml, but CDA expects application/xml mimetype = "application/xml" # For present and future versions, append the version number if one is provided. Zero will not set a version. - return mimetype + f";version={api_version}" if api_version else mimetype + return mimetype if api_version in [0, 1] else mimetype + f";version={api_version}" def get( diff --git a/tests/api_test.py b/tests/api_test.py index 86c5e3e3..86d7450d 100644 --- a/tests/api_test.py +++ b/tests/api_test.py @@ -48,10 +48,3 @@ def test_api_headers(): version = api_version_text(api_version=2) assert version == "application/json;version=2" - - -def test_api_headers_invalid_version(): - """An exception should be raised if the API version is not valid.""" - - with pytest.raises(InvalidVersion): - version = api_version_text(api_version=3) From 75ac1dcaadd73ab9a5763966b96562143188e4e0 Mon Sep 17 00:00:00 2001 From: Charles Graham SWT Date: Thu, 29 May 2025 15:36:26 -0500 Subject: [PATCH 20/25] Revert "Change all references to 102 (xml, v2) to format="xml" and version 2" This reverts commit 9c0befd44d7456d71502bede97dee1cd6a81d36a. --- cwms/ratings/ratings.py | 8 ++++---- cwms/ratings/ratings_spec.py | 2 +- cwms/ratings/ratings_template.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cwms/ratings/ratings.py b/cwms/ratings/ratings.py index d50bb8ad..3b871bcb 100644 --- a/cwms/ratings/ratings.py +++ b/cwms/ratings/ratings.py @@ -329,8 +329,8 @@ def store_rating(data: Any, store_template: Optional[bool] = True) -> None: "Cannot store a timeseries without a JSON data dictionary or in XML" ) - api_version = 2 - format = "json" if xml_heading in data: - format = "xml" - return api.post(endpoint, data, params, api_version=api_version, format=format) + api_version = 102 + else: + api_version = 2 + return api.post(endpoint, data, params, api_version=api_version) diff --git a/cwms/ratings/ratings_spec.py b/cwms/ratings/ratings_spec.py index f24602e1..5d08abb7 100644 --- a/cwms/ratings/ratings_spec.py +++ b/cwms/ratings/ratings_spec.py @@ -201,4 +201,4 @@ def store_rating_spec(data: str, fail_if_exists: Optional[bool] = True) -> None: endpoint = "ratings/spec/" params = {"fail-if-exists": fail_if_exists} - return api.post(endpoint, data, params, format="xml", api_version=2) + return api.post(endpoint, data, params, api_version=102) diff --git a/cwms/ratings/ratings_template.py b/cwms/ratings/ratings_template.py index 10f93a72..93d2d2ea 100644 --- a/cwms/ratings/ratings_template.py +++ b/cwms/ratings/ratings_template.py @@ -145,4 +145,4 @@ def store_rating_template(data: str, fail_if_exists: Optional[bool] = True) -> N endpoint = "ratings/template/" params = {"fail-if-exists": fail_if_exists} - return api.post(endpoint, data, params, api_version=2, format="xml") + return api.post(endpoint, data, params, api_version=102) From 9308abe999858a973ecbb64019eedc89ff40f455 Mon Sep 17 00:00:00 2001 From: Charles Graham SWT Date: Thu, 29 May 2025 15:39:44 -0500 Subject: [PATCH 21/25] Revert "Ensure accept mimetype is set for proper file type response;" This reverts commit 2fe3602878900a7ddccc8f72bbe97283535dc78b. --- cwms/api.py | 100 ++++++++++++++++++-------------- cwms/ratings/ratings.py | 123 ++++++++++++++++++++++++++++++++-------- 2 files changed, 156 insertions(+), 67 deletions(-) diff --git a/cwms/api.py b/cwms/api.py index 1511ffd5..cfa0fc1e 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -28,7 +28,6 @@ import json import logging -import mimetypes from json import JSONDecodeError from typing import Any, Optional, cast @@ -139,39 +138,39 @@ def return_base_url() -> str: return str(SESSION.base_url) -def api_version_text(api_version: int = 0, format: str = "") -> str: - """CDA supports multiple versions per endpoint. To request a specific version, the version number +def api_version_text(api_version: int) -> str: + """Initialize CDA request headers. + + The CDA supports multiple versions. To request a specific version, the version number must be included in the request headers. Args: api_version: The CDA version to use for the request. - format: The format of the response data. - Options include: - - json (default) - - xml - - csv - - tab - Example: - api_version_text(2, "json") returns "application/json;version=2" + Returns: - A accept header string with the specified version and/or format. + A dict containing the request headers. + + Raises: + InvalidVersion: If an unsupported API version is specified. """ - # Support future versions by dynamically pulling the format from the mimetypes module - mimetype = mimetypes.types_map.get(f".{format}", "application/json") - # Handle edge cases the stdlib does not return as expected - if format == "xml": - # XML returns from stdlib as text/xml, but CDA expects application/xml - mimetype = "application/xml" - # For present and future versions, append the version number if one is provided. Zero will not set a version. - return mimetype if api_version in [0, 1] else mimetype + f";version={api_version}" + if api_version == 1: + version = "application/json" + elif api_version == 2: + version = "application/json;version=2" + elif api_version == 102: + version = "application/xml;version=2" + else: + raise InvalidVersion(f"API version {api_version} is not supported.") -def get( + return version + + +def get_xml( endpoint: str, params: Optional[RequestParams] = None, *, api_version: int = API_VERSION, - format: str = "json", ) -> Any: """Make a GET request to the CWMS Data API. @@ -189,7 +188,34 @@ def get( Raises: ApiError: If an error response is return by the API. """ - headers = {"Accept": api_version_text(api_version, format=format)} + # Wrap the primary get for backwards compatibility + return get(endpoint=endpoint, params=params, api_version=api_version) + + +def get( + endpoint: str, + params: Optional[RequestParams] = None, + *, + api_version: int = API_VERSION, +) -> JSON: + """Make a GET request to the CWMS Data API. + + Args: + endpoint: The CDA endpoint for the record(s). + params (optional): Query parameters for the request. + + Keyword Args: + api_version (optional): The CDA version to use for the request. If not specified, + the default API_VERSION will be used. + + Returns: + The deserialized JSON response data. + + Raises: + ApiError: If an error response is return by the API. + """ + + headers = {"Accept": api_version_text(api_version)} with SESSION.get(endpoint, params=params, headers=headers) as response: if not response.ok: logging.error(f"CDA Error: response={response}") @@ -202,9 +228,9 @@ def get( return cast(JSON, response.json()) # Use automatic charset detection with .text if "text/plain" in content_type or "text/" in content_type: - return response.text + return {"value": response.text} # Fallback for remaining content types - return response.content.decode("utf-8") + return {"value": response.content.decode("utf-8")} except JSONDecodeError as error: logging.error( f"Error decoding CDA response as JSON: {error} on line {error.lineno}\n\tFalling back to text" @@ -218,8 +244,7 @@ def get_with_paging( params: RequestParams, *, api_version: int = API_VERSION, - format: str = "json", -) -> Any: +) -> JSON: """Make a GET request to the CWMS Data API with paging. Args: @@ -230,7 +255,6 @@ def get_with_paging( Keyword Args: api_version (optional): The CDA version to use for the request. If not specified, the default API_VERSION will be used. - format (optional): The format of the body data. Returns: The deserialized JSON response data. @@ -241,7 +265,7 @@ def get_with_paging( first_pass = True while (params["page"] is not None) or first_pass: - temp = get(endpoint, params, api_version=api_version, format=format) + temp = get(endpoint, params, api_version=api_version) if first_pass: response = temp else: @@ -260,7 +284,6 @@ def post( params: Optional[RequestParams] = None, *, api_version: int = API_VERSION, - format: str = "json", ) -> None: """Make a POST request to the CWMS Data API. @@ -272,7 +295,6 @@ def post( Keyword Args: api_version (optional): The CDA version to use for the request. If not specified, the default API_VERSION will be used. - format(optional): The format of the body data. Returns: The deserialized JSON response data. @@ -282,10 +304,7 @@ def post( """ # post requires different headers than get for - headers = { - "accept": "*/*", - "Content-Type": api_version_text(api_version, format=format), - } + headers = {"accept": "*/*", "Content-Type": api_version_text(api_version)} if isinstance(data, dict) or isinstance(data, list): data = json.dumps(data) @@ -302,7 +321,6 @@ def patch( params: Optional[RequestParams] = None, *, api_version: int = API_VERSION, - format: str = "json", ) -> None: """Make a PATCH request to the CWMS Data API. @@ -314,7 +332,6 @@ def patch( Keyword Args: api_version (optional): The CDA version to use for the request. If not specified, the default API_VERSION will be used. - format (optional): The format of the body data. Returns: The deserialized JSON response data. @@ -323,10 +340,7 @@ def patch( ApiError: If an error response is return by the API. """ - headers = { - "accept": "*/*", - "Content-Type": api_version_text(api_version, format=format), - } + headers = {"accept": "*/*", "Content-Type": api_version_text(api_version)} if data and isinstance(data, dict) or isinstance(data, list): data = json.dumps(data) @@ -341,14 +355,12 @@ def delete( params: Optional[RequestParams] = None, *, api_version: int = API_VERSION, - format: str = "json", ) -> None: """Make a DELETE request to the CWMS Data API. Args: endpoint: The CDA endpoint for the record. params (optional): Query parameters for the request. - format (optional): The format of the body data. Keyword Args: api_version (optional): The CDA version to use for the request. If not specified, @@ -358,7 +370,7 @@ def delete( ApiError: If an error response is return by the API. """ - headers = {"Accept": api_version_text(api_version, format=format)} + headers = {"Accept": api_version_text(api_version)} with SESSION.delete(endpoint, params=params, headers=headers) as response: if not response.ok: logging.error(f"CDA Error: response={response}") diff --git a/cwms/ratings/ratings.py b/cwms/ratings/ratings.py index 3b871bcb..b106bd62 100644 --- a/cwms/ratings/ratings.py +++ b/cwms/ratings/ratings.py @@ -34,8 +34,8 @@ def rating_current_effective_date(rating_id: str, office_id: str) -> Any: def get_current_rating( rating_id: str, office_id: str, -) -> Any: - """Retrieves the rating table for the current active rating. i.e. the rating table with the latest +) -> Data: + """Retrives the rating table for the current active rating. i.e. the rating table with the latest effective date for the rating specification Parameters @@ -69,15 +69,48 @@ def get_current_rating( return rating -def get_ratings( +def get_current_rating_xml( + rating_id: str, + office_id: str, +) -> Any: + """Retrives the rating table for the current active rating. i.e. the rating table with the latest + effective date for the rating specification + + Parameters + ---------- + rating_id: string + The rating-id of the effective dates to be retrieved + office_id: string + The owning office of the rating specifications. If no office is provided information from all offices will + be returned + Returns + ------- + rating : str + xml data as a string + """ + + max_effective = rating_current_effective_date( + rating_id=rating_id, office_id=office_id + ) + + rating = get_ratings_xml( + rating_id=rating_id, + office_id=office_id, + begin=max_effective, + end=max_effective, + method="EAGER", + ) + + return rating + + +def get_ratings_xml( rating_id: str, office_id: str, begin: Optional[datetime] = None, end: Optional[datetime] = None, timezone: Optional[str] = None, method: Optional[str] = "EAGER", - format: Optional[str] = None, - single_rating_df: Optional[bool] = False, ) -> Any: """Retrieves ratings for a specific rating-id @@ -95,16 +128,61 @@ def get_ratings( timezone: the time zone of the values in the being and end fields if not specified UTC is used method: - the retrieval method used - EAGER: retrieves all ratings data include the individual dependant and independent values - LAZY: retrieved all rating data excluding the individual dependence and independent values - REFERENCE: only retrieves reference data about the rating spec. - format: - Specifies the encoding format of the response. Valid values for the format field for this URI are: - - tab - - csv - - xml - - json (default) + the retrival method used + EAGER: retireves all ratings data include the individual dependenant and independant values + LAZY: retrieved all rating data excluding the individual dependance and independant values + REFERENCE: only retrievies reference data about the rating spec. + Returns + ------- + xml_data : str + xml data as a string + """ + methods = ["EAGER", "LAZY", "REFERENCE"] + if method not in methods: + raise ValueError("method needs to be one of EAGER, LAZY, or REFERENCE") + + endpoint = f"ratings/{rating_id}" + params = { + "office": office_id, + "begin": begin.isoformat() if begin else None, + "end": end.isoformat() if end else None, + "timezone": timezone, + "method": method, + } + + xml_data = api.get_xml(endpoint, params, api_version=102) + return xml_data + + +def get_ratings( + rating_id: str, + office_id: str, + begin: Optional[datetime] = None, + end: Optional[datetime] = None, + timezone: Optional[str] = None, + method: Optional[str] = "EAGER", + single_rating_df: Optional[bool] = False, +) -> Data: + """Retrives ratings for a specific rating-id + + Parameters + ---------- + rating_id: string + The rating-id of the effective dates to be retrieved + office_id: string + The owning office of the rating specifications. If no office is provided information from all offices will + be returned + begin: datetime, optional + the start of the time window for data to be included in the response. This is based on the effective date of the ratings + end: datetime, optional + the end of the time window for data to be included int he reponse. This is based on the effective date of the ratings + timezone: + the time zone of the values in the being and end fields if not specified UTC is used + method: + the retrival method used + EAGER: retireves all ratings data include the individual dependenant and independant values + LAZY: retrieved all rating data excluding the individual dependance and independant values + REFERENCE: only retrievies reference data about the rating spec. single_rating_df: bool, optional = False Set to True when using eager and a single rating is returned. Will place the single rating into the .df function used with the get_current_rating or when a only a single rating curve is to be returned. @@ -124,11 +202,9 @@ def get_ratings( "end": end.isoformat() if end else None, "timezone": timezone, "method": method, - "format": format, } - response = api.get(endpoint, params, api_version=2) - + response = api.get(endpoint, params) if (method == "EAGER") and single_rating_df: data = Data(response, selector="simple-rating.rating-points") elif method == "REFERENCE": @@ -147,7 +223,7 @@ def rating_simple_df_to_json( transition_start_date: Optional[datetime] = None, description: Optional[str] = None, active: Optional[bool] = True, -) -> Any: +) -> JSON: """This function converts a dataframe to a json dictionary in the correct format to be posted using the store_ratings function. Can only be used for simple ratings with a independent and 1 dependant variable. @@ -251,11 +327,12 @@ def update_ratings( raise ValueError( "Cannot store a rating without a JSON data dictionary or in XML" ) - format = "json" - api_version = 2 + if xml_heading in data: - format = "xml" - return api.patch(endpoint, data, params, api_version=api_version, format=format) + api_version = 102 + else: + api_version = 2 + return api.patch(endpoint, data, params, api_version=api_version) def delete_ratings( From efeb86d93be73fc22202fed2ce4b3270fab486cf Mon Sep 17 00:00:00 2001 From: Charles Graham SWT Date: Thu, 29 May 2025 15:42:13 -0500 Subject: [PATCH 22/25] Change to Any and update other mimetypes to pure output (not JSON wrapped) --- cwms/api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cwms/api.py b/cwms/api.py index cfa0fc1e..1abe2873 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -197,7 +197,7 @@ def get( params: Optional[RequestParams] = None, *, api_version: int = API_VERSION, -) -> JSON: +) -> Any: """Make a GET request to the CWMS Data API. Args: @@ -228,14 +228,14 @@ def get( return cast(JSON, response.json()) # Use automatic charset detection with .text if "text/plain" in content_type or "text/" in content_type: - return {"value": response.text} + return response.text # Fallback for remaining content types - return {"value": response.content.decode("utf-8")} + return response.content.decode("utf-8") except JSONDecodeError as error: logging.error( f"Error decoding CDA response as JSON: {error} on line {error.lineno}\n\tFalling back to text" ) - return {"error": response.text} + return response.text def get_with_paging( From fcaf1739f09f6e42145b8fa272c12a0772f4579b Mon Sep 17 00:00:00 2001 From: Charles Graham SWT Date: Thu, 29 May 2025 16:07:36 -0500 Subject: [PATCH 23/25] Bump version, handle Any for get_with_paging --- cwms/api.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cwms/api.py b/cwms/api.py index 1abe2873..7f335b6d 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -244,7 +244,7 @@ def get_with_paging( params: RequestParams, *, api_version: int = API_VERSION, -) -> JSON: +) -> Any: """Make a GET request to the CWMS Data API with paging. Args: diff --git a/pyproject.toml b/pyproject.toml index ec7c72a4..7419ed4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "cwms-python" repository = "https://github.com/HydrologicEngineeringCenter/cwms-python" -version = "0.6.1" +version = "0.6.4" packages = [ { include = "cwms" }, From 3efade4a25e66eaa0ad11c9e19b05841c44cf599 Mon Sep 17 00:00:00 2001 From: Charles Graham SWT Date: Thu, 29 May 2025 16:21:58 -0500 Subject: [PATCH 24/25] Handle base64 encoded content (images) --- cwms/api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cwms/api.py b/cwms/api.py index 7f335b6d..ecfa276e 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -28,6 +28,7 @@ import json import logging +import base64 from json import JSONDecodeError from typing import Any, Optional, cast @@ -229,6 +230,8 @@ def get( # Use automatic charset detection with .text if "text/plain" in content_type or "text/" in content_type: return response.text + if content_type.startswith("image/"): + return base64.b64encode(response.content).decode("utf-8") # Fallback for remaining content types return response.content.decode("utf-8") except JSONDecodeError as error: From 73bdcad31758ce0ca83d41eff174865ef14c158b Mon Sep 17 00:00:00 2001 From: Charles Graham SWT Date: Thu, 29 May 2025 16:27:05 -0500 Subject: [PATCH 25/25] Correct base64 import position --- cwms/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cwms/api.py b/cwms/api.py index ecfa276e..0784592e 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -26,9 +26,9 @@ the error. """ +import base64 import json import logging -import base64 from json import JSONDecodeError from typing import Any, Optional, cast