Skip to content

Commit

Permalink
refactor: inject url path parts instead of endpoints (#315)
Browse files Browse the repository at this point in the history
Refactors path building responsibilities to the creating action,
eliminating a ton of complexity along the way.

---------

Co-authored-by: Barret Schloerke <[email protected]>
  • Loading branch information
tdstein and schloerke authored Oct 31, 2024
1 parent 7c8acce commit c62956c
Show file tree
Hide file tree
Showing 5 changed files with 115 additions and 159 deletions.
4 changes: 3 additions & 1 deletion src/posit/connect/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ class ContentItemOwner(Resource):
class ContentItem(JobsMixin, VanityMixin, Resource):
def __init__(self, /, params: ResourceParameters, **kwargs):
ctx = Context(params.session, params.url)
super().__init__(ctx, **kwargs)
uid = kwargs["guid"]
path = f"v1/content/{uid}"
super().__init__(ctx, path, **kwargs)

def __getitem__(self, key: Any) -> Any:
v = super().__getitem__(key)
Expand Down
66 changes: 36 additions & 30 deletions src/posit/connect/jobs.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from typing import Literal, Optional, TypedDict, overload
import posixpath
from typing import Any, Literal, Optional, TypedDict, overload

from typing_extensions import NotRequired, Required, Unpack

from .context import Context
from .resources import Active, ActiveFinderMethods, ActiveSequence, Resource

JobTag = Literal[
Expand Down Expand Up @@ -99,13 +101,8 @@ 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, parent: Active, **kwargs: Unpack[_Job]):
super().__init__(ctx, parent, **kwargs)
self._parent = parent

@property
def _endpoint(self) -> str:
return self._ctx.url + f"v1/content/{self._parent['guid']}/jobs/{self['key']}"
def __init__(self, ctx: Context, path: str, /, **attributes: Unpack[_Job]):
super().__init__(ctx, path, **attributes)

def destroy(self) -> None:
"""Destroy the job.
Expand All @@ -120,40 +117,36 @@ def destroy(self) -> None:
----
This action requires administrator, owner, or collaborator privileges.
"""
self._ctx.session.delete(self._endpoint)
endpoint = self._ctx.url + self._path
self._ctx.session.delete(endpoint)


class Jobs(
ActiveFinderMethods[Job],
ActiveSequence[Job],
):
def __init__(self, ctx, parent: Active, uid="key"):
class Jobs(ActiveFinderMethods[Job], ActiveSequence[Job]):
def __init__(self, ctx: Context, path: str):
"""A collection of jobs.
Parameters
----------
ctx : Context
The context containing the HTTP session used to interact with the API.
parent : Active
Parent resource for maintaining hierarchical relationships
uid : str, optional
The default field name used to uniquely identify records, by default "key"
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, parent, uid)
self._parent = parent
super().__init__(ctx, path, "key")

@property
def _endpoint(self) -> str:
return self._ctx.url + f"v1/content/{self._parent['guid']}/jobs"
def _create_instance(self, path: str, /, **attributes: Any) -> Job:
"""Creates a Job instance.
def _create_instance(self, **kwargs) -> Job:
"""Creates a `Job` instance.
Parameters
----------
path : str
The HTTP path component for the Job resource endpoint (e.g., 'v1/content/544509fc-e4f0-41de-acb4-1fe3a2c1d797/jobs/7add0bc0-0d89-4397-ab51-90ad4bc3f5c9')
Returns
-------
Job
"""
return Job(self._ctx, self._parent, **kwargs)
return Job(self._ctx, path, **attributes)

class _FindByRequest(TypedDict, total=False):
# Identifiers
Expand Down Expand Up @@ -287,6 +280,19 @@ def find_by(self, **conditions) -> Optional[Job]:
class JobsMixin(Active, Resource):
"""Mixin class to add a jobs attribute to a resource."""

def __init__(self, ctx, **kwargs):
super().__init__(ctx, **kwargs)
self.jobs = Jobs(ctx, self)
def __init__(self, ctx, path, /, **attributes):
"""Mixin class which adds a `jobs` attribute to the Active 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
"""
super().__init__(ctx, path, **attributes)

path = posixpath.join(path, "jobs")
self.jobs = Jobs(ctx, path)
161 changes: 67 additions & 94 deletions src/posit/connect/resources.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import posixpath
import warnings
from abc import ABC, abstractmethod
from dataclasses import dataclass
Expand Down Expand Up @@ -50,84 +51,105 @@ def __init__(self, params: ResourceParameters) -> None:


class Active(ABC, Resource):
def __init__(self, ctx: Context, parent: Optional["Active"] = None, **kwargs):
"""A base class representing an active 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.
parent : Optional[Active], optional
An optional parent resource that establishes a hierarchical relationship, by default None.
**kwargs : dict
Additional keyword arguments passed to the parent `Resource` class.
path : str
The HTTP path component for the resource endpoint
**attributes : dict
Resource attributes passed
"""
params = ResourceParameters(ctx.session, ctx.url)
super().__init__(params, **kwargs)
super().__init__(params, **attributes)
self._ctx = ctx
self._parent = parent
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]):
def __init__(self, ctx: Context, parent: Optional[Active] = None):
"""A sequence abstraction for any HTTP GET endpoint that returns a collection.
"""A sequence for any HTTP GET endpoint that returns a collection."""

_cache: Optional[List[T]]

It lazily fetches data on demand, caches the results, and allows for standard sequence operations like indexing and slicing.
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 that holds the HTTP session used for sending the GET request.
parent : Optional[Active], optional
An optional parent resource to establish a nested relationship, by default None.
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._parent = parent
self._cache: Optional[List[T]] = None
self._path = path
self._uid = uid
self._cache = None

@property
@abstractmethod
def _endpoint(self) -> str:
def _create_instance(self, path: str, /, **kwargs: Any) -> T:
"""Create an instance of 'T'."""
raise NotImplementedError()

def reload(self) -> Self:
"""Reloads the collection from Connect.
Returns
-------
Self
"""
Abstract property to define the endpoint URL for the GET request.
self._cache = None
return self

def _fetch(self) -> List[T]:
"""Fetch the collection.
Subclasses must implement this property to return the API endpoint URL that will
be queried to fetch the data.
Fetches the collection directly from Connect. This operation does not effect the cache state.
Returns
-------
str
The API endpoint URL.
List[T]
"""
raise NotImplementedError()
endpoint = self._ctx.url + self._path
response = self._ctx.session.get(endpoint)
results = response.json()
return [self._to_instance(result) for result in results]

def _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]:
"""
Fetch and cache the data from the API.
"""Get the collection.
This method sends a GET request to the `_endpoint` and parses the response as a list of JSON objects.
Each JSON object is used to instantiate an item of type `T` using the class specified by `_cls`.
The results are cached after the first request and reused for subsequent access unless reloaded.
Fetches the collection from Connect and caches the result. Subsequent invocations return the cached results unless the cache is explicitly reset.
Returns
-------
List[T]
A list of items of type `T` representing the fetched data.
"""
if self._cache:
return self._cache
response = self._ctx.session.get(self._endpoint)
results = response.json()
self._cache = [self._create_instance(**result) for result in results]
See Also
--------
cached
reload
"""
if self._cache is None:
self._cache = self._fetch()
return self._cache

@overload
Expand All @@ -148,52 +170,18 @@ def __str__(self) -> str:
def __repr__(self) -> str:
return repr(self._data)

@abstractmethod
def _create_instance(self, **kwargs) -> T:
"""Create an instance of 'T'.

