This is an automated email from the ASF dual-hosted git repository.

MuhammadTahaNaveed pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/age.git


The following commit(s) were added to refs/heads/master by this push:
     new 26bd4f4d fix(python-driver): add null-guards in ANTLR parser and relax 
runtime version pin (#2372)
26bd4f4d is described below

commit 26bd4f4d05e519cc340630bb71dc82e419485f31
Author: Ueslei Lima <[email protected]>
AuthorDate: Mon Apr 20 07:41:03 2026 -0300

    fix(python-driver): add null-guards in ANTLR parser and relax runtime 
version pin (#2372)
    
    Fix two related issues in the Python driver's ANTLR4 parsing pipeline:
    
    - Add null-guards in ResultVisitor methods (visitAgValue, visitFloatLiteral,
      visitPair, visitObj, handleAnnotatedValue) to prevent AttributeError 
crashes
      when the ANTLR4 parse tree contains None child nodes. This occurs with
      vertices that have complex properties (large arrays, special characters,
      deeply nested structures).
    
    - Relax antlr4-python3-runtime version constraint from ==4.11.1 to
      >=4.11.1,<5.0 in both pyproject.toml and requirements.txt. The 4.11.1
      pin is incompatible with Python >= 3.13. The ANTLR ATN serialized format
      is unchanged between 4.11 and 4.13, so the generated lexer/parser files
      are compatible. Validated with antlr4-python3-runtime==4.13.2 on
      Python 3.11-3.14.
    
    - Also replaces shadowing of builtin 'dict' in handleAnnotatedValue with 
'd',
      and uses .get() for safer key access on parsed vertex/edge dicts.
    
    - Add tests for malformed/truncated agtype input handling.
      Verify that malformed and truncated agtype strings raise AGTypeError
      (or recover gracefully) rather than crashing with AttributeError.
      This tests the null-guards added to the ANTLR parser visitor.
    
    - visitFloatLiteral: raise AGTypeError on malformed child node instead
      of silently returning a fallback value
    
    - visitObj: add comment documenting that visitPair's validation makes
      the None-guard defensive-only
    
    - handleAnnotatedValue: add comment explaining partial-construction
      behavior on type-check failure
    
    - pyproject.toml: add comment explaining ANTLR4 version range rationale
    
    - Tests: assert AGTypeError (or graceful recovery) for malformed and
      truncated inputs, not just absence of AttributeError
    
    - handleAnnotatedValue: default properties to {} when missing from
      parsed dict, preventing __getitem__ crashes on access
    
    - Tests: replace weak assertNotIsInstance with structural type checks
    
    - Fix truncated test docstring to match actual assertion behavior
    
    - Use PostgreSQL "$user" placeholder in SET search_path.
    
    - Exercise real escapes and Unicode in special-characters vertex test 
(json.dumps).
    
    - Add Python 3.9 trove classifier to match requires-python and dependency 
comment.
    
    - Build vertex agtype with string concat to avoid invalid f-string braces.
    
    - Assert stored description matches parser behavior: JSON escapes remain
      literal, UTF-8 decodes normally (ensure_ascii=False on json.dumps).
    
    - Regenerate parser with ANTLR 4.13.2 to silence runtime-version-mismatch 
warning.
      The generated lexer/parser were hardcoded to check for ANTLR runtime
      4.11.1, which triggered a noisy 'ANTLR runtime and generated code
      versions disagree' warning when installed against a newer runtime like
      4.13.2. Regenerating from Agtype.g4 with the 4.13.2 tool aligns the
      generated checkVersion() call with the default-installed runtime in
      the allowed dependency range and eliminates the warning.
    
    - Bumps the declared floor of antlr4-python3-runtime to 4.13.2 so the
      default install path is warning-free.
    
    Made-with: Cursor
---
 drivers/python/age/builder.py            |  71 +++++++++----
 drivers/python/age/gen/AgtypeLexer.py    |   7 +-
 drivers/python/age/gen/AgtypeListener.py |   7 +-
 drivers/python/age/gen/AgtypeParser.py   |   7 +-
 drivers/python/age/gen/AgtypeVisitor.py  |   7 +-
 drivers/python/pyproject.toml            |   7 +-
 drivers/python/requirements.txt          |   2 +-
 drivers/python/test_agtypes.py           | 176 ++++++++++++++++++++++++++++++-
 8 files changed, 245 insertions(+), 39 deletions(-)

diff --git a/drivers/python/age/builder.py b/drivers/python/age/builder.py
index f1e7a2ce..08a40c25 100644
--- a/drivers/python/age/builder.py
+++ b/drivers/python/age/builder.py
@@ -92,9 +92,16 @@ class ResultVisitor(AgtypeVisitor):
 
         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)
 
 
@@ -109,9 +116,14 @@ class ResultVisitor(AgtypeVisitor):
 
     # 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:
@@ -150,15 +162,27 @@ class ResultVisitor(AgtypeVisitor):
                 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.
@@ -171,38 +195,49 @@ class ResultVisitor(AgtypeVisitor):
         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
diff --git a/drivers/python/age/gen/AgtypeLexer.py 
b/drivers/python/age/gen/AgtypeLexer.py
index 54f19c7d..f94dc828 100644
--- a/drivers/python/age/gen/AgtypeLexer.py
+++ b/drivers/python/age/gen/AgtypeLexer.py
@@ -12,8 +12,7 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-# Generated from ../Agtype.g4 by ANTLR 4.11.1
-
+# Generated from ../Agtype.g4 by ANTLR 4.13.2
 from antlr4 import *
 from io import StringIO
 import sys
@@ -142,9 +141,7 @@ class AgtypeLexer(Lexer):
 
     def __init__(self, input=None, output:TextIO = sys.stdout):
         super().__init__(input, output)
-        self.checkVersion("4.11.1")
+        self.checkVersion("4.13.2")
         self._interp = LexerATNSimulator(self, self.atn, self.decisionsToDFA, 
PredictionContextCache())
         self._actions = None
         self._predicates = None
-
-
diff --git a/drivers/python/age/gen/AgtypeListener.py 
b/drivers/python/age/gen/AgtypeListener.py
index 6e60da42..39751ac5 100644
--- a/drivers/python/age/gen/AgtypeListener.py
+++ b/drivers/python/age/gen/AgtypeListener.py
@@ -12,10 +12,9 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-# Generated from ../Agtype.g4 by ANTLR 4.11.1
-
+# Generated from ../Agtype.g4 by ANTLR 4.13.2
 from antlr4 import *
-if __name__ is not None and "." in __name__:
+if "." in __name__:
     from .AgtypeParser import AgtypeParser
 else:
     from AgtypeParser import AgtypeParser
@@ -159,4 +158,4 @@ class AgtypeListener(ParseTreeListener):
 
 
 
-del AgtypeParser
\ No newline at end of file
+del AgtypeParser
diff --git a/drivers/python/age/gen/AgtypeParser.py 
b/drivers/python/age/gen/AgtypeParser.py
index daaf578e..19a43d08 100644
--- a/drivers/python/age/gen/AgtypeParser.py
+++ b/drivers/python/age/gen/AgtypeParser.py
@@ -12,9 +12,8 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-# Generated from ../Agtype.g4 by ANTLR 4.11.1
+# Generated from ../Agtype.g4 by ANTLR 4.13.2
 # encoding: utf-8
-
 from antlr4 import *
 from io import StringIO
 import sys
@@ -108,7 +107,7 @@ class AgtypeParser ( Parser ):
 
     def __init__(self, input:TokenStream, output:TextIO = sys.stdout):
         super().__init__(input, output)
-        self.checkVersion("4.11.1")
+        self.checkVersion("4.13.2")
         self._interp = ParserATNSimulator(self, self.atn, self.decisionsToDFA, 
self.sharedContextCache)
         self._predicates = None
 
@@ -854,5 +853,3 @@ class AgtypeParser ( Parser ):
         finally:
             self.exitRule()
         return localctx
-
-
diff --git a/drivers/python/age/gen/AgtypeVisitor.py 
b/drivers/python/age/gen/AgtypeVisitor.py
index 824eb512..8ea2f147 100644
--- a/drivers/python/age/gen/AgtypeVisitor.py
+++ b/drivers/python/age/gen/AgtypeVisitor.py
@@ -12,10 +12,9 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-# Generated from ../Agtype.g4 by ANTLR 4.11.1
-
+# Generated from ../Agtype.g4 by ANTLR 4.13.2
 from antlr4 import *
-if __name__ is not None and "." in __name__:
+if "." in __name__:
     from .AgtypeParser import AgtypeParser
 else:
     from AgtypeParser import AgtypeParser
@@ -100,4 +99,4 @@ class AgtypeVisitor(ParseTreeVisitor):
 
 
 
-del AgtypeParser
\ No newline at end of file
+del AgtypeParser
diff --git a/drivers/python/pyproject.toml b/drivers/python/pyproject.toml
index 18112381..651fc205 100644
--- a/drivers/python/pyproject.toml
+++ b/drivers/python/pyproject.toml
@@ -29,6 +29,7 @@ authors = [
     {name = "Ikchan Kwon, Apache AGE", email = "[email protected]"}
 ]
 classifiers = [
+    "Programming Language :: Python :: 3.9",
     "Programming Language :: Python :: 3.10",
     "Programming Language :: Python :: 3.11",
     "Programming Language :: Python :: 3.12",
@@ -37,7 +38,11 @@ classifiers = [
 ]
 dependencies = [
     "psycopg",
-    "antlr4-python3-runtime==4.11.1",
+    # Parser is generated with ANTLR 4.13.2. Runtime is forward- and
+    # backward-compatible within the 4.x series (checkVersion() emits a
+    # warning on mismatch but parsing still works); tested on 4.11.1–4.13.2
+    # with Python 3.9–3.14.
+    "antlr4-python3-runtime>=4.13.2,<5.0",
 ]
 
 [project.urls]
diff --git a/drivers/python/requirements.txt b/drivers/python/requirements.txt
index 449d38c6..ceadc06c 100644
--- a/drivers/python/requirements.txt
+++ b/drivers/python/requirements.txt
@@ -1,4 +1,4 @@
 psycopg
-antlr4-python3-runtime==4.11.1
+antlr4-python3-runtime>=4.11.1,<5.0
 setuptools
 networkx
diff --git a/drivers/python/test_agtypes.py b/drivers/python/test_agtypes.py
index 4e9752e6..97f7972d 100644
--- a/drivers/python/test_agtypes.py
+++ b/drivers/python/test_agtypes.py
@@ -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
 
 
@@ -127,6 +129,178 @@ class TestAgtype(unittest.TestCase):
         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()

Reply via email to