cwms-python 0.8.0__tar.gz → 1.0.1__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 (40) hide show
  1. {cwms_python-0.8.0 → cwms_python-1.0.1}/PKG-INFO +4 -2
  2. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/api.py +105 -38
  3. cwms_python-1.0.1/cwms/catalog/blobs.py +148 -0
  4. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/catalog/catalog.py +35 -1
  5. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/levels/location_levels.py +34 -5
  6. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/locations/location_groups.py +85 -1
  7. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/locations/physical_locations.py +7 -3
  8. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/projects/water_supply/accounting.py +3 -1
  9. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/ratings/ratings.py +219 -1
  10. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/ratings/ratings_spec.py +16 -4
  11. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/timeseries/timeseries.py +289 -17
  12. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/timeseries/timeseries_group.py +1 -1
  13. {cwms_python-0.8.0 → cwms_python-1.0.1}/pyproject.toml +1 -1
  14. cwms_python-0.8.0/cwms/catalog/blobs.py +0 -99
  15. {cwms_python-0.8.0 → cwms_python-1.0.1}/LICENSE +0 -0
  16. {cwms_python-0.8.0 → cwms_python-1.0.1}/README.md +0 -0
  17. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/__init__.py +0 -0
  18. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/catalog/clobs.py +0 -0
  19. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/cwms_types.py +0 -0
  20. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/forecast/forecast_instance.py +0 -0
  21. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/forecast/forecast_spec.py +0 -0
  22. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/levels/specified_levels.py +0 -0
  23. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/locations/gate_changes.py +0 -0
  24. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/measurements/measurements.py +0 -0
  25. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/outlets/outlets.py +0 -0
  26. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/outlets/virtual_outlets.py +0 -0
  27. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/projects/project_lock_rights.py +0 -0
  28. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/projects/project_locks.py +0 -0
  29. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/projects/projects.py +0 -0
  30. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/ratings/ratings_template.py +0 -0
  31. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/standard_text/standard_text.py +0 -0
  32. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/timeseries/timeseries_bin.py +0 -0
  33. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/timeseries/timeseries_identifier.py +0 -0
  34. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/timeseries/timeseries_profile.py +0 -0
  35. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/timeseries/timeseries_profile_instance.py +0 -0
  36. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/timeseries/timeseries_profile_parser.py +0 -0
  37. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/timeseries/timeseries_txt.py +0 -0
  38. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/turbines/turbines.py +0 -0
  39. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/utils/__init__.py +0 -0
  40. {cwms_python-0.8.0 → cwms_python-1.0.1}/cwms/utils/checks.py +0 -0
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: cwms-python
3
- Version: 0.8.0
3
+ Version: 1.0.1
4
4
  Summary: Corps water management systems (CWMS) REST API for Data Retrieval of USACE water data
5
5
  License: LICENSE
6
+ License-File: LICENSE
6
7
  Keywords: USACE,water data,CWMS
7
8
  Author: Eric Novotny
8
9
  Author-email: eric.v.novotny@usace.army.mil
@@ -14,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.10
14
15
  Classifier: Programming Language :: Python :: 3.11
15
16
  Classifier: Programming Language :: Python :: 3.12
16
17
  Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
17
19
  Requires-Dist: pandas (>=2.1.3,<3.0.0)
18
20
  Requires-Dist: requests (>=2.31.0,<3.0.0)
19
21
  Requires-Dist: requests-toolbelt (>=1.0.0,<2.0.0)
@@ -29,6 +29,7 @@ the error.
29
29
  import base64
30
30
  import json
31
31
  import logging
32
+ from http import HTTPStatus
32
33
  from json import JSONDecodeError
33
34
  from typing import Any, Optional, cast
34
35
 
@@ -72,9 +73,9 @@ class InvalidVersion(Exception):
72
73
  class ApiError(Exception):
73
74
  """CWMS Data Api Error.
74
75
 
75
- This class is a light wrapper around a `requests.Response` object. Its primary purpose
76
- is to generate an error message that includes the request URL and provide additional
77
- information to the user to help them resolve the error.
76
+ Light wrapper around a response-like object (e.g., requests.Response or a
77
+ test stub with url, status_code, reason, and content attributes). Produces
78
+ a concise, single-line error message with an optional hint.
78
79
  """
79
80
 
80
81
  def __init__(self, response: Response):
@@ -91,23 +92,37 @@ class ApiError(Exception):
91
92
  message += "."
92
93
 
93
94
  # Add additional context to help the user resolve the issue.
