diff --git a/src/posit/connect/README.md b/src/posit/connect/README.md index 3f2c0056..15bbb071 100644 --- a/src/posit/connect/README.md +++ b/src/posit/connect/README.md @@ -2,13 +2,14 @@ > Note: this is design-by-wishful-thinking, not how things actually work today. > To discuss or propose changes, open a PR suggesting new language. + ### Connecting To get started, import the Connect `Client` and create a connection. You can specify the `endpoint` for your Connect server URL and your `api_key`; if not specified, they'll be pulled from the environment (`CONNECT_SERVER` and `CONNECT_API_KEY`). It is expected that `Client()` just works from within any Posit product's environment (Workbench, Connect, etc.), either by API key and prior system configuration, or by some means of identity federation. -``` +```python from posit.connect import Client con = Client() @@ -18,31 +19,31 @@ con = Client() Many resources in the SDK refer to *collections* of *entities* or records in Connect. -All of the general collections can be referenced as properties of the Client object (e.g. `client.content`, `client.users`). Some collections belong to a single entity and are referenced from them similarly (e.g. `content_item.permissions`). +All of the general collections can be referenced as properties of the Client object (e.g. `client.content`, `client.users`). Some collections belong to a single entity and are referenced from them similarly (e.g. `content_item.permissions`). All collections are iterable objects with all read-only List-like methods implemented. They also have the following methods: -* `.find()`: returns another iterable collection object. - * Calling `.find()` with no arguments retrieves all available entities - * If no entities match the query, `.find()` returns a length-0 collection. - * Iterating over a collection without having first called `find()` is equivalent to having queried for all. - * `find()` should use query-based REST APIs where existing, and fall back to retrieving all and filtering client-side where those APIs do not (yet) exist. - * Should `collection.find().find()` work? Probably. +* `.find()`: returns another iterable collection object. + * Calling `.find()` with no arguments retrieves all available entities + * If no entities match the query, `.find()` returns a length-0 collection. + * Iterating over a collection without having first called `find()` is equivalent to having queried for all. + * `find()` should use query-based REST APIs where existing, and fall back to retrieving all and filtering client-side where those APIs do not (yet) exist. + * Should `collection.find().find()` work? Probably. * `.get(guid)` method that returns a single entity by id. If one is not found, it raises `NotFoundError` * `.find_one()` is a convenience method that queries with `.find()` and returns a single entity - * If more than one entity match the query, `.find_one()` returns the first - * If no entities match, `.find_one()` returns `None` - * If you need stricter behavior (e.g. you want to be sure that one and only one entity are returned by your query), use `.find()` or `.get()`. -* `.to_pandas()` materializes the collection in a pandas `DataFrame`. - * pandas is not a required dependency of the SDK. `.to_pandas()` should try to import inside the method. + * If more than one entity match the query, `.find_one()` returns the first + * If no entities match, `.find_one()` returns `None` + * If you need stricter behavior (e.g. you want to be sure that one and only one entity are returned by your query), use `.find()` or `.get()`. +* `.to_pandas()` materializes the collection in a pandas `DataFrame`. + * pandas is not a required dependency of the SDK. `.to_pandas()` should try to import inside the method. -The `.find()` and `.find_one()` methods use named arguments rather than accepting a dict so that IDE tab completion can work. +The `.find()` and `.find_one()` methods use named arguments rather than accepting a dict so that IDE tab completion can work. -Collections should handle all API reponse pagination invisibly so that the Python user doesn't need to worry about pages. +Collections should handle all API reponse pagination invisibly so that the Python user doesn't need to worry about pages. -Entities have methods that are appropriate to them. Fields in the entity bodies can be accessed as properties. +Entities have methods that are appropriate to them. Fields in the entity bodies can be accessed as properties. -``` +```python for st in con.content.find(app_mode="streamlit"): print(st.title) @@ -53,9 +54,9 @@ for perm in my_app.permissions: ### Mapping to HTTP request methods -Entities have an `.update()` method that maps to a `PATCH` request. `.delete()` is `DELETE`. +Entities have an `.update()` method that maps to a `PATCH` request. `.delete()` is `DELETE`. -``` +```python my_app.update(title="Quarterly Analysis of Team Velocity") my_app.permissions.find_one(email="first.last@example.com").update(role="owner") my_app.permissions.find_one(email="first.last@example.com").delete() @@ -63,20 +64,158 @@ my_app.permissions.find_one(email="first.last@example.com").delete() Collections have a `.create()` method that maps to `POST` to create a new entity. It may be aliased to other verbs as appropriate for the entity. -``` +```python my_app.permissions.add(email="my.boss@example.com", role="viewer") ``` ### Field/attribute naming -The Python SDK should present the interface we wish we had, and we can evolve the REST API to match that over time. It is the adapter layer that allows us to evolve the Connect API more freely. +The Python SDK should present the interface we wish we had, and we can evolve the REST API to match that over time. It is the adapter layer that allows us to evolve the Connect API more freely. -Naming of fields and arguments in collection and entity methods should be standardized across entity types for consistency, even if this creates a gap between our current REST API specification. +Naming of fields and arguments in collection and entity methods should be standardized across entity types for consistency, even if this creates a gap between our current REST API specification. -As a result, the SDK takes on the burden of smoothing over the changes in the Connect API over time. Each collection and entity class may need its own adapter methods that take the current Python SDK field names and maps to the values for the version of the Connect server being used when passing to the HTTP methods. +As a result, the SDK takes on the burden of smoothing over the changes in the Connect API over time. Each collection and entity class may need its own adapter methods that take the current Python SDK field names and maps to the values for the version of the Connect server being used when passing to the HTTP methods. Entity `.to_dict()` methods likewise present the names and values in the Python interface, which may not map to the actual HTTP response body JSON. There should be some other way to access the raw response body. ### Lower-level HTTP interface -The client object has `.get`, `.post`, etc. methods that pass arguments through to the `requests` methods, accepting URL paths relative to the API root and including the necessary authorization. These are invoked inside the collection and entity action methods, and they are also available for users to call directly, whether because there are API resources we haven't wrapped in Pythonic methods yet, or because they are simpler RPC-style endpoints that just need to be hit directly. \ No newline at end of file +The client object has `.get`, `.post`, etc. methods that pass arguments through to the `requests` methods, accepting URL paths relative to the API root and including the necessary authorization. These are invoked inside the collection and entity action methods, and they are also available for users to call directly, whether because there are API resources we haven't wrapped in Pythonic methods yet, or because they are simpler RPC-style endpoints that just need to be hit directly. + +### Constructing classes + +Classes that contain dictionary-like data should inherit from `ReadOnlyDict` (or one of its subsclasses) and classes that contain list-like data should inherit from `ReadOnlySequence` (or one of its subsclasses). + +#### Classes + +`ReadOnlyDict` was created to provide a non-interactive interface to the data being returned for the class. This way users can not set any values without going through the API. By extension, any method that would change the data should return a new instance with the updated data. E.g. `.update()` methods should return a instance. The same applies for `ReadOnlySequence` classes. + +When retrieving objects from the server, it should be retrieved through a `@property` method. This way, the data is only retrieved when it is needed. This is especially important for list-like objects. + +```python +class ContentItem(..., ContentItemActiveDict): + ... + + @property + def repository(self) -> ContentItemRepository | None: + try: + return ContentItemRepository(self._ctx) + except ClientError: + return None + + ... +``` + +To avoid confusion between api exploration and internal values, all internal values should be prefixed with an underscore. This way, users can easily see what is part of the API and what is part of the internal workings of the class. + +Attempt to minimize the number of locations where the same intended `path` is defined. Preferably only at `._path` (so it works with `ApiCallMixin`). + +```python +class Bundles(ApiCallMixin, ContextP[ContentItemContext]): + def __init__( + self, + ctx: ContentItemContext, + ) -> None: + super().__init__() + self._ctx = ctx + self._path = f"v1/content/{ctx.content_guid}/bundles" + ... +``` + +#### Context + +* `Context` - A convenience class that holds information that can be passed down to child classes. + * Contains the request `.session` and `.url` information for easy API calls. + * By inheriting from `Context`, it can be extended to contain more information (e.g. `ContentItemContext` adds `.content_path` and `.content_guid` to remove the requirement of passing through `content_guid` as a parameter). + * These classes help prevent an explosion of parameters being passed through the classes. +* `ContextP` - Protocol class that defines the attributes that a Context class should have. +* `ContextT` - Type variable that defines the type of the Context class. +* `ApiCallMixin` - Mixin class that provides helper methods for API calls and parsing the JSON repsonse. (e.g. `._get_api()`) + * It requires `._path: str` to be defined on the instance. + +#### Ex: Content Item helper classes + +These example classes show how the Entity and Context classes can be extended to provide helper classes for classes related to `ContentItem`. + +* `ContentItemP` - Extends `ContextP` with context class set to `ContentItemContext`. +* `ContentItemContext` - Extends `Context` by including `content_path` and `content_guid` attributes. +* `ContentItemResourceDict` - Extends `ResourceDict` with context class set to `ContentItemContext`. +* `ContentItemActiveDict` - Extends `ActiveDict` with context class set to `ContentItemContext`. + +#### Entity Classes + +All entity classes are populated on initialization. + +* `ReadOnlyDict` - A class that provides a read-only dictionary interface to the data. + * Immutable dictionary-like object that can be iterated over. +* `ResourceDict` - Extends `ReadOnlyDict`, but is aware of `._ctx: ContextT`. +* `ActiveDict` - Extends `ResourceDict`, but is aware of the API calls (`ApiCallMixin`) that can be made on the data. + +Example: `Bundle` class's init method + +```python +class BundleContext(ContentItemContext): + bundle_id: str + + def __init__(self, ctx: ContentItemContext, /, *, bundle_id: str) -> None: + super().__init__(ctx, content_guid=ctx.content_guid) + self.bundle_id = bundle_id + +class Bundle(ApiDictEndpoint[BundleContext]): + def __init__(self, ctx: ContentItemContext, /, **kwargs) -> None: + bundle_id = kwargs.get("id") + assert isinstance(bundle_id, str), f"Bundle 'id' must be a string. Got: {id}" + assert bundle_id, "Bundle 'id' must not be an empty string." + + bundle_ctx = BundleContext(ctx, bundle_id=bundle_id) + path = f"v1/content/{ctx.content_guid}/bundles/{bundle_id}" + get_data = len(kwargs) == 1 # `id` is required + super().__init__(bundle_ctx, path, get_data, **kwargs) + ... +``` + +When possible `**kwargs` should be typed with `**kwargs: Unpack[_Attrs]` where `_Attrs` is a class that defines the attributes that can be passed to the class. (Please define the attribute class within the usage class and have its name start with a `_`) By using `Unpack` and `**kwargs`, it allows for future new/conflicting parameters can be type ignored by the caller, but they will be sent through in the implementation. + +Example: + +```python +class Association(ResourceDict): + class _Attrs(TypedDict, total=False): + app_guid: str + """The unique identifier of the content item.""" + oauth_integration_guid: str + """The unique identifier of an existing OAuth integration.""" + oauth_integration_name: str + """A descriptive name that identifies the OAuth integration.""" + oauth_integration_description: str + """A brief text that describes the OAuth integration.""" + oauth_integration_template: str + """The template used to configure this OAuth integration.""" + created_time: str + """The timestamp (RFC3339) indicating when this association was created.""" + + def __init__(self, ctx: Context, /, **kwargs: Unpack["Association._Attrs"]) -> None: + super().__init__(ctx, **kwargs) +``` + +#### Collection classes + +* `ReadOnlySequence` - A class that provides a read-only list interface to the data. + * Immutable list-like object that can be iterated over. +* `ResourceSequence` - Extends `ReadOnlySequence`, but is aware of `._ctx: ContextT`. + * Wants data to immediately exist in the class. +* `ActiveSequence` - Extends `ResourceSequence`, but is aware of the API calls that can be made on the data. It requires `._path` + * Requires `._create_instance(path: str, **kwars: Any) -> ResourceDictT` method to be implemented. + * During initialization, if the data is not provided, it will be fetched from the API. (...unless `get_data=False` is passed as a parameter) +* `ActiveFinderSequence` - Extends `ActiveSequence` with `.find()` and `.find_by()` methods. + +For Collections classes, if no data is to be maintained, the class should inherit from `ContextP[CONTEXT_CLASS]`. This will help pass through the `._ctx` to children objects. If API calls are needed, it can also inherit from `ApiCallMixin` to get access to its conveniece methods (e.g. `._get_api()` which returns a parsed json result). + + +When making a new class, +* Use a class to define the parameters and their types + * If attaching the type info class to the parent class, start with `_`. E.g.: `ContentItemRepository._Attrs` +* Document all attributes like normal + * When the time comes that there are multiple attribute types, we can use overloads with full documentation and unpacking of type info class for each overload method. +* Inherit from `ApiDictEndpoint` or `ApiListEndpoint` as needed + * Init signature should be `def __init__(self, ctx: Context, path: str, /, **attrs: Jsonifiable) -> None:` diff --git a/src/posit/connect/_active.py b/src/posit/connect/_active.py new file mode 100644 index 00000000..ea5cf28c --- /dev/null +++ b/src/posit/connect/_active.py @@ -0,0 +1,412 @@ +# ################################ +# Design Notes +# +# Please see the design notes in `src/posit/connect/README.md` for example usages. +# ################################ + +from __future__ import annotations + +import posixpath +from abc import ABC, abstractmethod +from collections.abc import Mapping as Mapping_abc +from typing import ( + Any, + Generator, + Iterator, + Optional, + SupportsIndex, + Tuple, + TypeVar, + cast, + overload, +) + +from ._api_call import ApiCallMixin, ContextP, get_api +from ._json import Jsonifiable, JsonifiableList, ResponseAttrs +from ._types_context import ContextT + +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_abc): + """A read-only dict abstraction.""" + + _dict: ResponseAttrs + """Data dictionary.""" + + def __init__(self, **kwargs: Any) -> None: + """ + A read-only dict abstraction for any HTTP endpoint that returns a singular resource. + + Parameters + ---------- + **kwargs : Any + Values to be stored + """ + super().__init__() + self._dict = kwargs + + def get(self, key: str, default: Any = None) -> Any: + return self._dict.get(key, default) + + def __getitem__(self, key: str) -> Any: + return self._dict[key] + + def __setitem__(self, key: str, value: Any) -> None: + raise NotImplementedError( + "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." + ) + + # * Only expose methods needed for `ReadOnlyDict`. + # * Ex: If inheriting from `dict`, we would need to shut down `update`, `pop`, etc. + + def __len__(self) -> int: + return self._dict.__len__() + + def __iter__(self): + return self._dict.__iter__() + + def __contains__(self, key: object) -> bool: + return self._dict.__contains__(key) + + def __repr__(self) -> str: + if hasattr(self, "_dict"): + return repr(self._dict) + return object.__repr__(self) + + def __str__(self) -> str: + return str(self._dict) + + def keys(self): + return self._dict.keys() + + def values(self): + return self._dict.values() + + def items(self): + return self._dict.items() + + +class ResourceDict(ReadOnlyDict, ContextP[ContextT]): + """An abstraction to contain the context and read-only information.""" + + _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. + **kwargs : Any + Values to be stored + """ + super().__init__(**kwargs) + self._ctx = ctx + + +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.""" + + 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. + + Adds helper methods to interact with the API with reduced boilerplate. + + Parameters + ---------- + ctx : ContextT + 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. + **kwargs : Any + Resource attributes passed + """ + # If no attributes are provided, fetch the API and set the attributes from the response + if get_data is None: + get_data = len(kwargs) == 0 + + # 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 + + super().__init__(ctx, **kwargs) + self._path = path + + +class ReadOnlySequence(Tuple[ResourceDictT, ...]): + """Read only Sequence.""" + + _data: Tuple[ResourceDictT, ...] + + def _set_data(self, data: Tuple[ResourceDictT, ...]) -> None: + self._data = data + + def __init__(self, *args: ResourceDictT) -> None: + """ + A read-only sequence abstraction. + + Parameters + ---------- + *args : Any + Values to be stored + """ + super().__init__() + self._data = args + + def __len__(self) -> int: + return len(tuple(self._data)) + + @overload + def __getitem__(self, key: SupportsIndex, /) -> ResourceDictT: ... + + @overload + def __getitem__(self, key: slice, /) -> tuple[ResourceDictT, ...]: ... + + def __getitem__( + self, + key: SupportsIndex | slice, + /, + ) -> ResourceDictT | tuple[ResourceDictT, ...]: + return self._data[key] + + def __iter__(self) -> Iterator[ResourceDictT]: + return iter(self._data) + + def __str__(self) -> str: + return str(self._data) + + def __repr__(self) -> str: + return repr(self._data) + + def __contains__(self, key: object) -> bool: + return key in self._data + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ReadOnlySequence): + return NotImplemented + return self._data == other._data + + def __ne__(self, other: object) -> bool: + if not isinstance(other, ReadOnlySequence): + return NotImplemented + return self._data != other._data + + def __setitem__(self, key: int, value: Any) -> None: + raise NotImplementedError( + "Values are locked. " + "To retrieve updated values, please retrieve the parent object again." + ) + + def __delitem__(self, key: int) -> None: + raise NotImplementedError( + "Values are locked. " + "To retrieve updated values, please retrieve the parent object again." + ) + + +class ResourceSequence(ReadOnlySequence[ResourceDictT], ContextP[ContextT]): + """An abstraction to contain the context and read-only tuple-like information.""" + + _ctx: ContextT + """The context object containing the session and URL for API interactions.""" + + def __init__( + self, + ctx: ContextT, + /, + *, + arr: list[ResourceDictT] | tuple[ResourceDictT, ...], + ) -> None: + """ + A read-only sequence abstraction that is Context aware. + + Parameters + ---------- + ctx : Context + The context object containing the session and URL for API interactions. + *args : Any + Values to be stored + """ + super().__init__(*tuple(arr)) + self._ctx = ctx + + +class ActiveSequence(ApiCallMixin, ABC, ResourceSequence[ResourceDictT, ContextT]): + """A read only sequence 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 : 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" + 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`. + + """ + 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 = 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) -> ResourceDictT: + """Create an instance of 'T'.""" + raise NotImplementedError() + + 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-future-q: Include params to `._get_api()`? + def _get_data(self) -> Generator[ResourceDictT, None, None]: + """Fetch the collection. + + Fetches the collection directly from Connect. + + Returns + ------- + List[T] + """ + results: Jsonifiable = self._get_api() + results_list = cast(JsonifiableList, results) + return (self._to_instance(result) for result in results_list) + + +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. + + Fetches the record from Connect by it's identifier. + + Parameters + ---------- + uid : Any + The unique identifier of the record. + + Returns + ------- + T + """ + 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: object, + ) -> ResourceDictT | 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._get_data() + conditions_items = conditions.items() + + # Get the first item of the generator that matches the conditions + return next( + ( + # Return result + result + # 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, + ) 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..ca7e23c2 --- /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[ContentItemContext]): + """ + 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..3aca2234 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 `owner` 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,44 @@ 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, + ) + 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 +386,7 @@ def is_rendered(self) -> bool: } -class Content(Resources): +class Content(ApiCallMixin, ContextP[Context]): """Content resource. Parameters @@ -512,11 +401,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 +483,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 +571,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 +744,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/context.py b/src/posit/connect/context.py index 1e275838..0beb0105 100644 --- a/src/posit/connect/context.py +++ b/src/posit/connect/context.py @@ -27,7 +27,28 @@ def wrapper(instance: ContextManager, *args, **kwargs): return decorator -class Context: +# Big mystery: `Context` is a subclass of `dict`. However, this is unnecessary. +# BUT, when `dict` is removed, the following errors occur: +# ``` +# uv run pyright +# /Users/barret/Documents/git/rstudio/posit-sdk-py/posit-sdk-py.nosync/src/posit/connect/client.py +# /Users/barret/Documents/git/rstudio/posit-sdk-py/posit-sdk-py.nosync/src/posit/connect/client.py:274:25 - error: Argument of type "Context" cannot be assigned to parameter "iterable" of type "Iterable[Package]" in function "__new__" +# "Context" is incompatible with protocol "Iterable[Package]" +# "__iter__" is not present (reportArgumentType) +# /Users/barret/Documents/git/rstudio/posit-sdk-py/posit-sdk-py.nosync/src/posit/connect/jobs.py +# /Users/barret/Documents/git/rstudio/posit-sdk-py/posit-sdk-py.nosync/src/posit/connect/jobs.py:302:21 - error: Argument of type "ContentItemContext" cannot be assigned to parameter "iterable" of type "Iterable[Job]" in function "__new__" +# "ContentItemContext" is incompatible with protocol "Iterable[Job]" +# "__iter__" is not present (reportArgumentType) +# /Users/barret/Documents/git/rstudio/posit-sdk-py/posit-sdk-py.nosync/src/posit/connect/packages.py +# /Users/barret/Documents/git/rstudio/posit-sdk-py/posit-sdk-py.nosync/src/posit/connect/packages.py:102:32 - error: Argument of type "ContentItemContext" cannot be assigned to parameter "iterable" of type "Iterable[ContentPackage]" in function "__new__" +# "ContentItemContext" is incompatible with protocol "Iterable[ContentPackage]" +# "__iter__" is not present (reportArgumentType) +# 3 errors, 0 warnings, 0 informations +# ``` +# This is a mystery because `Context` is not used as an iterable in the codebase. + + +class Context(dict): def __init__(self, session: requests.Session, url: Url): self.session = session self.url = url 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..6fdcea24 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,8 @@ def _create_instance(self, path: str, /, **attributes: Any) -> Job: class _FindByRequest(TypedDict, total=False): # Identifiers - id: Required[str] + # TODO-barret-Q: Is this change correct? It use to be required, but the docs say not-required + id: NotRequired[str] """A unique identifier for the job.""" ppid: NotRequired[Optional[str]] @@ -215,8 +216,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 +271,21 @@ 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 - """ - super().__init__(ctx, path, **attributes) + @property + def jobs(self: ContentItemP) -> Jobs: + """Get the jobs. - path = posixpath.join(path, "jobs") - self.jobs = Jobs(ctx, path) + Returns + ------- + Jobs + """ + 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..a9d70b86 100644 --- a/src/posit/connect/oauth/associations.py +++ b/src/posit/connect/oauth/associations.py @@ -1,78 +1,106 @@ """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["Association._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): +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-future-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-future-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..b1fbda16 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-future-q; 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..787ebd55 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-future-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-future-q: Should this be `.all()`? @overload def find( self, @@ -22,14 +88,15 @@ def find( all: Optional[bool] = ..., ) -> List[Session]: ... + # TODO-barret-future-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..b6a2b363 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["Task._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["Task._Attrs"]) -> None: + task_id = kwargs.get("id") + assert isinstance(task_id, str), "Task `id` must be a string." + assert task_id, "Task `id` must not be empty." + 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,40 @@ 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 + +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 +236,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..c91f2211 100644 --- a/src/posit/connect/users.py +++ b/src/posit/connect/users.py @@ -2,22 +2,107 @@ 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]): + 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 +127,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 +157,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 +177,8 @@ class UpdateUser(TypedDict): def update( self, - **kwargs: Unpack[UpdateUser], - ) -> None: + **kwargs: Unpack[_UpdateUser], + ) -> User: """ Update the user's attributes. @@ -116,18 +209,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 +238,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 +293,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 +336,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 +379,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 +409,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 +423,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..72312c40 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 ._types_context import ContextP from .tasks import Task -class Variant(Resource): +class Variant(ResourceDict): def render(self) -> Task: 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(ContextP[ContentItemContext]): + 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