├── aiomonitor ├── webui │ ├── __init__.py │ ├── templates │ │ ├── about.html │ │ ├── trace.html │ │ ├── layout.html │ │ └── index.html │ ├── static │ │ ├── loader.svg │ │ └── client-side-templates.js │ ├── utils.py │ └── app.py ├── termui │ ├── __init__.py │ ├── completion.py │ └── commands.py ├── exceptions.py ├── context.py ├── cli.py ├── __init__.py ├── types.py ├── task.py ├── console.py ├── utils.py ├── telnet.py └── monitor.py ├── pytest.ini ├── .gitattributes ├── docs ├── contributing.rst ├── webui-demo1.gif ├── screenshot-ps-where-example.png ├── reference │ ├── webui.rst │ ├── termui.rst │ └── monitor.rst ├── Makefile ├── make.bat ├── examples.rst ├── api.rst ├── index.rst ├── conf.py └── tutorial.rst ├── changes ├── 410.fix └── template.rst ├── requirements-doc.txt ├── .pyup.yml ├── MANIFEST.in ├── .yamllint ├── .git_archival.txt ├── examples ├── x.py ├── simple_loop.py ├── taskgroup.py ├── console-variables.py ├── simple_aiohttp_srv.py ├── web_srv.py ├── web_srv_custom_monitor.py ├── extension.py ├── cancellation.py └── demo.py ├── tests ├── conftest.py └── test_monitor.py ├── requirements-dev.txt ├── .github ├── dependabot.yml ├── workflows │ ├── flake8-matcher.json │ ├── mypy-matcher.json │ ├── lint.yml │ └── ci-cd.yml └── ISSUE_TEMPLATE │ ├── feature_request.yml │ └── bug_report.yml ├── .readthedocs.yml ├── .editorconfig ├── .codecov.yml ├── .pre-commit-config.yaml ├── .gitignore ├── pyproject.toml ├── CONTRIBUTING.rst ├── setup.cfg ├── CHANGES.rst ├── README.rst └── LICENSE /aiomonitor/webui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aiomonitor/termui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | asyncio_mode = auto 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .git_archival.txt export-subst 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /changes/410.fix: -------------------------------------------------------------------------------- 1 | Switch from stadnard-telnetlib to better maintained telnetlib3 2 | -------------------------------------------------------------------------------- /docs/webui-demo1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiomonitor/HEAD/docs/webui-demo1.gif -------------------------------------------------------------------------------- /requirements-doc.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | sphinx~=7.2 3 | sphinx_click~=5.1.0 4 | sphinx_autodoc_typehints~=1.25 5 | -------------------------------------------------------------------------------- /.pyup.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Label PRs with `deps-update` label 3 | label_prs: deps-update 4 | 5 | schedule: every week 6 | -------------------------------------------------------------------------------- /docs/screenshot-ps-where-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiomonitor/HEAD/docs/screenshot-ps-where-example.png -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include CHANGES.txt 3 | include README.rst 4 | graft aiomonitor 5 | global-exclude *.pyc *.swp 6 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | extends: default 4 | 5 | rules: 6 | indentation: 7 | level: error 8 | indent-sequences: false 9 | 10 | ... 11 | -------------------------------------------------------------------------------- /.git_archival.txt: -------------------------------------------------------------------------------- 1 | node: f686e53928bebb8d1b70a1e77eb1b5e35eac5a72 2 | node-date: 2025-11-25T08:52:12Z 3 | describe-name: v0.7.1-12-gf686e539 4 | ref-names: HEAD -> main 5 | -------------------------------------------------------------------------------- /aiomonitor/exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class MissingTask(Exception): 5 | def __init__(self, task_id: str | int) -> None: 6 | super().__init__(task_id) 7 | self.task_id = task_id 8 | -------------------------------------------------------------------------------- /aiomonitor/webui/templates/about.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 | 6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /examples/x.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | 4 | async def do(): 5 | await asyncio.sleep(100) 6 | 7 | 8 | async def main(): 9 | t = asyncio.create_task(do()) 10 | await asyncio.sleep(1) 11 | t.cancel() 12 | await t 13 | 14 | 15 | asyncio.run(main()) 16 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | import pytest 4 | 5 | # import uvloop # TODO: run the entire test suite with both uvloop and the vanilla loop. 6 | 7 | 8 | @pytest.fixture(scope="session") 9 | def unused_port(): 10 | def f(): 11 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 12 | s.bind(("127.0.0.1", 0)) 13 | return s.getsockname()[1] 14 | 15 | return f 16 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | aioconsole==0.8.1 3 | aiohttp==3.10.10 4 | aiotools==1.7.0 5 | attrs==24.2.0 6 | docutils==0.21.2 7 | ipdb==0.13.13 8 | mypy==1.18.2 9 | pre-commit==3.5.0 10 | pytest-asyncio==0.24.0 11 | pytest-cov==7.0.0 12 | pytest-sugar==1.1.1 13 | pytest==8.3.3 14 | ruff==0.14.2 15 | terminaltables==3.1.10 16 | towncrier==24.8.0 17 | types-requests 18 | telnetlib3==2.0.4 19 | uvloop==0.21.0 20 | build==1.3.0 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: pip 5 | directory: "/" 6 | schedule: 7 | interval: monthly 8 | cooldown: 9 | default-days: 7 10 | open-pull-requests-limit: 10 11 | target-branch: main 12 | - package-ecosystem: github-actions 13 | directory: "/" 14 | schedule: 15 | interval: monthly 16 | cooldown: 17 | default-days: 7 18 | open-pull-requests-limit: 10 19 | -------------------------------------------------------------------------------- /aiomonitor/context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from contextvars import ContextVar 5 | from typing import TYPE_CHECKING, TextIO 6 | 7 | if TYPE_CHECKING: 8 | from .monitor import Monitor 9 | 10 | current_monitor: ContextVar[Monitor] = ContextVar("current_monitor") 11 | current_stdout: ContextVar[TextIO] = ContextVar("current_stdout") 12 | command_done: ContextVar[asyncio.Event] = ContextVar("command_done") 13 | -------------------------------------------------------------------------------- /.github/workflows/flake8-matcher.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "flake8", 5 | "pattern": [ 6 | { 7 | "regexp": "^([^:]*):(\\d+):(\\d+): (.*)$", 8 | "file": 1, 9 | "line": 2, 10 | "column": 3, 11 | "message": 4 12 | } 13 | ] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/mypy-matcher.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "mypy", 5 | "pattern": [ 6 | { 7 | "regexp": "^([^:]*):(\\d+): ([^:]+): (.*)$", 8 | "file": 1, 9 | "line": 2, 10 | "severity": 3, 11 | "message": 4 12 | } 13 | ] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /aiomonitor/webui/templates/trace.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block content %} 3 |
4 | {% for item_type, item_text in trace_data %} 5 | {% if item_type == "header" %} 6 |

{{ item_text }}

