Skip to content

Commit 7c8acce

Browse files
authored
feat: add jobs (#314)
Adds job support. The mixin pattern is continued, and a 'jobs' property is added to `ContentItem.` A new abstraction around resources called `Active` is introduced. This abstraction begins transitioning to a more idiomatic approach to interacting with collections. Similar to how printing a `Resource` shows a `dict`, the `ActiveSequence` shows a familiar `List[dict]` representation. The `ActiveFinderMethods` supports the `find` and `find_by` methods. This is inspired by this Ruby on Rails module https://api.rubyonrails.org/classes/ActiveRecord/FinderMethods.html. Ideally, this separation of concerns provides a straightforward way to compose bindings in the future and reduces the amount of duplication across implementations. Additional background may be found here https://github.com/tdstein/active Related to #310 Resolves #306
1 parent 14922d2 commit 7c8acce

File tree

10 files changed

+744
-193
lines changed

10 files changed

+744
-193
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
from packaging import version
5+
6+
from posit import connect
7+
8+
from . import CONNECT_VERSION
9+
10+
11+
class TestJobs:
12+
@classmethod
13+
def setup_class(cls):
14+
cls.client = connect.Client()
15+
cls.content = cls.client.content.create(name="example-quarto-minimal")
16+
17+
@classmethod
18+
def teardown_class(cls):
19+
cls.content.delete()
20+
assert cls.client.content.count() == 0
21+
22+
@pytest.mark.skipif(
23+
CONNECT_VERSION <= version.parse("2023.01.1"),
24+
reason="Quarto not available",
25+
)
26+
def test(self):
27+
content = self.content
28+
29+
path = Path("../../../resources/connect/bundles/example-quarto-minimal/bundle.tar.gz")
30+
path = Path(__file__).parent / path
31+
path = path.resolve()
32+
path = str(path)
33+
34+
bundle = content.bundles.create(path)
35+
bundle.deploy()
36+
37+
jobs = content.jobs
38+
assert len(jobs) == 1

src/posit/connect/content.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99

1010
from . import tasks
1111
from .bundles import Bundles
12+
from .context import Context
1213
from .env import EnvVars
14+
from .jobs import JobsMixin
1315
from .oauth.associations import ContentItemAssociations
1416
from .permissions import Permissions
1517
from .resources import Resource, ResourceParameters, Resources
@@ -32,7 +34,11 @@ class ContentItemOwner(Resource):
3234
pass
3335

3436

35-
class ContentItem(VanityMixin, Resource):
37+
class ContentItem(JobsMixin, VanityMixin, Resource):
38+
def __init__(self, /, params: ResourceParameters, **kwargs):
39+
ctx = Context(params.session, params.url)
40+
super().__init__(ctx, **kwargs)
41+
3642
def __getitem__(self, key: Any) -> Any:
3743
v = super().__getitem__(key)
3844
if key == "owner" and isinstance(v, dict):

src/posit/connect/context.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import requests
55
from packaging.version import Version
66

7+
from .urls import Url
8+
79

810
def requires(version: str):
911
def decorator(func):
@@ -22,7 +24,7 @@ def wrapper(instance: ContextManager, *args, **kwargs):
2224

2325

2426
class Context(dict):
25-
def __init__(self, session: requests.Session, url: str):
27+
def __init__(self, session: requests.Session, url: Url):
2628
self.session = session
2729
self.url = url
2830

@@ -38,7 +40,7 @@ def version(self) -> Optional[str]:
3840
return value
3941

4042
@version.setter
41-
def version(self, value: str):
43+
def version(self, value):
4244
self["version"] = value
4345

4446

src/posit/connect/jobs.py

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
from typing import Literal, Optional, TypedDict, overload
2+
3+
from typing_extensions import NotRequired, Required, Unpack
4+
5+
from .resources import Active, ActiveFinderMethods, ActiveSequence, Resource
6+
7+
JobTag = Literal[
8+
"unknown",
9+
"build_report",
10+
"build_site",
11+
"build_jupyter",
12+
"packrat_restore",
13+
"python_restore",
14+
"configure_report",
15+
"run_app",
16+
"run_api",
17+
"run_tensorflow",
18+
"run_python_api",
19+
"run_dash_app",
20+
"run_streamlit",
21+
"run_bokeh_app",
22+
"run_fastapi_app",
23+
"run_pyshiny_app",
24+
"render_shiny",
25+
"run_voila_app",
26+
"testing",
27+
"git",
28+
"val_py_ext_pkg",
29+
"val_r_ext_pkg",
30+
"val_r_install",
31+
]
32+
33+
34+
class Job(Active):
35+
class _Job(TypedDict):
36+
# Identifiers
37+
id: Required[str]
38+
"""A unique identifier for the job."""
39+
40+
ppid: Required[Optional[str]]
41+
"""Identifier of the parent process."""
42+
43+
pid: Required[str]
44+
"""Identifier of the process running the job."""
45+
46+
key: Required[str]
47+
"""A unique key to identify this job."""
48+
49+
remote_id: Required[Optional[str]]
50+
"""Identifier for off-host execution configurations."""
51+
52+
app_id: Required[str]
53+
"""Identifier of the parent content associated with the job."""
54+
55+
variant_id: Required[str]
56+
"""Identifier of the variant responsible for the job."""
57+
58+
bundle_id: Required[str]
59+
"""Identifier of the content bundle linked to the job."""
60+
61+
# Timestamps
62+
start_time: Required[str]
63+
"""RFC3339 timestamp indicating when the job started."""
64+
65+
end_time: Required[Optional[str]]
66+
"""RFC3339 timestamp indicating when the job finished."""
67+
68+
last_heartbeat_time: Required[str]
69+
"""RFC3339 timestamp of the last recorded activity for the job."""
70+
71+
queued_time: Required[Optional[str]]
72+
"""RFC3339 timestamp when the job was added to the queue."""
73+
74+
# Status and Exit Information
75+
status: Required[Literal[0, 1, 2]]
76+
"""Current status. Options are 0 (Active), 1 (Finished), and 2 (Finalized)"""
77+
78+
exit_code: Required[Optional[int]]
79+
"""The job's exit code, available after completion."""
80+
81+
# Environment Information
82+
hostname: Required[str]
83+
"""Name of the node processing the job."""
84+
85+
cluster: Required[Optional[str]]
86+
"""Location where the job runs, either 'Local' or the cluster name."""
87+
88+
image: Required[Optional[str]]
89+
"""Location of the content in clustered environments."""
90+
91+
run_as: Required[str]
92+
"""UNIX user responsible for executing the job."""
93+
94+
# Queue and Scheduling Information
95+
queue_name: Required[Optional[str]]
96+
"""Name of the queue processing the job, relevant for scheduled reports."""
97+
98+
# Job Metadata
99+
tag: Required[JobTag]
100+
"""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."""
101+
102+
def __init__(self, ctx, parent: Active, **kwargs: Unpack[_Job]):
103+
super().__init__(ctx, parent, **kwargs)
104+
self._parent = parent
105+
106+
@property
107+
def _endpoint(self) -> str:
108+
return self._ctx.url + f"v1/content/{self._parent['guid']}/jobs/{self['key']}"
109+
110+
def destroy(self) -> None:
111+
"""Destroy the job.
112+
113+
Submit a request to kill the job.
114+
115+
Warnings
116+
--------
117+
This operation is irreversible.
118+
119+
Note
120+
----
121+
This action requires administrator, owner, or collaborator privileges.
122+
"""
123+
self._ctx.session.delete(self._endpoint)
124+
125+
126+
class Jobs(
127+
ActiveFinderMethods[Job],
128+
ActiveSequence[Job],
129+
):
130+
def __init__(self, ctx, parent: Active, uid="key"):
131+
"""A collection of jobs.
132+
133+
Parameters
134+
----------
135+
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
139+
uid : str, optional
140+
The default field name used to uniquely identify records, by default "key"
141+
"""
142+
super().__init__(ctx, parent, uid)
143+
self._parent = parent
144+
145+
@property
146+
def _endpoint(self) -> str:
147+
return self._ctx.url + f"v1/content/{self._parent['guid']}/jobs"
148+
149+
def _create_instance(self, **kwargs) -> Job:
150+
"""Creates a `Job` instance.
151+
152+
Returns
153+
-------
154+
Job
155+
"""
156+
return Job(self._ctx, self._parent, **kwargs)
157+
158+
class _FindByRequest(TypedDict, total=False):
159+
# Identifiers
160+
id: Required[str]
161+
"""A unique identifier for the job."""
162+
163+
ppid: NotRequired[Optional[str]]
164+
"""Identifier of the parent process."""
165+
166+
pid: NotRequired[str]
167+
"""Identifier of the process running the job."""
168+
169+
key: NotRequired[str]
170+
"""A unique key to identify this job."""
171+
172+
remote_id: NotRequired[Optional[str]]
173+
"""Identifier for off-host execution configurations."""
174+
175+
app_id: NotRequired[str]
176+
"""Identifier of the parent content associated with the job."""
177+
178+
variant_id: NotRequired[str]
179+
"""Identifier of the variant responsible for the job."""
180+
181+
bundle_id: NotRequired[str]
182+
"""Identifier of the content bundle linked to the job."""
183+
184+
# Timestamps
185+
start_time: NotRequired[str]
186+
"""RFC3339 timestamp indicating when the job started."""
187+
188+
end_time: NotRequired[Optional[str]]
189+
"""RFC3339 timestamp indicating when the job finished."""
190+
191+
last_heartbeat_time: NotRequired[str]
192+
"""RFC3339 timestamp of the last recorded activity for the job."""
193+
194+
queued_time: NotRequired[Optional[str]]
195+
"""RFC3339 timestamp when the job was added to the queue."""
196+
197+
# Status and Exit Information
198+
status: NotRequired[Literal[0, 1, 2]]
199+
"""Current status. Options are 0 (Active), 1 (Finished), and 2 (Finalized)"""
200+
201+
exit_code: NotRequired[Optional[int]]
202+
"""The job's exit code, available after completion."""
203+
204+
# Environment Information
205+
hostname: NotRequired[str]
206+
"""Name of the node processing the job."""
207+
208+
cluster: NotRequired[Optional[str]]
209+
"""Location where the job runs, either 'Local' or the cluster name."""
210+
211+
image: NotRequired[Optional[str]]
212+
"""Location of the content in clustered environments."""
213+
214+
run_as: NotRequired[str]
215+
"""UNIX user responsible for executing the job."""
216+
217+
# Queue and Scheduling Information
218+
queue_name: NotRequired[Optional[str]]
219+
"""Name of the queue processing the job, relevant for scheduled reports."""
220+
221+
# Job Metadata
222+
tag: NotRequired[JobTag]
223+
"""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."""
224+
225+
@overload
226+
def find_by(self, **conditions: Unpack[_FindByRequest]) -> Optional[Job]:
227+
"""Finds the first record matching the specified conditions.
228+
229+
There is no implied ordering so if order matters, you should specify it yourself.
230+
231+
Parameters
232+
----------
233+
id : str, not required
234+
A unique identifier for the job.
235+
ppid : Optional[str], not required
236+
Identifier of the parent process.
237+
pid : str, not required
238+
Identifier of the process running the job.
239+
key : str, not required
240+
A unique key to identify this job.
241+
remote_id : Optional[str], not required
242+
Identifier for off-host execution configurations.
243+
app_id : str, not required
244+
Identifier of the parent content associated with the job.
245+
variant_id : str, not required
246+
Identifier of the variant responsible for the job.
247+
bundle_id : str, not required
248+
Identifier of the content bundle linked to the job.
249+
start_time : str, not required
250+
RFC3339 timestamp indicating when the job started.
251+
end_time : Optional[str], not required
252+
RFC3339 timestamp indicating when the job finished.
253+
last_heartbeat_time : str, not required
254+
RFC3339 timestamp of the last recorded activity for the job.
255+
queued_time : Optional[str], not required
256+
RFC3339 timestamp when the job was added to the queue.
257+
status : int, not required
258+
Current status. Options are 0 (Active), 1 (Finished), and 2 (Finalized)
259+
exit_code : Optional[int], not required
260+
The job's exit code, available after completion.
261+
hostname : str, not required
262+
Name of the node processing the job.
263+
cluster : Optional[str], not required
264+
Location where the job runs, either 'Local' or the cluster name.
265+
image : Optional[str], not required
266+
Location of the content in clustered environments.
267+
run_as : str, not required
268+
UNIX user responsible for executing the job.
269+
queue_name : Optional[str], not required
270+
Name of the queue processing the job, relevant for scheduled reports.
271+
tag : JobTag, not required
272+
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.
273+
274+
Returns
275+
-------
276+
Optional[Job]
277+
"""
278+
...
279+
280+
@overload
281+
def find_by(self, **conditions): ...
282+
283+
def find_by(self, **conditions) -> Optional[Job]:
284+
return super().find_by(**conditions)
285+
286+
287+
class JobsMixin(Active, Resource):
288+
"""Mixin class to add a jobs attribute to a resource."""
289+
290+
def __init__(self, ctx, **kwargs):
291+
super().__init__(ctx, **kwargs)
292+
self.jobs = Jobs(ctx, self)

0 commit comments

Comments
 (0)