cwms-python 1.0.1__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.1 → cwms_python-1.0.3}/PKG-INFO +1 -1
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/__init__.py +1 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/api.py +13 -2
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/outlets/outlets.py +1 -1
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/timeseries/timeseries.py +38 -1
- cwms_python-1.0.3/cwms/users/users.py +155 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/pyproject.toml +1 -1
- {cwms_python-1.0.1 → cwms_python-1.0.3}/LICENSE +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/README.md +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/catalog/blobs.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/catalog/catalog.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/catalog/clobs.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/cwms_types.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/forecast/forecast_instance.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/forecast/forecast_spec.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/levels/location_levels.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/levels/specified_levels.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/locations/gate_changes.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/locations/location_groups.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/locations/physical_locations.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/measurements/measurements.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/outlets/virtual_outlets.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/projects/project_lock_rights.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/projects/project_locks.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/projects/projects.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/projects/water_supply/accounting.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/ratings/ratings.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/ratings/ratings_spec.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/ratings/ratings_template.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/standard_text/standard_text.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/timeseries/timeseries_bin.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/timeseries/timeseries_group.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/timeseries/timeseries_identifier.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/timeseries/timeseries_profile.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/timeseries/timeseries_profile_instance.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/timeseries/timeseries_profile_parser.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/timeseries/timeseries_txt.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/turbines/turbines.py +0 -0
- {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/utils/__init__.py +0 -0
- {cwms_python-1.0.1 → 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")
|
|
@@ -78,10 +78,14 @@ class ApiError(Exception):
|
|
|
78
78
|
a concise, single-line error message with an optional hint.
|
|
79
79
|
"""
|
|
80
80
|
|
|
81
|
-
def __init__(self, response: Response):
|
|
81
|
+
def __init__(self, response: Response, message: Optional[str] = None):
|
|
82
82
|
self.response = response
|
|
83
|
+
self.message = message
|
|
83
84
|
|
|
84
85
|
def __str__(self) -> str:
|
|
86
|
+
if self.message:
|
|
87
|
+
return self.message
|
|
88
|
+
|
|
85
89
|
# Include the request URL in the error message.
|
|
86
90
|
message = f"CWMS API Error ({self.response.url})"
|
|
87
91
|
|
|
@@ -125,6 +129,14 @@ class ApiError(Exception):
|
|
|
125
129
|
return ""
|
|
126
130
|
|
|
127
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."""
|
|
138
|
+
|
|
139
|
+
|
|
128
140
|
def init_session(
|
|
129
141
|
*,
|
|
130
142
|
api_root: Optional[str] = None,
|
|
@@ -160,7 +172,6 @@ def init_session(
|
|
|
160
172
|
if api_key:
|
|
161
173
|
if api_key.startswith("apikey "):
|
|
162
174
|
api_key = api_key.replace("apikey ", "")
|
|
163
|
-
logging.debug(f"Setting authorization key: api_key={api_key}")
|
|
164
175
|
SESSION.headers.update({"Authorization": "apikey " + api_key})
|
|
165
176
|
|
|
166
177
|
return SESSION
|
|
@@ -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([])))
|
|
@@ -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
|