-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: adds users implementation with lazy server-side fetching
- Loading branch information
Showing
13 changed files
with
530 additions
and
142 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,10 +1 @@ | ||
from typing import Optional | ||
|
||
from .client import Client | ||
|
||
|
||
def make_client( | ||
api_key: Optional[str] = None, endpoint: Optional[str] = None | ||
) -> Client: | ||
client = Client(api_key=api_key, endpoint=endpoint) | ||
return client | ||
from .client import create_client # noqa |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,13 @@ | ||
from requests import PreparedRequest | ||
from requests.auth import AuthBase | ||
|
||
from .config import Config | ||
|
||
|
||
class Auth(AuthBase): | ||
def __init__(self, key: str) -> None: | ||
self.key = key | ||
def __init__(self, config: Config) -> None: | ||
self._config = config | ||
|
||
def __call__(self, r: PreparedRequest) -> PreparedRequest: | ||
r.headers["Authorization"] = f"Key {self.key}" | ||
r.headers["Authorization"] = f"Key {self._config.api_key}" | ||
return r |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,13 +1,15 @@ | ||
from unittest.mock import Mock | ||
from unittest.mock import MagicMock, Mock, patch | ||
|
||
from .auth import Auth | ||
|
||
|
||
class TestAuth: | ||
def test_auth_headers(self): | ||
key = "foobar" | ||
auth = Auth(key=key) | ||
@patch("posit.connect.auth.Config") | ||
def test_auth_headers(self, Config: MagicMock): | ||
config = Config.return_value | ||
config.api_key = "foobar" | ||
auth = Auth(config=config) | ||
r = Mock() | ||
r.headers = {} | ||
auth(r) | ||
assert r.headers == {"Authorization": f"Key {key}"} | ||
assert r.headers == {"Authorization": f"Key {config.api_key}"} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,52 +1,40 @@ | ||
import pytest | ||
|
||
from unittest.mock import MagicMock, patch | ||
|
||
from .client import Client, _get_api_key, _get_endpoint | ||
from .client import Client, create_client | ||
|
||
|
||
class TestCreateClient: | ||
@patch("posit.connect.client.Client") | ||
def test(self, Client: MagicMock): | ||
api_key = "foobar" | ||
endpoint = "http://foo.bar" | ||
with create_client(api_key=api_key, endpoint=endpoint) as client: | ||
assert client == Client.return_value | ||
|
||
|
||
class TestClient: | ||
@patch("posit.connect.client.Users") | ||
@patch("posit.connect.client.Session") | ||
@patch("posit.connect.client.Config") | ||
@patch("posit.connect.client.Auth") | ||
def test_init(self, Auth: MagicMock, Session: MagicMock, Users: MagicMock): | ||
def test_init( | ||
self, Auth: MagicMock, Config: MagicMock, Session: MagicMock, Users: MagicMock | ||
): | ||
api_key = "foobar" | ||
endpoint = "http://foo.bar" | ||
client = Client(api_key=api_key, endpoint=endpoint) | ||
assert client._api_key == api_key | ||
assert client._endpoint == endpoint | ||
Client(api_key=api_key, endpoint=endpoint) | ||
config = Config.return_value | ||
Auth.assert_called_once_with(config=config) | ||
Config.assert_called_once_with(api_key=api_key, endpoint=endpoint) | ||
Session.assert_called_once() | ||
Auth.assert_called_once_with(api_key) | ||
Users.assert_called_once_with(endpoint, Session.return_value) | ||
|
||
|
||
class TestGetApiKey: | ||
@patch.dict("os.environ", {"CONNECT_API_KEY": "foobar"}) | ||
def test_get_api_key(self): | ||
api_key = _get_api_key() | ||
assert api_key == "foobar" | ||
|
||
@patch.dict("os.environ", {"CONNECT_API_KEY": ""}) | ||
def test_get_api_key_empty(self): | ||
with pytest.raises(ValueError): | ||
_get_api_key() | ||
|
||
def test_get_api_key_miss(self): | ||
with pytest.raises(ValueError): | ||
_get_api_key() | ||
Users.assert_called_once_with(config=config, session=Session.return_value) | ||
|
||
|
||
class TestGetEndpoint: | ||
@patch.dict("os.environ", {"CONNECT_SERVER": "http://foo.bar"}) | ||
def test_get_endpoint(self): | ||
endpoint = _get_endpoint() | ||
assert endpoint == "http://foo.bar" | ||
|
||
@patch.dict("os.environ", {"CONNECT_SERVER": ""}) | ||
def test_get_endpoint_empty(self): | ||
with pytest.raises(ValueError): | ||
_get_endpoint() | ||
|
||
def test_get_endpoint_miss(self): | ||
with pytest.raises(ValueError): | ||
_get_endpoint() | ||
@patch("posit.connect.client.Users") | ||
@patch("posit.connect.client.Session") | ||
@patch("posit.connect.client.Auth") | ||
def test_del(self, Auth: MagicMock, Session: MagicMock, Users: MagicMock): | ||
api_key = "foobar" | ||
endpoint = "http://foo.bar" | ||
client = Client(api_key=api_key, endpoint=endpoint) | ||
del client | ||
Session.return_value.close.assert_called_once() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
import os | ||
|
||
from typing import Optional | ||
|
||
|
||
def _get_api_key() -> str: | ||
"""Gets the API key from the environment variable 'CONNECT_API_KEY'. | ||
Raises: | ||
ValueError: if CONNECT_API_KEY is not set or invalid | ||
Returns: | ||
The API key | ||
""" | ||
value = os.environ.get("CONNECT_API_KEY") | ||
if value is None or value == "": | ||
raise ValueError( | ||
"Invalid value for 'CONNECT_API_KEY': Must be a non-empty string." | ||
) | ||
return value | ||
|
||
|
||
def _get_endpoint() -> str: | ||
"""Gets the endpoint from the environment variable 'CONNECT_SERVER'. | ||
The `requests` library uses 'endpoint' instead of 'server'. We will use 'endpoint' from here forward for consistency. | ||
Raises: | ||
ValueError: if CONNECT_SERVER is not set or invalid. | ||
Returns: | ||
The endpoint. | ||
""" | ||
value = os.environ.get("CONNECT_SERVER") | ||
if value is None or value == "": | ||
raise ValueError( | ||
"Invalid value for 'CONNECT_SERVER': Must be a non-empty string." | ||
) | ||
return value | ||
|
||
|
||
def _format_endpoint(endpoint: str) -> str: | ||
# todo - format endpoint url and ake sure it ends with __api__ | ||
return endpoint | ||
|
||
|
||
class Config: | ||
"""Derived configuration properties""" | ||
|
||
api_key: str | ||
endpoint: str | ||
|
||
def __init__( | ||
self, api_key: Optional[str] = None, endpoint: Optional[str] = None | ||
) -> None: | ||
self.api_key = api_key or _get_api_key() | ||
self.endpoint = _format_endpoint(endpoint or _get_endpoint()) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
import pytest | ||
|
||
from unittest.mock import patch | ||
|
||
from .config import Config, _get_api_key, _get_endpoint | ||
|
||
|
||
class TestGetApiKey: | ||
@patch.dict("os.environ", {"CONNECT_API_KEY": "foobar"}) | ||
def test_get_api_key(self): | ||
api_key = _get_api_key() | ||
assert api_key == "foobar" | ||
|
||
@patch.dict("os.environ", {"CONNECT_API_KEY": ""}) | ||
def test_get_api_key_empty(self): | ||
with pytest.raises(ValueError): | ||
_get_api_key() | ||
|
||
def test_get_api_key_miss(self): | ||
with pytest.raises(ValueError): | ||
_get_api_key() | ||
|
||
|
||
class TestGetEndpoint: | ||
@patch.dict("os.environ", {"CONNECT_SERVER": "http://foo.bar"}) | ||
def test_get_endpoint(self): | ||
endpoint = _get_endpoint() | ||
assert endpoint == "http://foo.bar" | ||
|
||
@patch.dict("os.environ", {"CONNECT_SERVER": ""}) | ||
def test_get_endpoint_empty(self): | ||
with pytest.raises(ValueError): | ||
_get_endpoint() | ||
|
||
def test_get_endpoint_miss(self): | ||
with pytest.raises(ValueError): | ||
_get_endpoint() | ||
|
||
|
||
class TestConfig: | ||
def test_init(self): | ||
api_key = "foobar" | ||
endpoint = "http://foo.bar" | ||
config = Config(api_key=api_key, endpoint=endpoint) | ||
assert config.api_key == api_key | ||
assert config.endpoint == endpoint |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
import os | ||
import requests | ||
|
||
_MAX_PAGE_SIZE = 500 | ||
|
||
|
||
def get_users( | ||
endpoint: str, | ||
session: requests.Session, | ||
/, | ||
page_number: int, | ||
*, | ||
page_size: int = 500, | ||
): | ||
""" | ||
Fetches the current page of users. | ||
Returns: | ||
List[User]: A list of User objects representing the fetched users. | ||
""" | ||
# Construct the endpoint URL. | ||
endpoint = os.path.join(endpoint, "v1/users") | ||
# Redefine the page number using 1-based indexing. | ||
page_number = page_number + 1 | ||
# Define query parameters for pagination. | ||
params = {"page_number": page_number, "page_size": page_size} | ||
# Send a GET request to the endpoint with the specified parameters. | ||
response = session.get(endpoint, params=params) | ||
# Convert response to dict | ||
json = response.json() | ||
# Parse the JSON response and extract the results. | ||
results = json["results"] | ||
# Mark exhausted if the result size is less than the maximum page size. | ||
exhausted = len(results) < page_size | ||
# Create User objects from the results and return them as a list. | ||
return (results, exhausted) |
Oops, something went wrong.