Skip to content

Commit 3e16a56

Browse files
committed
Add requests.Session wrapper
1 parent ab357dc commit 3e16a56

File tree

4 files changed

+371
-8
lines changed

4 files changed

+371
-8
lines changed
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Copyright 2025 EPAM Systems
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License
14+
15+
"""This module designed to help with synchronous HTTP request/response handling."""
16+
17+
from types import TracebackType
18+
from typing import Any, Callable, Optional, Type, Union
19+
20+
import requests
21+
22+
from reportportal_client._internal.services.auth import Auth
23+
24+
AUTH_PROBLEM_STATUSES: set = {401, 403}
25+
26+
27+
class ClientSession:
28+
"""Class wraps requests.Session and adds authentication support."""
29+
30+
_client: requests.Session
31+
__auth: Optional[Auth]
32+
33+
def __init__(
34+
self,
35+
auth: Optional[Auth] = None,
36+
):
37+
"""Initialize an instance of the session with arguments.
38+
39+
:param auth: authentication instance to use for requests
40+
"""
41+
self._client = requests.Session()
42+
self.__auth = auth
43+
44+
def __request(self, method: Callable, url: Union[str, bytes], **kwargs: Any) -> requests.Response:
45+
"""Make a request with authentication support.
46+
47+
The method adds Authorization header if auth is configured and handles auth refresh
48+
on 401/403 responses.
49+
"""
50+
# Clone kwargs and add Authorization header if auth is configured
51+
request_kwargs = kwargs.copy()
52+
if self.__auth:
53+
auth_header = self.__auth.get()
54+
if auth_header:
55+
if "headers" not in request_kwargs:
56+
request_kwargs["headers"] = {}
57+
else:
58+
request_kwargs["headers"] = request_kwargs["headers"].copy()
59+
request_kwargs["headers"]["Authorization"] = auth_header
60+
61+
result = method(url, **request_kwargs)
62+
63+
# Check for authentication errors
64+
if result.status_code in AUTH_PROBLEM_STATUSES and self.__auth:
65+
refreshed_header = self.__auth.refresh()
66+
if refreshed_header:
67+
# Retry with new auth header
68+
request_kwargs["headers"] = request_kwargs.get("headers", {}).copy()
69+
request_kwargs["headers"]["Authorization"] = refreshed_header
70+
result = method(url, **request_kwargs)
71+
72+
return result
73+
74+
def get(self, url: str, **kwargs: Any) -> requests.Response:
75+
"""Perform HTTP GET request."""
76+
return self.__request(self._client.get, url, **kwargs)
77+
78+
def post(self, url: str, **kwargs: Any) -> requests.Response:
79+
"""Perform HTTP POST request."""
80+
return self.__request(self._client.post, url, **kwargs)
81+
82+
def put(self, url: str, **kwargs: Any) -> requests.Response:
83+
"""Perform HTTP PUT request."""
84+
return self.__request(self._client.put, url, **kwargs)
85+
86+
def mount(self, prefix: str, adapter: requests.adapters.BaseAdapter) -> None:
87+
"""Mount an adapter to a specific URL prefix.
88+
89+
:param prefix: URL prefix (e.g., 'http://', 'https://')
90+
:param adapter: Adapter instance to mount
91+
"""
92+
self._client.mount(prefix, adapter)
93+
94+
def close(self) -> None:
95+
"""Gracefully close internal requests.Session class instance."""
96+
self._client.close()
97+
98+
def __enter__(self) -> "ClientSession":
99+
"""Auxiliary method which controls what `with` construction does on block enter."""
100+
return self
101+
102+
def __exit__(
103+
self,
104+
exc_type: Optional[Type[BaseException]],
105+
exc_val: Optional[BaseException],
106+
exc_tb: Optional[TracebackType],
107+
) -> None:
108+
"""Auxiliary method which controls what `with` construction does on block exit."""
109+
self.close()

tests/_internal/aio/test_http.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131

3232
# noinspection PyProtectedMember
3333
from reportportal_client._internal.aio.http import RetryingClientSession
34+
35+
# noinspection PyProtectedMember
3436
from reportportal_client._internal.services.auth import ApiKeyAuthAsync
3537

3638
HTTP_TIMEOUT_TIME = 1.2
@@ -93,12 +95,10 @@ def do_GET(self):
9395

9496
SERVER_PORT = 8000
9597
SERVER_ADDRESS = ("", SERVER_PORT)
96-
SERVER_CLASS = socketserver.TCPServer
97-
SERVER_HANDLER_CLASS = http.server.BaseHTTPRequestHandler
9898

9999

100-
def get_http_server(server_class=SERVER_CLASS, server_address=SERVER_ADDRESS, server_handler=SERVER_HANDLER_CLASS):
101-
httpd = server_class(server_address, server_handler)
100+
def get_http_server(*, server_handler, server_address=SERVER_ADDRESS):
101+
httpd = socketserver.TCPServer(server_address, server_handler)
102102
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
103103
thread.start()
104104
return httpd

tests/_internal/services/test_auth.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -259,10 +259,12 @@ def test_refresh_method_on_valid_token(self):
259259
assert result2 == f"Bearer {new_token}"
260260
assert oauth._access_token == new_token
261261

262-
# Verify it tried to use refresh token (since refresh clears the access token but refresh token is still available)
262+
# Verify it tried to use refresh token (since refresh clears the access token but refresh token is still
263+
# available)
263264
call_args = mock_session.post.call_args
264265
data = call_args[1]["data"]
265-
# After refresh() clears the access token, it should try to use refresh_token grant since refresh token is still available
266+
# After refresh() clears the access token, it should try to use refresh_token grant since refresh token is
267+
# still available
266268
assert data["grant_type"] == "refresh_token"
267269
assert data["refresh_token"] == REFRESH_TOKEN
268270

@@ -523,10 +525,12 @@ async def test_refresh_method_on_valid_token(self):
523525
assert result2 == f"Bearer {new_token}"
524526
assert oauth._access_token == new_token
525527

526-
# Verify it tried to use refresh token (since refresh clears the access token but refresh token is still available)
528+
# Verify it tried to use refresh token (since refresh clears the access token but refresh token is still
529+
# available)
527530
call_args = mock_session.post.call_args
528531
data = call_args[1]["data"]
529-
# After refresh() clears the access token, it should try to use refresh_token grant since refresh token is still available
532+
# After refresh() clears the access token, it should try to use refresh_token grant since refresh token is
533+
# still available
530534
assert data["grant_type"] == "refresh_token"
531535
assert data["refresh_token"] == REFRESH_TOKEN
532536

0 commit comments

Comments
 (0)