Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
38 changes: 38 additions & 0 deletions docs/examples/document_loaders/choice_by_type.py
Original file line number Diff line number Diff line change
@@ -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))
26 changes: 26 additions & 0 deletions docs/reference/document-loaders/choice-by-type.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions docs/reference/document-loaders/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---
Expand Down
2 changes: 2 additions & 0 deletions lib/pyld/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,6 +15,7 @@
'AioHttpDocumentLoader',
'BUNDLED_CONTEXTS',
'ChoiceBySchemeDocumentLoader',
'ChoiceByTypeDocumentLoader',
'ContextResolver',
'DocumentLoader',
'FileDocumentLoader',
Expand Down
56 changes: 56 additions & 0 deletions lib/pyld/documentloader/choice_by_type.py
Original file line number Diff line number Diff line change
@@ -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',
)
116 changes: 116 additions & 0 deletions tests/test_choice_by_type_document_loader.py
Original file line number Diff line number Diff line change
@@ -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'}]}]
Loading