cwms-python 1.0.3__tar.gz → 1.0.7__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.7}/PKG-INFO +19 -1
  2. {cwms_python-1.0.3 → cwms_python-1.0.7}/README.md +18 -0
  3. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/api.py +80 -26
  4. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/catalog/blobs.py +25 -8
  5. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/catalog/catalog.py +6 -2
  6. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/catalog/clobs.py +44 -23
  7. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/timeseries/timeseries.py +37 -4
  8. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/users/users.py +51 -3
  9. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/utils/checks.py +12 -0
  10. {cwms_python-1.0.3 → cwms_python-1.0.7}/pyproject.toml +1 -2
  11. {cwms_python-1.0.3 → cwms_python-1.0.7}/LICENSE +0 -0
  12. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/__init__.py +0 -0
  13. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/cwms_types.py +0 -0
  14. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/forecast/forecast_instance.py +0 -0
  15. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/forecast/forecast_spec.py +0 -0
  16. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/levels/location_levels.py +0 -0
  17. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/levels/specified_levels.py +0 -0
  18. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/locations/gate_changes.py +0 -0
  19. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/locations/location_groups.py +0 -0
  20. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/locations/physical_locations.py +0 -0
  21. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/measurements/measurements.py +0 -0
  22. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/outlets/outlets.py +0 -0
  23. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/outlets/virtual_outlets.py +0 -0
  24. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/projects/project_lock_rights.py +0 -0
  25. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/projects/project_locks.py +0 -0
  26. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/projects/projects.py +0 -0
  27. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/projects/water_supply/accounting.py +0 -0
  28. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/ratings/ratings.py +0 -0
  29. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/ratings/ratings_spec.py +0 -0
  30. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/ratings/ratings_template.py +0 -0
  31. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/standard_text/standard_text.py +0 -0
  32. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/timeseries/timeseries_bin.py +0 -0
  33. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/timeseries/timeseries_group.py +0 -0
  34. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/timeseries/timeseries_identifier.py +0 -0
  35. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/timeseries/timeseries_profile.py +0 -0
  36. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/timeseries/timeseries_profile_instance.py +0 -0
  37. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/timeseries/timeseries_profile_parser.py +0 -0
  38. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/timeseries/timeseries_txt.py +0 -0
  39. {cwms_python-1.0.3 → cwms_python-1.0.7}/cwms/turbines/turbines.py +0 -0
  40. {cwms_python-1.0.3 → cwms_python-1.0.7}/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.7
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
 
@@ -233,6 +233,14 @@ def combine_timeseries_results(results: List[Data]) -> Data:
233
233
  )
234
234
  combined_df["date-time"] = combined_df["date-time"].astype("Int64")
235
235
  combined_df = combined_df.reindex(columns=["date-time", "value", "quality-code"])
236
+
237
+ # Replace NaN in value column with None so they serialize as JSON null
238
+ # rather than the invalid JSON literal NaN.
239
+ combined_df["value"] = (
240
+ combined_df["value"]
241
+ .astype(object)
242
+ .where(combined_df["value"].notna(), other=None)
243
+ )
236
244
  # Update the "values" key in the JSON to include the combined data
237
245
  combined_json["values"] = combined_df.values.tolist()
238
246
 
@@ -438,8 +446,11 @@ def timeseries_df_to_json(
438
446
  pd.Timestamp.isoformat
439
447
  )
440
448
  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")
449
+
450
+ # Replace NaN/NA/NaT in value column with None so they serialize as JSON
451
+ # null rather than the invalid JSON literal NaN.
452
+ df["value"] = df["value"].astype(object).where(df["value"].notna(), other=None)
453
+
443
454
  if version_date:
444
455
  version_date_iso = version_date.isoformat()
445
456
  else:
@@ -458,6 +469,10 @@ def timeseries_df_to_json(
458
469
  def store_multi_timeseries_df(
459
470
  data: pd.DataFrame,
460
471
  office_id: str,
472
+ create_as_ltrs: Optional[bool] = False,
473
+ store_rule: Optional[str] = None,
474
+ override_protection: Optional[bool] = False,
475
+ multithread: Optional[bool] = True,
461
476
  max_workers: Optional[int] = 30,
462
477
  ) -> None:
463
478
  """stored mulitple timeseries from a dataframe. The dataframe must be a metled dataframe with columns
@@ -478,6 +493,19 @@ def store_multi_timeseries_df(
478
493
  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
494
  office_id: string
480
495
  The owning office of the time series(s).
496
+ create_as_ltrs: bool, optional, default is False
497
+ Flag indicating if timeseries should be created as Local Regular Time Series.
498
+ store_rule: str, optional, default is None:
499
+ The business rule to use when merging the incoming with existing data. Available values :
500
+ REPLACE_ALL,
501
+ DO_NOT_REPLACE,
502
+ REPLACE_MISSING_VALUES_ONLY,
503
+ REPLACE_WITH_NON_MISSING,
504
+ DELETE_INSERT.
505
+ override_protection: bool, optional, default is False
506
+ A flag to ignore the protected data quality flag when storing data.
507
+ multithread: bool, default is false
508
+ Specifies whether to store chunked time series values using multiple threads.
481
509
  max_workers: Int, Optional, default is None
482
510
  It is a number of Threads aka size of pool in concurrent.futures.ThreadPoolExecutor.
483
511
 
@@ -491,7 +519,6 @@ def store_multi_timeseries_df(
491
519
  ts_id: str,
492
520
  office_id: str,
493
521
  version_date: Optional[datetime] = None,
494
- multithread: bool = False,
495
522
  ) -> None:
496
523
  try:
497
524
  units = data["units"].iloc[0]
@@ -502,7 +529,13 @@ def store_multi_timeseries_df(
502
529
  office_id=office_id,
503
530
  version_date=version_date,
504
531
  )
505
- store_timeseries(data=data_json, multithread=multithread)
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
+ )
506
539
  except Exception as e:
507
540
  print(f"Error processing {ts_id}: {e}")
508
541
  return None
@@ -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.7"
7
6
 
8
7
  packages = [
9
8
  { include = "cwms" },
File without changes