Skip to content

Commit 6b8126d

Browse files
committed
refactor: inject url path parts instead of endpoints
1 parent 82b9b7e commit 6b8126d

File tree

5 files changed

+116
-102
lines changed

5 files changed

+116
-102
lines changed

src/posit/connect/content.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ class ContentItemOwner(Resource):
3737
class ContentItem(JobsMixin, VanityMixin, Resource):
3838
def __init__(self, /, params: ResourceParameters, **kwargs):
3939
ctx = Context(params.session, params.url)
40-
super().__init__(ctx, **kwargs)
40+
base = f"v1/content/{kwargs['guid']}"
41+
super().__init__(ctx, base, **kwargs)
4142

4243
def __getitem__(self, key: Any) -> Any:
4344
v = super().__getitem__(key)

src/posit/connect/jobs.py

Lines changed: 56 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
from typing import Literal, Optional, TypedDict, overload
1+
from typing import Any, Literal, Optional, TypedDict, overload
22

33
from typing_extensions import NotRequired, Required, Unpack
44

5+
from posit.connect.context import Context
6+
57
from .resources import Active, ActiveFinderMethods, ActiveSequence, Resource
68

79
JobTag = Literal[
@@ -99,13 +101,31 @@ class _Job(TypedDict):
99101
tag: Required[JobTag]
100102
"""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."""
101103

102-
def __init__(self, ctx, parent: Active, **kwargs: Unpack[_Job]):
103-
super().__init__(ctx, parent, **kwargs)
104-
self._parent = parent
104+
@overload
105+
def __init__(self, ctx: Context, base: str, uid: str, /, **kwargs: Unpack[_Job]):
106+
"""A Job.
107+
108+
A Job represents single execution instance of Content on Connect. Whenever Content runs, whether it's a scheduled report, a script execution, or server processes related to an application, a Job is created to manage and encapsulate that execution.
109+
110+
Parameters
111+
----------
112+
ctx : Context
113+
The context object that holds the HTTP session used for sending the GET request.
114+
parent : ContentItem
115+
The Content related to this Job.
116+
117+
Notes
118+
-----
119+
A Job is a reference to a server process on Connect, it is not the process itself. Jobs are executed asynchronously
120+
"""
121+
...
122+
123+
@overload
124+
def __init__(self, ctx: Context, base: str, uid: str, /, **kwargs: Any): ...
105125

106-
@property
107-
def _endpoint(self) -> str:
108-
return self._ctx.url + f"v1/content/{self._parent['guid']}/jobs/{self['key']}"
126+
def __init__(self, ctx: Context, base: str, uid: str, /, **kwargs: Any):
127+
super().__init__(ctx, **kwargs)
128+
self._endpoint = ctx.url + base + uid
109129

110130
def destroy(self) -> None:
111131
"""Destroy the job.
@@ -123,37 +143,38 @@ def destroy(self) -> None:
123143
self._ctx.session.delete(self._endpoint)
124144

125145

126-
class Jobs(
127-
ActiveFinderMethods[Job],
128-
ActiveSequence[Job],
129-
):
130-
def __init__(self, ctx, parent: Active, uid="key"):
146+
class Jobs(ActiveFinderMethods[Job], ActiveSequence[Job]):
147+
def __init__(self, ctx: Context, base: str, path: str = "jobs", uid="key"):
131148
"""A collection of jobs.
132149
133150
Parameters
134151
----------
135152
ctx : Context
136-
The context containing the HTTP session used to interact with the API.
137-
parent : Active
138-
Parent resource for maintaining hierarchical relationships
153+
The context object containing the session and URL for API interactions
154+
base : str
155+
The base HTTP path for the collection endpoint
156+
name : str
157+
The collection name, by default "jobs"
139158
uid : str, optional
140-
The default field name used to uniquely identify records, by default "key"
159+
The field name used to uniquely identify records, by default "key"
141160
"""
142-
super().__init__(ctx, parent, uid)
143-
self._parent = parent
161+
super().__init__(ctx, base, path, uid)
144162

145-
@property
146-
def _endpoint(self) -> str:
147-
return self._ctx.url + f"v1/content/{self._parent['guid']}/jobs"
163+
def _create_instance(self, base: str, uid: str, **kwargs: Any) -> Job:
164+
"""Creates a Job instance.
148165
149-
def _create_instance(self, **kwargs) -> Job:
150-
"""Creates a `Job` instance.
166+
Parameters
167+
----------
168+
base : str
169+
The base HTTP path for the instance endpoint
170+
uid : str
171+
The unique identifier for the instance.
151172
152173
Returns
153174
-------
154175
Job
155176
"""
156-
return Job(self._ctx, self._parent, **kwargs)
177+
return Job(self._ctx, base, uid, **kwargs)
157178

158179
class _FindByRequest(TypedDict, total=False):
159180
# Identifiers
@@ -287,6 +308,15 @@ def find_by(self, **conditions) -> Optional[Job]:
287308
class JobsMixin(Active, Resource):
288309
"""Mixin class to add a jobs attribute to a resource."""
289310

290-
def __init__(self, ctx, **kwargs):
311+
def __init__(self, ctx: Context, base: str, /, **kwargs):
312+
"""Mixin class which adds a `jobs` attribute to the Active Resource.
313+
314+
Parameters
315+
----------
316+
ctx : Context
317+
The context object containing the session and URL for API interactions
318+
base : str
319+
The base path associated with the instance.
320+
"""
291321
super().__init__(ctx, **kwargs)
292-
self.jobs = Jobs(ctx, self)
322+
self.jobs = Jobs(ctx, base)

src/posit/connect/resources.py

Lines changed: 48 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import posixpath
12
import warnings
23
from abc import ABC, abstractmethod
34
from dataclasses import dataclass
@@ -50,7 +51,7 @@ def __init__(self, params: ResourceParameters) -> None:
5051

5152

5253
class Active(ABC, Resource):
53-
def __init__(self, ctx: Context, parent: Optional["Active"] = None, **kwargs):
54+
def __init__(self, ctx: Context, **kwargs):
5455
"""A base class representing an active resource.
5556
5657
Extends the `Resource` class and provides additional functionality for via the session context and an optional parent resource.
@@ -59,55 +60,54 @@ def __init__(self, ctx: Context, parent: Optional["Active"] = None, **kwargs):
5960
----------
6061
ctx : Context
6162
The context object containing the session and URL for API interactions.
62-
parent : Optional[Active], optional
63-
An optional parent resource that establishes a hierarchical relationship, by default None.
6463
**kwargs : dict
6564
Additional keyword arguments passed to the parent `Resource` class.
6665
"""
6766
params = ResourceParameters(ctx.session, ctx.url)
6867
super().__init__(params, **kwargs)
6968
self._ctx = ctx
70-
self._parent = parent
7169

7270

7371
T = TypeVar("T", bound="Active")
7472
"""A type variable that is bound to the `Active` class"""
7573

7674

7775
class ActiveSequence(ABC, Generic[T], Sequence[T]):
78-
def __init__(self, ctx: Context, parent: Optional[Active] = None):
76+
def __init__(self, ctx: Context, base: str, name: str, uid="guid"):
7977
"""A sequence abstraction for any HTTP GET endpoint that returns a collection.
8078
8179
It lazily fetches data on demand, caches the results, and allows for standard sequence operations like indexing and slicing.
8280
8381
Parameters
8482
----------
8583
ctx : Context
86-
The context object that holds the HTTP session used for sending the GET request.
87-
parent : Optional[Active], optional
88-
An optional parent resource to establish a nested relationship, by default None.
84+
The context object containing the session and URL for API interactions
85+
base : str
86+
The base HTTP path for the collection endpoint
87+
name : str
88+
The collection name
89+
uid : str, optional
90+
The field name used to uniquely identify records, by default "guid"
91+
92+
Attributes
93+
----------
94+
_ctx : Context
95+
The context object containing the session and URL for API interactions
96+
_path : str
97+
The HTTP path for the collection endpoint.
98+
_endpoint : Url
99+
The HTTP URL for the collection endpoint.
100+
_uid : str
101+
The default field name used to uniquely identify records.
102+
_cache: Optional[List[T]]
89103
"""
90104
super().__init__()
91105
self._ctx = ctx
92-
self._parent = parent
106+
self._path: str = posixpath.join(base, name)
107+
self._endpoint: Url = ctx.url + self._path
108+
self._uid: str = uid
93109
self._cache: Optional[List[T]] = None
94110

95-
@property
96-
@abstractmethod
97-
def _endpoint(self) -> str:
98-
"""
99-
Abstract property to define the endpoint URL for the GET request.
100-
101-
Subclasses must implement this property to return the API endpoint URL that will
102-
be queried to fetch the data.
103-
104-
Returns
105-
-------
106-
str
107-
The API endpoint URL.
108-
"""
109-
raise NotImplementedError()
110-
111111
@property
112112
def _data(self) -> List[T]:
113113
"""
@@ -127,7 +127,13 @@ def _data(self) -> List[T]:
127127

128128
response = self._ctx.session.get(self._endpoint)
129129
results = response.json()
130-
self._cache = [self._create_instance(**result) for result in results]
130+
131+
self._cache = []
132+
for result in results:
133+
uid = result[self._uid]
134+
instance = self._create_instance(self._path, uid, **result)
135+
self._cache.append(instance)
136+
131137
return self._cache
132138

133139
@overload
@@ -149,7 +155,7 @@ def __repr__(self) -> str:
149155
return repr(self._data)
150156

151157
@abstractmethod
152-
def _create_instance(self, **kwargs) -> T:
158+
def _create_instance(self, base: str, uid: str, /, **kwargs: Any) -> T:
153159
"""Create an instance of 'T'.
154160
155161
Returns
@@ -171,29 +177,12 @@ def reload(self) -> Self:
171177
return self
172178

173179

174-
class ActiveFinderMethods(ActiveSequence[T], ABC, Generic[T]):
175-
def __init__(self, ctx: Context, parent: Optional[Active] = None, uid: str = "guid"):
176-
"""Finder methods.
177-
178-
Provides various finder methods for locating records in any endpoint supporting HTTP GET requests.
179-
180-
Parameters
181-
----------
182-
ctx : Context
183-
The context containing the HTTP session used to interact with the API.
184-
parent : Optional[Active], optional
185-
Optional parent resource for maintaining hierarchical relationships, by default None
186-
uid : str, optional
187-
The default field name used to uniquely identify records, by default "guid"
188-
"""
189-
super().__init__(ctx, parent)
190-
self._uid = uid
191-
180+
class ActiveFinderMethods(ActiveSequence[T], ABC):
192181
def find(self, uid) -> T:
193182
"""
194183
Find a record by its unique identifier.
195184
196-
Fetches a record either by searching the cache or by making a GET request to the endpoint.
185+
If the cache is already populated, it is checked first for matching record. If not, a conventional GET request is made to the Connect server.
197186
198187
Parameters
199188
----------
@@ -203,24 +192,23 @@ def find(self, uid) -> T:
203192
Returns
204193
-------
205194
T
206-
207-
Raises
208-
------
209-
ValueError
210-
If no record is found.
211195
"""
212-
# todo - add some more comments about this
213196
if self._cache:
197+
# Check if the record already exists in the cache.
198+
# It is assumed that local cache scan is faster than an additional HTTP request.
214199
conditions = {self._uid: uid}
215200
result = self.find_by(**conditions)
216-
else:
217-
endpoint = self._endpoint + uid
218-
response = self._ctx.session.get(endpoint)
219-
result = response.json()
220-
result = self._create_instance(**result)
221-
222-
if not result:
223-
raise ValueError(f"Failed to find instance where {self._uid} is '{uid}'")
201+
if result:
202+
return result
203+
204+
endpoint = self._endpoint + uid
205+
response = self._ctx.session.get(endpoint)
206+
result = response.json()
207+
result = self._create_instance(self._path, uid, **result)
208+
209+
# Invalidate the cache.
210+
# It is assumed that the cache is stale since a record exists on the server and not in the cache.
211+
self._cache = None
224212

225213
return result
226214

src/posit/connect/vanities.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,7 @@ def __init__(
7070
super().__init__(params, **kwargs)
7171
self._after_destroy = after_destroy
7272
self._content_guid = kwargs["content_guid"]
73-
74-
@property
75-
def _endpoint(self):
76-
return self.params.url + f"v1/content/{self._content_guid}/vanity"
73+
self.__endpoint = self.params.url + f"v1/content/{self._content_guid}/vanity"
7774

7875
def destroy(self) -> None:
7976
"""Destroy the vanity.
@@ -91,7 +88,7 @@ def destroy(self) -> None:
9188
----
9289
This action requires administrator privileges.
9390
"""
94-
self.params.session.delete(self._endpoint)
91+
self.params.session.delete(self.__endpoint)
9592

9693
if self._after_destroy:
9794
self._after_destroy()
@@ -128,12 +125,9 @@ class HasGuid(TypedDict):
128125
def __init__(self, params: ResourceParameters, **kwargs: Unpack[HasGuid]):
129126
super().__init__(params, **kwargs)
130127
self._content_guid = kwargs["guid"]
128+
self.__endpoint = self.params.url + f"v1/content/{self._content_guid}/vanity"
131129
self._vanity: Optional[Vanity] = None
132130

133-
@property
134-
def _endpoint(self):
135-
return self.params.url + f"v1/content/{self._content_guid}/vanity"
136-
137131
@property
138132
def vanity(self) -> Optional[str]:
139133
"""Get the vanity."""
@@ -220,7 +214,8 @@ def create_vanity(self, **kwargs: Unpack[CreateVanityRequest]) -> Vanity:
220214
--------
221215
If setting force=True, the destroy operation performed on the other vanity is irreversible.
222216
"""
223-
response = self.params.session.put(self._endpoint, json=kwargs)
217+
print(self.__endpoint)
218+
response = self.params.session.put(self.__endpoint, json=kwargs)
224219
result = response.json()
225220
return Vanity(self.params, **result)
226221

@@ -231,6 +226,7 @@ def find_vanity(self) -> Vanity:
231226
-------
232227
Vanity
233228
"""
234-
response = self.params.session.get(self._endpoint)
229+
print(self.__endpoint)
230+
response = self.params.session.get(self.__endpoint)
235231
result = response.json()
236232
return Vanity(self.params, **result)

tests/posit/connect/test_jobs.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,15 +73,14 @@ def test_miss(self):
7373
)
7474

7575
responses.get(
76-
"https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs",
77-
json=load_mock("v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs.json"),
76+
"https://connect.example/__api__/v1/content/f2f37341-e21d-3d80-c698-a935ad614066/jobs/not-found",
77+
status=404,
7878
)
7979

8080
c = Client("https://connect.example", "12345")
8181
content = c.content.get("f2f37341-e21d-3d80-c698-a935ad614066")
8282

83-
assert content.jobs
84-
with pytest.raises(ValueError):
83+
with pytest.raises(Exception):
8584
content.jobs.find("not-found")
8685

8786

0 commit comments

Comments
 (0)