7 | {% else %} 8 |
{{ item_text }}
9 | {% endif %} 10 | {% endfor %} 11 |
12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | version: 2 4 | 5 | # Build documentation in the docs/ directory with Sphinx 6 | sphinx: 7 | builder: dirhtml 8 | configuration: docs/conf.py 9 | # fail_on_warning: true 10 | 11 | # Optionally build your docs in additional formats 12 | # such as PDF and ePub 13 | formats: [] 14 | 15 | build: 16 | os: ubuntu-20.04 17 | tools: 18 | python: >- # PyYAML parses it as float but RTD demands an explicit string 19 | 3.11 20 | python: 21 | install: 22 | - method: pip 23 | path: . 24 | - requirements: requirements-doc.txt 25 | -------------------------------------------------------------------------------- /examples/simple_loop.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiomonitor 4 | 5 | 6 | async def main() -> None: 7 | loop = asyncio.get_running_loop() 8 | with aiomonitor.start_monitor(loop=loop) as monitor: 9 | print("Now you can connect with:") 10 | print(f" python -m aiomonitor.cli -H {monitor.host} -p {monitor.port}") 11 | print("or") 12 | print(f" telnet {monitor.host} {monitor.port} # Linux only") 13 | loop.run_forever() 14 | 15 | 16 | if __name__ == "__main__": 17 | try: 18 | asyncio.run(main()) 19 | except KeyboardInterrupt: 20 | pass 21 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | 7 | [*.sh] 8 | indent_style = space 9 | indent_size = 2 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.{py,md}] 14 | max_line_length = 88 15 | indent_style = space 16 | indent_size = 4 17 | insert_final_newline = true 18 | 19 | [*.py] 20 | trim_trailing_whitespace = true 21 | 22 | [*.md] 23 | trim_trailing_whitespace = false 24 | 25 | [*.rst] 26 | max_line_length = 0 27 | indent_style = space 28 | indent_size = 3 29 | 30 | [*.html] 31 | max_line_length = 0 32 | indent_style = space 33 | indent_size = 2 34 | -------------------------------------------------------------------------------- /docs/reference/webui.rst: -------------------------------------------------------------------------------- 1 | Web UI 2 | ====== 3 | 4 | The web UI provides a graphical inspection interface 5 | that covers the most of the telnet commands. 6 | 7 | .. versionadded:: 0.6.0 8 | 9 | ``aiomonitor.webui.app`` 10 | ------------------------ 11 | 12 | The main implementation of the aiohttp-based webapp implementation. 13 | 14 | .. automodule:: aiomonitor.webui.app 15 | :members: 16 | :undoc-members: 17 | 18 | ``aiomonitor.webui.utils`` 19 | -------------------------- 20 | 21 | It contains a set of utility functions to validate and process the HTTP handler arguments. 22 | 23 | .. automodule:: aiomonitor.webui.utils 24 | :members: 25 | :undoc-members: 26 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | codecov: 4 | branch: main 5 | notify: 6 | after_n_builds: 10 7 | 8 | coverage: 9 | range: "95..100" 10 | status: 11 | project: false 12 | 13 | flags: 14 | library: 15 | paths: 16 | - aiomonitor/ 17 | configs: 18 | paths: 19 | - requirements/ 20 | - ".git*" 21 | - "*.toml" 22 | - "*.yml" 23 | changelog: 24 | paths: 25 | - changes/ 26 | - CHANGES.rst 27 | docs: 28 | paths: 29 | - docs/ 30 | - "*.md" 31 | - "*.rst" 32 | - "*.txt" 33 | tests: 34 | paths: 35 | - tests/ 36 | tools: 37 | paths: 38 | - tools/ 39 | third-party: 40 | paths: 41 | - vendor/ 42 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = aiomonitor 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /changes/template.rst: -------------------------------------------------------------------------------- 1 | {# TOWNCRIER TEMPLATE #} 2 | {% for section, _ in sections.items() %} 3 | 4 | {% if sections[section] %} 5 | {% for category, val in definitions.items() if category in sections[section]%} 6 | {% if definitions[category]['showcontent'] %} 7 | {% for text, values in sections[section][category].items() %} 8 | - {{ text }} 9 | {{ values|join(',\n ') + '\n' }} 10 | {% endfor %} 11 | 12 | {% else %} 13 | - {{ sections[section][category]['']|join(', ') }} 14 | 15 | {% endif %} 16 | {% if sections[section][category]|length == 0 %} 17 | No significant changes. 18 | 19 | {% else %} 20 | {% endif %} 21 | 22 | {% endfor %} 23 | {% else %} 24 | No significant changes. 25 | {% endif %} 26 | {% endfor %} 27 | -------------------------------------------------------------------------------- /examples/taskgroup.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiotools # FIXME: replace with asyncio.TaskGroup in Python 3.11 4 | 5 | import aiomonitor 6 | 7 | 8 | async def inner2(): 9 | await asyncio.sleep(100) 10 | 11 | 12 | async def inner1(): 13 | t = asyncio.create_task(inner2()) 14 | await t 15 | 16 | 17 | async def main(): 18 | loop = asyncio.get_running_loop() 19 | with aiomonitor.start_monitor(loop, hook_task_factory=True): 20 | print("Starting...") 21 | async with aiotools.TaskGroup() as tg: 22 | for _ in range(10): 23 | tg.create_task(inner1()) 24 | print("Done") 25 | 26 | 27 | if __name__ == "__main__": 28 | try: 29 | asyncio.run(main()) 30 | except KeyboardInterrupt: 31 | pass 32 | -------------------------------------------------------------------------------- /examples/console-variables.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiomonitor 4 | 5 | 6 | async def main(): 7 | loop = asyncio.get_running_loop() 8 | data = { 9 | "a": 123, 10 | "b": 456, 11 | } 12 | with aiomonitor.start_monitor(loop=loop) as monitor: 13 | monitor.console_locals["data"] = data 14 | print("Now you can connect with:") 15 | print(f" python -m aiomonitor.cli -H {monitor.host} -p {monitor.port}") 16 | print("or") 17 | print(f" telnet {monitor.host} {monitor.port} # Linux only") 18 | print("\nTry 'console' command and inspect 'data' variable.") 19 | # run forever until interrupted 20 | while True: 21 | await asyncio.sleep(60) 22 | 23 | 24 | if __name__ == "__main__": 25 | try: 26 | asyncio.run(main()) 27 | except KeyboardInterrupt: 28 | pass 29 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | ci: 4 | autoupdate_schedule: quarterly 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v6.0.0 8 | hooks: 9 | - id: check-yaml 10 | - id: end-of-file-fixer 11 | - id: trailing-whitespace 12 | - repo: https://github.com/astral-sh/ruff-pre-commit 13 | rev: v0.14.2 14 | hooks: 15 | - id: ruff 16 | args: ['--fix'] 17 | - id: ruff-format 18 | - repo: https://github.com/pre-commit/mirrors-mypy 19 | rev: v1.18.2 20 | hooks: 21 | - id: mypy 22 | additional_dependencies: 23 | - types-requests 24 | - repo: https://github.com/adrienverge/yamllint.git 25 | rev: v1.37.1 26 | hooks: 27 | - id: yamllint 28 | types: 29 | - file 30 | - yaml 31 | args: 32 | - --strict 33 | - repo: https://github.com/twisted/towncrier 34 | rev: 25.8.0 35 | hooks: 36 | - id: towncrier-check 37 | -------------------------------------------------------------------------------- /aiomonitor/webui/static/loader.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/reference/termui.rst: -------------------------------------------------------------------------------- 1 | Terminal UI 2 | =========== 3 | 4 | .. versionchanged:: 0.6.0 5 | 6 | Refactored as a separate sub-package to match on par with the webui module. 7 | 8 | .. versionchanged:: 0.5.0 9 | 10 | Refactored to support advanced terminal features like auto-completion. 11 | 12 | .. seealso:: Check out the tutorial :ref:`cust-commands` about adding custom commands. 13 | 14 | .. _monitor-cli: 15 | 16 | ``aiomonitor.termui.commands`` 17 | ------------------------------ 18 | 19 | .. automodule:: aiomonitor.termui.commands 20 | :members: 21 | :undoc-members: 22 | 23 | .. click:: aiomonitor.termui.commands:monitor_cli 24 | :prog: monitor_cli 25 | 26 | ``aiomonitor.termui.completion`` 27 | -------------------------------- 28 | 29 | You may inspect or modify the auto-completion helpers here. 30 | 31 | .. automodule:: aiomonitor.termui.completion 32 | :members: 33 | :undoc-members: 34 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=aiomonitor 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | Examples of aiomonitor usage 2 | ============================ 3 | 4 | Below is a list of examples from `aiomonitor/examples 5 | `_ 6 | 7 | Every example is a correct tiny python program. 8 | 9 | .. _aiomonitor-examples-simple: 10 | 11 | Basic Usage 12 | ----------- 13 | 14 | Basic example, starts monitor around ``loop.run_forever()`` function: 15 | 16 | .. literalinclude:: ../examples/simple_loop.py 17 | 18 | aiohttp Example 19 | --------------- 20 | 21 | Full feature example with aiohttp application: 22 | 23 | .. literalinclude:: ../examples/simple_aiohttp_srv.py 24 | 25 | Any above examples compatible with uvloop_, in fact aiomonitor test suite 26 | executed against asyncio and uvloop. 27 | 28 | .. _uvloop: https://github.com/MagicStack/uvloop 29 | 30 | aiohttp Example with additional command 31 | --------------------------------------- 32 | 33 | Same as above, but showing a custom command: 34 | 35 | .. literalinclude:: ../examples/web_srv_custom_monitor.py 36 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. _api-reference: 2 | 3 | API Reference 4 | ============= 5 | 6 | **aiomonitor** has tiny and simple to use API, just factory function and 7 | class that support context management protocol. Starting a monitor is as 8 | simple as opening a file. 9 | 10 | .. code:: python 11 | 12 | import asyncio 13 | import aiomonitor 14 | 15 | async def main(): 16 | loop = asyncio.get_event_loop() 17 | with aiomonitor.start_monitor(loop): 18 | print("Now you can connect with: telnet localhost 20101") 19 | loop.run_forever() 20 | 21 | asyncio.run(main()) 22 | 23 | Alternatively you can use more verbose try/finally approach but do not forget 24 | call ``close()`` methods, to join thread and finalize resources: 25 | 26 | .. code:: python 27 | 28 | m = Monitor() 29 | m.start() 30 | try: 31 | loop.run_forever() 32 | finally: 33 | m.close() 34 | 35 | It is possible to add custom commands to the monitor's telnet CLI. 36 | Check out :doc:`examples`. 37 | 38 | .. toctree:: 39 | :maxdepth: 2 40 | 41 | reference/monitor 42 | reference/termui 43 | reference/webui 44 | -------------------------------------------------------------------------------- /aiomonitor/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | 4 | from .monitor import MONITOR_HOST, MONITOR_TERMUI_PORT 5 | from .telnet import TelnetClient 6 | 7 | 8 | async def async_monitor_client(host: str, port: int) -> None: 9 | async with TelnetClient(host, port) as client: 10 | await client.interact() 11 | 12 | 13 | def monitor_client(host: str, port: int) -> None: 14 | try: 15 | asyncio.run(async_monitor_client(host, port)) 16 | except KeyboardInterrupt: 17 | pass 18 | 19 | 20 | def main() -> None: 21 | parser = argparse.ArgumentParser() 22 | parser.add_argument( 23 | "-H", 24 | "--host", 25 | dest="monitor_host", 26 | default=MONITOR_HOST, 27 | type=str, 28 | help="monitor host ip", 29 | ) 30 | parser.add_argument( 31 | "-p", 32 | "--port", 33 | dest="monitor_port", 34 | default=MONITOR_TERMUI_PORT, 35 | type=int, 36 | help="monitor port number", 37 | ) 38 | args = parser.parse_args() 39 | monitor_client(args.monitor_host, args.monitor_port) 40 | 41 | 42 | if __name__ == "__main__": 43 | main() 44 | -------------------------------------------------------------------------------- /examples/simple_aiohttp_srv.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | import uvloop 5 | from aiohttp import web 6 | 7 | import aiomonitor 8 | 9 | 10 | async def inner2() -> None: 11 | await asyncio.sleep(100) 12 | 13 | 14 | async def inner1(tg: asyncio.TaskGroup) -> None: 15 | t = tg.create_task(inner2()) 16 | await t 17 | 18 | 19 | async def simple(request: web.Request) -> web.Response: 20 | print("Start sleeping") 21 | async with asyncio.TaskGroup() as tg: 22 | tg.create_task(inner1(tg)) 23 | print("Finished sleeping") 24 | return web.Response(text="Simple answer") 25 | 26 | 27 | async def main() -> None: 28 | loop = asyncio.get_running_loop() 29 | app = web.Application() 30 | app.router.add_get("/simple", simple) 31 | with aiomonitor.start_monitor(loop, hook_task_factory=True): 32 | await web._run_app(app, port=8090, host="localhost") 33 | 34 | 35 | if __name__ == "__main__": 36 | logging.basicConfig() 37 | logging.getLogger("aiomonitor").setLevel(logging.DEBUG) 38 | uvloop.install() 39 | try: 40 | asyncio.run(main()) 41 | except KeyboardInterrupt: 42 | pass 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ># Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | pyvenv/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | .eggs 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | 59 | # PyCharm 60 | .idea 61 | *.iml 62 | # rope 63 | *.swp 64 | .ropeproject 65 | tags 66 | node_modules/ 67 | 68 | .mypy_cache 69 | Pipfile 70 | Pipfile.lock 71 | .ctags 72 | .tags 73 | 74 | .pytest_cache 75 | .python-version 76 | .*rc 77 | 78 | .DS_Store 79 | ._.DS_Store 80 | .vim/ 81 | -------------------------------------------------------------------------------- /docs/reference/monitor.rst: -------------------------------------------------------------------------------- 1 | The Monitor 2 | =========== 3 | 4 | ``aiomonitor.monitor`` 5 | ---------------------- 6 | 7 | .. module:: aiomonitor.monitor 8 | .. currentmodule:: aiomonitor.monitor 9 | 10 | .. data:: MONITOR_HOST = '127.0.0.1' 11 | 12 | Specifies the default host to bind the services for the monitor 13 | 14 | .. warning:: 15 | 16 | Since aiomonitor exposes the internal states of the traced process, never bind it to 17 | publicly accessible address to prevent potential security breaches and denial of services! 18 | 19 | .. data:: MONITOR_TERMUI_PORT = 20101 20 | 21 | Specifies the default telnet port for teh monitor where you can connect using a telnet client 22 | 23 | .. data:: MONITOR_WEBUI_PORT = 20102 24 | 25 | Specifies the default HTTP port for the monitor where you can connect using a web browser 26 | 27 | .. data:: CONSOLE_PORT = 20103 28 | 29 | Specifies the default port for asynchronous python REPL 30 | 31 | .. autofunction:: start_monitor() 32 | 33 | .. class:: Monitor 34 | 35 | .. automethod:: start() 36 | 37 | .. automethod:: close() 38 | 39 | .. autoattribute:: closed 40 | 41 | .. autoattribute:: prompt 42 | 43 | .. autoproperty:: host 44 | 45 | .. autoproperty:: port 46 | -------------------------------------------------------------------------------- /aiomonitor/__init__.py: -------------------------------------------------------------------------------- 1 | """Debugging monitor for asyncio app with asynchronous python REPL capabilities 2 | 3 | To enable the monitor, just use context manager protocol with start function:: 4 | 5 | import asyncio 6 | import aiomonitor 7 | 8 | loop = asyncio.get_event_loop() 9 | with aiomonitor.start_monitor(): 10 | print("Now you can connect with: nc localhost 20101") 11 | loop.run_forever() 12 | 13 | Alternatively you can use more verbose try/finally approach:: 14 | 15 | m = Monitor() 16 | m.start() 17 | try: 18 | loop.run_forever() 19 | finally: 20 | m.close() 21 | """ 22 | 23 | from importlib.metadata import version 24 | 25 | from .monitor import ( 26 | CONSOLE_PORT, 27 | MONITOR_HOST, 28 | MONITOR_TERMUI_PORT, 29 | MONITOR_WEBUI_PORT, 30 | Monitor, 31 | start_monitor, 32 | ) 33 | from .termui.commands import monitor_cli 34 | 35 | MONITOR_PORT = MONITOR_TERMUI_PORT # for backward compatibility 36 | 37 | __all__ = ( 38 | "Monitor", 39 | "start_monitor", 40 | "monitor_cli", 41 | "MONITOR_HOST", 42 | "MONITOR_PORT", 43 | "MONITOR_TERMUI_PORT", 44 | "MONITOR_WEBUI_PORT", 45 | "CONSOLE_PORT", 46 | ) 47 | __version__ = version("aiomonitor") 48 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=69.2", "setuptools_scm[toml]>=8.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools_scm] 6 | # enables setuptools_scm to provide the dynamic version 7 | 8 | [tool.ruff] 9 | line-length = 88 10 | src = ["aiomonitor", "tests", "examples"] 11 | preview = true 12 | 13 | [tool.ruff.lint] 14 | select = [ 15 | "E", # pycodestyle errors 16 | "W", # pycodestyle warnings 17 | "F", # pyflakes 18 | "I", # isort 19 | "B", # flake8-bugbear 20 | "Q", # flake8-quotes 21 | ] 22 | ignore = ["E203", "E731", "E501", "Q000"] 23 | 24 | [tool.ruff.lint.isort] 25 | known-first-party = ["aiomonitor"] 26 | split-on-trailing-comma = true 27 | 28 | [tool.ruff.format] 29 | preview = true 30 | 31 | [tool.mypy] 32 | mypy_path = ["."] 33 | ignore_missing_imports = true 34 | 35 | [tool.towncrier] 36 | package = "aiomonitor" 37 | filename = "CHANGES.rst" 38 | directory = "changes/" 39 | title_format = "{version} ({project_date})" 40 | template = "changes/template.rst" 41 | underlines = ["-", "~", "^"] 42 | issue_format = "(`#{issue} `_)" 43 | 44 | [tool.pyright] 45 | include = [ 46 | ".", 47 | ] 48 | venvPath = "." 49 | venv = ".venv" 50 | typeCheckingMode = "standard" 51 | -------------------------------------------------------------------------------- /examples/web_srv.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import uvloop 4 | from aiohttp import web 5 | 6 | import aiomonitor 7 | 8 | 9 | async def simple(request: web.Request) -> web.Response: 10 | await asyncio.sleep(10) 11 | await asyncio.sleep(10) 12 | return web.Response(text="Simple answer") 13 | 14 | 15 | async def hello(request: web.Request) -> web.StreamResponse: 16 | resp = web.StreamResponse() 17 | name = request.match_info.get("name", "Anonymous") 18 | answer = ("Hello, " + name).encode("utf8") 19 | resp.content_length = len(answer) 20 | resp.content_type = "text/plain" 21 | await resp.prepare(request) 22 | await asyncio.sleep(100) 23 | await resp.write(answer) 24 | await resp.write_eof() 25 | return resp 26 | 27 | 28 | async def main() -> None: 29 | loop = asyncio.get_running_loop() 30 | app = web.Application() 31 | app.router.add_get("/simple", simple) 32 | app.router.add_get("/hello/{name}", hello) 33 | app.router.add_get("/hello", hello) 34 | host, port = "localhost", 8090 35 | with aiomonitor.start_monitor(loop): 36 | web.run_app(app, port=port, host=host) 37 | 38 | 39 | if __name__ == "__main__": 40 | uvloop.install() 41 | try: 42 | asyncio.run(main()) 43 | except KeyboardInterrupt: 44 | pass 45 | -------------------------------------------------------------------------------- /aiomonitor/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | import traceback 5 | from dataclasses import dataclass 6 | from typing import List, NamedTuple, Optional 7 | 8 | if sys.version_info >= (3, 11): 9 | from enum import StrEnum 10 | else: 11 | from backports.strenum import StrEnum 12 | 13 | 14 | @dataclass 15 | class FormattedLiveTaskInfo: 16 | task_id: str 17 | state: str 18 | name: str 19 | coro: str 20 | created_location: str 21 | since: str 22 | 23 | 24 | @dataclass 25 | class FormattedTerminatedTaskInfo: 26 | task_id: str 27 | name: str 28 | coro: str 29 | started_since: str 30 | terminated_since: str 31 | 32 | 33 | class FormatItemTypes(StrEnum): 34 | HEADER = "header" 35 | CONTENT = "content" 36 | 37 | 38 | class FormattedStackItem(NamedTuple): 39 | type: FormatItemTypes 40 | content: str 41 | 42 | 43 | @dataclass 44 | class TerminatedTaskInfo: 45 | id: str 46 | name: str 47 | coro: str 48 | started_at: float 49 | terminated_at: float 50 | cancelled: bool 51 | termination_stack: Optional[List[traceback.FrameSummary]] = None 52 | canceller_stack: Optional[List[traceback.FrameSummary]] = None 53 | exc_repr: Optional[str] = None 54 | persistent: bool = False 55 | 56 | 57 | @dataclass 58 | class CancellationChain: 59 | target_id: str 60 | canceller_id: str 61 | canceller_stack: Optional[List[traceback.FrameSummary]] = None 62 | -------------------------------------------------------------------------------- /aiomonitor/webui/static/client-side-templates.js: -------------------------------------------------------------------------------- 1 | htmx.defineExtension('client-side-templates', { 2 | transformResponse : function(text, xhr, elt) { 3 | 4 | var mustacheTemplate = htmx.closest(elt, "[mustache-template]"); 5 | if (mustacheTemplate) { 6 | var data = JSON.parse(text); 7 | var templateId = mustacheTemplate.getAttribute('mustache-template'); 8 | var template = htmx.find("#" + templateId); 9 | if (template) { 10 | return Mustache.render(template.innerHTML, data); 11 | } else { 12 | throw "Unknown mustache template: " + templateId; 13 | } 14 | } 15 | 16 | var handlebarsTemplate = htmx.closest(elt, "[handlebars-template]"); 17 | if (handlebarsTemplate) { 18 | var data = JSON.parse(text); 19 | var templateName = handlebarsTemplate.getAttribute('handlebars-template'); 20 | return Handlebars.partials[templateName](data); 21 | } 22 | 23 | var nunjucksTemplate = htmx.closest(elt, "[nunjucks-template]"); 24 | if (nunjucksTemplate) { 25 | var data = JSON.parse(text); 26 | var templateName = nunjucksTemplate.getAttribute('nunjucks-template'); 27 | var template = htmx.find('#' + templateName); 28 | if (template) { 29 | return nunjucks.renderString(template.innerHTML, data); 30 | } else { 31 | return nunjucks.render(templateName, data); 32 | } 33 | } 34 | 35 | return text; 36 | } 37 | }); 38 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Running Tests 5 | ------------- 6 | 7 | .. _GitHub: https://github.com/aio-libs/aiomonitor 8 | 9 | Thanks for your interest in contributing to ``aiomonitor``, there are multiple 10 | ways and places you can contribute. 11 | 12 | Fist of all just clone repository:: 13 | 14 | $ git clone git@github.com:aio-libs/aiomonitor.git 15 | 16 | Create a virtualenv with Python 3.8 or later. For example, 17 | using *pyenv* commands could look like:: 18 | 19 | $ cd aiomonitor 20 | $ pyenv virtualenv 3.11 aiomonitor-dev 21 | $ pyenv local aiomonitor-dev 22 | 23 | After that please install the dependencies required for development 24 | and the sources as an editable package:: 25 | 26 | $ pip install -r requirements-dev.txt 27 | 28 | Congratulations, you are ready to run the test suite:: 29 | 30 | $ python -m pytest --cov tests 31 | 32 | To run individual tests use the following command:: 33 | 34 | $ python -m pytest -sv tests/test_monitor.py -k test_name 35 | 36 | 37 | Reporting an Issue 38 | ------------------ 39 | 40 | If you have found issue with `aiomonitor` please do 41 | not hesitate to file an issue on the GitHub_ project. When filing your 42 | issue please make sure you can express the issue with a reproducible test 43 | case. 44 | 45 | When reporting an issue we also need as much information about your environment 46 | that you can include. We never know what information will be pertinent when 47 | trying narrow down the issue. Please include at least the following 48 | information: 49 | 50 | * Version of `aiomonitor` and `python`. 51 | * Version `uvloop` if installed. 52 | * Platform you're running on (OS X, Linux). 53 | -------------------------------------------------------------------------------- /aiomonitor/webui/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | from abc import ABCMeta, abstractmethod 3 | from contextlib import asynccontextmanager as actxmgr 4 | from typing import AsyncIterator, Type, TypeVar 5 | 6 | import trafaret as t 7 | from aiohttp import web 8 | 9 | 10 | class APIParams(metaclass=ABCMeta): 11 | @classmethod 12 | @abstractmethod 13 | def get_checker(cls) -> t.Trafaret: 14 | raise NotImplementedError 15 | 16 | @classmethod 17 | def check(cls, value): 18 | checker = cls.get_checker() 19 | data = checker.check(value) 20 | return cls(**data) # assumes dataclass-like init with kwargs 21 | 22 | 23 | T_APIParams = TypeVar("T_APIParams", bound=APIParams) 24 | 25 | 26 | @actxmgr 27 | async def check_params( 28 | request: web.Request, 29 | checker: Type[T_APIParams], 30 | ) -> AsyncIterator[T_APIParams]: 31 | try: 32 | if request.method in ("GET", "DELETE"): 33 | params = checker.check(request.query) 34 | else: 35 | body = await request.post() 36 | params = checker.check(body) 37 | yield params 38 | except t.DataError as e: 39 | error_data = e.as_dict() 40 | if isinstance(error_data, str): 41 | detail = error_data 42 | else: 43 | detail = "\n".join(v for v in error_data.values()) 44 | raise web.HTTPBadRequest( 45 | content_type="application/json", 46 | body=json.dumps({"msg": "Invalid parameters", "detail": detail}), 47 | ) from None 48 | except Exception as e: 49 | raise web.HTTPInternalServerError( 50 | content_type="application/json", 51 | body=json.dumps({"msg": "Internal server error", "detail": repr(e)}), 52 | ) from e 53 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: Linters 4 | 5 | on: # yamllint disable-line rule:truthy 6 | workflow_call: 7 | workflow_dispatch: 8 | 9 | env: 10 | FORCE_COLOR: "1" # Make tools pretty. 11 | PIP_DISABLE_PIP_VERSION_CHECK: "1" 12 | PIP_NO_PYTHON_VERSION_WARNING: "1" 13 | PYTHON_LATEST: "3.11" 14 | 15 | jobs: 16 | 17 | lint-ruff: 18 | name: Lint with Ruff 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout the source code 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | - name: Set up Python 26 | uses: actions/setup-python@v5 27 | with: 28 | python-version: ${{ env.PYTHON_LATEST }} 29 | check-latest: true 30 | cache: pip 31 | cache-dependency-path: | 32 | setup.cfg 33 | requirements-dev.txt 34 | - name: Install dependencies 35 | run: | 36 | pip install -U -r requirements-dev.txt 37 | - name: Lint with Ruff 38 | run: | 39 | python -m ruff check 40 | 41 | typecheck-mypy: 42 | name: Check typing and annotations 43 | runs-on: ubuntu-latest 44 | steps: 45 | - name: Checkout the source code 46 | uses: actions/checkout@v4 47 | with: 48 | fetch-depth: 0 49 | - name: Set up Python 50 | uses: actions/setup-python@v5 51 | with: 52 | python-version: ${{ env.PYTHON_LATEST }} 53 | check-latest: true 54 | cache: pip 55 | cache-dependency-path: | 56 | setup.cfg 57 | requirements-dev.txt 58 | - name: Install dependencies 59 | run: | 60 | pip install -U -r requirements-dev.txt 61 | - name: Typecheck with mypy 62 | run: | 63 | echo "::add-matcher::.github/workflows/mypy-matcher.json" 64 | python -m mypy aiomonitor/ examples/ tests/ 65 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Feature request 3 | description: Suggest an idea for this project. 4 | labels: enhancement 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | **Thanks for taking a minute to file a feature for aiomonitor!** 10 | 11 | ⚠ 12 | Verify first that your feature request is not [already reported on 13 | GitHub][issue search]. 14 | 15 | _Please fill out the form below with as many precise 16 | details as possible._ 17 | 18 | [issue search]: ../search?q=is%3Aissue&type=issues 19 | 20 | - type: textarea 21 | attributes: 22 | label: Is your feature request related to a problem? 23 | description: >- 24 | Please add a clear and concise description of what 25 | the problem is. _Ex. I'm always frustrated when [...]_ 26 | 27 | - type: textarea 28 | attributes: 29 | label: Describe the solution you'd like 30 | description: >- 31 | A clear and concise description of what you want to happen. 32 | validations: 33 | required: true 34 | 35 | - type: textarea 36 | attributes: 37 | label: Describe alternatives you've considered 38 | description: >- 39 | A clear and concise description of any alternative solutions 40 | or features you've considered. 41 | validations: 42 | required: true 43 | 44 | - type: textarea 45 | attributes: 46 | label: Additional context 47 | description: >- 48 | Add any other context or screenshots about 49 | the feature request here. 50 | 51 | - type: checkboxes 52 | attributes: 53 | label: Code of Conduct 54 | description: | 55 | Read the [aio-libs Code of Conduct][CoC] first. 56 | 57 | [CoC]: https://github.com/aio-libs/.github/blob/master/CODE_OF_CONDUCT.md 58 | options: 59 | - label: I agree to follow the aio-libs Code of Conduct 60 | required: true 61 | ... 62 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = aiomonitor 3 | version = attr: aiomonitor.__version__ 4 | author = Nikolay Novik 5 | author_email = nickolainovik@gmail.com 6 | maintainer = Joongi Kim 7 | maintainer_email = me@daybreaker.info 8 | description = Adds monitor and Python REPL capabilities for asyncio applications 9 | long_description = file: README.rst, CHANGES.rst 10 | keywords = asyncio, aiohttp, monitor, debugging, utility, devtool 11 | license = Apache-2.0 12 | license_files = LICENSE 13 | classifiers = 14 | License :: OSI Approved :: Apache Software License 15 | Intended Audience :: Developers 16 | Programming Language :: Python :: 3 17 | Programming Language :: Python :: 3.9 18 | Programming Language :: Python :: 3.10 19 | Programming Language :: Python :: 3.11 20 | Programming Language :: Python :: 3.12 21 | Programming Language :: Python :: 3.13 22 | Operating System :: POSIX 23 | Development Status :: 3 - Alpha 24 | Framework :: AsyncIO 25 | platforms = POSIX 26 | download_url = https://pypi.org/project/aiomonitor 27 | project_urls = 28 | Homepage = https://github.com/aio-libs/aiomonitor 29 | Documentation = https://aiomonitor.readthedocs.io 30 | Repository = https://github.com/aio-libs/aiomonitor 31 | Issues = https://github.com/aio-libs/aiomonitor/issues 32 | Changelog = https://github.com/aio-libs/aiomonitor/blob/main/CHANGES.rst 33 | Chat = https://matrix.to/#/!aio-libs:matrix.org 34 | 35 | [options] 36 | zip_safe = False 37 | include_package_data = True 38 | packages = find: 39 | python_requires = >=3.8 40 | install_requires = 41 | attrs>=20 42 | aiohttp>=3.8.5 43 | click>=8.0 44 | janus>=1.0 45 | jinja2>=3.1.2 46 | backports.strenum>=1.2.4; python_version<"3.11" 47 | terminaltables 48 | trafaret>=2.1.1 49 | typing-extensions>=4.1 50 | prompt_toolkit>=3.0 51 | aioconsole>=0.7.0 52 | telnetlib3>=2.0.4 53 | 54 | [options.packages.find] 55 | exclude = 56 | examples/* 57 | tools/* 58 | .*.yml 59 | *.egg-info 60 | -------------------------------------------------------------------------------- /examples/web_srv_custom_monitor.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Optional 3 | 4 | import click 5 | import requests 6 | import uvloop 7 | from aiohttp import web 8 | 9 | import aiomonitor 10 | from aiomonitor.termui.commands import ( 11 | auto_command_done, 12 | custom_help_option, 13 | monitor_cli, 14 | ) 15 | 16 | 17 | async def simple(request: web.Request) -> web.Response: 18 | await asyncio.sleep(10) 19 | return web.Response(text="Simple answer") 20 | 21 | 22 | async def hello(request: web.Request) -> web.StreamResponse: 23 | resp = web.StreamResponse() 24 | name = request.match_info.get("name", "Anonymous") 25 | answer = ("Hello, " + name).encode("utf8") 26 | resp.content_length = len(answer) 27 | resp.content_type = "text/plain" 28 | await resp.prepare(request) 29 | await asyncio.sleep(10) 30 | await resp.write(answer) 31 | await resp.write_eof() 32 | return resp 33 | 34 | 35 | @monitor_cli.command(name="hello") 36 | @click.argument("name", optional=True) 37 | @custom_help_option 38 | @auto_command_done 39 | def do_hello(ctx: click.Context, name: Optional[str] = None) -> None: 40 | """Using the /hello GET interface 41 | 42 | There is one optional argument, "name". This name argument must be 43 | provided with proper URL escape codes, like %20 for spaces. 44 | """ 45 | name = "" if name is None else "/" + name 46 | r = requests.get("http://localhost:8090/hello" + name) 47 | click.echo(r.text + "\n") 48 | 49 | 50 | async def main() -> None: 51 | loop = asyncio.get_running_loop() 52 | app = web.Application() 53 | app.router.add_get("/simple", simple) 54 | app.router.add_get("/hello/{name}", hello) 55 | app.router.add_get("/hello", hello) 56 | host, port = "localhost", 8090 57 | with aiomonitor.start_monitor(loop, locals=locals()): 58 | web.run_app(app, port=port, host=host) 59 | 60 | 61 | if __name__ == "__main__": 62 | uvloop.install() 63 | try: 64 | asyncio.run(main()) 65 | except KeyboardInterrupt: 66 | pass 67 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. aiomonitor documentation master file, created by 2 | sphinx-quickstart on Sun Dec 11 17:08:38 2016. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | aiomonitor's documentation! 7 | =========================== 8 | 9 | **aiomonitor** is Python 3.9+ module that adds monitor and cli capabilities 10 | for :mod:`asyncio` application. Idea and code borrowed from curio_ project. 11 | Task monitor that runs concurrently to the :mod:`asyncio` loop (or fast drop in 12 | replacement uvloop_) in a separate thread. This can inspect the loop and 13 | provide debugging capabilities. 14 | 15 | aiomonitor provides an python console using aioconsole_ library, it is possible 16 | to execute asynchronous command inside your running application. 17 | 18 | As of 0.6.0, it also provides a GUI to inspect and cancel asyncio tasks like below: 19 | 20 | +----------------------------+ 21 | | .. image:: webui-demo1.gif | 22 | +----------------------------+ 23 | 24 | 25 | Features 26 | -------- 27 | * Telnet server that provides insides of operation of you app 28 | 29 | * Supportes several commands that helps to list, cancel and trace running 30 | :mod:`asyncio` tasks 31 | 32 | * Provides python REPL capabilities, that is executed in the running event loop; 33 | helps to inspect state of your :mod:`asyncio` application. 34 | 35 | * Extensible with you own commands using :mod:`click`. 36 | 37 | Contents 38 | -------- 39 | 40 | .. toctree:: 41 | :maxdepth: 2 42 | 43 | tutorial 44 | examples 45 | api 46 | contributing 47 | 48 | 49 | Indices and tables 50 | ================== 51 | 52 | * :ref:`genindex` 53 | * :ref:`modindex` 54 | * :ref:`search` 55 | 56 | 57 | .. _PEP492: https://www.python.org/dev/peps/pep-0492/ 58 | .. _Python: https://www.python.org 59 | .. _aioconsole: https://github.com/vxgmichel/aioconsole 60 | .. _aiohttp: https://github.com/KeepSafe/aiohttp 61 | .. _curio: https://github.com/dabeaz/curio 62 | .. _uvloop: https://github.com/MagicStack/uvloop 63 | .. _cmd: http://docs.python.org/3/library/cmd.html 64 | -------------------------------------------------------------------------------- /examples/extension.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import click 4 | 5 | import aiomonitor 6 | from aiomonitor.termui.commands import ( 7 | auto_async_command_done, 8 | auto_command_done, 9 | print_fail, 10 | print_ok, 11 | ) 12 | 13 | 14 | @aiomonitor.monitor_cli.command(name="hello") 15 | @auto_command_done 16 | def do_hello(ctx: click.Context) -> None: 17 | """ 18 | Command extension example 19 | """ 20 | # To send outputs to the telnet client, 21 | # you should use either: 22 | 23 | click.echo("Hello, world!") 24 | 25 | # or: 26 | 27 | from aiomonitor.termui.commands import current_stdout 28 | 29 | stdout = current_stdout.get() 30 | print("Hello, world, again!", file=stdout) 31 | 32 | # or: 33 | 34 | from prompt_toolkit import print_formatted_text 35 | from prompt_toolkit.formatted_text import FormattedText 36 | 37 | print_formatted_text( 38 | FormattedText([ 39 | ("ansibrightblue", "Hello, "), 40 | ("ansibrightyellow", "world, "), 41 | ("ansibrightmagenta", "with color!"), 42 | ]) 43 | ) 44 | 45 | # or: 46 | 47 | print_ok("Hello, world, success!") 48 | print_fail("Hello, world, failure!") 49 | 50 | 51 | @aiomonitor.monitor_cli.command(name="hello-async") 52 | def do_async_hello(ctx: click.Context) -> None: 53 | """ 54 | Command extension example (async) 55 | """ 56 | 57 | @auto_async_command_done 58 | async def _do_async_hello(ctx: click.Context): 59 | await asyncio.sleep(1) 60 | click.echo("Hello, world, after sleep!") 61 | 62 | asyncio.create_task(_do_async_hello(ctx)) 63 | 64 | 65 | async def main(): 66 | loop = asyncio.get_running_loop() 67 | with aiomonitor.start_monitor(loop=loop) as monitor: 68 | print("Now you can connect with:") 69 | print(f" python -m aiomonitor.cli -H {monitor.host} -p {monitor.port}") 70 | print("or") 71 | print(f" telnet {monitor.host} {monitor.port} # Linux only") 72 | # run forever until interrupted 73 | while True: 74 | await asyncio.sleep(60) 75 | 76 | 77 | if __name__ == "__main__": 78 | try: 79 | asyncio.run(main()) 80 | except KeyboardInterrupt: 81 | pass 82 | -------------------------------------------------------------------------------- /aiomonitor/termui/completion.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import signal 5 | from typing import TYPE_CHECKING, Iterable, List 6 | 7 | import click 8 | from click.shell_completion import ( 9 | CompletionItem, 10 | _resolve_context, 11 | _resolve_incomplete, 12 | split_arg_string, 13 | ) 14 | from prompt_toolkit.completion import CompleteEvent, Completer, Completion 15 | from prompt_toolkit.document import Document 16 | 17 | from ..context import current_monitor 18 | 19 | if TYPE_CHECKING: 20 | from ..monitor import Monitor 21 | 22 | 23 | class ClickCompleter(Completer): 24 | def __init__(self, root_command: click.Command) -> None: 25 | self._root_command = root_command 26 | 27 | def get_completions( 28 | self, 29 | document: Document, 30 | complete_event: CompleteEvent, 31 | ) -> Iterable[Completion]: 32 | args = split_arg_string(document.current_line) 33 | incomplete = document.get_word_under_cursor() 34 | if incomplete and args and args[-1] == incomplete: 35 | args.pop() 36 | click_ctx = _resolve_context(self._root_command, {}, "", args) 37 | cmd_or_param, incomplete = _resolve_incomplete(click_ctx, args, incomplete) 38 | completions: List[CompletionItem] = cmd_or_param.shell_complete( 39 | click_ctx, incomplete 40 | ) 41 | start_position = document.find_backwards(incomplete) or 0 42 | for completion in completions: 43 | yield Completion( 44 | completion.value, 45 | start_position=start_position, 46 | ) 47 | 48 | 49 | def complete_task_id( 50 | ctx: click.Context, 51 | param: click.Parameter, 52 | incomplete: str, 53 | ) -> Iterable[str]: 54 | # ctx here is created in the completer and does not have ctx.obj set as monitor. 55 | # We take the monitor instance from the global context variable instead. 56 | try: 57 | self: Monitor = current_monitor.get() 58 | except LookupError: 59 | return [] 60 | return [ 61 | task_id 62 | for task_id in map( 63 | str, sorted(map(id, asyncio.all_tasks(loop=self._monitored_loop))) 64 | ) 65 | if task_id.startswith(incomplete) 66 | ][:10] 67 | 68 | 69 | def complete_trace_id( 70 | ctx: click.Context, 71 | param: click.Parameter, 72 | incomplete: str, 73 | ) -> Iterable[str]: 74 | try: 75 | self: Monitor = current_monitor.get() 76 | except LookupError: 77 | return [] 78 | return [ 79 | trace_id 80 | for trace_id in map(str, sorted(self._terminated_tasks.keys())) 81 | if trace_id.startswith(incomplete) 82 | ][:10] 83 | 84 | 85 | def complete_signal_names( 86 | ctx: click.Context, 87 | param: click.Parameter, 88 | incomplete: str, 89 | ) -> Iterable[str]: 90 | return [sig.name for sig in signal.Signals if sig.name.startswith(incomplete)] 91 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | description: Create a report to help us improve. 4 | labels: [bug] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | **Thanks for taking a minute to file a bug report!** 10 | 11 | ⚠ 12 | Verify first that your issue is not [already reported on 13 | GitHub][issue search]. 14 | 15 | _Please fill out the form below with as many precise 16 | details as possible._ 17 | 18 | [issue search]: ../search?q=is%3Aissue&type=issues 19 | 20 | - type: textarea 21 | attributes: 22 | label: Describe the bug 23 | description: >- 24 | A clear and concise description of what the bug is. 25 | validations: 26 | required: true 27 | 28 | - type: textarea 29 | attributes: 30 | label: To Reproduce 31 | description: >- 32 | Describe the steps to reproduce this bug. 33 | placeholder: | 34 | 1. Have certain environment 35 | 2. Then run '...' 36 | 3. An error occurs. 37 | validations: 38 | required: true 39 | 40 | - type: textarea 41 | attributes: 42 | label: Expected behavior 43 | description: >- 44 | A clear and concise description of what you expected to happen. 45 | validations: 46 | required: true 47 | 48 | - type: textarea 49 | attributes: 50 | label: Logs/tracebacks 51 | description: | 52 | If applicable, add logs/tracebacks to help explain your problem. 53 | Paste the output of the steps above, including the commands 54 | themselves and their output/traceback etc. 55 | render: python-traceback 56 | validations: 57 | required: true 58 | 59 | - type: textarea 60 | attributes: 61 | label: Python Version 62 | description: Attach your version of Python. 63 | render: console 64 | value: | 65 | $ python --version 66 | validations: 67 | required: true 68 | - type: textarea 69 | attributes: 70 | label: aiomonitor Version 71 | description: Attach your version of aiomonitor. 72 | render: console 73 | value: | 74 | $ python -m pip show aiomonitor 75 | validations: 76 | required: true 77 | 78 | - type: textarea 79 | attributes: 80 | label: OS 81 | placeholder: >- 82 | For example, Arch Linux, Windows, macOS, etc. 83 | validations: 84 | required: true 85 | 86 | - type: textarea 87 | attributes: 88 | label: Additional context 89 | description: | 90 | Add any other context about the problem here. 91 | 92 | Describe the environment you have that lead to your issue. 93 | 94 | - type: checkboxes 95 | attributes: 96 | label: Code of Conduct 97 | description: | 98 | Read the [aio-libs Code of Conduct][CoC] first. 99 | 100 | [CoC]: https://github.com/aio-libs/.github/blob/master/CODE_OF_CONDUCT.md 101 | options: 102 | - label: I agree to follow the aio-libs Code of Conduct 103 | required: true 104 | ... 105 | -------------------------------------------------------------------------------- /examples/cancellation.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiomonitor 4 | from aiomonitor.task import preserve_termination_log 5 | 6 | 7 | @preserve_termination_log 8 | async def should_have_run_until_exit(): 9 | print("should_have_run_until_exit: begin") 10 | await asyncio.sleep(5) 11 | print("should_have_run_until_exit: cancel") 12 | raise asyncio.CancelledError("cancelled-suspiciously") 13 | 14 | 15 | async def chain3(): 16 | await asyncio.sleep(10) 17 | 18 | 19 | async def chain2(): 20 | t = asyncio.create_task(chain3()) 21 | try: 22 | await asyncio.sleep(10) 23 | finally: 24 | t.cancel() 25 | await t 26 | 27 | 28 | async def chain1(): 29 | t = asyncio.create_task(chain2()) 30 | try: 31 | await asyncio.sleep(10) 32 | finally: 33 | t.cancel() 34 | await t 35 | 36 | 37 | async def chain_main(): 38 | try: 39 | while True: 40 | t = asyncio.create_task(chain1()) 41 | await asyncio.sleep(0.5) 42 | t.cancel() 43 | finally: 44 | print("terminating chain_main") 45 | 46 | 47 | async def self_cancel_main(): 48 | async def do_self_cancel(tick): 49 | await asyncio.sleep(tick) 50 | raise asyncio.CancelledError("self-cancelled") 51 | 52 | try: 53 | while True: 54 | await asyncio.sleep(0.21) 55 | asyncio.create_task(do_self_cancel(0.2)) 56 | finally: 57 | print("terminating self_cancel_main") 58 | 59 | 60 | async def unhandled_exc_main(): 61 | async def do_unhandled(tick): 62 | await asyncio.sleep(tick) 63 | return 1 / 0 64 | 65 | try: 66 | while True: 67 | try: 68 | await asyncio.create_task(do_unhandled(0.2)) 69 | except ZeroDivisionError: 70 | continue 71 | finally: 72 | print("terminating unhandled_exc_main") 73 | 74 | 75 | async def main(): 76 | loop = asyncio.get_running_loop() 77 | with aiomonitor.start_monitor( 78 | loop, 79 | hook_task_factory=True, 80 | max_termination_history=10, 81 | ): 82 | asyncio.create_task(should_have_run_until_exit()) 83 | chain_main_task = asyncio.create_task(chain_main()) 84 | self_cancel_main_task = asyncio.create_task(self_cancel_main()) 85 | unhandled_exc_main_task = asyncio.create_task(unhandled_exc_main()) 86 | try: 87 | print("running some test workloads for 10 seconds ...") 88 | await asyncio.sleep(10) 89 | finally: 90 | print("stopping ...") 91 | chain_main_task.cancel() 92 | self_cancel_main_task.cancel() 93 | unhandled_exc_main_task.cancel() 94 | await asyncio.gather( 95 | chain_main_task, 96 | self_cancel_main_task, 97 | unhandled_exc_main_task, 98 | return_exceptions=True, 99 | ) 100 | print("waiting inspection ...") 101 | while True: 102 | await asyncio.sleep(10) 103 | 104 | 105 | if __name__ == "__main__": 106 | try: 107 | asyncio.run(main()) 108 | except KeyboardInterrupt: 109 | print("terminated") 110 | -------------------------------------------------------------------------------- /aiomonitor/task.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import base64 3 | import functools 4 | import struct 5 | import sys 6 | import time 7 | import traceback 8 | import weakref 9 | from asyncio.coroutines import _format_coroutine # type: ignore 10 | from typing import Any, Callable, Coroutine, List, Optional, TypeVar 11 | 12 | import janus 13 | from typing_extensions import ParamSpec 14 | 15 | from .types import CancellationChain, TerminatedTaskInfo 16 | from .utils import _extract_stack_from_frame 17 | 18 | __all__ = ( 19 | "TracedTask", 20 | "preserve_termination_log", 21 | "persistent_coro", 22 | ) 23 | 24 | persistent_coro: "weakref.WeakSet[Coroutine]" = weakref.WeakSet() 25 | 26 | T = TypeVar("T") 27 | P = ParamSpec("P") 28 | 29 | 30 | class TracedTask(asyncio.Task): 31 | _orig_coro: Coroutine[Any, Any, Any] 32 | _termination_stack: Optional[List[traceback.FrameSummary]] 33 | 34 | def __init__( 35 | self, 36 | *args, 37 | termination_info_queue: janus._SyncQueueProxy[TerminatedTaskInfo], 38 | cancellation_chain_queue: janus._SyncQueueProxy[CancellationChain], 39 | persistent: bool = False, 40 | **kwargs, 41 | ) -> None: 42 | if sys.version_info < (3, 11): 43 | kwargs.pop("context") 44 | super().__init__(*args, **kwargs) 45 | self._termination_info_queue = termination_info_queue 46 | self._cancellation_chain_queue = cancellation_chain_queue 47 | self._started_at = time.perf_counter() 48 | self._termination_stack = None 49 | self.add_done_callback(self._trace_termination) 50 | self._persistent = persistent 51 | 52 | def get_trace_id(self) -> str: 53 | h = hash(( 54 | id(self), 55 | self.get_name(), 56 | )) 57 | b = struct.pack("P", h) 58 | return base64.b32encode(b).rstrip(b"=").decode() 59 | 60 | def _trace_termination(self, _: "asyncio.Task[Any]") -> None: 61 | self_id = self.get_trace_id() 62 | exc_repr = ( 63 | repr(self.exception()) 64 | if not self.cancelled() and self.exception() 65 | else None 66 | ) 67 | task_info = TerminatedTaskInfo( 68 | self_id, 69 | name=self.get_name(), 70 | coro=_format_coroutine(self._orig_coro).partition(" ")[0], 71 | started_at=self._started_at, 72 | terminated_at=time.perf_counter(), 73 | cancelled=self.cancelled(), 74 | termination_stack=self._termination_stack, 75 | canceller_stack=None, 76 | exc_repr=exc_repr, 77 | persistent=self._persistent, 78 | ) 79 | self._termination_info_queue.put_nowait(task_info) 80 | 81 | def cancel(self, msg: Optional[str] = None) -> bool: 82 | try: 83 | canceller_task = asyncio.current_task() 84 | except RuntimeError: 85 | canceller_task = None 86 | if canceller_task is not None and isinstance(canceller_task, TracedTask): 87 | canceller_stack = _extract_stack_from_frame(sys._getframe())[:-1] 88 | cancellation_chain = CancellationChain( 89 | self.get_trace_id(), 90 | canceller_task.get_trace_id(), 91 | canceller_stack, 92 | ) 93 | self._cancellation_chain_queue.put_nowait(cancellation_chain) 94 | return super().cancel(msg) 95 | 96 | 97 | def preserve_termination_log( 98 | corofunc: Callable[P, Coroutine[Any, None, T]], 99 | ) -> Callable[P, Coroutine[Any, None, T]]: 100 | """ 101 | Guard the given coroutine function from being stripped out due to the max history 102 | limit when created as TracedTask. 103 | """ 104 | 105 | @functools.wraps(corofunc) 106 | def inner(*args: P.args, **kwargs: P.kwargs) -> Coroutine[Any, None, T]: 107 | coro = corofunc(*args, **kwargs) 108 | persistent_coro.add(coro) 109 | return coro 110 | 111 | return inner 112 | -------------------------------------------------------------------------------- /examples/demo.py: -------------------------------------------------------------------------------- 1 | # NOTE: This example requires Python 3.11 or higher. 2 | # Use `requirements-dev.txt` to install dependencies. 3 | 4 | from __future__ import annotations 5 | 6 | import argparse 7 | import asyncio 8 | import contextlib 9 | import enum 10 | import logging 11 | import weakref 12 | 13 | import uvloop 14 | from aiohttp import web 15 | 16 | import aiomonitor 17 | from aiomonitor.task import preserve_termination_log 18 | 19 | 20 | class LoopImpl(enum.StrEnum): 21 | ASYNCIO = "asyncio" 22 | UVLOOP = "uvloop" 23 | 24 | 25 | async def inner2() -> None: 26 | await asyncio.sleep(100) 27 | 28 | 29 | async def inner1(tg: asyncio.TaskGroup) -> None: 30 | t = tg.create_task(inner2()) 31 | await t 32 | 33 | 34 | @preserve_termination_log 35 | async def timer_loop() -> None: 36 | async def interval_work(): 37 | await asyncio.sleep(3.0) 38 | 39 | scope: weakref.WeakSet[asyncio.Task] = weakref.WeakSet() 40 | try: 41 | while True: 42 | await asyncio.sleep(1.0) 43 | if len(scope) < 10: 44 | t = asyncio.create_task(interval_work(), name="interval-worker") 45 | scope.add(t) 46 | except asyncio.CancelledError: 47 | for t in {*scope}: 48 | with contextlib.suppress(asyncio.CancelledError): 49 | t.cancel() 50 | await t 51 | 52 | 53 | async def simple(request: web.Request) -> web.Response: 54 | log = logging.getLogger("demo.aiohttp_server.simple") 55 | log.info("Start sleeping") 56 | async with asyncio.TaskGroup() as tg: 57 | tg.create_task(inner1(tg)) 58 | log.info("Finished sleeping") 59 | return web.Response(text="Simple answer") 60 | 61 | 62 | async def main( 63 | *, 64 | history_limit: int, 65 | num_timers: int, 66 | ) -> None: 67 | loop = asyncio.get_running_loop() 68 | log_timer_loop = logging.getLogger("demo.timer_loop") 69 | log_aiohttp_server = logging.getLogger("demo.aiohttp_server") 70 | with aiomonitor.start_monitor( 71 | loop, 72 | hook_task_factory=True, 73 | max_termination_history=history_limit, 74 | ): 75 | app = web.Application() 76 | app.router.add_get("/", simple) 77 | timer_loop_tasks = set() 78 | for idx in range(num_timers): 79 | timer_loop_tasks.add( 80 | asyncio.create_task(timer_loop(), name=f"timer-loop-{idx}") 81 | ) 82 | log_timer_loop.info("started a timer loop (%d)", idx) 83 | app["timer_loop_tasks"] = timer_loop_tasks 84 | 85 | async def _shutdown_timer_loop_task(app: web.Application) -> None: 86 | for idx, t in enumerate(app["timer_loop_tasks"]): 87 | log_timer_loop.info("shutting down a timer loop (%d)", idx) 88 | t.cancel() 89 | await t 90 | 91 | app.on_cleanup.append(_shutdown_timer_loop_task) # type: ignore[arg-type] 92 | log_aiohttp_server.info( 93 | "started a sample aiohttp server: http://localhost:8090/" 94 | ) 95 | await web._run_app(app, port=8090, host="localhost") 96 | 97 | 98 | if __name__ == "__main__": 99 | logging.basicConfig() 100 | logging.getLogger("aiomonitor").setLevel(logging.DEBUG) 101 | logging.getLogger("demo").setLevel(logging.DEBUG) 102 | parser = argparse.ArgumentParser() 103 | parser.add_argument( 104 | "--loop", 105 | type=LoopImpl, 106 | choices=list(LoopImpl), 107 | default=LoopImpl.ASYNCIO, 108 | help="The event loop implementation to use [default: asyncio]", 109 | ) 110 | parser.add_argument( 111 | "--history-limit", 112 | type=int, 113 | metavar="COUNT", 114 | default=10, 115 | help="Set the maximum number of task termination history [default: 10]", 116 | ) 117 | parser.add_argument( 118 | "--num-timers", 119 | type=int, 120 | metavar="COUNT", 121 | default=5, 122 | help="Set the number of timer loop tasks to demonstrate persistent termination logs [default: 5]", 123 | ) 124 | args = parser.parse_args() 125 | match args.loop: 126 | case LoopImpl.UVLOOP: 127 | uvloop.install() 128 | case _: 129 | pass 130 | try: 131 | asyncio.run( 132 | main( 133 | history_limit=args.history_limit, 134 | num_timers=args.num_timers, 135 | ) 136 | ) 137 | except KeyboardInterrupt: 138 | pass 139 | -------------------------------------------------------------------------------- /.github/workflows/ci-cd.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: # yamllint disable-line rule:truthy 5 | merge_group: 6 | push: 7 | branches: ["main"] 8 | pull_request: 9 | branches: ["main"] 10 | release: 11 | types: [created] 12 | branches: 13 | - 'main' 14 | workflow_dispatch: 15 | 16 | env: 17 | FORCE_COLOR: "1" # Make tools pretty. 18 | PIP_DISABLE_PIP_VERSION_CHECK: "1" 19 | PIP_NO_PYTHON_VERSION_WARNING: "1" 20 | PYTHON_LATEST: "3.13" 21 | 22 | # For re-actors/checkout-python-sdist 23 | sdist-artifact: python-package-distributions 24 | 25 | jobs: 26 | 27 | build-sdist: 28 | name: 📦 Build the source distribution 29 | runs-on: ubuntu-latest 30 | # if: github.event_name == 'release' && github.event.action == 'created' 31 | steps: 32 | - name: Checkout project 33 | uses: actions/checkout@v4 34 | with: 35 | fetch-depth: 0 36 | - name: Set up Python 37 | uses: actions/setup-python@v5 38 | with: 39 | python-version: ${{ env.PYTHON_LATEST }} 40 | cache: pip 41 | - run: python -m pip install build 42 | name: Install core libraries for build and install 43 | - name: Build artifacts 44 | run: python -m build 45 | - name: Upload built artifacts for testing 46 | uses: actions/upload-artifact@v5 47 | with: 48 | name: ${{ env.sdist-artifact }} 49 | # NOTE: Exact expected file names are specified here 50 | # NOTE: as a safety measure — if anything weird ends 51 | # NOTE: up being in this dir or not all dists will be 52 | # NOTE: produced, this will fail the workflow. 53 | path: dist/${{ env.sdist-name }} 54 | retention-days: 15 55 | 56 | lint: 57 | uses: ./.github/workflows/lint.yml 58 | 59 | test-pytest: 60 | name: Tests on ${{ matrix.python-version }} 61 | needs: build-sdist 62 | runs-on: ubuntu-latest 63 | continue-on-error: ${{ matrix.experimental }} 64 | strategy: 65 | fail-fast: true 66 | matrix: 67 | python-version: 68 | - "3.9" 69 | - "3.10" 70 | - "3.11" 71 | - "3.12" 72 | - "3.13" 73 | experimental: [false] 74 | # include: 75 | # - python-version: "~3.12.0-0" 76 | # experimental: true 77 | steps: 78 | - name: Checkout the source code 79 | uses: actions/checkout@v4 80 | with: 81 | fetch-depth: 0 82 | - name: Set up Python 83 | uses: actions/setup-python@v5 84 | with: 85 | python-version: ${{ matrix.python-version }} 86 | cache: pip 87 | cache-dependency-path: | 88 | setup.cfg 89 | requirements-dev.txt 90 | - name: Install dependencies 91 | run: | 92 | pip install -U -r requirements-dev.txt 93 | - name: Run tests 94 | run: python -m pytest --cov=aiomonitor -v tests 95 | - name: Upload coverage data 96 | uses: codecov/codecov-action@v5 97 | 98 | check: # This job does nothing and is only used for the branch protection 99 | name: ✅ Ensure the required checks passing 100 | if: always() 101 | needs: [build-sdist, lint, test-pytest] 102 | runs-on: ubuntu-latest 103 | steps: 104 | - name: Decide whether the needed jobs succeeded or failed 105 | uses: re-actors/alls-green@release/v1 106 | with: 107 | jobs: ${{ toJSON(needs) }} 108 | 109 | publish: # Run only on creating release for new tag 110 | name: 📦 Publish to PyPI 111 | runs-on: ubuntu-latest 112 | needs: check 113 | if: github.event_name == 'release' && github.event.action == 'created' 114 | 115 | permissions: 116 | contents: write # IMPORTANT: mandatory for making GitHub Releases 117 | id-token: write # IMPORTANT: mandatory for trusted publishing & sigstore 118 | 119 | environment: 120 | name: pypi 121 | url: https://pypi.org/p/aiomonitor 122 | 123 | steps: 124 | - name: Download the sdist artifact 125 | uses: actions/download-artifact@v6 126 | with: 127 | name: ${{ env.sdist-artifact }} 128 | path: dist 129 | 130 | - name: >- 131 | Publish 🐍📦 to PyPI 132 | uses: pypa/gh-action-pypi-publish@release/v1 133 | 134 | - name: Sign the dists with Sigstore 135 | uses: sigstore/gh-action-sigstore-python@v3.1.0 136 | with: 137 | inputs: >- 138 | ./dist/*.tar.gz 139 | ./dist/*.whl 140 | 141 | - name: Upload artifact signatures to GitHub Release 142 | # Confusingly, this action also supports updating releases, not 143 | # just creating them. This is what we want here, since we've manually 144 | # created the release above. 145 | uses: softprops/action-gh-release@v2 146 | with: 147 | # dist/ contains the built packages, which smoketest-artifacts/ 148 | # contains the signatures and certificates. 149 | files: dist/** 150 | -------------------------------------------------------------------------------- /aiomonitor/console.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from typing import Any, Dict, Optional 5 | 6 | import aioconsole 7 | from prompt_toolkit import PromptSession 8 | from prompt_toolkit.input.base import Input 9 | from prompt_toolkit.output.base import Output 10 | 11 | 12 | class ConsoleProxy: 13 | # Runs inside the monitored event loop 14 | 15 | def __init__( 16 | self, 17 | stdin: Input, 18 | stdout: Output, 19 | host: str, 20 | port: int, 21 | ) -> None: 22 | self._host = host 23 | self._port = port 24 | self._stdin = stdin 25 | self._stdout = stdout 26 | 27 | async def __aenter__(self) -> ConsoleProxy: 28 | self._stdout.write_raw("\r\n") 29 | self._conn_reader, self._conn_writer = await asyncio.open_connection( 30 | self._host, self._port 31 | ) 32 | self._closed = asyncio.Event() 33 | self._recv_task = asyncio.create_task(self._handle_received()) 34 | self._input_task = asyncio.create_task(self._handle_user_input()) 35 | return self 36 | 37 | async def __aexit__(self, *exc_info) -> Optional[bool]: 38 | self._input_task.cancel() 39 | await self._input_task 40 | self._conn_writer.close() 41 | try: 42 | await self._conn_writer.wait_closed() 43 | except (NotImplementedError, ConnectionResetError): 44 | pass 45 | self._conn_reader.feed_eof() 46 | await self._recv_task 47 | return None 48 | 49 | async def interact(self) -> None: 50 | try: 51 | await self._closed.wait() 52 | finally: 53 | self._closed.set() 54 | if not self._conn_writer.is_closing(): 55 | self._conn_writer.write_eof() 56 | 57 | async def _handle_user_input(self) -> None: 58 | prompt_session: PromptSession[str] = PromptSession( 59 | input=self._stdin, output=self._stdout 60 | ) 61 | try: 62 | while not self._conn_reader.at_eof(): 63 | try: 64 | user_input = await prompt_session.prompt_async("") 65 | self._conn_writer.write(user_input.encode("utf8")) 66 | self._conn_writer.write(b"\n") 67 | await self._conn_writer.drain() 68 | except KeyboardInterrupt: 69 | # Send Ctrl+C to the console server. 70 | self._conn_writer.write(b"\x03") 71 | await self._conn_writer.drain() 72 | except EOFError: 73 | return 74 | except asyncio.CancelledError: 75 | pass 76 | finally: 77 | self._closed.set() 78 | 79 | async def _handle_received(self) -> None: 80 | try: 81 | while True: 82 | buf = await self._conn_reader.read(1024) 83 | if not buf: 84 | return 85 | self._stdout.write_raw(buf.decode("utf8")) 86 | self._stdout.flush() 87 | except (ConnectionResetError, asyncio.CancelledError): 88 | pass 89 | finally: 90 | self._closed.set() 91 | 92 | 93 | async def start( 94 | host: str, 95 | port: int, 96 | locals: Optional[Dict[str, Any]], 97 | monitor_loop: asyncio.AbstractEventLoop, 98 | ) -> asyncio.AbstractServer: 99 | async def _start() -> asyncio.AbstractServer: 100 | # Runs inside the monitored event loop 101 | def _factory(streams: Any = None) -> aioconsole.AsynchronousConsole: 102 | return aioconsole.AsynchronousConsole( 103 | locals=locals, streams=streams, loop=monitor_loop 104 | ) 105 | 106 | server = await aioconsole.start_interactive_server( 107 | host=host, 108 | port=port, 109 | factory=_factory, 110 | loop=monitor_loop, 111 | ) 112 | return server 113 | 114 | console_future = asyncio.wrap_future( 115 | asyncio.run_coroutine_threadsafe( 116 | _start(), 117 | loop=monitor_loop, 118 | ) 119 | ) 120 | return await console_future 121 | 122 | 123 | async def close( 124 | server: asyncio.AbstractServer, 125 | monitor_loop: asyncio.AbstractEventLoop, 126 | ) -> None: 127 | async def _close() -> None: 128 | # Runs inside the monitored event loop 129 | try: 130 | server.close() 131 | await server.wait_closed() 132 | except NotImplementedError: 133 | pass 134 | 135 | close_future = asyncio.wrap_future( 136 | asyncio.run_coroutine_threadsafe( 137 | _close(), 138 | loop=monitor_loop, 139 | ) 140 | ) 141 | await close_future 142 | 143 | 144 | async def proxy(sin: Input, sout: Output, host: str, port: int) -> None: 145 | async with ConsoleProxy(sin, sout, host, port) as proxy: 146 | await proxy.interact() 147 | -------------------------------------------------------------------------------- /aiomonitor/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import inspect 5 | import linecache 6 | import sys 7 | import traceback 8 | from asyncio.coroutines import _format_coroutine # type: ignore 9 | from datetime import timedelta 10 | from pathlib import Path 11 | from types import FrameType 12 | from typing import Any, List, Set 13 | 14 | from .types import TerminatedTaskInfo 15 | 16 | 17 | def _format_task(task: "asyncio.Task[Any]") -> str: 18 | """ 19 | A simpler version of task's repr() 20 | """ 21 | coro = _format_coroutine(task.get_coro()).partition(" ")[0] 22 | return f"" 23 | 24 | 25 | def _format_terminated_task(tinfo: TerminatedTaskInfo) -> str: 26 | """ 27 | A simpler version of task's repr() 28 | """ 29 | status = [] 30 | if tinfo.cancelled: 31 | status.append("cancelled") 32 | if tinfo.exc_repr and not tinfo.cancelled: 33 | status.append(f"exc={tinfo.exc_repr}") 34 | return f"" 35 | 36 | 37 | def _format_filename(filename: str) -> str: 38 | """ 39 | Simplifies the site-pkg directory path of the given source filename. 40 | """ 41 | stdlib = ( 42 | f"{sys.prefix}/lib/python{sys.version_info.major}.{sys.version_info.minor}/" 43 | ) 44 | site_pkg = f"{sys.prefix}/lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages/" 45 | home = f"{Path.home()}/" 46 | cwd = f"{Path.cwd()}/" 47 | if filename.startswith(site_pkg): 48 | return "/" + filename[len(site_pkg) :] 49 | if filename.startswith(stdlib): 50 | return "/" + filename[len(stdlib) :] 51 | if filename.startswith(cwd): 52 | return "/" + filename[len(cwd) :] 53 | if filename.startswith(home): 54 | return "/" + filename[len(home) :] 55 | return filename 56 | 57 | 58 | def _format_timedelta(td: timedelta) -> str: 59 | seconds = int(td.total_seconds()) 60 | periods = [ 61 | ("y", 60 * 60 * 24 * 365), 62 | ("m", 60 * 60 * 24 * 30), 63 | ("d", 60 * 60 * 24), 64 | ("h", 60 * 60), 65 | (":", 60), 66 | ("", 1), 67 | ] 68 | parts = [] 69 | for period_name, period_seconds in periods: 70 | period_value, seconds = divmod(seconds, period_seconds) 71 | if period_name in (":", ""): 72 | parts.append(f"{period_value:02d}{period_name}") 73 | else: 74 | if period_value == 0: 75 | continue 76 | parts.append(f"{period_value}{period_name}") 77 | parts.append(f"{td.microseconds / 1e6:.03f}"[1:]) 78 | return "".join(parts) 79 | 80 | 81 | def _filter_stack( 82 | stack: List[traceback.FrameSummary], 83 | ) -> List[traceback.FrameSummary]: 84 | """ 85 | Filters out commonly repeated frames of the asyncio internals from the given stack. 86 | """ 87 | # strip the task factory frame in the vanilla event loop 88 | if ( 89 | stack[-1].filename.endswith("asyncio/base_events.py") 90 | and stack[-1].name == "create_task" 91 | ): 92 | stack = stack[:-1] 93 | # strip the loop.create_task frame 94 | if ( 95 | stack[-1].filename.endswith("asyncio/tasks.py") 96 | and stack[-1].name == "create_task" 97 | ): 98 | stack = stack[:-1] 99 | _cut_idx = 0 100 | for _cut_idx, f in reversed(list(enumerate(stack))): 101 | # uvloop 102 | if f.filename.endswith("asyncio/runners.py") and f.name == "run": 103 | break 104 | # vanilla 105 | if f.filename.endswith("asyncio/events.py") and f.name == "_run": 106 | break 107 | return stack[_cut_idx + 1 :] 108 | 109 | 110 | def _extract_stack_from_task( 111 | task: "asyncio.Task[Any]", 112 | ) -> List[traceback.FrameSummary]: 113 | """ 114 | Extracts the stack as a list of FrameSummary objects from an asyncio task. 115 | """ 116 | frames: List[Any] = [] 117 | coro = task._coro # type: ignore 118 | while coro: 119 | f = getattr(coro, "cr_frame", getattr(coro, "gi_frame", None)) 120 | if f is not None: 121 | frames.append(f) 122 | coro = getattr(coro, "cr_await", getattr(coro, "gi_yieldfrom", None)) 123 | extracted_list = [] 124 | checked: Set[str] = set() 125 | for f in frames: 126 | lineno = f.f_lineno 127 | co = f.f_code 128 | filename = co.co_filename 129 | name = co.co_name 130 | if filename not in checked: 131 | checked.add(filename) 132 | linecache.checkcache(filename) 133 | line = linecache.getline(filename, lineno, f.f_globals) 134 | extracted_list.append(traceback.FrameSummary(filename, lineno, name, line=line)) 135 | return extracted_list 136 | 137 | 138 | def _extract_stack_from_frame(frame: FrameType) -> List[traceback.FrameSummary]: 139 | stack = traceback.StackSummary.extract(traceback.walk_stack(frame)) 140 | stack.reverse() 141 | return stack 142 | 143 | 144 | def _extract_stack_from_exception(e: BaseException) -> List[traceback.FrameSummary]: 145 | stack = traceback.StackSummary.extract(traceback.walk_tb(e.__traceback__)) 146 | stack.reverse() 147 | return stack 148 | 149 | 150 | def get_default_args(func): 151 | signature = inspect.signature(func) 152 | return { 153 | k: v.default 154 | for k, v in signature.parameters.items() 155 | if v.default is not inspect.Parameter.empty 156 | } 157 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | CHANGES 2 | ======= 3 | 4 | .. towncrier release notes start 5 | 6 | 0.7.1 (2024-11-12) 7 | ------------------ 8 | 9 | - Added Python 3.13 support by replacing telnetlib with telnetlib3 and dropped Python 3.8 support 10 | (`#411 `_) 11 | 12 | 13 | 0.7.0 (2023-12-21) 14 | ------------------ 15 | 16 | - Overhauled the documentation 17 | (`#393 `_) 18 | 19 | - Adopted ruff to replace black, flake8 and isort 20 | (`#391 `_) 21 | 22 | - Added a new demo example to show various features of aiomonitor, especially using the GUI (also for PyCon APAC 2023 talk) 23 | (`#385 `_) 24 | 25 | - Relaxed our direct dependnecy version range of aiohttp ("3.8.5 only" to "3.8.5 and higher") to enable installation on Python 3.12 26 | (`#389 `_) 27 | 28 | - Updated the README example to conform with the latest API and convention 29 | (`#383 `_) 30 | 31 | 32 | 0.6.0 (2023-08-27) 33 | ------------------ 34 | 35 | - Add the web-based monitoring user interface to list, inspect, and cancel running/terminated tasks, with refactoring the monitor business logic and presentation layers (`termui` and `webui`) 36 | (`#84 `_) 37 | 38 | - Replace the default port numbers for the terminal UI, the web UI, and the console access (50101, 50201, 50102 -> 20101, 20102, 20103 respectively) 39 | (`#374 `_) 40 | 41 | - Adopt towncrier to auto-generate the changelog 42 | (`#375 `_) 43 | 44 | 45 | 0.5.0 (2023-07-21) 46 | ------------------ 47 | 48 | * Fix a regression in Python 3.10 due to #10 (`#11 `_) 49 | 50 | * Support Python 3.11 properly by allowing the optional (`name` and `context` kwargs passed to `asyncio.create_task()` in the hooked task factory function `#10 `_) 51 | 52 | * Update development dependencies 53 | 54 | * Selective persistent termination logs (`#9 `_) 55 | 56 | * Implement cancellation chain tracker (`#8 `_) 57 | 58 | * Trigger auto-completion only when Tab is pressed 59 | 60 | * Support auto-completion of commands and arguments (`#7 `_) 61 | 62 | * Add missing explicit dependency to Click 63 | 64 | * Promote `console_locals` as public attr 65 | 66 | * Reimplement console command (`#6 `_) 67 | 68 | * Migrate to Click-based command line interface (`#5 `_) 69 | 70 | * Adopt (`prompt_toolkit` and support concurrent clients `#4 `_) 71 | 72 | * Show the total number of tasks when executing (`ps` `#3 `_) 73 | 74 | * Apply black, isort, mypy, flake8 and automate CI workflows using GitHub Actions 75 | 76 | * Fix the task creation location in the 'ps' command output 77 | 78 | * Remove loop=loop from all asynchronous calls to support newer Python versions (`#329 `_) 79 | 80 | * Added the task creation stack chain display to the 'where' command by setting a custom task factory (`#1 `_) 81 | 82 | These are the backported changes from [aiomonitor-ng](https://github.com/achimnol/aiomonitor-ng). 83 | As the version bumps have gone far away in the fork, all those extra releases are squashed into the v0.5.0 release. 84 | 85 | 86 | 0.4.5 (2019-11-03) 87 | ------------------ 88 | 89 | * Fixed endless loop on EOF (thanks @apatrushev) 90 | 91 | 92 | 0.4.4 (2019-03-23) 93 | ------------------ 94 | 95 | * Simplified python console start end #175 96 | 97 | * Added python 3.7 compatibility #176 98 | 99 | 100 | 0.4.3 (2019-02-02) 101 | ------------------ 102 | 103 | * Reworked console server start/close logic #169 104 | 105 | 106 | 0.4.2 (2019-01-13) 107 | ------------------ 108 | 109 | * Fixed issue with type annotations from 0.4.1 release #164 110 | 111 | 112 | 0.4.1 (2019-01-10) 113 | ------------------ 114 | 115 | * Fixed Python 3.5 support #161 (thanks @bmerry) 116 | 117 | 118 | 0.4.0 (2019-01-04) 119 | ------------------ 120 | 121 | * Added support for custom commands #133 (thanks @yggdr) 122 | 123 | * Fixed OptLocals being passed as the default value for "locals" #122 (thanks @agronholm) 124 | 125 | * Added an API inspired by the standard library's cmd module #135 (thanks @yggdr) 126 | 127 | * Correctly report the port running aioconsole #124 (thanks @bmerry) 128 | 129 | 130 | 0.3.1 (2018-07-03) 131 | ------------------ 132 | 133 | * Added the stacktrace command #120 (thanks @agronholm) 134 | 135 | 136 | 0.3.0 (2017-09-08) 137 | ------------------ 138 | 139 | * Added _locals_ parameter for passing environment to python REPL 140 | 141 | 142 | 0.2.1 (2016-01-03) 143 | ------------------ 144 | 145 | * Fixed import in telnet cli in #12 (thanks @hellysmile) 146 | 147 | 148 | 0.2.0 (2016-01-01) 149 | ------------------ 150 | 151 | * Added basic documentation 152 | 153 | * Most of methods of Monitor class are not not private api 154 | 155 | 156 | 0.1.0 (2016-12-14) 157 | ------------------ 158 | 159 | * Added missed LICENSE file 160 | 161 | * Updated API, added start_monitor() function 162 | 163 | 164 | 0.0.3 (2016-12-11) 165 | ------------------ 166 | 167 | * Fixed README.rst 168 | 169 | 170 | 0.0.2 (2016-12-11) 171 | ------------------ 172 | 173 | * Tests more stable now 174 | 175 | * Added simple tutorial to README.rst 176 | 177 | 178 | 0.0.1 (2016-12-10) 179 | ------------------ 180 | 181 | * Initial release. 182 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # aiomonitor documentation build configuration file, created by 5 | # sphinx-quickstart on Sun Dec 11 17:08:38 2016. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | 31 | import pathlib 32 | import sys 33 | from importlib.metadata import version as get_version 34 | from typing import Mapping 35 | 36 | _docs_path = pathlib.Path(__file__).parent 37 | 38 | sys.path.insert(0, str(_docs_path.parent)) 39 | 40 | try: 41 | _version_info = get_version("aiomonitor") 42 | except IndexError: 43 | raise RuntimeError("Unable to determine version.") from None 44 | 45 | # Add any Sphinx extension module names here, as strings. They can be 46 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 47 | # ones. 48 | extensions = [ 49 | "sphinx.ext.autodoc", 50 | "sphinx.ext.intersphinx", 51 | "sphinx.ext.todo", 52 | "sphinx.ext.coverage", 53 | "sphinx.ext.viewcode", 54 | "sphinx_autodoc_typehints", 55 | "sphinx_click", 56 | ] 57 | 58 | # Add any paths that contain templates here, relative to this directory. 59 | templates_path = ["_templates"] 60 | 61 | # The suffix(es) of source filenames. 62 | # You can specify multiple suffix as a list of string: 63 | # 64 | # source_suffix = ['.rst', '.md'] 65 | source_suffix = ".rst" 66 | 67 | # The master toctree document. 68 | master_doc = "index" 69 | 70 | # General information about the project. 71 | project = "aiomonitor" 72 | copyright = "Nikolay Novik (2016-), Joongi Kim (2022-) and contributors" 73 | author = "Nikolay Novik" 74 | 75 | # The version info for the project you're documenting, acts as replacement for 76 | # |version| and |release|, also used in various other places throughout the 77 | # built documents. 78 | # 79 | # The short X.Y version. 80 | version = ".".join(_version_info.split(".")[:2]) 81 | # The full version, including alpha/beta/rc tags. 82 | release = _version_info 83 | # The language for content autogenerated by Sphinx. Refer to documentation 84 | # for a list of supported languages. 85 | # 86 | # This is also used if you do content translation via gettext catalogs. 87 | # Usually you set "language" from the command line for these cases. 88 | language = "en" 89 | 90 | # List of patterns, relative to source directory, that match files and 91 | # directories to ignore when looking for source files. 92 | # This patterns also effect to html_static_path and html_extra_path 93 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 94 | 95 | # The name of the Pygments (syntax highlighting) style to use. 96 | pygments_style = "sphinx" 97 | 98 | # If true, `todo` and `todoList` produce output, else they produce nothing. 99 | todo_include_todos = True 100 | 101 | 102 | # -- Options for HTML output ---------------------------------------------- 103 | 104 | # The theme to use for HTML and HTML Help pages. See the documentation for 105 | # a list of builtin themes. 106 | # 107 | html_theme = "alabaster" 108 | 109 | # Theme options are theme-specific and customize the look and feel of a theme 110 | # further. For a list of options available for each theme, see the 111 | # documentation. 112 | # 113 | aiomonitor_desc = "module that adds monitor and cli capabilitiesfor asyncio application" 114 | html_theme_options = { 115 | "description": aiomonitor_desc, 116 | "github_user": "aio-libs", 117 | "github_repo": "aiomonitor", 118 | "github_button": True, 119 | "github_type": "star", 120 | "github_banner": True, 121 | } 122 | # Add any paths that contain custom static files (such as style sheets) here, 123 | # relative to this directory. They are copied after the builtin static files, 124 | # so a file named "default.css" will overwrite the builtin "default.css". 125 | html_static_path = ["_static"] 126 | 127 | 128 | # -- Options for HTMLHelp output ------------------------------------------ 129 | 130 | # Output file base name for HTML help builder. 131 | htmlhelp_basename = "aiomonitordoc" 132 | 133 | 134 | # -- Options for LaTeX output --------------------------------------------- 135 | 136 | latex_elements: Mapping[str, str] = { 137 | # The paper size ('letterpaper' or 'a4paper'). 138 | # 139 | # 'papersize': 'letterpaper', 140 | # The font size ('10pt', '11pt' or '12pt'). 141 | # 142 | # 'pointsize': '10pt', 143 | # Additional stuff for the LaTeX preamble. 144 | # 145 | # 'preamble': '', 146 | # Latex figure (float) alignment 147 | # 148 | # 'figure_align': 'htbp', 149 | } 150 | 151 | # Grouping the document tree into LaTeX files. List of tuples 152 | # (source start file, target name, title, 153 | # author, documentclass [howto, manual, or own class]). 154 | latex_documents = [ 155 | ( 156 | master_doc, 157 | "aiomonitor.tex", 158 | "aiomonitor Documentation", 159 | "Nikolay Novik", 160 | "manual", 161 | ), 162 | ] 163 | 164 | 165 | # -- Options for manual page output --------------------------------------- 166 | 167 | # One entry per manual page. List of tuples 168 | # (source start file, name, description, authors, manual section). 169 | man_pages = [(master_doc, "aiomonitor", "aiomonitor Documentation", [author], 1)] 170 | 171 | 172 | # -- Options for Texinfo output ------------------------------------------- 173 | 174 | # Grouping the document tree into Texinfo files. List of tuples 175 | # (source start file, target name, title, author, 176 | # dir menu entry, description, category) 177 | texinfo_documents = [ 178 | ( 179 | master_doc, 180 | "aiomonitor", 181 | "aiomonitor Documentation", 182 | author, 183 | "aiomonitor", 184 | aiomonitor_desc, 185 | "Miscellaneous", 186 | ), 187 | ] 188 | 189 | 190 | # Example configuration for intersphinx: refer to the Python standard library. 191 | intersphinx_mapping = { 192 | "python": ("https://docs.python.org/3", None), 193 | "click": ("https://click.palletsprojects.com/en/8.1.x/", None), 194 | } 195 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | aiomonitor 2 | ========== 3 | 4 | .. image:: https://github.com/aio-libs/aiomonitor/workflows/CI/badge.svg 5 | :target: https://github.com/aio-libs/aiomonitor/actions?query=workflow%3ACI 6 | :alt: GitHub Actions status for the main branch 7 | 8 | .. image:: https://codecov.io/gh/aio-libs/aiomonitor/branch/main/graph/badge.svg 9 | :target: https://codecov.io/gh/aio-libs/aiomonitor 10 | :alt: codecov.io status for the main branch 11 | 12 | .. image:: https://badge.fury.io/py/aiomonitor.svg 13 | :target: https://pypi.org/project/aiomonitor 14 | :alt: Latest PyPI package version 15 | 16 | .. image:: https://img.shields.io/pypi/dm/aiomonitor 17 | :target: https://pypistats.org/packages/aiomonitor 18 | :alt: Downloads count 19 | 20 | .. image:: https://readthedocs.org/projects/aiomonitor-ng/badge/?version=latest 21 | :target: https://aiomonitor.aio-libs.org/en/latest/?badge=latest 22 | :alt: Documentation Status 23 | 24 | **aiomonitor** is a module that adds monitor and cli capabilities 25 | for asyncio_ applications. Idea and code were borrowed from curio_ project. 26 | Task monitor that runs concurrently to the asyncio_ loop (or fast drop-in 27 | replacement uvloop_) in a separate thread as result monitor will work even if 28 | the event loop is blocked for some reason. 29 | 30 | This library provides a python console using aioconsole_ module. It is possible 31 | to execute asynchronous commands inside your running application. Extensible 32 | with you own commands, in the style of the standard library's cmd_ module 33 | 34 | .. image:: https://raw.githubusercontent.com/aio-libs/aiomonitor/main/docs/screenshot-ps-where-example.png 35 | :alt: An example to run the aiomonitor shell 36 | 37 | Installation 38 | ------------ 39 | Installation process is simple, just:: 40 | 41 | $ pip install aiomonitor 42 | 43 | 44 | Example 45 | ------- 46 | Monitor has context manager interface: 47 | 48 | .. code:: python 49 | 50 | import asyncio 51 | 52 | import aiomonitor 53 | 54 | async def main(): 55 | loop = asyncio.get_running_loop() 56 | run_forever = loop.create_future() 57 | with aiomonitor.start_monitor(loop): 58 | await run_forever 59 | 60 | try: 61 | asyncio.run(main()) 62 | except KeyboardInterrupt: 63 | pass 64 | 65 | Now from separate terminal it is possible to connect to the application:: 66 | 67 | $ telnet localhost 20101 68 | 69 | or the included python client:: 70 | 71 | $ python -m aiomonitor.cli 72 | 73 | 74 | Tutorial 75 | -------- 76 | 77 | Let's create a simple aiohttp_ application, and see how ``aiomonitor`` can 78 | be integrated with it. 79 | 80 | .. code:: python 81 | 82 | import asyncio 83 | 84 | import aiomonitor 85 | from aiohttp import web 86 | 87 | # Simple handler that returns response after 100s 88 | async def simple(request): 89 | print('Start sleeping') 90 | await asyncio.sleep(100) 91 | return web.Response(text="Simple answer") 92 | 93 | loop = asyncio.get_event_loop() 94 | # create application and register route 95 | app = web.Application() 96 | app.router.add_get('/simple', simple) 97 | 98 | # it is possible to pass a dictionary with local variables 99 | # to the python console environment 100 | host, port = "localhost", 8090 101 | locals_ = {"port": port, "host": host} 102 | # init monitor just before run_app 103 | with aiomonitor.start_monitor(loop=loop, locals=locals_): 104 | # run application with built-in aiohttp run_app function 105 | web.run_app(app, port=port, host=host, loop=loop) 106 | 107 | Let's save this code in file ``simple_srv.py``, so we can run it with the following command:: 108 | 109 | $ python simple_srv.py 110 | ======== Running on http://localhost:8090 ======== 111 | (Press CTRL+C to quit) 112 | 113 | And now one can connect to a running application from a separate terminal, with 114 | the ``telnet`` command, and ``aiomonitor`` will immediately respond with prompt:: 115 | 116 | $ telnet localhost 20101 117 | Asyncio Monitor: 1 tasks running 118 | Type help for commands 119 | monitor >>> 120 | 121 | Now you can type commands, for instance, ``help``:: 122 | 123 | monitor >>> help 124 | Usage: help [OPTIONS] COMMAND [ARGS]... 125 | 126 | To see the usage of each command, run them with "--help" option. 127 | 128 | Commands: 129 | cancel Cancel an indicated task 130 | console Switch to async Python REPL 131 | exit (q,quit) Leave the monitor client session 132 | help (?,h) Show the list of commands 133 | ps (p) Show task table 134 | ps-terminated (pst,pt) List recently terminated/cancelled tasks 135 | signal Send a Unix signal 136 | stacktrace (st,stack) Print a stack trace from the event loop thread 137 | where (w) Show stack frames and the task creation chain of a task 138 | where-terminated (wt) Show stack frames and the termination/cancellation chain of a task 139 | 140 | ``aiomonitor`` also supports async python console inside a running event loop 141 | so you can explore the state of your application:: 142 | 143 | monitor >>> console 144 | Python 3.10.7 (main, Sep 9 2022, 12:31:20) [Clang 13.1.6 (clang-1316.0.21.2.5)] on darwin 145 | Type "help", "copyright", "credits" or "license" for more information. 146 | --- 147 | This console is running in an asyncio event loop. 148 | It allows you to wait for coroutines using the 'await' syntax. 149 | Try: await asyncio.sleep(1, result=3) 150 | --- 151 | >>> await asyncio.sleep(1, result=3) 152 | 3 153 | >>> 154 | 155 | To leave the console type ``exit()`` or press Ctrl+D:: 156 | 157 | >>> exit() 158 | 159 | ✓ The console session is closed. 160 | monitor >>> 161 | 162 | Extension 163 | --------- 164 | 165 | Additional console variables 166 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 167 | 168 | You may add more variables that can be directly referenced in the ``console`` command. 169 | Refer `the console-variables example code `_ 170 | 171 | Custom console commands 172 | ~~~~~~~~~~~~~~~~~~~~~~~ 173 | 174 | ``aiomonitor`` is very easy to extend with your own console commands. 175 | Refer `the extension example code `_ 176 | 177 | Requirements 178 | ------------ 179 | 180 | * Python_ 3.8+ (3.10.7+ recommended) 181 | * aioconsole_ 182 | * Click_ 183 | * prompt_toolkit_ 184 | * uvloop_ (optional) 185 | 186 | 187 | .. _PEP492: https://www.python.org/dev/peps/pep-0492/ 188 | .. _Python: https://www.python.org 189 | .. _aioconsole: https://github.com/vxgmichel/aioconsole 190 | .. _aiohttp: https://github.com/aio-libs/aiohttp 191 | .. _asyncio: http://docs.python.org/3/library/asyncio.html 192 | .. _Click: https://click.palletsprojects.com 193 | .. _curio: https://github.com/dabeaz/curio 194 | .. _prompt_toolkit: https://python-prompt-toolkit.readthedocs.io 195 | .. _uvloop: https://github.com/MagicStack/uvloop 196 | .. _cmd: http://docs.python.org/3/library/cmd.html 197 | -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | Tutorial 2 | ======== 3 | 4 | Lets create simple aiohttp_ application, and see how ``aiomonitor`` can 5 | integrate with it. 6 | 7 | 8 | Basic aiohttp server 9 | -------------------- 10 | 11 | .. code:: python 12 | 13 | import asyncio 14 | 15 | import aiomonitor 16 | from aiohttp import web 17 | 18 | # A simple handler that returns response after 100s 19 | async def simple(request): 20 | print("Start sleeping") 21 | await asyncio.sleep(100) 22 | return web.Response(text="Simple answer") 23 | 24 | async def main(): 25 | # create application and register route create route 26 | app = web.Application() 27 | app.router.add_get("/simple", simple) 28 | 29 | # init monitor just before run_app 30 | loop = asyncio.get_running_loop() 31 | with aiomonitor.start_monitor(loop, hook_task_factory=True): 32 | await web._run_app(app, port=8090, host="localhost") 33 | 34 | if __name__ == "__main__": 35 | asyncio.run(main()) 36 | 37 | Lets save this code in file ``simple_srv.py``, so we can run it with command:: 38 | 39 | $ python simple_srv.py 40 | ======== Running on http://localhost:8090 ======== 41 | (Press CTRL+C to quit) 42 | 43 | 44 | Connection over telnet 45 | ---------------------- 46 | 47 | And now it is possible to connect to the running application from separate 48 | terminal, by execution ``nc`` command, immediately ``aiomonitor`` will 49 | respond with prompt:: 50 | 51 | $ telnet localhost 20101 52 | Asyncio Monitor: 1 tasks running 53 | Type help for commands 54 | monitor >>> 55 | 56 | *aiomonitor* packaged with own telnet client, just in case you do not have 57 | ``telnet`` client in the host:: 58 | 59 | $ python -m aiomonitor.cli 60 | Asyncio Monitor: 1 tasks running 61 | Type help for commands 62 | monitor >>> 63 | 64 | Once connection established, one can type commands, for instance ``help``:: 65 | 66 | monitor >>> help 67 | Usage: help [OPTIONS] COMMAND [ARGS]... 68 | 69 | To see the usage of each command, run them with "--help" option. 70 | 71 | Commands: 72 | cancel (ca) Cancel an indicated task 73 | console Switch to async Python REPL 74 | exit (q,quit) Leave the monitor client session 75 | help (?,h) Show the list of commands 76 | ps (p) Show task table 77 | ps-terminated (pst,pt) List recently terminated/cancelled tasks 78 | signal Send a Unix signal 79 | stacktrace (st,stack) Print a stack trace from the event loop thread 80 | where (w) Show stack frames and the task creation chain of a task 81 | where-terminated (wt) Show stack frames and the termination/cancellation chain of a task 82 | 83 | Additional commands can be added by defining a Click command function injected into :ref:`the monitor CLI `, see below :ref:`cust-commands`. 84 | 85 | .. versionchanged:: 0.5.0 86 | 87 | As of 0.5.0, you must use a telnet client that implements the actual telnet 88 | protocol. Previously a simple socket-to-console redirector like ``nc`` 89 | worked, but now it requires explicit negotiation of the terminal type to 90 | provide advanced terminal features including auto-completion of commands. 91 | 92 | 93 | Python REPL 94 | ----------- 95 | 96 | ``aiomonitor`` supports also async python console inside running event loop 97 | so you can explore state of your application:: 98 | 99 | monitor >>> console 100 | Python 3.11.7 (main, Dec 9 2023, 21:41:50) [GCC 11.4.0] on linux 101 | Type "help", "copyright", "credits" or "license" for more information. 102 | --- 103 | This console is running in an asyncio event loop. 104 | It allows you to wait for coroutines using the 'await' syntax. 105 | Try: await asyncio.sleep(1, result=3) 106 | --- 107 | >>> 108 | 109 | Now you may execute regular function as well as coroutines by 110 | adding ``await`` keyword:: 111 | 112 | >>> import aiohttp 113 | >>> session = aiohttp.ClientSession() 114 | >>> resp = await session.get('http://python.org') 115 | >>> resp.status 116 | 200 117 | >>> data = await resp.read() 118 | >>> len(data) 119 | 47373 120 | >>> 121 | 122 | To leave console type ``exit()``:: 123 | 124 | >>> exit() 125 | monitor >>> 126 | 127 | 128 | Expose Local Variables in Python REPL 129 | ------------------------------------- 130 | 131 | Local variables can be exposed in Python REPL by passing additional 132 | ``locals`` dictionary with mapping variable name in console to the value. 133 | 134 | .. code:: python 135 | 136 | locals = {"foo": "bar"} 137 | with aiomonitor.start_monitor(loop, locals=locals): 138 | web.run_app(app, port=20101, host='127.0.0.1') 139 | 140 | 141 | As result variable ``foo`` available in console:: 142 | 143 | monitor >>> console 144 | >>> foo 145 | bar 146 | >>> exit() 147 | monitor >>> 148 | 149 | 150 | Web-based Inspector 151 | ------------------- 152 | 153 | You may also open your web browser and navigate to http://localhost:20102 . 154 | This will show a web-based UI to inspect the currently running tasks and terminated tasks, 155 | including their recursive stack traces. You can also cancel specific tasks there. 156 | 157 | To see the recursive task creation and termination history, you should pass 158 | ``hook_task_factory=True`` to the ``start_monitor()`` function. 159 | 160 | 161 | .. _cust-commands: 162 | 163 | Adding custom commands 164 | ---------------------- 165 | 166 | By defining a new :func:`Click command ` on :ref:`the monitor CLI `, we can add our own commands to the 167 | telnet REPL. Use the standard :func:`click.echo()` to print something in the telnet console. 168 | You may also add additional arguments and options just like a normal Click application. 169 | 170 | .. code:: python 171 | 172 | import aiohttp 173 | import click 174 | import requests 175 | from aiomonitor.termui.commands import ( 176 | auto_async_command_done, 177 | auto_command_done, 178 | custom_help_option, 179 | monitor_cli, 180 | ) 181 | 182 | @monitor_cli.command(name="hello") 183 | @click.argument("name", optional=True) 184 | @custom_help_option 185 | @auto_command_done # sync version 186 | def do_hello(ctx: click.Context, name: Optional[str] = None) -> None: 187 | """An example command to say hello to another HTTP server.""" 188 | name = "unknown" if name is None else name 189 | r = requests.get("http://example.com/hello/" + name) 190 | click.echo(r.text + "\n") 191 | 192 | @monitor_cli.command(name="hello-async") 193 | @click.argument("name", optional=True) 194 | @custom_help_option 195 | @auto_async_command_done # async version 196 | async def do_async_hello(ctx: click.Context, name: Optional[str] = None) -> None: 197 | """An example command to asynchronously say hello to another HTTP server.""" 198 | name = "unknown" if name is None else name 199 | async with aiohttp.ClientSession() as sess: 200 | async with sess.get("http://example.com/hello/" + name) as resp: 201 | click.echo(await resp.text()) 202 | 203 | This custom command will be able to do anything you could do in the python REPL, 204 | so you can add custom shortcuts here, that would be tedious to do manually in 205 | the console. 206 | 207 | ``auto_command_done`` or ``auto_async_command_done`` is requried to ensure that 208 | the command function notifies its completion to the telnet's main loop coroutine. 209 | 210 | ``custom_help_option`` is required to provide a ``--help`` option to your command 211 | that is compatible with completion notification like above. 212 | 213 | By using the "locals" argument to ``start_monitor`` you can give any of your 214 | commands access to anything they might need to do their jobs by accessing 215 | them via ``ctx.obj.console_locals`` in the command function. 216 | 217 | 218 | .. _aiohttp: https://github.com/aio-libs/aiohttp 219 | -------------------------------------------------------------------------------- /aiomonitor/webui/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | aiomonitor - {{ title }} 8 | 9 | 10 | 11 | 12 | 13 | 24 | 25 | 26 |
27 | 47 | 48 |
49 |
50 |

