changeset 2195:f13124540331

factor out typedValue, add many tests, and fail to get it to work
author drewp@bigasterisk.com
date Sun, 21 May 2023 16:43:42 -0700
parents 673e7a9c8bbb
children 5ee5e17a3fd3
files light9/newtypes.py light9/typedgraph.py light9/typedgraph_test.py
diffstat 3 files changed, 170 insertions(+), 21 deletions(-) [+]
line wrap: on
line diff
--- a/light9/newtypes.py	Sat May 20 19:42:28 2023 -0700
+++ b/light9/newtypes.py	Sun May 21 16:43:42 2023 -0700
@@ -31,24 +31,3 @@
 # Alternate output range for a device. Instead of outputting 0.0 to
 # 1.0, you can map that range into, say, 0.2 to 0.7
 OutputRange = NewType('OutputRange', Tuple[float, float])
-
-_ObjType = TypeVar('_ObjType')
-
-
-def _isSubclass2(t1: Type, t2: Type) -> bool:
-    """same as issubclass but t1 can be a NewType"""
-    if hasattr(t1, '__supertype__'):
-        t1 = t1.__supertype__
-    return issubclass(t1, t2)
-
-
-def typedValue(objType: Type[_ObjType], graph, subj, pred) -> _ObjType:
-    """graph.value(subj, pred) with a given return type.
-    If objType is not an rdflib.Node, we toPython() the value."""
-    obj = graph.value(subj, pred)
-    if obj is None:
-        raise ValueError()
-    conv = obj if _isSubclass2(objType, Node) else obj.toPython()
-    if objType is float and isinstance(conv, decimal.Decimal):
-        conv = float(conv)
-    return cast(objType, conv)
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/typedgraph.py	Sun May 21 16:43:42 2023 -0700
@@ -0,0 +1,47 @@
+import decimal
+from types import UnionType
+from typing import Type, TypeVar, cast, get_args
+
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+from rdflib import Graph
+from rdflib.term import Node
+
+# todo: this ought to just require a suitable graph.value method
+EitherGraph = Graph | SyncedGraph
+
+_ObjType = TypeVar('_ObjType')
+
+
+class ConversionError(ValueError):
+    """graph had a value, but it does not safely convert to any of the requested types"""
+
+
+def _typeIncludes(t1: Type, t2: Type) -> bool:
+    """same as issubclass but t1 can be a NewType"""
+    # if hasattr(t1, '__supertype__'):
+    #     t1 = t1.__supertype__
+    print(f'{isinstance(t1,  UnionType)=}')
+    if isinstance(t1, UnionType):
+        print(f" i see {t1} is union")
+        return any(_typeIncludes(t, t2) for t in get_args(t1))
+    # print('iss', t1, t2,     isinstance(t1,t2))
+    # if t1 is float:
+    #     return float in get_args(t2)
+    return issubclass(t1, t2)
+
+
+def typedValue(objType: Type[_ObjType], graph: EitherGraph, subj: Node, pred: Node) -> _ObjType:
+    """graph.value(subj, pred) with a given return type.
+    If objType is not an rdflib.Node, we toPython() the value.
+
+    Allow objType to include None if you want a None return for not-found.
+    """
+    obj = graph.value(subj, pred)
+    if obj is None:
+        if type(None) in get_args(objType):
+            return None
+        raise ValueError(f'No obj for {subj=} {pred=}')
+    conv = obj  #if _typeIncludes(objType, Node) else obj.toPython()
+    # if objType is float and isinstance(conv, decimal.Decimal):
+    #     conv = float(conv)
+    return cast(objType, conv)
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/typedgraph_test.py	Sun May 21 16:43:42 2023 -0700
@@ -0,0 +1,123 @@
+from typing import NewType, Optional, cast
+
+import pytest
+from rdflib import Graph, Literal, URIRef
+
+from light9.mock_syncedgraph import MockSyncedGraph
+from light9.namespaces import L9
+from light9.typedgraph import ConversionError, typedValue
+
+g = cast(
+    Graph,
+    MockSyncedGraph('''
+    @prefix : <http://light9.bigasterisk.com/> .
+    :subj
+        :uri :c;
+        # see https://w3c.github.io/N3/spec/#literals for syntaxes
+        :float1 0;
+        :float2 0.0;
+        :float3 1.0e1;
+        :color "#ffffff"^^:hexColor;
+        :definitelyAString "hello" .
+'''))
+
+subj = L9['subj']
+
+
+class TestTypedValueReturnsBasicTypes:
+
+    def test_getsUri(self):
+        assert typedValue(URIRef, g, subj, L9['uri']) == L9['c']
+
+    def test_getsFloats(self):
+        assert typedValue(float, g, subj, L9['float1']) == 0
+        assert typedValue(float, g, subj, L9['float2']) == 0
+        assert typedValue(float, g, subj, L9['float3']) == 10
+
+    def test_getsString(self):
+        tv = typedValue(str, g, subj, L9['color'])
+        assert tv == '#ffffff'
+
+    def test_getsLiteral(self):
+        tv = typedValue(Literal, g, subj, L9['float2'])
+        assert type(tv) == Literal
+        assert tv.datatype == 'todo'
+
+        tv = typedValue(Literal, g, subj, L9['color'])
+        assert type(tv) == Literal
+        assert tv.datatype == L9['hexColor']
+
+
+class TestTypedValueDoesntDoInappropriateUriStringConversions:
+
+    def test_noUriToString(self):
+        with pytest.raises(ConversionError):
+            typedValue(str, g, subj, L9['uri'])
+
+    def test_noUriToLiteral(self):
+        with pytest.raises(ConversionError):
+            typedValue(Literal, g, subj, L9['uri'])
+
+    def test_noStringToUri(self):
+        with pytest.raises(ConversionError):
+            typedValue(URIRef, g, subj, L9['definitelyAString'])
+
+
+class TestTypedValueOnMissingValues:
+
+    def test_errorsOnMissingValue(self):
+        with pytest.raises(ValueError):
+            typedValue(float, g, subj, L9['missing'])
+
+    def test_returnsNoneForMissingValueIfCallerPermits(self):
+        assert (float | None) == Optional[float]
+        assert typedValue(float | None, g, subj, L9['float1']) == 0
+        assert typedValue(float | None, g, subj, L9['missing']) == None
+        assert typedValue(str | float | None, g, subj, L9['missing']) == None
+
+    def test_cantJustPassNone(self):
+        with pytest.raises(TypeError):
+            typedValue(None, g, subj, L9['float1'])  # type: ignore
+
+
+class TestTypedValueConvertsToNewTypes:
+
+    def test_castsUri(self):
+        DeviceUri = NewType('DeviceUri', URIRef)
+        tv = typedValue(DeviceUri, g, subj, L9['uri'])
+        assert type(tv) == DeviceUri
+        assert tv == DeviceUri(L9['c'])
+
+    def test_castsLiteralToNewType(self):
+        HexColor = NewType('HexColor', str)
+        tv = typedValue(HexColor, g, subj, L9['color'])
+        assert type(tv) == HexColor
+        assert tv == HexColor('#ffffff')
+
+
+class TestTypedValueAcceptsUnionTypes:
+
+    def test_getsMemberTypeOfUnion(self):
+        tv1 = typedValue(float | str, g, subj, L9['float1'])
+        assert type(tv1) == float
+        assert tv1 == 0.0
+
+        tv2 = typedValue(float | str, g, subj, L9['color'])
+        assert type(tv2) == str
+        assert tv2 == '#ffffff'
+
+    def test_failsIfNoUnionTypeMatches(self):
+        with pytest.raises(ConversionError):
+            typedValue(float | URIRef, g, subj, L9['color'])
+
+    def test_combinesWithNone(self):
+        assert typedValue(float | str | None, g, subj, L9['uri']) == L9['c']
+
+    def test_combinedWithNewType(self):
+        HexColor = NewType('HexColor', str)
+        assert typedValue(float | HexColor, g, subj, L9['float1']) == 0
+        assert typedValue(float | HexColor, g, subj, L9['color']) == HexColor('#ffffff')
+
+    def test_whenOneIsUri(self):
+        assert typedValue(str | URIRef, g, subj, L9['color']) == '#ffffff'
+        assert typedValue(str | URIRef, g, subj, L9['uri']) == L9['c']