rsconnect-python 1.30.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rsconnect/__init__.py +13 -0
- rsconnect/actions.py +565 -0
- rsconnect/actions_content.py +508 -0
- rsconnect/actions_environment.py +160 -0
- rsconnect/actions_integration.py +118 -0
- rsconnect/api.py +2582 -0
- rsconnect/bundle.py +2481 -0
- rsconnect/certificates.py +39 -0
- rsconnect/environment.py +390 -0
- rsconnect/environment_node.py +115 -0
- rsconnect/environment_r.py +300 -0
- rsconnect/exception.py +15 -0
- rsconnect/git_metadata.py +180 -0
- rsconnect/http_support.py +595 -0
- rsconnect/json_web_token.py +178 -0
- rsconnect/log.py +253 -0
- rsconnect/main.py +5889 -0
- rsconnect/metadata.py +879 -0
- rsconnect/models.py +835 -0
- rsconnect/oauth.py +623 -0
- rsconnect/py.typed +0 -0
- rsconnect/pyproject.py +283 -0
- rsconnect/quickstart/__init__.py +16 -0
- rsconnect/quickstart/quickstart.py +486 -0
- rsconnect/quickstart/templates/__init__.py +16 -0
- rsconnect/quickstart/templates/api/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/api/__connect__.py.tmpl +3 -0
- rsconnect/quickstart/templates/api/__init__.py.tmpl +1 -0
- rsconnect/quickstart/templates/api/__main__.py.tmpl +14 -0
- rsconnect/quickstart/templates/api/app.py.tmpl +11 -0
- rsconnect/quickstart/templates/api/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/fastapi/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +3 -0
- rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +1 -0
- rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +16 -0
- rsconnect/quickstart/templates/fastapi/app.py.tmpl +11 -0
- rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +14 -0
- rsconnect/quickstart/templates/notebook/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +34 -0
- rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/quarto/README.md.tmpl +19 -0
- rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +11 -0
- rsconnect/quickstart/templates/quarto/report.qmd.tmpl +8 -0
- rsconnect/quickstart/templates/shiny/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/shiny/app.py.tmpl +3 -0
- rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/streamlit/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/streamlit/app.py.tmpl +3 -0
- rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/voila/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +14 -0
- rsconnect/shiny_express.py +136 -0
- rsconnect/snowflake.py +93 -0
- rsconnect/subprocesses/__init__.py +0 -0
- rsconnect/subprocesses/inspect_environment.py +362 -0
- rsconnect/timeouts.py +89 -0
- rsconnect/utils_package.py +261 -0
- rsconnect/validation.py +156 -0
- rsconnect/version_check.py +154 -0
- rsconnect_python-1.30.0.dist-info/METADATA +89 -0
- rsconnect_python-1.30.0.dist-info/RECORD +63 -0
- rsconnect_python-1.30.0.dist-info/WHEEL +4 -0
- rsconnect_python-1.30.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public API for administering content.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
import traceback
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
11
|
+
from datetime import datetime, timedelta
|
|
12
|
+
from typing import Iterator, Literal, Optional, Sequence, cast, Union
|
|
13
|
+
|
|
14
|
+
import semver
|
|
15
|
+
|
|
16
|
+
from .api import RSConnectServer, SPCSConnectServer, RSConnectClient, emit_task_log
|
|
17
|
+
from .exception import RSConnectException
|
|
18
|
+
from .log import logger
|
|
19
|
+
from .metadata import ContentBuildStore, ContentItemWithBuildState
|
|
20
|
+
from .models import (
|
|
21
|
+
BuildStatus,
|
|
22
|
+
ContentGuidWithBundle,
|
|
23
|
+
ContentItemV1,
|
|
24
|
+
VersionSearchFilter,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_content_build_store: ContentBuildStore | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def content_build_store() -> ContentBuildStore:
|
|
31
|
+
if _content_build_store is None:
|
|
32
|
+
raise RSConnectException("_content_build_store has not been initialized.")
|
|
33
|
+
return _content_build_store
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ensure_content_build_store(connect_server: Union[RSConnectServer, SPCSConnectServer]) -> ContentBuildStore:
|
|
37
|
+
global _content_build_store
|
|
38
|
+
if not _content_build_store:
|
|
39
|
+
logger.info("Initializing ContentBuildStore for %s" % connect_server.url)
|
|
40
|
+
_content_build_store = ContentBuildStore(connect_server)
|
|
41
|
+
return _content_build_store
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_add_content(
|
|
45
|
+
connect_server: Union[RSConnectServer, SPCSConnectServer],
|
|
46
|
+
content_guids_with_bundle: Sequence[ContentGuidWithBundle],
|
|
47
|
+
):
|
|
48
|
+
"""
|
|
49
|
+
:param content_guids_with_bundle: Union[tuple[models.ContentGuidWithBundle], list[models.ContentGuidWithBundle]]
|
|
50
|
+
"""
|
|
51
|
+
build_store = ensure_content_build_store(connect_server)
|
|
52
|
+
with RSConnectClient(connect_server) as client:
|
|
53
|
+
if len(content_guids_with_bundle) == 1:
|
|
54
|
+
all_content = [client.content_get(content_guids_with_bundle[0].guid)]
|
|
55
|
+
else:
|
|
56
|
+
# if bulk-adding then we just do client side filtering so that we
|
|
57
|
+
# dont have to make so many requests to connect.
|
|
58
|
+
all_content = client.search_content()
|
|
59
|
+
|
|
60
|
+
# always filter just in case it's a bulk add
|
|
61
|
+
guids_to_add = list(map(lambda x: x.guid, content_guids_with_bundle))
|
|
62
|
+
content_to_add_list = list(filter(lambda x: x["guid"] in guids_to_add, all_content))
|
|
63
|
+
|
|
64
|
+
# merge the provided bundle_ids if they were specified
|
|
65
|
+
content_to_add = {c["guid"]: c for c in content_to_add_list}
|
|
66
|
+
for c in content_guids_with_bundle:
|
|
67
|
+
current_bundle_id = content_to_add[c.guid]["bundle_id"]
|
|
68
|
+
content_to_add[c.guid]["bundle_id"] = c.bundle_id if c.bundle_id else current_bundle_id
|
|
69
|
+
|
|
70
|
+
for content in content_to_add.values():
|
|
71
|
+
if not content["bundle_id"]:
|
|
72
|
+
raise RSConnectException(
|
|
73
|
+
"This content has never been published to this server. "
|
|
74
|
+
+ "You must specify a bundle_id for the build. Content GUID: %s" % content["guid"]
|
|
75
|
+
)
|
|
76
|
+
build_store.add_content_item(content)
|
|
77
|
+
build_store.set_content_item_build_status(content["guid"], BuildStatus.NEEDS_BUILD)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _validate_build_rm_args(guid: Optional[str], all: bool, purge: bool):
|
|
81
|
+
if guid and all:
|
|
82
|
+
raise RSConnectException("You must specify only one of -g/--guid or --all, not both.")
|
|
83
|
+
if not guid and not all:
|
|
84
|
+
raise RSConnectException("You must specify one of -g/--guid or --all.")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_remove_content(
|
|
88
|
+
connect_server: Union[RSConnectServer, SPCSConnectServer],
|
|
89
|
+
guid: Optional[str],
|
|
90
|
+
all: bool,
|
|
91
|
+
purge: bool,
|
|
92
|
+
) -> list[str]:
|
|
93
|
+
"""
|
|
94
|
+
:return: A list of guids of the content items that were removed
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
# Make sure that either `guid` is a string or `all == True`, but not both.
|
|
98
|
+
_validate_build_rm_args(guid, all, purge)
|
|
99
|
+
|
|
100
|
+
build_store = ensure_content_build_store(connect_server)
|
|
101
|
+
guids: list[str]
|
|
102
|
+
if all:
|
|
103
|
+
guids = [c["guid"] for c in build_store.get_content_items()]
|
|
104
|
+
else:
|
|
105
|
+
# If we got here, we know `guid` is not None.
|
|
106
|
+
guids = [cast(str, guid)]
|
|
107
|
+
for guid in guids:
|
|
108
|
+
build_store.remove_content_item(guid, purge)
|
|
109
|
+
return guids
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_list_content(connect_server: Union[RSConnectServer, SPCSConnectServer], guid: str, status: Optional[str]):
|
|
113
|
+
build_store = ensure_content_build_store(connect_server)
|
|
114
|
+
if guid:
|
|
115
|
+
return [build_store.get_content_item(g) for g in guid]
|
|
116
|
+
else:
|
|
117
|
+
return build_store.get_content_items(status=status)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build_history(connect_server: Union[RSConnectServer, SPCSConnectServer], guid: str):
|
|
121
|
+
return ensure_content_build_store(connect_server).get_build_history(guid)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def build_start(
|
|
125
|
+
connect_server: Union[RSConnectServer, SPCSConnectServer],
|
|
126
|
+
parallelism: int,
|
|
127
|
+
aborted: bool = False,
|
|
128
|
+
error: bool = False,
|
|
129
|
+
running: bool = False,
|
|
130
|
+
retry: bool = False,
|
|
131
|
+
all: bool = False,
|
|
132
|
+
poll_wait: int = 1,
|
|
133
|
+
debug: bool = False,
|
|
134
|
+
force: bool = False,
|
|
135
|
+
):
|
|
136
|
+
build_store = ensure_content_build_store(connect_server)
|
|
137
|
+
if build_store.get_build_running() and not force:
|
|
138
|
+
raise RSConnectException(
|
|
139
|
+
"A content build operation targeting '%s' is still running, or exited abnormally. "
|
|
140
|
+
"Use the '--force' option to override this check." % connect_server.url
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# if we are re-building any already "tracked" content items, then re-add them to be safe
|
|
144
|
+
if all:
|
|
145
|
+
logger.info("Adding all content to build...")
|
|
146
|
+
all_content = build_store.get_content_items()
|
|
147
|
+
all_content = list(map(lambda x: ContentGuidWithBundle(x["guid"], x["bundle_id"]), all_content))
|
|
148
|
+
build_add_content(connect_server, all_content)
|
|
149
|
+
else:
|
|
150
|
+
# --retry is shorthand for --aborted --error --running
|
|
151
|
+
if retry:
|
|
152
|
+
aborted = True
|
|
153
|
+
error = True
|
|
154
|
+
running = True
|
|
155
|
+
|
|
156
|
+
aborted_content = []
|
|
157
|
+
if aborted:
|
|
158
|
+
logger.info("Adding ABORTED content to build...")
|
|
159
|
+
aborted_content = build_store.get_content_items(status=BuildStatus.ABORTED)
|
|
160
|
+
aborted_content = list(map(lambda x: ContentGuidWithBundle(x["guid"], x["bundle_id"]), aborted_content))
|
|
161
|
+
error_content = []
|
|
162
|
+
if error:
|
|
163
|
+
logger.info("Adding ERROR content to build...")
|
|
164
|
+
error_content = build_store.get_content_items(status=BuildStatus.ERROR)
|
|
165
|
+
error_content = list(map(lambda x: ContentGuidWithBundle(x["guid"], x["bundle_id"]), error_content))
|
|
166
|
+
running_content = []
|
|
167
|
+
if running:
|
|
168
|
+
logger.info("Adding RUNNING content to build...")
|
|
169
|
+
running_content = build_store.get_content_items(status=BuildStatus.RUNNING)
|
|
170
|
+
running_content = list(map(lambda x: ContentGuidWithBundle(x["guid"], x["bundle_id"]), running_content))
|
|
171
|
+
|
|
172
|
+
if len(aborted_content + error_content + running_content) > 0:
|
|
173
|
+
build_add_content(connect_server, aborted_content + error_content + running_content)
|
|
174
|
+
|
|
175
|
+
content_items = build_store.get_content_items(status=BuildStatus.NEEDS_BUILD)
|
|
176
|
+
if len(content_items) == 0:
|
|
177
|
+
logger.info("Nothing to build...")
|
|
178
|
+
logger.info("\tUse `rsconnect content build add` to mark content for build.")
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
build_monitor = None
|
|
182
|
+
content_executor = None
|
|
183
|
+
try:
|
|
184
|
+
logger.info("Starting content build (%s)..." % connect_server.url)
|
|
185
|
+
build_store.set_build_running(True)
|
|
186
|
+
|
|
187
|
+
# spawn a single thread to monitor progress and report feedback to the user
|
|
188
|
+
build_monitor = ThreadPoolExecutor(max_workers=1)
|
|
189
|
+
summary_future = build_monitor.submit(_monitor_build, connect_server, content_items)
|
|
190
|
+
|
|
191
|
+
# TODO: stagger concurrent builds so the first batch of builds don't start at the exact same time.
|
|
192
|
+
# this would help resolve a race condidition in the packrat cache.
|
|
193
|
+
# or we could just re-run the build...
|
|
194
|
+
|
|
195
|
+
# https://docs.python.org/3/library/concurrent.futures.html#threadpoolexecutor-example
|
|
196
|
+
# spawn a pool of worker threads to perform the content builds
|
|
197
|
+
content_executor = ThreadPoolExecutor(max_workers=parallelism)
|
|
198
|
+
build_result_futures = {
|
|
199
|
+
content_executor.submit(_build_content_item, connect_server, content, poll_wait): ContentGuidWithBundle(
|
|
200
|
+
content["guid"], content["bundle_id"]
|
|
201
|
+
)
|
|
202
|
+
for content in content_items
|
|
203
|
+
}
|
|
204
|
+
for future in as_completed(build_result_futures):
|
|
205
|
+
guid_with_bundle = build_result_futures[future]
|
|
206
|
+
try:
|
|
207
|
+
future.result()
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
# catch any unexpected exceptions from the future thread
|
|
210
|
+
build_store.set_content_item_build_status(guid_with_bundle.guid, BuildStatus.ERROR)
|
|
211
|
+
logger.error("%s generated an exception: %s" % (guid_with_bundle, exc))
|
|
212
|
+
if debug:
|
|
213
|
+
logger.error(traceback.format_exc())
|
|
214
|
+
|
|
215
|
+
# all content builds are finished, mark the build as complete
|
|
216
|
+
build_store.set_build_running(False)
|
|
217
|
+
|
|
218
|
+
# wait for the build_monitor thread to resolve its future
|
|
219
|
+
try:
|
|
220
|
+
success = summary_future.result()
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
logger.error(exc)
|
|
223
|
+
success = False
|
|
224
|
+
|
|
225
|
+
logger.info("Content build complete.")
|
|
226
|
+
if not success:
|
|
227
|
+
exit(1)
|
|
228
|
+
except KeyboardInterrupt:
|
|
229
|
+
ContentBuildStore._BUILD_ABORTED = True
|
|
230
|
+
logger.info("Content build interrupted...")
|
|
231
|
+
logger.info(
|
|
232
|
+
"Content that was in the RUNNING state may still be building on the "
|
|
233
|
+
+ "Connect server. Server builds will not be interrupted."
|
|
234
|
+
)
|
|
235
|
+
logger.info(
|
|
236
|
+
"To find content items that _may_ still be running on the server, "
|
|
237
|
+
+ "use: rsconnect content build ls --status RUNNING"
|
|
238
|
+
)
|
|
239
|
+
logger.info(
|
|
240
|
+
"To retry the content build, including items that were interrupted "
|
|
241
|
+
+ "or failed, use: rsconnect content build run --retry"
|
|
242
|
+
)
|
|
243
|
+
finally:
|
|
244
|
+
# make sure that we always mark the build as complete but note
|
|
245
|
+
# there's no guarantee that the content_executor or build_monitor
|
|
246
|
+
# were allowed to shut down gracefully, they may have been interrupted.
|
|
247
|
+
build_store.set_build_running(False)
|
|
248
|
+
if content_executor:
|
|
249
|
+
content_executor.shutdown(wait=False)
|
|
250
|
+
if build_monitor:
|
|
251
|
+
build_monitor.shutdown()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _monitor_build(
|
|
255
|
+
connect_server: Union[RSConnectServer, SPCSConnectServer], content_items: list[ContentItemWithBuildState]
|
|
256
|
+
):
|
|
257
|
+
"""
|
|
258
|
+
:return bool: True if the build completed without errors, False otherwise
|
|
259
|
+
"""
|
|
260
|
+
build_store = ensure_content_build_store(connect_server)
|
|
261
|
+
complete = []
|
|
262
|
+
error = []
|
|
263
|
+
start = datetime.now()
|
|
264
|
+
while build_store.get_build_running() and not build_store.aborted():
|
|
265
|
+
time.sleep(5)
|
|
266
|
+
complete = [item for item in content_items if item["rsconnect_build_status"] == BuildStatus.COMPLETE]
|
|
267
|
+
error = [item for item in content_items if item["rsconnect_build_status"] == BuildStatus.ERROR]
|
|
268
|
+
running = [item for item in content_items if item["rsconnect_build_status"] == BuildStatus.RUNNING]
|
|
269
|
+
pending = [item for item in content_items if item["rsconnect_build_status"] == BuildStatus.NEEDS_BUILD]
|
|
270
|
+
logger.info(
|
|
271
|
+
"Running = %d, Pending = %d, Success = %d, Error = %d"
|
|
272
|
+
% (len(running), len(pending), len(complete), len(error))
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if build_store.aborted():
|
|
276
|
+
logger.warning("Build interrupted!")
|
|
277
|
+
aborted_builds = [i["guid"] for i in content_items if i["rsconnect_build_status"] == BuildStatus.RUNNING]
|
|
278
|
+
if len(aborted_builds) > 0:
|
|
279
|
+
logger.warning("Marking %d builds as ABORTED..." % len(aborted_builds))
|
|
280
|
+
for guid in aborted_builds:
|
|
281
|
+
logger.warning("Build aborted: %s" % guid)
|
|
282
|
+
build_store.set_content_item_build_status(guid, BuildStatus.ABORTED)
|
|
283
|
+
return False
|
|
284
|
+
|
|
285
|
+
# TODO: print summary as structured json object instead of a string when
|
|
286
|
+
# format = json so that it is easily parsed by log aggregators
|
|
287
|
+
current = datetime.now()
|
|
288
|
+
duration = current - start
|
|
289
|
+
# construct a new delta w/o millis since timedelta doesn't allow strfmt
|
|
290
|
+
rounded_duration = timedelta(seconds=duration.seconds)
|
|
291
|
+
logger.info(
|
|
292
|
+
"%d/%d content builds completed in %s" % (len(complete) + len(error), len(content_items), rounded_duration)
|
|
293
|
+
)
|
|
294
|
+
logger.info("Success = %d, Error = %d" % (len(complete), len(error)))
|
|
295
|
+
if len(error) > 0:
|
|
296
|
+
logger.error("There were %d failures during your build." % len(error))
|
|
297
|
+
return False
|
|
298
|
+
return True
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _build_content_item(
|
|
302
|
+
connect_server: Union[RSConnectServer, SPCSConnectServer], content: ContentItemWithBuildState, poll_wait: int
|
|
303
|
+
):
|
|
304
|
+
build_store = ensure_content_build_store(connect_server)
|
|
305
|
+
with RSConnectClient(connect_server) as client:
|
|
306
|
+
# Pending futures will still try to execute when ThreadPoolExecutor.shutdown() is called
|
|
307
|
+
# so just exit immediately if the current build has been aborted.
|
|
308
|
+
# ThreadPoolExecutor.shutdown(cancel_futures=) isnt available until py3.9
|
|
309
|
+
if build_store.aborted():
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
guid = content["guid"]
|
|
313
|
+
logger.info("Starting build: %s" % guid)
|
|
314
|
+
build_store.update_content_item_last_build_time(guid)
|
|
315
|
+
build_store.set_content_item_build_status(guid, BuildStatus.RUNNING)
|
|
316
|
+
build_store.ensure_logs_dir(guid)
|
|
317
|
+
try:
|
|
318
|
+
task_result = client.content_build(guid, content.get("bundle_id"))
|
|
319
|
+
task_id = task_result["task_id"]
|
|
320
|
+
except RSConnectException:
|
|
321
|
+
# if we can't submit the build to connect then there is no log file
|
|
322
|
+
# created on disk. When this happens we need to set the last_build_log
|
|
323
|
+
# to None so its clear that we submitted a build but it never started
|
|
324
|
+
build_store.update_content_item_last_build_log(guid, None)
|
|
325
|
+
raise
|
|
326
|
+
log_file = build_store.get_build_log(guid, task_id)
|
|
327
|
+
if log_file is None:
|
|
328
|
+
raise RSConnectException("Log file not found for content: %s" % guid)
|
|
329
|
+
with open(log_file, "w") as log:
|
|
330
|
+
|
|
331
|
+
def write_log(line: str):
|
|
332
|
+
log.write("%s\n" % line)
|
|
333
|
+
|
|
334
|
+
_, _, task = emit_task_log(
|
|
335
|
+
connect_server,
|
|
336
|
+
guid,
|
|
337
|
+
task_id,
|
|
338
|
+
log_callback=write_log,
|
|
339
|
+
abort_func=build_store.aborted,
|
|
340
|
+
poll_wait=poll_wait,
|
|
341
|
+
raise_on_error=False,
|
|
342
|
+
)
|
|
343
|
+
build_store.update_content_item_last_build_log(guid, log_file)
|
|
344
|
+
|
|
345
|
+
if build_store.aborted():
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
build_store.set_content_item_last_build_task_result(guid, task)
|
|
349
|
+
if task["code"] != 0:
|
|
350
|
+
logger.error("Build failed: %s" % guid)
|
|
351
|
+
build_store.set_content_item_build_status(guid, BuildStatus.ERROR)
|
|
352
|
+
else:
|
|
353
|
+
logger.info("Build succeeded: %s" % guid)
|
|
354
|
+
build_store.set_content_item_build_status(guid, BuildStatus.COMPLETE)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def emit_build_log(
|
|
358
|
+
connect_server: Union[RSConnectServer, SPCSConnectServer],
|
|
359
|
+
guid: str,
|
|
360
|
+
format: str,
|
|
361
|
+
task_id: Optional[str] = None,
|
|
362
|
+
):
|
|
363
|
+
build_store = ensure_content_build_store(connect_server)
|
|
364
|
+
log_file = build_store.get_build_log(guid, task_id)
|
|
365
|
+
if log_file:
|
|
366
|
+
with open(log_file, "r") as f:
|
|
367
|
+
for line in f.readlines():
|
|
368
|
+
if format == "json":
|
|
369
|
+
yield json.dumps({"message": line}) + "\n"
|
|
370
|
+
else:
|
|
371
|
+
yield line
|
|
372
|
+
else:
|
|
373
|
+
raise RSConnectException("Log file not found for content: %s" % guid)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def download_bundle(connect_server: Union[RSConnectServer, SPCSConnectServer], guid_with_bundle: ContentGuidWithBundle):
|
|
377
|
+
"""
|
|
378
|
+
:param guid_with_bundle: models.ContentGuidWithBundle
|
|
379
|
+
"""
|
|
380
|
+
with RSConnectClient(connect_server) as client:
|
|
381
|
+
# bundle_id not provided so grab the latest
|
|
382
|
+
if not guid_with_bundle.bundle_id:
|
|
383
|
+
content = client.get_content(guid_with_bundle.guid)
|
|
384
|
+
if "bundle_id" in content and content["bundle_id"]:
|
|
385
|
+
guid_with_bundle.bundle_id = content["bundle_id"]
|
|
386
|
+
else:
|
|
387
|
+
raise RSConnectException(
|
|
388
|
+
"There is no current bundle available for this content: %s" % guid_with_bundle.guid
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
return client.download_bundle(guid_with_bundle.guid, guid_with_bundle.bundle_id)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def download_lockfile(connect_server: Union[RSConnectServer, SPCSConnectServer], guid: str):
|
|
395
|
+
with RSConnectClient(connect_server) as client:
|
|
396
|
+
return client.content_lockfile(guid)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def get_content(connect_server: Union[RSConnectServer, SPCSConnectServer], guid: str | list[str]):
|
|
400
|
+
"""
|
|
401
|
+
:param guid: a single guid as a string or list of guids.
|
|
402
|
+
:return: a list of content items.
|
|
403
|
+
"""
|
|
404
|
+
with RSConnectClient(connect_server) as client:
|
|
405
|
+
if isinstance(guid, str):
|
|
406
|
+
result = [client.get_content(guid)]
|
|
407
|
+
else:
|
|
408
|
+
result = [client.get_content(g) for g in guid]
|
|
409
|
+
return result
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def search_content(
|
|
413
|
+
connect_server: Union[RSConnectServer, SPCSConnectServer],
|
|
414
|
+
published: bool,
|
|
415
|
+
unpublished: bool,
|
|
416
|
+
content_type: Sequence[str],
|
|
417
|
+
r_version: Optional[VersionSearchFilter],
|
|
418
|
+
py_version: Optional[VersionSearchFilter],
|
|
419
|
+
title_contains: Optional[str],
|
|
420
|
+
order_by: Optional[Literal["created", "last_deployed"]],
|
|
421
|
+
):
|
|
422
|
+
with RSConnectClient(connect_server) as client:
|
|
423
|
+
result = client.search_content()
|
|
424
|
+
result = _apply_content_filters(
|
|
425
|
+
result, published, unpublished, content_type, r_version, py_version, title_contains
|
|
426
|
+
)
|
|
427
|
+
return _order_content_results(result, order_by)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _apply_content_filters(
|
|
431
|
+
content_list: list[ContentItemV1],
|
|
432
|
+
published: bool,
|
|
433
|
+
unpublished: bool,
|
|
434
|
+
content_type: Sequence[str],
|
|
435
|
+
r_version: Optional[VersionSearchFilter],
|
|
436
|
+
py_version: Optional[VersionSearchFilter],
|
|
437
|
+
title_search: Optional[str],
|
|
438
|
+
) -> Iterator[ContentItemV1]:
|
|
439
|
+
def content_is_published(item: ContentItemV1):
|
|
440
|
+
return item.get("bundle_id") is not None
|
|
441
|
+
|
|
442
|
+
def content_is_unpublished(item: ContentItemV1):
|
|
443
|
+
return item.get("bundle_id") is None
|
|
444
|
+
|
|
445
|
+
def title_contains(item: ContentItemV1):
|
|
446
|
+
if title_search is None:
|
|
447
|
+
return True
|
|
448
|
+
return item["title"] is not None and title_search in item["title"]
|
|
449
|
+
|
|
450
|
+
def apply_content_type_filter(item: ContentItemV1):
|
|
451
|
+
return item["app_mode"] is not None and item["app_mode"] in content_type
|
|
452
|
+
|
|
453
|
+
def apply_version_filter(items: Iterator[ContentItemV1], version_filter: VersionSearchFilter):
|
|
454
|
+
def do_filter(item: ContentItemV1) -> bool:
|
|
455
|
+
vers = None
|
|
456
|
+
if version_filter.name not in item:
|
|
457
|
+
return False
|
|
458
|
+
else:
|
|
459
|
+
vers = cast(str, item[version_filter.name])
|
|
460
|
+
try:
|
|
461
|
+
compare = cast(
|
|
462
|
+
Literal[-1, 0, 1],
|
|
463
|
+
semver.compare(vers, version_filter.vers), # pyright: ignore[reportUnknownMemberType]
|
|
464
|
+
)
|
|
465
|
+
except (ValueError, TypeError):
|
|
466
|
+
return False
|
|
467
|
+
|
|
468
|
+
if version_filter.comp == ">":
|
|
469
|
+
return compare == 1
|
|
470
|
+
elif version_filter.comp == "<":
|
|
471
|
+
return compare == -1
|
|
472
|
+
elif version_filter.comp in ["=", "=="]:
|
|
473
|
+
return compare == 0
|
|
474
|
+
elif version_filter.comp == "<=":
|
|
475
|
+
return compare <= 0
|
|
476
|
+
elif version_filter.comp == ">=":
|
|
477
|
+
return compare >= 0
|
|
478
|
+
return False
|
|
479
|
+
|
|
480
|
+
return filter(do_filter, items)
|
|
481
|
+
|
|
482
|
+
result = iter(content_list)
|
|
483
|
+
if published:
|
|
484
|
+
result = filter(content_is_published, result)
|
|
485
|
+
if unpublished:
|
|
486
|
+
result = filter(content_is_unpublished, result)
|
|
487
|
+
if content_type:
|
|
488
|
+
result = filter(apply_content_type_filter, result)
|
|
489
|
+
if title_search:
|
|
490
|
+
result = filter(title_contains, result)
|
|
491
|
+
if r_version:
|
|
492
|
+
result = apply_version_filter(result, r_version)
|
|
493
|
+
if py_version:
|
|
494
|
+
result = apply_version_filter(result, py_version)
|
|
495
|
+
return result
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _order_content_results(
|
|
499
|
+
content_list: Iterator[ContentItemV1],
|
|
500
|
+
order_by: Optional[Literal["created", "last_deployed"]],
|
|
501
|
+
) -> list[ContentItemV1]:
|
|
502
|
+
result = content_list
|
|
503
|
+
if order_by == "last_deployed":
|
|
504
|
+
pass # do nothing, content is ordered by last_deployed by default
|
|
505
|
+
elif order_by == "created":
|
|
506
|
+
result = sorted(result, key=lambda c: c["created_time"], reverse=True)
|
|
507
|
+
|
|
508
|
+
return list(result)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public API for managing execution environments on Posit Connect.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Optional, Union
|
|
8
|
+
|
|
9
|
+
from .api import RSConnectClient, RSConnectServer, SPCSConnectServer
|
|
10
|
+
from .models import (
|
|
11
|
+
EnvironmentCreateInput,
|
|
12
|
+
EnvironmentInstallation,
|
|
13
|
+
EnvironmentInstallations,
|
|
14
|
+
EnvironmentPermissionInput,
|
|
15
|
+
EnvironmentPermissionV1,
|
|
16
|
+
EnvironmentUpdateInput,
|
|
17
|
+
EnvironmentV1,
|
|
18
|
+
EnvironmentVolumeMount,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def list_environments(
|
|
23
|
+
connect_server: Union[RSConnectServer, SPCSConnectServer],
|
|
24
|
+
) -> list[EnvironmentV1]:
|
|
25
|
+
with RSConnectClient(connect_server) as client:
|
|
26
|
+
return client.environment_list()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_environment(
|
|
30
|
+
connect_server: Union[RSConnectServer, SPCSConnectServer],
|
|
31
|
+
guid: str,
|
|
32
|
+
) -> EnvironmentV1:
|
|
33
|
+
with RSConnectClient(connect_server) as client:
|
|
34
|
+
return client.environment_get(guid)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_environment(
|
|
38
|
+
connect_server: Union[RSConnectServer, SPCSConnectServer],
|
|
39
|
+
image: str,
|
|
40
|
+
title: Optional[str] = None,
|
|
41
|
+
description: Optional[str] = None,
|
|
42
|
+
matching: Optional[str] = None,
|
|
43
|
+
supervisor: Optional[str] = None,
|
|
44
|
+
python: Optional[list[EnvironmentInstallation]] = None,
|
|
45
|
+
quarto: Optional[list[EnvironmentInstallation]] = None,
|
|
46
|
+
r: Optional[list[EnvironmentInstallation]] = None,
|
|
47
|
+
tensorflow: Optional[list[EnvironmentInstallation]] = None,
|
|
48
|
+
volume_mounts: Optional[list[EnvironmentVolumeMount]] = None,
|
|
49
|
+
user_guids: Optional[list[str]] = None,
|
|
50
|
+
group_guids: Optional[list[str]] = None,
|
|
51
|
+
) -> EnvironmentV1:
|
|
52
|
+
body: EnvironmentCreateInput = {
|
|
53
|
+
"cluster_name": "Kubernetes",
|
|
54
|
+
"name": image,
|
|
55
|
+
}
|
|
56
|
+
if title is not None:
|
|
57
|
+
body["title"] = title
|
|
58
|
+
if description is not None:
|
|
59
|
+
body["description"] = description
|
|
60
|
+
if matching is not None:
|
|
61
|
+
body["matching"] = matching
|
|
62
|
+
if supervisor is not None:
|
|
63
|
+
body["supervisor"] = supervisor
|
|
64
|
+
if python is not None:
|
|
65
|
+
body["python"] = _make_installations(python)
|
|
66
|
+
if quarto is not None:
|
|
67
|
+
body["quarto"] = _make_installations(quarto)
|
|
68
|
+
if r is not None:
|
|
69
|
+
body["r"] = _make_installations(r)
|
|
70
|
+
if tensorflow is not None:
|
|
71
|
+
body["tensorflow"] = _make_installations(tensorflow)
|
|
72
|
+
if volume_mounts is not None:
|
|
73
|
+
body["volume_mounts"] = volume_mounts
|
|
74
|
+
|
|
75
|
+
with RSConnectClient(connect_server) as client:
|
|
76
|
+
result = client.environment_create(body)
|
|
77
|
+
if user_guids is not None or group_guids is not None:
|
|
78
|
+
_sync_permissions(client, result["guid"], user_guids, group_guids)
|
|
79
|
+
return client.environment_get(result["guid"])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def update_environment(
|
|
83
|
+
connect_server: Union[RSConnectServer, SPCSConnectServer],
|
|
84
|
+
guid: str,
|
|
85
|
+
title: Optional[str] = None,
|
|
86
|
+
description: Optional[str] = None,
|
|
87
|
+
matching: Optional[str] = None,
|
|
88
|
+
supervisor: Optional[str] = None,
|
|
89
|
+
python: Optional[list[EnvironmentInstallation]] = None,
|
|
90
|
+
quarto: Optional[list[EnvironmentInstallation]] = None,
|
|
91
|
+
r: Optional[list[EnvironmentInstallation]] = None,
|
|
92
|
+
tensorflow: Optional[list[EnvironmentInstallation]] = None,
|
|
93
|
+
volume_mounts: Optional[list[EnvironmentVolumeMount]] = None,
|
|
94
|
+
user_guids: Optional[list[str]] = None,
|
|
95
|
+
group_guids: Optional[list[str]] = None,
|
|
96
|
+
) -> EnvironmentV1:
|
|
97
|
+
with RSConnectClient(connect_server) as client:
|
|
98
|
+
existing = client.environment_get(guid)
|
|
99
|
+
|
|
100
|
+
body: EnvironmentUpdateInput = {
|
|
101
|
+
"title": title if title is not None else existing["title"],
|
|
102
|
+
"description": description if description is not None else existing["description"],
|
|
103
|
+
"matching": matching if matching is not None else existing["matching"],
|
|
104
|
+
"supervisor": supervisor if supervisor is not None else existing["supervisor"],
|
|
105
|
+
"python": _make_installations(python) if python is not None else existing["python"],
|
|
106
|
+
"quarto": _make_installations(quarto) if quarto is not None else existing["quarto"],
|
|
107
|
+
"r": _make_installations(r) if r is not None else existing["r"],
|
|
108
|
+
"tensorflow": _make_installations(tensorflow) if tensorflow is not None else existing["tensorflow"],
|
|
109
|
+
"volume_mounts": volume_mounts if volume_mounts is not None else existing["volume_mounts"],
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
result = client.environment_update(guid, body)
|
|
113
|
+
|
|
114
|
+
if user_guids is not None or group_guids is not None:
|
|
115
|
+
_sync_permissions(client, guid, user_guids, group_guids)
|
|
116
|
+
return client.environment_get(guid)
|
|
117
|
+
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def delete_environment(
|
|
122
|
+
connect_server: Union[RSConnectServer, SPCSConnectServer],
|
|
123
|
+
guid: str,
|
|
124
|
+
) -> None:
|
|
125
|
+
with RSConnectClient(connect_server) as client:
|
|
126
|
+
client.environment_delete(guid)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _make_installations(items: list[EnvironmentInstallation]) -> EnvironmentInstallations:
|
|
130
|
+
return {"installations": items}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _sync_permissions(
|
|
134
|
+
client: RSConnectClient,
|
|
135
|
+
env_guid: str,
|
|
136
|
+
user_guids: Optional[list[str]],
|
|
137
|
+
group_guids: Optional[list[str]],
|
|
138
|
+
) -> list[EnvironmentPermissionV1]:
|
|
139
|
+
existing = client.environment_permission_list(env_guid)
|
|
140
|
+
|
|
141
|
+
desired_users = set(user_guids or [])
|
|
142
|
+
desired_groups = set(group_guids or [])
|
|
143
|
+
|
|
144
|
+
existing_users = {p["user_guid"]: p for p in existing if p["user_guid"] is not None}
|
|
145
|
+
existing_groups = {p["group_guid"]: p for p in existing if p["group_guid"] is not None}
|
|
146
|
+
|
|
147
|
+
results: list[EnvironmentPermissionV1] = []
|
|
148
|
+
for g in desired_users - set(existing_users.keys()):
|
|
149
|
+
body: EnvironmentPermissionInput = {"user_guid": g}
|
|
150
|
+
results.append(client.environment_permission_add(env_guid, body))
|
|
151
|
+
for g in desired_groups - set(existing_groups.keys()):
|
|
152
|
+
body = {"group_guid": g}
|
|
153
|
+
results.append(client.environment_permission_add(env_guid, body))
|
|
154
|
+
|
|
155
|
+
for g in set(existing_users.keys()) - desired_users:
|
|
156
|
+
client.environment_permission_delete(env_guid, existing_users[g]["guid"])
|
|
157
|
+
for g in set(existing_groups.keys()) - desired_groups:
|
|
158
|
+
client.environment_permission_delete(env_guid, existing_groups[g]["guid"])
|
|
159
|
+
|
|
160
|
+
return results
|