Skip to content

Commit cca0899

Browse files
authored
Use same requests session in oauth2 token provider as well. (#99)
* Use same requests session in oauth2 token provider as well. * Improve CC Flow Error Handling * add more tests and refactor verify behavior * add docs entry * feat: release v2.1.17
1 parent c904cf1 commit cca0899

File tree

10 files changed

+200
-34
lines changed

10 files changed

+200
-34
lines changed

docs/changelog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog],
66
and this project adheres to [Semantic Versioning].
77

8+
## [2.1.17] - 2025-05-19
9+
10+
## Added
11+
- Use same requests session in oauth2 token provider as well. (#99)
12+
813
## [2.1.16] - 2025-05-16
914

1015
## Added
@@ -342,6 +347,7 @@ and this project adheres to [Semantic Versioning].
342347
[Keep a Changelog]: https://keepachangelog.com/en/1.0.0/
343348
[Semantic Versioning]: https://semver.org/spec/v2.0.0.html
344349

350+
[2.1.17]: https://github.com/emdgroup/foundry-dev-tools/compare/v2.1.16...v2.1.17
345351
[2.1.16]: https://github.com/emdgroup/foundry-dev-tools/compare/v2.1.15...v2.1.16
346352
[2.1.15]: https://github.com/emdgroup/foundry-dev-tools/compare/v2.1.14...v2.1.15
347353
[2.1.14]: https://github.com/emdgroup/foundry-dev-tools/compare/v2.1.13...v2.1.14

docs/configuration.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,34 @@ def compute(ctx, output_transform, source: ResolvedSource):
184184
)
185185
```
186186

187+
### Configuration for Python Functions (Serverless)
188+
189+
```
190+
from functions.api import function
191+
from functions.sources import get_source
192+
193+
import json
194+
from foundry_dev_tools import FoundryContext, OAuthTokenProvider, Config
195+
196+
197+
@function(sources=["SourceFoundryApi"])
198+
def get_user_info() -> str:
199+
source = get_source("LsRestMTableauExtractSchedulerFoundryApi")
200+
client = source.get_https_connection().get_client()
201+
202+
ctx = FoundryContext(
203+
config=Config(requests_session=client),
204+
token_provider=OAuthTokenProvider(
205+
host="your-stack.palantirfoundry.com",
206+
client_id=source.get_secret("ClientID"),
207+
client_secret=source.get_secret("ClientSecret"),
208+
grant_type="client_credentials",
209+
),
210+
)
211+
return json.dumps(ctx.multipass.get_user_info())
212+
213+
```
214+
187215
## How the Configuration Gets Loaded and Merged
188216

189217
For example if there are the files /etc/foundry-dev-tools/config.toml:

libs/foundry-dev-tools/src/foundry_dev_tools/clients/context_client.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
import logging
66
import numbers
7+
import os
78
import time
89
import typing
10+
from pathlib import Path
911
from typing import TYPE_CHECKING
1012

1113
import requests
@@ -72,8 +74,9 @@ class ContextHTTPClient(requests.Session):
7274

7375
def __init__(self, debug: bool = False, requests_ca_bundle: PathLike[str] | None = None) -> None:
7476
self.debug = debug
75-
self.requests_ca_bundle = requests_ca_bundle
7677
super().__init__()
78+
if requests_ca_bundle is not None and Path(requests_ca_bundle).is_file():
79+
self.verify = os.fspath(requests_ca_bundle)
7780

7881
self._counter = 0
7982

@@ -117,8 +120,6 @@ def request(
117120
cert: see :py:meth:`requests.Session.request`
118121
json: see :py:meth:`requests.Session.request`
119122
"""
120-
if verify is None and (rcab := self.requests_ca_bundle) is not None:
121-
verify = rcab
122123
if self.debug:
123124
self._counter = count = self._counter + 1
124125
LOGGER.debug(f"(r{count}) Making {method!s} request to {url!s}") # noqa: G004

libs/foundry-dev-tools/src/foundry_dev_tools/config/config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,14 @@ class Config:
4646

4747
def __init__(
4848
self,
49-
requests_ca_bundle: PathLike[str] | None = None,
49+
requests_ca_bundle: PathLike[str] | str | None = None,
5050
transforms_sql_sample_row_limit: int = 5000,
5151
transforms_sql_dataset_size_threshold: int = 500,
5252
transforms_sql_sample_select_random: bool = False,
5353
transforms_force_full_dataset_download: bool = False,
54-
cache_dir: PathLike[str] | None = None,
54+
cache_dir: PathLike[str] | str | None = None,
5555
transforms_freeze_cache: bool = False,
56-
transforms_output_folder: PathLike[str] | None = None,
56+
transforms_output_folder: PathLike[str] | str | None = None,
5757
rich_traceback: bool = False,
5858
debug: bool = False,
5959
requests_session: requests.Session | None = None,

libs/foundry-dev-tools/src/foundry_dev_tools/config/context.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import os
65
from functools import cached_property, partial
76
from typing import TYPE_CHECKING
87

@@ -69,12 +68,11 @@ def __init__(
6968
else:
7069
self.client = self.config.requests_session
7170

71+
self.token_provider.set_requests_session(self.client)
7272
self.client.auth = lambda r: self.token_provider.requests_auth_handler(r)
7373
self.client.headers["User-Agent"] = requests.utils.default_user_agent(
7474
f"foundry-dev-tools/{__version__}/python-requests"
7575
)
76-
if self.config.requests_ca_bundle:
77-
self.verify = os.fspath(self.config.requests_ca_bundle)
7876

7977
if self.config.rich_traceback:
8078
from rich.traceback import install

libs/foundry-dev-tools/src/foundry_dev_tools/config/token_provider.py

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313

1414
from foundry_dev_tools.config.config_types import Host
1515
from foundry_dev_tools.errors.config import TokenProviderConfigError
16-
from foundry_dev_tools.errors.meta import FoundryAPIError
16+
from foundry_dev_tools.errors.handling import ErrorHandlingConfig, raise_foundry_api_error
17+
from foundry_dev_tools.errors.multipass import ClientAuthenticationFailedError
1718
from foundry_dev_tools.utils.config import entry_point_fdt_token_provider
1819

1920
if TYPE_CHECKING:
@@ -52,6 +53,9 @@ def requests_auth_handler(self, r: requests.PreparedRequest) -> requests.Prepare
5253
r.headers.setdefault("authorization", f"Bearer {self.token}")
5354
return r
5455

56+
def set_requests_session(self, session: requests.Session) -> None:
57+
"""No-op by default."""
58+
5559

5660
class JWTTokenProvider(TokenProvider):
5761
"""Provides Host and Token."""
@@ -150,6 +154,7 @@ def __init__(
150154
self.scopes = DEFAULT_OAUTH_SCOPES
151155
else:
152156
self.scopes = scopes
157+
self._requests_session = requests.Session()
153158

154159
def _scopes_to_list(self, scopes: list[str] | str | None) -> list[str] | None:
155160
if scopes is not None and isinstance(scopes, str):
@@ -168,27 +173,38 @@ def _request_token(self) -> tuple[Token, float]:
168173
)
169174
return credentials.token, credentials.expiry.timestamp()
170175
if self.grant_type == "client_credentials" and self._client_secret is not None:
171-
resp = requests.request(
172-
"POST",
173-
f"{self.host.url}/multipass/api/oauth2/token",
174-
data={"grant_type": "client_credentials", "scope": " ".join(self.scopes)}
175-
if self.scopes
176-
else {"grant_type": "client_credentials"},
177-
headers={
178-
"Content-Type": "application/x-www-form-urlencoded",
179-
"Authorization": "Basic "
180-
+ base64.b64encode(
181-
bytes(
182-
self._client_id + ":" + self._client_secret,
183-
"ISO-8859-1",
184-
),
185-
).decode("ascii"),
186-
},
187-
timeout=30,
176+
# since we share the same requests session everywhere and on this session
177+
# the auth is set to a lambda function we need to manually disable this
178+
# for the single token call
179+
auth_handler = self._requests_session.auth
180+
self._requests_session.auth = None
181+
try:
182+
resp = self._requests_session.request(
183+
"POST",
184+
f"{self.host.url}/multipass/api/oauth2/token",
185+
data={"grant_type": "client_credentials", "scope": " ".join(self.scopes)}
186+
if self.scopes
187+
else {"grant_type": "client_credentials"},
188+
headers={
189+
"Content-Type": "application/x-www-form-urlencoded",
190+
"Authorization": "Basic "
191+
+ base64.b64encode(
192+
bytes(
193+
self._client_id + ":" + self._client_secret,
194+
"ISO-8859-1",
195+
),
196+
).decode("ascii"),
197+
},
198+
timeout=30,
199+
)
200+
finally:
201+
# add original auth handler again
202+
self._requests_session.auth = auth_handler
203+
raise_foundry_api_error(
204+
resp,
205+
error_handling=ErrorHandlingConfig({401: ClientAuthenticationFailedError}, client_id=self._client_id),
188206
)
189207
credentials = resp.json()
190-
if "error" in credentials:
191-
raise FoundryAPIError(resp)
192208
return credentials["access_token"], credentials["expires_in"] + time.time()
193209
if self._client_secret is None:
194210
msg = f"For grant type {self.grant_type} you need to set a client_secret."
@@ -197,6 +213,10 @@ def _request_token(self) -> tuple[Token, float]:
197213
msg = f"Grant type {self.grant_type} is not implemented."
198214
raise NotImplementedError(msg)
199215

216+
def set_requests_session(self, session: requests.Session) -> None:
217+
"""Sets request session used for client credentials grant.."""
218+
self._requests_session = session
219+
200220

201221
class AppServiceTokenProvider(CachedTokenProvider):
202222
"""Token Provider for the AppService, which gets the token via a header from flask/dash/streamlit."""
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
11
"""Multipass specific errors."""
22

3+
import requests
4+
35
from foundry_dev_tools.errors.meta import FoundryAPIError
46

57

68
class DuplicateGroupNameError(FoundryAPIError):
79
"""Exception is thrown when the group name already exists."""
810

911
message = "The group name already exists!"
12+
13+
14+
class ClientAuthenticationFailedError(FoundryAPIError):
15+
"""Exception is thrown when client credentials grant failed."""
16+
17+
def __init__(
18+
self, response: requests.Response | None = None, info: str | None = None, client_id: str | None = None
19+
):
20+
self.client_id = client_id
21+
self.message = "Client authentication failed (invalid_client)."
22+
23+
super().__init__(response=response, info=info, client_id=client_id)

tests/unit/clients/test_context_client.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
import os
12
import time
3+
from pathlib import Path
24
from unittest import mock
35

6+
import pytest
47
import requests
58
from requests_mock import ANY
69

10+
from foundry_dev_tools import Config
711
from foundry_dev_tools.__about__ import __version__
8-
from foundry_dev_tools.clients.context_client import DEFAULT_TIMEOUT
9-
from tests.unit.mocks import MockOAuthTokenProvider
12+
from foundry_dev_tools.clients.context_client import DEFAULT_TIMEOUT, ContextHTTPClient
13+
from tests.unit.mocks import FoundryMockContext, MockOAuthTokenProvider, MockTokenProvider
1014

1115

1216
def test_context_http_client(test_context_mock, foundry_client_id):
@@ -51,3 +55,41 @@ def test_retry_on_connection_error(test_context_mock):
5155
m.side_effect = [requests.exceptions.ConnectionError(), response_mock]
5256
response = test_context_mock.client.request("GET", "test_call_args")
5357
assert response is response_mock
58+
59+
60+
def test_requests_ca_bundle(request, tmp_path_factory):
61+
cert_dir = tmp_path_factory.mktemp(f"foundry_dev_tools_test__{request.node.name}").absolute()
62+
with Path.open(cert_dir / "ca-bundle.pem", "w") as f:
63+
f.write("test")
64+
client1 = FoundryMockContext(
65+
Config(
66+
requests_ca_bundle=os.fspath(cert_dir / "ca-bundle.pem"),
67+
),
68+
MockTokenProvider(jwt=request.node.name + "_token"),
69+
).client
70+
assert client1.verify == os.fspath(cert_dir / "ca-bundle.pem")
71+
client_with_pathlib = FoundryMockContext(
72+
Config(
73+
requests_ca_bundle=cert_dir / "ca-bundle.pem",
74+
),
75+
MockTokenProvider(jwt=request.node.name + "_token"),
76+
).client
77+
assert client_with_pathlib.verify == os.fspath(cert_dir / "ca-bundle.pem")
78+
79+
client = ContextHTTPClient(debug=False, requests_ca_bundle=None)
80+
assert client.verify is True
81+
82+
with pytest.raises(TypeError):
83+
_ = ContextHTTPClient(debug=False, requests_ca_bundle=False)
84+
85+
session = requests.Session()
86+
assert session.verify is True
87+
88+
89+
def test_verify_is_passed(test_context_mock):
90+
client1 = test_context_mock.client
91+
assert client1.verify is True
92+
test_context_mock.mock_adapter.register_uri(ANY, ANY)
93+
94+
req = test_context_mock.client.request("POST", "http+mock://test_authorization", verify=False).request
95+
assert req.verify is False

tests/unit/config/test_context.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
from unittest import mock
77

88
import pytest
9+
import requests_mock
910

10-
from foundry_dev_tools import FoundryContext
11+
from foundry_dev_tools import Config, FoundryContext
1112
from foundry_dev_tools.errors.config import FoundryConfigError
12-
from tests.unit.mocks import TEST_DOMAIN
13+
from foundry_dev_tools.errors.multipass import ClientAuthenticationFailedError
14+
from tests.unit.mocks import TEST_DOMAIN, FoundryMockContext, MockOAuthTokenProvider
1315

1416
if TYPE_CHECKING:
1517
from pathlib import Path
@@ -26,3 +28,27 @@ def test_app_service(mock_config_location: dict[Path, None]):
2628
):
2729
warnings.simplefilter("error") # raise errors on warnings
2830
_ = FoundryContext()
31+
32+
33+
def test_client_credentials_error_handling(foundry_token):
34+
ctx = FoundryMockContext(
35+
config=Config(),
36+
token_provider=MockOAuthTokenProvider(
37+
client_id="test_client_id",
38+
client_secret="test", # noqa: S106
39+
grant_type="client_credentials",
40+
),
41+
)
42+
with requests_mock.Mocker() as m:
43+
m.post(
44+
f"{ctx.token_provider.host.url}/multipass/api/oauth2/token",
45+
json={"error": "invalid_client", "error_description": "Client authentication failed"},
46+
status_code=401,
47+
)
48+
m.get(
49+
f"{ctx.token_provider.host.url}/multipass/api/me",
50+
json={"id": "id", "attributes": "a", "username": "u"},
51+
)
52+
with pytest.raises(ClientAuthenticationFailedError) as catched:
53+
ctx.get_user_info()
54+
assert catched.value.client_id == "test_client_id"

0 commit comments

Comments
 (0)