cwms-python 1.1.1__tar.gz → 1.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {cwms_python-1.1.1 → cwms_python-1.1.2}/PKG-INFO +42 -1
- {cwms_python-1.1.1 → cwms_python-1.1.2}/README.md +41 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/api.py +97 -35
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/ratings/ratings.py +4 -4
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/ratings/ratings_spec.py +2 -2
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/timeseries/timeseries.py +85 -75
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/users/users.py +6 -12
- {cwms_python-1.1.1 → cwms_python-1.1.2}/pyproject.toml +1 -1
- {cwms_python-1.1.1 → cwms_python-1.1.2}/LICENSE +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/__init__.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/catalog/blobs.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/catalog/catalog.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/catalog/clobs.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/cwms_types.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/forecast/forecast_instance.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/forecast/forecast_spec.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/levels/location_levels.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/levels/specified_levels.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/locations/gate_changes.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/locations/location_groups.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/locations/lookups.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/locations/physical_locations.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/locks/locks.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/measurements/measurements.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/outlets/outlets.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/outlets/virtual_outlets.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/projects/project_lock_rights.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/projects/project_locks.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/projects/projects.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/projects/water_supply/accounting.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/projects/water_supply/water_contracts.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/projects/water_supply/water_users.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/properties/properties.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/ratings/ratings_template.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/standard_text/standard_text.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/timeseries/timeseries_bin.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/timeseries/timeseries_group.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/timeseries/timeseries_identifier.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/timeseries/timeseries_profile.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/timeseries/timeseries_profile_instance.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/timeseries/timeseries_profile_parser.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/timeseries/timeseries_txt.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/turbines/turbines.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/utils/__init__.py +0 -0
- {cwms_python-1.1.1 → cwms_python-1.1.2}/cwms/utils/checks.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cwms-python
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.2
|
|
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
|
|
@@ -69,6 +69,47 @@ cwms.init_session(
|
|
|
69
69
|
If both `token` and `api_key` are provided, `cwms-python` will use the token
|
|
70
70
|
and log a warning.
|
|
71
71
|
|
|
72
|
+
### HTTP connection pools
|
|
73
|
+
|
|
74
|
+
Sessions created with `cwms.init_session(api_root=..., pool_connections=100)`
|
|
75
|
+
configure the same connection pool size and retry policy for HTTP and HTTPS.
|
|
76
|
+
This includes unencrypted internal HTTP CDA roots used by Batch jobs. The default
|
|
77
|
+
pool retains up to 100 connections per host for reuse; it does not cap concurrent
|
|
78
|
+
requests. Set `pool_connections` when creating the session to size that pool for
|
|
79
|
+
your workload. A `Connection pool is full, discarding connection` warning means
|
|
80
|
+
an extra connection is being closed instead of retained, not that a response or
|
|
81
|
+
its data was discarded. Check raised exceptions for actual request failures.
|
|
82
|
+
|
|
83
|
+
### Errors and debugging
|
|
84
|
+
|
|
85
|
+
Failed HTTP requests raise `cwms.api.ApiError`. Its message includes the HTTP
|
|
86
|
+
status, method, URL, and CDA response body (including incident details when
|
|
87
|
+
provided). The original response is available as `error.response`. Custom
|
|
88
|
+
user-management errors retain these details too. Network exceptions propagate
|
|
89
|
+
with their original type. Invalid JSON responses raise `ApiError` with the
|
|
90
|
+
decoding exception as the cause; empty response bodies return an empty dictionary.
|
|
91
|
+
|
|
92
|
+
Concurrent time-series reads and writes raise `cwms.api.BatchError` if any
|
|
93
|
+
series or chunk fails. It subclasses `RuntimeError`, and `error.failures`
|
|
94
|
+
contains `(series_or_chunk, original_exception)` pairs for every failure.
|
|
95
|
+
Reads do not return incomplete results as success. Successful writes are not
|
|
96
|
+
rolled back. Failures while looking up time-series extents also propagate.
|
|
97
|
+
Chunk retries are limited to connection errors, timeouts, and HTTP
|
|
98
|
+
429/500/502/503/504; validation and other permanent errors fail immediately
|
|
99
|
+
at the chunk layer. The shared HTTP adapter retains its existing retry policy.
|
|
100
|
+
|
|
101
|
+
Enable request outcome and chunk diagnostics with Python logging:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
import logging
|
|
105
|
+
|
|
106
|
+
logging.basicConfig(level=logging.WARNING)
|
|
107
|
+
logging.getLogger("cwms").setLevel(logging.DEBUG)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Request diagnostics include the method, endpoint, and response status, without
|
|
111
|
+
request bodies or authentication headers.
|
|
112
|
+
|
|
72
113
|
## Getting Started
|
|
73
114
|
|
|
74
115
|
```python
|
|
@@ -38,6 +38,47 @@ cwms.init_session(
|
|
|
38
38
|
If both `token` and `api_key` are provided, `cwms-python` will use the token
|
|
39
39
|
and log a warning.
|
|
40
40
|
|
|
41
|
+
### HTTP connection pools
|
|
42
|
+
|
|
43
|
+
Sessions created with `cwms.init_session(api_root=..., pool_connections=100)`
|
|
44
|
+
configure the same connection pool size and retry policy for HTTP and HTTPS.
|
|
45
|
+
This includes unencrypted internal HTTP CDA roots used by Batch jobs. The default
|
|
46
|
+
pool retains up to 100 connections per host for reuse; it does not cap concurrent
|
|
47
|
+
requests. Set `pool_connections` when creating the session to size that pool for
|
|
48
|
+
your workload. A `Connection pool is full, discarding connection` warning means
|
|
49
|
+
an extra connection is being closed instead of retained, not that a response or
|
|
50
|
+
its data was discarded. Check raised exceptions for actual request failures.
|
|
51
|
+
|
|
52
|
+
### Errors and debugging
|
|
53
|
+
|
|
54
|
+
Failed HTTP requests raise `cwms.api.ApiError`. Its message includes the HTTP
|
|
55
|
+
status, method, URL, and CDA response body (including incident details when
|
|
56
|
+
provided). The original response is available as `error.response`. Custom
|
|
57
|
+
user-management errors retain these details too. Network exceptions propagate
|
|
58
|
+
with their original type. Invalid JSON responses raise `ApiError` with the
|
|
59
|
+
decoding exception as the cause; empty response bodies return an empty dictionary.
|
|
60
|
+
|
|
61
|
+
Concurrent time-series reads and writes raise `cwms.api.BatchError` if any
|
|
62
|
+
series or chunk fails. It subclasses `RuntimeError`, and `error.failures`
|
|
63
|
+
contains `(series_or_chunk, original_exception)` pairs for every failure.
|
|
64
|
+
Reads do not return incomplete results as success. Successful writes are not
|
|
65
|
+
rolled back. Failures while looking up time-series extents also propagate.
|
|
66
|
+
Chunk retries are limited to connection errors, timeouts, and HTTP
|
|
67
|
+
429/500/502/503/504; validation and other permanent errors fail immediately
|
|
68
|
+
at the chunk layer. The shared HTTP adapter retains its existing retry policy.
|
|
69
|
+
|
|
70
|
+
Enable request outcome and chunk diagnostics with Python logging:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import logging
|
|
74
|
+
|
|
75
|
+
logging.basicConfig(level=logging.WARNING)
|
|
76
|
+
logging.getLogger("cwms").setLevel(logging.DEBUG)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Request diagnostics include the method, endpoint, and response status, without
|
|
80
|
+
request bodies or authentication headers.
|
|
81
|
+
|
|
41
82
|
## Getting Started
|
|
42
83
|
|
|
43
84
|
```python
|
|
@@ -33,13 +33,14 @@ import base64
|
|
|
33
33
|
import json
|
|
34
34
|
import logging
|
|
35
35
|
from http import HTTPStatus
|
|
36
|
-
from
|
|
37
|
-
from typing import Any, Optional, cast
|
|
36
|
+
from typing import Any, Optional, Union, cast
|
|
38
37
|
|
|
39
38
|
from requests import Response, adapters
|
|
39
|
+
from requests.exceptions import JSONDecodeError, RequestException
|
|
40
40
|
from requests.exceptions import RetryError as RequestsRetryError
|
|
41
41
|
from requests_toolbelt import sessions # type: ignore
|
|
42
42
|
from requests_toolbelt.sessions import BaseUrlSession # type: ignore
|
|
43
|
+
from urllib3.exceptions import HTTPError as Urllib3HTTPError
|
|
43
44
|
from urllib3.util.retry import Retry
|
|
44
45
|
|
|
45
46
|
from cwms.cwms_types import JSON, RequestParams
|
|
@@ -47,6 +48,7 @@ from cwms.cwms_types import JSON, RequestParams
|
|
|
47
48
|
# Specify the default API root URL and version.
|
|
48
49
|
API_ROOT = "https://cwms-data.usace.army.mil/cwms-data/"
|
|
49
50
|
API_VERSION = 2
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
50
52
|
|
|
51
53
|
# Specify whether LRTS will use new ID format
|
|
52
54
|
USE_NEW_LRTS_IDS = False
|
|
@@ -71,6 +73,7 @@ adapter = adapters.HTTPAdapter(
|
|
|
71
73
|
pool_connections=100, pool_maxsize=100, max_retries=retry_strategy
|
|
72
74
|
)
|
|
73
75
|
SESSION.mount("https://", adapter)
|
|
76
|
+
SESSION.mount("http://", adapter)
|
|
74
77
|
|
|
75
78
|
|
|
76
79
|
class InvalidVersion(Exception):
|
|
@@ -90,17 +93,19 @@ class ApiError(Exception):
|
|
|
90
93
|
self.message = message
|
|
91
94
|
|
|
92
95
|
def __str__(self) -> str:
|
|
93
|
-
if self.message:
|
|
94
|
-
return self.message
|
|
95
|
-
|
|
96
96
|
# Include the request URL in the error message.
|
|
97
|
-
message = f"CWMS API Error ({self.response.url})"
|
|
97
|
+
message = f"CWMS API Error ({self.response.url}) {self.response.status_code}"
|
|
98
|
+
request = getattr(self.response, "request", None)
|
|
99
|
+
if request is not None:
|
|
100
|
+
message += f" {request.method}"
|
|
98
101
|
|
|
99
102
|
# If a reason is provided in the response, add it to the message.
|
|
100
103
|
if reason := self.response.reason:
|
|
101
104
|
message += f" {reason}"
|
|
102
105
|
|
|
103
106
|
message += "."
|
|
107
|
+
if self.message:
|
|
108
|
+
message += f" {self.message}"
|
|
104
109
|
|
|
105
110
|
# Add additional context to help the user resolve the issue.
|
|
106
111
|
hint = self.hint()
|
|
@@ -111,10 +116,7 @@ class ApiError(Exception):
|
|
|
111
116
|
content = getattr(self.response, "content", None)
|
|
112
117
|
if content:
|
|
113
118
|
if isinstance(content, bytes):
|
|
114
|
-
|
|
115
|
-
text = content.decode("utf-8", errors="replace")
|
|
116
|
-
except Exception:
|
|
117
|
-
text = repr(content)
|
|
119
|
+
text = content.decode("utf-8", errors="replace")
|
|
118
120
|
else:
|
|
119
121
|
text = str(content)
|
|
120
122
|
message += f" {text}"
|
|
@@ -130,6 +132,9 @@ class ApiError(Exception):
|
|
|
130
132
|
if status == 400:
|
|
131
133
|
return "Check that your parameters are correct."
|
|
132
134
|
if status == 404:
|
|
135
|
+
request = getattr(self.response, "request", None)
|
|
136
|
+
if request is not None and request.method != "GET":
|
|
137
|
+
return "Check the CDA response body for the failed operation."
|
|
133
138
|
return "May be the result of an empty query."
|
|
134
139
|
|
|
135
140
|
# No hint for other codes
|
|
@@ -144,8 +149,29 @@ class PermissionError(ApiError):
|
|
|
144
149
|
"""Raised when the CDA request is not authorized for the current caller."""
|
|
145
150
|
|
|
146
151
|
|
|
147
|
-
|
|
148
|
-
"""
|
|
152
|
+
class BatchError(RuntimeError):
|
|
153
|
+
"""A concurrent operation failed; ``failures`` retains every original exception.
|
|
154
|
+
|
|
155
|
+
Each failure is a (series or chunk description, exception) pair. Successful
|
|
156
|
+
writes are not rolled back. This remains compatible with RuntimeError handlers.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(self, message: str, failures: list[tuple[str, Exception]]):
|
|
160
|
+
self.failures = failures
|
|
161
|
+
super().__init__(
|
|
162
|
+
message
|
|
163
|
+
+ "\n"
|
|
164
|
+
+ "\n".join(f"{context}: {error}" for context, error in failures)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _unwrap_retry_error(
|
|
169
|
+
error: RequestsRetryError,
|
|
170
|
+
) -> Union[RequestException, Urllib3HTTPError]:
|
|
171
|
+
"""Unwrap transport errors; retain RetryError for other causes.
|
|
172
|
+
|
|
173
|
+
Unknown causes remain available through the original wrapper's chain or args.
|
|
174
|
+
"""
|
|
149
175
|
|
|
150
176
|
current: Exception = error
|
|
151
177
|
cause = error.__cause__
|
|
@@ -162,7 +188,9 @@ def _unwrap_retry_error(error: RequestsRetryError) -> Exception:
|
|
|
162
188
|
current = reason
|
|
163
189
|
reason = getattr(current, "reason", None)
|
|
164
190
|
|
|
165
|
-
|
|
191
|
+
if isinstance(current, (RequestException, Urllib3HTTPError)):
|
|
192
|
+
return current
|
|
193
|
+
return error
|
|
166
194
|
|
|
167
195
|
|
|
168
196
|
def init_session(
|
|
@@ -185,6 +213,9 @@ def init_session(
|
|
|
185
213
|
api_key (optional): An authentication key.
|
|
186
214
|
token (optional): A Keycloak access token. If both token and api_key are
|
|
187
215
|
provided, token is used.
|
|
216
|
+
pool_connections (optional): Number of host pools and reusable connections
|
|
217
|
+
per host when creating a session with api_root. Defaults to 100 for
|
|
218
|
+
both HTTP and HTTPS; this is not a limit on concurrent requests.
|
|
188
219
|
|
|
189
220
|
Returns:
|
|
190
221
|
Returns the updated session object.
|
|
@@ -202,6 +233,7 @@ def init_session(
|
|
|
202
233
|
max_retries=retry_strategy,
|
|
203
234
|
)
|
|
204
235
|
SESSION.mount("https://", adapter)
|
|
236
|
+
SESSION.mount("http://", adapter)
|
|
205
237
|
if token:
|
|
206
238
|
if api_key:
|
|
207
239
|
logging.warning(
|
|
@@ -336,6 +368,8 @@ def get_xml(
|
|
|
336
368
|
|
|
337
369
|
|
|
338
370
|
def _process_response(response: Response) -> Any:
|
|
371
|
+
if not response.content:
|
|
372
|
+
return {}
|
|
339
373
|
try:
|
|
340
374
|
# Avoid case sensitivity issues with the content type header
|
|
341
375
|
content_type = response.headers.get("Content-Type", "").lower()
|
|
@@ -356,10 +390,14 @@ def _process_response(response: Response) -> Any:
|
|
|
356
390
|
# Fallback for remaining content types
|
|
357
391
|
return response.content.decode("utf-8")
|
|
358
392
|
except JSONDecodeError as error:
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
393
|
+
raise ApiError(response, "Invalid JSON in CDA response.") from error
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _check_response(response: Response, method: str, endpoint: str) -> None:
|
|
397
|
+
"""Record request outcomes without logging credentials or request bodies."""
|
|
398
|
+
logger.debug("CDA %s %s returned HTTP %s", method, endpoint, response.status_code)
|
|
399
|
+
if not response.ok:
|
|
400
|
+
raise ApiError(response)
|
|
363
401
|
|
|
364
402
|
|
|
365
403
|
def get(
|
|
@@ -391,12 +429,16 @@ def get(
|
|
|
391
429
|
}
|
|
392
430
|
try:
|
|
393
431
|
with SESSION.get(endpoint, params=params, headers=headers) as response:
|
|
394
|
-
|
|
395
|
-
logging.error(f"CDA Error: response={response}")
|
|
396
|
-
raise ApiError(response)
|
|
432
|
+
_check_response(response, "GET", endpoint)
|
|
397
433
|
return _process_response(response)
|
|
398
434
|
except RequestsRetryError as error:
|
|
399
|
-
|
|
435
|
+
cause = _unwrap_retry_error(error)
|
|
436
|
+
if cause is error:
|
|
437
|
+
raise
|
|
438
|
+
raise cause from error
|
|
439
|
+
except RequestException as error:
|
|
440
|
+
logger.debug("CDA GET %s failed: %s", endpoint, type(error).__name__)
|
|
441
|
+
raise
|
|
400
442
|
|
|
401
443
|
|
|
402
444
|
def get_with_paging(
|
|
@@ -459,12 +501,16 @@ def _post_function(
|
|
|
459
501
|
with SESSION.post(
|
|
460
502
|
endpoint, params=params, headers=headers, data=data
|
|
461
503
|
) as response:
|
|
462
|
-
|
|
463
|
-
logging.error(f"CDA Error: response={response}")
|
|
464
|
-
raise ApiError(response)
|
|
504
|
+
_check_response(response, "POST", endpoint)
|
|
465
505
|
return response
|
|
466
506
|
except RequestsRetryError as error:
|
|
467
|
-
|
|
507
|
+
cause = _unwrap_retry_error(error)
|
|
508
|
+
if cause is error:
|
|
509
|
+
raise
|
|
510
|
+
raise cause from error
|
|
511
|
+
except RequestException as error:
|
|
512
|
+
logger.debug("CDA POST %s failed: %s", endpoint, type(error).__name__)
|
|
513
|
+
raise
|
|
468
514
|
|
|
469
515
|
|
|
470
516
|
def post(
|
|
@@ -556,17 +602,21 @@ def patch(
|
|
|
556
602
|
"X-CWMS-LRTS-Formatting": str(USE_NEW_LRTS_IDS).lower(),
|
|
557
603
|
}
|
|
558
604
|
|
|
559
|
-
if
|
|
605
|
+
if isinstance(data, (dict, list)):
|
|
560
606
|
data = json.dumps(data)
|
|
561
607
|
try:
|
|
562
608
|
with SESSION.patch(
|
|
563
609
|
endpoint, params=params, headers=headers, data=data
|
|
564
610
|
) as response:
|
|
565
|
-
|
|
566
|
-
logging.error(f"CDA Error: response={response}")
|
|
567
|
-
raise ApiError(response)
|
|
611
|
+
_check_response(response, "PATCH", endpoint)
|
|
568
612
|
except RequestsRetryError as error:
|
|
569
|
-
|
|
613
|
+
cause = _unwrap_retry_error(error)
|
|
614
|
+
if cause is error:
|
|
615
|
+
raise
|
|
616
|
+
raise cause from error
|
|
617
|
+
except RequestException as error:
|
|
618
|
+
logger.debug("CDA PATCH %s failed: %s", endpoint, type(error).__name__)
|
|
619
|
+
raise
|
|
570
620
|
|
|
571
621
|
|
|
572
622
|
def delete(
|
|
@@ -574,6 +624,7 @@ def delete(
|
|
|
574
624
|
params: Optional[RequestParams] = None,
|
|
575
625
|
*,
|
|
576
626
|
api_version: int = API_VERSION,
|
|
627
|
+
data: Optional[Any] = None,
|
|
577
628
|
) -> None:
|
|
578
629
|
"""Make a DELETE request to the CWMS Data API.
|
|
579
630
|
|
|
@@ -584,6 +635,7 @@ def delete(
|
|
|
584
635
|
Keyword Args:
|
|
585
636
|
api_version (optional): The CDA version to use for the request. If not specified,
|
|
586
637
|
the default API_VERSION will be used.
|
|
638
|
+
data (optional): Request body, JSON-encoded for dictionaries and lists.
|
|
587
639
|
|
|
588
640
|
Raises:
|
|
589
641
|
ApiError: If an error response is return by the API.
|
|
@@ -593,10 +645,20 @@ def delete(
|
|
|
593
645
|
"Accept": api_version_text(api_version),
|
|
594
646
|
"X-CWMS-LRTS-Formatting": str(USE_NEW_LRTS_IDS).lower(),
|
|
595
647
|
}
|
|
648
|
+
kwargs: dict[str, Any] = {}
|
|
649
|
+
if data is not None:
|
|
650
|
+
headers["Content-Type"] = api_version_text(api_version)
|
|
651
|
+
kwargs["data"] = json.dumps(data) if isinstance(data, (dict, list)) else data
|
|
596
652
|
try:
|
|
597
|
-
with SESSION.delete(
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
653
|
+
with SESSION.delete(
|
|
654
|
+
endpoint, params=params, headers=headers, **kwargs
|
|
655
|
+
) as response:
|
|
656
|
+
_check_response(response, "DELETE", endpoint)
|
|
601
657
|
except RequestsRetryError as error:
|
|
602
|
-
|
|
658
|
+
cause = _unwrap_retry_error(error)
|
|
659
|
+
if cause is error:
|
|
660
|
+
raise
|
|
661
|
+
raise cause from error
|
|
662
|
+
except RequestException as error:
|
|
663
|
+
logger.debug("CDA DELETE %s failed: %s", endpoint, type(error).__name__)
|
|
664
|
+
raise
|
|
@@ -424,8 +424,8 @@ def _validate_rating_params(
|
|
|
424
424
|
raise ValueError(f"Invalid rating identifer: {rating_id}")
|
|
425
425
|
try:
|
|
426
426
|
ind_params, _ = parts[1].split(";")
|
|
427
|
-
except
|
|
428
|
-
raise ValueError(f"Invalid rating template: {parts[1]}")
|
|
427
|
+
except ValueError as error:
|
|
428
|
+
raise ValueError(f"Invalid rating template: {parts[1]}") from error
|
|
429
429
|
if not office_id:
|
|
430
430
|
raise ValueError("Cannot rate values without an office identifier")
|
|
431
431
|
if not units:
|
|
@@ -466,8 +466,8 @@ def _perform_value_rating(
|
|
|
466
466
|
ind_params = _validate_rating_params(rating_id, office_id, units, values)
|
|
467
467
|
try:
|
|
468
468
|
ind_units_str, dep_unit = units.split(";")
|
|
469
|
-
except
|
|
470
|
-
raise ValueError("Invalid units string")
|
|
469
|
+
except ValueError as error:
|
|
470
|
+
raise ValueError("Invalid units string") from error
|
|
471
471
|
value_count = len(values[0])
|
|
472
472
|
times = _get_times(value_count, times)
|
|
473
473
|
if not rating_time:
|
|
@@ -127,7 +127,7 @@ def rating_spec_df_to_xml(data: pd.DataFrame) -> str:
|
|
|
127
127
|
try:
|
|
128
128
|
spec_xml += f"""
|
|
129
129
|
<source-agency>{data.loc[0,'source-agency']}</source-agency>"""
|
|
130
|
-
except
|
|
130
|
+
except KeyError:
|
|
131
131
|
spec_xml += """
|
|
132
132
|
<source-agency/>"""
|
|
133
133
|
spec_xml += f"""
|
|
@@ -154,7 +154,7 @@ def rating_spec_df_to_xml(data: pd.DataFrame) -> str:
|
|
|
154
154
|
try:
|
|
155
155
|
spec_xml2 += f"""
|
|
156
156
|
<description>{data.loc[0,'description']}</description>"""
|
|
157
|
-
except
|
|
157
|
+
except KeyError:
|
|
158
158
|
spec_xml2 += """
|
|
159
159
|
<description/>"""
|
|
160
160
|
spec_xml2 += """
|
|
@@ -6,11 +6,14 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
6
6
|
|
|
7
7
|
import pandas as pd
|
|
8
8
|
from pandas import DataFrame
|
|
9
|
+
from requests.exceptions import ConnectionError, Timeout
|
|
9
10
|
|
|
10
11
|
import cwms.api as api
|
|
11
12
|
from cwms.catalog.catalog import get_ts_extents
|
|
12
13
|
from cwms.cwms_types import JSON, Data
|
|
13
14
|
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
14
17
|
_DEFAULT_CHUNK_DAYS = 365
|
|
15
18
|
_MIN_INTERVAL_MINUTES = 2
|
|
16
19
|
_FINE_INTERVAL_MINUTES = 15
|
|
@@ -129,45 +132,53 @@ def get_multi_timeseries_df(
|
|
|
129
132
|
"""
|
|
130
133
|
|
|
131
134
|
def get_ts_ids(ts_id: str) -> Any:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
return result_dict
|
|
154
|
-
except Exception as e:
|
|
155
|
-
logging.error(f"Error processing {ts_id}: {e}")
|
|
156
|
-
return None
|
|
135
|
+
if ":" in ts_id:
|
|
136
|
+
ts_id, version_date = ts_id.split(":", 1)
|
|
137
|
+
version_date_dt = pd.to_datetime(version_date)
|
|
138
|
+
else:
|
|
139
|
+
version_date_dt = None
|
|
140
|
+
data = get_timeseries(
|
|
141
|
+
ts_id=ts_id,
|
|
142
|
+
office_id=office_id,
|
|
143
|
+
unit=unit,
|
|
144
|
+
begin=begin,
|
|
145
|
+
end=end,
|
|
146
|
+
version_date=version_date_dt,
|
|
147
|
+
multithread=False,
|
|
148
|
+
)
|
|
149
|
+
result_dict = {
|
|
150
|
+
"ts_id": ts_id,
|
|
151
|
+
"unit": data.json["units"],
|
|
152
|
+
"version_date": version_date_dt,
|
|
153
|
+
"values": data.df,
|
|
154
|
+
}
|
|
155
|
+
return result_dict
|
|
157
156
|
|
|
157
|
+
logger.debug(
|
|
158
|
+
"Fetching %s time series with up to %s workers", len(ts_ids), max_workers
|
|
159
|
+
)
|
|
160
|
+
failures: list[tuple[str, Exception]] = []
|
|
161
|
+
result_dict = []
|
|
158
162
|
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
159
|
-
|
|
163
|
+
futures = [(ts_id, executor.submit(get_ts_ids, ts_id)) for ts_id in ts_ids]
|
|
164
|
+
for ts_id, future in futures:
|
|
165
|
+
try:
|
|
166
|
+
result_dict.append(future.result())
|
|
167
|
+
except Exception as error:
|
|
168
|
+
failures.append((ts_id, error))
|
|
169
|
+
if failures:
|
|
170
|
+
raise api.BatchError(
|
|
171
|
+
f"{len(failures)} time series failed to fetch:", failures
|
|
172
|
+
) from failures[0][1]
|
|
160
173
|
|
|
161
|
-
result_dict = list(results)
|
|
162
174
|
data = pd.DataFrame()
|
|
163
175
|
for row in result_dict:
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
data = pd.concat([data, temp_df], ignore_index=True)
|
|
176
|
+
temp_df = row["values"]
|
|
177
|
+
temp_df = temp_df.assign(ts_id=row["ts_id"], units=row["unit"])
|
|
178
|
+
if "version_date" in row.keys():
|
|
179
|
+
temp_df = temp_df.assign(version_date=row["version_date"])
|
|
180
|
+
temp_df.dropna(how="all", axis=1, inplace=True)
|
|
181
|
+
data = pd.concat([data, temp_df], ignore_index=True)
|
|
171
182
|
|
|
172
183
|
if not melted and "date-time" in data.columns:
|
|
173
184
|
cols = ["ts_id", "units"]
|
|
@@ -234,16 +245,24 @@ _CHUNK_ATTEMPTS = 6
|
|
|
234
245
|
|
|
235
246
|
|
|
236
247
|
def _call_with_retry(fn: Any, *args: Any, attempts: int = _CHUNK_ATTEMPTS) -> Any:
|
|
248
|
+
if attempts < 1:
|
|
249
|
+
raise ValueError("attempts must be at least 1")
|
|
237
250
|
for i in range(attempts):
|
|
238
251
|
try:
|
|
239
252
|
return fn(*args)
|
|
240
|
-
except
|
|
253
|
+
except (api.ApiError, ConnectionError, Timeout) as e:
|
|
241
254
|
status_code = getattr(getattr(e, "response", None), "status_code", None)
|
|
242
|
-
if status_code
|
|
255
|
+
if isinstance(e, api.ApiError) and status_code not in {
|
|
256
|
+
429,
|
|
257
|
+
500,
|
|
258
|
+
502,
|
|
259
|
+
503,
|
|
260
|
+
504,
|
|
261
|
+
}:
|
|
243
262
|
raise
|
|
244
263
|
if i == attempts - 1:
|
|
245
264
|
raise
|
|
246
|
-
|
|
265
|
+
logger.warning(f"chunk attempt {i + 1}/{attempts} failed: {e}")
|
|
247
266
|
|
|
248
267
|
|
|
249
268
|
def fetch_timeseries_chunks(
|
|
@@ -254,7 +273,7 @@ def fetch_timeseries_chunks(
|
|
|
254
273
|
max_workers: int,
|
|
255
274
|
) -> List[Data]:
|
|
256
275
|
results: List[Data] = []
|
|
257
|
-
errors:
|
|
276
|
+
errors: list[tuple[str, Exception]] = []
|
|
258
277
|
|
|
259
278
|
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
260
279
|
future_to_chunk = {
|
|
@@ -278,14 +297,15 @@ def fetch_timeseries_chunks(
|
|
|
278
297
|
error_msg = (
|
|
279
298
|
f"Failed to fetch data from {chunk_start} to {chunk_end}: {e}"
|
|
280
299
|
)
|
|
281
|
-
|
|
282
|
-
errors.append(
|
|
300
|
+
logger.debug(error_msg)
|
|
301
|
+
errors.append(
|
|
302
|
+
(f"Failed to fetch data from {chunk_start} to {chunk_end}", e)
|
|
303
|
+
)
|
|
283
304
|
|
|
284
305
|
if errors:
|
|
285
|
-
raise
|
|
286
|
-
f"{len(errors)} of {len(chunks)} chunk(s) failed to fetch
|
|
287
|
-
|
|
288
|
-
)
|
|
306
|
+
raise api.BatchError(
|
|
307
|
+
f"{len(errors)} of {len(chunks)} chunk(s) failed to fetch:", errors
|
|
308
|
+
) from errors[0][1]
|
|
289
309
|
|
|
290
310
|
return results
|
|
291
311
|
|
|
@@ -442,25 +462,14 @@ def get_timeseries(
|
|
|
442
462
|
|
|
443
463
|
# grab extents if begin is before CWMS DB were implemented to prevent empty queries outside of extents
|
|
444
464
|
if begin < datetime(2014, 1, 1, tzinfo=timezone.utc) and multithread:
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
f"Requested begin was before any data in this timeseries. Reseting to {begin}"
|
|
452
|
-
)
|
|
453
|
-
except Exception as e:
|
|
454
|
-
# If getting extents fails, fall back to single-threaded mode
|
|
455
|
-
logging.debug(
|
|
456
|
-
f"Could not retrieve time series extents ({e}). Falling back to single-threaded mode."
|
|
465
|
+
begin_extent, _, _ = get_ts_extents(ts_id=ts_id, office_id=office_id)
|
|
466
|
+
# replace begin with begin extent if outside extents
|
|
467
|
+
if begin < begin_extent:
|
|
468
|
+
begin = begin_extent
|
|
469
|
+
logger.debug(
|
|
470
|
+
f"Requested begin was before any data in this timeseries. Reseting to {begin}"
|
|
457
471
|
)
|
|
458
472
|
|
|
459
|
-
response = api.get_with_paging(
|
|
460
|
-
selector=selector, endpoint=endpoint, params=params
|
|
461
|
-
)
|
|
462
|
-
return Data(response, selector=selector)
|
|
463
|
-
|
|
464
473
|
chunk_size = (
|
|
465
474
|
timedelta(days=max_days_per_chunk)
|
|
466
475
|
if max_days_per_chunk is not None
|
|
@@ -478,7 +487,7 @@ def get_timeseries(
|
|
|
478
487
|
)
|
|
479
488
|
return Data(response, selector=selector)
|
|
480
489
|
else:
|
|
481
|
-
|
|
490
|
+
logger.debug(
|
|
482
491
|
f"Fetching {len(chunks)} chunks of timeseries data with {max_workers} threads"
|
|
483
492
|
)
|
|
484
493
|
# fetch the data
|
|
@@ -659,7 +668,7 @@ def store_multi_timeseries_df(
|
|
|
659
668
|
ts_data_all["ts_id"].astype(str) + ":" + ts_data_all["version_date"].astype(str)
|
|
660
669
|
).unique()
|
|
661
670
|
|
|
662
|
-
errors:
|
|
671
|
+
errors: list[tuple[str, Exception]] = []
|
|
663
672
|
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
664
673
|
futures = {}
|
|
665
674
|
for unique_tsid in unique_tsids:
|
|
@@ -685,12 +694,12 @@ def store_multi_timeseries_df(
|
|
|
685
694
|
try:
|
|
686
695
|
future.result()
|
|
687
696
|
except Exception as e:
|
|
688
|
-
errors.append(
|
|
697
|
+
errors.append((str(futures[future]), e))
|
|
689
698
|
|
|
690
699
|
if errors:
|
|
691
|
-
raise
|
|
692
|
-
f"{len(errors)} time series failed to store
|
|
693
|
-
)
|
|
700
|
+
raise api.BatchError(
|
|
701
|
+
f"{len(errors)} time series failed to store:", errors
|
|
702
|
+
) from errors[0][1]
|
|
694
703
|
|
|
695
704
|
|
|
696
705
|
def chunk_timeseries_data(
|
|
@@ -790,13 +799,13 @@ def store_timeseries(
|
|
|
790
799
|
_call_with_retry(api.post, endpoint, chunks[0], params)
|
|
791
800
|
remaining_chunks = chunks[1:]
|
|
792
801
|
actual_workers = min(max_workers, len(remaining_chunks))
|
|
793
|
-
|
|
802
|
+
logger.debug(
|
|
794
803
|
f"Storing {len(chunks)} chunks of timeseries data with {actual_workers} threads"
|
|
795
804
|
)
|
|
796
805
|
|
|
797
806
|
# Store chunks concurrently
|
|
798
807
|
responses: List[Dict[str, Any]] = []
|
|
799
|
-
errors:
|
|
808
|
+
errors: list[tuple[str, Exception]] = []
|
|
800
809
|
|
|
801
810
|
with concurrent.futures.ThreadPoolExecutor(max_workers=actual_workers) as executor:
|
|
802
811
|
future_to_chunk = {
|
|
@@ -812,15 +821,16 @@ def store_timeseries(
|
|
|
812
821
|
start_time = chunk["values"][0][0]
|
|
813
822
|
end_time = chunk["values"][-1][0]
|
|
814
823
|
error_msg = f"Error storing chunk from {start_time} to {end_time}: {e}"
|
|
815
|
-
|
|
816
|
-
errors.append(
|
|
824
|
+
logger.debug(error_msg)
|
|
825
|
+
errors.append(
|
|
826
|
+
(f"Error storing chunk from {start_time} to {end_time}", e)
|
|
827
|
+
)
|
|
817
828
|
responses.append({"error": error_msg})
|
|
818
829
|
|
|
819
830
|
if errors:
|
|
820
|
-
raise
|
|
821
|
-
f"{len(errors)} of {len(chunks)} chunk(s) failed to store
|
|
822
|
-
|
|
823
|
-
)
|
|
831
|
+
raise api.BatchError(
|
|
832
|
+
f"{len(errors)} of {len(chunks)} chunk(s) failed to store:", errors
|
|
833
|
+
) from errors[0][1]
|
|
824
834
|
|
|
825
835
|
return
|
|
826
836
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import json
|
|
2
1
|
from typing import Any, List, Optional
|
|
3
2
|
|
|
4
3
|
import cwms.api as api
|
|
@@ -14,7 +13,7 @@ def _raise_user_management_error(error: api.ApiError, action: str) -> None:
|
|
|
14
13
|
"are not authorized for user-management access or are missing the "
|
|
15
14
|
f"required role assignment. CDA responded with 403 {response_hint}."
|
|
16
15
|
)
|
|
17
|
-
raise api.PermissionError(error.response, message) from
|
|
16
|
+
raise api.PermissionError(error.response, message) from error
|
|
18
17
|
raise error
|
|
19
18
|
|
|
20
19
|
|
|
@@ -110,7 +109,7 @@ def get_user(user_name: str) -> dict[str, Any]:
|
|
|
110
109
|
if status_code == 404:
|
|
111
110
|
raise api.NotFoundError(
|
|
112
111
|
error.response, f"User '{user_name}' was not found."
|
|
113
|
-
) from
|
|
112
|
+
) from error
|
|
114
113
|
if status_code == 403:
|
|
115
114
|
_raise_user_management_error(error, f"User '{user_name}' retrieval")
|
|
116
115
|
raise
|
|
@@ -153,15 +152,10 @@ def delete_user_roles(user_name: str, office_id: str, roles: List[str]) -> None:
|
|
|
153
152
|
raise ValueError("Delete user roles requires a roles list")
|
|
154
153
|
|
|
155
154
|
endpoint = f"user/{user_name}/roles/{office_id}"
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
) as response:
|
|
161
|
-
if not response.ok:
|
|
162
|
-
_raise_user_management_error(
|
|
163
|
-
api.ApiError(response), f"User '{user_name}' role deletion"
|
|
164
|
-
)
|
|
155
|
+
try:
|
|
156
|
+
api.delete(endpoint, data=roles)
|
|
157
|
+
except api.ApiError as error:
|
|
158
|
+
_raise_user_management_error(error, f"User '{user_name}' role deletion")
|
|
165
159
|
|
|
166
160
|
|
|
167
161
|
def update_user(user_name: str, office_id: str, roles: List[str]) -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|