94
- if hint := self.hint():
95
+ hint = self.hint()
96
+ if hint:
95
97
  message += f" {hint}"
96
98
 
97
- if content := self.response.content:
98
- message += f" {content.decode('utf8')}"
99
+ # Optional content (decoded if bytes)
100
+ content = getattr(self.response, "content", None)
101
+ if content:
102
+ if isinstance(content, bytes):
103
+ try:
104
+ text = content.decode("utf-8", errors="replace")
105
+ except Exception:
106
+ text = repr(content)
107
+ else:
108
+ text = str(content)
109
+ message += f" {text}"
99
110
 
100
111
  return message
101
112
 
102
113
  def hint(self) -> str:
103
- """Return a message with additional information on how to resolve the error."""
114
+ """Return a short hint based on HTTP status code."""
115
+ status = getattr(self.response, "status_code", None)
104
116
 
105
- if self.response.status_code == 400:
117
+ if status == 429:
118
+ return "Too many requests made."
119
+ if status == 400:
106
120
  return "Check that your parameters are correct."
107
- elif self.response.status_code == 404:
121
+ if status == 404:
108
122
  return "May be the result of an empty query."
109
- else:
110
- return ""
123
+
124
+ # No hint for other codes
125
+ return ""
111
126
 
112
127
 
