neo4j-viz 0.3.1__tar.gz → 0.4.1__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 (46) hide show
  1. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/PKG-INFO +1 -1
  2. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/pyproject.toml +1 -1
  3. neo4j_viz-0.4.1/src/neo4j_viz/__init__.py +25 -0
  4. neo4j_viz-0.4.1/src/neo4j_viz/gds.py +178 -0
  5. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/src/neo4j_viz/gql_create.py +46 -12
  6. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/src/neo4j_viz/neo4j.py +75 -19
  7. neo4j_viz-0.4.1/src/neo4j_viz/options.py +183 -0
  8. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/src/neo4j_viz/pandas.py +77 -17
  9. neo4j_viz-0.4.1/src/neo4j_viz/resources/nvl_entrypoint/base.js +2 -0
  10. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/src/neo4j_viz/visualization_graph.py +22 -2
  11. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/src/neo4j_viz.egg-info/PKG-INFO +1 -1
  12. neo4j_viz-0.4.1/tests/test_gds.py +346 -0
  13. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/tests/test_gql_create.py +27 -0
  14. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/tests/test_neo4j.py +105 -1
  15. neo4j_viz-0.4.1/tests/test_options.py +24 -0
  16. neo4j_viz-0.4.1/tests/test_pandas.py +228 -0
  17. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/tests/test_render.py +22 -0
  18. neo4j_viz-0.3.1/src/neo4j_viz/__init__.py +0 -6
  19. neo4j_viz-0.3.1/src/neo4j_viz/gds.py +0 -100
  20. neo4j_viz-0.3.1/src/neo4j_viz/options.py +0 -88
  21. neo4j_viz-0.3.1/src/neo4j_viz/resources/nvl_entrypoint/base.js +0 -2
  22. neo4j_viz-0.3.1/tests/test_gds.py +0 -134
  23. neo4j_viz-0.3.1/tests/test_options.py +0 -7
  24. neo4j_viz-0.3.1/tests/test_pandas.py +0 -106
  25. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/README.md +0 -0
  26. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/setup.cfg +0 -0
  27. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/src/neo4j_viz/colors.py +0 -0
  28. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/src/neo4j_viz/node.py +0 -0
  29. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/src/neo4j_viz/node_size.py +0 -0
  30. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/src/neo4j_viz/nvl.py +0 -0
  31. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/src/neo4j_viz/py.typed +0 -0
  32. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/src/neo4j_viz/relationship.py +0 -0
  33. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/src/neo4j_viz/resources/icons/screenshot.svg +0 -0
  34. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/src/neo4j_viz/resources/icons/zoom-in.svg +0 -0
  35. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/src/neo4j_viz/resources/icons/zoom-out.svg +0 -0
  36. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/src/neo4j_viz/resources/nvl_entrypoint/styles.css +0 -0
  37. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/src/neo4j_viz.egg-info/SOURCES.txt +0 -0
  38. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/src/neo4j_viz.egg-info/dependency_links.txt +0 -0
  39. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/src/neo4j_viz.egg-info/requires.txt +0 -0
  40. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/src/neo4j_viz.egg-info/top_level.txt +0 -0
  41. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/tests/test_colors.py +0 -0
  42. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/tests/test_node.py +0 -0
  43. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/tests/test_notebooks.py +0 -0
  44. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/tests/test_pinned.py +0 -0
  45. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/tests/test_relationship.py +0 -0
  46. {neo4j_viz-0.3.1 → neo4j_viz-0.4.1}/tests/test_sizes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: neo4j-viz
3
- Version: 0.3.1
3
+ Version: 0.4.1
4
4
  Summary: A simple graph visualization tool
5
5
  Author-email: Neo4j <team-gds@neo4j.org>
6
6
  Project-URL: Homepage, https://neo4j.com/
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "neo4j-viz"
7
- version = "0.3.1"
7
+ version = "0.4.1"
8
8
  description = "A simple graph visualization tool"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Neo4j", email = "team-gds@neo4j.org" }]
