cwms-python 1.0.8__tar.gz → 1.0.10__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 (43) hide show
  1. {cwms_python-1.0.8 → cwms_python-1.0.10}/PKG-INFO +15 -3
  2. {cwms_python-1.0.8 → cwms_python-1.0.10}/README.md +5 -0
  3. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/__init__.py +2 -0
  4. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/api.py +77 -7
  5. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/locations/gate_changes.py +3 -3
  6. cwms_python-1.0.10/cwms/locations/lookups.py +163 -0
  7. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/outlets/outlets.py +1 -1
  8. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/projects/projects.py +7 -7
  9. cwms_python-1.0.10/cwms/properties/properties.py +168 -0
  10. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/timeseries/timeseries.py +49 -22
  11. cwms_python-1.0.10/pyproject.toml +70 -0
  12. cwms_python-1.0.8/pyproject.toml +0 -45
  13. {cwms_python-1.0.8 → cwms_python-1.0.10}/LICENSE +0 -0
  14. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/catalog/blobs.py +0 -0
  15. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/catalog/catalog.py +0 -0
  16. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/catalog/clobs.py +0 -0
  17. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/cwms_types.py +0 -0
  18. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/forecast/forecast_instance.py +0 -0
  19. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/forecast/forecast_spec.py +0 -0
  20. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/levels/location_levels.py +0 -0
  21. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/levels/specified_levels.py +0 -0
  22. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/locations/location_groups.py +0 -0
  23. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/locations/physical_locations.py +0 -0
  24. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/measurements/measurements.py +0 -0
  25. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/outlets/virtual_outlets.py +0 -0
  26. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/projects/project_lock_rights.py +0 -0
  27. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/projects/project_locks.py +0 -0
  28. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/projects/water_supply/accounting.py +0 -0
  29. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/ratings/ratings.py +0 -0
  30. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/ratings/ratings_spec.py +0 -0
  31. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/ratings/ratings_template.py +0 -0
  32. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/standard_text/standard_text.py +0 -0
  33. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/timeseries/timeseries_bin.py +0 -0
  34. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/timeseries/timeseries_group.py +0 -0
  35. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/timeseries/timeseries_identifier.py +0 -0
  36. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/timeseries/timeseries_profile.py +0 -0
  37. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/timeseries/timeseries_profile_instance.py +0 -0
  38. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/timeseries/timeseries_profile_parser.py +0 -0
  39. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/timeseries/timeseries_txt.py +0 -0
  40. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/turbines/turbines.py +0 -0
  41. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/users/users.py +0 -0
  42. {cwms_python-1.0.8 → cwms_python-1.0.10}/cwms/utils/__init__.py +0 -0
  43. {cwms_python-1.0.8 → cwms_python-1.0.10}/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.8
3
+ Version: 1.0.10
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
@@ -16,9 +16,16 @@ Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Programming Language :: Python :: 3.14
19
- Requires-Dist: pandas (>=2.1.3,<3.0.0)
20
- Requires-Dist: requests (>=2.31.0,<3.0.0)
19
+ Classifier: Programming Language :: Python :: 3.15
20
+ Requires-Dist: numpy (>=1.23.5,<3) ; python_version >= "3.9" and python_version < "3.13"
21
+ Requires-Dist: numpy (>=2.1.0,<3) ; python_version == "3.13"
22
+ Requires-Dist: numpy (>=2.3.3,<3) ; python_version >= "3.14"
23
+ Requires-Dist: pandas (>=2.3.3,<3.0.0)
24
+ Requires-Dist: requests (>=2.32.4,<3.0.0) ; python_version == "3.9"
25
+ Requires-Dist: requests (>=2.33.0,<3.0.0) ; python_version >= "3.10"
21
26
  Requires-Dist: requests-toolbelt (>=1.0.0,<2.0.0)
27
+ Requires-Dist: urllib3 (>=2.6.3,<2.7) ; python_version == "3.9"
28
+ Requires-Dist: urllib3 (>=2.7.0,<3.0.0) ; python_version >= "3.10"
22
29
  Project-URL: Repository, https://github.com/HydrologicEngineeringCenter/cwms-python
23
30
  Description-Content-Type: text/markdown
24
31
 
@@ -124,3 +131,8 @@ endpoint implementation.
124
131
 
125
132
  Please view the contribution documentation here: [CONTRIBUTING.md]
126
133
 
134
+ ## Contributing and releases
135
+
136
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development checks, PR title conventions,
137
+ and the Release Please publishing workflow.
138
+
@@ -99,3 +99,8 @@ endpoint implementation.
99
99
  ## Contributing
100
100
 
