diff --git a/integration/tests/posit/connect/test_content_item_repository.py b/integration/tests/posit/connect/test_content_item_repository.py index 6260b6e3..7911847d 100644 --- a/integration/tests/posit/connect/test_content_item_repository.py +++ b/integration/tests/posit/connect/test_content_item_repository.py @@ -2,7 +2,8 @@ from packaging import version from posit import connect -from posit.connect.content import ContentItem, ContentItemRepository +from posit.connect.content import ContentItem +from posit.connect.repository import ContentItemRepository from . import CONNECT_VERSION @@ -76,12 +77,12 @@ def assert_repo(r: ContentItemRepository): # Update ex_branch = "main" - updated_repo = content_repo.update(branch=ex_branch) - assert updated_repo["branch"] == ex_branch + content_repo.update(branch=ex_branch) + assert content_repo["branch"] == ex_branch - assert updated_repo["repository"] == self.repo_repository - assert updated_repo["directory"] == self.repo_directory - assert updated_repo["polling"] is self.repo_polling + assert content_repo["repository"] == self.repo_repository + assert content_repo["directory"] == self.repo_directory + assert content_repo["polling"] is self.repo_polling # Delete content_repo.destroy() diff --git a/src/posit/connect/_api.py b/src/posit/connect/_api.py deleted file mode 100644 index 29325158..00000000 --- a/src/posit/connect/_api.py +++ /dev/null @@ -1,142 +0,0 @@ -# TODO-barret-future; Piecemeal migrate everything to leverage `ApiDictEndpoint` -# TODO-barret-future; Merge any trailing behavior of `Active` or `ActiveList` into the new classes. - -from __future__ import annotations - -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, Optional, cast - -from ._api_call import ApiCallMixin, get_api -from ._json import Jsonifiable, JsonifiableDict, ResponseAttrs - -if TYPE_CHECKING: - from .context import Context - - -# Design Notes: -# * Perform API calls on property retrieval. e.g. `my_content.repository` -# * Dictionary endpoints: Retrieve all attributes during init unless provided -# * List endpoints: Do not retrieve until `.fetch()` is called directly. Avoids cache invalidation issues. -# * While slower, all ApiListEndpoint helper methods should `.fetch()` on demand. -# * Only expose methods needed for `ReadOnlyDict`. -# * Ex: When inheriting from `dict`, we'd need to shut down `update`, `pop`, etc. -# * Use `ApiContextProtocol` to ensure that the class has the necessary attributes for API calls. -# * Inherit from `ApiCallMixin` to add all helper methods for API calls. -# * Classes should write the `path` only once within its init method. -# * Through regular interactions, the path should only be written once. - -# When making a new class, -# * Use a class to define the parameters and their types -# * If attaching the type info class to the parent class, start with `_`. E.g.: `ContentItemRepository._Attrs` -# * Document all attributes like normal -# * When the time comes that there are multiple attribute types, we can use overloads with full documentation and unpacking of type info class for each overload method. -# * Inherit from `ApiDictEndpoint` or `ApiListEndpoint` as needed -# * Init signature should be `def __init__(self, ctx: Context, path: str, /, **attrs: Jsonifiable) -> None:` - - -# This class should not have typing about the class keys as that would fix the class's typing. If -# for some reason, we _know_ the keys are fixed (as we've moved on to a higher version), we can add -# `Generic[AttrsT]` to the class. -class ReadOnlyDict(Mapping): - _attrs: ResponseAttrs - """Resource attributes passed.""" - - def __init__(self, attrs: ResponseAttrs) -> None: - """ - A read-only dict abstraction for any HTTP endpoint that returns a singular resource. - - Parameters - ---------- - attrs : dict - Resource attributes passed - """ - super().__init__() - self._attrs = attrs - - def get(self, key: str, default: Any = None) -> Any: - return self._attrs.get(key, default) - - def __getitem__(self, key: str) -> Any: - return self._attrs[key] - - def __setitem__(self, key: str, value: Any) -> None: - raise NotImplementedError( - "Resource attributes are locked. " - "To retrieve updated values, please retrieve the parent object again." - ) - - def __len__(self) -> int: - return self._attrs.__len__() - - def __iter__(self): - return self._attrs.__iter__() - - def __contains__(self, key: object) -> bool: - return self._attrs.__contains__(key) - - def __repr__(self) -> str: - return repr(self._attrs) - - def __str__(self) -> str: - return str(self._attrs) - - def keys(self): - return self._attrs.keys() - - def values(self): - return self._attrs.values() - - def items(self): - return self._attrs.items() - - -class ApiDictEndpoint(ApiCallMixin, ReadOnlyDict): - _ctx: Context - """The context object containing the session and URL for API interactions.""" - _path: str - """The HTTP path component for the resource endpoint.""" - - def _get_api(self, *path) -> JsonifiableDict | None: - super()._get_api(*path) - - def __init__( - self, - ctx: Context, - path: str, - get_data: Optional[bool] = None, - /, - **attrs: Jsonifiable, - ) -> None: - """ - A dict abstraction for any HTTP endpoint that returns a singular resource. - - Adds helper methods to interact with the API with reduced boilerplate. - - Parameters - ---------- - ctx : Context - The context object containing the session and URL for API interactions. - path : str - The HTTP path component for the resource endpoint - get_data : Optional[bool] - If `True`, fetch the API and set the attributes from the response. If `False`, only set - the provided attributes. If `None` [default], fetch the API if no attributes are - provided. - attrs : dict - Resource attributes passed - """ - # If no attributes are provided, fetch the API and set the attributes from the response - if get_data is None: - get_data = len(attrs) == 0 - - # If we should get data, fetch the API and set the attributes from the response - if get_data: - init_attrs: Jsonifiable = get_api(ctx, path) - init_attrs_dict = cast(ResponseAttrs, init_attrs) - # Overwrite the initial attributes with `attrs`: e.g. {'key': value} | {'content_guid': '123'} - init_attrs_dict.update(attrs) - attrs = init_attrs_dict - - super().__init__(attrs) - self._ctx = ctx - self._path = path diff --git a/src/posit/connect/_api_call.py b/src/posit/connect/_api_call.py deleted file mode 100644 index 56ce70f6..00000000 --- a/src/posit/connect/_api_call.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -import posixpath -from typing import TYPE_CHECKING, Protocol - -if TYPE_CHECKING: - from ._json import Jsonifiable - from .context import Context - - -class ApiCallProtocol(Protocol): - _ctx: Context - _path: str - - def _endpoint(self, *path) -> str: ... - def _get_api(self, *path) -> Jsonifiable: ... - def _delete_api(self, *path) -> Jsonifiable | None: ... - def _patch_api(self, *path, json: Jsonifiable | None) -> Jsonifiable: ... - def _put_api(self, *path, json: Jsonifiable | None) -> Jsonifiable: ... - - -def endpoint(*path) -> str: - return posixpath.join(*path) - - -# Helper methods for API interactions -def get_api(ctx: Context, *path) -> Jsonifiable: - response = ctx.client.get(*path) - return response.json() - - -def put_api( - ctx: Context, - *path, - json: Jsonifiable | None, -) -> Jsonifiable: - response = ctx.client.put(*path, json=json) - return response.json() - - -# Mixin class for API interactions - - -class ApiCallMixin: - def _endpoint(self: ApiCallProtocol, *path) -> str: - return endpoint(self._path, *path) - - def _get_api(self: ApiCallProtocol, *path) -> Jsonifiable: - response = self._ctx.client.get(self._endpoint(*path)) - return response.json() - - def _delete_api(self: ApiCallProtocol, *path) -> Jsonifiable | None: - response = self._ctx.client.delete(self._endpoint(*path)) - if len(response.content) == 0: - return None - return response.json() - - def _patch_api( - self: ApiCallProtocol, - *path, - json: Jsonifiable | None, - ) -> Jsonifiable: - response = self._ctx.client.patch(self._endpoint(*path), json=json) - return response.json() - - def _put_api( - self: ApiCallProtocol, - *path, - json: Jsonifiable | None, - ) -> Jsonifiable: - response = self._ctx.client.put(self._endpoint(*path), json=json) - return response.json() diff --git a/src/posit/connect/_json.py b/src/posit/connect/_json.py deleted file mode 100644 index 62f28f82..00000000 --- a/src/posit/connect/_json.py +++ /dev/null @@ -1,37 +0,0 @@ -from typing import Dict, List, Tuple, TypeVar, Union - -# Implemented in https://github.com/posit-dev/py-shiny/blob/415ced034e6c500adda524abb7579731c32088b5/shiny/types.py#L357-L386 -# Table from: https://github.com/python/cpython/blob/df1eec3dae3b1eddff819fd70f58b03b3fbd0eda/Lib/json/encoder.py#L77-L95 -# +-------------------+---------------+ -# | Python | JSON | -# +===================+===============+ -# | dict | object | -# +-------------------+---------------+ -# | list, tuple | array | -# +-------------------+---------------+ -# | str | string | -# +-------------------+---------------+ -# | int, float | number | -# +-------------------+---------------+ -# | True | true | -# +-------------------+---------------+ -# | False | false | -# +-------------------+---------------+ -# | None | null | -# +-------------------+---------------+ -Jsonifiable = Union[ - str, - int, - float, - bool, - None, - List["Jsonifiable"], - Tuple["Jsonifiable", ...], - "JsonifiableDict", -] - -JsonifiableT = TypeVar("JsonifiableT", bound="Jsonifiable") -JsonifiableDict = Dict[str, Jsonifiable] -JsonifiableList = List[JsonifiableT] - -ResponseAttrs = Dict[str, Jsonifiable] diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index dd900f6d..6adca5c3 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -4,26 +4,27 @@ import posixpath import time -from typing import ( + +from typing_extensions import ( TYPE_CHECKING, Any, List, Literal, + NotRequired, Optional, - cast, + Required, + TypedDict, + Unpack, overload, ) -from typing_extensions import NotRequired, Required, TypedDict, Unpack - from . import tasks -from ._api import ApiDictEndpoint, JsonifiableDict from .bundles import Bundles from .context import requires from .env import EnvVars -from .errors import ClientError from .oauth.associations import ContentItemAssociations from .permissions import Permissions +from .repository import ContentItemRepositoryMixin from .resources import Active, BaseResource, Resources, _ResourceSequence from .tags import ContentItemTags from .vanities import VanityMixin @@ -41,125 +42,6 @@ def _assert_guid(guid: str): assert len(guid) > 0, "Expected 'guid' to be non-empty" -def _assert_content_guid(content_guid: str): - assert isinstance(content_guid, str), "Expected 'content_guid' to be a string" - assert len(content_guid) > 0, "Expected 'content_guid' to be non-empty" - - -class ContentItemRepository(ApiDictEndpoint): - """ - Content items GitHub repository information. - - See Also - -------- - * Get info: https://docs.posit.co/connect/api/#get-/v1/content/-guid-/repository - * Delete info: https://docs.posit.co/connect/api/#delete-/v1/content/-guid-/repository - * Update info: https://docs.posit.co/connect/api/#patch-/v1/content/-guid-/repository - """ - - class _Attrs(TypedDict, total=False): - repository: str - """URL for the repository.""" - branch: NotRequired[str] - """The tracked Git branch.""" - directory: NotRequired[str] - """Directory containing the content.""" - polling: NotRequired[bool] - """Indicates that the Git repository is regularly polled.""" - - def __init__( - self, - ctx: Context, - /, - *, - content_guid: str, - # By default, the `attrs` will be retrieved from the API if no `attrs` are supplied. - **attrs: Unpack[ContentItemRepository._Attrs], - ) -> None: - """Content items GitHub repository information. - - Parameters - ---------- - ctx : Context - The context object containing the session and URL for API interactions. - content_guid : str - The unique identifier of the content item. - **attrs : ContentItemRepository._Attrs - Attributes for the content item repository. If not supplied, the attributes will be - retrieved from the API upon initialization - """ - _assert_content_guid(content_guid) - - path = self._api_path(content_guid) - # Only fetch data if `attrs` are not supplied - get_data = len(attrs) == 0 - super().__init__(ctx, path, get_data, **{"content_guid": content_guid, **attrs}) - - @classmethod - def _api_path(cls, content_guid: str) -> str: - return f"v1/content/{content_guid}/repository" - - @classmethod - def _create( - cls, - ctx: Context, - content_guid: str, - **attrs: Unpack[ContentItemRepository._Attrs], - ) -> ContentItemRepository: - from ._api_call import put_api - - result = put_api(ctx, cls._api_path(content_guid), json=cast(JsonifiableDict, attrs)) - - return ContentItemRepository( - ctx, - content_guid=content_guid, - **result, # pyright: ignore[reportCallIssue] - ) - - def destroy(self) -> None: - """ - Delete the content's git repository location. - - See Also - -------- - * https://docs.posit.co/connect/api/#delete-/v1/content/-guid-/repository - """ - self._delete_api() - - def update( - self, - # *, - **attrs: Unpack[ContentItemRepository._Attrs], - ) -> ContentItemRepository: - """Update the content's repository. - - Parameters - ---------- - repository: str, optional - URL for the repository. Default is None. - branch: str, optional - The tracked Git branch. Default is 'main'. - directory: str, optional - Directory containing the content. Default is '.' - polling: bool, optional - Indicates that the Git repository is regularly polled. Default is False. - - Returns - ------- - None - - See Also - -------- - * https://docs.posit.co/connect/api/#patch-/v1/content/-guid-/repository - """ - result = self._patch_api(json=cast(JsonifiableDict, dict(attrs))) - return ContentItemRepository( - self._ctx, - content_guid=self["content_guid"], - **result, # pyright: ignore[reportCallIssue] - ) - - class ContentItemOAuth(BaseResource): def __init__(self, ctx: Context, content_guid: str) -> None: super().__init__(ctx) @@ -174,7 +56,7 @@ class ContentItemOwner(BaseResource): pass -class ContentItem(Active, VanityMixin, BaseResource): +class ContentItem(Active, ContentItemRepositoryMixin, VanityMixin, BaseResource): class _AttrsBase(TypedDict, total=False): # # `name` will be set by other _Attrs classes # name: str @@ -261,36 +143,6 @@ def __getitem__(self, key: Any) -> Any: def oauth(self) -> ContentItemOAuth: return ContentItemOAuth(self._ctx, content_guid=self["guid"]) - @property - def repository(self) -> ContentItemRepository | None: - try: - return ContentItemRepository(self._ctx, content_guid=self["guid"]) - except ClientError: - return None - - def create_repository( - self, - **attrs: Unpack[ContentItemRepository._Attrs], - ) -> ContentItemRepository: - """Create repository. - - Parameters - ---------- - repository : str - URL for the respository. - branch : str, optional - The tracked Git branch. Default is 'main'. - directory : str, optional - Directory containing the content. Default is '.'. - polling : bool, optional - Indicates that the Git repository is regularly polled. Default is False. - - Returns - ------- - ContentItemRepository - """ - return ContentItemRepository._create(self._ctx, self["guid"], **attrs) - def delete(self) -> None: """Delete the content item.""" path = f"v1/content/{self['guid']}" diff --git a/src/posit/connect/repository.py b/src/posit/connect/repository.py new file mode 100644 index 00000000..1c562393 --- /dev/null +++ b/src/posit/connect/repository.py @@ -0,0 +1,136 @@ +"""Content item repository.""" + +from __future__ import annotations + +from typing_extensions import ( + Optional, + Protocol, + overload, + runtime_checkable, +) + +from .errors import ClientError +from .resources import Resource, _Resource + + +# ContentItem Repository uses a PATCH method, not a PUT for updating. +class _ContentItemRepository(_Resource): + def update(self, **attributes): + response = self._ctx.client.patch(self._path, json=attributes) + result = response.json() + # # Calling this method will call `_Resource.update` which will try to PUT to the path. + # super().update(**result) + # Instead, update the dict directly. + dict.update(self, **result) + + +@runtime_checkable +class ContentItemRepository(Resource, Protocol): + """ + Content items GitHub repository information. + + See Also + -------- + * Get info: https://docs.posit.co/connect/api/#get-/v1/content/-guid-/repository + * Delete info: https://docs.posit.co/connect/api/#delete-/v1/content/-guid-/repository + * Update info: https://docs.posit.co/connect/api/#patch-/v1/content/-guid-/repository + """ + + def destroy(self) -> None: + """ + Delete the content's git repository location. + + See Also + -------- + * https://docs.posit.co/connect/api/#delete-/v1/content/-guid-/repository + """ + ... + + def update( + self, + *, + repository: Optional[str] = None, + branch: str = "main", + directory: str = ".", + polling: bool = False, + ) -> None: + """Update the content's repository. + + Parameters + ---------- + repository: str, optional + URL for the repository. Default is None. + branch: str, optional + The tracked Git branch. Default is 'main'. + directory: str, optional + Directory containing the content. Default is '.' + polling: bool, optional + Indicates that the Git repository is regularly polled. Default is False. + + Returns + ------- + None + + See Also + -------- + * https://docs.posit.co/connect/api/#patch-/v1/content/-guid-/repository + """ + ... + + +class ContentItemRepositoryMixin: + @property + def repository(self: Resource) -> ContentItemRepository | None: + try: + path = f"v1/content/{self['guid']}/repository" + response = self._ctx.client.get(path) + result = response.json() + return _ContentItemRepository( + self._ctx, + path, + **result, + ) + except ClientError: + return None + + @overload + def create_repository( + self: Resource, + /, + *, + repository: Optional[str] = None, + branch: str = "main", + directory: str = ".", + polling: bool = False, + ) -> ContentItemRepository: ... + + @overload + def create_repository(self: Resource, /, **attributes) -> ContentItemRepository: ... + + def create_repository(self: Resource, /, **attributes) -> ContentItemRepository: + """Create repository. + + Parameters + ---------- + repository : str + URL for the respository. + branch : str, optional + The tracked Git branch. Default is 'main'. + directory : str, optional + Directory containing the content. Default is '.'. + polling : bool, optional + Indicates that the Git repository is regularly polled. Default is False. + + Returns + ------- + ContentItemRepository + """ + path = f"v1/content/{self['guid']}/repository" + response = self._ctx.client.put(path, json=attributes) + result = response.json() + + return _ContentItemRepository( + self._ctx, + path, + **result, + ) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index d6650e07..1916539d 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -70,6 +70,9 @@ def __init__(self, ctx: Context, path: str, /, **attributes): class Resource(Protocol): + _ctx: Context + _path: str + def __getitem__(self, key: Hashable, /) -> Any: ... diff --git a/tests/posit/connect/api.py b/tests/posit/connect/api.py index 06b5f6cc..1b99badb 100644 --- a/tests/posit/connect/api.py +++ b/tests/posit/connect/api.py @@ -20,7 +20,7 @@ def load_mock(path: str): Returns ------- - Jsonifiable + dict | list The parsed data from the JSONC file. Examples diff --git a/tests/posit/connect/test_api_endpoint.py b/tests/posit/connect/test_api_endpoint.py deleted file mode 100644 index 2281084f..00000000 --- a/tests/posit/connect/test_api_endpoint.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest - -from posit.connect._api import ReadOnlyDict - - -class TestApiEndpoint: - def test_read_only(self): - obj = ReadOnlyDict({}) - - assert len(obj) == 0 - - assert obj.get("foo", "bar") == "bar" - - with pytest.raises(NotImplementedError): - obj["foo"] = "baz" - - eq_obj = ReadOnlyDict({"foo": "bar", "a": 1}) - assert eq_obj == {"foo": "bar", "a": 1} diff --git a/tests/posit/connect/test_content.py b/tests/posit/connect/test_content.py index 27f5318b..310582b7 100644 --- a/tests/posit/connect/test_content.py +++ b/tests/posit/connect/test_content.py @@ -3,7 +3,8 @@ from responses import matchers from posit.connect.client import Client -from posit.connect.content import ContentItem, ContentItemRepository +from posit.connect.content import ContentItem +from posit.connect.resources import _Resource from .api import load_mock, load_mock_dict @@ -576,7 +577,7 @@ def mock_repository_info(self): ) repository_info = content_item.repository - assert isinstance(repository_info, ContentItemRepository) + assert isinstance(repository_info, _Resource) assert mock_get.call_count == 1 return repository_info @@ -594,14 +595,14 @@ def test_repository_update(self): self.endpoint, json=load_mock_dict(f"v1/content/{self.content_guid}/repository_patch.json"), ) - new_repository_info = repository_info.update(branch="testing-main") + repository_info.update(branch="testing-main") assert mock_patch.call_count == 1 for key, value in repository_info.items(): if key == "branch": - assert new_repository_info[key] == "testing-main" + assert repository_info[key] == "testing-main" else: - assert new_repository_info[key] == value + assert repository_info[key] == value @responses.activate def test_repository_delete(self):