Skip to content
Merged
140 changes: 2 additions & 138 deletions src/posit/connect/_api.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
# TODO-barret-future; Piecemeal migrate everything to leverage `ApiDictEndpoint` and `ApiListEndpoint` classes.
# TODO-barret-future; Piecemeal migrate everything to leverage `ApiDictEndpoint`
# TODO-barret-future; Merge any trailing behavior of `Active` or `ActiveList` into the new classes.

from __future__ import annotations

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 typing import TYPE_CHECKING, Any, Optional, cast

from ._api_call import ApiCallMixin, get_api
from ._json import Jsonifiable, JsonifiableDict, ResponseAttrs
Expand Down Expand Up @@ -143,136 +140,3 @@ def __init__(
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,
)
18 changes: 9 additions & 9 deletions src/posit/connect/_api_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ def _patch_api(self, *path, json: Jsonifiable | None) -> Jsonifiable: ...
def _put_api(self, *path, json: Jsonifiable | None) -> Jsonifiable: ...


def endpoint(ctx: Context, *path) -> str:
return ctx.url + posixpath.join(*path)
def endpoint(*path) -> str:
return posixpath.join(*path)


# Helper methods for API interactions
def get_api(ctx: Context, *path) -> Jsonifiable:
response = ctx.session.get(endpoint(ctx, *path))
response = ctx.client.get(*path)
return response.json()


Expand All @@ -34,7 +34,7 @@ def put_api(
*path,
json: Jsonifiable | None,
) -> Jsonifiable:
response = ctx.session.put(endpoint(ctx, *path), json=json)
response = ctx.client.put(*path, json=json)
return response.json()


Expand All @@ -43,14 +43,14 @@ def put_api(

class ApiCallMixin:
def _endpoint(self: ApiCallProtocol, *path) -> str:
return endpoint(self._ctx, self._path, *path)
return endpoint(self._path, *path)

def _get_api(self: ApiCallProtocol, *path) -> Jsonifiable:
response = self._ctx.session.get(self._endpoint(*path))
response = self._ctx.client.get(self._endpoint(*path))
return response.json()

def _delete_api(self: ApiCallProtocol, *path) -> Jsonifiable | None:
response = self._ctx.session.delete(self._endpoint(*path))
response = self._ctx.client.delete(self._endpoint(*path))
if len(response.content) == 0:
return None
return response.json()
Expand All @@ -60,13 +60,13 @@ def _patch_api(
*path,
json: Jsonifiable | None,
) -> Jsonifiable:
response = self._ctx.session.patch(self._endpoint(*path), json=json)
response = self._ctx.client.patch(self._endpoint(*path), json=json)
return response.json()

def _put_api(
self: ApiCallProtocol,
*path,
json: Jsonifiable | None,
) -> Jsonifiable:
response = self._ctx.session.put(self._endpoint(*path), json=json)
response = self._ctx.client.put(self._endpoint(*path), json=json)
return response.json()
37 changes: 17 additions & 20 deletions src/posit/connect/bundles.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
from __future__ import annotations

import io
from typing import List
from typing import TYPE_CHECKING, List

from . import resources, tasks

if TYPE_CHECKING:
from .context import Context


class BundleMetadata(resources.Resource):
pass
Expand All @@ -15,13 +18,12 @@ class BundleMetadata(resources.Resource):
class Bundle(resources.Resource):
@property
def metadata(self) -> BundleMetadata:
return BundleMetadata(self.params, **self.get("metadata", {}))
return BundleMetadata(self._ctx, **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._ctx.client.delete(path)

def deploy(self) -> tasks.Task:
"""Deploy the bundle.
Expand All @@ -40,10 +42,9 @@ def deploy(self) -> tasks.Task:
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"]})
response = self._ctx.client.post(path, json={"bundle_id": self["id"]})
result = response.json()
ts = tasks.Tasks(self.params)
ts = tasks.Tasks(self._ctx)
return ts.get(result["task_id"])

def download(self, output: io.BufferedWriter | str) -> None:
Expand Down Expand Up @@ -78,8 +79,7 @@ def download(self, output: io.BufferedWriter | str) -> None:
)

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 = self._ctx.client.get(path, stream=True)
if isinstance(output, io.BufferedWriter):
for chunk in response.iter_content():
output.write(chunk)
Expand Down Expand Up @@ -109,10 +109,10 @@ class Bundles(resources.Resources):

def __init__(
self,
params: resources.ResourceParameters,
ctx: Context,
content_guid: str,
) -> None:
super().__init__(params)
super().__init__(ctx)
self.content_guid = content_guid

def create(self, archive: io.BufferedReader | bytes | str) -> Bundle:
Expand Down Expand Up @@ -164,10 +164,9 @@ def create(self, archive: io.BufferedReader | bytes | str) -> Bundle:
)

path = f"v1/content/{self.content_guid}/bundles"
url = self.params.url + path
response = self.params.session.post(url, data=data)
response = self._ctx.client.post(path, data=data)
result = response.json()
return Bundle(self.params, **result)
return Bundle(self._ctx, **result)

def find(self) -> List[Bundle]:
"""Find all bundles.
Expand All @@ -178,10 +177,9 @@ def find(self) -> List[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)
response = self._ctx.client.get(path)
results = response.json()
return [Bundle(self.params, **result) for result in results]
return [Bundle(self._ctx, **result) for result in results]

def find_one(self) -> Bundle | None:
"""Find a bundle.
Expand All @@ -208,7 +206,6 @@ def get(self, uid: str) -> 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)
response = self._ctx.client.get(path)
result = response.json()
return Bundle(self.params, **result)
return Bundle(self._ctx, **result)
11 changes: 5 additions & 6 deletions src/posit/connect/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from .groups import Groups
from .metrics import Metrics
from .oauth import OAuth
from .resources import ResourceParameters, _PaginatedResourceSequence, _ResourceSequence
from .resources import _PaginatedResourceSequence, _ResourceSequence
from .tags import Tags
from .tasks import Tasks
from .users import User, Users
Expand Down Expand Up @@ -159,7 +159,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)

@property
Expand Down Expand Up @@ -207,7 +206,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:
Expand Down Expand Up @@ -281,7 +280,7 @@ def metrics(self) -> Metrics:
>>> len(events)
24
"""
return Metrics(self.resource_params)
return Metrics(self._ctx)

@property
@requires(version="2024.08.0")
Expand All @@ -294,7 +293,7 @@ def oauth(self) -> OAuth:
OAuth
The oauth API instance.
"""
return OAuth(self.resource_params, self.cfg.api_key)
return OAuth(self._ctx, self.cfg.api_key)

@property
@requires(version="2024.11.0")
Expand All @@ -303,7 +302,7 @@ def packages(self) -> _Packages:

@property
def vanities(self) -> Vanities:
return Vanities(self.resource_params)
return Vanities(self._ctx)

@property
@requires(version="2023.05.0")
Expand Down
Loading
Loading