101
101
  Please view the contribution documentation here: [CONTRIBUTING.md]
102
+
103
+ ## Contributing and releases
104
+
105
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development checks, PR title conventions,
106
+ and the Release Please publishing workflow.
@@ -10,6 +10,7 @@ from cwms.levels.location_levels import *
10
10
  from cwms.levels.specified_levels import *
11
11
  from cwms.locations.gate_changes import *
12
12
  from cwms.locations.location_groups import *
13
+ from cwms.locations.lookups import *
13
14
  from cwms.locations.physical_locations import *
14
15
  from cwms.measurements.measurements import *
15
16
  from cwms.outlets.outlets import *
@@ -18,6 +19,7 @@ from cwms.projects.project_lock_rights import *
18
19
  from cwms.projects.project_locks import *
19
20
  from cwms.projects.projects import *
20
21
  from cwms.projects.water_supply.accounting import *
22
+ from cwms.properties.properties import *
21
23
  from cwms.ratings.ratings import *
22
24
  from cwms.ratings.ratings_spec import *
23
25
  from cwms.ratings.ratings_template import *
@@ -48,6 +48,9 @@ from cwms.cwms_types import JSON, RequestParams
48
48
  API_ROOT = "https://cwms-data.usace.army.mil/cwms-data/"
49
49
  API_VERSION = 2
50
50
 
51
+ # Specify whether LRTS will use new ID format
52
+ USE_NEW_LRTS_IDS = False
53
+
51
54
  # Initialize a non-authenticated session with the default root URL and set default pool connections.
52
55
 
