Skip to content

Commit 746b41d

Browse files
authored
fix(download_lt): emit a warning when no checksum available (#216)
1 parent 75c7d1e commit 746b41d

4 files changed

Lines changed: 64 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/),
3737
- 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.
3838
- 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).
3939
- 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.
40+
- 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.
4041

4142
### Removed
4243
- **Breaking:** Removed all functions and classes previously deprecated in v3.3.0:

‎docs/source/references/env_vars.rst‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ resolved in this order:
4848
3. The bundled ``language_tool_python/_ressources/integrity.toml`` manifest, which
4949
covers release and archive downloads. Snapshots are not included.
5050

51-
If none of the above resolves to a checksum, the download proceeds without verification.
51+
If none of the above resolves to a checksum, a ``RuntimeWarning`` is emitted and the
52+
download proceeds without verification. This is typically the case for snapshots, which
53+
are absent from the bundled manifest. Provide a checksum via
54+
``LTP_DOWNLOAD_SHA256_<VERSION>`` to silence the warning and verify the download.
5255

5356
.. list-table::
5457
:header-rows: 1

‎src/language_tool_python/download_lt.py‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import contextlib
66
import hashlib
77
import importlib.resources
8+
import inspect
89
import logging
910
import os
1011
import re
@@ -83,6 +84,35 @@
8384
_LTP_JAR_DIR_PATH_ENV_VAR = "LTP_JAR_DIR_PATH"
8485
_DOWNLOAD_CHUNK_BYTES = 1024 * 1024
8586
_SAFE_ZIP_EXTRACTOR = SafeZipExtractor()
87+
_PACKAGE_DIR = Path(__file__).resolve().parent
88+
89+
90+
def _external_stacklevel() -> int:
91+
"""Compute the stacklevel of the first caller located outside this package.
92+
93+
The call chain leading into a warning here varies in depth depending on how it is
94+
reached (e.g. through ``LanguageTool.__init__()`` versus calling
95+
``LocalLanguageTool.download()`` directly), so a hardcoded stacklevel would point
96+
to the wrong line, or nothing at all, depending on the caller. Walking the stack
97+
until leaving this package keeps the warning attributed to the caller's code.
98+
99+
:return: The stacklevel to pass to :func:`warnings.warn`, from the perspective of
100+
the caller of this function.
101+
:rtype: int
102+
"""
103+
frame = inspect.currentframe()
104+
try:
105+
frame = frame.f_back if frame is not None else None
106+
stacklevel = 1
107+
while frame is not None:
108+
frame_dir = Path(frame.f_code.co_filename).resolve().parent
109+
if frame_dir != _PACKAGE_DIR:
110+
break
111+
frame = frame.f_back
112+
stacklevel += 1
113+
return stacklevel
114+
finally:
115+
del frame # Avoid reference cycles
86116

87117

88118
def _loads_manifest(raw_manifest: str) -> object:
@@ -156,7 +186,9 @@ def _get_zip_hash(version_name: str) -> str | None:
156186
for the given version. It normalizes the version name to construct the environment
157187
variable name. If no specific environment variable is found for the version, it
158188
falls back to a general environment variable or a manifest lookup. If the bypass
159-
environment variable is set, it will skip verification and return None.
189+
environment variable is set, it will skip verification and return None. If no
190+
checksum is configured for the version (typically snapshots, which are absent from
191+
the bundled manifest), a RuntimeWarning is emitted before returning None.
160192
161193
:param version_name: The version name of LanguageTool (e.g., '6.0', '20240101', or
162194
'latest').
@@ -173,7 +205,7 @@ def _get_zip_hash(version_name: str) -> str | None:
173205
f"{_LTP_BYPASS_VERIFIED_DOWNLOADS_ENV_VAR}="
174206
f"false to re-enable verification."
175207
)
176-
warn(err, RuntimeWarning, stacklevel=2)
208+
warn(err, RuntimeWarning, stacklevel=_external_stacklevel())
177209
return None
178210
suffix = re.sub(r"[^A-Za-z0-9]+", "_", version_name).strip("_").upper()
179211
version_env_var = f"LTP_DOWNLOAD_SHA256_{suffix}"
@@ -189,6 +221,12 @@ def _get_zip_hash(version_name: str) -> str | None:
189221
err = f"Invalid SHA-256 checksum configured by {source}."
190222
raise PathError(err)
191223
return normalized
224+
err = (
225+
f"No SHA-256 checksum available for LanguageTool {version_name}. "
226+
f"Integrity will not be verified for this download. "
227+
f"You can provide one via the {version_env_var} environment variable."
228+
)
229+
warn(err, RuntimeWarning, stacklevel=_external_stacklevel())
192230
return None
193231

194232

‎tests/unit/test_download_unit.py‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import re
66
import subprocess
7+
import warnings
78
from datetime import datetime, timezone
89
from typing import TYPE_CHECKING
910

@@ -321,14 +322,29 @@ def test_known_version_returns_hash(self) -> None:
321322
assert result is not None
322323
assert len(result) == _SHA256_HEX_LENGTH
323324

324-
def test_unknown_version_returns_none(
325+
def test_unknown_version_returns_none_with_warning(
325326
self, monkeypatch: pytest.MonkeyPatch
326327
) -> None:
327-
"""A version absent from the manifest returns None."""
328+
"""A version absent from the manifest returns None and warns about it."""
328329
monkeypatch.delenv("LTP_BYPASS_VERIFIED_DOWNLOADS", raising=False)
329-
result = _dl._get_zip_hash("0.0")
330+
monkeypatch.delenv("LTP_DOWNLOAD_SHA256", raising=False)
331+
monkeypatch.delenv("LTP_DOWNLOAD_SHA256_0_0", raising=False)
332+
with pytest.warns(RuntimeWarning, match="No SHA-256 checksum available"):
333+
result = _dl._get_zip_hash("0.0")
330334
assert result is None
331335

336+
def test_known_version_emits_no_checksum_warning(
337+
self, monkeypatch: pytest.MonkeyPatch
338+
) -> None:
339+
"""A version present in the manifest emits no missing-checksum warning."""
340+
monkeypatch.delenv("LTP_BYPASS_VERIFIED_DOWNLOADS", raising=False)
341+
monkeypatch.delenv("LTP_DOWNLOAD_SHA256", raising=False)
342+
monkeypatch.delenv("LTP_DOWNLOAD_SHA256_6_8", raising=False)
343+
with warnings.catch_warnings():
344+
warnings.simplefilter("error", RuntimeWarning)
345+
result = _dl._get_zip_hash("6.8")
346+
assert result is not None
347+
332348
def test_invalid_hash_in_env_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
333349
"""An invalid SHA-256 value in LTP_DOWNLOAD_SHA256 raises PathError."""
334350
monkeypatch.setenv("LTP_DOWNLOAD_SHA256", "not-a-valid-sha256")

0 commit comments

Comments
 (0)