From de073e1fcea07b618a473b076738f569fde51ee2 Mon Sep 17 00:00:00 2001 From: Adam Schill Collberg Date: Wed, 23 Apr 2025 13:16:03 +0200 Subject: [PATCH 1/4] Make `Node` and `Relationship` top-level fields case insensitive --- .../src/neo4j_viz/case_insensitive_model.py | 12 ++++++ python-wrapper/src/neo4j_viz/node.py | 11 +++-- python-wrapper/src/neo4j_viz/relationship.py | 13 ++++-- python-wrapper/tests/test_node.py | 23 ++++++++--- python-wrapper/tests/test_relationship.py | 40 +++++++++++++++++-- 5 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 python-wrapper/src/neo4j_viz/case_insensitive_model.py diff --git a/python-wrapper/src/neo4j_viz/case_insensitive_model.py b/python-wrapper/src/neo4j_viz/case_insensitive_model.py new file mode 100644 index 00000000..fdd09616 --- /dev/null +++ b/python-wrapper/src/neo4j_viz/case_insensitive_model.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, model_validator +from pydantic.alias_generators import to_snake + + +class CaseInsensitiveModel(BaseModel): + @model_validator(mode="before") + def _make_fields_snake(cls, values: Any) -> Any: + return {to_snake(k): v for k, v in values.items()} diff --git a/python-wrapper/src/neo4j_viz/node.py b/python-wrapper/src/neo4j_viz/node.py index f8e15c98..291ed82c 100644 --- a/python-wrapper/src/neo4j_viz/node.py +++ b/python-wrapper/src/neo4j_viz/node.py @@ -2,25 +2,30 @@ from typing import Any, Optional, Union -from pydantic import AliasChoices, BaseModel, Field, field_serializer, field_validator +from pydantic import AliasChoices, Field, field_serializer, field_validator from pydantic_extra_types.color import Color, ColorType +from .case_insensitive_model import CaseInsensitiveModel from .node_size import RealNumber from .options import CaptionAlignment NodeIdType = Union[str, int] -class Node(BaseModel, extra="allow"): +class Node(CaseInsensitiveModel, extra="forbid"): """ A node in a graph to visualize. + Each field is case-insensitive for input, and camelCase is also accepted. + For example, "CAPTION_ALIGN", "captionAlign" are also valid inputs keys for the `caption_align` field. + Upon construction however, the field names are converted to snake_case. + For more info on each field, see the NVL library docs: https://neo4j.com/docs/nvl/current/base-library/#_nodes """ #: Unique identifier for the node id: NodeIdType = Field( - validation_alias=AliasChoices("id", "nodeId", "node_id"), description="Unique identifier for the node" + validation_alias=AliasChoices("id", "nodeid", "node_id"), description="Unique identifier for the node" ) #: The caption of the node caption: Optional[str] = Field(None, description="The caption of the node") diff --git a/python-wrapper/src/neo4j_viz/relationship.py b/python-wrapper/src/neo4j_viz/relationship.py index f19d1b09..b76e550b 100644 --- a/python-wrapper/src/neo4j_viz/relationship.py +++ b/python-wrapper/src/neo4j_viz/relationship.py @@ -3,16 +3,21 @@ from typing import Any, Optional, Union from uuid import uuid4 -from pydantic import AliasChoices, BaseModel, Field, field_serializer, field_validator +from pydantic import AliasChoices, Field, field_serializer, field_validator from pydantic_extra_types.color import Color, ColorType +from .case_insensitive_model import CaseInsensitiveModel from .options import CaptionAlignment -class Relationship(BaseModel, extra="allow"): +class Relationship(CaseInsensitiveModel, extra="forbid"): """ A relationship in a graph to visualize. + Each field is case-insensitive for input, and camelCase is also accepted. + For example, "CAPTION_ALIGN", "captionAlign" are also valid inputs keys for the `caption_align` field. + Upon construction however, the field names are converted to snake_case. + For more info on each field, see the NVL library docs: https://neo4j.com/docs/nvl/current/base-library/#_relationships """ @@ -23,13 +28,13 @@ class Relationship(BaseModel, extra="allow"): #: Node ID where the relationship points from source: Union[str, int] = Field( serialization_alias="from", - validation_alias=AliasChoices("source", "sourceNodeId", "source_node_id", "from"), + validation_alias=AliasChoices("source", "sourcenodeid", "source_node_id", "from"), description="Node ID where the relationship points from", ) #: Node ID where the relationship points to target: Union[str, int] = Field( serialization_alias="to", - validation_alias=AliasChoices("target", "targetNodeId", "target_node_id", "to"), + validation_alias=AliasChoices("target", "targetnodeid", "target_node_id", "to"), description="Node ID where the relationship points to", ) #: The caption of the relationship diff --git a/python-wrapper/tests/test_node.py b/python-wrapper/tests/test_node.py index a15031f6..3d3051c4 100644 --- a/python-wrapper/tests/test_node.py +++ b/python-wrapper/tests/test_node.py @@ -54,20 +54,19 @@ def test_node_with_float_size() -> None: } -def test_node_with_additional_fields() -> None: +def test_node_with_properties() -> None: node = Node( id="1", - componentId=2, + properties=dict(componentId=2), ) assert node.to_dict() == { "id": "1", - "componentId": 2, - "properties": {}, + "properties": {"componentId": 2}, } -@pytest.mark.parametrize("alias", ["id", "nodeId", "node_id"]) +@pytest.mark.parametrize("alias", ["id", "nodeId", "node_id", "NODEID", "nodeid"]) def test_id_aliases(alias: str) -> None: node = Node(**{alias: 1}) @@ -80,3 +79,17 @@ def test_id_aliases(alias: str) -> None: def test_node_validation() -> None: with pytest.raises(ValueError, match="Input should be a valid integer, unable to parse string as an integer"): Node(id="1", x="not a number") + + +def test_node_casing() -> None: + node = Node( + ID="4:d09f48a4-5fca-421d-921d-a30a896c604d:0", + caption="Person", + captionAlign=CaptionAlignment.TOP, + CAPTION_SIZE=1, + ) + + assert node.id == "4:d09f48a4-5fca-421d-921d-a30a896c604d:0" + assert node.caption == "Person" + assert node.caption_align == CaptionAlignment.TOP + assert node.caption_size == 1 diff --git a/python-wrapper/tests/test_relationship.py b/python-wrapper/tests/test_relationship.py index 3b373443..f165e4cb 100644 --- a/python-wrapper/tests/test_relationship.py +++ b/python-wrapper/tests/test_relationship.py @@ -52,12 +52,32 @@ def test_rels_additional_fields() -> None: assert rel.properties["componentId"] == 2 -@pytest.mark.parametrize("src_alias", ["source", "sourceNodeId", "source_node_id", "from"]) -@pytest.mark.parametrize("trg_alias", ["target", "targetNodeId", "target_node_id", "to"]) -def test_aliases(src_alias: str, trg_alias: str) -> None: +@pytest.mark.parametrize( + "src_alias", + ["source", "sourceNodeId", "source_node_id", "from", "SOURCE", "SOURCE_NODE_ID", "SOURCENODEID", "FROM"], +) +def test_src_aliases(src_alias: str) -> None: rel = Relationship( **{ src_alias: "1", + "to": "2", + } + ) + + rel_dict = rel.to_dict() + + assert {"id", "from", "to", "properties"} == set(rel_dict.keys()) + assert rel_dict["from"] == "1" + assert rel_dict["to"] == "2" + + +@pytest.mark.parametrize( + "trg_alias", ["target", "targetNodeId", "target_node_id", "to", "TARGET", "TARGET_NODE_ID", "TARGETNODEID", "TO"] +) +def test_trg_aliases(trg_alias: str) -> None: + rel = Relationship( + **{ + "from": "1", trg_alias: "2", } ) @@ -67,3 +87,17 @@ def test_aliases(src_alias: str, trg_alias: str) -> None: assert {"id", "from", "to", "properties"} == set(rel_dict.keys()) assert rel_dict["from"] == "1" assert rel_dict["to"] == "2" + + +def test_rel_casing() -> None: + rel = Relationship( + ID="1", + source="2", + target="3", + captionAlign=CaptionAlignment.TOP, + CAPTION_SIZE=12, + ) + + assert rel.id == "1" + assert rel.caption_align == CaptionAlignment.TOP + assert rel.caption_size == 12 From 41d5a3d4c3b822c6d877f4eb22ec21a27c5bf59b Mon Sep 17 00:00:00 2001 From: Adam Schill Collberg Date: Wed, 23 Apr 2025 13:18:27 +0200 Subject: [PATCH 2/4] Update Snowflake notebook with less renaming --- examples/snowpark-example.ipynb | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/examples/snowpark-example.ipynb b/examples/snowpark-example.ipynb index 51d93348..a1ac48b4 100644 --- a/examples/snowpark-example.ipynb +++ b/examples/snowpark-example.ipynb @@ -171,7 +171,7 @@ "## Fetching the data\n", "\n", "Next we fetch our tables from Snowflake and convert them to pandas DataFrames.\n", - "Additionally, we rename the most of the table columns so that they are named according to the `neo4j-viz` API." + "Additionally, we rename some of the table columns so that they are named according to the `neo4j-viz` API." ] }, { @@ -181,16 +181,8 @@ "metadata": {}, "outputs": [], "source": [ - "products_df = (\n", - " session.table(\"products\")\n", - " .to_pandas()\n", - " .rename(columns={\"ID\": \"id\", \"NAME\": \"caption\"})\n", - ")\n", - "parents_df = (\n", - " session.table(\"parents\")\n", - " .to_pandas()\n", - " .rename(columns={\"SOURCE\": \"source\", \"TARGET\": \"target\", \"TYPE\": \"caption\"})\n", - ")" + "products_df = session.table(\"products\").to_pandas().rename(columns={\"NAME\": \"caption\"})\n", + "parents_df = session.table(\"parents\").to_pandas().rename(columns={\"TYPE\": \"caption\"})" ] }, { From 1af3f924f7d025ff190a6c667753c28fdfa3be65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florentin=20D=C3=B6rre?= Date: Fri, 16 May 2025 15:04:53 +0200 Subject: [PATCH 3/4] Allow case-insensitive input for field names using AliasGenerator --- .../src/neo4j_viz/case_insensitive_model.py | 12 ------- python-wrapper/src/neo4j_viz/node.py | 36 +++++++++++++------ python-wrapper/src/neo4j_viz/nvl.py | 2 +- python-wrapper/src/neo4j_viz/relationship.py | 36 +++++++++++++------ python-wrapper/tests/test_node.py | 6 ++-- python-wrapper/tests/test_relationship.py | 6 ++-- 6 files changed, 58 insertions(+), 40 deletions(-) delete mode 100644 python-wrapper/src/neo4j_viz/case_insensitive_model.py diff --git a/python-wrapper/src/neo4j_viz/case_insensitive_model.py b/python-wrapper/src/neo4j_viz/case_insensitive_model.py deleted file mode 100644 index fdd09616..00000000 --- a/python-wrapper/src/neo4j_viz/case_insensitive_model.py +++ /dev/null @@ -1,12 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from pydantic import BaseModel, model_validator -from pydantic.alias_generators import to_snake - - -class CaseInsensitiveModel(BaseModel): - @model_validator(mode="before") - def _make_fields_snake(cls, values: Any) -> Any: - return {to_snake(k): v for k, v in values.items()} diff --git a/python-wrapper/src/neo4j_viz/node.py b/python-wrapper/src/neo4j_viz/node.py index 291ed82c..b799cff7 100644 --- a/python-wrapper/src/neo4j_viz/node.py +++ b/python-wrapper/src/neo4j_viz/node.py @@ -2,17 +2,31 @@ from typing import Any, Optional, Union -from pydantic import AliasChoices, Field, field_serializer, field_validator +from pydantic import AliasChoices, AliasGenerator, BaseModel, ConfigDict, Field, field_serializer, field_validator +from pydantic.alias_generators import to_camel from pydantic_extra_types.color import Color, ColorType -from .case_insensitive_model import CaseInsensitiveModel from .node_size import RealNumber from .options import CaptionAlignment NodeIdType = Union[str, int] -class Node(CaseInsensitiveModel, extra="forbid"): +def create_aliases(field_name: str) -> AliasChoices: + valid_names = [field_name] + + if field_name == "id": + valid_names.extend(["nodeid", "node_id"]) + + choices = [[choice, choice.upper(), to_camel(choice)] for choice in valid_names] + + return AliasChoices(*[alias for aliases in choices for alias in aliases]) + + +class Node( + BaseModel, + extra="forbid", +): """ A node in a graph to visualize. @@ -23,22 +37,24 @@ class Node(CaseInsensitiveModel, extra="forbid"): For more info on each field, see the NVL library docs: https://neo4j.com/docs/nvl/current/base-library/#_nodes """ - #: Unique identifier for the node - id: NodeIdType = Field( - validation_alias=AliasChoices("id", "nodeid", "node_id"), description="Unique identifier for the node" + model_config = ConfigDict( + alias_generator=AliasGenerator( + validation_alias=create_aliases, + serialization_alias=lambda field_name: to_camel(field_name), + ), ) + + #: Unique identifier for the node + id: NodeIdType = Field(description="Unique identifier for the node") #: The caption of the node caption: Optional[str] = Field(None, description="The caption of the node") #: The alignment of the caption text - caption_align: Optional[CaptionAlignment] = Field( - None, serialization_alias="captionAlign", description="The alignment of the caption text" - ) + caption_align: Optional[CaptionAlignment] = Field(None, description="The alignment of the caption text") #: The size of the caption text. The font size to node radius ratio caption_size: Optional[int] = Field( None, ge=1, le=3, - serialization_alias="captionSize", description="The size of the caption text. The font size to node radius ratio", ) #: The size of the node as radius in pixel diff --git a/python-wrapper/src/neo4j_viz/nvl.py b/python-wrapper/src/neo4j_viz/nvl.py index f33b5533..e7f9b78c 100644 --- a/python-wrapper/src/neo4j_viz/nvl.py +++ b/python-wrapper/src/neo4j_viz/nvl.py @@ -112,7 +112,7 @@ def render(