{{ page.title }}

51 | {% block head_content %}{% endblock %} 52 |
53 |
54 |
55 |
56 | {% block content %}{% endblock %} 57 |
58 |
59 |
60 | {% raw %} 61 | 62 | 101 | 140 | {% endraw %} 141 | 142 | 168 | 169 | 170 | -------------------------------------------------------------------------------- /aiomonitor/webui/app.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import dataclasses 5 | import sys 6 | from importlib.metadata import version 7 | from pathlib import Path 8 | from typing import TYPE_CHECKING, Dict, Mapping, Tuple 9 | 10 | if sys.version_info >= (3, 11): 11 | from enum import StrEnum 12 | else: 13 | from backports.strenum import StrEnum 14 | 15 | import trafaret as t 16 | from aiohttp import web 17 | from jinja2 import Environment, PackageLoader, select_autoescape 18 | 19 | from .utils import APIParams, check_params 20 | 21 | if TYPE_CHECKING: 22 | from ..monitor import Monitor 23 | 24 | 25 | @dataclasses.dataclass 26 | class WebUIContext: 27 | monitor: Monitor 28 | jenv: Environment 29 | 30 | 31 | class TaskTypes(StrEnum): 32 | RUNNING = "running" 33 | TERMINATED = "terminated" 34 | 35 | 36 | @dataclasses.dataclass 37 | class TaskTypeParams(APIParams): 38 | task_type: TaskTypes 39 | 40 | @classmethod 41 | def get_checker(cls): 42 | return t.Dict({ 43 | t.Key("task_type", default=TaskTypes.RUNNING): t.Enum( 44 | TaskTypes.RUNNING, 45 | TaskTypes.TERMINATED, 46 | ), 47 | }) 48 | 49 | 50 | @dataclasses.dataclass 51 | class TaskIdParams(APIParams): 52 | task_id: str 53 | 54 | @classmethod 55 | def get_checker(cls) -> t.Trafaret: 56 | return t.Dict({ 57 | t.Key("task_id"): t.String, 58 | }) 59 | 60 | 61 | @dataclasses.dataclass 62 | class ListFilterParams(APIParams): 63 | filter: str 64 | persistent: bool 65 | 66 | @classmethod 67 | def get_checker(cls) -> t.Trafaret: 68 | return t.Dict({ 69 | t.Key("filter", default=""): t.String(allow_blank=True), 70 | t.Key("persistent", default=False): t.ToBool, 71 | }) 72 | 73 | 74 | @dataclasses.dataclass 75 | class NavigationItem: 76 | title: str 77 | current: bool 78 | 79 | 80 | nav_menus: Mapping[str, NavigationItem] = { 81 | "/": NavigationItem( 82 | title="Dashboard", 83 | current=False, 84 | ), 85 | "/about": NavigationItem( 86 | title="About", 87 | current=False, 88 | ), 89 | } 90 | 91 | 92 | ctx_key = web.AppKey("ctx_key", WebUIContext) 93 | 94 | 95 | def get_navigation_info( 96 | route: str, 97 | ) -> Tuple[NavigationItem, Mapping[str, NavigationItem]]: 98 | nav_items: Dict[str, NavigationItem] = {} 99 | current_item = None 100 | for path, item in nav_menus.items(): 101 | is_current = path == route 102 | nav_items[path] = NavigationItem(item.title, is_current) 103 | if is_current: 104 | current_item = item 105 | if current_item is None: 106 | raise web.HTTPNotFound 107 | return current_item, nav_items 108 | 109 | 110 | async def show_list_page(request: web.Request) -> web.Response: 111 | ctx: WebUIContext = request.app[ctx_key] 112 | nav_info, nav_items = get_navigation_info(request.path) 113 | template = ctx.jenv.get_template("index.html") 114 | async with check_params(request, TaskTypeParams) as params: 115 | output = template.render( 116 | navigation=nav_items, 117 | page={ 118 | "title": nav_info.title, 119 | }, 120 | current_list_type=params.task_type, 121 | list_types=[ 122 | {"id": TaskTypes.RUNNING, "title": "Running"}, 123 | {"id": TaskTypes.TERMINATED, "title": "Terminated"}, 124 | ], 125 | ) 126 | return web.Response(body=output, content_type="text/html") 127 | 128 | 129 | async def show_about_page(request: web.Request) -> web.Response: 130 | ctx: WebUIContext = request.app[ctx_key] 131 | nav_info, nav_items = get_navigation_info(request.path) 132 | template = ctx.jenv.get_template("about.html") 133 | output = template.render( 134 | navigation=nav_items, 135 | page={ 136 | "title": nav_info.title, 137 | }, 138 | ) 139 | return web.Response(body=output, content_type="text/html") 140 | 141 | 142 | async def show_trace_page(request: web.Request) -> web.Response: 143 | ctx: WebUIContext = request.app[ctx_key] 144 | template = ctx.jenv.get_template("trace.html") 145 | async with check_params(request, TaskIdParams) as params: 146 | if request.path.startswith("/trace-running"): 147 | trace_data = ctx.monitor.format_running_task_stack(params.task_id) 148 | elif request.path.startswith("/trace-terminated"): 149 | trace_data = ctx.monitor.format_terminated_task_stack(params.task_id) 150 | else: 151 | raise RuntimeError("should not reach here") 152 | output = template.render( 153 | navigation=nav_menus, 154 | page={ 155 | "title": f"Task trace for {params.task_id}", 156 | }, 157 | trace_data=trace_data, 158 | ) 159 | return web.Response(body=output, content_type="text/html") 160 | 161 | 162 | async def get_version(request: web.Request) -> web.Response: 163 | return web.json_response( 164 | data={ 165 | "value": version("aiomonitor"), 166 | } 167 | ) 168 | 169 | 170 | async def get_task_count(request: web.Request) -> web.Response: 171 | async with check_params(request, TaskTypeParams) as params: 172 | ctx: WebUIContext = request.app[ctx_key] 173 | if params.task_type == TaskTypes.RUNNING: 174 | count = len(asyncio.all_tasks(ctx.monitor._monitored_loop)) 175 | elif params.task_type == TaskTypes.TERMINATED: 176 | count = len(ctx.monitor._terminated_history) 177 | else: 178 | raise RuntimeError("should not reach here") 179 | return web.json_response( 180 | data={ 181 | "value": count, 182 | } 183 | ) 184 | 185 | 186 | async def get_live_task_list(request: web.Request) -> web.Response: 187 | ctx: WebUIContext = request.app[ctx_key] 188 | async with check_params(request, ListFilterParams) as params: 189 | tasks = ctx.monitor.format_running_task_list( 190 | params.filter, 191 | params.persistent, 192 | ) 193 | return web.json_response( 194 | data={ 195 | "tasks": [ 196 | { 197 | "task_id": t.task_id, 198 | "state": t.state, 199 | "name": t.name, 200 | "coro": t.coro, 201 | "created_location": t.created_location, 202 | "since": t.since, 203 | "is_root": t.created_location == "-", 204 | } 205 | for t in tasks 206 | ] 207 | } 208 | ) 209 | 210 | 211 | async def get_terminated_task_list(request: web.Request) -> web.Response: 212 | ctx: WebUIContext = request.app[ctx_key] 213 | async with check_params(request, ListFilterParams) as params: 214 | tasks = ctx.monitor.format_terminated_task_list( 215 | params.filter, 216 | params.persistent, 217 | ) 218 | return web.json_response( 219 | data={ 220 | "tasks": [ 221 | { 222 | "task_id": t.task_id, 223 | "name": t.name, 224 | "coro": t.coro, 225 | "started_since": t.started_since, 226 | "terminated_since": t.terminated_since, 227 | } 228 | for t in tasks 229 | ] 230 | } 231 | ) 232 | 233 | 234 | async def cancel_task(request: web.Request) -> web.Response: 235 | ctx: WebUIContext = request.app[ctx_key] 236 | async with check_params(request, TaskIdParams) as params: 237 | try: 238 | coro_repr = await ctx.monitor.cancel_monitored_task(params.task_id) 239 | return web.json_response( 240 | data={ 241 | "msg": f"Successfully cancelled {params.task_id}", 242 | "detail": coro_repr, 243 | }, 244 | ) 245 | except ValueError as e: 246 | return web.json_response( 247 | status=404, 248 | data={"msg": repr(e)}, 249 | ) 250 | 251 | 252 | async def init_webui(monitor: Monitor) -> web.Application: 253 | jenv = Environment( 254 | loader=PackageLoader("aiomonitor.webui"), autoescape=select_autoescape() 255 | ) 256 | app = web.Application() 257 | app[ctx_key] = WebUIContext( 258 | monitor=monitor, 259 | jenv=jenv, 260 | ) 261 | app.router.add_route("GET", "/", show_list_page) 262 | app.router.add_route("GET", "/about", show_about_page) 263 | app.router.add_route("GET", "/trace-running", show_trace_page) 264 | app.router.add_route("GET", "/trace-terminated", show_trace_page) 265 | app.router.add_route("GET", "/api/version", get_version) 266 | app.router.add_route("POST", "/api/task-count", get_task_count) 267 | app.router.add_route("POST", "/api/live-tasks", get_live_task_list) 268 | app.router.add_route("POST", "/api/terminated-tasks", get_terminated_task_list) 269 | app.router.add_route("DELETE", "/api/task", cancel_task) 270 | app.router.add_static("/static", Path(__file__).parent / "static") 271 | return app 272 | -------------------------------------------------------------------------------- /tests/test_monitor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import contextlib 5 | import contextvars 6 | import functools 7 | import io 8 | import sys 9 | import unittest.mock 10 | from typing import Sequence 11 | 12 | import click 13 | import pytest 14 | from prompt_toolkit.application import create_app_session 15 | from prompt_toolkit.input import create_pipe_input 16 | from prompt_toolkit.output import DummyOutput 17 | 18 | import aiomonitor.termui.commands 19 | from aiomonitor import Monitor, start_monitor 20 | from aiomonitor.termui.commands import ( 21 | auto_command_done, 22 | command_done, 23 | current_monitor, 24 | current_stdout, 25 | custom_help_option, 26 | monitor_cli, 27 | print_ok, 28 | ) 29 | 30 | 31 | @contextlib.contextmanager 32 | def monitor_common(): 33 | def make_baz(): 34 | return "baz" 35 | 36 | locals_ = {"foo": "bar", "make_baz": make_baz} 37 | # In the tests, we reuse the pytest's event loop as the monitored loop. 38 | # Because of this, all cross-loop coroutine invocations should use the following 39 | # pattern in both tests and the monitor/termui implementation: 40 | # > fut = asyncio.wrap_future(asyncio.run_coroutine_threadsafe(...)) 41 | # > await fut 42 | test_loop = asyncio.get_running_loop() 43 | mon = Monitor(test_loop, locals=locals_) 44 | with mon: 45 | yield mon 46 | 47 | 48 | @pytest.fixture 49 | async def monitor(request, event_loop): 50 | with monitor_common() as monitor_instance: 51 | yield monitor_instance 52 | 53 | 54 | def get_task_ids(event_loop): 55 | return [id(t) for t in asyncio.all_tasks(loop=event_loop)] 56 | 57 | 58 | class BufferedOutput(DummyOutput): 59 | def __init__(self) -> None: 60 | self._buffer = io.StringIO() 61 | 62 | def write(self, data: str) -> None: 63 | self._buffer.write(data) 64 | 65 | def write_raw(self, data: str) -> None: 66 | self._buffer.write(data) 67 | 68 | 69 | async def invoke_command( 70 | monitor: Monitor, 71 | args: Sequence[str], 72 | ) -> str: 73 | dummy_stdout = BufferedOutput() 74 | current_monitor_token = current_monitor.set(monitor) 75 | current_stdout_token = current_stdout.set(dummy_stdout._buffer) 76 | 77 | async def _ui_create_event() -> asyncio.Event: 78 | return asyncio.Event() 79 | 80 | fut = asyncio.run_coroutine_threadsafe(_ui_create_event(), monitor._ui_loop) 81 | command_done_event: asyncio.Event = await asyncio.wrap_future(fut) 82 | command_done_token = command_done.set(command_done_event) 83 | try: 84 | with unittest.mock.patch.object( 85 | aiomonitor.termui.commands, 86 | "print_formatted_text", 87 | functools.partial( 88 | aiomonitor.termui.commands.print_formatted_text, output=dummy_stdout 89 | ), 90 | ): 91 | ctx = contextvars.copy_context() 92 | ctx.run( 93 | monitor_cli.main, 94 | args, 95 | prog_name="", 96 | obj=monitor, 97 | standalone_mode=False, # type: ignore 98 | ) 99 | # If Click raises UsageError before running the command, 100 | # there will be no one to set command_done_event. 101 | # In this case, the error is propagated to the upper stack 102 | # immediately here. 103 | fut = asyncio.run_coroutine_threadsafe( 104 | command_done_event.wait(), # type: ignore 105 | monitor._ui_loop, 106 | ) 107 | await asyncio.wrap_future(fut) 108 | finally: 109 | command_done.reset(command_done_token) 110 | current_stdout.reset(current_stdout_token) 111 | current_monitor.reset(current_monitor_token) 112 | with contextlib.closing(dummy_stdout._buffer): 113 | return dummy_stdout._buffer.getvalue() 114 | 115 | 116 | @pytest.fixture(params=[True, False], ids=["console:True", "console:False"]) 117 | def console_enabled(request): 118 | return request.param 119 | 120 | 121 | @pytest.mark.asyncio 122 | async def test_ctor(event_loop, console_enabled): 123 | with Monitor(event_loop, console_enabled=console_enabled): 124 | await asyncio.sleep(0.01) 125 | with start_monitor(event_loop, console_enabled=console_enabled) as m: 126 | await asyncio.sleep(0.01) 127 | assert m.closed 128 | 129 | m = Monitor(event_loop, console_enabled=console_enabled) 130 | m.start() 131 | try: 132 | await asyncio.sleep(0.01) 133 | finally: 134 | m.close() 135 | m.close() # make sure call is idempotent 136 | assert m.closed 137 | 138 | m = Monitor(event_loop, console_enabled=console_enabled) 139 | m.start() 140 | with m: 141 | await asyncio.sleep(0.01) 142 | assert m.closed 143 | 144 | # make sure that monitor inside async func can exit correctly 145 | with Monitor(event_loop, console_enabled=console_enabled): 146 | await asyncio.sleep(0.01) 147 | 148 | 149 | @pytest.mark.asyncio 150 | async def test_basic_monitor(event_loop, monitor: Monitor): 151 | resp = await invoke_command(monitor, ["help"]) 152 | assert "Commands:" in resp 153 | 154 | with pytest.raises(click.UsageError): 155 | await invoke_command(monitor, ["xxx"]) 156 | 157 | resp = await invoke_command(monitor, ["ps"]) 158 | assert "Task" in resp 159 | 160 | with pytest.raises(click.UsageError): 161 | await invoke_command(monitor, ["ps", "123"]) 162 | 163 | resp = await invoke_command(monitor, ["signal", "name"]) 164 | assert "Unknown signal" in resp 165 | 166 | resp = await invoke_command(monitor, ["stacktrace"]) 167 | assert "self.run_forever()" in resp 168 | 169 | resp = await invoke_command(monitor, ["w", "123"]) 170 | assert "No task 123" in resp 171 | 172 | resp = await invoke_command(monitor, ["where", "123"]) 173 | assert "No task 123" in resp 174 | 175 | with pytest.raises(click.UsageError): 176 | await invoke_command(monitor, ["c", "123"]) 177 | 178 | resp = await invoke_command(monitor, ["cancel", "123"]) 179 | assert "Invalid or non-existent task ID" in resp 180 | 181 | resp = await invoke_command(monitor, ["ca", "123"]) 182 | assert "Invalid or non-existent task ID" in resp 183 | 184 | 185 | myvar = contextvars.ContextVar("myvar", default=42) 186 | 187 | 188 | @pytest.mark.asyncio 189 | async def test_monitor_task_factory(event_loop): 190 | async def do(): 191 | await asyncio.sleep(0) 192 | myself = asyncio.current_task() 193 | assert myself is not None 194 | assert myself.get_name() == "mytask" 195 | 196 | with Monitor(event_loop, console_enabled=False, hook_task_factory=True): 197 | t = asyncio.create_task(do(), name="mytask") 198 | await t 199 | 200 | 201 | @pytest.mark.skipif( 202 | sys.version_info < (3, 11), 203 | reason="The context argument of asyncio.create_task() is added in Python 3.11", 204 | ) 205 | @pytest.mark.asyncio 206 | async def test_monitor_task_factory_with_context(): 207 | ctx = contextvars.Context() 208 | # This context is bound at the outermost scope, 209 | # and inside it the initial value of myvar is kept intact. 210 | 211 | async def do(): 212 | await asyncio.sleep(0) 213 | assert myvar.get() == 42 # we are referring the outer context 214 | myself = asyncio.current_task() 215 | assert myself is not None 216 | assert myself.get_name() == "mytask" 217 | 218 | myvar.set(99) # override in the current task's context 219 | event_loop = asyncio.get_running_loop() 220 | with Monitor(event_loop, console_enabled=False, hook_task_factory=True): 221 | t = asyncio.create_task(do(), name="mytask", context=ctx) 222 | await t 223 | assert myvar.get() == 99 224 | 225 | 226 | @pytest.mark.asyncio 227 | async def test_cancel_where_tasks( 228 | monitor: Monitor, 229 | ) -> None: 230 | async def sleeper(): 231 | await asyncio.sleep(100) # xxx 232 | 233 | test_loop = monitor._monitored_loop 234 | t = test_loop.create_task(sleeper()) 235 | t_id = id(t) 236 | await asyncio.sleep(0.1) 237 | 238 | task_ids = get_task_ids(test_loop) 239 | assert len(task_ids) > 0 240 | assert t_id in task_ids 241 | resp = await invoke_command(monitor, ["where", str(t_id)]) 242 | assert "Task" in resp 243 | resp = await invoke_command(monitor, ["cancel", str(t_id)]) 244 | assert "Cancelled task" in resp 245 | assert t.done() 246 | 247 | 248 | @pytest.mark.asyncio 249 | async def test_monitor_with_console(monitor: Monitor) -> None: 250 | with create_pipe_input() as pipe_input: 251 | stdout_buf = BufferedOutput() 252 | with create_app_session(input=pipe_input, output=stdout_buf): 253 | 254 | async def _interact(): 255 | await asyncio.sleep(0.2) 256 | try: 257 | pipe_input.send_text("await asyncio.sleep(0.1, result=333)\r\n") 258 | pipe_input.flush() 259 | await asyncio.sleep(0.1) 260 | pipe_input.send_text("foo\r\n") 261 | pipe_input.flush() 262 | await asyncio.sleep(0.4) 263 | resp = stdout_buf._buffer.getvalue() 264 | assert "This console is running in an asyncio event loop." in resp 265 | assert "333" in resp 266 | assert "bar" in resp 267 | finally: 268 | pipe_input.send_text("exit()\r\n") 269 | 270 | t = asyncio.create_task(_interact()) 271 | await invoke_command(monitor, ["console"]) 272 | await t 273 | # Check if we are back to the original shell. 274 | resp = await invoke_command(monitor, ["help"]) 275 | assert "Commands" in resp 276 | 277 | 278 | @pytest.mark.asyncio 279 | async def test_custom_monitor_command(monitor: Monitor): 280 | @monitor_cli.command(name="something") 281 | @click.argument("arg") 282 | @custom_help_option 283 | @auto_command_done 284 | def do_something(ctx: click.Context, arg: str) -> None: 285 | print_ok(f"doing something with {arg}") 286 | 287 | resp = await invoke_command(monitor, ["something", "someargument"]) 288 | assert "doing something with someargument" in resp 289 | -------------------------------------------------------------------------------- /aiomonitor/telnet.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import collections 5 | import fcntl 6 | import os 7 | import struct 8 | import sys 9 | import termios 10 | from typing import Dict, Optional, TextIO, Tuple 11 | 12 | import telnetlib3 13 | 14 | ModeDef = collections.namedtuple( 15 | "ModeDef", ["iflag", "oflag", "cflag", "lflag", "ispeed", "ospeed", "cc"] 16 | ) 17 | 18 | 19 | class TelnetClient: 20 | """ 21 | A minimal telnet client-side protocol implementation to support `prompt_toolkit`. 22 | Some details like terminal mode handling is taken from telnetlib3. 23 | (https://github.com/jquast/telnetlib3) 24 | """ 25 | 26 | def __init__( 27 | self, 28 | host: str, 29 | port: int, 30 | stdin: Optional[TextIO] = None, 31 | stdout: Optional[TextIO] = None, 32 | ) -> None: 33 | self._host = host 34 | self._port = port 35 | self._term = os.environ.get("TERM", "unknown") 36 | self._stdin = stdin or sys.stdin 37 | self._stdout = stdout or sys.stdout 38 | try: 39 | self._isatty = os.path.sameopenfile( 40 | self._stdin.fileno(), self._stdout.fileno() 41 | ) 42 | except (NotImplementedError, ValueError): 43 | self._isatty = False 44 | self._remote_options: Dict[bytes, bool] = collections.defaultdict(lambda: False) 45 | 46 | def get_mode(self) -> Optional[ModeDef]: 47 | if self._isatty: 48 | try: 49 | return ModeDef(*termios.tcgetattr(self._stdin.fileno())) 50 | except termios.error: 51 | return None 52 | return None 53 | 54 | def set_mode(self, mode: ModeDef) -> None: 55 | termios.tcsetattr(sys.stdin.fileno(), termios.TCSAFLUSH, list(mode)) 56 | 57 | def restore_mode(self) -> None: 58 | if self._isatty and self._saved_mode is not None: 59 | termios.tcsetattr( 60 | self._stdin.fileno(), termios.TCSAFLUSH, list(self._saved_mode) 61 | ) 62 | 63 | def determine_mode(self, mode: ModeDef) -> ModeDef: 64 | # Reference: https://github.com/jquast/telnetlib3/blob/b90616f/telnetlib3/client_shell.py#L76 65 | if not self._remote_options[telnetlib3.ECHO]: 66 | return mode 67 | iflag = mode.iflag & ~( 68 | termios.BRKINT 69 | | termios.ICRNL # Do not send INTR signal on break 70 | | termios.INPCK # Do not map CR to NL on input 71 | | termios.ISTRIP # Disable input parity checking 72 | | termios.IXON # Do not strip input characters to 7 bits 73 | ) 74 | cflag = mode.cflag & ~(termios.CSIZE | termios.PARENB) 75 | cflag = cflag | termios.CS8 76 | lflag = mode.lflag & ~( 77 | termios.ICANON | termios.IEXTEN | termios.ISIG | termios.ECHO 78 | ) 79 | oflag = mode.oflag & ~(termios.OPOST | termios.ONLCR) 80 | cc = list(mode.cc) 81 | cc[termios.VMIN] = 1 82 | cc[termios.VTIME] = 0 83 | return ModeDef( 84 | iflag=iflag, 85 | oflag=oflag, 86 | cflag=cflag, 87 | lflag=lflag, 88 | ispeed=mode.ispeed, 89 | ospeed=mode.ospeed, 90 | cc=cc, 91 | ) 92 | 93 | async def _create_stdio_streams( 94 | self, 95 | ) -> Tuple[asyncio.StreamReader, asyncio.StreamWriter]: 96 | loop = asyncio.get_running_loop() 97 | stdin_reader = asyncio.StreamReader() 98 | stdin_protocol = asyncio.StreamReaderProtocol(stdin_reader) 99 | await loop.connect_read_pipe(lambda: stdin_protocol, self._stdin) 100 | write_target = self._stdin if self._isatty else self._stdout 101 | writer_transport, writer_protocol = await loop.connect_write_pipe( 102 | asyncio.streams.FlowControlMixin, 103 | write_target, 104 | ) 105 | stdout_writer = asyncio.StreamWriter( 106 | writer_transport, writer_protocol, stdin_reader, loop 107 | ) 108 | return stdin_reader, stdout_writer 109 | 110 | async def __aenter__(self) -> TelnetClient: 111 | self._conn_reader, self._conn_writer = await asyncio.open_connection( 112 | self._host, self._port 113 | ) 114 | self._closed = asyncio.Event() 115 | self._stdin_reader, self._stdout_writer = await self._create_stdio_streams() 116 | self._recv_task = asyncio.create_task(self._handle_received()) 117 | self._input_task = asyncio.create_task(self._handle_user_input()) 118 | await asyncio.sleep(0.3) # wait for negotiation to complete 119 | self._saved_mode = self.get_mode() 120 | if self._isatty: 121 | assert self._saved_mode is not None 122 | self.set_mode(self.determine_mode(self._saved_mode)) 123 | return self 124 | 125 | async def __aexit__(self, *exc_info) -> Optional[bool]: 126 | try: 127 | self._input_task.cancel() 128 | await self._input_task 129 | self._conn_writer.close() 130 | try: 131 | await self._conn_writer.wait_closed() 132 | except NotImplementedError: 133 | pass 134 | self._conn_reader.feed_eof() 135 | await self._recv_task 136 | self._stdout_writer.close() 137 | try: 138 | await self._stdout_writer.wait_closed() 139 | except NotImplementedError: 140 | pass 141 | finally: 142 | self.restore_mode() 143 | return None 144 | 145 | async def interact(self) -> None: 146 | try: 147 | await self._closed.wait() 148 | finally: 149 | self._closed.set() 150 | self._conn_writer.write_eof() 151 | 152 | async def _handle_user_input(self) -> None: 153 | try: 154 | while True: 155 | buf = await self._stdin_reader.read(128) 156 | if not buf: 157 | return 158 | self._conn_writer.write(buf) 159 | await self._conn_writer.drain() 160 | except asyncio.CancelledError: 161 | pass 162 | 163 | async def _handle_nego(self, command: bytes, option: bytes) -> None: 164 | if command == telnetlib3.DO and option == telnetlib3.TTYPE: 165 | self._conn_writer.write(telnetlib3.IAC + telnetlib3.WILL + telnetlib3.TTYPE) 166 | self._conn_writer.write(telnetlib3.IAC + telnetlib3.SB + telnetlib3.TTYPE) 167 | self._conn_writer.write(telnetlib3.BINARY + self._term.encode("ascii")) 168 | self._conn_writer.write(telnetlib3.IAC + telnetlib3.SE) 169 | await self._conn_writer.drain() 170 | elif command == telnetlib3.DO and option == telnetlib3.NAWS: 171 | self._conn_writer.write(telnetlib3.IAC + telnetlib3.WILL + telnetlib3.NAWS) 172 | self._conn_writer.write(telnetlib3.IAC + telnetlib3.SB + telnetlib3.NAWS) 173 | fmt = "HHHH" 174 | buf = b"\x00" * struct.calcsize(fmt) 175 | try: 176 | buf = fcntl.ioctl(self._stdin.fileno(), termios.TIOCGWINSZ, buf) 177 | rows, cols, _, _ = struct.unpack(fmt, buf) 178 | self._conn_writer.write(struct.pack(">HH", cols, rows)) 179 | self._conn_writer.write(telnetlib3.IAC + telnetlib3.SE) 180 | except OSError: 181 | rows, cols = 22, 80 182 | await self._conn_writer.drain() 183 | elif command == telnetlib3.WILL: 184 | self._remote_options[option] = True 185 | if option in (telnetlib3.ECHO, telnetlib3.BINARY, telnetlib3.SGA): 186 | self._conn_writer.write(telnetlib3.IAC + telnetlib3.DO + option) 187 | await self._conn_writer.drain() 188 | elif command == telnetlib3.WONT: 189 | self._remote_options[option] = False 190 | 191 | async def _handle_sb(self, option: bytes, chunk: bytes) -> None: 192 | pass 193 | 194 | async def _handle_received(self): 195 | buf = b"" 196 | try: 197 | while not self._conn_reader.at_eof(): 198 | buf += await self._conn_reader.read(128) 199 | while buf: 200 | cmd_begin = buf.find(telnetlib3.IAC) 201 | if cmd_begin == -1: 202 | self._stdout_writer.write(buf) 203 | await self._stdout_writer.drain() 204 | buf = b"" 205 | else: 206 | if cmd_begin >= len(buf) - 2: 207 | buf += await self._conn_reader.readexactly( 208 | 3 - (len(buf) - cmd_begin), 209 | ) 210 | command = buf[cmd_begin + 1 : cmd_begin + 2] 211 | option = buf[cmd_begin + 2 : cmd_begin + 3] 212 | buf_before, buf_after = buf[:cmd_begin], buf[cmd_begin + 3 :] 213 | self._stdout_writer.write(buf_before) 214 | await self._stdout_writer.drain() 215 | if command in ( 216 | telnetlib3.WILL, 217 | telnetlib3.WONT, 218 | telnetlib3.DO, 219 | telnetlib3.DONT, 220 | ): 221 | await self._handle_nego(command, option) 222 | elif command == telnetlib3.SB: 223 | subnego_end = buf_after.find(telnetlib3.IAC + telnetlib3.SE) 224 | if subnego_end == -1: 225 | subnego_chunk = ( 226 | buf_after 227 | + ( 228 | await self._conn_reader.readuntil( 229 | telnetlib3.IAC + telnetlib3.SE 230 | ) 231 | )[:-2] 232 | ) 233 | buf_after = b"" 234 | else: 235 | subnego_chunk = buf_after[:subnego_end] 236 | buf_after = buf_after[subnego_end + 2 :] 237 | await self._handle_sb(option, subnego_chunk) 238 | buf = buf_after 239 | finally: 240 | self._closed.set() 241 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2019 Nikolay Novik 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /aiomonitor/webui/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block head_content %} 3 |
4 | 25 |
26 | {% endblock %} 27 | {% block content %} 28 | {% if current_list_type == "running" %} 29 |
30 |
31 |
32 |
33 | 34 | 35 | 36 |
37 | 49 |
50 |
51 |
52 | 53 | 67 |
68 |
69 |
70 |
71 |
72 |
73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 85 | 86 | 87 | 97 | 98 |
Task IDStateNameCoroutineCreated Loc.Since 83 | Action 84 |
99 |
100 |
101 |
102 |
103 | 104 | {% raw %} 105 | 136 | {% endraw %} 137 | 138 | {% elif current_list_type == "terminated" %} 139 | 140 |
141 |
142 |
143 |
144 | 145 | 146 | 147 |
148 | 160 |
161 |
162 |
163 | 164 | 178 |
179 |
180 |
181 |
182 |
183 |
184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 195 | 196 | 197 | 207 | 208 |
Task IDNameCoroutineSince StartedSince Terminated 193 | Action 194 |
209 |
210 |
211 |
212 |
213 | 214 | {% raw %} 215 | 233 | {% endraw %} 234 | {% endif %} 235 | 243 | {% endblock %} 244 | -------------------------------------------------------------------------------- /aiomonitor/termui/commands.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import functools 5 | import logging 6 | import os 7 | import shlex 8 | import signal 9 | import sys 10 | import textwrap 11 | import traceback 12 | from contextvars import copy_context 13 | from typing import TYPE_CHECKING, List, TextIO, Tuple 14 | 15 | import click 16 | from prompt_toolkit import PromptSession 17 | from prompt_toolkit.application.current import get_app_session 18 | from prompt_toolkit.contrib.telnet.server import TelnetConnection 19 | from prompt_toolkit.formatted_text import FormattedText 20 | from prompt_toolkit.shortcuts import print_formatted_text 21 | from terminaltables import AsciiTable 22 | 23 | from .. import console 24 | from ..context import command_done, current_monitor, current_stdout 25 | from ..exceptions import MissingTask 26 | from .completion import ( 27 | ClickCompleter, 28 | complete_signal_names, 29 | complete_task_id, 30 | complete_trace_id, 31 | ) 32 | 33 | if TYPE_CHECKING: 34 | from ..monitor import Monitor 35 | 36 | log = logging.getLogger(__name__) 37 | 38 | __all__ = ( 39 | "interact", 40 | "monitor_cli", 41 | "auto_command_done", 42 | "auto_async_command_done", 43 | "custom_help_option", 44 | ) 45 | 46 | 47 | def _get_current_stdout() -> TextIO: 48 | stdout = current_stdout.get(None) 49 | if stdout is None: 50 | return sys.stdout 51 | else: 52 | return stdout 53 | 54 | 55 | def _get_current_stderr() -> TextIO: 56 | stdout = current_stdout.get(None) 57 | if stdout is None: 58 | return sys.stderr 59 | else: 60 | return stdout 61 | 62 | 63 | click.utils._default_text_stdout = _get_current_stdout 64 | click.utils._default_text_stderr = _get_current_stderr 65 | 66 | 67 | def print_ok(msg: str) -> None: 68 | print_formatted_text( 69 | FormattedText([ 70 | ("ansibrightgreen", "✓ "), 71 | ("", msg), 72 | ]) 73 | ) 74 | 75 | 76 | def print_fail(msg: str) -> None: 77 | print_formatted_text( 78 | FormattedText([ 79 | ("ansibrightred", "✗ "), 80 | ("", msg), 81 | ]) 82 | ) 83 | 84 | 85 | async def interact(self: Monitor, connection: TelnetConnection) -> None: 86 | """ 87 | The interactive loop for each telnet client connection. 88 | """ 89 | await asyncio.sleep(0.3) # wait until telnet negotiation is done 90 | tasknum = len(asyncio.all_tasks(loop=self._monitored_loop)) # TODO: refactor 91 | s = "" if tasknum == 1 else "s" 92 | intro = ( 93 | f"\nAsyncio Monitor: {tasknum} task{s} running\n" 94 | f"Type help for available commands\n" 95 | ) 96 | print(intro, file=connection.stdout) 97 | 98 | # Override the Click's stdout/stderr reference cache functions 99 | # to let them use the correct stdout handler. 100 | current_monitor_token = current_monitor.set(self) 101 | current_stdout_token = current_stdout.set(connection.stdout) 102 | # NOTE: prompt_toolkit's all internal console output automatically uses 103 | # an internal contextvar to keep the stdout consistent with the 104 | # current telnet connection. 105 | prompt_session: PromptSession[str] = PromptSession( 106 | completer=ClickCompleter(monitor_cli), 107 | complete_while_typing=False, 108 | ) 109 | lastcmd = "noop" 110 | style_prompt = "#5fd7ff bold" 111 | try: 112 | while True: 113 | try: 114 | user_input = ( 115 | await prompt_session.prompt_async( 116 | FormattedText([ 117 | (style_prompt, self.prompt), 118 | ]) 119 | ) 120 | ).strip() 121 | except KeyboardInterrupt: 122 | print_fail("To terminate, press Ctrl+D or type 'exit'.") 123 | except (EOFError, asyncio.CancelledError): 124 | return 125 | except Exception: 126 | print_fail(traceback.format_exc()) 127 | else: 128 | command_done_event = asyncio.Event() 129 | command_done_token = command_done.set(command_done_event) 130 | try: 131 | if not user_input and lastcmd is not None: 132 | user_input = lastcmd 133 | args = shlex.split(user_input) 134 | term_size = prompt_session.output.get_size() 135 | ctx = copy_context() 136 | ctx.run( 137 | monitor_cli.main, 138 | args, 139 | prog_name="", 140 | obj=self, 141 | standalone_mode=False, # type: ignore 142 | max_content_width=term_size.columns, 143 | ) 144 | await command_done_event.wait() 145 | if args[0] == "console": 146 | lastcmd = "noop" 147 | else: 148 | lastcmd = user_input 149 | except (click.BadParameter, click.UsageError) as e: 150 | print_fail(str(e)) 151 | except asyncio.CancelledError: 152 | return 153 | except Exception: 154 | print_fail(traceback.format_exc()) 155 | finally: 156 | command_done.reset(command_done_token) 157 | finally: 158 | current_stdout.reset(current_stdout_token) 159 | current_monitor.reset(current_monitor_token) 160 | 161 | 162 | class AliasGroupMixin(click.Group): 163 | def __init__(self, *args, **kwargs): 164 | super().__init__(*args, **kwargs) 165 | self._commands = {} 166 | self._aliases = {} 167 | 168 | def command(self, *args, **kwargs): 169 | aliases = kwargs.pop("aliases", []) 170 | decorator = super().command(*args, **kwargs) 171 | 172 | def _decorator(f): 173 | cmd = decorator(click.pass_context(f)) 174 | if aliases: 175 | self._commands[cmd.name] = aliases 176 | for alias in aliases: 177 | self._aliases[alias] = cmd.name 178 | return cmd 179 | 180 | return _decorator 181 | 182 | def group(self, *args, **kwargs): 183 | aliases = kwargs.pop("aliases", []) 184 | # keep the same class type 185 | kwargs["cls"] = type(self) 186 | decorator = super().group(*args, **kwargs) 187 | if not aliases: 188 | return decorator 189 | 190 | def _decorator(f): 191 | cmd = decorator(f) 192 | if aliases: 193 | self._commands[cmd.name] = aliases 194 | for alias in aliases: 195 | self._aliases[alias] = cmd.name 196 | return cmd 197 | 198 | return _decorator 199 | 200 | def get_command(self, ctx, cmd_name): 201 | if cmd_name in self._aliases: 202 | cmd_name = self._aliases[cmd_name] 203 | command = super().get_command(ctx, cmd_name) 204 | if command: 205 | return command 206 | 207 | def format_commands(self, ctx, formatter): 208 | commands = [] 209 | for subcommand in self.list_commands(ctx): 210 | cmd = self.get_command(ctx, subcommand) 211 | # What is this, the tool lied about a command. Ignore it 212 | if cmd is None: 213 | continue 214 | if cmd.hidden: 215 | continue 216 | if subcommand in self._commands: 217 | aliases = ",".join(sorted(self._commands[subcommand])) 218 | subcommand = "{0} ({1})".format(subcommand, aliases) 219 | commands.append((subcommand, cmd)) 220 | 221 | # allow for 3 times the default spacing 222 | if len(commands): 223 | limit = formatter.width - 6 - max(len(cmd[0]) for cmd in commands) 224 | rows = [] 225 | for subcommand, cmd in commands: 226 | help = cmd.get_short_help_str(limit) 227 | rows.append((subcommand, help)) 228 | if rows: 229 | with formatter.section("Commands"): 230 | formatter.write_dl(rows) 231 | 232 | 233 | @click.group(cls=AliasGroupMixin, add_help_option=False) 234 | def monitor_cli(): 235 | """ 236 | To see the usage of each command, run them with "--help" option. 237 | """ 238 | pass 239 | 240 | 241 | def auto_command_done(cmdfunc): 242 | @functools.wraps(cmdfunc) 243 | def _inner(ctx: click.Context, *args, **kwargs): 244 | command_done_event = command_done.get() 245 | try: 246 | return cmdfunc(ctx, *args, **kwargs) 247 | finally: 248 | command_done_event.set() 249 | 250 | return _inner 251 | 252 | 253 | def auto_async_command_done(cmdfunc): 254 | @functools.wraps(cmdfunc) 255 | async def _inner(ctx: click.Context, *args, **kwargs): 256 | command_done_event = command_done.get() 257 | command_done_event.clear() 258 | try: 259 | return await cmdfunc(ctx, *args, **kwargs) 260 | finally: 261 | command_done_event.set() 262 | 263 | return _inner 264 | 265 | 266 | def custom_help_option(cmdfunc): 267 | """ 268 | A custom help option to ensure setting `command_done_event`. 269 | """ 270 | 271 | @auto_command_done 272 | def show_help(ctx: click.Context, param: click.Parameter, value: bool) -> None: 273 | if not value: 274 | return 275 | click.echo(ctx.get_help(), color=ctx.color) 276 | ctx.exit() 277 | 278 | return click.option( 279 | "--help", 280 | is_flag=True, 281 | expose_value=False, 282 | is_eager=True, 283 | callback=show_help, 284 | help="Show the help message", 285 | )(cmdfunc) 286 | 287 | 288 | @monitor_cli.command(name="noop", hidden=True) 289 | @auto_command_done 290 | def do_noop(ctx: click.Context) -> None: 291 | pass 292 | 293 | 294 | @monitor_cli.command(name="help", aliases=["?", "h"]) 295 | @custom_help_option 296 | @auto_command_done 297 | def do_help(ctx: click.Context) -> None: 298 | """Show the list of commands""" 299 | click.echo(monitor_cli.get_help(ctx)) 300 | 301 | 302 | @monitor_cli.command(name="signal") 303 | @click.argument("signame", type=str, shell_complete=complete_signal_names) 304 | @custom_help_option 305 | @auto_command_done 306 | def do_signal(ctx: click.Context, signame: str) -> None: 307 | """Send a Unix signal""" 308 | if hasattr(signal, signame): 309 | os.kill(os.getpid(), getattr(signal, signame)) 310 | print_ok(f"Sent signal to {signame} PID {os.getpid()}") 311 | else: 312 | print_fail(f"Unknown signal {signame}") 313 | 314 | 315 | @monitor_cli.command(name="stacktrace", aliases=["st", "stack"]) 316 | @custom_help_option 317 | @auto_command_done 318 | def do_stacktrace(ctx: click.Context) -> None: 319 | """Print a stack trace from the event loop thread""" 320 | self: Monitor = ctx.obj 321 | stdout = _get_current_stdout() 322 | tid = self._event_loop_thread_id 323 | assert tid is not None 324 | frame = sys._current_frames()[tid] 325 | traceback.print_stack(frame, file=stdout) 326 | 327 | 328 | @monitor_cli.command(name="cancel", aliases=["ca"]) 329 | @click.argument("taskid", shell_complete=complete_task_id) 330 | @custom_help_option 331 | def do_cancel(ctx: click.Context, taskid: str) -> None: 332 | """Cancel an indicated task""" 333 | self: Monitor = ctx.obj 334 | 335 | @auto_async_command_done 336 | async def _do_cancel(ctx: click.Context) -> None: 337 | try: 338 | await self.cancel_monitored_task(taskid) 339 | print_ok(f"Cancelled task {taskid}") 340 | except ValueError as e: 341 | print_fail(repr(e)) 342 | 343 | task = self._ui_loop.create_task(_do_cancel(ctx)) 344 | self._termui_tasks.add(task) 345 | 346 | 347 | @monitor_cli.command(name="exit", aliases=["q", "quit"]) 348 | @custom_help_option 349 | @auto_command_done 350 | def do_exit(ctx: click.Context) -> None: 351 | """Leave the monitor client session""" 352 | raise asyncio.CancelledError("exit by user") 353 | 354 | 355 | @monitor_cli.command(name="console") 356 | @custom_help_option 357 | def do_console(ctx: click.Context) -> None: 358 | """Switch to async Python REPL""" 359 | self: Monitor = ctx.obj 360 | if not self._console_enabled: 361 | print_fail("Python console is disabled for this session!") 362 | return 363 | 364 | @auto_async_command_done 365 | async def _console(ctx: click.Context) -> None: 366 | log.info("Starting aioconsole at %s:%d", self._host, self._console_port) 367 | app_session = get_app_session() 368 | server = await console.start( 369 | self._host, 370 | self._console_port, 371 | self.console_locals, 372 | self._monitored_loop, 373 | ) 374 | try: 375 | await console.proxy( 376 | app_session.input, 377 | app_session.output, 378 | self._host, 379 | self._console_port, 380 | ) 381 | except asyncio.CancelledError: 382 | raise 383 | finally: 384 | await console.close(server, self._monitored_loop) 385 | log.info("Terminated aioconsole at %s:%d", self._host, self._console_port) 386 | print_ok("The console session is closed.") 387 | 388 | # Since we are already inside the UI's event loop, 389 | # spawn the async command function as a new task and let it 390 | # set `command_done_event` internally. 391 | task = self._ui_loop.create_task(_console(ctx)) 392 | self._termui_tasks.add(task) 393 | 394 | 395 | @monitor_cli.command(name="ps", aliases=["p"]) 396 | @click.option("-f", "--filter", "filter_", help="filter by coroutine or task name") 397 | @click.option("-p", "--persistent", is_flag=True, help="show only persistent tasks") 398 | @custom_help_option 399 | @auto_command_done 400 | def do_ps( 401 | ctx: click.Context, 402 | filter_: str, 403 | persistent: bool, 404 | ) -> None: 405 | """Show task table""" 406 | headers = ( 407 | "Task ID", 408 | "State", 409 | "Name", 410 | "Coroutine", 411 | "Created Location", 412 | "Since", 413 | ) 414 | self: Monitor = ctx.obj 415 | stdout = _get_current_stdout() 416 | table_data: List[Tuple[str, str, str, str, str, str]] = [headers] 417 | tasks = self.format_running_task_list(filter_, persistent) 418 | for task in tasks: 419 | table_data.append(( 420 | task.task_id, 421 | task.state, 422 | task.name, 423 | task.coro, 424 | task.created_location, 425 | task.since, 426 | )) 427 | table = AsciiTable(table_data) 428 | table.inner_row_border = False 429 | table.inner_column_border = False 430 | if filter_ or persistent: 431 | stdout.write( 432 | f"{len(tasks)} tasks running (showing {len(table_data) - 1} tasks)\n" 433 | ) 434 | else: 435 | stdout.write(f"{len(tasks)} tasks running\n") 436 | stdout.write(table.table) 437 | stdout.write("\n") 438 | stdout.flush() 439 | 440 | 441 | @monitor_cli.command(name="ps-terminated", aliases=["pt", "pst"]) 442 | @click.option("-f", "--filter", "filter_", help="filter by coroutine or task name") 443 | @click.option("-p", "--persistent", is_flag=True, help="show only persistent tasks") 444 | @custom_help_option 445 | @auto_command_done 446 | def do_ps_terminated( 447 | ctx: click.Context, 448 | filter_: str, 449 | persistent: bool, 450 | ) -> None: 451 | """List recently terminated/cancelled tasks""" 452 | headers = ( 453 | "Trace ID", 454 | "Name", 455 | "Coro", 456 | "Since Started", 457 | "Since Terminated", 458 | ) 459 | self: Monitor = ctx.obj 460 | stdout = _get_current_stdout() 461 | table_data: List[Tuple[str, str, str, str, str]] = [headers] 462 | tasks = self.format_terminated_task_list(filter_, persistent) 463 | for task in tasks: 464 | table_data.append(( 465 | task.task_id, 466 | task.name, 467 | task.coro, 468 | task.started_since, 469 | task.terminated_since, 470 | )) 471 | table = AsciiTable(table_data) 472 | table.inner_row_border = False 473 | table.inner_column_border = False 474 | if filter_ or persistent: 475 | stdout.write( 476 | f"{len(tasks)} tasks terminated (showing {len(table_data) - 1} tasks)\n" 477 | ) 478 | else: 479 | stdout.write(f"{len(tasks)} tasks terminated (old ones may be stripped)\n") 480 | stdout.write(table.table) 481 | stdout.write("\n") 482 | stdout.flush() 483 | 484 | 485 | @monitor_cli.command(name="where", aliases=["w"]) 486 | @click.argument("taskid", shell_complete=complete_task_id) 487 | @custom_help_option 488 | @auto_command_done 489 | def do_where(ctx: click.Context, taskid: str) -> None: 490 | """Show stack frames and the task creation chain of a task""" 491 | self: Monitor = ctx.obj 492 | stdout = _get_current_stdout() 493 | try: 494 | formatted_stack_list = self.format_running_task_stack(taskid) 495 | except MissingTask as e: 496 | print_fail(f"No task {e.task_id}") 497 | return 498 | for item_type, item_text in formatted_stack_list: 499 | if item_type == "header": 500 | stdout.write("\n") 501 | print_formatted_text( 502 | FormattedText([ 503 | ("ansiwhite", item_text), 504 | ]) 505 | ) 506 | else: 507 | stdout.write(textwrap.indent(item_text.strip("\n"), " ")) 508 | stdout.write("\n") 509 | 510 | 511 | @monitor_cli.command(name="where-terminated", aliases=["wt"]) 512 | @click.argument("trace_id", shell_complete=complete_trace_id) 513 | @custom_help_option 514 | @auto_command_done 515 | def do_where_terminated(ctx: click.Context, trace_id: str) -> None: 516 | """Show stack frames and the termination/cancellation chain of a task""" 517 | self: Monitor = ctx.obj 518 | stdout = _get_current_stdout() 519 | try: 520 | formatted_stack_list = self.format_terminated_task_stack(trace_id) 521 | except MissingTask as e: 522 | print_fail(f"No task {e.task_id}") 523 | return 524 | for item_type, item_text in formatted_stack_list: 525 | if item_type == "header": 526 | stdout.write("\n") 527 | print_formatted_text( 528 | FormattedText([ 529 | ("ansiwhite", item_text), 530 | ]) 531 | ) 532 | else: 533 | stdout.write(textwrap.indent(item_text.strip("\n"), " ")) 534 | stdout.write("\n") 535 | -------------------------------------------------------------------------------- /aiomonitor/monitor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import contextlib 5 | import contextvars 6 | import functools 7 | import logging 8 | import sys 9 | import textwrap 10 | import threading 11 | import time 12 | import traceback 13 | import weakref 14 | from asyncio.coroutines import _format_coroutine # type: ignore 15 | from datetime import timedelta 16 | from types import TracebackType 17 | from typing import ( 18 | Any, 19 | Awaitable, 20 | Coroutine, 21 | Dict, 22 | Final, 23 | Generator, 24 | List, 25 | Optional, 26 | Sequence, 27 | Type, 28 | TypeVar, 29 | cast, 30 | ) 31 | 32 | import janus 33 | from aiohttp import web 34 | from prompt_toolkit.contrib.telnet.server import TelnetServer 35 | 36 | from .exceptions import MissingTask 37 | from .task import TracedTask, persistent_coro 38 | from .termui.commands import interact 39 | from .types import ( 40 | CancellationChain, 41 | FormatItemTypes, 42 | FormattedLiveTaskInfo, 43 | FormattedStackItem, 44 | FormattedTerminatedTaskInfo, 45 | TerminatedTaskInfo, 46 | ) 47 | from .utils import ( 48 | _extract_stack_from_exception, 49 | _extract_stack_from_frame, 50 | _extract_stack_from_task, 51 | _filter_stack, 52 | _format_filename, 53 | _format_task, 54 | _format_terminated_task, 55 | _format_timedelta, 56 | get_default_args, 57 | ) 58 | from .webui.app import init_webui 59 | 60 | __all__ = ( 61 | "Monitor", 62 | "start_monitor", 63 | ) 64 | 65 | log = logging.getLogger(__name__) 66 | 67 | MONITOR_HOST: Final = "127.0.0.1" 68 | MONITOR_TERMUI_PORT: Final = 20101 69 | MONITOR_WEBUI_PORT: Final = 20102 70 | CONSOLE_PORT: Final = 20103 71 | 72 | T = TypeVar("T") 73 | T_co = TypeVar("T_co", covariant=True) 74 | 75 | 76 | def task_by_id( 77 | taskid: int, loop: asyncio.AbstractEventLoop 78 | ) -> "Optional[asyncio.Task[Any]]": 79 | tasks = asyncio.all_tasks(loop=loop) 80 | return next(filter(lambda t: id(t) == taskid, tasks), None) 81 | 82 | 83 | async def cancel_task(task: "asyncio.Task[Any]") -> None: 84 | with contextlib.suppress(asyncio.CancelledError): 85 | task.cancel() 86 | await task 87 | 88 | 89 | class Monitor: 90 | prompt: str 91 | """ 92 | The string that prompts you to enter a command, defaults to ``"monitor >>> "`` 93 | """ 94 | 95 | _event_loop_thread_id: Optional[int] = None 96 | 97 | console_locals: Dict[str, Any] 98 | _termui_tasks: weakref.WeakSet[asyncio.Task[Any]] 99 | 100 | _created_traceback_chains: weakref.WeakKeyDictionary[ 101 | asyncio.Task[Any], 102 | weakref.ReferenceType[asyncio.Task[Any]], 103 | ] 104 | _created_tracebacks: weakref.WeakKeyDictionary[ 105 | asyncio.Task[Any], List[traceback.FrameSummary] 106 | ] 107 | _terminated_tasks: Dict[str, TerminatedTaskInfo] 108 | _terminated_history: List[str] 109 | _termination_info_queue: janus.Queue[TerminatedTaskInfo] 110 | _canceller_chain: Dict[str, str] 111 | _canceller_stacks: Dict[str, List[traceback.FrameSummary] | None] 112 | _cancellation_chain_queue: janus.Queue[CancellationChain] 113 | 114 | def __init__( 115 | self, 116 | loop: asyncio.AbstractEventLoop, 117 | *, 118 | host: str = MONITOR_HOST, 119 | termui_port: int = MONITOR_TERMUI_PORT, 120 | webui_port: int = MONITOR_WEBUI_PORT, 121 | console_port: int = CONSOLE_PORT, 122 | console_enabled: bool = True, 123 | hook_task_factory: bool = False, 124 | max_termination_history: int = 1000, 125 | locals: Optional[Dict[str, Any]] = None, 126 | ) -> None: 127 | self._monitored_loop = loop or asyncio.get_running_loop() 128 | self._host = host 129 | self._termui_port = termui_port 130 | self._webui_port = webui_port 131 | self._console_port = console_port 132 | self._console_enabled = console_enabled 133 | if locals is None: 134 | self.console_locals = {"__name__": "__console__", "__doc__": None} 135 | else: 136 | self.console_locals = locals 137 | 138 | self.prompt = "monitor >>> " 139 | log.info( 140 | "Starting aiomonitor at telnet://%(host)s:%(tport)d and http://%(host)s:%(wport)d", 141 | { 142 | "host": host, 143 | "tport": termui_port, 144 | "wport": webui_port, 145 | }, 146 | ) 147 | 148 | self._closed = False 149 | self._started = False 150 | self._termui_tasks = weakref.WeakSet() 151 | 152 | self._hook_task_factory = hook_task_factory 153 | self._created_traceback_chains = weakref.WeakKeyDictionary() 154 | self._created_tracebacks = weakref.WeakKeyDictionary() 155 | self._terminated_tasks = {} 156 | self._canceller_chain = {} 157 | self._canceller_stacks = {} 158 | self._terminated_history = [] 159 | self._max_termination_history = max_termination_history 160 | 161 | self._ui_started = threading.Event() 162 | self._ui_thread = threading.Thread(target=self._ui_main, args=(), daemon=True) 163 | 164 | @property 165 | def host(self) -> str: 166 | """ 167 | The current hostname to bind the monitor server. 168 | """ 169 | return self._host 170 | 171 | @property 172 | def port(self) -> int: 173 | """ 174 | The port number to bind the monitor server. 175 | """ 176 | return self._termui_port 177 | 178 | def __repr__(self) -> str: 179 | name = self.__class__.__name__ 180 | return "<{name}: {host}:{port}>".format( 181 | name=name, host=self._host, port=self._termui_port 182 | ) 183 | 184 | def start(self) -> None: 185 | """ 186 | Starts monitoring thread, where telnet server is executed. 187 | """ 188 | assert not self._closed 189 | assert not self._started 190 | self._started = True 191 | self._original_task_factory = self._monitored_loop.get_task_factory() 192 | if self._hook_task_factory: 193 | self._monitored_loop.set_task_factory(self._create_task) 194 | self._event_loop_thread_id = threading.get_ident() 195 | self._ui_thread.start() 196 | self._ui_started.wait() 197 | 198 | @property 199 | def closed(self) -> bool: 200 | """ 201 | A flag indicates if monitor was closed, currntly instance of 202 | :class:`Monitor` can not be reused. For new monitor, new instance 203 | should be created. 204 | """ 205 | return self._closed 206 | 207 | def __enter__(self) -> Monitor: 208 | if not self._started: 209 | self.start() 210 | return self 211 | 212 | # exc_type should be Optional[Type[BaseException]], but 213 | # this runs into https://github.com/python/typing/issues/266 214 | # on Python 3.5. 215 | def __exit__( 216 | self, 217 | exc_type: Any, 218 | exc_value: Optional[BaseException], 219 | traceback: Optional[TracebackType], 220 | ) -> None: 221 | self.close() 222 | 223 | def close(self) -> None: 224 | """ 225 | Joins background thread, and cleans up resources. 226 | """ 227 | assert self._started, "The monitor must have been started to close it." 228 | if not self._closed: 229 | self._ui_loop.call_soon_threadsafe( 230 | self._ui_forever_future.cancel, 231 | ) 232 | self._monitored_loop.set_task_factory(self._original_task_factory) 233 | self._ui_thread.join() 234 | self._closed = True 235 | 236 | def format_running_task_list( 237 | self, filter_: str, persistent: bool 238 | ) -> Sequence[FormattedLiveTaskInfo]: 239 | all_running_tasks = asyncio.all_tasks(loop=self._monitored_loop) 240 | tasks = [] 241 | for task in sorted(all_running_tasks, key=id): 242 | taskid = str(id(task)) 243 | if isinstance(task, TracedTask): 244 | coro_repr = _format_coroutine(task._orig_coro).partition(" ")[0] 245 | if persistent and task._orig_coro not in persistent_coro: 246 | continue 247 | else: 248 | coro_repr = _format_coroutine(task.get_coro()).partition(" ")[0] 249 | if persistent: 250 | # untracked tasks should be skipped when showing persistent ones only 251 | continue 252 | if filter_ and ( 253 | filter_ not in coro_repr and filter_ not in task.get_name() 254 | ): 255 | continue 256 | creation_stack = self._created_tracebacks.get(task) 257 | # Some values are masked as "-" when they are unavailable 258 | # if it's the root task/coro or if the task factory is not applied. 259 | if not creation_stack: 260 | created_location = "-" 261 | else: 262 | creation_stack = _filter_stack(creation_stack) 263 | fn = _format_filename(creation_stack[-1].filename) 264 | lineno = creation_stack[-1].lineno 265 | created_location = f"{fn}:{lineno}" 266 | if isinstance(task, TracedTask): 267 | running_since = _format_timedelta( 268 | timedelta( 269 | seconds=(time.perf_counter() - task._started_at), 270 | ) 271 | ) 272 | else: 273 | running_since = "-" 274 | tasks.append( 275 | FormattedLiveTaskInfo( 276 | taskid, 277 | task._state, 278 | task.get_name(), 279 | coro_repr, 280 | created_location, 281 | running_since, 282 | ) 283 | ) 284 | return tasks 285 | 286 | def format_terminated_task_list( 287 | self, filter_: str, persistent: bool 288 | ) -> Sequence[FormattedTerminatedTaskInfo]: 289 | terminated_tasks = self._terminated_tasks.values() 290 | tasks = [] 291 | for item in sorted( 292 | terminated_tasks, 293 | key=lambda info: info.terminated_at, 294 | reverse=True, 295 | ): 296 | if persistent and not item.persistent: 297 | continue 298 | if filter_ and (filter_ not in item.coro and filter_ not in item.name): 299 | continue 300 | started_since = _format_timedelta( 301 | timedelta(seconds=time.perf_counter() - item.started_at) 302 | ) 303 | terminated_since = _format_timedelta( 304 | timedelta(seconds=time.perf_counter() - item.terminated_at) 305 | ) 306 | tasks.append( 307 | FormattedTerminatedTaskInfo( 308 | str(item.id), 309 | item.name, 310 | item.coro, 311 | started_since, 312 | terminated_since, 313 | ) 314 | ) 315 | return tasks 316 | 317 | async def cancel_monitored_task(self, task_id: str | int) -> str: 318 | task_id_ = int(task_id) 319 | task = task_by_id(task_id_, self._monitored_loop) 320 | if task is not None: 321 | if self._monitored_loop == asyncio.get_running_loop(): 322 | await cancel_task(task) 323 | else: 324 | fut = asyncio.wrap_future( 325 | asyncio.run_coroutine_threadsafe( 326 | cancel_task(task), loop=self._monitored_loop 327 | ) 328 | ) 329 | await fut 330 | if isinstance(task, TracedTask): 331 | coro_repr = _format_coroutine(task._orig_coro).partition(" ")[0] 332 | else: 333 | coro_repr = _format_coroutine(task.get_coro()).partition(" ")[0] 334 | return coro_repr 335 | else: 336 | raise ValueError("Invalid or non-existent task ID", task_id) 337 | 338 | def format_running_task_stack( 339 | self, 340 | task_id: str | int, 341 | ) -> Sequence[FormattedStackItem]: 342 | depth = 0 343 | task_id_ = int(task_id) 344 | task = task_by_id(task_id_, self._monitored_loop) 345 | if task is None: 346 | raise MissingTask(task_id_) 347 | task_chain: List[asyncio.Task[Any]] = [] 348 | while task is not None: 349 | task_chain.append(task) 350 | task_ref = self._created_traceback_chains.get(task) 351 | task = task_ref() if task_ref is not None else None 352 | prev_task = None 353 | formatted_stack_list = [] 354 | for task in reversed(task_chain): 355 | if depth == 0: 356 | formatted_stack_list.append( 357 | FormattedStackItem( 358 | FormatItemTypes.HEADER, 359 | ( 360 | "Stack of the root task or coroutine scheduled " 361 | "in the event loop (most recent call last)" 362 | ), 363 | ) 364 | ) 365 | elif depth > 0: 366 | assert prev_task is not None 367 | formatted_stack_list.append( 368 | FormattedStackItem( 369 | FormatItemTypes.HEADER, 370 | ( 371 | "Stack of %s when creating the next task " 372 | "(most recent call last)" % _format_task(prev_task) 373 | ), 374 | ) 375 | ) 376 | stack = self._created_tracebacks.get(task) 377 | if stack is None: 378 | formatted_stack_list.append( 379 | FormattedStackItem( 380 | FormatItemTypes.CONTENT, 381 | ( 382 | "No stack available (maybe it is a native code, " 383 | "a synchronous callback function, " 384 | "or the event loop itself)" 385 | ), 386 | ) 387 | ) 388 | else: 389 | stack = _filter_stack(stack) 390 | formatted_stack_list.append( 391 | FormattedStackItem( 392 | FormatItemTypes.CONTENT, 393 | textwrap.dedent("".join(traceback.format_list(stack))), 394 | ) 395 | ) 396 | prev_task = task 397 | depth += 1 398 | task = task_chain[0] 399 | formatted_stack_list.append( 400 | FormattedStackItem( 401 | FormatItemTypes.HEADER, 402 | "Stack of %s (most recent call last)" % _format_task(task), 403 | ) 404 | ) 405 | stack = _extract_stack_from_task(task) 406 | if not stack: 407 | formatted_stack_list.append( 408 | FormattedStackItem( 409 | FormatItemTypes.CONTENT, 410 | "No stack available for %s" % _format_task(task), 411 | ) 412 | ) 413 | else: 414 | formatted_stack_list.append( 415 | FormattedStackItem( 416 | FormatItemTypes.CONTENT, 417 | textwrap.dedent("".join(traceback.format_list(stack))), 418 | ) 419 | ) 420 | return formatted_stack_list 421 | 422 | def format_terminated_task_stack( 423 | self, 424 | trace_id: str, 425 | ) -> Sequence[FormattedStackItem]: 426 | depth = 0 427 | tinfo_chain: List[TerminatedTaskInfo] = [] 428 | while trace_id is not None: 429 | tinfo_chain.append(self._terminated_tasks[trace_id]) 430 | trace_id = self._canceller_chain.get(trace_id) # type: ignore 431 | prev_tinfo = None 432 | formatted_stack_list = [] 433 | for tinfo in reversed(tinfo_chain): 434 | if depth == 0: 435 | formatted_stack_list.append( 436 | FormattedStackItem( 437 | FormatItemTypes.HEADER, 438 | ( 439 | "Stack of the root task or coroutine " 440 | "scheduled in the event loop" 441 | "(most recent call last)" 442 | ), 443 | ) 444 | ) 445 | elif depth > 0: 446 | assert prev_tinfo is not None 447 | formatted_stack_list.append( 448 | FormattedStackItem( 449 | FormatItemTypes.HEADER, 450 | ( 451 | "Stack of %s when creating the next task " 452 | "(most recent call last)" 453 | % _format_terminated_task(prev_tinfo) 454 | ), 455 | ) 456 | ) 457 | stack = tinfo.canceller_stack 458 | if stack is None: 459 | formatted_stack_list.append( 460 | FormattedStackItem( 461 | FormatItemTypes.CONTENT, 462 | ( 463 | "No stack available " 464 | "(maybe it is a self-raised cancellation or exception)" 465 | ), 466 | ) 467 | ) 468 | else: 469 | stack = _filter_stack(stack) 470 | formatted_stack_list.append( 471 | FormattedStackItem( 472 | FormatItemTypes.CONTENT, 473 | textwrap.dedent("".join(traceback.format_list(stack))), 474 | ) 475 | ) 476 | prev_tinfo = tinfo 477 | depth += 1 478 | tinfo = tinfo_chain[0] 479 | formatted_stack_list.append( 480 | FormattedStackItem( 481 | FormatItemTypes.HEADER, 482 | "Stack of %s (most recent call last)" % _format_terminated_task(tinfo), 483 | ) 484 | ) 485 | stack = tinfo.termination_stack 486 | if not stack: 487 | formatted_stack_list.append( 488 | FormattedStackItem( 489 | FormatItemTypes.CONTENT, 490 | ( 491 | "No stack available for %s (the task has run to completion)" 492 | % _format_terminated_task(tinfo) 493 | ), 494 | ) 495 | ) 496 | else: 497 | formatted_stack_list.append( 498 | FormattedStackItem( 499 | FormatItemTypes.CONTENT, 500 | textwrap.dedent("".join(traceback.format_list(stack))), 501 | ) 502 | ) 503 | return formatted_stack_list 504 | 505 | async def _coro_wrapper(self, coro: Awaitable[T_co]) -> T_co: 506 | myself = asyncio.current_task() 507 | assert isinstance(myself, TracedTask) 508 | try: 509 | return await coro 510 | except BaseException as e: 511 | myself._termination_stack = _extract_stack_from_exception(e)[:-1] 512 | raise 513 | 514 | def _create_task( 515 | self, 516 | loop: asyncio.AbstractEventLoop, 517 | coro: Coroutine[Any, Any, T_co] | Generator[Any, None, T_co], 518 | *, 519 | name: str | None = None, 520 | context: contextvars.Context | None = None, 521 | ) -> asyncio.Future[T_co]: 522 | assert loop is self._monitored_loop 523 | try: 524 | parent_task = asyncio.current_task() 525 | except RuntimeError: 526 | parent_task = None 527 | persistent = coro in persistent_coro 528 | task = TracedTask( 529 | self._coro_wrapper(coro), # type: ignore 530 | termination_info_queue=self._termination_info_queue.sync_q, 531 | cancellation_chain_queue=self._cancellation_chain_queue.sync_q, 532 | persistent=persistent, 533 | loop=self._monitored_loop, 534 | name=name, # since Python 3.8 535 | context=context, # since Python 3.11 536 | ) 537 | task._orig_coro = cast(Coroutine[Any, Any, T_co], coro) 538 | self._created_tracebacks[task] = _extract_stack_from_frame(sys._getframe())[ 539 | :-1 540 | ] # strip this wrapper method 541 | if parent_task is not None: 542 | self._created_traceback_chains[task] = weakref.ref(parent_task) 543 | return task 544 | 545 | def _ui_main(self) -> None: 546 | asyncio.run(self._ui_main_async()) 547 | 548 | async def _ui_main_async(self) -> None: 549 | loop = asyncio.get_running_loop() 550 | self._termination_info_queue = janus.Queue() 551 | self._cancellation_chain_queue = janus.Queue() 552 | self._ui_loop = loop 553 | self._ui_forever_future = loop.create_future() 554 | self._ui_termination_handler_task = loop.create_task( 555 | self._ui_handle_termination_updates() 556 | ) 557 | self._ui_cancellation_handler_task = loop.create_task( 558 | self._ui_handle_cancellation_updates() 559 | ) 560 | telnet_server = TelnetServer( 561 | interact=functools.partial(interact, self), 562 | host=self._host, 563 | port=self._termui_port, 564 | ) 565 | webui_app = await init_webui(self) 566 | webui_runner = web.AppRunner(webui_app) 567 | await webui_runner.setup() 568 | webui_site = web.TCPSite( 569 | webui_runner, 570 | str(self._host), 571 | self._webui_port, 572 | reuse_port=True, 573 | ) 574 | await webui_site.start() 575 | telnet_server.start() 576 | await asyncio.sleep(0) 577 | self._ui_started.set() 578 | try: 579 | await self._ui_forever_future 580 | except asyncio.CancelledError: 581 | pass 582 | finally: 583 | termui_tasks = {*self._termui_tasks} 584 | for termui_task in termui_tasks: 585 | termui_task.cancel() 586 | await asyncio.gather(*termui_tasks, return_exceptions=True) 587 | self._ui_termination_handler_task.cancel() 588 | self._ui_cancellation_handler_task.cancel() 589 | with contextlib.suppress(asyncio.CancelledError): 590 | await self._ui_termination_handler_task 591 | with contextlib.suppress(asyncio.CancelledError): 592 | await self._ui_cancellation_handler_task 593 | await telnet_server.stop() 594 | await webui_runner.cleanup() 595 | 596 | async def _ui_handle_termination_updates(self) -> None: 597 | while True: 598 | try: 599 | update: TerminatedTaskInfo = ( 600 | await self._termination_info_queue.async_q.get() 601 | ) 602 | except asyncio.CancelledError: 603 | return 604 | self._terminated_tasks[update.id] = update 605 | if not update.persistent: 606 | self._terminated_history.append(update.id) 607 | # canceller stack is already put in _ui_handle_cancellation_updates() 608 | if canceller_stack := self._canceller_stacks.pop(update.id, None): 609 | update.canceller_stack = canceller_stack 610 | while len(self._terminated_history) > self._max_termination_history: 611 | removed_id = self._terminated_history.pop(0) 612 | self._terminated_tasks.pop(removed_id, None) 613 | self._canceller_chain.pop(removed_id, None) 614 | self._canceller_stacks.pop(removed_id, None) 615 | 616 | async def _ui_handle_cancellation_updates(self) -> None: 617 | while True: 618 | try: 619 | update: CancellationChain = ( 620 | await self._cancellation_chain_queue.async_q.get() 621 | ) 622 | except asyncio.CancelledError: 623 | return 624 | self._canceller_stacks[update.target_id] = update.canceller_stack 625 | self._canceller_chain[update.target_id] = update.canceller_id 626 | 627 | 628 | def start_monitor( 629 | loop: asyncio.AbstractEventLoop, 630 | *, 631 | monitor_cls: Type[Monitor] = Monitor, 632 | host: str = MONITOR_HOST, 633 | port: int = MONITOR_TERMUI_PORT, # kept the name for backward compatibility 634 | console_port: int = CONSOLE_PORT, 635 | webui_port: int = MONITOR_WEBUI_PORT, 636 | console_enabled: bool = True, 637 | hook_task_factory: bool = False, 638 | max_termination_history: Optional[int] = None, 639 | locals: Optional[Dict[str, Any]] = None, 640 | ) -> Monitor: 641 | """ 642 | Factory function, creates instance of :class:`Monitor` and starts 643 | monitoring thread. 644 | 645 | :param Type[Monitor] monitor: Monitor class to use 646 | :param str host: hostname to serve monitor telnet server 647 | :param int port: monitor port (terminal UI), by default 20101 648 | :param int webui_port: monitor port (web UI), by default 20102 649 | :param int console_port: python REPL port, by default 20103 650 | :param bool console_enabled: flag indicates if python REPL is requred 651 | to start with instance of monitor. 652 | :param dict locals: dictionary with variables exposed in python console 653 | environment 654 | """ 655 | m = monitor_cls( 656 | loop, 657 | host=host, 658 | termui_port=port, 659 | webui_port=webui_port, 660 | console_port=console_port, 661 | console_enabled=console_enabled, 662 | hook_task_factory=hook_task_factory, 663 | max_termination_history=( 664 | max_termination_history 665 | if max_termination_history is not None 666 | else get_default_args(monitor_cls.__init__)["max_termination_history"] 667 | ), 668 | locals=locals, 669 | ) 670 | m.start() 671 | return m 672 | --------------------------------------------------------------------------------