├── 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 |
4 | - Version: (loading)
5 |
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 |
54 |
55 |
56 | {% block content %}{% endblock %}
57 |
58 |
59 |
60 | {% raw %}
61 | {{ value }}
62 |
63 |
64 |
65 |
74 |
75 |
76 |
81 |
82 |
{{ msg }}
83 | {{# detail }}
{{ detail }}{{/ detail }}
84 |
85 |
86 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
113 |
114 |
115 |
120 |
121 |
{{ msg }}
122 | {{# detail }}
{{ detail }}{{/ detail }}
123 |
124 |
125 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
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 |
26 | {% endblock %}
27 | {% block content %}
28 | {% if current_list_type == "running" %}
29 |
30 |
51 |
52 |
53 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | | Task ID |
77 | State |
78 | Name |
79 | Coroutine |
80 | Created Loc. |
81 | Since |
82 |
83 | Action
84 | |
85 |
86 |
87 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 | {% raw %}
105 |
106 | {{# tasks}}
107 |
108 | | {{ task_id }} |
109 | {{ state }} |
110 | {{ name }} |
111 | {{ coro }} |
112 | {{ created_location }} |
113 | {{ since }} |
114 |
115 | {{^is_root}}
116 |
126 | {{/is_root}}
127 | Trace
132 | |
133 |
134 | {{/ tasks}}
135 |
136 | {% endraw %}
137 |
138 | {% elif current_list_type == "terminated" %}
139 |
140 |
141 |
162 |
163 |
164 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 | | Task ID |
188 | Name |
189 | Coroutine |
190 | Since Started |
191 | Since Terminated |
192 |
193 | Action
194 | |
195 |
196 |
197 |
207 |
208 |
209 |
210 |
211 |
212 |
213 |
214 | {% raw %}
215 |
216 | {{# tasks}}
217 |
218 | | {{ task_id }} |
219 | {{ name }} |
220 | {{ coro }} |
221 | {{ started_since }} |
222 | {{ terminated_since }} |
223 |
224 | Trace
229 | |
230 |
231 | {{/ tasks}}
232 |
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 |
--------------------------------------------------------------------------------