Returns
-------
T
"""
raise NotImplementedError()
class ActiveFinderMethods(ActiveSequence[T], ABC):
"""Finder methods.
def reload(self) -> Self:
"""
Clear the cache and reload the data from the API on the next access.
Returns
-------
ActiveSequence
The current instance with cleared cache, ready to reload data on next access.
"""
self._cache = None
return self


class ActiveFinderMethods(ActiveSequence[T], ABC, Generic[T]):
def __init__(self, ctx: Context, parent: Optional[Active] = None, uid: str = "guid"):
"""Finder methods.
Provides various finder methods for locating records in any endpoint supporting HTTP GET requests.
Parameters
----------
ctx : Context
The context containing the HTTP session used to interact with the API.
parent : Optional[Active], optional
Optional parent resource for maintaining hierarchical relationships, by default None
uid : str, optional
The default field name used to uniquely identify records, by default "guid"
"""
super().__init__(ctx, parent)
self._uid = uid
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 a record either by searching the cache or by making a GET request to the endpoint.
Fetches the record from Connect by it's identifier.
Parameters
----------
Expand All @@ -203,26 +191,11 @@ def find(self, uid) -> T:
Returns
-------
T
Raises
------
ValueError
If no record is found.
"""
# todo - add some more comments about this
if self._cache:
conditions = {self._uid: uid}
result = self.find_by(**conditions)
else:
endpoint = self._endpoint + uid
response = self._ctx.session.get(endpoint)
result = response.json()
result = self._create_instance(**result)

if not result:
raise ValueError(f"Failed to find instance where {self._uid} is '{uid}'")

return result
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) -> Optional[T]:
"""
Expand Down
Loading

0 comments on commit c62956c

Please sign in to comment.