From e14ff637888e80c9746a4b547f8c126433c57fd8 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 12 Nov 2024 13:46:38 -0500 Subject: [PATCH 01/52] move active and api code together to work on --- src/posit/connect/_active.py | 462 +++++++++++++++++++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 src/posit/connect/_active.py diff --git a/src/posit/connect/_active.py b/src/posit/connect/_active.py new file mode 100644 index 00000000..9a13594c --- /dev/null +++ b/src/posit/connect/_active.py @@ -0,0 +1,462 @@ +# 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, + List, + Optional, + Self, + Sequence, + TypeVar, + cast, + overload, +) + +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 + + +# 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:` + + +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 = 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) + + @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 + + @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() + return next((v for v in collection if v.items() >= conditions.items()), 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 + + +ReadOnlyDictT = TypeVar("ReadOnlyDictT", bound="ReadOnlyDict") +"""A type variable that is bound to the `Active` class""" + + +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, + ) From 9bc8701454e5cb8051655ce150ec364fa6a45c73 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 12 Nov 2024 13:48:28 -0500 Subject: [PATCH 02/52] move up readonlydict --- src/posit/connect/_active.py | 112 +++++++++++++++++------------------ 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/src/posit/connect/_active.py b/src/posit/connect/_active.py index 9a13594c..470602b5 100644 --- a/src/posit/connect/_active.py +++ b/src/posit/connect/_active.py @@ -50,6 +50,62 @@ # * 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 Active(ABC, Resource): def __init__(self, ctx: Context, path: str, /, **attributes): """A dict abstraction for any HTTP endpoint that returns a singular resource. @@ -219,62 +275,6 @@ def find_by(self, **conditions: Any) -> T | None: return next((v for v in collection if v.items() >= conditions.items()), 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.""" From 194f07b079233e6ccfc170d2c694f50d23dfe6cd Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 12 Nov 2024 13:59:55 -0500 Subject: [PATCH 03/52] 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 04/52] 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 05/52] 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 06/52] 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 07/52] 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 08/52] 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 09/52] 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 10/52] 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 11/52] 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 12/52] 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 13/52] 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 14/52] 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 15/52] 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 16/52] 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 17/52] 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 18/52] 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 19/52] 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 20/52] 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 21/52] 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 22/52] 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 23/52] 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 24/52] 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 25/52] 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 26/52] 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 27/52] 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 28/52] 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 29/52] 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 30/52] 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 31/52] 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 32/52] 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 33/52] 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 34/52] 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 35/52] 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 36/52] 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 37/52] 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 38/52] 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 39/52] 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 40/52] 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 41/52] 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 42/52] 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 43/52] 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 44/52] 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 45/52] 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 46/52] 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 47/52] 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 48/52] 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 49/52] 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" From c56d99ad1b13ebbe7c8fb44df32361e0506014c8 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 05:20:26 -0500 Subject: [PATCH 50/52] Context now inherits from `dict`. Would be nice to remove it --- src/posit/connect/context.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/posit/connect/context.py b/src/posit/connect/context.py index 1e275838..0beb0105 100644 --- a/src/posit/connect/context.py +++ b/src/posit/connect/context.py @@ -27,7 +27,28 @@ def wrapper(instance: ContextManager, *args, **kwargs): return decorator -class Context: +# Big mystery: `Context` is a subclass of `dict`. However, this is unnecessary. +# BUT, when `dict` is removed, the following errors occur: +# ``` +# uv run pyright +# /Users/barret/Documents/git/rstudio/posit-sdk-py/posit-sdk-py.nosync/src/posit/connect/client.py +# /Users/barret/Documents/git/rstudio/posit-sdk-py/posit-sdk-py.nosync/src/posit/connect/client.py:274:25 - error: Argument of type "Context" cannot be assigned to parameter "iterable" of type "Iterable[Package]" in function "__new__" +# "Context" is incompatible with protocol "Iterable[Package]" +# "__iter__" is not present (reportArgumentType) +# /Users/barret/Documents/git/rstudio/posit-sdk-py/posit-sdk-py.nosync/src/posit/connect/jobs.py +# /Users/barret/Documents/git/rstudio/posit-sdk-py/posit-sdk-py.nosync/src/posit/connect/jobs.py:302:21 - error: Argument of type "ContentItemContext" cannot be assigned to parameter "iterable" of type "Iterable[Job]" in function "__new__" +# "ContentItemContext" is incompatible with protocol "Iterable[Job]" +# "__iter__" is not present (reportArgumentType) +# /Users/barret/Documents/git/rstudio/posit-sdk-py/posit-sdk-py.nosync/src/posit/connect/packages.py +# /Users/barret/Documents/git/rstudio/posit-sdk-py/posit-sdk-py.nosync/src/posit/connect/packages.py:102:32 - error: Argument of type "ContentItemContext" cannot be assigned to parameter "iterable" of type "Iterable[ContentPackage]" in function "__new__" +# "ContentItemContext" is incompatible with protocol "Iterable[ContentPackage]" +# "__iter__" is not present (reportArgumentType) +# 3 errors, 0 warnings, 0 informations +# ``` +# This is a mystery because `Context` is not used as an iterable in the codebase. + + +class Context(dict): def __init__(self, session: requests.Session, url: Url): self.session = session self.url = url From d2811f6903ac8d4520d6ec593b2499bb34be8af3 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 10:08:15 -0500 Subject: [PATCH 51/52] Remove many commented code. Clarify some comments for now vs later --- src/posit/connect/_active.py | 148 +----------------------- src/posit/connect/content.py | 3 +- src/posit/connect/jobs.py | 15 +-- src/posit/connect/oauth/associations.py | 5 +- src/posit/connect/oauth/integrations.py | 2 +- src/posit/connect/oauth/sessions.py | 6 +- src/posit/connect/users.py | 22 ---- src/posit/connect/variants.py | 1 - 8 files changed, 10 insertions(+), 192 deletions(-) diff --git a/src/posit/connect/_active.py b/src/posit/connect/_active.py index 93966750..91ff64ab 100644 --- a/src/posit/connect/_active.py +++ b/src/posit/connect/_active.py @@ -1,6 +1,3 @@ -# 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 posixpath @@ -366,7 +363,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()`? + # TODO-barret-future-q: Include params to `._get_api()`? def _get_data(self) -> Generator[ResourceDictT, None, None]: """Fetch the collection. @@ -402,18 +399,6 @@ def _get_data(self) -> Generator[ResourceDictT, None, None]: # return repr(self._data) -# 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. @@ -475,134 +460,3 @@ def find_by( # 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/content.py b/src/posit/connect/content.py index 886421fc..3aca2234 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -132,7 +132,7 @@ def __init__( def __getitem__(self, key: Any) -> Any: v = super().__getitem__(key) - # TODO-barret-Q: Why isn't this a property? + # TODO-barret-Q: Why isn't `owner` a property? if key == "owner" and isinstance(v, dict): return ContentItemOwner(self._ctx, **v) return v @@ -329,7 +329,6 @@ def update( # `guid=` is contained within the `result` dict **result, ) - # TODO-barret Update method returns new content item return new_content_item # Relationships diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index 10e657a2..6fdcea24 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -150,7 +150,8 @@ def _create_instance(self, path: str, /, **attributes: Any) -> Job: class _FindByRequest(TypedDict, total=False): # Identifiers - id: NotRequired[str] # TODO-barret-q: Is this change correct? + # TODO-barret-Q: Is this change correct? It use to be required, but the docs say not-required + id: NotRequired[str] """A unique identifier for the job.""" ppid: NotRequired[Optional[str]] @@ -287,16 +288,4 @@ def jobs(self: ContentItemP) -> Jobs: ------- Jobs """ - # Do not cache result. `content.jobs` should always return the latest jobs. - - # if hasattr(self, "_jobs"): - # # 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 - return Jobs(self._ctx) diff --git a/src/posit/connect/oauth/associations.py b/src/posit/connect/oauth/associations.py index 75458db8..eaa7b6b6 100644 --- a/src/posit/connect/oauth/associations.py +++ b/src/posit/connect/oauth/associations.py @@ -63,7 +63,6 @@ def find(self) -> list[Association]: ] -# TODO-barret; Should this auto retrieve? If so, it should inherit from ActiveSequence class ContentItemAssociations(ApiCallMixin, ContextP[ContentItemContext]): """ContentItemAssociations resource.""" @@ -76,7 +75,7 @@ def __init__(self, ctx: ContentItemContext) -> None: 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) + # TODO-barret-future-q: Should this be inherited from ActiveFinderSequence? (It would add find_by) def find(self) -> list[Association]: """Find OAuth associations. @@ -94,7 +93,7 @@ def find(self) -> list[Association]: for result in results_list ] - # TODO-barret-q: Should this be destroy instead of delete? + # TODO-barret-future-q: Should this be destroy instead of delete? def delete(self) -> None: """Delete integration associations.""" data = [] diff --git a/src/posit/connect/oauth/integrations.py b/src/posit/connect/oauth/integrations.py index 10535448..b1fbda16 100644 --- a/src/posit/connect/oauth/integrations.py +++ b/src/posit/connect/oauth/integrations.py @@ -65,7 +65,7 @@ def update( return Integration(self._ctx, **result) -# TODO-barret; Should this auto retrieve? If so, it should inherit from ActiveSequence +# TODO-barret-future-q; Should this auto retrieve? If so, it should inherit from ActiveSequence class Integrations(ApiCallMixin, ContextP[Context]): """Integrations resource.""" diff --git a/src/posit/connect/oauth/sessions.py b/src/posit/connect/oauth/sessions.py index 0d4272c5..787ebd55 100644 --- a/src/posit/connect/oauth/sessions.py +++ b/src/posit/connect/oauth/sessions.py @@ -65,7 +65,7 @@ def __init__(self, ctx: Context, /, **kwargs) -> None: super().__init__(ctx, path, get_data, **kwargs) - # TODO-barret-q: Should this be destroy? + # TODO-barret-future-q: Should this be destroy? def delete(self) -> None: """Destroy the OAuth session.""" self._delete_api() @@ -80,7 +80,7 @@ def __init__(self, ctx: Context) -> None: super().__init__() self._ctx = ctx - # TODO-barret-q: Should this be `.all()`? + # TODO-barret-future-q: Should this be `.all()`? @overload def find( self, @@ -88,7 +88,7 @@ def find( all: Optional[bool] = ..., ) -> List[Session]: ... - # TODO-barret-q: Should this be `.find_by()`? + # TODO-barret-future-q: Should this be `.find_by()`? @overload def find(self, **kwargs) -> List[Session]: ... diff --git a/src/posit/connect/users.py b/src/posit/connect/users.py index 1850e57f..c91f2211 100644 --- a/src/posit/connect/users.py +++ b/src/posit/connect/users.py @@ -24,28 +24,6 @@ def __init__(self, ctx: Context, /, *, user_guid: str) -> None: 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=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""" diff --git a/src/posit/connect/variants.py b/src/posit/connect/variants.py index d0f2233b..28b0cad6 100644 --- a/src/posit/connect/variants.py +++ b/src/posit/connect/variants.py @@ -7,7 +7,6 @@ class Variant(ResourceDict): def render(self) -> Task: - # TODO Move to within Task logic? path = f"variants/{self['id']}/render" url = self._ctx.url + path response = self._ctx.session.post(url) From 788938c0acb30e3d9ccf971ab9f50709246bb1b6 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 14 Nov 2024 11:10:00 -0500 Subject: [PATCH 52/52] Update readme --- src/posit/connect/README.md | 187 ++++++++++++++++++++--- src/posit/connect/_active.py | 68 ++------- src/posit/connect/_content_repository.py | 2 +- src/posit/connect/oauth/associations.py | 2 +- src/posit/connect/tasks.py | 5 +- src/posit/connect/variants.py | 3 +- 6 files changed, 178 insertions(+), 89 deletions(-) diff --git a/src/posit/connect/README.md b/src/posit/connect/README.md index 3f2c0056..15bbb071 100644 --- a/src/posit/connect/README.md +++ b/src/posit/connect/README.md @@ -2,13 +2,14 @@ > Note: this is design-by-wishful-thinking, not how things actually work today. > To discuss or propose changes, open a PR suggesting new language. + ### Connecting To get started, import the Connect `Client` and create a connection. You can specify the `endpoint` for your Connect server URL and your `api_key`; if not specified, they'll be pulled from the environment (`CONNECT_SERVER` and `CONNECT_API_KEY`). It is expected that `Client()` just works from within any Posit product's environment (Workbench, Connect, etc.), either by API key and prior system configuration, or by some means of identity federation. -``` +```python from posit.connect import Client con = Client() @@ -18,31 +19,31 @@ con = Client() Many resources in the SDK refer to *collections* of *entities* or records in Connect. -All of the general collections can be referenced as properties of the Client object (e.g. `client.content`, `client.users`). Some collections belong to a single entity and are referenced from them similarly (e.g. `content_item.permissions`). +All of the general collections can be referenced as properties of the Client object (e.g. `client.content`, `client.users`). Some collections belong to a single entity and are referenced from them similarly (e.g. `content_item.permissions`). All collections are iterable objects with all read-only List-like methods implemented. They also have the following methods: -* `.find()`: returns another iterable collection object. - * Calling `.find()` with no arguments retrieves all available entities - * If no entities match the query, `.find()` returns a length-0 collection. - * Iterating over a collection without having first called `find()` is equivalent to having queried for all. - * `find()` should use query-based REST APIs where existing, and fall back to retrieving all and filtering client-side where those APIs do not (yet) exist. - * Should `collection.find().find()` work? Probably. +* `.find()`: returns another iterable collection object. + * Calling `.find()` with no arguments retrieves all available entities + * If no entities match the query, `.find()` returns a length-0 collection. + * Iterating over a collection without having first called `find()` is equivalent to having queried for all. + * `find()` should use query-based REST APIs where existing, and fall back to retrieving all and filtering client-side where those APIs do not (yet) exist. + * Should `collection.find().find()` work? Probably. * `.get(guid)` method that returns a single entity by id. If one is not found, it raises `NotFoundError` * `.find_one()` is a convenience method that queries with `.find()` and returns a single entity - * If more than one entity match the query, `.find_one()` returns the first - * If no entities match, `.find_one()` returns `None` - * If you need stricter behavior (e.g. you want to be sure that one and only one entity are returned by your query), use `.find()` or `.get()`. -* `.to_pandas()` materializes the collection in a pandas `DataFrame`. - * pandas is not a required dependency of the SDK. `.to_pandas()` should try to import inside the method. + * If more than one entity match the query, `.find_one()` returns the first + * If no entities match, `.find_one()` returns `None` + * If you need stricter behavior (e.g. you want to be sure that one and only one entity are returned by your query), use `.find()` or `.get()`. +* `.to_pandas()` materializes the collection in a pandas `DataFrame`. + * pandas is not a required dependency of the SDK. `.to_pandas()` should try to import inside the method. -The `.find()` and `.find_one()` methods use named arguments rather than accepting a dict so that IDE tab completion can work. +The `.find()` and `.find_one()` methods use named arguments rather than accepting a dict so that IDE tab completion can work. -Collections should handle all API reponse pagination invisibly so that the Python user doesn't need to worry about pages. +Collections should handle all API reponse pagination invisibly so that the Python user doesn't need to worry about pages. -Entities have methods that are appropriate to them. Fields in the entity bodies can be accessed as properties. +Entities have methods that are appropriate to them. Fields in the entity bodies can be accessed as properties. -``` +```python for st in con.content.find(app_mode="streamlit"): print(st.title) @@ -53,9 +54,9 @@ for perm in my_app.permissions: ### Mapping to HTTP request methods -Entities have an `.update()` method that maps to a `PATCH` request. `.delete()` is `DELETE`. +Entities have an `.update()` method that maps to a `PATCH` request. `.delete()` is `DELETE`. -``` +```python my_app.update(title="Quarterly Analysis of Team Velocity") my_app.permissions.find_one(email="first.last@example.com").update(role="owner") my_app.permissions.find_one(email="first.last@example.com").delete() @@ -63,20 +64,158 @@ my_app.permissions.find_one(email="first.last@example.com").delete() Collections have a `.create()` method that maps to `POST` to create a new entity. It may be aliased to other verbs as appropriate for the entity. -``` +```python my_app.permissions.add(email="my.boss@example.com", role="viewer") ``` ### Field/attribute naming -The Python SDK should present the interface we wish we had, and we can evolve the REST API to match that over time. It is the adapter layer that allows us to evolve the Connect API more freely. +The Python SDK should present the interface we wish we had, and we can evolve the REST API to match that over time. It is the adapter layer that allows us to evolve the Connect API more freely. -Naming of fields and arguments in collection and entity methods should be standardized across entity types for consistency, even if this creates a gap between our current REST API specification. +Naming of fields and arguments in collection and entity methods should be standardized across entity types for consistency, even if this creates a gap between our current REST API specification. -As a result, the SDK takes on the burden of smoothing over the changes in the Connect API over time. Each collection and entity class may need its own adapter methods that take the current Python SDK field names and maps to the values for the version of the Connect server being used when passing to the HTTP methods. +As a result, the SDK takes on the burden of smoothing over the changes in the Connect API over time. Each collection and entity class may need its own adapter methods that take the current Python SDK field names and maps to the values for the version of the Connect server being used when passing to the HTTP methods. Entity `.to_dict()` methods likewise present the names and values in the Python interface, which may not map to the actual HTTP response body JSON. There should be some other way to access the raw response body. ### Lower-level HTTP interface -The client object has `.get`, `.post`, etc. methods that pass arguments through to the `requests` methods, accepting URL paths relative to the API root and including the necessary authorization. These are invoked inside the collection and entity action methods, and they are also available for users to call directly, whether because there are API resources we haven't wrapped in Pythonic methods yet, or because they are simpler RPC-style endpoints that just need to be hit directly. \ No newline at end of file +The client object has `.get`, `.post`, etc. methods that pass arguments through to the `requests` methods, accepting URL paths relative to the API root and including the necessary authorization. These are invoked inside the collection and entity action methods, and they are also available for users to call directly, whether because there are API resources we haven't wrapped in Pythonic methods yet, or because they are simpler RPC-style endpoints that just need to be hit directly. + +### Constructing classes + +Classes that contain dictionary-like data should inherit from `ReadOnlyDict` (or one of its subsclasses) and classes that contain list-like data should inherit from `ReadOnlySequence` (or one of its subsclasses). + +#### Classes + +`ReadOnlyDict` was created to provide a non-interactive interface to the data being returned for the class. This way users can not set any values without going through the API. By extension, any method that would change the data should return a new instance with the updated data. E.g. `.update()` methods should return a instance. The same applies for `ReadOnlySequence` classes. + +When retrieving objects from the server, it should be retrieved through a `@property` method. This way, the data is only retrieved when it is needed. This is especially important for list-like objects. + +```python +class ContentItem(..., ContentItemActiveDict): + ... + + @property + def repository(self) -> ContentItemRepository | None: + try: + return ContentItemRepository(self._ctx) + except ClientError: + return None + + ... +``` + +To avoid confusion between api exploration and internal values, all internal values should be prefixed with an underscore. This way, users can easily see what is part of the API and what is part of the internal workings of the class. + +Attempt to minimize the number of locations where the same intended `path` is defined. Preferably only at `._path` (so it works with `ApiCallMixin`). + +```python +class Bundles(ApiCallMixin, ContextP[ContentItemContext]): + def __init__( + self, + ctx: ContentItemContext, + ) -> None: + super().__init__() + self._ctx = ctx + self._path = f"v1/content/{ctx.content_guid}/bundles" + ... +``` + +#### Context + +* `Context` - A convenience class that holds information that can be passed down to child classes. + * Contains the request `.session` and `.url` information for easy API calls. + * By inheriting from `Context`, it can be extended to contain more information (e.g. `ContentItemContext` adds `.content_path` and `.content_guid` to remove the requirement of passing through `content_guid` as a parameter). + * These classes help prevent an explosion of parameters being passed through the classes. +* `ContextP` - Protocol class that defines the attributes that a Context class should have. +* `ContextT` - Type variable that defines the type of the Context class. +* `ApiCallMixin` - Mixin class that provides helper methods for API calls and parsing the JSON repsonse. (e.g. `._get_api()`) + * It requires `._path: str` to be defined on the instance. + +#### Ex: Content Item helper classes + +These example classes show how the Entity and Context classes can be extended to provide helper classes for classes related to `ContentItem`. + +* `ContentItemP` - Extends `ContextP` with context class set to `ContentItemContext`. +* `ContentItemContext` - Extends `Context` by including `content_path` and `content_guid` attributes. +* `ContentItemResourceDict` - Extends `ResourceDict` with context class set to `ContentItemContext`. +* `ContentItemActiveDict` - Extends `ActiveDict` with context class set to `ContentItemContext`. + +#### Entity Classes + +All entity classes are populated on initialization. + +* `ReadOnlyDict` - A class that provides a read-only dictionary interface to the data. + * Immutable dictionary-like object that can be iterated over. +* `ResourceDict` - Extends `ReadOnlyDict`, but is aware of `._ctx: ContextT`. +* `ActiveDict` - Extends `ResourceDict`, but is aware of the API calls (`ApiCallMixin`) that can be made on the data. + +Example: `Bundle` class's init method + +```python +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(ApiDictEndpoint[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) + ... +``` + +When possible `**kwargs` should be typed with `**kwargs: Unpack[_Attrs]` where `_Attrs` is a class that defines the attributes that can be passed to the class. (Please define the attribute class within the usage class and have its name start with a `_`) By using `Unpack` and `**kwargs`, it allows for future new/conflicting parameters can be type ignored by the caller, but they will be sent through in the implementation. + +Example: + +```python +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["Association._Attrs"]) -> None: + super().__init__(ctx, **kwargs) +``` + +#### Collection classes + +* `ReadOnlySequence` - A class that provides a read-only list interface to the data. + * Immutable list-like object that can be iterated over. +* `ResourceSequence` - Extends `ReadOnlySequence`, but is aware of `._ctx: ContextT`. + * Wants data to immediately exist in the class. +* `ActiveSequence` - Extends `ResourceSequence`, but is aware of the API calls that can be made on the data. It requires `._path` + * Requires `._create_instance(path: str, **kwars: Any) -> ResourceDictT` method to be implemented. + * During initialization, if the data is not provided, it will be fetched from the API. (...unless `get_data=False` is passed as a parameter) +* `ActiveFinderSequence` - Extends `ActiveSequence` with `.find()` and `.find_by()` methods. + +For Collections classes, if no data is to be maintained, the class should inherit from `ContextP[CONTEXT_CLASS]`. This will help pass through the `._ctx` to children objects. If API calls are needed, it can also inherit from `ApiCallMixin` to get access to its conveniece methods (e.g. `._get_api()` which returns a parsed json result). + + +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:` diff --git a/src/posit/connect/_active.py b/src/posit/connect/_active.py index 91ff64ab..ea5cf28c 100644 --- a/src/posit/connect/_active.py +++ b/src/posit/connect/_active.py @@ -1,3 +1,9 @@ +# ################################ +# Design Notes +# +# Please see the design notes in `src/posit/connect/README.md` for example usages. +# ################################ + from __future__ import annotations import posixpath @@ -19,27 +25,6 @@ from ._json import Jsonifiable, JsonifiableList, ResponseAttrs from ._types_context import ContextT -# 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:` - - ReadOnlyDictT = TypeVar("ReadOnlyDictT", bound="ReadOnlyDict") """A type variable that is bound to the `Active` class""" ResourceDictT = TypeVar("ResourceDictT", bound="ResourceDict") @@ -85,6 +70,9 @@ def __delitem__(self, key: str) -> None: "To retrieve updated values, please retrieve the parent object again." ) + # * Only expose methods needed for `ReadOnlyDict`. + # * Ex: If inheriting from `dict`, we would need to shut down `update`, `pop`, etc. + def __len__(self) -> int: return self._dict.__len__() @@ -148,17 +136,6 @@ 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, - # ) -> 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, ctx: ContextT, @@ -259,12 +236,6 @@ def __ne__(self, other: object) -> bool: return NotImplemented return self._data != other._data - # def count(self, value: object) -> int: - # return self._data.count(value) - - # def index(self, value: object, start: int = 0, stop: int = 9223372036854775807) -> int: - # return self._data.index(value, start, stop) - def __setitem__(self, key: int, value: Any) -> None: raise NotImplementedError( "Values are locked. " @@ -377,27 +348,6 @@ def _get_data(self) -> Generator[ResourceDictT, None, None]: results_list = cast(JsonifiableList, results) return (self._to_instance(result) for result in results_list) - # @overload - # def __getitem__(self, index: int) -> T: ... - - # @overload - # def __getitem__(self, index: slice) -> tuple[T, ...]: ... - - # def __getitem__(self, index): - # return self[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 ActiveFinderSequence(ActiveSequence[ResourceDictT, ContextT]): """Finder methods. diff --git a/src/posit/connect/_content_repository.py b/src/posit/connect/_content_repository.py index 4ddb0bbb..ca7e23c2 100644 --- a/src/posit/connect/_content_repository.py +++ b/src/posit/connect/_content_repository.py @@ -11,7 +11,7 @@ from .context import Context -class ContentItemRepository(ActiveDict): +class ContentItemRepository(ActiveDict[ContentItemContext]): """ Content items GitHub repository information. diff --git a/src/posit/connect/oauth/associations.py b/src/posit/connect/oauth/associations.py index eaa7b6b6..a9d70b86 100644 --- a/src/posit/connect/oauth/associations.py +++ b/src/posit/connect/oauth/associations.py @@ -32,7 +32,7 @@ class _Attrs(TypedDict, total=False): created_time: str """The timestamp (RFC3339) indicating when this association was created.""" - def __init__(self, ctx: Context, /, **kwargs: Unpack[_Attrs]) -> None: + def __init__(self, ctx: Context, /, **kwargs: Unpack["Association._Attrs"]) -> None: super().__init__(ctx, **kwargs) diff --git a/src/posit/connect/tasks.py b/src/posit/connect/tasks.py index c80cdb54..b6a2b363 100644 --- a/src/posit/connect/tasks.py +++ b/src/posit/connect/tasks.py @@ -64,7 +64,7 @@ def __init__(self, ctx: Context, /, *, id: str) -> None: """ @overload - def __init__(self, ctx: Context, /, **kwargs: Unpack[_Attrs]) -> None: + def __init__(self, ctx: Context, /, **kwargs: Unpack["Task._Attrs"]) -> None: """Task resource. Parameters @@ -75,7 +75,7 @@ def __init__(self, ctx: Context, /, **kwargs: Unpack[_Attrs]) -> None: 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: + def __init__(self, ctx: Context, /, **kwargs: Unpack["Task._Attrs"]) -> None: task_id = kwargs.get("id") assert isinstance(task_id, str), "Task `id` must be a string." assert task_id, "Task `id` must not be empty." @@ -187,7 +187,6 @@ def wait_for(self) -> 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__() diff --git a/src/posit/connect/variants.py b/src/posit/connect/variants.py index 28b0cad6..72312c40 100644 --- a/src/posit/connect/variants.py +++ b/src/posit/connect/variants.py @@ -2,6 +2,7 @@ from ._active import ResourceDict from ._types_content_item import ContentItemContext +from ._types_context import ContextP from .tasks import Task @@ -14,7 +15,7 @@ def render(self) -> Task: # No special inheritance as it is a placeholder class -class Variants: +class Variants(ContextP[ContentItemContext]): def __init__(self, ctx: ContentItemContext) -> None: super().__init__() self._ctx = ctx