diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c48fde31..221fbfe3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,13 @@ 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. 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 - Posit Connect Cloud is now a supported deployment target, alongside Posit diff --git a/rsconnect/api.py b/rsconnect/api.py index f612d77b..06f61fed 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,34 @@ 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: 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 {} + 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 +2323,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 +3315,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 +3671,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 +3711,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 +3725,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 +3932,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 +3999,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 +4014,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..eb7dba1f 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,208 @@ 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 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") + +# 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 = (">=", ">", "==", "===", "~=") + + +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 _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 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: + continue + 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 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 +# 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. + """ + 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_permits_some_patch(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 + + +def _log_requested(minor: str) -> str: + """Log the version being asked for and return it. + + 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, which chooses the patch release." % 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. + + `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: 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. + + 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. + """ + 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 + ) + + 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) + 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: + 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: + return None + + # 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( + "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 3507ccd1..34acd800 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,217 @@ 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_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_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.*" + # 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_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") + + 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 _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("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 + # 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_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 + # 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)) + + 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_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_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): + 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 +2362,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.