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.
Files changed (40) hide show
  1. {cwms_python-1.0.0 → cwms_python-1.0.3}/PKG-INFO +1 -1
  2. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/__init__.py +1 -0
  3. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/api.py +45 -13
  4. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/outlets/outlets.py +1 -1
  5. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/timeseries/timeseries.py +40 -3
  6. cwms_python-1.0.3/cwms/users/users.py +155 -0
  7. {cwms_python-1.0.0 → cwms_python-1.0.3}/pyproject.toml +1 -1
  8. {cwms_python-1.0.0 → cwms_python-1.0.3}/LICENSE +0 -0
  9. {cwms_python-1.0.0 → cwms_python-1.0.3}/README.md +0 -0
  10. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/catalog/blobs.py +0 -0
  11. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/catalog/catalog.py +0 -0
  12. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/catalog/clobs.py +0 -0
  13. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/cwms_types.py +0 -0
  14. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/forecast/forecast_instance.py +0 -0
  15. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/forecast/forecast_spec.py +0 -0
  16. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/levels/location_levels.py +0 -0
  17. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/levels/specified_levels.py +0 -0
  18. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/locations/gate_changes.py +0 -0
  19. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/locations/location_groups.py +0 -0
  20. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/locations/physical_locations.py +0 -0
  21. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/measurements/measurements.py +0 -0
  22. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/outlets/virtual_outlets.py +0 -0
  23. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/projects/project_lock_rights.py +0 -0
  24. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/projects/project_locks.py +0 -0
  25. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/projects/projects.py +0 -0
  26. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/projects/water_supply/accounting.py +0 -0
  27. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/ratings/ratings.py +0 -0
  28. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/ratings/ratings_spec.py +0 -0
  29. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/ratings/ratings_template.py +0 -0
  30. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/standard_text/standard_text.py +0 -0
  31. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/timeseries/timeseries_bin.py +0 -0
  32. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/timeseries/timeseries_group.py +0 -0
  33. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/timeseries/timeseries_identifier.py +0 -0
  34. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/timeseries/timeseries_profile.py +0 -0
  35. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/timeseries/timeseries_profile_instance.py +0 -0
  36. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/timeseries/timeseries_profile_parser.py +0 -0
  37. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/timeseries/timeseries_txt.py +0 -0
  38. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/turbines/turbines.py +0 -0
  39. {cwms_python-1.0.0 → cwms_python-1.0.3}/cwms/utils/__init__.py +0 -0
  40. {cwms_python-1.0.0 → 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.0
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")
@@ -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
- This class is a light wrapper around a `requests.Response` object. Its primary purpose
76
- is to generate an error message that includes the request URL and provide additional
77
- information to the user to help them resolve the error.
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
- if hint := self.hint():
99
+ hint = self.hint()
100
+ if hint:
95
101
  message += f" {hint}"
96
102
 
97
- if content := self.response.content:
98
- message += f" {content.decode('utf8')}"
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 message with additional information on how to resolve the error."""
118
+ """Return a short hint based on HTTP status code."""
119
+ status = getattr(self.response, "status_code", None)
104
120
 
105
- if self.response.status_code == 400:
121
+ if status == 429:
122
+ return "Too many requests made."
123
+ if status == 400:
106
124
  return "Check that your parameters are correct."
107
- elif self.response.status_code == 404:
125
+ if status == 404:
108
126
  return "May be the result of an empty query."
109
- else:
110
- return ""
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:
@@ -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([])))
@@ -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, # The chunk of data to store
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")
@@ -2,7 +2,7 @@
2
2
  name = "cwms-python"
3
3
  repository = "https://github.com/HydrologicEngineeringCenter/cwms-python"
4
4
 
5
- version = "1.0.0"
5
+ version = "1.0.3"
6
6
 
7
7
 
8
8
  packages = [
File without changes
File without changes