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.
Files changed (40) hide show
  1. {cwms_python-1.0.1 → cwms_python-1.0.3}/PKG-INFO +1 -1
  2. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/__init__.py +1 -0
  3. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/api.py +13 -2
  4. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/outlets/outlets.py +1 -1
  5. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/timeseries/timeseries.py +38 -1
  6. cwms_python-1.0.3/cwms/users/users.py +155 -0
  7. {cwms_python-1.0.1 → cwms_python-1.0.3}/pyproject.toml +1 -1
  8. {cwms_python-1.0.1 → cwms_python-1.0.3}/LICENSE +0 -0
  9. {cwms_python-1.0.1 → cwms_python-1.0.3}/README.md +0 -0
  10. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/catalog/blobs.py +0 -0
  11. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/catalog/catalog.py +0 -0
  12. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/catalog/clobs.py +0 -0
  13. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/cwms_types.py +0 -0
  14. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/forecast/forecast_instance.py +0 -0
  15. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/forecast/forecast_spec.py +0 -0
  16. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/levels/location_levels.py +0 -0
  17. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/levels/specified_levels.py +0 -0
  18. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/locations/gate_changes.py +0 -0
  19. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/locations/location_groups.py +0 -0
  20. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/locations/physical_locations.py +0 -0
  21. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/measurements/measurements.py +0 -0
  22. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/outlets/virtual_outlets.py +0 -0
  23. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/projects/project_lock_rights.py +0 -0
  24. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/projects/project_locks.py +0 -0
  25. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/projects/projects.py +0 -0
  26. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/projects/water_supply/accounting.py +0 -0
  27. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/ratings/ratings.py +0 -0
  28. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/ratings/ratings_spec.py +0 -0
  29. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/ratings/ratings_template.py +0 -0
  30. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/standard_text/standard_text.py +0 -0
  31. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/timeseries/timeseries_bin.py +0 -0
  32. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/timeseries/timeseries_group.py +0 -0
  33. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/timeseries/timeseries_identifier.py +0 -0
  34. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/timeseries/timeseries_profile.py +0 -0
  35. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/timeseries/timeseries_profile_instance.py +0 -0
  36. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/timeseries/timeseries_profile_parser.py +0 -0
  37. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/timeseries/timeseries_txt.py +0 -0
  38. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/turbines/turbines.py +0 -0
  39. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/utils/__init__.py +0 -0
  40. {cwms_python-1.0.1 → cwms_python-1.0.3}/cwms/utils/checks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cwms-python
3
- Version: 1.0.1
3
+ Version: 1.0.3
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
@@ -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
@@ -41,7 +41,7 @@ def get_outlet(office_id: str, name: str) -> Data:
41
41
 
42
42
  endpoint = f"projects/outlets/{name}"
43
43
  params = {"office": office_id}
44
- response = api.get(endpoint, params)
44
+ response = api.get(endpoint, params, api_version=1)
45
45
  return Data(response)
46
46
 
47
47
 
@@ -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.to_dict(orient="records")
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")
@@ -2,7 +2,7 @@
2
2
  name = "cwms-python"
3
3
  repository = "https://github.com/HydrologicEngineeringCenter/cwms-python"
4
4
 
5
- version = "1.0.1"
5
+ version = "1.0.3"
6
6
 
7
7
 
8
8
  packages = [
File without changes
File without changes