cwms-python 0.3.0__tar.gz → 0.5.0__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 (37) hide show
  1. {cwms_python-0.3.0 → cwms_python-0.5.0}/PKG-INFO +2 -2
  2. {cwms_python-0.3.0 → cwms_python-0.5.0}/README.md +1 -1
  3. cwms_python-0.5.0/cwms/__init__.py +27 -0
  4. {cwms_python-0.3.0 → cwms_python-0.5.0}/cwms/api.py +110 -21
  5. cwms_python-0.5.0/cwms/catalog/catalog.py +128 -0
  6. cwms_python-0.5.0/cwms/cwms_types.py +107 -0
  7. cwms_python-0.5.0/cwms/forecast/forecast_instance.py +208 -0
  8. cwms_python-0.5.0/cwms/forecast/forecast_spec.py +181 -0
  9. cwms_python-0.5.0/cwms/levels/location_levels.py +221 -0
  10. cwms_python-0.5.0/cwms/levels/specified_levels.py +126 -0
  11. cwms_python-0.5.0/cwms/locations/physical_locations.py +181 -0
  12. cwms_python-0.5.0/cwms/outlets/outlets.py +195 -0
  13. cwms_python-0.5.0/cwms/outlets/virtual_outlets.py +164 -0
  14. cwms_python-0.5.0/cwms/projects/project_lock_rights.py +151 -0
  15. cwms_python-0.5.0/cwms/projects/project_locks.py +239 -0
  16. cwms_python-0.5.0/cwms/projects/projects.py +309 -0
  17. cwms_python-0.5.0/cwms/ratings/ratings.py +378 -0
  18. cwms_python-0.5.0/cwms/ratings/ratings_spec.py +154 -0
  19. cwms_python-0.5.0/cwms/ratings/ratings_template.py +148 -0
  20. cwms_python-0.5.0/cwms/standard_text/standard_text.py +201 -0
  21. cwms_python-0.5.0/cwms/timeseries/timerseries_identifier.py +135 -0
  22. cwms_python-0.5.0/cwms/timeseries/timeseries.py +397 -0
  23. {cwms_python-0.3.0 → cwms_python-0.5.0}/cwms/timeseries/timeseries_bin.py +1 -8
  24. {cwms_python-0.3.0 → cwms_python-0.5.0}/cwms/timeseries/timeseries_txt.py +1 -168
  25. {cwms_python-0.3.0 → cwms_python-0.5.0}/pyproject.toml +1 -1
  26. cwms_python-0.3.0/cwms/__init__.py +0 -13
  27. cwms_python-0.3.0/cwms/_constants.py +0 -33
  28. cwms_python-0.3.0/cwms/core.py +0 -26
  29. cwms_python-0.3.0/cwms/exceptions.py +0 -131
  30. cwms_python-0.3.0/cwms/forecast/forecast_instance.py +0 -260
  31. cwms_python-0.3.0/cwms/forecast/forecast_spec.py +0 -227
  32. cwms_python-0.3.0/cwms/levels/location_levels.py +0 -484
  33. cwms_python-0.3.0/cwms/locations/physical_locations.py +0 -47
  34. cwms_python-0.3.0/cwms/timeseries/timeseries.py +0 -208
  35. cwms_python-0.3.0/cwms/types.py +0 -67
  36. cwms_python-0.3.0/cwms/utils.py +0 -85
  37. {cwms_python-0.3.0 → cwms_python-0.5.0}/LICENSE +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cwms-python
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Corps water managerment systems (CWMS) REST API for Data Retrieval of USACE water data
5
5
  License: LICENSE
6
6
  Keywords: USACE,water data
@@ -49,7 +49,7 @@ from datetime import datetime, timedelta
49
49
 
50
50
  end = datetime.now()
51
51
  begin = end - timedelta(days = 10)
52
- data = cwms.get_timeseries(p_tsId='Some.Fully.Qualified.Ts.Id',begin = begin, end = end)
52
+ data = cwms.get_timeseries(ts_id='Some.Fully.Qualified.Ts.Id',office_id='OFFICE1' , begin = begin, end = end)
53
53
 
54
54
  #a cwms data object will be provided this object containes both the JSON as well
55
55
  #as the values converted into a dataframe
@@ -28,7 +28,7 @@ from datetime import datetime, timedelta
28
28
 
29
29
  end = datetime.now()
30
30
  begin = end - timedelta(days = 10)
31
- data = cwms.get_timeseries(p_tsId='Some.Fully.Qualified.Ts.Id',begin = begin, end = end)
31
+ data = cwms.get_timeseries(ts_id='Some.Fully.Qualified.Ts.Id',office_id='OFFICE1' , begin = begin, end = end)
32
32
 
