diff --git a/CHANGELOG.md b/CHANGELOG.md index b0d2dc01..30d0c1c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - `pyld.FileDocumentLoader`: a document loader for local `file:` URLs with optional root confinement. - `pyld.ChoiceBySchemeDocumentLoader`: a document loader that dispatches URL strings to per-scheme loaders. +- `pyld.ChoiceByTypeDocumentLoader`: a document loader that dispatches by Python + input type (e.g. `pathlib.Path` vs `str`). ### Fixed - If value objects contain array values for `@type` during expansion, an error is now raised. Fixes [expand#ter54](https://w3c.github.io/json-ld-api/tests/expand-manifest.html#ter54) and [toRdf#ter54](https://w3c.github.io/json-ld-api/tests/toRdf-manifest.html#ter54). diff --git a/docs/examples/document_loaders/choice_by_type.py b/docs/examples/document_loaders/choice_by_type.py new file mode 100644 index 00000000..2d9960ac --- /dev/null +++ b/docs/examples/document_loaders/choice_by_type.py @@ -0,0 +1,38 @@ +import json +from pathlib import Path + +from pyld import ChoiceByTypeDocumentLoader, DocumentLoader, jsonld + +person = Path(__file__).resolve().parent.parent / 'data' / 'person.jsonld' + + +class PathDocumentLoader(DocumentLoader): + def __call__(self, url, options=None): + return { + 'contentType': 'application/ld+json', + 'contextUrl': None, + 'documentUrl': url.resolve().as_uri(), + 'document': url.read_text(encoding='utf-8'), + } + + +class StrDocumentLoader(DocumentLoader): + def __call__(self, url, options=None): + raise AssertionError(f'unexpected str load: {url}') + + +loader = ChoiceByTypeDocumentLoader( + { + Path: PathDocumentLoader(), + str: StrDocumentLoader(), + } +) +remote = jsonld.load_document(person, options={'documentLoader': loader}) +result = jsonld.expand( + remote['document'], + options={ + 'documentLoader': loader, + 'base': remote['documentUrl'], + }, +) +print(json.dumps(result, indent=2)) diff --git a/docs/reference/document-loaders/choice-by-type.md b/docs/reference/document-loaders/choice-by-type.md new file mode 100644 index 00000000..eaf7640d --- /dev/null +++ b/docs/reference/document-loaders/choice-by-type.md @@ -0,0 +1,26 @@ +--- +hide: [toc] +--- +# :material-shape-outline: `ChoiceByTypeDocumentLoader` + +::: pyld.ChoiceByTypeDocumentLoader + options: + show_root_heading: false + show_bases: false + heading_level: 3 + members: false + +Dispatch by Python input type — here a `pathlib.Path` vs a URL `str`: + +=== "Example" + + {{ example('document_loaders/choice_by_type.py', output_syntax='json', indent=4) }} + +=== "person.jsonld" + + {{ example_data('data/person.jsonld', indent=4) }} + +Dispatch uses `isinstance`, so a `pathlib.Path` registration matches concrete +subclasses such as `PosixPath` and `WindowsPath`. The first matching entry in +insertion order wins. An unregistered input type raises `JsonLdError` with code +`loading document failed` and details naming the types that are registered. diff --git a/docs/reference/document-loaders/index.md b/docs/reference/document-loaders/index.md index d8127238..80eca245 100644 --- a/docs/reference/document-loaders/index.md +++ b/docs/reference/document-loaders/index.md @@ -44,6 +44,12 @@ class-based loaders for common cases and supports custom subclasses of Delegate to another Document Loader based on the scheme of the URL, for instance, `file:` vs `https://`. +- [:material-shape-outline:{ .lg .middle } `ChoiceByTypeDocumentLoader`](choice-by-type.md) + + --- + + Delegate by Python input type, for instance `pathlib.Path` vs URL `str`. + - [:material-code-braces:{ .lg .middle } __Custom Document Loaders__](custom.md) --- diff --git a/lib/pyld/__init__.py b/lib/pyld/__init__.py index 380d35af..70905139 100644 --- a/lib/pyld/__init__.py +++ b/lib/pyld/__init__.py @@ -5,6 +5,7 @@ from .documentloader.aiohttp import AioHttpDocumentLoader from .documentloader.base import DocumentLoader, RemoteDocument from .documentloader.choice_by_scheme import ChoiceBySchemeDocumentLoader +from .documentloader.choice_by_type import ChoiceByTypeDocumentLoader from .documentloader.file import FileDocumentLoader from .documentloader.frozen import BUNDLED_CONTEXTS, FrozenDocumentLoader from .documentloader.requests import RequestsDocumentLoader @@ -14,6 +15,7 @@ 'AioHttpDocumentLoader', 'BUNDLED_CONTEXTS', 'ChoiceBySchemeDocumentLoader', + 'ChoiceByTypeDocumentLoader', 'ContextResolver', 'DocumentLoader', 'FileDocumentLoader', diff --git a/lib/pyld/documentloader/choice_by_type.py b/lib/pyld/documentloader/choice_by_type.py new file mode 100644 index 00000000..f4f66fd4 --- /dev/null +++ b/lib/pyld/documentloader/choice_by_type.py @@ -0,0 +1,56 @@ +""" +Type-dispatching JSON-LD document loader. + +.. module:: jsonld.documentloader.choice_by_type + :synopsis: ChoiceByTypeDocumentLoader for type-based document loading +""" + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from pyld.documentloader.base import DocumentLoader, RemoteDocument +from pyld.jsonld import JsonLdError + + +@dataclass +class ChoiceByTypeDocumentLoader(DocumentLoader): + """Document loader that dispatches to per-type loaders. + + Constructed with a mapping from Python types to `DocumentLoader` instances. + Dispatch uses `isinstance`, so a `pathlib.Path` registration matches + `pathlib.PosixPath` / `WindowsPath`. The first matching entry in insertion + order wins. + + An unregistered input type raises `JsonLdError` with code + `loading document failed` and details naming the registered types. + + :param loaders: mapping of type to document loader. + """ + + loaders: Mapping[type, DocumentLoader] + + def __call__(self, url: Any, options: dict | None = None) -> RemoteDocument: + """Retrieve the JSON-LD document at `url` via the matching type loader. + + :param url: the URL, path, or other location to retrieve. + :param options: loader options forwarded to the chosen loader. + :return: a `RemoteDocument`. + """ + if options is None: + options = {} + + for typ, loader in self.loaders.items(): + if isinstance(url, typ): + return loader(url, options) + + raise JsonLdError( + 'URL could not be dereferenced; no loader is registered for ' + f'type "{type(url).__name__}".', + 'jsonld.InvalidUrl', + { + 'url': url, + 'types': [typ.__name__ for typ in self.loaders], + }, + code='loading document failed', + ) diff --git a/tests/test_choice_by_type_document_loader.py b/tests/test_choice_by_type_document_loader.py new file mode 100644 index 00000000..e4911469 --- /dev/null +++ b/tests/test_choice_by_type_document_loader.py @@ -0,0 +1,116 @@ +"""Tests for ChoiceByTypeDocumentLoader.""" + +import json +from pathlib import Path + +import pytest + +from pyld import ( + ChoiceBySchemeDocumentLoader, + ChoiceByTypeDocumentLoader, + FileDocumentLoader, + FrozenDocumentLoader, + jsonld, +) +from pyld.jsonld import JsonLdError + +_CONTEXT_URL = 'https://example.com/context' +_CONTEXT = {'@context': {'name': 'http://schema.org/name'}} +_PERSON = { + '@context': _CONTEXT_URL, + 'name': 'Ada Lovelace', +} + + +def test_dispatches_path_to_file_loader(tmp_path): + """A pathlib.Path is dispatched to the Path loader.""" + path = tmp_path / 'person.jsonld' + path.write_text(json.dumps(_PERSON), encoding='utf-8') + loader = ChoiceByTypeDocumentLoader( + { + Path: FileDocumentLoader(), + } + ) + + result = loader(path, {}) + + assert result['contentType'] == 'application/ld+json' + assert json.loads(result['document']) == _PERSON + assert result['documentUrl'] == path.resolve().as_uri() + + +def test_path_subclass_matches_path_registration(tmp_path): + """A concrete Path subclass matches a Path registration via isinstance.""" + path = tmp_path / 'person.jsonld' + path.write_text(json.dumps(_PERSON), encoding='utf-8') + assert type(path) is not Path + assert isinstance(path, Path) + loader = ChoiceByTypeDocumentLoader( + { + Path: FileDocumentLoader(), + } + ) + + result = loader(path, {}) + + assert json.loads(result['document']) == _PERSON + + +def test_dispatches_str_to_nested_scheme_loader(): + """A str URL is dispatched to the str loader (e.g. by-scheme).""" + http = FrozenDocumentLoader(documents={_CONTEXT_URL: _CONTEXT}) + loader = ChoiceByTypeDocumentLoader( + { + str: ChoiceBySchemeDocumentLoader(https=http), + } + ) + + result = loader(_CONTEXT_URL, {}) + + assert result['document'] == _CONTEXT + assert result['documentUrl'] == _CONTEXT_URL + + +def test_unregistered_type_raises(): + """An unregistered input type raises JsonLdError naming registered types.""" + loader = ChoiceByTypeDocumentLoader( + { + Path: FileDocumentLoader(), + } + ) + + with pytest.raises(JsonLdError) as exc: + loader('https://example.com/person.jsonld', {}) + + assert exc.value.code == 'loading document failed' + assert exc.value.type == 'jsonld.InvalidUrl' + assert exc.value.details['types'] == ['Path'] + + +def test_load_path_with_remote_context(tmp_path): + """Load a Path whose @context is an https URL via composed loaders.""" + path = tmp_path / 'person.jsonld' + path.write_text(json.dumps(_PERSON), encoding='utf-8') + file_loader = FileDocumentLoader() + http = FrozenDocumentLoader(documents={_CONTEXT_URL: _CONTEXT}) + loader = ChoiceByTypeDocumentLoader( + { + Path: file_loader, + str: ChoiceBySchemeDocumentLoader( + file=file_loader, + http=http, + https=http, + ), + } + ) + + remote = jsonld.load_document(path, options={'documentLoader': loader}) + expanded = jsonld.expand( + remote['document'], + options={ + 'documentLoader': loader, + 'base': remote['documentUrl'], + }, + ) + + assert expanded == [{'http://schema.org/name': [{'@value': 'Ada Lovelace'}]}]