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
5 changes: 5 additions & 0 deletions rsconnect/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 4 additions & 15 deletions rsconnect/preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
TOMLDecodeError,
detect_python_version_requirement,
get_python_version_requirement_parser,
local_python_mismatch,
lookup_metadata_file,
)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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


Expand Down
17 changes: 17 additions & 0 deletions rsconnect/pyproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down
77 changes: 77 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading