Skip to content

Commit c782627

Browse files
committed
test: increase coverage by adding unit tests
1 parent 42c9c4e commit c782627

16 files changed

Lines changed: 1893 additions & 43 deletions

Makefile

Lines changed: 6 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 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|test|doc|publish|clean]"
1010
@exit 1
1111

1212
install:
@@ -36,7 +36,10 @@ doc:
3636
uv run --group docs --locked sphinx-build -M html docs/source docs/build
3737

3838
publish:
39-
rm -rf dist/
39+
make clean
4040
uv build
4141
uvx twine check dist/*
4242
uv publish
43+
44+
clean:
45+
git clean -xfd --exclude .venv

make.bat

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ if "%1"=="check" goto check
1616
if "%1"=="test" goto test
1717
if "%1"=="doc" goto doc
1818
if "%1"=="publish" goto publish
19+
if "%1"=="clean" goto clean
1920

20-
echo Usage: make.bat [install^|format^|fix^|ruff-check^|mypy-check^|check^|test^|doc^|publish]
21+
echo Usage: make.bat [install^|format^|fix^|ruff-check^|mypy-check^|check^|test^|doc^|publish^|clean]
2122
exit /b 1
2223

2324
:install
@@ -58,7 +59,7 @@ call uv run --group docs --locked sphinx-build -M html docs/source docs/build
5859
exit /b %errorlevel%
5960

6061
:publish
61-
if exist dist\ rmdir /s /q dist\
62+
call .\make.bat clean
6263

6364
uv build
6465
if errorlevel 1 exit /b %errorlevel%
@@ -68,3 +69,7 @@ if errorlevel 1 exit /b %errorlevel%
6869

6970
uv publish
7071
exit /b %errorlevel%
72+
73+
:clean
74+
git clean -xfd --exclude .venv
75+
exit /b %errorlevel%

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: # pragma: no cover
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: # pragma: no cover
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: 2 additions & 0 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+
# Python < 3.11 fallback, cov CI runs on 3.11+, so this branch is never executed.
1617
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:
22+
# Python < 3.13 fallback, cov CI runs on 3.13+, so this branch is never executed.
2123
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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ 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+
# 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.
161163
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)

src/language_tool_python/download_lt.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,7 @@ def download(self) -> None:
385385
386386
:raises NotImplementedError: Always, unless implemented by a subclass.
387387
"""
388+
# Unreachable: ABC prevents direct instantiation of this abstract method.
388389
raise NotImplementedError # pragma: no cover
389390

390391
def _get_remote_zip(
@@ -618,7 +619,7 @@ def version_name(self) -> str:
618619
:rtype: str
619620
:raises NotImplementedError: Always, unless implemented by a subclass.
620621
"""
621-
raise NotImplementedError
622+
raise NotImplementedError # pragma: no cover # abstract body
622623

623624
@property
624625
@abstractmethod
@@ -633,7 +634,7 @@ def version_into(self) -> tuple[int, int] | datetime:
633634
:rtype: tuple[int, int] | datetime.datetime
634635
:raises NotImplementedError: Always, unless implemented by a subclass.
635636
"""
636-
raise NotImplementedError
637+
raise NotImplementedError # pragma: no cover # abstract body
637638

638639
@property
639640
@abstractmethod
@@ -647,7 +648,7 @@ def download_url(self) -> str:
647648
:rtype: str
648649
:raises NotImplementedError: Always, unless implemented by a subclass.
649650
"""
650-
raise NotImplementedError
651+
raise NotImplementedError # pragma: no cover # abstract body
651652

652653
def __eq__(self, other: object) -> bool:
653654
"""Check equality between two LocalLanguageTool instances.
@@ -751,7 +752,7 @@ def download(self) -> None:
751752
return
752753

753754
if self not in self.get_installed_versions():
754-
with (
755+
with ( # pragma: no cover # integration: HTTP download + extraction
755756
tempfile.TemporaryDirectory(dir=download_folder) as temp_dir,
756757
tempfile.NamedTemporaryFile(
757758
suffix=".zip",
@@ -905,7 +906,9 @@ def download(self) -> None:
905906
raise PathError(err)
906907

907908
expected_dir = download_folder / f"LanguageTool-{self.version_name}"
908-
if expected_dir.exists() or expected_dir.is_symlink():
909+
if ( # pragma: no cover # TOCTOU: dir appears between check and rename
910+
expected_dir.exists() or expected_dir.is_symlink()
911+
):
909912
err = (
910913
"Refusing to overwrite existing LanguageTool snapshot "
911914
f"directory: {expected_dir}."

src/language_tool_python/language_tag.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ class LanguageTag:
3636
def __init__(self, tag: str, languages: Iterable[str]) -> None:
3737
"""Initialize a LanguageTag instance.
3838
39+
:param tag: The language tag to normalize.
40+
:type tag: str
41+
:param languages: An iterable of supported language tags.
42+
:type languages: collections.abc.Iterable[str]
3943
:raises ValueError: If the tag is empty or unsupported.
4044
"""
4145
self.tag = tag

src/language_tool_python/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def _decode_response_content(response: requests.Response) -> str:
9494
content: object = response.content
9595
if isinstance(content, bytes):
9696
return content.decode()
97-
return str(content)
97+
return str(content) # pragma: no cover # requests always returns bytes
9898

9999

100100
class LanguageTool:

0 commit comments

Comments
 (0)