cwms-python 1.0.7__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.
Files changed (42) hide show
  1. {cwms_python-1.0.7 → cwms_python-1.0.9}/PKG-INFO +12 -3
  2. {cwms_python-1.0.7 → cwms_python-1.0.9}/README.md +5 -0
  3. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/__init__.py +2 -0
  4. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/api.py +77 -7
  5. cwms_python-1.0.9/cwms/locations/lookups.py +161 -0
  6. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/projects/projects.py +3 -3
  7. cwms_python-1.0.9/cwms/properties/properties.py +168 -0
  8. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/timeseries/timeseries.py +100 -50
  9. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/timeseries/timeseries_group.py +30 -8
  10. {cwms_python-1.0.7 → cwms_python-1.0.9}/pyproject.toml +21 -5
  11. {cwms_python-1.0.7 → cwms_python-1.0.9}/LICENSE +0 -0
  12. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/catalog/blobs.py +0 -0
  13. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/catalog/catalog.py +0 -0
  14. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/catalog/clobs.py +0 -0
  15. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/cwms_types.py +0 -0
  16. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/forecast/forecast_instance.py +0 -0
  17. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/forecast/forecast_spec.py +0 -0
  18. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/levels/location_levels.py +0 -0
  19. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/levels/specified_levels.py +0 -0
  20. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/locations/gate_changes.py +0 -0
  21. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/locations/location_groups.py +0 -0
  22. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/locations/physical_locations.py +0 -0
  23. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/measurements/measurements.py +0 -0
  24. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/outlets/outlets.py +0 -0
  25. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/outlets/virtual_outlets.py +0 -0
  26. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/projects/project_lock_rights.py +0 -0
  27. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/projects/project_locks.py +0 -0
  28. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/projects/water_supply/accounting.py +0 -0
  29. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/ratings/ratings.py +0 -0
  30. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/ratings/ratings_spec.py +0 -0
  31. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/ratings/ratings_template.py +0 -0
  32. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/standard_text/standard_text.py +0 -0
  33. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/timeseries/timeseries_bin.py +0 -0
  34. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/timeseries/timeseries_identifier.py +0 -0
  35. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/timeseries/timeseries_profile.py +0 -0
  36. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/timeseries/timeseries_profile_instance.py +0 -0
  37. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/timeseries/timeseries_profile_parser.py +0 -0
  38. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/timeseries/timeseries_txt.py +0 -0
  39. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/turbines/turbines.py +0 -0
  40. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/users/users.py +0 -0
  41. {cwms_python-1.0.7 → cwms_python-1.0.9}/cwms/utils/__init__.py +0 -0
  42. {cwms_python-1.0.7 → 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.7
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: pandas (>=2.1.3,<3.0.0)
20
- Requires-Dist: requests (>=2.31.0,<3.0.0)
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({"Authorization": "Bearer " + token})
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({"Authorization": "apikey " + api_key})
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 = {"Accept": api_version_text(api_version)}
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 = {"accept": "*/*", "Content-Type": api_version_text(api_version)}
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 = {"accept": "*/*", "Content-Type": api_version_text(api_version)}
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 = {"Accept": api_version_text(api_version)}
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)
@@ -154,6 +154,26 @@ def get_timeseries_chunk(
154
154
  return Data(response, selector=selector)
155
155
 
156
156
 
157
+ # Number of attempts for a single chunked timeseries request. CDA occasionally
158
+ # returns 500s caused by connection-pool exhaustion that succeed on retry; 500
159
+ # is intentionally not in the session-level status_forcelist (see PR #282), so
160
+ # we retry here, scoped to the chunked store/fetch paths only.
161
+ _CHUNK_ATTEMPTS = 6
162
+
163
+
164
+ def _call_with_retry(fn: Any, *args: Any, attempts: int = _CHUNK_ATTEMPTS) -> Any:
165
+ for i in range(attempts):
166
+ try:
167
+ return fn(*args)
168
+ except Exception as e:
169
+ status_code = getattr(getattr(e, "response", None), "status_code", None)
170
+ if status_code == 404:
171
+ raise
172
+ if i == attempts - 1:
173
+ raise
174
+ logging.warning(f"chunk attempt {i + 1}/{attempts} failed: {e}")
175
+
176
+
157
177
  def fetch_timeseries_chunks(
158
178
  chunks: List[Tuple[datetime, datetime]],
159
179
  params: Dict[str, Any],
@@ -161,14 +181,13 @@ def fetch_timeseries_chunks(
161
181
  endpoint: str,
162
182
  max_workers: int,
163
183
  ) -> List[Data]:
164
- # Initialize an empty list to store results
165
- results = []
184
+ results: List[Data] = []
185
+ errors: List[str] = []
166
186
 
167
- # Create a ThreadPoolExecutor to manage multithreading
168
187
  with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
169
- # Submit tasks for each chunk to the api
170
188
  future_to_chunk = {
171
189
  executor.submit(
190
+ _call_with_retry,
172
191
  get_timeseries_chunk,
173
192
  selector,
174
193
  endpoint,
@@ -179,18 +198,23 @@ def fetch_timeseries_chunks(
179
198
  for chunk_start, chunk_end in chunks
180
199
  }
181
200
 
182
- # Process completed threads as they finish
183
201
  for future in concurrent.futures.as_completed(future_to_chunk):
202
+ chunk_start, chunk_end = future_to_chunk[future]
184
203
  try:
185
- # Retrieve the result of the completed future
186
- result = future.result()
187
- results.append(result)
204
+ results.append(future.result())
188
205
  except Exception as e:
189
- chunk_start, chunk_end = future_to_chunk[future]
190
- # Log or handle any errors that occur during execution
191
- logging.error(
206
+ error_msg = (
192
207
  f"Failed to fetch data from {chunk_start} to {chunk_end}: {e}"
193
208
  )
209
+ logging.error(error_msg)
210
+ errors.append(error_msg)
211
+
212
+ if errors:
213
+ raise RuntimeError(
214
+ f"{len(errors)} of {len(chunks)} chunk(s) failed to fetch:\n"
215
+ + "\n".join(errors)
216
+ )
217
+
194
218
  return results
195
219
 
196
220
 
@@ -504,7 +528,7 @@ def store_multi_timeseries_df(
504
528
  DELETE_INSERT.
505
529
  override_protection: bool, optional, default is False
506
530
  A flag to ignore the protected data quality flag when storing data.
507
- multithread: bool, default is false
531
+ multithread: bool, default is true
508
532
  Specifies whether to store chunked time series values using multiple threads.
509
533
  max_workers: Int, Optional, default is None
510
534
  It is a number of Threads aka size of pool in concurrent.futures.ThreadPoolExecutor.
@@ -512,6 +536,12 @@ def store_multi_timeseries_df(
512
536
  Returns
513
537
  -------
514
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.
515
545
  """
516
546
 
517
547
  def store_ts_ids(
@@ -520,24 +550,21 @@ def store_multi_timeseries_df(
520
550
  office_id: str,
521
551
  version_date: Optional[datetime] = None,
522
552
  ) -> None:
523
- try:
524
- units = data["units"].iloc[0]
525
- data_json = timeseries_df_to_json(
526
- data=data,
527
- ts_id=ts_id,
528
- units=units,
529
- office_id=office_id,
530
- version_date=version_date,
531
- )
532
- store_timeseries(
533
- data=data_json,
534
- create_as_ltrs=create_as_ltrs,
535
- store_rule=store_rule,
536
- override_protection=override_protection,
537
- multithread=multithread,
538
- )
539
- except Exception as e:
540
- 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
+ )
541
568
  return None
542
569
 
543
570
  required_columns = ["date-time", "value", "ts_id", "units"]
@@ -553,7 +580,9 @@ def store_multi_timeseries_df(
553
580
  ts_data_all["ts_id"].astype(str) + ":" + ts_data_all["version_date"].astype(str)
554
581
  ).unique()
555
582
 
583
+ errors: List[str] = []
556
584
  with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
585
+ futures = {}
557
586
  for unique_tsid in unique_tsids:
558
587
  ts_id, version_date = unique_tsid.split(":", 1)
559
588
  if version_date != "NaT":
@@ -568,9 +597,21 @@ def store_multi_timeseries_df(
568
597
  (ts_data_all["ts_id"] == ts_id) & ts_data_all["version_date"].isna()
569
598
  ]
570
599
  if not data.empty:
571
- executor.submit(
600
+ future = executor.submit(
572
601
  store_ts_ids, ts_data, ts_id, office_id, version_date_dt
573
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
+ )
574
615
 
575
616
 
576
617
  def chunk_timeseries_data(
@@ -662,36 +703,45 @@ def store_timeseries(
662
703
  if len(chunks) == 1 or not multithread:
663
704
  return api.post(endpoint, data, params)
664
705
 
665
- actual_workers = min(max_workers, len(chunks))
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))
666
714
  logging.debug(
667
715
  f"Storing {len(chunks)} chunks of timeseries data with {actual_workers} threads"
668
716
  )
669
717
 
670
718
  # Store chunks concurrently
671
719
  responses: List[Dict[str, Any]] = []
672
- with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
673
- # Initialize an empty list to store futures
674
- futures = []
675
- # Submit each chunk as a separate task to the executor
676
- for chunk in chunks:
677
- future = executor.submit(
678
- api.post, # The function to execute
679
- endpoint,
680
- chunk, # The chunk of data to store
681
- params,
682
- )
683
- futures.append(future) # Add the future to the list
720
+ errors: List[str] = []
684
721
 
685
- for future in concurrent.futures.as_completed(futures):
722
+ with concurrent.futures.ThreadPoolExecutor(max_workers=actual_workers) as executor:
723
+ future_to_chunk = {
724
+ executor.submit(_call_with_retry, api.post, endpoint, chunk, params): chunk
725
+ for chunk in remaining_chunks
726
+ }
727
+
728
+ for future in concurrent.futures.as_completed(future_to_chunk):
729
+ chunk = future_to_chunk[future]
686
730
  try:
687
- responses.append({"success:": future.result()})
731
+ responses.append({"success": future.result()})
688
732
  except Exception as e:
689
733
  start_time = chunk["values"][0][0]
690
734
  end_time = chunk["values"][-1][0]
691
- logging.error(
692
- f"Error storing chunk from {start_time} to {end_time}: {e}"
693
- )
694
- responses.append({"error": str(e)})
735
+ error_msg = f"Error storing chunk from {start_time} to {end_time}: {e}"
736
+ logging.error(error_msg)
737
+ errors.append(error_msg)
738
+ responses.append({"error": error_msg})
739
+
740
+ if errors:
741
+ raise RuntimeError(
742
+ f"{len(errors)} of {len(chunks)} chunk(s) failed to store:\n"
743
+ + "\n".join(errors)
744
+ )
695
745
 
696
746
  return
697
747
 
@@ -11,8 +11,8 @@ from cwms.cwms_types import JSON, Data
11
11
 
12
12
  def get_timeseries_group(
13
13
  group_id: str,
14
- category_id: str,
15
- category_office_id: str,
14
+ category_id: Optional[str] = None,
15
+ category_office_id: Optional[str] = None,
16
16
  office_id: Optional[str] = None,
17
17
  group_office_id: Optional[str] = None,
18
18
  ) -> Data:
@@ -54,14 +54,16 @@ def get_timeseries_groups(
54
54
  timeseries_category_like: Optional[str] = None,
55
55
  timeseries_group_like: Optional[str] = None,
56
56
  category_office_id: Optional[str] = None,
57
+ group_office_id: Optional[str] = None,
57
58
  ) -> Data:
58
59
  """
59
60
  Retreives a list of time series groups.
60
61
 
61
62
  Parameters
62
63
  ----------
63
- category_id: string
64
- The category id that contains the timeseries group.
64
+ office_id: string
65
+ Specifies the owning office of the timeseries assigned to the group(s).
66
+ If not specified, group information for all assigned TS offices is returned.
65
67
  include_assigned: Boolean
66
68
  Include the assigned timeseries in the returned timeseries groups. (default: true)
67
69
  timeseries_category_like: string
@@ -70,6 +72,8 @@ def get_timeseries_groups(
70
72
  Posix regular expression matching against the timeseries group id
71
73
  category_office_id: string
72
74
  Specifies the owning office of the timeseries group category
75
+ group_office_id: string
76
+ Specifies the owning office of the timeseries group
73
77
  Returns
74
78
  -------
75
79
  cwms data type. data.json will return the JSON output and data.df will return a dataframe
@@ -78,9 +82,10 @@ def get_timeseries_groups(
78
82
  endpoint = "timeseries/group"
79
83
  params = {
80
84
  "office": office_id,
85
+ "group-office-id": group_office_id,
81
86
  "include-assigned": include_assigned,
82
87
  "timeseries-category-like": timeseries_category_like,
83
- "timeseries_group_like": timeseries_group_like,
88
+ "timeseries-group-like": timeseries_group_like,
84
89
  "category-office-id": category_office_id,
85
90
  }
86
91
  response = api.get(endpoint=endpoint, params=params, api_version=1)
@@ -165,7 +170,11 @@ def timeseries_group_df_to_json(
165
170
  return json_dict
166
171
 
167
172
 
168
- def store_timeseries_groups(data: JSON, fail_if_exists: Optional[bool] = True) -> None:
173
+ def store_timeseries_groups(
174
+ data: JSON,
175
+ fail_if_exists: Optional[bool] = True,
176
+ ignore_nulls: Optional[bool] = True,
177
+ ) -> None:
169
178
  """
170
179
  Create new TimeSeriesGroup
171
180
  Parameters
@@ -174,6 +183,11 @@ def store_timeseries_groups(data: JSON, fail_if_exists: Optional[bool] = True) -
174
183
  Time Series data to be stored.
175
184
  fail_if_exists: Boolean Defualt = True
176
185
  Create will fail if provided ID already exists.
186
+ ignore_nulls: Boolean Default = True
187
+ Ignore null values in the request body. If fail_if_exists is False
188
+ and ignore_nulls is False, an existing group's description or
189
+ assigned time series list may be replaced with null/empty values
190
+ from the request body.
177
191
 
178
192
  Returns
179
193
  -------
@@ -184,7 +198,7 @@ def store_timeseries_groups(data: JSON, fail_if_exists: Optional[bool] = True) -
184
198
  raise ValueError("Cannot store a standard text without timeseries group JSON")
185
199
 
186
200
  endpoint = "timeseries/group"
187
- params = {"fail-if-exists": fail_if_exists}
201
+ params = {"fail-if-exists": fail_if_exists, "ignore-nulls": ignore_nulls}
188
202
 
189
203
  return api.post(endpoint, data, params, api_version=1)
190
204
 
@@ -227,7 +241,12 @@ def update_timeseries_groups(
227
241
  api.patch(endpoint=endpoint, data=data, params=params, api_version=1)
228
242
 
229
243
 
230
- def delete_timeseries_group(group_id: str, category_id: str, office_id: str) -> None:
244
+ def delete_timeseries_group(
245
+ group_id: str,
246
+ category_id: str,
247
+ office_id: str,
248
+ cascade_delete: Optional[bool] = False,
249
+ ) -> None:
231
250
  """Deletes requested time series group
232
251
 
233
252
  Parameters
@@ -238,6 +257,8 @@ def delete_timeseries_group(group_id: str, category_id: str, office_id: str) ->
238
257
  Specifies the time series category of the time series group to be deleted
239
258
  office_id: string
240
259
  Specifies the owning office of the time series group to be deleted
260
+ cascade_delete: Boolean Default = False
261
+ Specifies whether to unassign time series in this group before deleting.
241
262
 
242
263
  Returns
243
264
  -------
@@ -248,6 +269,7 @@ def delete_timeseries_group(group_id: str, category_id: str, office_id: str) ->
248
269
  params = {
249
270
  "office": office_id,
250
271
  "category-id": category_id,
272
+ "cascade-delete": cascade_delete,
251
273
  }
252
274
 
253
275
  return api.delete(endpoint, params=params, api_version=1)
@@ -2,7 +2,8 @@
2
2
  name = "cwms-python"
3
3
  repository = "https://github.com/HydrologicEngineeringCenter/cwms-python"
4
4
 
5
- version = "1.0.7"
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.1.3"
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 = "^2.31.0"
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 = "^24.2.0"
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 = "^8.1.1"
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