invoke-toolkit 0.0.63__tar.gz → 0.0.65b1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/.gitignore +1 -0
  2. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/PKG-INFO +1 -1
  3. invoke_toolkit-0.0.65b1/src/invoke_toolkit/context/async_tools.py +198 -0
  4. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/context/context.py +20 -5
  5. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/context/types.py +7 -1
  6. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/executor.py +42 -81
  7. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/program/program.py +122 -25
  8. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/tasks/cache.py +34 -9
  9. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/tasks/tasks.py +125 -139
  10. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/script/test_script.py +5 -4
  11. invoke_toolkit-0.0.65b1/tests/test_alias_self_reference.py +108 -0
  12. invoke_toolkit-0.0.65b1/tests/test_async_edge_cases.py +44 -0
  13. invoke_toolkit-0.0.65b1/tests/test_async_support.py +88 -0
  14. invoke_toolkit-0.0.65b1/tests/test_list_filter_flags.py +208 -0
  15. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_proctitle.py +16 -8
  16. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/LICENSE.txt +0 -0
  17. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/README.md +0 -0
  18. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/pyproject.toml +0 -0
  19. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/__init__.py +0 -0
  20. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/__main__.py +0 -0
  21. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/collections.py +0 -0
  22. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/completion.py +0 -0
  23. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/config/__init__.py +0 -0
  24. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/config/config.py +0 -0
  25. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/config/registry.py +0 -0
  26. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/config/schema.py +0 -0
  27. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/config/status_helper.py +0 -0
  28. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/context/__init__.py +0 -0
  29. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/extensions/__init__.py +0 -0
  30. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/extensions/tasks/__init__.py +0 -0
  31. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/extensions/tasks/config.py +0 -0
  32. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/extensions/tasks/create.py +0 -0
  33. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/extensions/tasks/dist.py +0 -0
  34. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/extensions/tasks/shell.py +0 -0
  35. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/loader/entrypoint.py +0 -0
  36. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/log/__init__.py +0 -0
  37. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/log/logger.py +0 -0
  38. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/output/__init__.py +0 -0
  39. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/output/console.py +0 -0
  40. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/output/utils.py +0 -0
  41. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/parser.py +0 -0
  42. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/program/__init__.py +0 -0
  43. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/program/main.py +0 -0
  44. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/py.typed +0 -0
  45. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/runners/__init__.py +0 -0
  46. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/runners/rich.py +0 -0
  47. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/scripts/__init__.py +0 -0
  48. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/scripts/loader.py +0 -0
  49. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/tasks/__init__.py +0 -0
  50. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/tasks/autocomplete.py +0 -0
  51. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/tasks/types.py +0 -0
  52. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/testing.py +0 -0
  53. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/utils/__init__.py +0 -0
  54. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/utils/fzf.py +0 -0
  55. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/utils/inspection.py +0 -0
  56. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/utils/singleton.py +0 -0
  57. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/src/invoke_toolkit/utils/text.py +0 -0
  58. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/templates/package-template/.gitignore.jinja +0 -0
  59. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/templates/package-template/README.md.jinja +0 -0
  60. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/templates/package-template/copier.yml +0 -0
  61. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/templates/package-template/pyproject.toml.jinja +0 -0
  62. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/templates/package-template/src/{{package_slug}}/__init__.py.jinja +0 -0
  63. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/templates/package-template/src/{{package_slug}}/tasks.py.jinja +0 -0
  64. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/__init__.py +0 -0
  65. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/conftest.py +0 -0
  66. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/examples/cached_completion/tasks.py +0 -0
  67. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/examples/completion_with_config/.gitignore +0 -0
  68. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/examples/completion_with_config/tasks.py +0 -0
  69. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/examples/config_schema/tasks.py +0 -0
  70. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/examples/enum_select_size/tasks.py +0 -0
  71. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/examples/fzf_selector/tasks.py +0 -0
  72. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/examples/literal_set_level/tasks.py +0 -0
  73. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/extensions/conftest.py +0 -0
  74. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/extensions/test_config_tasks.py +0 -0
  75. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/extensions/test_create.py +0 -0
  76. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/extensions/test_package_template_entrypoint.py +0 -0
  77. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/extensions/test_shell_tasks.py +0 -0
  78. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/__init__.py +0 -0
  79. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/all-four/invoke.json +0 -0
  80. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/all-four/invoke.py +0 -0
  81. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/all-four/invoke.yml +0 -0
  82. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/collection.py +0 -0
  83. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/echo.yaml +0 -0
  84. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/json/invoke.json +0 -0
  85. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/json-and-python/invoke.json +0 -0
  86. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/json-and-python/invoke.py +0 -0
  87. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/no-dedupe.yaml +0 -0
  88. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/no-echo.yaml +0 -0
  89. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/package/invoke.yml +0 -0
  90. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/package/tasks/__init__.py +0 -0
  91. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/python/invoke.py +0 -0
  92. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/runtime.py +0 -0
  93. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/three-of-em/invoke.json +0 -0
  94. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/three-of-em/invoke.py +0 -0
  95. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/three-of-em/invoke.yml +0 -0
  96. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/underscores/tasks.py +0 -0
  97. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/yaml/explicit.py +0 -0
  98. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/yaml/tasks.py +0 -0
  99. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/yml/explicit.py +0 -0
  100. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/yml/invoke.yml +0 -0
  101. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/configs/yml/tasks.py +0 -0
  102. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/_support/has_modules.py +0 -0
  103. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/conftest.py +0 -0
  104. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/original_invoke/test_config.py +0 -0
  105. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/program/main.py +0 -0
  106. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/program/tasks/__init__.py +0 -0
  107. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/program/tasks/coll1.py +0 -0
  108. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/tasks/test_cache.py +0 -0
  109. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/tasks/test_extensions_config.py +0 -0
  110. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_annotated_help.py +0 -0
  111. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_cofig_class.py +0 -0
  112. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_collection.py +0 -0
  113. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_collection_configure.py +0 -0
  114. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_completion_with_choices.py +0 -0
  115. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_config_helper.py +0 -0
  116. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_config_registry.py +0 -0
  117. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_config_schema.py +0 -0
  118. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_console.py +0 -0
  119. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_context_class.py +0 -0
  120. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_disable_status_cli.py +0 -0
  121. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_enum_arguments.py +0 -0
  122. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_executor.py +0 -0
  123. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_file_completion.py +0 -0
  124. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_global_context.py +0 -0
  125. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_help_flags.py +0 -0
  126. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_invoke_3_compat.py +0 -0
  127. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_invoke_compatibility.py +0 -0
  128. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_loader.py +0 -0
  129. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_parsing.py +0 -0
  130. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_program_with_collection.py +0 -0
  131. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/test_toplevel.py +0 -0
  132. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.65b1}/tests/utils/test_fzf.py +0 -0
