Repository: allmonday/fastapi-voyager
Branch: main
Commit: 7db1a94cfae5
Files: 136
Total size: 513.5 KB
Directory structure:
gitextract_xdnctyxh/
├── .githooks/
│ ├── README.md
│ └── pre-commit
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ └── feature_request.md
│ └── workflows/
│ └── publish.yml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .python-version
├── CLAUDE.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── docs/
│ ├── changelog.md
│ ├── claude/
│ │ └── 0_REFACTORING_RENDER_NOTES.md
│ └── idea.md
├── pyproject.toml
├── release.md
├── setup-django-ninja.sh
├── setup-fastapi.sh
├── setup-hooks.sh
├── setup-litestar.sh
├── src/
│ └── fastapi_voyager/
│ ├── __init__.py
│ ├── adapters/
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── common.py
│ │ ├── django_ninja_adapter.py
│ │ ├── fastapi_adapter.py
│ │ └── litestar_adapter.py
│ ├── cli.py
│ ├── er_diagram.py
│ ├── filter.py
│ ├── introspectors/
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── detector.py
│ │ ├── django_ninja.py
│ │ ├── fastapi.py
│ │ └── litestar.py
│ ├── module.py
│ ├── pydantic_resolve_util.py
│ ├── render.py
│ ├── render_style.py
│ ├── server.py
│ ├── templates/
│ │ ├── dot/
│ │ │ ├── cluster.j2
│ │ │ ├── cluster_container.j2
│ │ │ ├── digraph.j2
│ │ │ ├── er_diagram.j2
│ │ │ ├── link.j2
│ │ │ ├── route_node.j2
│ │ │ ├── schema_node.j2
│ │ │ └── tag_node.j2
│ │ └── html/
│ │ ├── colored_text.j2
│ │ ├── pydantic_meta.j2
│ │ ├── schema_field_row.j2
│ │ ├── schema_header.j2
│ │ └── schema_table.j2
│ ├── type.py
│ ├── type_helper.py
│ ├── version.py
│ ├── voyager.py
│ └── web/
│ ├── component/
│ │ ├── demo.js
│ │ ├── loader-code-display.js
│ │ ├── render-graph.js
│ │ ├── route-code-display.js
│ │ └── schema-code-display.js
│ ├── graph-ui.js
│ ├── graphviz.svg.css
│ ├── graphviz.svg.js
│ ├── icon/
│ │ └── site.webmanifest
│ ├── index.html
│ ├── magnifying-glass.js
│ ├── package.json
│ ├── src/
│ │ ├── App.vue
│ │ ├── component/
│ │ │ ├── LoaderCodeDisplay.vue
│ │ │ ├── RenderGraph.vue
│ │ │ ├── RouteCodeDisplay.vue
│ │ │ └── SchemaCodeDisplay.vue
│ │ ├── graph-ui.js
│ │ ├── magnifying-glass.js
│ │ ├── main.js
│ │ └── store.js
│ ├── store.js
│ ├── sw.js
│ └── vite.config.js
└── tests/
├── README.md
├── __init__.py
├── django_ninja/
│ ├── __init__.py
│ ├── demo.py
│ ├── embedding.py
│ ├── settings.py
│ └── urls.py
├── embedding_test_utils.py
├── fastapi/
│ ├── __init__.py
│ ├── demo.py
│ ├── demo_anno.py
│ └── embedding.py
├── litestar/
│ ├── __init__.py
│ ├── demo.py
│ └── embedding.py
├── service/
│ ├── __init__.py
│ └── schema/
│ ├── __init__.py
│ ├── base_entity.py
│ ├── db.py
│ ├── dto/
│ │ ├── __init__.py
│ │ ├── attribute.py
│ │ ├── inventory.py
│ │ ├── marketing.py
│ │ ├── order.py
│ │ ├── product.py
│ │ ├── shipment.py
│ │ ├── store.py
│ │ ├── tag.py
│ │ └── user.py
│ ├── extra.py
│ ├── orm/
│ │ ├── __init__.py
│ │ ├── attribute.py
│ │ ├── inventory.py
│ │ ├── marketing.py
│ │ ├── order.py
│ │ ├── product.py
│ │ ├── shipment.py
│ │ ├── store.py
│ │ ├── tables.py
│ │ └── user.py
│ └── schema.py
├── test_adapter_interface.py
├── test_analysis.py
├── test_embedding_django_ninja.py
├── test_embedding_fastapi.py
├── test_embedding_litestar.py
├── test_filter.py
├── test_generic.py
├── test_import.py
├── test_module.py
├── test_resolve_util_impl.py
└── test_type_helper.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .githooks/README.md
================================================
# Git Hooks Setup
This repository uses Git hooks to automatically format code with Prettier before each commit.
## One-time Setup
After cloning the repository, run this command to enable the hooks:
```bash
git config core.hooksPath .githooks
```
That's it! The hooks will now run automatically before each commit.
## What it does
The `pre-commit` hook will:
- Automatically run `npx prettier --write .` before each commit
- Format all supported files (JS, CSS, HTML, JSON, Markdown)
- Stage the formatted files automatically
- Continue with the commit
## Skip the hook (if needed)
If you need to skip the formatting for a particular commit:
```bash
git commit --no-verify -m "your message"
```
## Troubleshooting
### Hook not running?
Check if the hooks path is set correctly:
```bash
git config core.hooksPath
# Should output: .githooks
```
### npx not found?
Make sure Node.js and npm are installed:
```bash
node --version
npm --version
```
================================================
FILE: .githooks/pre-commit
================================================
#!/bin/sh
# Git pre-commit hook to run Prettier on staged files
# Get the project root directory using Git command (works in all shells)
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
cd "$PROJECT_ROOT" || exit 1
# Check if npx is available
if ! command -v npx >/dev/null 2>&1; then
echo "Warning: npx not found. Skipping Prettier formatting."
echo "Please install Node.js and npm to use pre-commit formatting."
exit 0
fi
echo "Running Prettier on staged files..."
# Check if .prettierignore exists and run prettier
if [ -f "$PROJECT_ROOT/.prettierignore" ]; then
echo "Found .prettierignore, applying rules..."
npx prettier --write . --log-level=warn --ignore-path="$PROJECT_ROOT/.prettierignore"
else
echo "No .prettierignore found, formatting all files..."
npx prettier --write . --log-level=warn
fi
# Add any newly formatted files to the staging area
git add .
echo "Prettier formatting complete."
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
================================================
FILE: .github/workflows/publish.yml
================================================
name: Publish to PyPI via uv
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # 需要完整的 git 历史来获取 tag 信息
- name: Set up uv
uses: astral-sh/setup-uv@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Extract version from tag
id: version
run: |
# 从 tag 中提取版本号(去掉 v 前缀)
VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Extract release notes from CHANGELOG
id: extract_notes
run: |
VERSION=${{ steps.version.outputs.version }}
# 从 CHANGELOG.md 提取该版本的说明
# 匹配 "## VERSION" 或 "## VERSION," 但不匹配 "## VERSION.X"
awk -v ver="$VERSION" '
/^## / {
if ($0 ~ "^## " ver "([, \t]|$)") {
flag=1
next
}
else if ($0 ~ /^## [0-9]/) {
flag=0
}
}
flag {print}
' docs/changelog.md > release_notes.md
# 如果 CHANGELOG 中没有找到,使用 git tag 消息
if [ ! -s release_notes.md ]; then
echo "No CHANGELOG entry found, using tag message..."
git tag -l --format='%(contents)' ${{ github.ref_name }} > release_notes.md || echo "Release $VERSION" > release_notes.md
fi
# 显示提取的内容(用于调试)
echo "Release notes content:"
head -20 release_notes.md
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build frontend
run: cd src/fastapi_voyager/web && npm install && npm run build
- name: Build the package
run: uv build
- name: Publish to PyPI
run: uv publish --token ${{ secrets.PYPI_PUBLISHER }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body_path: release_notes.md
draft: false
prerelease: ${{ contains(steps.version.outputs.version, 'alpha') || contains(steps.version.outputs.version, 'beta') || contains(steps.version.outputs.version, 'rc') }}
files: |
dist/*.tar.gz
dist/*.whl
- name: Cleanup
if: always()
run: rm -f release_notes.md
================================================
FILE: .gitignore
================================================
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
*.dot
node_modules/
src/fastapi_voyager/web/node_modules/
================================================
FILE: .prettierignore
================================================
# Dependencies
node_modules/
.venv/
__pycache__/
*.pyc
# Build outputs
dist/
build/
*.egg-info/
# Static assets
*.min.js
*.min.css
# Generated files
package-lock.json
yarn.lock
pnpm-lock.yaml
# Cache
.ruff_cache/
.pytest_cache/
.vscode/
# Git
.git/
.github/
# Misc
*.md
.env
.env.*
================================================
FILE: .prettierrc
================================================
{
"semi": false,
"singleQuote": false,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "es5",
"printWidth": 100,
"arrowParens": "always",
"endOfLine": "lf",
"htmlWhitespaceSensitivity": "css"
}
================================================
FILE: .python-version
================================================
3.12
================================================
FILE: CLAUDE.md
================================================
# CLAUDE.md - fastapi-voyager
## 项目概述
FastAPI Voyager 是一个 Python 包,提供 API 路由树和依赖关系的可视化。前端使用 Vue 3 + Naive UI,通过 Vite 构建。
## 前端构建
前端源码位于 `src/fastapi_voyager/web/`,构建产物为 `src/fastapi_voyager/web/dist/`。
```bash
# 安装依赖(首次或 package.json 变更后)
. "$HOME/.nvm/nvm.sh" && nvm use 20
npm --prefix src/fastapi_voyager/web install
# 构建(修改前端代码后执行)
npm --prefix src/fastapi_voyager/web run build
```
构建产物 `dist/` 已在 `.gitignore` 中,通过 `pyproject.toml` 的 `force-include` 在 CI 打包时包含。
## 开发模式
```bash
# 终端 1:启动 Python 后端(任选一个 demo app)
uv run uvicorn demo_app:app --port 8000
# 或
. .venv/bin/activate && uvicorn demo_app:app --port 8000
# 终端 2(可选):Vite dev server,支持 HMR
cd src/fastapi_voyager/web && npm run dev
# 浏览器打开 http://localhost:5173,API 请求自动代理到 localhost:8000
```
不启动 Vite dev server 时,直接访问 http://localhost:8000/voyager/ 即可使用构建后的版本。
## 关键文件
| 路径 | 说明 |
|------|------|
| `src/fastapi_voyager/web/src/App.vue` | 主组件(Naive UI) |
| `src/fastapi_voyager/web/src/store.js` | 前端状态管理 |
| `src/fastapi_voyager/web/src/main.js` | Vue 入口 |
| `src/fastapi_voyager/web/src/component/*.vue` | 子组件 |
| `src/fastapi_voyager/web/src/graph-ui.js` | D3 Graphviz 渲染 |
| `src/fastapi_voyager/web/src/magnifying-glass.js` | 放大镜功能 |
| `src/fastapi_voyager/web/index.html` | Vite 入口模板(含 Python 占位符) |
| `src/fastapi_voyager/web/vite.config.js` | Vite 配置 |
| `src/fastapi_voyager/adapters/common.py` | Python 端读取 dist/index.html 并替换占位符 |
| `pyproject.toml` | 含 force-include 配置 |
| `.github/workflows/publish.yml` | CI 含 Node.js 构建步骤 |
## Python 占位符
`dist/index.html` 中的占位符由 Python 在 serve 时替换:
- `` → 静态文件路径
- `` → 版本号
- `` → 框架主题色
- `` → Google Analytics 代码
================================================
FILE: CONTRIBUTING.md
================================================
# How to develop & contribute?
fork, clone.
install uv.
```shell
uv venv
source .venv/bin/activate
uv pip install ".[dev]"
uvicorn tests.programatic:app --reload
```
open `localhost:8000/voyager`
frontend:
- `src/web/vue-main.js`: main js
backend:
- `voyager.py`: main entry
- `render.py`: generate dot file
- `server.py`: serve mode
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2025 tangkikodo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
[](https://pypi.python.org/pypi/fastapi-voyager)

[](https://pepy.tech/projects/fastapi-voyager)
# FastAPI Voyager
Visualize your API endpoints and explore them interactively.
Its vision is to make code easier to read and understand, serving as an ideal documentation tool.
**Now supports multiple frameworks:** FastAPI, Django Ninja, and Litestar.
> This repo is still in early stage, it supports Pydantic v2 only.
> **Breaking Change**: Since v0.19, `fastapi-voyager` depends on `pydantic-resolve>=4.0`. If you use `pydantic-resolve` v3, please pin `fastapi-voyager<=0.18`.
- **Live Demo**: https://www.newsyeah.fun/voyager/
- **Example Source**: [composition-oriented-development-pattern](https://github.com/allmonday/composition-oriented-development-pattern)
## Table of Contents
- [Quick Start](#quick-start)
- [Installation](#installation)
- [Supported Frameworks](#supported-frameworks)
- [Features](#features)
- [Command Line Usage](#command-line-usage)
- [About pydantic-resolve](#about-pydantic-resolve)
- [Development](#development)
- [Dependencies](#dependencies)
- [Credits](#credits)
## Quick Start
With simple configuration, fastapi-voyager can be embedded into your web application:
```python
from fastapi import FastAPI
from fastapi_voyager import create_voyager
app = FastAPI()
# ... define your routes ...
app.mount('/voyager',
create_voyager(
app,
module_color={'src.services': 'tomato'},
module_prefix='src.services',
swagger_url="/docs",
ga_id="G-XXXXXXXXVL",
initial_page_policy='first',
online_repo_url='https://github.com/your-org/your-repo/blob/master',
enable_pydantic_resolve_meta=True))
```
Visit `http://localhost:8000/voyager` to explore your API visually.
For framework-specific examples (Django Ninja, Litestar), see [Supported Frameworks](#supported-frameworks).
[View full example](https://github.com/allmonday/composition-oriented-development-pattern/blob/master/src/main.py#L48)
## Installation
### Install via pip
```bash
pip install fastapi-voyager
```
### Install via uv
```bash
uv add fastapi-voyager
```
### Run with CLI
```bash
voyager -m path.to.your.app.module --server
```
For sub-application scenarios (e.g., `app.mount("/api", api)`), specify the app name:
```bash
voyager -m path.to.your.app.module --server --app api
```
> **Note**: [Sub-Application mounts](https://fastapi.tiangolo.com/advanced/sub-applications/) are not supported yet, but you can specify the name of the FastAPI application with `--app`. Only a single application (default: `app`) can be selected.
## Supported Frameworks
fastapi-voyager automatically detects your framework and provides the appropriate integration. Currently supported frameworks:
### FastAPI
```python
from fastapi import FastAPI
from fastapi_voyager import create_voyager
app = FastAPI()
@app.get("/hello")
def hello():
return {"message": "Hello World"}
# Mount voyager
app.mount("/voyager", create_voyager(app))
```
Start with:
```bash
uvicorn your_app:app --reload
# Visit http://localhost:8000/voyager
```
### Django Ninja
```python
import os
import django
from django.core.asgi import get_asgi_application
from ninja import NinjaAPI
from fastapi_voyager import create_voyager
# Configure Django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myapp.settings")
django.setup()
# Create Django Ninja API
api = NinjaAPI()
@api.get("/hello")
def hello(request):
return {"message": "Hello World"}
# Create voyager ASGI app
voyager_app = create_voyager(api)
# Create ASGI application that routes between Django and voyager
async def application(scope, receive, send):
if scope["type"] == "http" and scope["path"].startswith("/voyager"):
await voyager_app(scope, receive, send)
else:
django_app = get_asgi_application()
await django_app(scope, receive, send)
```
Start with:
```bash
uvicorn your_app:application --reload
# Visit http://localhost:8000/voyager
```
### Litestar
Litestar doesn't support mounting to an existing app like FastAPI. The recommended pattern is to export `ROUTE_HANDLERS` from your main app:
```python
# In your main app file (e.g., app.py)
from litestar import Litestar, Controller
class MyController(Controller):
# ... your routes ...
ROUTE_HANDLERS = [MyController] # Export for extension
app = Litestar(route_handlers=ROUTE_HANDLERS)
```
Then create voyager by reusing `ROUTE_HANDLERS`:
```python
# In your voyager embedding file
from typing import Any, Awaitable, Callable
from litestar import Litestar, asgi
from fastapi_voyager import create_voyager
from your_app import ROUTE_HANDLERS, app as your_app
voyager_app = create_voyager(your_app)
@asgi("/voyager", is_mount=True, copy_scope=True)
async def voyager_mount(
scope: dict[str, Any],
receive: Callable[[], Awaitable[dict[str, Any]]],
send: Callable[[dict[str, Any]], Awaitable[None]]
) -> None:
await voyager_app(scope, receive, send)
app = Litestar(route_handlers=ROUTE_HANDLERS + [voyager_mount])
```
Start with:
```bash
uvicorn your_app:app --reload
# Visit http://localhost:8000/voyager
```
## Features
fastapi-voyager is designed for scenarios using web frameworks with Pydantic models (FastAPI, Django Ninja, Litestar). It helps visualize dependencies and serves as an architecture tool to identify implementation issues such as wrong relationships, overfetching, and more.
**Best Practice**: When building view models following the ER model pattern, fastapi-voyager can fully realize its potential - quickly identifying which APIs use specific entities and vice versa.
### Highlight Nodes and Links
Click a node to highlight its upstream and downstream nodes. Figure out the related models of one page, or how many pages are related with one model.
### View Source Code
Double-click a node or route to show source code or open the file in VSCode.
### Quick Search
Search schemas by name and display their upstream and downstream dependencies. Use `Shift + Click` on any node to quickly search for it.
### Display ER Diagram
ER diagram is a feature from pydantic-resolve which provides a solid expression for business descriptions. You can visualize application-level entity relationship diagrams.
```python
from pydantic_resolve import ErDiagram, Entity, Relationship
diagram = ErDiagram(
entities=[
Entity(
kls=Team,
relationships=[
Relationship(fk='id', name='sprints', target=list[Sprint], loader=sprint_loader.team_to_sprint_loader),
Relationship(fk='id', name='users', target=list[User], loader=user_loader.team_to_user_loader)
]
),
Entity(
kls=Sprint,
relationships=[
Relationship(fk='id', name='stories', target=list[Story], loader=story_loader.sprint_to_story_loader)
]
),
Entity(
kls=Story,
relationships=[
Relationship(fk='id', name='tasks', target=list[Task], loader=task_loader.story_to_task_loader),
Relationship(fk='owner_id', name='owner', target=User, loader=user_loader.user_batch_loader)
]
),
Entity(
kls=Task,
relationships=[
Relationship(fk='owner_id', name='owner', target=User, loader=user_loader.user_batch_loader)
]
)
]
)
# Display in voyager
app.mount('/voyager', create_voyager(app, er_diagram=diagram))
```
### Show Pydantic Resolve Meta Info
Set `enable_pydantic_resolve_meta=True` in `create_voyager`, then toggle the "pydantic resolve meta" button to visualize resolve/post/expose/collect operations.
## Command Line Usage
### Start Server
```bash
# FastAPI
voyager -m tests.demo --server --web fastapi
# Django Ninja
voyager -m tests.demo --server --web django-ninja
# Litestar
voyager -m tests.demo --server --web litestar
# Custom port
voyager -m tests.demo --server --port=8002
# Specify app name
voyager -m tests.demo --server --app my_app
```
> **Note**: Server mode does not support ER diagram or pydantic-resolve metadata configuration. Use `create_voyager()` in your code with `er_diagram` and `enable_pydantic_resolve_meta` parameters to enable these features.
### Generate DOT File
```bash
# Generate .dot file
voyager -m tests.demo
# Specify app
voyager -m tests.demo --app my_app
# Filter by schema
voyager -m tests.demo --schema Task
# Show all fields
voyager -m tests.demo --show_fields all
# Custom module colors
voyager -m tests.demo --module_color=tests.demo:red --module_color=tests.service:tomato
# Output to file
voyager -m tests.demo -o my_visualization.dot
# Version and help
voyager --version
voyager --help
```
## About pydantic-resolve
pydantic-resolve is a lightweight tool designed to build complex, nested data in a simple, declarative way. It provides `resolve_*` for loading associated data and `post_*` for computing derived fields, with automatic batch loading to eliminate N+1 queries.
When relationship definitions start repeating across multiple models, use ER Diagram with `base_entity()` and `__relationships__` to centralize relationship declarations. `DefineSubset` helps safely pick fields from entity classes while preserving ER diagram references.
Developers can use fastapi-voyager without needing to know anything about pydantic-resolve, but I still highly recommend everyone to give it a try.
## Development
### Setup Development Environment
```bash
# Fork and clone the repository
git clone https://github.com/your-username/fastapi-voyager.git
cd fastapi-voyager
# Install uv
curl -LsSf https://astral.sh/uv/install.sh | sh
# Create virtual environment and install dependencies
uv venv
source .venv/bin/activate
uv pip install ".[dev]"
# Run development server
uvicorn tests.programatic:app --reload
```
### Test Different Frameworks
You can test the framework-specific examples:
```bash
# FastAPI example
uvicorn tests.fastapi.embedding:app --reload
# Django Ninja example
uvicorn tests.django_ninja.embedding:app --reload
# Litestar example
uvicorn tests.litestar.embedding:asgi_app --reload
```
Visit `http://localhost:8000/voyager` to see changes.
### Setup Git Hooks (Optional)
Enable automatic code formatting before commits:
```bash
./setup-hooks.sh
# or manually:
git config core.hooksPath .githooks
```
This will run Prettier automatically before each commit. See [`.githooks/README.md`](./.githooks/README.md) for details.
### Project Structure
**Frontend:**
- `src/fastapi_voyager/web/vue-main.js` - Main JavaScript entry
**Backend:**
- `voyager.py` - Main entry point
- `render.py` - Generate DOT files
- `server.py` - Server mode
## Roadmap
- [Ideas](./docs/idea.md)
- [Changelog & Roadmap](./docs/changelog.md)
## Dependencies
- [pydantic-resolve](https://github.com/allmonday/pydantic-resolve)
- [Quasar Framework](https://quasar.dev/)
### Dev dependencies
- [FastAPI](https://fastapi.tiangolo.com/)
- [Django Ninja](https://django-ninja.rest-framework.com/)
- [Litestar](https://litestar.dev/)
## Credits
- [graphql-voyager](https://apis.guru/graphql-voyager/) - Thanks for inspiration
- [vscode-interactive-graphviz](https://github.com/tintinweb/vscode-interactive-graphviz) - Thanks for web visualization
## License
MIT License
================================================
FILE: docs/changelog.md
================================================
# Changelog & plan
## <0.9:
- [x] group schemas by module hierarchy
- [x] module-based coloring via Analytics(module_color={...})
- [x] view in web browser
- [x] config params
- [x] make a explorer dashboard, provide list of routes, schemas, to make it easy to switch and search
- [x] support programmatic usage
- [x] better schema /router node appearance
- [x] hide fields duplicated with parent's (show `parent fields` instead)
- [x] refactor the frontend to vue, and tweak the build process
- [x] find dependency based on picked schema and it's field.
- [x] optimize static resource (cdn -> local)
- [x] add configuration for highlight (optional)
- [x] alt+click to show field details
- [x] display source code of routes (including response_model)
- [x] handle excluded field
- [x] add tooltips
- [x] route
- [x] group routes by module hierarchy
- [x] add response_model in route
- [x] fixed left bar show tag/ route
- [x] export voyager core data into json (for better debugging)
- [x] add api to rebuild core data from json, and render it
- [x] fix Generic case `test_generic.py`
- [x] show tips for routes not return pydantic type.
- [x] fix duplicated link from class and parent class, it also break clicking highlight
- [x] refactor: abstract render module
## 0.9
- [x] refactor: server.py
- [x] rename create_app_with_fastapi -> create_voyager
- [x] add doc for parameters
- [x] improve initialization time cost
- [x] query route / schema info through realtime api
- [x] adjust fe
- 0.9.3
- [x] adjust layout
- [x] show field detail in right panel
- [x] show route info in bottom
- 0.9.4
- [x] close schema sidebar when switch tag/route
- [x] schema detail panel show fields by default
- [x] adjust schema panel's height
- [x] show from base information in subset case
- 0.9.5
- [x] route list should have a max height
## 0.10
- 0.10.1
- [x] refactor voyager.py tag -> route structure
- [x] fix missing route (tag has only one route which return primitive value)
- [x] make right panel resizable by dragging
- [x] allow closing tag expansion item
- [x] hide brief mode if not configured
- [x] add focus button to only show related nodes under current route/tag graph in dialog
- 0.10.2
- [x] fix graph height
- [x] show version in title
- 0.10.3
- [x] fix focus in brief-mode
- [x] ui: adjust focus position
- [x] refactor naming
- [x] fix layout issue when rendering huge graph
- 0.10.4
- [x] fix: when focus is on, should ensure changes from other params not broken.
- 0.10.5
- [x] double click to show details, and highlight as tomato
## 0.11
- 0.11.1
- [x] support opening route in swagger
- [x] config docs path
- [x] provide option to hide routes in brief mode (auto hide in full graph mode)
- 0.11.2
- [x] enable/disable module cluster (to save space)
- 0.11.3
- [x] support online repo url
- 0.11.4
- [x] add loading for field detail panel
- 0.11.5
- [x] optimize open in swagger link
- [x] change jquery cdn
- 0.11.6
- [x] flag of loading full graph in first render or not
- [x] optimize loading static resource
- 0.11.7
- [x] fix swagger link
- 0.11.8
- [x] fix swagger link in another way
- 0.11.9
- [x] replace issubclass with safe_issubclass to prevent exception.
- 0.11.10
- [x] fix bug during updating forward refs
- 0.11.11
- [x] replace print with logging and add `--log-level` in cli, by default info
- [x] fill node title color with module color
- [x] optimize cluster render logic
## 0.12
- 0.12.1
- [x] sort tag / route names in left panel
- [x] display schema name on top of detail panel
- [x] optimize dbclick style
- [x] persist the tag/ route in url
- 0.12.2
- [x] add google analytics
- 0.12.3
- [x] fix bug in `update_forward_refs`, class should not be skipped if it's parent class has been visited.
- 0.12.4
- [x] fix logger exception
- 0.12.5
- [x] fix nested cluster with same color
- [x] refactor fe with store based on reactive
- [x] fix duplicated focus toggle
- 0.12.6
- [x] fix overlapped edges
- [x] click link(edge) to highlight related nodes
- [x] on hover cursor effect
- 0.12.7
- [x] remove search component, integrated into main page
- 0.12.8
- [x] optimize ui elements, change icons, update reset behavior
- 0.12.9
- [x] fix: handle logging exception for forward ref info, preventing crash
- 0.12.10
- [x] fix: double trigger on reset search
- 0.12.11
- [x] better ui for schema select
- [x] fix: pick tag and then pick route directly from another tag will render nothing
- [x] feat: cancel search schema triggered by shift click will redirect back to previous tag, route selection
- [x] optimize the node style
- 0.12.12
- [x] disable `show module cluster` by default
## 0.13
- 0.13.0
- [x] if er diagram is provided, show it first.
- 0.13.1
- [x] show more details in er diagram
- 0.13.2
- [x] show dashed line for link without dataloader
- 0.13.3
- [x] show field description
## 0.14, integration with pydantic-resolve
- 0.14.0
- [x] show hint for resolve (>), post fields (<), post default handler (* at title)
- [x] show expose and collect info
- 0.14.1
- [x] minor ui enhancement
## 0.15, internal refactor
- 0.15.0
- [x] refactor render.py
- 0.15.1
- [x] add prettier (npx prettier --write .) and pre-commit hooks
- [x] add localstorage for toggle items
- [x] refactor er diagram renderer
- [x] fix error in search function
- 0.15.2
- [x] fix resetSearch issue: fail to go back previous tag/router after reset.
- [x] left panel can be toggled.
- 0.15.3
- [x] refactor vue-main.js, move methods to store
- [x] optimize search flow
- 0.15.4
- [x] static files cache buster
- [x] store voyager/erd toggle value in url query string
- [x] set highlight style
- 0.15.5
- [x] fix loadInitial bug
- 0.15.6
- [x] internal refactor: graph-ui.js
- [x] enhance the selected and unselected node & edges
## 0.16
- 0.16.0alpha-1
- [x] support django ninja and litestar
- 0.16.0alpha-2
- [x] fix import error
- 0.16.0alpha-3
- [x] fix voyager cli, add web parameter
- 0.16.1
- [x] improve litestar support
## 0.17, enhance er diagram
- 0.17.0
- [x] 1.different theme color for frameworks
- fastapi, keep current
- django-ninja, #4cae4f
- litestar, rgb(237, 182, 65)
- [x] 2.highight entity classes
- enable if er diagram is enabled
- entities in er diagram should be labeled as "Entity" after the title, and title should be bold
- [x] 3.click esc to cancel search
- 0.17.1
- [x] add magnification slider to adjust magnifying glass zoom level (2x-5x)
- [x] refactor magnifying glass module
- fix magnification offset issue when value changes
- optimize performance with content caching (reduce 90%+ DOM operations)
- add parameter validation and error handling
- extract constants and eliminate code redundancy
- add configurable debug logging
- [x] change double-click highlight color to orange (#FF8C00)
- [x] set minimum width for schema nodes (100px) to prevent narrow display
- 0.17.2
- [x] enable PWA
- 0.17.3
- [x] fix unstable size of magnification effect.
- [x] 1.show loader name
## 0.18
- 0.18.0
- [x] show query and mutation method info in er diagram.
## 0.19
- 0.19.0
- **Breaking Change**: migrate pydantic-resolve v4.0. If you use pydantic-resolve v3, please pin `fastapi-voyager<=0.18`.
- show relationship name on ER diagram edges.
- 0.19.1
- [x] fix: handle value type in diagram relationship.
## 0.20
- 0.20.0
- [x] migrate pydantic resolve from v4 to v5
## 0.21
- 0.21.0
- [x] add dataloader info in side bar
## 0.22
- 0.22.0
- [x] optimize er diagram ineraction and highlight
## 0.23
- 0.23.0
- [x] refactor query and mutation methods to standalone functions and integrate with ER diagram
- [x] enhance ER diagram data structure and update highlight modes in GraphUI
- [x] add edge length configuration for ER diagram (Small/Middle/Large)
- [x] preserve highlight state of nodes and edges after re-render
- [x] preserve zoom level after re-render (e.g. adjusting edge length)
- [x] add toggle to show/hide query and mutation methods in ER diagram
## 0.24
- 0.24.0
- [x] simplify highlight method by removing tooltip handling in GraphUI and GraphvizSvg
- [x] update edge click handling in GraphUI and modify onGenerate action in store
- [x] upgrade deps and init db
- 0.24.1
- [x] fix: use `safe_issubclass` to prevent `TypeError: issubclass() arg 1 must be a class` on Python 3.13
- Python 3.13 raises TypeError when `issubclass()` receives a `types.GenericAlias` (e.g. `dict[X, set[Y]]`), while Python 3.12 silently returns False
- Typical trigger: route with PEP 695 type alias as response_model (e.g. `type ResourceActionDict = dict[K, set[V]]`)
## 0.25
- 0.25.0
- [x] migrate frontend from Vue 3 + Quasar (CDN, ~692KB) to Vue 3 + Naive UI (Vite build, tree-shaken ~120KB)
- [x] add Vite build pipeline with dev server + HMR and API proxy
- [x] add CI Node.js build step in publish workflow
- [x] fix NCollapse tag expansion with v-model and accordion mode
- [x] fix NSelect schema/field display (remove render-tag, fix filterable conflict)
- [x] fix route item icon vertical alignment (flex layout)
- [x] fix drawer close button display (use built-in closable prop)
- [x] remove SchemaCodeDisplay outer border
- [x] switch toggle style to label + switch separated layout
- [x] remove edge :e/:w port anchors in DOT template
## 0.26
- 0.26.0
- [x] replace Material Icons with @vicons/ionicons5 (Naive UI native icon solution)
- [x] remove Google Fonts (Roboto + Material Icons) dependency, eliminate external font loading
- [x] rename CSS variable `--q-primary` to `--primary-color` (remove Quasar legacy naming)
- [x] defer Google Analytics script to post-load to avoid blocking page render
- [x] remove PWA manifest and Service Worker registration (not needed for dev-tool usage)
## 0.27
- 0.27.0
- [x] fix: include `web/dist/` in wheel via hatch artifacts config (was missing from PyPI wheel)
## unrelease
- x.x.x
- [ ] 2.show relationship list when double click entity in er diagram
- [ ] 3.highlight entity in use case
- [ ] 4.change cli -m param, use `path.to.module:app` instead.
## 1.0, release
- [ ] add tests
## 1.1 future
================================================
FILE: docs/claude/0_REFACTORING_RENDER_NOTES.md
================================================
# Jinja2 模板引擎重构说明
## 概述
已成功将 `render.py` 从硬编码的模板字符串重构为使用 Jinja2 模板引擎的架构。
## 变更内容
### 1. 新增文件
#### `src/fastapi_voyager/render_style.py`
- **ColorScheme**: 颜色配置类(节点、链接、文本颜色)
- **GraphvizStyle**: Graphviz 样式配置类(字体、布局、链接样式)
- **RenderConfig**: 完整的渲染配置类
#### 模板文件
```
templates/
├── dot/ # DOT 格式模板
│ ├── digraph.j2 # 主图模板
│ ├── tag_node.j2 # 标签节点
│ ├── schema_node.j2 # Schema 节点
│ ├── route_node.j2 # 路由节点
│ ├── cluster.j2 # 集群模板
│ ├── cluster_container.j2 # 容器集群
│ └── link.j2 # 链接模板
└── html/ # HTML 格式模板
├── schema_table.j2 # Schema 表格
├── schema_header.j2 # 表格头部
├── schema_field_row.j2 # 字段行
├── pydantic_meta.j2 # Pydantic 元数据
└── colored_text.j2 # 彩色文本
```
### 2. 重构文件
#### `src/fastapi_voyager/render.py`
- **新增 TemplateRenderer 类**: Jinja2 环境管理和模板渲染
- **重构 Renderer 类**:
- 使用模板渲染替代字符串拼接
- 分离关注点(格式化、渲染、配置)
- 保持公共 API 不变,向后兼容
### 3. 依赖更新
#### `pyproject.toml`
```toml
dependencies = [
"fastapi>=0.110",
"pydantic-resolve>=2.4.3",
"jinja2>=3.0.0" # 新增
]
```
## 架构优势
### 1. **关注点分离**
- **逻辑层**: Renderer 类处理业务逻辑
- **视图层**: Jinja2 模板处理格式化
- **配置层**: render_style.py 管理样式常量
### 2. **可维护性提升**
- ✅ 模板集中管理,易于查找和修改
- ✅ 样式常量集中定义
- ✅ 代码结构更清晰
### 3. **可扩展性**
- ✅ 支持主题切换(修改 ColorScheme)
- ✅ 支持自定义配置(注入 RenderConfig)
- ✅ 易于添加新的节点类型或样式
### 4. **可测试性**
- ✅ 模板可独立测试
- ✅ 样式配置可单独验证
- ✅ 渲染逻辑更清晰
## 向后兼容性
✅ **完全兼容**: Renderer 类的公共接口保持不变:
- `__init__()` 参数未变(新增可选的 `config` 参数)
- `render_dot()` 方法签名未变
- 所有渲染方法保持原有行为
## 使用示例
### 基础使用(无变化)
```python
from fastapi_voyager.render import Renderer
renderer = Renderer(
show_fields='all',
module_color={'myapp.services': 'tomato'}
)
dot_output = renderer.render_dot(tags, routes, nodes, links)
```
### 高级使用(新功能)
```python
from fastapi_voyager.render import Renderer
from fastapi_voyager.render_style import RenderConfig, ColorScheme, GraphvizStyle
# 自定义颜色主题
custom_colors = ColorScheme(
primary='#ff6b6b',
highlight='#ffd93d'
)
# 自定义样式
custom_style = GraphvizStyle(
font='Arial',
node_fontsize='14'
)
# 使用自定义配置
config = RenderConfig(colors=custom_colors, style=custom_style)
renderer = Renderer(config=config)
dot_output = renderer.render_dot(tags, routes, nodes, links)
```
## 测试验证
✅ 所有现有测试通过 (18/18)
✅ 模板渲染正确
✅ 向后兼容性验证通过
✅ 实际应用场景测试通过
## 未来改进建议
1. **模板继承**: 使用 Jinja2 模板继承减少重复
2. **主题系统**: 预定义多个主题(深色、浅色、高对比度)
3. **自定义模板**: 支持用户覆盖默认模板
4. **模板验证**: 添加模板语法检查
5. **性能优化**: 缓存编译后的模板
## 迁移指南
### 对于项目维护者
无需修改现有代码,但可选地:
1. **自定义样式**:
```python
from fastapi_voyager.render_style import RenderConfig, ColorScheme
config = RenderConfig(
colors=ColorScheme(primary='#custom-color')
)
renderer = Renderer(config=config)
```
2. **修改模板**:
编辑 `templates/dot/*.j2` 或 `templates/html/*.j2` 文件
3. **添加新样式**:
在 `render_style.py` 中扩展配置类
## 技术细节
### Jinja2 环境配置
```python
Environment(
loader=FileSystemLoader(template_dir),
autoescape=select_autoescape(),
trim_blocks=True, # 移除尾随换行符
lstrip_blocks=True # 移除前导空白
)
```
### 模板路径解析
```python
TEMPLATE_DIR = Path(__file__).parent / "templates"
```
自动定位到 `src/fastapi_voyager/templates/`
## 常见问题
**Q: 为什么要引入 Jinja2?**
A: 将视图模板从业务逻辑中分离,提高代码的可维护性和可扩展性。
**Q: 会影响性能吗?**
A: Jinja2 会编译并缓存模板,性能影响可忽略不计。
**Q: 如何自定义样式?**
A: 使用 RenderConfig 注入自定义配置,或直接修改 render_style.py。
**Q: 模板语法错误如何调试?**
A: Jinja2 会提供详细的错误信息,包括行号和上下文。
## 总结
此次重构成功地将散乱的模板字符串集中管理到 Jinja2 模板文件中,并提取了样式配置到专门的模块。这不仅提高了代码的可维护性,也为未来的功能扩展(如主题系统、自定义模板等)奠定了基础。
✅ **任务完成**: 所有计划任务已完成,测试通过,代码已准备就绪。
================================================
FILE: docs/idea.md
================================================
# Idea
## backlog
- [ ] user can generate nodes/edges manually and connect to generated ones
- [ ] eg: add owner
- [ ] add extra info for schema
- [ ] optimize static resource (allow manually config url)
- [ ] improve search dialog
- [ ] add route/tag list
- [ ] type alias should not be kept as node instead of compiling to original type
- [ ] how to correctly handle the generic type ?
- for example `Page[Student]` of `Page[T]` will be marked in `Page[T]`'s module
- [ ] sort field name in nodes (only table inside right panel)
- [ ] set max limit for fields in nodes (? need further thinking)
- [ ] minimap (good to have)
- ref: https://observablehq.com/@rabelais/d3-js-zoom-minimap
- [ ] ~~debug mode~~
- [ ] export dot content, load dot content
- [ ] abstract voyager-core
- [ ] support fastapi-voyager
- [ ] support django-ninja-voyager
## in analysis
- [ ] upgrade network algorithm (optional, for example networkx)
- [ ] click field to highlight links or click link to highlight related nodes
- [ ] animation effect for edges
- [ ] display standard ER diagram spec. `hard but important`
- [ ] display potential invalid links
- [ ] highlight relationship belongs to ER diagram
================================================
FILE: pyproject.toml
================================================
[project]
name = "fastapi-voyager"
dynamic = ["version"]
description = "Visualize FastAPI application's routing tree and dependencies"
authors = [ { name = "Tangkikodo", email = "allmonday@126.com" } ]
license = { text = "MIT" }
readme = "README.md"
requires-python = ">=3.10"
keywords = ["fastapi", "visualization", "routing", "openapi"]
dependencies = [
"pydantic-resolve>=5.1.0",
"jinja2>=3.0.0",
]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Framework :: FastAPI",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License"
]
[project.scripts]
voyager = "fastapi_voyager.cli:main"
[project.urls]
Homepage = "https://github.com/allmonday/fastapi-voyager"
Source = "https://github.com/allmonday/fastapi-voyager"
[project.optional-dependencies]
dev = ["ruff", "pytest", "pytest-asyncio", "httpx"]
fastapi = ["fastapi>=0.110", "uvicorn"]
django-ninja = ["django>=4.2", "django-ninja>=1.5.3", "uvicorn"]
litestar = ["litestar>=2.19.0", "pydantic>=2.0", "uvicorn"]
all = ["fastapi-voyager[dev,fastapi,django-ninja,litestar]"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.version]
path = "src/fastapi_voyager/version.py"
[tool.hatch.build.targets.sdist]
force-include."src/fastapi_voyager/web/dist" = "src/fastapi_voyager/web/dist"
artifacts = ["src/fastapi_voyager/web/dist/"]
[tool.hatch.build.targets.wheel]
artifacts = ["src/fastapi_voyager/web/dist/"]
[tool.uv]
# You can pin resolution or indexes here later.
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
[dependency-groups]
dev = [
"aiosqlite>=0.22.1",
"greenlet>=3.4.0",
"httpx>=0.28.1",
"pytest-asyncio>=1.3.0",
"pytest>=8.0.0",
"ruff>=0.9.0",
"sqlalchemy>=2.0.49",
]
fastapi = [
"fastapi>=0.116.1",
"uvicorn>=0.34.0",
]
django-ninja = [
"django>=4.2",
"django-ninja>=1.5.3",
"uvicorn>=0.34.0",
]
litestar = [
"litestar>=2.19.0",
"pydantic>=2.0",
"uvicorn>=0.34.0",
]
all = [
"django>=4.2",
"django-ninja>=1.5.3",
"fastapi>=0.116.1",
"litestar>=2.19.0",
"pydantic>=2.0",
"uvicorn>=0.34.0",
]
================================================
FILE: release.md
================================================
release by pushing the tag
```shell
git tag v1.0.0
git push origin v1.0.0
```
================================================
FILE: setup-django-ninja.sh
================================================
#!/bin/bash
# Django Ninja Development Setup Script
# Usage: ./setup-django-ninja.sh [--no-sync]
set -e
echo "🚀 Setting up Django Ninja development environment..."
echo ""
# Parse arguments
SYNC=true
for arg in "$@"; do
case $arg in
--no-sync)
SYNC=false
shift
;;
esac
done
# Sync dependencies
if [ "$SYNC" = true ]; then
echo "📦 Syncing dependencies..."
uv sync --group dev --group django-ninja
echo "✅ Dependencies synced"
echo ""
fi
# Check if uvicorn is installed
echo "🔍 Checking uvicorn installation..."
if uv run which uvicorn > /dev/null 2>&1; then
UVICORN_PATH=$(uv run which uvicorn)
echo "✅ Uvicorn found at: $UVICORN_PATH"
else
echo "❌ Uvicorn not found in project environment"
exit 1
fi
echo ""
# Start Django Ninja server
echo "🌟 Starting Django Ninja Voyager server..."
echo " App: tests.django_ninja.embedding:application"
echo " URL: http://127.0.0.1:8000"
echo ""
echo "Press Ctrl+C to stop the server"
echo ""
uv run uvicorn tests.django_ninja.embedding:application --reload --host 127.0.0.1 --port 8000
================================================
FILE: setup-fastapi.sh
================================================
#!/bin/bash
# FastAPI Development Setup Script
# Usage: ./setup-fastapi.sh [--no-sync]
set -e
echo "🚀 Setting up FastAPI development environment..."
echo ""
# Parse arguments
SYNC=true
for arg in "$@"; do
case $arg in
--no-sync)
SYNC=false
shift
;;
esac
done
# Sync dependencies
if [ "$SYNC" = true ]; then
echo "📦 Syncing dependencies..."
uv sync --group dev --group fastapi
echo "✅ Dependencies synced"
echo ""
fi
# Check if uvicorn is installed
echo "🔍 Checking uvicorn installation..."
if uv run which uvicorn > /dev/null 2>&1; then
UVICORN_PATH=$(uv run which uvicorn)
echo "✅ Uvicorn found at: $UVICORN_PATH"
else
echo "❌ Uvicorn not found in project environment"
exit 1
fi
echo ""
# Start FastAPI server
echo "🌟 Starting FastAPI Voyager server..."
echo " App: tests.fastapi.embedding:app"
echo " URL: http://127.0.0.1:8000"
echo ""
echo "Press Ctrl+C to stop the server"
echo ""
uv run uvicorn tests.fastapi.embedding:app --reload --host 127.0.0.1 --port 8000
================================================
FILE: setup-hooks.sh
================================================
#!/bin/bash
# Setup script for Git hooks
echo "Setting up Git hooks..."
# Check if we're in a git repository
if ! git rev-parse --git-dir > /dev/null 2>&1; then
echo "Error: Not a Git repository"
exit 1
fi
# Set the hooks path
git config core.hooksPath .githooks
# Make hooks executable
chmod +x .githooks/*
echo "✓ Git hooks configured successfully!"
echo ""
echo "Hooks are now enabled. Prettier will run automatically before each commit."
echo ""
echo "To verify:"
echo " git config core.hooksPath"
================================================
FILE: setup-litestar.sh
================================================
#!/bin/bash
# Litestar Development Setup Script
# Usage: ./setup-litestar.sh [--no-sync]
set -e
echo "🚀 Setting up Litestar development environment..."
echo ""
# Parse arguments
SYNC=true
for arg in "$@"; do
case $arg in
--no-sync)
SYNC=false
shift
;;
esac
done
# Sync dependencies
if [ "$SYNC" = true ]; then
echo "📦 Syncing dependencies..."
uv sync --group dev --group litestar
echo "✅ Dependencies synced"
echo ""
fi
# Check if uvicorn is installed
echo "🔍 Checking uvicorn installation..."
if uv run which uvicorn > /dev/null 2>&1; then
UVICORN_PATH=$(uv run which uvicorn)
echo "✅ Uvicorn found at: $UVICORN_PATH"
else
echo "❌ Uvicorn not found in project environment"
exit 1
fi
echo ""
# Start Litestar server
echo "🌟 Starting Litestar Voyager server..."
echo " App: tests.litestar.embedding:app"
echo " URL: http://127.0.0.1:8000"
echo ""
echo "Press Ctrl+C to stop the server"
echo ""
uv run uvicorn tests.litestar.embedding:app --reload --host 127.0.0.1 --port 8000
================================================
FILE: src/fastapi_voyager/__init__.py
================================================
"""fastapi_voyager
Utilities to introspect web applications and visualize their routing tree.
"""
from .server import create_voyager
from .version import __version__ # noqa: F401
__all__ = [ "__version__", "create_voyager" ]
================================================
FILE: src/fastapi_voyager/adapters/__init__.py
================================================
"""
Framework adapters for fastapi-voyager.
This module provides adapters that allow voyager to work with different web frameworks.
"""
from fastapi_voyager.adapters.base import VoyagerAdapter
from fastapi_voyager.adapters.django_ninja_adapter import DjangoNinjaAdapter
from fastapi_voyager.adapters.fastapi_adapter import FastAPIAdapter
from fastapi_voyager.adapters.litestar_adapter import LitestarAdapter
__all__ = [
"VoyagerAdapter",
"FastAPIAdapter",
"DjangoNinjaAdapter",
"LitestarAdapter",
]
================================================
FILE: src/fastapi_voyager/adapters/base.py
================================================
"""
Base adapter interface for framework-agnostic voyager server.
This module defines the abstract interface that all framework adapters must implement.
"""
from abc import ABC, abstractmethod
from typing import Any
class VoyagerAdapter(ABC):
"""
Abstract base class for framework-specific voyager adapters.
Each adapter is responsible for:
1. Creating routes/endpoints for the voyager UI
2. Handling HTTP requests and responses in a framework-specific way
3. Returning an object that can be mounted/integrated with the target app
"""
@abstractmethod
def create_app(self) -> Any:
"""
Create and return a framework-specific application object.
The returned object should be mountable/integrable with the target framework.
For example:
- FastAPI: returns a FastAPI app
- Django Ninja: returns an ASGI application
- Litestar: returns a Litestar app
Returns:
A framework-specific application object
"""
pass
================================================
FILE: src/fastapi_voyager/adapters/common.py
================================================
"""
Shared business logic for voyager endpoints.
This module contains the core logic that is reused across all framework adapters.
"""
from pathlib import Path
from typing import Any
from pydantic_resolve import ErDiagram
from fastapi_voyager.er_diagram import VoyagerErDiagram
from fastapi_voyager.introspectors.detector import FrameworkType, detect_framework
from fastapi_voyager.render import Renderer
from fastapi_voyager.render_style import RenderConfig
from fastapi_voyager.type import CoreData, SchemaNode, Tag
from fastapi_voyager.type_helper import get_source, get_vscode_link
from fastapi_voyager.version import __version__
from fastapi_voyager.voyager import Voyager
WEB_DIR = Path(__file__).parent.parent / "web"
WEB_DIR.mkdir(exist_ok=True)
STATIC_FILES_PATH = "/fastapi-voyager-static"
GA_PLACEHOLDER = ""
VERSION_PLACEHOLDER = ""
STATIC_PATH_PLACEHOLDER = ""
THEME_COLOR_PLACEHOLDER = ""
VOYAGER_PATH_PLACEHOLDER = ""
def build_ga_snippet(ga_id: str | None) -> str:
"""Build Google Analytics snippet."""
if not ga_id:
return ""
return f"""
"""
class VoyagerContext:
"""
Context object that holds configuration and provides business logic methods.
This is shared across all framework adapters to avoid code duplication.
"""
def __init__(
self,
target_app: Any,
module_color: dict[str, str] | None = None,
module_prefix: str | None = None,
swagger_url: str | None = None,
online_repo_url: str | None = None,
initial_page_policy: str = 'first',
ga_id: str | None = None,
er_diagram: ErDiagram | None = None,
enable_pydantic_resolve_meta: bool = False,
framework_name: str | None = None,
):
self.target_app = target_app
self.module_color = module_color or {}
self.module_prefix = module_prefix
self.swagger_url = swagger_url
self.online_repo_url = online_repo_url
self.initial_page_policy = initial_page_policy
self.ga_id = ga_id
self.er_diagram = er_diagram
self.enable_pydantic_resolve_meta = enable_pydantic_resolve_meta
# Detect and store framework type (single source of truth)
self._framework_type = detect_framework(target_app)
# Display name for frontend (backward compatible)
self.framework_name = framework_name or self._get_display_name()
def _get_display_name(self) -> str:
"""Get display name for the detected framework type."""
display_names = {
FrameworkType.FASTAPI: "FastAPI",
FrameworkType.DJANGO_NINJA: "Django Ninja",
FrameworkType.LITESTAR: "Litestar",
}
return display_names.get(self._framework_type, "API")
def _get_theme_color(self) -> str:
"""Get theme color for the current framework."""
config = RenderConfig()
return config.colors.get_framework_color(self._framework_type)
def _get_entity_class_names(self) -> set[str] | None:
"""Extract entity class names from er_diagram."""
if not self.er_diagram:
return None
from fastapi_voyager.type_helper import full_class_name
return {
full_class_name(entity.kls)
for entity in self.er_diagram.entities
}
def get_voyager(self, **kwargs) -> Voyager:
"""Create a Voyager instance with common configuration."""
config = {
"module_color": self.module_color,
"show_pydantic_resolve_meta": self.enable_pydantic_resolve_meta,
"theme_color": self._get_theme_color(),
"entity_class_names": self._get_entity_class_names(),
}
config.update(kwargs)
return Voyager(**config)
def analyze_and_get_dot(self) -> tuple[str, list[Tag], list[SchemaNode]]:
"""
Analyze the target app and return dot graph, tags, and schemas.
Returns:
Tuple of (dot_graph, tags, schemas)
"""
voyager = self.get_voyager()
voyager.analysis(self.target_app)
dot = voyager.render_dot()
# include tags and their routes
tags = voyager.tags
for t in tags:
t.routes.sort(key=lambda r: r.name)
tags.sort(key=lambda t: t.name)
schemas = voyager.nodes[:]
schemas.sort(key=lambda s: s.name)
return dot, tags, schemas
def get_option_param(self) -> dict:
"""Get the option parameter for the voyager UI."""
dot, tags, schemas = self.analyze_and_get_dot()
return {
"tags": tags,
"schemas": schemas,
"dot": dot,
"enable_brief_mode": bool(self.module_prefix),
"version": __version__,
"swagger_url": self.swagger_url,
"initial_page_policy": self.initial_page_policy,
"has_er_diagram": self.er_diagram is not None,
"enable_pydantic_resolve_meta": self.enable_pydantic_resolve_meta,
"framework_name": self.framework_name,
}
def get_search_dot(self, payload: dict) -> list[Tag]:
"""Get filtered tags for search."""
voyager = self.get_voyager(
schema=payload.get("schema_name"),
schema_field=payload.get("schema_field"),
show_fields=payload.get("show_fields", "object"),
hide_primitive_route=payload.get("hide_primitive_route", False),
show_module=payload.get("show_module", True),
show_pydantic_resolve_meta=payload.get("show_pydantic_resolve_meta", False),
)
voyager.analysis(self.target_app)
tags = voyager.calculate_filtered_tag_and_route()
for t in tags:
t.routes.sort(key=lambda r: r.name)
tags.sort(key=lambda t: t.name)
return tags
def get_filtered_dot(self, payload: dict) -> str:
"""Get filtered dot graph."""
voyager = self.get_voyager(
include_tags=payload.get("tags"),
schema=payload.get("schema_name"),
schema_field=payload.get("schema_field"),
show_fields=payload.get("show_fields", "object"),
route_name=payload.get("route_name"),
hide_primitive_route=payload.get("hide_primitive_route", False),
show_module=payload.get("show_module", True),
show_pydantic_resolve_meta=payload.get("show_pydantic_resolve_meta", False),
)
voyager.analysis(self.target_app)
if payload.get("brief"):
if payload.get("tags"):
return voyager.render_tag_level_brief_dot(module_prefix=self.module_prefix)
else:
return voyager.render_overall_brief_dot(module_prefix=self.module_prefix)
else:
return voyager.render_dot()
def get_core_data(self, payload: dict) -> CoreData:
"""Get core data for the graph."""
voyager = self.get_voyager(
include_tags=payload.get("tags"),
schema=payload.get("schema_name"),
schema_field=payload.get("schema_field"),
show_fields=payload.get("show_fields", "object"),
route_name=payload.get("route_name"),
)
voyager.analysis(self.target_app)
return voyager.dump_core_data()
def render_dot_from_core_data(self, core_data: CoreData) -> str:
"""Render dot graph from core data."""
renderer = Renderer(
show_fields=core_data.show_fields,
module_color=core_data.module_color,
schema=core_data.schema,
theme_color=self._get_theme_color(),
)
return renderer.render_dot(
core_data.tags, core_data.routes, core_data.nodes, core_data.links
)
def get_er_diagram_dot(self, payload: dict) -> str:
"""Get ER diagram dot graph."""
if self.er_diagram:
return VoyagerErDiagram(
self.er_diagram,
show_fields=payload.get("show_fields", "object"),
show_module=payload.get("show_module", True),
theme_color=self._get_theme_color(),
).render_dot()
return ""
def get_er_diagram_data(self, payload: dict) -> dict:
"""Get ER diagram dot graph and link metadata."""
if not self.er_diagram:
return {"dot": "", "links": [], "schemas": []}
edge_minlen = max(3, min(10, payload.get("edge_minlen", 3)))
diagram = VoyagerErDiagram(
self.er_diagram,
show_fields=payload.get("show_fields", "object"),
show_module=payload.get("show_module", True),
theme_color=self._get_theme_color(),
edge_minlen=edge_minlen,
show_methods=payload.get("show_methods", True),
)
dot = diagram.render_dot()
links_meta = [
{
"source_origin": link.source_origin,
"target_origin": link.target_origin,
"label": link.label,
"loader_fullname": link.loader_fullname,
}
for link in diagram.links
]
schemas_meta = [
{
"id": node.id,
"name": node.name,
"module": node.module,
"fields": [
{
"name": f.name,
"type_name": f.type_name,
"from_base": f.from_base,
"is_object": f.is_object,
"is_exclude": f.is_exclude,
"desc": f.desc,
}
for f in node.fields
],
}
for node in diagram.node_set.values()
]
return {"dot": dot, "links": links_meta, "schemas": schemas_meta}
def get_index_html(self) -> str:
"""Get the index HTML content."""
# Prefer built (dist) version, fall back to source index.html
index_file = WEB_DIR / "dist" / "index.html"
if not index_file.exists():
index_file = WEB_DIR / "index.html"
if index_file.exists():
content = index_file.read_text(encoding="utf-8")
content = content.replace(GA_PLACEHOLDER, build_ga_snippet(self.ga_id))
content = content.replace(VERSION_PLACEHOLDER, f"?v={__version__}")
# Replace static files path placeholder with actual path (without leading slash)
content = content.replace(STATIC_PATH_PLACEHOLDER, STATIC_FILES_PATH.lstrip("/"))
# Fix Vite absolute asset paths to be relative (for sub-app mounting)
content = content.replace(f"{STATIC_FILES_PATH}/dist/", f"{STATIC_FILES_PATH.lstrip('/')}/dist/")
# Replace theme color placeholder with framework-specific color
content = content.replace(THEME_COLOR_PLACEHOLDER, self._get_theme_color())
return content
# fallback simple page if index.html missing
return """
Graphviz Preview
index.html not found. Create one under src/fastapi_voyager/web/index.html
"""
def get_source_code(self, schema_name: str) -> dict:
"""Get source code for a schema."""
try:
components = schema_name.split(".")
if len(components) < 2:
return {"error": "Invalid schema name format. Expected format: module.ClassName"}
module_name = ".".join(components[:-1])
class_name = components[-1]
mod = __import__(module_name, fromlist=[class_name])
obj = getattr(mod, class_name)
source_code = get_source(obj)
return {"source_code": source_code}
except ImportError as e:
return {"error": f"Module not found: {e}"}
except AttributeError as e:
return {"error": f"Class not found: {e}"}
except Exception as e:
return {"error": f"Internal error: {str(e)}"}
def get_vscode_link(self, schema_name: str) -> dict:
"""Get VSCode link for a schema."""
try:
components = schema_name.split(".")
if len(components) < 2:
return {"error": "Invalid schema name format. Expected format: module.ClassName"}
module_name = ".".join(components[:-1])
class_name = components[-1]
mod = __import__(module_name, fromlist=[class_name])
obj = getattr(mod, class_name)
link = get_vscode_link(obj, online_repo_url=self.online_repo_url)
return {"link": link}
except ImportError as e:
return {"error": f"Module not found: {e}"}
except AttributeError as e:
return {"error": f"Class not found: {e}"}
except Exception as e:
return {"error": f"Internal error: {str(e)}"}
def get_service_worker(self) -> str:
"""Get the Service Worker JavaScript content with placeholders replaced."""
sw_file = WEB_DIR / "sw.js"
if sw_file.exists():
content = sw_file.read_text(encoding="utf-8")
content = content.replace(VERSION_PLACEHOLDER, __version__)
content = content.replace(STATIC_PATH_PLACEHOLDER, STATIC_FILES_PATH.lstrip("/"))
return content
return ""
def get_manifest(self) -> str:
"""Get the PWA manifest JSON content with placeholders replaced."""
manifest_file = WEB_DIR / "icon" / "site.webmanifest"
if manifest_file.exists():
content = manifest_file.read_text(encoding="utf-8")
# VOYAGER_PATH will be replaced with the voyager mount path (e.g., "/voyager/")
# This is set by adapters based on how they are mounted
content = content.replace(THEME_COLOR_PLACEHOLDER, self._get_theme_color())
return content
return "{}"
================================================
FILE: src/fastapi_voyager/adapters/django_ninja_adapter.py
================================================
"""
Django Ninja adapter for fastapi-voyager.
This module provides the Django Ninja-specific implementation of the voyager server.
It creates an ASGI application that can be integrated with Django.
"""
import json
import mimetypes
from typing import Any
from fastapi_voyager.adapters.base import VoyagerAdapter
from fastapi_voyager.adapters.common import STATIC_FILES_PATH, VOYAGER_PATH_PLACEHOLDER, WEB_DIR, VoyagerContext
from fastapi_voyager.type import CoreData, SchemaNode, Tag
class DjangoNinjaAdapter(VoyagerAdapter):
"""
Django Ninja-specific implementation of VoyagerAdapter.
Creates an ASGI application with voyager endpoints that can be integrated with Django.
"""
def __init__(
self,
target_app: Any,
module_color: dict[str, str] | None = None,
gzip_minimum_size: int | None = 500,
module_prefix: str | None = None,
swagger_url: str | None = None,
online_repo_url: str | None = None,
initial_page_policy: str = "first",
ga_id: str | None = None,
er_diagram: Any = None,
enable_pydantic_resolve_meta: bool = False,
server_mode: bool = False,
):
self.ctx = VoyagerContext(
target_app=target_app,
module_color=module_color,
module_prefix=module_prefix,
swagger_url=swagger_url,
online_repo_url=online_repo_url,
initial_page_policy=initial_page_policy,
ga_id=ga_id,
er_diagram=er_diagram,
enable_pydantic_resolve_meta=enable_pydantic_resolve_meta,
framework_name="Django Ninja",
)
self.server_mode = server_mode
# Note: gzip should be handled by Django's middleware, not here
async def _handle_request(self, scope, receive, send):
"""ASGI request handler."""
if scope["type"] != "http":
return
# Parse the request
method = scope["method"]
path = scope["path"]
# Remove /voyager prefix for internal routing (unless in server_mode)
if not self.server_mode and path.startswith("/voyager"):
path = path[8:] # Remove '/voyager'
if path == "":
path = "/"
# Handle static files
if method == "GET" and path.startswith(f"{STATIC_FILES_PATH}/"):
await self._handle_static_file(path, send)
return
# Route the request
if method == "GET" and path == "/":
await self._handle_index(send)
elif method == "GET" and path == "/sw.js":
await self._handle_service_worker(send)
elif method == "GET" and path == "/manifest.webmanifest":
await self._handle_manifest(send)
elif method == "GET" and path == "/dot":
await self._handle_get_dot(send)
elif method == "POST" and path == "/er-diagram":
await self._handle_post_request(receive, send, self._handle_er_diagram)
elif method == "POST" and path == "/dot-search":
await self._handle_post_request(receive, send, self._handle_search_dot)
elif method == "POST" and path == "/dot":
await self._handle_post_request(receive, send, self._handle_filtered_dot)
elif method == "POST" and path == "/dot-core-data":
await self._handle_post_request(receive, send, self._handle_core_data)
elif method == "POST" and path == "/dot-render-core-data":
await self._handle_post_request(receive, send, self._handle_render_core_data)
elif method == "POST" and path == "/source":
await self._handle_post_request(receive, send, self._handle_source)
elif method == "POST" and path == "/vscode-link":
await self._handle_post_request(receive, send, self._handle_vscode_link)
else:
await self._send_404(send)
async def _handle_post_request(self, receive, send, handler):
"""Helper to handle POST requests with JSON body."""
body = b""
more_body = True
while more_body:
message = await receive()
if message["type"] == "http.request":
body += message.get("body", b"")
more_body = message.get("more_body", False)
try:
payload = json.loads(body.decode())
await handler(payload, send)
except Exception as e:
await self._send_json({"error": str(e)}, send, status_code=400)
async def _handle_static_file(self, path: str, send):
"""Handle GET {STATIC_FILES_PATH}/* - serve static files."""
# Remove /fastapi-voyager-static/ prefix
prefix = f"{STATIC_FILES_PATH}/"
file_path = path[len(prefix):]
full_path = WEB_DIR / file_path
# Security check: ensure the path is within WEB_DIR
try:
full_path = full_path.resolve()
web_dir_resolved = WEB_DIR.resolve()
if not str(full_path).startswith(str(web_dir_resolved)):
await self._send_404(send)
return
except Exception:
await self._send_404(send)
return
if not full_path.exists() or not full_path.is_file():
await self._send_404(send)
return
# Read file content
try:
with open(full_path, "rb") as f:
content = f.read()
# Determine content type
content_type, _ = mimetypes.guess_type(str(full_path))
if content_type is None:
content_type = "application/octet-stream"
await self._send_response(content_type, content, send)
except Exception:
await self._send_404(send)
async def _handle_index(self, send):
"""Handle GET / - return the index HTML."""
html = self.ctx.get_index_html()
await self._send_html(html, send)
async def _handle_service_worker(self, send):
"""Handle GET /sw.js - return the Service Worker."""
sw_content = self.ctx.get_service_worker()
await self._send_response(
"application/javascript",
sw_content.encode("utf-8"),
send,
)
async def _handle_manifest(self, send):
"""Handle GET /manifest.webmanifest - return the PWA manifest."""
content = self.ctx.get_manifest()
content = content.replace(VOYAGER_PATH_PLACEHOLDER, "./")
await self._send_response(
"application/manifest+json",
content.encode("utf-8"),
send,
)
async def _handle_get_dot(self, send):
"""Handle GET /dot - return options and initial dot graph."""
data = self.ctx.get_option_param()
# Convert tags and schemas to dicts for JSON serialization
response_data = {
"tags": [self._tag_to_dict(t) for t in data["tags"]],
"schemas": [self._schema_to_dict(s) for s in data["schemas"]],
"dot": data["dot"],
"enable_brief_mode": data["enable_brief_mode"],
"version": data["version"],
"initial_page_policy": data["initial_page_policy"],
"swagger_url": data["swagger_url"],
"has_er_diagram": data["has_er_diagram"],
"enable_pydantic_resolve_meta": data["enable_pydantic_resolve_meta"],
"framework_name": data["framework_name"],
}
await self._send_json(response_data, send)
async def _handle_er_diagram(self, payload, send):
"""Handle POST /er-diagram."""
data = self.ctx.get_er_diagram_data(payload)
await self._send_json(data, send)
async def _handle_search_dot(self, payload, send):
"""Handle POST /dot-search."""
tags = self.ctx.get_search_dot(payload)
response_data = {"tags": [self._tag_to_dict(t) for t in tags]}
await self._send_json(response_data, send)
async def _handle_filtered_dot(self, payload, send):
"""Handle POST /dot."""
dot = self.ctx.get_filtered_dot(payload)
await self._send_text(dot, send)
async def _handle_core_data(self, payload, send):
"""Handle POST /dot-core-data."""
core_data = self.ctx.get_core_data(payload)
await self._send_json(core_data.model_dump(), send)
async def _handle_render_core_data(self, payload, send):
"""Handle POST /dot-render-core-data."""
core_data = CoreData(**payload)
dot = self.ctx.render_dot_from_core_data(core_data)
await self._send_text(dot, send)
async def _handle_source(self, payload, send):
"""Handle POST /source."""
result = self.ctx.get_source_code(payload.get("schema_name", ""))
status_code = 200 if "error" not in result else 400
if "error" in result and "not found" in result["error"]:
status_code = 404
await self._send_json(result, send, status_code=status_code)
async def _handle_vscode_link(self, payload, send):
"""Handle POST /vscode-link."""
result = self.ctx.get_vscode_link(payload.get("schema_name", ""))
status_code = 200 if "error" not in result else 400
if "error" in result and "not found" in result["error"]:
status_code = 404
await self._send_json(result, send, status_code=status_code)
async def _send_html(self, html: str, send):
"""Send HTML response."""
await self._send_response(
"text/html; charset=utf-8",
html.encode("utf-8"),
send,
status_code=200,
)
async def _send_json(self, data: dict, send, status_code: int = 200):
"""Send JSON response."""
body = json.dumps(data).encode("utf-8")
await self._send_response("application/json", body, send, status_code=status_code)
async def _send_text(self, text: str, send):
"""Send plain text response."""
await self._send_response("text/plain; charset=utf-8", text.encode("utf-8"), send)
async def _send_404(self, send):
"""Send 404 response."""
await self._send_response("text/plain", b"Not Found", send, status_code=404)
async def _send_response(
self, content_type: str, body: bytes, send, status_code: int = 200
):
"""Send ASGI response."""
await send(
{
"type": "http.response.start",
"status": status_code,
"headers": [
[b"content-type", content_type.encode()],
[b"content-length", str(len(body)).encode()],
],
}
)
await send({"type": "http.response.body", "body": body})
def _tag_to_dict(self, tag: Tag) -> dict:
"""Convert Tag object to dict."""
return {
"id": tag.id,
"name": tag.name,
"routes": [
{
"id": r.id,
"name": r.name,
"module": r.module,
"unique_id": r.unique_id,
"response_schema": r.response_schema,
"is_primitive": r.is_primitive,
}
for r in tag.routes
],
}
def _schema_to_dict(self, schema: SchemaNode) -> dict:
"""Convert SchemaNode to dict."""
return {
"id": schema.id,
"module": schema.module,
"name": schema.name,
"fields": [
{
"name": f.name,
"type_name": f.type_name,
"is_object": f.is_object,
"is_exclude": f.is_exclude,
}
for f in schema.fields
],
}
def create_app(self):
"""Create and return an ASGI application."""
async def asgi_app(scope, receive, send):
# In server_mode, handle all paths; otherwise only handle /voyager/*
if scope["type"] == "http":
if self.server_mode or scope["path"].startswith("/voyager"):
await self._handle_request(scope, receive, send)
else:
# Return 404 for non-voyager paths
# (Django should handle these before they reach here)
await self._send_404(send)
else:
await self._send_404(send)
return asgi_app
================================================
FILE: src/fastapi_voyager/adapters/fastapi_adapter.py
================================================
"""
FastAPI adapter for fastapi-voyager.
This module provides the FastAPI-specific implementation of the voyager server.
"""
from typing import Any, Literal
from pydantic import BaseModel
from fastapi_voyager.adapters.base import VoyagerAdapter
from fastapi_voyager.adapters.common import STATIC_FILES_PATH, VOYAGER_PATH_PLACEHOLDER, VoyagerContext
from fastapi_voyager.type import CoreData, SchemaNode, Tag
class OptionParam(BaseModel):
tags: list[Tag]
schemas: list[SchemaNode]
dot: str
enable_brief_mode: bool
version: str
initial_page_policy: Literal["first", "full", "empty"]
swagger_url: str | None = None
has_er_diagram: bool = False
enable_pydantic_resolve_meta: bool = False
framework_name: str = "API"
class Payload(BaseModel):
tags: list[str] | None = None
schema_name: str | None = None
schema_field: str | None = None
route_name: str | None = None
show_fields: str = "object"
brief: bool = False
hide_primitive_route: bool = False
show_module: bool = True
show_pydantic_resolve_meta: bool = False
class SearchResultOptionParam(BaseModel):
tags: list[Tag]
class SchemaSearchPayload(BaseModel):
schema_name: str | None = None
schema_field: str | None = None
show_fields: str = "object"
brief: bool = False
hide_primitive_route: bool = False
show_module: bool = True
show_pydantic_resolve_meta: bool = False
class ErDiagramPayload(BaseModel):
show_fields: str = "object"
show_module: bool = True
edge_minlen: int = 3
show_methods: bool = True
class SourcePayload(BaseModel):
schema_name: str
class FastAPIAdapter(VoyagerAdapter):
"""
FastAPI-specific implementation of VoyagerAdapter.
Creates a FastAPI application with voyager endpoints.
"""
def __init__(
self,
target_app: Any,
module_color: dict[str, str] | None = None,
gzip_minimum_size: int | None = 500,
module_prefix: str | None = None,
swagger_url: str | None = None,
online_repo_url: str | None = None,
initial_page_policy: str = "first",
ga_id: str | None = None,
er_diagram: Any = None,
enable_pydantic_resolve_meta: bool = False,
server_mode: bool = False,
):
self.ctx = VoyagerContext(
target_app=target_app,
module_color=module_color,
module_prefix=module_prefix,
swagger_url=swagger_url,
online_repo_url=online_repo_url,
initial_page_policy=initial_page_policy,
ga_id=ga_id,
er_diagram=er_diagram,
enable_pydantic_resolve_meta=enable_pydantic_resolve_meta,
framework_name="FastAPI",
)
self.gzip_minimum_size = gzip_minimum_size
# Note: server_mode is accepted for API consistency but not used
# since FastAPI apps are always standalone with routes at /
def create_app(self) -> Any:
"""Create and return a FastAPI application with voyager endpoints."""
# Lazy import FastAPI to avoid import errors when framework is not installed
from fastapi import APIRouter, FastAPI
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.gzip import GZipMiddleware
router = APIRouter(tags=["fastapi-voyager"])
@router.post("/er-diagram")
def get_er_diagram(payload: ErDiagramPayload):
return self.ctx.get_er_diagram_data(payload.model_dump())
@router.get("/dot", response_model=OptionParam)
def get_dot() -> OptionParam:
data = self.ctx.get_option_param()
return OptionParam(**data)
@router.post("/dot-search", response_model=SearchResultOptionParam)
def get_search_dot(payload: SchemaSearchPayload) -> SearchResultOptionParam:
tags = self.ctx.get_search_dot(payload.model_dump())
return SearchResultOptionParam(tags=tags)
@router.post("/dot", response_class=PlainTextResponse)
def get_filtered_dot(payload: Payload) -> str:
return self.ctx.get_filtered_dot(payload.model_dump())
@router.post("/dot-core-data", response_model=CoreData)
def get_filtered_dot_core_data(payload: Payload) -> CoreData:
return self.ctx.get_core_data(payload.model_dump())
@router.post("/dot-render-core-data", response_class=PlainTextResponse)
def render_dot_from_core_data(core_data: CoreData) -> str:
return self.ctx.render_dot_from_core_data(core_data)
@router.get("/", response_class=HTMLResponse)
def index() -> str:
return self.ctx.get_index_html()
@router.get("/sw.js")
def get_service_worker():
"""Serve the Service Worker with correct content type."""
from fastapi.responses import PlainTextResponse
return PlainTextResponse(
content=self.ctx.get_service_worker(),
media_type="application/javascript"
)
@router.get("/manifest.webmanifest")
def get_manifest():
"""Serve the PWA manifest with correct content type."""
from fastapi.responses import PlainTextResponse
content = self.ctx.get_manifest()
# Replace VOYAGER_PATH with root-relative path (works for any mount point)
content = content.replace(VOYAGER_PATH_PLACEHOLDER, "./")
return PlainTextResponse(
content=content,
media_type="application/manifest+json"
)
@router.post("/source")
def get_object_by_module_name(payload: SourcePayload) -> JSONResponse:
result = self.ctx.get_source_code(payload.schema_name)
status_code = 200 if "error" not in result else 400
if "error" in result and "not found" in result["error"]:
status_code = 404
return JSONResponse(content=result, status_code=status_code)
@router.post("/vscode-link")
def get_vscode_link_by_module_name(payload: SourcePayload) -> JSONResponse:
result = self.ctx.get_vscode_link(payload.schema_name)
status_code = 200 if "error" not in result else 400
if "error" in result and "not found" in result["error"]:
status_code = 404
return JSONResponse(content=result, status_code=status_code)
app = FastAPI(title="fastapi-voyager demo server")
if self.gzip_minimum_size is not None and self.gzip_minimum_size >= 0:
app.add_middleware(GZipMiddleware, minimum_size=self.gzip_minimum_size)
from fastapi_voyager.adapters.common import WEB_DIR
app.mount(STATIC_FILES_PATH, StaticFiles(directory=str(WEB_DIR)), name="static")
app.include_router(router)
return app
================================================
FILE: src/fastapi_voyager/adapters/litestar_adapter.py
================================================
"""
Litestar adapter for fastapi-voyager.
This module provides the Litestar-specific implementation of the voyager server.
"""
from typing import Any
from fastapi_voyager.adapters.base import VoyagerAdapter
from fastapi_voyager.adapters.common import STATIC_FILES_PATH, VOYAGER_PATH_PLACEHOLDER, WEB_DIR, VoyagerContext
from fastapi_voyager.type import CoreData, SchemaNode, Tag
class LitestarAdapter(VoyagerAdapter):
"""
Litestar-specific implementation of VoyagerAdapter.
Creates a Litestar application with voyager endpoints.
"""
def __init__(
self,
target_app: Any,
module_color: dict[str, str] | None = None,
gzip_minimum_size: int | None = 500,
module_prefix: str | None = None,
swagger_url: str | None = None,
online_repo_url: str | None = None,
initial_page_policy: str = "first",
ga_id: str | None = None,
er_diagram: Any = None,
enable_pydantic_resolve_meta: bool = False,
server_mode: bool = False,
):
self.ctx = VoyagerContext(
target_app=target_app,
module_color=module_color,
module_prefix=module_prefix,
swagger_url=swagger_url,
online_repo_url=online_repo_url,
initial_page_policy=initial_page_policy,
ga_id=ga_id,
er_diagram=er_diagram,
enable_pydantic_resolve_meta=enable_pydantic_resolve_meta,
framework_name="Litestar",
)
self.gzip_minimum_size = gzip_minimum_size
# Note: server_mode is accepted for API consistency but not used
# since Litestar apps are always standalone with routes at /
def create_app(self) -> Any:
"""Create and return a Litestar application with voyager endpoints."""
# Lazy import Litestar to avoid import errors when framework is not installed
from litestar import Litestar, MediaType, Request, Response, get, post
from litestar.static_files import create_static_files_router
@post("/er-diagram")
async def get_er_diagram(request: Request) -> dict:
payload = await request.json()
return self.ctx.get_er_diagram_data(payload)
@get("/dot")
async def get_dot(request: Request) -> dict:
data = self.ctx.get_option_param()
# Convert tags and schemas to dicts for JSON serialization
return {
"tags": [self._tag_to_dict(t) for t in data["tags"]],
"schemas": [self._schema_to_dict(s) for s in data["schemas"]],
"dot": data["dot"],
"enable_brief_mode": data["enable_brief_mode"],
"version": data["version"],
"initial_page_policy": data["initial_page_policy"],
"swagger_url": data["swagger_url"],
"has_er_diagram": data["has_er_diagram"],
"enable_pydantic_resolve_meta": data["enable_pydantic_resolve_meta"],
"framework_name": data["framework_name"],
}
@post("/dot-search")
async def get_search_dot(request: Request) -> dict:
payload = await request.json()
tags = self.ctx.get_search_dot(payload)
return {"tags": [self._tag_to_dict(t) for t in tags]}
@post("/dot")
async def get_filtered_dot(request: Request) -> str:
payload = await request.json()
return self.ctx.get_filtered_dot(payload)
@post("/dot-core-data")
async def get_filtered_dot_core_data(request: Request) -> CoreData:
payload = await request.json()
return self.ctx.get_core_data(payload)
@post("/dot-render-core-data")
async def render_dot_from_core_data(request: Request) -> str:
payload = await request.json()
core_data = CoreData(**payload)
return self.ctx.render_dot_from_core_data(core_data)
@get("/", media_type=MediaType.HTML)
async def index() -> str:
return self.ctx.get_index_html()
@get("/sw.js", media_type="application/javascript")
async def get_service_worker() -> str:
"""Serve the Service Worker."""
return self.ctx.get_service_worker()
@get("/manifest.webmanifest", media_type="application/manifest+json")
async def get_manifest() -> str:
"""Serve the PWA manifest."""
content = self.ctx.get_manifest()
return content.replace(VOYAGER_PATH_PLACEHOLDER, "./")
@post("/source")
async def get_object_by_module_name(request: Request) -> dict:
payload = await request.json()
result = self.ctx.get_source_code(payload.get("schema_name", ""))
status_code = 200 if "error" not in result else 400
if "error" in result and "not found" in result["error"]:
status_code = 404
return Response(
content=result,
status_code=status_code,
media_type=MediaType.JSON,
)
@post("/vscode-link")
async def get_vscode_link_by_module_name(request: Request) -> dict:
payload = await request.json()
result = self.ctx.get_vscode_link(payload.get("schema_name", ""))
status_code = 200 if "error" not in result else 400
if "error" in result and "not found" in result["error"]:
status_code = 404
return Response(
content=result,
status_code=status_code,
media_type=MediaType.JSON,
)
# Create static files router using the new API (replaces deprecated StaticFilesConfig)
static_files_router = create_static_files_router(
path=STATIC_FILES_PATH,
directories=[str(WEB_DIR)],
)
# Create Litestar app
app = Litestar(
route_handlers=[
get_er_diagram,
get_dot,
get_search_dot,
get_filtered_dot,
get_filtered_dot_core_data,
render_dot_from_core_data,
index,
get_service_worker,
get_manifest,
get_object_by_module_name,
get_vscode_link_by_module_name,
static_files_router,
],
)
return app
def _tag_to_dict(self, tag: Tag) -> dict:
"""Convert Tag object to dict."""
return {
"id": tag.id,
"name": tag.name,
"routes": [
{
"id": r.id,
"name": r.name,
"module": r.module,
"unique_id": r.unique_id,
"response_schema": r.response_schema,
"is_primitive": r.is_primitive,
}
for r in tag.routes
],
}
def _schema_to_dict(self, schema: SchemaNode) -> dict:
"""Convert SchemaNode to dict."""
return {
"id": schema.id,
"module": schema.module,
"name": schema.name,
"fields": [
{
"name": f.name,
"type_name": f.type_name,
"is_object": f.is_object,
"is_exclude": f.is_exclude,
}
for f in schema.fields
],
}
================================================
FILE: src/fastapi_voyager/cli.py
================================================
"""Command line interface for fastapi-voyager."""
import argparse
import importlib
import importlib.util
import logging
import os
import sys
from typing import Any
from fastapi_voyager import server as viz_server
from fastapi_voyager.version import __version__
from fastapi_voyager.voyager import Voyager
logger = logging.getLogger(__name__)
# Framework type constants
SUPPORTED_FRAMEWORKS = ["fastapi", "litestar", "django-ninja"]
def load_app_from_file(module_path: str, app_name: str = "app", framework: str | None = None) -> Any:
"""Load web framework app from a Python module file."""
try:
# Convert relative path to absolute path
if not os.path.isabs(module_path):
module_path = os.path.abspath(module_path)
# Load the module
spec = importlib.util.spec_from_file_location("app_module", module_path)
if spec is None or spec.loader is None:
logger.error(f"Could not load module from {module_path}")
return None
module = importlib.util.module_from_spec(spec)
sys.modules["app_module"] = module
spec.loader.exec_module(module)
# Get the app instance
if not hasattr(module, app_name):
logger.error(f"No attribute '{app_name}' found in the module")
return None
app = getattr(module, app_name)
# Verify app type if framework is specified
if framework is not None:
if not _validate_app_framework(app, framework):
logger.error(f"'{app_name}' is not a {framework} instance")
return None
return app
except Exception as e:
logger.error(f"Error loading app: {e}")
return None
def load_app_from_module(module_name: str, app_name: str = "app", framework: str | None = None) -> Any:
"""Load web framework app from a Python module name."""
try:
# Temporarily add the current working directory to sys.path
current_dir = os.getcwd()
if current_dir not in sys.path:
sys.path.insert(0, current_dir)
path_added = True
else:
path_added = False
try:
# Import the module by name
module = importlib.import_module(module_name)
# Get the app instance
if not hasattr(module, app_name):
logger.error(f"No attribute '{app_name}' found in module '{module_name}'")
return None
app = getattr(module, app_name)
# Verify app type if framework is specified
if framework is not None:
if not _validate_app_framework(app, framework):
logger.error(f"'{app_name}' is not a {framework} instance")
return None
return app
finally:
# Cleanup: if we added the path, remove it
if path_added and current_dir in sys.path:
sys.path.remove(current_dir)
except ImportError as e:
logger.error(f"Could not import module '{module_name}': {e}")
return None
except Exception as e:
logger.error(f"Error loading app from module '{module_name}': {e}")
return None
def _validate_app_framework(app: Any, framework: str) -> bool:
"""Validate that the app matches the expected framework type."""
try:
if framework == "fastapi":
from fastapi import FastAPI
return isinstance(app, FastAPI)
elif framework == "litestar":
from litestar import Litestar
return isinstance(app, Litestar)
elif framework == "django-ninja":
from ninja import NinjaAPI
return isinstance(app, NinjaAPI)
return False
except ImportError as e:
logger.error(
f"The {framework} package is not installed. "
f"Install it with: uv add fastapi-voyager[{framework}]"
)
logger.debug(f"Import error details: {e}")
return False
def generate_visualization(
app: Any,
output_file: str = "router_viz.dot", tags: list[str] | None = None,
schema: str | None = None,
show_fields: bool = False,
module_color: dict[str, str] | None = None,
route_name: str | None = None,
):
"""Generate DOT file for API router visualization."""
analytics = Voyager(
include_tags=tags,
schema=schema,
show_fields=show_fields,
module_color=module_color,
route_name=route_name,
)
analytics.analysis(app)
dot_content = analytics.render_dot()
# Optionally write to file
with open(output_file, 'w', encoding='utf-8') as f:
f.write(dot_content)
logger.info(f"DOT file generated: {output_file}")
logger.info("To render the graph, use: dot -Tpng router_viz.dot -o router_viz.png")
logger.info("Or view online: https://dreampuf.github.io/GraphvizOnline/")
def main():
"""Main CLI entry point."""
parser = argparse.ArgumentParser(
description="Visualize web application's routing tree and dependencies (supports FastAPI, Litestar, Django-Ninja)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
voyager app.py --web fastapi # Load 'app' from app.py (FastAPI)
voyager app.py --web litestar # Load 'app' from app.py (Litestar)
voyager -m tests.demo --web django-ninja # Load 'app' from demo module (Django-Ninja)
voyager -m tests.demo --app=api --web fastapi # Load 'api' from tests.demo
voyager -m tests.demo --web fastapi --schema=NodeA # filter nodes by schema name
voyager -m tests.demo --web fastapi --tags=page restful # filter routes by tags
voyager -m tests.demo --web fastapi --module_color=tests.demo:red --module_color=tests.service:yellow
voyager -m tests.demo --web fastapi -o my_graph.dot # Output to my_graph.dot
voyager -m tests.demo --web fastapi --server # start a local server to preview
voyager -m tests.demo --web fastapi --server --port=8001 # start a local server to preview
"""
)
# Create mutually exclusive group for module loading options
group = parser.add_mutually_exclusive_group(required=False)
group.add_argument(
"module",
nargs="?",
help="Python file containing the web application"
)
group.add_argument(
"-m", "--module",
dest="module_name",
help="Python module name containing the web application (like python -m)"
)
parser.add_argument(
"--web",
choices=SUPPORTED_FRAMEWORKS,
help="Web framework type (required when using --server): fastapi, litestar, django-ninja"
)
parser.add_argument(
"--app", "-a",
default="app",
help="Name of the app variable (default: app)"
)
parser.add_argument(
"--output", "-o",
default="router_viz.dot",
help="Output DOT file name (default: router_viz.dot)"
)
parser.add_argument(
"--server",
action="store_true",
help="Start a local server to preview the generated DOT graph"
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="Port for the preview server when --server is used (default: 8000)"
)
parser.add_argument(
"--host",
type=str,
default="127.0.0.1",
help="Host/IP for the preview server when --server is used (default: 127.0.0.1). Use 0.0.0.0 to listen on all interfaces."
)
parser.add_argument(
"--module_prefix",
type=str,
default=None,
help="Prefix routes with module name when rendering brief view (only valid with --server)"
)
parser.add_argument(
"--version", "-v",
action="version",
version=f"fastapi-voyager {__version__}"
)
parser.add_argument(
"--tags",
nargs="+",
help="Only include routes whose first tag is in the provided list"
)
parser.add_argument(
"--module_color",
action="append",
metavar="KEY:VALUE",
help="Module color mapping as key1:value1 key2:value2 (module name to Graphviz color)"
)
# removed service_prefixes option
parser.add_argument(
"--schema",
default=None,
help="Filter schemas by name"
)
parser.add_argument(
"--show_fields",
choices=["single", "object", "all"],
default="object",
help="Field display mode: single (no fields), object (only object-like fields), all (all fields). Default: object"
)
parser.add_argument(
"--route_name",
type=str,
default=None,
help="Filter by route id (format: _)"
)
parser.add_argument(
"--log-level",
dest="log_level",
default="INFO",
help="Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO)"
)
args = parser.parse_args()
# Validate arguments
if args.module_prefix and not args.server:
parser.error("--module_prefix can only be used together with --server")
if not (args.module_name or args.module):
parser.error("You must provide a module file or -m module name")
# When --server is used, --web is required
if args.server and not args.web:
parser.error("--web is required when using --server. Please specify: fastapi, litestar, or django-ninja")
# Determine the framework (default to the one specified, or None for non-server mode)
framework = args.web if args.server else None
# Configure logging based on --log-level
level_name = (args.log_level or "INFO").upper()
logging.basicConfig(level=level_name)
# Load app based on the input method (module_name takes precedence)
if args.module_name:
app = load_app_from_module(args.module_name, args.app, framework)
else:
if not os.path.exists(args.module):
logger.error(f"File '{args.module}' not found")
sys.exit(1)
app = load_app_from_file(args.module, args.app, framework)
if app is None:
sys.exit(1)
# helper: parse KEY:VALUE pairs into dict
def parse_kv_pairs(pairs: list[str] | None) -> dict[str, str] | None:
if not pairs:
return None
result: dict[str, str] = {}
for item in pairs:
if ":" in item:
k, v = item.split(":", 1)
k = k.strip()
v = v.strip()
if k:
result[k] = v
return result or None
try:
module_color = parse_kv_pairs(args.module_color)
if args.server:
# Build a preview server using the appropriate framework
try:
import uvicorn
except ImportError:
logger.info("uvicorn is required to run the server. Install via 'pip install uvicorn' or 'uv add uvicorn'.")
sys.exit(1)
# Create voyager app - it auto-detects framework and returns appropriate app type
app_server = viz_server.create_voyager(
app,
module_color=module_color,
module_prefix=args.module_prefix,
server_mode=True, # Enable server mode to serve at root path
)
logger.info(f"Starting {args.web} preview server at http://{args.host}:{args.port} ... (Ctrl+C to stop)")
uvicorn.run(app_server, host=args.host, port=args.port, log_level=level_name.lower())
else:
# Generate and write dot file locally
generate_visualization(
app,
args.output,
tags=args.tags,
schema=args.schema,
show_fields=args.show_fields,
module_color=module_color,
route_name=args.route_name,
)
except Exception as e:
logger.info(f"Error generating visualization: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
================================================
FILE: src/fastapi_voyager/er_diagram.py
================================================
from __future__ import annotations
from logging import getLogger
from pydantic import BaseModel
from pydantic_resolve import Entity, ErDiagram, Relationship
from fastapi_voyager.pydantic_resolve_util import extract_query_mutation_methods
from fastapi_voyager.render import Renderer
from fastapi_voyager.render_style import RenderConfig
from fastapi_voyager.type import (
FieldInfo,
FieldType,
Link,
LinkType,
MethodInfo,
PK,
SchemaNode,
)
from fastapi_voyager.type_helper import (
full_class_name,
get_core_types,
get_type_name,
is_list,
safe_issubclass,
update_forward_refs,
)
ARROR = "=>"
logger = getLogger(__name__)
def _get_loader_name(loader) -> str | None:
"""Extract loader function name (without module path)."""
if loader is None:
return None
# loader is a callable, get its __name__ or __qualname__
name = getattr(loader, '__name__', None) or getattr(loader, '__qualname__', None)
if name and '.' in name:
# Return only the function name, not the full path
return name.split('.')[-1]
return name
class DiagramRenderer(Renderer):
"""
Renderer for Entity-Relationship diagrams.
Inherits from Renderer to reuse template system and styling.
ER diagrams have simpler structure (no tags/routes), so we only
need to customize the top-level DOT structure.
"""
def __init__(
self,
*,
show_fields: FieldType = 'single',
show_module: bool = True,
theme_color: str | None = None,
edge_minlen: int = 3,
show_methods: bool = True,
) -> None:
# Initialize parent Renderer with shared config
super().__init__(
show_fields=show_fields,
show_module=show_module,
config=RenderConfig(), # Use unified style configuration
theme_color=theme_color,
show_methods=show_methods,
)
self.edge_minlen = edge_minlen
logger.info(f'show_module: {self.show_module}')
def render_link(self, link: Link) -> str:
"""Override link rendering for ER diagrams."""
source = self._handle_schema_anchor(link.source)
target = self._handle_schema_anchor(link.target)
# Build link attributes
if link.style is not None:
attrs = {'style': link.style}
if link.label:
attrs['label'] = link.label
attrs['minlen'] = self.edge_minlen
else:
attrs = self.style.get_link_attributes(link.type)
if link.label:
attrs['label'] = link.label
return self.template_renderer.render_template(
'dot/link.j2',
source=source,
target=target,
attributes=self._format_link_attributes(attrs)
)
def render_dot(self, nodes: list[SchemaNode], links: list[Link], spline_line=False) -> str:
"""
Render ER diagram as DOT format.
Reuses parent's render_module_schema_content and render_link methods.
Only customizes the top-level digraph structure.
"""
# Reuse parent's module schema rendering
module_schemas_str = self.render_module_schema_content(nodes)
# Reuse parent's link rendering
link_str = '\n'.join(self.render_link(link) for link in links)
# Render using ER diagram template
return self.template_renderer.render_template(
'dot/er_diagram.j2',
pad=self.style.pad,
nodesep=self.style.nodesep,
font=self.style.font,
node_fontsize=self.style.node_fontsize,
spline='line' if spline_line else None,
er_cluster=module_schemas_str,
links=link_str
)
class VoyagerErDiagram:
def __init__(self,
er_diagram: ErDiagram,
show_fields: FieldType = 'single',
show_module: bool = False,
theme_color: str | None = None,
edge_minlen: int = 3,
show_methods: bool = True):
self.er_diagram = er_diagram
self.nodes: list[SchemaNode] = []
self.node_set: dict[str, SchemaNode] = {}
self.links: list[Link] = []
self.link_set: set[tuple[str, str]] = set()
self.fk_set: dict[str, set[str]] = {}
self.show_field = show_fields
self.show_module = show_module
self.theme_color = theme_color
self.edge_minlen = edge_minlen
self.show_methods = show_methods
def generate_node_head(self, link_name: str):
return f'{link_name}::{PK}'
def analysis_entity(self, entity: Entity):
schema = entity.kls
update_forward_refs(schema)
self.add_to_node_set(
schema,
fk_set=self.fk_set.get(full_class_name(schema)),
entity_queries=entity.queries,
entity_mutations=entity.mutations,
)
for relationship in entity.relationships:
annos = get_core_types(relationship.target)
for anno in annos:
if not isinstance(anno, type) or not safe_issubclass(anno, BaseModel):
continue
self.add_to_node_set(anno, fk_set=self.fk_set.get(full_class_name(anno)))
source_name = f'{full_class_name(schema)}::f{relationship.fk}'
# Build label with cardinality and loader name
cardinality = f'1 {ARROR} N' if is_list(relationship.target) else f'1 {ARROR} 1'
loader_name = _get_loader_name(relationship.loader)
loader_fullname = (
f"{relationship.loader.__module__}.{loader_name}"
if relationship.loader and loader_name
else None
)
label = cardinality
if relationship.name:
label = f'{relationship.name}\n{label}'
self.add_to_link_set(
source=source_name,
source_origin=full_class_name(schema),
target=self.generate_node_head(full_class_name(anno)),
target_origin=full_class_name(anno),
type='schema',
label=label,
style='solid' if relationship.loader else 'solid, dashed',
loader_fullname=loader_fullname
)
def add_to_node_set(
self,
schema,
fk_set: set[str] | None = None,
entity_queries: list | None = None,
entity_mutations: list | None = None,
) -> str:
"""
1. calc full_path, add to node_set
2. if duplicated, do nothing, else insert
2. return the full_path
"""
full_name = full_class_name(schema)
if full_name not in self.node_set:
# Extract queries and mutations: prefer Entity-level configs, fallback to class decorators
queries, mutations = get_queries_and_mutations(
schema,
entity_queries=entity_queries,
entity_mutations=entity_mutations,
)
# skip meta info for normal queries
self.node_set[full_name] = SchemaNode(
id=full_name,
module=schema.__module__,
name=schema.__name__,
fields=get_fields(schema, fk_set),
is_entity=False, # Don't mark in ER diagram
queries=queries,
mutations=mutations
)
return full_name
def add_to_link_set(
self,
source: str,
source_origin: str,
target: str,
target_origin: str,
type: LinkType,
label: str,
style: str,
biz: str | None = None,
loader_fullname: str | None = None
) -> bool:
"""
1. add link to link_set
2. if duplicated, do nothing, else insert
"""
pair = (source, target, biz)
if result := pair not in self.link_set:
self.link_set.add(pair)
self.links.append(Link(
source=source,
source_origin=source_origin,
target=target,
target_origin=target_origin,
type=type,
label=label,
style=style,
loader_fullname=loader_fullname
))
return result
def render_dot(self):
self.fk_set = {
full_class_name(entity.kls): set([rel.fk for rel in entity.relationships])
for entity in self.er_diagram.entities
}
for entity in self.er_diagram.entities:
self.analysis_entity(entity)
renderer = DiagramRenderer(
show_fields=self.show_field,
show_module=self.show_module,
theme_color=self.theme_color,
edge_minlen=self.edge_minlen,
show_methods=self.show_methods,
)
return renderer.render_dot(list(self.node_set.values()), self.links)
def get_fields(schema: type[BaseModel], fk_set: set[str] | None = None) -> list[FieldInfo]:
fields: list[FieldInfo] = []
for k, v in schema.model_fields.items():
anno = v.annotation
fields.append(FieldInfo(
is_object=k in fk_set if fk_set is not None else False,
name=k,
from_base=False,
type_name=get_type_name(anno),
is_exclude=bool(v.exclude)
))
return fields
def get_queries_and_mutations(
schema: type[BaseModel],
entity_queries: list | None = None,
entity_mutations: list | None = None,
) -> tuple[list[MethodInfo], list[MethodInfo]]:
"""Extract @query and @mutation methods from an entity.
Prefers Entity-level QueryConfig/MutationConfig when available,
falls back to @query/@mutation decorators on the class.
"""
queries: list[MethodInfo] = []
mutations: list[MethodInfo] = []
if entity_queries:
for qc in entity_queries:
method = qc.method
name = qc.name or method.__name__
return_type = _get_return_type_str(method)
queries.append(MethodInfo(name=name, return_type=return_type))
elif entity_mutations is not None:
# No queries configured at entity level, skip decorator extraction
pass
else:
# Fallback: extract from class decorators
query_dicts, _ = extract_query_mutation_methods(schema)
queries = [MethodInfo(name=q['name'], return_type=q['return_type']) for q in query_dicts]
if entity_mutations:
for mc in entity_mutations:
method = mc.method
name = mc.name or method.__name__
return_type = _get_return_type_str(method)
mutations.append(MethodInfo(name=name, return_type=return_type))
elif entity_queries is not None:
# No mutations configured at entity level, skip decorator extraction
pass
else:
# Fallback: extract from class decorators
_, mutation_dicts = extract_query_mutation_methods(schema)
mutations = [MethodInfo(name=m['name'], return_type=m['return_type']) for m in mutation_dicts]
return queries, mutations
def _get_return_type_str(method) -> str:
"""Extract return type annotation string from a method."""
import inspect
sig = inspect.signature(method)
if sig.return_annotation != inspect.Parameter.empty:
ann = sig.return_annotation
if isinstance(ann, str):
return ann
if hasattr(ann, '__origin__'):
import typing
return str(ann).replace('typing.', '')
return getattr(ann, '__name__', str(ann))
return ''
================================================
FILE: src/fastapi_voyager/filter.py
================================================
from __future__ import annotations
from collections import deque
from fastapi_voyager.type import PK, Link, Route, SchemaNode, Tag
def filter_graph(
*,
schema: str | None,
schema_field: str | None,
tags: list[Tag],
routes: list[Route],
nodes: list[SchemaNode],
links: list[Link],
node_set: dict[str, SchemaNode],
) -> tuple[list[Tag], list[Route], list[SchemaNode], list[Link]]:
"""Filter tags, routes, schema nodes and links based on a target schema and optional field.
Behaviour summary (mirrors previous Analytics.filter_nodes_and_schemas_based_on_schemas):
1. If `schema` is None, return inputs unmodified.
2. Seed with the schema node id (full id match). If not found, return inputs.
3. If `schema_field` provided, prune parent/subset links so that only those whose *source* schema
contains that field and whose *target* is already accepted remain, recursively propagating upward.
4. Perform two traversals on the (possibly pruned) links set:
- Upstream: reverse walk (collect nodes that point to current frontier) -> brings in children & entry chain.
- Downstream: forward walk (collect targets from current frontier) -> brings in ancestors.
5. Keep only objects (tags, routes, nodes, links) whose origin ids are in the collected set.
"""
if schema is None:
return tags, routes, nodes, links
seed_node_ids = {n.id for n in nodes if n.id == schema}
if not seed_node_ids:
return tags, routes, nodes, links
# Step 1: schema_field pruning logic for parent/subset links
if schema_field:
current_targets = set(seed_node_ids)
accepted_targets = set(seed_node_ids)
accepted_links: list[Link] = []
parent_subset_links = [lk for lk in links if lk.type in ("parent", "subset")]
other_links = [lk for lk in links if lk.type not in ("parent", "subset")]
while current_targets:
next_targets: set[str] = set()
for lk in parent_subset_links:
if (
lk.target_origin in current_targets
and lk.source_origin not in accepted_targets
and lk.source_origin in node_set
and lk.target_origin in node_set
):
src_node = node_set.get(lk.source_origin)
if src_node and any(f.name == schema_field for f in src_node.fields):
accepted_links.append(lk)
next_targets.add(lk.source_origin)
accepted_targets.add(lk.source_origin)
elif lk.target_origin in current_targets and lk.source_origin in accepted_targets:
src_node = node_set.get(lk.source_origin)
if src_node and any(f.name == schema_field for f in src_node.fields):
if lk not in accepted_links:
accepted_links.append(lk)
current_targets = next_targets
filtered_links = other_links + accepted_links
else:
filtered_links = links
# Step 2: build adjacency maps
fwd: dict[str, set[str]] = {}
rev: dict[str, set[str]] = {}
for lk in filtered_links:
fwd.setdefault(lk.source_origin, set()).add(lk.target_origin)
rev.setdefault(lk.target_origin, set()).add(lk.source_origin)
# Upstream (reverse) traversal
upstream: set[str] = set()
frontier = set(seed_node_ids)
while frontier:
new_layer: set[str] = set()
for nid in frontier:
for src in rev.get(nid, ()): # src points to nid
if src not in upstream and src not in seed_node_ids:
new_layer.add(src)
upstream.update(new_layer)
frontier = new_layer
# Downstream (forward) traversal
downstream: set[str] = set()
frontier = set(seed_node_ids)
while frontier:
new_layer: set[str] = set()
for nid in frontier:
for tgt in fwd.get(nid, ()): # nid points to tgt
if tgt not in downstream and tgt not in seed_node_ids:
new_layer.add(tgt)
downstream.update(new_layer)
frontier = new_layer
included_ids: set[str] = set(seed_node_ids) | upstream | downstream
_nodes = [n for n in nodes if n.id in included_ids]
_links = [l for l in filtered_links if l.source_origin in included_ids and l.target_origin in included_ids]
_tags = [t for t in tags if t.id in included_ids]
_routes = [r for r in routes if r.id in included_ids]
return _tags, _routes, _nodes, _links
def filter_subgraph_by_module_prefix(
*,
tags: list[Tag],
routes: list[Route],
links: list[Link],
nodes: list[SchemaNode],
module_prefix: str
) -> tuple[list[Tag], list[Route], list[SchemaNode], list[Link]]:
"""Collapse schema graph so routes link directly to nodes whose module matches ``module_prefix``.
The routine keeps tag→route links untouched, prunes schema nodes whose module does not start
with ``module_prefix``, and merges the remaining schema relationships so each route connects
directly to the surviving schema nodes. Traversal stops once a qualifying node is reached and
guards against cycles in the schema graph.
"""
if not module_prefix:
# empty prefix keeps existing graph structure, so simply reuse incoming data
return tags, routes, nodes, [lk for lk in links if lk.type in ("tag_route", "route_to_schema")]
route_links = [lk for lk in links if lk.type == "route_to_schema"]
schema_links = [lk for lk in links if lk.type in {"schema", "parent", "subset"}]
tag_route_links = [lk for lk in links if lk.type == "tag_route"]
node_lookup: dict[str, SchemaNode] = {node.id: node for node in nodes}
filtered_nodes = [node for node in nodes if node_lookup[node.id].module.startswith(module_prefix)]
filtered_node_ids = {node.id for node in filtered_nodes}
adjacency: dict[str, list[str]] = {}
for link in schema_links:
if link.source_origin not in node_lookup or link.target_origin not in node_lookup:
continue
adjacency.setdefault(link.source_origin, [])
if link.target_origin not in adjacency[link.source_origin]:
adjacency[link.source_origin].append(link.target_origin)
merged_links: list[Link] = []
seen_pairs: set[tuple[str, str]] = set()
for link in route_links:
route_id = link.source_origin
start_node_id = link.target_origin
if route_id is None or start_node_id is None:
continue
if start_node_id not in node_lookup:
continue
visited: set[str] = set()
queue: deque[str] = deque([start_node_id])
while queue:
current = queue.popleft()
if current in visited:
continue
visited.add(current)
if current in filtered_node_ids:
key = (route_id, current)
if key not in seen_pairs:
seen_pairs.add(key)
merged_links.append(
Link(
source=link.source,
source_origin=route_id,
target=f"{current}::{PK}",
target_origin=current,
type="route_to_schema",
)
)
# stop traversing past a qualifying node
continue
for next_node in adjacency.get(current, () ):
if next_node not in visited:
queue.append(next_node)
module_prefix_links = [
lk
for lk in links
if (lk.source_origin or "").startswith(module_prefix)
and (lk.target_origin or "").startswith(module_prefix)
]
filtered_links = tag_route_links + merged_links + module_prefix_links
return tags, routes, filtered_nodes, filtered_links
def filter_subgraph_from_tag_to_schema_by_module_prefix(
*,
tags: list[Tag],
routes: list[Route],
links: list[Link],
nodes: list[SchemaNode],
module_prefix: str
) -> tuple[list[Tag], list[Route], list[SchemaNode], list[Link]]:
"""Collapse schema graph so routes link directly to nodes whose module matches ``module_prefix``.
The routine keeps tag→route links untouched, prunes schema nodes whose module does not start
with ``module_prefix``, and merges the remaining schema relationships so each route connects
directly to the surviving schema nodes. Traversal stops once a qualifying node is reached and
guards against cycles in the schema graph.
"""
if not module_prefix:
# empty prefix keeps existing graph structure, so simply reuse incoming data
return tags, routes, nodes, [lk for lk in links if lk.type in ("tag_route", "route_to_schema")]
route_links = [lk for lk in links if lk.type == "route_to_schema"]
schema_links = [lk for lk in links if lk.type in {"schema", "parent", "subset"}]
tag_route_links = [lk for lk in links if lk.type == "tag_route"]
node_lookup: dict[str, SchemaNode] = {node.id: node for node in (nodes + routes)}
filtered_nodes = [node for node in nodes if node_lookup[node.id].module.startswith(module_prefix)]
filtered_node_ids = {node.id for node in filtered_nodes}
adjacency: dict[str, list[str]] = {}
for link in (schema_links + route_links):
if link.source_origin not in node_lookup or link.target_origin not in node_lookup:
continue
adjacency.setdefault(link.source_origin, [])
if link.target_origin not in adjacency[link.source_origin]:
adjacency[link.source_origin].append(link.target_origin)
merged_links: list[Link] = []
seen_pairs: set[tuple[str, str]] = set()
for link in tag_route_links:
tag_id = link.source_origin
start_node_id = link.target_origin
if tag_id is None or start_node_id is None:
continue
if start_node_id not in node_lookup:
continue
visited: set[str] = set()
queue: deque[str] = deque([start_node_id])
while queue:
current = queue.popleft()
if current in visited:
continue
visited.add(current)
if current in filtered_node_ids:
key = (tag_id, current)
if key not in seen_pairs:
seen_pairs.add(key)
merged_links.append(
Link(
source=link.source,
source_origin=tag_id,
target=f"{current}::{PK}",
target_origin=current,
type="tag_to_schema",
)
)
# stop traversing past a qualifying node
continue
for next_node in adjacency.get(current, () ):
if next_node not in visited:
queue.append(next_node)
module_prefix_links = [
lk
for lk in links
if (lk.source_origin or "").startswith(module_prefix)
and (lk.target_origin or "").startswith(module_prefix)
]
filtered_links = merged_links + module_prefix_links
return tags, [], filtered_nodes, filtered_links # route is skipped
================================================
FILE: src/fastapi_voyager/introspectors/__init__.py
================================================
"""
Introspectors for different web frameworks.
This package contains built-in introspector implementations for various frameworks.
"""
from .base import AppIntrospector, RouteInfo
from .detector import FrameworkType, detect_framework, get_introspector
# Try to import each introspector, but don't fail if the framework isn't installed
try:
from .fastapi import FastAPIIntrospector
except ImportError:
FastAPIIntrospector = None # type: ignore
try:
from .django_ninja import DjangoNinjaIntrospector
except ImportError:
DjangoNinjaIntrospector = None # type: ignore
try:
from .litestar import LitestarIntrospector
except ImportError:
LitestarIntrospector = None # type: ignore
__all__ = [
"AppIntrospector",
"RouteInfo",
"FastAPIIntrospector",
"DjangoNinjaIntrospector",
"LitestarIntrospector",
"FrameworkType",
"detect_framework",
"get_introspector",
]
================================================
FILE: src/fastapi_voyager/introspectors/base.py
================================================
"""
Introspection abstraction layer for framework-agnostic route analysis.
This module provides the abstraction that allows fastapi-voyager to work with
different web frameworks that support OpenAPI and Pydantic, such as:
- FastAPI
- Django Ninja
- Litestar
- Flask-OpenAPI
"""
from abc import ABC, abstractmethod
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from typing import Any
@dataclass
class RouteInfo:
"""
Standardized route information that works across different frameworks.
This data class encapsulates the essential information needed by voyager
to analyze and visualize routes, independent of the underlying framework.
"""
# Unique identifier for the route (function path)
id: str
# Human-readable name (function name)
name: str
# Module where the route handler is defined
module: str
# Operation ID from OpenAPI spec
operation_id: str | None
# List of tags associated with this route
tags: list[str]
# The route handler function/endpoint
endpoint: Callable
# Response model (should be a Pydantic BaseModel)
response_model: type[Any]
# Any additional framework-specific data
extra: dict[str, Any] | None = None
class AppIntrospector(ABC):
"""
Abstract base class for app introspection.
Implement this class to add support for different web frameworks.
The introspector is responsible for extracting route information
from the framework's internal structure.
"""
@abstractmethod
def get_routes(self) -> Iterator[RouteInfo]:
"""
Iterate over all available routes in the application.
Yields:
RouteInfo: Standardized route information
Example:
>>> for route in introspector.get_routes():
... print(f"{route.id}: {route.tags}")
"""
pass
@abstractmethod
def get_swagger_url(self) -> str | None:
"""
Get the URL to the Swagger/OpenAPI documentation.
Returns:
The URL path or None if not available
"""
pass
================================================
FILE: src/fastapi_voyager/introspectors/detector.py
================================================
"""
Framework detection utility for fastapi-voyager.
This module provides a centralized framework detection mechanism that is used
by both introspectors and adapters to avoid code duplication.
"""
from enum import Enum
from typing import Any
from fastapi_voyager.introspectors.base import AppIntrospector
class FrameworkType(Enum):
"""Supported framework types."""
FASTAPI = "fastapi"
DJANGO_NINJA = "django_ninja"
LITESTAR = "litestar"
UNKNOWN = "unknown"
def detect_framework(app: Any) -> FrameworkType:
"""
Detect the framework type of the given application.
This function uses the same detection logic as the introspector system,
ensuring consistency across the codebase.
Args:
app: A web application instance
Returns:
FrameworkType: The detected framework type
Note:
The detection order matters: Litestar is checked before Django Ninja
to avoid Django import issues.
"""
# If it's already an introspector, try to determine framework from it
if isinstance(app, AppIntrospector):
app_class_name = type(app).__name__
if "FastAPI" in app_class_name:
return FrameworkType.FASTAPI
elif "DjangoNinja" in app_class_name or "Ninja" in app_class_name:
return FrameworkType.DJANGO_NINJA
elif "Litestar" in app_class_name:
return FrameworkType.LITESTAR
return FrameworkType.UNKNOWN
# Get the class name for type checking
app_class_name = type(app).__name__
# Try FastAPI
try:
from fastapi import FastAPI
if isinstance(app, FastAPI):
return FrameworkType.FASTAPI
except ImportError:
pass
# Try Litestar (check before Django Ninja to avoid Django import issues)
try:
from litestar import Litestar
if isinstance(app, Litestar):
return FrameworkType.LITESTAR
except ImportError:
pass
# Try Django Ninja (check by class name first to avoid import if not needed)
try:
if app_class_name == "NinjaAPI":
from ninja import NinjaAPI
if isinstance(app, NinjaAPI):
return FrameworkType.DJANGO_NINJA
except ImportError:
pass
return FrameworkType.UNKNOWN
def get_introspector(app: Any) -> AppIntrospector | None:
"""
Get the appropriate introspector for the given app.
This is a centralized function that uses the framework detection logic
to return the correct introspector instance.
Args:
app: A web application instance or AppIntrospector
Returns:
An AppIntrospector instance, or None if framework not supported
Raises:
TypeError: If the app type is not supported
"""
# If it's already an introspector, return it
if isinstance(app, AppIntrospector):
return app
framework = detect_framework(app)
if framework == FrameworkType.FASTAPI:
from fastapi_voyager.introspectors import FastAPIIntrospector
if FastAPIIntrospector:
return FastAPIIntrospector(app)
elif framework == FrameworkType.LITESTAR:
from fastapi_voyager.introspectors import LitestarIntrospector
if LitestarIntrospector:
return LitestarIntrospector(app)
elif framework == FrameworkType.DJANGO_NINJA:
from fastapi_voyager.introspectors import DjangoNinjaIntrospector
if DjangoNinjaIntrospector:
return DjangoNinjaIntrospector(app)
# If we get here, the app type is not supported
raise TypeError(
f"Unsupported app type: {type(app).__name__}. "
f"Supported types: FastAPI, Django Ninja API, Litestar, or any AppIntrospector implementation. "
f"If you're using a different framework, please implement AppIntrospector for that framework. "
f"See ADAPTER_EXAMPLE.md for instructions."
)
================================================
FILE: src/fastapi_voyager/introspectors/django_ninja.py
================================================
"""
Django Ninja implementation of the AppIntrospector interface.
This module provides the adapter that allows fastapi-voyager to work with Django Ninja applications.
"""
from collections.abc import Iterator
from fastapi_voyager.introspectors.base import AppIntrospector, RouteInfo
class DjangoNinjaIntrospector(AppIntrospector):
"""
Django Ninja-specific implementation of AppIntrospector.
This class extracts route information from Django Ninja's internal structure
and converts it to the framework-agnostic RouteInfo format.
"""
def __init__(self, ninja_api, swagger_url: str | None = None):
"""
Initialize the Django Ninja introspector.
Args:
ninja_api: The Django Ninja API instance
swagger_url: Optional custom URL to Swagger documentation
"""
self.api = ninja_api
self.swagger_url = swagger_url or "/api/docs"
def get_routes(self) -> Iterator[RouteInfo]:
"""
Iterate over all API routes in the Django Ninja application.
Yields:
RouteInfo: Standardized route information for each API route
"""
# Access the internal router structure
if not hasattr(self.api, "default_router"):
return
router = self.api.default_router
# Iterate through all path operations registered in the router
if not hasattr(router, "path_operations"):
return
for path, path_view in router.path_operations.items():
# path_view is a PathView object with a list of operations
if not hasattr(path_view, "operations"):
continue
for operation in path_view.operations:
try:
yield RouteInfo(
id=self._get_route_id(operation),
name=operation.view_func.__name__,
module=operation.view_func.__module__,
operation_id=operation.operation_id or operation.view_func.__name__,
tags=operation.tags or [],
endpoint=operation.view_func,
response_model=self._get_response_model(operation),
extra={
"methods": operation.methods, # This is a list
"path": path,
},
)
except (AttributeError, TypeError):
# Skip routes that don't have the expected structure
continue
def get_swagger_url(self) -> str | None:
"""
Get the URL to the Swagger UI documentation.
Returns:
The URL path to Swagger UI
"""
return self.swagger_url
def _get_route_id(self, operation) -> str:
"""
Generate a unique identifier for the route.
Uses the full class path of the view function.
Args:
operation: The Django Ninja operation object
Returns:
A unique identifier string
"""
# Import here to avoid circular dependency
from fastapi_voyager.type_helper import full_class_name
return full_class_name(operation.view_func)
def _get_response_model(self, operation) -> type:
"""
Extract the response model from the operation.
Django Ninja infers response model from function's return type annotation.
Args:
operation: The Django Ninja operation object
Returns:
The response model class, or type(None) if not found
"""
# Django Ninja uses type hints for response models
# The response_models field is always NOT_SET_TYPE, so we only check __annotations__
if hasattr(operation.view_func, "__annotations__") and "return" in operation.view_func.__annotations__:
return operation.view_func.__annotations__["return"]
# No response model found
return type(None) # type: ignore
================================================
FILE: src/fastapi_voyager/introspectors/fastapi.py
================================================
"""
FastAPI implementation of the AppIntrospector interface.
This module provides the adapter that allows fastapi-voyager to work with FastAPI applications.
"""
from collections.abc import Iterator
from typing import TYPE_CHECKING, Any
from fastapi_voyager.introspectors.base import AppIntrospector, RouteInfo
if TYPE_CHECKING:
from fastapi import FastAPI
class FastAPIIntrospector(AppIntrospector):
"""
FastAPI-specific implementation of AppIntrospector.
This class extracts route information from FastAPI's internal route structure
and converts it to the framework-agnostic RouteInfo format.
"""
def __init__(self, app: "FastAPI", swagger_url: str | None = None):
"""
Initialize the FastAPI introspector.
Args:
app: The FastAPI application instance
swagger_url: Optional custom URL to Swagger documentation
"""
# Lazy import to avoid import errors when FastAPI is not installed
from fastapi import FastAPI
if not isinstance(app, FastAPI):
raise TypeError(f"Expected FastAPI instance, got {type(app)}")
self.app = app
self.swagger_url = swagger_url or "/docs"
def get_routes(self) -> Iterator[RouteInfo]:
"""
Iterate over all API routes in the FastAPI application.
Yields:
RouteInfo: Standardized route information for each API route
"""
# Lazy import routing to avoid import errors when FastAPI is not installed
from fastapi import routing
for route in self.app.routes:
# Only process APIRoute instances (not static files, etc.)
if isinstance(route, routing.APIRoute):
# Extract tags from the route
tags = getattr(route, 'tags', None) or []
yield RouteInfo(
id=self._get_route_id(route),
name=route.endpoint.__name__,
module=route.endpoint.__module__,
operation_id=route.operation_id,
tags=tags,
endpoint=route.endpoint,
response_model=route.response_model,
extra={
'unique_id': route.unique_id,
'methods': route.methods,
'path': route.path,
}
)
def get_swagger_url(self) -> str | None:
"""
Get the URL to the Swagger UI documentation.
Returns:
The URL path to Swagger UI
"""
return self.swagger_url
def _get_route_id(self, route: Any) -> str:
"""
Generate a unique identifier for the route.
Uses the full class path of the endpoint function.
Args:
route: The FastAPI route object
Returns:
A unique identifier string
"""
# Import here to avoid circular dependency
from fastapi_voyager.type_helper import full_class_name
return full_class_name(route.endpoint)
================================================
FILE: src/fastapi_voyager/introspectors/litestar.py
================================================
"""
Litestar implementation of the AppIntrospector interface.
This module provides the adapter that allows fastapi-voyager to work with Litestar applications.
"""
from collections.abc import Iterator
from fastapi_voyager.introspectors.base import AppIntrospector, RouteInfo
class LitestarIntrospector(AppIntrospector):
"""
Litestar-specific implementation of AppIntrospector.
This class extracts route information from Litestar's internal structure
and converts it to the framework-agnostic RouteInfo format.
"""
def __init__(self, app, swagger_url: str | None = None):
"""
Initialize the Litestar introspector.
Args:
app: The Litestar application instance
swagger_url: Optional custom URL to Swagger/OpenAPI documentation
"""
self.app = app
self.swagger_url = swagger_url or "/schema/swagger"
def get_routes(self) -> Iterator[RouteInfo]:
"""
Iterate over all routes in the Litestar application.
Yields:
RouteInfo: Standardized route information for each route
"""
for route in self.app.routes:
try:
# Skip routes without path or methods
if not hasattr(route, "path") or not hasattr(route, "methods"):
continue
# Skip Litestar's auto-generated schema routes
if hasattr(route, "path") and route.path.startswith("/schema"):
continue
# Get the handler function from route_handlers
handler = None
handler_obj = None
if hasattr(route, "route_handlers") and route.route_handlers:
# Find the GET handler (or any non-OPTIONS handler)
for route_handler in route.route_handlers:
if hasattr(route_handler, "fn") and hasattr(route_handler.fn, "__name__"):
# Store the route handler object for tags
if hasattr(route_handler, "http_methods") and "GET" in route_handler.http_methods:
handler_obj = route_handler
handler = route_handler.fn
if handler_obj:
break
if not handler:
continue
# Skip handlers with names starting with _ (internal/private)
if hasattr(handler, "__name__") and handler.__name__.startswith("_"):
continue
# Extract tags from the route handler object
tags = []
if handler_obj and hasattr(handler_obj, "tags") and handler_obj.tags:
tags = list(handler_obj.tags)
# Get return type from handler's annotations
return_model = type(None)
if hasattr(handler, "__annotations__") and "return" in handler.__annotations__:
return_model = handler.__annotations__["return"]
yield RouteInfo(
id=self._get_route_id(handler),
name=handler.__name__,
module=handler.__module__,
operation_id=self._get_operation_id(route, handler),
tags=tags,
endpoint=handler,
response_model=return_model,
extra={
"methods": list(route.methods) if hasattr(route, "methods") else [],
"path": route.path,
},
)
except (AttributeError, TypeError):
# Skip routes that don't have the expected structure
continue
def get_swagger_url(self) -> str | None:
"""
Get the URL to the Swagger/OpenAPI documentation.
Returns:
The URL path to Swagger UI
"""
return self.swagger_url
def _get_route_id(self, handler) -> str:
"""
Generate a unique identifier for the route.
Uses the full module path of the handler function.
Args:
handler: The route handler function
Returns:
A unique identifier string
"""
# Import here to avoid circular dependency
from fastapi_voyager.type_helper import full_class_name
return full_class_name(handler)
def _get_operation_id(self, route, handler) -> str:
"""
Extract or generate the operation ID for the route.
Args:
route: The Litestar route object
handler: The handler function
Returns:
An operation ID string
"""
# Litestar might not have operation_id, so we generate one
if hasattr(route, "operation_id"):
return route.operation_id
# Fallback to using the handler function name
if hasattr(handler, "__name__"):
return handler.__name__
# Fallback to using the path
if hasattr(route, "path"):
return route.path
return ""
def _get_response_model(self, route) -> type:
"""
Extract the response model from the route.
Args:
route: The Litestar route object
Returns:
The response model class
"""
# Try to get response model from route
if hasattr(route, "responses"):
responses = route.responses
if responses and "200" in responses:
response_200 = responses["200"]
if hasattr(response_200, "model"):
return response_200.model
# Fallback: check if handler has return annotation
handler = route.handler if hasattr(route, "handler") else None
if handler and hasattr(handler, "__annotations__") and "return" in handler.__annotations__:
return handler.__annotations__["return"]
# Return None if no response model found
return type(None) # type: ignore
================================================
FILE: src/fastapi_voyager/module.py
================================================
from collections.abc import Callable
from typing import Any, TypeVar
from fastapi_voyager.type import ModuleNode, ModuleRoute, Route, SchemaNode
N = TypeVar('N') # Node type: ModuleNode or ModuleRoute
I = TypeVar('I') # Item type: SchemaNode or Route
def _build_module_tree(
items: list[I],
*,
get_module_path: Callable[[I], str | None],
NodeClass: type[N],
item_list_attr: str,
) -> list[N]:
"""
Generic builder that groups items by dotted module path into a tree of NodeClass.
NodeClass must accept kwargs: name, fullname, modules(list), and an item list via
item_list_attr (e.g., 'schema_nodes' or 'routes').
"""
# Map from top-level module name to node
top_modules: dict[str, N] = {}
# Items without module path
root_level_items: list[I] = []
def make_node(name: str, fullname: str) -> N:
kwargs: dict[str, Any] = {
'name': name,
'fullname': fullname,
'modules': [],
item_list_attr: [],
}
return NodeClass(**kwargs) # type: ignore[arg-type]
def get_or_create(child_name: str, parent: N) -> N:
for m in parent.modules:
if m.name == child_name:
return m
parent_full = parent.fullname
fullname = child_name if not parent_full or parent_full == "__root__" else f"{parent_full}.{child_name}"
new_node = make_node(child_name, fullname)
parent.modules.append(new_node)
return new_node
# Build the tree
for it in items:
module_path = get_module_path(it) or ""
if not module_path:
root_level_items.append(it)
continue
parts = module_path.split('.')
top_name = parts[0]
if top_name not in top_modules:
top_modules[top_name] = make_node(top_name, top_name)
current = top_modules[top_name]
for part in parts[1:]:
current = get_or_create(part, current)
getattr(current, item_list_attr).append(it)
result: list[N] = list(top_modules.values())
if root_level_items:
result.append(make_node("__root__", "__root__"))
setattr(result[-1], item_list_attr, root_level_items)
# Collapse linear chains: no items on node and exactly one child module
def collapse(node: N) -> None:
while len(node.modules) == 1 and len(getattr(node, item_list_attr)) == 0:
child = node.modules[0]
node.name = f"{node.name}.{child.name}"
node.fullname = child.fullname
setattr(node, item_list_attr, getattr(child, item_list_attr))
node.modules = child.modules
for m in node.modules:
collapse(m)
for top in result:
collapse(top)
return result
def build_module_schema_tree(schema_nodes: list[SchemaNode]) -> list[ModuleNode]:
"""Build a module tree for schema nodes, grouped by their module path."""
return _build_module_tree(
schema_nodes,
get_module_path=lambda sn: sn.module,
NodeClass=ModuleNode,
item_list_attr='schema_nodes',
)
def build_module_route_tree(routes: list[Route]) -> list[ModuleRoute]:
"""Build a module tree for routes, grouped by their module path."""
return _build_module_tree(
routes,
get_module_path=lambda r: r.module,
NodeClass=ModuleRoute,
item_list_attr='routes',
)
================================================
FILE: src/fastapi_voyager/pydantic_resolve_util.py
================================================
import inspect
import pydantic_resolve.constant as const
from pydantic import BaseModel
from pydantic.fields import FieldInfo
from pydantic_resolve.utils.collector import ICollector, SendToInfo
from pydantic_resolve.utils.er_diagram import LoaderInfo
from pydantic_resolve.utils.expose import ExposeInfo
def analysis_pydantic_resolve_fields(schema: type[BaseModel], field: str):
"""
get information for pydantic resolve specific info
in future, this function will be provide by pydantic-resolve package
is_resolve: bool = False
- check existence of def resolve_{field} method
- check existence of LoaderInfo in field.metadata
is_post: bool = False
- check existence of def post_{field} method
expose_as_info: str | None = None
- check ExposeInfo in field.metadata
- check field in schema.__pydantic_resolve_expose__ (const.EXPOSE_TO_DESCENDANT)
send_to_info: list[str] | None = None
- check SendToInfo in field.metadata
- check field in schema.__pydantic_resolve_collect__ (const.COLLECTOR_CONFIGURATION)
collect_info: list[str] | None = None
- 1. check existence of def post_{field} method
- 2. get the signature of this method
- 3. extrace the collector names from the parameters with ICollector metadata
return dict in form of
{
"is_resolve": True,
...
}
"""
has_meta = False
field_info: FieldInfo = schema.model_fields.get(field)
is_resolve = hasattr(schema, f'{const.RESOLVE_PREFIX}{field}')
is_post = hasattr(schema, f'{const.POST_PREFIX}{field}')
expose_as_info = None
send_to_info = None
post_collector = []
send_to_info_list = []
if field_info:
# Check metadata
for meta in field_info.metadata:
if isinstance(meta, LoaderInfo):
is_resolve = True
if isinstance(meta, ExposeInfo):
expose_as_info = meta.alias
if isinstance(meta, SendToInfo):
if isinstance(meta.collector_name, str):
send_to_info_list.append(meta.collector_name)
else:
send_to_info_list.extend(list(meta.collector_name))
# Check class attributes
expose_dict = getattr(schema, const.EXPOSE_TO_DESCENDANT, {})
if field in expose_dict:
expose_as_info = expose_dict[field]
collect_dict = getattr(schema, const.COLLECTOR_CONFIGURATION, {})
for keys, collectors in collect_dict.items():
target_keys = [keys] if isinstance(keys, str) else list(keys)
if field in target_keys:
if isinstance(collectors, str):
send_to_info_list.append(collectors)
else:
send_to_info_list.extend(list(collectors))
if send_to_info_list:
send_to_info = list(set(send_to_info_list)) # unique collectors
if is_post:
post_method = getattr(schema, f'{const.POST_PREFIX}{field}')
for _, param in inspect.signature(post_method).parameters.items():
if isinstance(param.default, ICollector):
post_collector.append(param.default.alias)
has_meta = any([is_resolve, is_post, expose_as_info, send_to_info])
return {
"has_pydantic_resolve_meta": has_meta,
"is_resolve": is_resolve,
"is_post": is_post,
"expose_as_info": expose_as_info,
"send_to_info": send_to_info,
"collect_info": None if len(post_collector) == 0 else post_collector
}
def extract_query_mutation_methods(entity: type) -> tuple[list[dict], list[dict]]:
"""
Extract all @query and @mutation decorated methods from an Entity.
Returns:
A tuple of (queries, mutations), each is a list of dicts:
- name: GraphQL name (from decorator or method name)
- return_type: Return type annotation as string
Each list is sorted alphabetically by name.
"""
# Lazy import to avoid circular dependency
from fastapi_voyager.type_helper import get_type_name
queries = []
mutations = []
for name, method in entity.__dict__.items():
# Handle classmethod - access underlying function
actual_method = method
if isinstance(method, classmethod):
actual_method = method.__func__
is_query = hasattr(actual_method, '_pydantic_resolve_query')
is_mutation = hasattr(actual_method, '_pydantic_resolve_mutation')
if is_query or is_mutation:
# Get GraphQL name
if is_query:
gql_name = getattr(actual_method, '_pydantic_resolve_query_name', None)
else:
gql_name = getattr(actual_method, '_pydantic_resolve_mutation_name', None)
# Use method name if no GraphQL name specified
display_name = gql_name or name
# Get return type from signature
return_type = 'Unknown'
try:
sig = inspect.signature(actual_method)
if sig.return_annotation != inspect.Signature.empty:
return_type = get_type_name(sig.return_annotation)
except Exception:
pass
method_info = {
'name': display_name,
'return_type': return_type
}
if is_query:
queries.append(method_info)
else:
mutations.append(method_info)
# Sort each list alphabetically by name
queries.sort(key=lambda m: m['name'])
mutations.sort(key=lambda m: m['name'])
return queries, mutations
================================================
FILE: src/fastapi_voyager/render.py
================================================
"""
Render FastAPI application structure to DOT format using Jinja2 templates.
"""
from logging import getLogger
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
from fastapi_voyager.module import build_module_route_tree, build_module_schema_tree
from fastapi_voyager.render_style import RenderConfig
from fastapi_voyager.type import (
FieldInfo,
FieldType,
Link,
MethodInfo,
ModuleNode,
ModuleRoute,
PK,
Route,
SchemaNode,
Tag,
)
from typing import Literal
logger = getLogger(__name__)
# Get the template directory relative to this file
TEMPLATE_DIR = Path(__file__).parent / "templates"
class TemplateRenderer:
"""
Jinja2-based template renderer for DOT and HTML templates.
"""
def __init__(self, template_dir: Path = TEMPLATE_DIR):
# Initialize Jinja2 environment
self.env = Environment(
loader=FileSystemLoader(template_dir),
autoescape=select_autoescape(),
trim_blocks=True,
lstrip_blocks=True,
)
def render_template(self, template_name: str, **context) -> str:
"""Render a template with the given context."""
template = self.env.get_template(template_name)
return template.render(**context)
class Renderer:
"""
Render FastAPI application structure to DOT format.
This class handles the conversion of tags, routes, schemas, and links
into Graphviz DOT format, with support for custom styling and filtering.
"""
def __init__(
self,
*,
show_fields: FieldType = 'single',
module_color: dict[str, str] | None = None,
schema: str | None = None,
show_module: bool = True,
show_pydantic_resolve_meta: bool = False,
config: RenderConfig | None = None,
theme_color: str | None = None,
show_methods: bool = True,
) -> None:
self.show_fields = show_fields if show_fields in ('single', 'object', 'all') else 'single'
self.module_color = module_color or {}
self.schema = schema
self.show_module = show_module
self.show_pydantic_resolve_meta = show_pydantic_resolve_meta
self.show_methods = show_methods
# Use provided config or create default
self.config = config or RenderConfig()
self.colors = self.config.colors
self.style = self.config.style
# Framework theme color (overrides default primary color)
self.theme_color = theme_color or self.colors.primary
# Initialize template renderer
self.template_renderer = TemplateRenderer()
logger.info(f'show_module: {self.show_module}')
logger.info(f'module_color: {self.module_color}')
def _render_pydantic_meta_parts(self, field: FieldInfo) -> list[str]:
"""Render pydantic-resolve metadata as HTML parts."""
if not self.show_pydantic_resolve_meta:
return []
parts = []
if field.is_resolve:
parts.append(
self.template_renderer.render_template(
'html/colored_text.j2',
text='● resolve',
color=self.colors.resolve
)
)
if field.is_post:
parts.append(
self.template_renderer.render_template(
'html/colored_text.j2',
text='● post',
color=self.colors.post
)
)
if field.expose_as_info:
parts.append(
self.template_renderer.render_template(
'html/colored_text.j2',
text=f'● expose as: {field.expose_as_info}',
color=self.colors.expose_as
)
)
if field.send_to_info:
to_collectors = ', '.join(field.send_to_info)
parts.append(
self.template_renderer.render_template(
'html/colored_text.j2',
text=f'● send to: {to_collectors}',
color=self.colors.send_to
)
)
if field.collect_info:
defined_collectors = ', '.join(field.collect_info)
parts.append(
self.template_renderer.render_template(
'html/colored_text.j2',
text=f'● collectors: {defined_collectors}',
color=self.colors.collector
)
)
return parts
def _render_schema_field(
self,
field: FieldInfo,
max_type_length: int | None = None
) -> str:
"""Render a single schema field."""
max_len = max_type_length or self.config.max_type_length
# Truncate type name if too long
type_name = field.type_name
if len(type_name) > max_len:
type_name = type_name[:max_len] + self.config.type_suffix
# Format field display
field_text = f'{field.name}: {type_name}'
# Render pydantic metadata
meta_parts = self._render_pydantic_meta_parts(field)
meta_html = self.template_renderer.render_template(
'html/pydantic_meta.j2',
meta_parts=meta_parts
)
# Render field text (with strikethrough if excluded)
text_html = self.template_renderer.render_template(
'html/colored_text.j2',
text=field_text,
color='#000', # Default color
strikethrough=field.is_exclude
)
# Combine field text and metadata
content = f' {text_html} {meta_html}'
# Render the table row
return self.template_renderer.render_template(
'html/schema_field_row.j2',
port=field.name,
align='left',
content=content
)
def _render_schema_method(self, method: MethodInfo, type: Literal['query', 'mutation']) -> str:
"""Render a single method row for @query or @mutation."""
# Format: [Q] name: type or [M] name: type
prefix = '[Q]' if type == 'query' else '[M]'
color = self.colors.query if type == 'query' else self.colors.mutation
# Truncate return type if too long
return_type = method.return_type
if len(return_type) > self.config.max_type_length:
return_type = return_type[:self.config.max_type_length] + self.config.type_suffix
method_text = f'{prefix} {method.name}: {return_type}'
# Render method text with color
text_html = self.template_renderer.render_template(
'html/colored_text.j2',
text=method_text,
color=color
)
content = f' {text_html} '
return self.template_renderer.render_template(
'html/schema_field_row.j2',
port=None, # No port needed for methods
align='left',
content=content
)
def _get_filtered_fields(self, node: SchemaNode) -> list[FieldInfo]:
"""Get fields filtered by show_fields and show_pydantic_resolve_meta settings."""
# Filter fields based on pydantic-resolve meta setting
if self.show_pydantic_resolve_meta:
fields = [n for n in node.fields if n.has_pydantic_resolve_meta or not n.from_base]
else:
fields = [n for n in node.fields if not n.from_base]
# Further filter by show_fields setting
if self.show_fields == 'all':
return fields
elif self.show_fields == 'object':
if self.show_pydantic_resolve_meta:
# Show object fields or fields with pydantic-resolve metadata
return [f for f in fields if f.is_object or f.has_pydantic_resolve_meta]
else:
# Show only object fields
return [f for f in fields if f.is_object]
else: # 'single'
return []
def render_schema_label(self, node: SchemaNode, color: str | None = None) -> str:
"""
Render a schema node's label as an HTML table.
TODO: Improve logic with show_pydantic_resolve_meta
"""
fields = self._get_filtered_fields(node)
# Render field rows
rows = []
has_base_fields = any(f.from_base for f in node.fields)
# Add inherited fields notice if needed
if self.show_fields == 'all' and has_base_fields:
notice = self.template_renderer.render_template(
'html/colored_text.j2',
text=' Inherited Fields ... ',
color=self.colors.text_gray
)
rows.append(
self.template_renderer.render_template(
'html/schema_field_row.j2',
content=notice,
align='left'
)
)
# Render each field
for field in fields:
rows.append(self._render_schema_field(field))
# Add methods if present (in all show_fields modes)
if self.show_methods and (node.queries or node.mutations):
# Render queries
for method in node.queries:
rows.append(self._render_schema_method(method, type='query'))
# Render mutations
for method in node.mutations:
rows.append(self._render_schema_method(method, type='mutation'))
# Determine header color
default_color = self.theme_color if color is None else color
header_color = self.colors.highlight if node.id == self.schema else default_color
# Render header
header = self.template_renderer.render_template(
'html/schema_header.j2',
text=node.name,
bg_color=header_color,
port=PK,
is_entity=node.is_entity
)
# Render complete table
return self.template_renderer.render_template(
'html/schema_table.j2',
header=header,
rows=''.join(rows)
)
def _handle_schema_anchor(self, source: str) -> str:
"""Handle schema anchor for DOT links."""
if '::' in source:
a, b = source.split('::', 1)
return f'"{a}":{b}'
return f'"{source}"'
def _format_link_attributes(self, attrs: dict) -> str:
"""Format link attributes for DOT format."""
return ', '.join(f'{k}="{v}"' for k, v in attrs.items())
def render_link(self, link: Link) -> str:
"""Render a link in DOT format."""
source = self._handle_schema_anchor(link.source)
target = self._handle_schema_anchor(link.target)
# Build link attributes
# If link.style is explicitly set (e.g., 'solid, dashed' for ER diagrams), use it
# Otherwise, get default style from configuration based on link.type
if link.style is not None:
attrs = {'style': link.style}
if link.label:
attrs['label'] = link.label
# attrs['minlen'] = 3
else:
attrs = self.style.get_link_attributes(link.type)
if link.label:
attrs['label'] = link.label
return self.template_renderer.render_template(
'dot/link.j2',
source=source,
target=target,
attributes=self._format_link_attributes(attrs)
)
def render_schema_node(self, node: SchemaNode, color: str | None = None) -> str:
"""Render a schema node in DOT format."""
label = self.render_schema_label(node, color)
return self.template_renderer.render_template(
'dot/schema_node.j2',
id=node.id,
label=label,
margin=self.style.node_margin
)
def render_tag_node(self, tag: Tag) -> str:
"""Render a tag node in DOT format."""
return self.template_renderer.render_template(
'dot/tag_node.j2',
id=tag.id,
name=tag.name,
margin=self.style.node_margin
)
def render_route_node(self, route: Route) -> str:
"""Render a route node in DOT format."""
# Truncate response schema if too long
response_schema = route.response_schema
if len(response_schema) > self.config.max_type_length:
response_schema = response_schema[:self.config.max_type_length] + self.config.type_suffix
return self.template_renderer.render_template(
'dot/route_node.j2',
id=route.id,
name=route.name,
response_schema=response_schema,
margin=self.style.node_margin
)
def _render_module_schema(
self,
mod: ModuleNode,
module_color_flag: set[str],
inherit_color: str | None = None,
show_cluster: bool = True
) -> str:
"""Render a module schema tree."""
color = inherit_color
cluster_color: str | None = None
# Check if this module has a custom color
for k in module_color_flag:
if mod.fullname.startswith(k):
module_color_flag.remove(k)
color = self.module_color[k]
cluster_color = color if color != inherit_color else None
break
# Render inner schema nodes
inner_nodes = [
self.render_schema_node(node, color)
for node in mod.schema_nodes
]
inner_nodes_str = '\n'.join(inner_nodes)
# Recursively render child modules
child_str = '\n'.join(
self._render_module_schema(
m,
module_color_flag=module_color_flag,
inherit_color=color,
show_cluster=show_cluster
)
for m in mod.modules
)
if show_cluster:
# Render as a cluster
cluster_id = f'module_{mod.fullname.replace(".", "_")}'
pen_style = ''
if cluster_color:
pen_style = f'pencolor = "{cluster_color}"'
pen_style += '\n' + 'penwidth = 3' if color else ''
else:
pen_style = 'pencolor="#ccc"'
return self.template_renderer.render_template(
'dot/cluster.j2',
cluster_id=cluster_id,
label=mod.name,
tooltip=mod.fullname,
border_color=self.colors.border,
pen_color=cluster_color,
pen_width=3 if color and not cluster_color else None,
content=f'{inner_nodes_str}\n{child_str}'
)
else:
# Render without cluster
return f'{inner_nodes_str}\n{child_str}'
def render_module_schema_content(self, nodes: list[SchemaNode]) -> str:
"""Render all module schemas."""
module_schemas = build_module_schema_tree(nodes)
module_color_flag = set(self.module_color.keys())
return '\n'.join(
self._render_module_schema(
m,
module_color_flag=module_color_flag,
show_cluster=self.show_module
)
for m in module_schemas
)
def _render_module_route(self, mod: ModuleRoute, show_cluster: bool = True) -> str:
"""Render a module route tree."""
# Render inner route nodes
inner_nodes = [self.render_route_node(r) for r in mod.routes]
inner_nodes_str = '\n'.join(inner_nodes)
# Recursively render child modules
child_str = '\n'.join(
self._render_module_route(m, show_cluster=show_cluster)
for m in mod.modules
)
if show_cluster:
cluster_id = f'route_module_{mod.fullname.replace(".", "_")}'
return self.template_renderer.render_template(
'dot/cluster.j2',
cluster_id=cluster_id,
label=mod.name,
tooltip=mod.fullname,
border_color=self.colors.border,
pen_color=None,
pen_width=None,
content=f'{inner_nodes_str}\n{child_str}'
)
else:
return f'{inner_nodes_str}\n{child_str}'
def render_module_route_content(self, routes: list[Route]) -> str:
"""Render all module routes."""
module_routes = build_module_route_tree(routes)
return '\n'.join(
self._render_module_route(m, show_cluster=self.show_module)
for m in module_routes
)
def _render_cluster_container(
self,
name: str,
label: str,
content: str,
fontsize: str | None = None
) -> str:
"""Render a cluster container (for tags, routes, schemas)."""
return self.template_renderer.render_template(
'dot/cluster_container.j2',
name=name,
label=label,
content=content,
border_color=self.colors.border,
margin=self.style.cluster_margin,
fontsize=fontsize or self.style.cluster_fontsize
)
def render_dot(
self,
tags: list[Tag],
routes: list[Route],
nodes: list[SchemaNode],
links: list[Link],
spline_line: bool = False
) -> str:
"""
Render the complete DOT graph.
Args:
tags: List of tags
routes: List of routes
nodes: List of schema nodes
links: List of links
spline_line: Whether to use spline lines
Returns:
Complete DOT graph as a string
"""
# Render tag nodes
tag_str = '\n'.join(self.render_tag_node(t) for t in tags)
# Render tags cluster
tags_cluster = self._render_cluster_container(
name='tags',
label='Tags',
content=tag_str
)
# Render routes cluster
module_routes_str = self.render_module_route_content(routes)
routes_cluster = self._render_cluster_container(
name='router',
label='Routes',
content=module_routes_str
)
# Render schemas cluster
module_schemas_str = self.render_module_schema_content(nodes)
schemas_cluster = self._render_cluster_container(
name='schema',
label='Schema',
content=module_schemas_str
)
# Render links
link_str = '\n'.join(self.render_link(link) for link in links)
# Render complete digraph
return self.template_renderer.render_template(
'dot/digraph.j2',
pad=self.style.pad,
nodesep=self.style.nodesep,
spline='line' if spline_line else '',
font=self.style.font,
node_fontsize=self.style.node_fontsize,
tags_cluster=tags_cluster,
routes_cluster=routes_cluster,
schemas_cluster=schemas_cluster,
links=link_str
)
================================================
FILE: src/fastapi_voyager/render_style.py
================================================
"""
Style constants and configuration for rendering DOT graphs and HTML tables.
"""
from dataclasses import dataclass, field
from fastapi_voyager.introspectors.detector import FrameworkType
@dataclass
class ColorScheme:
"""Color scheme for graph visualization."""
# Framework-specific theme colors (single source of truth)
FRAMEWORK_COLORS: dict[FrameworkType, str] = field(default_factory=lambda: {
FrameworkType.FASTAPI: '#009485',
FrameworkType.DJANGO_NINJA: '#4cae4f',
FrameworkType.LITESTAR: '#edb641',
})
# Node colors
primary: str = '#009485'
highlight: str = 'tomato'
# Pydantic-resolve metadata colors
resolve: str = '#47a80f'
post: str = '#427fa4'
expose_as: str = '#895cb9'
send_to: str = '#ca6d6d'
collector: str = '#777'
# GraphQL method colors
query: str = '#47a80f' # Green for @query methods
mutation: str = '#ca6d6d' # Red/coral for @mutation methods
# Link colors
inherit: str = 'purple'
subset: str = 'orange'
# Border colors
border: str = '#666'
cluster_border: str = '#ccc'
# Text colors
text_gray: str = '#999'
def get_framework_color(self, framework_type: FrameworkType) -> str:
"""Get theme color for a specific framework type."""
return self.FRAMEWORK_COLORS.get(framework_type, self.primary)
@dataclass
class GraphvizStyle:
"""Graphviz DOT style configuration."""
# Font settings
font: str = 'Helvetica,Arial,sans-serif'
node_fontsize: str = '16'
cluster_fontsize: str = '20'
# Layout settings
nodesep: str = '0.8'
pad: str = '0.5'
node_margin: str = '0.5,0.1'
cluster_margin: str = '18'
# Link styles configuration
LINK_STYLES: dict[str, dict] = field(default_factory=lambda: {
'tag_route': {
'style': 'solid',
'minlen': 3,
},
'route_to_schema': {
'style': 'solid',
'dir': 'back',
'arrowtail': 'odot',
'minlen': 3,
},
'schema': {
'style': 'solid',
'label': '',
'dir': 'back',
'minlen': 3,
'arrowtail': 'odot',
},
'parent': {
'style': 'solid,dashed',
'dir': 'back',
'minlen': 3,
'taillabel': '< inherit >',
'color': 'purple',
'tailport': 'n',
},
'subset': {
'style': 'solid,dashed',
'dir': 'back',
'minlen': 3,
'taillabel': '< subset >',
'color': 'orange',
'tailport': 'n',
},
'tag_to_schema': {
'style': 'solid',
'minlen': 3,
},
})
def get_link_attributes(self, link_type: str) -> dict:
"""Get link style attributes for a given link type."""
return self.LINK_STYLES.get(link_type, {})
@dataclass
class RenderConfig:
"""Complete rendering configuration."""
colors: ColorScheme = field(default_factory=ColorScheme)
style: GraphvizStyle = field(default_factory=GraphvizStyle)
# Field display settings
max_type_length: int = 25
type_suffix: str = '..'
================================================
FILE: src/fastapi_voyager/server.py
================================================
"""
FastAPI-voyager server module with framework adapter support.
This module provides the main `create_voyager` function that automatically
detects the framework type and returns an appropriately configured voyager UI.
"""
from typing import Any, Literal
from pydantic_resolve import ErDiagram
from fastapi_voyager.adapters import DjangoNinjaAdapter, FastAPIAdapter, LitestarAdapter
from fastapi_voyager.introspectors import FrameworkType, detect_framework
INITIAL_PAGE_POLICY = Literal["first", "full", "empty"]
def _get_adapter(
target_app: Any,
module_color: dict[str, str] | None = None,
gzip_minimum_size: int | None = 500,
module_prefix: str | None = None,
swagger_url: str | None = None,
online_repo_url: str | None = None,
initial_page_policy: INITIAL_PAGE_POLICY = "first",
ga_id: str | None = None,
er_diagram: ErDiagram | None = None,
enable_pydantic_resolve_meta: bool = False,
server_mode: bool = False,
) -> Any:
"""
Get the appropriate adapter for the given target app.
Automatically detects the framework type and returns the matching adapter.
Args:
target_app: The web application instance to introspect
module_color: Optional color mapping for modules
gzip_minimum_size: Minimum size for gzip compression
module_prefix: Optional module prefix for filtering
swagger_url: Optional custom URL to Swagger documentation
online_repo_url: Optional online repository URL for source links
initial_page_policy: Initial page display policy
ga_id: Optional Google Analytics ID
er_diagram: Optional ER diagram from pydantic-resolve
enable_pydantic_resolve_meta: Enable pydantic-resolve metadata display
Returns:
An adapter instance for the detected framework
Raises:
TypeError: If the app type is not supported
"""
# Use centralized framework detection from introspectors
framework = detect_framework(target_app)
if framework == FrameworkType.FASTAPI:
return FastAPIAdapter(
target_app=target_app,
module_color=module_color,
gzip_minimum_size=gzip_minimum_size,
module_prefix=module_prefix,
swagger_url=swagger_url,
online_repo_url=online_repo_url,
initial_page_policy=initial_page_policy,
ga_id=ga_id,
er_diagram=er_diagram,
enable_pydantic_resolve_meta=enable_pydantic_resolve_meta,
server_mode=server_mode,
)
elif framework == FrameworkType.LITESTAR:
return LitestarAdapter(
target_app=target_app,
module_color=module_color,
gzip_minimum_size=gzip_minimum_size,
module_prefix=module_prefix,
swagger_url=swagger_url,
online_repo_url=online_repo_url,
initial_page_policy=initial_page_policy,
ga_id=ga_id,
er_diagram=er_diagram,
enable_pydantic_resolve_meta=enable_pydantic_resolve_meta,
server_mode=server_mode,
)
elif framework == FrameworkType.DJANGO_NINJA:
return DjangoNinjaAdapter(
target_app=target_app,
module_color=module_color,
gzip_minimum_size=gzip_minimum_size, # Note: ignored for Django
module_prefix=module_prefix,
swagger_url=swagger_url,
online_repo_url=online_repo_url,
initial_page_policy=initial_page_policy,
ga_id=ga_id,
er_diagram=er_diagram,
enable_pydantic_resolve_meta=enable_pydantic_resolve_meta,
server_mode=server_mode,
)
# If we get here, the app type is not supported
raise TypeError(
f"Unsupported app type: {type(target_app).__name__}. "
f"Supported types: FastAPI, Django Ninja API, Litestar. "
f"If you're using a different framework, please implement a VoyagerAdapter for that framework. "
f"See fastapi_voyager/adapters/ for examples."
)
def create_voyager(
target_app: Any,
module_color: dict[str, str] | None = None,
gzip_minimum_size: int | None = 500,
module_prefix: str | None = None,
swagger_url: str | None = None,
online_repo_url: str | None = None,
initial_page_policy: INITIAL_PAGE_POLICY = "first",
ga_id: str | None = None,
er_diagram: ErDiagram | None = None,
enable_pydantic_resolve_meta: bool = False,
server_mode: bool = False,
) -> Any:
"""
Create a voyager UI application for the given target app.
This function automatically detects the framework type (FastAPI, Django Ninja, or Litestar)
and returns an appropriately configured voyager UI application.
For FastAPI: Returns a FastAPI app that can be mounted
For Django Ninja: Returns an ASGI application
For Litestar: Returns a Litestar app
Args:
target_app: The web application to visualize
module_color: Optional color mapping for modules (e.g., {"myapp": "blue"})
gzip_minimum_size: Minimum response size for gzip compression (set to <0 to disable)
module_prefix: Optional module prefix for filtering/organization
swagger_url: Optional custom URL to Swagger/OpenAPI documentation
online_repo_url: Optional base URL for online repository source links
initial_page_policy: Initial page display policy ('first', 'full', or 'empty')
ga_id: Optional Google Analytics tracking ID
er_diagram: Optional ER diagram from pydantic-resolve
enable_pydantic_resolve_meta: Enable display of pydantic-resolve metadata
server_mode: If True, serve voyager UI at root path (for standalone preview mode)
Returns:
A framework-specific application object that provides the voyager UI
Example:
# FastAPI
from fastapi import FastAPI
from fastapi_voyager import create_voyager
app = FastAPI()
voyager_app = create_voyager(app)
app.mount("/voyager", voyager_app)
# Django Ninja
from ninja import NinjaAPI
from fastapi_voyager import create_voyager
api = NinjaAPI()
voyager_asgi_app = create_voyager(api)
# See django_ninja tests for integration examples
# Litestar
from litestar import Litestar
from fastapi_voyager import create_voyager
app = Litestar()
voyager_app = create_voyager(app)
# Mount or integrate as needed
"""
adapter = _get_adapter(
target_app=target_app,
module_color=module_color,
gzip_minimum_size=gzip_minimum_size,
module_prefix=module_prefix,
swagger_url=swagger_url,
online_repo_url=online_repo_url,
initial_page_policy=initial_page_policy,
ga_id=ga_id,
er_diagram=er_diagram,
enable_pydantic_resolve_meta=enable_pydantic_resolve_meta,
server_mode=server_mode,
)
return adapter.create_app()
================================================
FILE: src/fastapi_voyager/templates/dot/cluster.j2
================================================
subgraph cluster_{{ cluster_id }} {
tooltip="{{ tooltip }}"
color = "{{ border_color }}"
style="rounded"
label = " {{ label }}"
labeljust = "l"
{% if pen_color %}pencolor = "{{ pen_color }}"{% endif %}
{% if pen_width %}penwidth = {{ pen_width }}{% endif %}
{{ content }}
}
================================================
FILE: src/fastapi_voyager/templates/dot/cluster_container.j2
================================================
subgraph cluster_{{ name }} {
color = "{{ border_color }}"
margin={{ margin }}
style="dashed"
label = " {{ label }}"
labeljust = "l"
fontsize = {{ fontsize }}
{{ content }}
}
================================================
FILE: src/fastapi_voyager/templates/dot/digraph.j2
================================================
digraph world {
pad="{{ pad }}"
nodesep={{ nodesep }}
{% if spline %}splines={{ spline }}{% endif %}
fontname="{{ font }}"
node [fontname="{{ font }}"]
edge [
fontname="{{ font }}"
color="gray"
]
graph [
rankdir = "LR"
];
node [
fontsize = {{ node_fontsize }}
];
{{ tags_cluster }}
{{ routes_cluster }}
{{ schemas_cluster }}
{{ links }}
}
================================================
FILE: src/fastapi_voyager/templates/dot/er_diagram.j2
================================================
digraph world {
pad="{{ pad }}"
nodesep={{ nodesep }}
{% if spline %}splines={{ spline }}{% endif %}
fontname="{{ font }}"
node [fontname="{{ font }}"]
edge [
fontname="{{ font }}"
color="gray"
]
graph [
rankdir = "LR"
];
node [
fontsize = {{ node_fontsize }}
];
subgraph cluster_schema {
color = "#aaa"
margin=18
style="dashed"
label=" ER Diagram"
labeljust="l"
fontsize="20"
{{ er_cluster }}
}
{{ links }}
}
================================================
FILE: src/fastapi_voyager/templates/dot/link.j2
================================================
{{ source }} -> {{ target }} [{{ attributes }}];
================================================
FILE: src/fastapi_voyager/templates/dot/route_node.j2
================================================
"{{ id }}" [
label = " {{ name }} | {{ response_schema }} "
margin="{{ margin }}"
shape = "record"
];
================================================
FILE: src/fastapi_voyager/templates/dot/schema_node.j2
================================================
"{{ id }}" [
label = {{ label }}
shape = "plain"
margin="{{ margin }}"
];
================================================
FILE: src/fastapi_voyager/templates/dot/tag_node.j2
================================================
"{{ id }}" [
label = " {{ name }} "
shape = "record"
margin="{{ margin }}"
];
================================================
FILE: src/fastapi_voyager/templates/html/colored_text.j2
================================================
{% if strikethrough %}{{ text }}{% else %}{{ text }}{% endif %}
================================================
FILE: src/fastapi_voyager/templates/html/pydantic_meta.j2
================================================
{% if meta_parts %}
>
================================================
FILE: src/fastapi_voyager/type.py
================================================
from dataclasses import field
from typing import Literal
from pydantic.dataclasses import dataclass
@dataclass
class NodeBase:
id: str
name: str
@dataclass
class FieldInfo:
name: str
type_name: str
from_base: bool = False
is_object: bool = False
is_exclude: bool = False
desc: str = ''
# pydantic resolve specific fields
has_pydantic_resolve_meta: bool = False # overall flag
is_resolve: bool = False
is_post: bool = False
expose_as_info: str | None = None
send_to_info: list[str] | None = None
collect_info: list[str] | None = None
@dataclass
class MethodInfo:
"""@query 或 @mutation 方法信息"""
name: str # GraphQL 名称(来自装饰器或方法名)
return_type: str # 返回类型字符串
@dataclass
class Tag(NodeBase):
routes: list['Route'] # route.id
@dataclass
class Route(NodeBase):
module: str
unique_id: str = ''
response_schema: str = ''
is_primitive: bool = True
@dataclass
class ModuleRoute:
name: str
fullname: str
routes: list[Route]
modules: list['ModuleRoute']
@dataclass
class SchemaNode(NodeBase):
module: str
fields: list[FieldInfo] = field(default_factory=list)
is_entity: bool = False # Mark if this is an ER diagram entity
queries: list[MethodInfo] = field(default_factory=list) # @query methods
mutations: list[MethodInfo] = field(default_factory=list) # @mutation methods
@dataclass
class ModuleNode:
name: str
fullname: str
schema_nodes: list[SchemaNode]
modules: list['ModuleNode']
# type:
# - tag_route: tag -> route
# - route_to_schema: route -> response model
# - subset: schema -> schema (subset)
# - parent: schema -> schema (inheritance)
# - schema: schema -> schema (field reference)
# - tag_to_schema: tag -> schema (only happens in module prefix filtering, aka brief mode)
LinkType = Literal['schema', 'parent', 'tag_route', 'subset', 'route_to_schema', 'tag_to_schema']
@dataclass
class Link:
# node + field level links
source: str
target: str
# node level links
source_origin: str
target_origin: str
type: LinkType
label: str | None = None
style: str | None = None
loader_fullname: str | None = None
FieldType = Literal['single', 'object', 'all']
PK = "PK"
@dataclass
class CoreData:
tags: list[Tag]
routes: list[Route]
nodes: list[SchemaNode]
links: list[Link]
show_fields: FieldType
module_color: dict[str, str] | None = None
schema: str | None = None
================================================
FILE: src/fastapi_voyager/type_helper.py
================================================
import inspect
import logging
import os
from types import UnionType
from typing import Annotated, Any, ForwardRef, Generic, Union, get_args, get_origin
import pydantic_resolve.constant as const
from pydantic import BaseModel
from fastapi_voyager.pydantic_resolve_util import analysis_pydantic_resolve_fields
from fastapi_voyager.type import FieldInfo
logger = logging.getLogger(__name__)
# Python <3.12 compatibility: TypeAliasType exists only from 3.12 (PEP 695)
try: # pragma: no cover - import guard
from typing import TypeAliasType # type: ignore
except Exception: # pragma: no cover
class _DummyTypeAliasType: # minimal sentinel so isinstance checks are safe
pass
TypeAliasType = _DummyTypeAliasType # type: ignore
def is_list(annotation):
return getattr(annotation, "__origin__", None) == list
def full_class_name(cls):
return f"{cls.__module__}.{cls.__qualname__}"
def is_base_entity_subclass(schema, entity_class_names: set[str] | None = None) -> bool:
"""
Check if a schema is a pydantic-resolve BaseEntity entity.
Checks if the class's full name is in the entity_class_names set.
Args:
schema: The schema class to check
entity_class_names: Optional set of full class names from er_diagram.entities
Returns:
True if the schema is an entity, False otherwise
"""
if not entity_class_names:
return False
schema_full_name = full_class_name(schema)
return schema_full_name in entity_class_names
def get_core_types(tp):
"""
- get the core type
- always return a tuple of core types
"""
# Helpers
def _unwrap_alias(t):
"""Unwrap PEP 695 type aliases by following __value__ repeatedly."""
while isinstance(t, TypeAliasType) or (
t.__class__.__name__ == 'TypeAliasType' and hasattr(t, '__value__')
):
try:
t = t.__value__
except Exception: # pragma: no cover - defensive
break
return t
def _enqueue(items, q):
for it in items:
if it is not type(None): # skip None in unions
q.append(it)
# Queue-based shelling to reach concrete core types
queue: list[object] = [tp]
result: list[object] = []
while queue:
cur = queue.pop(0)
if cur is type(None):
continue
cur = _unwrap_alias(cur)
# Handle Annotated[T, ...] as a shell
if get_origin(cur) is Annotated:
args = get_args(cur)
if args:
queue.append(args[0])
continue
# Handle Union / Optional / PEP 604 UnionType
orig = get_origin(cur)
if orig in (Union, UnionType):
args = get_args(cur)
# push all non-None members back for further shelling
_enqueue(args, queue)
continue
# Handle list shells
if is_list(cur):
args = getattr(cur, "__args__", ())
if args:
queue.append(args[0])
continue
# If still an alias-like wrapper, unwrap again and re-process
_cur2 = _unwrap_alias(cur)
if _cur2 is not cur:
queue.append(_cur2)
continue
# Otherwise treat as a concrete core type (could be a class, typing.Final, etc.)
result.append(cur)
return tuple(result)
def get_type_name(anno):
def name_of(tp):
origin = get_origin(tp)
args = get_args(tp)
# Annotated[T, ...] -> T
if origin is Annotated:
return name_of(args[0]) if args else 'Annotated'
# Union / Optional
if origin is Union:
non_none = [a for a in args if a is not type(None)]
if len(non_none) == 1 and len(args) == 2:
return f"Optional[{name_of(non_none[0])}]"
return f"Union[{', '.join(name_of(a) for a in args)}]"
# Parametrized generics
if origin is not None:
origin_name_map = {
list: 'List',
dict: 'Dict',
set: 'Set',
tuple: 'Tuple',
frozenset: 'FrozenSet',
}
origin_name = origin_name_map.get(origin)
if origin_name is None:
origin_name = getattr(origin, '__name__', None) or str(origin).replace('typing.', '')
if args:
return f"{origin_name}[{', '.join(name_of(a) for a in args)}]"
return origin_name
# Non-generic leaf types
if tp is Any:
return 'Any'
if tp is None or tp is type(None):
return 'None'
if isinstance(tp, type):
return tp.__name__
# ForwardRef
fwd = getattr(tp, '__forward_arg__', None) or getattr(tp, 'arg', None)
if fwd:
return str(fwd)
# Fallback clean string
return str(tp).replace('typing.', '').replace('', '').replace("'", '')
return name_of(anno)
def is_inheritance_of_pydantic_base(cls):
return safe_issubclass(cls, BaseModel) and cls is not BaseModel and not is_generic_container(cls)
def get_bases_fields(schemas: list[type[BaseModel]]) -> set[str]:
"""Collect field names from a list of BaseModel subclasses (their model_fields keys)."""
fields: set[str] = set()
for schema in schemas:
for k, _ in getattr(schema, 'model_fields', {}).items():
fields.add(k)
return fields
def get_pydantic_fields(schema: type[BaseModel], bases_fields: set[str]) -> list[FieldInfo]:
"""Extract pydantic model fields with metadata.
Parameters:
schema: The pydantic BaseModel subclass to inspect.
bases_fields: Set of field names that come from base classes (for from_base marking).
Returns:
A list of FieldInfo objects describing the schema's direct fields.
"""
def _is_object(anno): # internal helper, previously a method on Analytics
_types = get_core_types(anno)
return any(is_inheritance_of_pydantic_base(t) for t in _types if t)
fields: list[FieldInfo] = []
for k, v in schema.model_fields.items():
anno = v.annotation
pydantic_resolve_specific_params = analysis_pydantic_resolve_fields(schema, k)
fields.append(FieldInfo(
is_object=_is_object(anno),
name=k,
from_base=k in bases_fields,
type_name=get_type_name(anno),
is_exclude=bool(v.exclude),
desc=v.description or '',
**pydantic_resolve_specific_params
))
return fields
def get_vscode_link(kls, online_repo_url: str | None = None) -> str:
"""Build a VSCode deep link to the class definition.
Priority:
1. If running inside WSL and WSL_DISTRO_NAME is present, return a remote link:
vscode://vscode-remote/wsl+/:
(This opens directly in the VSCode WSL remote window.)
2. Else, if path is /mnt//..., translate to Windows drive and return vscode://file/C:\\...:line
3. Else, fallback to vscode://file/:line
"""
try:
source_file = inspect.getfile(kls)
_lines, start_line = inspect.getsourcelines(kls)
distro = os.environ.get("WSL_DISTRO_NAME")
if online_repo_url:
cwd = os.getcwd()
relative_path = os.path.relpath(source_file, cwd)
return f"{online_repo_url}/{relative_path}#L{start_line}"
if distro:
# Ensure absolute path (it should already be under /) and build remote link
return f"vscode://vscode-remote/wsl+{distro}{source_file}:{start_line}"
# Non-remote scenario: maybe user wants to open via translated Windows path
if source_file.startswith('/mnt/') and len(source_file) > 6:
parts = source_file.split('/')
if len(parts) >= 4 and len(parts[2]) == 1: # drive letter
drive = parts[2].upper()
rest = parts[3:]
win_path = drive + ':\\' + '\\'.join(rest)
return f"vscode://file/{win_path}:{start_line}"
# Fallback plain unix path
return f"vscode://file/{source_file}:{start_line}"
except Exception:
return ""
def get_source(kls):
try:
source = inspect.getsource(kls)
return source
except Exception:
return "failed to get source"
def safe_issubclass(kls, target_kls):
try:
return issubclass(kls, target_kls)
except TypeError:
# if kls is ForwardRef, log it
if isinstance(kls, ForwardRef):
logger.error(f'{str(kls)} is a ForwardRef, not a subclass of {target_kls.__module__}:{target_kls.__qualname__}')
elif isinstance(kls, type):
logger.debug(f'{kls.__module__}:{kls.__qualname__} is not subclass of {target_kls.__module__}:{target_kls.__qualname__}')
return False
def update_forward_refs(kls):
# TODO: refactor
def update_pydantic_forward_refs(pydantic_kls: type[BaseModel]):
"""
recursively update refs.
"""
pydantic_kls.model_rebuild()
setattr(pydantic_kls, const.PYDANTIC_FORWARD_REF_UPDATED, True)
values = pydantic_kls.model_fields.values()
for field in values:
update_forward_refs(field.annotation)
for shelled_type in get_core_types(kls):
# Only treat as updated if the flag is set on the class itself, not via inheritance
local_attrs = getattr(shelled_type, '__dict__', {})
if local_attrs.get(const.PYDANTIC_FORWARD_REF_UPDATED, False):
logger.debug("%s visited", shelled_type.__qualname__)
continue
if safe_issubclass(shelled_type, BaseModel):
update_pydantic_forward_refs(shelled_type)
def is_generic_container(cls):
"""
T = TypeVar('T')
class DataModel(BaseModel, Generic[T]):
data: T
id: int
type DataModelPageStory = DataModel[PageStory]
is_generic_container(DataModel) -> True
is_generic_container(DataModel[PageStory]) -> False
DataModel.__parameters__ == (T,)
DataModelPageStory.__parameters__ == (,)
"""
try:
return (hasattr(cls, '__bases__') and Generic in cls.__bases__ and (hasattr(cls, '__parameters__') and bool(cls.__parameters__)))
except (TypeError, AttributeError):
return False
def is_non_pydantic_type(tp):
for schema in get_core_types(tp):
if schema and safe_issubclass(schema, BaseModel):
return False
return True
if __name__ == "__main__":
from tests.demo_anno import PageOverall, PageSprint
update_forward_refs(PageOverall)
update_forward_refs(PageSprint)
================================================
FILE: src/fastapi_voyager/version.py
================================================
__all__ = ["__version__"]
__version__ = "0.27.0"
================================================
FILE: src/fastapi_voyager/voyager.py
================================================
import pydantic_resolve.constant as const
from pydantic import BaseModel
from fastapi_voyager.filter import (
filter_graph,
filter_subgraph_by_module_prefix,
filter_subgraph_from_tag_to_schema_by_module_prefix,
)
from fastapi_voyager.introspectors import AppIntrospector, RouteInfo
from fastapi_voyager.render import Renderer
from fastapi_voyager.type import PK, CoreData, FieldType, Link, LinkType, Route, SchemaNode, Tag
from fastapi_voyager.type_helper import (
full_class_name,
get_bases_fields,
get_core_types,
get_pydantic_fields,
get_type_name,
is_base_entity_subclass,
is_inheritance_of_pydantic_base,
is_non_pydantic_type,
safe_issubclass,
update_forward_refs,
)
class Voyager:
def __init__(
self,
schema: str | None = None,
schema_field: str | None = None,
show_fields: FieldType = 'single',
include_tags: list[str] | None = None,
module_color: dict[str, str] | None = None,
route_name: str | None = None,
hide_primitive_route: bool = False,
show_module: bool = True,
show_pydantic_resolve_meta: bool = False,
theme_color: str | None = None,
entity_class_names: set[str] | None = None,
):
self.routes: list[Route] = []
self.nodes: list[SchemaNode] = []
self.node_set: dict[str, SchemaNode] = {}
self.link_set: set[tuple[str, str]] = set()
self.links: list[Link] = []
# store Tag by id, and also keep a list for rendering order
self.tag_set: dict[str, Tag] = {}
self.tags: list[Tag] = []
self.include_tags = include_tags
self.schema = schema
self.schema_field = schema_field
self.show_fields = show_fields if show_fields in ('single','object','all') else 'object'
self.module_color = module_color or {}
self.route_name = route_name
self.hide_primitive_route = hide_primitive_route
self.show_module = show_module
self.show_pydantic_resolve_meta = show_pydantic_resolve_meta
self.theme_color = theme_color
self.entity_class_names = entity_class_names
def _get_introspector(self, app) -> AppIntrospector:
"""
Get the appropriate introspector for the given app.
Automatically detects the framework type and returns the matching introspector.
Args:
app: A web application instance or AppIntrospector
Returns:
An AppIntrospector instance
Raises:
TypeError: If the app type is not supported
"""
from fastapi_voyager.introspectors import get_introspector
return get_introspector(app)
def analysis(self, app):
"""
Analyze routes and schemas from a web application.
This method automatically detects the framework type and uses the appropriate
introspector. Supported frameworks:
- FastAPI (built-in)
- Any framework with a custom AppIntrospector implementation
Args:
app: A web application instance (FastAPI, Django Ninja API, etc.)
or an AppIntrospector instance for custom frameworks.
1. get routes which return pydantic schema
1.1 collect tags and routes, add links tag-> route
1.2 collect response_model and links route -> response_model
2. iterate schemas, construct the schema/model nodes and their links
"""
introspector = self._get_introspector(app)
schemas: list[type[BaseModel]] = []
# First, group all routes by tag
routes_by_tag: dict[str, list[RouteInfo]] = {}
for route_info in introspector.get_routes():
# using multiple tags is harmful, it's not recommended and will not be supported
route_tag = route_info.tags[0] if route_info.tags else '__default__'
routes_by_tag.setdefault(route_tag, []).append(route_info)
# Then filter by include_tags if provided
if self.include_tags:
filtered_routes_by_tag = {
tag: routes
for tag, routes in routes_by_tag.items()
if tag in self.include_tags
}
else:
filtered_routes_by_tag = routes_by_tag
# Process filtered routes
for route_tag, route_infos in filtered_routes_by_tag.items():
tag_id = f'tag__{route_tag}'
tag_obj = Tag(id=tag_id, name=route_tag, routes=[])
self.tags.append(tag_obj)
for route_info in route_infos:
# filter by route_name (route.id) if provided
if self.route_name is not None and route_info.id != self.route_name:
continue
is_primitive_response = is_non_pydantic_type(route_info.response_model)
# filter primitive route if needed
if self.hide_primitive_route and is_primitive_response:
continue
self.links.append(
Link(
source=tag_id,
source_origin=tag_id,
target=route_info.id,
target_origin=route_info.id,
type='tag_route',
)
)
# Get unique_id from extra data if available
unique_id = route_info.operation_id
if route_info.extra and 'unique_id' in route_info.extra:
unique_id = unique_id or route_info.extra['unique_id']
route_obj = Route(
id=route_info.id,
name=route_info.name,
module=route_info.module,
unique_id=unique_id,
response_schema=get_type_name(route_info.response_model),
is_primitive=is_primitive_response,
)
self.routes.append(route_obj)
tag_obj.routes.append(route_obj)
# add response_models and create links from route -> response_model
for schema in get_core_types(route_info.response_model):
if schema and safe_issubclass(schema, BaseModel):
is_primitive_response = False
target_name = full_class_name(schema)
self.links.append(
Link(
source=route_info.id,
source_origin=route_info.id,
target=self.generate_node_head(target_name),
target_origin=target_name,
type='route_to_schema',
)
)
schemas.append(schema)
for s in schemas:
self.analysis_schemas(s)
self.nodes = list(self.node_set.values())
def add_to_node_set(self, schema):
"""
1. calc full_path, add to node_set
2. if duplicated, do nothing, else insert
2. return the full_path
"""
full_name = full_class_name(schema)
bases_fields = get_bases_fields([s for s in schema.__bases__ if is_inheritance_of_pydantic_base(s)])
subset_reference = getattr(schema, const.ENSURE_SUBSET_REFERENCE, None)
if subset_reference and is_inheritance_of_pydantic_base(subset_reference):
bases_fields.update(get_bases_fields([subset_reference]))
if full_name not in self.node_set:
# skip meta info for normal queries
self.node_set[full_name] = SchemaNode(
id=full_name,
module=schema.__module__,
name=schema.__name__,
fields=get_pydantic_fields(schema, bases_fields),
is_entity=is_base_entity_subclass(schema, self.entity_class_names)
)
return full_name
def add_to_link_set(
self,
source: str,
source_origin: str,
target: str,
target_origin: str,
type: LinkType
) -> bool:
"""
1. add link to link_set
2. if duplicated, do nothing, else insert
"""
pair = (source, target)
if result := pair not in self.link_set:
self.link_set.add(pair)
self.links.append(Link(
source=source,
source_origin=source_origin,
target=target,
target_origin=target_origin,
type=type
))
return result
def analysis_schemas(self, schema: type[BaseModel]):
"""
1. cls is the source, add schema
2. pydantic fields are targets, if annotation is subclass of BaseMode, add fields and add links
3. recursively run walk_schema
"""
update_forward_refs(schema)
self.add_to_node_set(schema)
base_fields = set()
# handle schema inside ensure_subset(schema)
if subset_reference := getattr(schema, const.ENSURE_SUBSET_REFERENCE, None):
if is_inheritance_of_pydantic_base(subset_reference):
self.add_to_node_set(subset_reference)
self.add_to_link_set(
source=self.generate_node_head(full_class_name(schema)),
source_origin=full_class_name(schema),
target= self.generate_node_head(full_class_name(subset_reference)),
target_origin=full_class_name(subset_reference),
type='subset')
self.analysis_schemas(subset_reference)
# handle bases
for base_class in schema.__bases__:
if is_inheritance_of_pydantic_base(base_class):
# collect base class field names to avoid duplicating inherited fields
try:
base_fields.update(getattr(base_class, 'model_fields', {}).keys())
except Exception:
# be defensive in case of unconventional BaseModel subclasses
pass
self.add_to_node_set(base_class)
self.add_to_link_set(
source=self.generate_node_head(full_class_name(schema)),
source_origin=full_class_name(schema),
target=self.generate_node_head(full_class_name(base_class)),
target_origin=full_class_name(base_class),
type='parent')
self.analysis_schemas(base_class)
# handle fields
for k, v in schema.model_fields.items():
# skip fields inherited from base classes
if k in base_fields:
continue
annos = get_core_types(v.annotation)
for anno in annos:
if anno and is_inheritance_of_pydantic_base(anno):
self.add_to_node_set(anno)
# add f prefix to fix highlight issue in vsc graphviz interactive previewer
source_name = f'{full_class_name(schema)}::f{k}'
if self.add_to_link_set(
source=source_name,
source_origin=full_class_name(schema),
target=self.generate_node_head(full_class_name(anno)),
target_origin=full_class_name(anno),
type='schema'):
self.analysis_schemas(anno)
def generate_node_head(self, link_name: str):
return f'{link_name}::{PK}'
def dump_core_data(self):
_tags, _routes, _nodes, _links = filter_graph(
schema=self.schema,
schema_field=self.schema_field,
tags=self.tags,
routes=self.routes,
nodes=self.nodes,
links=self.links,
node_set=self.node_set,
)
return CoreData(
tags=_tags,
routes=_routes,
nodes=_nodes,
links=_links,
show_fields=self.show_fields,
module_color=self.module_color,
schema=self.schema
)
def handle_hide(self, tags, routes, links):
if self.include_tags:
return [], routes, [lk for lk in links if lk.type != 'tag_route']
else:
return tags, routes, links
def calculate_filtered_tag_and_route(self):
_tags, _routes, _, _ = filter_graph(
schema=self.schema,
schema_field=self.schema_field,
tags=self.tags,
routes=self.routes,
nodes=self.nodes,
links=self.links,
node_set=self.node_set,
)
# filter tag.routes based by _routes
route_ids = {r.id for r in _routes}
for t in _tags:
t.routes = [r for r in t.routes if r.id in route_ids]
return _tags
def render_dot(self):
_tags, _routes, _nodes, _links = filter_graph(
schema=self.schema,
schema_field=self.schema_field,
tags=self.tags,
routes=self.routes,
nodes=self.nodes,
links=self.links,
node_set=self.node_set,
)
renderer = Renderer(
show_fields=self.show_fields,
module_color=self.module_color,
schema=self.schema,
show_module=self.show_module,
show_pydantic_resolve_meta=self.show_pydantic_resolve_meta,
theme_color=self.theme_color)
_tags, _routes, _links = self.handle_hide(_tags, _routes, _links)
return renderer.render_dot(_tags, _routes, _nodes, _links)
def render_tag_level_brief_dot(self, module_prefix: str | None = None):
_tags, _routes, _nodes, _links = filter_graph(
schema=self.schema,
schema_field=self.schema_field,
tags=self.tags,
routes=self.routes,
nodes=self.nodes,
links=self.links,
node_set=self.node_set,
)
_tags, _routes, _nodes, _links = filter_subgraph_by_module_prefix(
module_prefix=module_prefix,
tags=_tags,
routes=_routes,
nodes=_nodes,
links=_links,
)
renderer = Renderer(
show_fields=self.show_fields,
module_color=self.module_color,
schema=self.schema,
show_module=self.show_module,
theme_color=self.theme_color)
_tags, _routes, _links = self.handle_hide(_tags, _routes, _links)
return renderer.render_dot(_tags, _routes, _nodes, _links, True)
def render_overall_brief_dot(self, module_prefix: str | None = None):
_tags, _routes, _nodes, _links = filter_graph(
schema=self.schema,
schema_field=self.schema_field,
tags=self.tags,
routes=self.routes,
nodes=self.nodes,
links=self.links,
node_set=self.node_set,
)
_tags, _routes, _nodes, _links = filter_subgraph_from_tag_to_schema_by_module_prefix(
module_prefix=module_prefix,
tags=_tags,
routes=_routes,
nodes=_nodes,
links=_links,
)
renderer = Renderer(
show_fields=self.show_fields,
module_color=self.module_color,
schema=self.schema,
show_module=self.show_module,
theme_color=self.theme_color)
_tags, _routes, _links = self.handle_hide(_tags, _routes, _links)
return renderer.render_dot(_tags, _routes, _nodes, _links, True)
================================================
FILE: src/fastapi_voyager/web/component/demo.js
================================================
const { defineComponent, computed } = window.Vue
import { store } from "../store.js"
export default defineComponent({
name: "Demo",
emits: ["close"],
setup() {
return { store }
},
template: `