53
56
  retry_strategy = Retry(
@@ -168,6 +171,7 @@ def init_session(
168
171
  api_key: Optional[str] = None,
169
172
  token: Optional[str] = None,
170
173
  pool_connections: int = 100,
174
+ use_new_lrts_format: bool = False,
171
175
  ) -> BaseUrlSession:
172
176
  """Specify a root URL and authentication credentials for the CWMS Data API.
173
177
 
@@ -186,7 +190,7 @@ def init_session(
186
190
  Returns the updated session object.
187
191
  """
188
192
 
189
- global SESSION
193
+ global SESSION, USE_NEW_LRTS_IDS
190
194
  if api_root:
191
195
  # Ensure the API_ROOT ends with a single slash
192
196
  api_root = api_root.rstrip("/") + "/"
@@ -206,11 +210,23 @@ def init_session(
206
210
  # Ensure we don't provide the bearer text twice
207
211
  if token.lower().startswith("bearer "):
208
212
  token = token[7:]
209
- SESSION.headers.update({"Authorization": "Bearer " + token})
213
+ SESSION.headers.update(
214
+ {
215
+ "Authorization": "Bearer " + token,
216
+ "X-CWMS-LRTS-Formatting": str(USE_NEW_LRTS_IDS).lower(),
217
+ }
218
+ )
210
219
  elif api_key:
211
220
  if api_key.startswith("apikey "):
212
221
  api_key = api_key.replace("apikey ", "")
213
- SESSION.headers.update({"Authorization": "apikey " + api_key})
222
+ SESSION.headers.update(
223
+ {
224
+ "Authorization": "apikey " + api_key,
225
+ "X-CWMS-LRTS-Formatting": str(USE_NEW_LRTS_IDS).lower(),
226
+ }
227
+ )
228
+
229
+ USE_NEW_LRTS_IDS = use_new_lrts_format
214
230
 
215
231
  return SESSION
216
232
 
@@ -225,6 +241,46 @@ def return_base_url() -> str:
225
241
  return str(SESSION.base_url)
226
242
 
227
243
 
244
+ def set_use_new_lrts_ids(state: bool) -> None:
245
+ """Sets whether the new LRTS identifer format is used for subsequent operations.
246
+
247
+ The old Local Regular Time Series (LRTS) identifier format is the same as the
248
+ Pseudo-Regular Time Series (PRTS) identifier format, where both prepend a tilde
249
+ character ('~') to valid Regular Time Series (RTS) interval identifiers (e.g.,
250
+ ~6Hours, ~1Day).
251
+
252
+ The new LRTS identifiers instead append "Local" to valid RTS interval identifiers
253
+ (e.g., 6HoursLocal, 1DayLocal), leaving the old format to specify only PRTS.
254
+
255
+ Args:
256
+ state: Whether the new LRTS identifier format is used
257
+ """
258
+
259
+ global USE_NEW_LRTS_IDS
260
+
261
+ USE_NEW_LRTS_IDS = state
262
+
263
+
264
+ def get_use_new_lrts_ids() -> bool:
265
+ """Gets whether the new LRTS identifer format is used for subsequent operations.
266
+
267
+ The old Local Regular Time Series (LRTS) identifier format is the same as the
268
+ Pseudo-Regular Time Series (PRTS) identifier format, where both prepend a tilde
269
+ character ('~') to valid Regular Time Series (RTS) interval identifiers (e.g.,
270
+ ~6Hours, ~1Day).
271
+
272
+ The new LRTS identifiers instead append "Local" to valid RTS interval identifiers
273
+ (e.g., 6HoursLocal, 1DayLocal), leaving the old format to specify only PRTS.
274
+
275
+ Returns:
276
+ Whether the new LRTS identifier format is used
277
+ """
278
+
279
+ global USE_NEW_LRTS_IDS
280
+
281
+ return USE_NEW_LRTS_IDS
282
+
283
+
228
284
  def api_version_text(api_version: int) -> str:
229
285
  """Initialize CDA request headers.
230
286
 
@@ -329,7 +385,10 @@ def get(
329
385
  ApiError: If an error response is return by the API.
330
386
  """
331
387
 
332
- headers = {"Accept": api_version_text(api_version)}
388
+ headers = {
389
+ "Accept": api_version_text(api_version),
390
+ "X-CWMS-LRTS-Formatting": str(USE_NEW_LRTS_IDS).lower(),
391
+ }
333
392
  try:
334
393
  with SESSION.get(endpoint, params=params, headers=headers) as response:
335
394
  if not response.ok:
@@ -389,7 +448,11 @@ def _post_function(
389
448
  ) -> Any:
390
449
 
391
450
  # post requires different headers than get for
392
- headers = {"accept": "*/*", "Content-Type": api_version_text(api_version)}
451
+ headers = {
452
+ "accept": "*/*",
453
+ "Content-Type": api_version_text(api_version),
454
+ "X-CWMS-LRTS-Formatting": str(USE_NEW_LRTS_IDS).lower(),
455
+ }
393
456
  if isinstance(data, dict) or isinstance(data, list):
394
457
  data = json.dumps(data)
395
458
  try:
@@ -487,7 +550,11 @@ def patch(
487
550
  ApiError: If an error response is return by the API.
488
551
  """
489
552
 
490
- headers = {"accept": "*/*", "Content-Type": api_version_text(api_version)}
553
+ headers = {
554
+ "accept": "*/*",
555
+ "Content-Type": api_version_text(api_version),
556
+ "X-CWMS-LRTS-Formatting": str(USE_NEW_LRTS_IDS).lower(),
557
+ }
491
558
 
492
559
  if data and isinstance(data, dict) or isinstance(data, list):
493
560
  data = json.dumps(data)
@@ -522,7 +589,10 @@ def delete(
522
589
  ApiError: If an error response is return by the API.
523
590
  """
524
591
 
525
- headers = {"Accept": api_version_text(api_version)}
592
+ headers = {
593
+ "Accept": api_version_text(api_version),
594
+ "X-CWMS-LRTS-Formatting": str(USE_NEW_LRTS_IDS).lower(),
595
+ }
526
596
  try:
527
597
  with SESSION.delete(endpoint, params=params, headers=headers) as response:
528
598
  if not response.ok:
@@ -62,7 +62,7 @@ def get_all_gate_changes(
62
62
  "unit-system": unit_system,
63
63
  "page-size": page_size,
64
64
  }
65
- response = api.get(endpoint, params=params)
65
+ response = api.get(endpoint, params=params, api_version=1)
66
66
  return Data(response)
67
67
 
68
68
 
@@ -144,7 +144,7 @@ def store_gate_change(
144
144
  params = {
145
145
  "fail-if-exists": fail_if_exists,
146
146
  }
147
- return api.post(endpoint, data=gate_change_data, params=params)
147
+ return api.post(endpoint, data=gate_change_data, params=params, api_version=1)
148
148
 
149
149
 
150
150
  def delete_gate_change(
@@ -182,4 +182,4 @@ def delete_gate_change(
182
182
  "override-protection": override_protection,
183
183
  }
184
184
 
185
- return api.delete(endpoint, params=params)
185
+ return api.delete(endpoint, params=params, api_version=1)
@@ -0,0 +1,163 @@
1
+ # Copyright (c) 2026
2
+ # United States Army Corps of Engineers - Hydrologic Engineering Center (USACE/HEC)
3
+ # All Rights Reserved. USACE PROPRIETARY/CONFIDENTIAL.
4
+ # Source may not be released without written approval from HEC
5
+
6
+ import cwms.api as api
7
+ from cwms.cwms_types import JSON, Data
8
+
9
+ ENDPOINT = "lookup-types"
10
+
11
+
12
+ def get_all_lookups(category: str, prefix: str, office_id: str) -> Data:
13
+ """
14
+ Retrieves all lookups for a given category, prefix, and office.
15
+
16
+ Parameters
17
+ ----------
18
+ category : str
19
+ Filters lookup types to the specified category
20
+ prefix : str
21
+ Filters lookup types to the specified prefix
22
+ office_id : str
23
+ Filters lookup types to the specified office ID
24
+
25
+ Returns
26
+ -------
27
+ Data
28
+ The JSON response from CWMS Data API wrapped in a Data object.
29
+
30
+ Raises
31
+ ------
32
+ ValueError
33
+ If any required argument is missing.
34
+ ClientError
35
+ If a 400 range error code response is returned from the server.
36
+ NoDataFoundError
37
+ If a 404 range error code response is returned from the server.
38
+ ServerError
39
+ If a 500 range error code response is returned from the server.
40
+ """
41
+ if not all([category, prefix, office_id]):
42
+ raise ValueError("Category, Prefix, and Office ID must be specified")
43
+
44
+ params = {"category": category, "prefix": prefix, "office": office_id}
45
+ response = api.get(ENDPOINT, params, api_version=1)
46
+ return Data(response)
47
+
48
+
49
+ def create_lookup(data: JSON, category: str, prefix: str) -> None:
50
+ """
51
+ Creates a new lookup entry.
52
+
53
+ Parameters
54
+ ----------
55
+ data: JSON
56
+ A dictionary representing the JSON data to be stored. This should match the
57
+ LookupType structure as defined by the API.
58
+ category : str
59
+ Specifies the category of the lookup.
60
+ prefix : str
61
+ Specifies the prefix of the lookup.
62
+
63
+ Returns
64
+ -------
65
+ None
66
+
67
+ Raises
68
+ ------
69
+ ValueError
70
+ If any required argument is missing.
71
+ ClientError
72
+ If a 400 range error code response is returned from the server.
73
+ NoDataFoundError
74
+ If a 404 range error code response is returned from the server.
75
+ ServerError
76
+ If a 500 range error code response is returned from the server.
77
+ """
78
+ if not all([category, prefix]):
79
+ raise ValueError("Category and Prefix must be specified")
80
+ if not data:
81
+ raise ValueError("Data must be specified")
82
+ params = {"category": category, "prefix": prefix}
83
+ api.post(ENDPOINT, data, params, api_version=1)
84
+
85
+
86
+ def update_lookup(data: JSON, category: str, prefix: str) -> None:
87
+ """
88
+ Updates a specified lookup entry.
89
+
90
+ Parameters
91
+ ----------
92
+ data : JSON
93
+ A dictionary representing the JSON data to be stored.
94
+ If the `data` value is None, a `ValueError` will be raised.
95
+ category : str
96
+ Specifies the category of the lookup.
97
+ prefix : str
98
+ Specifies the prefix of the lookup.
99
+
100
+ Returns
101
+ -------
102
+ None
103
+
104
+ Raises
105
+ ------
106
+ ValueError
107
+ If any required argument is missing.
108
+ ClientError
109
+ If a 400 range error code response is returned from the server.
110
+ NoDataFoundError
111
+ If a 404 range error code response is returned from the server.
112
+ ServerError
113
+ If a 500 range error code response is returned from the server.
114
+ """
115
+ if not all([category, prefix]):
116
+ raise ValueError("Category and Prefix must be specified")
117
+ if not data:
118
+ raise ValueError("Data must be specified")
119
+
120
+ # Note that the path parameter is unused in CDA
121
+ endpoint = f"{ENDPOINT}/{category}"
122
+ params = {"category": category, "prefix": prefix}
123
+ api.patch(endpoint, data, params, api_version=1)
124
+
125
+
126
+ def delete_lookup(
127
+ display_value: str, category: str, prefix: str, office_id: str
128
+ ) -> None:
129
+ """
130
+ Deletes a specified lookup entry.
131
+
132
+ Parameters
133
+ ----------
134
+ category : str
135
+ Specifies the category id of the lookup type to be deleted.
136
+ prefix : str
137
+ Specifies the prefix of the lookup type to be deleted.
138
+ office_id : str
139
+ Specifies the owning office of the lookup type to be deleted.
140
+
141
+ Returns
142
+ -------
143
+ None
144
+
145
+ Raises
146
+ ------
147
+ ValueError
148
+ If any required argument is missing.
149
+ ClientError
150
+ If a 400 range error code response is returned from the server.
151
+ NoDataFoundError
152
+ If a 404 range error code response is returned from the server.
153
+ ServerError
154
+ If a 500 range error code response is returned from the server.
155
+ """
156
+ if not all([display_value, category, prefix, office_id]):
157
+ raise ValueError(
158
+ "Display Value, Category, Prefix, and Office ID must be specified"
159
+ )
160
+
161
+ endpoint = f"{ENDPOINT}/{display_value}"
162
+ params = {"category": category, "prefix": prefix, "office": office_id}
163
+ api.delete(endpoint, params, api_version=1)
@@ -192,4 +192,4 @@ def store_outlet(data: JSON, fail_if_exists: Optional[bool] = True) -> None:
192
192
 
193
193
  endpoint = "projects/outlets"
194
194
  params = {"fail-if-exists": fail_if_exists}
195
- api.post(endpoint, data, params)
195
+ api.post(endpoint, data, params, api_version=1)
@@ -42,7 +42,7 @@ def get_project(office_id: str, name: str) -> Data:
42
42
 
43
43
  endpoint = f"projects/{name}"
44
44
  params = {"office": office_id}
45
- response = api.get(endpoint, params)
45
+ response = api.get(endpoint, params, api_version=1)
46
46
  return Data(response)
47
47
 
48
48
 
@@ -87,7 +87,7 @@ def get_projects(
87
87
  "page": page,
88
88
  "page-size": page_size,
89
89
  }
90
- response = api.get(endpoint, params)
90
+ response = api.get(endpoint, params, api_version=1)
91
91
  return Data(response)
92
92
 
93
93
 
@@ -129,7 +129,7 @@ def get_project_locations(
129
129
  "project-like": project_like,
130
130
  "location-kind-like": location_id_like,
131
131
  }
132
- response = api.get(endpoint, params)
132
+ response = api.get(endpoint, params, api_version=1)
133
133
  return Data(response)
134
134
 
135
135
 
@@ -169,7 +169,7 @@ def delete_project(office_id: str, name: str, delete_method: DeleteMethod) -> No
169
169
 
170
170
  endpoint = f"projects/{name}"
171
171
  params = {"office": office_id, "method": delete_method.name}
172
- api.delete(endpoint, params)
172
+ api.delete(endpoint, params, api_version=1)
173
173
 
174
174
 
175
175
  def rename_project(office_id: str, old_name: str, new_name: str) -> None:
@@ -208,7 +208,7 @@ def rename_project(office_id: str, old_name: str, new_name: str) -> None:
208
208
 
209
209
  endpoint = f"projects/{old_name}"
210
210
  params = {"office": office_id, "name": new_name}
211
- api.patch(endpoint=endpoint, params=params)
211
+ api.patch(endpoint=endpoint, params=params, api_version=1)
212
212
 
213
213
 
214
214
  def store_project(data: JSON, fail_if_exists: Optional[bool] = True) -> None:
@@ -243,7 +243,7 @@ def store_project(data: JSON, fail_if_exists: Optional[bool] = True) -> None:
243
243
 
244
244
  endpoint = "projects"
245
245
  params = {"fail-if-exists": fail_if_exists}
246
- api.post(endpoint, data, params)
246
+ api.post(endpoint, data, params, api_version=1)
247
247
 
248
248
 
249
249
  def status_update(
@@ -306,4 +306,4 @@ def status_update(
306
306
  "begin": (begin.isoformat() if begin else None),
307
307
  "end": (end.isoformat() if end else None),
308
308
  }
309
- api.post(endpoint, None, params)
309
+ api.post(endpoint, None, params, api_version=1)
@@ -0,0 +1,168 @@
1
+ # Copyright (c) 2026
2
+ # United States Army Corps of Engineers - Hydrologic Engineering Center (USACE/HEC)
3
+ # All Rights Reserved. USACE PROPRIETARY/CONFIDENTIAL.
4
+ # Source may not be released without written approval from HEC
5
+
6
+ from typing import Optional
7
+
8
+ import cwms.api as api
9
+ from cwms.cwms_types import JSON, Data
10
+
11
+
12
+ def get_properties(
13
+ office_mask: Optional[str] = None,
14
+ category_id_mask: Optional[str] = None,
15
+ name_mask: Optional[str] = None,
16
+ ) -> Data:
17
+ """
18
+ Returns matching CWMS Property Data.
19
+
20
+ Parameters
21
+ ----------
22
+ office_mask: string, optional
23
+ Filters properties to the specified office mask
24
+ category_id_mask: string, optional
25
+ Filters properties to the specified category mask
26
+ name_mask: string, optional
27
+ Filters properties to the specified name mask
28
+
29
+ Returns
30
+ -------
31
+ cwms data type
32
+ """
33
+
34
+ endpoint = "properties"
35
+ params = {
36
+ "office-mask": office_mask,
37
+ "category-id-mask": category_id_mask,
38
+ "name-mask": name_mask,
39
+ }
40
+
41
+ response = api.get(endpoint, params, api_version=1)
42
+ return Data(response)
43
+
44
+
45
+ def get_property(
46
+ name: str, office: str, category_id: str, default_value: Optional[str] = None
47
+ ) -> Data:
48
+ """
49
+ Returns CWMS Property Data.
50
+
51
+ Parameters
52
+ ----------
53
+ name: string
54
+ Specifies the name of the property to be retrieved.
55
+ office: string
56
+ Specifies the owning office of the property to be retrieved.
57
+ category_id: string
58
+ Specifies the category id of the property to be retrieved.
59
+ default_value: string, optional
60
+ Specifies the default value if the property does not exist.
61
+
62
+ Returns
63
+ -------
64
+ cwms data type
65
+ """
66
+
67
+ endpoint = f"properties/{name}"
68
+ params = {
69
+ "office": office,
70
+ "category-id": category_id,
71
+ "default-value": default_value,
72
+ }
73
+
74
+ response = api.get(endpoint, params, api_version=1)
75
+ return Data(response)
76
+
77
+
78
+ def create_property(data: JSON) -> None:
79
+ """
80
+ Create CWMS Property.
81
+
82
+ Parameters
83
+ ----------
84
+ data: JSON dictionary
85
+ Property data to be stored.
86
+ Example:
87
+ {
88
+ "office-id": "string",
89
+ "name": "string",
90
+ "category": "string",
91
+ "value": "string",
92
+ "comment": "string"
93
+ }
94
+
95
+ Returns
96
+ -------
97
+ None
98
+ """
99
+
100
+ endpoint = "properties"
101
+
102
+ if data is None:
103
+ raise ValueError("Cannot store a property without JSON data")
104
+
105
+ return api.post(endpoint, data, api_version=1)
106
+
107
+
108
+ def update_property(name: str, data: JSON) -> None:
109
+ """
110
+ Update CWMS Property.
111
+
112
+ Parameters
113
+ ----------
114
+ name: string
115
+ Specifies the name of the property to be updated.
116
+ data: JSON dictionary
117
+ Property data to be updated.
118
+ Example:
119
+ {
120
+ "office-id": "string",
121
+ "name": "string",
122
+ "category": "string",
123
+ "value": "string",
124
+ "comment": "string"
125
+ }
126
+
127
+ Returns
128
+ -------
129
+ None
130
+ """
131
+
132
+ endpoint = f"properties/{name}"
133
+
134
+ if name is None:
135
+ raise ValueError("Must specify a property name to update")
136
+
137
+ if data is None:
138
+ raise ValueError("Cannot update a property without JSON data")
139
+
140
+ return api.patch(endpoint, data, api_version=1)
141
+
142
+
143
+ def delete_property(name: str, office: str, category_id: str) -> None:
144
+ """
145
+ Delete CWMS Property.
146
+
147
+ Parameters
148
+ ----------
149
+ name: string
150
+ Specifies the name of the property to be deleted.
151
+ office: string
152
+ Specifies the owning office of the property to be deleted.
153
+ category_id: string
154
+ Specifies the category id of the property to be deleted.
155
+
156
+ Returns
157
+ -------
158
+ None
159
+ """
160
+
161
+ endpoint = f"properties/{name}"
162
+
163
+ params = {
164
+ "office": office,
165
+ "category-id": category_id,
166
+ }
167
+
168
+ return api.delete(endpoint, params, api_version=1)
@@ -166,6 +166,9 @@ def _call_with_retry(fn: Any, *args: Any, attempts: int = _CHUNK_ATTEMPTS) -> An
166
166
  try:
167
167
  return fn(*args)
168
168
  except Exception as e:
169
+ status_code = getattr(getattr(e, "response", None), "status_code", None)
170
+ if status_code == 404:
171
+ raise
169
172
  if i == attempts - 1:
170
173
  raise
171
174
  logging.warning(f"chunk attempt {i + 1}/{attempts} failed: {e}")
@@ -525,7 +528,7 @@ def store_multi_timeseries_df(
525
528
  DELETE_INSERT.
526
529
  override_protection: bool, optional, default is False
527
530
  A flag to ignore the protected data quality flag when storing data.
528
- multithread: bool, default is false
531
+ multithread: bool, default is true
529
532
  Specifies whether to store chunked time series values using multiple threads.
530
533
  max_workers: Int, Optional, default is None
531
534
  It is a number of Threads aka size of pool in concurrent.futures.ThreadPoolExecutor.
@@ -533,6 +536,12 @@ def store_multi_timeseries_df(
533
536
  Returns
534
537
  -------
535
538
  None
539
+
540
+ Raises
541
+ ------
542
+ RuntimeError
543
+ If any series fails to store. The message identifies failed series;
544
+ other series may already have been stored successfully.
536
545
  """
537
546
 
538
547
  def store_ts_ids(
@@ -541,24 +550,21 @@ def store_multi_timeseries_df(
541
550
  office_id: str,
542
551
  version_date: Optional[datetime] = None,
543
552
  ) -> None:
544
- try:
545
- units = data["units"].iloc[0]
546
- data_json = timeseries_df_to_json(
547
- data=data,
548
- ts_id=ts_id,
549
- units=units,
550
- office_id=office_id,
551
- version_date=version_date,
552
- )
553
- store_timeseries(
554
- data=data_json,
555
- create_as_ltrs=create_as_ltrs,
556
- store_rule=store_rule,
557
- override_protection=override_protection,
558
- multithread=multithread,
559
- )
560
- except Exception as e:
561
- print(f"Error processing {ts_id}: {e}")
553
+ units = data["units"].iloc[0]
554
+ data_json = timeseries_df_to_json(
555
+ data=data,
556
+ ts_id=ts_id,
557
+ units=units,
558
+ office_id=office_id,
559
+ version_date=version_date,
560
+ )
561
+ store_timeseries(
562
+ data=data_json,
563
+ create_as_ltrs=create_as_ltrs,
564
+ store_rule=store_rule,
565
+ override_protection=override_protection,
566
+ multithread=multithread,
567
+ )
562
568
  return None
563
569
 
564
570
  required_columns = ["date-time", "value", "ts_id", "units"]
@@ -574,7 +580,9 @@ def store_multi_timeseries_df(
574
580
  ts_data_all["ts_id"].astype(str) + ":" + ts_data_all["version_date"].astype(str)
575
581
  ).unique()
576
582
 
583
+ errors: List[str] = []
577
584
  with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
585
+ futures = {}
578
586
  for unique_tsid in unique_tsids:
579
587
  ts_id, version_date = unique_tsid.split(":", 1)
580
588
  if version_date != "NaT":
@@ -589,9 +597,21 @@ def store_multi_timeseries_df(
589
597
  (ts_data_all["ts_id"] == ts_id) & ts_data_all["version_date"].isna()
590
598
  ]
591
599
  if not data.empty:
592
- executor.submit(
600
+ future = executor.submit(
593
601
  store_ts_ids, ts_data, ts_id, office_id, version_date_dt
594
602
  )
603
+ futures[future] = unique_tsid
604
+
605
+ for future in concurrent.futures.as_completed(futures):
606
+ try:
607
+ future.result()
608
+ except Exception as e:
609
+ errors.append(f"{futures[future]}: {e}")
610
+
611
+ if errors:
612
+ raise RuntimeError(
613
+ f"{len(errors)} time series failed to store:\n" + "\n".join(errors)
614
+ )
595
615
 
596
616
 
597
617
  def chunk_timeseries_data(
@@ -683,7 +703,14 @@ def store_timeseries(
683
703
  if len(chunks) == 1 or not multithread:
684
704
  return api.post(endpoint, data, params)
685
705
 
686
- actual_workers = min(max_workers, len(chunks))
706
+ if max_workers <= 0:
707
+ raise ValueError("max_workers must be greater than 0")
708
+
709
+ # A new series must exist before multiple transactions can write its data.
710
+ # Complete one normal write first, then retain parallelism for the rest.
711
+ _call_with_retry(api.post, endpoint, chunks[0], params)
712
+ remaining_chunks = chunks[1:]
713
+ actual_workers = min(max_workers, len(remaining_chunks))
687
714
  logging.debug(
688
715
  f"Storing {len(chunks)} chunks of timeseries data with {actual_workers} threads"
689
716
  )
@@ -695,7 +722,7 @@ def store_timeseries(
695
722
  with concurrent.futures.ThreadPoolExecutor(max_workers=actual_workers) as executor:
696
723
  future_to_chunk = {
697
724
  executor.submit(_call_with_retry, api.post, endpoint, chunk, params): chunk
698
- for chunk in chunks
725
+ for chunk in remaining_chunks
699
726
  }
700
727
 
701
728
  for future in concurrent.futures.as_completed(future_to_chunk):
@@ -0,0 +1,70 @@
1
+ [tool.poetry]
2
+ name = "cwms-python"
3
+ repository = "https://github.com/HydrologicEngineeringCenter/cwms-python"
4
+
5
+ # Managed by Release Please; runtime versions come from package metadata.
6
+ version = "1.0.10"
7
+
8
+ packages = [
9
+ { include = "cwms" },
10
+ ]
11
+ description = "Corps water management systems (CWMS) REST API for Data Retrieval of USACE water data"
12
+ readme = "README.md"
13
+ license = "LICENSE"
14
+ keywords = ["USACE", "water data", "CWMS"]
15
+ authors = ["Eric Novotny <eric.v.novotny@usace.army.mil>"]
16
+
17
+ [tool.poetry.dependencies]
18
+ python = "^3.9"
19
+ pandas = "^2.3.3"
20
+ # Require NumPy wheels for newer Python versions while retaining Python 3.9.
21
+ numpy = [
22
+ {version = ">=1.23.5,<3", python = ">=3.9,<3.13"},
23
+ {version = ">=2.1.0,<3", python = ">=3.13,<3.14"},
24
+ {version = ">=2.3.3,<3", python = ">=3.14"},
25
+ ]
26
+ requests-toolbelt = "^1.0.0"
27
+ requests = [
28
+ {version = "^2.32.4", python = ">=3.9,<3.10"},
29
+ {version = "^2.33.0", python = ">=3.10"},
30
+ ]
31
+ # Keep the Python 3.9 fallback while requiring patched releases on newer Python.
32
+ urllib3 = [
33
+ {version = ">=2.6.3,<2.7", python = ">=3.9,<3.10"},
34
+ {version = "^2.7.0", python = ">=3.10"},
35
+ ]
36
+
37
+ [tool.poetry.group.dev.dependencies]
38
+ black = [
39
+ {version = "^25.1.0", python = ">=3.9,<3.10"},
40
+ {version = "^26.3.1", python = ">=3.10"},
41
+ ]
42
+ isort = "^5.13.2"
43
+ mypy = "^1.9.0"
44
+ pre-commit = "^3.6.2"
45
+ pytest = [
46
+ {version = "^8.3.5", python = ">=3.9,<3.10"},
47
+ {version = "^9.0.3", python = ">=3.10"},
48
+ ]
49
+ requests-mock = "^1.11.0"
50
+ pytest-cov = "^4.1.0"
51
+ pandas-stubs = "^2.2.1.240316"
52
+ types-requests = "^2.31.0.20240311"
53
+ yamlfix = "^1.16.0"
54
+ setuptools = [
55
+ {version = ">=82.0.1,<83", python = ">=3.9,<3.10"},
56
+ {version = ">=83.0.0", python = ">=3.10"},
57
+ ]
58
+
59
+ [build-system]
60
+ requires = ["poetry-core"]
61
+ build-backend = "poetry.core.masonry.api"
62
+
63
+ [tool.isort]
64
+ profile = "black"
65
+
66
+ [tool.yamlfix]
67
+ comments_min_spaces_from_content = 1
68
+ whitelines = 1
69
+ explicit_start = false
70
+ preserve_quotes = true
@@ -1,45 +0,0 @@
1
- [tool.poetry]
2
- name = "cwms-python"
3
- repository = "https://github.com/HydrologicEngineeringCenter/cwms-python"
4
-
5
- version = "1.0.8"
6
-
7
- packages = [
8
- { include = "cwms" },
9
- ]
10
- description = "Corps water management systems (CWMS) REST API for Data Retrieval of USACE water data"
11
- readme = "README.md"
12
- license = "LICENSE"
13
- keywords = ["USACE", "water data", "CWMS"]
14
- authors = ["Eric Novotny <eric.v.novotny@usace.army.mil>"]
15
-
16
- [tool.poetry.dependencies]
17
- python = "^3.9"
18
- pandas = "^2.1.3"
19
- requests-toolbelt = "^1.0.0"
20
- requests = "^2.31.0"
21
-
22
- [tool.poetry.group.dev.dependencies]
23
- black = "^24.2.0"
24
- isort = "^5.13.2"
25
- mypy = "^1.9.0"
26
- pre-commit = "^3.6.2"
27
- pytest = "^8.1.1"
28
- requests-mock = "^1.11.0"
29
- pytest-cov = "^4.1.0"
30
- pandas-stubs = "^2.2.1.240316"
31
- types-requests = "^2.31.0.20240311"
32
- yamlfix = "^1.16.0"
33
-
34
- [build-system]
35
- requires = ["poetry-core"]
36
- build-backend = "poetry.core.masonry.api"
37
-
38
- [tool.isort]
39
- profile = "black"
40
-
41
- [tool.yamlfix]
42
- comments_min_spaces_from_content = 1
43
- whitelines = 1
44
- explicit_start = false
45
- preserve_quotes = true
File without changes