@@ -18,3 +18,4 @@ invoke.yaml
18
18
  # Makrdowns
19
19
  *.md
20
20
  !docs/**/*.md
21
+ .worktrees/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: invoke-toolkit
3
- Version: 0.0.63
3
+ Version: 0.0.65b1
4
4
  Summary: Extended APIs for PyInvoke: composable task plugins, typed CLI args, Rich output, shell completion, and PEP 723 script support
5
5
  Project-URL: Documentation, https://github.com/D3f0/invoke-toolkit#readme
6
6
  Project-URL: Issues, https://github.com/D3f0/invoke-toolkit/issues
@@ -0,0 +1,198 @@
1
+ """Async execution helpers for :class:`ToolkitContext`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import asyncio.subprocess as async_subprocess
7
+ import contextvars
8
+ import os
9
+ import sys
10
+ from contextlib import contextmanager
11
+ from typing import Any, Awaitable, Iterator
12
+
13
+ from invoke.exceptions import CommandTimedOut, UnexpectedExit
14
+ from invoke.runners import Result, default_encoding, normalize_hide
15
+
16
+
17
+ _async_task_context: contextvars.ContextVar[bool] = contextvars.ContextVar(
18
+ "invoke_toolkit_async_task_context", default=False
19
+ )
20
+
21
+
22
+ @contextmanager
23
+ def async_task_context() -> Iterator[None]:
24
+ """Mark synchronous context methods called from an async task."""
25
+ token = _async_task_context.set(True)
26
+ try:
27
+ yield
28
+ finally:
29
+ _async_task_context.reset(token)
30
+
31
+
32
+ def in_async_task_context() -> bool:
33
+ """Return whether the current execution is an invoke-toolkit async task."""
34
+ return _async_task_context.get()
35
+
36
+
37
+ class AsyncGatherScope:
38
+ """Schedule awaitables and await them together when the scope exits.
39
+
40
+ Submitted awaitables start immediately. ``results`` contains their return
41
+ values in submission order after a successful scope exit.
42
+ """
43
+
44
+ def __init__(self) -> None:
45
+ self._tasks: list[asyncio.Future[Any]] = []
46
+ self.results: tuple[Any, ...] = ()
47
+
48
+ async def __aenter__(self) -> "AsyncGatherScope":
49
+ return self
50
+
51
+ def __call__(self, awaitable: Awaitable[Any]) -> asyncio.Future[Any]:
52
+ """Schedule *awaitable* and return its task handle."""
53
+ task = asyncio.ensure_future(awaitable)
54
+ self._tasks.append(task)
55
+ return task
56
+
57
+ async def __aexit__(self, exc_type, exc_value, traceback) -> bool:
58
+ if exc_type is not None:
59
+ await self._cancel_remaining()
60
+ return False
61
+
62
+ try:
63
+ self.results = tuple(await asyncio.gather(*self._tasks))
64
+ except BaseException:
65
+ await self._cancel_remaining()
66
+ raise
67
+ return False
68
+
69
+ async def _cancel_remaining(self) -> None:
70
+ pending = [task for task in self._tasks if not task.done()]
71
+ for task in pending:
72
+ task.cancel()
73
+ if pending:
74
+ await asyncio.gather(*pending, return_exceptions=True)
75
+
76
+
77
+ async def run_async_command( # pylint: disable=too-many-branches,too-many-locals
78
+ context: Any, command: str, **kwargs: Any
79
+ ) -> Result:
80
+ """Run a shell command without blocking the current event loop."""
81
+ options = dict(context.config.run.items())
82
+ for key, value in kwargs.items():
83
+ if key not in options and key != "timeout":
84
+ raise TypeError(f"run_async() got an unexpected keyword argument '{key}'")
85
+ options[key] = value
86
+
87
+ timeout = options.pop("timeout", context.config.timeouts.command)
88
+ if options.get("asynchronous") or options.get("disown"):
89
+ raise ValueError("run_async() cannot use asynchronous or disown")
90
+ if options.get("pty"):
91
+ raise ValueError("run_async() does not support pty=True")
92
+ if options.get("watchers"):
93
+ raise ValueError("run_async() does not support stream watchers")
94
+ if options.get("echo_stdin"):
95
+ raise ValueError("run_async() does not support echo_stdin")
96
+ if options.get("in_stream") not in (None, False):
97
+ raise ValueError("run_async() only supports disabled stdin")
98
+
99
+ command = context._prefix_commands(command) # pylint: disable=protected-access
100
+ shell = options.get("shell") or "bash"
101
+ encoding = options.get("encoding") or default_encoding()
102
+ hide = normalize_hide(
103
+ options.get("hide"), options.get("out_stream"), options.get("err_stream")
104
+ )
105
+ env_values = options.get("env") or {}
106
+ env = (
107
+ dict(env_values)
108
+ if options.get("replace_env")
109
+ else dict(os.environ, **env_values)
110
+ )
111
+
112
+ if options.get("echo"):
113
+ print(options.get("echo_format", "{command}").format(command=command))
114
+ if options.get("dry"):
115
+ return Result(
116
+ command=command,
117
+ shell=shell,
118
+ env=env,
119
+ encoding=encoding,
120
+ exited=0,
121
+ hide=hide,
122
+ )
123
+
124
+ process = await asyncio.create_subprocess_shell(
125
+ command,
126
+ executable=shell,
127
+ stdout=async_subprocess.PIPE,
128
+ stderr=async_subprocess.PIPE,
129
+ stdin=async_subprocess.DEVNULL,
130
+ env=env,
131
+ )
132
+ try:
133
+ communicate = process.communicate()
134
+ if timeout is None:
135
+ stdout, stderr = await communicate
136
+ else:
137
+ stdout, stderr = await asyncio.wait_for(communicate, timeout=timeout)
138
+ except asyncio.TimeoutError as exc:
139
+ await _terminate_process(process)
140
+ result = _result(command, shell, env, encoding, process, b"", b"", hide)
141
+ raise CommandTimedOut(result, timeout=timeout) from exc
142
+ except asyncio.CancelledError:
143
+ await _terminate_process(process)
144
+ raise
145
+
146
+ result = _result(command, shell, env, encoding, process, stdout, stderr, hide)
147
+ _write_streams(result, options)
148
+ if not result.ok and not options.get("warn"):
149
+ raise UnexpectedExit(result)
150
+ return result
151
+
152
+
153
+ async def _terminate_process(process: async_subprocess.Process) -> None:
154
+ if process.returncode is not None:
155
+ return
156
+ process.terminate()
157
+ try:
158
+ await asyncio.wait_for(process.wait(), timeout=1)
159
+ except asyncio.TimeoutError:
160
+ process.kill()
161
+ await process.wait()
162
+
163
+
164
+ def _result(
165
+ command: str,
166
+ shell: str,
167
+ env: dict[str, str],
168
+ encoding: str,
169
+ process: async_subprocess.Process,
170
+ stdout: bytes,
171
+ stderr: bytes,
172
+ hide: tuple[str, ...],
173
+ ) -> Result:
174
+ exited = process.returncode
175
+ if exited is None:
176
+ raise RuntimeError("Process completed without an exit code")
177
+ return Result(
178
+ stdout=stdout.decode(encoding, errors="replace"),
179
+ stderr=stderr.decode(encoding, errors="replace"),
180
+ command=command,
181
+ shell=shell,
182
+ env=env,
183
+ encoding=encoding,
184
+ exited=exited,
185
+ hide=hide,
186
+ pid=process.pid,
187
+ )
188
+
189
+
190
+ def _write_streams(result: Result, options: dict[str, Any]) -> None:
191
+ out_stream = options.get("out_stream") or sys.stdout
192
+ err_stream = options.get("err_stream") or sys.stderr
193
+ if "out" not in result.hide and out_stream is not False:
194
+ out_stream.write(result.stdout)
195
+ out_stream.flush()
196
+ if "err" not in result.hide and err_stream is not False:
197
+ err_stream.write(result.stderr)
198
+ err_stream.flush()
@@ -29,7 +29,8 @@ from invoke_toolkit.config import ToolkitConfig
29
29
  from invoke_toolkit.config.status_helper import StatusHelper
30
30
  from invoke_toolkit.output.console import get_console
31
31
 
32
- from .types import BoundPrintProtocol, ContextRunProtocol
32
+ from .types import AsyncContextRunProtocol, BoundPrintProtocol, ContextRunProtocol
33
+ from .async_tools import AsyncGatherScope, in_async_task_context, run_async_command
33
34
 
34
35
  if TYPE_CHECKING:
35
36
  from rich.console import Console
@@ -58,10 +59,10 @@ class ConfigProtocol(Protocol):
58
59
 
59
60
 
60
61
  class ToolkitContext(Context, ConfigProtocol):
61
- """Type annotated override"""
62
+ """Type annotated override with async task support."""
62
63
 
63
64
  run: ContextRunProtocol
64
- _console: "Console"
65
+ run_async: AsyncContextRunProtocol
65
66
  _config: ToolkitConfig
66
67
  _status_helper: StatusHelper
67
68
 
@@ -93,8 +94,22 @@ class ToolkitContext(Context, ConfigProtocol):
93
94
  @property
94
95
  def console(self) -> "Console":
95
96
  """A console instance to do rich output"""
96
- console = get_console()
97
- return console
97
+ return get_console()
98
+
99
+ def run(self, command: str, **kwargs: Any):
100
+ """Run a command synchronously, or return a coroutine in async tasks."""
101
+ if in_async_task_context() and not kwargs.get("asynchronous", False):
102
+ return self.run_async(command, **kwargs)
103
+ runner = self.config.runners.local(self)
104
+ return self._run(runner, command, **kwargs)
105
+
106
+ async def run_async(self, command: str, **kwargs: Any):
107
+ """Run a command without blocking the current asyncio event loop."""
108
+ return await run_async_command(self, command, **kwargs)
109
+
110
+ def gather(self) -> AsyncGatherScope:
111
+ """Return an async context manager for explicit concurrent work."""
112
+ return AsyncGatherScope()
98
113
 
99
114
  # @contextmanager
100
115
  @property
@@ -3,7 +3,7 @@ Type annotations for finding out what's there in ctx.attribute
3
3
  """
