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.
Files changed (68) hide show
  1. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/PKG-INFO +3 -2
  2. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/pyproject.toml +3 -2
  3. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/pyproject.toml.orig +2 -2
  4. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/api.py +104 -36
  5. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/bundle.py +141 -32
  6. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/connect_cloud.py +227 -2
  7. rsconnect_python-1.32.0/rsconnect/device_login.py +1272 -0
  8. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/exception.py +4 -0
  9. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/http_support.py +237 -67
  10. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/main.py +262 -1
  11. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/metadata.py +105 -18
  12. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/oauth.py +147 -76
  13. rsconnect_python-1.32.0/rsconnect/preflight.py +610 -0
  14. rsconnect_python-1.32.0/rsconnect/preflight_node.py +518 -0
  15. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/quickstart.py +11 -3
  16. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/subprocesses/inspect_environment.py +5 -2
  17. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/validation.py +5 -0
  18. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/LICENSE.md +0 -0
  19. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/README.md +0 -0
  20. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/__init__.py +0 -0
  21. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/actions.py +0 -0
  22. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/actions_content.py +0 -0
  23. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/actions_environment.py +0 -0
  24. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/actions_integration.py +0 -0
  25. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/certificates.py +0 -0
  26. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/environment.py +0 -0
  27. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/environment_node.py +0 -0
  28. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/environment_r.py +0 -0
  29. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/git_metadata.py +0 -0
  30. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/json_web_token.py +0 -0
  31. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/log.py +0 -0
  32. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/models.py +0 -0
  33. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/py.typed +0 -0
  34. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/pyproject.py +0 -0
  35. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/__init__.py +0 -0
  36. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/__init__.py +0 -0
  37. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/api/README.md.tmpl +0 -0
  38. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/api/__connect__.py.tmpl +0 -0
  39. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/api/__init__.py.tmpl +0 -0
  40. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/api/__main__.py.tmpl +0 -0
  41. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/api/app.py.tmpl +0 -0
  42. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/api/pyproject.toml.tmpl +0 -0
  43. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/fastapi/README.md.tmpl +0 -0
  44. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +0 -0
  45. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +0 -0
  46. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +0 -0
  47. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/fastapi/app.py.tmpl +0 -0
  48. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +0 -0
  49. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/notebook/README.md.tmpl +0 -0
  50. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +0 -0
  51. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +0 -0
  52. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/quarto/README.md.tmpl +0 -0
  53. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +0 -0
  54. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/quarto/report.qmd.tmpl +0 -0
  55. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/shiny/README.md.tmpl +0 -0
  56. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/shiny/app.py.tmpl +0 -0
  57. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +0 -0
  58. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/streamlit/README.md.tmpl +0 -0
  59. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/streamlit/app.py.tmpl +0 -0
  60. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +0 -0
  61. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/voila/README.md.tmpl +0 -0
  62. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +0 -0
  63. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/shiny_express.py +0 -0
  64. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/snowflake.py +0 -0
  65. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/subprocesses/__init__.py +0 -0
  66. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/timeouts.py +0 -0
  67. {rsconnect_python-1.31.0 → rsconnect_python-1.32.0}/rsconnect/utils_package.py +0 -0
  68. {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.31.0
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.31.0"
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.31.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", "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 - %s" % (_redacted_uri_for_log(self.url), response.exception),
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 %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 %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 - %s" % (self.token_endpoint(), response.exception),
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 %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(f"OAuth client re-registration failed: {exc}. Please run `rsconnect login` again.")
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(f"OAuth token refresh failed: {exc}")
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 = AppStore(fake_module_file_from_directory(self.path))
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 primary_file_for_connect_cloud(self) -> str:
2169
- """The entrypoint to report to Connect Cloud, read from the built bundle.
2182
+ def bundle_manifest(self) -> dict[str, Any]:
2183
+ """The manifest of the built bundle.
2170
2184
 
2171
- Connect Cloud needs a `primary_file` and uses it to decide, for example,
2172
- whether Shiny content is R or Python. Every deploy path writes the
2173
- entrypoint into the bundle's manifest, so reading it back from there
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 determining the primary file.")
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
- manifest = json.loads(extracted.read().decode("utf-8"))
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 = connect_cloud.refresh(server.refresh_token, server.environment)
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: %s" % exc)
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: %s" % exc)
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: %s" % exc)
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
- body: dict[str, Any] = {
3698
- "revision_overrides": {
3699
- "primary_file": primary_file,
3700
- "app_mode": app_mode,
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
- with open(environment_file, "w") as f:
569
- f.write(environment.contents)
570
- created.append(environment_relative_path)
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(relative_path: str, ignore_path_set: set[Path] = directories_to_ignore) -> bool:
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
- files = list(filter(keep_manifest_specified_file, manifest.get("files", {}).keys()))
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 if excludes else []
1252
- glob_set = create_glob_set(path, excludes)
1253
- exclude_paths = {Path(p) for p in excludes}
1254
- file_set: set[str] = set(extra_files)
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
- path_to_add = abspath(path) if use_abspath else path
1258
- file_set.add(path_to_add)
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
- for cur_dir, _, files in os.walk(path):
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
- relevant_files = [file_name] + list(extra_files or [])
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
- with open(environment_file_path, "w") as f:
2369
- f.write(environment.contents)
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(