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 get_user and get_current_user methods. #7

Merged
merged 1 commit into from
Jan 31, 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
2 changes: 1 addition & 1 deletion src/posit/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@


class Auth(AuthBase):
def __init__(self, key) -> None:
def __init__(self, key: str) -> None:
self.key = key

def __call__(self, r: PreparedRequest) -> PreparedRequest:
Expand Down
30 changes: 22 additions & 8 deletions src/posit/client.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,36 @@
from requests import Session
from typing import Optional

from . import hooks

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


class Client:
users: Users

def __init__(
self, endpoint: Optional[str] = None, api_key: Optional[str] = None
self,
api_key: Optional[str] = None,
endpoint: Optional[str] = None,
) -> None:
builder = ConfigBuilder()
builder.set_api_key(api_key)
builder.set_endpoint(endpoint)
if api_key:
builder.set_api_key(api_key)
if endpoint:
builder.set_endpoint(endpoint)
self._config = builder.build()

if self._config.api_key is None:
raise ValueError("Invalid value for 'api_key': Must be a non-empty string.")
if self._config.endpoint is None:
raise ValueError(
"Invalid value for 'endpoint': Must be a non-empty string."
)
Comment on lines +20 to +31
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm making a strategic decision here to perform validation in the thing that needs the property. In this case, the Client needs the api_key and the endpoint.

I could argue that this should go in the ConfigBuilder#build method, but since I expect additional methods to consume Config, it makes sense to me to lay SoC here.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not sure I see the value of this ConfigBuilder factory abstraction. If Config needs to exist apart from Client, I'd more expect that the __init__ method for Config (and any subclasses thereof) would take the arguments it needs. Not that I'm the arbiter of it, but that feels more Pythonic to me.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

tl;dr I'm planning a change to simplify this.

The crux of the idea is to provide some flexibility in how configuration attributes can be set. For example, you may want to set the endpoint URL inline or via CONNECT_SERVER. There may be other locations where configuration may come from, such as environment files, secret managers, and distributed stores, to name a few. The ConfigProvider definition provides this functionality.

The ConfigBuilder#build iterates through the providers in some priority order to decide on the in-memory value. Right now the priority is "in-line > environment property"

But who am I kidding? I over-engineered the mess out of that file, and I had fun doing it...

I'll see if simplifying it reduces the validation burden.

Copy link
Collaborator

Choose a reason for hiding this comment

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

FWIW what I have in mind is something along the lines of https://github.com/databricks/databricks-sdk-py/blob/main/databricks/sdk/__init__.py#L89-L143, though with fewer arguments. Basically:

class Client(object):
    def __init__(self, server=os.getenv("CONNECT_SERVER"), token=os.getenv("CONNECT_API_KEY"), **kwargs, config=None):
        if config is None:
            config = Config(server, token, **kwargs)
...

(could even omit kwargs for now since I don't think there's any other way today)

That way there could be future subclasses of Config, and those could be either provided explicitly as config, or we could wire up more logic in the Config init or whatever classmethod to detect.


self._session = Session()
self._session.hooks["response"].append(hooks.handle_errors)
self._session.auth = Auth(self._config.api_key)

def get(self, endpoint: str, *args, **kwargs): # pragma: no cover
return self._session.request(
"GET", f"{self._config.endpoint}/{endpoint}", *args, **kwargs
)
self.users = Users(self._config.endpoint, self._session)
30 changes: 30 additions & 0 deletions src/posit/client_test.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import pytest

from unittest.mock import MagicMock, Mock, patch

