fastapi-voyager 0.12.1__tar.gz → 0.12.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/PKG-INFO +5 -7
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/README.md +4 -5
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/docs/changelog.md +4 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/docs/idea.md +2 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/pyproject.toml +0 -1
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/__init__.py +1 -2
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/cli.py +3 -3
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/filter.py +6 -5
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/module.py +11 -10
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/render.py +17 -8
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/server.py +36 -17
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/type.py +5 -3
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/type_helper.py +13 -7
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/version.py +1 -1
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/voyager.py +14 -10
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/index.html +2 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/tests/demo.py +11 -9
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/tests/demo_anno.py +16 -23
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/tests/programatic.py +4 -2
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/tests/service/schema.py +4 -2
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/tests/test_analysis.py +6 -5
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/tests/test_filter.py +4 -2
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/tests/test_generic.py +10 -2
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/tests/test_type_helper.py +15 -9
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/.github/workflows/publish.yml +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/.gitignore +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/.python-version +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/LICENSE +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/release.md +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/component/render-graph.js +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/component/route-code-display.js +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/component/schema-code-display.js +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/component/schema-field-filter.js +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/graph-ui.js +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/graphviz.svg.css +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/graphviz.svg.js +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/icon/android-chrome-192x192.png +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/icon/android-chrome-512x512.png +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/icon/apple-touch-icon.png +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/icon/favicon-16x16.png +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/icon/favicon-32x32.png +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/icon/favicon.ico +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/icon/site.webmanifest +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/quasar.min.css +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/quasar.min.js +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/vue-main.js +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/tests/__init__.py +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/tests/service/__init__.py +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/tests/test_import.py +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/tests/test_module.py +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/uv.lock +0 -0
- {fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/voyager.jpg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-voyager
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.3
|
|
4
4
|
Summary: Visualize FastAPI application's routing tree and dependencies
|
|
5
5
|
Project-URL: Homepage, https://github.com/allmonday/fastapi-voyager
|
|
6
6
|
Project-URL: Source, https://github.com/allmonday/fastapi-voyager
|
|
@@ -12,7 +12,6 @@ Classifier: Framework :: FastAPI
|
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
13
|
Classifier: License :: OSI Approved :: MIT License
|
|
14
14
|
Classifier: Programming Language :: Python :: 3
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.10
|
|
17
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
18
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
@@ -40,6 +39,10 @@ Visualize your FastAPI endpoints, and explore them interactively.
|
|
|
40
39
|
|
|
41
40
|
<img width="1600" height="986" alt="image" src="https://github.com/user-attachments/assets/8829cda0-f42d-4c84-be2f-b019bb5fe7e1" />
|
|
42
41
|
|
|
42
|
+
## Plan & Raodmap
|
|
43
|
+
- [ideas](./docs/idea.md)
|
|
44
|
+
- [changelog & roadmap](./docs/changelog.md)
|
|
45
|
+
|
|
43
46
|
## Installation
|
|
44
47
|
|
|
45
48
|
```bash
|
|
@@ -176,8 +179,3 @@ backend:
|
|
|
176
179
|
- `voyager.py`: main entry
|
|
177
180
|
- `render.py`: generate dot file
|
|
178
181
|
- `server.py`: serve mode
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
## Plan & Raodmap
|
|
182
|
-
- [ideas](./docs/idea.md)
|
|
183
|
-
- [changelog & roadmap](./docs/changelog.md)
|
|
@@ -11,6 +11,10 @@ Visualize your FastAPI endpoints, and explore them interactively.
|
|
|
11
11
|
|
|
12
12
|
<img width="1600" height="986" alt="image" src="https://github.com/user-attachments/assets/8829cda0-f42d-4c84-be2f-b019bb5fe7e1" />
|
|
13
13
|
|
|
14
|
+
## Plan & Raodmap
|
|
15
|
+
- [ideas](./docs/idea.md)
|
|
16
|
+
- [changelog & roadmap](./docs/changelog.md)
|
|
17
|
+
|
|
14
18
|
## Installation
|
|
15
19
|
|
|
16
20
|
```bash
|
|
@@ -147,8 +151,3 @@ backend:
|
|
|
147
151
|
- `voyager.py`: main entry
|
|
148
152
|
- `render.py`: generate dot file
|
|
149
153
|
- `server.py`: serve mode
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
## Plan & Raodmap
|
|
153
|
-
- [ideas](./docs/idea.md)
|
|
154
|
-
- [changelog & roadmap](./docs/changelog.md)
|
|
@@ -106,6 +106,10 @@
|
|
|
106
106
|
- [x] optimize dbclick style
|
|
107
107
|
- [x] persist the tag/ route in url
|
|
108
108
|
- 0.12.2
|
|
109
|
+
- [x] add google analytics
|
|
110
|
+
- 0.12.3
|
|
111
|
+
- [x] fix bug in `update_forward_refs`, class should not be skipped if it's parent class has been visited.
|
|
112
|
+
- 0.12.4
|
|
109
113
|
- [ ] search tag/ route
|
|
110
114
|
- [ ] refactor render.py
|
|
111
115
|
- [ ] reorg: move variable into reactive in vue-main.js
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
- [ ] support Google analysis config
|
|
13
13
|
- [ ] sort field name in nodes (only table inside right panel), pending
|
|
14
14
|
- [ ] set max limit for fields in nodes
|
|
15
|
+
- [ ] minimap
|
|
16
|
+
- ref: https://observablehq.com/@rabelais/d3-js-zoom-minimap
|
|
15
17
|
|
|
16
18
|
## in analysis
|
|
17
19
|
- [ ] upgrade network algorithm (optional)
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Utilities to introspect a FastAPI application and visualize its routing tree.
|
|
4
4
|
"""
|
|
5
|
-
from .version import __version__ # noqa: F401
|
|
6
|
-
|
|
7
5
|
from .server import create_voyager
|
|
6
|
+
from .version import __version__ # noqa: F401
|
|
8
7
|
|
|
9
8
|
__all__ = ["__version__", "create_voyager"]
|
|
@@ -5,9 +5,9 @@ import importlib.util
|
|
|
5
5
|
import logging
|
|
6
6
|
import os
|
|
7
7
|
import sys
|
|
8
|
-
from typing import Optional
|
|
9
8
|
|
|
10
9
|
from fastapi import FastAPI
|
|
10
|
+
|
|
11
11
|
from fastapi_voyager import server as viz_server
|
|
12
12
|
from fastapi_voyager.version import __version__
|
|
13
13
|
from fastapi_voyager.voyager import Voyager
|
|
@@ -15,7 +15,7 @@ from fastapi_voyager.voyager import Voyager
|
|
|
15
15
|
logger = logging.getLogger(__name__)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
def load_fastapi_app_from_file(module_path: str, app_name: str = "app") ->
|
|
18
|
+
def load_fastapi_app_from_file(module_path: str, app_name: str = "app") -> FastAPI | None:
|
|
19
19
|
"""Load FastAPI app from a Python module file."""
|
|
20
20
|
try:
|
|
21
21
|
# Convert relative path to absolute path
|
|
@@ -47,7 +47,7 @@ def load_fastapi_app_from_file(module_path: str, app_name: str = "app") -> Optio
|
|
|
47
47
|
return None
|
|
48
48
|
|
|
49
49
|
|
|
50
|
-
def load_fastapi_app_from_module(module_name: str, app_name: str = "app") ->
|
|
50
|
+
def load_fastapi_app_from_module(module_name: str, app_name: str = "app") -> FastAPI | None:
|
|
51
51
|
"""Load FastAPI app from a Python module name."""
|
|
52
52
|
try:
|
|
53
53
|
# Temporarily add the current working directory to sys.path
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
from collections import deque
|
|
3
|
-
|
|
4
|
-
from fastapi_voyager.type import
|
|
4
|
+
|
|
5
|
+
from fastapi_voyager.type import PK, Link, Route, SchemaNode, Tag
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
def filter_graph(
|
|
@@ -13,7 +14,7 @@ def filter_graph(
|
|
|
13
14
|
nodes: list[SchemaNode],
|
|
14
15
|
links: list[Link],
|
|
15
16
|
node_set: dict[str, SchemaNode],
|
|
16
|
-
) ->
|
|
17
|
+
) -> tuple[list[Tag], list[Route], list[SchemaNode], list[Link]]:
|
|
17
18
|
"""Filter tags, routes, schema nodes and links based on a target schema and optional field.
|
|
18
19
|
|
|
19
20
|
Behaviour summary (mirrors previous Analytics.filter_nodes_and_schemas_based_on_schemas):
|
|
@@ -113,7 +114,7 @@ def filter_subgraph_by_module_prefix(
|
|
|
113
114
|
links: list[Link],
|
|
114
115
|
nodes: list[SchemaNode],
|
|
115
116
|
module_prefix: str
|
|
116
|
-
) ->
|
|
117
|
+
) -> tuple[list[Tag], list[Route], list[SchemaNode], list[Link]]:
|
|
117
118
|
"""Collapse schema graph so routes link directly to nodes whose module matches ``module_prefix``.
|
|
118
119
|
|
|
119
120
|
The routine keeps tag→route links untouched, prunes schema nodes whose module does not start
|
|
@@ -202,7 +203,7 @@ def filter_subgraph_from_tag_to_schema_by_module_prefix(
|
|
|
202
203
|
links: list[Link],
|
|
203
204
|
nodes: list[SchemaNode],
|
|
204
205
|
module_prefix: str
|
|
205
|
-
) ->
|
|
206
|
+
) -> tuple[list[Tag], list[Route], list[SchemaNode], list[Link]]:
|
|
206
207
|
"""Collapse schema graph so routes link directly to nodes whose module matches ``module_prefix``.
|
|
207
208
|
|
|
208
209
|
The routine keeps tag→route links untouched, prunes schema nodes whose module does not start
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
from
|
|
2
|
-
from
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any, TypeVar
|
|
3
3
|
|
|
4
|
+
from fastapi_voyager.type import ModuleNode, ModuleRoute, Route, SchemaNode
|
|
4
5
|
|
|
5
6
|
N = TypeVar('N') # Node type: ModuleNode or ModuleRoute
|
|
6
7
|
I = TypeVar('I') # Item type: SchemaNode or Route
|
|
@@ -10,7 +11,7 @@ def _build_module_tree(
|
|
|
10
11
|
items: list[I],
|
|
11
12
|
*,
|
|
12
13
|
get_module_path: Callable[[I], str | None],
|
|
13
|
-
NodeClass:
|
|
14
|
+
NodeClass: type[N],
|
|
14
15
|
item_list_attr: str,
|
|
15
16
|
) -> list[N]:
|
|
16
17
|
"""
|
|
@@ -34,13 +35,13 @@ def _build_module_tree(
|
|
|
34
35
|
return NodeClass(**kwargs) # type: ignore[arg-type]
|
|
35
36
|
|
|
36
37
|
def get_or_create(child_name: str, parent: N) -> N:
|
|
37
|
-
for m in
|
|
38
|
+
for m in parent.modules:
|
|
38
39
|
if m.name == child_name:
|
|
39
40
|
return m
|
|
40
|
-
parent_full =
|
|
41
|
+
parent_full = parent.fullname
|
|
41
42
|
fullname = child_name if not parent_full or parent_full == "__root__" else f"{parent_full}.{child_name}"
|
|
42
43
|
new_node = make_node(child_name, fullname)
|
|
43
|
-
|
|
44
|
+
parent.modules.append(new_node)
|
|
44
45
|
return new_node
|
|
45
46
|
|
|
46
47
|
# Build the tree
|
|
@@ -65,13 +66,13 @@ def _build_module_tree(
|
|
|
65
66
|
|
|
66
67
|
# Collapse linear chains: no items on node and exactly one child module
|
|
67
68
|
def collapse(node: N) -> None:
|
|
68
|
-
while len(
|
|
69
|
-
child =
|
|
69
|
+
while len(node.modules) == 1 and len(getattr(node, item_list_attr)) == 0:
|
|
70
|
+
child = node.modules[0]
|
|
70
71
|
node.name = f"{node.name}.{child.name}"
|
|
71
72
|
node.fullname = child.fullname
|
|
72
73
|
setattr(node, item_list_attr, getattr(child, item_list_attr))
|
|
73
|
-
|
|
74
|
-
for m in
|
|
74
|
+
node.modules = child.modules
|
|
75
|
+
for m in node.modules:
|
|
75
76
|
collapse(m)
|
|
76
77
|
|
|
77
78
|
for top in result:
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
-
from typing import Optional
|
|
2
|
-
from fastapi_voyager.type import SchemaNode, ModuleNode, Link, Tag, Route, FieldType, PK, ModuleRoute
|
|
3
|
-
from fastapi_voyager.module import build_module_schema_tree, build_module_route_tree
|
|
4
1
|
from logging import getLogger
|
|
5
2
|
|
|
3
|
+
from fastapi_voyager.module import build_module_route_tree, build_module_schema_tree
|
|
4
|
+
from fastapi_voyager.type import (
|
|
5
|
+
PK,
|
|
6
|
+
FieldType,
|
|
7
|
+
Link,
|
|
8
|
+
ModuleNode,
|
|
9
|
+
ModuleRoute,
|
|
10
|
+
Route,
|
|
11
|
+
SchemaNode,
|
|
12
|
+
Tag,
|
|
13
|
+
)
|
|
14
|
+
|
|
6
15
|
logger = getLogger(__name__)
|
|
7
16
|
|
|
8
17
|
|
|
@@ -23,7 +32,7 @@ class Renderer:
|
|
|
23
32
|
logger.info(f'show_module: {self.show_module}')
|
|
24
33
|
logger.info(f'module_color: {self.module_color}')
|
|
25
34
|
|
|
26
|
-
def render_schema_label(self, node: SchemaNode, color:
|
|
35
|
+
def render_schema_label(self, node: SchemaNode, color: str | None=None) -> str:
|
|
27
36
|
has_base_fields = any(f.from_base for f in node.fields)
|
|
28
37
|
fields = [n for n in node.fields if n.from_base is False]
|
|
29
38
|
|
|
@@ -74,7 +83,7 @@ class Renderer:
|
|
|
74
83
|
raise ValueError(f'Unknown link type: {link.type}')
|
|
75
84
|
|
|
76
85
|
def render_module_schema_content(self, nodes: list[SchemaNode]) -> str:
|
|
77
|
-
def render_node(node: SchemaNode, color:
|
|
86
|
+
def render_node(node: SchemaNode, color: str | None=None) -> str:
|
|
78
87
|
return f'''
|
|
79
88
|
"{node.id}" [
|
|
80
89
|
label = {self.render_schema_label(node, color)}
|
|
@@ -82,8 +91,8 @@ class Renderer:
|
|
|
82
91
|
margin="0.5,0.1"
|
|
83
92
|
];'''
|
|
84
93
|
|
|
85
|
-
def render_module_schema(mod: ModuleNode, inherit_color:
|
|
86
|
-
color:
|
|
94
|
+
def render_module_schema(mod: ModuleNode, inherit_color: str | None=None, show_cluster:bool=True) -> str:
|
|
95
|
+
color: str | None = inherit_color
|
|
87
96
|
|
|
88
97
|
# recursively vist module from short to long: 'a', 'a.b', 'a.b.c'
|
|
89
98
|
# color_flag: {'a', 'a.b.c'}
|
|
@@ -109,7 +118,7 @@ class Renderer:
|
|
|
109
118
|
label = " {mod.name}"
|
|
110
119
|
labeljust = "l"
|
|
111
120
|
{(f'pencolor = "{color}"' if color else 'pencolor="#ccc"')}
|
|
112
|
-
{(
|
|
121
|
+
{('penwidth = 3' if color else 'penwidth=""')}
|
|
113
122
|
{inner_nodes_str}
|
|
114
123
|
{child_str}
|
|
115
124
|
}}'''
|
|
@@ -1,20 +1,37 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
|
-
from typing import
|
|
3
|
-
|
|
4
|
-
from
|
|
5
|
-
from
|
|
6
|
-
from fastapi.responses import HTMLResponse, PlainTextResponse, JSONResponse
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, FastAPI
|
|
5
|
+
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
|
7
6
|
from fastapi.staticfiles import StaticFiles
|
|
8
|
-
from
|
|
9
|
-
from
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from starlette.middleware.gzip import GZipMiddleware
|
|
9
|
+
|
|
10
10
|
from fastapi_voyager.render import Renderer
|
|
11
|
+
from fastapi_voyager.type import CoreData, SchemaNode, Tag
|
|
11
12
|
from fastapi_voyager.type_helper import get_source, get_vscode_link
|
|
12
13
|
from fastapi_voyager.version import __version__
|
|
13
|
-
|
|
14
|
+
from fastapi_voyager.voyager import Voyager
|
|
14
15
|
|
|
15
16
|
WEB_DIR = Path(__file__).parent / "web"
|
|
16
17
|
WEB_DIR.mkdir(exist_ok=True)
|
|
17
18
|
|
|
19
|
+
GA_PLACEHOLDER = "<!-- GA_SNIPPET -->"
|
|
20
|
+
|
|
21
|
+
def _build_ga_snippet(ga_id: str | None) -> str:
|
|
22
|
+
if not ga_id:
|
|
23
|
+
return ""
|
|
24
|
+
|
|
25
|
+
return f""" <script async src="https://www.googletagmanager.com/gtag/js?id={ga_id}"></script>
|
|
26
|
+
<script>
|
|
27
|
+
window.dataLayer = window.dataLayer || [];
|
|
28
|
+
function gtag(){{dataLayer.push(arguments);}}
|
|
29
|
+
gtag('js', new Date());
|
|
30
|
+
|
|
31
|
+
gtag('config', '{ga_id}');
|
|
32
|
+
</script>
|
|
33
|
+
"""
|
|
34
|
+
|
|
18
35
|
INITIAL_PAGE_POLICY = Literal['first', 'full', 'empty']
|
|
19
36
|
|
|
20
37
|
class OptionParam(BaseModel):
|
|
@@ -24,13 +41,13 @@ class OptionParam(BaseModel):
|
|
|
24
41
|
enable_brief_mode: bool
|
|
25
42
|
version: str
|
|
26
43
|
initial_page_policy: INITIAL_PAGE_POLICY
|
|
27
|
-
swagger_url:
|
|
44
|
+
swagger_url: str | None = None
|
|
28
45
|
|
|
29
46
|
class Payload(BaseModel):
|
|
30
|
-
tags:
|
|
31
|
-
schema_name:
|
|
32
|
-
schema_field:
|
|
33
|
-
route_name:
|
|
47
|
+
tags: list[str] | None = None
|
|
48
|
+
schema_name: str | None = None
|
|
49
|
+
schema_field: str | None = None
|
|
50
|
+
route_name: str | None = None
|
|
34
51
|
show_fields: str = 'object'
|
|
35
52
|
show_meta: bool = False
|
|
36
53
|
brief: bool = False
|
|
@@ -42,10 +59,11 @@ def create_voyager(
|
|
|
42
59
|
target_app: FastAPI,
|
|
43
60
|
module_color: dict[str, str] | None = None,
|
|
44
61
|
gzip_minimum_size: int | None = 500,
|
|
45
|
-
module_prefix:
|
|
46
|
-
swagger_url:
|
|
47
|
-
online_repo_url:
|
|
62
|
+
module_prefix: str | None = None,
|
|
63
|
+
swagger_url: str | None = None,
|
|
64
|
+
online_repo_url: str | None = None,
|
|
48
65
|
initial_page_policy: INITIAL_PAGE_POLICY = 'first',
|
|
66
|
+
ga_id: str | None = None,
|
|
49
67
|
) -> FastAPI:
|
|
50
68
|
router = APIRouter(tags=['fastapi-voyager'])
|
|
51
69
|
|
|
@@ -116,7 +134,8 @@ def create_voyager(
|
|
|
116
134
|
def index():
|
|
117
135
|
index_file = WEB_DIR / "index.html"
|
|
118
136
|
if index_file.exists():
|
|
119
|
-
|
|
137
|
+
content = index_file.read_text(encoding="utf-8")
|
|
138
|
+
return content.replace(GA_PLACEHOLDER, _build_ga_snippet(ga_id))
|
|
120
139
|
# fallback simple page if index.html missing
|
|
121
140
|
return """
|
|
122
141
|
<!doctype html>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from dataclasses import field
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
2
4
|
from pydantic.dataclasses import dataclass
|
|
3
|
-
|
|
5
|
+
|
|
4
6
|
|
|
5
7
|
@dataclass
|
|
6
8
|
class NodeBase:
|
|
@@ -76,5 +78,5 @@ class CoreData:
|
|
|
76
78
|
nodes: list[SchemaNode]
|
|
77
79
|
links: list[Link]
|
|
78
80
|
show_fields: FieldType
|
|
79
|
-
module_color:
|
|
80
|
-
schema:
|
|
81
|
+
module_color: dict[str, str] | None = None
|
|
82
|
+
schema: str | None = None
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import inspect
|
|
2
2
|
import logging
|
|
3
3
|
import os
|
|
4
|
-
from pydantic import BaseModel
|
|
5
|
-
from typing import get_origin, get_args, Union, Annotated, Any, Type, Generic, Optional
|
|
6
|
-
from fastapi_voyager.type import FieldInfo
|
|
7
4
|
from types import UnionType
|
|
5
|
+
from typing import Annotated, Any, Generic, Union, get_args, get_origin
|
|
6
|
+
|
|
8
7
|
import pydantic_resolve.constant as const
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from fastapi_voyager.type import FieldInfo
|
|
9
11
|
|
|
10
12
|
logger = logging.getLogger(__name__)
|
|
11
13
|
|
|
@@ -185,7 +187,7 @@ def get_pydantic_fields(schema: type[BaseModel], bases_fields: set[str]) -> list
|
|
|
185
187
|
return fields
|
|
186
188
|
|
|
187
189
|
|
|
188
|
-
def get_vscode_link(kls, online_repo_url:
|
|
190
|
+
def get_vscode_link(kls, online_repo_url: str | None = None) -> str:
|
|
189
191
|
"""Build a VSCode deep link to the class definition.
|
|
190
192
|
|
|
191
193
|
Priority:
|
|
@@ -241,7 +243,7 @@ def safe_issubclass(kls, target_kls):
|
|
|
241
243
|
|
|
242
244
|
def update_forward_refs(kls):
|
|
243
245
|
# TODO: refactor
|
|
244
|
-
def update_pydantic_forward_refs(pydantic_kls:
|
|
246
|
+
def update_pydantic_forward_refs(pydantic_kls: type[BaseModel]):
|
|
245
247
|
"""
|
|
246
248
|
recursively update refs.
|
|
247
249
|
"""
|
|
@@ -254,7 +256,11 @@ def update_forward_refs(kls):
|
|
|
254
256
|
update_forward_refs(field.annotation)
|
|
255
257
|
|
|
256
258
|
for shelled_type in get_core_types(kls):
|
|
257
|
-
if
|
|
259
|
+
# Only treat as updated if the flag is set on the class itself, not via inheritance
|
|
260
|
+
|
|
261
|
+
local_attrs = getattr(shelled_type, '__dict__', {})
|
|
262
|
+
if local_attrs.get(const.PYDANTIC_FORWARD_REF_UPDATED, False):
|
|
263
|
+
logger.debug(shelled_type.__qualname__, 'visited')
|
|
258
264
|
continue
|
|
259
265
|
if safe_issubclass(shelled_type, BaseModel):
|
|
260
266
|
update_pydantic_forward_refs(shelled_type)
|
|
@@ -287,7 +293,7 @@ def is_non_pydantic_type(tp):
|
|
|
287
293
|
return True
|
|
288
294
|
|
|
289
295
|
if __name__ == "__main__":
|
|
290
|
-
from tests.demo_anno import
|
|
296
|
+
from tests.demo_anno import PageOverall, PageSprint
|
|
291
297
|
|
|
292
298
|
update_forward_refs(PageOverall)
|
|
293
299
|
update_forward_refs(PageSprint)
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
__all__ = ["__version__"]
|
|
2
|
-
__version__ = "0.12.
|
|
2
|
+
__version__ = "0.12.3"
|
|
@@ -1,21 +1,25 @@
|
|
|
1
|
-
|
|
1
|
+
|
|
2
|
+
import pydantic_resolve.constant as const
|
|
2
3
|
from fastapi import FastAPI, routing
|
|
3
|
-
from
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from fastapi_voyager.filter import (
|
|
7
|
+
filter_graph,
|
|
8
|
+
filter_subgraph_by_module_prefix,
|
|
9
|
+
filter_subgraph_from_tag_to_schema_by_module_prefix,
|
|
10
|
+
)
|
|
11
|
+
from fastapi_voyager.render import Renderer
|
|
12
|
+
from fastapi_voyager.type import PK, CoreData, FieldType, Link, LinkType, Route, SchemaNode, Tag
|
|
4
13
|
from fastapi_voyager.type_helper import (
|
|
5
|
-
get_core_types,
|
|
6
14
|
full_class_name,
|
|
7
15
|
get_bases_fields,
|
|
8
|
-
|
|
16
|
+
get_core_types,
|
|
9
17
|
get_pydantic_fields,
|
|
10
18
|
get_type_name,
|
|
19
|
+
is_inheritance_of_pydantic_base,
|
|
20
|
+
is_non_pydantic_type,
|
|
11
21
|
update_forward_refs,
|
|
12
|
-
is_non_pydantic_type
|
|
13
22
|
)
|
|
14
|
-
from pydantic import BaseModel
|
|
15
|
-
from fastapi_voyager.type import Route, SchemaNode, Link, Tag, LinkType, FieldType, PK, CoreData
|
|
16
|
-
from fastapi_voyager.filter import filter_graph, filter_subgraph_from_tag_to_schema_by_module_prefix, filter_subgraph_by_module_prefix
|
|
17
|
-
from fastapi_voyager.render import Renderer
|
|
18
|
-
import pydantic_resolve.constant as const
|
|
19
23
|
|
|
20
24
|
|
|
21
25
|
class Voyager:
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
from
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Generic, TypeVar
|
|
3
|
+
|
|
2
4
|
from fastapi import FastAPI
|
|
3
|
-
from
|
|
4
|
-
from pydantic_resolve import
|
|
5
|
-
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
from pydantic_resolve import Resolver, ensure_subset
|
|
7
|
+
|
|
6
8
|
import tests.service.schema as serv
|
|
7
|
-
from
|
|
9
|
+
from tests.service.schema import A, Story, Task
|
|
8
10
|
|
|
9
11
|
app = FastAPI(title="Demo API", description="A demo FastAPI application for router visualization")
|
|
10
12
|
|
|
@@ -31,7 +33,7 @@ class TaskB(Task):
|
|
|
31
33
|
|
|
32
34
|
type TaskUnion = TaskA | TaskB
|
|
33
35
|
class PageTask(Task):
|
|
34
|
-
owner:
|
|
36
|
+
owner: PageMember | None
|
|
35
37
|
|
|
36
38
|
@ensure_subset(Story)
|
|
37
39
|
class PageStory(BaseModel):
|
|
@@ -44,12 +46,12 @@ class PageStory(BaseModel):
|
|
|
44
46
|
return self.title + ' (processed ........................)'
|
|
45
47
|
|
|
46
48
|
tasks: list[PageTask] = []
|
|
47
|
-
owner:
|
|
49
|
+
owner: PageMember | None = None
|
|
48
50
|
union_tasks: list[TaskUnion] = []
|
|
49
51
|
|
|
50
52
|
class PageSprint(serv.Sprint):
|
|
51
53
|
stories: list[PageStory]
|
|
52
|
-
owner:
|
|
54
|
+
owner: PageMember | None = None
|
|
53
55
|
|
|
54
56
|
class PageOverall(BaseModel):
|
|
55
57
|
sprints: list[PageSprint]
|
|
@@ -109,7 +111,7 @@ def get_page_test_3_no_response_model():
|
|
|
109
111
|
return True
|
|
110
112
|
|
|
111
113
|
@app.get("/page_test_5/", tags=['long_long_long_tag_name', 'group_b'])
|
|
112
|
-
def
|
|
114
|
+
def get_page_test_3_no_response_model_long_long_long_name():
|
|
113
115
|
return True
|
|
114
116
|
|
|
115
117
|
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
3
4
|
from fastapi import FastAPI
|
|
4
|
-
from
|
|
5
|
-
from pydantic_resolve import
|
|
6
|
-
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
from pydantic_resolve import Resolver, ensure_subset
|
|
7
|
+
|
|
7
8
|
import tests.service.schema as serv
|
|
9
|
+
from tests.service.schema import Story, Task
|
|
8
10
|
|
|
9
11
|
app = FastAPI(title="Demo API", description="A demo FastAPI application for router visualization")
|
|
10
12
|
|
|
@@ -12,11 +14,6 @@ app = FastAPI(title="Demo API", description="A demo FastAPI application for rout
|
|
|
12
14
|
def get_sprint():
|
|
13
15
|
return []
|
|
14
16
|
|
|
15
|
-
class Tree(BaseModel):
|
|
16
|
-
id: int
|
|
17
|
-
name: str
|
|
18
|
-
children: list[Tree] = []
|
|
19
|
-
|
|
20
17
|
class PageMember(serv.Member):
|
|
21
18
|
fullname: str = ''
|
|
22
19
|
def post_fullname(self):
|
|
@@ -31,15 +28,15 @@ class TaskB(Task):
|
|
|
31
28
|
|
|
32
29
|
type TaskUnion = TaskA | TaskB
|
|
33
30
|
class PageTask(Task):
|
|
34
|
-
owner:
|
|
31
|
+
owner: PageMember | None
|
|
35
32
|
|
|
36
33
|
|
|
37
34
|
class PageOverall(BaseModel):
|
|
38
|
-
sprints: list[PageSprint]
|
|
35
|
+
sprints: Annotated[list[PageSprint], Field(description="List of sprints")]
|
|
39
36
|
|
|
40
37
|
class PageSprint(serv.Sprint):
|
|
41
|
-
stories: list[PageStory]
|
|
42
|
-
owner:
|
|
38
|
+
stories: Annotated[list[PageStory], Field(description="List of stories")]
|
|
39
|
+
owner: Annotated[PageMember | None, Field(description="Owner of the sprint")] = None
|
|
43
40
|
|
|
44
41
|
|
|
45
42
|
@ensure_subset(Story)
|
|
@@ -53,7 +50,7 @@ class PageStory(BaseModel):
|
|
|
53
50
|
return self.title + ' (processed)'
|
|
54
51
|
|
|
55
52
|
tasks: list[PageTask] = []
|
|
56
|
-
owner:
|
|
53
|
+
owner: PageMember | None = None
|
|
57
54
|
union_tasks: list[TaskUnion] = []
|
|
58
55
|
|
|
59
56
|
@app.get("/page_overall", tags=['for-page'], response_model=PageOverall)
|
|
@@ -62,13 +59,9 @@ async def get_page_info():
|
|
|
62
59
|
return await Resolver().resolve(page_overall)
|
|
63
60
|
|
|
64
61
|
|
|
65
|
-
class PageStories(BaseModel):
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
@app.get("/page_info/", tags=['for-page'], response_model=PageStories)
|
|
69
|
-
def get_page_stories():
|
|
70
|
-
return {} # no implementation
|
|
62
|
+
# class PageStories(BaseModel):
|
|
63
|
+
# stories: list[PageStory]
|
|
71
64
|
|
|
72
|
-
@app.get("/
|
|
73
|
-
def
|
|
74
|
-
|
|
65
|
+
# @app.get("/page_info/", tags=['for-page'], response_model=PageStories)
|
|
66
|
+
# def get_page_stories():
|
|
67
|
+
# return {} # no implementation
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
from fastapi_voyager import create_voyager
|
|
2
|
-
from tests.
|
|
2
|
+
from tests.demo_anno import app
|
|
3
|
+
# from tests.demo import app
|
|
3
4
|
|
|
4
5
|
app.mount(
|
|
5
6
|
'/voyager',
|
|
6
7
|
create_voyager(
|
|
7
8
|
app,
|
|
8
|
-
module_color={"tests.service": "purple", "tests.demo": "#
|
|
9
|
+
module_color={"tests.service": "purple", "tests.demo": "#00b1cc", "tests": "green"},
|
|
9
10
|
module_prefix="tests.service",
|
|
10
11
|
swagger_url="/docs",
|
|
11
12
|
initial_page_policy='first',
|
|
13
|
+
ga_id='G-R64S7Q49VL',
|
|
12
14
|
online_repo_url="https://github.com/allmonday/fastapi-voyager/blob/main"))
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
1
3
|
from pydantic import BaseModel
|
|
2
|
-
|
|
4
|
+
|
|
3
5
|
|
|
4
6
|
class Sprint(BaseModel):
|
|
5
7
|
id: int
|
|
@@ -8,7 +10,7 @@ class Sprint(BaseModel):
|
|
|
8
10
|
class Story(BaseModel):
|
|
9
11
|
id: int
|
|
10
12
|
type: Literal['feature', 'bugfix']
|
|
11
|
-
dct:
|
|
13
|
+
dct: dict
|
|
12
14
|
sprint_id: int
|
|
13
15
|
title: str
|
|
14
16
|
description: str
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
from pydantic import BaseModel
|
|
1
|
+
|
|
3
2
|
from fastapi import FastAPI
|
|
4
|
-
from
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
from fastapi_voyager.voyager import Voyager
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
def test_analysis():
|
|
@@ -22,11 +23,11 @@ def test_analysis():
|
|
|
22
23
|
|
|
23
24
|
app = FastAPI()
|
|
24
25
|
|
|
25
|
-
@app.get("/test", response_model=
|
|
26
|
+
@app.get("/test", response_model=A | None)
|
|
26
27
|
def home():
|
|
27
28
|
return None
|
|
28
29
|
|
|
29
|
-
@app.get("/test2", response_model=
|
|
30
|
+
@app.get("/test2", response_model=C | None)
|
|
30
31
|
def home2():
|
|
31
32
|
return None
|
|
32
33
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from fastapi_voyager.filter import filter_subgraph_by_module_prefix
|
|
2
|
-
from fastapi_voyager.type import
|
|
2
|
+
from fastapi_voyager.type import PK, Link, Route, SchemaNode, Tag
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
def _make_tag_route_link(tag: Tag, route: Route) -> Link:
|
|
@@ -52,7 +52,9 @@ def test_filter_subgraph_filters_nodes_and_links():
|
|
|
52
52
|
|
|
53
53
|
assert filtered_nodes == [node_b]
|
|
54
54
|
assert any(
|
|
55
|
-
lk.type == "route_to_schema" and
|
|
55
|
+
lk.type == "route_to_schema" and \
|
|
56
|
+
lk.source_origin == route.id and \
|
|
57
|
+
lk.target_origin == node_b.id
|
|
56
58
|
for lk in filtered_links
|
|
57
59
|
)
|
|
58
60
|
assert len(filtered_links) == 2 # tag -> route and merged route -> filtered node
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
import sys
|
|
2
2
|
from typing import Generic, TypeVar
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
3
6
|
from fastapi_voyager.type_helper import is_generic_container
|
|
4
7
|
|
|
8
|
+
|
|
5
9
|
class PageStory(BaseModel):
|
|
6
10
|
id: int
|
|
7
11
|
title: str
|
|
@@ -11,7 +15,11 @@ class DataModel(BaseModel, Generic[T]):
|
|
|
11
15
|
data: T
|
|
12
16
|
id: int
|
|
13
17
|
|
|
14
|
-
|
|
18
|
+
DataModelPageStory: object # Stub declaration for static analysis
|
|
19
|
+
if sys.version_info >= (3, 12):
|
|
20
|
+
exec("type DataModelPageStory = DataModel[PageStory]")
|
|
21
|
+
else:
|
|
22
|
+
DataModelPageStory = DataModel[PageStory]
|
|
15
23
|
|
|
16
24
|
def test_is_generic_container():
|
|
17
25
|
print(DataModelPageStory.__value__.__bases__)
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import sys
|
|
2
|
-
import typing
|
|
3
2
|
import pytest
|
|
4
|
-
|
|
5
3
|
from fastapi_voyager.type_helper import get_core_types
|
|
4
|
+
from typing import Annotated
|
|
6
5
|
|
|
7
6
|
|
|
8
7
|
def test_optional_and_list_core_types():
|
|
9
8
|
class T: ...
|
|
10
9
|
|
|
11
10
|
# Optional[T] -> (T,)
|
|
12
|
-
opt =
|
|
11
|
+
opt = T | None
|
|
13
12
|
core = get_core_types(opt)
|
|
14
13
|
assert core == (T,)
|
|
15
14
|
|
|
@@ -23,7 +22,7 @@ def test_typing_union_core_types():
|
|
|
23
22
|
class A: ...
|
|
24
23
|
class B: ...
|
|
25
24
|
|
|
26
|
-
u =
|
|
25
|
+
u = A | B
|
|
27
26
|
core = get_core_types(u)
|
|
28
27
|
# order preserved
|
|
29
28
|
assert core == (A, B)
|
|
@@ -43,7 +42,7 @@ def test_mixed_optional_list():
|
|
|
43
42
|
class T: ...
|
|
44
43
|
|
|
45
44
|
# Optional[list[T]] -> (T,) (list unwrapped after removing None)
|
|
46
|
-
anno =
|
|
45
|
+
anno = list[T] | None
|
|
47
46
|
core = get_core_types(anno)
|
|
48
47
|
assert core == (T,)
|
|
49
48
|
|
|
@@ -53,7 +52,7 @@ def test_nested_union_flattening():
|
|
|
53
52
|
class B: ...
|
|
54
53
|
class C: ...
|
|
55
54
|
|
|
56
|
-
anno =
|
|
55
|
+
anno = A | (B | C)
|
|
57
56
|
core = get_core_types(anno)
|
|
58
57
|
# typing normalizes nested unions -> (A, B, C)
|
|
59
58
|
assert core == (A, B, C)
|
|
@@ -72,12 +71,11 @@ def test_uniontype_with_list_member():
|
|
|
72
71
|
assert core2 == (A, B)
|
|
73
72
|
|
|
74
73
|
|
|
75
|
-
|
|
76
|
-
|
|
77
74
|
# Only Python 3.12+ supports the PEP 695 `type` statement producing TypeAliasType
|
|
78
75
|
@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 type aliases require Python 3.12+")
|
|
79
76
|
def test_union_type_alias_and_list():
|
|
80
|
-
# Dynamically exec a type alias using the new syntax
|
|
77
|
+
# Dynamically exec a type alias using the new syntax
|
|
78
|
+
# so test file stays valid on <3.12 (even though skipped)
|
|
81
79
|
ns: dict = {}
|
|
82
80
|
code = """
|
|
83
81
|
class A: ...
|
|
@@ -97,3 +95,11 @@ type MyAlias = A | B
|
|
|
97
95
|
# Direct alias should also work
|
|
98
96
|
core2 = get_core_types(MyAlias)
|
|
99
97
|
assert set(core2) == {A, B}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_annotated():
|
|
101
|
+
class A: ...
|
|
102
|
+
|
|
103
|
+
core = get_core_types(Annotated[A, 'hello'])
|
|
104
|
+
assert set(core) == {A}
|
|
105
|
+
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/component/render-graph.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/icon/apple-touch-icon.png
RENAMED
|
File without changes
|
{fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/icon/favicon-16x16.png
RENAMED
|
File without changes
|
{fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/icon/favicon-32x32.png
RENAMED
|
File without changes
|
|
File without changes
|
{fastapi_voyager-0.12.1 → fastapi_voyager-0.12.3}/src/fastapi_voyager/web/icon/site.webmanifest
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|