Skip to content

Commit 8c69980

Browse files
authored
Merge branch 'main' into update/users
2 parents 3a198db + 9d350ee commit 8c69980

5 files changed

Lines changed: 219 additions & 28 deletions

File tree

CONTRIBUTING.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ poetry run pytest tests/turbines/turbines_test.py
5151
5252
To test and develop with a local CDA instance, follow these steps:
5353
54+
For application code and manual local development, `cwms.api.init_session()`
55+
now supports either `api_key=` or `token=` authentication. When both are
56+
passed, bearer token auth takes precedence.
57+
5458
1. **Install Docker**
5559
Download and install Docker from the [official website](https://www.docker.com/get-started/).
5660
@@ -90,6 +94,10 @@ To test and develop with a local CDA instance, follow these steps:
9094
poetry run pytest tests/cda/ --api_root=http://localhost:8082/cwms-data/
9195
```
9296
97+
At the moment, the CDA pytest options are still keyed to `--api_key`; the
98+
new `token=` support is available through `cwms.api.init_session()` in
99+
library code.
100+
93101
> **Warning:** The tests in the `tests/cda/` package are destructive and may cause irreversible deletion of data from the targeted CDA instance. Use caution and avoid running these tests against production or important environments.
94102
95103
### Code Style
@@ -119,4 +127,4 @@ to grab the module from testpypi for testin run
119127
120128
```sh
121129
python3 -m pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ cwms-python
122-
```
130+
```

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,24 @@ Then import the package:
2020
import cwms
2121
```
2222

23+
### Authentication
24+
25+
`cwms.init_session()` supports both CDA API keys and Keycloak access tokens.
26+
Use `api_key=` for the headless CDA API key flow, or `token=` for an OIDC access
27+
token such as one saved by [`cwms-cli login`]().
28+
29+
```python
30+
import cwms
31+
32+
cwms.init_session(
33+
api_root="https://cwms-data.usace.army.mil/cwms-data/",
34+
token="ACCESS_TOKEN",
35+
)
36+
```
37+
38+
If both `token` and `api_key` are provided, `cwms-python` will use the token
39+
and log a warning.
40+
2341
## Getting Started
2442

2543
```python

cwms/api.py

Lines changed: 80 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
interact with these directly.
66
77
The `init_session()` function can be used to specify an alternative root URL, and to
8-
provide an authentication key (if required). If `init_session()` is not called, the
9-
default root URL (see `API_ROOT` below) will be used, and no authentication keys will be
10-
included when making API calls.
8+
provide an authentication key or bearer token (if required). If `init_session()` is not
9+
called, the default root URL (see `API_ROOT` below) will be used, and no authentication
10+
headers will be included when making API calls.
1111
1212
Example: Initializing a session
1313
@@ -17,6 +17,9 @@
1717
# Specify an alternate URL and an auth key
1818
init_session(api_root="https://example.com/cwms-data", api_key="API_KEY")
1919
20+
# Specify an alternate URL and an OIDC bearer token
21+
init_session(api_root="https://example.com/cwms-data", token="ACCESS_TOKEN")
22+
2023
Functions which make API calls that _may_ return a JSON response will return a `dict`
2124
containing the deserialized data. If the API response does not include data, an empty
2225
`dict` will be returned.
@@ -34,6 +37,7 @@
3437
from typing import Any, Optional, cast
3538

3639
from requests import Response, adapters
40+
from requests.exceptions import RetryError as RequestsRetryError
3741
from requests_toolbelt import sessions # type: ignore
3842
from requests_toolbelt.sessions import BaseUrlSession # type: ignore
3943
from urllib3.util.retry import Retry
@@ -52,12 +56,12 @@
5256
status_forcelist=[
5357
403,
5458
429,
55-
500,
5659
502,
5760
503,
5861
504,
5962
], # Example: also retry on these HTTP status codes
6063
allowed_methods=["GET", "PUT", "POST", "PATCH", "DELETE"], # Methods to retry
64+
raise_on_status=False,
6165
)
6266
SESSION = sessions.BaseUrlSession(base_url=API_ROOT)
6367
adapter = adapters.HTTPAdapter(
@@ -137,21 +141,46 @@ class PermissionError(ApiError):
137141
"""Raised when the CDA request is not authorized for the current caller."""
138142

139143

144+
def _unwrap_retry_error(error: RequestsRetryError) -> Exception:
145+
"""Return the original retry cause when requests wraps it in RetryError."""
146+
147+
current: Exception = error
148+
cause = error.__cause__
149+
while isinstance(cause, Exception):
150+
current = cause
151+
cause = cause.__cause__
152+
153+
if current is error and error.args:
154+
first_arg = error.args[0]
155+
if isinstance(first_arg, Exception):
156+
current = first_arg
157+
reason = getattr(current, "reason", None)
158+
while isinstance(reason, Exception):
159+
current = reason
160+
reason = getattr(current, "reason", None)
161+
162+
return current
163+
164+
140165
def init_session(
141166
*,
142167
api_root: Optional[str] = None,
143168
api_key: Optional[str] = None,
169+
token: Optional[str] = None,
144170
pool_connections: int = 100,
145171
) -> BaseUrlSession:
146-
"""Specify a root URL and authentication key for the CWMS Data API.
172+
"""Specify a root URL and authentication credentials for the CWMS Data API.
147173
148174
This function can be used to change the root URL used when interacting with the CDA.
149-
All API calls made after this function is called will use the specified URL. If an
150-
authentication key is given it will be included in all future request headers.
175+
All API calls made after this function is called will use the specified URL. If
176+
authentication credentials are given they will be included in all future request
177+
headers.
151178
152179
Keyword Args:
153180
api_root (optional): The root URL for the CWMS Data API.
154181
api_key (optional): An authentication key.
182+
token (optional): A Keycloak access token. If both token and api_key are
183+
provided, token is used.
155184
156185
Returns:
157186
Returns the updated session object.
@@ -169,7 +198,16 @@ def init_session(
169198
max_retries=retry_strategy,
170199
)
171200
SESSION.mount("https://", adapter)
172-
if api_key:
201+
if token:
202+
if api_key:
203+
logging.warning(
204+
"Both token and api_key were provided to init_session(); using token for Authorization."
205+
)
206+
# Ensure we don't provide the bearer text twice
207+
if token.lower().startswith("bearer "):
208+
token = token[7:]
209+
SESSION.headers.update({"Authorization": "Bearer " + token})
210+
elif api_key:
173211
if api_key.startswith("apikey "):
174212
api_key = api_key.replace("apikey ", "")
175213
SESSION.headers.update({"Authorization": "apikey " + api_key})
@@ -292,11 +330,14 @@ def get(
292330
"""
293331

294332
headers = {"Accept": api_version_text(api_version)}
295-
with SESSION.get(endpoint, params=params, headers=headers) as response:
296-
if not response.ok:
297-
logging.error(f"CDA Error: response={response}")
298-
raise ApiError(response)
299-
return _process_response(response)
333+
try:
334+
with SESSION.get(endpoint, params=params, headers=headers) as response:
335+
if not response.ok:
336+
logging.error(f"CDA Error: response={response}")
337+
raise ApiError(response)
338+
return _process_response(response)
339+
except RequestsRetryError as error:
340+
raise _unwrap_retry_error(error) from None
300341

301342

302343
def get_with_paging(
@@ -351,11 +392,16 @@ def _post_function(
351392
headers = {"accept": "*/*", "Content-Type": api_version_text(api_version)}
352393
if isinstance(data, dict) or isinstance(data, list):
353394
data = json.dumps(data)
354-
with SESSION.post(endpoint, params=params, headers=headers, data=data) as response:
355-
if not response.ok:
356-
logging.error(f"CDA Error: response={response}")
357-
raise ApiError(response)
358-
return response
395+
try:
396+
with SESSION.post(
397+
endpoint, params=params, headers=headers, data=data
398+
) as response:
399+
if not response.ok:
400+
logging.error(f"CDA Error: response={response}")
401+
raise ApiError(response)
402+
return response
403+
except RequestsRetryError as error:
404+
raise _unwrap_retry_error(error) from None
359405

360406

361407
def post(
@@ -445,10 +491,15 @@ def patch(
445491

446492
if data and isinstance(data, dict) or isinstance(data, list):
447493
data = json.dumps(data)
448-
with SESSION.patch(endpoint, params=params, headers=headers, data=data) as response:
449-
if not response.ok:
450-
logging.error(f"CDA Error: response={response}")
451-
raise ApiError(response)
494+
try:
495+
with SESSION.patch(
496+
endpoint, params=params, headers=headers, data=data
497+
) as response:
498+
if not response.ok:
499+
logging.error(f"CDA Error: response={response}")
500+
raise ApiError(response)
501+
except RequestsRetryError as error:
502+
raise _unwrap_retry_error(error) from None
452503

453504

454505
def delete(
@@ -472,7 +523,10 @@ def delete(
472523
"""
473524

474525
headers = {"Accept": api_version_text(api_version)}
475-
with SESSION.delete(endpoint, params=params, headers=headers) as response:
476-
if not response.ok:
477-
logging.error(f"CDA Error: response={response}")
478-
raise ApiError(response)
526+
try:
527+
with SESSION.delete(endpoint, params=params, headers=headers) as response:
528+
if not response.ok:
529+
logging.error(f"CDA Error: response={response}")
530+
raise ApiError(response)
531+
except RequestsRetryError as error:
532+
raise _unwrap_retry_error(error) from None
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import argparse
2+
import json
3+
import os
4+
from getpass import getpass
5+
6+
import cwms
7+
from cwms.api import ApiError
8+
9+
10+
def parse_args():
11+
parser = argparse.ArgumentParser(
12+
description="Check CWMS token auth by fetching the authenticated user profile."
13+
)
14+
parser.add_argument(
15+
"--api-root",
16+
default=os.getenv("CDA_API_ROOT"),
17+
help="CWMS Data API root URL.",
18+
)
19+
return parser.parse_args()
20+
21+
22+
def main():
23+
args = parse_args()
24+
if not args.api_root:
25+
print(
26+
"Error: API root URL is required. Provide it with --api-root or set CDA_API_ROOT environment variable."
27+
)
28+
raise SystemExit(1)
29+
token = getpass("Paste Keycloak access token: ").strip()
30+
if not token:
31+
raise SystemExit("No token provided.")
32+
33+
cwms.init_session(api_root=args.api_root, token=token)
34+
35+
try:
36+
profile = cwms.api.get("auth/profile", api_version=1)
37+
endpoint = "auth/profile"
38+
except ApiError as error:
39+
if getattr(error.response, "status_code", None) != 404:
40+
raise
41+
profile = cwms.get_user_profile()
42+
endpoint = "user/profile"
43+
44+
print(f"Fetched profile from {endpoint}:")
45+
print(json.dumps(profile, indent=2, sort_keys=True))
46+
47+
48+
if __name__ == "__main__":
49+
main()

tests/mock/api_test.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import pytest
2+
from requests.exceptions import RetryError as RequestsRetryError
3+
from urllib3.exceptions import MaxRetryError, ResponseError
24

3-
from cwms.api import SESSION, InvalidVersion, api_version_text, init_session
5+
import cwms.api
6+
from cwms.api import SESSION, ApiError, api_version_text, init_session
7+
8+
TEST_ENDPOINT = "/test-endpoint"
49

510

611
def test_session_default():
@@ -53,3 +58,60 @@ def test_api_headers():
5358

5459
version = api_version_text(api_version=2)
5560
assert version == "application/json;version=2"
61+
62+
63+
def test_retry_strategy_configuration():
64+
"""Verify retry behavior preserves the original CDA error path."""
65+
66+
retries = SESSION.adapters["https://"].max_retries
67+
68+
assert 500 not in retries.status_forcelist
69+
assert retries.raise_on_status is False
70+
71+
72+
def test_post_500_raises_api_error(monkeypatch):
73+
"""Verify a 500 response is surfaced directly as ApiError."""
74+
75+
class ResponseStub:
76+
url = "https://example.com/cwms-data/test-endpoint"
77+
ok = False
78+
status_code = 500
79+
reason = "Internal Server Error"
80+
content = b"incident identifier 34566432"
81+
82+
def __enter__(self):
83+
return self
84+
85+
def __exit__(self, exc_type, exc, tb):
86+
return False
87+
88+
class SessionStub:
89+
def post(self, endpoint, params=None, headers=None, data=None):
90+
return ResponseStub()
91+
92+
monkeypatch.setattr(cwms.api, "SESSION", SessionStub())
93+
94+
with pytest.raises(ApiError) as error:
95+
cwms.api._post_function(endpoint=TEST_ENDPOINT, data={})
96+
97+
assert error.value.response.status_code == 500
98+
assert "Internal Server Error" in str(error.value)
99+
assert "incident identifier 34566432" in str(error.value)
100+
101+
102+
def test_retry_error_unwraps_original_cause(monkeypatch):
103+
"""Verify wrapped retry failures propagate the underlying cause."""
104+
105+
original_error = ResponseError("too many 503 error responses")
106+
wrapped_error = RequestsRetryError(
107+
MaxRetryError(pool=None, url=TEST_ENDPOINT, reason=original_error)
108+
)
109+
110+
class SessionStub:
111+
def get(self, endpoint, params=None, headers=None):
112+
raise wrapped_error
113+
114+
monkeypatch.setattr(cwms.api, "SESSION", SessionStub())
115+
116+
with pytest.raises(ResponseError, match="too many 503 error responses"):
117+
cwms.api.get(TEST_ENDPOINT)

0 commit comments

Comments
 (0)