# HG changeset patch # User drewp@bigasterisk.com # Date 2023-05-21 23:43:42 # Node ID f13124540331284385de82d4b1eda4b9b953f587 # Parent 673e7a9c8bbbbbe52a52bce8583e22bb2e17a141 factor out typedValue, add many tests, and fail to get it to work diff --git a/light9/newtypes.py b/light9/newtypes.py --- a/light9/newtypes.py +++ b/light9/newtypes.py @@ -31,24 +31,3 @@ DeviceSetting = Tuple[DeviceUri, DeviceA # 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 diff --git a/light9/typedgraph.py b/light9/typedgraph.py new file mode 100644 --- /dev/null +++ b/light9/typedgraph.py @@ -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 diff --git a/light9/typedgraph_test.py b/light9/typedgraph_test.py new file mode 100644 --- /dev/null +++ b/light9/typedgraph_test.py @@ -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 : . + :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']