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.
Files changed (63) hide show
  1. rsconnect/__init__.py +13 -0
  2. rsconnect/actions.py +565 -0
  3. rsconnect/actions_content.py +508 -0
  4. rsconnect/actions_environment.py +160 -0
  5. rsconnect/actions_integration.py +118 -0
  6. rsconnect/api.py +2582 -0
  7. rsconnect/bundle.py +2481 -0
  8. rsconnect/certificates.py +39 -0
  9. rsconnect/environment.py +390 -0
  10. rsconnect/environment_node.py +115 -0
  11. rsconnect/environment_r.py +300 -0
  12. rsconnect/exception.py +15 -0
  13. rsconnect/git_metadata.py +180 -0
  14. rsconnect/http_support.py +595 -0
  15. rsconnect/json_web_token.py +178 -0
  16. rsconnect/log.py +253 -0
  17. rsconnect/main.py +5889 -0
  18. rsconnect/metadata.py +879 -0
  19. rsconnect/models.py +835 -0
  20. rsconnect/oauth.py +623 -0
  21. rsconnect/py.typed +0 -0
  22. rsconnect/pyproject.py +283 -0
  23. rsconnect/quickstart/__init__.py +16 -0
  24. rsconnect/quickstart/quickstart.py +486 -0
  25. rsconnect/quickstart/templates/__init__.py +16 -0
  26. rsconnect/quickstart/templates/api/README.md.tmpl +15 -0
  27. rsconnect/quickstart/templates/api/__connect__.py.tmpl +3 -0
  28. rsconnect/quickstart/templates/api/__init__.py.tmpl +1 -0
  29. rsconnect/quickstart/templates/api/__main__.py.tmpl +14 -0
  30. rsconnect/quickstart/templates/api/app.py.tmpl +11 -0
  31. rsconnect/quickstart/templates/api/pyproject.toml.tmpl +13 -0
  32. rsconnect/quickstart/templates/fastapi/README.md.tmpl +15 -0
  33. rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +3 -0
  34. rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +1 -0
  35. rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +16 -0
  36. rsconnect/quickstart/templates/fastapi/app.py.tmpl +11 -0
  37. rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +14 -0
  38. rsconnect/quickstart/templates/notebook/README.md.tmpl +15 -0
  39. rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +34 -0
  40. rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +13 -0
  41. rsconnect/quickstart/templates/quarto/README.md.tmpl +19 -0
  42. rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +11 -0
  43. rsconnect/quickstart/templates/quarto/report.qmd.tmpl +8 -0
  44. rsconnect/quickstart/templates/shiny/README.md.tmpl +15 -0
  45. rsconnect/quickstart/templates/shiny/app.py.tmpl +3 -0
  46. rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +13 -0
  47. rsconnect/quickstart/templates/streamlit/README.md.tmpl +15 -0
  48. rsconnect/quickstart/templates/streamlit/app.py.tmpl +3 -0
  49. rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +13 -0
  50. rsconnect/quickstart/templates/voila/README.md.tmpl +15 -0
  51. rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +14 -0
  52. rsconnect/shiny_express.py +136 -0
  53. rsconnect/snowflake.py +93 -0
  54. rsconnect/subprocesses/__init__.py +0 -0
  55. rsconnect/subprocesses/inspect_environment.py +362 -0
  56. rsconnect/timeouts.py +89 -0
  57. rsconnect/utils_package.py +261 -0
  58. rsconnect/validation.py +156 -0
  59. rsconnect/version_check.py +154 -0
  60. rsconnect_python-1.30.0.dist-info/METADATA +89 -0
  61. rsconnect_python-1.30.0.dist-info/RECORD +63 -0
  62. rsconnect_python-1.30.0.dist-info/WHEEL +4 -0
  63. 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