rsconnect-python 1.31.1__tar.gz → 1.32.0__tar.gz
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_python-1.31.1 → rsconnect_python-1.32.0}/PKG-INFO +3 -2
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/pyproject.toml +3 -2
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/pyproject.toml.orig +2 -2
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/api.py +44 -16
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/bundle.py +141 -32
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/connect_cloud.py +18 -2
- rsconnect_python-1.32.0/rsconnect/device_login.py +1272 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/exception.py +4 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/http_support.py +237 -67
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/main.py +262 -1
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/metadata.py +105 -18
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/oauth.py +147 -76
- rsconnect_python-1.32.0/rsconnect/preflight.py +610 -0
- rsconnect_python-1.32.0/rsconnect/preflight_node.py +518 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/quickstart.py +11 -3
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/subprocesses/inspect_environment.py +5 -2
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/validation.py +5 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/LICENSE.md +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/README.md +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/__init__.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/actions.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/actions_content.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/actions_environment.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/actions_integration.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/certificates.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/environment.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/environment_node.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/environment_r.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/git_metadata.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/json_web_token.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/log.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/models.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/py.typed +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/pyproject.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/__init__.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/__init__.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/api/README.md.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/api/__connect__.py.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/api/__init__.py.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/api/__main__.py.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/api/app.py.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/api/pyproject.toml.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/fastapi/README.md.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/fastapi/app.py.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/notebook/README.md.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/quarto/README.md.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/quarto/report.qmd.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/shiny/README.md.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/shiny/app.py.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/streamlit/README.md.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/streamlit/app.py.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/voila/README.md.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/shiny_express.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/snowflake.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/subprocesses/__init__.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/timeouts.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/utils_package.py +0 -0
- {rsconnect_python-1.31.1 → rsconnect_python-1.32.0}/rsconnect/version_check.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: rsconnect_python
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.32.0
|
|
4
4
|
Summary: The Posit Connect command-line interface.
|
|
5
5
|
Author: Posit, PBC
|
|
6
6
|
Author-email: Posit, PBC <rsconnect@posit.co>
|
|
@@ -29,7 +29,8 @@ Requires-Dist: typing-extensions>=4.8.0
|
|
|
29
29
|
Requires-Dist: pip>=10.0.0
|
|
30
30
|
Requires-Dist: uv>=0.9.0
|
|
31
31
|
Requires-Dist: semver>=2.0.0,<4.0.0
|
|
32
|
-
Requires-Dist: pyjwt>=2.4.0
|
|
32
|
+
Requires-Dist: pyjwt>=2.4.0 ; python_full_version < '3.9'
|
|
33
|
+
Requires-Dist: pyjwt>=2.15.0 ; python_full_version >= '3.9'
|
|
33
34
|
Requires-Dist: click>=8.0.0
|
|
34
35
|
Requires-Dist: packaging>=20.0
|
|
35
36
|
Requires-Dist: toml>=0.10 ; python_full_version < '3.11'
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
name = "rsconnect_python"
|
|
3
3
|
description = "The Posit Connect command-line interface."
|
|
4
4
|
requires-python = ">=3.8"
|
|
5
|
-
version = "1.
|
|
5
|
+
version = "1.32.0"
|
|
6
6
|
dependencies = [
|
|
7
7
|
"typing-extensions>=4.8.0",
|
|
8
8
|
"pip>=10.0.0",
|
|
9
9
|
"uv>=0.9.0",
|
|
10
10
|
"semver>=2.0.0,<4.0.0",
|
|
11
|
-
"pyjwt>=2.4.0",
|
|
11
|
+
"pyjwt>=2.4.0; python_version < '3.9'",
|
|
12
|
+
"pyjwt>=2.15.0; python_version >= '3.9'",
|
|
12
13
|
"click>=8.0.0",
|
|
13
14
|
"packaging>=20.0",
|
|
14
15
|
"toml>=0.10; python_version < '3.11'",
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
name = "rsconnect_python"
|
|
3
3
|
description = "The Posit Connect command-line interface."
|
|
4
4
|
requires-python = ">=3.8"
|
|
5
|
-
version = "1.
|
|
6
|
-
dependencies = [ "typing-extensions>=4.8.0", "pip>=10.0.0", "uv>=0.9.0", "semver>=2.0.0,<4.0.0", "pyjwt>=2.4.0", "click>=8.0.0", "packaging>=20.0", "toml>=0.10; python_version < '3.11'",]
|
|
5
|
+
version = "1.32.0"
|
|
6
|
+
dependencies = [ "typing-extensions>=4.8.0", "pip>=10.0.0", "uv>=0.9.0", "semver>=2.0.0,<4.0.0", "pyjwt>=2.4.0; python_version < '3.9'", "pyjwt>=2.15.0; python_version >= '3.9'", "click>=8.0.0", "packaging>=20.0", "toml>=0.10; python_version < '3.11'",]
|
|
7
7
|
[[project.authors]]
|
|
8
8
|
name = "Posit, PBC"
|
|
9
9
|
email = "rsconnect@posit.co"
|
|
@@ -167,7 +167,8 @@ class AbstractRemoteServer:
|
|
|
167
167
|
safe_uri = _redacted_uri_for_log(response.full_uri)
|
|
168
168
|
if response.exception:
|
|
169
169
|
raise RSConnectException(
|
|
170
|
-
"Could not connect to %s
|
|
170
|
+
"Could not connect to %s (%s)"
|
|
171
|
+
% (_redacted_uri_for_log(self.url), type(response.exception).__name__),
|
|
171
172
|
cause=response.exception,
|
|
172
173
|
)
|
|
173
174
|
# Sometimes an ISP will respond to an unknown server name by returning a friendly
|
|
@@ -188,12 +189,11 @@ class AbstractRemoteServer:
|
|
|
188
189
|
raise RSConnectException(error, status=response.status)
|
|
189
190
|
if response.status is None or response.status < 200 or response.status > 299:
|
|
190
191
|
raise RSConnectException(
|
|
191
|
-
"Received an unexpected response from %s (calling %s): %s
|
|
192
|
+
"Received an unexpected response from %s (calling %s): %s"
|
|
192
193
|
% (
|
|
193
194
|
self.remote_name,
|
|
194
195
|
safe_uri,
|
|
195
196
|
response.status,
|
|
196
|
-
response.reason,
|
|
197
197
|
),
|
|
198
198
|
status=response.status,
|
|
199
199
|
)
|
|
@@ -203,12 +203,11 @@ class AbstractRemoteServer:
|
|
|
203
203
|
# prior function call was not converted from a HTTPResponse to JSON. This
|
|
204
204
|
# should never happen, so raise an exception.
|
|
205
205
|
raise RSConnectException(
|
|
206
|
-
"Received an unexpected response from %s (calling %s): %s
|
|
206
|
+
"Received an unexpected response from %s (calling %s): %s"
|
|
207
207
|
% (
|
|
208
208
|
self.remote_name,
|
|
209
209
|
safe_uri,
|
|
210
210
|
response.status,
|
|
211
|
-
response.reason,
|
|
212
211
|
)
|
|
213
212
|
)
|
|
214
213
|
return response
|
|
@@ -259,6 +258,7 @@ class ConnectCloudServer(AbstractRemoteServer):
|
|
|
259
258
|
url: Optional[str] = None,
|
|
260
259
|
server_name: Optional[str] = None,
|
|
261
260
|
account_id: Optional[str] = None,
|
|
261
|
+
oauth_client_id: Optional[str] = None,
|
|
262
262
|
):
|
|
263
263
|
# Accepts the bare "connect.posit.cloud" a user types for --server, the
|
|
264
264
|
# same way ShinyappsServer accepts "shinyapps.io".
|
|
@@ -267,6 +267,9 @@ class ConnectCloudServer(AbstractRemoteServer):
|
|
|
267
267
|
# The account's id, when it is known without asking the server. Only valid
|
|
268
268
|
# for account_name: the two must be set together.
|
|
269
269
|
self.account_id = account_id
|
|
270
|
+
# An in-progress device login keeps the public OAuth client it started
|
|
271
|
+
# with, even if CONNECT_CLOUD_OAUTH_CLIENT_ID changes before it finishes.
|
|
272
|
+
self.oauth_client_id = oauth_client_id
|
|
270
273
|
self.access_token = access_token
|
|
271
274
|
self.refresh_token = refresh_token
|
|
272
275
|
# Retained so a new access token can be minted non-interactively when
|
|
@@ -396,6 +399,7 @@ class SPCSConnectServer(AbstractRemoteServer):
|
|
|
396
399
|
def exchange_token(self) -> str:
|
|
397
400
|
try:
|
|
398
401
|
server = HTTPServer(url=self.token_endpoint())
|
|
402
|
+
server._suppress_oauth_response_logging = True
|
|
399
403
|
payload = self.fmt_payload()
|
|
400
404
|
|
|
401
405
|
response = server.request(
|
|
@@ -408,17 +412,17 @@ class SPCSConnectServer(AbstractRemoteServer):
|
|
|
408
412
|
# since we don't want to pick up its json decoding assumptions
|
|
409
413
|
if response.exception is not None:
|
|
410
414
|
raise RSConnectException(
|
|
411
|
-
"Could not connect to %s
|
|
415
|
+
"Could not connect to %s (%s)"
|
|
416
|
+
% (_redacted_uri_for_log(self.token_endpoint()), type(response.exception).__name__),
|
|
412
417
|
cause=response.exception,
|
|
413
418
|
)
|
|
414
419
|
if response.status is None or response.status < 200 or response.status > 299:
|
|
415
420
|
raise RSConnectException(
|
|
416
|
-
"Received an unexpected response from %s (calling %s): %s
|
|
421
|
+
"Received an unexpected response from %s (calling %s): %s"
|
|
417
422
|
% (
|
|
418
423
|
self.url,
|
|
419
|
-
response.full_uri,
|
|
424
|
+
_redacted_uri_for_log(response.full_uri),
|
|
420
425
|
response.status,
|
|
421
|
-
response.reason,
|
|
422
426
|
)
|
|
423
427
|
)
|
|
424
428
|
|
|
@@ -594,10 +598,12 @@ class RSConnectClient(BearerTokenHTTPServer):
|
|
|
594
598
|
store._set(entry_name, entry) # type: ignore[possibly-undefined]
|
|
595
599
|
logger.warning("OAuth client was re-registered; please run `rsconnect login` again.")
|
|
596
600
|
except Exception as exc:
|
|
597
|
-
logger.warning(
|
|
601
|
+
logger.warning(
|
|
602
|
+
"OAuth client re-registration failed (%s). Please run `rsconnect login` again." % type(exc).__name__
|
|
603
|
+
)
|
|
598
604
|
return False
|
|
599
605
|
except Exception as exc:
|
|
600
|
-
logger.warning(
|
|
606
|
+
logger.warning("OAuth token refresh failed (%s)." % type(exc).__name__)
|
|
601
607
|
return False
|
|
602
608
|
|
|
603
609
|
new_access = token_response["access_token"]
|
|
@@ -668,6 +674,11 @@ class RSConnectClient(BearerTokenHTTPServer):
|
|
|
668
674
|
response = self._server.handle_bad_response(response)
|
|
669
675
|
return response
|
|
670
676
|
|
|
677
|
+
def nodejs_settings(self) -> dict[str, Any]:
|
|
678
|
+
"""Return Node.js availability and publishability information."""
|
|
679
|
+
response = self.get("v1/server_settings/nodejs")
|
|
680
|
+
return cast(typing.Dict[str, Any], self._server.handle_bad_response(response))
|
|
681
|
+
|
|
671
682
|
def app_get(self, app_id: str) -> ContentItemV0:
|
|
672
683
|
response = cast(Union[ContentItemV0, HTTPResponse], self.get(f"applications/{app_id}"))
|
|
673
684
|
response = self._server.handle_bad_response(response)
|
|
@@ -1375,6 +1386,7 @@ class RSConnectExecutor:
|
|
|
1375
1386
|
polling: bool = True,
|
|
1376
1387
|
quarto_inputs: Optional[List[str]] = None,
|
|
1377
1388
|
infer_target: Optional[bool] = None,
|
|
1389
|
+
app_store: Optional[AppStore] = None,
|
|
1378
1390
|
) -> None:
|
|
1379
1391
|
self.remote_server: TargetableServer
|
|
1380
1392
|
self.client: RSConnectClient | PositClient | ConnectCloudClient
|
|
@@ -1398,7 +1410,9 @@ class RSConnectExecutor:
|
|
|
1398
1410
|
# None outside the Quarto deploy commands.
|
|
1399
1411
|
self.quarto_inputs = quarto_inputs
|
|
1400
1412
|
self.app_mode: AppMode | None = None
|
|
1401
|
-
self.app_store: AppStore =
|
|
1413
|
+
self.app_store: AppStore = (
|
|
1414
|
+
app_store if app_store is not None else AppStore(fake_module_file_from_directory(self.path))
|
|
1415
|
+
)
|
|
1402
1416
|
self.app_store_version: int | None = None
|
|
1403
1417
|
self.api_key_is_required: bool | None = None
|
|
1404
1418
|
# `deploy manifest` / `deploy bundle` pre-resolve `title` to a
|
|
@@ -3389,6 +3403,20 @@ class ConnectCloudClient(BearerTokenHTTPServer):
|
|
|
3389
3403
|
server = self._server
|
|
3390
3404
|
return bool(server.refresh_token or (server.client_id and server.client_secret))
|
|
3391
3405
|
|
|
3406
|
+
def _refresh_user_token(self) -> dict[str, Any]:
|
|
3407
|
+
request_options: dict[str, Any] = {}
|
|
3408
|
+
if self.request_deadline is not None:
|
|
3409
|
+
remaining = self.request_deadline - time.monotonic()
|
|
3410
|
+
if remaining <= 0:
|
|
3411
|
+
raise RSConnectException("Device login finish deadline exceeded.")
|
|
3412
|
+
request_options["request_timeout"] = min(self.request_timeout or remaining, remaining)
|
|
3413
|
+
request_options["request_deadline"] = self.request_deadline
|
|
3414
|
+
if self._server.oauth_client_id is not None:
|
|
3415
|
+
request_options["client_id_override"] = self._server.oauth_client_id
|
|
3416
|
+
if self._suppress_oauth_response_logging:
|
|
3417
|
+
request_options["suppress_response_logging"] = True
|
|
3418
|
+
return connect_cloud.refresh(cast(str, self._server.refresh_token), self._server.environment, **request_options)
|
|
3419
|
+
|
|
3392
3420
|
def _attempt_token_refresh(self) -> bool:
|
|
3393
3421
|
"""Mint a new access token and apply it to this client.
|
|
3394
3422
|
|
|
@@ -3410,13 +3438,13 @@ class ConnectCloudClient(BearerTokenHTTPServer):
|
|
|
3410
3438
|
server.client_id, server.client_secret, server.environment
|
|
3411
3439
|
)
|
|
3412
3440
|
elif server.refresh_token:
|
|
3413
|
-
tokens =
|
|
3441
|
+
tokens = self._refresh_user_token()
|
|
3414
3442
|
else:
|
|
3415
3443
|
return False
|
|
3416
3444
|
except InvalidClientError as exc:
|
|
3417
3445
|
if not service_account:
|
|
3418
3446
|
# This CLI's own OAuth client, not the user's credential.
|
|
3419
|
-
logger.warning("Posit Connect Cloud token refresh failed
|
|
3447
|
+
logger.warning("Posit Connect Cloud token refresh failed (%s)." % type(exc).__name__)
|
|
3420
3448
|
return False
|
|
3421
3449
|
raise RSConnectException(
|
|
3422
3450
|
"The Posit Connect Cloud service account credential was rejected — it has been revoked or "
|
|
@@ -3425,7 +3453,7 @@ class ConnectCloudClient(BearerTokenHTTPServer):
|
|
|
3425
3453
|
) from exc
|
|
3426
3454
|
except InvalidGrantError as exc:
|
|
3427
3455
|
if service_account:
|
|
3428
|
-
logger.warning("Posit Connect Cloud token refresh failed
|
|
3456
|
+
logger.warning("Posit Connect Cloud token refresh failed (%s)." % type(exc).__name__)
|
|
3429
3457
|
return False
|
|
3430
3458
|
self._persist_tokens(None, None)
|
|
3431
3459
|
raise RSConnectException(
|
|
@@ -3433,7 +3461,7 @@ class ConnectCloudClient(BearerTokenHTTPServer):
|
|
|
3433
3461
|
"Authenticate again with `%s`." % self._add_command()
|
|
3434
3462
|
) from exc
|
|
3435
3463
|
except RSConnectException as exc:
|
|
3436
|
-
logger.warning("Posit Connect Cloud token refresh failed
|
|
3464
|
+
logger.warning("Posit Connect Cloud token refresh failed (%s)." % type(exc).__name__)
|
|
3437
3465
|
return False
|
|
3438
3466
|
|
|
3439
3467
|
access_token = tokens.get("access_token")
|
|
@@ -42,6 +42,8 @@ from typing import (
|
|
|
42
42
|
cast,
|
|
43
43
|
)
|
|
44
44
|
|
|
45
|
+
from . import metadata
|
|
46
|
+
|
|
45
47
|
# Even though TypedDict is available in Python 3.8, because it's used with NotRequired,
|
|
46
48
|
# they should both come from the same typing module.
|
|
47
49
|
# https://peps.python.org/pep-0655/#usage-in-python-3-11
|
|
@@ -79,10 +81,53 @@ directories_ignore_list = [
|
|
|
79
81
|
"node_modules/",
|
|
80
82
|
]
|
|
81
83
|
directories_to_ignore = {Path(d) for d in directories_ignore_list}
|
|
84
|
+
_config_dir_names = {".rsconnect-python"}
|
|
82
85
|
|
|
83
86
|
mimetypes.add_type("text/ipynb", ".ipynb")
|
|
84
87
|
|
|
85
88
|
|
|
89
|
+
def _resolved_config_dir() -> Path:
|
|
90
|
+
return Path(metadata.config_dirname()).expanduser().resolve()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _path_is_within(path: Path, directory: Path) -> bool:
|
|
94
|
+
return path == directory or directory in path.parents
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _is_config_path(
|
|
98
|
+
path: str | Path,
|
|
99
|
+
base_dir: str | Path | None = None,
|
|
100
|
+
config_dir: Path | None = None,
|
|
101
|
+
) -> bool:
|
|
102
|
+
candidate = Path(path)
|
|
103
|
+
if base_dir is not None and not candidate.is_absolute():
|
|
104
|
+
candidate = Path(base_dir) / candidate
|
|
105
|
+
if _config_dir_names.intersection(candidate.parts):
|
|
106
|
+
return True
|
|
107
|
+
if config_dir is None:
|
|
108
|
+
if base_dir is None and not candidate.is_absolute():
|
|
109
|
+
return False
|
|
110
|
+
config_dir = _resolved_config_dir()
|
|
111
|
+
resolved = candidate.resolve()
|
|
112
|
+
return bool(_config_dir_names.intersection(resolved.parts)) or _path_is_within(resolved, config_dir)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _ensure_content_root_is_safe(path: str | Path, config_dir: Path) -> None:
|
|
116
|
+
content_root = Path(path)
|
|
117
|
+
if content_root.is_file():
|
|
118
|
+
content_root = content_root.parent
|
|
119
|
+
if _is_config_path(content_root, config_dir=config_dir):
|
|
120
|
+
raise RSConnectException(
|
|
121
|
+
f"Cannot bundle content from '{content_root.resolve()}' because it is inside the "
|
|
122
|
+
f"rsconnect-python configuration directory '{config_dir}' or a reserved '.rsconnect-python' directory. "
|
|
123
|
+
"Choose a content directory outside the configuration directory."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _filter_config_paths(paths: Sequence[str], base_dir: str | Path, config_dir: Path) -> list[str]:
|
|
128
|
+
return [path for path in paths if not _is_config_path(path, base_dir, config_dir)]
|
|
129
|
+
|
|
130
|
+
|
|
86
131
|
class ManifestDataFile(TypedDict):
|
|
87
132
|
checksum: str
|
|
88
133
|
|
|
@@ -565,10 +610,9 @@ def write_manifest(
|
|
|
565
610
|
if environment.source == "file":
|
|
566
611
|
skipped.append(environment_relative_path)
|
|
567
612
|
else:
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
logger.debug("wrote environment file: %s", environment_file)
|
|
613
|
+
write_environment_file(environment, output_dir)
|
|
614
|
+
created.append(environment_relative_path)
|
|
615
|
+
logger.debug("wrote environment file: %s", environment_file)
|
|
572
616
|
|
|
573
617
|
return created, skipped
|
|
574
618
|
|
|
@@ -624,6 +668,11 @@ def make_notebook_source_bundle(
|
|
|
624
668
|
extra_files = []
|
|
625
669
|
base_dir = dirname(file)
|
|
626
670
|
nb_name = basename(file)
|
|
671
|
+
config_dir = _resolved_config_dir()
|
|
672
|
+
_ensure_content_root_is_safe(base_dir, config_dir)
|
|
673
|
+
if _is_config_path(file, config_dir=config_dir):
|
|
674
|
+
raise RSConnectException("A credential configuration file cannot be bundled as notebook content.")
|
|
675
|
+
extra_files = _filter_config_paths(extra_files, base_dir, config_dir)
|
|
627
676
|
|
|
628
677
|
manifest = make_source_manifest(
|
|
629
678
|
AppModes.JUPYTER_NOTEBOOK,
|
|
@@ -741,6 +790,11 @@ def make_notebook_html_bundle(
|
|
|
741
790
|
hide_tagged_input: bool,
|
|
742
791
|
check_output: Callable[..., bytes] = subprocess.check_output,
|
|
743
792
|
) -> typing.IO[bytes]:
|
|
793
|
+
config_dir = _resolved_config_dir()
|
|
794
|
+
_ensure_content_root_is_safe(dirname(filename), config_dir)
|
|
795
|
+
if _is_config_path(filename, config_dir=config_dir):
|
|
796
|
+
raise RSConnectException("A credential configuration file cannot be bundled as notebook content.")
|
|
797
|
+
|
|
744
798
|
# noinspection SpellCheckingInspection
|
|
745
799
|
cmd = [
|
|
746
800
|
python,
|
|
@@ -781,7 +835,12 @@ def make_notebook_html_bundle(
|
|
|
781
835
|
return bundle_file
|
|
782
836
|
|
|
783
837
|
|
|
784
|
-
def keep_manifest_specified_file(
|
|
838
|
+
def keep_manifest_specified_file(
|
|
839
|
+
relative_path: str,
|
|
840
|
+
ignore_path_set: set[Path] = directories_to_ignore,
|
|
841
|
+
base_dir: str | Path | None = None,
|
|
842
|
+
config_dir: Path | None = None,
|
|
843
|
+
) -> bool:
|
|
785
844
|
"""
|
|
786
845
|
A helper to see if the relative path given, which is assumed to have come
|
|
787
846
|
from a manifest.json file, should be kept or ignored.
|
|
@@ -789,6 +848,8 @@ def keep_manifest_specified_file(relative_path: str, ignore_path_set: set[Path]
|
|
|
789
848
|
:param relative_path: the relative path name to check.
|
|
790
849
|
:return: True, if the path should kept or False, if it should be ignored.
|
|
791
850
|
"""
|
|
851
|
+
if _is_config_path(relative_path, base_dir, config_dir):
|
|
852
|
+
return False
|
|
792
853
|
p = Path(relative_path)
|
|
793
854
|
for parent in p.parents:
|
|
794
855
|
if parent in ignore_path_set:
|
|
@@ -951,7 +1012,16 @@ def make_manifest_bundle(manifest_path: str | Path) -> typing.IO[bytes]:
|
|
|
951
1012
|
manifest, raw_manifest = read_manifest_file(manifest_path)
|
|
952
1013
|
|
|
953
1014
|
base_dir = dirname(manifest_path)
|
|
954
|
-
|
|
1015
|
+
config_dir = _resolved_config_dir()
|
|
1016
|
+
_ensure_content_root_is_safe(base_dir, config_dir)
|
|
1017
|
+
manifest_files = manifest.get("files", {})
|
|
1018
|
+
config_files = {path for path in manifest_files if _is_config_path(path, base_dir, config_dir)}
|
|
1019
|
+
files = [
|
|
1020
|
+
path for path in manifest_files if keep_manifest_specified_file(path, base_dir=base_dir, config_dir=config_dir)
|
|
1021
|
+
]
|
|
1022
|
+
if config_files:
|
|
1023
|
+
manifest["files"] = {path: value for path, value in manifest_files.items() if path not in config_files}
|
|
1024
|
+
raw_manifest = json.dumps(manifest, indent=2)
|
|
955
1025
|
|
|
956
1026
|
if "manifest.json" in files:
|
|
957
1027
|
# this will be created
|
|
@@ -1231,6 +1301,48 @@ def make_tensorflow_bundle(
|
|
|
1231
1301
|
return bundle.to_file(directory)
|
|
1232
1302
|
|
|
1233
1303
|
|
|
1304
|
+
def _should_skip_walk_directory(
|
|
1305
|
+
cur_dir: str,
|
|
1306
|
+
sub_dirs: list[str],
|
|
1307
|
+
exclude_paths: set[Path],
|
|
1308
|
+
config_dir: Path,
|
|
1309
|
+
) -> bool:
|
|
1310
|
+
sub_dirs[:] = [
|
|
1311
|
+
sub_dir for sub_dir in sub_dirs if not _is_config_path(join(cur_dir, sub_dir), config_dir=config_dir)
|
|
1312
|
+
]
|
|
1313
|
+
current_dir = Path(cur_dir)
|
|
1314
|
+
return current_dir in exclude_paths or any(parent in exclude_paths for parent in current_dir.parents)
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def _iter_directory_files(
|
|
1318
|
+
path: str,
|
|
1319
|
+
extra_files: Sequence[str],
|
|
1320
|
+
excludes: Sequence[str],
|
|
1321
|
+
use_abspath: bool,
|
|
1322
|
+
config_dir: Path,
|
|
1323
|
+
) -> Iterator[str]:
|
|
1324
|
+
glob_set = create_glob_set(path, excludes)
|
|
1325
|
+
exclude_paths = {Path(p) for p in excludes}
|
|
1326
|
+
for cur_dir, sub_dirs, files in os.walk(path):
|
|
1327
|
+
if _should_skip_walk_directory(cur_dir, sub_dirs, exclude_paths, config_dir):
|
|
1328
|
+
continue
|
|
1329
|
+
for file in files:
|
|
1330
|
+
cur_path = os.path.join(cur_dir, file)
|
|
1331
|
+
rel_path = relpath(cur_path, path)
|
|
1332
|
+
if Path(cur_path) in exclude_paths:
|
|
1333
|
+
continue
|
|
1334
|
+
if not keep_manifest_specified_file(
|
|
1335
|
+
rel_path,
|
|
1336
|
+
exclude_paths | directories_to_ignore,
|
|
1337
|
+
base_dir=path,
|
|
1338
|
+
config_dir=config_dir,
|
|
1339
|
+
):
|
|
1340
|
+
continue
|
|
1341
|
+
if rel_path not in extra_files and glob_set.matches(cur_path):
|
|
1342
|
+
continue
|
|
1343
|
+
yield abspath(cur_path) if use_abspath else rel_path
|
|
1344
|
+
|
|
1345
|
+
|
|
1234
1346
|
def create_file_list(
|
|
1235
1347
|
path: str,
|
|
1236
1348
|
extra_files: Sequence[str],
|
|
@@ -1248,32 +1360,19 @@ def create_file_list(
|
|
|
1248
1360
|
:return: the list of relevant files, relative to the given directory.
|
|
1249
1361
|
"""
|
|
1250
1362
|
extra_files = extra_files or []
|
|
1251
|
-
excludes = excludes
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1363
|
+
excludes = excludes or []
|
|
1364
|
+
config_dir = _resolved_config_dir()
|
|
1365
|
+
content_root = Path(path).parent if isfile(path) else Path(path)
|
|
1366
|
+
_ensure_content_root_is_safe(content_root, config_dir)
|
|
1367
|
+
file_set: set[str] = set(_filter_config_paths(extra_files, content_root, config_dir))
|
|
1255
1368
|
|
|
1256
1369
|
if isfile(path):
|
|
1257
|
-
|
|
1258
|
-
|
|
1370
|
+
if not _is_config_path(path, config_dir=config_dir):
|
|
1371
|
+
path_to_add = abspath(path) if use_abspath else path
|
|
1372
|
+
file_set.add(path_to_add)
|
|
1259
1373
|
return sorted(file_set)
|
|
1260
1374
|
|
|
1261
|
-
|
|
1262
|
-
if Path(cur_dir) in exclude_paths:
|
|
1263
|
-
continue
|
|
1264
|
-
if any(parent in exclude_paths for parent in Path(cur_dir).parents):
|
|
1265
|
-
continue
|
|
1266
|
-
for file in files:
|
|
1267
|
-
cur_path = os.path.join(cur_dir, file)
|
|
1268
|
-
rel_path = relpath(cur_path, path)
|
|
1269
|
-
|
|
1270
|
-
if Path(cur_path) in exclude_paths:
|
|
1271
|
-
continue
|
|
1272
|
-
if keep_manifest_specified_file(rel_path, exclude_paths | directories_to_ignore) and (
|
|
1273
|
-
rel_path in extra_files or not glob_set.matches(cur_path)
|
|
1274
|
-
):
|
|
1275
|
-
path_to_add = abspath(cur_path) if use_abspath else rel_path
|
|
1276
|
-
file_set.add(path_to_add)
|
|
1375
|
+
file_set.update(_iter_directory_files(path, extra_files, excludes, use_abspath, config_dir))
|
|
1277
1376
|
|
|
1278
1377
|
return sorted(file_set)
|
|
1279
1378
|
|
|
@@ -1713,7 +1812,11 @@ def make_quarto_manifest(
|
|
|
1713
1812
|
# Standalone Quarto document
|
|
1714
1813
|
base_dir = dirname(file_or_directory)
|
|
1715
1814
|
file_name = basename(file_or_directory)
|
|
1716
|
-
|
|
1815
|
+
config_dir = _resolved_config_dir()
|
|
1816
|
+
_ensure_content_root_is_safe(base_dir, config_dir)
|
|
1817
|
+
if _is_config_path(file_or_directory, config_dir=config_dir):
|
|
1818
|
+
raise RSConnectException("A credential configuration file cannot be bundled as Quarto content.")
|
|
1819
|
+
relevant_files = [file_name] + _filter_config_paths(extra_files or [], base_dir, config_dir)
|
|
1717
1820
|
|
|
1718
1821
|
manifest = make_source_manifest(
|
|
1719
1822
|
app_mode,
|
|
@@ -2016,8 +2119,12 @@ def write_notebook_manifest_json(
|
|
|
2016
2119
|
:return: whether or not the environment file (requirements.txt, environment.yml,
|
|
2017
2120
|
etc.) that goes along with the manifest exists.
|
|
2018
2121
|
"""
|
|
2019
|
-
extra_files = validate_extra_files(dirname(entry_point_file), extra_files)
|
|
2020
2122
|
directory = dirname(entry_point_file)
|
|
2123
|
+
config_dir = _resolved_config_dir()
|
|
2124
|
+
_ensure_content_root_is_safe(directory, config_dir)
|
|
2125
|
+
if _is_config_path(entry_point_file, config_dir=config_dir):
|
|
2126
|
+
raise RSConnectException("A credential configuration file cannot be bundled as notebook content.")
|
|
2127
|
+
extra_files = _filter_config_paths(validate_extra_files(directory, extra_files), directory, config_dir)
|
|
2021
2128
|
file_name = basename(entry_point_file)
|
|
2022
2129
|
manifest_path = join(directory, "manifest.json")
|
|
2023
2130
|
|
|
@@ -2365,8 +2472,10 @@ def write_environment_file(
|
|
|
2365
2472
|
:param directory: the directory where the file should be written.
|
|
2366
2473
|
"""
|
|
2367
2474
|
environment_file_path = join(directory, environment.filename)
|
|
2368
|
-
|
|
2369
|
-
|
|
2475
|
+
# Write bytes so that the file matches the manifest checksum on all platforms.
|
|
2476
|
+
# Text mode changes line endings and encoding on Windows.
|
|
2477
|
+
with open(environment_file_path, "wb") as f:
|
|
2478
|
+
f.write(to_bytes(environment.contents))
|
|
2370
2479
|
|
|
2371
2480
|
|
|
2372
2481
|
def describe_manifest(
|
|
@@ -236,14 +236,30 @@ def login_client_credentials(
|
|
|
236
236
|
)
|
|
237
237
|
|
|
238
238
|
|
|
239
|
-
def refresh(
|
|
239
|
+
def refresh(
|
|
240
|
+
refresh_token: str,
|
|
241
|
+
environment: Optional[str] = None,
|
|
242
|
+
request_timeout: Optional[float] = None,
|
|
243
|
+
request_deadline: Optional[float] = None,
|
|
244
|
+
client_id_override: Optional[str] = None,
|
|
245
|
+
*,
|
|
246
|
+
suppress_response_logging: bool = False,
|
|
247
|
+
) -> dict[str, Any]:
|
|
240
248
|
"""Mint a new access token from a refresh token."""
|
|
241
249
|
env = environment or environment_name()
|
|
250
|
+
request_options: dict[str, Any] = {}
|
|
251
|
+
if request_timeout is not None:
|
|
252
|
+
request_options["request_timeout"] = request_timeout
|
|
253
|
+
if request_deadline is not None:
|
|
254
|
+
request_options["request_deadline"] = request_deadline
|
|
255
|
+
if suppress_response_logging:
|
|
256
|
+
request_options["suppress_response_logging"] = True
|
|
242
257
|
return refresh_access_token(
|
|
243
258
|
metadata=urls(env).oauth_metadata(),
|
|
244
|
-
client_id=client_id(env),
|
|
259
|
+
client_id=client_id_override if client_id_override is not None else client_id(env),
|
|
245
260
|
refresh_token=refresh_token,
|
|
246
261
|
scope=SCOPE,
|
|
262
|
+
**request_options,
|
|
247
263
|
)
|
|
248
264
|
|
|
249
265
|
|