Skip to content

Commit 40897f1

Browse files
authored
refactor: avoid having "Any" expressions (typing) (jxmorris12#181)
* refactor: avoid having any expressions in tests * refactor: use new typing_extensions dep to use TypeGuard
1 parent d1a363d commit 40897f1

14 files changed

Lines changed: 340 additions & 173 deletions

‎language_tool_python/__main__.py‎

Lines changed: 87 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,51 @@
88
import re
99
import sys
1010
import traceback
11-
from collections.abc import Sequence
1211
from importlib.metadata import PackageNotFoundError, version
1312
from logging.config import dictConfig
1413
from pathlib import Path
15-
from typing import TYPE_CHECKING, cast
14+
from typing import TYPE_CHECKING, TypedDict, cast
1615

1716
from ._compat import toml_loads
1817
from .exceptions import LanguageToolError
1918
from .server import LanguageTool
2019

2120
if TYPE_CHECKING:
2221
from collections.abc import Sequence
22+
from typing import TextIO
23+
24+
25+
class _PyProjectProject(TypedDict):
26+
version: str
27+
28+
29+
class _PyProject(TypedDict):
30+
project: _PyProjectProject
31+
32+
33+
def _load_pyproject_and_logconfig(path: Path) -> dict[str, object]:
34+
"""Load a TOML file as a typed dictionary.
35+
36+
:param path: The path to the TOML file to load.
37+
:type path: Path
38+
:return: The contents of the TOML file as a dictionary.
39+
:rtype: dict[str, object]
40+
"""
41+
with path.open("rb") as f:
42+
return cast("dict[str, object]", toml_loads(f.read().decode("utf-8")))
43+
44+
45+
def _read_project_version(pyproject: Path) -> str:
46+
"""Read the package version from pyproject.toml.
47+
48+
:param pyproject: The path to the pyproject.toml file.
49+
:type pyproject: Path
50+
:return: The package version.
51+
:rtype: str
52+
"""
53+
pyproject_config = cast("_PyProject", _load_pyproject_and_logconfig(pyproject))
54+
return pyproject_config["project"]["version"]
55+
2356

2457
try:
2558
__version__ = version("language_tool_python")
@@ -28,26 +61,43 @@
2861
except PackageNotFoundError:
2962
project_root = Path(__file__).resolve().parent.parent
3063
pyproject = project_root / "pyproject.toml"
31-
with pyproject.open("rb") as f:
32-
__version__ = toml_loads(f.read().decode("utf-8"))["project"]["version"]
64+
__version__ = _read_project_version(pyproject)
3365

3466

3567
logger = logging.getLogger(__name__)
36-
with (
37-
importlib.resources.as_file(
38-
importlib.resources.files("language_tool_python").joinpath("logging.toml"),
39-
) as config_path,
40-
config_path.open("rb") as f,
41-
):
42-
log_config = toml_loads(f.read().decode("utf-8"))
68+
with importlib.resources.as_file(
69+
importlib.resources.files("language_tool_python").joinpath("logging.toml"),
70+
) as config_path:
71+
log_config = _load_pyproject_and_logconfig(config_path)
4372
dictConfig(log_config)
4473

74+
RULE_RE: re.Pattern[str] = re.compile(r"[\w-]+")
75+
4576

46-
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
77+
class CliArgs(argparse.Namespace):
78+
"""Typed command-line arguments."""
79+
80+
files: list[str]
81+
encoding: str | None
82+
language: str | None
83+
mother_tongue: str | None
84+
disable: set[str]
85+
enable: set[str]
86+
enabled_only: bool
87+
picky: bool
88+
apply: bool
89+
spell_check: bool
90+
ignore_lines: str | None
91+
remote_host: str | None
92+
remote_port: str | None
93+
verbose: bool
94+
95+
96+
def parse_args(argv: Sequence[str] | None = None) -> CliArgs:
4797
"""Parse command line arguments.
4898
4999
:return: parsed arguments
50-
:rtype: argparse.Namespace
100+
:rtype: CliArgs
51101
"""
52102
parser = argparse.ArgumentParser(
53103
description=__doc__.strip() if __doc__ else None,
@@ -73,7 +123,7 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
73123
metavar="RULES",
74124
type=get_rules,
75125
action=RulesAction,
76-
default=set(),
126+
default=set[str](),
77127
help="list of rule IDs to be disabled",
78128
)
79129
parser.add_argument(
@@ -82,7 +132,7 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
82132
metavar="RULES",
83133
type=get_rules,
84134
action=RulesAction,
85-
default=set(),
135+
default=set[str](),
86136
help="list of rule IDs to be enabled",
87137
)
88138
parser.add_argument(
@@ -126,7 +176,8 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
126176
parser.add_argument("--remote-port", help="port of the remote LanguageTool server")
127177
parser.add_argument("--verbose", action="store_true", help="enable verbose output")
128178

129-
args = parser.parse_args(argv)
179+
args = CliArgs()
180+
parser.parse_args(argv, namespace=args)
130181

131182
if args.enabled_only:
132183
if args.disable:
@@ -165,15 +216,21 @@ def __call__(
165216
:param _parser: The ArgumentParser object which contains this action.
166217
:type _parser: argparse.ArgumentParser
167218
:param namespace: The namespace object that will be returned by parse_args().
168-
:type namespace: argparse.Namespace
219+
:type namespace: CliArgs
169220
:param values: The argument values associated with the action.
170221
:type values: str | Sequence[object] | None
171222
:param _option_string: The option string that was used to invoke this action.
172223
:type _option_string: str | None
173224
"""
174-
getattr(namespace, self.dest).update(
175-
cast("set[str]", values),
176-
)
225+
cli_args = cast("CliArgs", namespace)
226+
rule_values = cast("set[str]", values)
227+
if self.dest == "disable":
228+
cli_args.disable.update(rule_values)
229+
elif self.dest == "enable":
230+
cli_args.enable.update(rule_values)
231+
else:
232+
err = f"unexpected rules destination: {self.dest}"
233+
raise ValueError(err)
177234

178235

179236
def get_rules(rules: str) -> set[str]:
@@ -184,7 +241,8 @@ def get_rules(rules: str) -> set[str]:
184241
:return: A set of rule IDs.
185242
:rtype: set[str]
186243
"""
187-
return {rule.upper() for rule in re.findall(r"[\w\-]+", rules)}
244+
rule_ids = cast("list[str]", RULE_RE.findall(rules))
245+
return {rule.upper() for rule in rule_ids}
188246

189247

190248
def get_text(
@@ -223,11 +281,11 @@ def print_exception(exc: Exception, debug: bool) -> None:
223281
print(exc, file=sys.stderr)
224282

225283

226-
def get_remote_server(args: argparse.Namespace) -> str | None:
284+
def get_remote_server(args: CliArgs) -> str | None:
227285
"""Build the remote server address from parsed arguments.
228286
229287
:param args: Parsed command-line arguments.
230-
:type args: argparse.Namespace
288+
:type args: CliArgs
231289
:return: The remote server address in the format "host:port" or None if no remote
232290
host is specified.
233291
:rtype: str | None
@@ -242,18 +300,19 @@ def get_remote_server(args: argparse.Namespace) -> str | None:
242300
return remote_server
243301

244302

245-
def get_input_text(filename: str, args: argparse.Namespace) -> str:
303+
def get_input_text(filename: str, args: CliArgs) -> str:
246304
"""Read input text from a file or stdin.
247305
248306
:param filename: The name of the file to read or "-" for stdin.
249307
:type filename: str
250308
:param args: Parsed command-line arguments.
251-
:type args: argparse.Namespace
309+
:type args: CliArgs
252310
:return: The input text as a string.
253311
:rtype: str
254312
"""
255313
if filename == "-":
256-
raw = sys.stdin.read()
314+
stdin = cast("TextIO", sys.stdin)
315+
raw = stdin.read()
257316
if args.ignore_lines:
258317
return "".join(
259318
"\n" if re.match(args.ignore_lines, line) else line
@@ -267,15 +326,15 @@ def get_input_text(filename: str, args: argparse.Namespace) -> str:
267326

268327
def process_file(
269328
filename: str,
270-
args: argparse.Namespace,
329+
args: CliArgs,
271330
remote_server: str | None,
272331
) -> int:
273332
"""Check a single input file and return the resulting status.
274333
275334
:param filename: The name of the file to check or "-" for stdin.
276335
:type filename: str
277336
:param args: Parsed command-line arguments.
278-
:type args: argparse.Namespace
337+
:type args: CliArgs
279338
:param remote_server: The remote server address or None.
280339
:type remote_server: str | None
281340
:return: The resulting status.

‎language_tool_python/_compat.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,18 @@
55
- ``deprecated``: built-in ``warnings.deprecated`` on Python 3.13+, otherwise
66
``typing_extensions.deprecated``.
77
- ``toml_loads``: built-in ``tomllib.loads`` on Python 3.11+, otherwise
8-
``tomli.loads``.
8+
``tomli.loads``.
9+
- ``TypeGuard``: built-in ``typing.TypeGuard`` on Python 3.10+, otherwise
10+
``typing_extensions.TypeGuard``.
911
"""
1012

1113
import sys
1214

15+
if sys.version_info >= (3, 10):
16+
from typing import TypeGuard
17+
else:
18+
from typing_extensions import TypeGuard
19+
1320
if sys.version_info >= (3, 11):
1421
from tomllib import loads as toml_loads
1522
else:
@@ -20,4 +27,4 @@
2027
else:
2128
from typing_extensions import deprecated
2229

23-
__all__ = ["deprecated", "toml_loads"]
30+
__all__ = ["TypeGuard", "deprecated", "toml_loads"]

‎language_tool_python/api_types.py‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
from __future__ import annotations
44

5-
from typing import TypedDict
5+
from typing import TYPE_CHECKING, TypedDict
6+
7+
if TYPE_CHECKING:
8+
from ._compat import TypeGuard
69

710
__all__ = [
811
"Category",
@@ -29,13 +32,13 @@ class LanguageInfo(TypedDict):
2932
name: str
3033

3134

32-
def is_language_info(value: object) -> bool: # No TypeGuard because py3.9
35+
def is_language_info(value: object) -> TypeGuard[LanguageInfo]:
3336
"""Verify that a value is a LanguageInfo.
3437
3538
:param value: The value to check.
3639
:type value: object
37-
:return: True if the value is a LanguageInfo, False otherwise.
38-
:rtype: bool
40+
:return: TypeGuard indicating whether the value is a LanguageInfo.
41+
:rtype: TypeGuard[LanguageInfo]
3942
"""
4043
if not isinstance(value, dict):
4144
return False
@@ -139,13 +142,13 @@ class CheckResponse(TypedDict):
139142
warnings: WarningInfo
140143

141144

142-
def is_check_response(value: object) -> bool: # No TypeGuard because py3.9
145+
def is_check_response(value: object) -> TypeGuard[CheckResponse]:
143146
"""Verify that a value is a CheckResponse.
144147
145148
:param value: The value to check.
146149
:type value: object
147-
:return: True if the value is a CheckResponse, False otherwise.
148-
:rtype: bool
150+
:return: TypeGuard indicating whether the value is a CheckResponse.
151+
:rtype: TypeGuard[CheckResponse]
149152
"""
150153
if not isinstance(value, dict):
151154
return False

0 commit comments

Comments
 (0)