33
33
  #a cwms data object will be provided this object containes both the JSON as well
34
34
  #as the values converted into a dataframe
@@ -0,0 +1,27 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from cwms.api import *
4
+ from cwms.catalog.catalog import *
5
+ from cwms.forecast.forecast_instance import *
6
+ from cwms.forecast.forecast_spec import *
7
+ from cwms.levels.location_levels import *
8
+ from cwms.levels.specified_levels import *
9
+ from cwms.locations.physical_locations import *
10
+ from cwms.outlets.outlets import *
11
+ from cwms.outlets.virtual_outlets import *
12
+ from cwms.projects.project_lock_rights import *
13
+ from cwms.projects.project_locks import *
14
+ from cwms.projects.projects import *
15
+ from cwms.ratings.ratings import *
16
+ from cwms.ratings.ratings_spec import *
17
+ from cwms.ratings.ratings_template import *
18
+ from cwms.standard_text.standard_text import *
19
+ from cwms.timeseries.timerseries_identifier import *
20
+ from cwms.timeseries.timeseries import *
21
+ from cwms.timeseries.timeseries_bin import *
22
+ from cwms.timeseries.timeseries_txt import *
23
+
24
+ try:
25
+ __version__ = version("cwms-python")
26
+ except PackageNotFoundError:
27
+ __version__ = "version-unknown"
@@ -29,20 +29,22 @@ the error.
29
29
  import json
30
30
  import logging
31
31
  from json import JSONDecodeError
32
- from typing import Optional, cast
32
+ from typing import Any, Optional, cast
33
33
 
34
- from requests import Response
34
+ from requests import Response, adapters
35
35
  from requests_toolbelt import sessions # type: ignore
36
36
  from requests_toolbelt.sessions import BaseUrlSession # type: ignore
37
37
 
38
- from cwms.types import JSON, RequestParams
38
+ from cwms.cwms_types import JSON, RequestParams
39
39
 
40
40
  # Specify the default API root URL and version.
41
41
  API_ROOT = "https://cwms-data.usace.army.mil/cwms-data/"
42
42
  API_VERSION = 2
43
43
 
44
- # Initialize a non-authenticated session with the default root URL.
44
+ # Initialize a non-authenticated session with the default root URL and set default pool connections.
45
45
  SESSION = sessions.BaseUrlSession(base_url=API_ROOT)
46
+ adapter = adapters.HTTPAdapter(pool_connections=100, pool_maxsize=100)
47
+ SESSION.mount("https://", adapter)
46
48
 
47
49
 
48
50
  class InvalidVersion(Exception):
@@ -91,7 +93,10 @@ class ApiError(Exception):
91
93
 
92
94
 
93
95
  def init_session(
94
- *, api_root: Optional[str] = None, api_key: Optional[str] = None
96
+ *,
97
+ api_root: Optional[str] = None,
98
+ api_key: Optional[str] = None,
99
+ pool_connections: int = 100,
95
100
  ) -> BaseUrlSession:
