fastapi-voyager 0.13.0__tar.gz → 0.13.2__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.
Files changed (61) hide show
  1. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/PKG-INFO +64 -27
  2. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/README.md +63 -26
  3. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/docs/changelog.md +7 -1
  4. fastapi_voyager-0.13.2/src/fastapi_voyager/er_diagram.py +279 -0
  5. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/type.py +2 -0
  6. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/version.py +1 -1
  7. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/tests/service/schema/schema.py +5 -2
  8. fastapi_voyager-0.13.0/src/fastapi_voyager/er_diagram.py +0 -119
  9. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  10. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  11. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/.github/workflows/publish.yml +0 -0
  12. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/.gitignore +0 -0
  13. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/.python-version +0 -0
  14. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/CONTRIBUTING.md +0 -0
  15. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/LICENSE +0 -0
  16. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/docs/idea.md +0 -0
  17. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/pyproject.toml +0 -0
  18. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/release.md +0 -0
  19. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/__init__.py +0 -0
  20. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/cli.py +0 -0
  21. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/filter.py +0 -0
  22. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/module.py +0 -0
  23. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/render.py +0 -0
  24. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/server.py +0 -0
  25. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/type_helper.py +0 -0
  26. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/voyager.py +0 -0
  27. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/web/component/demo.js +0 -0
  28. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/web/component/render-graph.js +0 -0
  29. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/web/component/route-code-display.js +0 -0
  30. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/web/component/schema-code-display.js +0 -0
  31. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/web/graph-ui.js +0 -0
  32. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/web/graphviz.svg.css +0 -0
  33. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/web/graphviz.svg.js +0 -0
  34. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/web/icon/android-chrome-192x192.png +0 -0
  35. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/web/icon/android-chrome-512x512.png +0 -0
  36. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/web/icon/apple-touch-icon.png +0 -0
  37. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/web/icon/favicon-16x16.png +0 -0
  38. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/web/icon/favicon-32x32.png +0 -0
  39. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/web/icon/favicon.ico +0 -0
  40. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/web/icon/site.webmanifest +0 -0
  41. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/web/index.html +0 -0
  42. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/web/quasar.min.css +0 -0
  43. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/web/quasar.min.js +0 -0
  44. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/web/store.js +0 -0
  45. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/src/fastapi_voyager/web/vue-main.js +0 -0
  46. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/tests/__init__.py +0 -0
  47. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/tests/demo.py +0 -0
  48. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/tests/demo_anno.py +0 -0
  49. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/tests/programatic.py +0 -0
  50. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/tests/service/__init__.py +0 -0
  51. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/tests/service/schema/__init__.py +0 -0
  52. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/tests/service/schema/base_entity.py +0 -0
  53. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/tests/service/schema/extra.py +0 -0
  54. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/tests/test_analysis.py +0 -0
  55. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/tests/test_filter.py +0 -0
  56. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/tests/test_generic.py +0 -0
  57. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/tests/test_import.py +0 -0
  58. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/tests/test_module.py +0 -0
  59. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/tests/test_type_helper.py +0 -0
  60. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/uv.lock +0 -0
  61. {fastapi_voyager-0.13.0 → fastapi_voyager-0.13.2}/voyager.jpg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-voyager
3
- Version: 0.13.0
3
+ Version: 0.13.2
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
@@ -35,11 +35,12 @@ Visualize your FastAPI endpoints, and explore them interactively.
35
35
 
36
36
  > This repo is still in early stage, it supports pydantic v2 only
37
37
 
