diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 32c02622..9d805a88 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,7 +1,8 @@ Release 0.14.0 (unreleased) =========================== -* 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 a03f5123..58b5d062 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,10 @@ 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.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 b4cace01..d81fea00 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,10 @@ 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.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 8dc83dd4..64862608 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 new file mode 100644 index 00000000..cf55f40d --- /dev/null +++ b/dfetch/util/github_version_check.py @@ -0,0 +1,43 @@ +"""Poll the GitHub releases API to see if a newer dfetch version is available.""" + +import http.client +import json +import urllib.request + +from dfetch import __version__ +from dfetch.util.versions import coerce + +_RELEASES_URL = "https://api.github.com/repos/dfetch-org/dfetch/releases/latest" + + +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: + 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: + req = urllib.request.Request( + _RELEASES_URL, + headers={"Accept": "application/vnd.github+json", "User-Agent": "dfetch"}, + ) + opener = urllib.request.build_opener(urllib.request.HTTPSHandler()) + with opener.open(req, timeout=2) as response: + data = json.loads(response.read()) + tag = data.get("tag_name") + if not isinstance(tag, str) or not tag: + return None + return _is_newer(tag) + 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/features/environment.feature b/features/environment.feature new file mode 100644 index 00000000..fb636eca --- /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 296a93d2..5129092c 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 643ee92c..fac18aeb 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) diff --git a/tests/test_github_version_check.py b/tests/test_github_version_check.py new file mode 100644 index 00000000..95700e79 --- /dev/null +++ b/tests/test_github_version_check.py @@ -0,0 +1,149 @@ +# mypy: ignore-errors +"""Tests for dfetch.util.github_version_check.""" + +import http.client +import json +import urllib.error +from unittest.mock import MagicMock, patch + +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() + mock = MagicMock() + mock.__enter__ = lambda s: s + mock.__exit__ = MagicMock(return_value=False) + mock.read = lambda: body + return mock + + +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_build_opener): + """A newer tag on GitHub is returned.""" + mock_build_opener.return_value = _make_opener( + _fake_response("v0.14.0") + ).return_value + + assert newer_version_available() == "0.14.0" + + +@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_build_opener): + """No update is signalled when running the latest release.""" + mock_build_opener.return_value = _make_opener( + _fake_response("v0.13.0") + ).return_value + + assert newer_version_available() is None + + +@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_build_opener, +): + """Pre-release or dev builds newer than the latest tag are not flagged.""" + mock_build_opener.return_value = _make_opener( + _fake_response("v0.13.0") + ).return_value + + assert newer_version_available() is None + + +@patch(_PATCH_BUILD_OPENER) +def test_newer_version_available_returns_none_on_network_error(mock_build_opener): + """Network failures are swallowed silently.""" + mock_build_opener.return_value = _make_opener_error( + urllib.error.URLError("timeout") + ).return_value + + 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.""" + 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