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
rsconnect/api.py ADDED
@@ -0,0 +1,2582 @@
1
+ """
2
+ Posit Connect API client and utility functions
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+ import binascii
9
+ import datetime
10
+ import hashlib
11
+ import hmac
12
+ import json
13
+ import os
14
+ import re
15
+ import sys
16
+ import time
17
+ import typing
18
+ import webbrowser
19
+ from os.path import abspath, dirname
20
+ from ssl import SSLError
21
+ from typing import (
22
+ IO,
23
+ TYPE_CHECKING,
24
+ Any,
25
+ Callable,
26
+ List,
27
+ Literal,
28
+ Mapping,
29
+ Optional,
30
+ TypeVar,
31
+ Union,
32
+ cast,
33
+ overload,
34
+ )
35
+ from urllib import parse
36
+ from urllib.parse import urlencode, urlparse
37
+ from warnings import warn
38
+
39
+ import click
40
+
41
+ if sys.version_info >= (3, 10):
42
+ from typing import ParamSpec
43
+ else:
44
+ from typing_extensions import ParamSpec
45
+
46
+ # Even though TypedDict is available in Python 3.8, because it's used with NotRequired,
47
+ # they should both come from the same typing module.
48
+ # https://peps.python.org/pep-0655/#usage-in-python-3-11
49
+ if sys.version_info >= (3, 11):
50
+ from typing import TypedDict
51
+ else:
52
+ from typing_extensions import TypedDict
53
+
54
+ from . import validation
55
+ from .bundle import _default_title
56
+ from .certificates import read_certificate_file
57
+ from .environment import fake_module_file_from_directory
58
+ from .exception import DeploymentFailedException, RSConnectException
59
+ from .http_support import (
60
+ CookieJar,
61
+ HTTPResponse,
62
+ HTTPServer,
63
+ JsonData,
64
+ append_to_path,
65
+ create_multipart_form_data,
66
+ )
67
+ from .log import cls_logged, connect_logger, console_logger, logger
68
+ from .metadata import AppStore, ServerData, ServerStore
69
+ from .models import (
70
+ AppMode,
71
+ AppModes,
72
+ BootstrapOutputDTO,
73
+ BuildOutputDTO,
74
+ BundleMetadata,
75
+ ContentItemV0,
76
+ ContentItemV1,
77
+ DeleteInputDTO,
78
+ DeleteOutputDTO,
79
+ EnvironmentCreateInput,
80
+ EnvironmentPermissionInput,
81
+ EnvironmentPermissionV1,
82
+ EnvironmentUpdateInput,
83
+ EnvironmentV1,
84
+ ListEntryOutputDTO,
85
+ OAuthIntegration,
86
+ OAuthIntegrationInput,
87
+ OAuthIntegrationUpdate,
88
+ OAuthTemplate,
89
+ PyInfo,
90
+ RepositoryBundleOutput,
91
+ RepositoryInfo,
92
+ ServerSettings,
93
+ TaskStatusV1,
94
+ UserRecord,
95
+ )
96
+ from .snowflake import generate_jwt, get_parameters
97
+ from .timeouts import get_task_timeout, get_task_timeout_help_message
98
+ from .utils_package import compare_semvers
99
+
100
+ if TYPE_CHECKING:
101
+ import logging
102
+
103
+
104
+ T = TypeVar("T")
105
+ P = ParamSpec("P")
106
+
107
+
108
+ class AbstractRemoteServer:
109
+ def __init__(self, url: str, remote_name: str):
110
+ self.url = url
111
+ self.remote_name = remote_name
112
+
113
+ @overload
114
+ def handle_bad_response(self, response: HTTPResponse, is_httpresponse: Literal[True]) -> HTTPResponse: ...
115
+
116
+ @overload
117
+ def handle_bad_response(self, response: HTTPResponse | T, is_httpresponse: Literal[False] = False) -> T: ...
118
+
119
+ def handle_bad_response(self, response: HTTPResponse | T, is_httpresponse: bool = False) -> T | HTTPResponse:
120
+ """
121
+ Handle a bad response from the server.
122
+
123
+ For most requests, we expect the response to already have been converted to
124
+ JSON. This is when `is_httpresponse` has the default value False. In these
125
+ cases:
126
+
127
+ * By the time a response object reaches this function, it should have been
128
+ converted to the JSON data contained in the original HTTPResponse object.
129
+ If the response is still an HTTPResponse object at this point, that means
130
+ that something went wrong, and it raises an exception, even if the status
131
+ was 2xx.
132
+
133
+ However, in some cases, we expect that the input object is an HTTPResponse
134
+ that did not contain JSON. This is when `is_httpresponse` is set to True. In
135
+ these cases:
136
+
137
+ * The response object should still be an HTTPResponse object. If it has a
138
+ 2xx status, then it will be returned. If it has any other status, then
139
+ an exceptio nwill be raised.
140
+
141
+ :param response: The response object to check.
142
+ :param is_httpresponse: If False (the default), expect that the input object is
143
+ a JsonData object. If True, expect that the input object is a HTTPResponse
144
+ object.
145
+ :return: The response object, if it is not an HTTPResponse object. If it was
146
+ an HTTPResponse object, this function will raise an exception and
147
+ not return.
148
+ """
149
+
150
+ if isinstance(response, HTTPResponse):
151
+ if response.exception:
152
+ raise RSConnectException(
153
+ "Could not connect to %s - %s" % (self.url, response.exception), cause=response.exception
154
+ )
155
+ # Sometimes an ISP will respond to an unknown server name by returning a friendly
156
+ # search page so trap that since we know we're expecting JSON from Connect. This
157
+ # also catches all error conditions which we will report as "not running Connect".
158
+ else:
159
+ if (
160
+ response.json_data
161
+ and isinstance(response.json_data, dict)
162
+ and "error" in response.json_data
163
+ and response.json_data["error"] is not None
164
+ ):
165
+ error = "%s reported an error (calling %s): %s" % (
166
+ self.remote_name,
167
+ response.full_uri,
168
+ response.json_data["error"],
169
+ )
170
+ raise RSConnectException(error, status=response.status)
171
+ if response.status < 200 or response.status > 299:
172
+ raise RSConnectException(
173
+ "Received an unexpected response from %s (calling %s): %s %s"
174
+ % (
175
+ self.remote_name,
176
+ response.full_uri,
177
+ response.status,
178
+ response.reason,
179
+ ),
180
+ status=response.status,
181
+ )
182
+ if not is_httpresponse:
183
+ # If we got here, it was a 2xx response that contained JSON and did not
184
+ # have an error field, but for some reason the object returned from the
185
+ # prior function call was not converted from a HTTPResponse to JSON. This
186
+ # should never happen, so raise an exception.
187
+ raise RSConnectException(
188
+ "Received an unexpected response from %s (calling %s): %s %s"
189
+ % (
190
+ self.remote_name,
191
+ response.full_uri,
192
+ response.status,
193
+ response.reason,
194
+ )
195
+ )
196
+ return response
197
+
198
+
199
+ class PositServer(AbstractRemoteServer):
200
+ """
201
+ A class used to represent the server of the shinyapps.io API.
202
+ """
203
+
204
+ def __init__(self, remote_name: str, url: str, account_name: str, token: str, secret: str):
205
+ super().__init__(url, remote_name)
206
+ self.account_name = account_name
207
+ self.token = token
208
+ self.secret = secret
209
+
210
+
211
+ class ShinyappsServer(PositServer):
212
+ """
213
+ A class to encapsulate the information needed to interact with an
214
+ instance of the shinyapps.io server.
215
+ """
216
+
217
+ def __init__(self, url: str, account_name: str, token: str, secret: str):
218
+ remote_name = "shinyapps.io"
219
+ if url == "shinyapps.io" or url is None:
220
+ url = "https://api.shinyapps.io"
221
+ super().__init__(remote_name=remote_name, url=url, account_name=account_name, token=token, secret=secret)
222
+
223
+
224
+ class RSConnectServer(AbstractRemoteServer):
225
+ """
226
+ A simple class to encapsulate the information needed to interact with an
227
+ instance of the Connect server.
228
+ """
229
+
230
+ def __init__(
231
+ self,
232
+ url: str,
233
+ api_key: Optional[str],
234
+ insecure: bool = False,
235
+ ca_data: Optional[str | bytes] = None,
236
+ bootstrap_jwt: Optional[str] = None,
237
+ oauth_access_token: Optional[str] = None,
238
+ oauth_client_id: Optional[str] = None,
239
+ server_name: Optional[str] = None,
240
+ ):
241
+ super().__init__(url, "Posit Connect")
242
+ self.api_key = api_key
243
+ self.bootstrap_jwt = bootstrap_jwt
244
+ self.insecure = insecure
245
+ self.ca_data = ca_data
246
+ self.oauth_access_token = oauth_access_token
247
+ self.oauth_client_id = oauth_client_id
248
+ self.server_name = server_name
249
+ # This is specifically not None.
250
+ self.cookie_jar = CookieJar()
251
+ # for compatibility with RSconnectClient
252
+ self.snowflake_connection_name = None
253
+
254
+
255
+ class SPCSConnectServer(AbstractRemoteServer):
256
+ """
257
+ A class to encapsulate the information needed to interact with an instance
258
+ of Posit Connect deployed in Snowflake SPCS (Snowpark Container Services).
259
+
260
+ SPCS deployments use Snowflake OIDC authentication combined with Connect API keys.
261
+ """
262
+
263
+ def __init__(
264
+ self,
265
+ url: str,
266
+ api_key: Optional[str],
267
+ snowflake_connection_name: Optional[str],
268
+ insecure: bool = False,
269
+ ca_data: Optional[str | bytes] = None,
270
+ ):
271
+ super().__init__(url, "Posit Connect (SPCS)")
272
+ self.snowflake_connection_name = snowflake_connection_name
273
+ self.insecure = insecure
274
+ self.ca_data = ca_data
275
+ # for compatibility with RSConnectClient
276
+ self.cookie_jar = CookieJar()
277
+ self.api_key = api_key
278
+ self.bootstrap_jwt = None
279
+
280
+ def token_endpoint(self) -> str:
281
+ params = get_parameters(self.snowflake_connection_name)
282
+
283
+ if params is None:
284
+ raise RSConnectException("No Snowflake connection found.")
285
+
286
+ return f"https://{params['account']}.snowflakecomputing.com/"
287
+
288
+ def fmt_payload(self):
289
+ params = get_parameters(self.snowflake_connection_name)
290
+
291
+ if params is None:
292
+ raise RSConnectException("No Snowflake connection found.")
293
+
294
+ authenticator = params.get("authenticator")
295
+ if not authenticator:
296
+ raise NotImplementedError("Snowflake connection does not declare an authenticator.")
297
+
298
+ authenticator = authenticator.lower()
299
+ if authenticator == "snowflake_jwt":
300
+ spcs_url = urlparse(self.url)
301
+ scope = f"session:role:{params['role']} {spcs_url.netloc}" if params.get("role") else spcs_url.netloc
302
+ jwt = generate_jwt(self.snowflake_connection_name)
303
+ grant_type = "urn:ietf:params:oauth:grant-type:jwt-bearer"
304
+
305
+ payload = {"scope": scope, "assertion": jwt, "grant_type": grant_type}
306
+ payload = urlencode(payload)
307
+ return {
308
+ "body": payload,
309
+ "headers": {"Content-Type": "application/x-www-form-urlencoded"},
310
+ "path": "/oauth/token",
311
+ }
312
+ elif authenticator == "oauth":
313
+ payload = {
314
+ "data": {
315
+ "AUTHENTICATOR": "OAUTH",
316
+ "TOKEN": params["token"],
317
+ }
318
+ }
319
+ return {
320
+ "body": payload,
321
+ "headers": {
322
+ "Content-Type": "application/json",
323
+ "Authorization": f"Bearer {params['token']}",
324
+ "X-Snowflake-Authorization-Token-Type": "OAUTH",
325
+ },
326
+ "path": "/session/v1/login-request",
327
+ }
328
+ else:
329
+ raise NotImplementedError(f"Unsupported authenticator for SPCS Connect: {authenticator}")
330
+
331
+ def exchange_token(self) -> str:
332
+ try:
333
+ server = HTTPServer(url=self.token_endpoint())
334
+ payload = self.fmt_payload()
335
+
336
+ response = server.request(
337
+ method="POST",
338
+ **payload, # type: ignore[arg-type] # fmt_payload returns a dict with body and headers
339
+ )
340
+ response = cast(HTTPResponse, response)
341
+
342
+ # borrowed from AbstractRemoteServer.handle_bad_response
343
+ # since we don't want to pick up its json decoding assumptions
344
+ if response.status < 200 or response.status > 299:
345
+ raise RSConnectException(
346
+ "Received an unexpected response from %s (calling %s): %s %s"
347
+ % (
348
+ self.url,
349
+ response.full_uri,
350
+ response.status,
351
+ response.reason,
352
+ )
353
+ )
354
+
355
+ # Validate response body exists
356
+ if not response.response_body:
357
+ raise RSConnectException("Token exchange returned empty response")
358
+
359
+ # Ensure response body is decoded to string on the object
360
+ if isinstance(response.response_body, bytes):
361
+ response.response_body = response.response_body.decode("utf-8")
362
+
363
+ # Try to parse as JSON first
364
+ try:
365
+ import json
366
+
367
+ json_data = json.loads(response.response_body)
368
+ # If it's JSON, extract the token from data.token
369
+ if isinstance(json_data, dict) and "data" in json_data and "token" in json_data["data"]:
370
+ return json_data["data"]["token"]
371
+ else:
372
+ # JSON format doesn't match expected structure, return raw response
373
+ return response.response_body
374
+ except (json.JSONDecodeError, ValueError):
375
+ # Not JSON, return the raw response body
376
+ return response.response_body
377
+
378
+ except RSConnectException as e:
379
+ raise RSConnectException(f"Failed to exchange Snowflake token: {str(e)}") from e
380
+
381
+
382
+ TargetableServer = typing.Union[ShinyappsServer, RSConnectServer, SPCSConnectServer]
383
+
384
+
385
+ class S3Server(AbstractRemoteServer):
386
+ def __init__(self, url: str):
387
+ super().__init__(url, "S3")
388
+
389
+
390
+ class RSConnectClientDeployResult(TypedDict):
391
+ task_id: str | None
392
+ app_id: str
393
+ app_guid: str | None
394
+ app_url: str
395
+ dashboard_url: str
396
+ draft_url: str | None
397
+ bundle_id: str | None
398
+ title: str | None
399
+
400
+
401
+ def server_supports_git_metadata(server_version: Optional[str]) -> bool:
402
+ """
403
+ Check if the server version supports git metadata in bundle uploads.
404
+
405
+ Git metadata support was added in Connect 2025.12.0.
406
+
407
+ :param server_version: The Connect server version string
408
+ :return: True if the server supports git metadata, False otherwise
409
+ """
410
+ if not server_version:
411
+ return False
412
+
413
+ try:
414
+ return compare_semvers(server_version, "2025.11.0") > 0
415
+ except Exception:
416
+ # If we can't parse the version, assume it doesn't support it
417
+ logger.debug(f"Unable to parse server version: {server_version}")
418
+ return False
419
+
420
+
421
+ def server_supports_draft_deploy(server_version: Optional[str]) -> bool:
422
+ """
423
+ Check if the server supports deploying a bundle as a draft and activating it
424
+ separately, i.e. the ``activate`` field on the content deploy/build endpoints.
425
+
426
+ Older servers reject the unknown field, so we must not send it to them.
427
+
428
+ Draft deploys were added in Connect 2025.06.0.
429
+
430
+ :param server_version: The Connect server version string
431
+ :return: True if the server supports draft deploys, False otherwise
432
+ """
433
+ if not server_version:
434
+ return False
435
+
436
+ try:
437
+ return compare_semvers(server_version, "2025.06.0") >= 0
438
+ except Exception:
439
+ # If we can't parse the version, assume it doesn't support it
440
+ logger.debug(f"Unable to parse server version: {server_version}")
441
+ return False
442
+
443
+
444
+ class RSConnectClient(HTTPServer):
445
+ def __init__(self, server: Union[RSConnectServer, SPCSConnectServer], cookies: Optional[CookieJar] = None):
446
+ if cookies is None:
447
+ cookies = server.cookie_jar
448
+ super().__init__(
449
+ append_to_path(server.url, "__api__"),
450
+ server.insecure,
451
+ server.ca_data,
452
+ cookies,
453
+ )
454
+ self._server = server
455
+
456
+ if server.api_key:
457
+ self.key_authorization(server.api_key)
458
+
459
+ if server.bootstrap_jwt:
460
+ self.bootstrap_authorization(server.bootstrap_jwt)
461
+
462
+ if server.snowflake_connection_name and isinstance(server, SPCSConnectServer):
463
+ token = server.exchange_token()
464
+ self.snowflake_authorization(token)
465
+ if server.api_key:
466
+ self._headers["X-RSC-Authorization"] = server.api_key
467
+
468
+ if (
469
+ isinstance(server, RSConnectServer)
470
+ and server.oauth_access_token
471
+ and not server.api_key
472
+ and not server.bootstrap_jwt
473
+ ):
474
+ self.authorization(f"Bearer {server.oauth_access_token}")
475
+
476
+ def request(
477
+ self,
478
+ method: str,
479
+ path: str,
480
+ query_params: Optional[Mapping[str, "JsonData"]] = None,
481
+ body: "str | bytes | IO[bytes] | Mapping[str, Any] | list[Any] | None" = None,
482
+ maximum_redirects: int = 5,
483
+ decode_response: bool = True,
484
+ headers: Optional[Mapping[str, str]] = None,
485
+ ) -> "JsonData | HTTPResponse":
486
+ can_retry = isinstance(self._server, RSConnectServer) and bool(self._server.oauth_client_id)
487
+ start_pos: "int | None" = None
488
+ if can_retry and hasattr(body, "read"):
489
+ if getattr(body, "seekable", lambda: False)():
490
+ start_pos = body.tell() # type: ignore[union-attr]
491
+ else:
492
+ body = body.read() # type: ignore[union-attr]
493
+ response = super().request(method, path, query_params, body, maximum_redirects, decode_response, headers) # pyright: ignore[reportUnknownArgumentType]
494
+ if can_retry and isinstance(response, HTTPResponse) and response.status == 401:
495
+ if self._attempt_token_refresh():
496
+ if start_pos is not None:
497
+ body.seek(start_pos) # type: ignore[union-attr]
498
+ return super().request(method, path, query_params, body, maximum_redirects, decode_response, headers) # pyright: ignore[reportUnknownArgumentType]
499
+ return response
500
+
501
+ def _attempt_token_refresh(self) -> bool:
502
+ from .oauth import (
503
+ InvalidClientError,
504
+ discover_oauth_metadata,
505
+ keyring_delete_tokens,
506
+ keyring_get_tokens,
507
+ keyring_store_token,
508
+ refresh_access_token,
509
+ register_client,
510
+ )
511
+ from .metadata import ServerStore
512
+
513
+ server = cast(RSConnectServer, self._server)
514
+
515
+ _, refresh_token = keyring_get_tokens(server.url)
516
+ if not refresh_token:
517
+ store = ServerStore()
518
+ entry = None
519
+ if server.server_name:
520
+ entry = store.get_by_name(server.server_name)
521
+ if not entry:
522
+ entry = store.get_by_url(server.url)
523
+ if entry:
524
+ refresh_token = entry.get("oauth_refresh_token") # type: ignore[assignment]
525
+ if not refresh_token:
526
+ return False
527
+
528
+ try:
529
+ metadata = discover_oauth_metadata(server.url, server.insecure, server.ca_data)
530
+ token_response = refresh_access_token(
531
+ metadata, server.oauth_client_id or "", refresh_token, server.insecure, server.ca_data
532
+ )
533
+ except InvalidClientError:
534
+ # Client was deleted server-side; clear stale tokens and re-register
535
+ keyring_delete_tokens(server.url)
536
+ store = ServerStore()
537
+ entry = None
538
+ if server.server_name:
539
+ entry = store.get_by_name(server.server_name)
540
+ if not entry:
541
+ entry = store.get_by_url(server.url)
542
+ if entry:
543
+ entry_name = str(entry.get("name", server.server_name or server.url))
544
+ store.update_oauth_tokens(entry_name, None, None, None)
545
+ try:
546
+ metadata = discover_oauth_metadata(server.url, server.insecure, server.ca_data)
547
+ new_client_id = register_client(metadata, server.url, server.insecure, server.ca_data)
548
+ server.oauth_client_id = new_client_id
549
+ if entry:
550
+ entry["oauth_client_id"] = new_client_id # type: ignore[typeddict-unknown-key]
551
+ store._set(entry_name, entry) # type: ignore[possibly-undefined]
552
+ logger.warning("OAuth client was re-registered; please run `rsconnect login` again.")
553
+ except Exception as exc:
554
+ logger.warning(f"OAuth client re-registration failed: {exc}. Please run `rsconnect login` again.")
555
+ return False
556
+ except Exception as exc:
557
+ logger.warning(f"OAuth token refresh failed: {exc}")
558
+ return False
559
+
560
+ new_access = token_response["access_token"]
561
+ new_refresh = token_response.get("refresh_token", refresh_token)
562
+ expires_in = token_response.get("expires_in")
563
+ import time
564
+
565
+ new_expiry = time.time() + expires_in if expires_in else None
566
+
567
+ self.authorization(f"Bearer {new_access}")
568
+ server.oauth_access_token = new_access
569
+
570
+ stored = keyring_store_token(server.url, new_access, new_refresh)
571
+ if not stored:
572
+ store = ServerStore()
573
+ entry = None
574
+ if server.server_name:
575
+ entry = store.get_by_name(server.server_name)
576
+ if not entry:
577
+ entry = store.get_by_url(server.url)
578
+ if entry:
579
+ entry_name = str(entry.get("name", server.server_name or server.url))
580
+ store.update_oauth_tokens(entry_name, new_access, new_refresh, new_expiry)
581
+
582
+ return True
583
+
584
+ def _tweak_response(self, response: HTTPResponse) -> JsonData | HTTPResponse:
585
+ return (
586
+ response.json_data
587
+ if response.status and response.status >= 200 and response.status <= 299 and response.json_data is not None
588
+ else response
589
+ )
590
+
591
+ def me(self) -> UserRecord:
592
+ response = cast(Union[UserRecord, HTTPResponse], self.get("v1/user"))
593
+ response = self._server.handle_bad_response(response)
594
+ return response
595
+
596
+ def bootstrap(self) -> BootstrapOutputDTO | HTTPResponse:
597
+ response = cast(Union[BootstrapOutputDTO, HTTPResponse], self.post("v1/experimental/bootstrap"))
598
+ # TODO: The place where bootstrap() is called expects a JSON object if the response is successfule, and a
599
+ # HTTPResponse if it is not; then it handles the error. This is different from the other methods, and probably
600
+ # should be changed in the future. For this to work, we will _not_ call .handle_bad_response() here at present.
601
+ # response = self._server.handle_bad_response(response)
602
+ return response
603
+
604
+ def server_settings(self) -> ServerSettings:
605
+ response = cast(Union[ServerSettings, HTTPResponse], self.get("server_settings"))
606
+ response = self._server.handle_bad_response(response)
607
+ return response
608
+
609
+ def server_version(self) -> str:
610
+ """
611
+ Determine the Connect server version used for feature-availability checks.
612
+
613
+ A server can be configured to suppress its version from the
614
+ ``server_settings`` endpoint, which makes version-gated features default to
615
+ "off". Setting the ``CONNECT_SERVER_VERSION`` environment variable overrides
616
+ this: when it is set we use it and skip the ``server_settings`` request
617
+ entirely, so the library acts as if it is talking to that version.
618
+
619
+ :return: The server version string, or an empty string if it is unknown.
620
+ """
621
+ env_version = os.environ.get("CONNECT_SERVER_VERSION")
622
+ if env_version:
623
+ logger.debug(f"Using CONNECT_SERVER_VERSION={env_version} for server version checks")
624
+ return env_version
625
+ return self.server_settings().get("version", "")
626
+
627
+ def python_settings(self) -> PyInfo:
628
+ response = cast(Union[PyInfo, HTTPResponse], self.get("v1/server_settings/python"))
629
+ response = self._server.handle_bad_response(response)
630
+ return response
631
+
632
+ def app_get(self, app_id: str) -> ContentItemV0:
633
+ response = cast(Union[ContentItemV0, HTTPResponse], self.get(f"applications/{app_id}"))
634
+ response = self._server.handle_bad_response(response)
635
+ return response
636
+
637
+ def add_environment_vars(self, content_guid: str, env_vars: list[tuple[str, str]]):
638
+ env_body = [dict(name=kv[0], value=kv[1]) for kv in env_vars]
639
+ return self.patch(f"v1/content/{content_guid}/environment", body=env_body)
640
+
641
+ def is_failed_response(self, response: HTTPResponse | JsonData) -> bool:
642
+ return isinstance(response, HTTPResponse) and response.status >= 500
643
+
644
+ def access_content(self, content_guid: str, bundle_id: Optional[str] = None) -> None:
645
+ method = "GET"
646
+ base = dirname(self._url.path).rstrip("/") # strip "__api__" and any trailing slash
647
+ # Access a specific (e.g. draft, not-yet-activated) bundle's preview URL when a
648
+ # bundle id is given. Connect spins the process up cold to serve this, so a
649
+ # successful response confirms the bundle actually runs without touching the
650
+ # active bundle.
651
+ suffix = f"_bundle{bundle_id}/" if bundle_id is not None else ""
652
+ path = f"{base}/content/{content_guid}/{suffix}"
653
+ response = self._do_request(method, path, None, None, 3, {}, False)
654
+
655
+ if self.is_failed_response(response):
656
+ # Get content metadata to construct logs URL
657
+ content = self.content_get(content_guid)
658
+ logs_url = content["dashboard_url"] + "/logs"
659
+ raise RSConnectException(
660
+ "Could not access the deployed content. "
661
+ + "The app might not have started successfully."
662
+ + f"\n\t For more information: {logs_url}"
663
+ )
664
+
665
+ def bundle_download(self, content_guid: str, bundle_id: str) -> HTTPResponse:
666
+ response = cast(
667
+ HTTPResponse,
668
+ self.get(f"v1/content/{content_guid}/bundles/{bundle_id}/download", decode_response=False),
669
+ )
670
+ response = self._server.handle_bad_response(response, is_httpresponse=True)
671
+ return response
672
+
673
+ def content_lockfile(self, content_guid: str) -> HTTPResponse:
674
+ response = cast(
675
+ HTTPResponse,
676
+ self.get(f"v1/content/{content_guid}/lockfile", decode_response=False),
677
+ )
678
+ response = self._server.handle_bad_response(response, is_httpresponse=True)
679
+ return response
680
+
681
+ def content_list(self, filters: Optional[Mapping[str, JsonData]] = None) -> list[ContentItemV1]:
682
+ response = cast(Union[List[ContentItemV1], HTTPResponse], self.get("v1/content", query_params=filters))
683
+ response = self._server.handle_bad_response(response)
684
+ return response
685
+
686
+ def content_get(self, content_guid: str) -> ContentItemV1:
687
+ response = cast(Union[ContentItemV1, HTTPResponse], self.get(f"v1/content/{content_guid}"))
688
+ response = self._server.handle_bad_response(response)
689
+ return response
690
+
691
+ def get_content_by_id(self, id: str) -> ContentItemV1:
692
+ """
693
+ Get content by ID, which can be either a numeric ID (legacy) or GUID.
694
+
695
+ :param app_id: Either a numeric ID (e.g., "1234") or GUID (e.g., "abc-def-123")
696
+ :return: ContentItemV1 data
697
+ """
698
+ # Check if it looks like a GUID (contains hyphens)
699
+ if "-" in str(id):
700
+ return self.content_get(id)
701
+ else:
702
+ # Legacy numeric ID - get v0 content first to get GUID
703
+ app_v0 = self.app_get(id)
704
+ # TODO: deprecation warning here
705
+ return self.content_get(app_v0["guid"])
706
+
707
+ def content_create(self, name: str) -> ContentItemV1:
708
+ response = cast(Union[ContentItemV1, HTTPResponse], self.post("v1/content", body={"name": name}))
709
+ response = self._server.handle_bad_response(response)
710
+ return response
711
+
712
+ def upload_bundle(
713
+ self, content_guid: str, tarball: typing.IO[bytes], metadata: Optional[dict[str, str]] = None
714
+ ) -> BundleMetadata:
715
+ """
716
+ Upload a bundle to the server.
717
+
718
+ :param app_id: Application ID
719
+ :param tarball: Bundle tarball file object
720
+ :param metadata: Optional metadata dictionary (e.g., git metadata)
721
+ :return: ContentItemV0 with bundle information
722
+ """
723
+ if metadata:
724
+ # Use multipart form upload when metadata is provided
725
+ tarball_content = tarball.read()
726
+ fields = {
727
+ "archive": ("bundle.tar.gz", tarball_content, "application/x-tar"),
728
+ "metadata": json.dumps(metadata),
729
+ }
730
+ body, content_type = create_multipart_form_data(fields)
731
+ response = cast(
732
+ Union[BundleMetadata, HTTPResponse],
733
+ self.post(f"v1/content/{content_guid}/bundles", body=body, headers={"Content-Type": content_type}),
734
+ )
735
+ else:
736
+ response = cast(
737
+ Union[BundleMetadata, HTTPResponse], self.post(f"v1/content/{content_guid}/bundles", body=tarball)
738
+ )
739
+ response = self._server.handle_bad_response(response)
740
+ return response
741
+
742
+ def content_update(self, content_guid: str, updates: Mapping[str, str | None]) -> ContentItemV1:
743
+ response = cast(Union[ContentItemV1, HTTPResponse], self.patch(f"v1/content/{content_guid}", body=updates))
744
+ response = self._server.handle_bad_response(response)
745
+ return response
746
+
747
+ def content_build(
748
+ self, content_guid: str, bundle_id: Optional[str] = None, activate: bool = True
749
+ ) -> BuildOutputDTO:
750
+ body: dict[str, str | bool | None] = {"bundle_id": bundle_id}
751
+ if not activate:
752
+ # The default behavior is to activate the app after building.
753
+ # So we only pass the parameter if we want to deactivate it.
754
+ # That way we can keep the API backwards compatible.
755
+ body["activate"] = False
756
+ response = cast(
757
+ Union[BuildOutputDTO, HTTPResponse],
758
+ self.post(f"v1/content/{content_guid}/build", body=body),
759
+ )
760
+ response = self._server.handle_bad_response(response)
761
+ return response
762
+
763
+ def content_deploy(
764
+ self, content_guid: str, bundle_id: Optional[str] = None, activate: bool = True
765
+ ) -> BuildOutputDTO:
766
+ body: dict[str, str | bool | None] = {"bundle_id": bundle_id}
767
+ if not activate:
768
+ # The default behavior is to activate the app after deploying.
769
+ # So we only pass the parameter if we want to deactivate it.
770
+ # That way we can keep the API backwards compatible.
771
+ body["activate"] = False
772
+ response = cast(
773
+ Union[BuildOutputDTO, HTTPResponse],
774
+ self.post(f"v1/content/{content_guid}/deploy", body=body),
775
+ )
776
+ response = self._server.handle_bad_response(response)
777
+ return response
778
+
779
+ def get_repository(self, content_guid: str) -> Optional[RepositoryInfo]:
780
+ """Get git repository configuration for a content item.
781
+
782
+ :param content_guid: The GUID of the content item
783
+ :return: Repository configuration if git-managed, None otherwise
784
+ """
785
+ response = self.get("v1/content/%s/repository" % content_guid)
786
+ if isinstance(response, HTTPResponse):
787
+ # 404 means not git-managed, which is not an error
788
+ if response.status == 404:
789
+ return None
790
+ self._server.handle_bad_response(response)
791
+ return cast(RepositoryInfo, response)
792
+
793
+ def set_repository(
794
+ self,
795
+ content_guid: str,
796
+ repository: str,
797
+ branch: str = "main",
798
+ directory: str = ".",
799
+ polling: bool = True,
800
+ ) -> RepositoryInfo:
801
+ """Create or overwrite git repository configuration for a content item.
802
+
803
+ :param content_guid: The GUID of the content item
804
+ :param repository: URL of the git repository (https:// only)
805
+ :param branch: Branch to deploy from (default: main)
806
+ :param directory: Directory containing manifest.json (default: .)
807
+ :param polling: Whether the git repository should be regularly polled (default: True)
808
+ :return: The repository configuration
809
+ """
810
+ body = {
811
+ "repository": repository,
812
+ "branch": branch,
813
+ "directory": directory,
814
+ "polling": polling,
815
+ }
816
+ response = cast(
817
+ Union[RepositoryInfo, HTTPResponse],
818
+ self.put("v1/content/%s/repository" % content_guid, body=body),
819
+ )
820
+ response = self._server.handle_bad_response(response)
821
+ return response
822
+
823
+ def update_repository(
824
+ self,
825
+ content_guid: str,
826
+ repository: Optional[str] = None,
827
+ branch: Optional[str] = None,
828
+ directory: Optional[str] = None,
829
+ polling: Optional[bool] = None,
830
+ ) -> RepositoryInfo:
831
+ """Partially update git repository configuration for a content item.
832
+
833
+ Only fields that are provided will be updated.
834
+
835
+ :param content_guid: The GUID of the content item
836
+ :param repository: URL of the git repository (https:// only)
837
+ :param branch: Branch to deploy from
838
+ :param directory: Directory containing manifest.json
839
+ :param polling: Whether the git repository should be regularly polled
840
+ :return: The updated repository configuration
841
+ """
842
+ body: dict[str, str | bool] = {}
843
+ if repository is not None:
844
+ body["repository"] = repository
845
+ if branch is not None:
846
+ body["branch"] = branch
847
+ if directory is not None:
848
+ body["directory"] = directory
849
+ if polling is not None:
850
+ body["polling"] = polling
851
+
852
+ response = cast(
853
+ Union[RepositoryInfo, HTTPResponse],
854
+ self.patch("v1/content/%s/repository" % content_guid, body=body),
855
+ )
856
+ response = self._server.handle_bad_response(response)
857
+ return response
858
+
859
+ def delete_repository(self, content_guid: str) -> None:
860
+ """Remove git repository configuration from a content item.
861
+
862
+ :param content_guid: The GUID of the content item
863
+ """
864
+ response = self.delete("v1/content/%s/repository" % content_guid)
865
+ if isinstance(response, HTTPResponse):
866
+ self._server.handle_bad_response(response, is_httpresponse=True)
867
+
868
+ def create_bundle_from_repository(
869
+ self,
870
+ content_guid: str,
871
+ repository: Optional[str] = None,
872
+ ref: Optional[str] = None,
873
+ directory: Optional[str] = None,
874
+ ) -> RepositoryBundleOutput:
875
+ """Create a bundle from a git repository location.
876
+
877
+ This triggers Connect to clone the repository and create a bundle.
878
+ If the content item has existing git configuration, those values are used
879
+ as defaults; provided parameters will override them.
880
+
881
+ :param content_guid: The GUID of the content item
882
+ :param repository: URL of the git repository (uses existing config if not provided)
883
+ :param ref: Git ref to bundle from (branch, tag, or commit; uses existing branch if not provided)
884
+ :param directory: Directory containing manifest.json (uses existing config if not provided)
885
+ :return: Bundle creation result with bundle_id and task_id
886
+ """
887
+ body: dict[str, str] = {}
888
+ if repository is not None:
889
+ body["repository"] = repository
890
+ if ref is not None:
891
+ body["ref"] = ref
892
+ if directory is not None:
893
+ body["directory"] = directory
894
+
895
+ response = cast(
896
+ Union[RepositoryBundleOutput, HTTPResponse],
897
+ self.post("v1/content/%s/repository/bundle" % content_guid, body=body),
898
+ )
899
+ response = self._server.handle_bad_response(response)
900
+ return response
901
+
902
+ def deploy_git(
903
+ self,
904
+ app_id: Optional[str],
905
+ name: str,
906
+ repository: str,
907
+ branch: str,
908
+ subdirectory: str,
909
+ title: Optional[str],
910
+ env_vars: Optional[dict[str, str]],
911
+ polling: bool = True,
912
+ activate: bool = True,
913
+ ) -> RSConnectClientDeployResult:
914
+ """Deploy content from a git repository.
915
+
916
+ Creates or updates a git-backed content item in Posit Connect. Connect will clone
917
+ the repository and regularly poll it for updates.
918
+
919
+ :param app_id: Existing content ID/GUID to update, or None to create new content
920
+ :param name: Name for the content item (used if creating new)
921
+ :param repository: URL of the git repository (https:// only)
922
+ :param branch: Branch to deploy from
923
+ :param subdirectory: Subdirectory containing manifest.json
924
+ :param title: Title for the content
925
+ :param env_vars: Environment variables to set
926
+ :param polling: Whether the git repository should be regularly polled (default: True)
927
+ :param activate: Whether to activate the deployment (False = draft mode)
928
+ :return: Deployment result with task_id, app info, etc.
929
+ """
930
+ # Create or get existing content
931
+ if app_id is None:
932
+ app = self.content_create(name)
933
+ else:
934
+ try:
935
+ app = self.get_content_by_id(app_id)
936
+ except RSConnectException as e:
937
+ raise RSConnectException(
938
+ f"{e} Try setting the --new flag or omit --app-id to create new content."
939
+ ) from e
940
+
941
+ app_guid = app["guid"]
942
+
943
+ # Map subdirectory to directory (API uses "directory" field)
944
+ directory = subdirectory if subdirectory else "."
945
+
946
+ # Check if content already has git configuration
947
+ existing_repo = self.get_repository(app_guid)
948
+
949
+ try:
950
+ if existing_repo:
951
+ # Update existing git configuration using PATCH
952
+ self.update_repository(
953
+ app_guid,
954
+ repository=repository,
955
+ branch=branch,
956
+ directory=directory,
957
+ polling=polling,
958
+ )
959
+ else:
960
+ # Create new git configuration using PUT
961
+ self.set_repository(
962
+ app_guid,
963
+ repository=repository,
964
+ branch=branch,
965
+ directory=directory,
966
+ polling=polling,
967
+ )
968
+ except RSConnectException as e:
969
+ # A 404 from the repository endpoint means git-backed deployment is
970
+ # not available on this Connect server.
971
+ if e.status == 404:
972
+ raise RSConnectException(
973
+ "Git-backed deployment is not enabled on this Connect server. "
974
+ "Contact your administrator to enable Git support."
975
+ ) from e
976
+ raise
977
+
978
+ # Update title if provided (and different from current)
979
+ if title and app.get("title") != title:
980
+ self.patch("v1/content/%s" % app_guid, body={"title": title})
981
+
982
+ # Set environment variables
983
+ if env_vars:
984
+ result = self.add_environment_vars(app_guid, list(env_vars.items()))
985
+ self._server.handle_bad_response(result)
986
+
987
+ # Trigger deployment (bundle_id=None uses the latest bundle from git clone)
988
+ task = self.content_deploy(app_guid, bundle_id=None, activate=activate)
989
+
990
+ return RSConnectClientDeployResult(
991
+ app_id=str(app["id"]),
992
+ app_guid=app_guid,
993
+ app_url=app["content_url"],
994
+ task_id=task["task_id"],
995
+ title=title or app.get("title"),
996
+ dashboard_url=app["dashboard_url"],
997
+ draft_url=None,
998
+ )
999
+
1000
+ def system_caches_runtime_list(self) -> list[ListEntryOutputDTO]:
1001
+ response = cast(Union[List[ListEntryOutputDTO], HTTPResponse], self.get("v1/system/caches/runtime"))
1002
+ response = self._server.handle_bad_response(response)
1003
+ return response
1004
+
1005
+ def system_caches_runtime_delete(self, target: DeleteInputDTO) -> DeleteOutputDTO:
1006
+ response = cast(Union[DeleteOutputDTO, HTTPResponse], self.delete("v1/system/caches/runtime", body=target))
1007
+ response = self._server.handle_bad_response(response)
1008
+ return response
1009
+
1010
+ def environment_list(self) -> list[EnvironmentV1]:
1011
+ response = cast(Union[List[EnvironmentV1], HTTPResponse], self.get("v1/environments"))
1012
+ response = self._server.handle_bad_response(response)
1013
+ return response
1014
+
1015
+ def environment_get(self, guid: str) -> EnvironmentV1:
1016
+ response = cast(Union[EnvironmentV1, HTTPResponse], self.get(f"v1/environments/{guid}"))
1017
+ response = self._server.handle_bad_response(response)
1018
+ return response
1019
+
1020
+ def environment_create(self, body: EnvironmentCreateInput) -> EnvironmentV1:
1021
+ response = cast(Union[EnvironmentV1, HTTPResponse], self.post("v1/environments", body=body))
1022
+ response = self._server.handle_bad_response(response)
1023
+ return response
1024
+
1025
+ def environment_update(self, guid: str, body: EnvironmentUpdateInput) -> EnvironmentV1:
1026
+ response = cast(Union[EnvironmentV1, HTTPResponse], self.put(f"v1/environments/{guid}", body=body))
1027
+ response = self._server.handle_bad_response(response)
1028
+ return response
1029
+
1030
+ def environment_delete(self, guid: str) -> None:
1031
+ response = cast(HTTPResponse, self.delete(f"v1/environments/{guid}", decode_response=False))
1032
+ self._server.handle_bad_response(response, is_httpresponse=True)
1033
+
1034
+ def environment_permission_list(self, env_guid: str) -> list[EnvironmentPermissionV1]:
1035
+ response = cast(
1036
+ Union[List[EnvironmentPermissionV1], HTTPResponse],
1037
+ self.get(f"v1/environments/{env_guid}/permissions"),
1038
+ )
1039
+ response = self._server.handle_bad_response(response)
1040
+ return response
1041
+
1042
+ def environment_permission_add(self, env_guid: str, body: EnvironmentPermissionInput) -> EnvironmentPermissionV1:
1043
+ response = cast(
1044
+ Union[EnvironmentPermissionV1, HTTPResponse],
1045
+ self.post(f"v1/environments/{env_guid}/permissions", body=body),
1046
+ )
1047
+ response = self._server.handle_bad_response(response)
1048
+ return response
1049
+
1050
+ def environment_permission_delete(self, env_guid: str, permission_guid: str) -> None:
1051
+ response = cast(
1052
+ HTTPResponse,
1053
+ self.delete(f"v1/environments/{env_guid}/permissions/{permission_guid}", decode_response=False),
1054
+ )
1055
+ self._server.handle_bad_response(response, is_httpresponse=True)
1056
+
1057
+ def oauth_integration_list(self) -> list[OAuthIntegration]:
1058
+ response = cast(Union[List[OAuthIntegration], HTTPResponse], self.get("v1/oauth/integrations"))
1059
+ response = self._server.handle_bad_response(response)
1060
+ return response
1061
+
1062
+ def oauth_integration_get(self, guid: str) -> OAuthIntegration:
1063
+ response = cast(Union[OAuthIntegration, HTTPResponse], self.get(f"v1/oauth/integrations/{guid}"))
1064
+ response = self._server.handle_bad_response(response)
1065
+ return response
1066
+
1067
+ def oauth_integration_create(self, body: OAuthIntegrationInput) -> OAuthIntegration:
1068
+ response = cast(Union[OAuthIntegration, HTTPResponse], self.post("v1/oauth/integrations", body=body))
1069
+ response = self._server.handle_bad_response(response)
1070
+ return response
1071
+
1072
+ def oauth_integration_update(self, guid: str, body: OAuthIntegrationUpdate) -> OAuthIntegration:
1073
+ response = cast(Union[OAuthIntegration, HTTPResponse], self.patch(f"v1/oauth/integrations/{guid}", body=body))
1074
+ response = self._server.handle_bad_response(response)
1075
+ return response
1076
+
1077
+ def oauth_integration_delete(self, guid: str) -> None:
1078
+ response = cast(HTTPResponse, self.delete(f"v1/oauth/integrations/{guid}", decode_response=False))
1079
+ self._server.handle_bad_response(response, is_httpresponse=True)
1080
+
1081
+ def oauth_template_list(self) -> list[OAuthTemplate]:
1082
+ response = cast(Union[List[OAuthTemplate], HTTPResponse], self.get("v1/oauth/templates"))
1083
+ response = self._server.handle_bad_response(response)
1084
+ return response
1085
+
1086
+ def oauth_template_get(self, key: str) -> OAuthTemplate:
1087
+ response = cast(Union[OAuthTemplate, HTTPResponse], self.get(f"v1/oauth/templates/{key}"))
1088
+ response = self._server.handle_bad_response(response)
1089
+ return response
1090
+
1091
+ def task_get(
1092
+ self,
1093
+ task_id: str,
1094
+ first: Optional[int] = None,
1095
+ wait: Optional[int] = None,
1096
+ ) -> TaskStatusV1:
1097
+ params = None
1098
+ if first is not None or wait is not None:
1099
+ params = {}
1100
+ if first is not None:
1101
+ params["first"] = first
1102
+ if wait is not None:
1103
+ params["wait"] = wait
1104
+ response = cast(Union[TaskStatusV1, HTTPResponse], self.get(f"v1/tasks/{task_id}", query_params=params))
1105
+ response = self._server.handle_bad_response(response)
1106
+
1107
+ # compatibility with rsconnect-jupyter
1108
+ response["status"] = response["output"]
1109
+ response["last_status"] = response["last"]
1110
+
1111
+ return response
1112
+
1113
+ def deploy(
1114
+ self,
1115
+ app_id: Optional[str],
1116
+ app_name: Optional[str],
1117
+ app_title: Optional[str],
1118
+ title_is_default: bool,
1119
+ tarball: IO[bytes],
1120
+ env_vars: Optional[dict[str, str]] = None,
1121
+ activate: bool = True,
1122
+ metadata: Optional[dict[str, str]] = None,
1123
+ ) -> RSConnectClientDeployResult:
1124
+ if app_id is None:
1125
+ if app_name is None:
1126
+ raise RSConnectException("An app ID or name is required to deploy an app.")
1127
+ # create content if id is not provided
1128
+ app = self.content_create(app_name)
1129
+
1130
+ # Force the title to update.
1131
+ title_is_default = False
1132
+ else:
1133
+ # assume content exists. if it was deleted then Connect will raise an error
1134
+ try:
1135
+ # app_id could be a numeric ID (legacy) or GUID
1136
+ app = self.get_content_by_id(app_id)
1137
+ except RSConnectException as e:
1138
+ raise RSConnectException(f"{e} Try setting the --new flag to overwrite the previous deployment.") from e
1139
+
1140
+ app_guid = app["guid"]
1141
+ if env_vars:
1142
+ result = self.add_environment_vars(app_guid, list(env_vars.items()))
1143
+ result = self._server.handle_bad_response(result)
1144
+
1145
+ if app["title"] != app_title and not title_is_default:
1146
+ result = self.content_update(app_guid, {"title": app_title})
1147
+ result = self._server.handle_bad_response(result)
1148
+ app["title"] = app_title
1149
+
1150
+ app_bundle = self.upload_bundle(app_guid, tarball, metadata=metadata)
1151
+
1152
+ task = self.content_deploy(app_guid, app_bundle["id"], activate=activate)
1153
+
1154
+ draft_url = app["dashboard_url"] + f"/draft/{app_bundle['id']}"
1155
+
1156
+ return {
1157
+ "task_id": task["task_id"],
1158
+ "app_id": app["id"],
1159
+ "app_guid": app["guid"],
1160
+ "app_url": app["content_url"],
1161
+ "dashboard_url": app["dashboard_url"],
1162
+ "draft_url": draft_url if not activate else None,
1163
+ "bundle_id": app_bundle["id"],
1164
+ "title": app["title"],
1165
+ }
1166
+
1167
+ def download_bundle(self, content_guid: str, bundle_id: str) -> HTTPResponse:
1168
+ results = self.bundle_download(content_guid, bundle_id)
1169
+ return results
1170
+
1171
+ def search_content(self) -> list[ContentItemV1]:
1172
+ results = self.content_list()
1173
+ return results
1174
+
1175
+ def get_content(self, content_guid: str) -> ContentItemV1:
1176
+ results = self.content_get(content_guid)
1177
+ return results
1178
+
1179
+ def wait_for_task(
1180
+ self,
1181
+ task_id: str,
1182
+ log_callback: Optional[Callable[[str], None]],
1183
+ abort_func: Callable[[], bool] = lambda: False,
1184
+ timeout: int = get_task_timeout(),
1185
+ poll_wait: int = 1,
1186
+ raise_on_error: bool = True,
1187
+ ) -> tuple[list[str] | None, TaskStatusV1]:
1188
+ if log_callback is None:
1189
+ log_lines: list[str] | None = []
1190
+ log_callback = log_lines.append
1191
+ else:
1192
+ log_lines = None
1193
+
1194
+ first: int | None = None
1195
+ start_time = time.time()
1196
+ while True:
1197
+ if (time.time() - start_time) > timeout:
1198
+ raise RSConnectException(get_task_timeout_help_message(timeout))
1199
+ elif abort_func():
1200
+ raise RSConnectException("Task aborted.")
1201
+
1202
+ task = self.task_get(task_id, first=first, wait=poll_wait)
1203
+ self.output_task_log(task, log_callback)
1204
+ first = task["last"]
1205
+ if task["finished"]:
1206
+ result = task.get("result")
1207
+ if isinstance(result, dict):
1208
+ data = result.get("data", "")
1209
+ type = result.get("type", "")
1210
+ if data or type:
1211
+ log_callback("%s (%s)" % (data, type))
1212
+
1213
+ err = task.get("error")
1214
+ if err:
1215
+ log_callback("Error from Connect server: " + err)
1216
+
1217
+ exit_code = task["code"]
1218
+ if exit_code != 0:
1219
+ exit_status = "Task exited with status %d." % exit_code
1220
+ if raise_on_error:
1221
+ raise RSConnectException(exit_status)
1222
+ else:
1223
+ log_callback("Task failed. %s" % exit_status)
1224
+ return log_lines, task
1225
+
1226
+ @staticmethod
1227
+ def output_task_log(
1228
+ task: TaskStatusV1,
1229
+ log_callback: Callable[[str], None],
1230
+ ):
1231
+ """Pipe any new output through the log_callback."""
1232
+ for line in task["output"]:
1233
+ log_callback(line)
1234
+
1235
+
1236
+ class ServerDetailsPython(TypedDict):
1237
+ api_enabled: bool
1238
+ versions: list[str]
1239
+
1240
+
1241
+ class ServerDetails(TypedDict):
1242
+ connect: str
1243
+ python: ServerDetailsPython
1244
+
1245
+
1246
+ class RSConnectExecutor:
1247
+ def __init__(
1248
+ self,
1249
+ ctx: Optional[click.Context] = None,
1250
+ name: Optional[str] = None,
1251
+ url: Optional[str] = None,
1252
+ api_key: Optional[str] = None,
1253
+ snowflake_connection_name: Optional[str] = None,
1254
+ insecure: bool = False,
1255
+ cacert: Optional[str] = None,
1256
+ ca_data: Optional[str | bytes] = None,
1257
+ cookies: Optional[CookieJar] = None,
1258
+ account: Optional[str] = None,
1259
+ token: Optional[str] = None,
1260
+ secret: Optional[str] = None,
1261
+ timeout: int = 30,
1262
+ logger: Optional[logging.Logger] = console_logger,
1263
+ *,
1264
+ path: Optional[str] = None,
1265
+ server: Optional[str] = None,
1266
+ exclude: Optional[tuple[str, ...]] = None,
1267
+ new: Optional[bool] = None,
1268
+ app_id: Optional[str] = None,
1269
+ title: Optional[str] = None,
1270
+ visibility: Optional[str] = None,
1271
+ disable_env_management: Optional[bool] = None,
1272
+ env_vars: Optional[dict[str, str]] = None,
1273
+ metadata: Optional[dict[str, str]] = None,
1274
+ repository: Optional[str] = None,
1275
+ branch: Optional[str] = None,
1276
+ subdirectory: Optional[str] = None,
1277
+ polling: bool = True,
1278
+ ) -> None:
1279
+ self.remote_server: TargetableServer
1280
+ self.client: RSConnectClient | PositClient
1281
+
1282
+ self.path = path or os.getcwd()
1283
+ self.server = server
1284
+ self.exclude = exclude
1285
+ self.new = new
1286
+ self.app_id = app_id
1287
+ self.title = title or _default_title(self.path)
1288
+ self.visibility = visibility
1289
+ self.disable_env_management = disable_env_management
1290
+ self.env_vars = env_vars
1291
+ self.metadata = metadata
1292
+ self.app_mode: AppMode | None = None
1293
+ self.app_store: AppStore = AppStore(fake_module_file_from_directory(self.path))
1294
+ self.app_store_version: int | None = None
1295
+ self.api_key_is_required: bool | None = None
1296
+ self.title_is_default: bool = not title
1297
+ self.deployment_name: str | None = None
1298
+
1299
+ # Git deployment parameters
1300
+ self.repository: str | None = repository
1301
+ self.branch: str | None = branch
1302
+ self.subdirectory: str | None = subdirectory
1303
+ self.polling: bool = polling
1304
+
1305
+ self.bundle: IO[bytes] | None = None
1306
+ self.deployed_info: RSConnectClientDeployResult | None = None
1307
+ self._draft_deploy_supported: bool | None = None
1308
+
1309
+ self.logger: logging.Logger | None = logger
1310
+ self.ctx = ctx
1311
+ self.setup_remote_server(
1312
+ ctx=ctx,
1313
+ name=name,
1314
+ url=url or server,
1315
+ api_key=api_key,
1316
+ snowflake_connection_name=snowflake_connection_name,
1317
+ insecure=insecure,
1318
+ cacert=cacert,
1319
+ ca_data=ca_data,
1320
+ account_name=account,
1321
+ token=token,
1322
+ secret=secret,
1323
+ )
1324
+ self.setup_client(cookies)
1325
+
1326
+ @classmethod
1327
+ def fromConnectServer(
1328
+ cls,
1329
+ connect_server: RSConnectServer,
1330
+ ctx: Optional[click.Context] = None,
1331
+ cookies: Optional[CookieJar] = None,
1332
+ account: Optional[str] = None,
1333
+ token: Optional[str] = None,
1334
+ secret: Optional[str] = None,
1335
+ timeout: int = 30,
1336
+ logger: Optional[logging.Logger] = console_logger,
1337
+ *,
1338
+ path: Optional[str] = None,
1339
+ server: Optional[str] = None,
1340
+ exclude: Optional[tuple[str, ...]] = None,
1341
+ new: Optional[bool] = None,
1342
+ app_id: Optional[str] = None,
1343
+ title: Optional[str] = None,
1344
+ visibility: Optional[str] = None,
1345
+ disable_env_management: Optional[bool] = None,
1346
+ env_vars: Optional[dict[str, str]] = None,
1347
+ metadata: Optional[dict[str, str]] = None,
1348
+ repository: Optional[str] = None,
1349
+ branch: Optional[str] = None,
1350
+ subdirectory: Optional[str] = None,
1351
+ polling: bool = True,
1352
+ ):
1353
+ return cls(
1354
+ ctx=ctx,
1355
+ url=connect_server.url,
1356
+ api_key=connect_server.api_key,
1357
+ insecure=connect_server.insecure,
1358
+ ca_data=connect_server.ca_data,
1359
+ cookies=cookies,
1360
+ account=account,
1361
+ token=token,
1362
+ secret=secret,
1363
+ timeout=timeout,
1364
+ logger=logger,
1365
+ path=path,
1366
+ server=server,
1367
+ exclude=exclude,
1368
+ new=new,
1369
+ app_id=app_id,
1370
+ title=title,
1371
+ visibility=visibility,
1372
+ disable_env_management=disable_env_management,
1373
+ env_vars=env_vars,
1374
+ metadata=metadata,
1375
+ repository=repository,
1376
+ branch=branch,
1377
+ subdirectory=subdirectory,
1378
+ polling=polling,
1379
+ )
1380
+
1381
+ def output_overlap_header(self, previous: bool) -> bool:
1382
+ if self.logger and not previous:
1383
+ self.logger.warning(
1384
+ "\nConnect detected CLI commands and/or environment variables that overlap with stored credential.\n"
1385
+ )
1386
+ self.logger.warning(
1387
+ "Check your environment variables (e.g. CONNECT_API_KEY) to make sure you want them to be used.\n"
1388
+ )
1389
+ self.logger.warning(
1390
+ "Credential parameters are taken with the following precedence: stored > CLI > environment.\n"
1391
+ )
1392
+ self.logger.warning(
1393
+ "To ignore an environment variable, override it in the CLI with an empty string (e.g. -k '').\n\n"
1394
+ )
1395
+ return True
1396
+ else:
1397
+ return False
1398
+
1399
+ def output_overlap_details(self, cli_param: str, previous: bool):
1400
+ new_previous = self.output_overlap_header(previous)
1401
+ sourceName = validation.get_parameter_source_name_from_ctx(cli_param, self.ctx)
1402
+ if self.logger is not None:
1403
+ self.logger.warning(f">> stored {cli_param} value overrides the {cli_param} value from {sourceName}\n")
1404
+ return new_previous
1405
+
1406
+ def setup_remote_server(
1407
+ self,
1408
+ ctx: Optional[click.Context],
1409
+ name: Optional[str] = None,
1410
+ url: Optional[str] = None,
1411
+ api_key: Optional[str] = None,
1412
+ snowflake_connection_name: Optional[str] = None,
1413
+ insecure: bool = False,
1414
+ cacert: Optional[str] = None,
1415
+ ca_data: Optional[str | bytes] = None,
1416
+ account_name: Optional[str] = None,
1417
+ token: Optional[str] = None,
1418
+ secret: Optional[str] = None,
1419
+ ):
1420
+ store = ServerStore()
1421
+ validation.validate_connection_options(
1422
+ ctx=ctx,
1423
+ url=url,
1424
+ api_key=api_key,
1425
+ snowflake_connection_name=snowflake_connection_name,
1426
+ insecure=insecure,
1427
+ cacert=cacert,
1428
+ account_name=account_name,
1429
+ token=token,
1430
+ secret=secret,
1431
+ name=name,
1432
+ has_default_server=store.get_default() is not None,
1433
+ )
1434
+ # The validation.validate_connection_options() function ensures that certain
1435
+ # combinations of arguments are present; the cast() calls inside of the
1436
+ # if-statements below merely reflect these validations.
1437
+ header_output = False
1438
+
1439
+ if cacert and not ca_data:
1440
+ ca_data = read_certificate_file(cacert)
1441
+
1442
+ # Skip default-server resolution when shinyapps credentials are explicitly
1443
+ # provided — the user is targeting shinyapps.io, not a stored Connect server.
1444
+ if token and secret and account_name and not name and not url:
1445
+ server_data = ServerData(None, None, False)
1446
+ else:
1447
+ server_data = store.resolve(name, url)
1448
+ if server_data.from_store:
1449
+ url = server_data.url
1450
+ if self.logger:
1451
+ if server_data.api_key and api_key:
1452
+ header_output = self.output_overlap_details("api-key", header_output)
1453
+ if server_data.snowflake_connection_name and snowflake_connection_name:
1454
+ header_output = self.output_overlap_details("snowflake_connection_name", header_output)
1455
+ if server_data.insecure and insecure:
1456
+ header_output = self.output_overlap_details("insecure", header_output)
1457
+ if server_data.ca_data and ca_data:
1458
+ header_output = self.output_overlap_details("cacert", header_output)
1459
+ if server_data.account_name and account_name:
1460
+ header_output = self.output_overlap_details("account", header_output)
1461
+ if server_data.token and token:
1462
+ header_output = self.output_overlap_details("token", header_output)
1463
+ if server_data.secret and secret:
1464
+ header_output = self.output_overlap_details("secret", header_output)
1465
+ if header_output:
1466
+ self.logger.warning("\n")
1467
+
1468
+ api_key = api_key or server_data.api_key
1469
+ snowflake_connection_name = snowflake_connection_name or server_data.snowflake_connection_name
1470
+ insecure = insecure or server_data.insecure
1471
+ ca_data = ca_data or server_data.ca_data
1472
+ account_name = account_name or server_data.account_name
1473
+ token = token or server_data.token
1474
+ secret = secret or server_data.secret
1475
+
1476
+ self.is_server_from_store = server_data.from_store
1477
+
1478
+ if snowflake_connection_name:
1479
+ url = cast(str, url)
1480
+ self.remote_server = SPCSConnectServer(url, api_key, snowflake_connection_name, insecure, ca_data)
1481
+ elif api_key:
1482
+ url = cast(str, url)
1483
+ self.remote_server = RSConnectServer(url, api_key, insecure, ca_data)
1484
+ elif token and secret:
1485
+ url = cast(str, url)
1486
+ account_name = cast(str, account_name)
1487
+ self.remote_server = ShinyappsServer(url, account_name, token, secret)
1488
+ elif server_data.from_store and server_data.oauth_client_id:
1489
+ url = cast(str, url)
1490
+ from .oauth import keyring_get_tokens
1491
+
1492
+ access_token, _ = keyring_get_tokens(url)
1493
+ oauth_access_token = access_token or server_data.oauth_access_token
1494
+ self.remote_server = RSConnectServer(
1495
+ url,
1496
+ None,
1497
+ insecure,
1498
+ ca_data,
1499
+ oauth_access_token=oauth_access_token,
1500
+ oauth_client_id=server_data.oauth_client_id,
1501
+ server_name=name or server_data.name,
1502
+ )
1503
+ else:
1504
+ raise RSConnectException("Unable to infer Connect server type and setup server.")
1505
+
1506
+ def setup_client(self, cookies: Optional[CookieJar] = None):
1507
+ if isinstance(self.remote_server, RSConnectServer):
1508
+ self.client = RSConnectClient(self.remote_server, cookies)
1509
+ elif isinstance(self.remote_server, SPCSConnectServer):
1510
+ self.client = RSConnectClient(self.remote_server)
1511
+ elif isinstance(self.remote_server, PositServer):
1512
+ self.client = PositClient(self.remote_server)
1513
+ else:
1514
+ raise RSConnectException("Unable to infer Connect client.")
1515
+
1516
+ def pipe(self, func: Callable[P, T], *args: P.args, **kwargs: P.kwargs):
1517
+ return func(*args, **kwargs)
1518
+
1519
+ @cls_logged("Validating server...")
1520
+ def validate_server(self):
1521
+ """
1522
+ Validate that there is enough information to talk to shinyapps.io or a Connect server.
1523
+ """
1524
+ if isinstance(self.remote_server, SPCSConnectServer):
1525
+ self.validate_spcs_server()
1526
+ elif isinstance(self.remote_server, RSConnectServer):
1527
+ self.validate_connect_server()
1528
+
1529
+ elif isinstance(self.remote_server, PositServer):
1530
+ self.validate_posit_server()
1531
+ else:
1532
+ raise RSConnectException("Unable to validate server from information provided.")
1533
+
1534
+ return self
1535
+
1536
+ def validate_connect_server(self):
1537
+ if not isinstance(self.remote_server, RSConnectServer):
1538
+ raise RSConnectException("remote_server must be a Connect server.")
1539
+ url = self.remote_server.url
1540
+ api_key = self.remote_server.api_key
1541
+ insecure = self.remote_server.insecure
1542
+ api_key_is_required = self.api_key_is_required
1543
+ ca_data = self.remote_server.ca_data
1544
+
1545
+ server_data = ServerStore().resolve(None, url)
1546
+ connect_server = RSConnectServer(url, None, insecure, ca_data)
1547
+
1548
+ # If our info came from the command line, make sure the URL really works.
1549
+ if not server_data.from_store:
1550
+ self.server_settings()
1551
+
1552
+ connect_server.api_key = api_key
1553
+
1554
+ if not connect_server.api_key:
1555
+ if api_key_is_required:
1556
+ raise RSConnectException('An API key must be specified for "%s".' % connect_server.url)
1557
+ return self
1558
+
1559
+ # If our info came from the command line, make sure the key really works.
1560
+ if not server_data.from_store:
1561
+ self.verify_api_key(connect_server)
1562
+
1563
+ self.remote_server = connect_server
1564
+ self.client = RSConnectClient(self.remote_server)
1565
+
1566
+ return self
1567
+
1568
+ def validate_spcs_server(self):
1569
+ if not isinstance(self.remote_server, SPCSConnectServer):
1570
+ raise RSConnectException("remote_server must be a Connect server in SPCS")
1571
+
1572
+ url = self.remote_server.url
1573
+ api_key = self.remote_server.api_key
1574
+ snowflake_connection_name = self.remote_server.snowflake_connection_name
1575
+ server = SPCSConnectServer(url, api_key, snowflake_connection_name)
1576
+
1577
+ with RSConnectClient(server) as client:
1578
+ try:
1579
+ result = client.me()
1580
+ result = server.handle_bad_response(result)
1581
+ except RSConnectException as exc:
1582
+ raise RSConnectException(f"Failed to verify with {server.remote_name} ({exc})")
1583
+
1584
+ return self
1585
+
1586
+ def validate_posit_server(self):
1587
+ if not isinstance(self.remote_server, PositServer):
1588
+ raise RSConnectException("remote_server is not a Posit server.")
1589
+
1590
+ remote_server: PositServer = self.remote_server
1591
+ url = remote_server.url
1592
+ account_name = remote_server.account_name
1593
+ token = remote_server.token
1594
+ secret = remote_server.secret
1595
+ server = ShinyappsServer(url, account_name, token, secret)
1596
+
1597
+ with PositClient(server) as client:
1598
+ try:
1599
+ result = client.get_current_user()
1600
+ result = server.handle_bad_response(result)
1601
+ except RSConnectException as exc:
1602
+ raise RSConnectException("Failed to verify with {} ({}).".format(server.remote_name, exc))
1603
+
1604
+ @cls_logged("Making bundle ...")
1605
+ def make_bundle(
1606
+ self,
1607
+ func: Callable[P, IO[bytes]],
1608
+ *args: P.args,
1609
+ **kwargs: P.kwargs,
1610
+ # These are the actual kwargs that appear to be present in practice
1611
+ # image: Optional[str] = None,
1612
+ # env_management_py: Optional[bool] = None,
1613
+ # env_management_r: Optional[bool] = None,
1614
+ # multi_notebook: Optional[bool] = None,
1615
+ ):
1616
+ force_unique_name = self.app_id is None
1617
+ self.deployment_name = self.make_deployment_name(self.title, force_unique_name)
1618
+
1619
+ try:
1620
+ self.bundle = func(*args, **kwargs)
1621
+ except IOError as error:
1622
+ msg = "Unable to include the file %s in the bundle: %s" % (
1623
+ error.filename,
1624
+ error.args[1],
1625
+ )
1626
+ raise RSConnectException(msg)
1627
+
1628
+ return self
1629
+
1630
+ def upload_posit_bundle(self, prepare_deploy_result: PrepareDeployResult, bundle_size: int, contents: bytes):
1631
+ upload_url = prepare_deploy_result.presigned_url
1632
+ parsed_upload_url = urlparse(upload_url)
1633
+ with S3Client(f"{parsed_upload_url.scheme}://{parsed_upload_url.netloc}") as s3_client:
1634
+ upload_result = cast(
1635
+ HTTPResponse,
1636
+ s3_client.upload(
1637
+ f"{parsed_upload_url.path}?{parsed_upload_url.query}",
1638
+ prepare_deploy_result.presigned_checksum,
1639
+ bundle_size,
1640
+ contents,
1641
+ ),
1642
+ )
1643
+ upload_result = S3Server(upload_url).handle_bad_response(upload_result, is_httpresponse=True)
1644
+
1645
+ @cls_logged("Deploying bundle ...")
1646
+ def deploy_bundle(self, activate: bool = True):
1647
+ if self.deployment_name is None:
1648
+ raise RSConnectException("A deployment name must be created before deploying a bundle.")
1649
+ if self.bundle is None:
1650
+ raise RSConnectException("A bundle must be created before deploying it.")
1651
+
1652
+ if isinstance(self.remote_server, (RSConnectServer, SPCSConnectServer)):
1653
+ if not isinstance(self.client, RSConnectClient):
1654
+ raise RSConnectException("client must be an RSConnectClient.")
1655
+ result = self.client.deploy(
1656
+ self.app_id,
1657
+ self.deployment_name,
1658
+ self.title,
1659
+ self.title_is_default,
1660
+ self.bundle,
1661
+ self.env_vars,
1662
+ activate=activate,
1663
+ metadata=self.metadata,
1664
+ )
1665
+ self.deployed_info = result
1666
+ return self
1667
+ else:
1668
+ contents = self.bundle.read()
1669
+ bundle_size = len(contents)
1670
+ bundle_hash = hashlib.md5(contents).hexdigest()
1671
+
1672
+ if not isinstance(self.client, PositClient):
1673
+ raise RSConnectException("client must be a PositClient.")
1674
+
1675
+ shinyapps_service = ShinyappsService(self.client, self.remote_server)
1676
+ prepare_deploy_result = shinyapps_service.prepare_deploy(
1677
+ self.app_id,
1678
+ self.deployment_name,
1679
+ bundle_size,
1680
+ bundle_hash,
1681
+ self.visibility,
1682
+ )
1683
+ self.upload_posit_bundle(prepare_deploy_result, bundle_size, contents)
1684
+ # type: ignore[arg-type] - PrepareDeployResult uses int, but format() accepts it
1685
+ shinyapps_service.do_deploy(prepare_deploy_result.bundle_id, prepare_deploy_result.app_id)
1686
+
1687
+ print(f"Application successfully deployed to {prepare_deploy_result.app_url}")
1688
+ webbrowser.open_new(prepare_deploy_result.app_url)
1689
+
1690
+ self.deployed_info = RSConnectClientDeployResult(
1691
+ app_url=prepare_deploy_result.app_url,
1692
+ app_id=str(prepare_deploy_result.app_id),
1693
+ app_guid=None,
1694
+ task_id=None,
1695
+ draft_url=None,
1696
+ bundle_id=None,
1697
+ title=self.title,
1698
+ )
1699
+ return self
1700
+
1701
+ @cls_logged("Creating git-backed deployment ...")
1702
+ def deploy_git(self, activate: bool = True):
1703
+ """Deploy content from a remote git repository.
1704
+
1705
+ Creates a git-backed content item in Posit Connect. Connect will clone
1706
+ the repository and regularly poll it for updates.
1707
+ """
1708
+ if not isinstance(self.client, RSConnectClient):
1709
+ raise RSConnectException(
1710
+ "Git deployment is only supported for Posit Connect servers, not shinyapps.io or Posit Cloud."
1711
+ )
1712
+
1713
+ if not self.repository:
1714
+ raise RSConnectException("Repository URL is required for git deployment.")
1715
+
1716
+ # Generate a valid deployment name from the title
1717
+ # This sanitizes characters like "/" that aren't allowed in names
1718
+ force_unique_name = self.app_id is None
1719
+ deployment_name = self.make_deployment_name(self.title, force_unique_name)
1720
+
1721
+ result = self.client.deploy_git(
1722
+ app_id=self.app_id,
1723
+ name=deployment_name,
1724
+ repository=self.repository,
1725
+ branch=self.branch or "main",
1726
+ subdirectory=self.subdirectory or "",
1727
+ title=self.title,
1728
+ env_vars=self.env_vars,
1729
+ polling=self.polling,
1730
+ activate=activate,
1731
+ )
1732
+
1733
+ self.deployed_info = result
1734
+ return self
1735
+
1736
+ def emit_task_log(
1737
+ self,
1738
+ log_callback: logging.Logger = connect_logger,
1739
+ abort_func: Callable[[], bool] = lambda: False,
1740
+ timeout: int = get_task_timeout(),
1741
+ poll_wait: int = 1,
1742
+ raise_on_error: bool = True,
1743
+ ):
1744
+ """
1745
+ Helper for spooling the deployment log for an app.
1746
+
1747
+ :param app_id: the ID of the app that was deployed.
1748
+ :param task_id: the ID of the task that is tracking the deployment of the app..
1749
+ :param log_callback: the callback to use to write the log to. If this is None
1750
+ (the default) the lines from the deployment log will be returned as a sequence.
1751
+ If a log callback is provided, then None will be returned for the log lines part
1752
+ of the return tuple.
1753
+ :param timeout: an optional timeout for the wait operation.
1754
+ :param poll_wait: how long to wait between polls of the task api for status/logs
1755
+ :param raise_on_error: whether to raise an exception when a task is failed, otherwise we
1756
+ return the task_result so we can record the exit code.
1757
+ """
1758
+ if isinstance(self.remote_server, (RSConnectServer, SPCSConnectServer)):
1759
+ if not isinstance(self.client, RSConnectClient):
1760
+ raise RSConnectException("To emit task log, client must be a RSConnectClient.")
1761
+
1762
+ log_lines, _ = self.client.wait_for_task(
1763
+ self.deployed_info["task_id"],
1764
+ log_callback.info,
1765
+ abort_func,
1766
+ timeout,
1767
+ poll_wait,
1768
+ raise_on_error,
1769
+ )
1770
+ log_lines = self.remote_server.handle_bad_response(log_lines)
1771
+
1772
+ log_callback.info("Deployment completed successfully.")
1773
+ if self.deployed_info.get("draft_url"):
1774
+ log_callback.info("\t Draft content URL: %s", self.deployed_info["draft_url"])
1775
+ else:
1776
+ log_callback.info("\t Dashboard content URL: %s", self.deployed_info["dashboard_url"])
1777
+ log_callback.info("\t Direct content URL: %s", self.deployed_info["app_url"])
1778
+
1779
+ return self
1780
+
1781
+ @cls_logged("Saving deployed information...")
1782
+ def save_deployed_info(self):
1783
+ app_store = self.app_store
1784
+ path = self.path
1785
+ deployed_info = self.deployed_info
1786
+
1787
+ app_store.set(
1788
+ self.remote_server.url,
1789
+ abspath(path),
1790
+ deployed_info["app_url"],
1791
+ deployed_info["app_id"],
1792
+ deployed_info["app_guid"],
1793
+ deployed_info["title"],
1794
+ self.app_mode,
1795
+ )
1796
+
1797
+ return self
1798
+
1799
+ @property
1800
+ def supports_verify_before_activate(self) -> bool:
1801
+ """Whether the target server supports deploying a bundle as a draft and
1802
+ activating it separately. shinyapps.io / Posit Cloud and pre-2025.06.0 Connect
1803
+ do not, so for those we deploy and activate in one step and verify the active
1804
+ content instead."""
1805
+ if not isinstance(self.client, RSConnectClient):
1806
+ return False
1807
+ if self._draft_deploy_supported is None:
1808
+ try:
1809
+ server_version = self.client.server_version()
1810
+ except Exception:
1811
+ server_version = None
1812
+ self._draft_deploy_supported = server_supports_draft_deploy(server_version)
1813
+ return self._draft_deploy_supported
1814
+
1815
+ def should_deploy_as_draft(self, draft: bool, no_verify: bool) -> bool:
1816
+ """Whether the bundle should be deployed without activating it.
1817
+
1818
+ An explicit ``--draft`` always deploys a draft. Otherwise we deploy a draft only
1819
+ when we are going to verify it before activating, which requires server support.
1820
+ With ``--no-verify`` we activate immediately.
1821
+ """
1822
+ if draft:
1823
+ if not self.supports_verify_before_activate:
1824
+ # We can't honor --draft without the activate field: silently activating
1825
+ # would be the opposite of what the user asked for, so fail loudly.
1826
+ raise RSConnectException("Deploying as a draft requires Posit Connect 2025.06.0 or later.")
1827
+ return True
1828
+ if no_verify:
1829
+ return False
1830
+ return self.supports_verify_before_activate
1831
+
1832
+ @cls_logged("Verifying deployed content...")
1833
+ def verify_deployment(self):
1834
+ if isinstance(self.remote_server, (RSConnectServer, SPCSConnectServer)):
1835
+ if not isinstance(self.client, RSConnectClient):
1836
+ raise RSConnectException("To verify deployment, client must be a RSConnectClient.")
1837
+ deployed_info = self.deployed_info
1838
+ app_guid = deployed_info["app_guid"]
1839
+ # If the bundle was deployed as a draft (not activated), verify the draft
1840
+ # bundle's preview URL rather than the currently-active content. Otherwise a
1841
+ # broken draft would be masked by a previously-working active bundle.
1842
+ bundle_id = deployed_info.get("bundle_id") if deployed_info.get("draft_url") else None
1843
+ self.client.access_content(app_guid, bundle_id=bundle_id)
1844
+ return self
1845
+
1846
+ @cls_logged("Activating deployed content...")
1847
+ def activate_deployment(self):
1848
+ """Activate the bundle deployed as a draft, e.g. after verifying it runs.
1849
+
1850
+ This re-issues the deploy request for the same bundle with ``activate=True``,
1851
+ which is what the "Activate Draft" button in the Connect UI does.
1852
+ """
1853
+ if isinstance(self.remote_server, (RSConnectServer, SPCSConnectServer)):
1854
+ if not isinstance(self.client, RSConnectClient):
1855
+ raise RSConnectException("To activate deployment, client must be a RSConnectClient.")
1856
+ deployed_info = self.deployed_info
1857
+ app_guid = deployed_info["app_guid"]
1858
+ bundle_id = deployed_info["bundle_id"]
1859
+ if app_guid is None or bundle_id is None:
1860
+ raise RSConnectException("An app GUID and bundle ID are required to activate a deployment.")
1861
+ task = self.client.content_deploy(app_guid, bundle_id, activate=True)
1862
+ # Update deployed_info so a subsequent emit_task_log() waits on the activation
1863
+ # task and reports the live content URLs instead of the draft URL.
1864
+ deployed_info["task_id"] = task["task_id"]
1865
+ deployed_info["draft_url"] = None
1866
+ return self
1867
+
1868
+ @cls_logged("Validating app mode...")
1869
+ def validate_app_mode(self, app_mode: AppMode):
1870
+ path = self.path
1871
+ app_store = self.app_store
1872
+ if not app_store:
1873
+ module_file = fake_module_file_from_directory(path)
1874
+ self.app_store = app_store = AppStore(module_file)
1875
+ new = self.new
1876
+ app_id = self.app_id
1877
+ app_mode = app_mode or self.app_mode
1878
+
1879
+ if new and app_id:
1880
+ raise RSConnectException("Specify either a new deploy or an app ID but not both.")
1881
+
1882
+ existing_app_mode = None
1883
+ app_store_version = 0
1884
+ if not new:
1885
+ if app_id is None:
1886
+ # Possible redeployment - check for saved metadata.
1887
+ # Use the saved app information unless overridden by the user.
1888
+ app_id, existing_app_mode, app_store_version = app_store.resolve(
1889
+ self.remote_server.url, app_id, app_mode
1890
+ )
1891
+ self.app_store_version = app_store_version
1892
+
1893
+ logger.debug("Using app mode from app %s: %s" % (app_id, app_mode))
1894
+ elif app_id is not None:
1895
+ # Don't read app metadata if app-id is specified. Instead, we need
1896
+ # to get this from the remote.
1897
+ if isinstance(self.remote_server, RSConnectServer):
1898
+ try:
1899
+ with RSConnectClient(self.remote_server) as client:
1900
+ content = client.get_content_by_id(app_id)
1901
+ existing_app_mode = AppModes.get_by_ordinal(content["app_mode"], True)
1902
+ except RSConnectException as e:
1903
+ raise RSConnectException(
1904
+ f"{e} Try setting the --new flag to overwrite the previous deployment."
1905
+ ) from e
1906
+ elif isinstance(self.remote_server, PositServer):
1907
+ try:
1908
+ app = get_posit_app_info(self.remote_server, app_id)
1909
+ existing_app_mode = AppModes.get_by_cloud_name(app["mode"])
1910
+ except RSConnectException as e:
1911
+ raise RSConnectException(
1912
+ f"{e} Try setting the --new flag to overwrite the previous deployment."
1913
+ ) from e
1914
+ else:
1915
+ raise RSConnectException("Unable to infer Connect client.")
1916
+ if existing_app_mode and existing_app_mode not in (None, AppModes.UNKNOWN, app_mode):
1917
+ msg = (
1918
+ "Deploying with mode '%s',\n"
1919
+ + "but the existing deployment has mode '%s'.\n"
1920
+ + "Use the --new option to create a new deployment of the desired type."
1921
+ ) % (app_mode.desc(), existing_app_mode.desc())
1922
+ raise RSConnectException(msg)
1923
+
1924
+ self.app_id = app_id
1925
+ self.app_mode = app_mode
1926
+ self.app_store_version = app_store_version
1927
+ return self
1928
+
1929
+ def server_settings(self):
1930
+ try:
1931
+ if not isinstance(self.client, RSConnectClient):
1932
+ raise RSConnectException("To get server settings, client must be a RSConnectClient.")
1933
+ result = self.client.server_settings()
1934
+ except SSLError as ssl_error:
1935
+ raise RSConnectException("There is an SSL/TLS configuration problem: %s" % ssl_error)
1936
+ return result
1937
+
1938
+ def verify_api_key(self, server: Optional[RSConnectServer] = None):
1939
+ """
1940
+ Verify that an API Key may be used to authenticate with the given Posit Connect server.
1941
+ """
1942
+ if not server:
1943
+ server = self.remote_server
1944
+ if isinstance(server, ShinyappsServer):
1945
+ raise RSConnectException("Shinnyapps server does not use an API key.")
1946
+ with RSConnectClient(server) as client:
1947
+ verify_api_key_response(client)
1948
+ return self
1949
+
1950
+ @property
1951
+ def api_username(self) -> str:
1952
+ if not isinstance(self.client, RSConnectClient):
1953
+ raise RSConnectException("To get server settings, client must be a RSConnectClient.")
1954
+ result = self.client.me()
1955
+ return result["username"]
1956
+
1957
+ @property
1958
+ def python_info(self):
1959
+ """
1960
+ Return information about versions of Python that are installed on the indicated
1961
+ Connect server.
1962
+
1963
+ :return: the Python installation information from Connect.
1964
+ """
1965
+ if not isinstance(self.client, RSConnectClient):
1966
+ raise RSConnectException("To get Python info, client must be a RSConnectClient.")
1967
+ result = self.client.python_settings()
1968
+ return result
1969
+
1970
+ def server_details(self) -> ServerDetails:
1971
+ """
1972
+ Builds a dictionary containing the version of Posit Connect that is running
1973
+ and the versions of Python installed there.
1974
+
1975
+ :return: a two-entry dictionary. The key 'connect' will refer to the version
1976
+ of Connect that was found. The key `python` will refer to a sequence of version
1977
+ strings for all the versions of Python that are installed.
1978
+ """
1979
+
1980
+ def _to_sort_key(text: str):
1981
+ parts = [part.zfill(5) for part in text.split(".")]
1982
+ return "".join(parts)
1983
+
1984
+ server_settings = self.server_settings()
1985
+ python_settings = self.python_info
1986
+ python_versions = sorted([item["version"] for item in python_settings["installations"]], key=_to_sort_key)
1987
+ return {
1988
+ "connect": server_settings["version"],
1989
+ "python": {
1990
+ "api_enabled": python_settings["api_enabled"] if "api_enabled" in python_settings else False,
1991
+ "versions": python_versions,
1992
+ },
1993
+ }
1994
+
1995
+ def make_deployment_name(self, title: str, force_unique: bool) -> str:
1996
+ """
1997
+ Produce a name for a deployment based on its title. It is assumed that the
1998
+ title is already defaulted and validated as appropriate (meaning the title
1999
+ isn't None or empty).
2000
+
2001
+ We follow the same rules for doing this as the R rsconnect package does. See
2002
+ the title.R code in https://github.com/rstudio/rsconnect/R with the exception
2003
+ that we collapse repeating underscores and, if the name is too short, it is
2004
+ padded to the left with underscores.
2005
+
2006
+ :param title: the title to start with.
2007
+ :param force_unique: a flag noting whether the generated name must be forced to be
2008
+ unique.
2009
+ :return: a name for a deployment based on its title.
2010
+ """
2011
+ _name_sub_pattern = re.compile(r"[^A-Za-z0-9_ -]+")
2012
+ _repeating_sub_pattern = re.compile(r"_+")
2013
+
2014
+ # First, Generate a default name from the given title.
2015
+ name = _name_sub_pattern.sub("", title.lower()).replace(" ", "_")
2016
+ name = _repeating_sub_pattern.sub("_", name)[:64].rjust(3, "_")
2017
+
2018
+ # Now, make sure it's unique, if needed.
2019
+ if force_unique:
2020
+ name = find_unique_name(self.remote_server, name)
2021
+
2022
+ return name
2023
+
2024
+ @property
2025
+ def runtime_caches(self) -> list[ListEntryOutputDTO]:
2026
+ if not isinstance(self.client, RSConnectClient):
2027
+ raise RSConnectException("To delete a runtime cache, client must be a RSConnectClient.")
2028
+ return self.client.system_caches_runtime_list()
2029
+
2030
+ def delete_runtime_cache(self, language: str, version: str, image_name: str, dry_run: bool):
2031
+ if not isinstance(self.client, RSConnectClient):
2032
+ raise RSConnectException("To delete a runtime cache, client must be a RSConnectClient.")
2033
+ target: DeleteInputDTO = {
2034
+ "language": language,
2035
+ "version": version,
2036
+ "image_name": image_name,
2037
+ "dry_run": dry_run,
2038
+ }
2039
+ result = self.client.system_caches_runtime_delete(target)
2040
+ self.result = result
2041
+ if result["task_id"] is None:
2042
+ print("Dry run finished")
2043
+ return result, None
2044
+ else:
2045
+ (_, task) = self.client.wait_for_task(result["task_id"], connect_logger.info, raise_on_error=False)
2046
+ return result, task
2047
+
2048
+
2049
+ class S3Client(HTTPServer):
2050
+ def upload(self, path: str, presigned_checksum: str, bundle_size: int, contents: bytes):
2051
+ headers = {
2052
+ "content-type": "application/x-tar",
2053
+ "content-length": str(bundle_size),
2054
+ "content-md5": presigned_checksum,
2055
+ }
2056
+ return self.put(path, headers=headers, body=contents, decode_response=False)
2057
+
2058
+
2059
+ class PrepareDeployResult:
2060
+ def __init__(
2061
+ self,
2062
+ app_id: int,
2063
+ app_url: str,
2064
+ bundle_id: int,
2065
+ presigned_url: str,
2066
+ presigned_checksum: str,
2067
+ ):
2068
+ self.app_id = app_id
2069
+ self.app_url = app_url
2070
+ self.bundle_id = bundle_id
2071
+ self.presigned_url = presigned_url
2072
+ self.presigned_checksum = presigned_checksum
2073
+
2074
+
2075
+ class PrepareDeployOutputResult(PrepareDeployResult):
2076
+ def __init__(
2077
+ self,
2078
+ app_id: int,
2079
+ app_url: str,
2080
+ bundle_id: int,
2081
+ presigned_url: str,
2082
+ presigned_checksum: str,
2083
+ application_id: int,
2084
+ ):
2085
+ super().__init__(
2086
+ app_id=app_id,
2087
+ app_url=app_url,
2088
+ bundle_id=bundle_id,
2089
+ presigned_url=presigned_url,
2090
+ presigned_checksum=presigned_checksum,
2091
+ )
2092
+ self.application_id = application_id
2093
+
2094
+
2095
+ # Placeholder types
2096
+ # NOTE: These were inferred from the existing code, but they should be updated with
2097
+ # the actual types from the Posit API.
2098
+ class PositClientDeployTask(TypedDict):
2099
+ id: str
2100
+ finished: bool
2101
+ status: str
2102
+ description: str
2103
+ error: str
2104
+
2105
+
2106
+ class PositClientApp(TypedDict):
2107
+ id: int
2108
+ name: str
2109
+ url: str
2110
+ deployment: dict[str, Any]
2111
+ content_id: str
2112
+
2113
+
2114
+ class PositClientAppSearchResults(TypedDict):
2115
+ applications: list[PositClientApp]
2116
+ count: int
2117
+ total: str
2118
+
2119
+
2120
+ class PositClientAccountSearchResults(TypedDict):
2121
+ accounts: list[PositClientAccount]
2122
+
2123
+
2124
+ class PositClientAccount(TypedDict):
2125
+ id: int
2126
+ name: str
2127
+
2128
+
2129
+ class PositClientBundle(TypedDict):
2130
+ id: str
2131
+ presigned_url: str
2132
+ presigned_checksum: str
2133
+
2134
+
2135
+ class PositClientShinyappsBuildTask(TypedDict):
2136
+ id: str
2137
+
2138
+
2139
+ class PositClientShinyappsBuildTaskSearchResults(TypedDict):
2140
+ tasks: list[PositClientShinyappsBuildTask]
2141
+
2142
+
2143
+ class PositClient(HTTPServer):
2144
+ """
2145
+ An HTTP client to call the shinyapps.io API.
2146
+ """
2147
+
2148
+ _TERMINAL_STATUSES = {"success", "failed", "error"}
2149
+
2150
+ def __init__(self, posit_server: PositServer):
2151
+ self._token = posit_server.token
2152
+ try:
2153
+ self._key = base64.b64decode(posit_server.secret)
2154
+ except binascii.Error as e:
2155
+ raise RSConnectException("Invalid secret.") from e
2156
+ self._server = posit_server
2157
+ super().__init__(posit_server.url)
2158
+
2159
+ def _get_canonical_request(self, method: str, path: str, timestamp: str, content_hash: str):
2160
+ return "\n".join([method, path, timestamp, content_hash])
2161
+
2162
+ def _get_canonical_request_signature(self, request: str):
2163
+ result = hmac.new(self._key, request.encode(), hashlib.sha256).hexdigest()
2164
+ return base64.b64encode(result.encode()).decode()
2165
+
2166
+ def _tweak_response(self, response: HTTPResponse) -> JsonData | HTTPResponse:
2167
+ return (
2168
+ response.json_data
2169
+ if (
2170
+ response.status and response.status >= 200 and response.status <= 299 and response.json_data is not None
2171
+ )
2172
+ else response
2173
+ )
2174
+
2175
+ def get_extra_headers(self, url: str, method: str, body: str | bytes):
2176
+ canonical_request_method = method.upper()
2177
+ canonical_request_path = parse.urlparse(url).path
2178
+ canonical_request_date = datetime.datetime.now(datetime.timezone.utc).strftime("%a, %d %b %Y %H:%M:%S GMT")
2179
+
2180
+ # get request checksum
2181
+ md5 = hashlib.md5()
2182
+ body = body or b""
2183
+ body_bytes = body if isinstance(body, bytes) else body.encode()
2184
+ md5.update(body_bytes)
2185
+ canonical_request_checksum = md5.hexdigest()
2186
+
2187
+ canonical_request = self._get_canonical_request(
2188
+ canonical_request_method, canonical_request_path, canonical_request_date, canonical_request_checksum
2189
+ )
2190
+
2191
+ signature = self._get_canonical_request_signature(canonical_request)
2192
+
2193
+ return {
2194
+ "X-Auth-Token": self._token,
2195
+ "X-Auth-Signature": f"{signature}; version=1",
2196
+ "Date": canonical_request_date,
2197
+ "X-Content-Checksum": canonical_request_checksum,
2198
+ }
2199
+
2200
+ def get_application(self, application_id: str):
2201
+ response = cast(Union[PositClientApp, HTTPResponse], self.get(f"/v1/applications/{application_id}"))
2202
+ response = self._server.handle_bad_response(response)
2203
+ return response
2204
+
2205
+ def update_application_property(self, application_id: int, property: str, value: str) -> HTTPResponse:
2206
+ response = cast(
2207
+ HTTPResponse,
2208
+ self.put(f"/v1/applications/{application_id}/properties/{property}", body={"value": value}),
2209
+ )
2210
+ response = self._server.handle_bad_response(response, is_httpresponse=True)
2211
+ return response
2212
+
2213
+ def create_application(self, account_id: int, application_name: str) -> PositClientApp:
2214
+ application_data = {
2215
+ "account": account_id,
2216
+ "name": application_name,
2217
+ "template": "shiny",
2218
+ }
2219
+ response = cast(Union[PositClientApp, HTTPResponse], self.post("/v1/applications/", body=application_data))
2220
+ response = self._server.handle_bad_response(response)
2221
+ return response
2222
+
2223
+ def get_accounts(self) -> PositClientAccountSearchResults:
2224
+ response = cast(Union[PositClientAccountSearchResults, HTTPResponse], self.get("/v1/accounts/"))
2225
+ response = self._server.handle_bad_response(response)
2226
+ return response
2227
+
2228
+ def _get_applications_like_name_page(self, name: str, offset: int) -> PositClientAppSearchResults:
2229
+ response = cast(
2230
+ Union[PositClientAppSearchResults, HTTPResponse],
2231
+ self.get(f"/v1/applications?filter=name:like:{name}&offset={offset}&count=100&use_advanced_filters=true"),
2232
+ )
2233
+ response = self._server.handle_bad_response(response)
2234
+ return response
2235
+
2236
+ def create_bundle(
2237
+ self, application_id: int, content_type: str, content_length: int, checksum: str
2238
+ ) -> PositClientBundle:
2239
+ bundle_data = {
2240
+ "application": application_id,
2241
+ "content_type": content_type,
2242
+ "content_length": content_length,
2243
+ "checksum": checksum,
2244
+ }
2245
+ response = cast(Union[PositClientBundle, HTTPResponse], self.post("/v1/bundles", body=bundle_data))
2246
+ response = self._server.handle_bad_response(response)
2247
+ return response
2248
+
2249
+ def set_bundle_status(self, bundle_id: str, bundle_status: str):
2250
+ response = self.post(f"/v1/bundles/{bundle_id}/status", body={"status": bundle_status})
2251
+ response = self._server.handle_bad_response(response)
2252
+ return response
2253
+
2254
+ def deploy_application(self, bundle_id: str, app_id: str) -> PositClientDeployTask:
2255
+ response = cast(
2256
+ Union[PositClientDeployTask, HTTPResponse],
2257
+ self.post(f"/v1/applications/{app_id}/deploy", body={"bundle": bundle_id, "rebuild": False}),
2258
+ )
2259
+ response = self._server.handle_bad_response(response)
2260
+ return response
2261
+
2262
+ def get_task(self, task_id: str) -> PositClientDeployTask:
2263
+ response = cast(
2264
+ Union[PositClientDeployTask, HTTPResponse],
2265
+ self.get(f"/v1/tasks/{task_id}", query_params={"legacy": "true"}),
2266
+ )
2267
+ response = self._server.handle_bad_response(response)
2268
+ return response
2269
+
2270
+ def get_shinyapps_build_task(self, parent_task_id: str) -> PositClientShinyappsBuildTaskSearchResults:
2271
+ response = cast(
2272
+ Union[PositClientShinyappsBuildTaskSearchResults, HTTPResponse],
2273
+ self.get(
2274
+ "/v1/tasks",
2275
+ query_params={
2276
+ "filter": [
2277
+ f"parent_id:eq:{parent_task_id}",
2278
+ "action:eq:image-build",
2279
+ ]
2280
+ },
2281
+ ),
2282
+ )
2283
+ response = self._server.handle_bad_response(response)
2284
+ return response
2285
+
2286
+ def get_task_logs(self, task_id: str) -> HTTPResponse:
2287
+ response = cast(HTTPResponse, self.get(f"/v1/tasks/{task_id}/logs"))
2288
+ response = self._server.handle_bad_response(response, is_httpresponse=True)
2289
+ return response
2290
+
2291
+ def get_current_user(self):
2292
+ response = self.get("/v1/users/me")
2293
+ response = self._server.handle_bad_response(response)
2294
+ return response
2295
+
2296
+ def wait_until_task_is_successful(self, task_id: str, timeout: int = get_task_timeout()) -> None:
2297
+ print()
2298
+ print(f"Waiting for task: {task_id}")
2299
+
2300
+ start_time = time.time()
2301
+ finished: bool | None = None
2302
+ status: str | None = None
2303
+ error: str | None = None
2304
+ description: str | None = None
2305
+
2306
+ while time.time() - start_time < timeout:
2307
+ task = self.get_task(task_id)
2308
+ finished = task["finished"]
2309
+ status = task["status"]
2310
+ description = task["description"]
2311
+ error = task["error"]
2312
+
2313
+ if finished:
2314
+ break
2315
+
2316
+ print(f" {status} - {description}")
2317
+ time.sleep(2)
2318
+
2319
+ if not finished:
2320
+ raise RSConnectException(get_task_timeout_help_message(timeout))
2321
+
2322
+ if status != "success":
2323
+ raise DeploymentFailedException(f"Application deployment failed with error: {error}")
2324
+
2325
+ print(f"Task done: {description}")
2326
+
2327
+ def get_applications_like_name(self, name: str) -> list[str]:
2328
+ applications: list[PositClientApp] = []
2329
+
2330
+ results = self._get_applications_like_name_page(name, 0)
2331
+ results = self._server.handle_bad_response(results)
2332
+ offset = 0
2333
+
2334
+ while len(applications) < int(results["total"]):
2335
+ results = self._get_applications_like_name_page(name, offset)
2336
+ applications = results["applications"]
2337
+ applications.extend(applications)
2338
+ offset += int(results["count"])
2339
+
2340
+ return [app["name"] for app in applications]
2341
+
2342
+
2343
+ class ShinyappsService:
2344
+ """
2345
+ Encapsulates operations involving multiple API calls to shinyapps.io.
2346
+ """
2347
+
2348
+ def __init__(self, posit_client: PositClient, server: ShinyappsServer):
2349
+ self._posit_client = posit_client
2350
+ self._server = server
2351
+
2352
+ def prepare_deploy(
2353
+ self,
2354
+ app_id: Optional[str],
2355
+ app_name: str,
2356
+ bundle_size: int,
2357
+ bundle_hash: str,
2358
+ visibility: Optional[str],
2359
+ ):
2360
+ accounts = self._posit_client.get_accounts()
2361
+ accounts = self._server.handle_bad_response(accounts)
2362
+ account: PositClientAccount = next(
2363
+ filter(lambda acct: acct["name"] == self._server.account_name, accounts["accounts"]), None
2364
+ )
2365
+ # TODO: also check this during `add` command
2366
+ if account is None:
2367
+ raise RSConnectException(
2368
+ "No account found by name : %s for given user credential" % self._server.account_name
2369
+ )
2370
+
2371
+ if app_id is None:
2372
+ application = self._posit_client.create_application(account["id"], app_name)
2373
+ if visibility is not None:
2374
+ self._posit_client.update_application_property(application["id"], "application.visibility", visibility)
2375
+
2376
+ else:
2377
+ application = self._posit_client.get_application(app_id)
2378
+
2379
+ if visibility is not None:
2380
+ if visibility != application["deployment"]["properties"]["application.visibility"]:
2381
+ self._posit_client.update_application_property(
2382
+ application["id"], "application.visibility", visibility
2383
+ )
2384
+
2385
+ app_id_int = application["id"]
2386
+ app_url = application["url"]
2387
+
2388
+ bundle = self._posit_client.create_bundle(app_id_int, "application/x-tar", bundle_size, bundle_hash)
2389
+
2390
+ return PrepareDeployResult(
2391
+ app_id_int,
2392
+ app_url,
2393
+ int(bundle["id"]),
2394
+ bundle["presigned_url"],
2395
+ bundle["presigned_checksum"],
2396
+ )
2397
+
2398
+ def do_deploy(self, bundle_id: str, app_id: str):
2399
+ self._posit_client.set_bundle_status(bundle_id, "ready")
2400
+ deploy_task = self._posit_client.deploy_application(bundle_id, app_id)
2401
+ try:
2402
+ self._posit_client.wait_until_task_is_successful(deploy_task["id"])
2403
+ except DeploymentFailedException as e:
2404
+ build_task_result = self._posit_client.get_shinyapps_build_task(deploy_task["id"])
2405
+ build_task = build_task_result["tasks"][0]
2406
+ logs = self._posit_client.get_task_logs(build_task["id"])
2407
+ logger.error(f"Build logs:\n{logs.response_body}")
2408
+ raise e
2409
+
2410
+
2411
+ def verify_server(connect_server: RSConnectServer):
2412
+ """
2413
+ Verify that the given server information represents a Connect instance that is
2414
+ reachable, active and appears to be actually running Posit Connect. If the
2415
+ check is successful, the server settings for the Connect server is returned.
2416
+
2417
+ :param connect_server: the Connect server information.
2418
+ :return: the server settings from the Connect server.
2419
+ """
2420
+ warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2)
2421
+ try:
2422
+ with RSConnectClient(connect_server) as client:
2423
+ result = client.server_settings()
2424
+ result = connect_server.handle_bad_response(result)
2425
+ return result
2426
+ except SSLError as ssl_error:
2427
+ raise RSConnectException("There is an SSL/TLS configuration problem: %s" % ssl_error)
2428
+
2429
+
2430
+ def verify_api_key_response(client: RSConnectClient) -> Optional[UserRecord]:
2431
+ """
2432
+ Issue GET v1/user and interpret the response for the purpose of API key verification.
2433
+
2434
+ :param client: a client configured with the credential to verify.
2435
+ :return: the user record on success, or None for a valid credential that has no
2436
+ associated user (a service principal or machine identity, for example one used
2437
+ for trusted publishing).
2438
+ :raises RSConnectException: if the credential is invalid or the request otherwise fails.
2439
+ """
2440
+ # Use the raw response rather than client.me(), which would raise a generic error
2441
+ # and discard the error code we need to distinguish the verification-specific cases
2442
+ # below. Everything else (success, connection errors, other HTTP errors) is left to
2443
+ # the standard handle_bad_response handler.
2444
+ result = client.get("v1/user")
2445
+ if isinstance(result, HTTPResponse) and not result.exception:
2446
+ json_data = result.json_data if isinstance(result.json_data, dict) else {}
2447
+ code = json_data.get("code")
2448
+ # A service principal or machine identity authenticates successfully but has no
2449
+ # associated user, so the v1/user endpoint rejects it with a 403 and error code
2450
+ # 22. That code is unambiguous on this endpoint -- a genuinely invalid credential
2451
+ # is rejected at the auth layer with code 30 instead -- so the credential is valid
2452
+ # and we treat it as verified. This distinction only holds for v1/user, which is
2453
+ # why it lives here rather than in handle_bad_response.
2454
+ if result.status == 403 and code == 22:
2455
+ return None
2456
+ if code == 30:
2457
+ raise RSConnectException("The specified API key is not valid.")
2458
+ return cast(UserRecord, client._server.handle_bad_response(result))
2459
+
2460
+
2461
+ def verify_api_key(connect_server: RSConnectServer) -> str:
2462
+ """
2463
+ Verify that an API Key may be used to authenticate with the given Posit Connect server.
2464
+ If the API key verifies, we return the username of the associated user.
2465
+
2466
+ :param connect_server: the Connect server information, including the API key to test.
2467
+ :return: the username of the user to whom the API key belongs, or an empty string for a
2468
+ valid credential with no associated user (a service principal or machine identity).
2469
+ """
2470
+ warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2)
2471
+ with RSConnectClient(connect_server) as client:
2472
+ user = verify_api_key_response(client)
2473
+ return user["username"] if user else ""
2474
+
2475
+
2476
+ def get_python_info(connect_server: Union[RSConnectServer, SPCSConnectServer]):
2477
+ """
2478
+ Return information about versions of Python that are installed on the indicated
2479
+ Connect server.
2480
+
2481
+ :param connect_server: the Connect server information.
2482
+ :return: the Python installation information from Connect.
2483
+ """
2484
+ warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2)
2485
+ with RSConnectClient(connect_server) as client:
2486
+ result = client.python_settings()
2487
+ return result
2488
+
2489
+
2490
+ def get_posit_app_info(server: PositServer, app_id: str):
2491
+ with PositClient(server) as client:
2492
+ return client.get_application(app_id)
2493
+
2494
+
2495
+ def emit_task_log(
2496
+ connect_server: Union[RSConnectServer, SPCSConnectServer],
2497
+ app_id: str,
2498
+ task_id: str,
2499
+ log_callback: Optional[Callable[[str], None]],
2500
+ abort_func: Callable[[], bool] = lambda: False,
2501
+ timeout: int = get_task_timeout(),
2502
+ poll_wait: int = 1,
2503
+ raise_on_error: bool = True,
2504
+ ):
2505
+ """
2506
+ Helper for spooling the deployment log for an app.
2507
+
2508
+ :param connect_server: the Connect server information.
2509
+ :param app_id: the ID of the app that was deployed.
2510
+ :param task_id: the ID of the task that is tracking the deployment of the app..
2511
+ :param log_callback: the callback to use to write the log to. If this is None
2512
+ (the default) the lines from the deployment log will be returned as a sequence.
2513
+ If a log callback is provided, then None will be returned for the log lines part
2514
+ of the return tuple.
2515
+ :param timeout: an optional timeout for the wait operation.
2516
+ :param poll_wait: how long to wait between polls of the task api for status/logs
2517
+ :param raise_on_error: whether to raise an exception when a task is failed, otherwise we
2518
+ return the task_result so we can record the exit code.
2519
+ :return: the ultimate URL where the deployed app may be accessed and the sequence
2520
+ of log lines. The log lines value will be None if a log callback was provided.
2521
+ """
2522
+ with RSConnectClient(connect_server) as client:
2523
+ result = client.wait_for_task(task_id, log_callback, abort_func, timeout, poll_wait, raise_on_error)
2524
+ result = connect_server.handle_bad_response(result)
2525
+ # Get content (handles both numeric IDs and GUIDs)
2526
+ content = client.get_content_by_id(app_id)
2527
+ app_url = content["dashboard_url"]
2528
+ return (app_url, *result)
2529
+
2530
+
2531
+ class AbbreviatedAppItem(TypedDict):
2532
+ id: int
2533
+ name: str
2534
+ title: str | None
2535
+ app_mode: AppModes.Modes
2536
+ url: str
2537
+ config_url: str
2538
+
2539
+
2540
+ def find_unique_name(remote_server: TargetableServer, name: str):
2541
+ """
2542
+ Poll through existing apps to see if anything with a similar name exists.
2543
+ If so, start appending numbers until a unique name is found.
2544
+
2545
+ :param remote_server: the remote server information.
2546
+ :param name: the default name for an app.
2547
+ :return: the name, potentially with a suffixed number to guarantee uniqueness.
2548
+ """
2549
+ if isinstance(remote_server, (RSConnectServer, SPCSConnectServer)):
2550
+ # Use v1/content API with name query parameter
2551
+ with RSConnectClient(remote_server) as client:
2552
+ results = client.content_list(filters={"name": name})
2553
+
2554
+ # If name exists, append suffix and try again
2555
+ if len(results) > 0:
2556
+ suffix = 1
2557
+ test_name = "%s%d" % (name, suffix)
2558
+ while True:
2559
+ results = client.content_list(filters={"name": test_name})
2560
+ if len(results) == 0:
2561
+ return test_name
2562
+ suffix = suffix + 1
2563
+ test_name = "%s%d" % (name, suffix)
2564
+
2565
+ return name
2566
+
2567
+ elif isinstance(remote_server, ShinyappsServer):
2568
+ client = PositClient(remote_server)
2569
+ existing_names = client.get_applications_like_name(name)
2570
+
2571
+ if name in existing_names:
2572
+ suffix = 1
2573
+ test = "%s%d" % (name, suffix)
2574
+ while test in existing_names:
2575
+ suffix = suffix + 1
2576
+ test = "%s%d" % (name, suffix)
2577
+ name = test
2578
+
2579
+ return name
2580
+ else:
2581
+ # non-unique names are permitted in cloud
2582
+ return name