Skip to content

Commit

Permalink
feat: adds users implementation with lazy server-side fetching
Browse files Browse the repository at this point in the history
  • Loading branch information
tdstein committed Feb 13, 2024
1 parent de8d922 commit 7855a6b
Show file tree
Hide file tree
Showing 13 changed files with 530 additions and 142 deletions.
8 changes: 8 additions & 0 deletions .github/workflows/test.yaml → .github/workflows/push.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,13 @@ jobs:
python-version: ${{ matrix.python-version }}
- run: just deps
- run: just test

cov:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: extractions/setup-just@v1
- uses: actions/setup-python@v5
- run: just deps
- run: just cov
- run: just lint
11 changes: 1 addition & 10 deletions src/posit/connect/__init__.py
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
8 changes: 5 additions & 3 deletions src/posit/connect/auth.py
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
12 changes: 7 additions & 5 deletions src/posit/connect/auth_test.py
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}"}
82 changes: 44 additions & 38 deletions src/posit/connect/client.py
Original file line number Diff line number Diff line change
@@ -1,48 +1,34 @@
import os
from __future__ import annotations

from contextlib import contextmanager
from requests import Session
from typing import Optional
from typing import Generator, Optional

from . import hooks

from .auth import Auth
from .config import Config
from .users import Users


def _get_api_key() -> str:
"""Gets the API key from the environment variable 'CONNECT_API_KEY'.
@contextmanager
def create_client(
api_key: Optional[str] = None, endpoint: Optional[str] = None
) -> Generator[Client, None, None]:
"""Creates a new :class:`Client` instance
Raises:
ValueError: if CONNECT_API_KEY is not set or invalid
Keyword Arguments:
api_key -- an api_key for authentication (default: {None})
endpoint -- a base api endpoint (url) (default: {None})
Returns:
The API key
A :class:`Client` instance
"""
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
client = Client(api_key=api_key, endpoint=endpoint)
try:
yield client
finally:
del client


class Client:
Expand All @@ -53,9 +39,29 @@ def __init__(
api_key: Optional[str] = None,
endpoint: Optional[str] = None,
) -> None:
self._api_key = api_key or _get_api_key()
self._endpoint = endpoint or _get_endpoint()
self._session = Session()
self._session.hooks["response"].append(hooks.handle_errors)
self._session.auth = Auth(self._api_key)
self.users = Users(self._endpoint, self._session)
"""
Initialize the Client instance.
Args:
api_key (str, optional): API key for authentication. Defaults to None.
endpoint (str, optional): API endpoint URL. Defaults to None.
"""
# Create a Config object.
config = Config(api_key=api_key, endpoint=endpoint)
# Create a Session object for making HTTP requests.
session = Session()
# Authenticate the session using the provided Config.
session.auth = Auth(config=config)
# Add error handling hooks to the session.
session.hooks["response"].append(hooks.handle_errors)

# Initialize the Users instance.
self.users = Users(config=config, session=session)
# Store the Session object.
self._session = session

def __del__(self):
"""
Close the session when the Client instance is deleted.
"""
self._session.close()
68 changes: 28 additions & 40 deletions src/posit/connect/client_test.py
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()
57 changes: 57 additions & 0 deletions src/posit/connect/config.py
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())
46 changes: 46 additions & 0 deletions src/posit/connect/config_test.py
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
36 changes: 36 additions & 0 deletions src/posit/connect/endpoints.py
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)
Loading

0 comments on commit 7855a6b

Please sign in to comment.