Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .github/workflows/snowflake-integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ on:
# branches: [ "main" ]
# Skip on this check PR to minimize the load against Snowflake (and keep PR checks fast)


# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:

Expand All @@ -33,7 +32,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: 'pip'
cache: "pip"
cache-dependency-path: pyproject.toml
- run: pip install ".[dev]"
- run: pip install ".[pandas]"
Expand All @@ -46,4 +45,4 @@ jobs:
SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_PASSWORD }}
SNOWFLAKE_ROLE: ACCOUNTADMIN
SNOWFLAKE_WAREHOUSE: ${{ secrets.SNOWFLAKE_WAREHOUSE }}
run: pytest tests/ --include-snowflake
run: pytest tests/ --include-snowflake -W "ignore:Python Runtime 3.9 reached its End-Of-Life:DeprecationWarning"
8 changes: 4 additions & 4 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ name: Run Python unit tests
on:
# Triggers the workflow on push or pull request events but only for the "main" branch
push:
branches: [ "main" ]
branches: ["main"]
pull_request:
paths:
- "python-wrapper/**" # python code + its resources
- "python-wrapper/pyproject.toml" # dependencies
branches: [ "main" ]
branches: ["main"]

# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
Expand All @@ -36,7 +36,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache: "pip"
cache-dependency-path: pyproject.toml
- run: pip install ".[dev]"
- run: pip install ".[pandas]"
Expand All @@ -45,4 +45,4 @@ jobs:
- run: pip install ".[snowflake]"

- name: Run tests
run: pytest tests/
run: pytest tests/ -W "ignore:Python Runtime 3.9 reached its End-Of-Life:DeprecationWarning"
7 changes: 5 additions & 2 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
# Changes in 0.5.1


## Breaking changes

- 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.

## New features


## Bug fixes

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

## Improvements

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

