atv 0.1.0__py3-none-any.whl

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.
atv/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """atv package metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["__version__"]
6
+
7
+ __version__ = "0.1.0"
atv/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Support ``python -m atv``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+
8
+ if __name__ == "__main__": # pragma: no cover - module level entry point
9
+ main()
atv/app.py ADDED
@@ -0,0 +1,249 @@
1
+ """Textual application scaffolding for atv."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ from typing import Iterable
8
+
9
+ from textual.app import App, ComposeResult
10
+ from textual.binding import Binding
11
+ from textual.containers import Container
12
+ from textual.coordinate import Coordinate
13
+ from textual.widgets import DataTable, Footer, Header, Log, Static
14
+
15
+ from .processes import ProcessInfo, list_python_processes
16
+
17
+
18
+ class ProcessTable(DataTable):
19
+ """Tabular view of detected Python processes."""
20
+
21
+ def on_mount(self) -> None: # noqa: D401 - Textual lifecycle hook
22
+ self.cursor_type = "row"
23
+ self.zebra_stripes = True
24
+ self.add_columns("PID", "Process", "File", "Command line")
25
+
26
+ def get_selected_pid(self) -> int | None:
27
+ """Return the PID associated with the highlighted row, if any."""
28
+
29
+ if self.row_count == 0:
30
+ return None
31
+ coordinate = self.cursor_coordinate
32
+ if coordinate is None:
33
+ return None
34
+
35
+ # Always read PID from column 0 to avoid reliance on internal row keys.
36
+ pid_coordinate = Coordinate(coordinate.row, 0)
37
+ try:
38
+ pid_value = self.get_cell_at(pid_coordinate)
39
+ except Exception: # pragma: no cover - defensive safeguard
40
+ return None
41
+
42
+ try:
43
+ return int(pid_value)
44
+ except (TypeError, ValueError):
45
+ return None
46
+
47
+ def update_processes(self, processes: Iterable[ProcessInfo]) -> None:
48
+ """Replace the table content with the given process collection."""
49
+
50
+ self.clear(columns=False)
51
+ for info in processes:
52
+ display_name = info.name or "(unknown)"
53
+ file_display = info.file or "-"
54
+ self.add_row(
55
+ str(info.pid),
56
+ display_name,
57
+ file_display,
58
+ info.command_line,
59
+ key=info.pid,
60
+ )
61
+
62
+ if self.row_count:
63
+ self.cursor_coordinate = (0, 0)
64
+
65
+
66
+ class AtvApp(App):
67
+ """Primary Textual application for atv."""
68
+
69
+ CSS = """
70
+ #content {
71
+ layout: horizontal;
72
+ padding: 1 2;
73
+ height: 1fr;
74
+ }
75
+
76
+ #process-pane, #output-pane {
77
+ layout: vertical;
78
+ height: 1fr;
79
+ }
80
+
81
+ #process-pane {
82
+ width: 1fr;
83
+ }
84
+
85
+ #output-pane {
86
+ width: 2fr;
87
+ margin-left: 2;
88
+ }
89
+
90
+ #intro {
91
+ color: #b3b3b3;
92
+ }
93
+
94
+ DataTable {
95
+ height: 1fr;
96
+ }
97
+
98
+ Log {
99
+ height: 1fr;
100
+ border: round $accent;
101
+ padding: 0 1;
102
+ }
103
+ """
104
+
105
+ TITLE = "atv"
106
+ SUB_TITLE = "Asyncio process discovery (alpha)"
107
+
108
+ BINDINGS = [
109
+ Binding("r", "refresh_processes", "Refresh", priority=True),
110
+ Binding("enter", "inspect_selected", "Inspect"),
111
+ Binding("p", "inspect_selected", "Inspect"),
112
+ Binding("q", "quit", "Quit"),
113
+ ]
114
+
115
+ def compose(self) -> ComposeResult:
116
+ yield Header(show_clock=True)
117
+ yield Container(
118
+ Container(
119
+ Static(
120
+ "Select a Python process to inspect. Press R to refresh. \n"
121
+ "Press Enter to capture `python -m asyncio ps <pid>`.",
122
+ id="intro",
123
+ ),
124
+ ProcessTable(id="process-table"),
125
+ id="process-pane",
126
+ ),
127
+ Container(
128
+ Static("Awaiting selection", id="ps-title"),
129
+ Log(id="ps-output"),
130
+ id="output-pane",
131
+ ),
132
+ id="content",
133
+ )
134
+ yield Footer()
135
+
136
+ def on_mount(self) -> None: # noqa: D401 - Textual lifecycle hook
137
+ self.refresh_process_list()
138
+
139
+ def action_refresh_processes(self) -> None:
140
+ """Binding target: refresh the process table."""
141
+
142
+ self.refresh_process_list()
143
+
144
+ def action_inspect_selected(self) -> None:
145
+ """Run ``python -m asyncio ps`` for the highlighted process."""
146
+
147
+ table = self.query_one(ProcessTable)
148
+ pid = table.get_selected_pid()
149
+ if pid is None:
150
+ self.notify("Select a Python process first.", severity="warning", timeout=3)
151
+ return
152
+
153
+ header = self.query_one("#ps-title", Static)
154
+ header.update(f"Running asyncio ps for PID {pid}…")
155
+ log = self.query_one("#ps-output", Log)
156
+ log.clear()
157
+ log.write("Awaiting asyncio ps output…")
158
+
159
+ self.run_worker(
160
+ self.capture_asyncio_ps(pid),
161
+ exclusive=True,
162
+ description=f"Inspecting PID {pid}",
163
+ name=f"ps-{pid}",
164
+ )
165
+
166
+ def refresh_process_list(self) -> None:
167
+ """Fetch Python processes and update UI state."""
168
+
169
+ processes = list_python_processes()
170
+ table = self.query_one(ProcessTable)
171
+ table.update_processes(processes)
172
+
173
+ count = len(processes)
174
+ noun = "process" if count == 1 else "processes"
175
+ self.sub_title = f"{count} Python {noun} visible"
176
+
177
+ if not count:
178
+ self.notify(
179
+ "No Python processes detected. Launch your target application and press R to refresh.",
180
+ severity="warning",
181
+ timeout=4,
182
+ )
183
+ output = self.query_one("#ps-output", Log)
184
+ output.clear()
185
+ self.query_one("#ps-title", Static).update("Awaiting selection")
186
+
187
+ async def capture_asyncio_ps(self, pid: int) -> None:
188
+ """Execute ``python -m asyncio ps`` for *pid* and stream the results."""
189
+
190
+ cmd = [sys.executable, "-m", "asyncio", "ps", str(pid)]
191
+ try:
192
+ process = await asyncio.create_subprocess_exec(
193
+ *cmd,
194
+ stdout=asyncio.subprocess.PIPE,
195
+ stderr=asyncio.subprocess.PIPE,
196
+ )
197
+ except FileNotFoundError:
198
+ self._display_ps_failure(pid, "Failed to locate python executable to launch asyncio ps.")
199
+ return
200
+
201
+ stdout_bytes, stderr_bytes = await process.communicate()
202
+ stdout_text = stdout_bytes.decode("utf-8", errors="replace")
203
+ stderr_text = stderr_bytes.decode("utf-8", errors="replace")
204
+
205
+ self.log.debug(
206
+ "asyncio ps completed",
207
+ pid=pid,
208
+ returncode=process.returncode,
209
+ stdout_preview=stdout_text[:200],
210
+ stderr_preview=stderr_text[:200],
211
+ )
212
+
213
+ if process.returncode != 0:
214
+ message = stderr_text or "asyncio ps exited with a non-zero status."
215
+ self._display_ps_failure(pid, message.strip())
216
+ return
217
+
218
+ self._display_ps_success(pid, stdout_text, stderr_text)
219
+
220
+ def _display_ps_success(self, pid: int, stdout_text: str, stderr_text: str) -> None:
221
+ header = self.query_one("#ps-title", Static)
222
+ header.update(f"asyncio ps output for PID {pid}")
223
+ log = self.query_one("#ps-output", Log)
224
+ log.clear()
225
+ lines: list[str] = [f"$ {sys.executable} -m asyncio ps {pid}"]
226
+
227
+ if stdout_text.strip():
228
+ lines.append("-- stdout --")
229
+ lines.extend(stdout_text.splitlines())
230
+ else:
231
+ lines.append("(stdout was empty)")
232
+
233
+ if stderr_text.strip():
234
+ lines.append("")
235
+ lines.append("-- stderr --")
236
+ lines.extend(stderr_text.splitlines())
237
+
238
+ log.write_lines(lines)
239
+
240
+ def _display_ps_failure(self, pid: int, message: str) -> None:
241
+ header = self.query_one("#ps-title", Static)
242
+ header.update(f"Failed to capture asyncio ps for PID {pid}")
243
+ log = self.query_one("#ps-output", Log)
244
+ log.clear()
245
+ log.write_lines([
246
+ f"$ {sys.executable} -m asyncio ps {pid}",
247
+ "-- error --",
248
+ message or "asyncio ps failed with an unknown error.",
249
+ ])
atv/cli.py ADDED
@@ -0,0 +1,15 @@
1
+ """Command-line entry points for atv."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .app import AtvApp
6
+
7
+
8
+ def main() -> None:
9
+ """Launch the Textual application."""
10
+
11
+ AtvApp().run()
12
+
13
+
14
+ if __name__ == "__main__":
15
+ main()
atv/processes.py ADDED
@@ -0,0 +1,82 @@
1
+ """Utilities for discovering asyncio-capable Python processes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Iterable, List, Sequence
8
+
9
+ import psutil
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class ProcessInfo:
14
+ """Lightweight descriptor for a running Python process."""
15
+
16
+ pid: int
17
+ name: str
18
+ command_line: str
19
+ file: str | None
20
+
21
+
22
+ def list_python_processes() -> List[ProcessInfo]:
23
+ """Return visible Python processes that are likely asyncio-capable.
24
+
25
+ We treat a process as "Python" if either its executable name or command
26
+ line contains the token ``python``. Processes we cannot introspect (e.g.
27
+ due to permissions) are skipped.
28
+ """
29
+
30
+ candidates: List[ProcessInfo] = []
31
+ for proc in psutil.process_iter(attrs=["pid", "name", "cmdline"]):
32
+ try:
33
+ name = proc.info.get("name") or ""
34
+ cmdline: Iterable[str] = proc.info.get("cmdline") or []
35
+ cmdline_list: List[str] = list(cmdline)
36
+ mark = "python" in name.lower() or any(
37
+ "python" in part.lower() for part in cmdline_list
38
+ )
39
+ if not mark:
40
+ continue
41
+
42
+ command_line = " ".join(cmdline_list) if cmdline_list else name
43
+ file = _detect_entrypoint(cmdline_list)
44
+ candidates.append(
45
+ ProcessInfo(
46
+ pid=proc.pid, name=name, command_line=command_line, file=file
47
+ )
48
+ )
49
+ except (psutil.AccessDenied, psutil.NoSuchProcess):
50
+ continue
51
+
52
+ candidates.sort(key=lambda info: info.pid)
53
+ return candidates
54
+
55
+
56
+ def _detect_entrypoint(cmdline: Sequence[str]) -> str | None:
57
+ """Best-effort extraction of the target script/module name.
58
+
59
+ Returns a basename for file paths, the module string for ``-m`` invocations,
60
+ or ``None`` if nothing matches.
61
+ """
62
+
63
+ if not cmdline:
64
+ return None
65
+
66
+ remainder = list(cmdline)
67
+
68
+ # If command begins with python executable, skip it.
69
+ if remainder and "python" in os.path.basename(remainder[0]).lower():
70
+ remainder = remainder[1:]
71
+
72
+ i = 0
73
+ while i < len(remainder):
74
+ token = remainder[i]
75
+ if token == "-m" and i + 1 < len(remainder):
76
+ return remainder[i + 1]
77
+ if token.startswith("-"):
78
+ i += 1
79
+ continue
80
+ # Treat first non-option token as a potential script path.
81
+ return os.path.basename(token)
82
+ return None
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: atv
3
+ Version: 0.1.0
4
+ Summary: TaskScope: a TUI tool for experimenting with Python 3.14 asyncio ps/pstree features
5
+ Requires-Python: >=3.14
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: textual<0.61,>=0.57
9
+ Requires-Dist: psutil>=5.9
10
+ Dynamic: license-file
11
+
12
+ # atv 🌱
13
+
14
+ <img width="2850" height="1480" alt="image" src="https://github.com/user-attachments/assets/9b8916b7-4ca0-4715-b63b-280f2c1bf8fd" />
15
+
16
+
17
+ atv(async task viewer) is a CLI for exploring Python `asyncio` processes with a Textual UI. It starts by listing Python processes running locally, and we plan to integrate execution of `asyncio ps` / `pstree` and call-graph visualization.
18
+
19
+ ## Setup
20
+
21
+ ```bash
22
+ cd atv
23
+ uv sync
24
+ ```
25
+
26
+ `uv sync` installs the specified dependencies (`textual`, `psutil`) into your local environment.
27
+
28
+ ## Usage
29
+
30
+ ```bash
31
+ uv run atv
32
+ ```
33
+
34
+ Shortcuts:
35
+
36
+ - `R` – Refresh the process list (the table shows PID / Process / File / Command Line)
37
+ - `Q` – Exit
38
+ - `Enter` / `P` – Run `python -m asyncio ps` for the selected PID and display the result in the right pane
39
+
40
+ ### Using uvx
41
+
42
+ Once released, you'll be able to launch it with a one-liner.
43
+
44
+ ```bash
45
+ uvx atv
46
+ ```
47
+
48
+ If you want to test uvx behavior during local development, run the following in the project root.
49
+
50
+ ```bash
51
+ uvx --from . atv
52
+ ```
53
+
54
+ ## Roadmap
55
+
56
+ - View that periodically runs `python -m asyncio ps` for the selected process
57
+ - Visualization for `asyncio pstree` / `format_call_graph`
58
+ - Snapshot capture and diff comparison
59
+ - Integration with the FastAPI demo (retrieve task information via the API)
60
+
61
+ ## License
62
+
63
+ This project is licensed under the MIT License. See `LICENSE` for details.
@@ -0,0 +1,11 @@
1
+ atv/__init__.py,sha256=cDhu2_cmAhSN_dpqA71H3Shvbcz3R675dtupTmqog7g,114
2
+ atv/__main__.py,sha256=cRSA9o0oEbImR-ZVm5QNdxuvxv027xXUo9yPPyWti_U,179
3
+ atv/app.py,sha256=wi8VCKuJF0tTHK3cPO4WaHzPAtDnF_1nTfCl7imdh9Q,7743
4
+ atv/cli.py,sha256=Nvl2rWMd56Uz3hqljePqxTIVUbQXNWdPrKz-irtBn78,226
5
+ atv/processes.py,sha256=66B6YpKUV4dmsemACtyGPTzlnNQkR_dsEfJbVp8k-MY,2480
6
+ atv-0.1.0.dist-info/licenses/LICENSE,sha256=7ZKgkwFg8Wl7hZzdWuaB5TD9p6ogW3rKUswUtS8vAIQ,1069
7
+ atv-0.1.0.dist-info/METADATA,sha256=dfW2UVK98pK0gHG-1A-4tYZTNKaCxyrncbKdnfz4-Iw,1673
8
+ atv-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
9
+ atv-0.1.0.dist-info/entry_points.txt,sha256=_k2ip4adJ7bdQuCUT66MyfOHCjL4MAkT5NUEfG4O_Dw,37
10
+ atv-0.1.0.dist-info/top_level.txt,sha256=SMwFWE_ISfvPk9wZWA-T84DwRwemIA-dR9f6gAGzCV0,4
11
+ atv-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ atv = atv.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Junya Fukuda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ atv