96
101
  """Specify a root URL and authentication key for the CWMS Data API.
97
102
 
@@ -112,7 +117,10 @@ def init_session(
112
117
  if api_root:
113
118
  logging.debug(f"Initializing root URL: api_root={api_root}")
114
119
  SESSION = sessions.BaseUrlSession(base_url=api_root)
115
-
120
+ adapter = adapters.HTTPAdapter(
121
+ pool_connections=pool_connections, pool_maxsize=pool_connections
122
+ )
123
+ SESSION.mount("https://", adapter)
116
124
  if api_key:
117
125
  logging.debug(f"Setting authorization key: api_key={api_key}")
118
126
  SESSION.headers.update({"Authorization": api_key})
@@ -150,12 +158,51 @@ def api_version_text(api_version: int) -> str:
150
158
  version = "application/json"
151
159
  elif api_version == 2:
152
160
  version = "application/json;version=2"
161
+ elif api_version == 102:
162
+ version = "application/xml;version=2"
153
163
  else:
154
164
  raise InvalidVersion(f"API version {api_version} is not supported.")
155
165
 
156
166
  return version
157
167
 
158
168
 
169
+ def get_xml(
170
+ endpoint: str,
171
+ params: Optional[RequestParams] = None,
172
+ *,
173
+ api_version: int = API_VERSION,
174
+ ) -> Any:
175
+ """Make a GET request to the CWMS Data API.
176
+
177
+ Args:
178
+ endpoint: The CDA endpoint for the record(s).
179
+ params (optional): Query parameters for the request.
180
+
181
+ Keyword Args:
182
+ api_version (optional): The CDA version to use for the request. If not specified,
183
+ the default API_VERSION will be used.
184
+
185
+ Returns:
186
+ The deserialized JSON response data.
187
+
188
+ Raises:
189
+ ApiError: If an error response is return by the API.
190
+ """
191
+
192
+ headers = {"Accept": api_version_text(api_version)}
193
+ response = SESSION.get(endpoint, params=params, headers=headers)
194
+
195
+ if response.status_code < 200 or response.status_code >= 300:
196
+ logging.error(f"CDA Error: response={response}")
197
+ raise ApiError(response)
198
+
199
+ try:
200
+ return response.content.decode("utf-8")
201
+ except JSONDecodeError as error:
202
+ logging.error(f"Error decoding CDA response as xml: {error}")
203
+ return {}
204
+
205
+
159
206
  def get(
160
207
  endpoint: str,
161
208
  params: Optional[RequestParams] = None,
@@ -181,21 +228,60 @@ def get(
181
228
 
182
229
  headers = {"Accept": api_version_text(api_version)}
183
230
  response = SESSION.get(endpoint, params=params, headers=headers)
184
-
185
- if response.status_code != 200:
231
+ if response.status_code < 200 or response.status_code >= 300:
186
232
  logging.error(f"CDA Error: response={response}")
187
233
  raise ApiError(response)
188
234
 
189
235
  try:
190
236
  return cast(JSON, response.json())
191
237
  except JSONDecodeError as error:
192
- logging.error(f"Error decoding CDA response: {error}")
238
+ logging.error(f"Error decoding CDA response as json: {error}")
193
239
  return {}
194
240
 
195
241
 
242
+ def get_with_paging(
243
+ selector: str,
244
+ endpoint: str,
245
+ params: RequestParams,
246
+ *,
247
+ api_version: int = API_VERSION,
248
+ ) -> JSON:
249
+ """Make a GET request to the CWMS Data API with paging.
250
+
251
+ Args:
252
+ endpoint: The CDA endpoint for the record(s).
253
+ selector: The json key that will be merged though each page call
254
+ params (optional): Query parameters for the request.
255
+
256
+ Keyword Args:
257
+ api_version (optional): The CDA version to use for the request. If not specified,
258
+ the default API_VERSION will be used.
259
+
260
+ Returns:
261
+ The deserialized JSON response data.
262
+
263
+ Raises:
264
+ ApiError: If an error response is return by the API.
265
+ """
266
+
267
+ first_pass = True
268
+ while (params["page"] is not None) or first_pass:
269
+ temp = get(endpoint, params, api_version=api_version)
270
+ if first_pass:
271
+ response = temp
272
+ else:
273
+ response[selector] = response[selector] + temp[selector]
274
+ if "next-page" in temp.keys():
275
+ params["page"] = temp["next-page"]
276
+ else:
277
+ params["page"] = None
278
+ first_pass = False
279
+ return response
280
+
281
+
196
282
  def post(
197
283
  endpoint: str,
198
- data: JSON,
284
+ data: Any,
199
285
  params: Optional[RequestParams] = None,
200
286
  *,
201
287
  api_version: int = API_VERSION,
@@ -221,18 +307,19 @@ def post(
221
307
  # post requires different headers than get for
222
308
  headers = {"accept": "*/*", "Content-Type": api_version_text(api_version)}
223
309
 
224
- response = SESSION.post(
225
- endpoint, params=params, headers=headers, data=json.dumps(data)
226
- )
310
+ if isinstance(data, dict):
311
+ data = json.dumps(data)
227
312
 
228
- if response.status_code != 200:
313
+ response = SESSION.post(endpoint, params=params, headers=headers, data=data)
314
+
315
+ if response.status_code < 200 or response.status_code >= 300:
229
316
  logging.error(f"CDA Error: response={response}")
230
317
  raise ApiError(response)
231
318
 
232
319
 
233
320
  def patch(
234
321
  endpoint: str,
235
- data: JSON,
322
+ data: Optional[Any] = None,
236
323
  params: Optional[RequestParams] = None,
237
324
  *,
238
325
  api_version: int = API_VERSION,
@@ -256,12 +343,14 @@ def patch(
256
343
  """
257
344
 
258
345
  headers = {"accept": "*/*", "Content-Type": api_version_text(api_version)}
346
+ if data is None:
347
+ response = SESSION.patch(endpoint, params=params, headers=headers)
348
+ else:
349
+ if isinstance(data, dict):
350
+ data = json.dumps(data)
351
+ response = SESSION.patch(endpoint, params=params, headers=headers, data=data)
259
352
 
