rsconnect-python 1.31.0__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.0 → rsconnect_python-1.32.0}/PKG-INFO +3 -2
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/pyproject.toml +3 -2
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/pyproject.toml.orig +2 -2
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/api.py +104 -36
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/bundle.py +141 -32
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/connect_cloud.py +227 -2
- rsconnect_python-1.32.0/rsconnect/device_login.py +1272 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/exception.py +4 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/http_support.py +237 -67
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/main.py +262 -1
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/metadata.py +105 -18
- {rsconnect_python-1.31.0 → 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.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/quickstart.py +11 -3
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/subprocesses/inspect_environment.py +5 -2
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/validation.py +5 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/LICENSE.md +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/README.md +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/__init__.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/actions.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/actions_content.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/actions_environment.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/actions_integration.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/certificates.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/environment.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/environment_node.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/environment_r.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/git_metadata.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/json_web_token.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/log.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/models.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/py.typed +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/pyproject.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/__init__.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/__init__.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/api/README.md.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/api/__connect__.py.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/api/__init__.py.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/api/__main__.py.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/api/app.py.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/api/pyproject.toml.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/fastapi/README.md.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/fastapi/app.py.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/notebook/README.md.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/quarto/README.md.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/quarto/report.qmd.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/shiny/README.md.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/shiny/app.py.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/streamlit/README.md.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/streamlit/app.py.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/voila/README.md.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/shiny_express.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/snowflake.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/subprocesses/__init__.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/timeouts.py +0 -0
- {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/utils_package.py +0 -0
- {rsconnect_python-1.31.0 → 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
|
|
@@ -2165,16 +2179,15 @@ for shinyapps.io. See command help for further details."
|
|
|
2165
2179
|
)
|
|
2166
2180
|
upload_result = S3Server(upload_url).handle_bad_response(upload_result, is_httpresponse=True)
|
|
2167
2181
|
|
|
2168
|
-
def
|
|
2169
|
-
"""The
|
|
2182
|
+
def bundle_manifest(self) -> dict[str, Any]:
|
|
2183
|
+
"""The manifest of the built bundle.
|
|
2170
2184
|
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
avoids having to thread it separately through each command.
|
|
2185
|
+
Every deploy path writes what Connect Cloud needs into the bundle's
|
|
2186
|
+
manifest, so reading it back from there avoids having to thread the same
|
|
2187
|
+
values separately through each command.
|
|
2175
2188
|
"""
|
|
2176
2189
|
if self.bundle is None:
|
|
2177
|
-
raise RSConnectException("A bundle must be created before
|
|
2190
|
+
raise RSConnectException("A bundle must be created before reading its manifest.")
|
|
2178
2191
|
|
|
2179
2192
|
position = self.bundle.tell()
|
|
2180
2193
|
try:
|
|
@@ -2186,12 +2199,34 @@ for shinyapps.io. See command help for further details."
|
|
|
2186
2199
|
extracted = tar.extractfile(member) if member is not None else None
|
|
2187
2200
|
if extracted is None:
|
|
2188
2201
|
raise RSConnectException("The bundle does not contain a manifest.json.")
|
|
2189
|
-
|
|
2202
|
+
return cast("dict[str, Any]", json.loads(extracted.read().decode("utf-8")))
|
|
2190
2203
|
except (tarfile.TarError, KeyError, ValueError) as exc:
|
|
2191
2204
|
raise RSConnectException("Could not read the bundle manifest: %s" % exc) from exc
|
|
2192
2205
|
finally:
|
|
2193
2206
|
self.bundle.seek(position)
|
|
2194
2207
|
|
|
2208
|
+
def python_version_for_connect_cloud(self) -> Optional[str]:
|
|
2209
|
+
"""The MAJOR.MINOR Python version to ask Connect Cloud for, or None.
|
|
2210
|
+
|
|
2211
|
+
None means the request omits the field: a redeploy keeps the version already
|
|
2212
|
+
set on the content, and a first deploy takes Connect Cloud's default. Content
|
|
2213
|
+
with no Python at all lands here too.
|
|
2214
|
+
"""
|
|
2215
|
+
manifest = self.bundle_manifest()
|
|
2216
|
+
environment: dict[str, Any] = manifest.get("environment") or {}
|
|
2217
|
+
requirement: dict[str, Any] = environment.get("python") or {}
|
|
2218
|
+
interpreter: dict[str, Any] = manifest.get("python") or {}
|
|
2219
|
+
requires: Optional[str] = requirement.get("requires")
|
|
2220
|
+
local_version: Optional[str] = interpreter.get("version")
|
|
2221
|
+
return connect_cloud.resolve_python_version(requires, local_version)
|
|
2222
|
+
|
|
2223
|
+
def primary_file_for_connect_cloud(self) -> str:
|
|
2224
|
+
"""The entrypoint to report to Connect Cloud, read from the built bundle.
|
|
2225
|
+
|
|
2226
|
+
Connect Cloud needs a `primary_file` and uses it to decide, for example,
|
|
2227
|
+
whether Shiny content is R or Python.
|
|
2228
|
+
"""
|
|
2229
|
+
manifest = self.bundle_manifest()
|
|
2195
2230
|
metadata = manifest.get("metadata") or {}
|
|
2196
2231
|
primary_file = metadata.get("entrypoint") or metadata.get("primary_rmd") or metadata.get("primary_html")
|
|
2197
2232
|
files = manifest.get("files") or {}
|
|
@@ -2302,6 +2337,7 @@ for shinyapps.io. See command help for further details."
|
|
|
2302
2337
|
title=self.title,
|
|
2303
2338
|
app_mode=self.app_mode,
|
|
2304
2339
|
primary_file=self.primary_file_for_connect_cloud(),
|
|
2340
|
+
python_version=self.python_version_for_connect_cloud(),
|
|
2305
2341
|
env_vars=self.env_vars,
|
|
2306
2342
|
update_title=not self.title_is_default,
|
|
2307
2343
|
app_id_is_explicit=self.app_id_is_explicit,
|
|
@@ -3293,6 +3329,7 @@ class ConnectCloudAccountSearchResults(TypedDict):
|
|
|
3293
3329
|
class ConnectCloudRevision(TypedDict):
|
|
3294
3330
|
id: str
|
|
3295
3331
|
content_id: NotRequired[str]
|
|
3332
|
+
python_version: NotRequired[Optional[str]]
|
|
3296
3333
|
status: NotRequired[str]
|
|
3297
3334
|
source_bundle_upload_url: NotRequired[str]
|
|
3298
3335
|
publish_result: NotRequired[Optional[str]]
|
|
@@ -3366,6 +3403,20 @@ class ConnectCloudClient(BearerTokenHTTPServer):
|
|
|
3366
3403
|
server = self._server
|
|
3367
3404
|
return bool(server.refresh_token or (server.client_id and server.client_secret))
|
|
3368
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
|
+
|
|
3369
3420
|
def _attempt_token_refresh(self) -> bool:
|
|
3370
3421
|
"""Mint a new access token and apply it to this client.
|
|
3371
3422
|
|
|
@@ -3387,13 +3438,13 @@ class ConnectCloudClient(BearerTokenHTTPServer):
|
|
|
3387
3438
|
server.client_id, server.client_secret, server.environment
|
|
3388
3439
|
)
|
|
3389
3440
|
elif server.refresh_token:
|
|
3390
|
-
tokens =
|
|
3441
|
+
tokens = self._refresh_user_token()
|
|
3391
3442
|
else:
|
|
3392
3443
|
return False
|
|
3393
3444
|
except InvalidClientError as exc:
|
|
3394
3445
|
if not service_account:
|
|
3395
3446
|
# This CLI's own OAuth client, not the user's credential.
|
|
3396
|
-
logger.warning("Posit Connect Cloud token refresh failed
|
|
3447
|
+
logger.warning("Posit Connect Cloud token refresh failed (%s)." % type(exc).__name__)
|
|
3397
3448
|
return False
|
|
3398
3449
|
raise RSConnectException(
|
|
3399
3450
|
"The Posit Connect Cloud service account credential was rejected — it has been revoked or "
|
|
@@ -3402,7 +3453,7 @@ class ConnectCloudClient(BearerTokenHTTPServer):
|
|
|
3402
3453
|
) from exc
|
|
3403
3454
|
except InvalidGrantError as exc:
|
|
3404
3455
|
if service_account:
|
|
3405
|
-
logger.warning("Posit Connect Cloud token refresh failed
|
|
3456
|
+
logger.warning("Posit Connect Cloud token refresh failed (%s)." % type(exc).__name__)
|
|
3406
3457
|
return False
|
|
3407
3458
|
self._persist_tokens(None, None)
|
|
3408
3459
|
raise RSConnectException(
|
|
@@ -3410,7 +3461,7 @@ class ConnectCloudClient(BearerTokenHTTPServer):
|
|
|
3410
3461
|
"Authenticate again with `%s`." % self._add_command()
|
|
3411
3462
|
) from exc
|
|
3412
3463
|
except RSConnectException as exc:
|
|
3413
|
-
logger.warning("Posit Connect Cloud token refresh failed
|
|
3464
|
+
logger.warning("Posit Connect Cloud token refresh failed (%s)." % type(exc).__name__)
|
|
3414
3465
|
return False
|
|
3415
3466
|
|
|
3416
3467
|
access_token = tokens.get("access_token")
|
|
@@ -3648,21 +3699,29 @@ class ConnectCloudClient(BearerTokenHTTPServer):
|
|
|
3648
3699
|
primary_file: str,
|
|
3649
3700
|
secrets: Optional[list[dict[str, str]]] = None,
|
|
3650
3701
|
access: Optional[str] = None,
|
|
3702
|
+
python_version: Optional[str] = None,
|
|
3651
3703
|
) -> ConnectCloudContent:
|
|
3652
3704
|
"""Create content to upload a bundle into.
|
|
3653
3705
|
|
|
3654
3706
|
`access` is the content's visibility ("public" or "private"). Omitted from
|
|
3655
3707
|
the request when None so the server picks its own default.
|
|
3708
|
+
|
|
3709
|
+
`python_version` is MAJOR.MINOR. Omitted when None, which is how content
|
|
3710
|
+
with no Python is created; Connect Cloud then falls back to its own
|
|
3711
|
+
default rather than reading the version out of the bundle's manifest.
|
|
3656
3712
|
"""
|
|
3713
|
+
next_revision: dict[str, Any] = {
|
|
3714
|
+
"source_type": "bundle",
|
|
3715
|
+
"content_type": content_type,
|
|
3716
|
+
"app_mode": app_mode,
|
|
3717
|
+
"primary_file": primary_file,
|
|
3718
|
+
}
|
|
3719
|
+
if python_version is not None:
|
|
3720
|
+
next_revision["python_version"] = python_version
|
|
3657
3721
|
body: dict[str, Any] = {
|
|
3658
3722
|
"account_id": account_id,
|
|
3659
3723
|
"title": title,
|
|
3660
|
-
"next_revision":
|
|
3661
|
-
"source_type": "bundle",
|
|
3662
|
-
"content_type": content_type,
|
|
3663
|
-
"app_mode": app_mode,
|
|
3664
|
-
"primary_file": primary_file,
|
|
3665
|
-
},
|
|
3724
|
+
"next_revision": next_revision,
|
|
3666
3725
|
"secrets": secrets or [],
|
|
3667
3726
|
}
|
|
3668
3727
|
if access is not None:
|
|
@@ -3680,6 +3739,7 @@ class ConnectCloudClient(BearerTokenHTTPServer):
|
|
|
3680
3739
|
new_bundle: bool = True,
|
|
3681
3740
|
title: Optional[str] = None,
|
|
3682
3741
|
access: Optional[str] = None,
|
|
3742
|
+
python_version: Optional[str] = None,
|
|
3683
3743
|
) -> ConnectCloudContent:
|
|
3684
3744
|
"""Update content, optionally minting a fresh revision to upload into.
|
|
3685
3745
|
|
|
@@ -3693,14 +3753,19 @@ class ConnectCloudClient(BearerTokenHTTPServer):
|
|
|
3693
3753
|
deploy without -E/-t must leave the existing values alone. `access` (the
|
|
3694
3754
|
content's visibility) is omitted the same way, so a redeploy without
|
|
3695
3755
|
-V keeps whatever visibility the content already has.
|
|
3756
|
+
|
|
3757
|
+
`python_version` is omitted when None for the same reason: a redeploy of
|
|
3758
|
+
content whose bundle carries no Python version keeps the version already
|
|
3759
|
+
set on the content, including one chosen in the Connect Cloud UI.
|
|
3696
3760
|
"""
|
|
3697
|
-
|
|
3698
|
-
"
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
"content_type": content_type,
|
|
3702
|
-
},
|
|
3761
|
+
revision_overrides: dict[str, Any] = {
|
|
3762
|
+
"primary_file": primary_file,
|
|
3763
|
+
"app_mode": app_mode,
|
|
3764
|
+
"content_type": content_type,
|
|
3703
3765
|
}
|
|
3766
|
+
if python_version is not None:
|
|
3767
|
+
revision_overrides["python_version"] = python_version
|
|
3768
|
+
body: dict[str, Any] = {"revision_overrides": revision_overrides}
|
|
3704
3769
|
if secrets is not None:
|
|
3705
3770
|
body["secrets"] = secrets
|
|
3706
3771
|
if title is not None:
|
|
@@ -3895,6 +3960,7 @@ class ConnectCloudService:
|
|
|
3895
3960
|
update_title: bool = False,
|
|
3896
3961
|
app_id_is_explicit: bool = False,
|
|
3897
3962
|
visibility: Optional[str] = None,
|
|
3963
|
+
python_version: Optional[str] = None,
|
|
3898
3964
|
) -> ConnectCloudDeployResult:
|
|
3899
3965
|
"""Create or fetch the content item and get a revision to upload into.
|
|
3900
3966
|
|
|
@@ -3961,6 +4027,7 @@ class ConnectCloudService:
|
|
|
3961
4027
|
content_type=content_type,
|
|
3962
4028
|
app_mode=app_mode.name(),
|
|
3963
4029
|
primary_file=primary_file,
|
|
4030
|
+
python_version=python_version,
|
|
3964
4031
|
secrets=secrets,
|
|
3965
4032
|
access=visibility,
|
|
3966
4033
|
)
|
|
@@ -3975,6 +4042,7 @@ class ConnectCloudService:
|
|
|
3975
4042
|
primary_file=primary_file,
|
|
3976
4043
|
app_mode=app_mode.name(),
|
|
3977
4044
|
content_type=content_type,
|
|
4045
|
+
python_version=python_version,
|
|
3978
4046
|
secrets=secrets,
|
|
3979
4047
|
new_bundle=upload,
|
|
3980
4048
|
title=title if update_title and title else None,
|
|
@@ -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(
|