from .client import Client
Expand All @@ -24,3 +26,31 @@ def test_init(self, Auth: MagicMock, ConfigBuilder: MagicMock, Session: MagicMoc
Session.assert_called_once()
Auth.assert_called_once_with(api_key)
assert client._config == config

@patch("posit.client.ConfigBuilder")
def test_init_without_api_key(self, ConfigBuilder: MagicMock):
api_key = None
endpoint = "http://foo.bar"
config = Mock()
config.api_key = api_key
config.endpoint = endpoint
builder = ConfigBuilder.return_value
builder.set_api_key = Mock()
builder.set_endpoint = Mock()
builder.build = Mock(return_value=config)
with pytest.raises(ValueError):
Client(api_key=api_key, endpoint=endpoint)

@patch("posit.client.ConfigBuilder")
def test_init_without_endpoint(self, ConfigBuilder: MagicMock):
api_key = "foobar"
endpoint = None
config = Mock()
config.api_key = api_key
config.endpoint = endpoint
builder = ConfigBuilder.return_value
builder.set_api_key = Mock()
builder.set_endpoint = Mock()
builder.build = Mock(return_value=config)
with pytest.raises(ValueError):
Client(api_key=api_key, endpoint=endpoint)
24 changes: 19 additions & 5 deletions src/posit/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import dataclasses

from abc import ABC, abstractmethod
from dataclasses import dataclass
Expand All @@ -20,10 +21,22 @@ def get_value(self, key: str) -> Optional[str]:
class EnvironmentConfigProvider(ConfigProvider):
def get_value(self, key: str) -> Optional[str]:
if key == "api_key":
return os.environ.get("CONNECT_API_KEY")
value = os.environ.get("CONNECT_API_KEY")
if value:
return value
if value == "":
raise ValueError(
"Invalid value for 'CONNECT_API_KEY': Must be a non-empty string."
)

if key == "endpoint":
return os.environ.get("CONNECT_SERVER")
value = os.environ.get("CONNECT_SERVER")
if value:
return os.path.join(value, "__api__")
if value == "":
raise ValueError(
"Invalid value for 'CONNECT_SERVER': Must be a non-empty string."
)

return None

Expand All @@ -36,7 +49,8 @@ def __init__(
self._providers = providers

def build(self) -> Config:
for key in Config.__annotations__:
for field in dataclasses.fields(Config):
key = field.name
if not getattr(self._config, key):
setattr(
self._config,
Expand All @@ -47,8 +61,8 @@ def build(self) -> Config:
)
return self._config

def set_api_key(self, api_key: Optional[str]):
def set_api_key(self, api_key: str):
self._config.api_key = api_key

def set_endpoint(self, endpoint: Optional[str]):
def set_endpoint(self, endpoint: str):
self._config.endpoint = endpoint
26 changes: 25 additions & 1 deletion src/posit/config_test.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import pytest

from unittest.mock import Mock, patch

from .config import Config, ConfigBuilder, EnvironmentConfigProvider
Expand All @@ -10,11 +12,33 @@ def test_get_api_key(self):
api_key = provider.get_value("api_key")
assert api_key == "foobar"

@patch.dict("os.environ", {"CONNECT_API_KEY": ""})
def test_get_api_key_empty(self):
provider = EnvironmentConfigProvider()
with pytest.raises(ValueError):
provider.get_value("api_key")

def test_get_api_key_miss(self):
provider = EnvironmentConfigProvider()
api_key = provider.get_value("api_key")
assert api_key is None

@patch.dict("os.environ", {"CONNECT_SERVER": "http://foo.bar"})
def test_get_endpoint(self):
provider = EnvironmentConfigProvider()
endpoint = provider.get_value("endpoint")
assert endpoint == "http://foo.bar"
assert endpoint == "http://foo.bar/__api__"

@patch.dict("os.environ", {"CONNECT_SERVER": ""})
def test_get_endpoint_empty(self):
provider = EnvironmentConfigProvider()
with pytest.raises(ValueError):
provider.get_value("endpoint")

def test_get_endpoint_miss(self):
provider = EnvironmentConfigProvider()
endpoint = provider.get_value("endpoint")
assert endpoint is None

def test_get_value_miss(self):
provider = EnvironmentConfigProvider()
Expand Down
11 changes: 11 additions & 0 deletions src/posit/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class ClientError(Exception):
def __init__(
self, error_code: int, error_message: str, http_status: int, http_message: str
):
self.error_code = error_code
self.error_message = error_message
self.http_status = http_status
self.http_message = http_message
super().__init__(
f"{error_message} (Error Code: {error_code}, HTTP Status: {http_status} {http_message})"
)
20 changes: 20 additions & 0 deletions src/posit/errors_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import pytest

from .errors import ClientError


class TestClientError:
def test(self):
error_code = 0
error_message = "foo"
http_status = 404
http_message = "Foo Bar"
with pytest.raises(
ClientError, match=r"foo \(Error Code: 0, HTTP Status: 404 Foo Bar\)"
):
raise ClientError(
error_code=error_code,
error_message=error_message,
http_status=http_status,
http_message=http_message,
)
15 changes: 15 additions & 0 deletions src/posit/hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from http.client import responses
from requests import Response

from .errors import ClientError


def handle_errors(response: Response, *args, **kwargs) -> Response:
if response.status_code >= 400 and response.status_code < 500:
data = response.json()
error_code = data["code"]
message = data["error"]
http_status = response.status_code
http_status_message = responses[http_status]
raise ClientError(error_code, message, http_status, http_status_message)
return response
20 changes: 20 additions & 0 deletions src/posit/hooks_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import pytest

from unittest.mock import Mock

from .hooks import handle_errors


class TestHandleErrors:
def test(self):
response = Mock()
response.status_code = 200
assert handle_errors(response) == response

def test_client_error(self):
response = Mock()
response.status_code = 400
response.json = Mock()
response.json.return_value = {"code": 0, "error": "foobar"}
with pytest.raises(Exception):
handle_errors(response)
17 changes: 17 additions & 0 deletions src/posit/users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import os

from requests import Session, Response


class Users:
def __init__(self, endpoint: str, session: Session) -> None:
self._endpoint = endpoint
self._session = session

def get_user(self, user_id: str) -> Response:
Copy link
Collaborator

Choose a reason for hiding this comment

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

We haven't had the design discussion on this, so take this is my opinion/taste on the matter, not strictly PR feedback. IMO it would be nice to have a common pattern to our collections of entities, like instead of client.users.get_user(user_id) client.content.get_content(content_id), more like:

client.users.find_one(id, name, email)
client.users.find(**kwargs)
client.content.find_one(id, title)
client.content.find_one(id).permissions.find(**kwargs)

etc.

Not attached to a particular verb per se, just that each collection has the same form, and the parameter names align. WTYT?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I like this model as well. It's been a while, but I recall using the ActiveRecord ORM pattern in Ruby-on-Rails. But, the advantage there was mapping relationships down to the SQL query layer for query optimization. We won't be able to do that here.

Do you have any prior art I could take a look at?

Let's see if we can get some consensus on a design pattern here. I'm too close to the implementation details on this one to know what our users would most enjoy.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah this isn't quite a SQL ORM. The model that came to mind first for me was pymongo, because we essentially have collections of records that we want to be able to (a) get one from or (b) get something iterable to scan over.

Other models we could look at could be pygithub, boto, [...] not an exhaustive list, just trying to think of other projects that users of this SDK may be familiar with and that have a similar objective--allow users to explore and manage collections of entities.

Copy link
Collaborator

Choose a reason for hiding this comment

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

#9

endpoint = os.path.join(self._endpoint, "v1/users", user_id)
return self._session.get(endpoint)
Copy link
Collaborator

Choose a reason for hiding this comment

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

So what does this return exactly? Looks like requests.Response? We probably want something better than that. (Can make a separate issue.)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

That's correct. Yes, we'll want something else. The most straightforward idea is to return the response body to the user on request. But I've been thinking through other ideas, like...

  • Return something that implements a to_dateframe (pandas) method.
  • Convert all responses into standard HATEOAS style that a paginator implementation understands.

Copy link
Collaborator

Choose a reason for hiding this comment

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

#10


def get_current_user(self) -> Response:
endpoint = os.path.join(self._endpoint, "v1/user")
return self._session.get(endpoint)
21 changes: 21 additions & 0 deletions src/posit/users_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from unittest.mock import Mock

from .users import Users


class TestUsers:
def test_get_user(self):
session = Mock()
session.get = Mock(return_value={})
users = Users(endpoint="http://foo.bar/", session=session)
response = users.get_user(user_id="foo")
assert response == {}
session.get.assert_called_once_with("http://foo.bar/v1/users/foo")

def test_get_current_user(self):
session = Mock()
session.get = Mock(return_value={})
users = Users(endpoint="http://foo.bar/", session=session)
response = users.get_current_user()
assert response == {}
session.get.assert_called_once_with("http://foo.bar/v1/user")
5 changes: 5 additions & 0 deletions tinkering.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from posit.client import Client

client = Client()
res = client.users.get_current_user()
print(res.json())
Comment on lines +1 to +5
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is the example shown in the Pull Request overview. You can invoke it as CONNECT_API_KEY=... CONNECT_SERVER=... python tinkering.py

Copy link
Collaborator

Choose a reason for hiding this comment

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

It would be good (soon, not here) to start thinking about things like quartodoc or other ways to structure code examples that are runable... and what example server we might want to run against for that, if at all.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, I like this idea a lot. I've used lots of Sphinx. The quartodoc setup looks similar.

I also had the thought to create a separate cookbooks module that's part of an extras package.

Loading