Full Code of allmonday/fastapi-voyager for AI

main 7db1a94cfae5 cached
136 files
513.5 KB
129.9k tokens
571 symbols
1 requests
Download .txt
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
================================================
[![pypi](https://img.shields.io/pypi/v/fastapi-voyager.svg)](https://pypi.python.org/pypi/fastapi-voyager)
![Python Versions](https://img.shields.io/pypi/pyversions/fastapi-voyager)
[![PyPI Downloads](https://static.pepy.tech/badge/fastapi-voyager/month)](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
Download .txt
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
Download .txt
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": "[![pypi](https://img.shields.io/pypi/v/fastapi-voyager.svg)](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.

Copied to clipboard!