rsconnect-python 1.30.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rsconnect/__init__.py +13 -0
- rsconnect/actions.py +565 -0
- rsconnect/actions_content.py +508 -0
- rsconnect/actions_environment.py +160 -0
- rsconnect/actions_integration.py +118 -0
- rsconnect/api.py +2582 -0
- rsconnect/bundle.py +2481 -0
- rsconnect/certificates.py +39 -0
- rsconnect/environment.py +390 -0
- rsconnect/environment_node.py +115 -0
- rsconnect/environment_r.py +300 -0
- rsconnect/exception.py +15 -0
- rsconnect/git_metadata.py +180 -0
- rsconnect/http_support.py +595 -0
- rsconnect/json_web_token.py +178 -0
- rsconnect/log.py +253 -0
- rsconnect/main.py +5889 -0
- rsconnect/metadata.py +879 -0
- rsconnect/models.py +835 -0
- rsconnect/oauth.py +623 -0
- rsconnect/py.typed +0 -0
- rsconnect/pyproject.py +283 -0
- rsconnect/quickstart/__init__.py +16 -0
- rsconnect/quickstart/quickstart.py +486 -0
- rsconnect/quickstart/templates/__init__.py +16 -0
- rsconnect/quickstart/templates/api/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/api/__connect__.py.tmpl +3 -0
- rsconnect/quickstart/templates/api/__init__.py.tmpl +1 -0
- rsconnect/quickstart/templates/api/__main__.py.tmpl +14 -0
- rsconnect/quickstart/templates/api/app.py.tmpl +11 -0
- rsconnect/quickstart/templates/api/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/fastapi/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +3 -0
- rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +1 -0
- rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +16 -0
- rsconnect/quickstart/templates/fastapi/app.py.tmpl +11 -0
- rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +14 -0
- rsconnect/quickstart/templates/notebook/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +34 -0
- rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/quarto/README.md.tmpl +19 -0
- rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +11 -0
- rsconnect/quickstart/templates/quarto/report.qmd.tmpl +8 -0
- rsconnect/quickstart/templates/shiny/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/shiny/app.py.tmpl +3 -0
- rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/streamlit/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/streamlit/app.py.tmpl +3 -0
- rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/voila/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +14 -0
- rsconnect/shiny_express.py +136 -0
- rsconnect/snowflake.py +93 -0
- rsconnect/subprocesses/__init__.py +0 -0
- rsconnect/subprocesses/inspect_environment.py +362 -0
- rsconnect/timeouts.py +89 -0
- rsconnect/utils_package.py +261 -0
- rsconnect/validation.py +156 -0
- rsconnect/version_check.py +154 -0
- rsconnect_python-1.30.0.dist-info/METADATA +89 -0
- rsconnect_python-1.30.0.dist-info/RECORD +63 -0
- rsconnect_python-1.30.0.dist-info/WHEEL +4 -0
- rsconnect_python-1.30.0.dist-info/entry_points.txt +3 -0
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
|