Skip to content

Commit d7812b1

Browse files
committed
test: add unit tests to increase coverage
1 parent facb79b commit d7812b1

16 files changed

Lines changed: 1847 additions & 20 deletions

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ jobs:
3535
- os: ubuntu-26.04
3636
python-version: "3.14"
3737
- os: ubuntu-26.04
38-
python-version: "3.15.0-beta.2"
38+
python-version: "3.15.0-beta.3"
3939
- os: macos-26
4040
python-version: "3.14"
4141
- os: windows-2025

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,9 @@ cython_debug/
204204
# Ruff stuff:
205205
.ruff_cache/
206206

207+
# Pytest tmp_path base directory (project-relative to avoid Windows temp permission issues)
208+
.pytest_tmp/
209+
207210
# PyPI configuration file
208211
.pypirc
209212

pyproject.toml

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -174,18 +174,8 @@ warn_unreachable = true
174174
warn_unused_configs = true
175175
warn_unused_ignores = true
176176

177-
[[tool.mypy.overrides]]
178-
module = ["tests.benchmarks.*"]
179-
# pytest-benchmark is untyped; relax Any restrictions for benchmark files only
180-
disallow_any_unimported = false
181-
disallow_any_expr = false
182-
disallow_any_explicit = false
183-
disallow_any_decorated = false
184-
185177
[[tool.mypy.overrides]]
186178
module = ["tests.property.*"]
187-
# hypothesis is untyped; relax Any restrictions for property test files only
188-
disallow_any_unimported = false
189-
disallow_any_expr = false
190-
disallow_any_explicit = false
179+
# hypothesis decorators contain Any expressions, so we need to disable the following checks for tests using hypothesis
191180
disallow_any_decorated = false
181+
disallow_any_expr = false

pytest.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[pytest]
2-
addopts = -vra --cov=src --cov-report=html --cov-report=xml
2+
addopts = -vra --cov=src --cov-report=html --cov-report=xml --basetemp=.pytest_tmp
33
testpaths = tests
44
markers =
55
unit: fast, isolated tests with no external dependencies

