Skip to content

Commit be97cfc

Browse files
authored
test: add and refactor tests to have a full coverage (#207)
* Reorganized tests into unit/, integration/, property/, and benchmarks/ suites with collection markers. * Added extensive new unit + property-based coverage for core modules (server, download, safe_zip, config, match, language tag, CLI). * Added test tooling/dependencies (hypothesis, pytest-benchmark) and minor CI/dev-experience tweaks (markers, --basetemp, clean target). * Added new makefile targets to trigger specific class tests. * Updated python 3.15 version in CI.
1 parent c13dbb5 commit be97cfc

47 files changed

Lines changed: 5474 additions & 612 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.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

‎Makefile‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
.PHONY: default install format fix ruff-check mypy-check check test doc publish
1+
.PHONY: default install format fix ruff-check mypy-check check benchmark-test integration-test property-test unit-test test doc publish clean
22

33
UV := $(shell command -v uv 2>/dev/null || true)
44
ifeq ($(UV),)
55
$(warning uv not found. Install uv (curl -LsSf https://astral.sh/uv/install.sh | sh) to use Makefile targets)
66
endif
77

88
default:
9-
@echo "Usage: make [install|format|fix|ruff-check|mypy-check|check|test|doc|publish]"
9+
@echo "Usage: make [install|format|fix|ruff-check|mypy-check|check|benchmark-test|integration-test|property-test|unit-test|test|doc|publish|clean]"
1010
@exit 1
1111

1212
install:
@@ -29,14 +29,29 @@ check:
2929
make ruff-check
3030
make mypy-check
3131

32+
benchmark-test:
33+
uv run --group tests --locked pytest -m perf
34+
35+
integration-test:
36+
uv run --group tests --locked pytest -m integration
37+
38+
property-test:
39+
uv run --group tests --locked pytest -m property
40+
41+
unit-test:
42+
uv run --group tests --locked pytest -m unit
43+
3244
test:
3345
uv run --group tests --locked pytest
3446

3547
doc:
3648
uv run --group docs --locked sphinx-build -M html docs/source docs/build
3749

3850
publish:
39-
rm -rf dist/
51+
make clean
4052
uv build
4153
uvx twine check dist/*
4254
uv publish
55+
56+
clean:
57+
git clean -xfd --exclude .venv

‎make.bat‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,16 @@ if "%1"=="fix" goto fix
1313
if "%1"=="ruff-check" goto ruff-check
1414
if "%1"=="mypy-check" goto mypy-check
1515
if "%1"=="check" goto check
16+
if "%1"=="benchmark-test" goto benchmark-test
17+
if "%1"=="integration-test" goto integration-test
18+
if "%1"=="property-test" goto property-test
19+
if "%1"=="unit-test" goto unit-test
1620
if "%1"=="test" goto test
1721
if "%1"=="doc" goto doc
1822
if "%1"=="publish" goto publish
23+
if "%1"=="clean" goto clean
1924

20-
echo Usage: make.bat [install^|format^|fix^|ruff-check^|mypy-check^|check^|test^|doc^|publish]
25+
echo Usage: make.bat [install^|format^|fix^|ruff-check^|mypy-check^|check^|benchmark-test^|integration-test^|property-test^|unit-test^|test^|doc^|publish^|clean]
2126
exit /b 1
2227

2328
:install
@@ -49,6 +54,22 @@ if errorlevel 1 exit /b %errorlevel%
4954
call :mypy-check
5055
exit /b %errorlevel%
5156

57+
:benchmark-test
58+
uv run --group tests --locked pytest -m perf
59+
exit /b %errorlevel%
60+
61+
:integration-test
62+
uv run --group tests --locked pytest -m integration
63+
exit /b %errorlevel%
64+
65+
:property-test
66+
uv run --group tests --locked pytest -m property
67+
exit /b %errorlevel%
68+
69+
:unit-test
70+
uv run --group tests --locked pytest -m unit
71+
exit /b %errorlevel%
72+
5273
:test
5374
uv run --group tests --locked pytest
5475
exit /b %errorlevel%
@@ -58,7 +79,7 @@ call uv run --group docs --locked sphinx-build -M html docs/source docs/build
5879
exit /b %errorlevel%
5980

6081
:publish
61-
if exist dist\ rmdir /s /q dist\
82+
call .\make.bat clean
6283

6384
uv build
6485
if errorlevel 1 exit /b %errorlevel%
@@ -68,3 +89,7 @@ if errorlevel 1 exit /b %errorlevel%
6889

6990
uv publish
7091
exit /b %errorlevel%
92+
93+
:clean
94+
git clean -xfd --exclude .venv
95+
exit /b %errorlevel%

‎pyproject.toml‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ changelog = "https://github.com/jxmorris12/language_tool_python/blob/master/CHAN
4242
[dependency-groups]
4343
tests = [
4444
"pytest",
45+
"pytest-benchmark",
4546
"pytest-cov",
47+
"hypothesis",
4648
]
4749

4850
docs = [
@@ -141,9 +143,10 @@ ignore = [
141143
]
142144

143145
[tool.ruff.lint.per-file-ignores]
144-
"tests/*.py" = [
145-
"S101", # Need to use assert statements in tests
146-
"SLF001" # Need to use private members of the library for testing
146+
"tests/**/*.py" = [
147+
"S101", # Need to use assert statements in tests
148+
"SLF001", # Need to use private members of the library for testing
149+
"RUF001", # LanguageTool output contains typographic quotes (‘’“”)
147150
]
148151
"src/language_tool_python/__main__.py" = ["T201"] # Allow usage of print in the CLI entry point
149152

@@ -170,3 +173,9 @@ warn_return_any = true
170173
warn_unreachable = true
171174
warn_unused_configs = true
172175
warn_unused_ignores = true
176+
177+
[[tool.mypy.overrides]]
178+
module = ["tests.property.*"]
179+
# hypothesis decorators contain Any expressions, so we need to disable the following checks for tests using hypothesis
180+
disallow_any_decorated = false
181+
disallow_any_expr = false

‎pytest.ini‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
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
4+
markers =
5+
unit: fast, isolated tests with no external dependencies
6+
integration: tests that require a live LanguageTool server or network
7+
property: property-based tests using Hypothesis
8+
perf: performance benchmark tests using pytest-benchmark
49

510
[coverage:run]
611
source = src

‎src/language_tool_python/__main__.py‎

Lines changed: 3 additions & 3 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 # package installed in test env
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 # defensive: all known dest values are handled above
262262
err = f"unexpected rules destination: {self.dest}"
263263
raise ValueError(err)
264264

@@ -449,5 +449,5 @@ def main(argv: Sequence[str] | None = None) -> int:
449449
return status
450450

451451

452-
if __name__ == "__main__":
452+
if __name__ == "__main__": # pragma: no cover
453453
raise SystemExit(main())

‎src/language_tool_python/_internals/compat.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@
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+
# Python < 3.11 fallback, cov CI runs on 3.11+, so this branch is never executed.
17+
from tomli import loads as toml_loads # pragma: no cover
1718

1819
if sys.version_info >= (3, 13):
1920
from warnings import deprecated
2021
else:
21-
from typing_extensions import deprecated
22+
# Python < 3.13 fallback, cov CI runs on 3.13+, so this branch is never executed.
23+
from typing_extensions import deprecated # pragma: no cover
2224

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

‎src/language_tool_python/_internals/safe_zip.py‎

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,9 @@ def _normalize_member_path(self, filename: str) -> PurePosixPath:
179179

180180
member_path = PurePosixPath(*parts)
181181

182-
if member_path.is_absolute() or any(part == ".." for part in member_path.parts):
182+
if ( # pragma: no cover # parts validated; PurePosixPath always relative
183+
member_path.is_absolute() or any(part == ".." for part in member_path.parts)
184+
):
183185
err = f"Unsafe ZIP member path: {filename!r}."
184186
raise PathError(err)
185187

@@ -256,7 +258,7 @@ def _zip_target(self, destination: Path, member_path: PurePosixPath) -> Path:
256258

257259
if destination_resolved != target_resolved and (
258260
destination_resolved not in target_resolved.parents
259-
):
261+
): # pragma: no cover # TOCTOU: escape needs concurrent modification
260262
err = f"Unsafe ZIP member path: {str(member_path)!r}."
261263
raise PathError(err)
262264

@@ -457,13 +459,13 @@ def _ensure_safe_parent(self, destination: Path, target: Path) -> None:
457459

458460
if destination_resolved != parent_resolved and (
459461
destination_resolved not in parent_resolved.parents
460-
):
462+
): # pragma: no cover # TOCTOU: escape needs concurrent modification
461463
err = f"Unsafe ZIP extraction parent path: {target.parent}."
462464
raise PathError(err)
463465

