cwms-python 1.0.3__tar.gz → 1.0.8__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-1.0.3 → cwms_python-1.0.8}/PKG-INFO +19 -1
  2. {cwms_python-1.0.3 → cwms_python-1.0.8}/README.md +18 -0
  3. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/api.py +80 -26
  4. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/catalog/blobs.py +25 -8
  5. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/catalog/catalog.py +6 -2
  6. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/catalog/clobs.py +44 -23
  7. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/timeseries/timeseries.py +89 -33
  8. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/timeseries/timeseries_group.py +30 -8
  9. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/users/users.py +51 -3
  10. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/utils/checks.py +12 -0
  11. {cwms_python-1.0.3 → cwms_python-1.0.8}/pyproject.toml +1 -2
  12. {cwms_python-1.0.3 → cwms_python-1.0.8}/LICENSE +0 -0
  13. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/__init__.py +0 -0
  14. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/cwms_types.py +0 -0
  15. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/forecast/forecast_instance.py +0 -0
  16. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/forecast/forecast_spec.py +0 -0
  17. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/levels/location_levels.py +0 -0
  18. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/levels/specified_levels.py +0 -0
  19. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/locations/gate_changes.py +0 -0
  20. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/locations/location_groups.py +0 -0
  21. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/locations/physical_locations.py +0 -0
  22. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/measurements/measurements.py +0 -0
  23. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/outlets/outlets.py +0 -0
  24. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/outlets/virtual_outlets.py +0 -0
  25. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/projects/project_lock_rights.py +0 -0
  26. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/projects/project_locks.py +0 -0
  27. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/projects/projects.py +0 -0
  28. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/projects/water_supply/accounting.py +0 -0
  29. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/ratings/ratings.py +0 -0
  30. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/ratings/ratings_spec.py +0 -0
  31. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/ratings/ratings_template.py +0 -0
  32. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/standard_text/standard_text.py +0 -0
  33. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/timeseries/timeseries_bin.py +0 -0
  34. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/timeseries/timeseries_identifier.py +0 -0
  35. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/timeseries/timeseries_profile.py +0 -0
  36. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/timeseries/timeseries_profile_instance.py +0 -0
  37. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/timeseries/timeseries_profile_parser.py +0 -0
  38. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/timeseries/timeseries_txt.py +0 -0
  39. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/turbines/turbines.py +0 -0
  40. {cwms_python-1.0.3 → cwms_python-1.0.8}/cwms/utils/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cwms-python
3
- Version: 1.0.3
3
+ Version: 1.0.8
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
@@ -44,6 +44,24 @@ Then import the package:
44
44
  import cwms
45
45
  ```
46
46
 
47
+ ### Authentication
48
+
49
+ `cwms.init_session()` supports both CDA API keys and Keycloak access tokens.
50
+ Use `api_key=` for the headless CDA API key flow, or `token=` for an OIDC access
51
+ token such as one saved by [`cwms-cli login`]().
52
+
53
+ ```python
54
+ import cwms
55
+
56
+ cwms.init_session(
57
+ api_root="https://cwms-data.usace.army.mil/cwms-data/",
58
+ token="ACCESS_TOKEN",
59
+ )
60
+ ```
61
+
62
+ If both `token` and `api_key` are provided, `cwms-python` will use the token
63
+ and log a warning.
64
+
47
65
  ## Getting Started
48
66
 
49
67
  ```python
@@ -20,6 +20,24 @@ Then import the package:
20
20
  import cwms
21
21
  ```
22
22
 
23
+ ### Authentication
24
+
25
+ `cwms.init_session()` supports both CDA API keys and Keycloak access tokens.
26
+ Use `api_key=` for the headless CDA API key flow, or `token=` for an OIDC access
27
+ token such as one saved by [`cwms-cli login`]().
28
+
29
+ ```python
30
+ import cwms
31
+
32
+ cwms.init_session(
33
+ api_root="https://cwms-data.usace.army.mil/cwms-data/",
34
+ token="ACCESS_TOKEN",
35
+ )
36
+ ```
37
+
38
+ If both `token` and `api_key` are provided, `cwms-python` will use the token
39
+ and log a warning.
40
+
23
41
  ## Getting Started
24
42
 
25
43
  ```python
