Files
manifoldbt/python/tests/test_update_check.py
T

336 lines
12 KiB
Python
Raw Normal View History

2026-08-25 18:49:00 +02:00
"""The PyPI update notice.
Everything here runs offline: the one function that touches the network
(``_fetch``) is either replaced or driven through a fake ``urlopen``. A test that
really queried PyPI would be a test that fails on a plane.
"""
import json
import os
import threading
import time
import urllib.error
2026-10-05 21:11:28 +00:00
import urllib.request # manifoldbt._update imports it only where it asks PyPI
2026-08-25 18:49:00 +02:00
import pytest
from manifoldbt import _update
@pytest.fixture(autouse=True)
def isolated(tmp_path, monkeypatch):
"""A private cache, no opt-out in the environment, and fresh process state."""
# Importing manifoldbt started a real check on a daemon thread. Let it finish
# before the cache directory is redirected, or it lands in this test's
# tmp_path and the assertions below read someone else's answer.
for thread in threading.enumerate():
if thread.name == "manifoldbt-update-check":
thread.join(timeout=10)
monkeypatch.setattr(_update, "_cache_dir", lambda: str(tmp_path))
for var in ("MANIFOLDBT_NO_UPDATE_CHECK", "MANIFOLDBT_NO_TELEMETRY", "CI"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setattr(_update, "_started", False)
monkeypatch.setattr(_update, "_announced", False)
monkeypatch.setattr(_update, "_current", None)
monkeypatch.setattr(_update, "_pending", None)
yield
def _write_cache(state):
_update._save(state)
def _read_cache():
with open(_update._cache_file(), encoding="utf-8") as handle:
return json.load(handle)
# ---------------------------------------------------------------------------
# Version comparison
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"current, latest, expected",
[
("0.19.1", "0.19.2", "0.19.2"),
("0.19.1", "0.20.0", "0.20.0"),
("0.9.0", "0.10.0", "0.10.0"), # not a string comparison
("0.19.1", "0.19.1", None),
("0.19.2", "0.19.1", None), # a local build ahead of PyPI says nothing
("0.19", "0.19.0", None), # 0.19 and 0.19.0 are the same release
("0.19.1", "0.20.0rc1", None), # `pip install -U` would not take it
("0.20.0rc1", "0.20.0rc2", "0.20.0rc2"), # unless you asked for pre-releases
("0.20.0rc1", "0.20.0", "0.20.0"),
("0.19.1", "0.19.1.post1", "0.19.1.post1"),
("0.19.1+cuda12", "0.19.2", "0.19.2"), # a local segment does not order releases
("0.19.1", "not-a-version", None),
("not-a-version", "0.19.2", None),
("0.19.1", "1!0.1.0", None), # an epoch is not understood, so: silence
],
)
def test_newer_decides_what_is_worth_saying(current, latest, expected):
assert _update._newer(current, latest) == expected
def test_prerelease_ordering_follows_pep440():
order = ["1.0.dev1", "1.0a1", "1.0b1", "1.0rc1", "1.0", "1.0.post1", "1.0.1"]
keys = [_update._parse(v) for v in order]
assert keys == sorted(keys)
# ---------------------------------------------------------------------------
# Cache
# ---------------------------------------------------------------------------
def test_unreadable_cache_reads_as_never_checked(tmp_path):
with open(_update._cache_file(), "w", encoding="utf-8") as handle:
handle.write("{not json")
assert _update._load() == {}
def test_save_leaves_no_temporary_file_behind(tmp_path):
_update._save({"latest": "0.19.2"})
assert _update._load() == {"latest": "0.19.2"}
assert os.listdir(str(tmp_path)) == ["update-check.json"]
# ---------------------------------------------------------------------------
# Refresh
# ---------------------------------------------------------------------------
def test_refresh_records_the_answer(monkeypatch):
monkeypatch.setattr(
_update, "_fetch", lambda etag: {"latest": "0.20.0", "etag": '"abc"'}
)
assert _update._refresh({}) == "0.20.0"
cached = _read_cache()
assert cached["latest"] == "0.20.0"
assert cached["etag"] == '"abc"'
assert cached["last_check"] > 0
def test_offline_keeps_the_last_answer_and_still_burns_the_day(monkeypatch):
"""An unreachable network must not mean one request per import for the rest
of the day, and it is not evidence that yesterday's answer was wrong."""
monkeypatch.setattr(_update, "_fetch", lambda etag: None)
state = {"latest": "0.20.0", "last_check": 0.0}
assert _update._refresh(state) == "0.20.0"
cached = _read_cache()
assert cached["latest"] == "0.20.0"
assert cached["last_check"] > time.time() - 60
def test_unchanged_response_keeps_the_cached_version_and_etag(monkeypatch):
seen = {}
def fetch(etag):
seen["etag"] = etag
return {"unchanged": True}
monkeypatch.setattr(_update, "_fetch", fetch)
assert _update._refresh({"latest": "0.20.0", "etag": '"abc"'}) == "0.20.0"
assert seen["etag"] == '"abc"' # the 304 was actually asked for
assert _read_cache()["etag"] == '"abc"'
def test_a_yanked_release_is_never_recommended(monkeypatch):
monkeypatch.setattr(
_update, "_fetch", lambda etag: {"latest": "0.20.0", "yanked": True}
)
assert _update._refresh({"latest": "0.19.9"}) == "0.19.9"
assert _read_cache()["latest"] == "0.19.9"
# ---------------------------------------------------------------------------
# The HTTP call itself
# ---------------------------------------------------------------------------
class _FakeResponse:
def __init__(self, body, headers=None):
self._body = body.encode("utf-8")
self.headers = headers or {}
def read(self):
return self._body
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def test_fetch_reads_info_version_from_the_pypi_json_api(monkeypatch):
captured = {}
def urlopen(request, timeout=None):
captured["url"] = request.full_url
captured["headers"] = {k.lower(): v for k, v in request.headers.items()}
body = json.dumps({"info": {"version": "0.20.0", "yanked": False}})
return _FakeResponse(body, {"ETag": '"xyz"'})
2026-10-05 21:11:28 +00:00
monkeypatch.setattr(urllib.request, "urlopen", urlopen)
2026-08-25 18:49:00 +02:00
assert _update._fetch(None) == {
"latest": "0.20.0",
"yanked": False,
"etag": '"xyz"',
}
assert captured["url"] == "https://pypi.org/pypi/manifoldbt/json"
assert "if-none-match" not in captured["headers"] # nothing cached to revalidate
def test_fetch_revalidates_with_the_stored_etag(monkeypatch):
captured = {}
def urlopen(request, timeout=None):
captured["headers"] = {k.lower(): v for k, v in request.headers.items()}
raise urllib.error.HTTPError(request.full_url, 304, "Not Modified", {}, None)
2026-10-05 21:11:28 +00:00
monkeypatch.setattr(urllib.request, "urlopen", urlopen)
2026-08-25 18:49:00 +02:00
assert _update._fetch('"xyz"') == {"unchanged": True}
assert captured["headers"]["if-none-match"] == '"xyz"'
def test_fetch_swallows_everything_else(monkeypatch):
def urlopen(request, timeout=None):
raise OSError("no route to host")
2026-10-05 21:11:28 +00:00
monkeypatch.setattr(urllib.request, "urlopen", urlopen)
2026-08-25 18:49:00 +02:00
assert _update._fetch(None) is None
def test_fetch_swallows_a_server_error(monkeypatch):
def urlopen(request, timeout=None):
raise urllib.error.HTTPError(request.full_url, 503, "nope", {}, None)
2026-10-05 21:11:28 +00:00
monkeypatch.setattr(urllib.request, "urlopen", urlopen)
2026-08-25 18:49:00 +02:00
assert _update._fetch(None) is None
# ---------------------------------------------------------------------------
# start(): what the user actually sees at import
# ---------------------------------------------------------------------------
def _never_called(*args, **kwargs):
raise AssertionError("the network was touched")
def test_a_fresh_cache_prints_the_notice_without_asking_pypi(monkeypatch, capsys):
monkeypatch.setattr(_update, "installed_version", lambda: "0.19.1")
monkeypatch.setattr(_update, "_fetch", _never_called)
_write_cache({"latest": "0.20.0", "last_check": time.time()})
_update.start()
out = capsys.readouterr().out
assert "0.20.0" in out and "0.19.1" in out
assert "pip install -U manifoldbt" in out
def test_being_up_to_date_prints_nothing(monkeypatch, capsys):
monkeypatch.setattr(_update, "installed_version", lambda: "0.20.0")
monkeypatch.setattr(_update, "_fetch", _never_called)
_write_cache({"latest": "0.20.0", "last_check": time.time()})
_update.start()
assert capsys.readouterr().out == ""
def test_a_source_checkout_is_never_compared(monkeypatch, capsys):
monkeypatch.setattr(_update, "installed_version", lambda: None)
monkeypatch.setattr(_update, "_fetch", _never_called)
_write_cache({"latest": "99.0.0", "last_check": time.time()})
_update.start()
assert capsys.readouterr().out == ""
@pytest.mark.parametrize(
"var", ["MANIFOLDBT_NO_UPDATE_CHECK", "MANIFOLDBT_NO_TELEMETRY", "CI"]
)
def test_opt_out_silences_it_entirely(monkeypatch, capsys, var):
monkeypatch.setenv(var, "1")
monkeypatch.setattr(_update, "installed_version", lambda: "0.19.1")
monkeypatch.setattr(_update, "_fetch", _never_called)
_write_cache({"latest": "0.20.0", "last_check": time.time()})
_update.start()
assert capsys.readouterr().out == ""
def test_opt_out_reads_zero_as_opting_in(monkeypatch, capsys):
monkeypatch.setenv("MANIFOLDBT_NO_UPDATE_CHECK", "0")
monkeypatch.setattr(_update, "installed_version", lambda: "0.19.1")
monkeypatch.setattr(_update, "_fetch", _never_called)
_write_cache({"latest": "0.20.0", "last_check": time.time()})
_update.start()
assert "0.20.0" in capsys.readouterr().out
def test_a_stale_cache_asks_pypi_in_the_background(monkeypatch, capsys):
"""The first run after a release: nothing to print at import, so the answer
comes back on the daemon thread and the exit hook says it."""
monkeypatch.setattr(_update, "installed_version", lambda: "0.19.1")
monkeypatch.setattr(_update, "_fetch", lambda etag: {"latest": "0.20.0"})
_write_cache({"latest": "0.19.1", "last_check": time.time() - 48 * 3600})
thread = _update.start()
assert capsys.readouterr().out == "" # the banner is not delayed for this
thread.join(timeout=5)
_update._flush() # what atexit runs
assert "0.20.0" in capsys.readouterr().out
assert _read_cache()["latest"] == "0.20.0"
def test_the_notice_is_printed_once_per_process(monkeypatch, capsys):
monkeypatch.setattr(_update, "installed_version", lambda: "0.19.1")
monkeypatch.setattr(_update, "_fetch", lambda etag: {"latest": "0.20.0"})
_write_cache({"latest": "0.20.0", "last_check": time.time() - 48 * 3600})
thread = _update.start()
assert "0.20.0" in capsys.readouterr().out
thread.join(timeout=5)
_update._flush()
assert capsys.readouterr().out == ""
def test_start_is_idempotent(monkeypatch, capsys):
monkeypatch.setattr(_update, "installed_version", lambda: "0.19.1")
monkeypatch.setattr(_update, "_fetch", _never_called)
_write_cache({"latest": "0.20.0", "last_check": time.time()})
_update.start()
capsys.readouterr()
_update.start()
assert capsys.readouterr().out == ""
# ---------------------------------------------------------------------------
# check_for_update()
# ---------------------------------------------------------------------------
def test_check_now_queries_and_answers(monkeypatch):
monkeypatch.setattr(_update, "installed_version", lambda: "0.19.1")
monkeypatch.setattr(_update, "_fetch", lambda etag: {"latest": "0.20.0"})
assert _update.check_now() == "0.20.0"
def test_check_now_is_none_when_current(monkeypatch):
monkeypatch.setattr(_update, "installed_version", lambda: "0.20.0")
monkeypatch.setattr(_update, "_fetch", lambda etag: {"latest": "0.20.0"})
assert _update.check_now() is None
def test_check_now_is_none_offline(monkeypatch):
monkeypatch.setattr(_update, "installed_version", lambda: "0.19.1")
monkeypatch.setattr(_update, "_fetch", lambda etag: None)
assert _update.check_now() is None
def test_public_alias_is_exported():
import manifoldbt as bt
assert "check_for_update" in bt.__all__
assert callable(bt.check_for_update)