Repository navigation
Expand file tree
/
Copy pathtest_internals_utils.py
More file actions
374 lines (294 loc) Β· 15.6 KB
/
Copy pathtest_internals_utils.py
File metadata and controls
374 lines (294 loc) Β· 15.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
"""Unit tests for language_tool_python._internals.utils."""
from __future__ import annotations
import locale
from typing import TYPE_CHECKING
import psutil
import pytest
from language_tool_python._internals.utils import (
FAILSAFE_LANGUAGE,
get_env_float,
get_env_int,
get_language_tool_download_path,
get_locale_language,
kill_process_force,
parse_url,
version_tuple,
)
from language_tool_python.exceptions import PathError
if TYPE_CHECKING:
from pathlib import Path
_DEFAULT_INT = 42
_ENV_INT_VALUE = 100
_DEFAULT_FLOAT = 1.5
class TestParseUrl:
"""Tests for parse_url() scheme normalisation."""
def test_full_url_unchanged(self) -> None:
"""A complete http URL is returned as-is."""
assert parse_url("http://localhost:8081") == "http://localhost:8081"
def test_https_url_unchanged(self) -> None:
"""A complete https URL is returned as-is."""
assert parse_url("https://example.com") == "https://example.com"
def test_adds_http_scheme(self) -> None:
"""A host:port string without a scheme gets http:// prepended and warns."""
with pytest.warns(RuntimeWarning, match="No scheme was specified"):
result = parse_url("localhost:8081")
assert result == "http://localhost:8081"
def test_canonical_form(self) -> None:
"""An already-complete URL with trailing slash is returned unchanged."""
assert parse_url("http://localhost:8081/") == "http://localhost:8081/"
def test_host_containing_http_substring_is_prefixed(self) -> None:
"""A schemeless host containing 'http' in its name still gets prefixed."""
with pytest.warns(RuntimeWarning, match="No scheme was specified"):
result = parse_url("myhttpserver.example.com")
assert result == "http://myhttpserver.example.com"
def test_host_with_port_and_path(self) -> None:
"""A schemeless host:port/path string is prefixed and preserved."""
with pytest.warns(RuntimeWarning, match="No scheme was specified"):
result = parse_url("example.com:8081/api")
assert result == "http://example.com:8081/api"
def test_uppercase_scheme_is_normalised(self) -> None:
"""An uppercase scheme is recognised and normalised to lowercase."""
assert parse_url("HTTP://example.com") == "http://example.com"
def test_mixed_case_https_scheme_is_normalised(self) -> None:
"""A mixed-case https scheme is recognised and normalised to lowercase."""
assert parse_url("HTTPS://example.com") == "https://example.com"
def test_strips_surrounding_whitespace(self) -> None:
"""Leading and trailing whitespace around the URL is ignored."""
assert parse_url(" http://example.com ") == "http://example.com"
def test_empty_string_raises(self) -> None:
"""An empty URL string raises ValueError."""
with pytest.raises(ValueError, match="must not be empty"):
parse_url("")
def test_whitespace_only_raises(self) -> None:
"""A whitespace-only URL string raises ValueError."""
with pytest.raises(ValueError, match="must not be empty"):
parse_url(" ")
def test_unsupported_scheme_raises(self) -> None:
"""An explicit, unsupported scheme (e.g. ftp) raises ValueError."""
with pytest.raises(ValueError, match="Unsupported URL scheme 'ftp'"):
parse_url("ftp://example.com")
def test_file_scheme_raises(self) -> None:
"""An explicit file:// scheme raises ValueError instead of passing through."""
with pytest.raises(ValueError, match="Unsupported URL scheme 'file'"):
parse_url("file:///etc/passwd")
def test_scheme_without_host_raises(self) -> None:
"""A scheme with no host raises ValueError."""
with pytest.raises(ValueError, match="must include a host"):
parse_url("http://")
def test_scheme_with_empty_host_and_port_raises(self) -> None:
"""A scheme with a port but no host raises ValueError."""
with pytest.raises(ValueError, match="must include a host"):
parse_url("http://:8081")
def test_error_message_strips_whitespace(self) -> None:
"""The host-missing error message does not leak surrounding whitespace."""
with pytest.raises(
ValueError, match=r"^The URL 'http://:8081' must include a host\.$"
):
parse_url(" http://:8081 ")
def test_protocol_relative_url_gets_http_scheme(self) -> None:
"""A protocol-relative URL (//host) is given an http:// scheme, not mangled."""
with pytest.warns(RuntimeWarning, match="No scheme was specified"):
result = parse_url("//example.com:8081")
assert result == "http://example.com:8081"
def test_explicit_scheme_without_slashes_raises_clean_message(self) -> None:
"""An RFC 3986 scheme:opaque form (no host) raises with a clean message."""
with pytest.raises(
ValueError, match=r"^The URL 'http:example\.com' must include a host\.$"
):
parse_url("http:example.com")
def test_uppercase_explicit_scheme_without_slashes_raises(self) -> None:
"""An uppercase scheme:opaque form is treated the same as lowercase."""
with pytest.raises(ValueError, match="must include a host"):
parse_url("HTTPS:example.com")
def test_schemeless_host_port_unaffected_by_scheme_without_slash_detection(
self,
) -> None:
"""A schemeless host:port input is still treated as schemeless."""
with pytest.warns(RuntimeWarning, match="No scheme was specified"):
result = parse_url("localhost:8081")
assert result == "http://localhost:8081"
def test_schemeless_host_port_path_unaffected_by_scheme_without_slash_detection(
self,
) -> None:
"""A schemeless host:port/path input is still treated as schemeless."""
with pytest.warns(RuntimeWarning, match="No scheme was specified"):
result = parse_url("example.com:8081/api")
assert result == "http://example.com:8081/api"
def test_ftp_scheme_without_slashes_raises_unsupported(self) -> None:
"""An unsupported scheme:opaque form (ftp:) raises Unsupported URL scheme."""
with pytest.raises(ValueError, match="Unsupported URL scheme 'ftp'"):
parse_url("ftp:example.com")
def test_mailto_scheme_without_slashes_raises_unsupported(self) -> None:
"""An unsupported scheme:opaque form (mailto:) raises Unsupported URL scheme."""
with pytest.raises(ValueError, match="Unsupported URL scheme 'mailto'"):
parse_url("mailto:a@b.com")
def test_ws_scheme_without_slashes_raises_unsupported(self) -> None:
"""An unsupported scheme:opaque form (ws:) raises Unsupported URL scheme."""
with pytest.raises(ValueError, match="Unsupported URL scheme 'ws'"):
parse_url("ws:example.com")
class TestGetEnvInt:
"""Tests for get_env_int() environment variable reader."""
def test_returns_default_when_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""The default is returned when the variable is not set."""
monkeypatch.delenv("TEST_INT_VAR", raising=False)
assert get_env_int("TEST_INT_VAR", _DEFAULT_INT) == _DEFAULT_INT
def test_reads_valid_value(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A valid integer string in the environment is returned as an int."""
monkeypatch.setenv("TEST_INT_VAR", str(_ENV_INT_VALUE))
assert get_env_int("TEST_INT_VAR", 0) == _ENV_INT_VALUE
def test_raises_on_non_integer(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A non-numeric string raises PathError."""
monkeypatch.setenv("TEST_INT_VAR", "notanint")
with pytest.raises(PathError, match="Invalid integer"):
get_env_int("TEST_INT_VAR", 0)
def test_raises_on_zero(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Zero is not a valid positive integer and raises PathError."""
monkeypatch.setenv("TEST_INT_VAR", "0")
with pytest.raises(PathError, match="Invalid integer"):
get_env_int("TEST_INT_VAR", 0)
def test_raises_on_negative(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A negative integer string raises PathError."""
monkeypatch.setenv("TEST_INT_VAR", "-5")
with pytest.raises(PathError, match="Invalid integer"):
get_env_int("TEST_INT_VAR", 0)
class TestGetEnvFloat:
"""Tests for get_env_float() environment variable reader."""
def test_returns_default_when_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""The default is returned when the variable is not set."""
monkeypatch.delenv("TEST_FLOAT_VAR", raising=False)
assert get_env_float("TEST_FLOAT_VAR", _DEFAULT_FLOAT) == _DEFAULT_FLOAT
def test_reads_valid_value(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A valid float string is returned as a float."""
monkeypatch.setenv("TEST_FLOAT_VAR", "3.14")
assert get_env_float("TEST_FLOAT_VAR", 0.0) == pytest.approx(3.14)
def test_raises_on_non_float(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A non-numeric string raises PathError."""
monkeypatch.setenv("TEST_FLOAT_VAR", "notafloat")
with pytest.raises(PathError, match="Invalid float"):
get_env_float("TEST_FLOAT_VAR", 0.0)
def test_raises_on_zero(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Zero is not a valid positive float and raises PathError."""
monkeypatch.setenv("TEST_FLOAT_VAR", "0.0")
with pytest.raises(PathError, match="Invalid float"):
get_env_float("TEST_FLOAT_VAR", 1.0)
def test_raises_on_negative(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A negative float string raises PathError."""
monkeypatch.setenv("TEST_FLOAT_VAR", "-1.0")
with pytest.raises(PathError, match="Invalid float"):
get_env_float("TEST_FLOAT_VAR", 1.0)
class TestGetLanguageToolDownloadPath:
"""Tests for get_language_tool_download_path() path resolver."""
def test_returns_path(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The returned path exists and is a directory."""
monkeypatch.setenv("LTP_PATH", str(tmp_path))
path = get_language_tool_download_path()
assert path.exists()
assert path.is_dir()
def test_creates_directory(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A non-existent directory under LTP_PATH is created on first use."""
new_dir = tmp_path / "new_subdir"
monkeypatch.setenv("LTP_PATH", str(new_dir))
path = get_language_tool_download_path()
assert path.exists()
def test_default_path_in_home(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Without LTP_PATH, the default path contains 'language_tool_python'."""
monkeypatch.delenv("LTP_PATH", raising=False)
path = get_language_tool_download_path()
assert "language_tool_python" in str(path)
class TestGetLocaleLanguage:
"""Tests for get_locale_language() system locale lookup."""
def test_returns_string(self) -> None:
"""The function returns a non-empty string."""
result = get_locale_language()
assert isinstance(result, str)
assert len(result) > 0
def test_falls_back_to_failsafe_when_no_locale_is_configured(
self,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""FAILSAFE_LANGUAGE is returned when both locale lookups yield no language."""
def _no_locale(*_args: object) -> tuple[None, None]:
return (None, None)
monkeypatch.setattr(locale, "getlocale", _no_locale)
monkeypatch.setattr(locale, "getdefaultlocale", _no_locale)
assert get_locale_language() == FAILSAFE_LANGUAGE
class _MockPsutilProcess:
"""Minimal stand-in for psutil.Process used in kill_process_force tests."""
def __init__(
self,
pid: int,
children: list[_MockPsutilProcess] | None = None,
*,
raise_on_kill: bool = False,
) -> None:
"""Initialise with a pid, optional children, and an optional kill() failure."""
self.pid = pid
self.killed = False
self._children = children or []
self._raise_on_kill = raise_on_kill
def children(self, *, recursive: bool = False) -> list[_MockPsutilProcess]: # noqa: ARG002
"""Return the configured child processes."""
return self._children
def kill(self) -> None:
"""Mark the process as killed, or raise NoSuchProcess if so configured."""
if self._raise_on_kill:
raise psutil.NoSuchProcess(self.pid)
self.killed = True
class TestKillProcessForce:
"""Tests for kill_process_force() process terminator."""
def test_raises_when_no_args(self) -> None:
"""Calling with neither pid nor proc raises ValueError."""
with pytest.raises(ValueError, match="Must pass either pid or proc"):
kill_process_force()
def test_kills_by_pid(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A process is terminated when its pid is given."""
mock_proc = _MockPsutilProcess(pid=123)
def _fake_process(_pid: int) -> _MockPsutilProcess:
return mock_proc
monkeypatch.setattr(psutil, "Process", _fake_process)
kill_process_force(pid=123)
assert mock_proc.killed
def test_kills_by_proc(self) -> None:
"""A process is terminated when a psutil.Process object is given."""
mock_proc = _MockPsutilProcess(pid=123)
kill_process_force(proc=mock_proc) # type: ignore[arg-type]
assert mock_proc.killed
def test_kills_process_with_children(self) -> None:
"""A process and its children are all terminated."""
child = _MockPsutilProcess(pid=456)
parent = _MockPsutilProcess(pid=123, children=[child])
kill_process_force(proc=parent) # type: ignore[arg-type]
assert child.killed
assert parent.killed
def test_nonexistent_pid_is_silent(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A nonexistent pid is silently ignored."""
def _raise_no_such_process(pid: int) -> _MockPsutilProcess:
raise psutil.NoSuchProcess(pid)
monkeypatch.setattr(psutil, "Process", _raise_no_such_process)
kill_process_force(pid=999999999) # must not raise
def test_suppresses_no_such_process_on_child_race(self) -> None:
"""A child that vanishes before kill() (NoSuchProcess) is silently skipped."""
child = _MockPsutilProcess(pid=456, raise_on_kill=True)
parent = _MockPsutilProcess(pid=123, children=[child])
kill_process_force(proc=parent) # type: ignore[arg-type] # must not raise
assert parent.killed
def test_suppresses_no_such_process_on_parent_race(self) -> None:
"""A parent that vanishes before kill() (NoSuchProcess) is silently skipped."""
parent = _MockPsutilProcess(pid=123, raise_on_kill=True)
kill_process_force(proc=parent) # type: ignore[arg-type] # must not raise
class TestVersionTuple:
"""Tests for version_tuple() version string parser."""
def test_parses_version(self) -> None:
"""A 'X.Y' version string is parsed to a (X, Y) int tuple."""
assert version_tuple("6.8") == (6, 8)
def test_parses_version_with_zeros(self) -> None:
"""A 'X.0' version string is parsed correctly."""
assert version_tuple("4.0") == (4, 0)
def test_raises_on_invalid_format(self) -> None:
"""A version string without a dot raises ValueError."""
with pytest.raises(ValueError, match="not enough values"):
version_tuple("invalid")