diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 930d09ea..8abce0b2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- `--override-python-version` with a major and minor version, such as `3.11`, + requests the latest Python patch release in that series instead of `3.11.0`. + This requires Posit Connect 2025.03.0 or later; older servers use the local + Python version, so pass a full version such as `3.11.5` for those. (#901) - Inspects activated or project virtual environments, including with `uv tool install`. Global installations now inspect activated environments. `rsconnect preflight` checks the same environment's Python version. Use `--python` to override selection. (#889) diff --git a/docs/deploying.md b/docs/deploying.md index 9b9774e1..9f2d62ac 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -243,6 +243,11 @@ rsconnect deploy api --override-python-version 3.11.5 my-api/ will deploy the content in `my-api` while requesting that Connect use Python version 3.11.5. +On Connect 2025.03.0 and later, a major and minor version such as `3.11` +requests the latest available patch release in that series. The manifest records +the local Python version separately for compatibility. Connect versions before +2025.03.0 use that local version instead, so specify a full version such as +`3.11.5` when deploying to those servers. > **Note** > The packages and package versions listed in `requirements.txt` must be diff --git a/rsconnect/environment.py b/rsconnect/environment.py index 00fc653e..fe71bd1d 100644 --- a/rsconnect/environment.py +++ b/rsconnect/environment.py @@ -151,7 +151,8 @@ def create_python_environment( "The --override-python-version option is deprecated, " "please use a .python-version file to force a specific interpreter version." ) - python_version_requirement = f"=={override_python_version}" + suffix = ".*" if override_python_version.count(".") == 1 else "" + python_version_requirement = f"=={override_python_version}{suffix}" # with cli_feedback("Inspecting Python environment"): environment = cls._get_python_env_info( @@ -159,7 +160,7 @@ def create_python_environment( ) environment.python_version_requirement = python_version_requirement - if override_python_version: + if override_python_version and override_python_version.count(".") == 2: # Retaing backward compatibility with old Connect versions # that didn't support environment.python.requires environment.python = override_python_version diff --git a/rsconnect/main.py b/rsconnect/main.py index e52863a8..2c1b3f7c 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -2144,7 +2144,10 @@ def _warn_on_ignored_requirements(directory: str, requirements_file_name: str): @click.option( "--override-python-version", type=validation.PYTHON_VERSION, - help=("An optional python version to use instead of the version from the detected environment."), + help=( + "Python version Connect should use instead of the version from the detected environment, as " + "major.minor.patch or major.minor. major.minor requires Posit Connect 2025.03.0 or later." + ), ) @click.option( "--force-generate", @@ -2338,7 +2341,10 @@ def deploy_notebook( @click.option( "--override-python-version", type=validation.PYTHON_VERSION, - help=("An optional python version to use instead of the version from the detected environment."), + help=( + "Python version Connect should use instead of the version from the detected environment, as " + "major.minor.patch or major.minor. major.minor requires Posit Connect 2025.03.0 or later." + ), ) @click.option( "--force-generate", @@ -3035,7 +3041,10 @@ def deploy_git( @click.option( "--override-python-version", type=validation.PYTHON_VERSION, - help=("An optional python version to use instead of the version from the detected environment."), + help=( + "Python version Connect should use instead of the version from the detected environment, as " + "major.minor.patch or major.minor. major.minor requires Posit Connect 2025.03.0 or later." + ), ) @click.option( "--force-generate", @@ -3521,7 +3530,10 @@ def generate_deploy_python( @click.option( "--override-python-version", type=validation.PYTHON_VERSION, - help=("An optional python version to use instead of the version from the detected environment."), + help=( + "Python version Connect should use instead of the version from the detected environment, as " + "major.minor.patch or major.minor. major.minor requires Posit Connect 2025.03.0 or later." + ), ) @click.option( "--force-generate", @@ -3899,7 +3911,10 @@ def write_manifest(): @click.option( "--override-python-version", type=validation.PYTHON_VERSION, - help=("An optional python version to use instead of the version from the detected environment."), + help=( + "Python version Connect should use instead of the version from the detected environment, as " + "major.minor.patch or major.minor. major.minor requires Posit Connect 2025.03.0 or later." + ), ) @click.option( "--force-generate", @@ -4019,7 +4034,10 @@ def write_manifest_notebook( @click.option( "--override-python-version", type=validation.PYTHON_VERSION, - help=("An optional python version to use instead of the version from the detected environment."), + help=( + "Python version Connect should use instead of the version from the detected environment, as " + "major.minor.patch or major.minor. major.minor requires Posit Connect 2025.03.0 or later." + ), ) @click.option( "--force-generate", @@ -4175,7 +4193,10 @@ def write_manifest_voila( @click.option( "--override-python-version", type=validation.PYTHON_VERSION, - help=("An optional python version to use instead of the version from the detected environment."), + help=( + "Python version Connect should use instead of the version from the detected environment, as " + "major.minor.patch or major.minor. major.minor requires Posit Connect 2025.03.0 or later." + ), ) @click.option( "--force-generate", @@ -4597,7 +4618,10 @@ def generate_write_manifest_python( @click.option( "--override-python-version", type=validation.PYTHON_VERSION, - help=("An optional python version to use instead of the version from the detected environment."), + help=( + "Python version Connect should use instead of the version from the detected environment, as " + "major.minor.patch or major.minor. major.minor requires Posit Connect 2025.03.0 or later." + ), ) @click.option( "--force-generate", diff --git a/rsconnect/validation.py b/rsconnect/validation.py index 6cac906b..f8689789 100644 --- a/rsconnect/validation.py +++ b/rsconnect/validation.py @@ -430,14 +430,16 @@ class PythonVersionParamType(click.ParamType): def convert(self, value: str, param: Optional[click.Parameter], ctx: Optional[click.Context]): try: parts = list(map(int, value.split("."))) - if len(parts) == 3: + if len(parts) in (2, 3): return value - elif len(parts) == 2: - return value + ".0" else: raise ValueError except (AttributeError, ValueError): - self.fail(f"{value!r} is not a valid python version; expected 3.x or 3.x.y", param, ctx) + self.fail( + f"{value!r} is not a valid python version; expected major.minor or major.minor.patch", + param, + ctx, + ) PYTHON_VERSION = PythonVersionParamType() diff --git a/tests/test_environment.py b/tests/test_environment.py index 01d50339..7c44b0df 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -1053,13 +1053,24 @@ def test_override_python_version(self): assert result.python_version_requirement is None with mock.patch.object(rsconnect.environment.logger, "warning") as mock_warning: - result = Environment.create_python_environment(get_dir("pip1-no-version"), override_python_version="3.8") + result = Environment.create_python_environment(get_dir("pip1-no-version"), override_python_version="3.8.1") assert mock_warning.call_count == 1 mock_warning.assert_called_once_with( "The --override-python-version option is deprecated, " "please use a .python-version file to force a specific interpreter version." ) - assert result.python_version_requirement == "==3.8" + assert result.python_version_requirement == "==3.8.1" + + def test_minor_python_override(self): + with mock.patch.object(rsconnect.environment.logger, "warning") as mock_warning: + result = Environment.create_python_environment(get_dir("pip1-no-version"), override_python_version="3.8") + + assert result.python == ".".join(str(part) for part in sys.version_info[:3]) + assert result.python_version_requirement == "==3.8.*" + mock_warning.assert_called_once_with( + "The --override-python-version option is deprecated, " + "please use a .python-version file to force a specific interpreter version." + ) def test_python_interpreter(self): current_python_version = ".".join((str(v) for v in sys.version_info[:3])) diff --git a/tests/test_main.py b/tests/test_main.py index 39fb5fae..b75ee962 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -13,6 +13,7 @@ import httpretty import pytest from click.testing import CliRunner +from packaging.specifiers import SpecifierSet from rsconnect import VERSION from rsconnect.api import RSConnectClient, RSConnectServer @@ -1756,6 +1757,43 @@ def test_deploy_api(self): result = runner.invoke(cli, args) assert result.exit_code == 0, result.output + @pytest.mark.parametrize("minor_only", [False, True]) + def test_deploy_api_with_python_override(self, tmp_path, minor_only): + require_connect_version("2025.03.0") + client = RSConnectClient(RSConnectServer(require_connect(), require_api_key())) + installations = client.python_settings()["installations"] + candidates = [] + for item in installations: + version = item["version"] + match = re.fullmatch(r"(3)\.(\d+)\.(\d+)", version) + if not match or item.get("publishable") is False: + continue + major, minor, patch = map(int, match.groups()) + if minor >= 8 and patch > 0: + candidates.append((major, minor, patch)) + + if not candidates: + pytest.skip("Connect needs a publishable Python >= 3.8 with a nonzero patch") + + major, minor, patch = max(candidates) + version = f"{major}.{minor}" if minor_only else f"{major}.{minor}.{patch}" + + source = get_api_path("flask") + (tmp_path / "app.py").write_text( + "import sys\n" + "from flask import Flask\n" + f"assert sys.version_info[:3] == ({major}, {minor}, {patch}), sys.version\n" + "app = Flask(__name__)\n" + "@app.route('/ping')\n" + "def ping():\n" + " return 'ok'\n" + ) + shutil.copy(join(source, "requirements.txt"), tmp_path) + args = self.create_deploy_args("api", str(tmp_path)) + args.extend(["--entrypoint", "app:app", "--exclude-renv", "--override-python-version", version]) + result = CliRunner().invoke(cli, args) + assert result.exit_code == 0, result.output + def test_deploy_api_fail_verify(self): target = optional_target(get_api_path("flask-bad")) runner = CliRunner() @@ -2378,6 +2416,47 @@ def test_no_package_json(self, tmp_path): class TestWriteManifestFramework: + @pytest.mark.parametrize( + "version, expected_requirement, matching, nonmatching", + [ + ("3.11", "==3.11.*", "3.11.15", "3.12.0"), + ("3.11.0", "==3.11.0", "3.11.0", "3.11.15"), + ("3.11.15", "==3.11.15", "3.11.15", "3.11.14"), + ], + ) + def test_override_python_version(self, tmp_path, version, expected_requirement, matching, nonmatching): + (tmp_path / "app.py").write_text("app = object()\n") + (tmp_path / "requirements.txt").write_text("flask\n") + + result = CliRunner().invoke( + cli, + [ + "write-manifest", + "api", + "--entrypoint", + "app:app", + "--exclude-renv", + "--override-python-version", + version, + str(tmp_path), + ], + ) + assert result.exit_code == 0, result.output + + manifest = json.loads((tmp_path / "manifest.json").read_text()) + expected_version = ".".join(str(part) for part in sys.version_info[:3]) if version == "3.11" else version + assert manifest["python"]["version"] == expected_version + requirement = manifest["environment"]["python"]["requires"] + assert requirement == expected_requirement + assert matching in SpecifierSet(requirement) + assert nonmatching not in SpecifierSet(requirement) + + def test_invalid_python_override_is_rejected(self, tmp_path): + result = CliRunner().invoke(cli, ["write-manifest", "api", "--override-python-version", "3", str(tmp_path)]) + assert result.exit_code != 0 + assert "expected major.minor or major.minor.patch" in result.output + assert not (tmp_path / "manifest.json").exists() + def test_dangling_symlink_reports_clear_error(self, tmp_path): (tmp_path / "app.py").write_text("app = object()\n") (tmp_path / "requirements.txt").write_text("flask\n")