Skip to content
Open
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
2 changes: 1 addition & 1 deletion drivers/python/age/age.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ def setUpAge(conn:psycopg.connection, graphName:str, load_from_plugins:bool=Fals
else:
cursor.execute("LOAD 'age';")

cursor.execute("SET search_path = ag_catalog, '$user', public;")
cursor.execute('SET search_path = ag_catalog, "$user", public;')

ag_info = TypeInfo.fetch(conn, 'agtype')

Expand Down
71 changes: 53 additions & 18 deletions drivers/python/age/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,16 @@ def visitAgValue(self, ctx:AgtypeParser.AgValueContext):

if annoCtx is not None:
annoCtx.accept(self)
anno = annoCtx.IDENT().getText()
identNode = annoCtx.IDENT()
if identNode is None:
raise AGTypeError(ctx.getText(), "Missing type annotation identifier")
anno = identNode.getText()
if valueCtx is None:
raise AGTypeError(ctx.getText(), "Missing value for annotated type")
return self.handleAnnotatedValue(anno, valueCtx)
else:
if valueCtx is None:
return None
return valueCtx.accept(self)


Expand All @@ -109,9 +116,14 @@ def visitIntegerValue(self, ctx:AgtypeParser.IntegerValueContext):

# Visit a parse tree produced by AgtypeParser#floatLiteral.
def visitFloatLiteral(self, ctx:AgtypeParser.FloatLiteralContext):
text = ctx.getText()
c = ctx.getChild(0)
if c is None or not hasattr(c, 'symbol') or c.symbol is None:
raise AGTypeError(
str(text),
"Malformed float literal: missing or invalid child node"
)
tp = c.symbol.type
text = ctx.getText()
if tp == AgtypeParser.RegularFloat:
return float(text)
elif tp == AgtypeParser.ExponentFloat:
Expand Down Expand Up @@ -150,15 +162,27 @@ def visitObj(self, ctx:AgtypeParser.ObjContext):
namVal = self.visitPair(c)
name = namVal[0]
valCtx = namVal[1]
val = valCtx.accept(self)
obj[name] = val
# visitPair() raises AGTypeError when the value node is
# missing, so valCtx should never be None here. The
# guard is kept as a defensive fallback only.
if valCtx is not None:
val = valCtx.accept(self)
obj[name] = val
else:
obj[name] = None
return obj


# Visit a parse tree produced by AgtypeParser#pair.
def visitPair(self, ctx:AgtypeParser.PairContext):
self.visitChildren(ctx)
return (ctx.STRING().getText().strip('"') , ctx.agValue())
strNode = ctx.STRING()
agValNode = ctx.agValue()
if strNode is None:
raise AGTypeError(ctx.getText(), "Missing key in object pair")
if agValNode is None:
raise AGTypeError(ctx.getText(), "Missing value in object pair")
return (strNode.getText().strip('"') , agValNode)


# Visit a parse tree produced by AgtypeParser#array.
Expand All @@ -171,38 +195,49 @@ def visitArray(self, ctx:AgtypeParser.ArrayContext):
return li

def handleAnnotatedValue(self, anno:str, ctx:ParserRuleContext):
# Each branch below constructs a model object (Vertex, Edge, Path)
# and populates it from the parsed dict/list. If a type check
# fails (e.g. the parsed value is not a dict), AGTypeError is
# raised and the partially-constructed object is discarded — no
# cleanup is needed because the caller propagates the exception.
if anno == "numeric":
return Decimal(ctx.getText())
elif anno == "vertex":
dict = ctx.accept(self)
vid = dict["id"]
d = ctx.accept(self)
if not isinstance(d, dict):
raise AGTypeError(str(ctx.getText()), "Expected dict for vertex, got " + type(d).__name__)
vid = d.get("id")
vertex = None
if self.vertexCache != None and vid in self.vertexCache :
if self.vertexCache is not None and vid in self.vertexCache:
vertex = self.vertexCache[vid]
else:
vertex = Vertex()
vertex.id = dict["id"]
vertex.label = dict["label"]
vertex.properties = dict["properties"]
vertex.id = d.get("id")
vertex.label = d.get("label")
vertex.properties = d.get("properties") or {}

if self.vertexCache != None:
if self.vertexCache is not None:
self.vertexCache[vid] = vertex

return vertex

elif anno == "edge":
edge = Edge()
dict = ctx.accept(self)
edge.id = dict["id"]
edge.label = dict["label"]
edge.end_id = dict["end_id"]
edge.start_id = dict["start_id"]
edge.properties = dict["properties"]
d = ctx.accept(self)
if not isinstance(d, dict):
raise AGTypeError(str(ctx.getText()), "Expected dict for edge, got " + type(d).__name__)
edge.id = d.get("id")
edge.label = d.get("label")
edge.end_id = d.get("end_id")
edge.start_id = d.get("start_id")
edge.properties = d.get("properties") or {}

return edge

elif anno == "path":
arr = ctx.accept(self)
if not isinstance(arr, list):
raise AGTypeError(str(ctx.getText()), "Expected list for path, got " + type(arr).__name__)
path = Path(arr)

return path
Expand Down
5 changes: 4 additions & 1 deletion drivers/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ authors = [
{name = "Ikchan Kwon, Apache AGE", email = "dev-subscribe@age.apache.org"}
]
classifiers = [
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
Expand All @@ -37,7 +38,9 @@ classifiers = [
]
dependencies = [
"psycopg",
"antlr4-python3-runtime==4.11.1",
# ANTLR4 runtime is format-compatible within major versions;
# tested on 4.11.1–4.13.2 with Python 3.9–3.14.
"antlr4-python3-runtime>=4.11.1,<5.0",
Comment on lines +41 to +43
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The added comment says the runtime was tested with Python 3.9–3.14, but the classifiers list does not include Python 3.9 (while requires-python is >=3.9). Consider either adding the Programming Language :: Python :: 3.9 classifier or adjusting the comment to match the supported/tested versions to avoid confusing package metadata.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the Programming Language :: Python :: 3.9 classifier so metadata matches requires-python = ">=3.9" and the ANTLR runtime comment (same commit).

]

[project.urls]
Expand Down
2 changes: 1 addition & 1 deletion drivers/python/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
psycopg
antlr4-python3-runtime==4.11.1
antlr4-python3-runtime>=4.11.1,<5.0
setuptools
networkx
176 changes: 175 additions & 1 deletion drivers/python/test_agtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
# specific language governing permissions and limitations
# under the License.

import json
import math
import unittest
from decimal import Decimal
import math

import age


Expand Down Expand Up @@ -127,6 +129,178 @@ def test_path(self):
self.assertEqual(vertexEnd.label, "Person")
self.assertEqual(vertexEnd["name"], "Joe")

def test_vertex_large_array_properties(self):
"""Issue #2367: Parser should handle vertices with large array properties."""
vertexExp = (
'{"id": 1125899906842625, "label": "TestNode", '
'"properties": {"name": "test", '
'"tags": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", '
'"tag8", "tag9", "tag10", "tag11", "tag12"]}}::vertex'
)
vertex = self.parse(vertexExp)
self.assertEqual(vertex.id, 1125899906842625)
self.assertEqual(vertex.label, "TestNode")
self.assertEqual(vertex["name"], "test")
self.assertEqual(len(vertex["tags"]), 12)
self.assertEqual(vertex["tags"][0], "tag1")
self.assertEqual(vertex["tags"][11], "tag12")

def test_vertex_special_characters_in_properties(self):
"""Issue #2367: Parser accepts JSON-escaped property strings and UTF-8."""
# Input uses json.dumps so quotes, backslashes, and newlines are valid JSON.
logical_description = 'Quoted "text", path C:\\tmp\\file, line1\nline2, café 雪'
props = json.dumps(
{"name": "test", "description": logical_description},
ensure_ascii=False,
)
vertexExp = (
'{"id": 1125899906842626, "label": "TestNode", '
'"properties": ' + props + '}::vertex'
)
vertex = self.parse(vertexExp)
self.assertEqual(vertex.id, 1125899906842626)
self.assertEqual(vertex["name"], "test")
# The agtype visitor keeps JSON string escapes as literal characters
# (except UTF-8 code points, which decode normally).
self.assertEqual(
vertex["description"],
'Quoted \\"text\\", path C:\\\\tmp\\\\file, line1\\nline2, café 雪',
)

def test_vertex_nested_properties(self):
"""Issue #2367: Parser should handle deeply nested property structures."""
vertexExp = (
'{"id": 1125899906842627, "label": "TestNode", '
'"properties": {"name": "test", '
'"metadata": {"level1": {"level2": {"level3": "deep_value"}}}}}::vertex'
)
vertex = self.parse(vertexExp)
self.assertEqual(vertex.id, 1125899906842627)
self.assertEqual(vertex["name"], "test")
self.assertEqual(vertex["metadata"]["level1"]["level2"]["level3"], "deep_value")

def test_vertex_empty_properties(self):
"""Parser should handle vertices with empty properties dict."""
vertexExp = '{"id": 1125899906842628, "label": "EmptyNode", "properties": {}}::vertex'
vertex = self.parse(vertexExp)
self.assertEqual(vertex.id, 1125899906842628)
self.assertEqual(vertex.label, "EmptyNode")
self.assertEqual(vertex.properties, {})

def test_vertex_null_property_values(self):
"""Parser should handle vertices with null property values."""
vertexExp = (
'{"id": 1125899906842629, "label": "TestNode", '
'"properties": {"name": "test", "optional": null, "also_null": null}}::vertex'
)
vertex = self.parse(vertexExp)
self.assertEqual(vertex["name"], "test")
self.assertIsNone(vertex["optional"])
self.assertIsNone(vertex["also_null"])

def test_edge_with_complex_properties(self):
"""Parser should handle edges with complex property structures."""
edgeExp = (
'{"id": 2533274790396577, "label": "HAS_RELATION", '
'"end_id": 1125899906842625, "start_id": 1125899906842626, '
'"properties": {"weight": 3, "tags": ["a", "b", "c"], "active": true}}::edge'
)
edge = self.parse(edgeExp)
self.assertEqual(edge.id, 2533274790396577)
self.assertEqual(edge.label, "HAS_RELATION")
self.assertEqual(edge.start_id, 1125899906842626)
self.assertEqual(edge.end_id, 1125899906842625)
self.assertEqual(edge["weight"], 3)
self.assertEqual(edge["tags"], ["a", "b", "c"])
self.assertEqual(edge["active"], True)

def test_path_with_multiple_edges(self):
"""Parser should handle paths with multiple edges and complex properties."""
pathExp = (
'[{"id": 1, "label": "A", "properties": {"name": "start"}}::vertex, '
'{"id": 10, "label": "r1", "end_id": 2, "start_id": 1, "properties": {"w": 1}}::edge, '
'{"id": 2, "label": "B", "properties": {"name": "middle"}}::vertex, '
'{"id": 11, "label": "r2", "end_id": 3, "start_id": 2, "properties": {"w": 2}}::edge, '
'{"id": 3, "label": "C", "properties": {"name": "end"}}::vertex]::path'
)
path = self.parse(pathExp)
self.assertEqual(len(path), 5)
self.assertEqual(path[0]["name"], "start")
self.assertEqual(path[2]["name"], "middle")
self.assertEqual(path[4]["name"], "end")

def test_empty_input(self):
"""Parser should handle empty/null input gracefully."""
self.assertIsNone(self.parse(''))
self.assertIsNone(self.parse(None))

def test_array_of_mixed_types(self):
"""Parser should handle arrays with mixed types including nested arrays."""
arrStr = '["str", 42, true, null, [1, 2, 3], {"key": "val"}]'
result = self.parse(arrStr)
self.assertEqual(result[0], "str")
self.assertEqual(result[1], 42)
self.assertEqual(result[2], True)
self.assertIsNone(result[3])
self.assertEqual(result[4], [1, 2, 3])
self.assertEqual(result[5], {"key": "val"})

def test_malformed_vertex_raises_agtypeerror_or_recovers(self):
"""Issue #2367: Malformed agtype must raise AGTypeError or recover gracefully."""
from age.exceptions import AGTypeError

malformed_inputs = [
'{"id": 1, "label":}::vertex',
'{"id": 1, "label": "X", "properties": {}::vertex',
'{::vertex',
'{"id": 1, "label": "X", "properties": {"key":}}::vertex',
]
for inp in malformed_inputs:
try:
result = self.parse(inp)
# Parser recovery is acceptable — verify the result is a
# usable Python value (None, container, or model object).
self.assertTrue(
result is None
or isinstance(result, (dict, list, tuple))
or hasattr(result, "__dict__"),
f"Recovered to unexpected type {type(result).__name__}: {inp}"
)
except AGTypeError:
pass # expected
except AttributeError:
self.fail(
f"Malformed input raised AttributeError instead of "
f"AGTypeError: {inp}"
)

def test_truncated_agtype_does_not_crash(self):
"""Issue #2367: Truncated agtype must raise AGTypeError or recover, never AttributeError."""
from age.exceptions import AGTypeError

truncated_inputs = [
'{"id": 1, "label": "X", "properties": {"name": "te',
'{"id": 1, "label": "X"',
'[{"id": 1}::vertex, {"id": 2',
]
for inp in truncated_inputs:
try:
result = self.parse(inp)
# Recovery is acceptable for truncated input
self.assertTrue(
result is None
or isinstance(result, (dict, list, tuple))
or hasattr(result, "__dict__"),
f"Recovered to unexpected type {type(result).__name__}: {inp}"
)
except AGTypeError:
pass # expected
except AttributeError:
self.fail(
f"Truncated input raised AttributeError instead of "
f"AGTypeError: {inp}"
)


if __name__ == '__main__':
unittest.main()