4
4
 
5
5
  import sys
6
- from typing import IO, Any, Optional, Union
6
+ from typing import IO, Any, Awaitable, Optional, Union
7
7
 
8
8
  from invoke.runners import Result
9
9
  from invoke.watchers import StreamWatcher
@@ -11,6 +11,12 @@ from rich.console import JustifyMethod, OverflowMethod, Style
11
11
  from typing_extensions import Annotated, Protocol
12
12
 
13
13
 
14
+ class AsyncContextRunProtocol(Protocol):
15
+ """Coroutine API for native asyncio command execution."""
16
+
17
+ def __call__(self, command: str, **kwargs: Any) -> Awaitable[Result]: ...
18
+
19
+
14
20
  class BoundPrintProtocol(Protocol):
15
21
  def __call__(
16
22
  self,
@@ -2,6 +2,7 @@
2
2
  Custom executor class to for Syntax highlighted output
3
3
  """
4
4
 
5
+ import inspect
5
6
  from pathlib import Path
6
7
  from typing import Any, Dict, List, Optional, Tuple, Union
7
8
 
@@ -13,6 +14,7 @@ from invoke.util import debug
13
14
 
14
15
  from invoke_toolkit.collections import ToolkitCollection
15
16
  from invoke_toolkit.config import ToolkitConfig
17
+ from invoke_toolkit.context.async_tools import async_task_context
16
18
  from invoke_toolkit.output import get_console
17
19
  from invoke_toolkit.tasks.tasks import ToolkitCall, ToolkitTask
18
20
 
@@ -50,110 +52,69 @@ class ToolkitExecutor(Executor):
50
52
  self.config = config if config is not None else ToolkitConfig()
51
53
  self.core = core if core is not None else ParseResult()
52
54
 
55
+ async def execute_async(
56
+ self, *tasks: Union[str, Tuple[str, Dict[str, Any]], ParserContext]
57
+ ) -> Dict["ToolkitTask", Any]:
58
+ """Execute task calls in order, awaiting coroutine task results."""
59
+ calls = self.normalize(tasks)
60
+ direct = list(calls)
61
+ expanded = self.expand_calls(calls)
62
+ try:
63
+ dedupe = self.config.tasks.dedupe
64
+ except AttributeError:
65
+ dedupe = True
66
+ calls = self.dedupe(expanded) if dedupe else expanded
67
+ results: Dict["ToolkitTask", Any] = {}
68
+ for call in calls:
69
+ autoprint = call in direct and call.autoprint
70
+ config = self.config
71
+ collection_config = self.collection.configuration(call.called_as)
72
+ config.load_collection(collection_config)
73
+ config.load_shell_env()
74
+ context = call.make_context(config, core_parse_result=self.core)
75
+ args = (context, *call.args)
76
+ with async_task_context():
77
+ result = call.task(*args, **call.kwargs)
78
+ if inspect.isawaitable(result):
79
+ result = await result
80
+ if autoprint:
81
+ if isinstance(result, (str, Path)):
82
+ print(result)
83
+ else:
84
+ get_console("out").print(result)
85
+ results[call.task] = result # ty: ignore[invalid-assignment]
86
+ return results
87
+
53
88
  def execute(
54
89
  self, *tasks: Union[str, Tuple[str, Dict[str, Any]], ParserContext]
55
90
  ) -> Dict["ToolkitTask", "Result"]:
56
- """
57
- Execute one or more ``tasks`` in sequence.
58
-
59
- :param tasks:
60
- An all-purpose iterable of "tasks to execute", each member of which
61
- may take one of the following forms:
62
-
63
- **A string** naming a task from the Executor's `.Collection`. This
64
- name may contain dotted syntax appropriate for calling namespaced
65
- tasks, e.g. ``subcollection.taskname``. Such tasks are executed
66
- without arguments.
67
-
68
- **A two-tuple** whose first element is a task name string (as
69
- above) and whose second element is a dict suitable for use as
70
- ``**kwargs`` when calling the named task. E.g.::
71
-
72
- [
73
- ('task1', {}),
74
- ('task2', {'arg1': 'val1'}),
75
- ...
76
- ]
77
-
78
- is equivalent, roughly, to::
79
-
80
- task1()
81
- task2(arg1='val1')
82
-
83
- **A `.ParserContext`** instance, whose ``.name`` attribute is used
84
- as the task name and whose ``.as_kwargs`` attribute is used as the
85
- task kwargs (again following the above specifications).
86
-
87
- .. note::
88
- When called without any arguments at all (i.e. when ``*tasks``
89
- is empty), the default task from ``self.collection`` is used
90
- instead, if defined.
91
-
92
- :returns:
93
- A dict mapping task objects to their return values.
94
-
95
- This dict may include pre- and post-tasks if any were executed. For
96
- example, in a collection with a ``build`` task depending on another
97
- task named ``setup``, executing ``build`` will result in a dict
98
- with two keys, one for ``build`` and one for ``setup``.
99
-
100
- .. versionadded:: 1.0
101
- """
91
+ """Execute one or more tasks in sequence."""
102
92
  # Normalize input
103
- debug("Examining top level tasks {!r}".format(list(tasks))) # pylint: disable=W1202
93
+ debug("Examining top level tasks {!r}".format(list(tasks)))
104
94
  calls = self.normalize(tasks)
105
- debug("Tasks (now Calls) with kwargs: {!r}".format(calls)) # pylint: disable=W1202
106
- # Obtain copy of directly-given tasks since they should sometimes
107
- # behave differently
108
95
  direct = list(calls)
109
- # Expand pre/post tasks
110
- # TODO: may make sense to bundle expansion & deduping now eh?
111
96
  expanded = self.expand_calls(calls)
112
- # Get some good value for dedupe option, even if config doesn't have
113
- # the tree we expect. (This is a concession to testing.)
114
97
  try:
115
98
  dedupe = self.config.tasks.dedupe
116
99
  except AttributeError:
117
100
  dedupe = True
118
- # Dedupe across entire run now that we know about all calls in order
119
101
  calls = self.dedupe(expanded) if dedupe else expanded
120
- # Execute
121
102
  results = {}
122
- # TODO: maybe clone initial config here? Probably not necessary,
123
- # especially given Executor is not designed to execute() >1 time at the
124
- # moment...
125
103
  for call in calls:
126
104
  autoprint = call in direct and call.autoprint
127
- debug("Executing {!r}".format(call)) # pylint: disable=W1202
128
- # Hand in reference to our config, which will preserve user
129
- # modifications across the lifetime of the session.
130
105
  config = self.config
131
- # But make sure we reset its task-sensitive levels each time
132
- # (collection & shell env)
133
- # TODO: load_collection needs to be skipped if task is anonymous
134
- # (Fabric 2 or other subclassing libs only)
135
- collection_config = self.collection.configuration(call.called_as) # type: ignore[attr-defined]
106
+ collection_config = self.collection.configuration(call.called_as)
136
107
  config.load_collection(collection_config)
137
108
  config.load_shell_env()
138
- debug("Finished loading collection & shell env configs")
139
- # Get final context from the Call (which will know how to generate
140
- # an appropriate one; e.g. subclasses might use extra data from
141
- # being parameterized), handing in this config for use there.
142
- context = call.make_context(config, core_parse_result=self.core) # type: ignore[attr-defined]
143
- args = (context, *call.args) # type: ignore[attr-defined]
144
- result = call.task(*args, **call.kwargs) # type: ignore[attr-defined]
109
+ context = call.make_context(config, core_parse_result=self.core)
110
+ args = (context, *call.args)
111
+ result = call.task(*args, **call.kwargs)
145
112
  if autoprint:
146
- # NOTE: Long strings will get wrapped when using autoprint in a console
147
- # we will use print for strings, for the case of piping output
148
- # and any non string type will be formatted by the console
149
113
  if isinstance(result, (str, Path)):
150
114
  print(result)
151
115
  else:
152
116
  get_console("out").print(result)
153
- # print(result)
154
- # TODO: handle the non-dedupe case / the same-task-different-args
155
- # case, wherein one task obj maps to >1 result.
156
- results[call.task] = result # type: ignore[attr-defined]
117
+ results[call.task] = result
157
118
  return results
158
119
 
159
120
  def normalize(
@@ -7,15 +7,16 @@ It allows three classes to be parametrized: Loader, Config and Executor
7
7
  __all__ = ["ToolkitProgram"]
8
8
 
9
9
  import ast
10
+ import asyncio
10
11
  import inspect
11
12
  import os
12
13
  import re
13
14
  import sys
14
- from importlib import metadata
15
+ from importlib import import_module, metadata
15
16
  from importlib.util import module_from_spec
16
17
  from logging import getLogger
17
18
  from pathlib import Path
18
- from typing import Dict, List, Optional, Sequence, Tuple, Union
19
+ from typing import Dict, List, Literal, Optional, Sequence, Tuple, Union
19
20
 
20
21
  from rich.table import Table
21
22
 
@@ -81,6 +82,9 @@ class ToolkitProgram(Program):
81
82
  config_class,
82
83
  binary_names,
83
84
  )
85
+ # Names of sub-collections that were loaded from entry-point plugins.
86
+ # Populated in load_collection(); used by --list-tasks / --list-plugins.
87
+ self._plugin_collection_names: set[str] = set()
84
88
 
85
89
  def get_version(self) -> str:
86
90
  """Compute version
@@ -126,13 +130,12 @@ class ToolkitProgram(Program):
126
130
  self.parse_tasks()
127
131
  # End of parsing (typically bailout stuff like --list, --help)
128
132
  self.parse_cleanup()
129
- # Update the earlier Config with new values from the parse step -
130
- # runtime config file contents and flag-derived overrides (e.g. for
131
- # run()'s echo, warn, etc options.)
133
+ # Apply runtime config and CLI-derived overrides before execution.
132
134
  self.update_config()
133
- # Create an Executor, passing in the data resulting from the prior
134
- # steps, then tell it to execute the tasks.
135
- self.execute()
135
+ if self._contains_async_task():
136
+ asyncio.run(self._execute_async())
137
+ else:
138
+ self.execute()
136
139
  except (UnexpectedExit, Exit, ParseError) as e:
137
140
  debug("Received a possibly-skippable exception: {!r}".format(e))
138
141
  # Print error messages from parser, runner, etc if necessary;
@@ -159,6 +162,31 @@ class ToolkitProgram(Program):
159
162
  except KeyboardInterrupt:
160
163
  sys.exit(1) # Same behavior as Python itself outside of REPL
161
164
 
165
+ def _contains_async_task(self) -> bool:
166
+ """Return whether the requested task graph contains coroutine work."""
167
+ for parser_context in self.tasks:
168
+ task = self.collection[parser_context.name]
169
+ calls = [task, *task.pre, *task.post]
170
+ if any(inspect.iscoroutinefunction(call.body) for call in calls):
171
+ return True
172
+ default = self.collection.default
173
+ return bool(
174
+ default and inspect.iscoroutinefunction(self.collection[default].body)
175
+ )
176
+
177
+ async def _execute_async(self) -> None:
178
+ """Execute parsed tasks through the async executor path."""
179
+ klass = self.executor_class
180
+ config_path = self.config.tasks.executor_class
181
+ if config_path is not None:
182
+ module_path, _, class_name = config_path.rpartition(".")
183
+ module = import_module(module_path)
184
+ klass = getattr(module, class_name)
185
+ executor = klass(self.collection, self.config, self.core)
186
+ if not hasattr(executor, "execute_async"):
187
+ raise TypeError("Async tasks require an executor with execute_async()")
188
+ await executor.execute_async(*self.tasks) # ty: ignore[call-non-callable]
189
+
162
190
  def setup_consoles(self):
163
191
  """Pre-populate the console objects"""
164
192
  patterns = self.args["redact_pattern"].value
@@ -368,10 +396,92 @@ class ToolkitProgram(Program):
368
396
  default=False,
369
397
  help="Initializes shell for runner via CLI flag",
370
398
  ),
