Skip to content

Commit 8db5dbe

Browse files
committed
Drop the tools. It's cleaner.
1 parent af6a8bf commit 8db5dbe

File tree

5 files changed

+96
-61
lines changed

5 files changed

+96
-61
lines changed

Doc/whatsnew/3.14.rst

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -518,14 +518,18 @@ asynchronous tasks, available via:
518518

519519
.. code-block:: bash
520520
521-
python -m asyncio.tools [--tree] PID
521+
python -m asyncio ps PID
522522
523523
This tool inspects the given process ID (PID) and displays information about
524-
currently running asyncio tasks. By default, it outputs a task table: a flat
524+
currently running asyncio tasks. It outputs a task table: a flat
525525
listing of all tasks, their names, their coroutine stacks, and which tasks are
526526
awaiting them.
527527

528-
With the ``--tree`` option, it instead renders a visual async call tree,
528+
.. code-block:: bash
529+
530+
python -m asyncio pstree PID
531+
532+
This tool fetches the same information, but renders a visual async call tree,
529533
showing coroutine relationships in a hierarchical format. This command is
530534
particularly useful for debugging long-running or stuck asynchronous programs.
531535
It can help developers quickly identify where a program is blocked, what tasks
@@ -565,7 +569,7 @@ Executing the new tool on the running process will yield a table like this:
565569

566570
.. code-block:: bash
567571
568-
python -m asyncio.tools 12345
572+
python -m asyncio ps 12345
569573
570574
tid task id task name coroutine chain awaiter name awaiter id
571575
---------------------------------------------------------------------------------------------------------------------------------------
@@ -576,11 +580,11 @@ Executing the new tool on the running process will yield a table like this:
576580
6826911 0x200013c0e20 Task-7 task_group Task-3 0x200013c0420
577581
578582
579-
and with the ``--tree`` option:
583+
or:
580584

581585
.. code-block:: bash
582586
583-
python -m asyncio.tools --tree 12345
587+
python -m asyncio pstree 12345
584588
585589
└── (T) Task-1
586590
└── main

Lib/asyncio/__main__.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import argparse
12
import ast
23
import asyncio
4+
import asyncio.tools
35
import concurrent.futures
46
import contextvars
57
import inspect
@@ -140,6 +142,36 @@ def interrupt(self) -> None:
140142

141143

142144
if __name__ == '__main__':
145+
parser = argparse.ArgumentParser(
146+
prog="python3 -m asyncio",
147+
description="Interactive asyncio shell and CLI tools",
148+
)
149+
subparsers = parser.add_subparsers(help="sub-commands", dest="command")
150+
ps = subparsers.add_parser(
151+
"ps", help="Display a table of all pending tasks in a process"
152+
)
153+
ps.add_argument("pid", type=int, help="Process ID to inspect")
154+
pstree = subparsers.add_parser(
155+
"pstree", help="Display a tree of all pending tasks in a process"
156+
)
157+
pstree.add_argument("pid", type=int, help="Process ID to inspect")
158+
args = parser.parse_args()
159+
match args.command:
160+
case "ps":
161+
asyncio.tools.display_awaited_by_tasks_table(args.pid)
162+
sys.exit(0)
163+
case "pstree":
164+
asyncio.tools.display_awaited_by_tasks_tree(args.pid)
165+
sys.exit(0)
166+
case None:
167+
pass # continue to the interactive shell
168+
case _:
169+
# shouldn't happen as an invalid command-line wouldn't parse
170+
# but let's keep it for the next person adding a command
171+
print(f"error: unhandled command {args.command}", file=sys.stderr)
172+
parser.print_usage(file=sys.stderr)
173+
sys.exit(1)
174+
143175
sys.audit("cpython.run_stdin")
144176

145177
if os.getenv('PYTHON_BASIC_REPL'):

Lib/asyncio/tools.py

Lines changed: 34 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import argparse
21
from dataclasses import dataclass
32
from collections import defaultdict
43
from itertools import count
@@ -107,10 +106,12 @@ def dfs(v):
107106

108107

109108
# ─── PRINT TREE FUNCTION ───────────────────────────────────────
110-
def print_async_tree(result, task_emoji="(T)", cor_emoji="", printer=print):
109+
def build_async_tree(result, task_emoji="(T)", cor_emoji="", printer=print):
111110
"""
112-
Pretty-print the async call tree produced by `get_all_async_stacks()`,
113-
prefixing tasks with *task_emoji* and coroutine frames with *cor_emoji*.
111+
Build a list of strings for pretty-print a async call tree.
112+
113+
The call tree is produced by `get_all_async_stacks()`, prefixing tasks
114+
with `task_emoji` and coroutine frames with `cor_emoji`.
114115
"""
115116
id2name, awaits = _index(result)
116117
g = _task_graph(awaits)
@@ -179,40 +180,39 @@ def _print_cycle_exception(exception: CycleFoundException):
179180
print(f"cycle: {inames}", file=sys.stderr)
180181

181182

