rsconnect-python 1.32.0__tar.gz → 1.33.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.32.0 → rsconnect_python-1.33.0}/PKG-INFO +17 -8
  2. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/README.md +13 -5
  3. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/pyproject.toml +6 -3
  4. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/pyproject.toml.orig +3 -3
  5. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/api.py +129 -101
  6. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/bundle.py +44 -6
  7. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/connect_cloud.py +2 -2
  8. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/environment.py +125 -53
  9. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/main.py +267 -120
  10. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/metadata.py +3 -3
  11. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/models.py +16 -60
  12. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/oauth.py +182 -1
  13. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/preflight.py +31 -21
  14. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/preflight_node.py +2 -2
  15. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/pyproject.py +19 -2
  16. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/subprocesses/inspect_environment.py +205 -37
  17. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/validation.py +6 -4
  18. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/LICENSE.md +0 -0
  19. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/__init__.py +0 -0
  20. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/actions.py +0 -0
  21. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/actions_content.py +0 -0
  22. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/actions_environment.py +0 -0
  23. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/actions_integration.py +0 -0
  24. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/certificates.py +0 -0
  25. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/device_login.py +0 -0
  26. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/environment_node.py +0 -0
  27. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/environment_r.py +0 -0
  28. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/exception.py +0 -0
  29. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/git_metadata.py +0 -0
  30. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/http_support.py +0 -0
  31. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/json_web_token.py +0 -0
  32. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/log.py +0 -0
  33. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/py.typed +0 -0
  34. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/__init__.py +0 -0
  35. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/quickstart.py +0 -0
  36. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/__init__.py +0 -0
  37. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/api/README.md.tmpl +0 -0
  38. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/api/__connect__.py.tmpl +0 -0
  39. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/api/__init__.py.tmpl +0 -0
  40. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/api/__main__.py.tmpl +0 -0
  41. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/api/app.py.tmpl +0 -0
  42. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/api/pyproject.toml.tmpl +0 -0
  43. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/fastapi/README.md.tmpl +0 -0
  44. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +0 -0
  45. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +0 -0
  46. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +0 -0
  47. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/fastapi/app.py.tmpl +0 -0
  48. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +0 -0
  49. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/notebook/README.md.tmpl +0 -0
  50. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +0 -0
  51. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +0 -0
  52. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/quarto/README.md.tmpl +0 -0
  53. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +0 -0
  54. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/quarto/report.qmd.tmpl +0 -0
  55. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/shiny/README.md.tmpl +0 -0
  56. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/shiny/app.py.tmpl +0 -0
  57. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +0 -0
  58. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/streamlit/README.md.tmpl +0 -0
  59. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/streamlit/app.py.tmpl +0 -0
  60. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +0 -0
  61. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/voila/README.md.tmpl +0 -0
  62. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +0 -0
  63. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/shiny_express.py +0 -0
  64. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/snowflake.py +0 -0
  65. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/subprocesses/__init__.py +0 -0
  66. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/timeouts.py +0 -0
  67. {rsconnect_python-1.32.0 → rsconnect_python-1.33.0}/rsconnect/utils_package.py +0 -0
  68. {rsconnect_python-1.32.0 → rsconnect_python-1.33.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.32.0
3
+ Version: 1.33.0
4
4
  Summary: The Posit Connect command-line interface.
5
5
  Author: Posit, PBC
6
6
  Author-email: Posit, PBC <rsconnect@posit.co>
@@ -26,7 +26,7 @@ License: # MIT License
26
26
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
27
  SOFTWARE.
28
28
  Requires-Dist: typing-extensions>=4.8.0
29
- Requires-Dist: pip>=10.0.0
29
+ Requires-Dist: pip>=20.3
30
30
  Requires-Dist: uv>=0.9.0
31
31
  Requires-Dist: semver>=2.0.0,<4.0.0
32
32
  Requires-Dist: pyjwt>=2.4.0 ; python_full_version < '3.9'
@@ -35,7 +35,8 @@ Requires-Dist: click>=8.0.0
35
35
  Requires-Dist: packaging>=20.0
36
36
  Requires-Dist: toml>=0.10 ; python_full_version < '3.11'
37
37
  Requires-Dist: keyring>=23.0.0 ; extra == 'keyring'
38
- Requires-Dist: snowflake-cli ; extra == 'snowflake'
38
+ Requires-Dist: snowflake-cli>=2.8.2 ; python_full_version < '3.10' and extra == 'snowflake'
39
+ Requires-Dist: snowflake-cli>=3.23.0 ; python_full_version >= '3.10' and extra == 'snowflake'
39
40
  Requires-Python: >=3.8
40
41
  Project-URL: Repository, http://github.com/posit-dev/rsconnect-python
41
42
  Project-URL: Documentation, https://docs.posit.co/rsconnect-python
@@ -69,12 +70,10 @@ python -m pip install rsconnect-python
69
70
 
70
71
  ## Usage with Posit Connect
71
72
 
72
- [Get an API key from your Posit Connect server](https://docs.posit.co/connect/user/api-keys/) with at least publisher privileges:
73
-
74
- Store your credentials:
73
+ Log in to your Posit Connect server:
75
74
 
76
75
  ```bash
77
- rsconnect add --server https://connect.example.com --api-key <YOUR-CONNECT-API-KEY> --name production
76
+ rsconnect login https://connect.example.com
78
77
  ```
79
78
 
80
79
  Deploy your application:
@@ -83,6 +82,16 @@ Deploy your application:
83
82
  rsconnect deploy shiny app.py --title "my shiny app"
84
83
  ```
85
84
 
85
+ Browser login requires Posit Connect 2026.02.0 or later. In remote containers,
86
+ or other sessions where the browser cannot reach the CLI's local callback, use
87
+ `--use-device-code` with Connect 2026.06.0 or later.
88
+
89
+ For older servers, or automation that cannot use identity tokens, [get an API
90
+ key](https://docs.posit.co/connect/user/api-keys/) and store it with
91
+ `rsconnect add`.
92
+ See the [authentication guide](https://docs.posit.co/rsconnect-python/#managing-server-information)
93
+ for identity token requirements and other login options.
94
+
86
95
  [Read more about publisher and admin capabilities on the docs site.](https://docs.posit.co/rsconnect-python)
87
96
 
88
97
  ## Usage with Posit Connect Cloud
@@ -99,7 +108,7 @@ Deploy your application:
99
108
  rsconnect deploy shiny app.py --name cloud --title "my shiny app"
100
109
  ```
101
110
 
102
- For non-interactive use such as CI, pass a service account credential with
111
+ For non-interactive use such as CI, pass client credentials with
103
112
  `--client-id` and `--client-secret`, or the `CONNECT_CLOUD_CLIENT_ID` and
104
113
  `CONNECT_CLOUD_CLIENT_SECRET` environment variables.
105
114
 
@@ -24,12 +24,10 @@ python -m pip install rsconnect-python
24
24
 
25
25
  ## Usage with Posit Connect
26
26
 
27
- [Get an API key from your Posit Connect server](https://docs.posit.co/connect/user/api-keys/) with at least publisher privileges:
28
-
29
- Store your credentials:
27
+ Log in to your Posit Connect server:
30
28
 
31
29
  ```bash
32
- rsconnect add --server https://connect.example.com --api-key <YOUR-CONNECT-API-KEY> --name production
30
+ rsconnect login https://connect.example.com
33
31
  ```
34
32
 
35
33
  Deploy your application:
@@ -38,6 +36,16 @@ Deploy your application:
38
36
  rsconnect deploy shiny app.py --title "my shiny app"
39
37
  ```
40
38
 
39
+ Browser login requires Posit Connect 2026.02.0 or later. In remote containers,
40
+ or other sessions where the browser cannot reach the CLI's local callback, use
41
+ `--use-device-code` with Connect 2026.06.0 or later.
42
+
43
+ For older servers, or automation that cannot use identity tokens, [get an API
44
+ key](https://docs.posit.co/connect/user/api-keys/) and store it with
45
+ `rsconnect add`.
46
+ See the [authentication guide](https://docs.posit.co/rsconnect-python/#managing-server-information)
47
+ for identity token requirements and other login options.
48
+
41
49
  [Read more about publisher and admin capabilities on the docs site.](https://docs.posit.co/rsconnect-python)
42
50
 
43
51
  ## Usage with Posit Connect Cloud
@@ -54,7 +62,7 @@ Deploy your application:
54
62
  rsconnect deploy shiny app.py --name cloud --title "my shiny app"
55
63
  ```
56
64
 
57
- For non-interactive use such as CI, pass a service account credential with
65
+ For non-interactive use such as CI, pass client credentials with
58
66
  `--client-id` and `--client-secret`, or the `CONNECT_CLOUD_CLIENT_ID` and
59
67
  `CONNECT_CLOUD_CLIENT_SECRET` environment variables.
60
68
 
@@ -2,10 +2,10 @@
2
2
  name = "rsconnect_python"
3
3
  description = "The Posit Connect command-line interface."
4
4
  requires-python = ">=3.8"
5
- version = "1.32.0"
5
+ version = "1.33.0"
6
6
  dependencies = [
7
7
  "typing-extensions>=4.8.0",
8
- "pip>=10.0.0",
8
+ "pip>=20.3",
9
9
  "uv>=0.9.0",
10
10
  "semver>=2.0.0,<4.0.0",
11
11
  "pyjwt>=2.4.0; python_version < '3.9'",
@@ -31,7 +31,10 @@ rsconnect = "rsconnect.main:cli"
31
31
 
32
32
  [project.optional-dependencies]
33
33
  keyring = ["keyring>=23.0.0"]
34
- snowflake = ["snowflake-cli"]
34
+ snowflake = [
35
+ "snowflake-cli>=2.8.2; python_version < '3.10'",
36
+ "snowflake-cli>=3.23.0; python_version >= '3.10'",
37
+ ]
35
38
 
36
39
  [project.urls]
37
40
  Repository = "http://github.com/posit-dev/rsconnect-python"
@@ -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.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'",]
5
+ version = "1.33.0"
6
+ dependencies = [ "typing-extensions>=4.8.0", "pip>=20.3", "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"
@@ -28,7 +28,7 @@ rsconnect = "rsconnect.main:cli"
28
28
 
29
29
  [project.optional-dependencies]
30
30
  keyring = [ "keyring>=23.0.0",]
31
- snowflake = [ "snowflake-cli",]
31
+ snowflake = [ "snowflake-cli>=2.8.2; python_version < '3.10'", "snowflake-cli>=3.23.0; python_version >= '3.10'",]
32
32
 
33
33
  [project.urls]
34
34
  Repository = "http://github.com/posit-dev/rsconnect-python"
@@ -86,7 +86,6 @@ from .models import (
86
86
  BootstrapOutputDTO,
87
87
  BuildOutputDTO,
88
88
  BundleMetadata,
89
- ContentItemV0,
90
89
  ContentItemV1,
91
90
  DeleteInputDTO,
92
91
  DeleteOutputDTO,
@@ -106,6 +105,7 @@ from .models import (
106
105
  ServerSettings,
107
106
  TaskStatusV1,
108
107
  UserRecord,
108
+ is_content_guid,
109
109
  )
110
110
  from .snowflake import generate_jwt, get_parameters
111
111
  from .timeouts import get_task_timeout, get_task_timeout_help_message
@@ -492,6 +492,16 @@ def server_supports_git_metadata(server_version: Optional[str]) -> bool:
492
492
  return False
493
493
 
494
494
 
495
+ # App modes that Connect renders (or serves as-is) at deploy time rather than running
496
+ # as a process. See RSConnectExecutor.is_rendered_content.
497
+ _RENDERED_APP_MODES = {
498
+ AppModes.RMD.name(),
499
+ AppModes.STATIC.name(),
500
+ AppModes.JUPYTER_NOTEBOOK.name(),
501
+ AppModes.STATIC_QUARTO.name(),
502
+ }
503
+
504
+
495
505
  def server_supports_draft_deploy(server_version: Optional[str]) -> bool:
496
506
  """
497
507
  Check if the server supports deploying a bundle as a draft and activating it
@@ -553,77 +563,29 @@ class RSConnectClient(BearerTokenHTTPServer):
553
563
  return isinstance(self._server, RSConnectServer) and bool(self._server.oauth_client_id)
554
564
 
555
565
  def _attempt_token_refresh(self) -> bool:
556
- from .oauth import (
557
- InvalidClientError,
558
- discover_oauth_metadata,
559
- keyring_delete_tokens,
560
- keyring_get_tokens,
561
- keyring_store_token,
562
- refresh_access_token,
563
- register_client,
564
- )
565
- from .metadata import ServerStore
566
+ from .oauth import InvalidClientError, NotLoggedInError, refresh_stored_tokens
566
567
 
567
568
  server = cast(RSConnectServer, self._server)
568
-
569
- # The keyring is where a login stores its tokens; the entry's own fields are
570
- # the fallback for a machine without one.
571
- _, refresh_token = keyring_get_tokens(server.url)
572
- if not refresh_token:
573
- entry = ServerStore().saved_entry(server.server_name, server.url)
574
- if entry:
575
- refresh_token = entry.get("oauth_refresh_token") # type: ignore[assignment]
576
- if not refresh_token:
577
- return False
578
-
579
569
  try:
580
- metadata = discover_oauth_metadata(server.url, server.insecure, server.ca_data)
581
- token_response = refresh_access_token(
582
- metadata, server.oauth_client_id or "", refresh_token, server.insecure, server.ca_data
570
+ new_access = refresh_stored_tokens(
571
+ server.url,
572
+ server.oauth_client_id or "",
573
+ server.server_name,
574
+ server.insecure,
575
+ server.ca_data,
576
+ rejected_access_token=server.oauth_access_token,
583
577
  )
578
+ except NotLoggedInError:
579
+ return False
584
580
  except InvalidClientError:
585
- # Client was deleted server-side; clear stale tokens and re-register
586
- keyring_delete_tokens(server.url)
587
- store = ServerStore()
588
- entry = store.saved_entry(server.server_name, server.url)
589
- if entry:
590
- entry_name = str(entry.get("name", server.server_name or server.url))
591
- store.update_oauth_tokens(entry_name, None, None, None)
592
- try:
593
- metadata = discover_oauth_metadata(server.url, server.insecure, server.ca_data)
594
- new_client_id = register_client(metadata, server.url, server.insecure, server.ca_data)
595
- server.oauth_client_id = new_client_id
596
- if entry:
597
- entry["oauth_client_id"] = new_client_id # type: ignore[typeddict-unknown-key]
598
- store._set(entry_name, entry) # type: ignore[possibly-undefined]
599
- logger.warning("OAuth client was re-registered; please run `rsconnect login` again.")
600
- except Exception as exc:
601
- logger.warning(
602
- "OAuth client re-registration failed (%s). Please run `rsconnect login` again." % type(exc).__name__
603
- )
581
+ logger.warning("The OAuth client was deleted on the server; please run `rsconnect login` again.")
604
582
  return False
605
583
  except Exception as exc:
606
584
  logger.warning("OAuth token refresh failed (%s)." % type(exc).__name__)
607
585
  return False
608
586
 
609
- new_access = token_response["access_token"]
610
- new_refresh = token_response.get("refresh_token", refresh_token)
611
- expires_in = token_response.get("expires_in")
612
- import time
613
-
614
- new_expiry = time.time() + expires_in if expires_in else None
615
-
616
587
  self.authorization(f"Bearer {new_access}")
617
588
  server.oauth_access_token = new_access
618
-
619
- stored = keyring_store_token(server.url, new_access, new_refresh)
620
- if not stored:
621
- store = ServerStore()
622
- entry = store.saved_entry(server.server_name, server.url)
623
- if entry:
624
- entry_name = str(entry.get("name", server.server_name or server.url))
625
- store.update_oauth_tokens(entry_name, new_access, new_refresh, new_expiry)
626
-
627
589
  return True
628
590
 
629
591
  def _tweak_response(self, response: HTTPResponse) -> JsonData | HTTPResponse:
@@ -639,7 +601,11 @@ class RSConnectClient(BearerTokenHTTPServer):
639
601
  return response
640
602
 
641
603
  def bootstrap(self) -> BootstrapOutputDTO | HTTPResponse:
642
- response = cast(Union[BootstrapOutputDTO, HTTPResponse], self.post("v1/experimental/bootstrap"))
604
+ response = cast(Union[BootstrapOutputDTO, HTTPResponse], self.post("v1/bootstrap"))
605
+ # Connect versions before 2024.05.0 only provide the experimental endpoint. The server version
606
+ # is not exposed to unauthenticated requests, so fall back based on the 404 instead.
607
+ if isinstance(response, HTTPResponse) and response.status == 404:
608
+ response = cast(Union[BootstrapOutputDTO, HTTPResponse], self.post("v1/experimental/bootstrap"))
643
609
  # TODO: The place where bootstrap() is called expects a JSON object if the response is successfule, and a
644
610
  # HTTPResponse if it is not; then it handles the error. This is different from the other methods, and probably
645
611
  # should be changed in the future. For this to work, we will _not_ call .handle_bad_response() here at present.
@@ -679,11 +645,6 @@ class RSConnectClient(BearerTokenHTTPServer):
679
645
  response = self.get("v1/server_settings/nodejs")
680
646
  return cast(typing.Dict[str, Any], self._server.handle_bad_response(response))
681
647
 
682
- def app_get(self, app_id: str) -> ContentItemV0:
683
- response = cast(Union[ContentItemV0, HTTPResponse], self.get(f"applications/{app_id}"))
684
- response = self._server.handle_bad_response(response)
685
- return response
686
-
687
648
  def add_environment_vars(self, content_guid: str, env_vars: list[tuple[str, str]]):
688
649
  env_body = [dict(name=kv[0], value=kv[1]) for kv in env_vars]
689
650
  return self.patch(f"v1/content/{content_guid}/environment", body=env_body)
@@ -738,22 +699,6 @@ class RSConnectClient(BearerTokenHTTPServer):
738
699
  response = self._server.handle_bad_response(response)
739
700
  return response
740
701
 
741
- def get_content_by_id(self, id: str) -> ContentItemV1:
742
- """
743
- Get content by ID, which can be either a numeric ID (legacy) or GUID.
744
-
745
- :param app_id: Either a numeric ID (e.g., "1234") or GUID (e.g., "abc-def-123")
746
- :return: ContentItemV1 data
747
- """
748
- # Check if it looks like a GUID (contains hyphens)
749
- if "-" in str(id):
750
- return self.content_get(id)
751
- else:
752
- # Legacy numeric ID - get v0 content first to get GUID
753
- app_v0 = self.app_get(id)
754
- # TODO: deprecation warning here
755
- return self.content_get(app_v0["guid"])
756
-
757
702
  def content_create(self, name: str) -> ContentItemV1:
758
703
  response = cast(Union[ContentItemV1, HTTPResponse], self.post("v1/content", body={"name": name}))
759
704
  response = self._server.handle_bad_response(response)
@@ -765,10 +710,10 @@ class RSConnectClient(BearerTokenHTTPServer):
765
710
  """
766
711
  Upload a bundle to the server.
767
712
 
768
- :param app_id: Application ID
713
+ :param content_guid: Content GUID
769
714
  :param tarball: Bundle tarball file object
770
715
  :param metadata: Optional metadata dictionary (e.g., git metadata)
771
- :return: ContentItemV0 with bundle information
716
+ :return: BundleMetadata for the uploaded bundle
772
717
  """
773
718
  if metadata:
774
719
  # Use multipart form upload when metadata is provided
@@ -966,7 +911,7 @@ class RSConnectClient(BearerTokenHTTPServer):
966
911
  Creates or updates a git-backed content item in Posit Connect. Connect will clone
967
912
  the repository and regularly poll it for updates.
968
913
 
969
- :param app_id: Existing content ID/GUID to update, or None to create new content
914
+ :param app_id: Existing content GUID to update, or None to create new content
970
915
  :param name: Name for the content item (used if creating new)
971
916
  :param repository: URL of the git repository (https:// only)
972
917
  :param branch: Branch to deploy from
@@ -982,11 +927,10 @@ class RSConnectClient(BearerTokenHTTPServer):
982
927
  app = self.content_create(name)
983
928
  else:
984
929
  try:
985
- app = self.get_content_by_id(app_id)
930
+ app = self.content_get(app_id)
986
931
  except RSConnectException as e:
987
- raise RSConnectException(
988
- f"{e} Try setting the --new flag or omit --app-id to create new content."
989
- ) from e
932
+ # app_id may have come from either --guid or --app-id (publish_args folds them together).
933
+ raise RSConnectException(f"{e} Omit --guid/--app-id to create new content instead.") from e
990
934
 
991
935
  app_guid = app["guid"]
992
936
 
@@ -1182,8 +1126,7 @@ class RSConnectClient(BearerTokenHTTPServer):
1182
1126
  else:
1183
1127
  # assume content exists. if it was deleted then Connect will raise an error
1184
1128
  try:
1185
- # app_id could be a numeric ID (legacy) or GUID
1186
- app = self.get_content_by_id(app_id)
1129
+ app = self.content_get(app_id)
1187
1130
  except RSConnectException as e:
1188
1131
  raise RSConnectException(f"{e} Try setting the --new flag to overwrite the previous deployment.") from e
1189
1132
 
@@ -1459,6 +1402,7 @@ class RSConnectExecutor:
1459
1402
  infer_target=(path is not None) if infer_target is None else infer_target,
1460
1403
  )
1461
1404
  self.setup_client(cookies)
1405
+ self.validate_explicit_app_id()
1462
1406
 
1463
1407
  @classmethod
1464
1408
  def fromConnectServer(
@@ -1515,6 +1459,22 @@ class RSConnectExecutor:
1515
1459
  polling=polling,
1516
1460
  )
1517
1461
 
1462
+ def validate_explicit_app_id(self) -> None:
1463
+ """Check an explicit --guid or --app-id against the kind of server it targets."""
1464
+ if self.app_id is None:
1465
+ return
1466
+ if isinstance(self.remote_server, (RSConnectServer, SPCSConnectServer)):
1467
+ if not is_content_guid(str(self.app_id)):
1468
+ raise RSConnectException(
1469
+ "--app-id must be a content GUID; numeric content IDs are no longer supported. "
1470
+ "Find the GUID in the content's Info panel in Connect or with `rsconnect content search`."
1471
+ )
1472
+ elif self.ctx is not None and self.ctx.params.get("guid"):
1473
+ raise RSConnectException(
1474
+ "--guid applies only to Posit Connect. "
1475
+ "Use --app-id to target existing Connect Cloud or shinyapps.io content."
1476
+ )
1477
+
1518
1478
  def output_overlap_header(self, previous: bool) -> bool:
1519
1479
  if self.logger and not previous:
1520
1480
  self.logger.warning(
@@ -2678,6 +2638,9 @@ for shinyapps.io. See command help for further details."
2678
2638
  # validate_connect_cloud_server, so without this check a viewer role is
2679
2639
  # caught only by the deploy -- after the source record has been removed.
2680
2640
  if not service.can_publish_to(account):
2641
+ sso_error = service.sso_blocked_error(account)
2642
+ if sso_error:
2643
+ raise sso_error
2681
2644
  raise RSConnectException(
2682
2645
  'You have access to the Posit Connect Cloud account "%s" but do not have '
2683
2646
  "permission to publish to it, so the next deploy from this directory would "
@@ -2739,12 +2702,33 @@ for shinyapps.io. See command help for further details."
2739
2702
  self._draft_deploy_supported = server_supports_draft_deploy(server_version)
2740
2703
  return self._draft_deploy_supported
2741
2704
 
2705
+ @property
2706
+ def is_rendered_content(self) -> bool:
2707
+ """Whether the bundle is rendered or static content rather than a running app.
2708
+
2709
+ Connect renders a document when its bundle is deployed and renders it again
2710
+ when a draft is activated, so verifying a draft first would render it twice.
2711
+ Requesting the rendered output also can't catch anything the deploy task's
2712
+ render didn't already, and Connect reverts to the previous bundle if an
2713
+ activating render fails.
2714
+ """
2715
+ app_mode = self.bundle_manifest().get("metadata", {}).get("appmode")
2716
+ return app_mode in _RENDERED_APP_MODES
2717
+
2718
+ def should_verify(self, no_verify: bool) -> bool:
2719
+ """Whether to verify the deployed content.
2720
+
2721
+ Rendered content is never verified, even as a draft: requesting a draft's
2722
+ rendered output only serves what the deploy task already rendered.
2723
+ """
2724
+ return not no_verify and not self.is_rendered_content
2725
+
2742
2726
  def should_deploy_as_draft(self, draft: bool, no_verify: bool) -> bool:
2743
2727
  """Whether the bundle should be deployed without activating it.
2744
2728
 
2745
2729
  An explicit ``--draft`` always deploys a draft. Otherwise we deploy a draft only
2746
2730
  when we are going to verify it before activating, which requires server support.
2747
- With ``--no-verify`` we activate immediately.
2731
+ With ``--no-verify``, or for rendered content, we activate immediately.
2748
2732
  """
2749
2733
  if draft:
2750
2734
  if not isinstance(self.client, RSConnectClient):
@@ -2756,10 +2740,21 @@ for shinyapps.io. See command help for further details."
2756
2740
  # would be the opposite of what the user asked for, so fail loudly.
2757
2741
  raise RSConnectException("Deploying as a draft requires Posit Connect 2025.06.0 or later.")
2758
2742
  return True
2759
- if no_verify:
2743
+ if not self.should_verify(no_verify):
2760
2744
  return False
2761
2745
  return self.supports_verify_before_activate
2762
2746
 
2747
+ def verify_and_activate(self, draft: bool, no_verify: bool):
2748
+ """Verify the deployed content, then activate it if it was deployed as a
2749
+ draft only so that it could be verified first."""
2750
+ if not self.should_verify(no_verify):
2751
+ return self
2752
+ self.verify_deployment()
2753
+ if not draft and self.deployed_info and self.deployed_info.get("draft_url"):
2754
+ # The draft bundle verified successfully, so activate it.
2755
+ self.activate_deployment().emit_task_log()
2756
+ return self
2757
+
2763
2758
  @cls_logged("Verifying deployed content...")
2764
2759
  def verify_deployment(self):
2765
2760
  if isinstance(self.remote_server, (RSConnectServer, SPCSConnectServer)):
@@ -2847,10 +2842,10 @@ for shinyapps.io. See command help for further details."
2847
2842
  elif app_id is not None:
2848
2843
  # Don't read app metadata if app-id is specified. Instead, we need
2849
2844
  # to get this from the remote.
2850
- if isinstance(self.remote_server, RSConnectServer):
2845
+ if isinstance(self.remote_server, (RSConnectServer, SPCSConnectServer)):
2851
2846
  try:
2852
2847
  with RSConnectClient(self.remote_server) as client:
2853
- content = client.get_content_by_id(app_id)
2848
+ content = client.content_get(app_id)
2854
2849
  existing_app_mode = AppModes.get_by_ordinal(content["app_mode"], True)
2855
2850
  except RSConnectException as e:
2856
2851
  raise RSConnectException(
@@ -3319,6 +3314,8 @@ class ConnectCloudAccount(TypedDict):
3319
3314
  id: str
3320
3315
  name: str
3321
3316
  permissions: NotRequired[list[str]]
3317
+ role: NotRequired[Optional[str]]
3318
+ sso_enabled: NotRequired[bool]
3322
3319
 
3323
3320
 
3324
3321
  class ConnectCloudAccountSearchResults(TypedDict):
@@ -3420,7 +3417,7 @@ class ConnectCloudClient(BearerTokenHTTPServer):
3420
3417
  def _attempt_token_refresh(self) -> bool:
3421
3418
  """Mint a new access token and apply it to this client.
3422
3419
 
3423
- Uses the client credentials grant when a service account credential is
3420
+ Uses the client credentials grant when client credentials are
3424
3421
  stored, and the refresh token otherwise. Returns whether a new token was
3425
3422
  obtained.
3426
3423
 
@@ -3447,8 +3444,8 @@ class ConnectCloudClient(BearerTokenHTTPServer):
3447
3444
  logger.warning("Posit Connect Cloud token refresh failed (%s)." % type(exc).__name__)
3448
3445
  return False
3449
3446
  raise RSConnectException(
3450
- "The Posit Connect Cloud service account credential was rejected — it has been revoked or "
3451
- "rotated. Create a new one at %s/identity/credentials, then save it with `%s`."
3447
+ "The Posit Connect Cloud client credentials were rejected — they have been revoked or "
3448
+ "rotated. Create new ones at %s/identity/credentials, then save them with `%s`."
3452
3449
  % (server.urls().auth, self._add_command(service_account=True))
3453
3450
  ) from exc
3454
3451
  except InvalidGrantError as exc:
@@ -3545,7 +3542,7 @@ class ConnectCloudClient(BearerTokenHTTPServer):
3545
3542
  # A refresh persists the new tokens and nothing else. Every other field
3546
3543
  # is taken from the saved entry alone — never from this run, which may
3547
3544
  # be publishing to a different account on the same login, or carrying
3548
- # service-account credentials from the environment that must not be
3545
+ # client credentials from the environment that must not be
3549
3546
  # grafted onto an interactively created entry. `rsconnect add` is what
3550
3547
  # changes those. This is how the R client's withTokenRefreshRetry()
3551
3548
  # writes back too. ServerStore.set writes the file itself, and omits
@@ -3650,6 +3647,9 @@ class ConnectCloudClient(BearerTokenHTTPServer):
3650
3647
  "skipping the publish permission check." % name
3651
3648
  )
3652
3649
  if not self._can_publish(account):
3650
+ sso_error = self._sso_blocked_error(self._server, account)
3651
+ if sso_error:
3652
+ raise sso_error
3653
3653
  raise RSConnectException(
3654
3654
  'You have access to the Posit Connect Cloud account "%s" but do not have '
3655
3655
  "permission to publish to it. Ask an account administrator for the publisher role." % name
@@ -3678,6 +3678,29 @@ class ConnectCloudClient(BearerTokenHTTPServer):
3678
3678
  return True
3679
3679
  return _CONNECT_CLOUD_PUBLISH_PERMISSION in permissions
3680
3680
 
3681
+ @staticmethod
3682
+ def _sso_blocked_error(server: ConnectCloudServer, account: ConnectCloudAccount) -> Optional[RSConnectException]:
3683
+ """The error for client credentials that SSO keeps from publishing to this account, if that is
3684
+ why the account cannot be published to.
3685
+
3686
+ The server does not grant content:create on SSO accounts to tokens without
3687
+ a recent SSO login, which client credentials never have.
3688
+ """
3689
+ if not (
3690
+ server.client_id
3691
+ and server.client_secret
3692
+ and account.get("sso_enabled")
3693
+ and account.get("role") in ("owner", "admin", "publisher")
3694
+ ):
3695
+ return None
3696
+ name = account.get("name")
3697
+ return RSConnectException(
3698
+ 'The Posit Connect Cloud account "%s" requires single sign-on (SSO). '
3699
+ "Client credentials cannot publish to accounts that require SSO. Log in interactively "
3700
+ "with SSO instead: run `rsconnect add --connect-cloud -n <nickname> -A %s` without "
3701
+ "client credentials." % (name, name)
3702
+ )
3703
+
3681
3704
  def get_content(self, content_id: str) -> ConnectCloudContent:
3682
3705
  response = cast(Union[ConnectCloudContent, HTTPResponse], self.get(f"/contents/{content_id}"))
3683
3706
  content = self._server.handle_bad_response(response)
@@ -4100,6 +4123,12 @@ class ConnectCloudService:
4100
4123
  """
4101
4124
  return ConnectCloudClient._can_publish(account)
4102
4125
 
4126
+ def sso_blocked_error(self, account: ConnectCloudAccount) -> Optional[RSConnectException]:
4127
+ """The error get_account_by_name raises when SSO keeps client credentials from
4128
+ publishing to this account, or None when that is not why publishing is refused.
4129
+ """
4130
+ return ConnectCloudClient._sso_blocked_error(self._server, account)
4131
+
4103
4132
  def content_url(self, content_id: str, account_id: Optional[str]) -> str:
4104
4133
  """Build the browsable URL for a content item.
4105
4134
 
@@ -4306,8 +4335,7 @@ def emit_task_log(
4306
4335
  with RSConnectClient(connect_server) as client:
4307
4336
  result = client.wait_for_task(task_id, log_callback, abort_func, timeout, poll_wait, raise_on_error)
4308
4337
  result = connect_server.handle_bad_response(result)
4309
- # Get content (handles both numeric IDs and GUIDs)
4310
- content = client.get_content_by_id(app_id)
4338
+ content = client.content_get(app_id)
4311
4339
  app_url = content["dashboard_url"]
4312
4340
  return (app_url, *result)
4313
4341