399
+ ToolkitArgument(
400
+ names=("list-tasks", "lt"),
401
+ kind=bool,
402
+ default=False,
403
+ help="List only the main tasks discovered in the current directory (excludes plugins)",
404
+ ),
405
+ ToolkitArgument(
406
+ names=("list-plugins", "lp"),
407
+ kind=bool,
408
+ default=False,
409
+ help="List only the plugin-provided task collections (excludes main tasks)",
410
+ ),
371
411
  ]
372
412
  args.extend(toolkit_program_arguments)
373
413
  return args
374
414
 
415
+ def _handle_list_flags(self) -> None:
416
+ """
417
+ Handle --list, --list-tasks, and --list-plugins flags.
418
+
419
+ Sets ``list_format`` / ``list_depth`` / ``scoped_collection`` as needed
420
+ and raises `Exit` when any listing flag is active.
421
+ """
422
+ self.list_format = self.args["list-format"].value
423
+ self.list_depth = self.args["list-depth"].value
424
+
425
+ # --list [root]: standard task listing (optionally scoped to a sub-collection)
426
+ list_root = self.args.list.value # True or a string namespace
427
+ if list_root:
428
+ if isinstance(list_root, str):
429
+ self.list_root = list_root
430
+ try:
431
+ sub = self.collection.subcollection_from_path(list_root)
432
+ self.scoped_collection = sub
433
+ except KeyError:
434
+ msg = "Sub-collection '{}' not found!"
435
+ raise Exit(msg.format(list_root))
436
+ self.list_tasks()
437
+ raise Exit
438
+
439
+ # --list-tasks: show only main (non-plugin) tasks
440
+ if self.args["list-tasks"].value:
441
+ debug("Saw --list-tasks, listing main tasks only & exiting")
442
+ self.scoped_collection = self._make_filtered_collection("tasks")
443
+ self.list_tasks()
444
+ raise Exit
445
+
446
+ # --list-plugins: show only plugin-provided collections
447
+ if self.args["list-plugins"].value:
448
+ debug("Saw --list-plugins, listing plugin collections only & exiting")
449
+ if not self._plugin_collection_names:
450
+ raise Exit("No plugin collections found.")
451
+ self.scoped_collection = self._make_filtered_collection("plugins")
452
+ self.list_tasks()
453
+ raise Exit
454
+
455
+ def _make_filtered_collection(
456
+ self, mode: "Literal['tasks', 'plugins']"
457
+ ) -> "ToolkitCollection":
458
+ """
459
+ Return a shallow copy of ``self.scoped_collection`` containing only
460
+ the subset of tasks and sub-collections appropriate for *mode*.
461
+
462
+ - ``'tasks'`` – direct tasks + non-plugin sub-collections only
463
+ - ``'plugins'`` – plugin sub-collections only (no direct tasks)
464
+ """
465
+ source = self.scoped_collection
466
+ filtered = ToolkitCollection(source.name or "")
467
+ if mode == "tasks":
468
+ # Include all direct tasks on the scoped collection
469
+ for task_name, task in source.tasks.items():
470
+ filtered.add_task(task, name=task_name)
471
+ # Include sub-collections that are NOT from plugins
472
+ for coll_name, coll in source.collections.items():
473
+ if coll_name not in self._plugin_collection_names:
474
+ filtered.add_collection(coll, name=coll_name)
475
+ else: # mode == "plugins"
476
+ # Include only the plugin sub-collections
477
+ for coll_name, coll in source.collections.items():
478
+ if coll_name in self._plugin_collection_names:
479
+ filtered.add_collection(coll, name=coll_name)
480
+ # Preserve the default task pointer when it still makes sense
481
+ if source.default and source.default in filtered.tasks:
482
+ filtered.default = source.default
483
+ return filtered
484
+
375
485
  def _load_entry_points_collection(self, start: str) -> "ToolkitCollection | None":
