Skip to content

Commit eee3e80

Browse files
authored
Merge pull request #217 from neo4j/fix-default-size-caption-from-neo4j
fix default size caption from neo4j
2 parents 9fbbbea + 86a706d commit eee3e80

File tree

11 files changed

+140
-50
lines changed

11 files changed

+140
-50
lines changed

.github/workflows/snowflake-integration-tests.yml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ on:
88
# branches: [ "main" ]
99
# Skip on this check PR to minimize the load against Snowflake (and keep PR checks fast)
1010

11-
1211
# Allows you to run this workflow manually from the Actions tab
1312
workflow_dispatch:
1413

@@ -33,7 +32,7 @@ jobs:
3332
- uses: actions/setup-python@v5
3433
with:
3534
python-version: "3.11"
36-
cache: 'pip'
35+
cache: "pip"
3736
cache-dependency-path: pyproject.toml
3837
- run: pip install ".[dev]"
3938
- run: pip install ".[pandas]"
@@ -46,4 +45,4 @@ jobs:
4645
SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_PASSWORD }}
4746
SNOWFLAKE_ROLE: ACCOUNTADMIN
4847
SNOWFLAKE_WAREHOUSE: ${{ secrets.SNOWFLAKE_WAREHOUSE }}
49-
run: pytest tests/ --include-snowflake
48+
run: pytest tests/ --include-snowflake -W "ignore:Python Runtime 3.9 reached its End-Of-Life:DeprecationWarning"

.github/workflows/unit-tests.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ name: Run Python unit tests
44
on:
55
# Triggers the workflow on push or pull request events but only for the "main" branch
66
push:
7-
branches: [ "main" ]
7+
branches: ["main"]
88
pull_request:
99
paths:
1010
- "python-wrapper/**" # python code + its resources
1111
- "python-wrapper/pyproject.toml" # dependencies
12-
branches: [ "main" ]
12+
branches: ["main"]
1313

1414
# Allows you to run this workflow manually from the Actions tab
1515
workflow_dispatch:
@@ -36,7 +36,7 @@ jobs:
3636
- uses: actions/setup-python@v5
3737
with:
3838
python-version: ${{ matrix.python-version }}
39-
cache: 'pip'
39+
cache: "pip"
4040
cache-dependency-path: pyproject.toml
4141
- run: pip install ".[dev]"
4242
- run: pip install ".[pandas]"
@@ -45,4 +45,4 @@ jobs:
4545
- run: pip install ".[snowflake]"
4646

4747
- name: Run tests
48-
run: pytest tests/
48+
run: pytest tests/ -W "ignore:Python Runtime 3.9 reached its End-Of-Life:DeprecationWarning"

changelog.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
# Changes in 0.5.1
22

3-
43
## Breaking changes
54

5+
- Do not automatically derive size and caption for `from_neo4j` and `from_gql_create`. Use the `size_property` and `node_caption` parameters to explicitly configure them.
66

77
## New features
88

9-
109
## Bug fixes
1110

11+
- fixed a bug in `from_neo4j`, where the node size would always be set to the `size` property.
12+
- fixed a bug in `from_neo4j`, where the node caption would always be set to the `caption` property.
1213

1314
## Improvements
1415

16+
- Validate fields of a node and relationship not only at construction but also on assignment.
17+
- Allow resizing per node property such as `VG.resize_nodes(property="score")`.
1518

1619
## Other changes

