From e7d6c2499a7211dfc169d01e9496230e187c0938 Mon Sep 17 00:00:00 2001 From: Sam Perman Date: Tue, 8 Sep 2026 17:37:13 -0400 Subject: [PATCH 1/4] fix: send the Python version to Connect Cloud Connect Cloud picks the interpreter from the revision's python_version field on POST /v1/contents and PATCH /v1/contents/{id}. rsconnect never sent it, and Connect Cloud does not read the version out of the bundle's manifest, so every deploy built with its fallback of Python 3.9 no matter what the project asked for. Content needing 3.10+ failed during dependency resolution. Resolve a MAJOR.MINOR version from the manifest the bundle already carries and send it on both requests. Reading the bundle rather than the environment covers `deploy manifest` too, which does no environment inspection of its own. The interpreter the bundle was built against wins when Connect Cloud offers that MAJOR.MINOR and the constraint allows it. Otherwise the lowest offered version satisfying the constraint is used, so ">=3.10" stays on 3.10 as Connect Cloud adds newer ones. A constraint no offered version satisfies now fails before the bundle is uploaded. Both payloads omit the field when there is no Python version to send, so R content deploys unchanged and a redeploy does not clear a version picked in the Connect Cloud UI. r_version and quarto_version are accepted on the same payloads and are still never sent; that is left for a separate change. --- docs/CHANGELOG.md | 9 +++ rsconnect/api.py | 79 +++++++++++++++++------ rsconnect/connect_cloud.py | 105 ++++++++++++++++++++++++++++++ tests/test_connect_cloud.py | 124 +++++++++++++++++++++++++++++++++++- 4 files changed, 296 insertions(+), 21 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c48fde31..785068b2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Deploys to Posit Connect Cloud now request the Python version the content needs, + instead of leaving Connect Cloud to fall back to Python 3.9. The version comes from + the same `.python-version`, `pyproject.toml`, or `setup.cfg` requirement that already + goes into the manifest; Connect Cloud does not read it out of the bundle. Connect + Cloud offers Python 3.9 through 3.14, and a project requiring something outside that + range now fails before the bundle is uploaded rather than during dependency + resolution. Content whose bundle has no Python keeps the version already set on it, + so a version chosen in the Connect Cloud UI survives a redeploy. + ## [1.31.0] - 2026-09-08 - Posit Connect Cloud is now a supported deployment target, alongside Posit diff --git a/rsconnect/api.py b/rsconnect/api.py index f612d77b..cb013c45 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -2165,16 +2165,15 @@ def upload_posit_bundle(self, prepare_deploy_result: PrepareDeployResult, bundle ) upload_result = S3Server(upload_url).handle_bad_response(upload_result, is_httpresponse=True) - def primary_file_for_connect_cloud(self) -> str: - """The entrypoint to report to Connect Cloud, read from the built bundle. + def bundle_manifest(self) -> dict[str, Any]: + """The manifest of the built bundle. - Connect Cloud needs a `primary_file` and uses it to decide, for example, - whether Shiny content is R or Python. Every deploy path writes the - entrypoint into the bundle's manifest, so reading it back from there - avoids having to thread it separately through each command. + Every deploy path writes what Connect Cloud needs into the bundle's + manifest, so reading it back from there avoids having to thread the same + values separately through each command. """ if self.bundle is None: - raise RSConnectException("A bundle must be created before determining the primary file.") + raise RSConnectException("A bundle must be created before reading its manifest.") position = self.bundle.tell() try: @@ -2186,12 +2185,33 @@ def primary_file_for_connect_cloud(self) -> str: extracted = tar.extractfile(member) if member is not None else None if extracted is None: raise RSConnectException("The bundle does not contain a manifest.json.") - manifest = json.loads(extracted.read().decode("utf-8")) + return cast("dict[str, Any]", json.loads(extracted.read().decode("utf-8"))) except (tarfile.TarError, KeyError, ValueError) as exc: raise RSConnectException("Could not read the bundle manifest: %s" % exc) from exc finally: self.bundle.seek(position) + def python_version_for_connect_cloud(self) -> Optional[str]: + """The MAJOR.MINOR Python version to ask Connect Cloud for, or None. + + None means the request omits the field, leaving the version Connect Cloud + already has on the content. Content with no Python at all lands here too. + """ + manifest = self.bundle_manifest() + environment: dict[str, Any] = manifest.get("environment") or {} + requirement: dict[str, Any] = environment.get("python") or {} + interpreter: dict[str, Any] = manifest.get("python") or {} + requires: Optional[str] = requirement.get("requires") + local_version: Optional[str] = interpreter.get("version") + return connect_cloud.resolve_python_version(requires, local_version) + + def primary_file_for_connect_cloud(self) -> str: + """The entrypoint to report to Connect Cloud, read from the built bundle. + + Connect Cloud needs a `primary_file` and uses it to decide, for example, + whether Shiny content is R or Python. + """ + manifest = self.bundle_manifest() metadata = manifest.get("metadata") or {} primary_file = metadata.get("entrypoint") or metadata.get("primary_rmd") or metadata.get("primary_html") files = manifest.get("files") or {} @@ -2302,6 +2322,7 @@ def deploy_bundle(self, activate: bool = True): title=self.title, app_mode=self.app_mode, primary_file=self.primary_file_for_connect_cloud(), + python_version=self.python_version_for_connect_cloud(), env_vars=self.env_vars, update_title=not self.title_is_default, app_id_is_explicit=self.app_id_is_explicit, @@ -3293,6 +3314,7 @@ class ConnectCloudAccountSearchResults(TypedDict): class ConnectCloudRevision(TypedDict): id: str content_id: NotRequired[str] + python_version: NotRequired[Optional[str]] status: NotRequired[str] source_bundle_upload_url: NotRequired[str] publish_result: NotRequired[Optional[str]] @@ -3648,21 +3670,29 @@ def create_content( primary_file: str, secrets: Optional[list[dict[str, str]]] = None, access: Optional[str] = None, + python_version: Optional[str] = None, ) -> ConnectCloudContent: """Create content to upload a bundle into. `access` is the content's visibility ("public" or "private"). Omitted from the request when None so the server picks its own default. + + `python_version` is MAJOR.MINOR. Omitted when None, which is how content + with no Python is created; Connect Cloud then falls back to its own + default rather than reading the version out of the bundle's manifest. """ + next_revision: dict[str, Any] = { + "source_type": "bundle", + "content_type": content_type, + "app_mode": app_mode, + "primary_file": primary_file, + } + if python_version is not None: + next_revision["python_version"] = python_version body: dict[str, Any] = { "account_id": account_id, "title": title, - "next_revision": { - "source_type": "bundle", - "content_type": content_type, - "app_mode": app_mode, - "primary_file": primary_file, - }, + "next_revision": next_revision, "secrets": secrets or [], } if access is not None: @@ -3680,6 +3710,7 @@ def update_content( new_bundle: bool = True, title: Optional[str] = None, access: Optional[str] = None, + python_version: Optional[str] = None, ) -> ConnectCloudContent: """Update content, optionally minting a fresh revision to upload into. @@ -3693,14 +3724,19 @@ def update_content( deploy without -E/-t must leave the existing values alone. `access` (the content's visibility) is omitted the same way, so a redeploy without -V keeps whatever visibility the content already has. + + `python_version` is omitted when None for the same reason: a redeploy of + content whose bundle carries no Python version keeps the version already + set on the content, including one chosen in the Connect Cloud UI. """ - body: dict[str, Any] = { - "revision_overrides": { - "primary_file": primary_file, - "app_mode": app_mode, - "content_type": content_type, - }, + revision_overrides: dict[str, Any] = { + "primary_file": primary_file, + "app_mode": app_mode, + "content_type": content_type, } + if python_version is not None: + revision_overrides["python_version"] = python_version + body: dict[str, Any] = {"revision_overrides": revision_overrides} if secrets is not None: body["secrets"] = secrets if title is not None: @@ -3895,6 +3931,7 @@ def prepare_deploy( update_title: bool = False, app_id_is_explicit: bool = False, visibility: Optional[str] = None, + python_version: Optional[str] = None, ) -> ConnectCloudDeployResult: """Create or fetch the content item and get a revision to upload into. @@ -3961,6 +3998,7 @@ def prepare_deploy( content_type=content_type, app_mode=app_mode.name(), primary_file=primary_file, + python_version=python_version, secrets=secrets, access=visibility, ) @@ -3975,6 +4013,7 @@ def prepare_deploy( primary_file=primary_file, app_mode=app_mode.name(), content_type=content_type, + python_version=python_version, secrets=secrets, new_bundle=upload, title=title if update_title and title else None, diff --git a/rsconnect/connect_cloud.py b/rsconnect/connect_cloud.py index 80a1fe38..46a04c2e 100644 --- a/rsconnect/connect_cloud.py +++ b/rsconnect/connect_cloud.py @@ -12,7 +12,11 @@ from typing import Any, NamedTuple, Optional from urllib.parse import urlparse +from packaging.specifiers import InvalidSpecifier, Specifier, SpecifierSet +from packaging.version import InvalidVersion, Version + from .exception import RSConnectException +from .log import logger from .oauth import ( ACCESS_TOKEN_FIELD, CLIENT_SECRET_FIELD, @@ -313,3 +317,104 @@ def credentials_from_keyring(url: str, nickname: str) -> tuple[Optional[str], Op def delete_credentials_from_keyring(url: str, nickname: str) -> None: """Delete a saved credential's secrets from the system keyring.""" keyring_delete_values(keyring_key(url, nickname), _CREDENTIAL_FIELDS) + + +# The MAJOR.MINOR values Connect Cloud accepts for a revision's python_version, +# matching the RequestPythonVersion enum in its OpenAPI schema +# (https://api.connect.posit.cloud/openapi.json). Ordered lowest first. +PYTHON_VERSIONS = ("3.9", "3.10", "3.11", "3.12", "3.13", "3.14") + + +def _clause_version(clause: Specifier) -> Optional[Version]: + """The version a single specifier clause names, or None if it cannot be parsed. + + Strips the wildcard from "==3.11.*", which is not a version on its own. + """ + try: + return Version(clause.version.rstrip("*").rstrip(".")) + except InvalidVersion: + return None + + +def _line_satisfies(minor: str, specifier: SpecifierSet) -> bool: + """Whether any patch release of a MAJOR.MINOR line satisfies the constraint. + + Connect Cloud names only the minor line, never the patch it runs, so + ">=3.11.3" has to be judged against the whole 3.11 line rather than against + 3.11.0 alone -- otherwise a constraint Connect Cloud can actually meet is + reported as unsupported. + + A SpecifierSet exposes no interval to test a range against, so this tests the + patches the constraint itself names, one above each of them, and the bottom of + the line. Every edge of the region a PEP 440 constraint admits falls on one of + those, so a region with anything in it contains one of them. + """ + line = Version(minor).release[:2] + patches = {0} + for clause in specifier: + version = _clause_version(clause) + if version is None or version.release[:2] != line: + continue + patch = version.release[2] if len(version.release) > 2 else 0 + patches.update((patch, patch + 1)) + return any(Version("%s.%d" % (minor, patch)) in specifier for patch in sorted(patches)) + + +def _minor_version(version: str) -> Optional[str]: + """The MAJOR.MINOR prefix of a version string, or None if it has no minor part.""" + parts = version.strip().split(".") + if len(parts) < 2 or not parts[0].isdigit() or not parts[1].isdigit(): + return None + return "%s.%s" % (parts[0], parts[1]) + + +def resolve_python_version(requires: Optional[str], local_version: Optional[str]) -> Optional[str]: + """Pick the Connect Cloud Python version for a deploy. + + `requires` is the PEP 440 constraint from the manifest's + ``environment.python.requires``; `local_version` is the interpreter the + bundle was built against, from ``python.version``. Returns None when neither + is usable, which leaves the field off the request so Connect Cloud keeps + whatever the content already has. + + The interpreter the content was built against wins when Connect Cloud offers + its MAJOR.MINOR and the constraint allows it, so a deploy reproduces the + development environment. Otherwise the lowest offered line satisfying the + constraint is used, which keeps ">=3.10" on the same version as Connect Cloud + adds newer ones. + + Connect Cloud picks the patch itself, so a constraint that names one ("==3.11.14") + can only be honored as far as its minor line. + """ + specifier = None + if requires: + try: + specifier = SpecifierSet(requires) + except InvalidSpecifier: + logger.warning( + "Ignoring the manifest's Python version requirement, which is not a valid " + "PEP 440 constraint: %s" % requires + ) + + if local_version: + local_minor = _minor_version(local_version) + try: + allowed = specifier is None or Version(local_version) in specifier + except InvalidVersion: + allowed = False + local_minor = None + if local_minor in PYTHON_VERSIONS and allowed: + return local_minor + + if specifier is None: + return None + + for candidate in PYTHON_VERSIONS: + if _line_satisfies(candidate, specifier): + return candidate + + raise RSConnectException( + "This content requires Python %s, which Posit Connect Cloud does not offer. " + "Supported versions are %s. Change the requirement in .python-version, " + "pyproject.toml, or setup.cfg." % (requires, ", ".join(PYTHON_VERSIONS)) + ) diff --git a/tests/test_connect_cloud.py b/tests/test_connect_cloud.py index 3507ccd1..506a9623 100644 --- a/tests/test_connect_cloud.py +++ b/tests/test_connect_cloud.py @@ -577,6 +577,15 @@ def test_create_content_with_access_sets_the_visibility(self): def test_create_content_without_access_takes_the_server_default(self): self.assertNotIn("access", self._create_content()) + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_create_content_sends_the_python_version(self): + body = self._create_content(python_version="3.12") + self.assertEqual(body["next_revision"]["python_version"], "3.12") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_create_content_without_a_python_version_omits_it(self): + self.assertNotIn("python_version", self._create_content()["next_revision"]) + def _update_content(self, **kwargs): """PATCH /contents/c1 with the always-required arguments; returns the request body.""" _register_json(httpretty.PATCH, f"{API}/contents/c1", {"id": "c1", "next_revision": {"id": "r2"}}) @@ -597,6 +606,17 @@ def test_update_content_sends_the_whole_revision_override_set(self): ) self.assertEqual(httpretty.last_request().querystring["new_bundle"], ["true"]) + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_update_content_sends_the_python_version(self): + body = self._update_content(python_version="3.12") + self.assertEqual(body["revision_overrides"]["python_version"], "3.12") + + @httpretty.activate(verbose=True, allow_net_connect=False) + def test_update_content_without_a_python_version_keeps_the_stored_one(self): + # A redeploy of content with no Python must not clear a version the user + # picked in the Connect Cloud UI. + self.assertNotIn("python_version", self._update_content()["revision_overrides"]) + @httpretty.activate(verbose=True, allow_net_connect=False) def test_update_content_without_secrets_leaves_them_alone(self): # The API replaces the whole secret set with whatever is sent, so a deploy @@ -1854,11 +1874,12 @@ def test_content_types_are_the_eight_the_api_accepts(self): ) -def _bundle_with_manifest(metadata, files=None, manifest_path="manifest.json"): +def _bundle_with_manifest(metadata, files=None, manifest_path="manifest.json", **extra): """A minimal gzipped bundle containing just a manifest.json.""" body = {"version": 1, "metadata": metadata} if files is not None: body["files"] = {name: {"checksum": "0"} for name in files} + body.update(extra) manifest = json.dumps(body).encode("utf-8") buffer = io.BytesIO() with tarfile.open(mode="w:gz", fileobj=buffer) as tar: @@ -1869,6 +1890,100 @@ def _bundle_with_manifest(metadata, files=None, manifest_path="manifest.json"): return buffer +class TestResolvePythonVersion(unittest.TestCase): + def test_uses_the_interpreter_the_bundle_was_built_against(self): + self.assertEqual(connect_cloud.resolve_python_version("~=3.12.0", "3.12.4"), "3.12") + + def test_falls_back_to_the_lowest_version_satisfying_the_constraint(self): + # Developed on 3.9, but the project requires 3.10+. + self.assertEqual(connect_cloud.resolve_python_version(">=3.10", "3.9.6"), "3.10") + + def test_open_ended_constraint_does_not_track_new_versions(self): + # ">=3.10" must keep landing on 3.10 as Connect Cloud adds newer versions. + self.assertEqual(connect_cloud.resolve_python_version(">=3.10", None), "3.10") + + def test_wildcard_constraint(self): + self.assertEqual(connect_cloud.resolve_python_version("==3.11.*", "3.12.4"), "3.11") + + def test_no_constraint_uses_the_local_interpreter(self): + self.assertEqual(connect_cloud.resolve_python_version(None, "3.13.1"), "3.13") + + def test_local_interpreter_connect_cloud_does_not_offer_is_not_used(self): + self.assertEqual(connect_cloud.resolve_python_version(">=3.8", "3.8.10"), "3.9") + + def test_patch_constraint_resolves_to_its_minor_line(self): + # Connect Cloud picks the patch itself, so ">=3.11.3" is honored as 3.11 + # rather than reported as unsupported. + self.assertEqual(connect_cloud.resolve_python_version(">=3.11.3,<3.12", None), "3.11") + + def test_exact_patch_constraint_resolves_to_its_minor_line(self): + self.assertEqual(connect_cloud.resolve_python_version("==3.11.2", None), "3.11") + + def test_patch_constraint_the_local_interpreter_fails(self): + # Built on 3.12 but constrained to a 3.11 patch: the 3.11 line still serves. + self.assertEqual(connect_cloud.resolve_python_version(">=3.11.3,<3.12", "3.12.4"), "3.11") + + def test_high_patch_numbers_are_not_a_cutoff(self): + # Both name the same minor line, so both must resolve the same way. + self.assertEqual(connect_cloud.resolve_python_version("==3.11.99", None), "3.11") + self.assertEqual(connect_cloud.resolve_python_version("==3.11.100", None), "3.11") + + def test_patch_constraint_on_the_newest_line(self): + self.assertEqual(connect_cloud.resolve_python_version(">=3.14.99,<3.15", None), "3.14") + + def test_excluded_patch_does_not_rule_out_the_line(self): + self.assertEqual(connect_cloud.resolve_python_version("!=3.9.5,<3.10", None), "3.9") + + def test_nothing_to_go_on(self): + self.assertIsNone(connect_cloud.resolve_python_version(None, None)) + + def test_unparsable_constraint_is_ignored(self): + self.assertEqual(connect_cloud.resolve_python_version("not-a-constraint", "3.12.4"), "3.12") + + def test_unparsable_constraint_with_no_local_version(self): + self.assertIsNone(connect_cloud.resolve_python_version("not-a-constraint", None)) + + def test_unsupported_version_is_an_error_before_the_deploy(self): + with self.assertRaises(RSConnectException) as context: + connect_cloud.resolve_python_version("~=3.8.0", "3.8.10") + self.assertIn("does not offer", str(context.exception)) + self.assertIn("3.9, 3.10, 3.11, 3.12, 3.13, 3.14", str(context.exception)) + + def test_version_above_the_supported_range_is_an_error(self): + with self.assertRaises(RSConnectException): + connect_cloud.resolve_python_version(">=3.20", None) + + +class TestConnectCloudPythonVersionFromBundle(unittest.TestCase): + def _executor(self, bundle): + executor = RSConnectExecutor.__new__(RSConnectExecutor) + executor.bundle = bundle + executor.path = "/deploys/some-project" + executor.quarto_inputs = None + return executor + + def _bundle(self, **extra): + return _bundle_with_manifest({"appmode": "python-shiny", "entrypoint": "app.py"}, **extra) + + def test_reads_the_requirement_and_the_interpreter_from_the_manifest(self): + executor = self._executor( + self._bundle( + python={"version": "3.12.4"}, + environment={"python": {"requires": "~=3.12.0"}}, + ) + ) + self.assertEqual(executor.python_version_for_connect_cloud(), "3.12") + + def test_manifest_with_no_requirement_uses_the_interpreter(self): + executor = self._executor(self._bundle(python={"version": "3.13.1"})) + self.assertEqual(executor.python_version_for_connect_cloud(), "3.13") + + def test_manifest_with_no_python_section_sends_nothing(self): + # R and Quarto-with-R content has no Python at all. + executor = self._executor(self._bundle()) + self.assertIsNone(executor.python_version_for_connect_cloud()) + + class TestConnectCloudPrimaryFile(unittest.TestCase): def _executor(self, bundle, path="/deploys/some-project", quarto_inputs=None): executor = RSConnectExecutor.__new__(RSConnectExecutor) @@ -2130,6 +2245,13 @@ def test_prepare_deploy_creates_content_when_no_app_id(self): self.assertEqual(result.upload_url, "https://up.example/1") self.assertEqual(result.app_url, "https://connect.posit.cloud/acme/content/c1") + def test_prepare_deploy_passes_the_python_version_through(self): + self._prepare_deploy(python_version="3.12") + self.assertEqual(self.client.create_content.call_args.kwargs["python_version"], "3.12") + + self._prepare_deploy(app_id="c1", python_version="3.12") + self.assertEqual(self.client.update_content.call_args.kwargs["python_version"], "3.12") + def test_prepare_deploy_without_env_vars_does_not_touch_secrets(self): # env_vars is empty when no -E was given; the PATCH must then omit # secrets entirely (None) so existing ones are not deleted. From 2a921fedf9fadca26afce2dc983fad8e480bf9a7 Mon Sep 17 00:00:00 2001 From: Sam Perman Date: Tue, 8 Sep 2026 20:27:28 -0400 Subject: [PATCH 2/4] fix: let Connect Cloud decide which Python versions it offers The resolver held the whole RequestPythonVersion enum and refused anything outside it. That list goes stale in the direction that hurts: the day Connect Cloud adds 3.15, a project requiring it would be told to change a requirement that is in fact fine, and the only real remedy would be upgrading rsconnect. Take the version from the constraint's lower bound rather than by enumerating a candidate set, and send it without judging whether Connect Cloud offers it. A version added after this release now deploys as it is. Keep only the floor. Requests below it are rejected outright rather than normalized -- the fallback to the lowest supported version applies to versions already stored, not to ones arriving over the API -- and rsconnect still runs on Pythons older than Connect Cloud offers, so a 3.8 user takes the platform default with a warning instead of a failed deploy. This drops the patch enumeration along with the list, since the lower bound carries the same information without a bound to pick. --- docs/CHANGELOG.md | 6 +- rsconnect/connect_cloud.py | 152 ++++++++++++++++++++++++------------ tests/test_connect_cloud.py | 58 +++++++++++--- 3 files changed, 150 insertions(+), 66 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 785068b2..c97549a4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,11 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Deploys to Posit Connect Cloud now request the Python version the content needs, instead of leaving Connect Cloud to fall back to Python 3.9. The version comes from the same `.python-version`, `pyproject.toml`, or `setup.cfg` requirement that already - goes into the manifest; Connect Cloud does not read it out of the bundle. Connect - Cloud offers Python 3.9 through 3.14, and a project requiring something outside that - range now fails before the bundle is uploaded rather than during dependency - resolution. Content whose bundle has no Python keeps the version already set on it, - so a version chosen in the Connect Cloud UI survives a redeploy. + goes into the manifest. ## [1.31.0] - 2026-09-08 diff --git a/rsconnect/connect_cloud.py b/rsconnect/connect_cloud.py index 46a04c2e..8c9af96c 100644 --- a/rsconnect/connect_cloud.py +++ b/rsconnect/connect_cloud.py @@ -319,10 +319,13 @@ def delete_credentials_from_keyring(url: str, nickname: str) -> None: keyring_delete_values(keyring_key(url, nickname), _CREDENTIAL_FIELDS) -# The MAJOR.MINOR values Connect Cloud accepts for a revision's python_version, -# matching the RequestPythonVersion enum in its OpenAPI schema -# (https://api.connect.posit.cloud/openapi.json). Ordered lowest first. -PYTHON_VERSIONS = ("3.9", "3.10", "3.11", "3.12", "3.13", "3.14") +# The lowest MAJOR.MINOR Connect Cloud will accept for a revision's python_version +# (the RequestPythonVersion enum in https://api.connect.posit.cloud/openapi.json). +MINIMUM_PYTHON_VERSION = Version("3.9") + +# Operators that put a floor under the versions a clause admits. "<" and "<=" bound +# only the top, and "!=" excludes a point, so neither says where the range starts. +_LOWER_BOUND_OPERATORS = (">=", ">", "==", "===", "~=") def _clause_version(clause: Specifier) -> Optional[Version]: @@ -336,18 +339,31 @@ def _clause_version(clause: Specifier) -> Optional[Version]: return None -def _line_satisfies(minor: str, specifier: SpecifierSet) -> bool: - """Whether any patch release of a MAJOR.MINOR line satisfies the constraint. +def _effective_floor(specifier: SpecifierSet) -> Optional[Version]: + """The lowest version the constraint admits, or None if it has no lower bound. + + Clauses are ANDed, so the highest of their floors is the one that binds: + ">=3.9,==3.12.*" starts at 3.12, not 3.9. + """ + floor = None + for clause in specifier: + if clause.operator not in _LOWER_BOUND_OPERATORS: + continue + version = _clause_version(clause) + if version is not None and (floor is None or version > floor): + floor = version + return floor + - Connect Cloud names only the minor line, never the patch it runs, so - ">=3.11.3" has to be judged against the whole 3.11 line rather than against - 3.11.0 alone -- otherwise a constraint Connect Cloud can actually meet is - reported as unsupported. +def _line_admits_anything(minor: str, specifier: SpecifierSet) -> bool: + """Whether any patch release of a MAJOR.MINOR line satisfies the whole constraint. - A SpecifierSet exposes no interval to test a range against, so this tests the - patches the constraint itself names, one above each of them, and the bottom of - the line. Every edge of the region a PEP 440 constraint admits falls on one of - those, so a region with anything in it contains one of them. + The floor alone is not enough to test: ">3.11.2" excludes 3.11.2 itself while + still admitting the rest of the 3.11 line, and an exclusion such as "!=3.11.*" + can empty a line the floor sits in. A SpecifierSet exposes no interval to test a + range against, so this tests the patches the constraint names on this line, one + above each of them, and the bottom of the line -- every edge of the region a PEP + 440 constraint admits falls on one of those. """ line = Version(minor).release[:2] patches = {0} @@ -360,12 +376,45 @@ def _line_satisfies(minor: str, specifier: SpecifierSet) -> bool: return any(Version("%s.%d" % (minor, patch)) in specifier for patch in sorted(patches)) -def _minor_version(version: str) -> Optional[str]: - """The MAJOR.MINOR prefix of a version string, or None if it has no minor part.""" - parts = version.strip().split(".") - if len(parts) < 2 or not parts[0].isdigit() or not parts[1].isdigit(): +# How many minor lines above the constraint's floor to consider when the floor's own +# line is excluded. Generous for any real requirement, and only there to stop a +# pathological constraint scanning forever. This is a bound on our own search, not on +# the versions Connect Cloud offers: it never rejects a version, it only gives up +# looking for one. +_LINE_SCAN_LIMIT = 20 + + +def _lowest_admitted_line(floor: Version, specifier: SpecifierSet) -> Optional[str]: + """The lowest MAJOR.MINOR at or above `floor` that the constraint admits. + + Usually the floor's own line. It is not when an exclusion empties that line, as + ">=3.9,!=3.9.*" does, which means 3.10 and is the reason this scans rather than + testing the floor alone. + """ + major, minor = floor.release[0], floor.release[1] + for offset in range(_LINE_SCAN_LIMIT + 1): + candidate = "%d.%d" % (major, minor + offset) + if _line_admits_anything(candidate, specifier): + return candidate + return None + + +def _minor_version(version: Version) -> Optional[str]: + """A version as the MAJOR.MINOR string Connect Cloud names its interpreters by. + + None when the version names no minor, as "==3.*" does: there is no single line + to ask for. + """ + if len(version.release) < 2: + return None + return "%d.%d" % (version.release[0], version.release[1]) + + +def _parsed(version: str) -> Optional[Version]: + try: + return Version(version) + except InvalidVersion: return None - return "%s.%s" % (parts[0], parts[1]) def resolve_python_version(requires: Optional[str], local_version: Optional[str]) -> Optional[str]: @@ -373,15 +422,19 @@ def resolve_python_version(requires: Optional[str], local_version: Optional[str] `requires` is the PEP 440 constraint from the manifest's ``environment.python.requires``; `local_version` is the interpreter the - bundle was built against, from ``python.version``. Returns None when neither - is usable, which leaves the field off the request so Connect Cloud keeps - whatever the content already has. + bundle was built against, from ``python.version``. Returns None when there is + nothing usable to send, which leaves the field off the request so Connect + Cloud keeps whatever the content already has. + + The interpreter the content was built against wins when the constraint allows + it, so a deploy reproduces the development environment. Otherwise the lowest + version the constraint admits is used, which keeps ">=3.10" on 3.10 as Connect + Cloud adds newer ones. - The interpreter the content was built against wins when Connect Cloud offers - its MAJOR.MINOR and the constraint allows it, so a deploy reproduces the - development environment. Otherwise the lowest offered line satisfying the - constraint is used, which keeps ">=3.10" on the same version as Connect Cloud - adds newer ones. + Whether Connect Cloud offers the result is left to Connect Cloud, so a version + it adds after this release still deploys. The one version rule kept here is the + floor: below it the request would be rejected outright, and omitting the field + to take the platform default is more useful than failing. Connect Cloud picks the patch itself, so a constraint that names one ("==3.11.14") can only be honored as far as its minor line. @@ -396,25 +449,28 @@ def resolve_python_version(requires: Optional[str], local_version: Optional[str] "PEP 440 constraint: %s" % requires ) - if local_version: - local_minor = _minor_version(local_version) - try: - allowed = specifier is None or Version(local_version) in specifier - except InvalidVersion: - allowed = False - local_minor = None - if local_minor in PYTHON_VERSIONS and allowed: - return local_minor - - if specifier is None: + minor = None + local = _parsed(local_version) if local_version else None + if local is not None and (specifier is None or local in specifier): + minor = _minor_version(local) + elif specifier is not None: + floor = _effective_floor(specifier) + # A floor naming only a major version ("==3.*") picks out no line to scan from. + if floor is not None and _minor_version(floor) is not None: + minor = _lowest_admitted_line(floor, specifier) + if minor is None: + logger.warning( + "Could not determine a Python version for the requirement %s; " + "Posit Connect Cloud will choose a version for this content." % requires + ) + + if minor is None: return None - - for candidate in PYTHON_VERSIONS: - if _line_satisfies(candidate, specifier): - return candidate - - raise RSConnectException( - "This content requires Python %s, which Posit Connect Cloud does not offer. " - "Supported versions are %s. Change the requirement in .python-version, " - "pyproject.toml, or setup.cfg." % (requires, ", ".join(PYTHON_VERSIONS)) - ) + if Version(minor) < MINIMUM_PYTHON_VERSION: + # Connect Cloud rejects anything lower, so send nothing and let it choose. + # rsconnect still runs on Pythons older than Connect Cloud offers. + logger.warning( + "Posit Connect Cloud does not offer Python %s; it will choose a version for this content." % minor + ) + return None + return minor diff --git a/tests/test_connect_cloud.py b/tests/test_connect_cloud.py index 506a9623..fd18730c 100644 --- a/tests/test_connect_cloud.py +++ b/tests/test_connect_cloud.py @@ -1908,9 +1908,6 @@ def test_wildcard_constraint(self): def test_no_constraint_uses_the_local_interpreter(self): self.assertEqual(connect_cloud.resolve_python_version(None, "3.13.1"), "3.13") - def test_local_interpreter_connect_cloud_does_not_offer_is_not_used(self): - self.assertEqual(connect_cloud.resolve_python_version(">=3.8", "3.8.10"), "3.9") - def test_patch_constraint_resolves_to_its_minor_line(self): # Connect Cloud picks the patch itself, so ">=3.11.3" is honored as 3.11 # rather than reported as unsupported. @@ -1931,8 +1928,37 @@ def test_high_patch_numbers_are_not_a_cutoff(self): def test_patch_constraint_on_the_newest_line(self): self.assertEqual(connect_cloud.resolve_python_version(">=3.14.99,<3.15", None), "3.14") - def test_excluded_patch_does_not_rule_out_the_line(self): - self.assertEqual(connect_cloud.resolve_python_version("!=3.9.5,<3.10", None), "3.9") + def test_constraint_with_no_lower_bound_sends_nothing(self): + # "<3.10" says where to stop, not where to start, so there is no version to + # ask for and Connect Cloud picks. + self.assertIsNone(connect_cloud.resolve_python_version("!=3.9.5,<3.10", None)) + + def test_the_highest_lower_bound_is_the_one_that_binds(self): + # Clauses are ANDed: ">=3.9" does not make 3.9 available when "==3.12.*" + # also has to hold. + self.assertEqual(connect_cloud.resolve_python_version(">=3.9,==3.12.*", None), "3.12") + + def test_strict_lower_bound_stays_on_its_line(self): + # ">3.11.2" excludes 3.11.2 but not the rest of 3.11. + self.assertEqual(connect_cloud.resolve_python_version(">3.11.2", None), "3.11") + + def test_major_only_constraint_names_no_line(self): + # "==3.*" and ">=3" admit every 3.x, so there is no single version to ask for. + self.assertIsNone(connect_cloud.resolve_python_version("==3.*", None)) + self.assertIsNone(connect_cloud.resolve_python_version(">=3", None)) + + def test_major_only_constraint_still_takes_the_local_interpreter(self): + self.assertEqual(connect_cloud.resolve_python_version("==3.*", "3.12.4"), "3.12") + + def test_constraint_excluding_the_line_its_floor_sits_in(self): + # ">=3.9,!=3.9.*" starts at 3.9 but admits nothing on that line, so it means + # 3.10. Sending nothing here would leave a redeploy on the stored 3.9, which + # is the version the requirement just ruled out. + self.assertEqual(connect_cloud.resolve_python_version(">=3.9,!=3.9.*", None), "3.10") + self.assertEqual(connect_cloud.resolve_python_version(">=3.9,!=3.9.*,!=3.10.*", None), "3.11") + + def test_constraint_admitting_nothing_sends_nothing(self): + self.assertIsNone(connect_cloud.resolve_python_version(">=3.9,<3.9", None)) def test_nothing_to_go_on(self): self.assertIsNone(connect_cloud.resolve_python_version(None, None)) @@ -1943,15 +1969,21 @@ def test_unparsable_constraint_is_ignored(self): def test_unparsable_constraint_with_no_local_version(self): self.assertIsNone(connect_cloud.resolve_python_version("not-a-constraint", None)) - def test_unsupported_version_is_an_error_before_the_deploy(self): - with self.assertRaises(RSConnectException) as context: - connect_cloud.resolve_python_version("~=3.8.0", "3.8.10") - self.assertIn("does not offer", str(context.exception)) - self.assertIn("3.9, 3.10, 3.11, 3.12, 3.13, 3.14", str(context.exception)) + def test_version_newer_than_this_release_knows_about_is_sent_anyway(self): + # Whether Connect Cloud offers it is Connect Cloud's call: a version added + # after this release must not need a new rsconnect to deploy against. + self.assertEqual(connect_cloud.resolve_python_version(">=3.20", None), "3.20") - def test_version_above_the_supported_range_is_an_error(self): - with self.assertRaises(RSConnectException): - connect_cloud.resolve_python_version(">=3.20", None) + def test_version_below_the_floor_sends_nothing(self): + # Connect Cloud rejects anything under 3.9 outright, and rsconnect still runs + # on older Pythons, so take the platform default rather than fail the deploy. + self.assertIsNone(connect_cloud.resolve_python_version("~=3.8.0", "3.8.10")) + + def test_local_interpreter_below_the_floor_sends_nothing(self): + self.assertIsNone(connect_cloud.resolve_python_version(None, "3.8.10")) + + def test_unparsable_local_version_is_ignored(self): + self.assertIsNone(connect_cloud.resolve_python_version(None, "not-a-version")) class TestConnectCloudPythonVersionFromBundle(unittest.TestCase): From f2337968897bceaf2c3e8e1300fe38788dbfca5a Mon Sep 17 00:00:00 2001 From: Sam Perman Date: Wed, 9 Sep 2026 08:43:24 -0400 Subject: [PATCH 3/4] log the Python version requested from Connect Cloud Say which version each deploy asks for, and warn when nothing Connect Cloud offers can satisfy the requirement. A .python-version of 3.11.5 is widened to the 3.11 line before the manifest is written, so the request was previously silent about dropping the patch. Also resolve from the higher of the requirement's lowest version and Connect Cloud's, so a requirement it can meet no longer falls back to the platform default: ">=3.8" resolves to 3.9 instead of nothing. --- docs/CHANGELOG.md | 4 +- rsconnect/connect_cloud.py | 112 +++++++++++++++++++++++------------- tests/test_connect_cloud.py | 94 +++++++++++++++++++++++++++--- 3 files changed, 162 insertions(+), 48 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c97549a4..221fbfe3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,7 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Deploys to Posit Connect Cloud now request the Python version the content needs, instead of leaving Connect Cloud to fall back to Python 3.9. The version comes from the same `.python-version`, `pyproject.toml`, or `setup.cfg` requirement that already - goes into the manifest. + goes into the manifest. The requested version is logged on each deploy. It names a + minor version only -- a `.python-version` of `3.11.5` requests 3.11 -- because Connect + Cloud selects the patch release itself. ## [1.31.0] - 2026-09-08 diff --git a/rsconnect/connect_cloud.py b/rsconnect/connect_cloud.py index 8c9af96c..d2aa79d5 100644 --- a/rsconnect/connect_cloud.py +++ b/rsconnect/connect_cloud.py @@ -355,25 +355,41 @@ def _effective_floor(specifier: SpecifierSet) -> Optional[Version]: return floor -def _line_admits_anything(minor: str, specifier: SpecifierSet) -> bool: - """Whether any patch release of a MAJOR.MINOR line satisfies the whole constraint. - - The floor alone is not enough to test: ">3.11.2" excludes 3.11.2 itself while - still admitting the rest of the 3.11 line, and an exclusion such as "!=3.11.*" - can empty a line the floor sits in. A SpecifierSet exposes no interval to test a - range against, so this tests the patches the constraint names on this line, one - above each of them, and the bottom of the line -- every edge of the region a PEP - 440 constraint admits falls on one of those. - """ +# Connect Cloud accepts a MAJOR.MINOR line, such as "3.11", and chooses the patch +# release. The helpers below determine whether a constraint permits none, some, +# or all patch releases in that line. +# +# SpecifierSet can test only concrete versions, so we sample patch 0 and, for each +# clause referring to the line, its named patch and the following patch. Between +# these boundaries the result cannot change, making those samples sufficient. + + +def _line_boundary_patches(minor: str, specifier: SpecifierSet) -> list[int]: + """The sample patches for a line. For "3.11" and ">3.11.5", [0, 5, 6].""" line = Version(minor).release[:2] patches = {0} for clause in specifier: version = _clause_version(clause) - if version is None or version.release[:2] != line: + if version is None: continue - patch = version.release[2] if len(version.release) > 2 else 0 + release = version.release + # Padded so a clause naming only a major, as ">4" does, is read against that + # major's first line rather than matching nothing. + clause_line = (release + (0, 0))[:2] + if clause_line != line: + continue + patch = release[2] if len(release) > 2 else 0 patches.update((patch, patch + 1)) - return any(Version("%s.%d" % (minor, patch)) in specifier for patch in sorted(patches)) + return sorted(patches) + + +def _line_permits_some_patch(minor: str, specifier: SpecifierSet) -> bool: + """Return whether `specifier` permits at least one patch release in `minor`. + + `minor` is a MAJOR.MINOR line such as "3.11". For example, ">3.11.5,<3.12" + permits the 3.11 line, while "!=3.11.*" does not. + """ + return any(Version("%s.%d" % (minor, patch)) in specifier for patch in _line_boundary_patches(minor, specifier)) # How many minor lines above the constraint's floor to consider when the floor's own @@ -391,10 +407,13 @@ def _lowest_admitted_line(floor: Version, specifier: SpecifierSet) -> Optional[s ">=3.9,!=3.9.*" does, which means 3.10 and is the reason this scans rather than testing the floor alone. """ - major, minor = floor.release[0], floor.release[1] + release = floor.release + # A floor naming only a major version, as ">=4" does, starts at that major's + # first line. + major, minor = release[0], release[1] if len(release) > 1 else 0 for offset in range(_LINE_SCAN_LIMIT + 1): candidate = "%d.%d" % (major, minor + offset) - if _line_admits_anything(candidate, specifier): + if _line_permits_some_patch(candidate, specifier): return candidate return None @@ -417,6 +436,17 @@ def _parsed(version: str) -> Optional[Version]: return None +def _log_requested(minor: str) -> str: + """Log the version being asked for and return it. + + Said on every deploy, so that a requirement naming a patch -- which is dropped + before it reaches here when it comes from .python-version -- is visibly not what + was asked for. + """ + logger.info("Requesting Python %s from Posit Connect Cloud." % minor) + return minor + + def resolve_python_version(requires: Optional[str], local_version: Optional[str]) -> Optional[str]: """Pick the Connect Cloud Python version for a deploy. @@ -427,14 +457,11 @@ def resolve_python_version(requires: Optional[str], local_version: Optional[str] Cloud keeps whatever the content already has. The interpreter the content was built against wins when the constraint allows - it, so a deploy reproduces the development environment. Otherwise the lowest - version the constraint admits is used, which keeps ">=3.10" on 3.10 as Connect - Cloud adds newer ones. + it. Otherwise the lowest version the constraint admits is used. - Whether Connect Cloud offers the result is left to Connect Cloud, so a version - it adds after this release still deploys. The one version rule kept here is the - floor: below it the request would be rejected outright, and omitting the field - to take the platform default is more useful than failing. + Availability is not checked here: an unrecognized version is sent and Connect + Cloud rules on it. A version below the lowest Connect Cloud offers is the + exception, and is not sent at all. Connect Cloud picks the patch itself, so a constraint that names one ("==3.11.14") can only be honored as far as its minor line. @@ -449,28 +476,35 @@ def resolve_python_version(requires: Optional[str], local_version: Optional[str] "PEP 440 constraint: %s" % requires ) - minor = None local = _parsed(local_version) if local_version else None if local is not None and (specifier is None or local in specifier): minor = _minor_version(local) - elif specifier is not None: - floor = _effective_floor(specifier) - # A floor naming only a major version ("==3.*") picks out no line to scan from. - if floor is not None and _minor_version(floor) is not None: - minor = _lowest_admitted_line(floor, specifier) - if minor is None: - logger.warning( - "Could not determine a Python version for the requirement %s; " - "Posit Connect Cloud will choose a version for this content." % requires - ) + if minor is not None and Version(minor) >= MINIMUM_PYTHON_VERSION: + return _log_requested(minor) + # rsconnect runs on Pythons older than Connect Cloud offers. Without a + # requirement there is nothing else to go on, so let the platform choose; + # with one, the search below may still find a version it does offer. + if specifier is None: + logger.warning( + "Posit Connect Cloud does not offer Python %s; it will choose a version for this content." % minor + ) + return None - if minor is None: + if specifier is None: return None - if Version(minor) < MINIMUM_PYTHON_VERSION: - # Connect Cloud rejects anything lower, so send nothing and let it choose. - # rsconnect still runs on Pythons older than Connect Cloud offers. + + # Search from the requirement's lowest version or Connect Cloud's, whichever is + # higher, so the result is one Connect Cloud can actually run: ">=3.8" is met by + # every version it offers and resolves to 3.9. A requirement with no lower bound, + # or one naming only a major version, starts at Connect Cloud's lowest. + floor = _effective_floor(specifier) + start = max(floor, MINIMUM_PYTHON_VERSION) if floor is not None else MINIMUM_PYTHON_VERSION + minor = _lowest_admitted_line(start, specifier) + if minor is None: logger.warning( - "Posit Connect Cloud does not offer Python %s; it will choose a version for this content." % minor + "No Python version Posit Connect Cloud offers satisfies the requirement %s. " + "It will choose a version, and the content will run on one the requirement " + "does not allow." % requires ) return None - return minor + return _log_requested(minor) diff --git a/tests/test_connect_cloud.py b/tests/test_connect_cloud.py index fd18730c..780df61c 100644 --- a/tests/test_connect_cloud.py +++ b/tests/test_connect_cloud.py @@ -1928,10 +1928,17 @@ def test_high_patch_numbers_are_not_a_cutoff(self): def test_patch_constraint_on_the_newest_line(self): self.assertEqual(connect_cloud.resolve_python_version(">=3.14.99,<3.15", None), "3.14") - def test_constraint_with_no_lower_bound_sends_nothing(self): - # "<3.10" says where to stop, not where to start, so there is no version to - # ask for and Connect Cloud picks. - self.assertIsNone(connect_cloud.resolve_python_version("!=3.9.5,<3.10", None)) + def test_constraint_with_no_lower_bound_starts_at_the_lowest_offered(self): + # "<3.10" says where to stop, not where to start, so the search begins at the + # lowest version Connect Cloud offers. + self.assertEqual(connect_cloud.resolve_python_version("!=3.9.5,<3.10", None), "3.9") + + def test_constraint_no_offered_version_can_meet_warns(self): + # "<3.9" has no lower bound either, but nothing Connect Cloud offers is under + # 3.9, so there is a requirement being broken and it is worth saying so. + result, logs = self._resolve_logs("<3.9") + self.assertIsNone(result) + self.assertIn("No Python version Posit Connect Cloud offers satisfies", logs) def test_the_highest_lower_bound_is_the_one_that_binds(self): # Clauses are ANDed: ">=3.9" does not make 3.9 available when "==3.12.*" @@ -1942,10 +1949,17 @@ def test_strict_lower_bound_stays_on_its_line(self): # ">3.11.2" excludes 3.11.2 but not the rest of 3.11. self.assertEqual(connect_cloud.resolve_python_version(">3.11.2", None), "3.11") - def test_major_only_constraint_names_no_line(self): - # "==3.*" and ">=3" admit every 3.x, so there is no single version to ask for. - self.assertIsNone(connect_cloud.resolve_python_version("==3.*", None)) - self.assertIsNone(connect_cloud.resolve_python_version(">=3", None)) + def test_major_only_constraint_takes_the_lowest_offered(self): + # "==3.*" and ">=3" admit every 3.x, so the lowest Connect Cloud offers serves. + self.assertEqual(connect_cloud.resolve_python_version("==3.*", None), "3.9") + self.assertEqual(connect_cloud.resolve_python_version(">=3", None), "3.9") + + def test_major_only_constraint_above_the_lowest_offered(self): + # A major with no minor starts at that major's first line. Connect Cloud does + # not offer 4.0, but that is its call to make, as with any version it does not + # recognize. + self.assertEqual(connect_cloud.resolve_python_version(">=4", None), "4.0") + self.assertEqual(connect_cloud.resolve_python_version("==4.*", None), "4.0") def test_major_only_constraint_still_takes_the_local_interpreter(self): self.assertEqual(connect_cloud.resolve_python_version("==3.*", "3.12.4"), "3.12") @@ -1960,6 +1974,70 @@ def test_constraint_excluding_the_line_its_floor_sits_in(self): def test_constraint_admitting_nothing_sends_nothing(self): self.assertIsNone(connect_cloud.resolve_python_version(">=3.9,<3.9", None)) + def _resolve_logs(self, requires, local_version=None): + """Resolve, returning (result, logged text) so the logging can be asserted on. + + Patches the module's logger rather than using assertLogs: rsconnect's logger is + its own RSLogger instance, not logging.getLogger("rsconnect"), and the negative + cases need to assert that nothing was logged. + """ + with mock.patch.object(connect_cloud.logger, "warning") as warn: + with mock.patch.object(connect_cloud.logger, "info") as info: + result = connect_cloud.resolve_python_version(requires, local_version) + calls = warn.call_args_list + info.call_args_list + return result, "\n".join(str(call.args[0]) for call in calls) + + def test_every_resolved_version_is_logged(self): + # ".python-version" of "3.11.5" reaches here as "~=3.11.0" -- adapt_python_requires + # drops the patch -- so the log is unconditional rather than trying to spot a + # requirement that named one. + result, logs = self._resolve_logs("~=3.11.0", "3.11.5") + self.assertEqual(result, "3.11") + self.assertIn("Requesting Python 3.11", logs) + + def test_resolved_version_is_logged_without_a_requirement(self): + result, logs = self._resolve_logs(None, "3.12.4") + self.assertEqual(result, "3.12") + self.assertIn("Requesting Python 3.12", logs) + + def test_nothing_is_logged_when_no_version_is_requested(self): + result, logs = self._resolve_logs(None, None) + self.assertIsNone(result) + self.assertEqual(logs, "") + + def test_requirement_no_offered_version_satisfies_warns_it_will_be_violated(self): + result, logs = self._resolve_logs(">=3.8,<3.9") + self.assertIsNone(result) + self.assertIn("No Python version Posit Connect Cloud offers satisfies", logs) + self.assertIn("does not allow", logs) + + def test_requirement_below_the_floor_that_a_newer_version_still_meets(self): + # ">=3.8" is met by every version Connect Cloud offers, so it resolves to the + # lowest of those rather than to an unaskable 3.8, with nothing to warn about. + result, logs = self._resolve_logs(">=3.8") + self.assertEqual(result, "3.9") + self.assertIn("Requesting Python 3.9", logs) + + def test_local_interpreter_below_the_floor_does_not_block_the_requirement(self): + # Built on 3.8, which Connect Cloud cannot run, but ">=3.8" is met by 3.9. An + # old interpreter must not produce a worse result than having none at all. + result, logs = self._resolve_logs(">=3.8", "3.8.10") + self.assertEqual(result, "3.9") + self.assertIn("Requesting Python 3.9", logs) + + def test_local_interpreter_below_the_floor_is_not_asked_for(self): + # rsconnect runs on Pythons Connect Cloud does not offer; asking for one would + # be rejected, so the platform chooses instead. + result, logs = self._resolve_logs(None, "3.8.10") + self.assertIsNone(result) + self.assertIn("does not offer Python 3.8", logs) + + def test_strict_major_only_bound_stays_on_the_first_line(self): + # ">4" excludes 4.0.0 but not 4.0.1, so the 4.0 line still serves. The clause + # names no minor, so it has to be read against that major's first line to be + # counted at all. + self.assertEqual(connect_cloud.resolve_python_version(">4,<4.1", None), "4.0") + def test_nothing_to_go_on(self): self.assertIsNone(connect_cloud.resolve_python_version(None, None)) From c5158fd04e585b3fbe7d9e1b188542663d7f3837 Mon Sep 17 00:00:00 2001 From: Sam Perman Date: Wed, 9 Sep 2026 17:04:32 -0400 Subject: [PATCH 4/4] say what omitting the Python version does on a first deploy The warnings and docstrings described only a redeploy, where leaving python_version off the request keeps the version already on the content. On a first deploy there is no stored version and Connect Cloud uses its own default, which the messages did not mention. Both warnings now end with the same sentence naming the two outcomes. resolve_python_version runs before prepare_deploy has looked the content up, so it cannot tell which case a deploy is in. Also say that Connect Cloud chooses the patch release when logging the request. "Requesting Python 3.11" alone did not convey that a patch the user asked for was dropped. --- rsconnect/api.py | 5 +++-- rsconnect/connect_cloud.py | 38 +++++++++++++++++++++++++------------ tests/test_connect_cloud.py | 9 ++++++++- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/rsconnect/api.py b/rsconnect/api.py index cb013c45..06f61fed 100644 --- a/rsconnect/api.py +++ b/rsconnect/api.py @@ -2194,8 +2194,9 @@ def bundle_manifest(self) -> dict[str, Any]: def python_version_for_connect_cloud(self) -> Optional[str]: """The MAJOR.MINOR Python version to ask Connect Cloud for, or None. - None means the request omits the field, leaving the version Connect Cloud - already has on the content. Content with no Python at all lands here too. + None means the request omits the field: a redeploy keeps the version already + set on the content, and a first deploy takes Connect Cloud's default. Content + with no Python at all lands here too. """ manifest = self.bundle_manifest() environment: dict[str, Any] = manifest.get("environment") or {} diff --git a/rsconnect/connect_cloud.py b/rsconnect/connect_cloud.py index d2aa79d5..eb7dba1f 100644 --- a/rsconnect/connect_cloud.py +++ b/rsconnect/connect_cloud.py @@ -323,6 +323,13 @@ def delete_credentials_from_keyring(url: str, nickname: str) -> None: # (the RequestPythonVersion enum in https://api.connect.posit.cloud/openapi.json). MINIMUM_PYTHON_VERSION = Version("3.9") +# Tail of both warnings below. resolve_python_version runs before the deploy knows +# whether the content already exists, so it has to name both outcomes. +_VERSION_CHOSEN_BY_CONNECT_CLOUD = ( + "The content will run on the Python version already set on it in Posit Connect " + "Cloud, or Connect Cloud's default if this is a first deployment." +) + # Operators that put a floor under the versions a clause admits. "<" and "<=" bound # only the top, and "!=" excludes a point, so neither says where the range starts. _LOWER_BOUND_OPERATORS = (">=", ">", "==", "===", "~=") @@ -439,11 +446,12 @@ def _parsed(version: str) -> Optional[Version]: def _log_requested(minor: str) -> str: """Log the version being asked for and return it. - Said on every deploy, so that a requirement naming a patch -- which is dropped - before it reaches here when it comes from .python-version -- is visibly not what - was asked for. + Said on every deploy, and names the patch as Connect Cloud's choice. A patch the + user asked for is already gone by the time it reaches here -- adapt_python_requires + turns a .python-version of "3.11.5" into "~=3.11.0" -- so this line is the only + place they see that it was not honored. """ - logger.info("Requesting Python %s from Posit Connect Cloud." % minor) + logger.info("Requesting Python %s from Posit Connect Cloud, which chooses the patch release." % minor) return minor @@ -453,8 +461,9 @@ def resolve_python_version(requires: Optional[str], local_version: Optional[str] `requires` is the PEP 440 constraint from the manifest's ``environment.python.requires``; `local_version` is the interpreter the bundle was built against, from ``python.version``. Returns None when there is - nothing usable to send, which leaves the field off the request so Connect - Cloud keeps whatever the content already has. + nothing usable to send, which leaves the field off the request: a redeploy then + keeps the version already set on the content, and a first deploy takes Connect + Cloud's default. The interpreter the content was built against wins when the constraint allows it. Otherwise the lowest version the constraint admits is used. @@ -485,9 +494,15 @@ def resolve_python_version(requires: Optional[str], local_version: Optional[str] # requirement there is nothing else to go on, so let the platform choose; # with one, the search below may still find a version it does offer. if specifier is None: - logger.warning( - "Posit Connect Cloud does not offer Python %s; it will choose a version for this content." % minor - ) + if minor is None: + logger.warning( + 'The manifest\'s Python version "%s" does not name a minor version to ask Posit ' + "Connect Cloud for. %s" % (local_version, _VERSION_CHOSEN_BY_CONNECT_CLOUD) + ) + else: + logger.warning( + "Posit Connect Cloud does not offer Python %s. %s" % (minor, _VERSION_CHOSEN_BY_CONNECT_CLOUD) + ) return None if specifier is None: @@ -502,9 +517,8 @@ def resolve_python_version(requires: Optional[str], local_version: Optional[str] minor = _lowest_admitted_line(start, specifier) if minor is None: logger.warning( - "No Python version Posit Connect Cloud offers satisfies the requirement %s. " - "It will choose a version, and the content will run on one the requirement " - "does not allow." % requires + "No Python version Posit Connect Cloud offers satisfies the requirement %s, so it " + "will not be met. %s" % (requires, _VERSION_CHOSEN_BY_CONNECT_CLOUD) ) return None return _log_requested(minor) diff --git a/tests/test_connect_cloud.py b/tests/test_connect_cloud.py index 780df61c..34acd800 100644 --- a/tests/test_connect_cloud.py +++ b/tests/test_connect_cloud.py @@ -2009,7 +2009,7 @@ def test_requirement_no_offered_version_satisfies_warns_it_will_be_violated(self result, logs = self._resolve_logs(">=3.8,<3.9") self.assertIsNone(result) self.assertIn("No Python version Posit Connect Cloud offers satisfies", logs) - self.assertIn("does not allow", logs) + self.assertIn("will not be met", logs) def test_requirement_below_the_floor_that_a_newer_version_still_meets(self): # ">=3.8" is met by every version Connect Cloud offers, so it resolves to the @@ -2032,6 +2032,13 @@ def test_local_interpreter_below_the_floor_is_not_asked_for(self): self.assertIsNone(result) self.assertIn("does not offer Python 3.8", logs) + def test_local_interpreter_naming_only_a_major_is_not_asked_for(self): + # A hand-written manifest can carry a python.version of "3", which names no + # line to request. + result, logs = self._resolve_logs(None, "3") + self.assertIsNone(result) + self.assertIn('Python version "3" does not name a minor version', logs) + def test_strict_major_only_bound_stays_on_the_first_line(self): # ">4" excludes 4.0.0 but not 4.0.1, so the 4.0 line still serves. The clause # names no minor, so it has to be read against that major's first line to be