Showing preview only (557K chars total). Download the full file or copy to clipboard to get everything.
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 时替换:
- `<!-- STATIC_PATH -->` → 静态文件路径
- `<!-- VERSION_PLACEHOLDER -->` → 版本号
- `<!-- THEME_COLOR -->` → 框架主题色
- `<!-- GA_SNIPPET -->` → 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)
<img width="1597" height="933" alt="fastapi-voyager overview" src="https://github.com/user-attachments/assets/020bf5b2-6c69-44bf-ba1f-39389d388d27" />
## 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.
<img width="1100" height="700" alt="highlight nodes and dependencies" src="https://github.com/user-attachments/assets/3e0369ea-5fa4-469a-82c1-ed57d407e53d" />
### View Source Code
Double-click a node or route to show source code or open the file in VSCode.
<img width="1297" height="940" alt="view source code" src="https://github.com/user-attachments/assets/c8bb2e7d-b727-42a6-8c9e-64dce297d2d8" />
### Quick Search
Search schemas by name and display their upstream and downstream dependencies. Use `Shift + Click` on any node to quickly search for it.
<img width="1587" height="873" alt="quick search functionality" src="https://github.com/user-attachments/assets/ee4716f3-233d-418f-bc0e-3b214d1498f7" />
### 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))
```
<img width="1276" height="613" alt="ER diagram visualization" src="https://github.com/user-attachments/assets/ea0091bb-ee11-4f71-8be3-7129d956c910" />
### 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.
<img width="1604" height="535" alt="pydantic resolve meta information" src="https://github.com/user-attachments/assets/d1639555-af41-4a08-9970-4b8ef314596a" />
## 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 = "<!-- GA_SNIPPET -->"
VERSION_PLACEHOLDER = "<!-- VERSION_PLACEHOLDER -->"
STATIC_PATH_PLACEHOLDER = "<!-- STATIC_PATH -->"
THEME_COLOR_PLACEHOLDER = "<!-- THEME_COLOR -->"
VOYAGER_PATH_PLACEHOLDER = "<!-- VOYAGER_PATH -->"
def build_ga_snippet(ga_id: str | None) -> str:
"""Build Google Analytics snippet."""
if not ga_id:
return ""
return f""" <script>
window.addEventListener('load', function() {{
var s = document.createElement('script');
s.src = 'https://www.googletagmanager.com/gtag/js?id={ga_id}';
s.async = true;
document.head.appendChild(s);
window.dataLayer = window.dataLayer || [];
function gtag(){{dataLayer.push(arguments);}}
gtag('js', new Date());
gtag('config', '{ga_id}');
}});
</script>
"""
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 """
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Graphviz Preview</title></head>
<body>
<p>index.html not found. Create one under src/fastapi_voyager/web/index.html</p>
</body>
</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: <endpoint>_<path with _>)"
)
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'<font> {text_html} </font> {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'<font> {text_html} </font>'
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
================================================
<font color="{{ color }}">{% if strikethrough %}<s>{{ text }}</s>{% else %}{{ text }}{% endif %}</font>
================================================
FILE: src/fastapi_voyager/templates/html/pydantic_meta.j2
================================================
{% if meta_parts %}<br align="left"/><br align="left"/>{{ meta_parts | join('<br align="left"/>') }}<br align="left"/>{% endif %}
================================================
FILE: src/fastapi_voyager/templates/html/schema_field_row.j2
================================================
<tr><td align="{{ align }}" {% if port %}port="f{{ port }}"{% endif %} cellpadding="8">{{ content }}</td></tr>
================================================
FILE: src/fastapi_voyager/templates/html/schema_header.j2
================================================
<tr><td cellpadding="6" bgcolor="{{ bg_color }}" align="center" colspan="1" width="75" {% if port %}port="{{ port }}"{% endif %}><font color="white">{% if is_entity %}<b>{{ text }} (E)</b>{% else %}{{ text }}{% endif %}</font></td></tr>
================================================
FILE: src/fastapi_voyager/templates/html/schema_table.j2
================================================
<<table border="0" cellborder="1" cellpadding="0" cellspacing="0" bgcolor="white" width="75">
{{ header }}
{{ rows }}
</table>>
================================================
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: TypeAli
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
SYMBOL INDEX (571 symbols across 73 files)
FILE: src/fastapi_voyager/adapters/base.py
class VoyagerAdapter (line 10) | class VoyagerAdapter(ABC):
method create_app (line 21) | def create_app(self) -> Any:
FILE: src/fastapi_voyager/adapters/common.py
function build_ga_snippet (line 32) | def build_ga_snippet(ga_id: str | None) -> str:
class VoyagerContext (line 52) | class VoyagerContext:
method __init__ (line 59) | def __init__(
method _get_display_name (line 87) | def _get_display_name(self) -> str:
method _get_theme_color (line 96) | def _get_theme_color(self) -> str:
method _get_entity_class_names (line 101) | def _get_entity_class_names(self) -> set[str] | None:
method get_voyager (line 113) | def get_voyager(self, **kwargs) -> Voyager:
method analyze_and_get_dot (line 124) | def analyze_and_get_dot(self) -> tuple[str, list[Tag], list[SchemaNode]]:
method get_option_param (line 146) | def get_option_param(self) -> dict:
method get_search_dot (line 163) | def get_search_dot(self, payload: dict) -> list[Tag]:
method get_filtered_dot (line 182) | def get_filtered_dot(self, payload: dict) -> str:
method get_core_data (line 204) | def get_core_data(self, payload: dict) -> CoreData:
method render_dot_from_core_data (line 216) | def render_dot_from_core_data(self, core_data: CoreData) -> str:
method get_er_diagram_dot (line 228) | def get_er_diagram_dot(self, payload: dict) -> str:
method get_er_diagram_data (line 239) | def get_er_diagram_data(self, payload: dict) -> dict:
method get_index_html (line 283) | def get_index_html(self) -> str:
method get_source_code (line 311) | def get_source_code(self, schema_name: str) -> dict:
method get_vscode_link (line 333) | def get_vscode_link(self, schema_name: str) -> dict:
method get_service_worker (line 355) | def get_service_worker(self) -> str:
method get_manifest (line 365) | def get_manifest(self) -> str:
FILE: src/fastapi_voyager/adapters/django_ninja_adapter.py
class DjangoNinjaAdapter (line 16) | class DjangoNinjaAdapter(VoyagerAdapter):
method __init__ (line 23) | def __init__(
method _handle_request (line 52) | async def _handle_request(self, scope, receive, send):
method _handle_post_request (line 97) | async def _handle_post_request(self, receive, send, handler):
method _handle_static_file (line 114) | async def _handle_static_file(self, path: str, send):
method _handle_index (line 150) | async def _handle_index(self, send):
method _handle_service_worker (line 155) | async def _handle_service_worker(self, send):
method _handle_manifest (line 164) | async def _handle_manifest(self, send):
method _handle_get_dot (line 174) | async def _handle_get_dot(self, send):
method _handle_er_diagram (line 192) | async def _handle_er_diagram(self, payload, send):
method _handle_search_dot (line 197) | async def _handle_search_dot(self, payload, send):
method _handle_filtered_dot (line 203) | async def _handle_filtered_dot(self, payload, send):
method _handle_core_data (line 208) | async def _handle_core_data(self, payload, send):
method _handle_render_core_data (line 213) | async def _handle_render_core_data(self, payload, send):
method _handle_source (line 219) | async def _handle_source(self, payload, send):
method _handle_vscode_link (line 227) | async def _handle_vscode_link(self, payload, send):
method _send_html (line 235) | async def _send_html(self, html: str, send):
method _send_json (line 244) | async def _send_json(self, data: dict, send, status_code: int = 200):
method _send_text (line 249) | async def _send_text(self, text: str, send):
method _send_404 (line 253) | async def _send_404(self, send):
method _send_response (line 257) | async def _send_response(
method _tag_to_dict (line 273) | def _tag_to_dict(self, tag: Tag) -> dict:
method _schema_to_dict (line 291) | def _schema_to_dict(self, schema: SchemaNode) -> dict:
method create_app (line 308) | def create_app(self):
FILE: src/fastapi_voyager/adapters/fastapi_adapter.py
class OptionParam (line 15) | class OptionParam(BaseModel):
class Payload (line 28) | class Payload(BaseModel):
class SearchResultOptionParam (line 40) | class SearchResultOptionParam(BaseModel):
class SchemaSearchPayload (line 44) | class SchemaSearchPayload(BaseModel):
class ErDiagramPayload (line 54) | class ErDiagramPayload(BaseModel):
class SourcePayload (line 61) | class SourcePayload(BaseModel):
class FastAPIAdapter (line 65) | class FastAPIAdapter(VoyagerAdapter):
method __init__ (line 72) | def __init__(
method create_app (line 102) | def create_app(self) -> Any:
FILE: src/fastapi_voyager/adapters/litestar_adapter.py
class LitestarAdapter (line 13) | class LitestarAdapter(VoyagerAdapter):
method __init__ (line 20) | def __init__(
method create_app (line 50) | def create_app(self) -> Any:
method _tag_to_dict (line 167) | def _tag_to_dict(self, tag: Tag) -> dict:
method _schema_to_dict (line 185) | def _schema_to_dict(self, schema: SchemaNode) -> dict:
FILE: src/fastapi_voyager/cli.py
function load_app_from_file (line 20) | def load_app_from_file(module_path: str, app_name: str = "app", framewor...
function load_app_from_module (line 57) | def load_app_from_module(module_name: str, app_name: str = "app", framew...
function _validate_app_framework (line 99) | def _validate_app_framework(app: Any, framework: str) -> bool:
function generate_visualization (line 121) | def generate_visualization(
function main (line 151) | def main():
FILE: src/fastapi_voyager/er_diagram.py
function _get_loader_name (line 33) | def _get_loader_name(loader) -> str | None:
class DiagramRenderer (line 45) | class DiagramRenderer(Renderer):
method __init__ (line 54) | def __init__(
method render_link (line 74) | def render_link(self, link: Link) -> str:
method render_dot (line 97) | def render_dot(self, nodes: list[SchemaNode], links: list[Link], splin...
class VoyagerErDiagram (line 123) | class VoyagerErDiagram:
method __init__ (line 124) | def __init__(self,
method generate_node_head (line 147) | def generate_node_head(self, link_name: str):
method analysis_entity (line 150) | def analysis_entity(self, entity: Entity):
method add_to_node_set (line 189) | def add_to_node_set(
method add_to_link_set (line 223) | def add_to_link_set(
method render_dot (line 255) | def render_dot(self):
function get_fields (line 273) | def get_fields(schema: type[BaseModel], fk_set: set[str] | None = None) ...
function get_queries_and_mutations (line 288) | def get_queries_and_mutations(
function _get_return_type_str (line 332) | def _get_return_type_str(method) -> str:
FILE: src/fastapi_voyager/filter.py
function filter_graph (line 8) | def filter_graph(
function filter_subgraph_by_module_prefix (line 110) | def filter_subgraph_by_module_prefix(
function filter_subgraph_from_tag_to_schema_by_module_prefix (line 199) | def filter_subgraph_from_tag_to_schema_by_module_prefix(
FILE: src/fastapi_voyager/introspectors/base.py
class RouteInfo (line 18) | class RouteInfo:
class AppIntrospector (line 50) | class AppIntrospector(ABC):
method get_routes (line 60) | def get_routes(self) -> Iterator[RouteInfo]:
method get_swagger_url (line 74) | def get_swagger_url(self) -> str | None:
FILE: src/fastapi_voyager/introspectors/detector.py
class FrameworkType (line 13) | class FrameworkType(Enum):
function detect_framework (line 21) | def detect_framework(app: Any) -> FrameworkType:
function get_introspector (line 80) | def get_introspector(app: Any) -> AppIntrospector | None:
FILE: src/fastapi_voyager/introspectors/django_ninja.py
class DjangoNinjaIntrospector (line 11) | class DjangoNinjaIntrospector(AppIntrospector):
method __init__ (line 19) | def __init__(self, ninja_api, swagger_url: str | None = None):
method get_routes (line 30) | def get_routes(self) -> Iterator[RouteInfo]:
method get_swagger_url (line 71) | def get_swagger_url(self) -> str | None:
method _get_route_id (line 80) | def _get_route_id(self, operation) -> str:
method _get_response_model (line 96) | def _get_response_model(self, operation) -> type:
FILE: src/fastapi_voyager/introspectors/fastapi.py
class FastAPIIntrospector (line 15) | class FastAPIIntrospector(AppIntrospector):
method __init__ (line 23) | def __init__(self, app: "FastAPI", swagger_url: str | None = None):
method get_routes (line 40) | def get_routes(self) -> Iterator[RouteInfo]:
method get_swagger_url (line 71) | def get_swagger_url(self) -> str | None:
method _get_route_id (line 80) | def _get_route_id(self, route: Any) -> str:
FILE: src/fastapi_voyager/introspectors/litestar.py
class LitestarIntrospector (line 11) | class LitestarIntrospector(AppIntrospector):
method __init__ (line 19) | def __init__(self, app, swagger_url: str | None = None):
method get_routes (line 30) | def get_routes(self) -> Iterator[RouteInfo]:
method get_swagger_url (line 95) | def get_swagger_url(self) -> str | None:
method _get_route_id (line 104) | def _get_route_id(self, handler) -> str:
method _get_operation_id (line 120) | def _get_operation_id(self, route, handler) -> str:
method _get_response_model (line 142) | def _get_response_model(self, route) -> type:
FILE: src/fastapi_voyager/module.py
function _build_module_tree (line 10) | def _build_module_tree(
function build_module_schema_tree (line 83) | def build_module_schema_tree(schema_nodes: list[SchemaNode]) -> list[Mod...
function build_module_route_tree (line 93) | def build_module_route_tree(routes: list[Route]) -> list[ModuleRoute]:
FILE: src/fastapi_voyager/pydantic_resolve_util.py
function analysis_pydantic_resolve_fields (line 11) | def analysis_pydantic_resolve_fields(schema: type[BaseModel], field: str):
function extract_query_mutation_methods (line 104) | def extract_query_mutation_methods(entity: type) -> tuple[list[dict], li...
FILE: src/fastapi_voyager/render.py
class TemplateRenderer (line 31) | class TemplateRenderer:
method __init__ (line 36) | def __init__(self, template_dir: Path = TEMPLATE_DIR):
method render_template (line 45) | def render_template(self, template_name: str, **context) -> str:
class Renderer (line 51) | class Renderer:
method __init__ (line 59) | def __init__(
method _render_pydantic_meta_parts (line 92) | def _render_pydantic_meta_parts(self, field: FieldInfo) -> list[str]:
method _render_schema_field (line 143) | def _render_schema_field(
method _render_schema_method (line 185) | def _render_schema_method(self, method: MethodInfo, type: Literal['que...
method _get_filtered_fields (line 214) | def _get_filtered_fields(self, node: SchemaNode) -> list[FieldInfo]:
method render_schema_label (line 236) | def render_schema_label(self, node: SchemaNode, color: str | None = No...
method _handle_schema_anchor (line 297) | def _handle_schema_anchor(self, source: str) -> str:
method _format_link_attributes (line 304) | def _format_link_attributes(self, attrs: dict) -> str:
method render_link (line 308) | def render_link(self, link: Link) -> str:
method render_schema_node (line 333) | def render_schema_node(self, node: SchemaNode, color: str | None = Non...
method render_tag_node (line 344) | def render_tag_node(self, tag: Tag) -> str:
method render_route_node (line 353) | def render_route_node(self, route: Route) -> str:
method _render_module_schema (line 368) | def _render_module_schema(
method render_module_schema_content (line 430) | def render_module_schema_content(self, nodes: list[SchemaNode]) -> str:
method _render_module_route (line 444) | def _render_module_route(self, mod: ModuleRoute, show_cluster: bool = ...
method render_module_route_content (line 472) | def render_module_route_content(self, routes: list[Route]) -> str:
method _render_cluster_container (line 481) | def _render_cluster_container(
method render_dot (line 499) | def render_dot(
FILE: src/fastapi_voyager/render_style.py
class ColorScheme (line 10) | class ColorScheme:
method get_framework_color (line 46) | def get_framework_color(self, framework_type: FrameworkType) -> str:
class GraphvizStyle (line 52) | class GraphvizStyle:
method get_link_attributes (line 107) | def get_link_attributes(self, link_type: str) -> dict:
class RenderConfig (line 113) | class RenderConfig:
FILE: src/fastapi_voyager/server.py
function _get_adapter (line 17) | def _get_adapter(
function create_voyager (line 110) | def create_voyager(
FILE: src/fastapi_voyager/type.py
class NodeBase (line 8) | class NodeBase:
class FieldInfo (line 13) | class FieldInfo:
class MethodInfo (line 31) | class MethodInfo:
class Tag (line 37) | class Tag(NodeBase):
class Route (line 41) | class Route(NodeBase):
class ModuleRoute (line 48) | class ModuleRoute:
class SchemaNode (line 55) | class SchemaNode(NodeBase):
class ModuleNode (line 63) | class ModuleNode:
class Link (line 80) | class Link:
class CoreData (line 97) | class CoreData:
FILE: src/fastapi_voyager/type_helper.py
class _DummyTypeAliasType (line 19) | class _DummyTypeAliasType: # minimal sentinel so isinstance checks are ...
function is_list (line 24) | def is_list(annotation):
function full_class_name (line 28) | def full_class_name(cls):
function is_base_entity_subclass (line 32) | def is_base_entity_subclass(schema, entity_class_names: set[str] | None ...
function get_core_types (line 52) | def get_core_types(tp):
function get_type_name (line 119) | def get_type_name(anno):
function is_inheritance_of_pydantic_base (line 170) | def is_inheritance_of_pydantic_base(cls):
function get_bases_fields (line 174) | def get_bases_fields(schemas: list[type[BaseModel]]) -> set[str]:
function get_pydantic_fields (line 183) | def get_pydantic_fields(schema: type[BaseModel], bases_fields: set[str])...
function get_vscode_link (line 214) | def get_vscode_link(kls, online_repo_url: str | None = None) -> str:
function get_source (line 252) | def get_source(kls):
function safe_issubclass (line 260) | def safe_issubclass(kls, target_kls):
function update_forward_refs (line 272) | def update_forward_refs(kls):
function is_generic_container (line 297) | def is_generic_container(cls):
function is_non_pydantic_type (line 317) | def is_non_pydantic_type(tp):
FILE: src/fastapi_voyager/voyager.py
class Voyager (line 27) | class Voyager:
method __init__ (line 28) | def __init__(
method _get_introspector (line 67) | def _get_introspector(self, app) -> AppIntrospector:
method analysis (line 86) | def analysis(self, app):
method add_to_node_set (line 190) | def add_to_node_set(self, schema):
method add_to_link_set (line 215) | def add_to_link_set(
method analysis_schemas (line 240) | def analysis_schemas(self, schema: type[BaseModel]):
method generate_node_head (line 303) | def generate_node_head(self, link_name: str):
method dump_core_data (line 306) | def dump_core_data(self):
method handle_hide (line 326) | def handle_hide(self, tags, routes, links):
method calculate_filtered_tag_and_route (line 332) | def calculate_filtered_tag_and_route(self):
method render_dot (line 348) | def render_dot(self):
method render_tag_level_brief_dot (line 371) | def render_tag_level_brief_dot(self, module_prefix: str | None = None):
method render_overall_brief_dot (line 400) | def render_overall_brief_dot(self, module_prefix: str | None = None):
FILE: src/fastapi_voyager/web/component/demo.js
method setup (line 8) | setup() {
FILE: src/fastapi_voyager/web/component/loader-code-display.js
method setup (line 11) | setup(props) {
FILE: src/fastapi_voyager/web/component/render-graph.js
method setup (line 10) | setup(props, { emit }) {
FILE: src/fastapi_voyager/web/component/route-code-display.js
method setup (line 12) | setup(props, { emit }) {
FILE: src/fastapi_voyager/web/component/schema-code-display.js
method setup (line 20) | setup(props, { emit }) {
FILE: src/fastapi_voyager/web/graph-ui.js
class GraphUI (line 1) | class GraphUI {
method constructor (line 13) | constructor(selector = "#graph", options = {}) {
method _highlight (line 36) | _highlight(mode = "bidirectional") {
method _highlightEdgeNodes (line 48) | _highlightEdgeNodes() {
method _highlightEdgeOnly (line 60) | _highlightEdgeOnly(edgeEl, sourceNodeName, targetNodeName) {
method _getAffectedNodes (line 83) | _getAffectedNodes($set, mode = "bidirectional") {
method highlightSchemaBanner (line 122) | highlightSchemaBanner(node) {
method clearSchemaBanners (line 140) | clearSchemaBanners() {
method _saveOriginalAttributes (line 154) | _saveOriginalAttributes(element) {
method _highlightNodeShallow (line 165) | _highlightNodeShallow(node) {
method _applyNodeHighlight (line 195) | _applyNodeHighlight(node) {
method setHighlightMode (line 209) | setHighlightMode(mode) {
method _restoreHighlight (line 213) | _restoreHighlight() {
method _triggerCallback (line 259) | _triggerCallback(callbackName, ...args) {
method _initMagnifyingGlass (line 274) | _initMagnifyingGlass() {
method _init (line 303) | _init() {
method render (line 424) | async render(dotSrc, resetZoom = true) {
FILE: src/fastapi_voyager/web/graphviz.svg.js
function Plugin (line 614) | function Plugin(option) {
FILE: src/fastapi_voyager/web/magnifying-glass.js
class MagnifyingGlass (line 15) | class MagnifyingGlass {
method _getViewBoxDimensions (line 27) | _getViewBoxDimensions() {
method radius (line 47) | get radius() {
method constructor (line 58) | constructor(svgElement, options = {}) {
method magnification (line 92) | get magnification() {
method magnification (line 100) | set magnification(value) {
method _validateNumber (line 122) | _validateNumber(value, defaultValue, min, max) {
method _log (line 133) | _log(...args) {
method _initLens (line 143) | _initLens() {
method _bindEvents (line 190) | _bindEvents() {
method _updatePosition (line 239) | _updatePosition(event) {
method _performUpdate (line 254) | _performUpdate(event) {
method _updateContent (line 330) | _updateContent(absoluteX, absoluteY) {
method _updateTransform (line 355) | _updateTransform(absoluteX, absoluteY) {
method activate (line 372) | activate() {
method _getCurrentMousePosition (line 385) | _getCurrentMousePosition() {
method _updateContentFromCurrentMouse (line 396) | _updateContentFromCurrentMouse() {
method deactivate (line 410) | deactivate() {
method toggle (line 421) | toggle() {
method destroy (line 428) | destroy() {
FILE: src/fastapi_voyager/web/src/graph-ui.js
class GraphUI (line 1) | class GraphUI {
method constructor (line 13) | constructor(selector = "#graph", options = {}) {
method _highlight (line 36) | _highlight(mode = "bidirectional") {
method _highlightEdgeNodes (line 48) | _highlightEdgeNodes() {
method _highlightEdgeOnly (line 60) | _highlightEdgeOnly(edgeEl, sourceNodeName, targetNodeName) {
method _getAffectedNodes (line 83) | _getAffectedNodes($set, mode = "bidirectional") {
method highlightSchemaBanner (line 122) | highlightSchemaBanner(node) {
method clearSchemaBanners (line 140) | clearSchemaBanners() {
method _saveOriginalAttributes (line 154) | _saveOriginalAttributes(element) {
method _highlightNodeShallow (line 165) | _highlightNodeShallow(node) {
method _applyNodeHighlight (line 195) | _applyNodeHighlight(node) {
method setHighlightMode (line 209) | setHighlightMode(mode) {
method _restoreHighlight (line 213) | _restoreHighlight() {
method _triggerCallback (line 259) | _triggerCallback(callbackName, ...args) {
method _initMagnifyingGlass (line 274) | _initMagnifyingGlass() {
method _init (line 303) | _init() {
method render (line 424) | async render(dotSrc, resetZoom = true) {
FILE: src/fastapi_voyager/web/src/magnifying-glass.js
class MagnifyingGlass (line 15) | class MagnifyingGlass {
method _getViewBoxDimensions (line 27) | _getViewBoxDimensions() {
method radius (line 47) | get radius() {
method constructor (line 58) | constructor(svgElement, options = {}) {
method magnification (line 92) | get magnification() {
method magnification (line 100) | set magnification(value) {
method _validateNumber (line 122) | _validateNumber(value, defaultValue, min, max) {
method _log (line 133) | _log(...args) {
method _initLens (line 143) | _initLens() {
method _bindEvents (line 190) | _bindEvents() {
method _updatePosition (line 239) | _updatePosition(event) {
method _performUpdate (line 254) | _performUpdate(event) {
method _updateContent (line 330) | _updateContent(absoluteX, absoluteY) {
method _updateTransform (line 355) | _updateTransform(absoluteX, absoluteY) {
method activate (line 372) | activate() {
method _getCurrentMousePosition (line 385) | _getCurrentMousePosition() {
method _updateContentFromCurrentMouse (line 396) | _updateContentFromCurrentMouse() {
method deactivate (line 410) | deactivate() {
method toggle (line 421) | toggle() {
method destroy (line 428) | destroy() {
FILE: src/fastapi_voyager/web/src/store.js
method findTagByRoute (line 113) | findTagByRoute(routeId) {
method readQuerySelection (line 122) | readQuerySelection() {
method syncSelectionToUrl (line 134) | syncSelectionToUrl() {
method applySelectionFromQuery (line 161) | applySelectionFromQuery(selection) {
method loadFullTags (line 184) | loadFullTags() {
method populateFieldOptions (line 188) | populateFieldOptions(schemaId) {
method rebuildSchemaOptions (line 207) | rebuildSchemaOptions() {
method loadSearchedTags (line 218) | async loadSearchedTags() {
method loadInitial (line 243) | async loadInitial(onGenerate, renderBasedOnInitialPolicy) {
method onSearchSchemaChange (line 297) | onSearchSchemaChange(val, onSearch) {
method resetDetailPanels (line 306) | resetDetailPanels() {
method onReset (line 316) | onReset(onGenerate) {
method togglePydanticResolveMeta (line 324) | togglePydanticResolveMeta(val, onGenerate) {
method toggleShowModule (line 334) | toggleShowModule(val, onGenerate) {
method toggleShowField (line 344) | toggleShowField(field, onGenerate) {
method toggleBrief (line 349) | toggleBrief(val, onGenerate) {
method toggleHidePrimitiveRoute (line 359) | toggleHidePrimitiveRoute(val, onGenerate) {
method updateMagnification (line 369) | updateMagnification(val) {
method updateEdgeMinlen (line 379) | updateEdgeMinlen(val, onGenerate) {
method toggleShowMethods (line 390) | toggleShowMethods(val, onGenerate) {
method renderBasedOnInitialPolicy (line 400) | renderBasedOnInitialPolicy(onGenerate) {
method buildVoyagerPayload (line 416) | buildVoyagerPayload() {
method buildErDiagramPayload (line 432) | buildErDiagramPayload() {
method resetSearchState (line 441) | resetSearchState() {
FILE: src/fastapi_voyager/web/store.js
method findTagByRoute (line 130) | findTagByRoute(routeId) {
method readQuerySelection (line 143) | readQuerySelection() {
method syncSelectionToUrl (line 159) | syncSelectionToUrl() {
method applySelectionFromQuery (line 192) | applySelectionFromQuery(selection) {
method loadFullTags (line 220) | loadFullTags() {
method populateFieldOptions (line 228) | populateFieldOptions(schemaId) {
method rebuildSchemaOptions (line 251) | rebuildSchemaOptions() {
method loadSearchedTags (line 267) | async loadSearchedTags() {
method loadInitial (line 298) | async loadInitial(onGenerate, renderBasedOnInitialPolicy) {
method filterSearchSchemas (line 363) | filterSearchSchemas(val, update) {
method onSearchSchemaChange (line 382) | onSearchSchemaChange(val, onSearch) {
method resetDetailPanels (line 395) | resetDetailPanels() {
method onReset (line 409) | onReset(onGenerate) {
method togglePydanticResolveMeta (line 422) | togglePydanticResolveMeta(val, onGenerate) {
method toggleShowModule (line 437) | toggleShowModule(val, onGenerate) {
method toggleShowField (line 452) | toggleShowField(field, onGenerate) {
method toggleBrief (line 462) | toggleBrief(val, onGenerate) {
method toggleHidePrimitiveRoute (line 477) | toggleHidePrimitiveRoute(val, onGenerate) {
method updateMagnification (line 491) | updateMagnification(val) {
method updateEdgeMinlen (line 506) | updateEdgeMinlen(val, onGenerate) {
method toggleShowMethods (line 522) | toggleShowMethods(val, onGenerate) {
method renderBasedOnInitialPolicy (line 536) | renderBasedOnInitialPolicy(onGenerate) {
method buildVoyagerPayload (line 556) | buildVoyagerPayload() {
method buildErDiagramPayload (line 576) | buildErDiagramPayload() {
method resetSearchState (line 589) | resetSearchState() {
FILE: src/fastapi_voyager/web/sw.js
constant CACHE_PREFIX (line 8) | const CACHE_PREFIX = "fastapi-voyager-v"
constant VERSION (line 9) | const VERSION = "<!-- VERSION_PLACEHOLDER -->"
constant CACHE_NAME (line 10) | const CACHE_NAME = CACHE_PREFIX + VERSION
constant STATIC_PATH (line 11) | const STATIC_PATH = "<!-- STATIC_PATH -->"
constant CDN_ASSETS (line 14) | const CDN_ASSETS = [
constant CDN_DOMAINS (line 25) | const CDN_DOMAINS = [
FILE: tests/django_ninja/demo.py
function get_products (line 44) | def get_products(request) -> list[Product]:
class GraphQLRequest (line 52) | class GraphQLRequest(BaseModel):
function graphiql_playground (line 130) | def graphiql_playground(request) -> HttpResponse:
function graphql_endpoint (line 135) | def graphql_endpoint(request):
function graphql_schema (line 145) | def graphql_schema(request) -> HttpResponse:
function api_graphiql_playground (line 156) | def api_graphiql_playground(request) -> HttpResponse:
function api_graphql_endpoint (line 162) | async def api_graphql_endpoint(request, req: GraphQLRequest):
function api_graphql_schema (line 169) | def api_graphql_schema(request) -> HttpResponse:
class PageUser (line 179) | class PageUser(User):
method post_display_name (line 182) | def post_display_name(self):
class Something (line 189) | class Something:
class VariantA (line 193) | class VariantA(ProductVariant):
class VariantB (line 197) | class VariantB(ProductVariant):
class PageVariant (line 204) | class PageVariant(ProductVariant):
class MiddleProduct (line 208) | class MiddleProduct(DefineSubset):
class PageProduct (line 212) | class PageProduct(DefineSubset):
method post_price_label (line 217) | def post_price_label(self):
method resolve_desc (line 222) | def resolve_desc(self):
method post_desc (line 225) | def post_desc(self):
method post_coll (line 232) | def post_coll(self, c=Collector(alias="top_collector")):
class PageBrand (line 236) | class PageBrand(Brand):
class PageOverall (line 240) | class PageOverall(BaseModel):
class PageOverallWrap (line 244) | class PageOverallWrap(PageOverall):
method post_all_variants (line 249) | def post_all_variants(self, collector=Collector(alias="SomeCollector")):
function get_page_info (line 254) | async def get_page_info(request) -> PageOverallWrap:
class PageProducts (line 259) | class PageProducts(BaseModel):
function get_page_stories (line 264) | def get_page_stories(request) -> PageProducts:
class DataModel (line 271) | class DataModel(BaseModel, Generic[T]):
function get_page_test_1 (line 280) | def get_page_test_1(request) -> DataModelPageProduct:
function get_page_test_2 (line 285) | def get_page_test_2(request) -> A:
function get_page_test_3_long_long_long_name (line 290) | def get_page_test_3_long_long_long_name(request) -> bool:
function get_page_test_3_no_response_model (line 295) | def get_page_test_3_no_response_model(request):
function get_page_test_3_no_response_model_long_long_long_name (line 300) | def get_page_test_3_no_response_model_long_long_long_name(request):
FILE: tests/django_ninja/embedding.py
function application (line 33) | async def application(scope, receive, send):
FILE: tests/django_ninja/urls.py
function graphql_view (line 10) | def graphql_view(request):
FILE: tests/embedding_test_utils.py
function test_dot_endpoint_returns_success (line 24) | async def test_dot_endpoint_returns_success(async_client: httpx.AsyncCli...
function test_dot_endpoint_has_tags (line 30) | async def test_dot_endpoint_has_tags(async_client: httpx.AsyncClient):
function test_dot_endpoint_tags_have_routes (line 54) | async def test_dot_endpoint_tags_have_routes(async_client: httpx.AsyncCl...
function test_dot_endpoint_routes_structure (line 71) | async def test_dot_endpoint_routes_structure(async_client: httpx.AsyncCl...
function test_dot_endpoint_other_fields (line 99) | async def test_dot_endpoint_other_fields(async_client: httpx.AsyncClient...
function test_dot_endpoint_expected_routes (line 130) | async def test_dot_endpoint_expected_routes(
FILE: tests/fastapi/demo.py
function lifespan (line 41) | async def lifespan(app):
function get_products (line 50) | def get_products():
class GraphQLRequest (line 58) | class GraphQLRequest(BaseModel):
function graphiql_playground (line 133) | async def graphiql_playground():
function graphql_endpoint (line 139) | async def graphql_endpoint(req: GraphQLRequest):
function graphql_schema (line 146) | async def graphql_schema():
class PageUser (line 159) | class PageUser(User):
method post_display_name (line 161) | def post_display_name(self):
class Something (line 167) | class Something:
class VariantA (line 171) | class VariantA(ProductVariant):
class VariantB (line 175) | class VariantB(ProductVariant):
class PageVariant (line 182) | class PageVariant(ProductVariant):
class MiddleProduct (line 186) | class MiddleProduct(DefineSubset):
class PageProduct (line 190) | class PageProduct(DefineSubset):
method post_price_label (line 194) | def post_price_label(self):
method resolve_desc (line 198) | def resolve_desc(self):
method post_desc (line 201) | def post_desc(self):
method post_coll (line 209) | def post_coll(self, c=Collector(alias="top_collector")):
class PageBrand (line 213) | class PageBrand(Brand):
class PageOverall (line 218) | class PageOverall(BaseModel):
class PageOverallWrap (line 222) | class PageOverallWrap(PageOverall):
method post_all_variants (line 226) | def post_all_variants(self, collector=Collector(alias="SomeCollector")):
function get_page_info (line 231) | async def get_page_info():
class PageProducts (line 236) | class PageProducts(BaseModel):
function get_page_stories (line 241) | def get_page_stories():
class DataModel (line 248) | class DataModel(BaseModel, Generic[T]):
function get_page_test_1 (line 257) | def get_page_test_1():
function get_page_test_2 (line 262) | def get_page_test_2():
function get_page_test_3_long_long_long_name (line 267) | def get_page_test_3_long_long_long_name():
function get_page_test_3_no_response_model (line 272) | def get_page_test_3_no_response_model():
function get_page_test_3_no_response_model_long_long_long_name (line 277) | def get_page_test_3_no_response_model_long_long_long_name():
FILE: tests/fastapi/demo_anno.py
function get_product (line 14) | def get_product():
class PageUser (line 17) | class PageUser(User):
method post_display_name (line 19) | def post_display_name(self):
class VariantA (line 22) | class VariantA(ProductVariant):
class VariantB (line 25) | class VariantB(ProductVariant):
class PageVariant (line 30) | class PageVariant(ProductVariant):
class PageOverall (line 34) | class PageOverall(BaseModel):
class PageBrand (line 37) | class PageBrand(Product):
class PageProduct (line 43) | class PageProduct(BaseModel):
method post_desc (line 49) | def post_desc(self):
function get_page_info (line 57) | async def get_page_info():
FILE: tests/litestar/demo.py
class GraphQLRequest (line 36) | class GraphQLRequest(BaseModel):
class GraphQLController (line 114) | class GraphQLController(Controller):
method graphiql_playground (line 119) | async def graphiql_playground(self) -> Response[str]:
method graphql_endpoint (line 124) | async def graphql_endpoint(self, data: GraphQLRequest) -> dict:
method graphql_schema (line 130) | async def graphql_schema(self) -> Response[str]:
class PageUser (line 140) | class PageUser(User):
method post_display_name (line 143) | def post_display_name(self):
class Something (line 150) | class Something:
class VariantA (line 154) | class VariantA(ProductVariant):
class VariantB (line 158) | class VariantB(ProductVariant):
class PageVariant (line 165) | class PageVariant(ProductVariant):
class MiddleProduct (line 169) | class MiddleProduct(DefineSubset):
class PageProduct (line 173) | class PageProduct(DefineSubset):
method post_price_label (line 178) | def post_price_label(self):
method resolve_desc (line 183) | def resolve_desc(self):
method post_desc (line 186) | def post_desc(self):
method post_coll (line 193) | def post_coll(self, c=Collector(alias="top_collector")):
class PageBrand (line 197) | class PageBrand(Brand):
class PageOverall (line 201) | class PageOverall(BaseModel):
class PageOverallWrap (line 205) | class PageOverallWrap(PageOverall):
method post_all_variants (line 210) | def post_all_variants(self, collector=Collector(alias="SomeCollector")):
class PageProducts (line 214) | class PageProducts(BaseModel):
class DataModel (line 221) | class DataModel(BaseModel, Generic[T]):
class DemoController (line 229) | class DemoController(Controller):
method get_products (line 233) | def get_products(self) -> list[Product]:
method get_page_info (line 237) | async def get_page_info(self) -> PageOverallWrap:
method get_page_stories (line 242) | def get_page_stories(self) -> PageProducts:
method get_page_test_1 (line 246) | def get_page_test_1(self) -> DataModelPageProduct:
method get_page_test_2 (line 250) | def get_page_test_2(self) -> A:
method get_page_test_3_long_long_long_name (line 254) | def get_page_test_3_long_long_long_name(self) -> bool:
method get_page_test_3_no_response_model (line 258) | def get_page_test_3_no_response_model(self) -> bool:
method get_page_test_3_no_response_model_long_long_long_name (line 262) | def get_page_test_3_no_response_model_long_long_long_name(self) -> bool:
FILE: tests/litestar/embedding.py
function voyager_mount (line 31) | async def voyager_mount(
FILE: tests/service/schema/db.py
class OrmBase (line 16) | class OrmBase(DeclarativeBase):
function create_tables (line 20) | async def create_tables():
FILE: tests/service/schema/dto/attribute.py
class Attribute (line 7) | class Attribute(BaseModel):
class AttributeValue (line 15) | class AttributeValue(BaseModel):
FILE: tests/service/schema/dto/inventory.py
class Warehouse (line 7) | class Warehouse(BaseModel):
class Inventory (line 16) | class Inventory(BaseModel):
FILE: tests/service/schema/dto/marketing.py
class Coupon (line 7) | class Coupon(BaseModel):
class CouponUsage (line 18) | class CouponUsage(BaseModel):
FILE: tests/service/schema/dto/order.py
class Order (line 9) | class Order(BaseModel):
class OrderItem (line 19) | class OrderItem(BaseModel):
class Payment (line 30) | class Payment(BaseModel):
class Refund (line 41) | class Refund(BaseModel):
FILE: tests/service/schema/dto/product.py
class Category (line 9) | class Category(BaseModel):
class Brand (line 18) | class Brand(BaseModel):
class Product (line 27) | class Product(BaseModel):
class ProductVariant (line 40) | class ProductVariant(BaseModel):
class ProductImage (line 51) | class ProductImage(BaseModel):
class Review (line 61) | class Review(BaseModel):
FILE: tests/service/schema/dto/shipment.py
class Shipment (line 9) | class Shipment(BaseModel):
class ShipmentItem (line 20) | class ShipmentItem(BaseModel):
FILE: tests/service/schema/dto/store.py
class Store (line 9) | class Store(BaseModel):
FILE: tests/service/schema/dto/tag.py
class Tag (line 7) | class Tag(BaseModel):
FILE: tests/service/schema/dto/user.py
class User (line 9) | class User(BaseModel):
class UserAddress (line 19) | class UserAddress(BaseModel):
FILE: tests/service/schema/extra.py
class B (line 4) | class B(BaseModel):
class A (line 7) | class A(BaseModel):
FILE: tests/service/schema/orm/attribute.py
class AttributeOrm (line 10) | class AttributeOrm(OrmBase):
class AttributeValueOrm (line 20) | class AttributeValueOrm(OrmBase):
FILE: tests/service/schema/orm/inventory.py
class WarehouseOrm (line 10) | class WarehouseOrm(OrmBase):
class InventoryOrm (line 22) | class InventoryOrm(OrmBase):
FILE: tests/service/schema/orm/marketing.py
class CouponOrm (line 10) | class CouponOrm(OrmBase):
class CouponUsageOrm (line 23) | class CouponUsageOrm(OrmBase):
FILE: tests/service/schema/orm/order.py
class OrderOrm (line 10) | class OrderOrm(OrmBase):
class OrderItemOrm (line 29) | class OrderItemOrm(OrmBase):
class PaymentOrm (line 43) | class PaymentOrm(OrmBase):
class RefundOrm (line 56) | class RefundOrm(OrmBase):
FILE: tests/service/schema/orm/product.py
class CategoryOrm (line 10) | class CategoryOrm(OrmBase):
class BrandOrm (line 26) | class BrandOrm(OrmBase):
class ProductOrm (line 37) | class ProductOrm(OrmBase):
class ProductVariantOrm (line 61) | class ProductVariantOrm(OrmBase):
class ProductImageOrm (line 80) | class ProductImageOrm(OrmBase):
class TagOrm (line 92) | class TagOrm(OrmBase):
class ReviewOrm (line 105) | class ReviewOrm(OrmBase):
FILE: tests/service/schema/orm/shipment.py
class ShipmentOrm (line 10) | class ShipmentOrm(OrmBase):
class ShipmentItemOrm (line 25) | class ShipmentItemOrm(OrmBase):
FILE: tests/service/schema/orm/store.py
class StoreOrm (line 10) | class StoreOrm(OrmBase):
FILE: tests/service/schema/orm/user.py
class UserOrm (line 10) | class UserOrm(OrmBase):
class UserAddressOrm (line 29) | class UserAddressOrm(OrmBase):
FILE: tests/service/schema/schema.py
class CreateProductInput (line 54) | class CreateProductInput(BaseModel):
class CreateOrderInput (line 65) | class CreateOrderInput(BaseModel):
function user_get_all (line 113) | async def user_get_all(limit: int = 10, offset: int = 0) -> List[User]:
function user_get_by_id (line 121) | async def user_get_by_id(id: int) -> Optional[User]:
function user_create (line 128) | async def user_create(username: str, email: str, phone: str = "") -> User:
function product_get_all (line 139) | async def product_get_all(
function product_get_by_id (line 152) | async def product_get_by_id(id: int) -> Optional[Product]:
function product_create (line 159) | async def product_create(
function order_get_all (line 184) | async def order_get_all(
function order_get_by_id (line 197) | async def order_get_by_id(id: int) -> Optional[Order]:
function order_create (line 204) | async def order_create(user_id: int, total_amount: float = 0) -> Order:
function order_update_status (line 214) | async def order_update_status(id: int, status: str) -> Optional[Order]:
function category_get_all (line 227) | async def category_get_all() -> List[Category]:
function category_create (line 235) | async def category_create(name: str, parent_id: Optional[int] = None) ->...
function brand_get_all (line 246) | async def brand_get_all() -> List[Brand]:
function brand_create (line 254) | async def brand_create(name: str, logo: str = "") -> Brand:
function tag_get_all (line 265) | async def tag_get_all() -> List[Tag]:
function tag_create (line 273) | async def tag_create(name: str) -> Tag:
function coupon_get_all (line 284) | async def coupon_get_all() -> List[Coupon]:
function coupon_create (line 292) | async def coupon_create(code: str, discount: float, min_amount: float = ...
function store_get_all (line 303) | async def store_get_all() -> List[Store]:
function store_create (line 311) | async def store_create(name: str, description: str = "") -> Store:
function warehouse_get_all (line 322) | async def warehouse_get_all() -> List[Warehouse]:
function product_variant_get_by_product (line 331) | async def product_variant_get_by_product(product_id: int) -> List[Produc...
function order_item_get_by_order (line 342) | async def order_item_get_by_order(order_id: int) -> List[OrderItem]:
function init_db (line 404) | async def init_db():
FILE: tests/test_adapter_interface.py
function test_adapter_base_class_does_not_have_get_mount_path (line 17) | def test_adapter_base_class_does_not_have_get_mount_path():
function test_adapter_create_app_exists_and_works (line 35) | def test_adapter_create_app_exists_and_works(app_factory):
function test_django_ninja_adapter_create_app_works (line 48) | def test_django_ninja_adapter_create_app_works():
function test_adapter_instances_do_not_have_get_mount_path (line 67) | def test_adapter_instances_do_not_have_get_mount_path():
function test_mount_path_is_user_responsibility (line 95) | def test_mount_path_is_user_responsibility():
function test_adapter_design_principles (line 132) | def test_adapter_design_principles():
FILE: tests/test_analysis.py
function test_analysis (line 8) | def test_analysis():
function test_analysis_with_non_class_response_model (line 40) | def test_analysis_with_non_class_response_model():
FILE: tests/test_embedding_django_ninja.py
function event_loop (line 17) | def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
function async_client (line 25) | async def async_client() -> AsyncGenerator[httpx.AsyncClient, None]:
function expected_framework_name (line 37) | def expected_framework_name() -> str:
function expected_routes (line 43) | def expected_routes() -> list[str]:
function test_dot_endpoint_returns_success (line 50) | async def test_dot_endpoint_returns_success(async_client: httpx.AsyncCli...
function test_dot_endpoint_has_tags (line 56) | async def test_dot_endpoint_has_tags(async_client: httpx.AsyncClient):
function test_dot_endpoint_tags_have_routes (line 62) | async def test_dot_endpoint_tags_have_routes(async_client: httpx.AsyncCl...
function test_dot_endpoint_routes_structure (line 68) | async def test_dot_endpoint_routes_structure(async_client: httpx.AsyncCl...
function test_dot_endpoint_expected_routes (line 74) | async def test_dot_endpoint_expected_routes(async_client: httpx.AsyncCli...
function test_dot_endpoint_other_fields (line 80) | async def test_dot_endpoint_other_fields(async_client: httpx.AsyncClient...
FILE: tests/test_embedding_fastapi.py
function event_loop (line 18) | def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
function async_client (line 26) | async def async_client() -> AsyncGenerator[httpx.AsyncClient, None]:
function expected_framework_name (line 35) | def expected_framework_name() -> str:
function expected_routes (line 41) | def expected_routes() -> list[str]:
function test_dot_endpoint_returns_success (line 48) | async def test_dot_endpoint_returns_success(async_client: httpx.AsyncCli...
function test_dot_endpoint_has_tags (line 54) | async def test_dot_endpoint_has_tags(async_client: httpx.AsyncClient):
function test_dot_endpoint_tags_have_routes (line 60) | async def test_dot_endpoint_tags_have_routes(async_client: httpx.AsyncCl...
function test_dot_endpoint_routes_structure (line 66) | async def test_dot_endpoint_routes_structure(async_client: httpx.AsyncCl...
function test_dot_endpoint_expected_routes (line 72) | async def test_dot_endpoint_expected_routes(async_client: httpx.AsyncCli...
function test_dot_endpoint_other_fields (line 78) | async def test_dot_endpoint_other_fields(async_client: httpx.AsyncClient...
FILE: tests/test_embedding_litestar.py
function event_loop (line 17) | def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
function async_client (line 25) | async def async_client() -> AsyncGenerator[httpx.AsyncClient, None]:
function expected_framework_name (line 37) | def expected_framework_name() -> str:
function expected_routes (line 43) | def expected_routes() -> list[str]:
function test_dot_endpoint_returns_success (line 50) | async def test_dot_endpoint_returns_success(async_client: httpx.AsyncCli...
function test_dot_endpoint_has_tags (line 56) | async def test_dot_endpoint_has_tags(async_client: httpx.AsyncClient):
function test_dot_endpoint_tags_have_routes (line 62) | async def test_dot_endpoint_tags_have_routes(async_client: httpx.AsyncCl...
function test_dot_endpoint_routes_structure (line 68) | async def test_dot_endpoint_routes_structure(async_client: httpx.AsyncCl...
function test_dot_endpoint_expected_routes (line 74) | async def test_dot_endpoint_expected_routes(async_client: httpx.AsyncCli...
function test_dot_endpoint_other_fields (line 80) | async def test_dot_endpoint_other_fields(async_client: httpx.AsyncClient...
FILE: tests/test_filter.py
function _make_tag_route_link (line 5) | def _make_tag_route_link(tag: Tag, route: Route) -> Link:
function test_filter_subgraph_filters_nodes_and_links (line 15) | def test_filter_subgraph_filters_nodes_and_links():
function test_filter_subgraph_handles_cycles_and_multiple_matches (line 64) | def test_filter_subgraph_handles_cycles_and_multiple_matches():
FILE: tests/test_generic.py
class PageStory (line 9) | class PageStory(BaseModel):
class DataModel (line 14) | class DataModel(BaseModel, Generic[T]):
function test_is_generic_container (line 24) | def test_is_generic_container():
FILE: tests/test_import.py
function test_import (line 1) | def test_import():
FILE: tests/test_module.py
function _sn (line 5) | def _sn(id: str, module: str, name: str) -> SchemaNode:
function _find_child (line 9) | def _find_child(module_node, name: str):
function _find_top (line 12) | def _find_top(top_modules, name: str):
function test_build_module_tree_basic (line 16) | def test_build_module_tree_basic():
function test_build_module_tree_empty_input (line 60) | def test_build_module_tree_empty_input():
function test_build_module_tree_root_level_nodes (line 65) | def test_build_module_tree_root_level_nodes():
function test_collapse_single_child_empty_modules (line 83) | def test_collapse_single_child_empty_modules():
FILE: tests/test_resolve_util_impl.py
class SchemaA (line 10) | class SchemaA(BaseModel):
method resolve_resolved_field (line 33) | def resolve_resolved_field(self):
method post_post_field (line 36) | def post_post_field(self):
method post_collector (line 40) | def post_collector(self, collector=Collector(alias="top_collector")):
function test_resolve_util (line 43) | def test_resolve_util():
FILE: tests/test_type_helper.py
function test_optional_and_list_core_types (line 9) | def test_optional_and_list_core_types():
function test_typing_union_core_types (line 23) | def test_typing_union_core_types():
function test_uniontype_pep604_core_types (line 34) | def test_uniontype_pep604_core_types():
function test_mixed_optional_list (line 43) | def test_mixed_optional_list():
function test_nested_union_flattening (line 52) | def test_nested_union_flattening():
function test_uniontype_with_list_member (line 64) | def test_uniontype_with_list_member():
function test_union_type_alias_and_list (line 78) | def test_union_type_alias_and_list():
function test_annotated (line 102) | def test_annotated():
Condensed preview — 136 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (565K chars).
[
{
"path": ".githooks/README.md",
"chars": 959,
"preview": "# Git Hooks Setup\n\nThis repository uses Git hooks to automatically format code with Prettier before each commit.\n\n## One"
},
{
"path": ".githooks/pre-commit",
"chars": 923,
"preview": "#!/bin/sh\n# Git pre-commit hook to run Prettier on staged files\n\n# Get the project root directory using Git command (wor"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 834,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the b"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.md",
"chars": 595,
"preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your fea"
},
{
"path": ".github/workflows/publish.yml",
"chars": 2563,
"preview": "name: Publish to PyPI via uv\n\non:\n workflow_dispatch:\n push:\n tags:\n - \"v*\"\n\njobs:\n publish:\n runs-on: ubu"
},
{
"path": ".gitignore",
"chars": 4748,
"preview": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[codz]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packag"
},
{
"path": ".prettierignore",
"chars": 288,
"preview": "# Dependencies\nnode_modules/\n.venv/\n__pycache__/\n*.pyc\n\n# Build outputs\ndist/\nbuild/\n*.egg-info/\n\n# Static assets\n*.min."
},
{
"path": ".prettierrc",
"chars": 214,
"preview": "{\n \"semi\": false,\n \"singleQuote\": false,\n \"tabWidth\": 2,\n \"useTabs\": false,\n \"trailingComma\": \"es5\",\n \"printWidth\""
},
{
"path": ".python-version",
"chars": 5,
"preview": "3.12\n"
},
{
"path": "CLAUDE.md",
"chars": 1732,
"preview": "# CLAUDE.md - fastapi-voyager\n\n## 项目概述\n\nFastAPI Voyager 是一个 Python 包,提供 API 路由树和依赖关系的可视化。前端使用 Vue 3 + Naive UI,通过 Vite 构"
},
{
"path": "CONTRIBUTING.md",
"chars": 344,
"preview": "# How to develop & contribute?\n\nfork, clone.\n\ninstall uv.\n\n```shell\nuv venv\nsource .venv/bin/activate\nuv pip install \".["
},
{
"path": "LICENSE",
"chars": 1067,
"preview": "MIT License\n\nCopyright (c) 2025 tangkikodo\n\nPermission is hereby granted, free of charge, to any person obtaining a copy"
},
{
"path": "README.md",
"chars": 12529,
"preview": "[](https://pypi.python.org/pypi/fastapi-voyager)\n![Python Vers"
},
{
"path": "docs/changelog.md",
"chars": 10651,
"preview": "# Changelog & plan\n\n## <0.9:\n- [x] group schemas by module hierarchy\n- [x] module-based coloring via Analytics(module_co"
},
{
"path": "docs/claude/0_REFACTORING_RENDER_NOTES.md",
"chars": 3579,
"preview": "# Jinja2 模板引擎重构说明\n\n## 概述\n\n已成功将 `render.py` 从硬编码的模板字符串重构为使用 Jinja2 模板引擎的架构。\n\n## 变更内容\n\n### 1. 新增文件\n\n#### `src/fastapi_voya"
},
{
"path": "docs/idea.md",
"chars": 1228,
"preview": "# Idea\n\n## backlog\n- [ ] user can generate nodes/edges manually and connect to generated ones\n - [ ] eg: add owner\n "
},
{
"path": "pyproject.toml",
"chars": 2382,
"preview": "[project]\nname = \"fastapi-voyager\"\ndynamic = [\"version\"]\ndescription = \"Visualize FastAPI application's routing tree and"
},
{
"path": "release.md",
"chars": 81,
"preview": "release by pushing the tag\n\n```shell\ngit tag v1.0.0\ngit push origin v1.0.0\n```\n\n "
},
{
"path": "setup-django-ninja.sh",
"chars": 1118,
"preview": "#!/bin/bash\n# Django Ninja Development Setup Script\n# Usage: ./setup-django-ninja.sh [--no-sync]\n\nset -e\n\necho \"🚀 Settin"
},
{
"path": "setup-fastapi.sh",
"chars": 1062,
"preview": "#!/bin/bash\n# FastAPI Development Setup Script\n# Usage: ./setup-fastapi.sh [--no-sync]\n\nset -e\n\necho \"🚀 Setting up FastA"
},
{
"path": "setup-hooks.sh",
"chars": 516,
"preview": "#!/bin/bash\n# Setup script for Git hooks\n\necho \"Setting up Git hooks...\"\n\n# Check if we're in a git repository\nif ! git "
},
{
"path": "setup-litestar.sh",
"chars": 1070,
"preview": "#!/bin/bash\n# Litestar Development Setup Script\n# Usage: ./setup-litestar.sh [--no-sync]\n\nset -e\n\necho \"🚀 Setting up Lit"
},
{
"path": "src/fastapi_voyager/__init__.py",
"chars": 228,
"preview": "\"\"\"fastapi_voyager\n\nUtilities to introspect web applications and visualize their routing tree.\n\"\"\"\nfrom .server import c"
},
{
"path": "src/fastapi_voyager/adapters/__init__.py",
"chars": 517,
"preview": "\"\"\"\nFramework adapters for fastapi-voyager.\n\nThis module provides adapters that allow voyager to work with different web"
},
{
"path": "src/fastapi_voyager/adapters/base.py",
"chars": 1039,
"preview": "\"\"\"\nBase adapter interface for framework-agnostic voyager server.\n\nThis module defines the abstract interface that all f"
},
{
"path": "src/fastapi_voyager/adapters/common.py",
"chars": 14628,
"preview": "\"\"\"\nShared business logic for voyager endpoints.\n\nThis module contains the core logic that is reused across all framewor"
},
{
"path": "src/fastapi_voyager/adapters/django_ninja_adapter.py",
"chars": 12489,
"preview": "\"\"\"\nDjango Ninja adapter for fastapi-voyager.\n\nThis module provides the Django Ninja-specific implementation of the voya"
},
{
"path": "src/fastapi_voyager/adapters/fastapi_adapter.py",
"chars": 6978,
"preview": "\"\"\"\nFastAPI adapter for fastapi-voyager.\n\nThis module provides the FastAPI-specific implementation of the voyager server"
},
{
"path": "src/fastapi_voyager/adapters/litestar_adapter.py",
"chars": 7534,
"preview": "\"\"\"\nLitestar adapter for fastapi-voyager.\n\nThis module provides the Litestar-specific implementation of the voyager serv"
},
{
"path": "src/fastapi_voyager/cli.py",
"chars": 12439,
"preview": "\"\"\"Command line interface for fastapi-voyager.\"\"\"\nimport argparse\nimport importlib\nimport importlib.util\nimport logging\n"
},
{
"path": "src/fastapi_voyager/er_diagram.py",
"chars": 11876,
"preview": "from __future__ import annotations\n\nfrom logging import getLogger\n\nfrom pydantic import BaseModel\nfrom pydantic_resolve "
},
{
"path": "src/fastapi_voyager/filter.py",
"chars": 11525,
"preview": "from __future__ import annotations\n\nfrom collections import deque\n\nfrom fastapi_voyager.type import PK, Link, Route, Sch"
},
{
"path": "src/fastapi_voyager/introspectors/__init__.py",
"chars": 917,
"preview": "\"\"\"\nIntrospectors for different web frameworks.\n\nThis package contains built-in introspector implementations for various"
},
{
"path": "src/fastapi_voyager/introspectors/base.py",
"chars": 2131,
"preview": "\"\"\"\nIntrospection abstraction layer for framework-agnostic route analysis.\n\nThis module provides the abstraction that al"
},
{
"path": "src/fastapi_voyager/introspectors/detector.py",
"chars": 3910,
"preview": "\"\"\"\nFramework detection utility for fastapi-voyager.\n\nThis module provides a centralized framework detection mechanism t"
},
{
"path": "src/fastapi_voyager/introspectors/django_ninja.py",
"chars": 4052,
"preview": "\"\"\"\nDjango Ninja implementation of the AppIntrospector interface.\n\nThis module provides the adapter that allows fastapi-"
},
{
"path": "src/fastapi_voyager/introspectors/fastapi.py",
"chars": 3085,
"preview": "\"\"\"\nFastAPI implementation of the AppIntrospector interface.\n\nThis module provides the adapter that allows fastapi-voyag"
},
{
"path": "src/fastapi_voyager/introspectors/litestar.py",
"chars": 6100,
"preview": "\"\"\"\nLitestar implementation of the AppIntrospector interface.\n\nThis module provides the adapter that allows fastapi-voya"
},
{
"path": "src/fastapi_voyager/module.py",
"chars": 3425,
"preview": "from collections.abc import Callable\nfrom typing import Any, TypeVar\n\nfrom fastapi_voyager.type import ModuleNode, Modul"
},
{
"path": "src/fastapi_voyager/pydantic_resolve_util.py",
"chars": 5617,
"preview": "import inspect\n\nimport pydantic_resolve.constant as const\nfrom pydantic import BaseModel\nfrom pydantic.fields import Fie"
},
{
"path": "src/fastapi_voyager/render.py",
"chars": 19130,
"preview": "\"\"\"\nRender FastAPI application structure to DOT format using Jinja2 templates.\n\"\"\"\nfrom logging import getLogger\nfrom pa"
},
{
"path": "src/fastapi_voyager/render_style.py",
"chars": 3235,
"preview": "\"\"\"\nStyle constants and configuration for rendering DOT graphs and HTML tables.\n\"\"\"\nfrom dataclasses import dataclass, f"
},
{
"path": "src/fastapi_voyager/server.py",
"chars": 7035,
"preview": "\"\"\"\nFastAPI-voyager server module with framework adapter support.\n\nThis module provides the main `create_voyager` functi"
},
{
"path": "src/fastapi_voyager/templates/dot/cluster.j2",
"chars": 307,
"preview": "subgraph cluster_{{ cluster_id }} {\n tooltip=\"{{ tooltip }}\"\n color = \"{{ border_color }}\"\n style=\"rounded\"\n "
},
{
"path": "src/fastapi_voyager/templates/dot/cluster_container.j2",
"chars": 204,
"preview": "subgraph cluster_{{ name }} {\n color = \"{{ border_color }}\"\n margin={{ margin }}\n style=\"dashed\"\n label = \" "
},
{
"path": "src/fastapi_voyager/templates/dot/digraph.j2",
"chars": 435,
"preview": "digraph world {\n pad=\"{{ pad }}\"\n nodesep={{ nodesep }}\n {% if spline %}splines={{ spline }}{% endif %}\n fon"
},
{
"path": "src/fastapi_voyager/templates/dot/er_diagram.j2",
"chars": 557,
"preview": "digraph world {\n pad=\"{{ pad }}\"\n nodesep={{ nodesep }}\n {% if spline %}splines={{ spline }}{% endif %}\n fon"
},
{
"path": "src/fastapi_voyager/templates/dot/link.j2",
"chars": 49,
"preview": "{{ source }} -> {{ target }} [{{ attributes }}];\n"
},
{
"path": "src/fastapi_voyager/templates/dot/route_node.j2",
"chars": 120,
"preview": "\"{{ id }}\" [\n label = \" {{ name }} | {{ response_schema }} \"\n margin=\"{{ margin }}\"\n shape = \"record\"\n];\n"
},
{
"path": "src/fastapi_voyager/templates/dot/schema_node.j2",
"chars": 86,
"preview": "\"{{ id }}\" [\n label = {{ label }}\n shape = \"plain\"\n margin=\"{{ margin }}\"\n];\n"
},
{
"path": "src/fastapi_voyager/templates/dot/tag_node.j2",
"chars": 96,
"preview": "\"{{ id }}\" [\n label = \" {{ name }} \"\n shape = \"record\"\n margin=\"{{ margin }}\"\n];\n"
},
{
"path": "src/fastapi_voyager/templates/html/colored_text.j2",
"chars": 104,
"preview": "<font color=\"{{ color }}\">{% if strikethrough %}<s>{{ text }}</s>{% else %}{{ text }}{% endif %}</font>\n"
},
{
"path": "src/fastapi_voyager/templates/html/pydantic_meta.j2",
"chars": 130,
"preview": "{% if meta_parts %}<br align=\"left\"/><br align=\"left\"/>{{ meta_parts | join('<br align=\"left\"/>') }}<br align=\"left\"/>{%"
},
{
"path": "src/fastapi_voyager/templates/html/schema_field_row.j2",
"chars": 111,
"preview": "<tr><td align=\"{{ align }}\" {% if port %}port=\"f{{ port }}\"{% endif %} cellpadding=\"8\">{{ content }}</td></tr>\n"
},
{
"path": "src/fastapi_voyager/templates/html/schema_header.j2",
"chars": 237,
"preview": "<tr><td cellpadding=\"6\" bgcolor=\"{{ bg_color }}\" align=\"center\" colspan=\"1\" width=\"75\" {% if port %}port=\"{{ port }}\"{% "
},
{
"path": "src/fastapi_voyager/templates/html/schema_table.j2",
"chars": 128,
"preview": "<<table border=\"0\" cellborder=\"1\" cellpadding=\"0\" cellspacing=\"0\" bgcolor=\"white\" width=\"75\">\n{{ header }}\n{{ rows }}\n</"
},
{
"path": "src/fastapi_voyager/type.py",
"chars": 2525,
"preview": "from dataclasses import field\nfrom typing import Literal\n\nfrom pydantic.dataclasses import dataclass\n\n\n@dataclass\nclass "
},
{
"path": "src/fastapi_voyager/type_helper.py",
"chars": 10830,
"preview": "import inspect\nimport logging\nimport os\nfrom types import UnionType\nfrom typing import Annotated, Any, ForwardRef, Gener"
},
{
"path": "src/fastapi_voyager/version.py",
"chars": 49,
"preview": "__all__ = [\"__version__\"]\n__version__ = \"0.27.0\"\n"
},
{
"path": "src/fastapi_voyager/voyager.py",
"chars": 15845,
"preview": "\nimport pydantic_resolve.constant as const\nfrom pydantic import BaseModel\n\nfrom fastapi_voyager.filter import (\n filt"
},
{
"path": "src/fastapi_voyager/web/component/demo.js",
"chars": 350,
"preview": "const { defineComponent, computed } = window.Vue\n\nimport { store } from \"../store.js\"\n\nexport default defineComponent({\n"
},
{
"path": "src/fastapi_voyager/web/component/loader-code-display.js",
"chars": 4087,
"preview": "const { defineComponent, ref, watch, onMounted } = window.Vue\n\nexport default defineComponent({\n name: \"LoaderCodeDispl"
},
{
"path": "src/fastapi_voyager/web/component/render-graph.js",
"chars": 2454,
"preview": "import { GraphUI } from \"../graph-ui.js\"\nconst { defineComponent, ref, onMounted, nextTick } = window.Vue\n\nexport defaul"
},
{
"path": "src/fastapi_voyager/web/component/route-code-display.js",
"chars": 3615,
"preview": "const { defineComponent, ref, watch, onMounted } = window.Vue\n\n// Component: RouteCodeDisplay\n// Props:\n// routeId: ro"
},
{
"path": "src/fastapi_voyager/web/component/schema-code-display.js",
"chars": 7186,
"preview": "const { defineComponent, ref, watch, onMounted } = window.Vue\n\n// Component: SchemaCodeDisplay\n// Props:\n// schemaName"
},
{
"path": "src/fastapi_voyager/web/graph-ui.js",
"chars": 14591,
"preview": "export class GraphUI {\n // ====================\n // Constants\n // ====================\n\n static HIGHLIGHT_COLOR = \"#"
},
{
"path": "src/fastapi_voyager/web/graphviz.svg.css",
"chars": 1937,
"preview": "/*\n * Copyright (c) 2015 Mountainstorm\n * \n * Permission is hereby granted, free of charge, to any person obtaining a co"
},
{
"path": "src/fastapi_voyager/web/graphviz.svg.js",
"chars": 18143,
"preview": ";+(function ($) {\n \"use strict\"\n\n // GRAPHVIZSVG PUBLIC CLASS DEFINITION\n // ===================================\n\n v"
},
{
"path": "src/fastapi_voyager/web/icon/site.webmanifest",
"chars": 564,
"preview": "{\n \"name\": \"FastAPI Voyager\",\n \"short_name\": \"Voyager\",\n \"description\": \"Visualize API routing tree and dependencies\""
},
{
"path": "src/fastapi_voyager/web/index.html",
"chars": 6230,
"preview": "<!doctype html>\n<html>\n <head>\n <title>FastAPI Voyager</title>\n <meta name=\"description\" content=\"Visualize API r"
},
{
"path": "src/fastapi_voyager/web/magnifying-glass.js",
"chars": 12632,
"preview": "/**\n * Magnifying Glass for SVG Graph Visualization\n *\n * Provides a circular magnifying glass effect that follows the m"
},
{
"path": "src/fastapi_voyager/web/package.json",
"chars": 321,
"preview": "{\n \"name\": \"fastapi-voyager-web\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\":"
},
{
"path": "src/fastapi_voyager/web/src/App.vue",
"chars": 31854,
"preview": "<template>\n <n-config-provider :theme-overrides=\"themeOverrides\">\n <n-notification-provider>\n <div style=\"displ"
},
{
"path": "src/fastapi_voyager/web/src/component/LoaderCodeDisplay.vue",
"chars": 3707,
"preview": "<template>\n <div\n class=\"frv-loader-display\"\n style=\"\n border: 1px solid #ccc;\n border-left: none;\n "
},
{
"path": "src/fastapi_voyager/web/src/component/RenderGraph.vue",
"chars": 2583,
"preview": "<template>\n <div style=\"height: 100%; position: relative; background: #fff\">\n <n-button\n size=\"small\"\n qua"
},
{
"path": "src/fastapi_voyager/web/src/component/RouteCodeDisplay.vue",
"chars": 3288,
"preview": "<template>\n <div\n class=\"frv-route-code-display\"\n style=\"border: 1px solid #ccc; position: relative; background: "
},
{
"path": "src/fastapi_voyager/web/src/component/SchemaCodeDisplay.vue",
"chars": 5656,
"preview": "<template>\n <div class=\"frv-code-display\" style=\"position: relative; height: 100%; background: #fff\">\n <div v-show=\""
},
{
"path": "src/fastapi_voyager/web/src/graph-ui.js",
"chars": 14591,
"preview": "export class GraphUI {\n // ====================\n // Constants\n // ====================\n\n static HIGHLIGHT_COLOR = \"#"
},
{
"path": "src/fastapi_voyager/web/src/magnifying-glass.js",
"chars": 12632,
"preview": "/**\n * Magnifying Glass for SVG Graph Visualization\n *\n * Provides a circular magnifying glass effect that follows the m"
},
{
"path": "src/fastapi_voyager/web/src/main.js",
"chars": 106,
"preview": "import { createApp } from \"vue\"\nimport App from \"./App.vue\"\n\nconst app = createApp(App)\napp.mount(\"#app\")\n"
},
{
"path": "src/fastapi_voyager/web/src/store.js",
"chars": 12351,
"preview": "import { reactive } from \"vue\"\n\nconst state = reactive({\n version: \"\",\n framework_name: \"\",\n config: {\n initial_pa"
},
{
"path": "src/fastapi_voyager/web/store.js",
"chars": 17457,
"preview": "const { reactive } = window.Vue\n\nconst state = reactive({\n version: \"\",\n framework_name: \"\",\n config: {\n initial_p"
},
{
"path": "src/fastapi_voyager/web/sw.js",
"chars": 4419,
"preview": "/**\n * Service Worker for fastapi-voyager\n *\n * Provides caching for CDN and local static resources.\n * Uses version-bas"
},
{
"path": "src/fastapi_voyager/web/vite.config.js",
"chars": 783,
"preview": "import { defineConfig } from \"vite\"\nimport vue from \"@vitejs/plugin-vue\"\n\nexport default defineConfig({\n plugins: [vue("
},
{
"path": "tests/README.md",
"chars": 4117,
"preview": "# Tests Directory Structure\n\nThis directory contains all tests for fastapi-voyager.\n\n## Directory Structure\n\n```\ntests/\n"
},
{
"path": "tests/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "tests/django_ninja/__init__.py",
"chars": 156,
"preview": "\"\"\"\nDjango Ninja test examples and utilities.\n\nThis directory contains test applications and utilities specifically for "
},
{
"path": "tests/django_ninja/demo.py",
"chars": 8133,
"preview": "import os\n\nimport django\n\n# Configure Django settings before importing django-ninja\nos.environ.setdefault('DJANGO_SETTIN"
},
{
"path": "tests/django_ninja/embedding.py",
"chars": 2792,
"preview": "\"\"\"\nDjango Ninja embedding example for fastapi-voyager.\n\nThis module demonstrates how to integrate voyager with a Django"
},
{
"path": "tests/django_ninja/settings.py",
"chars": 969,
"preview": "\"\"\"\nMinimal Django settings for django-ninja test app.\n\"\"\"\nfrom pathlib import Path\n\n# Build paths\nBASE_DIR = Path(__fil"
},
{
"path": "tests/django_ninja/urls.py",
"chars": 691,
"preview": "\"\"\"\nURL configuration for django-ninja test app.\n\"\"\"\nfrom django.urls import path\nfrom django.views.decorators.csrf impo"
},
{
"path": "tests/embedding_test_utils.py",
"chars": 4811,
"preview": "\"\"\"\nShared utilities for testing embedding services across different frameworks.\n\nThis module provides common test funct"
},
{
"path": "tests/fastapi/__init__.py",
"chars": 224,
"preview": "\"\"\"\nFastAPI test examples and utilities.\n\nThis directory contains test applications and utilities specifically for FastA"
},
{
"path": "tests/fastapi/demo.py",
"chars": 7480,
"preview": "from contextlib import asynccontextmanager\nfrom dataclasses import dataclass\nfrom typing import Annotated, Generic, Opti"
},
{
"path": "tests/fastapi/demo_anno.py",
"chars": 1673,
"preview": "from __future__ import annotations\n\nfrom typing import Annotated\n\nfrom fastapi import FastAPI\nfrom pydantic import BaseM"
},
{
"path": "tests/fastapi/embedding.py",
"chars": 535,
"preview": "from fastapi_voyager import create_voyager\n\n# from tests.fastapi.demo_anno import app\nfrom tests.fastapi.demo import app"
},
{
"path": "tests/litestar/__init__.py",
"chars": 148,
"preview": "\"\"\"\nLitestar test examples and utilities.\n\nThis directory contains test applications and utilities specifically for Lite"
},
{
"path": "tests/litestar/demo.py",
"chars": 7737,
"preview": "from dataclasses import dataclass\nfrom typing import Annotated, Generic, Optional, TypeVar\n\nfrom litestar import Control"
},
{
"path": "tests/litestar/embedding.py",
"chars": 1546,
"preview": "\"\"\"\nLitestar embedding example for fastapi-voyager.\n\nThis module demonstrates how to integrate voyager with a Litestar a"
},
{
"path": "tests/service/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "tests/service/schema/__init__.py",
"chars": 763,
"preview": "from .schema import (\n Attribute,\n AttributeValue,\n Brand,\n Category,\n Coupon,\n CouponUsage,\n Inven"
},
{
"path": "tests/service/schema/base_entity.py",
"chars": 68,
"preview": "from pydantic_resolve import base_entity\n\nBaseEntity = base_entity()"
},
{
"path": "tests/service/schema/db.py",
"chars": 597,
"preview": "\"\"\"\nSQLAlchemy async engine and session factory for test schema.\nUses SQLite in-memory for testing/demo purposes.\n\"\"\"\nfr"
},
{
"path": "tests/service/schema/dto/__init__.py",
"chars": 771,
"preview": "from .attribute import Attribute, AttributeValue\nfrom .inventory import Inventory, Warehouse\nfrom .marketing import Coup"
},
{
"path": "tests/service/schema/dto/attribute.py",
"chars": 511,
"preview": "\"\"\"\nAttribute and AttributeValue DTOs.\n\"\"\"\nfrom pydantic import BaseModel, ConfigDict, Field\n\n\nclass Attribute(BaseModel"
},
{
"path": "tests/service/schema/dto/inventory.py",
"chars": 609,
"preview": "\"\"\"\nWarehouse and Inventory DTOs.\n\"\"\"\nfrom pydantic import BaseModel, ConfigDict, Field\n\n\nclass Warehouse(BaseModel):\n "
},
{
"path": "tests/service/schema/dto/marketing.py",
"chars": 725,
"preview": "\"\"\"\nCoupon and CouponUsage DTOs.\n\"\"\"\nfrom pydantic import BaseModel, ConfigDict, Field\n\n\nclass Coupon(BaseModel):\n \"\""
},
{
"path": "tests/service/schema/dto/order.py",
"chars": 1456,
"preview": "\"\"\"\nOrder, OrderItem, Payment, Refund DTOs.\n\"\"\"\nfrom typing import Optional\n\nfrom pydantic import BaseModel, ConfigDict,"
},
{
"path": "tests/service/schema/dto/product.py",
"chars": 2146,
"preview": "\"\"\"\nProduct, ProductVariant, ProductImage, Brand, Category, Review DTOs.\n\"\"\"\nfrom typing import Optional\n\nfrom pydantic "
},
{
"path": "tests/service/schema/dto/shipment.py",
"chars": 810,
"preview": "\"\"\"\nShipment and ShipmentItem DTOs.\n\"\"\"\nfrom typing import Optional\n\nfrom pydantic import BaseModel, ConfigDict, Field\n\n"
},
{
"path": "tests/service/schema/dto/store.py",
"chars": 346,
"preview": "\"\"\"\nStore DTO.\n\"\"\"\nfrom typing import Optional\n\nfrom pydantic import BaseModel, ConfigDict, Field\n\n\nclass Store(BaseMode"
},
{
"path": "tests/service/schema/dto/tag.py",
"chars": 240,
"preview": "\"\"\"\nTag DTO.\n\"\"\"\nfrom pydantic import BaseModel, ConfigDict, Field\n\n\nclass Tag(BaseModel):\n \"\"\"标签\"\"\"\n model_config"
},
{
"path": "tests/service/schema/dto/user.py",
"chars": 827,
"preview": "\"\"\"\nUser and UserAddress DTOs.\n\"\"\"\nfrom typing import Optional\n\nfrom pydantic import BaseModel, ConfigDict, Field\n\n\nclas"
},
{
"path": "tests/service/schema/extra.py",
"chars": 106,
"preview": "from pydantic import BaseModel\n\n\nclass B(BaseModel):\n id: int\n\nclass A(BaseModel):\n id: int\n b: B"
},
{
"path": "tests/service/schema/orm/__init__.py",
"chars": 1089,
"preview": "from .attribute import AttributeOrm, AttributeValueOrm\nfrom .inventory import InventoryOrm, WarehouseOrm\nfrom .marketing"
},
{
"path": "tests/service/schema/orm/attribute.py",
"chars": 995,
"preview": "\"\"\"\nAttribute and AttributeValue ORM models.\n\"\"\"\nfrom sqlalchemy import ForeignKey, Integer, String\nfrom sqlalchemy.orm "
},
{
"path": "tests/service/schema/orm/inventory.py",
"chars": 1149,
"preview": "\"\"\"\nWarehouse and Inventory ORM models.\n\"\"\"\nfrom sqlalchemy import ForeignKey, Integer, String\nfrom sqlalchemy.orm impor"
},
{
"path": "tests/service/schema/orm/marketing.py",
"chars": 1230,
"preview": "\"\"\"\nCoupon and CouponUsage ORM models.\n\"\"\"\nfrom sqlalchemy import Float, ForeignKey, Integer, String\nfrom sqlalchemy.orm"
},
{
"path": "tests/service/schema/orm/order.py",
"chars": 2446,
"preview": "\"\"\"\nOrder, OrderItem, Payment, Refund ORM models.\n\"\"\"\nfrom sqlalchemy import Float, ForeignKey, Integer, String, Text\nfr"
},
{
"path": "tests/service/schema/orm/product.py",
"chars": 4334,
"preview": "\"\"\"\nProduct, ProductVariant, ProductImage, Brand, Category ORM models.\n\"\"\"\nfrom sqlalchemy import Float, ForeignKey, Int"
},
{
"path": "tests/service/schema/orm/shipment.py",
"chars": 1352,
"preview": "\"\"\"\nShipment and ShipmentItem ORM models.\n\"\"\"\nfrom sqlalchemy import ForeignKey, Integer, String\nfrom sqlalchemy.orm imp"
},
{
"path": "tests/service/schema/orm/store.py",
"chars": 711,
"preview": "\"\"\"\nStore ORM model.\n\"\"\"\nfrom sqlalchemy import Integer, String\nfrom sqlalchemy.orm import Mapped, mapped_column, relati"
},
{
"path": "tests/service/schema/orm/tables.py",
"chars": 1023,
"preview": "\"\"\"\nM:N association tables for e-commerce schema.\n\"\"\"\nfrom sqlalchemy import Column, ForeignKey, Integer, Table\n\nfrom .."
},
{
"path": "tests/service/schema/orm/user.py",
"chars": 1529,
"preview": "\"\"\"\nUser and UserAddress ORM models.\n\"\"\"\nfrom sqlalchemy import Boolean, ForeignKey, Integer, String\nfrom sqlalchemy.orm"
},
{
"path": "tests/service/schema/schema.py",
"chars": 28840,
"preview": "\"\"\"\n电商系统实体定义 - 用于 GraphQL 和 REST API 演示\n使用 SQLAlchemy ORM + build_relationship 自动构建 relationships 和 loaders\n\"\"\"\n\nfrom ty"
},
{
"path": "tests/test_adapter_interface.py",
"chars": 6128,
"preview": "\"\"\"\nTest adapter interface design to ensure clean and consistent API.\n\nThis test validates:\n1. Adapters don't have get_m"
},
{
"path": "tests/test_analysis.py",
"chars": 2417,
"preview": "\nfrom fastapi import FastAPI\nfrom pydantic import BaseModel\n\nfrom fastapi_voyager.voyager import Voyager\n\n\ndef test_anal"
},
{
"path": "tests/test_embedding_django_ninja.py",
"chars": 2922,
"preview": "\"\"\"\nTest Django Ninja embedding service with /dot endpoint.\n\nThis test starts the Django Ninja embedding service and val"
},
{
"path": "tests/test_embedding_fastapi.py",
"chars": 2780,
"preview": "\"\"\"\nTest FastAPI embedding service with /dot endpoint.\n\nThis test starts the FastAPI embedding service and validates the"
},
{
"path": "tests/test_embedding_litestar.py",
"chars": 2870,
"preview": "\"\"\"\nTest Litestar embedding service with /dot endpoint.\n\nThis test starts the Litestar embedding service and validates t"
},
{
"path": "tests/test_filter.py",
"chars": 4236,
"preview": "from fastapi_voyager.filter import filter_subgraph_by_module_prefix\nfrom fastapi_voyager.type import PK, Link, Route, Sc"
},
{
"path": "tests/test_generic.py",
"chars": 747,
"preview": "import sys\nfrom typing import Generic, TypeVar\n\nfrom pydantic import BaseModel\n\nfrom fastapi_voyager.type_helper import "
},
{
"path": "tests/test_import.py",
"chars": 92,
"preview": "def test_import():\n import fastapi_voyager as pkg\n assert hasattr(pkg, \"__version__\")\n"
},
{
"path": "tests/test_module.py",
"chars": 3504,
"preview": "from fastapi_voyager.module import build_module_schema_tree\nfrom fastapi_voyager.type import SchemaNode\n\n\ndef _sn(id: st"
},
{
"path": "tests/test_resolve_util_impl.py",
"chars": 2350,
"preview": "from typing import Annotated\n\nfrom pydantic import BaseModel\nfrom pydantic_resolve import Collector\nfrom pydantic_resolv"
},
{
"path": "tests/test_type_helper.py",
"chars": 2400,
"preview": "import sys\nfrom typing import Annotated\n\nimport pytest\n\nfrom fastapi_voyager.type_helper import get_core_types\n\n\ndef tes"
}
]
About this extraction
This page contains the full source code of the allmonday/fastapi-voyager GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 136 files (513.5 KB), approximately 129.9k tokens, and a symbol index with 571 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.