python-wrapper/src/neo4j_viz/gql_create.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -251,8 +251,8 @@ def from_gql_create(
251251
node_pattern = re.compile(r"^\(([^)]*)\)$")
252252
rel_pattern = re.compile(r"^\(([^)]*)\)-\s*\[\s*:(\w+)\s*(\{[^}]*\})?\s*\]->\(([^)]*)\)$")
253253

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"])
254+
node_top_level_keys = Node.all_validation_aliases(exempted_fields=["id", "size", "caption"])
255+
rel_top_level_keys = Relationship.all_validation_aliases(exempted_fields=["id", "source", "target", "caption"])
256256

257257
def _parse_validation_error(e: ValidationError, entity_type: type[BaseModel]) -> None:
258258
for err in e.errors():
@@ -358,8 +358,11 @@ def _parse_validation_error(e: ValidationError, entity_type: type[BaseModel]) ->
358358
raise ValueError(f"Invalid element in CREATE near: `{snippet}`.")
359359

360360
if size_property is not None:
361-
for node in nodes:
362-
node.size = node.properties.get(size_property)
361+
try:
362+
for node in nodes:
363+
node.size = node.properties.get(size_property)
364+
except ValidationError as e:
365+
_parse_validation_error(e, Node)
363366
if node_caption is not None:
364367
for node in nodes:
365368
if node_caption == "labels":
@@ -376,10 +379,6 @@ def _parse_validation_error(e: ValidationError, entity_type: type[BaseModel]) ->
376379

377380
VG = VisualizationGraph(nodes=nodes, relationships=relationships)
378381
if (node_radius_min_max is not None) and (size_property is not None):
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")
382+
VG.resize_nodes(node_radius_min_max=node_radius_min_max)
384383

385384
return VG

python-wrapper/src/neo4j_viz/neo4j.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ def from_neo4j(
7777
else:
7878
raise ValueError(f"Invalid input type `{type(data)}`. Expected `neo4j.Graph`, `neo4j.Result` or `neo4j.Driver`")
7979

80-
all_node_field_aliases = Node.all_validation_aliases()
81-
all_rel_field_aliases = Relationship.all_validation_aliases()
80+
all_node_field_aliases = Node.all_validation_aliases(exempted_fields=["size", "caption"])
81+
all_rel_field_aliases = Relationship.all_validation_aliases(exempted_fields=["caption"])
8282

8383
try:
8484
nodes = [

python-wrapper/src/neo4j_viz/node.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class Node(
3030
validation_alias=create_aliases,
3131
serialization_alias=lambda field_name: to_camel(field_name),
3232
),
33+
validate_assignment=True,
3334
):
3435
"""
3536
A node in a graph to visualize.

python-wrapper/src/neo4j_viz/relationship.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class Relationship(
3030
validation_alias=create_aliases,
3131
serialization_alias=lambda field_name: to_camel(field_name),
3232
),
33+
validate_assignment=True,
3334
):
3435
"""
3536
A relationship in a graph to visualize.

python-wrapper/src/neo4j_viz/visualization_graph.py

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ def resize_nodes(
154154
self,
155155
sizes: Optional[dict[NodeIdType, RealNumber]] = None,
156156
node_radius_min_max: Optional[tuple[RealNumber, RealNumber]] = (3, 60),
157+
property: Optional[str] = None,
157158
) -> None:
158159
"""
159160
Resize the nodes in the graph.
@@ -163,40 +164,55 @@ def resize_nodes(
163164
sizes:
164165
A dictionary mapping from node ID to the new size of the node.
165166
If a node ID is not in the dictionary, the size of the node is not changed.
167+
Must be None if `property` is provided.
166168
node_radius_min_max:
167169
Minimum and maximum node size radius as a tuple. To avoid tiny or huge nodes in the visualization, the
168170
node sizes are scaled to fit in the given range. If None, the sizes are used as is.
171+
property:
172+
The property of the nodes to use for sizing. Must be None if `sizes` is provided.
169173
"""
170-
if sizes is None and node_radius_min_max is None:
171-
raise ValueError("At least one of `sizes` and `node_radius_min_max` must be given")
174+
if sizes is not None and property is not None:
175+
raise ValueError("At most one of the arguments `sizes` and `property` can be provided")
172176

173-
# Gather and verify all node size values we have to work with
174-
all_sizes = {}
175-
for node in self.nodes:
176-
size = None
177-
if sizes is not None:
178-
size = sizes.get(node.id)
177+
if sizes is None and property is None and node_radius_min_max is None:
178+
raise ValueError("At least one of `sizes`, `property` or `node_radius_min_max` must be given")
179179

180+
# Gather node sizes
181+
all_sizes = {}
182+
if sizes is not None:
183+
for node in self.nodes:
184+
size = sizes.get(node.id, node.size)
180185
if size is not None:
181-
if not isinstance(size, (int, float)):
182-
raise ValueError(f"Size for node '{node.id}' must be a real number, but was {size}")
183-
184-
if size < 0:
185-
raise ValueError(f"Size for node '{node.id}' must be non-negative, but was {size}")
186-
187186
all_sizes[node.id] = size
188-
189-
if size is None:
187+
elif property is not None:
188+
for node in self.nodes:
189+
size = node.properties.get(property, node.size)
190+
if size is not None:
191+
all_sizes[node.id] = size
192+
else:
193+
for node in self.nodes:
190194
if node.size is not None:
191195
all_sizes[node.id] = node.size
192196

197+
# Validate node sizes
198+
for id, size in all_sizes.items():
199+
if size is None:
200+
continue
201+
202+
if not isinstance(size, (int, float)):
203+
raise ValueError(f"Size for node '{id}' must be a real number, but was {size}")
204+
205+
if size < 0:
206+
raise ValueError(f"Size for node '{id}' must be non-negative, but was {size}")
207+
193208
if node_radius_min_max is not None:
194209
verify_radii(node_radius_min_max)
195210

196211
final_sizes = self._normalize_values(all_sizes, node_radius_min_max)
197212
else:
198213
final_sizes = all_sizes
199214

215+
# Apply the final sizes to the nodes
200216
for node in self.nodes:
201217
size = final_sizes.get(node.id)
202218

python-wrapper/tests/test_gql_create.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ def test_from_gql_create_syntax() -> None:
3030
"properties": {"name": "Alice", "age": 23, "labels": ["User"], "__labels": ["Happy"], "id": 42},
3131
},
3232
{
33-
"top_level": {"caption": "Bridget"},
34-
"properties": {"name": "Bridget", "age": 34, "labels": ["User", "person"]},
33+
"top_level": {},
34+
"properties": {"name": "Bridget", "caption": "Bridget", "age": 34, "labels": ["User", "person"]},
3535
},
3636
{
3737
"top_level": {},
@@ -70,8 +70,8 @@ def test_from_gql_create_syntax() -> None:
7070
{
7171
"source_idx": 4,
7272
"target_idx": 7,
73-
"top_level": {"caption": "Balloon"},
74-
"properties": {"weight": -2, "type": "OTHER_LINK", "__type": 1, "source": 1337},
73+
"top_level": {},
74+
"properties": {"weight": -2, "caption": "Balloon", "type": "OTHER_LINK", "__type": 1, "source": 1337},
7575
},
7676
{"source_idx": 9, "target_idx": 10, "top_level": {}, "properties": {"type": "LINK"}},
7777
]
@@ -102,7 +102,7 @@ def test_from_gql_create_captions() -> None:
102102
},
103103
{
104104
"top_level": {"caption": "User:person"},
105-
"properties": {"name": "Bridget", "age": 34, "labels": ["User", "person"]},
105+
"properties": {"name": "Bridget", "caption": "Bridget", "age": 34, "labels": ["User", "person"]},
106106
},
107107
]
108108

