Files
CoreRasurae f14c0bad8c
CI / lint (pull_request) Successful in 1m35s
CI / typecheck (pull_request) Successful in 1m38s
CI / behave (3.11) (pull_request) Successful in 1m48s
CI / behave (3.12) (pull_request) Successful in 1m47s
CI / behave (3.13) (pull_request) Successful in 1m43s
CI / build (pull_request) Successful in 1m33s
CI / lint (push) Successful in 1m31s
CI / typecheck (push) Successful in 1m29s
CI / behave (3.11) (push) Successful in 1m37s
CI / behave (3.12) (push) Successful in 1m44s
CI / behave (3.13) (push) Successful in 1m36s
CI / build (push) Successful in 1m31s
docs: Initial documentation of CleverRDFLib
ISSUES CLOSED: #2
2025-12-22 10:50:03 +00:00

22 KiB

Resolvers

Reference resolvers are responsible for identifying and extracting external ontology references from RDF graphs. CleverRDFLib uses a Chain of Responsibility pattern to support multiple resolver types and allow easy extension.

Purpose

Resolvers serve the following purposes:

  • Extract references: Identify external ontology references in loaded graphs
  • Support multiple reference types: Handle different types of references (owl:imports, rdfs:seeAlso, etc.)
  • Enable extensibility: Allow custom resolvers for domain-specific reference types
  • Chain resolution: Combine multiple resolvers to handle various reference types

Built-in Resolvers

OWLImportsResolver

Resolves owl:imports statements:

from cleverrdf_lib.resolvers.owl_imports_resolver import OWLImportsResolver
from rdflib import Graph

resolver = OWLImportsResolver()
graph = Graph()
# ... populate graph with ontology containing owl:imports ...

# Extract imported ontology URIs
imported_uris = resolver.resolve_references(graph)
print(f"Imported URIs: {imported_uris}")

ExternalResourceResolver

Resolves external ontology URIs referenced in T-Box elements (classes, properties) through RDF/RDFS constructs (rdfs:subClassOf, rdfs:domain, rdfs:range, rdf:type):

from cleverrdf_lib.resolvers.external_resource_resolver import ExternalResourceResolver
from rdflib import Graph

resolver = ExternalResourceResolver()
graph = Graph()
# ... populate graph ...

# Extract external resource URIs
external_uris = resolver.resolve_references(graph)
print(f"External URIs: {external_uris}")

Composite Resolver

The CompositeReferenceResolver is the recommended way to combine multiple resolvers. It provides a simple interface for creating resolver chains without manually managing the chain:

from cleverrdf_lib.resolvers.composite_resolver import CompositeReferenceResolver
from cleverrdf_lib.resolvers.owl_imports_resolver import OWLImportsResolver
from cleverrdf_lib.resolvers.external_resource_resolver import ExternalResourceResolver

# Create a composite resolver with both OWL and RDFS resolvers
composite = CompositeReferenceResolver([
    OWLImportsResolver(),
    ExternalResourceResolver()
])

# Resolve all references - automatically uses all resolvers in the chain
all_references = composite.resolve_all_references(graph)

The composite resolver automatically chains the resolvers together and combines their results. This is the preferred approach for most use cases.

Factory Methods

The CompositeReferenceResolver provides factory methods for common configurations:

# Default: OWL + RDFS resolvers
composite = CompositeReferenceResolver.create_default()

# OWL-only resolver (only handles owl:imports)
owl_only = CompositeReferenceResolver.create_owl_only()

# External resources-only resolver (only handles external RDFS resource references)
external_resources_only = CompositeReferenceResolver.create_external_resources_only()

Using Composite Resolver

from cleverrdf_lib.resolvers.composite_resolver import CompositeReferenceResolver
from cleverrdf_lib.resolvers.owl_imports_resolver import OWLImportsResolver
from cleverrdf_lib.resolvers.external_resource_resolver import ExternalResourceResolver
from rdflib import Graph

# Create composite with multiple resolvers
composite = CompositeReferenceResolver([
    OWLImportsResolver(),
    ExternalResourceResolver()
])

# Resolve all references
graph = Graph()
# ... populate graph ...
all_refs = composite.resolve_all_references(graph)
# Contains both owl:imports URIs and RDFS resource URIs

