neo4j-viz 0.3.0__py3-none-any.whl → 0.4.0__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 CHANGED
@@ -1,6 +1,25 @@
1
1
  from .node import Node
2
- from .options import CaptionAlignment, Layout, Renderer
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__ = ["VisualizationGraph", "Node", "Relationship", "CaptionAlignment", "Layout", "Renderer"]
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
@@ -2,15 +2,17 @@ from __future__ import annotations
2
2
 
3
3
  from itertools import chain
4
4
  from typing import Optional
5
+ from uuid import uuid4
5
6
 
6
7
  import pandas as pd
7
8
  from graphdatascience import Graph, GraphDataScience
9
+ from pandas import Series
8
10
 
9
11
  from .pandas import _from_dfs
10
12
  from .visualization_graph import VisualizationGraph
11
13
 
12
14
 
13
- def _node_dfs(
15
+ def _fetch_node_dfs(
14
16
  gds: GraphDataScience, G: Graph, node_properties: list[str], node_labels: list[str]
15
17
  ) -> dict[str, pd.DataFrame]:
16
18
  return {
@@ -21,7 +23,21 @@ def _node_dfs(
21
23
  }
22
24
 
23
25
 
24
- def _rel_df(gds: GraphDataScience, G: Graph) -> pd.DataFrame:
26
+ def _fetch_rel_df(gds: GraphDataScience, G: Graph) -> pd.DataFrame:
27
+ relationship_properties = G.relationship_properties()
28
+ assert isinstance(relationship_properties, Series)
29
+
30
+ relationship_properties_per_type = relationship_properties.tolist()
31
+ property_set: set[str] = set()
32
+ for props in relationship_properties_per_type:
33
+ if props:
34
+ property_set.update(props)
35
+
36
+ if len(property_set) > 0:
37
+ return gds.graph.relationshipProperties.stream(
38
+ G, relationship_properties=list(property_set), separate_property_columns=True
39
+ )
40
+
25
41
  return gds.graph.relationships.stream(G)
26
42
 
27
43
 
@@ -31,6 +47,7 @@ def from_gds(
31
47
  size_property: Optional[str] = None,
32
48
  additional_node_properties: Optional[list[str]] = None,
33
49
  node_radius_min_max: Optional[tuple[float, float]] = (3, 60),
50
+ max_node_count: int = 10_000,
34
51
  ) -> VisualizationGraph:
35
52
  """
36
53
  Create a VisualizationGraph from a GraphDataScience object and a Graph object.
@@ -49,10 +66,14 @@ def from_gds(
49
66
  size_property : str, optional
50
67
  Property to use for node size, by default None.
51
68
  additional_node_properties : list[str], optional
52
- Additional properties to include in the visualization node, by default None. They can be used later for modifying the node appearance.
69
+ Additional properties to include in the visualization node, by default None which means that all node
70
+ properties will be fetched.
53
71
  node_radius_min_max : tuple[float, float], optional
54
72
  Minimum and maximum node radius, by default (3, 60).
55
73
  To avoid tiny or huge nodes in the visualization, the node sizes are scaled to fit in the given range.
74
+ max_node_count : int, optional
75
+ The maximum number of nodes to fetch from the graph. The graph will be sampled using random walk with restarts
76
+ if its node count exceeds this number.
56
77
  """
57
78
  node_properties_from_gds = G.node_properties()
58
79
  assert isinstance(node_properties_from_gds, pd.Series)
@@ -61,7 +82,9 @@ def from_gds(
61
82
  if size_property is not None and size_property not in actual_node_properties:
62
83
  raise ValueError(f"There is no node property '{size_property}' in graph '{G.name()}'")
63
84
 
64
- if additional_node_properties is not None:
85
+ if additional_node_properties is None:
86
+ additional_node_properties = actual_node_properties
87
+ else:
65
88
  for prop in additional_node_properties:
66
89
  if prop not in actual_node_properties:
67
90
  raise ValueError(f"There is no node property '{prop}' in graph '{G.name()}'")
@@ -69,14 +92,40 @@ def from_gds(
69
92
  node_properties = set()
70
93
  if additional_node_properties is not None:
71
94
  node_properties.update(additional_node_properties)
72
-
73
95
  if size_property is not None:
74
96
  node_properties.add(size_property)
75
-
76
97
  node_properties = list(node_properties)
77
- node_dfs = _node_dfs(gds, G, node_properties, G.node_labels())
98
+
99
+ node_count = G.node_count()
100
+ if node_count > max_node_count:
101
+ sampling_ratio = float(max_node_count) / node_count
102
+ sample_name = f"neo4j-viz_sample_{uuid4()}"
103
+ G_fetched, _ = gds.graph.sample.rwr(sample_name, G, samplingRatio=sampling_ratio, nodeLabelStratification=True)
104
+ else:
105
+ G_fetched = G
106
+
107
+ property_name = None
108
+ try:
109
+ # Since GDS does not allow us to only fetch node IDs, we add the degree property
110
+ # as a temporary property to ensure that we have at least one property to fetch
111
+ if len(actual_node_properties) == 0:
112
+ property_name = f"neo4j-viz_property_{uuid4()}"
113
+ gds.degree.mutate(G_fetched, mutateProperty=property_name)
114
+ node_properties = [property_name]
115
+
116
+ node_dfs = _fetch_node_dfs(gds, G_fetched, node_properties, G_fetched.node_labels())
117
+ rel_df = _fetch_rel_df(gds, G_fetched)
118
+ finally:
119
+ if G_fetched.name() != G.name():
120
+ G_fetched.drop()
121
+ elif property_name is not None:
122
+ gds.graph.nodeProperties.drop(G_fetched, node_properties=[property_name])
123
+
78
124
  for df in node_dfs.values():
79
125
  df.rename(columns={"nodeId": "id"}, inplace=True)
126
+ if property_name is not None and property_name in df.columns:
127
+ df.drop(columns=[property_name], inplace=True)
128
+ rel_df.rename(columns={"sourceNodeId": "source", "targetNodeId": "target"}, inplace=True)
80
129
 
81
130
  node_props_df = pd.concat(node_dfs.values(), ignore_index=True, axis=0).drop_duplicates()
82
131
  if size_property is not None:
@@ -89,12 +138,21 @@ def from_gds(
89
138
  df.rename(columns={"labels": "__labels"}, inplace=True)
90
139
  df["labels"] = lbl
91
140
 
92
- node_lbls_df = pd.concat([df[["id", "labels"]] for df in node_dfs.values()], ignore_index=True, axis=0)
93
- node_lbls_df = node_lbls_df.groupby("id").agg({"labels": list})
141
+ node_labels_df = pd.concat([df[["id", "labels"]] for df in node_dfs.values()], ignore_index=True, axis=0)
142
+ node_labels_df = node_labels_df.groupby("id").agg({"labels": list})
94
143
 
95
- node_df = node_props_df.merge(node_lbls_df, on="id")
144
+ node_df = node_props_df.merge(node_labels_df, on="id")
96
145
 
97
- rel_df = _rel_df(gds, G)
98
- rel_df.rename(columns={"sourceNodeId": "source", "targetNodeId": "target"}, inplace=True)
146
+ if "caption" not in actual_node_properties:
147
+ node_df["caption"] = node_df["labels"].astype(str)
99
148
 
100
- return _from_dfs(node_df, rel_df, node_radius_min_max=node_radius_min_max, rename_properties={"__size": "size"})
149
+ try:
150
+ return _from_dfs(node_df, rel_df, node_radius_min_max=node_radius_min_max, rename_properties={"__size": "size"})
151
+ except ValueError as e:
152
+ err_msg = str(e)
153
+ if "column" in err_msg:
154
+ err_msg = err_msg.replace("column", "property")
155
+ if ("'size'" in err_msg) and (size_property is not None):
156
+ err_msg = err_msg.replace("'size'", f"'{size_property}'")
157
+ raise ValueError(err_msg)
158
+ 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
 
@@ -249,13 +251,22 @@ def from_gql_create(
249
251
  node_pattern = re.compile(r"^\(([^)]*)\)$")
250
252
  rel_pattern = re.compile(r"^\(([^)]*)\)-\s*\[\s*:(\w+)\s*(\{[^}]*\})?\s*\]->\(([^)]*)\)$")
251
253
 
252
- node_top_level_keys = set(Node.model_fields.keys())
253
- node_top_level_keys.remove("id")
254
-
255
- rel_top_level_keys = set(Relationship.model_fields.keys())
256
- rel_top_level_keys.remove("id")
257
- rel_top_level_keys.remove("source")
258
- rel_top_level_keys.remove("target")
254
+ node_top_level_keys = Node.all_validation_aliases(exempted_fields=["id"])
255
+ rel_top_level_keys = Relationship.all_validation_aliases(exempted_fields=["id", "source", "target"])
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
+ )
259
270
 
260
271
  nodes = []
261
272
  relationships = []
@@ -272,7 +283,10 @@ def from_gql_create(
272
283
  anonymous_count += 1
273
284
  if alias not in alias_to_id:
274
285
  alias_to_id[alias] = str(uuid.uuid4())
275
- 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)
276
290
 
277
291
  continue
278
292
 
@@ -288,7 +302,10 @@ def from_gql_create(
288
302
  anonymous_count += 1
289
303
  if left_alias not in alias_to_id:
290
304
  alias_to_id[left_alias] = str(uuid.uuid4())
291
- 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)
292
309
  elif left_alias not in alias_to_id:
293
310
  snippet = _get_snippet(query, query.index(left_node))
294
311
  raise ValueError(f"Relationship references unknown node alias: '{left_alias}' near: `{snippet}`.")
@@ -300,7 +317,10 @@ def from_gql_create(
300
317
  anonymous_count += 1
301
318
  if right_alias not in alias_to_id:
302
319
  alias_to_id[right_alias] = str(uuid.uuid4())
303
- 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)
304
324
  elif right_alias not in alias_to_id:
305
325
  snippet = _get_snippet(query, query.index(right_node))
306
326
  raise ValueError(f"Relationship references unknown node alias: '{right_alias}' near: `{snippet}`.")
@@ -318,15 +338,20 @@ def from_gql_create(
318
338
  if "type" in props:
319
339
  props["__type"] = props["type"]
320
340
  props["type"] = rel_type
321
- relationships.append(
322
- Relationship(
323
- id=rel_id,
324
- source=alias_to_id[left_alias],
325
- target=alias_to_id[right_alias],
326
- **top_level,
327
- 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
+ )
328
351
  )
329
- )
352
+ except ValidationError as e:
353
+ _parse_validation_error(e, Relationship)
354
+
330
355
  continue
331
356
 
332
357
  snippet = part[:30]
@@ -351,6 +376,10 @@ def from_gql_create(
351
376
 
352
377
  VG = VisualizationGraph(nodes=nodes, relationships=relationships)
353
378
  if (node_radius_min_max is not None) and (size_property is not None):
354
- 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")
355
384
 
356
385
  return VG
neo4j_viz/neo4j.py CHANGED
@@ -4,12 +4,21 @@ from typing import Optional, Union
4
4
 
5
5
  import neo4j.graph
6
6
  from neo4j import Result
7
+ from pydantic import BaseModel, ValidationError
7
8
 
8
9
  from neo4j_viz.node import Node
9
10
  from neo4j_viz.relationship import Relationship
10
11
  from neo4j_viz.visualization_graph import VisualizationGraph
11
12
 
12
13
 
14
+ def _parse_validation_error(e: ValidationError, entity_type: type[BaseModel]) -> None:
15
+ for err in e.errors():
16
+ loc = err["loc"][0]
17
+ raise ValueError(
18
+ f"Error for {entity_type.__name__.lower()} property '{loc}' with provided input '{err['input']}'. Reason: {err['msg']}"
19
+ )
20
+
21
+
13
22
  def from_neo4j(
14
23
  result: Union[neo4j.graph.Graph, Result],
15
24
  size_property: Optional[str] = None,
@@ -47,12 +56,33 @@ def from_neo4j(
47
56
  else:
48
57
  raise ValueError(f"Invalid input type `{type(result)}`. Expected `neo4j.Graph` or `neo4j.Result`")
49
58
 
50
- nodes = [_map_node(node, size_property, caption_property=node_caption) for node in graph.nodes]
59
+ all_node_field_aliases = Node.all_validation_aliases()
60
+ all_rel_field_aliases = Relationship.all_validation_aliases()
61
+
62
+ try:
63
+ nodes = [
64
+ _map_node(node, all_node_field_aliases, size_property, caption_property=node_caption)
65
+ for node in graph.nodes
66
+ ]
67
+ except ValueError as e:
68
+ err_msg = str(e)
69
+ if ("'size'" in err_msg) and (size_property is not None):
70
+ err_msg = err_msg.replace("'size'", f"'{size_property}'")
71
+ elif ("'caption'" in err_msg) and (node_caption is not None):
72
+ err_msg = err_msg.replace("'caption'", f"'{node_caption}'")
73
+ raise ValueError(err_msg)
74
+
51
75
  relationships = []
52
- for rel in graph.relationships:
53
- mapped_rel = _map_relationship(rel, caption_property=relationship_caption)
54
- if mapped_rel:
55
- relationships.append(mapped_rel)
76
+ try:
77
+ for rel in graph.relationships:
78
+ mapped_rel = _map_relationship(rel, all_rel_field_aliases, caption_property=relationship_caption)
79
+ if mapped_rel:
80
+ relationships.append(mapped_rel)
81
+ except ValueError as e:
82
+ err_msg = str(e)
83
+ if ("'caption'" in err_msg) and (relationship_caption is not None):
84
+ err_msg = err_msg.replace("'caption'", f"'{relationship_caption}'")
85
+ raise ValueError(err_msg)
56
86
 
57
87
  VG = VisualizationGraph(nodes, relationships)
58
88
 
@@ -62,7 +92,12 @@ def from_neo4j(
62
92
  return VG
63
93
 
64
94
 
65
- def _map_node(node: neo4j.graph.Node, size_property: Optional[str], caption_property: Optional[str]) -> Node:
95
+ def _map_node(
96
+ node: neo4j.graph.Node,
97
+ all_node_field_aliases: set[str],
98
+ size_property: Optional[str],
99
+ caption_property: Optional[str],
100
+ ) -> Node:
66
101
  top_level_fields = {"id": node.element_id}
67
102
 
68
103
  if size_property:
@@ -78,7 +113,7 @@ def _map_node(node: neo4j.graph.Node, size_property: Optional[str], caption_prop
78
113
 
79
114
  properties = {}
80
115
  for prop, value in node.items():
81
- if prop not in Node.model_fields.keys():
116
+ if prop not in all_node_field_aliases:
82
117
  properties[prop] = value
83
118
  continue
84
119
 
@@ -92,10 +127,17 @@ def _map_node(node: neo4j.graph.Node, size_property: Optional[str], caption_prop
92
127
  properties["__labels"] = properties["labels"]
93
128
  properties["labels"] = labels
94
129
 
95
- return Node(**top_level_fields, properties=properties)
130
+ try:
131
+ viz_node = Node(**top_level_fields, properties=properties)
132
+ except ValidationError as e:
133
+ _parse_validation_error(e, Node)
96
134
 
135
+ return viz_node
97
136
 
98
- def _map_relationship(rel: neo4j.graph.Relationship, caption_property: Optional[str]) -> Optional[Relationship]:
137
+
138
+ def _map_relationship(
139
+ rel: neo4j.graph.Relationship, all_rel_field_aliases: set[str], caption_property: Optional[str]
140
+ ) -> Optional[Relationship]:
99
141
  if rel.start_node is None or rel.end_node is None:
100
142
  return None
101
143
 
@@ -109,7 +151,7 @@ def _map_relationship(rel: neo4j.graph.Relationship, caption_property: Optional[
109
151
 
110
152
  properties = {}
111
153
  for prop, value in rel.items():
112
- if prop not in Relationship.model_fields.keys():
154
+ if prop not in all_rel_field_aliases:
113
155
  properties[prop] = value
114
156
  continue
115
157
 
@@ -123,4 +165,9 @@ def _map_relationship(rel: neo4j.graph.Relationship, caption_property: Optional[
123
165
  properties["__type"] = properties["type"]
124
166
  properties["type"] = rel.type
125
167
 
126
- return Relationship(**top_level_fields, properties=properties)
168
+ try:
169
+ viz_rel = Relationship(**top_level_fields, properties=properties)
170
+ except ValidationError as e:
171
+ _parse_validation_error(e, Relationship)
172
+
173
+ return viz_rel
neo4j_viz/node.py CHANGED
@@ -2,7 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Any, Optional, Union
4
4
 
5
- from pydantic import AliasChoices, BaseModel, Field, field_serializer, field_validator
5
+ from pydantic import AliasChoices, AliasGenerator, BaseModel, Field, field_serializer, field_validator
6
+ from pydantic.alias_generators import to_camel
6
7
  from pydantic_extra_types.color import Color, ColorType
7
8
 
8
9
  from .node_size import RealNumber
@@ -11,29 +12,46 @@ from .options import CaptionAlignment
11
12
  NodeIdType = Union[str, int]
12
13
 
13
14
 
14
- class Node(BaseModel, extra="allow"):
15
+ def create_aliases(field_name: str) -> AliasChoices:
16
+ valid_names = [field_name]
17
+
18
+ if field_name == "id":
19
+ valid_names.extend(["nodeid", "node_id"])
20
+
21
+ choices = [[choice, choice.upper(), to_camel(choice)] for choice in valid_names]
22
+
23
+ return AliasChoices(*[alias for aliases in choices for alias in aliases])
24
+
25
+
26
+ class Node(
27
+ BaseModel,
28
+ extra="forbid",
29
+ alias_generator=AliasGenerator(
30
+ validation_alias=create_aliases,
31
+ serialization_alias=lambda field_name: to_camel(field_name),
32
+ ),
33
+ ):
15
34
  """
16
35
  A node in a graph to visualize.
17
36
 
37
+ Each field is case-insensitive for input, and camelCase is also accepted.
38
+ For example, "CAPTION_ALIGN", "captionAlign" are also valid inputs keys for the `caption_align` field.
39
+ Upon construction however, the field names are converted to snake_case.
40
+
18
41
  For more info on each field, see the NVL library docs: https://neo4j.com/docs/nvl/current/base-library/#_nodes
19
42
  """
20
43
 
21
44
  #: Unique identifier for the node
22
- id: NodeIdType = Field(
23
- validation_alias=AliasChoices("id", "nodeId", "node_id"), description="Unique identifier for the node"
24
- )
45
+ id: NodeIdType = Field(description="Unique identifier for the node")
25
46
  #: The caption of the node
26
47
  caption: Optional[str] = Field(None, description="The caption of the node")
27
48
  #: The alignment of the caption text
28
- caption_align: Optional[CaptionAlignment] = Field(
29
- None, serialization_alias="captionAlign", description="The alignment of the caption text"
30
- )
49
+ caption_align: Optional[CaptionAlignment] = Field(None, description="The alignment of the caption text")
31
50
  #: The size of the caption text. The font size to node radius ratio
32
51
  caption_size: Optional[int] = Field(
33
52
  None,
34
53
  ge=1,
35
54
  le=3,
36
- serialization_alias="captionSize",
37
55
  description="The size of the caption text. The font size to node radius ratio",
38
56
  )
39
57
  #: The size of the node as radius in pixel
@@ -70,3 +88,12 @@ class Node(BaseModel, extra="allow"):
70
88
 
71
89
  def to_dict(self) -> dict[str, Any]:
72
90
  return self.model_dump(exclude_none=True, by_alias=True)
91
+
92
+ @staticmethod
93
+ def all_validation_aliases(exempted_fields: Optional[list[str]] = None) -> set[str]:
94
+ if exempted_fields is None:
95
+ exempted_fields = []
96
+
97
+ by_field = [v.validation_alias.choices for k, v in Node.model_fields.items() if k not in exempted_fields] # type: ignore
98
+
99
+ return {str(alias) for aliases in by_field for alias in aliases}
neo4j_viz/nvl.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import json
4
4
  import uuid
5
5
  from importlib.resources import files
6
+ from typing import Union
6
7
 
7
8
  from IPython.display import HTML
8
9
 
@@ -40,10 +41,28 @@ class NVL:
40
41
  with screenshot_path.open("r", encoding="utf-8") as file:
41
42
  self.screenshot_svg = file.read()
42
43
 
43
- def unsupported_field_type_error(self, e: TypeError, entity: str) -> Exception:
44
- if "not JSON serializable" in str(e):
45
- return ValueError(f"A field of a {entity} object is not supported: {str(e)}")
46
- return e
44
+ @staticmethod
45
+ def _serialize_entity(entity: Union[Node, Relationship]) -> str:
46
+ try:
47
+ entity_dict = entity.to_dict()
48
+ return json.dumps(entity_dict)
49
+ except TypeError:
50
+ props_as_strings = {}
51
+ for k, v in entity_dict["properties"].items():
52
+ try:
53
+ json.dumps(v)
54
+ except TypeError:
55
+ props_as_strings[k] = str(v)
56
+ entity_dict["properties"].update(props_as_strings)
57
+
58
+ try:
59
+ return json.dumps(entity_dict)
60
+ except TypeError as e:
61
+ # This should never happen anymore, but just in case
62
+ if "not JSON serializable" in str(e):
63
+ raise ValueError(f"A field of a {type(entity).__name__} object is not supported: {str(e)}")
64
+ else:
65
+ raise e
47
66
 
48
67
  def render(
49
68
  self,
@@ -54,14 +73,8 @@ class NVL:
54
73
  height: str,
55
74
  show_hover_tooltip: bool,
56
75
  ) -> HTML:
57
- try:
58
- nodes_json = json.dumps([node.to_dict() for node in nodes])
59
- except TypeError as e:
60
- raise self.unsupported_field_type_error(e, "node")
61
- try:
62
- rels_json = json.dumps([rel.to_dict() for rel in relationships])
63
- except TypeError as e:
64
- raise self.unsupported_field_type_error(e, "relationship")
76
+ nodes_json = f"[{','.join([self._serialize_entity(node) for node in nodes])}]"
77
+ rels_json = f"[{','.join([self._serialize_entity(rel) for rel in relationships])}]"
65
78
 
66
79
  render_options_json = json.dumps(render_options.to_dict())
67
80
  container_id = str(uuid.uuid4())
@@ -112,7 +125,7 @@ class NVL:
112
125
  <script>
113
126
  getTheme = () => {{
114
127
  const backgroundColorString = window.getComputedStyle(document.body, null).getPropertyValue('background-color')
115
- const colorsArray = backgroundColorString.match(/\d+/g);
128
+ const colorsArray = backgroundColorString.match(/\\d+/g);
116
129
  const brightness = Number(colorsArray[0]) * 0.2126 + Number(colorsArray[1]) * 0.7152 + Number(colorsArray[2]) * 0.0722
117
130
  return brightness < 128 ? "dark" : "light"
118
131
  }}
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
+ )
neo4j_viz/pandas.py CHANGED
@@ -4,6 +4,7 @@ from collections.abc import Iterable
4
4
  from typing import Optional, Union
5
5
 
6
6
  from pandas import DataFrame
7
+ from pydantic import BaseModel, ValidationError
7
8
 
8
9
  from .node import Node
9
10
  from .relationship import Relationship
@@ -12,17 +13,55 @@ from .visualization_graph import VisualizationGraph
12
13
  DFS_TYPE = Union[DataFrame, Iterable[DataFrame]]
13
14
 
14
15
 
16
+ def _parse_validation_error(e: ValidationError, entity_type: type[BaseModel]) -> None:
17
+ for err in e.errors():
18
+ loc = err["loc"][0]
19
+ if err["type"] == "missing":
20
+ raise ValueError(
21
+ f"Mandatory {entity_type.__name__.lower()} column '{loc}' is missing. Expected one of {entity_type.model_fields[loc].validation_alias.choices} to be present" # type: ignore
22
+ )
23
+ else:
24
+ raise ValueError(
25
+ f"Error for {entity_type.__name__.lower()} column '{loc}' with provided input '{err['input']}'. Reason: {err['msg']}"
26
+ )
27
+
28
+
15
29
  def _from_dfs(
16
- node_dfs: DFS_TYPE,
30
+ node_dfs: Optional[DFS_TYPE],
17
31
  rel_dfs: DFS_TYPE,
18
32
  node_radius_min_max: Optional[tuple[float, float]] = (3, 60),
19
33
  rename_properties: Optional[dict[str, str]] = None,
20
34
  ) -> VisualizationGraph:
35
+ relationships = _parse_relationships(rel_dfs, rename_properties=rename_properties)
36
+
37
+ if node_dfs is None:
38
+ has_size = False
39
+ node_ids = set()
40
+ for rel in relationships:
41
+ node_ids.add(rel.source)
42
+ node_ids.add(rel.target)
43
+ nodes = [Node(id=id) for id in node_ids]
44
+ else:
45
+ nodes, has_size = _parse_nodes(node_dfs, rename_properties=rename_properties)
46
+
47
+ VG = VisualizationGraph(nodes=nodes, relationships=relationships)
48
+
49
+ if node_radius_min_max is not None and has_size:
50
+ VG.resize_nodes(node_radius_min_max=node_radius_min_max)
51
+
52
+ return VG
53
+
54
+
55
+ def _parse_nodes(node_dfs: DFS_TYPE, rename_properties: Optional[dict[str, str]]) -> tuple[list[Node], bool]:
21
56
  if isinstance(node_dfs, DataFrame):
22
57
  node_dfs_iter: Iterable[DataFrame] = [node_dfs]
58
+ elif node_dfs is None:
59
+ node_dfs_iter = []
23
60
  else:
24
61
  node_dfs_iter = node_dfs
25
62
 
63
+ all_node_field_aliases = Node.all_validation_aliases()
64
+
26
65
  has_size = True
27
66
  nodes = []
28
67
  for node_df in node_dfs_iter:
@@ -31,45 +70,52 @@ def _from_dfs(
31
70
  top_level = {}
32
71
  properties = {}
33
72
  for key, value in row.to_dict().items():
34
- if key in Node.model_fields.keys():
73
+ if key in all_node_field_aliases:
35
74
  top_level[key] = value
36
75
  else:
37
76
  if rename_properties and key in rename_properties:
38
77
  key = rename_properties[key]
39
78
  properties[key] = value
40
79
 
41
- nodes.append(Node(**top_level, properties=properties))
80
+ try:
81
+ nodes.append(Node(**top_level, properties=properties))
82
+ except ValidationError as e:
83
+ _parse_validation_error(e, Node)
84
+
85
+ return nodes, has_size
86
+
87
+
88
+ def _parse_relationships(rel_dfs: DFS_TYPE, rename_properties: Optional[dict[str, str]]) -> list[Relationship]:
89
+ all_rel_field_aliases = Relationship.all_validation_aliases()
42
90
 
43
91
  if isinstance(rel_dfs, DataFrame):
44
92
  rel_dfs_iter: Iterable[DataFrame] = [rel_dfs]
45
93
  else:
46
94
  rel_dfs_iter = rel_dfs
95
+ relationships: list[Relationship] = []
47
96
 
48
- relationships = []
49
97
  for rel_df in rel_dfs_iter:
50
98
  for _, row in rel_df.iterrows():
51
99
  top_level = {}
52
100
  properties = {}
53
101
  for key, value in row.to_dict().items():
54
- if key in Relationship.model_fields.keys():
102
+ if key in all_rel_field_aliases:
55
103
  top_level[key] = value
56
104
  else:
57
105
  if rename_properties and key in rename_properties:
58
106
  key = rename_properties[key]
59
107
  properties[key] = value
60
108
 
61
- relationships.append(Relationship(**top_level, properties=properties))
109
+ try:
110
+ relationships.append(Relationship(**top_level, properties=properties))
111
+ except ValidationError as e:
112
+ _parse_validation_error(e, Relationship)
62
113
 
63
- VG = VisualizationGraph(nodes=nodes, relationships=relationships)
64
-
65
- if node_radius_min_max is not None and has_size:
66
- VG.resize_nodes(node_radius_min_max=node_radius_min_max)
67
-
68
- return VG
114
+ return relationships
69
115
 
70
116
 
71
117
  def from_dfs(
72
- node_dfs: DFS_TYPE,
118
+ node_dfs: Optional[DFS_TYPE],
73
119
  rel_dfs: DFS_TYPE,
74
120
  node_radius_min_max: Optional[tuple[float, float]] = (3, 60),
75
121
  ) -> VisualizationGraph:
@@ -82,8 +128,9 @@ def from_dfs(
82
128
 
83
129
  Parameters
84
130
  ----------
85
- node_dfs: Union[DataFrame, Iterable[DataFrame]]
131
+ node_dfs: Optional[Union[DataFrame, Iterable[DataFrame]]]
86
132
  DataFrame or iterable of DataFrames containing node data.
133
+ If None, the nodes will be created from the source and target node ids in the rel_dfs.
87
134
  rel_dfs: Union[DataFrame, Iterable[DataFrame]]
88
135
  DataFrame or iterable of DataFrames containing relationship data.
89
136
  node_radius_min_max : tuple[float, float], optional
neo4j_viz/relationship.py CHANGED
@@ -3,16 +3,41 @@ from __future__ import annotations
3
3
  from typing import Any, Optional, Union
4
4
  from uuid import uuid4
5
5
 
6
- from pydantic import AliasChoices, BaseModel, Field, field_serializer, field_validator
6
+ from pydantic import AliasChoices, AliasGenerator, BaseModel, Field, field_serializer, field_validator
7
+ from pydantic.alias_generators import to_camel
7
8
  from pydantic_extra_types.color import Color, ColorType
8
9
 
9
10
  from .options import CaptionAlignment
10
11
 
11
12
 
12
- class Relationship(BaseModel, extra="allow"):
13
+ def create_aliases(field_name: str) -> AliasChoices:
14
+ valid_names = [field_name]
15
+
16
+ if field_name == "source":
17
+ valid_names.extend(["sourcenodeid", "source_node_id", "from"])
18
+ if field_name == "target":
19
+ valid_names.extend(["targetnodeid", "target_node_id", "to"])
20
+
21
+ choices = [[choice, choice.upper(), to_camel(choice)] for choice in valid_names]
22
+
23
+ return AliasChoices(*[alias for aliases in choices for alias in aliases])
24
+
25
+
26
+ class Relationship(
27
+ BaseModel,
28
+ extra="forbid",
29
+ alias_generator=AliasGenerator(
30
+ validation_alias=create_aliases,
31
+ serialization_alias=lambda field_name: to_camel(field_name),
32
+ ),
33
+ ):
13
34
  """
14
35
  A relationship in a graph to visualize.
15
36
 
37
+ Each field is case-insensitive for input, and camelCase is also accepted.
38
+ For example, "CAPTION_ALIGN", "captionAlign" are also valid inputs keys for the `caption_align` field.
39
+ Upon construction however, the field names are converted to snake_case.
40
+
16
41
  For more info on each field, see the NVL library docs: https://neo4j.com/docs/nvl/current/base-library/#_relationships
17
42
  """
18
43
 
@@ -23,25 +48,19 @@ class Relationship(BaseModel, extra="allow"):
23
48
  #: Node ID where the relationship points from
24
49
  source: Union[str, int] = Field(
25
50
  serialization_alias="from",
26
- validation_alias=AliasChoices("source", "sourceNodeId", "source_node_id", "from"),
27
51
  description="Node ID where the relationship points from",
28
52
  )
29
53
  #: Node ID where the relationship points to
30
54
  target: Union[str, int] = Field(
31
55
  serialization_alias="to",
32
- validation_alias=AliasChoices("target", "targetNodeId", "target_node_id", "to"),
33
56
  description="Node ID where the relationship points to",
34
57
  )
35
58
  #: The caption of the relationship
36
59
  caption: Optional[str] = Field(None, description="The caption of the relationship")
37
60
  #: The alignment of the caption text
38
- caption_align: Optional[CaptionAlignment] = Field(
39
- None, serialization_alias="captionAlign", description="The alignment of the caption text"
40
- )
61
+ caption_align: Optional[CaptionAlignment] = Field(None, description="The alignment of the caption text")
41
62
  #: The size of the caption text
42
- caption_size: Optional[Union[int, float]] = Field(
43
- None, gt=0.0, serialization_alias="captionSize", description="The size of the caption text"
44
- )
63
+ caption_size: Optional[Union[int, float]] = Field(None, gt=0.0, description="The size of the caption text")
45
64
  #: The color of the relationship. Allowed input is for example "#FF0000", "red" or (255, 0, 0)
46
65
  color: Optional[ColorType] = Field(None, description="The color of the relationship")
47
66
  #: Additional properties of the relationship that do not directly impact the visualization
@@ -76,3 +95,16 @@ class Relationship(BaseModel, extra="allow"):
76
95
 
77
96
  def to_dict(self) -> dict[str, Any]:
78
97
  return self.model_dump(exclude_none=True, by_alias=True)
98
+
99
+ @staticmethod
100
+ def all_validation_aliases(exempted_fields: Optional[list[str]] = None) -> set[str]:
101
+ if exempted_fields is None:
102
+ exempted_fields = []
103
+
104
+ by_field = [
105
+ v.validation_alias.choices # type: ignore
106
+ for k, v in Relationship.model_fields.items()
107
+ if k not in exempted_fields
108
+ ]
109
+
110
+ return {str(alias) for aliases in by_field for alias in aliases}
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import warnings
4
4
  from collections.abc import Iterable
5
- from typing import Any, Callable, Hashable, Optional
5
+ from typing import Any, Callable, Hashable, Optional, Union
6
6
 
7
7
  from IPython.display import HTML
8
8
  from pydantic_extra_types.color import Color, ColorType
@@ -11,7 +11,13 @@ from .colors import NEO4J_COLORS_CONTINUOUS, NEO4J_COLORS_DISCRETE, ColorSpace,
11
11
  from .node import Node, NodeIdType
12
12
  from .node_size import RealNumber, verify_radii
13
13
  from .nvl import NVL
14
- from .options import Layout, Renderer, RenderOptions
14
+ from .options import (
15
+ Layout,
16
+ LayoutOptions,
17
+ Renderer,
18
+ RenderOptions,
19
+ construct_layout_options,
20
+ )
15
21
  from .relationship import Relationship
16
22
 
17
23
 
@@ -42,6 +48,7 @@ class VisualizationGraph:
42
48
  def render(
43
49
  self,
44
50
  layout: Optional[Layout] = None,
51
+ layout_options: Union[dict[str, Any], LayoutOptions, None] = None,
45
52
  renderer: Renderer = Renderer.CANVAS,
46
53
  width: str = "100%",
47
54
  height: str = "600px",
@@ -60,6 +67,8 @@ class VisualizationGraph:
60
67
  ----------
61
68
  layout:
62
69
  The `Layout` to use.
70
+ layout_options:
71
+ The `LayoutOptions` to use.
63
72
  renderer:
64
73
  The `Renderer` to use.
65
74
  width:
@@ -92,8 +101,19 @@ class VisualizationGraph:
92
101
 
93
102
  Renderer.check(renderer, num_nodes)
94
103
 
104
+ if not layout:
105
+ layout = Layout.FORCE_DIRECTED
106
+ if not layout_options:
107
+ layout_options = {}
108
+
109
+ if isinstance(layout_options, dict):
110
+ layout_options_typed = construct_layout_options(layout, layout_options)
111
+ else:
112
+ layout_options_typed = layout_options
113
+
95
114
  render_options = RenderOptions(
96
115
  layout=layout,
116
+ layout_options=layout_options_typed,
97
117
  renderer=renderer,
98
118
  pan_X=pan_position[0] if pan_position is not None else None,
99
119
  pan_Y=pan_position[1] if pan_position is not None else None,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: neo4j-viz
3
- Version: 0.3.0
3
+ Version: 0.4.0
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/
@@ -0,0 +1,22 @@
1
+ neo4j_viz/__init__.py,sha256=Q-VZlJe3_kAow_-F_-9RsHCQfbOfv5on26YD9ihw27o,504
2
+ neo4j_viz/colors.py,sha256=IvOCTmCu7WTMna_wNLZ3GrThTwFyIoKtNkmZYDLdYac,6694
3
+ neo4j_viz/gds.py,sha256=Cu-0RDi6YtWiuWP1N06h5P0zd5mQx0z5cafr0fs2K24,6637
4
+ neo4j_viz/gql_create.py,sha256=K33cT6dOj8eJPGNNJXiXlCfLIzNxTwcW4n_2AG3_zaY,14751
5
+ neo4j_viz/neo4j.py,sha256=_bZkj1XQKazueRM7Ti0pIPdSI8fVd432mZbQsxFO2yw,6112
6
+ neo4j_viz/node.py,sha256=MiLoghsn2NLs_iV65NuW7u3iaxP8MTKoNy6La9TdreY,3886
7
+ neo4j_viz/node_size.py,sha256=c_sMtQSD8eJ_6Y0Kr6ku0LOs9VoEDxfYCUUzUWZ-1Xo,1197
8
+ neo4j_viz/nvl.py,sha256=ZN3tyWar9ugR88r5N6txW3ThfNEWOt5A1KzrrRnLKwk,5262
9
+ neo4j_viz/options.py,sha256=eOpiLcIfFvUiPoozyT44F9MHGRkqCfBZFmh0u_6DfwY,6400
10
+ neo4j_viz/pandas.py,sha256=Ajn77VRcen4lqrVQp0O9yUG5bZ7QkjiCIpjj0UFCD0w,5162
11
+ neo4j_viz/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ neo4j_viz/relationship.py,sha256=sRgGjNzlqt6wPmiB3WXBTxR_W_5Z40VofpHUZvkRS1I,4143
13
+ neo4j_viz/visualization_graph.py,sha256=Fizmzm8p4p3Fbd3gsiTymkXgI3crpdmr5ipv4gFv3SY,13760
14
+ neo4j_viz/resources/icons/screenshot.svg,sha256=Ns9Yi2Iq4lIaiFvzc0pXBmjxt4fcmBO-I4cI8Xiu1HE,311
15
+ neo4j_viz/resources/icons/zoom-in.svg,sha256=PsO5yFkA1JnGM2QV_qxHKG13qmoR-RrlWARpaXNp5qU,415
16
+ neo4j_viz/resources/icons/zoom-out.svg,sha256=OQRADAoe2bxbCeFufg6W22nR41q5NlI8QspT9l5pXUw,400
17
+ neo4j_viz/resources/nvl_entrypoint/base.js,sha256=QfFjqd7Uo2F-plgFlPfLzUcjqzTzqUh533VaBemJhLI,1816247
18
+ neo4j_viz/resources/nvl_entrypoint/styles.css,sha256=JjeTSB9OJT2KMfb8yFUUMLMG7Rzrf3o60hSCD547zTk,1123
19
+ neo4j_viz-0.4.0.dist-info/METADATA,sha256=J3KTYHc4S8w50RqS4VbTaOQ4EaAWjGqyzVQn9zfU7OY,7074
20
+ neo4j_viz-0.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
21
+ neo4j_viz-0.4.0.dist-info/top_level.txt,sha256=jPUM3z8MOtxqDanc2VzqkxG4HJn8aaq4S7rnCFNk_Vs,10
22
+ neo4j_viz-0.4.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.7.1)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,22 +0,0 @@
1
- neo4j_viz/__init__.py,sha256=ATDPdaupFWIWaOWtJtfCGVRVuuwXKNe1hNdENofgRLk,270
2
- neo4j_viz/colors.py,sha256=IvOCTmCu7WTMna_wNLZ3GrThTwFyIoKtNkmZYDLdYac,6694
3
- neo4j_viz/gds.py,sha256=v6b3XdSoWSoOu-U2MWQM-5QXLiPT3_9gGOv7haxuozE,4135
4
- neo4j_viz/gql_create.py,sha256=Eqo_fFxtsrsoKV0gjXIpHZdeBqjX2q-vwe_xBCJU7OI,13354
5
- neo4j_viz/neo4j.py,sha256=0urKoYwtCyyKXMu_tWPspehyjOFF5ETyiWuYVNA1Wfc,4589
6
- neo4j_viz/node.py,sha256=NgB8preP2v1PWW177iU4YeojvfWafz8yIyY7MQrP6iE,2850
7
- neo4j_viz/node_size.py,sha256=c_sMtQSD8eJ_6Y0Kr6ku0LOs9VoEDxfYCUUzUWZ-1Xo,1197
8
- neo4j_viz/nvl.py,sha256=32NTuNCaf78bHEVD1nWQD5jUHS2ZUBemxKktuIpiCK0,4774
9
- neo4j_viz/options.py,sha256=QBeHLqHOJFtSCjzHvv-2A9TzTbnIaC6_onQq9fUlT_4,3079
10
- neo4j_viz/pandas.py,sha256=4ArBUV6DKOhwbplD8edJd5DsIilDNKyCpPjqrus0bPk,3269
11
- neo4j_viz/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- neo4j_viz/relationship.py,sha256=DmmBUHup_jtqnenQKACCfz4Mz7CjbCnDmOZfsSj6qiQ,3042
13
- neo4j_viz/visualization_graph.py,sha256=ogYhxAyPzqOcSogYSesGAV6iRQDLIA-YhBImEShuCPM,13175
14
- neo4j_viz/resources/icons/screenshot.svg,sha256=Ns9Yi2Iq4lIaiFvzc0pXBmjxt4fcmBO-I4cI8Xiu1HE,311
15
- neo4j_viz/resources/icons/zoom-in.svg,sha256=PsO5yFkA1JnGM2QV_qxHKG13qmoR-RrlWARpaXNp5qU,415
16
- neo4j_viz/resources/icons/zoom-out.svg,sha256=OQRADAoe2bxbCeFufg6W22nR41q5NlI8QspT9l5pXUw,400
17
- neo4j_viz/resources/nvl_entrypoint/base.js,sha256=QfFjqd7Uo2F-plgFlPfLzUcjqzTzqUh533VaBemJhLI,1816247
18
- neo4j_viz/resources/nvl_entrypoint/styles.css,sha256=JjeTSB9OJT2KMfb8yFUUMLMG7Rzrf3o60hSCD547zTk,1123
19
- neo4j_viz-0.3.0.dist-info/METADATA,sha256=wCM57O61k4kllIiYOqopDJiQPBzyftooJwhwz9h8oSw,7074
20
- neo4j_viz-0.3.0.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
21
- neo4j_viz-0.3.0.dist-info/top_level.txt,sha256=jPUM3z8MOtxqDanc2VzqkxG4HJn8aaq4S7rnCFNk_Vs,10
22
- neo4j_viz-0.3.0.dist-info/RECORD,,