Skip to content
Draft
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 @@ -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
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 @@ -1325,8 +1326,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, Posit Cloud, 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
140 changes: 140 additions & 0 deletions rsconnect/version_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""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 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()
# 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:
_write_cache(_fetch_latest_version())

def get_warning_message(self) -> Optional[str]:
return _update_message(self._latest)
Loading
Loading