@@ -0,0 +1,25 @@
1
+ from .node import Node
2
+ from .options import (
3
+ CaptionAlignment,
4
+ Direction,
5
+ ForceDirectedLayoutOptions,
6
+ HierarchicalLayoutOptions,
7
+ Layout,
8
+ Packing,
9
+ Renderer,
10
+ )
11
+ from .relationship import Relationship
12
+ from .visualization_graph import VisualizationGraph
13
+
14
+ __all__ = [
15
+ "VisualizationGraph",
16
+ "Node",
17
+ "Relationship",
18
+ "CaptionAlignment",
19
+ "Layout",
20
+ "Renderer",
21
+ "ForceDirectedLayoutOptions",
22
+ "HierarchicalLayoutOptions",
23
+ "Direction",
24
+ "Packing",
25
+ ]
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+ from itertools import chain
5
+ from typing import Optional
6
+ from uuid import uuid4
7
+
8
+ import pandas as pd
9
+ from graphdatascience import Graph, GraphDataScience
10
+ from pandas import Series
11
+
12
+ from .pandas import _from_dfs
13
+ from .visualization_graph import VisualizationGraph
14
+
15
+
16
+ def _fetch_node_dfs(
17
+ gds: GraphDataScience, G: Graph, node_properties_by_label: dict[str, list[str]], node_labels: list[str]
18
+ ) -> dict[str, pd.DataFrame]:
19
+ return {
20
+ lbl: gds.graph.nodeProperties.stream(
21
+ G, node_properties=node_properties_by_label[lbl], node_labels=[lbl], separate_property_columns=True
22
+ )
23
+ for lbl in node_labels
24
+ }
25
+
26
+
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
+
42
+ return gds.graph.relationships.stream(G)
43
+
44
+
45
+ def from_gds(
46
+ gds: GraphDataScience,
47
+ G: Graph,
48
+ size_property: Optional[str] = None,
49
+ additional_node_properties: Optional[list[str]] = None,
50
+ node_radius_min_max: Optional[tuple[float, float]] = (3, 60),
51
+ max_node_count: int = 10_000,
52
+ ) -> VisualizationGraph:
53
+ """
54
+ Create a VisualizationGraph from a GraphDataScience object and a Graph object.
55
+
56
+ All `additional_node_properties` will be included in the visualization graph.
57
+ If the properties are named as the fields of the `Node` class, they will be included as top level fields of the
58
+ created `Node` objects. Otherwise, they will be included in the `properties` dictionary.
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.
61
+
62
+ Parameters
63
+ ----------
64
+ gds : GraphDataScience
65
+ GraphDataScience object.
66
+ G : Graph
67
+ Graph object.
68
+ size_property : str, optional
69
+ Property to use for node size, by default None.
70
+ additional_node_properties : list[str], optional
71
+ Additional properties to include in the visualization node, by default None which means that all node
72
+ properties will be fetched.
73
+ node_radius_min_max : tuple[float, float], optional
74
+ Minimum and maximum node radius, by default (3, 60).
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.
79
+ """
80
+ node_properties_from_gds = G.node_properties()
81
+ assert isinstance(node_properties_from_gds, pd.Series)
82
+ actual_node_properties = node_properties_from_gds.to_dict()
83
+ all_actual_node_properties = list(chain.from_iterable(actual_node_properties.values()))
84
+
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()}'")
88
+
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:
92
+ for prop in additional_node_properties:
93
+ if prop not in all_actual_node_properties:
94
+ raise ValueError(f"There is no node property '{prop}' in graph '{G.name()}'")
95
+
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
+ }
101
+
102
+ if size_property is not None:
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])
140
+
141
+ for df in node_dfs.values():
142
+ if property_name is not None and property_name in df.columns:
143
+ df.drop(columns=[property_name], inplace=True)
144
+
145
+ node_props_df = pd.concat(node_dfs.values(), ignore_index=True, axis=0).drop_duplicates()
146
+ if size_property is not None:
147
+ if "size" in all_actual_node_properties and size_property != "size":
148
+ node_props_df.rename(columns={"size": "__size"}, inplace=True)
149
+ node_props_df.rename(columns={size_property: "size"}, inplace=True)
150
+
151
+ for lbl, df in node_dfs.items():
152
+ if "labels" in all_actual_node_properties:
153
+ df.rename(columns={"labels": "__labels"}, inplace=True)
154
+ df["labels"] = lbl
155
+
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})
158
+
159
+ node_df = node_props_df.merge(node_labels_df, on="nodeId")
160
+
161
+ if "caption" not in all_actual_node_properties:
162
+ node_df["caption"] = node_df["labels"].astype(str)
163
+
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
@@ -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
- nodes.append(Node(id=alias_to_id[alias], **top_level, properties=props))
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
- nodes.append(Node(id=alias_to_id[left_alias], **left_top_level, properties=left_props))
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
- nodes.append(Node(id=alias_to_id[right_alias], **right_top_level, properties=right_props))
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
- relationships.append(
317
- Relationship(
318
- id=rel_id,
319
- source=alias_to_id[left_alias],
320
- target=alias_to_id[right_alias],
321
- **top_level,
322
- properties=props,
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
- VG.resize_nodes(node_radius_min_max=node_radius_min_max)
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
@@ -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
- result: Union[neo4j.graph.Graph, Result],
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 Result object.
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
- result : Union[neo4j.graph.Graph, Result]
31
- Query result either in shape of a Graph or result.
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(result, Result):
44
- graph = result.graph()
45
- elif isinstance(result, neo4j.graph.Graph):
46
- graph = result
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(result)}`. Expected `neo4j.Graph` or `neo4j.Result`")
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
- nodes = [
54
- _map_node(node, all_node_field_aliases, size_property, caption_property=node_caption) for node in graph.nodes
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
- for rel in graph.relationships:
58
- mapped_rel = _map_relationship(rel, all_rel_field_aliases, caption_property=relationship_caption)
59
- if mapped_rel:
60
- relationships.append(mapped_rel)
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
- return Node(**top_level_fields, properties=properties)
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
- return Relationship(**top_level_fields, properties=properties)
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
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+ from enum import Enum
5
+ from typing import Any, Optional, Union
6
+
7
+ import enum_tools.documentation
8
+ from pydantic import BaseModel, Field, ValidationError, model_validator
9
+
10
+
11
+ @enum_tools.documentation.document_enum
12
+ class CaptionAlignment(str, Enum):
13
+ """
14
+ The alignment of the caption text for nodes and relationships.
15
+ """
16
+
17
+ TOP = "top"
18
+ CENTER = "center"
19
+ BOTTOM = "bottom"
20
+
21
+
22
+ @enum_tools.documentation.document_enum
23
+ class Layout(str, Enum):
24
+ FORCE_DIRECTED = "forcedirected"
25
+ HIERARCHICAL = "hierarchical"
26
+ """
27
+ The nodes are then arranged by the directionality of their relationships
28
+ """
29
+ COORDINATE = "free"
30
+ """
31
+ The coordinate layout sets the position of each node based on the `x` and `y` properties of the node.
32
+ """
33
+ GRID = "grid"
34
+
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
+
99
+ @enum_tools.documentation.document_enum
100
+ class Renderer(str, Enum):
101
+ """
102
+ The renderer used to render the visualization.
103
+ """
104
+
105
+ WEB_GL = "webgl"
106
+ """
107
+ The WebGL renderer is optimized for performance and handles large graphs better.
108
+ However, it does not render text, icons, and arrowheads on relationships.
109
+ """
110
+ CANVAS = "canvas"
111
+ """
112
+ The canvas renderer has worse performance than the WebGL renderer, so is less well suited to render large graphs.
113
+ However, it can render text, icons, and arrowheads on relationships.
114
+ """
115
+
116
+ @classmethod
117
+ def check(self, renderer: Renderer, num_nodes: int) -> None:
118
+ if renderer == Renderer.CANVAS and num_nodes > 10_000:
119
+ warnings.warn(
120
+ "To visualize more than 10.000 nodes, we recommend using the WebGL renderer "
121
+ "instead of the canvas renderer for better performance. You can set the renderer "
122
+ "using the `renderer` parameter"
123
+ )
124
+ if renderer == Renderer.WEB_GL:
125
+ warnings.warn(
126
+ "Although better for performance, the WebGL renderer cannot render text, icons "
127
+ "and arrowheads on relationships. If you need these features, use the canvas renderer "
128
+ "by setting the `renderer` parameter"
129
+ )
130
+
131
+
132
+ class RenderOptions(BaseModel, extra="allow"):
133
+ """
134
+ Options as documented at https://neo4j.com/docs/nvl/current/base-library/#_options
135
+ """
136
+
137
+ layout: Optional[Layout] = None
138
+ layout_options: Optional[Union[HierarchicalLayoutOptions, ForceDirectedLayoutOptions]] = Field(
139
+ None, serialization_alias="layoutOptions"
140
+ )
141
+ renderer: Optional[Renderer] = None
142
+
143
+ pan_X: Optional[float] = Field(None, serialization_alias="panX")
144
+ pan_Y: Optional[float] = Field(None, serialization_alias="panY")
145
+
146
+ initial_zoom: Optional[float] = Field(None, serialization_alias="initialZoom", description="The initial zoom level")
147
+ max_zoom: Optional[float] = Field(
148
+ None, serialization_alias="maxZoom", description="The maximum zoom level allowed."
149
+ )
150
+ min_zoom: Optional[float] = Field(None, serialization_alias="minZoom", description="The minimum zoom level allowed")
151
+ allow_dynamic_min_zoom: Optional[bool] = Field(None, serialization_alias="allowDynamicMinZoom")
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
+
164
+ def to_dict(self) -> dict[str, Any]:
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
+ )