diff --git a/README.md b/README.md index 2a4aaeb..b42b5d0 100644 --- a/README.md +++ b/README.md @@ -269,9 +269,7 @@ RDF import functionality is available after registering `lodkit.RDFImporter` wit ## URI Tools -### uritools.utils - -## URIConstructor +### URIConstructor The `URIConstructor` class provides namespaced URI constructor functionality. @@ -292,47 +290,41 @@ make_uri("test") == make_uri("test") # True ## Namespace Tools -### NamespaceGraph -`lodkit.NamespaceGraph` is a simple rdflib.Graph subclass for easy and convenient namespace binding. +### ClosedOntologyNamespace -```python -from lodkit import NamespaceGraph -from rdflib import Namespace +`ClosedOntologyNamespace` is an `rdflib.namespace.ClosedNamespace`-inspired utility class that constructs an immutable (*closed*) mapping of RDF term names to IRIs based on an Ontology or generally an RDF graph source. -class CLSGraph(NamespaceGraph): - crm = Namespace("http://www.cidoc-crm.org/cidoc-crm/") - crmcls = Namespace("https://clscor.io/ontologies/CRMcls/") - clscore = Namespace("https://clscor.io/entity/") +Given a `lodkit.types.GraphParseSource` or an `rdflib.Graph`, a `MappingProxyType[str, rdflib.URIRef]` mapping is created and stored in `ClosedOntologyNamespace.mapping` by -graph = CLSGraph() +1. Querying the RDF source for RDF class and property definitions (RDF/RDFS/OWL class/property type assertions and OWL named individual assertions) -ns_check: bool = all( - ns in map(lambda x: x[0], graph.namespaces()) - for ns in ("crm", "crmcls", "clscore") -) +2. Deriving RDF term names by extracting the last IRI component delimited by `#`, `/` or `:` for generating the RDF term name -> IRI mapping. -print(ns_check) # True -``` -## ClosedOntologyNamespace, DefinedOntologyNamespace -`lodkit.ClosedOntologyNamespace` and `lodkit.DefinedOntologyNamespace` are `rdflib.ClosedNamespace` and `rdflib.DefinedNameSpace` subclasses -that are able to load namespace members based on an ontology. +Namespace members are accessible as both attributes and items of a given `ClosedOntologyNamespace` instance, i.e. attribute and item access is routed to `ClosedOntologyNamespace.mapping`. +For dictionary operations over the namespace mapping, the public `ClosedOntologyNamespace.mapping` can be accessed directly. -```python -crm = ClosedOntologyNamespace(ontology="./CIDOC_CRM_v7.1.3.ttl") -crm.E39_Actor # URIRef('http://www.cidoc-crm.org/cidoc-crm/E39_Actor') -crm.E39_Author # AttributeError -``` +The following example loads a remote Ontology and accesses namespace members using attribute and item lookup. ```python -class crm(DefinedOntologyNamespace): - ontology = "./CIDOC_CRM_v7.1.3.ttl" +from lodkit import ClosedOntologyNamespace + +crm = ClosedOntologyNamespace( + source="https://cidoc-crm.org/rdfs/7.1.3/CIDOC_CRM_v7.1.3.rdf" +) -crm.E39_Actor # URIRef('http://www.cidoc-crm.org/cidoc-crm/E39_Actor') -crm.E39_Author # URIRef('http://www.cidoc-crm.org/cidoc-crm/E39_Author') + UserWarning +crm.E92_Spacetime_Volume # URIRef('http://www.cidoc-crm.org/cidoc-crm/E92_Spacetime_Volume') +crm["E52_Time-Span"] # URIRef('http://www.cidoc-crm.org/cidoc-crm/E52_Time-Span') + +crm.E21_Author # AttributeError +crm["E21-Person"] # AttributeError ``` +> Note that lookup failure for both attribute and item access on `ClosedOntologyNamespace` objects raises an `AttributeError`! + +In the case of RDF term names conflicting with class namespace names, the class namespace names take precedence for attribute access; conflicting RDF terms are still accessible via item lookup or through the `ClosedOntologyNamespace.mapping` proxy. -Note that `rdflib.ClosedNamespaces` are meant to be instantiated and `rdflib.DefinedNameSpaces` are meant to be extended, -which is reflected in `lodkit.ClosedOntologyNamespace` and `lodkit.DefinedOntologyNamespace`. +> Note that *currently* `ClosedOntologyNamespace` is a highly dynamic runtime construct and does not support static analysis and IDE completion for namespace entries. + + diff --git a/lodkit/__init__.py b/lodkit/__init__.py index a98fd30..8f6d6e7 100644 --- a/lodkit/__init__.py +++ b/lodkit/__init__.py @@ -1,9 +1,9 @@ """Entry point for LODkit.""" -from lodkit.namespace_tools.namespace_graph import NamespaceGraph -from lodkit.namespace_tools.ontology_namespaces import ( +from lodkit.namespace_tools.ontology_namespace import ( ClosedOntologyNamespace, - DefinedOntologyNamespace, + EmptySolutionException, + NoSolutionException, ) from lodkit.rdf_importer import RDFImporter, enable_rdf_import from lodkit.triple_tools.triple_chain import TripleChain diff --git a/lodkit/namespace_tools/__init__.py b/lodkit/namespace_tools/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/lodkit/namespace_tools/_exceptions.py b/lodkit/namespace_tools/_exceptions.py deleted file mode 100644 index 906de42..0000000 --- a/lodkit/namespace_tools/_exceptions.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Exceptions for RDF Namespace functionality.""" - - -class OntologyNamespaceException(Exception): - """Base exception for indicating errors during OntologyNamespace construction.""" - - -class NamespaceDelimiterException(OntologyNamespaceException): - """Exception indicating that determining a delimiter failed.""" - - -class MultiOntologyHeadersException(OntologyNamespaceException): - """Exception indicating that more than one ontology header is defined in the ontology.""" - - -class NoOntologyHeaderException(OntologyNamespaceException): - """Exception indicating that no ontology header is defined in the ontology.""" - - -class MissingOntologyClassAttributeException(Exception): - """Exception indicating that a class-level attribute 'ontology' is required but missing.""" diff --git a/lodkit/namespace_tools/_messages.py b/lodkit/namespace_tools/_messages.py deleted file mode 100644 index 227f1c6..0000000 --- a/lodkit/namespace_tools/_messages.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Messages and message constructor functions for RDF Namespace exceptions/warnings.""" - -_namespace_delimiter_exception_message = ( - "Unable to determine namespace delimiter.\n" - "The namespace in the ontology header does not specify a common URI entity delimiter (# or /) " - "and attempting to retrieve a delimiter by matching declared namespaces failed.\n" -) - -_no_ontology_header_message = ( - "Unable to detect ontology namespace. " - "An ontology namespace should be defined in the ontology header.\n" - "See https://www.w3.org/TR/owl-ref/#Ontology-def." -) - -_missing_ontology_attribute_message = ( - "ClosedOntologyNamespace subclasses expect an 'ontology' class attribute.\n" - "If dynamic namespace generation from an ontology is not needed, " - "use rdflib.ClosedNamespace or rdflib.DefinedNamespace instead." -) - - -def _namespace_delimiter_warning_message(namespace: str) -> str: - """Warning message constructor for signalling a missing entity delimiter.""" - _message = ( - f"The derived Ontology namespace '{namespace}' " - "does not feature a common URI entity delimiter (#, /)." - ) - return _message - - -def _multi_header_message(namespace_assertions: list[str]) -> str: - """Error message constructor for MultiOntologyHeadersExceptions.""" - _message = ( - "Only a single ontology namespace permissible.\n" - f"Found {', '.join(namespace_assertions)}" - ) - return _message diff --git a/lodkit/namespace_tools/namespace_graph.py b/lodkit/namespace_tools/namespace_graph.py deleted file mode 100644 index 98fa36d..0000000 --- a/lodkit/namespace_tools/namespace_graph.py +++ /dev/null @@ -1,40 +0,0 @@ -"""NamespaceGraph: rdflib.Graph extension for convenient namespace binding.""" - -from rdflib import Graph, Namespace - - -class NamespaceGraph(Graph): - """Simple rdflib.Graph subclass for easy and convenient namespace binding. - - Public class-level attributes of NamespaceGraph subclasses are interpreted as namespaces - and automatically bound for instances of that graph class. - - Example: - - class CLSGraph(NamespaceGraph): - crm = Namespace("http://www.cidoc-crm.org/cidoc-crm/") - crmcls = Namespace("https://clscor.io/ontologies/CRMcls/") - clscore = Namespace("https://clscor.io/entity/") - - graph = CLSGraph() - - ns_check: bool = all( - ns in map(lambda x: x[0], graph.namespaces()) - for ns in ("crm", "crmcls", "clscore") - ) - - print(ns_check) # True - """ - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - for name, namespace in self._bindings.items(): - self.bind(name, namespace) - - def __init_subclass__(cls) -> None: - cls._bindings = { - name: Namespace(namespace) - for name, namespace in cls.__dict__.items() - if not name.startswith("_") - } diff --git a/lodkit/namespace_tools/ontology_namespace.py b/lodkit/namespace_tools/ontology_namespace.py new file mode 100644 index 0000000..f97e707 --- /dev/null +++ b/lodkit/namespace_tools/ontology_namespace.py @@ -0,0 +1,106 @@ +from types import MappingProxyType + +from lodkit.types import GraphParseSource +from rdflib import Graph, URIRef +from rdflib.query import Result + + +class NoSolutionException(Exception): ... + + +class EmptySolutionException(Exception): ... # pragma: no cover + + +class ClosedOntologyNamespace: + """Ontology-based Namespace constructor. + + ClosedOntologyNamespace allows constructing a namespace + based on an Ontology or generally an RDF graph source. + + Given a lodkit.types.GraphParseSource or an rdflib.Graph, + the source is queried for RDF class and property definition assertions. + RDF term names are extracted by splitting the last IRI segment delimited by + '#', '/' or ':' and matching name/IRI pairs are registered in the namespace mapping. + + Namespace members are accessible as both attributes and items of a given + `ClosedOntologyNamespace` instance, i.e. attribute and item access is routed + to `ClosedOntologyNamespace.mapping`. For dictionary operations over the namespace mapping, + the public `ClosedOntologyNamespace.mapping` can be accessed directly. + + In the case of RDF term names conflicting with class namespace names, + the class namespace names take precedence for attribute access; + conflicting RDF terms are still accessible via item lookup + or through the `ClosedOntologyNamespace.mapping` proxy. + + """ + + _query = """ + prefix rdf: + prefix rdfs: + prefix owl: + + select distinct ?name ?uri + where { + values ?type { + rdfs:Class + owl:Class + + rdf:Property + owl:ObjectProperty + owl:DatatypeProperty + owl:AnnotationProperty + + owl:NamedIndividual + } + + ?uri a ?type . + filter (isIRI(?uri)) + + bind (replace(str(?uri), "^.*[#/:]", "") AS ?name) + filter (?name != "") + } + """ + + def __init__(self, source: GraphParseSource | Graph, *parse_args, **parse_kwargs): + self.source = source + + graph: Graph = ( + self.source + if isinstance(self.source, Graph) + else Graph().parse(source=self.source, *parse_args, **parse_kwargs) + ) + sparql_result: Result = graph.query(self._query) + + self.mapping: MappingProxyType[str, URIRef] = self._get_uris( + sparql_result=sparql_result + ) + + def __repr__(self) -> str: # pragma: no cover + return f"<{self.__class__.__name__} source={self.source!r}>" + + def __getattr__(self, value): + return self[value] + + def __getitem__(self, key: str) -> URIRef: + try: + return self.mapping[key] + except KeyError: + raise AttributeError( + f"'{self.__class__.__name__}' object has no attribute '{key}'." + ) + + def _get_uris(self, sparql_result: Result) -> MappingProxyType[str, URIRef]: + _bindings = sparql_result.bindings + + match _bindings: + case []: + raise NoSolutionException() + case [{**items}] if not items: # pragma: no cover (unreachable) + raise EmptySolutionException() + case _: + return MappingProxyType( + { + str(binding["name"]): binding["uri"] # type: ignore + for binding in _bindings + } + ) diff --git a/lodkit/namespace_tools/ontology_namespaces.py b/lodkit/namespace_tools/ontology_namespaces.py deleted file mode 100644 index 8634624..0000000 --- a/lodkit/namespace_tools/ontology_namespaces.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Functionality for Ontology Derived Dynamic (ODD) namespaces.""" - -from lodkit.namespace_tools._exceptions import MissingOntologyClassAttributeException -from lodkit.namespace_tools._messages import _missing_ontology_attribute_message -from lodkit.namespace_tools.utils import ( - _TGraphParseSource, - _get_namespace_from_ontology, - _get_ontology_graph, - _get_terms_from_ontology, -) -from rdflib import Graph, Namespace, URIRef -from rdflib.namespace import ClosedNamespace, DefinedNamespace - - -class ClosedOntologyNamespace(ClosedNamespace): - """Ontology-derived ClosedNamespace. - - Namespace members are determined by parsing an Ontology for entities. - Trying to access an undefined member results in an AttributeError. - - rdflib.ClosedNamespaces are meant to be instantiated, - so this extension is implemented to take an ontology argument upon instantiation. - See https://rdflib.readthedocs.io/en/stable/apidocs/rdflib.namespace.html#rdflib.namespace.ClosedNamespace. - - Example: - - crm = ClosedOntologyNamespace(ontology="./CIDOC_CRM_v7.1.3.ttl") - crm.E39_Actor # URIRef('http://www.cidoc-crm.org/cidoc-crm/E39_Actor') - crm.E39_Author # AttributeError - """ - - def __new__(cls, ontology: _TGraphParseSource, strict_delimiters: bool = True): - _ontology: Graph = _get_ontology_graph(ontology) - _namespace: Namespace = _get_namespace_from_ontology( - _ontology, strict_delimiters - ) - _terms: list[str] = _get_terms_from_ontology(_ontology, _namespace) - - return super().__new__(cls, uri=_namespace, terms=_terms) - - -class DefinedOntologyNamespace(DefinedNamespace): - """Ontology-derived DefinedNamespace. - - Namespace members are determined by parsing an Ontology for entities. - Trying to access an undefined member emits a UserWarning. - - rdflib.DefinedNameSpace is meant to be extended, - parameters for namespace generation are set as class-level attributes in the subclass; - so this extension is implemented to expect an 'ontology' class attribute. - See https://rdflib.readthedocs.io/en/stable/apidocs/rdflib.namespace.html#rdflib.namespace.DefinedNamespace. - - Example: - - class crm(DefinedOntologyNamespace): - ontology = "./CIDOC_CRM_v7.1.3.ttl" - - crm.E39_Actor # URIRef('http://www.cidoc-crm.org/cidoc-crm/E39_Actor') - crm.E39_Author # URIRef('http://www.cidoc-crm.org/cidoc-crm/E39_Author') + UserWarning - """ - - def __init_subclass__(cls) -> None: - ontology: _TGraphParseSource = cls._get_ontology_attribute() - - _ontology: Graph = _get_ontology_graph(ontology) - _namespace: Namespace = _get_namespace_from_ontology(_ontology) - _terms: list[str] = _get_terms_from_ontology(_ontology, _namespace) - - cls._NS = _namespace - cls.__annotations__ = cls.__annotations__ | {term: URIRef for term in _terms} - - @classmethod - def _get_ontology_attribute(cls) -> _TGraphParseSource: - try: - ontology: _TGraphParseSource = cls.ontology - except AttributeError: - raise MissingOntologyClassAttributeException( - _missing_ontology_attribute_message - ) from None - else: - return ontology diff --git a/lodkit/namespace_tools/utils.py b/lodkit/namespace_tools/utils.py deleted file mode 100644 index fac50e6..0000000 --- a/lodkit/namespace_tools/utils.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Utility functions for Ontology Namespaces.""" - -from collections.abc import Iterator -import logging -from pathlib import PurePath -import re -from typing import Annotated, IO, TextIO, TypeAlias, cast - -from lodkit.namespace_tools._exceptions import ( - MultiOntologyHeadersException, - NamespaceDelimiterException, - NoOntologyHeaderException, -) -from lodkit.namespace_tools._messages import ( - _multi_header_message, - _namespace_delimiter_exception_message, - _namespace_delimiter_warning_message, - _no_ontology_header_message, -) -from rdflib import Graph, Namespace, OWL, RDF, RDFS, URIRef -from rdflib.parser import InputSource - - -logger = logging.getLogger(__name__) - - -_TGraphParseSource: Annotated[ - TypeAlias, - """Source parameter type for rdflib.Graph.parse. - This is the exact type defined in RDFLib. - """, -] = IO[bytes] | TextIO | InputSource | str | bytes | PurePath - - -def _get_ontology_graph(ontology_reference: Graph | _TGraphParseSource) -> Graph: # type: ignore - """Get a graph object from a _TGraphOrPath.""" - graph = ( - ontology_reference - if isinstance(ontology_reference, Graph) - else Graph().parse(source=ontology_reference) - ) - - return graph - - -def _delimited_namespace_p(namespace: str) -> bool: - """Check if a namespace uses '#' or '/' as URI entity delimiters.""" - if re.search(r"(/|#)$", namespace): - return True - return False - - -def _delimiter_check_invoke_side_effects( - namespace: str, strict_delimiters: bool -) -> None: - """Check if namespace is delimited and invoke side effects according to strict_delimiters.""" - if not _delimited_namespace_p(namespace): - if strict_delimiters: - raise NamespaceDelimiterException(_namespace_delimiter_exception_message) - else: - logger.warning(_namespace_delimiter_warning_message(namespace)) - - -def _resolve_namespace_from_namespace_assertion( - namespace_assertion: Namespace, ontology: Graph, strict_delimiters: bool -) -> Namespace: - """Helper for _get_namespace_from_ontology. - - Resolve a namespace from""" - if _delimited_namespace_p(namespace_assertion): - namespace = Namespace(namespace_assertion) - return namespace - else: - for _, ns in ontology.namespaces(): - if namespace_assertion in ns: - namespace = Namespace(ns) - break - else: - namespace = namespace_assertion - - _delimiter_check_invoke_side_effects(namespace, strict_delimiters) - return namespace - - -def _get_namespace_from_ontology( - ontology: Graph, strict_delimiters: bool = True -) -> Namespace: - """Get the ontology namespace from an ontology graph. - - The ontology namespace is expected to be declared in the ontology header. - See https://www.w3.org/TR/owl-ref/#Ontology-def. - - If the namespace asserted in the ontology header does not exhibit either - a fragment or URI path entity delimiter ('#' or '/'), try to match the header namespace - with a declared namespace which then takes precedence (regardless of delimiters). - - If a namespace does not have a #|/ delimiter, and strict=True, an error is raised; - else the namespace is returned and a warning emitted. - """ - # cast: RDFLib triple subjects are typed as graph._SubjectType/terms.Node - namespace_assertions = cast( - list[Namespace], [uri for uri in ontology.subjects(RDF.type, OWL.Ontology)] - ) - - match namespace_assertions: - case [URIRef()]: - namespace_assertion = namespace_assertions[0] - namespace = _resolve_namespace_from_namespace_assertion( - namespace_assertion=namespace_assertion, - ontology=ontology, - strict_delimiters=strict_delimiters, - ) - return namespace - case [URIRef(), *_]: - raise MultiOntologyHeadersException( - _multi_header_message(namespace_assertions) - ) - case []: - raise NoOntologyHeaderException(_no_ontology_header_message) - case _: # pragma: no cover - raise Exception("This should never happen.") - - -def _split_uri(uri: str) -> tuple[str, URIRef]: - """Split a URI on entity delimiter.""" - *_, last = re.split("(#|/)", uri) - return (last, URIRef(uri)) - - -def _get_terms_from_ontology(ontology: Graph, namespace: Namespace) -> list[str]: - """Get the names of all terms of an ontology. - - Ontology terms are terms that are - 1. in the ontology namespace and - 2. instances of either rdfs:Class, rdf:Property, - owl:Class, owl:ObjectProperty or owl:DatatypeProperty. - - Note: Terms get set-casted to prevent duplicates on inferred graphs. - """ - - def _get_terms() -> Iterator[str]: - _entity_classes: tuple[URIRef, ...] = ( - RDFS.Class, - RDF.Property, - OWL.Class, - OWL.ObjectProperty, - OWL.DatatypeProperty, - ) - - for s, _, o in ontology.triples((None, RDF.type, None)): - # cast: s is a URIRef/str and has special behavior for containment checks against Namesapce - s = cast(URIRef, s) - if (o in _entity_classes) and (s in namespace): - name, _ = _split_uri(s) - yield name - - return list(set(_get_terms())) diff --git a/tests/test_namespace_tools/test_closed_ontology_namespace.py b/tests/test_namespace_tools/test_closed_ontology_namespace.py new file mode 100644 index 0000000..a27d9f0 --- /dev/null +++ b/tests/test_namespace_tools/test_closed_ontology_namespace.py @@ -0,0 +1,133 @@ +"""Pytest entry point for ClosedOntologyNamespace tests.""" + +import pytest + +from lodkit import ClosedOntologyNamespace, NoSolutionException +from rdflib import Graph, RDF, RDFS, URIRef + + +def test_closed_ns_term_types(): + """Check if all RDF type terms are recognized by ClosedOntologyNamespace.""" + + data = """ + @prefix ex: . + @prefix rdf: . + @prefix rdfs: . + @prefix owl: . + + a rdfs:Class . + a owl:Class . + + a rdf:Property . + a owl:ObjectProperty . + a owl:DatatypeProperty . + a owl:AnnotationProperty . + + a owl:NamedIndividual . + """ + + g = Graph().parse(data=data, format="ttl") + ns = ClosedOntologyNamespace(source=g) + + expected = { + "rdf_class": URIRef("urn:rdf_class"), + "owl_class": URIRef("urn:owl_class"), + "rdf_property": URIRef("urn:rdf_property"), + "owl_objectproperty": URIRef("urn:owl_objectproperty"), + "owl_datatypeproperty": URIRef("urn:owl_datatypeproperty"), + "owl_annotationproperty": URIRef("urn:owl_annotationproperty"), + "owl_namedindividual": URIRef("urn:owl_namedindividual"), + } + + assert ns.mapping == expected + + +def test_closed_ns_delimited_iris(): + """Check name extraction logic for all supported delimiters.""" + data = """ + @prefix rdfs: . + + a rdfs:Class . + a rdfs:Class . + a rdfs:Class . + a rdfs:Class . + a rdfs:Class . + a rdfs:Class . + a rdfs:Class . + a rdfs:Class . + """ + g = Graph().parse(data=data, format="ttl") + ns = ClosedOntologyNamespace(source=g) + + expected = { + "s1": URIRef("urn:s1"), + "s2": URIRef("urn:example/s2"), + "s3": URIRef("https://example.com/s3"), + "s4": URIRef("https://example.com#s4"), + "s5": URIRef("https://example.com/something#s5"), + "nose": URIRef("foo://example.com:8042/over/there?name=ferret#nose"), + "there?name=ferret": URIRef("foo://example.com:8042/over/there?name=ferret"), + } + + assert ns.mapping == expected + + # getitem checks + for k, v in ns.mapping.items(): + assert ns[k] == expected[k] + + +def test_closed_ns_name_collisions(): + """Check handling of collisions between RDF term names and class namespace names. + + In the case of RDF term names conflicting with class namespace names, + class namespace names take precedence over term names for attribute access. + + Getitem access on ClosedOntologyNamespace directly and ClosedOntologyNamespace.mapping + retrieve the RDF term however. + """ + + g = Graph() + + triples = [ + (URIRef("urn:mapping"), RDF.type, RDFS.Class), + (URIRef("urn:source"), RDF.type, RDFS.Class), + ] + + for triple in triples: + g.add(triple) + + ns = ClosedOntologyNamespace(source=g) + + expected = {"mapping": URIRef("urn:mapping"), "source": URIRef("urn:source")} + + assert ns.source is g + assert ns.mapping == expected + + assert ns["mapping"] == URIRef("urn:mapping") + assert ns["source"] == URIRef("urn:source") + + +def test_closed_ns_no_solution(): + """Check that NoSolutionException is raised if no classes/properties are found.""" + + g = Graph() + + with pytest.raises(NoSolutionException): + ClosedOntologyNamespace(source=g) + + +def test_closed_ns_attribute_error(): + """Check that attribute and getitem access failure results in an AttributeError.""" + + g = Graph() + g.add( + (URIRef("urn:s"), RDF.type, RDFS.Class) + ) # add a class assertion to avoid NoSolutionException + + ns = ClosedOntologyNamespace(source=g) + + with pytest.raises(AttributeError): + ns.dne + + with pytest.raises(AttributeError): + ns["dne"]