182-
183-
if __name__ == "__main__":
184-
parser = argparse.ArgumentParser(description="Show Python async tasks in a process")
185-
parser.add_argument("pid", type=int, help="Process ID(s) to inspect.")
186-
parser.add_argument(
187-
"--tree", "-t", action="store_true", help="Display tasks in a tree format"
188-
)
189-
args = parser.parse_args()
190-
183+
def _get_awaited_by_tasks(pid: int) -> list:
191184
try:
192-
tasks = get_all_awaited_by(args.pid)
185+
return get_all_awaited_by(pid)
193186
except RuntimeError as e:
194187
while e.__context__ is not None:
195188
e = e.__context__
196189
print(f"Error retrieving tasks: {e}")
197190
sys.exit(1)
198191

199-
if args.tree:
200-
# Print the async call tree
201-
try:
202-
result = print_async_tree(tasks)
203-
except CycleFoundException as e:
204-
_print_cycle_exception(e)
205-
sys.exit(1)
206-
207-
for tree in result:
208-
print("\n".join(tree))
209-
else:
210-
# Build and print the task table
211-
table = build_task_table(tasks)
212-
# Print the table in a simple tabular format
213-
print(
214-
f"{'tid':<10} {'task id':<20} {'task name':<20} {'coroutine chain':<50} {'awaiter name':<20} {'awaiter id':<15}"
215-
)
216-
print("-" * 135)
217-
for row in table:
218-
print(f"{row[0]:<10} {row[1]:<20} {row[2]:<20} {row[3]:<50} {row[4]:<20} {row[5]:<15}")
192+
193+
def display_awaited_by_tasks_table(pid: int) -> None:
194+
"""Build and print a table of all pending tasks under `pid`."""
195+
196+
tasks = _get_awaited_by_tasks(pid)
197+
table = build_task_table(tasks)
198+
# Print the table in a simple tabular format
199+
print(
200+
f"{'tid':<10} {'task id':<20} {'task name':<20} {'coroutine chain':<50} {'awaiter name':<20} {'awaiter id':<15}"
201+
)
202+
print("-" * 135)
203+
for row in table:
204+
print(f"{row[0]:<10} {row[1]:<20} {row[2]:<20} {row[3]:<50} {row[4]:<20} {row[5]:<15}")
205+
206+
207+
def display_awaited_by_tasks_tree(pid: int) -> None:
208+
"""Build and print a tree of all pending tasks under `pid`."""
209+
210+
tasks = _get_awaited_by_tasks(pid)
211+
try:
212+
result = print_async_tree(tasks)
213+
except CycleFoundException as e:
214+
_print_cycle_exception(e)
215+
sys.exit(1)
216+
217+
for tree in result:
218+
print("\n".join(tree))

Lib/test/test_asyncio/test_tools.py

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
"""Tests for the asyncio tools script."""
2-
31
import unittest
42

53
from asyncio import tools
@@ -595,13 +593,13 @@ class TestAsyncioToolsTree(unittest.TestCase):
595593
def test_asyncio_utils(self):
596594
for input_, tree in TEST_INPUTS_TREE:
597595
with self.subTest(input_):
598-
self.assertEqual(tools.print_async_tree(input_), tree)
596+
self.assertEqual(tools.build_async_tree(input_), tree)
599597

600598
def test_asyncio_utils_cycles(self):
601599
for input_, cycles in TEST_INPUTS_CYCLES_TREE:
602600
with self.subTest(input_):
603601
try:
604-
tools.print_async_tree(input_)
602+
tools.build_async_tree(input_)
605603
except tools.CycleFoundException as e:
606604
self.assertEqual(e.cycles, cycles)
607605

@@ -615,10 +613,10 @@ def test_asyncio_utils(self):
615613

616614
class TestAsyncioToolsBasic(unittest.TestCase):
617615
def test_empty_input_tree(self):
618-
"""Test print_async_tree with empty input."""
616+
"""Test build_async_tree with empty input."""
619617
result = []
620618
expected_output = []
621-
self.assertEqual(tools.print_async_tree(result), expected_output)
619+
self.assertEqual(tools.build_async_tree(result), expected_output)
622620

623621
def test_empty_input_table(self):
624622
"""Test build_task_table with empty input."""
@@ -629,7 +627,7 @@ def test_empty_input_table(self):
629627
def test_only_independent_tasks_tree(self):
630628
input_ = [(1, [(10, "taskA", []), (11, "taskB", [])])]
631629
expected = [["└── (T) taskA"], ["└── (T) taskB"]]
632-
result = tools.print_async_tree(input_)
630+
result = tools.build_async_tree(input_)
633631
self.assertEqual(sorted(result), sorted(expected))
634632

635633
def test_only_independent_tasks_table(self):
@@ -640,7 +638,7 @@ def test_only_independent_tasks_table(self):
640638
)
641639

