From 1df325187d3b3014531270fbe2f7fb54434ada4c Mon Sep 17 00:00:00 2001 From: zack-rma Date: Wed, 22 Jul 2026 11:58:27 -0700 Subject: [PATCH 1/6] Initial implementation for properties endpoint support --- cwms/__init__.py | 1 + cwms/properties/properties.py | 166 ++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 cwms/properties/properties.py diff --git a/cwms/__init__.py b/cwms/__init__.py index f1e9a48e..106ad89c 100644 --- a/cwms/__init__.py +++ b/cwms/__init__.py @@ -7,6 +7,7 @@ from cwms.levels.location_levels import * from cwms.levels.specified_levels import * from cwms.locations.physical_locations import * +from cwms.properties.properties import * from cwms.ratings.ratings import * from cwms.ratings.ratings_spec import * from cwms.ratings.ratings_template import * diff --git a/cwms/properties/properties.py b/cwms/properties/properties.py new file mode 100644 index 00000000..ef1f166b --- /dev/null +++ b/cwms/properties/properties.py @@ -0,0 +1,166 @@ +# Copyright (c) 2026 +# 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 + + +def get_properties( + office_mask: Optional[str] = None, + category_id_mask: Optional[str] = None, + name_mask: Optional[str] = None, +) -> Data: + """ + Returns matching CWMS Property Data. + + Parameters + ---------- + office_mask: string, optional + Filters properties to the specified office mask + category_id_mask: string, optional + Filters properties to the specified category mask + name_mask: string, optional + Filters properties to the specified name mask + + Returns + ------- + cwms data type + """ + + endpoint = "properties" + params = { + "office-mask": office_mask, + "category-id-mask": category_id_mask, + "name-mask": name_mask, + } + + response = api.get(endpoint, params) + return Data(response) + + +def get_property( + name: str, office: str, category_id: str, default_value: Optional[str] = None +) -> Data: + """ + Returns CWMS Property Data. + + Parameters + ---------- + name: string + Specifies the name of the property to be retrieved. + office: string + Specifies the owning office of the property to be retrieved. + category_id: string + Specifies the category id of the property to be retrieved. + default_value: string, optional + Specifies the default value if the property does not exist. + + Returns + ------- + cwms data type + """ + + endpoint = f"properties/{name}" + params = { + "office": office, + "category-id": category_id, + "default-value": default_value, + } + + response = api.get(endpoint, params) + return Data(response) + + +def create_property(data: JSON) -> None: + """ + Create CWMS Property. + + Parameters + ---------- + data: JSON dictionary + Property data to be stored. + Example: + { + "name": "string", + "category": "string", + "value": 0, + "comment": "string + } + + Returns + ------- + None + """ + + endpoint = "properties" + + if data is None: + raise ValueError("Cannot store a property without JSON data") + + return api.post(endpoint, data) + + +def update_property(name: str, data: JSON) -> None: + """ + Update CWMS Property. + + Parameters + ---------- + name: string + Specifies the name of the property to be updated. + data: JSON dictionary + Property data to be updated. + Example: + { + "name": "string", + "category": "string", + "value": 0, + "comment": "string + } + + Returns + ------- + None + """ + + endpoint = f"properties/{name}" + + if name is None: + raise ValueError("Must specify a property name to update") + + if data is None: + raise ValueError("Cannot update a property without JSON data") + + return api.patch(endpoint, data) + + +def delete_property(name: str, office: str, category_id: str) -> None: + """ + Delete CWMS Property. + + Parameters + ---------- + name: string + Specifies the name of the property to be deleted. + office: string + Specifies the owning office of the property to be deleted. + category_id: string + Specifies the category id of the property to be deleted. + + Returns + ------- + None + """ + + endpoint = f"properties/{name}" + + params = { + "office": office, + "category-id": category_id, + } + + return api.delete(endpoint, params) From e035b4f89e63e9736e3ac41a4110ed92042e59b9 Mon Sep 17 00:00:00 2001 From: zack-rma Date: Wed, 22 Jul 2026 13:03:32 -0700 Subject: [PATCH 2/6] Mock tests added along with associated resource files. --- cwms/properties/properties.py | 2 + tests/cda/properties/properties_CDA_test.py | 4 ++ tests/mock/properties/properties_test.py | 63 +++++++++++++++++++++ tests/resources/properties.json | 16 ++++++ tests/resources/property.json | 7 +++ 5 files changed, 92 insertions(+) create mode 100644 tests/cda/properties/properties_CDA_test.py create mode 100644 tests/mock/properties/properties_test.py create mode 100644 tests/resources/properties.json create mode 100644 tests/resources/property.json diff --git a/cwms/properties/properties.py b/cwms/properties/properties.py index ef1f166b..0bfe3f63 100644 --- a/cwms/properties/properties.py +++ b/cwms/properties/properties.py @@ -85,6 +85,7 @@ def create_property(data: JSON) -> None: Property data to be stored. Example: { + "office-id": "string", "name": "string", "category": "string", "value": 0, @@ -116,6 +117,7 @@ def update_property(name: str, data: JSON) -> None: Property data to be updated. Example: { + "office-id": "string", "name": "string", "category": "string", "value": 0, diff --git a/tests/cda/properties/properties_CDA_test.py b/tests/cda/properties/properties_CDA_test.py new file mode 100644 index 00000000..9f81e57e --- /dev/null +++ b/tests/cda/properties/properties_CDA_test.py @@ -0,0 +1,4 @@ +# Copyright (c) 2026 +# 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 diff --git a/tests/mock/properties/properties_test.py b/tests/mock/properties/properties_test.py new file mode 100644 index 00000000..795e6a35 --- /dev/null +++ b/tests/mock/properties/properties_test.py @@ -0,0 +1,63 @@ +# Copyright (c) 2026 +# 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 + +import pytest + +import cwms.api +import cwms.properties.properties as properties +from tests._test_utils import read_resource_file + +_MOCK_ROOT = "https://mockwebserver.cwms.gov" +_PROPERTY_JSON = read_resource_file("property.json") + + +@pytest.fixture(autouse=True) +def init_session(): + cwms.api.init_session(api_root=_MOCK_ROOT) + + +def test_get_properties(requests_mock): + requests_mock.get( + f"{_MOCK_ROOT}" "/properties?office-mask=SWT", + json=read_resource_file("properties.json"), + ) + data = properties.get_properties(office_mask="SWT") + assert data.json == read_resource_file("properties.json") + + +def test_get_property(requests_mock): + requests_mock.get( + f"{_MOCK_ROOT}" "/properties/TestProperty1?office=SWT&category-id=TestCategory", + json=_PROPERTY_JSON, + ) + data = properties.get_property( + name="TestProperty1", office="SWT", category_id="TestCategory" + ) + assert data.json == _PROPERTY_JSON + + +def test_create_property(requests_mock): + requests_mock.post(f"{_MOCK_ROOT}" "/properties", status_code=201) + data = _PROPERTY_JSON + properties.create_property(data) + assert requests_mock.called + + +def test_update_property(requests_mock): + requests_mock.patch(f"{_MOCK_ROOT}" "/properties/TestProperty1", status_code=200) + data = _PROPERTY_JSON + properties.update_property("TestProperty1", data) + assert requests_mock.called + + +def test_delete_property(requests_mock): + requests_mock.delete( + f"{_MOCK_ROOT}" "/properties/TestProperty1?office=SWT&category-id=TestCategory", + status_code=204, + ) + properties.delete_property( + name="TestProperty1", office="SWT", category_id="TestCategory" + ) + assert requests_mock.called diff --git a/tests/resources/properties.json b/tests/resources/properties.json new file mode 100644 index 00000000..1a946039 --- /dev/null +++ b/tests/resources/properties.json @@ -0,0 +1,16 @@ +[ + { + "name": "TestProperty1", + "office-id": "SWT", + "category-id": "TestCategory", + "value": "Value1", + "comment": "Comment1" + }, + { + "name": "TestProperty2", + "office-id": "SWT", + "category-id": "TestCategory", + "value": "Value2", + "comment": "Comment2" + } +] diff --git a/tests/resources/property.json b/tests/resources/property.json new file mode 100644 index 00000000..f127c642 --- /dev/null +++ b/tests/resources/property.json @@ -0,0 +1,7 @@ +{ + "name": "TestProperty1", + "office-id": "SWT", + "category": "TestCategory", + "value": "Value1", + "comment": "Comment1" +} From 881a932b04e27665efa147c11902804a685b903e Mon Sep 17 00:00:00 2001 From: zack-rma Date: Wed, 22 Jul 2026 13:29:33 -0700 Subject: [PATCH 3/6] Dropped mock tests, added integration tests. --- cwms/properties/properties.py | 4 +- tests/cda/properties/properties_CDA_test.py | 105 ++++++++++++++++++++ tests/mock/properties/properties_test.py | 63 ------------ 3 files changed, 107 insertions(+), 65 deletions(-) delete mode 100644 tests/mock/properties/properties_test.py diff --git a/cwms/properties/properties.py b/cwms/properties/properties.py index 0bfe3f63..4b33ce2a 100644 --- a/cwms/properties/properties.py +++ b/cwms/properties/properties.py @@ -88,7 +88,7 @@ def create_property(data: JSON) -> None: "office-id": "string", "name": "string", "category": "string", - "value": 0, + "value": "string, "comment": "string } @@ -120,7 +120,7 @@ def update_property(name: str, data: JSON) -> None: "office-id": "string", "name": "string", "category": "string", - "value": 0, + "value": "string", "comment": "string } diff --git a/tests/cda/properties/properties_CDA_test.py b/tests/cda/properties/properties_CDA_test.py index 9f81e57e..29c37635 100644 --- a/tests/cda/properties/properties_CDA_test.py +++ b/tests/cda/properties/properties_CDA_test.py @@ -2,3 +2,108 @@ # 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 + +import pytest + +import cwms.api as api +import cwms.properties.properties as properties + +TEST_OFFICE = "SPK" +TEST_CATEGORY = "PytestCategory" +TEST_NAME = "PytestProperty" +TEST_VALUE = "PytestValue" +TEST_COMMENT = "PytestComment" + +TEST_PROPERTY_DATA = { + "office-id": TEST_OFFICE, + "name": TEST_NAME, + "category": TEST_CATEGORY, + "value": TEST_VALUE, + "comment": TEST_COMMENT, +} + + +@pytest.fixture(scope="module", autouse=True) +def setup_data(): + # Clean up any leftover state from a prior aborted run before starting. + try: + properties.delete_property(TEST_NAME, TEST_OFFICE, TEST_CATEGORY) + except Exception: + pass + + properties.create_property(TEST_PROPERTY_DATA) + yield + try: + properties.delete_property(TEST_NAME, TEST_OFFICE, TEST_CATEGORY) + except Exception: + pass + + +@pytest.fixture(autouse=True) +def init_session(): + # Session initialization is handled by environment or global config in CDA tests + print("Initializing CWMS API session for properties tests...") + + +def test_create_property(): + properties.create_property(TEST_PROPERTY_DATA) + props = properties.get_property(TEST_NAME, TEST_OFFICE, TEST_CATEGORY) + assert props.json.get("name") == TEST_NAME + assert props.json.get("office-id") == TEST_OFFICE + assert props.json.get("category") == TEST_CATEGORY + assert props.json.get("value") == TEST_VALUE + assert props.json.get("comment") == TEST_COMMENT + + +def test_get_properties(): + data = properties.get_properties( + office_mask=TEST_OFFICE, category_id_mask=TEST_CATEGORY + ) + assert data is not None + # Check if our test property is in the returned list + found = False + for prop in data.json: + if prop.get("name") == TEST_NAME and prop.get("office-id") == TEST_OFFICE: + found = True + break + assert found + + +def test_get_property(): + data = properties.get_property(TEST_NAME, TEST_OFFICE, TEST_CATEGORY) + assert data is not None + assert data.json.get("name") == TEST_NAME + assert data.json.get("office-id") == TEST_OFFICE + assert data.json.get("category") == TEST_CATEGORY + assert data.json.get("value") == TEST_VALUE + + +def test_update_property(): + updated_value = "UpdatedPytestValue" + updated_data = TEST_PROPERTY_DATA.copy() + updated_data["value"] = updated_value + + properties.update_property(TEST_NAME, updated_data) + + data = properties.get_property(TEST_NAME, TEST_OFFICE, TEST_CATEGORY) + assert data.json.get("value") == updated_value + + +def test_delete_property(): + # Create a temporary property to delete + temp_name = "TempDeleteProperty" + temp_data = TEST_PROPERTY_DATA.copy() + temp_data["name"] = temp_name + + properties.create_property(temp_data) + + # Verify it was created + data = properties.get_property(temp_name, TEST_OFFICE, TEST_CATEGORY) + assert data.json.get("name") == temp_name + + # Delete it + properties.delete_property(temp_name, TEST_OFFICE, TEST_CATEGORY) + + # Verify it was deleted + with pytest.raises(api.ApiError): + properties.get_property(temp_name, TEST_OFFICE, TEST_CATEGORY) diff --git a/tests/mock/properties/properties_test.py b/tests/mock/properties/properties_test.py deleted file mode 100644 index 795e6a35..00000000 --- a/tests/mock/properties/properties_test.py +++ /dev/null @@ -1,63 +0,0 @@ -# Copyright (c) 2026 -# 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 - -import pytest - -import cwms.api -import cwms.properties.properties as properties -from tests._test_utils import read_resource_file - -_MOCK_ROOT = "https://mockwebserver.cwms.gov" -_PROPERTY_JSON = read_resource_file("property.json") - - -@pytest.fixture(autouse=True) -def init_session(): - cwms.api.init_session(api_root=_MOCK_ROOT) - - -def test_get_properties(requests_mock): - requests_mock.get( - f"{_MOCK_ROOT}" "/properties?office-mask=SWT", - json=read_resource_file("properties.json"), - ) - data = properties.get_properties(office_mask="SWT") - assert data.json == read_resource_file("properties.json") - - -def test_get_property(requests_mock): - requests_mock.get( - f"{_MOCK_ROOT}" "/properties/TestProperty1?office=SWT&category-id=TestCategory", - json=_PROPERTY_JSON, - ) - data = properties.get_property( - name="TestProperty1", office="SWT", category_id="TestCategory" - ) - assert data.json == _PROPERTY_JSON - - -def test_create_property(requests_mock): - requests_mock.post(f"{_MOCK_ROOT}" "/properties", status_code=201) - data = _PROPERTY_JSON - properties.create_property(data) - assert requests_mock.called - - -def test_update_property(requests_mock): - requests_mock.patch(f"{_MOCK_ROOT}" "/properties/TestProperty1", status_code=200) - data = _PROPERTY_JSON - properties.update_property("TestProperty1", data) - assert requests_mock.called - - -def test_delete_property(requests_mock): - requests_mock.delete( - f"{_MOCK_ROOT}" "/properties/TestProperty1?office=SWT&category-id=TestCategory", - status_code=204, - ) - properties.delete_property( - name="TestProperty1", office="SWT", category_id="TestCategory" - ) - assert requests_mock.called From 7678d2c7083995e3fb3222eb98123a323bd5f77e Mon Sep 17 00:00:00 2001 From: zack-rma Date: Wed, 22 Jul 2026 14:09:49 -0700 Subject: [PATCH 4/6] Fix format versioning to match CDA endpoints. Fixed imports in initialization file. --- cwms/__init__.py | 2 +- cwms/properties/properties.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cwms/__init__.py b/cwms/__init__.py index dc7e3666..d743cc21 100644 --- a/cwms/__init__.py +++ b/cwms/__init__.py @@ -11,7 +11,6 @@ from cwms.locations.gate_changes import * from cwms.locations.location_groups import * from cwms.locations.physical_locations import * -from cwms.properties.properties import * from cwms.measurements.measurements import * from cwms.outlets.outlets import * from cwms.outlets.virtual_outlets import * @@ -19,6 +18,7 @@ from cwms.projects.project_locks import * from cwms.projects.projects import * from cwms.projects.water_supply.accounting import * +from cwms.properties.properties import * from cwms.ratings.ratings import * from cwms.ratings.ratings_spec import * from cwms.ratings.ratings_template import * diff --git a/cwms/properties/properties.py b/cwms/properties/properties.py index 4b33ce2a..992681c3 100644 --- a/cwms/properties/properties.py +++ b/cwms/properties/properties.py @@ -38,7 +38,7 @@ def get_properties( "name-mask": name_mask, } - response = api.get(endpoint, params) + response = api.get(endpoint, params, api_version=1) return Data(response) @@ -71,7 +71,7 @@ def get_property( "default-value": default_value, } - response = api.get(endpoint, params) + response = api.get(endpoint, params, api_version=1) return Data(response) @@ -137,7 +137,7 @@ def update_property(name: str, data: JSON) -> None: if data is None: raise ValueError("Cannot update a property without JSON data") - return api.patch(endpoint, data) + return api.patch(endpoint, data, api_version=1) def delete_property(name: str, office: str, category_id: str) -> None: @@ -165,4 +165,4 @@ def delete_property(name: str, office: str, category_id: str) -> None: "category-id": category_id, } - return api.delete(endpoint, params) + return api.delete(endpoint, params, api_version=1) From ded76ef9152c4770fb3f6f78978b68b1007059c4 Mon Sep 17 00:00:00 2001 From: zack-rma Date: Wed, 22 Jul 2026 14:28:10 -0700 Subject: [PATCH 5/6] Added missing format version specifier. --- cwms/properties/properties.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cwms/properties/properties.py b/cwms/properties/properties.py index 992681c3..8a5b66c2 100644 --- a/cwms/properties/properties.py +++ b/cwms/properties/properties.py @@ -102,7 +102,7 @@ def create_property(data: JSON) -> None: if data is None: raise ValueError("Cannot store a property without JSON data") - return api.post(endpoint, data) + return api.post(endpoint, data, api_version=1) def update_property(name: str, data: JSON) -> None: From 156aa6d7c3d808c16260b24ce9a66a9b1c9f1727 Mon Sep 17 00:00:00 2001 From: zack-rma Date: Wed, 22 Jul 2026 14:41:50 -0700 Subject: [PATCH 6/6] Updated error expectation --- tests/cda/properties/properties_CDA_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/cda/properties/properties_CDA_test.py b/tests/cda/properties/properties_CDA_test.py index 29c37635..ee4d7ab5 100644 --- a/tests/cda/properties/properties_CDA_test.py +++ b/tests/cda/properties/properties_CDA_test.py @@ -7,6 +7,7 @@ import cwms.api as api import cwms.properties.properties as properties +from cwms.api import ApiError TEST_OFFICE = "SPK" TEST_CATEGORY = "PytestCategory" @@ -105,5 +106,5 @@ def test_delete_property(): properties.delete_property(temp_name, TEST_OFFICE, TEST_CATEGORY) # Verify it was deleted - with pytest.raises(api.ApiError): + with pytest.raises(ApiError): properties.get_property(temp_name, TEST_OFFICE, TEST_CATEGORY)