From e5169d2b770a1d41138b9483d49438feb345a3cc Mon Sep 17 00:00:00 2001 From: joshyam-k Date: Wed, 24 Jun 2026 10:59:42 -0700 Subject: [PATCH 1/5] add version warn --- docs/CHANGELOG.md | 4 + docs/deploying.md | 15 +++ pyproject.toml | 1 + rsconnect/main.py | 21 +++- rsconnect/version_check.py | 143 +++++++++++++++++++++++++ tests/test_version_check.py | 203 ++++++++++++++++++++++++++++++++++++ 6 files changed, 385 insertions(+), 2 deletions(-) create mode 100644 rsconnect/version_check.py create mode 100644 tests/test_version_check.py diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6e52bc83..292ac554 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 0af64af9..a96a885c 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -432,6 +432,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 aa4ef4a2..388ed7d6 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 e4679d40..b7c42250 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, @@ -1325,8 +1326,24 @@ def quickstart(app_type: str, name: str, python_version: Optional[str]): @cli.group(no_args_is_help=True, help="Deploy content to Posit Connect, Posit Cloud, or shinyapps.io.") -def deploy(): - pass +@click.pass_context +def deploy(ctx: click.Context): + ctx.ensure_object(dict) + checker = BackgroundVersionCheck() + checker.start() + ctx.obj["version_checker"] = checker + + +@deploy.result_callback() +@click.pass_context +def _print_version_warning( # pyright: ignore[reportUnusedFunction] + ctx: click.Context, *args: object, **kwargs: object +) -> None: + checker = ctx.obj.get("version_checker") if ctx.obj else None + if checker is not None: + message = checker.get_warning_message() + if message: + click.secho(message, fg="yellow", err=True) 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..73a63590 --- /dev/null +++ b/rsconnect/version_check.py @@ -0,0 +1,143 @@ +"""Background version update check against PyPI. + +Checked only on deploy commands. The latest known version from PyPI is cached on +disk and refreshed at most once per :data:`_CACHE_TTL_SECONDS`, so the common case +adds no network traffic and no latency. +""" + +from __future__ import annotations + +import json +import os +import threading +import time +from http.client import HTTPSConnection +from os.path import dirname, join +from typing import Optional, Tuple + +from packaging.version import Version + +from . import VERSION +from .metadata import config_dirname + +RSCONNECT_DISABLE_VERSION_CHECK = "RSCONNECT_DISABLE_VERSION_CHECK" +_PYPI_HOST = "pypi.org" +_PYPI_PATH = "/pypi/rsconnect-python/json" +_PYPI_TIMEOUT_SECONDS = 2 +_JOIN_TIMEOUT_SECONDS = 0.5 +_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)``. + + ``is_fresh`` is True when a non-expired cache entry exists; ``latest`` is the + cached PyPI version (or None, e.g. when the last fetch failed). When the cache + is missing, expired, or unreadable, returns ``(False, None)``. + """ + try: + with open(_cache_path()) as f: + data = json.load(f) + if time.time() - float(data["checked_at"]) > _CACHE_TTL_SECONDS: + return (False, None) + latest = data.get("latest") + return (True, latest if isinstance(latest, str) else None) + 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() + os.makedirs(dirname(path), exist_ok=True) + 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: + try: + conn.close() + except Exception: + pass + + +def _update_message(latest: Optional[str]) -> Optional[str]: + """Return an upgrade warning if ``latest`` is newer than the running version.""" + if latest is None: + return None + try: + if 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: + return None + 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() + if is_fresh: + # Use the cached value directly; no network, no thread. + self._latest = latest + return + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def _run(self) -> None: + latest = _fetch_latest_version() + _write_cache(latest) + self._latest = latest + + def get_warning_message(self) -> Optional[str]: + if self._thread is not None: + self._thread.join(timeout=_JOIN_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..1b41d638 --- /dev/null +++ b/tests/test_version_check.py @@ -0,0 +1,203 @@ +import json +import os +import time +from unittest.mock import patch + +import click +import click.testing +import pytest + +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, +) + + +# 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") + + +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): + 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, None) + + 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="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_stale_cache_fetches_and_writes(self, _disabled, _dev, _read, mock_fetch, mock_write): + checker = BackgroundVersionCheck() + checker.start() + assert checker._thread is not None + message = checker.get_warning_message() + assert message is not None + assert "2.0.0" in message + mock_fetch.assert_called_once() + 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._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 = click.testing.CliRunner(mix_stderr=False) + result = runner.invoke(cli, ["deploy", "_version_check_test_noop"]) + assert result.exit_code == 0 + assert "99.0.0" not in result.output + 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 = click.testing.CliRunner(mix_stderr=False) + 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 = click.testing.CliRunner(mix_stderr=False) + 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() From 9e2138d5b9bce5c5d14f5c0d298efb587e952810 Mon Sep 17 00:00:00 2001 From: joshyam-k Date: Wed, 24 Jun 2026 11:49:27 -0700 Subject: [PATCH 2/5] ensure message prints on every exit path --- rsconnect/main.py | 15 +++++---------- rsconnect/version_check.py | 17 ++++++----------- tests/test_version_check.py | 21 +++++++++++++++++++++ 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/rsconnect/main.py b/rsconnect/main.py index b7c42250..2cc20537 100644 --- a/rsconnect/main.py +++ b/rsconnect/main.py @@ -1328,23 +1328,18 @@ def quickstart(app_type: str, name: str, python_version: Optional[str]): @cli.group(no_args_is_help=True, help="Deploy content to Posit Connect, Posit Cloud, or shinyapps.io.") @click.pass_context def deploy(ctx: click.Context): - ctx.ensure_object(dict) checker = BackgroundVersionCheck() checker.start() - ctx.obj["version_checker"] = checker - -@deploy.result_callback() -@click.pass_context -def _print_version_warning( # pyright: ignore[reportUnusedFunction] - ctx: click.Context, *args: object, **kwargs: object -) -> None: - checker = ctx.obj.get("version_checker") if ctx.obj else None - if checker is not None: + 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 index 73a63590..5d460cae 100644 --- a/rsconnect/version_check.py +++ b/rsconnect/version_check.py @@ -12,13 +12,13 @@ import threading import time from http.client import HTTPSConnection -from os.path import dirname, join +from os.path import join from typing import Optional, Tuple from packaging.version import Version from . import VERSION -from .metadata import config_dirname +from .metadata import config_dirname, makedirs RSCONNECT_DISABLE_VERSION_CHECK = "RSCONNECT_DISABLE_VERSION_CHECK" _PYPI_HOST = "pypi.org" @@ -71,7 +71,7 @@ def _write_cache(latest: Optional[str]) -> None: """ try: path = _cache_path() - os.makedirs(dirname(path), exist_ok=True) + makedirs(path) with open(path, "w") as f: json.dump({"checked_at": time.time(), "latest": latest}, f) except Exception: @@ -92,25 +92,20 @@ def _fetch_latest_version() -> Optional[str]: return None finally: if conn is not None: - try: - conn.close() - except Exception: - pass + conn.close() def _update_message(latest: Optional[str]) -> Optional[str]: """Return an upgrade warning if ``latest`` is newer than the running version.""" - if latest is None: - return None try: - if Version(latest) > Version(VERSION): + 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: - return None + pass return None diff --git a/tests/test_version_check.py b/tests/test_version_check.py index 1b41d638..8eb8ee33 100644 --- a/tests/test_version_check.py +++ b/tests/test_version_check.py @@ -7,6 +7,8 @@ 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, @@ -26,6 +28,14 @@ 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): @@ -181,6 +191,17 @@ def test_warning_on_deploy_command_stderr(self, _disabled, _dev, _read): assert "99.0.0" not in result.output 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 = click.testing.CliRunner(mix_stderr=False) + 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._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) From a92c6f1c94bf46dd872158222388ccacf1303f32 Mon Sep 17 00:00:00 2001 From: joshyam-k Date: Wed, 24 Jun 2026 12:34:46 -0700 Subject: [PATCH 3/5] fix click incompatability with some python versions --- tests/test_version_check.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/test_version_check.py b/tests/test_version_check.py index 8eb8ee33..14a81b07 100644 --- a/tests/test_version_check.py +++ b/tests/test_version_check.py @@ -21,6 +21,18 @@ ) +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) @@ -185,7 +197,7 @@ class TestCLIIntegration: @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 = click.testing.CliRunner(mix_stderr=False) + runner = _cli_runner() result = runner.invoke(cli, ["deploy", "_version_check_test_noop"]) assert result.exit_code == 0 assert "99.0.0" not in result.output @@ -196,7 +208,7 @@ def test_warning_on_deploy_command_stderr(self, _disabled, _dev, _read): @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 = click.testing.CliRunner(mix_stderr=False) + 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. @@ -206,7 +218,7 @@ def test_warning_on_failed_deploy_command(self, _disabled, _dev, _read): @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 = click.testing.CliRunner(mix_stderr=False) + runner = _cli_runner() result = runner.invoke(cli, ["deploy", "_version_check_test_noop"]) assert result.exit_code == 0 assert "new version" not in result.stderr @@ -216,7 +228,7 @@ def test_no_warning_when_current(self, _disabled, _dev, _read): @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 = click.testing.CliRunner(mix_stderr=False) + runner = _cli_runner() result = runner.invoke(cli, ["version"]) assert result.exit_code == 0 assert "99.0.0" not in result.stderr From 135ed5dc289e484019c3de6f29d569e755d85747 Mon Sep 17 00:00:00 2001 From: joshyam-k Date: Wed, 24 Jun 2026 12:41:33 -0700 Subject: [PATCH 4/5] more click workarounds --- tests/test_version_check.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_version_check.py b/tests/test_version_check.py index 14a81b07..0f71033a 100644 --- a/tests/test_version_check.py +++ b/tests/test_version_check.py @@ -200,7 +200,9 @@ 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 - assert "99.0.0" not in result.output + # 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")) From ab9011c776d591781b30169617b02a4154fa647e Mon Sep 17 00:00:00 2001 From: joshyam-k Date: Wed, 24 Jun 2026 14:25:54 -0700 Subject: [PATCH 5/5] still print on fast failure --- rsconnect/version_check.py | 38 +++++++++++++++++++------------------ tests/test_version_check.py | 30 +++++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/rsconnect/version_check.py b/rsconnect/version_check.py index 5d460cae..eaa70583 100644 --- a/rsconnect/version_check.py +++ b/rsconnect/version_check.py @@ -24,7 +24,6 @@ _PYPI_HOST = "pypi.org" _PYPI_PATH = "/pypi/rsconnect-python/json" _PYPI_TIMEOUT_SECONDS = 2 -_JOIN_TIMEOUT_SECONDS = 0.5 _CACHE_TTL_SECONDS = 24 * 60 * 60 # Re-check PyPI at most once a day. _CACHE_FILENAME = "version_check.json" @@ -48,17 +47,19 @@ def _cache_path() -> str: def _read_cache() -> Tuple[bool, Optional[str]]: """Return ``(is_fresh, latest)``. - ``is_fresh`` is True when a non-expired cache entry exists; ``latest`` is the - cached PyPI version (or None, e.g. when the last fetch failed). When the cache - is missing, expired, or unreadable, returns ``(False, None)``. + ``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) - if time.time() - float(data["checked_at"]) > _CACHE_TTL_SECONDS: - return (False, None) latest = data.get("latest") - return (True, latest if isinstance(latest, str) else None) + 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) @@ -120,19 +121,20 @@ def start(self) -> None: if _is_check_disabled() or _is_dev_version(VERSION): return is_fresh, latest = _read_cache() - if is_fresh: - # Use the cached value directly; no network, no thread. - self._latest = latest - return - self._thread = threading.Thread(target=self._run, daemon=True) - self._thread.start() + # Drive the hint from the cached value, synchronously. Even a stale value + # is good enough to suggest an upgrade and reading it adds no latency, so + # the warning prints reliably on every exit path -- including commands + # that fail fast before any network fetch could have completed. + self._latest = latest + if not is_fresh: + # Refresh the cache in the background for the next invocation. This + # run never waits on or reads the result, so its output (and timing) + # are unaffected by the fetch. + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() def _run(self) -> None: - latest = _fetch_latest_version() - _write_cache(latest) - self._latest = latest + _write_cache(_fetch_latest_version()) def get_warning_message(self) -> Optional[str]: - if self._thread is not None: - self._thread.join(timeout=_JOIN_TIMEOUT_SECONDS) return _update_message(self._latest) diff --git a/tests/test_version_check.py b/tests/test_version_check.py index 0f71033a..17904914 100644 --- a/tests/test_version_check.py +++ b/tests/test_version_check.py @@ -121,10 +121,12 @@ def test_missing_cache(self, tmp_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, None) + assert _read_cache() == (False, "1.29.0") def test_corrupt_cache(self, tmp_path): path = tmp_path / "version_check.json" @@ -147,19 +149,39 @@ def test_fresh_cache_avoids_thread(self, _disabled, _dev, mock_read): assert "2.0.0" in message @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._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_fetches_and_writes(self, _disabled, _dev, _read, mock_fetch, mock_write): + def test_stale_cache_warns_from_cache_and_refreshes(self, _disabled, _dev, _read, mock_fetch, mock_write): checker = BackgroundVersionCheck() checker.start() + # The warning is driven by the stale cached value, not the fetch result, + # so it is available synchronously without waiting on the network. assert checker._thread is not None message = checker.get_warning_message() assert message is not None assert "2.0.0" in message + # The fetch only 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_is_silent_but_refreshes(self, _disabled, _dev, _read, mock_fetch, mock_write): + checker = BackgroundVersionCheck() + checker.start() + # With no cached value there is nothing to show on this run; the fetch + # warms the cache so the next invocation can warn. + assert checker._thread is not None + assert checker.get_warning_message() is None + checker._thread.join() mock_write.assert_called_once_with("2.0.0") @patch("rsconnect.version_check._read_cache", return_value=(True, None))