cwms-python 1.0.8__tar.gz → 1.0.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cwms_python-1.0.8 → cwms_python-1.0.9}/PKG-INFO +12 -3
- {cwms_python-1.0.8 → cwms_python-1.0.9}/README.md +5 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/__init__.py +2 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/api.py +77 -7
- cwms_python-1.0.9/cwms/locations/lookups.py +161 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/projects/projects.py +3 -3
- cwms_python-1.0.9/cwms/properties/properties.py +168 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/timeseries/timeseries.py +49 -22
- {cwms_python-1.0.8 → cwms_python-1.0.9}/pyproject.toml +21 -5
- {cwms_python-1.0.8 → cwms_python-1.0.9}/LICENSE +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/catalog/blobs.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/catalog/catalog.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/catalog/clobs.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/cwms_types.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/forecast/forecast_instance.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/forecast/forecast_spec.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/levels/location_levels.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/levels/specified_levels.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/locations/gate_changes.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/locations/location_groups.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/locations/physical_locations.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/measurements/measurements.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/outlets/outlets.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/outlets/virtual_outlets.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/projects/project_lock_rights.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/projects/project_locks.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/projects/water_supply/accounting.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/ratings/ratings.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/ratings/ratings_spec.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/ratings/ratings_template.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/standard_text/standard_text.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/timeseries/timeseries_bin.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/timeseries/timeseries_group.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/timeseries/timeseries_identifier.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/timeseries/timeseries_profile.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/timeseries/timeseries_profile_instance.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/timeseries/timeseries_profile_parser.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/timeseries/timeseries_txt.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/turbines/turbines.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/users/users.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/utils/__init__.py +0 -0
- {cwms_python-1.0.8 → cwms_python-1.0.9}/cwms/utils/checks.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cwms-python
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.9
|
|
4
4
|
Summary: Corps water management systems (CWMS) REST API for Data Retrieval of USACE water data
|
|
5
5
|
License: LICENSE
|
|
6
6
|
License-File: LICENSE
|
|
@@ -16,8 +16,12 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
-
Requires-Dist:
|
|
20
|
-
Requires-Dist:
|
|
19
|
+
Requires-Dist: numpy (>=1.23.5,<3) ; python_version >= "3.9" and python_version < "3.13"
|
|
20
|
+
Requires-Dist: numpy (>=2.1.0,<3) ; python_version == "3.13"
|
|
21
|
+
Requires-Dist: numpy (>=2.3.3,<3) ; python_version >= "3.14"
|
|
22
|
+
Requires-Dist: pandas (>=2.3.3,<3.0.0)
|
|
23
|
+
Requires-Dist: requests (>=2.32.4,<3.0.0) ; python_version == "3.9"
|
|
24
|
+
Requires-Dist: requests (>=2.33.0,<3.0.0) ; python_version >= "3.10"
|
|
21
25
|
Requires-Dist: requests-toolbelt (>=1.0.0,<2.0.0)
|
|
22
26
|
Project-URL: Repository, https://github.com/HydrologicEngineeringCenter/cwms-python
|
|
23
27
|
Description-Content-Type: text/markdown
|
|
@@ -124,3 +128,8 @@ endpoint implementation.
|
|
|
124
128
|
|
|
125
129
|
Please view the contribution documentation here: [CONTRIBUTING.md]
|
|
126
130
|
|
|
131
|
+
## Contributing and releases
|
|
132
|
+
|
|
133
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development checks, PR title conventions,
|
|
134
|
+
and the Release Please publishing workflow.
|
|
135
|
+
|
|
@@ -99,3 +99,8 @@ endpoint implementation.
|
|
|
99
99
|
## Contributing
|
|
100
100
|
|
|
101
101
|
Please view the contribution documentation here: [CONTRIBUTING.md]
|
|
102
|
+
|
|
103
|
+
## Contributing and releases
|
|
104
|
+
|
|
105
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development checks, PR title conventions,
|
|
106
|
+
and the Release Please publishing workflow.
|
|
@@ -10,6 +10,7 @@ from cwms.levels.location_levels import *
|
|
|
10
10
|
from cwms.levels.specified_levels import *
|
|
11
11
|
from cwms.locations.gate_changes import *
|
|
12
12
|
from cwms.locations.location_groups import *
|
|
13
|
+
from cwms.locations.lookups import *
|
|
13
14
|
from cwms.locations.physical_locations import *
|
|
14
15
|
from cwms.measurements.measurements import *
|
|
15
16
|
from cwms.outlets.outlets import *
|
|
@@ -18,6 +19,7 @@ from cwms.projects.project_lock_rights import *
|
|
|
18
19
|
from cwms.projects.project_locks import *
|
|
19
20
|
from cwms.projects.projects import *
|
|
20
21
|
from cwms.projects.water_supply.accounting import *
|
|
22
|
+
from cwms.properties.properties import *
|
|
21
23
|
from cwms.ratings.ratings import *
|
|
22
24
|
from cwms.ratings.ratings_spec import *
|
|
23
25
|
from cwms.ratings.ratings_template import *
|
|
@@ -48,6 +48,9 @@ from cwms.cwms_types import JSON, RequestParams
|
|
|
48
48
|
API_ROOT = "https://cwms-data.usace.army.mil/cwms-data/"
|
|
49
49
|
API_VERSION = 2
|
|
50
50
|
|
|
51
|
+
# Specify whether LRTS will use new ID format
|
|
52
|
+
USE_NEW_LRTS_IDS = False
|
|
53
|
+
|
|
51
54
|
# Initialize a non-authenticated session with the default root URL and set default pool connections.
|
|
52
55
|
|
|
53
56
|
retry_strategy = Retry(
|
|
@@ -168,6 +171,7 @@ def init_session(
|
|
|
168
171
|
api_key: Optional[str] = None,
|
|
169
172
|
token: Optional[str] = None,
|
|
170
173
|
pool_connections: int = 100,
|
|
174
|
+
use_new_lrts_format: bool = False,
|
|
171
175
|
) -> BaseUrlSession:
|
|
172
176
|
"""Specify a root URL and authentication credentials for the CWMS Data API.
|
|
173
177
|
|
|
@@ -186,7 +190,7 @@ def init_session(
|
|
|
186
190
|
Returns the updated session object.
|
|
187
191
|
"""
|
|
188
192
|
|
|
189
|
-
global SESSION
|
|
193
|
+
global SESSION, USE_NEW_LRTS_IDS
|
|
190
194
|
if api_root:
|
|
191
195
|
# Ensure the API_ROOT ends with a single slash
|
|
192
196
|
api_root = api_root.rstrip("/") + "/"
|
|
@@ -206,11 +210,23 @@ def init_session(
|
|
|
206
210
|
# Ensure we don't provide the bearer text twice
|
|
207
211
|
if token.lower().startswith("bearer "):
|
|
208
212
|
token = token[7:]
|
|
209
|
-
SESSION.headers.update(
|
|
213
|
+
SESSION.headers.update(
|
|
214
|
+
{
|
|
215
|
+
"Authorization": "Bearer " + token,
|
|
216
|
+
"X-CWMS-LRTS-Formatting": str(USE_NEW_LRTS_IDS).lower(),
|
|
217
|
+
}
|
|
218
|
+
)
|
|
210
219
|
elif api_key:
|
|
211
220
|
if api_key.startswith("apikey "):
|
|
212
221
|
api_key = api_key.replace("apikey ", "")
|
|
213
|
-
SESSION.headers.update(
|
|
222
|
+
SESSION.headers.update(
|
|
223
|
+
{
|
|
224
|
+
"Authorization": "apikey " + api_key,
|
|
225
|
+
"X-CWMS-LRTS-Formatting": str(USE_NEW_LRTS_IDS).lower(),
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
USE_NEW_LRTS_IDS = use_new_lrts_format
|
|
214
230
|
|
|
215
231
|
return SESSION
|
|
216
232
|
|
|
@@ -225,6 +241,46 @@ def return_base_url() -> str:
|
|
|
225
241
|
return str(SESSION.base_url)
|
|
226
242
|
|
|
227
243
|
|
|
244
|
+
def set_use_new_lrts_ids(state: bool) -> None:
|
|
245
|
+
"""Sets whether the new LRTS identifer format is used for subsequent operations.
|
|
246
|
+
|
|
247
|
+
The old Local Regular Time Series (LRTS) identifier format is the same as the
|
|
248
|
+
Pseudo-Regular Time Series (PRTS) identifier format, where both prepend a tilde
|
|
249
|
+
character ('~') to valid Regular Time Series (RTS) interval identifiers (e.g.,
|
|
250
|
+
~6Hours, ~1Day).
|
|
251
|
+
|
|
252
|
+
The new LRTS identifiers instead append "Local" to valid RTS interval identifiers
|
|
253
|
+
(e.g., 6HoursLocal, 1DayLocal), leaving the old format to specify only PRTS.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
state: Whether the new LRTS identifier format is used
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
global USE_NEW_LRTS_IDS
|
|
260
|
+
|
|
261
|
+
USE_NEW_LRTS_IDS = state
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def get_use_new_lrts_ids() -> bool:
|
|
265
|
+
"""Gets whether the new LRTS identifer format is used for subsequent operations.
|
|
266
|
+
|
|
267
|
+
The old Local Regular Time Series (LRTS) identifier format is the same as the
|
|
268
|
+
Pseudo-Regular Time Series (PRTS) identifier format, where both prepend a tilde
|
|
269
|
+
character ('~') to valid Regular Time Series (RTS) interval identifiers (e.g.,
|
|
270
|
+
~6Hours, ~1Day).
|
|
271
|
+
|
|
272
|
+
The new LRTS identifiers instead append "Local" to valid RTS interval identifiers
|
|
273
|
+
(e.g., 6HoursLocal, 1DayLocal), leaving the old format to specify only PRTS.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Whether the new LRTS identifier format is used
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
global USE_NEW_LRTS_IDS
|
|
280
|
+
|
|
281
|
+
return USE_NEW_LRTS_IDS
|
|
282
|
+
|
|
283
|
+
|
|
228
284
|
def api_version_text(api_version: int) -> str:
|
|
229
285
|
"""Initialize CDA request headers.
|
|
230
286
|
|
|
@@ -329,7 +385,10 @@ def get(
|
|
|
329
385
|
ApiError: If an error response is return by the API.
|
|
330
386
|
"""
|
|
331
387
|
|
|
332
|
-
headers = {
|
|
388
|
+
headers = {
|
|
389
|
+
"Accept": api_version_text(api_version),
|
|
390
|
+
"X-CWMS-LRTS-Formatting": str(USE_NEW_LRTS_IDS).lower(),
|
|
391
|
+
}
|
|
333
392
|
try:
|
|
334
393
|
with SESSION.get(endpoint, params=params, headers=headers) as response:
|
|
335
394
|
if not response.ok:
|
|
@@ -389,7 +448,11 @@ def _post_function(
|
|
|
389
448
|
) -> Any:
|
|
390
449
|
|
|
391
450
|
# post requires different headers than get for
|
|
392
|
-
headers = {
|
|
451
|
+
headers = {
|
|
452
|
+
"accept": "*/*",
|
|
453
|
+
"Content-Type": api_version_text(api_version),
|
|
454
|
+
"X-CWMS-LRTS-Formatting": str(USE_NEW_LRTS_IDS).lower(),
|
|
455
|
+
}
|
|
393
456
|
if isinstance(data, dict) or isinstance(data, list):
|
|
394
457
|
data = json.dumps(data)
|
|
395
458
|
try:
|
|
@@ -487,7 +550,11 @@ def patch(
|
|
|
487
550
|
ApiError: If an error response is return by the API.
|
|
488
551
|
"""
|
|
489
552
|
|
|
490
|
-
headers = {
|
|
553
|
+
headers = {
|
|
554
|
+
"accept": "*/*",
|
|
555
|
+
"Content-Type": api_version_text(api_version),
|
|
556
|
+
"X-CWMS-LRTS-Formatting": str(USE_NEW_LRTS_IDS).lower(),
|
|
557
|
+
}
|
|
491
558
|
|
|
492
559
|
if data and isinstance(data, dict) or isinstance(data, list):
|
|
493
560
|
data = json.dumps(data)
|
|
@@ -522,7 +589,10 @@ def delete(
|
|
|
522
589
|
ApiError: If an error response is return by the API.
|
|
523
590
|
"""
|
|
524
591
|
|
|
525
|
-
headers = {
|
|
592
|
+
headers = {
|
|
593
|
+
"Accept": api_version_text(api_version),
|
|
594
|
+
"X-CWMS-LRTS-Formatting": str(USE_NEW_LRTS_IDS).lower(),
|
|
595
|
+
}
|
|
526
596
|
try:
|
|
527
597
|
with SESSION.delete(endpoint, params=params, headers=headers) as response:
|
|
528
598
|
if not response.ok:
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# Copyright (c) 2026
|
|
2
|
+
# United States Army Corps of Engineers - Hydrologic Engineering Center (USACE/HEC)
|
|
3
|
+
# All Rights Reserved. USACE PROPRIETARY/CONFIDENTIAL.
|
|
4
|
+
# Source may not be released without written approval from HEC
|
|
5
|
+
|
|
6
|
+
import cwms.api as api
|
|
7
|
+
from cwms.cwms_types import JSON, Data
|
|
8
|
+
|
|
9
|
+
ENDPOINT = "lookup-types"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_all_lookups(category: str, prefix: str, office_id: str) -> Data:
|
|
13
|
+
"""
|
|
14
|
+
Retrieves all lookups for a given category, prefix, and office.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
category : str
|
|
19
|
+
Filters lookup types to the specified category
|
|
20
|
+
prefix : str
|
|
21
|
+
Filters lookup types to the specified prefix
|
|
22
|
+
office_id : str
|
|
23
|
+
Filters lookup types to the specified office ID
|
|
24
|
+
|
|
25
|
+
Returns
|
|
26
|
+
-------
|
|
27
|
+
Data
|
|
28
|
+
The JSON response from CWMS Data API wrapped in a Data object.
|
|
29
|
+
|
|
30
|
+
Raises
|
|
31
|
+
------
|
|
32
|
+
ValueError
|
|
33
|
+
If any required argument is missing.
|
|
34
|
+
ClientError
|
|
35
|
+
If a 400 range error code response is returned from the server.
|
|
36
|
+
NoDataFoundError
|
|
37
|
+
If a 404 range error code response is returned from the server.
|
|
38
|
+
ServerError
|
|
39
|
+
If a 500 range error code response is returned from the server.
|
|
40
|
+
"""
|
|
41
|
+
if not all([category, prefix, office_id]):
|
|
42
|
+
raise ValueError("Category, Prefix, and Office ID must be specified")
|
|
43
|
+
|
|
44
|
+
params = {"category": category, "prefix": prefix, "office": office_id}
|
|
45
|
+
response = api.get(ENDPOINT, params, api_version=1)
|
|
46
|
+
return Data(response)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def create_lookup(data: JSON, category: str, prefix: str) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Creates a new lookup entry.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
data: JSON
|
|
56
|
+
A dictionary representing the JSON data to be stored. This should match the
|
|
57
|
+
LookupType structure as defined by the API.
|
|
58
|
+
category : str
|
|
59
|
+
Specifies the category of the lookup.
|
|
60
|
+
prefix : str
|
|
61
|
+
Specifies the prefix of the lookup.
|
|
62
|
+
|
|
63
|
+
Returns
|
|
64
|
+
-------
|
|
65
|
+
None
|
|
66
|
+
|
|
67
|
+
Raises
|
|
68
|
+
------
|
|
69
|
+
ValueError
|
|
70
|
+
If any required argument is missing.
|
|
71
|
+
ClientError
|
|
72
|
+
If a 400 range error code response is returned from the server.
|
|
73
|
+
NoDataFoundError
|
|
74
|
+
If a 404 range error code response is returned from the server.
|
|
75
|
+
ServerError
|
|
76
|
+
If a 500 range error code response is returned from the server.
|
|
77
|
+
"""
|
|
78
|
+
if not all([category, prefix]):
|
|
79
|
+
raise ValueError("Category and Prefix must be specified")
|
|
80
|
+
if not data:
|
|
81
|
+
raise ValueError("Data must be specified")
|
|
82
|
+
params = {"category": category, "prefix": prefix}
|
|
83
|
+
api.post(ENDPOINT, data, params, api_version=1)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def update_lookup(data: JSON, category: str, prefix: str) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Updates a specified lookup entry.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
data : JSON
|
|
93
|
+
A dictionary representing the JSON data to be stored.
|
|
94
|
+
If the `data` value is None, a `ValueError` will be raised.
|
|
95
|
+
category : str
|
|
96
|
+
Specifies the category of the lookup.
|
|
97
|
+
prefix : str
|
|
98
|
+
Specifies the prefix of the lookup.
|
|
99
|
+
|
|
100
|
+
Returns
|
|
101
|
+
-------
|
|
102
|
+
None
|
|
103
|
+
|
|
104
|
+
Raises
|
|
105
|
+
------
|
|
106
|
+
ValueError
|
|
107
|
+
If any required argument is missing.
|
|
108
|
+
ClientError
|
|
109
|
+
If a 400 range error code response is returned from the server.
|
|
110
|
+
NoDataFoundError
|
|
111
|
+
If a 404 range error code response is returned from the server.
|
|
112
|
+
ServerError
|
|
113
|
+
If a 500 range error code response is returned from the server.
|
|
114
|
+
"""
|
|
115
|
+
if not all([category, prefix]):
|
|
116
|
+
raise ValueError("Category and Prefix must be specified")
|
|
117
|
+
if not data:
|
|
118
|
+
raise ValueError("Data must be specified")
|
|
119
|
+
|
|
120
|
+
# Note that the path parameter is unused in CDA
|
|
121
|
+
endpoint = f"{ENDPOINT}/{category}"
|
|
122
|
+
params = {"category": category, "prefix": prefix}
|
|
123
|
+
api.patch(endpoint, data, params, api_version=1)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def delete_lookup(name: str, category: str, prefix: str, office_id: str) -> None:
|
|
127
|
+
"""
|
|
128
|
+
Deletes a specified lookup entry.
|
|
129
|
+
|
|
130
|
+
Parameters
|
|
131
|
+
----------
|
|
132
|
+
name : str
|
|
133
|
+
Specifies the location type to delete.
|
|
134
|
+
category : str
|
|
135
|
+
Specifies the category id of the lookup type to be deleted.
|
|
136
|
+
prefix : str
|
|
137
|
+
Specifies the prefix of the lookup type to be deleted.
|
|
138
|
+
office_id : str
|
|
139
|
+
Specifies the owning office of the lookup type to be deleted.
|
|
140
|
+
|
|
141
|
+
Returns
|
|
142
|
+
-------
|
|
143
|
+
None
|
|
144
|
+
|
|
145
|
+
Raises
|
|
146
|
+
------
|
|
147
|
+
ValueError
|
|
148
|
+
If any required argument is missing.
|
|
149
|
+
ClientError
|
|
150
|
+
If a 400 range error code response is returned from the server.
|
|
151
|
+
NoDataFoundError
|
|
152
|
+
If a 404 range error code response is returned from the server.
|
|
153
|
+
ServerError
|
|
154
|
+
If a 500 range error code response is returned from the server.
|
|
155
|
+
"""
|
|
156
|
+
if not all([name, category, prefix, office_id]):
|
|
157
|
+
raise ValueError("Name, Category, Prefix, and Office ID must be specified")
|
|
158
|
+
|
|
159
|
+
endpoint = f"{ENDPOINT}/{name}"
|
|
160
|
+
params = {"category": category, "prefix": prefix, "office": office_id}
|
|
161
|
+
api.delete(endpoint, params, api_version=1)
|
|
@@ -42,7 +42,7 @@ def get_project(office_id: str, name: str) -> Data:
|
|
|
42
42
|
|
|
43
43
|
endpoint = f"projects/{name}"
|
|
44
44
|
params = {"office": office_id}
|
|
45
|
-
response = api.get(endpoint, params)
|
|
45
|
+
response = api.get(endpoint, params, api_version=1)
|
|
46
46
|
return Data(response)
|
|
47
47
|
|
|
48
48
|
|
|
@@ -129,7 +129,7 @@ def get_project_locations(
|
|
|
129
129
|
"project-like": project_like,
|
|
130
130
|
"location-kind-like": location_id_like,
|
|
131
131
|
}
|
|
132
|
-
response = api.get(endpoint, params)
|
|
132
|
+
response = api.get(endpoint, params, api_version=1)
|
|
133
133
|
return Data(response)
|
|
134
134
|
|
|
135
135
|
|
|
@@ -169,7 +169,7 @@ def delete_project(office_id: str, name: str, delete_method: DeleteMethod) -> No
|
|
|
169
169
|
|
|
170
170
|
endpoint = f"projects/{name}"
|
|
171
171
|
params = {"office": office_id, "method": delete_method.name}
|
|
172
|
-
api.delete(endpoint, params)
|
|
172
|
+
api.delete(endpoint, params, api_version=1)
|
|
173
173
|
|
|
174
174
|
|
|
175
175
|
def rename_project(office_id: str, old_name: str, new_name: str) -> None:
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Copyright (c) 2026
|
|
2
|
+
# United States Army Corps of Engineers - Hydrologic Engineering Center (USACE/HEC)
|
|
3
|
+
# All Rights Reserved. USACE PROPRIETARY/CONFIDENTIAL.
|
|
4
|
+
# Source may not be released without written approval from HEC
|
|
5
|
+
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import cwms.api as api
|
|
9
|
+
from cwms.cwms_types import JSON, Data
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_properties(
|
|
13
|
+
office_mask: Optional[str] = None,
|
|
14
|
+
category_id_mask: Optional[str] = None,
|
|
15
|
+
name_mask: Optional[str] = None,
|
|
16
|
+
) -> Data:
|
|
17
|
+
"""
|
|
18
|
+
Returns matching CWMS Property Data.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
office_mask: string, optional
|
|
23
|
+
Filters properties to the specified office mask
|
|
24
|
+
category_id_mask: string, optional
|
|
25
|
+
Filters properties to the specified category mask
|
|
26
|
+
name_mask: string, optional
|
|
27
|
+
Filters properties to the specified name mask
|
|
28
|
+
|
|
29
|
+
Returns
|
|
30
|
+
-------
|
|
31
|
+
cwms data type
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
endpoint = "properties"
|
|
35
|
+
params = {
|
|
36
|
+
"office-mask": office_mask,
|
|
37
|
+
"category-id-mask": category_id_mask,
|
|
38
|
+
"name-mask": name_mask,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
response = api.get(endpoint, params, api_version=1)
|
|
42
|
+
return Data(response)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_property(
|
|
46
|
+
name: str, office: str, category_id: str, default_value: Optional[str] = None
|
|
47
|
+
) -> Data:
|
|
48
|
+
"""
|
|
49
|
+
Returns CWMS Property Data.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
name: string
|
|
54
|
+
Specifies the name of the property to be retrieved.
|
|
55
|
+
office: string
|
|
56
|
+
Specifies the owning office of the property to be retrieved.
|
|
57
|
+
category_id: string
|
|
58
|
+
Specifies the category id of the property to be retrieved.
|
|
59
|
+
default_value: string, optional
|
|
60
|
+
Specifies the default value if the property does not exist.
|
|
61
|
+
|
|
62
|
+
Returns
|
|
63
|
+
-------
|
|
64
|
+
cwms data type
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
endpoint = f"properties/{name}"
|
|
68
|
+
params = {
|
|
69
|
+
"office": office,
|
|
70
|
+
"category-id": category_id,
|
|
71
|
+
"default-value": default_value,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
response = api.get(endpoint, params, api_version=1)
|
|
75
|
+
return Data(response)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def create_property(data: JSON) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Create CWMS Property.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
data: JSON dictionary
|
|
85
|
+
Property data to be stored.
|
|
86
|
+
Example:
|
|
87
|
+
{
|
|
88
|
+
"office-id": "string",
|
|
89
|
+
"name": "string",
|
|
90
|
+
"category": "string",
|
|
91
|
+
"value": "string",
|
|
92
|
+
"comment": "string"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
None
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
endpoint = "properties"
|
|
101
|
+
|
|
102
|
+
if data is None:
|
|
103
|
+
raise ValueError("Cannot store a property without JSON data")
|
|
104
|
+
|
|
105
|
+
return api.post(endpoint, data, api_version=1)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def update_property(name: str, data: JSON) -> None:
|
|
109
|
+
"""
|
|
110
|
+
Update CWMS Property.
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
name: string
|
|
115
|
+
Specifies the name of the property to be updated.
|
|
116
|
+
data: JSON dictionary
|
|
117
|
+
Property data to be updated.
|
|
118
|
+
Example:
|
|
119
|
+
{
|
|
120
|
+
"office-id": "string",
|
|
121
|
+
"name": "string",
|
|
122
|
+
"category": "string",
|
|
123
|
+
"value": "string",
|
|
124
|
+
"comment": "string"
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
Returns
|
|
128
|
+
-------
|
|
129
|
+
None
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
endpoint = f"properties/{name}"
|
|
133
|
+
|
|
134
|
+
if name is None:
|
|
135
|
+
raise ValueError("Must specify a property name to update")
|
|
136
|
+
|
|
137
|
+
if data is None:
|
|
138
|
+
raise ValueError("Cannot update a property without JSON data")
|
|
139
|
+
|
|
140
|
+
return api.patch(endpoint, data, api_version=1)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def delete_property(name: str, office: str, category_id: str) -> None:
|
|
144
|
+
"""
|
|
145
|
+
Delete CWMS Property.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
name: string
|
|
150
|
+
Specifies the name of the property to be deleted.
|
|
151
|
+
office: string
|
|
152
|
+
Specifies the owning office of the property to be deleted.
|
|
153
|
+
category_id: string
|
|
154
|
+
Specifies the category id of the property to be deleted.
|
|
155
|
+
|
|
156
|
+
Returns
|
|
157
|
+
-------
|
|
158
|
+
None
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
endpoint = f"properties/{name}"
|
|
162
|
+
|
|
163
|
+
params = {
|
|
164
|
+
"office": office,
|
|
165
|
+
"category-id": category_id,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return api.delete(endpoint, params, api_version=1)
|
|
@@ -166,6 +166,9 @@ def _call_with_retry(fn: Any, *args: Any, attempts: int = _CHUNK_ATTEMPTS) -> An
|
|
|
166
166
|
try:
|
|
167
167
|
return fn(*args)
|
|
168
168
|
except Exception as e:
|
|
169
|
+
status_code = getattr(getattr(e, "response", None), "status_code", None)
|
|
170
|
+
if status_code == 404:
|
|
171
|
+
raise
|
|
169
172
|
if i == attempts - 1:
|
|
170
173
|
raise
|
|
171
174
|
logging.warning(f"chunk attempt {i + 1}/{attempts} failed: {e}")
|
|
@@ -525,7 +528,7 @@ def store_multi_timeseries_df(
|
|
|
525
528
|
DELETE_INSERT.
|
|
526
529
|
override_protection: bool, optional, default is False
|
|
527
530
|
A flag to ignore the protected data quality flag when storing data.
|
|
528
|
-
multithread: bool, default is
|
|
531
|
+
multithread: bool, default is true
|
|
529
532
|
Specifies whether to store chunked time series values using multiple threads.
|
|
530
533
|
max_workers: Int, Optional, default is None
|
|
531
534
|
It is a number of Threads aka size of pool in concurrent.futures.ThreadPoolExecutor.
|
|
@@ -533,6 +536,12 @@ def store_multi_timeseries_df(
|
|
|
533
536
|
Returns
|
|
534
537
|
-------
|
|
535
538
|
None
|
|
539
|
+
|
|
540
|
+
Raises
|
|
541
|
+
------
|
|
542
|
+
RuntimeError
|
|
543
|
+
If any series fails to store. The message identifies failed series;
|
|
544
|
+
other series may already have been stored successfully.
|
|
536
545
|
"""
|
|
537
546
|
|
|
538
547
|
def store_ts_ids(
|
|
@@ -541,24 +550,21 @@ def store_multi_timeseries_df(
|
|
|
541
550
|
office_id: str,
|
|
542
551
|
version_date: Optional[datetime] = None,
|
|
543
552
|
) -> None:
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
)
|
|
560
|
-
except Exception as e:
|
|
561
|
-
print(f"Error processing {ts_id}: {e}")
|
|
553
|
+
units = data["units"].iloc[0]
|
|
554
|
+
data_json = timeseries_df_to_json(
|
|
555
|
+
data=data,
|
|
556
|
+
ts_id=ts_id,
|
|
557
|
+
units=units,
|
|
558
|
+
office_id=office_id,
|
|
559
|
+
version_date=version_date,
|
|
560
|
+
)
|
|
561
|
+
store_timeseries(
|
|
562
|
+
data=data_json,
|
|
563
|
+
create_as_ltrs=create_as_ltrs,
|
|
564
|
+
store_rule=store_rule,
|
|
565
|
+
override_protection=override_protection,
|
|
566
|
+
multithread=multithread,
|
|
567
|
+
)
|
|
562
568
|
return None
|
|
563
569
|
|
|
564
570
|
required_columns = ["date-time", "value", "ts_id", "units"]
|
|
@@ -574,7 +580,9 @@ def store_multi_timeseries_df(
|
|
|
574
580
|
ts_data_all["ts_id"].astype(str) + ":" + ts_data_all["version_date"].astype(str)
|
|
575
581
|
).unique()
|
|
576
582
|
|
|
583
|
+
errors: List[str] = []
|
|
577
584
|
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
585
|
+
futures = {}
|
|
578
586
|
for unique_tsid in unique_tsids:
|
|
579
587
|
ts_id, version_date = unique_tsid.split(":", 1)
|
|
580
588
|
if version_date != "NaT":
|
|
@@ -589,9 +597,21 @@ def store_multi_timeseries_df(
|
|
|
589
597
|
(ts_data_all["ts_id"] == ts_id) & ts_data_all["version_date"].isna()
|
|
590
598
|
]
|
|
591
599
|
if not data.empty:
|
|
592
|
-
executor.submit(
|
|
600
|
+
future = executor.submit(
|
|
593
601
|
store_ts_ids, ts_data, ts_id, office_id, version_date_dt
|
|
594
602
|
)
|
|
603
|
+
futures[future] = unique_tsid
|
|
604
|
+
|
|
605
|
+
for future in concurrent.futures.as_completed(futures):
|
|
606
|
+
try:
|
|
607
|
+
future.result()
|
|
608
|
+
except Exception as e:
|
|
609
|
+
errors.append(f"{futures[future]}: {e}")
|
|
610
|
+
|
|
611
|
+
if errors:
|
|
612
|
+
raise RuntimeError(
|
|
613
|
+
f"{len(errors)} time series failed to store:\n" + "\n".join(errors)
|
|
614
|
+
)
|
|
595
615
|
|
|
596
616
|
|
|
597
617
|
def chunk_timeseries_data(
|
|
@@ -683,7 +703,14 @@ def store_timeseries(
|
|
|
683
703
|
if len(chunks) == 1 or not multithread:
|
|
684
704
|
return api.post(endpoint, data, params)
|
|
685
705
|
|
|
686
|
-
|
|
706
|
+
if max_workers <= 0:
|
|
707
|
+
raise ValueError("max_workers must be greater than 0")
|
|
708
|
+
|
|
709
|
+
# A new series must exist before multiple transactions can write its data.
|
|
710
|
+
# Complete one normal write first, then retain parallelism for the rest.
|
|
711
|
+
_call_with_retry(api.post, endpoint, chunks[0], params)
|
|
712
|
+
remaining_chunks = chunks[1:]
|
|
713
|
+
actual_workers = min(max_workers, len(remaining_chunks))
|
|
687
714
|
logging.debug(
|
|
688
715
|
f"Storing {len(chunks)} chunks of timeseries data with {actual_workers} threads"
|
|
689
716
|
)
|
|
@@ -695,7 +722,7 @@ def store_timeseries(
|
|
|
695
722
|
with concurrent.futures.ThreadPoolExecutor(max_workers=actual_workers) as executor:
|
|
696
723
|
future_to_chunk = {
|
|
697
724
|
executor.submit(_call_with_retry, api.post, endpoint, chunk, params): chunk
|
|
698
|
-
for chunk in
|
|
725
|
+
for chunk in remaining_chunks
|
|
699
726
|
}
|
|
700
727
|
|
|
701
728
|
for future in concurrent.futures.as_completed(future_to_chunk):
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
name = "cwms-python"
|
|
3
3
|
repository = "https://github.com/HydrologicEngineeringCenter/cwms-python"
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
# Managed by Release Please; runtime versions come from package metadata.
|
|
6
|
+
version = "1.0.9"
|
|
6
7
|
|
|
7
8
|
packages = [
|
|
8
9
|
{ include = "cwms" },
|
|
@@ -15,16 +16,31 @@ authors = ["Eric Novotny <eric.v.novotny@usace.army.mil>"]
|
|
|
15
16
|
|
|
16
17
|
[tool.poetry.dependencies]
|
|
17
18
|
python = "^3.9"
|
|
18
|
-
pandas = "^2.
|
|
19
|
+
pandas = "^2.3.3"
|
|
20
|
+
# Require NumPy wheels for newer Python versions while retaining Python 3.9.
|
|
21
|
+
numpy = [
|
|
22
|
+
{version = ">=1.23.5,<3", python = ">=3.9,<3.13"},
|
|
23
|
+
{version = ">=2.1.0,<3", python = ">=3.13,<3.14"},
|
|
24
|
+
{version = ">=2.3.3,<3", python = ">=3.14"},
|
|
25
|
+
]
|
|
19
26
|
requests-toolbelt = "^1.0.0"
|
|
20
|
-
requests =
|
|
27
|
+
requests = [
|
|
28
|
+
{version = "^2.32.4", python = ">=3.9,<3.10"},
|
|
29
|
+
{version = "^2.33.0", python = ">=3.10"},
|
|
30
|
+
]
|
|
21
31
|
|
|
22
32
|
[tool.poetry.group.dev.dependencies]
|
|
23
|
-
black =
|
|
33
|
+
black = [
|
|
34
|
+
{version = "^25.1.0", python = ">=3.9,<3.10"},
|
|
35
|
+
{version = "^26.3.1", python = ">=3.10"},
|
|
36
|
+
]
|
|
24
37
|
isort = "^5.13.2"
|
|
25
38
|
mypy = "^1.9.0"
|
|
26
39
|
pre-commit = "^3.6.2"
|
|
27
|
-
pytest =
|
|
40
|
+
pytest = [
|
|
41
|
+
{version = "^8.3.5", python = ">=3.9,<3.10"},
|
|
42
|
+
{version = "^9.0.3", python = ">=3.10"},
|
|
43
|
+
]
|
|
28
44
|
requests-mock = "^1.11.0"
|
|
29
45
|
pytest-cov = "^4.1.0"
|
|
30
46
|
pandas-stubs = "^2.2.1.240316"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|