464466
try:
465467
relative_parent = target.parent.relative_to(destination)
466-
except ValueError as e:
468+
except ValueError as e: # pragma: no cover # caught by check above
467469
err = f"Unsafe ZIP extraction parent path: {target.parent}."
468470
raise PathError(err) from e
469471

@@ -472,19 +474,19 @@ def _ensure_safe_parent(self, destination: Path, target: Path) -> None:
472474
for part in relative_parent.parts:
473475
current = current / part
474476

475-
if current.is_symlink():
477+
if current.is_symlink(): # pragma: no cover # TOCTOU: mkdir'd above
476478
err = f"Refusing to extract through symlinked directory: {current}."
477479
raise PathError(err)
478480

479-
if not current.is_dir():
481+
if not current.is_dir(): # pragma: no cover # TOCTOU: mkdir'd above
480482
err = f"Refusing to extract through non-directory path: {current}."
481483
raise PathError(err)
482484

483485
current_resolved = current.resolve(strict=True)
484486

485487
if destination_resolved != current_resolved and (
486488
destination_resolved not in current_resolved.parents
487-
):
489+
): # pragma: no cover # TOCTOU: escape needs concurrent modification
488490
err = f"Unsafe ZIP extraction directory path: {current}."
489491
raise PathError(err)
490492