113
128
  def init_session(
@@ -132,6 +147,8 @@ def init_session(
132
147
 
133
148
  global SESSION
134
149
  if api_root:
150
+ # Ensure the API_ROOT ends with a single slash
151
+ api_root = api_root.rstrip("/") + "/"
135
152
  logging.debug(f"Initializing root URL: api_root={api_root}")
136
153
  SESSION = sessions.BaseUrlSession(base_url=api_root)
137
154
  adapter = adapters.HTTPAdapter(
@@ -213,6 +230,33 @@ def get_xml(
213
230
  return get(endpoint=endpoint, params=params, api_version=api_version)
214
231
 
215
232
 
233
+ def _process_response(response: Response) -> Any:
234
+ try:
235
+ # Avoid case sensitivity issues with the content type header
236
+ content_type = response.headers.get("Content-Type", "").lower()
237
+ # Most CDA content is JSON
238
+ if "application/json" in content_type or not content_type:
239
+ return cast(JSON, response.json())
240
+ # Use automatic charset detection with .text
241
+ if "text/plain" in content_type or "text/" in content_type:
242
+ return response.text
243
+ if content_type.startswith("image/"):
244
+ return base64.b64encode(response.content).decode("utf-8")
245
+ # Handle excel content types
246
+ if content_type in [
247
+ "application/vnd.ms-excel",
248
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
249
+ ]:
250
+ return response.content
251
+ # Fallback for remaining content types
252
+ return response.content.decode("utf-8")
253
+ except JSONDecodeError as error:
254
+ logging.error(
255
+ f"Error decoding CDA response as JSON: {error} on line {error.lineno}\n\tFalling back to text"
256
+ )
257
+ return response.text
258
+
259
+
216
260
  def get(
217
261
  endpoint: str,
218
262
  params: Optional[RequestParams] = None,
@@ -241,24 +285,7 @@ def get(
241
285
  if not response.ok:
242
286
  logging.error(f"CDA Error: response={response}")
243
287
  raise ApiError(response)
244
- try:
245
- # Avoid case sensitivity issues with the content type header
246
- content_type = response.headers.get("Content-Type", "").lower()
247
- # Most CDA content is JSON
248
- if "application/json" in content_type or not content_type:
249
- return cast(JSON, response.json())
250
- # Use automatic charset detection with .text
251
- if "text/plain" in content_type or "text/" in content_type:
252
- return response.text
253
- if content_type.startswith("image/"):
254
- return base64.b64encode(response.content).decode("utf-8")
255
- # Fallback for remaining content types
256
- return response.content.decode("utf-8")
257
- except JSONDecodeError as error:
258
- logging.error(
259
- f"Error decoding CDA response as JSON: {error} on line {error.lineno}\n\tFalling back to text"
260
- )
261
- return response.text
288
+ return _process_response(response)
262
289
 
263
290
 
264
291
  def get_with_paging(
@@ -301,6 +328,25 @@ def get_with_paging(
301
328
  return response
302
329
 
303
330
 
331
+ def _post_function(
332
+ endpoint: str,
333
+ data: Any,
334
+ params: Optional[RequestParams] = None,
335
+ *,
336
+ api_version: int = API_VERSION,
337
+ ) -> Any:
338
+
339
+ # post requires different headers than get for
340
+ headers = {"accept": "*/*", "Content-Type": api_version_text(api_version)}
341
+ if isinstance(data, dict) or isinstance(data, list):
342
+ data = json.dumps(data)
343
+ with SESSION.post(endpoint, params=params, headers=headers, data=data) as response:
344
+ if not response.ok:
345
+ logging.error(f"CDA Error: response={response}")
346
+ raise ApiError(response)
347
+ return response
348
+
349
+
304
350
  def post(
305
351
  endpoint: str,
306
352
  data: Any,
@@ -320,22 +366,43 @@ def post(
320
366
  the default API_VERSION will be used.
321
367
 
322
368
  Returns:
323
- The deserialized JSON response data.
369
+ None
324
370
 
325
371
  Raises:
326
372
  ApiError: If an error response is return by the API.
327
373
  """
374
+ _post_function(endpoint=endpoint, data=data, params=params, api_version=api_version)
328
375
 
329
- # post requires different headers than get for
330
- headers = {"accept": "*/*", "Content-Type": api_version_text(api_version)}
331
376
 
332
- if isinstance(data, dict) or isinstance(data, list):
333
- data = json.dumps(data)
377
+ def post_with_returned_data(
378
+ endpoint: str,
379
+ data: Any,
380
+ params: Optional[RequestParams] = None,
381
+ *,
382
+ api_version: int = API_VERSION,
383
+ ) -> Any:
384
+ """Make a POST request to the CWMS Data API.
334
385
 
335
- with SESSION.post(endpoint, params=params, headers=headers, data=data) as response:
336
- if not response.ok:
337
- logging.error(f"CDA Error: response={response}")
338
- raise ApiError(response)
386
+ Args:
387
+ endpoint: The CDA endpoint for the record type.
388
+ data: A dict containing the new record data. Must be JSON-serializable.
389
+ params (optional): Query parameters for the request.
390
+
391
+ Keyword Args:
392
+ api_version (optional): The CDA version to use for the request. If not specified,
393
+ the default API_VERSION will be used.
394
+
395
+ Returns:
396
+ The response data.
397
+
398
+ Raises:
399
+ ApiError: If an error response is return by the API.
400
+ """
401
+
402
+ response = _post_function(
403
+ endpoint=endpoint, data=data, params=params, api_version=api_version
404
+ )
405
+ return _process_response(response)
339
406
 
340
407
 
341
408
  def patch(
@@ -0,0 +1,148 @@
1
+ import base64
2
+ from typing import Optional
3
+
4
+ import cwms.api as api
5
+ from cwms.cwms_types import JSON, Data
6
+ from cwms.utils.checks import is_base64
7
+
8
+ STORE_DICT = """data = {
9
+ "office-id": "SWT",
10
+ "id": "MYFILE_OR_BLOB_ID.TXT",
11
+ "description": "Your description here",
12
+ "media-type-id": "text/plain",
13
+ "value": "STRING of content or BASE64_ENCODED_STRING"
14
+ }
15
+ """
16
+
17
+
18
+ def get_blob(blob_id: str, office_id: str) -> str:
19
+ """Get a single BLOB (Binary Large Object).
20
+
21
+ Parameters
22
+ blob_id: string
23
+ Specifies the id of the blob. ALL blob ids are UPPERCASE.
24
+ office_id: string
25
+ Specifies the office of the blob.
26
+
27
+
28
+ Returns
29
+ str: the value returned based on the content-type it was stored with as a string
30
+ """
31
+
32
+ endpoint = f"blobs/{blob_id}"
33
+ params = {"office": office_id}
34
+ response = api.get(endpoint, params, api_version=1)
35
+ return str(response)
36
+
37
+
38
+ def get_blobs(
39
+ office_id: Optional[str] = None,
40
+ page_size: Optional[int] = 100,
41
+ blob_id_like: Optional[str] = None,
42
+ ) -> Data:
43
+ """Get a subset of Blobs
44
+
45
+ Parameters:
46
+ office_id: Optional[string]
47
+ Specifies the office of the blob.
48
+ page_sie: Optional[Integer]
49
+ How many entries per page returned. Default 100.
50
+ blob_id_like: Optional[string]
51
+ Posix regular expression matching against the clob id
52
+
53
+ Returns:
54
+ cwms data type. data.json will return the JSON output and data.df will return a dataframe
55
+ """
56
+
57
+ endpoint = "blobs"
58
+ params = {"office": office_id, "page-size": page_size, "like": blob_id_like}
59
+
60
+ response = api.get(endpoint, params, api_version=2)
61
+ return Data(response, selector="blobs")
62
+
63
+
64
+ def store_blobs(data: JSON, fail_if_exists: Optional[bool] = True) -> None:
65
+ """Create New Blob
66
+
67
+ Parameters:
68
+ **Note**: The "id" field is automatically cast to uppercase.
69
+
70
+ Data: JSON dictionary
71
+ JSON containing information of Blob to be updated.
72
+
73
+ fail_if_exists: Boolean
74
+ Create will fail if the provided ID already exists. Default: True
75
+
76
+ Returns:
77
+ None
78
+ """
79
+
80
+ if not isinstance(data, dict):
81
+ raise ValueError(
82
+ f"Cannot store a Blob without a JSON data dictionary:\n{STORE_DICT}"
83
+ )
84
+
85
+ # Encode value if it's not already Base64-encoded
86
+ if "value" in data and not is_base64(data["value"]):
87
+ # Encode to bytes, then Base64, then decode to string for storing
88
+ data["value"] = base64.b64encode(data["value"].encode("utf-8")).decode("utf-8")
89
+
90
+ endpoint = "blobs"
91
+ params = {"fail-if-exists": fail_if_exists}
92
+ return api.post(endpoint, data, params, api_version=1)
93
+
94
+
95
+ def delete_blob(blob_id: str, office_id: str) -> None:
96
+ """Delete a single BLOB.
97
+
98
+ Parameters
99
+ ----------
100
+ blob_id: string
101
+ Specifies the id of the blob. ALL blob ids are UPPERCASE.
102
+ office_id: string
103
+ Specifies the office of the blob.
104
+
105
+ Returns
106
+ -------
107
+ None
108
+ """
109
+
110
+ endpoint = f"blobs/{blob_id}"
111
+ params = {"office": office_id}
112
+ return api.delete(endpoint, params, api_version=1)
113
+
114
+
115
+ def update_blob(data: JSON, fail_if_not_exists: Optional[bool] = True) -> None:
116
+ """Update Existing Blob
117
+
118
+ Parameters:
119
+ **Note**: The "id" field is automatically cast to uppercase.
120
+
121
+ Data: JSON dictionary
122
+ JSON containing information of Blob to be updated.
123
+
124
+ fail_if_not_exists: Boolean
125
+ Update will fail if the provided ID does not already exist. Default: True
126
+
127
+ Returns:
128
+ None
129
+ """
130
+
131
+ if not data:
132
+ raise ValueError(
133
+ f"Cannot update a Blob without a JSON data dictionary:\n{STORE_DICT}"
134
+ )
135
+
136
+ if "id" not in data:
137
+ raise ValueError(f"Cannot update a Blob without an 'id' field:\n{STORE_DICT}")
138
+
139
+ # Encode value if it's not already Base64-encoded
140
+ if "value" in data and not is_base64(data["value"]):
141
+ # Encode to bytes, then Base64, then decode to string for storing
142
+ data["value"] = base64.b64encode(data["value"].encode("utf-8")).decode("utf-8")
143
+
144
+ blob_id = data.get("id", "").upper()
145
+
146
+ endpoint = f"blobs/{blob_id}"
147
+ params = {"fail-if-not-exists": fail_if_not_exists}
148
+ return api.patch(endpoint, data, params, api_version=1)
@@ -1,4 +1,7 @@
1
- from typing import Optional
1
+ from datetime import datetime
2
+ from typing import Optional, Tuple
3
+
4
+ import pandas as pd
2
5
 
3
6
  import cwms.api as api
4
7
  from cwms.cwms_types import Data
@@ -130,3 +133,34 @@ def get_timeseries_catalog(
130
133
 
131
134
  response = api.get(endpoint=endpoint, params=params, api_version=2)
132
135
  return Data(response, selector="entries")
136
+
137
+
138
+ def get_ts_extents(ts_id: str, office_id: str) -> Tuple[datetime, datetime, datetime]:
139
+ """Retrieves earliest extent, latest extent, and last update via cwms.get_timeseries_catalog
140
+
141
+ Parameters
142
+ ----------
143
+ ts_id: string
144
+ Timseries id to query.
145
+ office_id: string
146
+ The owning office of the timeseries group.
147
+
148
+ Returns
149
+ -------
150
+ tuple of datetime objects (earliest_time, latest_time, last_update)
151
+ """
152
+ cwms_cat = get_timeseries_catalog(
153
+ office_id=office_id,
154
+ like=ts_id,
155
+ timeseries_group_like=None,
156
+ page_size=500,
157
+ include_extents=True,
158
+ ).df
159
+
160
+ times = cwms_cat[cwms_cat.name == ts_id].extents.values[0][0]
161
+
162
+ earliest_time = pd.to_datetime(times["earliest-time"])
163
+ latest_time = pd.to_datetime(times["latest-time"])
164
+ last_update = pd.to_datetime(times["last-update"])
165
+
166
+ return earliest_time, latest_time, last_update
@@ -13,7 +13,7 @@ from cwms.cwms_types import JSON, Data
13
13
 
14
14
 
15
15
  def get_location_levels(
16
- level_id_mask: str = "*",
16
+ level_id_mask: Optional[str] = None,
17
17
  office_id: Optional[str] = None,
18
18
  unit: Optional[str] = None,
19
19
  datum: Optional[str] = None,
@@ -58,13 +58,13 @@ def get_location_levels(
58
58
  "level-id-mask": level_id_mask,
59
59
  "unit": unit,
60
60
  "datum": datum,
61
- "begin": begin.isoformat() if begin else "",
62
- "end": end.isoformat() if end else "",
61
+ "begin": begin.isoformat() if begin else None,
62
+ "end": end.isoformat() if end else None,
63
63
  "page": page,
64
64
  "page-size": page_size,
65
65
  }
66
- response = api.get(endpoint, params)
67
- return Data(response)
66
+ response = api.get(endpoint=endpoint, params=params)
67
+ return Data(json=response, selector="levels")
68
68
 
69
69
 
70
70
  def get_location_level(
@@ -169,6 +169,35 @@ def delete_location_level(
169
169
  return api.delete(endpoint, params)
170
170
 
171
171
 
172
+ def update_location_level(
173
+ data: JSON, level_id: str, effective_date: Optional[datetime] = None
174
+ ) -> None:
175
+ """
176
+ Parameters
177
+ ----------
178
+ data : dict
179
+ The JSON data dictionary containing the updated location level information.
180
+ level_id : str
181
+ The ID of the location level to be updated.
182
+ effective_date : datetime, optional
183
+ The effective date of the location level to be updated.
184
+ If the datetime has a timezone it will be used, otherwise it is assumed to be in UTC.
185
+
186
+ """
187
+ if data is None:
188
+ raise ValueError(
189
+ "Cannot update a location level without a JSON data dictionary"
190
+ )
191
+ if level_id is None:
192
+ raise ValueError("Cannot update a location level without an id")
193
+ endpoint = f"levels/{level_id}"
194
+
195
+ params = {
196
+ "effective-date": (effective_date.isoformat() if effective_date else None),
197
+ }
198
+ return api.patch(endpoint, data, params)
199
+
200
+
172
201
  def get_level_as_timeseries(
173
202
  location_level_id: str,
174
203
  office_id: str,
@@ -85,6 +85,84 @@ def get_location_groups(
85
85
  return Data(response)
86
86
 
87
87
 
88
+ def location_group_df_to_json(
89
+ data: pd.DataFrame,
90
+ group_id: str,
91
+ group_office_id: str,
92
+ category_office_id: str,
93
+ category_id: str,
94
+ ) -> JSON:
95
+ """
96
+ Converts a dataframe to a json dictionary in the correct format.
97
+
98
+ Parameters
99
+ ----------
100
+ data: pd.DataFrame
101
+ Dataframe containing timeseries information.
102
+ group_id: str
103
+ The group ID for the timeseries.
104
+ office_id: str
105
+ The ID of the office associated with the specified timeseries.
106
+ category_id: str
107
+ The ID of the category associated with the group
108
+
109
+ Returns
110
+ -------
111
+ JSON
112
+ JSON dictionary of the timeseries data.
113
+ """
114
+ df = data.copy()
115
+ required_columns = ["office-id", "location-id"]
116
+ optional_columns = ["alias-id", "attribute", "ref-location-id"]
117
+ for column in required_columns:
118
+ if column not in df.columns:
119
+ raise TypeError(
120
+ f"{column} is a required column in data when posting as a dataframe"
121
+ )
122
+
123
+ if df[required_columns].isnull().any().any():
124
+ raise ValueError(
125
+ f"Null/NaN values found in required columns: {required_columns}. "
126
+ )
127
+
128
+ # Fill optional columns with default values if missing
129
+ if "alias-id" not in df.columns:
130
+ df["alias-id"] = None
131
+ if "attribute" not in df.columns:
132
+ df["attribute"] = 0
133
+
134
+ # Replace NaN with None for optional columns
135
+ for column in optional_columns:
136
+ if column in df.columns:
137
+ df[column] = df[column].where(pd.notnull(df[column]), None)
138
+
139
+ # Build the list of time-series entries
140
+ assigned_locs = df.apply(
141
+ lambda entry: {
142
+ "office-id": entry["office-id"],
143
+ "location-id": entry["location-id"],
144
+ "alias-id": entry["alias-id"],
145
+ "attribute": entry["attribute"],
146
+ **(
147
+ {"ref-location-id": entry["ref-location-id"]}
148
+ if "ref-location-id" in entry and pd.notna(entry["ref-location-id"])
149
+ else {}
150
+ ),
151
+ },
152
+ axis=1,
153
+ ).tolist()
154
+
155
+ # Construct the final JSON dictionary
156
+ json_dict = {
157
+ "office-id": group_office_id,
158
+ "id": group_id,
159
+ "location-category": {"office-id": category_office_id, "id": category_id},
160
+ "assigned-locations": assigned_locs,
161
+ }
162
+
163
+ return json_dict
164
+
165
+
88
166
  def store_location_groups(data: JSON) -> None:
89
167
  """
90
168
  Create new Location Group
@@ -140,7 +218,12 @@ def update_location_group(
140
218
  api.patch(endpoint=endpoint, data=data, params=params, api_version=1)
141
219
 
142
220
 
143
- def delete_location_group(group_id: str, category_id: str, office_id: str) -> None:
221
+ def delete_location_group(
222
+ group_id: str,
223
+ category_id: str,
224
+ office_id: str,
225
+ cascade_delete: Optional[bool] = False,
226
+ ) -> None:
144
227
  """Deletes requested time series group
145
228
 
146
229
  Parameters
@@ -161,6 +244,7 @@ def delete_location_group(group_id: str, category_id: str, office_id: str) -> No
161
244
  params = {
162
245
  "office": office_id,
163
246
  "category-id": category_id,
247
+ "cascade-delete": cascade_delete,
164
248
  }
165
249
 
166
250
  return api.delete(endpoint, params=params, api_version=1)
@@ -128,7 +128,7 @@ def delete_location(
128
128
  return api.delete(endpoint, params=params)
129
129
 
130
130
 
131
- def store_location(data: JSON) -> None:
131
+ def store_location(data: JSON, fail_if_exists: bool = True) -> None:
132
132
  """
133
133
  This method is used to store and update location's data through CWMS Data API.
134
134
 
@@ -137,6 +137,10 @@ def store_location(data: JSON) -> None:
137
137
  data : dict
138
138
  A dictionary representing the JSON data to be stored.
139
139
  If the `data` value is None, a `ValueError` will be raised.
140
+ fail_if_exists : bool, optional
141
+ A boolean value indicating whether to fail if the outlet already exists.
142
+ Default is True.
143
+
140
144
 
141
145
  Returns
142
146
  -------
@@ -148,8 +152,8 @@ def store_location(data: JSON) -> None:
148
152
  raise ValueError("Storing location requires a JSON data dictionary")
149
153
 
150
154
  endpoint = "locations"
151
-
152
- return api.post(endpoint, data)
155
+ params = {"fail-if-exists": fail_if_exists}
156
+ return api.post(endpoint, data, params=params)
153
157
 
154
158
 
155
159
  def update_location(location_id: str, data: JSON) -> None:
@@ -2,6 +2,8 @@
2
2
  # United States Army Corps of Engineers - Hydrologic Engineering Center (USACE/HEC)
3
3
  # All Rights Reserved. USACE PROPRIETARY/CONFIDENTIAL.
4
4
  # Source may not be released without written approval from HEC
5
+ from typing import Union
6
+
5
7
  import cwms.api as api
6
8
  from cwms.cwms_types import JSON, Data
7
9
 
@@ -74,7 +76,7 @@ def get_pump_accounting(
74
76
 
75
77
  endpoint = f"projects/{office_id}/{project_id}/water-user/{water_user}/contracts/{contract_name}/accounting"
76
78
 
77
- params: dict[str, str | int] = {
79
+ params: dict[str, Union[str, int]] = {
78
80
  "start": start,
79
81
  "end": end,
80
82
  "timezone": timezone,