This is an automated email from the ASF dual-hosted git repository. Cole-Greer pushed a commit to branch simplePDT in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
commit bab3d4a26aaf762c6fe1b66a0ef84c92be29f1fe Author: Cole Greer <[email protected]> AuthorDate: Wed Jun 24 14:26:32 2026 -0700 Add PrimitivePDT support to gremlin-python (first GLV) Implements PrimitivePDT in the Python GLV, mirroring the composite support. - structure/graph.py: PrimitiveProviderDefinedType(name, value) and registry support for primitive adapters (register + hydrate_primitive with graceful degradation); reuses the existing pdt_registry threading. - structure/io/graphbinaryV4.py: DataType.primitive_pdt=0xf1 and PrimitiveProviderDefinedTypeIO (writes/reads two fully-qualified Strings); reader hydration dispatch for PrimitiveProviderDefinedType, including primitive-nested-in-composite. - driver/serializer.py: primitive registry threaded through the same pdt_registry path as composite. GraphSON read support is intentionally omitted: the gremlin-python driver is GraphBinary-only for V4 (no GraphSON V4 deserializer exists), so there is no g:PrimitivePdt read path to add. Clients send PrimitivePDT as the gremlin-lang PDT("name","value") literal and receive it via GraphBinary. Tests: 40 passing (GraphBinary round-trip incl. opaque-value fidelity, registry hydration, primitive-nested-in-composite), 3 pre-existing entry_points skips. tinkerpop-2gy.8 Assisted-by: Kiro:claude-opus-4.8 --- .../python/gremlin_python/driver/serializer.py | 2 + .../main/python/gremlin_python/structure/graph.py | 74 +++++++++ .../gremlin_python/structure/io/graphbinaryV4.py | 32 +++- .../structure/io/test_provider_defined_type.py | 179 +++++++++++++++++++++ 4 files changed, 285 insertions(+), 2 deletions(-) diff --git a/gremlin-python/src/main/python/gremlin_python/driver/serializer.py b/gremlin-python/src/main/python/gremlin_python/driver/serializer.py index c333a4cc54..47182b04da 100644 --- a/gremlin-python/src/main/python/gremlin_python/driver/serializer.py +++ b/gremlin-python/src/main/python/gremlin_python/driver/serializer.py @@ -51,6 +51,8 @@ class GraphBinarySerializersV4(object): else: self._graphbinary_reader.pdt_registry._adapters_by_name.update(pdt_registry._adapters_by_name) self._graphbinary_reader.pdt_registry._adapters_by_class.update(pdt_registry._adapters_by_class) + self._graphbinary_reader.pdt_registry._primitive_adapters_by_name.update(pdt_registry._primitive_adapters_by_name) + self._graphbinary_reader.pdt_registry._primitive_adapters_by_class.update(pdt_registry._primitive_adapters_by_class) @property def version(self): diff --git a/gremlin-python/src/main/python/gremlin_python/structure/graph.py b/gremlin-python/src/main/python/gremlin_python/structure/graph.py index 619eb64899..5d66e12bac 100644 --- a/gremlin-python/src/main/python/gremlin_python/structure/graph.py +++ b/gremlin-python/src/main/python/gremlin_python/structure/graph.py @@ -173,10 +173,41 @@ class ProviderDefinedType(object): return f"pdt[{self._name}]{self._fields}" +class PrimitiveProviderDefinedType(object): + """An immutable primitive provider-defined type consisting of a name and an opaque string value.""" + + def __init__(self, name, value): + if not name: + raise ValueError("name cannot be null or empty") + if value is None: + raise ValueError("value cannot be null") + self._name = name + self._value = value + + @property + def name(self): + return self._name + + @property + def value(self): + return self._value + + def __eq__(self, other): + return isinstance(other, PrimitiveProviderDefinedType) and self._name == other._name and self._value == other._value + + def __hash__(self): + return hash((self._name, self._value)) + + def __repr__(self): + return f"pdt[{self._name}]({self._value})" + + class ProviderDefinedTypeRegistry(object): def __init__(self): self._adapters_by_name = {} self._adapters_by_class = {} + self._primitive_adapters_by_name = {} + self._primitive_adapters_by_class = {} def register(self, type_name, deserialize_fn, serialize_fn=None, target_class=None): self._adapters_by_name[type_name] = { @@ -190,6 +221,26 @@ class ProviderDefinedTypeRegistry(object): 'serialize': serialize_fn, } + def register_primitive(self, type_name, from_value, to_value=None, target_class=None): + """Register a primitive PDT adapter. + + Args: + type_name: The PDT type name string. + from_value: Callable(str) -> object for deserialization. + to_value: Callable(object) -> str for serialization (optional). + target_class: The Python class this adapter produces (optional). + """ + self._primitive_adapters_by_name[type_name] = { + 'from_value': from_value, + 'to_value': to_value, + 'target_class': target_class + } + if target_class is not None: + self._primitive_adapters_by_class[target_class] = { + 'type_name': type_name, + 'to_value': to_value, + } + @classmethod def create(cls): """Create a registry populated by entry_points discovery. @@ -234,6 +285,11 @@ class ProviderDefinedTypeRegistry(object): if h is not v: changed = True hydrated_fields[k] = h + elif isinstance(v, PrimitiveProviderDefinedType): + h = self.hydrate_primitive(v) + if h is not v: + changed = True + hydrated_fields[k] = h else: hydrated_fields[k] = v @@ -247,10 +303,28 @@ class ProviderDefinedTypeRegistry(object): logging.getLogger(__name__).warning(f"PDT hydration failed for '{pdt.name}': {e}") return pdt + def hydrate_primitive(self, pdt): + """Attempt to hydrate a PrimitiveProviderDefinedType. Returns typed object or raw PDT.""" + if not isinstance(pdt, PrimitiveProviderDefinedType): + return pdt + adapter = self._primitive_adapters_by_name.get(pdt.name) + if adapter is None: + return pdt + try: + return adapter['from_value'](pdt.value) + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"Primitive PDT hydration failed for '{pdt.name}': {e}") + return pdt + def get_adapter_by_class(self, cls): """Return (type_name, serialize_fn) tuple for the given class, or None.""" return self._adapters_by_class.get(cls) + def get_primitive_adapter_by_class(self, cls): + """Return adapter dict for the given class, or None.""" + return self._primitive_adapters_by_class.get(cls) + # Module-level registry of @provider_defined decorated classes keyed by PDT name. _pdt_decorated_types = {} diff --git a/gremlin-python/src/main/python/gremlin_python/structure/io/graphbinaryV4.py b/gremlin-python/src/main/python/gremlin_python/structure/io/graphbinaryV4.py index c6098619ce..12c0a0f46e 100644 --- a/gremlin-python/src/main/python/gremlin_python/structure/io/graphbinaryV4.py +++ b/gremlin-python/src/main/python/gremlin_python/structure/io/graphbinaryV4.py @@ -31,7 +31,7 @@ from gremlin_python.process.traversal import Direction, T, Merge, GType from gremlin_python.statics import FloatType, BigDecimal, ShortType, IntType, LongType, BigIntType, \ DictType, SetType, SingleByte, SingleChar from gremlin_python.structure.graph import Graph, Edge, Property, Vertex, VertexProperty, Path, ProviderDefinedType, \ - _pdt_decorated_types + PrimitiveProviderDefinedType, _pdt_decorated_types from gremlin_python.structure.io.util import HashableDict, SymbolUtil, Marker log = logging.getLogger(__name__) @@ -75,6 +75,7 @@ class DataType(Enum): char = 0x80 duration = 0x81 composite_pdt = 0xf0 + primitive_pdt = 0xf1 marker = 0xfd @@ -168,6 +169,11 @@ class GraphBinaryReader(object): result = self.deserializers[DataType(bt)].objectify(buff, self, nullable) else: result = self.deserializers[data_type].objectify(buff, self, nullable) + if self.pdt_registry is not None and isinstance(result, PrimitiveProviderDefinedType): + hydrated = self.pdt_registry.hydrate_primitive(result) + if not isinstance(hydrated, PrimitiveProviderDefinedType): + return hydrated + result = hydrated if self.pdt_registry is not None and isinstance(result, ProviderDefinedType): hydrated = self.pdt_registry.hydrate(result) if not isinstance(hydrated, ProviderDefinedType): @@ -969,4 +975,26 @@ class ProviderDefinedTypeIO(_GraphBinaryTypeIO): def _read_pdt(cls, b, r): name = r.read_object(b) fields = r.read_object(b) - return ProviderDefinedType(name, fields) \ No newline at end of file + return ProviderDefinedType(name, fields) + + +class PrimitiveProviderDefinedTypeIO(_GraphBinaryTypeIO): + python_type = PrimitiveProviderDefinedType + graphbinary_type = DataType.primitive_pdt + + @classmethod + def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True): + cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend) + StringIO.dictify(obj.name, writer, to_extend) + StringIO.dictify(obj.value, writer, to_extend) + return to_extend + + @classmethod + def objectify(cls, buff, reader, nullable=True): + return cls.is_null(buff, reader, cls._read_primitive_pdt, nullable) + + @classmethod + def _read_primitive_pdt(cls, b, r): + name = r.read_object(b) + value = r.read_object(b) + return PrimitiveProviderDefinedType(name, value) \ No newline at end of file diff --git a/gremlin-python/src/main/python/tests/unit/structure/io/test_provider_defined_type.py b/gremlin-python/src/main/python/tests/unit/structure/io/test_provider_defined_type.py index aac685982c..147e4b670e 100644 --- a/gremlin-python/src/main/python/tests/unit/structure/io/test_provider_defined_type.py +++ b/gremlin-python/src/main/python/tests/unit/structure/io/test_provider_defined_type.py @@ -20,6 +20,7 @@ under the License. import pytest from gremlin_python.structure.graph import ProviderDefinedType, ProviderDefinedTypeRegistry, provider_defined +from gremlin_python.structure.graph import PrimitiveProviderDefinedType from gremlin_python.structure.io.graphbinaryV4 import GraphBinaryWriter, GraphBinaryReader @@ -242,3 +243,181 @@ class TestPdtRegistryWiring(object): with patch.object(Client, '_fill_pool'): drc = DriverRemoteConnection("ws://localhost:8182/gremlin", "g", pdt_registry=registry) assert drc._client._response_serializer._graphbinary_reader.pdt_registry is registry + + +class TestPrimitiveProviderDefinedType(object): + + def test_empty_name_rejected(self): + with pytest.raises(ValueError): + PrimitiveProviderDefinedType("", "123") + + def test_none_name_rejected(self): + with pytest.raises(ValueError): + PrimitiveProviderDefinedType(None, "123") + + def test_none_value_rejected(self): + with pytest.raises(ValueError): + PrimitiveProviderDefinedType("Uint32", None) + + def test_equality(self): + a = PrimitiveProviderDefinedType("Uint32", "42") + b = PrimitiveProviderDefinedType("Uint32", "42") + assert a == b + assert hash(a) == hash(b) + + def test_inequality(self): + a = PrimitiveProviderDefinedType("Uint32", "42") + b = PrimitiveProviderDefinedType("Uint32", "43") + assert a != b + + def test_repr(self): + pdt = PrimitiveProviderDefinedType("Uint32", "42") + assert "Uint32" in repr(pdt) + assert "42" in repr(pdt) + + +class TestPrimitiveProviderDefinedTypeGraphBinary(object): + graphbinary_writer = GraphBinaryWriter() + graphbinary_reader = GraphBinaryReader() + + def test_round_trip_simple(self): + pdt = PrimitiveProviderDefinedType("Uint32", "42") + ba = self.graphbinary_writer.write_object(pdt) + result = self.graphbinary_reader.read_object(ba) + assert isinstance(result, PrimitiveProviderDefinedType) + assert result == pdt + + def test_round_trip_leading_zeros(self): + """Opaque value: leading zeros must be preserved.""" + pdt = PrimitiveProviderDefinedType("Uint32", "007") + ba = self.graphbinary_writer.write_object(pdt) + result = self.graphbinary_reader.read_object(ba) + assert result.value == "007" + + def test_round_trip_large_number(self): + """Opaque value: large numbers preserved as string.""" + pdt = PrimitiveProviderDefinedType("BigNum", "99999999999999999999999999999") + ba = self.graphbinary_writer.write_object(pdt) + result = self.graphbinary_reader.read_object(ba) + assert result.value == "99999999999999999999999999999" + + def test_round_trip_non_numeric(self): + """Opaque value: non-numeric strings work.""" + pdt = PrimitiveProviderDefinedType("TinkerId", "abc-def-123") + ba = self.graphbinary_writer.write_object(pdt) + result = self.graphbinary_reader.read_object(ba) + assert result.value == "abc-def-123" + + def test_round_trip_empty_value(self): + """Edge case: empty string value.""" + pdt = PrimitiveProviderDefinedType("Empty", "") + ba = self.graphbinary_writer.write_object(pdt) + result = self.graphbinary_reader.read_object(ba) + assert result.value == "" + + +class TestPrimitiveRegistryHydration(object): + + def test_hydrate_simple(self): + registry = ProviderDefinedTypeRegistry() + registry.register_primitive("Uint32", lambda v: int(v)) + pdt = PrimitiveProviderDefinedType("Uint32", "42") + result = registry.hydrate_primitive(pdt) + assert result == 42 + + def test_hydrate_no_adapter_returns_raw(self): + registry = ProviderDefinedTypeRegistry() + pdt = PrimitiveProviderDefinedType("Unknown", "hello") + result = registry.hydrate_primitive(pdt) + assert result is pdt + + def test_hydrate_adapter_throws_falls_back(self): + registry = ProviderDefinedTypeRegistry() + registry.register_primitive("Bad", lambda v: 1 / 0) + pdt = PrimitiveProviderDefinedType("Bad", "x") + result = registry.hydrate_primitive(pdt) + assert result is pdt + + def test_reader_auto_hydrates_primitive(self): + registry = ProviderDefinedTypeRegistry() + registry.register_primitive("Uint32", lambda v: int(v)) + writer = GraphBinaryWriter() + reader = GraphBinaryReader(pdt_registry=registry) + + pdt = PrimitiveProviderDefinedType("Uint32", "42") + result = reader.read_object(writer.write_object(pdt)) + assert result == 42 + + def test_reader_no_registry_returns_raw(self): + writer = GraphBinaryWriter() + reader = GraphBinaryReader() + + pdt = PrimitiveProviderDefinedType("Uint32", "42") + result = reader.read_object(writer.write_object(pdt)) + assert isinstance(result, PrimitiveProviderDefinedType) + assert result == pdt + + +class TestPrimitiveNestedInComposite(object): + + def test_primitive_nested_in_composite_hydrates(self): + """A PrimitiveProviderDefinedType nested as a field value in a composite PDT is hydrated.""" + registry = ProviderDefinedTypeRegistry() + registry.register_primitive("Uint32", lambda v: int(v)) + registry.register("com.example.Wrapper", lambda fields: {"id": fields["id"], "count": fields["count"]}) + + inner = PrimitiveProviderDefinedType("Uint32", "99") + outer = ProviderDefinedType("com.example.Wrapper", {"id": "abc", "count": inner}) + result = registry.hydrate(outer) + assert result == {"id": "abc", "count": 99} + + def test_primitive_nested_in_unregistered_composite_hydrates(self): + """Primitive nested inside an unregistered composite still hydrates.""" + registry = ProviderDefinedTypeRegistry() + registry.register_primitive("Uint32", lambda v: int(v)) + + inner = PrimitiveProviderDefinedType("Uint32", "7") + outer = ProviderDefinedType("com.example.Unregistered", {"val": inner}) + result = registry.hydrate(outer) + assert isinstance(result, ProviderDefinedType) + assert result.fields["val"] == 7 + + def test_graphbinary_primitive_nested_in_composite(self): + """Round-trip a composite PDT containing a primitive PDT field via GraphBinary.""" + registry = ProviderDefinedTypeRegistry() + registry.register_primitive("Uint32", lambda v: int(v)) + registry.register("com.example.Outer", + lambda fields: {"name": fields["name"], "count": fields["count"]}) + writer = GraphBinaryWriter() + reader = GraphBinaryReader(pdt_registry=registry) + + inner = PrimitiveProviderDefinedType("Uint32", "5") + outer = ProviderDefinedType("com.example.Outer", {"name": "test", "count": inner}) + ba = writer.write_object(outer) + result = reader.read_object(ba) + assert result == {"name": "test", "count": 5} + + +class TestPrimitiveRegistryEntryPoints(object): + + def test_entry_points_can_register_primitives(self): + """Verifies that the entry_points 'tinkerpop.pdt' mechanism works for primitives.""" + from unittest.mock import patch, MagicMock + + def register_primitives(registry): + registry.register_primitive("Uint32", lambda v: int(v)) + + mock_ep = MagicMock() + mock_ep.name = "mock_primitive" + mock_ep.load.return_value = register_primitives + + with patch("importlib.metadata.entry_points") as mock_entry_points: + import sys + if sys.version_info >= (3, 10): + mock_entry_points.return_value = [mock_ep] + else: + mock_entry_points.return_value = {'tinkerpop.pdt': [mock_ep]} + + registry = ProviderDefinedTypeRegistry.create() + pdt = PrimitiveProviderDefinedType("Uint32", "123") + assert registry.hydrate_primitive(pdt) == 123
