Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions docs/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions rsconnect/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,15 +151,16 @@ 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(
module_file, python, requirements_file=requirements_file, project_directory=directory
)
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
Expand Down
40 changes: 32 additions & 8 deletions rsconnect/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions rsconnect/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
15 changes: 13 additions & 2 deletions tests/test_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
Expand Down
79 changes: 79 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")
Expand Down
Loading