diff options
author | Matthias Baumgartner <dev@igsor.net> | 2023-01-21 22:32:33 +0100 |
---|---|---|
committer | Matthias Baumgartner <dev@igsor.net> | 2023-01-21 22:32:33 +0100 |
commit | 9310610a7edf4dcbb934aedcecff1d11348197bb (patch) | |
tree | e7d342d8f25ae40e6bdaf33549f7145bb8697f23 | |
parent | c196d2ce73d8351a18c19bcddd4b06d224e644fc (diff) | |
download | bsfs-9310610a7edf4dcbb934aedcecff1d11348197bb.tar.gz bsfs-9310610a7edf4dcbb934aedcecff1d11348197bb.tar.bz2 bsfs-9310610a7edf4dcbb934aedcecff1d11348197bb.zip |
nodes predicate walk sugar
-rw-r--r-- | bsfs/graph/nodes.py | 15 | ||||
-rw-r--r-- | bsfs/graph/walk.py | 120 | ||||
-rw-r--r-- | bsfs/schema/schema.py | 4 | ||||
-rw-r--r-- | test/graph/test_nodes.py | 14 | ||||
-rw-r--r-- | test/graph/test_walk.py | 173 |
5 files changed, 324 insertions, 2 deletions
diff --git a/bsfs/graph/nodes.py b/bsfs/graph/nodes.py index a4ba45f..18ab30d 100644 --- a/bsfs/graph/nodes.py +++ b/bsfs/graph/nodes.py @@ -19,6 +19,7 @@ from bsfs.utils import errors, URI, typename # inner-module imports from . import ac from . import result +from . import walk # exports __all__: typing.Sequence[str] = ( @@ -87,6 +88,18 @@ class Nodes(): """Return all node guids.""" return iter(self._guids) + @property + def schema(self) -> bsc.Schema: + """Return the store's local schema.""" + return self._backend.schema + + def __getattr__(self, name: str): + try: + return super().__getattr__(name) # type: ignore [misc] # parent has no getattr + except AttributeError: + pass + return walk.Walk(self, walk.Walk.step(self.schema, self.node_type, name)) + def set( self, pred: URI, # FIXME: URI or bsc.Predicate? @@ -141,7 +154,7 @@ class Nodes(): if view not in (dict, list): raise ValueError(f'expected dict or list, found {view}') # process paths: create fetch ast, build name mapping, and find unique paths - schema = self._backend.schema + schema = self.schema statements = set() name2path = {} unique_paths = set() # paths that result in a single (unique) value diff --git a/bsfs/graph/walk.py b/bsfs/graph/walk.py new file mode 100644 index 0000000..63ef5e9 --- /dev/null +++ b/bsfs/graph/walk.py @@ -0,0 +1,120 @@ +""" + +Part of the BlackStar filesystem (bsfs) module. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# imports +from collections import abc +import typing + +# bsfs imports +from bsfs import schema as bsc + +# inner-module imports +# NOTE: circular import! OK as long as only used for type annotations. +from . import nodes # pylint: disable=cyclic-import + +# exports +__all__: typing.Sequence[str] = ( + 'Walk', + ) + + +## code ## + +class Walk(abc.Hashable, abc.Callable): # type: ignore [misc] # invalid base class (Callable) + """Syntactic sugar for `Nodes` to build and act on predicate paths via members.""" + + # Link to Nodes instance. + _root: 'nodes.Nodes' + + # Current predicate path. + _path: typing.Tuple[bsc.Predicate, ...] + + def __init__( + self, + root: 'nodes.Nodes', + path: typing.Sequence[bsc.Predicate], + ): + self._root = root + self._path = tuple(path) + + @property + def tail(self): + """Return the node type at the end of the path.""" + return self._path[-1].range + + + ## comparison + + def __hash__(self) -> int: + """Return an integer hash that identifies the instance.""" + return hash((type(self), self._root, self._path)) + + def __eq__(self, other) -> bool: + """Compare against *other* backend.""" + return isinstance(other, type(self)) \ + and self._root == other._root \ + and self._path == other._path + + + ## representation + + def __repr__(self) -> str: + """Return a formal string representation.""" + path = ', '.join(pred.uri for pred in self._path) + return f'Walk({self._root.node_type.uri}, ({path}))' + + def __str__(self) -> str: + """Return an informal string representation.""" + path = ', '.join(pred.uri for pred in self._path) + return f'Walk(@{self._root.node_type.uri}: {path})' + + + ## walk + + @staticmethod + def step( + schema: bsc.Schema, + node: bsc.Node, + name: str, + ) -> typing.Tuple[bsc.Predicate]: + """Get an predicate at *node* whose fragment matches *name*.""" + predicates = tuple( + pred + for pred + in schema.predicates_at(node) + if pred.uri.get('fragment', None) == name + ) + if len(predicates) == 0: # no fragment found for name + raise ValueError(f'no available predicate matches {name}') + if len(predicates) > 1: # ambiguous name + raise ValueError(f'{name} matches multiple predicates') + # append predicate to walk + return predicates # type: ignore [return-value] # size is one + + def __getattr__(self, name: str) -> 'Walk': + """Alias for `Walk.step(name)`.""" + try: + return super().__getattr__(name) + except AttributeError: + pass + # get predicate + pred = self.step(self._root.schema, self.tail, name) + # append predicate to walk + return Walk(self._root, self._path + pred) + + + ## get paths ## + + def get(self, **kwargs) -> typing.Any: + """Alias for `Nodes.get(..)`.""" + return self._root.get(tuple(pred.uri for pred in self._path), **kwargs) + + def __call__(self, **kwargs) -> typing.Any: # pylint: disable=arguments-differ + """Alias for `Walk.get(...)`.""" + return self.get(**kwargs) + + +## EOF ## diff --git a/bsfs/schema/schema.py b/bsfs/schema/schema.py index 8d9a821..1644926 100644 --- a/bsfs/schema/schema.py +++ b/bsfs/schema/schema.py @@ -312,4 +312,8 @@ class Schema(): """Return the Literal matching the *uri*.""" return self._literals[uri] + def predicates_at(self, node: types.Node) -> typing.Iterator[types.Predicate]: + """Return predicates that have domain *node* (or superclass thereof).""" + return iter(pred for pred in self._predicates.values() if node <= pred.domain) + ## EOF ## diff --git a/test/graph/test_nodes.py b/test/graph/test_nodes.py index a4e07ee..670df69 100644 --- a/test/graph/test_nodes.py +++ b/test/graph/test_nodes.py @@ -10,6 +10,7 @@ import unittest # bsie imports from bsfs import schema as bsc +from bsfs.graph.walk import Walk from bsfs.namespace import Namespace, ns from bsfs.triple_store.sparql import SparqlStore from bsfs.utils import errors, URI @@ -108,7 +109,8 @@ class TestNodes(unittest.TestCase): self.p_filesize = self.backend.schema.predicate(ns.bse.filesize) self.p_author = self.backend.schema.predicate(ns.bse.author) self.p_tag = self.backend.schema.predicate(ns.bse.tag) - self.p_representative = self.backend.schema.predicate(URI('http://bsfs.ai/schema/Tag#representative')) + self.p_representative = self.backend.schema.predicate(bst.representative) + self.p_label = self.backend.schema.predicate(bst.label) self.t_created = self.backend.schema.predicate(ns.bsm.t_created) self.ent_ids = { URI('http://example.com/me/entity#1234'), @@ -458,6 +460,16 @@ class TestNodes(unittest.TestCase): Nodes(self.backend, self.user, self.ent_type, {'http://example.com/me/entity#1234'}): {'hello world'}, }) + def test_getattr(self): + nodes = Nodes(self.backend, self.user, self.ent_type, {'http://example.com/me/entity#1234'}) + # can get walks to values + self.assertEqual(nodes.filesize, Walk(nodes, (self.p_filesize, ))) + # can get walks to nodes + self.assertEqual(nodes.tag, Walk(nodes, (self.p_tag, ))) + # can do multiple hops + self.assertEqual(nodes.tag.label, Walk(nodes, (self.p_tag, self.p_label))) + # invalid step raises an error + self.assertRaises(ValueError, getattr, nodes, 'foobar') ## main ## diff --git a/test/graph/test_walk.py b/test/graph/test_walk.py new file mode 100644 index 0000000..057ac85 --- /dev/null +++ b/test/graph/test_walk.py @@ -0,0 +1,173 @@ +""" + +Part of the bsfs test suite. +A copy of the license is provided with the project. +Author: Matthias Baumgartner, 2022 +""" +# imports +import unittest + +# bsfs imports +from bsfs import schema as bsc +from bsfs.graph import Graph +from bsfs.namespace import Namespace, ns +from bsfs.triple_store.sparql import SparqlStore +from bsfs.utils import URI + +# symbol to test +from bsfs.graph.walk import Walk + +## code ## + +bse = ns.bse +bst = Namespace('http://bsfs.ai/schema/Tag') + +class TestWalk(unittest.TestCase): + def setUp(self): + # backend setup + self.schema = bsc.from_string(''' + prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> + prefix xsd: <http://www.w3.org/2001/XMLSchema#> + prefix bsfs: <http://bsfs.ai/schema/> + prefix bse: <http://bsfs.ai/schema/Entity#> + prefix bst: <http://bsfs.ai/schema/Tag#> + + bsfs:Entity rdfs:subClassOf bsfs:Node . + bsfs:Tag rdfs:subClassOf bsfs:Node . + bsfs:User rdfs:subClassOf bsfs:Node . + xsd:string rdfs:subClassOf bsfs:Literal . + + bse:author rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Node ; + rdfs:range bsfs:User . + + bse:tag rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Entity ; + rdfs:range bsfs:Tag . + + bst:label rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Tag ; + rdfs:range xsd:string ; + bsfs:unique "true"^^xsd:boolean . + + bst:subTagOf rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Tag ; + rdfs:range bsfs:Tag . + + bst:main rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Tag ; + rdfs:range bsfs:Entity . + + bst:author rdfs:subClassOf bsfs:Predicate ; + rdfs:domain bsfs:Tag ; + rdfs:range xsd:string . + + ''') + self.backend = SparqlStore.Open() + self.user = URI('http://example.com/me') + self.graph = Graph(self.backend, self.user) + self.graph.migrate(self.schema) + + # nodes setup + self.ents = self.graph.nodes(ns.bsfs.Entity, { + URI('http://example.com/me/entity#1234'), + URI('http://example.com/me/entity#4321')}) + self.tags = self.graph.nodes(ns.bsfs.Tag, { + URI('http://example.com/me/tag#1234'), + URI('http://example.com/me/tag#4321')}) + # add some instances + self.ents.set(bse.tag, self.tags) + self.graph.node(ns.bsfs.Tag, URI('http://example.com/me/tag#1234')).set(bst.label, 'hello') + self.graph.node(ns.bsfs.Tag, URI('http://example.com/me/tag#4321')).set(bst.label, 'world') + + def test_essentials(self): # __eq__, __hash__, __str__, __repr__ + p_author = self.schema.predicate(bse.author) + p_tag = self.schema.predicate(bse.tag) + p_main = self.schema.predicate(bst.main) + # comparison + self.assertEqual(Walk(self.ents, [p_tag]), Walk(self.ents, [p_tag])) + self.assertEqual(hash(Walk(self.ents, [p_tag])), hash(Walk(self.ents, [p_tag]))) + # comparison respects type + class Foo(Walk): pass + self.assertNotEqual(Walk(self.ents, [p_tag]), Foo(self.ents, [p_tag])) + self.assertNotEqual(hash(Walk(self.ents, [p_tag])), hash(Foo(self.ents, [p_tag]))) + # comparison respects root + self.assertNotEqual(Walk(self.ents, [p_author]), Walk(self.tags, [p_author])) + self.assertNotEqual(hash(Walk(self.ents, [p_author])), hash(Walk(self.tags, [p_author]))) + # comparison respects path + self.assertNotEqual(Walk(self.tags, [p_author]), Walk(self.tags, [p_main])) + self.assertNotEqual(hash(Walk(self.tags, [p_author])), hash(Walk(self.tags, [p_main]))) + # string conversion + self.assertEqual(str(Walk(self.ents, [p_tag, p_main])), + 'Walk(@http://bsfs.ai/schema/Entity: http://bsfs.ai/schema/Entity#tag, http://bsfs.ai/schema/Tag#main)') + self.assertEqual(repr(Walk(self.ents, [p_tag, p_main])), + 'Walk(http://bsfs.ai/schema/Entity, (http://bsfs.ai/schema/Entity#tag, http://bsfs.ai/schema/Tag#main))') + + def test_tail(self): + self.assertEqual(Walk(self.ents, ( + self.schema.predicate(bse.tag), + )).tail, + self.schema.node(ns.bsfs.Tag)) + self.assertEqual(Walk(self.ents, ( + self.schema.predicate(bse.tag), + self.schema.predicate(bst.main), + )).tail, + self.schema.node(ns.bsfs.Entity)) + + def test_step(self): + tag_type = self.schema.node(ns.bsfs.Tag) + # step returns a predicate + self.assertEqual(Walk.step(self.schema, tag_type, 'subTagOf'), + (self.schema.predicate(bst.subTagOf), )) + # invalid step raises an error + self.assertRaises(ValueError, Walk.step, self.schema, tag_type, 'foobar') + # ambiguous step raises an error + self.assertRaises(ValueError, Walk.step, self.schema, tag_type, 'author') + + def test_getattr(self): # __getattr__ + walk = Walk(self.ents, (self.schema.predicate(bse.tag), )) + # first step + self.assertEqual(walk.subTagOf, Walk(self.ents, ( + self.schema.predicate(bse.tag), + self.schema.predicate(bst.subTagOf), + ))) + # second step + self.assertEqual(walk.subTagOf.main, Walk(self.ents, ( + self.schema.predicate(bse.tag), + self.schema.predicate(bst.subTagOf), + self.schema.predicate(bst.main), + ))) + # invalid step raises an error + self.assertRaises(ValueError, getattr, walk, 'foobar') + # ambiguous step raises an error + self.assertRaises(ValueError, getattr, walk, 'author') + + def test_get(self): # get, __call__ + walk = Walk(self.ents, (self.schema.predicate(bse.tag), )) + tags = { + self.graph.node(ns.bsfs.Tag, URI('http://example.com/me/tag#1234')), + self.graph.node(ns.bsfs.Tag, URI('http://example.com/me/tag#4321'))} + # get returns from Nodes.get + self.assertDictEqual(walk.get(), { + self.graph.node(ns.bsfs.Entity, URI('http://example.com/me/entity#1234')): tags, + self.graph.node(ns.bsfs.Entity, URI('http://example.com/me/entity#4321')): tags, + }) + self.assertDictEqual(walk(), { + self.graph.node(ns.bsfs.Entity, URI('http://example.com/me/entity#1234')): tags, + self.graph.node(ns.bsfs.Entity, URI('http://example.com/me/entity#4321')): tags, + }) + # get passes kwargs to Nodes.get + self.assertSetEqual(tags, walk.get(node=True)) + self.assertSetEqual(tags, walk(node=True)) + self.assertSetEqual(tags, set(walk.get(view=list, node=True))) + self.assertSetEqual(tags, set(walk(view=list, node=True))) + # get returns values if need be + self.assertSetEqual(walk.label(node=True), {'hello', 'world'}) + + +## main ## + +if __name__ == '__main__': + unittest.main() + +## EOF ## |