## Other changes
17 changes: 8 additions & 9 deletions python-wrapper/src/neo4j_viz/gql_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,8 @@ def from_gql_create(
node_pattern = re.compile(r"^\(([^)]*)\)$")
rel_pattern = re.compile(r"^\(([^)]*)\)-\s*\[\s*:(\w+)\s*(\{[^}]*\})?\s*\]->\(([^)]*)\)$")

node_top_level_keys = Node.all_validation_aliases(exempted_fields=["id"])
rel_top_level_keys = Relationship.all_validation_aliases(exempted_fields=["id", "source", "target"])
node_top_level_keys = Node.all_validation_aliases(exempted_fields=["id", "size", "caption"])
rel_top_level_keys = Relationship.all_validation_aliases(exempted_fields=["id", "source", "target", "caption"])

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

if size_property is not None:
for node in nodes:
node.size = node.properties.get(size_property)
try:
for node in nodes:
node.size = node.properties.get(size_property)
except ValidationError as e:
_parse_validation_error(e, Node)
if node_caption is not None:
for node in nodes:
if node_caption == "labels":
Expand All @@ -376,10 +379,6 @@ def _parse_validation_error(e: ValidationError, entity_type: type[BaseModel]) ->

VG = VisualizationGraph(nodes=nodes, relationships=relationships)
if (node_radius_min_max is not None) and (size_property is not None):
try:
VG.resize_nodes(node_radius_min_max=node_radius_min_max)
except TypeError:
loc = "size" if size_property is None else size_property
raise ValueError(f"Error for node property '{loc}'. Reason: must be a numerical value")
VG.resize_nodes(node_radius_min_max=node_radius_min_max)

return VG
4 changes: 2 additions & 2 deletions python-wrapper/src/neo4j_viz/neo4j.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ def from_neo4j(
else:
raise ValueError(f"Invalid input type `{type(data)}`. Expected `neo4j.Graph`, `neo4j.Result` or `neo4j.Driver`")

all_node_field_aliases = Node.all_validation_aliases()
all_rel_field_aliases = Relationship.all_validation_aliases()
all_node_field_aliases = Node.all_validation_aliases(exempted_fields=["size", "caption"])
all_rel_field_aliases = Relationship.all_validation_aliases(exempted_fields=["caption"])

try:
nodes = [
Expand Down
1 change: 1 addition & 0 deletions python-wrapper/src/neo4j_viz/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class Node(
validation_alias=create_aliases,
serialization_alias=lambda field_name: to_camel(field_name),
),
validate_assignment=True,
):
"""
A node in a graph to visualize.
Expand Down
1 change: 1 addition & 0 deletions python-wrapper/src/neo4j_viz/relationship.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class Relationship(
validation_alias=create_aliases,
serialization_alias=lambda field_name: to_camel(field_name),
),
validate_assignment=True,
):
"""
A relationship in a graph to visualize.
Expand Down
48 changes: 32 additions & 16 deletions python-wrapper/src/neo4j_viz/visualization_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ def resize_nodes(
self,
sizes: Optional[dict[NodeIdType, RealNumber]] = None,
node_radius_min_max: Optional[tuple[RealNumber, RealNumber]] = (3, 60),
property: Optional[str] = None,
) -> None:
"""
Resize the nodes in the graph.
Expand All @@ -163,40 +164,55 @@ def resize_nodes(
sizes:
A dictionary mapping from node ID to the new size of the node.
If a node ID is not in the dictionary, the size of the node is not changed.
Must be None if `property` is provided.
node_radius_min_max:
Minimum and maximum node size radius as a tuple. To avoid tiny or huge nodes in the visualization, the
node sizes are scaled to fit in the given range. If None, the sizes are used as is.
property:
The property of the nodes to use for sizing. Must be None if `sizes` is provided.
"""
if sizes is None and node_radius_min_max is None:
raise ValueError("At least one of `sizes` and `node_radius_min_max` must be given")
if sizes is not None and property is not None:
raise ValueError("At most one of the arguments `sizes` and `property` can be provided")

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

# Gather node sizes
all_sizes = {}
if sizes is not None:
for node in self.nodes:
size = sizes.get(node.id, node.size)
if size is not None:
if not isinstance(size, (int, float)):
raise ValueError(f"Size for node '{node.id}' must be a real number, but was {size}")

if size < 0:
raise ValueError(f"Size for node '{node.id}' must be non-negative, but was {size}")

all_sizes[node.id] = size

if size is None:
elif property is not None:
for node in self.nodes:
size = node.properties.get(property, node.size)
if size is not None:
all_sizes[node.id] = size
else:
for node in self.nodes:
if node.size is not None:
all_sizes[node.id] = node.size

# Validate node sizes
for id, size in all_sizes.items():
if size is None:
continue

if not isinstance(size, (int, float)):
raise ValueError(f"Size for node '{id}' must be a real number, but was {size}")

if size < 0:
raise ValueError(f"Size for node '{id}' must be non-negative, but was {size}")

if node_radius_min_max is not None:
verify_radii(node_radius_min_max)

final_sizes = self._normalize_values(all_sizes, node_radius_min_max)
else:
final_sizes = all_sizes

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

Expand Down
16 changes: 8 additions & 8 deletions python-wrapper/tests/test_gql_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ def test_from_gql_create_syntax() -> None:
"properties": {"name": "Alice", "age": 23, "labels": ["User"], "__labels": ["Happy"], "id": 42},
},
{
"top_level": {"caption": "Bridget"},
"properties": {"name": "Bridget", "age": 34, "labels": ["User", "person"]},
"top_level": {},
"properties": {"name": "Bridget", "caption": "Bridget", "age": 34, "labels": ["User", "person"]},
},
{
"top_level": {},
Expand Down Expand Up @@ -70,8 +70,8 @@ def test_from_gql_create_syntax() -> None:
{
"source_idx": 4,
"target_idx": 7,
"top_level": {"caption": "Balloon"},
"properties": {"weight": -2, "type": "OTHER_LINK", "__type": 1, "source": 1337},
"top_level": {},
"properties": {"weight": -2, "caption": "Balloon", "type": "OTHER_LINK", "__type": 1, "source": 1337},
},
{"source_idx": 9, "target_idx": 10, "top_level": {}, "properties": {"type": "LINK"}},
]
Expand Down Expand Up @@ -102,7 +102,7 @@ def test_from_gql_create_captions() -> None:
},
{
"top_level": {"caption": "User:person"},
"properties": {"name": "Bridget", "age": 34, "labels": ["User", "person"]},
"properties": {"name": "Bridget", "caption": "Bridget", "age": 34, "labels": ["User", "person"]},
},
]

Expand Down Expand Up @@ -148,8 +148,8 @@ def test_from_gql_create_sizes() -> None:
"properties": {"name": "Alice", "age": 23, "labels": ["User"]},
},
{
"top_level": {"caption": "Bridget", "size": 60.0},
"properties": {"name": "Bridget", "age": 34, "labels": ["User", "person"]},
"top_level": {"size": 60.0},
"properties": {"name": "Bridget", "caption": "Bridget", "age": 34, "labels": ["User", "person"]},
},
]

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

