diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 20a3ffff..082d5e81 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 +- `rsconnect deploy` commands now check PyPI once a day for a newer release of + rsconnect-python and print an upgrade hint to stderr when one is available. + The result is cached so most invocations make no network request. Set + `RSCONNECT_DISABLE_VERSION_CHECK=1` to turn the check off entirely. - The "no package-lock.json" error when deploying Node.js content now states that Connect installs Node.js dependencies with npm, helping publishers who build with yarn or pnpm understand why a `package-lock.json` is required. diff --git a/docs/deploying.md b/docs/deploying.md index cc0a085e..60642075 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -452,6 +452,21 @@ is uploaded and deployed. If the deployment fails, the new environment variables will still take effect. +### Update notifications + +When you run a `rsconnect deploy` command, rsconnect-python checks PyPI for a +newer release and prints an upgrade hint to stderr if one is available. The +result is cached for 24 hours, so most deployments make no extra network +request, and the check never blocks or fails a deployment. + +To disable the check entirely, set the `RSCONNECT_DISABLE_VERSION_CHECK` +environment variable to `1` (or `true`/`yes`): + +```bash +export RSCONNECT_DISABLE_VERSION_CHECK=1 +``` + + ### Updating a Deployment If you deploy a file again to the same server, `rsconnect` will update the previous diff --git a/pyproject.toml b/pyproject.toml index f35ba49a..c5ffc207 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "semver>=2.0.0,<4.0.0", "pyjwt>=2.4.0", "click>=8.0.0", + "packaging>=20.0", "toml>=0.10; python_version < '3.11'", ] diff --git a/rsconnect/main.py b/rsconnect/main.py index e3def4a4..d3f7ec9f 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -39,6 +39,7 @@ from rsconnect.certificates import read_certificate_file from . import VERSION, api, validation +from .version_check import BackgroundVersionCheck from .actions import ( cli_feedback, create_quarto_deployment_bundle, @@ -1442,8 +1443,19 @@ def quickstart(app_type: str, name: str, python_version: Optional[str]): @cli.group(no_args_is_help=True, help="Deploy content to Posit Connect or shinyapps.io.") -def deploy(): - pass +@click.pass_context +def deploy(ctx: click.Context): + checker = BackgroundVersionCheck() + checker.start() + + def _print_version_warning() -> None: + message = checker.get_warning_message() + if message: + click.secho(message, fg="yellow", err=True) + + # Registered on the context (not as a result callback) so the hint prints on + # every exit path, including failed deploys that raise or call sys.exit(). + ctx.call_on_close(_print_version_warning) def _warn_on_ignored_manifest(directory: str): diff --git a/rsconnect/version_check.py b/rsconnect/version_check.py new file mode 100644 index 00000000..df334776 --- /dev/null +++ b/rsconnect/version_check.py @@ -0,0 +1,154 @@ +"""Background version update check against PyPI. + +Checked only on deploy commands. The latest known version is cached on disk and +refreshed at most once per :data:`_CACHE_TTL_SECONDS` in a background thread, so a +warm cache adds no network traffic and no latency and prints on every exit path +(including fast failures). Only a cold cache -- the first run, or an ephemeral +environment where the cache never persists -- waits briefly on the fetch so it can +still warn. +""" + +from __future__ import annotations + +import json +import os +import threading +import time +from http.client import HTTPSConnection +from os.path import join +from typing import Optional, Tuple + +from packaging.version import Version + +from . import VERSION +from .metadata import config_dirname, makedirs + +RSCONNECT_DISABLE_VERSION_CHECK = "RSCONNECT_DISABLE_VERSION_CHECK" +_PYPI_HOST = "pypi.org" +_PYPI_PATH = "/pypi/rsconnect-python/json" +_PYPI_TIMEOUT_SECONDS = 2 +_CACHE_TTL_SECONDS = 24 * 60 * 60 # Re-check PyPI at most once a day. +_CACHE_FILENAME = "version_check.json" + + +def _is_check_disabled() -> bool: + value = os.environ.get(RSCONNECT_DISABLE_VERSION_CHECK, "").strip().lower() + return value in ("1", "true", "yes") + + +def _is_dev_version(version_str: str) -> bool: + try: + return Version(version_str).is_devrelease + except Exception: + return True + + +def _cache_path() -> str: + return join(config_dirname(), _CACHE_FILENAME) + + +def _read_cache() -> Tuple[bool, Optional[str]]: + """Return ``(is_fresh, latest)``. + + ``latest`` is the last known PyPI version recorded in the cache (or None when + the cache is missing/unreadable or the last fetch failed). It is returned + regardless of freshness so even a stale value can drive the upgrade hint while + a refresh runs in the background. ``is_fresh`` is True only when the entry is + within the TTL, signalling that no background refresh is needed. + """ + try: + with open(_cache_path()) as f: + data = json.load(f) + latest = data.get("latest") + latest = latest if isinstance(latest, str) else None + is_fresh = time.time() - float(data["checked_at"]) <= _CACHE_TTL_SECONDS + return (is_fresh, latest) + except Exception: + return (False, None) + + +def _write_cache(latest: Optional[str]) -> None: + """Persist the latest known version (or None) with the current timestamp. + + A None value is still cached so repeated failures don't re-hit PyPI on every + deploy until the TTL expires. + """ + try: + path = _cache_path() + makedirs(path) + with open(path, "w") as f: + json.dump({"checked_at": time.time(), "latest": latest}, f) + except Exception: + pass + + +def _fetch_latest_version() -> Optional[str]: + conn = None + try: + conn = HTTPSConnection(_PYPI_HOST, timeout=_PYPI_TIMEOUT_SECONDS) + conn.request("GET", _PYPI_PATH, headers={"Accept": "application/json"}) + response = conn.getresponse() + if response.status != 200: + return None + data = json.loads(response.read()) + return data.get("info", {}).get("version") + except Exception: + return None + finally: + if conn is not None: + conn.close() + + +def _update_message(latest: Optional[str]) -> Optional[str]: + """Return an upgrade warning if ``latest`` is newer than the running version.""" + try: + if latest is not None and Version(latest) > Version(VERSION): + return ( + f"A new version of rsconnect-python is available: {latest} " + f"(you have {VERSION}).\n" + f"Upgrade with: pip install --upgrade rsconnect-python" + ) + except Exception: + pass + return None + + +class BackgroundVersionCheck: + """Resolves the latest available version, using a cache and a background fetch.""" + + def __init__(self) -> None: + self._thread: Optional[threading.Thread] = None + self._latest: Optional[str] = None + + def start(self) -> None: + if _is_check_disabled() or _is_dev_version(VERSION): + return + is_fresh, latest = _read_cache() + # Seed the hint from the cached value (even a stale one is good enough to + # suggest an upgrade), so a warm cache prints instantly on every exit path + # -- including commands that fail fast. + self._latest = latest + if not is_fresh: + # Refresh in the background so the fetch overlaps the command's own + # work. A warm or stale cache never waits on this; only a cold cache + # waits briefly at exit -- see get_warning_message. + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def _run(self) -> None: + latest = _fetch_latest_version() + _write_cache(latest) + # Adopt a successful result so a cold-cache run can warn this time, but + # never overwrite a usable cached value with a failed (None) refresh. + if latest is not None: + self._latest = latest + + def get_warning_message(self) -> Optional[str]: + # With no cached value yet (a cold cache: first run, or an ephemeral + # environment where the cache never persists), give the in-flight fetch a + # bounded chance to finish so this run can still warn. The bound is the + # fetch's own time budget -- a slower network just means no hint this run. + # A warm or stale cache already has a value here and never waits. + if self._latest is None and self._thread is not None: + self._thread.join(timeout=_PYPI_TIMEOUT_SECONDS) + return _update_message(self._latest) diff --git a/tests/test_version_check.py b/tests/test_version_check.py new file mode 100644 index 00000000..d98374d5 --- /dev/null +++ b/tests/test_version_check.py @@ -0,0 +1,317 @@ +import json +import os +import threading +import time +from unittest.mock import patch + +import click +import click.testing +import pytest + +from rsconnect.exception import RSConnectException +from rsconnect.actions import cli_feedback +from rsconnect.main import cli, deploy +from rsconnect.version_check import ( + RSCONNECT_DISABLE_VERSION_CHECK, + BackgroundVersionCheck, + _is_check_disabled, + _is_dev_version, + _read_cache, + _update_message, + _write_cache, +) + + +def _cli_runner() -> click.testing.CliRunner: + """Build a CliRunner with stdout/stderr kept separate across Click versions. + + Click < 8.2 needs ``mix_stderr=False`` to expose ``result.stderr``; Click >= 8.2 + removed the argument and always separates the streams. + """ + try: + return click.testing.CliRunner(mix_stderr=False) + except TypeError: + return click.testing.CliRunner() + + +# A throwaway deploy subcommand so we can exercise the deploy group's result +# callback without contacting a real server. +@deploy.command(name="_version_check_test_noop", hidden=True) +def _version_check_test_noop(): + click.echo("noop-ran") + + +# A deploy subcommand that fails the way real ones do: cli_feedback catches the +# exception and calls sys.exit(1). The upgrade hint should still print. +@deploy.command(name="_version_check_test_fail", hidden=True) +def _version_check_test_fail(): + with cli_feedback(""): + raise RSConnectException("boom") + + +class TestIsCheckDisabled: + def test_unset(self): + with patch.dict(os.environ, {}, clear=True): + assert _is_check_disabled() is False + + @pytest.mark.parametrize("value", ["1", "true", "yes", "TRUE", "Yes"]) + def test_disabled(self, value): + with patch.dict(os.environ, {RSCONNECT_DISABLE_VERSION_CHECK: value}): + assert _is_check_disabled() is True + + @pytest.mark.parametrize("value", ["0", "no", "false", ""]) + def test_not_disabled(self, value): + with patch.dict(os.environ, {RSCONNECT_DISABLE_VERSION_CHECK: value}): + assert _is_check_disabled() is False + + +class TestIsDevVersion: + def test_dev_version(self): + assert _is_dev_version("1.29.1.dev10+g68dd1934d.d20260622") is True + + def test_release_version(self): + assert _is_dev_version("1.29.1") is False + + def test_prerelease_version(self): + assert _is_dev_version("1.29.1rc1") is False + + def test_unparseable(self): + assert _is_dev_version("NOTSET") is True + + +class TestUpdateMessage: + @patch("rsconnect.version_check.VERSION", "1.28.0") + def test_update_available(self): + message = _update_message("1.29.0") + assert message is not None + assert "1.29.0" in message + assert "pip install --upgrade rsconnect-python" in message + + @patch("rsconnect.version_check.VERSION", "1.29.0") + def test_up_to_date(self): + assert _update_message("1.29.0") is None + + @patch("rsconnect.version_check.VERSION", "1.30.0") + def test_ahead_of_pypi(self): + assert _update_message("1.29.0") is None + + def test_none_latest(self): + assert _update_message(None) is None + + @patch("rsconnect.version_check.VERSION", "1.28.0") + def test_unparseable_latest(self): + assert _update_message("not-a-version") is None + + +class TestCache: + def test_round_trip(self, tmp_path): + path = str(tmp_path / "version_check.json") + with patch("rsconnect.version_check._cache_path", return_value=path): + _write_cache("1.29.0") + assert _read_cache() == (True, "1.29.0") + + def test_caches_none(self, tmp_path): + path = str(tmp_path / "version_check.json") + with patch("rsconnect.version_check._cache_path", return_value=path): + _write_cache(None) + assert _read_cache() == (True, None) + + def test_missing_cache(self, tmp_path): + path = str(tmp_path / "nope.json") + with patch("rsconnect.version_check._cache_path", return_value=path): + assert _read_cache() == (False, None) + + def test_expired_cache(self, tmp_path): + # A stale entry is reported as not-fresh but still surfaces its value, so + # the upgrade hint can use it while a refresh runs in the background. + path = tmp_path / "version_check.json" + path.write_text(json.dumps({"checked_at": time.time() - 999999, "latest": "1.29.0"})) + with patch("rsconnect.version_check._cache_path", return_value=str(path)): + assert _read_cache() == (False, "1.29.0") + + def test_corrupt_cache(self, tmp_path): + path = tmp_path / "version_check.json" + path.write_text("{not json") + with patch("rsconnect.version_check._cache_path", return_value=str(path)): + assert _read_cache() == (False, None) + + +class TestBackgroundVersionCheck: + @patch("rsconnect.version_check._read_cache", return_value=(True, "2.0.0")) + @patch("rsconnect.version_check.VERSION", "1.0.0") + @patch("rsconnect.version_check._is_dev_version", return_value=False) + @patch("rsconnect.version_check._is_check_disabled", return_value=False) + def test_fresh_cache_avoids_thread(self, _disabled, _dev, mock_read): + checker = BackgroundVersionCheck() + checker.start() + assert checker._thread is None # No network when the cache is fresh. + message = checker.get_warning_message() + assert message is not None + assert "2.0.0" in message + + @patch("rsconnect.version_check._write_cache") + @patch("rsconnect.version_check._fetch_latest_version", return_value="3.0.0") + @patch("rsconnect.version_check._read_cache", return_value=(False, "2.0.0")) + @patch("rsconnect.version_check.VERSION", "1.0.0") + @patch("rsconnect.version_check._is_dev_version", return_value=False) + @patch("rsconnect.version_check._is_check_disabled", return_value=False) + def test_stale_cache_warns_from_cache_and_refreshes(self, _disabled, _dev, _read, mock_fetch, mock_write): + checker = BackgroundVersionCheck() + checker.start() + # A stale cached value is enough to warn synchronously, so the message is + # available without waiting on the network (it names either the stale + # 2.0.0 or the freshly fetched 3.0.0, depending on thread timing -- both + # are newer than the running version, so either is a valid hint). + assert checker._thread is not None + message = checker.get_warning_message() + assert message is not None + # The fetch also refreshes the cache for the next invocation. + checker._thread.join() + mock_fetch.assert_called_once() + mock_write.assert_called_once_with("3.0.0") + + @patch("rsconnect.version_check._write_cache") + @patch("rsconnect.version_check._fetch_latest_version", return_value="2.0.0") + @patch("rsconnect.version_check._read_cache", return_value=(False, None)) + @patch("rsconnect.version_check.VERSION", "1.0.0") + @patch("rsconnect.version_check._is_dev_version", return_value=False) + @patch("rsconnect.version_check._is_check_disabled", return_value=False) + def test_absent_cache_blocks_briefly_and_warns(self, _disabled, _dev, _read, mock_fetch, mock_write): + checker = BackgroundVersionCheck() + checker.start() + # With no cached value, the in-flight fetch is given a bounded chance to + # finish so even a cold cache (first run, or an ephemeral environment) + # can still warn this run -- not just refresh for the next one. + assert checker._thread is not None + message = checker.get_warning_message() + assert message is not None + assert "2.0.0" in message + checker._thread.join() + mock_write.assert_called_once_with("2.0.0") + + @patch("rsconnect.version_check._read_cache", return_value=(True, None)) + @patch("rsconnect.version_check._is_dev_version", return_value=False) + @patch("rsconnect.version_check._is_check_disabled", return_value=False) + def test_no_warning_when_cache_has_no_version(self, _disabled, _dev, _read): + checker = BackgroundVersionCheck() + checker.start() + assert checker.get_warning_message() is None + + @patch("rsconnect.version_check._PYPI_TIMEOUT_SECONDS", 0.2) + @patch("rsconnect.version_check._write_cache") + @patch("rsconnect.version_check._read_cache", return_value=(False, None)) + @patch("rsconnect.version_check._is_dev_version", return_value=False) + @patch("rsconnect.version_check._is_check_disabled", return_value=False) + def test_hung_fetch_does_not_block_beyond_timeout(self, _disabled, _dev, _read, _write): + # A fetch that never returns (network black hole) must not hang the CLI: + # the cold-cache wait is bounded by _PYPI_TIMEOUT_SECONDS, after which the + # run proceeds with no hint and the daemon thread is left behind. + release = threading.Event() + + def hung_fetch(): + release.wait(timeout=30) # Far longer than the join timeout. + return "99.0.0" + + try: + with patch("rsconnect.version_check._fetch_latest_version", side_effect=hung_fetch): + checker = BackgroundVersionCheck() + checker.start() + assert checker._thread is not None + assert checker._thread.daemon # Never blocks interpreter exit. + start = time.monotonic() + message = checker.get_warning_message() + elapsed = time.monotonic() - start + # Returned promptly (well under the 30s fetch) with no hint this run. + assert message is None + assert elapsed < 5 + finally: + release.set() # Let the background thread unwind. + + @patch("rsconnect.version_check._read_cache") + @patch("rsconnect.version_check._is_dev_version", return_value=True) + @patch("rsconnect.version_check._is_check_disabled", return_value=False) + def test_no_thread_for_dev_version(self, _disabled, _dev, mock_read): + checker = BackgroundVersionCheck() + checker.start() + assert checker._thread is None + mock_read.assert_not_called() + assert checker.get_warning_message() is None + + @patch("rsconnect.version_check._read_cache") + @patch("rsconnect.version_check._is_dev_version", return_value=False) + @patch("rsconnect.version_check._is_check_disabled", return_value=True) + def test_no_thread_when_disabled(self, _disabled, _dev, mock_read): + checker = BackgroundVersionCheck() + checker.start() + assert checker._thread is None + mock_read.assert_not_called() + assert checker.get_warning_message() is None + + +class TestCLIIntegration: + @patch("rsconnect.version_check._read_cache", return_value=(True, "99.0.0")) + @patch("rsconnect.version_check.VERSION", "1.0.0") + @patch("rsconnect.version_check._is_dev_version", return_value=False) + @patch("rsconnect.version_check._is_check_disabled", return_value=False) + def test_warning_on_deploy_command_stderr(self, _disabled, _dev, _read): + runner = _cli_runner() + result = runner.invoke(cli, ["deploy", "_version_check_test_noop"]) + assert result.exit_code == 0 + # Use stdout (not output): in Click >= 8.2 result.output is the combined + # stream, while stdout stays stderr-free across Click versions. + assert "99.0.0" not in result.stdout + assert "99.0.0" in result.stderr + + @patch("rsconnect.version_check._read_cache", return_value=(True, "99.0.0")) + @patch("rsconnect.version_check.VERSION", "1.0.0") + @patch("rsconnect.version_check._is_dev_version", return_value=False) + @patch("rsconnect.version_check._is_check_disabled", return_value=False) + def test_warning_on_failed_deploy_command(self, _disabled, _dev, _read): + runner = _cli_runner() + result = runner.invoke(cli, ["deploy", "_version_check_test_fail"]) + assert result.exit_code != 0 + # The hint prints even though the deploy failed and exited non-zero. + assert "99.0.0" in result.stderr + + @patch("rsconnect.version_check._write_cache") + @patch("rsconnect.version_check._read_cache", return_value=(False, None)) + @patch("rsconnect.version_check.VERSION", "1.0.0") + @patch("rsconnect.version_check._is_dev_version", return_value=False) + @patch("rsconnect.version_check._is_check_disabled", return_value=False) + def test_warning_on_failed_deploy_with_cold_cache(self, _disabled, _dev, _read, _write): + # The original regression: a fast failure (missing requirements.txt, etc.) + # with no cache. The deploy raises and exits almost instantly, so a fetch + # with any real latency would lose the race -- the warning only prints + # because get_warning_message blocks briefly on the cold cache. The slow + # fetch below makes that race deterministic: without the blocking join the + # thread couldn't have set the version in time. + def slow_fetch(): + time.sleep(0.3) + return "99.0.0" + + runner = _cli_runner() + with patch("rsconnect.version_check._fetch_latest_version", side_effect=slow_fetch): + result = runner.invoke(cli, ["deploy", "_version_check_test_fail"]) + assert result.exit_code != 0 + assert "99.0.0" in result.stderr + + @patch("rsconnect.version_check._read_cache", return_value=(True, None)) + @patch("rsconnect.version_check._is_dev_version", return_value=False) + @patch("rsconnect.version_check._is_check_disabled", return_value=False) + def test_no_warning_when_current(self, _disabled, _dev, _read): + runner = _cli_runner() + result = runner.invoke(cli, ["deploy", "_version_check_test_noop"]) + assert result.exit_code == 0 + assert "new version" not in result.stderr + + @patch("rsconnect.version_check._read_cache", return_value=(True, "99.0.0")) + @patch("rsconnect.version_check.VERSION", "1.0.0") + @patch("rsconnect.version_check._is_dev_version", return_value=False) + @patch("rsconnect.version_check._is_check_disabled", return_value=False) + def test_no_check_on_non_deploy_command(self, _disabled, _dev, _read): + runner = _cli_runner() + result = runner.invoke(cli, ["version"]) + assert result.exit_code == 0 + assert "99.0.0" not in result.stderr + # The check never runs for non-deploy commands, so the cache isn't even read. + _read.assert_not_called()