From 194f07b079233e6ccfc170d2c694f50d23dfe6cd Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 12 Nov 2024 13:59:55 -0500 Subject: [PATCH 01/47] merge Active and ApiDict class into ActiveDict --- src/posit/connect/_active.py | 100 ++++++++++++++--------------------- 1 file changed, 39 insertions(+), 61 deletions(-) diff --git a/src/posit/connect/_active.py b/src/posit/connect/_active.py index 470602b5..fca38977 100644 --- a/src/posit/connect/_active.py +++ b/src/posit/connect/_active.py @@ -23,7 +23,6 @@ from ._api_call import ApiCallMixin, get_api from ._json import Jsonifiable, JsonifiableDict, ResponseAttrs -from .resources import Resource, ResourceParameters if TYPE_CHECKING: from .context import Context @@ -106,11 +105,27 @@ def items(self): return self._attrs.items() -class Active(ABC, Resource): - def __init__(self, ctx: Context, path: str, /, **attributes): - """A dict abstraction for any HTTP endpoint that returns a singular resource. +class ActiveDict(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. - Extends the `Resource` class and provides additional functionality for via the session context and an optional parent resource. + Adds helper methods to interact with the API with reduced boilerplate. Parameters ---------- @@ -118,16 +133,31 @@ def __init__(self, ctx: Context, path: str, /, **attributes): The context object containing the session and URL for API interactions. path : str The HTTP path component for the resource endpoint - **attributes : dict + 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 """ - params = ResourceParameters(ctx.session, ctx.url) - super().__init__(params, **attributes) + # 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 -T = TypeVar("T", bound="Active") +T = TypeVar("T", bound="ActiveDict") """A type variable that is bound to the `Active` class""" @@ -275,58 +305,6 @@ def find_by(self, **conditions: Any) -> T | None: return next((v for v in collection if v.items() >= conditions.items()), None) -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 - - ReadOnlyDictT = TypeVar("ReadOnlyDictT", bound="ReadOnlyDict") """A type variable that is bound to the `Active` class""" From c5088a925fecb29e70e26f9089745495ce90a56c Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 12 Nov 2024 14:18:06 -0500 Subject: [PATCH 02/47] Remove `_api.py` file --- src/posit/connect/_api.py | 278 ----------------------- src/posit/connect/content.py | 4 +- tests/posit/connect/test_api_endpoint.py | 2 +- 3 files changed, 3 insertions(+), 281 deletions(-) delete mode 100644 src/posit/connect/_api.py diff --git a/src/posit/connect/_api.py b/src/posit/connect/_api.py deleted file mode 100644 index b602c7b0..00000000 --- a/src/posit/connect/_api.py +++ /dev/null @@ -1,278 +0,0 @@ -# TODO-barret-future; Piecemeal migrate everything to leverage `ApiDictEndpoint` and `ApiListEndpoint` classes. -# TODO-barret-future; Merge any trailing behavior of `Active` or `ActiveList` into the new classes. - -from __future__ import annotations - -import itertools -import posixpath -from abc import ABC, abstractmethod -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, Generator, Generic, Optional, TypeVar, cast, overload - -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 - - -T = TypeVar("T", bound="ReadOnlyDict") -"""A type variable that is bound to the `Active` class""" - - -class ApiListEndpoint(ApiCallMixin, Generic[T], ABC, object): - """A HTTP GET endpoint that can fetch a collection.""" - - def __init__(self, *, ctx: Context, path: str, uid_key: str = "guid") -> None: - """A sequence abstraction for any HTTP GET endpoint that returns a collection. - - Parameters - ---------- - ctx : Context - The context object containing the session and URL for API interactions. - path : str - The HTTP path component for the collection endpoint - uid_key : str, optional - The field name of that uniquely identifiers an instance of T, by default "guid" - """ - super().__init__() - self._ctx = ctx - self._path = path - self._uid_key = uid_key - - @abstractmethod - def _create_instance(self, path: str, /, **kwargs: Any) -> T: - """Create an instance of 'T'.""" - raise NotImplementedError() - - def fetch(self) -> Generator[T, None, None]: - """Fetch the collection. - - Fetches the collection directly from Connect. This operation does not effect the cache state. - - Returns - ------- - List[T] - """ - results: Jsonifiable = self._get_api() - results_list = cast(list[JsonifiableDict], results) - for result in results_list: - yield self._to_instance(result) - - def __iter__(self) -> Generator[T, None, None]: - return self.fetch() - - def _to_instance(self, result: dict) -> T: - """Converts a result into an instance of T.""" - uid = result[self._uid_key] - path = posixpath.join(self._path, uid) - return self._create_instance(path, **result) - - @overload - def __getitem__(self, index: int) -> T: ... - - @overload - def __getitem__(self, index: slice) -> Generator[T, None, None]: ... - - def __getitem__(self, index: int | slice) -> T | Generator[T, None, None]: - if isinstance(index, slice): - results = itertools.islice(self.fetch(), index.start, index.stop, index.step) - for result in results: - yield result - else: - return list(itertools.islice(self.fetch(), index, index + 1))[0] - - # def __len__(self) -> int: - # return len(self.fetch()) - - def __str__(self) -> str: - return self.__repr__() - - def __repr__(self) -> str: - # Jobs - 123 items - return repr( - f"{self.__class__.__name__} - { len(list(self.fetch())) } items - {self._path}" - ) - - def find(self, uid: str) -> T | None: - """ - Find a record by its unique identifier. - - Fetches the record from Connect by it's identifier. - - Parameters - ---------- - uid : str - The unique identifier of the record. - - Returns - ------- - : - Single instance of T if found, else None - """ - result: Jsonifiable = self._get_api(uid) - result_obj = cast(JsonifiableDict, result) - - return self._to_instance(result_obj) - - def find_by(self, **conditions: Any) -> T | None: - """ - Find the first record matching the specified conditions. - - There is no implied ordering, so if order matters, you should specify it yourself. - - Parameters - ---------- - **conditions : Any - - Returns - ------- - T - The first record matching the conditions, or `None` if no match is found. - """ - results = self.fetch() - - conditions_items = conditions.items() - - # Get the first item of the generator that matches the conditions - # If no item is found, return None - return next( - ( - # Return result - result - # Iterate through `results` generator - for result in results - # If all `conditions`'s key/values are found in `result`'s key/values... - if result.items() >= conditions_items - ), - None, - ) diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 0ff2db75..48cecd74 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -16,7 +16,7 @@ ) from . import tasks -from ._api import ApiDictEndpoint, JsonifiableDict +from ._active import ActiveDict, JsonifiableDict from ._typing_extensions import NotRequired, Required, TypedDict, Unpack from .bundles import Bundles from .context import Context @@ -43,7 +43,7 @@ def _assert_content_guid(content_guid: str): assert len(content_guid) > 0, "Expected 'content_guid' to be non-empty" -class ContentItemRepository(ApiDictEndpoint): +class ContentItemRepository(ActiveDict): """ Content items GitHub repository information. diff --git a/tests/posit/connect/test_api_endpoint.py b/tests/posit/connect/test_api_endpoint.py index 2281084f..e620e159 100644 --- a/tests/posit/connect/test_api_endpoint.py +++ b/tests/posit/connect/test_api_endpoint.py @@ -1,6 +1,6 @@ import pytest -from posit.connect._api import ReadOnlyDict +from posit.connect._active import ReadOnlyDict class TestApiEndpoint: From 1b1f20e4584e0f054fc3cb7ac425545001e0ecb7 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 12 Nov 2024 14:22:46 -0500 Subject: [PATCH 03/47] Store data in `_dict` not `_attrs`. Clean up wording --- src/posit/connect/_active.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/posit/connect/_active.py b/src/posit/connect/_active.py index fca38977..f0d3e8a9 100644 --- a/src/posit/connect/_active.py +++ b/src/posit/connect/_active.py @@ -53,56 +53,56 @@ # 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.""" + _dict: ResponseAttrs + """Read only dictionary.""" - def __init__(self, attrs: ResponseAttrs) -> None: + def __init__(self, **kwargs: Any) -> None: """ A read-only dict abstraction for any HTTP endpoint that returns a singular resource. Parameters ---------- - attrs : dict - Resource attributes passed + **kwargs : Any + Values to be stored """ super().__init__() - self._attrs = attrs + self._dict = kwargs def get(self, key: str, default: Any = None) -> Any: - return self._attrs.get(key, default) + return self._dict.get(key, default) def __getitem__(self, key: str) -> Any: - return self._attrs[key] + return self._dict[key] def __setitem__(self, key: str, value: Any) -> None: raise NotImplementedError( - "Resource attributes are locked. " + "Attributes are locked. " "To retrieve updated values, please retrieve the parent object again." ) def __len__(self) -> int: - return self._attrs.__len__() + return self._dict.__len__() def __iter__(self): - return self._attrs.__iter__() + return self._dict.__iter__() def __contains__(self, key: object) -> bool: - return self._attrs.__contains__(key) + return self._dict.__contains__(key) def __repr__(self) -> str: - return repr(self._attrs) + return repr(self._dict) def __str__(self) -> str: - return str(self._attrs) + return str(self._dict) def keys(self): - return self._attrs.keys() + return self._dict.keys() def values(self): - return self._attrs.values() + return self._dict.values() def items(self): - return self._attrs.items() + return self._dict.items() class ActiveDict(ApiCallMixin, ReadOnlyDict): From 69bfa82537da42e531a1aa3bdddb120f6d56042d Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 12 Nov 2024 14:58:02 -0500 Subject: [PATCH 04/47] Add in `ResourceDict` class --- src/posit/connect/_active.py | 39 ++++++++++++++++++++++++++++++---- src/posit/connect/_api_call.py | 15 ++++++++++++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/posit/connect/_active.py b/src/posit/connect/_active.py index f0d3e8a9..7e417e06 100644 --- a/src/posit/connect/_active.py +++ b/src/posit/connect/_active.py @@ -21,7 +21,7 @@ overload, ) -from ._api_call import ApiCallMixin, get_api +from ._api_call import ApiCallMixin, ContextP, get_api from ._json import Jsonifiable, JsonifiableDict, ResponseAttrs if TYPE_CHECKING: @@ -105,7 +105,39 @@ def items(self): return self._dict.items() -class ActiveDict(ApiCallMixin, ReadOnlyDict): +class ResourceDict(ReadOnlyDict, ContextP): + """An abstraction to contain the context and read-only information.""" + + _ctx: Context + """The context object containing the session and URL for API interactions.""" + + def __init__( + self, + ctx: Context, + /, + **kwargs: Any, + ) -> 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 + **kwargs : Any + Values to be stored + """ + super().__init__(**kwargs) + self._ctx = ctx + + +class ActiveDict(ApiCallMixin, ResourceDict): + """A dict abstraction for any HTTP endpoint that returns a singular resource.""" + _ctx: Context """The context object containing the session and URL for API interactions.""" _path: str @@ -152,8 +184,7 @@ def __init__( init_attrs_dict.update(attrs) attrs = init_attrs_dict - super().__init__(attrs) - self._ctx = ctx + super().__init__(ctx, **attrs) self._path = path diff --git a/src/posit/connect/_api_call.py b/src/posit/connect/_api_call.py index f90244aa..cff8df47 100644 --- a/src/posit/connect/_api_call.py +++ b/src/posit/connect/_api_call.py @@ -8,8 +8,21 @@ from .context import Context -class ApiCallProtocol(Protocol): +# Just the same as `.context.py` ContextManager but with `._ctx` attribute, not `.ctx` +class ContextP(Protocol): _ctx: Context + + +class ContextCls(ContextP): + """Class that contains the client context.""" + + _ctx: Context + + def __init__(self, ctx: Context): + self._ctx = ctx + + +class ApiCallProtocol(ContextP, Protocol): _path: str def _endpoint(self, *path) -> str: ... From 7efb8fae570be6accc324fca525b55adcd74ad01 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 12 Nov 2024 15:05:47 -0500 Subject: [PATCH 05/47] Update test_api_endpoint.py --- tests/posit/connect/test_api_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/posit/connect/test_api_endpoint.py b/tests/posit/connect/test_api_endpoint.py index e620e159..01a19a1a 100644 --- a/tests/posit/connect/test_api_endpoint.py +++ b/tests/posit/connect/test_api_endpoint.py @@ -5,7 +5,7 @@ class TestApiEndpoint: def test_read_only(self): - obj = ReadOnlyDict({}) + obj = ReadOnlyDict() assert len(obj) == 0 @@ -14,5 +14,5 @@ def test_read_only(self): with pytest.raises(NotImplementedError): obj["foo"] = "baz" - eq_obj = ReadOnlyDict({"foo": "bar", "a": 1}) + eq_obj = ReadOnlyDict(foo="bar", a=1) assert eq_obj == {"foo": "bar", "a": 1} From 5998b15cab67981bac7f495bc93d3ac254aeebf7 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 12 Nov 2024 15:06:23 -0500 Subject: [PATCH 06/47] Implement Variant with ResourceDict --- src/posit/connect/resources.py | 13 ++++++++++++- src/posit/connect/variants.py | 22 ++++++++++++++++------ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index bfef8365..c60fe493 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -6,11 +6,12 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Generic, List, Optional, Sequence, TypeVar, overload +from .context import Context + if TYPE_CHECKING: import requests from ._typing_extensions import Self - from .context import Context from .urls import Url @@ -31,6 +32,16 @@ class ResourceParameters: url: Url +def context_to_resource_parameters(ctx: Context) -> ResourceParameters: + """Temp method to aid in transitioning from `Context` to `ResourceParameters`.""" + return ResourceParameters(ctx.session, ctx.url) + + +def resource_parameters_to_context(params: ResourceParameters) -> Context: + """Temp method to aid in transitioning from `ResourceParameters` to `Context`.""" + return Context(params.session, params.url) + + class Resource(dict): def __init__(self, /, params: ResourceParameters, **kwargs): self.params = params diff --git a/src/posit/connect/variants.py b/src/posit/connect/variants.py index eb6a28c0..614121be 100644 --- a/src/posit/connect/variants.py +++ b/src/posit/connect/variants.py @@ -1,17 +1,25 @@ from typing import List -from .resources import Resource, ResourceParameters, Resources +from ._active import ResourceDict +from .resources import ( + ResourceParameters, + Resources, + context_to_resource_parameters, + resource_parameters_to_context, +) from .tasks import Task -class Variant(Resource): +class Variant(ResourceDict): def render(self) -> Task: + # TODO Move to within Task logic? path = f"variants/{self['id']}/render" - url = self.params.url + path - response = self.params.session.post(url) - return Task(self.params, **response.json()) + url = self._ctx.url + path + response = self._ctx.session.post(url) + return Task(context_to_resource_parameters(self._ctx), **response.json()) +# TODO; Inherit from ActiveList class Variants(Resources): def __init__(self, params: ResourceParameters, content_guid: str) -> None: super().__init__(params) @@ -22,4 +30,6 @@ def find(self) -> List[Variant]: url = self.params.url + path response = self.params.session.get(url) results = response.json() or [] - return [Variant(self.params, **result) for result in results] + return [ + Variant(resource_parameters_to_context(self.params), **result) for result in results + ] From c91b3d14189031800dfe2dd5f5ca454940912c90 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 12 Nov 2024 15:51:26 -0500 Subject: [PATCH 07/47] Move content item repository to its own file --- src/posit/connect/_utils.py | 10 ++ src/posit/connect/content.py | 125 +---------------------- src/posit/connect/content_repository.py | 127 ++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 124 deletions(-) create mode 100644 src/posit/connect/content_repository.py diff --git a/src/posit/connect/_utils.py b/src/posit/connect/_utils.py index 26842bbc..d3a2d1f3 100644 --- a/src/posit/connect/_utils.py +++ b/src/posit/connect/_utils.py @@ -5,3 +5,13 @@ def drop_none(x: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in x.items() if v is not None} + + +def _assert_guid(guid: str): + assert isinstance(guid, str), "Expected 'guid' to be a string" + 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" diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 48cecd74..5ee9fd93 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -18,6 +18,7 @@ from . import tasks from ._active import ActiveDict, JsonifiableDict from ._typing_extensions import NotRequired, Required, TypedDict, Unpack +from ._utils import _assert_content_guid, _assert_guid from .bundles import Bundles from .context import Context from .env import EnvVars @@ -33,130 +34,6 @@ from .tasks import Task -def _assert_guid(guid: str): - assert isinstance(guid, str), "Expected 'guid' to be a string" - 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(ActiveDict): - """ - 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(Resource): def __init__(self, params: ResourceParameters, content_guid: str) -> None: super().__init__(params) diff --git a/src/posit/connect/content_repository.py b/src/posit/connect/content_repository.py new file mode 100644 index 00000000..153f98cc --- /dev/null +++ b/src/posit/connect/content_repository.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from typing import ( + TYPE_CHECKING, + cast, +) + +from ._active import ActiveDict, JsonifiableDict +from ._typing_extensions import NotRequired, TypedDict, Unpack +from ._utils import _assert_content_guid + +if TYPE_CHECKING: + from .context import Context + + +class ContentItemRepository(ActiveDict): + """ + 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] + ) From 3089b87508f00a496207d1e7c9af0887e2ed4460 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 12 Nov 2024 15:51:51 -0500 Subject: [PATCH 08/47] Relax type restrictions (explicitly) --- src/posit/connect/_active.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/posit/connect/_active.py b/src/posit/connect/_active.py index 7e417e06..f0d6e85f 100644 --- a/src/posit/connect/_active.py +++ b/src/posit/connect/_active.py @@ -10,6 +10,7 @@ from typing import ( TYPE_CHECKING, Any, + Dict, Generator, Generic, List, @@ -17,6 +18,7 @@ Self, Sequence, TypeVar, + Unpack, cast, overload, ) @@ -152,7 +154,7 @@ def __init__( path: str, get_data: Optional[bool] = None, /, - **attrs: Jsonifiable, + **kwargs: Any, ) -> None: """ A dict abstraction for any HTTP endpoint that returns a singular resource. @@ -169,22 +171,22 @@ def __init__( 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 + **kwargs : Any 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 + get_data = len(kwargs) == 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 + init_kwargs: Jsonifiable = get_api(ctx, path) + init_kwargs_dict = cast(ResponseAttrs, init_kwargs) + # Overwrite the initial attributes with `kwargs`: e.g. {'key': value} | {'content_guid': '123'} + init_kwargs_dict.update(kwargs) + kwargs = init_kwargs_dict - super().__init__(ctx, **attrs) + super().__init__(ctx, **kwargs) self._path = path From 85f648c561a9851a2b0f45c3f69cce4e028fe4c2 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 12 Nov 2024 15:52:27 -0500 Subject: [PATCH 09/47] Create `_types` file to hold protocol classes --- src/posit/connect/_api_call.py | 16 ++-------------- src/posit/connect/_types.py | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 14 deletions(-) create mode 100644 src/posit/connect/_types.py diff --git a/src/posit/connect/_api_call.py b/src/posit/connect/_api_call.py index cff8df47..d82244ad 100644 --- a/src/posit/connect/_api_call.py +++ b/src/posit/connect/_api_call.py @@ -3,25 +3,13 @@ import posixpath from typing import TYPE_CHECKING, Protocol +from ._types import ContextP + if TYPE_CHECKING: from ._json import Jsonifiable from .context import Context -# Just the same as `.context.py` ContextManager but with `._ctx` attribute, not `.ctx` -class ContextP(Protocol): - _ctx: Context - - -class ContextCls(ContextP): - """Class that contains the client context.""" - - _ctx: Context - - def __init__(self, ctx: Context): - self._ctx = ctx - - class ApiCallProtocol(ContextP, Protocol): _path: str diff --git a/src/posit/connect/_types.py b/src/posit/connect/_types.py new file mode 100644 index 00000000..e19c1d9c --- /dev/null +++ b/src/posit/connect/_types.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from .context import Context + + +# Just the same as `.context.py` ContextManager but with `._ctx` attribute, not `.ctx` +class ContextP(Protocol): + _ctx: Context + + +class ContentItemP(ContextP, Protocol): + _path: str + + +class ContextCls(ContextP): + """Class that contains the client context.""" + + _ctx: Context + + def __init__(self, ctx: Context): + self._ctx = ctx From 57a7da9aa462610041d72b1252a73136d3619f78 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 12 Nov 2024 15:57:40 -0500 Subject: [PATCH 10/47] Rename file to private --- .../connect/{content_repository.py => _content_repository.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/posit/connect/{content_repository.py => _content_repository.py} (100%) diff --git a/src/posit/connect/content_repository.py b/src/posit/connect/_content_repository.py similarity index 100% rename from src/posit/connect/content_repository.py rename to src/posit/connect/_content_repository.py From a3263aff0d6f6590c60015a92963ba1787567b52 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 Nov 2024 11:29:54 -0500 Subject: [PATCH 11/47] Use helper classes for ContentItem and Context --- src/posit/connect/_active.py | 27 ++++++++++----------- src/posit/connect/_api_call.py | 2 +- src/posit/connect/_types.py | 24 ------------------- src/posit/connect/_types_content_item.py | 30 ++++++++++++++++++++++++ src/posit/connect/_types_context.py | 14 +++++++++++ 5 files changed, 58 insertions(+), 39 deletions(-) delete mode 100644 src/posit/connect/_types.py create mode 100644 src/posit/connect/_types_content_item.py create mode 100644 src/posit/connect/_types_context.py diff --git a/src/posit/connect/_active.py b/src/posit/connect/_active.py index f0d6e85f..066d9503 100644 --- a/src/posit/connect/_active.py +++ b/src/posit/connect/_active.py @@ -10,7 +10,6 @@ from typing import ( TYPE_CHECKING, Any, - Dict, Generator, Generic, List, @@ -18,13 +17,13 @@ Self, Sequence, TypeVar, - Unpack, cast, overload, ) from ._api_call import ApiCallMixin, ContextP, get_api from ._json import Jsonifiable, JsonifiableDict, ResponseAttrs +from ._types_context import ContextT if TYPE_CHECKING: from .context import Context @@ -107,15 +106,15 @@ def items(self): return self._dict.items() -class ResourceDict(ReadOnlyDict, ContextP): +class ResourceDict(ReadOnlyDict, ContextP[ContextT]): """An abstraction to contain the context and read-only information.""" - _ctx: Context + _ctx: ContextT """The context object containing the session and URL for API interactions.""" def __init__( self, - ctx: Context, + ctx: ContextT, /, **kwargs: Any, ) -> None: @@ -137,10 +136,10 @@ def __init__( self._ctx = ctx -class ActiveDict(ApiCallMixin, ResourceDict): +class ActiveDict(ApiCallMixin, ResourceDict[ContextT]): """A dict abstraction for any HTTP endpoint that returns a singular resource.""" - _ctx: Context + _ctx: ContextT """The context object containing the session and URL for API interactions.""" _path: str """The HTTP path component for the resource endpoint.""" @@ -150,7 +149,7 @@ def _get_api(self, *path) -> JsonifiableDict | None: def __init__( self, - ctx: Context, + ctx: ContextT, path: str, get_data: Optional[bool] = None, /, @@ -163,7 +162,7 @@ def __init__( Parameters ---------- - ctx : Context + ctx : ContextT The context object containing the session and URL for API interactions. path : str The HTTP path component for the resource endpoint @@ -194,17 +193,17 @@ def __init__( """A type variable that is bound to the `Active` class""" -class ActiveSequence(ABC, Generic[T], Sequence[T]): +class ActiveSequence(ABC, Generic[T, ContextT], Sequence[T]): """A sequence for any HTTP GET endpoint that returns a collection.""" _cache: Optional[List[T]] - def __init__(self, ctx: Context, path: str, uid: str = "guid"): + def __init__(self, ctx: ContextT, path: str, uid: str = "guid"): """A sequence abstraction for any HTTP GET endpoint that returns a collection. Parameters ---------- - ctx : Context + ctx : ContextT The context object containing the session and URL for API interactions. path : str The HTTP path component for the collection endpoint @@ -293,7 +292,7 @@ def __repr__(self) -> str: return repr(self._data) -class ActiveFinderMethods(ActiveSequence[T]): +class ActiveFinderMethods(ActiveSequence[T, ContextT]): """Finder methods. Provides various finder methods for locating records in any endpoint supporting HTTP GET requests. @@ -319,7 +318,7 @@ def find(self, uid) -> T: result = response.json() return self._to_instance(result) - def find_by(self, **conditions: Any) -> T | None: + def find_by(self, **conditions) -> T | None: """ Find the first record matching the specified conditions. diff --git a/src/posit/connect/_api_call.py b/src/posit/connect/_api_call.py index d82244ad..03a65dd3 100644 --- a/src/posit/connect/_api_call.py +++ b/src/posit/connect/_api_call.py @@ -3,7 +3,7 @@ import posixpath from typing import TYPE_CHECKING, Protocol -from ._types import ContextP +from ._types_context import ContextP if TYPE_CHECKING: from ._json import Jsonifiable diff --git a/src/posit/connect/_types.py b/src/posit/connect/_types.py deleted file mode 100644 index e19c1d9c..00000000 --- a/src/posit/connect/_types.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Protocol - -if TYPE_CHECKING: - from .context import Context - - -# Just the same as `.context.py` ContextManager but with `._ctx` attribute, not `.ctx` -class ContextP(Protocol): - _ctx: Context - - -class ContentItemP(ContextP, Protocol): - _path: str - - -class ContextCls(ContextP): - """Class that contains the client context.""" - - _ctx: Context - - def __init__(self, ctx: Context): - self._ctx = ctx diff --git a/src/posit/connect/_types_content_item.py b/src/posit/connect/_types_content_item.py new file mode 100644 index 00000000..9d18cc21 --- /dev/null +++ b/src/posit/connect/_types_content_item.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Protocol + +from ._active import ActiveDict, ResourceDict +from ._types_context import ContextP +from .context import Context + + +class ContentItemP(ContextP["ContentItemContext"], Protocol): + _ctx: ContentItemContext + + +class ContentItemResourceDict(ResourceDict["ContentItemContext"], ContentItemP): + pass + + +class ContentItemActiveDict(ActiveDict["ContentItemContext"], ContentItemP): + pass + + +class ContentItemContext(Context): + content_guid: str + content_path: str + + def __init__(self, ctx: Context, *, content_guid: str) -> None: + super().__init__(ctx.session, ctx.url) + self.content_guid = content_guid + content_path = f"v1/content/{content_guid}" + self.content_path = content_path diff --git a/src/posit/connect/_types_context.py b/src/posit/connect/_types_context.py new file mode 100644 index 00000000..b0a67db6 --- /dev/null +++ b/src/posit/connect/_types_context.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Generic, Protocol, TypeVar + +if TYPE_CHECKING: + from .context import Context + +# Any subclass of Context should be able to be used where Context is expected +ContextT = TypeVar("ContextT", bound="Context", covariant=True) + + +# Just the same as `.context.py` ContextManager but with `._ctx` attribute, not `.ctx` +class ContextP(Generic[ContextT], Protocol): + _ctx: ContextT From dbfbc3377ff0b23b064b3f8368d000ee1a9b3c32 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 Nov 2024 11:31:23 -0500 Subject: [PATCH 12/47] Update _content_repository.py --- src/posit/connect/_content_repository.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/posit/connect/_content_repository.py b/src/posit/connect/_content_repository.py index 153f98cc..f7ab38be 100644 --- a/src/posit/connect/_content_repository.py +++ b/src/posit/connect/_content_repository.py @@ -6,8 +6,8 @@ ) from ._active import ActiveDict, JsonifiableDict +from ._types_content_item import ContentItemContext from ._typing_extensions import NotRequired, TypedDict, Unpack -from ._utils import _assert_content_guid if TYPE_CHECKING: from .context import Context @@ -36,10 +36,8 @@ class _Attrs(TypedDict, total=False): def __init__( self, - ctx: Context, + ctx: ContentItemContext, /, - *, - content_guid: str, # By default, the `attrs` will be retrieved from the API if no `attrs` are supplied. **attrs: Unpack[ContentItemRepository._Attrs], ) -> None: @@ -49,18 +47,14 @@ def __init__( ---------- 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) + path = self._api_path(ctx.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}) + super().__init__(ctx, path, get_data, **attrs) @classmethod def _api_path(cls, content_guid: str) -> str: @@ -76,10 +70,14 @@ def _create( from ._api_call import put_api result = put_api(ctx, cls._api_path(content_guid), json=cast(JsonifiableDict, attrs)) + content_ctx = ( + ctx + if isinstance(ctx, ContentItemContext) + else ContentItemContext(ctx, content_guid=content_guid) + ) return ContentItemRepository( - ctx, - content_guid=content_guid, + content_ctx, **result, # pyright: ignore[reportCallIssue] ) @@ -122,6 +120,5 @@ def update( result = self._patch_api(json=cast(JsonifiableDict, dict(attrs))) return ContentItemRepository( self._ctx, - content_guid=self["content_guid"], **result, # pyright: ignore[reportCallIssue] ) From 47ec1e36b2798dfecc6fd6ffdb8668c46d08fc46 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 Nov 2024 11:32:20 -0500 Subject: [PATCH 13/47] Update Vanity --- src/posit/connect/vanities.py | 101 +++++++++++++++++---------- tests/posit/connect/test_vanities.py | 35 +++++++--- 2 files changed, 89 insertions(+), 47 deletions(-) diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index c0345eed..312fc069 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -1,11 +1,14 @@ -from typing import Callable, List, Optional +from __future__ import annotations +from typing import Callable, Optional, Protocol + +from ._types_content_item import ContentItemActiveDict, ContentItemContext, ContentItemP from ._typing_extensions import NotRequired, Required, TypedDict, Unpack from .errors import ClientError -from .resources import Resource, ResourceParameters, Resources +from .resources import Resources, resource_parameters_to_content_item_context -class Vanity(Resource): +class Vanity(ContentItemActiveDict): """A vanity resource. Vanities maintain custom URL paths assigned to content. @@ -43,20 +46,19 @@ class Vanity(Resource): AfterDestroyCallback = Callable[[], None] - class VanityAttributes(TypedDict): + class _VanityAttributes(TypedDict): """Vanity attributes.""" path: Required[str] - content_guid: Required[str] created_time: Required[str] def __init__( self, /, - params: ResourceParameters, + ctx: ContentItemContext, *, after_destroy: Optional[AfterDestroyCallback] = None, - **kwargs: Unpack[VanityAttributes], + **kwargs: Unpack[_VanityAttributes], ): """Initialize a Vanity. @@ -66,9 +68,11 @@ def __init__( after_destroy : AfterDestroyCallback, optional Called after the Vanity is successfully destroyed, by default None """ - super().__init__(params, **kwargs) + path = f"v1/content/{ctx.content_guid}/vanity" + get_data = len(kwargs) == 0 + super().__init__(ctx, path, get_data, **kwargs) + self._after_destroy = after_destroy - self._content_guid = kwargs["content_guid"] def destroy(self) -> None: """Destroy the vanity. @@ -86,9 +90,7 @@ def destroy(self) -> None: ---- This action requires administrator privileges. """ - endpoint = self.params.url + f"v1/content/{self._content_guid}/vanity" - self.params.session.delete(endpoint) - + self._delete_api() if self._after_destroy: self._after_destroy() @@ -96,7 +98,7 @@ def destroy(self) -> None: class Vanities(Resources): """Manages a collection of vanities.""" - def all(self) -> List[Vanity]: + def all(self) -> list[Vanity]: """Retrieve all vanities. Returns @@ -110,26 +112,51 @@ def all(self) -> List[Vanity]: endpoint = self.params.url + "v1/vanities" response = self.params.session.get(endpoint) results = response.json() - return [Vanity(self.params, **result) for result in results] + ret: list[Vanity] = [] + for result in results: + assert isinstance(result, dict) + assert "content_guid" in result + + ret.append( + Vanity( + resource_parameters_to_content_item_context( + self.params, + content_guid=result["content_guid"], + ), + **result, + ) + ) + return ret + + +class ContentItemVanityP(ContentItemP, Protocol): + _vanity: Vanity | None + + def find_vanity(self) -> Vanity: ... + def create_vanity( + self, **kwargs: Unpack["ContentItemVanityMixin._CreateVanityRequest"] + ) -> Vanity: ... -class VanityMixin(Resource): - """Mixin class to add a vanity attribute to a resource.""" + def reset_vanity(self) -> None: ... - class HasGuid(TypedDict): - """Has a guid.""" + @property + def vanity(self) -> Optional[str]: ... + + @vanity.setter + def vanity(self, value: str) -> None: ... - guid: Required[str] + @vanity.deleter + def vanity(self) -> None: ... - def __init__(self, params: ResourceParameters, **kwargs: Unpack[HasGuid]): - super().__init__(params, **kwargs) - self._content_guid = kwargs["guid"] - self._vanity: Optional[Vanity] = None + +class ContentItemVanityMixin: + """Class to add a vanity attribute to a resource.""" @property - def vanity(self) -> Optional[str]: + def vanity(self: ContentItemVanityP) -> str | None: """Get the vanity.""" - if self._vanity: + if "_vanity" in self.__dict__ and self._vanity: return self._vanity["path"] try: @@ -142,7 +169,7 @@ def vanity(self) -> Optional[str]: raise e @vanity.setter - def vanity(self, value: str) -> None: + def vanity(self: ContentItemVanityP, value: str) -> None: """Set the vanity. Parameters @@ -162,7 +189,7 @@ def vanity(self, value: str) -> None: self._vanity._after_destroy = self.reset_vanity @vanity.deleter - def vanity(self) -> None: + def vanity(self: ContentItemVanityP) -> None: """Destroy the vanity. Warnings @@ -184,14 +211,14 @@ def vanity(self) -> None: self._vanity.destroy() self.reset_vanity() - def reset_vanity(self) -> None: + def reset_vanity(self: ContentItemVanityP) -> None: """Unload the cached vanity. Forces the next access, if any, to query the vanity from the Connect server. """ self._vanity = None - class CreateVanityRequest(TypedDict, total=False): + class _CreateVanityRequest(TypedDict, total=False): """A request schema for creating a vanity.""" path: Required[str] @@ -200,7 +227,7 @@ class CreateVanityRequest(TypedDict, total=False): force: NotRequired[bool] """Whether to force creation of the vanity""" - def create_vanity(self, **kwargs: Unpack[CreateVanityRequest]) -> Vanity: + def create_vanity(self: ContentItemVanityP, **kwargs: Unpack[_CreateVanityRequest]) -> Vanity: """Create a vanity. Parameters @@ -214,19 +241,19 @@ def create_vanity(self, **kwargs: Unpack[CreateVanityRequest]) -> Vanity: -------- If setting force=True, the destroy operation performed on the other vanity is irreversible. """ - endpoint = self.params.url + f"v1/content/{self._content_guid}/vanity" - response = self.params.session.put(endpoint, json=kwargs) + endpoint = self._ctx.url + f"v1/content/{self._ctx.content_guid}/vanity" + response = self._ctx.session.put(endpoint, json=kwargs) result = response.json() - return Vanity(self.params, **result) + return Vanity(self._ctx, **result) - def find_vanity(self) -> Vanity: + def find_vanity(self: ContentItemVanityP) -> Vanity: """Find the vanity. Returns ------- Vanity """ - endpoint = self.params.url + f"v1/content/{self._content_guid}/vanity" - response = self.params.session.get(endpoint) + endpoint = self._ctx.url + f"v1/content/{self._ctx.content_guid}/vanity" + response = self._ctx.session.get(endpoint) result = response.json() - return Vanity(self.params, **result) + return Vanity(self._ctx, **result) diff --git a/tests/posit/connect/test_vanities.py b/tests/posit/connect/test_vanities.py index cfa3dd4a..f1bbd985 100644 --- a/tests/posit/connect/test_vanities.py +++ b/tests/posit/connect/test_vanities.py @@ -4,9 +4,12 @@ import responses from responses.matchers import json_params_matcher +from posit.connect._types_content_item import ContentItemContext +from posit.connect.content import ContentItem +from posit.connect.context import Context from posit.connect.resources import ResourceParameters from posit.connect.urls import Url -from posit.connect.vanities import Vanities, Vanity, VanityMixin +from posit.connect.vanities import Vanities, Vanity class TestVanityDestroy: @@ -19,8 +22,8 @@ def test_destroy_sends_delete_request(self): session = requests.Session() url = Url(base_url) - params = ResourceParameters(session, url) - vanity = Vanity(params, content_guid=content_guid, path=Mock(), created_time=Mock()) + ctx = ContentItemContext(Context(session, url), content_guid=content_guid) + vanity = Vanity(ctx, path=Mock(), created_time=Mock()) vanity.destroy() @@ -36,11 +39,10 @@ def test_destroy_calls_after_destroy_callback(self): session = requests.Session() url = Url(base_url) after_destroy = Mock() - params = ResourceParameters(session, url) + ctx = ContentItemContext(Context(session, url), content_guid=content_guid) vanity = Vanity( - params, + ctx, after_destroy=after_destroy, - content_guid=content_guid, path=Mock(), created_time=Mock(), ) @@ -78,7 +80,11 @@ def test_vanity_getter_returns_vanity(self): session = requests.Session() url = Url(base_url) params = ResourceParameters(session, url) - content = VanityMixin(params, guid=guid) + content = ContentItem( + params, + guid=guid, + name="testing", # provide name to avoid request + ) assert content.vanity == "my-dashboard" assert mock_get.call_count == 1 @@ -98,7 +104,11 @@ def test_vanity_setter_with_string(self): session = requests.Session() url = Url(base_url) params = ResourceParameters(session, url) - content = VanityMixin(params, guid=guid) + content = ContentItem( + params=params, + guid=guid, + name="testing", # provide name to avoid request + ) content.vanity = path assert content.vanity == path @@ -114,8 +124,13 @@ def test_vanity_deleter(self): session = requests.Session() url = Url(base_url) params = ResourceParameters(session, url) - content = VanityMixin(params, guid=guid) - content._vanity = Vanity(params, path=Mock(), content_guid=guid, created_time=Mock()) + content = ContentItem( + params=params, + guid=guid, + name="testing", # provide name to avoid request + ) + + content._vanity = Vanity(content._ctx, path=Mock(), created_time=Mock()) del content.vanity assert content._vanity is None From a1ba95380b4ad1669af4a316c353e072a64fb6a0 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 Nov 2024 11:32:33 -0500 Subject: [PATCH 14/47] Cosmetic to Users --- src/posit/connect/users.py | 41 +++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/src/posit/connect/users.py b/src/posit/connect/users.py index cc206da8..0e4acac4 100644 --- a/src/posit/connect/users.py +++ b/src/posit/connect/users.py @@ -10,6 +10,33 @@ from .paginator import Paginator from .resources import Resource, ResourceParameters, Resources +# TODO-barret-future; Separate PR for updating User to ActiveDict class + +# from typing import cast +# from ._active import ActiveDict +# from ._json import JsonifiableDict +# from .context import Context +# from .resources import context_to_resource_parameters +# @classmethod +# def _api_path(cls) -> str: +# return "v1/users" + +# @classmethod +# def _create( +# cls, +# ctx: Context, +# **attrs: Unpack[ContentItemRepository._Attrs], +# ) -> User: +# from ._api_call import put_api + +# # todo - use the 'context' module to inspect the 'authentication' object and route to POST (local) or PUT (remote). +# result = put_api(ctx, cls._api_path(), json=cast(JsonifiableDict, attrs)) + +# return User( +# ctx, +# **result, # pyright: ignore[reportCallIssue] +# ) + class User(Resource): @property @@ -72,7 +99,7 @@ def unlock(self): self.params.session.post(url, json=body) super().update(locked=False) - class UpdateUser(TypedDict): + class _UpdateUser(TypedDict): """Update user request.""" email: NotRequired[str] @@ -83,7 +110,7 @@ class UpdateUser(TypedDict): def update( self, - **kwargs: Unpack[UpdateUser], + **kwargs: Unpack[_UpdateUser], ) -> None: """ Update the user's attributes. @@ -126,7 +153,7 @@ class Users(Resources): def __init__(self, params: ResourceParameters) -> None: super().__init__(params) - class CreateUser(TypedDict): + class _CreateUser(TypedDict): """Create user request.""" username: Required[str] @@ -141,7 +168,7 @@ class CreateUser(TypedDict): user_role: NotRequired[Literal["administrator", "publisher", "viewer"]] unique_id: NotRequired[str] - def create(self, **attributes: Unpack[CreateUser]) -> User: + def create(self, **attributes: Unpack[_CreateUser]) -> User: """ Create a new user with the specified attributes. @@ -200,14 +227,14 @@ def create(self, **attributes: Unpack[CreateUser]) -> User: response = self.params.session.post(url, json=attributes) return User(self.params, **response.json()) - class FindUser(TypedDict): + class _FindUser(TypedDict): """Find user request.""" prefix: NotRequired[str] user_role: NotRequired[Literal["administrator", "publisher", "viewer"] | str] account_status: NotRequired[Literal["locked", "licensed", "inactive"] | str] - def find(self, **conditions: Unpack[FindUser]) -> List[User]: + def find(self, **conditions: Unpack[_FindUser]) -> List[User]: """ Find users matching the specified conditions. @@ -250,7 +277,7 @@ def find(self, **conditions: Unpack[FindUser]) -> List[User]: for user in results ] - def find_one(self, **conditions: Unpack[FindUser]) -> User | None: + def find_one(self, **conditions: Unpack[_FindUser]) -> User | None: """ Find a user matching the specified conditions. From 81ebea9dd5f4f36752a085df6cb56d6bbc2c85c0 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 Nov 2024 11:32:44 -0500 Subject: [PATCH 15/47] Helper method --- src/posit/connect/resources.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index c60fe493..97c68633 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Generic, List, Optional, Sequence, TypeVar, overload +from ._types_content_item import ContentItemContext from .context import Context if TYPE_CHECKING: @@ -42,6 +43,15 @@ def resource_parameters_to_context(params: ResourceParameters) -> Context: return Context(params.session, params.url) +def resource_parameters_to_content_item_context( + params: ResourceParameters, + content_guid: str, +) -> ContentItemContext: + """Temp method to aid in transitioning from `ResourceParameters` to `Context`.""" + ctx = Context(params.session, params.url) + return ContentItemContext(ctx, content_guid=content_guid) + + class Resource(dict): def __init__(self, /, params: ResourceParameters, **kwargs): self.params = params From df1180cd26f1fd245503d9f8fe50f7f357418b85 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 Nov 2024 11:33:12 -0500 Subject: [PATCH 16/47] Job to inherit from ActiveDict --- src/posit/connect/jobs.py | 73 +++++++++++++++++++++------------------ 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index bcf97092..b70e152f 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -1,9 +1,11 @@ +from __future__ import annotations + import posixpath -from typing import Any, Literal, Optional, overload +from typing import Any, Literal, Optional +from ._active import ActiveDict, ActiveFinderMethods, ActiveSequence +from ._types_content_item import ContentItemContext, ContentItemP from ._typing_extensions import NotRequired, Required, TypedDict, Unpack -from .context import Context -from .resources import Active, ActiveFinderMethods, ActiveSequence, Resource JobTag = Literal[ "unknown", @@ -32,7 +34,7 @@ ] -class Job(Active): +class Job(ActiveDict): class _Job(TypedDict): # Identifiers id: Required[str] @@ -100,7 +102,7 @@ class _Job(TypedDict): tag: Required[JobTag] """A tag categorizing the job type. Options are build_jupyter, build_report, build_site, configure_report, git, packrat_restore, python_restore, render_shiny, run_api, run_app, run_bokeh_app, run_dash_app, run_fastapi_app, run_pyshiny_app, run_python_api, run_streamlit, run_tensorflow, run_voila_app, testing, unknown, val_py_ext_pkg, val_r_ext_pkg, and val_r_install.""" - def __init__(self, ctx: Context, path: str, /, **attributes: Unpack[_Job]): + def __init__(self, ctx: ContentItemContext, path: str, /, **attributes: Unpack[_Job]): super().__init__(ctx, path, **attributes) def destroy(self) -> None: @@ -116,21 +118,19 @@ def destroy(self) -> None: ---- This action requires administrator, owner, or collaborator privileges. """ - endpoint = self._ctx.url + self._path - self._ctx.session.delete(endpoint) + self._delete_api() -class Jobs(ActiveFinderMethods[Job], ActiveSequence[Job]): - def __init__(self, ctx: Context, path: str): +class Jobs(ActiveFinderMethods[Job, ContentItemContext], ActiveSequence[Job, ContentItemContext]): + def __init__(self, ctx: ContentItemContext) -> None: """A collection of jobs. Parameters ---------- - ctx : Context + ctx : ContentItemContext The context object containing the session and URL for API interactions - path : str - The HTTP path component for the jobs endpoint (e.g., 'v1/content/544509fc-e4f0-41de-acb4-1fe3a2c1d797/jobs') """ + path = posixpath.join(ctx.content_path, "jobs") super().__init__(ctx, path, "key") def _create_instance(self, path: str, /, **attributes: Any) -> Job: @@ -149,7 +149,7 @@ def _create_instance(self, path: str, /, **attributes: Any) -> Job: class _FindByRequest(TypedDict, total=False): # Identifiers - id: Required[str] + id: NotRequired[str] # TODO-barret-q: Is this change correct? """A unique identifier for the job.""" ppid: NotRequired[Optional[str]] @@ -214,8 +214,10 @@ class _FindByRequest(TypedDict, total=False): tag: NotRequired[JobTag] """A tag categorizing the job type. Options are build_jupyter, build_report, build_site, configure_report, git, packrat_restore, python_restore, render_shiny, run_api, run_app, run_bokeh_app, run_dash_app, run_fastapi_app, run_pyshiny_app, run_python_api, run_streamlit, run_tensorflow, run_voila_app, testing, unknown, val_py_ext_pkg, val_r_ext_pkg, and val_r_install.""" - @overload - def find_by(self, **conditions: Unpack[_FindByRequest]) -> Optional[Job]: + def find_by( # pyright: ignore[reportIncompatibleMethodOverride] + self, + **conditions: Unpack[_FindByRequest], + ) -> Job | None: """Finds the first record matching the specified conditions. There is no implied ordering so if order matters, you should specify it yourself. @@ -267,30 +269,33 @@ def find_by(self, **conditions: Unpack[_FindByRequest]) -> Optional[Job]: ------- Optional[Job] """ - - @overload - def find_by(self, **conditions): ... - - def find_by(self, **conditions) -> Optional[Job]: return super().find_by(**conditions) -class JobsMixin(Active, Resource): +class JobsMixin: """Mixin class to add a jobs attribute to a resource.""" - def __init__(self, ctx, path, /, **attributes): - """Mixin class which adds a `jobs` attribute to the Active Resource. + # def __init__(self: ContentItemP, /, **kwargs: Any) -> None: + # super().__init__(**kwargs) - Parameters - ---------- - ctx : Context - The context object containing the session and URL for API interactions - path : str - The HTTP path component for the resource endpoint - **attributes : dict - Resource attributes passed + @property + def jobs(self: ContentItemP) -> Jobs: + """Get the jobs. + + Returns + ------- + Jobs """ - super().__init__(ctx, path, **attributes) + # Do not cache result. `content.jobs` should always return the latest jobs. + + # if self.__dict__.get("_jobs") is not None: + # # Early return + # return self._jobs + + # # Populate Jobs info + # class ContentItemJobsP(ContentItemP, Protocol): + # _jobs: Jobs + # self._jobs = Jobs(self._ctx, posixpath.join(self._path, "jobs")) + # return self._jobs - path = posixpath.join(path, "jobs") - self.jobs = Jobs(ctx, path) + return Jobs(self._ctx) From 45660cffa8929dfd28079e6944998424c5dc1a35 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 Nov 2024 11:34:05 -0500 Subject: [PATCH 17/47] BundleMetadata --- src/posit/connect/bundles.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/posit/connect/bundles.py b/src/posit/connect/bundles.py index c6a8a265..09407a46 100644 --- a/src/posit/connect/bundles.py +++ b/src/posit/connect/bundles.py @@ -6,16 +6,18 @@ from typing import List from . import resources, tasks +from ._active import ReadOnlyDict -class BundleMetadata(resources.Resource): +class BundleMetadata(ReadOnlyDict): pass +# TODO-barret Inherit from `ActiveDict` class Bundle(resources.Resource): @property def metadata(self) -> BundleMetadata: - return BundleMetadata(self.params, **self.get("metadata", {})) + return BundleMetadata(**self.get("metadata", {})) def delete(self) -> None: """Delete the bundle.""" From 8dd6d0b21586f9ae8f20b641a18dddf29ad2dee6 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 Nov 2024 11:46:46 -0500 Subject: [PATCH 18/47] Overhaul ContentItem --- src/posit/connect/content.py | 86 +++++++++++++++++------------ tests/posit/connect/test_content.py | 16 ++++-- 2 files changed, 63 insertions(+), 39 deletions(-) diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 5ee9fd93..73afa706 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -16,9 +16,11 @@ ) from . import tasks -from ._active import ActiveDict, JsonifiableDict +from ._content_repository import ContentItemRepository +from ._json import JsonifiableDict +from ._types_content_item import ContentItemActiveDict, ContentItemContext, ContentItemResourceDict from ._typing_extensions import NotRequired, Required, TypedDict, Unpack -from ._utils import _assert_content_guid, _assert_guid +from ._utils import _assert_guid from .bundles import Bundles from .context import Context from .env import EnvVars @@ -26,29 +28,32 @@ from .jobs import JobsMixin from .oauth.associations import ContentItemAssociations from .permissions import Permissions -from .resources import Resource, ResourceParameters, Resources -from .vanities import VanityMixin +from .resources import ResourceParameters, Resources, context_to_resource_parameters +from .vanities import ContentItemVanityMixin from .variants import Variants if TYPE_CHECKING: from .tasks import Task + from .users import User -class ContentItemOAuth(Resource): - def __init__(self, params: ResourceParameters, content_guid: str) -> None: - super().__init__(params) - self["content_guid"] = content_guid +class ContentItemOAuth(ContentItemResourceDict): + def __init__(self, ctx: ContentItemContext) -> None: + super().__init__(ctx) @property def associations(self) -> ContentItemAssociations: - return ContentItemAssociations(self.params, content_guid=self["content_guid"]) + return ContentItemAssociations( + context_to_resource_parameters(self._ctx), + content_guid=self._ctx.content_guid, + ) -class ContentItemOwner(Resource): +class ContentItemOwner(ContentItemResourceDict): pass -class ContentItem(JobsMixin, VanityMixin, Resource): +class ContentItem(JobsMixin, ContentItemVanityMixin, ContentItemActiveDict): class _AttrsBase(TypedDict, total=False): # # `name` will be set by other _Attrs classes # name: str @@ -119,24 +124,27 @@ def __init__( ) -> None: _assert_guid(guid) - ctx = Context(params.session, params.url) + ctx = ContentItemContext(Context(params.session, params.url), content_guid=guid) path = f"v1/content/{guid}" - super().__init__(ctx, path, guid=guid, **kwargs) + get_data = len(kwargs) == 0 + + super().__init__(ctx, path, get_data, guid=guid, **kwargs) def __getitem__(self, key: Any) -> Any: v = super().__getitem__(key) + # TODO-barret-Q: Why isn't this a property? if key == "owner" and isinstance(v, dict): - return ContentItemOwner(params=self.params, **v) + return ContentItemOwner(self._ctx, **v) return v @property def oauth(self) -> ContentItemOAuth: - return ContentItemOAuth(self.params, content_guid=self["guid"]) + return ContentItemOAuth(self._ctx) @property def repository(self) -> ContentItemRepository | None: try: - return ContentItemRepository(self._ctx, content_guid=self["guid"]) + return ContentItemRepository(self._ctx) except ClientError: return None @@ -163,11 +171,10 @@ def create_repository( """ return ContentItemRepository._create(self._ctx, self["guid"], **attrs) + # Rename to destroy()? def delete(self) -> None: """Delete the content item.""" - path = f"v1/content/{self['guid']}" - url = self.params.url + path - self.params.session.delete(url) + self._delete_api() def deploy(self) -> tasks.Task: """Deploy the content. @@ -186,10 +193,10 @@ def deploy(self) -> tasks.Task: None """ path = f"v1/content/{self['guid']}/deploy" - url = self.params.url + path - response = self.params.session.post(url, json={"bundle_id": None}) + url = self._ctx.url + path + response = self._ctx.session.post(url, json={"bundle_id": None}) result = response.json() - ts = tasks.Tasks(self.params) + ts = tasks.Tasks(context_to_resource_parameters(self._ctx)) return ts.get(result["task_id"]) def render(self) -> Task: @@ -242,8 +249,8 @@ def restart(self) -> None: self.environment_variables.create(key, unix_epoch_in_seconds) self.environment_variables.delete(key) # GET via the base Connect URL to force create a new worker thread. - url = posixpath.join(dirname(self.params.url), f"content/{self['guid']}") - self.params.session.get(url) + url = posixpath.join(dirname(self._ctx.url), f"content/{self['guid']}") + self._ctx.session.get(url) return None else: raise ValueError( @@ -253,7 +260,7 @@ def restart(self) -> None: def update( self, **attrs: Unpack[ContentItem._Attrs], - ) -> None: + ) -> ContentItem: """Update the content item. Parameters @@ -313,38 +320,47 @@ def update( ------- None """ - url = self.params.url + f"v1/content/{self['guid']}" - response = self.params.session.patch(url, json=attrs) - super().update(**response.json()) + result = self._patch_api(json=cast(JsonifiableDict, dict(attrs))) + assert isinstance(result, dict) + assert "guid" in result + new_content_item = ContentItem( + params=context_to_resource_parameters(self._ctx), + # `guid=` is contained within the `result` dict + **result, # pyright: ignore[reportArgumentType, reportCallIssue] + ) + # TODO-barret Update method returns new content item + return new_content_item # Relationships @property def bundles(self) -> Bundles: - return Bundles(self.params, self["guid"]) + return Bundles(context_to_resource_parameters(self._ctx), self["guid"]) @property def environment_variables(self) -> EnvVars: - return EnvVars(self.params, self["guid"]) + return EnvVars(context_to_resource_parameters(self._ctx), self["guid"]) @property def permissions(self) -> Permissions: - return Permissions(self.params, self["guid"]) + return Permissions(context_to_resource_parameters(self._ctx), self["guid"]) + + _owner: User @property def owner(self) -> dict: - if "owner" not in self: + if "_owner" not in self.__dict__: # It is possible to get a content item that does not contain owner. # "owner" is an optional additional request param. # If it's not included, we can retrieve the information by `owner_guid` from .users import Users - self["owner"] = Users(self.params).get(self["owner_guid"]) - return self["owner"] + self._owner = Users(context_to_resource_parameters(self._ctx)).get(self["owner_guid"]) + return self._owner @property def _variants(self) -> Variants: - return Variants(self.params, self["guid"]) + return Variants(context_to_resource_parameters(self._ctx), self["guid"]) @property def is_interactive(self) -> bool: diff --git a/tests/posit/connect/test_content.py b/tests/posit/connect/test_content.py index 926df753..315f6dae 100644 --- a/tests/posit/connect/test_content.py +++ b/tests/posit/connect/test_content.py @@ -3,6 +3,7 @@ import responses from responses import matchers +from posit.connect._types_content_item import ContentItemContext from posit.connect.client import Client from posit.connect.content import ContentItem, ContentItemRepository from posit.connect.context import Context @@ -119,8 +120,8 @@ def test_update(self): json=fake_content, ) - content.update(name=new_name) - assert content["name"] == new_name + new_content = content.update(name=new_name) + assert new_content["name"] == new_name class TestContentCreate: @@ -562,7 +563,11 @@ def content_guid(self): @property def content_item(self): - return ContentItem(self.params, guid=self.content_guid) + return ContentItem( + self.params, + guid=self.content_guid, + name="testing", # provide name to avoid request + ) @property def endpoint(self): @@ -570,7 +575,10 @@ def endpoint(self): @property def ctx(self): - return Context(requests.Session(), Url(self.base_url)) + return ContentItemContext( + Context(requests.Session(), Url(self.base_url)), + content_guid=self.content_guid, + ) @property def params(self): From b525358fb9f2903b26a262baf7239f72e032aea5 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 Nov 2024 13:04:42 -0500 Subject: [PATCH 19/47] post merge updates; Update packages --- src/posit/connect/_active.py | 5 +-- src/posit/connect/_content_repository.py | 3 +- src/posit/connect/_types_content_item.py | 2 ++ src/posit/connect/client.py | 2 +- src/posit/connect/content.py | 1 - src/posit/connect/jobs.py | 7 ++-- src/posit/connect/packages.py | 43 +++++++++++++----------- src/posit/connect/resources.py | 5 +-- src/posit/connect/vanities.py | 3 +- 9 files changed, 36 insertions(+), 35 deletions(-) diff --git a/src/posit/connect/_active.py b/src/posit/connect/_active.py index 066d9503..c243b713 100644 --- a/src/posit/connect/_active.py +++ b/src/posit/connect/_active.py @@ -14,13 +14,14 @@ Generic, List, Optional, - Self, Sequence, TypeVar, cast, overload, ) +from typing_extensions import Self + from ._api_call import ApiCallMixin, ContextP, get_api from ._json import Jsonifiable, JsonifiableDict, ResponseAttrs from ._types_context import ContextT @@ -189,7 +190,7 @@ def __init__( self._path = path -T = TypeVar("T", bound="ActiveDict") +T = TypeVar("T", bound="ResourceDict") """A type variable that is bound to the `Active` class""" diff --git a/src/posit/connect/_content_repository.py b/src/posit/connect/_content_repository.py index f7ab38be..f8907ece 100644 --- a/src/posit/connect/_content_repository.py +++ b/src/posit/connect/_content_repository.py @@ -5,9 +5,10 @@ cast, ) +from typing_extensions import NotRequired, TypedDict, Unpack + from ._active import ActiveDict, JsonifiableDict from ._types_content_item import ContentItemContext -from ._typing_extensions import NotRequired, TypedDict, Unpack if TYPE_CHECKING: from .context import Context diff --git a/src/posit/connect/_types_content_item.py b/src/posit/connect/_types_content_item.py index 9d18cc21..0a800463 100644 --- a/src/posit/connect/_types_content_item.py +++ b/src/posit/connect/_types_content_item.py @@ -21,7 +21,9 @@ class ContentItemActiveDict(ActiveDict["ContentItemContext"], ContentItemP): class ContentItemContext(Context): content_guid: str + """The GUID of the content item""" content_path: str + """The path to the content item. Ex: 'v1/content/{self.content_guid}'""" def __init__(self, ctx: Context, *, content_guid: str) -> None: super().__init__(ctx.session, ctx.url) diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index e1ba808c..3756d7e5 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -273,7 +273,7 @@ def oauth(self) -> OAuth: @property @requires(version="2024.10.0-dev") def packages(self) -> Packages: - return Packages(self._ctx, "v1/packages") + return Packages(self._ctx) @property def vanities(self) -> Vanities: diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 32677803..c7d5f203 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -21,7 +21,6 @@ from ._content_repository import ContentItemRepository from ._json import JsonifiableDict from ._types_content_item import ContentItemActiveDict, ContentItemContext, ContentItemResourceDict -from ._typing_extensions import NotRequired, Required, TypedDict, Unpack from ._utils import _assert_guid from .bundles import Bundles from .context import Context diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index cfde7e80..c46a233f 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -3,13 +3,10 @@ import posixpath from typing import Any, Literal, Optional -from ._active import ActiveDict, ActiveFinderMethods, ActiveSequence -from ._types_content_item import ContentItemContext, ContentItemP -from ._typing_extensions import NotRequired, Required, TypedDict, Unpack from typing_extensions import NotRequired, Required, TypedDict, Unpack -from .context import Context -from .resources import Active, ActiveFinderMethods, ActiveSequence, Resource +from ._active import ActiveDict, ActiveFinderMethods, ActiveSequence +from ._types_content_item import ContentItemContext, ContentItemP JobTag = Literal[ "unknown", diff --git a/src/posit/connect/packages.py b/src/posit/connect/packages.py index 27e24475..80e84313 100644 --- a/src/posit/connect/packages.py +++ b/src/posit/connect/packages.py @@ -5,14 +5,14 @@ from typing_extensions import NotRequired, Required, Unpack -from posit.connect.context import requires -from posit.connect.errors import ClientError -from posit.connect.paginator import Paginator +from ._active import ActiveFinderMethods, ActiveSequence, ResourceDict +from ._types_content_item import ContentItemContext, ContentItemP +from .context import Context, requires +from .errors import ClientError +from .paginator import Paginator -from .resources import Active, ActiveFinderMethods, ActiveSequence - -class ContentPackage(Active): +class ContentPackage(ResourceDict): class _Package(TypedDict): language: Required[str] name: Required[str] @@ -20,14 +20,17 @@ class _Package(TypedDict): hash: Required[Optional[str]] def __init__(self, ctx, /, **attributes: Unpack[_Package]): - # todo - passing "" is a hack since path isn't needed. Instead, this class should inherit from Resource, but ActiveSequence is designed to operate on Active. That should change. - super().__init__(ctx, "", **attributes) + super().__init__(ctx, **attributes) -class ContentPackages(ActiveFinderMethods["ContentPackage"], ActiveSequence["ContentPackage"]): +class ContentPackages( + ActiveFinderMethods["ContentPackage", ContentItemContext], + ActiveSequence["ContentPackage", ContentItemContext], +): """A collection of packages.""" - def __init__(self, ctx, path): + def __init__(self, ctx: ContentItemContext): + path = posixpath.join(ctx.content_path, "packages") super().__init__(ctx, path, "name") def _create_instance(self, path, /, **attributes): # noqa: ARG002 @@ -88,17 +91,16 @@ def find_by(self, **conditions: Unpack[_FindBy]): # type: ignore return super().find_by(**conditions) -class ContentPackagesMixin(Active): +class ContentPackagesMixin: """Mixin class to add a packages attribute.""" @property @requires(version="2024.10.0-dev") - def packages(self): - path = posixpath.join(self._path, "packages") - return ContentPackages(self._ctx, path) + def packages(self: ContentItemP): + return ContentPackages(self._ctx) -class Package(Active): +class Package(ResourceDict): class _Package(TypedDict): language: Required[Literal["python", "r"]] """Programming language ecosystem, options are 'python' and 'r'""" @@ -125,12 +127,15 @@ class _Package(TypedDict): """The unique identifier of the application this package is associated with""" def __init__(self, ctx, /, **attributes: Unpack[_Package]): - # todo - passing "" is a hack since path isn't needed. Instead, this class should inherit from Resource, but ActiveSequence is designed to operate on Active. That should change. - super().__init__(ctx, "", **attributes) + super().__init__(ctx, **attributes) -class Packages(ActiveFinderMethods["Package"], ActiveSequence["Package"]): - def __init__(self, ctx, path): +class Packages( + ActiveFinderMethods["Package", Context], + ActiveSequence["Package", Context], +): + def __init__(self, ctx: Context): + path = "v1/packages" super().__init__(ctx, path, "name") def _create_instance(self, path, /, **attributes): # noqa: ARG002 diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 55eec138..e99c4926 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -16,16 +16,13 @@ overload, ) -from typing_extensions import Self - from ._types_content_item import ContentItemContext from .context import Context if TYPE_CHECKING: import requests + from typing_extensions import Self - from ._typing_extensions import Self - from .context import Context from .urls import Url diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index b60cab5f..c1c7b552 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -2,10 +2,9 @@ from typing import Callable, Optional, Protocol -from ._types_content_item import ContentItemActiveDict, ContentItemContext, ContentItemP -from ._typing_extensions import NotRequired, Required, TypedDict, Unpack from typing_extensions import NotRequired, Required, TypedDict, Unpack +from ._types_content_item import ContentItemActiveDict, ContentItemContext, ContentItemP from .errors import ClientError from .resources import Resources, resource_parameters_to_content_item_context From 137ba8b4cb956ed6b3590270479815027d591774 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 Nov 2024 13:11:28 -0500 Subject: [PATCH 20/47] Use `hasattr()` instead of `self.__dict__` --- src/posit/connect/content.py | 8 ++++---- src/posit/connect/jobs.py | 2 +- src/posit/connect/vanities.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index c7d5f203..4d3a8227 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -347,17 +347,17 @@ def environment_variables(self) -> EnvVars: def permissions(self) -> Permissions: return Permissions(context_to_resource_parameters(self._ctx), self["guid"]) - _owner: User - @property def owner(self) -> dict: - if "_owner" not in self.__dict__: + if not hasattr(self, "_owner"): # It is possible to get a content item that does not contain owner. # "owner" is an optional additional request param. # If it's not included, we can retrieve the information by `owner_guid` from .users import Users - self._owner = Users(context_to_resource_parameters(self._ctx)).get(self["owner_guid"]) + self._owner: User = Users(context_to_resource_parameters(self._ctx)).get( + self["owner_guid"] + ) return self._owner @property diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index c46a233f..bf4a2ccf 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -289,7 +289,7 @@ def jobs(self: ContentItemP) -> Jobs: """ # Do not cache result. `content.jobs` should always return the latest jobs. - # if self.__dict__.get("_jobs") is not None: + # if hasattr(self, "_jobs"): # # Early return # return self._jobs diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index c1c7b552..22967c14 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -157,7 +157,7 @@ class ContentItemVanityMixin: @property def vanity(self: ContentItemVanityP) -> str | None: """Get the vanity.""" - if "_vanity" in self.__dict__ and self._vanity: + if hasattr(self, "_vanity") and self._vanity: return self._vanity["path"] try: From f70ebaae37e16ce3e99c49de429d4dfb237506aa Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 Nov 2024 14:42:04 -0500 Subject: [PATCH 21/47] When updating within a ContentItem, use the fully returned result --- src/posit/connect/content.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 4d3a8227..2260002d 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -214,10 +214,10 @@ def render(self) -> Task: -------- >>> render() """ - self.update() # pyright: ignore[reportCallIssue] + full_content_item: ContentItem = self.update() # pyright: ignore[reportCallIssue] - if self.is_rendered: - variants = self._variants.find() + if full_content_item.is_rendered: + variants = full_content_item._variants.find() variants = [variant for variant in variants if variant["is_default"]] if len(variants) != 1: raise RuntimeError( @@ -243,16 +243,18 @@ def restart(self) -> None: -------- >>> restart() """ - self.update() # pyright: ignore[reportCallIssue] + full_content_item: ContentItem = self.update() # pyright: ignore[reportCallIssue] - if self.is_interactive: + if full_content_item.is_interactive: unix_epoch_in_seconds = str(int(time.time())) key = f"_CONNECT_RESTART_TMP_{unix_epoch_in_seconds}" - self.environment_variables.create(key, unix_epoch_in_seconds) - self.environment_variables.delete(key) + full_content_item.environment_variables.create(key, unix_epoch_in_seconds) + full_content_item.environment_variables.delete(key) # GET via the base Connect URL to force create a new worker thread. - url = posixpath.join(dirname(self._ctx.url), f"content/{self['guid']}") - self._ctx.session.get(url) + url = posixpath.join( + dirname(full_content_item._ctx.url), f"content/{full_content_item['guid']}" + ) + full_content_item._ctx.session.get(url) return None else: raise ValueError( From b7ea2fbe9f1c1db7523019298877a6e24bc739c2 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 Nov 2024 22:03:00 -0500 Subject: [PATCH 22/47] Make methods public within module --- src/posit/connect/_utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/posit/connect/_utils.py b/src/posit/connect/_utils.py index d3a2d1f3..bef6c8ef 100644 --- a/src/posit/connect/_utils.py +++ b/src/posit/connect/_utils.py @@ -7,11 +7,12 @@ def drop_none(x: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in x.items() if v is not None} -def _assert_guid(guid: str): +def assert_guid(guid: Any) -> str: assert isinstance(guid, str), "Expected 'guid' to be a string" assert len(guid) > 0, "Expected 'guid' to be non-empty" + return guid -def _assert_content_guid(content_guid: str): +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" From 778108fd3d98aa7e60bcec78d0513b575337c19f Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 Nov 2024 22:03:34 -0500 Subject: [PATCH 23/47] Update Variants --- src/posit/connect/variants.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/posit/connect/variants.py b/src/posit/connect/variants.py index 614121be..e6be01c4 100644 --- a/src/posit/connect/variants.py +++ b/src/posit/connect/variants.py @@ -1,11 +1,9 @@ from typing import List from ._active import ResourceDict +from ._types_content_item import ContentItemContext from .resources import ( - ResourceParameters, - Resources, context_to_resource_parameters, - resource_parameters_to_context, ) from .tasks import Task @@ -19,17 +17,15 @@ def render(self) -> Task: return Task(context_to_resource_parameters(self._ctx), **response.json()) -# TODO; Inherit from ActiveList -class Variants(Resources): - def __init__(self, params: ResourceParameters, content_guid: str) -> None: - super().__init__(params) - self.content_guid = content_guid +# No special inheritance as it is a placeholder class +class Variants: + def __init__(self, ctx: ContentItemContext) -> None: + super().__init__() + self._ctx = ctx def find(self) -> List[Variant]: - path = f"applications/{self.content_guid}/variants" - url = self.params.url + path - response = self.params.session.get(url) + path = f"applications/{self._ctx.content_guid}/variants" + url = self._ctx.url + path + response = self._ctx.session.get(url) results = response.json() or [] - return [ - Variant(resource_parameters_to_context(self.params), **result) for result in results - ] + return [Variant(self._ctx, **result) for result in results] From 47a7a48c0ae614e35ad9b6895f5c7453b6c5eb38 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 Nov 2024 22:04:07 -0500 Subject: [PATCH 24/47] Update ContentPackages and Packages --- src/posit/connect/packages.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/posit/connect/packages.py b/src/posit/connect/packages.py index 80e84313..64c156de 100644 --- a/src/posit/connect/packages.py +++ b/src/posit/connect/packages.py @@ -5,7 +5,7 @@ from typing_extensions import NotRequired, Required, Unpack -from ._active import ActiveFinderMethods, ActiveSequence, ResourceDict +from ._active import ActiveFinderSequence, ResourceDict from ._types_content_item import ContentItemContext, ContentItemP from .context import Context, requires from .errors import ClientError @@ -24,24 +24,26 @@ def __init__(self, ctx, /, **attributes: Unpack[_Package]): class ContentPackages( - ActiveFinderMethods["ContentPackage", ContentItemContext], - ActiveSequence["ContentPackage", ContentItemContext], + ActiveFinderSequence["ContentPackage", ContentItemContext], ): """A collection of packages.""" def __init__(self, ctx: ContentItemContext): path = posixpath.join(ctx.content_path, "packages") - super().__init__(ctx, path, "name") + super().__init__(ctx, path, uid="name") def _create_instance(self, path, /, **attributes): # noqa: ARG002 return ContentPackage(self._ctx, **attributes) - def fetch(self, **conditions): + def _get_data(self, **conditions): try: - return super().fetch(**conditions) + return super()._get_data(**conditions) except ClientError as e: if e.http_status == 204: - return [] + # Work around typing to return an empty generator + ret: list[ContentPackage] = [] + return (_ for _ in ret) + # (End workaround) raise e def find(self, uid): @@ -130,13 +132,10 @@ def __init__(self, ctx, /, **attributes: Unpack[_Package]): super().__init__(ctx, **attributes) -class Packages( - ActiveFinderMethods["Package", Context], - ActiveSequence["Package", Context], -): +class Packages(ActiveFinderSequence["Package", Context]): def __init__(self, ctx: Context): path = "v1/packages" - super().__init__(ctx, path, "name") + super().__init__(ctx, path, uid="name") def _create_instance(self, path, /, **attributes): # noqa: ARG002 return Package(self._ctx, **attributes) @@ -151,7 +150,10 @@ class _Fetch(TypedDict, total=False): version: Required[str] """The package version""" - def fetch(self, **conditions: Unpack[_Fetch]) -> Generator["Package"]: # type: ignore + def _get_data( + self, + **conditions, # : Unpack[_Fetch] + ) -> Generator["Package", None, None]: # todo - add pagination support to ActiveSequence url = self._ctx.url + self._path paginator = Paginator(self._ctx.session, url, dict(**conditions)) From 1206e0ff6774b92b3528ef36baabec62247cc357 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 Nov 2024 22:04:27 -0500 Subject: [PATCH 25/47] Update Sessions and Session --- src/posit/connect/oauth/sessions.py | 90 ++++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 13 deletions(-) diff --git a/src/posit/connect/oauth/sessions.py b/src/posit/connect/oauth/sessions.py index da216043..0d4272c5 100644 --- a/src/posit/connect/oauth/sessions.py +++ b/src/posit/connect/oauth/sessions.py @@ -2,19 +2,85 @@ from typing import List, Optional, overload -from ..resources import Resource, Resources +from typing_extensions import TypedDict, Unpack +from .._active import ActiveDict +from .._utils import assert_guid +from ..context import Context -class Session(Resource): + +class Session(ActiveDict): """OAuth session resource.""" + class _Attrs(TypedDict, total=False): + id: str + "The internal numeric identifier of this OAuth session." + guid: str + "The unique identifier of this OAuth session which is used in REST API requests." + user_guid: str + "The unique identifier of the user that is associated with this OAuth session." + oauth_integration_guid: str + "The unique identifier of the OAuth integration that is associated with this OAuth session." + has_refresh_token: bool + "Indicates whether this OAuth session has a refresh token." + created_time: str + "The timestamp (RFC3339) indicating when this OAuth session was created." + updated_time: str + "The timestamp (RFC3339) indicating when this OAuth session was last updated." + + @overload + def __init__(self, ctx: Context, /, *, guid: str) -> None: ... + + """ + Retrieve an OAuth session by its unique identifier. + + Parameters + ---------- + ctx : Context + The context object containing the session and URL for API interactions. + guid : str + The unique identifier of the OAuth session. + """ + + @overload + def __init__(self, ctx: Context, /, **kwargs: Unpack["Session._Attrs"]) -> None: ... + + """ + Retrieve an OAuth session by its unique identifier. + + Parameters + ---------- + ctx : Context + The context object containing the session and URL for API interactions. + **kwargs : Session._Attrs + Attributes for the OAuth session. If not supplied, the attributes will be retrieved from the API upon initialization. + """ + + def __init__(self, ctx: Context, /, **kwargs) -> None: + guid = assert_guid(kwargs.get("guid")) + path = self._api_path(guid) + + # Only fetch data if `kwargs` only contains `"guid"` + get_data = len(kwargs) == 1 + + super().__init__(ctx, path, get_data, **kwargs) + + # TODO-barret-q: Should this be destroy? def delete(self) -> None: - path = f"v1/oauth/sessions/{self['guid']}" - url = self.params.url + path - self.params.session.delete(url) + """Destroy the OAuth session.""" + self._delete_api() + + @classmethod + def _api_path(cls, session_guid: str) -> str: + return f"v1/oauth/sessions/{session_guid}" + +class Sessions: + def __init__(self, ctx: Context) -> None: + super().__init__() + self._ctx = ctx -class Sessions(Resources): + # TODO-barret-q: Should this be `.all()`? @overload def find( self, @@ -22,14 +88,15 @@ def find( all: Optional[bool] = ..., ) -> List[Session]: ... + # TODO-barret-q: Should this be `.find_by()`? @overload def find(self, **kwargs) -> List[Session]: ... def find(self, **kwargs) -> List[Session]: - url = self.params.url + "v1/oauth/sessions" - response = self.params.session.get(url, params=kwargs) + url = self._ctx.url + "v1/oauth/sessions" + response = self._ctx.session.get(url, params=kwargs) results = response.json() - return [Session(self.params, **result) for result in results] + return [Session(self._ctx, **result) for result in results] def get(self, guid: str) -> Session: """Get an OAuth session. @@ -42,7 +109,4 @@ def get(self, guid: str) -> Session: ------- Session """ - path = f"v1/oauth/sessions/{guid}" - url = self.params.url + path - response = self.params.session.get(url) - return Session(self.params, **response.json()) + return Session(self._ctx, guid=guid) From e754475320792a6f1cafc2e97cb19d94144717d4 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 Nov 2024 22:05:52 -0500 Subject: [PATCH 26/47] ActiveFinderSequence now inherits from ActiveSequence --- src/posit/connect/_active.py | 496 +++++++++++++++++++++-------------- src/posit/connect/jobs.py | 6 +- 2 files changed, 307 insertions(+), 195 deletions(-) diff --git a/src/posit/connect/_active.py b/src/posit/connect/_active.py index c243b713..586da005 100644 --- a/src/posit/connect/_active.py +++ b/src/posit/connect/_active.py @@ -3,33 +3,25 @@ from __future__ import annotations -import itertools import posixpath from abc import ABC, abstractmethod -from collections.abc import Mapping +from collections.abc import Mapping as Mapping_abc +from collections.abc import Sequence as Sequence_abc from typing import ( - TYPE_CHECKING, Any, Generator, - Generic, - List, + Iterator, Optional, - Sequence, + Tuple, TypeVar, cast, overload, ) -from typing_extensions import Self - from ._api_call import ApiCallMixin, ContextP, get_api -from ._json import Jsonifiable, JsonifiableDict, ResponseAttrs +from ._json import Jsonifiable, JsonifiableDict, JsonifiableList, ResponseAttrs from ._types_context import ContextT -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 @@ -51,10 +43,16 @@ # * Init signature should be `def __init__(self, ctx: Context, path: str, /, **attrs: Jsonifiable) -> None:` +ReadOnlyDictT = TypeVar("ReadOnlyDictT", bound="ReadOnlyDict") +"""A type variable that is bound to the `Active` class""" +ResourceDictT = TypeVar("ResourceDictT", bound="ResourceDict") +"""A type variable that is bound to the `ResourceDict` class""" + + # 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): +class ReadOnlyDict(Mapping_abc): _dict: ResponseAttrs """Read only dictionary.""" @@ -128,8 +126,6 @@ def __init__( ---------- ctx : Context The context object containing the session and URL for API interactions. - path : str - The HTTP path component for the resource endpoint **kwargs : Any Values to be stored """ @@ -190,100 +186,39 @@ def __init__( self._path = path -T = TypeVar("T", bound="ResourceDict") -"""A type variable that is bound to the `Active` class""" - +class ReadOnlySequence(Sequence_abc[ResourceDictT]): + """Read only Sequence.""" -class ActiveSequence(ABC, Generic[T, ContextT], Sequence[T]): - """A sequence for any HTTP GET endpoint that returns a collection.""" + _data: Tuple[ResourceDictT, ...] - _cache: Optional[List[T]] + def _set_data(self, data: Tuple[ResourceDictT, ...]) -> None: + self._data = data - def __init__(self, ctx: ContextT, path: str, uid: str = "guid"): - """A sequence abstraction for any HTTP GET endpoint that returns a collection. + def __init__(self, *args: ResourceDictT) -> None: + """ + A read-only sequence abstraction. Parameters ---------- - ctx : ContextT - The context object containing the session and URL for API interactions. - path : str - The HTTP path component for the collection endpoint - uid : str, optional - The field name of that uniquely identifiers an instance of T, by default "guid" + *args : Any + Values to be stored """ super().__init__() - self._ctx = ctx - self._path = path - self._uid = uid - self._cache = None - - @abstractmethod - def _create_instance(self, path: str, /, **kwargs: Any) -> T: - """Create an instance of 'T'.""" - raise NotImplementedError() - - def fetch(self) -> List[T]: - """Fetch the collection. - - Fetches the collection directly from Connect. This operation does not effect the cache state. - - Returns - ------- - List[T] - """ - endpoint = self._ctx.url + self._path - response = self._ctx.session.get(endpoint) - results = response.json() - return [self._to_instance(result) for result in results] - - def reload(self) -> Self: - """Reloads the collection from Connect. - - Returns - ------- - Self - """ - self._cache = None - return self - - def _to_instance(self, result: dict) -> T: - """Converts a result into an instance of T.""" - uid = result[self._uid] - path = posixpath.join(self._path, uid) - return self._create_instance(path, **result) + self._data = args - @property - def _data(self) -> List[T]: - """Get the collection. - - Fetches the collection from Connect and caches the result. Subsequent invocations return the cached results unless the cache is explicitly reset. - - Returns - ------- - List[T] - - See Also - -------- - cached - reload - """ - if self._cache is None: - self._cache = self.fetch() - return self._cache + def __len__(self) -> int: + return len(self._data) @overload - def __getitem__(self, index: int) -> T: ... + def __getitem__(self, index: int) -> ResourceDictT: ... @overload - def __getitem__(self, index: slice) -> Sequence[T]: ... + def __getitem__(self, index: slice) -> Tuple[ResourceDictT, ...]: ... - def __getitem__(self, index): + def __getitem__(self, index: int | slice) -> ResourceDictT | Tuple[ResourceDictT, ...]: return self._data[index] - def __len__(self) -> int: - return len(self._data) - - def __iter__(self): + def __iter__(self) -> Iterator[ResourceDictT]: return iter(self._data) def __str__(self) -> str: @@ -292,133 +227,177 @@ def __str__(self) -> str: def __repr__(self) -> str: return repr(self._data) + def __contains__(self, key: object) -> bool: + return key in self._data -class ActiveFinderMethods(ActiveSequence[T, ContextT]): - """Finder methods. + def __eq__(self, other: object) -> bool: + if not isinstance(other, ReadOnlySequence): + return NotImplemented + return self._data == other._data - Provides various finder methods for locating records in any endpoint supporting HTTP GET requests. - """ + def __ne__(self, other: object) -> bool: + if not isinstance(other, ReadOnlySequence): + return NotImplemented + return self._data != other._data - def find(self, uid) -> T: - """ - Find a record by its unique identifier. + # def count(self, value: object) -> int: + # return self._data.count(value) - Fetches the record from Connect by it's identifier. + # def index(self, value: object, start: int = 0, stop: int = 9223372036854775807) -> int: + # return self._data.index(value, start, stop) - Parameters - ---------- - uid : Any - The unique identifier of the record. + def __setitem__(self, key: int, value: Any) -> None: + raise NotImplementedError( + "Values are locked. " + "To retrieve updated values, please retrieve the parent object again." + ) - Returns - ------- - T - """ - endpoint = self._ctx.url + self._path + uid - response = self._ctx.session.get(endpoint) - result = response.json() - return self._to_instance(result) + def __delitem__(self, key: int) -> None: + raise NotImplementedError( + "Values are locked. " + "To retrieve updated values, please retrieve the parent object again." + ) - def find_by(self, **conditions) -> T | None: - """ - Find the first record matching the specified conditions. - There is no implied ordering, so if order matters, you should specify it yourself. +class ResourceSequence(ReadOnlySequence[ResourceDictT], ContextP[ContextT]): + """An abstraction to contain the context and read-only tuple-like information.""" - Parameters - ---------- - **conditions : Any + _ctx: ContextT + """The context object containing the session and URL for API interactions.""" - Returns - ------- - Optional[T] - The first record matching the conditions, or `None` if no match is found. + def __init__( + self, + ctx: ContextT, + /, + *, + arr: list[ResourceDictT] | tuple[ResourceDictT, ...], + ) -> None: """ - collection = self.fetch() - return next((v for v in collection if v.items() >= conditions.items()), None) + A read-only sequence abstraction that is Context aware. - -ReadOnlyDictT = TypeVar("ReadOnlyDictT", bound="ReadOnlyDict") -"""A type variable that is bound to the `Active` class""" + Parameters + ---------- + ctx : Context + The context object containing the session and URL for API interactions. + *args : Any + Values to be stored + """ + super().__init__(*tuple(arr)) + self._ctx = ctx -class ApiListEndpoint(ApiCallMixin, Generic[ReadOnlyDictT], ABC, object): - """A HTTP GET endpoint that can fetch a collection.""" +class ActiveSequence(ApiCallMixin, ABC, ResourceSequence[ResourceDictT, ContextT]): + """A read only sequence for any HTTP GET endpoint that returns a collection.""" - def __init__(self, *, ctx: Context, path: str, uid_key: str = "guid") -> None: - """A sequence abstraction for any HTTP GET endpoint that returns a collection. + def __init__( + self, + ctx: ContextT, + path: str, + /, + *, + uid: str = "guid", + # Named parameter to allow for future param expansion + arr: Optional[tuple[ResourceDictT, ...]] = None, + get_data: Optional[bool] = None, + ): + """ + A sequence abstraction for any HTTP GET endpoint that returns a collection. Parameters ---------- - ctx : Context + ctx : ContextT The context object containing the session and URL for API interactions. path : str The HTTP path component for the collection endpoint - uid_key : str, optional + uid : str, optional The field name of that uniquely identifiers an instance of T, by default "guid" + arr : tuple[ResourceDictT, ...], optional + Values to be stored. If no values are given, they are retrieved from the API. + get_data : Optional[bool] + If `True`, fetch the API and set the `arr` from the response. If `False`, do not fetch + any data for `arr`. If `None` [default], fetch data from the API if `arr` is `None`. + """ - super().__init__() - self._ctx = ctx + if get_data is None: + get_data = arr is None + arr = () + + assert arr is not None + + super().__init__(ctx, arr=arr) self._path = path - self._uid_key = uid_key + self._uid = uid + + # TODO-barret-future; Figure out how to better handle this. I'd like to call + # self._get_data() here, but it hasn't been initialized yet. + if get_data: + self._set_data(tuple(self._get_data())) @abstractmethod - def _create_instance(self, path: str, /, **kwargs: Any) -> ReadOnlyDictT: - """Create an instance of 'ReadOnlyDictT'.""" + def _create_instance(self, path: str, /, **kwargs: Any) -> ResourceDictT: + """Create an instance of 'T'.""" raise NotImplementedError() - def fetch(self) -> Generator[ReadOnlyDictT, None, None]: + def _to_instance(self, result: dict) -> ResourceDictT: + """Converts a result into an instance of T.""" + uid = result[self._uid] + path = posixpath.join(self._path, uid) + return self._create_instance(path, **result) + + def _get_data(self) -> Generator[ResourceDictT, None, None]: """Fetch the collection. - Fetches the collection directly from Connect. This operation does not effect the cache state. + Fetches the collection directly from Connect. Returns ------- - List[ReadOnlyDictT] + List[T] """ results: Jsonifiable = self._get_api() - results_list = cast(list[JsonifiableDict], results) - for result in results_list: - yield self._to_instance(result) + results_list = cast(JsonifiableList, results) + return (self._to_instance(result) for result in results_list) - def __iter__(self) -> Generator[ReadOnlyDictT, None, None]: - return self.fetch() + # @overload + # def __getitem__(self, index: int) -> T: ... - def _to_instance(self, result: dict) -> ReadOnlyDictT: - """Converts a result into an instance of ReadOnlyDictT.""" - uid = result[self._uid_key] - path = posixpath.join(self._path, uid) - return self._create_instance(path, **result) + # @overload + # def __getitem__(self, index: slice) -> tuple[T, ...]: ... - @overload - def __getitem__(self, index: int) -> ReadOnlyDictT: ... - - @overload - def __getitem__(self, index: slice) -> Generator[ReadOnlyDictT, None, None]: ... - - def __getitem__( - self, index: int | slice - ) -> ReadOnlyDictT | Generator[ReadOnlyDictT, None, None]: - if isinstance(index, slice): - results = itertools.islice(self.fetch(), index.start, index.stop, index.step) - for result in results: - yield result - else: - return list(itertools.islice(self.fetch(), index, index + 1))[0] + # def __getitem__(self, index): + # return self[index] # def __len__(self) -> int: - # return len(self.fetch()) + # return len(self._data) - def __str__(self) -> str: - return self.__repr__() + # def __iter__(self): + # return iter(self._data) + + # def __str__(self) -> str: + # return str(self._data) + + # def __repr__(self) -> str: + # return repr(self._data) - def __repr__(self) -> str: - # Jobs - 123 items - return repr( - f"{self.__class__.__name__} - { len(list(self.fetch())) } items - {self._path}" - ) - def find(self, uid: str) -> ReadOnlyDictT | None: +# class ActiveSequenceP( # pyright: ignore[reportInvalidTypeVarUse] +# Generic[ResourceDictT, ContextT], +# Protocol, +# ): +# _ctx: ContextT +# _path: str + +# def _get_api(self, *path) -> Jsonifiable | None: ... +# def _to_instance(self, result: dict) -> ResourceDictT: ... +# def _get_data(self, **conditions: object) -> Generator[ResourceDictT, None, None]: ... + + +class ActiveFinderSequence(ActiveSequence[ResourceDictT, ContextT]): + """Finder methods. + + Provides various finder methods for locating records in any endpoint supporting HTTP GET requests. + """ + + def find(self, uid: str) -> ResourceDictT: """ Find a record by its unique identifier. @@ -426,20 +405,23 @@ def find(self, uid: str) -> ReadOnlyDictT | None: Parameters ---------- - uid : str + uid : Any The unique identifier of the record. Returns ------- - : - Single instance of T if found, else None + T """ - result: Jsonifiable = self._get_api(uid) - result_obj = cast(JsonifiableDict, result) - - return self._to_instance(result_obj) + self._get_api(uid) + endpoint = self._ctx.url + self._path + uid + response = self._ctx.session.get(endpoint) + result = response.json() + return self._to_instance(result) - def find_by(self, **conditions: Any) -> ReadOnlyDictT | None: + def find_by( + self, + **conditions: object, + ) -> ResourceDictT | None: """ Find the first record matching the specified conditions. @@ -451,23 +433,153 @@ def find_by(self, **conditions: Any) -> ReadOnlyDictT | None: Returns ------- - ReadOnlyDictT + Optional[T] The first record matching the conditions, or `None` if no match is found. """ - results = self.fetch() - + collection = self._get_data() conditions_items = conditions.items() # Get the first item of the generator that matches the conditions - # If no item is found, return None return next( ( # Return result result - # Iterate through `results` generator - for result in results + # Iterate through `collection` list + for result in collection # If all `conditions`'s key/values are found in `result`'s key/values... if result.items() >= conditions_items ), + # If no item is found, return None None, ) + + +# class ApiListEndpoint(ApiCallMixin, Generic[ReadOnlyDictT], ABC, object): +# """A HTTP GET endpoint that can fetch a collection.""" + +# def __init__(self, *, ctx: Context, path: str, uid_key: str = "guid") -> None: +# """A sequence abstraction for any HTTP GET endpoint that returns a collection. + +# Parameters +# ---------- +# ctx : Context +# The context object containing the session and URL for API interactions. +# path : str +# The HTTP path component for the collection endpoint +# uid_key : str, optional +# The field name of that uniquely identifiers an instance of T, by default "guid" +# """ +# super().__init__() +# self._ctx = ctx +# self._path = path +# self._uid_key = uid_key + +# @abstractmethod +# def _create_instance(self, path: str, /, **kwargs: Any) -> ReadOnlyDictT: +# """Create an instance of 'ReadOnlyDictT'.""" +# raise NotImplementedError() + +# def fetch(self) -> Generator[ReadOnlyDictT, None, None]: +# """Fetch the collection. + +# Fetches the collection directly from Connect. This operation does not effect the cache state. + +# Returns +# ------- +# List[ReadOnlyDictT] +# """ +# results: Jsonifiable = self._get_api() +# results_list = cast(list[JsonifiableDict], results) +# for result in results_list: +# yield self._to_instance(result) + +# def __iter__(self) -> Generator[ReadOnlyDictT, None, None]: +# return self.fetch() + +# def _to_instance(self, result: dict) -> ReadOnlyDictT: +# """Converts a result into an instance of ReadOnlyDictT.""" +# uid = result[self._uid_key] +# path = posixpath.join(self._path, uid) +# return self._create_instance(path, **result) + +# @overload +# def __getitem__(self, index: int) -> ReadOnlyDictT: ... + +# @overload +# def __getitem__(self, index: slice) -> Generator[ReadOnlyDictT, None, None]: ... + +# def __getitem__( +# self, index: int | slice +# ) -> ReadOnlyDictT | Generator[ReadOnlyDictT, None, None]: +# if isinstance(index, slice): +# results = itertools.islice(self.fetch(), index.start, index.stop, index.step) +# for result in results: +# yield result +# else: +# return list(itertools.islice(self.fetch(), index, index + 1))[0] + +# # def __len__(self) -> int: +# # return len(self.fetch()) + +# def __str__(self) -> str: +# return self.__repr__() + +# def __repr__(self) -> str: +# # Jobs - 123 items +# return repr( +# f"{self.__class__.__name__} - { len(list(self.fetch())) } items - {self._path}" +# ) + +# def find(self, uid: str) -> ReadOnlyDictT | None: +# """ +# Find a record by its unique identifier. + +# Fetches the record from Connect by it's identifier. + +# Parameters +# ---------- +# uid : str +# The unique identifier of the record. + +# Returns +# ------- +# : +# Single instance of T if found, else None +# """ +# result: Jsonifiable = self._get_api(uid) +# result_obj = cast(JsonifiableDict, result) + +# return self._to_instance(result_obj) + +# def find_by(self, **conditions: Any) -> ReadOnlyDictT | None: +# """ +# Find the first record matching the specified conditions. + +# There is no implied ordering, so if order matters, you should specify it yourself. + +# Parameters +# ---------- +# **conditions : Any + +# Returns +# ------- +# ReadOnlyDictT +# The first record matching the conditions, or `None` if no match is found. +# """ +# results = self.fetch() + +# conditions_items = conditions.items() + +# # Get the first item of the generator that matches the conditions +# # If no item is found, return None +# return next( +# ( +# # Return result +# result +# # Iterate through `results` generator +# for result in results +# # If all `conditions`'s key/values are found in `result`'s key/values... +# if result.items() >= conditions_items +# ), +# None, +# ) diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index bf4a2ccf..10e657a2 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -5,7 +5,7 @@ from typing_extensions import NotRequired, Required, TypedDict, Unpack -from ._active import ActiveDict, ActiveFinderMethods, ActiveSequence +from ._active import ActiveDict, ActiveFinderSequence from ._types_content_item import ContentItemContext, ContentItemP JobTag = Literal[ @@ -122,7 +122,7 @@ def destroy(self) -> None: self._delete_api() -class Jobs(ActiveFinderMethods[Job, ContentItemContext], ActiveSequence[Job, ContentItemContext]): +class Jobs(ActiveFinderSequence[Job, ContentItemContext]): def __init__(self, ctx: ContentItemContext) -> None: """A collection of jobs. @@ -132,7 +132,7 @@ def __init__(self, ctx: ContentItemContext) -> None: The context object containing the session and URL for API interactions """ path = posixpath.join(ctx.content_path, "jobs") - super().__init__(ctx, path, "key") + super().__init__(ctx, path, uid="key") def _create_instance(self, path: str, /, **attributes: Any) -> Job: """Creates a Job instance. From f40cfe498a58136f6fda73e43d8f51375ec722bc Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 Nov 2024 22:06:15 -0500 Subject: [PATCH 27/47] Minor updates and fix tests --- src/posit/connect/_content_repository.py | 1 + src/posit/connect/_types_content_item.py | 2 ++ src/posit/connect/content.py | 6 +++--- src/posit/connect/oauth/oauth.py | 4 ++-- src/posit/connect/users.py | 1 + .../__api__/v1/packages-pagination-1-500.json | 11 +++++++++++ tests/posit/connect/test_jobs.py | 12 ++++++++++++ tests/posit/connect/test_packages.py | 11 +++++++++++ 8 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 tests/posit/connect/__api__/v1/packages-pagination-1-500.json diff --git a/src/posit/connect/_content_repository.py b/src/posit/connect/_content_repository.py index f8907ece..5bc776b6 100644 --- a/src/posit/connect/_content_repository.py +++ b/src/posit/connect/_content_repository.py @@ -66,6 +66,7 @@ def _create( cls, ctx: Context, content_guid: str, + /, **attrs: Unpack[ContentItemRepository._Attrs], ) -> ContentItemRepository: from ._api_call import put_api diff --git a/src/posit/connect/_types_content_item.py b/src/posit/connect/_types_content_item.py index 0a800463..a895d7aa 100644 --- a/src/posit/connect/_types_content_item.py +++ b/src/posit/connect/_types_content_item.py @@ -20,6 +20,8 @@ class ContentItemActiveDict(ActiveDict["ContentItemContext"], ContentItemP): class ContentItemContext(Context): + """Context object for a ContentItem resource.""" + content_guid: str """The GUID of the content item""" content_path: str diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 2260002d..0f0ada1b 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -21,7 +21,7 @@ from ._content_repository import ContentItemRepository from ._json import JsonifiableDict from ._types_content_item import ContentItemActiveDict, ContentItemContext, ContentItemResourceDict -from ._utils import _assert_guid +from ._utils import assert_guid from .bundles import Bundles from .context import Context from .env import EnvVars @@ -124,7 +124,7 @@ def __init__( guid: str, **kwargs: Unpack[ContentItem._AttrsNotRequired], ) -> None: - _assert_guid(guid) + assert_guid(guid) ctx = ContentItemContext(Context(params.session, params.url), content_guid=guid) path = f"v1/content/{guid}" @@ -364,7 +364,7 @@ def owner(self) -> dict: @property def _variants(self) -> Variants: - return Variants(context_to_resource_parameters(self._ctx), self["guid"]) + return Variants(self._ctx) @property def is_interactive(self) -> bool: diff --git a/src/posit/connect/oauth/oauth.py b/src/posit/connect/oauth/oauth.py index 306170b8..78524ab8 100644 --- a/src/posit/connect/oauth/oauth.py +++ b/src/posit/connect/oauth/oauth.py @@ -4,7 +4,7 @@ from typing_extensions import TypedDict -from ..resources import ResourceParameters, Resources +from ..resources import ResourceParameters, Resources, resource_parameters_to_context from .integrations import Integrations from .sessions import Sessions @@ -20,7 +20,7 @@ def integrations(self): @property def sessions(self): - return Sessions(self.params) + return Sessions(resource_parameters_to_context(self.params)) def get_credentials(self, user_session_token: Optional[str] = None) -> Credentials: url = self.params.url + "v1/oauth/integrations/credentials" diff --git a/src/posit/connect/users.py b/src/posit/connect/users.py index 5f2d4610..80a5cab5 100644 --- a/src/posit/connect/users.py +++ b/src/posit/connect/users.py @@ -26,6 +26,7 @@ # def _create( # cls, # ctx: Context, +# /, # **attrs: Unpack[ContentItemRepository._Attrs], # ) -> User: # from ._api_call import put_api diff --git a/tests/posit/connect/__api__/v1/packages-pagination-1-500.json b/tests/posit/connect/__api__/v1/packages-pagination-1-500.json new file mode 100644 index 00000000..23fe73e2 --- /dev/null +++ b/tests/posit/connect/__api__/v1/packages-pagination-1-500.json @@ -0,0 +1,11 @@ +{ + "results": [ + { + "language": "python", + "name": "posit", + "version": "0.6.0" + } + ], + "current_page": 1, + "total": 1 +} diff --git a/tests/posit/connect/test_jobs.py b/tests/posit/connect/test_jobs.py index d97df373..8e26c98d 100644 --- a/tests/posit/connect/test_jobs.py +++ b/tests/posit/connect/test_jobs.py @@ -40,6 +40,10 @@ def test(self): "v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx.json", ), ) + responses.get( + "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs", + json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs.json"), + ) c = Client("https://connect.example", "12345") content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") @@ -58,6 +62,10 @@ def test_miss(self): "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/not-found", status=404, ) + responses.get( + "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs", + json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs.json"), + ) c = Client("https://connect.example", "12345") content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") @@ -105,6 +113,10 @@ def test(self): responses.delete( "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/tHawGvHZTosJA2Dx", ) + responses.get( + "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs", + json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs.json"), + ) c = Client("https://connect.example", "12345") content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") diff --git a/tests/posit/connect/test_packages.py b/tests/posit/connect/test_packages.py index 4d42f535..bf930665 100644 --- a/tests/posit/connect/test_packages.py +++ b/tests/posit/connect/test_packages.py @@ -29,6 +29,12 @@ def test(self): class TestPackagesFind: @responses.activate def test(self): + # c.packages + responses.get( + "https://connect.example/__api__/v1/packages?page_number=1&page_size=500", + json=load_mock("v1/packages-pagination-1-500.json"), + ) + c = Client("https://connect.example", "12345") c._ctx.version = None @@ -39,6 +45,11 @@ def test(self): class TestPackagesFindBy: @responses.activate def test(self): + # c.packages + responses.get( + "https://connect.example/__api__/v1/packages?page_number=1&page_size=500", + json=load_mock("v1/packages-pagination-1-500.json"), + ) mock_get = responses.get( "https://connect.example/__api__/v1/packages", json=load_mock("v1/packages.json"), From 474af27e19d82c0137cf7f0e1c4b8415af67f75b Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 Nov 2024 22:09:28 -0500 Subject: [PATCH 28/47] Fix len bug --- src/posit/connect/_active.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/posit/connect/_active.py b/src/posit/connect/_active.py index 586da005..9bd5b942 100644 --- a/src/posit/connect/_active.py +++ b/src/posit/connect/_active.py @@ -207,7 +207,7 @@ def __init__(self, *args: ResourceDictT) -> None: self._data = args def __len__(self) -> int: - return len(self._data) + return len(tuple(self._data)) @overload def __getitem__(self, index: int) -> ResourceDictT: ... From 17c0270465ef82c9b7439a2a04f20ae459f3444b Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 13 Nov 2024 23:56:14 -0500 Subject: [PATCH 29/47] Update Tasks and Task --- src/posit/connect/_active.py | 13 +- src/posit/connect/_api_call.py | 6 +- src/posit/connect/bundles.py | 6 +- src/posit/connect/client.py | 2 +- src/posit/connect/content.py | 3 +- src/posit/connect/tasks.py | 154 ++++++++++++++---- src/posit/connect/variants.py | 5 +- .../v1/tasks/jXhOhdm5OOSkGhJw-finished.json | 9 + .../v1/tasks/jXhOhdm5OOSkGhJw-unfinished.json | 9 + .../__api__/v1/tasks/jXhOhdm5OOSkGhJw.json | 12 -- tests/posit/connect/test_bundles.py | 2 +- tests/posit/connect/test_content.py | 2 +- tests/posit/connect/test_tasks.py | 32 ++-- 13 files changed, 182 insertions(+), 73 deletions(-) create mode 100644 tests/posit/connect/__api__/v1/tasks/jXhOhdm5OOSkGhJw-finished.json create mode 100644 tests/posit/connect/__api__/v1/tasks/jXhOhdm5OOSkGhJw-unfinished.json delete mode 100644 tests/posit/connect/__api__/v1/tasks/jXhOhdm5OOSkGhJw.json diff --git a/src/posit/connect/_active.py b/src/posit/connect/_active.py index 9bd5b942..c2ed414f 100644 --- a/src/posit/connect/_active.py +++ b/src/posit/connect/_active.py @@ -141,8 +141,16 @@ class ActiveDict(ApiCallMixin, ResourceDict[ContextT]): _path: str """The HTTP path component for the resource endpoint.""" - def _get_api(self, *path) -> JsonifiableDict | None: - super()._get_api(*path) + def _get_api( + self, + *path, + params: Optional[dict[str, object]] = None, + ) -> JsonifiableDict | None: + result: Jsonifiable = super()._get_api(*path, params=params) + if result is None: + return None + assert isinstance(result, dict), f"Expected dict from server, got {type(result)}" + return result def __init__( self, @@ -344,6 +352,7 @@ def _to_instance(self, result: dict) -> ResourceDictT: path = posixpath.join(self._path, uid) return self._create_instance(path, **result) + # TODO-barret-q: Include params to `._get_api()`? def _get_data(self) -> Generator[ResourceDictT, None, None]: """Fetch the collection. diff --git a/src/posit/connect/_api_call.py b/src/posit/connect/_api_call.py index 03a65dd3..4d7b1869 100644 --- a/src/posit/connect/_api_call.py +++ b/src/posit/connect/_api_call.py @@ -1,7 +1,7 @@ from __future__ import annotations import posixpath -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Optional, Protocol from ._types_context import ContextP @@ -46,8 +46,8 @@ class ApiCallMixin: def _endpoint(self: ApiCallProtocol, *path) -> str: return endpoint(self._ctx, self._path, *path) - def _get_api(self: ApiCallProtocol, *path) -> Jsonifiable: - response = self._ctx.session.get(self._endpoint(*path)) + def _get_api(self: ApiCallProtocol, *path, params: Optional[dict] = None) -> Jsonifiable: + response = self._ctx.session.get(self._endpoint(*path), params=params) return response.json() def _delete_api(self: ApiCallProtocol, *path) -> Jsonifiable | None: diff --git a/src/posit/connect/bundles.py b/src/posit/connect/bundles.py index 09407a46..a9e1f37e 100644 --- a/src/posit/connect/bundles.py +++ b/src/posit/connect/bundles.py @@ -7,6 +7,7 @@ from . import resources, tasks from ._active import ReadOnlyDict +from .resources import resource_parameters_to_content_item_context class BundleMetadata(ReadOnlyDict): @@ -39,13 +40,14 @@ def deploy(self) -> tasks.Task: -------- >>> task = bundle.deploy() >>> task.wait_for() - None """ path = f"v1/content/{self['content_guid']}/deploy" url = self.params.url + path response = self.params.session.post(url, json={"bundle_id": self["id"]}) result = response.json() - ts = tasks.Tasks(self.params) + ts = tasks.Tasks( + resource_parameters_to_content_item_context(self.params, self["content_guid"]) + ) return ts.get(result["task_id"]) def download(self, output: io.BufferedWriter | str) -> None: diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index 3756d7e5..28a3c18c 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -203,7 +203,7 @@ def tasks(self) -> Tasks: tasks.Tasks The tasks resource instance. """ - return Tasks(self.resource_params) + return Tasks(self._ctx) @property def users(self) -> Users: diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 0f0ada1b..dcb5a74f 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -192,13 +192,12 @@ def deploy(self) -> tasks.Task: -------- >>> task = content.deploy() >>> task.wait_for() - None """ path = f"v1/content/{self['guid']}/deploy" url = self._ctx.url + path response = self._ctx.session.post(url, json={"bundle_id": None}) result = response.json() - ts = tasks.Tasks(context_to_resource_parameters(self._ctx)) + ts = tasks.Tasks(self._ctx) return ts.get(result["task_id"]) def render(self) -> Task: diff --git a/src/posit/connect/tasks.py b/src/posit/connect/tasks.py index 0319b05a..7cf5eb33 100644 --- a/src/posit/connect/tasks.py +++ b/src/posit/connect/tasks.py @@ -4,10 +4,88 @@ from typing import overload -from . import resources +from typing_extensions import TypedDict, Unpack + +from ._active import ActiveDict +from ._types_context import ContextP +from .context import Context + + +class TaskContext(Context): + task_id: str + + def __init__(self, ctx: Context, *, task_id: str) -> None: + super().__init__(ctx.session, ctx.url) + self.task_id = task_id + + +class Task(ActiveDict[TaskContext]): + @classmethod + def _api_path(cls, task_uid: str) -> str: + return f"v1/tasks/{task_uid}" + + class _AttrResult(TypedDict): + type: str + data: object + + class _Attrs(TypedDict, total=False): + id: str + """The identifier for this task.""" + output: list[str] + """An array containing lines of output produced by the task.""" + finished: bool + """Indicates that a task has completed.""" + code: int + """Numeric indication as to the cause of an error. Non-zero when an error has occured.""" + error: str + """Description of the error. An empty string when no error has occurred.""" + last: int + """ + The total number of output lines produced so far. Use as the value + to `first` in the next request to only fetch output lines beyond + what you have already received. + """ + result: "Task._AttrResult" + """A value representing the result of the operation, if any. For deployment tasks, this + value is `null`.""" + + @overload + def __init__(self, ctx: Context, /, *, id: str) -> None: + """Task resource. + + Since the task attributes are not supplied, the attributes will be retrieved from the API upon initialization. + + Parameters + ---------- + ctx : Context + The context object containing the session and URL for API interactions. + id : str + The identifier for this task. + """ + + @overload + def __init__(self, ctx: Context, /, **kwargs: Unpack[_Attrs]) -> None: + """Task resource. + + Parameters + ---------- + ctx : Context + The context object containing the session and URL for API interactions. + **kwargs : Task._Attrs + Attributes for the task. If not supplied, the attributes will be retrieved from the API upon initialization. + """ + def __init__(self, ctx: Context, /, **kwargs: Unpack[_Attrs]) -> None: + task_id = kwargs.get("id") + assert isinstance(task_id, str), "Task `id` must be a string." + assert len(task_id) > 0, "Task `id` must not be empty." + + task_ctx = TaskContext(ctx, task_id=task_id) + path = self._api_path(task_id) + get_data = len(kwargs) == 1 + print("task: ", task_ctx, path, get_data, kwargs) + super().__init__(task_ctx, path, get_data, **kwargs) -class Task(resources.Resource): @property def is_finished(self) -> bool: """The task state. @@ -50,7 +128,12 @@ def error_message(self) -> str | None: # CRUD Methods @overload - def update(self, *args, first: int, wait: int, **kwargs) -> None: + def update(self, /, *, first: int, wait: int, **kwargs) -> Task: ... + + @overload + def update(self, /, **kwargs) -> Task: ... + + def update(self, /, **kwargs) -> Task: """Update the task. Parameters @@ -59,14 +142,8 @@ def update(self, *args, first: int, wait: int, **kwargs) -> None: Line to start output on. wait : int, default 0 Maximum number of seconds to wait for the task to complete. - """ - - @overload - def update(self, *args, **kwargs) -> None: - """Update the task.""" - - def update(self, *args, **kwargs) -> None: - """Update the task. + **kwargs + Additional query parameters to pass to the API. See Also -------- @@ -82,33 +159,47 @@ def update(self, *args, **kwargs) -> None: [ "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." ] - >>> task.update() - >>> task.output + >>> updated_task = task.update() + >>> updated_task.output [ "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", "Pretium aenean pharetra magna ac placerat vestibulum lectus mauris." ] """ - params = dict(*args, **kwargs) - path = f"v1/tasks/{self['id']}" - url = self.params.url + path - response = self.params.session.get(url, params=kwargs) - result = response.json() - super().update(**result) - - def wait_for(self) -> None: + result = self._get_api(params=kwargs) + print("result", result) + new_task = Task( # pyright: ignore[reportCallIssue] + self._ctx, + **result, # pyright: ignore[reportArgumentType] + ) + return new_task + + def wait_for(self) -> Task: """Wait for the task to finish. Examples -------- >>> task.wait_for() - None """ - while not self.is_finished: - self.update() + cur_task = self + + print("\nwait_for()!") + print("self_task", cur_task) + + while not cur_task.is_finished: + print("waiting for task to finish") + cur_task = self.update() + print("new cur_task", cur_task) + + return cur_task + +# No special class for Tasks, just a placeholder for the get method +class Tasks(ContextP[Context]): + def __init__(self, ctx: Context) -> None: + super().__init__() + self._ctx = ctx -class Tasks(resources.Resources): @overload def get(self, *, uid: str, first: int, wait: int) -> Task: """Get a task. @@ -153,8 +244,11 @@ def get(self, uid: str, **kwargs) -> Task: ------- Task """ - path = f"v1/tasks/{uid}" - url = self.params.url + path - response = self.params.session.get(url, params=kwargs) - result = response.json() - return Task(self.params, **result) + # TODO-barret-future; Find better way to pass through query params to the API calls on init + task = Task( + self._ctx, + id=uid, + _placeholder=True, # pyright: ignore[reportCallIssue] + ) + ret_task = task.update(**kwargs) + return ret_task diff --git a/src/posit/connect/variants.py b/src/posit/connect/variants.py index e6be01c4..d0f2233b 100644 --- a/src/posit/connect/variants.py +++ b/src/posit/connect/variants.py @@ -2,9 +2,6 @@ from ._active import ResourceDict from ._types_content_item import ContentItemContext -from .resources import ( - context_to_resource_parameters, -) from .tasks import Task @@ -14,7 +11,7 @@ def render(self) -> Task: path = f"variants/{self['id']}/render" url = self._ctx.url + path response = self._ctx.session.post(url) - return Task(context_to_resource_parameters(self._ctx), **response.json()) + return Task(self._ctx, **response.json()) # No special inheritance as it is a placeholder class diff --git a/tests/posit/connect/__api__/v1/tasks/jXhOhdm5OOSkGhJw-finished.json b/tests/posit/connect/__api__/v1/tasks/jXhOhdm5OOSkGhJw-finished.json new file mode 100644 index 00000000..f65ba483 --- /dev/null +++ b/tests/posit/connect/__api__/v1/tasks/jXhOhdm5OOSkGhJw-finished.json @@ -0,0 +1,9 @@ +{ + "id": "jXhOhdm5OOSkGhJw", + "output": ["Building static content...", "Launching static content..."], + "finished": true, + "code": 1, + "error": "Unable to render: Rendering exited abnormally: exit status 1", + "last": 2, + "result": null +} diff --git a/tests/posit/connect/__api__/v1/tasks/jXhOhdm5OOSkGhJw-unfinished.json b/tests/posit/connect/__api__/v1/tasks/jXhOhdm5OOSkGhJw-unfinished.json new file mode 100644 index 00000000..7abc7618 --- /dev/null +++ b/tests/posit/connect/__api__/v1/tasks/jXhOhdm5OOSkGhJw-unfinished.json @@ -0,0 +1,9 @@ +{ + "id": "jXhOhdm5OOSkGhJw", + "output": ["Building static content...", "Launching static content..."], + "finished": false, + "code": 1, + "error": "Unable to render: Rendering exited abnormally: exit status 1", + "last": 2, + "result": null +} diff --git a/tests/posit/connect/__api__/v1/tasks/jXhOhdm5OOSkGhJw.json b/tests/posit/connect/__api__/v1/tasks/jXhOhdm5OOSkGhJw.json deleted file mode 100644 index c1308f5b..00000000 --- a/tests/posit/connect/__api__/v1/tasks/jXhOhdm5OOSkGhJw.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": "jXhOhdm5OOSkGhJw", - "output": [ - "Building static content...", - "Launching static content..." - ], - "finished": true, - "code": 1, - "error": "Unable to render: Rendering exited abnormally: exit status 1", - "last": 2, - "result": null - } diff --git a/tests/posit/connect/test_bundles.py b/tests/posit/connect/test_bundles.py index 9188d1a6..b60793d0 100644 --- a/tests/posit/connect/test_bundles.py +++ b/tests/posit/connect/test_bundles.py @@ -70,7 +70,7 @@ def test(self): mock_tasks_get = responses.get( f"https://connect.example/__api__/v1/tasks/{task_id}", - json=load_mock(f"v1/tasks/{task_id}.json"), + json=load_mock(f"v1/tasks/{task_id}-finished.json"), ) # setup diff --git a/tests/posit/connect/test_content.py b/tests/posit/connect/test_content.py index 315f6dae..53ffb923 100644 --- a/tests/posit/connect/test_content.py +++ b/tests/posit/connect/test_content.py @@ -83,7 +83,7 @@ def test(self): mock_tasks_get = responses.get( f"https://connect.example/__api__/v1/tasks/{task_id}", - json=load_mock(f"v1/tasks/{task_id}.json"), + json=load_mock(f"v1/tasks/{task_id}-finished.json"), ) # setup diff --git a/tests/posit/connect/test_tasks.py b/tests/posit/connect/test_tasks.py index 5de1d2cf..3db7df7d 100644 --- a/tests/posit/connect/test_tasks.py +++ b/tests/posit/connect/test_tasks.py @@ -12,9 +12,9 @@ class TestTaskAttributes: @classmethod def setup_class(cls): - cls.task = tasks.Task( + cls.task = tasks.Task( # pyright: ignore[reportCallIssue] mock.Mock(), - **load_mock_dict("v1/tasks/jXhOhdm5OOSkGhJw.json"), + **load_mock_dict("v1/tasks/jXhOhdm5OOSkGhJw-finished.json"), # pyright: ignore[reportArgumentType] ) def test_id(self): @@ -51,11 +51,11 @@ def test(self): mock_tasks_get = [ responses.get( f"https://connect.example/__api__/v1/tasks/{uid}", - json={**load_mock_dict(f"v1/tasks/{uid}.json"), "finished": False}, + json=load_mock_dict(f"v1/tasks/{uid}-unfinished.json"), ), responses.get( f"https://connect.example/__api__/v1/tasks/{uid}", - json={**load_mock_dict(f"v1/tasks/{uid}.json"), "finished": True}, + json=load_mock_dict(f"v1/tasks/{uid}-finished.json"), ), ] @@ -65,10 +65,11 @@ def test(self): assert not task.is_finished # invoke - task.update() + finished_task = task.update() # assert - assert task.is_finished + assert not task.is_finished + assert finished_task.is_finished assert mock_tasks_get[0].call_count == 1 assert mock_tasks_get[1].call_count == 1 @@ -81,11 +82,11 @@ def test_with_params(self): mock_tasks_get = [ responses.get( f"https://connect.example/__api__/v1/tasks/{uid}", - json={**load_mock_dict(f"v1/tasks/{uid}.json"), "finished": False}, + json=load_mock_dict(f"v1/tasks/{uid}-unfinished.json"), ), responses.get( f"https://connect.example/__api__/v1/tasks/{uid}", - json={**load_mock_dict(f"v1/tasks/{uid}.json"), "finished": True}, + json=load_mock_dict(f"v1/tasks/{uid}-finished.json"), match=[matchers.query_param_matcher(params)], ), ] @@ -96,10 +97,11 @@ def test_with_params(self): assert not task.is_finished # invoke - task.update(**params) + finished_task = task.update(**params) # assert - assert task.is_finished + assert not task.is_finished + assert finished_task.is_finished assert mock_tasks_get[0].call_count == 1 assert mock_tasks_get[1].call_count == 1 @@ -113,11 +115,11 @@ def test(self): mock_tasks_get = [ responses.get( f"https://connect.example/__api__/v1/tasks/{uid}", - json={**load_mock_dict(f"v1/tasks/{uid}.json"), "finished": False}, + json=load_mock_dict(f"v1/tasks/{uid}-unfinished.json"), ), responses.get( f"https://connect.example/__api__/v1/tasks/{uid}", - json={**load_mock_dict(f"v1/tasks/{uid}.json"), "finished": True}, + json=load_mock_dict(f"v1/tasks/{uid}-finished.json"), ), ] @@ -127,10 +129,10 @@ def test(self): assert not task.is_finished # invoke - task.wait_for() + finished_task = task.wait_for() # assert - assert task.is_finished + assert finished_task.is_finished assert mock_tasks_get[0].call_count == 1 assert mock_tasks_get[1].call_count == 1 @@ -143,7 +145,7 @@ def test(self): # behavior mock_tasks_get: BaseResponse = responses.get( f"https://connect.example/__api__/v1/tasks/{uid}", - json={**load_mock_dict(f"v1/tasks/{uid}.json"), "finished": False}, + json=load_mock_dict(f"v1/tasks/{uid}-finished.json"), ) # setup From 197d1c09cd9684b64ca9743fee17bfdcbf56fc69 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 00:43:22 -0500 Subject: [PATCH 30/47] Remove many debug prints --- src/posit/connect/tasks.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/posit/connect/tasks.py b/src/posit/connect/tasks.py index 7cf5eb33..4c63e674 100644 --- a/src/posit/connect/tasks.py +++ b/src/posit/connect/tasks.py @@ -78,12 +78,11 @@ def __init__(self, ctx: Context, /, **kwargs: Unpack[_Attrs]) -> None: def __init__(self, ctx: Context, /, **kwargs: Unpack[_Attrs]) -> None: task_id = kwargs.get("id") assert isinstance(task_id, str), "Task `id` must be a string." - assert len(task_id) > 0, "Task `id` must not be empty." + assert task_id, "Task `id` must not be empty." task_ctx = TaskContext(ctx, task_id=task_id) path = self._api_path(task_id) get_data = len(kwargs) == 1 - print("task: ", task_ctx, path, get_data, kwargs) super().__init__(task_ctx, path, get_data, **kwargs) @property @@ -167,7 +166,6 @@ def update(self, /, **kwargs) -> Task: ] """ result = self._get_api(params=kwargs) - print("result", result) new_task = Task( # pyright: ignore[reportCallIssue] self._ctx, **result, # pyright: ignore[reportArgumentType] @@ -183,13 +181,8 @@ def wait_for(self) -> Task: """ cur_task = self - print("\nwait_for()!") - print("self_task", cur_task) - while not cur_task.is_finished: - print("waiting for task to finish") cur_task = self.update() - print("new cur_task", cur_task) return cur_task From d5d645911767f751ff6b8f5a2d799f80f86d5440 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 00:43:41 -0500 Subject: [PATCH 31/47] If it hasn't been initialized, use the object repr --- src/posit/connect/_active.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/posit/connect/_active.py b/src/posit/connect/_active.py index c2ed414f..994af1e5 100644 --- a/src/posit/connect/_active.py +++ b/src/posit/connect/_active.py @@ -53,8 +53,10 @@ # 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_abc): + """A read-only dict abstraction.""" + _dict: ResponseAttrs - """Read only dictionary.""" + """Data dictionary.""" def __init__(self, **kwargs: Any) -> None: """ @@ -90,7 +92,9 @@ def __contains__(self, key: object) -> bool: return self._dict.__contains__(key) def __repr__(self) -> str: - return repr(self._dict) + if hasattr(self, "_dict"): + return repr(self._dict) + return object.__repr__(self) def __str__(self) -> str: return str(self._dict) From d1c8b66502184208024cdd6654801894ea3e5999 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 00:44:12 -0500 Subject: [PATCH 32/47] Update Permissions --- src/posit/connect/content.py | 2 +- src/posit/connect/permissions.py | 82 +++++++++++-------- .../permissions.json | 28 +++---- .../permissions/94.json | 10 +-- tests/posit/connect/test_permissions.py | 74 ++++++++++++----- 5 files changed, 122 insertions(+), 74 deletions(-) diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index dcb5a74f..325d5939 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -346,7 +346,7 @@ def environment_variables(self) -> EnvVars: @property def permissions(self) -> Permissions: - return Permissions(context_to_resource_parameters(self._ctx), self["guid"]) + return Permissions(self._ctx) @property def owner(self) -> dict: diff --git a/src/posit/connect/permissions.py b/src/posit/connect/permissions.py index c5c9a268..79928e72 100644 --- a/src/posit/connect/permissions.py +++ b/src/posit/connect/permissions.py @@ -4,20 +4,46 @@ from typing import List, overload -from requests.sessions import Session as Session +from ._active import ActiveDict +from ._types_content_item import ContentItemContext +from ._types_context import ContextP -from .resources import Resource, ResourceParameters, Resources +class PermissionContext(ContentItemContext): + permission_id: str + + def __init__(self, ctx: ContentItemContext, /, *, permission_id: str) -> None: + super().__init__(ctx, content_guid=ctx.content_guid) + self.permission_id = permission_id + + +class Permission(ActiveDict[PermissionContext]): + @classmethod + def _api_path(cls, content_guid: str, permission_id: str) -> str: + return f"v1/content/{content_guid}/permissions/{permission_id}" + + def __init__(self, ctx: ContentItemContext, /, **kwargs) -> None: + permission_id = kwargs.get("id") + assert isinstance( + permission_id, str + ), f"Permission 'id' must be a string. Got: {permission_id}" + assert permission_id, "Permission 'id' must not be an empty string." + + permission_ctx = PermissionContext( + ctx, + permission_id=permission_id, + ) + path = self._api_path(permission_ctx.content_guid, permission_ctx.permission_id) + get_data = len(kwargs) == 1 # `id` is required + + super().__init__(permission_ctx, path, get_data, **kwargs) -class Permission(Resource): def delete(self) -> None: """Delete the permission.""" - path = f"v1/content/{self['content_guid']}/permissions/{self['id']}" - url = self.params.url + path - self.params.session.delete(url) + self._delete_api() @overload - def update(self, *args, role: str, **kwargs) -> None: + def update(self, *args, role: str, **kwargs) -> Permission: """Update the permission. Parameters @@ -27,10 +53,10 @@ def update(self, *args, role: str, **kwargs) -> None: """ @overload - def update(self, *args, **kwargs) -> None: + def update(self, *args, **kwargs) -> Permission: """Update the permission.""" - def update(self, *args, **kwargs) -> None: + def update(self, *args, **kwargs) -> Permission: """Update the permission.""" body = { "principal_guid": self.get("principal_guid"), @@ -39,19 +65,14 @@ def update(self, *args, **kwargs) -> None: } body.update(dict(*args)) body.update(**kwargs) - path = f"v1/content/{self['content_guid']}/permissions/{self['id']}" - url = self.params.url + path - response = self.params.session.put( - url, - json=body, - ) - super().update(**response.json()) + result = self._put_api(json=body) + return Permission(self._ctx, **result) # pyright: ignore[reportCallIssue] -class Permissions(Resources): - def __init__(self, params: ResourceParameters, content_guid: str) -> None: - super().__init__(params) - self.content_guid = content_guid +class Permissions(ContextP[ContentItemContext]): + def __init__(self, ctx: ContentItemContext) -> None: + super().__init__() + self._ctx = ctx def count(self) -> int: """Count the number of permissions. @@ -93,10 +114,10 @@ def create(self, **kwargs) -> Permission: ------- Permission """ - path = f"v1/content/{self.content_guid}/permissions" - url = self.params.url + path - response = self.params.session.post(url, json=kwargs) - return Permission(params=self.params, **response.json()) + path = f"v1/content/{self._ctx.content_guid}/permissions" + url = self._ctx.url + path + response = self._ctx.session.post(url, json=kwargs) + return Permission(self._ctx, **response.json()) def find(self, **kwargs) -> List[Permission]: """Find permissions. @@ -105,11 +126,11 @@ def find(self, **kwargs) -> List[Permission]: ------- List[Permission] """ - path = f"v1/content/{self.content_guid}/permissions" - url = self.params.url + path - response = self.params.session.get(url, json=kwargs) + path = f"v1/content/{self._ctx.content_guid}/permissions" + url = self._ctx.url + path + response = self._ctx.session.get(url, json=kwargs) results = response.json() - return [Permission(self.params, **result) for result in results] + return [Permission(self._ctx, **result) for result in results] def find_one(self, **kwargs) -> Permission | None: """Find a permission. @@ -133,7 +154,4 @@ def get(self, uid: str) -> Permission: ------- Permission """ - path = f"v1/content/{self.content_guid}/permissions/{uid}" - url = self.params.url + path - response = self.params.session.get(url) - return Permission(self.params, **response.json()) + return Permission(self._ctx, id=uid) diff --git a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions.json b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions.json index 9db6f6bf..dfb60f95 100644 --- a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions.json +++ b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions.json @@ -1,16 +1,16 @@ [ - { - "id": 94, - "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", - "principal_guid": "78974391-d89f-4f11-916a-ba50cfe993db", - "principal_type": "user", - "role": "owner" - }, - { - "id": 59, - "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", - "principal_guid": "75b95fc0-ae02-4d85-8732-79a845143eed", - "principal_type": "group", - "role": "viewer" - } + { + "id": "94", + "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", + "principal_guid": "78974391-d89f-4f11-916a-ba50cfe993db", + "principal_type": "user", + "role": "owner" + }, + { + "id": "59", + "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", + "principal_guid": "75b95fc0-ae02-4d85-8732-79a845143eed", + "principal_type": "group", + "role": "viewer" + } ] diff --git a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions/94.json b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions/94.json index 491db40c..496fee15 100644 --- a/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions/94.json +++ b/tests/posit/connect/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/permissions/94.json @@ -1,7 +1,7 @@ { - "id": 94, - "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", - "principal_guid": "78974391-d89f-4f11-916a-ba50cfe993db", - "principal_type": "user", - "role": "owner" + "id": "94", + "content_guid": "f2f37341-e21d-3d80-c698-a935ad614066", + "principal_guid": "78974391-d89f-4f11-916a-ba50cfe993db", + "principal_type": "user", + "role": "owner" } diff --git a/tests/posit/connect/test_permissions.py b/tests/posit/connect/test_permissions.py index 0f3a390a..92adcda8 100644 --- a/tests/posit/connect/test_permissions.py +++ b/tests/posit/connect/test_permissions.py @@ -5,8 +5,9 @@ import responses from responses import matchers +from posit.connect._types_content_item import ContentItemContext +from posit.connect.context import Context from posit.connect.permissions import Permission, Permissions -from posit.connect.resources import ResourceParameters from posit.connect.urls import Url from .api import load_mock, load_mock_dict, load_mock_list @@ -25,9 +26,12 @@ def test(self): ) # setup - params = ResourceParameters(requests.Session(), Url("https://connect.example/__api__")) + ctx = ContentItemContext( + Context(requests.Session(), Url("https://connect.example/__api__")), + content_guid=content_guid, + ) fake_permission = load_mock_dict(f"v1/content/{content_guid}/permissions/{uid}.json") - permission = Permission(params, **fake_permission) + permission = Permission(ctx, **fake_permission) # invoke permission.delete() @@ -40,7 +44,7 @@ class TestPermissionUpdate: @responses.activate def test_request_shape(self): # test data - uid = random.randint(0, 100) + uid = str(random.randint(0, 100)) content_guid = str(uuid.uuid4()) principal_guid = str(uuid.uuid4()) principal_type = "principal_type" @@ -51,7 +55,9 @@ def test_request_shape(self): responses.put( f"https://connect.example/__api__/v1/content/{content_guid}/permissions/{uid}", json={ - # doesn't matter for this test + # doesn't matter for this test, but something more than `id` is needed to avoid an API call + "id": uid, + "content_guid": content_guid, }, match=[ # assertion @@ -69,9 +75,13 @@ def test_request_shape(self): ) # setup - params = ResourceParameters(requests.Session(), Url("https://connect.example/__api__")) + ctx = ContentItemContext( + Context(requests.Session(), Url("https://connect.example/__api__")), + content_guid=content_guid, + ) + permission = Permission( - params, + ctx, id=uid, content_guid=content_guid, principal_guid=principal_guid, @@ -95,7 +105,7 @@ def test_role_update(self): fake_permission.update(role=new_role) # define api behavior - uid = random.randint(0, 100) + uid = str(random.randint(0, 100)) content_guid = str(uuid.uuid4()) responses.put( f"https://connect.example/__api__/v1/content/{content_guid}/permissions/{uid}", @@ -112,13 +122,17 @@ def test_role_update(self): ) # setup - params = ResourceParameters(requests.Session(), Url("https://connect.example/__api__")) - permission = Permission(params, id=uid, content_guid=content_guid, role=old_role) + ctx = ContentItemContext( + Context(requests.Session(), Url("https://connect.example/__api__")), + content_guid=content_guid, + ) + permission = Permission(ctx, id=uid, content_guid=content_guid, role=old_role) # assert role change with respect to api response assert permission["role"] == old_role - permission.update(role=new_role) - assert permission["role"] == new_role + updated_permission = permission.update(role=new_role) + assert permission["role"] == old_role + assert updated_permission["role"] == new_role class TestPermissionsCount: @@ -135,8 +149,11 @@ def test(self): ) # setup - params = ResourceParameters(requests.Session(), Url("https://connect.example/__api__")) - permissions = Permissions(params, content_guid=content_guid) + ctx = ContentItemContext( + Context(requests.Session(), Url("https://connect.example/__api__")), + content_guid=content_guid, + ) + permissions = Permissions(ctx) # invoke count = permissions.count() @@ -177,8 +194,12 @@ def test(self): ) # setup - params = ResourceParameters(requests.Session(), Url("https://connect.example/__api__")) - permissions = Permissions(params, content_guid=content_guid) + ctx = ContentItemContext( + Context(requests.Session(), Url("https://connect.example/__api__")), + content_guid=content_guid, + ) + + permissions = Permissions(ctx) # invoke permission = permissions.create( @@ -205,8 +226,11 @@ def test(self): ) # setup - params = ResourceParameters(requests.Session(), Url("https://connect.example/__api__")) - permissions = Permissions(params, content_guid=content_guid) + ctx = ContentItemContext( + Context(requests.Session(), Url("https://connect.example/__api__")), + content_guid=content_guid, + ) + permissions = Permissions(ctx) # invoke permissions = permissions.find() @@ -229,8 +253,11 @@ def test(self): ) # setup - params = ResourceParameters(requests.Session(), Url("https://connect.example/__api__")) - permissions = Permissions(params, content_guid=content_guid) + ctx = ContentItemContext( + Context(requests.Session(), Url("https://connect.example/__api__")), + content_guid=content_guid, + ) + permissions = Permissions(ctx) # invoke permission = permissions.find_one() @@ -254,8 +281,11 @@ def test(self): ) # setup - params = ResourceParameters(requests.Session(), Url("https://connect.example/__api__")) - permissions = Permissions(params, content_guid=content_guid) + ctx = ContentItemContext( + Context(requests.Session(), Url("https://connect.example/__api__")), + content_guid=content_guid, + ) + permissions = Permissions(ctx) # invoke permission = permissions.get(uid) From 074afad514f274cbe4670bd3a7253ca7623cf575 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 01:20:55 -0500 Subject: [PATCH 33/47] Relax `json=` requirement to `Any`, matching `requests` package --- src/posit/connect/_api_call.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/posit/connect/_api_call.py b/src/posit/connect/_api_call.py index 4d7b1869..8144a38f 100644 --- a/src/posit/connect/_api_call.py +++ b/src/posit/connect/_api_call.py @@ -1,7 +1,7 @@ from __future__ import annotations import posixpath -from typing import TYPE_CHECKING, Optional, Protocol +from typing import TYPE_CHECKING, Any, Optional, Protocol from ._types_context import ContextP @@ -16,8 +16,8 @@ class ApiCallProtocol(ContextP, Protocol): 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 _patch_api(self, *path, json: Any | None) -> Jsonifiable: ... + def _put_api(self, *path, json: Any | None) -> Jsonifiable: ... def endpoint(ctx: Context, *path) -> str: @@ -33,7 +33,7 @@ def get_api(ctx: Context, *path) -> Jsonifiable: def put_api( ctx: Context, *path, - json: Jsonifiable | None, + json: Any | None, ) -> Jsonifiable: response = ctx.session.put(endpoint(ctx, *path), json=json) return response.json() @@ -59,7 +59,7 @@ def _delete_api(self: ApiCallProtocol, *path) -> Jsonifiable | None: def _patch_api( self: ApiCallProtocol, *path, - json: Jsonifiable | None, + json: Any | None, ) -> Jsonifiable: response = self._ctx.session.patch(self._endpoint(*path), json=json) return response.json() @@ -67,7 +67,7 @@ def _patch_api( def _put_api( self: ApiCallProtocol, *path, - json: Jsonifiable | None, + json: Any | None, ) -> Jsonifiable: response = self._ctx.session.put(self._endpoint(*path), json=json) return response.json() From faa2d8a872a3a13066453e71d77c45d3fa2cbf52 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 01:21:23 -0500 Subject: [PATCH 34/47] Update associations --- src/posit/connect/content.py | 3 +- .../oauth/_types_context_integration.py | 11 +++ src/posit/connect/oauth/associations.py | 93 ++++++++++++------- src/posit/connect/oauth/integrations.py | 6 +- 4 files changed, 77 insertions(+), 36 deletions(-) create mode 100644 src/posit/connect/oauth/_types_context_integration.py diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 325d5939..82967869 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -46,8 +46,7 @@ def __init__(self, ctx: ContentItemContext) -> None: @property def associations(self) -> ContentItemAssociations: return ContentItemAssociations( - context_to_resource_parameters(self._ctx), - content_guid=self._ctx.content_guid, + self._ctx, ) diff --git a/src/posit/connect/oauth/_types_context_integration.py b/src/posit/connect/oauth/_types_context_integration.py new file mode 100644 index 00000000..ad1a54d8 --- /dev/null +++ b/src/posit/connect/oauth/_types_context_integration.py @@ -0,0 +1,11 @@ +"""OAuth integration resources.""" + +from ..context import Context + + +class IntegrationContext(Context): + integration_guid: str + + def __init__(self, ctx: Context, /, *, integration_guid: str) -> None: + super().__init__(ctx.session, ctx.url) + self.integration_guid = integration_guid diff --git a/src/posit/connect/oauth/associations.py b/src/posit/connect/oauth/associations.py index efb85c60..57d683ca 100644 --- a/src/posit/connect/oauth/associations.py +++ b/src/posit/connect/oauth/associations.py @@ -1,78 +1,107 @@ """OAuth association resources.""" -from typing import List +from __future__ import annotations -from ..resources import Resource, ResourceParameters, Resources +from typing import TYPE_CHECKING, cast +from typing_extensions import TypedDict, Unpack -class Association(Resource): - pass +from .._active import ResourceDict +from .._api_call import ApiCallMixin +from .._json import Jsonifiable, JsonifiableList +from .._types_content_item import ContentItemContext +from .._types_context import ContextP +from ._types_context_integration import IntegrationContext +if TYPE_CHECKING: + from ..context import Context -class IntegrationAssociations(Resources): + +class Association(ResourceDict): + class _Attrs(TypedDict, total=False): + app_guid: str + """The unique identifier of the content item.""" + oauth_integration_guid: str + """The unique identifier of an existing OAuth integration.""" + oauth_integration_name: str + """A descriptive name that identifies the OAuth integration.""" + oauth_integration_description: str + """A brief text that describes the OAuth integration.""" + oauth_integration_template: str + """The template used to configure this OAuth integration.""" + created_time: str + """The timestamp (RFC3339) indicating when this association was created.""" + + def __init__(self, ctx: Context, /, **kwargs: Unpack[_Attrs]) -> None: + super().__init__(ctx, **kwargs) + + +class IntegrationAssociations(ContextP[IntegrationContext]): """IntegrationAssociations resource.""" - def __init__(self, params: ResourceParameters, integration_guid: str) -> None: - super().__init__(params) - self.integration_guid = integration_guid + def __init__(self, ctx: Context, integration_guid: str) -> None: + super().__init__() + self._ctx = IntegrationContext(ctx, integration_guid=integration_guid) - def find(self) -> List[Association]: + def find(self) -> list[Association]: """Find OAuth associations. Returns ------- - List[Association] + list[Association] """ - path = f"v1/oauth/integrations/{self.integration_guid}/associations" - url = self.params.url + path + path = f"v1/oauth/integrations/{self._ctx.integration_guid}/associations" + url = self._ctx.url + path - response = self.params.session.get(url) + response = self._ctx.session.get(url) return [ Association( - self.params, + self._ctx, **result, ) for result in response.json() ] -class ContentItemAssociations(Resources): +# TODO-barret; Should this auto retrieve? If so, it should inherit from ActiveSequence +class ContentItemAssociations(ApiCallMixin, ContextP[ContentItemContext]): """ContentItemAssociations resource.""" - def __init__(self, params: ResourceParameters, content_guid: str) -> None: - super().__init__(params) - self.content_guid = content_guid + @classmethod + def _api_path(cls, content_guid: str) -> str: + return f"v1/content/{content_guid}/oauth/integrations/associations" - def find(self) -> List[Association]: + def __init__(self, ctx: ContentItemContext) -> None: + super().__init__() + self._ctx = ctx + self._path = self._api_path(ctx.content_guid) + + # TODO-barret-q: Should this be inherited from ActiveFinderSequence? (It would add find_by) + def find(self) -> list[Association]: """Find OAuth associations. Returns ------- - List[Association] + list[Association] """ - path = f"v1/content/{self.content_guid}/oauth/integrations/associations" - url = self.params.url + path - response = self.params.session.get(url) + results: Jsonifiable = self._get_api() + results_list = cast(JsonifiableList, results) return [ Association( - self.params, + self._ctx, **result, ) - for result in response.json() + for result in results_list ] + # TODO-barret-q: Should this be destroy instead of delete? def delete(self) -> None: """Delete integration associations.""" data = [] - - path = f"v1/content/{self.content_guid}/oauth/integrations/associations" - url = self.params.url + path - self.params.session.put(url, json=data) + self._put_api(json=data) def update(self, integration_guid: str) -> None: """Set integration associations.""" data = [{"oauth_integration_guid": integration_guid}] - path = f"v1/content/{self.content_guid}/oauth/integrations/associations" - url = self.params.url + path - self.params.session.put(url, json=data) + self._put_api(json=data) diff --git a/src/posit/connect/oauth/integrations.py b/src/posit/connect/oauth/integrations.py index ccbaaf73..1c64725e 100644 --- a/src/posit/connect/oauth/integrations.py +++ b/src/posit/connect/oauth/integrations.py @@ -2,7 +2,7 @@ from typing import List, Optional, overload -from ..resources import Resource, Resources +from ..resources import Resource, Resources, resource_parameters_to_context from .associations import IntegrationAssociations @@ -11,7 +11,9 @@ class Integration(Resource): @property def associations(self) -> IntegrationAssociations: - return IntegrationAssociations(self.params, integration_guid=self["guid"]) + return IntegrationAssociations( + resource_parameters_to_context(self.params), integration_guid=self["guid"] + ) def delete(self) -> None: """Delete the OAuth integration.""" From fbfe8c88d768fff19b9ae8cacf2c76808b93170d Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 01:47:10 -0500 Subject: [PATCH 35/47] Updated Integrations --- src/posit/connect/_api_call.py | 9 ++ src/posit/connect/oauth/associations.py | 4 +- src/posit/connect/oauth/integrations.py | 91 +++++++++++-------- src/posit/connect/oauth/oauth.py | 2 +- .../posit/connect/oauth/test_integrations.py | 6 +- 5 files changed, 70 insertions(+), 42 deletions(-) diff --git a/src/posit/connect/_api_call.py b/src/posit/connect/_api_call.py index 8144a38f..a1bf5b02 100644 --- a/src/posit/connect/_api_call.py +++ b/src/posit/connect/_api_call.py @@ -17,6 +17,7 @@ def _endpoint(self, *path) -> str: ... def _get_api(self, *path) -> Jsonifiable: ... def _delete_api(self, *path) -> Jsonifiable | None: ... def _patch_api(self, *path, json: Any | None) -> Jsonifiable: ... + def _post_api(self, *path, json: Any | None) -> Jsonifiable: ... def _put_api(self, *path, json: Any | None) -> Jsonifiable: ... @@ -64,6 +65,14 @@ def _patch_api( response = self._ctx.session.patch(self._endpoint(*path), json=json) return response.json() + def _post_api( + self: ApiCallProtocol, + *path, + json: Any | None, + ) -> Jsonifiable: + response = self._ctx.session.post(self._endpoint(*path), json=json) + return response.json() + def _put_api( self: ApiCallProtocol, *path, diff --git a/src/posit/connect/oauth/associations.py b/src/posit/connect/oauth/associations.py index 57d683ca..75458db8 100644 --- a/src/posit/connect/oauth/associations.py +++ b/src/posit/connect/oauth/associations.py @@ -39,9 +39,9 @@ def __init__(self, ctx: Context, /, **kwargs: Unpack[_Attrs]) -> None: class IntegrationAssociations(ContextP[IntegrationContext]): """IntegrationAssociations resource.""" - def __init__(self, ctx: Context, integration_guid: str) -> None: + def __init__(self, ctx: IntegrationContext) -> None: super().__init__() - self._ctx = IntegrationContext(ctx, integration_guid=integration_guid) + self._ctx = ctx def find(self) -> list[Association]: """Find OAuth associations. diff --git a/src/posit/connect/oauth/integrations.py b/src/posit/connect/oauth/integrations.py index 1c64725e..dc583a33 100644 --- a/src/posit/connect/oauth/integrations.py +++ b/src/posit/connect/oauth/integrations.py @@ -1,58 +1,80 @@ """OAuth integration resources.""" -from typing import List, Optional, overload +from typing import List, Optional, cast, overload -from ..resources import Resource, Resources, resource_parameters_to_context +from typing_extensions import TypedDict, Unpack + +from .._active import ActiveDict +from .._api_call import ApiCallMixin +from .._json import JsonifiableList +from .._types_context import ContextP +from .._utils import assert_guid +from ..context import Context +from ._types_context_integration import IntegrationContext from .associations import IntegrationAssociations -class Integration(Resource): +class Integration(ActiveDict[IntegrationContext]): """OAuth integration resource.""" + def __init__(self, ctx: Context, /, *, guid: str, **kwargs): + guid = assert_guid(guid) + + integration_ctx = IntegrationContext(ctx, integration_guid=guid) + path = f"v1/oauth/integrations/{guid}" + get_data = len(kwargs) == 0 # `guid` is required + super().__init__(integration_ctx, path, get_data, guid=guid, **kwargs) + @property def associations(self) -> IntegrationAssociations: return IntegrationAssociations( - resource_parameters_to_context(self.params), integration_guid=self["guid"] + self._ctx, ) def delete(self) -> None: """Delete the OAuth integration.""" path = f"v1/oauth/integrations/{self['guid']}" - url = self.params.url + path - self.params.session.delete(url) + url = self._ctx.url + path + self._ctx.session.delete(url) + + class _AttrsUpdate(TypedDict, total=False): + name: str + description: str + config: dict - @overload def update( self, - *args, - name: str = ..., - description: str = ..., - config: dict = ..., - **kwargs, - ) -> None: + **kwargs: Unpack[_AttrsUpdate], + ) -> "Integration": """Update the OAuth integration. Parameters ---------- name: str, optional + A descriptive name to identify each OAuth integration. description: str, optional + A brief text to describe each OAuth integration. config: dict, optional + The OAuth integration configuration based on the template. See List OAuth templates for + more information on available fields for each template. The configuration combines + elements from both options and fields from a given template. """ + result = self._patch_api(json=kwargs) + return Integration(self._ctx, **result) # pyright: ignore[reportCallIssue] - @overload - def update(self, *args, **kwargs) -> None: - """Update the OAuth integration.""" - def update(self, *args, **kwargs) -> None: - """Update the OAuth integration.""" - body = dict(*args, **kwargs) - url = self.params.url + f"v1/oauth/integrations/{self['guid']}" - response = self.params.session.patch(url, json=body) - super().update(**response.json()) +# TODO-barret; Should this auto retrieve? If so, it should inherit from ActiveSequence +class Integrations(ApiCallMixin, ContextP[Context]): + """Integrations resource.""" + @classmethod + def _api_path(cls) -> str: + return "v1/oauth/integrations" -class Integrations(Resources): - """Integrations resource.""" + def __init__(self, ctx: Context) -> None: + super().__init__() + self._ctx = ctx + self._path = self._api_path() @overload def create( @@ -100,10 +122,8 @@ def create(self, **kwargs) -> Integration: ------- Integration """ - path = "v1/oauth/integrations" - url = self.params.url + path - response = self.params.session.post(url, json=kwargs) - return Integration(self.params, **response.json()) + result = self._post_api(json=kwargs) + return Integration(self._ctx, **result) # pyright: ignore[reportCallIssue] def find(self) -> List[Integration]: """Find OAuth integrations. @@ -112,16 +132,15 @@ def find(self) -> List[Integration]: ------- List[Integration] """ - path = "v1/oauth/integrations" - url = self.params.url + path + results = self._get_api() + results_list = cast(JsonifiableList, results) - response = self.params.session.get(url) return [ Integration( - self.params, + self._ctx, **result, ) - for result in response.json() + for result in results_list ] def get(self, guid: str) -> Integration: @@ -135,7 +154,5 @@ def get(self, guid: str) -> Integration: ------- Integration """ - path = f"v1/oauth/integrations/{guid}" - url = self.params.url + path - response = self.params.session.get(url) - return Integration(self.params, **response.json()) + result = self._get_api(guid) + return Integration(self._ctx, **result) # pyright: ignore[reportCallIssue] diff --git a/src/posit/connect/oauth/oauth.py b/src/posit/connect/oauth/oauth.py index 78524ab8..6784db89 100644 --- a/src/posit/connect/oauth/oauth.py +++ b/src/posit/connect/oauth/oauth.py @@ -16,7 +16,7 @@ def __init__(self, params: ResourceParameters, api_key: str) -> None: @property def integrations(self): - return Integrations(self.params) + return Integrations(resource_parameters_to_context(self.params)) @property def sessions(self): diff --git a/tests/posit/connect/oauth/test_integrations.py b/tests/posit/connect/oauth/test_integrations.py index 27405fd7..f6389d56 100644 --- a/tests/posit/connect/oauth/test_integrations.py +++ b/tests/posit/connect/oauth/test_integrations.py @@ -47,6 +47,7 @@ def test(self): c._ctx.version = None integration = c.oauth.integrations.get(guid) assert integration["guid"] == guid + old_name = integration["name"] new_name = "New Name" @@ -59,9 +60,10 @@ def test(self): json=fake_integration, ) - integration.update(name=new_name) + updated_integration = integration.update(name=new_name) assert mock_update.call_count == 1 - assert integration["name"] == new_name + assert integration["name"] == old_name + assert updated_integration["name"] == new_name class TestIntegrationsCreate: From bacbe857a0da9a6f77da57139b88430b5446adcd Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 02:06:30 -0500 Subject: [PATCH 36/47] Update Usage, Visits, and Metrics --- src/posit/connect/client.py | 2 +- src/posit/connect/metrics/metrics.py | 11 ++++-- src/posit/connect/metrics/shiny_usage.py | 28 ++++++++------ src/posit/connect/metrics/usage.py | 37 +++++++++++-------- src/posit/connect/metrics/visits.py | 28 ++++++++------ .../posit/connect/metrics/test_shiny_usage.py | 10 ++--- tests/posit/connect/metrics/test_visits.py | 10 ++--- 7 files changed, 75 insertions(+), 51 deletions(-) diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index 28a3c18c..80738db9 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -255,7 +255,7 @@ def metrics(self) -> Metrics: >>> len(events) 24 """ - return Metrics(self.resource_params) + return Metrics(self._ctx) @property @requires(version="2024.08.0") diff --git a/src/posit/connect/metrics/metrics.py b/src/posit/connect/metrics/metrics.py index a205c517..de2bdc36 100644 --- a/src/posit/connect/metrics/metrics.py +++ b/src/posit/connect/metrics/metrics.py @@ -1,10 +1,11 @@ """Metric resources.""" -from .. import resources +from .._types_context import ContextP +from ..context import Context from .usage import Usage -class Metrics(resources.Resources): +class Metrics(ContextP[Context]): """Metrics resource. Attributes @@ -13,6 +14,10 @@ class Metrics(resources.Resources): Usage resource. """ + def __init__(self, ctx: Context): + super().__init__() + self._ctx = ctx + @property def usage(self) -> Usage: - return Usage(self.params) + return Usage(self._ctx) diff --git a/src/posit/connect/metrics/shiny_usage.py b/src/posit/connect/metrics/shiny_usage.py index 050f801a..53ffc3fe 100644 --- a/src/posit/connect/metrics/shiny_usage.py +++ b/src/posit/connect/metrics/shiny_usage.py @@ -2,11 +2,14 @@ from typing import List, overload +from .._active import ResourceDict +from .._api_call import ApiCallMixin +from .._types_context import ContextP +from ..context import Context from ..cursors import CursorPaginator -from ..resources import Resource, Resources -class ShinyUsageEvent(Resource): +class ShinyUsageEvent(ResourceDict): @property def content_guid(self) -> str: """The associated unique content identifier. @@ -58,7 +61,12 @@ def data_version(self) -> int: return self["data_version"] -class ShinyUsage(Resources): +class ShinyUsage(ApiCallMixin, ContextP[Context]): + def __init__(self, ctx: Context) -> None: + super().__init__() + self._ctx = ctx + self._path = "v1/instrumentation/shiny/usage" + @overload def find( self, @@ -104,13 +112,12 @@ def find(self, **kwargs) -> List[ShinyUsageEvent]: """ params = rename_params(kwargs) - path = "/v1/instrumentation/shiny/usage" - url = self.params.url + path - paginator = CursorPaginator(self.params.session, url, params=params) + url = self._ctx.url + self._path + paginator = CursorPaginator(self._ctx.session, url, params=params) results = paginator.fetch_results() return [ ShinyUsageEvent( - self.params, + self._ctx, **result, ) for result in results @@ -160,14 +167,13 @@ def find_one(self, **kwargs) -> ShinyUsageEvent | None: ShinyUsageEvent | None """ params = rename_params(kwargs) - path = "/v1/instrumentation/shiny/usage" - url = self.params.url + path - paginator = CursorPaginator(self.params.session, url, params=params) + url = self._ctx.url + self._path + paginator = CursorPaginator(self._ctx.session, url, params=params) pages = paginator.fetch_pages() results = (result for page in pages for result in page.results) visits = ( ShinyUsageEvent( - self.params, + self._ctx, **result, ) for result in results diff --git a/src/posit/connect/metrics/usage.py b/src/posit/connect/metrics/usage.py index 41bc3db4..438ce603 100644 --- a/src/posit/connect/metrics/usage.py +++ b/src/posit/connect/metrics/usage.py @@ -6,27 +6,30 @@ from requests.sessions import Session as Session -from .. import resources -from . import shiny_usage, visits +from .._active import ResourceDict +from .._types_context import ContextP +from ..context import Context +from .shiny_usage import ShinyUsage, ShinyUsageEvent +from .visits import VisitEvent, Visits -class UsageEvent(resources.Resource): +class UsageEvent(ResourceDict): @staticmethod def from_event( - event: visits.VisitEvent | shiny_usage.ShinyUsageEvent, + event: VisitEvent | ShinyUsageEvent, ) -> UsageEvent: - if isinstance(event, visits.VisitEvent): + if isinstance(event, VisitEvent): return UsageEvent.from_visit_event(event) - if isinstance(event, shiny_usage.ShinyUsageEvent): + if isinstance(event, ShinyUsageEvent): return UsageEvent.from_shiny_usage_event(event) raise TypeError @staticmethod - def from_visit_event(event: visits.VisitEvent) -> UsageEvent: + def from_visit_event(event: VisitEvent) -> UsageEvent: return UsageEvent( - event.params, + event._ctx, content_guid=event.content_guid, user_guid=event.user_guid, variant_key=event.variant_key, @@ -40,10 +43,10 @@ def from_visit_event(event: visits.VisitEvent) -> UsageEvent: @staticmethod def from_shiny_usage_event( - event: shiny_usage.ShinyUsageEvent, + event: ShinyUsageEvent, ) -> UsageEvent: return UsageEvent( - event.params, + event._ctx, content_guid=event.content_guid, user_guid=event.user_guid, variant_key=None, @@ -148,9 +151,13 @@ def path(self) -> str | None: return self["path"] -class Usage(resources.Resources): +class Usage(ContextP[Context]): """Usage resource.""" + def __init__(self, ctx: Context): + super().__init__() + self._ctx = ctx + @overload def find( self, @@ -195,9 +202,9 @@ def find(self, **kwargs) -> List[UsageEvent]: List[UsageEvent] """ events = [] - finders = (visits.Visits, shiny_usage.ShinyUsage) + finders = (Visits, ShinyUsage) for finder in finders: - instance = finder(self.params) + instance = finder(self._ctx) events.extend( [ UsageEvent.from_event(event) @@ -249,9 +256,9 @@ def find_one(self, **kwargs) -> UsageEvent | None: ------- UsageEvent | None """ - finders = (visits.Visits, shiny_usage.ShinyUsage) + finders = (Visits, ShinyUsage) for finder in finders: - instance = finder(self.params) + instance = finder(self._ctx) event = instance.find_one(**kwargs) # type: ignore[attr-defined] if event: return UsageEvent.from_event(event) diff --git a/src/posit/connect/metrics/visits.py b/src/posit/connect/metrics/visits.py index 59b3acfb..d83de517 100644 --- a/src/posit/connect/metrics/visits.py +++ b/src/posit/connect/metrics/visits.py @@ -2,11 +2,14 @@ from typing import List, overload +from .._active import ResourceDict +from .._api_call import ApiCallMixin +from .._types_context import ContextP +from ..context import Context from ..cursors import CursorPaginator -from ..resources import Resource, Resources -class VisitEvent(Resource): +class VisitEvent(ResourceDict): @property def content_guid(self) -> str: """The associated unique content identifier. @@ -90,7 +93,12 @@ def path(self) -> str: return self["path"] -class Visits(Resources): +class Visits(ApiCallMixin, ContextP[Context]): + def __init__(self, ctx: Context) -> None: + super().__init__() + self._ctx = ctx + self._path = "v1/instrumentation/content/visits" + @overload def find( self, @@ -136,13 +144,12 @@ def find(self, **kwargs) -> List[VisitEvent]: """ params = rename_params(kwargs) - path = "/v1/instrumentation/content/visits" - url = self.params.url + path - paginator = CursorPaginator(self.params.session, url, params=params) + url = self._ctx.url + self._path + paginator = CursorPaginator(self._ctx.session, url, params=params) results = paginator.fetch_results() return [ VisitEvent( - self.params, + self._ctx, **result, ) for result in results @@ -192,14 +199,13 @@ def find_one(self, **kwargs) -> VisitEvent | None: Visit | None """ params = rename_params(kwargs) - path = "/v1/instrumentation/content/visits" - url = self.params.url + path - paginator = CursorPaginator(self.params.session, url, params=params) + url = self._ctx.url + self._path + paginator = CursorPaginator(self._ctx.session, url, params=params) pages = paginator.fetch_pages() results = (result for page in pages for result in page.results) visits = ( VisitEvent( - self.params, + self._ctx, **result, ) for result in results diff --git a/tests/posit/connect/metrics/test_shiny_usage.py b/tests/posit/connect/metrics/test_shiny_usage.py index 01988a21..5e5bd6f4 100644 --- a/tests/posit/connect/metrics/test_shiny_usage.py +++ b/tests/posit/connect/metrics/test_shiny_usage.py @@ -4,8 +4,8 @@ import responses from responses import matchers +from posit.connect.context import Context from posit.connect.metrics import shiny_usage -from posit.connect.resources import ResourceParameters from posit.connect.urls import Url from ..api import load_mock, load_mock_dict @@ -68,10 +68,10 @@ def test(self): ] # setup - params = ResourceParameters(requests.Session(), Url("https://connect.example/__api__")) + ctx = Context(requests.Session(), Url("https://connect.example/__api__")) # invoke - events = shiny_usage.ShinyUsage(params).find() + events = shiny_usage.ShinyUsage(ctx).find() # assert assert mock_get[0].call_count == 1 @@ -110,10 +110,10 @@ def test(self): ] # setup - params = ResourceParameters(requests.Session(), Url("https://connect.example/__api__")) + ctx = Context(requests.Session(), Url("https://connect.example/__api__")) # invoke - event = shiny_usage.ShinyUsage(params).find_one() + event = shiny_usage.ShinyUsage(ctx).find_one() # assert assert mock_get[0].call_count == 1 diff --git a/tests/posit/connect/metrics/test_visits.py b/tests/posit/connect/metrics/test_visits.py index a8c12449..6fe561bb 100644 --- a/tests/posit/connect/metrics/test_visits.py +++ b/tests/posit/connect/metrics/test_visits.py @@ -4,8 +4,8 @@ import responses from responses import matchers +from posit.connect.context import Context from posit.connect.metrics import visits -from posit.connect.resources import ResourceParameters from posit.connect.urls import Url from ..api import load_mock, load_mock_dict @@ -81,10 +81,10 @@ def test(self): ] # setup - params = ResourceParameters(requests.Session(), Url("https://connect.example/__api__")) + ctx = Context(requests.Session(), Url("https://connect.example/__api__")) # invoke - events = visits.Visits(params).find() + events = visits.Visits(ctx).find() # assert assert mock_get[0].call_count == 1 @@ -125,10 +125,10 @@ def test(self): ] # setup - params = ResourceParameters(requests.Session(), Url("https://connect.example/__api__")) + ctx = Context(requests.Session(), Url("https://connect.example/__api__")) # invoke - event = visits.Visits(params).find_one() + event = visits.Visits(ctx).find_one() # assert assert mock_get[0].call_count == 1 From b5fafdbaea8c6e5caa5b3344c639201dd2d24e8c Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 03:02:12 -0500 Subject: [PATCH 37/47] Relax return type; Remove ignore statements --- src/posit/connect/_api_call.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/posit/connect/_api_call.py b/src/posit/connect/_api_call.py index a1bf5b02..5d652a28 100644 --- a/src/posit/connect/_api_call.py +++ b/src/posit/connect/_api_call.py @@ -6,7 +6,6 @@ from ._types_context import ContextP if TYPE_CHECKING: - from ._json import Jsonifiable from .context import Context @@ -14,11 +13,11 @@ class ApiCallProtocol(ContextP, Protocol): _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: Any | None) -> Jsonifiable: ... - def _post_api(self, *path, json: Any | None) -> Jsonifiable: ... - def _put_api(self, *path, json: Any | None) -> Jsonifiable: ... + def _get_api(self, *path) -> Any: ... + def _delete_api(self, *path) -> Any | None: ... + def _patch_api(self, *path, json: Any | None) -> Any: ... + def _post_api(self, *path, json: Any | None) -> Any: ... + def _put_api(self, *path, json: Any | None) -> Any: ... def endpoint(ctx: Context, *path) -> str: @@ -26,7 +25,7 @@ def endpoint(ctx: Context, *path) -> str: # Helper methods for API interactions -def get_api(ctx: Context, *path) -> Jsonifiable: +def get_api(ctx: Context, *path) -> Any: response = ctx.session.get(endpoint(ctx, *path)) return response.json() @@ -35,7 +34,7 @@ def put_api( ctx: Context, *path, json: Any | None, -) -> Jsonifiable: +) -> Any: response = ctx.session.put(endpoint(ctx, *path), json=json) return response.json() @@ -47,11 +46,11 @@ class ApiCallMixin: def _endpoint(self: ApiCallProtocol, *path) -> str: return endpoint(self._ctx, self._path, *path) - def _get_api(self: ApiCallProtocol, *path, params: Optional[dict] = None) -> Jsonifiable: + def _get_api(self: ApiCallProtocol, *path, params: Optional[dict] = None) -> Any: response = self._ctx.session.get(self._endpoint(*path), params=params) return response.json() - def _delete_api(self: ApiCallProtocol, *path) -> Jsonifiable | None: + def _delete_api(self: ApiCallProtocol, *path) -> Any | None: response = self._ctx.session.delete(self._endpoint(*path)) if len(response.content) == 0: return None @@ -61,7 +60,7 @@ def _patch_api( self: ApiCallProtocol, *path, json: Any | None, - ) -> Jsonifiable: + ) -> Any: response = self._ctx.session.patch(self._endpoint(*path), json=json) return response.json() @@ -69,7 +68,7 @@ def _post_api( self: ApiCallProtocol, *path, json: Any | None, - ) -> Jsonifiable: + ) -> Any: response = self._ctx.session.post(self._endpoint(*path), json=json) return response.json() @@ -77,6 +76,6 @@ def _put_api( self: ApiCallProtocol, *path, json: Any | None, - ) -> Jsonifiable: + ) -> Any: response = self._ctx.session.put(self._endpoint(*path), json=json) return response.json() From 5f5082d2a25f77ca18c93d924ae3b2d7ddc8a6c7 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 03:02:23 -0500 Subject: [PATCH 38/47] Update User, Users, ContentItem, me, Content --- src/posit/connect/_active.py | 22 +-- src/posit/connect/_content_repository.py | 7 +- src/posit/connect/client.py | 6 +- src/posit/connect/content.py | 50 ++--- src/posit/connect/me.py | 11 +- src/posit/connect/oauth/integrations.py | 6 +- src/posit/connect/permissions.py | 2 +- src/posit/connect/tasks.py | 4 +- src/posit/connect/users.py | 221 ++++++++++++++++------- tests/posit/connect/test_content.py | 7 +- tests/posit/connect/test_users.py | 22 ++- tests/posit/connect/test_vanities.py | 12 +- 12 files changed, 228 insertions(+), 142 deletions(-) diff --git a/src/posit/connect/_active.py b/src/posit/connect/_active.py index 994af1e5..bfd78513 100644 --- a/src/posit/connect/_active.py +++ b/src/posit/connect/_active.py @@ -19,7 +19,7 @@ ) from ._api_call import ApiCallMixin, ContextP, get_api -from ._json import Jsonifiable, JsonifiableDict, JsonifiableList, ResponseAttrs +from ._json import Jsonifiable, JsonifiableList, ResponseAttrs from ._types_context import ContextT # Design Notes: @@ -145,16 +145,16 @@ class ActiveDict(ApiCallMixin, ResourceDict[ContextT]): _path: str """The HTTP path component for the resource endpoint.""" - def _get_api( - self, - *path, - params: Optional[dict[str, object]] = None, - ) -> JsonifiableDict | None: - result: Jsonifiable = super()._get_api(*path, params=params) - if result is None: - return None - assert isinstance(result, dict), f"Expected dict from server, got {type(result)}" - return result + # def _get_api( + # self, + # *path, + # params: Optional[dict[str, object]] = None, + # ) -> Any | None: + # result: Jsonifiable = super()._get_api(*path, params=params) + # if result is None: + # return None + # assert isinstance(result, dict), f"Expected dict from server, got {type(result)}" + # return result def __init__( self, diff --git a/src/posit/connect/_content_repository.py b/src/posit/connect/_content_repository.py index 5bc776b6..01e5c4ab 100644 --- a/src/posit/connect/_content_repository.py +++ b/src/posit/connect/_content_repository.py @@ -7,7 +7,8 @@ from typing_extensions import NotRequired, TypedDict, Unpack -from ._active import ActiveDict, JsonifiableDict +from ._active import ActiveDict +from ._json import JsonifiableDict from ._types_content_item import ContentItemContext if TYPE_CHECKING: @@ -80,7 +81,7 @@ def _create( return ContentItemRepository( content_ctx, - **result, # pyright: ignore[reportCallIssue] + **result, ) def destroy(self) -> None: @@ -122,5 +123,5 @@ def update( result = self._patch_api(json=cast(JsonifiableDict, dict(attrs))) return ContentItemRepository( self._ctx, - **result, # pyright: ignore[reportCallIssue] + **result, ) diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index 80738db9..85a26f74 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -180,7 +180,7 @@ def me(self) -> User: User The currently authenticated user. """ - return me.get(self.resource_params) + return me.get(self._ctx) @property def groups(self) -> Groups: @@ -215,7 +215,7 @@ def users(self) -> Users: Users The users resource instance. """ - return Users(self.resource_params) + return Users(self._ctx) @property def content(self) -> Content: @@ -227,7 +227,7 @@ def content(self) -> Content: Content The content resource instance. """ - return Content(self.resource_params) + return Content(self._ctx) @property def metrics(self) -> Metrics: diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 82967869..0ffa9070 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -18,9 +18,11 @@ from typing_extensions import NotRequired, Required, TypedDict, Unpack from . import tasks +from ._api_call import ApiCallMixin from ._content_repository import ContentItemRepository from ._json import JsonifiableDict from ._types_content_item import ContentItemActiveDict, ContentItemContext, ContentItemResourceDict +from ._types_context import ContextP from ._utils import assert_guid from .bundles import Bundles from .context import Context @@ -30,7 +32,7 @@ from .oauth.associations import ContentItemAssociations from .packages import ContentPackagesMixin as PackagesMixin from .permissions import Permissions -from .resources import ResourceParameters, Resources, context_to_resource_parameters +from .resources import context_to_resource_parameters from .vanities import ContentItemVanityMixin from .variants import Variants @@ -103,7 +105,7 @@ class _AttrsCreate(_AttrsBase): def __init__( self, /, - params: ResourceParameters, + ctx: Context, guid: str, ) -> None: ... @@ -111,7 +113,7 @@ def __init__( def __init__( self, /, - params: ResourceParameters, + ctx: Context, guid: str, **kwargs: Unpack[ContentItem._Attrs], ) -> None: ... @@ -119,13 +121,13 @@ def __init__( def __init__( self, /, - params: ResourceParameters, + ctx: Context, guid: str, **kwargs: Unpack[ContentItem._AttrsNotRequired], ) -> None: assert_guid(guid) - ctx = ContentItemContext(Context(params.session, params.url), content_guid=guid) + ctx = ContentItemContext(ctx, content_guid=guid) path = f"v1/content/{guid}" get_data = len(kwargs) == 0 @@ -241,7 +243,7 @@ def restart(self) -> None: -------- >>> restart() """ - full_content_item: ContentItem = self.update() # pyright: ignore[reportCallIssue] + full_content_item = self.update() # pyright: ignore[reportCallIssue] if full_content_item.is_interactive: unix_epoch_in_seconds = str(int(time.time())) @@ -326,9 +328,9 @@ def update( assert isinstance(result, dict) assert "guid" in result new_content_item = ContentItem( - params=context_to_resource_parameters(self._ctx), + self._ctx, # `guid=` is contained within the `result` dict - **result, # pyright: ignore[reportArgumentType, reportCallIssue] + **result, ) # TODO-barret Update method returns new content item return new_content_item @@ -348,16 +350,14 @@ def permissions(self) -> Permissions: return Permissions(self._ctx) @property - def owner(self) -> dict: + def owner(self) -> User: if not hasattr(self, "_owner"): # It is possible to get a content item that does not contain owner. # "owner" is an optional additional request param. # If it's not included, we can retrieve the information by `owner_guid` from .users import Users - self._owner: User = Users(context_to_resource_parameters(self._ctx)).get( - self["owner_guid"] - ) + self._owner: User = Users(self._ctx).get(self["owner_guid"]) return self._owner @property @@ -390,7 +390,7 @@ def is_rendered(self) -> bool: } -class Content(Resources): +class Content(ApiCallMixin, ContextP[Context]): """Content resource. Parameters @@ -405,11 +405,13 @@ class Content(Resources): def __init__( self, - params: ResourceParameters, + ctx: Context, *, owner_guid: str | None = None, ) -> None: - super().__init__(params) + super().__init__() + self._ctx = ctx + self._path = "v1/content" self.owner_guid = owner_guid def count(self) -> int: @@ -485,9 +487,9 @@ def create( ContentItem """ path = "v1/content" - url = self.params.url + path - response = self.params.session.post(url, json=attrs) - return ContentItem(self.params, **response.json()) + url = self._ctx.url + path + response = self._ctx.session.post(url, json=attrs) + return ContentItem(self._ctx, **response.json()) @overload def find( @@ -573,11 +575,11 @@ def find(self, include: Optional[str | list[Any]] = None, **conditions) -> List[ conditions["owner_guid"] = self.owner_guid path = "v1/content" - url = self.params.url + path - response = self.params.session.get(url, params=conditions) + url = self._ctx.url + path + response = self._ctx.session.get(url, params=conditions) return [ ContentItem( - self.params, + self._ctx, **result, ) for result in response.json() @@ -746,6 +748,6 @@ def get(self, guid: str) -> ContentItem: ContentItem """ path = f"v1/content/{guid}" - url = self.params.url + path - response = self.params.session.get(url) - return ContentItem(self.params, **response.json()) + url = self._ctx.url + path + response = self._ctx.session.get(url) + return ContentItem(self._ctx, **response.json()) diff --git a/src/posit/connect/me.py b/src/posit/connect/me.py index a32d7c63..ee795724 100644 --- a/src/posit/connect/me.py +++ b/src/posit/connect/me.py @@ -1,9 +1,8 @@ -from posit.connect.resources import ResourceParameters - +from .context import Context from .users import User -def get(params: ResourceParameters) -> User: +def get(ctx: Context) -> User: """ Gets the current user. @@ -15,6 +14,6 @@ def get(params: ResourceParameters) -> User: ------- User: The current user. """ - url = params.url + "v1/user" - response = params.session.get(url) - return User(params, **response.json()) + url = ctx.url + "v1/user" + response = ctx.session.get(url) + return User(ctx, **response.json()) diff --git a/src/posit/connect/oauth/integrations.py b/src/posit/connect/oauth/integrations.py index dc583a33..085eedd6 100644 --- a/src/posit/connect/oauth/integrations.py +++ b/src/posit/connect/oauth/integrations.py @@ -60,7 +60,7 @@ def update( elements from both options and fields from a given template. """ result = self._patch_api(json=kwargs) - return Integration(self._ctx, **result) # pyright: ignore[reportCallIssue] + return Integration(self._ctx, **result) # TODO-barret; Should this auto retrieve? If so, it should inherit from ActiveSequence @@ -123,7 +123,7 @@ def create(self, **kwargs) -> Integration: Integration """ result = self._post_api(json=kwargs) - return Integration(self._ctx, **result) # pyright: ignore[reportCallIssue] + return Integration(self._ctx, **result) def find(self) -> List[Integration]: """Find OAuth integrations. @@ -155,4 +155,4 @@ def get(self, guid: str) -> Integration: Integration """ result = self._get_api(guid) - return Integration(self._ctx, **result) # pyright: ignore[reportCallIssue] + return Integration(self._ctx, **result) diff --git a/src/posit/connect/permissions.py b/src/posit/connect/permissions.py index 79928e72..62d428a7 100644 --- a/src/posit/connect/permissions.py +++ b/src/posit/connect/permissions.py @@ -66,7 +66,7 @@ def update(self, *args, **kwargs) -> Permission: body.update(dict(*args)) body.update(**kwargs) result = self._put_api(json=body) - return Permission(self._ctx, **result) # pyright: ignore[reportCallIssue] + return Permission(self._ctx, **result) class Permissions(ContextP[ContentItemContext]): diff --git a/src/posit/connect/tasks.py b/src/posit/connect/tasks.py index 4c63e674..c80cdb54 100644 --- a/src/posit/connect/tasks.py +++ b/src/posit/connect/tasks.py @@ -166,9 +166,9 @@ def update(self, /, **kwargs) -> Task: ] """ result = self._get_api(params=kwargs) - new_task = Task( # pyright: ignore[reportCallIssue] + new_task = Task( self._ctx, - **result, # pyright: ignore[reportArgumentType] + **result, ) return new_task diff --git a/src/posit/connect/users.py b/src/posit/connect/users.py index 80a5cab5..90f97276 100644 --- a/src/posit/connect/users.py +++ b/src/posit/connect/users.py @@ -2,50 +2,129 @@ from __future__ import annotations -from typing import List, Literal +from typing import List, Literal, overload from typing_extensions import NotRequired, Required, TypedDict, Unpack from . import me +from ._active import ActiveDict +from ._api_call import ApiCallMixin +from ._types_context import ContextP from .content import Content +from .context import Context from .paginator import Paginator -from .resources import Resource, ResourceParameters, Resources - -# TODO-barret-future; Separate PR for updating User to ActiveDict class - -# from typing import cast -# from ._active import ActiveDict -# from ._json import JsonifiableDict -# from .context import Context -# from .resources import context_to_resource_parameters -# @classmethod -# def _api_path(cls) -> str: -# return "v1/users" - -# @classmethod -# def _create( -# cls, -# ctx: Context, -# /, -# **attrs: Unpack[ContentItemRepository._Attrs], -# ) -> User: -# from ._api_call import put_api - -# # todo - use the 'context' module to inspect the 'authentication' object and route to POST (local) or PUT (remote). -# result = put_api(ctx, cls._api_path(), json=cast(JsonifiableDict, attrs)) - -# return User( -# ctx, -# **result, # pyright: ignore[reportCallIssue] -# ) - - -class User(Resource): + + +class UserContext(Context): + user_guid: str + + def __init__(self, ctx: Context, /, *, user_guid: str) -> None: + super().__init__(ctx.session, ctx.url) + self.user_guid = user_guid + + +class User(ActiveDict[UserContext]): + # @classmethod + # def _api_path(cls) -> str: + # return "v1/users" + + # @classmethod + # def _create( + # cls, + # ctx: Context, + # /, + # # **attrs: Unpack[ContentItemRepository._Attrs], + # **attrs, + # ) -> User: + # from ._api_call import put_api + + # # todo - use the 'context' module to inspect the 'authentication' object and route to POST (local) or PUT (remote). + # result = put_api(ctx, cls._api_path(), json=cast(JsonifiableDict, attrs)) + + # return User( + # ctx, + # **result, + # ) + + class _Attrs(TypedDict, total=False): + guid: str + """The user's GUID, or unique identifier, in UUID [RFC4122](https://www.rfc-editor.org/rfc/rfc4122) format""" + email: str + """The user's email""" + username: str + """The user's username""" + first_name: str + """The user's first name""" + last_name: str + """The user's last name""" + user_role: Literal["administrator", "publisher", "viewer"] + """The user's role""" + created_time: str + """ + Timestamp (in [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339) format) indicating when + the user was created in the Posit Connect server. + """ + updated_time: str + """ + Timestamp (in [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339) format) indicating when + information about this user was last updated in the Posit Connect server. + """ + active_time: str + """ + Timestamp (in [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339) format) indicating + approximately when the user was last active. Highly active users only receive periodic updates. + """ + confirmed: bool + """ + When `false`, the created user must confirm their account through an email. This feature is unique to password authentication. + """ + locked: bool + """Whether or not the user is locked""" + + @overload + def __init__(self, ctx: Context, /, *, guid: str) -> None: ... + + @overload + def __init__( + self, + ctx: Context, + /, + # By default, the `attrs` will be retrieved from the API if no `attrs` are supplied. + **attrs: Unpack[_Attrs], + ) -> None: ... + def __init__( + self, + ctx: Context, + /, + **attrs: Unpack[_Attrs], + ) -> None: + """User resource. + + Parameters + ---------- + ctx : Context + The context object containing the session and URL for API interactions. + guid : str + The GUID of the user + **attrs : ActiveDict + Attributes for the user. If not supplied, the attributes will be + retrieved from the API upon initialization + """ + user_guid = attrs.get("guid") + assert isinstance(user_guid, str), f"User `guid` must be a string. Got: {user_guid}" + assert user_guid, "User `guid` must not be empty." + + user_ctx = UserContext(ctx, user_guid=user_guid) + path = f"v1/users/{user_guid}" + # Only fetch data if `guid` is the only attr + get_data = len(attrs) == 1 + super().__init__(user_ctx, path, get_data, **attrs) + @property def content(self) -> Content: - return Content(self.params, owner_guid=self["guid"]) + return Content(self._ctx, owner_guid=self["guid"]) - def lock(self, *, force: bool = False): + def lock(self, *, force: bool = False) -> User: """ Lock the user account. @@ -70,15 +149,19 @@ def lock(self, *, force: bool = False): >>> user.lock(force=True) """ - _me = me.get(self.params) + _me = me.get(self._ctx) if _me["guid"] == self["guid"] and not force: raise RuntimeError( "You cannot lock your own account. Set force=True to override this behavior.", ) - url = self.params.url + f"v1/users/{self['guid']}/lock" - body = {"locked": True} - self.params.session.post(url, json=body) - super().update(locked=True) + # Ignore result + self._post_api("lock", json={"locked": True}) + + # Return updated user + attrs = dict(self) + attrs["locked"] = True + + return User(self._ctx, **attrs) def unlock(self): """ @@ -96,12 +179,16 @@ def unlock(self): >>> user.unlock() """ - url = self.params.url + f"v1/users/{self['guid']}/lock" - body = {"locked": False} - self.params.session.post(url, json=body) - super().update(locked=False) + # Ignore result + self._post_api("lock", json={"locked": False}) + + # Return updated user + attrs = dict(self) + attrs["locked"] = False - class _UpdateUser(TypedDict): + return User(self._ctx, **attrs) + + class _UpdateUser(TypedDict, total=False): """Update user request.""" email: NotRequired[str] @@ -113,7 +200,7 @@ class _UpdateUser(TypedDict): def update( self, **kwargs: Unpack[_UpdateUser], - ) -> None: + ) -> User: """ Update the user's attributes. @@ -144,16 +231,18 @@ def update( >>> user.update(first_name="Jane", last_name="Smith") """ - url = self.params.url + f"v1/users/{self['guid']}" - response = self.params.session.put(url, json=kwargs) - super().update(**response.json()) + result = self._put_api(json=kwargs) + + return User(self._ctx, **result) -class Users(Resources): +class Users(ApiCallMixin, ContextP[Context]): """Users resource.""" - def __init__(self, params: ResourceParameters) -> None: - super().__init__(params) + def __init__(self, ctx: Context) -> None: + super().__init__() + self._ctx = ctx + self._path = "v1/users" class _CreateUser(TypedDict): """Create user request.""" @@ -225,9 +314,8 @@ def create(self, **attributes: Unpack[_CreateUser]) -> User: ... ) """ # todo - use the 'context' module to inspect the 'authentication' object and route to POST (local) or PUT (remote). - url = self.params.url + "v1/users" - response = self.params.session.post(url, json=attributes) - return User(self.params, **response.json()) + result = self._post_api(json=attributes) + return User(self._ctx, **result) class _FindUser(TypedDict): """Find user request.""" @@ -268,12 +356,12 @@ def find(self, **conditions: Unpack[_FindUser]) -> List[User]: >>> users = client.find(account_status="locked|licensed") """ - url = self.params.url + "v1/users" - paginator = Paginator(self.params.session, url, params={**conditions}) + url = self._ctx.url + "v1/users" + paginator = Paginator(self._ctx.session, url, params={**conditions}) results = paginator.fetch_results() return [ User( - self.params, + self._ctx, **user, ) for user in results @@ -311,13 +399,13 @@ def find_one(self, **conditions: Unpack[_FindUser]) -> User | None: >>> user = client.find_one(account_status="locked|licensed") """ - url = self.params.url + "v1/users" - paginator = Paginator(self.params.session, url, params={**conditions}) + url = self._ctx.url + self._path + paginator = Paginator(self._ctx.session, url, params={**conditions}) pages = paginator.fetch_pages() results = (result for page in pages for result in page.results) users = ( User( - self.params, + self._ctx, **result, ) for result in results @@ -341,11 +429,10 @@ def get(self, uid: str) -> User: -------- >>> user = client.get("123e4567-e89b-12d3-a456-426614174000") """ - url = self.params.url + f"v1/users/{uid}" - response = self.params.session.get(url) + result = self._get_api(uid) return User( - self.params, - **response.json(), + self._ctx, + **result, ) def count(self) -> int: @@ -356,7 +443,5 @@ def count(self) -> int: ------- int """ - url = self.params.url + "v1/users" - response = self.params.session.get(url, params={"page_size": 1}) - result: dict = response.json() + result: dict = self._get_api(params={"page_size": 1}) return result["total"] diff --git a/tests/posit/connect/test_content.py b/tests/posit/connect/test_content.py index 53ffb923..a3155349 100644 --- a/tests/posit/connect/test_content.py +++ b/tests/posit/connect/test_content.py @@ -7,7 +7,6 @@ from posit.connect.client import Client from posit.connect.content import ContentItem, ContentItemRepository from posit.connect.context import Context -from posit.connect.resources import ResourceParameters from posit.connect.urls import Url from .api import load_mock, load_mock_dict @@ -564,7 +563,7 @@ def content_guid(self): @property def content_item(self): return ContentItem( - self.params, + self.ctx, guid=self.content_guid, name="testing", # provide name to avoid request ) @@ -580,10 +579,6 @@ def ctx(self): content_guid=self.content_guid, ) - @property - def params(self): - return ResourceParameters(self.ctx.session, self.ctx.url) - def mock_repository_info(self): content_item = self.content_item diff --git a/tests/posit/connect/test_users.py b/tests/posit/connect/test_users.py index 68adbb3b..4905ca74 100644 --- a/tests/posit/connect/test_users.py +++ b/tests/posit/connect/test_users.py @@ -67,9 +67,10 @@ def test_lock(self): responses.post( "https://connect.example/__api__/v1/users/a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6/lock", match=[responses.matchers.json_params_matcher({"locked": True})], + json={}, ) - user.lock() - assert user["locked"] + locked_user = user.lock() + assert locked_user["locked"] @responses.activate def test_lock_self_true(self): @@ -88,9 +89,10 @@ def test_lock_self_true(self): responses.post( "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4/lock", match=[responses.matchers.json_params_matcher({"locked": True})], + json={}, ) - user.lock(force=True) - assert user["locked"] + unlocked_user = user.lock(force=True) + assert unlocked_user["locked"] @responses.activate def test_lock_self_false(self): @@ -129,9 +131,10 @@ def test_unlock(self): responses.post( "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4/lock", match=[responses.matchers.json_params_matcher({"locked": False})], + json={}, ) - user.unlock() - assert not user["locked"] + unlocked_user = user.unlock() + assert not unlocked_user["locked"] class TestUsers: @@ -173,7 +176,7 @@ def test_user_update(self): patch_request = responses.put( "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4", match=[responses.matchers.json_params_matcher({"first_name": "Carlitos"})], - json={"first_name": "Carlitos"}, + json={"first_name": "Carlitos", "guid": "20a79ce3-6e87-4522-9faf-be24228800a4"}, ) con = Client(api_key="12345", url="https://connect.example/") @@ -182,10 +185,11 @@ def test_user_update(self): assert patch_request.call_count == 0 assert carlos["first_name"] == "Carlos" - carlos.update(first_name="Carlitos") + carlitos = carlos.update(first_name="Carlitos") assert patch_request.call_count == 1 - assert carlos["first_name"] == "Carlitos" + assert carlos["first_name"] == "Carlos" + assert carlitos["first_name"] == "Carlitos" @responses.activate def test_user_update_server_error(self): diff --git a/tests/posit/connect/test_vanities.py b/tests/posit/connect/test_vanities.py index f1bbd985..0a65e7d9 100644 --- a/tests/posit/connect/test_vanities.py +++ b/tests/posit/connect/test_vanities.py @@ -79,9 +79,9 @@ def test_vanity_getter_returns_vanity(self): session = requests.Session() url = Url(base_url) - params = ResourceParameters(session, url) + ctx = Context(session, url) content = ContentItem( - params, + ctx, guid=guid, name="testing", # provide name to avoid request ) @@ -103,9 +103,9 @@ def test_vanity_setter_with_string(self): session = requests.Session() url = Url(base_url) - params = ResourceParameters(session, url) + ctx = Context(session, url) content = ContentItem( - params=params, + ctx, guid=guid, name="testing", # provide name to avoid request ) @@ -123,9 +123,9 @@ def test_vanity_deleter(self): session = requests.Session() url = Url(base_url) - params = ResourceParameters(session, url) + ctx = Context(session, url) content = ContentItem( - params=params, + ctx, guid=guid, name="testing", # provide name to avoid request ) From 7fd92827bbf921638db937e43c6ba7215a85d9e3 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 03:15:14 -0500 Subject: [PATCH 39/47] Try using a tuple for base class for better python 3.8 support --- src/posit/connect/_active.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/posit/connect/_active.py b/src/posit/connect/_active.py index bfd78513..052c3aba 100644 --- a/src/posit/connect/_active.py +++ b/src/posit/connect/_active.py @@ -6,12 +6,12 @@ import posixpath from abc import ABC, abstractmethod from collections.abc import Mapping as Mapping_abc -from collections.abc import Sequence as Sequence_abc from typing import ( Any, Generator, Iterator, Optional, + SupportsIndex, Tuple, TypeVar, cast, @@ -198,7 +198,7 @@ def __init__( self._path = path -class ReadOnlySequence(Sequence_abc[ResourceDictT]): +class ReadOnlySequence(Tuple[ResourceDictT, ...]): """Read only Sequence.""" _data: Tuple[ResourceDictT, ...] @@ -222,13 +222,17 @@ def __len__(self) -> int: return len(tuple(self._data)) @overload - def __getitem__(self, index: int) -> ResourceDictT: ... + def __getitem__(self, key: SupportsIndex, /) -> ResourceDictT: ... @overload - def __getitem__(self, index: slice) -> Tuple[ResourceDictT, ...]: ... + def __getitem__(self, key: slice, /) -> tuple[ResourceDictT, ...]: ... - def __getitem__(self, index: int | slice) -> ResourceDictT | Tuple[ResourceDictT, ...]: - return self._data[index] + def __getitem__( + self, + key: SupportsIndex | slice, + /, + ) -> ResourceDictT | tuple[ResourceDictT, ...]: + return self._data[key] def __iter__(self) -> Iterator[ResourceDictT]: return iter(self._data) From 0d3e9ae82b4efb75c689cfae704b47af74d2c6ad Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 03:35:27 -0500 Subject: [PATCH 40/47] Fix json errors in integration tests --- src/posit/connect/_api_call.py | 10 +++++++--- src/posit/connect/oauth/integrations.py | 1 + src/posit/connect/permissions.py | 1 + src/posit/connect/users.py | 2 ++ tests/posit/connect/test_users.py | 3 --- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/posit/connect/_api_call.py b/src/posit/connect/_api_call.py index 5d652a28..fb40352d 100644 --- a/src/posit/connect/_api_call.py +++ b/src/posit/connect/_api_call.py @@ -16,7 +16,7 @@ def _endpoint(self, *path) -> str: ... def _get_api(self, *path) -> Any: ... def _delete_api(self, *path) -> Any | None: ... def _patch_api(self, *path, json: Any | None) -> Any: ... - def _post_api(self, *path, json: Any | None) -> Any: ... + def _post_api(self, *path, json: Any | None) -> Any | None: ... def _put_api(self, *path, json: Any | None) -> Any: ... @@ -68,14 +68,18 @@ def _post_api( self: ApiCallProtocol, *path, json: Any | None, - ) -> Any: + ) -> Any | None: response = self._ctx.session.post(self._endpoint(*path), json=json) + if len(response.content) == 0: + return None return response.json() def _put_api( self: ApiCallProtocol, *path, json: Any | None, - ) -> Any: + ) -> Any | None: response = self._ctx.session.put(self._endpoint(*path), json=json) + if len(response.content) == 0: + return None return response.json() diff --git a/src/posit/connect/oauth/integrations.py b/src/posit/connect/oauth/integrations.py index 085eedd6..0aabc8ab 100644 --- a/src/posit/connect/oauth/integrations.py +++ b/src/posit/connect/oauth/integrations.py @@ -123,6 +123,7 @@ def create(self, **kwargs) -> Integration: Integration """ result = self._post_api(json=kwargs) + assert result is not None, "Integration creation failed" return Integration(self._ctx, **result) def find(self) -> List[Integration]: diff --git a/src/posit/connect/permissions.py b/src/posit/connect/permissions.py index 62d428a7..e873d66d 100644 --- a/src/posit/connect/permissions.py +++ b/src/posit/connect/permissions.py @@ -66,6 +66,7 @@ def update(self, *args, **kwargs) -> Permission: body.update(dict(*args)) body.update(**kwargs) result = self._put_api(json=body) + assert result is not None, "Permission update failed." return Permission(self._ctx, **result) diff --git a/src/posit/connect/users.py b/src/posit/connect/users.py index 90f97276..94b0ffb6 100644 --- a/src/posit/connect/users.py +++ b/src/posit/connect/users.py @@ -232,6 +232,7 @@ def update( >>> user.update(first_name="Jane", last_name="Smith") """ result = self._put_api(json=kwargs) + assert result is not None, "User update failed." return User(self._ctx, **result) @@ -315,6 +316,7 @@ def create(self, **attributes: Unpack[_CreateUser]) -> User: """ # todo - use the 'context' module to inspect the 'authentication' object and route to POST (local) or PUT (remote). result = self._post_api(json=attributes) + assert result is not None, "User creation failed." return User(self._ctx, **result) class _FindUser(TypedDict): diff --git a/tests/posit/connect/test_users.py b/tests/posit/connect/test_users.py index 4905ca74..a2c55727 100644 --- a/tests/posit/connect/test_users.py +++ b/tests/posit/connect/test_users.py @@ -67,7 +67,6 @@ def test_lock(self): responses.post( "https://connect.example/__api__/v1/users/a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6/lock", match=[responses.matchers.json_params_matcher({"locked": True})], - json={}, ) locked_user = user.lock() assert locked_user["locked"] @@ -89,7 +88,6 @@ def test_lock_self_true(self): responses.post( "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4/lock", match=[responses.matchers.json_params_matcher({"locked": True})], - json={}, ) unlocked_user = user.lock(force=True) assert unlocked_user["locked"] @@ -131,7 +129,6 @@ def test_unlock(self): responses.post( "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4/lock", match=[responses.matchers.json_params_matcher({"locked": False})], - json={}, ) unlocked_user = user.unlock() assert not unlocked_user["locked"] From 890ffd13628de1c22a8f197c7bcc4ecb0e6df64e Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 03:39:42 -0500 Subject: [PATCH 41/47] Update Vanities --- src/posit/connect/client.py | 2 +- src/posit/connect/vanities.py | 28 +++++++++++++++------------- tests/posit/connect/test_vanities.py | 5 ++--- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index 85a26f74..e1ed577e 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -277,7 +277,7 @@ def packages(self) -> Packages: @property def vanities(self) -> Vanities: - return Vanities(self.resource_params) + return Vanities(self._ctx) def __del__(self): """Close the session when the Client instance is deleted.""" diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index 22967c14..c91bd2ed 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -4,9 +4,12 @@ from typing_extensions import NotRequired, Required, TypedDict, Unpack +from posit.connect._api_call import ApiCallMixin +from posit.connect._types_context import ContextP +from posit.connect.context import Context + from ._types_content_item import ContentItemActiveDict, ContentItemContext, ContentItemP from .errors import ClientError -from .resources import Resources, resource_parameters_to_content_item_context class Vanity(ContentItemActiveDict): @@ -96,9 +99,14 @@ def destroy(self) -> None: self._after_destroy() -class Vanities(Resources): +class Vanities(ApiCallMixin, ContextP[Context]): """Manages a collection of vanities.""" + def __init__(self, ctx: Context) -> None: + super().__init__() + self._ctx = ctx + self._path = "v1/vanities" + def all(self) -> list[Vanity]: """Retrieve all vanities. @@ -110,23 +118,17 @@ def all(self) -> list[Vanity]: ----- This action requires administrator privileges. """ - endpoint = self.params.url + "v1/vanities" - response = self.params.session.get(endpoint) + endpoint = self._ctx.url + "v1/vanities" + response = self._ctx.session.get(endpoint) results = response.json() ret: list[Vanity] = [] for result in results: assert isinstance(result, dict) assert "content_guid" in result - ret.append( - Vanity( - resource_parameters_to_content_item_context( - self.params, - content_guid=result["content_guid"], - ), - **result, - ) - ) + content_item_ctx = ContentItemContext(self._ctx, content_guid=result["content_guid"]) + + ret.append(Vanity(content_item_ctx, **result)) return ret diff --git a/tests/posit/connect/test_vanities.py b/tests/posit/connect/test_vanities.py index 0a65e7d9..843caaad 100644 --- a/tests/posit/connect/test_vanities.py +++ b/tests/posit/connect/test_vanities.py @@ -7,7 +7,6 @@ from posit.connect._types_content_item import ContentItemContext from posit.connect.content import ContentItem from posit.connect.context import Context -from posit.connect.resources import ResourceParameters from posit.connect.urls import Url from posit.connect.vanities import Vanities, Vanity @@ -61,8 +60,8 @@ def test_all_sends_get_request(self): session = requests.Session() url = Url(base_url) - params = ResourceParameters(session, url) - vanities = Vanities(params) + ctx = Context(session, url) + vanities = Vanities(ctx) vanities.all() From 42580b9f5aba0c4de5297d22e1933e024542796a Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 04:04:29 -0500 Subject: [PATCH 42/47] Update Bundles --- src/posit/connect/_api_call.py | 28 ++++++++-- src/posit/connect/bundles.py | 97 ++++++++++++++++++++-------------- src/posit/connect/content.py | 2 +- src/posit/connect/vanities.py | 7 ++- 4 files changed, 85 insertions(+), 49 deletions(-) diff --git a/src/posit/connect/_api_call.py b/src/posit/connect/_api_call.py index fb40352d..7137cf14 100644 --- a/src/posit/connect/_api_call.py +++ b/src/posit/connect/_api_call.py @@ -6,6 +6,8 @@ from ._types_context import ContextP if TYPE_CHECKING: + from requests import Response + from .context import Context @@ -16,7 +18,7 @@ def _endpoint(self, *path) -> str: ... def _get_api(self, *path) -> Any: ... def _delete_api(self, *path) -> Any | None: ... def _patch_api(self, *path, json: Any | None) -> Any: ... - def _post_api(self, *path, json: Any | None) -> Any | None: ... + def _post_api(self, *path, json: Any | None, data: Any | None) -> Any | None: ... def _put_api(self, *path, json: Any | None) -> Any: ... @@ -30,6 +32,10 @@ def get_api(ctx: Context, *path) -> Any: return response.json() +def get_api_stream(ctx: Context, *path) -> Response: + return ctx.session.get(endpoint(ctx, *path), stream=True) + + def put_api( ctx: Context, *path, @@ -39,6 +45,17 @@ def put_api( return response.json() +def post_api( + ctx: Context, + *path, + json: Any | None, +) -> Any | None: + response = ctx.session.post(endpoint(ctx, *path), json=json) + if len(response.content) == 0: + return None + return response.json() + + # Mixin class for API interactions @@ -59,7 +76,7 @@ def _delete_api(self: ApiCallProtocol, *path) -> Any | None: def _patch_api( self: ApiCallProtocol, *path, - json: Any | None, + json: Any | None = None, ) -> Any: response = self._ctx.session.patch(self._endpoint(*path), json=json) return response.json() @@ -67,9 +84,10 @@ def _patch_api( def _post_api( self: ApiCallProtocol, *path, - json: Any | None, + json: Any | None = None, + data: Any | None = None, ) -> Any | None: - response = self._ctx.session.post(self._endpoint(*path), json=json) + response = self._ctx.session.post(self._endpoint(*path), json=json, data=data) if len(response.content) == 0: return None return response.json() @@ -77,7 +95,7 @@ def _post_api( def _put_api( self: ApiCallProtocol, *path, - json: Any | None, + json: Any | None = None, ) -> Any | None: response = self._ctx.session.put(self._endpoint(*path), json=json) if len(response.content) == 0: diff --git a/src/posit/connect/bundles.py b/src/posit/connect/bundles.py index a9e1f37e..7a7fbf29 100644 --- a/src/posit/connect/bundles.py +++ b/src/posit/connect/bundles.py @@ -5,35 +5,59 @@ import io from typing import List -from . import resources, tasks -from ._active import ReadOnlyDict -from .resources import resource_parameters_to_content_item_context +from posit.connect._types_context import ContextP + +from ._active import ActiveDict, ReadOnlyDict +from ._api_call import ApiCallMixin, get_api_stream, post_api +from ._types_content_item import ContentItemContext +from .tasks import Task, Tasks class BundleMetadata(ReadOnlyDict): pass -# TODO-barret Inherit from `ActiveDict` -class Bundle(resources.Resource): +class BundleContext(ContentItemContext): + bundle_id: str + + def __init__( + self, + ctx: ContentItemContext, + /, + *, + bundle_id: str, + ) -> None: + super().__init__(ctx, content_guid=ctx.content_guid) + self.bundle_id = bundle_id + + +class Bundle(ActiveDict[BundleContext]): + def __init__(self, ctx: ContentItemContext, /, **kwargs) -> None: + bundle_id = kwargs.get("id") + assert isinstance(bundle_id, str), f"Bundle 'id' must be a string. Got: {id}" + assert bundle_id, "Bundle 'id' must not be an empty string." + + bundle_ctx = BundleContext(ctx, bundle_id=bundle_id) + path = f"v1/content/{ctx.content_guid}/bundles/{bundle_id}" + get_data = len(kwargs) == 1 # `id` is required + super().__init__(bundle_ctx, path, get_data, **kwargs) + @property def metadata(self) -> BundleMetadata: return BundleMetadata(**self.get("metadata", {})) def delete(self) -> None: """Delete the bundle.""" - path = f"v1/content/{self['content_guid']}/bundles/{self['id']}" - url = self.params.url + path - self.params.session.delete(url) + self._delete_api() - def deploy(self) -> tasks.Task: + def deploy(self) -> Task: """Deploy the bundle. Spawns an asynchronous task, which activates the bundle. Returns ------- - tasks.Task + Task The task for the deployment. Examples @@ -41,13 +65,15 @@ def deploy(self) -> tasks.Task: >>> task = bundle.deploy() >>> task.wait_for() """ - path = f"v1/content/{self['content_guid']}/deploy" - url = self.params.url + path - response = self.params.session.post(url, json={"bundle_id": self["id"]}) - result = response.json() - ts = tasks.Tasks( - resource_parameters_to_content_item_context(self.params, self["content_guid"]) + result = post_api( + self._ctx, + self._ctx.content_path, + "deploy", + json={"bundle_id": self["id"]}, ) + assert isinstance(result, dict), f"Deploy response must be a dict. Got: {result}" + assert "task_id" in result, f"Task ID not found in response: {result}" + ts = Tasks(self._ctx) return ts.get(result["task_id"]) def download(self, output: io.BufferedWriter | str) -> None: @@ -81,9 +107,9 @@ def download(self, output: io.BufferedWriter | str) -> None: f"download() expected argument type 'io.BufferedWriter` or 'str', but got '{type(output).__name__}'", ) - path = f"v1/content/{self['content_guid']}/bundles/{self['id']}/download" - url = self.params.url + path - response = self.params.session.get(url, stream=True) + response = get_api_stream( + self._ctx, self._ctx.content_path, "bundles", self._ctx.bundle_id, "download" + ) if isinstance(output, io.BufferedWriter): for chunk in response.iter_content(): output.write(chunk) @@ -93,7 +119,7 @@ def download(self, output: io.BufferedWriter | str) -> None: file.write(chunk) -class Bundles(resources.Resources): +class Bundles(ApiCallMixin, ContextP[ContentItemContext]): """Bundles resource. Parameters @@ -113,11 +139,11 @@ class Bundles(resources.Resources): def __init__( self, - params: resources.ResourceParameters, - content_guid: str, + ctx: ContentItemContext, ) -> None: - super().__init__(params) - self.content_guid = content_guid + super().__init__() + self._ctx = ctx + self._path = f"v1/content/{ctx.content_guid}/bundles" def create(self, archive: io.BufferedReader | bytes | str) -> Bundle: """ @@ -167,11 +193,10 @@ def create(self, archive: io.BufferedReader | bytes | str) -> Bundle: f"create() expected argument type 'io.BufferedReader', 'bytes', or 'str', but got '{type(archive).__name__}'", ) - path = f"v1/content/{self.content_guid}/bundles" - url = self.params.url + path - response = self.params.session.post(url, data=data) - result = response.json() - return Bundle(self.params, **result) + result = self._post_api(data=data) + assert result is not None, "Bundle creation failed" + + return Bundle(self._ctx, **result) def find(self) -> List[Bundle]: """Find all bundles. @@ -181,11 +206,8 @@ def find(self) -> List[Bundle]: list of Bundle List of all found bundles. """ - path = f"v1/content/{self.content_guid}/bundles" - url = self.params.url + path - response = self.params.session.get(url) - results = response.json() - return [Bundle(self.params, **result) for result in results] + results = self._get_api() + return [Bundle(self._ctx, **result) for result in results] def find_one(self) -> Bundle | None: """Find a bundle. @@ -211,8 +233,5 @@ def get(self, uid: str) -> Bundle: Bundle The bundle with the specified ID. """ - path = f"v1/content/{self.content_guid}/bundles/{uid}" - url = self.params.url + path - response = self.params.session.get(url) - result = response.json() - return Bundle(self.params, **result) + result = self._get_api(uid) + return Bundle(self._ctx, **result) diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 0ffa9070..88cf8d88 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -339,7 +339,7 @@ def update( @property def bundles(self) -> Bundles: - return Bundles(context_to_resource_parameters(self._ctx), self["guid"]) + return Bundles(self._ctx) @property def environment_variables(self) -> EnvVars: diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index c91bd2ed..866a92a6 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -4,11 +4,10 @@ from typing_extensions import NotRequired, Required, TypedDict, Unpack -from posit.connect._api_call import ApiCallMixin -from posit.connect._types_context import ContextP -from posit.connect.context import Context - +from ._api_call import ApiCallMixin from ._types_content_item import ContentItemActiveDict, ContentItemContext, ContentItemP +from ._types_context import ContextP +from .context import Context from .errors import ClientError From 61eb20e6dd96fc80b8a469c3f733df04b7cef154 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 04:09:55 -0500 Subject: [PATCH 43/47] Update OAuth --- src/posit/connect/bundles.py | 3 +-- src/posit/connect/client.py | 2 +- src/posit/connect/oauth/oauth.py | 20 ++++++++++++-------- src/posit/connect/resources.py | 10 ---------- 4 files changed, 14 insertions(+), 21 deletions(-) diff --git a/src/posit/connect/bundles.py b/src/posit/connect/bundles.py index 7a7fbf29..50c2baf0 100644 --- a/src/posit/connect/bundles.py +++ b/src/posit/connect/bundles.py @@ -5,11 +5,10 @@ import io from typing import List -from posit.connect._types_context import ContextP - from ._active import ActiveDict, ReadOnlyDict from ._api_call import ApiCallMixin, get_api_stream, post_api from ._types_content_item import ContentItemContext +from ._types_context import ContextP from .tasks import Task, Tasks diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index e1ed577e..f3069a59 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -268,7 +268,7 @@ def oauth(self) -> OAuth: OAuth The oauth API instance. """ - return OAuth(self.resource_params, self.cfg.api_key) + return OAuth(self._ctx, self.cfg.api_key) @property @requires(version="2024.10.0-dev") diff --git a/src/posit/connect/oauth/oauth.py b/src/posit/connect/oauth/oauth.py index 6784db89..32341414 100644 --- a/src/posit/connect/oauth/oauth.py +++ b/src/posit/connect/oauth/oauth.py @@ -4,26 +4,30 @@ from typing_extensions import TypedDict -from ..resources import ResourceParameters, Resources, resource_parameters_to_context +from .._types_context import ContextP +from ..context import Context from .integrations import Integrations from .sessions import Sessions -class OAuth(Resources): - def __init__(self, params: ResourceParameters, api_key: str) -> None: - super().__init__(params) +class OAuth(ContextP[Context]): + def __init__(self, ctx: Context, api_key: str) -> None: + super().__init__() + self._ctx = ctx + + # TODO-barret-q: Is this used? self.api_key = api_key @property def integrations(self): - return Integrations(resource_parameters_to_context(self.params)) + return Integrations(self._ctx) @property def sessions(self): - return Sessions(resource_parameters_to_context(self.params)) + return Sessions(self._ctx) def get_credentials(self, user_session_token: Optional[str] = None) -> Credentials: - url = self.params.url + "v1/oauth/integrations/credentials" + url = self._ctx.url + "v1/oauth/integrations/credentials" # craft a credential exchange request data = {} @@ -32,7 +36,7 @@ def get_credentials(self, user_session_token: Optional[str] = None) -> Credentia if user_session_token: data["subject_token"] = user_session_token - response = self.params.session.post(url, data=data) + response = self._ctx.session.post(url, data=data) return Credentials(**response.json()) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index e99c4926..160a579a 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -16,7 +16,6 @@ overload, ) -from ._types_content_item import ContentItemContext from .context import Context if TYPE_CHECKING: @@ -53,15 +52,6 @@ def resource_parameters_to_context(params: ResourceParameters) -> Context: return Context(params.session, params.url) -def resource_parameters_to_content_item_context( - params: ResourceParameters, - content_guid: str, -) -> ContentItemContext: - """Temp method to aid in transitioning from `ResourceParameters` to `Context`.""" - ctx = Context(params.session, params.url) - return ContentItemContext(ctx, content_guid=content_guid) - - class Resource(dict): def __init__(self, /, params: ResourceParameters, **kwargs): self.params = params From 04eb3fac23e665484bb453fb10614cdae0422a50 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 04:19:41 -0500 Subject: [PATCH 44/47] Update EnvVars --- src/posit/connect/_api_call.py | 8 ++++--- src/posit/connect/_content_repository.py | 1 + src/posit/connect/content.py | 3 +-- src/posit/connect/env.py | 27 +++++++++++------------- src/posit/connect/oauth/integrations.py | 2 ++ src/posit/connect/resources.py | 8 +------ 6 files changed, 22 insertions(+), 27 deletions(-) diff --git a/src/posit/connect/_api_call.py b/src/posit/connect/_api_call.py index 7137cf14..c0ea1316 100644 --- a/src/posit/connect/_api_call.py +++ b/src/posit/connect/_api_call.py @@ -17,9 +17,9 @@ class ApiCallProtocol(ContextP, Protocol): def _endpoint(self, *path) -> str: ... def _get_api(self, *path) -> Any: ... def _delete_api(self, *path) -> Any | None: ... - def _patch_api(self, *path, json: Any | None) -> Any: ... + def _patch_api(self, *path, json: Any | None) -> Any | None: ... def _post_api(self, *path, json: Any | None, data: Any | None) -> Any | None: ... - def _put_api(self, *path, json: Any | None) -> Any: ... + def _put_api(self, *path, json: Any | None) -> Any | None: ... def endpoint(ctx: Context, *path) -> str: @@ -77,8 +77,10 @@ def _patch_api( self: ApiCallProtocol, *path, json: Any | None = None, - ) -> Any: + ) -> Any | None: response = self._ctx.session.patch(self._endpoint(*path), json=json) + if len(response.content) == 0: + return None return response.json() def _post_api( diff --git a/src/posit/connect/_content_repository.py b/src/posit/connect/_content_repository.py index 01e5c4ab..4d29ebea 100644 --- a/src/posit/connect/_content_repository.py +++ b/src/posit/connect/_content_repository.py @@ -121,6 +121,7 @@ def update( * https://docs.posit.co/connect/api/#patch-/v1/content/-guid-/repository """ result = self._patch_api(json=cast(JsonifiableDict, dict(attrs))) + assert isinstance(result, dict), f"Update response must be a dict. Got: {result}" return ContentItemRepository( self._ctx, **result, diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 88cf8d88..1b2be53f 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -32,7 +32,6 @@ from .oauth.associations import ContentItemAssociations from .packages import ContentPackagesMixin as PackagesMixin from .permissions import Permissions -from .resources import context_to_resource_parameters from .vanities import ContentItemVanityMixin from .variants import Variants @@ -343,7 +342,7 @@ def bundles(self) -> Bundles: @property def environment_variables(self) -> EnvVars: - return EnvVars(context_to_resource_parameters(self._ctx), self["guid"]) + return EnvVars(self._ctx) @property def permissions(self) -> Permissions: diff --git a/src/posit/connect/env.py b/src/posit/connect/env.py index 60a58b02..37e1d6a6 100644 --- a/src/posit/connect/env.py +++ b/src/posit/connect/env.py @@ -2,13 +2,16 @@ from typing import Any, Iterator, List, Mapping, MutableMapping, Optional -from .resources import ResourceParameters, Resources +from posit.connect._api_call import ApiCallMixin +from posit.connect._types_content_item import ContentItemContext +from posit.connect._types_context import ContextP -class EnvVars(Resources, MutableMapping[str, Optional[str]]): - def __init__(self, params: ResourceParameters, content_guid: str) -> None: - super().__init__(params) - self.content_guid = content_guid +class EnvVars(ApiCallMixin, ContextP[ContentItemContext], MutableMapping[str, Optional[str]]): + def __init__(self, ctx: ContentItemContext) -> None: + super().__init__() + self._ctx = ctx + self._path = f"v1/content/{self._ctx.content_guid}/environment" def __delitem__(self, key: str, /) -> None: """Delete the environment variable. @@ -62,9 +65,7 @@ def clear(self) -> None: -------- >>> clear() """ - path = f"v1/content/{self.content_guid}/environment" - url = self.params.url + path - self.params.session.put(url, json=[]) + self._put_api(json=[]) def create(self, key: str, value: str, /) -> None: """Create an environment variable. @@ -120,10 +121,8 @@ def find(self) -> List[str]: >>> find() ['DATABASE_URL'] """ - path = f"v1/content/{self.content_guid}/environment" - url = self.params.url + path - response = self.params.session.get(url) - return response.json() + result = self._get_api() + return result def items(self): raise NotImplementedError( @@ -193,6 +192,4 @@ def update(self, other=(), /, **kwargs: Optional[str]): d[key] = value body = [{"name": key, "value": value} for key, value in d.items()] - path = f"v1/content/{self.content_guid}/environment" - url = self.params.url + path - self.params.session.patch(url, json=body) + self._patch_api(json=body) diff --git a/src/posit/connect/oauth/integrations.py b/src/posit/connect/oauth/integrations.py index 0aabc8ab..10535448 100644 --- a/src/posit/connect/oauth/integrations.py +++ b/src/posit/connect/oauth/integrations.py @@ -60,6 +60,8 @@ def update( elements from both options and fields from a given template. """ result = self._patch_api(json=kwargs) + assert result is not None, "Integration update failed" + assert "guid" in result, "Integration update failed. No guid returned." return Integration(self._ctx, **result) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 160a579a..e338b046 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -16,12 +16,11 @@ overload, ) -from .context import Context - if TYPE_CHECKING: import requests from typing_extensions import Self + from .context import Context from .urls import Url @@ -47,11 +46,6 @@ def context_to_resource_parameters(ctx: Context) -> ResourceParameters: return ResourceParameters(ctx.session, ctx.url) -def resource_parameters_to_context(params: ResourceParameters) -> Context: - """Temp method to aid in transitioning from `ResourceParameters` to `Context`.""" - return Context(params.session, params.url) - - class Resource(dict): def __init__(self, /, params: ResourceParameters, **kwargs): self.params = params From dd88ab2fd80d48027decb363d28aa4d48c336074 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 04:39:37 -0500 Subject: [PATCH 45/47] Update Group/Groups --- src/posit/connect/_content_repository.py | 10 +--- src/posit/connect/client.py | 4 +- src/posit/connect/content.py | 4 +- src/posit/connect/groups.py | 74 ++++++++++++++---------- src/posit/connect/resources.py | 5 -- src/posit/connect/users.py | 2 +- src/posit/connect/vanities.py | 3 +- tests/posit/connect/api.py | 6 +- 8 files changed, 56 insertions(+), 52 deletions(-) diff --git a/src/posit/connect/_content_repository.py b/src/posit/connect/_content_repository.py index 4d29ebea..4ddb0bbb 100644 --- a/src/posit/connect/_content_repository.py +++ b/src/posit/connect/_content_repository.py @@ -1,14 +1,10 @@ from __future__ import annotations -from typing import ( - TYPE_CHECKING, - cast, -) +from typing import TYPE_CHECKING from typing_extensions import NotRequired, TypedDict, Unpack from ._active import ActiveDict -from ._json import JsonifiableDict from ._types_content_item import ContentItemContext if TYPE_CHECKING: @@ -72,7 +68,7 @@ def _create( ) -> ContentItemRepository: from ._api_call import put_api - result = put_api(ctx, cls._api_path(content_guid), json=cast(JsonifiableDict, attrs)) + result = put_api(ctx, cls._api_path(content_guid), json=attrs) content_ctx = ( ctx if isinstance(ctx, ContentItemContext) @@ -120,7 +116,7 @@ def update( -------- * https://docs.posit.co/connect/api/#patch-/v1/content/-guid-/repository """ - result = self._patch_api(json=cast(JsonifiableDict, dict(attrs))) + result = self._patch_api(json=attrs) assert isinstance(result, dict), f"Update response must be a dict. Got: {result}" return ContentItemRepository( self._ctx, diff --git a/src/posit/connect/client.py b/src/posit/connect/client.py index f3069a59..3e58bcba 100644 --- a/src/posit/connect/client.py +++ b/src/posit/connect/client.py @@ -15,7 +15,6 @@ from .metrics import Metrics from .oauth import OAuth from .packages import Packages -from .resources import ResourceParameters from .tasks import Tasks from .users import User, Users from .vanities import Vanities @@ -155,7 +154,6 @@ def __init__(self, *args, **kwargs) -> None: session.hooks["response"].append(hooks.check_for_deprecation_header) session.hooks["response"].append(hooks.handle_errors) self.session = session - self.resource_params = ResourceParameters(session, self.cfg.url) self._ctx = Context(self.session, self.cfg.url) @property @@ -191,7 +189,7 @@ def groups(self) -> Groups: Groups The groups resource interface. """ - return Groups(self.resource_params) + return Groups(self._ctx) @property def tasks(self) -> Tasks: diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 1b2be53f..886421fc 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -11,7 +11,6 @@ List, Literal, Optional, - cast, overload, ) @@ -20,7 +19,6 @@ from . import tasks from ._api_call import ApiCallMixin from ._content_repository import ContentItemRepository -from ._json import JsonifiableDict from ._types_content_item import ContentItemActiveDict, ContentItemContext, ContentItemResourceDict from ._types_context import ContextP from ._utils import assert_guid @@ -323,7 +321,7 @@ def update( ------- None """ - result = self._patch_api(json=cast(JsonifiableDict, dict(attrs))) + result = self._patch_api(json=attrs) assert isinstance(result, dict) assert "guid" in result new_content_item = ContentItem( diff --git a/src/posit/connect/groups.py b/src/posit/connect/groups.py index 217f47ee..3614b589 100644 --- a/src/posit/connect/groups.py +++ b/src/posit/connect/groups.py @@ -2,26 +2,48 @@ from __future__ import annotations -from typing import TYPE_CHECKING, List, overload +from typing import List, overload +from ._active import ActiveDict +from ._api_call import ApiCallMixin +from ._types_context import ContextP +from .context import Context from .paginator import Paginator -from .resources import Resource, Resources -if TYPE_CHECKING: - import requests +class GroupContext(Context): + group_guid: str + + def __init__(self, ctx: Context, /, *, group_guid: str): + super().__init__(ctx.session, ctx.url) + self.group_guid = group_guid + + +class Group(ActiveDict[GroupContext]): + """Group resource.""" + + def __init__(self, ctx: Context, /, *, guid: str, **kwargs): + assert isinstance(guid, str), "guid must be a string" + assert guid, "guid must not be empty" + + group_ctx = GroupContext(ctx, group_guid=guid) + path = f"v1/groups/{guid}" + get_data = len(kwargs) == 0 + super().__init__(group_ctx, path, get_data, guid=guid, **kwargs) -class Group(Resource): def delete(self) -> None: """Delete the group.""" - path = f"v1/groups/{self['guid']}" - url = self.params.url + path - self.params.session.delete(url) + self._delete_api() -class Groups(Resources): +class Groups(ApiCallMixin, ContextP[Context]): """Groups resource.""" + def __init__(self, ctx: Context): + super().__init__() + self._ctx = ctx + self._path = "v1/groups" + @overload def create(self, *, name: str, unique_id: str | None) -> Group: """Create a group. @@ -57,10 +79,9 @@ def create(self, **kwargs) -> Group: ------- Group """ - path = "v1/groups" - url = self.params.url + path - response = self.params.session.post(url, json=kwargs) - return Group(self.params, **response.json()) + result = self._post_api(json=kwargs) + assert result is not None, "Group creation failed" + return Group(self._ctx, **result) @overload def find( @@ -84,13 +105,12 @@ def find(self, **kwargs): ------- List[Group] """ - path = "v1/groups" - url = self.params.url + path - paginator = Paginator(self.params.session, url, params=kwargs) + url = self._ctx.url + self._path + paginator = Paginator(self._ctx.session, url, params=kwargs) results = paginator.fetch_results() return [ Group( - self.params, + self._ctx, **result, ) for result in results @@ -118,14 +138,13 @@ def find_one(self, **kwargs) -> Group | None: ------- Group | None """ - path = "v1/groups" - url = self.params.url + path - paginator = Paginator(self.params.session, url, params=kwargs) + url = self._ctx.url + self._path + paginator = Paginator(self._ctx.session, url, params=kwargs) pages = paginator.fetch_pages() results = (result for page in pages for result in page.results) groups = ( Group( - self.params, + self._ctx, **result, ) for result in results @@ -143,11 +162,9 @@ def get(self, guid: str) -> Group: ------- Group """ - url = self.params.url + f"v1/groups/{guid}" - response = self.params.session.get(url) return Group( - self.params, - **response.json(), + self._ctx, + guid=guid, ) def count(self) -> int: @@ -157,8 +174,7 @@ def count(self) -> int: ------- int """ - path = "v1/groups" - url = self.params.url + path - response: requests.Response = self.params.session.get(url, params={"page_size": 1}) - result: dict = response.json() + result = self._get_api(params={"page_size": 1}) + assert result is not None, "Group count failed" + assert "total" in result, "`'total'` key not found in Group response" return result["total"] diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index e338b046..48f77897 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -41,11 +41,6 @@ class ResourceParameters: url: Url -def context_to_resource_parameters(ctx: Context) -> ResourceParameters: - """Temp method to aid in transitioning from `Context` to `ResourceParameters`.""" - return ResourceParameters(ctx.session, ctx.url) - - class Resource(dict): def __init__(self, /, params: ResourceParameters, **kwargs): self.params = params diff --git a/src/posit/connect/users.py b/src/posit/connect/users.py index 94b0ffb6..1850e57f 100644 --- a/src/posit/connect/users.py +++ b/src/posit/connect/users.py @@ -39,7 +39,7 @@ class User(ActiveDict[UserContext]): # from ._api_call import put_api # # todo - use the 'context' module to inspect the 'authentication' object and route to POST (local) or PUT (remote). - # result = put_api(ctx, cls._api_path(), json=cast(JsonifiableDict, attrs)) + # result = put_api(ctx, cls._api_path(), json=attrs) # return User( # ctx, diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index 866a92a6..37441b56 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -67,7 +67,8 @@ def __init__( Parameters ---------- - params : ResourceParameters + ctx : ContentItemContext + The content item context object containing the session and URL for API interactions. after_destroy : AfterDestroyCallback, optional Called after the Vanity is successfully destroyed, by default None """ diff --git a/tests/posit/connect/api.py b/tests/posit/connect/api.py index de2651b0..63ad1827 100644 --- a/tests/posit/connect/api.py +++ b/tests/posit/connect/api.py @@ -2,7 +2,7 @@ import pyjson5 as json -from posit.connect._json import Jsonifiable, JsonifiableDict, JsonifiableList +from posit.connect._json import Jsonifiable def load_mock(path: str) -> Jsonifiable: @@ -33,13 +33,13 @@ def load_mock(path: str) -> Jsonifiable: return json.loads((Path(__file__).parent / "__api__" / path).read_text()) -def load_mock_dict(path: str) -> JsonifiableDict: +def load_mock_dict(path: str) -> dict: result = load_mock(path) assert isinstance(result, dict) return result -def load_mock_list(path: str) -> JsonifiableList: +def load_mock_list(path: str) -> list: result = load_mock(path) assert isinstance(result, list) return result From e948f557fe1eee69d37416a98e12f19fb78ce7c1 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 04:40:09 -0500 Subject: [PATCH 46/47] Update env.py --- src/posit/connect/env.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/posit/connect/env.py b/src/posit/connect/env.py index 37e1d6a6..9cbeb49c 100644 --- a/src/posit/connect/env.py +++ b/src/posit/connect/env.py @@ -2,9 +2,9 @@ from typing import Any, Iterator, List, Mapping, MutableMapping, Optional -from posit.connect._api_call import ApiCallMixin -from posit.connect._types_content_item import ContentItemContext -from posit.connect._types_context import ContextP +from ._api_call import ApiCallMixin +from ._types_content_item import ContentItemContext +from ._types_context import ContextP class EnvVars(ApiCallMixin, ContextP[ContentItemContext], MutableMapping[str, Optional[str]]): From a1b4440c0a44945b9ff9a875e39186408bff8f83 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 04:47:27 -0500 Subject: [PATCH 47/47] Remove `resources` file --- src/posit/connect/_active.py | 6 + src/posit/connect/resources.py | 235 -------------------------- tests/posit/connect/test_resources.py | 20 ++- 3 files changed, 17 insertions(+), 244 deletions(-) delete mode 100644 src/posit/connect/resources.py diff --git a/src/posit/connect/_active.py b/src/posit/connect/_active.py index 052c3aba..93966750 100644 --- a/src/posit/connect/_active.py +++ b/src/posit/connect/_active.py @@ -82,6 +82,12 @@ def __setitem__(self, key: str, value: Any) -> None: "To retrieve updated values, please retrieve the parent object again." ) + def __delitem__(self, key: str) -> None: + raise NotImplementedError( + "Attributes are locked. " + "To retrieve updated values, please retrieve the parent object again." + ) + def __len__(self) -> int: return self._dict.__len__() diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py deleted file mode 100644 index 48f77897..00000000 --- a/src/posit/connect/resources.py +++ /dev/null @@ -1,235 +0,0 @@ -from __future__ import annotations - -import posixpath -import warnings -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import ( - TYPE_CHECKING, - Any, - Generic, - Iterable, - List, - Optional, - Sequence, - TypeVar, - overload, -) - -if TYPE_CHECKING: - import requests - from typing_extensions import Self - - from .context import Context - from .urls import Url - - -@dataclass(frozen=True) -class ResourceParameters: - """Shared parameter object for resources. - - Attributes - ---------- - session: requests.Session - A `requests.Session` object. Provides cookie persistence, connection-pooling, and - configuration. - url: str - The Connect API base URL (e.g., https://connect.example.com/__api__) - """ - - session: requests.Session - url: Url - - -class Resource(dict): - def __init__(self, /, params: ResourceParameters, **kwargs): - self.params = params - super().__init__(**kwargs) - - def __getattr__(self, name): - if name in self: - warnings.warn( - f"Accessing the field '{name}' via attribute is deprecated and will be removed in v1.0.0. " - f"Please use __getitem__ (e.g., {self.__class__.__name__.lower()}['{name}']) for field access instead.", - DeprecationWarning, - stacklevel=2, - ) - return self[name] - raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'") - - def update(self, *args, **kwargs): - super().update(*args, **kwargs) - - -class Resources: - def __init__(self, params: ResourceParameters) -> None: - self.params = params - - -class Active(ABC, Resource): - def __init__(self, ctx: Context, path: str, /, **attributes): - """A dict abstraction for any HTTP endpoint that returns a singular resource. - - Extends the `Resource` class and provides additional functionality for via the session context and an optional parent resource. - - Parameters - ---------- - ctx : Context - The context object containing the session and URL for API interactions. - path : str - The HTTP path component for the resource endpoint - **attributes : dict - Resource attributes passed - """ - params = ResourceParameters(ctx.session, ctx.url) - super().__init__(params, **attributes) - self._ctx = ctx - self._path = path - - -T = TypeVar("T", bound="Active") -"""A type variable that is bound to the `Active` class""" - - -class ActiveSequence(ABC, Generic[T], Sequence[T]): - """A sequence for any HTTP GET endpoint that returns a collection.""" - - _cache: Optional[List[T]] - - def __init__(self, ctx: Context, path: str, uid: str = "guid"): - """A sequence abstraction for any HTTP GET endpoint that returns a collection. - - Parameters - ---------- - ctx : Context - The context object containing the session and URL for API interactions. - path : str - The HTTP path component for the collection endpoint - uid : str, optional - The field name of that uniquely identifiers an instance of T, by default "guid" - """ - super().__init__() - self._ctx = ctx - self._path = path - self._uid = uid - self._cache: Optional[List[T]] = None - - @abstractmethod - def _create_instance(self, path: str, /, **kwargs: Any) -> T: - """Create an instance of 'T'.""" - raise NotImplementedError() - - def fetch(self, **conditions: Any) -> Iterable[T]: - """Fetch the collection. - - Fetches the collection directly from Connect. This operation does not effect the cache state. - - Returns - ------- - List[T] - """ - endpoint = self._ctx.url + self._path - response = self._ctx.session.get(endpoint, params=conditions) - results = response.json() - return [self._to_instance(result) for result in results] - - def reload(self) -> Self: - """Reloads the collection from Connect. - - Returns - ------- - Self - """ - self._cache = None - return self - - def _to_instance(self, result: dict) -> T: - """Converts a result into an instance of T.""" - uid = result[self._uid] - path = posixpath.join(self._path, uid) - return self._create_instance(path, **result) - - @property - def _data(self) -> List[T]: - """Get the collection. - - Fetches the collection from Connect and caches the result. Subsequent invocations return the cached results unless the cache is explicitly reset. - - Returns - ------- - List[T] - - See Also - -------- - cached - reload - """ - if self._cache is None: - self._cache = list(self.fetch()) - return self._cache - - @overload - def __getitem__(self, index: int) -> T: ... - - @overload - def __getitem__(self, index: slice) -> Sequence[T]: ... - - def __getitem__(self, index): - return self._data[index] - - def __len__(self) -> int: - return len(self._data) - - def __iter__(self): - return iter(self._data) - - def __str__(self) -> str: - return str(self._data) - - def __repr__(self) -> str: - return repr(self._data) - - -class ActiveFinderMethods(ActiveSequence[T]): - """Finder methods. - - Provides various finder methods for locating records in any endpoint supporting HTTP GET requests. - """ - - def find(self, uid) -> T: - """ - Find a record by its unique identifier. - - Fetches the record from Connect by it's identifier. - - Parameters - ---------- - uid : Any - The unique identifier of the record. - - Returns - ------- - T - """ - endpoint = self._ctx.url + self._path + uid - response = self._ctx.session.get(endpoint) - result = response.json() - return self._to_instance(result) - - def find_by(self, **conditions: Any) -> T | None: - """ - Find the first record matching the specified conditions. - - There is no implied ordering, so if order matters, you should specify it yourself. - - Parameters - ---------- - **conditions : Any - - Returns - ------- - Optional[T] - The first record matching the conditions, or `None` if no match is found. - """ - collection = self.fetch(**conditions) - return next((v for v in collection if v.items() >= conditions.items()), None) diff --git a/tests/posit/connect/test_resources.py b/tests/posit/connect/test_resources.py index 6ac6d204..40664d4f 100644 --- a/tests/posit/connect/test_resources.py +++ b/tests/posit/connect/test_resources.py @@ -3,13 +3,15 @@ from unittest import mock from unittest.mock import Mock -from posit.connect.resources import Resource +import pytest + +from posit.connect._active import ResourceDict config = Mock() session = Mock() -class FakeResource(Resource): +class FakeResource(ResourceDict): @property def foo(self) -> Optional[str]: return self.get("foo") @@ -17,12 +19,12 @@ def foo(self) -> Optional[str]: class TestResource: def test_init(self): - p = mock.Mock() + ctx = mock.Mock() k = "foo" v = "bar" d = {k: v} - r = FakeResource(p, **d) - assert r.params == p + r = FakeResource(ctx, **d) + assert r._ctx == ctx def test__getitem__(self): warnings.filterwarnings("ignore", category=FutureWarning) @@ -41,8 +43,8 @@ def test__setitem__(self): d = {k: v1} r = FakeResource(mock.Mock(), **d) assert r[k] == v1 - r[k] = v2 - assert r[k] == v2 + with pytest.raises(NotImplementedError): + r[k] = v2 def test__delitem__(self): warnings.filterwarnings("ignore", category=FutureWarning) @@ -52,8 +54,8 @@ def test__delitem__(self): r = FakeResource(mock.Mock(), **d) assert k in r assert r[k] == v - del r[k] - assert k not in r + with pytest.raises(NotImplementedError): + del r[k] def test_foo(self): k = "foo"