diff --git a/src/posit/connect/_active.py b/src/posit/connect/_active.py index 470602b5..93966750 100644 --- a/src/posit/connect/_active.py +++ b/src/posit/connect/_active.py @@ -3,31 +3,24 @@ 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 typing import ( - TYPE_CHECKING, Any, Generator, - Generic, - List, + Iterator, Optional, - Self, - Sequence, + SupportsIndex, + Tuple, 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 - +from ._api_call import ApiCallMixin, ContextP, get_api +from ._json import Jsonifiable, JsonifiableList, ResponseAttrs +from ._types_context import ContextT # Design Notes: # * Perform API calls on property retrieval. e.g. `my_content.repository` @@ -50,177 +43,204 @@ # * 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): - _attrs: ResponseAttrs - """Resource attributes passed.""" +class ReadOnlyDict(Mapping_abc): + """A read-only dict abstraction.""" + + _dict: ResponseAttrs + """Data 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 __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._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) + if hasattr(self, "_dict"): + return repr(self._dict) + return object.__repr__(self) 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 Active(ABC, Resource): - def __init__(self, ctx: Context, path: str, /, **attributes): - """A dict abstraction for any HTTP endpoint that returns a singular resource. +class ResourceDict(ReadOnlyDict, ContextP[ContextT]): + """An abstraction to contain the context and read-only information.""" - Extends the `Resource` class and provides additional functionality for via the session context and an optional parent resource. + _ctx: ContextT + """The context object containing the session and URL for API interactions.""" + + def __init__( + self, + ctx: ContextT, + /, + **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 - **attributes : dict - Resource attributes passed + **kwargs : Any + Values to be stored """ - params = ResourceParameters(ctx.session, ctx.url) - super().__init__(params, **attributes) + super().__init__(**kwargs) self._ctx = ctx - self._path = path -T = TypeVar("T", bound="Active") -"""A type variable that is bound to the `Active` class""" +class ActiveDict(ApiCallMixin, ResourceDict[ContextT]): + """A dict abstraction for any HTTP endpoint that returns a singular resource.""" + _ctx: ContextT + """The context object containing the session and URL for API interactions.""" + _path: str + """The HTTP path component for the resource endpoint.""" -class ActiveSequence(ABC, Generic[T], Sequence[T]): - """A sequence for any HTTP GET endpoint that returns a collection.""" + # 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 - _cache: Optional[List[T]] + def __init__( + self, + ctx: ContextT, + path: str, + get_data: Optional[bool] = None, + /, + **kwargs: Any, + ) -> None: + """ + A dict abstraction for any HTTP endpoint that returns a singular resource. - def __init__(self, ctx: Context, path: str, uid: str = "guid"): - """A sequence abstraction for any HTTP GET endpoint that returns a collection. + Adds helper methods to interact with the API with reduced boilerplate. 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 : str, optional - The field name of that uniquely identifiers an instance of T, by default "guid" + 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. + **kwargs : Any + Resource attributes passed """ - 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. + # If no attributes are provided, fetch the API and set the attributes from the response + if get_data is None: + get_data = len(kwargs) == 0 - 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] + # If we should get data, fetch the API and set the attributes from the response + if get_data: + 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 - def reload(self) -> Self: - """Reloads the collection from Connect. + super().__init__(ctx, **kwargs) + self._path = path - 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) +class ReadOnlySequence(Tuple[ResourceDictT, ...]): + """Read only Sequence.""" - @property - def _data(self) -> List[T]: - """Get the collection. + _data: Tuple[ResourceDictT, ...] - Fetches the collection from Connect and caches the result. Subsequent invocations return the cached results unless the cache is explicitly reset. + def _set_data(self, data: Tuple[ResourceDictT, ...]) -> None: + self._data = data - Returns - ------- - List[T] + def __init__(self, *args: ResourceDictT) -> None: + """ + A read-only sequence abstraction. - See Also - -------- - cached - reload + Parameters + ---------- + *args : Any + Values to be stored """ - if self._cache is None: - self._cache = self.fetch() - return self._cache + super().__init__() + self._data = args - @overload - def __getitem__(self, index: int) -> T: ... + def __len__(self) -> int: + return len(tuple(self._data)) @overload - def __getitem__(self, index: slice) -> Sequence[T]: ... + def __getitem__(self, key: SupportsIndex, /) -> ResourceDictT: ... - def __getitem__(self, index): - return self._data[index] + @overload + def __getitem__(self, key: slice, /) -> tuple[ResourceDictT, ...]: ... - def __len__(self) -> int: - return len(self._data) + def __getitem__( + self, + key: SupportsIndex | slice, + /, + ) -> ResourceDictT | tuple[ResourceDictT, ...]: + return self._data[key] - def __iter__(self): + def __iter__(self) -> Iterator[ResourceDictT]: return iter(self._data) def __str__(self) -> str: @@ -229,185 +249,178 @@ 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]): - """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. + def __eq__(self, other: object) -> bool: + if not isinstance(other, ReadOnlySequence): + return NotImplemented + return self._data == other._data - Parameters - ---------- - uid : Any - The unique identifier of the record. + def __ne__(self, other: object) -> bool: + if not isinstance(other, ReadOnlySequence): + return NotImplemented + return self._data != other._data - Returns - ------- - T - """ - endpoint = self._ctx.url + self._path + uid - response = self._ctx.session.get(endpoint) - result = response.json() - return self._to_instance(result) + # def count(self, value: object) -> int: + # return self._data.count(value) - def find_by(self, **conditions: Any) -> T | None: - """ - Find the first record matching the specified conditions. + # def index(self, value: object, start: int = 0, stop: int = 9223372036854775807) -> int: + # return self._data.index(value, start, stop) - There is no implied ordering, so if order matters, you should specify it yourself. + def __setitem__(self, key: int, value: Any) -> None: + raise NotImplementedError( + "Values are locked. " + "To retrieve updated values, please retrieve the parent object again." + ) - Parameters - ---------- - **conditions : Any + def __delitem__(self, key: int) -> None: + raise NotImplementedError( + "Values are locked. " + "To retrieve updated values, please retrieve the parent object again." + ) - 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) +class ResourceSequence(ReadOnlySequence[ResourceDictT], ContextP[ContextT]): + """An abstraction to contain the context and read-only tuple-like information.""" -class ApiDictEndpoint(ApiCallMixin, ReadOnlyDict): - _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.""" - - def _get_api(self, *path) -> JsonifiableDict | None: - super()._get_api(*path) def __init__( self, - ctx: Context, - path: str, - get_data: Optional[bool] = None, + ctx: ContextT, /, - **attrs: Jsonifiable, + *, + arr: list[ResourceDictT] | tuple[ResourceDictT, ...], ) -> None: """ - A dict abstraction for any HTTP endpoint that returns a singular resource. - - Adds helper methods to interact with the API with reduced boilerplate. + A read-only sequence abstraction that is Context aware. 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 + *args : Any + Values to be stored """ - # 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) + super().__init__(*tuple(arr)) self._ctx = ctx - self._path = path -ReadOnlyDictT = TypeVar("ReadOnlyDictT", bound="ReadOnlyDict") -"""A type variable that is bound to the `Active` class""" - +class ActiveSequence(ApiCallMixin, ABC, ResourceSequence[ResourceDictT, ContextT]): + """A read only sequence for any HTTP GET endpoint that returns a collection.""" -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. + 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) + + # TODO-barret-q: Include params to `._get_api()`? + 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: ... + # def __getitem__(self, index): + # return self[index] - @overload - def __getitem__(self, index: slice) -> Generator[ReadOnlyDictT, None, None]: ... + # def __len__(self) -> int: + # return len(self._data) - 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 __iter__(self): + # return iter(self._data) - # def __len__(self) -> int: - # return len(self.fetch()) + # def __str__(self) -> str: + # return str(self._data) - def __str__(self) -> str: - return self.__repr__() + # 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. @@ -415,20 +428,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. @@ -440,23 +456,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/_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/_api_call.py b/src/posit/connect/_api_call.py index f90244aa..c0ea1316 100644 --- a/src/posit/connect/_api_call.py +++ b/src/posit/connect/_api_call.py @@ -1,22 +1,25 @@ from __future__ import annotations import posixpath -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Any, Optional, Protocol + +from ._types_context import ContextP if TYPE_CHECKING: - from ._json import Jsonifiable + from requests import Response + from .context import Context -class ApiCallProtocol(Protocol): - _ctx: Context +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: Jsonifiable | None) -> Jsonifiable: ... - def _put_api(self, *path, json: Jsonifiable | None) -> Jsonifiable: ... + def _get_api(self, *path) -> Any: ... + def _delete_api(self, *path) -> Any | None: ... + 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 | None: ... def endpoint(ctx: Context, *path) -> str: @@ -24,20 +27,35 @@ 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() +def get_api_stream(ctx: Context, *path) -> Response: + return ctx.session.get(endpoint(ctx, *path), stream=True) + + def put_api( ctx: Context, *path, - json: Jsonifiable | None, -) -> Jsonifiable: + json: Any | None, +) -> Any: response = ctx.session.put(endpoint(ctx, *path), json=json) 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 @@ -45,11 +63,11 @@ 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) -> 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 @@ -58,15 +76,30 @@ def _delete_api(self: ApiCallProtocol, *path) -> Jsonifiable | None: def _patch_api( self: ApiCallProtocol, *path, - json: Jsonifiable | None, - ) -> Jsonifiable: + json: Any | None = None, + ) -> 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( + self: ApiCallProtocol, + *path, + json: Any | None = None, + data: Any | None = None, + ) -> Any | None: + response = self._ctx.session.post(self._endpoint(*path), json=json, data=data) + if len(response.content) == 0: + return None return response.json() def _put_api( self: ApiCallProtocol, *path, - json: Jsonifiable | None, - ) -> Jsonifiable: + json: Any | None = None, + ) -> 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/_content_repository.py b/src/posit/connect/_content_repository.py new file mode 100644 index 00000000..4ddb0bbb --- /dev/null +++ b/src/posit/connect/_content_repository.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from typing_extensions import NotRequired, TypedDict, Unpack + +from ._active import ActiveDict +from ._types_content_item import ContentItemContext + +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: ContentItemContext, + /, + # 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. + **attrs : ContentItemRepository._Attrs + Attributes for the content item repository. If not supplied, the attributes will be + retrieved from the API upon initialization + """ + 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, **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=attrs) + content_ctx = ( + ctx + if isinstance(ctx, ContentItemContext) + else ContentItemContext(ctx, content_guid=content_guid) + ) + + return ContentItemRepository( + content_ctx, + **result, + ) + + 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=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/_types_content_item.py b/src/posit/connect/_types_content_item.py new file mode 100644 index 00000000..a895d7aa --- /dev/null +++ b/src/posit/connect/_types_content_item.py @@ -0,0 +1,34 @@ +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): + """Context object for a ContentItem resource.""" + + 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) + 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 diff --git a/src/posit/connect/_utils.py b/src/posit/connect/_utils.py index 26842bbc..bef6c8ef 100644 --- a/src/posit/connect/_utils.py +++ b/src/posit/connect/_utils.py @@ -5,3 +5,14 @@ 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: 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): + 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/bundles.py b/src/posit/connect/bundles.py index c6a8a265..50c2baf0 100644 --- a/src/posit/connect/bundles.py +++ b/src/posit/connect/bundles.py @@ -5,45 +5,74 @@ import io from typing import List -from . import resources, tasks +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 -class BundleMetadata(resources.Resource): +class BundleMetadata(ReadOnlyDict): pass -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.params, **self.get("metadata", {})) + 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 -------- >>> 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) + 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: @@ -77,9 +106,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) @@ -89,7 +118,7 @@ def download(self, output: io.BufferedWriter | str) -> None: file.write(chunk) -class Bundles(resources.Resources): +class Bundles(ApiCallMixin, ContextP[ContentItemContext]): """Bundles resource. Parameters @@ -109,11 +138,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: """ @@ -163,11 +192,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. @@ -177,11 +205,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. @@ -207,8 +232,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/client.py b/src/posit/connect/client.py index e1ba808c..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 @@ -180,7 +178,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: @@ -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: @@ -203,7 +201,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: @@ -215,7 +213,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 +225,7 @@ def content(self) -> Content: Content The content resource instance. """ - return Content(self.resource_params) + return Content(self._ctx) @property def metrics(self) -> Metrics: @@ -255,7 +253,7 @@ def metrics(self) -> Metrics: >>> len(events) 24 """ - return Metrics(self.resource_params) + return Metrics(self._ctx) @property @requires(version="2024.08.0") @@ -268,16 +266,16 @@ 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") def packages(self) -> Packages: - return Packages(self._ctx, "v1/packages") + return Packages(self._ctx) @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/content.py b/src/posit/connect/content.py index 404867b7..886421fc 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -11,14 +11,17 @@ List, Literal, Optional, - cast, overload, ) from typing_extensions import NotRequired, Required, TypedDict, Unpack from . import tasks -from ._api import ApiDictEndpoint, JsonifiableDict +from ._api_call import ApiCallMixin +from ._content_repository import ContentItemRepository +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 from .env import EnvVars @@ -27,153 +30,30 @@ from .oauth.associations import ContentItemAssociations from .packages import ContentPackagesMixin as PackagesMixin from .permissions import Permissions -from .resources import Resource, ResourceParameters, Resources -from .vanities import VanityMixin +from .vanities import ContentItemVanityMixin from .variants import Variants if TYPE_CHECKING: from .tasks import Task + from .users import User -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(ApiDictEndpoint): - """ - Content items GitHub repository information. - - See Also - -------- - * Get info: https://docs.posit.co/connect/api/#get-/v1/content/-guid-/repository - * Delete info: https://docs.posit.co/connect/api/#delete-/v1/content/-guid-/repository - * Update info: https://docs.posit.co/connect/api/#patch-/v1/content/-guid-/repository - """ - - class _Attrs(TypedDict, total=False): - repository: str - """URL for the repository.""" - branch: NotRequired[str] - """The tracked Git branch.""" - directory: NotRequired[str] - """Directory containing the content.""" - polling: NotRequired[bool] - """Indicates that the Git repository is regularly polled.""" - - def __init__( - self, - ctx: Context, - /, - *, - content_guid: str, - # By default, the `attrs` will be retrieved from the API if no `attrs` are supplied. - **attrs: Unpack[ContentItemRepository._Attrs], - ) -> None: - """Content items GitHub repository information. - - Parameters - ---------- - ctx : Context - The context object containing the session and URL for API interactions. - content_guid : str - The unique identifier of the content item. - **attrs : ContentItemRepository._Attrs - Attributes for the content item repository. If not supplied, the attributes will be - retrieved from the API upon initialization - """ - _assert_content_guid(content_guid) - - path = self._api_path(content_guid) - # Only fetch data if `attrs` are not supplied - get_data = len(attrs) == 0 - super().__init__(ctx, path, get_data, **{"content_guid": content_guid, **attrs}) - - @classmethod - def _api_path(cls, content_guid: str) -> str: - return f"v1/content/{content_guid}/repository" - - @classmethod - def _create( - cls, - ctx: Context, - content_guid: str, - **attrs: Unpack[ContentItemRepository._Attrs], - ) -> ContentItemRepository: - from ._api_call import put_api - - result = put_api(ctx, cls._api_path(content_guid), json=cast(JsonifiableDict, attrs)) - - return ContentItemRepository( - ctx, - content_guid=content_guid, - **result, # pyright: ignore[reportCallIssue] - ) - - def destroy(self) -> None: - """ - Delete the content's git repository location. - - See Also - -------- - * https://docs.posit.co/connect/api/#delete-/v1/content/-guid-/repository - """ - self._delete_api() - - def update( - self, - # *, - **attrs: Unpack[ContentItemRepository._Attrs], - ) -> ContentItemRepository: - """Update the content's repository. - - Parameters - ---------- - repository: str, optional - URL for the repository. Default is None. - branch: str, optional - The tracked Git branch. Default is 'main'. - directory: str, optional - Directory containing the content. Default is '.' - polling: bool, optional - Indicates that the Git repository is regularly polled. Default is False. - - Returns - ------- - None - - See Also - -------- - * https://docs.posit.co/connect/api/#patch-/v1/content/-guid-/repository - """ - result = self._patch_api(json=cast(JsonifiableDict, dict(attrs))) - return ContentItemRepository( - self._ctx, - content_guid=self["content_guid"], - **result, # pyright: ignore[reportCallIssue] - ) - - -class ContentItemOAuth(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( + self._ctx, + ) -class ContentItemOwner(Resource): +class ContentItemOwner(ContentItemResourceDict): pass -class ContentItem(JobsMixin, PackagesMixin, VanityMixin, Resource): +class ContentItem(JobsMixin, PackagesMixin, ContentItemVanityMixin, ContentItemActiveDict): class _AttrsBase(TypedDict, total=False): # # `name` will be set by other _Attrs classes # name: str @@ -222,7 +102,7 @@ class _AttrsCreate(_AttrsBase): def __init__( self, /, - params: ResourceParameters, + ctx: Context, guid: str, ) -> None: ... @@ -230,7 +110,7 @@ def __init__( def __init__( self, /, - params: ResourceParameters, + ctx: Context, guid: str, **kwargs: Unpack[ContentItem._Attrs], ) -> None: ... @@ -238,30 +118,33 @@ def __init__( def __init__( self, /, - params: ResourceParameters, + ctx: Context, guid: str, **kwargs: Unpack[ContentItem._AttrsNotRequired], ) -> None: - _assert_guid(guid) + assert_guid(guid) - ctx = Context(params.session, params.url) + ctx = ContentItemContext(ctx, 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 @@ -288,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. @@ -308,13 +190,12 @@ def deploy(self) -> tasks.Task: -------- >>> task = content.deploy() >>> task.wait_for() - 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(self._ctx) return ts.get(result["task_id"]) def render(self) -> Task: @@ -330,10 +211,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( @@ -359,16 +240,18 @@ def restart(self) -> None: -------- >>> restart() """ - self.update() # pyright: ignore[reportCallIssue] + full_content_item = 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.params.url), f"content/{self['guid']}") - self.params.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( @@ -378,7 +261,7 @@ def restart(self) -> None: def update( self, **attrs: Unpack[ContentItem._Attrs], - ) -> None: + ) -> ContentItem: """Update the content item. Parameters @@ -438,38 +321,45 @@ 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=attrs) + assert isinstance(result, dict) + assert "guid" in result + new_content_item = ContentItem( + self._ctx, + # `guid=` is contained within the `result` dict + **result, + ) + # 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(self._ctx) @property def environment_variables(self) -> EnvVars: - return EnvVars(self.params, self["guid"]) + return EnvVars(self._ctx) @property def permissions(self) -> Permissions: - return Permissions(self.params, self["guid"]) + return Permissions(self._ctx) @property - def owner(self) -> dict: - if "owner" not in self: + 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"] = Users(self.params).get(self["owner_guid"]) - return self["owner"] + self._owner: User = Users(self._ctx).get(self["owner_guid"]) + return self._owner @property def _variants(self) -> Variants: - return Variants(self.params, self["guid"]) + return Variants(self._ctx) @property def is_interactive(self) -> bool: @@ -497,7 +387,7 @@ def is_rendered(self) -> bool: } -class Content(Resources): +class Content(ApiCallMixin, ContextP[Context]): """Content resource. Parameters @@ -512,11 +402,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: @@ -592,9 +484,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( @@ -680,11 +572,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() @@ -853,6 +745,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/env.py b/src/posit/connect/env.py index 60a58b02..9cbeb49c 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 ._api_call import ApiCallMixin +from ._types_content_item import ContentItemContext +from ._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/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/jobs.py b/src/posit/connect/jobs.py index 96fff315..10e657a2 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -1,10 +1,12 @@ +from __future__ import annotations + import posixpath -from typing import Any, Literal, Optional, overload +from typing import Any, Literal, Optional from typing_extensions import NotRequired, Required, TypedDict, Unpack -from .context import Context -from .resources import Active, ActiveFinderMethods, ActiveSequence, Resource +from ._active import ActiveDict, ActiveFinderSequence +from ._types_content_item import ContentItemContext, ContentItemP JobTag = Literal[ "unknown", @@ -33,7 +35,7 @@ ] -class Job(Active): +class Job(ActiveDict): class _Job(TypedDict): # Identifiers id: Required[str] @@ -101,7 +103,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: @@ -117,22 +119,20 @@ 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(ActiveFinderSequence[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') """ - super().__init__(ctx, path, "key") + path = posixpath.join(ctx.content_path, "jobs") + super().__init__(ctx, path, uid="key") def _create_instance(self, path: str, /, **attributes: Any) -> Job: """Creates a Job instance. @@ -150,7 +150,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]] @@ -215,8 +215,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. @@ -268,30 +270,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 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 - path = posixpath.join(path, "jobs") - self.jobs = Jobs(ctx, path) + return Jobs(self._ctx) 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/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/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..75458db8 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: IntegrationContext) -> None: + super().__init__() + self._ctx = ctx - 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..10535448 100644 --- a/src/posit/connect/oauth/integrations.py +++ b/src/posit/connect/oauth/integrations.py @@ -1,56 +1,82 @@ """OAuth integration resources.""" -from typing import List, Optional, overload +from typing import List, Optional, cast, overload -from ..resources import Resource, Resources +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(self.params, integration_guid=self["guid"]) + return IntegrationAssociations( + 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) + assert result is not None, "Integration update failed" + assert "guid" in result, "Integration update failed. No guid returned." + return Integration(self._ctx, **result) - @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( @@ -98,10 +124,9 @@ 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) + assert result is not None, "Integration creation failed" + return Integration(self._ctx, **result) def find(self) -> List[Integration]: """Find OAuth integrations. @@ -110,16 +135,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: @@ -133,7 +157,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) diff --git a/src/posit/connect/oauth/oauth.py b/src/posit/connect/oauth/oauth.py index 306170b8..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 +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(self.params) + return Integrations(self._ctx) @property def sessions(self): - return Sessions(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/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) diff --git a/src/posit/connect/packages.py b/src/posit/connect/packages.py index 27e24475..64c156de 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 ActiveFinderSequence, 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,25 +20,30 @@ 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( + ActiveFinderSequence["ContentPackage", ContentItemContext], +): """A collection of packages.""" - def __init__(self, ctx, path): - super().__init__(ctx, path, "name") + def __init__(self, ctx: ContentItemContext): + path = posixpath.join(ctx.content_path, "packages") + 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): @@ -88,17 +93,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,13 +129,13 @@ 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): - super().__init__(ctx, path, "name") +class Packages(ActiveFinderSequence["Package", Context]): + def __init__(self, ctx: Context): + path = "v1/packages" + super().__init__(ctx, path, uid="name") def _create_instance(self, path, /, **attributes): # noqa: ARG002 return Package(self._ctx, **attributes) @@ -146,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)) diff --git a/src/posit/connect/permissions.py b/src/posit/connect/permissions.py index c5c9a268..e873d66d 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,15 @@ 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) + assert result is not None, "Permission update failed." + return Permission(self._ctx, **result) -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 +115,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 +127,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 +155,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/src/posit/connect/resources.py b/src/posit/connect/resources.py deleted file mode 100644 index 90598e66..00000000 --- a/src/posit/connect/resources.py +++ /dev/null @@ -1,236 +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, -) - -from typing_extensions import Self - -if TYPE_CHECKING: - import requests - - 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/src/posit/connect/tasks.py b/src/posit/connect/tasks.py index 0319b05a..c80cdb54 100644 --- a/src/posit/connect/tasks.py +++ b/src/posit/connect/tasks.py @@ -4,10 +4,87 @@ 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 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 + super().__init__(task_ctx, path, get_data, **kwargs) -class Task(resources.Resource): @property def is_finished(self) -> bool: """The task state. @@ -50,7 +127,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 +141,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 +158,41 @@ 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) + new_task = Task( + self._ctx, + **result, + ) + 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 + + while not cur_task.is_finished: + cur_task = self.update() + + 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 +237,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/users.py b/src/posit/connect/users.py index dd9c6833..1850e57f 100644 --- a/src/posit/connect/users.py +++ b/src/posit/connect/users.py @@ -2,22 +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 -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=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. @@ -42,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): """ @@ -68,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] @@ -84,8 +199,8 @@ class UpdateUser(TypedDict): def update( self, - **kwargs: Unpack[UpdateUser], - ) -> None: + **kwargs: Unpack[_UpdateUser], + ) -> User: """ Update the user's attributes. @@ -116,18 +231,21 @@ 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) + assert result is not None, "User update failed." + + 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): + class _CreateUser(TypedDict): """Create user request.""" username: Required[str] @@ -142,7 +260,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. @@ -197,18 +315,18 @@ 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) + assert result is not None, "User creation failed." + return User(self._ctx, **result) - 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. @@ -240,18 +358,18 @@ 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 ] - 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. @@ -283,13 +401,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 @@ -313,11 +431,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: @@ -328,7 +445,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/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index 5f9a1679..37441b56 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -1,12 +1,17 @@ -from typing import Callable, List, Optional +from __future__ import annotations + +from typing import Callable, Optional, Protocol from typing_extensions import NotRequired, Required, TypedDict, Unpack +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 .resources import Resource, ResourceParameters, Resources -class Vanity(Resource): +class Vanity(ContentItemActiveDict): """A vanity resource. Vanities maintain custom URL paths assigned to content. @@ -44,32 +49,34 @@ 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. 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 """ - 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. @@ -87,17 +94,20 @@ 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() -class Vanities(Resources): +class Vanities(ApiCallMixin, ContextP[Context]): """Manages a collection of vanities.""" - def all(self) -> List[Vanity]: + def __init__(self, ctx: Context) -> None: + super().__init__() + self._ctx = ctx + self._path = "v1/vanities" + + def all(self) -> list[Vanity]: """Retrieve all vanities. Returns @@ -108,29 +118,48 @@ 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() - 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 + content_item_ctx = ContentItemContext(self._ctx, content_guid=result["content_guid"]) -class VanityMixin(Resource): - """Mixin class to add a vanity attribute to a resource.""" + ret.append(Vanity(content_item_ctx, **result)) + return ret - class HasGuid(TypedDict): - """Has a guid.""" - guid: Required[str] +class ContentItemVanityP(ContentItemP, Protocol): + _vanity: Vanity | None - def __init__(self, params: ResourceParameters, **kwargs: Unpack[HasGuid]): - super().__init__(params, **kwargs) - self._content_guid = kwargs["guid"] - self._vanity: Optional[Vanity] = None + def find_vanity(self) -> Vanity: ... + + def create_vanity( + self, **kwargs: Unpack["ContentItemVanityMixin._CreateVanityRequest"] + ) -> Vanity: ... + + def reset_vanity(self) -> None: ... @property - def vanity(self) -> Optional[str]: + def vanity(self) -> Optional[str]: ... + + @vanity.setter + def vanity(self, value: str) -> None: ... + + @vanity.deleter + def vanity(self) -> None: ... + + +class ContentItemVanityMixin: + """Class to add a vanity attribute to a resource.""" + + @property + def vanity(self: ContentItemVanityP) -> str | None: """Get the vanity.""" - if self._vanity: + if hasattr(self, "_vanity") and self._vanity: return self._vanity["path"] try: @@ -143,7 +172,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 @@ -163,7 +192,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 @@ -185,14 +214,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] @@ -201,7 +230,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 @@ -215,19 +244,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/src/posit/connect/variants.py b/src/posit/connect/variants.py index eb6a28c0..d0f2233b 100644 --- a/src/posit/connect/variants.py +++ b/src/posit/connect/variants.py @@ -1,25 +1,28 @@ from typing import List -from .resources import Resource, ResourceParameters, Resources +from ._active import ResourceDict +from ._types_content_item import ContentItemContext 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(self._ctx, **response.json()) -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(self.params, **result) for result in results] + return [Variant(self._ctx, **result) for result in results] 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/__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/__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/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 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 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: diff --git a/tests/posit/connect/test_api_endpoint.py b/tests/posit/connect/test_api_endpoint.py index 2281084f..01a19a1a 100644 --- a/tests/posit/connect/test_api_endpoint.py +++ b/tests/posit/connect/test_api_endpoint.py @@ -1,11 +1,11 @@ import pytest -from posit.connect._api import ReadOnlyDict +from posit.connect._active import ReadOnlyDict 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} 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 926df753..a3155349 100644 --- a/tests/posit/connect/test_content.py +++ b/tests/posit/connect/test_content.py @@ -3,10 +3,10 @@ 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 -from posit.connect.resources import ResourceParameters from posit.connect.urls import Url from .api import load_mock, load_mock_dict @@ -82,7 +82,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 @@ -119,8 +119,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 +562,11 @@ def content_guid(self): @property def content_item(self): - return ContentItem(self.params, guid=self.content_guid) + return ContentItem( + self.ctx, + guid=self.content_guid, + name="testing", # provide name to avoid request + ) @property def endpoint(self): @@ -570,11 +574,10 @@ def endpoint(self): @property def ctx(self): - return Context(requests.Session(), Url(self.base_url)) - - @property - def params(self): - return ResourceParameters(self.ctx.session, self.ctx.url) + return ContentItemContext( + Context(requests.Session(), Url(self.base_url)), + content_guid=self.content_guid, + ) def mock_repository_info(self): content_item = self.content_item 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"), 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) 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" 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 diff --git a/tests/posit/connect/test_users.py b/tests/posit/connect/test_users.py index 68adbb3b..a2c55727 100644 --- a/tests/posit/connect/test_users.py +++ b/tests/posit/connect/test_users.py @@ -68,8 +68,8 @@ def test_lock(self): "https://connect.example/__api__/v1/users/a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6/lock", match=[responses.matchers.json_params_matcher({"locked": True})], ) - user.lock() - assert user["locked"] + locked_user = user.lock() + assert locked_user["locked"] @responses.activate def test_lock_self_true(self): @@ -89,8 +89,8 @@ def test_lock_self_true(self): "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4/lock", match=[responses.matchers.json_params_matcher({"locked": True})], ) - 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): @@ -130,8 +130,8 @@ def test_unlock(self): "https://connect.example/__api__/v1/users/20a79ce3-6e87-4522-9faf-be24228800a4/lock", match=[responses.matchers.json_params_matcher({"locked": False})], ) - user.unlock() - assert not user["locked"] + unlocked_user = user.unlock() + assert not unlocked_user["locked"] class TestUsers: @@ -173,7 +173,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 +182,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 cfa3dd4a..843caaad 100644 --- a/tests/posit/connect/test_vanities.py +++ b/tests/posit/connect/test_vanities.py @@ -4,9 +4,11 @@ import responses from responses.matchers import json_params_matcher -from posit.connect.resources import ResourceParameters +from posit.connect._types_content_item import ContentItemContext +from posit.connect.content import ContentItem +from posit.connect.context import Context 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 +21,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 +38,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(), ) @@ -59,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() @@ -77,8 +78,12 @@ def test_vanity_getter_returns_vanity(self): session = requests.Session() url = Url(base_url) - params = ResourceParameters(session, url) - content = VanityMixin(params, guid=guid) + ctx = Context(session, url) + content = ContentItem( + ctx, + guid=guid, + name="testing", # provide name to avoid request + ) assert content.vanity == "my-dashboard" assert mock_get.call_count == 1 @@ -97,8 +102,12 @@ def test_vanity_setter_with_string(self): session = requests.Session() url = Url(base_url) - params = ResourceParameters(session, url) - content = VanityMixin(params, guid=guid) + ctx = Context(session, url) + content = ContentItem( + ctx, + guid=guid, + name="testing", # provide name to avoid request + ) content.vanity = path assert content.vanity == path @@ -113,9 +122,14 @@ 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()) + ctx = Context(session, url) + content = ContentItem( + ctx, + 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