@@ -504,11 +506,11 @@ def _copy_member(
504506
:type target: Path
505507
:raises PathError: If the target is unsafe or size checks fail.
506508
"""
507-
if target.exists() or target.is_symlink():
509+
if target.exists() or target.is_symlink(): # pragma: no cover # TOCTOU
508510
err = f"Refusing to overwrite existing path while extracting ZIP: {target}."
509511
raise PathError(err)
510512

511-
if target.parent.is_symlink():
513+
if target.parent.is_symlink(): # pragma: no cover # TOCTOU: parent safe above
512514
err = (
513515
f"Refusing to extract into symlinked parent directory: {target.parent}."
514516
)
@@ -578,21 +580,23 @@ def _extract_to_private_directory(
578580

579581
destination.mkdir(parents=True, exist_ok=True)
580582

581-
if destination.is_symlink():
583+
if destination.is_symlink(): # pragma: no cover # TOCTOU: mkdtemp dir
582584
err = f"Refusing to extract into symlinked destination: {destination}."
583585
raise PathError(err)
584586

585587
destination_resolved = destination.resolve(strict=True)
586588

587-
if not destination_resolved.is_dir():
589+
if not destination_resolved.is_dir(): # pragma: no cover # TOCTOU: mkdir'd
588590
err = f"ZIP extraction destination is not a directory: {destination}."
589591
raise PathError(err)
590592

591593
for member, member_path in validated_members:
592594
target = self._zip_target(destination, member_path)
593595

594596
if member.is_dir():
595-
if target.exists() and not target.is_dir():
597+
if ( # pragma: no cover # dup-path check catches file-vs-dir above
598+
target.exists() and not target.is_dir()
599+
):
596600
err = (
597601
f"Refusing to overwrite existing path while extracting ZIP: "
598602
f"{target}."
@@ -602,16 +606,17 @@ def _extract_to_private_directory(
602606
target.mkdir(parents=True, exist_ok=True)
603607
self._ensure_safe_parent(destination, target)
604608

605-
if target.is_symlink():
609+
if target.is_symlink(): # pragma: no cover # TOCTOU: mkdir'd above
606610
err = (
607611
f"Refusing to create or use symlinked ZIP directory: {target}."
608612
)
609613
raise PathError(err)
610614

611615
target_resolved = target.resolve(strict=True)
612616

613-
if destination_resolved != target_resolved and (
614-
destination_resolved not in target_resolved.parents
617+
if ( # pragma: no cover # TOCTOU: mkdir'd dir escaped
618+
destination_resolved != target_resolved
619+
and destination_resolved not in target_resolved.parents
615620
):
616621
err = f"Unsafe ZIP directory path after creation: {target}."
617622
raise PathError(err)
@@ -687,15 +692,15 @@ def _extractall_to_directory(
687692

688693
final_directory_resolved = final_directory.resolve(strict=True)
689694

690-
if not final_directory_resolved.is_dir():
695+
if not final_directory_resolved.is_dir(): # pragma: no cover # TOCTOU
691696
err = (
692697
f"ZIP extraction destination is not a directory: {final_directory}."
693698
)
694699
raise PathError(err)
695700

696701
destinations: list[tuple[Path, Path]] = []
697702
for child in extract_dir.iterdir():
698-
if child.is_symlink():
703+
if child.is_symlink(): # pragma: no cover # symlinks rejected above
699704
err = f"Refusing to move symlinked extracted path: {child}."
700705
raise PathError(err)
701706

‎src/language_tool_python/config_file.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,9 @@ 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+
# Defensive: a path that exists but is neither file nor directory (e.g. socket,
162+
# device node, FIFO) cannot be created portably in unit tests.
163+
if not p.is_file() and not p.is_dir(): # pragma: no cover
162164
err = f"path is not a file/directory: {p}"
163165
raise PathError(err)
164166

0 commit comments

Comments
 (0)