From 987248515d95789810c133de770eaf08212457e0 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Wed, 19 Aug 2026 17:29:04 -0400 Subject: [PATCH 1/3] Migrate from mypy to ty as a type checker --- .github/workflows/typecheck.yml | 2 +- Makefile | 18 +++++++++--------- cmd2/annotated.py | 2 +- pyproject.toml | 30 ++---------------------------- ruff.toml | 3 ++- ty.toml | 14 ++++++++++++++ 6 files changed, 29 insertions(+), 40 deletions(-) create mode 100644 ty.toml diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 4057d0aab..f5e875fe8 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -32,4 +32,4 @@ jobs: python-version: ${{ matrix.python-version }} - name: Check typing - run: uv run mypy . + run: uv run ty check diff --git a/Makefile b/Makefile index 8319eb89b..01f28658d 100644 --- a/Makefile +++ b/Makefile @@ -18,8 +18,8 @@ check: ## Run code quality tools. @uv lock --locked @echo "🚀 Auto-formatting/Linting code and documentation: Running prek" @uv run prek run -a - @echo "🚀 Static type checking: Running mypy" - @uv run mypy + @echo "🚀 Static type checking: Running ty" + @uv run ty check .PHONY: format format: ## Perform ruff formatting @@ -31,7 +31,7 @@ lint: ## Perform ruff linting .PHONY: typecheck typecheck: ## Perform type checking - @uv run mypy + @uv run ty check .PHONY: test test: ## Test the code with pytest. @@ -76,7 +76,7 @@ publish: validate-tag build ## Publish a release to PyPI, uses token from ~/.pyp # Define variables for files/directories to clean BUILD_DIRS = build dist *.egg-info DOC_DIRS = build -MYPY_DIRS = .mypy_cache dmypy.json dmypy.sock +TY_DIRS = .ty_cache .red_knot_cache TEST_DIRS = .cache .pytest_cache htmlcov TEST_FILES = .coverage coverage.xml @@ -90,10 +90,10 @@ clean-docs: ## Clean documentation artifacts @echo "🚀 Removing documentation artifacts" @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)]" -.PHONY: clean-mypy -clean-mypy: ## Clean mypy artifacts - @echo "🚀 Removing mypy artifacts" - @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)]" +.PHONY: clean-ty +clean-ty: ## Clean ty artifacts + @echo "🚀 Removing ty artifacts" + @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)]" .PHONY: clean-pycache clean-pycache: ## Clean pycache artifacts @@ -112,7 +112,7 @@ clean-test: ## Clean test artifacts @uv run python -c "from pathlib import Path; [Path(f).unlink(missing_ok=True) for f in '$(TEST_FILES)'.split()]" .PHONY: clean -clean: clean-build clean-docs clean-mypy clean-pycache clean-ruff clean-test ## Clean all artifacts +clean: clean-build clean-docs clean-ty clean-pycache clean-ruff clean-test ## Clean all artifacts @echo "🚀 Cleaned all artifacts" .PHONY: help diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 913bf85bb..46a510a8b 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -879,7 +879,7 @@ def _resolve_union( raise TypeError(f"Union type {type_names} is ambiguous for auto-resolution.") parts = [_resolve_base_type(member, allow_unknown_entry=allow_unknown_entry) for member in non_none] - # Every part is an Enum (guarded above), so each has a converter; the None-filter keeps mypy happy. + # Every part is an Enum (guarded above), so each has a converter; the None-filter keeps the type checker happy. converters = [part.converter for part in parts if part.converter is not None] choices = _dedupe_choices(choice for part in parts for choice in (part.choices or [])) diff --git a/pyproject.toml b/pyproject.toml index 24e754951..840670685 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,12 +40,12 @@ dev = [ "codecov>=2.1", "ipython>=8.23", "mkdocstrings[python]>=1", - "mypy>=1.13", "prek>=0.3.5", "pytest>=8.1.1", "pytest-cov>=5", "pytest-mock>=3.14.1", "ruff>=0.14.10", + "ty>=0.0.73", "uv-publish>=1.3", "zensical>=0.0.17", ] @@ -63,33 +63,7 @@ test = [ "pytest-cov>=5", "pytest-mock>=3.14.1", ] -validate = ["mypy>=1.13", "ruff>=0.14.10", "types-setuptools>=80.8.0"] - -[tool.mypy] -disallow_incomplete_defs = true -disallow_untyped_calls = true -disallow_untyped_defs = true -exclude = [ - "^.git/", - "^.venv/", - "^build/", # .build directory - "^docs/", # docs directory - "^dist/", - "^examples/", # examples directory - "^noxfile\\.py$", # nox config file - "setup\\.py$", # any files named setup.py - "^site/", - "^tests/", # tests directory -] -files = ['.'] -show_column_numbers = true -show_error_codes = true -show_error_context = true -strict = true -warn_redundant_casts = true -warn_return_any = true -warn_unreachable = true -warn_unused_ignores = false +validate = ["ruff>=0.14.10", "ty>=0.0.73", "types-setuptools>=80.8.0"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/ruff.toml b/ruff.toml index 107e615b5..ca97d1ce7 100644 --- a/ruff.toml +++ b/ruff.toml @@ -7,15 +7,16 @@ exclude = [ ".git-rewrite", ".hg", ".ipynb_checkpoints", - ".mypy_cache", ".nox", ".pants.d", ".pyenv", ".pytest_cache", ".pytype", + ".red_knot_cache", ".ruff_cache", ".svn", ".tox", + ".ty_cache", ".venv", ".vscode", "__pypackages__", diff --git a/ty.toml b/ty.toml new file mode 100644 index 000000000..d2bf6833e --- /dev/null +++ b/ty.toml @@ -0,0 +1,14 @@ +[environment] +python-version = "3.11" + +[src] +include = ["cmd2"] + +[rules] +unresolved-attribute = "ignore" # 64 warnings +invalid-method-override = "ignore" # 4 warnings +invalid-argument-type = "ignore" # 8 warnings +invalid-assignment = "ignore" # 16 warnings +call-top-callable = "ignore" # 2 warnings +invalid-return-type = "ignore" # 1 warning +unknown-argument = "ignore" # 1 warning From b2daa16f0c4d515b559859cdbb421c15049bb882 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 20 Aug 2026 12:39:15 -0400 Subject: [PATCH 2/3] Eliminated all the ty ignore rules other than the one for unresolved attributes --- cmd2/annotated.py | 8 +++++--- cmd2/argparse_utils.py | 13 +++++++------ cmd2/cmd2.py | 34 +++++++++++++++++----------------- cmd2/decorators.py | 6 +++--- cmd2/pt_utils.py | 2 +- cmd2/rich_utils.py | 2 +- ty.toml | 6 ------ 7 files changed, 34 insertions(+), 37 deletions(-) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 46a510a8b..86bdccd00 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -708,10 +708,10 @@ def __init__(self, *args: Any, container_factory: Callable[[list[Any]], Any] | N def __call__( self, - _parser: argparse.ArgumentParser, + parser: argparse.ArgumentParser, # noqa: ARG002 namespace: argparse.Namespace, values: Any, - _option_string: str | None = None, + option_string: str | None = None, # noqa: ARG002 ) -> None: result = values if self._container_factory is not None and isinstance(values, list): @@ -2189,7 +2189,7 @@ def _find_argument_block(hint: Any) -> type[ArgumentBlock] | None: return None -def _init_field_names(dc_type: type) -> list[str]: +def _init_field_names(dc_type: Any) -> list[str]: """Names of a dataclass's ``init`` fields in definition order (the flat argument names of a block).""" return [f.name for f in fields(dc_type) if f.init] @@ -3113,6 +3113,8 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: except SystemExit as exc: raise Cmd2ArgparseError from exc + if ns is None: + raise ValueError("ns is None") setattr(ns, constants.NS_ATTR_STATEMENT, statement) handler = getattr(ns, constants.NS_ATTR_SUBCOMMAND_FUNC, None) if base_command and handler is not None: diff --git a/cmd2/argparse_utils.py b/cmd2/argparse_utils.py index f58128167..0584103df 100644 --- a/cmd2/argparse_utils.py +++ b/cmd2/argparse_utils.py @@ -747,8 +747,8 @@ def __init__( super().__init__( prog=prog, usage=usage, - description=description, # type: ignore[arg-type] - epilog=epilog, # type: ignore[arg-type] + description=description, # type: ignore[arg-type, ty:invalid-argument-type] + epilog=epilog, # type: ignore[arg-type, ty:invalid-argument-type] parents=parents, formatter_class=formatter_class, prefix_chars=prefix_chars, @@ -772,7 +772,7 @@ def __init__( self.description: HelpContent | None # type: ignore[assignment] self.epilog: HelpContent | None # type: ignore[assignment] - def print_usage(self, file: IO[str] | None = None) -> None: # type:ignore[override] + def print_usage(self, file: IO[str] | None = None) -> None: # type: ignore[override, ty:invalid-method-override] """Override to ensure the formatter is aware of the target file.""" if file is None: file = self._thread_locals.current_output_file @@ -780,7 +780,7 @@ def print_usage(self, file: IO[str] | None = None) -> None: # type:ignore[overr with self.output_to(file): super().print_usage(file) - def print_help(self, file: IO[str] | None = None) -> None: # type:ignore[override] + def print_help(self, file: IO[str] | None = None) -> None: # type: ignore[override, ty:invalid-method-override] """Override to ensure the formatter is aware of the target file.""" if file is None: file = self._thread_locals.current_output_file @@ -831,7 +831,7 @@ def _build_subparsers_prog_prefix(self, positionals: list[argparse.Action]) -> s temp_parser = Cmd2ArgumentParser( prog=self.prog, usage=None, - formatter_class=self.formatter_class, + formatter_class=cast(type[Cmd2HelpFormatter], self.formatter_class), add_help=False, ) @@ -1037,7 +1037,8 @@ def error(self, message: str) -> NoReturn: def _get_formatter(self, *_args: Any, **_kwargs: Any) -> Cmd2HelpFormatter: """Override with customizations for Cmd2HelpFormatter.""" - return self.formatter_class(prog=self.prog, file=self._thread_locals.current_output_file) + formatter_class = cast(type[Cmd2HelpFormatter], self.formatter_class) + return formatter_class(prog=self.prog, file=self._thread_locals.current_output_file) def format_help(self, *args: Any, **kwargs: Any) -> str: """Override to add a newline.""" diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 3bfb4574a..010af5de2 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -849,7 +849,7 @@ def _autoload_commands(self) -> None: all_commandset_defs = CommandSet.__subclasses__() existing_commandset_types = [type(command_set) for command_set in self._installed_command_sets] - def load_commandset_by_type(commandset_types: list[type[CommandSet[Any]]]) -> None: + def load_commandset_by_type(commandset_types: Sequence[type[CommandSet[Any]]]) -> None: for cmdset_type in commandset_types: # check if the type has sub-classes. We will only auto-load leaf class types. subclasses = cmdset_type.__subclasses__() @@ -2547,11 +2547,11 @@ def _perform_completion( completer.complete, tokens=raw_tokens[1:] if spec.preserve_quotes else tokens[1:], cmd_set=cmd_set ) else: - completer_func = self.completedefault # type: ignore[assignment] + completer_func = self.completedefault # type: ignore[assignment, ty:invalid-assignment] # Not a recognized macro or command else: - completer_func = self.completedefault # type: ignore[assignment] + completer_func = self.completedefault # type: ignore[assignment, ty:invalid-assignment] # Otherwise we are completing the command token or performing custom completion else: @@ -2968,7 +2968,7 @@ def onecmd_plus_hooks( with self.sigint_protection: if py_bridge_call: # Start saving command's stdout at this point - self.stdout.pause_storage = False # type: ignore[attr-defined] + self.stdout.pause_storage = False # type: ignore[attr-defined, ty:invalid-assignment] redir_saved_state = self._redirect_output(statement) @@ -3007,7 +3007,7 @@ def onecmd_plus_hooks( if py_bridge_call: # Stop saving command's stdout before command finalization hooks run - self.stdout.pause_storage = True # type: ignore[attr-defined] + self.stdout.pause_storage = True # type: ignore[attr-defined, ty:invalid-assignment] except (SkipPostcommandHooks, EmptyStatement): # Don't do anything, but do allow command finalization hooks to run pass @@ -3512,7 +3512,7 @@ def _read_raw_input( self.active_session = self.main_session # We're not at a terminal, so we're likely reading from a file or a pipe. - prompt_obj = prompt() if callable(prompt) else prompt + prompt_obj = prompt if isinstance(prompt, (ANSI, str)) else prompt() prompt_str = prompt_obj.value if isinstance(prompt_obj, ANSI) else prompt_obj # If this is an interactive pipe, then display the prompt first @@ -3800,7 +3800,7 @@ def _build_alias_parser() -> Cmd2ArgumentParser: "An alias is a command that enables replacement of a word by another string.", ) alias_parser = argparse_utils.DEFAULT_ARGUMENT_PARSER(description=alias_description) - alias_parser.epilog = TextGroup( + alias_parser.epilog = TextGroup( # type: ignore[assignment, ty:invalid-assignment] "See Also", "macro", ) @@ -3832,7 +3832,7 @@ def _build_alias_create_parser(cls) -> Cmd2ArgumentParser: "for the actual command the alias resolves to." ), ) - alias_create_parser.epilog = TextGroup("Notes", alias_create_notes) + alias_create_parser.epilog = TextGroup("Notes", alias_create_notes) # type: ignore[assignment, ty:invalid-assignment] # Add arguments alias_create_parser.add_argument("name", help="name of this alias") @@ -4014,7 +4014,7 @@ def _build_macro_parser() -> Cmd2ArgumentParser: "A macro is similar to an alias, but it can contain argument placeholders.", ) macro_parser = argparse_utils.DEFAULT_ARGUMENT_PARSER(description=macro_description) - macro_parser.epilog = TextGroup( + macro_parser.epilog = TextGroup( # type: ignore[assignment, ty:invalid-assignment] "See Also", "alias", ) @@ -4077,7 +4077,7 @@ def _build_macro_create_parser(cls) -> Cmd2ArgumentParser: "This default behavior changes if custom completion for macro arguments has been implemented." ), ) - macro_create_parser.epilog = TextGroup("Notes", macro_create_notes) + macro_create_parser.epilog = TextGroup("Notes", macro_create_notes) # type: ignore[assignment, ty:invalid-assignment] # Add arguments macro_create_parser.add_argument("name", help="name of this macro") @@ -4572,7 +4572,7 @@ def do_shortcuts(self, _: argparse.Namespace) -> None: @staticmethod def _build__eof_parser() -> Cmd2ArgumentParser: _eof_parser = argparse_utils.DEFAULT_ARGUMENT_PARSER(description="Called when Ctrl-D is pressed.") - _eof_parser.epilog = TextGroup( + _eof_parser.epilog = TextGroup( # type: ignore[assignment, ty:invalid-assignment] "Note", "This command is for internal use and is not intended to be called from the command line.", ) @@ -5032,7 +5032,7 @@ def py_quit() -> None: # Check if we are running Python code if py_code_to_run: try: # noqa: SIM105 - interp.runcode(py_code_to_run) # type: ignore[arg-type] + interp.runcode(py_code_to_run) # type: ignore[arg-type, ty:invalid-argument-type] except BaseException: # noqa: BLE001, S110 # We don't care about any exception that happened in the Python code pass @@ -5418,11 +5418,11 @@ def _initialize_history(self, hist_file: str) -> None: try: import lzma as decompress_lib - decompress_exceptions: tuple[type[Exception]] = (decompress_lib.LZMAError,) + decompress_exceptions: tuple[type[Exception], ...] = (decompress_lib.LZMAError,) except ModuleNotFoundError: # pragma: no cover import bz2 as decompress_lib # type: ignore[no-redef] - decompress_exceptions: tuple[type[Exception]] = (OSError, ValueError) # type: ignore[no-redef] + decompress_exceptions: tuple[type[Exception], ...] = (OSError, ValueError) # type: ignore[no-redef] try: history_json = decompress_lib.decompress(compressed_bytes).decode(encoding="utf-8") @@ -5471,7 +5471,7 @@ def _persist_history(self) -> None: def _build_edit_parser(cls) -> Cmd2ArgumentParser: edit_description = "Run a text editor and optionally open a file with it." edit_parser = argparse_utils.DEFAULT_ARGUMENT_PARSER(description=edit_description) - edit_parser.epilog = TextGroup( + edit_parser.epilog = TextGroup( # type: ignore[assignment, ty:invalid-assignment] "Note", Text.assemble( "To set a new editor, run: ", @@ -5593,7 +5593,7 @@ def _build__relative_run_script_parser(cls) -> Cmd2ArgumentParser: _relative_run_script_parser = cls._build_base_run_script_parser() # Append to existing description - _relative_run_script_parser.description = Group( + _relative_run_script_parser.description = Group( # type: ignore[assignment, ty:invalid-assignment] cast(Group, _relative_run_script_parser.description), "\n", ( @@ -5602,7 +5602,7 @@ def _build__relative_run_script_parser(cls) -> Cmd2ArgumentParser: ), ) - _relative_run_script_parser.epilog = TextGroup( + _relative_run_script_parser.epilog = TextGroup( # type: ignore[assignment, ty:invalid-assignment] "Note", "This command is intended to be used from within a text script.", ) diff --git a/cmd2/decorators.py b/cmd2/decorators.py index c65996700..8ad5bfd52 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -310,7 +310,7 @@ def arg_decorator(func: ArgparseCommandFunc[CmdOrSetT]) -> RawCommandFunc[CmdOrS :return: Function that takes raw input and converts to an argparse Namespace to passed to the wrapped function. """ - @functools.wraps(func) + @functools.wraps(func) # type: ignore[arg-type, ty:invalid-argument-type] def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: """Command function wrapper which translates command line into argparse Namespace and call actual command function. @@ -345,9 +345,9 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: parsing_results: tuple[argparse.Namespace] | tuple[argparse.Namespace, list[str]] with arg_parser.output_to(cmd_app.stdout): if with_unknown_args: - parsing_results = arg_parser.parse_known_args(command_arg_list, initial_namespace) + parsing_results = arg_parser.parse_known_args(command_arg_list, initial_namespace) # type: ignore[assignment, ty:invalid-assignment] else: - parsing_results = (arg_parser.parse_args(command_arg_list, initial_namespace),) + parsing_results = (arg_parser.parse_args(command_arg_list, initial_namespace),) # type: ignore[assignment, ty:invalid-assignment] except SystemExit as exc: raise Cmd2ArgparseError from exc diff --git a/cmd2/pt_utils.py b/cmd2/pt_utils.py index 7084e58fd..58ac3cd3e 100644 --- a/cmd2/pt_utils.py +++ b/cmd2/pt_utils.py @@ -148,7 +148,7 @@ def __init__( self._cmd_app = cmd_app self.custom_settings = custom_settings - def get_completions(self, document: Document, _complete_event: object) -> Iterable[Completion]: + def get_completions(self, document: Document, complete_event: object) -> Iterable[Completion]: # noqa: ARG002 """Get completions for the current input.""" # Find the beginning of the current word based on delimiters line = document.text diff --git a/cmd2/rich_utils.py b/cmd2/rich_utils.py index 77a6091e7..9b4567175 100644 --- a/cmd2/rich_utils.py +++ b/cmd2/rich_utils.py @@ -105,7 +105,7 @@ def __repr__(self) -> str: # Controls when ANSI style sequences are allowed in output -ALLOW_STYLE = AllowStyle.TERMINAL +ALLOW_STYLE: AllowStyle = AllowStyle.TERMINAL class Cmd2HelpFormatter(RichHelpFormatter): diff --git a/ty.toml b/ty.toml index d2bf6833e..3a27737dd 100644 --- a/ty.toml +++ b/ty.toml @@ -6,9 +6,3 @@ include = ["cmd2"] [rules] unresolved-attribute = "ignore" # 64 warnings -invalid-method-override = "ignore" # 4 warnings -invalid-argument-type = "ignore" # 8 warnings -invalid-assignment = "ignore" # 16 warnings -call-top-callable = "ignore" # 2 warnings -invalid-return-type = "ignore" # 1 warning -unknown-argument = "ignore" # 1 warning From 4de029f366411c2a438404f72067fd04c345a290 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 20 Aug 2026 13:46:38 -0400 Subject: [PATCH 3/3] Add unit test for uncovered line --- cmd2/annotated.py | 1 + tests/test_annotated.py | 43 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/cmd2/annotated.py b/cmd2/annotated.py index 86bdccd00..f11cce437 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -3115,6 +3115,7 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: if ns is None: raise ValueError("ns is None") + setattr(ns, constants.NS_ATTR_STATEMENT, statement) handler = getattr(ns, constants.NS_ATTR_SUBCOMMAND_FUNC, None) if base_command and handler is not None: diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 6f39699f7..1829147f8 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -5644,3 +5644,46 @@ def root_show(self, cmd2_parent_args: _SharedOpts) -> None: ... app.stdout = cmd2.utils.StdSim(app.stdout) with pytest.raises(TypeError, match="unexpected keyword argument"): app.do_root("show", level=5) + + +class TestNsIsNoneValueError: + """Verify that a ValueError is raised when parsing results in a None namespace.""" + + def test_ns_is_none_raises_value_error(self) -> None: + class App(cmd2.Cmd): + @with_annotated + def do_greet(self, name: str) -> None: + self.poutput(f"Hello {name}") + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + parser = app.command_parsers.get(app.do_greet) + assert parser is not None + + import unittest.mock + + with ( + unittest.mock.patch.object(parser, "parse_args", return_value=None), + pytest.raises(ValueError, match="ns is None"), + ): + app.onecmd("greet Alice") + + def test_ns_is_none_with_unknown_args_raises_value_error(self) -> None: + class App(cmd2.Cmd): + @with_annotated(with_unknown_args=True) + def do_flex(self, name: str, _unknown: list[str] | None = None) -> None: + pass + + app = App() + app.stdout = cmd2.utils.StdSim(app.stdout) + parser = app.command_parsers.get(app.do_flex) + assert parser is not None + + import unittest.mock + + # parse_known_args returns (namespace, unknown_args) + with ( + unittest.mock.patch.object(parser, "parse_known_args", return_value=(None, [])), + pytest.raises(ValueError, match="ns is None"), + ): + app.onecmd("flex Alice")