Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adds abstraction for resources and subsequent implementation of users. #20

Merged
merged 3 commits into from
Feb 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 }}
Comment on lines +44 to +48
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cov will fails < 100%, but show the report anyways in the pull request.

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:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might consider using NewType to make distinct string types for things that have special meaning (API keys, server URLs, endpoints, ...).

"""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
Loading