From 129fddddf85406903ed5b52c0c4525b8ff519a84 Mon Sep 17 00:00:00 2001 From: Neal Richardson Date: Wed, 1 Jul 2026 15:38:14 -0400 Subject: [PATCH] Support CONNECT_SERVER_VERSION env var for feature checks Some Connect servers can be configured to suppress their version from the server_settings endpoint, which makes version-gated features (draft deploys, git metadata) default to off. Add a centralized RSConnectClient.server_version() that returns CONNECT_SERVER_VERSION when set (skipping the server_settings request) and otherwise falls back to the reported version. Route all version lookups through it. Fixes #807 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/CHANGELOG.md | 6 ++++++ rsconnect/api.py | 20 ++++++++++++++++++- rsconnect/main.py | 20 +++++++++---------- tests/test_api.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 11 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index beb313bd2..eceb3fced 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- The `CONNECT_SERVER_VERSION` environment variable can now be set to tell + rsconnect-python which Connect version to assume for feature-availability + checks. Some servers can be configured to suppress their version, which makes + version-gated features (such as draft deploys and git metadata) default to + off; setting `CONNECT_SERVER_VERSION` opts back in. When it is set, its value + is used directly and the `server_settings` request for the version is skipped. - `rsconnect deploy` commands now verify content before activating it. The new bundle is deployed as a draft, its preview URL is accessed to confirm the content starts, and only then is the bundle activated. If verification fails, diff --git a/rsconnect/api.py b/rsconnect/api.py index 48eb3a7d3..c909a9dcb 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -606,6 +606,24 @@ def server_settings(self) -> ServerSettings: response = self._server.handle_bad_response(response) return response + def server_version(self) -> str: + """ + Determine the Connect server version used for feature-availability checks. + + A server can be configured to suppress its version from the + ``server_settings`` endpoint, which makes version-gated features default to + "off". Setting the ``CONNECT_SERVER_VERSION`` environment variable overrides + this: when it is set we use it and skip the ``server_settings`` request + entirely, so the library acts as if it is talking to that version. + + :return: The server version string, or an empty string if it is unknown. + """ + env_version = os.environ.get("CONNECT_SERVER_VERSION") + if env_version: + logger.debug(f"Using CONNECT_SERVER_VERSION={env_version} for server version checks") + return env_version + return self.server_settings().get("version", "") + def python_settings(self) -> PyInfo: response = cast(Union[PyInfo, HTTPResponse], self.get("v1/server_settings/python")) response = self._server.handle_bad_response(response) @@ -1788,7 +1806,7 @@ def supports_verify_before_activate(self) -> bool: return False if self._draft_deploy_supported is None: try: - server_version = self.client.server_settings().get("version", "") + server_version = self.client.server_version() except Exception: server_version = None self._draft_deploy_supported = server_supports_draft_deploy(server_version) diff --git a/rsconnect/main.py b/rsconnect/main.py index c2df862fb..733477422 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -1549,7 +1549,7 @@ def deploy_notebook( # Prepare metadata for upload server_version = None if isinstance(ce.client, RSConnectClient): - server_version = ce.client.server_settings().get("version", "") + server_version = ce.client.server_version() deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) ce.metadata = deploy_metadata @@ -1727,7 +1727,7 @@ def deploy_voila( # Prepare metadata for upload server_version = None if isinstance(ce.client, RSConnectClient): - server_version = ce.client.server_settings().get("version", "") + server_version = ce.client.server_version() base_dir = path if isdir(path) else dirname(path) deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) ce.metadata = deploy_metadata @@ -1825,7 +1825,7 @@ def deploy_manifest( # Prepare metadata for upload server_version = None if isinstance(ce.client, RSConnectClient): - server_version = ce.client.server_settings().get("version", "") + server_version = ce.client.server_version() base_dir = dirname(file_name) deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) ce.metadata = deploy_metadata @@ -1920,7 +1920,7 @@ def deploy_bundle( # explicit --metadata overrides are sent. server_version = None if isinstance(ce.client, RSConnectClient): - server_version = ce.client.server_settings().get("version", "") + server_version = ce.client.server_version() ce.metadata = prepare_deploy_metadata(None, metadata, no_metadata, server_version) ( @@ -2134,7 +2134,7 @@ def quickstart_hint() -> str: server_version = None if isinstance(ce.client, RSConnectClient): - server_version = ce.client.server_settings().get("version", "") + server_version = ce.client.server_version() ce.metadata = prepare_deploy_metadata(directory, metadata, no_metadata, server_version) ( @@ -2406,7 +2406,7 @@ def deploy_quarto( # Prepare metadata for upload server_version = None if isinstance(ce.client, RSConnectClient): - server_version = ce.client.server_settings().get("version", "") + server_version = ce.client.server_version() deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) ce.metadata = deploy_metadata @@ -2521,7 +2521,7 @@ def deploy_tensorflow( # Prepare metadata for upload server_version = None if isinstance(ce.client, RSConnectClient): - server_version = ce.client.server_settings().get("version", "") + server_version = ce.client.server_version() deploy_metadata = prepare_deploy_metadata(directory, metadata, no_metadata, server_version) ce.metadata = deploy_metadata @@ -2647,7 +2647,7 @@ def deploy_html( # Prepare metadata for upload server_version = None if isinstance(ce.client, RSConnectClient): - server_version = ce.client.server_settings().get("version", "") + server_version = ce.client.server_version() base_dir = path if isdir(path) else dirname(path) deploy_metadata = prepare_deploy_metadata(base_dir, metadata, no_metadata, server_version) ce.metadata = deploy_metadata @@ -2866,7 +2866,7 @@ def deploy_app( # Update the starlette version if needed. After all users are on Connect # 2024.01.1 or later, this can be removed. Requires access to the # Connect server version, which may be hidden. - connect_version_string = ce.client.server_settings().get("version", "") + connect_version_string = ce.client.server_version() server_version = connect_version_string if connect_version_string: environment = fix_starlette_requirements( @@ -3044,7 +3044,7 @@ def deploy_nodejs( ) if isinstance(ce.client, RSConnectClient): - connect_version_string = ce.client.server_settings().get("version", "") + connect_version_string = ce.client.server_version() server_version = connect_version_string deploy_metadata = prepare_deploy_metadata(directory, metadata, no_metadata, server_version) diff --git a/tests/test_api.py b/tests/test_api.py index 3d75b7996..15a0fa52a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,5 +1,6 @@ import io import json +import os import sys from unittest import TestCase from unittest.mock import Mock, patch @@ -1122,3 +1123,53 @@ def test_deploy_git_requires_repository(self): with pytest.raises(RSConnectException, match="Repository URL is required"): RSConnectExecutor.deploy_git(executor) + + +class RSConnectClientServerVersionTestCase(TestCase): + """Tests for RSConnectClient.server_version() and the CONNECT_SERVER_VERSION override.""" + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_server_version_from_settings(self): + """Without the env var, the version comes from the server_settings endpoint.""" + server = RSConnectServer("http://test-server", "api_key") + client = RSConnectClient(server) + httpretty.register_uri( + httpretty.GET, + "http://test-server/__api__/server_settings", + body=json.dumps({"hostname": "test-server", "version": "2025.06.0"}), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + with patch.dict("os.environ", {}, clear=False): + os.environ.pop("CONNECT_SERVER_VERSION", None) + self.assertEqual(client.server_version(), "2025.06.0") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_server_version_hidden_returns_empty(self): + """A suppressed version yields an empty string when the env var is unset.""" + server = RSConnectServer("http://test-server", "api_key") + client = RSConnectClient(server) + httpretty.register_uri( + httpretty.GET, + "http://test-server/__api__/server_settings", + body=json.dumps({"hostname": "test-server"}), + status=200, + forcing_headers={"Content-Type": "application/json"}, + ) + + with patch.dict("os.environ", {}, clear=False): + os.environ.pop("CONNECT_SERVER_VERSION", None) + self.assertEqual(client.server_version(), "") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_server_version_env_var_overrides_without_request(self): + """When CONNECT_SERVER_VERSION is set, it is returned without hitting the server.""" + server = RSConnectServer("http://test-server", "api_key") + client = RSConnectClient(server) + + with patch.dict("os.environ", {"CONNECT_SERVER_VERSION": "2025.12.0"}): + self.assertEqual(client.server_version(), "2025.12.0") + + # No request to server_settings should have been made. + self.assertFalse(httpretty.has_request())