@@ -148,8 +148,8 @@ def test_from_gql_create_sizes() -> None:
148148
"properties": {"name": "Alice", "age": 23, "labels": ["User"]},
149149
},
150150
{
151-
"top_level": {"caption": "Bridget", "size": 60.0},
152-
"properties": {"name": "Bridget", "age": 34, "labels": ["User", "person"]},
151+
"top_level": {"size": 60.0},
152+
"properties": {"name": "Bridget", "caption": "Bridget", "age": 34, "labels": ["User", "person"]},
153153
},
154154
]
155155

@@ -232,7 +232,7 @@ def test_illegal_node_size() -> None:
232232
query = "CREATE (a:User {hello: 'tennis'})"
233233
with pytest.raises(
234234
ValueError,
235-
match="Error for node property 'hello'. Reason: must be a numerical value",
235+
match="Error for node property 'hello' with provided input 'tennis'",
236236
):
237237
from_gql_create(query, size_property="hello")
238238

python-wrapper/tests/test_neo4j.py

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
@pytest.fixture(scope="class", autouse=True)
1313
def graph_setup(neo4j_session: Session) -> Generator[None, None, None]:
1414
neo4j_session.run(
15-
"CREATE (a:_CI_A {name:'Alice', height:20, id:42, _id: 1337, caption: 'hello'})-[:KNOWS {year: 2025, id: 41, source: 1, target: 2}]->"
16-
"(b:_CI_A:_CI_B {name:'Bob', height:10, id: 84, size: 11, labels: [1,2]}), (b)-[:RELATED {year: 2015, _type: 'A', caption:'hej'}]->(a)"
15+
"CREATE "
16+
" (a:_CI_A {name:'Alice', height:20, id:42, _id: 1337, caption: 'hello'})"
17+
" ,(b:_CI_A:_CI_B {name:'Bob', height:10, id: 84, size: 11, labels: [1,2]})"
18+
" ,(a)-[:KNOWS {year: 2025, id: 41, source: 1, target: 2}]->(b)"
19+
" ,(b)-[:RELATED {year: 2015, _type: 'A', caption:'hej'}]->(a)"
1720
)
1821
yield
1922
neo4j_session.run("MATCH (n:_CI_A|_CI_B) DETACH DELETE n")
@@ -44,8 +47,9 @@ def test_from_neo4j_graph_basic(neo4j_session: Session) -> None:
4447
Node(
4548
id=node_ids[1],
4649
caption="_CI_A:_CI_B",
47-
size=11,
50+
size=None,
4851
properties=dict(
52+
size=11,
4953
labels=["_CI_A", "_CI_B"],
5054
name="Bob",
5155
height=10,
@@ -66,6 +70,41 @@ def test_from_neo4j_graph_basic(neo4j_session: Session) -> None:
6670
]
6771

6872

73+
@pytest.mark.requires_neo4j_and_gds
74+
def test_from_neo4j_graph_size_property(neo4j_session: Session) -> None:
75+
# set a non parsable size property, by default it should not be picked up
76+
neo4j_session.run("MATCH (n) SET n.size = 'banana'")
77+
78+
graph = neo4j_session.run("MATCH (a:_CI_A|_CI_B)-[r]->(b) RETURN a, b, r ORDER BY a").graph()
79+
80+
VG = from_neo4j(graph, size_property="height", node_radius_min_max=None)
81+
82+
assert {n.properties["name"]: n.size for n in VG.nodes} == {"Alice": 20, "Bob": 10}
83+
84+
VG = from_neo4j(graph, size_property=None, node_radius_min_max=None)
85+
86+
assert {n.properties["name"]: n.size for n in VG.nodes} == {"Alice": None, "Bob": None}
87+
88+
89+
@pytest.mark.requires_neo4j_and_gds
90+
def test_from_neo4j_graph_default_caption(neo4j_session: Session) -> None:
91+
neo4j_session.run("MATCH (n) SET n.caption = 'my_caption' SET n.other_caption = 'other_caption'")
92+
93+
graph = neo4j_session.run("MATCH (a:_CI_A|_CI_B)-[r]->(b) RETURN a, b, r ORDER BY a").graph()
94+
95+
VG = from_neo4j(graph, node_caption=None, node_radius_min_max=None)
96+
97+
assert [n.caption for n in VG.nodes] == [None, None]
98+
99+
VG = from_neo4j(graph, node_caption="other_caption", node_radius_min_max=None)
100+
101+
assert [n.caption for n in VG.nodes] == ["other_caption", "other_caption"]
102+
103+
VG = from_neo4j(graph, relationship_caption="year")
104+
105+
assert {e.properties["type"]: e.caption for e in VG.relationships} == {"KNOWS": "2025", "RELATED": "2015"}
106+
107+
69108
@pytest.mark.requires_neo4j_and_gds
70109
def test_from_neo4j_result(neo4j_session: Session) -> None:
71110
result = neo4j_session.run("MATCH (a:_CI_A|_CI_B)-[r]->(b) RETURN a, b, r ORDER BY a")
@@ -93,8 +132,8 @@ def test_from_neo4j_result(neo4j_session: Session) -> None:
93132
Node(
94133
id=node_ids[1],
95134
caption="_CI_A:_CI_B",
96-
size=11,
97135
properties=dict(
136+
size=11,
98137
labels=["_CI_A", "_CI_B"],
99138
name="Bob",
100139
height=10,
@@ -230,9 +269,9 @@ def test_from_neo4j_graph_driver(neo4j_session: Session, neo4j_driver: Driver) -
230269
Node(
231270
id=node_ids[1],
232271
caption="_CI_A:_CI_B",
233-
size=11,
234272
properties=dict(
235273
labels=["_CI_A", "_CI_B"],
274+
size=11,
236275
name="Bob",
237276
height=10,
238277
id=84,

0 commit comments

Comments
 (0)