Skip to content

Commit

Permalink
feat: adds abstraction for resources and subsequent implementation of…
Browse files Browse the repository at this point in the history
… users. (#20)
  • Loading branch information
tdstein authored Feb 15, 2024
1 parent 0a0d3fa commit 5d95633
Show file tree
Hide file tree
Showing 16 changed files with 498 additions and 151 deletions.
48 changes: 48 additions & 0 deletions .github/workflows/pull-request.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Pull Request
on:
pull_request:
concurrency:
group: ${{ github.head_ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version:
- '3.8'
- '3.9'
- '3.10'
- '3.11'
- '3.12'
steps:
- uses: actions/checkout@v4
- uses: extractions/setup-just@v1
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: just deps
- run: just test
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: extractions/setup-just@v1
- uses: actions/setup-python@v5
- run: just deps
- run: just lint
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 test
- run: just cov xml
- if: always()
uses: orgoro/[email protected]
with:
coverageFile: coverage.xml
token: ${{ secrets.GITHUB_TOKEN }}
24 changes: 0 additions & 24 deletions .github/workflows/test.yaml

This file was deleted.

2 changes: 1 addition & 1 deletion src/posit/connect/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
from .client import Client # noqa
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}"}
86 changes: 45 additions & 41 deletions src/posit/connect/client.py
Original file line number Diff line number Diff line change
@@ -1,61 +1,65 @@
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 .users import Users
from .config import Config
from .users import LazyUsers, 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:
users: Users

def __init__(
self,
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 = LazyUsers(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()
73 changes: 32 additions & 41 deletions src/posit/connect/client_test.py
Original file line number Diff line number Diff line change
@@ -1,52 +1,43 @@
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.LazyUsers")
@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,
LazyUsers: 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()
LazyUsers.assert_called_once_with(config=config, session=Session.return_value)

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()
@patch("posit.connect.client.Session")
@patch("posit.connect.client.Auth")
def test_del(self, Auth: MagicMock, Session: 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())
Loading

0 comments on commit 5d95633

Please sign in to comment.