Skip to content

Commit e57cd62

Browse files
authored
Migrate from mypy to ty as a type checker (#1731)
* Migrate from mypy to ty as a type checker * Eliminated all the ty ignore rules other than the one for unresolved attributes * Add unit test for uncovered line
1 parent 8731ef9 commit e57cd62

12 files changed

Lines changed: 101 additions & 71 deletions

File tree

.github/workflows/typecheck.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,4 @@ jobs:
3232
python-version: ${{ matrix.python-version }}
3333

3434
- name: Check typing
35-
run: uv run mypy .
35+
run: uv run ty check

Makefile

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ check: ## Run code quality tools.
1818
@uv lock --locked
1919
@echo "🚀 Auto-formatting/Linting code and documentation: Running prek"
2020
@uv run prek run -a
21-
@echo "🚀 Static type checking: Running mypy"
22-
@uv run mypy
21+
@echo "🚀 Static type checking: Running ty"
22+
@uv run ty check
2323

2424
.PHONY: format
2525
format: ## Perform ruff formatting
@@ -31,7 +31,7 @@ lint: ## Perform ruff linting
3131

3232
.PHONY: typecheck
3333
typecheck: ## Perform type checking
34-
@uv run mypy
34+
@uv run ty check
3535

3636
.PHONY: test
3737
test: ## Test the code with pytest.
@@ -76,7 +76,7 @@ publish: validate-tag build ## Publish a release to PyPI, uses token from ~/.pyp
7676
# Define variables for files/directories to clean
7777
BUILD_DIRS = build dist *.egg-info
7878
DOC_DIRS = build
79-
MYPY_DIRS = .mypy_cache dmypy.json dmypy.sock
79+
TY_DIRS = .ty_cache .red_knot_cache
8080
TEST_DIRS = .cache .pytest_cache htmlcov
8181
TEST_FILES = .coverage coverage.xml
8282

@@ -90,10 +90,10 @@ clean-docs: ## Clean documentation artifacts
9090
@echo "🚀 Removing documentation artifacts"
9191
@uv run python -c "import shutil; import os; [shutil.rmtree(d, ignore_errors=True) for d in '$(DOC_DIRS)'.split() if os.path.isdir(d)]"
9292

93-
.PHONY: clean-mypy
94-
clean-mypy: ## Clean mypy artifacts
95-
@echo "🚀 Removing mypy artifacts"
96-
@uv run python -c "import shutil; import os; [shutil.rmtree(d, ignore_errors=True) for d in '$(MYPY_DIRS)'.split() if os.path.isdir(d)]"
93+
.PHONY: clean-ty
94+
clean-ty: ## Clean ty artifacts
95+
@echo "🚀 Removing ty artifacts"
96+
@uv run python -c "import shutil; import os; [shutil.rmtree(d, ignore_errors=True) for d in '$(TY_DIRS)'.split() if os.path.isdir(d)]"
9797

9898
.PHONY: clean-pycache
9999
clean-pycache: ## Clean pycache artifacts
@@ -112,7 +112,7 @@ clean-test: ## Clean test artifacts
112112
@uv run python -c "from pathlib import Path; [Path(f).unlink(missing_ok=True) for f in '$(TEST_FILES)'.split()]"
113113

114114
.PHONY: clean
115-
clean: clean-build clean-docs clean-mypy clean-pycache clean-ruff clean-test ## Clean all artifacts
115+
clean: clean-build clean-docs clean-ty clean-pycache clean-ruff clean-test ## Clean all artifacts
116116
@echo "🚀 Cleaned all artifacts"
117117

118118
.PHONY: help

cmd2/annotated.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -708,10 +708,10 @@ def __init__(self, *args: Any, container_factory: Callable[[list[Any]], Any] | N
708708

709709
def __call__(
710710
self,
711-
_parser: argparse.ArgumentParser,
711+
parser: argparse.ArgumentParser, # noqa: ARG002
712712
namespace: argparse.Namespace,
713713
values: Any,
714-
_option_string: str | None = None,
714+
option_string: str | None = None, # noqa: ARG002
715715
) -> None:
716716
result = values
717717
if self._container_factory is not None and isinstance(values, list):
@@ -879,7 +879,7 @@ def _resolve_union(
879879
raise TypeError(f"Union type {type_names} is ambiguous for auto-resolution.")
880880

881881
parts = [_resolve_base_type(member, allow_unknown_entry=allow_unknown_entry) for member in non_none]
882-
# Every part is an Enum (guarded above), so each has a converter; the None-filter keeps mypy happy.
882+
# Every part is an Enum (guarded above), so each has a converter; the None-filter keeps the type checker happy.
883883
converters = [part.converter for part in parts if part.converter is not None]
884884
choices = _dedupe_choices(choice for part in parts for choice in (part.choices or []))
885885

@@ -2189,7 +2189,7 @@ def _find_argument_block(hint: Any) -> type[ArgumentBlock] | None:
21892189
return None
21902190

21912191

2192-
def _init_field_names(dc_type: type) -> list[str]:
2192+
def _init_field_names(dc_type: Any) -> list[str]:
21932193
"""Names of a dataclass's ``init`` fields in definition order (the flat argument names of a block)."""
21942194
return [f.name for f in fields(dc_type) if f.init]
21952195

@@ -3113,6 +3113,9 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
31133113
except SystemExit as exc:
31143114
raise Cmd2ArgparseError from exc
31153115

3116+
if ns is None:
3117+
raise ValueError("ns is None")
3118+
31163119
setattr(ns, constants.NS_ATTR_STATEMENT, statement)
31173120
handler = getattr(ns, constants.NS_ATTR_SUBCOMMAND_FUNC, None)
31183121
if base_command and handler is not None:

cmd2/argparse_utils.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -747,8 +747,8 @@ def __init__(
747747
super().__init__(
748748
prog=prog,
749749
usage=usage,
750-
description=description, # type: ignore[arg-type]
751-
epilog=epilog, # type: ignore[arg-type]
750+
description=description, # type: ignore[arg-type, ty:invalid-argument-type]
751+
epilog=epilog, # type: ignore[arg-type, ty:invalid-argument-type]
752752
parents=parents,
753753
formatter_class=formatter_class,
754754
prefix_chars=prefix_chars,
@@ -772,15 +772,15 @@ def __init__(
772772
self.description: HelpContent | None # type: ignore[assignment]
773773
self.epilog: HelpContent | None # type: ignore[assignment]
774774

775-
def print_usage(self, file: IO[str] | None = None) -> None: # type:ignore[override]
775+
def print_usage(self, file: IO[str] | None = None) -> None: # type: ignore[override, ty:invalid-method-override]
776776
"""Override to ensure the formatter is aware of the target file."""
777777
if file is None:
778778
file = self._thread_locals.current_output_file
779779

780780
with self.output_to(file):
781781
super().print_usage(file)
782782

783-
def print_help(self, file: IO[str] | None = None) -> None: # type:ignore[override]
783+
def print_help(self, file: IO[str] | None = None) -> None: # type: ignore[override, ty:invalid-method-override]
784784
"""Override to ensure the formatter is aware of the target file."""
785785
if file is None:
786786
file = self._thread_locals.current_output_file
@@ -831,7 +831,7 @@ def _build_subparsers_prog_prefix(self, positionals: list[argparse.Action]) -> s
831831
temp_parser = Cmd2ArgumentParser(
832832
prog=self.prog,
833833
usage=None,
834-
formatter_class=self.formatter_class,
834+
formatter_class=cast(type[Cmd2HelpFormatter], self.formatter_class),
835835
add_help=False,
836836
)
837837

@@ -1037,7 +1037,8 @@ def error(self, message: str) -> NoReturn:
10371037

10381038
def _get_formatter(self, *_args: Any, **_kwargs: Any) -> Cmd2HelpFormatter:
10391039
"""Override with customizations for Cmd2HelpFormatter."""
1040-
return self.formatter_class(prog=self.prog, file=self._thread_locals.current_output_file)
1040+
formatter_class = cast(type[Cmd2HelpFormatter], self.formatter_class)
1041+
return formatter_class(prog=self.prog, file=self._thread_locals.current_output_file)
10411042

10421043
def format_help(self, *args: Any, **kwargs: Any) -> str:
10431044
"""Override to add a newline."""

cmd2/cmd2.py

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -849,7 +849,7 @@ def _autoload_commands(self) -> None:
849849
all_commandset_defs = CommandSet.__subclasses__()
850850
existing_commandset_types = [type(command_set) for command_set in self._installed_command_sets]
851851

852-
def load_commandset_by_type(commandset_types: list[type[CommandSet[Any]]]) -> None:
852+
def load_commandset_by_type(commandset_types: Sequence[type[CommandSet[Any]]]) -> None:
853853
for cmdset_type in commandset_types:
854854
# check if the type has sub-classes. We will only auto-load leaf class types.
855855
subclasses = cmdset_type.__subclasses__()
@@ -2547,11 +2547,11 @@ def _perform_completion(
25472547
completer.complete, tokens=raw_tokens[1:] if spec.preserve_quotes else tokens[1:], cmd_set=cmd_set
25482548
)
25492549
else:
2550-
completer_func = self.completedefault # type: ignore[assignment]
2550+
completer_func = self.completedefault # type: ignore[assignment, ty:invalid-assignment]
25512551

25522552
# Not a recognized macro or command
25532553
else:
2554-
completer_func = self.completedefault # type: ignore[assignment]
2554+
completer_func = self.completedefault # type: ignore[assignment, ty:invalid-assignment]
25552555

25562556
# Otherwise we are completing the command token or performing custom completion
25572557
else:
@@ -2968,7 +2968,7 @@ def onecmd_plus_hooks(
29682968
with self.sigint_protection:
29692969
if py_bridge_call:
29702970
# Start saving command's stdout at this point
2971-
self.stdout.pause_storage = False # type: ignore[attr-defined]
2971+
self.stdout.pause_storage = False # type: ignore[attr-defined, ty:invalid-assignment]
29722972

29732973
redir_saved_state = self._redirect_output(statement)
29742974

@@ -3007,7 +3007,7 @@ def onecmd_plus_hooks(
30073007

30083008
if py_bridge_call:
30093009
# Stop saving command's stdout before command finalization hooks run
3010-
self.stdout.pause_storage = True # type: ignore[attr-defined]
3010+
self.stdout.pause_storage = True # type: ignore[attr-defined, ty:invalid-assignment]
30113011
except (SkipPostcommandHooks, EmptyStatement):
30123012
# Don't do anything, but do allow command finalization hooks to run
30133013
pass
@@ -3512,7 +3512,7 @@ def _read_raw_input(
35123512
self.active_session = self.main_session
35133513

35143514
# We're not at a terminal, so we're likely reading from a file or a pipe.
3515-
prompt_obj = prompt() if callable(prompt) else prompt
3515+
prompt_obj = prompt if isinstance(prompt, (ANSI, str)) else prompt()
35163516
prompt_str = prompt_obj.value if isinstance(prompt_obj, ANSI) else prompt_obj
35173517

35183518
# If this is an interactive pipe, then display the prompt first
@@ -3800,7 +3800,7 @@ def _build_alias_parser() -> Cmd2ArgumentParser:
38003800
"An alias is a command that enables replacement of a word by another string.",
38013801
)
38023802
alias_parser = argparse_utils.DEFAULT_ARGUMENT_PARSER(description=alias_description)
3803-
alias_parser.epilog = TextGroup(
3803+
alias_parser.epilog = TextGroup( # type: ignore[assignment, ty:invalid-assignment]
38043804
"See Also",
38053805
"macro",
38063806
)
@@ -3832,7 +3832,7 @@ def _build_alias_create_parser(cls) -> Cmd2ArgumentParser:
38323832
"for the actual command the alias resolves to."
38333833
),
38343834
)
3835-
alias_create_parser.epilog = TextGroup("Notes", alias_create_notes)
3835+
alias_create_parser.epilog = TextGroup("Notes", alias_create_notes) # type: ignore[assignment, ty:invalid-assignment]
38363836

38373837
# Add arguments
38383838
alias_create_parser.add_argument("name", help="name of this alias")
@@ -4014,7 +4014,7 @@ def _build_macro_parser() -> Cmd2ArgumentParser:
40144014
"A macro is similar to an alias, but it can contain argument placeholders.",
40154015
)
40164016
macro_parser = argparse_utils.DEFAULT_ARGUMENT_PARSER(description=macro_description)
4017-
macro_parser.epilog = TextGroup(
4017+
macro_parser.epilog = TextGroup( # type: ignore[assignment, ty:invalid-assignment]
40184018
"See Also",
40194019
"alias",
40204020
)
@@ -4077,7 +4077,7 @@ def _build_macro_create_parser(cls) -> Cmd2ArgumentParser:
40774077
"This default behavior changes if custom completion for macro arguments has been implemented."
40784078
),
40794079
)
4080-
macro_create_parser.epilog = TextGroup("Notes", macro_create_notes)
4080+
macro_create_parser.epilog = TextGroup("Notes", macro_create_notes) # type: ignore[assignment, ty:invalid-assignment]
40814081

40824082
# Add arguments
40834083
macro_create_parser.add_argument("name", help="name of this macro")
@@ -4572,7 +4572,7 @@ def do_shortcuts(self, _: argparse.Namespace) -> None:
45724572
@staticmethod
45734573
def _build__eof_parser() -> Cmd2ArgumentParser:
45744574
_eof_parser = argparse_utils.DEFAULT_ARGUMENT_PARSER(description="Called when Ctrl-D is pressed.")
4575-
_eof_parser.epilog = TextGroup(
4575+
_eof_parser.epilog = TextGroup( # type: ignore[assignment, ty:invalid-assignment]
45764576
"Note",
45774577
"This command is for internal use and is not intended to be called from the command line.",
45784578
)
@@ -5032,7 +5032,7 @@ def py_quit() -> None:
50325032
# Check if we are running Python code
50335033
if py_code_to_run:
50345034
try: # noqa: SIM105
5035-
interp.runcode(py_code_to_run) # type: ignore[arg-type]
5035+
interp.runcode(py_code_to_run) # type: ignore[arg-type, ty:invalid-argument-type]
50365036
except BaseException: # noqa: BLE001, S110
50375037
# We don't care about any exception that happened in the Python code
50385038
pass
@@ -5418,11 +5418,11 @@ def _initialize_history(self, hist_file: str) -> None:
54185418
try:
54195419
import lzma as decompress_lib
54205420

5421-
decompress_exceptions: tuple[type[Exception]] = (decompress_lib.LZMAError,)
5421+
decompress_exceptions: tuple[type[Exception], ...] = (decompress_lib.LZMAError,)
54225422
except ModuleNotFoundError: # pragma: no cover
54235423
import bz2 as decompress_lib # type: ignore[no-redef]
54245424

5425-
decompress_exceptions: tuple[type[Exception]] = (OSError, ValueError) # type: ignore[no-redef]
5425+
decompress_exceptions: tuple[type[Exception], ...] = (OSError, ValueError) # type: ignore[no-redef]
54265426

54275427
try:
54285428
history_json = decompress_lib.decompress(compressed_bytes).decode(encoding="utf-8")
@@ -5471,7 +5471,7 @@ def _persist_history(self) -> None:
54715471
def _build_edit_parser(cls) -> Cmd2ArgumentParser:
54725472
edit_description = "Run a text editor and optionally open a file with it."
54735473
edit_parser = argparse_utils.DEFAULT_ARGUMENT_PARSER(description=edit_description)
5474-
edit_parser.epilog = TextGroup(
5474+
edit_parser.epilog = TextGroup( # type: ignore[assignment, ty:invalid-assignment]
54755475
"Note",
54765476
Text.assemble(
54775477
"To set a new editor, run: ",
@@ -5593,7 +5593,7 @@ def _build__relative_run_script_parser(cls) -> Cmd2ArgumentParser:
55935593
_relative_run_script_parser = cls._build_base_run_script_parser()
55945594

55955595
# Append to existing description
5596-
_relative_run_script_parser.description = Group(
5596+
_relative_run_script_parser.description = Group( # type: ignore[assignment, ty:invalid-assignment]
55975597
cast(Group, _relative_run_script_parser.description),
55985598
"\n",
55995599
(
@@ -5602,7 +5602,7 @@ def _build__relative_run_script_parser(cls) -> Cmd2ArgumentParser:
56025602
),
56035603
)
56045604

5605-
_relative_run_script_parser.epilog = TextGroup(
5605+
_relative_run_script_parser.epilog = TextGroup( # type: ignore[assignment, ty:invalid-assignment]
56065606
"Note",
56075607
"This command is intended to be used from within a text script.",
56085608
)

cmd2/decorators.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ def arg_decorator(func: ArgparseCommandFunc[CmdOrSetT]) -> RawCommandFunc[CmdOrS
310310
:return: Function that takes raw input and converts to an argparse Namespace to passed to the wrapped function.
311311
"""
312312

313-
@functools.wraps(func)
313+
@functools.wraps(func) # type: ignore[arg-type, ty:invalid-argument-type]
314314
def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
315315
"""Command function wrapper which translates command line into argparse Namespace and call actual command function.
316316
@@ -345,9 +345,9 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
345345
parsing_results: tuple[argparse.Namespace] | tuple[argparse.Namespace, list[str]]
346346
with arg_parser.output_to(cmd_app.stdout):
347347
if with_unknown_args:
348-
parsing_results = arg_parser.parse_known_args(command_arg_list, initial_namespace)
348+
parsing_results = arg_parser.parse_known_args(command_arg_list, initial_namespace) # type: ignore[assignment, ty:invalid-assignment]
349349
else:
350-
parsing_results = (arg_parser.parse_args(command_arg_list, initial_namespace),)
350+
parsing_results = (arg_parser.parse_args(command_arg_list, initial_namespace),) # type: ignore[assignment, ty:invalid-assignment]
351351
except SystemExit as exc:
352352
raise Cmd2ArgparseError from exc
353353

cmd2/pt_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ def __init__(
148148
self._cmd_app = cmd_app
149149
self.custom_settings = custom_settings
150150

151-
def get_completions(self, document: Document, _complete_event: object) -> Iterable[Completion]:
151+
def get_completions(self, document: Document, complete_event: object) -> Iterable[Completion]: # noqa: ARG002
152152
"""Get completions for the current input."""
153153
# Find the beginning of the current word based on delimiters
154154
line = document.text

cmd2/rich_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def __repr__(self) -> str:
105105

106106

107107
# Controls when ANSI style sequences are allowed in output
108-
ALLOW_STYLE = AllowStyle.TERMINAL
108+
ALLOW_STYLE: AllowStyle = AllowStyle.TERMINAL
109109

110110

111111
class Cmd2HelpFormatter(RichHelpFormatter):

pyproject.toml

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,12 @@ dev = [
4040
"codecov>=2.1",
4141
"ipython>=8.23",
4242
"mkdocstrings[python]>=1",
43-
"mypy>=1.13",
4443
"prek>=0.3.5",
4544
"pytest>=8.1.1",
4645
"pytest-cov>=5",
4746
"pytest-mock>=3.14.1",
4847
"ruff>=0.14.10",
48+
"ty>=0.0.73",
4949
"uv-publish>=1.3",
5050
"zensical>=0.0.17",
5151
]
@@ -63,33 +63,7 @@ test = [
6363
"pytest-cov>=5",
6464
"pytest-mock>=3.14.1",
6565
]
66-
validate = ["mypy>=1.13", "ruff>=0.14.10", "types-setuptools>=80.8.0"]
67-
68-
[tool.mypy]
69-
disallow_incomplete_defs = true
70-
disallow_untyped_calls = true
71-
disallow_untyped_defs = true
72-
exclude = [
73-
"^.git/",
74-
"^.venv/",
75-
"^build/", # .build directory
76-
"^docs/", # docs directory
77-
"^dist/",
78-
"^examples/", # examples directory
79-
"^noxfile\\.py$", # nox config file
80-
"setup\\.py$", # any files named setup.py
81-
"^site/",
82-
"^tests/", # tests directory
83-
]
84-
files = ['.']
85-
show_column_numbers = true
86-
show_error_codes = true
87-
show_error_context = true
88-
strict = true
89-
warn_redundant_casts = true
90-
warn_return_any = true
91-
warn_unreachable = true
92-
warn_unused_ignores = false
66+
validate = ["ruff>=0.14.10", "ty>=0.0.73", "types-setuptools>=80.8.0"]
9367

9468
[tool.pytest.ini_options]
9569
testpaths = ["tests"]

ruff.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@ exclude = [
77
".git-rewrite",
88
".hg",
99
".ipynb_checkpoints",
10-
".mypy_cache",
1110
".nox",
1211
".pants.d",
1312
".pyenv",
1413
".pytest_cache",
1514
".pytype",
15+
".red_knot_cache",
1616
".ruff_cache",
1717
".svn",
1818
".tox",
19+
".ty_cache",
1920
".venv",
2021
".vscode",
2122
"__pypackages__",

0 commit comments

Comments
 (0)