diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 00000000..b4297af5 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,24 @@ +name: Main +on: + push: + branches: + - main +jobs: + default: + 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: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: make + - run: make install diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 4dd598ef..2ed545a2 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -43,6 +43,4 @@ jobs: with: coverageFile: coverage.xml thresholdAll: 0.8 - thresholdNew: 0.9 - thresholdModified: 0.9 token: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 9ddbc34c..83ae98c2 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,26 @@ -# posit-sdk-py -Posit SDK for Python +# Posit SDK for Python + +This package provides a Pythonic interface for developers to work against the public APIs of Posit's professional products. It is intended to be lightweight yet expressive. + +> The Posit SDK is in the very early stages of development, and currently only Posit Connect has any support. + +## Installation + +```shell +pip install posit-sdk +``` + +## Usage + +```python +from posit.connect import Client + +# If CONNECT_API_KEY and CONNECT_SERVER are set in your environment, +# they will be picked up, or you can pass them as arguments +con = Client() +con.users.find() +``` + + + + \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt index e43cbfb2..d75abeb9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,6 +2,7 @@ build==1.0.3 coverage==7.4.0 mypy==1.8.0 pytest==7.4.4 +responses>=0.25 ruff==0.1.14 setuptools==69.0.3 setuptools-scm==8.0.4 diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index 1009f1cd..092ec23b 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -1,68 +1,174 @@ from __future__ import annotations -import os -from requests import Session +from requests import Response, Session from typing import Optional -from . import hooks +from . import hooks, urls from .auth import Auth from .config import Config -from .users import User, Users, CachedUsers +from .users import CachedUsers, Users, User class Client: def __init__( self, api_key: Optional[str] = None, - endpoint: Optional[str] = None, + url: Optional[str] = None, ) -> None: """ 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. + url (str, optional): API url URL. Defaults to None. """ # Create a Config object. - config = Config(api_key=api_key, endpoint=endpoint) + self.config = Config(api_key=api_key, url=url) # Create a Session object for making HTTP requests. session = Session() # Authenticate the session using the provided Config. - session.auth = Auth(config=config) + session.auth = Auth(config=self.config) # Add error handling hooks to the session. session.hooks["response"].append(hooks.handle_errors) - - # Store the Config and Session objects. - self._config = config - self._session = session + # Store the Session object. + self.session = session # Internal properties for storing public resources + self.server_settings = None self._current_user: Optional[User] = None - @property def me(self) -> User: if self._current_user is None: - endpoint = os.path.join(self._config.endpoint, "v1/user") - response = self._session.get(endpoint) + url = urls.append_path(self.config.url, "v1/user") + response = self.session.get(url) self._current_user = User(**response.json()) return self._current_user - @property def users(self) -> CachedUsers: return Users(client=self) + # Place to cache the server settings + self.server_settings = None + @property + def connect_version(self): + if self.server_settings is None: + self.server_settings = self.get("server_settings").json() + return self.server_settings["version"] def __del__(self): """ Close the session when the Client instance is deleted. """ - self._session.close() + if hasattr(self, "session") and self.session is not None: + self.session.close() def __enter__(self): + """ + Enter method for using the client as a context manager. + """ return self def __exit__(self, exc_type, exc_value, exc_tb): - self._session.close() + """ + Closes the session if it exists. + + Args: + exc_type: The type of the exception raised (if any). + exc_value: The exception instance raised (if any). + exc_tb: The traceback for the exception raised (if any). + """ + if hasattr(self, "session") and self.session is not None: + self.session.close() + + def request(self, method: str, path: str, **kwargs) -> Response: + """ + Sends an HTTP request to the specified path using the given method. + + Args: + method (str): The HTTP method to use for the request. + path (str): The path to send the request to. + **kwargs: Additional keyword arguments to pass to the underlying session's request method. + + Returns: + Response: The response object containing the server's response to the request. + """ + url = urls.append_path(self.config.url, path) + return self.session.request(method, url, **kwargs) + + def get(self, path: str, **kwargs) -> Response: + """ + Send a GET request to the specified path. + + Args: + path (str): The path to send the request to. + **kwargs: Additional keyword arguments to be passed to the underlying session's `get` method. + + Returns: + Response: The response object. + + """ + url = urls.append_path(self.config.url, path) + return self.session.get(url, **kwargs) + + def post(self, path: str, **kwargs) -> Response: + """ + Send a POST request to the specified path. + + Args: + path (str): The path to send the request to. + **kwargs: Additional keyword arguments to be passed to the underlying session's `post` method. + + Returns: + Response: The response object. + + """ + url = urls.append_path(self.config.url, path) + return self.session.post(url, **kwargs) + + def put(self, path: str, **kwargs) -> Response: + """ + Send a PUT request to the specified path. + + Args: + path (str): The path to send the request to. + **kwargs: Additional keyword arguments to be passed to the underlying session's `put` method. + + Returns: + Response: The response object. + + """ + url = urls.append_path(self.config.url, path) + return self.session.put(url, **kwargs) + + def patch(self, path: str, **kwargs) -> Response: + """ + Send a PATCH request to the specified path. + + Args: + path (str): The path to send the request to. + **kwargs: Additional keyword arguments to be passed to the underlying session's `patch` method. + + Returns: + Response: The response object. + + """ + url = urls.append_path(self.config.url, path) + return self.session.patch(url, **kwargs) + + def delete(self, path: str, **kwargs) -> Response: + """ + Send a DELETE request to the specified path. + + Args: + path (str): The path to send the request to. + **kwargs: Additional keyword arguments to be passed to the underlying session's `delete` method. + + Returns: + Response: The response object. + + """ + url = urls.append_path(self.config.url, path) + return self.session.delete(url, **kwargs) diff --git a/src/posit/connect/client_test.py b/src/posit/connect/client_test.py index 75f162b3..cfb56606 100644 --- a/src/posit/connect/client_test.py +++ b/src/posit/connect/client_test.py @@ -1,39 +1,60 @@ +import pytest +import responses + from unittest.mock import MagicMock, patch + from .client import Client +@pytest.fixture +def MockAuth(): + with patch("posit.connect.client.Auth") as mock: + yield mock + + +@pytest.fixture +def MockConfig(): + with patch("posit.connect.client.Config") as mock: + yield mock + + +@pytest.fixture +def MockSession(): + with patch("posit.connect.client.Session") as mock: + yield mock + + +@pytest.fixture +def MockUsers(): + with patch("posit.connect.client.Users") as mock: + yield mock + + class TestClient: - @patch("posit.connect.client.Session") - @patch("posit.connect.client.Config") - @patch("posit.connect.client.Auth") def test_init( self, - Auth: MagicMock, - Config: MagicMock, - Session: MagicMock, + MockAuth: MagicMock, + MockConfig: MagicMock, + MockSession: MagicMock, + MockUsers: MagicMock, ): api_key = "foobar" - endpoint = "http://foo.bar" - 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() + url = "http://foo.bar/__api__" + Client(api_key=api_key, url=url) + MockAuth.assert_called_once_with(config=MockConfig.return_value) + MockConfig.assert_called_once_with(api_key=api_key, url=url) + MockSession.assert_called_once() - - @patch("posit.connect.client.Users") def test_users( self, - Users: MagicMock, + MockUsers: MagicMock, ): api_key = "foobar" - endpoint = "http://foo.bar" - client = Client(api_key=api_key, endpoint=endpoint) - Users.assert_not_called() + url = "http://foo.bar/__api__" + client = Client(api_key=api_key, url=url) client.users - Users.assert_called_once_with(client=client) - + MockUsers.assert_called_once_with(client=client) @patch("posit.connect.client.Session") @patch("posit.connect.client.User") @@ -43,29 +64,88 @@ def test_me( Session: MagicMock, ): api_key = "foobar" - endpoint = "http://foo.bar" - client = Client(api_key=api_key, endpoint=endpoint) + url = "http://foo.bar/__api__" + client = Client(api_key=api_key, url=url) User.assert_not_called() assert client._current_user is None client.me User.assert_called_once() - - @patch("posit.connect.client.Session") - @patch("posit.connect.client.Auth") - def test_del(self, Auth: MagicMock, Session: MagicMock): + def test__del__(self, MockAuth, MockConfig, MockSession, MockUsers): api_key = "foobar" - endpoint = "http://foo.bar" - client = Client(api_key=api_key, endpoint=endpoint) + url = "http://foo.bar/__api__" + client = Client(api_key=api_key, url=url) del client - Session.return_value.close.assert_called_once() + MockSession.return_value.close.assert_called_once() - @patch("posit.connect.client.Session") - @patch("posit.connect.client.Auth") - def test_context_manager(self, Auth: MagicMock, Session: MagicMock): - # What is this testing? + def test__enter__(self): + api_key = "foobar" + url = "http://foo.bar/__api__" + with Client(api_key=api_key, url=url) as client: + assert isinstance(client, Client) + + def test__exit__(self, MockSession): + api_key = "foobar" + url = "http://foo.bar/__api__" api_key = "foobar" - endpoint = "http://foo.bar" - with Client(api_key=api_key, endpoint=endpoint) as client: + url = "http://foo.bar/__api__" + with Client(api_key=api_key, url=url) as client: assert isinstance(client, Client) - Session.return_value.close.assert_called_once() + MockSession.return_value.close.assert_called_once() + + @responses.activate + def test_connect_version(self): + api_key = "foobar" + url = "http://foo.bar/__api__" + client = Client(api_key=api_key, url=url) + + # The actual server_settings response has a lot more attributes, but we + # don't need to include them all here because we don't use them + responses.get( + "http://foo.bar/__api__/server_settings", + json={"version": "2024.01.0"}, + ) + assert client.connect_version == "2024.01.0" + + def test_request(self, MockSession): + api_key = "foobar" + url = "http://foo.bar/__api__" + client = Client(api_key=api_key, url=url) + client.request("GET", "/foo") + MockSession.return_value.request.assert_called_once_with( + "GET", "http://foo.bar/__api__/foo" + ) + + def test_get(self, MockSession): + api_key = "foobar" + url = "http://foo.bar/__api__" + client = Client(api_key=api_key, url=url) + client.get("/foo") + client.session.get.assert_called_once_with("http://foo.bar/__api__/foo") + + def test_post(self, MockSession): + api_key = "foobar" + url = "http://foo.bar/__api__" + client = Client(api_key=api_key, url=url) + client.post("/foo") + client.session.post.assert_called_once_with("http://foo.bar/__api__/foo") + + def test_put(self, MockSession): + api_key = "foobar" + url = "http://foo.bar/__api__" + client = Client(api_key=api_key, url=url) + client.put("/foo") + client.session.put.assert_called_once_with("http://foo.bar/__api__/foo") + + def test_patch(self, MockSession): + api_key = "foobar" + url = "http://foo.bar/__api__" + client = Client(api_key=api_key, url=url) + client.patch("/foo") + client.session.patch.assert_called_once_with("http://foo.bar/__api__/foo") + + def test_delete(self, MockSession): + api_key = "foobar" + url = "http://foo.bar/__api__" + client = Client(api_key=api_key, url=url) + client.delete("/foo") diff --git a/src/posit/connect/config.py b/src/posit/connect/config.py index 858a80b6..d4a93850 100644 --- a/src/posit/connect/config.py +++ b/src/posit/connect/config.py @@ -1,5 +1,4 @@ import os - from typing import Optional @@ -13,14 +12,14 @@ def _get_api_key() -> str: The API key """ value = os.environ.get("CONNECT_API_KEY") - if value is None or value == "": + if not value: raise ValueError( "Invalid value for 'CONNECT_API_KEY': Must be a non-empty string." ) return value -def _get_endpoint() -> str: +def _get_url() -> 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. @@ -32,26 +31,18 @@ def _get_endpoint() -> str: The endpoint. """ value = os.environ.get("CONNECT_SERVER") - if value is None or value == "": + if not 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 + self, api_key: Optional[str] = None, url: Optional[str] = None ) -> None: self.api_key = api_key or _get_api_key() - self.endpoint = _format_endpoint(endpoint or _get_endpoint()) + self.url = url or _get_url() diff --git a/src/posit/connect/config_test.py b/src/posit/connect/config_test.py index c95a951a..6c38769e 100644 --- a/src/posit/connect/config_test.py +++ b/src/posit/connect/config_test.py @@ -2,45 +2,46 @@ from unittest.mock import patch -from .config import Config, _get_api_key, _get_endpoint +from .config import Config, _get_api_key, _get_url -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": "foobar"}) +def test_get_api_key(): + 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() +@patch.dict("os.environ", {"CONNECT_API_KEY": ""}) +def test_get_api_key_empty(): + 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" +def test_get_api_key_miss(): + with pytest.raises(ValueError): + _get_api_key() - @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.dict("os.environ", {"CONNECT_SERVER": "http://foo.bar"}) +def test_get_url(): + url = _get_url() + assert url == "http://foo.bar" -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 +@patch.dict("os.environ", {"CONNECT_SERVER": ""}) +def test_get_url_empty(): + with pytest.raises(ValueError): + _get_url() + + +def test_get_url_miss(): + with pytest.raises(ValueError): + _get_url() + + +def test_init(): + api_key = "foobar" + url = "http://foo.bar" + config = Config(api_key=api_key, url=url) + assert config.api_key == api_key + assert config.url == url diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index d14be0e0..1b9032db 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -1,6 +1,5 @@ from __future__ import annotations - from abc import ABC, abstractmethod from typing import Generic, Iterator, Optional, TypeVar, List, TypedDict, Tuple @@ -51,8 +50,9 @@ def to_pandas(self): class Resources(CachedResources[T]): - def __init__(self, data: List[T] = []) -> None: + def __init__(self, url: str, data: List[T] = []) -> None: super().__init__(data) + self.url = url self.data = data self.exhausted = False self.index = 0 diff --git a/src/posit/connect/resources_test.py b/src/posit/connect/resources_test.py index 78acacf0..93336df0 100644 --- a/src/posit/connect/resources_test.py +++ b/src/posit/connect/resources_test.py @@ -19,8 +19,8 @@ def get(self, _: str) -> FakeResource: return Mock(spec=FakeResource) -class TestResources: - def test(self): +class TestCachedResources: + def test_init(self): resources = FakeCachedResources() assert resources == resources.find() assert resources.find_one() @@ -28,12 +28,15 @@ def test(self): class FakeResources(FakeCachedResources, Resources): + def __init__(self) -> None: + super().__init__("") + def fetch(self, index) -> Tuple[Optional[Iterator[FakeResource]], bool]: return iter([FakeResource()]), len(self.data) > 0 -class TestFakeLazyResources: - def test(self): +class TestFakeResources: + def test_init(self): resources = FakeResources() assert resources == resources.find() assert resources.find_one() diff --git a/src/posit/connect/urls.py b/src/posit/connect/urls.py new file mode 100644 index 00000000..e6004a7d --- /dev/null +++ b/src/posit/connect/urls.py @@ -0,0 +1,43 @@ +import posixpath + +from urllib.parse import urlsplit, urlunsplit + + +def append_path(url: str, path: str) -> str: + """ + Appends a path to the end of a URL. + + Args: + url (str): The URL to append the path to. + path (str): The path to append to the URL. + + Returns: + str: The modified URL with the appended path. + + Raises: + ValueError: If the URL does not specify a scheme, is not absolute, or does not end with "/__api__". + + Example: + >>> append_path("http://example.com", "api") + 'http://example.com/__api__/api' + """ + split = urlsplit(url, allow_fragments=False) + if not split.scheme: + raise ValueError( + f"url must specify a scheme (e.g., http://example.com/__api__): {url}" + ) + + if not split.netloc: + raise ValueError( + f"url must be absolute (e.g., http://example.com/__api__): {url}" + ) + + if not (split.path and split.path.endswith("/__api__")): + raise ValueError( + f"url must end with path __api__ (e.g., http://example.com/__api__): {url}" + ) + + joined_path = posixpath.join(split.path, path.lstrip("/")) + + # See https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urlunsplit + return urlunsplit((split.scheme, split.netloc, joined_path, split.query, None)) diff --git a/src/posit/connect/urls_test.py b/src/posit/connect/urls_test.py new file mode 100644 index 00000000..1009311f --- /dev/null +++ b/src/posit/connect/urls_test.py @@ -0,0 +1,28 @@ +import pytest + +from .urls import append_path + + +def test_fix_without_scheme(): + with pytest.raises(ValueError): + append_path("foo.bar/__api__", "baz") + + +def test_fix_without_netloc(): + with pytest.raises(ValueError): + append_path("http:///__api__", "baz") + + +def test_fix_without_path(): + with pytest.raises(ValueError): + append_path("http://foo.bar", "baz") + + +def test_fix_without_correct_path(): + with pytest.raises(ValueError): + append_path("http://foo.bar/baz", "qux") + + +def test_fix_with_no_errors(): + url = append_path("http://foo.bar/baz/__api__", "qux") + assert url == "http://foo.bar/baz/__api__/qux" diff --git a/src/posit/connect/users.py b/src/posit/connect/users.py index 42e4a628..0136fa30 100644 --- a/src/posit/connect/users.py +++ b/src/posit/connect/users.py @@ -1,13 +1,13 @@ from __future__ import annotations -import os - from datetime import datetime from typing import Iterator, Callable, List, TYPE_CHECKING if TYPE_CHECKING: from .client import Client +from . import urls + from .resources import Resources, Resource, CachedResources # The maximum page size supported by the API. @@ -43,16 +43,16 @@ def get(self, id: str) -> User: class Users(CachedUsers, Resources[User]): - def __init__( - self, client: Client, *, page_size=_MAX_PAGE_SIZE - ) -> None: + def __init__(self, client: Client, *, page_size=_MAX_PAGE_SIZE) -> None: if page_size > _MAX_PAGE_SIZE: raise ValueError( f"page_size must be less than or equal to {_MAX_PAGE_SIZE}" ) - super().__init__() + url = urls.append_path(client.config.url, "v1/users") + super().__init__(url) self.client = client + self.url = url self.page_size = page_size def fetch(self, index) -> tuple[Iterator[User] | None, bool]: @@ -62,14 +62,12 @@ def fetch(self, index) -> tuple[Iterator[User] | None, bool]: raise ValueError( f"index ({index}) must be a multiple of page size ({self.page_size})" ) - # Construct the endpoint URL. - endpoint = os.path.join(self.client._config.endpoint, "v1/users") # Define the page number using 1-based indexing. page_number = int(index / self.page_size) + 1 # Define query parameters for pagination. params = {"page_number": page_number, "page_size": self.page_size} # Send a GET request to the endpoint with the specified parameters. - response = self.client._session.get(endpoint, params=params) + response = self.client.session.get(self.url, params=params) # Convert response to dict json: dict = dict(response.json()) # Parse the JSON response and extract the results. @@ -81,6 +79,6 @@ def fetch(self, index) -> tuple[Iterator[User] | None, bool]: return (users, exhausted) def get(self, id: str) -> User: - endpoint = os.path.join(self.client._config.endpoint, "v1/users", id) - response = self.client._session.get(endpoint) + url = urls.append_path(self.client.config.url, f"v1/users/{id}") + response = self.client.session.get(url) return User(**response.json()) diff --git a/tinkering.py b/tinkering.py index b285cccd..4273d2ac 100644 --- a/tinkering.py +++ b/tinkering.py @@ -2,6 +2,7 @@ with Client() as client: print(client.me) + print(client.get("v1/users")) print(client.users.get("f55ca95d-ce52-43ed-b31b-48dc4a07fe13")) users = client.users users = users.find(lambda user: user["first_name"].startswith("T"))