diff --git a/rsconnect/environment.py b/rsconnect/environment.py index fe71bd1d..5a0286be 100644 --- a/rsconnect/environment.py +++ b/rsconnect/environment.py @@ -160,6 +160,11 @@ def create_python_environment( ) environment.python_version_requirement = python_version_requirement + if environment.source == "pip_freeze": + mismatch = pyproject.local_python_mismatch(python_version_requirement, environment.python) + if mismatch: + warn_user(mismatch) + if override_python_version and override_python_version.count(".") == 2: # Retaing backward compatibility with old Connect versions # that didn't support environment.python.requires diff --git a/rsconnect/preflight.py b/rsconnect/preflight.py index ed7165b8..9503d13f 100644 --- a/rsconnect/preflight.py +++ b/rsconnect/preflight.py @@ -24,6 +24,7 @@ TOMLDecodeError, detect_python_version_requirement, get_python_version_requirement_parser, + local_python_mismatch, lookup_metadata_file, ) @@ -484,20 +485,6 @@ def _unknown_advice( actions.append("Check the project's Python metadata and server availability before publishing.") -def _warn_local_python_mismatch(constraint: Optional[str], local_python: str, warnings: list[str]) -> None: - if constraint is None: - return - try: - matches_local = SpecifierSet(constraint).contains(local_python) - except (InvalidSpecifier, TypeError): - return - if not matches_local: - warnings.append( - f"Local Python {local_python} does not satisfy the project requirement {constraint}; " - "dependency compatibility has not been tested." - ) - - def _local_python_version(project_dir: str, python: Optional[str]) -> str: interpreter = which_python(python, project_dir) if interpreter == sys.executable: @@ -541,7 +528,9 @@ def _python_project_metadata( project_dir = str(target_path.parent) if target_path.is_file() else target local_python = _local_python_version(project_dir, python) constraint, invalid_metadata = _project_constraint(project_dir, warnings) - _warn_local_python_mismatch(constraint, local_python, warnings) + mismatch = local_python_mismatch(constraint, local_python) + if mismatch: + warnings.append(mismatch) return project_dir, local_python, constraint, invalid_metadata diff --git a/rsconnect/pyproject.py b/rsconnect/pyproject.py index ddf77fec..5c03d41a 100644 --- a/rsconnect/pyproject.py +++ b/rsconnect/pyproject.py @@ -12,6 +12,9 @@ import typing from collections.abc import Mapping +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.version import InvalidVersion + from .log import logger from .models import AppMode, AppModes @@ -31,6 +34,20 @@ VALID_VERSION_REQ_REGEX = rf"^({PEP440_OPERATORS_REGEX}?\d+(\.[\d\*]+)*)+$" +def local_python_mismatch(constraint: typing.Optional[str], version: str) -> typing.Optional[str]: + if constraint is None: + return None + try: + if SpecifierSet(constraint).contains(version): + return None + except (InvalidSpecifier, InvalidVersion, TypeError): + return None + return ( + f"Local Python {version} does not satisfy the project requirement {constraint}; " + "dependency compatibility has not been tested." + ) + + def detect_python_version_requirement(directory: typing.Union[str, pathlib.Path]) -> typing.Optional[str]: """Detect the python version requirement for a project. diff --git a/tests/test_main.py b/tests/test_main.py index b75ee962..01fdda91 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -58,6 +58,83 @@ def test_python_option_help_does_not_require_rsconnect_in_target_environment(com assert "must have the rsconnect" not in result.output +@pytest.mark.parametrize( + "filename,contents,requirement", + [ + (".python-version", "2.7\n", "~=2.7.0"), + ("pyproject.toml", '[project]\nrequires-python = "<3"\n', "<3"), + ("setup.cfg", "[options]\npython_requires = <3\n", "<3"), + ], +) +def test_write_manifest_warns_when_frozen_python_does_not_match_project(tmp_path, filename, contents, requirement): + (tmp_path / "app.py").write_text("app = object()\n") + (tmp_path / filename).write_text(contents) + result = CliRunner().invoke(cli, ["write-manifest", "api", str(tmp_path), "--force-generate"]) + + version = ".".join(map(str, sys.version_info[:3])) + warning = ( + f"Local Python {version} does not satisfy the project requirement {requirement}; " + "dependency compatibility has not been tested." + ) + assert result.exit_code == 0, result.output + assert warning in result.output + manifest = json.loads((tmp_path / "manifest.json").read_text()) + assert manifest["python"]["version"] == version + assert manifest["environment"]["python"]["requires"] == requirement + + +@pytest.mark.parametrize("source", ["compatible", "file", "invalid"]) +def test_write_manifest_does_not_warn_without_a_valid_frozen_mismatch(tmp_path, source): + (tmp_path / "app.py").write_text("app = object()\n") + requirement = ">=3" if source == "compatible" else ("invalid" if source == "invalid" else "<3") + (tmp_path / "pyproject.toml").write_text(f'[project]\nrequires-python = "{requirement}"\n') + args = ["write-manifest", "api", str(tmp_path)] + if source == "file": + (tmp_path / "requirements.txt").write_text("flask\n") + else: + args.append("--force-generate") + + result = CliRunner().invoke(cli, args) + + assert result.exit_code == 0, result.output + assert "does not satisfy the project requirement" not in result.output + + +def test_deploy_warns_before_server_connection_when_freezing_incompatible_python(tmp_path, monkeypatch): + from rsconnect.exception import RSConnectException + + (tmp_path / "app.py").write_text("app = object()\n") + (tmp_path / ".python-version").write_text("2.7\n") + + def stop_before_server_connection(*args, **kwargs): + raise RSConnectException("Stopped before server connection") + + monkeypatch.setattr("rsconnect.main.RSConnectExecutor", stop_before_server_connection) + try: + result = CliRunner().invoke(cli, ["deploy", "api", str(tmp_path), "--force-generate", "--quiet"]) + finally: + rs_logger.set_quiet(False) + rs_logger.setLevel(logging.INFO) + console_logger.setLevel(logging.DEBUG) + + version = ".".join(map(str, sys.version_info[:3])) + assert result.exit_code != 0 + assert "Stopped before server connection" in result.output + assert f"Local Python {version} does not satisfy the project requirement ~=2.7.0" in result.output + + +def test_override_warns_using_frozen_python_before_manifest_version_override(tmp_path): + (tmp_path / "app.py").write_text("app = object()\n") + result = CliRunner().invoke( + cli, ["write-manifest", "api", str(tmp_path), "--force-generate", "--override-python-version", "2.7.1"] + ) + + version = ".".join(map(str, sys.version_info[:3])) + assert result.exit_code == 0, result.output + assert f"Local Python {version} does not satisfy the project requirement ==2.7.1" in result.output + assert json.loads((tmp_path / "manifest.json").read_text())["python"]["version"] == "2.7.1" + + class TestMain: # Every env var ``metadata.config_dirname()`` consults. ``HOME`` alone only # isolates macOS: Linux prefers ``XDG_CONFIG_HOME`` and Windows prefers