Expand Down
49 changes: 44 additions & 5 deletions python-wrapper/tests/test_neo4j.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@
@pytest.fixture(scope="class", autouse=True)
def graph_setup(neo4j_session: Session) -> Generator[None, None, None]:
neo4j_session.run(
"CREATE (a:_CI_A {name:'Alice', height:20, id:42, _id: 1337, caption: 'hello'})-[:KNOWS {year: 2025, id: 41, source: 1, target: 2}]->"
"(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)"
"CREATE "
" (a:_CI_A {name:'Alice', height:20, id:42, _id: 1337, caption: 'hello'})"
" ,(b:_CI_A:_CI_B {name:'Bob', height:10, id: 84, size: 11, labels: [1,2]})"
" ,(a)-[:KNOWS {year: 2025, id: 41, source: 1, target: 2}]->(b)"
" ,(b)-[:RELATED {year: 2015, _type: 'A', caption:'hej'}]->(a)"
)
yield
neo4j_session.run("MATCH (n:_CI_A|_CI_B) DETACH DELETE n")
Expand Down Expand Up @@ -44,8 +47,9 @@ def test_from_neo4j_graph_basic(neo4j_session: Session) -> None:
Node(
id=node_ids[1],
caption="_CI_A:_CI_B",
size=11,
size=None,
properties=dict(
size=11,
labels=["_CI_A", "_CI_B"],
name="Bob",
height=10,
Expand All @@ -66,6 +70,41 @@ def test_from_neo4j_graph_basic(neo4j_session: Session) -> None:
]


@pytest.mark.requires_neo4j_and_gds
def test_from_neo4j_graph_size_property(neo4j_session: Session) -> None:
# set a non parsable size property, by default it should not be picked up
neo4j_session.run("MATCH (n) SET n.size = 'banana'")

graph = neo4j_session.run("MATCH (a:_CI_A|_CI_B)-[r]->(b) RETURN a, b, r ORDER BY a").graph()

VG = from_neo4j(graph, size_property="height", node_radius_min_max=None)

assert {n.properties["name"]: n.size for n in VG.nodes} == {"Alice": 20, "Bob": 10}

VG = from_neo4j(graph, size_property=None, node_radius_min_max=None)

assert {n.properties["name"]: n.size for n in VG.nodes} == {"Alice": None, "Bob": None}


@pytest.mark.requires_neo4j_and_gds
def test_from_neo4j_graph_default_caption(neo4j_session: Session) -> None:
neo4j_session.run("MATCH (n) SET n.caption = 'my_caption' SET n.other_caption = 'other_caption'")

graph = neo4j_session.run("MATCH (a:_CI_A|_CI_B)-[r]->(b) RETURN a, b, r ORDER BY a").graph()

VG = from_neo4j(graph, node_caption=None, node_radius_min_max=None)

assert [n.caption for n in VG.nodes] == [None, None]

VG = from_neo4j(graph, node_caption="other_caption", node_radius_min_max=None)

assert [n.caption for n in VG.nodes] == ["other_caption", "other_caption"]

VG = from_neo4j(graph, relationship_caption="year")

assert {e.properties["type"]: e.caption for e in VG.relationships} == {"KNOWS": "2025", "RELATED": "2015"}


@pytest.mark.requires_neo4j_and_gds
def test_from_neo4j_result(neo4j_session: Session) -> None:
result = neo4j_session.run("MATCH (a:_CI_A|_CI_B)-[r]->(b) RETURN a, b, r ORDER BY a")
Expand Down Expand Up @@ -93,8 +132,8 @@ def test_from_neo4j_result(neo4j_session: Session) -> None:
Node(
id=node_ids[1],
caption="_CI_A:_CI_B",
size=11,
properties=dict(
size=11,
labels=["_CI_A", "_CI_B"],
name="Bob",
height=10,
Expand Down Expand Up @@ -230,9 +269,9 @@ def test_from_neo4j_graph_driver(neo4j_session: Session, neo4j_driver: Driver) -
Node(
id=node_ids[1],
caption="_CI_A:_CI_B",
size=11,
properties=dict(
labels=["_CI_A", "_CI_B"],
size=11,
name="Bob",
height=10,
id=84,
Expand Down
Loading