src/language_tool_python/__main__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def _read_project_version(pyproject: Path) -> str:
5858
__version__ = version("language_tool_python")
5959
# If the package is not installed in the environment,
6060
# read the version from pyproject.toml
61-
except PackageNotFoundError:
61+
except PackageNotFoundError: # pragma: no cover
6262
project_root = Path(__file__).resolve().parent.parent
6363
pyproject = project_root / "pyproject.toml"
6464
__version__ = _read_project_version(pyproject)
@@ -258,7 +258,7 @@ def __call__(
258258
cli_args.disable_categories.update(rule_values)
259259
elif self.dest == "enable_categories":
260260
cli_args.enable_categories.update(rule_values)
261-
else:
261+
else: # pragma: no cover
262262
err = f"unexpected rules destination: {self.dest}"
263263
raise ValueError(err)
264264

src/language_tool_python/_internals/compat.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@
1313
if sys.version_info >= (3, 11):
1414
from tomllib import loads as toml_loads
1515
else:
16-
from tomli import loads as toml_loads
16+
from tomli import loads as toml_loads # pragma: no cover
1717

1818
if sys.version_info >= (3, 13):
1919
from warnings import deprecated
2020
else:
21-
from typing_extensions import deprecated
21+
from typing_extensions import deprecated # pragma: no cover
2222

2323
__all__ = ["deprecated", "toml_loads"]

src/language_tool_python/config_file.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ def _path_validator(v: PathLike[str] | str) -> None:
158158
if not p.exists():
159159
err = f"path does not exist: {p}"
160160
raise PathError(err)
161-
if not p.is_file() and not p.is_dir():
161+
if not p.is_file() and not p.is_dir(): # pragma: no cover
162162
err = f"path is not a file/directory: {p}"
163163
raise PathError(err)
164164

src/language_tool_python/download_lt.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ def download(self) -> None:
385385
386386
:raises NotImplementedError: Always, unless implemented by a subclass.
387387
"""
388-
raise NotImplementedError
388+
raise NotImplementedError # pragma: no cover
389389

390390
def _get_remote_zip(
391391
self,

tests/unit/test_api_types.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Unit tests for _internals/api_types.py TypeGuard helpers."""
2+
3+
from language_tool_python._internals.api_types import (
4+
is_check_response,
5+
is_language_info,
6+
)
7+
8+
9+
def test_is_language_info_valid() -> None:
10+
"""Accepts a well-formed LanguageInfo dict."""
11+
assert is_language_info({"code": "en", "longCode": "en-US", "name": "English"})
12+
13+
14+
def test_is_language_info_not_dict() -> None:
15+
"""Rejects non-dict values."""
16+
assert not is_language_info("not a dict")
17+
assert not is_language_info(42)
18+
assert not is_language_info(None)
19+
assert not is_language_info(["code", "longCode", "name"])
20+
21+
22+
def test_is_language_info_missing_field() -> None:
23+
"""Rejects dicts with missing required fields."""
24+
assert not is_language_info({"code": "en", "longCode": "en-US"})
25+
assert not is_language_info({"code": "en", "name": "English"})
26+
assert not is_language_info({})
27+
28+
29+
def test_is_language_info_wrong_type() -> None:
30+
"""Rejects dicts with non-string field values."""
31+
assert not is_language_info({"code": 1, "longCode": "en-US", "name": "English"})
32+
assert not is_language_info({"code": "en", "longCode": None, "name": "English"})
33+
34+
35+
def test_is_check_response_valid() -> None:
36+
"""Accepts a well-formed CheckResponse dict."""
37+
assert is_check_response(
38+
{
39+
"matches": [],
40+
"language": {"code": "en"},
41+
"warnings": {"incompleteResults": False},
42+
}
43+
)
44+
45+
46+
def test_is_check_response_not_dict() -> None:
47+
"""Rejects non-dict values."""
48+
assert not is_check_response("not a dict")
49+
assert not is_check_response(None)
50+
assert not is_check_response(123)
51+
52+
53+
def test_is_check_response_missing_field() -> None:
54+
"""Rejects dicts with missing required fields."""
55+
assert not is_check_response({"matches": [], "language": {}})
56+
assert not is_check_response({"matches": [], "warnings": {}})
57+
assert not is_check_response({})
58+
59+
60+
def test_is_check_response_wrong_type() -> None:
61+
"""Rejects dicts with wrong field types."""
62+
assert not is_check_response({"matches": "[]", "language": {}, "warnings": {}})
63+
assert not is_check_response({"matches": [], "language": "en", "warnings": {}})
64+
assert not is_check_response({"matches": [], "language": {}, "warnings": "none"})

tests/unit/test_cli_unit.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
"""Unit tests for the CLI helper functions in __main__.py."""
2+
3+
from __future__ import annotations
4+
5+
import io
6+
from pathlib import Path
7+
8+
import pytest
9+
10+
from language_tool_python.__main__ import (
11+
CliArgs,
12+
_read_project_version,
13+
get_input_text,
14+
get_remote_server,
15+
get_rules,
16+
get_text,
17+
parse_args,
18+
print_exception,
19+
)
20+
21+
22+
class TestGetRules:
23+
"""Tests for the get_rules() rule-string parser."""
24+
25+
def test_comma_separated(self) -> None:
26+
"""Comma-separated rule IDs are returned as a set."""
27+
assert get_rules("RULE_A,RULE_B") == {"RULE_A", "RULE_B"}
28+
29+
def test_uppercases(self) -> None:
30+
"""Rule IDs are uppercased."""
31+
assert get_rules("rule_a") == {"RULE_A"}
32+
33+
def test_hyphen_allowed(self) -> None:
34+
"""Hyphens inside rule IDs are preserved."""
35+
assert get_rules("MORFOLOGIK-RULE") == {"MORFOLOGIK-RULE"}
36+
37+
def test_whitespace_separated(self) -> None:
38+
"""Whitespace-separated rule IDs are each returned."""
39+
assert get_rules("RULE_A RULE_B") == {"RULE_A", "RULE_B"}
40+
41+
def test_empty_string(self) -> None:
42+
"""Empty input returns an empty set."""
43+
assert get_rules("") == set()
44+
45+
46+
class TestParseArgsEnabledOnly:
47+
"""Tests for the --enabled-only CLI argument validation."""
48+
49+
def test_enabled_only_with_disable_raises(self) -> None:
50+
"""--enabled-only combined with --disable causes SystemExit."""
51+
with pytest.raises(SystemExit):
52+
parse_args(
53+
[
54+
"-l",
55+
"en-US",
56+
"--enabled-only",
57+
"-e",
58+
"RULE",
59+
"-d",
60+
"OTHER",
61+
"file.txt",
62+
]
63+
)
64+
65+
def test_enabled_only_with_enable_passes(self) -> None:
66+
"""--enabled-only with --enable is accepted."""
67+
args = parse_args(["-l", "en-US", "--enabled-only", "-e", "RULE", "file.txt"])
68+
assert args.enabled_only is True
69+
assert "RULE" in args.enable
70+
71+
72+
class TestGetRemoteServer:
73+
"""Tests for the get_remote_server() URL builder."""
74+
75+
def _args(self, host: str | None = None, port: str | None = None) -> CliArgs:
76+
"""Build a minimal CliArgs with only remote_host/remote_port set."""
77+
args = CliArgs()
78+
args.remote_host = host
79+
args.remote_port = port
80+
return args
81+
82+
def test_no_host_returns_none(self) -> None:
83+
"""Returns None when no remote host is set."""
84+
assert get_remote_server(self._args()) is None
85+
86+
def test_host_without_port(self) -> None:
87+
"""Returns the host name alone when no port is given."""
88+
assert get_remote_server(self._args(host="localhost")) == "localhost"
89+
90+
def test_host_with_port(self) -> None:
91+
"""Returns host:port when both are provided."""
92+
result = get_remote_server(self._args(host="localhost", port="8081"))
93+
assert result == "localhost:8081"
94+
95+
96+
class TestPrintException:
97+
"""Tests for the print_exception() stderr printer."""
98+
99+
def test_without_debug_prints_to_stderr(
100+
self, capsys: pytest.CaptureFixture[str]
101+
) -> None:
102+
"""Without debug=True, only the message is printed to stderr."""
103+
print_exception(ValueError("test error"), debug=False)
104+
assert "test error" in capsys.readouterr().err
105+
106+
def test_with_debug_prints_traceback(
107+
self, capsys: pytest.CaptureFixture[str]
108+
) -> None:
109+
"""With debug=True, the full traceback is printed to stderr."""
110+
try:
111+
msg = "original error"
112+
raise ValueError(msg)
113+
except ValueError:
114+
print_exception(ValueError("current error"), debug=True)
115+
captured = capsys.readouterr()
116+
assert "ValueError" in captured.err
117+
118+
119+
class TestGetText:
120+
"""Tests for the get_text() file reader."""
121+
122+
def test_reads_file(self, tmp_path: Path) -> None:
123+
"""File content is returned as-is when no ignore pattern is given."""
124+
f = tmp_path / "test.txt"
125+
f.write_text("hello world\n", encoding="utf-8")
126+
result = get_text(str(f), encoding="utf-8", ignore=None)
127+
assert result == "hello world\n"
128+
129+
def test_ignore_replaces_matching_lines(self, tmp_path: Path) -> None:
130+
"""Lines matching the ignore regex are replaced with a newline."""
131+
f = tmp_path / "test.txt"
132+
f.write_text("keep this\n# skip this\nkeep too\n", encoding="utf-8")
133+
result = get_text(str(f), encoding="utf-8", ignore=r"#.*")
134+
assert "# skip this" not in result
135+
assert "keep this" in result
136+
assert "keep too" in result
137+
138+
def test_no_ignore_keeps_all(self, tmp_path: Path) -> None:
139+
"""All lines are kept when no ignore pattern is set."""
140+
f = tmp_path / "test.txt"
141+
f.write_text("line1\nline2\n", encoding="utf-8")
142+
result = get_text(str(f), encoding=None, ignore=None)
143+
assert result == "line1\nline2\n"
144+
145+
146+
class TestGetInputText:
147+
"""Tests for the get_input_text() stdin/file dispatcher."""
148+
149+
def _args(
150+
self, ignore_lines: str | None = None, encoding: str | None = None
151+
) -> CliArgs:
152+
"""Build a minimal CliArgs with only ignore_lines/encoding set."""
153+
args = CliArgs()
154+
args.ignore_lines = ignore_lines
155+
args.encoding = encoding
156+
return args
157+
158+
def test_reads_from_file(self, tmp_path: Path) -> None:
159+
"""Regular filename is read from disk."""
160+
f = tmp_path / "input.txt"
161+
f.write_text("test content", encoding="utf-8")
162+
result = get_input_text(str(f), self._args())
163+
assert result == "test content"
164+
165+
def test_reads_from_stdin(self, monkeypatch: pytest.MonkeyPatch) -> None:
166+
"""Filename '-' reads from stdin."""
167+
monkeypatch.setattr("sys.stdin", io.StringIO("stdin content"))
168+
result = get_input_text("-", self._args())
169+
assert result == "stdin content"
170+
171+
def test_stdin_with_ignore_lines(self, monkeypatch: pytest.MonkeyPatch) -> None:
172+
"""Matching lines from stdin are suppressed when ignore_lines is set."""
173+
monkeypatch.setattr("sys.stdin", io.StringIO("keep\n# skip\nkeep2\n"))
174+
result = get_input_text("-", self._args(ignore_lines=r"#.*"))
175+
assert "# skip" not in result
176+
assert "keep" in result
177+
178+
def test_uses_encoding(self, tmp_path: Path) -> None:
179+
"""Non-UTF-8 files are decoded with the specified encoding."""
180+
f = tmp_path / "latin.txt"
181+
content = "caf\xe9"
182+
f.write_bytes(content.encode("latin-1"))
183+
result = get_input_text(str(f), self._args(encoding="latin-1"))
184+
assert "caf" in result
185+
186+
187+
class TestReadProjectVersion:
188+
"""Tests for _read_project_version()."""
189+
190+
def test_reads_version_from_pyproject(self) -> None:
191+
"""Version string is read from the project's pyproject.toml."""
192+
pyproject = Path(__file__).parent.parent.parent / "pyproject.toml"
193+
version = _read_project_version(pyproject)
194+
assert isinstance(version, str)
195+
assert version.count(".") >= 1

0 commit comments

Comments
 (0)