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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion docs/source/references/env_vars.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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_<VERSION>`` to silence the warning and verify the download.

.. list-table::
:header-rows: 1
Expand Down
42 changes: 40 additions & 2 deletions src/language_tool_python/download_lt.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import contextlib
import hashlib
import importlib.resources
import inspect
import logging
import os
import re
Expand Down Expand Up @@ -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

Check warning

Code scanning / CodeQL

Unnecessary delete statement in function Warning

Unnecessary deletion of local variable
frame
in function
_external_stacklevel
.
Comment thread
mdevolde marked this conversation as resolved.
Dismissed


def _loads_manifest(raw_manifest: str) -> object:
Expand Down Expand Up @@ -156,7 +186,9 @@
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').
Expand All @@ -173,7 +205,7 @@
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}"
Expand All @@ -189,6 +221,12 @@
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


Expand Down
22 changes: 19 additions & 3 deletions tests/unit/test_download_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import re
import subprocess
import warnings
from datetime import datetime, timezone
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -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")
Expand Down
Loading