# Access the head resolver if needed
head_resolver = composite.head_resolver

Resolver Factory

The ResolverFactory provides convenient factory methods for creating resolver chains:

from cleverrdf_lib.factories.resolver_factory import ResolverFactory

# Create default resolver (OWL + RDFS)
resolver = ResolverFactory.create_default()

# Create OWL-only resolver
owl_resolver = ResolverFactory.create_owl_only()

# Create external resources-only resolver
external_resources_resolver = ResolverFactory.create_external_resources_only()

# Create custom resolver
custom_resolver = ResolverFactory.create_custom(my_custom_resolver)

Creating Custom Resolvers

To create a custom resolver, extend BaseReferenceResolver and implement the _extract_references method. The base class handles all chain management automatically:

from cleverrdf_lib.resolvers.base_resolver import BaseReferenceResolver
from rdflib import Graph, URIRef

class CustomReferenceResolver(BaseReferenceResolver):
    """Custom resolver for domain-specific references."""
    
    def _extract_references(self, graph: Graph) -> set[str]:
        """
        Extract references from the graph.
        
        This method is called by the base class's resolve_references method.
        The base class automatically handles chaining to the next resolver.
        
        Args:
            graph: The RDF graph to extract references from
            
        Returns:
            Set of referenced URIs for this resolver's specific reference type
        """
        references = set()
        
        # Implement custom reference extraction logic
        # For example, extract custom namespace references
        for subject, predicate, obj in graph.triples((None, None, None)):
            if isinstance(obj, URIRef):
                uri_str = str(obj)
                if self._is_custom_reference(uri_str):
                    references.add(uri_str)
        
        return references
    
    def _is_custom_reference(self, uri: str) -> bool:
        """Check if URI is a custom reference."""
        return uri.startswith("http://example.org/custom/")

The BaseReferenceResolver automatically:

  • Manages the chain of resolvers (set_next, get_next)
  • Combines results from all resolvers in the chain
  • Delegates to the next resolver when needed

You only need to implement _extract_references to define what references your resolver should extract.

IRI to URI Rewriters

IRI to URI rewriters are responsible for converting external IRI references (which typically point to specific classes or properties within an ontology) into the actual ontology URIs that need to be fetched. This conversion happens automatically within each resolver, allowing precise modifications to external URIs at an early stage in the resolution process.

Purpose

