diff --git a/dev-requirements.txt b/dev-requirements.txt index 800afa4e..a1acfbf3 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -20,5 +20,5 @@ setuptools>56 # Debuggery icecream>=2.1 # typing -mypy==0.971 +mypy==1.0.0 types-PyYAML==6.0.12.4 diff --git a/integration/__init__.py b/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/integration/_explicit.py b/integration/_explicit.py index a4467889..3e697d94 100644 --- a/integration/_explicit.py +++ b/integration/_explicit.py @@ -2,7 +2,7 @@ @task -def foo(c): +def foo(c: object) -> None: """ Frobazz """ diff --git a/integration/_support/__init__.py b/integration/_support/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/integration/_support/nested_or_piped.py b/integration/_support/nested_or_piped.py index dcc3c864..deb067fe 100644 --- a/integration/_support/nested_or_piped.py +++ b/integration/_support/nested_or_piped.py @@ -1,11 +1,11 @@ -from invoke import task +from invoke import Config, task @task -def calls_foo(c): +def calls_foo(c: Config) -> None: c.run("inv -c nested_or_piped foo") @task -def foo(c): +def foo(c: Config) -> None: c.run("echo meh") diff --git a/integration/_support/parsing.py b/integration/_support/parsing.py index c305aa57..733ab14b 100644 --- a/integration/_support/parsing.py +++ b/integration/_support/parsing.py @@ -2,5 +2,5 @@ @task(optional=["meh"]) -def foo(c, meh=False): +def foo(c: object, meh: bool = False) -> None: print(meh) diff --git a/integration/_support/regression.py b/integration/_support/regression.py index f0720972..11d5ae1b 100644 --- a/integration/_support/regression.py +++ b/integration/_support/regression.py @@ -12,11 +12,11 @@ import sys -from invoke import task +from invoke import Config, task @task -def check(c): +def check(c: Config) -> None: count = 0 failures = [] for _ in range(0, 1000): diff --git a/integration/_support/tasks.py b/integration/_support/tasks.py index 9e24e85a..1b8b2a30 100644 --- a/integration/_support/tasks.py +++ b/integration/_support/tasks.py @@ -2,19 +2,19 @@ Tasks module for use within the integration tests. """ -from invoke import task +from invoke import Config, task @task -def print_foo(c): +def print_foo(c: object) -> None: print("foo") @task -def print_name(c, name): +def print_name(c: object, name: object) -> None: print(name) @task -def print_config(c): +def print_config(c: Config) -> None: print(c.foo) diff --git a/integration/_util.py b/integration/_util.py index 3acc7ddc..219562c3 100644 --- a/integration/_util.py +++ b/integration/_util.py @@ -1,20 +1,21 @@ from contextlib import contextmanager from functools import wraps from resource import getrusage, RUSAGE_SELF +from typing import Any, Callable, Iterator, Optional, TypeVar import sys import time - from pytest import skip +_T = TypeVar("_T") -def current_cpu_usage(): +def current_cpu_usage() -> float: rusage = getrusage(RUSAGE_SELF) return rusage.ru_utime + rusage.ru_stime @contextmanager -def assert_cpu_usage(lt, verbose=False): +def assert_cpu_usage(lt: float, verbose: bool = False) -> Iterator[None]: """ Execute wrapped block, asserting CPU utilization was less than ``lt``%. @@ -41,13 +42,14 @@ def assert_cpu_usage(lt, verbose=False): assert percentage < lt -def only_utf8(f): +def only_utf8(f: Callable[..., _T]) -> Callable[..., Optional[_T]]: """ Decorator causing tests to skip if local shell pipes aren't UTF-8. """ # TODO: use actual test selection labels or whatever nose has + # TODO(PY310): Use ParamSpec. @wraps(f) - def inner(*args, **kwargs): + def inner(*args: Any, **kwargs: Any) -> _T: if getattr(sys.stdout, "encoding", None) == "UTF-8": return f(*args, **kwargs) # TODO: could remove this so they show green, but figure yellow is more diff --git a/integration/context.py b/integration/context.py index ae2ee901..41aba933 100644 --- a/integration/context.py +++ b/integration/context.py @@ -4,7 +4,7 @@ class Context_: class sudo: - def base_case(self): + def base_case(self) -> None: c = Context() # Grab CI-oriented sudo user/pass direct from invocations.ci # TODO: might be nice to give Collection a way to get a Config @@ -15,5 +15,9 @@ def base_case(self): # Safety 1: ensure configured user even exists assert c.run("id {}".format(user), warn=True) # Safety 2: make sure we ARE them (and not eg root already) - assert c.run("whoami", hide=True).stdout.strip() == user - assert c.sudo("whoami", hide=True).stdout.strip() == "root" + result = c.run("whoami", hide=True) + assert result is not None + assert result.stdout.strip() == user + result = c.sudo("whoami", hide=True) + assert result is not None + assert result.stdout.strip() == "root" diff --git a/integration/main.py b/integration/main.py index 253aae15..fc4a8675 100644 --- a/integration/main.py +++ b/integration/main.py @@ -9,59 +9,66 @@ from invoke._version import __version__ from invoke.terminals import WINDOWS -from _util import only_utf8 +from ._util import only_utf8 -def _output_eq(cmd, expected): - assert run(cmd, hide=True).stdout == expected +def _output_eq(cmd: str, expected: str) -> None: + r = run(cmd, hide=True) + assert r is not None + assert r.stdout == expected class Main: - def setup_method(self): + def setup_method(self) -> None: self.cwd = os.getcwd() # Enter integration/_support as all support files are in there now os.chdir(Path(__file__).parent / "_support") - def teardown_method(self): + def teardown_method(self) -> None: os.chdir(self.cwd) class basics: @trap - def basic_invocation(self): + def basic_invocation(self) -> None: _output_eq("invoke print-foo", "foo\n") @trap - def version_output(self): + def version_output(self) -> None: _output_eq("invoke --version", "Invoke {}\n".format(__version__)) @trap - def help_output(self): - assert "Usage: inv[oke] " in run("invoke --help").stdout + def help_output(self) -> None: + r = run("invoke --help") + assert r is not None + assert "Usage: inv[oke] " in r.stdout @trap - def per_task_help(self): - assert "Frobazz" in run("invoke -c _explicit foo --help").stdout + def per_task_help(self) -> None: + r = run("invoke -c _explicit foo --help") + assert r is not None + assert "Frobazz" in r.stdout @trap - def shorthand_binary_name(self): + def shorthand_binary_name(self) -> None: _output_eq("inv print-foo", "foo\n") @trap - def explicit_task_module(self): + def explicit_task_module(self) -> None: _output_eq("inv --collection _explicit foo", "Yup\n") @trap - def invocation_with_args(self): + def invocation_with_args(self) -> None: _output_eq("inv print-name --name whatevs", "whatevs\n") @trap - def bad_collection_exits_nonzero(self): + def bad_collection_exits_nonzero(self) -> None: result = run("inv -c nope -l", warn=True) + assert result is not None assert result.exited == 1 assert not result.stdout assert result.stderr - def loads_real_user_config(self): + def loads_real_user_config(self) -> None: path = os.path.expanduser("~/.invoke.yaml") try: with open(path, "w") as fd: @@ -74,51 +81,54 @@ def loads_real_user_config(self): pass @trap - def invocable_via_python_dash_m(self): + def invocable_via_python_dash_m(self) -> None: _output_eq( "python -m invoke print-name --name mainline", "mainline\n" ) class funky_characters_in_stdout: @only_utf8 - def basic_nonstandard_characters(self): + def basic_nonstandard_characters(self) -> None: # Crummy "doesn't explode with decode errors" test cmd = ("type" if WINDOWS else "cat") + " tree.out" run(cmd, hide="stderr") @only_utf8 - def nonprinting_bytes(self): + def nonprinting_bytes(self) -> None: # Seriously non-printing characters (i.e. non UTF8) also don't # asplode (they would print as escapes normally, but still) run("echo '\xff'", hide="stderr") @only_utf8 - def nonprinting_bytes_pty(self): + def nonprinting_bytes_pty(self) -> None: if WINDOWS: return # PTY use adds another utf-8 decode spot which can also fail. run("echo '\xff'", pty=True, hide="stderr") class ptys: - def complex_nesting_under_ptys_doesnt_break(self): + def complex_nesting_under_ptys_doesnt_break(self) -> None: if WINDOWS: # Not sure how to make this work on Windows return # GH issue 191 substr = " hello\t\t\nworld with spaces" cmd = """ eval 'echo "{}" ' """.format(substr) expected = " hello\t\t\r\nworld with spaces\r\n" - assert run(cmd, pty=True, hide="both").stdout == expected + r = run(cmd, pty=True, hide="both") + assert r is not None + assert r.stdout == expected - def pty_puts_both_streams_in_stdout(self): + def pty_puts_both_streams_in_stdout(self) -> None: if WINDOWS: return err_echo = "{} err.py".format(sys.executable) command = "echo foo && {} bar".format(err_echo) r = run(command, hide="both", pty=True) + assert r is not None assert r.stdout == "foo\r\nbar\r\n" assert r.stderr == "" - def simple_command_with_pty(self): + def simple_command_with_pty(self) -> None: """ Run command under PTY """ @@ -126,23 +136,26 @@ def simple_command_with_pty(self): # under a pty, and prints useful info otherwise result = run("stty -a", hide=True, pty=True) # PTYs use \r\n, not \n, line separation + assert result is not None assert "\r\n" in result.stdout assert result.pty is True @pytest.mark.skip(reason="CircleCI env actually does have 0x0 stty") - def pty_size_is_realistic(self): + def pty_size_is_realistic(self) -> None: # When we don't explicitly set pty size, 'stty size' sees it as # 0x0. # When we do set it, it should be some non 0x0, non 80x24 (the # default) value. (yes, this means it fails if you really do have # an 80x24 terminal. but who does that?) - size = run("stty size", hide=True, pty=True).stdout.strip() + r = run("stty size", hide=True, pty=True) + assert r is not None + size = r.stdout.strip() assert size != "" assert size != "0 0" assert size != "24 80" class parsing: - def false_as_optional_arg_default_value_works_okay(self): + def false_as_optional_arg_default_value_works_okay(self) -> None: # (Dis)proves #416. When bug present, parser gets very confused, # asks "what the hell is 'whee'?". See also a unit test for # Task.get_arguments. diff --git a/integration/runners.py b/integration/runners.py index 6906d9c2..37a1e932 100644 --- a/integration/runners.py +++ b/integration/runners.py @@ -2,7 +2,7 @@ import platform import time -from unittest.mock import Mock +from unittest.mock import Mock, patch from pytest import skip, raises from invoke import ( @@ -17,18 +17,18 @@ CommandTimedOut, ) -from _util import assert_cpu_usage +from ._util import assert_cpu_usage PYPY = platform.python_implementation() == "PyPy" class Runner_: - def setup(self): + def setup(self) -> None: os.chdir(os.path.join(os.path.dirname(__file__), "_support")) class responding: - def base_case(self): + def base_case(self) -> None: # Basic "doesn't explode" test: respond.py will exit nonzero unless # this works, causing a Failure. watcher = Responder(r"What's the password\?", "Rosebud\n") @@ -41,7 +41,7 @@ def base_case(self): timeout=5, ) - def both_streams(self): + def both_streams(self) -> None: watchers = [ Responder("standard out", "with it\n"), Responder("standard error", "between chair and keyboard\n"), @@ -53,7 +53,7 @@ def both_streams(self): timeout=5, ) - def watcher_errors_become_Failures(self): + def watcher_errors_become_Failures(self) -> None: watcher = FailingResponder( pattern=r"What's the password\?", response="Rosebud\n", @@ -73,16 +73,16 @@ def watcher_errors_become_Failures(self): assert False, "Did not raise Failure!" class stdin_mirroring: - def piped_stdin_is_not_conflated_with_mocked_stdin(self): + def piped_stdin_is_not_conflated_with_mocked_stdin(self) -> None: # Re: GH issue #308 # Will die on broken-pipe OSError if bug is present. run("echo 'lollerskates' | inv -c nested_or_piped foo", hide=True) - def nested_invoke_sessions_not_conflated_with_mocked_stdin(self): + def nested_invoke_sessions_not_conflated_with_mocked_stdin(self) -> None: # Also re: GH issue #308. This one will just hang forever. Woo! run("inv -c nested_or_piped calls-foo", hide=True) - def isnt_cpu_heavy(self): + def isnt_cpu_heavy(self) -> None: "stdin mirroring isn't CPU-heavy" # CPU measurement under PyPy is...rather different. NBD. if PYPY: @@ -91,14 +91,14 @@ def isnt_cpu_heavy(self): with assert_cpu_usage(lt=7.0): run("python -u busywork.py 10", pty=True, hide=True) - def doesnt_break_when_stdin_exists_but_null(self): + def doesnt_break_when_stdin_exists_but_null(self) -> None: # Re: #425 - IOError occurs when bug present run("inv -c nested_or_piped foo < /dev/null", hide=True) class IO_hangs: "IO hangs" - def _hang_on_full_pipe(self, pty): + def _hang_on_full_pipe(self, pty: bool) -> None: class Whoops(Exception): pass @@ -106,29 +106,29 @@ class Whoops(Exception): # Force runner IO thread-body method to raise an exception to mimic # real world encoding explosions/etc. When bug is present, this # will make the test hang until forcibly terminated. - runner.handle_stdout = Mock(side_effect=Whoops, __name__="sigh") - # NOTE: both Darwin (10.10) and Linux (Travis' docker image) have - # this file. It's plenty large enough to fill most pipe buffers, - # which is the triggering behavior. - try: - runner.run("cat /usr/share/dict/words", pty=pty) - except ThreadException as e: - assert len(e.exceptions) == 1 - assert e.exceptions[0].type is Whoops - else: - assert False, "Did not receive expected ThreadException!" - - def pty_subproc_should_not_hang_if_IO_thread_has_an_exception(self): + with patch.object(runner, "handle_stdout", side_effect=Whoops, __name__="sigh"): + # NOTE: both Darwin (10.10) and Linux (Travis' docker image) have + # this file. It's plenty large enough to fill most pipe buffers, + # which is the triggering behavior. + try: + runner.run("cat /usr/share/dict/words", pty=pty) + except ThreadException as e: + assert len(e.exceptions) == 1 + assert e.exceptions[0].type is Whoops + else: + assert False, "Did not receive expected ThreadException!" + + def pty_subproc_should_not_hang_if_IO_thread_has_an_exception(self) -> None: self._hang_on_full_pipe(pty=True) - def nonpty_subproc_should_not_hang_if_IO_thread_has_an_exception(self): + def nonpty_subproc_should_not_hang_if_IO_thread_has_an_exception(self) -> None: self._hang_on_full_pipe(pty=False) class timeouts: - def does_not_fire_when_command_quick(self): + def does_not_fire_when_command_quick(self) -> None: assert run("sleep 1", timeout=5) - def triggers_exception_when_command_slow(self): + def triggers_exception_when_command_slow(self) -> None: before = time.time() with raises(CommandTimedOut) as info: run("sleep 5", timeout=0.5) diff --git a/invoke/collection.py b/invoke/collection.py index 23dcff92..2e6c2930 100644 --- a/invoke/collection.py +++ b/invoke/collection.py @@ -114,14 +114,14 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self._add_object(obj, name) def _add_object(self, obj: Any, name: Optional[str] = None) -> None: - method: Callable + method: Callable[[Any, Optional[str]], None] if isinstance(obj, Task): method = self.add_task elif isinstance(obj, (Collection, ModuleType)): method = self.add_collection else: raise TypeError("No idea how to insert {!r}!".format(type(obj))) - method(obj, name=name) + method(obj, name) def __repr__(self) -> str: task_names = list(self.tasks.keys()) @@ -150,7 +150,7 @@ def from_module( config: Optional[Dict[str, Any]] = None, loaded_from: Optional[str] = None, auto_dash_names: Optional[bool] = None, - ) -> "Collection": + ) -> "Collection": # TODO(PY311): Use Self """ Return a new `.Collection` created from ``module``. @@ -237,7 +237,7 @@ def instantiate(obj_name: Optional[str] = None) -> "Collection": def add_task( self, - task: "Task", + task: Task[Any], name: Optional[str] = None, aliases: Optional[Tuple[str, ...]] = None, default: Optional[bool] = None, @@ -266,7 +266,7 @@ def add_task( name = task.name # XXX https://github.com/python/mypy/issues/1424 elif hasattr(task.body, "func_name"): - name = task.body.func_name # type: ignore + name = task.body.func_name elif hasattr(task.body, "__name__"): name = task.__name__ else: diff --git a/invoke/completion/complete.py b/invoke/completion/complete.py index 97e9a959..13f9c89f 100644 --- a/invoke/completion/complete.py +++ b/invoke/completion/complete.py @@ -9,20 +9,18 @@ import shlex from typing import TYPE_CHECKING +from ..collection import Collection from ..exceptions import Exit, ParseError +from ..parser import Parser, ParseResult, ParserContext from ..util import debug, task_name_sort_key -if TYPE_CHECKING: - from ..collection import Collection - from ..parser import Parser, ParseResult, ParserContext - def complete( names: List[str], - core: "ParseResult", - initial_context: "ParserContext", - collection: "Collection", - parser: "Parser", + core: ParseResult, + initial_context: ParserContext, + collection: Collection, + parser: Parser, ) -> Exit: # Strip out program name (scripts give us full command line) # TODO: this may not handle path/to/script though? @@ -94,7 +92,7 @@ def complete( raise Exit -def print_task_names(collection: "Collection") -> None: +def print_task_names(collection: Collection) -> None: for name in sorted(collection.task_names, key=task_name_sort_key): print(name) # Just stick aliases after the thing they're aliased to. Sorting isn't diff --git a/invoke/config.py b/invoke/config.py index c38afc67..17d47991 100644 --- a/invoke/config.py +++ b/invoke/config.py @@ -4,7 +4,7 @@ import types from os import PathLike from os.path import join, splitext, expanduser -from typing import Any, Dict, Iterator, Optional, Tuple, Type, Union +from typing import Any, Dict, Iterator, Optional, Tuple, Type, TypeVar, Union, cast from .env import Environment from .exceptions import UnknownFileType, UnpicklableConfigMember @@ -16,10 +16,13 @@ try: from importlib.machinery import SourceFileLoader except ImportError: # PyPy3 - from importlib._bootstrap import ( # type: ignore[no-redef] + from importlib._bootstrap import ( # type: ignore[import, no-redef] _SourceFileLoader as SourceFileLoader, ) +_T = TypeVar("_T") +_U = TypeVar("_U") + def load_source(name: str, path: str) -> Dict[str, Any]: if not os.path.exists(path): @@ -73,7 +76,7 @@ def from_data( data: Dict[str, Any], root: Optional["DataProxy"] = None, keypath: Tuple[str, ...] = tuple(), - ) -> "DataProxy": + ) -> "DataProxy": # TODO(PY311): Self """ Alternate constructor for 'baby' DataProxies used as sub-dict values. @@ -1169,8 +1172,8 @@ class AmbiguousMergeError(ValueError): def merge_dicts( - base: Dict[str, Any], updates: Dict[str, Any] -) -> Dict[str, Any]: + base: Dict[str, _T], updates: Optional[Dict[str, _T]] +) -> Dict[str, _T]: """ Recursively merge dict ``updates`` into dict ``base`` (mutating ``base``.) @@ -1199,7 +1202,7 @@ def merge_dicts( if key in base: if isinstance(value, dict): if isinstance(base[key], dict): - merge_dicts(base[key], value) + merge_dicts(cast(Dict[str, Any], base[key]), value) else: raise _merge_error(base[key], value) else: @@ -1216,7 +1219,7 @@ def merge_dicts( # Dict values get reconstructed to avoid being references to the # updates dict, which can lead to nasty state-bleed bugs otherwise if isinstance(value, dict): - base[key] = copy_dict(value) + base[key] = copy_dict(value) # type: ignore[assignment] # Fileno-bearing objects are probably 'real' files which do not # copy well & must be passed by reference. Meh. elif hasattr(value, "fileno"): @@ -1239,7 +1242,7 @@ def _format_mismatch(x: object) -> str: return "{} ({!r})".format(type(x), x) -def copy_dict(source: Dict[str, Any]) -> Dict[str, Any]: +def copy_dict(source: Dict[str, _T]) -> Dict[str, _T]: """ Return a fresh copy of ``source`` with as little shared state as possible. diff --git a/invoke/context.py b/invoke/context.py index e9beaf4d..c19350e3 100644 --- a/invoke/context.py +++ b/invoke/context.py @@ -6,7 +6,6 @@ from typing import ( TYPE_CHECKING, Any, - Generator, Iterator, List, Optional, @@ -16,12 +15,9 @@ from .config import Config, DataProxy from .exceptions import Failure, AuthFailure, ResponseNotAccepted -from .runners import Result +from .runners import Result, Runner from .watchers import FailingResponder -if TYPE_CHECKING: - from invoke.runners import Runner - class Context(DataProxy): """ @@ -43,6 +39,10 @@ class Context(DataProxy): .. versionadded:: 1.0 """ + _config: Config + command_prefixes: List[str] + command_cwds: List[str] + def __init__(self, config: Optional[Config] = None) -> None: """ :param config: @@ -107,7 +107,7 @@ def run(self, command: str, **kwargs: Any) -> Optional[Result]: # Fabric/etc, which needs to juggle multiple runner class types (local and # remote). def _run( - self, runner: "Runner", command: str, **kwargs: Any + self, runner: Runner, command: str, **kwargs: Any ) -> Optional[Result]: command = self._prefix_commands(command) return runner.run(command, **kwargs) @@ -186,7 +186,7 @@ def sudo(self, command: str, **kwargs: Any) -> Optional[Result]: # NOTE: this is for runner injection; see NOTE above _run(). def _sudo( - self, runner: "Runner", command: str, **kwargs: Any + self, runner: Runner, command: str, **kwargs: Any ) -> Optional[Result]: prompt = self.config.sudo.prompt password = kwargs.pop("password", self.config.sudo.password) @@ -264,7 +264,7 @@ def _prefix_commands(self, command: str) -> str: return " && ".join(prefixes + [command]) @contextmanager - def prefix(self, command: str) -> Generator[None, None, None]: + def prefix(self, command: str) -> Iterator[None]: """ Prefix all nested `run`/`sudo` commands with given command plus ``&&``. @@ -340,10 +340,10 @@ def cwd(self) -> str: # TODO: see if there's a stronger "escape this path" function somewhere # we can reuse. e.g., escaping tildes or slashes in filenames. paths = [path.replace(" ", r"\ ") for path in self.command_cwds[i:]] - return str(os.path.join(*paths)) + return os.path.join(*paths) @contextmanager - def cd(self, path: Union[PathLike, str]) -> Generator[None, None, None]: + def cd(self, path: Union[PathLike, str]) -> Iterator[None]: """ Context manager that keeps directory state when executing commands. diff --git a/invoke/exceptions.py b/invoke/exceptions.py index 19ca563b..23290bd5 100644 --- a/invoke/exceptions.py +++ b/invoke/exceptions.py @@ -86,7 +86,7 @@ def streams_for_display(self) -> Tuple[str, str]: def __repr__(self) -> str: return self._repr() - def _repr(self, **kwargs: Any) -> str: + def _repr(self, **kwargs: object) -> str: """ Return ``__repr__``-like value from inner result + any kwargs. """ diff --git a/invoke/loader.py b/invoke/loader.py index 23bffdf0..cf51790a 100644 --- a/invoke/loader.py +++ b/invoke/loader.py @@ -16,7 +16,7 @@ class Loader: .. versionadded:: 1.0 """ - def __init__(self, config: Optional["Config"] = None) -> None: + def __init__(self, *, config: Optional["Config"] = None, **kwargs: Any) -> None: """ Set up a new loader with some `.Config`. @@ -29,7 +29,7 @@ def __init__(self, config: Optional["Config"] = None) -> None: config = Config() self.config = config - def find(self, name: str) -> Tuple[IO, str, Tuple[str, str, int]]: + def find(self, name: str) -> Tuple[IO[Any], str, Tuple[str, str, int]]: """ Implementation-specific finder method seeking collection ``name``. @@ -76,7 +76,7 @@ def load(self, name: Optional[str] = None) -> Tuple[ModuleType, str]: sys.path.insert(0, parent) # FIXME: deprecated capability that needs replacement # Actual import - module = imp.load_module(name, fd, path, desc) # type: ignore + module = imp.load_module(name, fd, path, desc) # type: ignore[arg-type] # Return module + path. # TODO: is there a reason we're not simply having clients refer to # os.path.dirname(module.__file__)? @@ -102,7 +102,7 @@ class FilesystemLoader(Loader): # TODO: otherwise Loader has to know about specific bits to transmit, such # as auto-dashes, and has to grow one of those for every bit Collection # ever needs to know - def __init__(self, start: Optional[str] = None, **kwargs: Any) -> None: + def __init__(self, *, start: Optional[str] = None, **kwargs: Any) -> None: super().__init__(**kwargs) if start is None: start = self.config.tasks.search_root @@ -113,7 +113,7 @@ def start(self) -> str: # Lazily determine default CWD if configured value is falsey return self._start or os.getcwd() - def find(self, name: str) -> Tuple[IO, str, Tuple[str, str, int]]: + def find(self, name: str) -> Tuple[IO[Any], str, Tuple[str, str, int]]: # Accumulate all parent directories start = self.start debug("FilesystemLoader find starting at {!r}".format(start)) diff --git a/invoke/parser/argument.py b/invoke/parser/argument.py index 761eb602..cab4ee37 100644 --- a/invoke/parser/argument.py +++ b/invoke/parser/argument.py @@ -1,10 +1,10 @@ -from typing import Any, Iterable, Optional, Tuple +from typing import Any, Generic, Iterable, List, Optional, Tuple, Type, TypeVar, Union, cast as cast_, overload -# TODO: dynamic type for kind -# T = TypeVar('T') +_T = TypeVar("_T") +_U = TypeVar("_U") -class Argument: +class Argument(Generic[_T, _U]): """ A command-line argument/flag. @@ -39,12 +39,71 @@ class Argument: .. versionadded:: 1.0 """ + # TODO(PY312): Use TypeVar("_T", default=str) and remove overloads + @overload + def __init__( + self: Argument[str, _U], + name: Optional[str] = ..., + names: Iterable[str] = ..., + *, + default: _U, + help: Optional[str] = ..., + positional: bool = ..., + optional: bool = ..., + incrementable: bool = ..., + attr_name: Optional[str] = ..., + ) -> None: + ... + @overload + def __init__( + self, + name: Optional[str] = ..., + names: Iterable[str] = ..., + kind: Type[_T] = ..., + *, + default: _U, + help: Optional[str] = ..., + positional: bool = ..., + optional: bool = ..., + incrementable: bool = ..., + attr_name: Optional[str] = ..., + ) -> None: + ... + @overload + def __init__( + self: Argument[str, None], + name: Optional[str] = ..., + names: Iterable[str] = ..., + *, + default: _U = ..., + help: Optional[str] = ..., + positional: bool = ..., + optional: bool = ..., + incrementable: bool = ..., + attr_name: Optional[str] = ..., + ) -> None: + ... + @overload + def __init__( + self: Argument[_T, None], + name: Optional[str] = ..., + names: Iterable[str] = ..., + kind: Type[_T] = ..., + *, + default: _U = ..., + help: Optional[str] = ..., + positional: bool = ..., + optional: bool = ..., + incrementable: bool = ..., + attr_name: Optional[str] = ..., + ) -> None: + ... def __init__( self, name: Optional[str] = None, names: Iterable[str] = (), - kind: Any = str, - default: Optional[Any] = None, + kind: Type[Any] = str, + default: _U = None, # type: ignore[assignment] help: Optional[str] = None, positional: bool = False, optional: bool = False, @@ -62,14 +121,15 @@ def __init__( elif name and not names: self.names = (name,) self.kind = kind - initial_value: Optional[Any] = None + initial_value: Union[_T, _U, None] = None # Special case: list-type args start out as empty list, not None. if kind is list: - initial_value = [] + initial_value = [] # type: ignore[assignment] # Another: incrementable args start out as their default value. if incrementable: initial_value = default - self.raw_value = self._value = initial_value + self.raw_value: Union[_T, _U, str, None] = initial_value + self._value = initial_value self.default = default self.help = help self.positional = positional @@ -122,7 +182,7 @@ def takes_value(self) -> bool: return True @property - def value(self) -> Any: + def value(self) -> _T | _U: # TODO: should probably be optional instead return self._value if self._value is not None else self.default @@ -154,12 +214,12 @@ def set_value(self, value: Any, cast: bool = True) -> None: func = self.kind # If self.kind is a list, append instead of using cast func. if self.kind is list: - func = lambda x: self.value + [x] + func = lambda x: cast_(List[Any], self.value) + [x] # If incrementable, just increment. if self.incrementable: # TODO: explode nicely if self.value was not an int to start # with - func = lambda x: self.value + 1 + func = lambda x: self.value + 1 # type: ignore[operator] self._value = func(value) @property diff --git a/invoke/parser/parser.py b/invoke/parser/parser.py index 43e95df0..2f3922c7 100644 --- a/invoke/parser/parser.py +++ b/invoke/parser/parser.py @@ -1,23 +1,17 @@ import copy -from typing import TYPE_CHECKING, Any, Iterable, List, Optional +from typing import Any, Iterable, List, Optional, overload try: from ..vendor.lexicon import Lexicon from ..vendor.fluidity import StateMachine, state, transition except ImportError: from lexicon import Lexicon # type: ignore[no-redef] - from fluidity import ( # type: ignore[no-redef] - StateMachine, - state, - transition, - ) + from fluidity import StateMachine, state, transition # type: ignore[no-redef] +from .context import ParserContext from ..exceptions import ParseError from ..util import debug -if TYPE_CHECKING: - from .context import ParserContext - def is_flag(value: str) -> bool: return value.startswith("-") @@ -27,7 +21,7 @@ def is_long_flag(value: str) -> bool: return value.startswith("--") -class ParseResult(List["ParserContext"]): +class ParseResult(List[ParserContext]): """ List-like object with some extra parse-related attributes. @@ -38,8 +32,17 @@ class ParseResult(List["ParserContext"]): .. versionadded:: 1.0 """ - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) + @overload + def __init__(self) -> None: + ... + @overload + def __init__(self, iterable: Iterable[ParserContext], /): + ... + def __init__(self, iterable: Optional[Iterable[ParserContext]] = None, /): # type: ignore[misc] + if iterable is None: + super().__init__() + else: + super().__init__(iterable) self.remainder = "" self.unparsed: List[str] = [] @@ -378,7 +381,7 @@ def complete_flag(self) -> None: # Skip casting so the bool gets preserved self.flag.set_value(True, cast=False) - def check_ambiguity(self, value: Any) -> bool: + def check_ambiguity(self, value: str) -> bool: """ Guard against ambiguity when current flag takes an optional value. @@ -436,7 +439,7 @@ def switch_to_flag(self, flag: str, inverse: bool = False) -> None: debug("Marking seen flag {!r} as {}".format(self.flag, val)) self.flag.value = val - def see_value(self, value: Any) -> None: + def see_value(self, value: str) -> None: self.check_ambiguity(value) if self.flag and self.flag.takes_value: debug("Setting flag {!r} to value {!r}".format(self.flag, value)) @@ -445,7 +448,7 @@ def see_value(self, value: Any) -> None: else: self.error("Flag {!r} doesn't take any value!".format(self.flag)) - def see_positional_arg(self, value: Any) -> None: + def see_positional_arg(self, value: str) -> None: for arg in self.context.positional_args: if arg.value is None: arg.value = value diff --git a/invoke/program.py b/invoke/program.py index c7e5cd00..12241698 100644 --- a/invoke/program.py +++ b/invoke/program.py @@ -5,16 +5,7 @@ import sys import textwrap from importlib import import_module # buffalo buffalo -from typing import ( - TYPE_CHECKING, - Any, - Dict, - List, - Optional, - Sequence, - Tuple, - Type, -) +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple, Type, Union from . import Collection, Config, Executor, FilesystemLoader from .completion.complete import complete, print_completion_script @@ -190,12 +181,12 @@ def task_args(self) -> List["Argument"]: def __init__( self, version: Optional[str] = None, - namespace: Optional["Collection"] = None, + namespace: Optional[Collection] = None, name: Optional[str] = None, binary: Optional[str] = None, loader_class: Optional[Type["Loader"]] = None, - executor_class: Optional[Type["Executor"]] = None, - config_class: Optional[Type["Config"]] = None, + executor_class: Optional[Type[Executor]] = None, + config_class: Optional[Type[Config]] = None, binary_names: Optional[List[str]] = None, ) -> None: """ @@ -352,7 +343,7 @@ def update_config(self, merge: bool = True) -> None: if merge: self.config.merge() - def run(self, argv: Optional[List[str]] = None, exit: bool = True) -> None: + def run(self, argv: Union[List[str], str, None] = None, exit: bool = True) -> None: """ Execute main CLI logic, based on ``argv``. @@ -421,7 +412,7 @@ def run(self, argv: Optional[List[str]] = None, exit: bool = True) -> None: except KeyboardInterrupt: sys.exit(1) # Same behavior as Python itself outside of REPL - def parse_core(self, argv: Optional[List[str]]) -> None: + def parse_core(self, argv: Union[List[str], str, None]) -> None: debug("argv given to Program.run: {!r}".format(argv)) self.normalize_argv(argv) @@ -582,7 +573,7 @@ def execute(self) -> None: executor = klass(self.collection, self.config, self.core) executor.execute(*self.tasks) - def normalize_argv(self, argv: Optional[List[str]]) -> None: + def normalize_argv(self, argv: Union[List[str], str, None]) -> None: """ Massages ``argv`` into a useful list of strings. @@ -708,7 +699,7 @@ def load_collection(self) -> None: # NOTE: start, coll_name both fall back to configuration values within # Loader (which may, however, get them from our config.) start = self.args["search-root"].value - loader = self.loader_class( # type: ignore + loader = self.loader_class( config=self.config, start=start ) coll_name = self.args.collection.value @@ -941,9 +932,7 @@ def display_with_columns( # TODO: trim/prefix dots print("Default{} task: {}\n".format(specific, default)) - def print_columns( - self, tuples: Sequence[Tuple[str, Optional[str]]] - ) -> None: + def print_columns(self, tuples: Sequence[Tuple[str, Optional[str]]]) -> None: """ Print tabbed columns from (name, help) ``tuples``. diff --git a/invoke/runners.py b/invoke/runners.py index 423cb797..b33cccd6 100644 --- a/invoke/runners.py +++ b/invoke/runners.py @@ -13,12 +13,13 @@ Any, Callable, Dict, - Generator, IO, + Iterator, List, Optional, Tuple, Type, + Union, ) # Import some platform-specific things at top level so they can be mocked for @@ -57,6 +58,8 @@ from .context import Context from .watchers import StreamWatcher +_HandleIO = Callable[..., None] + class Runner: """ @@ -606,7 +609,7 @@ def _collate_result(self, watcher_errors: List[WatcherError]) -> "Result": ) return result - def _thread_join_timeout(self, target: Callable) -> Optional[int]: + def _thread_join_timeout(self, target: _HandleIO) -> Optional[int]: # Add a timeout to out/err thread joins when it looks like they're not # dead but their counterpart is dead; this indicates issue #351 (fixed # by #432) where the subproc may hang because its stdout (or stderr) is @@ -624,7 +627,7 @@ def _thread_join_timeout(self, target: Callable) -> Optional[int]: def create_io_threads( self, - ) -> Tuple[Dict[Callable, ExceptionHandlingThread], List[str], List[str]]: + ) -> Tuple[Dict[_HandleIO, ExceptionHandlingThread], List[str], List[str]]: """ Create and return a dictionary of IO thread worker objects. @@ -634,7 +637,7 @@ def create_io_threads( stdout: List[str] = [] stderr: List[str] = [] # Set up IO thread parameters (format - body_func: {kwargs}) - thread_args: Dict[Callable, Any] = { + thread_args: Dict[_HandleIO, Any] = { self.handle_stdout: { "buffer_": stdout, "hide": "stdout" in self.opts["hide"], @@ -676,7 +679,7 @@ def generate_result(self, **kwargs: Any) -> "Result": """ return Result(**kwargs) - def read_proc_output(self, reader: Callable) -> Generator[str, None, None]: + def read_proc_output(self, reader: Callable[[int], bytes]) -> Iterator[str]: """ Iteratively read & decode bytes from a subprocess' out/err stream. @@ -1533,7 +1536,7 @@ def ok(self) -> bool: .. versionadded:: 1.0 """ - return bool(self.exited == 0) + return self.exited == 0 @property def failed(self) -> bool: @@ -1617,7 +1620,7 @@ def join(self) -> Result: finally: self.runner.stop() - def __enter__(self) -> "Promise": + def __enter__(self) -> "Promise": # TODO(PY311): Use Self return self def __exit__( @@ -1630,7 +1633,7 @@ def __exit__( def normalize_hide( - val: Any, + val: Union[str, bool, None], out_stream: Optional[str] = None, err_stream: Optional[str] = None, ) -> Tuple[str, ...]: @@ -1648,6 +1651,7 @@ def normalize_hide( elif val == "err": hide = ["stderr"] else: + assert val is not None and not isinstance(val, bool) hide = [val] # Revert any streams that have been overridden from the default value if out_stream is not None and "stdout" in hide: diff --git a/invoke/tasks.py b/invoke/tasks.py index cd3075e9..c9a29f80 100644 --- a/invoke/tasks.py +++ b/invoke/tasks.py @@ -4,6 +4,7 @@ """ import inspect +import sys import types from copy import deepcopy from functools import update_wrapper @@ -12,6 +13,7 @@ Any, Callable, Dict, + Generic, List, Generic, Iterable, @@ -21,6 +23,7 @@ Type, TypeVar, Union, + overload ) from .context import Context @@ -30,10 +33,10 @@ from inspect import Signature from .config import Config -T = TypeVar("T", bound=Callable) +_T = TypeVar("_T") -class Task(Generic[T]): +class Task(Generic[_T]): """ Core object representing an executable task & its argument specification. @@ -58,14 +61,14 @@ class Task(Generic[T]): # except a debug shell whose frame is exactly inside this class. def __init__( self, - body: Callable, + body: Callable[..., _T], name: Optional[str] = None, aliases: Iterable[str] = (), positional: Optional[Iterable[str]] = None, optional: Iterable[str] = (), default: bool = False, auto_shortflags: bool = True, - help: Optional[Dict[str, Any]] = None, + help: Optional[Dict[str, str]] = None, pre: Optional[Union[List[str], str]] = None, post: Optional[Union[List[str], str]] = None, autoprint: bool = False, @@ -129,7 +132,8 @@ def __hash__(self) -> int: # this for now. return hash(self.name) + hash(self.body) - def __call__(self, *args: Any, **kwargs: Any) -> T: + # TODO(PY310): Use ParamSpec here and in Generic above. + def __call__(self, *args: Any, **kwargs: Any) -> _T: # Guard against calling tasks with no context. if not isinstance(args[0], Context): err = "Task expected a Context as its first arg, got {} instead!" @@ -162,7 +166,7 @@ def argspec(self, body: Callable) -> "Signature": func = ( body if isinstance(body, types.FunctionType) - else body.__call__ # type: ignore + else body.__call__ # type: ignore[operator] ) # Rebuild signature with first arg dropped, or die usefully(ish trying sig = inspect.signature(func) @@ -188,7 +192,7 @@ def fill_implicit_positionals( return positional def arg_opts( - self, name: str, default: str, taken_names: Set[str] + self, name: str, default: object, taken_names: Set[str] ) -> Dict[str, Any]: opts: Dict[str, Any] = {} # Whether it's positional or not @@ -286,7 +290,13 @@ def get_arguments( return args -def task(*args: Any, **kwargs: Any) -> Callable: +@overload +def task(arg1: Callable[..., _T], /) -> Task[_T]: + ... +@overload +def task(arg1: Any = ..., *args: Any, **kwargs: Any) -> Callable[[Callable[..., _T]], Task[_T]]: + ... +def task(*args: Any, **kwargs: Any) -> Any: """ Marks wrapped callable object as a valid Invoke task. @@ -352,7 +362,7 @@ def task(*args: Any, **kwargs: Any) -> Callable: ) kwargs["pre"] = args - def inner(body: Callable) -> Task[T]: + def inner(body: Callable[..., _T]) -> Task[_T]: _task = klass(body, **kwargs) return _task @@ -360,7 +370,7 @@ def inner(body: Callable) -> Task[T]: return inner -class Call: +class Call(Generic[_T]): """ Represents a call/execution of a `.Task` with given (kw)args. @@ -373,9 +383,9 @@ class Call: def __init__( self, - task: "Task", + task: Task[_T], called_as: Optional[str] = None, - args: Optional[Tuple[str, ...]] = None, + args: Optional[Tuple[Any, ...]] = None, kwargs: Optional[Dict[str, Any]] = None, ) -> None: """ @@ -403,7 +413,7 @@ def __init__( def __getattr__(self, name: str) -> Any: return getattr(self.task, name) - def __deepcopy__(self, memo: object) -> "Call": + def __deepcopy__(self, memo: object) -> "Call[_T]": return self.clone() def __repr__(self) -> str: @@ -453,7 +463,7 @@ def clone( self, into: Optional[Type["Call"]] = None, with_: Optional[Dict[str, Any]] = None, - ) -> "Call": + ) -> "Call[_T]": """ Return a standalone copy of this Call. @@ -483,7 +493,7 @@ def clone( return klass(**data) -def call(task: "Task", *args: Any, **kwargs: Any) -> "Call": +def call(task: Task[_T], *args: Any, **kwargs: Any) -> Call[_T]: """ Describes execution of a `.Task`, typically with pre-supplied arguments. diff --git a/invoke/terminals.py b/invoke/terminals.py index 2694712f..37435989 100644 --- a/invoke/terminals.py +++ b/invoke/terminals.py @@ -8,7 +8,7 @@ """ from contextlib import contextmanager -from typing import Generator, IO, Optional, Tuple +from typing import IO, Iterator, Optional, Tuple import os import select import sys @@ -28,7 +28,7 @@ .. versionadded:: 1.0 """ -if sys.platform == "win32": +if sys.platform == "win32": # https://github.com/python/mypy/issues/14604 import msvcrt from ctypes import ( Structure, @@ -174,9 +174,7 @@ def cbreak_already_set(stream: IO) -> bool: @contextmanager -def character_buffered( - stream: IO, -) -> Generator[None, None, None]: +def character_buffered(stream: IO) -> Iterator[None]: """ Force local terminal ``stream`` be character, not line, buffered. @@ -243,5 +241,5 @@ def bytes_to_read(input_: IO) -> int: # going to work re: ioctl(). if not WINDOWS and isatty(input_) and has_fileno(input_): fionread = fcntl.ioctl(input_, termios.FIONREAD, b" ") - return int(struct.unpack("h", fionread)[0]) + return struct.unpack("h", fionread)[0] return 1 diff --git a/invoke/util.py b/invoke/util.py index df29c841..1d6cb99b 100644 --- a/invoke/util.py +++ b/invoke/util.py @@ -1,7 +1,7 @@ from collections import namedtuple from contextlib import contextmanager from types import TracebackType -from typing import Any, Generator, List, IO, Optional, Tuple, Type, Union +from typing import Any, Iterator, List, IO, Optional, Tuple, Type, Union import io import logging import os @@ -61,7 +61,7 @@ def task_name_sort_key(name: str) -> Tuple[List[str], str]: # TODO: Make part of public API sometime @contextmanager -def cd(where: str) -> Generator[None, None, None]: +def cd(where: str) -> Iterator[None]: cwd = os.getcwd() os.chdir(where) try: @@ -94,7 +94,7 @@ def has_fileno(stream: IO) -> bool: return False -def isatty(stream: IO) -> Union[bool, Any]: +def isatty(stream: IO) -> bool: """ Cleanly determine whether ``stream`` is a TTY. @@ -162,6 +162,7 @@ class ExceptionHandlingThread(threading.Thread): .. versionadded:: 1.0 """ + # TODO(PY312): https://peps.python.org/pep-0692/ def __init__(self, **kwargs: Any) -> None: """ Create a new exception-handling thread instance. @@ -191,7 +192,7 @@ def run(self) -> None: # doesn't appear to be the case, then assume we're being used # directly and just use super() ourselves. # XXX https://github.com/python/mypy/issues/1424 - if hasattr(self, "_run") and callable(self._run): # type: ignore + if hasattr(self, "_run") and callable(self._run): # TODO: this could be: # - io worker with no 'result' (always local) # - tunnel worker, also with no 'result' (also always local) @@ -206,7 +207,7 @@ def run(self) -> None: # and let it continue acting like a normal thread (meh) # - assume the run/sudo/etc case will use a queue inside its # worker body, orthogonal to how exception handling works - self._run() # type: ignore + self._run() else: super().run() except BaseException: @@ -253,7 +254,7 @@ def is_dead(self) -> bool: def __repr__(self) -> str: # TODO: beef this up more - return str(self.kwargs["target"].__name__) + return self.kwargs["target"].__name__ # NOTE: ExceptionWrapper defined here, not in exceptions.py, to avoid circular diff --git a/invoke/watchers.py b/invoke/watchers.py index eb813df2..f1b0f2bb 100644 --- a/invoke/watchers.py +++ b/invoke/watchers.py @@ -1,6 +1,7 @@ import re +import sys import threading -from typing import Generator, Iterable +from typing import Iterable, Iterator from .exceptions import ResponseNotAccepted @@ -104,7 +105,7 @@ def pattern_matches( setattr(self, index_attr, index + len(new)) return matches - def submit(self, stream: str) -> Generator[str, None, None]: + def submit(self, stream: str) -> Iterator[str]: # Iterate over findall() response in case >1 match occurred. for _ in self.pattern_matches(stream, self.pattern, "index"): yield self.response @@ -127,7 +128,7 @@ def __init__(self, pattern: str, response: str, sentinel: str) -> None: self.failure_index = 0 self.tried = False - def submit(self, stream: str) -> Generator[str, None, None]: + def submit(self, stream: str) -> Iterator[str]: # Behave like regular Responder initially response = super().submit(stream) # Also check stream for our failure sentinel diff --git a/pyproject.toml b/pyproject.toml index 8f10b685..fdf8129a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,30 +1,30 @@ [tool.mypy] -# check_untyped_defs = true -# follow_imports_for_stubs = true +files = ["invoke", "integration", "sites"] +check_untyped_defs = true +follow_imports_for_stubs = true # disallow_any_decorated = true # disallow_any_generics = true -# disallow_any_unimported = true -# disallow_incomplete_defs = true -# disallow_subclassing_any = true +disallow_any_unimported = true +disallow_incomplete_defs = true +disallow_subclassing_any = true # disallow_untyped_calls = true # disallow_untyped_decorators = true disallow_untyped_defs = true -# enable_error_code = [ -# "redundant-expr", -# "truthy-bool", -# "ignore-without-code", -# "unused-awaitable", -# -exclude = [ - "integration/", "tests/", "setup.py", "sites/www/conf.py", "build/", +enable_error_code = [ + "redundant-expr", + "redundant-self", +# "truthy-bool", +# "truthy-iterable", + "ignore-without-code", +# "possibly-undefined", + "unused-awaitable", ] -ignore_missing_imports = true -# implicit_reexport = False -# no_implicit_optional = true -# pretty = true -# show_column_numbers = true -# show_error_codes = true -# strict_equality = true +# implicit_reexport = false +no_implicit_optional = true +pretty = true +show_column_numbers = true +show_error_codes = true +strict_equality = true warn_incomplete_stub = true warn_redundant_casts = true # warn_return_any = true @@ -35,10 +35,19 @@ warn_unused_ignores = true module = "invoke.vendor.*" ignore_errors = true +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false + + [[tool.mypy.overrides]] module = "alabaster" ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "fluidity" +ignore_missing_imports = true + [[tool.mypy.overrides]] module = "icecream" ignore_missing_imports = true @@ -47,6 +56,10 @@ ignore_missing_imports = true module = "invocations" ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "lexicon" +ignore_missing_imports = true + [[tool.mypy.overrides]] module = "pytest_relaxed" ignore_missing_imports = true diff --git a/sites/docs/__init__.py b/sites/docs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sites/www/__init__.py b/sites/www/__init__.py new file mode 100644 index 00000000..e69de29b