376
486
  """
377
487
  Load task collections from entry points.
@@ -453,6 +563,7 @@ class ToolkitProgram(Program):
453
563
  debug("Merging entry point collections with tasks.py")
454
564
  for name, collection in ep_collection.collections.items():
455
565
  self.collection.add_collection(collection, name=name)
566
+ self._plugin_collection_names.add(name)
456
567
  except CollectionNotFound as e:
457
568
  start = self.args["search-root"].value or "."
458
569
  # First try to load entry points (for packages without tasks.py)
@@ -460,6 +571,7 @@ class ToolkitProgram(Program):
460
571
  if ep_collection is not None:
461
572
  debug("Loading collections from entry points")
462
573
  self.collection = ep_collection
574
+ self._plugin_collection_names.update(ep_collection.collections.keys())
463
575
  else:
464
576
  # If entry points weren't found, try local_tasks or fail
465
577
  if not (Path(start) / "local_tasks.py").exists():
@@ -571,23 +683,8 @@ class ToolkitProgram(Program):
571
683
  # should?
572
684
  raise ParseError("No idea what '{}' is!".format(halp))
573
685
 
574
- # Print discovered tasks if necessary
575
- list_root = self.args.list.value # will be True or string
576
-
577
- self.list_format = self.args["list-format"].value
578
- self.list_depth = self.args["list-depth"].value
579
- if list_root:
580
- # Not just --list, but --list some-root - do moar work
581
- if isinstance(list_root, str):
582
- self.list_root = list_root
583
- try:
584
- sub = self.collection.subcollection_from_path(list_root)
585
- self.scoped_collection = sub
586
- except KeyError:
587
- msg = "Sub-collection '{}' not found!"
588
- raise Exit(msg.format(list_root))
589
- self.list_tasks()
590
- raise Exit
686
+ # Handle --list / --list-tasks / --list-plugins (raises Exit if triggered)
687
+ self._handle_list_flags()
591
688
 
592
689
  # Print completion helpers if necessary
593
690
  if self.args.complete.value: