From 0094b21b2eeaa9c0c386800128f0b2baf5cbd7da Mon Sep 17 00:00:00 2001 From: mart-r Date: Fri, 10 Oct 2025 11:36:10 +0100 Subject: [PATCH 01/10] CU-869ary4dq: Add code to check for updates --- medcat-v2/medcat/__init__.py | 8 + medcat-v2/medcat/utils/check_for_updates.py | 175 ++++++++++++++++++++ medcat-v2/medcat/utils/defaults.py | 10 ++ 3 files changed, 193 insertions(+) create mode 100644 medcat-v2/medcat/utils/check_for_updates.py diff --git a/medcat-v2/medcat/__init__.py b/medcat-v2/medcat/__init__.py index 10b1a22b9..9e44fd23b 100644 --- a/medcat-v2/medcat/__init__.py +++ b/medcat-v2/medcat/__init__.py @@ -1,7 +1,15 @@ from importlib.metadata import version as __version_method from importlib.metadata import PackageNotFoundError as __PackageNotFoundError +from medcat.utils.check_for_updates import ( + check_for_updates as __check_for_updates) + try: __version__ = __version_method("medcat") except __PackageNotFoundError: __version__ = "0.0.0-dev" + + +# NOTE: this will not always actually do the check +# it will only (by default) check once a week +__check_for_updates("medcat", __version__) diff --git a/medcat-v2/medcat/utils/check_for_updates.py b/medcat-v2/medcat/utils/check_for_updates.py new file mode 100644 index 000000000..774bc48e5 --- /dev/null +++ b/medcat-v2/medcat/utils/check_for_updates.py @@ -0,0 +1,175 @@ +from typing import TypedDict +import json +import os +import time +import urllib.request +from pathlib import Path +from packaging.version import Version, InvalidVersion +import logging + +from medcat.utils.defaults import ( + MEDCAT_DISABLE_VERSION_CHECK_ENVIRON, MEDCAT_PYPI_URL_ENVIRON, + MEDCAT_MINOR_UPDATE_THRESHOLD_ENVIRON, + MEDCAT_PATCH_UPDATE_THRESHOLD_ENVIRON, +) +from medcat.utils.defaults import ( + DEFAULT_PYPI_URL, DEFAULT_MINOR_FOR_INFO, DEFAULT_PATCH_FOR_INFO) + + +DEFAULT_CACHE_PATH = Path.home() / ".cache" / "medcat_version.json" +# 1 week +DEFAULT_CHECK_INTERVAL = 7 * 24 * 3600 + + +logger = logging.getLogger(__name__) + + +def _get_env_int(name: str, default: int) -> int: + try: + return int(os.getenv(name, default)) + except ValueError: + return default + + +def _should_check(cache_path: Path, check_interval: int) -> bool: + if not cache_path.exists(): + return True + try: + with open(cache_path) as f: + last_check = json.load(f)["last_check"] + return time.time() - last_check > check_interval + except Exception: + return True + + +class UpdateCheckConfig(TypedDict): + pkg_name: str + cache_path: Path + url: str + enabled: bool + minor_threshold: int + patch_threshold: int + timeout: float + check_interval: int + + +def _get_config(pkg_name: str) -> UpdateCheckConfig: + if os.getenv(MEDCAT_DISABLE_VERSION_CHECK_ENVIRON): + return { + "pkg_name": pkg_name, + "enabled": False, + "cache_path": Path("."), + "url": "-1", + "minor_threshold": -1, + "patch_threshold": -1, + "timeout": -1.0, + "check_interval": -1, + } + base_url = os.getenv(MEDCAT_PYPI_URL_ENVIRON, DEFAULT_PYPI_URL).rstrip("/") + url = f"{base_url}/{pkg_name}/json" + minor_thresh = _get_env_int(MEDCAT_MINOR_UPDATE_THRESHOLD_ENVIRON, + DEFAULT_MINOR_FOR_INFO) + patch_thresh = _get_env_int(MEDCAT_PATCH_UPDATE_THRESHOLD_ENVIRON, + DEFAULT_PATCH_FOR_INFO) + # TODO: add env variables for timeout and default cache? + return { + "pkg_name": pkg_name, + "enabled": True, + "cache_path": DEFAULT_CACHE_PATH, + "url": url, + "minor_threshold": minor_thresh, + "patch_threshold": patch_thresh, + "timeout": 3.0, + "check_interval": DEFAULT_CHECK_INTERVAL, + } + + +def check_for_updates(pkg_name: str, current_version: str): + cnf = _get_config(pkg_name) + if not cnf["enabled"]: + return + + if not _should_check(cnf["cache_path"], cnf["check_interval"]): + print("Ignoring check - interval") + return + print("DOING CHECK") + + try: + with urllib.request.urlopen(cnf["url"], + timeout=cnf["timeout"]) as r: + data = json.load(r) + releases = { + v: files for v, files in data.get("releases", {}).items() + if files # skip empty entries + } + except Exception as e: + logger.info("Unable to check for update", exc_info=e) + return + + # cache update time + cnf["cache_path"].parent.mkdir(parents=True, exist_ok=True) + with open(cnf["cache_path"], "w") as f: + json.dump({"last_check": time.time()}, f) + + _do_check(cnf, releases, current_version) + + +def _do_check(cnf: UpdateCheckConfig, releases: dict, + current_version: str): + try: + current = Version(current_version) + except InvalidVersion: + return + pkg_name = cnf["pkg_name"] + patch_thresh = cnf["patch_threshold"] + minor_thresh = cnf["minor_threshold"] + + newer_minors, newer_patches = [], [] + yanked = False + for v_str, files in releases.items(): + try: + v = Version(v_str) + except InvalidVersion: + continue + if v <= current: + continue + if any(f.get("yanked") for f in files): + continue # don’t count yanked releases in comparisons + if v.major == current.major and v.minor == current.minor: + newer_patches.append(v) + elif v.major == current.major and v.minor > current.minor: + newer_minors.append(v) + # print("Newer patches", newer_patches) + # print("Newer minors", newer_minors) + + # detect if current version is yanked + for f in releases.get(current_version, []): + if f.get("yanked"): + reason = f.get("yanked_reason", "") + msg = (f"⚠️ You are using a yanked version ({pkg_name} " + f"{current_version}). {reason}") + logger.warning(msg) + # TODO: make this configurable? + print(msg) + yanked = True + break + + # report newer versions + if len(newer_patches) >= patch_thresh: + latest_patch = max(newer_patches) + msg = (f"ℹ️ {pkg_name} {current_version} → {latest_patch} " + f"({len(newer_patches)} newer patch releases available)") + logger.error(msg) + # print(msg) + elif len(newer_minors) >= minor_thresh: + latest_minor = max(newer_minors) + msg = (f"⚠️ {pkg_name} {current_version} → {latest_minor} " + f"({len(newer_minors)} newer minor releases available)") + logger.info(msg) + # print(msg) + + if yanked and not (newer_minors or newer_patches): + msg = (f"⚠️ Your installed version {current_version} was yanked and " + "has no newer stable releases yet.") + logger.info(msg) + # print(msg) diff --git a/medcat-v2/medcat/utils/defaults.py b/medcat-v2/medcat/utils/defaults.py index c1ce9002e..dc64e28e1 100644 --- a/medcat-v2/medcat/utils/defaults.py +++ b/medcat-v2/medcat/utils/defaults.py @@ -10,6 +10,16 @@ COMPONENTS_FOLDER = "saved_components" AVOID_LEGACY_CONVERSION_ENVIRON = "MEDCAT_AVOID_LECACY_CONVERSION" +# version check +MEDCAT_DISABLE_VERSION_CHECK_ENVIRON = "MEDCAT_DISABLE_VERSION_CHECK" +MEDCAT_PYPI_URL_ENVIRON = "MEDCAT_PYPI_URL" +DEFAULT_PYPI_URL = "https://pypi.org/pypi" +MEDCAT_MINOR_UPDATE_THRESHOLD_ENVIRON = "MEDCAT_MINOR_UPDATE_THRESHOLD" +DEFAULT_MINOR_FOR_INFO = 3 +MEDCAT_PATCH_UPDATE_THRESHOLD_ENVIRON = "MEDCAT_PATCH_UPDATE_THRESHOLD" +DEFAULT_PATCH_FOR_INFO = 3 + + def avoid_legacy_conversion() -> bool: return os.environ.get( From 2808233d1b14853b31fbf1ba5405a78152dc95be Mon Sep 17 00:00:00 2001 From: mart-r Date: Fri, 10 Oct 2025 11:41:58 +0100 Subject: [PATCH 02/10] CU-869ary4dq: Add option to change log level with environmental variables --- medcat-v2/medcat/utils/check_for_updates.py | 23 ++++++++++++++++++--- medcat-v2/medcat/utils/defaults.py | 3 ++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/medcat-v2/medcat/utils/check_for_updates.py b/medcat-v2/medcat/utils/check_for_updates.py index 774bc48e5..93b1de429 100644 --- a/medcat-v2/medcat/utils/check_for_updates.py +++ b/medcat-v2/medcat/utils/check_for_updates.py @@ -11,9 +11,11 @@ MEDCAT_DISABLE_VERSION_CHECK_ENVIRON, MEDCAT_PYPI_URL_ENVIRON, MEDCAT_MINOR_UPDATE_THRESHOLD_ENVIRON, MEDCAT_PATCH_UPDATE_THRESHOLD_ENVIRON, + MEDCAT_VERSION_UPDATE_LOG_LEVEL_ENVIRON ) from medcat.utils.defaults import ( - DEFAULT_PYPI_URL, DEFAULT_MINOR_FOR_INFO, DEFAULT_PATCH_FOR_INFO) + DEFAULT_PYPI_URL, DEFAULT_MINOR_FOR_INFO, DEFAULT_PATCH_FOR_INFO, + DEFAULT_VERSION_INFO_LEVEL) DEFAULT_CACHE_PATH = Path.home() / ".cache" / "medcat_version.json" @@ -24,6 +26,23 @@ logger = logging.getLogger(__name__) +def log_info(msg: str): + lvl = os.environ.get(MEDCAT_VERSION_UPDATE_LOG_LEVEL_ENVIRON, + DEFAULT_VERSION_INFO_LEVEL).upper() + _level_map = { + "NOTSET": logging.NOTSET, + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARN": logging.WARNING, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR, + "CRITICAL": logging.CRITICAL, + "FATAL": logging.FATAL, + } + level = _level_map.get(lvl, logging.INFO) + logger.log(level, msg) + + def _get_env_int(name: str, default: int) -> int: try: return int(os.getenv(name, default)) @@ -90,9 +109,7 @@ def check_for_updates(pkg_name: str, current_version: str): return if not _should_check(cnf["cache_path"], cnf["check_interval"]): - print("Ignoring check - interval") return - print("DOING CHECK") try: with urllib.request.urlopen(cnf["url"], diff --git a/medcat-v2/medcat/utils/defaults.py b/medcat-v2/medcat/utils/defaults.py index dc64e28e1..e4b151453 100644 --- a/medcat-v2/medcat/utils/defaults.py +++ b/medcat-v2/medcat/utils/defaults.py @@ -18,7 +18,8 @@ DEFAULT_MINOR_FOR_INFO = 3 MEDCAT_PATCH_UPDATE_THRESHOLD_ENVIRON = "MEDCAT_PATCH_UPDATE_THRESHOLD" DEFAULT_PATCH_FOR_INFO = 3 - +MEDCAT_VERSION_UPDATE_LOG_LEVEL_ENVIRON = "MEDCAT_VERSION_UPDATE_LOG_LEVEL" +DEFAULT_VERSION_INFO_LEVEL = "INFO" def avoid_legacy_conversion() -> bool: From 39279d9649564bd3e0856d697540f759e28042b4 Mon Sep 17 00:00:00 2001 From: mart-r Date: Fri, 10 Oct 2025 11:43:39 +0100 Subject: [PATCH 03/10] CU-869ary4dq: Use option to change log level with environmental variables --- medcat-v2/medcat/utils/check_for_updates.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/medcat-v2/medcat/utils/check_for_updates.py b/medcat-v2/medcat/utils/check_for_updates.py index 93b1de429..6e294474c 100644 --- a/medcat-v2/medcat/utils/check_for_updates.py +++ b/medcat-v2/medcat/utils/check_for_updates.py @@ -26,7 +26,7 @@ logger = logging.getLogger(__name__) -def log_info(msg: str): +def log_info(msg: str, *args, **kwargs): lvl = os.environ.get(MEDCAT_VERSION_UPDATE_LOG_LEVEL_ENVIRON, DEFAULT_VERSION_INFO_LEVEL).upper() _level_map = { @@ -40,7 +40,7 @@ def log_info(msg: str): "FATAL": logging.FATAL, } level = _level_map.get(lvl, logging.INFO) - logger.log(level, msg) + logger.log(level, msg, *args, **kwargs) def _get_env_int(name: str, default: int) -> int: @@ -120,7 +120,7 @@ def check_for_updates(pkg_name: str, current_version: str): if files # skip empty entries } except Exception as e: - logger.info("Unable to check for update", exc_info=e) + log_info("Unable to check for update", exc_info=e) return # cache update time @@ -165,7 +165,7 @@ def _do_check(cnf: UpdateCheckConfig, releases: dict, reason = f.get("yanked_reason", "") msg = (f"⚠️ You are using a yanked version ({pkg_name} " f"{current_version}). {reason}") - logger.warning(msg) + log_info(msg) # TODO: make this configurable? print(msg) yanked = True @@ -176,17 +176,14 @@ def _do_check(cnf: UpdateCheckConfig, releases: dict, latest_patch = max(newer_patches) msg = (f"ℹ️ {pkg_name} {current_version} → {latest_patch} " f"({len(newer_patches)} newer patch releases available)") - logger.error(msg) - # print(msg) + log_info(msg) elif len(newer_minors) >= minor_thresh: latest_minor = max(newer_minors) msg = (f"⚠️ {pkg_name} {current_version} → {latest_minor} " f"({len(newer_minors)} newer minor releases available)") - logger.info(msg) - # print(msg) + log_info(msg) if yanked and not (newer_minors or newer_patches): msg = (f"⚠️ Your installed version {current_version} was yanked and " "has no newer stable releases yet.") - logger.info(msg) - # print(msg) + log_info(msg) From 2266d820892ef842c2e527ea2853a6608388d7d0 Mon Sep 17 00:00:00 2001 From: mart-r Date: Fri, 10 Oct 2025 11:44:29 +0100 Subject: [PATCH 04/10] CU-869ary4dq: Remove debug print output --- medcat-v2/medcat/utils/check_for_updates.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/medcat-v2/medcat/utils/check_for_updates.py b/medcat-v2/medcat/utils/check_for_updates.py index 6e294474c..510b5f298 100644 --- a/medcat-v2/medcat/utils/check_for_updates.py +++ b/medcat-v2/medcat/utils/check_for_updates.py @@ -156,8 +156,6 @@ def _do_check(cnf: UpdateCheckConfig, releases: dict, newer_patches.append(v) elif v.major == current.major and v.minor > current.minor: newer_minors.append(v) - # print("Newer patches", newer_patches) - # print("Newer minors", newer_minors) # detect if current version is yanked for f in releases.get(current_version, []): @@ -166,8 +164,6 @@ def _do_check(cnf: UpdateCheckConfig, releases: dict, msg = (f"⚠️ You are using a yanked version ({pkg_name} " f"{current_version}). {reason}") log_info(msg) - # TODO: make this configurable? - print(msg) yanked = True break From d17bbf90f55d6b926e10954f3bdb00cbfbe12da8 Mon Sep 17 00:00:00 2001 From: mart-r Date: Fri, 10 Oct 2025 11:47:50 +0100 Subject: [PATCH 05/10] CU-869ary4dq: Add separate log level for YANKED releases --- medcat-v2/medcat/utils/check_for_updates.py | 19 ++++++++++++------- medcat-v2/medcat/utils/defaults.py | 3 +++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/medcat-v2/medcat/utils/check_for_updates.py b/medcat-v2/medcat/utils/check_for_updates.py index 510b5f298..6ad1bdce1 100644 --- a/medcat-v2/medcat/utils/check_for_updates.py +++ b/medcat-v2/medcat/utils/check_for_updates.py @@ -11,11 +11,12 @@ MEDCAT_DISABLE_VERSION_CHECK_ENVIRON, MEDCAT_PYPI_URL_ENVIRON, MEDCAT_MINOR_UPDATE_THRESHOLD_ENVIRON, MEDCAT_PATCH_UPDATE_THRESHOLD_ENVIRON, - MEDCAT_VERSION_UPDATE_LOG_LEVEL_ENVIRON + MEDCAT_VERSION_UPDATE_LOG_LEVEL_ENVIRON, + MEDCAT_VERSION_UPDATE_YANKED_LOG_LEVEL_ENVIRON, ) from medcat.utils.defaults import ( DEFAULT_PYPI_URL, DEFAULT_MINOR_FOR_INFO, DEFAULT_PATCH_FOR_INFO, - DEFAULT_VERSION_INFO_LEVEL) + DEFAULT_VERSION_INFO_LEVEL, DEFAULT_VERSION_INFO_YANKED_LEVEL) DEFAULT_CACHE_PATH = Path.home() / ".cache" / "medcat_version.json" @@ -26,9 +27,13 @@ logger = logging.getLogger(__name__) -def log_info(msg: str, *args, **kwargs): - lvl = os.environ.get(MEDCAT_VERSION_UPDATE_LOG_LEVEL_ENVIRON, - DEFAULT_VERSION_INFO_LEVEL).upper() +def log_info(msg: str, *args, yanked: bool = False, **kwargs): + if yanked: + lvl = os.environ.get(MEDCAT_VERSION_UPDATE_YANKED_LOG_LEVEL_ENVIRON, + DEFAULT_VERSION_INFO_YANKED_LEVEL).upper() + else: + lvl = os.environ.get(MEDCAT_VERSION_UPDATE_LOG_LEVEL_ENVIRON, + DEFAULT_VERSION_INFO_LEVEL).upper() _level_map = { "NOTSET": logging.NOTSET, "DEBUG": logging.DEBUG, @@ -163,7 +168,7 @@ def _do_check(cnf: UpdateCheckConfig, releases: dict, reason = f.get("yanked_reason", "") msg = (f"⚠️ You are using a yanked version ({pkg_name} " f"{current_version}). {reason}") - log_info(msg) + log_info(msg, yanked=True) yanked = True break @@ -182,4 +187,4 @@ def _do_check(cnf: UpdateCheckConfig, releases: dict, if yanked and not (newer_minors or newer_patches): msg = (f"⚠️ Your installed version {current_version} was yanked and " "has no newer stable releases yet.") - log_info(msg) + log_info(msg, yanked=True) diff --git a/medcat-v2/medcat/utils/defaults.py b/medcat-v2/medcat/utils/defaults.py index e4b151453..ba4171f63 100644 --- a/medcat-v2/medcat/utils/defaults.py +++ b/medcat-v2/medcat/utils/defaults.py @@ -20,6 +20,9 @@ DEFAULT_PATCH_FOR_INFO = 3 MEDCAT_VERSION_UPDATE_LOG_LEVEL_ENVIRON = "MEDCAT_VERSION_UPDATE_LOG_LEVEL" DEFAULT_VERSION_INFO_LEVEL = "INFO" +MEDCAT_VERSION_UPDATE_YANKED_LOG_LEVEL_ENVIRON = ( + "MEDCAT_VERSION_UPDATE_YANKED_LOG_LEVEL") +DEFAULT_VERSION_INFO_YANKED_LEVEL = "WARNING" def avoid_legacy_conversion() -> bool: From c270f139140551fad9c2a8014e9c2082291387e1 Mon Sep 17 00:00:00 2001 From: mart-r Date: Fri, 10 Oct 2025 12:00:15 +0100 Subject: [PATCH 06/10] CU-869ary4dq: Add tests for update checker --- .../tests/utils/test_check_for_updates.py | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 medcat-v2/tests/utils/test_check_for_updates.py diff --git a/medcat-v2/tests/utils/test_check_for_updates.py b/medcat-v2/tests/utils/test_check_for_updates.py new file mode 100644 index 000000000..a5977861a --- /dev/null +++ b/medcat-v2/tests/utils/test_check_for_updates.py @@ -0,0 +1,148 @@ +import io +import json +import logging +import time +import unittest +from unittest.mock import patch +from pathlib import Path +from medcat.utils import check_for_updates + + +class TestVersionCheck(unittest.TestCase): + + def setUp(self): + self.pkg = "medcat" + self.current_version = "1.3.0" + self.cache_path = Path("/tmp/fake_cache.json") + + def tearDown(self): + if self.cache_path.exists(): + self.cache_path.unlink() + + # --- helpers --- + def _make_releases(self, versions, yanked=None): + """Return a fake releases dict.""" + yanked = yanked or {} + return { + v: [{"yanked": yanked.get(v, False)}] + for v in versions + } + + # 1. runs if cache missing + @patch("medcat.utils.check_for_updates._do_check") + @patch("medcat.utils.check_for_updates.urllib.request.urlopen") + def test_runs_without_cache(self, mock_urlopen, mock_do_check): + data = {"releases": self._make_releases(["1.3.1", "1.3.2", "1.4.0"])} + mock_urlopen.return_value.__enter__.return_value = io.StringIO( + json.dumps(data)) + with patch("medcat.utils.check_for_updates.DEFAULT_CACHE_PATH", + self.cache_path): + check_for_updates.check_for_updates(self.pkg, self.current_version) + mock_do_check.assert_called_once() + + # 2. runs if cache interval expired + @patch("medcat.utils.check_for_updates._do_check") + @patch("medcat.utils.check_for_updates.urllib.request.urlopen") + def test_runs_if_interval_expired(self, mock_urlopen, mock_do_check): + data = {"releases": self._make_releases(["1.3.1"])} + mock_urlopen.return_value.__enter__.return_value = io.StringIO( + json.dumps(data)) + # create old cache + self.cache_path.parent.mkdir(parents=True, exist_ok=True) + with open(self.cache_path, "w") as f: + json.dump({"last_check": time.time() - ( + check_for_updates.DEFAULT_CHECK_INTERVAL + 1)}, f) + with patch("medcat.utils.check_for_updates.DEFAULT_CACHE_PATH", + self.cache_path): + check_for_updates.check_for_updates(self.pkg, self.current_version) + mock_do_check.assert_called_once() + + # 3. doesn't run if cache still valid + @patch("medcat.utils.check_for_updates._do_check") + def test_does_not_run_if_interval_not_expired(self, mock_do_check): + # recent cache + self.cache_path.parent.mkdir(parents=True, exist_ok=True) + with open(self.cache_path, "w") as f: + json.dump({"last_check": time.time()}, f) + with patch("medcat.utils.check_for_updates.DEFAULT_CACHE_PATH", + self.cache_path): + check_for_updates.check_for_updates(self.pkg, self.current_version) + mock_do_check.assert_not_called() + + # 4. info for 3+ patch versions + @patch("medcat.utils.check_for_updates.log_info") + def test_patch_threshold_triggered(self, mock_log): + releases = self._make_releases(["1.3.1", "1.3.2", "1.3.3", "1.3.4"]) + cnf = { + "pkg_name": self.pkg, + "minor_threshold": 99, + "patch_threshold": 3, + } + cnf.update(enabled=True, cache_path=self.cache_path, url="", + timeout=0, check_interval=0) + check_for_updates._do_check(cnf, releases, self.current_version) + self.assertTrue(any("patch releases available" in c[0][0] + for c in mock_log.call_args_list)) + + # 5. info for 3+ minor versions + @patch("medcat.utils.check_for_updates.log_info") + def test_minor_threshold_triggered(self, mock_log): + releases = self._make_releases(["1.4.0", "1.5.0", "1.6.0", "1.7.0"]) + cnf = { + "pkg_name": self.pkg, + "minor_threshold": 3, + "patch_threshold": 99, + } + cnf.update(enabled=True, cache_path=self.cache_path, url="", + timeout=0, check_interval=0) + check_for_updates._do_check(cnf, releases, self.current_version) + self.assertTrue(any("minor releases available" in c[0][0] + for c in mock_log.call_args_list)) + + # 6. env variable changes log level (regular) + @patch.dict("os.environ", { + "MEDCAT_VERSION_UPDATE_LOG_LEVEL": "ERROR"}) + def test_env_log_level_regular(self): + msg = "Test" + with patch.object(check_for_updates.logger, "log") as mock_log: + check_for_updates.log_info(msg) + self.assertEqual(mock_log.call_args[0][0], logging.ERROR) + + # 7. env variable changes log level (yanked) + @patch.dict("os.environ", { + "MEDCAT_VERSION_UPDATE_YANKED_LOG_LEVEL": "CRITICAL"}) + def test_env_log_level_yanked(self): + msg = "Yanked" + with patch.object(check_for_updates.logger, "log") as mock_log: + check_for_updates.log_info(msg, yanked=True) + self.assertEqual(mock_log.call_args[0][0], logging.CRITICAL) + + # 8. yanked version triggers warning + @patch("medcat.utils.check_for_updates.log_info") + def test_yanked_version_logs(self, mock_log): + releases = self._make_releases(["1.3.0"], yanked={"1.3.0": True}) + cnf = { + "pkg_name": self.pkg, + "minor_threshold": 99, + "patch_threshold": 99, + } + cnf.update(enabled=True, cache_path=self.cache_path, url="", + timeout=0, check_interval=0) + check_for_updates._do_check(cnf, releases, self.current_version) + self.assertTrue(any("yanked version" in c[0][0] + for c in mock_log.call_args_list)) + + # 9. invalid current version handled gracefully + def test_invalid_current_version_does_not_raise(self): + releases = self._make_releases(["1.2.0"]) + cnf = { + "pkg_name": self.pkg, + "minor_threshold": 99, + "patch_threshold": 99, + } + cnf.update(enabled=True, cache_path=self.cache_path, url="", + timeout=0, check_interval=0) + try: + check_for_updates._do_check(cnf, releases, "not_a_version") + except Exception as e: + self.fail(f"Should not raise, but got {e!r}") From bb66d2d822771b24fdfe4be651d153c26ac29c2a Mon Sep 17 00:00:00 2001 From: mart-r Date: Tue, 14 Oct 2025 12:31:27 +0100 Subject: [PATCH 07/10] CU-869ary4dq: Move cache to subfolder --- medcat-v2/medcat/utils/check_for_updates.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/medcat-v2/medcat/utils/check_for_updates.py b/medcat-v2/medcat/utils/check_for_updates.py index 6ad1bdce1..fb3e39c11 100644 --- a/medcat-v2/medcat/utils/check_for_updates.py +++ b/medcat-v2/medcat/utils/check_for_updates.py @@ -19,7 +19,8 @@ DEFAULT_VERSION_INFO_LEVEL, DEFAULT_VERSION_INFO_YANKED_LEVEL) -DEFAULT_CACHE_PATH = Path.home() / ".cache" / "medcat_version.json" +DEFAULT_CACHE_PATH = ( + Path.home() / ".cache" / "cogstack" / "medcat_version.json") # 1 week DEFAULT_CHECK_INTERVAL = 7 * 24 * 3600 From 270f109c77f9de2d95f216cf2853334e6b76ad80 Mon Sep 17 00:00:00 2001 From: mart-r Date: Tue, 14 Oct 2025 12:33:50 +0100 Subject: [PATCH 08/10] CU-869ary4dq: Check the value of version check enabling environmental value --- medcat-v2/medcat/utils/check_for_updates.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/medcat-v2/medcat/utils/check_for_updates.py b/medcat-v2/medcat/utils/check_for_updates.py index fb3e39c11..61d041bf2 100644 --- a/medcat-v2/medcat/utils/check_for_updates.py +++ b/medcat-v2/medcat/utils/check_for_updates.py @@ -79,7 +79,8 @@ class UpdateCheckConfig(TypedDict): def _get_config(pkg_name: str) -> UpdateCheckConfig: - if os.getenv(MEDCAT_DISABLE_VERSION_CHECK_ENVIRON): + if os.getenv(MEDCAT_DISABLE_VERSION_CHECK_ENVIRON + ).lower() in ("true", "yes", "disable"): return { "pkg_name": pkg_name, "enabled": False, From 32daaca95d19f39e8bc7525fbe836f04c3fea2b0 Mon Sep 17 00:00:00 2001 From: mart-r Date: Tue, 14 Oct 2025 12:35:52 +0100 Subject: [PATCH 09/10] CU-869ary4dq: Unify environmental value getting --- medcat-v2/medcat/utils/check_for_updates.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/medcat-v2/medcat/utils/check_for_updates.py b/medcat-v2/medcat/utils/check_for_updates.py index 61d041bf2..4f3a14bd1 100644 --- a/medcat-v2/medcat/utils/check_for_updates.py +++ b/medcat-v2/medcat/utils/check_for_updates.py @@ -79,8 +79,8 @@ class UpdateCheckConfig(TypedDict): def _get_config(pkg_name: str) -> UpdateCheckConfig: - if os.getenv(MEDCAT_DISABLE_VERSION_CHECK_ENVIRON - ).lower() in ("true", "yes", "disable"): + if os.getenv(MEDCAT_DISABLE_VERSION_CHECK_ENVIRON, + "False").lower() in ("true", "yes", "disable"): return { "pkg_name": pkg_name, "enabled": False, From 8713562b9e5ccc8227f8de3fe71b136c6524e710 Mon Sep 17 00:00:00 2001 From: mart-r Date: Tue, 14 Oct 2025 12:39:55 +0100 Subject: [PATCH 10/10] CU-869ary4dq: Add section regarding version and update checks to README --- medcat-v2/README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/medcat-v2/README.md b/medcat-v2/README.md index d791212d4..8aa596c31 100644 --- a/medcat-v2/README.md +++ b/medcat-v2/README.md @@ -86,6 +86,24 @@ pip install "medcat[deid]~=2.0.0" # for DeID models pip install "medcat[spacy,meta-cat,deid,rel-cat,dict-ner]~=2.0.0" # for all of the above ``` +### Version / update checking + +MedCAT now has the ability to check for newer versions of itself on PyPI (or a local mirror of it). +This is so users don't get left behind too far with older versions of our software. +This is configurable by evnironmental variables so that sys admins (e.g for JupyterHub) can specify the settings they wish. +Version checks are done once a week and the results are cached. + +Below is a table of the environmental variables that govern the version checking and their defaults. + +| Variable | Default | Description | +|-----------|----------|-------------| +| **`MEDCAT_DISABLE_VERSION_CHECK`** | *(unset)* | When set to `true`, `yes` or `disable`, disables the version update check entirely. Useful for CI environments, offline setups, or deployments where external network access is restricted. | +| **`MEDCAT_PYPI_URL`** | `https://pypi.org/pypi` | Base URL used to query package metadata. Can be changed to a PyPI mirror or internal repository that exposes the `/pypi/{pkg}/json` API. | +| **`MEDCAT_MINOR_UPDATE_THRESHOLD`** | `3` | Number of newer **minor** versions (e.g. `1.4.x`, `1.5.x`) that must exist before MedCAT emits a “newer version available” log message. | +| **`MEDCAT_PATCH_UPDATE_THRESHOLD`** | `3` | Number of newer **patch** versions (e.g. `1.3.1`, `1.3.2`, `1.3.3`) on the same minor line required before emitting an informational update message. | +| **`MEDCAT_VERSION_UPDATE_LOG_LEVEL`** | `INFO` | Logging level used when reporting available newer versions (minor/patch thresholds). Accepts any valid `logging` level string (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`). | +| **`MEDCAT_VERSION_UPDATE_YANKED_LOG_LEVEL`** | `WARNING` | Logging level used when reporting that the current version has been **yanked** on PyPI. Accepts the same values as above. | + ## Demo The MedCAT v2 demo web app is available [here](https://medcat.sites.er.kcl.ac.uk/).