From 3a0ba5e7a124da485cf88a26d3118ba4aefd10bf Mon Sep 17 00:00:00 2001 From: mdevolde Date: Thu, 2 Jul 2026 23:20:49 +0300 Subject: [PATCH] fix(download_lt): emit a warning when no checksum available --- CHANGELOG.md | 1 + docs/source/references/env_vars.rst | 5 ++- src/language_tool_python/download_lt.py | 42 +++++++++++++++++++++++-- tests/unit/test_download_unit.py | 22 +++++++++++-- 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8a7a7d..8145c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/), - Fixed a bug in `LanguageTool._start_server_on_free_port` where `_url` was not updated when retrying on a different port, causing all subsequent server requests to target the wrong (original) port. - Fixed a bug in `LanguageTool._query_server` where `RateLimitError` was only raised when the rate-limit response body was invalid JSON, a valid JSON body with status 426 was silently returned as data instead (for now, the body from LanguageTool for rate-limiting responses is "Upgrade Required", which is not valid JSON, but this may change in the future). - Fixed a bug in `LanguageTool._terminate_server` where `_RUNNING_SERVER_PROCESSES.remove()` could raise `ValueError` if the server process was not yet in the list or was no longer in it. +- Fixed a missing warning when downloading a LanguageTool zip file (with `LanguageTool` or `LocalLanguageTool`) without an available SHA-256 checksum. Now a `RuntimeWarning` is emitted in this case. ### Removed - **Breaking:** Removed all functions and classes previously deprecated in v3.3.0: diff --git a/docs/source/references/env_vars.rst b/docs/source/references/env_vars.rst index 08c66fd..0bff9e1 100644 --- a/docs/source/references/env_vars.rst +++ b/docs/source/references/env_vars.rst @@ -48,7 +48,10 @@ resolved in this order: 3. The bundled ``language_tool_python/_ressources/integrity.toml`` manifest, which covers release and archive downloads. Snapshots are not included. -If none of the above resolves to a checksum, the download proceeds without verification. +If none of the above resolves to a checksum, a ``RuntimeWarning`` is emitted and the +download proceeds without verification. This is typically the case for snapshots, which +are absent from the bundled manifest. Provide a checksum via +``LTP_DOWNLOAD_SHA256_`` to silence the warning and verify the download. .. list-table:: :header-rows: 1 diff --git a/src/language_tool_python/download_lt.py b/src/language_tool_python/download_lt.py index 86567c9..432bc93 100644 --- a/src/language_tool_python/download_lt.py +++ b/src/language_tool_python/download_lt.py @@ -5,6 +5,7 @@ import contextlib import hashlib import importlib.resources +import inspect import logging import os import re @@ -83,6 +84,35 @@ _LTP_JAR_DIR_PATH_ENV_VAR = "LTP_JAR_DIR_PATH" _DOWNLOAD_CHUNK_BYTES = 1024 * 1024 _SAFE_ZIP_EXTRACTOR = SafeZipExtractor() +_PACKAGE_DIR = Path(__file__).resolve().parent + + +def _external_stacklevel() -> int: + """Compute the stacklevel of the first caller located outside this package. + + The call chain leading into a warning here varies in depth depending on how it is + reached (e.g. through ``LanguageTool.__init__()`` versus calling + ``LocalLanguageTool.download()`` directly), so a hardcoded stacklevel would point + to the wrong line, or nothing at all, depending on the caller. Walking the stack + until leaving this package keeps the warning attributed to the caller's code. + + :return: The stacklevel to pass to :func:`warnings.warn`, from the perspective of + the caller of this function. + :rtype: int + """ + frame = inspect.currentframe() + try: + frame = frame.f_back if frame is not None else None + stacklevel = 1 + while frame is not None: + frame_dir = Path(frame.f_code.co_filename).resolve().parent + if frame_dir != _PACKAGE_DIR: + break + frame = frame.f_back + stacklevel += 1 + return stacklevel + finally: + del frame # Avoid reference cycles def _loads_manifest(raw_manifest: str) -> object: @@ -156,7 +186,9 @@ def _get_zip_hash(version_name: str) -> str | None: for the given version. It normalizes the version name to construct the environment variable name. If no specific environment variable is found for the version, it falls back to a general environment variable or a manifest lookup. If the bypass - environment variable is set, it will skip verification and return None. + environment variable is set, it will skip verification and return None. If no + checksum is configured for the version (typically snapshots, which are absent from + the bundled manifest), a RuntimeWarning is emitted before returning None. :param version_name: The version name of LanguageTool (e.g., '6.0', '20240101', or 'latest'). @@ -173,7 +205,7 @@ def _get_zip_hash(version_name: str) -> str | None: f"{_LTP_BYPASS_VERIFIED_DOWNLOADS_ENV_VAR}=" f"false to re-enable verification." ) - warn(err, RuntimeWarning, stacklevel=2) + warn(err, RuntimeWarning, stacklevel=_external_stacklevel()) return None suffix = re.sub(r"[^A-Za-z0-9]+", "_", version_name).strip("_").upper() version_env_var = f"LTP_DOWNLOAD_SHA256_{suffix}" @@ -189,6 +221,12 @@ def _get_zip_hash(version_name: str) -> str | None: err = f"Invalid SHA-256 checksum configured by {source}." raise PathError(err) return normalized + err = ( + f"No SHA-256 checksum available for LanguageTool {version_name}. " + f"Integrity will not be verified for this download. " + f"You can provide one via the {version_env_var} environment variable." + ) + warn(err, RuntimeWarning, stacklevel=_external_stacklevel()) return None diff --git a/tests/unit/test_download_unit.py b/tests/unit/test_download_unit.py index 2950085..475a788 100644 --- a/tests/unit/test_download_unit.py +++ b/tests/unit/test_download_unit.py @@ -4,6 +4,7 @@ import re import subprocess +import warnings from datetime import datetime, timezone from typing import TYPE_CHECKING @@ -321,14 +322,29 @@ def test_known_version_returns_hash(self) -> None: assert result is not None assert len(result) == _SHA256_HEX_LENGTH - def test_unknown_version_returns_none( + def test_unknown_version_returns_none_with_warning( self, monkeypatch: pytest.MonkeyPatch ) -> None: - """A version absent from the manifest returns None.""" + """A version absent from the manifest returns None and warns about it.""" monkeypatch.delenv("LTP_BYPASS_VERIFIED_DOWNLOADS", raising=False) - result = _dl._get_zip_hash("0.0") + monkeypatch.delenv("LTP_DOWNLOAD_SHA256", raising=False) + monkeypatch.delenv("LTP_DOWNLOAD_SHA256_0_0", raising=False) + with pytest.warns(RuntimeWarning, match="No SHA-256 checksum available"): + result = _dl._get_zip_hash("0.0") assert result is None + def test_known_version_emits_no_checksum_warning( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A version present in the manifest emits no missing-checksum warning.""" + monkeypatch.delenv("LTP_BYPASS_VERIFIED_DOWNLOADS", raising=False) + monkeypatch.delenv("LTP_DOWNLOAD_SHA256", raising=False) + monkeypatch.delenv("LTP_DOWNLOAD_SHA256_6_8", raising=False) + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + result = _dl._get_zip_hash("6.8") + assert result is not None + def test_invalid_hash_in_env_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: """An invalid SHA-256 value in LTP_DOWNLOAD_SHA256 raises PathError.""" monkeypatch.setenv("LTP_DOWNLOAD_SHA256", "not-a-valid-sha256")