Skip to content
Merged
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
3 changes: 2 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
5 changes: 5 additions & 0 deletions dfetch/commands/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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)

Expand Down
6 changes: 6 additions & 0 deletions dfetch/commands/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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()}"
)
Expand Down
7 changes: 7 additions & 0 deletions dfetch/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]")
Expand Down
43 changes: 43 additions & 0 deletions dfetch/util/github_version_check.py
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions features/environment.feature
Original file line number Diff line number Diff line change
@@ -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
"""
12 changes: 12 additions & 0 deletions features/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
14 changes: 14 additions & 0 deletions features/steps/generic_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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, ""),
Expand All @@ -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, ""),
Expand All @@ -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)
Expand Down
149 changes: 149 additions & 0 deletions tests/test_github_version_check.py
Original file line number Diff line number Diff line change
@@ -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
Loading