38
- [live demo](https://www.newsyeah.fun/voyager/) of project: [composition oriented development pattern](https://github.com/allmonday/composition-oriented-development-pattern)
38
+ visit [live demo](https://www.newsyeah.fun/voyager/)
39
+ source code:[composition oriented development pattern](https://github.com/allmonday/composition-oriented-development-pattern)
39
40
 
40
- <img width="1600" height="986" alt="image" src="https://github.com/user-attachments/assets/8829cda0-f42d-4c84-be2f-b019bb5fe7e1" />
41
+ <img width="1597" height="933" alt="image" src="https://github.com/user-attachments/assets/020bf5b2-6c69-44bf-ba1f-39389d388d27" />
41
42
 
42
- with configuration:
43
+ with simple configuration it can be embedded into FastAPI.
43
44
 
44
45
  ```python
45
46
  app.mount('/voyager',
@@ -67,6 +68,8 @@ pip install fastapi-voyager
67
68
  uv add fastapi-voyager
68
69
  ```
69
70
 
71
+ run with cli:
72
+
70
73
  ```shell
71
74
  voyager -m path.to.your.app.module --server
72
75
  ```
@@ -77,47 +80,81 @@ voyager -m path.to.your.app.module --server
77
80
  voyager -m path.to.your.app.module --server --app api
78
81
  ```
79
82
 
80
- ## Mount into project
81
-
82
- ```python
83
- from fastapi import FastAPI
84
- from fastapi_voyager import create_voyager
85
- from tests.demo import app
86
-
87
- app.mount('/voyager', create_voyager(
88
- app,
89
- module_color={"tests.service": "red"},
90
- module_prefix="tests.service"),
91
- swagger_url="/docs")
92
- ```
93
83
 
94
84
  ## Features
95
85
 
96
86
  For scenarios of using FastAPI as internal API integration endpoints, `fastapi-voyager` helps to visualize the dependencies.
97
87
 
98
- It is also an architecture inspection tool that can identify issues in data relationships during design phase before turly implemtatioin.
88
+ It is also an architecture tool that can identify issues inside implementation, finding out wrong relationships, overfetchs, or anything else.
89
+
90
+ **If the process of building the view model follows the ER model**, the full potential of fastapi-voyager can be realized. It allows for quick identification of APIs that use entities, as well as which entities are used by a specific API
99
91
 
100
- If the process of building the view model follows the ER model, the full potential of fastapi-voyager can be realized. It allows for quick identification of APIs that use entities, as well as which entities are used by a specific API
92
+ Given ErDiagram defined by pydantic-resolve, application level entity relationship diagram can be visualized too.
101
93
 
102
94
  ### highlight nodes and links
103
95
  click a node to highlight it's upperstream and downstream nodes. figure out the related models of one page, or homw many pages are related with one model.
104
96
 
105
-
106
97
  <img width="1100" height="700" alt="image" src="https://github.com/user-attachments/assets/3e0369ea-5fa4-469a-82c1-ed57d407e53d" />
107
98
 
108
- ### focus on nodes
109
-
110
- Double click a node, and then toggle focus to hide irrelevant nodes.
111
-
112
- <img width="1061" height="937" alt="image" src="https://github.com/user-attachments/assets/79709b02-7571-43fc-abc9-17a287a97515" />
113
-
114
99
  ### view source code
115
100
 
116
101
  double click a node or route to show source code or open file in vscode.
117
102
 
118
103
  <img width="1297" height="940" alt="image" src="https://github.com/user-attachments/assets/c8bb2e7d-b727-42a6-8c9e-64dce297d2d8" />
119
104
 
120
- <img width="1132" height="824" alt="image" src="https://github.com/user-attachments/assets/b706e879-e4fc-48dd-ace1-99bf97e3ed6a" />
105
+ ### quick search
106
+
107
+ seach schemas by name and dispaly it's upstream and downstreams.
108
+
109
+ shift + click can quickly search current one
110
+
111
+ <img width="1587" height="873" alt="image" src="https://github.com/user-attachments/assets/ee4716f3-233d-418f-bc0e-3b214d1498f7" />
112
+
113
+ ### display ER diagram
114
+
115
+ ER diagram is a new feature from pydantic-resolve which provide a solid expression for business descritpions.
116
+
117
+ ```python
118
+ diagram = ErDiagram(
119
+ configs=[
120
+ Entity(
121
+ kls=Team,
122
+ relationships=[
123
+ Relationship( field='id', target_kls=list[Sprint], loader=sprint_loader.team_to_sprint_loader),
124
+ Relationship( field='id', target_kls=list[User], loader=user_loader.team_to_user_loader)
125
+ ]
126
+ ),
127
+ Entity(
128
+ kls=Sprint,
129
+ relationships=[
130
+ Relationship( field='id', target_kls=list[Story], loader=story_loader.sprint_to_story_loader)
131
+ ]
132
+ ),
133
+ Entity(
134
+ kls=Story,
135
+ relationships=[
136
+ Relationship( field='id', target_kls=list[Task], loader=task_loader.story_to_task_loader),
137
+ Relationship( field='owner_id', target_kls=User, loader=user_loader.user_batch_loader)
138
+ ]
139
+ ),
140
+ Entity(
141
+ kls=Task,
142
+ relationships=[
143
+ Relationship( field='owner_id', target_kls=User, loader=user_loader.user_batch_loader)
144
+ ]
145
+ )
146
+ ]
147
+ )
148
+
149
+ # display in voyager
150
+ app.mount('/voyager',
151
+ create_voyager(
152
+ app,
153
+ er_diagram=diagram)
154
+ ```
155
+
156
+ <img width="1276" height="613" alt="image" src="https://github.com/user-attachments/assets/ea0091bb-ee11-4f71-8be3-7129d956c910" />
157
+
121
158
 
122
159
 
123
160
  ## Command Line Usage
@@ -7,11 +7,12 @@ Visualize your FastAPI endpoints, and explore them interactively.
7
7
 
8
8
  > This repo is still in early stage, it supports pydantic v2 only
9
9
 
10
- [live demo](https://www.newsyeah.fun/voyager/) of project: [composition oriented development pattern](https://github.com/allmonday/composition-oriented-development-pattern)
10
+ visit [live demo](https://www.newsyeah.fun/voyager/)
11
+ source code:[composition oriented development pattern](https://github.com/allmonday/composition-oriented-development-pattern)
11
12
 
12
- <img width="1600" height="986" alt="image" src="https://github.com/user-attachments/assets/8829cda0-f42d-4c84-be2f-b019bb5fe7e1" />
13
+ <img width="1597" height="933" alt="image" src="https://github.com/user-attachments/assets/020bf5b2-6c69-44bf-ba1f-39389d388d27" />
13
14
 
14
- with configuration:
15
+ with simple configuration it can be embedded into FastAPI.
15
16
 
16
17
  ```python
17
18
  app.mount('/voyager',
@@ -39,6 +40,8 @@ pip install fastapi-voyager
39
40
  uv add fastapi-voyager
40
41
  ```
41
42
 
43
+ run with cli:
44
+
42
45
  ```shell
43
46
  voyager -m path.to.your.app.module --server
44
47
  ```
@@ -49,47 +52,81 @@ voyager -m path.to.your.app.module --server
49
52
  voyager -m path.to.your.app.module --server --app api
50
53
  ```
51
54
 
52
- ## Mount into project
53
-
54
- ```python
55
- from fastapi import FastAPI
56
- from fastapi_voyager import create_voyager
57
- from tests.demo import app
58
-
59
- app.mount('/voyager', create_voyager(
60
- app,
61
- module_color={"tests.service": "red"},
62
- module_prefix="tests.service"),
63
- swagger_url="/docs")
64
- ```
65
55
 
66
56
  ## Features
67
57
 
68
58
  For scenarios of using FastAPI as internal API integration endpoints, `fastapi-voyager` helps to visualize the dependencies.
69
59
 
70
- It is also an architecture inspection tool that can identify issues in data relationships during design phase before turly implemtatioin.
60
+ It is also an architecture tool that can identify issues inside implementation, finding out wrong relationships, overfetchs, or anything else.
61
+
62
+ **If the process of building the view model follows the ER model**, the full potential of fastapi-voyager can be realized. It allows for quick identification of APIs that use entities, as well as which entities are used by a specific API
71
63
 
72
- If the process of building the view model follows the ER model, the full potential of fastapi-voyager can be realized. It allows for quick identification of APIs that use entities, as well as which entities are used by a specific API
64
+ Given ErDiagram defined by pydantic-resolve, application level entity relationship diagram can be visualized too.
73
65
 
74
66
  ### highlight nodes and links
75
67
  click a node to highlight it's upperstream and downstream nodes. figure out the related models of one page, or homw many pages are related with one model.
76
68
 
77
-
78
69
  <img width="1100" height="700" alt="image" src="https://github.com/user-attachments/assets/3e0369ea-5fa4-469a-82c1-ed57d407e53d" />
79
70
 
80
- ### focus on nodes
81
-
82
- Double click a node, and then toggle focus to hide irrelevant nodes.
83
-
84
- <img width="1061" height="937" alt="image" src="https://github.com/user-attachments/assets/79709b02-7571-43fc-abc9-17a287a97515" />
85
-
86
71
  ### view source code
87
72
 
88
73
  double click a node or route to show source code or open file in vscode.
89
74
 
90
75
  <img width="1297" height="940" alt="image" src="https://github.com/user-attachments/assets/c8bb2e7d-b727-42a6-8c9e-64dce297d2d8" />
91
76
 
92
- <img width="1132" height="824" alt="image" src="https://github.com/user-attachments/assets/b706e879-e4fc-48dd-ace1-99bf97e3ed6a" />
77
+ ### quick search
78
+
79
+ seach schemas by name and dispaly it's upstream and downstreams.
80
+
81
+ shift + click can quickly search current one
82
+
83
+ <img width="1587" height="873" alt="image" src="https://github.com/user-attachments/assets/ee4716f3-233d-418f-bc0e-3b214d1498f7" />
84
+
85
+ ### display ER diagram
86
+
87
+ ER diagram is a new feature from pydantic-resolve which provide a solid expression for business descritpions.
88
+
89
+ ```python
90
+ diagram = ErDiagram(
91
+ configs=[
92
+ Entity(
93
+ kls=Team,
94
+ relationships=[
95
+ Relationship( field='id', target_kls=list[Sprint], loader=sprint_loader.team_to_sprint_loader),
96
+ Relationship( field='id', target_kls=list[User], loader=user_loader.team_to_user_loader)
97
+ ]
98
+ ),
99
+ Entity(
100
+ kls=Sprint,
101
+ relationships=[
102
+ Relationship( field='id', target_kls=list[Story], loader=story_loader.sprint_to_story_loader)
103
+ ]
104
+ ),
105
+ Entity(
106
+ kls=Story,
107
+ relationships=[
108
+ Relationship( field='id', target_kls=list[Task], loader=task_loader.story_to_task_loader),
109
+ Relationship( field='owner_id', target_kls=User, loader=user_loader.user_batch_loader)
110
+ ]
111
+ ),
112
+ Entity(
113
+ kls=Task,
114
+ relationships=[
115
+ Relationship( field='owner_id', target_kls=User, loader=user_loader.user_batch_loader)
116
+ ]
117
+ )
118
+ ]
119
+ )
120
+
121
+ # display in voyager
122
+ app.mount('/voyager',
123
+ create_voyager(
124
+ app,
125
+ er_diagram=diagram)
126
+ ```
127
+
128
+ <img width="1276" height="613" alt="image" src="https://github.com/user-attachments/assets/ea0091bb-ee11-4f71-8be3-7129d956c910" />
129
+
93
130
 
94
131
 
95
132
  ## Command Line Usage
@@ -139,11 +139,17 @@
139
139
  - 0.13.0
140
140
  - [x] if er diagram is provided, show it first.
141
141
  - 0.13.1
142
+ - [x] show more details in er diagram
143
+ - 0.13.2
144
+ - [x] show dashed line for link without dataloader
145
+ - 0.13.3
146
+ - [ ] show field description
147
+ - 0.13.4
142
148
  - [ ] integration with pydantic-resolve
143
149
  - [ ] show hint for resolve, post fields
144
150
  - [ ] display loader as edges
145
151
  - [ ] add tests
146
- - 0.13.2
152
+ - 0.13.5
147
153
  - [ ] refactor vue-main.js, move methods to store
148
154
  - [ ] refactor render.py
149
155
 
@@ -0,0 +1,279 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi_voyager.type_helper import (
4
+ update_forward_refs,
5
+ full_class_name,
6
+ get_core_types,
7
+ get_type_name
8
+ )
9
+ from fastapi_voyager.type import (
10
+ FieldInfo,
11
+ PK,
12
+ FieldType,
13
+ LinkType,
14
+ Link,
15
+ ModuleNode,
16
+ SchemaNode,
17
+ )
18
+ from pydantic import BaseModel
19
+ from pydantic_resolve import ErDiagram, Entity, Relationship, MultipleRelationship
20
+ from logging import getLogger
21
+ from fastapi_voyager.module import build_module_schema_tree
22
+
23
+ logger = getLogger(__name__)
24
+
25
+
26
+ class DiagramRenderer:
27
+ def __init__(
28
+ self,
29
+ *,
30
+ show_fields: FieldType = 'single',
31
+ show_module: bool = True
32
+ ) -> None:
33
+ self.show_fields = show_fields if show_fields in ('single', 'object', 'all') else 'single'
34
+ self.show_module = show_module
35
+
36
+ logger.info(f'show_module: {self.show_module}')
37
+
38
+ def render_schema_label(self, node: SchemaNode, color: str | None=None) -> str:
39
+ has_base_fields = any(f.from_base for f in node.fields)
40
+ fields = [n for n in node.fields if n.from_base is False]
41
+
42
+ if self.show_fields == 'all':
43
+ _fields = fields
44
+ elif self.show_fields == 'object':
45
+ _fields = [f for f in fields if f.is_object is True]
46
+ else: # 'single'
47
+ _fields = []
48
+
49
+ fields_parts: list[str] = []
50
+ if self.show_fields == 'all' and has_base_fields:
51
+ fields_parts.append('<tr><td align="left" cellpadding="8"><font color="#999"> Inherited Fields ... </font></td></tr>')
52
+
53
+ for field in _fields:
54
+ type_name = field.type_name[:25] + '..' if len(field.type_name) > 25 else field.type_name
55
+ display_xml = f'<s align="left">{field.name}: {type_name}</s>' if field.is_exclude else f'{field.name}: {type_name}'
56
+ field_str = f"""<tr><td align="left" port="f{field.name}" cellpadding="8"><font> {display_xml} </font></td></tr>"""
57
+ fields_parts.append(field_str)
58
+
59
+ header_color = '#009485' if color is None else color
60
+ header = f"""<tr><td cellpadding="6" bgcolor="{header_color}" align="center" colspan="1" port="{PK}"> <font color="white"> {node.name} </font></td> </tr>"""
61
+ field_content = ''.join(fields_parts) if fields_parts else ''
62
+ return f"""<<table border="0" cellborder="1" cellpadding="0" cellspacing="0" bgcolor="white"> {header} {field_content} </table>>"""
63
+
64
+ def _handle_schema_anchor(self, source: str) -> str:
65
+ if '::' in source:
66
+ a, b = source.split('::', 1)
67
+ return f'"{a}":{b}'
68
+ return f'"{source}"'
69
+
70
+ def render_link(self, link: Link) -> str:
71
+ h = self._handle_schema_anchor
72
+ if link.type == 'schema':
73
+ return f"""{h(link.source)}:e -> {h(link.target)}:w [style = "{link.style}", label = "{link.label}", minlen=3];"""
74
+ else:
75
+ raise ValueError(f'Unknown link type: {link.type}')
76
+
77
+ def render_module_schema_content(self, nodes: list[SchemaNode]) -> str:
78
+ def render_node(node: SchemaNode, color: str | None=None) -> str:
79
+ return f'''
80
+ "{node.id}" [
81
+ label = {self.render_schema_label(node, color)}
82
+ shape = "plain"
83
+ margin="0.5,0.1"
84
+ ];'''
85
+
86
+ def render_module_schema(mod: ModuleNode, show_cluster:bool=True) -> str:
87
+ inner_nodes = [ render_node(node) for node in mod.schema_nodes ]
88
+ inner_nodes_str = '\n'.join(inner_nodes)
89
+ child_str = '\n'.join(render_module_schema(mod=m, show_cluster=show_cluster) for m in mod.modules)
90
+
91
+ if show_cluster:
92
+ return f'''
93
+ subgraph cluster_module_{mod.fullname.replace('.', '_')} {{
94
+ tooltip="{mod.fullname}"
95
+ color = "#666"
96
+ style="rounded"
97
+ label = " {mod.name}"
98
+ labeljust = "l"
99
+ pencolor="#ccc"
100
+ penwidth=""
101
+ {inner_nodes_str}
102
+ {child_str}
103
+ }}'''
104
+ else:
105
+ return f'''
106
+ {inner_nodes_str}
107
+ {child_str}
108
+ '''
109
+
110
+ # if self.show_module:
111
+ module_schemas = build_module_schema_tree(nodes)
112
+ return '\n'.join(render_module_schema(mod=m, show_cluster=self.show_module) for m in module_schemas)
113
+
114
+ def render_dot(self, nodes: list[SchemaNode], links: list[Link], spline_line=False) -> str:
115
+ module_schemas_str = self.render_module_schema_content(nodes)
116
+ link_str = '\n'.join(self.render_link(link) for link in links)
117
+
118
+ dot_str = f'''
119
+ digraph world {{
120
+ pad="0.5"
121
+ nodesep=0.8
122
+ {'splines=line' if spline_line else ''}
123
+ fontname="Helvetica,Arial,sans-serif"
124
+ node [fontname="Helvetica,Arial,sans-serif"]
125
+ edge [
126
+ fontname="Helvetica,Arial,sans-serif"
127
+ color="gray"
128
+ ]
129
+ graph [
130
+ rankdir = "LR"
131
+ ];
132
+ node [
133
+ fontsize = "16"
134
+ ];
135
+
136
+ subgraph cluster_schema {{
137
+ color = "#aaa"
138
+ margin=18
139
+ style="dashed"
140
+ label=" ER Diagram"
141
+ labeljust="l"
142
+ fontsize="20"
143
+ {module_schemas_str}
144
+ }}
145
+
146
+ {link_str}
147
+ }}
148
+ '''
149
+ return dot_str
150
+
151
+
152
+ class VoyagerErDiagram:
153
+ def __init__(self,
154
+ er_diagram: ErDiagram,
155
+ show_fields: FieldType = 'single',
156
+ show_module: bool = False):
157
+
158
+ self.er_diagram = er_diagram
159
+ self.nodes: list[SchemaNode] = []
160
+ self.node_set: dict[str, SchemaNode] = {}
161
+
162
+ self.links: list[Link] = []
163
+ self.link_set: set[tuple[str, str]] = set()
164
+
165
+ self.fk_set: dict[str, set[str]] = {}
166
+
167
+ self.show_field = show_fields
168
+ self.show_module = show_module
169
+
170
+ def generate_node_head(self, link_name: str):
171
+ return f'{link_name}::{PK}'
172
+
173
+ def analysis_entity(self, entity: Entity):
174
+ schema = entity.kls
175
+ update_forward_refs(schema)
176
+ self.add_to_node_set(schema, fk_set=self.fk_set.get(full_class_name(schema)))
177
+
178
+ for relationship in entity.relationships:
179
+ annos = get_core_types(relationship.target_kls)
180
+ for anno in annos:
181
+ self.add_to_node_set(anno, fk_set=self.fk_set.get(full_class_name(anno)))
182
+ source_name = f'{full_class_name(schema)}::f{relationship.field}'
183
+ if isinstance(relationship, Relationship):
184
+ self.add_to_link_set(
185
+ source=source_name,
186
+ source_origin=full_class_name(schema),
187
+ target=self.generate_node_head(full_class_name(anno)),
188
+ target_origin=full_class_name(anno),
189
+ type='schema',
190
+ label=get_type_name(relationship.target_kls),
191
+ style='solid' if relationship.loader else 'solid, dashed'
192
+ )
193
+
194
+ elif isinstance(relationship, MultipleRelationship):
195
+ for link in relationship.links:
196
+ self.add_to_link_set(
197
+ source=source_name,
198
+ source_origin=full_class_name(schema),
199
+ target=self.generate_node_head(full_class_name(anno)),
200
+ target_origin=full_class_name(anno),
201
+ type='schema',
202
+ biz=link.biz,
203
+ label=f'{get_type_name(relationship.target_kls)} / {link.biz} ',
204
+ style='solid' if link.loader else 'solid, dashed'
205
+ )
206
+
207
+ def add_to_node_set(self, schema, fk_set: set[str] | None = None) -> str:
208
+ """
209
+ 1. calc full_path, add to node_set
210
+ 2. if duplicated, do nothing, else insert
211
+ 2. return the full_path
212
+ """
213
+ full_name = full_class_name(schema)
214
+
215
+ if full_name not in self.node_set:
216
+ # skip meta info for normal queries
217
+ self.node_set[full_name] = SchemaNode(
218
+ id=full_name,
219
+ module=schema.__module__,
220
+ name=schema.__name__,
221
+ fields=get_fields(schema, fk_set)
222
+ )
223
+ return full_name
224
+
225
+ def add_to_link_set(
226
+ self,
227
+ source: str,
228
+ source_origin: str,
229
+ target: str,
230
+ target_origin: str,
231
+ type: LinkType,
232
+ label: str,
233
+ style: str,
234
+ biz: str | None = None
235
+ ) -> bool:
236
+ """
237
+ 1. add link to link_set
238
+ 2. if duplicated, do nothing, else insert
239
+ """
240
+ pair = (source, target, biz)
241
+ if result := pair not in self.link_set:
242
+ self.link_set.add(pair)
243
+ self.links.append(Link(
244
+ source=source,
245
+ source_origin=source_origin,
246
+ target=target,
247
+ target_origin=target_origin,
248
+ type=type,
249
+ label=label,
250
+ style=style
251
+ ))
252
+ return result
253
+
254
+
255
+ def render_dot(self):
256
+ self.fk_set = {
257
+ full_class_name(entity.kls): set([rel.field for rel in entity.relationships])
258
+ for entity in self.er_diagram.configs
259
+ }
260
+
261
+ for entity in self.er_diagram.configs:
262
+ self.analysis_entity(entity)
263
+ renderer = DiagramRenderer(show_fields=self.show_field, show_module=self.show_module)
264
+ return renderer.render_dot(list(self.node_set.values()), self.links)
265
+
266
+
267
+ def get_fields(schema: type[BaseModel], fk_set: set[str] | None = None) -> list[FieldInfo]:
268
+
269
+ fields: list[FieldInfo] = []
270
+ for k, v in schema.model_fields.items():
271
+ anno = v.annotation
272
+ fields.append(FieldInfo(
273
+ is_object=k in fk_set if fk_set is not None else False,
274
+ name=k,
275
+ from_base=False,
276
+ type_name=get_type_name(anno),
277
+ is_exclude=bool(v.exclude)
278
+ ))
279
+ return fields
@@ -67,6 +67,8 @@ class Link:
67
67
  source_origin: str
68
68
  target_origin: str
69
69
  type: LinkType
70
+ label: str | None = None
71
+ style: str | None = None
70
72
 
71
73
  FieldType = Literal['single', 'object', 'all']
72
74
  PK = "PK"
@@ -1,2 +1,2 @@
1
1
  __all__ = ["__version__"]
2
- __version__ = "0.13.0"
2
+ __version__ = "0.13.2"
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
  from typing import Literal
3
3
  from pydantic import BaseModel
4
- from pydantic_resolve import Relationship
4
+ from pydantic_resolve import Relationship, MultipleRelationship, Link
5
5
  from .base_entity import BaseEntity
6
6
 
7
7
 
@@ -34,7 +34,10 @@ class Story(BaseModel, BaseEntity):
34
34
 
35
35
  class Sprint(BaseModel, BaseEntity):
36
36
  __pydantic_resolve_relationships__ = [
37
- Relationship(field='id', target_kls=list[Story])
37
+ MultipleRelationship(field='id', target_kls=list[Story], links=[
38
+ Link(biz='all', loader=lambda x: x),
39
+ Link(biz='done', loader=lambda x: x),
40
+ ])
38
41
  ]
39
42
  id: int
40
43
  name: str
@@ -1,119 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from fastapi_voyager.type import PK, FieldType, Link, LinkType, SchemaNode
4
- from fastapi_voyager.type_helper import (
5
- update_forward_refs,
6
- full_class_name,
7
- get_core_types,
8
- get_type_name
9
- )
10
- from fastapi_voyager.render import Renderer
11
- from fastapi_voyager.type import FieldInfo
12
- from pydantic import BaseModel
13
- from pydantic_resolve import ErDiagram, Entity
14
-
15
- class VoyagerErDiagram:
16
- def __init__(self,
17
- er_diagram: ErDiagram,
18
- show_fields: FieldType = 'single',
19
- show_module: bool = False):
20
- self.er_diagram = er_diagram
21
- self.nodes: list[SchemaNode] = []
22
- self.node_set: dict[str, SchemaNode] = {}
23
-
24
- self.links: list[Link] = []
25
- self.link_set: set[tuple[str, str]] = set()
26
-
27
- self.fk_set: dict[str, set[str]] = {}
28
-
29
- self.show_field = show_fields
30
- self.show_module = show_module
31
-
32
- def generate_node_head(self, link_name: str):
33
- return f'{link_name}::{PK}'
34
-
35
- def analysis_entity(self, entity: Entity):
36
- schema = entity.kls
37
- update_forward_refs(schema)
38
- self.add_to_node_set(schema, fk_set=self.fk_set.get(full_class_name(schema)))
39
-
40
- for relationship in entity.relationships:
41
- annos = get_core_types(relationship.target_kls)
42
- for anno in annos:
43
- self.add_to_node_set(anno, fk_set=self.fk_set.get(full_class_name(anno)))
44
- source_name = f'{full_class_name(schema)}::f{relationship.field}'
45
- self.add_to_link_set(
46
- source=source_name,
47
- source_origin=full_class_name(schema),
48
- target=self.generate_node_head(full_class_name(anno)),
49
- target_origin=full_class_name(anno),
50
- type='schema')
51
-
52
- def add_to_node_set(self, schema, fk_set: set[str] | None = None) -> str:
53
- """
54
- 1. calc full_path, add to node_set
55
- 2. if duplicated, do nothing, else insert
56
- 2. return the full_path
57
- """
58
- full_name = full_class_name(schema)
59
-
60
- if full_name not in self.node_set:
61
- # skip meta info for normal queries
62
- self.node_set[full_name] = SchemaNode(
63
- id=full_name,
64
- module=schema.__module__,
65
- name=schema.__name__,
66
- fields=get_fields(schema, fk_set)
67
- )
68
- return full_name
69
-
70
- def add_to_link_set(
71
- self,
72
- source: str,
73
- source_origin: str,
74
- target: str,
75
- target_origin: str,
76
- type: LinkType
77
- ) -> bool:
78
- """
79
- 1. add link to link_set
80
- 2. if duplicated, do nothing, else insert
81
- """
82
- pair = (source, target)
83
- if result := pair not in self.link_set:
84
- self.link_set.add(pair)
85
- self.links.append(Link(
86
- source=source,
87
- source_origin=source_origin,
88
- target=target,
89
- target_origin=target_origin,
90
- type=type
91
- ))
92
- return result
93
-
94
-
95
- def render_dot(self):
96
- self.fk_set = {
97
- full_class_name(entity.kls): set([rel.field for rel in entity.relationships])
98
- for entity in self.er_diagram.configs
99
- }
100
-
101
- for entity in self.er_diagram.configs:
102
- self.analysis_entity(entity)
103
- renderer = Renderer(show_fields=self.show_field, show_module=self.show_module)
104
- return renderer.render_dot([], [], list(self.node_set.values()), self.links)
105
-
106
-
107
- def get_fields(schema: type[BaseModel], fk_set: set[str] | None = None) -> list[FieldInfo]:
108
-
109
- fields: list[FieldInfo] = []
110
- for k, v in schema.model_fields.items():
111
- anno = v.annotation
112
- fields.append(FieldInfo(
113
- is_object=k in fk_set if fk_set is not None else False,
114
- name=k,
115
- from_base=False,
116
- type_name=get_type_name(anno),
117
- is_exclude=bool(v.exclude)
118
- ))
119
- return fields