cwms-python 1.0.0__tar.gz → 1.0.3__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.0.0 → cwms_python-1.0.3}/PKG-INFO +1 -1
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/__init__.py +1 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/api.py +45 -13
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/outlets/outlets.py +1 -1
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/timeseries/timeseries.py +40 -3
- cwms_python-1.0.3/cwms/users/users.py +155 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/pyproject.toml +1 -1
- {cwms_python-1.0.0 → cwms_python-1.0.3}/LICENSE +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/README.md +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/catalog/blobs.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/catalog/catalog.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/catalog/clobs.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/cwms_types.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/forecast/forecast_instance.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/forecast/forecast_spec.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/levels/location_levels.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/levels/specified_levels.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/locations/gate_changes.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/locations/location_groups.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/locations/physical_locations.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/measurements/measurements.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/outlets/virtual_outlets.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/projects/project_lock_rights.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/projects/project_locks.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/projects/projects.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/projects/water_supply/accounting.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/ratings/ratings.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/ratings/ratings_spec.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/ratings/ratings_template.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/standard_text/standard_text.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/timeseries/timeseries_bin.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/timeseries/timeseries_group.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/timeseries/timeseries_identifier.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/timeseries/timeseries_profile.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/timeseries/timeseries_profile_instance.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/timeseries/timeseries_profile_parser.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/timeseries/timeseries_txt.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/turbines/turbines.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/utils/__init__.py +0 -0
- {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/utils/checks.py +0 -0
|
@@ -31,6 +31,7 @@ from cwms.timeseries.timeseries_profile_instance import *
|
|
|
31
31
|
from cwms.timeseries.timeseries_profile_parser import *
|
|
32
32
|
from cwms.timeseries.timeseries_txt import *
|
|
33
33
|
from cwms.turbines.turbines import *
|
|
34
|
+
from cwms.users.users import *
|
|
34
35
|
|
|
35
36
|
try:
|
|
36
37
|
__version__ = version("cwms-python")
|
|
@@ -29,6 +29,7 @@ the error.
|
|
|
29
29
|
import base64
|
|
30
30
|
import json
|
|
31
31
|
import logging
|
|
32
|
+
from http import HTTPStatus
|
|
32
33
|
from json import JSONDecodeError
|
|
33
34
|
from typing import Any, Optional, cast
|
|
34
35
|
|
|
@@ -72,15 +73,19 @@ class InvalidVersion(Exception):
|
|
|
72
73
|
class ApiError(Exception):
|
|
73
74
|
"""CWMS Data Api Error.
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
Light wrapper around a response-like object (e.g., requests.Response or a
|
|
77
|
+
test stub with url, status_code, reason, and content attributes). Produces
|
|
78
|
+
a concise, single-line error message with an optional hint.
|
|
78
79
|
"""
|
|
79
80
|
|
|
80
|
-
def __init__(self, response: Response):
|
|
81
|
+
def __init__(self, response: Response, message: Optional[str] = None):
|
|
81
82
|
self.response = response
|
|
83
|
+
self.message = message
|
|
82
84
|
|
|
83
85
|
def __str__(self) -> str:
|
|
86
|
+
if self.message:
|
|
87
|
+
return self.message
|
|
88
|
+
|
|
84
89
|
# Include the request URL in the error message.
|
|
85
90
|
message = f"CWMS API Error ({self.response.url})"
|
|
86
91
|
|
|
@@ -91,23 +96,45 @@ class ApiError(Exception):
|
|
|
91
96
|
message += "."
|
|
92
97
|
|
|
93
98
|
# Add additional context to help the user resolve the issue.
|
|
94
|
-
|
|
99
|
+
hint = self.hint()
|
|
100
|
+
if hint:
|
|
95
101
|
message += f" {hint}"
|
|
96
102
|
|
|
97
|
-
|
|
98
|
-
|
|
103
|
+
# Optional content (decoded if bytes)
|
|
104
|
+
content = getattr(self.response, "content", None)
|
|
105
|
+
if content:
|
|
106
|
+
if isinstance(content, bytes):
|
|
107
|
+
try:
|
|
108
|
+
text = content.decode("utf-8", errors="replace")
|
|
109
|
+
except Exception:
|
|
110
|
+
text = repr(content)
|
|
111
|
+
else:
|
|
112
|
+
text = str(content)
|
|
113
|
+
message += f" {text}"
|
|
99
114
|
|
|
100
115
|
return message
|
|
101
116
|
|
|
102
117
|
def hint(self) -> str:
|
|
103
|
-
"""Return a
|
|
118
|
+
"""Return a short hint based on HTTP status code."""
|
|
119
|
+
status = getattr(self.response, "status_code", None)
|
|
104
120
|
|
|
105
|
-
if
|
|
121
|
+
if status == 429:
|
|
122
|
+
return "Too many requests made."
|
|
123
|
+
if status == 400:
|
|
106
124
|
return "Check that your parameters are correct."
|
|
107
|
-
|
|
125
|
+
if status == 404:
|
|
108
126
|
return "May be the result of an empty query."
|
|
109
|
-
|
|
110
|
-
|
|
127
|
+
|
|
128
|
+
# No hint for other codes
|
|
129
|
+
return ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class NotFoundError(ApiError):
|
|
133
|
+
"""Raised when a requested CDA resource does not exist."""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class PermissionError(ApiError):
|
|
137
|
+
"""Raised when the CDA request is not authorized for the current caller."""
|
|
111
138
|
|
|
112
139
|
|
|
113
140
|
def init_session(
|
|
@@ -145,7 +172,6 @@ def init_session(
|
|
|
145
172
|
if api_key:
|
|
146
173
|
if api_key.startswith("apikey "):
|
|
147
174
|
api_key = api_key.replace("apikey ", "")
|
|
148
|
-
logging.debug(f"Setting authorization key: api_key={api_key}")
|
|
149
175
|
SESSION.headers.update({"Authorization": "apikey " + api_key})
|
|
150
176
|
|
|
151
177
|
return SESSION
|
|
@@ -227,6 +253,12 @@ def _process_response(response: Response) -> Any:
|
|
|
227
253
|
return response.text
|
|
228
254
|
if content_type.startswith("image/"):
|
|
229
255
|
return base64.b64encode(response.content).decode("utf-8")
|
|
256
|
+
# Handle excel content types
|
|
257
|
+
if content_type in [
|
|
258
|
+
"application/vnd.ms-excel",
|
|
259
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
260
|
+
]:
|
|
261
|
+
return response.content
|
|
230
262
|
# Fallback for remaining content types
|
|
231
263
|
return response.content.decode("utf-8")
|
|
232
264
|
except JSONDecodeError as error:
|
|
@@ -228,8 +228,13 @@ def combine_timeseries_results(results: List[Data]) -> Data:
|
|
|
228
228
|
combined_json["end"] = combined_df["date-time"].max().isoformat()
|
|
229
229
|
combined_json["total"] = len(combined_df)
|
|
230
230
|
|
|
231
|
+
combined_df["date-time"] = combined_df["date-time"].apply(
|
|
232
|
+
lambda x: int(pd.Timestamp(x).timestamp() * 1000)
|
|
233
|
+
)
|
|
234
|
+
combined_df["date-time"] = combined_df["date-time"].astype("Int64")
|
|
235
|
+
combined_df = combined_df.reindex(columns=["date-time", "value", "quality-code"])
|
|
231
236
|
# Update the "values" key in the JSON to include the combined data
|
|
232
|
-
combined_json["values"] = combined_df.
|
|
237
|
+
combined_json["values"] = combined_df.values.tolist()
|
|
233
238
|
|
|
234
239
|
# Return a new cwms Data object with the combined DataFrame and updated metadata
|
|
235
240
|
return Data(combined_json, selector="values")
|
|
@@ -455,6 +460,32 @@ def store_multi_timeseries_df(
|
|
|
455
460
|
office_id: str,
|
|
456
461
|
max_workers: Optional[int] = 30,
|
|
457
462
|
) -> None:
|
|
463
|
+
"""stored mulitple timeseries from a dataframe. The dataframe must be a metled dataframe with columns
|
|
464
|
+
for date-time, value, quality-code(optional), ts_id, units, and version_date(optional). The dataframe will
|
|
465
|
+
be grouped by ts_id and version_date and each group will be posted as a separate timeseries using the store_timeseries
|
|
466
|
+
function. If version_date column is not included then all data will be stored as unversioned data. If version_date
|
|
467
|
+
column is included then data will be grouped by ts_id and version_date and stored as versioned timeseries with the
|
|
468
|
+
version date specified in the version_date column.
|
|
469
|
+
|
|
470
|
+
Parameters
|
|
471
|
+
----------
|
|
472
|
+
data: dataframe
|
|
473
|
+
Time Series data to be stored. Dataframe must be melted with columns for date-time, value, quality-code(optional),
|
|
474
|
+
ts_id, units, and version_date(optional).
|
|
475
|
+
date-time value quality-code ts_id units version_date
|
|
476
|
+
0 2023-12-20T14:45:00.000-05:00 93.1 0 OMA.Stage.Inst.6Hours.0.Fcst-MRBWM-GRFT ft 2024-04-22 07:00:00-05:00
|
|
477
|
+
1 2023-12-20T15:00:00.000-05:00 99.8 0 OMA.Stage.Inst.6Hours.0.Fcst-MRBWM-GRFT ft 2024-04-22 07:00:00-05:00
|
|
478
|
+
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
|
+
office_id: string
|
|
480
|
+
The owning office of the time series(s).
|
|
481
|
+
max_workers: Int, Optional, default is None
|
|
482
|
+
It is a number of Threads aka size of pool in concurrent.futures.ThreadPoolExecutor.
|
|
483
|
+
|
|
484
|
+
Returns
|
|
485
|
+
-------
|
|
486
|
+
None
|
|
487
|
+
"""
|
|
488
|
+
|
|
458
489
|
def store_ts_ids(
|
|
459
490
|
data: pd.DataFrame,
|
|
460
491
|
ts_id: str,
|
|
@@ -476,6 +507,12 @@ def store_multi_timeseries_df(
|
|
|
476
507
|
print(f"Error processing {ts_id}: {e}")
|
|
477
508
|
return None
|
|
478
509
|
|
|
510
|
+
required_columns = ["date-time", "value", "ts_id", "units"]
|
|
511
|
+
for col in required_columns:
|
|
512
|
+
if col not in data.columns:
|
|
513
|
+
raise TypeError(
|
|
514
|
+
f"{col} is a required column in data when posting multiple timeseries from a dataframe. Make sure you are using a melted dataframe with columns for date-time, value, quality-code(optional), ts_id, units, and version_date(optional)."
|
|
515
|
+
)
|
|
479
516
|
ts_data_all = data.copy()
|
|
480
517
|
if "version_date" not in ts_data_all.columns:
|
|
481
518
|
ts_data_all = ts_data_all.assign(version_date=pd.to_datetime(pd.Series([])))
|
|
@@ -606,8 +643,8 @@ def store_timeseries(
|
|
|
606
643
|
for chunk in chunks:
|
|
607
644
|
future = executor.submit(
|
|
608
645
|
api.post, # The function to execute
|
|
609
|
-
endpoint,
|
|
610
|
-
data
|
|
646
|
+
endpoint,
|
|
647
|
+
chunk, # The chunk of data to store
|
|
611
648
|
params,
|
|
612
649
|
)
|
|
613
650
|
futures.append(future) # Add the future to the list
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, List, Optional
|
|
3
|
+
|
|
4
|
+
import cwms.api as api
|
|
5
|
+
from cwms.cwms_types import Data
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _raise_user_management_error(error: api.ApiError, action: str) -> None:
|
|
9
|
+
status_code = getattr(error.response, "status_code", None)
|
|
10
|
+
if status_code == 403:
|
|
11
|
+
response_hint = getattr(error.response, "reason", None) or "Forbidden"
|
|
12
|
+
message = (
|
|
13
|
+
f"{action} could not be completed because the current credentials "
|
|
14
|
+
"are not authorized for user-management access or are missing the "
|
|
15
|
+
f"required role assignment. CDA responded with 403 {response_hint}."
|
|
16
|
+
)
|
|
17
|
+
raise api.PermissionError(error.response, message) from None
|
|
18
|
+
raise error
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_roles() -> List[str]:
|
|
22
|
+
"""Retrieve all available user-management roles."""
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
response = api.get("roles", api_version=1)
|
|
26
|
+
except api.ApiError as error:
|
|
27
|
+
_raise_user_management_error(error, "User role lookup")
|
|
28
|
+
return list(response)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_user_profile() -> dict[str, Any]:
|
|
32
|
+
"""Retrieve the profile for the currently authenticated user."""
|
|
33
|
+
|
|
34
|
+
response = api.get("user/profile", api_version=1)
|
|
35
|
+
return dict(response)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_users(
|
|
39
|
+
office_id: Optional[str] = None,
|
|
40
|
+
page: Optional[str] = None,
|
|
41
|
+
page_size: Optional[int] = None,
|
|
42
|
+
) -> Data:
|
|
43
|
+
"""Retrieve users with optional office and paging filters."""
|
|
44
|
+
|
|
45
|
+
params = {"office": office_id, "page": page, "page-size": page_size}
|
|
46
|
+
try:
|
|
47
|
+
response = api.get("users", params=params, api_version=1)
|
|
48
|
+
except api.ApiError as error:
|
|
49
|
+
_raise_user_management_error(error, "User list lookup")
|
|
50
|
+
return Data(response, selector="users")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_user(user_name: str) -> dict[str, Any]:
|
|
54
|
+
"""Retrieve a single user by user name."""
|
|
55
|
+
|
|
56
|
+
if not user_name:
|
|
57
|
+
raise ValueError("Get user requires a user name")
|
|
58
|
+
try:
|
|
59
|
+
response = api.get(f"users/{user_name}", api_version=1)
|
|
60
|
+
except api.ApiError as error:
|
|
61
|
+
status_code = getattr(error.response, "status_code", None)
|
|
62
|
+
if status_code == 404:
|
|
63
|
+
raise api.NotFoundError(
|
|
64
|
+
error.response, f"User '{user_name}' was not found."
|
|
65
|
+
) from None
|
|
66
|
+
if status_code == 403:
|
|
67
|
+
_raise_user_management_error(error, f"User '{user_name}' retrieval")
|
|
68
|
+
raise
|
|
69
|
+
return dict(response)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def store_user(user_name: str, office_id: str, roles: List[str]) -> None:
|
|
73
|
+
"""Create a user role assignment for an office.
|
|
74
|
+
|
|
75
|
+
Notes
|
|
76
|
+
-----
|
|
77
|
+
The CDA User Management API creates/manages user access through role assignment
|
|
78
|
+
at `/user/{user-name}/roles/{office-id}`.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
if not user_name:
|
|
82
|
+
raise ValueError("Store user requires a user name")
|
|
83
|
+
if not office_id:
|
|
84
|
+
raise ValueError("Store user requires an office id")
|
|
85
|
+
if not roles:
|
|
86
|
+
raise ValueError("Store user requires a roles list")
|
|
87
|
+
|
|
88
|
+
endpoint = f"user/{user_name}/roles/{office_id}"
|
|
89
|
+
try:
|
|
90
|
+
api.post(endpoint, roles)
|
|
91
|
+
except api.ApiError as error:
|
|
92
|
+
_raise_user_management_error(
|
|
93
|
+
error, f"User '{user_name}' role assignment update"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def delete_user_roles(user_name: str, office_id: str, roles: List[str]) -> None:
|
|
98
|
+
"""Delete user role assignments for an office."""
|
|
99
|
+
|
|
100
|
+
if not user_name:
|
|
101
|
+
raise ValueError("Delete user roles requires a user name")
|
|
102
|
+
if not office_id:
|
|
103
|
+
raise ValueError("Delete user roles requires an office id")
|
|
104
|
+
if roles is None:
|
|
105
|
+
raise ValueError("Delete user roles requires a roles list")
|
|
106
|
+
|
|
107
|
+
endpoint = f"user/{user_name}/roles/{office_id}"
|
|
108
|
+
headers = {"accept": "*/*", "Content-Type": api.api_version_text(api.API_VERSION)}
|
|
109
|
+
# TODO: Delete does not currently support a body in the api module. Use SESSION directly
|
|
110
|
+
with api.SESSION.delete(
|
|
111
|
+
endpoint, headers=headers, data=json.dumps(roles)
|
|
112
|
+
) as response:
|
|
113
|
+
if not response.ok:
|
|
114
|
+
_raise_user_management_error(
|
|
115
|
+
api.ApiError(response), f"User '{user_name}' role deletion"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def update_user(user_name: str, office_id: str, roles: List[str]) -> None:
|
|
120
|
+
"""Update a user's roles for an office by replacing the current role set."""
|
|
121
|
+
|
|
122
|
+
if not user_name:
|
|
123
|
+
raise ValueError("Update user requires a user name")
|
|
124
|
+
if not office_id:
|
|
125
|
+
raise ValueError("Update user requires an office id")
|
|
126
|
+
if not roles:
|
|
127
|
+
raise ValueError("Update user requires a roles list")
|
|
128
|
+
|
|
129
|
+
endpoint = f"user/{user_name}/roles/{office_id}"
|
|
130
|
+
user = get_user(user_name)
|
|
131
|
+
|
|
132
|
+
roles_by_office = user.get("roles")
|
|
133
|
+
if isinstance(roles_by_office, dict):
|
|
134
|
+
existing_roles = roles_by_office.get(office_id, [])
|
|
135
|
+
elif isinstance(roles_by_office, list):
|
|
136
|
+
existing_roles = roles_by_office
|
|
137
|
+
else:
|
|
138
|
+
existing_roles = []
|
|
139
|
+
|
|
140
|
+
if not isinstance(existing_roles, list):
|
|
141
|
+
existing_roles = []
|
|
142
|
+
|
|
143
|
+
desired_roles = sorted(set(roles))
|
|
144
|
+
current_roles = sorted(set(existing_roles))
|
|
145
|
+
# Determine roles to add and remove
|
|
146
|
+
roles_to_remove = [role for role in current_roles if role not in desired_roles]
|
|
147
|
+
roles_to_add = [role for role in desired_roles if role not in current_roles]
|
|
148
|
+
|
|
149
|
+
if roles_to_remove:
|
|
150
|
+
delete_user_roles(user_name, office_id, roles_to_remove)
|
|
151
|
+
if roles_to_add:
|
|
152
|
+
try:
|
|
153
|
+
api.post(endpoint, roles_to_add)
|
|
154
|
+
except api.ApiError as error:
|
|
155
|
+
_raise_user_management_error(error, f"User '{user_name}' role replacement")
|
|
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
|