diff --git a/.vscode/settings.json b/.vscode/settings.json index 1055e1ec..6e1ef32f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,4 +24,7 @@ "build/**": true, "venv/**": true, }, + "python.analysis.exclude": [ + "tests" + ], } diff --git a/CHANGELOG.md b/CHANGELOG.md index 4830b427..0185cae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `rsconnect` now detects Python interpreter version requirements from `.python-version`, `pyproject.toml` and `setup.cfg` +- `--python` and `--override-python-version` options are now deprecated + in favor of using `.python-version` requirement file. ## [1.25.2] - 2025-02-26 diff --git a/rsconnect/environment.py b/rsconnect/environment.py index ec0052bd..35280fef 100644 --- a/rsconnect/environment.py +++ b/rsconnect/environment.py @@ -128,10 +128,20 @@ def create_python_environment( python_version_requirement = pyproject.detect_python_version_requirement(directory) _warn_on_missing_python_version(python_version_requirement) + if python is not None: + # TODO: Remove the option in a future release + logger.warning( + "On modern Posit Connect versions, the --python option won't influence " + "the Python version used to deploy the application anymore. " + "Please use a .python-version file to force a specific interpreter version." + ) + if override_python_version: - # TODO: --override-python-version should be deprecated in the future - # and instead we should suggest the user sets it in .python-version - # or pyproject.toml + # TODO: Remove the option in a future release + logger.warning( + "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}" # with cli_feedback("Inspecting Python environment"): diff --git a/rsconnect/pyproject.py b/rsconnect/pyproject.py index 0e3c0adc..6252ddd9 100644 --- a/rsconnect/pyproject.py +++ b/rsconnect/pyproject.py @@ -5,9 +5,10 @@ but not from setup.py due to its dynamic nature. """ +import configparser import pathlib +import re import typing -import configparser try: import tomllib @@ -15,6 +16,12 @@ # Python 3.11+ has tomllib in the standard library import toml as tomllib # type: ignore[no-redef] +from .log import logger + + +PEP440_OPERATORS_REGEX = r"(===|==|!=|<=|>=|<|>|~=)" +VALID_VERSION_REQ_REGEX = rf"^({PEP440_OPERATORS_REGEX}?\d+(\.[\d\*]+)*)+$" + def detect_python_version_requirement(directory: typing.Union[str, pathlib.Path]) -> typing.Optional[str]: """Detect the python version requirement for a project. @@ -26,7 +33,12 @@ def detect_python_version_requirement(directory: typing.Union[str, pathlib.Path] """ for _, metadata_file in lookup_metadata_file(directory): parser = get_python_version_requirement_parser(metadata_file) - version_constraint = parser(metadata_file) + try: + version_constraint = parser(metadata_file) + except InvalidVersionConstraintError as err: + logger.error(f"Invalid python version constraint in {metadata_file}, ignoring it: {err}") + continue + if version_constraint: return version_constraint @@ -103,5 +115,47 @@ def parse_pyversion_python_requires(pyversion_file: pathlib.Path) -> typing.Opti Returns None if the field is not found. """ - content = pyversion_file.read_text() - return content.strip() + return adapt_python_requires(pyversion_file.read_text().strip()) + + +def adapt_python_requires( + python_requires: str, +) -> str: + """Convert a literal python version to a PEP440 constraint. + + Connect expects a PEP440 format, but the .python-version file can contain + plain version numbers and other formats. + + We should convert them to the constraints that connect expects. + """ + current_contraints = python_requires.split(",") + + def _adapt_contraint(constraints: typing.List[str]) -> typing.Generator[str, None, None]: + for constraint in constraints: + constraint = constraint.strip() + if "@" in constraint or "-" in constraint or "/" in constraint: + raise InvalidVersionConstraintError(f"python specific implementations are not supported: {constraint}") + + if "b" in constraint or "rc" in constraint or "a" in constraint: + raise InvalidVersionConstraintError(f"pre-release versions are not supported: {constraint}") + + if re.match(VALID_VERSION_REQ_REGEX, constraint) is None: + raise InvalidVersionConstraintError(f"Invalid python version: {constraint}") + + if re.search(PEP440_OPERATORS_REGEX, constraint): + yield constraint + else: + # Convert to PEP440 format + if "*" in constraint: + yield f"=={constraint}" + else: + # only major specified “3” → ~=3.0 → >=3.0,<4.0 + # major and minor specified “3.8” or “3.8.11” → ~=3.8.0 → >=3.8.0,<3.9.0 + constraint = ".".join(constraint.split(".")[:2] + ["0"]) + yield f"~={constraint}" + + return ",".join(_adapt_contraint(current_contraints)) + + +class InvalidVersionConstraintError(ValueError): + pass diff --git a/tests/test_environment.py b/tests/test_environment.py index 52df3c35..1a1c5a95 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -4,6 +4,7 @@ import tempfile import subprocess from unittest import TestCase +from unittest import mock import rsconnect.environment from rsconnect.exception import RSConnectException @@ -142,12 +143,12 @@ def test_pyproject_toml(self): def test_python_version(self): env = Environment.create_python_environment(os.path.join(TESTDATA, "python-project", "using_pyversion")) assert env.python_interpreter == sys.executable - assert env.python_version_requirement == ">=3.8, <3.12" + assert env.python_version_requirement == ">=3.8,<3.12" def test_all_of_them(self): env = Environment.create_python_environment(os.path.join(TESTDATA, "python-project", "allofthem")) assert env.python_interpreter == sys.executable - assert env.python_version_requirement == ">=3.8, <3.12" + assert env.python_version_requirement == ">=3.8,<3.12" def test_missing(self): env = Environment.create_python_environment(os.path.join(TESTDATA, "python-project", "empty")) @@ -270,3 +271,37 @@ def fake_inspect_environment( assert environment.python_interpreter == expected_python assert environment == expected_environment + +class TestEnvironmentDeprecations: + def test_override_python_version(self): + with mock.patch.object(rsconnect.environment.logger, "warning") as mock_warning: + result = Environment.create_python_environment(get_dir("pip1"), override_python_version=None) + assert mock_warning.call_count == 0 + 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"), override_python_version="3.8") + 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" + + def test_python_interpreter(self): + current_python_version = ".".join((str(v) for v in sys.version_info[:3])) + + with mock.patch.object(rsconnect.environment.logger, "warning") as mock_warning: + result = Environment.create_python_environment(get_dir("pip1")) + assert mock_warning.call_count == 0 + assert result.python == current_python_version + + with mock.patch.object(rsconnect.environment.logger, "warning") as mock_warning: + result = Environment.create_python_environment(get_dir("pip1"), python=sys.executable) + assert mock_warning.call_count == 1 + mock_warning.assert_called_once_with( + "On modern Posit Connect versions, the --python option won't influence " + "the Python version used to deploy the application anymore. " + "Please use a .python-version file to force a specific interpreter version." + ) + assert result.python == current_python_version diff --git a/tests/test_pyproject.py b/tests/test_pyproject.py index eb5b3f28..2863de0c 100644 --- a/tests/test_pyproject.py +++ b/tests/test_pyproject.py @@ -1,17 +1,19 @@ import os import pathlib +import tempfile + +import pytest from rsconnect.pyproject import ( + detect_python_version_requirement, + get_python_version_requirement_parser, lookup_metadata_file, parse_pyproject_python_requires, - parse_setupcfg_python_requires, parse_pyversion_python_requires, - get_python_version_requirement_parser, - detect_python_version_requirement, + parse_setupcfg_python_requires, + InvalidVersionConstraintError, ) -import pytest - HERE = os.path.dirname(__file__) PROJECTS_DIRECTORY = os.path.abspath(os.path.join(HERE, "testdata", "python-project")) @@ -117,7 +119,7 @@ def test_setupcfg_python_requires(project_dir, expected): @pytest.mark.parametrize( "project_dir, expected", [ - (os.path.join(PROJECTS_DIRECTORY, "using_pyversion"), ">=3.8, <3.12"), + (os.path.join(PROJECTS_DIRECTORY, "using_pyversion"), ">=3.8,<3.12"), ], ids=["option-exists"], ) @@ -139,6 +141,62 @@ def test_detect_python_version_requirement(): version requirement is used. """ project_dir = os.path.join(PROJECTS_DIRECTORY, "allofthem") - assert detect_python_version_requirement(project_dir) == ">=3.8, <3.12" + assert detect_python_version_requirement(project_dir) == ">=3.8,<3.12" assert detect_python_version_requirement(os.path.join(PROJECTS_DIRECTORY, "empty")) is None + + +@pytest.mark.parametrize( # type: ignore + ["content", "expected"], + [ + ("3", "~=3.0"), + ("3.8", "~=3.8.0"), + ("3.8.0", "~=3.8.0"), + ("3.8.11", "~=3.8.0"), + ("3.8.0b1", InvalidVersionConstraintError("pre-release versions are not supported: 3.8.0b1")), + ("3.8.0rc1", InvalidVersionConstraintError("pre-release versions are not supported: 3.8.0rc1")), + ("3.8.0a1", InvalidVersionConstraintError("pre-release versions are not supported: 3.8.0a1")), + ("3.8.*", "==3.8.*"), + ("3.*", "==3.*"), + ("*", InvalidVersionConstraintError("Invalid python version: *")), + # This is not perfect, but the added regex complexity doesn't seem worth it. + ("invalid", InvalidVersionConstraintError("pre-release versions are not supported: invalid")), + ("pypi@3.1", InvalidVersionConstraintError("python specific implementations are not supported: pypi@3.1")), + ( + "cpython-3.12.3-macos-aarch64-none", + InvalidVersionConstraintError( + "python specific implementations are not supported: cpython-3.12.3-macos-aarch64-none" + ), + ), + ( + "/usr/bin/python3.8", + InvalidVersionConstraintError("python specific implementations are not supported: /usr/bin/python3.8"), + ), + (">=3.8,<3.10", ">=3.8,<3.10"), + (">=3.8, <*", ValueError("Invalid python version: <*")), + ], +) +def test_python_version_file_adapt(content, expected): + """Test that the python version is correctly converted to a PEP440 format. + + Connect expects a PEP440 format, but the .python-version file can contain + plain version numbers and other formats. + + We should convert them to the constraints that connect expects. + """ + with tempfile.TemporaryDirectory() as tmpdir: + versionfile = pathlib.Path(tmpdir) / ".python-version" + with open(versionfile, "w") as tmpfile: + tmpfile.write(content) + + try: + if isinstance(expected, Exception): + with pytest.raises(expected.__class__) as excinfo: + parse_pyversion_python_requires(versionfile) + assert str(excinfo.value) == expected.args[0] + assert detect_python_version_requirement(tmpdir) is None + else: + assert parse_pyversion_python_requires(versionfile) == expected + assert detect_python_version_requirement(tmpdir) == expected + finally: + os.remove(tmpfile.name)