@@ -5,9 +5,9 @@ functions should be used internally to interact with the API. The user should no
5
5
  interact with these directly.
6
6
 
7
7
  The `init_session()` function can be used to specify an alternative root URL, and to
8
- provide an authentication key (if required). If `init_session()` is not called, the
9
- default root URL (see `API_ROOT` below) will be used, and no authentication keys will be
10
- included when making API calls.
8
+ provide an authentication key or bearer token (if required). If `init_session()` is not
9
+ called, the default root URL (see `API_ROOT` below) will be used, and no authentication
10
+ headers will be included when making API calls.
11
11
 
12
12
  Example: Initializing a session
13
13
 
@@ -17,6 +17,9 @@ Example: Initializing a session
17
17
  # Specify an alternate URL and an auth key
18
18
  init_session(api_root="https://example.com/cwms-data", api_key="API_KEY")
19
19
 
20
+ # Specify an alternate URL and an OIDC bearer token
21
+ init_session(api_root="https://example.com/cwms-data", token="ACCESS_TOKEN")
22
+
20
23
  Functions which make API calls that _may_ return a JSON response will return a `dict`
21
24
  containing the deserialized data. If the API response does not include data, an empty
22
25
  `dict` will be returned.
@@ -34,6 +37,7 @@ from json import JSONDecodeError
34
37
  from typing import Any, Optional, cast
35
38
 
36
39
  from requests import Response, adapters
40
+ from requests.exceptions import RetryError as RequestsRetryError
37
41
  from requests_toolbelt import sessions # type: ignore
38
42
  from requests_toolbelt.sessions import BaseUrlSession # type: ignore
39
43
  from urllib3.util.retry import Retry
@@ -52,12 +56,12 @@ retry_strategy = Retry(
52
56
  status_forcelist=[
53
57
  403,
54
58
  429,
55
- 500,
56
59
  502,
57
60
  503,
58
61
  504,
59
62
  ], # Example: also retry on these HTTP status codes
60
63
  allowed_methods=["GET", "PUT", "POST", "PATCH", "DELETE"], # Methods to retry
64
+ raise_on_status=False,
61
65
  )
62
66
  SESSION = sessions.BaseUrlSession(base_url=API_ROOT)
