diff --git a/src/posit/connect/content.py b/src/posit/connect/content.py index 27142d25..5e7e9ae7 100644 --- a/src/posit/connect/content.py +++ b/src/posit/connect/content.py @@ -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) diff --git a/src/posit/connect/jobs.py b/src/posit/connect/jobs.py index acaf0765..2d116b50 100644 --- a/src/posit/connect/jobs.py +++ b/src/posit/connect/jobs.py @@ -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[ @@ -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. @@ -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 @@ -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) diff --git a/src/posit/connect/resources.py b/src/posit/connect/resources.py index 3d652281..d7e31428 100644 --- a/src/posit/connect/resources.py +++ b/src/posit/connect/resources.py @@ -1,3 +1,4 @@ +import posixpath import warnings from abc import ABC, abstractmethod from dataclasses import dataclass @@ -50,8 +51,8 @@ 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. @@ -59,15 +60,15 @@ def __init__(self, ctx: Context, parent: Optional["Active"] = None, **kwargs): ---------- 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") @@ -75,59 +76,80 @@ def __init__(self, ctx: Context, parent: Optional["Active"] = None, **kwargs): 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 @@ -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 ---------- @@ -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]: """ diff --git a/src/posit/connect/vanities.py b/src/posit/connect/vanities.py index 571dccef..dab794e1 100644 --- a/src/posit/connect/vanities.py +++ b/src/posit/connect/vanities.py @@ -71,10 +71,6 @@ def __init__( self._after_destroy = after_destroy self._content_guid = kwargs["content_guid"] - @property - def _endpoint(self): - return self.params.url + f"v1/content/{self._content_guid}/vanity" - def destroy(self) -> None: """Destroy the vanity. @@ -91,7 +87,8 @@ def destroy(self) -> None: ---- This action requires administrator privileges. """ - self.params.session.delete(self._endpoint) + endpoint = self.params.url + f"v1/content/{self._content_guid}/vanity" + self.params.session.delete(endpoint) if self._after_destroy: self._after_destroy() @@ -130,10 +127,6 @@ def __init__(self, params: ResourceParameters, **kwargs: Unpack[HasGuid]): self._content_guid = kwargs["guid"] self._vanity: Optional[Vanity] = None - @property - def _endpoint(self): - return self.params.url + f"v1/content/{self._content_guid}/vanity" - @property def vanity(self) -> Optional[str]: """Get the vanity.""" @@ -220,7 +213,8 @@ def create_vanity(self, **kwargs: Unpack[CreateVanityRequest]) -> Vanity: -------- If setting force=True, the destroy operation performed on the other vanity is irreversible. """ - response = self.params.session.put(self._endpoint, json=kwargs) + endpoint = self.params.url + f"v1/content/{self._content_guid}/vanity" + response = self.params.session.put(endpoint, json=kwargs) result = response.json() return Vanity(self.params, **result) @@ -231,6 +225,7 @@ def find_vanity(self) -> Vanity: ------- Vanity """ - response = self.params.session.get(self._endpoint) + endpoint = self.params.url + f"v1/content/{self._content_guid}/vanity" + response = self.params.session.get(endpoint) result = response.json() return Vanity(self.params, **result) diff --git a/tests/posit/connect/test_jobs.py b/tests/posit/connect/test_jobs.py index e74d6081..91cc2555 100644 --- a/tests/posit/connect/test_jobs.py +++ b/tests/posit/connect/test_jobs.py @@ -46,25 +46,6 @@ def test(self): job = content.jobs.find("tHawGvHZTosJA2Dx") assert job["key"] == "tHawGvHZTosJA2Dx" - @responses.activate - def test_cached(self): - responses.get( - "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066", - json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066.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") - - assert content.jobs - job = content.jobs.find("tHawGvHZTosJA2Dx") - assert job["key"] == "tHawGvHZTosJA2Dx" - @responses.activate def test_miss(self): responses.get( @@ -73,15 +54,14 @@ def test_miss(self): ) 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"), + "https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/not-found", + status=404, ) c = Client("https://connect.example", "12345") content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066") - assert content.jobs - with pytest.raises(ValueError): + with pytest.raises(Exception): content.jobs.find("not-found")