Skip to content

Commit f7df171

Browse files
authored
fix: fix warns catching and warns stacklevels (#218)
1 parent 106a6f2 commit f7df171

6 files changed

Lines changed: 135 additions & 80 deletions

File tree

‎src/language_tool_python/__main__.py‎

Lines changed: 44 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import re
99
import sys
1010
import traceback
11+
import warnings
1112
from importlib.metadata import PackageNotFoundError, version
1213
from logging.config import dictConfig
1314
from pathlib import Path
@@ -374,55 +375,61 @@ def process_file(
374375
print(filename, file=sys.stderr)
375376

376377
try:
377-
with LanguageTool(
378-
language=args.language,
379-
mother_tongue=args.mother_tongue,
380-
remote_server=remote_server,
381-
) as lang_tool:
382-
try:
383-
text = get_input_text(filename, args)
384-
except (UnicodeError, FileNotFoundError) as exception:
385-
print_exception(exception, args.verbose)
386-
return 0
378+
with warnings.catch_warnings():
379+
warnings.filterwarnings(
380+
"ignore",
381+
message="No scheme was specified in the URL",
382+
category=RuntimeWarning,
383+
)
384+
with LanguageTool(
385+
language=args.language,
386+
mother_tongue=args.mother_tongue,
387+
remote_server=remote_server,
388+
) as lang_tool:
389+
try:
390+
text = get_input_text(filename, args)
391+
except (UnicodeError, FileNotFoundError) as exception:
392+
print_exception(exception, args.verbose)
393+
return 0
387394

388-
if not args.spell_check:
389-
lang_tool.disable_spellchecking()
395+
if not args.spell_check:
396+
lang_tool.disable_spellchecking()
390397

391-
lang_tool.disabled_rules.update(args.disable)
392-
lang_tool.enabled_rules.update(args.enable)
393-
lang_tool.disabled_categories.update(args.disable_categories)
394-
lang_tool.enabled_categories.update(args.enable_categories)
395-
lang_tool.enabled_rules_only = args.enabled_only
398+
lang_tool.disabled_rules.update(args.disable)
399+
lang_tool.enabled_rules.update(args.enable)
400+
lang_tool.disabled_categories.update(args.disable_categories)
401+
lang_tool.enabled_categories.update(args.enable_categories)
402+
lang_tool.enabled_rules_only = args.enabled_only
396403

397-
if args.picky:
398-
lang_tool.picky = True
404+
if args.picky:
405+
lang_tool.picky = True
399406

400-
if args.apply:
401-
print(lang_tool.correct(text))
402-
return 0
407+
if args.apply:
408+
print(lang_tool.correct(text))
409+
return 0
403410

404-
status = 0
405-
for match in lang_tool.check(text):
406-
rule_id = match.rule_id
411+
status = 0
412+
for match in lang_tool.check(text):
413+
rule_id = match.rule_id
407414

408-
replacement_text = ", ".join(
409-
f"'{word}'" for word in match.replacements
410-
).strip()
415+
replacement_text = ", ".join(
416+
f"'{word}'" for word in match.replacements
417+
).strip()
411418

412-
message = match.message
419+
message = match.message
413420

414-
# Messages that end with punctuation already include the
415-
# suggestion.
416-
if replacement_text and not message.endswith("?"):
417-
message += " Suggestions: " + replacement_text
421+
# Messages that end with punctuation already include the
422+
# suggestion.
423+
if replacement_text and not message.endswith("?"):
424+
message += " Suggestions: " + replacement_text
418425

419-
line, column = match.get_line_and_column(text)
426+
line, column = match.get_line_and_column(text)
420427

421-
print(f"{filename}:{line}:{column}: {rule_id}: {message}")
428+
print(f"{filename}:{line}:{column}: {rule_id}: {message}")
422429

423-
status = 2
430+
status = 2
424431

425-
return status
432+
return status
426433
except LanguageToolError as exception:
427434
print_exception(exception, args.verbose)
428435
return 0

‎src/language_tool_python/_internals/utils.py‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import contextlib
2+
import inspect
23
import locale
34
import logging
45
import math
@@ -15,6 +16,7 @@
1516

1617
__all__ = [
1718
"SupportsBool",
19+
"external_stacklevel",
1820
"get_env_float",
1921
"get_env_int",
2022
"get_language_tool_download_path",
@@ -39,6 +41,36 @@
3941
# (RFC 3986's scheme:opaque form, e.g. "ftp:example.com" or "mailto:a@b.com").
4042
_EXPLICIT_SCHEME_NO_SLASH_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*):(?!\d)")
4143

44+
_PACKAGE_DIR = Path(__file__).resolve().parent.parent
45+
46+
47+
def external_stacklevel() -> int:
48+
"""Compute the stacklevel of the first caller located outside this package.
49+
50+
The call chain leading into a warning here varies in depth depending on how it is
51+
reached (e.g. through ``LanguageTool.__init__()`` versus calling
52+
``LocalLanguageTool.download()`` directly), so a hardcoded stacklevel would point
53+
to the wrong line, or nothing at all, depending on the caller. Walking the stack
54+
until leaving this package keeps the warning attributed to the caller's code.
55+
56+
:return: The stacklevel to pass to :func:`warnings.warn`, from the perspective of
57+
the caller of this function.
58+
:rtype: int
59+
"""
60+
frame = inspect.currentframe()
61+
try:
62+
frame = frame.f_back if frame is not None else None
63+
stacklevel = 1
64+
while frame is not None:
65+
frame_dir = Path(frame.f_code.co_filename).resolve().parent
66+
if not frame_dir.is_relative_to(_PACKAGE_DIR):
67+
break
68+
frame = frame.f_back
69+
stacklevel += 1
70+
return stacklevel
71+
finally:
72+
del frame # Avoid reference cycles
73+
4274

4375
def _check_supported_scheme(scheme: str) -> None:
4476
"""Raise ValueError if ``scheme`` is not one of the supported URL schemes."""
@@ -85,7 +117,7 @@ def parse_url(url_str: str) -> str:
85117
"scheme was used by default. It is recommended to explicitly set the "
86118
"protocol ('http://' or 'https://') yourself in the URL.",
87119
RuntimeWarning,
88-
stacklevel=2,
120+
stacklevel=external_stacklevel(),
89121
)
90122

91123
parsed = urllib.parse.urlparse(stripped)

‎src/language_tool_python/download_lt.py‎

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import contextlib
66
import hashlib
77
import importlib.resources
8-
import inspect
98
import logging
109
import os
1110
import re
@@ -27,6 +26,7 @@
2726
from ._internals.compat import toml_loads
2827
from ._internals.safe_zip import SafeZipExtractor
2928
from ._internals.utils import (
29+
external_stacklevel,
3030
get_env_int,
3131
get_language_tool_download_path,
3232
version_tuple,
@@ -84,35 +84,6 @@
8484
_LTP_JAR_DIR_PATH_ENV_VAR = "LTP_JAR_DIR_PATH"
8585
_DOWNLOAD_CHUNK_BYTES = 1024 * 1024
8686
_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
11687

11788

11889
def _loads_manifest(raw_manifest: str) -> object:
@@ -205,7 +176,7 @@ def _get_zip_hash(version_name: str) -> str | None:
205176
f"{_LTP_BYPASS_VERIFIED_DOWNLOADS_ENV_VAR}="
206177
f"false to re-enable verification."
207178
)
208-
warn(err, RuntimeWarning, stacklevel=_external_stacklevel())
179+
warn(err, RuntimeWarning, stacklevel=external_stacklevel())
209180
return None
210181
suffix = re.sub(r"[^A-Za-z0-9]+", "_", version_name).strip("_").upper()
211182
version_env_var = f"LTP_DOWNLOAD_SHA256_{suffix}"
@@ -226,7 +197,7 @@ def _get_zip_hash(version_name: str) -> str | None:
226197
f"Integrity will not be verified for this download. "
227198
f"You can provide one via the {version_env_var} environment variable."
228199
)
229-
warn(err, RuntimeWarning, stacklevel=_external_stacklevel())
200+
warn(err, RuntimeWarning, stacklevel=external_stacklevel())
230201
return None
231202