63
67
  adapter = adapters.HTTPAdapter(
@@ -137,21 +141,46 @@ class PermissionError(ApiError):
137
141
  """Raised when the CDA request is not authorized for the current caller."""
138
142
 
139
143
 
144
+ def _unwrap_retry_error(error: RequestsRetryError) -> Exception:
145
+ """Return the original retry cause when requests wraps it in RetryError."""
146
+
147
+ current: Exception = error
148
+ cause = error.__cause__
149
+ while isinstance(cause, Exception):
150
+ current = cause
151
+ cause = cause.__cause__
152
+
153
+ if current is error and error.args:
154
+ first_arg = error.args[0]
155
+ if isinstance(first_arg, Exception):
156
+ current = first_arg
157
+ reason = getattr(current, "reason", None)
158
+ while isinstance(reason, Exception):
159
+ current = reason
160
+ reason = getattr(current, "reason", None)
161
+
162
+ return current
163
+
164
+
140
165
  def init_session(
141
166
  *,
142
167
  api_root: Optional[str] = None,
143
168
  api_key: Optional[str] = None,
169
+ token: Optional[str] = None,
144
170
  pool_connections: int = 100,
145
171
  ) -> BaseUrlSession:
146
- """Specify a root URL and authentication key for the CWMS Data API.
172
+ """Specify a root URL and authentication credentials for the CWMS Data API.
147
173
 
148
174
  This function can be used to change the root URL used when interacting with the CDA.
149
- All API calls made after this function is called will use the specified URL. If an
150
- authentication key is given it will be included in all future request headers.
175
+ All API calls made after this function is called will use the specified URL. If
176
+ authentication credentials are given they will be included in all future request
177
+ headers.
151
178
 
152
179
  Keyword Args:
153
180
  api_root (optional): The root URL for the CWMS Data API.
154
181
  api_key (optional): An authentication key.
182
+ token (optional): A Keycloak access token. If both token and api_key are
183
+ provided, token is used.
155
184
 
156
185
  Returns:
157
186
  Returns the updated session object.
@@ -169,7 +198,16 @@ def init_session(
169
198
  max_retries=retry_strategy,
170
199
  )
171
200
  SESSION.mount("https://", adapter)
172
- if api_key:
201
+ if token:
202
+ if api_key:
203
+ logging.warning(
204
+ "Both token and api_key were provided to init_session(); using token for Authorization."
205
+ )
206
+ # Ensure we don't provide the bearer text twice
207
+ if token.lower().startswith("bearer "):
208
+ token = token[7:]
209
+ SESSION.headers.update({"Authorization": "Bearer " + token})
210
+ elif api_key:
173
211
  if api_key.startswith("apikey "):
174
212
  api_key = api_key.replace("apikey ", "")
175
213
  SESSION.headers.update({"Authorization": "apikey " + api_key})
@@ -292,11 +330,14 @@ def get(
292
330
  """
293
331
 
294
332
  headers = {"Accept": api_version_text(api_version)}
295
- with SESSION.get(endpoint, params=params, headers=headers) as response:
296
- if not response.ok:
297
- logging.error(f"CDA Error: response={response}")
298
- raise ApiError(response)
299
- return _process_response(response)
333
+ try:
334
+ with SESSION.get(endpoint, params=params, headers=headers) as response:
335
+ if not response.ok:
336
+ logging.error(f"CDA Error: response={response}")
337
+ raise ApiError(response)
338
+ return _process_response(response)
339
+ except RequestsRetryError as error:
340
+ raise _unwrap_retry_error(error) from None
300
341
 
301
342
 
302
343
  def get_with_paging(
@@ -351,11 +392,16 @@ def _post_function(
351
392
  headers = {"accept": "*/*", "Content-Type": api_version_text(api_version)}
352
393
  if isinstance(data, dict) or isinstance(data, list):
353
394
  data = json.dumps(data)
354
- with SESSION.post(endpoint, params=params, headers=headers, data=data) as response:
355
- if not response.ok:
356
- logging.error(f"CDA Error: response={response}")
357
- raise ApiError(response)
358
- return response
395
+ try:
396
+ with SESSION.post(
397
+ endpoint, params=params, headers=headers, data=data
398
+ ) as response:
399
+ if not response.ok:
400
+ logging.error(f"CDA Error: response={response}")
401
+ raise ApiError(response)
402
+ return response
403
+ except RequestsRetryError as error:
404
+ raise _unwrap_retry_error(error) from None
359
405
 
360
406
 
361
407
  def post(
@@ -445,10 +491,15 @@ def patch(
445
491
 
446
492
  if data and isinstance(data, dict) or isinstance(data, list):
447
493
  data = json.dumps(data)
448
- with SESSION.patch(endpoint, params=params, headers=headers, data=data) as response:
449
- if not response.ok:
450
- logging.error(f"CDA Error: response={response}")
451
- raise ApiError(response)
494
+ try:
495
+ with SESSION.patch(
496
+ endpoint, params=params, headers=headers, data=data
497
+ ) as response:
498
+ if not response.ok:
499
+ logging.error(f"CDA Error: response={response}")
500
+ raise ApiError(response)
501
+ except RequestsRetryError as error:
502
+ raise _unwrap_retry_error(error) from None
452
503
 
453
504
 
454
505
  def delete(
@@ -472,7 +523,10 @@ def delete(
472
523
  """
473
524
 
474
525
  headers = {"Accept": api_version_text(api_version)}
475
- with SESSION.delete(endpoint, params=params, headers=headers) as response:
476
- if not response.ok:
477
- logging.error(f"CDA Error: response={response}")
478
- raise ApiError(response)
526
+ try:
527
+ with SESSION.delete(endpoint, params=params, headers=headers) as response:
528
+ if not response.ok:
529
+ logging.error(f"CDA Error: response={response}")
530
+ raise ApiError(response)
531
+ except RequestsRetryError as error:
532
+ raise _unwrap_retry_error(error) from None
@@ -1,9 +1,9 @@
1
1
  import base64
2
- from typing import Optional
2
+ from typing import Any, Optional
3
3
 
4
4
  import cwms.api as api
5
5
  from cwms.cwms_types import JSON, Data
6
- from cwms.utils.checks import is_base64
6
+ from cwms.utils.checks import has_invalid_chars, is_base64
7
7
 
8
8
  STORE_DICT = """data = {
9
9
  "office-id": "SWT",
@@ -14,6 +14,8 @@ STORE_DICT = """data = {
14
14
  }
15
15
  """
16
16
 
17
+ IGNORED_ID = "ignored"
18
+
17
19
 
18
20
  def get_blob(blob_id: str, office_id: str) -> str:
19
21
  """Get a single BLOB (Binary Large Object).
@@ -29,8 +31,13 @@ def get_blob(blob_id: str, office_id: str) -> str:
29
31
  str: the value returned based on the content-type it was stored with as a string
30
32
  """
31
33
 
32
- endpoint = f"blobs/{blob_id}"
33
- params = {"office": office_id}
34
+ params: dict[str, Any] = {}
35
+ if has_invalid_chars(blob_id):
36
+ endpoint = f"blobs/{IGNORED_ID}"
37
+ params["blob-id"] = blob_id
38
+ else:
39
+ endpoint = f"blobs/{blob_id}"
40
+ params["office"] = office_id
34
41
  response = api.get(endpoint, params, api_version=1)
35
42
  return str(response)
36
43
 
@@ -107,8 +114,13 @@ def delete_blob(blob_id: str, office_id: str) -> None:
107
114
  None
108
115
  """
109
116
 
110
- endpoint = f"blobs/{blob_id}"
111
- params = {"office": office_id}
117
+ params: dict[str, Any] = {}
118
+ if has_invalid_chars(blob_id):
119
+ endpoint = f"blobs/{IGNORED_ID}"
120
+ params["blob-id"] = blob_id
121
+ else:
122
+ endpoint = f"blobs/{blob_id}"
123
+ params["office"] = office_id
112
124
  return api.delete(endpoint, params, api_version=1)
113
125
 
114
126
 
@@ -143,6 +155,11 @@ def update_blob(data: JSON, fail_if_not_exists: Optional[bool] = True) -> None:
143
155
 
144
156
  blob_id = data.get("id", "").upper()
145
157
 
146
- endpoint = f"blobs/{blob_id}"
147
- params = {"fail-if-not-exists": fail_if_not_exists}
158
+ params: dict[str, Any] = {}
159
+ if has_invalid_chars(blob_id):
160
+ endpoint = f"blobs/{IGNORED_ID}"
161
+ params["blob-id"] = blob_id
162
+ else:
163
+ endpoint = f"blobs/{blob_id}"
164
+ params["fail-if-not-exists"] = fail_if_not_exists
148
165
  return api.patch(endpoint, data, params, api_version=1)
@@ -67,7 +67,9 @@ def get_locations_catalog(
67
67
  "location-kind-like": location_kind_like,
68
68
  }
69
69
 
70
- response = api.get(endpoint=endpoint, params=params, api_version=2)
70
+ response = api.get_with_paging(
71
+ endpoint=endpoint, selector="entries", params=params, api_version=2
72
+ )
71
73
  return Data(response, selector="entries")
72
74
 
73
75
 
@@ -131,7 +133,9 @@ def get_timeseries_catalog(
131
133
  "include-extents": include_extents,
132
134
  }
133
135
 
134
- response = api.get(endpoint=endpoint, params=params, api_version=2)
136
+ response = api.get_with_paging(
137
+ endpoint=endpoint, selector="entries", params=params, api_version=2
138
+ )
135
139
  return Data(response, selector="entries")
136
140
 
137
141
 
@@ -1,10 +1,21 @@
1
- from typing import Optional
1
+ from typing import Any, Optional
2
2
 
3
3
  import cwms.api as api
4
4
  from cwms.cwms_types import JSON, Data
5
+ from cwms.utils.checks import has_invalid_chars
5
6
 
7
+ STORE_DICT = """data = {
8
+ "office-id": "SWT",
9
+ "id": "CLOB_ID",
10
+ "description": "Your description here",
11
+ "value": "STRING of content"
12
+ }
13
+ """
6
14
 
7
- def get_clob(clob_id: str, office_id: str, clob_id_query: Optional[str] = None) -> Data:
15
+ IGNORED_ID = "ignored"
16
+
17
+
18
+ def get_clob(clob_id: str, office_id: str) -> Data:
8
19
  """Get a single clob.
9
20
 
10
21
  Parameters
@@ -13,16 +24,6 @@ def get_clob(clob_id: str, office_id: str, clob_id_query: Optional[str] = None)
13
24
  Specifies the id of the clob
14
25
  office_id: string
15
26
  Specifies the office of the clob.
16
- clob_id_query: string
17
- If this query parameter is provided the id path parameter is ignored and the
18
- value of the query parameter is used. Note: this query parameter is necessary
19
- for id's that contain '/' or other special characters. Because of abuse even
20
- properly escaped '/' in url paths are blocked. When using this query parameter
21
- a valid path parameter must still be provided for the request to be properly
22
- routed. If your clob id contains '/' you can't specify the clob-id query
23
- parameter and also specify the id path parameter because firewall and/or server
24
- rules will deny the request even though you are specifying this override. "ignored"
25
- is suggested.
26
27
 
27
28
 
28
29
  Returns
@@ -30,11 +31,13 @@ def get_clob(clob_id: str, office_id: str, clob_id_query: Optional[str] = None)
30
31
  cwms data type. data.json will return the JSON output and data.df will return a dataframe
31
32
  """
32
33
 
33
- endpoint = f"clobs/{clob_id}"
34
- params = {
35
- "office": office_id,
36
- "clob-id-query": clob_id_query,
37
- }
34
+ params: dict[str, Any] = {}
35
+ if has_invalid_chars(clob_id):
36
+ endpoint = f"clobs/{IGNORED_ID}"
37
+ params["clob-id"] = clob_id
38
+ else:
39
+ endpoint = f"clobs/{clob_id}"
40
+ params["office"] = office_id
38
41
  response = api.get(endpoint, params)
39
42
  return Data(response)
40
43
 
@@ -90,13 +93,20 @@ def delete_clob(clob_id: str, office_id: str) -> None:
90
93
  None
91
94
  """
92
95
 
93
- endpoint = f"clobs/{clob_id}"
94
- params = {"office": office_id}
96
+ params: dict[str, Any] = {}
97
+ if has_invalid_chars(clob_id):
98
+ endpoint = f"clobs/{IGNORED_ID}"
99
+ params["clob-id"] = clob_id
100
+ else:
101
+ endpoint = f"clobs/{clob_id}"
102
+ params["office"] = office_id
95
103
 
96
104
  return api.delete(endpoint, params=params, api_version=1)
97
105
 
98
106
 
99
- def update_clob(data: JSON, clob_id: str, ignore_nulls: Optional[bool] = True) -> None:
107
+ def update_clob(
108
+ data: JSON, clob_id: Optional[str] = None, ignore_nulls: Optional[bool] = True
109
+ ) -> None:
100
110
  """Updates clob
101
111
 
102
112
  Parameters
@@ -110,7 +120,7 @@ def update_clob(data: JSON, clob_id: str, ignore_nulls: Optional[bool] = True) -
110
120
  "value": "string"
111
121
  }
112
122
  clob_id: string
113
- Specifies the id of the clob to be deleted
123
+ Specifies the id of the clob to be deleted. Unused if "id" is present in JSON data.
114
124
  ignore_nulls: Boolean
115
125
  If true, null and empty fields in the provided clob will be ignored and the existing value of those fields left in place. Default: true
116
126
 
@@ -122,8 +132,19 @@ def update_clob(data: JSON, clob_id: str, ignore_nulls: Optional[bool] = True) -
122
132
  if not isinstance(data, dict):
123
133
  raise ValueError("Cannot store a Clob without a JSON data dictionary")
124
134
 
125
- endpoint = f"clobs/{clob_id}"
126
- params = {"ignore-nulls": ignore_nulls}
135
+ if "id" in data:
136
+ clob_id = data.get("id", "").upper()
137
+
138
+ if clob_id is None:
139
+ raise ValueError(f"Cannot update a Clob without an 'id' field:\n{STORE_DICT}")
140
+
141
+ params: dict[str, Any] = {}
142
+ if has_invalid_chars(clob_id):
143
+ endpoint = f"clobs/{IGNORED_ID}"
144
+ params["clob-id"] = clob_id
145
+ else:
146
+ endpoint = f"clobs/{clob_id}"
147
+ params["ignore-nulls"] = ignore_nulls
127
148
 
128
149
  return api.patch(endpoint, data, params, api_version=1)
129
150
 
@@ -154,6 +154,23 @@ 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
+ if i == attempts - 1:
170
+ raise
171
+ logging.warning(f"chunk attempt {i + 1}/{attempts} failed: {e}")
172
+
173
+
157
174
  def fetch_timeseries_chunks(
158
175
  chunks: List[Tuple[datetime, datetime]],
159
176
  params: Dict[str, Any],
@@ -161,14 +178,13 @@ def fetch_timeseries_chunks(
161
178
  endpoint: str,
162
179
  max_workers: int,
163
180
  ) -> List[Data]:
164
- # Initialize an empty list to store results
165
- results = []
181
+ results: List[Data] = []
182
+ errors: List[str] = []
166
183
 
167
- # Create a ThreadPoolExecutor to manage multithreading
168
184
  with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
169
- # Submit tasks for each chunk to the api
170
185
  future_to_chunk = {
171
186
  executor.submit(
187
+ _call_with_retry,
172
188
  get_timeseries_chunk,
173
189
  selector,
174
190
  endpoint,
@@ -179,18 +195,23 @@ def fetch_timeseries_chunks(
179
195
  for chunk_start, chunk_end in chunks
180
196
  }
181
197
 
182
- # Process completed threads as they finish
183
198
  for future in concurrent.futures.as_completed(future_to_chunk):
199
+ chunk_start, chunk_end = future_to_chunk[future]
184
200
  try:
185
- # Retrieve the result of the completed future
186
- result = future.result()
187
- results.append(result)
201
+ results.append(future.result())
188
202
  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(
203
+ error_msg = (
192
204
  f"Failed to fetch data from {chunk_start} to {chunk_end}: {e}"
193
205
  )
206
+ logging.error(error_msg)
207
+ errors.append(error_msg)
208
+
209
+ if errors:
210
+ raise RuntimeError(
211
+ f"{len(errors)} of {len(chunks)} chunk(s) failed to fetch:\n"
212
+ + "\n".join(errors)
213
+ )
214
+
194
215
  return results
195
216
 
196
217
 
@@ -233,6 +254,14 @@ def combine_timeseries_results(results: List[Data]) -> Data:
233
254
  )
234
255
  combined_df["date-time"] = combined_df["date-time"].astype("Int64")
235
256
  combined_df = combined_df.reindex(columns=["date-time", "value", "quality-code"])
257
+
258
+ # Replace NaN in value column with None so they serialize as JSON null
259
+ # rather than the invalid JSON literal NaN.
260
+ combined_df["value"] = (
261
+ combined_df["value"]
262
+ .astype(object)
263
+ .where(combined_df["value"].notna(), other=None)
264
+ )
236
265
  # Update the "values" key in the JSON to include the combined data
237
266
  combined_json["values"] = combined_df.values.tolist()
238
267
 
@@ -438,8 +467,11 @@ def timeseries_df_to_json(
438
467
  pd.Timestamp.isoformat
439
468
  )
440
469
  df = df.reindex(columns=["date-time", "value", "quality-code"])
441
- if df.isnull().values.any():
442
- raise ValueError("Null/NaN data must be removed from the dataframe")
470
+
471
+ # Replace NaN/NA/NaT in value column with None so they serialize as JSON
472
+ # null rather than the invalid JSON literal NaN.
473
+ df["value"] = df["value"].astype(object).where(df["value"].notna(), other=None)
474
+
443
475
  if version_date:
444
476
  version_date_iso = version_date.isoformat()
445
477
  else:
@@ -458,6 +490,10 @@ def timeseries_df_to_json(
458
490
  def store_multi_timeseries_df(
459
491
  data: pd.DataFrame,
460
492
  office_id: str,
493
+ create_as_ltrs: Optional[bool] = False,
494
+ store_rule: Optional[str] = None,
495
+ override_protection: Optional[bool] = False,
496
+ multithread: Optional[bool] = True,
461
497
  max_workers: Optional[int] = 30,
462
498
  ) -> None:
463
499
  """stored mulitple timeseries from a dataframe. The dataframe must be a metled dataframe with columns
@@ -478,6 +514,19 @@ def store_multi_timeseries_df(
478
514
  2 2023-12-20T15:15:00.000-05:00 98.5 0 OMA.Stage.Inst.6Hours.0.Fcst-MRBWM-GRFT ft 2024-04-22 07:15:00-05:00
479
515
  office_id: string
480
516
  The owning office of the time series(s).
517
+ create_as_ltrs: bool, optional, default is False
518
+ Flag indicating if timeseries should be created as Local Regular Time Series.
519
+ store_rule: str, optional, default is None:
520
+ The business rule to use when merging the incoming with existing data. Available values :
521
+ REPLACE_ALL,
522
+ DO_NOT_REPLACE,
523
+ REPLACE_MISSING_VALUES_ONLY,
524
+ REPLACE_WITH_NON_MISSING,
525
+ DELETE_INSERT.
526
+ override_protection: bool, optional, default is False
527
+ A flag to ignore the protected data quality flag when storing data.
528
+ multithread: bool, default is false
529
+ Specifies whether to store chunked time series values using multiple threads.
481
530
  max_workers: Int, Optional, default is None
482
531
  It is a number of Threads aka size of pool in concurrent.futures.ThreadPoolExecutor.
483
532
 
@@ -491,7 +540,6 @@ def store_multi_timeseries_df(
491
540
  ts_id: str,
492
541
  office_id: str,
493
542
  version_date: Optional[datetime] = None,
494
- multithread: bool = False,
495
543
  ) -> None:
496
544
  try:
497
545
  units = data["units"].iloc[0]
@@ -502,7 +550,13 @@ def store_multi_timeseries_df(
502
550
  office_id=office_id,
503
551
  version_date=version_date,
504
552
  )
505
- store_timeseries(data=data_json, multithread=multithread)
553
+ store_timeseries(
554
+ data=data_json,
555
+ create_as_ltrs=create_as_ltrs,
556
+ store_rule=store_rule,
557
+ override_protection=override_protection,
558
+ multithread=multithread,
559
+ )
506
560
  except Exception as e:
507
561
  print(f"Error processing {ts_id}: {e}")
508
562
  return None
@@ -636,29 +690,31 @@ def store_timeseries(
636
690
 
637
691
  # Store chunks concurrently
638
692
  responses: List[Dict[str, Any]] = []
639
- with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
640
- # Initialize an empty list to store futures
641
- futures = []
642
- # Submit each chunk as a separate task to the executor
643
- for chunk in chunks:
644
- future = executor.submit(
645
- api.post, # The function to execute
646
- endpoint,
647
- chunk, # The chunk of data to store
648
- params,
649
- )
650
- futures.append(future) # Add the future to the list
693
+ errors: List[str] = []
651
694
 
652
- for future in concurrent.futures.as_completed(futures):
695
+ with concurrent.futures.ThreadPoolExecutor(max_workers=actual_workers) as executor:
696
+ future_to_chunk = {
697
+ executor.submit(_call_with_retry, api.post, endpoint, chunk, params): chunk
698
+ for chunk in chunks
699
+ }
700
+
701
+ for future in concurrent.futures.as_completed(future_to_chunk):
702
+ chunk = future_to_chunk[future]
653
703
  try:
654
- responses.append({"success:": future.result()})
704
+ responses.append({"success": future.result()})
655
705
  except Exception as e:
656
706
  start_time = chunk["values"][0][0]
657
707
  end_time = chunk["values"][-1][0]
658
- logging.error(
659
- f"Error storing chunk from {start_time} to {end_time}: {e}"
660
- )
661
- responses.append({"error": str(e)})
708
+ error_msg = f"Error storing chunk from {start_time} to {end_time}: {e}"
709
+ logging.error(error_msg)
710
+ errors.append(error_msg)
711
+ responses.append({"error": error_msg})
712
+
713
+ if errors:
714
+ raise RuntimeError(
715
+ f"{len(errors)} of {len(chunks)} chunk(s) failed to store:\n"
716
+ + "\n".join(errors)
717
+ )
662
718
 
663
719
  return
664
720
 
@@ -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)
@@ -35,18 +35,66 @@ def get_user_profile() -> dict[str, Any]:
35
35
  return dict(response)
36
36
 
37
37
 
38
+ def filter_users_by_office(data: dict[str, Any], office: str) -> dict[str, Any]:
39
+ """
40
+ Filter users JSON to only include users that have roles for the specified office.
41
+ Each user's roles dict will only contain the entry for that office.
42
+
43
+ Args:
44
+ data: The full users JSON as a Python dict.
45
+ office: The office key to filter by (e.g., 'MVP', 'LRL').
46
+
47
+ Returns:
48
+ A new dict with the same structure, filtered to the specified office.
49
+ """
50
+ filtered_users = []
51
+
52
+ for user in data.get("users", []):
53
+ roles = user.get("roles", {})
54
+
55
+ if office in roles:
56
+ # Build a copy of the user with only the target office's roles
57
+ filtered_user = {k: v for k, v in user.items() if k != "roles"}
58
+ filtered_user["roles"] = {office: roles[office]}
59
+ filtered_users.append(filtered_user)
60
+
61
+ return {
62
+ "page": data.get("page"),
63
+ "page-size": data.get("page-size"),
64
+ "total": len(filtered_users),
65
+ "users": filtered_users,
66
+ }
67
+
68
+
38
69
  def get_users(
39
70
  office_id: Optional[str] = None,
71
+ username_like: Optional[str] = None,
72
+ include_roles: Optional[bool] = None,
40
73
  page: Optional[str] = None,
41
- page_size: Optional[int] = None,
74
+ page_size: Optional[int] = 5000,
42
75
  ) -> Data:
43
76
  """Retrieve users with optional office and paging filters."""
44
77
 
45
- params = {"office": office_id, "page": page, "page-size": page_size}
78
+ endpoint = "users"
79
+ params = {
80
+ "office": office_id,
81
+ "username-like": username_like,
82
+ "include-roles": include_roles,
83
+ "page": page,
84
+ "page-size": page_size,
85
+ }
46
86
  try:
47
- response = api.get("users", params=params, api_version=1)
87
+ response = api.get_with_paging(
88
+ endpoint=endpoint, selector="users", params=params, api_version=1
89
+ )
48
90
  except api.ApiError as error:
49
91
  _raise_user_management_error(error, "User list lookup")
92
+
93
+ # filter by office if office_id is provided since the API does not
94
+ # currently support filtering by office on the backend. This is a
95
+ # temporary workaround until the API supports office filtering.
96
+ if office_id:
97
+ response = filter_users_by_office(response, office_id)
50
98
  return Data(response, selector="users")
51
99
 
52
100
 
@@ -8,3 +8,15 @@ def is_base64(s: str) -> bool:
8
8
  return base64.b64encode(decoded).decode("utf-8") == s
9
9
  except (ValueError, TypeError):
10
10
  return False
11
+
12
+
13
+ def has_invalid_chars(id: str) -> bool:
14
+ """
15
+ Checks if ID contains any invalid web path characters.
16
+ """
17
+ INVALID_PATH_CHARS = ["/", "\\", "&", "?", "="]
18
+
19
+ for char in INVALID_PATH_CHARS:
20
+ if char in id:
21
+ return True
22
+ return False
@@ -2,8 +2,7 @@
2
2
  name = "cwms-python"
3
3
  repository = "https://github.com/HydrologicEngineeringCenter/cwms-python"
4
4
 
5
- version = "1.0.3"
6
-
5
+ version = "1.0.8"
7
6
 
8
7
  packages = [
9
8
  { include = "cwms" },
File without changes