├── tests
├── __init__.py
├── panels
│ ├── __init__.py
│ ├── tortoise
│ │ ├── __init__.py
│ │ ├── models.py
│ │ ├── crud.py
│ │ ├── test_tortoise.py
│ │ └── conftest.py
│ ├── sqlalchemy
│ │ ├── __init__.py
│ │ ├── models.py
│ │ ├── database.py
│ │ ├── crud.py
│ │ ├── test_sqlalchemy.py
│ │ └── conftest.py
│ ├── test_timer.py
│ ├── test_settings.py
│ ├── test_versions.py
│ ├── test_routes.py
│ ├── test_headers.py
│ ├── test_profiling.py
│ ├── test_redirects.py
│ ├── test_request.py
│ └── test_logging.py
├── templates
│ └── index.html
├── mark.py
├── test_api.py
├── test_middleware.py
├── testclient.py
└── conftest.py
├── debug_toolbar
├── py.typed
├── __init__.py
├── statics
│ ├── js
│ │ ├── redirect.js
│ │ ├── refresh.js
│ │ ├── timer.js
│ │ ├── versions.js
│ │ ├── utils.js
│ │ └── toolbar.js
│ ├── css
│ │ ├── print.css
│ │ └── toolbar.css
│ └── img
│ │ ├── icon-green.svg
│ │ └── icon-white.svg
├── types.py
├── responses.py
├── panels
│ ├── settings.py
│ ├── routes.py
│ ├── headers.py
│ ├── request.py
│ ├── versions.py
│ ├── redirects.py
│ ├── profiling.py
│ ├── sqlalchemy.py
│ ├── tortoise.py
│ ├── timer.py
│ ├── __init__.py
│ ├── logging.py
│ └── sql.py
├── templates
│ ├── panels
│ │ ├── profiling.html
│ │ ├── settings.html
│ │ ├── routes.html
│ │ ├── logging.html
│ │ ├── versions.html
│ │ ├── timer.html
│ │ ├── headers.html
│ │ ├── request.html
│ │ └── sql.html
│ ├── includes
│ │ ├── panel_content.html
│ │ └── panel_button.html
│ ├── redirect.html
│ └── base.html
├── api.py
├── dependencies.py
├── utils.py
├── toolbar.py
├── middleware.py
└── settings.py
├── docs
├── changelog.md
├── CNAME
├── img
│ ├── tab.png
│ ├── logo.png
│ ├── Swagger.png
│ ├── favicon.ico
│ └── panels
│ │ ├── Routes.png
│ │ ├── Timer.png
│ │ ├── Headers.png
│ │ ├── Logging.png
│ │ ├── Profiling.png
│ │ ├── Request.png
│ │ ├── Settings.png
│ │ ├── Versions.png
│ │ └── SQLAlchemy.png
├── overrides
│ └── main.html
├── src
│ ├── quickstart.py
│ ├── panels
│ │ ├── profiling.py
│ │ ├── tortoise.py
│ │ ├── sqlalchemy
│ │ │ ├── panel.py
│ │ │ └── add_engines.py
│ │ └── settings.py
│ └── dev
│ │ ├── quickstart.py
│ │ └── panels.py
├── panels
│ ├── panel.md
│ ├── dev.md
│ ├── sql.md
│ └── default.md
├── settings.md
├── css
│ └── styles.css
└── index.md
├── .prettierrc
├── scripts
├── test
└── lint
├── .gitignore
├── .github
├── dependabot.yml
└── workflows
│ ├── docs.yml
│ ├── publish.yml
│ └── test-suite.yml
├── mkdocs.yml
├── LICENSE
├── README.md
├── CHANGELOG.md
└── pyproject.toml
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/debug_toolbar/py.typed:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/panels/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/panels/tortoise/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/panels/sqlalchemy/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/changelog.md:
--------------------------------------------------------------------------------
1 | --8<-- "CHANGELOG.md"
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120
3 | }
--------------------------------------------------------------------------------
/docs/CNAME:
--------------------------------------------------------------------------------
1 | fastapi-debug-toolbar.domake.io
2 |
--------------------------------------------------------------------------------
/debug_toolbar/__init__.py:
--------------------------------------------------------------------------------
1 | __version__ = "0.6.3"
2 |
--------------------------------------------------------------------------------
/tests/templates/index.html:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/debug_toolbar/statics/js/redirect.js:
--------------------------------------------------------------------------------
1 | document.getElementById("redirect_to").focus();
2 |
--------------------------------------------------------------------------------
/debug_toolbar/statics/css/print.css:
--------------------------------------------------------------------------------
1 | #fastDebug {
2 | display: none !important;
3 | }
4 |
--------------------------------------------------------------------------------
/docs/img/tab.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/HEAD/docs/img/tab.png
--------------------------------------------------------------------------------
/docs/img/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/HEAD/docs/img/logo.png
--------------------------------------------------------------------------------
/docs/img/Swagger.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/HEAD/docs/img/Swagger.png
--------------------------------------------------------------------------------
/docs/img/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/HEAD/docs/img/favicon.ico
--------------------------------------------------------------------------------
/docs/img/panels/Routes.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/HEAD/docs/img/panels/Routes.png
--------------------------------------------------------------------------------
/docs/img/panels/Timer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/HEAD/docs/img/panels/Timer.png
--------------------------------------------------------------------------------
/docs/img/panels/Headers.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/HEAD/docs/img/panels/Headers.png
--------------------------------------------------------------------------------
/docs/img/panels/Logging.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/HEAD/docs/img/panels/Logging.png
--------------------------------------------------------------------------------
/docs/img/panels/Profiling.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/HEAD/docs/img/panels/Profiling.png
--------------------------------------------------------------------------------
/docs/img/panels/Request.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/HEAD/docs/img/panels/Request.png
--------------------------------------------------------------------------------
/docs/img/panels/Settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/HEAD/docs/img/panels/Settings.png
--------------------------------------------------------------------------------
/docs/img/panels/Versions.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/HEAD/docs/img/panels/Versions.png
--------------------------------------------------------------------------------
/docs/img/panels/SQLAlchemy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mongkok/fastapi-debug-toolbar/HEAD/docs/img/panels/SQLAlchemy.png
--------------------------------------------------------------------------------
/debug_toolbar/types.py:
--------------------------------------------------------------------------------
1 | import typing as t
2 |
3 | Stats = t.Dict[str, t.Any]
4 | ServerTiming = t.List[t.Sequence[t.Union[str, float]]]
5 |
--------------------------------------------------------------------------------
/docs/overrides/main.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block htmltitle %}
4 | {{ config.site_name | striptags }}
5 | {% endblock %}
6 |
--------------------------------------------------------------------------------
/debug_toolbar/responses.py:
--------------------------------------------------------------------------------
1 | from fastapi.responses import StreamingResponse
2 |
3 |
4 | class StreamingHTMLResponse(StreamingResponse):
5 | media_type = "text/html"
6 |
--------------------------------------------------------------------------------
/scripts/test:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -e
4 | set -x
5 |
6 | scripts/lint
7 | coverage run -m pytest tests $@
8 | coverage report --show-missing
9 | coverage xml
10 |
--------------------------------------------------------------------------------
/debug_toolbar/panels/settings.py:
--------------------------------------------------------------------------------
1 | from debug_toolbar.panels import Panel
2 |
3 |
4 | class SettingsPanel(Panel):
5 | title = "Settings"
6 | template = "panels/settings.html"
7 |
--------------------------------------------------------------------------------
/docs/src/quickstart.py:
--------------------------------------------------------------------------------
1 | from debug_toolbar.middleware import DebugToolbarMiddleware
2 | from fastapi import FastAPI
3 |
4 | app = FastAPI(debug=True)
5 | app.add_middleware(DebugToolbarMiddleware)
6 |
--------------------------------------------------------------------------------
/tests/panels/tortoise/models.py:
--------------------------------------------------------------------------------
1 | from tortoise import fields, models
2 |
3 |
4 | class User(models.Model):
5 | id = fields.IntField(pk=True)
6 | username = fields.CharField(max_length=20, unique=True)
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.*~
2 | *.DS_Store
3 | *.box
4 | *.log
5 | *.pyc
6 |
7 | __pycache__/
8 | .coverage
9 | .mypy_cache/
10 | .pytest_cache/
11 | .vscode/
12 | coverage.xml
13 | dist/
14 | htmlcov/
15 | site/
16 | venv*
17 |
--------------------------------------------------------------------------------
/docs/panels/panel.md:
--------------------------------------------------------------------------------
1 | ::: debug_toolbar.panels.Panel
2 | handlers: python
3 | options:
4 | heading_level: 4
5 | show_if_no_docstring: true
6 | show_root_heading: false
7 | show_root_toc_entry: false
8 |
--------------------------------------------------------------------------------
/docs/src/panels/profiling.py:
--------------------------------------------------------------------------------
1 | from debug_toolbar.middleware import DebugToolbarMiddleware
2 | from fastapi import FastAPI
3 |
4 | app = FastAPI(debug=True)
5 | app.add_middleware(DebugToolbarMiddleware, profiler_options={'interval': .0002})
6 |
--------------------------------------------------------------------------------
/scripts/lint:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -e
4 | set -x
5 |
6 | ruff debug_toolbar tests scripts
7 | ruff format debug_toolbar tests --check
8 | bandit --configfile pyproject.toml --recursive debug_toolbar
9 | mypy --install-types --non-interactive debug_toolbar tests
10 |
--------------------------------------------------------------------------------
/tests/panels/sqlalchemy/models.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Column, Integer, String
2 |
3 | from .database import Base
4 |
5 |
6 | class User(Base):
7 | __tablename__ = "users"
8 |
9 | id = Column(Integer, primary_key=True)
10 | username = Column(String, unique=True)
11 |
--------------------------------------------------------------------------------
/docs/src/panels/tortoise.py:
--------------------------------------------------------------------------------
1 | from debug_toolbar.middleware import DebugToolbarMiddleware
2 | from fastapi import FastAPI
3 |
4 | app = FastAPI(debug=True)
5 |
6 | app.add_middleware(
7 | DebugToolbarMiddleware,
8 | panels=["debug_toolbar.panels.tortoise.TortoisePanel"],
9 | )
10 |
--------------------------------------------------------------------------------
/docs/src/panels/sqlalchemy/panel.py:
--------------------------------------------------------------------------------
1 | from debug_toolbar.middleware import DebugToolbarMiddleware
2 | from fastapi import FastAPI
3 |
4 | app = FastAPI(debug=True)
5 |
6 | app.add_middleware(
7 | DebugToolbarMiddleware,
8 | panels=["debug_toolbar.panels.sqlalchemy.SQLAlchemyPanel"],
9 | )
10 |
--------------------------------------------------------------------------------
/debug_toolbar/templates/panels/profiling.html:
--------------------------------------------------------------------------------
1 |
5 |
6 |
12 |
--------------------------------------------------------------------------------
/tests/panels/sqlalchemy/database.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import create_engine
2 | from sqlalchemy.orm import declarative_base
3 | from sqlalchemy.pool import StaticPool
4 |
5 | engine = create_engine(
6 | "sqlite://",
7 | connect_args={"check_same_thread": False},
8 | poolclass=StaticPool,
9 | )
10 | Base = declarative_base()
11 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | updates:
4 | - package-ecosystem: pip
5 | directory: /
6 | schedule:
7 | interval: weekly
8 | groups:
9 | python-packages:
10 | patterns:
11 | - '*'
12 | - package-ecosystem: github-actions
13 | directory: /
14 | schedule:
15 | interval: weekly
16 |
--------------------------------------------------------------------------------
/tests/panels/test_timer.py:
--------------------------------------------------------------------------------
1 | from ..mark import override_panels
2 | from ..testclient import TestClient
3 |
4 |
5 | @override_panels(["debug_toolbar.panels.timer.TimerPanel"])
6 | def test_timer(client: TestClient) -> None:
7 | store_id = client.get_store_id("/async")
8 | stats = client.get_stats(store_id, "TimerPanel")
9 |
10 | assert "total" in stats
11 |
--------------------------------------------------------------------------------
/tests/panels/test_settings.py:
--------------------------------------------------------------------------------
1 | from ..mark import override_panels
2 | from ..testclient import TestClient
3 |
4 |
5 | @override_panels(["debug_toolbar.panels.settings.SettingsPanel"])
6 | def test_settings(client: TestClient) -> None:
7 | store_id = client.get_store_id("/async")
8 | stats = client.get_stats(store_id, "SettingsPanel")
9 |
10 | assert not stats
11 |
--------------------------------------------------------------------------------
/docs/src/panels/sqlalchemy/add_engines.py:
--------------------------------------------------------------------------------
1 | from debug_toolbar.panels.sqlalchemy import SQLAlchemyPanel as BasePanel
2 | from sqlalchemy import create_engine
3 |
4 | engine = create_engine("sqlite://", connect_args={"check_same_thread": False})
5 |
6 |
7 | class SQLAlchemyPanel(BasePanel):
8 | async def add_engines(self, request: Request):
9 | self.engines.add(engine)
10 |
--------------------------------------------------------------------------------
/tests/panels/test_versions.py:
--------------------------------------------------------------------------------
1 | from ..mark import override_panels
2 | from ..testclient import TestClient
3 |
4 |
5 | @override_panels(["debug_toolbar.panels.versions.VersionsPanel"])
6 | def test_versions(client: TestClient) -> None:
7 | store_id = client.get_store_id("/async")
8 | stats = client.get_stats(store_id, "VersionsPanel")
9 |
10 | assert stats["packages"]
11 |
--------------------------------------------------------------------------------
/docs/src/panels/settings.py:
--------------------------------------------------------------------------------
1 | from debug_toolbar.middleware import DebugToolbarMiddleware
2 | from fastapi import FastAPI
3 | from pydantic import SecretStr
4 | from pydantic_settings import BaseSettings
5 |
6 |
7 | class APISettings(BaseSettings):
8 | SECRET_KEY: SecretStr
9 |
10 |
11 | app = FastAPI(debug=True)
12 | app.add_middleware(DebugToolbarMiddleware, settings=[APISettings()])
13 |
--------------------------------------------------------------------------------
/tests/panels/tortoise/crud.py:
--------------------------------------------------------------------------------
1 | import typing as t
2 |
3 | from tortoise.queryset import QuerySetSingle
4 |
5 | from . import models
6 |
7 |
8 | def create_user(username: str) -> t.Coroutine[t.Any, t.Any, models.User]:
9 | return models.User.create(username=username)
10 |
11 |
12 | def get_user(user_id: int) -> QuerySetSingle[models.User]:
13 | return models.User.get(id=user_id)
14 |
--------------------------------------------------------------------------------
/tests/mark.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import typing as t
4 |
5 | import pytest
6 | from _pytest.mark import MarkDecorator
7 |
8 |
9 | def override_settings(**settings: t.Any) -> MarkDecorator:
10 | return pytest.mark.parametrize("settings", [settings])
11 |
12 |
13 | def override_panels(panels: list[str]) -> MarkDecorator:
14 | return override_settings(panels=panels)
15 |
--------------------------------------------------------------------------------
/tests/panels/test_routes.py:
--------------------------------------------------------------------------------
1 | from ..mark import override_panels
2 | from ..testclient import TestClient
3 |
4 |
5 | @override_panels(["debug_toolbar.panels.routes.RoutesPanel"])
6 | def test_routes(client: TestClient) -> None:
7 | store_id = client.get_store_id("/async")
8 | stats = client.get_stats(store_id, "RoutesPanel")
9 |
10 | assert any(route.name == "openapi" for route in stats["routes"])
11 |
--------------------------------------------------------------------------------
/docs/src/dev/quickstart.py:
--------------------------------------------------------------------------------
1 | from debug_toolbar.middleware import DebugToolbarMiddleware
2 | from fastapi import FastAPI
3 | from fastapi.templating import Jinja2Templates
4 |
5 | app = FastAPI(debug=True)
6 | templates = Jinja2Templates(directory="templates")
7 |
8 | app.add_middleware(
9 | DebugToolbarMiddleware,
10 | panels=["panels.ExamplePanel"],
11 | jinja_loaders=[templates.env.loader],
12 | )
13 |
--------------------------------------------------------------------------------
/docs/src/dev/panels.py:
--------------------------------------------------------------------------------
1 | from debug_toolbar.panels import Panel
2 |
3 |
4 | class ExamplePanel(Panel):
5 | title = "Example Panel"
6 | template = "example.html"
7 |
8 | async def process_request(self, request):
9 | response = await super().process_request(request)
10 | return response
11 |
12 | async def generate_stats(self, request, response):
13 | return {"example": "value"}
14 |
--------------------------------------------------------------------------------
/docs/settings.md:
--------------------------------------------------------------------------------
1 | Here's a list of settings available:
2 |
3 | ::: debug_toolbar.settings.DebugToolbarSettings
4 | handlers: python
5 | options:
6 | heading_level: 4
7 | show_bases: false
8 | show_if_no_docstring: true
9 | show_root_toc_entry: false
10 | filters:
11 | - "!^_"
12 | - "!Config$"
13 | - "!ci$"
14 | watch:
15 | - debug_toolbar/settings.py
16 |
--------------------------------------------------------------------------------
/debug_toolbar/panels/routes.py:
--------------------------------------------------------------------------------
1 | from fastapi import Request, Response
2 |
3 | from debug_toolbar.panels import Panel
4 | from debug_toolbar.types import Stats
5 |
6 |
7 | class RoutesPanel(Panel):
8 | title = "Routes"
9 | template = "panels/routes.html"
10 |
11 | async def generate_stats(self, request: Request, response: Response) -> Stats:
12 | return {
13 | "routes": request.app.routes,
14 | "endpoint": request["endpoint"],
15 | }
16 |
--------------------------------------------------------------------------------
/.github/workflows/docs.yml:
--------------------------------------------------------------------------------
1 | name: Docs
2 |
3 | on:
4 | push:
5 | branches: ["main"]
6 |
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - uses: actions/checkout@v4
13 | - uses: actions/setup-python@v5
14 | with:
15 | cache: pip
16 | python-version: "3.12"
17 |
18 | - name: Install dependencies
19 | run: pip install .[doc]
20 |
21 | - name: Deploy to GitHub Pages
22 | run: mkdocs gh-deploy --force
23 |
--------------------------------------------------------------------------------
/tests/panels/test_headers.py:
--------------------------------------------------------------------------------
1 | from ..mark import override_panels
2 | from ..testclient import TestClient
3 |
4 |
5 | @override_panels(["debug_toolbar.panels.headers.HeadersPanel"])
6 | def test_headers(client: TestClient) -> None:
7 | headers = {
8 | "cookie": "",
9 | }
10 | store_id = client.get_store_id("/async", headers=headers)
11 | stats = client.get_stats(store_id, "HeadersPanel")
12 | request_headers = stats["request_headers"]
13 |
14 | assert request_headers["cookie"]
15 |
--------------------------------------------------------------------------------
/tests/panels/test_profiling.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from ..mark import override_panels
4 | from ..testclient import TestClient
5 |
6 |
7 | @pytest.mark.parametrize("path", ["sync", "async"])
8 | @override_panels(["debug_toolbar.panels.profiling.ProfilingPanel"])
9 | def test_profiling(client: TestClient, path: str) -> None:
10 | store_id = client.get_store_id(f"/{path}")
11 | stats = client.get_stats(store_id, "ProfilingPanel")
12 |
13 | assert "pyinstrumentHTMLRenderer" in stats["content"]
14 |
--------------------------------------------------------------------------------
/debug_toolbar/templates/includes/panel_content.html:
--------------------------------------------------------------------------------
1 | {% if panel.has_content and panel.enabled %}
2 |
3 |
4 | ×
5 |
{{ panel.title }}
6 |
7 |
11 |
12 | {% endif %}
13 |
--------------------------------------------------------------------------------
/tests/panels/sqlalchemy/crud.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from sqlalchemy import Column
4 | from sqlalchemy.orm import Session
5 |
6 | from . import models
7 |
8 |
9 | def create_user(db: Session, username: str) -> models.User:
10 | user = models.User(username=username)
11 | db.add(user)
12 | db.commit()
13 | return user
14 |
15 |
16 | def get_user(db: Session, user_id: Column[int]) -> models.User | None:
17 | return db.query(models.User).filter(models.User.id == user_id).first()
18 |
--------------------------------------------------------------------------------
/tests/panels/tortoise/test_tortoise.py:
--------------------------------------------------------------------------------
1 | from ...mark import override_panels
2 | from ...testclient import TestClient
3 |
4 |
5 | @override_panels(["debug_toolbar.panels.tortoise.TortoisePanel"])
6 | def test_tortoise(client: TestClient) -> None:
7 | store_id = client.get_store_id("/sql")
8 | stats = client.get_stats(store_id, "TortoisePanel")
9 | queries = stats["queries"]
10 |
11 | assert len(queries) == 3
12 | assert queries[0][1]["sql"].startswith("INSERT")
13 | assert queries[1][1]["dup_count"] == 2
14 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 |
3 | on:
4 | release:
5 | types: [created]
6 |
7 | jobs:
8 | publish:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - uses: actions/checkout@v4
13 |
14 | - name: Set up Python
15 | uses: actions/setup-python@v5
16 |
17 | - name: Install dependencies
18 | run: pip install build
19 |
20 | - name: Build
21 | run: python -m build
22 |
23 | - name: Publish to PyPI
24 | uses: pypa/gh-action-pypi-publish@release/v1
25 | with:
26 | password: ${{ secrets.PYPI_TOKEN }}
27 |
--------------------------------------------------------------------------------
/debug_toolbar/templates/redirect.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Debug Toolbar Redirects Panel: {{ status_code }}
5 |
6 |
7 |
8 | {{ status_line }}
9 |
10 |
11 | The Debug Toolbar has intercepted a redirect to the above URL for debug viewing purposes. You can click the above link to continue with the redirect as normal.
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/debug_toolbar/panels/headers.py:
--------------------------------------------------------------------------------
1 | from fastapi import Request, Response
2 |
3 | from debug_toolbar.panels import Panel
4 | from debug_toolbar.types import Stats
5 |
6 |
7 | class HeadersPanel(Panel):
8 | title = "Headers"
9 | template = "panels/headers.html"
10 |
11 | async def generate_stats(self, request: Request, response: Response) -> Stats:
12 | if "cookie" in (request_headers := dict(request.headers)):
13 | request_headers["cookie"] = "=> see Request panel"
14 |
15 | return {
16 | "request_headers": request_headers,
17 | "environ": request.scope.get("asgi", {}),
18 | "response_headers": response.headers,
19 | }
20 |
--------------------------------------------------------------------------------
/debug_toolbar/api.py:
--------------------------------------------------------------------------------
1 | import html
2 | import typing as t
3 |
4 | from fastapi import Request
5 |
6 | from debug_toolbar.toolbar import DebugToolbar
7 |
8 |
9 | def render_panel(request: Request, store_id: str, panel_id: str) -> t.Any:
10 | toolbar = DebugToolbar.fetch(store_id)
11 |
12 | if toolbar is None:
13 | content = (
14 | "Data for this panel isn't available anymore. "
15 | "Please reload the page and retry."
16 | )
17 | content = f"{html.escape(content)}
"
18 | scripts = []
19 | else:
20 | panel = toolbar.get_panel_by_id(panel_id)
21 | content, scripts = panel.content, panel.scripts
22 |
23 | return {"content": content, "scripts": scripts}
24 |
--------------------------------------------------------------------------------
/debug_toolbar/templates/panels/settings.html:
--------------------------------------------------------------------------------
1 | {% macro pprint_settings(settings, exclude=None) %}
2 | {% if settings.model_config.title %}{{ settings.model_config.title }} {% endif %}
3 |
4 |
5 |
6 |
7 | Key
8 | Value
9 |
10 |
11 |
12 | {% for key, value in settings.model_dump(exclude=exclude).items() %}
13 |
14 | {{ key|escape }}
15 | {{ value|pprint|escape }}
16 |
17 | {% endfor %}
18 |
19 |
20 | {% endmacro %}
21 |
22 | {% for settings in toolbar.settings.SETTINGS %}
23 | {{ pprint_settings(settings) }}
24 | {% endfor %}
25 |
26 | {{ pprint_settings(toolbar.settings, exclude=to_set(['SETTINGS'])) }}
27 |
--------------------------------------------------------------------------------
/tests/test_api.py:
--------------------------------------------------------------------------------
1 | from fastapi import status
2 |
3 | from .mark import override_panels
4 | from .testclient import TestClient
5 |
6 |
7 | @override_panels(["debug_toolbar.panels.timer.TimerPanel"])
8 | def test_render_panel(client: TestClient) -> None:
9 | store_id = client.get_store_id("/async")
10 | response = client.render_panel(store_id=store_id, panel_id="TimerPanel")
11 |
12 | assert response.status_code == status.HTTP_200_OK
13 | assert response.json()["scripts"]
14 |
15 |
16 | @override_panels(["debug_toolbar.panels.timer.TimerPanel"])
17 | def test_invalid_store_id(client: TestClient) -> None:
18 | response = client.render_panel(store_id="", panel_id="TimerPanel")
19 |
20 | assert response.status_code == status.HTTP_200_OK
21 | assert not response.json()["scripts"]
22 |
--------------------------------------------------------------------------------
/debug_toolbar/templates/panels/routes.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Name
5 | Methods
6 | Path
7 | Endpoint
8 | Description
9 |
10 |
11 |
12 | {% for route in routes|sort(attribute='path') %}
13 |
14 | {{ route.name|default('', true) }}
15 | {% if route.methods %}{{ route.methods|sort|join(', ') }}{% endif %}
16 | {{ route.path }}
17 | {{ get_name_from_obj(route.endpoint) }}
18 | {{ route.description }}
19 |
20 | {% endfor %}
21 |
22 |
23 |
--------------------------------------------------------------------------------
/.github/workflows/test-suite.yml:
--------------------------------------------------------------------------------
1 | name: Test Suite
2 |
3 | on:
4 | push:
5 |
6 | pull_request:
7 | branches: ['main']
8 |
9 | jobs:
10 | tests:
11 | name: Python ${{ matrix.python-version }}
12 | runs-on: ubuntu-latest
13 |
14 | strategy:
15 | matrix:
16 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
17 |
18 | steps:
19 | - uses: actions/checkout@v4
20 | - uses: actions/setup-python@v5
21 | with:
22 | cache: pip
23 | python-version: ${{ matrix.python-version }}
24 |
25 | - name: Install dependencies
26 | run: pip install -e .[test]
27 |
28 | - name: Tests
29 | run: scripts/test
30 |
31 | - name: Upload coverage
32 | uses: codecov/codecov-action@v4
33 | env:
34 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
35 |
--------------------------------------------------------------------------------
/debug_toolbar/dependencies.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import typing as t
4 | from contextlib import AsyncExitStack
5 |
6 | from fastapi import HTTPException, Request
7 | from fastapi.dependencies.utils import solve_dependencies
8 |
9 |
10 | async def get_dependencies(request: Request) -> dict[str, t.Any] | None:
11 | route = request["route"]
12 |
13 | if hasattr(route, "dependant"):
14 | try:
15 | solved_result = await solve_dependencies(
16 | request=request,
17 | dependant=route.dependant,
18 | dependency_overrides_provider=route.dependency_overrides_provider,
19 | async_exit_stack=AsyncExitStack(),
20 | )
21 | except HTTPException:
22 | pass
23 | else:
24 | return solved_result[0]
25 | return None
26 |
--------------------------------------------------------------------------------
/debug_toolbar/templates/panels/logging.html:
--------------------------------------------------------------------------------
1 | {% if records %}
2 |
3 |
4 |
5 | Level
6 | Time
7 | Channel
8 | Message
9 | Location
10 |
11 |
12 |
13 | {% for record in records %}
14 |
15 | {{ record.level }}
16 | {{ record.time.strftime('%H:%M:%S %m/%d/%Y') }}
17 | {{ record.channel|default('-') }}
18 | {{ record.message }}
19 | {{ record.file }}:{{ record.line }}
20 |
21 | {% endfor %}
22 |
23 |
24 | {% else %}
25 | No messages logged.
26 | {% endif %}
27 |
--------------------------------------------------------------------------------
/debug_toolbar/templates/panels/versions.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Package
7 | Version
8 | Latest version
9 | Python
10 | Status
11 | Home page
12 |
13 |
14 |
15 | {% for package in packages %}
16 |
17 |
18 |
19 | {{ package.metadata.name }}
20 |
21 |
22 | {{ package.version }}
23 |
24 |
25 |
26 |
27 |
28 | {% endfor %}
29 |
30 |
31 |
--------------------------------------------------------------------------------
/tests/panels/sqlalchemy/test_sqlalchemy.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import typing as t
4 |
5 | import pytest
6 | from sqlalchemy.orm import declarative_base
7 |
8 | from ...mark import override_panels
9 | from ...testclient import TestClient
10 | from .database import Base, engine
11 |
12 |
13 | @override_panels(["debug_toolbar.panels.sqlalchemy.SQLAlchemyPanel"])
14 | @pytest.mark.parametrize(
15 | "session_options",
16 | (None, {"binds": {Base: engine, declarative_base(): engine}}),
17 | )
18 | def test_sqlalchemy(client: TestClient, session_options: dict[str, t.Any]) -> None:
19 | store_id = client.get_store_id("/sql")
20 | stats = client.get_stats(store_id, "SQLAlchemyPanel")
21 | queries = stats["queries"]
22 |
23 | assert len(queries) == 4
24 | assert queries[0][1]["sql"].startswith("INSERT")
25 | assert queries[2][1]["dup_count"] == 2
26 |
--------------------------------------------------------------------------------
/debug_toolbar/templates/includes/panel_button.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | {% if panel.has_content and panel.enabled %}
4 |
5 | {% else %}
6 |
7 | {% endif %}
8 | {{ panel.nav_title }}
9 | {% if panel.enabled %}
10 | {% with subtitle=panel.nav_subtitle %}
11 | {% if subtitle %}{{ subtitle }} {% endif %}
12 | {% endwith %}
13 | {% endif %}
14 | {% if panel.has_content and panel.enabled %}
15 |
16 | {% else %}
17 |
18 | {% endif %}
19 |
20 |
--------------------------------------------------------------------------------
/debug_toolbar/panels/request.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import typing as t
4 |
5 | from fastapi import Request, Response
6 |
7 | from debug_toolbar.panels import Panel
8 | from debug_toolbar.types import Stats
9 | from debug_toolbar.utils import get_name_from_obj
10 |
11 |
12 | class RequestPanel(Panel):
13 | title = "Request"
14 | template = "panels/request.html"
15 |
16 | @property
17 | def nav_subtitle(self) -> str:
18 | return get_name_from_obj(self.endpoint)
19 |
20 | async def generate_stats(self, request: Request, response: Response) -> Stats:
21 | self.endpoint = request["endpoint"]
22 | stats: dict[str, t.Any] = {"request": request}
23 |
24 | if hasattr(self, "_form"):
25 | stats["form"] = await request.form()
26 |
27 | if "session" in request.scope:
28 | stats["session"] = request.session
29 | return stats
30 |
--------------------------------------------------------------------------------
/debug_toolbar/panels/versions.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from importlib import metadata
4 |
5 | from fastapi import Request, Response, __version__
6 |
7 | from debug_toolbar.panels import Panel
8 | from debug_toolbar.types import Stats
9 |
10 |
11 | class VersionsPanel(Panel):
12 | title = "Versions"
13 | template = "panels/versions.html"
14 |
15 | @property
16 | def nav_subtitle(self) -> str:
17 | return f"FastAPI {__version__}"
18 |
19 | @property
20 | def scripts(self) -> list[str]:
21 | scripts = super().scripts
22 | scripts.append(self.url_for("debug_toolbar.static", path="js/versions.js"))
23 | return scripts
24 |
25 | async def generate_stats(self, request: Request, response: Response) -> Stats:
26 | packages = sorted(
27 | metadata.distributions(),
28 | key=lambda dist: dist.metadata["name"].lower(),
29 | )
30 | return {"packages": packages}
31 |
--------------------------------------------------------------------------------
/tests/panels/test_redirects.py:
--------------------------------------------------------------------------------
1 | import pytest
2 | from fastapi import FastAPI, status
3 | from fastapi.responses import RedirectResponse
4 |
5 | from ..mark import override_panels
6 | from ..testclient import TestClient
7 |
8 |
9 | @pytest.fixture
10 | def client(app: FastAPI) -> TestClient:
11 | @app.get(
12 | "/redirect",
13 | response_class=RedirectResponse,
14 | status_code=status.HTTP_302_FOUND,
15 | )
16 | async def redirect() -> str:
17 | return "http://"
18 |
19 | return TestClient(app)
20 |
21 |
22 | @override_panels(["debug_toolbar.panels.redirects.RedirectsPanel"])
23 | def test_redirects(client: TestClient) -> None:
24 | headers = {
25 | "Location": "http://",
26 | }
27 | store_id = client.get_store_id("/redirect", headers=headers)
28 | response = client.render_panel(store_id, "RedirectsPanel")
29 |
30 | assert response.status_code == status.HTTP_200_OK
31 | assert not response.json()["content"]
32 |
--------------------------------------------------------------------------------
/tests/panels/test_request.py:
--------------------------------------------------------------------------------
1 | import typing as t
2 |
3 | import pytest
4 | from fastapi import FastAPI, Request
5 | from fastapi.responses import HTMLResponse
6 | from starlette.middleware.sessions import SessionMiddleware
7 |
8 | from ..mark import override_panels
9 | from ..testclient import TestClient
10 |
11 |
12 | @pytest.fixture
13 | def client(app: FastAPI, get_index: t.Callable) -> TestClient:
14 | app.add_middleware(SessionMiddleware, secret_key="")
15 |
16 | @app.get("/session", response_class=HTMLResponse)
17 | async def get_session(request: Request) -> HTMLResponse:
18 | request.session["debug"] = True
19 | return get_index(request)
20 |
21 | return TestClient(app)
22 |
23 |
24 | @override_panels(["debug_toolbar.panels.request.RequestPanel"])
25 | def test_session(client: TestClient) -> None:
26 | store_id = client.get_store_id("/session")
27 | stats = client.get_stats(store_id, "RequestPanel")
28 |
29 | assert stats["session"]["debug"]
30 |
--------------------------------------------------------------------------------
/tests/panels/tortoise/conftest.py:
--------------------------------------------------------------------------------
1 | import typing as t
2 |
3 | import pytest
4 | from fastapi import FastAPI, Request
5 | from fastapi.responses import HTMLResponse
6 | from tortoise import Tortoise
7 |
8 | from ...testclient import TestClient
9 | from .crud import create_user, get_user
10 |
11 |
12 | @pytest.fixture
13 | async def client(
14 | app: FastAPI,
15 | get_index: t.Callable,
16 | ) -> t.AsyncGenerator[TestClient, None]:
17 | @app.get("/sql", response_class=HTMLResponse)
18 | async def get_sql(request: Request) -> HTMLResponse:
19 | user = await create_user(username="test")
20 | await get_user(user_id=user.id)
21 | await get_user(user_id=user.id)
22 | return get_index(request)
23 |
24 | await Tortoise.init(
25 | db_url="sqlite://:memory:",
26 | modules={"models": ["tests.panels.tortoise.models"]},
27 | )
28 | await Tortoise.generate_schemas()
29 | yield TestClient(app)
30 | await Tortoise.close_connections()
31 |
--------------------------------------------------------------------------------
/docs/panels/dev.md:
--------------------------------------------------------------------------------
1 | ## First steps
2 |
3 | Before writing your own panel you need to provide a [Jinja loader](https://jinja.palletsprojects.com/en/latest/api/#loaders) instance used to load your templates from the file system or other locations.
4 |
5 | ```py hl_lines="10 11"
6 | {!src/dev/quickstart.py!}
7 | ```
8 |
9 | ## Create a panel
10 |
11 | Subclass `Panel` and override `generate_stats()` method to implement a custom panel on your `panels.py`.
12 | This method should return a dict with the panel stats.
13 |
14 | ```py hl_lines="6 12"
15 | {!src/dev/panels.py!}
16 | ```
17 |
18 | !!! tip
19 |
20 | The `process_request()` method is **optional** and particularly useful for adding behavior that occurs before the request is processed.
21 |
22 | Please see the [Panel](panel.md) class reference for further details.
23 |
24 | ## Writing the template
25 |
26 | Create a template at `templates/example.html` to display your panel stats:
27 |
28 | ```html
29 | {{ example }}
30 | ```
31 |
--------------------------------------------------------------------------------
/debug_toolbar/templates/panels/timer.html:
--------------------------------------------------------------------------------
1 | Resource usage
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Resource
10 | Value
11 |
12 |
13 |
14 | {% for key, value in rows %}
15 |
16 | {{ key|escape }}
17 | {{ value|escape }}
18 |
19 | {% endfor %}
20 |
21 |
22 |
23 |
24 |
Browser timing
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | Timing attribute
34 | Timeline
35 | Milliseconds since navigation start (+length)
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/tests/test_middleware.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI, Request, status
2 |
3 | from debug_toolbar.middleware import show_toolbar
4 | from debug_toolbar.settings import DebugToolbarSettings
5 |
6 | from .testclient import TestClient
7 |
8 |
9 | def test_sync(client: TestClient) -> None:
10 | assert client.get_store_id("/sync")
11 |
12 |
13 | def test_async(client: TestClient) -> None:
14 | assert client.get_store_id("/async")
15 |
16 |
17 | def test_json(client: TestClient) -> None:
18 | assert client.get_store_id("/openapi.json")
19 |
20 |
21 | def test_404(client: TestClient) -> None:
22 | response = client.get("/404")
23 | assert response.status_code == status.HTTP_404_NOT_FOUND
24 |
25 |
26 | def test_show_toolbar_not_allowed(app: FastAPI) -> None:
27 | scope = {
28 | "app": app,
29 | "type": "http",
30 | "client": ("invalid", 80),
31 | }
32 | request = Request(scope=scope)
33 | settings = DebugToolbarSettings(ALLOWED_HOSTS=["test"])
34 | assert not show_toolbar(request, settings)
35 |
--------------------------------------------------------------------------------
/docs/panels/sql.md:
--------------------------------------------------------------------------------
1 | ## SQLAlchemy
2 |
3 | Add the `SQLAlchemyPanel` to your panel list:
4 |
5 | ```py hl_lines="8"
6 | {!src/panels/sqlalchemy/panel.py!}
7 | ```
8 |
9 | 
10 |
11 | This panel records all queries using the *"Dependency Injection"* system as described in the [FastAPI docs](https://fastapi.tiangolo.com/tutorial/sql-databases/#create-a-dependency).
12 |
13 | If you don't use dependencies then create a new class that inherits from `SQLAlchemyPanel`, override the `add_engines` method and add the class path to your panel list:
14 |
15 | ```py hl_lines="8 9"
16 | {!src/panels/sqlalchemy/add_engines.py!}
17 | ```
18 |
19 | # ::: debug_toolbar.panels.sqlalchemy.SQLAlchemyPanel
20 | handlers: python
21 | options:
22 | heading_level: 3
23 | show_bases: false
24 | show_root_heading: true
25 | members:
26 | - add_engines
27 |
28 | ## Tortoise ORM
29 |
30 | Add the `TortoisePanel` to your panel list:
31 |
32 | ```py hl_lines="8"
33 | {!src/panels/tortoise.py!}
34 | ```
35 |
--------------------------------------------------------------------------------
/debug_toolbar/panels/redirects.py:
--------------------------------------------------------------------------------
1 | import typing as t
2 |
3 | from fastapi import Request, Response, status
4 |
5 | from debug_toolbar.panels import Panel
6 | from debug_toolbar.responses import StreamingHTMLResponse
7 |
8 |
9 | class RedirectsPanel(Panel):
10 | has_content = False
11 | nav_title = "Intercept redirects"
12 | template = "redirect.html"
13 |
14 | async def process_request(self, request: Request) -> Response:
15 | response = await super().process_request(request)
16 |
17 | if 300 <= response.status_code < 400:
18 | if redirect_to := response.headers.get("Location"):
19 |
20 | async def content() -> t.AsyncGenerator[str, None]:
21 | yield self.render(
22 | redirect_to=redirect_to,
23 | status_code=response.status_code,
24 | )
25 |
26 | response = StreamingHTMLResponse(
27 | content=content(),
28 | status_code=status.HTTP_200_OK,
29 | )
30 | return response
31 |
--------------------------------------------------------------------------------
/docs/css/styles.css:
--------------------------------------------------------------------------------
1 | .md-header,
2 | .md-nav__source,
3 | .md-footer {
4 | background-color: #24292e;
5 | }
6 |
7 | .md-header .md-ellipsis > span {
8 | font-size: .6em;
9 | }
10 |
11 | .md-nav--primary {
12 | font-size: 1.8em;
13 | }
14 |
15 | .md-nav--secondary li > a[href^="#debug_toolbar.settings"] {
16 | text-transform: lowercase;
17 | }
18 |
19 | .md-nav__title,
20 | h4[id^="debug_toolbar.setting"] .doc-label-class-attribute {
21 | display: none;
22 | }
23 |
24 | h4[id^="debug_toolbar"] > code :first-child {
25 | font-weight: bold;
26 | text-transform: lowercase;
27 | }
28 |
29 | img.dt-tab {
30 | display: block;
31 | float: right;
32 | margin-top: -30px;
33 | }
34 |
35 | .doc > code span:nth-child(n+2) {
36 | font-size: .8em;
37 | }
38 |
39 | .doc-contents > p {
40 | padding-left: 30px;
41 | font-style: italic;
42 | font-size: .9em;
43 | color: #383838;
44 | }
45 |
46 | .md-typeset p > code {
47 | font-weight: bold;
48 | color: var(--md-primary-fg-color);
49 | background-color: #f7f7f9;
50 | padding: 2px 4px;
51 | border: 1px solid #e1e1e8;
52 | }
53 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | A debug toolbar for FastAPI based on the original [django-debug-toolbar](https://github.com/jazzband/django-debug-toolbar).
2 |
3 | **Swagger UI** & **GraphQL** are supported.
4 |
5 | ## Installation
6 |
7 | ```sh
8 | pip install fastapi-debug-toolbar
9 | ```
10 |
11 | !!! info
12 |
13 | The following packages are automatically installed:
14 |
15 | * [Jinja2](https://github.com/pallets/jinja) for toolbar templates.
16 | * [pyinstrument](https://github.com/joerick/pyinstrument) for profiling support.
17 |
18 | ## Quickstart
19 |
20 | Add `DebugToolbarMiddleware` middleware to your FastAPI application:
21 |
22 | ```py
23 | {!src/quickstart.py!}
24 | ```
25 |
26 | ## How it works
27 |
28 | { class=dt-tab }
29 |
30 | Once installed, the **debug toolbar tab** is displayed on the right side of any html page, just **click on it** to open the navbar.
31 |
32 | The debug toolbar can be used with [Swagger UI](https://swagger.io/tools/swagger-ui/) or [GraphiQL](https://github.com/graphql/graphiql) and it is automatically updated after any request using a cookie-based system.
33 |
34 | 
35 |
--------------------------------------------------------------------------------
/debug_toolbar/panels/profiling.py:
--------------------------------------------------------------------------------
1 | import typing as t
2 |
3 | from fastapi import Request, Response
4 | from pyinstrument import Profiler
5 | from starlette.concurrency import run_in_threadpool
6 |
7 | from debug_toolbar.panels import Panel
8 | from debug_toolbar.types import Stats
9 | from debug_toolbar.utils import is_coroutine
10 |
11 |
12 | class ProfilingPanel(Panel):
13 | title = "Profiling"
14 | template = "panels/profiling.html"
15 |
16 | async def process_request(self, request: Request) -> Response:
17 | self.profiler = Profiler(**self.toolbar.settings.PROFILER_OPTIONS)
18 | is_async = is_coroutine(request["route"].endpoint)
19 |
20 | async def call(f: t.Callable) -> None:
21 | await run_in_threadpool(f) if not is_async else f()
22 |
23 | await call(self.profiler.start)
24 |
25 | try:
26 | response = await super().process_request(request)
27 | finally:
28 | await call(self.profiler.stop)
29 | return response
30 |
31 | async def generate_stats(self, request: Request, response: Response) -> Stats:
32 | return {"content": self.profiler.output_html()}
33 |
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: FastAPI Debug Toolbar
2 | site_description: A debug toolbar for FastAPI.
3 | site_url: https://fastapi-debug-toolbar.domake.io
4 |
5 | repo_name: mongkok/fastapi-debug-toolbar
6 | repo_url: https://github.com/mongkok/fastapi-debug-toolbar
7 | edit_uri: ''
8 |
9 | theme:
10 | name: material
11 | custom_dir: docs/overrides
12 | logo: img/logo.png
13 | favicon: img/favicon.ico
14 | icon:
15 | repo: fontawesome/brands/github-alt
16 | palette:
17 | - scheme: default
18 | primary: red
19 | accent: red
20 |
21 | nav:
22 | - Introduction: index.md
23 | - Default panels: panels/default.md
24 | - SQL panels: panels/sql.md
25 | - Development: panels/dev.md
26 | - Settings: settings.md
27 | - Changelog: changelog.md
28 |
29 | extra:
30 | social:
31 | - icon: fontawesome/brands/github-alt
32 | link: https://github.com/mongkok
33 |
34 | extra_css:
35 | - css/styles.css
36 |
37 | markdown_extensions:
38 | - admonition
39 | - pymdownx.superfences
40 | - pymdownx.snippets
41 | - attr_list
42 | - markdown_include.include:
43 | base_path: docs
44 |
45 | plugins:
46 | - search
47 | - mkdocstrings
48 |
--------------------------------------------------------------------------------
/docs/panels/default.md:
--------------------------------------------------------------------------------
1 | Here's a list of default panels available:
2 |
3 | ## Versions
4 |
5 | 
6 |
7 | ## Timer
8 |
9 | 
10 |
11 | ## Settings
12 |
13 | 
14 |
15 | Add your pydantic's [BaseSettings](https://pydantic-docs.helpmanual.io/usage/settings/) classes to this panel:
16 |
17 | ```py hl_lines="11"
18 | {!src/panels/settings.py!}
19 | ```
20 |
21 | ## Request
22 |
23 | 
24 |
25 | ## Headers
26 |
27 | 
28 |
29 | ## Routes
30 |
31 | 
32 |
33 | ## Logging
34 |
35 | 
36 |
37 | ## Profiling
38 |
39 | 
40 |
41 | Profiling reports provided by [Pyinstrument](https://github.com/joerick/pyinstrument), you can configure the [profiler parameters](https://pyinstrument.readthedocs.io/en/latest/reference.html#pyinstrument.Profiler) by adding `profiler_options` settings:
42 |
43 | ```py hl_lines="5"
44 | {!src/panels/profiling.py!}
45 | ```
46 |
--------------------------------------------------------------------------------
/tests/panels/sqlalchemy/conftest.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import typing as t
4 |
5 | import pytest
6 | from fastapi import Depends, FastAPI, Request
7 | from fastapi.responses import HTMLResponse
8 | from sqlalchemy.orm import Session, sessionmaker
9 |
10 | from ...testclient import TestClient
11 | from .crud import create_user, get_user
12 | from .database import Base, engine
13 |
14 | Base.metadata.create_all(bind=engine)
15 |
16 |
17 | @pytest.fixture
18 | def get_db(session_options: dict[str, t.Any]) -> t.Callable:
19 | SessionLocal = sessionmaker(**session_options or {"bind": engine})
20 |
21 | def f() -> t.Generator:
22 | db = SessionLocal()
23 | try:
24 | yield db
25 | finally:
26 | db.close()
27 |
28 | return f
29 |
30 |
31 | @pytest.fixture
32 | def client(app: FastAPI, get_index: t.Callable, get_db: t.Callable) -> TestClient:
33 | @app.get("/sql", response_class=HTMLResponse)
34 | async def get_sql(request: Request, db: Session = Depends(get_db)) -> HTMLResponse:
35 | user = create_user(db=db, username=str(id(get_db)))
36 | get_user(db=db, user_id=user.id)
37 | get_user(db=db, user_id=user.id)
38 | return get_index(request)
39 |
40 | return TestClient(app)
41 |
--------------------------------------------------------------------------------
/debug_toolbar/statics/js/refresh.js:
--------------------------------------------------------------------------------
1 | const refresh = (function () {
2 | function getCookie(name) {
3 | const parts = `; ${document.cookie}`.split(`; ${name}=`);
4 | if (parts.length === 2) return parts.pop().split(";").shift();
5 | }
6 | let lastCookie = getCookie("dtRefresh");
7 | const fastDebug = document.getElementById("fastDebug");
8 |
9 | return function () {
10 | const dtCookie = getCookie("dtRefresh");
11 |
12 | if (dtCookie && dtCookie !== lastCookie) {
13 | lastCookie = dtCookie;
14 | const toolbar = JSON.parse(decodeURIComponent(lastCookie));
15 |
16 | Object.entries(toolbar.panels).map(([id, subtitle]) => {
17 | document.getElementById(`fastdt-${id}`).querySelector("small").textContent = subtitle;
18 | });
19 | fastDebug.querySelectorAll(".fastDebugPanelContent").forEach(function (e) {
20 | e.querySelector(".fastdt-scroll").innerHTML = "";
21 |
22 | if (e.querySelector(".fastdt-loader") === null) {
23 | const loader = document.createElement("div");
24 | loader.className = "fastdt-loader";
25 | e.prepend(loader);
26 | }
27 | });
28 | fastDebug.setAttribute("data-store-id", toolbar.storeId);
29 | }
30 | };
31 | })();
32 |
33 | window.setInterval(refresh, 100);
34 |
--------------------------------------------------------------------------------
/tests/panels/test_logging.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import typing as t
3 |
4 | import pytest
5 | from fastapi import FastAPI, Request
6 | from fastapi.logger import logger
7 | from fastapi.responses import HTMLResponse
8 |
9 | from ..mark import override_panels
10 | from ..testclient import TestClient
11 |
12 |
13 | @pytest.fixture
14 | def client(app: FastAPI, get_index: t.Callable) -> TestClient:
15 | @app.get("/log/sync", response_class=HTMLResponse)
16 | def get_log(request: Request, level: str) -> HTMLResponse:
17 | logger.log(logging._nameToLevel[level], "")
18 | return get_index(request)
19 |
20 | @app.get("/log/async", response_class=HTMLResponse)
21 | async def get_log_async(request: Request, level: str) -> HTMLResponse:
22 | return get_log(request, level)
23 |
24 | return TestClient(app)
25 |
26 |
27 | @pytest.mark.parametrize("level", ["ERROR", "WARNING"])
28 | @pytest.mark.parametrize("path", ["sync", "async"])
29 | @override_panels(["debug_toolbar.panels.logging.LoggingPanel"])
30 | def test_logging(client: TestClient, path: str, level: str) -> None:
31 | store_id = client.get_store_id(f"/log/{path}", params={"level": level})
32 | stats = client.get_stats(store_id, "LoggingPanel")
33 |
34 | assert stats["records"][0]["level"] == level
35 |
--------------------------------------------------------------------------------
/debug_toolbar/templates/panels/headers.html:
--------------------------------------------------------------------------------
1 | Request headers
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Key
11 | Value
12 |
13 |
14 |
15 | {% for key, value in request_headers.items()|sort %}
16 |
17 | {{ key|escape }}
18 | {{ value|escape }}
19 |
20 | {% endfor %}
21 |
22 |
23 |
24 | Response headers
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | Key
34 | Value
35 |
36 |
37 |
38 | {% for key, value in response_headers.items()|sort %}
39 |
40 | {{ key|escape }}
41 | {{ value|escape }}
42 |
43 | {% endfor %}
44 |
45 |
46 |
47 | ASGI environ
48 |
49 |
50 |
51 |
52 | Key
53 | Value
54 |
55 |
56 |
57 | {% for key, value in environ.items()|sort %}
58 |
59 | {{ key|escape }}
60 | {{ value|escape }}
61 |
62 | {% endfor %}
63 |
64 |
65 |
--------------------------------------------------------------------------------
/tests/testclient.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import re
4 | import typing as t
5 | from urllib import parse
6 |
7 | import httpx
8 | from fastapi import status
9 | from fastapi.testclient import TestClient as BaseTestClient
10 |
11 | from debug_toolbar.toolbar import DebugToolbar
12 | from debug_toolbar.types import Stats
13 |
14 |
15 | class TestClient(BaseTestClient):
16 | def get_store_id(
17 | self,
18 | path: str,
19 | method: str | None = None,
20 | **kwargs: t.Any,
21 | ) -> str:
22 | response = getattr(self, (method or "get"))(path, **kwargs)
23 |
24 | if response.headers["content-type"].startswith("text/html"):
25 | return re.findall(r'store-id="(.+?)"', response.content.decode())[0]
26 |
27 | cookie = parse.unquote(response.headers["set-cookie"])
28 | return re.findall(r'"storeId": "(.+?)"', cookie)[0]
29 |
30 | def render_panel(self, store_id: str, panel_id: str) -> httpx.Response:
31 | return self.get(
32 | "/_debug_toolbar",
33 | params={"store_id": store_id, "panel_id": panel_id},
34 | )
35 |
36 | def get_stats(self, store_id: str, panel_id: str) -> Stats:
37 | response = self.render_panel(store_id, panel_id)
38 | assert response.status_code == status.HTTP_200_OK
39 |
40 | toolbar = DebugToolbar.fetch(store_id)
41 | assert toolbar
42 | return toolbar.stats[panel_id]
43 |
--------------------------------------------------------------------------------
/debug_toolbar/statics/img/icon-green.svg:
--------------------------------------------------------------------------------
1 |
2 |
13 |
15 |
17 |
18 |
20 | image/svg+xml
21 |
23 |
24 |
25 |
26 |
27 |
30 |
34 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/debug_toolbar/statics/img/icon-white.svg:
--------------------------------------------------------------------------------
1 |
2 |
13 |
15 |
17 |
18 |
20 | image/svg+xml
21 |
23 |
24 |
25 |
26 |
27 |
30 |
34 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import typing as t
4 |
5 | import pytest
6 | from fastapi import FastAPI, Request
7 | from fastapi.responses import HTMLResponse
8 | from fastapi.templating import Jinja2Templates
9 |
10 | from debug_toolbar.middleware import DebugToolbarMiddleware
11 | from debug_toolbar.toolbar import DebugToolbar
12 |
13 | from .testclient import TestClient
14 |
15 |
16 | @pytest.fixture
17 | def settings() -> dict[str, t.Any]:
18 | return {}
19 |
20 |
21 | @pytest.fixture
22 | async def app(settings: dict[str, t.Any]) -> FastAPI:
23 | settings.setdefault("default_panels", [])
24 | settings.setdefault("disable_panels", [])
25 | DebugToolbar._panel_classes = None
26 |
27 | _app = FastAPI(debug=True)
28 | _app.add_middleware(DebugToolbarMiddleware, **settings)
29 | return _app
30 |
31 |
32 | @pytest.fixture
33 | def templates() -> Jinja2Templates:
34 | return Jinja2Templates(directory="tests/templates")
35 |
36 |
37 | @pytest.fixture
38 | def get_index(templates: Jinja2Templates) -> t.Callable:
39 | def f(request: Request) -> HTMLResponse:
40 | return templates.TemplateResponse(name="index.html", request=request)
41 |
42 | return f
43 |
44 |
45 | @pytest.fixture
46 | def client(app: FastAPI, get_index: t.Callable) -> TestClient:
47 | app.get("/sync", response_class=HTMLResponse)(get_index)
48 |
49 | @app.get("/async", response_class=HTMLResponse)
50 | async def get_async(request: Request) -> HTMLResponse:
51 | return get_index(request)
52 |
53 | return TestClient(app)
54 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) Rob Hudson and individual contributors.
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without modification,
5 | are permitted provided that the following conditions are met:
6 |
7 | 1. Redistributions of source code must retain the above copyright notice,
8 | this list of conditions and the following disclaimer.
9 |
10 | 2. Redistributions in binary form must reproduce the above copyright
11 | notice, this list of conditions and the following disclaimer in the
12 | documentation and/or other materials provided with the distribution.
13 |
14 | 3. Neither the name of Django nor the names of its contributors may be used
15 | to endorse or promote products derived from this software without
16 | specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
/debug_toolbar/templates/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
12 |
20 |
28 |
29 | {% for panel in toolbar.panels %}
30 | {% include "includes/panel_content.html" %}
31 | {% endfor %}
32 |
33 |
34 |
--------------------------------------------------------------------------------
/debug_toolbar/templates/panels/request.html:
--------------------------------------------------------------------------------
1 | Route information
2 |
3 |
4 |
5 |
6 | Name
7 | Endpoint
8 | Path params
9 |
10 |
11 |
12 |
13 | {{ request.route.name }}
14 | {{ get_name_from_obj(request.endpoint) }}
15 |
16 | {% if request.path_params %}
17 | {% for k, v in request.path_params.items() %}
18 | {{ k }}={{ v }}{% if not loop.last %}, {% endif %}
19 | {% endfor %}
20 | {% else %}
21 | -
22 | {% endif %}
23 |
24 |
25 |
26 |
27 |
28 | {% macro pprint_vars(variables) %}
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | Variable
37 | Value
38 |
39 |
40 |
41 | {% for key, value in variables|sort %}
42 |
43 | {{ key|pprint }}
44 | {{ value|pprint }}
45 |
46 | {% endfor %}
47 |
48 |
49 | {% endmacro %}
50 |
51 |
52 | Cookies
53 | {% if request.cookies %}
54 | {{ pprint_vars(request.cookies.items()) }}
55 | {% else %}
56 | No cookies
57 | {% endif %}
58 |
59 | Session data
60 | {% if session %}
61 | {{ pprint_vars(session.items()) }}
62 | {% else %}
63 | No session data
64 | {% endif %}
65 |
66 | GET data
67 | {% if request.query_params %}
68 | {{ pprint_vars(request.query_params.multi_items()) }}
69 | {% else %}
70 | No GET data
71 | {% endif %}
72 |
73 | POST data
74 | {% if form %}
75 | {{ pprint_vars(form.multi_items()) }}
76 | {% else %}
77 | No POST data
78 | {% endif %}
79 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | #  Debug Toolbar
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | 🐞A debug toolbar for FastAPI based on the original django-debug-toolbar.🐞
10 | Swagger UI & GraphQL are supported.
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | ---
29 |
30 | **Documentation**: [https://fastapi-debug-toolbar.domake.io](https://fastapi-debug-toolbar.domake.io)
31 |
32 | ---
33 |
34 | ## Installation
35 |
36 | ```sh
37 | pip install fastapi-debug-toolbar
38 | ```
39 |
40 | ## Quickstart
41 |
42 | Add `DebugToolbarMiddleware` middleware to your FastAPI application:
43 |
44 | ```py
45 | from debug_toolbar.middleware import DebugToolbarMiddleware
46 | from fastapi import FastAPI
47 |
48 | app = FastAPI(debug=True)
49 | app.add_middleware(DebugToolbarMiddleware)
50 | ```
51 |
52 | ## SQLAlchemy
53 |
54 | Please make sure to use the *"Dependency Injection"* system as described in the [FastAPI docs](https://fastapi.tiangolo.com/tutorial/sql-databases/#create-a-dependency) and add the `SQLAlchemyPanel` to your panel list:
55 |
56 | ```py
57 | app.add_middleware(
58 | DebugToolbarMiddleware,
59 | panels=["debug_toolbar.panels.sqlalchemy.SQLAlchemyPanel"],
60 | )
61 | ```
62 |
63 | ## Tortoise ORM
64 |
65 | Add the `TortoisePanel` to your panel list:
66 |
67 | ```py
68 | app.add_middleware(
69 | DebugToolbarMiddleware,
70 | panels=["debug_toolbar.panels.tortoise.TortoisePanel"],
71 | )
72 | ```
73 |
--------------------------------------------------------------------------------
/debug_toolbar/utils.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | import functools
5 | import inspect
6 | import sys
7 | import typing as t
8 |
9 | from fastapi import Request
10 | from fastapi.routing import APIRoute
11 | from pydantic_extra_types.color import Color
12 | from starlette.routing import Match
13 |
14 |
15 | def import_string(import_name: str) -> t.Any:
16 | try:
17 | __import__(import_name)
18 | except ImportError:
19 | if "." not in import_name:
20 | raise
21 | else:
22 | return sys.modules[import_name]
23 |
24 | module_name, obj_name = import_name.rsplit(".", 1)
25 | module = __import__(module_name, globals(), locals(), [obj_name])
26 | try:
27 | return getattr(module, obj_name)
28 | except AttributeError as e:
29 | raise ImportError(e) from None
30 |
31 |
32 | def get_name_from_obj(obj: t.Any) -> str:
33 | if hasattr(obj, "__name__"):
34 | name = obj.__name__
35 | else:
36 | name = obj.__class__.__name__
37 |
38 | if hasattr(obj, "__module__"):
39 | name = f"{obj.__module__}.{name}"
40 | return name
41 |
42 |
43 | def matched_route(request: Request) -> APIRoute | None:
44 | for route in request.app.routes:
45 | match, _ = route.matches(request.scope)
46 |
47 | if match == Match.FULL:
48 | if hasattr(route, "endpoint"):
49 | return route
50 | break
51 | return None
52 |
53 |
54 | def is_coroutine(endpoint: t.Callable) -> bool:
55 | handler = endpoint
56 |
57 | while isinstance(handler, functools.partial):
58 | handler = handler.func
59 | if not (inspect.ismethod(handler) or inspect.isfunction(handler)):
60 | handler = handler.__call__ # type: ignore[operator]
61 | return asyncio.iscoroutinefunction(handler)
62 |
63 |
64 | def pluralize(value: int, arg: str = "s") -> str:
65 | if "," not in arg:
66 | arg = f",{arg}"
67 |
68 | bits = arg.split(",")
69 |
70 | if len(bits) > 2:
71 | return ""
72 |
73 | singular_suffix, plural_suffix = bits[:2]
74 |
75 | if value == 1:
76 | return singular_suffix
77 | return plural_suffix
78 |
79 |
80 | def color_generator() -> t.Generator[Color, None, None]:
81 | triples = [
82 | (0, 1, 1),
83 | (1, 1, 0),
84 | (1, 0, 1),
85 | (1, 1, 1),
86 | (0, 1, 0),
87 | (0, 0, 1),
88 | ]
89 | n = 1 << 7
90 | so_far = [[0, 0, 0]]
91 | while True:
92 | if n == 0:
93 | yield Color("black")
94 | copy_so_far = list(so_far)
95 | for triple in triples:
96 | for previous in copy_so_far:
97 | rgb = [n * triple[i] + previous[i] for i in range(3)]
98 | so_far.append(rgb)
99 | yield Color(f"rgb{tuple(rgb)}")
100 | n >>= 1
101 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ### 0.6.3
4 |
5 | * Added support to SQLAlchemy `AsyncSession` and multiple binds
6 |
7 | ### 0.6.2
8 |
9 | * **SQLAlchemyPanel**: Handled HTTPException from dependencies
10 | * Removed pydantic future annotations
11 |
12 | ### 0.6.1
13 |
14 | * Removed deprecated `fastapi_astack` scope
15 | * Fixed `0.6` release tag.
16 |
17 | ### 0.6.0
18 |
19 | * **SQLAlchemyPanel**: Added `async_exit_stack` arg to `solve_dependencies` function
20 | * **DebugToolbarMiddleware**: Removed `settings.ALLOWED_IPS` in favor of `settings.ALLOWED_HOSTS`
21 | * **VersionsPanel**: Removed `pkg_resources` in favor of `importlib.metadata`
22 | * Added ruff and bandit and removed black and isort
23 | * Removed deprecated `on_event`
24 | * Added minor improvements
25 |
26 | ### 0.5.0
27 |
28 | * Added Pydantic v2 support
29 | * Removed Pydantic v1 support
30 | * Removed `PydanticPanel`
31 |
32 | ### 0.4.0
33 |
34 | * Fixed middleware `url_path_for`
35 | * Improved SQLAlchemy panel
36 |
37 | ### 0.3.2
38 |
39 | * Fixed response body stream
40 |
41 | ### 0.3.1
42 |
43 | * Fixed pyproject.toml, added package data
44 | * Improved panel templates
45 | * Fixed profiling on Safari browser
46 |
47 | ### 0.3.0
48 |
49 | * Added refresh cookie system and `JSON.parse` swap removed
50 | * Fixed SQL query encoding
51 | * Fixed `SQLAlchemyPanel` , added missing `fastapi_astack` to scope (`fastapi >= 0.74.0`)
52 | * Added `SQLAlchemyPanel.add_engines` method
53 | * Added `tortoise-orm >= 0.19.0` support
54 | * Fixed `VersionsPanel` JS, package home can be null
55 |
56 | ### 0.2.1
57 |
58 | * Added `PydanticPanel`
59 | * Removed `current_thread` in favor of `get_ident`
60 | * Added anyio task groups
61 | * Removed `get_running_loop` in favor of `get_event_loop`
62 | * Improved tables styles
63 |
64 | ### 0.2.0
65 |
66 | * Fixed `ThreadPoolExecutor` for all sync endpoints
67 | * Added cookie-based refresh
68 | * Added exception handling for dependency resolution
69 | * Added minor improvements to `VersionPanel`
70 |
71 | ### 0.1.3
72 |
73 | * Added `TortoisePanel`
74 |
75 | ### 0.1.2
76 |
77 | * Removed SQL compiled query in favor of statement params
78 | * Added SQLAlchemy unregister
79 | * Added `SQLPanel` base class
80 |
81 | ### 0.1.1
82 |
83 | * Improved dependency resolution
84 | * Added minor improvements
85 |
86 | ### 0.1.0
87 |
88 | * Added `SQLAlchemyPanel`
89 | * Added `LOGGING_COLORS` to panel templates
90 | * Minor improvements
91 |
92 | ### 0.0.6
93 |
94 | * Improved `VersionsPanel` script
95 | * Added docs
96 |
97 | ### 0.0.5
98 |
99 | * Fixed multiple profilers on the same thread
100 | * Fixed `VersionsPanel` Pypi url
101 |
102 | ### 0.0.4
103 |
104 | * Added pypi details to `VersionsPanel`
105 | * Improved assets
106 | * Added `LOGGING_COLORS`
107 | * Highlighted matched endpoint
108 |
109 | ### 0.0.3
110 |
111 | * Sorted routes by path
112 |
113 | ### 0.0.2
114 |
115 | * Added mounted apps support (e.g. ariadne.asgi.GraphQL)
116 |
117 | ### 0.0.1
118 |
119 | * 📦
120 |
--------------------------------------------------------------------------------
/debug_toolbar/panels/sqlalchemy.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import typing as t
4 | from time import perf_counter
5 |
6 | from fastapi import Request, Response
7 | from sqlalchemy import event
8 | from sqlalchemy.engine import Connection, Engine, ExecutionContext
9 | from sqlalchemy.exc import UnboundExecutionError
10 | from sqlalchemy.ext.asyncio import AsyncSession
11 | from sqlalchemy.orm import Session
12 |
13 | from debug_toolbar.dependencies import get_dependencies
14 | from debug_toolbar.panels.sql import SQLPanel
15 |
16 |
17 | class SQLAlchemyPanel(SQLPanel):
18 | title = "SQLAlchemy"
19 |
20 | def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
21 | super().__init__(*args, **kwargs)
22 | self.engines: t.Set[Engine] = set()
23 |
24 | def register(self, engine: Engine) -> None:
25 | event.listen(engine, "before_cursor_execute", self.before_execute, named=True)
26 | event.listen(engine, "after_cursor_execute", self.after_execute, named=True)
27 |
28 | def unregister(self, engine: Engine) -> None:
29 | event.remove(engine, "before_cursor_execute", self.before_execute)
30 | event.remove(engine, "after_cursor_execute", self.after_execute)
31 |
32 | def before_execute(self, context: ExecutionContext, **kwargs: t.Any) -> None:
33 | context._start_time = perf_counter() # type: ignore[attr-defined]
34 |
35 | def after_execute(self, context: ExecutionContext, **kwargs: t.Any) -> None:
36 | query = {
37 | "duration": (
38 | perf_counter() - context._start_time # type: ignore[attr-defined]
39 | )
40 | * 1000,
41 | "sql": context.statement,
42 | "params": context.parameters,
43 | }
44 | self.add_query(str(context.engine.url), query)
45 |
46 | def add_bind(self, bind: Connection | Engine):
47 | if isinstance(bind, Connection):
48 | self.engines.add(bind.engine)
49 | else:
50 | self.engines.add(bind)
51 |
52 | async def add_engines(self, request: Request):
53 | dependencies = await get_dependencies(request)
54 |
55 | if dependencies is not None:
56 | for value in dependencies.values():
57 | if isinstance(value, AsyncSession):
58 | value = value.sync_session
59 |
60 | if isinstance(value, Session):
61 | try:
62 | bind = value.get_bind()
63 | except UnboundExecutionError:
64 | for bind in value._Session__binds.values(): # type: ignore[attr-defined]
65 | self.add_bind(bind)
66 | else:
67 | self.add_bind(bind)
68 |
69 | async def process_request(self, request: Request) -> Response:
70 | await self.add_engines(request)
71 |
72 | for engine in self.engines:
73 | self.register(engine)
74 | try:
75 | response = await super().process_request(request)
76 | finally:
77 | for engine in self.engines:
78 | self.unregister(engine)
79 | return response
80 |
--------------------------------------------------------------------------------
/debug_toolbar/panels/tortoise.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import typing as t
4 | from contextlib import contextmanager
5 | from time import perf_counter
6 |
7 | from fastapi import Request, Response
8 | from tortoise.backends.base.client import BaseDBAsyncClient
9 |
10 | try:
11 | from tortoise.connection import connections
12 | except ImportError:
13 | connections = None # type: ignore
14 |
15 | from debug_toolbar.panels.sql import SQLPanel, raw_sql
16 |
17 |
18 | class DBWrapper:
19 | def __init__(self, db: BaseDBAsyncClient, on_execute: t.Callable[..., t.Any]):
20 | self.db = db
21 | self.on_execute = on_execute
22 |
23 | def __getattr__(self, attr: str) -> t.Any:
24 | return getattr(self.db, attr)
25 |
26 | async def execute_insert(self, query: str, values: list) -> t.Any:
27 | with self.on_execute(self.db, query, values):
28 | return await self.db.execute_insert(query, values)
29 |
30 | async def execute_query(
31 | self,
32 | query: str,
33 | values: list | None = None,
34 | ) -> tuple[int, t.Sequence[dict]]:
35 | with self.on_execute(self.db, query, values):
36 | return await self.db.execute_query(query, values)
37 |
38 | async def execute_script(self, query: str) -> None:
39 | with self.on_execute(self.db, query):
40 | await self.db.execute_script(query)
41 |
42 | async def execute_many(self, query: str, values: list[list]) -> None:
43 | with self.on_execute(self.db, query, values):
44 | await self.db.execute_many(query, values)
45 |
46 | async def execute_query_dict(
47 | self,
48 | query: str,
49 | values: list | None = None,
50 | ) -> list[dict]:
51 | with self.on_execute(self.db, query, values):
52 | return await self.db.execute_query_dict(query, values)
53 |
54 |
55 | class TortoisePanel(SQLPanel):
56 | title = "Tortoise ORM"
57 |
58 | @contextmanager
59 | def on_execute(
60 | self,
61 | db: BaseDBAsyncClient,
62 | statement: str,
63 | values: t.Any = None,
64 | ) -> t.Iterator[None]:
65 | start_time = perf_counter()
66 | try:
67 | yield
68 | finally:
69 | query = {
70 | "duration": (perf_counter() - start_time) * 1000,
71 | "sql": statement,
72 | "raw": raw_sql(statement),
73 | "params": values,
74 | "is_select": statement.lower().strip().startswith("select"),
75 | }
76 | self.add_query(db.connection_name, query)
77 |
78 | async def process_request(self, request: Request) -> Response:
79 | assert connections is not None, "tortoise-orm >= 0.19.0 is required"
80 |
81 | for conn in connections.all():
82 | db = DBWrapper(conn, self.on_execute)
83 | connections.set(conn.connection_name, db) # type: ignore[arg-type]
84 | try:
85 | response = await super().process_request(request)
86 | finally:
87 | for conn in connections.all():
88 | connections.set(conn.connection_name, conn.db) # type: ignore
89 | return response
90 |
--------------------------------------------------------------------------------
/debug_toolbar/statics/js/timer.js:
--------------------------------------------------------------------------------
1 | import { $$ } from "./utils.js";
2 |
3 | function insertBrowserTiming() {
4 | const timing = performance.timing,
5 | timingOffset = timing.navigationStart,
6 | totalTime = timing.loadEventEnd - timingOffset;
7 |
8 | function getLeft(stat) {
9 | return ((timing[stat] - timingOffset) / totalTime) * 100.0;
10 | }
11 | function getCSSWidth(stat, endStat) {
12 | let width = ((timing[endStat] - timing[stat]) / totalTime) * 100.0;
13 | // Calculate relative percent
14 | width = (100.0 * width) / (100.0 - getLeft(stat));
15 | return width < 1 ? "2px" : `${width || 0}%`;
16 | }
17 | function addRow(tbody, stat, endStat) {
18 | const row = document.createElement("tr");
19 | if (endStat) {
20 | // Render a start through end bar
21 | row.innerHTML = `
22 | ${stat.replace("Start", "")}
23 |
24 |
28 |
29 |
30 |
31 |
32 | ${timing[stat] - timingOffset}
33 | (${timing[endStat] - timing[stat]})
34 | `;
35 | row.querySelector("rect").setAttribute("width", getCSSWidth(stat, endStat));
36 | } else {
37 | // Render a point in time
38 | row.innerHTML = `
39 | ${stat}
40 |
41 |
45 |
46 |
47 |
48 | ${timing[stat] - timingOffset} `;
49 | row.querySelector("rect").setAttribute("width", 2);
50 | }
51 | row.querySelector("rect").setAttribute("x", getLeft(stat));
52 | tbody.appendChild(row);
53 | }
54 |
55 | const browserTiming = document.getElementById("fastDebugBrowserTiming");
56 | // Determine if the browser timing section has already been rendered.
57 | if (browserTiming.classList.contains("fastdt-hidden")) {
58 | const tbody = document.getElementById("fastDebugBrowserTimingTableBody");
59 | // This is a reasonably complete and ordered set of
60 | // timing periods (2 params) and events (1 param)
61 | addRow(tbody, "domainLookupStart", "domainLookupEnd");
62 | addRow(tbody, "connectStart", "connectEnd");
63 | addRow(tbody, "requestStart", "responseEnd"); // There is no requestEnd
64 | addRow(tbody, "responseStart", "responseEnd");
65 | addRow(tbody, "domLoading", "domComplete"); // Spans the events below
66 | addRow(tbody, "domInteractive");
67 | addRow(tbody, "domContentLoadedEventStart", "domContentLoadedEventEnd");
68 | addRow(tbody, "loadEventStart", "loadEventEnd");
69 | browserTiming.classList.remove("fastdt-hidden");
70 | }
71 | }
72 |
73 | const fastDebug = document.getElementById("fastDebug");
74 | // Insert the browser timing now since it's possible for this
75 | // script to miss the initial panel load event.
76 | insertBrowserTiming();
77 | $$.onPanelRender(fastDebug, "TimerPanel", insertBrowserTiming);
78 |
--------------------------------------------------------------------------------
/debug_toolbar/panels/timer.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from time import perf_counter
4 |
5 | from fastapi import Request, Response
6 |
7 | from debug_toolbar.panels import Panel
8 | from debug_toolbar.types import ServerTiming, Stats
9 |
10 | try:
11 | import resource
12 | except ImportError:
13 | resource = None # type: ignore[assignment]
14 |
15 |
16 | class TimerPanel(Panel):
17 | has_content = resource is not None
18 | title = "Time"
19 | template = "panels/timer.html"
20 |
21 | @property
22 | def nav_subtitle(self) -> str:
23 | stats = self.get_stats()
24 |
25 | if hasattr(self, "_start_ru"):
26 | utime = self._end_ru.ru_utime - self._start_ru.ru_utime
27 | stime = self._end_ru.ru_stime - self._start_ru.ru_stime
28 | return f"CPU: {(utime + stime) * 1000.0:.2f}ms ({stats['elapsed']:.2f}ms)"
29 |
30 | return f"Total: {stats['elapsed']:.2f}ms"
31 |
32 | @property
33 | def content(self) -> str:
34 | stats = self.get_stats()
35 |
36 | rows = (
37 | ("User CPU time", f"{stats['utime']:.3f} msec"),
38 | ("System CPU time", f"{stats['stime']:.3f} msec"),
39 | ("Total CPU time", f"{stats['total']:.3f} msec"),
40 | ("Elapsed time", f"{stats['elapsed']:.3f} msec"),
41 | (
42 | "Context switches",
43 | f"{stats['vcsw']} voluntary, {stats['ivcsw']} involuntary",
44 | ),
45 | )
46 | return self.render(rows=rows)
47 |
48 | @property
49 | def scripts(self) -> list[str]:
50 | scripts = super().scripts
51 | scripts.append(self.url_for("debug_toolbar.static", path="js/timer.js"))
52 | return scripts
53 |
54 | async def process_request(self, request: Request) -> Response:
55 | self._start_time = perf_counter()
56 | if self.has_content:
57 | self._start_ru = resource.getrusage(resource.RUSAGE_SELF)
58 | return await super().process_request(request)
59 |
60 | async def generate_stats(self, request: Request, response: Response) -> Stats:
61 | stats = {
62 | "elapsed": (perf_counter() - self._start_time) * 1000,
63 | }
64 | if hasattr(self, "_start_ru"):
65 | self._end_ru = resource.getrusage(resource.RUSAGE_SELF)
66 | utime = 1000 * self._elapsed_ru("ru_utime")
67 | stime = 1000 * self._elapsed_ru("ru_stime")
68 |
69 | stats.update(
70 | {
71 | "utime": utime,
72 | "stime": stime,
73 | "total": utime + stime,
74 | "vcsw": self._elapsed_ru("ru_nvcsw"),
75 | "ivcsw": self._elapsed_ru("ru_nivcsw"),
76 | }
77 | )
78 | return stats
79 |
80 | async def generate_server_timing(
81 | self,
82 | request: Request,
83 | response: Response,
84 | ) -> ServerTiming:
85 | stats = self.get_stats()
86 |
87 | return [
88 | ("utime", "User CPU time", stats.get("utime", 0)),
89 | ("stime", "System CPU time", stats.get("stime", 0)),
90 | ("total", "Total CPU time", stats.get("total", 0)),
91 | ("elapsed", "Elapsed time", stats.get("elapsed", 0)),
92 | ]
93 |
94 | def _elapsed_ru(self, name: str) -> float:
95 | return getattr(self._end_ru, name) - getattr(self._start_ru, name)
96 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["hatchling"]
3 | build-backend = "hatchling.build"
4 |
5 | [project]
6 | name = "fastapi-debug-toolbar"
7 | description = "A debug toolbar for FastAPI."
8 | readme = "README.md"
9 | license = "BSD-3-Clause"
10 | requires-python = ">=3.8"
11 | dynamic = ["version"]
12 | authors = [
13 | { name = "Dani", email = "dani@domake.io" }
14 | ]
15 | keywords = ["fastapi", "debug", "profiling"]
16 |
17 | classifiers = [
18 | "License :: OSI Approved :: BSD License",
19 | "Development Status :: 2 - Pre-Alpha",
20 | "Framework :: AnyIO",
21 | "Framework :: FastAPI",
22 | "Environment :: Web Environment",
23 | "Intended Audience :: Developers",
24 | "Intended Audience :: System Administrators",
25 | "Operating System :: OS Independent",
26 | "Typing :: Typed",
27 | "Programming Language :: Python",
28 | "Programming Language :: Python :: 3",
29 | "Programming Language :: Python :: 3.8",
30 | "Programming Language :: Python :: 3.9",
31 | "Programming Language :: Python :: 3.10",
32 | "Programming Language :: Python :: 3.11",
33 | "Programming Language :: Python :: 3.12",
34 | "Topic :: Software Development",
35 | "Topic :: Software Development :: Debuggers",
36 | "Topic :: Software Development :: Libraries",
37 | "Topic :: Software Development :: Libraries :: Python Modules",
38 | "Topic :: Internet",
39 | "Topic :: Internet :: WWW/HTTP",
40 | "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
41 | "Topic :: Utilities",
42 | ]
43 |
44 | dependencies = [
45 | "fastapi >=0.106.0",
46 | "anyio >=3.0.0",
47 | "Jinja2 >=2.9",
48 | "pydantic >=2.0",
49 | "pydantic-extra-types >=2.0.0",
50 | "pydantic-settings >=2.0.0",
51 | "pyinstrument >=3.0.0",
52 | "sqlparse >=0.2.0",
53 | ]
54 |
55 | [project.urls]
56 | homepage = "https://github.com/mongkok/fastapi-debug-toolbar"
57 | repository = "https://github.com/mongkok/fastapi-debug-toolbar"
58 | documentation = "https://fastapi-debug-toolbar.domake.io"
59 | changelog = "https://fastapi-debug-toolbar.domake.io/changelog/"
60 |
61 | [project.optional-dependencies]
62 | test = [
63 | "bandit",
64 | "coverage",
65 | "httpx",
66 | "itsdangerous",
67 | "mypy",
68 | "pytest",
69 | "pytest-asyncio",
70 | "python-multipart",
71 | "ruff",
72 | "SQLAlchemy",
73 | "tortoise-orm",
74 | ]
75 |
76 | doc = [
77 | "mkdocs",
78 | "mkdocs-material",
79 | "markdown-include",
80 | "mkdocstrings[python]",
81 | ]
82 |
83 | [tool.hatch.build.targets.sdist]
84 | include = ["debug_toolbar"]
85 |
86 | [tool.hatch.build.targets.wheel]
87 | packages = ["debug_toolbar"]
88 |
89 | [tool.hatch.version]
90 | path = "debug_toolbar/__init__.py"
91 |
92 | [[tool.mypy.overrides]]
93 | module = ["sqlparse"]
94 | ignore_missing_imports = true
95 |
96 | [tool.ruff.lint]
97 | select = [
98 | "E", # pycodestyle errors
99 | "W", # pycodestyle warnings
100 | "F", # pyflakes
101 | "I", # isort
102 | "C", # flake8-comprehensions
103 | "B", # flake8-bugbear
104 | ]
105 |
106 | [tool.ruff.lint.flake8-bugbear]
107 | extend-immutable-calls = ["fastapi.Depends"]
108 |
109 | [tool.bandit]
110 | skips = ["B101"]
111 |
112 | [tool.pytest.ini_options]
113 | addopts = ["--strict-config", "--strict-markers"]
114 | asyncio_mode = "auto"
115 |
116 | [tool.coverage.run]
117 | source_pkgs = ["debug_toolbar", "tests"]
118 |
--------------------------------------------------------------------------------
/debug_toolbar/panels/__init__.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import typing as t
4 |
5 | from fastapi import Request, Response
6 | from starlette.middleware.base import RequestResponseEndpoint
7 |
8 | from debug_toolbar.types import ServerTiming, Stats
9 | from debug_toolbar.utils import get_name_from_obj
10 |
11 | if t.TYPE_CHECKING:
12 | from debug_toolbar.toolbar import DebugToolbar
13 |
14 |
15 | class Panel:
16 | has_content: bool = True
17 |
18 | def __init__(
19 | self,
20 | toolbar: "DebugToolbar",
21 | call_next: RequestResponseEndpoint,
22 | ) -> None:
23 | self.toolbar = toolbar
24 | self.call_next = call_next
25 |
26 | @property
27 | def panel_id(self) -> str:
28 | return self.__class__.__name__
29 |
30 | @property
31 | def enabled(self) -> bool:
32 | disabled_panels = self.toolbar.settings.DISABLE_PANELS
33 | panel_path = get_name_from_obj(self)
34 |
35 | disable_panel = (
36 | panel_path in disabled_panels
37 | or panel_path.replace(".panel.", ".") in disabled_panels
38 | )
39 | if disable_panel:
40 | default = "off"
41 | else:
42 | default = "on"
43 |
44 | return self.toolbar.request.cookies.get(f"dt{self.panel_id}", default) == "on"
45 |
46 | @property
47 | def nav_title(self) -> str:
48 | return self.title
49 |
50 | @property
51 | def nav_subtitle(self) -> str:
52 | return ""
53 |
54 | @property
55 | def title(self) -> str:
56 | raise NotImplementedError
57 |
58 | @property
59 | def template(self) -> str:
60 | raise NotImplementedError
61 |
62 | @property
63 | def content(self) -> str:
64 | if self.has_content:
65 | return self.render(**self.get_stats())
66 | return ""
67 |
68 | def render(self, **context: t.Any) -> str:
69 | return self.toolbar.render(self.template, **context)
70 |
71 | def url_for(self, name: str, **path_params: t.Any) -> str:
72 | return str(self.toolbar.request.url_for(name, **path_params))
73 |
74 | @property
75 | def scripts(self) -> list[str]:
76 | return []
77 |
78 | async def process_request(self, request: Request) -> Response:
79 | return await self.call_next(request)
80 |
81 | async def generate_stats(self, request: Request, response: Response) -> Stats:
82 | return {}
83 |
84 | def get_stats(self) -> Stats:
85 | return self.toolbar.stats.get(self.panel_id, {})
86 |
87 | async def record_stats(self, request: Request, response: Response) -> None:
88 | stats = await self.generate_stats(request, response)
89 |
90 | if stats is not None:
91 | self.toolbar.stats.setdefault(self.panel_id, {}).update(stats)
92 |
93 | async def generate_server_timing(
94 | self,
95 | request: Request,
96 | response: Response,
97 | ) -> ServerTiming:
98 | return []
99 |
100 | def get_server_timing_stats(self) -> ServerTiming:
101 | return self.toolbar.server_timing_stats.get(self.panel_id, [])
102 |
103 | async def record_server_timing(self, request: Request, response: Response) -> None:
104 | stats = await self.generate_server_timing(request, response)
105 |
106 | if stats is not None:
107 | st_stats = self.toolbar.server_timing_stats.setdefault(self.panel_id, [])
108 | st_stats += list(stats)
109 |
--------------------------------------------------------------------------------
/debug_toolbar/statics/js/versions.js:
--------------------------------------------------------------------------------
1 | import { $$ } from "./utils.js";
2 |
3 | function pypiIndex() {
4 | const loader = document.getElementById("VersionsPanel").querySelector(".fastdt-loader");
5 |
6 | function versionInfo(releases, version) {
7 | return `
8 | ${version}
9 |
10 | ${new Date(releases[version]).toLocaleDateString()}
11 | `;
12 | }
13 | function link(url) {
14 | return `
15 |
16 | ${$$.truncatechars(url, 40)}
17 | `;
18 | }
19 | function render(rowVersion, data) {
20 | if (rowVersion.textContent !== data.version) {
21 | rowVersion.parentNode.style.backgroundColor = loader.nextElementSibling.dataset.warningColor;
22 | }
23 | if (data.releases[rowVersion.textContent] !== null) {
24 | rowVersion.innerHTML = versionInfo(data.releases, rowVersion.textContent);
25 | }
26 | const lastVersion = rowVersion.nextElementSibling;
27 | lastVersion.innerHTML = versionInfo(data.releases, data.version);
28 |
29 | const python = lastVersion.nextElementSibling;
30 | python.innerHTML = data.requires_python;
31 |
32 | const status = python.nextElementSibling;
33 | status.innerHTML = data.status ? data.status.slice(26) : "";
34 |
35 | if (data.home_page) {
36 | status.nextElementSibling.innerHTML = link(data.home_page);
37 | }
38 | }
39 | function getData(pypi) {
40 | return {
41 | version: pypi.info.version,
42 | requires_python: pypi.info.requires_python,
43 | home_page: pypi.info.home_page,
44 | releases: Object.fromEntries(
45 | Object.entries(pypi.releases).map(function ([k, v], i) {
46 | return [k, v.length ? v[0].upload_time : null];
47 | }),
48 | ),
49 | status: pypi.info.classifiers.find(function (classifier) {
50 | return classifier.startsWith("Development Status");
51 | }),
52 | };
53 | }
54 | function updateRow(row) {
55 | return new Promise((resolve) => {
56 | const name = row.firstElementChild.textContent.trim();
57 | const data = JSON.parse(localStorage.getItem(`pypi-${name}`));
58 | const rowVersion = row.children.item(1);
59 |
60 | if (data === null || !(rowVersion.textContent in data.releases)) {
61 | fetch(`https://pypi.org/pypi/${name}/json`).then(function (response) {
62 | if (response.ok) {
63 | response.json().then(function (pypi) {
64 | const data = getData(pypi);
65 |
66 | if (!(rowVersion.textContent in data.releases)) {
67 | data.releases[rowVersion.textContent] = null;
68 | }
69 | localStorage.setItem(`pypi-${name}`, JSON.stringify(data));
70 | render(rowVersion, data);
71 | resolve();
72 | });
73 | }
74 | });
75 | } else {
76 | render(rowVersion, data);
77 | resolve();
78 | }
79 | });
80 | }
81 | if (loader && !loader.getAttribute("lock")) {
82 | loader.setAttribute("lock", true);
83 |
84 | const table = loader.nextElementSibling;
85 | const queryResult = table.querySelectorAll("tbody > tr");
86 | const promises = [];
87 |
88 | for (let i = 0; i < queryResult.length; i++) {
89 | promises.push(updateRow(queryResult[i]));
90 | }
91 | Promise.all(promises).then(() => {
92 | loader.remove();
93 | table.classList.remove("fastdt-hidden");
94 | });
95 | }
96 | }
97 |
98 | const fastDebug = document.getElementById("fastDebug");
99 | pypiIndex();
100 | $$.onPanelRender(fastDebug, "VersionsPanel", pypiIndex);
101 |
--------------------------------------------------------------------------------
/debug_toolbar/panels/logging.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import datetime
4 | import logging
5 | import typing as t
6 |
7 | from fastapi import Request, Response
8 | from starlette.concurrency import run_in_threadpool
9 |
10 | from debug_toolbar.panels import Panel
11 | from debug_toolbar.types import Stats
12 | from debug_toolbar.utils import is_coroutine, pluralize
13 |
14 | try:
15 | import threading
16 | except ImportError:
17 | threading = None # type: ignore[assignment]
18 |
19 |
20 | class LogCollector:
21 | def __init__(self) -> None:
22 | if threading is None:
23 | raise NotImplementedError(
24 | "threading module is not available, "
25 | "this panel cannot be used without it"
26 | )
27 | self.collections: dict[int, list[dict[str, t.Any]]] = {}
28 |
29 | def get_collection(self, thread_id: int | None = None) -> list[dict[str, t.Any]]:
30 | if thread_id is None:
31 | thread_id = threading.get_ident()
32 | if thread_id not in self.collections:
33 | self.collections[thread_id] = []
34 | return self.collections[thread_id]
35 |
36 | def clear_collection(self, thread_id: int | None = None) -> None:
37 | if thread_id is None:
38 | thread_id = threading.get_ident()
39 | if thread_id in self.collections:
40 | del self.collections[thread_id]
41 |
42 | def collect(self, item: dict[str, t.Any], thread_id: int | None = None) -> None:
43 | self.get_collection(thread_id).append(item)
44 |
45 |
46 | class ThreadTrackingHandler(logging.Handler):
47 | def __init__(self, collector: LogCollector) -> None:
48 | logging.Handler.__init__(self)
49 | self.collector = collector
50 |
51 | def emit(self, record: logging.LogRecord) -> None:
52 | try:
53 | message = record.getMessage()
54 | except Exception:
55 | message = "[Could not get log message]"
56 |
57 | self.collector.collect(
58 | {
59 | "message": message,
60 | "time": datetime.datetime.fromtimestamp(record.created),
61 | "level": record.levelname,
62 | "file": record.pathname,
63 | "line": record.lineno,
64 | "channel": record.name,
65 | }
66 | )
67 |
68 |
69 | collector = LogCollector()
70 | logging_handler = ThreadTrackingHandler(collector)
71 | logging.root.addHandler(logging_handler)
72 |
73 |
74 | class LoggingPanel(Panel):
75 | nav_title = "Logging"
76 | title = "Log messages"
77 | template = "panels/logging.html"
78 |
79 | def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
80 | super().__init__(*args, **kwargs)
81 | self._records: dict[t.Any, list[dict[str, t.Any]]] = {}
82 |
83 | @property
84 | def nav_subtitle(self) -> str:
85 | stats = self.get_stats()
86 | record_count = len(stats["records"])
87 | return f"{record_count} message{pluralize(record_count)}"
88 |
89 | async def process_request(self, request: Request) -> Response:
90 | if is_coroutine(request["route"].endpoint):
91 | self.thread_id = threading.get_ident()
92 | else:
93 | self.thread_id = await run_in_threadpool(threading.get_ident)
94 |
95 | collector.clear_collection(thread_id=self.thread_id)
96 | return await super().process_request(request)
97 |
98 | async def generate_stats(self, request: Request, response: Response) -> Stats:
99 | records = collector.get_collection(thread_id=self.thread_id)
100 | self._records[self.thread_id] = records
101 | collector.clear_collection(thread_id=self.thread_id)
102 | return {"records": records}
103 |
--------------------------------------------------------------------------------
/debug_toolbar/templates/panels/sql.html:
--------------------------------------------------------------------------------
1 | {% if queries %}
2 |
3 | {% for alias, info in databases.items() %}
4 |
5 | {{ alias }}
6 | {{ '%0.2f'|format(info.time_spent|float) }} ms ({% if info.num_queries == 1 %}{{ info.num_queries }} query{% else %}{{ info.num_queries }} queries{% endif %}
7 | {% if info.sim_count %}
8 | including {{ info.sim_count }} similar
9 | {%- if info.dup_count %}
10 | and {{ info.dup_count }} duplicates
11 | {%- endif %}
12 | {%- endif %})
13 |
14 | {% endfor %}
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | Query
28 | Timeline
29 | Time (ms)
30 | {# TODO: Action #}
31 |
32 |
33 |
34 | {% for alias, query in queries %}
35 |
36 |
37 |
38 | +
39 |
40 |
41 |
42 | {{ query.sql_formatted|safe }}
43 | {{ query.sql_simple|safe }}
44 |
45 | {% if query.sim_count %}
46 |
47 |
48 | {{ query.sim_count }} similar queries.
49 |
50 | {% endif %}
51 | {% if query.dup_count %}
52 |
53 |
54 | Duplicated {{ query.dup_count }} times.
55 |
56 | {% endif %}
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | {{ '%0.2f'|format(query.duration|float) }}
65 |
66 |
67 |
68 |
69 |
70 |
71 | {% if query.params %}
Params: {{ query.params }}
{% endif %}
72 |
Connection: {{ alias }}
73 |
74 |
75 |
76 | {% endfor %}
77 |
78 |
79 | {% else %}
80 | No SQL queries were recorded during this request.
81 | {% endif %}
82 |
--------------------------------------------------------------------------------
/debug_toolbar/statics/js/utils.js:
--------------------------------------------------------------------------------
1 | const $$ = {
2 | on(root, eventName, selector, fn) {
3 | root.addEventListener(eventName, function (event) {
4 | const target = event.target.closest(selector);
5 | if (root.contains(target)) {
6 | fn.call(target, event);
7 | }
8 | });
9 | },
10 | onPanelRender(root, panelId, fn) {
11 | /*
12 | This is a helper function to attach a handler for a `fastdt.panel.render`
13 | event of a specific panel.
14 |
15 | root: The container element that the listener should be attached to.
16 | panelId: The Id of the panel.
17 | fn: A function to execute when the event is triggered.
18 | */
19 | root.addEventListener("fastdt.panel.render", function (event) {
20 | if (event.detail.panelId === panelId) {
21 | fn.call(event);
22 | }
23 | });
24 | },
25 | show(element) {
26 | element.classList.remove("fastdt-hidden");
27 | },
28 | hide(element) {
29 | element.classList.add("fastdt-hidden");
30 | },
31 | toggle(element, value) {
32 | if (value) {
33 | $$.show(element);
34 | } else {
35 | $$.hide(element);
36 | }
37 | },
38 | visible(element) {
39 | return !element.classList.contains("fastdt-hidden");
40 | },
41 | executeScripts(scripts) {
42 | scripts.forEach(function (script) {
43 | const el = document.createElement("script");
44 | el.type = "module";
45 | el.src = script;
46 | el.async = true;
47 | document.head.appendChild(el);
48 | });
49 | },
50 | applyStyles(container) {
51 | /*
52 | * Given a container element, apply styles set via data-fastdt-styles attribute.
53 | * The format is data-fastdt-styles="styleName1:value;styleName2:value2"
54 | * The style names should use the CSSStyleDeclaration camel cased names.
55 | */
56 | container.querySelectorAll("[data-fastdt-styles]").forEach(function (element) {
57 | const styles = element.dataset.fastdtStyles || "";
58 | styles.split(";").forEach(function (styleText) {
59 | const styleKeyPair = styleText.split(":");
60 | if (styleKeyPair.length === 2) {
61 | const name = styleKeyPair[0].trim();
62 | const value = styleKeyPair[1].trim();
63 | element.style[name] = value;
64 | }
65 | });
66 | });
67 | },
68 | loadScripts(container) {
69 | container.querySelectorAll("script").forEach(function (element) {
70 | const script = document.createElement("script");
71 | Array.from(element.attributes).forEach((attr) => script.setAttribute(attr.name, attr.value));
72 | script.appendChild(document.createTextNode(element.innerHTML));
73 | element.parentNode.replaceChild(script, element);
74 | });
75 | },
76 | truncatechars(text, n) {
77 | return text.length > n ? `${text.slice(0, n)}...` : text;
78 | },
79 | };
80 |
81 | function ajax(url, init) {
82 | init = Object.assign({ credentials: "same-origin" }, init);
83 | return fetch(url, init)
84 | .then(function (response) {
85 | if (response.ok) {
86 | return response.json();
87 | }
88 | return Promise.reject(new Error(response.status + ": " + response.statusText));
89 | })
90 | .catch(function (error) {
91 | const win = document.getElementById("fastDebugWindow");
92 | win.innerHTML = `
93 |
94 | »
95 |
${error.message}
96 | `;
97 | $$.show(win);
98 | throw error;
99 | });
100 | }
101 |
102 | function ajaxForm(element) {
103 | const form = element.closest("form");
104 | const url = new URL(form.action);
105 | const formData = new FormData(form);
106 | for (const [name, value] of formData.entries()) {
107 | url.searchParams.append(name, value);
108 | }
109 | const ajaxData = {
110 | method: form.method.toUpperCase(),
111 | };
112 | return ajax(url, ajaxData);
113 | }
114 |
115 | export { $$, ajax, ajaxForm };
116 |
--------------------------------------------------------------------------------
/debug_toolbar/toolbar.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import typing as t
4 | import uuid
5 | from collections import OrderedDict
6 |
7 | from anyio import create_task_group
8 | from fastapi import Request, Response
9 | from starlette.middleware.base import RequestResponseEndpoint
10 |
11 | from debug_toolbar.panels import Panel
12 | from debug_toolbar.settings import DebugToolbarSettings
13 | from debug_toolbar.types import ServerTiming, Stats
14 | from debug_toolbar.utils import get_name_from_obj, import_string
15 |
16 | DT = t.TypeVar("DT", bound="DebugToolbar")
17 |
18 | if t.TYPE_CHECKING:
19 | StoreDT = OrderedDict[str, DT]
20 |
21 |
22 | class DebugToolbar:
23 | _store: "StoreDT" = OrderedDict()
24 | _panel_classes: t.Sequence[t.Type[Panel]] | None = None
25 |
26 | def __init__(
27 | self,
28 | request: Request,
29 | call_next: RequestResponseEndpoint,
30 | settings: DebugToolbarSettings,
31 | ) -> None:
32 | self.request = request
33 | self.settings = settings
34 | panels = []
35 |
36 | for panel_class in self.get_panel_classes(
37 | settings.DEFAULT_PANELS + settings.PANELS,
38 | )[::-1]:
39 | panel = panel_class(self, call_next)
40 | panels.append(panel)
41 |
42 | if panel.enabled:
43 | call_next = panel.process_request
44 |
45 | self.process_request = call_next
46 | self._panels = {}
47 |
48 | while panels:
49 | panel = panels.pop()
50 | self._panels[panel.panel_id] = panel
51 |
52 | self.stats: Stats = {}
53 | self.server_timing_stats: dict[str, ServerTiming] = {}
54 | self.store_id: str | None = None
55 |
56 | @classmethod
57 | def get_panel_classes(
58 | cls: t.Type[DT],
59 | panels: t.Sequence[str],
60 | ) -> t.Sequence[t.Type[Panel]]:
61 | if cls._panel_classes is None:
62 | cls._panel_classes = [import_string(panel_path) for panel_path in panels]
63 | return cls._panel_classes
64 |
65 | @property
66 | def panels(self) -> t.Sequence[Panel]:
67 | return list(self._panels.values())
68 |
69 | def get_panel_by_id(self, panel_id: str) -> Panel:
70 | return self._panels[panel_id]
71 |
72 | @property
73 | def enabled_panels(self) -> t.Sequence[Panel]:
74 | return [panel for panel in self._panels.values() if panel.enabled]
75 |
76 | def store(self) -> None:
77 | if self.store_id:
78 | return
79 |
80 | self.store_id = uuid.uuid4().hex
81 | self._store[self.store_id] = self
82 |
83 | for _ in range(self.settings.RESULTS_CACHE_SIZE, len(self._store)):
84 | self._store.popitem(last=False)
85 |
86 | @classmethod
87 | def fetch(cls: t.Type[DT], store_id: str) -> DT | None:
88 | return cls._store.get(store_id)
89 |
90 | async def record_stats(self, response: Response) -> None:
91 | async with create_task_group() as tg:
92 | for panel in self.enabled_panels:
93 | tg.start_soon(panel.record_stats, self.request, response)
94 |
95 | async def record_server_timing(self, response: Response) -> None:
96 | async with create_task_group() as tg:
97 | for panel in self.enabled_panels:
98 | tg.start_soon(panel.record_server_timing, self.request, response)
99 |
100 | def refresh(self) -> dict[str, t.Any]:
101 | self.store()
102 | return {
103 | "storeId": self.store_id,
104 | "panels": {
105 | panel.panel_id: panel.nav_subtitle
106 | for panel in self.enabled_panels
107 | if panel.nav_subtitle
108 | },
109 | }
110 |
111 | def generate_server_timing_header(self, response: Response) -> None:
112 | data = []
113 |
114 | for panel in self.enabled_panels:
115 | stats = panel.get_server_timing_stats()
116 | if not stats:
117 | continue
118 | for key, title, value in stats:
119 | data.append(f'{panel.panel_id}_{key};dur={value};desc="{title}"')
120 | if data:
121 | response.headers["Server-Timing"] = ", ".join(data)
122 |
123 | def render(self, template: str, **context: t.Any) -> str:
124 | return self.settings.JINJA_ENV.get_template(template).render(
125 | toolbar=self,
126 | url_for=self.request.url_for,
127 | get_name_from_obj=get_name_from_obj,
128 | to_set=set,
129 | **context,
130 | )
131 |
132 | def render_toolbar(self) -> str:
133 | self.store()
134 | return self.render("base.html")
135 |
--------------------------------------------------------------------------------
/debug_toolbar/middleware.py:
--------------------------------------------------------------------------------
1 | import functools
2 | import json
3 | import re
4 | import typing as t
5 | from urllib import parse
6 |
7 | from anyio import CapacityLimiter
8 | from anyio.lowlevel import RunVar
9 | from fastapi import APIRouter, HTTPException, Request, Response, status
10 | from fastapi.responses import StreamingResponse
11 | from fastapi.staticfiles import StaticFiles
12 | from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
13 | from starlette.routing import NoMatchFound
14 | from starlette.types import ASGIApp
15 |
16 | from debug_toolbar.api import render_panel
17 | from debug_toolbar.settings import DebugToolbarSettings
18 | from debug_toolbar.toolbar import DebugToolbar
19 | from debug_toolbar.utils import import_string, matched_route
20 |
21 |
22 | def show_toolbar(request: Request, settings: DebugToolbarSettings) -> bool:
23 | if request.client is not None and settings.ALLOWED_HOSTS is not None:
24 | return request.app.debug and request.client.host in settings.ALLOWED_HOSTS
25 | return request.app.debug
26 |
27 |
28 | class DebugToolbarMiddleware(BaseHTTPMiddleware):
29 | def __init__(self, app: ASGIApp, **settings: t.Any) -> None:
30 | super().__init__(app)
31 | self.settings = DebugToolbarSettings(**settings)
32 | self.show_toolbar = import_string(self.settings.SHOW_TOOLBAR_CALLBACK)
33 | self.router: APIRouter = app # type: ignore[assignment]
34 |
35 | while not isinstance(self.router, APIRouter):
36 | self.router = self.router.app
37 | try:
38 | self.router.url_path_for("debug_toolbar.render_panel")
39 | except NoMatchFound:
40 | self.init_toolbar()
41 |
42 | def init_toolbar(self) -> None:
43 | self.router.get(
44 | self.settings.API_URL,
45 | name="debug_toolbar.render_panel",
46 | include_in_schema=False,
47 | )(self.require_show_toolbar(render_panel))
48 |
49 | self.router.mount(
50 | self.settings.STATIC_URL,
51 | StaticFiles(packages=[__package__]),
52 | name="debug_toolbar.static",
53 | )
54 |
55 | _default_thread_limiter: RunVar[CapacityLimiter] = RunVar(
56 | "_default_thread_limiter"
57 | )
58 | _default_thread_limiter.set(CapacityLimiter(1))
59 |
60 | async def dispatch(
61 | self,
62 | request: Request,
63 | call_next: RequestResponseEndpoint,
64 | ) -> Response:
65 | request.scope["route"] = matched_route(request)
66 |
67 | if (
68 | not request["route"]
69 | or not self.show_toolbar(request, self.settings)
70 | or self.settings.API_URL in request.url.path
71 | ):
72 | return await call_next(request)
73 |
74 | toolbar = DebugToolbar(request, call_next, self.settings)
75 | response = t.cast(StreamingResponse, await toolbar.process_request(request))
76 | content_type = response.headers.get("Content-Type", "")
77 | is_html = content_type.startswith("text/html")
78 |
79 | if not (is_html or content_type == "application/json") or (
80 | "gzip" in response.headers.get("Accept-Encoding", "")
81 | ):
82 | return response
83 |
84 | await toolbar.record_stats(response)
85 | await toolbar.record_server_timing(response)
86 | toolbar.generate_server_timing_header(response)
87 |
88 | if is_html:
89 | body = b""
90 |
91 | async for chunk in response.body_iterator:
92 | if not isinstance(chunk, bytes):
93 | chunk = chunk.encode(response.charset)
94 | body += chunk
95 |
96 | decoded = body.decode(response.charset)
97 | pattern = re.escape(self.settings.INSERT_BEFORE)
98 | bits = re.split(pattern, decoded, flags=re.IGNORECASE)
99 |
100 | if len(bits) > 1:
101 | bits[-2] += toolbar.render_toolbar()
102 | body = self.settings.INSERT_BEFORE.join(bits).encode(response.charset)
103 | response.headers["Content-Length"] = str(len(body))
104 |
105 | async def stream() -> t.AsyncGenerator[bytes, None]:
106 | yield body
107 |
108 | response.body_iterator = stream()
109 | else:
110 | data = parse.quote(json.dumps(toolbar.refresh()))
111 | response.set_cookie(key="dtRefresh", value=data)
112 |
113 | return response
114 |
115 | def require_show_toolbar(
116 | self,
117 | f: t.Callable[..., t.Any],
118 | ) -> t.Callable[[t.Any], t.Any]:
119 | @functools.wraps(f)
120 | def decorator(request: Request, *args: t.Any, **kwargs: t.Any) -> t.Any:
121 | if not self.show_toolbar(request, self.settings):
122 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
123 | return f(request, *args, **kwargs)
124 |
125 | return decorator
126 |
--------------------------------------------------------------------------------
/debug_toolbar/settings.py:
--------------------------------------------------------------------------------
1 | import typing as t
2 |
3 | from jinja2 import BaseLoader, ChoiceLoader, Environment, PackageLoader
4 | from jinja2.ext import Extension
5 | from pydantic import Field, model_validator
6 | from pydantic_extra_types.color import Color
7 | from pydantic_settings import BaseSettings, SettingsConfigDict
8 |
9 |
10 | class DebugToolbarSettings(BaseSettings):
11 | model_config = SettingsConfigDict(
12 | title="Debug Toolbar",
13 | env_prefix="DT_",
14 | case_sensitive=False,
15 | )
16 |
17 | DEFAULT_PANELS: t.List[str] = Field(
18 | [
19 | "debug_toolbar.panels.versions.VersionsPanel",
20 | "debug_toolbar.panels.timer.TimerPanel",
21 | "debug_toolbar.panels.settings.SettingsPanel",
22 | "debug_toolbar.panels.request.RequestPanel",
23 | "debug_toolbar.panels.headers.HeadersPanel",
24 | "debug_toolbar.panels.routes.RoutesPanel",
25 | "debug_toolbar.panels.logging.LoggingPanel",
26 | "debug_toolbar.panels.profiling.ProfilingPanel",
27 | "debug_toolbar.panels.redirects.RedirectsPanel",
28 | ],
29 | description=(
30 | "Specifies the full Python path to each panel that you "
31 | "want included in the toolbar."
32 | ),
33 | )
34 | PANELS: t.List[str] = Field(
35 | [],
36 | description=(
37 | "A list of the full Python paths to each panel that you "
38 | "want to append to `DEFAULT_PANELS`."
39 | ),
40 | )
41 | DISABLE_PANELS: t.Sequence[str] = Field(
42 | ["debug_toolbar.panels.redirects.RedirectsPanel"],
43 | description=(
44 | "A list of the full Python paths to each panel that you "
45 | "want disabled (but still displayed) by default."
46 | ),
47 | )
48 | ALLOWED_HOSTS: t.Optional[t.Sequence[str]] = Field(
49 | None,
50 | description=(
51 | "If it's set, the Debug Toolbar is shown only "
52 | "if the request host is listed."
53 | ),
54 | )
55 | JINJA_ENV: Environment = Field(
56 | Environment(autoescape=True),
57 | description="The Jinja environment instance used to render the toolbar.",
58 | )
59 | JINJA_LOADERS: t.List[BaseLoader] = Field(
60 | [],
61 | description=(
62 | "Jinja `BaseLoader` subclasses used to load templates "
63 | "from the file system or other locations."
64 | ),
65 | )
66 | JINJA_EXTENSIONS: t.Sequence[t.Union[str, t.Type[Extension]]] = Field(
67 | [],
68 | description=(
69 | "Load the extensions from the list and bind them to the Jinja environment."
70 | ),
71 | )
72 | API_URL: str = Field(
73 | "/_debug_toolbar",
74 | description="URL prefix to use for toolbar endpoints.",
75 | )
76 | STATIC_URL: str = Field(
77 | f"{API_URL.default}/static", # type: ignore[attr-defined]
78 | description="URL to use when referring to toolbar static files.",
79 | )
80 | SHOW_TOOLBAR_CALLBACK: str = Field(
81 | "debug_toolbar.middleware.show_toolbar",
82 | description=(
83 | "This is the dotted path to a function used for "
84 | "determining whether the toolbar should show or not."
85 | ),
86 | )
87 | INSERT_BEFORE: str = Field(
88 | "