232203

‎src/language_tool_python/server.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
)
2727
from ._internals.utils import (
2828
FAILSAFE_LANGUAGE,
29+
external_stacklevel,
2930
get_locale_language,
3031
kill_process_force,
3132
parse_url,
@@ -349,7 +350,9 @@ def __del__(self) -> None:
349350
necessary cleanup.
350351
"""
351352
if self._server_is_alive():
352-
warnings.warn("unclosed server", ResourceWarning, stacklevel=2)
353+
warnings.warn(
354+
"unclosed server", ResourceWarning, stacklevel=external_stacklevel()
355+
)
353356
logger.warning(
354357
"Unclosed server (server still running at %s). Closing it now.",
355358
getattr(self, "_url", "unknown"),

‎tests/unit/test_download.py‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -430,11 +430,16 @@ def test_snapshot_download_renames_archive_root_to_requested_date(
430430
lambda: tmp_path,
431431
)
432432

433-
with patch(
434-
"language_tool_python.download_lt.requests.get",
435-
return_value=MockDownloadResponse(payload),
433+
with (
434+
patch(
435+
"language_tool_python.download_lt.requests.get",
436+
return_value=MockDownloadResponse(payload),
437+
),
436438
):
437-
local_language_tool.download()
439+
with pytest.warns(
440+
RuntimeWarning, match="No SHA-256 checksum available for LanguageTool"
441+
):
442+
local_language_tool.download()
438443

439444
expected_dir = tmp_path / f"LanguageTool-{requested_snapshot}"
440445
assert (expected_dir / "languagetool-server.jar").read_bytes() == b"jar"
@@ -505,6 +510,9 @@ def test_snapshot_download_raises_when_archive_has_multiple_root_dirs(
505510
return_value=MockDownloadResponse(payload),
506511
),
507512
pytest.raises(PathError, match="Expected snapshot archive"),
513+
pytest.warns(
514+
RuntimeWarning, match="No SHA-256 checksum available for LanguageTool"
515+
),
508516
):
509517
local_language_tool.download()
510518

@@ -538,7 +546,10 @@ def test_latest_snapshot_download_renames_archive_root_to_current_date(
538546
"language_tool_python.download_lt.requests.get",
539547
return_value=MockDownloadResponse(payload),
540548
):
541-
local_language_tool.download()
549+
with pytest.warns(
550+
RuntimeWarning, match="No SHA-256 checksum available for LanguageTool"
551+
):
552+
local_language_tool.download()
542553

543554
expected_dir = tmp_path / f"LanguageTool-{current_snapshot_date}"
544555
assert (expected_dir / "languagetool-server.jar").read_bytes() == b"jar"

‎tests/unit/test_internals_utils.py‎

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
from __future__ import annotations
44

55
import locale
6-
from typing import TYPE_CHECKING
6+
from pathlib import Path
77

88
import psutil
99
import pytest
1010

1111
from language_tool_python._internals.utils import (
1212
FAILSAFE_LANGUAGE,
13+
external_stacklevel,
1314
get_env_float,
1415
get_env_int,
1516
get_language_tool_download_path,
@@ -20,9 +21,6 @@
2021
)
2122
from language_tool_python.exceptions import PathError
2223

23-
if TYPE_CHECKING:
24-
from pathlib import Path
25-
2624
_DEFAULT_INT = 42
2725
_ENV_INT_VALUE = 100
2826
_DEFAULT_FLOAT = 1.5
@@ -159,6 +157,39 @@ def test_ws_scheme_without_slashes_raises_unsupported(self) -> None:
159157
with pytest.raises(ValueError, match="Unsupported URL scheme 'ws'"):
160158
parse_url("ws:example.com")
161159

160+
def test_warning_is_attributed_to_the_external_caller(self) -> None:
161+
"""The 'no scheme' warning points at the caller's file, not internal code."""
162+
with pytest.warns(RuntimeWarning, match="No scheme was specified") as record:
163+
parse_url("example.com")
164+
assert len(record) == 1
165+
assert record[0].filename == __file__
166+
167+
168+
class TestExternalStacklevel:
169+
"""Tests for external_stacklevel()'s package-boundary detection."""
170+
171+
def test_walks_past_frames_in_a_subdirectory_of_the_package_root(
172+
self, monkeypatch: pytest.MonkeyPatch
173+
) -> None:
174+
"""A frame in a subdirectory of the package root still counts as internal."""
175+
monkeypatch.setattr(
176+
"language_tool_python._internals.utils._PACKAGE_DIR",
177+
Path(__file__).resolve().parent.parent,
178+
)
179+
180+
def _wrapper() -> int:
181+
return external_stacklevel()
182+
183+
stacklevel_from_here = external_stacklevel()
184+
stacklevel_from_wrapper = _wrapper()
185+
186+
# The wrapper adds exactly one extra internal frame compared to calling
187+
# external_stacklevel() directly, so the reported stacklevel must be one
188+
# higher. Under the old strict-equality bug, both calls would stop at the
189+
# first frame (this test module isn't *exactly* the patched package root)
190+
# and return the same value regardless of the extra wrapper frame.
191+
assert stacklevel_from_wrapper == stacklevel_from_here + 1
192+
162193

163194
class TestGetEnvInt:
164195
"""Tests for get_env_int() environment variable reader."""

0 commit comments

Comments
 (0)