260
- response = SESSION.patch(
261
- endpoint, params=params, headers=headers, data=json.dumps(data)
262
- )
263
-
264
- if response.status_code != 200:
353
+ if response.status_code < 200 or response.status_code >= 300:
265
354
  logging.error(f"CDA Error: response={response}")
266
355
  raise ApiError(response)
267
356
 
@@ -289,6 +378,6 @@ def delete(
289
378
  headers = {"Accept": api_version_text(api_version)}
290
379
  response = SESSION.delete(endpoint, params=params, headers=headers)
291
380
 
292
- if response.status_code != 200:
381
+ if response.status_code < 200 or response.status_code >= 300:
293
382
  logging.error(f"CDA Error: response={response}")
294
383
  raise ApiError(response)
@@ -0,0 +1,128 @@
1
+ from typing import Optional
2
+
3
+ import cwms.api as api
4
+ from cwms.cwms_types import Data
5
+
6
+
7
+ def get_locations_catalog(
8
+ office_id: str,
9
+ page: Optional[str] = None,
10
+ page_size: Optional[int] = 5000,
11
+ unit_system: Optional[str] = None,
12
+ like: Optional[str] = None,
13
+ location_category_like: Optional[str] = None,
14
+ location_group_like: Optional[str] = None,
15
+ bounding_office_like: Optional[str] = None,
16
+ location_kind_like: Optional[str] = None,
17
+ ) -> Data:
18
+ """Retrieves filters for a locations catalog
19
+
20
+ Parameters
21
+ ----------
22
+ page: string
23
+ The endpoint used to identify where the request is located.
24
+ page_size: integer
25
+ The entries per page returned. The default value is 5000.
26
+ unit_system: string
27
+ The unit system desired in response. Valid values for this
28
+ field are:
29
+ 1. SI
30
+ 2. EN
31
+ office_id: string
32
+ The owning office of the timeseries group.
33
+ like: string
34
+ The regex for matching against the id
35
+ location_category_like: string
36
+ The regex for matching against the location category id
37
+ location_group_like: string
38
+ The regex for matching against the location group id
39
+ bounding_office_like: string
40
+ The regex for matching against the location bounding office
41
+ location_kind_like: string
42
+ Posix regular expression matching against the location kind. The location-kind is typically unset or one of the following: {"SITE", "EMBANKMENT", "OVERFLOW", "TURBINE", "STREAM", "PROJECT", "STREAMGAGE", "BASIN", "OUTLET", "LOCK", "GATE"}. Multiple kinds can be matched by using Regular Expression OR clauses. For example: "(SITE|STREAM)"
43
+
44
+ Returns
45
+ -------
46
+ cwms data type
47
+ """
48
+
49
+ # CHECKS
50
+ if office_id is None:
51
+ raise ValueError("Retrieve locations catalog requires an office")
52
+
53
+ dataset = "LOCATIONS"
54
+ endpoint = f"catalog/{dataset}"
55
+ params = {
56
+ "page": page,
57
+ "page-size": page_size,
58
+ "units": unit_system,
59
+ "office": office_id,
60
+ "like": like,
61
+ "location-category-like": location_category_like,
62
+ "location-group-like": location_group_like,
63
+ "bounding-office-like": bounding_office_like,
64
+ "location-kind-like": location_kind_like,
65
+ }
66
+
67
+ response = api.get(endpoint=endpoint, params=params, api_version=2)
68
+ return Data(response, selector="entries")
69
+
70
+
71
+ def get_timeseries_catalog(
72
+ office_id: str,
73
+ page: Optional[str] = None,
74
+ page_size: Optional[int] = 5000,
75
+ unit_system: Optional[str] = None,
76
+ like: Optional[str] = None,
77
+ timeseries_category_like: Optional[str] = None,
78
+ timeseries_group_like: Optional[str] = "DMZ Include List",
79
+ bounding_office_like: Optional[str] = None,
80
+ ) -> Data:
81
+ """Retrieves filters for the timeseries catalog
82
+
83
+ Parameters
84
+ ----------
85
+ page: string
86
+ The endpoint used to identify where the request is located.
87
+ page_size: integer
88
+ The entries per page returned. The default value is 500.
89
+ unit_system: string
90
+ The unit system desired in response. Valid values for this
91
+ field are:
92
+ 1. SI
93
+ 2. EN
94
+ office_id: string
95
+ The owning office of the timeseries group.
96
+ like: string
97
+ The regex for matching against the id
98
+ timeseries_category_like: string
99
+ The regex for matching against the category id
100
+ timeseries_group_like: string
101
+ The regex for matching against the timeseries group id. This will default to pull only public datasets
102
+ bounding_office_like: string
103
+ The regex for matching against the location bounding office
104
+
105
+ Returns
106
+ -------
107
+ cwms data type
108
+ """
109
+
110
+ # CHECKS
111
+ if office_id is None:
112
+ raise ValueError("Retrieve timeseries catalog requires an office")
113
+
114
+ dataset = "TIMESERIES"
115
+ endpoint = f"catalog/{dataset}"
116
+ params = {
117
+ "page": page,
118
+ "page-size": page_size,
119
+ "unit-system": unit_system,
120
+ "office": office_id,
121
+ "like": like,
122
+ "timeseries-category-like": timeseries_category_like,
123
+ "timeseries-group-like": timeseries_group_like,
124
+ "bounding-office-like": bounding_office_like,
125
+ }
126
+
127
+ response = api.get(endpoint=endpoint, params=params, api_version=2)
128
+ return Data(response, selector="entries")
@@ -0,0 +1,107 @@
1
+ from copy import deepcopy
2
+ from enum import Enum, auto
3
+ from typing import Any, Optional
4
+
5
+ from pandas import DataFrame, Index, json_normalize, to_datetime
6
+
7
+ # Describes generic JSON serializable data.
8
+ JSON = dict[str, Any]
9
+
10
+ # Describes request parameters.
11
+ RequestParams = dict[str, Any]
12
+
13
+
14
+ class DeleteMethod(Enum):
15
+ DELETE_ALL = auto()
16
+ DELETE_KEY = auto()
17
+ DELETE_DATA = auto()
18
+
19
+
20
+ class RatingMethod(Enum):
21
+ EAGER = auto()
22
+ LAZY = auto()
23
+ REFERENCE = auto()
24
+
25
+
26
+ class Data:
27
+ """Wrapper for CWMS API data."""
28
+
29
+ def __init__(self, json: JSON, *, selector: Optional[str] = None):
30
+ """Wrap CWMS API Data.
31
+
32
+ Args:
33
+ data:
34
+ selector:
35
+ """
36
+
37
+ self.json = json
38
+ self.selector = selector
39
+
40
+ self._df: Optional[DataFrame] = None
41
+
42
+ @staticmethod
43
+ def to_df(json: JSON, selector: Optional[str]) -> DataFrame:
44
+ """Create a data frame from JSON data.
45
+
46
+ Args:
47
+ json: JSON data returned in the API response.
48
+ selector: Dot separated string of keys used to extract data for data frame.
49
+
50
+ Returns:
51
+ A data frame containing the data located
52
+ """
53
+
54
+ def get_df_data(data: JSON, selector: str) -> JSON:
55
+ # get the data that will be stored in the dataframe using the selectors
56
+ df_data = data
57
+ for key in selector.split("."):
58
+ if key in df_data.keys():
59
+ df_data = df_data[key]
60
+ return df_data
61
+
62
+ def rating_type(data: JSON) -> DataFrame:
63
+ # grab the correct point values for a rating table
64
+ df = DataFrame(data["point"]) if data["point"] else DataFrame()
65
+ return df
66
+
67
+ def timeseries_type(orig_json: JSON, value_json: JSON) -> DataFrame:
68
+ # if timeseries values are present then grab the values and put into
69
+ # dataframe else create empty dataframe
70
+ columns = Index([sub["name"] for sub in orig_json["value-columns"]])
71
+ if value_json:
72
+ df = DataFrame(value_json)
73
+ df.columns = columns
74
+ else:
75
+ df = DataFrame(columns=columns)
76
+
77
+ if "date-time" in df.columns:
78
+ df["date-time"] = to_datetime(df["date-time"], unit="ms", utc=True)
79
+ return df
80
+
81
+ data = deepcopy(json)
82
+
83
+ if selector:
84
+ df_data = get_df_data(data, selector)
85
+
86
+ # if the dataframe is for a rating table
87
+ if ("rating-points" in selector) and ("point" in df_data.keys()):
88
+ df = rating_type(df_data)
89
+
90
+ elif selector == "values":
91
+ df = timeseries_type(data, df_data)
92
+
93
+ else:
94
+ df = json_normalize(df_data) if df_data else DataFrame()
95
+ else:
96
+ df = json_normalize(data)
97
+
98
+ return df
99
+
100
+ @property
101
+ def df(self) -> DataFrame:
102
+ """Return the data frame."""
103
+
104
+ if not isinstance(self._df, DataFrame):
105
+ self._df = Data.to_df(self.json, self.selector)
106
+
107
+ return self._df