642640
def test_single_task_tree(self):
643-
"""Test print_async_tree with a single task and no awaits."""
641+
"""Test build_async_tree with a single task and no awaits."""
644642
result = [
645643
(
646644
1,
@@ -654,7 +652,7 @@ def test_single_task_tree(self):
654652
"└── (T) Task-1",
655653
]
656654
]
657-
self.assertEqual(tools.print_async_tree(result), expected_output)
655+
self.assertEqual(tools.build_async_tree(result), expected_output)
658656

659657
def test_single_task_table(self):
660658
"""Test build_task_table with a single task and no awaits."""
@@ -670,7 +668,7 @@ def test_single_task_table(self):
670668
self.assertEqual(tools.build_task_table(result), expected_output)
671669

672670
def test_cycle_detection(self):
673-
"""Test print_async_tree raises CycleFoundException for cyclic input."""
671+
"""Test build_async_tree raises CycleFoundException for cyclic input."""
674672
result = [
675673
(
676674
1,
@@ -681,11 +679,11 @@ def test_cycle_detection(self):
681679
)
682680
]
683681
with self.assertRaises(tools.CycleFoundException) as context:
684-
tools.print_async_tree(result)
682+
tools.build_async_tree(result)
685683
self.assertEqual(context.exception.cycles, [[3, 2, 3]])
686684

687685
def test_complex_tree(self):
688-
"""Test print_async_tree with a more complex tree structure."""
686+
"""Test build_async_tree with a more complex tree structure."""
689687
result = [
690688
(
691689
1,
@@ -705,7 +703,7 @@ def test_complex_tree(self):
705703
" └── (T) Task-3",
706704
]
707705
]
708-
self.assertEqual(tools.print_async_tree(result), expected_output)
706+
self.assertEqual(tools.build_async_tree(result), expected_output)
709707

710708
def test_complex_table(self):
711709
"""Test build_task_table with a more complex tree structure."""
@@ -747,7 +745,7 @@ def test_deep_coroutine_chain(self):
747745
" └── (T) leaf",
748746
]
749747
]
750-
result = tools.print_async_tree(input_)
748+
result = tools.build_async_tree(input_)
751749
self.assertEqual(result, expected)
752750

753751
def test_multiple_cycles_same_node(self):
@@ -762,7 +760,7 @@ def test_multiple_cycles_same_node(self):
762760
)
763761
]
764762
with self.assertRaises(tools.CycleFoundException) as ctx:
765-
tools.print_async_tree(input_)
763+
tools.build_async_tree(input_)
766764
cycles = ctx.exception.cycles
767765
self.assertTrue(any(set(c) == {1, 2, 3} for c in cycles))
768766

@@ -789,7 +787,7 @@ def test_task_awaits_self(self):
789787
"""A task directly awaits itself – should raise a cycle."""
790788
input_ = [(1, [(1, "Self-Awaiter", [[["loopback"], 1]])])]
791789
with self.assertRaises(tools.CycleFoundException) as ctx:
792-
tools.print_async_tree(input_)
790+
tools.build_async_tree(input_)
793791
self.assertIn([1, 1], ctx.exception.cycles)
794792

795793
def test_task_with_missing_awaiter_id(self):
@@ -811,7 +809,7 @@ def test_duplicate_coroutine_frames(self):
811809
],
812810
)
813811
]
814-
tree = tools.print_async_tree(input_)
812+
tree = tools.build_async_tree(input_)
815813
# Both children should be under the same coroutine node
816814
flat = "\n".join(tree[0])
817815
self.assertIn("frameA", flat)
@@ -827,13 +825,13 @@ def test_task_with_no_name(self):
827825
"""Task with no name in id2name – should still render with fallback."""
828826
input_ = [(1, [(1, "root", [[["f1"], 2]]), (2, None, [])])]
829827
# If name is None, fallback to string should not crash
830-
tree = tools.print_async_tree(input_)
828+
tree = tools.build_async_tree(input_)
831829
self.assertIn("(T) None", "\n".join(tree[0]))
832830

833831
def test_tree_rendering_with_custom_emojis(self):
834832
"""Pass custom emojis to the tree renderer."""
835833
input_ = [(1, [(1, "MainTask", [[["f1", "f2"], 2]]), (2, "SubTask", [])])]
836-
tree = tools.print_async_tree(input_, task_emoji="🧵", cor_emoji="🔁")
834+
tree = tools.build_async_tree(input_, task_emoji="🧵", cor_emoji="🔁")
837835
flat = "\n".join(tree[0])
838836
self.assertIn("🧵 MainTask", flat)
839837
self.assertIn("🔁 f1", flat)
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
Add a new ``python -m asyncio.tools`` command-line interface to inspect
1+
Add a new ``python -m asyncio ps PID`` command-line interface to inspect
22
asyncio tasks in a running Python process. Displays a flat table of await
3-
relationships or a tree view with ``--tree``, useful for debugging async
3+
relationships. A variant showing a tree view is also available as
4+
``python -m asyncio pstree PID``. Both are useful for debugging async
45
code. Patch by Pablo Galindo, Łukasz Langa, Yury Selivanov, and Marta
56
Gomez Macias.

0 commit comments

Comments
 (0)