Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: inject url path parts instead of endpoints #315

Merged
merged 33 commits into from
Oct 31, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
0533f19
feat: add jobs
tdstein Oct 18, 2024
6b79912
--wip-- [skip ci]
tdstein Oct 22, 2024
279fcd6
refactor: introduce the active pattern
tdstein Oct 23, 2024
e349870
add link to parent
tdstein Oct 23, 2024
533839b
skip when Quarto unavailable
tdstein Oct 23, 2024
1066ca3
adds unit tests
tdstein Oct 23, 2024
437c515
adds docstrings
tdstein Oct 23, 2024
a1ca377
Update src/posit/connect/resources.py
tdstein Oct 24, 2024
82b9b7e
applies feedback discussed in pull requests
tdstein Oct 24, 2024
6b8126d
refactor: inject url path parts instead of endpoints
tdstein Oct 25, 2024
b64f3e7
update docstrings
tdstein Oct 28, 2024
f57340d
renames init arguments to path and pathinfo
tdstein Oct 28, 2024
72b62ac
minor cleanup
tdstein Oct 28, 2024
f1d6f42
refactors _data property into _get_or_fetch method
tdstein Oct 29, 2024
dd74d60
fix method signature
tdstein Oct 29, 2024
fb52c83
fix cache check
tdstein Oct 29, 2024
bbbd6b4
Update src/posit/connect/resources.py
tdstein Oct 29, 2024
bc2cfcb
feat: add jobs
tdstein Oct 18, 2024
9c3d6dd
--wip-- [skip ci]
tdstein Oct 22, 2024
9019386
refactor: introduce the active pattern
tdstein Oct 23, 2024
4bfe3f8
add link to parent
tdstein Oct 23, 2024
a721b61
skip when Quarto unavailable
tdstein Oct 23, 2024
107ee85
adds unit tests
tdstein Oct 23, 2024
a070f0a
adds docstrings
tdstein Oct 23, 2024
d196271
Update src/posit/connect/resources.py
tdstein Oct 24, 2024
d87cfe7
applies feedback discussed in pull requests
tdstein Oct 24, 2024
2add280
Merge remote-tracking branch 'origin/tdstein/jobs' into tdstein/jobs-…
tdstein Oct 29, 2024
97d24f6
Merge remote-tracking branch 'origin/main' into tdstein/jobs-endpoint…
tdstein Oct 30, 2024
7eeb054
refactor: wrap cache interactions (#318)
tdstein Oct 30, 2024
03accf8
removes pathinfo options when not needed
tdstein Oct 30, 2024
77f8a38
remove unecessary arg
tdstein Oct 30, 2024
ac488c7
additional path cleanup
tdstein Oct 30, 2024
b3ff1cd
remove self imposed complexity of the method
tdstein Oct 31, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/posit/connect/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ class ContentItemOwner(Resource):
class ContentItem(JobsMixin, VanityMixin, Resource):
def __init__(self, /, params: ResourceParameters, **kwargs):
ctx = Context(params.session, params.url)
path = f"v1/content"
pathinfo = kwargs["guid"]
super().__init__(ctx, path, pathinfo, **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
43 changes: 16 additions & 27 deletions src/posit/connect/jobs.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import posixpath
from typing import Any, Literal, Optional, TypedDict, overload

from typing_extensions import NotRequired, Required, Unpack
Expand Down Expand Up @@ -100,14 +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."""

@overload
def __init__(self, ctx: Context, path: str, pathinfo: str, /, **attributes: Unpack[_Job]): ...

@overload
def __init__(self, ctx: Context, path: str, pathinfo: str, /, **attributes: Any): ...

def __init__(self, ctx: Context, path: str, pathinfo: str, /, **attributes: Any):
super().__init__(ctx, path, pathinfo, **attributes)
def __init__(self, ctx: Context, path: str, /, **attributes: Unpack[_Job]):
super().__init__(ctx, path, **attributes)

def destroy(self) -> None:
"""Destroy the job.
Expand All @@ -127,37 +122,31 @@ def destroy(self) -> None:


class Jobs(ActiveFinderMethods[Job], ActiveSequence[Job]):
def __init__(self, ctx: Context, path: str, pathinfo: str = "jobs", uid: str = "key"):
def __init__(self, ctx: Context, path: str):
"""A collection of jobs.

Parameters
----------
ctx : Context
The context object containing the session and URL for API interactions
path : str
The HTTP path component for the collection endpoint
pathinfo : str
The HTTP part of the path directed at a specific resource, by default "jobs"
uid : str, optional
The field name used to uniquely identify records, by default "guid"
The HTTP path component for the jobs endpoint (e.g., 'v1/content/544509fc-e4f0-41de-acb4-1fe3a2c1d797/jobs')
"""
super().__init__(ctx, path, pathinfo, uid)
super().__init__(ctx, path, "key")

def _create_instance(self, path: str, pathinfo: str, /, **kwargs: Any) -> Job:
def _create_instance(self, path: str, /, **attributes: Any) -> Job:
"""Creates a Job instance.

Parameters
----------
path : str
The HTTP path component for the collection endpoint
pathinfo : str
The HTTP part of the path directed at a specific resource
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, path, pathinfo, **kwargs)
return Job(self._ctx, path, **attributes)

class _FindByRequest(TypedDict, total=False):
# Identifiers
Expand Down Expand Up @@ -291,19 +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, path, pathinfo="", /, **kwargs):
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.
The context object containing the session and URL for API interactions
path : str
The HTTP path component for the collection endpoint
pathinfo : str
The HTTP part of the path directed at a specific resource
The HTTP path component for the resource endpoint
**attributes : dict
Resource attributes passed
"""
super().__init__(ctx, path, pathinfo, **kwargs)
self.jobs = Jobs(ctx, self._path)
super().__init__(ctx, path, **attributes)

path = posixpath.join(path, "jobs")
self.jobs = Jobs(ctx, path)
158 changes: 77 additions & 81 deletions src/posit/connect/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def __init__(self, params: ResourceParameters) -> None:


class Active(ABC, Resource):
def __init__(self, ctx: Context, path: str, pathinfo: str = "", /, **attributes):
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.
Expand All @@ -61,77 +61,108 @@ def __init__(self, ctx: Context, path: str, pathinfo: str = "", /, **attributes)
ctx : Context
The context object containing the session and URL for API interactions.
path : str
The HTTP path component for the collection endpoint
pathinfo : str
The HTTP part of the path directed at a specific resource
The HTTP path component for the resource endpoint
**attributes : dict
Resource attributes passed

Attributes
----------
_ctx : Context
The context object containing the session and URL for API interactions
_path : str
The HTTP path for the collection endpoint.
"""
params = ResourceParameters(ctx.session, ctx.url)
super().__init__(params, **attributes)
self._ctx = ctx
self._path = posixpath.join(path, pathinfo)
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, path: str, pathinfo: str = "", uid: str = "guid"):
"""A sequence abstraction for any HTTP GET endpoint that returns a collection.
"""A sequence for any HTTP GET endpoint that returns a collection."""

It lazily fetches data on demand, caches the results, and allows for standard sequence operations like indexing and slicing.
_cache: Optional[List[T]]
schloerke marked this conversation as resolved.
Show resolved Hide resolved

Attributes
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 for the collection endpoint.
_uid : str
The field name used to uniquely identify records.
_cache: Optional[List[T]]
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 = posixpath.join(path, pathinfo)
self._path = path
self._uid = uid
self._cache: Optional[List[T]] = None
self._cache = None

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

def cached(self) -> bool:
tdstein marked this conversation as resolved.
Show resolved Hide resolved
"""Returns True if the collection is cached.

def _get_or_fetch(self) -> List[T]:
Returns
-------
bool

See Also
--------
reload
"""
Fetch and cache the data from the API.
return self._cache is not None

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.
def reload(self) -> Self:
"""Reloads the collection from Connect.
tdstein marked this conversation as resolved.
Show resolved Hide resolved

Returns
-------
List[T]
A list of items of type `T` representing the fetched data.
Self
"""
if self._cache is not None:
return self._cache
self._cache = None
return self

def _fetch(self) -> List[T]:
"""Fetch the collection.

Fetches the collection directly from Connect. This operation does not effect the cache state.

Returns
-------
List[T]
"""
endpoint = self._ctx.url + self._path
response = self._ctx.session.get(endpoint)
results = response.json()
return [self._to_instance(result) for result in results]

def _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.

self._cache = []
for result in results:
uid = result[self._uid]
instance = self._create_instance(self._path, uid, **result)
self._cache.append(instance)
Returns
-------
List[T]

See Also
--------
cached
reload
"""
if self._cache is None:
tdstein marked this conversation as resolved.
Show resolved Hide resolved
self._cache = self._fetch()
return self._cache

@overload
Expand All @@ -141,42 +172,16 @@ def __getitem__(self, index: int) -> T: ...
def __getitem__(self, index: slice) -> Sequence[T]: ...

def __getitem__(self, index):
data = self._get_or_fetch()
return data[index]
return self._data[index]

def __len__(self) -> int:
data = self._get_or_fetch()
return len(data)
return len(self._data)

def __str__(self) -> str:
data = self._get_or_fetch()
return str(data)
return str(self._data)

def __repr__(self) -> str:
data = self._get_or_fetch()
return repr(data)

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

Returns
-------
T
"""
raise NotImplementedError()

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
return repr(self._data)


class ActiveFinderMethods(ActiveSequence[T], ABC):
Expand All @@ -200,9 +205,7 @@ def find(self, uid) -> T:
-------
T
"""
if self._cache:
# Check if the record already exists in the cache.
# It is assumed that local cache scan is faster than an additional HTTP request.
if self.cached():
conditions = {self._uid: uid}
result = self.find_by(**conditions)
if result:
Expand All @@ -211,13 +214,7 @@ def find(self, uid) -> T:
endpoint = self._ctx.url + self._path + uid
response = self._ctx.session.get(endpoint)
result = response.json()
result = self._create_instance(self._path, uid, **result)

# Invalidate the cache.
# It is assumed that the cache is stale since a record exists on the server and not in the cache.
self._cache = None

return result
return self._to_instance(result)

def find_by(self, **conditions: Any) -> Optional[T]:
"""
Expand All @@ -234,5 +231,4 @@ def find_by(self, **conditions: Any) -> Optional[T]:
Optional[T]
The first record matching the conditions, or `None` if no match is found.
"""
data = self._get_or_fetch()
return next((v for v in data if v.items() >= conditions.items()), None)
return next((v for v in self._data if v.items() >= conditions.items()), None)