From 4ccd007d87433c3134b4808be4aa52073ee5dcab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 07:56:58 +0000 Subject: [PATCH 1/5] Add unobtrusive newer-version notice to check and environment commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Poll the GitHub releases API (api.github.com/repos/dfetch-org/dfetch/releases/latest) and display a dim hint when a newer dfetch release is available. - dfetch/util/github_version_check.py: pure utility — fetches latest release tag and returns it if newer than the running version, silently swallowing any network/parse failure - dfetch/commands/check.py: calls it when CI env var is not set - dfetch/commands/environment.py: calls it unconditionally (diagnostic command) - api.github.com:443 is already in the harden-runner allowlist in test.yml https://claude.ai/code/session_011XLxZxgRCWhSc5PfHGam5P --- CHANGELOG.rst | 1 + dfetch/commands/check.py | 8 ++++ dfetch/commands/environment.py | 9 ++++ dfetch/util/github_version_check.py | 37 +++++++++++++++ tests/test_github_version_check.py | 71 +++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+) create mode 100644 dfetch/util/github_version_check.py create mode 100644 tests/test_github_version_check.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 32c02622a..d399a45d2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,7 @@ Release 0.14.0 (unreleased) =========================== +* Show a dim notice below the dfetch header during ``dfetch check`` when a newer release is available on GitHub (skipped in CI environments) * Respect the superproject's line-ending preference (#1206) * Strip ``user:password@`` userinfo before storing metadata (#1206) * Warn when a project URL uses a plaintext transport scheme (#1229) diff --git a/dfetch/commands/check.py b/dfetch/commands/check.py index a03f5123f..e5262b797 100644 --- a/dfetch/commands/check.py +++ b/dfetch/commands/check.py @@ -45,6 +45,7 @@ from dfetch.reporting.check.reporter import CheckReporter from dfetch.reporting.check.sarif_reporter import SarifReporter from dfetch.reporting.check.stdout_reporter import CheckStdoutReporter +from dfetch.util.github_version_check import newer_version_available from dfetch.util.util import catch_runtime_exceptions, in_directory logger = get_logger(__name__) @@ -98,6 +99,13 @@ def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None def __call__(self, args: argparse.Namespace) -> None: """Perform the check.""" + if not os.environ.get("CI"): + newer = newer_version_available() + if newer: + logger.info( + f"[dim] dfetch {newer} available" + " — https://github.com/dfetch-org/dfetch/releases[/dim]" + ) superproject = create_super_project() reporters = self._get_reporters(args, superproject.manifest) diff --git a/dfetch/commands/environment.py b/dfetch/commands/environment.py index b4cace015..d1d289850 100644 --- a/dfetch/commands/environment.py +++ b/dfetch/commands/environment.py @@ -15,8 +15,10 @@ import platform import dfetch.commands.command +from dfetch import __version__ from dfetch.log import get_logger from dfetch.project import SUPPORTED_SUBPROJECT_TYPES +from dfetch.util.github_version_check import newer_version_available logger = get_logger(__name__) @@ -34,6 +36,13 @@ def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None def __call__(self, _: argparse.Namespace) -> None: """Perform listing the environment.""" + logger.print_report_line("dfetch", __version__) + newer = newer_version_available() + if newer: + logger.info( + f"[dim] dfetch {newer} available" + " — https://github.com/dfetch-org/dfetch/releases[/dim]" + ) logger.print_report_line( "platform", f"{platform.system()} {platform.release()}" ) diff --git a/dfetch/util/github_version_check.py b/dfetch/util/github_version_check.py new file mode 100644 index 000000000..12c8552fc --- /dev/null +++ b/dfetch/util/github_version_check.py @@ -0,0 +1,37 @@ +"""Poll the GitHub releases API to see if a newer dfetch version is available.""" + +import json +import urllib.error +import urllib.request +from typing import Optional + +from dfetch import __version__ +from dfetch.util.versions import coerce + +_RELEASES_URL = ( + "https://api.github.com/repos/dfetch-org/dfetch/releases/latest" +) + + +def newer_version_available() -> Optional[str]: + """Return the latest release version string if it is newer than the running version. + + Returns: + Optional[str]: The newer version string (without leading ``v``), or ``None`` + when the current version is up-to-date or the check cannot be completed. + """ + try: + req = urllib.request.Request( + _RELEASES_URL, + headers={"Accept": "application/vnd.github+json", "User-Agent": "dfetch"}, + ) + with urllib.request.urlopen(req, timeout=2) as response: + data = json.loads(response.read()) + tag = str(data.get("tag_name", "")) + _, latest, _ = coerce(tag) + _, current, _ = coerce(__version__) + if latest and current and latest > current: + return tag.lstrip("vV") + except (urllib.error.URLError, json.JSONDecodeError, ValueError): + pass + return None diff --git a/tests/test_github_version_check.py b/tests/test_github_version_check.py new file mode 100644 index 000000000..090e4c160 --- /dev/null +++ b/tests/test_github_version_check.py @@ -0,0 +1,71 @@ +# mypy: ignore-errors +"""Tests for dfetch.util.github_version_check.""" + +import json +import urllib.error +from io import BytesIO +from unittest.mock import MagicMock, patch + +import pytest + +from dfetch.util.github_version_check import newer_version_available + + +def _fake_response(tag_name: str) -> MagicMock: + body = json.dumps({"tag_name": tag_name}).encode() + mock = MagicMock() + mock.__enter__ = lambda s: s + mock.__exit__ = MagicMock(return_value=False) + mock.read = lambda: body + return mock + + +@patch("dfetch.util.github_version_check.urllib.request.urlopen") +@patch("dfetch.util.github_version_check.__version__", "0.13.0") +def test_newer_version_available_returns_version_when_newer(mock_urlopen): + """A newer tag on GitHub is returned.""" + mock_urlopen.return_value = _fake_response("v0.14.0") + + result = newer_version_available() + + assert result == "0.14.0" + + +@patch("dfetch.util.github_version_check.urllib.request.urlopen") +@patch("dfetch.util.github_version_check.__version__", "0.13.0") +def test_newer_version_available_returns_none_when_current_is_latest(mock_urlopen): + """No update is signalled when running the latest release.""" + mock_urlopen.return_value = _fake_response("v0.13.0") + + assert newer_version_available() is None + + +@patch("dfetch.util.github_version_check.urllib.request.urlopen") +@patch("dfetch.util.github_version_check.__version__", "0.14.0") +def test_newer_version_available_returns_none_when_running_newer_than_release( + mock_urlopen, +): + """Pre-release or dev builds newer than the latest tag are not flagged.""" + mock_urlopen.return_value = _fake_response("v0.13.0") + + assert newer_version_available() is None + + +@patch("dfetch.util.github_version_check.urllib.request.urlopen") +def test_newer_version_available_returns_none_on_network_error(mock_urlopen): + """Network failures are swallowed silently.""" + mock_urlopen.side_effect = urllib.error.URLError("timeout") + + assert newer_version_available() is None + + +@patch("dfetch.util.github_version_check.urllib.request.urlopen") +def test_newer_version_available_returns_none_on_bad_json(mock_urlopen): + """Malformed JSON responses are swallowed silently.""" + mock = MagicMock() + mock.__enter__ = lambda s: s + mock.__exit__ = MagicMock(return_value=False) + mock.read = lambda: b"not json {" + mock_urlopen.return_value = mock + + assert newer_version_available() is None From cc4fbd7c46d675d50c098bb827cdcb5080963e50 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 04:26:51 +0000 Subject: [PATCH 2/5] Remove unused test imports; let black format _RELEASES_URL as single line Removes `BytesIO` and `pytest` from test_github_version_check.py (both unused, flagged by ruff F401). Black also reformats the _RELEASES_URL constant to a single line since it fits within the 88-char limit. https://claude.ai/code/session_011XLxZxgRCWhSc5PfHGam5P --- dfetch/util/github_version_check.py | 4 +--- tests/test_github_version_check.py | 3 --- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/dfetch/util/github_version_check.py b/dfetch/util/github_version_check.py index 12c8552fc..0b0e12747 100644 --- a/dfetch/util/github_version_check.py +++ b/dfetch/util/github_version_check.py @@ -8,9 +8,7 @@ from dfetch import __version__ from dfetch.util.versions import coerce -_RELEASES_URL = ( - "https://api.github.com/repos/dfetch-org/dfetch/releases/latest" -) +_RELEASES_URL = "https://api.github.com/repos/dfetch-org/dfetch/releases/latest" def newer_version_available() -> Optional[str]: diff --git a/tests/test_github_version_check.py b/tests/test_github_version_check.py index 090e4c160..09fe71666 100644 --- a/tests/test_github_version_check.py +++ b/tests/test_github_version_check.py @@ -3,11 +3,8 @@ import json import urllib.error -from io import BytesIO from unittest.mock import MagicMock, patch -import pytest - from dfetch.util.github_version_check import newer_version_available From 24fdd5f42621fc91db85f45d58007ebc72490847 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 14 Jun 2026 09:45:43 +0000 Subject: [PATCH 3/5] Review comments --- CHANGELOG.rst | 4 +- dfetch/commands/check.py | 5 +- dfetch/commands/environment.py | 5 +- dfetch/log.py | 7 ++ dfetch/util/github_version_check.py | 26 ++++--- tests/test_github_version_check.py | 104 ++++++++++++++++++++++------ 6 files changed, 110 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d399a45d2..9d805a88a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,8 +1,8 @@ Release 0.14.0 (unreleased) =========================== -* Show a dim notice below the dfetch header during ``dfetch check`` when a newer release is available on GitHub (skipped in CI environments) -* Respect the superproject's line-ending preference (#1206) +* Check for new dfetch version during ``dfetch check`` & ``dfetch environment`` (#1262) +* Respect the superproject's line-ending preference (#1260) * Strip ``user:password@`` userinfo before storing metadata (#1206) * Warn when a project URL uses a plaintext transport scheme (#1229) * Documentation and threat-model clarifications for existing release attestation support (#1208) diff --git a/dfetch/commands/check.py b/dfetch/commands/check.py index e5262b797..58b5d0627 100644 --- a/dfetch/commands/check.py +++ b/dfetch/commands/check.py @@ -102,10 +102,7 @@ def __call__(self, args: argparse.Namespace) -> None: if not os.environ.get("CI"): newer = newer_version_available() if newer: - logger.info( - f"[dim] dfetch {newer} available" - " — https://github.com/dfetch-org/dfetch/releases[/dim]" - ) + logger.print_newer_version_notice(newer) superproject = create_super_project() reporters = self._get_reporters(args, superproject.manifest) diff --git a/dfetch/commands/environment.py b/dfetch/commands/environment.py index d1d289850..d81fea00d 100644 --- a/dfetch/commands/environment.py +++ b/dfetch/commands/environment.py @@ -39,10 +39,7 @@ def __call__(self, _: argparse.Namespace) -> None: logger.print_report_line("dfetch", __version__) newer = newer_version_available() if newer: - logger.info( - f"[dim] dfetch {newer} available" - " — https://github.com/dfetch-org/dfetch/releases[/dim]" - ) + logger.print_newer_version_notice(newer) logger.print_report_line( "platform", f"{platform.system()} {platform.release()}" ) diff --git a/dfetch/log.py b/dfetch/log.py index 8dc83dd40..64862608c 100644 --- a/dfetch/log.py +++ b/dfetch/log.py @@ -132,6 +132,13 @@ def print_overview(self, name: str, title: str, info: dict[str, Any]) -> None: f" [blue]{safe_key + ':':20s}[/blue][white] {markup_escape(str(value))}[/white]" ) + def print_newer_version_notice(self, version: str) -> None: + """Print a dim notice that a newer dfetch release is available.""" + self.info( + f"[dim] dfetch {markup_escape(version)} available" + " — https://github.com/dfetch-org/dfetch/releases[/dim]" + ) + def print_title(self) -> None: """Print the DFetch tool title and version.""" self.info(f"[bold blue]Dfetch ({__version__})[/bold blue]") diff --git a/dfetch/util/github_version_check.py b/dfetch/util/github_version_check.py index 0b0e12747..e7babb158 100644 --- a/dfetch/util/github_version_check.py +++ b/dfetch/util/github_version_check.py @@ -3,7 +3,6 @@ import json import urllib.error import urllib.request -from typing import Optional from dfetch import __version__ from dfetch.util.versions import coerce @@ -11,11 +10,20 @@ _RELEASES_URL = "https://api.github.com/repos/dfetch-org/dfetch/releases/latest" -def newer_version_available() -> Optional[str]: +def _is_newer(tag: str) -> str | None: + """Return *tag* (sans leading v/V) when it is newer than the installed version.""" + _, latest, _ = coerce(tag) + _, current, _ = coerce(__version__) + if latest and current and latest > current: + return tag.lstrip("vV") + return None + + +def newer_version_available() -> str | None: """Return the latest release version string if it is newer than the running version. Returns: - Optional[str]: The newer version string (without leading ``v``), or ``None`` + str | None: The newer version string (without leading ``v``), or ``None`` when the current version is up-to-date or the check cannot be completed. """ try: @@ -23,13 +31,13 @@ def newer_version_available() -> Optional[str]: _RELEASES_URL, headers={"Accept": "application/vnd.github+json", "User-Agent": "dfetch"}, ) - with urllib.request.urlopen(req, timeout=2) as response: + opener = urllib.request.build_opener(urllib.request.HTTPSHandler()) + with opener.open(req, timeout=2) as response: data = json.loads(response.read()) - tag = str(data.get("tag_name", "")) - _, latest, _ = coerce(tag) - _, current, _ = coerce(__version__) - if latest and current and latest > current: - return tag.lstrip("vV") + tag = data.get("tag_name") + if not isinstance(tag, str) or not tag: + return None + return _is_newer(tag) except (urllib.error.URLError, json.JSONDecodeError, ValueError): pass return None diff --git a/tests/test_github_version_check.py b/tests/test_github_version_check.py index 09fe71666..7e7bbb570 100644 --- a/tests/test_github_version_check.py +++ b/tests/test_github_version_check.py @@ -7,6 +7,8 @@ from dfetch.util.github_version_check import newer_version_available +_PATCH_BUILD_OPENER = "dfetch.util.github_version_check.urllib.request.build_opener" + def _fake_response(tag_name: str) -> MagicMock: body = json.dumps({"tag_name": tag_name}).encode() @@ -17,52 +19,110 @@ def _fake_response(tag_name: str) -> MagicMock: return mock -@patch("dfetch.util.github_version_check.urllib.request.urlopen") +def _fake_response_raw(body: bytes) -> MagicMock: + mock = MagicMock() + mock.__enter__ = lambda s: s + mock.__exit__ = MagicMock(return_value=False) + mock.read = lambda: body + return mock + + +def _make_opener(response: MagicMock) -> MagicMock: + opener = MagicMock() + opener.open.return_value = response + return MagicMock(return_value=opener) + + +def _make_opener_error(error: Exception) -> MagicMock: + opener = MagicMock() + opener.open.side_effect = error + return MagicMock(return_value=opener) + + +@patch(_PATCH_BUILD_OPENER) @patch("dfetch.util.github_version_check.__version__", "0.13.0") -def test_newer_version_available_returns_version_when_newer(mock_urlopen): +def test_newer_version_available_returns_version_when_newer(mock_build_opener): """A newer tag on GitHub is returned.""" - mock_urlopen.return_value = _fake_response("v0.14.0") + mock_build_opener.return_value = _make_opener( + _fake_response("v0.14.0") + ).return_value - result = newer_version_available() + assert newer_version_available() == "0.14.0" - assert result == "0.14.0" - -@patch("dfetch.util.github_version_check.urllib.request.urlopen") +@patch(_PATCH_BUILD_OPENER) @patch("dfetch.util.github_version_check.__version__", "0.13.0") -def test_newer_version_available_returns_none_when_current_is_latest(mock_urlopen): +def test_newer_version_available_returns_none_when_current_is_latest(mock_build_opener): """No update is signalled when running the latest release.""" - mock_urlopen.return_value = _fake_response("v0.13.0") + mock_build_opener.return_value = _make_opener( + _fake_response("v0.13.0") + ).return_value assert newer_version_available() is None -@patch("dfetch.util.github_version_check.urllib.request.urlopen") +@patch(_PATCH_BUILD_OPENER) @patch("dfetch.util.github_version_check.__version__", "0.14.0") def test_newer_version_available_returns_none_when_running_newer_than_release( - mock_urlopen, + mock_build_opener, ): """Pre-release or dev builds newer than the latest tag are not flagged.""" - mock_urlopen.return_value = _fake_response("v0.13.0") + mock_build_opener.return_value = _make_opener( + _fake_response("v0.13.0") + ).return_value assert newer_version_available() is None -@patch("dfetch.util.github_version_check.urllib.request.urlopen") -def test_newer_version_available_returns_none_on_network_error(mock_urlopen): +@patch(_PATCH_BUILD_OPENER) +def test_newer_version_available_returns_none_on_network_error(mock_build_opener): """Network failures are swallowed silently.""" - mock_urlopen.side_effect = urllib.error.URLError("timeout") + mock_build_opener.return_value = _make_opener_error( + urllib.error.URLError("timeout") + ).return_value assert newer_version_available() is None -@patch("dfetch.util.github_version_check.urllib.request.urlopen") -def test_newer_version_available_returns_none_on_bad_json(mock_urlopen): +@patch(_PATCH_BUILD_OPENER) +def test_newer_version_available_returns_none_on_bad_json(mock_build_opener): """Malformed JSON responses are swallowed silently.""" - mock = MagicMock() - mock.__enter__ = lambda s: s - mock.__exit__ = MagicMock(return_value=False) - mock.read = lambda: b"not json {" - mock_urlopen.return_value = mock + mock_build_opener.return_value = _make_opener( + _fake_response_raw(b"not json {") + ).return_value + + assert newer_version_available() is None + + +@patch(_PATCH_BUILD_OPENER) +def test_newer_version_missing_tag_name_returns_none(mock_build_opener): + """A response without a tag_name key is silently ignored.""" + mock_build_opener.return_value = _make_opener( + _fake_response_raw(b'{"some_other_key": "x"}') + ).return_value + + assert newer_version_available() is None + + +@patch(_PATCH_BUILD_OPENER) +def test_newer_version_empty_tag_name_returns_none(mock_build_opener): + """An empty tag_name string is silently ignored.""" + mock_build_opener.return_value = _make_opener( + _fake_response_raw(b'{"tag_name": ""}') + ).return_value + + assert newer_version_available() is None + + +@patch(_PATCH_BUILD_OPENER) +def test_newer_version_non_string_tag_name_returns_none(mock_build_opener): + """Non-string tag_name values (integer or null) are silently ignored.""" + mock_build_opener.return_value = _make_opener( + _fake_response_raw(b'{"tag_name": 123}') + ).return_value + assert newer_version_available() is None + mock_build_opener.return_value = _make_opener( + _fake_response_raw(b'{"tag_name": null}') + ).return_value assert newer_version_available() is None From 4636dde33d3d75bc9686d03624a8c179af418799 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 14 Jun 2026 11:23:52 +0000 Subject: [PATCH 4/5] Add test --- dfetch/util/github_version_check.py | 2 +- features/environment.feature | 24 ++++++++++++++++++++++++ features/environment.py | 12 ++++++++++++ features/steps/generic_steps.py | 14 ++++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 features/environment.feature diff --git a/dfetch/util/github_version_check.py b/dfetch/util/github_version_check.py index e7babb158..4d4999ac8 100644 --- a/dfetch/util/github_version_check.py +++ b/dfetch/util/github_version_check.py @@ -39,5 +39,5 @@ def newer_version_available() -> str | None: return None return _is_newer(tag) except (urllib.error.URLError, json.JSONDecodeError, ValueError): - pass + pass # best-effort check — network or parse failures must not affect the main command return None diff --git a/features/environment.feature b/features/environment.feature new file mode 100644 index 000000000..fb636ecaa --- /dev/null +++ b/features/environment.feature @@ -0,0 +1,24 @@ +@environment +Feature: Display environment information + + dfetch environment shows information about the working environment, + including the installed dfetch version and the versions of supported + VCS tools found on PATH. + + Scenario: Environment information is shown + When I run "dfetch environment" + Then the output starts with: + """ + Dfetch (0.13.0) + dfetch : 0.13.0 + """ + + Scenario: A newer dfetch version is available + Given dfetch "1.99.0" is available on GitHub + When I run "dfetch environment" + Then the output starts with: + """ + Dfetch (0.13.0) + dfetch : 0.13.0 + dfetch 1.99.0 available — https://github.com/dfetch-org/dfetch/releases + """ diff --git a/features/environment.py b/features/environment.py index 296a93d27..5129092c6 100644 --- a/features/environment.py +++ b/features/environment.py @@ -3,6 +3,7 @@ import os import tempfile from pathlib import Path +from unittest.mock import patch from behave import fixture, use_fixture from rich.console import Console @@ -36,6 +37,17 @@ def before_scenario(context, _): width=1024, ) + context.newer_version_patcher = patch( + "dfetch.commands.environment.newer_version_available", + return_value=None, + ) + context.newer_version_patcher.start() + + +def after_scenario(context, _): + """Hook called after scenario is executed.""" + context.newer_version_patcher.stop() + def before_all(context): """Hook called before first test is run.""" diff --git a/features/steps/generic_steps.py b/features/steps/generic_steps.py index 643ee92c8..fac18aebe 100644 --- a/features/steps/generic_steps.py +++ b/features/steps/generic_steps.py @@ -12,6 +12,7 @@ from contextlib import contextmanager from itertools import zip_longest from typing import Iterable, List, Optional, Pattern, Tuple, Union +from unittest.mock import patch from behave import given, then, when # pylint: disable=no-name-in-module from behave.runner import Context @@ -23,6 +24,7 @@ ansi_escape = re.compile(r"\[/?[a-z\_ ]+\]") dfetch_title = re.compile(r"Dfetch \(\d+.\d+.\d+\)") +dfetch_version_report = re.compile(r"(dfetch\s+:\s+)\d+\.\d+\.\d+") timestamp = re.compile(r"\d+\/\d+\/\d+, \d+:\d+:\d+") git_hash = re.compile(r"(\s?)[a-f0-9]{40}(\s?)") git_timestamp = re.compile( @@ -213,6 +215,7 @@ def check_output(context, line_count=None): expected_text = multisub( patterns=[ (dfetch_title, "Dfetch (x.x.x)"), + (dfetch_version_report, r"\1x.x.x"), (git_hash, r"\1[commit-hash]\2"), (timestamp, "[timestamp]"), (ansi_escape, ""), @@ -224,6 +227,7 @@ def check_output(context, line_count=None): actual_text = multisub( patterns=[ (dfetch_title, "Dfetch (x.x.x)"), + (dfetch_version_report, r"\1x.x.x"), (git_hash, r"\1[commit-hash]\2"), (timestamp, "[timestamp]"), (ansi_escape, ""), @@ -250,6 +254,16 @@ def check_output(context, line_count=None): assert False, "Output not as expected!" +@given('dfetch "{version}" is available on GitHub') +def step_impl(context, version: str): + context.newer_version_patcher.stop() + context.newer_version_patcher = patch( + "dfetch.commands.environment.newer_version_available", + return_value=version, + ) + context.newer_version_patcher.start() + + @given('"{old}" is replaced with "{new}" in "{path}"') def step_impl(_, old: str, new: str, path: str): replace_in_file(path, old, new) From e633e2279066d3e03cda94a8f3a907e8ac61a589 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 14 Jun 2026 12:20:47 +0000 Subject: [PATCH 5/5] Catch more exceptions --- dfetch/util/github_version_check.py | 4 ++-- tests/test_github_version_check.py | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/dfetch/util/github_version_check.py b/dfetch/util/github_version_check.py index 4d4999ac8..cf55f40d4 100644 --- a/dfetch/util/github_version_check.py +++ b/dfetch/util/github_version_check.py @@ -1,7 +1,7 @@ """Poll the GitHub releases API to see if a newer dfetch version is available.""" +import http.client import json -import urllib.error import urllib.request from dfetch import __version__ @@ -38,6 +38,6 @@ def newer_version_available() -> str | None: if not isinstance(tag, str) or not tag: return None return _is_newer(tag) - except (urllib.error.URLError, json.JSONDecodeError, ValueError): + except (OSError, json.JSONDecodeError, ValueError, http.client.HTTPException): pass # best-effort check — network or parse failures must not affect the main command return None diff --git a/tests/test_github_version_check.py b/tests/test_github_version_check.py index 7e7bbb570..95700e791 100644 --- a/tests/test_github_version_check.py +++ b/tests/test_github_version_check.py @@ -1,6 +1,7 @@ # mypy: ignore-errors """Tests for dfetch.util.github_version_check.""" +import http.client import json import urllib.error from unittest.mock import MagicMock, patch @@ -84,6 +85,26 @@ def test_newer_version_available_returns_none_on_network_error(mock_build_opener assert newer_version_available() is None +@patch(_PATCH_BUILD_OPENER) +def test_newer_version_available_returns_none_on_remote_disconnected(mock_build_opener): + """HTTP protocol errors (e.g. RemoteDisconnected) are swallowed silently.""" + mock_build_opener.return_value = _make_opener_error( + http.client.RemoteDisconnected("remote end closed connection") + ).return_value + + assert newer_version_available() is None + + +@patch(_PATCH_BUILD_OPENER) +def test_newer_version_available_returns_none_on_ssl_error(mock_build_opener): + """SSL errors are swallowed silently.""" + mock_build_opener.return_value = _make_opener_error( + OSError("SSL: CERTIFICATE_VERIFY_FAILED") + ).return_value + + assert newer_version_available() is None + + @patch(_PATCH_BUILD_OPENER) def test_newer_version_available_returns_none_on_bad_json(mock_build_opener): """Malformed JSON responses are swallowed silently."""