diff --git a/.github/workflows/snowflake-integration-tests.yml b/.github/workflows/snowflake-integration-tests.yml index 07831478..d9d4c8b8 100644 --- a/.github/workflows/snowflake-integration-tests.yml +++ b/.github/workflows/snowflake-integration-tests.yml @@ -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: @@ -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]" @@ -46,4 +45,4 @@ jobs: SNOWFLAKE_PASSWORD: ${{ secrets.SNOWFLAKE_PASSWORD }} SNOWFLAKE_ROLE: ACCOUNTADMIN SNOWFLAKE_WAREHOUSE: ${{ secrets.SNOWFLAKE_WAREHOUSE }} - run: pytest tests/ --include-snowflake \ No newline at end of file + run: pytest tests/ --include-snowflake -W "ignore:Python Runtime 3.9 reached its End-Of-Life:DeprecationWarning" diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index d5fe6e64..0e44291c 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -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: @@ -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]" @@ -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" diff --git a/changelog.md b/changelog.md index 41a53390..3dc0199c 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/python-wrapper/src/neo4j_viz/gql_create.py b/python-wrapper/src/neo4j_viz/gql_create.py index e5c52965..e584d9a4 100644 --- a/python-wrapper/src/neo4j_viz/gql_create.py +++ b/python-wrapper/src/neo4j_viz/gql_create.py @@ -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(): @@ -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": @@ -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 diff --git a/python-wrapper/src/neo4j_viz/neo4j.py b/python-wrapper/src/neo4j_viz/neo4j.py index 3d789ad5..5b202b87 100644 --- a/python-wrapper/src/neo4j_viz/neo4j.py +++ b/python-wrapper/src/neo4j_viz/neo4j.py @@ -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 = [ diff --git a/python-wrapper/src/neo4j_viz/node.py b/python-wrapper/src/neo4j_viz/node.py index 15e73191..03dbc29c 100644 --- a/python-wrapper/src/neo4j_viz/node.py +++ b/python-wrapper/src/neo4j_viz/node.py @@ -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. diff --git a/python-wrapper/src/neo4j_viz/relationship.py b/python-wrapper/src/neo4j_viz/relationship.py index 5efe845f..f72fb66e 100644 --- a/python-wrapper/src/neo4j_viz/relationship.py +++ b/python-wrapper/src/neo4j_viz/relationship.py @@ -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. diff --git a/python-wrapper/src/neo4j_viz/visualization_graph.py b/python-wrapper/src/neo4j_viz/visualization_graph.py index c3387dd2..a9661bc7 100644 --- a/python-wrapper/src/neo4j_viz/visualization_graph.py +++ b/python-wrapper/src/neo4j_viz/visualization_graph.py @@ -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. @@ -163,33 +164,47 @@ 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) @@ -197,6 +212,7 @@ def resize_nodes( else: final_sizes = all_sizes + # Apply the final sizes to the nodes for node in self.nodes: size = final_sizes.get(node.id) diff --git a/python-wrapper/tests/test_gql_create.py b/python-wrapper/tests/test_gql_create.py index e18f74d3..67923198 100644 --- a/python-wrapper/tests/test_gql_create.py +++ b/python-wrapper/tests/test_gql_create.py @@ -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": {}, @@ -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"}}, ] @@ -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"]}, }, ] @@ -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"]}, }, ] @@ -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") diff --git a/python-wrapper/tests/test_neo4j.py b/python-wrapper/tests/test_neo4j.py index d46be658..b4de935a 100644 --- a/python-wrapper/tests/test_neo4j.py +++ b/python-wrapper/tests/test_neo4j.py @@ -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") @@ -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, @@ -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") @@ -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, @@ -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, diff --git a/python-wrapper/tests/test_sizes.py b/python-wrapper/tests/test_sizes.py index 37a353b3..251fd351 100644 --- a/python-wrapper/tests/test_sizes.py +++ b/python-wrapper/tests/test_sizes.py @@ -37,6 +37,17 @@ def test_verify_radii() -> None: verify_radii((1, 2)) +def test_resize_nodes_either_sizes_or_property() -> None: + nodes = [ + Node(id=42), + Node(id="1337", size=10), + ] + VG = VisualizationGraph(nodes=nodes, relationships=[]) + + with pytest.raises(ValueError, match="At most one of the arguments `sizes` and `property` can be provided"): + VG.resize_nodes(sizes={"1337": 20}, property="size", node_radius_min_max=(3, 60)) + + def test_resize_nodes_no_scaling() -> None: nodes = [ Node(id=42), @@ -61,6 +72,27 @@ def test_resize_nodes_no_scaling() -> None: VG.resize_nodes(new_sizes, None) +def test_resize_nodes_by_property() -> None: + nodes = [ + Node(id=42, properties={"age": 4}), + Node(id="1337", properties={"age": 2}), + Node(id=55, properties={"age": 8}), + ] + VG = VisualizationGraph(nodes=nodes, relationships=[]) + + VG.resize_nodes(property="age", node_radius_min_max=None) + + assert VG.nodes[0].size == 4 + assert VG.nodes[1].size == 2 + assert VG.nodes[2].size == 8 + + VG.resize_nodes(property="age", node_radius_min_max=(1, 4)) + + assert VG.nodes[0].size == 2 + assert VG.nodes[1].size == 1 + assert VG.nodes[2].size == 4 + + def test_resize_nodes_with_scaling_constant() -> None: nodes = [ Node(id=42), @@ -124,5 +156,5 @@ def test_resize_nodes_with_scaling_only() -> None: def test_resize_nodes_no_args_failure() -> None: VG = VisualizationGraph(nodes=[], relationships=[]) - with pytest.raises(ValueError, match="At least one of `sizes` and `node_radius_min_max` must be given"): + with pytest.raises(ValueError, match="At least one of `sizes`, `property` or `node_radius_min_max` must be given"): VG.resize_nodes(node_radius_min_max=None)