From c733ae41c469bf7f96c5aa4d60d7351b0b7cc410 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Thu, 4 Dec 2025 20:14:59 -0600 Subject: [PATCH 1/8] Add 401 and ensure status code and hint are always returned for errors --- cwms/api.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/cwms/api.py b/cwms/api.py index 56cac1c4..162457a0 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -36,6 +36,7 @@ from requests_toolbelt import sessions # type: ignore from requests_toolbelt.sessions import BaseUrlSession # type: ignore from urllib3.util.retry import Retry +from http import HTTPStatus from cwms.cwms_types import JSON, RequestParams @@ -90,24 +91,32 @@ def __str__(self) -> str: message += "." + if content := self.response.content: + message += f"\n\n\t{content.decode('utf8')}" + # Add additional context to help the user resolve the issue. if hint := self.hint(): - message += f" {hint}" - - if content := self.response.content: - message += f" {content.decode('utf8')}" + message += f"\n\n\t{hint}" return message def hint(self) -> str: """Return a message with additional information on how to resolve the error.""" - if self.response.status_code == 400: - return "Check that your parameters are correct." + # Always show the status code and common phrase + message = f"{self.response.status_code} {HTTPStatus(self.response.status_code).phrase}" + + # Helpful hints in relation to cwms-python for a given status code + if self.response.status_code == 429: + message += ": Too many requests made" + elif self.response.status_code == 400: + message += ": Check that your parameters are correct." + elif self.response.status_code == 401: + message += ": You must provide a valid API key.\n\tIf you have one set it might be invalid for the OFFICE/CDA instance you are targeting." elif self.response.status_code == 404: - return "May be the result of an empty query." - else: - return "" + message += ": May be the result of an empty query." + + return message def init_session( From 41416047b386683ffa85638e67ad43dfdddc89c7 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Thu, 4 Dec 2025 20:15:06 -0600 Subject: [PATCH 2/8] Patch bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b34c3163..bf9fbdce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "cwms-python" repository = "https://github.com/HydrologicEngineeringCenter/cwms-python" -version = "1.0.0" +version = "1.0.1" packages = [ From f06a93220a3f01e15ac804bd0433a234d87c54da Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Thu, 4 Dec 2025 20:20:58 -0600 Subject: [PATCH 3/8] Forgot to install black precommit locally on this system --- cwms/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cwms/api.py b/cwms/api.py index 162457a0..27e35616 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -29,6 +29,7 @@ import base64 import json import logging +from http import HTTPStatus from json import JSONDecodeError from typing import Any, Optional, cast @@ -36,7 +37,6 @@ from requests_toolbelt import sessions # type: ignore from requests_toolbelt.sessions import BaseUrlSession # type: ignore from urllib3.util.retry import Retry -from http import HTTPStatus from cwms.cwms_types import JSON, RequestParams From 266be1385d0af9875ccd2a22ef2670ee6618842b Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Thu, 4 Dec 2025 23:55:06 -0600 Subject: [PATCH 4/8] Make 401 clear and give response text --- cwms/api.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/cwms/api.py b/cwms/api.py index 27e35616..34bf3162 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -103,18 +103,23 @@ def __str__(self) -> str: def hint(self) -> str: """Return a message with additional information on how to resolve the error.""" + # Attempt to extract a message from the error response body + response_msg = "" + try: + response_msg = self.response.json().get("message", "") + except Exception: + response_msg = self.response.text + # Always show the status code and common phrase - message = f"{self.response.status_code} {HTTPStatus(self.response.status_code).phrase}" + message = f"{self.response.status_code} {HTTPStatus(self.response.status_code).phrase} \n\t{response_msg}\n\t" # Helpful hints in relation to cwms-python for a given status code if self.response.status_code == 429: - message += ": Too many requests made" + message += "Too many requests made" elif self.response.status_code == 400: - message += ": Check that your parameters are correct." - elif self.response.status_code == 401: - message += ": You must provide a valid API key.\n\tIf you have one set it might be invalid for the OFFICE/CDA instance you are targeting." + message += "Check that your parameters are correct." elif self.response.status_code == 404: - message += ": May be the result of an empty query." + message += "May be the result of an empty query." return message From bc1efff15fb91a321646f96577fb5b92eb8f3769 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Fri, 5 Dec 2025 15:51:50 +0000 Subject: [PATCH 5/8] Correct for empty response/ 400s --- cwms/api.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cwms/api.py b/cwms/api.py index 34bf3162..7e5e1743 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -107,8 +107,9 @@ def hint(self) -> str: response_msg = "" try: response_msg = self.response.json().get("message", "") - except Exception: - response_msg = self.response.text + except Exception as e: + logging.exception(f"Error extracting message from response: {e}") + response_msg = str(e) # Always show the status code and common phrase message = f"{self.response.status_code} {HTTPStatus(self.response.status_code).phrase} \n\t{response_msg}\n\t" From 4507f1cf6998a1887e68feb2deab4b9b42511e3b Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Fri, 5 Dec 2025 15:56:33 +0000 Subject: [PATCH 6/8] Handle excel response process --- cwms/api.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cwms/api.py b/cwms/api.py index 7e5e1743..7be755ae 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -242,6 +242,12 @@ def _process_response(response: Response) -> Any: return response.text if content_type.startswith("image/"): return base64.b64encode(response.content).decode("utf-8") + # Handle excel content types + if content_type in [ + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ]: + return response.content # Fallback for remaining content types return response.content.decode("utf-8") except JSONDecodeError as error: From 0c3849e0eb3bb4e468759a91c1d4ff7dbc5e8f74 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Fri, 5 Dec 2025 15:56:51 +0000 Subject: [PATCH 7/8] Ensure correct mime_type is sent even if OS does not have it in guess_types --- tests/cda/blobs/blob_CDA_test.py | 41 ++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/tests/cda/blobs/blob_CDA_test.py b/tests/cda/blobs/blob_CDA_test.py index 62f8fa6f..6e67606e 100644 --- a/tests/cda/blobs/blob_CDA_test.py +++ b/tests/cda/blobs/blob_CDA_test.py @@ -59,10 +59,20 @@ def _find_blob_row(office: str, blob_id: str) -> Optional[pd.Series]: def test_store_blob_excel(): + # Create an empty file with the excel extension excel_file_path = Path(__file__).parent.parent / "resources" / "blob_test.xlsx" with open(excel_file_path, "rb") as f: file_data = f.read() - mime_type, _ = mimetypes.guess_type(excel_file_path) + + # Get the file extension and decide which type to use if xlsx or xlx + ext = excel_file_path.suffix.lower() + mime_type = mimetypes.guess_type(excel_file_path.name)[0] + # Some linux systems may not have the excel mimetypes registered + if ext == ".xlsx": + mime_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + elif ext == ".xls": + mime_type = "application/vnd.ms-excel" + excel_blob_id = "TEST_BLOB_EXCEL" payload = { "office-id": TEST_OFFICE, @@ -72,12 +82,16 @@ def test_store_blob_excel(): "value": base64.b64encode(file_data).decode("utf-8"), } blobs.store_blobs(data=payload) - try: - row = _find_blob_row(TEST_OFFICE, excel_blob_id) - assert row is not None, "Stored blob not found in listing" - finally: - # Cleanup excel - blobs.delete_blob(blob_id=excel_blob_id, office_id=TEST_OFFICE) + row = _find_blob_row(TEST_OFFICE, excel_blob_id) + assert row is not None, "Stored blob not found in listing" + + +def test_get_excel_blob(): + # Retrieve the excel blob stored in the previous test + excel_blob_id = "TEST_BLOB_EXCEL" + content = blobs.get_blob(office_id=TEST_OFFICE, blob_id=excel_blob_id) + assert content is not None, "Failed to retrieve excel blob" + assert len(content) > 0, "Excel blob content is empty" def test_store_blob(): @@ -133,3 +147,16 @@ def test_update_blob(): # Verify new content content = blobs.get_blob(office_id=TEST_OFFICE, blob_id=TEST_BLOB_UPDATED_ID) assert TEST_TEXT_UPDATED in content + + +def test_delete_blobs(): + # Delete the test blob + blobs.delete_blob(office_id=TEST_OFFICE, blob_id=TEST_BLOB_ID) + blobs.delete_blob(office_id=TEST_OFFICE, blob_id="TEST_BLOB_EXCEL") + + # Confirm deletion via listing + row = _find_blob_row(TEST_OFFICE, TEST_BLOB_ID) + assert row is None, "Blob still found after deletion" + + row = _find_blob_row(TEST_OFFICE, "TEST_BLOB_EXCEL") + assert row is None, "Excel blob still found after deletion" From 417beff6a7f95d088ce2841536181fd5ea4418c1 Mon Sep 17 00:00:00 2001 From: "Charles Graham, SWT" Date: Wed, 10 Dec 2025 22:35:28 +0000 Subject: [PATCH 8/8] Correct issue for tests with static json response in hint in favor of getattr --- cwms/api.py | 60 ++++++++++++++++++++++++++--------------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/cwms/api.py b/cwms/api.py index 7be755ae..cdb605a9 100644 --- a/cwms/api.py +++ b/cwms/api.py @@ -73,9 +73,9 @@ class InvalidVersion(Exception): class ApiError(Exception): """CWMS Data Api Error. - This class is a light wrapper around a `requests.Response` object. Its primary purpose - is to generate an error message that includes the request URL and provide additional - information to the user to help them resolve the error. + Light wrapper around a response-like object (e.g., requests.Response or a + test stub with url, status_code, reason, and content attributes). Produces + a concise, single-line error message with an optional hint. """ def __init__(self, response: Response): @@ -91,38 +91,38 @@ def __str__(self) -> str: message += "." - if content := self.response.content: - message += f"\n\n\t{content.decode('utf8')}" - # Add additional context to help the user resolve the issue. - if hint := self.hint(): - message += f"\n\n\t{hint}" + hint = self.hint() + if hint: + message += f" {hint}" + + # Optional content (decoded if bytes) + content = getattr(self.response, "content", None) + if content: + if isinstance(content, bytes): + try: + text = content.decode("utf-8", errors="replace") + except Exception: + text = repr(content) + else: + text = str(content) + message += f" {text}" return message def hint(self) -> str: - """Return a message with additional information on how to resolve the error.""" - - # Attempt to extract a message from the error response body - response_msg = "" - try: - response_msg = self.response.json().get("message", "") - except Exception as e: - logging.exception(f"Error extracting message from response: {e}") - response_msg = str(e) - - # Always show the status code and common phrase - message = f"{self.response.status_code} {HTTPStatus(self.response.status_code).phrase} \n\t{response_msg}\n\t" - - # Helpful hints in relation to cwms-python for a given status code - if self.response.status_code == 429: - message += "Too many requests made" - elif self.response.status_code == 400: - message += "Check that your parameters are correct." - elif self.response.status_code == 404: - message += "May be the result of an empty query." - - return message + """Return a short hint based on HTTP status code.""" + status = getattr(self.response, "status_code", None) + + if status == 429: + return "Too many requests made." + if status == 400: + return "Check that your parameters are correct." + if status == 404: + return "May be the result of an empty query." + + # No hint for other codes + return "" def init_session(