diff --git a/docs/tutorial.md b/docs/tutorial.md index 1217166a..e0f81fd2 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -186,6 +186,65 @@ True ``` +### Class restrictions +When working with OWL ontologies, it is often required to inspect or add class [restriction]s. +The Triplestore class has two convenient methods for this, that do not require knowledge about how restrictions are represented in RDF. +Only support basic restrictions, without any nested logical constructs, are supoprted. +For more advanced restrictions, we recommend to use [EMMOntoPy] or [Owlready2]. + +A [restriction] is described by the following set of parameters. + + * **cls**: IRI of class to which the restriction applies. + * **property**: IRI of restriction property. + * **type**: The type of the restriction. Should be one of: + - *some*: existential restriction (target is a class IRI) + - *only*: universal restriction (target is a class IRI) + - *exactly*: cardinality restriction (target is a class IRI) + - *min*: minimum cardinality restriction (target is a class IRI) + - *max*: maximum cardinality restriction (target is a class IRI) + - *value*: Value restriction (target is an IRI of an individual or a literal) + + * **cardinality**: the cardinality value for cardinality restrictions. + * **value**: The IRI or literal value of the restriction target. + +As an example, the class `onto:Bacteria` can be logically restricted to be unicellular. +In Manchester syntax, this can be stated as `onto:Bacteria emmo:hasPart exactly 1 onto:Cell`. +With Tripper this can be stated as: + +```python +>>> iri = ts.add_restriction( +... cls=ONTO.Bacteria, +... property=EMMO.hasPart, +... type="exactly", +... cardinality=1, +... value=ONTO.Cell, +... ) + +``` +The returned `iri` is the blank node IRI of the new restriction. + + +To find the above restriction, the `restrictions()` method can be used. +It returns an iterator over all restrictions that matches the provided criteria. +For example: + +```python +>>> g = ts.restrictions(cls=ONTO.Bacteria, property=EMMO.hasPart, asdict=True) +>>> list(g) # doctest: +ELLIPSIS +[{'iri': '_:...', 'cls': 'http://example.com/onto#Bacteria', 'property': 'https://w3id.org/emmo#EMMO_17e27c22_37e1_468c_9dd7_95e137f73e7f', 'type': 'exactly', 'cardinality': 1, 'value': 'http://example.com/onto#Cell'}] + +``` + +With the `return_dicts` argument set to false, an iterator over the IRIs of all matching restrictions is returned: + +```python +>>> g = ts.restrictions(cls=ONTO.Bacteria, property=EMMO.hasPart, asdict=False) +>>> next(g) == iri +True + +``` + + ### Utilities *Todo: Describe the `tripper.utils` module* @@ -272,3 +331,6 @@ https://emmc-asbl.github.io/tripper/latest/api_reference/literal/#tripper.litera [EMMO]: https://emmc.eu/emmo/ [Function Ontology (FnO)]: https://fno.io/ [list of currently supported backends]: https://github.com/EMMC-ASBL/tripper?tab=readme-ov-file#available-backends +[EMMOntoPy]: https://emmo-repo.github.io/EMMOntoPy/ +[Owlready2]: https://pypi.org/project/owlready2/ +[restriction]: https://www.w3.org/TR/owl-ref/#Restriction diff --git a/tests/test_triplestore.py b/tests/test_triplestore.py index 5a51c88f..bd006111 100644 --- a/tests/test_triplestore.py +++ b/tests/test_triplestore.py @@ -95,7 +95,7 @@ def test_triplestore( # pylint: disable=too-many-locals # if True: -def test_restriction() -> None: +def test_restriction() -> None: # pylint: disable=too-many-statements """Test add_restriction() method.""" pytest.importorskip("rdflib") @@ -109,7 +109,7 @@ def test_restriction() -> None: iri = ts.add_restriction( cls=EX.Animal, property=EX.hasPart, - target=EX.Cell, + value=EX.Cell, type="some", ) txt = ts.serialize(format="ntriples") @@ -122,7 +122,7 @@ def test_restriction() -> None: iri2 = ts.add_restriction( cls=EX.Kerberos, property=EX.hasBodyPart, - target=EX.Head, + value=EX.Head, type="exactly", cardinality=3, ) @@ -140,7 +140,7 @@ def test_restriction() -> None: iri3 = ts.add_restriction( cls=EX.Kerberos, property=EX.position, - target=Literal("The port of Hades"), + value=Literal("The port of Hades"), type="value", ) txt3 = ts.serialize(format="ntriples") @@ -155,41 +155,97 @@ def test_restriction() -> None: ts.add_restriction( cls=EX.Kerberos, property=EX.hasBodyPart, - target=EX.Head, + value=EX.Head, type="wrong_type", ) with pytest.raises(ArgumentTypeError): ts.add_restriction( cls=EX.Kerberos, property=EX.hasBodyPart, - target=EX.Head, + value=EX.Head, type="min", ) # Test find restriction - assert set(ts.restrictions()) == {iri, iri2, iri3} - assert set(ts.restrictions(cls=EX.Kerberos)) == {iri2, iri3} - assert set(ts.restrictions(cls=EX.Animal)) == {iri} - assert not set(ts.restrictions(cls=EX.Dog)) - assert set(ts.restrictions(cls=EX.Kerberos, cardinality=3)) == {iri2} - assert set(ts.restrictions(cardinality=3)) == {iri2} - assert not set(ts.restrictions(cls=EX.Kerberos, cardinality=2)) - assert set(ts.restrictions(property=EX.hasBodyPart)) == {iri2} - assert set(ts.restrictions(property=EX.hasPart)) == {iri} - assert not set(ts.restrictions(property=EX.hasNoPart)) - assert set(ts.restrictions(target=EX.Cell)) == {iri} - assert set(ts.restrictions(target=EX.Head)) == {iri2} - assert not set(ts.restrictions(target=EX.Leg)) - assert set(ts.restrictions(target=EX.Cell, type="some")) == {iri} - assert set(ts.restrictions(target=EX.Head, type="exactly")) == {iri2} - assert set(ts.restrictions(target=Literal("The port of Hades"))) == {iri3} + assert set(ts.restrictions(asdict=False)) == {iri, iri2, iri3} + assert set(ts.restrictions(cls=EX.Kerberos, asdict=False)) == {iri2, iri3} + assert set(ts.restrictions(cls=EX.Animal, asdict=False)) == {iri} + assert not set(ts.restrictions(cls=EX.Dog, asdict=False)) + assert set( + ts.restrictions(cls=EX.Kerberos, cardinality=3, asdict=False) + ) == {iri2} + assert set(ts.restrictions(cardinality=3, asdict=False)) == {iri2} + assert not set( + ts.restrictions(cls=EX.Kerberos, cardinality=2, asdict=False) + ) + assert set(ts.restrictions(property=EX.hasBodyPart, asdict=False)) == { + iri2 + } + assert set(ts.restrictions(property=EX.hasPart, asdict=False)) == {iri} + assert not set(ts.restrictions(property=EX.hasNoPart, asdict=False)) + assert set(ts.restrictions(value=EX.Cell, asdict=False)) == {iri} + assert set(ts.restrictions(value=EX.Head, asdict=False)) == {iri2} + assert not set(ts.restrictions(value=EX.Leg, asdict=False)) + assert set(ts.restrictions(value=EX.Cell, type="some", asdict=False)) == { + iri + } + assert set( + ts.restrictions(value=EX.Head, type="exactly", asdict=False) + ) == {iri2} + assert set( + ts.restrictions(value=Literal("The port of Hades"), asdict=False) + ) == {iri3} with pytest.raises(ArgumentValueError): - set(ts.restrictions(target=EX.Cell, type="wrong_type")) + set(ts.restrictions(value=EX.Cell, type="wrong_type")) with pytest.raises(ArgumentValueError): set(ts.restrictions(type="value", cardinality=2)) + # Test returning as dicts (asdict=True) + dicts = sorted(ts.restrictions(asdict=True), key=lambda d: d["value"]) + for d in dicts: # Remove iri keys, since they refer to blank nodes + d.pop("iri") + assert dicts == sorted( + [ + { + "cls": "http://example.com/onto#Animal", + "property": "http://example.com/onto#hasPart", + "type": "some", + "value": "http://example.com/onto#Cell", + "cardinality": None, + }, + { + "cls": "http://example.com/onto#Kerberos", + "property": "http://example.com/onto#hasBodyPart", + "type": "exactly", + "value": "http://example.com/onto#Head", + "cardinality": 3, + }, + { + "cls": "http://example.com/onto#Kerberos", + "property": "http://example.com/onto#position", + "type": "value", + "value": Literal("The port of Hades", datatype=XSD.string), + "cardinality": None, + }, + ], + key=lambda d: d["value"], + ) + + dicts = list(ts.restrictions(type="some", asdict=True)) + for d in dicts: # Remove iri keys, since they refer to blank nodes + d.pop("iri") + assert dicts == [ + { + "cls": "http://example.com/onto#Animal", + "property": "http://example.com/onto#hasPart", + "type": "some", + "value": "http://example.com/onto#Cell", + "cardinality": None, + }, + ] + def test_backend_rdflib(expected_function_triplestore: str) -> None: """Specifically test the rdflib backend Triplestore. diff --git a/tripper/triplestore.py b/tripper/triplestore.py index c7907051..e5180088 100644 --- a/tripper/triplestore.py +++ b/tripper/triplestore.py @@ -729,13 +729,11 @@ def prefix_iri(self, iri: str, require_prefixed: bool = False): "value": (OWL.hasValue, None), } - # xtype: "TypeLiteral['some', 'only', 'exactly', 'min', 'max', 'value']", - def add_restriction( # pylint: disable=redefined-builtin self, cls: str, property: str, - target: "Union[str, Literal]", + value: "Union[str, Literal]", type: "RestrictionType", cardinality: "Optional[int]" = None, hashlength: int = 16, @@ -745,14 +743,14 @@ def add_restriction( # pylint: disable=redefined-builtin Parameters: cls: IRI of class to which the restriction applies. property: IRI of restriction property. - target: The IRI or literal value of the restriction target. + value: The IRI or literal value of the restriction target. type: The type of the restriction. Should be one of: - - some: existential restriction (target is a class IRI) - - only: universal restriction (target is a class IRI) - - exactly: cardinality restriction (target is a class IRI) - - min: minimum cardinality restriction (target is a class IRI) - - max: maximum cardinality restriction (target is a class IRI) - - value: Value restriction (target is an IRI of an individual + - some: existential restriction (value is a class IRI) + - only: universal restriction (value is a class IRI) + - exactly: cardinality restriction (value is a class IRI) + - min: minimum cardinality restriction (value is a class IRI) + - max: maximum cardinality restriction (value is a class IRI) + - value: Value restriction (value is an IRI of an individual or a literal) cardinality: the cardinality value for cardinality restrictions. @@ -763,7 +761,7 @@ def add_restriction( # pylint: disable=redefined-builtin """ iri = bnode_iri( prefix="restriction", - source=f"{cls} {property} {target} {type} {cardinality}", + source=f"{cls} {property} {value} {type} {cardinality}", length=hashlength, ) triples = [ @@ -777,7 +775,7 @@ def add_restriction( # pylint: disable=redefined-builtin '"max" or "value"' ) pred, card = self._restriction_types[type] - triples.append((iri, pred, target)) + triples.append((iri, pred, value)) if card: if not cardinality: raise ArgumentTypeError( @@ -794,18 +792,14 @@ def add_restriction( # pylint: disable=redefined-builtin self.add_triples(triples) return iri - # xtype: ( - # "Optional[TypeLiteral['some', 'only', 'exactly', 'min', " - # "'max', 'value']]" - # ) = None, - def restrictions( # pylint: disable=redefined-builtin self, cls: "Optional[str]" = None, property: "Optional[str]" = None, - target: "Optional[Union[str, Literal]]" = None, + value: "Optional[Union[str, Literal]]" = None, type: "Optional[RestrictionType]" = None, cardinality: "Optional[int]" = None, + asdict: bool = True, ) -> "Generator[Triple, None, None]": # pylint: disable=too-many-boolean-expressions """Returns a generator over matching restrictions. @@ -813,17 +807,24 @@ def restrictions( # pylint: disable=redefined-builtin Parameters: cls: IRI of class to which the restriction applies. property: IRI of restriction property. - target: The IRI or literal value of the restriction target. + value: The IRI or literal value of the restriction target. type: The type of the restriction. Should be one of: - - some: existential restriction (target is a class IRI) - - only: universal restriction (target is a class IRI) - - exactly: cardinality restriction (target is a class IRI) - - min: minimum cardinality restriction (target is a class IRI) - - max: maximum cardinality restriction (target is a class IRI) - - value: Value restriction (target is an IRI of an individual + - some: existential restriction (value is a class IRI) + - only: universal restriction (value is a class IRI) + - exactly: cardinality restriction (value is a class IRI) + - min: minimum cardinality restriction (value is a class IRI) + - max: maximum cardinality restriction (value is a class IRI) + - value: Value restriction (value is an IRI of an individual or a literal) cardinality: the cardinality value for cardinality restrictions. + asdict: Whether to returned generator is over dicts (see + _get_restriction_dict()). Default is to return a generator + over blank node IRIs. + + Returns: + A generator over matching restrictions. See `asdict` argument + for types iterated over. """ if type is None: types = set(self._restriction_types.keys()) @@ -835,16 +836,16 @@ def restrictions( # pylint: disable=redefined-builtin else: types = {type} if isinstance(type, str) else set(type) - if isinstance(target, Literal): + if isinstance(value, Literal): types.intersection_update({"value"}) - elif isinstance(target, str): + elif isinstance(value, str): types.difference_update({"value"}) if cardinality: types.intersection_update({"exactly", "min", "max"}) if not types: raise ArgumentValueError( - f"Inconsistent type='{type}', target='{target}' and " + f"Inconsistent type='{type}', value='{value}' and " f"cardinality='{cardinality}' arguments" ) pred = {self._restriction_types[t][0] for t in types} @@ -860,15 +861,48 @@ def restrictions( # pylint: disable=redefined-builtin for iri in self.subjects(predicate=OWL.onProperty, object=property): if ( self.has(iri, RDF.type, OWL.Restriction) - and self.has(cls, RDFS.subClassOf, iri) - and any(self.has(iri, p, target) for p in pred) + and (not cls or self.has(cls, RDFS.subClassOf, iri)) + and any(self.has(iri, p, value) for p in pred) and ( not card or not cardinality or any(self.has(iri, c, lcard) for c in card) ) ): - yield iri + yield self._get_restriction_dict(iri) if asdict else iri + + def _get_restriction_dict(self, iri): + """Return a dict describing restriction with `iri`. + + The returned dict has the following keys: + - iri: (str) IRI of the restriction itself (blank node). + - cls: (str) IRI of class to which the restriction applies. + - property: (str) IRI of restriction property + - type: (str) One of: "some", "only", "exactly", "min", "max", "value". + - cardinality: (int) Restriction cardinality (optional). + - value: (str|Literal) IRI or literal value of the restriction target. + """ + dct = dict(self.predicate_objects(iri)) + if OWL.onClass in dct: + ((t, p, c),) = [ + (t, p, c) + for t, (p, c) in self._restriction_types.items() + if c in dct + ] + else: + ((t, p, c),) = [ + (t, p, c) + for t, (p, c) in self._restriction_types.items() + if p in dct + ] + return { + "iri": iri, + "cls": self.value(predicate=RDFS.subClassOf, object=iri), + "property": dct[OWL.onProperty], + "type": t, + "cardinality": int(dct[c]) if c else None, + "value": dct[p], + } def map( self,