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
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions docs/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
]

Expand Down
16 changes: 14 additions & 2 deletions rsconnect/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
154 changes: 154 additions & 0 deletions rsconnect/version_check.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading