Skip to content

Commit 22ece7f

Browse files
authored
Merge pull request #145 from neo4j/case-insensitive-2
Make `Node` and `Relationship` top-level fields case insensitive
2 parents 17b8d82 + 553f471 commit 22ece7f

File tree

6 files changed

+115
-39
lines changed

6 files changed

+115
-39
lines changed

examples/snowpark-example.ipynb

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@
171171
"## Fetching the data\n",
172172
"\n",
173173
"Next we fetch our tables from Snowflake and convert them to pandas DataFrames.\n",
174-
"Additionally, we rename the most of the table columns so that they are named according to the `neo4j-viz` API."
174+
"Additionally, we rename some of the table columns so that they are named according to the `neo4j-viz` API."
175175
]
176176
},
177177
{
@@ -181,16 +181,8 @@
181181
"metadata": {},
182182
"outputs": [],
183183
"source": [
184-
"products_df = (\n",
185-
" session.table(\"products\")\n",
186-
" .to_pandas()\n",
187-
" .rename(columns={\"ID\": \"id\", \"NAME\": \"caption\"})\n",
188-
")\n",
189-
"parents_df = (\n",
190-
" session.table(\"parents\")\n",
191-
" .to_pandas()\n",
192-
" .rename(columns={\"SOURCE\": \"source\", \"TARGET\": \"target\", \"TYPE\": \"caption\"})\n",
193-
")"
184+
"products_df = session.table(\"products\").to_pandas().rename(columns={\"NAME\": \"caption\"})\n",
185+
"parents_df = session.table(\"parents\").to_pandas().rename(columns={\"TYPE\": \"caption\"})"
194186
]
195187
},
196188
{

python-wrapper/src/neo4j_viz/node.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
from typing import Any, Optional, Union
44

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
67
from pydantic_extra_types.color import Color, ColorType
78

89
from .node_size import RealNumber
@@ -11,29 +12,46 @@
1112
NodeIdType = Union[str, int]
1213

1314

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+
):
1534
"""
1635
A node in a graph to visualize.
1736
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+
1841
For more info on each field, see the NVL library docs: https://neo4j.com/docs/nvl/current/base-library/#_nodes
1942
"""
2043

2144
#: 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")
2546
#: The caption of the node
2647
caption: Optional[str] = Field(None, description="The caption of the node")
2748
#: 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")
3150
#: The size of the caption text. The font size to node radius ratio
3251
caption_size: Optional[int] = Field(
3352
None,
3453
ge=1,
3554
le=3,
36-
serialization_alias="captionSize",
3755
description="The size of the caption text. The font size to node radius ratio",
3856
)
3957
#: The size of the node as radius in pixel

python-wrapper/src/neo4j_viz/nvl.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ def render(
112112
<script>
113113
getTheme = () => {{
114114
const backgroundColorString = window.getComputedStyle(document.body, null).getPropertyValue('background-color')
115-
const colorsArray = backgroundColorString.match(/\d+/g);
115+
const colorsArray = backgroundColorString.match(/\\d+/g);
116116
const brightness = Number(colorsArray[0]) * 0.2126 + Number(colorsArray[1]) * 0.7152 + Number(colorsArray[2]) * 0.0722
117117
return brightness < 128 ? "dark" : "light"
118118
}}

python-wrapper/src/neo4j_viz/relationship.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,41 @@
33
from typing import Any, Optional, Union
44
from uuid import uuid4
55

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
78
from pydantic_extra_types.color import Color, ColorType
89

910
from .options import CaptionAlignment
1011

1112

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+
):
1334
"""
1435
A relationship in a graph to visualize.
1536
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+
1641
For more info on each field, see the NVL library docs: https://neo4j.com/docs/nvl/current/base-library/#_relationships
1742
"""
1843

@@ -23,25 +48,19 @@ class Relationship(BaseModel, extra="allow"):
2348
#: Node ID where the relationship points from
2449
source: Union[str, int] = Field(
2550
serialization_alias="from",
26-
validation_alias=AliasChoices("source", "sourceNodeId", "source_node_id", "from"),
2751
description="Node ID where the relationship points from",
2852
)
2953
#: Node ID where the relationship points to
3054
target: Union[str, int] = Field(
3155
serialization_alias="to",
32-
validation_alias=AliasChoices("target", "targetNodeId", "target_node_id", "to"),
3356
description="Node ID where the relationship points to",
3457
)
3558
#: The caption of the relationship
3659
caption: Optional[str] = Field(None, description="The caption of the relationship")
3760
#: 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")
4162
#: 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")
4564
#: The color of the relationship. Allowed input is for example "#FF0000", "red" or (255, 0, 0)
4665
color: Optional[ColorType] = Field(None, description="The color of the relationship")
4766
#: Additional properties of the relationship that do not directly impact the visualization

python-wrapper/tests/test_node.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,20 +54,19 @@ def test_node_with_float_size() -> None:
5454
}
5555

5656

57-
def test_node_with_additional_fields() -> None:
57+
def test_node_with_properties() -> None:
5858
node = Node(
5959
id="1",
60-
componentId=2,
60+
properties=dict(componentId=2),
6161
)
6262

6363
assert node.to_dict() == {
6464
"id": "1",
65-
"componentId": 2,
66-
"properties": {},
65+
"properties": {"componentId": 2},
6766
}
6867

6968

70-
@pytest.mark.parametrize("alias", ["id", "nodeId", "node_id"])
69+
@pytest.mark.parametrize("alias", ["id", "nodeId", "node_id", "NODEID", "nodeid"])
7170
def test_id_aliases(alias: str) -> None:
7271
node = Node(**{alias: 1})
7372

@@ -80,3 +79,17 @@ def test_id_aliases(alias: str) -> None:
8079
def test_node_validation() -> None:
8180
with pytest.raises(ValueError, match="Input should be a valid integer, unable to parse string as an integer"):
8281
Node(id="1", x="not a number")
82+
83+
84+
def test_node_casing() -> None:
85+
node = Node(
86+
ID="4:d09f48a4-5fca-421d-921d-a30a896c604d:0",
87+
caption="Person",
88+
captionAlign=CaptionAlignment.TOP,
89+
CAPTION_SIZE=1,
90+
)
91+
92+
assert node.id == "4:d09f48a4-5fca-421d-921d-a30a896c604d:0"
93+
assert node.caption == "Person"
94+
assert node.caption_align == CaptionAlignment.TOP
95+
assert node.caption_size == 1

python-wrapper/tests/test_relationship.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,32 @@ def test_rels_additional_fields() -> None:
5252
assert rel.properties["componentId"] == 2
5353

5454

55-
@pytest.mark.parametrize("src_alias", ["source", "sourceNodeId", "source_node_id", "from"])
56-
@pytest.mark.parametrize("trg_alias", ["target", "targetNodeId", "target_node_id", "to"])
57-
def test_aliases(src_alias: str, trg_alias: str) -> None:
55+
@pytest.mark.parametrize(
56+
"src_alias",
57+
["source", "sourceNodeId", "source_node_id", "from", "SOURCE", "SOURCE_NODE_ID", "SOURCENODEID", "FROM"],
58+
)
59+
def test_src_aliases(src_alias: str) -> None:
5860
rel = Relationship(
5961
**{
6062
src_alias: "1",
63+
"to": "2",
64+
}
65+
)
66+
67+
rel_dict = rel.to_dict()
68+
69+
assert {"id", "from", "to", "properties"} == set(rel_dict.keys())
70+
assert rel_dict["from"] == "1"
71+
assert rel_dict["to"] == "2"
72+
73+
74+
@pytest.mark.parametrize(
75+
"trg_alias", ["target", "targetNodeId", "target_node_id", "to", "TARGET", "TARGET_NODE_ID", "TARGETNODEID", "TO"]
76+
)
77+
def test_trg_aliases(trg_alias: str) -> None:
78+
rel = Relationship(
79+
**{
80+
"from": "1",
6181
trg_alias: "2",
6282
}
6383
)
@@ -67,3 +87,17 @@ def test_aliases(src_alias: str, trg_alias: str) -> None:
6787
assert {"id", "from", "to", "properties"} == set(rel_dict.keys())
6888
assert rel_dict["from"] == "1"
6989
assert rel_dict["to"] == "2"
90+
91+
92+
def test_rel_casing() -> None:
93+
rel = Relationship(
94+
ID="1",
95+
source="2",
96+
target="3",
97+
captionAlign=CaptionAlignment.TOP,
98+
CAPTION_SIZE=12,
99+
)
100+
101+
assert rel.id == "1"
102+
assert rel.caption_align == CaptionAlignment.TOP
103+
assert rel.caption_size == 12

0 commit comments

Comments
 (0)