neo4j-viz 0.3.1__py3-none-any.whl → 0.4.1__py3-none-any.whl
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.
- neo4j_viz/__init__.py +21 -2
- neo4j_viz/gds.py +103 -25
- neo4j_viz/gql_create.py +46 -12
- neo4j_viz/neo4j.py +75 -19
- neo4j_viz/options.py +97 -2
- neo4j_viz/pandas.py +77 -17
- neo4j_viz/resources/nvl_entrypoint/base.js +1 -1
- neo4j_viz/visualization_graph.py +22 -2
- {neo4j_viz-0.3.1.dist-info → neo4j_viz-0.4.1.dist-info}/METADATA +1 -1
- neo4j_viz-0.4.1.dist-info/RECORD +22 -0
- {neo4j_viz-0.3.1.dist-info → neo4j_viz-0.4.1.dist-info}/WHEEL +1 -1
- neo4j_viz-0.3.1.dist-info/RECORD +0 -22
- {neo4j_viz-0.3.1.dist-info → neo4j_viz-0.4.1.dist-info}/top_level.txt +0 -0
neo4j_viz/__init__.py
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
from .node import Node
|
|
2
|
-
from .options import
|
|
2
|
+
from .options import (
|
|
3
|
+
CaptionAlignment,
|
|
4
|
+
Direction,
|
|
5
|
+
ForceDirectedLayoutOptions,
|
|
6
|
+
HierarchicalLayoutOptions,
|
|
7
|
+
Layout,
|
|
8
|
+
Packing,
|
|
9
|
+
Renderer,
|
|
10
|
+
)
|
|
3
11
|
from .relationship import Relationship
|
|
4
12
|
from .visualization_graph import VisualizationGraph
|
|
5
13
|
|
|
6
|
-
__all__ = [
|
|
14
|
+
__all__ = [
|
|
15
|
+
"VisualizationGraph",
|
|
16
|
+
"Node",
|
|
17
|
+
"Relationship",
|
|
18
|
+
"CaptionAlignment",
|
|
19
|
+
"Layout",
|
|
20
|
+
"Renderer",
|
|
21
|
+
"ForceDirectedLayoutOptions",
|
|
22
|
+
"HierarchicalLayoutOptions",
|
|
23
|
+
"Direction",
|
|
24
|
+
"Packing",
|
|
25
|
+
]
|
neo4j_viz/gds.py
CHANGED
|
@@ -1,27 +1,44 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import warnings
|
|
3
4
|
from itertools import chain
|
|
4
5
|
from typing import Optional
|
|
6
|
+
from uuid import uuid4
|
|
5
7
|
|
|
6
8
|
import pandas as pd
|
|
7
9
|
from graphdatascience import Graph, GraphDataScience
|
|
10
|
+
from pandas import Series
|
|
8
11
|
|
|
9
12
|
from .pandas import _from_dfs
|
|
10
13
|
from .visualization_graph import VisualizationGraph
|
|
11
14
|
|
|
12
15
|
|
|
13
|
-
def
|
|
14
|
-
gds: GraphDataScience, G: Graph,
|
|
16
|
+
def _fetch_node_dfs(
|
|
17
|
+
gds: GraphDataScience, G: Graph, node_properties_by_label: dict[str, list[str]], node_labels: list[str]
|
|
15
18
|
) -> dict[str, pd.DataFrame]:
|
|
16
19
|
return {
|
|
17
20
|
lbl: gds.graph.nodeProperties.stream(
|
|
18
|
-
G, node_properties=
|
|
21
|
+
G, node_properties=node_properties_by_label[lbl], node_labels=[lbl], separate_property_columns=True
|
|
19
22
|
)
|
|
20
23
|
for lbl in node_labels
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
|
|
24
|
-
def
|
|
27
|
+
def _fetch_rel_df(gds: GraphDataScience, G: Graph) -> pd.DataFrame:
|
|
28
|
+
relationship_properties = G.relationship_properties()
|
|
29
|
+
assert isinstance(relationship_properties, Series)
|
|
30
|
+
|
|
31
|
+
relationship_properties_per_type = relationship_properties.tolist()
|
|
32
|
+
property_set: set[str] = set()
|
|
33
|
+
for props in relationship_properties_per_type:
|
|
34
|
+
if props:
|
|
35
|
+
property_set.update(props)
|
|
36
|
+
|
|
37
|
+
if len(property_set) > 0:
|
|
38
|
+
return gds.graph.relationshipProperties.stream(
|
|
39
|
+
G, relationship_properties=list(property_set), separate_property_columns=True
|
|
40
|
+
)
|
|
41
|
+
|
|
25
42
|
return gds.graph.relationships.stream(G)
|
|
26
43
|
|
|
27
44
|
|
|
@@ -31,6 +48,7 @@ def from_gds(
|
|
|
31
48
|
size_property: Optional[str] = None,
|
|
32
49
|
additional_node_properties: Optional[list[str]] = None,
|
|
33
50
|
node_radius_min_max: Optional[tuple[float, float]] = (3, 60),
|
|
51
|
+
max_node_count: int = 10_000,
|
|
34
52
|
) -> VisualizationGraph:
|
|
35
53
|
"""
|
|
36
54
|
Create a VisualizationGraph from a GraphDataScience object and a Graph object.
|
|
@@ -39,6 +57,7 @@ def from_gds(
|
|
|
39
57
|
If the properties are named as the fields of the `Node` class, they will be included as top level fields of the
|
|
40
58
|
created `Node` objects. Otherwise, they will be included in the `properties` dictionary.
|
|
41
59
|
Additionally, a new "labels" node property will be added, containing the node labels of the node.
|
|
60
|
+
Similarly for relationships, a new "relationshipType" property will be added.
|
|
42
61
|
|
|
43
62
|
Parameters
|
|
44
63
|
----------
|
|
@@ -49,52 +68,111 @@ def from_gds(
|
|
|
49
68
|
size_property : str, optional
|
|
50
69
|
Property to use for node size, by default None.
|
|
51
70
|
additional_node_properties : list[str], optional
|
|
52
|
-
Additional properties to include in the visualization node, by default None
|
|
71
|
+
Additional properties to include in the visualization node, by default None which means that all node
|
|
72
|
+
properties will be fetched.
|
|
53
73
|
node_radius_min_max : tuple[float, float], optional
|
|
54
74
|
Minimum and maximum node radius, by default (3, 60).
|
|
55
75
|
To avoid tiny or huge nodes in the visualization, the node sizes are scaled to fit in the given range.
|
|
76
|
+
max_node_count : int, optional
|
|
77
|
+
The maximum number of nodes to fetch from the graph. The graph will be sampled using random walk with restarts
|
|
78
|
+
if its node count exceeds this number.
|
|
56
79
|
"""
|
|
57
80
|
node_properties_from_gds = G.node_properties()
|
|
58
81
|
assert isinstance(node_properties_from_gds, pd.Series)
|
|
59
|
-
actual_node_properties =
|
|
82
|
+
actual_node_properties = node_properties_from_gds.to_dict()
|
|
83
|
+
all_actual_node_properties = list(chain.from_iterable(actual_node_properties.values()))
|
|
60
84
|
|
|
61
|
-
if size_property is not None
|
|
62
|
-
|
|
85
|
+
if size_property is not None:
|
|
86
|
+
if size_property not in all_actual_node_properties:
|
|
87
|
+
raise ValueError(f"There is no node property '{size_property}' in graph '{G.name()}'")
|
|
63
88
|
|
|
64
|
-
if additional_node_properties is
|
|
89
|
+
if additional_node_properties is None:
|
|
90
|
+
node_properties_by_label = {k: set(v) for k, v in actual_node_properties.items()}
|
|
91
|
+
else:
|
|
65
92
|
for prop in additional_node_properties:
|
|
66
|
-
if prop not in
|
|
93
|
+
if prop not in all_actual_node_properties:
|
|
67
94
|
raise ValueError(f"There is no node property '{prop}' in graph '{G.name()}'")
|
|
68
95
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
96
|
+
node_properties_by_label = {}
|
|
97
|
+
for label, props in actual_node_properties.items():
|
|
98
|
+
node_properties_by_label[label] = {
|
|
99
|
+
prop for prop in actual_node_properties[label] if prop in additional_node_properties
|
|
100
|
+
}
|
|
72
101
|
|
|
73
102
|
if size_property is not None:
|
|
74
|
-
|
|
103
|
+
for label, props in node_properties_by_label.items():
|
|
104
|
+
props.add(size_property)
|
|
105
|
+
|
|
106
|
+
node_properties_by_label = {k: list(v) for k, v in node_properties_by_label.items()}
|
|
107
|
+
|
|
108
|
+
node_count = G.node_count()
|
|
109
|
+
if node_count > max_node_count:
|
|
110
|
+
warnings.warn(
|
|
111
|
+
f"The '{G.name()}' projection's node count ({G.node_count()}) exceeds `max_node_count` ({max_node_count}), so subsampling will be applied. Increase `max_node_count` if needed"
|
|
112
|
+
)
|
|
113
|
+
sampling_ratio = float(max_node_count) / node_count
|
|
114
|
+
sample_name = f"neo4j-viz_sample_{uuid4()}"
|
|
115
|
+
G_fetched, _ = gds.graph.sample.rwr(sample_name, G, samplingRatio=sampling_ratio, nodeLabelStratification=True)
|
|
116
|
+
else:
|
|
117
|
+
G_fetched = G
|
|
118
|
+
|
|
119
|
+
property_name = None
|
|
120
|
+
try:
|
|
121
|
+
# Since GDS does not allow us to only fetch node IDs, we add the degree property
|
|
122
|
+
# as a temporary property to ensure that we have at least one property for each label to fetch
|
|
123
|
+
if sum([len(props) == 0 for props in node_properties_by_label.values()]) > 0:
|
|
124
|
+
property_name = f"neo4j-viz_property_{uuid4()}"
|
|
125
|
+
gds.degree.mutate(G_fetched, mutateProperty=property_name)
|
|
126
|
+
for props in node_properties_by_label.values():
|
|
127
|
+
props.append(property_name)
|
|
128
|
+
|
|
129
|
+
node_dfs = _fetch_node_dfs(gds, G_fetched, node_properties_by_label, G_fetched.node_labels())
|
|
130
|
+
if property_name is not None:
|
|
131
|
+
for df in node_dfs.values():
|
|
132
|
+
df.drop(columns=[property_name], inplace=True)
|
|
133
|
+
|
|
134
|
+
rel_df = _fetch_rel_df(gds, G_fetched)
|
|
135
|
+
finally:
|
|
136
|
+
if G_fetched.name() != G.name():
|
|
137
|
+
G_fetched.drop()
|
|
138
|
+
elif property_name is not None:
|
|
139
|
+
gds.graph.nodeProperties.drop(G_fetched, node_properties=[property_name])
|
|
75
140
|
|
|
76
|
-
node_properties = list(node_properties)
|
|
77
|
-
node_dfs = _node_dfs(gds, G, node_properties, G.node_labels())
|
|
78
141
|
for df in node_dfs.values():
|
|
79
|
-
df.
|
|
142
|
+
if property_name is not None and property_name in df.columns:
|
|
143
|
+
df.drop(columns=[property_name], inplace=True)
|
|
80
144
|
|
|
81
145
|
node_props_df = pd.concat(node_dfs.values(), ignore_index=True, axis=0).drop_duplicates()
|
|
82
146
|
if size_property is not None:
|
|
83
|
-
if "size" in
|
|
147
|
+
if "size" in all_actual_node_properties and size_property != "size":
|
|
84
148
|
node_props_df.rename(columns={"size": "__size"}, inplace=True)
|
|
85
149
|
node_props_df.rename(columns={size_property: "size"}, inplace=True)
|
|
86
150
|
|
|
87
151
|
for lbl, df in node_dfs.items():
|
|
88
|
-
if "labels" in
|
|
152
|
+
if "labels" in all_actual_node_properties:
|
|
89
153
|
df.rename(columns={"labels": "__labels"}, inplace=True)
|
|
90
154
|
df["labels"] = lbl
|
|
91
155
|
|
|
92
|
-
|
|
93
|
-
|
|
156
|
+
node_labels_df = pd.concat([df[["nodeId", "labels"]] for df in node_dfs.values()], ignore_index=True, axis=0)
|
|
157
|
+
node_labels_df = node_labels_df.groupby("nodeId").agg({"labels": list})
|
|
94
158
|
|
|
95
|
-
node_df = node_props_df.merge(
|
|
159
|
+
node_df = node_props_df.merge(node_labels_df, on="nodeId")
|
|
96
160
|
|
|
97
|
-
|
|
98
|
-
|
|
161
|
+
if "caption" not in all_actual_node_properties:
|
|
162
|
+
node_df["caption"] = node_df["labels"].astype(str)
|
|
99
163
|
|
|
100
|
-
|
|
164
|
+
if "caption" not in rel_df.columns:
|
|
165
|
+
rel_df["caption"] = rel_df["relationshipType"]
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
return _from_dfs(
|
|
169
|
+
node_df, rel_df, node_radius_min_max=node_radius_min_max, rename_properties={"__size": "size"}, dropna=True
|
|
170
|
+
)
|
|
171
|
+
except ValueError as e:
|
|
172
|
+
err_msg = str(e)
|
|
173
|
+
if "column" in err_msg:
|
|
174
|
+
err_msg = err_msg.replace("column", "property")
|
|
175
|
+
if ("'size'" in err_msg) and (size_property is not None):
|
|
176
|
+
err_msg = err_msg.replace("'size'", f"'{size_property}'")
|
|
177
|
+
raise ValueError(err_msg)
|
|
178
|
+
raise e
|
neo4j_viz/gql_create.py
CHANGED
|
@@ -2,6 +2,8 @@ import re
|
|
|
2
2
|
import uuid
|
|
3
3
|
from typing import Any, Optional
|
|
4
4
|
|
|
5
|
+
from pydantic import BaseModel, ValidationError
|
|
6
|
+
|
|
5
7
|
from neo4j_viz import Node, Relationship, VisualizationGraph
|
|
6
8
|
|
|
7
9
|
|
|
@@ -252,6 +254,20 @@ def from_gql_create(
|
|
|
252
254
|
node_top_level_keys = Node.all_validation_aliases(exempted_fields=["id"])
|
|
253
255
|
rel_top_level_keys = Relationship.all_validation_aliases(exempted_fields=["id", "source", "target"])
|
|
254
256
|
|
|
257
|
+
def _parse_validation_error(e: ValidationError, entity_type: type[BaseModel]) -> None:
|
|
258
|
+
for err in e.errors():
|
|
259
|
+
loc = err["loc"][0]
|
|
260
|
+
if (loc == "size") and size_property is not None:
|
|
261
|
+
loc = size_property
|
|
262
|
+
if loc == "caption":
|
|
263
|
+
if (entity_type == Node) and (node_caption is not None):
|
|
264
|
+
loc = node_caption
|
|
265
|
+
elif (entity_type == Relationship) and (relationship_caption is not None):
|
|
266
|
+
loc = relationship_caption
|
|
267
|
+
raise ValueError(
|
|
268
|
+
f"Error for {entity_type.__name__.lower()} property '{loc}' with provided input '{err['input']}'. Reason: {err['msg']}"
|
|
269
|
+
)
|
|
270
|
+
|
|
255
271
|
nodes = []
|
|
256
272
|
relationships = []
|
|
257
273
|
alias_to_id = {}
|
|
@@ -267,7 +283,10 @@ def from_gql_create(
|
|
|
267
283
|
anonymous_count += 1
|
|
268
284
|
if alias not in alias_to_id:
|
|
269
285
|
alias_to_id[alias] = str(uuid.uuid4())
|
|
270
|
-
|
|
286
|
+
try:
|
|
287
|
+
nodes.append(Node(id=alias_to_id[alias], **top_level, properties=props))
|
|
288
|
+
except ValidationError as e:
|
|
289
|
+
_parse_validation_error(e, Node)
|
|
271
290
|
|
|
272
291
|
continue
|
|
273
292
|
|
|
@@ -283,7 +302,10 @@ def from_gql_create(
|
|
|
283
302
|
anonymous_count += 1
|
|
284
303
|
if left_alias not in alias_to_id:
|
|
285
304
|
alias_to_id[left_alias] = str(uuid.uuid4())
|
|
286
|
-
|
|
305
|
+
try:
|
|
306
|
+
nodes.append(Node(id=alias_to_id[left_alias], **left_top_level, properties=left_props))
|
|
307
|
+
except ValidationError as e:
|
|
308
|
+
_parse_validation_error(e, Node)
|
|
287
309
|
elif left_alias not in alias_to_id:
|
|
288
310
|
snippet = _get_snippet(query, query.index(left_node))
|
|
289
311
|
raise ValueError(f"Relationship references unknown node alias: '{left_alias}' near: `{snippet}`.")
|
|
@@ -295,7 +317,10 @@ def from_gql_create(
|
|
|
295
317
|
anonymous_count += 1
|
|
296
318
|
if right_alias not in alias_to_id:
|
|
297
319
|
alias_to_id[right_alias] = str(uuid.uuid4())
|
|
298
|
-
|
|
320
|
+
try:
|
|
321
|
+
nodes.append(Node(id=alias_to_id[right_alias], **right_top_level, properties=right_props))
|
|
322
|
+
except ValidationError as e:
|
|
323
|
+
_parse_validation_error(e, Node)
|
|
299
324
|
elif right_alias not in alias_to_id:
|
|
300
325
|
snippet = _get_snippet(query, query.index(right_node))
|
|
301
326
|
raise ValueError(f"Relationship references unknown node alias: '{right_alias}' near: `{snippet}`.")
|
|
@@ -313,15 +338,20 @@ def from_gql_create(
|
|
|
313
338
|
if "type" in props:
|
|
314
339
|
props["__type"] = props["type"]
|
|
315
340
|
props["type"] = rel_type
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
relationships.append(
|
|
344
|
+
Relationship(
|
|
345
|
+
id=rel_id,
|
|
346
|
+
source=alias_to_id[left_alias],
|
|
347
|
+
target=alias_to_id[right_alias],
|
|
348
|
+
**top_level,
|
|
349
|
+
properties=props,
|
|
350
|
+
)
|
|
323
351
|
)
|
|
324
|
-
|
|
352
|
+
except ValidationError as e:
|
|
353
|
+
_parse_validation_error(e, Relationship)
|
|
354
|
+
|
|
325
355
|
continue
|
|
326
356
|
|
|
327
357
|
snippet = part[:30]
|
|
@@ -346,6 +376,10 @@ def from_gql_create(
|
|
|
346
376
|
|
|
347
377
|
VG = VisualizationGraph(nodes=nodes, relationships=relationships)
|
|
348
378
|
if (node_radius_min_max is not None) and (size_property is not None):
|
|
349
|
-
|
|
379
|
+
try:
|
|
380
|
+
VG.resize_nodes(node_radius_min_max=node_radius_min_max)
|
|
381
|
+
except TypeError:
|
|
382
|
+
loc = "size" if size_property is None else size_property
|
|
383
|
+
raise ValueError(f"Error for node property '{loc}'. Reason: must be a numerical value")
|
|
350
384
|
|
|
351
385
|
return VG
|
neo4j_viz/neo4j.py
CHANGED
|
@@ -1,24 +1,35 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import warnings
|
|
3
4
|
from typing import Optional, Union
|
|
4
5
|
|
|
5
6
|
import neo4j.graph
|
|
6
|
-
from neo4j import Result
|
|
7
|
+
from neo4j import Driver, Result, RoutingControl
|
|
8
|
+
from pydantic import BaseModel, ValidationError
|
|
7
9
|
|
|
8
10
|
from neo4j_viz.node import Node
|
|
9
11
|
from neo4j_viz.relationship import Relationship
|
|
10
12
|
from neo4j_viz.visualization_graph import VisualizationGraph
|
|
11
13
|
|
|
12
14
|
|
|
15
|
+
def _parse_validation_error(e: ValidationError, entity_type: type[BaseModel]) -> None:
|
|
16
|
+
for err in e.errors():
|
|
17
|
+
loc = err["loc"][0]
|
|
18
|
+
raise ValueError(
|
|
19
|
+
f"Error for {entity_type.__name__.lower()} property '{loc}' with provided input '{err['input']}'. Reason: {err['msg']}"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
13
23
|
def from_neo4j(
|
|
14
|
-
|
|
24
|
+
data: Union[neo4j.graph.Graph, Result, Driver],
|
|
15
25
|
size_property: Optional[str] = None,
|
|
16
26
|
node_caption: Optional[str] = "labels",
|
|
17
27
|
relationship_caption: Optional[str] = "type",
|
|
18
28
|
node_radius_min_max: Optional[tuple[float, float]] = (3, 60),
|
|
29
|
+
row_limit: int = 10_000,
|
|
19
30
|
) -> VisualizationGraph:
|
|
20
31
|
"""
|
|
21
|
-
Create a VisualizationGraph from a Neo4j Graph or Neo4j
|
|
32
|
+
Create a VisualizationGraph from a Neo4j `Graph`, Neo4j `Result` or Neo4j `Driver`.
|
|
22
33
|
|
|
23
34
|
All node and relationship properties will be included in the visualization graph.
|
|
24
35
|
If the properties are named as the fields of the `Node` or `Relationship` classes, they will be included as
|
|
@@ -27,8 +38,9 @@ def from_neo4j(
|
|
|
27
38
|
|
|
28
39
|
Parameters
|
|
29
40
|
----------
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
data : Union[neo4j.graph.Graph, neo4j.Result, neo4j.Driver]
|
|
42
|
+
Either a query result in the shape of a `neo4j.graph.Graph` or `neo4j.Result`, or a `neo4j.Driver` in
|
|
43
|
+
which case a simple default query will be executed internally to retrieve the graph data.
|
|
32
44
|
size_property : str, optional
|
|
33
45
|
Property to use for node size, by default None.
|
|
34
46
|
node_caption : str, optional
|
|
@@ -38,26 +50,60 @@ def from_neo4j(
|
|
|
38
50
|
node_radius_min_max : tuple[float, float], optional
|
|
39
51
|
Minimum and maximum node radius, by default (3, 60).
|
|
40
52
|
To avoid tiny or huge nodes in the visualization, the node sizes are scaled to fit in the given range.
|
|
53
|
+
row_limit : int, optional
|
|
54
|
+
Maximum number of rows to return from the query, by default 10_000.
|
|
55
|
+
This is only used if a `neo4j.Driver` is passed as `result` argument, otherwise the limit is ignored.
|
|
41
56
|
"""
|
|
42
57
|
|
|
43
|
-
if isinstance(
|
|
44
|
-
graph =
|
|
45
|
-
elif isinstance(
|
|
46
|
-
graph =
|
|
58
|
+
if isinstance(data, Result):
|
|
59
|
+
graph = data.graph()
|
|
60
|
+
elif isinstance(data, neo4j.graph.Graph):
|
|
61
|
+
graph = data
|
|
62
|
+
elif isinstance(data, Driver):
|
|
63
|
+
rel_count = data.execute_query(
|
|
64
|
+
"MATCH ()-[r]->() RETURN count(r) as count",
|
|
65
|
+
routing_=RoutingControl.READ,
|
|
66
|
+
result_transformer_=Result.single,
|
|
67
|
+
).get("count") # type: ignore[union-attr]
|
|
68
|
+
if rel_count > row_limit:
|
|
69
|
+
warnings.warn(
|
|
70
|
+
f"Database relationship count ({rel_count}) exceeds `row_limit` ({row_limit}), so limiting will be applied. Increase the `row_limit` if needed"
|
|
71
|
+
)
|
|
72
|
+
graph = data.execute_query(
|
|
73
|
+
f"MATCH (n)-[r]->(m) RETURN n,r,m LIMIT {row_limit}",
|
|
74
|
+
routing_=RoutingControl.READ,
|
|
75
|
+
result_transformer_=Result.graph,
|
|
76
|
+
)
|
|
47
77
|
else:
|
|
48
|
-
raise ValueError(f"Invalid input type `{type(
|
|
78
|
+
raise ValueError(f"Invalid input type `{type(data)}`. Expected `neo4j.Graph`, `neo4j.Result` or `neo4j.Driver`")
|
|
49
79
|
|
|
50
80
|
all_node_field_aliases = Node.all_validation_aliases()
|
|
51
81
|
all_rel_field_aliases = Relationship.all_validation_aliases()
|
|
52
82
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
83
|
+
try:
|
|
84
|
+
nodes = [
|
|
85
|
+
_map_node(node, all_node_field_aliases, size_property, caption_property=node_caption)
|
|
86
|
+
for node in graph.nodes
|
|
87
|
+
]
|
|
88
|
+
except ValueError as e:
|
|
89
|
+
err_msg = str(e)
|
|
90
|
+
if ("'size'" in err_msg) and (size_property is not None):
|
|
91
|
+
err_msg = err_msg.replace("'size'", f"'{size_property}'")
|
|
92
|
+
elif ("'caption'" in err_msg) and (node_caption is not None):
|
|
93
|
+
err_msg = err_msg.replace("'caption'", f"'{node_caption}'")
|
|
94
|
+
raise ValueError(err_msg)
|
|
95
|
+
|
|
56
96
|
relationships = []
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
97
|
+
try:
|
|
98
|
+
for rel in graph.relationships:
|
|
99
|
+
mapped_rel = _map_relationship(rel, all_rel_field_aliases, caption_property=relationship_caption)
|
|
100
|
+
if mapped_rel:
|
|
101
|
+
relationships.append(mapped_rel)
|
|
102
|
+
except ValueError as e:
|
|
103
|
+
err_msg = str(e)
|
|
104
|
+
if ("'caption'" in err_msg) and (relationship_caption is not None):
|
|
105
|
+
err_msg = err_msg.replace("'caption'", f"'{relationship_caption}'")
|
|
106
|
+
raise ValueError(err_msg)
|
|
61
107
|
|
|
62
108
|
VG = VisualizationGraph(nodes, relationships)
|
|
63
109
|
|
|
@@ -102,7 +148,12 @@ def _map_node(
|
|
|
102
148
|
properties["__labels"] = properties["labels"]
|
|
103
149
|
properties["labels"] = labels
|
|
104
150
|
|
|
105
|
-
|
|
151
|
+
try:
|
|
152
|
+
viz_node = Node(**top_level_fields, properties=properties)
|
|
153
|
+
except ValidationError as e:
|
|
154
|
+
_parse_validation_error(e, Node)
|
|
155
|
+
|
|
156
|
+
return viz_node
|
|
106
157
|
|
|
107
158
|
|
|
108
159
|
def _map_relationship(
|
|
@@ -135,4 +186,9 @@ def _map_relationship(
|
|
|
135
186
|
properties["__type"] = properties["type"]
|
|
136
187
|
properties["type"] = rel.type
|
|
137
188
|
|
|
138
|
-
|
|
189
|
+
try:
|
|
190
|
+
viz_rel = Relationship(**top_level_fields, properties=properties)
|
|
191
|
+
except ValidationError as e:
|
|
192
|
+
_parse_validation_error(e, Relationship)
|
|
193
|
+
|
|
194
|
+
return viz_rel
|
neo4j_viz/options.py
CHANGED
|
@@ -2,10 +2,10 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import warnings
|
|
4
4
|
from enum import Enum
|
|
5
|
-
from typing import Any, Optional
|
|
5
|
+
from typing import Any, Optional, Union
|
|
6
6
|
|
|
7
7
|
import enum_tools.documentation
|
|
8
|
-
from pydantic import BaseModel, Field
|
|
8
|
+
from pydantic import BaseModel, Field, ValidationError, model_validator
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
@enum_tools.documentation.document_enum
|
|
@@ -33,6 +33,69 @@ class Layout(str, Enum):
|
|
|
33
33
|
GRID = "grid"
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
@enum_tools.documentation.document_enum
|
|
37
|
+
class Direction(str, Enum):
|
|
38
|
+
"""
|
|
39
|
+
The direction in which the layout should be oriented
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
LEFT = "left"
|
|
43
|
+
RIGHT = "right"
|
|
44
|
+
UP = "up"
|
|
45
|
+
DOWN = "down"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@enum_tools.documentation.document_enum
|
|
49
|
+
class Packing(str, Enum):
|
|
50
|
+
"""
|
|
51
|
+
The packing method to be used
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
BIN = "bin"
|
|
55
|
+
STACK = "stack"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class HierarchicalLayoutOptions(BaseModel, extra="forbid"):
|
|
59
|
+
"""
|
|
60
|
+
The options for the hierarchical layout.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
direction: Optional[Direction] = None
|
|
64
|
+
packaging: Optional[Packing] = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ForceDirectedLayoutOptions(BaseModel, extra="forbid"):
|
|
68
|
+
"""
|
|
69
|
+
The options for the force-directed layout.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
gravity: Optional[float] = None
|
|
73
|
+
simulationStopVelocity: Optional[float] = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
LayoutOptions = Union[HierarchicalLayoutOptions, ForceDirectedLayoutOptions]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def construct_layout_options(layout: Layout, options: dict[str, Any]) -> Optional[LayoutOptions]:
|
|
80
|
+
if not options:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
if layout == Layout.FORCE_DIRECTED:
|
|
84
|
+
try:
|
|
85
|
+
return ForceDirectedLayoutOptions(**options)
|
|
86
|
+
except ValidationError as e:
|
|
87
|
+
_parse_validation_error(e, ForceDirectedLayoutOptions)
|
|
88
|
+
elif layout == Layout.HIERARCHICAL:
|
|
89
|
+
try:
|
|
90
|
+
return HierarchicalLayoutOptions(**options)
|
|
91
|
+
except ValidationError as e:
|
|
92
|
+
_parse_validation_error(e, ForceDirectedLayoutOptions)
|
|
93
|
+
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"Layout options only supported for layouts `{Layout.FORCE_DIRECTED}` and `{Layout.HIERARCHICAL}`, but was `{layout}`"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
36
99
|
@enum_tools.documentation.document_enum
|
|
37
100
|
class Renderer(str, Enum):
|
|
38
101
|
"""
|
|
@@ -72,6 +135,9 @@ class RenderOptions(BaseModel, extra="allow"):
|
|
|
72
135
|
"""
|
|
73
136
|
|
|
74
137
|
layout: Optional[Layout] = None
|
|
138
|
+
layout_options: Optional[Union[HierarchicalLayoutOptions, ForceDirectedLayoutOptions]] = Field(
|
|
139
|
+
None, serialization_alias="layoutOptions"
|
|
140
|
+
)
|
|
75
141
|
renderer: Optional[Renderer] = None
|
|
76
142
|
|
|
77
143
|
pan_X: Optional[float] = Field(None, serialization_alias="panX")
|
|
@@ -84,5 +150,34 @@ class RenderOptions(BaseModel, extra="allow"):
|
|
|
84
150
|
min_zoom: Optional[float] = Field(None, serialization_alias="minZoom", description="The minimum zoom level allowed")
|
|
85
151
|
allow_dynamic_min_zoom: Optional[bool] = Field(None, serialization_alias="allowDynamicMinZoom")
|
|
86
152
|
|
|
153
|
+
@model_validator(mode="after")
|
|
154
|
+
def check_layout_options_match(self) -> RenderOptions:
|
|
155
|
+
if self.layout_options is None:
|
|
156
|
+
return self
|
|
157
|
+
|
|
158
|
+
if self.layout == Layout.HIERARCHICAL and not isinstance(self.layout_options, HierarchicalLayoutOptions):
|
|
159
|
+
raise ValueError("layout_options must be of type HierarchicalLayoutOptions for hierarchical layout")
|
|
160
|
+
if self.layout == Layout.FORCE_DIRECTED and not isinstance(self.layout_options, ForceDirectedLayoutOptions):
|
|
161
|
+
raise ValueError("layout_options must be of type ForceDirectedLayoutOptions for force-directed layout")
|
|
162
|
+
return self
|
|
163
|
+
|
|
87
164
|
def to_dict(self) -> dict[str, Any]:
|
|
88
165
|
return self.model_dump(exclude_none=True, by_alias=True)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _parse_validation_error(e: ValidationError, entity_type: type[BaseModel]) -> None:
|
|
169
|
+
for err in e.errors():
|
|
170
|
+
loc = err["loc"][0]
|
|
171
|
+
if err["type"] == "missing":
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"Mandatory `{entity_type.__name__}` parameter '{loc}' is missing. Expected one of {entity_type.model_fields[loc].validation_alias.choices} to be present" # type: ignore
|
|
174
|
+
)
|
|
175
|
+
elif err["type"] == "extra_forbidden":
|
|
176
|
+
raise ValueError(
|
|
177
|
+
f"Unexpected `{entity_type.__name__}` parameter '{loc}' with provided input '{err['input']}'. "
|
|
178
|
+
f"Allowed parameters are: {', '.join(entity_type.model_fields.keys())}"
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
raise ValueError(
|
|
182
|
+
f"Error for `{entity_type.__name__}` parameter '{loc}' with provided input '{err['input']}'. Reason: {err['msg']}"
|
|
183
|
+
)
|