The IRI to URI rewriter serves several critical purposes:

  • Convert class/property IRIs to ontology URIs: External IRIs extracted from the graph often refer to specific classes or properties (e.g., http://example.org/ontology#MyClass) rather than the ontology document itself (e.g., http://example.org/ontology.owl). Rewriters convert these IRIs to the proper ontology URIs.

  • Prevent duplicate ontology loading: When multiple external IRIs belong to the same external ontology, converting them early ensures they're recognized as a single ontology rather than multiple separate ontologies. This prevents unnecessary duplicate loading attempts.

  • Enable resolver-specific rewriting: Each resolver can have its own rewriter chain, allowing precise modifications tailored to the specific type of references that resolver handles.

  • Early-stage conversion: By performing the conversion at the resolver level (before graph loading), the framework can properly deduplicate and optimize ontology loading.

Relationship to Graph Loader Remapper

It's important to understand the distinction between IRI to URI rewriters and the GraphLoaderRemapperHandler:

  • IRI to URI Rewriters: Convert external IRI references (class/property identifiers) to ontology document URIs. This happens at the resolver level, before graph loading.

  • GraphLoaderRemapperHandler: Remaps actual ontology URIs for different purposes:

    • Redirecting URIs to local files
    • Changing URI schemes (e.g., HTTP to HTTPS)
    • Connecting to external ontology repository servers
    • This happens during graph loading, after the IRI-to-URI conversion.

Built-in Rewriter: NullIRI2URIRewriter

The NullIRI2URIRewriter is the default rewriter used by all resolvers. It performs no conversion - it simply returns IRIs as-is, assuming they are already valid ontology URIs:

from cleverrdf_lib.rewriters.null_iri_2_uri_rewriter import NullIRI2URIRewriter

# Null rewriter returns IRIs unchanged
rewriter = NullIRI2URIRewriter()
iris = {"http://example.org/ontology#MyClass"}
uris = rewriter.rewrite_references(iris)
# uris = {"http://example.org/ontology#MyClass"}  # No change

Note: The NullIRI2URIRewriter is optional. The base IRI2URIRewriterBaseChain implementation automatically returns any unhandled IRI as-is (as the URI) if no rewriter in the chain handles it. The NullIRI2URIRewriter is provided for clarity and explicit fallback behavior, but it's not required for the chain to function correctly.

This is suitable when:

  • External IRIs are already ontology URIs (e.g., from owl:imports)
  • No conversion is needed for the specific resolver's reference types
  • You want to make the fallback behavior explicit in your rewriter chain

Creating Custom Rewriters

To create a custom rewriter, extend IRI2URIRewriterBaseChain and implement the rewrite_reference method:

from cleverrdf_lib.core.interfaces.iri_2_uri_rewriter import IRI2URIRewriterBaseChain

class NamespaceToOntologyRewriter(IRI2URIRewriterBaseChain):
    """
    Rewriter that converts class/property IRIs to their ontology URIs.
    
    Example: Converts http://example.org/ontology#MyClass
             to http://example.org/ontology.owl
    """
    
    def rewrite_reference(self, iri: str) -> str | None:
        """
        Convert a class/property IRI to its ontology URI.
        
        Args:
            iri: The IRI to convert (e.g., http://example.org/ontology#MyClass)
            
        Returns:
            The ontology URI (e.g., http://example.org/ontology.owl), or None if conversion not applicable
        """
        # Check if this is a class/property IRI (contains # or /)
        if "#" in iri:
            # Convert namespace IRI to ontology URI
            # e.g., http://example.org/ontology#MyClass -> http://example.org/ontology.owl
            base_uri = iri.split("#")[0]
            return f"{base_uri}.owl"
        elif "/" in iri and not iri.endswith((".owl", ".rdf", ".ttl", ".xml")):
            # Convert path-based IRI to ontology URI
            # e.g., http://example.org/ontology/MyClass -> http://example.org/ontology.owl
            parts = iri.rsplit("/", 1)
            if len(parts) == 2:
                return f"{parts[0]}.owl"
        
        # Return None if this rewriter doesn't handle this IRI
        # The chain will try the next rewriter
        return None
    
    def get_name(self) -> str:
        """Return the name of this rewriter."""
        return "NamespaceToOntologyRewriter"

Chaining Rewriters

Rewriters can be chained together using the Chain of Responsibility pattern, allowing multiple conversion strategies to be applied in sequence:

from cleverrdf_lib.core.interfaces.iri_2_uri_rewriter import IRI2URIRewriterBaseChain
from cleverrdf_lib.rewriters.null_iri_2_uri_rewriter import NullIRI2URIRewriter

class PrefixRewriter(IRI2URIRewriterBaseChain):
    """Rewriter that handles specific namespace prefixes."""
    
    def __init__(self, prefix: str, target_uri: str):
        super().__init__()
        self._prefix = prefix
        self._target_uri = target_uri
    
    def rewrite_reference(self, iri: str) -> str | None:
        """Convert IRIs with specific prefix to target URI."""
        if iri.startswith(self._prefix):
            return self._target_uri
        return None
    
    def get_name(self) -> str:
        return f"PrefixRewriter({self._prefix})"

# Create a chain of rewriters
prefix_rewriter = PrefixRewriter("http://schema.org/", "http://schema.org/version/latest/schema.owl")
null_rewriter = NullIRI2URIRewriter()

# Chain them together
prefix_rewriter.set_next_rewriter(null_rewriter)

# Use the chain
iris = {
    "http://schema.org/Person",  # Will be converted by PrefixRewriter
    "http://example.org/ontology.owl"  # Will pass through to NullRewriter
}
uris = prefix_rewriter.rewrite_references(iris)
# uris = {"http://schema.org/version/latest/schema.owl", "http://example.org/ontology.owl"}

Using Rewriters with Resolvers

Resolvers automatically use rewriters to convert extracted IRIs. You can configure a custom rewriter chain for any resolver:

from cleverrdf_lib.resolvers.external_resource_resolver import ExternalResourceResolver
from cleverrdf_lib.core.interfaces.iri_2_uri_rewriter import IRI2URIRewriterBaseChain

# Assuming NamespaceToOntologyRewriter is defined as shown above
custom_rewriter = NamespaceToOntologyRewriter()

# Create resolver with custom rewriter
resolver = ExternalResourceResolver(rewriters_chain=custom_rewriter)

# Or set the rewriter chain after creation
resolver.set_rewriters_chain(custom_rewriter)

# When resolving references, IRIs will be automatically converted
graph = Graph()
# ... populate graph with external references ...
uris = resolver.resolve_references(graph)
# uris contains converted ontology URIs, not the original class/property IRIs

Example: Converting Schema.org IRIs

Here's a practical example that converts Schema.org class IRIs to the omg.org ontology URI:

import logging
from urllib.parse import ParseResult, urlparse

from cleverrdf_lib.resolvers.external_resource_resolver import ExternalResourceResolver
from cleverrdf_lib.core.interfaces.iri_2_uri_rewriter import IRI2URIRewriterBaseChain
from cleverrdf_lib.rewriters.null_iri_2_uri_rewriter import NullIRI2URIRewriter

LOGGER = logging.getLogger(__name__)

class OMGRewriter(IRI2URIRewriterBaseChain):
    """Rewriter that converts OMG specification IRIs to their ontology URIs."""
    
    def _can_handle(self, iri_scheme: str, iri_netloc: str, iri_path: str) -> bool:
        """Check if this rewriter can handle the given IRI components."""
        iri_host_path = iri_netloc.split(":")[0] + iri_path
        return iri_host_path.startswith("www.omg.org/spec") and iri_path.count(".") == 0 
    
    def rewrite_reference(self, iri: str) -> str | None:
        """
        Convert OMG specification IRIs to their ontology URIs.
        
        Example: Converts http://www.omg.org/spec/UML/20161101/MyClass
                 to http://www.omg.org/spec/UML/20161101.rdf
        """
        parsed: ParseResult = urlparse(iri)
        iri_scheme = parsed.scheme
        iri_netloc = parsed.netloc
        iri_path = parsed.path
  
        # Check if we can handle this IRI
        if not self._can_handle(iri_scheme, iri_netloc, iri_path):
            LOGGER.info(f"Cannot handle {iri_path}")
            return None
        
        # This IRI is for us, lets rewrite it
        # Remove the last path component (the class name) and add .rdf extension
        path_less_last = iri_path.rsplit("/", 1)[0]  # Get path without last component
        uri_path_remapped = f"{path_less_last}.rdf"
        return f"{iri_scheme}://{iri_netloc}{uri_path_remapped}"

    def get_name(self) -> str:
        """Return the name of this rewriter."""
        return "OMG rewriter"

# Create resolver with OMG rewriter
omg_rewriter = OMGRewriter()
# Null rewriter is optional - the base implementation automatically returns
# unhandled IRIs as-is. Including it makes the fallback behavior explicit.
null_rewriter = NullIRI2URIRewriter()
omg_rewriter.set_next_rewriter(null_rewriter)

resolver = ExternalResourceResolver(rewriters_chain=omg_rewriter)

# Now when resolving references:
# - IRIs matching www.omg.org/spec pattern -> converted to .rdf ontology URI
# - Other IRIs -> returned as-is (automatic fallback, or explicit via NullIRI2URIRewriter)

Best Practices

  1. Automatic fallback behavior: The base IRI2URIRewriterBaseChain implementation automatically returns any unhandled IRI as-is (as the URI) if no rewriter in the chain handles it. The NullIRI2URIRewriter is optional and can be used for explicit fallback behavior, but it's not required.

  2. Resolver-specific rewriters: Different resolvers may need different rewriter chains. For example, OWLImportsResolver typically doesn't need rewriting (since owl:imports already points to ontology URIs), while ExternalResourceResolver often needs conversion.

  3. Early conversion benefits: Converting IRIs to URIs at the resolver level ensures proper deduplication - multiple IRIs from the same ontology are recognized as a single ontology to load.

  4. Chain order matters: Place more specific rewriters before general ones. The first rewriter that returns a non-None value wins.

  5. Return None for unhandled IRIs: If your rewriter doesn't handle a particular IRI, return None to allow the chain to continue to the next rewriter. If no rewriter handles the IRI, the base implementation will return the IRI itself as the URI.

Using Custom Resolvers with Composite

The easiest way to use custom resolvers is with CompositeReferenceResolver:

from cleverrdf_lib.resolvers.composite_resolver import CompositeReferenceResolver
from cleverrdf_lib.resolvers.owl_imports_resolver import OWLImportsResolver
from rdflib import Graph

# Assuming CustomReferenceResolver is defined elsewhere
# Create composite with custom resolver
composite = CompositeReferenceResolver([
    OWLImportsResolver(),
    CustomReferenceResolver()
])

# Use the composite
graph = Graph()
# ... populate graph ...
all_refs = composite.resolve_all_references(graph)

Using Resolvers with OntologyLoader

Configure the resolver when creating an OntologyLoader:

from cleverrdf_lib import OntologyLoader
from cleverrdf_lib.factories.resolver_factory import ResolverFactory

# Use default resolver
loader = OntologyLoader()

# Use OWL-only resolver
owl_loader = OntologyLoader(resolver=ResolverFactory.create_owl_only())

# Use custom resolver
custom_loader = OntologyLoader(resolver=my_custom_resolver)

Chain of Responsibility Pattern

Under the hood, resolvers use the Chain of Responsibility pattern. While CompositeReferenceResolver is the recommended way to combine resolvers, you can also manually chain resolvers if needed.

Manual Chaining

Resolvers can be manually chained together using the set_next method:

from cleverrdf_lib.resolvers.owl_imports_resolver import OWLImportsResolver
from cleverrdf_lib.resolvers.external_resource_resolver import ExternalResourceResolver
from rdflib import Graph

# Create individual resolvers
owl_resolver = OWLImportsResolver()
external_resources_resolver = ExternalResourceResolver()

# Chain them together manually
owl_resolver.set_next(external_resources_resolver)

# Use the head resolver
graph = Graph()
# ... populate graph ...
references = owl_resolver.resolve_references(graph)
# This will call all resolvers in the chain automatically

How Chaining Works

When multiple resolvers are chained:

  1. Sequential processing: Each resolver processes the graph
  2. Reference aggregation: All references from all resolvers are collected
  3. No duplicates: The final set contains unique URIs
  4. Order matters: Resolvers are processed in chain order

The BaseReferenceResolver handles the chain traversal automatically - each resolver calls _extract_references() and then delegates to the next resolver in the chain.

When to Use Manual Chaining

Manual chaining is rarely needed. Use CompositeReferenceResolver instead, which provides a cleaner interface and automatically manages the chain. Manual chaining is only useful if you need fine-grained control over the chain structure or are building custom composite resolvers.

Best Practices

  1. Use CompositeReferenceResolver: Prefer CompositeReferenceResolver over manual chaining for combining multiple resolvers
  2. Use factory methods: Prefer factory methods (ResolverFactory or CompositeReferenceResolver.create_*) for common configurations
  3. Order matters: When combining resolvers, order them by specificity (most specific first)
  4. Extend, don't modify: Create custom resolvers rather than modifying built-in ones
  5. Test resolvers: Test custom resolvers with sample graphs
  6. Handle empty results: Resolvers may return empty sets if no references are found

Resolver Base Class

All resolvers extend BaseReferenceResolver, which provides the chain management logic:

class BaseReferenceResolver(ReferenceResolver):
    """Base class for reference resolvers."""
    
    def resolve_references(self, graph: Graph) -> set[str]:
        """
        Extract all references from the graph.
        
        This method calls _extract_references() and then delegates
        to the next resolver in the chain, combining all results.
        """
        references = self._extract_references(graph)
        if self._next_resolver is not None:
            next_references = self._next_resolver.resolve_references(graph)
            references.update(next_references)
        return references
    
    @abstractmethod
    def _extract_references(self, graph: Graph) -> set[str]:
        """
        Extract references of the type this resolver handles.
        
        Subclasses must implement this method to define their
        specific reference extraction logic.
        """
        pass
    
    def set_next(self, resolver: ReferenceResolver) -> ReferenceResolver:
        """Set the next resolver in the chain."""
        self._next_resolver = resolver
        return self
    
    def get_next(self) -> ReferenceResolver | None:
        """Get the next resolver in the chain."""
        return self._next_resolver

When creating custom resolvers, extend BaseReferenceResolver and implement only _extract_references. The base class handles all chain management automatically.

Next Steps